| author | |
| committer | |
| log | b9afba59f435e2e0d6368392b49f10d187125a09 |
| tree | 5bba699a1d1639bfc1fa15200d0e0a05f4c2489a |
| parent | a908a7ac8780c5b4d6fb0bed8945d640f69d2d1d |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: GPT-6.1-Sol (Codex) <noreply@openai.com>
Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>56 files changed, 7214 insertions(+), 659 deletions(-)
apps/server/src/auth/RpcAuthorization.test.ts+7| ... | ... | @@ -8,6 +8,7 @@ import { |
| 8 | 8 | ProviderInstanceId, |
| 9 | 9 | ThreadId, |
| 10 | 10 | AuthOrchestrationOperateScope, |
| 11 | ORCHESTRATION_V2_WS_METHODS, | |
| 11 | 12 | AuthOrchestrationReadScope, |
| 12 | 13 | AuthSourceControlWriteScope, |
| 13 | 14 | AuthPreviewOperateScope, |
| ... | ... | @@ -35,6 +36,12 @@ describe("RPC authorization scopes", () => { |
| 35 | 36 | expect(new Set(Object.keys(RPC_REQUIRED_SCOPES))).toEqual(new Set(WsRpcGroup.requests.keys())); |
| 36 | 37 | }); |
| 37 | 38 | |
| 39 | it("requires operate access to stop a workflow", () => { | |
| 40 | expect(requiredScopeForRpcMethod(ORCHESTRATION_V2_WS_METHODS.stopWorkflow)).toBe( | |
| 41 | AuthOrchestrationOperateScope, | |
| 42 | ); | |
| 43 | }); | |
| 44 | ||
| 38 | 45 | it("authorizes background policy reporting and observation deliberately", () => { |
| 39 | 46 | expect(requiredScopeForRpcMethod(WS_METHODS.serverReportClientActivity)).toBe( |
| 40 | 47 | AuthOrchestrationReadScope, |
apps/server/src/auth/RpcAuthorization.ts+1| ... | ... | @@ -46,6 +46,7 @@ export const RPC_REQUIRED_SCOPES = { |
| 46 | 46 | ...CLIENT_GUARDED_RPC_SCOPES, |
| 47 | 47 | [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: AuthOrchestrationOperateScope, |
| 48 | 48 | [ORCHESTRATION_V2_WS_METHODS.getWorkflowScript]: AuthOrchestrationReadScope, |
| 49 | [ORCHESTRATION_V2_WS_METHODS.stopWorkflow]: AuthOrchestrationOperateScope, | |
| 49 | 50 | [ORCHESTRATION_V2_WS_METHODS.getTurnDiff]: AuthOrchestrationReadScope, |
| 50 | 51 | [ORCHESTRATION_V2_WS_METHODS.getFullThreadDiff]: AuthOrchestrationReadScope, |
| 51 | 52 | [ORCHESTRATION_V2_WS_METHODS.searchThreads]: AuthOrchestrationReadScope, |
apps/server/src/mcp/OrchestratorMcpService.ts+1| ... | ... | @@ -879,6 +879,7 @@ function timelineItem(input: { |
| 879 | 879 | visibility: input.row.visibility, |
| 880 | 880 | sourceThreadId: input.row.sourceThreadId, |
| 881 | 881 | itemId: input.row.sourceItemId, |
| 882 | ...(input.row.item.type === "subagent" ? { subagentId: input.row.item.subagentId } : {}), | |
| 882 | 883 | runId: input.row.item.runId, |
| 883 | 884 | messageId, |
| 884 | 885 | createdBy: message?.createdBy ?? null, |
apps/server/src/mcp/toolkits/core.test.ts+132-21| ... | ... | @@ -8,9 +8,14 @@ import { |
| 8 | 8 | ChatImageAttachment, |
| 9 | 9 | CommandId, |
| 10 | 10 | EnvironmentId, |
| 11 | NodeId, | |
| 12 | OrchestratorMcpThreadReadResult, | |
| 13 | ProviderDriverKind, | |
| 11 | 14 | ProviderInstanceId, |
| 12 | 15 | RunId, |
| 13 | 16 | ThreadId, |
| 17 | TurnItemId, | |
| 18 | type OrchestrationV2ProjectedTurnItem, | |
| 14 | 19 | type OrchestrationV2ThreadShell, |
| 15 | 20 | } from "@t3tools/contracts"; |
| 16 | 21 | import * as Effect from "effect/Effect"; |
| ... | ... | @@ -69,6 +74,7 @@ import { |
| 69 | 74 | import { htmlRenderFromToolItem } from "@t3tools/shared/toolOutput"; |
| 70 | 75 | |
| 71 | 76 | const decodeMcpAttachmentInput = Schema.decodeUnknownEffect(McpAttachmentInput); |
| 77 | const decodeThreadReadResult = Schema.decodeUnknownEffect(OrchestratorMcpThreadReadResult); | |
| 72 | 78 | |
| 73 | 79 | // Registration asks for every service the thread tools declare; these cases call none that use them. |
| 74 | 80 | const layerThreadToolkit = McpHttpServer.layerThreadToolkit.pipe( |
| ... | ... | @@ -663,16 +669,127 @@ function scheduledTask(id: string, runtimeMode: "auto" | "full-access"): never { |
| 663 | 669 | } as never; |
| 664 | 670 | } |
| 665 | 671 | |
| 666 | it.effect("a caller cannot interrupt a thread that runs above its own modes", () => | |
| 667 | Effect.gen(function* () { | |
| 672 | it.effect.each(["t3_thread_interrupt", "t3_workflow_stop"])( | |
| 673 | "a caller cannot use %s on a thread that runs above its own modes", | |
| 674 | (toolName) => | |
| 675 | Effect.gen(function* () { | |
| 676 | const server = yield* McpServer.McpServer; | |
| 677 | const result = yield* server | |
| 678 | .callTool({ | |
| 679 | name: toolName, | |
| 680 | arguments: { | |
| 681 | threadId: "full-access-thread", | |
| 682 | ...(toolName === "t3_workflow_stop" ? { subagentId: "workflow-coordinator" } : {}), | |
| 683 | }, | |
| 684 | }) | |
| 685 | .pipe( | |
| 686 | Effect.provideService(McpInvocationContext.McpInvocationContext, clientScope("auto")), | |
| 687 | Effect.provideService(McpSchema.McpServerClient, client), | |
| 688 | ); | |
| 689 | expect(declaredFailure(result)).toMatchObject({ code: "runtime_mode_escalation_denied" }); | |
| 690 | }).pipe( | |
| 691 | Effect.provide( | |
| 692 | McpHttpServer.layerOrchestratorToolkit.pipe( | |
| 693 | Layer.provideMerge(McpServer.McpServer.layer), | |
| 694 | Layer.provide(NodeCrypto.layer), | |
| 695 | Layer.provide( | |
| 696 | Layer.mock(ThreadManagement.ThreadManagementService)({ | |
| 697 | getThreadShell: () => | |
| 698 | Effect.succeed({ | |
| 699 | projectId: "project-a", | |
| 700 | deletedAt: null, | |
| 701 | runtimeMode: "full-access", | |
| 702 | interactionMode: "default", | |
| 703 | } as never), | |
| 704 | getProjectThreadRecords: () => | |
| 705 | Effect.succeed({ | |
| 706 | thread: { | |
| 707 | id: ThreadId.make("full-access-thread"), | |
| 708 | projectId: "project-a", | |
| 709 | runtimeMode: "full-access", | |
| 710 | interactionMode: "default", | |
| 711 | deletedAt: null, | |
| 712 | }, | |
| 713 | runs: [], | |
| 714 | } as never), | |
| 715 | stopWorkflow: () => Effect.die("workflow stop must not dispatch above the ceiling"), | |
| 716 | interruptThread: () => Effect.die("interrupt must not dispatch above the ceiling"), | |
| 717 | }), | |
| 718 | ), | |
| 719 | Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})), | |
| 720 | Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})), | |
| 721 | Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})), | |
| 722 | Layer.provide(Layer.mock(ProjectService.ProjectService)({})), | |
| 723 | Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})), | |
| 724 | ), | |
| 725 | ), | |
| 726 | ), | |
| 727 | ); | |
| 728 | ||
| 729 | it.effect("discovers workflow stop targets and requires orchestration capability", () => { | |
| 730 | const threadId = ThreadId.make("workflow-parent"); | |
| 731 | const shell = McpToolAccessTestkit.liveThreadShell(threadId); | |
| 732 | const subagentId = NodeId.make("workflow-coordinator"); | |
| 733 | const itemId = TurnItemId.make("workflow-coordinator-activity"); | |
| 734 | const row: OrchestrationV2ProjectedTurnItem = { | |
| 735 | position: 0, | |
| 736 | visibility: "local", | |
| 737 | sourceThreadId: threadId, | |
| 738 | sourceItemId: itemId, | |
| 739 | item: { | |
| 740 | id: itemId, | |
| 741 | threadId, | |
| 742 | runId: shell.activeRunId, | |
| 743 | nodeId: subagentId, | |
| 744 | providerThreadId: null, | |
| 745 | providerTurnId: null, | |
| 746 | nativeItemRef: null, | |
| 747 | parentItemId: null, | |
| 748 | ordinal: 0, | |
| 749 | status: "running", | |
| 750 | title: "Workflow: checkout review", | |
| 751 | startedAt: shell.createdAt, | |
| 752 | completedAt: null, | |
| 753 | updatedAt: shell.updatedAt, | |
| 754 | type: "subagent", | |
| 755 | subagentId, | |
| 756 | origin: "provider_native", | |
| 757 | driver: ProviderDriverKind.make("claudeAgent"), | |
| 758 | providerInstanceId: ProviderInstanceId.make("claudeAgent"), | |
| 759 | childThreadId: ThreadId.make("workflow-child"), | |
| 760 | prompt: "Review checkout", | |
| 761 | result: null, | |
| 762 | }, | |
| 763 | }; | |
| 764 | const stopped: Array<{ readonly threadId: ThreadId; readonly subagentId: string }> = []; | |
| 765 | return Effect.gen(function* () { | |
| 668 | 766 | const server = yield* McpServer.McpServer; |
| 669 | const result = yield* server | |
| 670 | .callTool({ name: "t3_thread_interrupt", arguments: { threadId: "full-access-thread" } }) | |
| 767 | const scope = clientScope("full-access"); | |
| 768 | const read = yield* server | |
| 769 | .callTool({ name: "t3_thread_read", arguments: { threadId, view: "activity" } }) | |
| 671 | 770 | .pipe( |
| 672 | Effect.provideService(McpInvocationContext.McpInvocationContext, clientScope("auto")), | |
| 771 | Effect.provideService(McpInvocationContext.McpInvocationContext, scope), | |
| 673 | 772 | Effect.provideService(McpSchema.McpServerClient, client), |
| 674 | 773 | ); |
| 675 | expect(declaredFailure(result)).toMatchObject({ code: "runtime_mode_escalation_denied" }); | |
| 774 | expect(read.isError).toBe(false); | |
| 775 | const result = yield* decodeThreadReadResult(read.structuredContent); | |
| 776 | expect(result.items[0]?.subagentId).toBe(subagentId); | |
| 777 | const call = (invocation: McpInvocationContext.McpInvocationScope) => | |
| 778 | server | |
| 779 | .callTool({ | |
| 780 | name: "t3_workflow_stop", | |
| 781 | arguments: { threadId, subagentId: result.items[0]?.subagentId }, | |
| 782 | }) | |
| 783 | .pipe( | |
| 784 | Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), | |
| 785 | Effect.provideService(McpSchema.McpServerClient, client), | |
| 786 | ); | |
| 787 | const denied = yield* call({ ...scope, capabilities: new Set(["worktree"]) }); | |
| 788 | expect(declaredFailure(denied)).toMatchObject({ code: "capability_denied" }); | |
| 789 | expect(stopped).toEqual([]); | |
| 790 | const allowed = yield* call(scope); | |
| 791 | expect(allowed.isError).toBe(false); | |
| 792 | expect(stopped).toEqual([{ threadId: "workflow-parent", subagentId: "workflow-coordinator" }]); | |
| 676 | 793 | }).pipe( |
| 677 | 794 | Effect.provide( |
| 678 | 795 | McpHttpServer.layerOrchestratorToolkit.pipe( |
| ... | ... | @@ -680,20 +797,14 @@ it.effect("a caller cannot interrupt a thread that runs above its own modes", () |
| 680 | 797 | Layer.provide(NodeCrypto.layer), |
| 681 | 798 | Layer.provide( |
| 682 | 799 | Layer.mock(ThreadManagement.ThreadManagementService)({ |
| 683 | getThreadShell: () => | |
| 684 | Effect.succeed({ projectId: "project-a", deletedAt: null } as never), | |
| 800 | getThreadShell: (id) => Effect.succeed(McpToolAccessTestkit.liveThreadShell(id)), | |
| 685 | 801 | getProjectThreadRecords: () => |
| 686 | Effect.succeed({ | |
| 687 | thread: { | |
| 688 | id: ThreadId.make("full-access-thread"), | |
| 689 | projectId: "project-a", | |
| 690 | runtimeMode: "full-access", | |
| 691 | interactionMode: "default", | |
| 692 | deletedAt: null, | |
| 693 | }, | |
| 694 | runs: [], | |
| 695 | } as never), | |
| 696 | interruptThread: () => Effect.die("interrupt must not dispatch above the ceiling"), | |
| 802 | Effect.succeed(McpToolAccessTestkit.idleThreadProjection(shell)), | |
| 803 | getTimelinePage: () => Effect.succeed({ items: [row], totalItems: 1, hasMore: false }), | |
| 804 | stopWorkflow: (input) => | |
| 805 | Effect.sync(() => { | |
| 806 | stopped.push(input); | |
| 807 | }), | |
| 697 | 808 | }), |
| 698 | 809 | ), |
| 699 | 810 | Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})), |
| ... | ... | @@ -703,8 +814,8 @@ it.effect("a caller cannot interrupt a thread that runs above its own modes", () |
| 703 | 814 | Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})), |
| 704 | 815 | ), |
| 705 | 816 | ), |
| 706 | ), | |
| 707 | ); | |
| 817 | ); | |
| 818 | }); | |
| 708 | 819 | |
| 709 | 820 | it.effect("only the caller that prepared a pending upload can discard it", () => |
| 710 | 821 | Effect.gen(function* () { |
apps/server/src/mcp/toolkits/orchestrator/handlers.ts+22| ... | ... | @@ -1,6 +1,8 @@ |
| 1 | import { OrchestratorMcpFailure } from "@t3tools/contracts"; | |
| 1 | 2 | import { OrchestratorToolkit } from "./tools.ts"; |
| 2 | 3 | import * as Effect from "effect/Effect"; |
| 3 | 4 | |
| 5 | import { readCaller } from "../../threadAccess.ts"; | |
| 4 | 6 | import * as McpInvocationContext from "../../McpInvocationContext.ts"; |
| 5 | 7 | import * as McpToolAccess from "../../McpToolAccess.ts"; |
| 6 | 8 | import * as OrchestratorMcpService from "../../OrchestratorMcpService.ts"; |
| ... | ... | @@ -35,6 +37,26 @@ const handlers = { |
| 35 | 37 | return yield* service.cancelTask(scope, input); |
| 36 | 38 | }), |
| 37 | 39 | ), |
| 40 | t3_workflow_stop: McpToolAccess.writesThreads( | |
| 41 | (input) => [input.threadId], | |
| 42 | (input) => | |
| 43 | Effect.gen(function* () { | |
| 44 | const { threads } = yield* readCaller(); | |
| 45 | return yield* threads.stopWorkflow(input).pipe( | |
| 46 | Effect.as({}), | |
| 47 | Effect.mapError( | |
| 48 | (cause) => | |
| 49 | new OrchestratorMcpFailure({ | |
| 50 | code: | |
| 51 | cause.reason === "unavailable" || cause.reason === "stop-failed" | |
| 52 | ? "orchestration_error" | |
| 53 | : "invalid_request", | |
| 54 | message: cause.message, | |
| 55 | }), | |
| 56 | ), | |
| 57 | ); | |
| 58 | }), | |
| 59 | ), | |
| 38 | 60 | schedule_task: McpToolAccess.startsThreads( |
| 39 | 61 | // A scheduled task runs with the caller's own modes. |
| 40 | 62 | () => ({}), |
apps/server/src/mcp/toolkits/orchestrator/tools.ts+15| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | import { |
| 2 | 2 | OrchestratorMcpCapabilitiesResult, |
| 3 | OrchestrationV2StopWorkflowInput, | |
| 3 | 4 | OrchestratorMcpCreateThreadsInput, |
| 4 | 5 | OrchestratorMcpCreateThreadsResult, |
| 5 | 6 | OrchestratorMcpDelegateTaskInput, |
| ... | ... | @@ -31,6 +32,7 @@ import { |
| 31 | 32 | ThreadMetadataMcpUpdateResult, |
| 32 | 33 | } from "@t3tools/contracts"; |
| 33 | 34 | import { Tool, Toolkit } from "effect/ai"; |
| 35 | import * as Schema from "effect/Schema"; | |
| 34 | 36 | |
| 35 | 37 | import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts"; |
| 36 | 38 | import * as McpInvocationContext from "../../McpInvocationContext.ts"; |
| ... | ... | @@ -100,6 +102,18 @@ const TaskCancelTool = Tool.make("task_cancel", { |
| 100 | 102 | .annotate(Tool.Title, "Cancel delegated task") |
| 101 | 103 | .annotate(Tool.Destructive, true); |
| 102 | 104 | |
| 105 | const WorkflowStopTool = Tool.make("t3_workflow_stop", { | |
| 106 | description: | |
| 107 | "Stop one running Claude workflow in a T3 thread. Use the workflow coordinator's subagentId returned by t3_thread_read with view='activity'. Stops the whole workflow, including its phases and agents. Does not stop the parent thread or other workflows. Individual workflow phases and agents cannot be stopped separately.", | |
| 108 | parameters: OrchestrationV2StopWorkflowInput, | |
| 109 | success: Schema.Struct({}), | |
| 110 | failure: OrchestratorMcpFailure, | |
| 111 | failureMode: "return", | |
| 112 | dependencies, | |
| 113 | }) | |
| 114 | .annotate(Tool.Title, "Stop a workflow") | |
| 115 | .annotate(Tool.Destructive, true); | |
| 116 | ||
| 103 | 117 | export const ScheduleTaskTool = Tool.make("schedule_task", { |
| 104 | 118 | description: |
| 105 | 119 | "Create persistent work in the app scheduler that runs even when no turn is active. Pass schedule as a STRUCTURED OBJECT, never JSON text. Timers: {type:'interval', everyMs:3600000} is hourly; {type:'fixed_time', timeOfDay:'09:00', weekdays:[1,2,3,4,5]} is weekday mornings; report the returned nextRunAt. Webhooks: {type:'webhook'} runs once per request to a generated URL. The run sees the request ONLY through prompt placeholders: {{body.path}} (e.g. {{body.action}}, {{body.release.tag_name}}), {{headers.name}}, {{query.name}}, {{body}}, or {{request}} (method, headers with credentials redacted, and body). For a sender that signs requests, first call request_secret so the user enters the secret privately (never ask for it in chat or invent one), then set signature with the returned secretRef, e.g. GitHub: {type:'webhook', signature:{header:'x-hub-signature-256', encoding:'hex', prefix:'sha256=', secretRef}}. The result's webhookUrl is the public URL to give the user; if it is absent, this environment has no T3 Connect managed tunnel, so tell the user to enable T3 Connect remote access rather than sharing a path. Omit projectId for this thread's project. In this thread's project, runs post into THIS thread by default (bindToCurrentThread=true), which suits an orchestrator that sees every trigger, delegates work, and can dedupe against what is in flight; use false only when the user wants a fresh top-level thread per run. Elsewhere each run launches a fresh thread. Provider, model, and runtime settings inherit from this thread, or from the project default when there is no calling thread.", |
| ... | ... | @@ -261,6 +275,7 @@ export const OrchestratorToolkit = Toolkit.make( |
| 261 | 275 | DelegateTaskTool, |
| 262 | 276 | TaskStatusTool, |
| 263 | 277 | TaskCancelTool, |
| 278 | WorkflowStopTool, | |
| 264 | 279 | ScheduleTaskTool, |
| 265 | 280 | ListScheduledTasksTool, |
| 266 | 281 | UpdateScheduledTaskTool, |
apps/server/src/observability/RpcInstrumentation.ts+1| ... | ... | @@ -17,6 +17,7 @@ type WsRpcMethod = RpcGroup.Rpcs<typeof WsRpcGroup>["_tag"]; |
| 17 | 17 | const RPC_AGGREGATES = { |
| 18 | 18 | [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: "orchestrationV2", |
| 19 | 19 | [ORCHESTRATION_V2_WS_METHODS.getWorkflowScript]: "orchestration", |
| 20 | [ORCHESTRATION_V2_WS_METHODS.stopWorkflow]: "orchestration", | |
| 20 | 21 | [ORCHESTRATION_V2_WS_METHODS.getTurnItem]: "orchestration", |
| 21 | 22 | [ORCHESTRATION_V2_WS_METHODS.getTurnDiff]: "orchestration", |
| 22 | 23 | [ORCHESTRATION_V2_WS_METHODS.getFullThreadDiff]: "orchestration", |
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts+2382-11| ... | ... | @@ -1,6 +1,8 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off - the fake Claude CLI hands the SDK Node streams. | |
| 1 | // @effect-diagnostics nodeBuiltinImport:off -- workflow fixtures use files and the fake Claude CLI uses Node streams | |
| 2 | 2 | import * as NodeEvents from "node:events"; |
| 3 | import * as NodeFS from "node:fs"; | |
| 3 | 4 | import * as NodeOS from "node:os"; |
| 5 | import * as NodePath from "node:path"; | |
| 4 | 6 | import * as NodeStream from "node:stream"; |
| 5 | 7 | |
| 6 | 8 | import type { |
| ... | ... | @@ -44,6 +46,7 @@ import { |
| 44 | 46 | ThreadId, |
| 45 | 47 | } from "@t3tools/contracts"; |
| 46 | 48 | import { assert, describe, it } from "@effect/vitest"; |
| 49 | import * as Clock from "effect/Clock"; | |
| 47 | 50 | import * as Context from "effect/Context"; |
| 48 | 51 | import * as DateTime from "effect/DateTime"; |
| 49 | 52 | import * as Deferred from "effect/Deferred"; |
| ... | ... | @@ -1024,6 +1027,7 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => { |
| 1024 | 1027 | offer: () => Effect.void, |
| 1025 | 1028 | setModel: () => Effect.void, |
| 1026 | 1029 | setPermissionMode: () => Effect.void, |
| 1030 | stopTask: () => Effect.void, | |
| 1027 | 1031 | interrupt: Effect.void, |
| 1028 | 1032 | close: Effect.void, |
| 1029 | 1033 | }; |
| ... | ... | @@ -1183,6 +1187,7 @@ const captureSdkExecutablePaths = Effect.fn("captureSdkExecutablePaths")(functio |
| 1183 | 1187 | offer: () => Effect.void, |
| 1184 | 1188 | setModel: () => Effect.void, |
| 1185 | 1189 | setPermissionMode: () => Effect.void, |
| 1190 | stopTask: () => Effect.void, | |
| 1186 | 1191 | interrupt: Effect.void, |
| 1187 | 1192 | close: Effect.void, |
| 1188 | 1193 | }; |
| ... | ... | @@ -1302,6 +1307,7 @@ describe("ClaudeAdapterV2 resume compaction", () => { |
| 1302 | 1307 | offer: () => Effect.void, |
| 1303 | 1308 | setModel: () => Effect.void, |
| 1304 | 1309 | setPermissionMode: () => Effect.void, |
| 1310 | stopTask: () => Effect.void, | |
| 1305 | 1311 | interrupt: Effect.void, |
| 1306 | 1312 | close: Effect.void, |
| 1307 | 1313 | }; |
| ... | ... | @@ -1527,6 +1533,7 @@ describe("ClaudeAdapterV2 attachments", () => { |
| 1527 | 1533 | }), |
| 1528 | 1534 | setModel: () => Effect.void, |
| 1529 | 1535 | setPermissionMode: () => Effect.void, |
| 1536 | stopTask: () => Effect.void, | |
| 1530 | 1537 | interrupt: Effect.void, |
| 1531 | 1538 | close: Effect.void, |
| 1532 | 1539 | }), |
| ... | ... | @@ -1671,6 +1678,7 @@ describe("ClaudeAdapterV2 attachments", () => { |
| 1671 | 1678 | offer: () => Effect.void, |
| 1672 | 1679 | setModel: () => Effect.void, |
| 1673 | 1680 | setPermissionMode: () => Effect.void, |
| 1681 | stopTask: () => Effect.void, | |
| 1674 | 1682 | interrupt: Effect.void, |
| 1675 | 1683 | close: Effect.void, |
| 1676 | 1684 | }; |
| ... | ... | @@ -1765,6 +1773,7 @@ describe("ClaudeAdapterV2 native fork", () => { |
| 1765 | 1773 | offer: () => Effect.void, |
| 1766 | 1774 | setModel: () => Effect.void, |
| 1767 | 1775 | setPermissionMode: () => Effect.void, |
| 1776 | stopTask: () => Effect.void, | |
| 1768 | 1777 | interrupt: Effect.void, |
| 1769 | 1778 | close: Effect.void, |
| 1770 | 1779 | }; |
| ... | ... | @@ -2056,6 +2065,7 @@ describe("ClaudeAdapterV2 native session identity", () => { |
| 2056 | 2065 | offer: () => Effect.void, |
| 2057 | 2066 | setModel: () => Effect.void, |
| 2058 | 2067 | setPermissionMode: () => Effect.void, |
| 2068 | stopTask: () => Effect.void, | |
| 2059 | 2069 | interrupt: Effect.void, |
| 2060 | 2070 | close: Effect.void, |
| 2061 | 2071 | }; |
| ... | ... | @@ -2309,6 +2319,8 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 2309 | 2319 | readonly interrupt?: Effect.Effect<void>; |
| 2310 | 2320 | readonly stopTask?: (taskId: string) => Effect.Effect<void>; |
| 2311 | 2321 | readonly environment?: NodeJS.ProcessEnv; |
| 2322 | readonly subagentLaunchToolUseId?: string; | |
| 2323 | readonly nativeSessionId?: string; | |
| 2312 | 2324 | readonly settings?: ClaudeSettings; |
| 2313 | 2325 | // A CLI process opened after the first streams from its own queue, so the |
| 2314 | 2326 | // first one can exit (Queue.shutdown) and a later turn can start another. |
| ... | ... | @@ -2323,24 +2335,30 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 2323 | 2335 | const sdkMessages = yield* Queue.unbounded<SDKMessage>(); |
| 2324 | 2336 | const processQueues: Array<Queue.Queue<SDKMessage>> = []; |
| 2325 | 2337 | const processedMessages = new WeakMap<SDKMessage, Deferred.Deferred<void>>(); |
| 2326 | const offerAndWait = Effect.fnUntraced(function* (message: SDKMessage) { | |
| 2338 | const offerAndWait = Effect.fnUntraced(function* ( | |
| 2339 | message: SDKMessage, | |
| 2340 | offered?: Deferred.Deferred<void>, | |
| 2341 | ) { | |
| 2327 | 2342 | const processed = yield* Deferred.make<void>(); |
| 2328 | 2343 | processedMessages.set(message, processed); |
| 2329 | 2344 | yield* Queue.offer(sdkMessages, message); |
| 2345 | if (offered !== undefined) yield* Deferred.succeed(offered, undefined); | |
| 2330 | 2346 | yield* Deferred.await(processed); |
| 2331 | 2347 | }); |
| 2332 | 2348 | const offeredMessages: Array<SDKUserMessage> = []; |
| 2333 | 2349 | const permissionModeChanges: Array<string> = []; |
| 2334 | 2350 | const continuationRequests: Array<ProviderContinuationRequests.ProviderContinuationRequest> = |
| 2335 | 2351 | []; |
| 2336 | const subagentReceipts = | |
| 2337 | yield* Queue.unbounded< | |
| 2338 | Extract<ProviderAdapter.ProviderAdapterV2Event, { type: "subagent.updated" }> | |
| 2339 | >(); | |
| 2352 | const continuationReceipts = | |
| 2353 | yield* Queue.unbounded<ProviderContinuationRequests.ProviderContinuationRequest>(); | |
| 2340 | 2354 | const terminalReceipts = |
| 2341 | 2355 | yield* Queue.unbounded< |
| 2342 | 2356 | Extract<ProviderAdapter.ProviderAdapterV2Event, { type: "turn.terminal" }> |
| 2343 | 2357 | >(); |
| 2358 | const subagentReceipts = | |
| 2359 | yield* Queue.unbounded< | |
| 2360 | Extract<ProviderAdapter.ProviderAdapterV2Event, { type: "subagent.updated" }> | |
| 2361 | >(); | |
| 2344 | 2362 | const systemNoticeReceipts = |
| 2345 | 2363 | yield* Queue.unbounded< |
| 2346 | 2364 | Extract<ProviderAdapter.ProviderAdapterV2Event, { type: "turn_item.updated" }> |
| ... | ... | @@ -2359,10 +2377,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 2359 | 2377 | offer: (request) => |
| 2360 | 2378 | Effect.sync(() => { |
| 2361 | 2379 | continuationRequests.push(request); |
| 2362 | }), | |
| 2380 | }).pipe(Effect.andThen(Queue.offer(continuationReceipts, request))), | |
| 2363 | 2381 | }, |
| 2364 | 2382 | queryRunner: { |
| 2365 | allocateSessionId: Effect.succeed(WAKE_NATIVE_SESSION), | |
| 2383 | allocateSessionId: Effect.succeed(options?.nativeSessionId ?? WAKE_NATIVE_SESSION), | |
| 2366 | 2384 | open: (input) => |
| 2367 | 2385 | Effect.gen(function* () { |
| 2368 | 2386 | openedOptions = input.options; |
| ... | ... | @@ -2377,6 +2395,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 2377 | 2395 | }), |
| 2378 | 2396 | setModel: () => Effect.void, |
| 2379 | 2397 | setPermissionMode: () => Effect.void, |
| 2398 | stopTask: options?.stopTask ?? (() => Effect.void), | |
| 2380 | 2399 | interrupt: Effect.void, |
| 2381 | 2400 | close: Queue.shutdown(processMessages), |
| 2382 | 2401 | }; |
| ... | ... | @@ -2409,13 +2428,13 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 2409 | 2428 | Effect.sync(() => { |
| 2410 | 2429 | permissionModeChanges.push(mode); |
| 2411 | 2430 | }), |
| 2412 | ...(options?.stopTask === undefined ? {} : { stopTask: options.stopTask }), | |
| 2431 | stopTask: options?.stopTask ?? (() => Effect.void), | |
| 2413 | 2432 | interrupt: options?.interrupt ?? Effect.void, |
| 2414 | 2433 | close: options?.close?.(sdkMessages) ?? Effect.void, |
| 2415 | 2434 | }; |
| 2416 | 2435 | }), |
| 2417 | 2436 | forkSession: () => Effect.die("unused forkSession"), |
| 2418 | subagentLaunchToolUseId: () => Effect.succeed(null), | |
| 2437 | subagentLaunchToolUseId: () => Effect.succeed(options?.subagentLaunchToolUseId ?? null), | |
| 2419 | 2438 | assertComplete: Effect.void, |
| 2420 | 2439 | }, |
| 2421 | 2440 | }); |
| ... | ... | @@ -2436,7 +2455,9 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 2436 | 2455 | Stream.runForEach((event) => |
| 2437 | 2456 | Effect.gen(function* () { |
| 2438 | 2457 | events.push(event); |
| 2439 | if (event.type === "subagent.updated") yield* Queue.offer(subagentReceipts, event); | |
| 2458 | if (event.type === "subagent.updated") { | |
| 2459 | yield* Queue.offer(subagentReceipts, event); | |
| 2460 | } | |
| 2440 | 2461 | if (event.type === "turn.terminal") { |
| 2441 | 2462 | yield* Queue.offer(terminalReceipts, event); |
| 2442 | 2463 | } |
| ... | ... | @@ -2474,6 +2495,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 2474 | 2495 | }, |
| 2475 | 2496 | permissionModeChanges, |
| 2476 | 2497 | continuationRequests, |
| 2498 | continuationReceipts, | |
| 2477 | 2499 | events, |
| 2478 | 2500 | terminalReceipts, |
| 2479 | 2501 | subagentReceipts, |
| ... | ... | @@ -4709,6 +4731,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 4709 | 4731 | offer: () => Effect.void, |
| 4710 | 4732 | setModel: () => Effect.void, |
| 4711 | 4733 | setPermissionMode: () => Effect.void, |
| 4734 | stopTask: () => Effect.void, | |
| 4712 | 4735 | interrupt: Effect.void, |
| 4713 | 4736 | // The first CLI process keeps streaming until the test ends |
| 4714 | 4737 | // it, so Stop stays parked waiting for it to exit. |
| ... | ... | @@ -4968,6 +4991,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 4968 | 4991 | offer: () => Effect.void, |
| 4969 | 4992 | setModel: () => Effect.void, |
| 4970 | 4993 | setPermissionMode: () => Effect.void, |
| 4994 | stopTask: () => Effect.void, | |
| 4971 | 4995 | interrupt: Effect.void, |
| 4972 | 4996 | close: Queue.shutdown(queue), |
| 4973 | 4997 | }; |
| ... | ... | @@ -6547,6 +6571,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 6547 | 6571 | const harness = yield* makeWakeHarnessWithOptions({ |
| 6548 | 6572 | close: (sdkMessages) => |
| 6549 | 6573 | Deferred.await(closeGate).pipe(Effect.andThen(Queue.shutdown(sdkMessages))), |
| 6574 | stopTask: () => Effect.void, | |
| 6550 | 6575 | interrupt: Deferred.succeed(interruptStarted, undefined), |
| 6551 | 6576 | }); |
| 6552 | 6577 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| ... | ... | @@ -6608,6 +6633,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 6608 | 6633 | const harness = yield* makeWakeHarnessWithOptions({ |
| 6609 | 6634 | close: (sdkMessages) => |
| 6610 | 6635 | Deferred.await(closeGate).pipe(Effect.andThen(Queue.shutdown(sdkMessages))), |
| 6636 | stopTask: () => Effect.void, | |
| 6611 | 6637 | interrupt: Deferred.succeed(interruptStarted, undefined), |
| 6612 | 6638 | }); |
| 6613 | 6639 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| ... | ... | @@ -9692,6 +9718,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 9692 | 9718 | offer: () => Effect.void, |
| 9693 | 9719 | setModel: () => Effect.void, |
| 9694 | 9720 | setPermissionMode: () => Effect.void, |
| 9721 | stopTask: () => Effect.void, | |
| 9695 | 9722 | interrupt: Effect.void, |
| 9696 | 9723 | // End this process stream so openQuery can replace it. |
| 9697 | 9724 | close: Queue.shutdown(sdkMessages), |
| ... | ... | @@ -9881,6 +9908,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 9881 | 9908 | offer: () => Effect.void, |
| 9882 | 9909 | setModel: () => Effect.void, |
| 9883 | 9910 | setPermissionMode: () => Effect.void, |
| 9911 | stopTask: () => Effect.void, | |
| 9884 | 9912 | interrupt: Effect.void, |
| 9885 | 9913 | close: Queue.shutdown(sdkMessages), |
| 9886 | 9914 | }; |
| ... | ... | @@ -10122,6 +10150,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 10122 | 10150 | offer: () => Effect.void, |
| 10123 | 10151 | setModel: () => Effect.void, |
| 10124 | 10152 | setPermissionMode: () => Effect.void, |
| 10153 | stopTask: () => Effect.void, | |
| 10125 | 10154 | interrupt: Effect.void, |
| 10126 | 10155 | close: Queue.shutdown(sdkMessages), |
| 10127 | 10156 | }; |
| ... | ... | @@ -10320,6 +10349,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 10320 | 10349 | offer: () => Effect.void, |
| 10321 | 10350 | setModel: () => Effect.void, |
| 10322 | 10351 | setPermissionMode: () => Effect.void, |
| 10352 | stopTask: () => Effect.void, | |
| 10323 | 10353 | interrupt: Effect.void, |
| 10324 | 10354 | close: Queue.shutdown(sdkMessages), |
| 10325 | 10355 | }; |
| ... | ... | @@ -10502,6 +10532,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 10502 | 10532 | offer: () => Effect.void, |
| 10503 | 10533 | setModel: () => Effect.void, |
| 10504 | 10534 | setPermissionMode: () => Effect.void, |
| 10535 | stopTask: () => Effect.void, | |
| 10505 | 10536 | interrupt: Effect.void, |
| 10506 | 10537 | close: Queue.shutdown(sdkMessages), |
| 10507 | 10538 | }; |
| ... | ... | @@ -10640,6 +10671,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 10640 | 10671 | offer: () => Effect.void, |
| 10641 | 10672 | setModel: () => Effect.void, |
| 10642 | 10673 | setPermissionMode: () => Effect.void, |
| 10674 | stopTask: () => Effect.void, | |
| 10643 | 10675 | interrupt: Effect.void, |
| 10644 | 10676 | close: Queue.shutdown(sdkMessages), |
| 10645 | 10677 | }; |
| ... | ... | @@ -10874,6 +10906,2345 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 10874 | 10906 | ), |
| 10875 | 10907 | ); |
| 10876 | 10908 | |
| 10909 | const WORKFLOW_TASK_ID = "task-workflow-run"; | |
| 10910 | const WORKFLOW_TOOL_USE_ID = "toolu-workflow-run"; | |
| 10911 | const makeWorkflowHarnessWithOptions = ( | |
| 10912 | options?: Parameters<typeof makeWakeHarnessWithOptions>[0], | |
| 10913 | ) => | |
| 10914 | Effect.gen(function* () { | |
| 10915 | const fileSystem = yield* FileSystem.FileSystem; | |
| 10916 | const configDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-wf-" }); | |
| 10917 | const transcriptDir = NodePath.join(configDir, "projects", "workflow"); | |
| 10918 | yield* fileSystem.makeDirectory(transcriptDir, { recursive: true }); | |
| 10919 | yield* fileSystem.writeFileString( | |
| 10920 | NodePath.join(transcriptDir, "agent-a1.jsonl"), | |
| 10921 | '{"type":"assistant","message":{"role":"assistant","id":"msg_a1","content":[{"type":"text","text":"A1 from the transcript"}]}}\n', | |
| 10922 | ); | |
| 10923 | const harness = yield* makeWakeHarnessWithOptions({ | |
| 10924 | ...options, | |
| 10925 | environment: { ...options?.environment, CLAUDE_CONFIG_DIR: configDir }, | |
| 10926 | }); | |
| 10927 | return { ...harness, transcriptDir }; | |
| 10928 | }); | |
| 10929 | const makeWorkflowHarness = makeWorkflowHarnessWithOptions(); | |
| 10930 | ||
| 10931 | const workflowToolUse = claudeSdkFrame({ | |
| 10932 | type: "assistant", | |
| 10933 | message: { | |
| 10934 | model: "claude-sonnet-4-6", | |
| 10935 | content: [ | |
| 10936 | { | |
| 10937 | type: "tool_use", | |
| 10938 | id: WORKFLOW_TOOL_USE_ID, | |
| 10939 | name: "Workflow", | |
| 10940 | input: { script: "alpha.js" }, | |
| 10941 | }, | |
| 10942 | ], | |
| 10943 | }, | |
| 10944 | parent_tool_use_id: null, | |
| 10945 | uuid: "00000000-0000-4000-8000-000000001001", | |
| 10946 | session_id: WAKE_NATIVE_SESSION, | |
| 10947 | }); | |
| 10948 | const workflowTaskStarted = claudeSdkFrame({ | |
| 10949 | type: "system", | |
| 10950 | subtype: "task_started", | |
| 10951 | task_id: WORKFLOW_TASK_ID, | |
| 10952 | tool_use_id: WORKFLOW_TOOL_USE_ID, | |
| 10953 | description: "Run the alpha workflow", | |
| 10954 | task_type: "local_workflow", | |
| 10955 | workflow_name: "probe-wf", | |
| 10956 | uuid: "00000000-0000-4000-8000-000000001002", | |
| 10957 | session_id: WAKE_NATIVE_SESSION, | |
| 10958 | }); | |
| 10959 | const workflowLaunchAck = (transcriptDir: string) => | |
| 10960 | claudeSdkFrame({ | |
| 10961 | type: "user", | |
| 10962 | message: { | |
| 10963 | role: "user", | |
| 10964 | content: [ | |
| 10965 | { | |
| 10966 | type: "tool_result", | |
| 10967 | tool_use_id: WORKFLOW_TOOL_USE_ID, | |
| 10968 | content: [{ type: "text", text: "Workflow launched." }], | |
| 10969 | }, | |
| 10970 | ], | |
| 10971 | }, | |
| 10972 | parent_tool_use_id: null, | |
| 10973 | uuid: "00000000-0000-4000-8000-000000001003", | |
| 10974 | session_id: WAKE_NATIVE_SESSION, | |
| 10975 | tool_use_result: { | |
| 10976 | taskType: "local_workflow", | |
| 10977 | runId: "wf_probe", | |
| 10978 | transcriptDir, | |
| 10979 | }, | |
| 10980 | }); | |
| 10981 | const workflowSnapshot = (input: { | |
| 10982 | readonly uuid: string; | |
| 10983 | readonly state: "queued" | "start" | "done"; | |
| 10984 | readonly secondState?: "queued" | "start" | "done"; | |
| 10985 | readonly startedAt?: number; | |
| 10986 | readonly label?: string; | |
| 10987 | readonly prompt?: string; | |
| 10988 | readonly model?: string; | |
| 10989 | readonly attempt?: number; | |
| 10990 | readonly omitResult?: boolean; | |
| 10991 | }) => | |
| 10992 | claudeSdkFrame({ | |
| 10993 | type: "system", | |
| 10994 | subtype: "task_progress", | |
| 10995 | task_id: WORKFLOW_TASK_ID, | |
| 10996 | tool_use_id: WORKFLOW_TOOL_USE_ID, | |
| 10997 | description: "Alpha: alpha:one", | |
| 10998 | workflow_progress: [ | |
| 10999 | { type: "workflow_phase", index: 1, title: "Alpha" }, | |
| 11000 | { | |
| 11001 | type: "workflow_agent", | |
| 11002 | index: 1, | |
| 11003 | label: input.label ?? "alpha:one", | |
| 11004 | agentId: "a1", | |
| 11005 | state: input.state, | |
| 11006 | phaseIndex: 1, | |
| 11007 | phaseTitle: "Alpha", | |
| 11008 | model: input.model ?? "claude-opus-5[1m]", | |
| 11009 | promptPreview: input.prompt ?? "Reply with exactly: A1", | |
| 11010 | ...(input.startedAt === undefined ? {} : { startedAt: input.startedAt }), | |
| 11011 | ...(input.attempt === undefined ? {} : { attempt: input.attempt }), | |
| 11012 | ...(input.state === "done" && input.omitResult !== true | |
| 11013 | ? { resultPreview: "A1 excerpt" } | |
| 11014 | : {}), | |
| 11015 | }, | |
| 11016 | { | |
| 11017 | type: "workflow_agent", | |
| 11018 | index: 2, | |
| 11019 | label: "alpha:two", | |
| 11020 | agentId: "a2", | |
| 11021 | state: input.secondState ?? input.state, | |
| 11022 | phaseIndex: 1, | |
| 11023 | phaseTitle: "Alpha", | |
| 11024 | promptPreview: "Reply with exactly: A2", | |
| 11025 | ...(input.state === "done" ? { resultPreview: "A2 excerpt" } : {}), | |
| 11026 | }, | |
| 11027 | ], | |
| 11028 | uuid: input.uuid, | |
| 11029 | session_id: WAKE_NATIVE_SESSION, | |
| 11030 | }); | |
| 11031 | const workflowNotification = (input: { | |
| 11032 | readonly uuid: string; | |
| 11033 | readonly status: "completed" | "failed" | "stopped"; | |
| 11034 | }) => | |
| 11035 | claudeSdkFrame({ | |
| 11036 | type: "system", | |
| 11037 | subtype: "task_notification", | |
| 11038 | task_id: WORKFLOW_TASK_ID, | |
| 11039 | tool_use_id: WORKFLOW_TOOL_USE_ID, | |
| 11040 | status: input.status, | |
| 11041 | summary: "Workflow finished.", | |
| 11042 | output_file: "/tmp/workflow.output", | |
| 11043 | uuid: input.uuid, | |
| 11044 | session_id: WAKE_NATIVE_SESSION, | |
| 11045 | }); | |
| 11046 | const workflowCoordinatorEvents = ( | |
| 11047 | events: ReadonlyArray<ProviderAdapter.ProviderAdapterV2Event>, | |
| 11048 | ) => | |
| 11049 | events.filter( | |
| 11050 | ( | |
| 11051 | event, | |
| 11052 | ): event is Extract<ProviderAdapter.ProviderAdapterV2Event, { type: "subagent.updated" }> => | |
| 11053 | event.type === "subagent.updated" && | |
| 11054 | event.subagent.nativeTaskRef?.nativeId === WORKFLOW_TASK_ID, | |
| 11055 | ); | |
| 11056 | const workflowMemberEvents = ( | |
| 11057 | events: ReadonlyArray<ProviderAdapter.ProviderAdapterV2Event>, | |
| 11058 | index: number, | |
| 11059 | ) => | |
| 11060 | events.filter( | |
| 11061 | ( | |
| 11062 | event, | |
| 11063 | ): event is Extract<ProviderAdapter.ProviderAdapterV2Event, { type: "subagent.updated" }> => | |
| 11064 | event.type === "subagent.updated" && | |
| 11065 | event.subagent.nativeTaskRef?.nativeId?.endsWith(`:agent:${index}`) === true, | |
| 11066 | ); | |
| 11067 | const threadMessages = ( | |
| 11068 | events: ReadonlyArray<ProviderAdapter.ProviderAdapterV2Event>, | |
| 11069 | threadId: ThreadId | null | undefined, | |
| 11070 | ) => | |
| 11071 | events.filter( | |
| 11072 | ( | |
| 11073 | event, | |
| 11074 | ): event is Extract<ProviderAdapter.ProviderAdapterV2Event, { type: "message.updated" }> => | |
| 11075 | event.type === "message.updated" && event.message.threadId === threadId, | |
| 11076 | ); | |
| 11077 | ||
| 11078 | const launchWorkflow = Effect.fnUntraced(function* (input: { | |
| 11079 | readonly harness: Effect.Success<typeof makeWorkflowHarness>; | |
| 11080 | readonly snapshotUuid: string; | |
| 11081 | readonly state?: "queued" | "start"; | |
| 11082 | }) { | |
| 11083 | yield* input.harness.offerAndWait(workflowToolUse); | |
| 11084 | yield* input.harness.offerAndWait(workflowTaskStarted); | |
| 11085 | yield* input.harness.offerAndWait(workflowLaunchAck(input.harness.transcriptDir)); | |
| 11086 | yield* input.harness.offerAndWait( | |
| 11087 | workflowSnapshot({ uuid: input.snapshotUuid, state: input.state ?? "start" }), | |
| 11088 | ); | |
| 11089 | yield* awaitUntil( | |
| 11090 | () => workflowMemberEvents(input.harness.events, 2).length === 1, | |
| 11091 | "workflow members projected", | |
| 11092 | ); | |
| 11093 | }); | |
| 11094 | ||
| 11095 | it.effect.each(["Agent", "Workflow"] as const)( | |
| 11096 | "keeps a workflow running after its %s launch reply without an async flag", | |
| 11097 | (toolName) => | |
| 11098 | Effect.scoped( | |
| 11099 | Effect.gen(function* () { | |
| 11100 | const harness = yield* makeWorkflowHarness; | |
| 11101 | yield* harness.runtime.startTurn( | |
| 11102 | makeClaudeTestTurnInput({ | |
| 11103 | threadId: harness.threadId, | |
| 11104 | providerThread: harness.providerThread, | |
| 11105 | now: yield* DateTime.now, | |
| 11106 | attemptId: RunAttemptId.make(`attempt-workflow-launch-${toolName}`), | |
| 11107 | text: "Run the workflow.", | |
| 11108 | attachments: [], | |
| 11109 | }), | |
| 11110 | ); | |
| 11111 | yield* harness.offerAndWait( | |
| 11112 | claudeSdkFrame({ | |
| 11113 | ...workflowToolUse, | |
| 11114 | message: { | |
| 11115 | model: "claude-sonnet-4-6", | |
| 11116 | content: [ | |
| 11117 | { type: "tool_use", id: WORKFLOW_TOOL_USE_ID, name: toolName, input: {} }, | |
| 11118 | ], | |
| 11119 | }, | |
| 11120 | }), | |
| 11121 | ); | |
| 11122 | yield* harness.offerAndWait(workflowTaskStarted); | |
| 11123 | yield* harness.offerAndWait(workflowLaunchAck(harness.transcriptDir)); | |
| 11124 | yield* harness.offerAndWait( | |
| 11125 | workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001200", state: "start" }), | |
| 11126 | ); | |
| 11127 | yield* Queue.offer( | |
| 11128 | harness.sdkMessages, | |
| 11129 | makeResultFrame({ uuid: "00000000-0000-4000-8000-000000001201", result: "Launched." }), | |
| 11130 | ); | |
| 11131 | yield* Queue.take(harness.terminalReceipts); | |
| 11132 | assert.equal( | |
| 11133 | workflowCoordinatorEvents(harness.events).at(-1)?.subagent.status, | |
| 11134 | "running", | |
| 11135 | ); | |
| 11136 | assert.equal(workflowMemberEvents(harness.events, 1).at(-1)?.subagent.status, "running"); | |
| 11137 | assert.isTrue(yield* harness.hasPendingBackgroundWork); | |
| 11138 | const toolItems = harness.events.flatMap((event) => | |
| 11139 | event.type === "turn_item.updated" && | |
| 11140 | event.turnItem.type === "dynamic_tool" && | |
| 11141 | event.turnItem.nativeItemRef?.nativeId === WORKFLOW_TOOL_USE_ID | |
| 11142 | ? [event.turnItem.status] | |
| 11143 | : [], | |
| 11144 | ); | |
| 11145 | assert.deepEqual(toolItems, toolName === "Workflow" ? ["running", "completed"] : []); | |
| 11146 | yield* harness.offerAndWait( | |
| 11147 | workflowNotification({ | |
| 11148 | uuid: "00000000-0000-4000-8000-000000001202", | |
| 11149 | status: "completed", | |
| 11150 | }), | |
| 11151 | ); | |
| 11152 | yield* Queue.take(harness.continuationReceipts); | |
| 11153 | yield* harness.runtime.startTurn( | |
| 11154 | makeClaudeTestTurnInput({ | |
| 11155 | threadId: harness.threadId, | |
| 11156 | providerThread: harness.providerThread, | |
| 11157 | now: yield* DateTime.now, | |
| 11158 | attemptId: RunAttemptId.make(`attempt-workflow-complete-${toolName}`), | |
| 11159 | text: "Workflow finished.", | |
| 11160 | attachments: [], | |
| 11161 | providerTurnOrdinal: 2, | |
| 11162 | messageCreatedBy: "agent", | |
| 11163 | messageCreationSource: "provider", | |
| 11164 | }), | |
| 11165 | ); | |
| 11166 | yield* Queue.offer( | |
| 11167 | harness.sdkMessages, | |
| 11168 | makeResultFrame({ uuid: "00000000-0000-4000-8000-000000001205", result: "Finished." }), | |
| 11169 | ); | |
| 11170 | yield* Queue.take(harness.terminalReceipts); | |
| 11171 | assert.equal( | |
| 11172 | workflowCoordinatorEvents(harness.events).at(-1)?.subagent.status, | |
| 11173 | "completed", | |
| 11174 | ); | |
| 11175 | assert.equal( | |
| 11176 | workflowMemberEvents(harness.events, 1).at(-1)?.subagent.status, | |
| 11177 | "completed", | |
| 11178 | ); | |
| 11179 | assert.isFalse(yield* harness.hasPendingBackgroundWork); | |
| 11180 | }).pipe( | |
| 11181 | Effect.provide( | |
| 11182 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 11183 | ), | |
| 11184 | ), | |
| 11185 | ), | |
| 11186 | ); | |
| 11187 | ||
| 11188 | it.effect("scopes workflow member artifacts to the provider thread", () => | |
| 11189 | Effect.scoped( | |
| 11190 | Effect.gen(function* () { | |
| 11191 | const memberArtifacts: Array<Array<string>> = []; | |
| 11192 | const memberThreads: Array<ThreadId | null | undefined> = []; | |
| 11193 | for (const nativeSessionId of ["workflow-session-a", "workflow-session-b"]) { | |
| 11194 | const harness = yield* makeWorkflowHarnessWithOptions({ nativeSessionId }); | |
| 11195 | yield* harness.runtime.startTurn( | |
| 11196 | makeClaudeTestTurnInput({ | |
| 11197 | threadId: harness.threadId, | |
| 11198 | providerThread: harness.providerThread, | |
| 11199 | now: yield* DateTime.now, | |
| 11200 | attemptId: RunAttemptId.make(`attempt-${nativeSessionId}`), | |
| 11201 | text: "Run the workflow.", | |
| 11202 | attachments: [], | |
| 11203 | }), | |
| 11204 | ); | |
| 11205 | for (const frame of [ | |
| 11206 | workflowToolUse, | |
| 11207 | workflowTaskStarted, | |
| 11208 | workflowLaunchAck(harness.transcriptDir), | |
| 11209 | workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001203", state: "done" }), | |
| 11210 | ]) { | |
| 11211 | yield* harness.offerAndWait(claudeSdkFrame({ ...frame, session_id: nativeSessionId })); | |
| 11212 | } | |
| 11213 | yield* Queue.offer( | |
| 11214 | harness.sdkMessages, | |
| 11215 | claudeSdkFrame({ | |
| 11216 | ...makeResultFrame({ uuid: "00000000-0000-4000-8000-000000001204", result: "Done." }), | |
| 11217 | session_id: nativeSessionId, | |
| 11218 | }), | |
| 11219 | ); | |
| 11220 | yield* Queue.take(harness.terminalReceipts); | |
| 11221 | const member = workflowMemberEvents(harness.events, 1).at(-1)?.subagent; | |
| 11222 | assert.isDefined(member); | |
| 11223 | memberThreads.push(member?.childThreadId); | |
| 11224 | const ids = harness.events.flatMap<string>((event) => { | |
| 11225 | if (event.type === "subagent.updated" && event.subagent.id === member?.id) | |
| 11226 | return [event.subagent.id]; | |
| 11227 | if (event.type === "node.updated" && event.node.threadId === member?.childThreadId) | |
| 11228 | return [event.node.id]; | |
| 11229 | if ( | |
| 11230 | event.type === "message.updated" && | |
| 11231 | event.message.threadId === member?.childThreadId | |
| 11232 | ) | |
| 11233 | return [event.message.id]; | |
| 11234 | if ( | |
| 11235 | event.type === "turn_item.updated" && | |
| 11236 | event.turnItem.threadId === member?.childThreadId | |
| 11237 | ) | |
| 11238 | return [event.turnItem.id]; | |
| 11239 | return []; | |
| 11240 | }); | |
| 11241 | assert.isAtLeast(new Set(ids).size, 6); | |
| 11242 | memberArtifacts.push(ids); | |
| 11243 | } | |
| 11244 | assert.notEqual(memberThreads[0], memberThreads[1]); | |
| 11245 | assert.deepEqual( | |
| 11246 | memberArtifacts[0]?.filter((id) => memberArtifacts[1]?.includes(id)), | |
| 11247 | [], | |
| 11248 | ); | |
| 11249 | }).pipe( | |
| 11250 | Effect.provide( | |
| 11251 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 11252 | ), | |
| 11253 | ), | |
| 11254 | ), | |
| 11255 | ); | |
| 11256 | ||
| 11257 | it.effect("stops only a live workflow task and waits for its stopped notification", () => | |
| 11258 | Effect.scoped( | |
| 11259 | Effect.gen(function* () { | |
| 11260 | const stoppedTasks: Array<string> = []; | |
| 11261 | let interrupted = false; | |
| 11262 | let closed = false; | |
| 11263 | const harness = yield* makeWorkflowHarnessWithOptions({ | |
| 11264 | stopTask: (taskId) => | |
| 11265 | Effect.sync(() => { | |
| 11266 | stoppedTasks.push(taskId); | |
| 11267 | }), | |
| 11268 | interrupt: Effect.sync(() => { | |
| 11269 | interrupted = true; | |
| 11270 | }), | |
| 11271 | close: () => | |
| 11272 | Effect.sync(() => { | |
| 11273 | closed = true; | |
| 11274 | }), | |
| 11275 | }); | |
| 11276 | yield* harness.runtime.startTurn( | |
| 11277 | makeClaudeTestTurnInput({ | |
| 11278 | threadId: harness.threadId, | |
| 11279 | providerThread: harness.providerThread, | |
| 11280 | now: yield* DateTime.now, | |
| 11281 | attemptId: RunAttemptId.make("attempt-workflow-stop"), | |
| 11282 | text: "Run the workflow.", | |
| 11283 | attachments: [], | |
| 11284 | }), | |
| 11285 | ); | |
| 11286 | yield* harness.offerAndWait(workflowToolUse); | |
| 11287 | yield* harness.offerAndWait(workflowTaskStarted); | |
| 11288 | yield* harness.offerAndWait(workflowLaunchAck(harness.transcriptDir)); | |
| 11289 | yield* harness.offerAndWait( | |
| 11290 | workflowSnapshot({ | |
| 11291 | uuid: "00000000-0000-4000-8000-000000001100", | |
| 11292 | state: "start", | |
| 11293 | }), | |
| 11294 | ); | |
| 11295 | let member = yield* Queue.take(harness.subagentReceipts); | |
| 11296 | while (!member.subagent.nativeTaskRef?.nativeId?.endsWith(":agent:2")) { | |
| 11297 | member = yield* Queue.take(harness.subagentReceipts); | |
| 11298 | } | |
| 11299 | const stopTask = harness.runtime.stopTask!; | |
| 11300 | for (const taskId of ["missing-task", `${WORKFLOW_TASK_ID}:agent:1`]) { | |
| 11301 | const exit = yield* stopTask({ providerThread: harness.providerThread, taskId }).pipe( | |
| 11302 | Effect.exit, | |
| 11303 | ); | |
| 11304 | assert.isTrue(Exit.isFailure(exit)); | |
| 11305 | } | |
| 11306 | const wrongThread = { | |
| 11307 | ...harness.providerThread, | |
| 11308 | nativeThreadRef: { | |
| 11309 | driver: harness.providerThread.driver, | |
| 11310 | nativeId: "wrong-session", | |
| 11311 | strength: "strong" as const, | |
| 11312 | }, | |
| 11313 | }; | |
| 11314 | assert.isTrue( | |
| 11315 | Exit.isFailure( | |
| 11316 | yield* stopTask({ | |
| 11317 | providerThread: wrongThread, | |
| 11318 | taskId: WORKFLOW_TASK_ID, | |
| 11319 | }).pipe(Effect.exit), | |
| 11320 | ), | |
| 11321 | ); | |
| 11322 | assert.deepEqual(stoppedTasks, []); | |
| 11323 | yield* stopTask({ providerThread: harness.providerThread, taskId: WORKFLOW_TASK_ID }); | |
| 11324 | assert.deepEqual(stoppedTasks, [WORKFLOW_TASK_ID]); | |
| 11325 | assert.isFalse(interrupted); | |
| 11326 | assert.isFalse(closed); | |
| 11327 | assert.equal(workflowCoordinatorEvents(harness.events).at(-1)?.subagent.status, "running"); | |
| 11328 | yield* harness.offerAndWait( | |
| 11329 | workflowNotification({ | |
| 11330 | uuid: "00000000-0000-4000-8000-000000001101", | |
| 11331 | status: "stopped", | |
| 11332 | }), | |
| 11333 | ); | |
| 11334 | member = yield* Queue.take(harness.subagentReceipts); | |
| 11335 | while (!member.subagent.nativeTaskRef?.nativeId?.endsWith(":agent:2")) { | |
| 11336 | member = yield* Queue.take(harness.subagentReceipts); | |
| 11337 | } | |
| 11338 | assert.equal( | |
| 11339 | workflowCoordinatorEvents(harness.events).at(-1)?.subagent.status, | |
| 11340 | "cancelled", | |
| 11341 | ); | |
| 11342 | for (const index of [1, 2]) { | |
| 11343 | assert.equal( | |
| 11344 | workflowMemberEvents(harness.events, index).at(-1)?.subagent.status, | |
| 11345 | "cancelled", | |
| 11346 | ); | |
| 11347 | } | |
| 11348 | assert.isTrue( | |
| 11349 | Exit.isFailure( | |
| 11350 | yield* stopTask({ | |
| 11351 | providerThread: harness.providerThread, | |
| 11352 | taskId: WORKFLOW_TASK_ID, | |
| 11353 | }).pipe(Effect.exit), | |
| 11354 | ), | |
| 11355 | ); | |
| 11356 | assert.deepEqual(stoppedTasks, [WORKFLOW_TASK_ID]); | |
| 11357 | }).pipe( | |
| 11358 | Effect.provide( | |
| 11359 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 11360 | ), | |
| 11361 | ), | |
| 11362 | ), | |
| 11363 | ); | |
| 11364 | ||
| 11365 | it.effect("projects each workflow member as its own subagent thread", () => | |
| 11366 | Effect.scoped( | |
| 11367 | Effect.gen(function* () { | |
| 11368 | const harness = yield* makeWorkflowHarness; | |
| 11369 | const now = yield* DateTime.now; | |
| 11370 | const coordinatorTurnItems = () => | |
| 11371 | harness.events.filter( | |
| 11372 | (event) => | |
| 11373 | event.type === "turn_item.updated" && | |
| 11374 | event.turnItem.type === "subagent" && | |
| 11375 | event.turnItem.nativeItemRef?.nativeId === WORKFLOW_TASK_ID, | |
| 11376 | ); | |
| 11377 | ||
| 11378 | yield* harness.runtime.startTurn( | |
| 11379 | makeClaudeTestTurnInput({ | |
| 11380 | threadId: harness.threadId, | |
| 11381 | providerThread: harness.providerThread, | |
| 11382 | now, | |
| 11383 | attemptId: RunAttemptId.make("attempt-claude-workflow-members"), | |
| 11384 | text: "Run the alpha workflow.", | |
| 11385 | attachments: [], | |
| 11386 | }), | |
| 11387 | ); | |
| 11388 | yield* launchWorkflow({ | |
| 11389 | harness, | |
| 11390 | snapshotUuid: "00000000-0000-4000-8000-000000001004", | |
| 11391 | state: "queued", | |
| 11392 | }); | |
| 11393 | ||
| 11394 | const coordinator = workflowCoordinatorEvents(harness.events).at(-1)?.subagent; | |
| 11395 | const first = workflowMemberEvents(harness.events, 1).at(-1)?.subagent; | |
| 11396 | const second = workflowMemberEvents(harness.events, 2).at(-1)?.subagent; | |
| 11397 | assert.equal(first?.status, "pending"); | |
| 11398 | assert.isNull(first?.startedAt); | |
| 11399 | assert.equal(second?.status, "pending"); | |
| 11400 | assert.isNull(second?.startedAt); | |
| 11401 | const coordinatorThread = harness.events.find( | |
| 11402 | (event) => | |
| 11403 | event.type === "app_thread.created" && | |
| 11404 | event.appThread.id === coordinator?.childThreadId, | |
| 11405 | ); | |
| 11406 | assert.equal( | |
| 11407 | coordinatorThread?.type === "app_thread.created" | |
| 11408 | ? coordinatorThread.appThread.title | |
| 11409 | : null, | |
| 11410 | "Workflow · probe-wf", | |
| 11411 | ); | |
| 11412 | assert.equal(first?.title, "alpha:one"); | |
| 11413 | assert.equal(first?.prompt, "Reply with exactly: A1"); | |
| 11414 | assert.equal(first?.model, "claude-opus-5[1m]"); | |
| 11415 | // Members hang off the coordinator's own thread, not the launching run's. | |
| 11416 | assert.equal(first?.threadId, coordinator?.childThreadId); | |
| 11417 | assert.equal(second?.threadId, coordinator?.childThreadId); | |
| 11418 | assert.notEqual(first?.childThreadId, second?.childThreadId); | |
| 11419 | ||
| 11420 | assert.equal( | |
| 11421 | harness.events.find( | |
| 11422 | ( | |
| 11423 | event, | |
| 11424 | ): event is Extract< | |
| 11425 | ProviderAdapter.ProviderAdapterV2Event, | |
| 11426 | { type: "app_thread.created" } | |
| 11427 | > => event.type === "app_thread.created" && event.appThread.id === first?.childThreadId, | |
| 11428 | )?.appThread.title, | |
| 11429 | "alpha:one", | |
| 11430 | ); | |
| 11431 | const memberNodes = harness.events.filter( | |
| 11432 | ( | |
| 11433 | event, | |
| 11434 | ): event is Extract<ProviderAdapter.ProviderAdapterV2Event, { type: "node.updated" }> => | |
| 11435 | event.type === "node.updated" && | |
| 11436 | event.node.nativeItemRef?.nativeId === first?.nativeTaskRef?.nativeId, | |
| 11437 | ); | |
| 11438 | assert.deepEqual( | |
| 11439 | memberNodes.map((event) => event.node.kind), | |
| 11440 | ["subagent", "root_turn"], | |
| 11441 | ); | |
| 11442 | assert.deepEqual( | |
| 11443 | memberNodes.map((event) => [event.node.status, event.node.startedAt]), | |
| 11444 | [ | |
| 11445 | ["pending", null], | |
| 11446 | ["pending", null], | |
| 11447 | ], | |
| 11448 | ); | |
| 11449 | assert.deepEqual( | |
| 11450 | threadMessages(harness.events, first?.childThreadId).map((event) => event.message.text), | |
| 11451 | ["Reply with exactly: A1"], | |
| 11452 | ); | |
| 11453 | assert.deepEqual( | |
| 11454 | threadMessages(harness.events, second?.childThreadId).map((event) => event.message.text), | |
| 11455 | ["Reply with exactly: A2"], | |
| 11456 | ); | |
| 11457 | ||
| 11458 | yield* TestClock.adjust("60 seconds"); | |
| 11459 | const memberStartedAt = DateTime.add(now, { seconds: 30 }); | |
| 11460 | yield* harness.offerAndWait( | |
| 11461 | workflowSnapshot({ | |
| 11462 | uuid: "00000000-0000-4000-8000-000000001007", | |
| 11463 | state: "start", | |
| 11464 | secondState: "queued", | |
| 11465 | startedAt: DateTime.toEpochMillis(memberStartedAt), | |
| 11466 | }), | |
| 11467 | ); | |
| 11468 | let started = yield* Queue.take(harness.subagentReceipts); | |
| 11469 | while (started.subagent.id !== first?.id || started.subagent.status !== "running") { | |
| 11470 | started = yield* Queue.take(harness.subagentReceipts); | |
| 11471 | } | |
| 11472 | assert.deepEqual(started.subagent.startedAt, memberStartedAt); | |
| 11473 | const startedRoot = harness.events.findLast( | |
| 11474 | (event) => event.type === "node.updated" && event.node.threadId === first?.childThreadId, | |
| 11475 | ); | |
| 11476 | assert.equal( | |
| 11477 | startedRoot?.type === "node.updated" ? startedRoot.node.status : null, | |
| 11478 | "running", | |
| 11479 | ); | |
| 11480 | assert.deepEqual( | |
| 11481 | startedRoot?.type === "node.updated" ? startedRoot.node.startedAt : null, | |
| 11482 | memberStartedAt, | |
| 11483 | ); | |
| 11484 | ||
| 11485 | const turnItemsBeforeRepeat = coordinatorTurnItems().length; | |
| 11486 | yield* Queue.offer( | |
| 11487 | harness.sdkMessages, | |
| 11488 | workflowSnapshot({ | |
| 11489 | uuid: "00000000-0000-4000-8000-000000001005", | |
| 11490 | state: "start", | |
| 11491 | secondState: "queued", | |
| 11492 | }), | |
| 11493 | ); | |
| 11494 | // The coordinator's turn item is emitted after its members are | |
| 11495 | // projected, so a re-emitted member would already be in `events`. | |
| 11496 | yield* awaitUntil( | |
| 11497 | () => coordinatorTurnItems().length > turnItemsBeforeRepeat, | |
| 11498 | "repeated workflow snapshot", | |
| 11499 | ); | |
| 11500 | assert.lengthOf(workflowMemberEvents(harness.events, 1), 2); | |
| 11501 | assert.lengthOf(workflowMemberEvents(harness.events, 2), 1); | |
| 11502 | ||
| 11503 | const firstThreadId = first?.childThreadId; | |
| 11504 | const firstThreadEvents = () => | |
| 11505 | harness.events.filter( | |
| 11506 | ( | |
| 11507 | event, | |
| 11508 | ): event is Extract< | |
| 11509 | ProviderAdapter.ProviderAdapterV2Event, | |
| 11510 | { type: "app_thread.created" } | |
| 11511 | > => event.type === "app_thread.created" && event.appThread.id === firstThreadId, | |
| 11512 | ); | |
| 11513 | const turnItemsBeforeMetadata = coordinatorTurnItems().length; | |
| 11514 | yield* Queue.offer( | |
| 11515 | harness.sdkMessages, | |
| 11516 | workflowSnapshot({ | |
| 11517 | uuid: "00000000-0000-4000-8000-000000001017", | |
| 11518 | state: "start", | |
| 11519 | secondState: "queued", | |
| 11520 | label: "alpha:review", | |
| 11521 | prompt: "Review the change", | |
| 11522 | model: "claude-sonnet-4-6", | |
| 11523 | }), | |
| 11524 | ); | |
| 11525 | yield* awaitUntil( | |
| 11526 | () => coordinatorTurnItems().length > turnItemsBeforeMetadata, | |
| 11527 | "updated workflow member metadata", | |
| 11528 | ); | |
| 11529 | assert.equal( | |
| 11530 | workflowMemberEvents(harness.events, 1).at(-1)?.subagent.title, | |
| 11531 | "alpha:review", | |
| 11532 | ); | |
| 11533 | assert.equal( | |
| 11534 | workflowMemberEvents(harness.events, 1).at(-1)?.subagent.prompt, | |
| 11535 | "Review the change", | |
| 11536 | ); | |
| 11537 | assert.equal( | |
| 11538 | workflowMemberEvents(harness.events, 1).at(-1)?.subagent.model, | |
| 11539 | "claude-sonnet-4-6", | |
| 11540 | ); | |
| 11541 | assert.equal(firstThreadEvents().at(-1)?.appThread.title, "alpha:review"); | |
| 11542 | assert.equal( | |
| 11543 | firstThreadEvents().at(-1)?.appThread.modelSelection.model, | |
| 11544 | "claude-sonnet-4-6", | |
| 11545 | ); | |
| 11546 | assert.deepEqual( | |
| 11547 | firstThreadEvents().at(-1)?.appThread.createdAt, | |
| 11548 | firstThreadEvents()[0]?.appThread.createdAt, | |
| 11549 | ); | |
| 11550 | assert.equal( | |
| 11551 | threadMessages(harness.events, firstThreadId).at(-1)?.message.text, | |
| 11552 | "Review the change", | |
| 11553 | ); | |
| 11554 | ||
| 11555 | yield* TestClock.adjust("10 seconds"); | |
| 11556 | yield* harness.offerAndWait( | |
| 11557 | workflowSnapshot({ | |
| 11558 | uuid: "00000000-0000-4000-8000-000000001018", | |
| 11559 | state: "done", | |
| 11560 | secondState: "queued", | |
| 11561 | omitResult: true, | |
| 11562 | }), | |
| 11563 | ); | |
| 11564 | let finished = yield* Queue.take(harness.subagentReceipts); | |
| 11565 | while (finished.subagent.id !== first?.id || finished.subagent.status !== "completed") { | |
| 11566 | finished = yield* Queue.take(harness.subagentReceipts); | |
| 11567 | } | |
| 11568 | const completedAt = finished.subagent.completedAt; | |
| 11569 | assert.deepEqual(completedAt, DateTime.add(now, { seconds: 70 })); | |
| 11570 | yield* TestClock.adjust("60 seconds"); | |
| 11571 | yield* harness.offerAndWait( | |
| 11572 | workflowSnapshot({ | |
| 11573 | uuid: "00000000-0000-4000-8000-000000001019", | |
| 11574 | state: "done", | |
| 11575 | secondState: "queued", | |
| 11576 | }), | |
| 11577 | ); | |
| 11578 | let excerpt = yield* Queue.take(harness.subagentReceipts); | |
| 11579 | while (excerpt.subagent.id !== first?.id || excerpt.subagent.result !== "A1 excerpt") { | |
| 11580 | excerpt = yield* Queue.take(harness.subagentReceipts); | |
| 11581 | } | |
| 11582 | assert.deepEqual(excerpt.subagent.completedAt, completedAt); | |
| 11583 | const finishedRoot = harness.events.findLast( | |
| 11584 | (event) => event.type === "node.updated" && event.node.threadId === firstThreadId, | |
| 11585 | ); | |
| 11586 | assert.deepEqual( | |
| 11587 | finishedRoot?.type === "node.updated" ? finishedRoot.node.completedAt : undefined, | |
| 11588 | completedAt, | |
| 11589 | ); | |
| 11590 | ||
| 11591 | yield* harness.offerAndWait( | |
| 11592 | workflowNotification({ uuid: "00000000-0000-4000-8000-000000001008", status: "stopped" }), | |
| 11593 | ); | |
| 11594 | yield* Queue.offer( | |
| 11595 | harness.sdkMessages, | |
| 11596 | makeResultFrame({ | |
| 11597 | uuid: "00000000-0000-4000-8000-000000001006", | |
| 11598 | result: "Launched the workflow.", | |
| 11599 | }), | |
| 11600 | ); | |
| 11601 | yield* Queue.take(harness.terminalReceipts); | |
| 11602 | const stopped = workflowMemberEvents(harness.events, 2).at(-1)?.subagent; | |
| 11603 | assert.equal(stopped?.status, "cancelled"); | |
| 11604 | assert.isNull(stopped?.startedAt); | |
| 11605 | const stoppedRoot = harness.events.findLast( | |
| 11606 | (event) => event.type === "node.updated" && event.node.threadId === second?.childThreadId, | |
| 11607 | ); | |
| 11608 | assert.equal( | |
| 11609 | stoppedRoot?.type === "node.updated" ? stoppedRoot.node.status : null, | |
| 11610 | "cancelled", | |
| 11611 | ); | |
| 11612 | assert.isNull( | |
| 11613 | stoppedRoot?.type === "node.updated" ? stoppedRoot.node.startedAt : undefined, | |
| 11614 | ); | |
| 11615 | }).pipe( | |
| 11616 | Effect.provide( | |
| 11617 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 11618 | ), | |
| 11619 | ), | |
| 11620 | ), | |
| 11621 | ); | |
| 11622 | ||
| 11623 | it.effect("projects workflow member threads after resuming a restarted session", () => | |
| 11624 | Effect.scoped( | |
| 11625 | Effect.gen(function* () { | |
| 11626 | const harness = yield* makeWorkflowHarnessWithOptions({ | |
| 11627 | subagentLaunchToolUseId: WORKFLOW_TOOL_USE_ID, | |
| 11628 | }); | |
| 11629 | yield* harness.runtime.startTurn( | |
| 11630 | makeClaudeTestTurnInput({ | |
| 11631 | threadId: harness.threadId, | |
| 11632 | providerThread: harness.providerThread, | |
| 11633 | now: yield* DateTime.now, | |
| 11634 | attemptId: RunAttemptId.make("attempt-claude-workflow-resume"), | |
| 11635 | text: "Continue the workflow.", | |
| 11636 | attachments: [], | |
| 11637 | }), | |
| 11638 | ); | |
| 11639 | yield* harness.offerAndWait( | |
| 11640 | claudeSdkFrame({ | |
| 11641 | ...workflowToolUse, | |
| 11642 | message: { | |
| 11643 | model: "claude-sonnet-4-6", | |
| 11644 | content: [ | |
| 11645 | { type: "tool_use", id: "toolu-resume-workflow", name: "SendMessage", input: {} }, | |
| 11646 | ], | |
| 11647 | }, | |
| 11648 | }), | |
| 11649 | ); | |
| 11650 | yield* harness.offerAndWait( | |
| 11651 | claudeSdkFrame({ | |
| 11652 | ...workflowTaskStarted, | |
| 11653 | tool_use_id: "toolu-resume-workflow", | |
| 11654 | prompt: "Continue the workflow.", | |
| 11655 | }), | |
| 11656 | ); | |
| 11657 | yield* harness.offerAndWait(workflowLaunchAck(harness.transcriptDir)); | |
| 11658 | yield* harness.offerAndWait( | |
| 11659 | workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001040", state: "start" }), | |
| 11660 | ); | |
| 11661 | yield* harness.offerAndWait( | |
| 11662 | makeResultFrame({ uuid: "00000000-0000-4000-8000-000000001041", result: "Resumed." }), | |
| 11663 | ); | |
| 11664 | yield* Queue.take(harness.terminalReceipts); | |
| 11665 | const member = workflowMemberEvents(harness.events, 1).at(-1)?.subagent; | |
| 11666 | const coordinator = workflowCoordinatorEvents(harness.events).at(-1)?.subagent; | |
| 11667 | assert.isDefined(member); | |
| 11668 | assert.equal(member?.status, "running"); | |
| 11669 | assert.isTrue( | |
| 11670 | harness.events.some( | |
| 11671 | (event) => | |
| 11672 | event.type === "app_thread.created" && event.appThread.id === member?.childThreadId, | |
| 11673 | ), | |
| 11674 | ); | |
| 11675 | assert.deepEqual( | |
| 11676 | threadMessages(harness.events, member?.childThreadId).map((event) => event.message.text), | |
| 11677 | ["Reply with exactly: A1"], | |
| 11678 | ); | |
| 11679 | assert.isFalse( | |
| 11680 | harness.events.some( | |
| 11681 | (event) => | |
| 11682 | event.type === "app_thread.created" && | |
| 11683 | event.appThread.id === coordinator?.childThreadId, | |
| 11684 | ), | |
| 11685 | ); | |
| 11686 | }).pipe( | |
| 11687 | Effect.provide( | |
| 11688 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 11689 | ), | |
| 11690 | ), | |
| 11691 | ), | |
| 11692 | ); | |
| 11693 | ||
| 11694 | it.effect("does not republish a workflow roster on progress-only frames", () => | |
| 11695 | Effect.scoped( | |
| 11696 | Effect.gen(function* () { | |
| 11697 | const harness = yield* makeWorkflowHarness; | |
| 11698 | const now = yield* DateTime.now; | |
| 11699 | yield* harness.runtime.startTurn( | |
| 11700 | makeClaudeTestTurnInput({ | |
| 11701 | threadId: harness.threadId, | |
| 11702 | providerThread: harness.providerThread, | |
| 11703 | now, | |
| 11704 | attemptId: RunAttemptId.make("attempt-claude-workflow-progress-only"), | |
| 11705 | text: "Run the alpha workflow.", | |
| 11706 | attachments: [], | |
| 11707 | }), | |
| 11708 | ); | |
| 11709 | yield* launchWorkflow({ harness, snapshotUuid: "00000000-0000-4000-8000-000000001120" }); | |
| 11710 | const coordinatorCount = () => workflowCoordinatorEvents(harness.events).length; | |
| 11711 | const offerProgressOnly = (index: number) => | |
| 11712 | Queue.offer( | |
| 11713 | harness.sdkMessages, | |
| 11714 | claudeSdkFrame({ | |
| 11715 | type: "system", | |
| 11716 | subtype: "task_progress", | |
| 11717 | task_id: WORKFLOW_TASK_ID, | |
| 11718 | tool_use_id: WORKFLOW_TOOL_USE_ID, | |
| 11719 | description: "Member is working", | |
| 11720 | uuid: `00000000-0000-4000-8000-${String(1200 + index).padStart(12, "0")}`, | |
| 11721 | session_id: WAKE_NATIVE_SESSION, | |
| 11722 | }), | |
| 11723 | ); | |
| 11724 | ||
| 11725 | const inTurnCount = coordinatorCount(); | |
| 11726 | for (let index = 0; index < 5; index++) yield* offerProgressOnly(index); | |
| 11727 | yield* Queue.offer( | |
| 11728 | harness.sdkMessages, | |
| 11729 | workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001121", state: "start" }), | |
| 11730 | ); | |
| 11731 | yield* awaitUntil(() => coordinatorCount() > inTurnCount, "in-turn workflow snapshot"); | |
| 11732 | assert.equal(coordinatorCount(), inTurnCount + 1); | |
| 11733 | ||
| 11734 | yield* Queue.offer( | |
| 11735 | harness.sdkMessages, | |
| 11736 | makeResultFrame({ | |
| 11737 | uuid: "00000000-0000-4000-8000-000000001122", | |
| 11738 | result: "Launched the workflow.", | |
| 11739 | }), | |
| 11740 | ); | |
| 11741 | yield* Queue.take(harness.terminalReceipts); | |
| 11742 | ||
| 11743 | const betweenTurnCount = coordinatorCount(); | |
| 11744 | for (let index = 5; index < 10; index++) yield* offerProgressOnly(index); | |
| 11745 | yield* Queue.offer( | |
| 11746 | harness.sdkMessages, | |
| 11747 | workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001123", state: "done" }), | |
| 11748 | ); | |
| 11749 | yield* awaitUntil( | |
| 11750 | () => coordinatorCount() > betweenTurnCount, | |
| 11751 | "between-turn workflow snapshot", | |
| 11752 | ); | |
| 11753 | assert.equal(coordinatorCount(), betweenTurnCount + 1); | |
| 11754 | }).pipe( | |
| 11755 | Effect.provide( | |
| 11756 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 11757 | ), | |
| 11758 | ), | |
| 11759 | ), | |
| 11760 | ); | |
| 11761 | ||
| 11762 | it.effect("keeps resumed workflow tools open after stale interrupted cleanup", () => | |
| 11763 | Effect.scoped( | |
| 11764 | Effect.gen(function* () { | |
| 11765 | const clock = yield* Clock.Clock; | |
| 11766 | const cleanupPaused = yield* Deferred.make<void>(); | |
| 11767 | const releaseCleanup = yield* Deferred.make<void>(); | |
| 11768 | let readsUntilPause = 0; | |
| 11769 | yield* Effect.gen(function* () { | |
| 11770 | const harness = yield* makeWorkflowHarnessWithOptions({ | |
| 11771 | close: Queue.shutdown, | |
| 11772 | freshQueueOnReopen: true, | |
| 11773 | }); | |
| 11774 | const oldAttempt = RunAttemptId.make("attempt-workflow-stale-cleanup-old"); | |
| 11775 | const oldBash = "toolu-workflow-stale-cleanup-old-bash"; | |
| 11776 | const newBash = "toolu-workflow-stale-cleanup-new-bash"; | |
| 11777 | const resumeTool = "toolu-workflow-stale-cleanup-resume"; | |
| 11778 | const toolUpdates = (nativeId: string) => | |
| 11779 | harness.events.flatMap((event) => | |
| 11780 | event.type === "turn_item.updated" && | |
| 11781 | event.turnItem.type === "command_execution" && | |
| 11782 | event.turnItem.nativeItemRef?.nativeId === nativeId | |
| 11783 | ? [event.turnItem] | |
| 11784 | : [], | |
| 11785 | ); | |
| 11786 | yield* harness.runtime.startTurn( | |
| 11787 | makeClaudeTestTurnInput({ | |
| 11788 | threadId: harness.threadId, | |
| 11789 | providerThread: harness.providerThread, | |
| 11790 | now: yield* DateTime.now, | |
| 11791 | attemptId: oldAttempt, | |
| 11792 | text: "Run the workflow.", | |
| 11793 | attachments: [], | |
| 11794 | }), | |
| 11795 | ); | |
| 11796 | yield* launchWorkflow({ harness, snapshotUuid: "00000000-0000-4000-8000-000000001150" }); | |
| 11797 | for (const frame of makeSubagentAssistantFrames({ | |
| 11798 | parentToolUseId: WORKFLOW_TOOL_USE_ID, | |
| 11799 | uuid: "00000000-0000-4000-8000-000000001151", | |
| 11800 | bashToolUseId: oldBash, | |
| 11801 | })) { | |
| 11802 | yield* harness.offerAndWait(frame); | |
| 11803 | } | |
| 11804 | yield* awaitUntil(() => toolUpdates(oldBash).length === 1, "old workflow tool running"); | |
| 11805 | readsUntilPause = 2; | |
| 11806 | const stop = yield* harness.runtime | |
| 11807 | .interruptTurn({ | |
| 11808 | providerThread: harness.providerThread, | |
| 11809 | providerTurnId: (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({ | |
| 11810 | driver: ClaudeAdapterV2.CLAUDE_PROVIDER, | |
| 11811 | nativeTurnId: `turn:${oldAttempt}`, | |
| 11812 | }), | |
| 11813 | }) | |
| 11814 | .pipe(Effect.forkScoped); | |
| 11815 | yield* Deferred.await(cleanupPaused); | |
| 11816 | yield* TestClock.adjust("10 seconds"); | |
| 11817 | yield* Fiber.join(stop); | |
| 11818 | yield* Queue.take(harness.terminalReceipts); | |
| 11819 | const oldToolCount = toolUpdates(oldBash).length; | |
| 11820 | yield* harness.runtime.startTurn( | |
| 11821 | makeClaudeTestTurnInput({ | |
| 11822 | threadId: harness.threadId, | |
| 11823 | providerThread: harness.providerThread, | |
| 11824 | now: yield* DateTime.now, | |
| 11825 | attemptId: RunAttemptId.make("attempt-workflow-stale-cleanup-new"), | |
| 11826 | text: "Resume the workflow.", | |
| 11827 | attachments: [], | |
| 11828 | providerTurnOrdinal: 2, | |
| 11829 | }), | |
| 11830 | ); | |
| 11831 | const resumedQueue = harness.processQueues[1]!; | |
| 11832 | yield* Queue.offer( | |
| 11833 | resumedQueue, | |
| 11834 | claudeSdkFrame({ | |
| 11835 | ...workflowTaskStarted, | |
| 11836 | tool_use_id: resumeTool, | |
| 11837 | prompt: "Resume the workflow.", | |
| 11838 | uuid: "00000000-0000-4000-8000-000000001152", | |
| 11839 | }), | |
| 11840 | ); | |
| 11841 | for (const frame of makeSubagentAssistantFrames({ | |
| 11842 | parentToolUseId: resumeTool, | |
| 11843 | uuid: "00000000-0000-4000-8000-000000001153", | |
| 11844 | bashToolUseId: newBash, | |
| 11845 | })) { | |
| 11846 | yield* Queue.offer(resumedQueue, frame); | |
| 11847 | } | |
| 11848 | yield* awaitUntil( | |
| 11849 | () => toolUpdates(newBash).length === 1, | |
| 11850 | "resumed workflow tool running", | |
| 11851 | ); | |
| 11852 | yield* Deferred.succeed(releaseCleanup, undefined); | |
| 11853 | yield* Queue.take(harness.terminalReceipts); | |
| 11854 | assert.lengthOf(toolUpdates(oldBash), oldToolCount); | |
| 11855 | assert.deepEqual( | |
| 11856 | toolUpdates(newBash).map((item) => item.status), | |
| 11857 | ["running"], | |
| 11858 | ); | |
| 11859 | yield* Queue.offer( | |
| 11860 | resumedQueue, | |
| 11861 | makeSubagentToolResultFrame({ | |
| 11862 | parentToolUseId: resumeTool, | |
| 11863 | uuid: "00000000-0000-4000-8000-000000001154", | |
| 11864 | toolUseId: newBash, | |
| 11865 | }), | |
| 11866 | ); | |
| 11867 | yield* awaitUntil( | |
| 11868 | () => toolUpdates(newBash).at(-1)?.status === "completed", | |
| 11869 | "resumed workflow tool completed", | |
| 11870 | ); | |
| 11871 | assert.equal(toolUpdates(newBash).at(-1)?.output, "ok"); | |
| 11872 | }).pipe( | |
| 11873 | Effect.provideService(Clock.Clock, { | |
| 11874 | ...clock, | |
| 11875 | currentTimeMillis: Effect.gen(function* () { | |
| 11876 | if (readsUntilPause > 0 && --readsUntilPause === 0) { | |
| 11877 | yield* Deferred.succeed(cleanupPaused, undefined); | |
| 11878 | yield* Deferred.await(releaseCleanup); | |
| 11879 | } | |
| 11880 | return yield* clock.currentTimeMillis; | |
| 11881 | }), | |
| 11882 | }), | |
| 11883 | ); | |
| 11884 | }).pipe( | |
| 11885 | Effect.provide( | |
| 11886 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 11887 | ), | |
| 11888 | ), | |
| 11889 | ), | |
| 11890 | ); | |
| 11891 | ||
| 11892 | it.effect.each(["root result", "query close"] as const)( | |
| 11893 | "keeps a waiting continuation inactive until replay can handle %s", | |
| 11894 | (arrival) => | |
| 11895 | Effect.scoped( | |
| 11896 | Effect.gen(function* () { | |
| 11897 | const crypto = yield* Crypto.Crypto; | |
| 11898 | const digestStarted = yield* Deferred.make<void>(); | |
| 11899 | const releaseDigest = yield* Deferred.make<void>(); | |
| 11900 | const closeStarted = yield* Deferred.make<void>(); | |
| 11901 | let pauseDigest = false; | |
| 11902 | yield* Effect.gen(function* () { | |
| 11903 | const harness = yield* makeWorkflowHarnessWithOptions({ | |
| 11904 | freshQueueOnReopen: true, | |
| 11905 | close: (messages) => | |
| 11906 | Deferred.succeed(closeStarted, undefined).pipe( | |
| 11907 | Effect.andThen(Queue.shutdown(messages)), | |
| 11908 | ), | |
| 11909 | }); | |
| 11910 | const start = (attemptId: string, ordinal: number, continuation: boolean) => | |
| 11911 | harness.runtime.startTurn( | |
| 11912 | makeClaudeTestTurnInput({ | |
| 11913 | threadId: harness.threadId, | |
| 11914 | providerThread: harness.providerThread, | |
| 11915 | now: DateTime.makeUnsafe(0), | |
| 11916 | attemptId: RunAttemptId.make(attemptId), | |
| 11917 | providerTurnOrdinal: ordinal, | |
| 11918 | text: "Continue working.", | |
| 11919 | attachments: [], | |
| 11920 | ...(continuation | |
| 11921 | ? { messageCreatedBy: "agent", messageCreationSource: "provider" } | |
| 11922 | : {}), | |
| 11923 | }), | |
| 11924 | ); | |
| 11925 | yield* start("attempt-workflow-waiting-launch", 1, false); | |
| 11926 | yield* launchWorkflow({ | |
| 11927 | harness, | |
| 11928 | snapshotUuid: "00000000-0000-4000-8000-000000003200", | |
| 11929 | }); | |
| 11930 | const fileSystem = yield* FileSystem.FileSystem; | |
| 11931 | const liveTranscriptDir = NodePath.join(harness.transcriptDir, "live"); | |
| 11932 | yield* fileSystem.makeDirectory(liveTranscriptDir); | |
| 11933 | yield* fileSystem.writeFileString( | |
| 11934 | NodePath.join(liveTranscriptDir, "agent-a1.jsonl"), | |
| 11935 | '{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Live member answer"}]}}\n', | |
| 11936 | ); | |
| 11937 | const liveTaskId = "task-workflow-live-while-waiting"; | |
| 11938 | const liveToolUseId = "toolu-workflow-live-while-waiting"; | |
| 11939 | yield* harness.offerAndWait( | |
| 11940 | claudeSdkFrame({ | |
| 11941 | type: "assistant", | |
| 11942 | message: { | |
| 11943 | model: "claude-sonnet-4-6", | |
| 11944 | content: [{ type: "tool_use", id: liveToolUseId, name: "Workflow", input: {} }], | |
| 11945 | }, | |
| 11946 | parent_tool_use_id: null, | |
| 11947 | session_id: WAKE_NATIVE_SESSION, | |
| 11948 | uuid: "00000000-0000-4000-8000-000000003201", | |
| 11949 | }), | |
| 11950 | ); | |
| 11951 | yield* harness.offerAndWait( | |
| 11952 | claudeSdkFrame({ | |
| 11953 | ...workflowTaskStarted, | |
| 11954 | task_id: liveTaskId, | |
| 11955 | tool_use_id: liveToolUseId, | |
| 11956 | uuid: "00000000-0000-4000-8000-000000003202", | |
| 11957 | }), | |
| 11958 | ); | |
| 11959 | yield* harness.offerAndWait( | |
| 11960 | claudeSdkFrame({ | |
| 11961 | type: "user", | |
| 11962 | message: { | |
| 11963 | role: "user", | |
| 11964 | content: [ | |
| 11965 | { type: "tool_result", tool_use_id: liveToolUseId, content: "Launched." }, | |
| 11966 | ], | |
| 11967 | }, | |
| 11968 | tool_use_result: { | |
| 11969 | taskType: "local_workflow", | |
| 11970 | runId: "wf_live_while_waiting", | |
| 11971 | transcriptDir: liveTranscriptDir, | |
| 11972 | }, | |
| 11973 | parent_tool_use_id: null, | |
| 11974 | session_id: WAKE_NATIVE_SESSION, | |
| 11975 | uuid: "00000000-0000-4000-8000-000000003203", | |
| 11976 | }), | |
| 11977 | ); | |
| 11978 | const liveProgress = (state: "start" | "done", uuid: string) => | |
| 11979 | claudeSdkFrame({ | |
| 11980 | ...workflowSnapshot({ state, uuid }), | |
| 11981 | task_id: liveTaskId, | |
| 11982 | tool_use_id: liveToolUseId, | |
| 11983 | }); | |
| 11984 | yield* harness.offerAndWait( | |
| 11985 | liveProgress("start", "00000000-0000-4000-8000-000000003204"), | |
| 11986 | ); | |
| 11987 | yield* harness.offerAndWait( | |
| 11988 | makeResultFrame({ | |
| 11989 | uuid: "00000000-0000-4000-8000-000000003205", | |
| 11990 | result: "Launched both workflows.", | |
| 11991 | }), | |
| 11992 | ); | |
| 11993 | yield* Queue.take(harness.terminalReceipts); | |
| 11994 | yield* harness.offerAndWait( | |
| 11995 | workflowNotification({ | |
| 11996 | uuid: "00000000-0000-4000-8000-000000003206", | |
| 11997 | status: "completed", | |
| 11998 | }), | |
| 11999 | ); | |
| 12000 | yield* Queue.take(harness.continuationReceipts); | |
| 12001 | yield* harness.offerAndWait(wakeTurnInit); | |
| 12002 | pauseDigest = true; | |
| 12003 | const progress = yield* harness | |
| 12004 | .offerAndWait(liveProgress("done", "00000000-0000-4000-8000-000000003207")) | |
| 12005 | .pipe(Effect.forkScoped); | |
| 12006 | yield* Deferred.await(digestStarted); | |
| 12007 | const replay = yield* start("attempt-workflow-waiting-replay", 2, true).pipe( | |
| 12008 | Effect.forkScoped({ startImmediately: true }), | |
| 12009 | ); | |
| 12010 | if (arrival === "query close") { | |
| 12011 | const stop = yield* harness.runtime | |
| 12012 | .interruptTurn({ | |
| 12013 | providerThread: harness.providerThread, | |
| 12014 | providerTurnId: (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({ | |
| 12015 | driver: ClaudeAdapterV2.CLAUDE_PROVIDER, | |
| 12016 | nativeTurnId: "turn:attempt-workflow-waiting-replay", | |
| 12017 | }), | |
| 12018 | requestRuntimeRestart: true, | |
| 12019 | }) | |
| 12020 | .pipe(Effect.forkScoped); | |
| 12021 | yield* Deferred.await(closeStarted); | |
| 12022 | yield* Deferred.succeed(releaseDigest, undefined); | |
| 12023 | yield* Fiber.join(progress); | |
| 12024 | yield* Fiber.join(stop); | |
| 12025 | assert.isTrue(Exit.isFailure(yield* Fiber.await(replay))); | |
| 12026 | } else { | |
| 12027 | const offered = yield* Deferred.make<void>(); | |
| 12028 | const result = yield* harness | |
| 12029 | .offerAndWait(wakeResult, offered) | |
| 12030 | .pipe(Effect.forkScoped); | |
| 12031 | yield* Deferred.await(offered); | |
| 12032 | yield* Deferred.succeed(releaseDigest, undefined); | |
| 12033 | yield* Fiber.join(progress); | |
| 12034 | yield* Fiber.join(replay); | |
| 12035 | yield* Fiber.join(result); | |
| 12036 | yield* Queue.take(harness.terminalReceipts); | |
| 12037 | assert.lengthOf(harness.continuationRequests, 1); | |
| 12038 | } | |
| 12039 | yield* start("attempt-workflow-waiting-next-user", 3, false); | |
| 12040 | }).pipe( | |
| 12041 | Effect.provideService(Crypto.Crypto, { | |
| 12042 | ...crypto, | |
| 12043 | digest: (algorithm, data) => | |
| 12044 | Effect.gen(function* () { | |
| 12045 | if (pauseDigest) { | |
| 12046 | pauseDigest = false; | |
| 12047 | yield* Deferred.succeed(digestStarted, undefined); | |
| 12048 | yield* Deferred.await(releaseDigest); | |
| 12049 | } | |
| 12050 | return yield* crypto.digest(algorithm, data); | |
| 12051 | }), | |
| 12052 | }), | |
| 12053 | ); | |
| 12054 | }).pipe( | |
| 12055 | Effect.provide( | |
| 12056 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 12057 | ), | |
| 12058 | ), | |
| 12059 | ), | |
| 12060 | ); | |
| 12061 | ||
| 12062 | it.effect.each(["registry", "transcript"] as const)( | |
| 12063 | "does not replay stale idle progress over a completed workflow after awaiting %s", | |
| 12064 | (boundary) => | |
| 12065 | Effect.scoped( | |
| 12066 | Effect.gen(function* () { | |
| 12067 | const clock = yield* Clock.Clock; | |
| 12068 | const crypto = yield* Crypto.Crypto; | |
| 12069 | const progressRead = yield* Deferred.make<void>(); | |
| 12070 | const releaseProgress = yield* Deferred.make<void>(); | |
| 12071 | let pauseNextRead = false; | |
| 12072 | yield* Effect.gen(function* () { | |
| 12073 | const harness = yield* makeWorkflowHarness; | |
| 12074 | yield* harness.runtime.startTurn( | |
| 12075 | makeClaudeTestTurnInput({ | |
| 12076 | threadId: harness.threadId, | |
| 12077 | providerThread: harness.providerThread, | |
| 12078 | now: yield* DateTime.now, | |
| 12079 | attemptId: RunAttemptId.make("attempt-claude-idle-race-launch"), | |
| 12080 | text: "Run the workflow.", | |
| 12081 | attachments: [], | |
| 12082 | }), | |
| 12083 | ); | |
| 12084 | yield* launchWorkflow({ | |
| 12085 | harness, | |
| 12086 | snapshotUuid: "00000000-0000-4000-8000-000000001060", | |
| 12087 | }); | |
| 12088 | yield* harness.offerAndWait( | |
| 12089 | makeResultFrame({ | |
| 12090 | uuid: "00000000-0000-4000-8000-000000001061", | |
| 12091 | result: "Launched.", | |
| 12092 | }), | |
| 12093 | ); | |
| 12094 | yield* Queue.take(harness.terminalReceipts); | |
| 12095 | yield* harness.offerAndWait( | |
| 12096 | workflowNotification({ | |
| 12097 | uuid: "00000000-0000-4000-8000-000000001062", | |
| 12098 | status: "completed", | |
| 12099 | }), | |
| 12100 | ); | |
| 12101 | if (boundary === "registry") { | |
| 12102 | yield* harness.offerAndWait( | |
| 12103 | makeResultFrame({ uuid: "00000000-0000-4000-8000-000000001063", result: "Done." }), | |
| 12104 | ); | |
| 12105 | } | |
| 12106 | const continuationInput = makeClaudeTestTurnInput({ | |
| 12107 | threadId: harness.threadId, | |
| 12108 | providerThread: harness.providerThread, | |
| 12109 | now: yield* DateTime.now, | |
| 12110 | attemptId: RunAttemptId.make("attempt-claude-idle-race-continuation"), | |
| 12111 | text: "Continue.", | |
| 12112 | attachments: [], | |
| 12113 | providerTurnOrdinal: 2, | |
| 12114 | messageCreatedBy: "agent", | |
| 12115 | messageCreationSource: "provider", | |
| 12116 | }); | |
| 12117 | pauseNextRead = true; | |
| 12118 | const progress = yield* harness | |
| 12119 | .offerAndWait( | |
| 12120 | workflowSnapshot({ | |
| 12121 | uuid: "00000000-0000-4000-8000-000000001064", | |
| 12122 | state: boundary === "transcript" ? "done" : "start", | |
| 12123 | secondState: "start", | |
| 12124 | }), | |
| 12125 | ) | |
| 12126 | .pipe(Effect.forkScoped); | |
| 12127 | yield* Deferred.await(progressRead); | |
| 12128 | const continuation = yield* harness.runtime | |
| 12129 | .startTurn(continuationInput) | |
| 12130 | .pipe(Effect.forkScoped); | |
| 12131 | yield* Deferred.succeed(releaseProgress, undefined); | |
| 12132 | yield* Fiber.join(progress); | |
| 12133 | yield* Fiber.join(continuation); | |
| 12134 | if (boundary === "registry") { | |
| 12135 | yield* Queue.take(harness.terminalReceipts); | |
| 12136 | } else { | |
| 12137 | const lastMemberId = workflowMemberEvents(harness.events, 2)[0]?.subagent.id; | |
| 12138 | while (true) { | |
| 12139 | const event = yield* Queue.take(harness.subagentReceipts); | |
| 12140 | if (event.subagent.id === lastMemberId && event.subagent.status === "completed") | |
| 12141 | break; | |
| 12142 | } | |
| 12143 | } | |
| 12144 | if (boundary === "transcript") { | |
| 12145 | yield* harness.offerAndWait( | |
| 12146 | makeResultFrame({ uuid: "00000000-0000-4000-8000-000000001065", result: "Done." }), | |
| 12147 | ); | |
| 12148 | yield* Queue.take(harness.terminalReceipts); | |
| 12149 | } | |
| 12150 | assert.isFalse(yield* harness.hasPendingBackgroundWork); | |
| 12151 | assert.equal( | |
| 12152 | workflowCoordinatorEvents(harness.events).at(-1)?.subagent.status, | |
| 12153 | "completed", | |
| 12154 | ); | |
| 12155 | const member = workflowMemberEvents(harness.events, 2).at(-1)?.subagent; | |
| 12156 | assert.equal(member?.status, "completed"); | |
| 12157 | const root = harness.events.findLast( | |
| 12158 | (event) => | |
| 12159 | event.type === "node.updated" && event.node.threadId === member?.childThreadId, | |
| 12160 | ); | |
| 12161 | assert.equal(root?.type === "node.updated" ? root.node.status : null, "completed"); | |
| 12162 | const item = harness.events.findLast( | |
| 12163 | (event) => | |
| 12164 | event.type === "turn_item.updated" && | |
| 12165 | event.turnItem.type === "subagent" && | |
| 12166 | event.turnItem.nativeItemRef?.nativeId === WORKFLOW_TASK_ID, | |
| 12167 | ); | |
| 12168 | assert.equal( | |
| 12169 | item?.type === "turn_item.updated" ? item.turnItem.status : null, | |
| 12170 | "completed", | |
| 12171 | ); | |
| 12172 | }).pipe( | |
| 12173 | Effect.provideService(Clock.Clock, { | |
| 12174 | ...clock, | |
| 12175 | currentTimeMillis: Effect.gen(function* () { | |
| 12176 | if (boundary === "registry" && pauseNextRead) { | |
| 12177 | pauseNextRead = false; | |
| 12178 | yield* Deferred.succeed(progressRead, undefined); | |
| 12179 | yield* Deferred.await(releaseProgress); | |
| 12180 | } | |
| 12181 | return yield* clock.currentTimeMillis; | |
| 12182 | }), | |
| 12183 | }), | |
| 12184 | Effect.provideService(Crypto.Crypto, { | |
| 12185 | ...crypto, | |
| 12186 | digest: (algorithm, data) => | |
| 12187 | Effect.gen(function* () { | |
| 12188 | if (boundary === "transcript" && pauseNextRead) { | |
| 12189 | pauseNextRead = false; | |
| 12190 | yield* Deferred.succeed(progressRead, undefined); | |
| 12191 | yield* Deferred.await(releaseProgress); | |
| 12192 | } | |
| 12193 | return yield* crypto.digest(algorithm, data); | |
| 12194 | }), | |
| 12195 | }), | |
| 12196 | ); | |
| 12197 | }).pipe( | |
| 12198 | Effect.provide( | |
| 12199 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 12200 | ), | |
| 12201 | ), | |
| 12202 | ), | |
| 12203 | ); | |
| 12204 | ||
| 12205 | it.effect.each([ | |
| 12206 | "root result", | |
| 12207 | "resume after root result", | |
| 12208 | "completion before buffered start", | |
| 12209 | ] as const)("orders %s arriving during workflow transcript replay", (arrival) => | |
| 12210 | Effect.scoped( | |
| 12211 | Effect.gen(function* () { | |
| 12212 | const crypto = yield* Crypto.Crypto; | |
| 12213 | const digestStarted = yield* Deferred.make<void>(); | |
| 12214 | const releaseDigest = yield* Deferred.make<void>(); | |
| 12215 | const liveFrameOffered = yield* Deferred.make<void>(); | |
| 12216 | let pauseDigest = false; | |
| 12217 | yield* Effect.gen(function* () { | |
| 12218 | const harness = yield* makeWorkflowHarnessWithOptions({ close: Queue.shutdown }); | |
| 12219 | const taskId = "task-resumed-during-workflow-replay"; | |
| 12220 | const taskStart = (toolUseId: string, prompt: string, uuid: string) => | |
| 12221 | claudeSdkFrame({ | |
| 12222 | ...workflowTaskStarted, | |
| 12223 | task_id: taskId, | |
| 12224 | task_type: "local_agent", | |
| 12225 | is_backgrounded: true, | |
| 12226 | subagent_type: "general-purpose", | |
| 12227 | spawn_depth: 1, | |
| 12228 | workflow_name: undefined, | |
| 12229 | tool_use_id: toolUseId, | |
| 12230 | prompt, | |
| 12231 | uuid, | |
| 12232 | }); | |
| 12233 | const taskFinished = (toolUseId: string, summary: string, uuid: string) => | |
| 12234 | claudeSdkFrame({ | |
| 12235 | ...workflowNotification({ uuid, status: "completed" }), | |
| 12236 | task_id: taskId, | |
| 12237 | tool_use_id: toolUseId, | |
| 12238 | summary, | |
| 12239 | }); | |
| 12240 | yield* harness.runtime.startTurn( | |
| 12241 | makeClaudeTestTurnInput({ | |
| 12242 | threadId: harness.threadId, | |
| 12243 | providerThread: harness.providerThread, | |
| 12244 | now: yield* DateTime.now, | |
| 12245 | attemptId: RunAttemptId.make("attempt-workflow-replay-launch"), | |
| 12246 | text: "Run the workflow and a background subagent.", | |
| 12247 | attachments: [], | |
| 12248 | }), | |
| 12249 | ); | |
| 12250 | yield* harness.offerAndWait(workflowToolUse); | |
| 12251 | yield* harness.offerAndWait(workflowTaskStarted); | |
| 12252 | yield* harness.offerAndWait(workflowLaunchAck(harness.transcriptDir)); | |
| 12253 | yield* harness.offerAndWait( | |
| 12254 | workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000003001", state: "start" }), | |
| 12255 | ); | |
| 12256 | if (arrival !== "completion before buffered start") { | |
| 12257 | yield* harness.offerAndWait( | |
| 12258 | taskStart("toolu-initial", "Initial work.", "00000000-0000-4000-8000-000000003002"), | |
| 12259 | ); | |
| 12260 | } | |
| 12261 | yield* harness.offerAndWait( | |
| 12262 | makeResultFrame({ | |
| 12263 | uuid: "00000000-0000-4000-8000-000000003003", | |
| 12264 | result: "Launched both tasks.", | |
| 12265 | }), | |
| 12266 | ); | |
| 12267 | yield* Queue.take(harness.terminalReceipts); | |
| 12268 | yield* harness.offerAndWait( | |
| 12269 | workflowNotification({ | |
| 12270 | uuid: "00000000-0000-4000-8000-000000003004", | |
| 12271 | status: "completed", | |
| 12272 | }), | |
| 12273 | ); | |
| 12274 | yield* Queue.take(harness.continuationReceipts); | |
| 12275 | if (arrival === "completion before buffered start") { | |
| 12276 | yield* harness.offerAndWait( | |
| 12277 | taskStart("toolu-initial", "Initial work.", "00000000-0000-4000-8000-000000003005"), | |
| 12278 | ); | |
| 12279 | } else { | |
| 12280 | yield* harness.offerAndWait( | |
| 12281 | taskFinished( | |
| 12282 | "toolu-initial", | |
| 12283 | "Initial result.", | |
| 12284 | "00000000-0000-4000-8000-000000003005", | |
| 12285 | ), | |
| 12286 | ); | |
| 12287 | } | |
| 12288 | if (arrival === "root result") { | |
| 12289 | yield* harness.offerAndWait( | |
| 12290 | claudeSdkFrame({ | |
| 12291 | type: "system", | |
| 12292 | subtype: "init", | |
| 12293 | session_id: WAKE_NATIVE_SESSION, | |
| 12294 | uuid: "00000000-0000-4000-8000-000000003006", | |
| 12295 | }), | |
| 12296 | ); | |
| 12297 | } | |
| 12298 | pauseDigest = true; | |
| 12299 | const replay = yield* harness.runtime | |
| 12300 | .startTurn( | |
| 12301 | makeClaudeTestTurnInput({ | |
| 12302 | threadId: harness.threadId, | |
| 12303 | providerThread: harness.providerThread, | |
| 12304 | now: yield* DateTime.now, | |
| 12305 | attemptId: RunAttemptId.make("attempt-workflow-replay-continuation"), | |
| 12306 | providerTurnOrdinal: 2, | |
| 12307 | text: "Continue.", | |
| 12308 | attachments: [], | |
| 12309 | messageCreatedBy: "agent", | |
| 12310 | messageCreationSource: "provider", | |
| 12311 | }), | |
| 12312 | ) | |
| 12313 | .pipe(Effect.forkScoped); | |
| 12314 | yield* Deferred.await(digestStarted); | |
| 12315 | const liveFrames = yield* Effect.gen(function* () { | |
| 12316 | if (arrival === "completion before buffered start") { | |
| 12317 | yield* harness.offerAndWait( | |
| 12318 | taskFinished( | |
| 12319 | "toolu-initial", | |
| 12320 | "Initial result.", | |
| 12321 | "00000000-0000-4000-8000-000000003007", | |
| 12322 | ), | |
| 12323 | liveFrameOffered, | |
| 12324 | ); | |
| 12325 | return; | |
| 12326 | } | |
| 12327 | yield* harness.offerAndWait( | |
| 12328 | claudeSdkFrame({ | |
| 12329 | ...makeResultFrame({ | |
| 12330 | uuid: "00000000-0000-4000-8000-000000003008", | |
| 12331 | result: "Native wake turn finished.", | |
| 12332 | }), | |
| 12333 | origin: { kind: "task-notification" }, | |
| 12334 | num_turns: 1, | |
| 12335 | }), | |
| 12336 | liveFrameOffered, | |
| 12337 | ); | |
| 12338 | if (arrival === "resume after root result") { | |
| 12339 | yield* harness.offerAndWait( | |
| 12340 | claudeSdkFrame({ | |
| 12341 | type: "system", | |
| 12342 | subtype: "init", | |
| 12343 | session_id: WAKE_NATIVE_SESSION, | |
| 12344 | uuid: "00000000-0000-4000-8000-000000003009", | |
| 12345 | }), | |
| 12346 | ); | |
| 12347 | yield* harness.offerAndWait( | |
| 12348 | taskStart( | |
| 12349 | "toolu-latest", | |
| 12350 | "Latest live resume.", | |
| 12351 | "00000000-0000-4000-8000-000000003010", | |
| 12352 | ), | |
| 12353 | ); | |
| 12354 | } | |
| 12355 | }).pipe(Effect.forkScoped); | |
| 12356 | yield* Deferred.await(liveFrameOffered); | |
| 12357 | yield* Deferred.succeed(releaseDigest, undefined); | |
| 12358 | yield* Fiber.join(replay); | |
| 12359 | yield* Fiber.join(liveFrames); | |
| 12360 | if (arrival === "resume after root result") { | |
| 12361 | yield* Queue.take(harness.terminalReceipts); | |
| 12362 | yield* Queue.take(harness.continuationReceipts); | |
| 12363 | yield* harness.runtime.startTurn( | |
| 12364 | makeClaudeTestTurnInput({ | |
| 12365 | threadId: harness.threadId, | |
| 12366 | providerThread: harness.providerThread, | |
| 12367 | now: yield* DateTime.now, | |
| 12368 | attemptId: RunAttemptId.make("attempt-workflow-replay-resume-continuation"), | |
| 12369 | providerTurnOrdinal: 3, | |
| 12370 | text: "Continue.", | |
| 12371 | attachments: [], | |
| 12372 | messageCreatedBy: "agent", | |
| 12373 | messageCreationSource: "provider", | |
| 12374 | }), | |
| 12375 | ); | |
| 12376 | } | |
| 12377 | if (arrival !== "root result") { | |
| 12378 | yield* harness.offerAndWait( | |
| 12379 | makeResultFrame({ | |
| 12380 | uuid: "00000000-0000-4000-8000-000000003011", | |
| 12381 | result: "Workflow answer delivered.", | |
| 12382 | }), | |
| 12383 | ); | |
| 12384 | } | |
| 12385 | yield* Queue.take(harness.terminalReceipts); | |
| 12386 | const latest = harness.events | |
| 12387 | .flatMap((event) => | |
| 12388 | event.type === "subagent.updated" && event.subagent.nativeTaskRef?.nativeId === taskId | |
| 12389 | ? [event.subagent] | |
| 12390 | : [], | |
| 12391 | ) | |
| 12392 | .at(-1); | |
| 12393 | if (arrival === "resume after root result") { | |
| 12394 | assert.equal(latest?.status, "running"); | |
| 12395 | assert.equal(latest?.prompt, "Latest live resume."); | |
| 12396 | assert.isNull(latest?.result); | |
| 12397 | assert.isTrue(yield* harness.hasPendingBackgroundWork); | |
| 12398 | } else { | |
| 12399 | assert.equal(latest?.status, "completed"); | |
| 12400 | assert.equal(latest?.result, "Initial result."); | |
| 12401 | assert.isFalse(yield* harness.hasPendingBackgroundWork); | |
| 12402 | assert.lengthOf(harness.continuationRequests, 1); | |
| 12403 | // The native wake turn ended once; no extra provider result will | |
| 12404 | // arrive to settle a mistakenly opened continuation. | |
| 12405 | yield* harness.runtime.startTurn( | |
| 12406 | makeClaudeTestTurnInput({ | |
| 12407 | threadId: harness.threadId, | |
| 12408 | providerThread: harness.providerThread, | |
| 12409 | now: yield* DateTime.now, | |
| 12410 | attemptId: RunAttemptId.make("attempt-workflow-replay-next-user"), | |
| 12411 | providerTurnOrdinal: 3, | |
| 12412 | text: "Next task.", | |
| 12413 | attachments: [], | |
| 12414 | }), | |
| 12415 | ); | |
| 12416 | } | |
| 12417 | }).pipe( | |
| 12418 | Effect.provideService(Crypto.Crypto, { | |
| 12419 | ...crypto, | |
| 12420 | digest: (algorithm, data) => | |
| 12421 | Effect.gen(function* () { | |
| 12422 | if (pauseDigest) { | |
| 12423 | pauseDigest = false; | |
| 12424 | yield* Deferred.succeed(digestStarted, undefined); | |
| 12425 | yield* Deferred.await(releaseDigest); | |
| 12426 | } | |
| 12427 | return yield* crypto.digest(algorithm, data); | |
| 12428 | }), | |
| 12429 | }), | |
| 12430 | ); | |
| 12431 | }).pipe( | |
| 12432 | Effect.provide( | |
| 12433 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 12434 | ), | |
| 12435 | ), | |
| 12436 | ), | |
| 12437 | ); | |
| 12438 | ||
| 12439 | it.effect("preserves buffered workflow outcomes when interrupted during replay", () => | |
| 12440 | Effect.scoped( | |
| 12441 | Effect.gen(function* () { | |
| 12442 | const crypto = yield* Crypto.Crypto; | |
| 12443 | const digestStarted = yield* Deferred.make<void>(); | |
| 12444 | const releaseDigest = yield* Deferred.make<void>(); | |
| 12445 | let pauseDigest = false; | |
| 12446 | yield* Effect.gen(function* () { | |
| 12447 | const harness = yield* makeWorkflowHarnessWithOptions({ close: Queue.shutdown }); | |
| 12448 | const secondTaskId = "task-workflow-buffered-completion"; | |
| 12449 | const secondToolUseId = "toolu-workflow-buffered-completion"; | |
| 12450 | yield* harness.runtime.startTurn( | |
| 12451 | makeClaudeTestTurnInput({ | |
| 12452 | threadId: harness.threadId, | |
| 12453 | providerThread: harness.providerThread, | |
| 12454 | now: yield* DateTime.now, | |
| 12455 | attemptId: RunAttemptId.make("attempt-workflow-buffered-launch"), | |
| 12456 | text: "Run both workflows.", | |
| 12457 | attachments: [], | |
| 12458 | }), | |
| 12459 | ); | |
| 12460 | yield* launchWorkflow({ | |
| 12461 | harness, | |
| 12462 | snapshotUuid: "00000000-0000-4000-8000-000000003100", | |
| 12463 | }); | |
| 12464 | yield* harness.offerAndWait( | |
| 12465 | claudeSdkFrame({ | |
| 12466 | ...workflowTaskStarted, | |
| 12467 | task_id: secondTaskId, | |
| 12468 | tool_use_id: secondToolUseId, | |
| 12469 | uuid: "00000000-0000-4000-8000-000000003101", | |
| 12470 | }), | |
| 12471 | ); | |
| 12472 | yield* harness.offerAndWait( | |
| 12473 | claudeSdkFrame({ | |
| 12474 | ...workflowSnapshot({ | |
| 12475 | uuid: "00000000-0000-4000-8000-000000003102", | |
| 12476 | state: "start", | |
| 12477 | }), | |
| 12478 | task_id: secondTaskId, | |
| 12479 | tool_use_id: secondToolUseId, | |
| 12480 | }), | |
| 12481 | ); | |
| 12482 | yield* harness.offerAndWait( | |
| 12483 | makeResultFrame({ | |
| 12484 | uuid: "00000000-0000-4000-8000-000000003103", | |
| 12485 | result: "Launched both workflows.", | |
| 12486 | }), | |
| 12487 | ); | |
| 12488 | yield* Queue.take(harness.terminalReceipts); | |
| 12489 | yield* harness.offerAndWait( | |
| 12490 | workflowNotification({ | |
| 12491 | uuid: "00000000-0000-4000-8000-000000003104", | |
| 12492 | status: "completed", | |
| 12493 | }), | |
| 12494 | ); | |
| 12495 | yield* Queue.take(harness.continuationReceipts); | |
| 12496 | yield* harness.offerAndWait( | |
| 12497 | claudeSdkFrame({ | |
| 12498 | ...workflowNotification({ | |
| 12499 | uuid: "00000000-0000-4000-8000-000000003105", | |
| 12500 | status: "completed", | |
| 12501 | }), | |
| 12502 | task_id: secondTaskId, | |
| 12503 | tool_use_id: secondToolUseId, | |
| 12504 | }), | |
| 12505 | ); | |
| 12506 | const continuationInput = makeClaudeTestTurnInput({ | |
| 12507 | threadId: harness.threadId, | |
| 12508 | providerThread: harness.providerThread, | |
| 12509 | now: yield* DateTime.now, | |
| 12510 | attemptId: RunAttemptId.make("attempt-workflow-buffered-replay"), | |
| 12511 | providerTurnOrdinal: 2, | |
| 12512 | text: "Continue.", | |
| 12513 | attachments: [], | |
| 12514 | messageCreatedBy: "agent", | |
| 12515 | messageCreationSource: "provider", | |
| 12516 | }); | |
| 12517 | pauseDigest = true; | |
| 12518 | const replay = yield* harness.runtime | |
| 12519 | .startTurn(continuationInput) | |
| 12520 | .pipe(Effect.forkScoped); | |
| 12521 | yield* Deferred.await(digestStarted); | |
| 12522 | yield* harness.runtime.interruptTurn({ | |
| 12523 | providerThread: harness.providerThread, | |
| 12524 | providerTurnId: (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({ | |
| 12525 | driver: ClaudeAdapterV2.CLAUDE_PROVIDER, | |
| 12526 | nativeTurnId: `turn:${continuationInput.attemptId}`, | |
| 12527 | }), | |
| 12528 | }); | |
| 12529 | yield* Deferred.succeed(releaseDigest, undefined); | |
| 12530 | yield* Fiber.join(replay); | |
| 12531 | const second = harness.events | |
| 12532 | .flatMap((event) => | |
| 12533 | event.type === "subagent.updated" && | |
| 12534 | event.subagent.nativeTaskRef?.nativeId === secondTaskId | |
| 12535 | ? [event.subagent] | |
| 12536 | : [], | |
| 12537 | ) | |
| 12538 | .at(-1); | |
| 12539 | assert.equal(second?.status, "completed"); | |
| 12540 | assert.equal(second?.result, "Workflow finished."); | |
| 12541 | }).pipe( | |
| 12542 | Effect.provideService(Crypto.Crypto, { | |
| 12543 | ...crypto, | |
| 12544 | digest: (algorithm, data) => | |
| 12545 | Effect.gen(function* () { | |
| 12546 | if (pauseDigest) { | |
| 12547 | pauseDigest = false; | |
| 12548 | yield* Deferred.succeed(digestStarted, undefined); | |
| 12549 | yield* Deferred.await(releaseDigest); | |
| 12550 | } | |
| 12551 | return yield* crypto.digest(algorithm, data); | |
| 12552 | }), | |
| 12553 | }), | |
| 12554 | ); | |
| 12555 | }).pipe( | |
| 12556 | Effect.provide( | |
| 12557 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 12558 | ), | |
| 12559 | ), | |
| 12560 | ), | |
| 12561 | ); | |
| 12562 | ||
| 12563 | it.effect.each([ | |
| 12564 | ["api_error", false], | |
| 12565 | ["aborted_streaming", false], | |
| 12566 | ["api_error", true], | |
| 12567 | ] as const)( | |
| 12568 | "stops the run's live workflow after root result %s, continuation=%s", | |
| 12569 | ([terminalReason, continuation]) => | |
| 12570 | Effect.scoped( | |
| 12571 | Effect.gen(function* () { | |
| 12572 | const stoppedTasks: Array<string> = []; | |
| 12573 | const harness = yield* makeWorkflowHarnessWithOptions({ | |
| 12574 | stopTask: (taskId) => Effect.sync(() => void stoppedTasks.push(taskId)), | |
| 12575 | }); | |
| 12576 | const now = yield* DateTime.now; | |
| 12577 | const turnInput = { | |
| 12578 | threadId: harness.threadId, | |
| 12579 | providerThread: harness.providerThread, | |
| 12580 | now, | |
| 12581 | attemptId: RunAttemptId.make("attempt-claude-workflow-root-failure"), | |
| 12582 | text: "Run the workflow.", | |
| 12583 | attachments: [], | |
| 12584 | }; | |
| 12585 | yield* harness.runtime.startTurn(makeClaudeTestTurnInput(turnInput)); | |
| 12586 | yield* launchWorkflow({ harness, snapshotUuid: "00000000-0000-4000-8000-000000001050" }); | |
| 12587 | if (continuation) { | |
| 12588 | yield* harness.offerAndWait( | |
| 12589 | makeResultFrame({ | |
| 12590 | uuid: "00000000-0000-4000-8000-000000001051", | |
| 12591 | result: "Launched.", | |
| 12592 | }), | |
| 12593 | ); | |
| 12594 | yield* Queue.take(harness.terminalReceipts); | |
| 12595 | yield* harness.offerAndWait( | |
| 12596 | claudeSdkFrame({ | |
| 12597 | ...workflowTaskStarted, | |
| 12598 | uuid: "00000000-0000-4000-8000-000000001052", | |
| 12599 | }), | |
| 12600 | ); | |
| 12601 | } | |
| 12602 | yield* harness.offerAndWait( | |
| 12603 | makeResultFrame({ | |
| 12604 | uuid: "00000000-0000-4000-8000-000000001053", | |
| 12605 | result: "Root turn ended.", | |
| 12606 | terminalReason, | |
| 12607 | isError: terminalReason === "api_error", | |
| 12608 | }), | |
| 12609 | ); | |
| 12610 | if (continuation) { | |
| 12611 | yield* harness.runtime.startTurn( | |
| 12612 | makeClaudeTestTurnInput({ | |
| 12613 | ...turnInput, | |
| 12614 | attemptId: RunAttemptId.make("attempt-claude-workflow-root-failure-wake"), | |
| 12615 | providerTurnOrdinal: 2, | |
| 12616 | messageCreatedBy: "agent", | |
| 12617 | messageCreationSource: "provider", | |
| 12618 | }), | |
| 12619 | ); | |
| 12620 | } | |
| 12621 | const terminal = yield* Queue.take(harness.terminalReceipts); | |
| 12622 | assert.equal(terminal.status, terminalReason === "api_error" ? "failed" : "interrupted"); | |
| 12623 | assert.equal(terminal.threadDisposition, "reusable"); | |
| 12624 | // The run following the workflow (its launcher, or the run a resumed | |
| 12625 | // workflow was re-attributed to) stops following it, so it is stopped | |
| 12626 | // instead of left running out of sight. | |
| 12627 | const settled = terminalReason === "api_error" ? "failed" : "cancelled"; | |
| 12628 | assert.deepEqual(stoppedTasks, [WORKFLOW_TASK_ID]); | |
| 12629 | assert.isFalse(yield* harness.hasPendingBackgroundWork); | |
| 12630 | assert.equal(workflowCoordinatorEvents(harness.events).at(-1)?.subagent.status, settled); | |
| 12631 | for (const index of [1, 2]) { | |
| 12632 | assert.equal( | |
| 12633 | workflowMemberEvents(harness.events, index).at(-1)?.subagent.status, | |
| 12634 | settled, | |
| 12635 | ); | |
| 12636 | } | |
| 12637 | }).pipe( | |
| 12638 | Effect.provide( | |
| 12639 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 12640 | ), | |
| 12641 | ), | |
| 12642 | ), | |
| 12643 | ); | |
| 12644 | ||
| 12645 | it.effect.each([ | |
| 12646 | ["stopped", false, false, false], | |
| 12647 | ["failed", false, false, false], | |
| 12648 | ["completed", false, false, false], | |
| 12649 | ["stopped", true, false, false], | |
| 12650 | ["failed", true, false, false], | |
| 12651 | ["completed", true, false, false], | |
| 12652 | ["interrupted", false, false, false], | |
| 12653 | ["interrupted", true, false, false], | |
| 12654 | ["interrupt_timeout", true, false, false], | |
| 12655 | ["stop_buffered", true, false, false], | |
| 12656 | ["stop_buffered", true, false, "buffered"], | |
| 12657 | ["stop_buffered", true, false, "live"], | |
| 12658 | ["drain_buffered", true, false, "live"], | |
| 12659 | ["drain_buffered", true, false, "live_after_buffered"], | |
| 12660 | ["query_failed", false, false, false], | |
| 12661 | ["query_failed", true, false, false], | |
| 12662 | ["query_failed", true, false, "buffered"], | |
| 12663 | ["query_failed", true, true, false], | |
| 12664 | ] as const)( | |
| 12665 | "projects workflow members on %s, between turns=%s, after an unrelated failed turn=%s, resume=%s", | |
| 12666 | ([status, betweenTurns, afterFailedTurn, resumed]) => | |
| 12667 | Effect.scoped( | |
| 12668 | Effect.gen(function* () { | |
| 12669 | const interrupted = status === "interrupted" || status === "interrupt_timeout"; | |
| 12670 | const liveResume = resumed === "live" || resumed === "live_after_buffered"; | |
| 12671 | const expected = | |
| 12672 | status === "drain_buffered" | |
| 12673 | ? resumed === "live_after_buffered" | |
| 12674 | ? "completed" | |
| 12675 | : "running" | |
| 12676 | : status === "stopped" || interrupted || (status === "stop_buffered" && resumed) | |
| 12677 | ? "cancelled" | |
| 12678 | : status === "query_failed" | |
| 12679 | ? "failed" | |
| 12680 | : status === "stop_buffered" | |
| 12681 | ? "completed" | |
| 12682 | : status; | |
| 12683 | const harness = yield* makeWorkflowHarnessWithOptions({ | |
| 12684 | close: status === "interrupt_timeout" ? () => Effect.void : Queue.shutdown, | |
| 12685 | }); | |
| 12686 | const now = yield* DateTime.now; | |
| 12687 | yield* harness.runtime.startTurn( | |
| 12688 | makeClaudeTestTurnInput({ | |
| 12689 | threadId: harness.threadId, | |
| 12690 | providerThread: harness.providerThread, | |
| 12691 | now, | |
| 12692 | attemptId: RunAttemptId.make("attempt-workflow-terminal"), | |
| 12693 | text: "Run the workflow.", | |
| 12694 | attachments: [], | |
| 12695 | }), | |
| 12696 | ); | |
| 12697 | yield* launchWorkflow({ harness, snapshotUuid: "00000000-0000-4000-8000-000000001010" }); | |
| 12698 | if (betweenTurns) { | |
| 12699 | yield* Queue.offer( | |
| 12700 | harness.sdkMessages, | |
| 12701 | makeResultFrame({ | |
| 12702 | uuid: "00000000-0000-4000-8000-000000001013", | |
| 12703 | result: "Launched.", | |
| 12704 | }), | |
| 12705 | ); | |
| 12706 | yield* Queue.take(harness.terminalReceipts); | |
| 12707 | if (afterFailedTurn || status === "interrupt_timeout") { | |
| 12708 | yield* harness.runtime.startTurn( | |
| 12709 | makeClaudeTestTurnInput({ | |
| 12710 | threadId: harness.threadId, | |
| 12711 | providerThread: harness.providerThread, | |
| 12712 | now, | |
| 12713 | attemptId: RunAttemptId.make("attempt-unrelated-failed"), | |
| 12714 | providerTurnOrdinal: 2, | |
| 12715 | text: "Unrelated follow-up.", | |
| 12716 | attachments: [], | |
| 12717 | }), | |
| 12718 | ); | |
| 12719 | if (afterFailedTurn) { | |
| 12720 | yield* Queue.offer( | |
| 12721 | harness.sdkMessages, | |
| 12722 | makeResultFrame({ | |
| 12723 | uuid: "00000000-0000-4000-8000-000000001015", | |
| 12724 | result: "Unrelated follow-up failed.", | |
| 12725 | terminalReason: "api_error", | |
| 12726 | isError: true, | |
| 12727 | }), | |
| 12728 | ); | |
| 12729 | yield* Queue.take(harness.terminalReceipts); | |
| 12730 | } | |
| 12731 | } | |
| 12732 | if (status === "query_failed") { | |
| 12733 | yield* harness.offerAndWait( | |
| 12734 | workflowSnapshot({ | |
| 12735 | uuid: "00000000-0000-4000-8000-000000001014", | |
| 12736 | state: "start", | |
| 12737 | label: "Updated between turns", | |
| 12738 | }), | |
| 12739 | ); | |
| 12740 | } | |
| 12741 | } | |
| 12742 | const bufferedCompletion = | |
| 12743 | status === "stop_buffered" || status === "drain_buffered" || resumed; | |
| 12744 | if ((!interrupted && status !== "query_failed") || resumed) | |
| 12745 | yield* Queue.offer( | |
| 12746 | harness.sdkMessages, | |
| 12747 | workflowNotification({ | |
| 12748 | uuid: "00000000-0000-4000-8000-000000001011", | |
| 12749 | status: bufferedCompletion ? "completed" : status, | |
| 12750 | }), | |
| 12751 | ); | |
| 12752 | if (betweenTurns && ((status !== "query_failed" && !interrupted) || resumed)) { | |
| 12753 | yield* Queue.take(harness.continuationReceipts); | |
| 12754 | } | |
| 12755 | if (resumed) { | |
| 12756 | if (resumed === "live_after_buffered") { | |
| 12757 | yield* harness.offerAndWait( | |
| 12758 | claudeSdkFrame({ | |
| 12759 | ...workflowTaskStarted, | |
| 12760 | tool_use_id: "toolu-workflow-earlier-resume", | |
| 12761 | prompt: "Earlier buffered resume.", | |
| 12762 | uuid: "00000000-0000-4000-8000-000000001019", | |
| 12763 | }), | |
| 12764 | ); | |
| 12765 | yield* harness.offerAndWait( | |
| 12766 | claudeSdkFrame({ | |
| 12767 | ...workflowNotification({ | |
| 12768 | uuid: "00000000-0000-4000-8000-000000001020", | |
| 12769 | status: "completed", | |
| 12770 | }), | |
| 12771 | tool_use_id: "toolu-workflow-earlier-resume", | |
| 12772 | }), | |
| 12773 | ); | |
| 12774 | } | |
| 12775 | if (liveResume) { | |
| 12776 | yield* harness.runtime.startTurn( | |
| 12777 | makeClaudeTestTurnInput({ | |
| 12778 | threadId: harness.threadId, | |
| 12779 | providerThread: harness.providerThread, | |
| 12780 | now, | |
| 12781 | attemptId: RunAttemptId.make("attempt-workflow-live-resume"), | |
| 12782 | providerTurnOrdinal: 2, | |
| 12783 | text: "Continue the workflow.", | |
| 12784 | attachments: [], | |
| 12785 | }), | |
| 12786 | ); | |
| 12787 | } | |
| 12788 | yield* harness.offerAndWait( | |
| 12789 | claudeSdkFrame({ | |
| 12790 | ...workflowTaskStarted, | |
| 12791 | tool_use_id: "toolu-workflow-resume", | |
| 12792 | prompt: "Continue the workflow.", | |
| 12793 | uuid: "00000000-0000-4000-8000-000000001016", | |
| 12794 | }), | |
| 12795 | ); | |
| 12796 | yield* harness.offerAndWait( | |
| 12797 | workflowSnapshot({ | |
| 12798 | uuid: "00000000-0000-4000-8000-000000001017", | |
| 12799 | state: "start", | |
| 12800 | attempt: 2, | |
| 12801 | label: "Updated between turns", | |
| 12802 | }), | |
| 12803 | ); | |
| 12804 | if (liveResume) { | |
| 12805 | if (resumed === "live_after_buffered") { | |
| 12806 | yield* harness.offerAndWait( | |
| 12807 | claudeSdkFrame({ | |
| 12808 | ...workflowNotification({ | |
| 12809 | uuid: "00000000-0000-4000-8000-000000001021", | |
| 12810 | status: "completed", | |
| 12811 | }), | |
| 12812 | tool_use_id: "toolu-workflow-resume", | |
| 12813 | summary: "Latest workflow result.", | |
| 12814 | }), | |
| 12815 | ); | |
| 12816 | } | |
| 12817 | yield* harness.offerAndWait( | |
| 12818 | makeResultFrame({ | |
| 12819 | uuid: "00000000-0000-4000-8000-000000001018", | |
| 12820 | result: "Resumed the workflow.", | |
| 12821 | }), | |
| 12822 | ); | |
| 12823 | yield* Queue.take(harness.terminalReceipts); | |
| 12824 | } | |
| 12825 | } | |
| 12826 | if (betweenTurns && status !== "query_failed" && !interrupted && !bufferedCompletion) { | |
| 12827 | yield* harness.runtime.startTurn( | |
| 12828 | makeClaudeTestTurnInput({ | |
| 12829 | threadId: harness.threadId, | |
| 12830 | providerThread: harness.providerThread, | |
| 12831 | now, | |
| 12832 | attemptId: RunAttemptId.make("attempt-workflow-terminal-wake"), | |
| 12833 | text: "Workflow finished.", | |
| 12834 | attachments: [], | |
| 12835 | providerTurnOrdinal: 2, | |
| 12836 | messageCreatedBy: "agent", | |
| 12837 | messageCreationSource: "provider", | |
| 12838 | }), | |
| 12839 | ); | |
| 12840 | } | |
| 12841 | if (interrupted || status === "stop_buffered") { | |
| 12842 | const interrupt = harness.runtime.interruptTurn({ | |
| 12843 | providerThread: harness.providerThread, | |
| 12844 | providerTurnId: (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({ | |
| 12845 | driver: ClaudeAdapterV2.CLAUDE_PROVIDER, | |
| 12846 | nativeTurnId: | |
| 12847 | status === "interrupt_timeout" | |
| 12848 | ? "turn:attempt-unrelated-failed" | |
| 12849 | : liveResume | |
| 12850 | ? "turn:attempt-workflow-live-resume" | |
| 12851 | : "turn:attempt-workflow-terminal", | |
| 12852 | }), | |
| 12853 | ...(betweenTurns ? { requestRuntimeRestart: true } : {}), | |
| 12854 | }); | |
| 12855 | if (status === "interrupt_timeout") { | |
| 12856 | const stop = yield* interrupt.pipe(Effect.forkScoped); | |
| 12857 | yield* TestClock.adjust("10 seconds"); | |
| 12858 | yield* Fiber.join(stop); | |
| 12859 | assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "interrupted"); | |
| 12860 | assert.isFalse(yield* harness.hasPendingBackgroundWork); | |
| 12861 | } else { | |
| 12862 | yield* interrupt; | |
| 12863 | } | |
| 12864 | } else if (status === "query_failed") { | |
| 12865 | yield* Queue.shutdown(harness.sdkMessages); | |
| 12866 | } else if (status === "drain_buffered") { | |
| 12867 | yield* harness.runtime.startTurn( | |
| 12868 | makeClaudeTestTurnInput({ | |
| 12869 | threadId: harness.threadId, | |
| 12870 | providerThread: harness.providerThread, | |
| 12871 | now, | |
| 12872 | attemptId: RunAttemptId.make("attempt-workflow-terminal-wake"), | |
| 12873 | text: "Workflow finished.", | |
| 12874 | attachments: [], | |
| 12875 | providerTurnOrdinal: 3, | |
| 12876 | messageCreatedBy: "agent", | |
| 12877 | messageCreationSource: "provider", | |
| 12878 | }), | |
| 12879 | ); | |
| 12880 | assert.equal( | |
| 12881 | workflowCoordinatorEvents(harness.events).at(-1)?.subagent.status, | |
| 12882 | expected, | |
| 12883 | ); | |
| 12884 | } else { | |
| 12885 | yield* Queue.offer( | |
| 12886 | harness.sdkMessages, | |
| 12887 | makeResultFrame({ | |
| 12888 | uuid: "00000000-0000-4000-8000-000000001012", | |
| 12889 | result: "Finished.", | |
| 12890 | }), | |
| 12891 | ); | |
| 12892 | } | |
| 12893 | if ( | |
| 12894 | betweenTurns && | |
| 12895 | (status === "query_failed" || interrupted || status === "stop_buffered") | |
| 12896 | ) { | |
| 12897 | // The launching turn already ended. Await the last member's | |
| 12898 | // terminal event, which follows its root node update. | |
| 12899 | const lastMemberId = workflowMemberEvents(harness.events, 2)[0]?.subagent.id; | |
| 12900 | while (true) { | |
| 12901 | const update = yield* Queue.take(harness.subagentReceipts); | |
| 12902 | if (update.subagent.id === lastMemberId && update.subagent.status !== "running") | |
| 12903 | break; | |
| 12904 | } | |
| 12905 | assert.isFalse(yield* harness.hasPendingBackgroundWork); | |
| 12906 | if (status === "query_failed") { | |
| 12907 | assert.equal( | |
| 12908 | workflowMemberEvents(harness.events, 1).at(-1)?.subagent.title, | |
| 12909 | "Updated between turns", | |
| 12910 | ); | |
| 12911 | } | |
| 12912 | } else { | |
| 12913 | yield* Queue.take(harness.terminalReceipts); | |
| 12914 | } | |
| 12915 | assert.equal(workflowCoordinatorEvents(harness.events).at(-1)?.subagent.status, expected); | |
| 12916 | if (status === "drain_buffered") | |
| 12917 | assert.equal(yield* harness.hasPendingBackgroundWork, expected === "running"); | |
| 12918 | if (resumed === "live_after_buffered") { | |
| 12919 | assert.equal( | |
| 12920 | workflowCoordinatorEvents(harness.events).at(-1)?.subagent.result, | |
| 12921 | "Latest workflow result.", | |
| 12922 | ); | |
| 12923 | } | |
| 12924 | if (resumed) { | |
| 12925 | assert.notEqual( | |
| 12926 | workflowCoordinatorEvents(harness.events).at(-1)?.subagent.result, | |
| 12927 | "Workflow finished.", | |
| 12928 | ); | |
| 12929 | } | |
| 12930 | for (const index of [1, 2]) { | |
| 12931 | const updates = workflowMemberEvents(harness.events, index); | |
| 12932 | const member = updates.at(-1)?.subagent; | |
| 12933 | assert.equal(member?.status, expected); | |
| 12934 | assert.deepEqual(member?.startedAt, updates[0]?.subagent.startedAt); | |
| 12935 | const root = harness.events.findLast( | |
| 12936 | (event) => | |
| 12937 | event.type === "node.updated" && event.node.threadId === member?.childThreadId, | |
| 12938 | ); | |
| 12939 | assert.equal(root?.type === "node.updated" ? root.node.status : null, expected); | |
| 12940 | } | |
| 12941 | }).pipe( | |
| 12942 | Effect.provide( | |
| 12943 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 12944 | ), | |
| 12945 | ), | |
| 12946 | ), | |
| 12947 | ); | |
| 12948 | ||
| 12949 | it.effect("reads a transcript that appears after a terminal workflow notification", () => | |
| 12950 | Effect.scoped( | |
| 12951 | Effect.gen(function* () { | |
| 12952 | const harness = yield* makeWorkflowHarness; | |
| 12953 | const now = yield* DateTime.now; | |
| 12954 | yield* harness.runtime.startTurn( | |
| 12955 | makeClaudeTestTurnInput({ | |
| 12956 | threadId: harness.threadId, | |
| 12957 | providerThread: harness.providerThread, | |
| 12958 | now, | |
| 12959 | attemptId: RunAttemptId.make("attempt-claude-workflow-late-terminal-answer"), | |
| 12960 | text: "Run the alpha workflow.", | |
| 12961 | attachments: [], | |
| 12962 | }), | |
| 12963 | ); | |
| 12964 | yield* launchWorkflow({ harness, snapshotUuid: "00000000-0000-4000-8000-000000001030" }); | |
| 12965 | const secondThreadId = workflowMemberEvents(harness.events, 2)[0]?.subagent.childThreadId; | |
| 12966 | yield* Queue.offer( | |
| 12967 | harness.sdkMessages, | |
| 12968 | workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001031", state: "done" }), | |
| 12969 | ); | |
| 12970 | yield* awaitUntil( | |
| 12971 | () => threadMessages(harness.events, secondThreadId).length === 2, | |
| 12972 | "workflow member excerpt", | |
| 12973 | ); | |
| 12974 | const notification = workflowNotification({ | |
| 12975 | uuid: "00000000-0000-4000-8000-000000001032", | |
| 12976 | status: "completed", | |
| 12977 | }); | |
| 12978 | yield* Queue.offer(harness.sdkMessages, notification); | |
| 12979 | yield* awaitUntil( | |
| 12980 | () => | |
| 12981 | harness.events.some( | |
| 12982 | (event) => | |
| 12983 | event.type === "turn_item.updated" && | |
| 12984 | event.turnItem.type === "subagent" && | |
| 12985 | event.turnItem.nativeItemRef?.nativeId === WORKFLOW_TASK_ID && | |
| 12986 | event.turnItem.status === "completed", | |
| 12987 | ), | |
| 12988 | "terminal workflow notification", | |
| 12989 | ); | |
| 12990 | NodeFS.writeFileSync( | |
| 12991 | NodePath.join(harness.transcriptDir, "agent-a2.jsonl"), | |
| 12992 | '{"type":"assistant","message":{"role":"assistant","id":"msg_late","content":[{"type":"text","text":"A2 final answer"}]}}\n', | |
| 12993 | ); | |
| 12994 | yield* Queue.offer( | |
| 12995 | harness.sdkMessages, | |
| 12996 | claudeSdkFrame({ ...notification, uuid: "00000000-0000-4000-8000-000000001033" }), | |
| 12997 | ); | |
| 12998 | yield* awaitUntil( | |
| 12999 | () => | |
| 13000 | threadMessages(harness.events, secondThreadId).at(-1)?.message.text === | |
| 13001 | "A2 final answer", | |
| 13002 | "late terminal transcript answer", | |
| 13003 | ); | |
| 13004 | yield* Queue.offer( | |
| 13005 | harness.sdkMessages, | |
| 13006 | makeResultFrame({ uuid: "00000000-0000-4000-8000-000000001034", result: "Done." }), | |
| 13007 | ); | |
| 13008 | yield* Queue.take(harness.terminalReceipts); | |
| 13009 | }).pipe( | |
| 13010 | Effect.provide( | |
| 13011 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 13012 | ), | |
| 13013 | ), | |
| 13014 | ), | |
| 13015 | ); | |
| 13016 | ||
| 13017 | it.effect("answers settled workflow members from a transcript, or the snapshot excerpt", () => | |
| 13018 | Effect.scoped( | |
| 13019 | Effect.gen(function* () { | |
| 13020 | const harness = yield* makeWorkflowHarness; | |
| 13021 | const now = yield* DateTime.now; | |
| 13022 | ||
| 13023 | yield* harness.runtime.startTurn( | |
| 13024 | makeClaudeTestTurnInput({ | |
| 13025 | threadId: harness.threadId, | |
| 13026 | providerThread: harness.providerThread, | |
| 13027 | now, | |
| 13028 | attemptId: RunAttemptId.make("attempt-claude-workflow-settled"), | |
| 13029 | text: "Run the alpha workflow in the background.", | |
| 13030 | attachments: [], | |
| 13031 | }), | |
| 13032 | ); | |
| 13033 | yield* launchWorkflow({ harness, snapshotUuid: "00000000-0000-4000-8000-000000001007" }); | |
| 13034 | const firstThreadId = workflowMemberEvents(harness.events, 1)[0]?.subagent.childThreadId; | |
| 13035 | const secondThreadId = workflowMemberEvents(harness.events, 2)[0]?.subagent.childThreadId; | |
| 13036 | const currentAssistantAnswers = () => [ | |
| 13037 | ...new Map( | |
| 13038 | threadMessages(harness.events, firstThreadId) | |
| 13039 | .filter((event) => event.message.role === "assistant") | |
| 13040 | .map((event) => [event.message.id, event.message.text]), | |
| 13041 | ).values(), | |
| 13042 | ]; | |
| 13043 | yield* Queue.offer( | |
| 13044 | harness.sdkMessages, | |
| 13045 | makeResultFrame({ | |
| 13046 | uuid: "00000000-0000-4000-8000-000000001008", | |
| 13047 | result: "Launched the workflow.", | |
| 13048 | }), | |
| 13049 | ); | |
| 13050 | yield* Queue.take(harness.terminalReceipts); | |
| 13051 | yield* harness.runtime.startTurn( | |
| 13052 | makeClaudeTestTurnInput({ | |
| 13053 | threadId: harness.threadId, | |
| 13054 | providerThread: harness.providerThread, | |
| 13055 | now, | |
| 13056 | attemptId: RunAttemptId.make("attempt-unrelated-failure"), | |
| 13057 | text: "Do another task.", | |
| 13058 | attachments: [], | |
| 13059 | providerTurnOrdinal: 2, | |
| 13060 | }), | |
| 13061 | ); | |
| 13062 | yield* Queue.offer( | |
| 13063 | harness.sdkMessages, | |
| 13064 | makeResultFrame({ | |
| 13065 | uuid: "00000000-0000-4000-8000-000000001014", | |
| 13066 | result: "Another task failed.", | |
| 13067 | terminalReason: "api_error", | |
| 13068 | isError: true, | |
| 13069 | }), | |
| 13070 | ); | |
| 13071 | yield* Queue.take(harness.terminalReceipts); | |
| 13072 | assert.equal(workflowCoordinatorEvents(harness.events).at(-1)?.subagent.status, "running"); | |
| 13073 | assert.equal(workflowMemberEvents(harness.events, 1).at(-1)?.subagent.status, "running"); | |
| 13074 | ||
| 13075 | // The run outlives the turn that launched it, so this snapshot arrives | |
| 13076 | // with no turn context at all. | |
| 13077 | yield* Queue.offer( | |
| 13078 | harness.sdkMessages, | |
| 13079 | workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001009", state: "done" }), | |
| 13080 | ); | |
| 13081 | yield* awaitUntil( | |
| 13082 | () => threadMessages(harness.events, secondThreadId).length === 2, | |
| 13083 | "settled workflow member answers", | |
| 13084 | ); | |
| 13085 | ||
| 13086 | const coordinator = workflowCoordinatorEvents(harness.events).at(-1)?.subagent; | |
| 13087 | assert.deepEqual( | |
| 13088 | coordinator?.workflow?.agents.map((member) => member.state), | |
| 13089 | ["completed", "completed"], | |
| 13090 | ); | |
| 13091 | assert.equal(workflowMemberEvents(harness.events, 1).at(-1)?.subagent.status, "completed"); | |
| 13092 | assert.equal(workflowMemberEvents(harness.events, 2).at(-1)?.subagent.status, "completed"); | |
| 13093 | assert.deepEqual( | |
| 13094 | threadMessages(harness.events, firstThreadId).map((event) => event.message.text), | |
| 13095 | ["Reply with exactly: A1", "A1 from the transcript"], | |
| 13096 | ); | |
| 13097 | assert.deepEqual( | |
| 13098 | threadMessages(harness.events, secondThreadId).map((event) => event.message.text), | |
| 13099 | ["Reply with exactly: A2", "Partial answer from workflow progress:\n\nA2 excerpt"], | |
| 13100 | ); | |
| 13101 | const coordinatorEventsBeforeRepeat = workflowCoordinatorEvents(harness.events).length; | |
| 13102 | const memberEventsBeforeRepeat = workflowMemberEvents(harness.events, 2).length; | |
| 13103 | NodeFS.appendFileSync( | |
| 13104 | NodePath.join(harness.transcriptDir, "agent-a1.jsonl"), | |
| 13105 | '{"type":"assistant","message":{"role":"assistant","id":"msg_a1","content":[{"type":"text","text":"A1 continued"}]}}\n', | |
| 13106 | ); | |
| 13107 | yield* Queue.offer( | |
| 13108 | harness.sdkMessages, | |
| 13109 | workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001015", state: "done" }), | |
| 13110 | ); | |
| 13111 | yield* awaitUntil( | |
| 13112 | () => workflowCoordinatorEvents(harness.events).length > coordinatorEventsBeforeRepeat, | |
| 13113 | "repeated settled workflow snapshot", | |
| 13114 | ); | |
| 13115 | yield* awaitUntil( | |
| 13116 | () => | |
| 13117 | threadMessages(harness.events, firstThreadId).at(-1)?.message.text === | |
| 13118 | "A1 from the transcript\n\nA1 continued", | |
| 13119 | "continued workflow transcript answer", | |
| 13120 | ); | |
| 13121 | assert.lengthOf(workflowMemberEvents(harness.events, 2), memberEventsBeforeRepeat); | |
| 13122 | assert.lengthOf(threadMessages(harness.events, secondThreadId), 2); | |
| 13123 | assert.equal( | |
| 13124 | threadMessages(harness.events, firstThreadId).at(-1)?.message.text, | |
| 13125 | "A1 from the transcript\n\nA1 continued", | |
| 13126 | ); | |
| 13127 | NodeFS.writeFileSync( | |
| 13128 | NodePath.join(harness.transcriptDir, "agent-a2.jsonl"), | |
| 13129 | '{"type":"assistant","message":{"role":"assistant","id":"msg_a2","content":[{"type":"text","text":"A2 from the transcript"}]}}\n', | |
| 13130 | ); | |
| 13131 | yield* Queue.offer( | |
| 13132 | harness.sdkMessages, | |
| 13133 | workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001016", state: "done" }), | |
| 13134 | ); | |
| 13135 | yield* awaitUntil( | |
| 13136 | () => threadMessages(harness.events, secondThreadId).length === 3, | |
| 13137 | "late workflow transcript answer", | |
| 13138 | ); | |
| 13139 | assert.lengthOf(workflowMemberEvents(harness.events, 2), memberEventsBeforeRepeat); | |
| 13140 | assert.equal( | |
| 13141 | threadMessages(harness.events, secondThreadId).at(-1)?.message.text, | |
| 13142 | "A2 from the transcript", | |
| 13143 | ); | |
| 13144 | const previousStartedAt = workflowMemberEvents(harness.events, 1).at(-1)?.subagent | |
| 13145 | .startedAt; | |
| 13146 | yield* TestClock.adjust(1_000); | |
| 13147 | NodeFS.writeFileSync( | |
| 13148 | NodePath.join(harness.transcriptDir, "agent-a1.jsonl"), | |
| 13149 | [ | |
| 13150 | '{"type":"assistant","message":{"role":"assistant","id":"msg_first","content":[{"type":"text","text":"First answer"}]}}', | |
| 13151 | '{"type":"assistant","message":{"role":"assistant","id":"msg_second","content":[{"type":"text","text":"Second answer"}]}}', | |
| 13152 | ].join("\n") + "\n", | |
| 13153 | ); | |
| 13154 | yield* harness.offerAndWait( | |
| 13155 | workflowSnapshot({ | |
| 13156 | uuid: "00000000-0000-4000-8000-000000001018", | |
| 13157 | state: "done", | |
| 13158 | attempt: 2, | |
| 13159 | }), | |
| 13160 | ); | |
| 13161 | yield* awaitUntil( | |
| 13162 | () => currentAssistantAnswers().join("\n\n").includes("Second answer"), | |
| 13163 | "two-turn workflow member answer", | |
| 13164 | ); | |
| 13165 | assert.equal(currentAssistantAnswers().join("\n\n"), "First answer\n\nSecond answer"); | |
| 13166 | assert.notDeepEqual( | |
| 13167 | workflowMemberEvents(harness.events, 1).at(-1)?.subagent.startedAt, | |
| 13168 | previousStartedAt, | |
| 13169 | ); | |
| 13170 | yield* Queue.offer( | |
| 13171 | harness.sdkMessages, | |
| 13172 | workflowSnapshot({ | |
| 13173 | uuid: "00000000-0000-4000-8000-000000001019", | |
| 13174 | state: "start", | |
| 13175 | attempt: 3, | |
| 13176 | }), | |
| 13177 | ); | |
| 13178 | yield* awaitUntil( | |
| 13179 | () => workflowMemberEvents(harness.events, 1).at(-1)?.subagent.status === "running", | |
| 13180 | "retried workflow member running", | |
| 13181 | ); | |
| 13182 | assert.notInclude(currentAssistantAnswers().join("\n\n"), "First answer"); | |
| 13183 | assert.notInclude(currentAssistantAnswers().join("\n\n"), "Second answer"); | |
| 13184 | const answerEventsDuringRetry = threadMessages(harness.events, firstThreadId).length; | |
| 13185 | yield* Queue.offer( | |
| 13186 | harness.sdkMessages, | |
| 13187 | workflowSnapshot({ | |
| 13188 | uuid: "00000000-0000-4000-8000-000000001020", | |
| 13189 | state: "start", | |
| 13190 | attempt: 3, | |
| 13191 | }), | |
| 13192 | ); | |
| 13193 | NodeFS.writeFileSync( | |
| 13194 | NodePath.join(harness.transcriptDir, "agent-a1.jsonl"), | |
| 13195 | '{"type":"assistant","message":{"role":"assistant","id":"msg_retry","content":[{"type":"text","text":"A1 retry answer"}]}}\n', | |
| 13196 | ); | |
| 13197 | yield* Queue.offer( | |
| 13198 | harness.sdkMessages, | |
| 13199 | workflowSnapshot({ | |
| 13200 | uuid: "00000000-0000-4000-8000-000000001021", | |
| 13201 | state: "done", | |
| 13202 | attempt: 3, | |
| 13203 | }), | |
| 13204 | ); | |
| 13205 | yield* awaitUntil( | |
| 13206 | () => | |
| 13207 | threadMessages(harness.events, firstThreadId).at(-1)?.message.text === | |
| 13208 | "A1 retry answer", | |
| 13209 | "retried workflow member answer", | |
| 13210 | ); | |
| 13211 | assert.deepEqual(currentAssistantAnswers(), ["A1 retry answer"]); | |
| 13212 | assert.lengthOf(threadMessages(harness.events, firstThreadId), answerEventsDuringRetry + 1); | |
| 13213 | NodeFS.rmSync(NodePath.join(harness.transcriptDir, "agent-a1.jsonl")); | |
| 13214 | yield* Queue.offer( | |
| 13215 | harness.sdkMessages, | |
| 13216 | workflowSnapshot({ | |
| 13217 | uuid: "00000000-0000-4000-8000-000000001022", | |
| 13218 | state: "start", | |
| 13219 | attempt: 4, | |
| 13220 | }), | |
| 13221 | ); | |
| 13222 | yield* awaitUntil( | |
| 13223 | () => currentAssistantAnswers().at(-1) === "Retry in progress.", | |
| 13224 | "retry progress answer", | |
| 13225 | ); | |
| 13226 | yield* Queue.offer( | |
| 13227 | harness.sdkMessages, | |
| 13228 | workflowSnapshot({ | |
| 13229 | uuid: "00000000-0000-4000-8000-000000001023", | |
| 13230 | state: "done", | |
| 13231 | attempt: 4, | |
| 13232 | omitResult: true, | |
| 13233 | }), | |
| 13234 | ); | |
| 13235 | yield* awaitUntil( | |
| 13236 | () => currentAssistantAnswers().at(-1) === "No answer from this workflow attempt.", | |
| 13237 | "empty retry answer", | |
| 13238 | ); | |
| 13239 | assert.deepEqual(currentAssistantAnswers(), ["No answer from this workflow attempt."]); | |
| 13240 | }).pipe( | |
| 13241 | Effect.provide( | |
| 13242 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 13243 | ), | |
| 13244 | ), | |
| 13245 | ), | |
| 13246 | ); | |
| 13247 | ||
| 10877 | 13248 | describe("native goals", () => { |
| 10878 | 13249 | const syntheticFrame = (uuid: string, text: string) => |
| 10879 | 13250 | claudeSdkFrame({ |
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts+6-1| ... | ... | @@ -17,8 +17,8 @@ import { |
| 17 | 17 | type ProviderReplayTranscript, |
| 18 | 18 | } from "@t3tools/contracts"; |
| 19 | 19 | import * as Cause from "effect/Cause"; |
| 20 | import * as Duration from "effect/Duration"; | |
| 21 | 20 | import * as Crypto from "effect/Crypto"; |
| 21 | import * as Duration from "effect/Duration"; | |
| 22 | 22 | import * as Effect from "effect/Effect"; |
| 23 | 23 | import * as Exit from "effect/Exit"; |
| 24 | 24 | import * as Layer from "effect/Layer"; |
| ... | ... | @@ -242,6 +242,7 @@ interface ClaudeSubagentFoundFrame { |
| 242 | 242 | } |
| 243 | 243 | |
| 244 | 244 | type ClaudeOutboundFrame = |
| 245 | | { readonly type: "query.stop_task"; readonly taskId: string } | |
| 245 | 246 | | ClaudeQueryOpenFrame |
| 246 | 247 | | ClaudePromptOfferFrame |
| 247 | 248 | | ClaudeQuerySetModelFrame |
| ... | ... | @@ -825,6 +826,10 @@ function makeReplayQueryRunner( |
| 825 | 826 | mode, |
| 826 | 827 | }); |
| 827 | 828 | }), |
| 829 | stopTask: (taskId) => | |
| 830 | replayEffect(() => { | |
| 831 | assertNextOutboundFrame({ type: "query.stop_task", taskId }); | |
| 832 | }), | |
| 828 | 833 | interrupt: replayEffect(() => { |
| 829 | 834 | assertNextOutboundFrame({ type: "query.interrupt" }); |
| 830 | 835 | }), |
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts+896-235| ... | ... | @@ -4,6 +4,15 @@ import { |
| 4 | 4 | formatReadToolLabel, |
| 5 | 5 | formatSearchToolLabel, |
| 6 | 6 | } from "@t3tools/shared/toolActivity"; |
| 7 | import { field, text } from "../unknownField.ts"; | |
| 8 | import { readWorkflowAgentAnswers } from "../workflowAgentAnswers.ts"; | |
| 9 | import { | |
| 10 | CLAUDE_WORKFLOW_TASK_TYPE, | |
| 11 | hasClaudeWorkflowSnapshot, | |
| 12 | mergeClaudeWorkflowProgress, | |
| 13 | parseClaudeWorkflowRunHandles, | |
| 14 | } from "./claudeWorkflowProgress.ts"; | |
| 15 | ||
| 7 | 16 | import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; |
| 8 | 17 | import { normalizeClaudeTurnTokenUsage } from "../../provider/ClaudeTurnTokenUsage.ts"; |
| 9 | 18 | import { |
| ... | ... | @@ -58,6 +67,9 @@ import { |
| 58 | 67 | type OrchestrationV2RuntimeRequest, |
| 59 | 68 | type OrchestrationV2UserInputQuestion, |
| 60 | 69 | type OrchestrationV2Subagent, |
| 70 | type OrchestrationV2AppThread, | |
| 71 | type OrchestrationV2SubagentWorkflow, | |
| 72 | type OrchestrationV2WorkflowRunHandles, | |
| 61 | 73 | type OrchestrationV2TurnItem, |
| 62 | 74 | type OrchestrationV2WebSearchResult, |
| 63 | 75 | type ProviderApprovalDecision, |
| ... | ... | @@ -66,15 +78,18 @@ import { |
| 66 | 78 | type ProviderRequestKind, |
| 67 | 79 | type ProviderUserInputAnswers, |
| 68 | 80 | type ProviderThreadId, |
| 81 | type RunId, | |
| 69 | 82 | type ThreadId, |
| 70 | 83 | } from "@t3tools/contracts"; |
| 71 | 84 | |
| 72 | 85 | import * as Cause from "effect/Cause"; |
| 73 | 86 | import * as Context from "effect/Context"; |
| 74 | 87 | import * as Crypto from "effect/Crypto"; |
| 88 | import * as Hex from "effect/encoding/Hex"; | |
| 75 | 89 | import * as DateTime from "effect/DateTime"; |
| 76 | 90 | import * as Deferred from "effect/Deferred"; |
| 77 | 91 | import * as Effect from "effect/Effect"; |
| 92 | import * as Equal from "effect/Equal"; | |
| 78 | 93 | import * as Exit from "effect/Exit"; |
| 79 | 94 | import * as FileSystem from "effect/FileSystem"; |
| 80 | 95 | import * as Layer from "effect/Layer"; |
| ... | ... | @@ -83,6 +98,7 @@ import * as Path from "effect/Path"; |
| 83 | 98 | import * as Queue from "effect/Queue"; |
| 84 | 99 | import * as Ref from "effect/Ref"; |
| 85 | 100 | import * as Schema from "effect/Schema"; |
| 101 | import * as Semaphore from "effect/Semaphore"; | |
| 86 | 102 | import * as Stream from "effect/Stream"; |
| 87 | 103 | |
| 88 | 104 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; |
| ... | ... | @@ -365,7 +381,7 @@ export interface ClaudeAgentSdkQuerySession { |
| 365 | 381 | readonly setPermissionMode: ( |
| 366 | 382 | mode: PermissionMode, |
| 367 | 383 | ) => Effect.Effect<void, ClaudeAgentSdkQueryRunnerError>; |
| 368 | readonly stopTask?: (taskId: string) => Effect.Effect<void, ClaudeAgentSdkQueryRunnerError>; | |
| 384 | readonly stopTask: (taskId: string) => Effect.Effect<void, ClaudeAgentSdkQueryRunnerError>; | |
| 369 | 385 | readonly interrupt: Effect.Effect<void, ClaudeAgentSdkQueryRunnerError>; |
| 370 | 386 | readonly close: Effect.Effect<void, ClaudeAgentSdkQueryRunnerError>; |
| 371 | 387 | } |
| ... | ... | @@ -513,6 +529,14 @@ export type ClaudeAgentSdkProtocolLogEvent = |
| 513 | 529 | readonly mode: PermissionMode; |
| 514 | 530 | }; |
| 515 | 531 | } |
| 532 | | { | |
| 533 | readonly direction: "outgoing"; | |
| 534 | readonly stage: "decoded"; | |
| 535 | readonly payload: { | |
| 536 | readonly type: "query.stop_task"; | |
| 537 | readonly taskId: string; | |
| 538 | }; | |
| 539 | } | |
| 516 | 540 | | { |
| 517 | 541 | readonly direction: "outgoing"; |
| 518 | 542 | readonly stage: "decoded"; |
| ... | ... | @@ -809,7 +833,15 @@ export const layerQueryRunner: Layer.Layer< |
| 809 | 833 | Effect.tryPromise({ |
| 810 | 834 | try: () => queryRuntime.stopTask(taskId), |
| 811 | 835 | catch: (cause) => queryRunnerError(cause, "stopTask"), |
| 812 | }), | |
| 836 | }).pipe( | |
| 837 | Effect.tap(() => | |
| 838 | logProtocolEvent({ | |
| 839 | direction: "outgoing", | |
| 840 | stage: "decoded", | |
| 841 | payload: { type: "query.stop_task", taskId }, | |
| 842 | }), | |
| 843 | ), | |
| 844 | ), | |
| 813 | 845 | interrupt: Effect.tryPromise({ |
| 814 | 846 | try: () => queryRuntime.interrupt(), |
| 815 | 847 | catch: (cause) => queryRunnerError(cause, "interrupt"), |
| ... | ... | @@ -1534,6 +1566,12 @@ const makeClaudeUserMessageWithAttachments = Effect.fnUntraced(function* (input: |
| 1534 | 1566 | } satisfies SDKUserMessage; |
| 1535 | 1567 | }); |
| 1536 | 1568 | |
| 1569 | const sha256Hex = Effect.fnUntraced(function* (crypto: Crypto.Crypto, value: string) { | |
| 1570 | return Hex.encode( | |
| 1571 | yield* crypto.digest("SHA-256", new TextEncoder().encode(value)).pipe(Effect.orDie), | |
| 1572 | ); | |
| 1573 | }); | |
| 1574 | ||
| 1537 | 1575 | export function isClaudePromptUuid(value: string): value is NonNullable<SDKUserMessage["uuid"]> { |
| 1538 | 1576 | return /^[^-]+-[^-]+-[^-]+-[^-]+-[^-]+$/.test(value); |
| 1539 | 1577 | } |
| ... | ... | @@ -2908,6 +2946,12 @@ interface ActiveClaudeSubagent { |
| 2908 | 2946 | // owning subagent's node for a subagent another subagent started. |
| 2909 | 2947 | readonly rootNodeId: OrchestrationV2ExecutionNode["id"]; |
| 2910 | 2948 | readonly childThreadId: ThreadId; |
| 2949 | /** | |
| 2950 | * The child thread object itself, not just its id: workflow members are | |
| 2951 | * parented to it long after the launching turn has settled, and building a | |
| 2952 | * child thread needs the whole parent. | |
| 2953 | */ | |
| 2954 | childThread: OrchestrationV2AppThread | null; | |
| 2911 | 2955 | readonly childRootNodeId: OrchestrationV2ExecutionNode["id"]; |
| 2912 | 2956 | readonly turnItemId: OrchestrationV2TurnItem["id"]; |
| 2913 | 2957 | readonly turnItemOrdinal: number; |
| ... | ... | @@ -2932,6 +2976,8 @@ interface ClaudeLiveQueryContext { |
| 2932 | 2976 | readonly queryPolicyKey: string; |
| 2933 | 2977 | readonly selectionKey: string; |
| 2934 | 2978 | readonly closed: Deferred.Deferred<void, never>; |
| 2979 | // Retained for process-exit cleanup even when its last turn failed. | |
| 2980 | lastTurn: ActiveClaudeTurnContext | null; | |
| 2935 | 2981 | // Whether this CLI process echoes a prompt's uuid on the first frame of |
| 2936 | 2982 | // the turn answering it ("early") or only on its result. Learned from the |
| 2937 | 2983 | // first prompt turn. "acknowledged": the CLI confirmed it took a prompt's |
| ... | ... | @@ -3334,6 +3380,29 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( |
| 3334 | 3380 | }); |
| 3335 | 3381 | const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>(); |
| 3336 | 3382 | const activeTurn = yield* Ref.make<ActiveClaudeTurnContext | null>(null); |
| 3383 | const sdkMessageLock = yield* Semaphore.make(1); | |
| 3384 | // Survives turn settle: members mostly finish after the launching | |
| 3385 | // turn has ended, and each transition must be emitted exactly once. | |
| 3386 | const workflowMemberStates = yield* Ref.make( | |
| 3387 | new Map< | |
| 3388 | string, | |
| 3389 | { | |
| 3390 | readonly status: OrchestrationV2Subagent["status"]; | |
| 3391 | readonly attempt: number; | |
| 3392 | /** Digest of the last emitted transcript answer, not the text: it can run to the transcript cap. */ | |
| 3393 | readonly transcriptAnswerDigest: string | undefined; | |
| 3394 | readonly transcriptReadAttempts: number; | |
| 3395 | readonly finalTranscriptRead: boolean; | |
| 3396 | readonly result: string | undefined; | |
| 3397 | readonly label: string; | |
| 3398 | readonly prompt: string; | |
| 3399 | readonly model: string; | |
| 3400 | readonly createdAt: DateTime.Utc; | |
| 3401 | readonly startedAt: DateTime.Utc | null; | |
| 3402 | readonly completedAt: DateTime.Utc | null; | |
| 3403 | } | |
| 3404 | >(), | |
| 3405 | ); | |
| 3337 | 3406 | const interruptedTurns = yield* Ref.make(new Set<OrchestrationV2ProviderTurn["id"]>()); |
| 3338 | 3407 | const steeredTurns = yield* Ref.make(new Set<OrchestrationV2ProviderTurn["id"]>()); |
| 3339 | 3408 | const queryContext = yield* Ref.make<ClaudeLiveQueryContext | null>(null); |
| ... | ... | @@ -4335,6 +4404,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( |
| 4335 | 4404 | const ids = claudeSubagentIds(resume.context, resume.taskId); |
| 4336 | 4405 | const now = yield* DateTime.now; |
| 4337 | 4406 | const subagent: ActiveClaudeSubagent = { |
| 4407 | childThread: null, | |
| 4338 | 4408 | task: { |
| 4339 | 4409 | id: ids.nodeId, |
| 4340 | 4410 | threadId: resume.context.input.threadId, |
| ... | ... | @@ -4385,6 +4455,341 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( |
| 4385 | 4455 | ); |
| 4386 | 4456 | }); |
| 4387 | 4457 | |
| 4458 | /** | |
| 4459 | * Stamps each member with the thread it owns. Derived from the | |
| 4460 | * coordinator's own child thread so the id inherits its provider-thread | |
| 4461 | * scoping, and done here so the snapshot the clients read carries the | |
| 4462 | * same id the projection emits. | |
| 4463 | */ | |
| 4464 | const withWorkflowMemberThreadIds = ( | |
| 4465 | workflow: OrchestrationV2SubagentWorkflow, | |
| 4466 | coordinatorChildThreadId: ThreadId, | |
| 4467 | ): OrchestrationV2SubagentWorkflow => ({ | |
| 4468 | ...workflow, | |
| 4469 | agents: workflow.agents.map((member) => ({ | |
| 4470 | ...member, | |
| 4471 | childThreadId: idAllocator.derive.threadFromProviderThread({ | |
| 4472 | driver: CLAUDE_PROVIDER, | |
| 4473 | nativeThreadId: `${coordinatorChildThreadId}:agent:${member.index}`, | |
| 4474 | }), | |
| 4475 | })), | |
| 4476 | }); | |
| 4477 | ||
| 4478 | /** Emits one conversation message into a subagent's own thread. */ | |
| 4479 | const emitSubagentConversationMessage = Effect.fnUntraced(function* (input: { | |
| 4480 | readonly nativeItemId: string; | |
| 4481 | readonly threadId: ThreadId; | |
| 4482 | readonly senderThreadId?: ThreadId; | |
| 4483 | readonly rootNodeId: OrchestrationV2ExecutionNode["id"]; | |
| 4484 | readonly role: "user" | "assistant"; | |
| 4485 | readonly text: string; | |
| 4486 | readonly ordinal: number; | |
| 4487 | readonly now: DateTime.Utc; | |
| 4488 | }) { | |
| 4489 | const artifacts = makeSubagentConversationArtifacts({ | |
| 4490 | messageId: idAllocator.derive.messageFromProviderItem({ | |
| 4491 | driver: CLAUDE_PROVIDER, | |
| 4492 | nativeItemId: input.nativeItemId, | |
| 4493 | }), | |
| 4494 | turnItemId: idAllocator.derive.turnItemFromProviderItem({ | |
| 4495 | driver: CLAUDE_PROVIDER, | |
| 4496 | nativeItemId: input.nativeItemId, | |
| 4497 | }), | |
| 4498 | threadId: input.threadId, | |
| 4499 | ...(input.senderThreadId === undefined ? {} : { senderThreadId: input.senderThreadId }), | |
| 4500 | rootNodeId: input.rootNodeId, | |
| 4501 | providerThreadId: null, | |
| 4502 | providerTurnId: null, | |
| 4503 | nativeItemRef: { | |
| 4504 | driver: CLAUDE_PROVIDER, | |
| 4505 | nativeId: input.nativeItemId, | |
| 4506 | strength: "strong", | |
| 4507 | }, | |
| 4508 | role: input.role, | |
| 4509 | text: input.text, | |
| 4510 | ordinal: input.ordinal, | |
| 4511 | now: input.now, | |
| 4512 | }); | |
| 4513 | yield* emitProviderEvent({ | |
| 4514 | type: "message.updated", | |
| 4515 | driver: CLAUDE_PROVIDER, | |
| 4516 | message: artifacts.message, | |
| 4517 | }); | |
| 4518 | yield* emitProviderEvent({ | |
| 4519 | type: "turn_item.updated", | |
| 4520 | driver: CLAUDE_PROVIDER, | |
| 4521 | turnItem: artifacts.turnItem, | |
| 4522 | }); | |
| 4523 | }); | |
| 4524 | ||
| 4525 | /** | |
| 4526 | * Projects a workflow's members as ordinary subagents. | |
| 4527 | * | |
| 4528 | * A member is not a provider task — it exists only inside the | |
| 4529 | * coordinator's progress snapshot — but everything the user expects | |
| 4530 | * from a subagent (a thread to open, a prompt, an answer) is expressed | |
| 4531 | * by the same events, so this emits exactly the sequence | |
| 4532 | * updateClaudeSubagentNode emits rather than inventing a parallel | |
| 4533 | * shape. Members hang off the coordinator's thread, not the run's, so | |
| 4534 | * a fan-out of fifty does not bury the parent's lineage. | |
| 4535 | */ | |
| 4536 | const projectClaudeWorkflowMembers = Effect.fnUntraced(function* (input: { | |
| 4537 | readonly coordinator: ActiveClaudeSubagent; | |
| 4538 | readonly workflow: OrchestrationV2SubagentWorkflow; | |
| 4539 | }) { | |
| 4540 | const parentThread = input.coordinator.childThread; | |
| 4541 | const taskId = input.coordinator.task.nativeTaskRef?.nativeId; | |
| 4542 | if (parentThread === null || taskId === undefined || taskId === null) return; | |
| 4543 | const now = yield* DateTime.now; | |
| 4544 | const seen = yield* Ref.get(workflowMemberStates); | |
| 4545 | const transcriptDir = input.workflow.runHandles?.transcriptDir; | |
| 4546 | ||
| 4547 | for (const member of input.workflow.agents) { | |
| 4548 | if ((yield* Ref.get(sessionSubagentsByTaskId)).get(taskId) !== input.coordinator) | |
| 4549 | return; | |
| 4550 | const memberKey = `${input.coordinator.childThreadId}:agent:${member.index}`; | |
| 4551 | const previous = seen.get(memberKey); | |
| 4552 | const status = | |
| 4553 | (member.state === "queued" || member.state === "running") && | |
| 4554 | input.coordinator.task.status !== "running" | |
| 4555 | ? input.coordinator.task.status | |
| 4556 | : member.state === "queued" | |
| 4557 | ? "pending" | |
| 4558 | : member.state; | |
| 4559 | const settled = status !== "pending" && status !== "running"; | |
| 4560 | const prompt = member.prompt ?? member.label; | |
| 4561 | const model = member.model ?? parentThread.modelSelection.model; | |
| 4562 | const attempt = member.attempt ?? previous?.attempt ?? 1; | |
| 4563 | const restarted = previous !== undefined && attempt > previous.attempt; | |
| 4564 | const createdAt = previous?.createdAt ?? now; | |
| 4565 | const startedAt = | |
| 4566 | status === "pending" | |
| 4567 | ? null | |
| 4568 | : member.startedAt !== undefined | |
| 4569 | ? DateTime.makeUnsafe(member.startedAt) | |
| 4570 | : ((restarted ? null : previous?.startedAt) ?? | |
| 4571 | (status === "running" ? now : null)); | |
| 4572 | const completedAt = settled | |
| 4573 | ? ((restarted ? null : previous?.completedAt) ?? now) | |
| 4574 | : null; | |
| 4575 | const changed = | |
| 4576 | restarted || | |
| 4577 | previous?.status !== status || | |
| 4578 | !Equal.equals(previous?.startedAt, startedAt) || | |
| 4579 | previous?.result !== member.result || | |
| 4580 | previous?.label !== member.label || | |
| 4581 | previous?.prompt !== prompt || | |
| 4582 | previous?.model !== model; | |
| 4583 | const agentId = member.agentId; | |
| 4584 | const readTranscript = | |
| 4585 | settled && | |
| 4586 | transcriptDir !== undefined && | |
| 4587 | agentId !== undefined && | |
| 4588 | (input.coordinator.task.status === "running" | |
| 4589 | ? (restarted ? 0 : (previous?.transcriptReadAttempts ?? 0)) < 3 | |
| 4590 | : restarted || previous?.finalTranscriptRead !== true); | |
| 4591 | if (!changed && !readTranscript) continue; | |
| 4592 | ||
| 4593 | const nodeId = idAllocator.derive.nodeFromProviderItem({ | |
| 4594 | driver: CLAUDE_PROVIDER, | |
| 4595 | nativeItemId: memberKey, | |
| 4596 | }); | |
| 4597 | const childRootNodeId = idAllocator.derive.nodeFromProviderItem({ | |
| 4598 | driver: CLAUDE_PROVIDER, | |
| 4599 | nativeItemId: `${memberKey}:thread-root`, | |
| 4600 | }); | |
| 4601 | const childThreadId = member.childThreadId; | |
| 4602 | if (childThreadId === undefined) continue; | |
| 4603 | ||
| 4604 | if ( | |
| 4605 | previous === undefined || | |
| 4606 | previous.label !== member.label || | |
| 4607 | previous.model !== model | |
| 4608 | ) { | |
| 4609 | yield* emitProviderEvent({ | |
| 4610 | type: "app_thread.created", | |
| 4611 | driver: CLAUDE_PROVIDER, | |
| 4612 | appThread: makeSubagentChildThread({ | |
| 4613 | parentThread, | |
| 4614 | childThreadId, | |
| 4615 | parentNodeId: nodeId, | |
| 4616 | activeProviderThreadId: null, | |
| 4617 | providerInstanceId: parentThread.providerInstanceId, | |
| 4618 | modelSelection: { instanceId: parentThread.providerInstanceId, model }, | |
| 4619 | title: member.label, | |
| 4620 | now: createdAt, | |
| 4621 | createdBy: "agent", | |
| 4622 | creationSource: "provider", | |
| 4623 | }), | |
| 4624 | }); | |
| 4625 | } | |
| 4626 | if (previous === undefined || previous.prompt !== prompt) { | |
| 4627 | yield* emitSubagentConversationMessage({ | |
| 4628 | nativeItemId: `${memberKey}:prompt`, | |
| 4629 | threadId: childThreadId, | |
| 4630 | rootNodeId: childRootNodeId, | |
| 4631 | role: "user", | |
| 4632 | text: prompt, | |
| 4633 | ordinal: 100, | |
| 4634 | now, | |
| 4635 | }); | |
| 4636 | } | |
| 4637 | if (restarted && !settled) { | |
| 4638 | yield* emitSubagentConversationMessage({ | |
| 4639 | nativeItemId: `${memberKey}:answer:200`, | |
| 4640 | threadId: childThreadId, | |
| 4641 | rootNodeId: childRootNodeId, | |
| 4642 | role: "assistant", | |
| 4643 | text: "Retry in progress.", | |
| 4644 | ordinal: 200, | |
| 4645 | now, | |
| 4646 | }); | |
| 4647 | } | |
| 4648 | ||
| 4649 | const task = { | |
| 4650 | id: nodeId, | |
| 4651 | threadId: parentThread.id, | |
| 4652 | runId: null, | |
| 4653 | parentNodeId: input.coordinator.childRootNodeId, | |
| 4654 | origin: "provider_native" as const, | |
| 4655 | createdBy: "agent" as const, | |
| 4656 | driver: CLAUDE_PROVIDER, | |
| 4657 | providerInstanceId: parentThread.providerInstanceId, | |
| 4658 | providerThreadId: null, | |
| 4659 | childThreadId, | |
| 4660 | nativeTaskRef: { | |
| 4661 | driver: CLAUDE_PROVIDER, | |
| 4662 | nativeId: memberKey, | |
| 4663 | strength: "strong" as const, | |
| 4664 | }, | |
| 4665 | prompt, | |
| 4666 | title: member.label, | |
| 4667 | model, | |
| 4668 | status, | |
| 4669 | result: member.result ?? null, | |
| 4670 | startedAt, | |
| 4671 | completedAt, | |
| 4672 | updatedAt: now, | |
| 4673 | } satisfies OrchestrationV2Subagent; | |
| 4674 | ||
| 4675 | const nodeBase = { | |
| 4676 | runId: null, | |
| 4677 | status, | |
| 4678 | countsForRun: false, | |
| 4679 | providerThreadId: null, | |
| 4680 | providerTurnId: null, | |
| 4681 | nativeItemRef: task.nativeTaskRef, | |
| 4682 | runtimeRequestId: null, | |
| 4683 | checkpointScopeId: null, | |
| 4684 | startedAt, | |
| 4685 | completedAt, | |
| 4686 | }; | |
| 4687 | if (changed) { | |
| 4688 | for (const node of [ | |
| 4689 | { | |
| 4690 | ...nodeBase, | |
| 4691 | id: nodeId, | |
| 4692 | threadId: parentThread.id, | |
| 4693 | parentNodeId: input.coordinator.childRootNodeId, | |
| 4694 | rootNodeId: input.coordinator.childRootNodeId, | |
| 4695 | kind: "subagent" as const, | |
| 4696 | }, | |
| 4697 | { | |
| 4698 | ...nodeBase, | |
| 4699 | id: childRootNodeId, | |
| 4700 | threadId: childThreadId, | |
| 4701 | parentNodeId: null, | |
| 4702 | rootNodeId: childRootNodeId, | |
| 4703 | kind: "root_turn" as const, | |
| 4704 | }, | |
| 4705 | ]) { | |
| 4706 | yield* emitProviderEvent({ type: "node.updated", driver: CLAUDE_PROVIDER, node }); | |
| 4707 | } | |
| 4708 | yield* emitProviderEvent({ | |
| 4709 | type: "subagent.updated", | |
| 4710 | driver: CLAUDE_PROVIDER, | |
| 4711 | subagent: task, | |
| 4712 | }); | |
| 4713 | } | |
| 4714 | ||
| 4715 | let transcriptAnswerDigest = restarted ? undefined : previous?.transcriptAnswerDigest; | |
| 4716 | let transcript: ReadonlyArray<string> = []; | |
| 4717 | if (settled) { | |
| 4718 | // The member's own transcript, when the run left one: a missing or | |
| 4719 | // unreadable file is expected (a run predating run-handle capture, | |
| 4720 | // a member that never started) and falls back to the excerpt. | |
| 4721 | transcript = readTranscript | |
| 4722 | ? yield* readWorkflowAgentAnswers({ | |
| 4723 | transcriptDir, | |
| 4724 | agentId, | |
| 4725 | ...(adapterOptions.environment.CLAUDE_CONFIG_DIR === undefined | |
| 4726 | ? {} | |
| 4727 | : { | |
| 4728 | configDir: path.resolve( | |
| 4729 | session.cwd, | |
| 4730 | adapterOptions.environment.CLAUDE_CONFIG_DIR, | |
| 4731 | ), | |
| 4732 | }), | |
| 4733 | }).pipe(Effect.orElseSucceed((): ReadonlyArray<string> => [])) | |
| 4734 | : []; | |
| 4735 | const transcriptAnswer = transcript.length > 0 ? transcript.join("\n\n") : undefined; | |
| 4736 | const answerDigest = | |
| 4737 | transcriptAnswer === undefined | |
| 4738 | ? undefined | |
| 4739 | : yield* sha256Hex(crypto, transcriptAnswer); | |
| 4740 | // A continuation may settle or resume this workflow during the read. | |
| 4741 | if ((yield* Ref.get(sessionSubagentsByTaskId)).get(taskId) !== input.coordinator) | |
| 4742 | return; | |
| 4743 | const previousDigest = transcriptAnswerDigest; | |
| 4744 | if (answerDigest !== undefined) transcriptAnswerDigest = answerDigest; | |
| 4745 | const answer = | |
| 4746 | transcriptAnswer ?? | |
| 4747 | (member.result !== undefined && changed && previousDigest === undefined | |
| 4748 | ? `Partial answer from workflow progress:\n\n${member.result}` | |
| 4749 | : attempt > 1 && changed && previousDigest === undefined | |
| 4750 | ? "No answer from this workflow attempt." | |
| 4751 | : undefined); | |
| 4752 | if ( | |
| 4753 | answer !== undefined && | |
| 4754 | (transcriptAnswer === undefined || restarted || answerDigest !== previousDigest) | |
| 4755 | ) { | |
| 4756 | yield* emitSubagentConversationMessage({ | |
| 4757 | nativeItemId: `${memberKey}:answer:200`, | |
| 4758 | threadId: childThreadId, | |
| 4759 | rootNodeId: childRootNodeId, | |
| 4760 | role: "assistant", | |
| 4761 | text: answer, | |
| 4762 | ordinal: 200, | |
| 4763 | now, | |
| 4764 | }); | |
| 4765 | } | |
| 4766 | } | |
| 4767 | ||
| 4768 | yield* Ref.update(workflowMemberStates, (current) => | |
| 4769 | new Map(current).set(memberKey, { | |
| 4770 | status, | |
| 4771 | attempt, | |
| 4772 | result: member.result, | |
| 4773 | label: member.label, | |
| 4774 | prompt, | |
| 4775 | model, | |
| 4776 | transcriptAnswerDigest, | |
| 4777 | transcriptReadAttempts: | |
| 4778 | (restarted ? 0 : (previous?.transcriptReadAttempts ?? 0)) + | |
| 4779 | (readTranscript ? 1 : 0), | |
| 4780 | finalTranscriptRead: | |
| 4781 | (!restarted && previous?.finalTranscriptRead === true) || | |
| 4782 | (readTranscript && | |
| 4783 | transcript.length > 0 && | |
| 4784 | input.coordinator.task.status !== "running"), | |
| 4785 | createdAt, | |
| 4786 | startedAt, | |
| 4787 | completedAt, | |
| 4788 | }), | |
| 4789 | ); | |
| 4790 | } | |
| 4791 | }); | |
| 4792 | ||
| 4388 | 4793 | const updateClaudeSubagentNode = Effect.fnUntraced(function* (input: { |
| 4389 | 4794 | readonly context: ActiveClaudeTurnContext; |
| 4390 | 4795 | readonly taskId: string; |
| ... | ... | @@ -4397,24 +4802,39 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( |
| 4397 | 4802 | readonly effort?: string; |
| 4398 | 4803 | readonly owner?: ActiveClaudeSubagent; |
| 4399 | 4804 | readonly progress?: string; |
| 4805 | /** Raw SDK frame, folded into the coordinator's workflow snapshot. */ | |
| 4806 | readonly workflowFrame?: unknown; | |
| 4807 | readonly workflowName?: string; | |
| 4808 | readonly workflowRunHandles?: OrchestrationV2WorkflowRunHandles; | |
| 4400 | 4809 | readonly result?: string; |
| 4401 | 4810 | readonly status: Extract< |
| 4402 | 4811 | OrchestrationV2ExecutionNode["status"], |
| 4403 | 4812 | "running" | "completed" | "failed" | "cancelled" |
| 4404 | 4813 | >; |
| 4405 | 4814 | readonly reopen?: boolean; |
| 4815 | readonly onlyIfRunningInRun?: RunId | null; | |
| 4406 | 4816 | }) { |
| 4407 | 4817 | // The session registry lets a wake-replay turn (fresh context maps) |
| 4408 | 4818 | // hydrate a subagent that was created by an earlier, settled turn. |
| 4819 | const registeredSubagent = (yield* Ref.get(sessionSubagentsByTaskId)).get(input.taskId); | |
| 4820 | // Terminal cleanup must include snapshots received after the turn settled. | |
| 4409 | 4821 | const existingSubagent = |
| 4410 | input.context.subagentsByTaskId.get(input.taskId) ?? | |
| 4411 | (input.toolUseId === undefined | |
| 4412 | ? undefined | |
| 4413 | : input.context.subagentsByToolUseId.get(input.toolUseId)) ?? | |
| 4414 | (yield* Ref.get(sessionSubagentsByTaskId)).get(input.taskId); | |
| 4822 | input.onlyIfRunningInRun !== undefined | |
| 4823 | ? registeredSubagent | |
| 4824 | : (input.context.subagentsByTaskId.get(input.taskId) ?? | |
| 4825 | (input.toolUseId === undefined | |
| 4826 | ? undefined | |
| 4827 | : input.context.subagentsByToolUseId.get(input.toolUseId)) ?? | |
| 4828 | registeredSubagent); | |
| 4415 | 4829 | if (existingSubagent === undefined && input.status !== "running") { |
| 4416 | 4830 | return; |
| 4417 | 4831 | } |
| 4832 | if ( | |
| 4833 | input.onlyIfRunningInRun !== undefined && | |
| 4834 | (existingSubagent?.task.status !== "running" || | |
| 4835 | existingSubagent.task.runId !== input.onlyIfRunningInRun) | |
| 4836 | ) | |
| 4837 | return; | |
| 4418 | 4838 | // Status is monotone with one exception: task_started for a known |
| 4419 | 4839 | // task id is an authoritative CLI lifecycle event (SendMessage to a |
| 4420 | 4840 | // completed subagent resumes it and re-emits task_started with the |
| ... | ... | @@ -4433,23 +4853,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( |
| 4433 | 4853 | ) { |
| 4434 | 4854 | return; |
| 4435 | 4855 | } |
| 4436 | // A call the subagent left open never returns once it ends. It ends | |
| 4437 | // first, while the run that owns the child thread still takes its | |
| 4438 | // events. | |
| 4439 | if (existingSubagent !== undefined && input.status !== "running") { | |
| 4440 | const openCalls = [...subagentToolCalls.values()].filter( | |
| 4441 | (toolCall) => toolCall.threadId === existingSubagent.childThreadId, | |
| 4442 | ); | |
| 4443 | yield* endToolCalls({ | |
| 4444 | context: input.context, | |
| 4445 | toolCalls: openCalls, | |
| 4446 | status: input.status === "cancelled" ? "interrupted" : "failed", | |
| 4447 | completedAt: yield* DateTime.now, | |
| 4448 | }); | |
| 4449 | for (const toolCall of openCalls) { | |
| 4450 | subagentToolCalls.delete(toolCall.nativeItemId); | |
| 4451 | } | |
| 4452 | } | |
| 4453 | 4856 | // A task_started under a tool call other than the current run's is |
| 4454 | 4857 | // a resume: SendMessage re-emits task_started for the same task id |
| 4455 | 4858 | // under its own tool_use_id, with the sent message as the prompt. |
| ... | ... | @@ -4493,6 +4896,21 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( |
| 4493 | 4896 | existingSubagent.task, |
| 4494 | 4897 | ) |
| 4495 | 4898 | : existingSubagent.task; |
| 4899 | // Workflow telemetry is cumulative: fold this frame into whatever the | |
| 4900 | // coordinator already carried, so a usage-only frame cannot blank the | |
| 4901 | // member roster the user is watching. | |
| 4902 | const workflow = mergeClaudeWorkflowProgress({ | |
| 4903 | previous: priorTask?.workflow, | |
| 4904 | ...(input.workflowName === undefined ? {} : { name: input.workflowName }), | |
| 4905 | ...(input.workflowRunHandles === undefined | |
| 4906 | ? {} | |
| 4907 | : { runHandles: input.workflowRunHandles }), | |
| 4908 | message: input.workflowFrame, | |
| 4909 | }); | |
| 4910 | const workflowWithThreads = | |
| 4911 | workflow === undefined | |
| 4912 | ? undefined | |
| 4913 | : withWorkflowMemberThreadIds(workflow, childThreadId); | |
| 4496 | 4914 | const effort = |
| 4497 | 4915 | existingSubagent === undefined |
| 4498 | 4916 | ? (input.effort ?? |
| ... | ... | @@ -4552,6 +4970,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( |
| 4552 | 4970 | } |
| 4553 | 4971 | : {}), |
| 4554 | 4972 | ...(input.progress === undefined ? {} : { progress: input.progress }), |
| 4973 | ...(workflowWithThreads === undefined ? {} : { workflow: workflowWithThreads }), | |
| 4555 | 4974 | ...(input.result === undefined ? {} : { result: input.result }), |
| 4556 | 4975 | ...(isReopen ? { startedAt: now } : {}), |
| 4557 | 4976 | completedAt: input.status === "running" ? null : (priorTask?.completedAt ?? now), |
| ... | ... | @@ -4568,6 +4987,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( |
| 4568 | 4987 | existingSubagent === undefined |
| 4569 | 4988 | ? (input.toolUseId ?? null) |
| 4570 | 4989 | : (resumeToolUseId ?? existingSubagent.runToolUseId), |
| 4990 | childThread: existingSubagent?.childThread ?? null, | |
| 4571 | 4991 | effort, |
| 4572 | 4992 | nextChildItemOrdinal: existingSubagent?.nextChildItemOrdinal ?? 100, |
| 4573 | 4993 | resultItemOrdinal: existingSubagent?.resultItemOrdinal ?? null, |
| ... | ... | @@ -4579,16 +4999,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( |
| 4579 | 4999 | lastAssistantMessageId: |
| 4580 | 5000 | input.reopen === true ? null : (existingSubagent?.lastAssistantMessageId ?? null), |
| 4581 | 5001 | } satisfies ActiveClaudeSubagent; |
| 4582 | input.context.subagentsByTaskId.set(input.taskId, subagent); | |
| 4583 | if (input.toolUseId !== undefined) { | |
| 4584 | input.context.subagentsByToolUseId.set(input.toolUseId, subagent); | |
| 4585 | const toolUseId = input.toolUseId; | |
| 4586 | yield* Ref.update(sessionSubagentTaskIdsByToolUseId, (current) => | |
| 4587 | current.get(toolUseId) === input.taskId | |
| 4588 | ? current | |
| 4589 | : new Map(current).set(toolUseId, input.taskId), | |
| 4590 | ); | |
| 4591 | } | |
| 4592 | 5002 | // The same terminal protection, applied atomically: a concurrent |
| 4593 | 5003 | // fiber (live stream vs continuation drain) may have terminalized |
| 4594 | 5004 | // the registry entry after this update's lookup read it. A resume |
| ... | ... | @@ -4596,20 +5006,51 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( |
| 4596 | 5006 | // is still the terminal generation the lookup resolved; if a |
| 4597 | 5007 | // concurrent fiber installed a newer terminal entry meanwhile, the |
| 4598 | 5008 | // re-open must not clobber its result. |
| 4599 | yield* Ref.update(sessionSubagentsByTaskId, (current) => { | |
| 5009 | const accepted = yield* Ref.modify(sessionSubagentsByTaskId, (current) => { | |
| 4600 | 5010 | const registered = current.get(input.taskId); |
| 4601 | 5011 | if ( |
| 4602 | registered !== undefined && | |
| 4603 | registered.task.status !== "running" && | |
| 4604 | input.status === "running" && | |
| 4605 | !(isReopen && registered === existingSubagent) | |
| 5012 | (input.onlyIfRunningInRun !== undefined && | |
| 5013 | (registered?.task.status !== "running" || | |
| 5014 | registered.task.runId !== input.onlyIfRunningInRun)) || | |
| 5015 | (registered !== undefined && | |
| 5016 | registered.task.status !== "running" && | |
| 5017 | input.status === "running" && | |
| 5018 | !(isReopen && registered === existingSubagent)) | |
| 4606 | 5019 | ) { |
| 4607 | return current; | |
| 5020 | return [false, current] as const; | |
| 4608 | 5021 | } |
| 4609 | return new Map(current).set(input.taskId, subagent); | |
| 5022 | return [true, new Map(current).set(input.taskId, subagent)] as const; | |
| 4610 | 5023 | }); |
| 5024 | if (!accepted) return; | |
| 5025 | // A call the subagent left open never returns once it ends. It ends | |
| 5026 | // first, while the run that owns the child thread still takes its | |
| 5027 | // events. | |
| 5028 | if (existingSubagent !== undefined && input.status !== "running") { | |
| 5029 | const openCalls = [...subagentToolCalls.values()].filter( | |
| 5030 | (toolCall) => toolCall.threadId === existingSubagent.childThreadId, | |
| 5031 | ); | |
| 5032 | yield* endToolCalls({ | |
| 5033 | context: input.context, | |
| 5034 | toolCalls: openCalls, | |
| 5035 | status: input.status === "cancelled" ? "interrupted" : "failed", | |
| 5036 | completedAt: yield* DateTime.now, | |
| 5037 | }); | |
| 5038 | for (const toolCall of openCalls) { | |
| 5039 | subagentToolCalls.delete(toolCall.nativeItemId); | |
| 5040 | } | |
| 5041 | } | |
| 5042 | input.context.subagentsByTaskId.set(input.taskId, subagent); | |
| 5043 | if (input.toolUseId !== undefined) { | |
| 5044 | input.context.subagentsByToolUseId.set(input.toolUseId, subagent); | |
| 5045 | const toolUseId = input.toolUseId; | |
| 5046 | yield* Ref.update(sessionSubagentTaskIdsByToolUseId, (current) => | |
| 5047 | current.get(toolUseId) === input.taskId | |
| 5048 | ? current | |
| 5049 | : new Map(current).set(toolUseId, input.taskId), | |
| 5050 | ); | |
| 5051 | } | |
| 4611 | 5052 | |
| 4612 | if (existingSubagent === undefined) { | |
| 5053 | if (subagent.childThread === null) { | |
| 4613 | 5054 | const childThread = makeSubagentChildThread({ |
| 4614 | 5055 | parentThread: input.context.input.appThread, |
| 4615 | 5056 | childThreadId, |
| ... | ... | @@ -4620,18 +5061,24 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( |
| 4620 | 5061 | title: subagentThreadTitle({ |
| 4621 | 5062 | parentTitle: input.context.input.appThread.title, |
| 4622 | 5063 | prompt: task.prompt, |
| 4623 | title: task.title, | |
| 5064 | title: | |
| 5065 | workflowWithThreads === undefined | |
| 5066 | ? task.title | |
| 5067 | : `Workflow · ${workflowWithThreads.name ?? task.title ?? "Untitled"}`, | |
| 4624 | 5068 | ordinal: input.context.subagentsByTaskId.size, |
| 4625 | 5069 | }), |
| 4626 | 5070 | now, |
| 4627 | 5071 | createdBy: "agent", |
| 4628 | 5072 | creationSource: "provider", |
| 4629 | 5073 | }); |
| 4630 | yield* emitProviderEvent({ | |
| 4631 | type: "app_thread.created", | |
| 4632 | driver: CLAUDE_PROVIDER, | |
| 4633 | appThread: childThread, | |
| 4634 | }); | |
| 5074 | if (existingSubagent === undefined) { | |
| 5075 | yield* emitProviderEvent({ | |
| 5076 | type: "app_thread.created", | |
| 5077 | driver: CLAUDE_PROVIDER, | |
| 5078 | appThread: childThread, | |
| 5079 | }); | |
| 5080 | } | |
| 5081 | subagent.childThread = childThread; | |
| 4635 | 5082 | } |
| 4636 | 5083 | |
| 4637 | 5084 | if (lifecycleChanged) { |
| ... | ... | @@ -4695,46 +5142,29 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( |
| 4695 | 5142 | ? null |
| 4696 | 5143 | : `${nativeItemId}:prompt:${resumeToolUseId}`; |
| 4697 | 5144 | if (promptNativeItemId !== null) { |
| 4698 | const promptArtifacts = makeSubagentConversationArtifacts({ | |
| 5145 | yield* emitSubagentConversationMessage({ | |
| 5146 | nativeItemId: promptNativeItemId, | |
| 4699 | 5147 | senderThreadId: input.context.input.threadId, |
| 4700 | messageId: idAllocator.derive.messageFromProviderItem({ | |
| 4701 | driver: CLAUDE_PROVIDER, | |
| 4702 | nativeItemId: promptNativeItemId, | |
| 4703 | }), | |
| 4704 | turnItemId: idAllocator.derive.turnItemFromProviderItem({ | |
| 4705 | driver: CLAUDE_PROVIDER, | |
| 4706 | nativeItemId: promptNativeItemId, | |
| 4707 | }), | |
| 4708 | 5148 | threadId: childThreadId, |
| 4709 | 5149 | rootNodeId: childRootNodeId, |
| 4710 | providerThreadId: null, | |
| 4711 | providerTurnId: null, | |
| 4712 | nativeItemRef: { | |
| 4713 | driver: CLAUDE_PROVIDER, | |
| 4714 | nativeId: promptNativeItemId, | |
| 4715 | strength: "strong", | |
| 4716 | }, | |
| 4717 | 5150 | role: "user", |
| 4718 | 5151 | text: task.prompt, |
| 4719 | 5152 | ordinal: existingSubagent === undefined ? 100 : ++subagent.nextChildItemOrdinal, |
| 4720 | 5153 | now, |
| 4721 | 5154 | }); |
| 4722 | yield* emitProviderEvent({ | |
| 4723 | type: "message.updated", | |
| 4724 | driver: CLAUDE_PROVIDER, | |
| 4725 | message: promptArtifacts.message, | |
| 4726 | }); | |
| 4727 | yield* emitProviderEvent({ | |
| 4728 | type: "turn_item.updated", | |
| 4729 | driver: CLAUDE_PROVIDER, | |
| 4730 | turnItem: promptArtifacts.turnItem, | |
| 4731 | }); | |
| 4732 | 5155 | } |
| 4733 | 5156 | yield* emitProviderEvent({ |
| 4734 | 5157 | type: "subagent.updated", |
| 4735 | 5158 | driver: CLAUDE_PROVIDER, |
| 4736 | 5159 | subagent: task, |
| 4737 | 5160 | }); |
| 5161 | if (workflowWithThreads !== undefined) { | |
| 5162 | yield* projectClaudeWorkflowMembers({ | |
| 5163 | coordinator: subagent, | |
| 5164 | workflow: workflowWithThreads, | |
| 5165 | }); | |
| 5166 | if ((yield* Ref.get(sessionSubagentsByTaskId)).get(input.taskId) !== subagent) return; | |
| 5167 | } | |
| 4738 | 5168 | yield* emitProviderEvent({ |
| 4739 | 5169 | type: "turn_item.updated", |
| 4740 | 5170 | driver: CLAUDE_PROVIDER, |
| ... | ... | @@ -4782,39 +5212,15 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( |
| 4782 | 5212 | const resultNativeItemId = `${nativeItemId}:result`; |
| 4783 | 5213 | const resultItemOrdinal = subagent.resultItemOrdinal ?? ++subagent.nextChildItemOrdinal; |
| 4784 | 5214 | subagent.resultItemOrdinal = resultItemOrdinal; |
| 4785 | const resultArtifacts = makeSubagentConversationArtifacts({ | |
| 4786 | messageId: idAllocator.derive.messageFromProviderItem({ | |
| 4787 | driver: CLAUDE_PROVIDER, | |
| 4788 | nativeItemId: resultNativeItemId, | |
| 4789 | }), | |
| 4790 | turnItemId: idAllocator.derive.turnItemFromProviderItem({ | |
| 4791 | driver: CLAUDE_PROVIDER, | |
| 4792 | nativeItemId: resultNativeItemId, | |
| 4793 | }), | |
| 5215 | yield* emitSubagentConversationMessage({ | |
| 5216 | nativeItemId: resultNativeItemId, | |
| 4794 | 5217 | threadId: childThreadId, |
| 4795 | 5218 | rootNodeId: childRootNodeId, |
| 4796 | providerThreadId: null, | |
| 4797 | providerTurnId: null, | |
| 4798 | nativeItemRef: { | |
| 4799 | driver: CLAUDE_PROVIDER, | |
| 4800 | nativeId: resultNativeItemId, | |
| 4801 | strength: "strong", | |
| 4802 | }, | |
| 4803 | 5219 | role: "assistant", |
| 4804 | 5220 | text: input.result, |
| 4805 | 5221 | ordinal: resultItemOrdinal, |
| 4806 | 5222 | now, |
| 4807 | 5223 | }); |
| 4808 | yield* emitProviderEvent({ | |
| 4809 | type: "message.updated", | |
| 4810 | driver: CLAUDE_PROVIDER, | |
| 4811 | message: resultArtifacts.message, | |
| 4812 | }); | |
| 4813 | yield* emitProviderEvent({ | |
| 4814 | type: "turn_item.updated", | |
| 4815 | driver: CLAUDE_PROVIDER, | |
| 4816 | turnItem: resultArtifacts.turnItem, | |
| 4817 | }); | |
| 4818 | 5224 | } |
| 4819 | 5225 | }); |
| 4820 | 5226 | |
| ... | ... | @@ -5281,6 +5687,33 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( |
| 5281 | 5687 | readonly result?: SDKResultMessage; |
| 5282 | 5688 | }) { |
| 5283 | 5689 | yield* reasoningDeltas.flushTurn(input.context.nativeTurnId); |
| 5690 | if (input.status !== "completed") { | |
| 5691 | // The run stops following its subagents once its root turn fails or is | |
| 5692 | // interrupted, so a workflow it follows is stopped rather than left | |
| 5693 | // running where Lineage can no longer see or stop it. | |
| 5694 | const liveQuery = yield* Ref.get(queryContext); | |
| 5695 | for (const [taskId, subagent] of yield* Ref.get(sessionSubagentsByTaskId)) { | |
| 5696 | if ( | |
| 5697 | subagent.task.workflow === undefined || | |
| 5698 | subagent.task.status !== "running" || | |
| 5699 | subagent.task.runId !== input.context.input.runId | |
| 5700 | ) | |
| 5701 | continue; | |
| 5702 | if (liveQuery !== null && !liveQuery.stopping) { | |
| 5703 | // Best effort and off the terminal path: an unanswered stop must | |
| 5704 | // not keep the turn from ending. | |
| 5705 | yield* liveQuery.query | |
| 5706 | .stopTask(taskId) | |
| 5707 | .pipe(Effect.timeout("15 seconds"), Effect.ignore, Effect.forkIn(sessionScope)); | |
| 5708 | } | |
| 5709 | yield* updateClaudeSubagentNode({ | |
| 5710 | context: input.context, | |
| 5711 | taskId, | |
| 5712 | status: input.status === "failed" ? "failed" : "cancelled", | |
| 5713 | onlyIfRunningInRun: input.context.input.runId, | |
| 5714 | }); | |
| 5715 | } | |
| 5716 | } | |
| 5284 | 5717 | // A subagent still running in the background keeps its open calls: |
| 5285 | 5718 | // their results arrive after this turn. One left by an earlier CLI |
| 5286 | 5719 | // process is gone and never reports them. |
| ... | ... | @@ -5288,14 +5721,15 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( |
| 5288 | 5721 | const runningChildThreadIds = new Set( |
| 5289 | 5722 | [...(yield* Ref.get(sessionSubagentsByTaskId)).values()].flatMap((subagent) => |
| 5290 | 5723 | subagent.task.status === "running" && |
| 5724 | (input.status === "completed" || | |
| 5725 | (liveQuery !== null && subagent.task.runId !== input.context.input.runId)) && | |
| 5291 | 5726 | liveQuery?.subagentsFromEarlierProcesses.has(subagent) !== true |
| 5292 | 5727 | ? [subagent.childThreadId] |
| 5293 | 5728 | : [], |
| 5294 | 5729 | ), |
| 5295 | 5730 | ); |
| 5296 | 5731 | const endingSubagentCalls = [...subagentToolCalls.values()].filter( |
| 5297 | (toolCall) => | |
| 5298 | input.status !== "completed" || !runningChildThreadIds.has(toolCall.threadId), | |
| 5732 | (toolCall) => !runningChildThreadIds.has(toolCall.threadId), | |
| 5299 | 5733 | ); |
| 5300 | 5734 | yield* endToolCalls({ |
| 5301 | 5735 | context: input.context, |
| ... | ... | @@ -5570,27 +6004,72 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( |
| 5570 | 6004 | }); |
| 5571 | 6005 | |
| 5572 | 6006 | const finalizeActiveTurnAfterQueryExit = Effect.fnUntraced(function* ( |
| 6007 | closedQuery: ClaudeLiveQueryContext, | |
| 5573 | 6008 | cause?: Cause.Cause<ClaudeAgentSdkQueryRunnerError>, |
| 5574 | 6009 | ) { |
| 5575 | const context = yield* Ref.get(activeTurn); | |
| 5576 | if (context === null) { | |
| 5577 | return; | |
| 5578 | } | |
| 5579 | const completedAt = yield* DateTime.now; | |
| 5580 | const interrupted = (yield* Ref.get(interruptedTurns)).has(context.providerTurnId); | |
| 5581 | yield* finalizeActiveTurn({ | |
| 5582 | context, | |
| 5583 | status: interrupted ? "interrupted" : "failed", | |
| 5584 | completedAt, | |
| 5585 | ...(interrupted | |
| 5586 | ? {} | |
| 5587 | : { | |
| 5588 | failure: makeProviderFailure({ | |
| 5589 | cause: cause === undefined ? undefined : Cause.squash(cause), | |
| 5590 | class: "transport_error", | |
| 6010 | const current = yield* Ref.get(activeTurn); | |
| 6011 | const context = | |
| 6012 | current?.nativeThreadId === closedQuery.nativeThreadId ? current : closedQuery.lastTurn; | |
| 6013 | if (context === null) return; | |
| 6014 | const interrupted = | |
| 6015 | closedQuery.stopping || (yield* Ref.get(interruptedTurns)).has(context.providerTurnId); | |
| 6016 | const subagents = yield* Ref.get(sessionSubagentsByTaskId); | |
| 6017 | const buffered = (yield* Ref.get(wakeBuffers)).get(closedQuery.nativeThreadId)?.messages; | |
| 6018 | if (current === context) { | |
| 6019 | yield* finalizeActiveTurn({ | |
| 6020 | context, | |
| 6021 | status: interrupted ? "interrupted" : "failed", | |
| 6022 | completedAt: yield* DateTime.now, | |
| 6023 | ...(interrupted | |
| 6024 | ? {} | |
| 6025 | : { | |
| 6026 | failure: makeProviderFailure({ | |
| 6027 | cause: cause === undefined ? undefined : Cause.squash(cause), | |
| 6028 | class: "transport_error", | |
| 6029 | }), | |
| 5591 | 6030 | }), |
| 5592 | }), | |
| 5593 | }); | |
| 6031 | }); | |
| 6032 | } else { | |
| 6033 | yield* resetBackgroundTaskStateForNativeThreadProcess(closedQuery.nativeThreadId); | |
| 6034 | } | |
| 6035 | // A workflow can outlive its launching turn, but not its CLI process. | |
| 6036 | // Apply buffered outcomes before Stop can discard their wake buffer. | |
| 6037 | // Capture ownership before finalization, which may yield to a new process. | |
| 6038 | for (const [taskId, subagent] of subagents) { | |
| 6039 | if ( | |
| 6040 | subagent.task.workflow === undefined || | |
| 6041 | subagent.task.status !== "running" || | |
| 6042 | closedQuery.subagentsFromEarlierProcesses.has(subagent) | |
| 6043 | ) | |
| 6044 | continue; | |
| 6045 | const latestLifecycle = buffered?.findLast( | |
| 6046 | ( | |
| 6047 | message, | |
| 6048 | ): message is Extract< | |
| 6049 | SDKMessage, | |
| 6050 | { readonly subtype: "task_started" | "task_notification" } | |
| 6051 | > => | |
| 6052 | message.type === "system" && | |
| 6053 | (message.subtype === "task_started" || message.subtype === "task_notification") && | |
| 6054 | message.task_id === taskId, | |
| 6055 | ); | |
| 6056 | // A later start resumes the same task and invalidates its older outcome. | |
| 6057 | const completion = | |
| 6058 | latestLifecycle?.subtype === "task_notification" ? latestLifecycle : undefined; | |
| 6059 | yield* updateClaudeSubagentNode({ | |
| 6060 | context, | |
| 6061 | taskId, | |
| 6062 | status: completion | |
| 6063 | ? claudeTaskOutcome(completion.status) | |
| 6064 | : interrupted | |
| 6065 | ? "cancelled" | |
| 6066 | : "failed", | |
| 6067 | ...(completion === undefined | |
| 6068 | ? {} | |
| 6069 | : { workflowFrame: completion, result: completion.summary }), | |
| 6070 | onlyIfRunningInRun: subagent.task.runId, | |
| 6071 | }); | |
| 6072 | } | |
| 5594 | 6073 | yield* Ref.update(interruptedTurns, (current) => { |
| 5595 | 6074 | const next = new Set(current); |
| 5596 | 6075 | next.delete(context.providerTurnId); |
| ... | ... | @@ -5606,6 +6085,64 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( |
| 5606 | 6085 | } |
| 5607 | 6086 | }); |
| 5608 | 6087 | |
| 6088 | /** | |
| 6089 | * Refreshes a background workflow coordinator's entity between turns. | |
| 6090 | * Deliberately narrow: only a registered coordinator that this frame | |
| 6091 | * carries a snapshot for is touched, and only `subagent.updated` is | |
| 6092 | * emitted, because node and turn-item ids need a turn context this path | |
| 6093 | * does not have. Status is left alone — `task_notification` owns the | |
| 6094 | * lifecycle, and it has its own turn-aware path. | |
| 6095 | * These updates only reach the projection while the launching run is still | |
| 6096 | * ingesting, which holds because a running coordinator keeps that run open. | |
| 6097 | */ | |
| 6098 | const applyWorkflowProgressWithoutTurn = Effect.fnUntraced(function* (message: SDKMessage) { | |
| 6099 | if (message.type !== "system" || message.subtype !== "task_progress") return; | |
| 6100 | if (!hasClaudeWorkflowSnapshot(message)) return; | |
| 6101 | const taskId = message.task_id; | |
| 6102 | const registered = (yield* Ref.get(sessionSubagentsByTaskId)).get(taskId); | |
| 6103 | if ( | |
| 6104 | registered === undefined || | |
| 6105 | registered.task.workflow === undefined || | |
| 6106 | registered.task.status !== "running" | |
| 6107 | ) | |
| 6108 | return; | |
| 6109 | const workflow = mergeClaudeWorkflowProgress({ | |
| 6110 | previous: registered.task.workflow, | |
| 6111 | message, | |
| 6112 | }); | |
| 6113 | if (workflow === undefined) return; | |
| 6114 | const now = yield* DateTime.now; | |
| 6115 | const progress = text(message.description); | |
| 6116 | const workflowWithThreads = withWorkflowMemberThreadIds( | |
| 6117 | workflow, | |
| 6118 | registered.childThreadId, | |
| 6119 | ); | |
| 6120 | const task = { | |
| 6121 | ...registered.task, | |
| 6122 | ...(progress === undefined ? {} : { progress }), | |
| 6123 | workflow: workflowWithThreads, | |
| 6124 | updatedAt: now, | |
| 6125 | } satisfies OrchestrationV2Subagent; | |
| 6126 | const coordinator = { ...registered, task }; | |
| 6127 | const accepted = yield* Ref.modify(sessionSubagentsByTaskId, (current) => { | |
| 6128 | const latest = current.get(taskId); | |
| 6129 | if (latest !== registered || latest.task.status !== "running") { | |
| 6130 | return [false, current] as const; | |
| 6131 | } | |
| 6132 | return [true, new Map(current).set(taskId, coordinator)] as const; | |
| 6133 | }); | |
| 6134 | if (!accepted) return; | |
| 6135 | yield* emitProviderEvent({ | |
| 6136 | type: "subagent.updated", | |
| 6137 | driver: CLAUDE_PROVIDER, | |
| 6138 | subagent: task, | |
| 6139 | }); | |
| 6140 | yield* projectClaudeWorkflowMembers({ | |
| 6141 | coordinator, | |
| 6142 | workflow: workflowWithThreads, | |
| 6143 | }); | |
| 6144 | }); | |
| 6145 | ||
| 5609 | 6146 | const bufferWakeMessage = Effect.fnUntraced(function* (wakeInput: { |
| 5610 | 6147 | readonly nativeThreadId: string; |
| 5611 | 6148 | readonly message: SDKMessage; |
| ... | ... | @@ -6131,6 +6668,13 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( |
| 6131 | 6668 | (yield* Ref.get(activeTurn)) ?? |
| 6132 | 6669 | (yield* settledTurnForSubagentFrame(liveQuery.nativeThreadId, message)); |
| 6133 | 6670 | if (context === null) { |
| 6671 | // A workflow is normally launched in the background, so most of its | |
| 6672 | // run happens after the turn that started it has settled. Nothing | |
| 6673 | // here can build turn items or nodes without a turn context, but the | |
| 6674 | // coordinator entity is what Lineage renders, so refresh it from the | |
| 6675 | // session registry and a live run keeps reporting its phases, members | |
| 6676 | // and usage instead of freezing at turn end. | |
| 6677 | yield* applyWorkflowProgressWithoutTurn(message); | |
| 6134 | 6678 | // task_notification must buffer wake evidence while still tracked |
| 6135 | 6679 | // on the roster; clearing first would drop the wake pin. |
| 6136 | 6680 | if (message.type === "system" && message.subtype === "task_notification") { |
| ... | ... | @@ -6523,6 +7067,14 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( |
| 6523 | 7067 | toolUseId: message.tool_use_id, |
| 6524 | 7068 | title: message.description, |
| 6525 | 7069 | }); |
| 7070 | // A workflow coordinator is marked as one the moment it starts, so | |
| 7071 | // Lineage can expand it before the first member spawns rather than | |
| 7072 | // after the first progress snapshot. | |
| 7073 | const isWorkflow = | |
| 7074 | claudeTaskTypeFromSdkMessage(message) === CLAUDE_WORKFLOW_TASK_TYPE; | |
| 7075 | // `meta.name` from the workflow script; only workflow tasks carry it. | |
| 7076 | const workflowName = isWorkflow ? text(field(message, "workflow_name")) : undefined; | |
| 7077 | ||
| 6526 | 7078 | yield* updateClaudeSubagentNode({ |
| 6527 | 7079 | context, |
| 6528 | 7080 | taskId: message.task_id, |
| ... | ... | @@ -6531,10 +7083,37 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( |
| 6531 | 7083 | ...(model === undefined ? {} : { model }), |
| 6532 | 7084 | ...(launch?.effort === undefined ? {} : { effort: launch.effort }), |
| 6533 | 7085 | ...(owner === undefined ? {} : { owner }), |
| 7086 | ...(isWorkflow | |
| 7087 | ? { | |
| 7088 | workflowFrame: message, | |
| 7089 | ...(workflowName === undefined ? {} : { workflowName }), | |
| 7090 | } | |
| 7091 | : {}), | |
| 7092 | ||
| 6534 | 7093 | title: message.description, |
| 6535 | 7094 | status: "running", |
| 6536 | 7095 | reopen: true, |
| 6537 | 7096 | }); |
| 7097 | // A resume supersedes earlier buffered starts and outcomes for this task. | |
| 7098 | // Replaying a start preserves the completion buffered after it. | |
| 7099 | yield* Ref.update(wakeBuffers, (current) => { | |
| 7100 | const entry = current.get(liveQuery.nativeThreadId); | |
| 7101 | if (entry === undefined) return current; | |
| 7102 | const replayIndex = entry.messages.indexOf(message); | |
| 7103 | const messages = entry.messages.filter( | |
| 7104 | (buffered, index) => | |
| 7105 | !( | |
| 7106 | (replayIndex === -1 || index < replayIndex) && | |
| 7107 | buffered.type === "system" && | |
| 7108 | (buffered.subtype === "task_started" || | |
| 7109 | buffered.subtype === "task_notification") && | |
| 7110 | buffered.task_id === message.task_id | |
| 7111 | ), | |
| 7112 | ); | |
| 7113 | return messages.length === entry.messages.length | |
| 7114 | ? current | |
| 7115 | : new Map(current).set(liveQuery.nativeThreadId, { ...entry, messages }); | |
| 7116 | }); | |
| 6538 | 7117 | } |
| 6539 | 7118 | } |
| 6540 | 7119 | |
| ... | ... | @@ -6544,8 +7123,14 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( |
| 6544 | 7123 | liveQuery.nativeThreadId, |
| 6545 | 7124 | message.task_id, |
| 6546 | 7125 | ); |
| 7126 | // A workflow frame is worth an update even with an empty description: | |
| 7127 | // it carries the phase plan, the member roster and the run's usage. | |
| 7128 | const carriesWorkflowTelemetry = hasClaudeWorkflowSnapshot(message); | |
| 7129 | const isWorkflowCoordinator = | |
| 7130 | (yield* Ref.get(sessionSubagentsByTaskId)).get(message.task_id)?.task.workflow !== | |
| 7131 | undefined; | |
| 6547 | 7132 | if ( |
| 6548 | progress.length > 0 && | |
| 7133 | (carriesWorkflowTelemetry || (progress.length > 0 && !isWorkflowCoordinator)) && | |
| 6549 | 7134 | !context.ignoredTaskIds.has(message.task_id) && |
| 6550 | 7135 | !isBackgroundTask |
| 6551 | 7136 | ) { |
| ... | ... | @@ -6553,7 +7138,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( |
| 6553 | 7138 | context, |
| 6554 | 7139 | taskId: message.task_id, |
| 6555 | 7140 | ...(message.tool_use_id === undefined ? {} : { toolUseId: message.tool_use_id }), |
| 6556 | progress, | |
| 7141 | ...(progress.length === 0 ? {} : { progress }), | |
| 7142 | workflowFrame: message, | |
| 6557 | 7143 | status: "running", |
| 6558 | 7144 | }); |
| 6559 | 7145 | } |
| ... | ... | @@ -6607,6 +7193,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( |
| 6607 | 7193 | context, |
| 6608 | 7194 | taskId: message.task_id, |
| 6609 | 7195 | ...(message.tool_use_id === undefined ? {} : { toolUseId: message.tool_use_id }), |
| 7196 | // Terminal usage totals arrive here, after the last snapshot. | |
| 7197 | workflowFrame: message, | |
| 6610 | 7198 | result: message.summary, |
| 6611 | 7199 | status: |
| 6612 | 7200 | message.status === "completed" |
| ... | ... | @@ -6673,10 +7261,25 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( |
| 6673 | 7261 | } |
| 6674 | 7262 | |
| 6675 | 7263 | for (const { toolResult, output } of claudeToolResultEntriesFromMessage(message)) { |
| 6676 | // Session scope: while the root is idle, this context is the turn | |
| 6677 | // that settled last, which never saw a subagent an earlier turn | |
| 6678 | // launched (a nested one whose Agent call returns now). | |
| 6679 | 7264 | const subagent = yield* resolveSubagentByToolUseId(context, toolResult.tool_use_id); |
| 7265 | const runHandles = parseClaudeWorkflowRunHandles(claudeNativeToolOutputValue(output)); | |
| 7266 | // Independent of the branches below: unlike the Agent tool, a | |
| 7267 | // Workflow tool_use is an ordinary tool call and so lands in | |
| 7268 | // toolCalls, which skips the subagent branch entirely. Its launch | |
| 7269 | // acknowledgement is the only carrier of the run's filesystem | |
| 7270 | // handles, so harvest them wherever the result arrives. | |
| 7271 | if (subagent !== undefined && runHandles !== undefined) { | |
| 7272 | yield* updateClaudeSubagentNode({ | |
| 7273 | context, | |
| 7274 | taskId: subagent.task.nativeTaskRef?.nativeId ?? String(subagent.task.id), | |
| 7275 | toolUseId: toolResult.tool_use_id, | |
| 7276 | workflowRunHandles: runHandles, | |
| 7277 | // The acknowledgement arrives at launch, so the coordinator | |
| 7278 | // is still running; a late one is dropped by the same | |
| 7279 | // terminal protection that guards every other update. | |
| 7280 | status: "running", | |
| 7281 | }); | |
| 7282 | } | |
| 6680 | 7283 | // A resume task_started reuses the resuming tool call's |
| 6681 | 7284 | // tool_use_id (e.g. SendMessage), whose tool_result only |
| 6682 | 7285 | // acknowledges delivery. Only the Agent launch's tool_result may |
| ... | ... | @@ -6689,7 +7292,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( |
| 6689 | 7292 | // A background Agent launch resolves its tool_use immediately |
| 6690 | 7293 | // with an async-launch ACK while the task keeps running; only |
| 6691 | 7294 | // the eventual task_notification terminalizes the subagent. |
| 6692 | if (isClaudeSubagentAsyncLaunchAck(output)) { | |
| 7295 | if ( | |
| 7296 | !isClaudeToolResultError(toolResult) && | |
| 7297 | (runHandles !== undefined || isClaudeSubagentAsyncLaunchAck(output)) | |
| 7298 | ) { | |
| 6693 | 7299 | continue; |
| 6694 | 7300 | } |
| 6695 | 7301 | const result = claudeSubagentResultText(output); |
| ... | ... | @@ -6838,39 +7444,15 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( |
| 6838 | 7444 | ? `${subagent.lastAssistantText ?? ""}\n${assistantText.text}` |
| 6839 | 7445 | : assistantText.text; |
| 6840 | 7446 | subagent.lastAssistantMessageId = nativeMessageId; |
| 6841 | const artifacts = makeSubagentConversationArtifacts({ | |
| 6842 | messageId: idAllocator.derive.messageFromProviderItem({ | |
| 6843 | driver: CLAUDE_PROVIDER, | |
| 6844 | nativeItemId: assistantText.nativeItemId, | |
| 6845 | }), | |
| 6846 | turnItemId: idAllocator.derive.turnItemFromProviderItem({ | |
| 6847 | driver: CLAUDE_PROVIDER, | |
| 6848 | nativeItemId: assistantText.nativeItemId, | |
| 6849 | }), | |
| 7447 | yield* emitSubagentConversationMessage({ | |
| 7448 | nativeItemId: assistantText.nativeItemId, | |
| 6850 | 7449 | threadId: subagent.childThreadId, |
| 6851 | 7450 | rootNodeId: subagent.childRootNodeId, |
| 6852 | providerThreadId: null, | |
| 6853 | providerTurnId: null, | |
| 6854 | nativeItemRef: { | |
| 6855 | driver: CLAUDE_PROVIDER, | |
| 6856 | nativeId: assistantText.nativeItemId, | |
| 6857 | strength: "strong", | |
| 6858 | }, | |
| 6859 | 7451 | role: "assistant", |
| 6860 | 7452 | text: assistantText.text, |
| 6861 | 7453 | ordinal: ++subagent.nextChildItemOrdinal, |
| 6862 | 7454 | now, |
| 6863 | 7455 | }); |
| 6864 | yield* emitProviderEvent({ | |
| 6865 | type: "message.updated", | |
| 6866 | driver: CLAUDE_PROVIDER, | |
| 6867 | message: artifacts.message, | |
| 6868 | }); | |
| 6869 | yield* emitProviderEvent({ | |
| 6870 | type: "turn_item.updated", | |
| 6871 | driver: CLAUDE_PROVIDER, | |
| 6872 | turnItem: artifacts.turnItem, | |
| 6873 | }); | |
| 6874 | 7456 | return; |
| 6875 | 7457 | } |
| 6876 | 7458 | if (assistantText !== null && assistantText.text.length > 0) { |
| ... | ... | @@ -7726,6 +8308,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( |
| 7726 | 8308 | queryPolicyKey, |
| 7727 | 8309 | selectionKey: compiledSelection.queryIdentity, |
| 7728 | 8310 | closed, |
| 8311 | lastTurn: null, | |
| 7729 | 8312 | promptEchoMode: "unknown", |
| 7730 | 8313 | openedPermissionMode: queryOptions.permissionMode, |
| 7731 | 8314 | permissionMode: queryOptions.permissionMode, |
| ... | ... | @@ -7738,16 +8321,20 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( |
| 7738 | 8321 | }; |
| 7739 | 8322 | yield* Ref.set(queryContext, context); |
| 7740 | 8323 | yield* querySession.messages.pipe( |
| 7741 | Stream.runForEach((message) => { | |
| 7742 | if ( | |
| 7743 | message.type === "system" && | |
| 7744 | (message.subtype === "init" || message.subtype === "status") && | |
| 7745 | message.permissionMode !== undefined | |
| 7746 | ) { | |
| 7747 | context.permissionMode = message.permissionMode; | |
| 7748 | } | |
| 7749 | return handleSdkMessage({ query: querySession, message }); | |
| 7750 | }), | |
| 8324 | Stream.runForEach((message) => | |
| 8325 | sdkMessageLock.withPermits(1)( | |
| 8326 | Effect.suspend(() => { | |
| 8327 | if ( | |
| 8328 | message.type === "system" && | |
| 8329 | (message.subtype === "init" || message.subtype === "status") && | |
| 8330 | message.permissionMode !== undefined | |
| 8331 | ) { | |
| 8332 | context.permissionMode = message.permissionMode; | |
| 8333 | } | |
| 8334 | return handleSdkMessage({ query: querySession, message }); | |
| 8335 | }), | |
| 8336 | ), | |
| 8337 | ), | |
| 7751 | 8338 | Effect.exit, |
| 7752 | 8339 | Effect.flatMap( |
| 7753 | 8340 | Effect.fnUntraced(function* (exit: ClaudeQueryStreamExit) { |
| ... | ... | @@ -7767,6 +8354,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( |
| 7767 | 8354 | ); |
| 7768 | 8355 | if (ownsLiveQuery) { |
| 7769 | 8356 | yield* finalizeActiveTurnAfterQueryExit( |
| 8357 | context, | |
| 7770 | 8358 | exit._tag === "Failure" ? exit.cause : undefined, |
| 7771 | 8359 | ); |
| 7772 | 8360 | yield* endSubagentCallsOfClosedQuery( |
| ... | ... | @@ -7885,17 +8473,21 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( |
| 7885 | 8473 | uuid: promptUuid, |
| 7886 | 8474 | }); |
| 7887 | 8475 | const querySession = yield* openQuery(turnInput, nativeThreadId); |
| 7888 | yield* Ref.set(activeTurn, context); | |
| 7889 | yield* emitProviderEvent({ | |
| 7890 | type: "provider_turn.updated", | |
| 7891 | driver: CLAUDE_PROVIDER, | |
| 7892 | providerTurn: providerTurnPayload({ | |
| 7893 | context, | |
| 7894 | status: "running", | |
| 7895 | completedAt: null, | |
| 7896 | }), | |
| 8476 | const attachTurn = Effect.gen(function* () { | |
| 8477 | querySession.lastTurn = context; | |
| 8478 | yield* Ref.set(activeTurn, context); | |
| 8479 | yield* emitProviderEvent({ | |
| 8480 | type: "provider_turn.updated", | |
| 8481 | driver: CLAUDE_PROVIDER, | |
| 8482 | providerTurn: providerTurnPayload({ | |
| 8483 | context, | |
| 8484 | status: "running", | |
| 8485 | completedAt: null, | |
| 8486 | }), | |
| 8487 | }); | |
| 7897 | 8488 | }); |
| 7898 | 8489 | if (userMessage !== null) { |
| 8490 | yield* attachTurn; | |
| 7899 | 8491 | // A user turn that races a wake leaves the buffer alone: the |
| 7900 | 8492 | // continuation run the worker queued behind this run drains it |
| 7901 | 8493 | // afterwards with correct attribution. |
| ... | ... | @@ -7904,64 +8496,95 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( |
| 7904 | 8496 | yield* querySession.query.offer(userMessage); |
| 7905 | 8497 | return; |
| 7906 | 8498 | } |
| 7907 | const drained = yield* Ref.modify(wakeBuffers, (current) => { | |
| 7908 | const entry = current.get(nativeThreadId); | |
| 7909 | if (entry === undefined) { | |
| 7910 | return [[] as ReadonlyArray<SDKMessage>, current] as const; | |
| 7911 | } | |
| 7912 | const updated = new Map(current); | |
| 7913 | updated.delete(nativeThreadId); | |
| 7914 | return [entry.messages, updated] as const; | |
| 7915 | }); | |
| 7916 | yield* Ref.update(requestedContinuations, (current) => { | |
| 7917 | const updated = new Set(current); | |
| 7918 | updated.delete(nativeThreadId); | |
| 7919 | return updated; | |
| 7920 | }); | |
| 7921 | if (drained.length === 0) { | |
| 7922 | // Spurious continuation (buffer already lost with a recycled | |
| 7923 | // session, or a duplicate request): settle immediately instead | |
| 7924 | // of leaving a run waiting on a prompt that was never sent. | |
| 7925 | const completedAt = yield* DateTime.now; | |
| 7926 | yield* finalizeActiveTurn({ context, status: "completed", completedAt }); | |
| 7927 | return; | |
| 7928 | } | |
| 7929 | // Replay any result message last: a result finalizes the turn, and | |
| 7930 | // replaying it before the rest would drop them back into the wake | |
| 7931 | // buffer and request another continuation. | |
| 7932 | const resultMessages = drained.filter((entry) => entry.type === "result"); | |
| 7933 | const opaqueReplayTombstones = taskIdSetForNativeThread( | |
| 7934 | yield* Ref.get(opaqueBackgroundTaskReplayTombstonesByNativeThread), | |
| 7935 | nativeThreadId, | |
| 7936 | ); | |
| 7937 | const hasOpaqueTaskNotification = drained.some( | |
| 7938 | (entry) => | |
| 7939 | entry.type === "system" && | |
| 7940 | entry.subtype === "task_notification" && | |
| 7941 | opaqueReplayTombstones.has(entry.task_id), | |
| 7942 | ); | |
| 7943 | for (const entry of drained) { | |
| 7944 | if (entry.type !== "result") { | |
| 7945 | yield* handleSdkMessage({ query: querySession.query, message: entry }); | |
| 7946 | } | |
| 7947 | } | |
| 7948 | const lastResult = resultMessages.at(-1); | |
| 7949 | if (lastResult !== undefined) { | |
| 7950 | yield* handleSdkMessage({ query: querySession.query, message: lastResult }); | |
| 7951 | return; | |
| 7952 | } | |
| 7953 | // A drained `init` means Claude began the wake turn, so its output | |
| 7954 | // may still be on the way: stay open for it. | |
| 7955 | const hasNativeWakeFrame = drained.some( | |
| 7956 | (entry) => | |
| 7957 | entry.type === "user" || | |
| 7958 | entry.type === "assistant" || | |
| 7959 | isClaudeTurnStartMessage(entry), | |
| 8499 | // Transcript reads suspend replay. Hold live SDK frames until the whole | |
| 8500 | // buffer has been applied so starts, completions and the root result stay ordered. | |
| 8501 | yield* sdkMessageLock.withPermits(1)( | |
| 8502 | Effect.gen(function* () { | |
| 8503 | if ( | |
| 8504 | (yield* Ref.get(queryContext))?.query !== querySession.query || | |
| 8505 | querySession.stopping | |
| 8506 | ) { | |
| 8507 | return yield* new ProviderAdapter.ProviderAdapterProtocolError({ | |
| 8508 | driver: CLAUDE_PROVIDER, | |
| 8509 | detail: "Claude provider query ended before the continuation could start.", | |
| 8510 | }); | |
| 8511 | } | |
| 8512 | // Live frames can finish while this continuation waits for the permit. | |
| 8513 | yield* attachTurn; | |
| 8514 | // Exit and interrupt cleanup can run during transcript reads. | |
| 8515 | // Keep pending outcomes visible until replay applies them. | |
| 8516 | const drained = (yield* Ref.get(wakeBuffers)).get(nativeThreadId)?.messages ?? []; | |
| 8517 | yield* Ref.update(requestedContinuations, (current) => { | |
| 8518 | const updated = new Set(current); | |
| 8519 | updated.delete(nativeThreadId); | |
| 8520 | return updated; | |
| 8521 | }); | |
| 8522 | if (drained.length === 0) { | |
| 8523 | // Spurious continuation (buffer already lost with a recycled | |
| 8524 | // session, or a duplicate request): settle immediately instead | |
| 8525 | // of leaving a run waiting on a prompt that was never sent. | |
| 8526 | const completedAt = yield* DateTime.now; | |
| 8527 | yield* finalizeActiveTurn({ context, status: "completed", completedAt }); | |
| 8528 | return; | |
| 8529 | } | |
| 8530 | // Replay any result message last: a result finalizes the turn, and | |
| 8531 | // replaying it before the rest would drop them back into the wake | |
| 8532 | // buffer and request another continuation. | |
| 8533 | const resultMessages = drained.filter((entry) => entry.type === "result"); | |
| 8534 | const opaqueReplayTombstones = taskIdSetForNativeThread( | |
| 8535 | yield* Ref.get(opaqueBackgroundTaskReplayTombstonesByNativeThread), | |
| 8536 | nativeThreadId, | |
| 8537 | ); | |
| 8538 | const hasOpaqueTaskNotification = drained.some( | |
| 8539 | (entry) => | |
| 8540 | entry.type === "system" && | |
| 8541 | entry.subtype === "task_notification" && | |
| 8542 | opaqueReplayTombstones.has(entry.task_id), | |
| 8543 | ); | |
| 8544 | for (const entry of drained) { | |
| 8545 | if ( | |
| 8546 | !(yield* Ref.get(wakeBuffers)).get(nativeThreadId)?.messages.includes(entry) | |
| 8547 | ) { | |
| 8548 | continue; | |
| 8549 | } | |
| 8550 | if (entry.type !== "result") { | |
| 8551 | yield* handleSdkMessage({ query: querySession.query, message: entry }); | |
| 8552 | } | |
| 8553 | yield* Ref.update(wakeBuffers, (current) => { | |
| 8554 | const buffered = current.get(nativeThreadId); | |
| 8555 | if (buffered === undefined) return current; | |
| 8556 | const index = buffered.messages.indexOf(entry); | |
| 8557 | if (index === -1) return current; | |
| 8558 | // An idle handler may requeue this same frame for the next continuation. | |
| 8559 | const messages = [ | |
| 8560 | ...buffered.messages.slice(0, index), | |
| 8561 | ...buffered.messages.slice(index + 1), | |
| 8562 | ]; | |
| 8563 | const next = new Map(current); | |
| 8564 | if (messages.length === 0) next.delete(nativeThreadId); | |
| 8565 | else next.set(nativeThreadId, { ...buffered, messages }); | |
| 8566 | return next; | |
| 8567 | }); | |
| 8568 | } | |
| 8569 | const lastResult = resultMessages.at(-1); | |
| 8570 | if (lastResult !== undefined) { | |
| 8571 | yield* handleSdkMessage({ query: querySession.query, message: lastResult }); | |
| 8572 | return; | |
| 8573 | } | |
| 8574 | // A drained `init` means Claude began the wake turn, so its output | |
| 8575 | // may still be on the way: stay open for it. | |
| 8576 | const hasNativeWakeFrame = drained.some( | |
| 8577 | (entry) => | |
| 8578 | entry.type === "user" || | |
| 8579 | entry.type === "assistant" || | |
| 8580 | isClaudeTurnStartMessage(entry), | |
| 8581 | ); | |
| 8582 | if (hasOpaqueTaskNotification && !hasNativeWakeFrame) { | |
| 8583 | const completedAt = yield* DateTime.now; | |
| 8584 | yield* finalizeActiveTurn({ context, status: "completed", completedAt }); | |
| 8585 | } | |
| 8586 | }), | |
| 7960 | 8587 | ); |
| 7961 | if (hasOpaqueTaskNotification && !hasNativeWakeFrame) { | |
| 7962 | const completedAt = yield* DateTime.now; | |
| 7963 | yield* finalizeActiveTurn({ context, status: "completed", completedAt }); | |
| 7964 | } | |
| 7965 | 8588 | }, |
| 7966 | 8589 | (effect, turnInput) => |
| 7967 | 8590 | effect.pipe( |
| ... | ... | @@ -8042,14 +8665,21 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( |
| 8042 | 8665 | providerThreadId: turnInput.providerThread.id, |
| 8043 | 8666 | providerTurnId: turnInput.providerTurnId, |
| 8044 | 8667 | }); |
| 8045 | yield* Ref.update(queryContext, (current) => | |
| 8046 | current?.query === existing.query ? null : current, | |
| 8668 | const ownedLiveQuery = yield* Ref.modify(queryContext, (current) => | |
| 8669 | current?.query === existing.query ? [true, null] : [false, current], | |
| 8047 | 8670 | ); |
| 8048 | yield* finalizeActiveTurn({ | |
| 8049 | context: currentTurn, | |
| 8050 | status: "interrupted", | |
| 8051 | completedAt, | |
| 8052 | }); | |
| 8671 | if (ownedLiveQuery) { | |
| 8672 | existing.stopping = true; | |
| 8673 | yield* finalizeActiveTurnAfterQueryExit(existing); | |
| 8674 | yield* endSubagentCallsOfClosedQuery(existing.nativeThreadId, "interrupted"); | |
| 8675 | } else { | |
| 8676 | // The stream may already be finalizing while another process starts. | |
| 8677 | yield* finalizeActiveTurn({ | |
| 8678 | context: currentTurn, | |
| 8679 | status: "interrupted", | |
| 8680 | completedAt, | |
| 8681 | }); | |
| 8682 | } | |
| 8053 | 8683 | yield* Deferred.succeed(existing.closed, undefined); |
| 8054 | 8684 | }, |
| 8055 | 8685 | (effect, turnInput) => |
| ... | ... | @@ -8155,6 +8785,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( |
| 8155 | 8785 | current?.query === existing.query ? [true, null] : [false, current], |
| 8156 | 8786 | ); |
| 8157 | 8787 | if (ownedLiveQuery) { |
| 8788 | yield* finalizeActiveTurnAfterQueryExit(existing); | |
| 8158 | 8789 | yield* endSubagentCallsOfClosedQuery(nativeThreadId, "interrupted"); |
| 8159 | 8790 | } |
| 8160 | 8791 | yield* Deferred.succeed(existing.closed, undefined); |
| ... | ... | @@ -8266,6 +8897,36 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( |
| 8266 | 8897 | message: { ...turnInput.message, text: "/compact" }, |
| 8267 | 8898 | }), |
| 8268 | 8899 | steerTurn, |
| 8900 | stopTask: ({ providerThread, taskId }) => | |
| 8901 | Effect.gen(function* () { | |
| 8902 | const liveQuery = yield* Ref.get(queryContext); | |
| 8903 | const task = (yield* Ref.get(sessionSubagentsByTaskId)).get(taskId); | |
| 8904 | if ( | |
| 8905 | liveQuery === null || | |
| 8906 | liveQuery.stopping || | |
| 8907 | liveQuery.nativeThreadId !== providerThread.nativeThreadRef?.nativeId || | |
| 8908 | task === undefined || | |
| 8909 | task.task.threadId !== providerThread.appThreadId || | |
| 8910 | task.task.workflow === undefined || | |
| 8911 | task.task.status !== "running" || | |
| 8912 | liveQuery.subagentsFromEarlierProcesses.has(task) | |
| 8913 | ) { | |
| 8914 | return yield* new ProviderAdapter.ProviderAdapterProtocolError({ | |
| 8915 | driver: CLAUDE_PROVIDER, | |
| 8916 | detail: "No running workflow task belongs to this provider thread", | |
| 8917 | }); | |
| 8918 | } | |
| 8919 | yield* liveQuery.query.stopTask(taskId).pipe( | |
| 8920 | Effect.mapError( | |
| 8921 | (cause) => | |
| 8922 | new ProviderAdapter.ProviderAdapterProtocolError({ | |
| 8923 | driver: CLAUDE_PROVIDER, | |
| 8924 | detail: "Workflow stop request failed", | |
| 8925 | cause, | |
| 8926 | }), | |
| 8927 | ), | |
| 8928 | ); | |
| 8929 | }), | |
| 8269 | 8930 | interruptTurn, |
| 8270 | 8931 | stopSubagent: Effect.fn("ClaudeAdapterV2.stopSubagent")(function* (input) { |
| 8271 | 8932 | const existing = yield* Ref.get(queryContext); |
apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts created+80| ... | ... | @@ -0,0 +1,80 @@ |
| 1 | import * as ClaudeSdk from "@anthropic-ai/claude-agent-sdk"; | |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 3 | import { assert, it } from "@effect/vitest"; | |
| 4 | import { ProviderSessionId, ThreadId } from "@t3tools/contracts"; | |
| 5 | import * as Effect from "effect/Effect"; | |
| 6 | import * as Exit from "effect/Exit"; | |
| 7 | import * as Layer from "effect/Layer"; | |
| 8 | import { vi } from "vite-plus/test"; | |
| 9 | ||
| 10 | import { ProviderEventLoggers } from "@t3tools/provider-core/server/ProviderEventLoggers"; | |
| 11 | import { | |
| 12 | CLAUDE_AGENT_SDK_QUERY_PROTOCOL, | |
| 13 | ClaudeAgentSdkQueryRunner, | |
| 14 | layerQueryRunner, | |
| 15 | } from "./ClaudeAdapterV2.ts"; | |
| 16 | ||
| 17 | vi.mock("@anthropic-ai/claude-agent-sdk", { spy: true }); | |
| 18 | ||
| 19 | it.effect("logs successful task stops for replay and omits failed stops", () => | |
| 20 | Effect.gen(function* () { | |
| 21 | const writes: unknown[] = []; | |
| 22 | const stopTask = vi.fn(async (_taskId: string) => {}); | |
| 23 | const query = vi.spyOn(ClaudeSdk, "query").mockReturnValue({ | |
| 24 | stopTask, | |
| 25 | } as unknown as ClaudeSdk.Query); | |
| 26 | yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore())); | |
| 27 | const threadId = ThreadId.make("thread-stop"); | |
| 28 | const providerSessionId = ProviderSessionId.make("session-stop"); | |
| 29 | yield* Effect.gen(function* () { | |
| 30 | const runner = yield* ClaudeAgentSdkQueryRunner; | |
| 31 | const session = yield* runner.open({ | |
| 32 | threadId, | |
| 33 | providerSessionId, | |
| 34 | options: { | |
| 35 | sessionId: "native-session-stop", | |
| 36 | model: "claude-sonnet-4-6", | |
| 37 | tools: [], | |
| 38 | permissionMode: "default", | |
| 39 | }, | |
| 40 | }); | |
| 41 | writes.length = 0; | |
| 42 | yield* session.stopTask("workflow-task"); | |
| 43 | assert.deepEqual(stopTask.mock.calls, [["workflow-task"]]); | |
| 44 | assert.deepEqual(writes, [ | |
| 45 | { | |
| 46 | provider: "claudeAgent", | |
| 47 | protocol: CLAUDE_AGENT_SDK_QUERY_PROTOCOL, | |
| 48 | kind: "protocol", | |
| 49 | providerSessionId, | |
| 50 | event: { | |
| 51 | direction: "outgoing", | |
| 52 | stage: "decoded", | |
| 53 | payload: { type: "query.stop_task", taskId: "workflow-task" }, | |
| 54 | }, | |
| 55 | }, | |
| 56 | ]); | |
| 57 | stopTask.mockRejectedValueOnce(new Error("Stop failed")); | |
| 58 | assert.isTrue(Exit.isFailure(yield* Effect.exit(session.stopTask("failed-task")))); | |
| 59 | assert.equal(writes.length, 1); | |
| 60 | }).pipe( | |
| 61 | Effect.provide( | |
| 62 | layerQueryRunner.pipe( | |
| 63 | Layer.provide( | |
| 64 | Layer.succeed(ProviderEventLoggers, { | |
| 65 | native: { | |
| 66 | filePath: "/tmp/events.log", | |
| 67 | write: (event) => | |
| 68 | Effect.sync(() => { | |
| 69 | writes.push(event); | |
| 70 | }), | |
| 71 | close: () => Effect.void, | |
| 72 | }, | |
| 73 | canonical: undefined, | |
| 74 | }), | |
| 75 | ), | |
| 76 | ), | |
| 77 | ), | |
| 78 | ); | |
| 79 | }).pipe(Effect.provide(NodeServices.layer)), | |
| 80 | ); |
apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts+40-40| ... | ... | @@ -4976,15 +4976,15 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( |
| 4976 | 4976 | node: artifacts.node, |
| 4977 | 4977 | }); |
| 4978 | 4978 | yield* emitProviderEvent({ |
| 4979 | type: "runtime_request.updated", | |
| 4979 | type: "turn_item.updated", | |
| 4980 | 4980 | driver: CODEX_PROVIDER, |
| 4981 | threadId: artifacts.node.threadId, | |
| 4982 | runtimeRequest: artifacts.request, | |
| 4981 | turnItem: artifacts.turnItem, | |
| 4983 | 4982 | }); |
| 4984 | 4983 | yield* emitProviderEvent({ |
| 4985 | type: "turn_item.updated", | |
| 4984 | type: "runtime_request.updated", | |
| 4986 | 4985 | driver: CODEX_PROVIDER, |
| 4987 | turnItem: artifacts.turnItem, | |
| 4986 | threadId: artifacts.node.threadId, | |
| 4987 | runtimeRequest: artifacts.request, | |
| 4988 | 4988 | }); |
| 4989 | 4989 | yield* Ref.update(finalAnswerItemIdsByTurn, (current) => { |
| 4990 | 4990 | const ids = current.get(payload.turnId); |
| ... | ... | @@ -5083,15 +5083,15 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( |
| 5083 | 5083 | node: artifacts.node, |
| 5084 | 5084 | }); |
| 5085 | 5085 | yield* emitProviderEvent({ |
| 5086 | type: "runtime_request.updated", | |
| 5086 | type: "turn_item.updated", | |
| 5087 | 5087 | driver: CODEX_PROVIDER, |
| 5088 | threadId: artifacts.node.threadId, | |
| 5089 | runtimeRequest: artifacts.request, | |
| 5088 | turnItem: artifacts.turnItem, | |
| 5090 | 5089 | }); |
| 5091 | 5090 | yield* emitProviderEvent({ |
| 5092 | type: "turn_item.updated", | |
| 5091 | type: "runtime_request.updated", | |
| 5093 | 5092 | driver: CODEX_PROVIDER, |
| 5094 | turnItem: artifacts.turnItem, | |
| 5093 | threadId: artifacts.node.threadId, | |
| 5094 | runtimeRequest: artifacts.request, | |
| 5095 | 5095 | }); |
| 5096 | 5096 | |
| 5097 | 5097 | const resolved = yield* Deferred.await(decision).pipe( |
| ... | ... | @@ -5143,15 +5143,15 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( |
| 5143 | 5143 | node: artifacts.node, |
| 5144 | 5144 | }); |
| 5145 | 5145 | yield* emitProviderEvent({ |
| 5146 | type: "runtime_request.updated", | |
| 5146 | type: "turn_item.updated", | |
| 5147 | 5147 | driver: CODEX_PROVIDER, |
| 5148 | threadId: artifacts.node.threadId, | |
| 5149 | runtimeRequest: artifacts.request, | |
| 5148 | turnItem: artifacts.turnItem, | |
| 5150 | 5149 | }); |
| 5151 | 5150 | yield* emitProviderEvent({ |
| 5152 | type: "turn_item.updated", | |
| 5151 | type: "runtime_request.updated", | |
| 5153 | 5152 | driver: CODEX_PROVIDER, |
| 5154 | turnItem: artifacts.turnItem, | |
| 5153 | threadId: artifacts.node.threadId, | |
| 5154 | runtimeRequest: artifacts.request, | |
| 5155 | 5155 | }); |
| 5156 | 5156 | |
| 5157 | 5157 | const resolved = yield* Deferred.await(decision).pipe( |
| ... | ... | @@ -5204,15 +5204,15 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( |
| 5204 | 5204 | node: artifacts.node, |
| 5205 | 5205 | }); |
| 5206 | 5206 | yield* emitProviderEvent({ |
| 5207 | type: "runtime_request.updated", | |
| 5207 | type: "turn_item.updated", | |
| 5208 | 5208 | driver: CODEX_PROVIDER, |
| 5209 | threadId: artifacts.node.threadId, | |
| 5210 | runtimeRequest: artifacts.request, | |
| 5209 | turnItem: artifacts.turnItem, | |
| 5211 | 5210 | }); |
| 5212 | 5211 | yield* emitProviderEvent({ |
| 5213 | type: "turn_item.updated", | |
| 5212 | type: "runtime_request.updated", | |
| 5214 | 5213 | driver: CODEX_PROVIDER, |
| 5215 | turnItem: artifacts.turnItem, | |
| 5214 | threadId: artifacts.node.threadId, | |
| 5215 | runtimeRequest: artifacts.request, | |
| 5216 | 5216 | }); |
| 5217 | 5217 | |
| 5218 | 5218 | const resolved = yield* Deferred.await(decision).pipe( |
| ... | ... | @@ -5289,15 +5289,15 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( |
| 5289 | 5289 | node: artifacts.node, |
| 5290 | 5290 | }); |
| 5291 | 5291 | yield* emitProviderEvent({ |
| 5292 | type: "runtime_request.updated", | |
| 5292 | type: "turn_item.updated", | |
| 5293 | 5293 | driver: CODEX_PROVIDER, |
| 5294 | threadId: artifacts.node.threadId, | |
| 5295 | runtimeRequest: artifacts.request, | |
| 5294 | turnItem: artifacts.turnItem, | |
| 5296 | 5295 | }); |
| 5297 | 5296 | yield* emitProviderEvent({ |
| 5298 | type: "turn_item.updated", | |
| 5297 | type: "runtime_request.updated", | |
| 5299 | 5298 | driver: CODEX_PROVIDER, |
| 5300 | turnItem: artifacts.turnItem, | |
| 5299 | threadId: artifacts.node.threadId, | |
| 5300 | runtimeRequest: artifacts.request, | |
| 5301 | 5301 | }); |
| 5302 | 5302 | |
| 5303 | 5303 | const resolved = yield* Deferred.await(decision).pipe( |
| ... | ... | @@ -5348,15 +5348,15 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( |
| 5348 | 5348 | node: artifacts.node, |
| 5349 | 5349 | }); |
| 5350 | 5350 | yield* emitProviderEvent({ |
| 5351 | type: "runtime_request.updated", | |
| 5351 | type: "turn_item.updated", | |
| 5352 | 5352 | driver: CODEX_PROVIDER, |
| 5353 | threadId: artifacts.node.threadId, | |
| 5354 | runtimeRequest: artifacts.request, | |
| 5353 | turnItem: artifacts.turnItem, | |
| 5355 | 5354 | }); |
| 5356 | 5355 | yield* emitProviderEvent({ |
| 5357 | type: "turn_item.updated", | |
| 5356 | type: "runtime_request.updated", | |
| 5358 | 5357 | driver: CODEX_PROVIDER, |
| 5359 | turnItem: artifacts.turnItem, | |
| 5358 | threadId: artifacts.node.threadId, | |
| 5359 | runtimeRequest: artifacts.request, | |
| 5360 | 5360 | }); |
| 5361 | 5361 | |
| 5362 | 5362 | const resolved = yield* Deferred.await(decision).pipe( |
| ... | ... | @@ -5408,15 +5408,15 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( |
| 5408 | 5408 | node: artifacts.node, |
| 5409 | 5409 | }); |
| 5410 | 5410 | yield* emitProviderEvent({ |
| 5411 | type: "runtime_request.updated", | |
| 5411 | type: "turn_item.updated", | |
| 5412 | 5412 | driver: CODEX_PROVIDER, |
| 5413 | threadId: artifacts.node.threadId, | |
| 5414 | runtimeRequest: artifacts.request, | |
| 5413 | turnItem: artifacts.turnItem, | |
| 5415 | 5414 | }); |
| 5416 | 5415 | yield* emitProviderEvent({ |
| 5417 | type: "turn_item.updated", | |
| 5416 | type: "runtime_request.updated", | |
| 5418 | 5417 | driver: CODEX_PROVIDER, |
| 5419 | turnItem: artifacts.turnItem, | |
| 5418 | threadId: artifacts.node.threadId, | |
| 5419 | runtimeRequest: artifacts.request, | |
| 5420 | 5420 | }); |
| 5421 | 5421 | |
| 5422 | 5422 | const resolved = yield* Deferred.await(decision).pipe( |
| ... | ... | @@ -5466,15 +5466,15 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( |
| 5466 | 5466 | node: artifacts.node, |
| 5467 | 5467 | }); |
| 5468 | 5468 | yield* emitProviderEvent({ |
| 5469 | type: "runtime_request.updated", | |
| 5469 | type: "turn_item.updated", | |
| 5470 | 5470 | driver: CODEX_PROVIDER, |
| 5471 | threadId: artifacts.node.threadId, | |
| 5472 | runtimeRequest: artifacts.request, | |
| 5471 | turnItem: artifacts.turnItem, | |
| 5473 | 5472 | }); |
| 5474 | 5473 | yield* emitProviderEvent({ |
| 5475 | type: "turn_item.updated", | |
| 5474 | type: "runtime_request.updated", | |
| 5476 | 5475 | driver: CODEX_PROVIDER, |
| 5477 | turnItem: artifacts.turnItem, | |
| 5476 | threadId: artifacts.node.threadId, | |
| 5477 | runtimeRequest: artifacts.request, | |
| 5478 | 5478 | }); |
| 5479 | 5479 | |
| 5480 | 5480 | const resolved = yield* Deferred.await(answers).pipe( |
apps/server/src/orchestration-v2/Adapters/claudeWorkflowProgress.test.ts created+318| ... | ... | @@ -0,0 +1,318 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { | |
| 4 | mergeClaudeWorkflowProgress, | |
| 5 | parseClaudeWorkflowRunHandles, | |
| 6 | } from "./claudeWorkflowProgress.ts"; | |
| 7 | ||
| 8 | const startFrame = { | |
| 9 | type: "system", | |
| 10 | subtype: "task_started", | |
| 11 | task_id: "w1", | |
| 12 | task_type: "local_workflow", | |
| 13 | workflow_name: "probe-wf", | |
| 14 | }; | |
| 15 | ||
| 16 | const snapshotFrame = ( | |
| 17 | entries: ReadonlyArray<Record<string, unknown>>, | |
| 18 | usage?: Record<string, number>, | |
| 19 | ) => ({ | |
| 20 | type: "system", | |
| 21 | subtype: "task_progress", | |
| 22 | task_id: "w1", | |
| 23 | description: "Alpha: alpha:one", | |
| 24 | workflow_progress: entries, | |
| 25 | ...(usage === undefined ? {} : { usage }), | |
| 26 | }); | |
| 27 | ||
| 28 | const phase = (index: number, title: string) => ({ type: "workflow_phase", index, title }); | |
| 29 | ||
| 30 | const agent = (overrides: Record<string, unknown>) => ({ | |
| 31 | type: "workflow_agent", | |
| 32 | index: 1, | |
| 33 | label: "alpha:one", | |
| 34 | state: "start", | |
| 35 | phaseIndex: 1, | |
| 36 | phaseTitle: "Alpha", | |
| 37 | ...overrides, | |
| 38 | }); | |
| 39 | ||
| 40 | describe("mergeClaudeWorkflowProgress", () => { | |
| 41 | it("marks a coordinator as a workflow before any member exists", () => { | |
| 42 | const workflow = mergeClaudeWorkflowProgress({ | |
| 43 | previous: undefined, | |
| 44 | name: "probe-wf", | |
| 45 | message: startFrame, | |
| 46 | }); | |
| 47 | expect(workflow).toEqual({ name: "probe-wf", phases: [], agents: [] }); | |
| 48 | }); | |
| 49 | ||
| 50 | it("leaves an ordinary subagent's frames alone", () => { | |
| 51 | expect( | |
| 52 | mergeClaudeWorkflowProgress({ | |
| 53 | previous: undefined, | |
| 54 | message: { | |
| 55 | type: "system", | |
| 56 | subtype: "task_progress", | |
| 57 | task_id: "t1", | |
| 58 | description: "reading files", | |
| 59 | usage: { total_tokens: 500, tool_uses: 2, duration_ms: 90 }, | |
| 60 | }, | |
| 61 | }), | |
| 62 | ).toBeUndefined(); | |
| 63 | }); | |
| 64 | ||
| 65 | it("maps provider agent states and per-member metrics", () => { | |
| 66 | const workflow = mergeClaudeWorkflowProgress({ | |
| 67 | previous: undefined, | |
| 68 | message: snapshotFrame([ | |
| 69 | phase(1, "Alpha"), | |
| 70 | agent({ | |
| 71 | state: "done", | |
| 72 | agentId: "a1", | |
| 73 | model: "claude-opus-5[1m]", | |
| 74 | attempt: 2, | |
| 75 | tokens: 71_141, | |
| 76 | toolCalls: 3, | |
| 77 | durationMs: 1277, | |
| 78 | startedAt: 1_700_000_000_000, | |
| 79 | queuedAt: 1_699_999_999_000, | |
| 80 | promptPreview: "Reply with exactly: A1", | |
| 81 | resultPreview: "A1", | |
| 82 | }), | |
| 83 | agent({ index: 2, label: "alpha:two", state: "queued" }), | |
| 84 | ]), | |
| 85 | }); | |
| 86 | expect(workflow?.phases).toEqual([{ index: 1, title: "Alpha" }]); | |
| 87 | expect(workflow?.agents[0]).toEqual({ | |
| 88 | index: 1, | |
| 89 | label: "alpha:one", | |
| 90 | agentId: "a1", | |
| 91 | state: "completed", | |
| 92 | phaseIndex: 1, | |
| 93 | phaseTitle: "Alpha", | |
| 94 | model: "claude-opus-5[1m]", | |
| 95 | attempt: 2, | |
| 96 | totalTokens: 71_141, | |
| 97 | toolCalls: 3, | |
| 98 | durationMs: 1277, | |
| 99 | queuedAt: 1_699_999_999_000, | |
| 100 | startedAt: 1_700_000_000_000, | |
| 101 | prompt: "Reply with exactly: A1", | |
| 102 | result: "A1", | |
| 103 | }); | |
| 104 | expect(workflow?.agents[1]?.state).toBe("queued"); | |
| 105 | }); | |
| 106 | ||
| 107 | it.each(["something-new", "toString", "constructor", "__proto__"])( | |
| 108 | "treats unrecognised state %s as still running rather than settled", | |
| 109 | (state) => { | |
| 110 | const workflow = mergeClaudeWorkflowProgress({ | |
| 111 | previous: undefined, | |
| 112 | message: snapshotFrame([agent({ state })]), | |
| 113 | }); | |
| 114 | expect(workflow?.agents[0]?.state).toBe("running"); | |
| 115 | }, | |
| 116 | ); | |
| 117 | ||
| 118 | it("keeps the roster through a usage-only frame", () => { | |
| 119 | const first = mergeClaudeWorkflowProgress({ | |
| 120 | previous: undefined, | |
| 121 | name: "probe-wf", | |
| 122 | message: snapshotFrame([phase(1, "Alpha"), agent({})], { | |
| 123 | total_tokens: 100, | |
| 124 | tool_uses: 1, | |
| 125 | duration_ms: 40, | |
| 126 | }), | |
| 127 | }); | |
| 128 | const second = mergeClaudeWorkflowProgress({ | |
| 129 | previous: first, | |
| 130 | message: snapshotFrame([], { total_tokens: 250, tool_uses: 2, duration_ms: 900 }), | |
| 131 | }); | |
| 132 | expect(second?.agents).toHaveLength(1); | |
| 133 | expect(second?.phases).toHaveLength(1); | |
| 134 | expect(second?.name).toBe("probe-wf"); | |
| 135 | expect(second?.totalTokens).toBe(250); | |
| 136 | }); | |
| 137 | ||
| 138 | it("harvests run handles from the launch acknowledgement", () => { | |
| 139 | const workflow = mergeClaudeWorkflowProgress({ | |
| 140 | previous: undefined, | |
| 141 | runHandles: parseClaudeWorkflowRunHandles({ | |
| 142 | status: "async_launched", | |
| 143 | taskType: "local_workflow", | |
| 144 | runId: "wf_abc", | |
| 145 | transcriptDir: "/root/.claude/projects/p/s/subagents/workflows/wf_abc", | |
| 146 | scriptPath: "/root/.claude/projects/p/s/workflows/scripts/x.js", | |
| 147 | }), | |
| 148 | message: startFrame, | |
| 149 | }); | |
| 150 | expect(workflow?.runHandles?.transcriptDir).toBe( | |
| 151 | "/root/.claude/projects/p/s/subagents/workflows/wf_abc", | |
| 152 | ); | |
| 153 | expect(workflow?.runHandles?.runId).toBe("wf_abc"); | |
| 154 | }); | |
| 155 | ||
| 156 | it("ignores a non-workflow tool acknowledgement", () => { | |
| 157 | expect( | |
| 158 | parseClaudeWorkflowRunHandles({ status: "async_launched", taskType: "local_agent" }), | |
| 159 | ).toBeUndefined(); | |
| 160 | }); | |
| 161 | ||
| 162 | it("keeps a phase the plan reached at runtime even if a later frame omits it", () => { | |
| 163 | const first = mergeClaudeWorkflowProgress({ | |
| 164 | previous: undefined, | |
| 165 | message: snapshotFrame([phase(1, "Alpha"), phase(2, "Beta"), phase(3, "Gamma")]), | |
| 166 | }); | |
| 167 | const second = mergeClaudeWorkflowProgress({ | |
| 168 | previous: first, | |
| 169 | message: snapshotFrame([phase(1, "Alpha")]), | |
| 170 | }); | |
| 171 | expect(second?.phases.map((entry) => entry.title)).toEqual(["Alpha", "Beta", "Gamma"]); | |
| 172 | }); | |
| 173 | ||
| 174 | it("keeps other members and phase links when a sparse frame starts a new attempt", () => { | |
| 175 | const first = mergeClaudeWorkflowProgress({ | |
| 176 | previous: undefined, | |
| 177 | message: snapshotFrame([ | |
| 178 | phase(1, "Alpha"), | |
| 179 | agent({ | |
| 180 | state: "error", | |
| 181 | attempt: 1, | |
| 182 | promptPreview: "First prompt", | |
| 183 | resultPreview: "First result", | |
| 184 | tokens: 100, | |
| 185 | toolCalls: 2, | |
| 186 | durationMs: 50, | |
| 187 | queuedAt: 1000, | |
| 188 | startedAt: 1010, | |
| 189 | }), | |
| 190 | agent({ index: 2, state: "done" }), | |
| 191 | ]), | |
| 192 | }); | |
| 193 | const second = mergeClaudeWorkflowProgress({ | |
| 194 | previous: first, | |
| 195 | message: snapshotFrame([ | |
| 196 | { type: "workflow_agent", index: 1, label: "alpha:one", state: "start", attempt: 2 }, | |
| 197 | ]), | |
| 198 | }); | |
| 199 | expect(second?.agents).toEqual([ | |
| 200 | { | |
| 201 | index: 1, | |
| 202 | label: "alpha:one", | |
| 203 | state: "running", | |
| 204 | attempt: 2, | |
| 205 | phaseIndex: 1, | |
| 206 | phaseTitle: "Alpha", | |
| 207 | }, | |
| 208 | first?.agents[1], | |
| 209 | ]); | |
| 210 | expect(second?.phases).toEqual(first?.phases); | |
| 211 | }); | |
| 212 | ||
| 213 | it("ignores an older attempt after a retry completes", () => { | |
| 214 | const first = mergeClaudeWorkflowProgress({ | |
| 215 | previous: undefined, | |
| 216 | message: snapshotFrame([agent({ state: "done", attempt: 2, resultPreview: "New result" })]), | |
| 217 | }); | |
| 218 | const second = mergeClaudeWorkflowProgress({ | |
| 219 | previous: first, | |
| 220 | message: snapshotFrame([agent({ state: "error", attempt: 1, resultPreview: "Old result" })]), | |
| 221 | }); | |
| 222 | expect(second?.agents).toEqual(first?.agents); | |
| 223 | }); | |
| 224 | ||
| 225 | it.each([ | |
| 226 | ["done", "start"], | |
| 227 | ["done", "queued"], | |
| 228 | ["error", "start"], | |
| 229 | ["error", "queued"], | |
| 230 | ["start", "queued"], | |
| 231 | ])("keeps member state when the same attempt regresses from %s to %s", (state, staleState) => { | |
| 232 | const first = mergeClaudeWorkflowProgress({ | |
| 233 | previous: undefined, | |
| 234 | message: snapshotFrame([agent({ state, attempt: 2, resultPreview: "Current result" })]), | |
| 235 | }); | |
| 236 | const second = mergeClaudeWorkflowProgress({ | |
| 237 | previous: first, | |
| 238 | message: snapshotFrame([ | |
| 239 | agent({ state: staleState, attempt: 2, resultPreview: "Old result" }), | |
| 240 | ]), | |
| 241 | }); | |
| 242 | expect(second?.agents).toEqual(first?.agents); | |
| 243 | }); | |
| 244 | ||
| 245 | it("retains member fields omitted by a later frame in the same attempt", () => { | |
| 246 | const first = mergeClaudeWorkflowProgress({ | |
| 247 | previous: undefined, | |
| 248 | message: snapshotFrame([agent({ attempt: 2, promptPreview: "Current prompt", tokens: 100 })]), | |
| 249 | }); | |
| 250 | const second = mergeClaudeWorkflowProgress({ | |
| 251 | previous: first, | |
| 252 | message: snapshotFrame([agent({ state: "done", resultPreview: "Current result" })]), | |
| 253 | }); | |
| 254 | expect(second?.agents[0]).toMatchObject({ | |
| 255 | state: "completed", | |
| 256 | attempt: 2, | |
| 257 | prompt: "Current prompt", | |
| 258 | result: "Current result", | |
| 259 | totalTokens: 100, | |
| 260 | }); | |
| 261 | }); | |
| 262 | ||
| 263 | it("keeps existing run handles when a later acknowledgement supplies only a script path", () => { | |
| 264 | const first = mergeClaudeWorkflowProgress({ | |
| 265 | previous: undefined, | |
| 266 | runHandles: { runId: "wf_abc", transcriptDir: "/workflows/wf_abc" }, | |
| 267 | message: startFrame, | |
| 268 | }); | |
| 269 | const second = mergeClaudeWorkflowProgress({ | |
| 270 | previous: first, | |
| 271 | runHandles: { scriptPath: "/scripts/workflow.js" }, | |
| 272 | message: startFrame, | |
| 273 | }); | |
| 274 | expect(second?.runHandles).toEqual({ | |
| 275 | runId: "wf_abc", | |
| 276 | transcriptDir: "/workflows/wf_abc", | |
| 277 | scriptPath: "/scripts/workflow.js", | |
| 278 | }); | |
| 279 | }); | |
| 280 | ||
| 281 | it("preserves prompt and result whitespace", () => { | |
| 282 | const workflow = mergeClaudeWorkflowProgress({ | |
| 283 | previous: undefined, | |
| 284 | message: snapshotFrame([ | |
| 285 | agent({ promptPreview: " indented\n", resultPreview: "\n answer\n" }), | |
| 286 | ]), | |
| 287 | }); | |
| 288 | expect(workflow?.agents[0]?.prompt).toBe(" indented\n"); | |
| 289 | expect(workflow?.agents[0]?.result).toBe("\n answer\n"); | |
| 290 | }); | |
| 291 | ||
| 292 | it("drops timestamps outside the DateTime range", () => { | |
| 293 | const workflow = mergeClaudeWorkflowProgress({ | |
| 294 | previous: undefined, | |
| 295 | message: snapshotFrame([ | |
| 296 | agent({ startedAt: 8_640_000_000_000_001, queuedAt: Number.MAX_SAFE_INTEGER }), | |
| 297 | agent({ index: 2, startedAt: 8_640_000_000_000_000, queuedAt: 0 }), | |
| 298 | ]), | |
| 299 | }); | |
| 300 | expect(workflow?.agents[0]).not.toHaveProperty("startedAt"); | |
| 301 | expect(workflow?.agents[0]).not.toHaveProperty("queuedAt"); | |
| 302 | expect(workflow?.agents[1]?.startedAt).toBe(8_640_000_000_000_000); | |
| 303 | expect(workflow?.agents[1]?.queuedAt).toBe(0); | |
| 304 | }); | |
| 305 | ||
| 306 | it("drops malformed entries instead of failing the frame", () => { | |
| 307 | const workflow = mergeClaudeWorkflowProgress({ | |
| 308 | previous: undefined, | |
| 309 | message: snapshotFrame([ | |
| 310 | { type: "workflow_phase", title: "No index" }, | |
| 311 | { type: "workflow_agent", index: 4 }, | |
| 312 | { type: "something_else", index: 1, title: "Ignored" }, | |
| 313 | phase(1, "Alpha"), | |
| 314 | ]), | |
| 315 | }); | |
| 316 | expect(workflow).toEqual({ phases: [{ index: 1, title: "Alpha" }], agents: [] }); | |
| 317 | }); | |
| 318 | }); |
apps/server/src/orchestration-v2/Adapters/claudeWorkflowProgress.ts created+192| ... | ... | @@ -0,0 +1,192 @@ |
| 1 | import type { | |
| 2 | OrchestrationV2SubagentWorkflow, | |
| 3 | OrchestrationV2WorkflowAgent, | |
| 4 | OrchestrationV2WorkflowPhase, | |
| 5 | OrchestrationV2WorkflowRunHandles, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | ||
| 8 | import { field, text } from "../unknownField.ts"; | |
| 9 | ||
| 10 | export const CLAUDE_WORKFLOW_TASK_TYPE = "local_workflow"; | |
| 11 | ||
| 12 | export function hasClaudeWorkflowSnapshot( | |
| 13 | message: unknown, | |
| 14 | ): message is { readonly workflow_progress: ReadonlyArray<unknown> } { | |
| 15 | const entries = field(message, "workflow_progress"); | |
| 16 | return Array.isArray(entries) && entries.length > 0; | |
| 17 | } | |
| 18 | ||
| 19 | const AGENT_STATES: Record<string, OrchestrationV2WorkflowAgent["state"] | undefined> = { | |
| 20 | queued: "queued", | |
| 21 | start: "running", | |
| 22 | done: "completed", | |
| 23 | error: "failed", | |
| 24 | }; | |
| 25 | ||
| 26 | function count(value: unknown): number | undefined { | |
| 27 | return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined; | |
| 28 | } | |
| 29 | ||
| 30 | function timestamp(value: unknown): number | undefined { | |
| 31 | const milliseconds = count(value); | |
| 32 | return milliseconds !== undefined && milliseconds <= 8_640_000_000_000_000 | |
| 33 | ? milliseconds | |
| 34 | : undefined; | |
| 35 | } | |
| 36 | ||
| 37 | function preview(value: unknown): string | undefined { | |
| 38 | return typeof value === "string" && value.length > 0 ? value : undefined; | |
| 39 | } | |
| 40 | ||
| 41 | function optional<T>(key: string, value: T | undefined): Record<string, T> { | |
| 42 | return value === undefined ? {} : ({ [key]: value } as Record<string, T>); | |
| 43 | } | |
| 44 | ||
| 45 | function parsePhase(entry: unknown): OrchestrationV2WorkflowPhase | null { | |
| 46 | const index = count(field(entry, "index")); | |
| 47 | const title = text(field(entry, "title")); | |
| 48 | return index === undefined || title === undefined ? null : { index, title }; | |
| 49 | } | |
| 50 | ||
| 51 | function parseAgent(entry: unknown): OrchestrationV2WorkflowAgent | null { | |
| 52 | const index = count(field(entry, "index")); | |
| 53 | const label = text(field(entry, "label")); | |
| 54 | if (index === undefined || label === undefined) return null; | |
| 55 | // An unknown state is in-flight, not settled: guessing "completed" would | |
| 56 | // freeze a live row, while guessing "running" self-corrects on the next frame. | |
| 57 | const reportedState = text(field(entry, "state")) ?? ""; | |
| 58 | const state = Object.hasOwn(AGENT_STATES, reportedState) | |
| 59 | ? (AGENT_STATES[reportedState] ?? "running") | |
| 60 | : "running"; | |
| 61 | return { | |
| 62 | index, | |
| 63 | label, | |
| 64 | state, | |
| 65 | ...optional("agentId", text(field(entry, "agentId"))), | |
| 66 | ...optional("phaseIndex", count(field(entry, "phaseIndex"))), | |
| 67 | ...optional("phaseTitle", text(field(entry, "phaseTitle"))), | |
| 68 | ...optional("model", text(field(entry, "model"))), | |
| 69 | ...optional("attempt", count(field(entry, "attempt"))), | |
| 70 | ...optional("totalTokens", count(field(entry, "tokens"))), | |
| 71 | ...optional("toolCalls", count(field(entry, "toolCalls"))), | |
| 72 | ...optional("durationMs", count(field(entry, "durationMs"))), | |
| 73 | ...optional("queuedAt", timestamp(field(entry, "queuedAt"))), | |
| 74 | ...optional("startedAt", timestamp(field(entry, "startedAt"))), | |
| 75 | ...optional("prompt", preview(field(entry, "promptPreview"))), | |
| 76 | ...optional("result", preview(field(entry, "resultPreview"))), | |
| 77 | }; | |
| 78 | } | |
| 79 | ||
| 80 | /** | |
| 81 | * Reads the Workflow tool's structured result. A background launch answers the | |
| 82 | * model with an acknowledgement rather than the run's output, and that | |
| 83 | * acknowledgement is the only place the run's filesystem handles appear — | |
| 84 | * nothing later on the event stream repeats them. | |
| 85 | */ | |
| 86 | export function parseClaudeWorkflowRunHandles( | |
| 87 | toolUseResult: unknown, | |
| 88 | ): OrchestrationV2WorkflowRunHandles | undefined { | |
| 89 | if (text(field(toolUseResult, "taskType")) !== CLAUDE_WORKFLOW_TASK_TYPE) return undefined; | |
| 90 | const handles = { | |
| 91 | ...optional("runId", text(field(toolUseResult, "runId"))), | |
| 92 | ...optional("transcriptDir", text(field(toolUseResult, "transcriptDir"))), | |
| 93 | ...optional("scriptPath", text(field(toolUseResult, "scriptPath"))), | |
| 94 | }; | |
| 95 | return Object.keys(handles).length === 0 ? undefined : handles; | |
| 96 | } | |
| 97 | ||
| 98 | function parseClaudeWorkflowProgress(message: unknown): { | |
| 99 | readonly phases: ReadonlyArray<OrchestrationV2WorkflowPhase>; | |
| 100 | readonly agents: ReadonlyArray<OrchestrationV2WorkflowAgent>; | |
| 101 | } | null { | |
| 102 | if (!hasClaudeWorkflowSnapshot(message)) return null; | |
| 103 | const phases: OrchestrationV2WorkflowPhase[] = []; | |
| 104 | const agents: OrchestrationV2WorkflowAgent[] = []; | |
| 105 | for (const entry of message.workflow_progress) { | |
| 106 | const type = text(field(entry, "type")); | |
| 107 | if (type === "workflow_phase") { | |
| 108 | const phase = parsePhase(entry); | |
| 109 | if (phase !== null) phases.push(phase); | |
| 110 | } else if (type === "workflow_agent") { | |
| 111 | const agent = parseAgent(entry); | |
| 112 | if (agent !== null) agents.push(agent); | |
| 113 | } | |
| 114 | } | |
| 115 | return phases.length === 0 && agents.length === 0 ? null : { phases, agents }; | |
| 116 | } | |
| 117 | ||
| 118 | /** | |
| 119 | * Folds one frame into the coordinator's workflow state. A frame without a | |
| 120 | * snapshot still carries usage, so totals keep climbing between snapshots. | |
| 121 | * A phase only ever joins the plan — the provider re-sends the full plan on | |
| 122 | * every snapshot, but a late frame that omits an already-seen phase must not | |
| 123 | * shorten the arc the user is looking at. | |
| 124 | * | |
| 125 | * Returns undefined when nothing identifies the task as a workflow. Progress | |
| 126 | * frames do not repeat `task_type`, so this is the single place that decides | |
| 127 | * whether a task is a coordinator; without it every ordinary subagent's usage | |
| 128 | * frame would mint an empty workflow and render as a coordinator. | |
| 129 | */ | |
| 130 | export function mergeClaudeWorkflowProgress(input: { | |
| 131 | readonly previous: OrchestrationV2SubagentWorkflow | undefined; | |
| 132 | readonly name?: string | undefined; | |
| 133 | readonly runHandles?: OrchestrationV2WorkflowRunHandles | undefined; | |
| 134 | readonly message: unknown; | |
| 135 | }): OrchestrationV2SubagentWorkflow | undefined { | |
| 136 | const { previous } = input; | |
| 137 | const snapshot = parseClaudeWorkflowProgress(input.message); | |
| 138 | if ( | |
| 139 | previous === undefined && | |
| 140 | input.name === undefined && | |
| 141 | input.runHandles === undefined && | |
| 142 | snapshot === null | |
| 143 | ) { | |
| 144 | return undefined; | |
| 145 | } | |
| 146 | const reported = field(input.message, "usage"); | |
| 147 | const phasesByIndex = new Map( | |
| 148 | (previous?.phases ?? []).map((phase) => [phase.index, phase] as const), | |
| 149 | ); | |
| 150 | for (const phase of snapshot?.phases ?? []) phasesByIndex.set(phase.index, phase); | |
| 151 | const agentsByIndex = new Map( | |
| 152 | (previous?.agents ?? []).map((agent) => [agent.index, agent] as const), | |
| 153 | ); | |
| 154 | for (const agent of snapshot?.agents ?? []) { | |
| 155 | const prior = agentsByIndex.get(agent.index); | |
| 156 | const attempt = agent.attempt ?? prior?.attempt ?? 1; | |
| 157 | if (prior !== undefined && attempt < (prior.attempt ?? 1)) continue; | |
| 158 | const restarted = prior !== undefined && attempt > (prior.attempt ?? 1); | |
| 159 | const regressed = | |
| 160 | !restarted && | |
| 161 | (((prior?.state === "completed" || prior?.state === "failed") && | |
| 162 | agent.state !== prior.state) || | |
| 163 | (prior?.state === "running" && agent.state === "queued")); | |
| 164 | agentsByIndex.set( | |
| 165 | agent.index, | |
| 166 | regressed | |
| 167 | ? { ...agent, ...prior } | |
| 168 | : { | |
| 169 | ...(restarted | |
| 170 | ? { | |
| 171 | ...optional("phaseIndex", prior.phaseIndex), | |
| 172 | ...optional("phaseTitle", prior.phaseTitle), | |
| 173 | } | |
| 174 | : prior), | |
| 175 | ...agent, | |
| 176 | }, | |
| 177 | ); | |
| 178 | } | |
| 179 | const runHandles = | |
| 180 | input.runHandles === undefined | |
| 181 | ? previous?.runHandles | |
| 182 | : { ...previous?.runHandles, ...input.runHandles }; | |
| 183 | return { | |
| 184 | ...optional("name", input.name ?? previous?.name), | |
| 185 | ...optional("runHandles", runHandles), | |
| 186 | phases: Array.from(phasesByIndex.values()).sort((left, right) => left.index - right.index), | |
| 187 | agents: Array.from(agentsByIndex.values()).sort((left, right) => left.index - right.index), | |
| 188 | ...optional("totalTokens", count(field(reported, "total_tokens")) ?? previous?.totalTokens), | |
| 189 | ...optional("toolCalls", count(field(reported, "tool_uses")) ?? previous?.toolCalls), | |
| 190 | ...optional("durationMs", count(field(reported, "duration_ms")) ?? previous?.durationMs), | |
| 191 | }; | |
| 192 | } |
apps/server/src/orchestration-v2/BackgroundWorkStop.integration.test.ts+64-22| ... | ... | @@ -42,10 +42,12 @@ const stopEarlierBackgroundWork = ({ |
| 42 | 42 | stopWithQueue, |
| 43 | 43 | olderStart = false, |
| 44 | 44 | stalledRun, |
| 45 | workflowMember = false, | |
| 45 | 46 | }: { |
| 46 | 47 | readonly failedStart?: boolean; |
| 47 | 48 | readonly stopWithQueue?: "thread.stop" | "run.interrupt"; |
| 48 | 49 | readonly olderStart?: boolean; |
| 50 | readonly workflowMember?: boolean; | |
| 49 | 51 | readonly stalledRun?: |
| 50 | 52 | | "missing-session" |
| 51 | 53 | | "missing-session-terminal" |
| ... | ... | @@ -488,23 +490,27 @@ const stopEarlierBackgroundWork = ({ |
| 488 | 490 | events: [ |
| 489 | 491 | // A subagent the reviewer started, which the provider records on the |
| 490 | 492 | // reviewer's own thread. |
| 491 | { | |
| 492 | id: EventId.make("nested-subagent-item"), | |
| 493 | type: "turn-item.updated", | |
| 494 | threadId: reviewerThreadId, | |
| 495 | occurredAt: now, | |
| 496 | payload: { | |
| 497 | ...nested, | |
| 498 | id: nestedItemId, | |
| 499 | nodeId: nestedSubagentId, | |
| 500 | providerTurnId: null, | |
| 501 | nativeItemRef: null, | |
| 502 | parentItemId: null, | |
| 503 | ordinal: 1, | |
| 504 | type: "subagent", | |
| 505 | subagentId: nestedSubagentId, | |
| 506 | }, | |
| 507 | }, | |
| 493 | ...(workflowMember | |
| 494 | ? [] | |
| 495 | : [ | |
| 496 | { | |
| 497 | id: EventId.make("nested-subagent-item"), | |
| 498 | type: "turn-item.updated", | |
| 499 | threadId: reviewerThreadId, | |
| 500 | occurredAt: now, | |
| 501 | payload: { | |
| 502 | ...nested, | |
| 503 | id: nestedItemId, | |
| 504 | nodeId: nestedSubagentId, | |
| 505 | providerTurnId: null, | |
| 506 | nativeItemRef: null, | |
| 507 | parentItemId: null, | |
| 508 | ordinal: 1, | |
| 509 | type: "subagent", | |
| 510 | subagentId: nestedSubagentId, | |
| 511 | }, | |
| 512 | } as const, | |
| 513 | ]), | |
| 508 | 514 | { |
| 509 | 515 | id: EventId.make("nested-subagent"), |
| 510 | 516 | type: "subagent.updated", |
| ... | ... | @@ -515,12 +521,36 @@ const stopEarlierBackgroundWork = ({ |
| 515 | 521 | id: nestedSubagentId, |
| 516 | 522 | parentNodeId: reviewerRootId, |
| 517 | 523 | createdBy: "agent", |
| 518 | nativeTaskRef: null, | |
| 524 | nativeTaskRef: workflowMember | |
| 525 | ? { driver, nativeId: "workflow:member", strength: "strong" } | |
| 526 | : null, | |
| 519 | 527 | model: null, |
| 520 | 528 | }, |
| 521 | 529 | }, |
| 522 | 530 | ], |
| 523 | 531 | }); |
| 532 | if (workflowMember) { | |
| 533 | // Workflow members have execution nodes and child roots, but no subagent turn item. | |
| 534 | const memberRoot = (yield* orchestrator.getThreadProjection(tester.threadId)).nodes[0]!; | |
| 535 | yield* sink.write({ | |
| 536 | events: [ | |
| 537 | { | |
| 538 | id: EventId.make("workflow-member-node"), | |
| 539 | type: "node.updated", | |
| 540 | threadId: reviewerThreadId, | |
| 541 | occurredAt: now, | |
| 542 | payload: { | |
| 543 | ...memberRoot, | |
| 544 | id: nestedSubagentId, | |
| 545 | threadId: reviewerThreadId, | |
| 546 | parentNodeId: reviewerRootId, | |
| 547 | rootNodeId: reviewerRootId, | |
| 548 | kind: "subagent", | |
| 549 | }, | |
| 550 | }, | |
| 551 | ], | |
| 552 | }); | |
| 553 | } | |
| 524 | 554 | // A settled run with its root node, attempt, provider turn and, |
| 525 | 555 | // optionally, a command or native subagent it left running. |
| 526 | 556 | const settledRun = (input: { |
| ... | ... | @@ -883,10 +913,18 @@ const stopEarlierBackgroundWork = ({ |
| 883 | 913 | const reviewerThread = yield* orchestrator.getThreadProjection(reviewerThreadId); |
| 884 | 914 | assert.equal(reviewerThread.nodes[0]?.status, "interrupted"); |
| 885 | 915 | assert.equal(reviewerThread.subagents[0]?.status, "interrupted"); |
| 886 | assert.equal( | |
| 887 | reviewerThread.turnItems.find((item) => item.id === nestedItemId)?.status, | |
| 888 | "interrupted", | |
| 889 | ); | |
| 916 | if (workflowMember) { | |
| 917 | assert.isUndefined(reviewerThread.turnItems.find((item) => item.id === nestedItemId)); | |
| 918 | assert.equal( | |
| 919 | reviewerThread.nodes.find((node) => node.id === nestedSubagentId)?.status, | |
| 920 | "interrupted", | |
| 921 | ); | |
| 922 | } else { | |
| 923 | assert.equal( | |
| 924 | reviewerThread.turnItems.find((item) => item.id === nestedItemId)?.status, | |
| 925 | "interrupted", | |
| 926 | ); | |
| 927 | } | |
| 890 | 928 | const testerThread = yield* orchestrator.getThreadProjection(tester.threadId); |
| 891 | 929 | assert.equal(testerThread.nodes[0]?.status, "interrupted"); |
| 892 | 930 | }).pipe( |
| ... | ... | @@ -905,6 +943,10 @@ it.effect("Stop reaches background work an earlier provider thread still runs", |
| 905 | 943 | stopEarlierBackgroundWork({}), |
| 906 | 944 | ); |
| 907 | 945 | |
| 946 | it.effect("Stop settles workflow members without subagent turn items", () => | |
| 947 | stopEarlierBackgroundWork({ workflowMember: true }), | |
| 948 | ); | |
| 949 | ||
| 908 | 950 | it.effect( |
| 909 | 951 | "Stop reaches earlier background work after the newest run fails before provider start", |
| 910 | 952 | () => stopEarlierBackgroundWork({ failedStart: true }), |
apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts+1| ... | ... | @@ -158,6 +158,7 @@ it.effect.each(["child completion", "scheduled message", "user steering"] as con |
| 158 | 158 | }), |
| 159 | 159 | setModel: () => Effect.void, |
| 160 | 160 | setPermissionMode: () => Effect.void, |
| 161 | stopTask: () => Effect.void, | |
| 161 | 162 | interrupt: Effect.die("automatic delivery must never interrupt"), |
| 162 | 163 | close: Effect.void, |
| 163 | 164 | }), |
apps/server/src/orchestration-v2/Orchestrator.ts+18-1| ... | ... | @@ -8421,7 +8421,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio |
| 8421 | 8421 | OrchestrationV2ThreadProjection, |
| 8422 | 8422 | "nodes" | "subagents" | "turnItems" |
| 8423 | 8423 | >; |
| 8424 | readonly subagent: Extract<OrchestrationV2TurnItem, { readonly type: "subagent" }>; | |
| 8424 | readonly subagent: Pick< | |
| 8425 | Extract<OrchestrationV2TurnItem, { readonly type: "subagent" }>, | |
| 8426 | "subagentId" | "origin" | "childThreadId" | "providerInstanceId" | |
| 8427 | >; | |
| 8425 | 8428 | }> = [{ projection: input.projection, subagent: item }]; |
| 8426 | 8429 | for (const { projection, subagent } of dying) { |
| 8427 | 8430 | const ended = yield* endOrphanedNativeSubagent({ |
| ... | ... | @@ -8466,6 +8469,20 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio |
| 8466 | 8469 | for (const nested of child.turnItems) { |
| 8467 | 8470 | if (nested.type === "subagent") dying.push({ projection: child, subagent: nested }); |
| 8468 | 8471 | } |
| 8472 | // Workflow members have records and child roots without subagent turn items. | |
| 8473 | for (const nested of child.subagents) { | |
| 8474 | if (nested.origin === "provider_native" && nested.runId === null) { | |
| 8475 | dying.push({ | |
| 8476 | projection: child, | |
| 8477 | subagent: { | |
| 8478 | subagentId: nested.id, | |
| 8479 | origin: nested.origin, | |
| 8480 | childThreadId: nested.childThreadId, | |
| 8481 | providerInstanceId: nested.providerInstanceId, | |
| 8482 | }, | |
| 8483 | }); | |
| 8484 | } | |
| 8485 | } | |
| 8469 | 8486 | } |
| 8470 | 8487 | } |
| 8471 | 8488 | for (const providerThread of input.projection.providerThreads) { |
apps/server/src/orchestration-v2/ProjectionStore.test.ts+163-1| ... | ... | @@ -349,6 +349,54 @@ it.effect("memory recovery selection includes unfinished items from missing runs |
| 349 | 349 | }).pipe(Effect.provide(ProjectionStore.layerMemory)), |
| 350 | 350 | ); |
| 351 | 351 | |
| 352 | const runlessNativeSubagentNeedsRecovery = Effect.gen(function* () { | |
| 353 | const store = yield* ProjectionStore.ProjectionStoreV2; | |
| 354 | const threadId = yield* addRolledBackRecoveryCandidate("runless-native-recovery"); | |
| 355 | const now = yield* DateTime.now; | |
| 356 | const id = NodeId.make("node:runless-native-recovery"); | |
| 357 | assert.notInclude(yield* store.getRecoveryThreadIds("runtime"), threadId); | |
| 358 | for (const status of ["pending", "running", "waiting", "completed"] as const) { | |
| 359 | yield* store.apply({ | |
| 360 | id: EventId.make(`event:runless-native-recovery:${status}`), | |
| 361 | type: "subagent.updated", | |
| 362 | threadId, | |
| 363 | nodeId: id, | |
| 364 | driver, | |
| 365 | providerInstanceId, | |
| 366 | occurredAt: now, | |
| 367 | payload: { | |
| 368 | id, | |
| 369 | threadId, | |
| 370 | runId: null, | |
| 371 | parentNodeId: NodeId.make("node:runless-native-parent"), | |
| 372 | origin: "provider_native", | |
| 373 | createdBy: "agent", | |
| 374 | driver, | |
| 375 | providerInstanceId, | |
| 376 | providerThreadId: null, | |
| 377 | childThreadId: null, | |
| 378 | nativeTaskRef: { driver, nativeId: "runless-native", strength: "strong" }, | |
| 379 | prompt: "Do the work", | |
| 380 | title: "Workflow member", | |
| 381 | model: null, | |
| 382 | status, | |
| 383 | result: null, | |
| 384 | startedAt: now, | |
| 385 | completedAt: status === "completed" ? now : null, | |
| 386 | updatedAt: now, | |
| 387 | }, | |
| 388 | }); | |
| 389 | assert.equal( | |
| 390 | (yield* store.getRecoveryThreadIds("runtime")).includes(threadId), | |
| 391 | status !== "completed", | |
| 392 | ); | |
| 393 | } | |
| 394 | }); | |
| 395 | ||
| 396 | it.effect("memory recovery selects unfinished runless native subagents", () => | |
| 397 | runlessNativeSubagentNeedsRecovery.pipe(Effect.provide(ProjectionStore.layerMemory)), | |
| 398 | ); | |
| 399 | ||
| 352 | 400 | // Project A holds an active thread, an archived thread and a fork of a run in |
| 353 | 401 | // an archived project B thread, so a project-scoped snapshot still has to load |
| 354 | 402 | // a fork source that is outside the project and archived. |
| ... | ... | @@ -501,6 +549,11 @@ it.effect("memory shell snapshots scope to one project", () => |
| 501 | 549 | ); |
| 502 | 550 | |
| 503 | 551 | it.layer(layerTest)("ProjectionStoreV2", (it) => { |
| 552 | it.effect( | |
| 553 | "SQL recovery selects unfinished runless native subagents", | |
| 554 | () => runlessNativeSubagentNeedsRecovery, | |
| 555 | ); | |
| 556 | ||
| 504 | 557 | it.effect( |
| 505 | 558 | "keeps restart-cancelled work through a stale run.updated", |
| 506 | 559 | () => restartCancelledWorkSurvivesStaleRunUpdate, |
| ... | ... | @@ -2520,6 +2573,8 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { |
| 2520 | 2573 | const now = yield* DateTime.now; |
| 2521 | 2574 | const settledThreadId = ThreadId.make("thread:recovery-candidates:settled"); |
| 2522 | 2575 | const runningThreadId = ThreadId.make("thread:recovery-candidates:running"); |
| 2576 | const memberThreadId = ThreadId.make("thread:recovery-candidates:member"); | |
| 2577 | const childThreadId = ThreadId.make("thread:recovery-candidates:child"); | |
| 2523 | 2578 | const rolledBackThreadId = yield* addRolledBackRecoveryCandidate("recovery-candidates"); |
| 2524 | 2579 | const orphanedThreadId = yield* addOrphanedRecoveryCandidate("recovery-candidates"); |
| 2525 | 2580 | const projectId = ProjectId.make("project:recovery-candidates"); |
| ... | ... | @@ -2551,7 +2606,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { |
| 2551 | 2606 | deletedAt: null, |
| 2552 | 2607 | }); |
| 2553 | 2608 | |
| 2554 | for (const threadId of [settledThreadId, runningThreadId]) { | |
| 2609 | for (const threadId of [settledThreadId, runningThreadId, memberThreadId, childThreadId]) { | |
| 2555 | 2610 | yield* projectionStore.apply({ |
| 2556 | 2611 | id: EventId.make(`event:recovery-candidates:${threadId}:created`), |
| 2557 | 2612 | type: "thread.created", |
| ... | ... | @@ -2591,11 +2646,118 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { |
| 2591 | 2646 | }, |
| 2592 | 2647 | }); |
| 2593 | 2648 | |
| 2649 | const memberId = NodeId.make("node:recovery-candidates:member"); | |
| 2650 | const childRootNodeId = NodeId.make("node:recovery-candidates:child-root"); | |
| 2651 | const nativeTaskRef = { driver, nativeId: "workflow:agent:1", strength: "strong" as const }; | |
| 2652 | const memberNode = { | |
| 2653 | id: memberId, | |
| 2654 | threadId: memberThreadId, | |
| 2655 | runId: null, | |
| 2656 | parentNodeId: NodeId.make("node:recovery-candidates:coordinator-root"), | |
| 2657 | rootNodeId: NodeId.make("node:recovery-candidates:coordinator-root"), | |
| 2658 | kind: "subagent" as const, | |
| 2659 | status: "running" as const, | |
| 2660 | countsForRun: false, | |
| 2661 | providerThreadId: null, | |
| 2662 | providerTurnId: null, | |
| 2663 | nativeItemRef: nativeTaskRef, | |
| 2664 | runtimeRequestId: null, | |
| 2665 | checkpointScopeId: null, | |
| 2666 | startedAt: now, | |
| 2667 | completedAt: null, | |
| 2668 | }; | |
| 2669 | yield* projectionStore.apply({ | |
| 2670 | id: EventId.make("event:recovery-candidates:member"), | |
| 2671 | type: "subagent.updated", | |
| 2672 | threadId: memberThreadId, | |
| 2673 | nodeId: memberId, | |
| 2674 | driver, | |
| 2675 | providerInstanceId, | |
| 2676 | occurredAt: now, | |
| 2677 | payload: { | |
| 2678 | id: memberId, | |
| 2679 | threadId: memberThreadId, | |
| 2680 | runId: null, | |
| 2681 | parentNodeId: memberNode.parentNodeId, | |
| 2682 | origin: "provider_native", | |
| 2683 | createdBy: "agent", | |
| 2684 | driver, | |
| 2685 | providerInstanceId, | |
| 2686 | providerThreadId: null, | |
| 2687 | childThreadId, | |
| 2688 | nativeTaskRef, | |
| 2689 | prompt: "Do the work", | |
| 2690 | title: "Member", | |
| 2691 | model: null, | |
| 2692 | status: "running", | |
| 2693 | result: null, | |
| 2694 | startedAt: now, | |
| 2695 | completedAt: null, | |
| 2696 | updatedAt: now, | |
| 2697 | }, | |
| 2698 | }); | |
| 2699 | for (const [threadId, node] of [ | |
| 2700 | [memberThreadId, memberNode], | |
| 2701 | [ | |
| 2702 | childThreadId, | |
| 2703 | { | |
| 2704 | ...memberNode, | |
| 2705 | id: childRootNodeId, | |
| 2706 | threadId: childThreadId, | |
| 2707 | parentNodeId: null, | |
| 2708 | rootNodeId: childRootNodeId, | |
| 2709 | kind: "root_turn" as const, | |
| 2710 | }, | |
| 2711 | ], | |
| 2712 | ] as const) { | |
| 2713 | yield* projectionStore.apply({ | |
| 2714 | id: EventId.make(`event:recovery-candidates:node:${node.id}`), | |
| 2715 | type: "node.updated", | |
| 2716 | threadId, | |
| 2717 | nodeId: node.id, | |
| 2718 | driver, | |
| 2719 | occurredAt: now, | |
| 2720 | payload: node, | |
| 2721 | }); | |
| 2722 | } | |
| 2723 | ||
| 2594 | 2724 | const recoveryThreadIds = yield* projectionStore.getRecoveryThreadIds("runtime"); |
| 2595 | 2725 | assert.include(recoveryThreadIds, runningThreadId); |
| 2596 | 2726 | assert.include(recoveryThreadIds, orphanedThreadId); |
| 2727 | assert.include(recoveryThreadIds, memberThreadId); | |
| 2728 | assert.include(recoveryThreadIds, childThreadId); | |
| 2597 | 2729 | assert.notInclude(recoveryThreadIds, settledThreadId); |
| 2598 | 2730 | assert.notInclude(recoveryThreadIds, rolledBackThreadId); |
| 2731 | const memberRecovery = yield* projectionStore.getRuntimeRecoveryProjection(memberThreadId); | |
| 2732 | const childRecovery = yield* projectionStore.getRuntimeRecoveryProjection(childThreadId); | |
| 2733 | assert.deepEqual( | |
| 2734 | memberRecovery.subagents.map((subagent) => subagent.id), | |
| 2735 | [memberId], | |
| 2736 | ); | |
| 2737 | assert.deepEqual( | |
| 2738 | memberRecovery.nodes.map((node) => node.id), | |
| 2739 | [memberId], | |
| 2740 | ); | |
| 2741 | assert.deepEqual( | |
| 2742 | childRecovery.nodes.map((node) => node.id), | |
| 2743 | [childRootNodeId], | |
| 2744 | ); | |
| 2745 | yield* projectionStore.apply({ | |
| 2746 | id: EventId.make("event:recovery-candidates:member-cancelled"), | |
| 2747 | type: "subagent.updated", | |
| 2748 | threadId: memberThreadId, | |
| 2749 | nodeId: memberId, | |
| 2750 | driver, | |
| 2751 | providerInstanceId, | |
| 2752 | occurredAt: now, | |
| 2753 | payload: { | |
| 2754 | ...memberRecovery.subagents[0]!, | |
| 2755 | status: "cancelled", | |
| 2756 | completedAt: now, | |
| 2757 | updatedAt: now, | |
| 2758 | }, | |
| 2759 | }); | |
| 2760 | assert.include(yield* projectionStore.getRecoveryThreadIds("runtime"), childThreadId); | |
| 2599 | 2761 | }), |
| 2600 | 2762 | ); |
| 2601 | 2763 |
apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts+84| ... | ... | @@ -1589,6 +1589,90 @@ layer("ProviderEventIngestorV2", (it) => { |
| 1589 | 1589 | }), |
| 1590 | 1590 | ); |
| 1591 | 1591 | |
| 1592 | it.effect("updates provider child metadata without recreating its saved thread", () => | |
| 1593 | Effect.gen(function* () { | |
| 1594 | const now = yield* DateTime.now; | |
| 1595 | const eventSink = yield* EventSink.EventSinkV2; | |
| 1596 | const projectionStore = yield* ProjectionStore.ProjectionStoreV2; | |
| 1597 | const ingestor = yield* ProviderEventIngestor.ProviderEventIngestorV2; | |
| 1598 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | |
| 1599 | const rootEvent = yield* threadCreatedEvent(now); | |
| 1600 | if (rootEvent.type !== "thread.created") throw new Error("Expected thread fixture"); | |
| 1601 | const childThreadId = idAllocator.derive.threadFromProviderThread({ | |
| 1602 | driver: CODEX_DRIVER, | |
| 1603 | nativeThreadId: "workflow-member-metadata", | |
| 1604 | }); | |
| 1605 | const providerSessionId = yield* idAllocator.allocate.providerSession({ | |
| 1606 | providerInstanceId: modelSelection.instanceId, | |
| 1607 | threadId: rootEvent.threadId, | |
| 1608 | }); | |
| 1609 | const offered = { | |
| 1610 | ...rootEvent.payload, | |
| 1611 | id: childThreadId, | |
| 1612 | title: "Inspect checkout", | |
| 1613 | activeProviderThreadId: null, | |
| 1614 | lineage: { | |
| 1615 | parentThreadId: rootEvent.threadId, | |
| 1616 | relationshipToParent: "subagent" as const, | |
| 1617 | rootThreadId: rootEvent.threadId, | |
| 1618 | }, | |
| 1619 | }; | |
| 1620 | const ingest = (appThread: OrchestrationV2AppThread) => | |
| 1621 | ingestor.ingestNormalized({ | |
| 1622 | providerSessionId, | |
| 1623 | providerInstanceId: modelSelection.instanceId, | |
| 1624 | threadId: rootEvent.threadId, | |
| 1625 | event: { type: "app_thread.created", driver: CODEX_DRIVER, appThread }, | |
| 1626 | }); | |
| 1627 | yield* eventSink.write({ events: [rootEvent] }); | |
| 1628 | const created = yield* Effect.all([ingest(offered), ingest(offered)], { | |
| 1629 | concurrency: "unbounded", | |
| 1630 | }); | |
| 1631 | assert.deepEqual( | |
| 1632 | created.flat().map((stored) => stored.event.type), | |
| 1633 | ["thread.created"], | |
| 1634 | ); | |
| 1635 | const saved = { | |
| 1636 | ...offered, | |
| 1637 | archivedAt: now, | |
| 1638 | deletedAt: now, | |
| 1639 | activeProviderThreadId: rootEvent.payload.activeProviderThreadId, | |
| 1640 | branch: "user/checkout", | |
| 1641 | }; | |
| 1642 | yield* eventSink.write({ | |
| 1643 | events: [ | |
| 1644 | { | |
| 1645 | ...rootEvent, | |
| 1646 | id: yield* idAllocator.allocate.event({ threadId: childThreadId }), | |
| 1647 | threadId: childThreadId, | |
| 1648 | type: "thread.deleted", | |
| 1649 | payload: saved, | |
| 1650 | }, | |
| 1651 | ], | |
| 1652 | }); | |
| 1653 | for (const metadata of [ | |
| 1654 | { title: "Review checkout", modelSelection }, | |
| 1655 | { title: "Review checkout", modelSelection: { ...modelSelection, model: "gpt-6.1-sol" } }, | |
| 1656 | ]) { | |
| 1657 | const updated = yield* ingest({ ...offered, ...metadata }); | |
| 1658 | assert.deepEqual( | |
| 1659 | updated.map((stored) => stored.event.type), | |
| 1660 | ["thread.metadata-updated"], | |
| 1661 | ); | |
| 1662 | const thread = yield* projectionStore.getThread(childThreadId); | |
| 1663 | assert.deepEqual(thread, { ...saved, ...metadata, updatedAt: thread.updatedAt }); | |
| 1664 | } | |
| 1665 | assert.deepEqual( | |
| 1666 | yield* ingest({ | |
| 1667 | ...offered, | |
| 1668 | title: "Review checkout", | |
| 1669 | modelSelection: { ...modelSelection, model: "gpt-6.1-sol" }, | |
| 1670 | }), | |
| 1671 | [], | |
| 1672 | ); | |
| 1673 | }), | |
| 1674 | ); | |
| 1675 | ||
| 1592 | 1676 | it.effect("moves a native subagent's thread to the model its provider reports later", () => |
| 1593 | 1677 | Effect.gen(function* () { |
| 1594 | 1678 | const now = yield* DateTime.now; |
apps/server/src/orchestration-v2/ProviderEventIngestor.ts+31-4| ... | ... | @@ -535,14 +535,37 @@ export const layer: Layer.Layer< |
| 535 | 535 | const normalize: ProviderEventIngestorV2Shape["normalize"] = (input) => |
| 536 | 536 | Effect.gen(function* () { |
| 537 | 537 | switch (input.event.type) { |
| 538 | case "app_thread.created": | |
| 538 | case "app_thread.created": { | |
| 539 | const offered = input.event.appThread; | |
| 540 | const existing = yield* projections.getThread(offered.id).pipe( | |
| 541 | Effect.catchTags({ | |
| 542 | ProjectionStoreThreadNotFoundError: () => Effect.succeed(null), | |
| 543 | }), | |
| 544 | ); | |
| 545 | if ( | |
| 546 | existing !== null && | |
| 547 | existing.title === offered.title && | |
| 548 | modelSelectionsEqual(existing.modelSelection, offered.modelSelection) | |
| 549 | ) | |
| 550 | return []; | |
| 551 | const now = yield* DateTime.now; | |
| 539 | 552 | return [ |
| 540 | 553 | yield* makeDomainEvent(input, { |
| 541 | type: "thread.created", | |
| 542 | threadId: input.event.appThread.id, | |
| 543 | payload: input.event.appThread, | |
| 554 | type: existing === null ? "thread.created" : "thread.metadata-updated", | |
| 555 | threadId: offered.id, | |
| 556 | payload: | |
| 557 | existing === null | |
| 558 | ? offered | |
| 559 | : { | |
| 560 | ...existing, | |
| 561 | title: offered.title, | |
| 562 | modelSelection: offered.modelSelection, | |
| 563 | updatedAt: now, | |
| 564 | }, | |
| 565 | occurredAt: now, | |
| 544 | 566 | }), |
| 545 | 567 | ]; |
| 568 | } | |
| 546 | 569 | case "provider_session.updated": |
| 547 | 570 | return [ |
| 548 | 571 | yield* makeDomainEvent(input, { |
| ... | ... | @@ -795,6 +818,10 @@ export const layer: Layer.Layer< |
| 795 | 818 | ); |
| 796 | 819 | }), |
| 797 | 820 | ), |
| 821 | (effect) => | |
| 822 | input.event.type === "app_thread.created" | |
| 823 | ? threadCommands.withLock(input.event.appThread.id, effect) | |
| 824 | : effect, | |
| 798 | 825 | ), |
| 799 | 826 | }); |
| 800 | 827 | }), |
apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts+91| ... | ... | @@ -854,6 +854,97 @@ it.effect( |
| 854 | 854 | }, |
| 855 | 855 | ); |
| 856 | 856 | |
| 857 | it.effect("cancels workflow members and child roots without run ids after restart", () => { | |
| 858 | const parentThreadId = ThreadId.make("thread_recovery_workflow_members"); | |
| 859 | const childThreadId = ThreadId.make("thread_recovery_workflow_member_child"); | |
| 860 | const memberId = NodeId.make("node_recovery_workflow_member"); | |
| 861 | const childRootId = NodeId.make("node_recovery_workflow_member_root"); | |
| 862 | const providerInstanceId = ProviderInstanceId.make("claudeAgent"); | |
| 863 | const driver = ProviderDriverKind.make("claudeAgent"); | |
| 864 | const member = { | |
| 865 | id: memberId, | |
| 866 | threadId: parentThreadId, | |
| 867 | runId: null, | |
| 868 | origin: "provider_native", | |
| 869 | status: "running", | |
| 870 | driver, | |
| 871 | providerInstanceId, | |
| 872 | }; | |
| 873 | const parentProjection = { | |
| 874 | thread: { id: parentThreadId, providerInstanceId }, | |
| 875 | runs: [], | |
| 876 | attempts: [], | |
| 877 | nodes: [ | |
| 878 | { id: memberId, threadId: parentThreadId, runId: null, kind: "subagent", status: "running" }, | |
| 879 | ], | |
| 880 | subagents: [member], | |
| 881 | runtimeRequests: [], | |
| 882 | providerSessions: [], | |
| 883 | providerThreads: [], | |
| 884 | providerTurns: [], | |
| 885 | messages: [], | |
| 886 | turnItems: [], | |
| 887 | } as unknown as OrchestrationV2ThreadProjection; | |
| 888 | const childProjection = { | |
| 889 | ...parentProjection, | |
| 890 | thread: { id: childThreadId, providerInstanceId }, | |
| 891 | nodes: [ | |
| 892 | { | |
| 893 | id: childRootId, | |
| 894 | threadId: childThreadId, | |
| 895 | runId: null, | |
| 896 | kind: "root_turn", | |
| 897 | status: "running", | |
| 898 | }, | |
| 899 | ], | |
| 900 | subagents: [], | |
| 901 | } as unknown as OrchestrationV2ThreadProjection; | |
| 902 | const committed: Array<Parameters<EventSink.EventSinkV2["Service"]["commitCommand"]>[0]> = []; | |
| 903 | const layer = ProviderRuntimeRecovery.layer.pipe( | |
| 904 | Layer.provide(ServerSettings.layerTest()), | |
| 905 | Layer.provide( | |
| 906 | Layer.mergeAll( | |
| 907 | Layer.mock(ProjectionStore.ProjectionStoreV2)({ | |
| 908 | getRecoveryThreadIds: () => Effect.succeed([parentThreadId, childThreadId]), | |
| 909 | getRuntimeRecoveryProjection: (threadId) => | |
| 910 | Effect.succeed(threadId === parentThreadId ? parentProjection : childProjection), | |
| 911 | }), | |
| 912 | Layer.mock(EventSink.EventSinkV2)({ | |
| 913 | commitCommand: (input) => { | |
| 914 | committed.push(input); | |
| 915 | return Effect.succeed({ committed: true, cancelledEffectCount: 0 } as never); | |
| 916 | }, | |
| 917 | }), | |
| 918 | IdAllocator.layer, | |
| 919 | Layer.mock(EffectWorker.OrchestrationEffectWorkerV2)({ | |
| 920 | runRecoveryOnce: Effect.succeed(false), | |
| 921 | }), | |
| 922 | Layer.mock(EffectOutbox.EffectOutboxV2)({ | |
| 923 | reconcileAfterProcessLoss: Effect.succeed({ requeued: 0, cancelled: 0 }), | |
| 924 | }), | |
| 925 | ), | |
| 926 | ), | |
| 927 | ); | |
| 928 | ||
| 929 | return Effect.gen(function* () { | |
| 930 | yield* (yield* ProviderRuntimeRecovery.ProviderRuntimeRecoveryService).reconcile("startup"); | |
| 931 | assert.deepEqual( | |
| 932 | committed.flatMap((command) => | |
| 933 | command.events.map((event) => [ | |
| 934 | command.threadId, | |
| 935 | event.type, | |
| 936 | "status" in event.payload ? event.payload.status : null, | |
| 937 | ]), | |
| 938 | ), | |
| 939 | [ | |
| 940 | [parentThreadId, "subagent.updated", "cancelled"], | |
| 941 | [parentThreadId, "node.updated", "cancelled"], | |
| 942 | [childThreadId, "node.updated", "cancelled"], | |
| 943 | ], | |
| 944 | ); | |
| 945 | }).pipe(Effect.provide(layer)); | |
| 946 | }); | |
| 947 | ||
| 857 | 948 | it.effect( |
| 858 | 949 | "clears persisted pendingBackgroundTasks and terminalizes stale background items on settled runs", |
| 859 | 950 | () => { |
apps/server/src/orchestration-v2/RunExecutionService.test.ts+207-16| ... | ... | @@ -165,7 +165,7 @@ it.effect("routes shared-runtime events only to their owning root run", () => |
| 165 | 165 | }), |
| 166 | 166 | ); |
| 167 | 167 | |
| 168 | it("leaves a child thread created after the root turn ended to the run that is live then", () => { | |
| 168 | it("routes only enrolled workflow members after the root turn ends", () => { | |
| 169 | 169 | const threadId = ThreadId.make("thread:late-child"); |
| 170 | 170 | const rootProviderTurnId = ProviderTurnId.make("provider-turn:late-child"); |
| 171 | 171 | const identity: RunExecutionService.ProviderEventRouteIdentity = { |
| ... | ... | @@ -174,14 +174,17 @@ it("leaves a child thread created after the root turn ended to the run that is l |
| 174 | 174 | attemptId: RunAttemptId.make("attempt:late-child"), |
| 175 | 175 | providerThreadId: ProviderThreadId.make("provider-thread:late-child"), |
| 176 | 176 | }; |
| 177 | const childCreated = (childThreadId: ThreadId): ProviderAdapter.ProviderAdapterV2Event => | |
| 177 | const childCreated = ( | |
| 178 | childThreadId: ThreadId, | |
| 179 | parentThreadId = threadId, | |
| 180 | ): ProviderAdapter.ProviderAdapterV2Event => | |
| 178 | 181 | ({ |
| 179 | 182 | type: "app_thread.created", |
| 180 | 183 | driver, |
| 181 | 184 | appThread: { |
| 182 | 185 | id: childThreadId, |
| 183 | 186 | lineage: { |
| 184 | parentThreadId: threadId, | |
| 187 | parentThreadId, | |
| 185 | 188 | relationshipToParent: "subagent", |
| 186 | 189 | rootThreadId: threadId, |
| 187 | 190 | }, |
| ... | ... | @@ -200,6 +203,13 @@ it("leaves a child thread created after the root turn ended to the run that is l |
| 200 | 203 | initial, |
| 201 | 204 | ); |
| 202 | 205 | assert.isTrue(earlyAccepted); |
| 206 | const existingDescendant = ThreadId.make("thread:late-child:existing-descendant"); | |
| 207 | const [descendantAccepted, withDescendant] = RunExecutionService.routeProviderEvent( | |
| 208 | childCreated(existingDescendant, earlyChild), | |
| 209 | identity, | |
| 210 | live, | |
| 211 | ); | |
| 212 | assert.isTrue(descendantAccepted); | |
| 203 | 213 | const [terminalAccepted, ended] = RunExecutionService.routeProviderEvent( |
| 204 | 214 | { |
| 205 | 215 | type: "turn.terminal", |
| ... | ... | @@ -212,7 +222,7 @@ it("leaves a child thread created after the root turn ended to the run that is l |
| 212 | 222 | threadDisposition: "reusable", |
| 213 | 223 | }, |
| 214 | 224 | identity, |
| 215 | live, | |
| 225 | withDescendant, | |
| 216 | 226 | ); |
| 217 | 227 | assert.isTrue(terminalAccepted); |
| 218 | 228 | // A child the root launched before it ended stays with this run. |
| ... | ... | @@ -224,6 +234,92 @@ it("leaves a child thread created after the root turn ended to the run that is l |
| 224 | 234 | ); |
| 225 | 235 | assert.isFalse(lateAccepted); |
| 226 | 236 | assert.isFalse(afterLate.ownedThreadIds.has(lateChild)); |
| 237 | ||
| 238 | const memberId = ThreadId.make("thread:late-child:workflow-member"); | |
| 239 | assert.isFalse( | |
| 240 | RunExecutionService.routeProviderEvent(childCreated(memberId, earlyChild), identity, ended)[0], | |
| 241 | ); | |
| 242 | const coordinator: OrchestrationV2Subagent = { | |
| 243 | id: NodeId.make("node:late-child:workflow"), | |
| 244 | threadId, | |
| 245 | runId: identity.runId, | |
| 246 | parentNodeId: NodeId.make("node:late-child"), | |
| 247 | origin: "provider_native", | |
| 248 | createdBy: "agent", | |
| 249 | driver, | |
| 250 | providerInstanceId: ProviderInstanceId.make("codex"), | |
| 251 | providerThreadId: null, | |
| 252 | childThreadId: earlyChild, | |
| 253 | nativeTaskRef: null, | |
| 254 | prompt: "Review the code", | |
| 255 | title: "Review", | |
| 256 | model: null, | |
| 257 | status: "running", | |
| 258 | result: null, | |
| 259 | startedAt: null, | |
| 260 | completedAt: null, | |
| 261 | updatedAt: DateTime.makeUnsafe(0), | |
| 262 | workflow: { | |
| 263 | phases: [], | |
| 264 | agents: [{ index: 0, label: "Reviewer", state: "running", childThreadId: memberId }], | |
| 265 | }, | |
| 266 | }; | |
| 267 | const [foreignAccepted, afterForeign] = RunExecutionService.routeProviderEvent( | |
| 268 | { | |
| 269 | type: "subagent.updated", | |
| 270 | driver, | |
| 271 | subagent: { | |
| 272 | ...coordinator, | |
| 273 | threadId: earlyChild, | |
| 274 | childThreadId: existingDescendant, | |
| 275 | runId: RunId.make("run:late-child:new"), | |
| 276 | }, | |
| 277 | }, | |
| 278 | identity, | |
| 279 | ended, | |
| 280 | ); | |
| 281 | assert.isTrue(foreignAccepted); | |
| 282 | assert.isFalse( | |
| 283 | RunExecutionService.routeProviderEvent( | |
| 284 | childCreated(memberId, existingDescendant), | |
| 285 | identity, | |
| 286 | afterForeign, | |
| 287 | )[0], | |
| 288 | ); | |
| 289 | const [workflowAccepted, afterWorkflow] = RunExecutionService.routeProviderEvent( | |
| 290 | { type: "subagent.updated", driver, subagent: coordinator }, | |
| 291 | identity, | |
| 292 | ended, | |
| 293 | ); | |
| 294 | assert.isTrue(workflowAccepted); | |
| 295 | assert.isFalse( | |
| 296 | RunExecutionService.routeProviderEvent(childCreated(memberId), identity, afterWorkflow)[0], | |
| 297 | ); | |
| 298 | const [memberAccepted, afterMember] = RunExecutionService.routeProviderEvent( | |
| 299 | childCreated(memberId, earlyChild), | |
| 300 | identity, | |
| 301 | afterWorkflow, | |
| 302 | ); | |
| 303 | assert.isTrue(memberAccepted); | |
| 304 | assert.isTrue(afterMember.ownedThreadIds.has(memberId)); | |
| 305 | const [unrelatedAccepted] = RunExecutionService.routeProviderEvent( | |
| 306 | childCreated(ThreadId.make("thread:late-child:unrelated-member"), lateChild), | |
| 307 | identity, | |
| 308 | ended, | |
| 309 | ); | |
| 310 | assert.isFalse(unrelatedAccepted); | |
| 311 | const [unrelatedDescendantAccepted, afterUnrelatedDescendant] = | |
| 312 | RunExecutionService.routeProviderEvent( | |
| 313 | childCreated(ThreadId.make("thread:late-child:unrelated-descendant"), earlyChild), | |
| 314 | identity, | |
| 315 | afterWorkflow, | |
| 316 | ); | |
| 317 | assert.isFalse(unrelatedDescendantAccepted); | |
| 318 | assert.isFalse( | |
| 319 | afterUnrelatedDescendant.ownedThreadIds.has( | |
| 320 | ThreadId.make("thread:late-child:unrelated-descendant"), | |
| 321 | ), | |
| 322 | ); | |
| 227 | 323 | }); |
| 228 | 324 | |
| 229 | 325 | it("does not route a superseded attempt through a reused provider thread", () => { |
| ... | ... | @@ -1225,10 +1321,11 @@ it.effect.each(["failure", "interruption", "stale-attempt", "start-guard"] as co |
| 1225 | 1321 | }), |
| 1226 | 1322 | ); |
| 1227 | 1323 | |
| 1228 | it.effect("keeps ingesting owned child events after the root turn terminalizes", () => | |
| 1324 | it.effect("ingests new member threads of an owned child after the root turn terminalizes", () => | |
| 1229 | 1325 | Effect.gen(function* () { |
| 1230 | 1326 | const threadId = ThreadId.make("thread:run-execution-late-child"); |
| 1231 | 1327 | const childThreadId = ThreadId.make("thread:run-execution-late-child:child"); |
| 1328 | const memberThreadId = ThreadId.make("thread:run-execution-late-child:member"); | |
| 1232 | 1329 | const runId = RunId.make("run:run-execution-late-child"); |
| 1233 | 1330 | const attemptId = RunAttemptId.make("attempt:run-execution-late-child"); |
| 1234 | 1331 | const providerInstanceId = ProviderInstanceId.make("codex"); |
| ... | ... | @@ -1268,9 +1365,15 @@ it.effect("keeps ingesting owned child events after the root turn terminalizes", |
| 1268 | 1365 | Layer.mock(ProviderEventIngestor.ProviderEventIngestorV2)({ |
| 1269 | 1366 | ingestNormalized: (input) => |
| 1270 | 1367 | Effect.gen(function* () { |
| 1368 | if ( | |
| 1369 | input.event.type === "app_thread.created" && | |
| 1370 | input.event.appThread.id === memberThreadId | |
| 1371 | ) { | |
| 1372 | yield* Ref.update(order, (current) => [...current, "member-thread"]); | |
| 1373 | } | |
| 1271 | 1374 | if ( |
| 1272 | 1375 | input.event.type === "message.updated" && |
| 1273 | input.event.message.threadId === childThreadId | |
| 1376 | input.event.message.threadId === memberThreadId | |
| 1274 | 1377 | ) { |
| 1275 | 1378 | yield* Ref.update(order, (current) => [...current, "child-message"]); |
| 1276 | 1379 | yield* Deferred.succeed(childMessageIngested, undefined).pipe(Effect.ignore); |
| ... | ... | @@ -1307,12 +1410,21 @@ it.effect("keeps ingesting owned child events after the root turn terminalizes", |
| 1307 | 1410 | type: "subagent.updated", |
| 1308 | 1411 | driver, |
| 1309 | 1412 | subagent: { |
| 1310 | id: subagentNodeId, | |
| 1311 | threadId, | |
| 1312 | runId, | |
| 1313 | status: "running", | |
| 1413 | ...makeRunOwnedSubagentFixture({ | |
| 1414 | ids: backgroundScenarioIds("run-execution-late-child"), | |
| 1415 | providerInstanceId, | |
| 1416 | childThreadId, | |
| 1417 | driver, | |
| 1418 | status: "running", | |
| 1419 | }), | |
| 1420 | workflow: { | |
| 1421 | phases: [], | |
| 1422 | agents: [ | |
| 1423 | { index: 0, label: "Reviewer", state: "running", childThreadId: memberThreadId }, | |
| 1424 | ], | |
| 1425 | }, | |
| 1314 | 1426 | }, |
| 1315 | } as ProviderAdapter.ProviderAdapterV2Event, | |
| 1427 | }, | |
| 1316 | 1428 | { |
| 1317 | 1429 | type: "provider_turn.updated", |
| 1318 | 1430 | driver, |
| ... | ... | @@ -1335,12 +1447,24 @@ it.effect("keeps ingesting owned child events after the root turn terminalizes", |
| 1335 | 1447 | failure: null, |
| 1336 | 1448 | threadDisposition: "reusable", |
| 1337 | 1449 | }, |
| 1450 | { | |
| 1451 | type: "app_thread.created", | |
| 1452 | driver, | |
| 1453 | appThread: { | |
| 1454 | id: memberThreadId, | |
| 1455 | lineage: { | |
| 1456 | parentThreadId: childThreadId, | |
| 1457 | relationshipToParent: "subagent", | |
| 1458 | rootThreadId: threadId, | |
| 1459 | }, | |
| 1460 | }, | |
| 1461 | } as ProviderAdapter.ProviderAdapterV2Event, | |
| 1338 | 1462 | { |
| 1339 | 1463 | type: "message.updated", |
| 1340 | 1464 | driver, |
| 1341 | 1465 | message: { |
| 1342 | 1466 | id: MessageId.make("message:run-execution-late-child:child"), |
| 1343 | threadId: childThreadId, | |
| 1467 | threadId: memberThreadId, | |
| 1344 | 1468 | runId: null, |
| 1345 | 1469 | nodeId: childNodeId, |
| 1346 | 1470 | role: "assistant", |
| ... | ... | @@ -1428,7 +1552,7 @@ it.effect("keeps ingesting owned child events after the root turn terminalizes", |
| 1428 | 1552 | Effect.timeoutOption("2 seconds"), |
| 1429 | 1553 | ); |
| 1430 | 1554 | assert.isTrue(Option.isSome(observed), "child message was not ingested after root terminal"); |
| 1431 | assert.deepEqual(yield* Ref.get(order), ["root-finalized", "child-message"]); | |
| 1555 | assert.deepEqual(yield* Ref.get(order), ["root-finalized", "member-thread", "child-message"]); | |
| 1432 | 1556 | }), |
| 1433 | 1557 | ); |
| 1434 | 1558 | |
| ... | ... | @@ -2614,11 +2738,42 @@ it.effect( |
| 2614 | 2738 | ids, |
| 2615 | 2739 | status: "running", |
| 2616 | 2740 | }); |
| 2741 | const memberThreadId = ThreadId.make("thread:subagent-interrupt-cascade:member"); | |
| 2742 | const runningMember = { | |
| 2743 | ...runningSubagent, | |
| 2744 | id: NodeId.make("node:subagent-interrupt-cascade:member"), | |
| 2745 | threadId: childThreadId, | |
| 2746 | runId: null, | |
| 2747 | parentNodeId: runningChildNode.id, | |
| 2748 | childThreadId: memberThreadId, | |
| 2749 | }; | |
| 2750 | const foreignSubagent = { | |
| 2751 | ...runningMember, | |
| 2752 | id: NodeId.make("node:subagent-interrupt-cascade:foreign-subagent"), | |
| 2753 | runId: RunId.make("run:subagent-interrupt-cascade:foreign"), | |
| 2754 | childThreadId: unrelatedChildThreadId, | |
| 2755 | }; | |
| 2756 | const runningMemberRoot = { | |
| 2757 | ...runningChildNode, | |
| 2758 | id: NodeId.make("node:subagent-interrupt-cascade:member-root"), | |
| 2759 | threadId: memberThreadId, | |
| 2760 | rootNodeId: NodeId.make("node:subagent-interrupt-cascade:member-root"), | |
| 2761 | }; | |
| 2762 | const foreignChildNode = { | |
| 2763 | ...runningChildNode, | |
| 2764 | id: NodeId.make("node:subagent-interrupt-cascade:foreign-child"), | |
| 2765 | runId: foreignSubagent.runId, | |
| 2766 | }; | |
| 2617 | 2767 | const runningChildTurnItem = makeLinkedChildTurnItemFixture({ |
| 2618 | 2768 | ids, |
| 2619 | 2769 | driver, |
| 2620 | 2770 | type: "command_execution", |
| 2621 | 2771 | }); |
| 2772 | const foreignChildTurnItem = { | |
| 2773 | ...runningChildTurnItem, | |
| 2774 | id: TurnItemId.make("turn-item:subagent-interrupt-cascade:foreign-child"), | |
| 2775 | runId: foreignSubagent.runId, | |
| 2776 | }; | |
| 2622 | 2777 | const suppressedChildAssistantTurnItem = makeLinkedChildTurnItemFixture({ |
| 2623 | 2778 | ids: { |
| 2624 | 2779 | ...ids, |
| ... | ... | @@ -2692,6 +2847,11 @@ it.effect( |
| 2692 | 2847 | driver, |
| 2693 | 2848 | turnItem: unrelatedChildTurnItem, |
| 2694 | 2849 | }, |
| 2850 | { type: "subagent.updated", driver, subagent: runningMember }, | |
| 2851 | { type: "subagent.updated", driver, subagent: foreignSubagent }, | |
| 2852 | { type: "node.updated", driver, node: runningMemberRoot }, | |
| 2853 | { type: "node.updated", driver, node: foreignChildNode }, | |
| 2854 | { type: "turn_item.updated", driver, turnItem: foreignChildTurnItem }, | |
| 2695 | 2855 | rootTerminalEvent(ids, "interrupted"), |
| 2696 | 2856 | // Late provider completion after interrupt must not be ingested. |
| 2697 | 2857 | { |
| ... | ... | @@ -2758,7 +2918,7 @@ it.effect( |
| 2758 | 2918 | providerTurnId: ids.rootProviderTurnId, |
| 2759 | 2919 | } as OrchestrationV2RunAttempt, |
| 2760 | 2920 | attemptId: ids.attemptId, |
| 2761 | relatedThreadIds: [unrelatedChildThreadId], | |
| 2921 | relatedThreadIds: [unrelatedChildThreadId, memberThreadId], | |
| 2762 | 2922 | providerTurnOrdinal: 1, |
| 2763 | 2923 | message: { |
| 2764 | 2924 | messageId: MessageId.make("message:subagent-interrupt-cascade:user"), |
| ... | ... | @@ -2810,8 +2970,39 @@ it.effect( |
| 2810 | 2970 | const runUpdatedIndex = events.findIndex((event) => event.type === "run.updated"); |
| 2811 | 2971 | assert.isAtLeast(runUpdatedIndex, 0, "root run.updated must be written"); |
| 2812 | 2972 | |
| 2813 | assert.lengthOf(subagentEvents, 1); | |
| 2814 | const terminalSubagent = subagentEvents[0]; | |
| 2973 | assert.lengthOf(subagentEvents, 2); | |
| 2974 | const terminalSubagent = subagentEvents.find( | |
| 2975 | (event) => event.payload.id === runningSubagent.id, | |
| 2976 | ); | |
| 2977 | const terminalMember = subagentEvents.find((event) => event.payload.id === runningMember.id); | |
| 2978 | assert.isDefined(terminalMember); | |
| 2979 | assert.equal(terminalMember.payload.status, "interrupted"); | |
| 2980 | assert.equal(terminalMember.payload.runId, null); | |
| 2981 | assert.equal(terminalMember.payload.childThreadId, memberThreadId); | |
| 2982 | assert.isTrue( | |
| 2983 | events.some( | |
| 2984 | (event) => | |
| 2985 | event.type === "node.updated" && | |
| 2986 | event.payload.id === runningMemberRoot.id && | |
| 2987 | event.payload.status === "interrupted", | |
| 2988 | ), | |
| 2989 | ); | |
| 2990 | assert.isFalse( | |
| 2991 | events.some( | |
| 2992 | (event) => event.type === "subagent.updated" && event.payload.id === foreignSubagent.id, | |
| 2993 | ), | |
| 2994 | ); | |
| 2995 | assert.isFalse( | |
| 2996 | events.some( | |
| 2997 | (event) => event.type === "node.updated" && event.payload.id === foreignChildNode.id, | |
| 2998 | ), | |
| 2999 | ); | |
| 3000 | assert.isFalse( | |
| 3001 | events.some( | |
| 3002 | (event) => | |
| 3003 | event.type === "turn-item.updated" && event.payload.id === foreignChildTurnItem.id, | |
| 3004 | ), | |
| 3005 | ); | |
| 2815 | 3006 | assert.isDefined(terminalSubagent); |
| 2816 | 3007 | assert.equal(terminalSubagent.payload.status, "interrupted"); |
| 2817 | 3008 | assert.equal(terminalSubagent.payload.childThreadId, childThreadId); |
apps/server/src/orchestration-v2/RunExecutionService.ts+40-7| ... | ... | @@ -53,8 +53,12 @@ import * as RunFinalizationService from "./RunFinalizationService.ts"; |
| 53 | 53 | export interface ProviderEventRoutingState { |
| 54 | 54 | readonly ownedThreadIds: ReadonlySet<ThreadId>; |
| 55 | 55 | // Set once this run's root turn ended. A child thread created after that |
| 56 | // belongs to the run that is live then, so this one no longer adopts it. | |
| 56 | // belongs to the run that is live then, so this one no longer adopts it, | |
| 57 | // except a workflow member enrolled below: its coordinator keeps this run | |
| 58 | // open, so the member is this run's to follow. | |
| 57 | 59 | readonly rootTurnEnded: boolean; |
| 60 | /** Member thread id to the coordinator child thread that spawned it. */ | |
| 61 | readonly workflowMemberParents: ReadonlyMap<ThreadId, ThreadId>; | |
| 58 | 62 | readonly ownedProviderThreadIds: ReadonlySet<ProviderThreadId>; |
| 59 | 63 | readonly ownedProviderTurnIds: ReadonlySet<ProviderTurnId>; |
| 60 | 64 | readonly inheritedBackgroundTurnItems: ReadonlyMap<TurnItemId, OrchestrationV2Run["id"]>; |
| ... | ... | @@ -334,6 +338,7 @@ export function makeProviderEventRoutingState(input: { |
| 334 | 338 | return { |
| 335 | 339 | ownedThreadIds: new Set([input.identity.threadId, ...(input.relatedThreadIds ?? [])]), |
| 336 | 340 | rootTurnEnded: false, |
| 341 | workflowMemberParents: new Map(), | |
| 337 | 342 | ownedProviderThreadIds: new Set([ |
| 338 | 343 | input.identity.providerThreadId, |
| 339 | 344 | ...(input.relatedProviderThreadIds ?? []), |
| ... | ... | @@ -379,10 +384,12 @@ export function routeProviderEvent( |
| 379 | 384 | return [true, state]; |
| 380 | 385 | } |
| 381 | 386 | const isOwnedSubagent = |
| 382 | !state.rootTurnEnded && | |
| 383 | 387 | event.appThread.lineage.relationshipToParent === "subagent" && |
| 384 | 388 | event.appThread.lineage.parentThreadId !== null && |
| 385 | ownsThread(event.appThread.lineage.parentThreadId); | |
| 389 | ownsThread(event.appThread.lineage.parentThreadId) && | |
| 390 | (!state.rootTurnEnded || | |
| 391 | state.workflowMemberParents.get(event.appThread.id) === | |
| 392 | event.appThread.lineage.parentThreadId); | |
| 386 | 393 | if (!isOwnedSubagent) { |
| 387 | 394 | return [false, state]; |
| 388 | 395 | } |
| ... | ... | @@ -417,8 +424,24 @@ export function routeProviderEvent( |
| 417 | 424 | } |
| 418 | 425 | return [true, addProviderThread(event.node.providerThreadId)]; |
| 419 | 426 | } |
| 420 | case "subagent.updated": | |
| 421 | return [ownsRun(event.subagent.runId) || ownsChildThread(event.subagent.threadId), state]; | |
| 427 | case "subagent.updated": { | |
| 428 | const belongs = ownsRun(event.subagent.runId) || ownsChildThread(event.subagent.threadId); | |
| 429 | const coordinator = event.subagent; | |
| 430 | if ( | |
| 431 | !ownsRun(coordinator.runId) || | |
| 432 | coordinator.workflow === undefined || | |
| 433 | coordinator.childThreadId === null | |
| 434 | ) { | |
| 435 | return [belongs, state]; | |
| 436 | } | |
| 437 | const workflowMemberParents = new Map(state.workflowMemberParents); | |
| 438 | for (const member of coordinator.workflow.agents) { | |
| 439 | if (member.childThreadId !== undefined) { | |
| 440 | workflowMemberParents.set(member.childThreadId, coordinator.childThreadId); | |
| 441 | } | |
| 442 | } | |
| 443 | return [true, { ...state, workflowMemberParents }]; | |
| 444 | } | |
| 422 | 445 | case "message.updated": |
| 423 | 446 | return [ownsRun(event.message.runId) || ownsChildThread(event.message.threadId), state]; |
| 424 | 447 | case "turn_item.updated": { |
| ... | ... | @@ -1070,7 +1093,13 @@ export const layer: Layer.Layer< |
| 1070 | 1093 | const belongsToOwnedChildThread = |
| 1071 | 1094 | event.node.threadId !== input.run.threadId && |
| 1072 | 1095 | routing.ownedThreadIds.has(event.node.threadId); |
| 1073 | if (!belongsToRootSubagent && !belongsToOwnedChildThread) { | |
| 1096 | if ( | |
| 1097 | !belongsToRootSubagent && | |
| 1098 | !( | |
| 1099 | belongsToOwnedChildThread && | |
| 1100 | (event.node.runId === null || event.node.runId === input.run.id) | |
| 1101 | ) | |
| 1102 | ) { | |
| 1074 | 1103 | return; |
| 1075 | 1104 | } |
| 1076 | 1105 | yield* Ref.update(openRunOwnedSubagents, (current) => { |
| ... | ... | @@ -1108,7 +1137,11 @@ export const layer: Layer.Layer< |
| 1108 | 1137 | return next; |
| 1109 | 1138 | }); |
| 1110 | 1139 | } |
| 1111 | if (belongsToOwnedChildThread && deliverable) { | |
| 1140 | if ( | |
| 1141 | belongsToOwnedChildThread && | |
| 1142 | (event.turnItem.runId === null || belongsToRootRun) && | |
| 1143 | deliverable | |
| 1144 | ) { | |
| 1112 | 1145 | yield* Ref.update(openRunOwnedSubagents, (current) => { |
| 1113 | 1146 | const childTurnItems = new Map(current.childTurnItems); |
| 1114 | 1147 | if (isSettledTurnItemStatus(event.turnItem.status)) { |
apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts+43-28| ... | ... | @@ -388,7 +388,7 @@ function makeCompletingHandoffAdapter( |
| 388 | 388 | }; |
| 389 | 389 | } |
| 390 | 390 | |
| 391 | it.live("restarts selection as a new attempt and retries after old-session cleanup", () => | |
| 391 | it.live("retries a failed selection restart session open before completing", () => | |
| 392 | 392 | Effect.scoped( |
| 393 | 393 | Effect.gen(function* () { |
| 394 | 394 | const cwd = yield* checkpointWorkspace("selection-restart-lifecycle"); |
| ... | ... | @@ -404,6 +404,9 @@ it.live("restarts selection as a new attempt and retries after old-session clean |
| 404 | 404 | |
| 405 | 405 | const result = yield* Effect.gen(function* () { |
| 406 | 406 | const orchestrator = yield* Orchestrator.OrchestratorV2; |
| 407 | const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; | |
| 408 | const eventSink = yield* EventSink.EventSinkV2; | |
| 409 | ||
| 407 | 410 | yield* orchestrator.dispatch({ |
| 408 | 411 | type: "thread.create", |
| 409 | 412 | createdBy: "user", |
| ... | ... | @@ -418,6 +421,21 @@ it.live("restarts selection as a new attempt and retries after old-session clean |
| 418 | 421 | branch: null, |
| 419 | 422 | worktreePath: cwd, |
| 420 | 423 | }); |
| 424 | const running = yield* eventSink | |
| 425 | .stream({ | |
| 426 | threadId, | |
| 427 | afterSequence: yield* eventSink.latestSequence({ threadId }), | |
| 428 | }) | |
| 429 | .pipe( | |
| 430 | Stream.map((stored) => stored.event), | |
| 431 | Stream.filter( | |
| 432 | (event) => | |
| 433 | event.type === "provider-turn.updated" && event.payload.status === "running", | |
| 434 | ), | |
| 435 | Stream.take(1), | |
| 436 | Stream.runDrain, | |
| 437 | Effect.forkScoped, | |
| 438 | ); | |
| 421 | 439 | yield* orchestrator.dispatch({ |
| 422 | 440 | type: "message.dispatch", |
| 423 | 441 | createdBy: "user", |
| ... | ... | @@ -430,11 +448,8 @@ it.live("restarts selection as a new attempt and retries after old-session clean |
| 430 | 448 | modelSelection: initialSelection, |
| 431 | 449 | dispatchMode: { type: "start_immediately" }, |
| 432 | 450 | }); |
| 433 | for (let index = 0; index < 1_000; index += 1) { | |
| 434 | const current = yield* orchestrator.getThreadProjection(threadId); | |
| 435 | if (current.providerTurns.some((turn) => turn.status === "running")) break; | |
| 436 | yield* Effect.sleep("5 millis"); | |
| 437 | } | |
| 451 | yield* Fiber.join(running); | |
| 452 | yield* worker.drain(); | |
| 438 | 453 | const activeProjection = yield* orchestrator.getThreadProjection(threadId); |
| 439 | 454 | assert.isTrue(activeProjection.providerTurns.some((turn) => turn.status === "running")); |
| 440 | 455 | const activeRunId = activeProjection.runs[0]?.id; |
| ... | ... | @@ -442,6 +457,20 @@ it.live("restarts selection as a new attempt and retries after old-session clean |
| 442 | 457 | return yield* Effect.die("active restart test run is missing"); |
| 443 | 458 | } |
| 444 | 459 | |
| 460 | const completed = yield* eventSink | |
| 461 | .stream({ | |
| 462 | threadId, | |
| 463 | afterSequence: yield* eventSink.latestSequence({ threadId }), | |
| 464 | }) | |
| 465 | .pipe( | |
| 466 | Stream.map((stored) => stored.event), | |
| 467 | Stream.filter( | |
| 468 | (event) => event.type === "run.updated" && event.payload.status === "completed", | |
| 469 | ), | |
| 470 | Stream.take(1), | |
| 471 | Stream.runDrain, | |
| 472 | Effect.forkScoped, | |
| 473 | ); | |
| 445 | 474 | yield* orchestrator.dispatch({ |
| 446 | 475 | type: "message.dispatch", |
| 447 | 476 | createdBy: "user", |
| ... | ... | @@ -454,27 +483,12 @@ it.live("restarts selection as a new attempt and retries after old-session clean |
| 454 | 483 | modelSelection: replacementSelection, |
| 455 | 484 | dispatchMode: { type: "restart_active", targetRunId: activeRunId }, |
| 456 | 485 | }); |
| 457 | for (let index = 0; index < 1_000; index += 1) { | |
| 458 | const current = yield* orchestrator.getThreadProjection(threadId); | |
| 459 | if (current.attempts.length === 2 && current.attempts[1]?.status === "completed") { | |
| 460 | const captured = yield* Ref.get(state); | |
| 461 | return { projection: current, captured }; | |
| 462 | } | |
| 463 | yield* Effect.sleep("5 millis"); | |
| 464 | } | |
| 465 | const current = yield* orchestrator.getThreadProjection(threadId); | |
| 466 | const adapterState = yield* Ref.get(state); | |
| 467 | yield* Effect.logError("selection restart did not complete", { | |
| 468 | runs: current.runs.map((run) => [run.status, run.activeAttemptId]), | |
| 469 | attempts: current.attempts.map((attempt) => [attempt.id, attempt.status]), | |
| 470 | providerTurns: current.providerTurns.map((turn) => [turn.id, turn.status]), | |
| 471 | providerThreads: current.providerThreads.map((thread) => [ | |
| 472 | thread.providerSessionId, | |
| 473 | thread.status, | |
| 474 | ]), | |
| 475 | adapterState, | |
| 476 | }); | |
| 477 | return yield* Effect.die("selection restart did not complete"); | |
| 486 | yield* Fiber.join(completed); | |
| 487 | yield* worker.drain(); | |
| 488 | return { | |
| 489 | projection: yield* orchestrator.getThreadProjection(threadId), | |
| 490 | captured: yield* Ref.get(state), | |
| 491 | }; | |
| 478 | 492 | }).pipe( |
| 479 | 493 | Effect.provide( |
| 480 | 494 | ProviderReplayHarness.layerWithRegistry( |
| ... | ... | @@ -501,7 +515,8 @@ it.live("restarts selection as a new attempt and retries after old-session clean |
| 501 | 515 | ), |
| 502 | 516 | "selection restart supersede must not project hard-Stop interrupt items", |
| 503 | 517 | ); |
| 504 | assert.equal(projection.runs[0]?.modelSelection.model, replacementSelection.model); | |
| 518 | assert.equal(projection.runs.at(-1)?.modelSelection.model, replacementSelection.model); | |
| 519 | assert.equal(projection.runs.at(-1)?.status, "completed"); | |
| 505 | 520 | assert.isTrue(captured.failedReplacementOpen); |
| 506 | 521 | // The old pooled process remains available to its other threads; this |
| 507 | 522 | // thread moved to a freshly allocated replacement session. |
apps/server/src/orchestration-v2/ThreadManagementService.test.ts+235| ... | ... | @@ -5,25 +5,38 @@ import { |
| 5 | 5 | NodeId, |
| 6 | 6 | type OrchestrationV2Command, |
| 7 | 7 | type OrchestrationV2Run, |
| 8 | type OrchestrationV2Subagent, | |
| 9 | type OrchestrationV2ProviderThread, | |
| 10 | ProviderThreadId, | |
| 11 | ProviderDriverKind, | |
| 12 | ProviderSessionId, | |
| 8 | 13 | type OrchestrationV2StoredEvent, |
| 9 | 14 | type OrchestrationV2ThreadProjection, |
| 15 | type OrchestrationV2ThreadShell, | |
| 10 | 16 | ProjectId, |
| 11 | 17 | ProviderInstanceId, |
| 12 | 18 | RunId, |
| 13 | 19 | ThreadId, |
| 14 | 20 | } from "@t3tools/contracts"; |
| 15 | 21 | import * as Deferred from "effect/Deferred"; |
| 22 | import * as DateTime from "effect/DateTime"; | |
| 23 | import * as Option from "effect/Option"; | |
| 16 | 24 | import * as Duration from "effect/Duration"; |
| 17 | 25 | import * as Effect from "effect/Effect"; |
| 18 | 26 | import * as Fiber from "effect/Fiber"; |
| 19 | 27 | import * as Layer from "effect/Layer"; |
| 20 | 28 | import * as Queue from "effect/Queue"; |
| 29 | import * as Ref from "effect/Ref"; | |
| 21 | 30 | import * as Stream from "effect/Stream"; |
| 22 | 31 | import * as TestClock from "effect/testing/TestClock"; |
| 23 | 32 | |
| 24 | 33 | import * as LegacyV1ThreadImporter from "./legacy/LegacyV1ThreadImporter.ts"; |
| 25 | 34 | import * as Orchestrator from "./Orchestrator.ts"; |
| 35 | import * as ProviderSessionManager from "./ProviderSessionManager.ts"; | |
| 36 | import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; | |
| 26 | 37 | import * as ThreadManagementService from "./ThreadManagementService.ts"; |
| 38 | import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; | |
| 39 | import { DispatchModeLimit, type DispatchModeRefusal } from "./DispatchModeLimit.ts"; | |
| 27 | 40 | |
| 28 | 41 | it("stamps authoritative provenance on commands that create threads or messages", () => { |
| 29 | 42 | const command: OrchestrationV2Command = { |
| ... | ... | @@ -269,6 +282,7 @@ it.effect("classifies projection infrastructure failures separately from a missi |
| 269 | 282 | cause: infrastructureCause, |
| 270 | 283 | }); |
| 271 | 284 | const layerTest = ThreadManagementService.layer.pipe( |
| 285 | Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})), | |
| 272 | 286 | Layer.provide( |
| 273 | 287 | Layer.mock(Orchestrator.OrchestratorV2)({ |
| 274 | 288 | getThreadProjection: () => Effect.fail(projectionError), |
| ... | ... | @@ -302,6 +316,7 @@ it.effect("uses thread-not-found only after a projection loads outside the proje |
| 302 | 316 | }, |
| 303 | 317 | } as OrchestrationV2ThreadProjection; |
| 304 | 318 | const layerTest = ThreadManagementService.layer.pipe( |
| 319 | Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})), | |
| 305 | 320 | Layer.provide( |
| 306 | 321 | Layer.mock(Orchestrator.OrchestratorV2)({ |
| 307 | 322 | getThreadProjection: () => Effect.succeed(projection), |
| ... | ... | @@ -327,6 +342,7 @@ it.effect("preserves failed legacy materialization when reading checkpoint conte |
| 327 | 342 | cause: new Error("checkpoint import failed"), |
| 328 | 343 | }); |
| 329 | 344 | const layerTest = ThreadManagementService.layerWithLegacyImporter.pipe( |
| 345 | Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})), | |
| 330 | 346 | Layer.provide( |
| 331 | 347 | Layer.mergeAll( |
| 332 | 348 | Layer.mock(Orchestrator.OrchestratorV2)({ |
| ... | ... | @@ -368,6 +384,7 @@ it.effect.each([ |
| 368 | 384 | runs: status === "missing" ? [] : [{ id: runId, status }], |
| 369 | 385 | }) as unknown as OrchestrationV2ThreadProjection; |
| 370 | 386 | const layerTest = ThreadManagementService.layer.pipe( |
| 387 | Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})), | |
| 371 | 388 | Layer.provide( |
| 372 | 389 | Layer.mock(Orchestrator.OrchestratorV2)({ |
| 373 | 390 | getThreadEventSequence: () => Effect.succeed(0), |
| ... | ... | @@ -434,6 +451,7 @@ it.effect("waitForThread reads the run again only when the run updates", () => |
| 434 | 451 | const stored = (sequence: number, event: object) => |
| 435 | 452 | ({ sequence, event: { threadId, ...event } }) as unknown as OrchestrationV2StoredEvent; |
| 436 | 453 | const layerTest = ThreadManagementService.layer.pipe( |
| 454 | Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})), | |
| 437 | 455 | Layer.provide( |
| 438 | 456 | Layer.mock(Orchestrator.OrchestratorV2)({ |
| 439 | 457 | getThreadEventSequence: () => Effect.succeed(0), |
| ... | ... | @@ -474,6 +492,222 @@ it.effect("waitForThread reads the run again only when the run updates", () => |
| 474 | 492 | }), |
| 475 | 493 | ); |
| 476 | 494 | |
| 495 | it.effect.each([ | |
| 496 | "running", | |
| 497 | "missing", | |
| 498 | "completed", | |
| 499 | "unsupported", | |
| 500 | "missing-native-id", | |
| 501 | "wrong-thread", | |
| 502 | "no-session", | |
| 503 | "unsupported-runtime", | |
| 504 | "provider-failed", | |
| 505 | "provider-timeout", | |
| 506 | "within-limit", | |
| 507 | "runtime-raised", | |
| 508 | "interaction-raised", | |
| 509 | ] as const)("stops a persisted workflow with state %s", (state) => | |
| 510 | Effect.gen(function* () { | |
| 511 | const threadId = ThreadId.make("thread:workflow-stop"); | |
| 512 | const subagentId = NodeId.make("node:workflow-stop"); | |
| 513 | const runId = RunId.make("run:workflow-stop"); | |
| 514 | const providerThreadId = ProviderThreadId.make("provider-thread:workflow-stop"); | |
| 515 | const providerSessionId = ProviderSessionId.make("provider-session:workflow-stop"); | |
| 516 | const driver = ProviderDriverKind.make("claudeAgent"); | |
| 517 | const instanceId = ProviderInstanceId.make("claudeAgent"); | |
| 518 | const now = yield* DateTime.now; | |
| 519 | let targetModes: Pick<OrchestrationV2ThreadShell, "runtimeMode" | "interactionMode"> = { | |
| 520 | runtimeMode: "approval-required", | |
| 521 | interactionMode: "plan", | |
| 522 | }; | |
| 523 | const task: OrchestrationV2Subagent = { | |
| 524 | id: subagentId, | |
| 525 | threadId, | |
| 526 | runId, | |
| 527 | parentNodeId: NodeId.make("node:workflow-parent"), | |
| 528 | origin: "provider_native", | |
| 529 | createdBy: "agent", | |
| 530 | driver, | |
| 531 | providerInstanceId: instanceId, | |
| 532 | providerThreadId: null, | |
| 533 | childThreadId: null, | |
| 534 | nativeTaskRef: | |
| 535 | state === "missing-native-id" | |
| 536 | ? null | |
| 537 | : { | |
| 538 | driver, | |
| 539 | nativeId: "native-workflow-id", | |
| 540 | strength: "strong", | |
| 541 | }, | |
| 542 | workflow: { phases: [], agents: [] }, | |
| 543 | prompt: "Run the workflow", | |
| 544 | title: null, | |
| 545 | model: null, | |
| 546 | status: state === "completed" ? "completed" : "running", | |
| 547 | result: null, | |
| 548 | startedAt: now, | |
| 549 | completedAt: null, | |
| 550 | updatedAt: now, | |
| 551 | ...(state === "unsupported" ? { driver: ProviderDriverKind.make("codex") } : {}), | |
| 552 | }; | |
| 553 | const providerThread: OrchestrationV2ProviderThread = { | |
| 554 | id: providerThreadId, | |
| 555 | driver, | |
| 556 | providerInstanceId: instanceId, | |
| 557 | providerSessionId, | |
| 558 | appThreadId: state === "wrong-thread" ? ThreadId.make("other-thread") : threadId, | |
| 559 | ownerNodeId: null, | |
| 560 | nativeThreadRef: { driver, nativeId: "native-thread", strength: "strong" }, | |
| 561 | nativeConversationHeadRef: null, | |
| 562 | status: "idle", | |
| 563 | firstRunOrdinal: 1, | |
| 564 | lastRunOrdinal: 1, | |
| 565 | handoffIds: [], | |
| 566 | forkedFrom: null, | |
| 567 | pendingBackgroundTasks: [], | |
| 568 | contextUsage: null, | |
| 569 | nativeMetadata: null, | |
| 570 | createdAt: now, | |
| 571 | updatedAt: now, | |
| 572 | }; | |
| 573 | const calls: Array<{ taskId: string; providerThread: OrchestrationV2ProviderThread }> = []; | |
| 574 | const stopStarted = yield* Deferred.make<void>(); | |
| 575 | const runtime = { | |
| 576 | driver, | |
| 577 | providerSessionId, | |
| 578 | instanceId, | |
| 579 | ...(state === "unsupported-runtime" | |
| 580 | ? {} | |
| 581 | : { | |
| 582 | stopTask: (input: (typeof calls)[number]) => | |
| 583 | state === "provider-failed" | |
| 584 | ? Effect.fail( | |
| 585 | new ProviderAdapter.ProviderAdapterProtocolError({ | |
| 586 | driver, | |
| 587 | detail: "private provider detail", | |
| 588 | }), | |
| 589 | ) | |
| 590 | : state === "provider-timeout" | |
| 591 | ? Deferred.succeed(stopStarted, undefined).pipe(Effect.andThen(Effect.never)) | |
| 592 | : Effect.sync(() => { | |
| 593 | calls.push(input); | |
| 594 | }), | |
| 595 | }), | |
| 596 | } as unknown as ProviderAdapter.ProviderAdapterV2SessionRuntime; | |
| 597 | const layerTest = ThreadManagementService.layer.pipe( | |
| 598 | Layer.provide( | |
| 599 | Layer.mergeAll( | |
| 600 | Layer.mock(Orchestrator.OrchestratorV2)({ | |
| 601 | getThreadRecords: () => | |
| 602 | Effect.sync( | |
| 603 | () => | |
| 604 | ({ | |
| 605 | thread: { id: threadId, deletedAt: null, ...targetModes }, | |
| 606 | subagents: state === "missing" ? [] : [task], | |
| 607 | runs: [{ id: runId, providerThreadId }], | |
| 608 | providerThreads: [providerThread], | |
| 609 | }) as unknown as OrchestrationV2ThreadProjection, | |
| 610 | ), | |
| 611 | }), | |
| 612 | Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({ | |
| 613 | get: (id) => { | |
| 614 | expect(id).toBe(providerSessionId); | |
| 615 | return Effect.succeed(state === "no-session" ? Option.none() : Option.some(runtime)); | |
| 616 | }, | |
| 617 | }), | |
| 618 | ), | |
| 619 | ), | |
| 620 | ); | |
| 621 | yield* Effect.gen(function* () { | |
| 622 | const service = yield* ThreadManagementService.ThreadManagementService; | |
| 623 | const refused = yield* Ref.make<DispatchModeRefusal | undefined>(undefined); | |
| 624 | const stop = service | |
| 625 | .stopWorkflow({ threadId, subagentId }) | |
| 626 | .pipe( | |
| 627 | Effect.provideService( | |
| 628 | DispatchModeLimit, | |
| 629 | state === "within-limit" || state === "runtime-raised" || state === "interaction-raised" | |
| 630 | ? { runtimeMode: "approval-required", interactionMode: "plan", refused } | |
| 631 | : undefined, | |
| 632 | ), | |
| 633 | Effect.result, | |
| 634 | ); | |
| 635 | const executor = yield* ThreadCommandExecutor.ThreadCommandExecutor; | |
| 636 | const result = | |
| 637 | state === "runtime-raised" || state === "interaction-raised" | |
| 638 | ? yield* Effect.gen(function* () { | |
| 639 | const started = yield* Deferred.make<void>(); | |
| 640 | const pending = yield* executor.withLock( | |
| 641 | threadId, | |
| 642 | Effect.gen(function* () { | |
| 643 | const fiber = yield* Deferred.succeed(started, undefined).pipe( | |
| 644 | Effect.andThen(stop), | |
| 645 | Effect.forkChild, | |
| 646 | ); | |
| 647 | yield* Deferred.await(started); | |
| 648 | targetModes = | |
| 649 | state === "runtime-raised" | |
| 650 | ? { ...targetModes, runtimeMode: "full-access" } | |
| 651 | : { ...targetModes, interactionMode: "default" }; | |
| 652 | return fiber; | |
| 653 | }), | |
| 654 | ); | |
| 655 | return yield* Fiber.join(pending); | |
| 656 | }) | |
| 657 | : state === "provider-timeout" | |
| 658 | ? yield* Effect.gen(function* () { | |
| 659 | const finished = yield* Ref.make(false); | |
| 660 | const pending = yield* stop.pipe( | |
| 661 | Effect.tap(() => Ref.set(finished, true)), | |
| 662 | Effect.forkChild, | |
| 663 | ); | |
| 664 | yield* Deferred.await(stopStarted); | |
| 665 | yield* TestClock.adjust("15 seconds"); | |
| 666 | expect(yield* Ref.get(finished)).toBe(true); | |
| 667 | const result = yield* Fiber.join(pending); | |
| 668 | expect(yield* executor.withLock(threadId, Effect.succeed("lock-released"))).toBe( | |
| 669 | "lock-released", | |
| 670 | ); | |
| 671 | return result; | |
| 672 | }) | |
| 673 | : yield* stop; | |
| 674 | if (state === "running" || state === "within-limit") { | |
| 675 | expect(result).toMatchObject({ _tag: "Success" }); | |
| 676 | expect(calls).toEqual([{ taskId: "native-workflow-id", providerThread }]); | |
| 677 | expect(task.status).toBe("running"); | |
| 678 | } else { | |
| 679 | expect(result).toMatchObject({ | |
| 680 | _tag: "Failure", | |
| 681 | failure: { | |
| 682 | _tag: "OrchestrationV2StopWorkflowError", | |
| 683 | threadId, | |
| 684 | subagentId, | |
| 685 | reason: | |
| 686 | state === "missing" || state === "completed" | |
| 687 | ? "not-running" | |
| 688 | : state === "unsupported" || state === "unsupported-runtime" | |
| 689 | ? "unsupported" | |
| 690 | : state === "provider-failed" || state === "provider-timeout" | |
| 691 | ? "stop-failed" | |
| 692 | : "unavailable", | |
| 693 | }, | |
| 694 | }); | |
| 695 | expect(calls).toEqual([]); | |
| 696 | if (state === "provider-timeout") { | |
| 697 | expect(result).toMatchObject({ failure: { cause: { _tag: "TimeoutError" } } }); | |
| 698 | } | |
| 699 | if (state === "runtime-raised" || state === "interaction-raised") { | |
| 700 | expect(yield* Ref.get(refused)).toEqual({ | |
| 701 | threadId, | |
| 702 | ...targetModes, | |
| 703 | mode: state === "runtime-raised" ? "runtime" : "interaction", | |
| 704 | }); | |
| 705 | } | |
| 706 | } | |
| 707 | }).pipe(Effect.provide(Layer.merge(layerTest, ThreadCommandExecutor.layer))); | |
| 708 | }), | |
| 709 | ); | |
| 710 | ||
| 477 | 711 | it.effect.each([ |
| 478 | 712 | { status: "completed" as const, settles: true }, |
| 479 | 713 | { status: "failed" as const, settles: false }, |
| ... | ... | @@ -485,6 +719,7 @@ it.effect.each([ |
| 485 | 719 | const runId = RunId.make("run:thread-management:settle-after-run"); |
| 486 | 720 | const dispatched: Array<string> = []; |
| 487 | 721 | const layerTest = ThreadManagementService.layer.pipe( |
| 722 | Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})), | |
| 488 | 723 | Layer.provide( |
| 489 | 724 | Layer.mock(Orchestrator.OrchestratorV2)({ |
| 490 | 725 | getThreadEventSequence: () => Effect.succeed(0), |
apps/server/src/orchestration-v2/ThreadManagementService.ts+112-4| ... | ... | @@ -11,6 +11,8 @@ import { |
| 11 | 11 | type ChatAttachment, |
| 12 | 12 | CommandId, |
| 13 | 13 | MessageId, |
| 14 | OrchestrationV2StopWorkflowError, | |
| 15 | type OrchestrationV2StopWorkflowInput, | |
| 14 | 16 | type ModelSelection, |
| 15 | 17 | type OrchestrationV2Actor, |
| 16 | 18 | type OrchestrationV2Command, |
| ... | ... | @@ -35,10 +37,14 @@ import * as Duration from "effect/Duration"; |
| 35 | 37 | import * as Effect from "effect/Effect"; |
| 36 | 38 | import * as Layer from "effect/Layer"; |
| 37 | 39 | import * as Option from "effect/Option"; |
| 40 | import * as Ref from "effect/Ref"; | |
| 38 | 41 | import * as Schema from "effect/Schema"; |
| 39 | 42 | import * as Stream from "effect/Stream"; |
| 40 | 43 | |
| 41 | 44 | import * as Orchestrator from "./Orchestrator.ts"; |
| 45 | import * as ProviderSessionManager from "./ProviderSessionManager.ts"; | |
| 46 | import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; | |
| 47 | import { DispatchModeLimit, exceededDispatchModeLimit } from "./DispatchModeLimit.ts"; | |
| 42 | 48 | import { projectTurnItemForDetail } from "./WireProjection.ts"; |
| 43 | 49 | import * as LegacyV1ThreadImporter from "./legacy/LegacyV1ThreadImporter.ts"; |
| 44 | 50 | |
| ... | ... | @@ -364,6 +370,13 @@ export interface ThreadManagementServiceShape { |
| 364 | 370 | readonly interruptThread: ( |
| 365 | 371 | input: ThreadManagementInterruptInput, |
| 366 | 372 | ) => Effect.Effect<ThreadManagementInterruptResult, ThreadManagementFailure>; |
| 373 | /** | |
| 374 | * Asks the provider to stop a running workflow coordinator. The provider's | |
| 375 | * own terminal notification then settles the coordinator and its members. | |
| 376 | */ | |
| 377 | readonly stopWorkflow: ( | |
| 378 | input: OrchestrationV2StopWorkflowInput, | |
| 379 | ) => Effect.Effect<void, OrchestrationV2StopWorkflowError>; | |
| 367 | 380 | /** |
| 368 | 381 | * Sends `thread.stop` to every delegated task under a thread, depth first. Their command IDs |
| 369 | 382 | * derive from `commandId`, so a retry repeats nothing that already stopped. A task that |
| ... | ... | @@ -440,7 +453,9 @@ function latestSteerableRun( |
| 440 | 453 | const SETTLE_AFTER_RUN_WAIT_MS = 24 * 60 * 60 * 1_000; |
| 441 | 454 | |
| 442 | 455 | const make = Effect.gen(function* () { |
| 456 | const threadDispatch = yield* ThreadCommandExecutor.ThreadCommandExecutor; | |
| 443 | 457 | const orchestrator = yield* Orchestrator.OrchestratorV2; |
| 458 | const sessions = yield* ProviderSessionManager.ProviderSessionManagerV2; | |
| 444 | 459 | const layerScope = yield* Effect.scope; |
| 445 | 460 | const legacyImporter = yield* LegacyV1ThreadImporter.LegacyV1ThreadImporter; |
| 446 | 461 | |
| ... | ... | @@ -841,6 +856,90 @@ const make = Effect.gen(function* () { |
| 841 | 856 | return { type: "interrupt_requested", run: interruptibleRun, dispatch } as const; |
| 842 | 857 | }); |
| 843 | 858 | |
| 859 | const stopWorkflow: ThreadManagementServiceShape["stopWorkflow"] = (input) => | |
| 860 | Effect.gen(function* () { | |
| 861 | const records = yield* orchestrator | |
| 862 | .getThreadRecords(input.threadId, ["subagents", "runs", "providerThreads"]) | |
| 863 | .pipe( | |
| 864 | Effect.mapError( | |
| 865 | (cause) => | |
| 866 | new OrchestrationV2StopWorkflowError({ ...input, reason: "unavailable", cause }), | |
| 867 | ), | |
| 868 | ); | |
| 869 | if (records.thread.deletedAt !== null) { | |
| 870 | return yield* new OrchestrationV2StopWorkflowError({ ...input, reason: "unavailable" }); | |
| 871 | } | |
| 872 | // A limited caller (an agent over MCP) may not stop work on a thread | |
| 873 | // running above its own modes. Checked under the thread lock, where the | |
| 874 | // thread's user cannot raise the modes between this read and the stop. | |
| 875 | const limit = yield* DispatchModeLimit; | |
| 876 | const exceeded = | |
| 877 | limit === undefined ? undefined : exceededDispatchModeLimit(limit, records.thread); | |
| 878 | if (limit !== undefined && exceeded !== undefined) { | |
| 879 | if (limit.refused !== undefined) { | |
| 880 | yield* Ref.set(limit.refused, { | |
| 881 | threadId: input.threadId, | |
| 882 | runtimeMode: records.thread.runtimeMode, | |
| 883 | interactionMode: records.thread.interactionMode, | |
| 884 | mode: exceeded, | |
| 885 | }); | |
| 886 | } | |
| 887 | return yield* new OrchestrationV2StopWorkflowError({ ...input, reason: "unavailable" }); | |
| 888 | } | |
| 889 | const task = records.subagents.find((candidate) => candidate.id === input.subagentId); | |
| 890 | if ( | |
| 891 | task === undefined || | |
| 892 | task.threadId !== input.threadId || | |
| 893 | task.workflow === undefined || | |
| 894 | task.status !== "running" | |
| 895 | ) { | |
| 896 | return yield* new OrchestrationV2StopWorkflowError({ ...input, reason: "not-running" }); | |
| 897 | } | |
| 898 | if (task.origin !== "provider_native" || task.driver !== "claudeAgent") { | |
| 899 | return yield* new OrchestrationV2StopWorkflowError({ ...input, reason: "unsupported" }); | |
| 900 | } | |
| 901 | const providerThreadId = | |
| 902 | task.providerThreadId ?? | |
| 903 | records.runs.find((run) => run.id === task.runId)?.providerThreadId; | |
| 904 | const providerThread = records.providerThreads.find( | |
| 905 | (candidate) => | |
| 906 | candidate.id === providerThreadId && | |
| 907 | candidate.appThreadId === input.threadId && | |
| 908 | candidate.providerInstanceId === task.providerInstanceId && | |
| 909 | candidate.driver === task.driver, | |
| 910 | ); | |
| 911 | const taskId = task.nativeTaskRef?.nativeId; | |
| 912 | if (providerThread?.providerSessionId == null || taskId == null || taskId.length === 0) { | |
| 913 | return yield* new OrchestrationV2StopWorkflowError({ ...input, reason: "unavailable" }); | |
| 914 | } | |
| 915 | const runtime = yield* sessions | |
| 916 | .get(providerThread.providerSessionId) | |
| 917 | .pipe( | |
| 918 | Effect.mapError( | |
| 919 | (cause) => | |
| 920 | new OrchestrationV2StopWorkflowError({ ...input, reason: "unavailable", cause }), | |
| 921 | ), | |
| 922 | ); | |
| 923 | if (Option.isNone(runtime)) { | |
| 924 | return yield* new OrchestrationV2StopWorkflowError({ ...input, reason: "unavailable" }); | |
| 925 | } | |
| 926 | if (runtime.value.stopTask === undefined) { | |
| 927 | return yield* new OrchestrationV2StopWorkflowError({ ...input, reason: "unsupported" }); | |
| 928 | } | |
| 929 | // Bounded so a provider that never answers cannot hold the thread lock. | |
| 930 | yield* runtime.value.stopTask({ providerThread, taskId }).pipe( | |
| 931 | Effect.timeout("15 seconds"), | |
| 932 | Effect.mapError( | |
| 933 | (cause) => | |
| 934 | new OrchestrationV2StopWorkflowError({ | |
| 935 | ...input, | |
| 936 | reason: "stop-failed", | |
| 937 | cause, | |
| 938 | }), | |
| 939 | ), | |
| 940 | ); | |
| 941 | }).pipe((effect) => threadDispatch.withLock(input.threadId, effect)); | |
| 942 | ||
| 844 | 943 | const stopDelegatedTasks: ThreadManagementServiceShape["stopDelegatedTasks"] = (input) => |
| 845 | 944 | Effect.gen(function* () { |
| 846 | 945 | const { subagents } = yield* orchestrator.getThreadRecords(input.threadId, ["subagents"]); |
| ... | ... | @@ -919,6 +1018,7 @@ const make = Effect.gen(function* () { |
| 919 | 1018 | settleThread, |
| 920 | 1019 | interruptThread, |
| 921 | 1020 | stopDelegatedTasks, |
| 1021 | stopWorkflow, | |
| 922 | 1022 | getThreadEventSequence: orchestrator.getThreadEventSequence, |
| 923 | 1023 | recoverDelegatedTask: orchestrator.recoverDelegatedTask, |
| 924 | 1024 | delegatedTaskResultPending: orchestrator.delegatedTaskResultPending, |
| ... | ... | @@ -938,11 +1038,19 @@ const layerLegacyV1ThreadImporterNoop = Layer.succeed( |
| 938 | 1038 | }), |
| 939 | 1039 | ); |
| 940 | 1040 | |
| 941 | export const layer: Layer.Layer<ThreadManagementService, never, Orchestrator.OrchestratorV2> = | |
| 942 | Layer.effect(ThreadManagementService, make).pipe(Layer.provide(layerLegacyV1ThreadImporterNoop)); | |
| 1041 | export const layer: Layer.Layer< | |
| 1042 | ThreadManagementService, | |
| 1043 | never, | |
| 1044 | Orchestrator.OrchestratorV2 | ProviderSessionManager.ProviderSessionManagerV2 | |
| 1045 | > = Layer.effect(ThreadManagementService, make).pipe( | |
| 1046 | Layer.provide(layerLegacyV1ThreadImporterNoop), | |
| 1047 | Layer.provide(ThreadCommandExecutor.layer), | |
| 1048 | ); | |
| 943 | 1049 | |
| 944 | 1050 | export const layerWithLegacyImporter: Layer.Layer< |
| 945 | 1051 | ThreadManagementService, |
| 946 | 1052 | never, |
| 947 | LegacyV1ThreadImporter.LegacyV1ThreadImporter | Orchestrator.OrchestratorV2 | |
| 948 | > = Layer.effect(ThreadManagementService, make); | |
| 1053 | | LegacyV1ThreadImporter.LegacyV1ThreadImporter | |
| 1054 | | Orchestrator.OrchestratorV2 | |
| 1055 | | ProviderSessionManager.ProviderSessionManagerV2 | |
| 1056 | > = Layer.effect(ThreadManagementService, make).pipe(Layer.provide(ThreadCommandExecutor.layer)); |
apps/server/src/orchestration-v2/runtimeLayer.ts+7-1| ... | ... | @@ -240,7 +240,13 @@ const layerAgentSessionImporterProvided = AgentSessionImporter.layer.pipe( |
| 240 | 240 | ); |
| 241 | 241 | |
| 242 | 242 | const layerThreadManagementProvided = ThreadManagementService.layerWithLegacyImporter.pipe( |
| 243 | Layer.provide(Layer.merge(layerOrchestratorProvided, layerLegacyV1ThreadImporterProvided)), | |
| 243 | Layer.provide( | |
| 244 | Layer.mergeAll( | |
| 245 | layerOrchestratorProvided, | |
| 246 | layerLegacyV1ThreadImporterProvided, | |
| 247 | layerProviderSessionManagerProvided, | |
| 248 | ), | |
| 249 | ), | |
| 244 | 250 | ); |
| 245 | 251 | export const layerProjectSetupScriptRunner = ProjectSetupScriptRunner.layer.pipe( |
| 246 | 252 | Layer.provide(layerProjectService), |
apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts+4-1| ... | ... | @@ -242,7 +242,10 @@ export function layerProviderReplay<Transcript extends ProviderReplayTranscript, |
| 242 | 242 | readonly replayGate?: ProviderReplayGate; |
| 243 | 243 | } = {}, |
| 244 | 244 | ): Layer.Layer< |
| 245 | Orchestrator.OrchestratorV2 | EffectWorker.OrchestrationEffectWorkerV2 | EventSink.EventSinkV2, | |
| 245 | | Orchestrator.OrchestratorV2 | |
| 246 | | EffectWorker.OrchestrationEffectWorkerV2 | |
| 247 | | EventSink.EventSinkV2 | |
| 248 | | ProviderSessionManager.ProviderSessionManagerV2, | |
| 246 | 249 | Error | MigrationError | PlatformError.PlatformError | SqlError |
| 247 | 250 | > { |
| 248 | 251 | const layerRegistry = harness.makeProviderAdapterRegistryLayer( |
apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2_approval/codex_output.ts+14| ... | ... | @@ -59,6 +59,20 @@ export function assertSubagentV2ApprovalOutput( |
| 59 | 59 | const approval = approvalItems[0]!; |
| 60 | 60 | assert.equal(approval.runId, run.id); |
| 61 | 61 | assert.equal(approval.status, "completed"); |
| 62 | const pendingRequestIndex = result.domainEvents.findIndex( | |
| 63 | (event) => | |
| 64 | event.type === "runtime-request.updated" && | |
| 65 | event.payload.id === request.id && | |
| 66 | event.payload.status === "pending", | |
| 67 | ); | |
| 68 | const waitingItemIndex = result.domainEvents.findIndex( | |
| 69 | (event) => | |
| 70 | event.type === "turn-item.updated" && | |
| 71 | event.payload.id === approval.id && | |
| 72 | event.payload.status === "waiting", | |
| 73 | ); | |
| 74 | assert.isAtLeast(waitingItemIndex, 0); | |
| 75 | assert.isAbove(pendingRequestIndex, waitingItemIndex); | |
| 62 | 76 | if (approval.type !== "approval_request") throw new Error("expected an approval item"); |
| 63 | 77 | assert.equal(approval.requestId, request.id); |
| 64 | 78 | assert.include(approval.prompt ?? "", "subagent-approval.txt"); |
apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2_nested_approval/codex_output.ts+14| ... | ... | @@ -72,6 +72,20 @@ export function assertSubagentV2NestedApprovalOutput( |
| 72 | 72 | const approval = approvalItems[0]!; |
| 73 | 73 | assert.equal(approval.runId, run.id); |
| 74 | 74 | assert.equal(approval.status, "completed"); |
| 75 | const pendingRequestIndex = result.domainEvents.findIndex( | |
| 76 | (event) => | |
| 77 | event.type === "runtime-request.updated" && | |
| 78 | event.payload.id === request.id && | |
| 79 | event.payload.status === "pending", | |
| 80 | ); | |
| 81 | const waitingItemIndex = result.domainEvents.findIndex( | |
| 82 | (event) => | |
| 83 | event.type === "turn-item.updated" && | |
| 84 | event.payload.id === approval.id && | |
| 85 | event.payload.status === "waiting", | |
| 86 | ); | |
| 87 | assert.isAtLeast(waitingItemIndex, 0); | |
| 88 | assert.isAbove(pendingRequestIndex, waitingItemIndex); | |
| 75 | 89 | if (approval.type !== "approval_request") throw new Error("expected an approval item"); |
| 76 | 90 | assert.equal(approval.requestId, request.id); |
| 77 | 91 | assert.include(approval.prompt ?? "", "nested-approval.txt"); |
apps/server/src/orchestration-v2/unknownField.ts created+10| ... | ... | @@ -0,0 +1,10 @@ |
| 1 | /** Readers for decoding undeclared provider payloads without trusting shapes. */ | |
| 2 | export function field(source: unknown, key: string): unknown { | |
| 3 | return typeof source === "object" && source !== null ? Reflect.get(source, key) : undefined; | |
| 4 | } | |
| 5 | ||
| 6 | export function text(value: unknown): string | undefined { | |
| 7 | if (typeof value !== "string") return undefined; | |
| 8 | const trimmed = value.trim(); | |
| 9 | return trimmed.length === 0 ? undefined : trimmed; | |
| 10 | } |
apps/server/src/orchestration-v2/workflowAgentAnswers.test.ts created+121| ... | ... | @@ -0,0 +1,121 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off | |
| 2 | import * as NodeFS from "node:fs"; | |
| 3 | import * as NodeOS from "node:os"; | |
| 4 | import * as NodePath from "node:path"; | |
| 5 | import { it as effectIt } from "@effect/vitest"; | |
| 6 | import * as Effect from "effect/Effect"; | |
| 7 | import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test"; | |
| 8 | ||
| 9 | import { parseWorkflowAgentAnswers, readWorkflowAgentAnswers } from "./workflowAgentAnswers.ts"; | |
| 10 | ||
| 11 | const line = (value: unknown) => `${JSON.stringify(value)}\n`; | |
| 12 | ||
| 13 | const assistant = (id: string, content: ReadonlyArray<unknown>) => | |
| 14 | line({ type: "assistant", message: { role: "assistant", id, content } }); | |
| 15 | ||
| 16 | describe("parseWorkflowAgentAnswers", () => { | |
| 17 | it("returns one entry per assistant turn", () => { | |
| 18 | expect( | |
| 19 | parseWorkflowAgentAnswers( | |
| 20 | assistant("a", [{ type: "text", text: "first" }]) + | |
| 21 | assistant("b", [{ type: "text", text: "second" }]), | |
| 22 | ), | |
| 23 | ).toEqual(["first", "second"]); | |
| 24 | }); | |
| 25 | ||
| 26 | it("joins the lines of one split assistant message into a single turn", () => { | |
| 27 | expect( | |
| 28 | parseWorkflowAgentAnswers( | |
| 29 | assistant("a", [{ type: "thinking", thinking: "weighing it up" }]) + | |
| 30 | assistant("a", [{ type: "text", text: "the answer" }]) + | |
| 31 | assistant("a", [{ type: "text", text: "and more" }]), | |
| 32 | ), | |
| 33 | ).toEqual(["the answer\n\nand more"]); | |
| 34 | }); | |
| 35 | ||
| 36 | it("ignores harness bookkeeping and the member's own prompt", () => { | |
| 37 | expect( | |
| 38 | parseWorkflowAgentAnswers( | |
| 39 | line({ type: "attachment", attachment: { type: "skill_listing", content: "- adhd" } }) + | |
| 40 | line({ type: "user", message: { role: "user", content: "the prompt" } }) + | |
| 41 | line({ | |
| 42 | type: "assistant", | |
| 43 | isMeta: true, | |
| 44 | message: { role: "assistant", id: "m", content: [{ type: "text", text: "injected" }] }, | |
| 45 | }) + | |
| 46 | assistant("a", [{ type: "text", text: "real answer" }]), | |
| 47 | ), | |
| 48 | ).toEqual(["real answer"]); | |
| 49 | }); | |
| 50 | ||
| 51 | it("skips a turn that produced no text", () => { | |
| 52 | expect( | |
| 53 | parseWorkflowAgentAnswers( | |
| 54 | assistant("a", [{ type: "tool_use", id: "t", name: "Bash", input: {} }]), | |
| 55 | ), | |
| 56 | ).toEqual([]); | |
| 57 | }); | |
| 58 | ||
| 59 | it("preserves answer indentation and ignores blank text", () => { | |
| 60 | expect( | |
| 61 | parseWorkflowAgentAnswers( | |
| 62 | assistant("a", [{ type: "text", text: " indented code" }]) + | |
| 63 | assistant("b", [{ type: "text", text: " " }]), | |
| 64 | ), | |
| 65 | ).toEqual([" indented code"]); | |
| 66 | }); | |
| 67 | ||
| 68 | it("survives a trailing line the byte cap cut in half", () => { | |
| 69 | expect( | |
| 70 | parseWorkflowAgentAnswers( | |
| 71 | assistant("a", [{ type: "text", text: "complete" }]) + '{"type":"assistant","mess', | |
| 72 | ), | |
| 73 | ).toEqual(["complete"]); | |
| 74 | }); | |
| 75 | }); | |
| 76 | ||
| 77 | // Transcripts must sit under <configDir>/projects: readContainedWorkflowFile | |
| 78 | // rejects any path outside it. A temporary configDir keeps the real | |
| 79 | // ~/.claude untouched. | |
| 80 | let configDir: string; | |
| 81 | let transcriptDir: string; | |
| 82 | beforeAll(() => { | |
| 83 | configDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "wf-config-")); | |
| 84 | transcriptDir = NodePath.join(configDir, "projects", "test"); | |
| 85 | NodeFS.mkdirSync(transcriptDir, { recursive: true }); | |
| 86 | }); | |
| 87 | ||
| 88 | afterAll(() => { | |
| 89 | NodeFS.rmSync(configDir, { recursive: true, force: true }); | |
| 90 | }); | |
| 91 | ||
| 92 | describe("readWorkflowAgentAnswers", () => { | |
| 93 | // The under-cap case is the control: without it a read stuck on [] would pass. | |
| 94 | effectIt.effect("reads final answers from both small and capped transcripts", () => | |
| 95 | Effect.gen(function* () { | |
| 96 | const turn = assistant("a", [{ type: "text", text: "the answer" }]); | |
| 97 | const filler = line({ type: "user", message: { role: "user", content: "x".repeat(4096) } }); | |
| 98 | const write = (agentId: string, contents: string) => | |
| 99 | NodeFS.writeFileSync(NodePath.join(transcriptDir, `agent-${agentId}.jsonl`), contents); | |
| 100 | const read = (agentId: string) => | |
| 101 | readWorkflowAgentAnswers({ transcriptDir, agentId, configDir }); | |
| 102 | write("small", turn); | |
| 103 | const edgeAnswer = "x".repeat( | |
| 104 | 512 * 1024 + 1 - Buffer.byteLength(assistant("edge", [{ type: "text", text: "" }])), | |
| 105 | ); | |
| 106 | write("edge", assistant("edge", [{ type: "text", text: edgeAnswer }])); | |
| 107 | write( | |
| 108 | "boundary", | |
| 109 | filler.repeat(160) + turn + " ".repeat(512 * 1024 - Buffer.byteLength(turn)), | |
| 110 | ); | |
| 111 | write( | |
| 112 | "huge", | |
| 113 | turn + filler.repeat(160) + assistant("final", [{ type: "text", text: "final answer" }]), | |
| 114 | ); | |
| 115 | expect(yield* read("small")).toEqual(["the answer"]); | |
| 116 | expect(yield* read("edge")).toEqual([edgeAnswer]); | |
| 117 | expect(yield* read("huge")).toEqual(["final answer"]); | |
| 118 | expect(yield* read("boundary")).toEqual(["the answer"]); | |
| 119 | }), | |
| 120 | ); | |
| 121 | }); |
apps/server/src/orchestration-v2/workflowAgentAnswers.ts created+97| ... | ... | @@ -0,0 +1,97 @@ |
| 1 | // Path joining only; the filesystem access lives in readContainedWorkflowFile. | |
| 2 | // @effect-diagnostics nodeBuiltinImport:off | |
| 3 | /** | |
| 4 | * Reads what a workflow member actually answered. | |
| 5 | * | |
| 6 | * A member agent never becomes a provider task and emits nothing on the event | |
| 7 | * stream, so the transcript the harness writes is the only full record of its | |
| 8 | * turn — the progress snapshot carries a capped excerpt. The path is derived | |
| 9 | * here from the run's transcript directory and the member's provider id rather | |
| 10 | * than accepted from the caller, and the read itself is contained by | |
| 11 | * readContainedWorkflowFile. | |
| 12 | */ | |
| 13 | import * as NodePath from "node:path"; | |
| 14 | ||
| 15 | import { OrchestrationWorkflowFileError } from "@t3tools/contracts"; | |
| 16 | import * as Effect from "effect/Effect"; | |
| 17 | ||
| 18 | import { field, text } from "./unknownField.ts"; | |
| 19 | import { readContainedWorkflowFile } from "./workflowFileRead.ts"; | |
| 20 | ||
| 21 | const TRANSCRIPT_BYTE_CAP = 512 * 1024; | |
| 22 | ||
| 23 | /** A member id names a file, so anything path-shaped is rejected outright. */ | |
| 24 | const AGENT_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/; | |
| 25 | ||
| 26 | /** | |
| 27 | * Collects the assistant text of each turn in the harness's JSONL. | |
| 28 | * | |
| 29 | * Two shapes have to be handled: `attachment` lines and `isMeta` user lines are | |
| 30 | * harness bookkeeping (hook output, skill listings, injected context) and are | |
| 31 | * never conversation, and one assistant API message is split across several | |
| 32 | * lines sharing a `message.id`, so those are joined into a single turn rather | |
| 33 | * than read as separate ones. | |
| 34 | */ | |
| 35 | export function parseWorkflowAgentAnswers(contents: string): ReadonlyArray<string> { | |
| 36 | const turns: string[] = []; | |
| 37 | const turnIndexById = new Map<string, number>(); | |
| 38 | ||
| 39 | for (const line of contents.split("\n")) { | |
| 40 | if (line.trim().length === 0) continue; | |
| 41 | let entry: unknown; | |
| 42 | try { | |
| 43 | entry = JSON.parse(line); | |
| 44 | } catch { | |
| 45 | // A capped read can cut the final line mid-object; drop it and keep the rest. | |
| 46 | continue; | |
| 47 | } | |
| 48 | if (text(field(entry, "type")) !== "assistant") continue; | |
| 49 | if (field(entry, "isMeta") === true) continue; | |
| 50 | ||
| 51 | const message = field(entry, "message"); | |
| 52 | const content = field(message, "content"); | |
| 53 | if (!Array.isArray(content)) continue; | |
| 54 | const body = content | |
| 55 | .filter((block) => text(field(block, "type")) === "text") | |
| 56 | .map((block) => field(block, "text")) | |
| 57 | .filter((value): value is string => typeof value === "string" && value.trim().length > 0) | |
| 58 | .join("\n\n"); | |
| 59 | if (body.length === 0) continue; | |
| 60 | ||
| 61 | const messageId = text(field(message, "id")); | |
| 62 | const existing = messageId === undefined ? undefined : turnIndexById.get(messageId); | |
| 63 | if (existing !== undefined) { | |
| 64 | turns[existing] = `${turns[existing]}\n\n${body}`; | |
| 65 | continue; | |
| 66 | } | |
| 67 | if (messageId !== undefined) turnIndexById.set(messageId, turns.length); | |
| 68 | turns.push(body); | |
| 69 | } | |
| 70 | ||
| 71 | return turns; | |
| 72 | } | |
| 73 | ||
| 74 | export const readWorkflowAgentAnswers = Effect.fn("orchestration.readWorkflowAgentAnswers")( | |
| 75 | function* (input: { | |
| 76 | readonly transcriptDir: string; | |
| 77 | readonly agentId: string; | |
| 78 | readonly configDir?: string; | |
| 79 | }) { | |
| 80 | if (!AGENT_ID_PATTERN.test(input.agentId)) { | |
| 81 | return yield* new OrchestrationWorkflowFileError({ | |
| 82 | reason: "invalid-path", | |
| 83 | path: input.agentId, | |
| 84 | }); | |
| 85 | } | |
| 86 | const file = yield* readContainedWorkflowFile({ | |
| 87 | path: NodePath.join(input.transcriptDir, `agent-${input.agentId}.jsonl`), | |
| 88 | extension: ".jsonl", | |
| 89 | byteCap: TRANSCRIPT_BYTE_CAP, | |
| 90 | tail: true, | |
| 91 | ...(input.configDir === undefined ? {} : { configDir: input.configDir }), | |
| 92 | }); | |
| 93 | return parseWorkflowAgentAnswers( | |
| 94 | file.truncated ? file.contents.slice(file.contents.indexOf("\n") + 1) : file.contents, | |
| 95 | ); | |
| 96 | }, | |
| 97 | ); |
apps/server/src/orchestration-v2/workflowFileRead.ts created+112| ... | ... | @@ -0,0 +1,112 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off | |
| 2 | /** | |
| 3 | * Contained reads of files the Claude harness persisted for a workflow run. | |
| 4 | * | |
| 5 | * Containment rules (lifted from the reviewed #3650 inspection service): | |
| 6 | * - the resolved realpath must live under the configured Claude projects | |
| 7 | * directory (where the harness persists workflow scripts and transcripts) — | |
| 8 | * realpath re-containment defeats symlink escapes, including a symlinked | |
| 9 | * leaf file; | |
| 10 | * - only the caller's expected extension is served; | |
| 11 | * - reads are size-capped rather than failed, with a truncation marker. | |
| 12 | * | |
| 13 | * The client-supplied path is a hint from the workflow's runHandles; it is | |
| 14 | * never trusted beyond these checks. | |
| 15 | */ | |
| 16 | import * as NodeFS from "node:fs"; | |
| 17 | import * as NodeFSP from "node:fs/promises"; | |
| 18 | import * as NodePath from "node:path"; | |
| 19 | ||
| 20 | import { OrchestrationWorkflowFileError } from "@t3tools/contracts"; | |
| 21 | import * as Effect from "effect/Effect"; | |
| 22 | import * as HostProcess from "@t3tools/shared/HostProcess"; | |
| 23 | ||
| 24 | function workflowFilesRoot(home: string, configDir?: string): string { | |
| 25 | return NodePath.join(configDir?.trim() || NodePath.join(home, ".claude"), "projects"); | |
| 26 | } | |
| 27 | ||
| 28 | export const readContainedWorkflowFile = Effect.fn("orchestration.readContainedWorkflowFile")( | |
| 29 | function* (input: { | |
| 30 | readonly path: string; | |
| 31 | readonly extension: string; | |
| 32 | readonly byteCap: number; | |
| 33 | readonly tail?: boolean; | |
| 34 | readonly configDir?: string; | |
| 35 | }) { | |
| 36 | if (!NodePath.isAbsolute(input.path) || NodePath.extname(input.path) !== input.extension) { | |
| 37 | return yield* new OrchestrationWorkflowFileError({ | |
| 38 | reason: "invalid-path", | |
| 39 | path: input.path, | |
| 40 | }); | |
| 41 | } | |
| 42 | ||
| 43 | const home = yield* HostProcess.HomeDirectory; | |
| 44 | const root = yield* Effect.tryPromise({ | |
| 45 | try: () => NodeFSP.realpath(workflowFilesRoot(home, input.configDir)), | |
| 46 | catch: (cause) => | |
| 47 | new OrchestrationWorkflowFileError({ | |
| 48 | reason: "root-unavailable", | |
| 49 | path: input.path, | |
| 50 | cause, | |
| 51 | }), | |
| 52 | }); | |
| 53 | ||
| 54 | // Realpath the FILE itself (not just its directory): a symlink named | |
| 55 | // like a transcript inside a contained directory must not escape. | |
| 56 | const resolved = yield* Effect.tryPromise({ | |
| 57 | try: () => NodeFSP.realpath(input.path), | |
| 58 | catch: (cause) => | |
| 59 | new OrchestrationWorkflowFileError({ reason: "not-found", path: input.path, cause }), | |
| 60 | }); | |
| 61 | ||
| 62 | if (resolved !== root && !resolved.startsWith(`${root}${NodePath.sep}`)) { | |
| 63 | return yield* new OrchestrationWorkflowFileError({ reason: "outside-root", path: resolved }); | |
| 64 | } | |
| 65 | if (NodePath.extname(resolved) !== input.extension) { | |
| 66 | return yield* new OrchestrationWorkflowFileError({ | |
| 67 | reason: "wrong-extension", | |
| 68 | path: resolved, | |
| 69 | }); | |
| 70 | } | |
| 71 | ||
| 72 | // fd inode vs path inode catches a swapped leaf, not a swapped intermediate | |
| 73 | // dir; root is 0700 ~/.claude, so that already needs the uid we run as. | |
| 74 | // Both containment checks fail with their own tagged reason; "read-failed" | |
| 75 | // is reserved for genuine platform failures with the real cause attached. | |
| 76 | const read = yield* Effect.tryPromise({ | |
| 77 | try: async () => { | |
| 78 | const handle = await NodeFSP.open( | |
| 79 | resolved, | |
| 80 | NodeFS.constants.O_RDONLY | (NodeFS.constants.O_NONBLOCK ?? 0), | |
| 81 | ); | |
| 82 | try { | |
| 83 | const stat = await handle.stat(); | |
| 84 | if (!stat.isFile()) { | |
| 85 | return { failure: "not-regular-file" as const }; | |
| 86 | } | |
| 87 | const pathStat = await NodeFSP.lstat(resolved); | |
| 88 | if (stat.ino !== pathStat.ino || stat.dev !== pathStat.dev) { | |
| 89 | return { failure: "changed-during-read" as const }; | |
| 90 | } | |
| 91 | const buffer = Buffer.alloc(Math.min(stat.size, input.byteCap + (input.tail ? 1 : 0))); | |
| 92 | const truncated = stat.size > buffer.length; | |
| 93 | const offset = input.tail ? stat.size - buffer.length : 0; | |
| 94 | const { bytesRead } = await handle.read(buffer, 0, buffer.length, offset); | |
| 95 | return { | |
| 96 | contents: buffer.subarray(0, bytesRead).toString("utf8"), | |
| 97 | truncated, | |
| 98 | }; | |
| 99 | } finally { | |
| 100 | await handle.close(); | |
| 101 | } | |
| 102 | }, | |
| 103 | catch: (cause) => | |
| 104 | new OrchestrationWorkflowFileError({ reason: "read-failed", path: resolved, cause }), | |
| 105 | }); | |
| 106 | if ("failure" in read) { | |
| 107 | return yield* new OrchestrationWorkflowFileError({ reason: read.failure, path: resolved }); | |
| 108 | } | |
| 109 | ||
| 110 | return { path: resolved, contents: read.contents, truncated: read.truncated }; | |
| 111 | }, | |
| 112 | ); |
apps/server/src/orchestration-v2/workflowScriptQuery.test.ts+21-1| ... | ... | @@ -2,6 +2,9 @@ |
| 2 | 2 | import * as NodeFS from "node:fs"; |
| 3 | 3 | import * as NodeOS from "node:os"; |
| 4 | 4 | import * as NodePath from "node:path"; |
| 5 | import * as NodeChildProcess from "node:child_process"; | |
| 6 | import { OrchestrationWorkflowFileError } from "@t3tools/contracts"; | |
| 7 | import * as HostProcess from "@t3tools/shared/HostProcess"; | |
| 5 | 8 | import { it as effectIt } from "@effect/vitest"; |
| 6 | 9 | import * as Effect from "effect/Effect"; |
| 7 | 10 | import { afterAll, assert, describe } from "vite-plus/test"; |
| ... | ... | @@ -30,7 +33,18 @@ afterAll(() => { |
| 30 | 33 | NodeFS.rmSync(outside, { force: true }); |
| 31 | 34 | }); |
| 32 | 35 | |
| 33 | describe("readWorkflowScript containment", () => { | |
| 36 | describe("workflow script containment", () => { | |
| 37 | effectIt.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( | |
| 38 | "rejects a FIFO without waiting for a writer", | |
| 39 | () => | |
| 40 | Effect.gen(function* () { | |
| 41 | const fifo = NodePath.join(root, "pipe.js"); | |
| 42 | NodeChildProcess.execFileSync("mkfifo", [fifo]); | |
| 43 | const error = yield* Effect.flip(readWorkflowScript({ scriptPath: fifo })); | |
| 44 | assert.equal(error.reason, "not-regular-file"); | |
| 45 | }), | |
| 46 | ); | |
| 47 | ||
| 34 | 48 | effectIt.effect("serves a real script under the projects root", () => |
| 35 | 49 | Effect.gen(function* () { |
| 36 | 50 | const result = yield* readWorkflowScript({ scriptPath }); |
| ... | ... | @@ -47,6 +61,12 @@ describe("readWorkflowScript containment", () => { |
| 47 | 61 | readWorkflowScript({ scriptPath: scriptPath.replace(".js", ".ts") }), |
| 48 | 62 | ); |
| 49 | 63 | assert.equal(nonJs._tag, "Failure"); |
| 64 | const error = yield* Effect.flip(readWorkflowScript({ scriptPath: "run.js" })); | |
| 65 | assert.equal(error._tag, "OrchestrationGetWorkflowScriptError"); | |
| 66 | assert.equal(error.scriptPath, "run.js"); | |
| 67 | assert.equal(error.reason, "invalid-path"); | |
| 68 | assert.equal(error.message, "Workflow scripts must be absolute .js paths."); | |
| 69 | assert.instanceOf(error.cause, OrchestrationWorkflowFileError); | |
| 50 | 70 | }), |
| 51 | 71 | ); |
| 52 | 72 |
apps/server/src/orchestration-v2/workflowScriptQuery.ts+16-121| ... | ... | @@ -1,128 +1,23 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off | |
| 2 | /** | |
| 3 | * Read-only access to persisted workflow scripts for the Agents surface's | |
| 4 | * "{} script" affordance. | |
| 5 | * | |
| 6 | * Containment rules (lifted from the reviewed #3650 inspection service): | |
| 7 | * - the resolved realpath must live under ~/.claude/projects (where the | |
| 8 | * Claude harness persists workflow scripts) — realpath re-containment | |
| 9 | * defeats symlink escapes, including a symlinked leaf file; | |
| 10 | * - only .js leaf files are served; | |
| 11 | * - reads are size-capped rather than failed, with a truncation marker. | |
| 12 | * | |
| 13 | * The client-supplied path is a hint from the workflow's runHandles; it is | |
| 14 | * never trusted beyond these checks. | |
| 15 | */ | |
| 16 | import * as NodeFSP from "node:fs/promises"; | |
| 17 | import * as NodePath from "node:path"; | |
| 18 | ||
| 19 | 1 | import { OrchestrationGetWorkflowScriptError } from "@t3tools/contracts"; |
| 20 | 2 | import * as Effect from "effect/Effect"; |
| 21 | import * as HostProcess from "@t3tools/shared/HostProcess"; | |
| 22 | ||
| 23 | const SCRIPT_BYTE_CAP = 256 * 1024; | |
| 24 | ||
| 25 | function scriptsRoot(home: string): string { | |
| 26 | return NodePath.join(home, ".claude", "projects"); | |
| 27 | } | |
| 3 | import { readContainedWorkflowFile } from "./workflowFileRead.ts"; | |
| 28 | 4 | |
| 29 | 5 | export const readWorkflowScript = Effect.fn("orchestration.readWorkflowScript")(function* (input: { |
| 30 | 6 | readonly scriptPath: string; |
| 31 | 7 | }) { |
| 32 | const requested = input.scriptPath; | |
| 33 | ||
| 34 | if (!NodePath.isAbsolute(requested) || NodePath.extname(requested) !== ".js") { | |
| 35 | return yield* new OrchestrationGetWorkflowScriptError({ | |
| 36 | reason: "invalid-path", | |
| 37 | scriptPath: requested, | |
| 38 | }); | |
| 39 | } | |
| 40 | ||
| 41 | const home = yield* HostProcess.HomeDirectory; | |
| 42 | const root = yield* Effect.tryPromise({ | |
| 43 | try: () => NodeFSP.realpath(scriptsRoot(home)), | |
| 44 | catch: (cause) => | |
| 45 | new OrchestrationGetWorkflowScriptError({ | |
| 46 | reason: "root-unavailable", | |
| 47 | scriptPath: requested, | |
| 48 | cause, | |
| 49 | }), | |
| 50 | }); | |
| 51 | ||
| 52 | // Realpath the FILE itself (not just its directory): a symlink named | |
| 53 | // like a script inside a contained directory must not escape. | |
| 54 | const resolved = yield* Effect.tryPromise({ | |
| 55 | try: () => NodeFSP.realpath(requested), | |
| 56 | catch: (cause) => | |
| 57 | new OrchestrationGetWorkflowScriptError({ | |
| 58 | reason: "not-found", | |
| 59 | scriptPath: requested, | |
| 60 | cause, | |
| 61 | }), | |
| 62 | }); | |
| 63 | ||
| 64 | if (resolved !== root && !resolved.startsWith(`${root}${NodePath.sep}`)) { | |
| 65 | return yield* new OrchestrationGetWorkflowScriptError({ | |
| 66 | reason: "outside-root", | |
| 67 | scriptPath: resolved, | |
| 68 | }); | |
| 69 | } | |
| 70 | if (NodePath.extname(resolved) !== ".js") { | |
| 71 | return yield* new OrchestrationGetWorkflowScriptError({ | |
| 72 | reason: "not-js", | |
| 73 | scriptPath: resolved, | |
| 74 | }); | |
| 75 | } | |
| 76 | ||
| 77 | // TOCTOU-safe read (review finding): open FIRST, then verify what was | |
| 78 | // actually opened via the file descriptor. Re-checking the path after | |
| 79 | // open would race against a swap; fstat on the handle cannot. The two | |
| 80 | // containment checks fail with their own tagged reasons (not manufactured | |
| 81 | // Errors folded into read-failed); "read-failed" is reserved for genuine | |
| 82 | // platform failures with the real cause attached. | |
| 83 | const read = yield* Effect.tryPromise({ | |
| 84 | try: async () => { | |
| 85 | const handle = await NodeFSP.open(resolved, "r"); | |
| 86 | try { | |
| 87 | const stat = await handle.stat(); | |
| 88 | if (!stat.isFile()) { | |
| 89 | return { failure: "not-regular-file" as const }; | |
| 90 | } | |
| 91 | // The opened inode must be the same one realpath resolved to: a | |
| 92 | // process swapping the path between realpath and open changes the | |
| 93 | // inode, which this comparison catches. | |
| 94 | const pathStat = await NodeFSP.lstat(resolved); | |
| 95 | if (stat.ino !== pathStat.ino || stat.dev !== pathStat.dev) { | |
| 96 | return { failure: "changed-during-read" as const }; | |
| 97 | } | |
| 98 | const truncated = stat.size > SCRIPT_BYTE_CAP; | |
| 99 | const buffer = Buffer.alloc(Math.min(stat.size, SCRIPT_BYTE_CAP)); | |
| 100 | const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0); | |
| 101 | return { | |
| 102 | contents: buffer.subarray(0, bytesRead).toString("utf8"), | |
| 103 | truncated, | |
| 104 | }; | |
| 105 | } finally { | |
| 106 | await handle.close(); | |
| 107 | } | |
| 108 | }, | |
| 109 | catch: (cause) => | |
| 110 | new OrchestrationGetWorkflowScriptError({ | |
| 111 | reason: "read-failed", | |
| 112 | scriptPath: resolved, | |
| 113 | cause, | |
| 114 | }), | |
| 115 | }); | |
| 116 | if ("failure" in read) { | |
| 117 | return yield* new OrchestrationGetWorkflowScriptError({ | |
| 118 | reason: read.failure, | |
| 119 | scriptPath: resolved, | |
| 120 | }); | |
| 121 | } | |
| 122 | ||
| 123 | return { | |
| 124 | scriptPath: resolved, | |
| 125 | contents: read.contents, | |
| 126 | truncated: read.truncated, | |
| 127 | }; | |
| 8 | const file = yield* readContainedWorkflowFile({ | |
| 9 | path: input.scriptPath, | |
| 10 | extension: ".js", | |
| 11 | byteCap: 256 * 1024, | |
| 12 | }).pipe( | |
| 13 | Effect.mapError( | |
| 14 | (error) => | |
| 15 | new OrchestrationGetWorkflowScriptError({ | |
| 16 | reason: error.reason === "wrong-extension" ? "not-js" : error.reason, | |
| 17 | scriptPath: error.path, | |
| 18 | cause: error, | |
| 19 | }), | |
| 20 | ), | |
| 21 | ); | |
| 22 | return { scriptPath: file.path, contents: file.contents, truncated: file.truncated }; | |
| 128 | 23 | }); |
apps/server/src/relay/AgentAwarenessRelay.test.ts+1| ... | ... | @@ -212,6 +212,7 @@ const makeTestRelay = Effect.fnUntraced(function* ( |
| 212 | 212 | settleAfterRun: unused, |
| 213 | 213 | settleThread: unused, |
| 214 | 214 | interruptThread: unused, |
| 215 | stopWorkflow: () => Effect.die("unused stopWorkflow"), | |
| 215 | 216 | stopDelegatedTasks: unused, |
| 216 | 217 | getThreadEventSequence: unused, |
| 217 | 218 | recoverDelegatedTask: unused, |
apps/server/src/ws.ts+1| ... | ... | @@ -1864,6 +1864,7 @@ const layerWsRpc = ( |
| 1864 | 1864 | ), |
| 1865 | 1865 | ), |
| 1866 | 1866 | ), |
| 1867 | [ORCHESTRATION_V2_WS_METHODS.stopWorkflow]: (input) => threadManagement.stopWorkflow(input), | |
| 1867 | 1868 | [ORCHESTRATION_V2_WS_METHODS.getWorkflowScript]: (input) => |
| 1868 | 1869 | readWorkflowScript({ scriptPath: input.scriptPath }), |
| 1869 | 1870 | [ORCHESTRATION_V2_WS_METHODS.getTurnItem]: (input) => |
apps/web/src/components/chat/ThreadLineageWorkflowRow.tsx created+266| ... | ... | @@ -0,0 +1,266 @@ |
| 1 | import type { | |
| 2 | SubagentWorkflowGroup, | |
| 3 | RuntimeSubagent, | |
| 4 | } from "@t3tools/client-runtime/state/subagentRuntime"; | |
| 5 | import type { ProviderDriverKind, ProviderInstanceId, ServerProvider } from "@t3tools/contracts"; | |
| 6 | import { ChevronDownIcon, LoaderCircleIcon, SquareIcon } from "lucide-react"; | |
| 7 | import { useState, type ReactNode } from "react"; | |
| 8 | ||
| 9 | import { AgentElapsed } from "./AgentElapsed"; | |
| 10 | import { SubagentTooltipContent } from "./SubagentTooltipContent"; | |
| 11 | import { ThreadHoverCardPopup } from "../ThreadHoverCard"; | |
| 12 | import { ThreadRelationshipIcon } from "./ThreadRelationshipIcon"; | |
| 13 | import { cn } from "../../lib/utils"; | |
| 14 | import { ThreadDetailsControl } from "./ThreadDetailsControl"; | |
| 15 | import { CollapsibleSectionHeader, SectionHeaderStatus } from "../ui/collapsible-section-header"; | |
| 16 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | |
| 17 | import { | |
| 18 | THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS, | |
| 19 | THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS, | |
| 20 | } from "./threadDetailsPanelStyles"; | |
| 21 | ||
| 22 | /** | |
| 23 | * Wall clock for the phase: its members run in parallel, so the span from the | |
| 24 | * first start to the last finish is the time the phase actually took. Shaped | |
| 25 | * for AgentElapsed, which ticks it while the phase is still running. | |
| 26 | */ | |
| 27 | function phaseElapsed(phase: SubagentWorkflowGroup["phases"][number]) { | |
| 28 | const instants = (key: "startedAt" | "completedAt") => | |
| 29 | phase.members | |
| 30 | .map((member) => member[key]) | |
| 31 | .filter((value) => value !== null) | |
| 32 | .sort(); | |
| 33 | const running = phase.state === "running"; | |
| 34 | const startedAt = instants("startedAt")[0] ?? null; | |
| 35 | const completedAt = running ? null : (instants("completedAt").at(-1) ?? null); | |
| 36 | return { | |
| 37 | status: running ? ("running" as const) : ("completed" as const), | |
| 38 | // A settled phase whose members never reported an end has no span to show. | |
| 39 | startedAt: running || completedAt !== null ? startedAt : null, | |
| 40 | completedAt, | |
| 41 | }; | |
| 42 | } | |
| 43 | ||
| 44 | /** Phase state for the header's label; the tone and member badges show it visually. */ | |
| 45 | function phaseStatusLabel(phase: SubagentWorkflowGroup["phases"][number]) { | |
| 46 | if (phase.state === "running") return "running"; | |
| 47 | if (phase.members.some((member) => member.status === "failed")) return "failed"; | |
| 48 | if ( | |
| 49 | phase.members.some((member) => member.status === "cancelled" || member.status === "interrupted") | |
| 50 | ) { | |
| 51 | return "stopped"; | |
| 52 | } | |
| 53 | return phase.state === "done" ? "done" : "not started"; | |
| 54 | } | |
| 55 | ||
| 56 | /** Members run under the coordinator's provider, so they share its glyph. */ | |
| 57 | function WorkflowMemberRow({ | |
| 58 | member, | |
| 59 | providerInstanceId, | |
| 60 | provider, | |
| 61 | providers, | |
| 62 | driver, | |
| 63 | onOpen, | |
| 64 | isThreadAvailable, | |
| 65 | }: { | |
| 66 | member: RuntimeSubagent; | |
| 67 | providerInstanceId: ProviderInstanceId; | |
| 68 | provider: ServerProvider | undefined; | |
| 69 | providers: ReadonlyArray<ServerProvider> | undefined; | |
| 70 | driver: ProviderDriverKind | undefined; | |
| 71 | onOpen: (threadId: string) => void; | |
| 72 | isThreadAvailable: (threadId: string) => boolean; | |
| 73 | }) { | |
| 74 | const threadId = member.childThreadId; | |
| 75 | const canOpen = threadId !== null && isThreadAvailable(threadId); | |
| 76 | return ( | |
| 77 | <li> | |
| 78 | <Tooltip> | |
| 79 | <TooltipTrigger | |
| 80 | delay={200} | |
| 81 | render={ | |
| 82 | <ThreadDetailsControl | |
| 83 | part="row" | |
| 84 | aria-label={`Open ${member.title} chat`} | |
| 85 | disabled={!canOpen} | |
| 86 | onClick={() => canOpen && onOpen(threadId)} | |
| 87 | /> | |
| 88 | } | |
| 89 | > | |
| 90 | <ThreadRelationshipIcon driver={driver} provider={provider} status={member.status} /> | |
| 91 | <span className="min-w-0 flex-1 truncate">{member.title}</span> | |
| 92 | <span className="sr-only">{member.status}</span> | |
| 93 | {member.startedAt ? ( | |
| 94 | <span className="shrink-0 text-2xs font-normal tabular-nums text-muted-foreground"> | |
| 95 | <AgentElapsed agent={member} /> | |
| 96 | </span> | |
| 97 | ) : null} | |
| 98 | </TooltipTrigger> | |
| 99 | <ThreadHoverCardPopup side="left"> | |
| 100 | <SubagentTooltipContent | |
| 101 | title={member.title} | |
| 102 | model={member.model} | |
| 103 | providerInstanceId={providerInstanceId} | |
| 104 | origin="provider_native" | |
| 105 | provider={provider} | |
| 106 | providers={providers} | |
| 107 | driver={driver} | |
| 108 | elapsed={<AgentElapsed agent={member} />} | |
| 109 | status={member.status} | |
| 110 | result={member.result ?? member.error} | |
| 111 | progress={member.progress} | |
| 112 | /> | |
| 113 | </ThreadHoverCardPopup> | |
| 114 | </Tooltip> | |
| 115 | </li> | |
| 116 | ); | |
| 117 | } | |
| 118 | ||
| 119 | export function ThreadLineageWorkflowRow({ | |
| 120 | group, | |
| 121 | header, | |
| 122 | providerInstanceId, | |
| 123 | provider, | |
| 124 | providers, | |
| 125 | driver, | |
| 126 | onOpenThread, | |
| 127 | isThreadAvailable, | |
| 128 | onStop, | |
| 129 | stopping, | |
| 130 | stopDisabled, | |
| 131 | }: { | |
| 132 | readonly group: SubagentWorkflowGroup; | |
| 133 | readonly header: ReactNode; | |
| 134 | readonly providerInstanceId: ProviderInstanceId; | |
| 135 | readonly provider: ServerProvider | undefined; | |
| 136 | readonly providers: ReadonlyArray<ServerProvider> | undefined; | |
| 137 | readonly driver: ProviderDriverKind | undefined; | |
| 138 | readonly onOpenThread: (threadId: string) => void; | |
| 139 | readonly isThreadAvailable: (threadId: string) => boolean; | |
| 140 | readonly onStop?: (() => void) | undefined; | |
| 141 | readonly stopping: boolean; | |
| 142 | readonly stopDisabled: boolean; | |
| 143 | }) { | |
| 144 | const [expanded, setExpanded] = useState(false); | |
| 145 | // The running phase opens itself until the user picks a side for it. | |
| 146 | const [phaseOpen, setPhaseOpen] = useState<ReadonlyMap<number, boolean>>(() => new Map()); | |
| 147 | const label = group.workflow.workflowName ?? group.workflow.title; | |
| 148 | const phases = group.phases.filter((phase) => phase.members.length > 0); | |
| 149 | return ( | |
| 150 | // The flag lets the lineage list trade its compact height for the open tree. | |
| 151 | <li className="group" data-workflow-expanded={expanded ? "" : undefined}> | |
| 152 | <div className={cn("relative", THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS)}> | |
| 153 | {header} | |
| 154 | {onStop ? ( | |
| 155 | <div className="pointer-events-none absolute right-9 top-1/2 -translate-y-1/2 opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100 pointer-coarse:pointer-events-auto pointer-coarse:opacity-100 [@media(hover:none)]:pointer-events-auto [@media(hover:none)]:opacity-100"> | |
| 156 | <Tooltip> | |
| 157 | <TooltipTrigger | |
| 158 | render={ | |
| 159 | <ThreadDetailsControl | |
| 160 | size="icon-xs" | |
| 161 | variant="ghost" | |
| 162 | part="icon" | |
| 163 | tone="destructive" | |
| 164 | aria-label={`Stop workflow ${label}`} | |
| 165 | disabled={stopDisabled} | |
| 166 | onClick={onStop} | |
| 167 | /> | |
| 168 | } | |
| 169 | > | |
| 170 | {stopping ? ( | |
| 171 | <LoaderCircleIcon aria-hidden className="size-3 animate-spin" /> | |
| 172 | ) : ( | |
| 173 | <SquareIcon aria-hidden className="size-3 fill-current" /> | |
| 174 | )} | |
| 175 | </TooltipTrigger> | |
| 176 | <TooltipPopup side="left">Stop entire workflow</TooltipPopup> | |
| 177 | </Tooltip> | |
| 178 | </div> | |
| 179 | ) : null} | |
| 180 | <span aria-hidden="true" className={THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS} /> | |
| 181 | <ThreadDetailsControl | |
| 182 | size="sm" | |
| 183 | variant="ghost" | |
| 184 | part="secondary" | |
| 185 | aria-expanded={expanded} | |
| 186 | aria-label={`${expanded ? "Collapse" : "Expand"} ${label}`} | |
| 187 | onClick={() => setExpanded((value) => !value)} | |
| 188 | > | |
| 189 | <ChevronDownIcon | |
| 190 | aria-hidden | |
| 191 | className={cn( | |
| 192 | "size-3.5 text-muted-foreground transition-transform", | |
| 193 | expanded && "rotate-180", | |
| 194 | )} | |
| 195 | /> | |
| 196 | </ThreadDetailsControl> | |
| 197 | </div> | |
| 198 | {expanded ? ( | |
| 199 | <ul className="m-0 list-none p-0 ps-6.5"> | |
| 200 | {phases.map((phase) => { | |
| 201 | const open = phaseOpen.get(phase.index) ?? phase.state === "running"; | |
| 202 | const status = phaseStatusLabel(phase); | |
| 203 | return ( | |
| 204 | <li key={phase.index}> | |
| 205 | <CollapsibleSectionHeader | |
| 206 | variant="panel" | |
| 207 | tone={phase.state === "running" ? "emphasized" : "muted"} | |
| 208 | expanded={open} | |
| 209 | aria-label={`${open ? "Collapse" : "Expand"} ${phase.title} phase, ${status}`} | |
| 210 | onClick={() => setPhaseOpen((phases) => new Map(phases).set(phase.index, !open))} | |
| 211 | accessory={ | |
| 212 | <> | |
| 213 | {status === "failed" ? ( | |
| 214 | <SectionHeaderStatus>Failed</SectionHeaderStatus> | |
| 215 | ) : null} | |
| 216 | <span className="shrink-0 text-2xs font-normal tabular-nums"> | |
| 217 | {phase.settledCount}/{phase.members.length}{" "} | |
| 218 | {phase.members.length === 1 ? "agent" : "agents"} | |
| 219 | </span> | |
| 220 | <span className="shrink-0 text-2xs font-normal tabular-nums empty:hidden"> | |
| 221 | <AgentElapsed agent={phaseElapsed(phase)} /> | |
| 222 | </span> | |
| 223 | </> | |
| 224 | } | |
| 225 | > | |
| 226 | {phase.title} | |
| 227 | </CollapsibleSectionHeader> | |
| 228 | {open ? ( | |
| 229 | <ul className="m-0 list-none p-0"> | |
| 230 | {phase.members.map((member) => ( | |
| 231 | <WorkflowMemberRow | |
| 232 | key={member.id} | |
| 233 | member={member} | |
| 234 | providerInstanceId={providerInstanceId} | |
| 235 | provider={provider} | |
| 236 | providers={providers} | |
| 237 | driver={driver} | |
| 238 | onOpen={onOpenThread} | |
| 239 | isThreadAvailable={isThreadAvailable} | |
| 240 | /> | |
| 241 | ))} | |
| 242 | </ul> | |
| 243 | ) : null} | |
| 244 | </li> | |
| 245 | ); | |
| 246 | })} | |
| 247 | {group.unphasedMembers.map((member) => ( | |
| 248 | <WorkflowMemberRow | |
| 249 | key={member.id} | |
| 250 | member={member} | |
| 251 | providerInstanceId={providerInstanceId} | |
| 252 | provider={provider} | |
| 253 | providers={providers} | |
| 254 | driver={driver} | |
| 255 | onOpen={onOpenThread} | |
| 256 | isThreadAvailable={isThreadAvailable} | |
| 257 | /> | |
| 258 | ))} | |
| 259 | {phases.length === 0 && group.unphasedMembers.length === 0 ? ( | |
| 260 | <li className="px-2.5 py-1.5 text-2xs text-muted-foreground/70">No agents yet</li> | |
| 261 | ) : null} | |
| 262 | </ul> | |
| 263 | ) : null} | |
| 264 | </li> | |
| 265 | ); | |
| 266 | } |
apps/web/src/components/chat/ThreadRelationshipsControl.agents.test.tsx+470-53| ... | ... | @@ -18,6 +18,8 @@ const state = vi.hoisted(() => ({ |
| 18 | 18 | configs: new Map<string, unknown>(), |
| 19 | 19 | showTooltips: false, |
| 20 | 20 | command: vi.fn().mockResolvedValue({ _tag: "Success" }), |
| 21 | workflowCommand: vi.fn().mockResolvedValue({ _tag: "Success" }), | |
| 22 | toast: vi.fn(), | |
| 21 | 23 | })); |
| 22 | 24 | |
| 23 | 25 | vi.mock("@tanstack/react-router", () => ({ useNavigate: () => state.navigate })); |
| ... | ... | @@ -30,7 +32,25 @@ vi.mock("../../state/entities", () => ({ |
| 30 | 32 | vi.mock("../../lib/archivedThreadsState", () => ({ |
| 31 | 33 | useArchivedThreadSnapshots: () => ({ snapshots: [] }), |
| 32 | 34 | })); |
| 33 | vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => state.command })); | |
| 35 | vi.mock("../../state/use-atom-command", () => ({ | |
| 36 | useAtomCommand: (command: { label: string }) => | |
| 37 | command.label === "environment-data:orchestration:stop-workflow" | |
| 38 | ? state.workflowCommand | |
| 39 | : state.command, | |
| 40 | })); | |
| 41 | vi.mock("../ui/toast", () => ({ toastManager: { add: state.toast } })); | |
| 42 | vi.mock("../../state/orchestration", async () => { | |
| 43 | const { Atom } = await import("effect/reactivity"); | |
| 44 | const granted = Atom.make(true); | |
| 45 | return { | |
| 46 | orchestrationEnvironment: { | |
| 47 | stopWorkflow: { | |
| 48 | label: "environment-data:orchestration:stop-workflow", | |
| 49 | permissionAtom: () => granted, | |
| 50 | }, | |
| 51 | }, | |
| 52 | }; | |
| 53 | }); | |
| 34 | 54 | vi.mock("../ui/tooltip", () => ({ |
| 35 | 55 | Tooltip: ({ children }: { children: ReactNode }) => children, |
| 36 | 56 | TooltipTrigger: ({ render, children }: { render: ReactElement; children: ReactNode }) => |
| ... | ... | @@ -49,17 +69,423 @@ afterEach(async () => { |
| 49 | 69 | state.projects = []; |
| 50 | 70 | state.configs.clear(); |
| 51 | 71 | state.showTooltips = false; |
| 72 | state.navigate.mockClear(); | |
| 52 | 73 | state.command.mockClear(); |
| 74 | state.workflowCommand.mockReset().mockResolvedValue({ _tag: "Success" }); | |
| 75 | state.toast.mockClear(); | |
| 53 | 76 | state.projection = null; |
| 54 | 77 | }); |
| 55 | 78 | |
| 79 | it("stops the entire native workflow through its parent without changing state before acknowledgement", async () => { | |
| 80 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | |
| 81 | const coordinator = { | |
| 82 | id: "workflow", | |
| 83 | origin: "provider_native", | |
| 84 | driver: "claudeAgent", | |
| 85 | providerInstanceId: "claude-work", | |
| 86 | childThreadId: "workflow-chat", | |
| 87 | title: "Checkout review", | |
| 88 | status: "running", | |
| 89 | startedAt: DateTime.makeUnsafe("2026-09-21T12:00:00Z"), | |
| 90 | completedAt: null, | |
| 91 | updatedAt: DateTime.makeUnsafe("2026-09-21T12:00:00Z"), | |
| 92 | workflow: { | |
| 93 | name: "Checkout review", | |
| 94 | phases: [{ index: 0, title: "Inspect" }], | |
| 95 | agents: [{ index: 0, label: "Checker", state: "running", phaseIndex: 0 }], | |
| 96 | }, | |
| 97 | }; | |
| 98 | state.projection = { | |
| 99 | thread: { id: "parent", lineage: { relationshipToParent: null } }, | |
| 100 | runs: [], | |
| 101 | providerThreads: [], | |
| 102 | providerSessions: [], | |
| 103 | contextTransfers: [], | |
| 104 | subagents: [ | |
| 105 | coordinator, | |
| 106 | { | |
| 107 | ...coordinator, | |
| 108 | id: "worker", | |
| 109 | childThreadId: "worker-chat", | |
| 110 | origin: "app_owned", | |
| 111 | title: "Worker", | |
| 112 | workflow: undefined, | |
| 113 | }, | |
| 114 | ], | |
| 115 | }; | |
| 116 | state.shells = [ | |
| 117 | { | |
| 118 | environmentId: "remote", | |
| 119 | source: { | |
| 120 | id: "workflow-chat", | |
| 121 | title: "Coordinator chat", | |
| 122 | lineage: { parentThreadId: "parent", relationshipToParent: "subagent" }, | |
| 123 | activityRunStatus: "waiting", | |
| 124 | }, | |
| 125 | }, | |
| 126 | ]; | |
| 127 | await act(async () => { | |
| 128 | renderer = create( | |
| 129 | <ThreadRelationshipsPanel | |
| 130 | environmentId={EnvironmentId.make("remote")} | |
| 131 | threadId={ThreadId.make("parent")} | |
| 132 | />, | |
| 133 | ); | |
| 134 | }); | |
| 135 | await act(async () => | |
| 136 | renderer.root.findByProps({ "aria-label": "Expand Checkout review" }).props.onClick(), | |
| 137 | ); | |
| 138 | const stopButton = () => | |
| 139 | renderer.root.findByProps({ "aria-label": "Stop workflow Checkout review" }); | |
| 140 | expect( | |
| 141 | renderer.root.findAll( | |
| 142 | (node) => node.type === "button" && String(node.props["aria-label"]).startsWith("Stop "), | |
| 143 | ), | |
| 144 | ).toHaveLength(2); | |
| 145 | let acknowledge!: (result: { _tag: string }) => void; | |
| 146 | state.workflowCommand.mockImplementationOnce( | |
| 147 | () => | |
| 148 | new Promise((resolve) => { | |
| 149 | acknowledge = resolve; | |
| 150 | }), | |
| 151 | ); | |
| 152 | await act(async () => { | |
| 153 | stopButton().props.onClick(); | |
| 154 | }); | |
| 155 | expect(state.workflowCommand).toHaveBeenCalledExactlyOnceWith({ | |
| 156 | environmentId: "remote", | |
| 157 | input: { threadId: "parent", subagentId: "workflow" }, | |
| 158 | }); | |
| 159 | expect(state.command).not.toHaveBeenCalled(); | |
| 160 | expect(state.navigate).not.toHaveBeenCalled(); | |
| 161 | expect(stopButton().props.disabled).toBe(true); | |
| 162 | expect(renderer.root.findByProps({ "aria-label": "Stop subagent Worker" }).props.disabled).toBe( | |
| 163 | true, | |
| 164 | ); | |
| 165 | expect(renderer.root.findByProps({ "aria-label": "Open Checker chat" })).toBeDefined(); | |
| 166 | expect(renderer.root.findByType("h3").children).toEqual(["Lineage · 2 running"]); | |
| 167 | await act(async () => { | |
| 168 | acknowledge({ _tag: "Success" }); | |
| 169 | }); | |
| 170 | expect(stopButton().props.disabled).toBe(false); | |
| 171 | expect(renderer.root.findByType("h3").children).toEqual(["Lineage · 2 running"]); | |
| 172 | state.workflowCommand.mockResolvedValueOnce({ _tag: "Failure" }); | |
| 173 | await act(async () => { | |
| 174 | stopButton().props.onClick(); | |
| 175 | }); | |
| 176 | expect(state.toast).toHaveBeenCalledExactlyOnceWith({ | |
| 177 | type: "error", | |
| 178 | title: "Could not stop workflow", | |
| 179 | }); | |
| 180 | expect(stopButton().props.disabled).toBe(false); | |
| 181 | expect(state.command).not.toHaveBeenCalled(); | |
| 182 | expect(state.navigate).not.toHaveBeenCalled(); | |
| 183 | }); | |
| 184 | ||
| 185 | it.each([ | |
| 186 | ["claudeAgent", "provider_native", "completed"], | |
| 187 | ["claudeAgent", "provider_native", "failed"], | |
| 188 | ["claudeAgent", "provider_native", "interrupted"], | |
| 189 | ["claudeAgent", "provider_native", "waiting"], | |
| 190 | ["codex", "provider_native", "running"], | |
| 191 | ["claudeAgent", "app_owned", "running"], | |
| 192 | ])("does not offer workflow stop for %s %s %s", async (driver, origin, status) => { | |
| 193 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | |
| 194 | state.projection = { | |
| 195 | thread: { id: "parent", lineage: { relationshipToParent: null } }, | |
| 196 | runs: [], | |
| 197 | providerThreads: [], | |
| 198 | providerSessions: [], | |
| 199 | contextTransfers: [], | |
| 200 | subagents: [ | |
| 201 | { | |
| 202 | id: "workflow", | |
| 203 | driver, | |
| 204 | origin, | |
| 205 | status, | |
| 206 | providerInstanceId: "claude-work", | |
| 207 | childThreadId: "workflow-chat", | |
| 208 | title: "Checkout review", | |
| 209 | startedAt: DateTime.makeUnsafe("2026-09-21T12:00:00Z"), | |
| 210 | completedAt: null, | |
| 211 | updatedAt: DateTime.makeUnsafe("2026-09-21T12:00:00Z"), | |
| 212 | workflow: { name: "Checkout review", phases: [], agents: [] }, | |
| 213 | }, | |
| 214 | ], | |
| 215 | }; | |
| 216 | await act(async () => { | |
| 217 | renderer = create( | |
| 218 | <ThreadRelationshipsPanel | |
| 219 | environmentId={EnvironmentId.make("remote")} | |
| 220 | threadId={ThreadId.make("parent")} | |
| 221 | />, | |
| 222 | ); | |
| 223 | }); | |
| 224 | const previousAgents = renderer.root.findAll( | |
| 225 | (node) => | |
| 226 | node.type === "button" && node.props["aria-expanded"] === false && !node.props["aria-label"], | |
| 227 | ); | |
| 228 | if (previousAgents.length > 0) await act(async () => previousAgents[0]!.props.onClick()); | |
| 229 | expect(renderer.root.findByProps({ "aria-label": "Expand Checkout review" })).toBeDefined(); | |
| 230 | expect( | |
| 231 | renderer.root.findAllByProps({ "aria-label": "Stop workflow Checkout review" }), | |
| 232 | ).toHaveLength(0); | |
| 233 | expect(state.workflowCommand).not.toHaveBeenCalled(); | |
| 234 | }); | |
| 235 | ||
| 236 | it("opens the correct chat for every workflow phase and unphased member", async () => { | |
| 237 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | |
| 238 | state.showTooltips = true; | |
| 239 | state.configs.set("remote", { | |
| 240 | providers: [ | |
| 241 | { | |
| 242 | instanceId: "claude-personal", | |
| 243 | driver: "claudeAgent", | |
| 244 | displayName: "Personal account", | |
| 245 | models: [], | |
| 246 | }, | |
| 247 | { | |
| 248 | instanceId: "claude-work", | |
| 249 | driver: "claudeAgent", | |
| 250 | displayName: "Work account", | |
| 251 | models: [{ slug: "claude-sonnet-4-6", name: "Sonnet" }], | |
| 252 | }, | |
| 253 | ], | |
| 254 | }); | |
| 255 | const phases = ["Inspect", "Improve", "Verify"].map((title, index) => ({ index, title })); | |
| 256 | const agents = Array.from({ length: 7 }, (_, index) => ({ | |
| 257 | index, | |
| 258 | label: `Member ${index}`, | |
| 259 | state: | |
| 260 | index === 2 ? "cancelled" : index === 4 ? "failed" : index === 5 ? "running" : "completed", | |
| 261 | ...(index < 6 ? { phaseIndex: Math.floor(index / 2) } : {}), | |
| 262 | childThreadId: `member-chat-${index}`, | |
| 263 | model: "claude-sonnet-4-6", | |
| 264 | result: `Result ${index}`, | |
| 265 | totalTokens: 1200, | |
| 266 | // Staggered starts, so a phase spans longer than either member's own run. | |
| 267 | startedAt: 1_700_000_000_000 + index * 1000, | |
| 268 | durationMs: 5000, | |
| 269 | })); | |
| 270 | const project = (members: typeof agents) => ({ | |
| 271 | thread: { id: "parent", lineage: { relationshipToParent: null } }, | |
| 272 | runs: [], | |
| 273 | providerThreads: [], | |
| 274 | providerSessions: [], | |
| 275 | contextTransfers: [], | |
| 276 | subagents: [ | |
| 277 | { | |
| 278 | id: "workflow", | |
| 279 | driver: "claudeAgent", | |
| 280 | providerInstanceId: "claude-work", | |
| 281 | childThreadId: "workflow-chat", | |
| 282 | title: "Checkout review", | |
| 283 | status: "running", | |
| 284 | startedAt: null, | |
| 285 | completedAt: null, | |
| 286 | updatedAt: DateTime.makeUnsafe("2026-09-21T12:00:00Z"), | |
| 287 | workflow: { name: "Checkout review", phases, agents: members }, | |
| 288 | }, | |
| 289 | ], | |
| 290 | }); | |
| 291 | state.projection = project(agents); | |
| 292 | // Matching IDs from another environment cannot make these chats available. | |
| 293 | const memberShells = agents.map((agent) => ({ | |
| 294 | id: agent.childThreadId, | |
| 295 | title: agent.label, | |
| 296 | lineage: { parentThreadId: "workflow-chat", relationshipToParent: "subagent" }, | |
| 297 | status: "completed", | |
| 298 | activityRunStatus: null, | |
| 299 | })); | |
| 300 | state.shells = memberShells.map((source) => ({ environmentId: "other", source })); | |
| 301 | const panel = ( | |
| 302 | <ThreadRelationshipsPanel | |
| 303 | environmentId={EnvironmentId.make("remote")} | |
| 304 | threadId={ThreadId.make("parent")} | |
| 305 | /> | |
| 306 | ); | |
| 307 | await act(async () => { | |
| 308 | renderer = create(panel); | |
| 309 | }); | |
| 310 | await act(async () => | |
| 311 | renderer.root.findByProps({ "aria-label": "Expand Checkout review" }).props.onClick(), | |
| 312 | ); | |
| 313 | const memberButtons = () => | |
| 314 | renderer.root.findAll( | |
| 315 | (node) => | |
| 316 | node.type === "button" && String(node.props["aria-label"]).startsWith("Open Member"), | |
| 317 | ); | |
| 318 | const phaseButtons = () => | |
| 319 | renderer.root.findAll( | |
| 320 | (node) => | |
| 321 | node.type === "button" && | |
| 322 | typeof node.props["aria-expanded"] === "boolean" && | |
| 323 | String(node.props["aria-label"]).includes(" phase, "), | |
| 324 | ); | |
| 325 | // Only the running phase starts open, beside the unphased member. | |
| 326 | expect(memberButtons()).toHaveLength(3); | |
| 327 | expect(phaseButtons()).toHaveLength(3); | |
| 328 | expect(renderer.root.findByProps({ "aria-label": "Expand Inspect phase, done" })).toBeDefined(); | |
| 329 | expect( | |
| 330 | renderer.root.findByProps({ "aria-label": "Expand Improve phase, stopped" }), | |
| 331 | ).toBeDefined(); | |
| 332 | expect( | |
| 333 | renderer.root.findByProps({ "aria-label": "Collapse Verify phase, running" }), | |
| 334 | ).toBeDefined(); | |
| 335 | for (const phase of phaseButtons().slice(0, 2)) await act(async () => phase.props.onClick()); | |
| 336 | expect(memberButtons()).toHaveLength(7); | |
| 337 | for (const button of memberButtons()) { | |
| 338 | expect(button.props.disabled).toBe(true); | |
| 339 | await act(async () => button.props.onClick()); | |
| 340 | } | |
| 341 | expect(state.navigate).not.toHaveBeenCalled(); | |
| 342 | // The coordinator roster arrives before the member thread shells. | |
| 343 | state.shells = memberShells.map((source) => ({ environmentId: "remote", source })); | |
| 344 | await act(async () => renderer.update(cloneElement(panel))); | |
| 345 | for (const button of memberButtons()) expect(button.props.disabled).toBe(false); | |
| 346 | for (const agent of agents) { | |
| 347 | await act(async () => | |
| 348 | renderer.root.findByProps({ "aria-label": `Open ${agent.label} chat` }).props.onClick(), | |
| 349 | ); | |
| 350 | expect(state.navigate).toHaveBeenLastCalledWith({ | |
| 351 | to: "/$environmentId/$threadId", | |
| 352 | params: { environmentId: "remote", threadId: agent.childThreadId }, | |
| 353 | }); | |
| 354 | } | |
| 355 | const rendered = JSON.stringify(renderer.toJSON()); | |
| 356 | expect(rendered.match(/Sonnet · Work account/g)).toHaveLength(7); | |
| 357 | expect(rendered).not.toContain("Personal account"); | |
| 358 | for (const phase of phases) expect(rendered).toContain(phase.title); | |
| 359 | // The closed row leaves phase progress to the tree. | |
| 360 | expect(rendered).not.toContain(" phases"); | |
| 361 | expect(rendered).toContain('"1","/","2"," ","agents"'); | |
| 362 | expect(rendered).toContain("Running"); | |
| 363 | // The phase holding the running member reports itself as the active one. | |
| 364 | expect(rendered).toContain("running"); | |
| 365 | expect(rendered).toContain("done"); | |
| 366 | expect(rendered).toContain("stopped"); | |
| 367 | // Members 0 and 1 start a second apart and run 5s each, so Inspect took 6s. | |
| 368 | expect(rendered).toContain('"6s"'); | |
| 369 | for (const phase of phaseButtons()) { | |
| 370 | const label = String(phase.props["aria-label"]).slice("Collapse ".length); | |
| 371 | await act(async () => phase.props.onClick()); | |
| 372 | expect(memberButtons()).toHaveLength(5); | |
| 373 | await act(async () => | |
| 374 | renderer.root.findByProps({ "aria-label": `Expand ${label}` }).props.onClick(), | |
| 375 | ); | |
| 376 | expect(memberButtons()).toHaveLength(7); | |
| 377 | } | |
| 378 | // A phase the user closed stays closed once it starts running, and stays | |
| 379 | // closed after it settles. | |
| 380 | await act(async () => phaseButtons()[0]!.props.onClick()); | |
| 381 | expect(memberButtons()).toHaveLength(5); | |
| 382 | const restarted = agents.map((agent) => | |
| 383 | agent.index === 0 ? { ...agent, state: "running" } : agent, | |
| 384 | ); | |
| 385 | state.projection = project(restarted); | |
| 386 | await act(async () => renderer.update(cloneElement(panel))); | |
| 387 | expect(memberButtons()).toHaveLength(5); | |
| 388 | state.projection = project(agents); | |
| 389 | await act(async () => renderer.update(cloneElement(panel))); | |
| 390 | expect(memberButtons()).toHaveLength(5); | |
| 391 | await act(async () => | |
| 392 | renderer.root.findByProps({ "aria-label": "Collapse Checkout review" }).props.onClick(), | |
| 393 | ); | |
| 394 | expect(memberButtons()).toHaveLength(0); | |
| 395 | expect(state.navigate).toHaveBeenCalledTimes(7); | |
| 396 | }); | |
| 397 | ||
| 398 | it("groups and counts a workflow by its run status instead of its coordinator chat", async () => { | |
| 399 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | |
| 400 | state.showTooltips = true; | |
| 401 | const coordinator = { | |
| 402 | id: "workflow", | |
| 403 | driver: "claudeAgent", | |
| 404 | providerInstanceId: "claudeAgent", | |
| 405 | childThreadId: "workflow-chat", | |
| 406 | title: "Checkout review", | |
| 407 | status: "running", | |
| 408 | startedAt: DateTime.makeUnsafe("2026-09-21T12:00:00Z"), | |
| 409 | progress: "Inspecting the checkout workflow", | |
| 410 | result: "Workflow checks complete", | |
| 411 | completedAt: null, | |
| 412 | updatedAt: DateTime.makeUnsafe("2026-09-21T12:00:00Z"), | |
| 413 | workflow: { | |
| 414 | name: "Checkout review", | |
| 415 | phases: [{ index: 0, title: "Inspect" }], | |
| 416 | agents: [{ index: 0, label: "Checker", state: "running", phaseIndex: 0 }], | |
| 417 | }, | |
| 418 | }; | |
| 419 | const projection = { | |
| 420 | thread: { id: "parent", lineage: { relationshipToParent: null } }, | |
| 421 | runs: [], | |
| 422 | providerThreads: [], | |
| 423 | providerSessions: [], | |
| 424 | contextTransfers: [], | |
| 425 | subagents: [coordinator], | |
| 426 | }; | |
| 427 | const child = { | |
| 428 | id: "workflow-chat", | |
| 429 | title: "Coordinator chat", | |
| 430 | lineage: { parentThreadId: "parent", relationshipToParent: "subagent" }, | |
| 431 | status: "completed", | |
| 432 | activityRunStatus: null, | |
| 433 | }; | |
| 434 | state.projection = projection; | |
| 435 | state.shells = [{ environmentId: "test", source: child }]; | |
| 436 | const panel = ( | |
| 437 | <ThreadRelationshipsPanel | |
| 438 | environmentId={EnvironmentId.make("test")} | |
| 439 | threadId={ThreadId.make("parent")} | |
| 440 | /> | |
| 441 | ); | |
| 442 | await act(async () => { | |
| 443 | renderer = create(panel); | |
| 444 | }); | |
| 445 | const text = () => | |
| 446 | renderer.root | |
| 447 | .findAll((node) => typeof node.type === "string") | |
| 448 | .flatMap((node) => node.children.filter((child) => typeof child === "string")) | |
| 449 | .join(""); | |
| 450 | expect(renderer.root.findByType("h3").children).toEqual(["Lineage · 1 running"]); | |
| 451 | await act(async () => | |
| 452 | renderer.root.findByProps({ "aria-label": "Expand Checkout review" }).props.onClick(), | |
| 453 | ); | |
| 454 | expect(renderer.root.findByProps({ "aria-label": "Open Checker chat" })).toBeDefined(); | |
| 455 | state.shells = [{ environmentId: "test", source: { ...child, activityRunStatus: "waiting" } }]; | |
| 456 | await act(async () => renderer.update(cloneElement(panel))); | |
| 457 | expect(renderer.root.findByType("h3").children).toEqual(["Lineage · 1 running"]); | |
| 458 | expect(text()).toContain("Inspecting the checkout workflow"); | |
| 459 | state.projection = { | |
| 460 | ...projection, | |
| 461 | subagents: [ | |
| 462 | { | |
| 463 | ...coordinator, | |
| 464 | status: "completed", | |
| 465 | completedAt: DateTime.makeUnsafe("2026-09-21T12:10:00Z"), | |
| 466 | }, | |
| 467 | ], | |
| 468 | }; | |
| 469 | await act(async () => renderer.update(cloneElement(panel))); | |
| 470 | expect(renderer.root.findByType("h3").children).toEqual(["Lineage"]); | |
| 471 | expect(text()).toContain("Previous agents"); | |
| 472 | expect(renderer.root.findAllByProps({ "aria-label": "Open Checker chat" })).toHaveLength(0); | |
| 473 | await act(async () => | |
| 474 | renderer.root.findByProps({ type: "button", "aria-expanded": false }).props.onClick(), | |
| 475 | ); | |
| 476 | expect(renderer.root.findByProps({ "aria-label": "Expand Checkout review" })).toBeDefined(); | |
| 477 | expect(text()).toContain("Workflow checks complete"); | |
| 478 | expect(text()).toContain("10m"); | |
| 479 | }); | |
| 480 | ||
| 56 | 481 | it.each([ |
| 57 | { driver: "codex", origin: "app_owned" }, | |
| 58 | { driver: "claudeAgent", origin: "app_owned" }, | |
| 59 | { driver: "claudeAgent", origin: "provider_native" }, | |
| 482 | { driver: "codex", origin: "app_owned", runId: null, canStop: true }, | |
| 483 | { driver: "claudeAgent", origin: "app_owned", runId: null, canStop: true }, | |
| 484 | { driver: "claudeAgent", origin: "provider_native", runId: "run-1", canStop: true }, | |
| 485 | { driver: "claudeAgent", origin: "provider_native", runId: null, canStop: false }, | |
| 60 | 486 | ])( |
| 61 | "stops active $origin $driver subagents without opening their thread", | |
| 62 | async ({ driver, origin }) => { | |
| 487 | "offers individual Stop for supported $origin $driver subagents with run $runId", | |
| 488 | async ({ driver, origin, runId, canStop }) => { | |
| 63 | 489 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); |
| 64 | 490 | const parent = { |
| 65 | 491 | id: "parent", |
| ... | ... | @@ -76,6 +502,7 @@ it.each([ |
| 76 | 502 | childThreadId: "child", |
| 77 | 503 | origin, |
| 78 | 504 | threadId: "parent", |
| 505 | runId, | |
| 79 | 506 | nativeTaskRef: { driver, nativeId: "claude-task", strength: "strong" }, |
| 80 | 507 | driver, |
| 81 | 508 | providerInstanceId: "codex", |
| ... | ... | @@ -108,6 +535,12 @@ it.each([ |
| 108 | 535 | await act(async () => { |
| 109 | 536 | renderer = create(panel); |
| 110 | 537 | }); |
| 538 | if (!canStop) { | |
| 539 | expect(renderer.root.findAllByProps({ "aria-label": "Stop subagent Worker" })).toHaveLength( | |
| 540 | 0, | |
| 541 | ); | |
| 542 | return; | |
| 543 | } | |
| 111 | 544 | const stopButton = () => renderer.root.findByProps({ "aria-label": "Stop subagent Worker" }); |
| 112 | 545 | await act(async () => stopButton().props.onClick()); |
| 113 | 546 | expect(state.command).toHaveBeenCalledWith({ |
| ... | ... | @@ -397,11 +830,16 @@ it("shows readable models and only differing workspace details in agent tooltips |
| 397 | 830 | }; |
| 398 | 831 | return read(renderer.root); |
| 399 | 832 | }; |
| 400 | expect(text()).toContain("My GPT · high"); | |
| 401 | state.projection = { | |
| 833 | const rerender = async (source = child) => { | |
| 834 | state.shells = [{ environmentId: "test", source: { ...source } }]; | |
| 835 | await act(async () => renderer.update(cloneElement(panel))); | |
| 836 | }; | |
| 837 | const nativeProjection = { | |
| 402 | 838 | ...projection, |
| 403 | 839 | subagents: [{ ...projection.subagents[0], origin: "provider_native" }], |
| 404 | 840 | }; |
| 841 | expect(text()).toContain("My GPT · high"); | |
| 842 | state.projection = nativeProjection; | |
| 405 | 843 | await act(async () => renderer.update(cloneElement(panel))); |
| 406 | 844 | expect(text()).toContain("My GPT"); |
| 407 | 845 | expect(text()).not.toContain("My GPT · high"); |
| ... | ... | @@ -467,8 +905,7 @@ it("shows readable models and only differing workspace details in agent tooltips |
| 467 | 905 | [undefined, ""], |
| 468 | 906 | ] as const) { |
| 469 | 907 | child.modelSelection.options = options; |
| 470 | state.shells = [{ environmentId: "test", source: { ...child } }]; | |
| 471 | await act(async () => renderer.update(cloneElement(panel))); | |
| 908 | await rerender(); | |
| 472 | 909 | expect(text()).toContain(`My GPT${expected}`); |
| 473 | 910 | if (!expected) expect(text()).not.toContain("My GPT ·"); |
| 474 | 911 | } |
| ... | ... | @@ -532,8 +969,7 @@ it("shows readable models and only differing workspace details in agent tooltips |
| 532 | 969 | { id: "reasoningEffort", value: "high" }, |
| 533 | 970 | ...(value === undefined ? [] : [{ id: descriptor.id, value }]), |
| 534 | 971 | ]; |
| 535 | state.shells = [{ environmentId: "test", source: { ...child } }]; | |
| 536 | await act(async () => renderer.update(cloneElement(panel))); | |
| 972 | await rerender(); | |
| 537 | 973 | expect(text(true)).toContain("My GPT · high"); |
| 538 | 974 | expect(text(true)).not.toMatch(/Fast|Ultrafast|Normal|Standard|Flex/); |
| 539 | 975 | expect(text()).toContain(`My GPT · ${iconLabel}high`); |
| ... | ... | @@ -541,27 +977,11 @@ it("shows readable models and only differing workspace details in agent tooltips |
| 541 | 977 | child.modelSelection.options = child.modelSelection.options.filter( |
| 542 | 978 | ({ id }) => id !== "reasoningEffort", |
| 543 | 979 | ); |
| 544 | state.shells = [{ environmentId: "test", source: { ...child } }]; | |
| 545 | await act(async () => renderer.update(cloneElement(panel))); | |
| 980 | await rerender(); | |
| 546 | 981 | expect(text()).not.toContain(" · high"); |
| 547 | 982 | expect(text(true)).not.toMatch(/Fast|Ultrafast|Normal|Standard|Flex/); |
| 548 | 983 | if (iconLabel) expect(text()).toContain(iconLabel); |
| 549 | 984 | else expect(text()).not.toContain("mode on"); |
| 550 | state.projection = { | |
| 551 | ...projection, | |
| 552 | subagents: [{ ...projection.subagents[0], origin: "provider_native" }], | |
| 553 | }; | |
| 554 | await act(async () => renderer.update(cloneElement(panel))); | |
| 555 | expect(text()).not.toMatch(/Fast|Ultrafast|Normal|Standard|Flex| · high/); | |
| 556 | state.projection = projection; | |
| 557 | for (const modelSelection of [ | |
| 558 | { ...child.modelSelection, instanceId: "other" }, | |
| 559 | { ...child.modelSelection, model: "gpt-5.5" }, | |
| 560 | ]) { | |
| 561 | state.shells = [{ environmentId: "test", source: { ...child, modelSelection } }]; | |
| 562 | await act(async () => renderer.update(cloneElement(panel))); | |
| 563 | expect(text()).not.toMatch(/Fast|Ultrafast|Normal|Standard|Flex| · high/); | |
| 564 | } | |
| 565 | 985 | } |
| 566 | 986 | state.configs.set("test", { |
| 567 | 987 | providers: [ |
| ... | ... | @@ -589,11 +1009,21 @@ it("shows readable models and only differing workspace details in agent tooltips |
| 589 | 1009 | { id: "reasoningEffort", value: "high" }, |
| 590 | 1010 | { id: "serviceTier", value: "priority" }, |
| 591 | 1011 | ]; |
| 592 | state.shells = [{ environmentId: "test", source: { ...child } }]; | |
| 593 | await act(async () => renderer.update(cloneElement(panel))); | |
| 1012 | await rerender(); | |
| 594 | 1013 | expect(text(true)).toContain("My GPT · Work account · high"); |
| 595 | 1014 | expect(text()).toContain("My GPT · Work account · Fast mode onhigh"); |
| 596 | 1015 | expect(text()).not.toContain("Personal account"); |
| 1016 | state.projection = nativeProjection; | |
| 1017 | await act(async () => renderer.update(cloneElement(panel))); | |
| 1018 | expect(text()).not.toMatch(/Fast|Ultrafast|Normal|Standard|Flex| · high/); | |
| 1019 | state.projection = projection; | |
| 1020 | for (const modelSelection of [ | |
| 1021 | { ...child.modelSelection, instanceId: "other" }, | |
| 1022 | { ...child.modelSelection, model: "gpt-5.5" }, | |
| 1023 | ]) { | |
| 1024 | await rerender({ ...child, modelSelection }); | |
| 1025 | expect(text()).not.toMatch(/Fast|Ultrafast|Normal|Standard|Flex| · high/); | |
| 1026 | } | |
| 597 | 1027 | state.shells = []; |
| 598 | 1028 | for (const status of ["running", "completed", "failed"] as const) { |
| 599 | 1029 | state.projection = { |
| ... | ... | @@ -629,21 +1059,14 @@ it("shows readable models and only differing workspace details in agent tooltips |
| 629 | 1059 | state.projection = projection; |
| 630 | 1060 | state.configs.set("test", speedConfig); |
| 631 | 1061 | child.modelSelection.options = [{ id: "reasoningEffort", value: "high" }]; |
| 632 | state.shells = [ | |
| 633 | { | |
| 634 | environmentId: "test", | |
| 635 | source: { ...child, modelSelection: { ...child.modelSelection, instanceId: "other" } }, | |
| 636 | }, | |
| 637 | ]; | |
| 638 | await act(async () => renderer.update(cloneElement(panel))); | |
| 1062 | await rerender({ ...child, modelSelection: { ...child.modelSelection, instanceId: "other" } }); | |
| 639 | 1063 | expect(text()).not.toContain("My GPT ·"); |
| 640 | 1064 | const config = state.configs.get("test"); |
| 641 | 1065 | state.configs.clear(); |
| 642 | 1066 | await act(async () => renderer.update(cloneElement(panel))); |
| 643 | 1067 | expect(text()).toContain("GPT-5.4"); |
| 644 | 1068 | expect(text()).not.toContain("GPT-5.4 ·"); |
| 645 | state.shells = [{ environmentId: "test", source: { ...child } }]; | |
| 646 | await act(async () => renderer.update(cloneElement(panel))); | |
| 1069 | await rerender(); | |
| 647 | 1070 | expect(text()).toContain("GPT-5.4 · high"); |
| 648 | 1071 | state.configs.set("test", config); |
| 649 | 1072 | for (const model of ["custom/model-v1", "custom/model-v2"]) { |
| ... | ... | @@ -651,13 +1074,10 @@ it("shows readable models and only differing workspace details in agent tooltips |
| 651 | 1074 | ...projection, |
| 652 | 1075 | subagents: [{ ...projection.subagents[0], model }], |
| 653 | 1076 | }; |
| 654 | state.shells = [ | |
| 655 | { | |
| 656 | environmentId: "test", | |
| 657 | source: { ...child, modelSelection: { ...child.modelSelection, model: "custom/model-v1" } }, | |
| 658 | }, | |
| 659 | ]; | |
| 660 | await act(async () => renderer.update(cloneElement(panel))); | |
| 1077 | await rerender({ | |
| 1078 | ...child, | |
| 1079 | modelSelection: { ...child.modelSelection, model: "custom/model-v1" }, | |
| 1080 | }); | |
| 661 | 1081 | expect(text().includes(`${model} · high`)).toBe(model === "custom/model-v1"); |
| 662 | 1082 | } |
| 663 | 1083 | |
| ... | ... | @@ -692,16 +1112,14 @@ it("shows readable models and only differing workspace details in agent tooltips |
| 692 | 1112 | state.projection = projection; |
| 693 | 1113 | |
| 694 | 1114 | child.worktreePath = "/main/worktrees/checker"; |
| 695 | state.shells = [{ environmentId: "test", source: { ...child } }]; | |
| 696 | await act(async () => renderer.update(cloneElement(panel))); | |
| 1115 | await rerender(); | |
| 697 | 1116 | expect(text()).toContain("Worktree"); |
| 698 | 1117 | expect(text()).toContain("checker"); |
| 699 | 1118 | expect(text()).not.toContain("/main/worktrees"); |
| 700 | 1119 | expect(text()).not.toContain("Project"); |
| 701 | 1120 | |
| 702 | 1121 | child.branch = "fix/checker"; |
| 703 | state.shells = [{ environmentId: "test", source: { ...child } }]; | |
| 704 | await act(async () => renderer.update(cloneElement(panel))); | |
| 1122 | await rerender(); | |
| 705 | 1123 | expect(text()).toContain("Branch"); |
| 706 | 1124 | expect(text()).toContain("fix/checker"); |
| 707 | 1125 | expect(text()).not.toContain("Worktree"); |
| ... | ... | @@ -710,8 +1128,7 @@ it("shows readable models and only differing workspace details in agent tooltips |
| 710 | 1128 | child.projectId = "other"; |
| 711 | 1129 | child.worktreePath = null; |
| 712 | 1130 | child.branch = null; |
| 713 | state.shells = [{ environmentId: "test", source: { ...child } }]; | |
| 714 | await act(async () => renderer.update(cloneElement(panel))); | |
| 1131 | await rerender(); | |
| 715 | 1132 | expect(text()).toContain("Other project"); |
| 716 | 1133 | expect(text()).toContain("Workspace"); |
| 717 | 1134 | expect(text()).toContain("other"); |
apps/web/src/components/chat/ThreadRelationshipsControl.tsx+160-70| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | |
| 1 | 2 | import { ThreadDetailsControl } from "./ThreadDetailsControl"; |
| 2 | 3 | import { ThreadHoverCardPopup } from "../ThreadHoverCard"; |
| 3 | 4 | import { ThreadDetailsSection } from "./ThreadDetailsSection"; |
| ... | ... | @@ -6,6 +7,7 @@ import { SubagentTooltipContent } from "./SubagentTooltipContent"; |
| 6 | 7 | import { PullRequestGlyph } from "../pullRequest/pullRequestIcons"; |
| 7 | 8 | import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 8 | 9 | import { |
| 10 | deriveWorkflowGroups, | |
| 9 | 11 | projectedSubagentsToRuntime, |
| 10 | 12 | type RuntimeSubagent, |
| 11 | 13 | } from "@t3tools/client-runtime/state/subagentRuntime"; |
| ... | ... | @@ -24,11 +26,12 @@ import { |
| 24 | 26 | canDetachThreadProviderSession, |
| 25 | 27 | resolveLatestMergeBackRun, |
| 26 | 28 | } from "@t3tools/client-runtime/state/thread-workflows"; |
| 27 | import type { | |
| 28 | EnvironmentId, | |
| 29 | OrchestrationV2Subagent, | |
| 30 | OrchestrationV2ThreadShell, | |
| 31 | ThreadId, | |
| 29 | import { | |
| 30 | NodeId, | |
| 31 | type EnvironmentId, | |
| 32 | type OrchestrationV2Subagent, | |
| 33 | type OrchestrationV2ThreadShell, | |
| 34 | type ThreadId, | |
| 32 | 35 | } from "@t3tools/contracts"; |
| 33 | 36 | import { deriveSubagentElapsedMs } from "@t3tools/shared/orchestrationTiming"; |
| 34 | 37 | import { groupBy } from "effect/Array"; |
| ... | ... | @@ -56,9 +59,11 @@ import { |
| 56 | 59 | useThreadShells, |
| 57 | 60 | } from "../../state/entities"; |
| 58 | 61 | import { threadEnvironment } from "../../state/threads"; |
| 62 | import { orchestrationEnvironment } from "../../state/orchestration"; | |
| 59 | 63 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 60 | 64 | import { AgentElapsed } from "./AgentElapsed"; |
| 61 | 65 | import { ThreadRelationshipIcon, threadRelationshipStatusLabel } from "./ThreadRelationshipIcon"; |
| 66 | import { ThreadLineageWorkflowRow } from "./ThreadLineageWorkflowRow"; | |
| 62 | 67 | |
| 63 | 68 | import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; |
| 64 | 69 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| ... | ... | @@ -97,11 +102,13 @@ export function ThreadLineageRowList(props: { |
| 97 | 102 | component: this sits inside an already scrolling panel, where a |
| 98 | 103 | max-height-only virtual viewport measures badly. Every row is a focusable |
| 99 | 104 | button, so keyboard users reach and scroll the region through the rows |
| 100 | themselves and the container needs no extra tab stop of its own. | |
| 105 | themselves and the container needs no extra tab stop of its own. An | |
| 106 | expanded workflow asks for a taller window, so it raises the bound while | |
| 107 | its tree is open. | |
| 101 | 108 | */} |
| 102 | 109 | <ul |
| 103 | 110 | aria-label="Related threads" |
| 104 | className="m-0 max-h-[13.5rem] list-none overflow-y-auto overscroll-contain p-0" | |
| 111 | className="m-0 max-h-[13.5rem] list-none overflow-y-auto overscroll-contain p-0 has-data-workflow-expanded:max-h-[min(28rem,55dvh)]" | |
| 105 | 112 | > |
| 106 | 113 | {props.children} |
| 107 | 114 | </ul> |
| ... | ... | @@ -136,6 +143,7 @@ function ThreadLineageGroup(props: { |
| 136 | 143 | <div> |
| 137 | 144 | {props.label ? ( |
| 138 | 145 | <CollapsibleSectionHeader |
| 146 | variant="panel" | |
| 139 | 147 | expanded={expanded} |
| 140 | 148 | onClick={() => setExpanded(!expanded)} |
| 141 | 149 | accessory={ |
| ... | ... | @@ -217,28 +225,39 @@ export function ThreadRelationshipsPanel(props: { |
| 217 | 225 | const ref = scopeThreadRef(props.environmentId, props.threadId); |
| 218 | 226 | const projection = useThreadProjection(ref)?.projection ?? null; |
| 219 | 227 | const providers = useServerConfigs().get(props.environmentId)?.providers; |
| 220 | const subagentsByThreadId = useMemo( | |
| 221 | () => | |
| 222 | new Map( | |
| 223 | (projection?.subagents ?? []) | |
| 224 | .filter((subagent) => subagent.childThreadId !== null) | |
| 225 | .map((subagent) => [ | |
| 226 | subagent.childThreadId, | |
| 227 | { | |
| 228 | ...projectedSubagentsToRuntime([subagent])[0]!, | |
| 229 | id: subagent.id, | |
| 230 | threadId: subagent.threadId, | |
| 231 | nativeTaskRef: subagent.nativeTaskRef, | |
| 232 | nativeStatus: subagent.status, | |
| 233 | driver: subagent.driver, | |
| 234 | providerInstanceId: subagent.providerInstanceId, | |
| 235 | origin: subagent.origin, | |
| 236 | modelSelection: subagent.modelSelection, | |
| 237 | }, | |
| 238 | ]), | |
| 239 | ), | |
| 228 | const runtimeSubagents = useMemo( | |
| 229 | () => projectedSubagentsToRuntime(projection?.subagents ?? []), | |
| 240 | 230 | [projection?.subagents], |
| 241 | 231 | ); |
| 232 | const subagentsByThreadId = useMemo(() => { | |
| 233 | const byId = new Map(runtimeSubagents.map((agent) => [agent.id, agent])); | |
| 234 | return new Map( | |
| 235 | (projection?.subagents ?? []) | |
| 236 | .filter((subagent) => subagent.childThreadId !== null) | |
| 237 | .map((subagent) => [ | |
| 238 | subagent.childThreadId, | |
| 239 | { | |
| 240 | ...byId.get(subagent.id)!, | |
| 241 | id: subagent.id, | |
| 242 | threadId: subagent.threadId, | |
| 243 | runId: subagent.runId, | |
| 244 | nativeTaskRef: subagent.nativeTaskRef, | |
| 245 | nativeStatus: subagent.status, | |
| 246 | driver: subagent.driver, | |
| 247 | providerInstanceId: subagent.providerInstanceId, | |
| 248 | origin: subagent.origin, | |
| 249 | modelSelection: subagent.modelSelection, | |
| 250 | }, | |
| 251 | ]), | |
| 252 | ); | |
| 253 | }, [projection?.subagents, runtimeSubagents]); | |
| 254 | // A workflow coordinator is already a subagent row here; the grouped model | |
| 255 | // is what lets that row unfold into the phases and members it ran. | |
| 256 | const workflowGroupsById = useMemo( | |
| 257 | () => | |
| 258 | new Map(deriveWorkflowGroups(runtimeSubagents).map((group) => [group.workflow.id, group])), | |
| 259 | [runtimeSubagents], | |
| 260 | ); | |
| 242 | 261 | const threadShells = useThreadShells(); |
| 243 | 262 | const projects = useProjects().filter((project) => project.environmentId === props.environmentId); |
| 244 | 263 | const archived = useArchivedThreadSnapshots([props.environmentId]); |
| ... | ... | @@ -260,8 +279,12 @@ export function ThreadRelationshipsPanel(props: { |
| 260 | 279 | const mergeBack = useAtomCommand(threadEnvironment.mergeBack); |
| 261 | 280 | const stopSession = useAtomCommand(threadEnvironment.stopSession); |
| 262 | 281 | const interruptTurn = useAtomCommand(threadEnvironment.interruptTurn); |
| 282 | const stopWorkflowCommand = useAtomCommand(orchestrationEnvironment.stopWorkflow); | |
| 283 | const canOperateWorkflow = useAtomValue( | |
| 284 | orchestrationEnvironment.stopWorkflow.permissionAtom(props.environmentId), | |
| 285 | ); | |
| 263 | 286 | const [busyAction, setBusyAction] = useState<"merge" | "detach" | null>(null); |
| 264 | const [stoppingThreadId, setStoppingThreadId] = useState<ThreadId | null>(null); | |
| 287 | const [stoppingId, setStoppingId] = useState<string | null>(null); | |
| 265 | 288 | const latestMergeBackRun = projection === null ? null : resolveLatestMergeBackRun(projection); |
| 266 | 289 | const mergeTargetThreadId = resolveMergeBackTargetThreadId(projection); |
| 267 | 290 | const relationshipRows = useMemo( |
| ... | ... | @@ -271,8 +294,17 @@ export function ThreadRelationshipsPanel(props: { |
| 271 | 294 | rows: immediateThreadRelationships(graph, props.threadId), |
| 272 | 295 | currentThreadId: props.threadId, |
| 273 | 296 | mergeTargetThreadId, |
| 297 | }).map((row) => { | |
| 298 | const agent = | |
| 299 | row.edge.kind === "subagent" && !isParentThreadRelationship(row.edge, props.threadId) | |
| 300 | ? subagentsByThreadId.get(row.threadId) | |
| 301 | : undefined; | |
| 302 | const workflowGroup = agent === undefined ? undefined : workflowGroupsById.get(agent.id); | |
| 303 | return workflowGroup === undefined | |
| 304 | ? row | |
| 305 | : { ...row, edge: { ...row.edge, status: workflowGroup.workflow.status } }; | |
| 274 | 306 | }), |
| 275 | [graph, mergeTargetThreadId, props.threadId], | |
| 307 | [graph, mergeTargetThreadId, props.threadId, subagentsByThreadId, workflowGroupsById], | |
| 276 | 308 | ); |
| 277 | 309 | const canMerge = mergeTargetThreadId !== null && latestMergeBackRun !== null; |
| 278 | 310 | const canDetach = projection ? canDetachThreadProviderSession(projection) : false; |
| ... | ... | @@ -341,8 +373,8 @@ export function ThreadRelationshipsPanel(props: { |
| 341 | 373 | childThreadId: ThreadId, |
| 342 | 374 | agent: Pick<OrchestrationV2Subagent, "id" | "origin" | "threadId">, |
| 343 | 375 | ) => { |
| 344 | if (stoppingThreadId !== null) return; | |
| 345 | setStoppingThreadId(childThreadId); | |
| 376 | if (stoppingId !== null) return; | |
| 377 | setStoppingId(childThreadId); | |
| 346 | 378 | const result = await interruptTurn({ |
| 347 | 379 | environmentId: props.environmentId, |
| 348 | 380 | input: |
| ... | ... | @@ -350,12 +382,25 @@ export function ThreadRelationshipsPanel(props: { |
| 350 | 382 | ? { threadId: agent.threadId, subagentId: agent.id } |
| 351 | 383 | : { threadId: childThreadId }, |
| 352 | 384 | }); |
| 353 | setStoppingThreadId(null); | |
| 385 | setStoppingId(null); | |
| 354 | 386 | if (result._tag === "Failure") { |
| 355 | 387 | toastManager.add({ type: "error", title: "Could not stop subagent" }); |
| 356 | 388 | } |
| 357 | 389 | }; |
| 358 | 390 | |
| 391 | const stopWorkflow = async (subagentId: string) => { | |
| 392 | if (stoppingId !== null) return; | |
| 393 | setStoppingId(subagentId); | |
| 394 | const result = await stopWorkflowCommand({ | |
| 395 | environmentId: props.environmentId, | |
| 396 | input: { threadId: props.threadId, subagentId: NodeId.make(subagentId) }, | |
| 397 | }); | |
| 398 | setStoppingId(null); | |
| 399 | if (result._tag === "Failure") { | |
| 400 | toastManager.add({ type: "error", title: "Could not stop workflow" }); | |
| 401 | } | |
| 402 | }; | |
| 403 | ||
| 359 | 404 | const parentTitle = |
| 360 | 405 | mergeTargetThreadId === null |
| 361 | 406 | ? null |
| ... | ... | @@ -400,33 +445,41 @@ export function ThreadRelationshipsPanel(props: { |
| 400 | 445 | const isSubagent = edge.kind === "subagent"; |
| 401 | 446 | const isMergeTarget = threadId === mergeTargetThreadId; |
| 402 | 447 | const isParent = isParentThreadRelationship(edge, props.threadId); |
| 403 | const status = threadRelationshipRowStatus(graph, { threadId, edge }); | |
| 448 | const rowStatus = threadRelationshipRowStatus(graph, { threadId, edge }); | |
| 404 | 449 | const RelationshipIcon = isParent |
| 405 | 450 | ? CornerLeftUpIcon |
| 406 | 451 | : isSubagent |
| 407 | 452 | ? BotIcon |
| 408 | 453 | : GitForkIcon; |
| 409 | 454 | const relationship = relationshipLabel(edge, props.threadId); |
| 410 | const agent = currentSubagent( | |
| 411 | isSubagent && !isParent ? subagentsByThreadId.get(threadId) : undefined, | |
| 412 | node?.thread, | |
| 413 | ); | |
| 414 | const failed = status === "failed" || status === "error"; | |
| 455 | const projectedAgent = | |
| 456 | isSubagent && !isParent ? subagentsByThreadId.get(threadId) : undefined; | |
| 457 | const workflowGroup = | |
| 458 | projectedAgent === undefined | |
| 459 | ? undefined | |
| 460 | : workflowGroupsById.get(projectedAgent.id); | |
| 461 | const agent = workflowGroup | |
| 462 | ? projectedAgent | |
| 463 | : currentSubagent(projectedAgent, node?.thread); | |
| 464 | const failed = rowStatus === "failed" || rowStatus === "error"; | |
| 415 | 465 | const canStop = |
| 416 | 466 | agent && |
| 417 | 467 | (agent.origin === "app_owned" || |
| 418 | 468 | (agent.origin === "provider_native" && |
| 419 | 469 | agent.driver === "claudeAgent" && |
| 470 | agent.runId != null && | |
| 420 | 471 | agent.nativeTaskRef?.strength === "strong" && |
| 421 | 472 | agent.nativeTaskRef.nativeId !== null && |
| 422 | 473 | ["pending", "running", "waiting"].includes(agent.nativeStatus))) && |
| 423 | 474 | agent.startedAt && |
| 424 | 475 | ["pending", "running", "waiting"].includes(agent.status); |
| 425 | const trailingVisibilityClass = canStop | |
| 426 | ? "group-hover:opacity-0 group-focus-within:opacity-0 pointer-coarse:opacity-0 [@media(hover:none)]:opacity-0" | |
| 427 | : ""; | |
| 428 | 476 | const threadTitle = relationshipThreadTitle({ |
| 429 | title: node?.thread?.title ?? agent?.title ?? threadId, | |
| 477 | // A workflow's name is shorter than its child thread's title. | |
| 478 | title: | |
| 479 | workflowGroup?.workflow.workflowName ?? | |
| 480 | node?.thread?.title ?? | |
| 481 | agent?.title ?? | |
| 482 | threadId, | |
| 430 | 483 | isSubagent, |
| 431 | 484 | }); |
| 432 | 485 | const provider = providers?.find( |
| ... | ... | @@ -435,6 +488,14 @@ export function ThreadRelationshipsPanel(props: { |
| 435 | 488 | (agent?.providerInstanceId ?? node?.thread?.providerInstanceId), |
| 436 | 489 | ); |
| 437 | 490 | const providerDriver = agent?.driver ?? provider?.driver; |
| 491 | const canStopWorkflow = | |
| 492 | canOperateWorkflow && | |
| 493 | workflowGroup?.workflow.status === "running" && | |
| 494 | agent?.origin === "provider_native" && | |
| 495 | providerDriver === "claudeAgent"; | |
| 496 | const trailingVisibilityClass = (workflowGroup ? canStopWorkflow : canStop) | |
| 497 | ? "group-hover:opacity-0 group-focus-within:opacity-0 pointer-coarse:opacity-0 [@media(hover:none)]:opacity-0" | |
| 498 | : ""; | |
| 438 | 499 | const project = projects.find((project) => project.id === node?.thread?.projectId); |
| 439 | 500 | const relationshipHint = node?.missing |
| 440 | 501 | ? "This related thread is unavailable" |
| ... | ... | @@ -468,7 +529,7 @@ export function ThreadRelationshipsPanel(props: { |
| 468 | 529 | driver={isSubagent && !isParent ? providerDriver : undefined} |
| 469 | 530 | provider={provider} |
| 470 | 531 | fallbackIcon={RelationshipIcon} |
| 471 | status={status} | |
| 532 | status={rowStatus} | |
| 472 | 533 | /> |
| 473 | 534 | <span className="min-w-0 flex-1"> |
| 474 | 535 | <span className="block truncate text-left text-sm font-medium leading-4 text-foreground/85"> |
| ... | ... | @@ -478,23 +539,68 @@ export function ThreadRelationshipsPanel(props: { |
| 478 | 539 | {agent ? null : ( |
| 479 | 540 | <ArrowRightIcon className="size-3 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" /> |
| 480 | 541 | )} |
| 481 | {/* One trailing item keeps room for the title: the icon dot already | |
| 482 | carries status, so an agent with a known time shows only that. */} | |
| 483 | {agent && !failed && deriveSubagentElapsedMs(agent, 0) !== null ? ( | |
| 542 | {agent && | |
| 543 | !failed && | |
| 544 | (deriveSubagentElapsedMs(agent, 0) !== null || canStopWorkflow) ? ( | |
| 484 | 545 | <span |
| 485 | className={`shrink-0 text-2xs font-normal tabular-nums text-muted-foreground ${trailingVisibilityClass}`} | |
| 546 | className={`shrink-0 text-2xs font-normal tabular-nums text-muted-foreground ${canStopWorkflow ? "min-w-6 text-end" : ""} ${trailingVisibilityClass}`} | |
| 486 | 547 | > |
| 487 | 548 | <AgentElapsed agent={agent} compact /> |
| 488 | 549 | </span> |
| 489 | 550 | ) : !isMergeTarget ? ( |
| 490 | 551 | <span |
| 491 | className={`shrink-0 text-2xs ${failed ? "text-destructive" : "text-muted-foreground"} ${trailingVisibilityClass}`} | |
| 552 | className={ | |
| 553 | workflowGroup | |
| 554 | ? "sr-only" | |
| 555 | : `shrink-0 text-2xs ${failed ? "text-destructive" : "text-muted-foreground"} ${trailingVisibilityClass}` | |
| 556 | } | |
| 492 | 557 | > |
| 493 | {threadRelationshipStatusLabel(status)} | |
| 558 | {threadRelationshipStatusLabel(rowStatus)} | |
| 494 | 559 | </span> |
| 495 | 560 | ) : null} |
| 496 | 561 | </> |
| 497 | 562 | ); |
| 563 | // A workflow pairs this row with a disclosure, so it renders as the | |
| 564 | // leading half of a split row; everything else renders it directly. | |
| 565 | const relationshipLink = ( | |
| 566 | <Tooltip> | |
| 567 | <TooltipTrigger | |
| 568 | delay={200} | |
| 569 | render={ | |
| 570 | <ThreadDetailsControl | |
| 571 | size="sm" | |
| 572 | variant="ghost" | |
| 573 | part={workflowGroup ? "primary" : "row"} | |
| 574 | disabled={node?.missing === true} | |
| 575 | onClick={() => openThread(threadId)} | |
| 576 | /> | |
| 577 | } | |
| 578 | > | |
| 579 | {relationshipContent} | |
| 580 | </TooltipTrigger> | |
| 581 | <RelationshipPopup side="left">{relationshipTooltip}</RelationshipPopup> | |
| 582 | </Tooltip> | |
| 583 | ); | |
| 584 | if (workflowGroup && agent) { | |
| 585 | return ( | |
| 586 | <ThreadLineageWorkflowRow | |
| 587 | key={threadId} | |
| 588 | group={workflowGroup} | |
| 589 | providerInstanceId={agent.providerInstanceId} | |
| 590 | provider={provider} | |
| 591 | providers={providers} | |
| 592 | driver={providerDriver} | |
| 593 | onOpenThread={(memberThreadId) => openThread(memberThreadId as ThreadId)} | |
| 594 | isThreadAvailable={(memberThreadId) => | |
| 595 | graph.nodes.get(memberThreadId as ThreadId)?.missing === false | |
| 596 | } | |
| 597 | onStop={canStopWorkflow ? () => void stopWorkflow(agent.id) : undefined} | |
| 598 | stopping={stoppingId === agent.id} | |
| 599 | stopDisabled={stoppingId !== null} | |
| 600 | header={relationshipLink} | |
| 601 | /> | |
| 602 | ); | |
| 603 | } | |
| 498 | 604 | return ( |
| 499 | 605 | <li key={threadId} className="group relative flex h-8 items-center rounded-lg"> |
| 500 | 606 | {isMergeTarget ? ( |
| ... | ... | @@ -507,7 +613,7 @@ export function ThreadRelationshipsPanel(props: { |
| 507 | 613 | size="sm" |
| 508 | 614 | variant="ghost" |
| 509 | 615 | part="primary" |
| 510 | aria-label={`${threadTitle} ${threadRelationshipStatusLabel(status)}`} | |
| 616 | aria-label={`${threadTitle} ${threadRelationshipStatusLabel(rowStatus)}`} | |
| 511 | 617 | disabled={node?.missing === true} |
| 512 | 618 | onClick={() => openThread(threadId)} |
| 513 | 619 | /> |
| ... | ... | @@ -553,27 +659,11 @@ export function ThreadRelationshipsPanel(props: { |
| 553 | 659 | </TooltipPopup> |
| 554 | 660 | </Tooltip> |
| 555 | 661 | <span className="shrink-0 border border-transparent ps-1 pe-2.5 text-2xs font-medium text-muted-foreground"> |
| 556 | {threadRelationshipStatusLabel(status)} | |
| 662 | {threadRelationshipStatusLabel(rowStatus)} | |
| 557 | 663 | </span> |
| 558 | 664 | </div> |
| 559 | 665 | ) : ( |
| 560 | <Tooltip> | |
| 561 | <TooltipTrigger | |
| 562 | delay={200} | |
| 563 | render={ | |
| 564 | <ThreadDetailsControl | |
| 565 | size="sm" | |
| 566 | variant="ghost" | |
| 567 | disabled={node?.missing === true} | |
| 568 | onClick={() => openThread(threadId)} | |
| 569 | part="row" | |
| 570 | /> | |
| 571 | } | |
| 572 | > | |
| 573 | {relationshipContent} | |
| 574 | </TooltipTrigger> | |
| 575 | <RelationshipPopup side="left">{relationshipTooltip}</RelationshipPopup> | |
| 576 | </Tooltip> | |
| 666 | relationshipLink | |
| 577 | 667 | )} |
| 578 | 668 | {canStop && agent ? ( |
| 579 | 669 | <div className="pointer-events-none absolute right-1 top-1/2 -translate-y-1/2 opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100 pointer-coarse:pointer-events-auto pointer-coarse:opacity-100 [@media(hover:none)]:pointer-events-auto [@media(hover:none)]:opacity-100"> |
| ... | ... | @@ -586,12 +676,12 @@ export function ThreadRelationshipsPanel(props: { |
| 586 | 676 | part="icon" |
| 587 | 677 | tone="destructive" |
| 588 | 678 | aria-label={`Stop subagent ${threadTitle}`} |
| 589 | disabled={stoppingThreadId !== null} | |
| 679 | disabled={stoppingId !== null} | |
| 590 | 680 | onClick={() => void stopSubagent(threadId, agent)} |
| 591 | 681 | /> |
| 592 | 682 | } |
| 593 | 683 | > |
| 594 | {stoppingThreadId === threadId ? ( | |
| 684 | {stoppingId === threadId ? ( | |
| 595 | 685 | <LoaderCircleIcon aria-hidden className="size-3 animate-spin" /> |
| 596 | 686 | ) : ( |
| 597 | 687 | <SquareIcon aria-hidden className="size-3 fill-current" /> |
apps/web/src/components/ui/collapsible-section-header.tsx+4-1| ... | ... | @@ -14,11 +14,13 @@ export function CollapsibleSectionHeader({ |
| 14 | 14 | children, |
| 15 | 15 | expanded, |
| 16 | 16 | tone = "muted", |
| 17 | variant = "default", | |
| 17 | 18 | accessory, |
| 18 | 19 | ...buttonProps |
| 19 | 20 | }: Omit<ComponentProps<"button">, "className" | "style" | "aria-expanded"> & { |
| 20 | 21 | expanded: boolean; |
| 21 | 22 | tone?: keyof typeof tones; |
| 23 | variant?: "default" | "panel"; | |
| 22 | 24 | accessory?: ReactNode; |
| 23 | 25 | }) { |
| 24 | 26 | return ( |
| ... | ... | @@ -27,7 +29,8 @@ export function CollapsibleSectionHeader({ |
| 27 | 29 | type="button" |
| 28 | 30 | aria-expanded={expanded} |
| 29 | 31 | className={cn( |
| 30 | "flex h-8 w-full cursor-pointer items-center gap-2 rounded-md px-2 text-left text-xs font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70", | |
| 32 | "flex h-8 w-full cursor-pointer items-center gap-2 rounded-md text-left text-xs font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70", | |
| 33 | variant === "panel" ? "ps-2 pe-2.5" : "px-2", | |
| 31 | 34 | tones[tone].label, |
| 32 | 35 | )} |
| 33 | 36 | > |
packages/client-runtime/src/state/orchestration.ts+4| ... | ... | @@ -44,6 +44,10 @@ export function createOrchestrationEnvironmentAtoms<R, E>( |
| 44 | 44 | staleTimeMs: 300_000, |
| 45 | 45 | idleTtlMs: 300_000, |
| 46 | 46 | }), |
| 47 | stopWorkflow: createEnvironmentRpcCommand(runtime, { | |
| 48 | label: "environment-data:orchestration:stop-workflow", | |
| 49 | tag: ORCHESTRATION_V2_WS_METHODS.stopWorkflow, | |
| 50 | }), | |
| 47 | 51 | // Keyed by the item revision, so a live row refetches as its output grows. |
| 48 | 52 | turnItem: createEnvironmentRpcQueryAtomFamily(runtime, { |
| 49 | 53 | label: "environment-data:orchestration:turn-item", |
packages/client-runtime/src/state/subagentRuntime.test.ts created+220| ... | ... | @@ -0,0 +1,220 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | import type { OrchestrationV2Subagent, OrchestrationV2SubagentWorkflow } from "@t3tools/contracts"; | |
| 3 | import * as DateTime from "effect/DateTime"; | |
| 4 | ||
| 5 | import { deriveWorkflowGroups, projectedSubagentsToRuntime } from "./subagentRuntime.ts"; | |
| 6 | ||
| 7 | const at = (iso: string) => DateTime.makeUnsafe(iso); | |
| 8 | ||
| 9 | type ProjectedSubagent = Parameters<typeof projectedSubagentsToRuntime>[0][number]; | |
| 10 | ||
| 11 | function subagent(overrides: Partial<ProjectedSubagent> = {}): ProjectedSubagent { | |
| 12 | return { | |
| 13 | id: "coordinator", | |
| 14 | title: "probe", | |
| 15 | prompt: "export const meta = …", | |
| 16 | model: "claude-opus-5[1m]", | |
| 17 | status: "running" satisfies OrchestrationV2Subagent["status"], | |
| 18 | result: null, | |
| 19 | startedAt: at("2026-08-01T10:00:00.000Z"), | |
| 20 | completedAt: null, | |
| 21 | updatedAt: at("2026-08-01T10:00:05.000Z"), | |
| 22 | ...overrides, | |
| 23 | }; | |
| 24 | } | |
| 25 | ||
| 26 | const member = ( | |
| 27 | overrides: Partial<OrchestrationV2SubagentWorkflow["agents"][number]> = {}, | |
| 28 | ): OrchestrationV2SubagentWorkflow["agents"][number] => ({ | |
| 29 | index: 1, | |
| 30 | label: "alpha:one", | |
| 31 | state: "running", | |
| 32 | phaseIndex: 1, | |
| 33 | phaseTitle: "Alpha", | |
| 34 | ...overrides, | |
| 35 | }); | |
| 36 | ||
| 37 | const groupsOf = (subagents: ReadonlyArray<ProjectedSubagent>) => | |
| 38 | deriveWorkflowGroups(projectedSubagentsToRuntime(subagents)); | |
| 39 | ||
| 40 | describe("projectedSubagentsToRuntime workflow expansion", () => { | |
| 41 | it("leaves a plain subagent flat", () => { | |
| 42 | const [agent, ...rest] = projectedSubagentsToRuntime([subagent()]); | |
| 43 | expect(rest).toEqual([]); | |
| 44 | expect(agent?.kind).toBe("subagent"); | |
| 45 | expect(agent?.phases).toEqual([]); | |
| 46 | }); | |
| 47 | ||
| 48 | it("projects a coordinator plus one row per member", () => { | |
| 49 | const rows = projectedSubagentsToRuntime([ | |
| 50 | subagent({ | |
| 51 | workflow: { | |
| 52 | name: "probe-wf", | |
| 53 | phases: [ | |
| 54 | { index: 1, title: "Alpha" }, | |
| 55 | { index: 2, title: "Beta" }, | |
| 56 | ], | |
| 57 | agents: [member(), member({ index: 2, label: "alpha:two", state: "queued" })], | |
| 58 | totalTokens: 1000, | |
| 59 | }, | |
| 60 | }), | |
| 61 | ]); | |
| 62 | expect(rows.map((row) => row.kind)).toEqual(["workflow", "workflow_agent", "workflow_agent"]); | |
| 63 | expect(rows[0]?.workflowName).toBe("probe-wf"); | |
| 64 | expect(rows[0]?.usage?.totalTokens).toBe(1000); | |
| 65 | expect(rows[1]).toMatchObject({ | |
| 66 | id: "coordinator:agent:1", | |
| 67 | title: "alpha:one", | |
| 68 | parentAgentId: "coordinator", | |
| 69 | phaseIndex: 1, | |
| 70 | phaseTitle: "Alpha", | |
| 71 | status: "running", | |
| 72 | }); | |
| 73 | // A queued member is not started work: it must not report an elapsed clock. | |
| 74 | expect(rows[2]?.status).toBe("pending"); | |
| 75 | expect(rows[2]?.startedAt).toBeNull(); | |
| 76 | }); | |
| 77 | ||
| 78 | it("derives a settled member's end from its reported duration", () => { | |
| 79 | const [, settled] = projectedSubagentsToRuntime([ | |
| 80 | subagent({ | |
| 81 | workflow: { | |
| 82 | phases: [], | |
| 83 | agents: [ | |
| 84 | member({ | |
| 85 | state: "completed", | |
| 86 | startedAt: Date.UTC(2026, 7, 1, 10, 0, 0), | |
| 87 | durationMs: 1500, | |
| 88 | totalTokens: 71_141, | |
| 89 | toolCalls: 3, | |
| 90 | prompt: "Reply with exactly: A1", | |
| 91 | result: "A1", | |
| 92 | }), | |
| 93 | ], | |
| 94 | }, | |
| 95 | }), | |
| 96 | ]); | |
| 97 | expect(settled?.status).toBe("completed"); | |
| 98 | expect(settled?.completedAt).toBe("2026-08-01T10:00:01.500Z"); | |
| 99 | expect(settled?.result).toBe("A1"); | |
| 100 | // The prompt stays on progress so the detail view can show both halves. | |
| 101 | expect(settled?.progress).toBe("Reply with exactly: A1"); | |
| 102 | expect(settled?.usage).toEqual({ totalTokens: 71_141, toolUses: 3, durationMs: 1500 }); | |
| 103 | }); | |
| 104 | ||
| 105 | it.each(["completed", "cancelled", "failed"] as const)( | |
| 106 | "settles unfinished members when the coordinator is %s", | |
| 107 | (status) => { | |
| 108 | const groups = groupsOf([ | |
| 109 | subagent({ | |
| 110 | status, | |
| 111 | completedAt: at("2026-08-01T10:00:09.000Z"), | |
| 112 | workflow: { | |
| 113 | phases: [], | |
| 114 | agents: [ | |
| 115 | member(), | |
| 116 | member({ index: 2, state: "queued" }), | |
| 117 | member({ index: 3, state: "completed" }), | |
| 118 | ], | |
| 119 | }, | |
| 120 | }), | |
| 121 | ]); | |
| 122 | // The settled run keeps its members reachable under the coordinator. | |
| 123 | expect(groups[0]?.workflow.status).toBe(status); | |
| 124 | const members = groups[0]?.phases.flatMap((phase) => phase.members); | |
| 125 | expect(members?.map((agent) => agent.status)).toEqual([status, status, "completed"]); | |
| 126 | expect(members?.[0]?.completedAt).toBe("2026-08-01T10:00:09.000Z"); | |
| 127 | }, | |
| 128 | ); | |
| 129 | ||
| 130 | it("surfaces a retried member through the row's activation count", () => { | |
| 131 | const [, retried] = projectedSubagentsToRuntime([ | |
| 132 | subagent({ workflow: { phases: [], agents: [member({ attempt: 3 })] } }), | |
| 133 | ]); | |
| 134 | expect(retried?.activationCount).toBe(3); | |
| 135 | expect(retried?.attempt).toBe(3); | |
| 136 | }); | |
| 137 | }); | |
| 138 | ||
| 139 | describe("deriveWorkflowGroups over a dynamic workflow", () => { | |
| 140 | it("groups members under their phases and counts the run", () => { | |
| 141 | const groups = groupsOf([ | |
| 142 | subagent({ | |
| 143 | workflow: { | |
| 144 | name: "probe-wf", | |
| 145 | phases: [ | |
| 146 | { index: 1, title: "Alpha" }, | |
| 147 | { index: 2, title: "Beta" }, | |
| 148 | ], | |
| 149 | agents: [ | |
| 150 | member({ state: "completed" }), | |
| 151 | member({ index: 2, label: "alpha:two", state: "failed" }), | |
| 152 | member({ index: 3, label: "beta:one", phaseIndex: 2, phaseTitle: "Beta" }), | |
| 153 | member({ | |
| 154 | index: 4, | |
| 155 | label: "beta:two", | |
| 156 | phaseIndex: 2, | |
| 157 | phaseTitle: "Beta", | |
| 158 | state: "queued", | |
| 159 | }), | |
| 160 | ], | |
| 161 | }, | |
| 162 | }), | |
| 163 | ]); | |
| 164 | const group = groups[0]; | |
| 165 | expect(group?.phases.map((phase) => [phase.title, phase.state])).toEqual([ | |
| 166 | ["Alpha", "done"], | |
| 167 | ["Beta", "running"], | |
| 168 | ]); | |
| 169 | expect(group?.phases.map((phase) => phase.settledCount)).toEqual([2, 0]); | |
| 170 | expect(group?.phases.flatMap((phase) => phase.members)).toHaveLength(4); | |
| 171 | expect(group?.unphasedMembers).toEqual([]); | |
| 172 | }); | |
| 173 | ||
| 174 | it("shows a phase the script only reached at runtime", () => { | |
| 175 | const groups = groupsOf([ | |
| 176 | subagent({ | |
| 177 | workflow: { | |
| 178 | // The declared plan has not caught up with the member's phase yet. | |
| 179 | phases: [{ index: 1, title: "Alpha" }], | |
| 180 | agents: [ | |
| 181 | member({ state: "completed" }), | |
| 182 | member({ index: 2, label: "dynamic:late", phaseIndex: 3, phaseTitle: "Gamma" }), | |
| 183 | ], | |
| 184 | }, | |
| 185 | }), | |
| 186 | ]); | |
| 187 | const group = groups[0]; | |
| 188 | expect(group?.phases.map((phase) => phase.title)).toEqual(["Alpha", "Gamma"]); | |
| 189 | expect(group?.unphasedMembers).toEqual([]); | |
| 190 | }); | |
| 191 | ||
| 192 | it.each([false, true])("keeps a reported phase title, missing title first=%s", (missingFirst) => { | |
| 193 | const agents = [member({ phaseTitle: "Review" }), member({ index: 2, phaseTitle: undefined })]; | |
| 194 | const groups = groupsOf([ | |
| 195 | subagent({ workflow: { phases: [], agents: missingFirst ? agents.toReversed() : agents } }), | |
| 196 | ]); | |
| 197 | expect(groups[0]?.phases[0]?.title).toBe("Review"); | |
| 198 | }); | |
| 199 | ||
| 200 | it("keeps unphased members with their workflow and excludes unrelated agents", () => { | |
| 201 | const runtime = projectedSubagentsToRuntime([ | |
| 202 | subagent({ id: "ordinary" }), | |
| 203 | subagent({ | |
| 204 | id: "__proto__", | |
| 205 | workflow: { | |
| 206 | phases: [], | |
| 207 | agents: [member({ phaseIndex: undefined, phaseTitle: undefined })], | |
| 208 | }, | |
| 209 | }), | |
| 210 | subagent({ id: "constructor", workflow: { phases: [], agents: [] } }), | |
| 211 | subagent({ id: "orphan", workflow: { phases: [], agents: [member()] } }), | |
| 212 | ]); | |
| 213 | const groups = deriveWorkflowGroups(runtime.filter((agent) => agent.id !== "orphan")); | |
| 214 | expect(groups.map((group) => group.workflow.id)).toEqual(["__proto__", "constructor"]); | |
| 215 | expect(groups[0]?.phases).toEqual([]); | |
| 216 | expect(groups[0]?.unphasedMembers.map((agent) => agent.title)).toEqual(["alpha:one"]); | |
| 217 | expect(groups[1]?.phases).toEqual([]); | |
| 218 | expect(groups[1]?.unphasedMembers).toEqual([]); | |
| 219 | }); | |
| 220 | }); |
packages/client-runtime/src/state/subagentRuntime.ts+184-20| ... | ... | @@ -2,8 +2,13 @@ |
| 2 | 2 | * Subagent status helpers shared by web and mobile, and the runtime shape the |
| 3 | 3 | * web agent rows render. |
| 4 | 4 | */ |
| 5 | import { groupBy } from "effect/Array"; | |
| 5 | 6 | import * as DateTime from "effect/DateTime"; |
| 6 | import type { OrchestrationV2Subagent, OrchestrationV2TurnItem } from "@t3tools/contracts"; | |
| 7 | import type { | |
| 8 | OrchestrationV2Subagent, | |
| 9 | OrchestrationV2SubagentWorkflow, | |
| 10 | OrchestrationV2TurnItem, | |
| 11 | } from "@t3tools/contracts"; | |
| 7 | 12 | import { isOrchestrationV2WorkActive } from "@t3tools/contracts"; |
| 8 | 13 | |
| 9 | 14 | export type RuntimeSubagentStatus = |
| ... | ... | @@ -36,11 +41,12 @@ export interface SubagentWorkflowPhase { |
| 36 | 41 | readonly title: string; |
| 37 | 42 | } |
| 38 | 43 | |
| 44 | // Optional-and-undefined, matching the contract's own optionals. | |
| 39 | 45 | export interface SubagentRunHandles { |
| 40 | readonly runId?: string; | |
| 41 | readonly scriptPath?: string; | |
| 42 | readonly transcriptDir?: string; | |
| 43 | readonly sessionUrl?: string; | |
| 46 | readonly runId?: string | undefined; | |
| 47 | readonly scriptPath?: string | undefined; | |
| 48 | readonly transcriptDir?: string | undefined; | |
| 49 | readonly sessionUrl?: string | undefined; | |
| 44 | 50 | } |
| 45 | 51 | |
| 46 | 52 | export interface RuntimeSubagent { |
| ... | ... | @@ -66,6 +72,8 @@ export interface RuntimeSubagent { |
| 66 | 72 | readonly workflowName: string | null; |
| 67 | 73 | readonly phases: ReadonlyArray<SubagentWorkflowPhase>; |
| 68 | 74 | readonly runHandles: SubagentRunHandles | null; |
| 75 | /** The thread this agent owns, when it has one. */ | |
| 76 | readonly childThreadId: string | null; | |
| 69 | 77 | readonly recentActivity: ReadonlyArray<SubagentActivityEntry>; |
| 70 | 78 | /** First retained observation, used as the roster's stable display order. */ |
| 71 | 79 | readonly firstSeenAt: string; |
| ... | ... | @@ -91,18 +99,105 @@ export function isActiveSubagentStatus(status: RuntimeSubagentStatus): boolean { |
| 91 | 99 | return isOrchestrationV2WorkActive(status); |
| 92 | 100 | } |
| 93 | 101 | |
| 94 | /** | |
| 95 | * A subagent card whose child is still working. The card outlives its | |
| 96 | * launching turn: once that turn settles, the waiting footer counts the child, | |
| 97 | * so the card stays out of the turn's fold until the child finishes. | |
| 98 | */ | |
| 99 | 102 | export function isLiveSubagentTurnItem(item: OrchestrationV2TurnItem): boolean { |
| 100 | 103 | return item.type === "subagent" && isOrchestrationV2WorkActive(item.status); |
| 101 | 104 | } |
| 102 | 105 | |
| 106 | /** A workflow coordinator with its members grouped by phase, as Lineage renders it. */ | |
| 107 | export interface SubagentWorkflowGroup { | |
| 108 | readonly workflow: RuntimeSubagent; | |
| 109 | readonly phases: ReadonlyArray<{ | |
| 110 | readonly index: number; | |
| 111 | readonly title: string; | |
| 112 | readonly members: ReadonlyArray<RuntimeSubagent>; | |
| 113 | /** done = every member settled (success or error); running = any active. */ | |
| 114 | readonly state: "pending" | "running" | "done"; | |
| 115 | readonly settledCount: number; | |
| 116 | }>; | |
| 117 | /** Members with no resolvable phase (orphans render under the workflow). */ | |
| 118 | readonly unphasedMembers: ReadonlyArray<RuntimeSubagent>; | |
| 119 | } | |
| 120 | ||
| 121 | function workflowUsage(source: { | |
| 122 | readonly totalTokens?: number | undefined; | |
| 123 | readonly toolCalls?: number | undefined; | |
| 124 | readonly durationMs?: number | undefined; | |
| 125 | }): SubagentUsage | null { | |
| 126 | if (source.totalTokens === undefined) return null; | |
| 127 | return { | |
| 128 | totalTokens: source.totalTokens, | |
| 129 | ...(source.toolCalls === undefined ? {} : { toolUses: source.toolCalls }), | |
| 130 | ...(source.durationMs === undefined ? {} : { durationMs: source.durationMs }), | |
| 131 | }; | |
| 132 | } | |
| 133 | ||
| 134 | function isoFromEpochMillis(value: number | undefined): string | null { | |
| 135 | return value === undefined ? null : DateTime.formatIso(DateTime.makeUnsafe(value)); | |
| 136 | } | |
| 137 | ||
| 138 | /** | |
| 139 | * Expands a coordinator's workflow roster into member rows. Members are | |
| 140 | * synthesized from the coordinator's snapshot rather than read from their own | |
| 141 | * subagent entities, so a parent thread's Lineage can show them without | |
| 142 | * loading the coordinator's child thread; their identity is the coordinator id | |
| 143 | * plus the spawn ordinal. | |
| 144 | */ | |
| 145 | function workflowMembersToRuntime( | |
| 146 | coordinator: RuntimeSubagent, | |
| 147 | workflow: OrchestrationV2SubagentWorkflow, | |
| 148 | ): ReadonlyArray<RuntimeSubagent> { | |
| 149 | return workflow.agents.map((agent) => { | |
| 150 | // Every member state but queued is already a runtime status. | |
| 151 | const status = | |
| 152 | (agent.state === "queued" || agent.state === "running") && | |
| 153 | isTerminalSubagentStatus(coordinator.status) | |
| 154 | ? coordinator.status | |
| 155 | : agent.state === "queued" | |
| 156 | ? "pending" | |
| 157 | : agent.state; | |
| 158 | const failed = status === "failed"; | |
| 159 | const startedAt = isoFromEpochMillis(agent.startedAt); | |
| 160 | // The provider reports a settled member's duration, not its end instant. | |
| 161 | const completedAt = isTerminalSubagentStatus(status) | |
| 162 | ? agent.startedAt !== undefined && agent.durationMs !== undefined | |
| 163 | ? isoFromEpochMillis(agent.startedAt + agent.durationMs) | |
| 164 | : coordinator.completedAt | |
| 165 | : null; | |
| 166 | return { | |
| 167 | // Members share the run's handles: the transcript directory is what | |
| 168 | // makes their conversation readable, and only the run knows it. | |
| 169 | ...coordinator, | |
| 170 | id: `${coordinator.id}:agent:${agent.index}`, | |
| 171 | kind: "workflow_agent" as const, | |
| 172 | title: agent.label, | |
| 173 | model: agent.model ?? null, | |
| 174 | status, | |
| 175 | // A member the workflow retried counts each attempt as a run. | |
| 176 | activationCount: agent.attempt ?? 1, | |
| 177 | usage: workflowUsage(agent), | |
| 178 | // A member keeps its prompt on `progress` even once settled: that is the | |
| 179 | // question half of its conversation, and the detail view shows both halves. | |
| 180 | progress: agent.prompt ?? null, | |
| 181 | result: failed ? null : (agent.result ?? null), | |
| 182 | error: failed ? (agent.result ?? null) : null, | |
| 183 | parentAgentId: coordinator.id, | |
| 184 | agentIndex: agent.index, | |
| 185 | phaseIndex: agent.phaseIndex ?? null, | |
| 186 | phaseTitle: agent.phaseTitle ?? null, | |
| 187 | attempt: agent.attempt ?? null, | |
| 188 | phases: [], | |
| 189 | childThreadId: agent.childThreadId ?? null, | |
| 190 | recentActivity: [], | |
| 191 | firstSeenAt: isoFromEpochMillis(agent.queuedAt) ?? startedAt ?? coordinator.firstSeenAt, | |
| 192 | startedAt, | |
| 193 | completedAt, | |
| 194 | updatedAt: completedAt ?? startedAt ?? coordinator.firstSeenAt, | |
| 195 | } satisfies RuntimeSubagent; | |
| 196 | }); | |
| 197 | } | |
| 198 | ||
| 103 | 199 | /** |
| 104 | * Projects orchestration-v2 subagent entities into the runtime shape the web | |
| 105 | * agent rows render. | |
| 200 | * Projects subagents and workflow members into the runtime roster. | |
| 106 | 201 | */ |
| 107 | 202 | export function projectedSubagentsToRuntime( |
| 108 | 203 | subagents: ReadonlyArray<{ |
| ... | ... | @@ -112,18 +207,23 @@ export function projectedSubagentsToRuntime( |
| 112 | 207 | readonly model: string | null; |
| 113 | 208 | readonly status: OrchestrationV2Subagent["status"]; |
| 114 | 209 | readonly progress?: string | undefined; |
| 210 | readonly childThreadId?: string | null | undefined; | |
| 211 | readonly workflow?: OrchestrationV2SubagentWorkflow | undefined; | |
| 115 | 212 | readonly result: string | null; |
| 116 | 213 | readonly startedAt: DateTime.Utc | null; |
| 117 | 214 | readonly completedAt: DateTime.Utc | null; |
| 118 | 215 | readonly updatedAt: DateTime.Utc; |
| 119 | 216 | }>, |
| 120 | 217 | ): ReadonlyArray<RuntimeSubagent> { |
| 121 | return subagents.map((subagent) => { | |
| 218 | return subagents.flatMap((subagent) => { | |
| 122 | 219 | const updatedAt = DateTime.formatIso(subagent.updatedAt); |
| 123 | 220 | const startedAt = subagent.startedAt === null ? null : DateTime.formatIso(subagent.startedAt); |
| 124 | return { | |
| 221 | const firstSeenAt = startedAt ?? updatedAt; | |
| 222 | const { workflow } = subagent; | |
| 223 | const runHandles = workflow?.runHandles ?? null; | |
| 224 | const coordinator = { | |
| 125 | 225 | id: subagent.id, |
| 126 | kind: "subagent" as const, | |
| 226 | kind: workflow === undefined ? "subagent" : "workflow", | |
| 127 | 227 | title: |
| 128 | 228 | subagent.title ?? |
| 129 | 229 | (subagent.prompt.length > 80 ? `${subagent.prompt.slice(0, 77)}...` : subagent.prompt), |
| ... | ... | @@ -132,7 +232,7 @@ export function projectedSubagentsToRuntime( |
| 132 | 232 | effort: null, |
| 133 | 233 | status: subagent.status, |
| 134 | 234 | activationCount: 1, |
| 135 | usage: null, | |
| 235 | usage: workflow === undefined ? null : workflowUsage(workflow), | |
| 136 | 236 | progress: subagent.progress ?? null, |
| 137 | 237 | lastToolName: null, |
| 138 | 238 | result: subagent.result, |
| ... | ... | @@ -143,14 +243,78 @@ export function projectedSubagentsToRuntime( |
| 143 | 243 | phaseIndex: null, |
| 144 | 244 | phaseTitle: null, |
| 145 | 245 | attempt: null, |
| 146 | workflowName: null, | |
| 147 | phases: [], | |
| 148 | runHandles: null, | |
| 246 | workflowName: workflow?.name ?? null, | |
| 247 | phases: workflow?.phases ?? [], | |
| 248 | runHandles, | |
| 249 | childThreadId: subagent.childThreadId ?? null, | |
| 149 | 250 | recentActivity: [], |
| 150 | firstSeenAt: startedAt ?? updatedAt, | |
| 251 | firstSeenAt, | |
| 151 | 252 | startedAt, |
| 152 | 253 | completedAt: subagent.completedAt === null ? null : DateTime.formatIso(subagent.completedAt), |
| 153 | 254 | updatedAt, |
| 154 | 255 | } satisfies RuntimeSubagent; |
| 256 | return workflow === undefined | |
| 257 | ? [coordinator] | |
| 258 | : [coordinator, ...workflowMembersToRuntime(coordinator, workflow)]; | |
| 259 | }); | |
| 260 | } | |
| 261 | ||
| 262 | export function deriveWorkflowGroups( | |
| 263 | agents: ReadonlyArray<RuntimeSubagent>, | |
| 264 | ): ReadonlyArray<SubagentWorkflowGroup> { | |
| 265 | const workflows = agents | |
| 266 | .filter((agent) => agent.kind === "workflow") | |
| 267 | .sort((a, b) => a.firstSeenAt.localeCompare(b.firstSeenAt) || a.id.localeCompare(b.id)); | |
| 268 | const members = groupBy( | |
| 269 | agents.filter((agent) => agent.kind !== "workflow" && agent.parentAgentId !== null), | |
| 270 | (agent) => agent.parentAgentId!, | |
| 271 | ); | |
| 272 | ||
| 273 | return workflows.map((workflow) => { | |
| 274 | const workflowMembers = Object.hasOwn(members, workflow.id) ? members[workflow.id]! : []; | |
| 275 | // Union, not either/or: the declared plan lags the members, because the | |
| 276 | // provider only admits a phase once something in it starts. Members seed | |
| 277 | // the map; the declared titles then overwrite whatever they guessed. | |
| 278 | const phaseTitles = new Map<number, string>(); | |
| 279 | for (const member of workflowMembers) { | |
| 280 | if (member.phaseIndex === null) continue; | |
| 281 | phaseTitles.set( | |
| 282 | member.phaseIndex, | |
| 283 | member.phaseTitle ?? phaseTitles.get(member.phaseIndex) ?? `Phase ${member.phaseIndex + 1}`, | |
| 284 | ); | |
| 285 | } | |
| 286 | for (const phase of workflow.phases) phaseTitles.set(phase.index, phase.title); | |
| 287 | const knownPhases = Array.from(phaseTitles.entries()) | |
| 288 | .map(([index, title]) => ({ index, title })) | |
| 289 | .sort((a, b) => a.index - b.index); | |
| 290 | ||
| 291 | const phases = knownPhases.map((phase) => { | |
| 292 | const phaseMembers = workflowMembers | |
| 293 | .filter((member) => member.phaseIndex === phase.index) | |
| 294 | .sort((a, b) => (a.agentIndex ?? 0) - (b.agentIndex ?? 0)); | |
| 295 | const hasActiveMember = phaseMembers.some( | |
| 296 | // Idle members count as active for phase-liveness: a resumable Codex | |
| 297 | // member has not finished the phase. | |
| 298 | (member) => isActiveSubagentStatus(member.status) || member.status === "idle", | |
| 299 | ); | |
| 300 | const settledCount = phaseMembers.filter((member) => | |
| 301 | isTerminalSubagentStatus(member.status), | |
| 302 | ).length; | |
| 303 | const state: "pending" | "running" | "done" = | |
| 304 | phaseMembers.length === 0 ? "pending" : hasActiveMember ? "running" : "done"; | |
| 305 | return { | |
| 306 | index: phase.index, | |
| 307 | title: phase.title, | |
| 308 | members: phaseMembers, | |
| 309 | state, | |
| 310 | settledCount, | |
| 311 | }; | |
| 312 | }); | |
| 313 | ||
| 314 | const unphasedMembers = workflowMembers | |
| 315 | .filter((member) => member.phaseIndex === null) | |
| 316 | .sort((a, b) => (a.agentIndex ?? 0) - (b.agentIndex ?? 0)); | |
| 317 | ||
| 318 | return { workflow, phases, unphasedMembers }; | |
| 155 | 319 | }); |
| 156 | 320 | } |
packages/client-runtime/src/t3ToolSummary.test.ts+17| ... | ... | @@ -248,4 +248,21 @@ describe("summarizeT3ToolCalls", () => { |
| 248 | 248 | ]).label, |
| 249 | 249 | ).toBe("Requested deletion of 1 scheduled task"); |
| 250 | 250 | }); |
| 251 | ||
| 252 | it("counts workflow stop requests by coordinator and excludes failed requests", () => { | |
| 253 | const first = completed({ threadId: "parent", subagentId: "workflow-1" }, {}); | |
| 254 | const failed = completed({ threadId: "parent", subagentId: "workflow-3" }, { isError: true }); | |
| 255 | expect( | |
| 256 | summarizeT3ToolCalls("workflow-stop", [ | |
| 257 | first, | |
| 258 | first, | |
| 259 | completed({ threadId: "parent", subagentId: "workflow-2" }, {}), | |
| 260 | failed, | |
| 261 | ]), | |
| 262 | ).toEqual({ label: "Requested stops for 2 workflows", failedCount: 1 }); | |
| 263 | expect(summarizeT3ToolCalls("workflow-stop", [failed])).toEqual({ | |
| 264 | label: "Tried to stop 1 workflow", | |
| 265 | failedCount: 1, | |
| 266 | }); | |
| 267 | }); | |
| 251 | 268 | }); |
packages/client-runtime/src/t3ToolSummary.ts+7| ... | ... | @@ -168,6 +168,13 @@ export function summarizeT3ToolCalls( |
| 168 | 168 | quantity(countEntities(threadIds), "thread"), |
| 169 | 169 | ); |
| 170 | 170 | break; |
| 171 | case "workflow-stop": | |
| 172 | label = phrase( | |
| 173 | "Requested stops for", | |
| 174 | "stop", | |
| 175 | quantity(countEntities(entityIds("subagentId")), "workflow"), | |
| 176 | ); | |
| 177 | break; | |
| 171 | 178 | case "task-status": |
| 172 | 179 | label = phrase("Checked", "check", `task status ${times}`); |
| 173 | 180 | break; |
packages/client-runtime/src/work-log/presentation.test.ts+42| ... | ... | @@ -308,6 +308,7 @@ describe("resolveWorkEntryToolPresentation", () => { |
| 308 | 308 | ["t3_thread_fork", "Forking this thread", "Requested a fork of this thread"], |
| 309 | 309 | ["t3_thread_send_attachments", "Sending attachments", "Sent attachments"], |
| 310 | 310 | ["run_scheduled_task_now", "Running a scheduled task", "Requested a run of a scheduled task"], |
| 311 | ["t3_workflow_stop", "Stopping a workflow", "Requested a stop for a workflow"], | |
| 311 | 312 | ])("labels %s through its lifecycle", (tool, running, completed) => { |
| 312 | 313 | expect(resolveWorkEntryToolPresentation({ label: `T3-code.${tool}` })?.displayName).toBe( |
| 313 | 314 | running, |
| ... | ... | @@ -320,6 +321,47 @@ describe("resolveWorkEntryToolPresentation", () => { |
| 320 | 321 | ).toBe(completed); |
| 321 | 322 | }); |
| 322 | 323 | |
| 324 | it("summarizes workflow stop targets separately from their parent thread", () => { | |
| 325 | const entry = { | |
| 326 | id: "workflow-stop", | |
| 327 | createdAt: "2026-10-07T00:00:00.000Z", | |
| 328 | tone: "tool", | |
| 329 | label: "Custom title", | |
| 330 | itemType: "dynamic_tool", | |
| 331 | toolLifecycleStatus: "completed", | |
| 332 | toolData: { | |
| 333 | server: "t3-code", | |
| 334 | tool: "t3_workflow_stop", | |
| 335 | arguments: { threadId: "parent", subagentId: "workflow-1" }, | |
| 336 | result: {}, | |
| 337 | }, | |
| 338 | } satisfies WorkLogPresentationEntry; | |
| 339 | expect( | |
| 340 | summarizeToolGroup([ | |
| 341 | entry, | |
| 342 | entry, | |
| 343 | { | |
| 344 | ...entry, | |
| 345 | toolData: { | |
| 346 | ...entry.toolData, | |
| 347 | arguments: { threadId: "parent", subagentId: "workflow-2" }, | |
| 348 | }, | |
| 349 | }, | |
| 350 | { | |
| 351 | ...entry, | |
| 352 | toolData: { | |
| 353 | ...entry.toolData, | |
| 354 | tool: "t3_thread_interrupt", | |
| 355 | arguments: { threadId: "parent" }, | |
| 356 | }, | |
| 357 | }, | |
| 358 | ]), | |
| 359 | ).toEqual({ | |
| 360 | summary: "Requested stops for 2 workflows and requested interrupts for 1 thread", | |
| 361 | hasFailure: false, | |
| 362 | }); | |
| 363 | }); | |
| 364 | ||
| 323 | 365 | it("summarizes project tools from MCP arguments and results without claiming failed effects", () => { |
| 324 | 366 | const entry: WorkLogPresentationEntry = { |
| 325 | 367 | id: "clone", |
packages/client-runtime/src/work-log/presentation.ts+1| ... | ... | @@ -609,6 +609,7 @@ function summaryActionPriority(action: ToolGroupAction | T3McpToolSummaryAction) |
| 609 | 609 | case "thread-create": |
| 610 | 610 | case "thread-send": |
| 611 | 611 | case "thread-interrupt": |
| 612 | case "workflow-stop": | |
| 612 | 613 | case "schedule-create": |
| 613 | 614 | case "schedule-update": |
| 614 | 615 | case "schedule-delete": |
packages/contracts/src/clientRpcPermissions.ts+2| ... | ... | @@ -6,6 +6,7 @@ import { |
| 6 | 6 | AuthSourceControlWriteScope, |
| 7 | 7 | type AuthEnvironmentScope, |
| 8 | 8 | } from "./auth.ts"; |
| 9 | import { ORCHESTRATION_V2_WS_METHODS } from "./orchestrationV2.ts"; | |
| 9 | 10 | import { WS_METHODS } from "./rpc.ts"; |
| 10 | 11 | |
| 11 | 12 | /** Incremental client enforcement; the server still authorizes every request. */ |
| ... | ... | @@ -43,6 +44,7 @@ export const CLIENT_GUARDED_RPC_SCOPES = { |
| 43 | 44 | [WS_METHODS.scheduledTasksDelete]: AuthOrchestrationOperateScope, |
| 44 | 45 | [WS_METHODS.scheduledTasksRunNow]: AuthOrchestrationOperateScope, |
| 45 | 46 | [WS_METHODS.scheduledTasksRotateWebhookToken]: AuthOrchestrationOperateScope, |
| 47 | [ORCHESTRATION_V2_WS_METHODS.stopWorkflow]: AuthOrchestrationOperateScope, | |
| 46 | 48 | } as const; |
| 47 | 49 | export type ClientGuardedRpcTag = keyof typeof CLIENT_GUARDED_RPC_SCOPES; |
| 48 | 50 |
packages/contracts/src/orchestrationV2.test.ts+64| ... | ... | @@ -36,6 +36,7 @@ import { |
| 36 | 36 | OrchestrationV2ShellSnapshot, |
| 37 | 37 | OrchestrationV2SubscribeThreadInput, |
| 38 | 38 | OrchestrationV2Subagent, |
| 39 | OrchestrationV2SubagentJson, | |
| 39 | 40 | OrchestrationV2ThreadHistoryPage, |
| 40 | 41 | OrchestrationV2ThreadProjection, |
| 41 | 42 | OrchestrationV2ThreadStreamItem, |
| ... | ... | @@ -112,6 +113,7 @@ const decodeOrchestrationV2Checkpoint = Schema.decodeUnknownSync(OrchestrationV2 |
| 112 | 113 | const decodeOrchestrationV2DomainEvent = Schema.decodeUnknownSync(OrchestrationV2DomainEvent); |
| 113 | 114 | const decodeProviderReplayTranscript = Schema.decodeUnknownSync(ProviderReplayTranscript); |
| 114 | 115 | const decodeOrchestrationV2Subagent = Schema.decodeUnknownSync(OrchestrationV2Subagent); |
| 116 | const encodeOrchestrationV2SubagentJson = Schema.encodeSync(OrchestrationV2SubagentJson); | |
| 115 | 117 | const decodeOrchestrationV2ThreadProjection = Schema.decodeUnknownSync( |
| 116 | 118 | OrchestrationV2ThreadProjection, |
| 117 | 119 | ); |
| ... | ... | @@ -929,6 +931,68 @@ describe("orchestration V2 contracts", () => { |
| 929 | 931 | expect(turnItem.progress).toBe("Inspecting package metadata"); |
| 930 | 932 | }); |
| 931 | 933 | |
| 934 | it.each([" indented prompt\n", "\n \t\n"])( | |
| 935 | "preserves workflow prompt and result %j in stored and wire JSON", | |
| 936 | (preview) => { | |
| 937 | const subagent = { | |
| 938 | ...decodeOrchestrationV2Subagent({ | |
| 939 | id: "workflow-coordinator", | |
| 940 | threadId: "thread-1", | |
| 941 | runId: "run-1", | |
| 942 | parentNodeId: "node-root-1", | |
| 943 | origin: "provider_native", | |
| 944 | createdBy: "agent", | |
| 945 | driver: "claudeAgent", | |
| 946 | providerInstanceId: "claudeAgent", | |
| 947 | providerThreadId: null, | |
| 948 | childThreadId: null, | |
| 949 | nativeTaskRef: null, | |
| 950 | prompt: "Run workflow", | |
| 951 | title: null, | |
| 952 | model: null, | |
| 953 | status: "running", | |
| 954 | result: null, | |
| 955 | startedAt: now, | |
| 956 | completedAt: null, | |
| 957 | updatedAt: now, | |
| 958 | }), | |
| 959 | workflow: { | |
| 960 | phases: [], | |
| 961 | agents: [ | |
| 962 | { | |
| 963 | index: 1, | |
| 964 | label: "Member", | |
| 965 | state: "completed" as const, | |
| 966 | prompt: preview, | |
| 967 | result: preview, | |
| 968 | }, | |
| 969 | ], | |
| 970 | }, | |
| 971 | }; | |
| 972 | const { workflow, ...coordinator } = subagent; | |
| 973 | const incoming = { ...encodeOrchestrationV2SubagentJson(coordinator), workflow }; | |
| 974 | for (const codec of [ | |
| 975 | OrchestrationV2SubagentJson, | |
| 976 | Schema.toCodecJson(OrchestrationV2Subagent), | |
| 977 | ]) { | |
| 978 | const encode = Schema.encodeSync(codec); | |
| 979 | const decode = Schema.decodeUnknownSync(codec); | |
| 980 | const json: unknown = JSON.parse(JSON.stringify(encode(subagent))); | |
| 981 | expect(json).toMatchObject({ | |
| 982 | workflow: { agents: [{ prompt: preview, result: preview }] }, | |
| 983 | }); | |
| 984 | expect(decode(json).workflow?.agents[0]).toMatchObject({ | |
| 985 | prompt: preview, | |
| 986 | result: preview, | |
| 987 | }); | |
| 988 | expect(decode(incoming).workflow?.agents[0]).toMatchObject({ | |
| 989 | prompt: preview, | |
| 990 | result: preview, | |
| 991 | }); | |
| 992 | } | |
| 993 | }, | |
| 994 | ); | |
| 995 | ||
| 932 | 996 | it("decodes app-owned subagent parent-wake policies", () => { |
| 933 | 997 | const appOwnedSubagent = { |
| 934 | 998 | id: "node-subagent-2", |
packages/contracts/src/orchestrationV2.ts+155| ... | ... | @@ -664,6 +664,86 @@ export const OrchestrationV2ExecutionNode = Schema.Struct({ |
| 664 | 664 | }); |
| 665 | 665 | export type OrchestrationV2ExecutionNode = typeof OrchestrationV2ExecutionNode.Type; |
| 666 | 666 | |
| 667 | /** | |
| 668 | * One entry of a dynamic workflow's phase plan. The plan grows while the run | |
| 669 | * executes: a phase a script only reaches at runtime joins the list when it | |
| 670 | * starts, so a client must treat the list as append-only rather than as a | |
| 671 | * fixed arc declared up front. | |
| 672 | */ | |
| 673 | export const OrchestrationV2WorkflowPhase = Schema.Struct({ | |
| 674 | index: NonNegativeInt, | |
| 675 | title: TrimmedNonEmptyString, | |
| 676 | }); | |
| 677 | export type OrchestrationV2WorkflowPhase = typeof OrchestrationV2WorkflowPhase.Type; | |
| 678 | ||
| 679 | /** | |
| 680 | * One agent inside a workflow run. Timestamps are epoch milliseconds rather | |
| 681 | * than `DateTimeUtc` because only the top-level entity fields are remapped for | |
| 682 | * the JSON wire shape (see `OrchestrationV2SubagentJson`), so a nested | |
| 683 | * `DateTimeUtc` would never be encoded. | |
| 684 | */ | |
| 685 | export const OrchestrationV2WorkflowAgent = Schema.Struct({ | |
| 686 | /** Spawn ordinal within the run. Stable identity: an agent keeps it across updates. */ | |
| 687 | index: NonNegativeInt, | |
| 688 | label: TrimmedNonEmptyString, | |
| 689 | /** | |
| 690 | * Provider-assigned id, absent until the agent actually starts. Names the | |
| 691 | * member's transcript file inside the run's transcript directory. | |
| 692 | */ | |
| 693 | agentId: Schema.optional(TrimmedNonEmptyString), | |
| 694 | /** The member's own thread, so the UI can open it like any subagent's. */ | |
| 695 | childThreadId: Schema.optional(ThreadId), | |
| 696 | state: Schema.Literals(["queued", "running", "completed", "failed"]), | |
| 697 | phaseIndex: Schema.optional(NonNegativeInt), | |
| 698 | phaseTitle: Schema.optional(TrimmedNonEmptyString), | |
| 699 | model: Schema.optional(TrimmedNonEmptyString), | |
| 700 | /** Retry counter; > 1 means the agent was restarted after a provider error. */ | |
| 701 | attempt: Schema.optional(NonNegativeInt), | |
| 702 | totalTokens: Schema.optional(NonNegativeInt), | |
| 703 | toolCalls: Schema.optional(NonNegativeInt), | |
| 704 | durationMs: Schema.optional(NonNegativeInt), | |
| 705 | queuedAt: Schema.optional(NonNegativeInt), | |
| 706 | startedAt: Schema.optional(NonNegativeInt), | |
| 707 | /** | |
| 708 | * Prompt and result excerpts. A workflow member's whole conversation is one | |
| 709 | * prompt and one answer, so together these are its transcript as far as the | |
| 710 | * provider exposes it — both are capped by the CLI at a few hundred | |
| 711 | * characters. | |
| 712 | */ | |
| 713 | prompt: Schema.optional(Schema.NonEmptyString), | |
| 714 | result: Schema.optional(Schema.NonEmptyString), | |
| 715 | }); | |
| 716 | export type OrchestrationV2WorkflowAgent = typeof OrchestrationV2WorkflowAgent.Type; | |
| 717 | ||
| 718 | /** | |
| 719 | * Filesystem handles the provider reports when a run launches. The transcript | |
| 720 | * directory is what makes a member's full answer readable: the progress | |
| 721 | * snapshot only carries a capped excerpt, so the server reads the member's | |
| 722 | * transcript from here to fill its thread. | |
| 723 | */ | |
| 724 | export const OrchestrationV2WorkflowRunHandles = Schema.Struct({ | |
| 725 | runId: Schema.optional(TrimmedNonEmptyString), | |
| 726 | transcriptDir: Schema.optional(TrimmedNonEmptyString), | |
| 727 | scriptPath: Schema.optional(TrimmedNonEmptyString), | |
| 728 | }); | |
| 729 | export type OrchestrationV2WorkflowRunHandles = typeof OrchestrationV2WorkflowRunHandles.Type; | |
| 730 | ||
| 731 | /** | |
| 732 | * Workflow-run shape carried by the coordinator subagent. The snapshot is the | |
| 733 | * provider's own replace-in-full view of the run; members are additionally | |
| 734 | * projected as subagents in their own right. | |
| 735 | */ | |
| 736 | export const OrchestrationV2SubagentWorkflow = Schema.Struct({ | |
| 737 | name: Schema.optional(TrimmedNonEmptyString), | |
| 738 | runHandles: Schema.optional(OrchestrationV2WorkflowRunHandles), | |
| 739 | phases: Schema.Array(OrchestrationV2WorkflowPhase), | |
| 740 | agents: Schema.Array(OrchestrationV2WorkflowAgent), | |
| 741 | totalTokens: Schema.optional(NonNegativeInt), | |
| 742 | toolCalls: Schema.optional(NonNegativeInt), | |
| 743 | durationMs: Schema.optional(NonNegativeInt), | |
| 744 | }); | |
| 745 | export type OrchestrationV2SubagentWorkflow = typeof OrchestrationV2SubagentWorkflow.Type; | |
| 746 | ||
| 667 | 747 | export const OrchestrationV2Subagent = Schema.Struct({ |
| 668 | 748 | id: NodeId, |
| 669 | 749 | threadId: ThreadId, |
| ... | ... | @@ -698,6 +778,8 @@ export const OrchestrationV2Subagent = Schema.Struct({ |
| 698 | 778 | "interrupted", |
| 699 | 779 | ]), |
| 700 | 780 | progress: Schema.optional(Schema.String), |
| 781 | /** Present only on a workflow coordinator task; absent on ordinary subagents. */ | |
| 782 | workflow: Schema.optional(OrchestrationV2SubagentWorkflow), | |
| 701 | 783 | result: Schema.NullOr(Schema.String), |
| 702 | 784 | startedAt: Schema.NullOr(Schema.DateTimeUtc), |
| 703 | 785 | completedAt: Schema.NullOr(Schema.DateTimeUtc), |
| ... | ... | @@ -3163,6 +3245,7 @@ export const ORCHESTRATION_V2_WS_METHODS = { |
| 3163 | 3245 | getArchivedShellSnapshot: "orchestration.getArchivedShellSnapshot", |
| 3164 | 3246 | getThreadProjection: "orchestration.getThreadProjection", |
| 3165 | 3247 | getWorkflowScript: "orchestration.getWorkflowScript", |
| 3248 | stopWorkflow: "orchestration.stopWorkflow", | |
| 3166 | 3249 | getTurnItem: "orchestration.getTurnItem", |
| 3167 | 3250 | launchThread: "orchestration.launchThread", |
| 3168 | 3251 | subscribeArchivedShell: "orchestration.subscribeArchivedShell", |
| ... | ... | @@ -3448,11 +3531,41 @@ export class OrchestrationV2ThreadLaunchError extends Schema.TaggedError<Orchest |
| 3448 | 3531 | }, |
| 3449 | 3532 | ) {} |
| 3450 | 3533 | |
| 3534 | export const OrchestrationV2StopWorkflowInput = Schema.Struct({ | |
| 3535 | threadId: ThreadId, | |
| 3536 | subagentId: NodeId, | |
| 3537 | }); | |
| 3538 | export type OrchestrationV2StopWorkflowInput = typeof OrchestrationV2StopWorkflowInput.Type; | |
| 3539 | ||
| 3540 | export class OrchestrationV2StopWorkflowError extends Schema.TaggedError<OrchestrationV2StopWorkflowError>()( | |
| 3541 | "OrchestrationV2StopWorkflowError", | |
| 3542 | { | |
| 3543 | threadId: ThreadId, | |
| 3544 | subagentId: NodeId, | |
| 3545 | reason: Schema.Literals(["not-running", "unsupported", "unavailable", "stop-failed"]), | |
| 3546 | cause: Schema.optional(Schema.Defect()), | |
| 3547 | }, | |
| 3548 | ) { | |
| 3549 | override get message(): string { | |
| 3550 | switch (this.reason) { | |
| 3551 | case "not-running": | |
| 3552 | return "This workflow is no longer running."; | |
| 3553 | case "unsupported": | |
| 3554 | return "This provider cannot stop a workflow."; | |
| 3555 | case "unavailable": | |
| 3556 | return "This workflow has no live provider task."; | |
| 3557 | case "stop-failed": | |
| 3558 | return "Failed to stop the workflow."; | |
| 3559 | } | |
| 3560 | } | |
| 3561 | } | |
| 3562 | ||
| 3451 | 3563 | export const OrchestrationV2RpcError = Schema.Union([ |
| 3452 | 3564 | OrchestrationV2DispatchCommandError, |
| 3453 | 3565 | OrchestrationV2GetThreadProjectionError, |
| 3454 | 3566 | OrchestrationV2GetShellSnapshotError, |
| 3455 | 3567 | OrchestrationV2ThreadLaunchError, |
| 3568 | OrchestrationV2StopWorkflowError, | |
| 3456 | 3569 | ]); |
| 3457 | 3570 | export type OrchestrationV2RpcError = typeof OrchestrationV2RpcError.Type; |
| 3458 | 3571 | |
| ... | ... | @@ -3473,6 +3586,44 @@ export const OrchestrationV2GetWorkflowScriptResult = Schema.Struct({ |
| 3473 | 3586 | export type OrchestrationV2GetWorkflowScriptResult = |
| 3474 | 3587 | typeof OrchestrationV2GetWorkflowScriptResult.Type; |
| 3475 | 3588 | |
| 3589 | /** | |
| 3590 | * Shared by every read of a file the Claude harness persisted for a workflow | |
| 3591 | * run — the script and each member's transcript. One vocabulary because both | |
| 3592 | * reads apply identical containment rules. | |
| 3593 | */ | |
| 3594 | const WORKFLOW_FILE_ERROR_MESSAGES = { | |
| 3595 | "invalid-path": "Workflow files must be absolute paths with the expected extension.", | |
| 3596 | "root-unavailable": "Workflow file root unavailable.", | |
| 3597 | "not-found": "Workflow file not found.", | |
| 3598 | "outside-root": "Path is outside the workflow files root.", | |
| 3599 | "wrong-extension": "Resolved file has the wrong extension.", | |
| 3600 | "not-regular-file": "Workflow file is not a regular file.", | |
| 3601 | "changed-during-read": "Workflow file changed between resolution and open.", | |
| 3602 | "read-failed": "Workflow file read failed.", | |
| 3603 | } as const; | |
| 3604 | ||
| 3605 | export class OrchestrationWorkflowFileError extends Schema.TaggedError<OrchestrationWorkflowFileError>()( | |
| 3606 | "OrchestrationWorkflowFileError", | |
| 3607 | { | |
| 3608 | reason: Schema.Literals([ | |
| 3609 | "invalid-path", | |
| 3610 | "root-unavailable", | |
| 3611 | "not-found", | |
| 3612 | "outside-root", | |
| 3613 | "wrong-extension", | |
| 3614 | "not-regular-file", | |
| 3615 | "changed-during-read", | |
| 3616 | "read-failed", | |
| 3617 | ]), | |
| 3618 | path: Schema.String, | |
| 3619 | cause: Schema.optional(Schema.Defect()), | |
| 3620 | }, | |
| 3621 | ) { | |
| 3622 | override get message(): string { | |
| 3623 | return WORKFLOW_FILE_ERROR_MESSAGES[this.reason]; | |
| 3624 | } | |
| 3625 | } | |
| 3626 | ||
| 3476 | 3627 | export const OrchestrationV2GetTurnItemInput = Schema.Struct({ |
| 3477 | 3628 | threadId: ThreadId, |
| 3478 | 3629 | itemId: TurnItemId, |
| ... | ... | @@ -3601,6 +3752,10 @@ export const OrchestrationV2RpcSchemas = { |
| 3601 | 3752 | input: OrchestrationV2GetThreadProjectionInput, |
| 3602 | 3753 | output: OrchestrationV2ThreadProjection, |
| 3603 | 3754 | }, |
| 3755 | stopWorkflow: { | |
| 3756 | input: OrchestrationV2StopWorkflowInput, | |
| 3757 | output: Schema.Void, | |
| 3758 | }, | |
| 3604 | 3759 | getWorkflowScript: { |
| 3605 | 3760 | input: OrchestrationV2GetWorkflowScriptInput, |
| 3606 | 3761 | output: OrchestrationV2GetWorkflowScriptResult, |
packages/contracts/src/orchestratorMcp.ts+1| ... | ... | @@ -399,6 +399,7 @@ export const OrchestratorMcpThreadTimelineItem = Schema.Struct({ |
| 399 | 399 | visibility: Schema.Literals(["local", "inherited", "synthetic"]), |
| 400 | 400 | sourceThreadId: ThreadId, |
| 401 | 401 | itemId: TurnItemId, |
| 402 | subagentId: Schema.optional(NodeId), | |
| 402 | 403 | runId: Schema.NullOr(RunId), |
| 403 | 404 | messageId: Schema.NullOr(MessageId), |
| 404 | 405 | createdBy: Schema.NullOr(OrchestrationV2Actor), |
packages/contracts/src/rpc.ts+8| ... | ... | @@ -208,6 +208,7 @@ import { |
| 208 | 208 | import { |
| 209 | 209 | ORCHESTRATION_V2_WS_METHODS, |
| 210 | 210 | OrchestrationGetWorkflowScriptError, |
| 211 | OrchestrationV2StopWorkflowError, | |
| 211 | 212 | OrchestrationV2DispatchCommandError, |
| 212 | 213 | OrchestrationV2GetShellSnapshotError, |
| 213 | 214 | OrchestrationV2GetThreadProjectionError, |
| ... | ... | @@ -1662,6 +1663,12 @@ const WsOrchestrationV2GetThreadProjectionRpc = Rpc.make( |
| 1662 | 1663 | }, |
| 1663 | 1664 | ); |
| 1664 | 1665 | |
| 1666 | const WsOrchestrationV2StopWorkflowRpc = Rpc.make(ORCHESTRATION_V2_WS_METHODS.stopWorkflow, { | |
| 1667 | payload: OrchestrationV2RpcSchemas.stopWorkflow.input, | |
| 1668 | success: OrchestrationV2RpcSchemas.stopWorkflow.output, | |
| 1669 | error: Schema.Union([OrchestrationV2StopWorkflowError, EnvironmentAuthorizationError]), | |
| 1670 | }); | |
| 1671 | ||
| 1665 | 1672 | const WsOrchestrationV2GetWorkflowScriptRpc = Rpc.make( |
| 1666 | 1673 | ORCHESTRATION_V2_WS_METHODS.getWorkflowScript, |
| 1667 | 1674 | { |
| ... | ... | @@ -2032,6 +2039,7 @@ export const WsRpcGroup = RpcGroup.make( |
| 2032 | 2039 | WsSubscribeResourceTelemetryRpc, |
| 2033 | 2040 | WsOrchestrationV2DispatchCommandRpc, |
| 2034 | 2041 | WsOrchestrationV2GetWorkflowScriptRpc, |
| 2042 | WsOrchestrationV2StopWorkflowRpc, | |
| 2035 | 2043 | WsOrchestrationV2GetTurnItemRpc, |
| 2036 | 2044 | WsOrchestrationV2GetTurnDiffRpc, |
| 2037 | 2045 | WsOrchestrationV2GetFullThreadDiffRpc, |
packages/provider-core/src/server/ProviderAdapter.ts+4| ... | ... | @@ -595,6 +595,10 @@ export interface ProviderAdapterV2SessionRuntime { |
| 595 | 595 | readonly steerTurn: ( |
| 596 | 596 | input: ProviderAdapterV2SteerInput, |
| 597 | 597 | ) => Effect.Effect<void, ProviderAdapterV2Error>; |
| 598 | readonly stopTask?: (input: { | |
| 599 | readonly providerThread: OrchestrationV2ProviderThread; | |
| 600 | readonly taskId: string; | |
| 601 | }) => Effect.Effect<void, ProviderAdapterV2Error>; | |
| 598 | 602 | readonly interruptTurn: ( |
| 599 | 603 | input: ProviderAdapterV2InterruptInput, |
| 600 | 604 | ) => Effect.Effect<void, ProviderAdapterV2Error>; |
packages/shared/src/t3McpToolPresentation.ts+5| ... | ... | @@ -21,6 +21,7 @@ export type T3McpToolSummaryAction = |
| 21 | 21 | | "thread-send" |
| 22 | 22 | | "thread-wait" |
| 23 | 23 | | "thread-interrupt" |
| 24 | | "workflow-stop" | |
| 24 | 25 | | "thread-configuration" |
| 25 | 26 | | "thread-configure" |
| 26 | 27 | | "thread-fork" |
| ... | ... | @@ -138,6 +139,10 @@ const T3_MCP_TOOLS: Readonly<Record<string, T3McpToolDefinition>> = { |
| 138 | 139 | t3_thread_read: tool(["Read", "Reading", "Read", "a T3 thread"], "thread-read"), |
| 139 | 140 | t3_thread_send: tool(["Send", "Sending", "Sent", "to a T3 thread"], "thread-send"), |
| 140 | 141 | t3_thread_wait: tool(["Wait", "Waiting", "Waited", "for a T3 thread"], "thread-wait"), |
| 142 | t3_workflow_stop: tool( | |
| 143 | ["Stop", "Stopping", "Requested a stop for", "a workflow"], | |
| 144 | "workflow-stop", | |
| 145 | ), | |
| 141 | 146 | t3_thread_interrupt: tool( |
| 142 | 147 | ["Interrupt", "Interrupting", "Requested an interrupt of", "a T3 thread"], |
| 143 | 148 | "thread-interrupt", |