| 1 | import { assert } from "@effect/vitest"; |
| 2 | import type { ProviderReplayTranscript } from "@t3tools/contracts"; |
| 3 | |
| 4 | import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts"; |
| 5 | import { |
| 6 | assertBaseProjection, |
| 7 | assertSemanticProjectionIntegrity, |
| 8 | assertUserMessagesInclude, |
| 9 | backgroundNotifications, |
| 10 | projectionFor, |
| 11 | } from "../shared.ts"; |
| 12 | import { GROK_BACKGROUND_SUBAGENT_PROMPT } from "./input.ts"; |
| 13 | |
| 14 | function runAssistantTexts( |
| 15 | projection: ReturnType<typeof projectionFor>, |
| 16 | runId: string | undefined, |
| 17 | ): ReadonlyArray<string> { |
| 18 | return projection.turnItems.flatMap((item) => |
| 19 | item.runId === runId && item.type === "assistant_message" ? [item.text.trim()] : [], |
| 20 | ); |
| 21 | } |
| 22 | |
| 23 | // A background subagent outlives its root turn. Grok ends it only with the |
| 24 | // root-session `subagent_finished` notification: the spawn tool completed at |
| 25 | // launch and nothing names the subagent done in text. Run 1 is held open for |
| 26 | // the subagent and completes with it; Grok's own reply to the finished |
| 27 | // subagent (its `subagent-completed-*` wake) is a provider continuation. |
| 28 | export function assertGrokBackgroundSubagentOutput( |
| 29 | result: OrchestratorV2ScenarioResult, |
| 30 | transcript: ProviderReplayTranscript, |
| 31 | ) { |
| 32 | assertBaseProjection({ |
| 33 | result, |
| 34 | transcript, |
| 35 | runCount: 2, |
| 36 | runStatuses: ["completed", "completed"], |
| 37 | }); |
| 38 | const projection = projectionFor(result, transcript.scenario); |
| 39 | assertSemanticProjectionIntegrity(projection); |
| 40 | assertUserMessagesInclude(projection, [GROK_BACKGROUND_SUBAGENT_PROMPT]); |
| 41 | const [rootRun, wakeRun] = projection.runs; |
| 42 | |
| 43 | assert.lengthOf(projection.subagents, 1); |
| 44 | const subagent = projection.subagents[0]; |
| 45 | assert.equal(subagent?.status, "completed"); |
| 46 | assert.equal(subagent?.origin, "provider_native"); |
| 47 | assert.equal(subagent?.runId, rootRun?.id, "the subagent belongs to the run that spawned it"); |
| 48 | const subagentItem = projection.turnItems.find((item) => item.type === "subagent"); |
| 49 | assert.equal(subagentItem?.status, "completed"); |
| 50 | assert.equal(subagentItem?.runId, rootRun?.id); |
| 51 | |
| 52 | // Run 1 settles only after the subagent's structured end. |
| 53 | const subagentCompletedAt = result.domainEvents.findIndex( |
| 54 | (event) => |
| 55 | event.type === "turn-item.updated" && |
| 56 | event.payload.type === "subagent" && |
| 57 | event.payload.status === "completed", |
| 58 | ); |
| 59 | const rootCompletedAt = result.domainEvents.findIndex( |
| 60 | (event) => |
| 61 | event.type === "run.updated" && |
| 62 | event.payload.id === rootRun?.id && |
| 63 | event.payload.status === "completed", |
| 64 | ); |
| 65 | assert.isAtLeast(subagentCompletedAt, 0); |
| 66 | assert.isAbove(rootCompletedAt, subagentCompletedAt, "run 1 completed before the subagent"); |
| 67 | assert.include(runAssistantTexts(projection, rootRun?.id), "ROOT_DONE"); |
| 68 | |
| 69 | // Grok ends ROOT_DONE with its prompt; the subagent running on must not keep |
| 70 | // the reply streaming. |
| 71 | const rootDoneCompletedAt = result.domainEvents.findIndex( |
| 72 | (event) => |
| 73 | event.type === "turn-item.updated" && |
| 74 | event.payload.runId === rootRun?.id && |
| 75 | event.payload.type === "assistant_message" && |
| 76 | event.payload.text.trim() === "ROOT_DONE" && |
| 77 | event.payload.status === "completed", |
| 78 | ); |
| 79 | assert.isAtLeast(rootDoneCompletedAt, 0); |
| 80 | assert.isBelow( |
| 81 | rootDoneCompletedAt, |
| 82 | subagentCompletedAt, |
| 83 | "ROOT_DONE streamed until the subagent finished", |
| 84 | ); |
| 85 | |
| 86 | // The subagent's own reply stays in its child thread. |
| 87 | if (subagent?.childThreadId == null) { |
| 88 | throw new Error("The background subagent is missing its child thread."); |
| 89 | } |
| 90 | const child = result.projections.get(subagent.childThreadId); |
| 91 | assert.isDefined(child); |
| 92 | assert.isTrue( |
| 93 | child?.turnItems.some( |
| 94 | (item) => item.type === "assistant_message" && item.text.includes("SUBAGENT_DONE"), |
| 95 | ), |
| 96 | "the subagent's reply must land in its child thread", |
| 97 | ); |
| 98 | assert.notInclude(runAssistantTexts(projection, rootRun?.id), "SUBAGENT_DONE"); |
| 99 | |
| 100 | // The timeline says which subagent finished, and opens its thread. |
| 101 | assert.deepEqual(backgroundNotifications(projection), [ |
| 102 | { |
| 103 | summary: 'Subagent "Sleep then reply done" finished', |
| 104 | outcome: "completed", |
| 105 | source: { kind: "subagent", childThreadId: subagent.childThreadId }, |
| 106 | }, |
| 107 | ]); |
| 108 | |
| 109 | // Grok's reply to the finished subagent is a provider continuation. |
| 110 | const wakeMessage = projection.messages.find((message) => message.id === wakeRun?.userMessageId); |
| 111 | assert.equal(`${wakeMessage?.createdBy}:${wakeMessage?.creationSource}`, "agent:provider"); |
| 112 | assert.isNotEmpty( |
| 113 | runAssistantTexts(projection, wakeRun?.id), |
| 114 | "Grok's reply to the finished subagent must land in the continuation run", |
| 115 | ); |
| 116 | } |