Skip to content
Source
Sign inSign up

handoff.test.ts

272 lines8.7 KB

1import {
2 ContextHandoffId,
3 ProviderInstanceId,
4 RunId,
5 type OrchestrationV2ThreadProjection,
6 type ServerProvider,
7} from "@t3tools/contracts";
8import { describe, expect, it } from "vite-plus/test";
9import {
10 resolveHandoffEndpoints,
11 resolveModelSwitchChange,
12 resolvePendingHandoff,
13 type HandoffTimelineRun,
14} from "./handoff.ts";
15​
16const from = ProviderInstanceId.make("codex_personal");
17const to = ProviderInstanceId.make("claudeAgent");
18const item = {
19 runId: RunId.make("target"),
20 fromProviderInstanceIds: [from],
21 toProviderInstanceId: to,
22};
23const run = (
24 id: string,
25 ordinal: number,
26 instanceId: ProviderInstanceId,
27 model: string,
28): HandoffTimelineRun => ({
29 id: RunId.make(id),
30 ordinal,
31 providerInstanceId: instanceId,
32 modelSelection: { instanceId, model },
33});
34​
35describe("handoff endpoints shared by web and mobile", () => {
36 it("preserves stamped models including several models from the same provider", () => {
37 const fromModelSelections = [
38 { instanceId: from, model: "source-a" },
39 { instanceId: from, model: "source-b" },
40 ];
41 expect(
42 resolveHandoffEndpoints({ ...item, fromModelSelections, toModel: "destination" }, [
43 run("target", 2, to, "later-model"),
44 ]),
45 ).toEqual({
46 from: fromModelSelections,
47 to: { instanceId: to, model: "destination" },
48 });
49 });
50​
51 it("recovers legacy models from the handoff run and latest earlier source run", () => {
52 const runs = [
53 run("later", 4, from, "wrong-later-model"),
54 run("old", 1, from, "old-model"),
55 run("target", 3, to, "destination"),
56 run("source", 2, from, "source-model"),
57 ];
58 expect(resolveHandoffEndpoints(item, runs)).toEqual({
59 from: [{ instanceId: from, model: "source-model" }],
60 to: { instanceId: to, model: "destination" },
61 });
62 });
63​
64 it("retains provider identities when historical runs are not loaded", () => {
65 expect(resolveHandoffEndpoints(item, [])).toEqual({
66 from: [{ instanceId: from, model: undefined }],
67 to: { instanceId: to, model: undefined },
68 });
69 });
70​
71 it("does not borrow a target model from another provider", () => {
72 expect(
73 resolveHandoffEndpoints(item, [run("target", 2, from, "wrong-model")]).to.model,
74 ).toBeUndefined();
75 });
76});
77​
78const projection = {
79 runs: [
80 {
81 ...run("source", 1, from, "source"),
82 status: "completed",
83 contextHandoffId: null,
84 startedAt: "2026-10-11T00:00:00Z",
85 },
86 ],
87 visibleTurnItems: [{ item: { type: "user_message" } }],
88} as unknown as OrchestrationV2ThreadProjection;
89​
90describe("pending context handoff", () => {
91 it("removes the preview while the handoff starts", () => {
92 const selection = { instanceId: to, model: "destination" };
93 const providers = [{ instanceId: from }] as unknown as ServerProvider[];
94 const starting = {
95 ...projection,
96 runs: [
97 ...projection.runs,
98 {
99 ...projection.runs[0]!,
100 ...run("target", 2, to, "destination"),
101 status: "starting" as const,
102 startedAt: null,
103 contextHandoffId: ContextHandoffId.make("handoff"),
104 },
105 ],
106 };
107​
108 expect(resolvePendingHandoff(projection, selection, providers)).not.toBeNull();
109 expect(resolvePendingHandoff(starting, selection, providers)).toBeNull();
110 expect(
111 resolvePendingHandoff(starting, { instanceId: from, model: "source" }, providers),
112 ).toEqual({
113 from: [selection],
114 to: { instanceId: from, model: "source" },
115 });
116 });
117​
118 it.each(["preparing", "queued", "failed", "cancelled", "rolled_back"] as const)(
119 "keeps the preview when an unstarted handoff run is %s",
120 (status) => {
121 const selection = { instanceId: to, model: "destination" };
122 const unstarted = {
123 ...projection,
124 runs: [
125 ...projection.runs,
126 {
127 ...projection.runs[0]!,
128 ...run("target", 2, to, "destination"),
129 status,
130 startedAt: null,
131 contextHandoffId: ContextHandoffId.make("handoff"),
132 },
133 ],
134 };
135​
136 expect(resolvePendingHandoff(unstarted, selection, [])).toEqual({
137 from: [{ instanceId: from, model: "source" }],
138 to: selection,
139 });
140 },
141 );
142​
143 it("appears on selection, updates the destination, and disappears on returning", () => {
144 expect(resolvePendingHandoff(projection, { instanceId: to, model: "first" }, [])).toEqual({
145 from: [{ instanceId: from, model: "source" }],
146 to: { instanceId: to, model: "first" },
147 });
148 expect(
149 resolvePendingHandoff(projection, { instanceId: to, model: "second" }, [])?.to.model,
150 ).toBe("second");
151 expect(resolvePendingHandoff(projection, { instanceId: from, model: "source" }, [])).toBeNull();
152 expect(resolvePendingHandoff(projection, { instanceId: from, model: "other" }, [])).toBeNull();
153 });
154 it("stays hidden while the thread's timeline is empty", () => {
155 const empty = { ...projection, visibleTurnItems: [] };
156 expect(resolvePendingHandoff(empty, { instanceId: to, model: "first" }, [])).toBeNull();
157 // Paging can leave a window of only tool rows after the prompt.
158 const toolRowsOnly = {
159 ...projection,
160 visibleTurnItems: [{ item: { type: "command_execution" } }],
161 } as unknown as OrchestrationV2ThreadProjection;
162 expect(
163 resolvePendingHandoff(toolRowsOnly, { instanceId: to, model: "first" }, [])?.to.model,
164 ).toBe("first");
165 });
166 it("resumes compatible account overlays without showing a handoff", () => {
167 const providers = [from, to].map((instanceId) => ({
168 instanceId,
169 driver: "codex",
170 continuation: { groupKey: "shared" },
171 })) as unknown as ServerProvider[];
172 expect(
173 resolvePendingHandoff(projection, { instanceId: to, model: "target" }, providers),
174 ).toBeNull();
175 });
176});
177​
178describe("model switch change", () => {
179 const selection = (model: string, options?: Record<string, string>) => ({
180 instanceId: from,
181 model,
182 ...(options === undefined
183 ? {}
184 : { options: Object.entries(options).map(([id, value]) => ({ id, value })) }),
185 });
186 const providers = [
187 {
188 instanceId: from,
189 models: ["gpt", "mini"].map((slug) => ({
190 slug,
191 capabilities: {
192 optionDescriptors: [
193 { id: "serviceTier", type: "select", options: [{ id: "fast", label: "Fast" }] },
194 {
195 id: "thought_level",
196 type: "select",
197 role: "reasoning",
198 options: [
199 { id: "medium", label: "Medium", isDefault: true },
200 { id: "high", label: "High" },
201 ],
202 },
203 ],
204 },
205 })),
206 },
207 ] as unknown as ServerProvider[];
208​
209 it("shows only what changed, reading an unset effort as the default", () => {
210 expect(
211 resolveModelSwitchChange(
212 { from: selection("gpt"), to: selection("gpt", { thought_level: "high" }) },
213 providers,
214 ),
215 ).toEqual({ modelChanged: false, effort: { from: "Medium", to: "High" } });
216 expect(
217 resolveModelSwitchChange({ from: selection("gpt"), to: selection("mini") }, providers),
218 ).toEqual({ modelChanged: true, effort: null });
219 });
220​
221 it("ignores changes the user cannot see", () => {
222 expect(
223 resolveModelSwitchChange(
224 { from: selection("gpt"), to: selection("gpt", { thought_level: "medium" }) },
225 providers,
226 ),
227 ).toBeNull();
228 expect(
229 resolveModelSwitchChange(
230 { from: selection("gpt"), to: selection("gpt", { serviceTier: "fast" }) },
231 providers,
232 ),
233 ).toBeNull();
234 // A cataloged model without an effort control ignores stale effort options.
235 expect(
236 resolveModelSwitchChange(
237 {
238 from: selection("plain", { reasoningEffort: "low" }),
239 to: selection("plain", { reasoningEffort: "high" }),
240 },
241 [{ instanceId: from, models: [{ slug: "plain" }] }] as unknown as ServerProvider[],
242 ),
243 ).toBeNull();
244 });
245​
246 it("falls back to stated efforts without a catalog", () => {
247 expect(
248 resolveModelSwitchChange(
249 {
250 from: selection("gpt", { reasoningEffort: "low" }),
251 to: selection("gpt", { reasoningEffort: "high" }),
252 },
253 [],
254 ),
255 ).toEqual({ modelChanged: false, effort: { from: "low", to: "high" } });
256 expect(
257 resolveModelSwitchChange(
258 {
259 from: selection("pi", { thinking: "low" }),
260 to: selection("pi", { thinking: "high" }),
261 },
262 [],
263 ),
264 ).toEqual({ modelChanged: false, effort: { from: "low", to: "high" } });
265 expect(
266 resolveModelSwitchChange(
267 { from: selection("gpt"), to: selection("gpt", { reasoningEffort: "high" }) },
268 [],
269 ),
270 ).toBeNull();
271 });
272});