Skip to content
46 changes: 45 additions & 1 deletion packages/client-runtime/src/handoff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,9 @@ describe("handoff endpoints shared by web and mobile", () => {
});
});

const startedAt = "2026-10-11T00:00:00Z";
const projection = {
runs: [{ ...run("source", 1, from, "source"), startedAt: "2026-10-11T00:00:00Z" }],
runs: [{ ...run("source", 1, from, "source"), startedAt, status: "completed" }],
} as unknown as OrchestrationV2ThreadProjection;

describe("pending context handoff", () => {
Expand All @@ -89,6 +90,49 @@ describe("pending context handoff", () => {
expect(resolvePendingHandoff(projection, { instanceId: from, model: "source" }, [])).toBeNull();
expect(resolvePendingHandoff(projection, { instanceId: from, model: "other" }, [])).toBeNull();
});
it("stays hidden until a run has history to hand off", () => {
const firstRun = (status: string) =>
({
runs: [{ ...run("source", 1, from, "source"), startedAt, status }],
}) as unknown as OrchestrationV2ThreadProjection;
for (const status of ["running", "cancelled", "rolled_back"]) {
expect(
resolvePendingHandoff(firstRun(status), { instanceId: to, model: "first" }, []),
).toBeNull();
}
});
it("hands off from the latest started run, not the latest finished one", () => {
const withLaterRun = (status: string, laterStartedAt: string | null) =>
({
runs: [
...projection.runs,
{ ...run("later", 2, to, "later"), startedAt: laterStartedAt, status },
],
}) as unknown as OrchestrationV2ThreadProjection;
// A running handoff on the new provider is the source for the next send.
expect(
resolvePendingHandoff(
withLaterRun("running", startedAt),
{ instanceId: to, model: "later" },
[],
),
).toBeNull();
expect(
resolvePendingHandoff(
withLaterRun("running", startedAt),
{ instanceId: from, model: "source" },
[],
)?.from,
).toEqual([{ instanceId: to, model: "later" }]);
// A handoff that failed before starting never reached the new provider.
expect(
resolvePendingHandoff(
withLaterRun("failed", null),
{ instanceId: from, model: "source" },
[],
),
).toBeNull();
});
it("only previews a same-provider model change when it needs a fresh native thread", () => {
const providers = [
{ instanceId: from, requiresNewThreadForModelChange: true },
Expand Down
12 changes: 9 additions & 3 deletions packages/client-runtime/src/handoff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,16 @@ export function resolvePendingHandoff(
selection: ModelSelection | null | undefined,
providers: ReadonlyArray<ServerProvider>,
): HandoffEndpoints | null {
const from = projection?.runs.findLast(
const startedRuns = projection?.runs.filter(
(run) => run.startedAt !== null && run.status !== "rolled_back",
)?.modelSelection;
if (from === undefined || selection == null) return null;
);
// Mirrors the server's handoff sources: a thread whose only run is still
// running has no conversation to hand off yet.
const hasHistory = startedRuns?.some(
(run) => run.status === "completed" || run.status === "failed" || run.status === "interrupted",
);
const from = startedRuns?.at(-1)?.modelSelection;
if (!hasHistory || from === undefined || selection == null) return null;
const source = providers.find((provider) => provider.instanceId === from.instanceId);
const target = providers.find((provider) => provider.instanceId === selection.instanceId);
const needsHandoff =
Expand Down
Loading