| 1 | import { assert } from "@effect/vitest"; |
| 2 | import type { OrchestrationV2ThreadProjection, ProviderReplayTranscript } from "@t3tools/contracts"; |
| 3 | |
| 4 | import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts"; |
| 5 | import { |
| 6 | assertBaseProjection, |
| 7 | assertProviderNativeSubagentRootTurns, |
| 8 | assertSemanticProjectionIntegrity, |
| 9 | assertUserMessagesInclude, |
| 10 | backgroundNotifications, |
| 11 | OPENCODE2_BACKGROUND_PROMPT, |
| 12 | projectionFor, |
| 13 | } from "../shared.ts"; |
| 14 | |
| 15 | const 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 | */ |
| 26 | export 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 | } |