1import { describe, expect, it } from "@effect/vitest";
2import * as DateTime from "effect/DateTime";
3import * as Schema from "effect/Schema";
4
5import {
6 CheckpointId,
7 CheckpointRef,
8 CheckpointScopeId,
9 ContextTransferId,
10 CommandId,
11 EventId,
12 MessageId,
13 NodeId,
14 NonNegativeInt,
15 ProjectId,
16 ProviderInstanceId,
17 ProviderReplayTranscript,
18 ProviderThreadId,
19 RunAttemptId,
20 RunId,
21 ThreadId,
22 TrimmedNonEmptyString,
23 TurnItemId,
24} from "./index.ts";
25import {
26 latestProviderTurnForAttempt,
27 OrchestrationV2Checkpoint,
28 OrchestrationV2CheckpointScope,
29 OrchestrationV2Command,
30 OrchestrationV2LimitRecoveryUpdate,
31 OrchestrationV2DomainEvent,
32 OrchestrationV2DispatchCommandError,
33 OrchestrationV2ProviderCapabilities,
34 OrchestrationV2ProviderThread,
35 OrchestrationV2ProviderThreadJson,
36 OrchestrationV2RpcSchemas,
37 OrchestrationV2ShellSnapshot,
38 OrchestrationV2SubscribeThreadInput,
39 OrchestrationV2Subagent,
40 OrchestrationV2SubagentJson,
41 OrchestrationV2ThreadHistoryPage,
42 OrchestrationV2ThreadProjection,
43 OrchestrationV2ThreadStreamItem,
44 OrchestrationV2ThreadShell,
45 OrchestrationV2TurnItem,
46 OrchestrationV2TurnItemJson,
47} from "./orchestrationV2.ts";
48
49const now = DateTime.makeUnsafe("2026-04-20T00:00:00.000Z");
50const encodeDispatchCommandError = Schema.encodeSync(OrchestrationV2DispatchCommandError);
51const decodeDispatchCommandError = Schema.decodeUnknownSync(OrchestrationV2DispatchCommandError);
52
53it("preserves conditional archive rejection across the dispatch error wire schema", () => {
54 const error = new OrchestrationV2DispatchCommandError({
55 commandId: CommandId.make("archive-condition-wire"),
56 commandType: "thread.archive",
57 reason: "archive-condition-changed",
58 message: "The thread changed before archival.",
59 });
60 const encoded = encodeDispatchCommandError(error);
61 const decoded = decodeDispatchCommandError(JSON.parse(JSON.stringify(encoded)));
62
63 expect(decoded.reason).toBe("archive-condition-changed");
64});
65
66function emptyThreadProjection() {
67 return {
68 thread: {
69 createdBy: "user",
70 creationSource: "web",
71 id: "thread-1",
72 projectId: "project-1",
73 title: "Thread",
74 providerInstanceId: "codex",
75 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex" },
76 runtimeMode: "full-access",
77 interactionMode: "default",
78 branch: null,
79 worktreePath: null,
80 activeProviderThreadId: null,
81 lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: "thread-1" },
82 forkedFrom: null,
83 createdAt: now,
84 updatedAt: now,
85 archivedAt: null,
86 deletedAt: null,
87 },
88 runs: [],
89 attempts: [],
90 nodes: [],
91 subagents: [],
92 providerSessions: [],
93 providerThreads: [],
94 providerTurns: [],
95 runtimeRequests: [],
96 messages: [],
97 plans: [],
98 turnItems: [],
99 checkpointScopes: [],
100 checkpoints: [],
101 contextHandoffs: [],
102 contextTransfers: [],
103 visibleTurnItems: [],
104 updatedAt: now,
105 };
106}
107
108const LegacyShellStreamItem = Schema.Union([
109 Schema.Struct({ kind: Schema.Literal("synchronized") }),
110 Schema.Struct({
111 kind: Schema.Literal("snapshot"),
112 snapshot: OrchestrationV2ShellSnapshot,
113 }),
114]);
115const LegacySubscribeThreadInput = Schema.Struct({
116 threadId: ThreadId,
117 afterSequence: Schema.optionalKey(NonNegativeInt),
118 requestCompletionMarker: Schema.optionalKey(Schema.Boolean),
119});
120const decodeLegacyShellStreamItem = Schema.decodeUnknownSync(LegacyShellStreamItem);
121const decodeLegacySubscribeThreadInput = Schema.decodeUnknownSync(LegacySubscribeThreadInput);
122const decodeOrchestrationV2Command = Schema.decodeUnknownSync(OrchestrationV2Command);
123const decodeOrchestrationV2TurnItem = Schema.decodeUnknownSync(OrchestrationV2TurnItem);
124const decodeOrchestrationV2TurnItemJson = Schema.decodeUnknownSync(OrchestrationV2TurnItemJson);
125const encodeOrchestrationV2TurnItemJson = Schema.encodeSync(OrchestrationV2TurnItemJson);
126const decodeOrchestrationV2CheckpointScope = Schema.decodeUnknownSync(
127 OrchestrationV2CheckpointScope,
128);
129const decodeOrchestrationV2Checkpoint = Schema.decodeUnknownSync(OrchestrationV2Checkpoint);
130const decodeOrchestrationV2DomainEvent = Schema.decodeUnknownSync(OrchestrationV2DomainEvent);
131const decodeProviderReplayTranscript = Schema.decodeUnknownSync(ProviderReplayTranscript);
132const decodeOrchestrationV2Subagent = Schema.decodeUnknownSync(OrchestrationV2Subagent);
133const encodeOrchestrationV2SubagentJson = Schema.encodeSync(OrchestrationV2SubagentJson);
134const decodeOrchestrationV2ThreadProjection = Schema.decodeUnknownSync(
135 OrchestrationV2ThreadProjection,
136);
137const decodeOrchestrationV2ThreadStreamItem = Schema.decodeUnknownSync(
138 OrchestrationV2ThreadStreamItem,
139);
140const decodeOrchestrationV2ProviderThreadJson = Schema.decodeUnknownSync(
141 OrchestrationV2ProviderThreadJson,
142);
143const encodeOrchestrationV2ProviderThreadJson = Schema.encodeSync(
144 OrchestrationV2ProviderThreadJson,
145);
146const decodeOrchestrationV2ProviderThread = Schema.decodeUnknownSync(OrchestrationV2ProviderThread);
147const decodeOrchestrationV2ThreadShell = Schema.decodeUnknownSync(OrchestrationV2ThreadShell);
148const decodeOrchestrationV2ProviderCapabilities = Schema.decodeUnknownSync(
149 OrchestrationV2ProviderCapabilities,
150);
151
152const decodeOrchestrationV2SubscribeThreadInput = Schema.decodeUnknownSync(
153 OrchestrationV2SubscribeThreadInput,
154);
155
156describe("orchestration V2 contracts", () => {
157 it("carries command failure metadata through runtime and JSON schemas without output text", () => {
158 const base = {
159 id: "command-item",
160 type: "command_execution",
161 threadId: "thread-1",
162 runId: null,
163 nodeId: null,
164 providerThreadId: null,
165 providerTurnId: null,
166 nativeItemRef: null,
167 parentItemId: null,
168 ordinal: 1,
169 status: "completed",
170 title: "Command",
171 input: "example",
172 startedAt: null,
173 completedAt: null,
174 };
175 for (const metadata of [
176 {},
177 { outputIndicatesFailure: true },
178 { outputIndicatesFailure: false },
179 ]) {
180 const runtime = decodeOrchestrationV2TurnItem({ ...base, ...metadata, updatedAt: now });
181 const json = decodeOrchestrationV2TurnItemJson({
182 ...base,
183 ...metadata,
184 updatedAt: DateTime.formatIso(now),
185 });
186 expect(runtime.type).toBe("command_execution");
187 expect(json.type).toBe("command_execution");
188 expect(runtime).toMatchObject(metadata);
189 expect(json).toMatchObject(metadata);
190 expect(runtime).not.toHaveProperty("output");
191 expect(json).not.toHaveProperty("output");
192 }
193 });
194
195 it("decodes thread event types from a newer server as skippable items", () => {
196 const decodeWireItems = Schema.decodeUnknownSync(
197 Schema.toCodecJson(Schema.Array(OrchestrationV2RpcSchemas.subscribeThread.output)),
198 );
199 const detached = (id: string, sequence: number) => ({
200 kind: "event",
201 sequence,
202 event: {
203 id,
204 type: "provider-session.detached",
205 threadId: "thread-1",
206 occurredAt: DateTime.formatIso(now),
207 payload: { providerSessionId: "provider-session-1", detachedAt: DateTime.formatIso(now) },
208 },
209 });
210
211 const items = decodeWireItems([
212 detached("event-1", 1),
213 {
214 kind: "event",
215 sequence: 2,
216 event: {
217 id: "event-2",
218 // A type no build of this client knows, standing in for a newer server's event.
219 type: "run.from-a-future-server",
220 threadId: "thread-1",
221 occurredAt: DateTime.formatIso(now),
222 payload: { runId: "run-1" },
223 },
224 },
225 detached("event-3", 3),
226 ]);
227
228 expect(items.map((item) => item.kind)).toEqual(["event", "unknown-event", "event"]);
229 expect(items[1]).toEqual({
230 kind: "unknown-event",
231 sequence: 2,
232 eventType: "run.from-a-future-server",
233 });
234 // A known type with a broken payload is a real defect, not a newer event.
235 expect(() =>
236 decodeWireItems([
237 { ...detached("event-4", 4), event: { ...detached("event-4", 4).event, payload: {} } },
238 ]),
239 ).toThrow();
240 });
241
242 it("skips turn item types from a newer server in snapshots and turn-item events", () => {
243 const decodeWireItems = Schema.decodeUnknownSync(
244 Schema.toCodecJson(Schema.Array(OrchestrationV2RpcSchemas.subscribeThread.output)),
245 );
246 const decodeHistoryPage = Schema.decodeUnknownSync(
247 Schema.toCodecJson(OrchestrationV2ThreadHistoryPage),
248 );
249 const item = (id: string, type: string, extra: Record<string, unknown>) => ({
250 id,
251 type,
252 threadId: "thread-1",
253 runId: null,
254 nodeId: null,
255 providerThreadId: null,
256 providerTurnId: null,
257 nativeItemRef: null,
258 parentItemId: null,
259 ordinal: 1,
260 status: "completed",
261 title: null,
262 startedAt: null,
263 completedAt: null,
264 updatedAt: DateTime.formatIso(now),
265 ...extra,
266 });
267 const known = item("item-known", "system_notice", { message: "Hello" });
268 // A type no build of this client knows, standing in for a newer server's item.
269 const future = item("item-future", "hologram", { beam: "ref-1" });
270 const projected = (position: number, turnItem: { readonly id: string }) => ({
271 position,
272 visibility: "local",
273 sourceThreadId: "thread-1",
274 sourceItemId: turnItem.id,
275 item: turnItem,
276 });
277 const projection = {
278 thread: {
279 createdBy: "user",
280 creationSource: "web",
281 id: "thread-1",
282 projectId: "project-1",
283 title: "Thread",
284 providerInstanceId: "codex",
285 modelSelection: { instanceId: "codex", model: "gpt-5-codex" },
286 runtimeMode: "full-access",
287 interactionMode: "default",
288 branch: null,
289 worktreePath: null,
290 activeProviderThreadId: null,
291 lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: "thread-1" },
292 forkedFrom: null,
293 createdAt: DateTime.formatIso(now),
294 updatedAt: DateTime.formatIso(now),
295 archivedAt: null,
296 deletedAt: null,
297 },
298 runs: [],
299 attempts: [],
300 nodes: [],
301 subagents: [],
302 providerSessions: [],
303 providerThreads: [],
304 providerTurns: [],
305 runtimeRequests: [],
306 messages: [],
307 plans: [],
308 turnItems: [future, known],
309 checkpointScopes: [],
310 checkpoints: [],
311 contextHandoffs: [],
312 contextTransfers: [],
313 visibleTurnItems: [projected(0, future), projected(1, known)],
314 updatedAt: DateTime.formatIso(now),
315 };
316 const turnItemEvent = (sequence: number, payload: unknown) => ({
317 kind: "event",
318 sequence,
319 event: {
320 id: `event-${sequence}`,
321 type: "turn-item.updated",
322 threadId: "thread-1",
323 occurredAt: DateTime.formatIso(now),
324 payload,
325 },
326 });
327
328 const [snapshot, futureEvent, knownEvent] = decodeWireItems([
329 { kind: "snapshot", snapshotSequence: 1, projection },
330 turnItemEvent(2, future),
331 turnItemEvent(3, known),
332 ]);
333
334 expect(snapshot).toMatchObject({ kind: "snapshot" });
335 if (snapshot?.kind !== "snapshot") throw new Error("expected a snapshot");
336 expect(snapshot.projection.turnItems.map((turnItem) => turnItem.id)).toEqual(["item-known"]);
337 expect(snapshot.projection.visibleTurnItems.map((row) => row.item.id)).toEqual(["item-known"]);
338 expect(futureEvent).toEqual({
339 kind: "unknown-event",
340 sequence: 2,
341 eventType: "turn-item.updated",
342 });
343 expect(knownEvent).toMatchObject({
344 kind: "event",
345 event: { type: "turn-item.updated", payload: { id: "item-known", type: "system_notice" } },
346 });
347 expect(
348 decodeHistoryPage({
349 snapshotSequence: 1,
350 items: [projected(0, future), projected(1, known)],
351 nextCursor: null,
352 hasMoreHistory: false,
353 }).items.map((row) => row.item.id),
354 ).toEqual(["item-known"]);
355
356 // A known turn item type with a broken payload is a real defect, not a newer item.
357 const broken = item("item-broken", "system_notice", {});
358 expect(() => decodeWireItems([turnItemEvent(4, broken)])).toThrow();
359 expect(() =>
360 decodeWireItems([
361 {
362 kind: "snapshot",
363 snapshotSequence: 1,
364 projection: { ...projection, turnItems: [broken] },
365 },
366 ]),
367 ).toThrow();
368 });
369
370 it("negotiates bounded socket snapshots as an optional capability", () => {
371 expect(
372 decodeOrchestrationV2SubscribeThreadInput({
373 threadId: "thread-1",
374 acceptBoundedSnapshot: true,
375 }).acceptBoundedSnapshot,
376 ).toBe(true);
377 expect(
378 decodeOrchestrationV2SubscribeThreadInput({ threadId: "thread-1" }).acceptBoundedSnapshot,
379 ).toBeUndefined();
380
381 const legacyDecoded = decodeLegacySubscribeThreadInput({
382 threadId: "thread-1",
383 afterSequence: 12,
384 acceptBoundedSnapshot: true,
385 });
386 expect(legacyDecoded.afterSequence).toBe(12);
387 expect("acceptBoundedSnapshot" in legacyDecoded).toBe(false);
388 });
389
390 it("negotiates compact bounded turnItems without disturbing older peers", () => {
391 // Older servers strip the unknown opt-in, so they keep sending full turnItems.
392 const legacyDecoded = decodeLegacySubscribeThreadInput({
393 threadId: "thread-1",
394 acceptBoundedSnapshot: true,
395 acceptCompactTurnItems: true,
396 });
397 expect("acceptCompactTurnItems" in legacyDecoded).toBe(false);
398 expect(
399 decodeOrchestrationV2SubscribeThreadInput({
400 threadId: "thread-1",
401 acceptCompactTurnItems: true,
402 }).acceptCompactTurnItems,
403 ).toBe(true);
404
405 const decodeStreamItem = Schema.decodeUnknownSync(OrchestrationV2ThreadStreamItem);
406 const projection = decodeStreamItem({
407 kind: "snapshot",
408 snapshotSequence: 1,
409 projection: emptyThreadProjection(),
410 });
411 // Older servers never send the marker; newer clients treat absence as full turnItems.
412 expect(projection.kind === "snapshot" && projection.turnItemsOmitLocalVisible).toBe(undefined);
413 const marked = decodeStreamItem({
414 kind: "snapshot",
415 snapshotSequence: 1,
416 projection: emptyThreadProjection(),
417 turnItemsOmitLocalVisible: true,
418 });
419 expect(marked.kind === "snapshot" && marked.turnItemsOmitLocalVisible).toBe(true);
420 // The marker only means "omitted"; any other value is a protocol error.
421 expect(() =>
422 decodeStreamItem({
423 kind: "snapshot",
424 snapshotSequence: 1,
425 projection: emptyThreadProjection(),
426 turnItemsOmitLocalVisible: false,
427 }),
428 ).toThrow();
429 });
430
431 it("decodes persisted capability snapshots that predate runtimePolicy", () => {
432 // Events written before the field existed must replay; absent decodes to
433 // the weaker client-boundary guarantee so history never overclaims.
434 const legacyCapabilities = {
435 sessions: {
436 supportsMultipleProviderThreadsPerSession: false,
437 supportsModelSwitchInSession: false,
438 supportsProviderSwitchingViaHandoff: true,
439 supportsRuntimeModeSwitchInSession: false,
440 pendingRequestsSurviveRestart: false,
441 },
442 threads: {
443 canCreateEmptyThread: true,
444 canReadThreadSnapshot: false,
445 canRollbackThread: true,
446 canForkThread: false,
447 canForkFromTurn: false,
448 canForkFromSubagentThread: false,
449 exposesNativeThreadId: true,
450 },
451 turns: {
452 exposesNativeTurnId: false,
453 emitsTurnStarted: true,
454 emitsTurnCompleted: true,
455 supportsInterrupt: true,
456 supportsActiveSteering: false,
457 supportsSteeringByInterruptRestart: true,
458 supportsQueuedMessages: true,
459 terminalStatusQuality: "strong",
460 },
461 streaming: {
462 streamsAssistantText: true,
463 streamsReasoning: true,
464 streamsToolOutput: true,
465 streamsPlanText: false,
466 emitsMessageCompleted: true,
467 },
468 tools: {
469 exposesToolItemIds: true,
470 emitsToolStarted: true,
471 emitsToolCompleted: true,
472 emitsToolOutput: true,
473 supportsMcpTools: false,
474 supportsDynamicToolCallbacks: false,
475 },
476 approvals: {
477 supportsCommandApproval: true,
478 supportsFileReadApproval: true,
479 supportsFileChangeApproval: true,
480 supportsApplyPatchApproval: false,
481 approvalsHaveNativeRequestIds: false,
482 approvalCallbacksAreLiveOnly: true,
483 approvalsCanOriginateFromSubagents: false,
484 },
485 planning: {
486 emitsPlanUpdated: true,
487 emitsTodoList: true,
488 emitsProposedPlan: false,
489 supportsStructuredQuestions: true,
490 planDeltasHaveItemIds: false,
491 },
492 subagents: {
493 supportsSubagents: false,
494 exposesSubagentThreadIds: false,
495 emitsSubagentLifecycle: false,
496 canWaitForSubagents: false,
497 canCloseSubagents: false,
498 canForkSubagentThread: false,
499 },
500 context: {
501 acceptsSystemContext: false,
502 acceptsDeveloperContext: false,
503 acceptsSyntheticUserContext: true,
504 canGenerateSummaries: true,
505 canConsumeHandoffSummaries: true,
506 supportsDeltaHandoff: true,
507 supportsFullThreadHandoff: true,
508 maxRecommendedHandoffChars: null,
509 },
510 checkpointing: {
511 appCanCheckpointFilesystem: true,
512 supportsNestedCheckpointScopes: true,
513 providerCanRollbackConversation: true,
514 providerRollbackReturnsSnapshot: true,
515 providerCanReadConversationSnapshot: false,
516 },
517 identity: {
518 nativeThreadIds: "strong",
519 nativeTurnIds: "weak",
520 nativeItemIds: "weak",
521 nativeRequestIds: "weak",
522 },
523 };
524
525 const decoded = decodeOrchestrationV2ProviderCapabilities(legacyCapabilities);
526 expect(decoded.runtimePolicy).toEqual({ enforcement: "client-boundary" });
527
528 const explicit = decodeOrchestrationV2ProviderCapabilities({
529 ...legacyCapabilities,
530 runtimePolicy: { enforcement: "native" },
531 });
532 expect(explicit.runtimePolicy).toEqual({ enforcement: "native" });
533 });
534
535 it("lets legacy snapshot decoders ignore enrichment metadata", () => {
536 const decoded = decodeLegacyShellStreamItem({
537 kind: "snapshot",
538 snapshot: {
539 schemaVersion: 1,
540 snapshotSequence: 0,
541 projects: [],
542 threads: [],
543 archivedThreads: [],
544 },
545 resolvedRepositoryIdentityRoots: ["/workspace/project"],
546 });
547
548 expect(decoded.kind).toBe("snapshot");
549 expect("resolvedRepositoryIdentityRoots" in decoded).toBe(false);
550 });
551
552 it("decodes nested checkpoint scopes without making child scopes advance app run count", () => {
553 const rootScope = decodeOrchestrationV2CheckpointScope({
554 id: "scope-root-1",
555 threadId: "thread-1",
556 runId: "run-1",
557 nodeId: "node-root-1",
558 parentScopeId: null,
559 providerThreadId: "provider-thread-1",
560 kind: "root_run",
561 ordinalWithinParent: 1,
562 advancesAppRunCount: true,
563 cwd: "/tmp/project",
564 createdAt: now,
565 });
566 const childScope = decodeOrchestrationV2CheckpointScope({
567 id: "scope-child-1",
568 threadId: "thread-1",
569 runId: "run-1",
570 nodeId: "node-child-1",
571 parentScopeId: rootScope.id,
572 providerThreadId: "provider-thread-child-1",
573 kind: "subagent",
574 ordinalWithinParent: 1,
575 advancesAppRunCount: false,
576 cwd: "/tmp/project",
577 createdAt: now,
578 });
579
580 expect(rootScope.advancesAppRunCount).toBe(true);
581 expect(childScope.parentScopeId).toBe(rootScope.id);
582 expect(childScope.advancesAppRunCount).toBe(false);
583 });
584
585 it("decodes checkpoint captures that attach to scopes, nodes, and optional app run ordinals", () => {
586 const checkpoint = decodeOrchestrationV2Checkpoint({
587 id: "checkpoint-1",
588 threadId: "thread-1",
589 scopeId: "scope-child-1",
590 runId: "run-1",
591 nodeId: "node-child-1",
592 parentCheckpointId: "checkpoint-root-1",
593 ordinalWithinScope: 1,
594 appRunOrdinal: null,
595 ref: "git-ref-1",
596 status: "ready",
597 files: [{ path: "package.json", kind: "modified", additions: 2, deletions: 1 }],
598 capturedAt: now,
599 });
600
601 expect(checkpoint.appRunOrdinal).toBeNull();
602 expect(checkpoint.scopeId).toBe(CheckpointScopeId.make("scope-child-1"));
603 expect(checkpoint.parentCheckpointId).toBe(CheckpointId.make("checkpoint-root-1"));
604 });
605
606 it("decodes command and domain event shapes for command-to-projection tests", () => {
607 const command = decodeOrchestrationV2Command({
608 type: "message.dispatch",
609 createdBy: "user",
610 creationSource: "web",
611 commandId: "command-1",
612 threadId: "thread-1",
613 messageId: "message-1",
614 text: "hello",
615 attachments: [],
616 dispatchMode: { type: "start_immediately" },
617 });
618 const event = decodeOrchestrationV2DomainEvent({
619 id: "event-1",
620 type: "run.created",
621 threadId: "thread-1",
622 runId: "run-1",
623 occurredAt: now,
624 payload: {
625 id: "run-1",
626 threadId: "thread-1",
627 ordinal: 1,
628 providerInstanceId: "codex",
629 modelSelection: {
630 instanceId: "codex",
631 model: "gpt-5.4",
632 },
633 providerThreadId: "provider-thread-1",
634 userMessageId: "message-1",
635 rootNodeId: null,
636 activeAttemptId: null,
637 status: "queued",
638 requestedAt: now,
639 startedAt: null,
640 completedAt: null,
641 checkpointId: null,
642 contextHandoffId: null,
643 },
644 });
645
646 expect(command.commandId).toBe(CommandId.make("command-1"));
647 expect(event.id).toBe(EventId.make("event-1"));
648 if (event.type !== "run.created") {
649 throw new Error(`Expected run.created, received ${event.type}.`);
650 }
651 expect(event.payload.id).toBe(RunId.make("run-1"));
652 });
653
654 it("decodes app-owned delegated task commands", () => {
655 const command = decodeOrchestrationV2Command({
656 type: "delegated_task.request",
657 createdBy: "user",
658 creationSource: "web",
659 commandId: "command-delegate-1",
660 parentThreadId: "thread-parent-1",
661 parentRunId: "run-parent-1",
662 parentNodeId: "node-parent-1",
663 task: "Inspect the API boundary.",
664 title: "API inspection",
665 modelSelection: {
666 instanceId: "claudeAgent",
667 model: "claude-sonnet-4-6",
668 },
669 runtimeMode: "approval-required",
670 interactionMode: "plan",
671 });
672
673 expect(command.type).toBe("delegated_task.request");
674 if (command.type !== "delegated_task.request") {
675 throw new Error("expected delegated_task.request");
676 }
677 expect(command.parentThreadId).toBe(ThreadId.make("thread-parent-1"));
678 expect(command.parentRunId).toBe(RunId.make("run-parent-1"));
679 expect(command.parentNodeId).toBe(NodeId.make("node-parent-1"));
680 });
681
682 it("decodes delegated task wake-policy commands", () => {
683 const command = decodeOrchestrationV2Command({
684 type: "delegated_task.wake-policy",
685 commandId: "command-delegate-wake-policy-1",
686 parentThreadId: "thread-parent-1",
687 taskId: "node-subagent-1",
688 completionWake: "always",
689 });
690
691 expect(command.type).toBe("delegated_task.wake-policy");
692 if (command.type !== "delegated_task.wake-policy") {
693 throw new Error("expected delegated_task.wake-policy");
694 }
695 expect(command.parentThreadId).toBe(ThreadId.make("thread-parent-1"));
696 expect(command.taskId).toBe(NodeId.make("node-subagent-1"));
697 expect(command.completionWake).toBe("always");
698
699 // The policy carries the whole decision, so it is required and closed.
700 expect(() =>
701 decodeOrchestrationV2Command({
702 type: "delegated_task.wake-policy",
703 commandId: "command-delegate-wake-policy-2",
704 parentThreadId: "thread-parent-1",
705 taskId: "node-subagent-1",
706 }),
707 ).toThrow();
708 expect(() =>
709 decodeOrchestrationV2Command({
710 type: "delegated_task.wake-policy",
711 commandId: "command-delegate-wake-policy-3",
712 parentThreadId: "thread-parent-1",
713 taskId: "node-subagent-1",
714 completionWake: "sometimes",
715 }),
716 ).toThrow();
717 });
718
719 it("decodes durable created-thread timeline records", () => {
720 const command = decodeOrchestrationV2Command({
721 type: "thread.created.record",
722 commandId: "command-thread-record-1",
723 parentThreadId: "thread-parent-1",
724 parentRunId: "run-parent-1",
725 parentNodeId: "node-parent-1",
726 targetThreadId: "thread-child-1",
727 targetRunId: "run-child-1",
728 });
729 const item = decodeOrchestrationV2TurnItem({
730 id: "turn-item-thread-created-1",
731 type: "thread_created",
732 threadId: "thread-parent-1",
733 runId: "run-parent-1",
734 nodeId: "node-parent-1",
735 providerThreadId: "provider-thread-parent-1",
736 providerTurnId: null,
737 nativeItemRef: null,
738 parentItemId: null,
739 ordinal: 4,
740 status: "completed",
741 title: "Child thread",
742 targetThreadId: "thread-child-1",
743 targetRunId: "run-child-1",
744 targetProviderInstanceId: "claude-default",
745 targetModel: "claude-sonnet-4-6",
746 startedAt: now,
747 completedAt: now,
748 updatedAt: now,
749 });
750
751 expect(command.type).toBe("thread.created.record");
752 if (command.type !== "thread.created.record") {
753 throw new Error("expected thread.created.record");
754 }
755 expect(command.targetThreadId).toBe(ThreadId.make("thread-child-1"));
756 expect(item.type).toBe("thread_created");
757 if (item.type !== "thread_created") {
758 throw new Error("expected thread_created");
759 }
760 expect(item.targetRunId).toBe(RunId.make("run-child-1"));
761 });
762
763 it("decodes provider-neutral replay transcripts", () => {
764 const transcript = decodeProviderReplayTranscript({
765 provider: "codex",
766 protocol: "codex.app-server",
767 version: "0.120.0",
768 scenario: "simple",
769 metadata: {
770 source: "real-probe",
771 },
772 entries: [
773 {
774 type: "expect_outbound",
775 label: "initialize",
776 frame: { id: 1, method: "initialize" },
777 },
778 {
779 type: "emit_inbound",
780 label: "initialize-result",
781 frame: { id: 1, result: { ok: true } },
782 },
783 {
784 type: "runtime_exit",
785 status: "success",
786 },
787 ],
788 });
789
790 expect(transcript.entries).toHaveLength(3);
791 expect(transcript.protocol).toBe("codex.app-server");
792 });
793
794 it("decodes strictly typed turn items for known tools and dynamic fallback tools", () => {
795 const fileChange = decodeOrchestrationV2TurnItem({
796 id: "turn-item-file-change-1",
797 type: "file_change",
798 threadId: "thread-1",
799 runId: "run-1",
800 nodeId: "node-file-change-1",
801 providerThreadId: "provider-thread-1",
802 providerTurnId: "provider-turn-1",
803 nativeItemRef: { driver: "codex", nativeId: "item-file-change-1", strength: "strong" },
804 parentItemId: null,
805 ordinal: 3,
806 status: "completed",
807 title: "Edited package.json",
808 fileName: "package.json",
809 additions: 4,
810 deletions: 2,
811 diffStr: "@@ fixture diff",
812 startedAt: now,
813 completedAt: now,
814 updatedAt: now,
815 });
816 const dynamicTool = decodeOrchestrationV2TurnItem({
817 id: "turn-item-dynamic-1",
818 type: "dynamic_tool",
819 threadId: "thread-1",
820 runId: "run-1",
821 nodeId: "node-dynamic-1",
822 providerThreadId: "provider-thread-1",
823 providerTurnId: "provider-turn-1",
824 nativeItemRef: { driver: "codex", nativeId: "item-dynamic-1", strength: "strong" },
825 parentItemId: null,
826 ordinal: 4,
827 status: "completed",
828 title: "Custom tool",
829 toolName: "custom.lookup",
830 input: { query: "fixture" },
831 output: { ok: true },
832 startedAt: now,
833 completedAt: now,
834 updatedAt: now,
835 });
836
837 expect(fileChange.type).toBe("file_change");
838 if (fileChange.type !== "file_change") {
839 throw new Error("expected file_change");
840 }
841 expect(fileChange.fileName).toBe("package.json");
842 expect(fileChange.additions).toBe(4);
843 expect(dynamicTool.id).toBe(TurnItemId.make("turn-item-dynamic-1"));
844 });
845
846 it("decodes bounded provider failures as expected error turn items", () => {
847 const errorItem = decodeOrchestrationV2TurnItem({
848 id: "turn-item-error-1",
849 type: "error",
850 threadId: "thread-1",
851 runId: "run-1",
852 nodeId: "node-root-1",
853 providerThreadId: "provider-thread-1",
854 providerTurnId: "provider-turn-1",
855 nativeItemRef: null,
856 parentItemId: null,
857 ordinal: 199,
858 status: "failed",
859 title: "Provider error",
860 failure: {
861 class: "validation_error",
862 message: "Invalid reasoning effort.",
863 code: "invalid_request",
864 retryable: false,
865 },
866 startedAt: now,
867 completedAt: now,
868 updatedAt: now,
869 });
870
871 expect(errorItem.type).toBe("error");
872 if (errorItem.type !== "error") throw new Error("expected error item");
873 expect(errorItem.failure.message).toBe("Invalid reasoning effort.");
874 const usageLimited = decodeOrchestrationV2TurnItem({
875 ...errorItem,
876 startedAt: now,
877 completedAt: now,
878 updatedAt: now,
879 failure: { ...errorItem.failure, class: "usage_limit" },
880 });
881 expect(usageLimited.type === "error" && usageLimited.failure.class).toBe("usage_limit");
882 expect(() =>
883 decodeOrchestrationV2TurnItem({
884 ...errorItem,
885 failure: { ...errorItem.failure, message: "x".repeat(4_097) },
886 }),
887 ).toThrow();
888 });
889
890 it("decodes provider-native subagent lifecycle records and timeline items", () => {
891 const subagent = decodeOrchestrationV2Subagent({
892 id: "node-subagent-1",
893 threadId: "thread-1",
894 runId: "run-1",
895 parentNodeId: "node-root-1",
896 origin: "provider_native",
897 createdBy: "agent",
898 driver: "codex",
899 providerInstanceId: "codex",
900 providerThreadId: "provider-thread-subagent-1",
901 childThreadId: null,
902 nativeTaskRef: {
903 driver: "codex",
904 nativeId: "native-task-1",
905 strength: "strong",
906 },
907 prompt: "Inspect package.json",
908 title: "Package audit",
909 model: "gpt-5.4",
910 status: "completed",
911 progress: "Inspecting package metadata",
912 result: "Package is private.",
913 startedAt: now,
914 completedAt: now,
915 updatedAt: now,
916 });
917 const turnItem = decodeOrchestrationV2TurnItem({
918 id: "turn-item-subagent-1",
919 type: "subagent",
920 threadId: "thread-1",
921 runId: "run-1",
922 nodeId: subagent.id,
923 providerThreadId: subagent.providerThreadId,
924 providerTurnId: "provider-turn-1",
925 nativeItemRef: subagent.nativeTaskRef,
926 parentItemId: null,
927 ordinal: 2,
928 status: "completed",
929 title: subagent.title,
930 subagentId: subagent.id,
931 origin: subagent.origin,
932 driver: subagent.driver,
933 providerInstanceId: subagent.providerInstanceId,
934 childThreadId: subagent.childThreadId,
935 prompt: subagent.prompt,
936 progress: subagent.progress,
937 result: subagent.result,
938 startedAt: now,
939 completedAt: now,
940 updatedAt: now,
941 });
942
943 expect(subagent.origin).toBe("provider_native");
944 expect(subagent.progress).toBe("Inspecting package metadata");
945 expect(subagent.childThreadId).toBeNull();
946 expect(turnItem.type).toBe("subagent");
947 if (turnItem.type !== "subagent") throw new Error("expected subagent item");
948 expect(turnItem.progress).toBe("Inspecting package metadata");
949 });
950
951 it.each([" indented prompt\n", "\n \t\n"])(
952 "preserves workflow prompt and result %j in stored and wire JSON",
953 (preview) => {
954 const subagent = {
955 ...decodeOrchestrationV2Subagent({
956 id: "workflow-coordinator",
957 threadId: "thread-1",
958 runId: "run-1",
959 parentNodeId: "node-root-1",
960 origin: "provider_native",
961 createdBy: "agent",
962 driver: "claudeAgent",
963 providerInstanceId: "claudeAgent",
964 providerThreadId: null,
965 childThreadId: null,
966 nativeTaskRef: null,
967 prompt: "Run workflow",
968 title: null,
969 model: null,
970 status: "running",
971 result: null,
972 startedAt: now,
973 completedAt: null,
974 updatedAt: now,
975 }),
976 workflow: {
977 phases: [],
978 agents: [
979 {
980 index: 1,
981 label: "Member",
982 state: "completed" as const,
983 prompt: preview,
984 result: preview,
985 },
986 ],
987 },
988 };
989 const { workflow, ...coordinator } = subagent;
990 const incoming = { ...encodeOrchestrationV2SubagentJson(coordinator), workflow };
991 for (const codec of [
992 OrchestrationV2SubagentJson,
993 Schema.toCodecJson(OrchestrationV2Subagent),
994 ]) {
995 const encode = Schema.encodeSync(codec);
996 const decode = Schema.decodeUnknownSync(codec);
997 const json: unknown = JSON.parse(JSON.stringify(encode(subagent)));
998 expect(json).toMatchObject({
999 workflow: { agents: [{ prompt: preview, result: preview }] },
1000 });
1001 expect(decode(json).workflow?.agents[0]).toMatchObject({
1002 prompt: preview,
1003 result: preview,
1004 });
1005 expect(decode(incoming).workflow?.agents[0]).toMatchObject({
1006 prompt: preview,
1007 result: preview,
1008 });
1009 }
1010 },
1011 );
1012
1013 it("decodes app-owned subagent parent-wake policies", () => {
1014 const appOwnedSubagent = {
1015 id: "node-subagent-2",
1016 threadId: "thread-1",
1017 runId: "run-1",
1018 parentNodeId: "node-root-1",
1019 origin: "app_owned",
1020 createdBy: "agent",
1021 driver: "codex",
1022 providerInstanceId: "codex",
1023 providerThreadId: null,
1024 childThreadId: "thread-child-1",
1025 nativeTaskRef: null,
1026 prompt: "Inspect the API boundary.",
1027 title: "API inspection",
1028 model: "gpt-5.4",
1029 status: "running",
1030 result: null,
1031 startedAt: now,
1032 completedAt: null,
1033 updatedAt: now,
1034 };
1035 const decode = Schema.decodeUnknownSync(OrchestrationV2Subagent);
1036
1037 // Legacy records predate the field and must behave as settled_only.
1038 expect(decode(appOwnedSubagent).completionWake).toBeUndefined();
1039 expect(decode({ ...appOwnedSubagent, completionWake: "always" }).completionWake).toBe("always");
1040 expect(decode({ ...appOwnedSubagent, completionWake: "settled_only" }).completionWake).toBe(
1041 "settled_only",
1042 );
1043 expect(() => decode({ ...appOwnedSubagent, completionWake: "sometimes" })).toThrow();
1044 });
1045
1046 it("decodes thread projections with an ordered turn item rendering stream", () => {
1047 const projection = decodeOrchestrationV2ThreadProjection({
1048 thread: {
1049 createdBy: "user",
1050 creationSource: "web",
1051 id: "thread-1",
1052 projectId: "project-1",
1053 title: "Thread",
1054 providerInstanceId: "codex",
1055 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex" },
1056 runtimeMode: "full-access",
1057 interactionMode: "default",
1058 branch: null,
1059 worktreePath: null,
1060 activeProviderThreadId: null,
1061 lineage: {
1062 parentThreadId: null,
1063 relationshipToParent: null,
1064 rootThreadId: "thread-1",
1065 },
1066 forkedFrom: null,
1067 createdAt: now,
1068 updatedAt: now,
1069 archivedAt: null,
1070 deletedAt: null,
1071 },
1072 runs: [],
1073 attempts: [],
1074 nodes: [],
1075 subagents: [],
1076 providerSessions: [],
1077 providerThreads: [],
1078 providerTurns: [],
1079 runtimeRequests: [],
1080 messages: [],
1081 plans: [],
1082 turnItems: [
1083 {
1084 id: "turn-item-command-1",
1085 type: "command_execution",
1086 threadId: "thread-1",
1087 runId: "run-1",
1088 nodeId: "node-command-1",
1089 providerThreadId: "provider-thread-1",
1090 providerTurnId: "provider-turn-1",
1091 nativeItemRef: { driver: "codex", nativeId: "item-command-1", strength: "strong" },
1092 parentItemId: null,
1093 ordinal: 1,
1094 status: "completed",
1095 title: "Ran command",
1096 input: "bun typecheck",
1097 output: "Tasks: 10 successful",
1098 exitCode: 0,
1099 startedAt: now,
1100 completedAt: now,
1101 updatedAt: now,
1102 },
1103 ],
1104 visibleTurnItems: [
1105 {
1106 position: 0,
1107 visibility: "local",
1108 sourceThreadId: "thread-1",
1109 sourceItemId: "turn-item-command-1",
1110 item: {
1111 id: "turn-item-command-1",
1112 type: "command_execution",
1113 threadId: "thread-1",
1114 runId: "run-1",
1115 nodeId: "node-command-1",
1116 providerThreadId: "provider-thread-1",
1117 providerTurnId: "provider-turn-1",
1118 nativeItemRef: { driver: "codex", nativeId: "item-command-1", strength: "strong" },
1119 parentItemId: null,
1120 ordinal: 1,
1121 status: "completed",
1122 title: "Ran command",
1123 input: "bun typecheck",
1124 output: "Tasks: 10 successful",
1125 exitCode: 0,
1126 startedAt: now,
1127 completedAt: now,
1128 updatedAt: now,
1129 },
1130 },
1131 ],
1132 checkpointScopes: [],
1133 checkpoints: [],
1134 contextHandoffs: [],
1135 contextTransfers: [],
1136 updatedAt: now,
1137 });
1138
1139 expect(projection.turnItems.map((item) => item.type)).toEqual(["command_execution"]);
1140
1141 const boundedSnapshot = decodeOrchestrationV2ThreadStreamItem({
1142 kind: "snapshot",
1143 snapshotSequence: 12,
1144 projection,
1145 historyCursor: "older-page",
1146 hasMoreHistory: true,
1147 latestLocalTurnOrdinal: 1,
1148 payloadBudgetExceeded: false,
1149 });
1150 expect(boundedSnapshot).toMatchObject({
1151 kind: "snapshot",
1152 historyCursor: "older-page",
1153 hasMoreHistory: true,
1154 latestLocalTurnOrdinal: 1,
1155 payloadBudgetExceeded: false,
1156 });
1157 });
1158
1159 it("decodes orchestration lifecycle turn items for compaction, handoff, and fork UI", () => {
1160 const compaction = decodeOrchestrationV2TurnItem({
1161 id: "turn-item-compaction-1",
1162 type: "compaction",
1163 threadId: "thread-1",
1164 runId: "run-1",
1165 nodeId: "node-compaction-1",
1166 providerThreadId: "provider-thread-1",
1167 providerTurnId: "provider-turn-1",
1168 nativeItemRef: null,
1169 parentItemId: null,
1170 ordinal: 5,
1171 status: "running",
1172 title: "Compacting context...",
1173 driver: "codex",
1174 beforeTokenCount: 180000,
1175 startedAt: now,
1176 completedAt: null,
1177 updatedAt: now,
1178 });
1179 const handoff = decodeOrchestrationV2TurnItem({
1180 id: "turn-item-handoff-1",
1181 type: "handoff",
1182 threadId: "thread-1",
1183 runId: "run-2",
1184 nodeId: null,
1185 providerThreadId: "provider-thread-claude-1",
1186 providerTurnId: null,
1187 nativeItemRef: null,
1188 parentItemId: null,
1189 ordinal: 6,
1190 status: "completed",
1191 title: "Handed off to Claude",
1192 contextHandoffId: "handoff-1",
1193 fromProviderThreadIds: ["provider-thread-codex-1"],
1194 toProviderThreadId: "provider-thread-claude-1",
1195 fromProviderInstanceIds: ["codex"],
1196 toProviderInstanceId: "claudeAgent",
1197 strategy: "delta_since_target_last_seen",
1198 summary: "Codex completed the setup work.",
1199 startedAt: now,
1200 completedAt: now,
1201 updatedAt: now,
1202 });
1203 const fork = decodeOrchestrationV2TurnItem({
1204 id: "turn-item-fork-1",
1205 type: "fork",
1206 threadId: "thread-1",
1207 runId: "run-2",
1208 nodeId: "node-subagent-1",
1209 providerThreadId: "provider-thread-child-1",
1210 providerTurnId: null,
1211 nativeItemRef: null,
1212 parentItemId: null,
1213 ordinal: 7,
1214 status: "completed",
1215 title: "Forked subagent thread",
1216 source: { type: "node", nodeId: "node-subagent-1" },
1217 targetThreadId: "thread-fork-1",
1218 startedAt: now,
1219 completedAt: now,
1220 updatedAt: now,
1221 });
1222
1223 expect(compaction.type).toBe("compaction");
1224 expect(compaction.status).toBe("running");
1225 expect(handoff.type).toBe("handoff");
1226 if (handoff.type !== "handoff") {
1227 throw new Error("expected handoff");
1228 }
1229 expect(handoff.toProviderInstanceId).toBe("claudeAgent");
1230 expect(fork.type).toBe("fork");
1231 });
1232
1233 it("exports the V2 branded ids through the public contracts entrypoint", () => {
1234 expect(ThreadId.make("thread-1")).toBe("thread-1");
1235 expect(ProjectId.make("project-1")).toBe("project-1");
1236 expect(MessageId.make("message-1")).toBe("message-1");
1237 expect(NodeId.make("node-1")).toBe("node-1");
1238 expect(ProviderThreadId.make("provider-thread-1")).toBe("provider-thread-1");
1239 expect(CheckpointRef.make("git-ref-1")).toBe("git-ref-1");
1240 expect(ContextTransferId.make("context-transfer-1")).toBe("context-transfer-1");
1241 });
1242
1243 it("decodes historical provider-thread JSON without pendingBackgroundTasks as empty roster", () => {
1244 const providerThread = decodeOrchestrationV2ProviderThreadJson({
1245 id: "provider-thread-1",
1246 driver: "claude",
1247 providerInstanceId: "claudeAgent",
1248 providerSessionId: "provider-session-1",
1249 appThreadId: "thread-1",
1250 ownerNodeId: null,
1251 nativeThreadRef: {
1252 driver: "claude",
1253 nativeId: "native-session-1",
1254 strength: "strong",
1255 },
1256 nativeConversationHeadRef: null,
1257 status: "idle",
1258 firstRunOrdinal: 1,
1259 lastRunOrdinal: 1,
1260 handoffIds: [],
1261 forkedFrom: null,
1262 createdAt: "2026-04-20T00:00:00.000Z",
1263 updatedAt: "2026-04-20T00:00:00.000Z",
1264 });
1265
1266 expect(providerThread.pendingBackgroundTasks).toEqual([]);
1267 expect(providerThread.contextUsage).toBeNull();
1268 expect(providerThread.nativeMetadata).toBeNull();
1269
1270 const runtimeThread = decodeOrchestrationV2ProviderThread({
1271 id: "provider-thread-2",
1272 driver: "claude",
1273 providerInstanceId: "claudeAgent",
1274 providerSessionId: null,
1275 appThreadId: "thread-2",
1276 ownerNodeId: null,
1277 nativeThreadRef: null,
1278 nativeConversationHeadRef: null,
1279 status: "idle",
1280 firstRunOrdinal: null,
1281 lastRunOrdinal: null,
1282 handoffIds: [],
1283 forkedFrom: null,
1284 createdAt: now,
1285 updatedAt: now,
1286 });
1287 expect(runtimeThread.pendingBackgroundTasks).toEqual([]);
1288 expect(runtimeThread.contextUsage).toBeNull();
1289 expect(runtimeThread.nativeMetadata).toBeNull();
1290 });
1291
1292 it("decodes historical thread shell JSON without pendingBackgroundTasks as empty roster", () => {
1293 const shell = decodeOrchestrationV2ThreadShell({
1294 createdBy: "user",
1295 creationSource: "web",
1296 id: "thread-1",
1297 projectId: "project-1",
1298 title: "Thread",
1299 providerInstanceId: "claudeAgent",
1300 modelSelection: {
1301 instanceId: ProviderInstanceId.make("claudeAgent"),
1302 model: "claude-sonnet",
1303 },
1304 runtimeMode: "full-access",
1305 interactionMode: "default",
1306 branch: null,
1307 worktreePath: null,
1308 lineage: {
1309 parentThreadId: null,
1310 relationshipToParent: null,
1311 rootThreadId: "thread-1",
1312 },
1313 forkedFrom: null,
1314 activeProviderThreadId: "provider-thread-1",
1315 latestRunId: "run-1",
1316 activeRunId: null,
1317 status: "completed",
1318 pendingRuntimeRequest: null,
1319 latestVisibleMessage: null,
1320 latestUserMessageAt: null,
1321 hasActionableProposedPlan: false,
1322 itemCount: 0,
1323 visibleItemCount: 0,
1324 createdAt: now,
1325 updatedAt: now,
1326 archivedAt: null,
1327 settledOverride: null,
1328 settledAt: null,
1329 deletedAt: null,
1330 });
1331
1332 expect(shell.pendingBackgroundTasks).toEqual([]);
1333 });
1334});
1335
1336it("preserves tool cancellation and denial metadata through persisted and wire schemas", () => {
1337 for (const kind of ["cancelled", "denied", undefined]) {
1338 const item = decodeOrchestrationV2TurnItem({
1339 id: "tool-result",
1340 threadId: "thread",
1341 runId: null,
1342 nodeId: null,
1343 providerThreadId: null,
1344 providerTurnId: null,
1345 nativeItemRef: null,
1346 parentItemId: null,
1347 ordinal: 1,
1348 type: "dynamic_tool",
1349 toolName: "task_status",
1350 input: { taskId: "child" },
1351 status: kind === "cancelled" ? "cancelled" : "failed",
1352 ...(kind === undefined ? {} : { toolNonExecutionKind: kind }),
1353 title: null,
1354 startedAt: now,
1355 completedAt: now,
1356 updatedAt: now,
1357 output: "Tool did not execute",
1358 });
1359 const wire = encodeOrchestrationV2TurnItemJson(item);
1360 expect(wire.toolNonExecutionKind).toBe(kind);
1361 expect(decodeOrchestrationV2TurnItemJson(wire)).toEqual(item);
1362 }
1363});
1364
1365it("round-trips typed notifications and keeps work outcome separate from item status", () => {
1366 const now = DateTime.makeUnsafe("2026-09-09T00:00:00Z");
1367 const base = {
1368 id: "notification",
1369 threadId: "parent",
1370 runId: null,
1371 nodeId: null,
1372 providerThreadId: null,
1373 providerTurnId: null,
1374 nativeItemRef: null,
1375 parentItemId: null,
1376 ordinal: 1,
1377 status: "completed",
1378 title: null,
1379 startedAt: null,
1380 completedAt: null,
1381 type: "notification",
1382 outcome: "failed",
1383 summary: "Build failed",
1384 detail: "Exit code 1",
1385 };
1386 for (const source of [
1387 { kind: "delegated_task", taskIds: ["task-1", "task-2"] },
1388 { kind: "delegated_task", taskIds: ["task-1"], childThreadId: "child" },
1389 { kind: "subagent", childThreadId: "child" },
1390 { kind: "subagent" },
1391 { kind: "command" },
1392 { kind: "monitor" },
1393 { kind: "background_task" },
1394 ]) {
1395 const runtime = decodeOrchestrationV2TurnItem({ ...base, source, updatedAt: now });
1396 const wire = encodeOrchestrationV2TurnItemJson(runtime);
1397 expect(decodeOrchestrationV2TurnItemJson(wire)).toEqual(runtime);
1398 expect(runtime).toMatchObject({ status: "completed", outcome: "failed", source });
1399 expect(runtime).not.toHaveProperty("messageId");
1400 expect(() =>
1401 decodeOrchestrationV2TurnItem({ ...base, source, summary: "", updatedAt: now }),
1402 ).toThrow();
1403 }
1404 // A known kind with fields that do not decode is a real defect, not a newer kind.
1405 expect(() =>
1406 decodeOrchestrationV2TurnItem({ ...base, source: { kind: "delegated_task" }, updatedAt: now }),
1407 ).toThrow();
1408 expect(() =>
1409 decodeOrchestrationV2TurnItem({
1410 ...base,
1411 source: { kind: "subagent", childThreadId: 7 },
1412 updatedAt: now,
1413 }),
1414 ).toThrow();
1415});
1416
1417describe("background work kinds from older or newer servers", () => {
1418 const storedNotification = (source: unknown) => ({
1419 id: "notification",
1420 threadId: "parent",
1421 runId: null,
1422 nodeId: null,
1423 providerThreadId: null,
1424 providerTurnId: null,
1425 nativeItemRef: null,
1426 parentItemId: null,
1427 ordinal: 1,
1428 status: "completed",
1429 title: null,
1430 startedAt: null,
1431 completedAt: null,
1432 updatedAt: "2026-09-09T00:00:00.000Z",
1433 type: "notification",
1434 outcome: "completed",
1435 summary: "Background activity updated",
1436 source,
1437 });
1438
1439 it("decodes notification sources stored before specific kinds existed", () => {
1440 const sourceOf = (source: unknown) => {
1441 const item = decodeOrchestrationV2TurnItemJson(storedNotification(source));
1442 return item.type === "notification" ? item.source : undefined;
1443 };
1444 expect(
1445 sourceOf({ kind: "background_task", nativeRef: { driver: "claude", nativeId: "task-1" } }),
1446 ).toEqual({ kind: "background_task" });
1447 expect(sourceOf({ kind: "background_command" })).toEqual({ kind: "command" });
1448 expect(sourceOf({ kind: "monitor" })).toEqual({ kind: "monitor" });
1449 });
1450
1451 it("sends and stores sources that clients from before specific kinds still decode", () => {
1452 // The notification source schema clients shipped with before #13948. They
1453 // reject a kind outside it, which fails the whole thread load.
1454 const PreSpecificKindsNotification = Schema.Struct({
1455 type: Schema.Literal("notification"),
1456 source: Schema.Union([
1457 Schema.Struct({ kind: Schema.Literal("delegated_task"), taskIds: Schema.Array(NodeId) }),
1458 Schema.Struct({
1459 kind: Schema.Literals(["background_task", "background_command", "monitor"]),
1460 nativeRef: Schema.optional(Schema.Unknown),
1461 }),
1462 ]),
1463 outcome: Schema.Literals(["completed", "failed", "cancelled", "updated", "unknown"]),
1464 summary: Schema.String,
1465 detail: Schema.optional(Schema.String),
1466 });
1467 const decodePreSpecificKinds = Schema.decodeUnknownSync(PreSpecificKindsNotification);
1468 const sendOverWire = Schema.encodeSync(Schema.toCodecJson(OrchestrationV2TurnItem));
1469 const cases = [
1470 [{ kind: "subagent", childThreadId: "child" }, { kind: "background_task" }],
1471 [{ kind: "subagent" }, { kind: "background_task" }],
1472 [{ kind: "command" }, { kind: "background_command" }],
1473 [{ kind: "monitor" }, { kind: "monitor" }],
1474 [{ kind: "background_task" }, { kind: "background_task" }],
1475 [
1476 { kind: "delegated_task", taskIds: ["task-1"], childThreadId: "child" },
1477 { kind: "delegated_task", taskIds: ["task-1"] },
1478 ],
1479 ] as const;
1480 for (const [source, preSpecificKindsSource] of cases) {
1481 const item = decodeOrchestrationV2TurnItemJson(storedNotification(source));
1482 for (const encoded of [encodeOrchestrationV2TurnItemJson(item), sendOverWire(item)]) {
1483 expect(decodePreSpecificKinds(encoded).source).toEqual(preSpecificKindsSource);
1484 // Current clients read the specific kind back.
1485 expect(decodeOrchestrationV2TurnItemJson(encoded)).toEqual(item);
1486 }
1487 expect(item).toMatchObject({ source });
1488 }
1489 });
1490
1491 it("decodes a notification source kind from a newer server as generic background work", () => {
1492 const item = decodeOrchestrationV2TurnItemJson(
1493 storedNotification({ kind: "workflow", workflowId: "wf-1" }),
1494 );
1495 expect(item).toMatchObject({ type: "notification", source: { kind: "background_task" } });
1496 });
1497
1498 it("decodes rosters stored before kinds existed, and kinds from a newer server, as generic tasks", () => {
1499 const providerThread = decodeOrchestrationV2ProviderThreadJson({
1500 id: "provider-thread-1",
1501 driver: "claude",
1502 providerInstanceId: "claudeAgent",
1503 providerSessionId: null,
1504 appThreadId: "thread-1",
1505 ownerNodeId: null,
1506 nativeThreadRef: null,
1507 nativeConversationHeadRef: null,
1508 status: "idle",
1509 firstRunOrdinal: 1,
1510 lastRunOrdinal: 1,
1511 handoffIds: [],
1512 forkedFrom: null,
1513 createdAt: "2026-04-20T00:00:00.000Z",
1514 updatedAt: "2026-04-20T00:00:00.000Z",
1515 pendingBackgroundTasks: [
1516 { taskId: "bg-1", description: "Background sleep", taskType: "local_bash" },
1517 { taskId: "bg-2" },
1518 { taskId: "bg-3", description: "Nightly", kind: "workflow", schedule: "0 3 * * *" },
1519 { taskId: "bg-4", description: "npm test", kind: "command" },
1520 { taskId: "bg-5", kind: "subagent", childThreadId: "thread-child" },
1521 ],
1522 });
1523 expect(providerThread.pendingBackgroundTasks).toEqual([
1524 { taskId: "bg-1", description: "Background sleep", kind: "background_task" },
1525 { taskId: "bg-2", kind: "background_task" },
1526 { taskId: "bg-3", description: "Nightly", kind: "background_task" },
1527 { taskId: "bg-4", description: "npm test", kind: "command" },
1528 { taskId: "bg-5", kind: "subagent", childThreadId: "thread-child" },
1529 ]);
1530 // The fallback is decode-only: what was decoded encodes as its known member.
1531 const encoded = encodeOrchestrationV2ProviderThreadJson(providerThread).pendingBackgroundTasks;
1532 expect(encoded).toEqual(providerThread.pendingBackgroundTasks);
1533 // Clients from before kinds read a roster entry as this struct and ignore `kind`.
1534 const decodePreKindsRoster = Schema.decodeUnknownSync(
1535 Schema.Array(
1536 Schema.Struct({
1537 taskId: TrimmedNonEmptyString,
1538 description: Schema.optional(TrimmedNonEmptyString),
1539 taskType: Schema.optional(TrimmedNonEmptyString),
1540 }),
1541 ),
1542 );
1543 expect(decodePreKindsRoster(encoded).map((task) => task.taskId)).toEqual([
1544 "bg-1",
1545 "bg-2",
1546 "bg-3",
1547 "bg-4",
1548 "bg-5",
1549 ]);
1550 expect(() =>
1551 decodeOrchestrationV2ProviderThreadJson({
1552 ...encodeOrchestrationV2ProviderThreadJson(providerThread),
1553 pendingBackgroundTasks: [{ taskId: "", kind: "command" }],
1554 }),
1555 ).toThrow();
1556 });
1557});
1558
1559describe("limit recovery choice updates", () => {
1560 const decode = Schema.decodeUnknownSync(OrchestrationV2LimitRecoveryUpdate);
1561 const identity = { runId: "run:limited", resetAt: "2026-09-20T21:00:00.000Z" };
1562 it("rejects updates that would disable defaults without choosing an option", () => {
1563 expect(() => decode(identity)).toThrow("A recovery update must include autoResume or snooze");
1564 });
1565 it.each([
1566 { autoResume: true },
1567 { autoResume: false },
1568 { snooze: true },
1569 { snooze: false },
1570 { autoResume: true, snooze: false },
1571 ])("accepts an explicit independent choice %j", (choice) => {
1572 expect(decode({ ...identity, ...choice })).toEqual({ ...identity, ...choice });
1573 });
1574});
1575
1576describe("latestProviderTurnForAttempt", () => {
1577 it("returns the attempt's highest-ordinal turn, as a Codex goal run spans several", () => {
1578 const turns = [
1579 { id: "first", runAttemptId: RunAttemptId.make("goal-attempt"), ordinal: 3 },
1580 { id: "other", runAttemptId: RunAttemptId.make("other-attempt"), ordinal: 9 },
1581 { id: "last", runAttemptId: RunAttemptId.make("goal-attempt"), ordinal: 5 },
1582 { id: "subagent", runAttemptId: null, ordinal: 7 },
1583 ];
1584 expect(latestProviderTurnForAttempt(turns, RunAttemptId.make("goal-attempt"))?.id).toBe("last");
1585 expect(latestProviderTurnForAttempt(turns, null)).toBeUndefined();
1586 });
1587});