1import { assert } from "@effect/vitest";
2import type { OrchestrationV2ThreadProjection, ProviderReplayTranscript } from "@t3tools/contracts";
3
4import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts";
5import {
6 assertBaseProjection,
7 assertProviderNativeSubagentRootTurns,
8 assertSemanticProjectionIntegrity,
9 assertUserMessagesInclude,
10 backgroundNotifications,
11 OPENCODE2_BACKGROUND_PROMPT,
12 projectionFor,
13} from "../shared.ts";
14
15const runTexts = (projection: OrchestrationV2ThreadProjection, runId: string | undefined) =>
16 projection.turnItems.flatMap((item) =>
17 item.runId === runId && item.type === "assistant_message" ? [item.text.trim()] : [],
18 );
19
20/**
21 * A background `subagent` call. The parent's first execution ends at once,
22 * so run 1 waits on the subagent. When it ends, OpenCode reports it to the
23 * parent and runs the parent again on its own: that execution is run 2, a
24 * continuation T3 opens for it, and nothing stays waiting afterwards.
25 */
26export function assertOpenCode2BackgroundOutput(
27 result: OrchestratorV2ScenarioResult,
28 transcript: ProviderReplayTranscript,
29) {
30 assertBaseProjection({
31 result,
32 transcript,
33 runCount: 2,
34 runStatuses: ["completed", "completed"],
35 });
36 const projection = projectionFor(result, transcript.scenario);
37 assertSemanticProjectionIntegrity(projection);
38 assertProviderNativeSubagentRootTurns(result);
39 assertUserMessagesInclude(projection, [OPENCODE2_BACKGROUND_PROMPT]);
40 const [rootRun, wakeRun] = projection.runs;
41
42 assert.lengthOf(projection.subagents, 1);
43 const subagent = projection.subagents[0]!;
44 assert.deepInclude(subagent, {
45 origin: "provider_native",
46 status: "completed",
47 runId: rootRun!.id,
48 title: "Sleep and reply",
49 result: "CHILD_OK",
50 });
51 assert.deepEqual(runTexts(projection, rootRun?.id), ["PARENT_OK"]);
52
53 // Run 1's turn ends while the subagent runs: the run parks at `waiting` on
54 // that background work, and the wake follows the subagent's end.
55 const indexOf = (predicate: (event: (typeof result.domainEvents)[number]) => boolean) =>
56 result.domainEvents.findIndex(predicate);
57 const rootWaiting = indexOf(
58 (event) =>
59 event.type === "run.updated" &&
60 event.payload.id === rootRun?.id &&
61 event.payload.status === "waiting",
62 );
63 const subagentDone = indexOf(
64 (event) =>
65 event.type === "turn-item.updated" &&
66 event.payload.type === "subagent" &&
67 event.payload.status === "completed",
68 );
69 const wakeStarted = indexOf(
70 (event) => event.type === "run.updated" && event.payload.id === wakeRun?.id,
71 );
72 assert.isAtLeast(rootWaiting, 0);
73 assert.isAbove(subagentDone, rootWaiting, "run 1 ends its turn before its subagent ends");
74 assert.isAbove(wakeStarted, subagentDone, "the wake follows the subagent's end");
75
76 // The child's own tool call and answer stay in its thread.
77 const child = result.projections.get(subagent.childThreadId!);
78 assert.isDefined(child);
79 assert.isTrue(
80 child.turnItems.some(
81 (item) => item.type === "command_execution" && item.input.includes("sleep 20"),
82 ),
83 );
84 assert.isTrue(
85 child.turnItems.some((item) => item.type === "assistant_message" && item.text === "CHILD_OK"),
86 );
87 assert.isFalse(
88 projection.turnItems.some((item) => item.type === "command_execution"),
89 "the child's shell call must not land on the parent",
90 );
91
92 // The continuation is a provider wake that names the subagent, and it
93 // carries the parent's own answer to the report.
94 const wakeMessage = projection.messages.find((message) => message.id === wakeRun?.userMessageId);
95 assert.equal(`${wakeMessage?.createdBy}:${wakeMessage?.creationSource}`, "agent:provider");
96 assert.include(wakeMessage?.text ?? "", "CHILD_OK");
97 assert.deepEqual(backgroundNotifications(projection), [
98 {
99 summary: 'Subagent "Sleep and reply" finished',
100 outcome: "completed",
101 source: { kind: "subagent", childThreadId: subagent.childThreadId! },
102 },
103 ]);
104 assert.deepEqual(runTexts(projection, wakeRun?.id), [
105 "The background subagent finished and returned `CHILD_OK`.",
106 ]);
107
108 // Nothing is left waiting once the subagent and the wake ended.
109 const shell = result.shellSnapshot.threads.find((thread) => thread.id === projection.thread.id);
110 assert.deepEqual(shell?.pendingBackgroundTasks ?? [], []);
111 assert.isFalse(
112 projection.turnItems.some((item) => item.status === "running" || item.status === "waiting"),
113 );
114}