1import { vi } from "vite-plus/test";
2import { historyResponseItems } from "@t3tools/provider-core/server/handoffBudget";
3import { assert, describe, it } from "@effect/vitest";
4import {
5 CommandId,
6 type ChatAttachment,
7 EventId,
8 MessageId,
9 type ModelSelection,
10 type OrchestrationV2Command,
11 type OrchestrationV2ProviderCapabilities,
12 type OrchestrationV2ProviderSession,
13 type OrchestrationV2ProviderThread,
14 type OrchestrationV2ProviderTurnTokenUsage,
15 ProjectId,
16 ProviderInstanceId,
17 ProviderSessionId,
18 ProviderThreadId,
19 ProviderTurnId,
20 ThreadId,
21 TurnItemId,
22 ProviderDriverKind,
23} from "@t3tools/contracts";
24import * as DateTime from "effect/DateTime";
25import * as Deferred from "effect/Deferred";
26import * as Effect from "effect/Effect";
27import * as Fiber from "effect/Fiber";
28import * as Layer from "effect/Layer";
29import * as PubSub from "effect/PubSub";
30import * as Ref from "effect/Ref";
31import * as Stream from "effect/Stream";
32import * as Schema from "effect/Schema";
33import * as SqlClient from "effect/sql/SqlClient";
34
35import * as SqlitePersistence from "../../persistence/Sqlite.ts";
36import { CommandPolicyCapabilityUnsupportedError } from "../CommandPolicy.ts";
37import {
38 ClaudeBackgroundWorkBlocksQueryReplacementError,
39 ClaudeProviderCapabilitiesV2,
40} from "../Adapters/ClaudeAdapterV2.ts";
41import {
42 CodexProviderCapabilitiesV2,
43 canReuseCodexContextUsage,
44} from "../Adapters/CodexAdapterV2.ts";
45import { AcpProviderCapabilitiesV2 } from "@t3tools/provider-acp/server/adapter";
46import { CursorProviderCapabilitiesV2 } from "@t3tools/provider-cursor/testing";
47import * as EventSink from "../EventSink.ts";
48import * as EventStore from "../EventStore.ts";
49import * as LegacyV1ThreadImporter from "../legacy/LegacyV1ThreadImporter.ts";
50import * as Orchestrator from "../Orchestrator.ts";
51import * as EffectWorker from "../EffectWorker.ts";
52import * as EffectOutbox from "../EffectOutbox.ts";
53import * as ProjectionMaintenance from "../ProjectionMaintenance.ts";
54import * as ProjectionStore from "../ProjectionStore.ts";
55import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts";
56import { makeProviderFailure } from "@t3tools/provider-core/server/failure";
57import {
58 CLAUDE_MODEL_SELECTION,
59 CODEX_MODEL_SELECTION,
60 CURSOR_MODEL_SELECTION,
61 GROK_MODEL_SELECTION,
62} from "./fixtures/shared.ts";
63import * as ProviderReplayHarness from "./ProviderReplayHarness.ts";
64import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace";
65import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
66
67const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
68
69const threadId = ThreadId.make("thread:provider-switch");
70const projectId = ProjectId.make("project:provider-switch");
71const firstPrompt = "Respond with exactly: codex before switch";
72const claudePrompt = "Respond with exactly: claude switched response";
73const returnPrompt = "Respond with exactly: codex after return";
74const CODEX_DRIVER = ProviderDriverKind.make("codex");
75const CLAUDE_DRIVER = ProviderDriverKind.make("claudeAgent");
76const CURSOR_DRIVER = ProviderDriverKind.make("cursor");
77const GROK_DRIVER = ProviderDriverKind.make("acp");
78
79interface CapturedTurn {
80 readonly driver: ProviderDriverKind;
81 readonly threadId: ThreadId;
82 readonly providerThreadId: ProviderThreadId;
83 readonly text: string;
84 readonly attachments: ReadonlyArray<ChatAttachment>;
85 readonly nativeThreadId?: string | null;
86 readonly providerTurnOrdinal?: number;
87 readonly nativeThreadHasTurns?: boolean;
88}
89
90function unimplemented(driver: ProviderDriverKind, detail: string) {
91 return Effect.fail(new ProviderAdapter.ProviderAdapterProtocolError({ driver, detail }));
92}
93
94function makeTestAdapter(input: {
95 readonly instanceId: ProviderInstanceId;
96 readonly driver: ProviderDriverKind;
97 readonly capabilities: OrchestrationV2ProviderCapabilities;
98 readonly modelSelection: ModelSelection;
99 readonly responseByRunOrdinal: Readonly<Record<number, string>>;
100 readonly responseByThreadId?: Readonly<Record<string, Readonly<Record<number, string>>>>;
101 readonly capturedTurns: Ref.Ref<ReadonlyArray<CapturedTurn>>;
102 readonly injectedHistory?: Ref.Ref<ReadonlyArray<unknown>>;
103 readonly failStartOnce?: Ref.Ref<boolean>;
104 /** Starts left to refuse the way Claude does while background work runs. */
105 readonly refuseStarts?: Ref.Ref<number>;
106 readonly failInjectionOnce?: Ref.Ref<boolean>;
107 readonly nativeThreadGeneration?: Ref.Ref<number>;
108 readonly failResume?: boolean;
109 readonly failResumeOnce?: Ref.Ref<boolean>;
110 readonly initialContextUsage?: OrchestrationV2ProviderThread["contextUsage"];
111 readonly getModelContextWindow?: (selection: ModelSelection) => number | undefined;
112 readonly canReuseContextUsage?: ProviderAdapter.ProviderAdapterV2SessionRuntime["canReuseContextUsage"];
113 readonly tokenUsageByRunOrdinal?: Readonly<
114 Record<number, Omit<OrchestrationV2ProviderTurnTokenUsage, "updatedAt">>
115 >;
116 readonly failedRunOrdinals?: ReadonlySet<number>;
117 readonly interruptedRunOrdinals?: ReadonlySet<number>;
118 readonly holdRunOrdinal?: number;
119 readonly holdFirstTurn?: Deferred.Deferred<void>;
120 readonly releaseFirstTurn?: Deferred.Deferred<void>;
121}): ProviderAdapter.ProviderAdapterV2["Service"] {
122 return {
123 instanceId: input.instanceId,
124 driver: input.driver,
125 getCapabilities: () => Effect.succeed(input.capabilities),
126 planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" }),
127 openSession: (sessionInput) =>
128 Effect.gen(function* () {
129 const events = yield* PubSub.unbounded<ProviderAdapter.ProviderAdapterV2Event>();
130 const now = yield* DateTime.now;
131 const providerSession: OrchestrationV2ProviderSession = {
132 id: sessionInput.providerSessionId,
133 driver: input.driver,
134 providerInstanceId: input.instanceId,
135 status: "ready",
136 cwd: sessionInput.runtimePolicy.cwd ?? process.cwd(),
137 model: input.modelSelection.model,
138 capabilities: input.capabilities,
139 createdAt: now,
140 updatedAt: now,
141 lastError: null,
142 };
143
144 const runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime = {
145 instanceId: input.instanceId,
146 driver: input.driver,
147 providerSessionId: sessionInput.providerSessionId,
148 providerSession,
149 events: Stream.fromPubSub(events),
150 ...(input.getModelContextWindow === undefined
151 ? {}
152 : { getModelContextWindow: input.getModelContextWindow }),
153 ...(input.canReuseContextUsage === undefined
154 ? {}
155 : { canReuseContextUsage: input.canReuseContextUsage }),
156 ensureThread: (threadInput) =>
157 Effect.gen(function* () {
158 const createdAt = yield* DateTime.now;
159 const generation =
160 input.nativeThreadGeneration === undefined
161 ? ""
162 : `:${yield* Ref.getAndUpdate(input.nativeThreadGeneration, (value) => value + 1)}`;
163 const nativeThreadId = `${input.driver}:${threadInput.threadId}${generation}`;
164 return {
165 id: ProviderThreadId.make(`provider-thread:${nativeThreadId}`),
166 driver: input.driver,
167 providerInstanceId: input.instanceId,
168 providerSessionId: sessionInput.providerSessionId,
169 appThreadId: threadInput.threadId,
170 ownerNodeId: null,
171 nativeThreadRef: {
172 driver: input.driver,
173 nativeId: nativeThreadId,
174 strength: "strong",
175 },
176 nativeConversationHeadRef: null,
177 status: "idle",
178 firstRunOrdinal: null,
179 lastRunOrdinal: null,
180 handoffIds: [],
181 contextUsage: input.initialContextUsage ?? null,
182 forkedFrom: null,
183 createdAt,
184 updatedAt: createdAt,
185 } satisfies OrchestrationV2ProviderThread;
186 }),
187 resumeThread: ({ providerThread }) =>
188 Effect.gen(function* () {
189 if (
190 input.failResume ||
191 (input.failResumeOnce !== undefined &&
192 (yield* Ref.getAndSet(input.failResumeOnce, false)))
193 )
194 return yield* unimplemented(input.driver, "simulated native resume failure");
195 return providerThread;
196 }),
197 ...(input.injectedHistory === undefined
198 ? {}
199 : {
200 injectHistory: (history: ProviderAdapter.ProviderAdapterV2HistoricalContext) =>
201 Ref.update(input.injectedHistory!, (current) => [
202 ...current,
203 ...historyResponseItems(history.messages, history.context),
204 ]).pipe(
205 Effect.andThen(
206 Effect.gen(function* () {
207 if (
208 input.failInjectionOnce !== undefined &&
209 (yield* Ref.getAndSet(input.failInjectionOnce, false))
210 )
211 return yield* unimplemented(
212 input.driver,
213 "lost injection acknowledgement",
214 );
215 return true;
216 }),
217 ),
218 ),
219 }),
220 compactThread: (turnInput) => runtime.startTurn(turnInput),
221 startTurn: (turnInput) =>
222 Effect.gen(function* () {
223 if (
224 input.failStartOnce !== undefined &&
225 (yield* Ref.getAndSet(input.failStartOnce, false))
226 )
227 return yield* unimplemented(input.driver, "turn start failed after injection");
228 if (
229 input.refuseStarts !== undefined &&
230 (yield* Ref.getAndUpdate(input.refuseStarts, (left) => Math.max(0, left - 1))) > 0
231 )
232 return yield* new ProviderAdapter.ProviderAdapterTurnStartError({
233 driver: input.driver,
234 threadId: turnInput.threadId,
235 providerThreadId: turnInput.providerThread.id,
236 runId: turnInput.runId,
237 cause: new ClaudeBackgroundWorkBlocksQueryReplacementError(),
238 });
239 yield* Effect.yieldNow;
240 yield* Ref.update(input.capturedTurns, (turns) => [
241 ...turns,
242 {
243 driver: input.driver,
244 threadId: turnInput.threadId,
245 providerThreadId: turnInput.providerThread.id,
246 text: turnInput.message.text,
247 attachments: turnInput.message.attachments,
248 nativeThreadId: turnInput.providerThread.nativeThreadRef?.nativeId ?? null,
249 providerTurnOrdinal: turnInput.providerTurnOrdinal,
250 ...(turnInput.nativeThreadHasTurns === undefined
251 ? {}
252 : { nativeThreadHasTurns: turnInput.nativeThreadHasTurns }),
253 },
254 ]);
255 if (
256 turnInput.runOrdinal === (input.holdRunOrdinal ?? 1) &&
257 input.holdFirstTurn !== undefined
258 ) {
259 yield* Deferred.succeed(input.holdFirstTurn, undefined);
260 if (input.releaseFirstTurn === undefined) return;
261 yield* Deferred.await(input.releaseFirstTurn);
262 }
263 const eventTime = yield* DateTime.now;
264 const providerTurnId = ProviderTurnId.make(
265 `provider-turn:${input.driver}:${turnInput.threadId}:${turnInput.runOrdinal}`,
266 );
267 const terminalStatus = input.failedRunOrdinals?.has(turnInput.runOrdinal)
268 ? "failed"
269 : input.interruptedRunOrdinals?.has(turnInput.runOrdinal)
270 ? "interrupted"
271 : "completed";
272 const response =
273 input.responseByThreadId?.[turnInput.threadId]?.[turnInput.runOrdinal] ??
274 input.responseByRunOrdinal[turnInput.runOrdinal] ??
275 `${input.driver} response for run ${turnInput.runOrdinal}`;
276 const providerEvents: ReadonlyArray<ProviderAdapter.ProviderAdapterV2Event> = [
277 {
278 type: "provider_turn.updated",
279 driver: input.driver,
280 providerTurn: {
281 id: providerTurnId,
282 providerThreadId: turnInput.providerThread.id,
283 nodeId: turnInput.rootNodeId,
284 runAttemptId: turnInput.attemptId,
285 nativeTurnRef: {
286 driver: input.driver,
287 nativeId: `native-turn:${turnInput.threadId}:${turnInput.runOrdinal}`,
288 strength: "strong",
289 },
290 ordinal: turnInput.runOrdinal,
291 status: terminalStatus,
292 startedAt: eventTime,
293 completedAt: eventTime,
294 },
295 },
296 {
297 type: "turn_item.updated",
298 driver: input.driver,
299 turnItem: {
300 id: TurnItemId.make(
301 `turn-item:${input.driver}:${turnInput.threadId}:${turnInput.runOrdinal}:assistant`,
302 ),
303 threadId: turnInput.threadId,
304 runId: turnInput.runId,
305 nodeId: turnInput.rootNodeId,
306 providerThreadId: turnInput.providerThread.id,
307 providerTurnId,
308 nativeItemRef: null,
309 parentItemId: null,
310 ordinal: turnInput.runOrdinal * 100 + 1,
311 status: terminalStatus,
312 title: null,
313 startedAt: eventTime,
314 completedAt: eventTime,
315 updatedAt: eventTime,
316 type: "assistant_message",
317 messageId: MessageId.make(
318 `message:${input.driver}:${turnInput.threadId}:${turnInput.runOrdinal}:assistant`,
319 ),
320 text: response,
321 streaming: false,
322 },
323 },
324 {
325 type: "turn.terminal",
326 driver: input.driver,
327 providerThreadId: turnInput.providerThread.id,
328 providerTurnId,
329 runOrdinal: turnInput.runOrdinal,
330 ...(terminalStatus === "failed"
331 ? {
332 status: terminalStatus,
333 failureItemOrdinal: turnInput.runOrdinal * 100 + 2,
334 failure: makeProviderFailure({
335 message: "Simulated provider failure.",
336 code: "simulated_failure",
337 class: "provider_error",
338 }),
339 }
340 : { status: terminalStatus, failure: null }),
341 threadDisposition: "reusable",
342 },
343 ];
344 const reportedUsage = input.tokenUsageByRunOrdinal?.[turnInput.runOrdinal];
345 const turnEvent = providerEvents[0];
346 if (reportedUsage && turnEvent?.type === "provider_turn.updated") {
347 yield* PubSub.publish(events, {
348 ...turnEvent,
349 providerTurn: {
350 ...turnEvent.providerTurn,
351 status: "running",
352 completedAt: null,
353 tokenUsage: { ...reportedUsage, updatedAt: DateTime.formatIso(eventTime) },
354 },
355 });
356 }
357 for (const event of providerEvents) {
358 yield* PubSub.publish(events, event);
359 }
360 }),
361 steerTurn: () => Effect.void,
362 interruptTurn: () => Effect.void,
363 respondToRuntimeRequest: () => Effect.void,
364 readThreadSnapshot: () =>
365 unimplemented(input.driver, "readThreadSnapshot unused in provider switch test"),
366 rollbackThread: () =>
367 unimplemented(input.driver, "rollbackThread unused in provider switch test"),
368 forkThread: () =>
369 unimplemented(input.driver, "forkThread unused in provider switch test"),
370 };
371 return runtime;
372 }),
373 };
374}
375
376const waitForIdle = Effect.fn("ProviderSwitchTest.waitForIdle")(function* (
377 targetThreadId: ThreadId,
378) {
379 const orchestrator = yield* Orchestrator.OrchestratorV2;
380 for (let attempt = 0; attempt < 1_000; attempt += 1) {
381 const projection = yield* orchestrator.getThreadProjection(targetThreadId);
382 if (
383 projection.runs.every(
384 (run) => !["queued", "starting", "running", "waiting"].includes(run.status),
385 )
386 ) {
387 return projection;
388 }
389 yield* Effect.sleep("5 millis");
390 }
391 return yield* Effect.die(new Error("Provider switch test timed out waiting for idle"));
392});
393
394describe("orchestration v2 provider switching", () => {
395 it.live.each([
396 "compact-native",
397 "compact-fallback",
398 "compact-legacy",
399 "large-current-input",
400 "screenshot-native",
401 "screenshot-fallback",
402 "screenshot-pair-native",
403 "screenshot-pair-fallback",
404 "screenshot-eight-native",
405 "screenshot-eight-fallback",
406 "screenshot-large-model-native",
407 "screenshot-reported-capacity-native",
408 "screenshot-model-change-native",
409 "screenshot-option-change-native",
410 "screenshot-model-change-retry-native",
411 "screenshot-option-change-retry-native",
412 "prior-images-reasoning-change-replacement-native",
413 "prior-images-reasoning-change-replacement-small-native",
414 "prior-images-reasoning-change-native",
415 "prior-images-turn-usage-native",
416 "prior-images-turn-usage-replacement-native",
417 "prior-images-turn-usage-model-change-native",
418 "prior-images-turn-usage-option-change-retry-native",
419 "prior-images-reasoning-change-retry-native",
420 "prior-images-native",
421 "prior-images-fallback",
422 "imported-prior-images-native",
423 "imported-prior-images-fallback",
424 "prior-images-telemetry-native",
425 "prior-images-unsent-native",
426 "prior-images-replacement-native",
427 "prior-images-legacy-replacement-native",
428 "delivery-write-failure",
429 ] as const)("preserves handoffs through %s", (scenario) =>
430 Effect.scoped(
431 Effect.gen(function* () {
432 const cwd = yield* checkpointWorkspace(`handoff-${scenario}`);
433 const capturedTurns = yield* Ref.make<ReadonlyArray<CapturedTurn>>([]);
434 const injectedHistory = yield* Ref.make<ReadonlyArray<unknown>>([]);
435 const reasoningScenario = scenario.includes("reasoning-change");
436 const turnUsageScenario = reasoningScenario || scenario.includes("turn-usage");
437 const modelScenario =
438 scenario.includes("model") || scenario.includes("option-change") || reasoningScenario;
439 const failStartOnce = yield* Ref.make(false);
440 const failResumeOnce = yield* Ref.make(false);
441 const generation = yield* Ref.make(0);
442 const priorImages = scenario.includes("prior-images");
443 const replaceNative = scenario.includes("replacement");
444 const capacityScenario = modelScenario || scenario.includes("reported-capacity");
445 const returning =
446 scenario === "large-current-input" || scenario.includes("-change-") || priorImages;
447 const targetSelection: ModelSelection = !modelScenario
448 ? CLAUDE_MODEL_SELECTION
449 : {
450 ...CLAUDE_MODEL_SELECTION,
451 ...(reasoningScenario
452 ? { options: [{ id: "reasoningEffort", value: "low" }] }
453 : scenario.includes("option-change")
454 ? { options: [{ id: "contextWindow", value: "1m" }] }
455 : { model: `${CLAUDE_MODEL_SELECTION.model}-large` }),
456 };
457 const layerRegistry = ProviderAdapterRegistry.layerFromAdapters([
458 makeTestAdapter({
459 instanceId: CODEX_MODEL_SELECTION.instanceId,
460 driver: CODEX_DRIVER,
461 capabilities: CodexProviderCapabilitiesV2,
462 modelSelection: CODEX_MODEL_SELECTION,
463 responseByRunOrdinal: { 1: "Original partial work" },
464 capturedTurns,
465 }),
466 makeTestAdapter({
467 instanceId: CLAUDE_MODEL_SELECTION.instanceId,
468 driver: CLAUDE_DRIVER,
469 capabilities: ClaudeProviderCapabilitiesV2,
470 modelSelection: CLAUDE_MODEL_SELECTION,
471 responseByRunOrdinal: {},
472 capturedTurns,
473 ...(scenario.includes("retry") || scenario.includes("unsent") ? { failStartOnce } : {}),
474 ...(replaceNative ? { failResumeOnce, nativeThreadGeneration: generation } : {}),
475 ...(scenario.includes("reported-capacity")
476 ? { initialContextUsage: { usedTokens: 999_999, maxTokens: 1_000_000 } }
477 : {}),
478 ...(turnUsageScenario
479 ? {
480 canReuseContextUsage: canReuseCodexContextUsage,
481 tokenUsageByRunOrdinal: {
482 2: {
483 usedTokens: replaceNative ? 30_000 : 37_321,
484 maxTokens: replaceNative
485 ? scenario.includes("small")
486 ? 32_000
487 : 64_000
488 : 258_400,
489 },
490 },
491 }
492 : modelScenario
493 ? {
494 getModelContextWindow: (selection: ModelSelection) =>
495 selection.model.endsWith("-large") ||
496 selection.options?.some(
497 (option) => option.id === "contextWindow" && option.value === "1m",
498 )
499 ? 1_000_000
500 : 32_000,
501 }
502 : scenario === "large-current-input" || priorImages
503 ? { getModelContextWindow: () => 32_000 }
504 : {}),
505 ...(scenario.endsWith("-native") || scenario === "large-current-input"
506 ? { injectedHistory }
507 : {}),
508 }),
509 ]);
510 yield* Effect.gen(function* () {
511 const orchestrator = yield* Orchestrator.OrchestratorV2;
512 const worker = yield* EffectWorker.OrchestrationEffectWorkerV2;
513 const eventSink = yield* EventSink.EventSinkV2;
514 const screenshot: ChatAttachment = {
515 type: "image",
516 id: "screenshot",
517 name: "screenshot.png",
518 mimeType: "image/png",
519 sizeBytes: 100_000,
520 };
521 const screenshots = Array.from(
522 {
523 length:
524 scenario.includes("eight") || capacityScenario
525 ? 8
526 : scenario.includes("pair")
527 ? 2
528 : 1,
529 },
530 (_, index) => ({ ...screenshot, id: `screenshot-${index}` }),
531 );
532 const targetOrdinal = returning ? 4 : 2;
533 const dispatch = (ordinal: number, text: string, selection: ModelSelection) =>
534 orchestrator.dispatch({
535 type: "message.dispatch",
536 commandId: CommandId.make(`regression:${ordinal}`),
537 threadId,
538 messageId: MessageId.make(`regression:${ordinal}`),
539 createdBy: "user",
540 creationSource: "web",
541 text,
542 attachments:
543 turnUsageScenario && ordinal === 2
544 ? Array.from({ length: 8 }, (_, index) => ({
545 ...screenshot,
546 id: `prior-${index}`,
547 }))
548 : turnUsageScenario && ordinal >= targetOrdinal
549 ? [screenshot, { ...screenshot, id: "current-2" }]
550 : scenario.startsWith("screenshot") && ordinal >= targetOrdinal
551 ? screenshots
552 : priorImages && ordinal === (scenario.startsWith("imported") ? 1 : 2)
553 ? [screenshot]
554 : [],
555 modelSelection: selection,
556 dispatchMode: { type: "start_immediately" },
557 });
558 const wait = (ordinal: number) =>
559 orchestrator.streamStoredEvents.pipe(
560 Stream.filter(
561 ({ event }) =>
562 event.type === "run.updated" &&
563 event.payload.ordinal === ordinal &&
564 (event.payload.status === "completed" || event.payload.status === "failed"),
565 ),
566 Stream.runHead,
567 Effect.andThen(worker.drain()),
568 );
569 yield* orchestrator.dispatch({
570 type: "thread.create",
571 commandId: CommandId.make("regression:create"),
572 threadId,
573 projectId,
574 createdBy: "user",
575 creationSource: "web",
576 title: "Handoff regression",
577 modelSelection: CODEX_MODEL_SELECTION,
578 runtimeMode: "full-access",
579 interactionMode: "default",
580 branch: null,
581 worktreePath: null,
582 });
583 yield* dispatch(1, "Original request with constraints", CODEX_MODEL_SELECTION);
584 yield* wait(1);
585 const write = eventSink.write;
586 const spy =
587 scenario === "delivery-write-failure"
588 ? vi.spyOn(eventSink, "write").mockImplementation((input) =>
589 input.events.some(
590 (event) =>
591 event.type === "context-handoff.updated" &&
592 event.payload.delivery?.status === "inline",
593 )
594 ? Effect.fail(
595 new EventSink.EventSinkWriteError({
596 eventCount: input.events.length,
597 cause: "bookkeeping unavailable",
598 }),
599 )
600 : write(input),
601 )
602 : undefined;
603 yield* Effect.addFinalizer(() => Effect.sync(() => spy?.mockRestore()));
604 const current = scenario.startsWith("compact")
605 ? "/compact"
606 : capacityScenario && !turnUsageScenario
607 ? "x".repeat(70_000)
608 : scenario === "large-current-input"
609 ? "x".repeat(9_000)
610 : "Continue work";
611 // First establish the returning native thread: the current request must
612 // not be charged as existing context on the subsequent handoff.
613 if (returning) {
614 if (scenario.includes("unsent")) yield* Ref.set(failStartOnce, true);
615 yield* dispatch(
616 2,
617 turnUsageScenario ? "x".repeat(27_460) : "Establish target",
618 CLAUDE_MODEL_SELECTION,
619 );
620 yield* wait(2);
621 if (modelScenario && !turnUsageScenario) {
622 const target = (yield* orchestrator.getThreadProjection(
623 threadId,
624 )).providerThreads.find(
625 (thread) => thread.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId,
626 )!;
627 yield* eventSink.write({
628 events: [
629 {
630 id: EventId.make("old-model-usage"),
631 type: "provider-thread.updated",
632 threadId,
633 occurredAt: yield* DateTime.now,
634 payload: {
635 ...target,
636 contextUsage: {
637 usedTokens: 30_000,
638 maxTokens: 32_000,
639 autoCompactThreshold: 31_000,
640 },
641 },
642 },
643 ],
644 });
645 }
646 if (scenario.includes("legacy-replacement")) {
647 const existing = yield* orchestrator.getThreadProjection(threadId);
648 yield* eventSink.write({
649 events: existing.attempts.map(
650 ({ nativeThreadId: _nativeThreadId, ...legacy }, index) => ({
651 id: EventId.make(`legacy-attempt:${index}`),
652 type: "run-attempt.updated" as const,
653 threadId,
654 occurredAt: existing.thread.createdAt,
655 payload: legacy,
656 }),
657 ),
658 });
659 }
660 if (scenario.includes("telemetry")) {
661 const target = (yield* orchestrator.getThreadProjection(
662 threadId,
663 )).providerThreads.find(
664 (thread) => thread.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId,
665 )!;
666 yield* eventSink.write({
667 events: [
668 {
669 id: EventId.make("compacted-context-usage"),
670 type: "provider-thread.updated",
671 threadId,
672 occurredAt: yield* DateTime.now,
673 payload: { ...target, contextUsage: { usedTokens: 100, maxTokens: 32_000 } },
674 },
675 ],
676 });
677 }
678 yield* dispatch(
679 3,
680 priorImages && !replaceNative
681 ? "New source constraint " + "q".repeat(9_000)
682 : "New source constraint",
683 CODEX_MODEL_SELECTION,
684 );
685 yield* wait(3);
686 }
687 if (replaceNative) {
688 const target = (yield* orchestrator.getThreadProjection(threadId)).providerThreads.find(
689 (thread) => thread.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId,
690 )!;
691 yield* orchestrator.dispatch({
692 type: "provider-session.detach",
693 commandId: CommandId.make("detach-for-native-replacement"),
694 threadId,
695 providerSessionId: target.providerSessionId!,
696 });
697 yield* worker.drain();
698 yield* Ref.set(failResumeOnce, true);
699 }
700 if (scenario.includes("retry")) yield* Ref.set(failStartOnce, true);
701 yield* dispatch(targetOrdinal, current, targetSelection);
702 yield* wait(targetOrdinal);
703 if (scenario.includes("retry")) {
704 const failed = yield* orchestrator.getThreadProjection(threadId);
705 assert.equal(failed.runs.at(-1)?.status, "failed");
706 const failedUsage = failed.providerThreads.find(
707 (thread) => thread.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId,
708 )!.contextUsage;
709 if (reasoningScenario || scenario.includes("turn-usage")) {
710 assert.deepEqual(failedUsage, { usedTokens: 37_321, maxTokens: 258_400 });
711 } else {
712 assert.deepEqual(failedUsage, { usedTokens: 30_000, maxTokens: 1_000_000 });
713 }
714 if (reasoningScenario) {
715 const target = failed.providerThreads.find(
716 (thread) => thread.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId,
717 )!;
718 yield* orchestrator.dispatch({
719 type: "provider-session.detach",
720 commandId: CommandId.make("detach-before-reasoning-retry"),
721 threadId,
722 providerSessionId: target.providerSessionId!,
723 });
724 yield* worker.drain();
725 }
726 yield* dispatch(targetOrdinal + 1, current, targetSelection);
727 yield* wait(targetOrdinal + 1);
728 }
729 if (reasoningScenario && replaceNative) {
730 const replaced = yield* orchestrator.getThreadProjection(threadId);
731 assert.equal(
732 replaced.runs.at(-1)?.status,
733 scenario.includes("small") ? "failed" : "completed",
734 );
735 const target = replaced.providerThreads.find(
736 (thread) => thread.providerInstanceId === CLAUDE_MODEL_SELECTION.instanceId,
737 )!;
738 assert.isNull(target.contextUsage);
739 assert.equal(yield* Ref.get(generation), 2);
740 assert.equal(
741 (yield* Ref.get(capturedTurns)).at(-1)!.driver,
742 scenario.includes("small") ? CODEX_DRIVER : CLAUDE_DRIVER,
743 );
744 return;
745 }
746 if (replaceNative) {
747 if (scenario.includes("legacy-replacement")) {
748 const recovered = yield* orchestrator.getThreadProjection(threadId);
749 const handoff = recovered.contextHandoffs.at(-1)!;
750 const { history: _history, ...legacyHandoff } = handoff;
751 yield* eventSink.write({
752 events: [
753 {
754 id: EventId.make("legacy-replacement-handoff"),
755 type: "context-handoff.updated",
756 threadId,
757 occurredAt: yield* DateTime.now,
758 payload: {
759 ...legacyHandoff,
760 delivery: {
761 nativeThreadId: handoff.delivery!.nativeThreadId,
762 status: handoff.delivery!.status,
763 itemIds: [],
764 },
765 },
766 },
767 ],
768 });
769 }
770 yield* dispatch(
771 targetOrdinal + 1,
772 "Continue after native replacement",
773 CLAUDE_MODEL_SELECTION,
774 );
775 yield* wait(targetOrdinal + 1);
776 yield* dispatch(
777 targetOrdinal + 2,
778 "Another source constraint " + "q".repeat(9_000),
779 CODEX_MODEL_SELECTION,
780 );
781 yield* wait(targetOrdinal + 2);
782 yield* dispatch(targetOrdinal + 3, current, CLAUDE_MODEL_SELECTION);
783 yield* wait(targetOrdinal + 3);
784 }
785 const projection = yield* orchestrator.getThreadProjection(threadId);
786 assert.equal(projection.runs.at(-1)?.status, "completed");
787 if (priorImages) {
788 const handoff = projection.contextHandoffs.at(-1)!;
789 const context = scenario.endsWith("-native")
790 ? yield* encodeJson(yield* Ref.get(injectedHistory))
791 : (yield* Ref.get(capturedTurns)).at(-1)!.text;
792 const shouldFit =
793 turnUsageScenario ||
794 scenario.startsWith("imported") ||
795 scenario.includes("telemetry") ||
796 scenario.includes("unsent") ||
797 replaceNative;
798 const sourceText =
799 `${replaceNative ? "Another" : "New"} source constraint ` + "q".repeat(9_000);
800 const sourceItem = projection.turnItems.find(
801 (item) => item.type === "user_message" && item.text === sourceText,
802 )!;
803 assert.isDefined(sourceItem);
804 if (shouldFit) {
805 assert.include(context, sourceText);
806 assert.include(
807 projection.contextHandoffs
808 .filter(
809 (record) =>
810 record.targetRunId ===
811 (scenario.includes("retry")
812 ? projection.runs.find((run) => run.ordinal === targetOrdinal)!.id
813 : projection.runs.at(-1)!.id),
814 )
815 .flatMap((record) => record.delivery?.itemIds ?? []),
816 sourceItem.id,
817 );
818 } else {
819 assert.notInclude(context, "q".repeat(9_000));
820 assert.isAbove(handoff.delivery!.omittedItemIds!.length, 0);
821 }
822 const latestRun = projection.runs.at(-1)!;
823 const latestAttempt = projection.attempts.find(
824 (attempt) => attempt.id === latestRun.activeAttemptId,
825 )!;
826 const target = projection.providerThreads.find(
827 (thread) => thread.id === latestAttempt.providerThreadId,
828 )!;
829 assert.equal(latestAttempt.nativeThreadId, target.nativeThreadRef!.nativeId);
830 if (turnUsageScenario) {
831 if (replaceNative) assert.isNull(target.contextUsage);
832 else
833 assert.deepEqual(target.contextUsage, { usedTokens: 37_321, maxTokens: 258_400 });
834 assert.lengthOf((yield* Ref.get(capturedTurns)).at(-1)!.attachments, 2);
835 }
836 if (replaceNative) assert.equal(yield* Ref.get(generation), 2);
837 return;
838 }
839 const handoff = projection.contextHandoffs.at(-1)!;
840 if (scenario.startsWith("screenshot")) {
841 assert.deepEqual((yield* Ref.get(capturedTurns)).at(-1)!.attachments, screenshots);
842 }
843 if (scenario === "compact-fallback" || scenario === "compact-legacy") {
844 if (scenario === "compact-legacy") {
845 const { history: _history, ...legacyHandoff } = handoff;
846 yield* eventSink.write({
847 events: [
848 {
849 id: EventId.make("legacy-handoff-shape"),
850 type: "context-handoff.updated",
851 threadId,
852 runId: handoff.targetRunId,
853 occurredAt: yield* DateTime.now,
854 payload: legacyHandoff,
855 },
856 ],
857 });
858 }
859 assert.isUndefined(handoff.delivery);
860 yield* dispatch(3, "Continue after compact", CLAUDE_MODEL_SELECTION);
861 yield* wait(3);
862 assert.include(
863 (yield* Ref.get(capturedTurns)).at(-1)!.text,
864 "Original request with constraints",
865 );
866 assert.equal(
867 (yield* orchestrator.getThreadProjection(threadId)).contextHandoffs.at(-1)?.delivery
868 ?.status,
869 "inline",
870 );
871 } else if (
872 scenario === "delivery-write-failure" ||
873 (scenario.startsWith("screenshot") && scenario.endsWith("fallback"))
874 ) {
875 assert.equal(
876 handoff.delivery?.status,
877 scenario.startsWith("screenshot") ? "inline" : "pending",
878 );
879 assert.include(
880 (yield* Ref.get(capturedTurns)).at(-1)!.text,
881 "Original request with constraints",
882 );
883 } else {
884 assert.equal(handoff.delivery?.status, "injected");
885 assert.equal((yield* Ref.get(capturedTurns)).at(-1)!.text, current);
886 assert.include(
887 yield* encodeJson(yield* Ref.get(injectedHistory)),
888 "Original request with constraints",
889 );
890 }
891 }).pipe(
892 Effect.provide(
893 ProviderReplayHarness.layerWithRegistry(
894 {
895 name: `handoff-${scenario}`,
896 runtimePolicyOverride: {
897 cwd,
898 approvalPolicy: "never",
899 sandboxPolicy: { type: "readOnly" },
900 },
901 },
902 layerRegistry,
903 ),
904 ),
905 );
906 }),
907 ),
908 );
909 it.live.each(["turn-start", "injection", "large-missed-request"] as const)(
910 "recovers %s failure without duplicating history in the same native thread",
911 (failure) =>
912 Effect.scoped(
913 Effect.gen(function* () {
914 const cwd = yield* checkpointWorkspace(`handoff-retry-${failure}`);
915 const capturedTurns = yield* Ref.make<ReadonlyArray<CapturedTurn>>([]);
916 const injectedHistory = yield* Ref.make<ReadonlyArray<unknown>>([]);
917 const failOnce = yield* Ref.make(true);
918 const generation = yield* Ref.make(0);
919 const layerRegistry = ProviderAdapterRegistry.layerFromAdapters([
920 makeTestAdapter({
921 instanceId: CODEX_MODEL_SELECTION.instanceId,
922 driver: CODEX_DRIVER,
923 capabilities: CodexProviderCapabilitiesV2,
924 modelSelection: CODEX_MODEL_SELECTION,
925 responseByRunOrdinal: { 1: "Original partial work" },
926 capturedTurns,
927 }),
928 makeTestAdapter({
929 instanceId: CLAUDE_MODEL_SELECTION.instanceId,
930 driver: CLAUDE_DRIVER,
931 capabilities: ClaudeProviderCapabilitiesV2,
932 modelSelection: CLAUDE_MODEL_SELECTION,
933 responseByRunOrdinal: {},
934 capturedTurns,
935 injectedHistory,
936 nativeThreadGeneration: generation,
937 getModelContextWindow: () => 32_000,
938 ...(failure !== "injection"
939 ? { failStartOnce: failOnce }
940 : { failInjectionOnce: failOnce }),
941 }),
942 ]);
943 yield* Effect.gen(function* () {
944 const orchestrator = yield* Orchestrator.OrchestratorV2;
945 const worker = yield* EffectWorker.OrchestrationEffectWorkerV2;
946 const dispatch = (ordinal: number, text: string, selection: ModelSelection) =>
947 orchestrator.dispatch({
948 type: "message.dispatch",
949 commandId: CommandId.make(`retry:${ordinal}`),
950 threadId,
951 messageId: MessageId.make(`retry:${ordinal}`),
952 createdBy: "user",
953 creationSource: "web",
954 text,
955 attachments: [],
956 modelSelection: selection,
957 dispatchMode: { type: "start_immediately" },
958 });
959 const wait = (ordinal: number, status: "failed" | "completed") =>
960 orchestrator.streamStoredEvents.pipe(
961 Stream.filter(
962 ({ event }) =>
963 event.type === "run.updated" &&
964 event.payload.ordinal === ordinal &&
965 (event.payload.status === "completed" || event.payload.status === "failed"),
966 ),
967 Stream.runHead,
968 Effect.andThen(worker.drain()),
969 Effect.andThen(
970 Effect.gen(function* () {
971 assert.equal(
972 (yield* orchestrator.getThreadProjection(threadId)).runs.at(-1)?.status,
973 status,
974 );
975 }),
976 ),
977 );
978 yield* orchestrator.dispatch({
979 type: "thread.create",
980 commandId: CommandId.make("retry:create"),
981 threadId,
982 projectId,
983 createdBy: "user",
984 creationSource: "web",
985 title: "Handoff retry",
986 modelSelection: CODEX_MODEL_SELECTION,
987 runtimeMode: "full-access",
988 interactionMode: "default",
989 branch: null,
990 worktreePath: null,
991 });
992 yield* dispatch(1, "Original request with constraints", CODEX_MODEL_SELECTION);
993 yield* wait(1, "completed");
994 yield* dispatch(
995 2,
996 failure === "large-missed-request" ? "x".repeat(7_000) : "First target request",
997 CLAUDE_MODEL_SELECTION,
998 );
999 yield* wait(2, "failed");
1000 const failed = yield* orchestrator.getThreadProjection(threadId);
1001 const failedHandoff = failed.contextHandoffs.at(-1)!;
1002 assert.equal(
1003 failedHandoff.delivery?.status,
1004 failure !== "injection" ? "injected" : "pending",
1005 );
1006 const historyBeforeRetry = (yield* Ref.get(injectedHistory)).length;
1007 const retryText =
1008 failure === "large-missed-request" ? "y".repeat(10_000) : "Retry target request";
1009 yield* dispatch(3, retryText, CLAUDE_MODEL_SELECTION);
1010 yield* wait(3, "completed");
1011 const retried = yield* orchestrator.getThreadProjection(threadId);
1012 const lastTurn = (yield* Ref.get(capturedTurns)).at(-1)!;
1013 assert.equal(lastTurn.text, retryText);
1014 if (failure !== "injection") {
1015 const delta = yield* encodeJson(
1016 (yield* Ref.get(injectedHistory)).slice(historyBeforeRetry),
1017 );
1018 if (failure === "large-missed-request") {
1019 assert.include(delta, "omitted 1 items");
1020 const missedRequest = retried.turnItems.find(
1021 (item) => item.type === "user_message" && item.text === "x".repeat(7_000),
1022 )!;
1023 const delivery = retried.contextHandoffs.at(-1)!.delivery!;
1024 assert.include(delivery.omittedItemIds ?? [], missedRequest.id);
1025 assert.notInclude(delivery.itemIds, missedRequest.id);
1026 } else assert.include(delta, "First target request");
1027 assert.notInclude(delta, "Original request with constraints");
1028 assert.notInclude(delta, "Original partial work");
1029 assert.equal(yield* Ref.get(generation), 1);
1030 } else {
1031 assert.equal(yield* Ref.get(generation), 2);
1032 assert.notEqual(
1033 retried.contextHandoffs.at(-1)?.delivery?.nativeThreadId,
1034 failedHandoff.delivery?.nativeThreadId,
1035 );
1036 const newHistory = yield* encodeJson(
1037 (yield* Ref.get(injectedHistory)).slice(historyBeforeRetry),
1038 );
1039 assert.include(newHistory, "Original request with constraints");
1040 assert.include(newHistory, "Original partial work");
1041 assert.notInclude(newHistory, "Retry target request");
1042 }
1043 const beforeFollowup = yield* Ref.get(injectedHistory);
1044 const followup = "z".repeat(6_000);
1045 yield* dispatch(4, followup, CLAUDE_MODEL_SELECTION);
1046 yield* wait(4, "completed");
1047 assert.equal((yield* Ref.get(capturedTurns)).at(-1)!.text, followup);
1048 assert.deepEqual(yield* Ref.get(injectedHistory), beforeFollowup);
1049 assert.equal(
1050 (yield* orchestrator.getThreadProjection(threadId)).contextHandoffs.length,
1051 retried.contextHandoffs.length,
1052 );
1053 }).pipe(
1054 Effect.provide(
1055 ProviderReplayHarness.layerWithRegistry(
1056 {
1057 name: `handoff-retry-${failure}`,
1058 runtimePolicyOverride: {
1059 cwd,
1060 approvalPolicy: "never",
1061 sandboxPolicy: { type: "readOnly" },
1062 },
1063 },
1064 layerRegistry,
1065 ),
1066 ),
1067 );
1068 }),
1069 ),
1070 );
1071
1072 it.live.each(["native", "legacy"] as const)(
1073 "starts a replacement native thread as new after the resume fallback with %s attempts",
1074 (attempts) =>
1075 Effect.scoped(
1076 Effect.gen(function* () {
1077 const cwd = yield* checkpointWorkspace("resume-fallback-native-turns");
1078 const capturedTurns = yield* Ref.make<ReadonlyArray<CapturedTurn>>([]);
1079 const failResumeOnce = yield* Ref.make(false);
1080 const generation = yield* Ref.make(0);
1081 const layerRegistry = ProviderAdapterRegistry.layerFromAdapters([
1082 makeTestAdapter({
1083 instanceId: CLAUDE_MODEL_SELECTION.instanceId,
1084 driver: CLAUDE_DRIVER,
1085 capabilities: ClaudeProviderCapabilitiesV2,
1086 modelSelection: CLAUDE_MODEL_SELECTION,
1087 responseByRunOrdinal: {},
1088 capturedTurns,
1089 failResumeOnce,
1090 nativeThreadGeneration: generation,
1091 }),
1092 ]);
1093 yield* Effect.gen(function* () {
1094 const orchestrator = yield* Orchestrator.OrchestratorV2;
1095 const worker = yield* EffectWorker.OrchestrationEffectWorkerV2;
1096 const runToCompletion = Effect.fn("runToCompletion")(function* (ordinal: number) {
1097 yield* orchestrator.dispatch({
1098 type: "message.dispatch",
1099 commandId: CommandId.make(`resume-fallback:${ordinal}`),
1100 threadId,
1101 messageId: MessageId.make(`resume-fallback:${ordinal}`),
1102 createdBy: "user",
1103 creationSource: "web",
1104 text: `Request ${ordinal}`,
1105 attachments: [],
1106 modelSelection: CLAUDE_MODEL_SELECTION,
1107 dispatchMode: { type: "start_immediately" },
1108 });
1109 yield* orchestrator.streamStoredEvents.pipe(
1110 Stream.filter(
1111 ({ event }) =>
1112 event.type === "run.updated" &&
1113 event.payload.ordinal === ordinal &&
1114 (event.payload.status === "completed" || event.payload.status === "failed"),
1115 ),
1116 Stream.runHead,
1117 );
1118 yield* worker.drain();
1119 assert.equal(
1120 (yield* orchestrator.getThreadProjection(threadId)).runs.at(-1)?.status,
1121 "completed",
1122 );
1123 });
1124 yield* orchestrator.dispatch({
1125 type: "thread.create",
1126 commandId: CommandId.make("resume-fallback:create"),
1127 threadId,
1128 projectId,
1129 createdBy: "user",
1130 creationSource: "web",
1131 title: "Resume fallback",
1132 modelSelection: CLAUDE_MODEL_SELECTION,
1133 runtimeMode: "full-access",
1134 interactionMode: "default",
1135 branch: null,
1136 worktreePath: null,
1137 });
1138 yield* runToCompletion(1);
1139 // A detached session resumes on the next turn, as after a restart.
1140 const providerThread = (yield* orchestrator.getThreadProjection(
1141 threadId,
1142 )).providerThreads.at(0);
1143 if (providerThread === undefined || providerThread.providerSessionId === null) {
1144 return yield* Effect.die("Expected a provider session after the first turn.");
1145 }
1146 yield* orchestrator.dispatch({
1147 type: "provider-session.detach",
1148 commandId: CommandId.make("resume-fallback:detach"),
1149 threadId,
1150 providerSessionId: providerThread.providerSessionId,
1151 });
1152 yield* worker.drain();
1153 if (attempts === "legacy") {
1154 // Attempts persisted before native identity existed carry no native id.
1155 const existing = yield* orchestrator.getThreadProjection(threadId);
1156 yield* (yield* EventSink.EventSinkV2).write({
1157 events: existing.attempts.map(
1158 ({ nativeThreadId: _nativeThreadId, ...legacy }, index) => ({
1159 id: EventId.make(`resume-fallback-legacy-attempt:${index}`),
1160 type: "run-attempt.updated" as const,
1161 threadId,
1162 occurredAt: existing.thread.createdAt,
1163 payload: legacy,
1164 }),
1165 ),
1166 });
1167 }
1168 yield* Ref.set(failResumeOnce, true);
1169 yield* runToCompletion(2);
1170 yield* runToCompletion(3);
1171
1172 const turns = yield* Ref.get(capturedTurns);
1173 assert.equal(turns.length, 3);
1174 assert.equal(yield* Ref.get(generation), 2);
1175 // The fallback binds a new native thread to a provider thread that
1176 // already has a turn, so the provider must not resume it.
1177 assert.notEqual(turns[1]?.nativeThreadId, turns[0]?.nativeThreadId);
1178 assert.isAbove(turns[1]?.providerTurnOrdinal ?? 0, 1);
1179 assert.isFalse(turns[1]?.nativeThreadHasTurns);
1180 assert.equal(turns[2]?.nativeThreadId, turns[1]?.nativeThreadId);
1181 assert.isTrue(turns[2]?.nativeThreadHasTurns);
1182 }).pipe(
1183 Effect.provide(
1184 ProviderReplayHarness.layerWithRegistry(
1185 {
1186 name: "resume-fallback-native-turns",
1187 runtimePolicyOverride: {
1188 cwd,
1189 approvalPolicy: "never",
1190 sandboxPolicy: { type: "readOnly" },
1191 },
1192 },
1193 layerRegistry,
1194 ),
1195 ),
1196 );
1197 }),
1198 ),
1199 );
1200
1201 it.live("keeps the native session after turns refused before reaching the provider", () =>
1202 Effect.scoped(
1203 Effect.gen(function* () {
1204 const cwd = yield* checkpointWorkspace("refused-start-keeps-native");
1205 const capturedTurns = yield* Ref.make<ReadonlyArray<CapturedTurn>>([]);
1206 const refuseStarts = yield* Ref.make(0);
1207 const generation = yield* Ref.make(0);
1208 const registry = ProviderAdapterRegistry.layerFromAdapters([
1209 makeTestAdapter({
1210 instanceId: CLAUDE_MODEL_SELECTION.instanceId,
1211 driver: CLAUDE_DRIVER,
1212 capabilities: ClaudeProviderCapabilitiesV2,
1213 modelSelection: CLAUDE_MODEL_SELECTION,
1214 responseByRunOrdinal: {},
1215 capturedTurns,
1216 refuseStarts,
1217 nativeThreadGeneration: generation,
1218 }),
1219 ]);
1220 yield* Effect.gen(function* () {
1221 const orchestrator = yield* Orchestrator.OrchestratorV2;
1222 const worker = yield* EffectWorker.OrchestrationEffectWorkerV2;
1223 const run = Effect.fn("run")(function* (ordinal: number) {
1224 yield* orchestrator.dispatch({
1225 type: "message.dispatch",
1226 commandId: CommandId.make(`refused-start:${ordinal}`),
1227 threadId,
1228 messageId: MessageId.make(`refused-start:${ordinal}`),
1229 createdBy: "user",
1230 creationSource: "web",
1231 text: `Request ${ordinal}`,
1232 attachments: [],
1233 modelSelection: CLAUDE_MODEL_SELECTION,
1234 dispatchMode: { type: "start_immediately" },
1235 });
1236 yield* orchestrator.streamStoredEvents.pipe(
1237 Stream.filter(
1238 ({ event }) =>
1239 event.type === "run.updated" &&
1240 event.payload.ordinal === ordinal &&
1241 (event.payload.status === "completed" || event.payload.status === "failed"),
1242 ),
1243 Stream.runHead,
1244 );
1245 yield* worker.drain();
1246 return (yield* orchestrator.getThreadProjection(threadId)).runs.at(-1)?.status;
1247 });
1248 yield* orchestrator.dispatch({
1249 type: "thread.create",
1250 commandId: CommandId.make("refused-start:create"),
1251 threadId,
1252 projectId,
1253 createdBy: "user",
1254 creationSource: "web",
1255 title: "Refused start",
1256 modelSelection: CLAUDE_MODEL_SELECTION,
1257 runtimeMode: "full-access",
1258 interactionMode: "default",
1259 branch: null,
1260 worktreePath: null,
1261 });
1262 assert.equal(yield* run(1), "completed");
1263 // Run 3 carries run 2's missed request as a handoff and is refused too.
1264 yield* Ref.set(refuseStarts, 2);
1265 assert.equal(yield* run(2), "failed");
1266 assert.equal(yield* run(3), "failed");
1267 assert.equal(yield* run(4), "completed");
1268
1269 const turns = yield* Ref.get(capturedTurns);
1270 assert.equal(turns.length, 2);
1271 // Nothing reached the provider, so the next turn continues the same
1272 // native session instead of replacing it with a summary.
1273 assert.equal(yield* Ref.get(generation), 1);
1274 assert.equal(turns[1]?.nativeThreadId, turns[0]?.nativeThreadId);
1275 assert.include(turns[1]?.text, "Request 2");
1276 assert.include(turns[1]?.text, "Request 3");
1277 }).pipe(
1278 Effect.provide(
1279 ProviderReplayHarness.layerWithRegistry(
1280 {
1281 name: "refused-start-keeps-native",
1282 runtimePolicyOverride: {
1283 cwd,
1284 approvalPolicy: "never",
1285 sandboxPolicy: { type: "readOnly" },
1286 },
1287 },
1288 registry,
1289 ),
1290 ),
1291 );
1292 }),
1293 ),
1294 );
1295
1296 it.live.each(
1297 (["failed", "interrupted"] as const).flatMap((status) =>
1298 [false, true].flatMap((queued) =>
1299 [false, true].flatMap((returning) =>
1300 [false, true].map(
1301 (native) =>
1302 [
1303 `${status} context ${queued ? "through the queue" : "immediately"} to ${returning ? "a returning" : "a new"} provider via ${native ? "native history" : "text"}`,
1304 { status, queued, returning, native },
1305 ] as const,
1306 ),
1307 ),
1308 ),
1309 ),
1310 )("hands off %s", ([, { status, queued, returning, native }]) =>
1311 Effect.scoped(
1312 Effect.gen(function* () {
1313 const cwd = yield* checkpointWorkspace(`handoff-${status}-${queued}-${returning}`);
1314 const capturedTurns = yield* Ref.make<ReadonlyArray<CapturedTurn>>([]);
1315 const injectedHistory = yield* Ref.make<ReadonlyArray<unknown>>([]);
1316 const started = yield* Deferred.make<void>();
1317 const release = yield* Deferred.make<void>();
1318 const sourceOrdinal = returning ? 2 : 1;
1319 const sourceSelection = returning ? CLAUDE_MODEL_SELECTION : CODEX_MODEL_SELECTION;
1320 const targetSelection = returning ? CODEX_MODEL_SELECTION : CLAUDE_MODEL_SELECTION;
1321 const originalPrompt = "Keep the release marker violet and preserve the existing API.";
1322 const partialResponse = "I checked the API and found the release configuration.";
1323 const layerRegistry = ProviderAdapterRegistry.layerFromAdapters(
1324 (
1325 [
1326 [CODEX_MODEL_SELECTION, CODEX_DRIVER, CodexProviderCapabilitiesV2],
1327 [CLAUDE_MODEL_SELECTION, CLAUDE_DRIVER, ClaudeProviderCapabilitiesV2],
1328 ] as const
1329 ).map(([modelSelection, driver, capabilities]) =>
1330 makeTestAdapter({
1331 instanceId: modelSelection.instanceId,
1332 driver,
1333 capabilities,
1334 modelSelection,
1335 responseByRunOrdinal: { [sourceOrdinal]: partialResponse },
1336 capturedTurns,
1337 ...(native && modelSelection.instanceId === targetSelection.instanceId
1338 ? { injectedHistory }
1339 : {}),
1340 ...(modelSelection.instanceId === sourceSelection.instanceId
1341 ? {
1342 failedRunOrdinals: new Set(status === "failed" ? [sourceOrdinal] : []),
1343 interruptedRunOrdinals: new Set(
1344 status === "interrupted" ? [sourceOrdinal] : [],
1345 ),
1346 holdRunOrdinal: sourceOrdinal,
1347 holdFirstTurn: started,
1348 releaseFirstTurn: release,
1349 }
1350 : {}),
1351 }),
1352 ),
1353 );
1354 yield* Effect.gen(function* () {
1355 const orchestrator = yield* Orchestrator.OrchestratorV2;
1356 const worker = yield* EffectWorker.OrchestrationEffectWorkerV2;
1357 const waitForRun = (
1358 ordinal: number,
1359 expectedStatus: "completed" | "failed" | "interrupted",
1360 ) =>
1361 orchestrator.streamStoredEvents.pipe(
1362 Stream.filter(
1363 ({ event }) =>
1364 event.type === "run.updated" &&
1365 event.threadId === threadId &&
1366 event.payload.ordinal === ordinal &&
1367 event.payload.status === expectedStatus,
1368 ),
1369 Stream.runHead,
1370 Effect.andThen(worker.drain()),
1371 );
1372 const dispatch = (
1373 key: string,
1374 text: string,
1375 modelSelection: ModelSelection,
1376 queue = false,
1377 ) =>
1378 orchestrator.dispatch({
1379 type: "message.dispatch",
1380 commandId: CommandId.make(`command:handoff:${key}`),
1381 threadId,
1382 messageId: MessageId.make(`message:handoff:${key}`),
1383 createdBy: "user",
1384 creationSource: "web",
1385 text,
1386 attachments: [],
1387 modelSelection,
1388 dispatchMode: { type: queue ? "queue_after_active" : "start_immediately" },
1389 });
1390 yield* orchestrator.dispatch({
1391 type: "thread.create",
1392 commandId: CommandId.make("command:handoff:create"),
1393 threadId,
1394 projectId,
1395 createdBy: "user",
1396 creationSource: "web",
1397 title: "Interrupted provider handoff",
1398 modelSelection: CODEX_MODEL_SELECTION,
1399 runtimeMode: "full-access",
1400 interactionMode: "default",
1401 branch: null,
1402 worktreePath: null,
1403 });
1404 if (returning) {
1405 yield* dispatch("initial", "Earlier successful request.", CODEX_MODEL_SELECTION);
1406 yield* waitForRun(1, "completed");
1407 }
1408 yield* dispatch("source", originalPrompt, sourceSelection);
1409 yield* Deferred.await(started);
1410 if (queued) {
1411 yield* dispatch("target", "Continue", targetSelection, true);
1412 assert.equal(
1413 (yield* orchestrator.getThreadProjection(threadId)).runs.at(-1)?.status,
1414 "queued",
1415 );
1416 }
1417 yield* Deferred.succeed(release, undefined);
1418 yield* waitForRun(sourceOrdinal, status);
1419 if (!queued) {
1420 yield* dispatch("target", "Continue", targetSelection);
1421 }
1422 yield* waitForRun(sourceOrdinal + 1, "completed");
1423 const projection = yield* orchestrator.getThreadProjection(threadId);
1424 const targetRun = projection.runs.at(-1)!;
1425 const handoff = projection.contextHandoffs.find(
1426 (candidate) => candidate.id === targetRun.contextHandoffId,
1427 );
1428 assert.isDefined(handoff);
1429 assert.equal(
1430 handoff?.strategy,
1431 returning ? "delta_since_target_last_seen" : "full_thread_summary",
1432 );
1433 assert.deepEqual(handoff?.coveredRunOrdinals, {
1434 from: sourceOrdinal,
1435 to: sourceOrdinal,
1436 });
1437 const delivered = (yield* Ref.get(capturedTurns)).at(-1)!;
1438 const history = yield* Ref.get(injectedHistory);
1439 const deliveredHistory = native ? yield* encodeJson(history) : delivered.text;
1440 assert.include(deliveredHistory, originalPrompt);
1441 assert.include(deliveredHistory, partialResponse);
1442 assert.include(deliveredHistory, `run-status=${status}`);
1443 if (native) {
1444 assert.equal(delivered.text, "Continue");
1445 assert.notInclude(deliveredHistory, '"text":"Continue"');
1446 assert.include(deliveredHistory, '"role":"assistant"');
1447 assert.include(deliveredHistory, '"role":"user"');
1448 assert.equal(handoff?.delivery?.status, "injected");
1449 } else {
1450 assert.include(delivered.text, "User message:\nContinue");
1451 assert.equal(handoff?.delivery?.status, "inline");
1452 }
1453 assert.notInclude(deliveredHistory, "Earlier successful request.");
1454 // The returning source already has its own failed/interrupted native turn.
1455 yield* dispatch("back", "Finish the remaining work", sourceSelection);
1456 yield* waitForRun(sourceOrdinal + 2, "completed");
1457 const back = (yield* Ref.get(capturedTurns)).at(-1)!;
1458 assert.notInclude(back.text, originalPrompt);
1459 assert.notInclude(back.text, partialResponse);
1460 }).pipe(
1461 Effect.provide(
1462 ProviderReplayHarness.layerWithRegistry(
1463 {
1464 name: `handoff-${status}-${queued}-${returning}`,
1465 runtimePolicyOverride: {
1466 cwd,
1467 approvalPolicy: "never",
1468 sandboxPolicy: {
1469 type: "readOnly",
1470 access: { type: "fullAccess" },
1471 networkAccess: false,
1472 },
1473 },
1474 },
1475 layerRegistry,
1476 ),
1477 ),
1478 );
1479 }),
1480 ),
1481 );
1482
1483 it.live("checks the queued provider's capability while the current provider stays running", () =>
1484 Effect.scoped(
1485 Effect.gen(function* () {
1486 for (const scenario of [
1487 { activeSupportsQueue: false, selectedSupportsQueue: true },
1488 { activeSupportsQueue: true, selectedSupportsQueue: false },
1489 ]) {
1490 const key = `active-${scenario.activeSupportsQueue}-selected-${scenario.selectedSupportsQueue}`;
1491 const cwd = yield* checkpointWorkspace(`queued-capability-${key}`);
1492 const capturedTurns = yield* Ref.make<ReadonlyArray<CapturedTurn>>([]);
1493 const started = yield* Deferred.make<void>();
1494 const scenarioThreadId = ThreadId.make(`thread:queued-capability:${key}`);
1495 const layerRegistry = ProviderAdapterRegistry.layerFromAdapters([
1496 makeTestAdapter({
1497 instanceId: CODEX_MODEL_SELECTION.instanceId,
1498 driver: CODEX_DRIVER,
1499 capabilities: {
1500 ...CodexProviderCapabilitiesV2,
1501 turns: {
1502 ...CodexProviderCapabilitiesV2.turns,
1503 supportsQueuedMessages: scenario.activeSupportsQueue,
1504 },
1505 },
1506 modelSelection: CODEX_MODEL_SELECTION,
1507 responseByRunOrdinal: {},
1508 capturedTurns,
1509 holdFirstTurn: started,
1510 }),
1511 makeTestAdapter({
1512 instanceId: CLAUDE_MODEL_SELECTION.instanceId,
1513 driver: CLAUDE_DRIVER,
1514 capabilities: {
1515 ...ClaudeProviderCapabilitiesV2,
1516 turns: {
1517 ...ClaudeProviderCapabilitiesV2.turns,
1518 supportsQueuedMessages: scenario.selectedSupportsQueue,
1519 },
1520 },
1521 modelSelection: CLAUDE_MODEL_SELECTION,
1522 responseByRunOrdinal: {},
1523 capturedTurns,
1524 }),
1525 ]);
1526 yield* Effect.gen(function* () {
1527 const orchestrator = yield* Orchestrator.OrchestratorV2;
1528 const worker = yield* EffectWorker.OrchestrationEffectWorkerV2;
1529 yield* orchestrator.dispatch({
1530 type: "thread.create",
1531 createdBy: "user",
1532 creationSource: "web",
1533 commandId: CommandId.make(`command:queued-capability:create:${key}`),
1534 threadId: scenarioThreadId,
1535 projectId: ProjectId.make(`project:queued-capability:${key}`),
1536 title: "Queued capability",
1537 modelSelection: CODEX_MODEL_SELECTION,
1538 runtimeMode: "full-access",
1539 interactionMode: "default",
1540 branch: null,
1541 worktreePath: cwd,
1542 });
1543 yield* orchestrator.dispatch({
1544 type: "message.dispatch",
1545 createdBy: "user",
1546 creationSource: "web",
1547 commandId: CommandId.make(`command:queued-capability:first:${key}`),
1548 threadId: scenarioThreadId,
1549 messageId: MessageId.make(`message:queued-capability:first:${key}`),
1550 text: "Current Codex turn",
1551 attachments: [],
1552 modelSelection: CODEX_MODEL_SELECTION,
1553 dispatchMode: { type: "start_immediately" },
1554 });
1555 yield* Deferred.await(started);
1556 yield* worker.drain();
1557 const queue = orchestrator.dispatch({
1558 type: "message.dispatch",
1559 createdBy: "user",
1560 creationSource: "web",
1561 commandId: CommandId.make(`command:queued-capability:claude:${key}`),
1562 threadId: scenarioThreadId,
1563 messageId: MessageId.make(`message:queued-capability:claude:${key}`),
1564 text: "Queued Claude turn",
1565 attachments: [],
1566 modelSelection: CLAUDE_MODEL_SELECTION,
1567 dispatchMode: { type: "queue_after_active" },
1568 });
1569 if (scenario.selectedSupportsQueue) {
1570 yield* queue;
1571 } else {
1572 const error = yield* queue.pipe(Effect.flip);
1573 assert.instanceOf(error, Orchestrator.OrchestratorDispatchError);
1574 assert.instanceOf(error.cause, CommandPolicyCapabilityUnsupportedError);
1575 assert.equal(error.cause.capability, "queued_messages");
1576 }
1577 const projection = yield* orchestrator.getThreadProjection(scenarioThreadId);
1578 assert.deepEqual(
1579 projection.runs.map((run) => run.status),
1580 scenario.selectedSupportsQueue ? ["running", "queued"] : ["running"],
1581 );
1582 assert.deepEqual(projection.thread.modelSelection, CODEX_MODEL_SELECTION);
1583 }).pipe(
1584 Effect.provide(
1585 ProviderReplayHarness.layerWithRegistry(
1586 {
1587 name: `queued-capability-${key}`,
1588 runtimePolicyOverride: {
1589 cwd,
1590 approvalPolicy: "never",
1591 sandboxPolicy: {
1592 type: "readOnly",
1593 access: { type: "fullAccess" },
1594 networkAccess: false,
1595 },
1596 },
1597 },
1598 layerRegistry,
1599 ),
1600 ),
1601 );
1602 }
1603 }),
1604 ),
1605 );
1606
1607 it.live("hands completed Grok steering context to earlier queued Codex and later Claude", () =>
1608 Effect.scoped(
1609 Effect.gen(function* () {
1610 const cwd = yield* checkpointWorkspace("queued-steer-provider-switch");
1611 const capturedTurns = yield* Ref.make<ReadonlyArray<CapturedTurn>>([]);
1612 const started = yield* Deferred.make<void>();
1613 const layerRegistry = ProviderAdapterRegistry.layerFromAdapters([
1614 makeTestAdapter({
1615 instanceId: CODEX_MODEL_SELECTION.instanceId,
1616 driver: CODEX_DRIVER,
1617 capabilities: CodexProviderCapabilitiesV2,
1618 modelSelection: CODEX_MODEL_SELECTION,
1619 responseByRunOrdinal: { 2: "Codex queued response" },
1620 capturedTurns,
1621 holdFirstTurn: started,
1622 }),
1623 makeTestAdapter({
1624 instanceId: CLAUDE_MODEL_SELECTION.instanceId,
1625 driver: CLAUDE_DRIVER,
1626 capabilities: ClaudeProviderCapabilitiesV2,
1627 modelSelection: CLAUDE_MODEL_SELECTION,
1628 responseByRunOrdinal: { 3: "Claude queued response" },
1629 capturedTurns,
1630 }),
1631 makeTestAdapter({
1632 instanceId: GROK_MODEL_SELECTION.instanceId,
1633 driver: GROK_DRIVER,
1634 capabilities: AcpProviderCapabilitiesV2,
1635 modelSelection: GROK_MODEL_SELECTION,
1636 responseByRunOrdinal: { 1: "Grok steered response" },
1637 capturedTurns,
1638 }),
1639 ]);
1640 const queuedThreadId = ThreadId.make("thread:queued-steer-provider-switch");
1641 const projection = yield* Effect.gen(function* () {
1642 const orchestrator = yield* Orchestrator.OrchestratorV2;
1643 const worker = yield* EffectWorker.OrchestrationEffectWorkerV2;
1644 const eventSink = yield* EventSink.EventSinkV2;
1645 const dispatch = (
1646 key: string,
1647 modelSelection: ModelSelection,
1648 dispatchMode: Extract<
1649 OrchestrationV2Command,
1650 { readonly type: "message.dispatch" }
1651 >["dispatchMode"],
1652 ) =>
1653 orchestrator.dispatch({
1654 type: "message.dispatch",
1655 createdBy: "user",
1656 creationSource: "web",
1657 commandId: CommandId.make(`command:queued-steer-provider-switch:${key}`),
1658 threadId: queuedThreadId,
1659 messageId: MessageId.make(`message:queued-steer-provider-switch:${key}`),
1660 text: `Prompt ${key}`,
1661 attachments: [],
1662 modelSelection,
1663 dispatchMode,
1664 });
1665 yield* orchestrator.dispatch({
1666 type: "thread.create",
1667 createdBy: "user",
1668 creationSource: "web",
1669 commandId: CommandId.make("command:queued-steer-provider-switch:create"),
1670 threadId: queuedThreadId,
1671 projectId: ProjectId.make("project:queued-steer-provider-switch"),
1672 title: "Queued steer provider switch",
1673 modelSelection: CODEX_MODEL_SELECTION,
1674 runtimeMode: "full-access",
1675 interactionMode: "default",
1676 branch: null,
1677 worktreePath: cwd,
1678 });
1679 yield* dispatch("first", CODEX_MODEL_SELECTION, { type: "start_immediately" });
1680 yield* Deferred.await(started);
1681 yield* worker.drain();
1682 yield* dispatch("codex-queued", CODEX_MODEL_SELECTION, { type: "queue_after_active" });
1683 yield* dispatch("claude-queued", CLAUDE_MODEL_SELECTION, { type: "queue_after_active" });
1684 const beforeSteer = yield* orchestrator.getThreadProjection(queuedThreadId);
1685 assert.deepEqual(
1686 beforeSteer.runs.map((run) => run.status),
1687 ["running", "queued", "queued"],
1688 );
1689 assert.equal(beforeSteer.thread.providerInstanceId, CODEX_MODEL_SELECTION.instanceId);
1690 assert.lengthOf(beforeSteer.contextHandoffs, 0);
1691 const now = yield* DateTime.now;
1692 yield* eventSink.write({
1693 events: [
1694 {
1695 id: EventId.make("event:queued-steer-provider-switch:first-turn-running"),
1696 type: "provider-turn.updated",
1697 threadId: queuedThreadId,
1698 runId: beforeSteer.runs[0]!.id,
1699 nodeId: beforeSteer.runs[0]!.rootNodeId!,
1700 driver: CODEX_DRIVER,
1701 providerInstanceId: CODEX_MODEL_SELECTION.instanceId,
1702 occurredAt: now,
1703 payload: {
1704 id: ProviderTurnId.make("provider-turn:queued-steer-provider-switch:first"),
1705 providerThreadId: beforeSteer.runs[0]!.providerThreadId!,
1706 nodeId: beforeSteer.runs[0]!.rootNodeId!,
1707 runAttemptId: beforeSteer.runs[0]!.activeAttemptId!,
1708 nativeTurnRef: null,
1709 ordinal: 1,
1710 status: "running",
1711 startedAt: now,
1712 completedAt: null,
1713 },
1714 },
1715 ],
1716 });
1717 const queuedClaudeCompleted = yield* orchestrator.streamStoredEvents.pipe(
1718 Stream.filter(
1719 (event) =>
1720 event.event.type === "run.updated" &&
1721 event.event.runId === beforeSteer.runs[2]?.id &&
1722 event.event.payload.status === "completed",
1723 ),
1724 Stream.runHead,
1725 Effect.forkScoped,
1726 );
1727 yield* dispatch("grok-steer", GROK_MODEL_SELECTION, {
1728 type: "steer_active",
1729 targetRunId: beforeSteer.runs[0]!.id,
1730 });
1731 const afterSteer = yield* orchestrator.getThreadProjection(queuedThreadId);
1732 assert.deepEqual(afterSteer.thread.modelSelection, GROK_MODEL_SELECTION);
1733 const interruptedTurn = afterSteer.providerTurns.find(
1734 (turn) => turn.runAttemptId === beforeSteer.runs[0]?.activeAttemptId,
1735 )!;
1736 const interruptedAttempt = afterSteer.attempts.find(
1737 (attempt) => attempt.id === beforeSteer.runs[0]?.activeAttemptId,
1738 )!;
1739 const interruptedAt = yield* DateTime.now;
1740 yield* eventSink.write({
1741 events: [
1742 {
1743 id: EventId.make("event:queued-steer-provider-switch:first-turn-interrupted"),
1744 type: "provider-turn.updated",
1745 threadId: queuedThreadId,
1746 runId: beforeSteer.runs[0]!.id,
1747 nodeId: beforeSteer.runs[0]!.rootNodeId!,
1748 driver: CODEX_DRIVER,
1749 providerInstanceId: CODEX_MODEL_SELECTION.instanceId,
1750 occurredAt: interruptedAt,
1751 payload: {
1752 ...interruptedTurn,
1753 status: "interrupted",
1754 completedAt: interruptedAt,
1755 },
1756 },
1757 {
1758 id: EventId.make("event:queued-steer-provider-switch:first-attempt-interrupted"),
1759 type: "run-attempt.updated",
1760 threadId: queuedThreadId,
1761 runId: beforeSteer.runs[0]!.id,
1762 nodeId: beforeSteer.runs[0]!.rootNodeId!,
1763 providerInstanceId: CODEX_MODEL_SELECTION.instanceId,
1764 occurredAt: interruptedAt,
1765 payload: {
1766 ...interruptedAttempt,
1767 status: "interrupted",
1768 completedAt: interruptedAt,
1769 },
1770 },
1771 ],
1772 });
1773 yield* worker.drain();
1774 yield* Fiber.join(queuedClaudeCompleted);
1775 return yield* orchestrator.getThreadProjection(queuedThreadId);
1776 }).pipe(
1777 Effect.provide(
1778 ProviderReplayHarness.layerWithRegistry(
1779 {
1780 name: "queued-steer-provider-switch",
1781 runtimePolicyOverride: {
1782 cwd,
1783 approvalPolicy: "never",
1784 sandboxPolicy: {
1785 type: "readOnly",
1786 access: { type: "fullAccess" },
1787 networkAccess: false,
1788 },
1789 },
1790 },
1791 layerRegistry,
1792 ),
1793 ),
1794 );
1795 assert.deepEqual(
1796 projection.runs.map((run) => [run.providerInstanceId, run.status]),
1797 [
1798 [GROK_MODEL_SELECTION.instanceId, "completed"],
1799 [CODEX_MODEL_SELECTION.instanceId, "completed"],
1800 [CLAUDE_MODEL_SELECTION.instanceId, "completed"],
1801 ],
1802 );
1803 const turns = yield* Ref.get(capturedTurns);
1804 assert.deepEqual(
1805 turns.map((turn) => turn.driver),
1806 [CODEX_DRIVER, GROK_DRIVER, CODEX_DRIVER, CLAUDE_DRIVER],
1807 );
1808 assert.include(turns[2]?.text ?? "", "Grok steered response");
1809 assert.include(turns[3]?.text ?? "", "Grok steered response");
1810 assert.include(turns[3]?.text ?? "", "Codex queued response");
1811 assert.deepEqual(
1812 projection.contextHandoffs.map((handoff) => handoff.targetRunId),
1813 [projection.runs[0]?.id, projection.runs[1]?.id, projection.runs[2]?.id],
1814 );
1815 }),
1816 ),
1817 );
1818
1819 it.live("resumes a queued account switch without requiring a portable handoff", () =>
1820 Effect.scoped(
1821 Effect.gen(function* () {
1822 const cwd = yield* checkpointWorkspace("queued-account-switch");
1823 const capturedTurns = yield* Ref.make<ReadonlyArray<CapturedTurn>>([]);
1824 const started = yield* Deferred.make<void>();
1825 const alternateSelection: ModelSelection = {
1826 ...CODEX_MODEL_SELECTION,
1827 instanceId: ProviderInstanceId.make("codex-alternate"),
1828 };
1829 const alternateCapabilities = {
1830 ...CodexProviderCapabilitiesV2,
1831 canConsumeHandoffSummaries: false,
1832 };
1833 const adapters = [
1834 makeTestAdapter({
1835 instanceId: CODEX_MODEL_SELECTION.instanceId,
1836 driver: CODEX_DRIVER,
1837 capabilities: CodexProviderCapabilitiesV2,
1838 modelSelection: CODEX_MODEL_SELECTION,
1839 responseByRunOrdinal: {},
1840 capturedTurns,
1841 holdFirstTurn: started,
1842 }),
1843 makeTestAdapter({
1844 instanceId: alternateSelection.instanceId,
1845 driver: CODEX_DRIVER,
1846 capabilities: alternateCapabilities,
1847 modelSelection: alternateSelection,
1848 responseByRunOrdinal: { 2: "Alternate account complete" },
1849 capturedTurns,
1850 }),
1851 ];
1852 const layerRegistry = Layer.succeed(
1853 ProviderAdapterRegistry.ProviderAdapterRegistryV2,
1854 ProviderAdapterRegistry.ProviderAdapterRegistryV2.of({
1855 get: (instanceId) => {
1856 const adapter = adapters.find((candidate) => candidate.instanceId === instanceId);
1857 return adapter === undefined
1858 ? Effect.fail(
1859 new ProviderAdapterRegistry.ProviderAdapterRegistryLookupError({ instanceId }),
1860 )
1861 : Effect.succeed(adapter);
1862 },
1863 list: () => Effect.succeed(adapters.map((adapter) => adapter.instanceId)),
1864 getMetadata: (instanceId) => {
1865 const adapter = adapters.find((candidate) => candidate.instanceId === instanceId);
1866 return adapter === undefined
1867 ? Effect.fail(
1868 new ProviderAdapterRegistry.ProviderAdapterRegistryLookupError({ instanceId }),
1869 )
1870 : Effect.succeed({
1871 driver: CODEX_DRIVER,
1872 continuationKey: "codex:shared-native-account-history",
1873 enabled: true,
1874 capabilities:
1875 instanceId === alternateSelection.instanceId
1876 ? alternateCapabilities
1877 : CodexProviderCapabilitiesV2,
1878 });
1879 },
1880 }),
1881 );
1882 const queuedThreadId = ThreadId.make("thread:queued-account-switch");
1883 const projection = yield* Effect.gen(function* () {
1884 const orchestrator = yield* Orchestrator.OrchestratorV2;
1885 const eventSink = yield* EventSink.EventSinkV2;
1886 const worker = yield* EffectWorker.OrchestrationEffectWorkerV2;
1887 yield* orchestrator.dispatch({
1888 type: "thread.create",
1889 createdBy: "user",
1890 creationSource: "web",
1891 commandId: CommandId.make("command:queued-account-switch:create"),
1892 threadId: queuedThreadId,
1893 projectId: ProjectId.make("project:queued-account-switch"),
1894 title: "Queued account switch",
1895 modelSelection: CODEX_MODEL_SELECTION,
1896 runtimeMode: "full-access",
1897 interactionMode: "default",
1898 branch: null,
1899 worktreePath: cwd,
1900 });
1901 yield* orchestrator.dispatch({
1902 type: "message.dispatch",
1903 createdBy: "user",
1904 creationSource: "web",
1905 commandId: CommandId.make("command:queued-account-switch:first"),
1906 threadId: queuedThreadId,
1907 messageId: MessageId.make("message:queued-account-switch:first"),
1908 text: "First account turn",
1909 attachments: [],
1910 modelSelection: CODEX_MODEL_SELECTION,
1911 dispatchMode: { type: "start_immediately" },
1912 });
1913 yield* Deferred.await(started);
1914 yield* orchestrator.dispatch({
1915 type: "message.dispatch",
1916 createdBy: "user",
1917 creationSource: "web",
1918 commandId: CommandId.make("command:queued-account-switch:second"),
1919 threadId: queuedThreadId,
1920 messageId: MessageId.make("message:queued-account-switch:second"),
1921 text: "Alternate account turn",
1922 attachments: [],
1923 modelSelection: alternateSelection,
1924 dispatchMode: { type: "queue_after_active" },
1925 });
1926 const queued = yield* orchestrator.getThreadProjection(queuedThreadId);
1927 assert.deepEqual(
1928 queued.runs.map((run) => run.status),
1929 ["running", "queued"],
1930 );
1931 assert.equal(queued.thread.providerInstanceId, CODEX_MODEL_SELECTION.instanceId);
1932 const sourceNativeRef = queued.providerThreads.find(
1933 (providerThread) => providerThread.id === queued.runs[0]?.providerThreadId,
1934 )?.nativeThreadRef;
1935 assert.isNotNull(sourceNativeRef);
1936 const now = yield* DateTime.now;
1937 yield* eventSink.write({
1938 events: [
1939 {
1940 id: EventId.make("event:queued-account-switch:first-complete"),
1941 type: "run.updated",
1942 threadId: queuedThreadId,
1943 runId: queued.runs[0]!.id,
1944 providerInstanceId: CODEX_MODEL_SELECTION.instanceId,
1945 occurredAt: now,
1946 payload: { ...queued.runs[0]!, status: "completed", completedAt: now },
1947 },
1948 ],
1949 });
1950 yield* orchestrator.resumeQueuedRuns;
1951 yield* orchestrator.streamStoredEvents.pipe(
1952 Stream.filter(
1953 (event) =>
1954 event.event.type === "run.updated" &&
1955 event.event.runId === queued.runs[1]?.id &&
1956 event.event.payload.status === "completed",
1957 ),
1958 Stream.runHead,
1959 );
1960 yield* worker.drain();
1961 const delivered = yield* orchestrator.getThreadProjection(queuedThreadId);
1962 const targetNativeRef = delivered.providerThreads.find(
1963 (providerThread) => providerThread.id === delivered.runs[1]?.providerThreadId,
1964 )?.nativeThreadRef;
1965 assert.deepEqual(targetNativeRef, sourceNativeRef);
1966 return delivered;
1967 }).pipe(
1968 Effect.provide(
1969 ProviderReplayHarness.layerWithRegistry(
1970 {
1971 name: "queued-account-switch",
1972 runtimePolicyOverride: {
1973 cwd,
1974 approvalPolicy: "never",
1975 sandboxPolicy: {
1976 type: "readOnly",
1977 access: { type: "fullAccess" },
1978 networkAccess: false,
1979 },
1980 },
1981 },
1982 layerRegistry,
1983 ),
1984 ),
1985 );
1986 assert.deepEqual(
1987 projection.runs.map((run) => [run.providerInstanceId, run.status]),
1988 [
1989 [CODEX_MODEL_SELECTION.instanceId, "completed"],
1990 [alternateSelection.instanceId, "completed"],
1991 ],
1992 );
1993 assert.lengthOf(projection.contextHandoffs, 0);
1994 assert.deepEqual(
1995 (yield* Ref.get(capturedTurns)).map((turn) => turn.text),
1996 ["First account turn", "Alternate account turn"],
1997 );
1998 }),
1999 ),
2000 );
2001
2002 it.live("finishes earlier queued Codex turns before handing context to queued Claude", () =>
2003 Effect.scoped(
2004 Effect.gen(function* () {
2005 const cwd = yield* checkpointWorkspace("queued-provider-switch");
2006 const capturedTurns = yield* Ref.make<ReadonlyArray<CapturedTurn>>([]);
2007 const started = yield* Deferred.make<void>();
2008 const layerRegistry = ProviderAdapterRegistry.layerFromAdapters([
2009 makeTestAdapter({
2010 instanceId: ProviderInstanceId.make("codex"),
2011 driver: CODEX_DRIVER,
2012 capabilities: CodexProviderCapabilitiesV2,
2013 modelSelection: CODEX_MODEL_SELECTION,
2014 responseByRunOrdinal: {
2015 1: "Codex current turn complete",
2016 2: "Codex first queued turn complete",
2017 3: "Codex second queued turn complete",
2018 },
2019 capturedTurns,
2020 holdFirstTurn: started,
2021 }),
2022 makeTestAdapter({
2023 instanceId: ProviderInstanceId.make("claudeAgent"),
2024 driver: CLAUDE_DRIVER,
2025 capabilities: ClaudeProviderCapabilitiesV2,
2026 modelSelection: CLAUDE_MODEL_SELECTION,
2027 responseByRunOrdinal: { 4: "Claude turn complete" },
2028 capturedTurns,
2029 }),
2030 ]);
2031 const queuedThreadId = ThreadId.make("thread:queued-provider-switch");
2032 const layerDatabase = SqlitePersistence.layerMemory;
2033 const layerOutboxProvided = EffectOutbox.layer.pipe(Layer.provide(layerDatabase));
2034 const projection = yield* Effect.gen(function* () {
2035 const orchestrator = yield* Orchestrator.OrchestratorV2;
2036 const worker = yield* EffectWorker.OrchestrationEffectWorkerV2;
2037 const eventSink = yield* EventSink.EventSinkV2;
2038 const effectOutbox = yield* EffectOutbox.EffectOutboxV2;
2039 const dispatch = (ordinal: number, modelSelection: ModelSelection) =>
2040 orchestrator.dispatch({
2041 type: "message.dispatch",
2042 createdBy: "user",
2043 creationSource: "web",
2044 commandId: CommandId.make(`command:queued-provider-switch:${ordinal}`),
2045 threadId: queuedThreadId,
2046 messageId: MessageId.make(`message:queued-provider-switch:${ordinal}`),
2047 text: `Prompt ${ordinal}`,
2048 attachments: [],
2049 modelSelection,
2050 dispatchMode: {
2051 type: ordinal === 1 ? "start_immediately" : "queue_after_active",
2052 },
2053 });
2054 yield* orchestrator.dispatch({
2055 type: "thread.create",
2056 createdBy: "user",
2057 creationSource: "web",
2058 commandId: CommandId.make("command:queued-provider-switch:create"),
2059 threadId: queuedThreadId,
2060 projectId: ProjectId.make("project:queued-provider-switch"),
2061 title: "Queued provider switch",
2062 modelSelection: CODEX_MODEL_SELECTION,
2063 runtimeMode: "full-access",
2064 interactionMode: "default",
2065 branch: null,
2066 worktreePath: cwd,
2067 });
2068 yield* dispatch(1, CODEX_MODEL_SELECTION);
2069 yield* Deferred.await(started);
2070 yield* dispatch(2, CODEX_MODEL_SELECTION);
2071 yield* dispatch(3, CODEX_MODEL_SELECTION);
2072 yield* dispatch(4, CLAUDE_MODEL_SELECTION);
2073 const queued = yield* orchestrator.getThreadProjection(queuedThreadId);
2074 assert.deepEqual(
2075 queued.runs.map((run) => run.status),
2076 ["running", "queued", "queued", "queued"],
2077 );
2078 assert.equal(queued.thread.activeProviderThreadId, queued.runs[0]?.providerThreadId);
2079 assert.equal(queued.thread.providerInstanceId, CODEX_MODEL_SELECTION.instanceId);
2080 assert.lengthOf(queued.contextHandoffs, 0);
2081 const activeSession = queued.providerSessions.find(
2082 (session) => session.providerInstanceId === CODEX_MODEL_SELECTION.instanceId,
2083 );
2084 assert.isDefined(activeSession);
2085 const now = yield* DateTime.now;
2086 yield* eventSink.write({
2087 events: [
2088 ...(["stopped", "error"] as const).map((status) => ({
2089 id: EventId.make(`event:queued-provider-switch:dead-session:${status}`),
2090 type: "provider-session.updated" as const,
2091 threadId: queuedThreadId,
2092 providerInstanceId: CODEX_MODEL_SELECTION.instanceId,
2093 occurredAt: now,
2094 payload: {
2095 ...activeSession!,
2096 id: ProviderSessionId.make(`provider-session:queued-provider-switch:${status}`),
2097 status,
2098 },
2099 })),
2100 {
2101 id: EventId.make("event:queued-provider-switch:first-response"),
2102 type: "turn-item.updated",
2103 threadId: queuedThreadId,
2104 runId: queued.runs[0]!.id,
2105 nodeId: queued.runs[0]!.rootNodeId!,
2106 providerInstanceId: CODEX_MODEL_SELECTION.instanceId,
2107 occurredAt: now,
2108 payload: {
2109 id: TurnItemId.make("turn-item:queued-provider-switch:first-response"),
2110 threadId: queuedThreadId,
2111 runId: queued.runs[0]!.id,
2112 nodeId: queued.runs[0]!.rootNodeId!,
2113 providerThreadId: queued.runs[0]!.providerThreadId,
2114 providerTurnId: null,
2115 nativeItemRef: null,
2116 parentItemId: null,
2117 ordinal: 101,
2118 status: "completed",
2119 title: null,
2120 startedAt: now,
2121 completedAt: now,
2122 updatedAt: now,
2123 type: "assistant_message",
2124 messageId: MessageId.make("message:queued-provider-switch:first-response"),
2125 text: "Codex current turn complete",
2126 streaming: false,
2127 },
2128 },
2129 {
2130 id: EventId.make("event:queued-provider-switch:first-complete"),
2131 type: "run.updated",
2132 threadId: queuedThreadId,
2133 runId: queued.runs[0]!.id,
2134 providerInstanceId: CODEX_MODEL_SELECTION.instanceId,
2135 occurredAt: now,
2136 payload: { ...queued.runs[0]!, status: "completed", completedAt: now },
2137 },
2138 ],
2139 });
2140 yield* orchestrator.resumeQueuedRuns;
2141 yield* orchestrator.streamStoredEvents.pipe(
2142 Stream.filter(
2143 (event) =>
2144 event.event.type === "run.updated" &&
2145 event.event.runId === queued.runs[3]?.id &&
2146 event.event.payload.status === "completed",
2147 ),
2148 Stream.runHead,
2149 );
2150 yield* worker.drain();
2151 const detachEvents = yield* eventSink.stream({ threadId: queuedThreadId }).pipe(
2152 Stream.filter((stored) => stored.event.type === "provider-session.detached"),
2153 Stream.take(1),
2154 Stream.runCollect,
2155 );
2156 assert.equal(
2157 detachEvents[0]?.event.type === "provider-session.detached"
2158 ? detachEvents[0].event.payload.providerSessionId
2159 : null,
2160 activeSession?.id,
2161 );
2162 const startCommandId = CommandId.make(
2163 `command:system:start-queued:${queued.runs[3]!.id}`,
2164 );
2165 const detachEffects = (yield* effectOutbox.listByCommandId(startCommandId)).filter(
2166 (effect) => effect.request.type === "provider-session.detach",
2167 );
2168 assert.deepEqual(
2169 detachEffects.map((effect) =>
2170 effect.request.type === "provider-session.detach"
2171 ? effect.request.providerSessionId
2172 : null,
2173 ),
2174 [activeSession?.id],
2175 );
2176 return yield* orchestrator.getThreadProjection(queuedThreadId);
2177 }).pipe(
2178 Effect.provide(
2179 Layer.merge(
2180 ProviderReplayHarness.layerWithRegistry(
2181 {
2182 name: "queued-provider-switch",
2183 runtimePolicyOverride: {
2184 cwd,
2185 approvalPolicy: "never",
2186 sandboxPolicy: {
2187 type: "readOnly",
2188 access: { type: "fullAccess" },
2189 networkAccess: false,
2190 },
2191 },
2192 },
2193 layerRegistry,
2194 { databaseLayer: layerDatabase },
2195 ),
2196 layerOutboxProvided,
2197 ),
2198 ),
2199 );
2200 const turns = yield* Ref.get(capturedTurns);
2201 assert.deepEqual(
2202 projection.runs.map((run) => [run.providerInstanceId, run.status]),
2203 [
2204 ["codex", "completed"],
2205 ["codex", "completed"],
2206 ["codex", "completed"],
2207 ["claudeAgent", "completed"],
2208 ],
2209 );
2210 assert.deepEqual(
2211 turns.map((turn) => [turn.driver, turn.text.includes("Prompt 4")]),
2212 [
2213 ["codex", false],
2214 ["codex", false],
2215 ["codex", false],
2216 ["claudeAgent", true],
2217 ],
2218 );
2219 assert.lengthOf(projection.contextHandoffs, 1);
2220 assert.equal(projection.contextHandoffs[0]?.targetRunId, projection.runs[3]?.id);
2221 const handoffItem = projection.turnItems.find(
2222 (item) => item.type === "handoff" && item.runId === projection.runs[3]?.id,
2223 );
2224 assert.equal(
2225 handoffItem?.type === "handoff" ? handoffItem.contextHandoffId : null,
2226 projection.contextHandoffs[0]?.id,
2227 );
2228 const queuedUserItem = projection.turnItems.find(
2229 (item) => item.type === "user_message" && item.runId === projection.runs[3]?.id,
2230 );
2231 assert.isBelow(handoffItem?.ordinal ?? Infinity, queuedUserItem?.ordinal ?? -Infinity);
2232 assert.include(
2233 handoffItem?.type === "handoff" ? handoffItem.summary : "",
2234 "Codex second queued turn complete",
2235 );
2236 assert.include(turns[3]?.text ?? "", "Codex current turn complete");
2237 assert.include(turns[3]?.text ?? "", "Codex first queued turn complete");
2238 assert.include(turns[3]?.text ?? "", "Codex second queued turn complete");
2239 }),
2240 ),
2241 );
2242
2243 it.live("fails an unsupported queued handoff and advances to the next queued provider", () =>
2244 Effect.scoped(
2245 Effect.gen(function* () {
2246 const cwd = yield* checkpointWorkspace("queued-handoff-rejection");
2247 const capturedTurns = yield* Ref.make<ReadonlyArray<CapturedTurn>>([]);
2248 const started = yield* Deferred.make<void>();
2249 const rejectedThreadId = ThreadId.make("thread:queued-handoff-rejection");
2250 const rejectedMessageId = MessageId.make("message:queued-handoff-rejection:claude");
2251 const unsupportedClaudeCapabilities = {
2252 ...ClaudeProviderCapabilitiesV2,
2253 context: {
2254 ...ClaudeProviderCapabilitiesV2.context,
2255 canConsumeHandoffSummaries: false,
2256 },
2257 };
2258 const layerRegistry = ProviderAdapterRegistry.layerFromAdapters([
2259 makeTestAdapter({
2260 instanceId: CODEX_MODEL_SELECTION.instanceId,
2261 driver: CODEX_DRIVER,
2262 capabilities: CodexProviderCapabilitiesV2,
2263 modelSelection: CODEX_MODEL_SELECTION,
2264 responseByRunOrdinal: { 3: "Later Codex queued turn complete" },
2265 capturedTurns,
2266 holdFirstTurn: started,
2267 }),
2268 makeTestAdapter({
2269 instanceId: CLAUDE_MODEL_SELECTION.instanceId,
2270 driver: CLAUDE_DRIVER,
2271 capabilities: unsupportedClaudeCapabilities,
2272 modelSelection: CLAUDE_MODEL_SELECTION,
2273 responseByRunOrdinal: {},
2274 capturedTurns,
2275 }),
2276 ]);
2277 const projection = yield* Effect.gen(function* () {
2278 const orchestrator = yield* Orchestrator.OrchestratorV2;
2279 const eventSink = yield* EventSink.EventSinkV2;
2280 const worker = yield* EffectWorker.OrchestrationEffectWorkerV2;
2281 yield* orchestrator.dispatch({
2282 type: "thread.create",
2283 createdBy: "user",
2284 creationSource: "web",
2285 commandId: CommandId.make("command:queued-handoff-rejection:create"),
2286 threadId: rejectedThreadId,
2287 projectId: ProjectId.make("project:queued-handoff-rejection"),
2288 title: "Queued handoff rejection",
2289 modelSelection: CODEX_MODEL_SELECTION,
2290 runtimeMode: "full-access",
2291 interactionMode: "default",
2292 branch: null,
2293 worktreePath: cwd,
2294 });
2295 yield* orchestrator.dispatch({
2296 type: "message.dispatch",
2297 createdBy: "user",
2298 creationSource: "web",
2299 commandId: CommandId.make("command:queued-handoff-rejection:first"),
2300 threadId: rejectedThreadId,
2301 messageId: MessageId.make("message:queued-handoff-rejection:first"),
2302 text: "First Codex turn",
2303 attachments: [],
2304 modelSelection: CODEX_MODEL_SELECTION,
2305 dispatchMode: { type: "start_immediately" },
2306 });
2307 yield* Deferred.await(started);
2308 const active = yield* orchestrator.getThreadProjection(rejectedThreadId);
2309 const now = yield* DateTime.now;
2310 yield* eventSink.write({
2311 events: [
2312 {
2313 id: EventId.make("event:queued-handoff-rejection:existing-item"),
2314 type: "turn-item.updated",
2315 threadId: rejectedThreadId,
2316 runId: active.runs[0]!.id,
2317 nodeId: active.runs[0]!.rootNodeId!,
2318 providerInstanceId: CODEX_MODEL_SELECTION.instanceId,
2319 occurredAt: now,
2320 payload: {
2321 id: TurnItemId.make("turn-item:queued-handoff-rejection:existing-item"),
2322 threadId: rejectedThreadId,
2323 runId: active.runs[0]!.id,
2324 nodeId: active.runs[0]!.rootNodeId!,
2325 providerThreadId: active.runs[0]!.providerThreadId,
2326 providerTurnId: null,
2327 nativeItemRef: null,
2328 parentItemId: null,
2329 ordinal: 150,
2330 status: "completed",
2331 title: null,
2332 inputIntent: "turn_start",
2333 startedAt: now,
2334 completedAt: now,
2335 updatedAt: now,
2336 type: "user_message",
2337 messageId: rejectedMessageId,
2338 text: "Unsupported Claude turn",
2339 attachments: [],
2340 createdBy: "user",
2341 creationSource: "web",
2342 },
2343 },
2344 ],
2345 });
2346 yield* orchestrator.dispatch({
2347 type: "message.dispatch",
2348 createdBy: "user",
2349 creationSource: "web",
2350 commandId: CommandId.make("command:queued-handoff-rejection:claude"),
2351 threadId: rejectedThreadId,
2352 messageId: rejectedMessageId,
2353 text: "Unsupported Claude turn",
2354 attachments: [],
2355 modelSelection: CLAUDE_MODEL_SELECTION,
2356 dispatchMode: { type: "queue_after_active" },
2357 });
2358 yield* orchestrator.dispatch({
2359 type: "message.dispatch",
2360 createdBy: "user",
2361 creationSource: "web",
2362 commandId: CommandId.make("command:queued-handoff-rejection:later"),
2363 threadId: rejectedThreadId,
2364 messageId: MessageId.make("message:queued-handoff-rejection:later"),
2365 text: "Later Codex turn",
2366 attachments: [],
2367 modelSelection: CODEX_MODEL_SELECTION,
2368 dispatchMode: { type: "queue_after_active" },
2369 });
2370 const queued = yield* orchestrator.getThreadProjection(rejectedThreadId);
2371 assert.deepEqual(
2372 queued.runs.map((run) => run.status),
2373 ["running", "queued", "queued"],
2374 );
2375 const queuedItem = queued.turnItems.find(
2376 (item) => item.type === "user_message" && item.messageId === rejectedMessageId,
2377 );
2378 assert.equal(queuedItem?.runId, queued.runs[1]?.id);
2379 assert.equal(queuedItem?.providerThreadId, queued.runs[1]?.providerThreadId);
2380 yield* eventSink.write({
2381 events: [
2382 {
2383 id: EventId.make("event:queued-handoff-rejection:first-complete"),
2384 type: "run.updated",
2385 threadId: rejectedThreadId,
2386 runId: queued.runs[0]!.id,
2387 providerInstanceId: CODEX_MODEL_SELECTION.instanceId,
2388 occurredAt: now,
2389 payload: { ...queued.runs[0]!, status: "completed", completedAt: now },
2390 },
2391 ],
2392 });
2393 yield* orchestrator.resumeQueuedRuns;
2394 yield* orchestrator.streamStoredEvents.pipe(
2395 Stream.filter(
2396 (event) =>
2397 event.event.type === "run.updated" &&
2398 event.event.runId === queued.runs[2]?.id &&
2399 event.event.payload.status === "completed",
2400 ),
2401 Stream.runHead,
2402 );
2403 yield* worker.drain();
2404 return yield* orchestrator.getThreadProjection(rejectedThreadId);
2405 }).pipe(
2406 Effect.provide(
2407 ProviderReplayHarness.layerWithRegistry(
2408 {
2409 name: "queued-handoff-rejection",
2410 runtimePolicyOverride: {
2411 cwd,
2412 approvalPolicy: "never",
2413 sandboxPolicy: {
2414 type: "readOnly",
2415 access: { type: "fullAccess" },
2416 networkAccess: false,
2417 },
2418 },
2419 },
2420 layerRegistry,
2421 ),
2422 ),
2423 );
2424 assert.deepEqual(
2425 projection.runs.map((run) => run.status),
2426 ["completed", "failed", "completed"],
2427 );
2428 assert.equal(projection.runs[1]?.queuePosition, null);
2429 assert.equal(
2430 projection.attempts.find((attempt) => attempt.runId === projection.runs[1]?.id)?.status,
2431 "failed",
2432 );
2433 assert.equal(
2434 projection.nodes.find((node) => node.runId === projection.runs[1]?.id)?.status,
2435 "failed",
2436 );
2437 assert.equal(projection.thread.providerInstanceId, CODEX_MODEL_SELECTION.instanceId);
2438 assert.lengthOf(projection.contextHandoffs, 1);
2439 assert.include((yield* Ref.get(capturedTurns)).at(-1)!.text, "Unsupported Claude turn");
2440 const failureItem = projection.turnItems.find(
2441 (item) => item.type === "error" && item.runId === projection.runs[1]?.id,
2442 );
2443 assert.equal(
2444 failureItem?.type === "error" ? failureItem.failure.code : null,
2445 "context_handoff_unsupported",
2446 );
2447 assert.deepEqual(
2448 (yield* Ref.get(capturedTurns)).map((turn) => turn.driver),
2449 [CODEX_DRIVER, CODEX_DRIVER],
2450 );
2451 }),
2452 ),
2453 );
2454
2455 const importedFailureScenario = (queueBeforeFailure: boolean) =>
2456 Effect.scoped(
2457 Effect.gen(function* () {
2458 const importedThreadId = ThreadId.make("thread:provider-switch:legacy-import");
2459 const importedProjectId = ProjectId.make("project:provider-switch:legacy-import");
2460 const failedPrompt = "This first provider attempt should fail.";
2461 const recoveryPrompt = "What was the imported release marker?";
2462 const cwd = yield* checkpointWorkspace("provider-switch-legacy-import");
2463 const capturedTurns = yield* Ref.make<ReadonlyArray<CapturedTurn>>([]);
2464 const firstTurnStarted = yield* Deferred.make<void>();
2465 const releaseFirstTurn = yield* Deferred.make<void>();
2466 const layerRegistry = ProviderAdapterRegistry.layerFromAdapters([
2467 makeTestAdapter({
2468 instanceId: ProviderInstanceId.make("codex"),
2469 driver: CODEX_DRIVER,
2470 capabilities: CodexProviderCapabilitiesV2,
2471 modelSelection: CODEX_MODEL_SELECTION,
2472 responseByRunOrdinal: {},
2473 capturedTurns,
2474 failedRunOrdinals: new Set([1]),
2475 ...(queueBeforeFailure ? { holdFirstTurn: firstTurnStarted, releaseFirstTurn } : {}),
2476 }),
2477 makeTestAdapter({
2478 instanceId: ProviderInstanceId.make("claudeAgent"),
2479 driver: CLAUDE_DRIVER,
2480 capabilities: ClaudeProviderCapabilitiesV2,
2481 modelSelection: CLAUDE_MODEL_SELECTION,
2482 responseByRunOrdinal: { 2: "The imported release marker is violet." },
2483 capturedTurns,
2484 }),
2485 ]);
2486 const layerDatabase = SqlitePersistence.layerMemory;
2487 const layerEventStoreProvided = EventStore.layer.pipe(Layer.provideMerge(layerDatabase));
2488 const layerProjectionStoreProvided = ProjectionStore.layer.pipe(
2489 Layer.provideMerge(layerDatabase),
2490 );
2491 const layerStoresProvided = Layer.mergeAll(
2492 layerDatabase,
2493 layerEventStoreProvided,
2494 layerProjectionStoreProvided,
2495 );
2496 const layerEventSinkProvided = EventSink.layer.pipe(Layer.provide(layerStoresProvided));
2497 const layerImporterProvided = LegacyV1ThreadImporter.layer.pipe(
2498 Layer.provide(Layer.mergeAll(layerStoresProvided, layerEventSinkProvided)),
2499 );
2500 const layerMaintenanceProvided = ProjectionMaintenance.layer.pipe(
2501 Layer.provide(layerStoresProvided),
2502 );
2503 const layerOrchestratorProvided = ProviderReplayHarness.layerWithRegistry(
2504 {
2505 name: "provider-switch-legacy-import",
2506 runtimePolicyOverride: {
2507 cwd,
2508 approvalPolicy: "never",
2509 sandboxPolicy: {
2510 type: "readOnly",
2511 access: { type: "fullAccess" },
2512 networkAccess: false,
2513 },
2514 },
2515 },
2516 layerRegistry,
2517 { databaseLayer: layerDatabase },
2518 );
2519 const layerTest = Layer.mergeAll(
2520 layerStoresProvided,
2521 layerImporterProvided,
2522 layerMaintenanceProvided,
2523 layerOrchestratorProvided,
2524 );
2525
2526 const projection = yield* Effect.gen(function* () {
2527 const sql = yield* SqlClient.SqlClient;
2528 const importer = yield* LegacyV1ThreadImporter.LegacyV1ThreadImporter;
2529 const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2;
2530 const orchestrator = yield* Orchestrator.OrchestratorV2;
2531 const worker = yield* EffectWorker.OrchestrationEffectWorkerV2;
2532
2533 yield* sql`
2534 INSERT INTO projection_projects (
2535 project_id,
2536 title,
2537 workspace_root,
2538 default_model_selection_json,
2539 scripts_json,
2540 created_at,
2541 updated_at,
2542 deleted_at
2543 ) VALUES (
2544 ${importedProjectId},
2545 'Imported provider switch project',
2546 ${cwd},
2547 '{"instanceId":"codex","model":"gpt-5.4"}',
2548 '[]',
2549 '2026-01-01T00:00:00.000Z',
2550 '2026-01-01T00:00:00.000Z',
2551 NULL
2552 )
2553 `;
2554 yield* sql`
2555 INSERT INTO projection_threads (
2556 thread_id,
2557 project_id,
2558 title,
2559 model_selection_json,
2560 runtime_mode,
2561 interaction_mode,
2562 branch,
2563 worktree_path,
2564 latest_turn_id,
2565 created_at,
2566 updated_at,
2567 archived_at,
2568 settled_override,
2569 settled_at,
2570 deleted_at
2571 ) VALUES (
2572 ${importedThreadId},
2573 ${importedProjectId},
2574 'Imported provider switch thread',
2575 '{"instanceId":"codex","model":"gpt-5.4"}',
2576 'full-access',
2577 'default',
2578 'main',
2579 ${cwd},
2580 NULL,
2581 '2026-01-01T00:00:00.000Z',
2582 '2026-01-01T00:00:00.000Z',
2583 NULL,
2584 NULL,
2585 NULL,
2586 NULL
2587 )
2588 `;
2589 yield* sql`
2590 INSERT INTO projection_thread_messages (
2591 message_id,
2592 thread_id,
2593 turn_id,
2594 role,
2595 text,
2596 attachments_json,
2597 is_streaming,
2598 created_at,
2599 updated_at
2600 ) VALUES
2601 (
2602 'message:provider-switch:legacy-import:user',
2603 ${importedThreadId},
2604 NULL,
2605 'user',
2606 'Remember that the imported release marker is violet.',
2607 '[]',
2608 0,
2609 '2026-01-01T01:00:00.000Z',
2610 '2026-01-01T01:00:00.000Z'
2611 ),
2612 (
2613 'message:provider-switch:legacy-import:assistant',
2614 ${importedThreadId},
2615 NULL,
2616 'assistant',
2617 'I will remember violet.',
2618 '[]',
2619 0,
2620 '2026-01-01T01:01:00.000Z',
2621 '2026-01-01T01:01:00.000Z'
2622 )
2623 `;
2624
2625 yield* importer.reconcileShells;
2626 yield* maintenance.rebuild;
2627 yield* importer.ensureTranscript(importedThreadId);
2628
2629 yield* orchestrator.dispatch({
2630 type: "message.dispatch",
2631 createdBy: "user",
2632 creationSource: "web",
2633 commandId: CommandId.make("command:provider-switch:legacy-import:failed"),
2634 threadId: importedThreadId,
2635 messageId: MessageId.make("message:provider-switch:legacy-import:failed"),
2636 text: failedPrompt,
2637 attachments: [],
2638 modelSelection: CODEX_MODEL_SELECTION,
2639 dispatchMode: { type: "start_immediately" },
2640 });
2641 if (queueBeforeFailure) {
2642 yield* Deferred.await(firstTurnStarted);
2643 } else {
2644 yield* waitForIdle(importedThreadId);
2645 }
2646 yield* orchestrator.dispatch({
2647 type: "message.dispatch",
2648 createdBy: "user",
2649 creationSource: "web",
2650 commandId: CommandId.make("command:provider-switch:legacy-import:recovery"),
2651 threadId: importedThreadId,
2652 messageId: MessageId.make("message:provider-switch:legacy-import:recovery"),
2653 text: recoveryPrompt,
2654 attachments: [],
2655 modelSelection: CLAUDE_MODEL_SELECTION,
2656 dispatchMode: {
2657 type: queueBeforeFailure ? "queue_after_active" : "start_immediately",
2658 },
2659 });
2660 if (queueBeforeFailure) {
2661 const queued = yield* orchestrator.getThreadProjection(importedThreadId);
2662 assert.deepEqual(
2663 queued.runs.map((run) => run.status),
2664 ["running", "queued"],
2665 );
2666 assert.deepEqual(
2667 queued.contextHandoffs.map((handoff) => handoff.targetRunId),
2668 [queued.runs[0]?.id],
2669 );
2670 yield* Deferred.succeed(releaseFirstTurn, undefined);
2671 yield* orchestrator.streamStoredEvents.pipe(
2672 Stream.filter(
2673 (event) =>
2674 event.event.type === "run.updated" &&
2675 event.event.runId === queued.runs[1]?.id &&
2676 event.event.payload.status === "completed",
2677 ),
2678 Stream.runHead,
2679 );
2680 yield* worker.drain();
2681 } else {
2682 yield* waitForIdle(importedThreadId);
2683 }
2684 const beforeRebuild = yield* orchestrator.getThreadProjection(importedThreadId);
2685 for (const attempt of beforeRebuild.attempts) {
2686 const nativeId = beforeRebuild.providerThreads.find(
2687 (thread) => thread.id === attempt.providerThreadId,
2688 )?.nativeThreadRef?.nativeId;
2689 assert.isDefined(nativeId);
2690 assert.equal(attempt.nativeThreadId, nativeId);
2691 }
2692 yield* maintenance.rebuild;
2693 const rebuilt = yield* orchestrator.getThreadProjection(importedThreadId);
2694 assert.deepEqual(rebuilt.attempts, beforeRebuild.attempts);
2695 return rebuilt;
2696 }).pipe(Effect.provide(layerTest));
2697
2698 const turns = yield* Ref.get(capturedTurns);
2699 assert.deepEqual(
2700 projection.runs.map((run) => [run.providerInstanceId, run.status]),
2701 [
2702 ["codex", "failed"],
2703 ["claudeAgent", "completed"],
2704 ],
2705 );
2706 assert.sameDeepMembers(
2707 projection.contextHandoffs.map((handoff) => [
2708 handoff.targetRunId,
2709 handoff.strategy,
2710 handoff.status,
2711 ]),
2712 [
2713 [projection.runs[0]?.id, "manual_context", "ready"],
2714 [projection.runs[1]?.id, "manual_context", "ready"],
2715 [projection.runs[1]?.id, "full_thread_summary", "ready"],
2716 ],
2717 );
2718 const recoveryHandoff = projection.contextHandoffs.find(
2719 (handoff) => handoff.id === projection.runs[1]?.contextHandoffId,
2720 );
2721 assert.equal(recoveryHandoff?.strategy, "full_thread_summary");
2722 assert.include(recoveryHandoff?.summaryText ?? "", failedPrompt);
2723 if (queueBeforeFailure) {
2724 const handoffItem = projection.turnItems.find(
2725 (item) => item.type === "handoff" && item.runId === projection.runs[1]?.id,
2726 );
2727 assert.equal(
2728 handoffItem?.type === "handoff" ? handoffItem.contextHandoffId : null,
2729 recoveryHandoff?.id,
2730 );
2731 }
2732 assert.include(turns[1]?.text ?? "", "Context handoff (manual_context):");
2733 assert.include(turns[1]?.text ?? "", "imported release marker is violet");
2734 assert.include(turns[1]?.text ?? "", "I will remember violet.");
2735 assert.include(turns[1]?.text ?? "", recoveryPrompt);
2736 assert.include(turns[1]?.text ?? "", failedPrompt);
2737 }),
2738 );
2739
2740 it.live("reissues imported v1 context when switching after the first provider fails", () =>
2741 importedFailureScenario(false),
2742 );
2743 it.live(
2744 "reissues imported v1 context when a queued provider starts after the first provider fails",
2745 () => importedFailureScenario(true),
2746 );
2747
2748 it.live("uses portable fallback when native resume fails after a provider switch", () =>
2749 Effect.scoped(
2750 Effect.gen(function* () {
2751 const cwd = yield* checkpointWorkspace("provider-switch");
2752 const capturedTurns = yield* Ref.make<ReadonlyArray<CapturedTurn>>([]);
2753 const codexNativeThreadGeneration = yield* Ref.make(0);
2754 const layerRegistry = ProviderAdapterRegistry.layerFromAdapters([
2755 makeTestAdapter({
2756 instanceId: ProviderInstanceId.make("codex"),
2757 driver: CODEX_DRIVER,
2758 capabilities: CodexProviderCapabilitiesV2,
2759 modelSelection: CODEX_MODEL_SELECTION,
2760 responseByRunOrdinal: {
2761 1: "codex before switch",
2762 3: "codex after return",
2763 },
2764 capturedTurns,
2765 failResume: true,
2766 nativeThreadGeneration: codexNativeThreadGeneration,
2767 }),
2768 makeTestAdapter({
2769 instanceId: ProviderInstanceId.make("claudeAgent"),
2770 driver: CLAUDE_DRIVER,
2771 capabilities: ClaudeProviderCapabilitiesV2,
2772 modelSelection: CLAUDE_MODEL_SELECTION,
2773 responseByRunOrdinal: { 2: "claude switched response" },
2774 capturedTurns,
2775 }),
2776 ]);
2777 const commands = [
2778 {
2779 type: "thread.create",
2780 createdBy: "user",
2781 creationSource: "web",
2782 commandId: CommandId.make("command:provider-switch:create"),
2783 threadId,
2784 projectId,
2785 title: "Provider switch",
2786 modelSelection: CODEX_MODEL_SELECTION,
2787 runtimeMode: "full-access",
2788 interactionMode: "default",
2789 branch: null,
2790 worktreePath: null,
2791 },
2792 {
2793 type: "message.dispatch",
2794 createdBy: "user",
2795 creationSource: "web",
2796 commandId: CommandId.make("command:provider-switch:codex"),
2797 threadId,
2798 messageId: MessageId.make("message:provider-switch:codex"),
2799 text: firstPrompt,
2800 attachments: [],
2801 modelSelection: CODEX_MODEL_SELECTION,
2802 dispatchMode: { type: "start_immediately" },
2803 },
2804 {
2805 type: "message.dispatch",
2806 createdBy: "user",
2807 creationSource: "web",
2808 commandId: CommandId.make("command:provider-switch:claude"),
2809 threadId,
2810 messageId: MessageId.make("message:provider-switch:claude"),
2811 text: claudePrompt,
2812 attachments: [],
2813 modelSelection: CLAUDE_MODEL_SELECTION,
2814 dispatchMode: { type: "start_immediately" },
2815 },
2816 {
2817 type: "message.dispatch",
2818 createdBy: "user",
2819 creationSource: "web",
2820 commandId: CommandId.make("command:provider-switch:return"),
2821 threadId,
2822 messageId: MessageId.make("message:provider-switch:return"),
2823 text: returnPrompt,
2824 attachments: [],
2825 modelSelection: CODEX_MODEL_SELECTION,
2826 dispatchMode: { type: "start_immediately" },
2827 },
2828 ] satisfies ReadonlyArray<OrchestrationV2Command>;
2829
2830 const projection = yield* Effect.gen(function* () {
2831 const orchestrator = yield* Orchestrator.OrchestratorV2;
2832 yield* orchestrator.dispatch(commands[0]!);
2833 yield* orchestrator.dispatch(commands[1]!);
2834 yield* waitForIdle(threadId);
2835 yield* orchestrator.dispatch(commands[2]!);
2836 assert.deepEqual(
2837 (yield* orchestrator.getThreadProjection(threadId)).thread.modelSelection,
2838 CLAUDE_MODEL_SELECTION,
2839 );
2840 yield* waitForIdle(threadId);
2841 // Stopping the shared Codex process drops its loaded native thread, so
2842 // returning to Codex has to resume it (and fall back when that fails).
2843 const codexSession = (yield* orchestrator.getThreadProjection(
2844 threadId,
2845 )).providerSessions.find(
2846 (session) => session.providerInstanceId === CODEX_MODEL_SELECTION.instanceId,
2847 )!;
2848 yield* orchestrator.dispatch({
2849 type: "provider-session.detach",
2850 commandId: CommandId.make("command:provider-switch:stop-codex"),
2851 threadId,
2852 providerSessionId: codexSession.id,
2853 });
2854 yield* (yield* EffectWorker.OrchestrationEffectWorkerV2).drain();
2855 yield* orchestrator.dispatch(commands[3]!);
2856 assert.deepEqual(
2857 (yield* orchestrator.getThreadProjection(threadId)).thread.modelSelection,
2858 CODEX_MODEL_SELECTION,
2859 );
2860 return yield* waitForIdle(threadId);
2861 }).pipe(
2862 Effect.provide(
2863 ProviderReplayHarness.layerWithRegistry(
2864 {
2865 name: "provider-switch",
2866 runtimePolicyOverride: {
2867 cwd,
2868 approvalPolicy: "never",
2869 sandboxPolicy: {
2870 type: "readOnly",
2871 access: { type: "fullAccess" },
2872 networkAccess: false,
2873 },
2874 },
2875 },
2876 layerRegistry,
2877 ),
2878 ),
2879 );
2880 const turns = yield* Ref.get(capturedTurns);
2881
2882 assert.deepEqual(
2883 projection.runs.map((run) => [run.providerInstanceId, run.status]),
2884 [
2885 ["codex", "completed"],
2886 ["claudeAgent", "completed"],
2887 ["codex", "completed"],
2888 ],
2889 );
2890 assert.lengthOf(projection.providerThreads, 2);
2891 assert.equal(projection.runs[0]?.providerThreadId, projection.runs[2]?.providerThreadId);
2892 assert.notEqual(projection.runs[0]?.providerThreadId, projection.runs[1]?.providerThreadId);
2893 // The failed resume bound a fresh native Codex thread to the same row.
2894 assert.equal(yield* Ref.get(codexNativeThreadGeneration), 2);
2895 const codexThread = projection.providerThreads.find(
2896 (providerThread) => providerThread.id === projection.runs[2]?.providerThreadId,
2897 );
2898 assert.equal(codexThread?.nativeThreadRef?.nativeId, `codex:${threadId}:1`);
2899 assert.deepEqual(
2900 projection.contextHandoffs.map((handoff) => [
2901 handoff.strategy,
2902 handoff.coveredRunOrdinals,
2903 handoff.delivery?.status,
2904 handoff.delivery?.nativeThreadId,
2905 ]),
2906 [
2907 ["full_thread_summary", { from: 1, to: 1 }, "inline", `claudeAgent:${threadId}`],
2908 ["delta_since_target_last_seen", { from: 2, to: 2 }, "inline", `codex:${threadId}:1`],
2909 ["full_thread_summary", { from: 1, to: 2 }, "inline", `codex:${threadId}:1`],
2910 ],
2911 );
2912 assert.deepEqual(
2913 projection.contextTransfers.map((transfer) => [
2914 transfer.type,
2915 transfer.status,
2916 transfer.resolution?.strategy,
2917 ]),
2918 [
2919 ["provider_handoff", "consumed", "portable_context"],
2920 ["provider_handoff", "consumed", "delta_context"],
2921 ["provider_handoff", "resolved_portable", "portable_context"],
2922 ],
2923 );
2924 assert.deepEqual(
2925 projection.turnItems
2926 .filter((item) => item.type === "user_message")
2927 .map((item) => item.text),
2928 [firstPrompt, claudePrompt, returnPrompt],
2929 );
2930 assert.deepEqual(
2931 projection.providerThreads.map((providerThread) => [
2932 providerThread.driver,
2933 providerThread.status,
2934 providerThread.handoffIds.length,
2935 ]),
2936 [
2937 ["codex", "idle", 1],
2938 ["claudeAgent", "idle", 1],
2939 ],
2940 );
2941 assert.equal(turns[0]?.text, firstPrompt);
2942 assert.include(turns[1]?.text ?? "", "Context handoff (full_thread_summary):");
2943 assert.include(turns[1]?.text ?? "", "codex before switch");
2944 assert.include(turns[1]?.text ?? "", claudePrompt);
2945 // The fresh native thread has none of the earlier Codex turn, so the
2946 // portable fallback re-sends it alongside the Claude delta.
2947 assert.include(turns[2]?.text ?? "", "Context handoff (full_thread_summary):");
2948 assert.include(turns[2]?.text ?? "", "Context handoff (delta_since_target_last_seen):");
2949 assert.include(turns[2]?.text ?? "", "codex before switch");
2950 assert.include(turns[2]?.text ?? "", "claude switched response");
2951 assert.include(turns[2]?.text ?? "", returnPrompt);
2952 assert.equal(turns[0]?.providerThreadId, turns[2]?.providerThreadId);
2953 }),
2954 ),
2955 );
2956
2957 it.live("resolves a Claude fork into portable Codex context on first dispatch", () =>
2958 Effect.scoped(
2959 Effect.gen(function* () {
2960 const sourceThreadId = ThreadId.make("thread:cross-provider-fork:source");
2961 const targetThreadId = ThreadId.make("thread:cross-provider-fork:target");
2962 const sourcePrompt = "Remember that the release color is violet.";
2963 const targetPrompt = "What release color did we choose?";
2964 const cwd = yield* checkpointWorkspace("cross-provider-fork");
2965 const capturedTurns = yield* Ref.make<ReadonlyArray<CapturedTurn>>([]);
2966 const layerRegistry = ProviderAdapterRegistry.layerFromAdapters([
2967 makeTestAdapter({
2968 instanceId: ProviderInstanceId.make("codex"),
2969 driver: CODEX_DRIVER,
2970 capabilities: CodexProviderCapabilitiesV2,
2971 modelSelection: CODEX_MODEL_SELECTION,
2972 responseByRunOrdinal: { 1: "The release color is violet." },
2973 capturedTurns,
2974 }),
2975 makeTestAdapter({
2976 instanceId: ProviderInstanceId.make("claudeAgent"),
2977 driver: CLAUDE_DRIVER,
2978 capabilities: ClaudeProviderCapabilitiesV2,
2979 modelSelection: CLAUDE_MODEL_SELECTION,
2980 responseByRunOrdinal: { 1: "I will remember violet." },
2981 capturedTurns,
2982 }),
2983 ]);
2984 const commands = [
2985 {
2986 type: "thread.create",
2987 createdBy: "user",
2988 creationSource: "web",
2989 commandId: CommandId.make("command:cross-provider-fork:create"),
2990 threadId: sourceThreadId,
2991 projectId,
2992 title: "Cross-provider fork source",
2993 modelSelection: CLAUDE_MODEL_SELECTION,
2994 runtimeMode: "full-access",
2995 interactionMode: "default",
2996 branch: null,
2997 worktreePath: null,
2998 },
2999 {
3000 type: "message.dispatch",
3001 createdBy: "user",
3002 creationSource: "web",
3003 commandId: CommandId.make("command:cross-provider-fork:source"),
3004 threadId: sourceThreadId,
3005 messageId: MessageId.make("message:cross-provider-fork:source"),
3006 text: sourcePrompt,
3007 attachments: [],
3008 modelSelection: CLAUDE_MODEL_SELECTION,
3009 dispatchMode: { type: "start_immediately" },
3010 },
3011 {
3012 type: "thread.fork",
3013 createdBy: "user",
3014 creationSource: "web",
3015 commandId: CommandId.make("command:cross-provider-fork:fork"),
3016 sourceThreadId,
3017 targetThreadId,
3018 sourcePoint: { type: "latest_stable" },
3019 title: "Cross-provider fork target",
3020 },
3021 {
3022 type: "message.dispatch",
3023 createdBy: "user",
3024 creationSource: "web",
3025 commandId: CommandId.make("command:cross-provider-fork:target"),
3026 threadId: targetThreadId,
3027 messageId: MessageId.make("message:cross-provider-fork:target"),
3028 text: targetPrompt,
3029 attachments: [],
3030 modelSelection: CODEX_MODEL_SELECTION,
3031 dispatchMode: { type: "start_immediately" },
3032 },
3033 ] satisfies ReadonlyArray<OrchestrationV2Command>;
3034
3035 const targetProjection = yield* Effect.gen(function* () {
3036 const orchestrator = yield* Orchestrator.OrchestratorV2;
3037 yield* orchestrator.dispatch(commands[0]!);
3038 yield* orchestrator.dispatch(commands[1]!);
3039 yield* waitForIdle(sourceThreadId);
3040 yield* orchestrator.dispatch(commands[2]!);
3041 yield* orchestrator.dispatch(commands[3]!);
3042 return yield* waitForIdle(targetThreadId);
3043 }).pipe(
3044 Effect.provide(
3045 ProviderReplayHarness.layerWithRegistry(
3046 {
3047 name: "cross-provider-fork",
3048 runtimePolicyOverride: {
3049 cwd,
3050 approvalPolicy: "never",
3051 sandboxPolicy: {
3052 type: "readOnly",
3053 access: { type: "fullAccess" },
3054 networkAccess: false,
3055 },
3056 },
3057 },
3058 layerRegistry,
3059 ),
3060 ),
3061 );
3062 const turns = yield* Ref.get(capturedTurns);
3063 const targetTurn = turns.find((turn) => turn.threadId === targetThreadId);
3064
3065 assert.deepEqual(
3066 targetProjection.runs.map((run) => [run.providerInstanceId, run.status]),
3067 [["codex", "completed"]],
3068 );
3069 assert.lengthOf(targetProjection.providerThreads, 1);
3070 assert.equal(targetProjection.providerThreads[0]?.driver, "codex");
3071 assert.isNull(targetProjection.providerThreads[0]?.forkedFrom);
3072 assert.deepEqual(
3073 targetProjection.contextTransfers.map((transfer) => [
3074 transfer.type,
3075 transfer.status,
3076 transfer.resolution?.strategy,
3077 ]),
3078 [["fork", "consumed", "portable_context"]],
3079 );
3080 assert.deepEqual(
3081 targetProjection.contextHandoffs.map((handoff) => handoff.strategy),
3082 ["full_thread_summary"],
3083 );
3084 assert.equal(
3085 targetProjection.runs[0]?.contextHandoffId,
3086 targetProjection.contextHandoffs[0]?.id,
3087 );
3088 assert.include(targetTurn?.text ?? "", "Context handoff (full_thread_summary):");
3089 assert.include(targetTurn?.text ?? "", sourcePrompt);
3090 assert.include(targetTurn?.text ?? "", "I will remember violet.");
3091 assert.include(targetTurn?.text ?? "", targetPrompt);
3092 }),
3093 ),
3094 );
3095
3096 it.live("resolves a same-provider Cursor fork with portable context", () =>
3097 Effect.scoped(
3098 Effect.gen(function* () {
3099 const sourceThreadId = ThreadId.make("thread:cursor-portable-fork:source");
3100 const targetThreadId = ThreadId.make("thread:cursor-portable-fork:target");
3101 const sourcePrompt = "Remember that the deployment marker is indigo.";
3102 const sourceResponse = "I will remember indigo.";
3103 const targetPrompt = "What deployment marker did we choose?";
3104 const cwd = yield* checkpointWorkspace("cursor-portable-fork");
3105 const capturedTurns = yield* Ref.make<ReadonlyArray<CapturedTurn>>([]);
3106 const layerRegistry = ProviderAdapterRegistry.layerFromAdapters([
3107 makeTestAdapter({
3108 instanceId: ProviderInstanceId.make("cursor"),
3109 driver: CURSOR_DRIVER,
3110 capabilities: CursorProviderCapabilitiesV2,
3111 modelSelection: CURSOR_MODEL_SELECTION,
3112 responseByRunOrdinal: {},
3113 responseByThreadId: {
3114 [sourceThreadId]: { 1: sourceResponse },
3115 [targetThreadId]: { 1: "The deployment marker is indigo." },
3116 },
3117 capturedTurns,
3118 }),
3119 ]);
3120 const commands = [
3121 {
3122 type: "thread.create",
3123 createdBy: "user",
3124 creationSource: "web",
3125 commandId: CommandId.make("command:cursor-portable-fork:create"),
3126 threadId: sourceThreadId,
3127 projectId,
3128 title: "Cursor portable fork source",
3129 modelSelection: CURSOR_MODEL_SELECTION,
3130 runtimeMode: "full-access",
3131 interactionMode: "default",
3132 branch: null,
3133 worktreePath: null,
3134 },
3135 {
3136 type: "message.dispatch",
3137 createdBy: "user",
3138 creationSource: "web",
3139 commandId: CommandId.make("command:cursor-portable-fork:source"),
3140 threadId: sourceThreadId,
3141 messageId: MessageId.make("message:cursor-portable-fork:source"),
3142 text: sourcePrompt,
3143 attachments: [],
3144 modelSelection: CURSOR_MODEL_SELECTION,
3145 dispatchMode: { type: "start_immediately" },
3146 },
3147 {
3148 type: "thread.fork",
3149 createdBy: "user",
3150 creationSource: "web",
3151 commandId: CommandId.make("command:cursor-portable-fork:fork"),
3152 sourceThreadId,
3153 targetThreadId,
3154 sourcePoint: { type: "latest_stable" },
3155 title: "Cursor portable fork target",
3156 },
3157 {
3158 type: "message.dispatch",
3159 createdBy: "user",
3160 creationSource: "web",
3161 commandId: CommandId.make("command:cursor-portable-fork:target"),
3162 threadId: targetThreadId,
3163 messageId: MessageId.make("message:cursor-portable-fork:target"),
3164 text: targetPrompt,
3165 attachments: [],
3166 modelSelection: CURSOR_MODEL_SELECTION,
3167 dispatchMode: { type: "start_immediately" },
3168 },
3169 ] satisfies ReadonlyArray<OrchestrationV2Command>;
3170
3171 const targetProjection = yield* Effect.gen(function* () {
3172 const orchestrator = yield* Orchestrator.OrchestratorV2;
3173 yield* orchestrator.dispatch(commands[0]!);
3174 yield* orchestrator.dispatch(commands[1]!);
3175 yield* waitForIdle(sourceThreadId);
3176 yield* orchestrator.dispatch(commands[2]!);
3177 yield* orchestrator.dispatch(commands[3]!);
3178 return yield* waitForIdle(targetThreadId);
3179 }).pipe(
3180 Effect.provide(
3181 ProviderReplayHarness.layerWithRegistry(
3182 {
3183 name: "cursor-portable-fork",
3184 runtimePolicyOverride: {
3185 cwd,
3186 approvalPolicy: "never",
3187 sandboxPolicy: {
3188 type: "readOnly",
3189 access: { type: "fullAccess" },
3190 networkAccess: false,
3191 },
3192 },
3193 },
3194 layerRegistry,
3195 ),
3196 ),
3197 );
3198 const turns = yield* Ref.get(capturedTurns);
3199 const targetTurn = turns.find((turn) => turn.threadId === targetThreadId);
3200
3201 assert.deepEqual(
3202 targetProjection.runs.map((run) => [run.providerInstanceId, run.status]),
3203 [["cursor", "completed"]],
3204 );
3205 assert.lengthOf(targetProjection.providerThreads, 1);
3206 assert.equal(targetProjection.providerThreads[0]?.driver, "cursor");
3207 assert.isNull(targetProjection.providerThreads[0]?.forkedFrom);
3208 assert.deepEqual(
3209 targetProjection.contextTransfers.map((transfer) => [
3210 transfer.type,
3211 transfer.status,
3212 transfer.resolution?.strategy,
3213 ]),
3214 [["fork", "consumed", "portable_context"]],
3215 );
3216 assert.deepEqual(
3217 targetProjection.contextHandoffs.map((handoff) => handoff.strategy),
3218 ["full_thread_summary"],
3219 );
3220 assert.include(targetTurn?.text ?? "", "Context handoff (full_thread_summary):");
3221 assert.include(targetTurn?.text ?? "", sourcePrompt);
3222 assert.include(targetTurn?.text ?? "", sourceResponse);
3223 assert.include(targetTurn?.text ?? "", targetPrompt);
3224 }),
3225 ),
3226 );
3227
3228 it.live.each([false, true])(
3229 "switches providers while consuming a pending cross-provider merge-back (resume failure: %s)",
3230 (failResume) =>
3231 Effect.scoped(
3232 Effect.gen(function* () {
3233 const sourceThreadId = ThreadId.make("thread:cross-provider-merge:source");
3234 const forkThreadId = ThreadId.make("thread:cross-provider-merge:fork");
3235 const firstSourcePrompt = "Remember that the first source marker is amber.";
3236 const secondSourcePrompt = "Remember that the second source marker is violet.";
3237 const forkPrompt = "Remember that the fork marker is cobalt.";
3238 const mergePrompt = "Report all three remembered markers.";
3239 const cwd = yield* checkpointWorkspace("cross-provider-merge");
3240 const capturedTurns = yield* Ref.make<ReadonlyArray<CapturedTurn>>([]);
3241 const layerRegistry = ProviderAdapterRegistry.layerFromAdapters([
3242 makeTestAdapter({
3243 instanceId: ProviderInstanceId.make("codex"),
3244 driver: CODEX_DRIVER,
3245 capabilities: CodexProviderCapabilitiesV2,
3246 modelSelection: CODEX_MODEL_SELECTION,
3247 responseByRunOrdinal: {},
3248 responseByThreadId: {
3249 [sourceThreadId]: {
3250 1: "I will remember amber.",
3251 3: "The markers are amber, violet, and cobalt.",
3252 },
3253 [forkThreadId]: {
3254 1: "I will remember cobalt.",
3255 },
3256 },
3257 capturedTurns,
3258 failResume,
3259 }),
3260 makeTestAdapter({
3261 instanceId: ProviderInstanceId.make("claudeAgent"),
3262 driver: CLAUDE_DRIVER,
3263 capabilities: ClaudeProviderCapabilitiesV2,
3264 modelSelection: CLAUDE_MODEL_SELECTION,
3265 responseByRunOrdinal: { 2: "I will remember violet." },
3266 capturedTurns,
3267 }),
3268 ]);
3269 const commands = [
3270 {
3271 type: "thread.create",
3272 createdBy: "user",
3273 creationSource: "web",
3274 commandId: CommandId.make("command:cross-provider-merge:create"),
3275 threadId: sourceThreadId,
3276 projectId,
3277 title: "Cross-provider merge source",
3278 modelSelection: CODEX_MODEL_SELECTION,
3279 runtimeMode: "full-access",
3280 interactionMode: "default",
3281 branch: null,
3282 worktreePath: null,
3283 },
3284 {
3285 type: "message.dispatch",
3286 createdBy: "user",
3287 creationSource: "web",
3288 commandId: CommandId.make("command:cross-provider-merge:first-source"),
3289 threadId: sourceThreadId,
3290 messageId: MessageId.make("message:cross-provider-merge:first-source"),
3291 text: firstSourcePrompt,
3292 attachments: [],
3293 modelSelection: CODEX_MODEL_SELECTION,
3294 dispatchMode: { type: "start_immediately" },
3295 },
3296 {
3297 type: "message.dispatch",
3298 createdBy: "user",
3299 creationSource: "web",
3300 commandId: CommandId.make("command:cross-provider-merge:second-source"),
3301 threadId: sourceThreadId,
3302 messageId: MessageId.make("message:cross-provider-merge:second-source"),
3303 text: secondSourcePrompt,
3304 attachments: [],
3305 modelSelection: CLAUDE_MODEL_SELECTION,
3306 dispatchMode: { type: "start_immediately" },
3307 },
3308 {
3309 type: "thread.fork",
3310 createdBy: "user",
3311 creationSource: "web",
3312 commandId: CommandId.make("command:cross-provider-merge:fork"),
3313 sourceThreadId,
3314 targetThreadId: forkThreadId,
3315 sourcePoint: { type: "latest_stable" },
3316 title: "Cross-provider merge fork",
3317 },
3318 {
3319 type: "message.dispatch",
3320 createdBy: "user",
3321 creationSource: "web",
3322 commandId: CommandId.make("command:cross-provider-merge:fork-turn"),
3323 threadId: forkThreadId,
3324 messageId: MessageId.make("message:cross-provider-merge:fork-turn"),
3325 text: forkPrompt,
3326 attachments: [],
3327 modelSelection: CODEX_MODEL_SELECTION,
3328 dispatchMode: { type: "start_immediately" },
3329 },
3330 {
3331 type: "thread.merge_back",
3332 createdBy: "user",
3333 creationSource: "web",
3334 commandId: CommandId.make("command:cross-provider-merge:merge"),
3335 sourceThreadId: forkThreadId,
3336 targetThreadId: sourceThreadId,
3337 sourcePoint: { type: "latest_stable" },
3338 },
3339 {
3340 type: "message.dispatch",
3341 createdBy: "user",
3342 creationSource: "web",
3343 commandId: CommandId.make("command:cross-provider-merge:consume"),
3344 threadId: sourceThreadId,
3345 messageId: MessageId.make("message:cross-provider-merge:consume"),
3346 text: mergePrompt,
3347 attachments: [],
3348 modelSelection: CODEX_MODEL_SELECTION,
3349 dispatchMode: { type: "start_immediately" },
3350 },
3351 ] satisfies ReadonlyArray<OrchestrationV2Command>;
3352
3353 const projection = yield* Effect.gen(function* () {
3354 const orchestrator = yield* Orchestrator.OrchestratorV2;
3355 yield* orchestrator.dispatch(commands[0]!);
3356 yield* orchestrator.dispatch(commands[1]!);
3357 yield* waitForIdle(sourceThreadId);
3358 yield* orchestrator.dispatch(commands[2]!);
3359 yield* waitForIdle(sourceThreadId);
3360 yield* orchestrator.dispatch(commands[3]!);
3361 yield* orchestrator.dispatch(commands[4]!);
3362 yield* waitForIdle(forkThreadId);
3363 yield* orchestrator.dispatch(commands[5]!);
3364 if (failResume) {
3365 // A persisted native ref that is not loaded in this process must
3366 // exercise resume rather than the session manager's warm cache.
3367 const beforeResume = yield* orchestrator.getThreadProjection(sourceThreadId);
3368 const codexThread = beforeResume.providerThreads.find(
3369 (thread) => thread.id === beforeResume.runs[0]?.providerThreadId,
3370 )!;
3371 yield* (yield* EventSink.EventSinkV2).write({
3372 events: [
3373 {
3374 id: EventId.make("unloaded-merge-target"),
3375 type: "provider-thread.updated",
3376 threadId: sourceThreadId,
3377 occurredAt: yield* DateTime.now,
3378 payload: {
3379 ...codexThread,
3380 nativeThreadRef: {
3381 ...codexThread.nativeThreadRef!,
3382 nativeId: "unloaded-native-merge-target",
3383 },
3384 },
3385 },
3386 ],
3387 });
3388 }
3389 yield* orchestrator.dispatch(commands[6]!);
3390 return yield* waitForIdle(sourceThreadId);
3391 }).pipe(
3392 Effect.provide(
3393 ProviderReplayHarness.layerWithRegistry(
3394 {
3395 name: "cross-provider-merge",
3396 runtimePolicyOverride: {
3397 cwd,
3398 approvalPolicy: "never",
3399 sandboxPolicy: {
3400 type: "readOnly",
3401 access: { type: "fullAccess" },
3402 networkAccess: false,
3403 },
3404 },
3405 },
3406 layerRegistry,
3407 ),
3408 ),
3409 );
3410 const turns = yield* Ref.get(capturedTurns);
3411 const mergedTurn = turns.findLast(
3412 (turn) => turn.threadId === sourceThreadId && turn.driver === "codex",
3413 );
3414 const mergeTransfer = projection.contextTransfers.find(
3415 (transfer) => transfer.type === "merge_back",
3416 );
3417
3418 if (failResume)
3419 assert.isAtLeast(
3420 projection.contextHandoffs.filter(
3421 (handoff) => handoff.targetRunId === projection.runs.at(-1)?.id,
3422 ).length,
3423 2,
3424 );
3425 assert.isDefined(mergedTurn);
3426 if (failResume)
3427 assert.notEqual(
3428 projection.providerThreads.find((thread) => thread.id === mergedTurn.providerThreadId)
3429 ?.nativeThreadRef?.nativeId,
3430 "unloaded-native-merge-target",
3431 );
3432 assert.include(mergedTurn.text, "Context handoff (full_thread_summary):");
3433 assert.include(mergedTurn.text, firstSourcePrompt);
3434 assert.include(mergedTurn.text, "I will remember amber.");
3435 assert.include(mergedTurn.text, secondSourcePrompt);
3436 assert.include(mergedTurn.text, "I will remember violet.");
3437 assert.include(mergedTurn.text, "Context handoff (merge_back / fork_delta_summary):");
3438 assert.include(mergedTurn.text, forkPrompt);
3439 assert.include(mergedTurn.text, "I will remember cobalt.");
3440 assert.include(mergedTurn.text, mergePrompt);
3441 assert.isDefined(mergeTransfer);
3442 assert.equal(mergeTransfer.status, "consumed");
3443 assert.equal(mergeTransfer.targetProviderInstanceId, "codex");
3444 assert.equal(mergeTransfer.resolution?.strategy, "fork_delta_context");
3445 }),
3446 ),
3447 );
3448
3449 it.live("routes two custom instances of the same driver independently", () =>
3450 Effect.scoped(
3451 Effect.gen(function* () {
3452 const personalThreadId = ThreadId.make("thread:custom-codex-personal");
3453 const workThreadId = ThreadId.make("thread:custom-codex-work");
3454 const personalSelection = {
3455 instanceId: ProviderInstanceId.make("codex_personal"),
3456 model: "gpt-5.4",
3457 } satisfies ModelSelection;
3458 const workSelection = {
3459 instanceId: ProviderInstanceId.make("codex_work"),
3460 model: "gpt-5.4",
3461 } satisfies ModelSelection;
3462 const cwd = yield* checkpointWorkspace("custom-codex-instances");
3463 const capturedTurns = yield* Ref.make<ReadonlyArray<CapturedTurn>>([]);
3464 const layerRegistry = ProviderAdapterRegistry.layerFromAdapters([
3465 makeTestAdapter({
3466 instanceId: personalSelection.instanceId,
3467 driver: CODEX_DRIVER,
3468 capabilities: CodexProviderCapabilitiesV2,
3469 modelSelection: personalSelection,
3470 responseByRunOrdinal: { 1: "personal response" },
3471 capturedTurns,
3472 }),
3473 makeTestAdapter({
3474 instanceId: workSelection.instanceId,
3475 driver: CODEX_DRIVER,
3476 capabilities: CodexProviderCapabilitiesV2,
3477 modelSelection: workSelection,
3478 responseByRunOrdinal: { 1: "work response" },
3479 capturedTurns,
3480 }),
3481 ]);
3482
3483 const [personal, work] = yield* Effect.gen(function* () {
3484 const orchestrator = yield* Orchestrator.OrchestratorV2;
3485 for (const [targetThreadId, selection, suffix] of [
3486 [personalThreadId, personalSelection, "personal"],
3487 [workThreadId, workSelection, "work"],
3488 ] as const) {
3489 yield* orchestrator.dispatch({
3490 type: "thread.create",
3491 createdBy: "user",
3492 creationSource: "web",
3493 commandId: CommandId.make(`command:custom-codex:${suffix}:create`),
3494 threadId: targetThreadId,
3495 projectId,
3496 title: `Custom Codex ${suffix}`,
3497 modelSelection: selection,
3498 runtimeMode: "full-access",
3499 interactionMode: "default",
3500 branch: null,
3501 worktreePath: null,
3502 });
3503 yield* orchestrator.dispatch({
3504 type: "message.dispatch",
3505 createdBy: "user",
3506 creationSource: "web",
3507 commandId: CommandId.make(`command:custom-codex:${suffix}:message`),
3508 threadId: targetThreadId,
3509 messageId: MessageId.make(`message:custom-codex:${suffix}`),
3510 text: `${suffix} prompt`,
3511 attachments: [],
3512 modelSelection: selection,
3513 dispatchMode: { type: "start_immediately" },
3514 });
3515 yield* waitForIdle(targetThreadId);
3516 }
3517 return yield* Effect.all([
3518 orchestrator.getThreadProjection(personalThreadId),
3519 orchestrator.getThreadProjection(workThreadId),
3520 ]);
3521 }).pipe(
3522 Effect.provide(
3523 ProviderReplayHarness.layerWithRegistry(
3524 {
3525 name: "custom-codex-instances",
3526 runtimePolicyOverride: {
3527 cwd,
3528 approvalPolicy: "never",
3529 sandboxPolicy: {
3530 type: "readOnly",
3531 access: { type: "fullAccess" },
3532 networkAccess: false,
3533 },
3534 },
3535 },
3536 layerRegistry,
3537 ),
3538 ),
3539 );
3540
3541 assert.equal(personal.runs[0]?.providerInstanceId, personalSelection.instanceId);
3542 assert.equal(
3543 personal.providerSessions[0]?.providerInstanceId,
3544 personalSelection.instanceId,
3545 );
3546 assert.equal(work.runs[0]?.providerInstanceId, workSelection.instanceId);
3547 assert.equal(work.providerSessions[0]?.providerInstanceId, workSelection.instanceId);
3548 assert.notEqual(personal.providerSessions[0]?.id, work.providerSessions[0]?.id);
3549 assert.deepEqual(
3550 (yield* Ref.get(capturedTurns)).map((turn) => [turn.threadId, turn.text]),
3551 [
3552 [personalThreadId, "personal prompt"],
3553 [workThreadId, "work prompt"],
3554 ],
3555 );
3556 }),
3557 ),
3558 );
3559});