From 95cb068c10a6cf0b33eed0a0cab3b298763fe852 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 18:51:52 -0800 Subject: [PATCH 001/390] test(web): sidebar drag test ignores a fading row clone after folder collapse (#35817) --- apps/web/src/components/Sidebar.drag.browser.test.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/Sidebar.drag.browser.test.tsx b/apps/web/src/components/Sidebar.drag.browser.test.tsx index 53d6744f27b7c..c5811df12b765 100644 --- a/apps/web/src/components/Sidebar.drag.browser.test.tsx +++ b/apps/web/src/components/Sidebar.drag.browser.test.tsx @@ -51,10 +51,13 @@ it("keeps the title when dragging a search result from a collapsed folder", asyn }); const folder = page.getByRole("button", { name: /^render/ }); await expect.element(folder).toHaveAttribute("aria-expanded", "true"); + // A removed row fades out as a clone without its test id, so match the live row only. + const secondRow = page.getByTestId("sidebar-row-folder").filter({ hasText: second.title }); + await expect.element(secondRow).toBeInTheDocument(); // The shift guard drops pointer presses on a row that appeared under 500ms ago; keys pass. folder.element().focus(); await userEvent.keyboard("{Enter}"); - await expect.element(page.getByText(second.title, { exact: true })).not.toBeInTheDocument(); + await expect.element(secondRow).not.toBeInTheDocument(); await userEvent.fill(page.getByRole("combobox", { name: "Search threads" }), "search-target"); const result = page.getByRole("option", { name: /Thread search-target/ }); await expect.element(result).toBeVisible(); From edd6fc3489353a768ea76b6d5b228ecbcb593f49 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 19:28:45 -0800 Subject: [PATCH 002/390] fix(server): ACP streamed chunks compare message ids exactly (#35836) --- .../orchestration-v2/Adapters/AcpAdapterV2.test.ts | 13 +++++++++++++ packages/provider-acp/src/server/adapter.ts | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index 4457897fa11ef..eba10c4b55fb2 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -2878,6 +2878,19 @@ describe("AcpAdapterV2", () => { ["reasoning", "Thinking more"], ], }, + { + label: "opaque ids differ only in surrounding whitespace", + steps: [answer("answer", "A"), answer(" answer ", "B")], + expected: [ + ["assistant_message", "A"], + ["assistant_message", "B"], + ], + }, + { + label: "a chunk continues a full update with the same whitespace-padded id", + steps: [agentMessage(" answer ", "A"), answer(" answer ", "B")], + expected: [["assistant_message", "AB"]], + }, ] satisfies ReadonlyArray<{ readonly label: string; readonly steps: ReadonlyArray; diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index 547158c97164a..963e3dbedea24 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -3002,7 +3002,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( if (other !== kind) yield* closeTextStream(context, other); } const stream = textStreamFor(context, kind); - const sourceMessageId = messageId?.trim() || null; + const sourceMessageId = messageId || null; if ( stream.current !== null && sourceMessageId !== null && From 0fa18ad7125f2bfd433bd15467eddd443bdae103 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 19:28:57 -0800 Subject: [PATCH 003/390] fix(install): name the archive when its download fails (#35854) --- scripts/install.sh | 2 +- scripts/install.test.ts | 52 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/scripts/install.sh b/scripts/install.sh index 46df9a715eb8e..2630bb3a60b34 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -248,7 +248,7 @@ else fi expected="$(grep " \*\{0,1\}${archive}\$" "${staging}/SHA256SUMS" | cut -d' ' -f1)" [ -n "$expected" ] || fail "t3 ${version} has no release archive for ${platform}-${arch}" - download "${base_url}/v${version}/${archive}" "${staging}/${archive}" + download "${base_url}/v${version}/${archive}" "${staging}/${archive}" || fail "could not download ${archive}" step "Verifying the download..." actual="$(checksum "${staging}/${archive}")" diff --git a/scripts/install.test.ts b/scripts/install.test.ts index ec4212a89ec5e..3536ca2dd36dc 100644 --- a/scripts/install.test.ts +++ b/scripts/install.test.ts @@ -130,6 +130,58 @@ describe.skipIf(HostProcess.Platform.defaultValue() !== "linux")("installer chan }); }); +// regression: #35834 +describe.skipIf(HostProcess.Platform.defaultValue() !== "linux")( + "installer archive download", + () => { + it("names the archive when it returns 404", async () => { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-install-archive-")); + const arch = HostProcess.Architecture.defaultValue() === "arm64" ? "arm64" : "x64"; + const archive = `t3-1.2.3-linux-${arch}.tar.gz`; + const server = NodeHttp.createServer((request, response) => { + if (request.url === "/v1.2.3/SHA256SUMS") + return response.end(`${"0".repeat(64)} ${archive}\n`); + response.writeHead(404).end(); + }); + try { + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Expected a TCP listener"); + const { code, output } = await new Promise<{ code: number | null; output: string }>( + (resolve, reject) => { + const child = NodeChildProcess.spawn( + "sh", + [NodePath.resolve(import.meta.dirname, "install.sh")], + { + env: { + ...process.env, + T3CODE_CHANNEL: "", + T3CODE_VERSION: "1.2.3", + T3CODE_HOME: NodePath.join(root, "home"), + T3CODE_INSTALL_BIN_DIR: NodePath.join(root, "bin"), + T3CODE_RELEASE_BASE_URL: `http://127.0.0.1:${address.port}`, + }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + let output = ""; + child.stdout.on("data", (chunk: Buffer) => (output += chunk.toString())); + child.stderr.on("data", (chunk: Buffer) => (output += chunk.toString())); + child.on("error", reject); + child.on("close", (code) => resolve({ code, output })); + }, + ); + expect(code, output).not.toBe(0); + expect(output).toMatch(new RegExp(`t3 install: .*${archive.replaceAll(".", "\\.")}`)); + } finally { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + await NodeFSP.rm(root, { recursive: true, force: true }); + } + }); + }, +); + /** Runs an installer for a version that is already unpacked, so only the t3 command step runs. */ async function installOver(input: { readonly script: "install.sh" | "install.ps1"; From 7fb6098cfd6bff09304d0963597c06668e7a26ea Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 19:29:10 -0800 Subject: [PATCH 004/390] test(server): drop the flaky Kiro provider_thread_resume replay (#35867) --- .../testkit/fixtures/index.ts | 7 -- .../provider_thread_resume/kiro_output.ts | 61 ----------------- .../kiro_transcript.ndjson | 65 ------------------- 3 files changed, 133 deletions(-) delete mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/provider_thread_resume/kiro_output.ts delete mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/provider_thread_resume/kiro_transcript.ndjson diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/index.ts b/apps/server/src/orchestration-v2/testkit/fixtures/index.ts index 8ee31f2b14460..ce6d739b1cdd3 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/index.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/index.ts @@ -90,7 +90,6 @@ import { assertMuseTurnInterruptOutput } from "./turn_interrupt/muse_output.ts"; import { piCompactionInput } from "./pi_compaction/input.ts"; import { assertPiCompactionOutput } from "./pi_compaction/output.ts"; import { providerThreadResumeInput } from "./provider_thread_resume/input.ts"; -import { assertKiroProviderThreadResumeOutput } from "./provider_thread_resume/kiro_output.ts"; import { assertPiProviderThreadResumeOutput } from "./provider_thread_resume/pi_output.ts"; import { assertMultiTurnClaudeOutput } from "./multi_turn/claude_output.ts"; import { assertMultiTurnOutput } from "./multi_turn/codex_output.ts"; @@ -1585,12 +1584,6 @@ export const ORCHESTRATOR_REPLAY_FIXTURES: ReadonlyArray - entry.type === "expect_outbound" && field(entry.frame, "kind") === "request" - ? [field(entry.frame, "method")] - : [], - ); - assert.equal( - requests.filter((method) => method === "initialize").length, - 2, - "the idle release must retire the first Kiro process", - ); - assert.deepEqual( - requests.filter((method) => method === "session/new" || method === "session/load"), - ["session/new", "session/load"], - "the respawned process must load the first session, not start another", - ); - - assertBaseProjection({ - result, - transcript, - runCount: 2, - runStatuses: ["completed", "completed"], - }); - const projection = projectionFor(result, transcript.scenario); - assertSemanticProjectionIntegrity(projection); - assertUserMessagesInclude(projection, [ - PROVIDER_THREAD_RESUME_FIRST_PROMPT, - PROVIDER_THREAD_RESUME_SECOND_PROMPT, - ]); - assert.lengthOf(projection.providerThreads, 1, "resume must keep one provider thread"); - const answers = projection.turnItems.flatMap((item) => - item.type === "assistant_message" ? [item.text] : [], - ); - assert.include(answers.at(-1), FIRST_FINAL, "the resumed session must remember the first answer"); -} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/provider_thread_resume/kiro_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/provider_thread_resume/kiro_transcript.ndjson deleted file mode 100644 index b97a35bf80e9b..0000000000000 --- a/apps/server/src/orchestration-v2/testkit/fixtures/provider_thread_resume/kiro_transcript.ndjson +++ /dev/null @@ -1,65 +0,0 @@ -{"type":"transcript_start","provider":"kiro","protocol":"acp.ndjson-jsonrpc","version":"1","scenario":"provider_thread_resume","metadata":{"generatedBy":"live-kiro-recorder","kiroVersion":"2.32.0","normalization":"Session ids are fixed sess_ UUIDs and other UUIDs (message and request ids) are numbered in first-seen order, the workspace is , HOME is /home/kiro-replay and the recording user is kiro-replay. T3-owned prompt text, MCP servers and initialize params other than clientCapabilities and _meta are . Kiro's log directory, user-scoped commands and agents, and the _kiro/* broadcasts T3 ignores (governance, steering documents, powers, tools, hooks, MCP status, session roster) are removed. Timestamps are kept as recorded.","droppedFrames":26}} -{"type":"expect_outbound","label":"request:initialize","frame":{"kind":"request","method":"initialize","params":{"protocolVersion":2,"info":"","capabilities":"","clientCapabilities":{"fs":{"readTextFile":false,"writeTextFile":false},"terminal":false,"session":{"notices":{},"compaction":{}},"elicitation":{"form":{}},"_meta":{"kiro":{"hooks":{"enabled":true,"v2":true}}}},"clientInfo":""}}} -{"type":"emit_inbound","label":"response:initialize","frame":{"kind":"response","method":"initialize","result":{"protocolVersion":2,"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":true,"embeddedContext":true},"mcpCapabilities":{"http":true,"sse":true},"sessionCapabilities":{"list":{},"close":{},"delete":{},"fork":{"_meta":{"kiro":{"messageId":true}}}},"_meta":{"kiro":{"checkpoints":true,"sessionList":true,"policyNotifications":true,"extensionMethods":["_kiro/knowledge","_kiro/memory/list","_kiro/memory/get","_kiro/memory/create","_kiro/memory/update","_kiro/memory/delete","_kiro/codeIntelligence","_kiro/session/context","_kiro/session/compact","_kiro/session/export","_kiro/session/history","_kiro/config/template","_kiro/workflow/new","_kiro/workflow/invoke","_kiro/workflow/load","_kiro/workflow/inspect","_kiro/workflow/list","_kiro/workflow/pause","_kiro/workflow/cancel","_kiro/workflow/delete","_kiro/workflow/listRecipes","_kiro/workflow/listWatchHandlers","_kiro/workflow/resumeAll","_kiro/workflow/retry","_kiro/workflow/update","_kiro/workflow/resume","_kiro/sourceProviders/list","_kiro/sourceProviders/listResources"],"sessionSources":["local","remote"],"sessionListScopes":["workspace","user"],"executionTargets":["local","cloud-sandbox"],"sourceProviders":true,"replayMarking":true}}},"authMethods":[{"id":"aws-builder-id","name":"AWS Builder ID"},{"id":"aws-iam-identity-center","name":"AWS IAM Identity Center"}]}}} -{"type":"expect_outbound","label":"request:session/new","frame":{"kind":"request","method":"session/new","params":{"cwd":"","mcpServers":""}}} -{"type":"emit_inbound","label":"notification:session/update:available_commands_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"sess_00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"architecture-selection","description":"Steering: architecture-selection","input":{"hint":"optional context"},"_meta":{"kiro":{"type":"steering","contextQuery":"global:architecture-selection","commandId":"architecture-selection","resource":{"resourceType":"steering","source":{"origin":"bundled"}},"originalName":"architecture-selection"}}},{"name":"quick-spec","description":"Steering: quick-spec","input":{"hint":"optional context"},"_meta":{"kiro":{"type":"steering","contextQuery":"global:quick-spec","commandId":"quick-spec","resource":{"resourceType":"steering","source":{"origin":"bundled"}},"originalName":"quick-spec"}}},{"name":"bug-fix","description":"Steering: bug-fix","input":{"hint":"optional context"},"_meta":{"kiro":{"type":"steering","contextQuery":"global:bug-fix","commandId":"bug-fix","resource":{"resourceType":"steering","source":{"origin":"bundled"}},"originalName":"bug-fix"}}},{"name":"general-task-execution","description":"General-purpose sub-agent with access to all tools for executing arbitrary tasks","input":{"hint":"task to delegate"},"_meta":{"kiro":{"type":"custom-agent","commandId":"general-task-execution","resource":{"resourceType":"agent","source":{"origin":"bundled"}},"originalName":"general-task-execution"}}},{"name":"context-gatherer","description":"Investigates the codebase to answer a specific question about it. Deeply analyzes existing code by tracing execution paths, mapping architecture and abstraction layers, understanding patterns, and documenting dependencies, returning a thorough, direct written analysis that explains how the relevant code works and points to the specific files and lines involved. Use it to understand code before you change it: before designing or implementing a change to code you don't already fully understand, start with context-gatherer so your edits are grounded in how the code actually works. When a task requires understanding code you don't already know, dispatch context-gatherer to do that exploration before running your own searches or reads. Rules: 1. Provide a clear, specific prompt that includes your intent: a detailed task description of the context you need and what you're trying to accomplish. 2. The subagent explores the codebase and returns an analysis of your query 3. Trust the returned results. You may still inspect additional code yourself when you need context beyond what was returned. 4. Reuse existing context-gatherer output for related follow-up questions; do not re-invoke on the same question with different phrasing. 5. For multiple distinct questions, dispatch several context-gatherers in parallel, each targeting a different aspect of the codebase.","input":{"hint":"task to delegate"},"_meta":{"kiro":{"type":"custom-agent","commandId":"context-gatherer","resource":{"resourceType":"agent","source":{"origin":"bundled"}},"originalName":"context-gatherer"}}}]}}}} -{"type":"emit_inbound","label":"response:session/new","frame":{"kind":"response","method":"session/new","result":{"_meta":{"schemaVersion":"1.0.0","id":"sess_00000000-0000-4000-8000-000000000001","title":"New Session","agentMode":"vibe","workspacePaths":[""],"createdAt":"2026-10-19T20:48:11.040Z","lastModifiedAt":"2026-10-19T20:48:11.040Z","semanticReviewEnabled":true,"memoryConfig":{"mode":"read_write","reflection":true},"disableAutoCompaction":false,"ftaEnabled":false,"workflowsEnabled":false,"specPlanEnabled":false,"specWorkflow":"quick","specSkipClarificationEnabled":true,"source":"local"},"sessionId":"sess_00000000-0000-4000-8000-000000000001","modes":{"availableModes":[{"id":"vibe","name":"Default","description":"General coding assistance","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"id":"spec","name":"Spec","description":"Structured feature development","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"id":"quick-spec","name":"Quick Spec","description":"Fast spec workflow: clarify, then auto-generate requirements, design, and tasks","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"id":"bug-fix","name":"Bug Fix","description":"Structured bug-fix workflow: investigate, diagnose, and resolve bugs","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"id":"plan","name":"Plan","description":"Plan-only mode that helps break ideas down into an implementation plan without making any changes","_meta":{"kiro":{"welcomeMessage":"Transform any idea into fully working code. What do you want to build today?","source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"id":"autonomous","name":"Autonomous","description":"Autonomous agent execution","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"id":"semantic_reviewer","name":"semantic_reviewer","description":"Behavioral, narrative code review of a local or PR diff. Writes a review document and a verdict.","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}}],"currentModeId":"vibe"},"configOptions":[{"type":"select","id":"mode","name":"Mode","category":"mode","currentValue":"vibe","options":[{"value":"vibe","name":"Default","description":"General coding assistance","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"value":"spec","name":"Spec","description":"Structured feature development","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"value":"quick-spec","name":"Quick Spec","description":"Fast spec workflow: clarify, then auto-generate requirements, design, and tasks","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"value":"bug-fix","name":"Bug Fix","description":"Structured bug-fix workflow: investigate, diagnose, and resolve bugs","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"value":"plan","name":"Plan","description":"Plan-only mode that helps break ideas down into an implementation plan without making any changes","_meta":{"kiro":{"welcomeMessage":"Transform any idea into fully working code. What do you want to build today?","source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"value":"autonomous","name":"Autonomous","description":"Autonomous agent execution","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"value":"semantic_reviewer","name":"semantic_reviewer","description":"Behavioral, narrative code review of a local or PR diff. Writes a review document and a verdict.","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}}]},{"type":"select","id":"memoryReflection","name":"Memory reflection","description":"Allow this session to reflect on new memory","currentValue":"on","options":[{"value":"on","name":"On"},{"value":"off","name":"Off"}]},{"type":"select","id":"autopilot","name":"Autopilot","currentValue":"on","options":[{"value":"on","name":"Autopilot","description":"Agent executes tools without confirmation"},{"value":"off","name":"Supervised","description":"Agent asks for approval before file changes"}]},{"type":"select","id":"contentCollection","name":"Content Collection","currentValue":"enabled","options":[{"value":"enabled","name":"Enabled","description":"Allow content to be used for service improvement"},{"value":"disabled","name":"Disabled","description":"Opt out of content collection"}]}]}}} -{"type":"expect_outbound","label":"request:session/set_config_option","frame":{"kind":"request","method":"session/set_config_option","params":{"sessionId":"sess_00000000-0000-4000-8000-000000000001","configId":"model","value":"claude-haiku-4.5"}}} -{"type":"emit_inbound","label":"notification:session/update:session_info_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"sess_00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"session_info_update","_meta":{"kiro":{"contextUsage":{"usagePercentage":0.9},"breakdown":{"contextFiles":{"tokens":0,"percent":0,"items":[]},"tools":{"tokens":5481,"percent":0.5,"mcp":{"tokens":0,"percent":0},"builtin":{"tokens":5481,"percent":0.5}},"memory":{"tokens":0,"percent":0},"kiroResponses":{"tokens":0,"percent":0},"yourPrompts":{"tokens":4305,"percent":0.4},"sessionFiles":{"tokens":0,"percent":0,"items":[]}},"kind":"context_usage","usagePercentage":0.9}}}}}} -{"type":"emit_inbound","label":"notification:session/update:available_commands_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"sess_00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"architecture-selection","description":"Steering: architecture-selection","input":{"hint":"optional context"},"_meta":{"kiro":{"type":"steering","contextQuery":"global:architecture-selection","commandId":"architecture-selection","resource":{"resourceType":"steering","source":{"origin":"bundled"}},"originalName":"architecture-selection"}}},{"name":"quick-spec","description":"Steering: quick-spec","input":{"hint":"optional context"},"_meta":{"kiro":{"type":"steering","contextQuery":"global:quick-spec","commandId":"quick-spec","resource":{"resourceType":"steering","source":{"origin":"bundled"}},"originalName":"quick-spec"}}},{"name":"bug-fix","description":"Steering: bug-fix","input":{"hint":"optional context"},"_meta":{"kiro":{"type":"steering","contextQuery":"global:bug-fix","commandId":"bug-fix","resource":{"resourceType":"steering","source":{"origin":"bundled"}},"originalName":"bug-fix"}}},{"name":"general-task-execution","description":"General-purpose sub-agent with access to all tools for executing arbitrary tasks","input":{"hint":"task to delegate"},"_meta":{"kiro":{"type":"custom-agent","commandId":"general-task-execution","resource":{"resourceType":"agent","source":{"origin":"bundled"}},"originalName":"general-task-execution"}}},{"name":"context-gatherer","description":"Investigates the codebase to answer a specific question about it. Deeply analyzes existing code by tracing execution paths, mapping architecture and abstraction layers, understanding patterns, and documenting dependencies, returning a thorough, direct written analysis that explains how the relevant code works and points to the specific files and lines involved. Use it to understand code before you change it: before designing or implementing a change to code you don't already fully understand, start with context-gatherer so your edits are grounded in how the code actually works. When a task requires understanding code you don't already know, dispatch context-gatherer to do that exploration before running your own searches or reads. Rules: 1. Provide a clear, specific prompt that includes your intent: a detailed task description of the context you need and what you're trying to accomplish. 2. The subagent explores the codebase and returns an analysis of your query 3. Trust the returned results. You may still inspect additional code yourself when you need context beyond what was returned. 4. Reuse existing context-gatherer output for related follow-up questions; do not re-invoke on the same question with different phrasing. 5. For multiple distinct questions, dispatch several context-gatherers in parallel, each targeting a different aspect of the codebase.","input":{"hint":"task to delegate"},"_meta":{"kiro":{"type":"custom-agent","commandId":"context-gatherer","resource":{"resourceType":"agent","source":{"origin":"bundled"}},"originalName":"context-gatherer"}}}]}}}} -{"type":"emit_inbound","label":"response:session/set_config_option","frame":{"kind":"response","method":"session/set_config_option","result":{"configOptions":[{"type":"select","id":"mode","name":"Mode","category":"mode","currentValue":"vibe","options":[{"value":"vibe","name":"Default","description":"General coding assistance","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"value":"spec","name":"Spec","description":"Structured feature development","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"value":"quick-spec","name":"Quick Spec","description":"Fast spec workflow: clarify, then auto-generate requirements, design, and tasks","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"value":"bug-fix","name":"Bug Fix","description":"Structured bug-fix workflow: investigate, diagnose, and resolve bugs","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"value":"plan","name":"Plan","description":"Plan-only mode that helps break ideas down into an implementation plan without making any changes","_meta":{"kiro":{"welcomeMessage":"Transform any idea into fully working code. What do you want to build today?","source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"value":"autonomous","name":"Autonomous","description":"Autonomous agent execution","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"value":"semantic_reviewer","name":"semantic_reviewer","description":"Behavioral, narrative code review of a local or PR diff. Writes a review document and a verdict.","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}}]},{"type":"select","id":"memoryReflection","name":"Memory reflection","description":"Allow this session to reflect on new memory","currentValue":"on","options":[{"value":"on","name":"On"},{"value":"off","name":"Off"}]},{"type":"select","id":"autopilot","name":"Autopilot","currentValue":"on","options":[{"value":"on","name":"Autopilot","description":"Agent executes tools without confirmation"},{"value":"off","name":"Supervised","description":"Agent asks for approval before file changes"}]},{"type":"select","id":"contentCollection","name":"Content Collection","currentValue":"enabled","options":[{"value":"enabled","name":"Enabled","description":"Allow content to be used for service improvement"},{"value":"disabled","name":"Disabled","description":"Opt out of content collection"}]}]}}} -{"type":"emit_inbound","label":"notification:session/update:config_option_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"sess_00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"config_option_update","configOptions":[{"type":"select","id":"mode","name":"Mode","category":"mode","currentValue":"vibe","options":[{"value":"vibe","name":"Default","description":"General coding assistance","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"value":"spec","name":"Spec","description":"Structured feature development","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"value":"quick-spec","name":"Quick Spec","description":"Fast spec workflow: clarify, then auto-generate requirements, design, and tasks","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"value":"bug-fix","name":"Bug Fix","description":"Structured bug-fix workflow: investigate, diagnose, and resolve bugs","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"value":"plan","name":"Plan","description":"Plan-only mode that helps break ideas down into an implementation plan without making any changes","_meta":{"kiro":{"welcomeMessage":"Transform any idea into fully working code. What do you want to build today?","source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"value":"autonomous","name":"Autonomous","description":"Autonomous agent execution","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"value":"semantic_reviewer","name":"semantic_reviewer","description":"Behavioral, narrative code review of a local or PR diff. Writes a review document and a verdict.","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}}]},{"type":"select","id":"model","name":"Model","category":"model","currentValue":"claude-haiku-4.5","options":[{"value":"auto","name":"Auto","description":"Models chosen by task for optimal usage and consistent quality","_meta":{"kiro":{"rateMultiplier":1,"rateUnit":"Credit","hasEffort":false,"thinkingToggleable":false}}},{"value":"claude-opus-5.5","name":"Claude Opus 5.5","description":"Experimental preview of Claude Opus 5.5 model with 1M context window","_meta":{"kiro":{"rateMultiplier":2,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"output_config","effortLevels":["low","medium","high","xhigh","max"],"defaultEffortLevel":"medium","thinkingToggleable":false}}},{"value":"claude-sonnet-5.5","name":"Claude Sonnet 5.5","description":"Claude Sonnet 5.5 model with 1M context window","_meta":{"kiro":{"rateMultiplier":1.3,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"output_config","effortLevels":["low","medium","high","xhigh","max"],"defaultEffortLevel":"high","thinkingToggleable":false}}},{"value":"claude-opus-5","name":"Claude Opus 5","description":"Claude Opus 5 model with 1M context window","_meta":{"kiro":{"rateMultiplier":2.2,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"output_config","effortLevels":["low","medium","high","xhigh","max"],"defaultEffortLevel":"high","thinkingToggleable":true,"defaultThinkingEnabled":true}}},{"value":"claude-sonnet-5","name":"Claude Sonnet 5","description":"Claude Sonnet 5 model with 1M context window","_meta":{"kiro":{"rateMultiplier":1.3,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"output_config","effortLevels":["low","medium","high","xhigh","max"],"defaultEffortLevel":"high","thinkingToggleable":true,"defaultThinkingEnabled":true}}},{"value":"claude-opus-4.8","name":"Claude Opus 4.8","description":"Claude Opus 4.8 model with 1M context window","_meta":{"kiro":{"rateMultiplier":2.2,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"output_config","effortLevels":["low","medium","high","xhigh","max"],"defaultEffortLevel":"high","thinkingToggleable":true,"defaultThinkingEnabled":true}}},{"value":"gpt-5.6-sol","name":"GPT 5.6 Sol","description":"Experimental preview of OpenAI GPT 5.6 Sol with 1M context window","_meta":{"kiro":{"rateMultiplier":4.4,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"reasoning","effortLevels":["none","low","medium","high","xhigh","max"],"defaultEffortLevel":"high","thinkingToggleable":false}}},{"value":"gpt-5.6-terra","name":"GPT 5.6 Terra","description":"Experimental preview of OpenAI GPT 5.6 Terra with 1M context window","_meta":{"kiro":{"rateMultiplier":2.2,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"reasoning","effortLevels":["none","low","medium","high","xhigh","max"],"defaultEffortLevel":"high","thinkingToggleable":false}}},{"value":"gpt-5.6-luna","name":"GPT 5.6 Luna","description":"Experimental preview of OpenAI GPT 5.6 Luna with 1M context window","_meta":{"kiro":{"rateMultiplier":0.6,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"reasoning","effortLevels":["none","low","medium","high","xhigh","max"],"defaultEffortLevel":"high","thinkingToggleable":false}}},{"value":"claude-opus-4.7","name":"Claude Opus 4.7","description":"Claude Opus 4.7 model with 1M context window","_meta":{"kiro":{"rateMultiplier":2.2,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"output_config","effortLevels":["low","medium","high","xhigh","max"],"defaultEffortLevel":"xhigh","thinkingToggleable":true,"defaultThinkingEnabled":true}}},{"value":"claude-opus-4.6","name":"Claude Opus 4.6","description":"Claude Opus 4.6 model with 1M context window","_meta":{"kiro":{"rateMultiplier":2.2,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"output_config","effortLevels":["low","medium","high","max"],"defaultEffortLevel":"high","thinkingToggleable":true,"defaultThinkingEnabled":true}}},{"value":"claude-sonnet-4.6","name":"Claude Sonnet 4.6","description":"Claude Sonnet 4.6 model with 1M context window","_meta":{"kiro":{"rateMultiplier":1.3,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"output_config","effortLevels":["low","medium","high","max"],"defaultEffortLevel":"high","thinkingToggleable":true,"defaultThinkingEnabled":true}}},{"value":"claude-opus-4.5","name":"Claude Opus 4.5","description":"Claude Opus 4.5 model","_meta":{"kiro":{"rateMultiplier":2.2,"rateUnit":"Credit","hasEffort":false,"thinkingToggleable":false}}},{"value":"claude-sonnet-4.5","name":"Claude Sonnet 4.5","description":"Claude Sonnet 4.5 model","_meta":{"kiro":{"rateMultiplier":1.3,"rateUnit":"Credit","hasEffort":false,"thinkingToggleable":false}}},{"value":"claude-haiku-4.5","name":"Claude Haiku 4.5","description":"The latest Claude Haiku model","_meta":{"kiro":{"rateMultiplier":0.4,"rateUnit":"Credit","hasEffort":false,"thinkingToggleable":false}}},{"value":"deepseek-3.2","name":"Deepseek v3.2","description":"Experimental preview of DeepSeek V3.2","_meta":{"kiro":{"rateMultiplier":0.25,"rateUnit":"Credit","hasEffort":false,"thinkingToggleable":false}}},{"value":"minimax-m2.5","name":"MiniMax M2.5","description":"MiniMax M2.5 model","_meta":{"kiro":{"rateMultiplier":0.25,"rateUnit":"Credit","hasEffort":false,"thinkingToggleable":false}}},{"value":"minimax-m2.1","name":"MiniMax M2.1","description":"Experimental preview of MiniMax M2.1","_meta":{"kiro":{"rateMultiplier":0.15,"rateUnit":"Credit","hasEffort":false,"thinkingToggleable":false}}},{"value":"glm-5","name":"GLM 5","description":"GLM-5 model","_meta":{"kiro":{"rateMultiplier":0.5,"rateUnit":"Credit","hasEffort":false,"thinkingToggleable":false}}},{"value":"qwen3-coder-next","name":"Qwen3 Coder Next","description":"Experimental preview of Qwen3 Coder Next","_meta":{"kiro":{"rateMultiplier":0.05,"rateUnit":"Credit","hasEffort":false,"thinkingToggleable":false}}},{"value":"kimi-k3","name":"Kimi K3","description":"Experimental preview of Kimi K3. Kimi K3 is a Kiro Excluded Model. For more info, see documentation.","_meta":{"kiro":{"rateMultiplier":2.4,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"reasoning","effortLevels":["low","high","max"],"defaultEffortLevel":"max","thinkingToggleable":false}}}]},{"type":"select","id":"memoryReflection","name":"Memory reflection","description":"Allow this session to reflect on new memory","currentValue":"on","options":[{"value":"on","name":"On"},{"value":"off","name":"Off"}]},{"type":"select","id":"autopilot","name":"Autopilot","currentValue":"on","options":[{"value":"on","name":"Autopilot","description":"Agent executes tools without confirmation"},{"value":"off","name":"Supervised","description":"Agent asks for approval before file changes"}]},{"type":"select","id":"contentCollection","name":"Content Collection","currentValue":"enabled","options":[{"value":"enabled","name":"Enabled","description":"Allow content to be used for service improvement"},{"value":"disabled","name":"Disabled","description":"Opt out of content collection"}]}]}}}} -{"type":"expect_outbound","label":"request:session/prompt","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"sess_00000000-0000-4000-8000-000000000001","prompt":[{"type":"text","text":"\n\n\n\n\nRespond with exactly: provider thread resume fixture first turn complete\n"}]}}} -{"type":"emit_inbound","label":"notification:session/update:session_info_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"sess_00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"session_info_update","_meta":{"kiro":{"userMessageId":"00000000-0000-4000-9000-000000000001","kind":"user_message_id_assigned"}}}}}} -{"type":"emit_inbound","label":"notification:session/update:session_info_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"sess_00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"session_info_update","title":"","_meta":{"kiro":{"focus":{"title":""},"kind":"focus_update","title":""}}}}}} -{"type":"emit_inbound","label":"notification:session/update:session_info_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"sess_00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"session_info_update","_meta":{"kiro":{"focus":{"status":"in_progress"},"kind":"focus_update","status":"in_progress","activity":{"turnActive":true,"runningWorkflows":0,"pausedWorkflows":0}}}}}}} -{"type":"emit_inbound","label":"notification:session/update:session_info_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"sess_00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"session_info_update","_meta":{"kiro":{"turnStart":true,"kind":"turn_start","messageId":"00000000-0000-4000-9000-000000000002-turn-start"}}}}}} -{"type":"emit_inbound","label":"notification:session/update:session_info_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"sess_00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"session_info_update","title":"provider thread resume fixture first turn complete","_meta":{"kiro":{"focus":{"title":"provider thread resume fixture first turn complete"},"kind":"focus_update","title":"provider thread resume fixture first turn complete"}}}}}} -{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"sess_00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"provider"},"_meta":{"kiro":{"replayId":"00000000-0000-4000-9000-000000000003-say"}}}}}} -{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"sess_00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" thread resume fixture first turn complete"},"_meta":{"kiro":{"replayId":"00000000-0000-4000-9000-000000000003-say"}}}}}} -{"type":"emit_inbound","label":"notification:session/update:session_info_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"sess_00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"session_info_update","_meta":{"kiro":{"contextUsage":{"usagePercentage":9.29900074005127},"kind":"context_usage","usagePercentage":9.29900074005127}}}}}} -{"type":"emit_inbound","label":"notification:session/update:session_info_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"sess_00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"session_info_update","_meta":{"kiro":{"contextUsage":{"usagePercentage":9.29900074005127},"breakdown":{"contextFiles":{"tokens":0,"percent":0,"items":[]},"tools":{"tokens":5481,"percent":0.5,"mcp":{"tokens":0,"percent":0},"builtin":{"tokens":5481,"percent":0.5}},"memory":{"tokens":0,"percent":0},"kiroResponses":{"tokens":0,"percent":0},"yourPrompts":{"tokens":0,"percent":8.8},"sessionFiles":{"tokens":0,"percent":0,"items":[]}},"kind":"context_usage","usagePercentage":9.29900074005127}}}}}} -{"type":"emit_inbound","label":"notification:session/update:session_info_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"sess_00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"session_info_update","_meta":{"kiro":{"promptTurnSummaries":[{"unit":"credit","unitPlural":"credits","usage":0.023869583416252074}],"elapsedTime":1347,"status":"success","kind":"turn_completion","requestIds":["00000000-0000-4000-9000-000000000004"]}}}}}} -{"type":"emit_inbound","label":"notification:session/update:session_info_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"sess_00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"session_info_update","_meta":{"kiro":{"turnEnd":{"stopReason":"end_turn"},"kind":"turn_end","stopReason":"end_turn","messageId":"00000000-0000-4000-9000-000000000002-turn-end"}}}}}} -{"type":"emit_inbound","label":"notification:session/update:session_info_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"sess_00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"session_info_update","_meta":{"kiro":{"focus":{"status":"idle"},"kind":"focus_update","status":"idle","activity":{"turnActive":false,"runningWorkflows":0,"pausedWorkflows":0}}}}}}} -{"type":"emit_inbound","label":"response:session/prompt","frame":{"kind":"response","method":"session/prompt","result":{"stopReason":"end_turn"}}} -{"type":"emit_inbound","label":"notification:session/update:session_info_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"sess_00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"session_info_update","_meta":{"kiro":{"contextUsage":{"usagePercentage":9.29900074005127},"breakdown":{"contextFiles":{"tokens":0,"percent":0,"items":[]},"tools":{"tokens":5481,"percent":0.5,"mcp":{"tokens":0,"percent":0},"builtin":{"tokens":5481,"percent":0.5}},"memory":{"tokens":0,"percent":0},"kiroResponses":{"tokens":13,"percent":0.2},"yourPrompts":{"tokens":466,"percent":8.6},"sessionFiles":{"tokens":0,"percent":0,"items":[]}},"kind":"context_usage","usagePercentage":9.29900074005127}}}}}} -{"type":"expect_outbound","label":"request:session/close","frame":{"kind":"request","method":"session/close","params":{"sessionId":"sess_00000000-0000-4000-8000-000000000001"}}} -{"type":"emit_inbound","label":"response:session/close","frame":{"kind":"response","method":"session/close","result":{}}} -{"type":"runtime_exit","status":"success"} -{"type":"expect_outbound","label":"request:initialize","frame":{"kind":"request","method":"initialize","params":{"protocolVersion":2,"info":"","capabilities":"","clientCapabilities":{"fs":{"readTextFile":false,"writeTextFile":false},"terminal":false,"session":{"notices":{},"compaction":{}},"elicitation":{"form":{}},"_meta":{"kiro":{"hooks":{"enabled":true,"v2":true}}}},"clientInfo":""}}} -{"type":"emit_inbound","label":"response:initialize","frame":{"kind":"response","method":"initialize","result":{"protocolVersion":2,"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":true,"embeddedContext":true},"mcpCapabilities":{"http":true,"sse":true},"sessionCapabilities":{"list":{},"close":{},"delete":{},"fork":{"_meta":{"kiro":{"messageId":true}}}},"_meta":{"kiro":{"checkpoints":true,"sessionList":true,"policyNotifications":true,"extensionMethods":["_kiro/knowledge","_kiro/memory/list","_kiro/memory/get","_kiro/memory/create","_kiro/memory/update","_kiro/memory/delete","_kiro/codeIntelligence","_kiro/session/context","_kiro/session/compact","_kiro/session/export","_kiro/session/history","_kiro/config/template","_kiro/workflow/new","_kiro/workflow/invoke","_kiro/workflow/load","_kiro/workflow/inspect","_kiro/workflow/list","_kiro/workflow/pause","_kiro/workflow/cancel","_kiro/workflow/delete","_kiro/workflow/listRecipes","_kiro/workflow/listWatchHandlers","_kiro/workflow/resumeAll","_kiro/workflow/retry","_kiro/workflow/update","_kiro/workflow/resume","_kiro/sourceProviders/list","_kiro/sourceProviders/listResources"],"sessionSources":["local","remote"],"sessionListScopes":["workspace","user"],"executionTargets":["local","cloud-sandbox"],"sourceProviders":true,"replayMarking":true}}},"authMethods":[{"id":"aws-builder-id","name":"AWS Builder ID"},{"id":"aws-iam-identity-center","name":"AWS IAM Identity Center"}]}}} -{"type":"expect_outbound","label":"request:session/load","frame":{"kind":"request","method":"session/load","params":{"mcpServers":"","cwd":"","sessionId":"sess_00000000-0000-4000-8000-000000000001"}}} -{"type":"emit_inbound","label":"notification:session/update:user_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"sess_00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"\n## T3 Code interaction mode: Default\n\nPrefer making reasonable assumptions and carrying out the user's request. Ask a concise question only when a missing user decision would materially change the result. Treat this mode as active until T3 Code supplies a different interaction-mode instruction.\n\n\n\nRespond with exactly: provider thread resume fixture first turn complete\n\nIn case you're asked: you are running in T3 Code through the Kiro harness, as claude-haiku-4.5. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\nWhen the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.\n"},"_meta":{"kiro":{"userMessageTag":"prompt_00000000-0000-4000-9000-000000000005","messageId":"00000000-0000-4000-9000-000000000001","timestamp":"2026-10-19T20:48:11.510Z","replay":true}}}}}} -{"type":"emit_inbound","label":"notification:session/update:session_info_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"sess_00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"session_info_update","_meta":{"kiro":{"turnStart":true,"kind":"turn_start","messageId":"00000000-0000-4000-9000-000000000002-turn-start","replay":true}}}}}} -{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"sess_00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"provider thread resume fixture first turn complete"},"_meta":{"kiro":{"agentMode":"vibe","messageId":"00000000-0000-4000-9000-000000000003-say","timestamp":"2026-10-19T20:48:12.853Z","replay":true}}}}}} -{"type":"emit_inbound","label":"notification:session/update:session_info_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"sess_00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"session_info_update","_meta":{"kiro":{"contextUsage":{"usagePercentage":9.29900074005127},"kind":"context_usage","usagePercentage":9.29900074005127,"replay":true}}}}}} -{"type":"emit_inbound","label":"notification:session/update:session_info_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"sess_00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"session_info_update","_meta":{"kiro":{"promptTurnSummaries":[{"unit":"credit","unitPlural":"credits","usage":0.023869583416252074}],"elapsedTime":1347,"status":"success","kind":"turn_completion","requestIds":["00000000-0000-4000-9000-000000000004"],"replay":true}}}}}} -{"type":"emit_inbound","label":"notification:session/update:session_info_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"sess_00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"session_info_update","_meta":{"kiro":{"turnEnd":{"stopReason":"end_turn"},"kind":"turn_end","stopReason":"end_turn","messageId":"00000000-0000-4000-9000-000000000002-turn-end","replay":true}}}}}} -{"type":"emit_inbound","label":"response:session/load","frame":{"kind":"response","method":"session/load","result":{"_meta":{"agentMode":"vibe","memoryConfig":{"mode":"read_write","reflection":true},"contextUsage":{"summarizationThreshold":80,"truncationThreshold":95,"usagePercentage":9.29900074005127},"createdAt":"2026-10-19T20:48:11.040Z","id":"sess_00000000-0000-4000-8000-000000000001","lastModifiedAt":"2026-10-19T20:48:12.888Z","title":"provider thread resume fixture first turn complete","workspacePaths":[""],"source":"local","executionTarget":{"kind":"local"},"kiro":{"loadTiming":{"schema":1,"totalMs":331,"startedAt":1792442894443,"phases":[{"id":"init-wait","label":"Wait for agent init","startMs":0,"endMs":0},{"id":"read-session","label":"Read session record","startMs":1,"endMs":14},{"id":"shell-type","label":"Resolve shell type","startMs":15,"endMs":15},{"id":"hooks","label":"Load hooks","startMs":17,"endMs":20},{"id":"agent-registry","label":"Load agent profiles","startMs":20,"endMs":50},{"id":"policy","label":"Load and compile policy","startMs":50,"endMs":54},{"id":"knowledge","label":"Init knowledge registry","startMs":55,"endMs":66},{"id":"sandbox","label":"Create session sandbox","startMs":66,"endMs":66},{"id":"memory","label":"Load memory learnings","startMs":66,"endMs":66},{"id":"workspace-connection","label":"Build workspace connection","startMs":66,"endMs":68},{"id":"governance","label":"Refresh governance","startMs":72,"endMs":72},{"id":"user-agent-gate","label":"Wait for user-agent experiment","startMs":77,"endMs":320},{"id":"read-messages","label":"Read transcript","startMs":320,"endMs":323},{"id":"replay","label":"Replay history to client","startMs":323,"endMs":326},{"id":"build-context","label":"Rebuild model context","startMs":328,"endMs":329},{"id":"tools","label":"Build tool list","startMs":329,"endMs":331}],"counts":{"events":6}}}},"modes":{"availableModes":[{"id":"vibe","name":"Default","description":"General coding assistance","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"id":"spec","name":"Spec","description":"Structured feature development","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"id":"quick-spec","name":"Quick Spec","description":"Fast spec workflow: clarify, then auto-generate requirements, design, and tasks","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"id":"bug-fix","name":"Bug Fix","description":"Structured bug-fix workflow: investigate, diagnose, and resolve bugs","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"id":"plan","name":"Plan","description":"Plan-only mode that helps break ideas down into an implementation plan without making any changes","_meta":{"kiro":{"welcomeMessage":"Transform any idea into fully working code. What do you want to build today?","source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"id":"autonomous","name":"Autonomous","description":"Autonomous agent execution","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"id":"semantic_reviewer","name":"semantic_reviewer","description":"Behavioral, narrative code review of a local or PR diff. Writes a review document and a verdict.","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}}],"currentModeId":"vibe"},"configOptions":[{"type":"select","id":"mode","name":"Mode","category":"mode","currentValue":"vibe","options":[{"value":"vibe","name":"Default","description":"General coding assistance","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"value":"spec","name":"Spec","description":"Structured feature development","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"value":"quick-spec","name":"Quick Spec","description":"Fast spec workflow: clarify, then auto-generate requirements, design, and tasks","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"value":"bug-fix","name":"Bug Fix","description":"Structured bug-fix workflow: investigate, diagnose, and resolve bugs","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"value":"plan","name":"Plan","description":"Plan-only mode that helps break ideas down into an implementation plan without making any changes","_meta":{"kiro":{"welcomeMessage":"Transform any idea into fully working code. What do you want to build today?","source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"value":"autonomous","name":"Autonomous","description":"Autonomous agent execution","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"value":"semantic_reviewer","name":"semantic_reviewer","description":"Behavioral, narrative code review of a local or PR diff. Writes a review document and a verdict.","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}}]},{"type":"select","id":"memoryReflection","name":"Memory reflection","description":"Allow this session to reflect on new memory","currentValue":"on","options":[{"value":"on","name":"On"},{"value":"off","name":"Off"}]},{"type":"select","id":"autopilot","name":"Autopilot","currentValue":"on","options":[{"value":"on","name":"Autopilot","description":"Agent executes tools without confirmation"},{"value":"off","name":"Supervised","description":"Agent asks for approval before file changes"}]},{"type":"select","id":"contentCollection","name":"Content Collection","currentValue":"enabled","options":[{"value":"enabled","name":"Enabled","description":"Allow content to be used for service improvement"},{"value":"disabled","name":"Disabled","description":"Opt out of content collection"}]}]}}} -{"type":"expect_outbound","label":"request:session/set_config_option","frame":{"kind":"request","method":"session/set_config_option","params":{"sessionId":"sess_00000000-0000-4000-8000-000000000001","configId":"model","value":"claude-haiku-4.5"}}} -{"type":"emit_inbound","label":"notification:session/update:session_info_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"sess_00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"session_info_update","_meta":{"kiro":{"focus":{"status":"idle"},"kind":"focus_update","status":"idle","activity":{"turnActive":false,"runningWorkflows":0,"pausedWorkflows":0}}}}}}} -{"type":"emit_inbound","label":"notification:session/update:session_info_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"sess_00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"session_info_update","_meta":{"kiro":{"contextUsage":{"usagePercentage":9.29900074005127},"breakdown":{"contextFiles":{"tokens":0,"percent":0,"items":[]},"tools":{"tokens":5481,"percent":0.5,"mcp":{"tokens":0,"percent":0},"builtin":{"tokens":5481,"percent":0.5}},"memory":{"tokens":0,"percent":0},"kiroResponses":{"tokens":13,"percent":0.2},"yourPrompts":{"tokens":466,"percent":8.6},"sessionFiles":{"tokens":0,"percent":0,"items":[]}},"kind":"context_usage","usagePercentage":9.29900074005127}}}}}} -{"type":"emit_inbound","label":"notification:session/update:available_commands_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"sess_00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"architecture-selection","description":"Steering: architecture-selection","input":{"hint":"optional context"},"_meta":{"kiro":{"type":"steering","contextQuery":"global:architecture-selection","commandId":"architecture-selection","resource":{"resourceType":"steering","source":{"origin":"bundled"}},"originalName":"architecture-selection"}}},{"name":"quick-spec","description":"Steering: quick-spec","input":{"hint":"optional context"},"_meta":{"kiro":{"type":"steering","contextQuery":"global:quick-spec","commandId":"quick-spec","resource":{"resourceType":"steering","source":{"origin":"bundled"}},"originalName":"quick-spec"}}},{"name":"bug-fix","description":"Steering: bug-fix","input":{"hint":"optional context"},"_meta":{"kiro":{"type":"steering","contextQuery":"global:bug-fix","commandId":"bug-fix","resource":{"resourceType":"steering","source":{"origin":"bundled"}},"originalName":"bug-fix"}}},{"name":"general-task-execution","description":"General-purpose sub-agent with access to all tools for executing arbitrary tasks","input":{"hint":"task to delegate"},"_meta":{"kiro":{"type":"custom-agent","commandId":"general-task-execution","resource":{"resourceType":"agent","source":{"origin":"bundled"}},"originalName":"general-task-execution"}}},{"name":"context-gatherer","description":"Investigates the codebase to answer a specific question about it. Deeply analyzes existing code by tracing execution paths, mapping architecture and abstraction layers, understanding patterns, and documenting dependencies, returning a thorough, direct written analysis that explains how the relevant code works and points to the specific files and lines involved. Use it to understand code before you change it: before designing or implementing a change to code you don't already fully understand, start with context-gatherer so your edits are grounded in how the code actually works. When a task requires understanding code you don't already know, dispatch context-gatherer to do that exploration before running your own searches or reads. Rules: 1. Provide a clear, specific prompt that includes your intent: a detailed task description of the context you need and what you're trying to accomplish. 2. The subagent explores the codebase and returns an analysis of your query 3. Trust the returned results. You may still inspect additional code yourself when you need context beyond what was returned. 4. Reuse existing context-gatherer output for related follow-up questions; do not re-invoke on the same question with different phrasing. 5. For multiple distinct questions, dispatch several context-gatherers in parallel, each targeting a different aspect of the codebase.","input":{"hint":"task to delegate"},"_meta":{"kiro":{"type":"custom-agent","commandId":"context-gatherer","resource":{"resourceType":"agent","source":{"origin":"bundled"}},"originalName":"context-gatherer"}}}]}}}} -{"type":"emit_inbound","label":"notification:session/update:available_commands_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"sess_00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"architecture-selection","description":"Steering: architecture-selection","input":{"hint":"optional context"},"_meta":{"kiro":{"type":"steering","contextQuery":"global:architecture-selection","commandId":"architecture-selection","resource":{"resourceType":"steering","source":{"origin":"bundled"}},"originalName":"architecture-selection"}}},{"name":"quick-spec","description":"Steering: quick-spec","input":{"hint":"optional context"},"_meta":{"kiro":{"type":"steering","contextQuery":"global:quick-spec","commandId":"quick-spec","resource":{"resourceType":"steering","source":{"origin":"bundled"}},"originalName":"quick-spec"}}},{"name":"bug-fix","description":"Steering: bug-fix","input":{"hint":"optional context"},"_meta":{"kiro":{"type":"steering","contextQuery":"global:bug-fix","commandId":"bug-fix","resource":{"resourceType":"steering","source":{"origin":"bundled"}},"originalName":"bug-fix"}}},{"name":"general-task-execution","description":"General-purpose sub-agent with access to all tools for executing arbitrary tasks","input":{"hint":"task to delegate"},"_meta":{"kiro":{"type":"custom-agent","commandId":"general-task-execution","resource":{"resourceType":"agent","source":{"origin":"bundled"}},"originalName":"general-task-execution"}}},{"name":"context-gatherer","description":"Investigates the codebase to answer a specific question about it. Deeply analyzes existing code by tracing execution paths, mapping architecture and abstraction layers, understanding patterns, and documenting dependencies, returning a thorough, direct written analysis that explains how the relevant code works and points to the specific files and lines involved. Use it to understand code before you change it: before designing or implementing a change to code you don't already fully understand, start with context-gatherer so your edits are grounded in how the code actually works. When a task requires understanding code you don't already know, dispatch context-gatherer to do that exploration before running your own searches or reads. Rules: 1. Provide a clear, specific prompt that includes your intent: a detailed task description of the context you need and what you're trying to accomplish. 2. The subagent explores the codebase and returns an analysis of your query 3. Trust the returned results. You may still inspect additional code yourself when you need context beyond what was returned. 4. Reuse existing context-gatherer output for related follow-up questions; do not re-invoke on the same question with different phrasing. 5. For multiple distinct questions, dispatch several context-gatherers in parallel, each targeting a different aspect of the codebase.","input":{"hint":"task to delegate"},"_meta":{"kiro":{"type":"custom-agent","commandId":"context-gatherer","resource":{"resourceType":"agent","source":{"origin":"bundled"}},"originalName":"context-gatherer"}}}]}}}} -{"type":"emit_inbound","label":"response:session/set_config_option","frame":{"kind":"response","method":"session/set_config_option","result":{"configOptions":[{"type":"select","id":"mode","name":"Mode","category":"mode","currentValue":"vibe","options":[{"value":"vibe","name":"Default","description":"General coding assistance","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"value":"spec","name":"Spec","description":"Structured feature development","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"value":"quick-spec","name":"Quick Spec","description":"Fast spec workflow: clarify, then auto-generate requirements, design, and tasks","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"value":"bug-fix","name":"Bug Fix","description":"Structured bug-fix workflow: investigate, diagnose, and resolve bugs","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"value":"plan","name":"Plan","description":"Plan-only mode that helps break ideas down into an implementation plan without making any changes","_meta":{"kiro":{"welcomeMessage":"Transform any idea into fully working code. What do you want to build today?","source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"value":"autonomous","name":"Autonomous","description":"Autonomous agent execution","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"value":"semantic_reviewer","name":"semantic_reviewer","description":"Behavioral, narrative code review of a local or PR diff. Writes a review document and a verdict.","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}}]},{"type":"select","id":"memoryReflection","name":"Memory reflection","description":"Allow this session to reflect on new memory","currentValue":"on","options":[{"value":"on","name":"On"},{"value":"off","name":"Off"}]},{"type":"select","id":"autopilot","name":"Autopilot","currentValue":"on","options":[{"value":"on","name":"Autopilot","description":"Agent executes tools without confirmation"},{"value":"off","name":"Supervised","description":"Agent asks for approval before file changes"}]},{"type":"select","id":"contentCollection","name":"Content Collection","currentValue":"enabled","options":[{"value":"enabled","name":"Enabled","description":"Allow content to be used for service improvement"},{"value":"disabled","name":"Disabled","description":"Opt out of content collection"}]}]}}} -{"type":"emit_inbound","label":"notification:session/update:config_option_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"sess_00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"config_option_update","configOptions":[{"type":"select","id":"mode","name":"Mode","category":"mode","currentValue":"vibe","options":[{"value":"vibe","name":"Default","description":"General coding assistance","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"value":"spec","name":"Spec","description":"Structured feature development","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"value":"quick-spec","name":"Quick Spec","description":"Fast spec workflow: clarify, then auto-generate requirements, design, and tasks","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"value":"bug-fix","name":"Bug Fix","description":"Structured bug-fix workflow: investigate, diagnose, and resolve bugs","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"value":"plan","name":"Plan","description":"Plan-only mode that helps break ideas down into an implementation plan without making any changes","_meta":{"kiro":{"welcomeMessage":"Transform any idea into fully working code. What do you want to build today?","source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"value":"autonomous","name":"Autonomous","description":"Autonomous agent execution","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}},{"value":"semantic_reviewer","name":"semantic_reviewer","description":"Behavioral, narrative code review of a local or PR diff. Writes a review document and a verdict.","_meta":{"kiro":{"source":"bundled","resource":{"resourceType":"agent","source":{"origin":"bundled"}}}}}]},{"type":"select","id":"model","name":"Model","category":"model","currentValue":"claude-haiku-4.5","options":[{"value":"auto","name":"Auto","description":"Models chosen by task for optimal usage and consistent quality","_meta":{"kiro":{"rateMultiplier":1,"rateUnit":"Credit","hasEffort":false,"thinkingToggleable":false}}},{"value":"claude-opus-5.5","name":"Claude Opus 5.5","description":"Experimental preview of Claude Opus 5.5 model with 1M context window","_meta":{"kiro":{"rateMultiplier":2,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"output_config","effortLevels":["low","medium","high","xhigh","max"],"defaultEffortLevel":"medium","thinkingToggleable":false}}},{"value":"claude-sonnet-5.5","name":"Claude Sonnet 5.5","description":"Claude Sonnet 5.5 model with 1M context window","_meta":{"kiro":{"rateMultiplier":1.3,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"output_config","effortLevels":["low","medium","high","xhigh","max"],"defaultEffortLevel":"high","thinkingToggleable":false}}},{"value":"claude-opus-5","name":"Claude Opus 5","description":"Claude Opus 5 model with 1M context window","_meta":{"kiro":{"rateMultiplier":2.2,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"output_config","effortLevels":["low","medium","high","xhigh","max"],"defaultEffortLevel":"high","thinkingToggleable":true,"defaultThinkingEnabled":true}}},{"value":"claude-sonnet-5","name":"Claude Sonnet 5","description":"Claude Sonnet 5 model with 1M context window","_meta":{"kiro":{"rateMultiplier":1.3,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"output_config","effortLevels":["low","medium","high","xhigh","max"],"defaultEffortLevel":"high","thinkingToggleable":true,"defaultThinkingEnabled":true}}},{"value":"claude-opus-4.8","name":"Claude Opus 4.8","description":"Claude Opus 4.8 model with 1M context window","_meta":{"kiro":{"rateMultiplier":2.2,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"output_config","effortLevels":["low","medium","high","xhigh","max"],"defaultEffortLevel":"high","thinkingToggleable":true,"defaultThinkingEnabled":true}}},{"value":"gpt-5.6-sol","name":"GPT 5.6 Sol","description":"Experimental preview of OpenAI GPT 5.6 Sol with 1M context window","_meta":{"kiro":{"rateMultiplier":4.4,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"reasoning","effortLevels":["none","low","medium","high","xhigh","max"],"defaultEffortLevel":"high","thinkingToggleable":false}}},{"value":"gpt-5.6-terra","name":"GPT 5.6 Terra","description":"Experimental preview of OpenAI GPT 5.6 Terra with 1M context window","_meta":{"kiro":{"rateMultiplier":2.2,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"reasoning","effortLevels":["none","low","medium","high","xhigh","max"],"defaultEffortLevel":"high","thinkingToggleable":false}}},{"value":"gpt-5.6-luna","name":"GPT 5.6 Luna","description":"Experimental preview of OpenAI GPT 5.6 Luna with 1M context window","_meta":{"kiro":{"rateMultiplier":0.6,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"reasoning","effortLevels":["none","low","medium","high","xhigh","max"],"defaultEffortLevel":"high","thinkingToggleable":false}}},{"value":"claude-opus-4.7","name":"Claude Opus 4.7","description":"Claude Opus 4.7 model with 1M context window","_meta":{"kiro":{"rateMultiplier":2.2,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"output_config","effortLevels":["low","medium","high","xhigh","max"],"defaultEffortLevel":"xhigh","thinkingToggleable":true,"defaultThinkingEnabled":true}}},{"value":"claude-opus-4.6","name":"Claude Opus 4.6","description":"Claude Opus 4.6 model with 1M context window","_meta":{"kiro":{"rateMultiplier":2.2,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"output_config","effortLevels":["low","medium","high","max"],"defaultEffortLevel":"high","thinkingToggleable":true,"defaultThinkingEnabled":true}}},{"value":"claude-sonnet-4.6","name":"Claude Sonnet 4.6","description":"Claude Sonnet 4.6 model with 1M context window","_meta":{"kiro":{"rateMultiplier":1.3,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"output_config","effortLevels":["low","medium","high","max"],"defaultEffortLevel":"high","thinkingToggleable":true,"defaultThinkingEnabled":true}}},{"value":"claude-opus-4.5","name":"Claude Opus 4.5","description":"Claude Opus 4.5 model","_meta":{"kiro":{"rateMultiplier":2.2,"rateUnit":"Credit","hasEffort":false,"thinkingToggleable":false}}},{"value":"claude-sonnet-4.5","name":"Claude Sonnet 4.5","description":"Claude Sonnet 4.5 model","_meta":{"kiro":{"rateMultiplier":1.3,"rateUnit":"Credit","hasEffort":false,"thinkingToggleable":false}}},{"value":"claude-haiku-4.5","name":"Claude Haiku 4.5","description":"The latest Claude Haiku model","_meta":{"kiro":{"rateMultiplier":0.4,"rateUnit":"Credit","hasEffort":false,"thinkingToggleable":false}}},{"value":"deepseek-3.2","name":"Deepseek v3.2","description":"Experimental preview of DeepSeek V3.2","_meta":{"kiro":{"rateMultiplier":0.25,"rateUnit":"Credit","hasEffort":false,"thinkingToggleable":false}}},{"value":"minimax-m2.5","name":"MiniMax M2.5","description":"MiniMax M2.5 model","_meta":{"kiro":{"rateMultiplier":0.25,"rateUnit":"Credit","hasEffort":false,"thinkingToggleable":false}}},{"value":"minimax-m2.1","name":"MiniMax M2.1","description":"Experimental preview of MiniMax M2.1","_meta":{"kiro":{"rateMultiplier":0.15,"rateUnit":"Credit","hasEffort":false,"thinkingToggleable":false}}},{"value":"glm-5","name":"GLM 5","description":"GLM-5 model","_meta":{"kiro":{"rateMultiplier":0.5,"rateUnit":"Credit","hasEffort":false,"thinkingToggleable":false}}},{"value":"qwen3-coder-next","name":"Qwen3 Coder Next","description":"Experimental preview of Qwen3 Coder Next","_meta":{"kiro":{"rateMultiplier":0.05,"rateUnit":"Credit","hasEffort":false,"thinkingToggleable":false}}},{"value":"kimi-k3","name":"Kimi K3","description":"Experimental preview of Kimi K3. Kimi K3 is a Kiro Excluded Model. For more info, see documentation.","_meta":{"kiro":{"rateMultiplier":2.4,"rateUnit":"Credit","hasEffort":true,"effortSchemaPath":"reasoning","effortLevels":["low","high","max"],"defaultEffortLevel":"max","thinkingToggleable":false}}}]},{"type":"select","id":"memoryReflection","name":"Memory reflection","description":"Allow this session to reflect on new memory","currentValue":"on","options":[{"value":"on","name":"On"},{"value":"off","name":"Off"}]},{"type":"select","id":"autopilot","name":"Autopilot","currentValue":"on","options":[{"value":"on","name":"Autopilot","description":"Agent executes tools without confirmation"},{"value":"off","name":"Supervised","description":"Agent asks for approval before file changes"}]},{"type":"select","id":"contentCollection","name":"Content Collection","currentValue":"enabled","options":[{"value":"enabled","name":"Enabled","description":"Allow content to be used for service improvement"},{"value":"disabled","name":"Disabled","description":"Opt out of content collection"}]}]}}}} -{"type":"expect_outbound","label":"request:session/prompt","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"sess_00000000-0000-4000-8000-000000000001","prompt":[{"type":"text","text":"\n\n\n\n\nUsing the conversation history available in this resumed thread, first repeat the exact final answer you gave in the previous turn. Then on a new line write exactly: provider thread resume fixture second turn complete\n"}]}}} -{"type":"emit_inbound","label":"notification:session/update:session_info_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"sess_00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"session_info_update","_meta":{"kiro":{"userMessageId":"00000000-0000-4000-9000-000000000006","kind":"user_message_id_assigned"}}}}}} -{"type":"emit_inbound","label":"notification:session/update:session_info_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"sess_00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"session_info_update","_meta":{"kiro":{"focus":{"status":"in_progress"},"kind":"focus_update","status":"in_progress","activity":{"turnActive":true,"runningWorkflows":0,"pausedWorkflows":0}}}}}}} -{"type":"emit_inbound","label":"notification:session/update:session_info_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"sess_00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"session_info_update","_meta":{"kiro":{"turnStart":true,"kind":"turn_start","messageId":"00000000-0000-4000-9000-000000000007-turn-start"}}}}}} -{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"sess_00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"provider"},"_meta":{"kiro":{"replayId":"00000000-0000-4000-9000-000000000008-say"}}}}}} -{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"sess_00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" thread resume fixture first"},"_meta":{"kiro":{"replayId":"00000000-0000-4000-9000-000000000008-say"}}}}}} -{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"sess_00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" turn complete"},"_meta":{"kiro":{"replayId":"00000000-0000-4000-9000-000000000008-say"}}}}}} -{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"sess_00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"\nprovider"},"_meta":{"kiro":{"replayId":"00000000-0000-4000-9000-000000000008-say"}}}}}} -{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"sess_00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" thread resume fixture second turn complete"},"_meta":{"kiro":{"replayId":"00000000-0000-4000-9000-000000000008-say"}}}}}} -{"type":"emit_inbound","label":"notification:session/update:session_info_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"sess_00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"session_info_update","_meta":{"kiro":{"contextUsage":{"usagePercentage":9.546499252319336},"kind":"context_usage","usagePercentage":9.546499252319336}}}}}} -{"type":"emit_inbound","label":"notification:session/update:session_info_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"sess_00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"session_info_update","_meta":{"kiro":{"contextUsage":{"usagePercentage":9.546499252319336},"breakdown":{"contextFiles":{"tokens":0,"percent":0,"items":[]},"tools":{"tokens":5481,"percent":0.5,"mcp":{"tokens":0,"percent":0},"builtin":{"tokens":5481,"percent":0.5}},"memory":{"tokens":0,"percent":0},"kiroResponses":{"tokens":13,"percent":0.2},"yourPrompts":{"tokens":466,"percent":8.8},"sessionFiles":{"tokens":0,"percent":0,"items":[]}},"kind":"context_usage","usagePercentage":9.546499252319336}}}}}} -{"type":"emit_inbound","label":"notification:session/update:session_info_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"sess_00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"session_info_update","_meta":{"kiro":{"promptTurnSummaries":[{"unit":"credit","unitPlural":"credits","usage":0.0199145304145937}],"elapsedTime":1180,"status":"success","kind":"turn_completion","requestIds":["00000000-0000-4000-9000-000000000009"]}}}}}} -{"type":"emit_inbound","label":"notification:session/update:session_info_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"sess_00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"session_info_update","_meta":{"kiro":{"turnEnd":{"stopReason":"end_turn"},"kind":"turn_end","stopReason":"end_turn","messageId":"00000000-0000-4000-9000-000000000007-turn-end"}}}}}} -{"type":"emit_inbound","label":"notification:session/update:session_info_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"sess_00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"session_info_update","_meta":{"kiro":{"focus":{"status":"idle"},"kind":"focus_update","status":"idle","activity":{"turnActive":false,"runningWorkflows":0,"pausedWorkflows":0}}}}}}} -{"type":"emit_inbound","label":"response:session/prompt","frame":{"kind":"response","method":"session/prompt","result":{"stopReason":"end_turn"}}} -{"type":"emit_inbound","label":"notification:session/update:session_info_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"sess_00000000-0000-4000-8000-000000000001","update":{"sessionUpdate":"session_info_update","_meta":{"kiro":{"contextUsage":{"usagePercentage":9.546499252319336},"breakdown":{"contextFiles":{"tokens":0,"percent":0,"items":[]},"tools":{"tokens":5481,"percent":0.5,"mcp":{"tokens":0,"percent":0},"builtin":{"tokens":5481,"percent":0.5}},"memory":{"tokens":0,"percent":0},"kiroResponses":{"tokens":39,"percent":0.4},"yourPrompts":{"tokens":968,"percent":8.6},"sessionFiles":{"tokens":0,"percent":0,"items":[]}},"kind":"context_usage","usagePercentage":9.546499252319336}}}}}} -{"type":"expect_outbound","label":"request:session/close","frame":{"kind":"request","method":"session/close","params":{"sessionId":"sess_00000000-0000-4000-8000-000000000001"}}} -{"type":"emit_inbound","label":"response:session/close","frame":{"kind":"response","method":"session/close","result":{}}} -{"type":"runtime_exit","status":"success"} From ed2a319814c7e506fdf4645f6f84d206133e95c7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 19:29:22 -0800 Subject: [PATCH 005/390] fix(web): arrow and page keys scroll the conversation again after a click or F6 (#27209) --- apps/web/src/components/ChatView.logic.ts | 6 +-- .../chat/MessagesTimeline.browser.test.tsx | 35 ++++++++++++++++- .../src/components/chat/MessagesTimeline.tsx | 3 ++ apps/web/src/index.css | 12 +++++- .../src/lib/workspaceFocus.browser.test.ts | 38 ++++++++++++++++--- apps/web/src/lib/workspaceFocus.ts | 33 ++++++++++------ 6 files changed, 105 insertions(+), 22 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 0848d27c05bdb..e55c68589f7ff 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1070,9 +1070,9 @@ export function hasServerAcknowledgedLocalDispatch(input: { } // Window return puts the caret in the composer unless focus is deliberate: another text field, -// a terminal, an open dialog, menu, or popup, a region or sidebar row reached from the keyboard, -// or a region with its own keys (aria-keyshortcuts). A plain focused button or a clicked row -// yields to the composer. +// a terminal, an open dialog, menu, or popup, a region, timeline, or sidebar row reached from the +// keyboard, or a region with its own keys (aria-keyshortcuts). A plain focused button or a clicked +// row yields to the composer. export function shouldRefocusComposerOnWindowFocus( activeElement: | (Pick & { isContentEditable?: boolean }) diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx index 3907ef764c5a5..0b33414e80dca 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx @@ -2,7 +2,7 @@ import { injectHtmlRenderBootstrap } from "@t3tools/shared/htmlRender"; import { HtmlRenderDocument } from "../files/BrowserDocumentFrame"; import { flushSync } from "react-dom"; import { AudioPreview } from "../files/AudioPreview"; -import { cdp, page } from "vite-plus/test/browser"; +import { cdp, page, userEvent } from "vite-plus/test/browser"; import { ComposerContextId, EnvironmentId, @@ -37,6 +37,7 @@ import { environmentThreadDetails } from "../../state/threads"; import { act, fakeTimers, render, stubGlobal } from "../../../test/render"; import { ChangedFilesCard } from "./ChangedFilesTree"; import { MessagesTimeline } from "./MessagesTimeline"; +import { createWorkspaceFocusController, workspaceAreaProps } from "../../lib/workspaceFocus"; import { resolveTimelineIsAtEnd } from "./MessagesTimeline.logic"; import { makeStreamingTimelineFixture } from "../../test-fixtures"; import { @@ -565,6 +566,38 @@ describe("MessagesTimeline messages", () => { await expectHeadingInView("Last turn", "Turn 118:"); }); + it("scrolls from the keyboard once F6 moves into the conversation", async () => { + setup(); + const props = baseProps(); + const entries = Array.from({ length: 40 }, (_, index) => + message(`Turn ${index}:\n\n${"x ".repeat(200)}`, {}, `turn-${index}`), + ); + await using _screen = await renderElement( + <> +
+ +
+
+ +
+ , + ); + const focus = createWorkspaceFocusController(document); + using _focus = { [Symbol.dispose]: focus.dispose }; + const viewport = props.listRef.current!.getScrollableNode()!; + await vi.waitFor(() => expect(viewport.scrollTop).toBeGreaterThan(0)); + const atEnd = viewport.scrollTop; + page.getByRole("button", { name: "Send" }).element().focus(); + await userEvent.keyboard("{F6}"); + focus.move(-1); + expect(viewport).toHaveAttribute("data-workspace-keyboard-focus"); + await userEvent.keyboard("{PageUp}"); + await vi.waitFor(() => expect(viewport.scrollTop).toBeLessThan(atEnd)); + }); + it("disables the minimap's next turn button at the bottom of the thread", async () => { setup(); const props = baseProps(); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index ac091176ce426..eb4bd6fea6efd 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1468,6 +1468,9 @@ const ConversationTimeline = memo(function ConversationTimeline({ onScroll={handleScroll} onItemSizeChanged={scheduleMinimapRefresh} onMetricsChange={scheduleMinimapRefresh} + // Keyboard scrolling only moves the focused element; F6 and clicks land here. + tabIndex={-1} + data-workspace-focus-default className={cn( "messages-timeline-scroll scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain [overflow-anchor:none]", topFadeEnabled && "topbar-scroll-fade", diff --git a/apps/web/src/index.css b/apps/web/src/index.css index b4597954cb764..0f9942e3295ee 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -2624,11 +2624,19 @@ code { } /* A workspace area with no control of its own takes F6 focus itself; keep that visible. - workspaceFocus.ts marks those, since :focus-visible also matches a clicked area after a key press. */ + workspaceFocus.ts marks those, since :focus-visible also matches a clicked area after a key press. + The conversation's timeline takes it instead, and its top fade mask would hide its own ring. */ [data-workspace-area]:focus-visible { outline: none; } -[data-workspace-keyboard-focus]:focus { +[data-workspace-keyboard-focus]:focus, +[data-workspace-area="conversation"]:has( + .messages-timeline-scroll[data-workspace-keyboard-focus]:focus +) { outline: 2px solid var(--ring); outline-offset: -2px; } + +.messages-timeline-scroll:focus-visible { + outline: none; +} diff --git a/apps/web/src/lib/workspaceFocus.browser.test.ts b/apps/web/src/lib/workspaceFocus.browser.test.ts index 4674e02ea61fc..769423a512b2c 100644 --- a/apps/web/src/lib/workspaceFocus.browser.test.ts +++ b/apps/web/src/lib/workspaceFocus.browser.test.ts @@ -33,6 +33,15 @@ function setup() { }; } +/** Mirrors MessagesTimeline's scroller, which takes focus so the keyboard scrolls it. */ +function conversationScroller(height: number) { + const timeline = document.createElement("div"); + timeline.style.cssText = `height: ${height}px; overflow-y: auto`; + timeline.tabIndex = -1; + timeline.dataset.workspaceFocusDefault = ""; + return timeline; +} + describe("workspace area focus", () => { it("cycles in workspace order in both directions, independent of DOM order", () => { using fixture = setup(); @@ -42,7 +51,7 @@ describe("workspace area focus", () => { const composer = fixture.area("composer", 2); sidebar.button.focus(); fixture.controller.move(1); - // The conversation is a region; it takes focus itself rather than its first button. + // Without a timeline (an empty thread), the region takes focus rather than its first button. expect(document.activeElement).toBe(conversation.element); fixture.controller.move(1); expect(document.activeElement).toBe(composer.button); @@ -204,8 +213,7 @@ describe("workspace area focus", () => { it("does not scroll the conversation to an old control", () => { using fixture = setup(); const conversation = fixture.area("conversation", 1); - const timeline = document.createElement("div"); - timeline.style.cssText = "height: 50px; overflow-y: auto"; + const timeline = conversationScroller(50); const copy = document.createElement("button"); copy.textContent = "Copy"; timeline.append(copy, Object.assign(document.createElement("div"), { style: "height: 400px" })); @@ -217,10 +225,28 @@ describe("workspace area focus", () => { // Live follow moved the timeline to the newest output. timeline.scrollTop = 300; fixture.controller.move(-1); - expect(document.activeElement).toBe(conversation.element); + expect(document.activeElement).toBe(timeline); expect(timeline.scrollTop).toBe(300); }); + it("scrolls the conversation with the keyboard once an empty thread gets a timeline", async () => { + using fixture = setup(); + const conversation = fixture.area("conversation", 1); + conversation.button.remove(); + conversation.element.style.height = "200px"; + const composer = fixture.area("composer", 2); + composer.button.focus(); + fixture.controller.move(-1); + expect(document.activeElement).toBe(conversation.element); + fixture.controller.move(1); + const timeline = conversationScroller(200); + timeline.append(Object.assign(document.createElement("div"), { style: "height: 2000px" })); + conversation.element.append(timeline); + fixture.controller.move(-1); + await userEvent.keyboard("{ArrowDown}{PageDown}"); + await vi.waitFor(() => expect(timeline.scrollTop).toBeGreaterThan(0)); + }); + it.each([ ["panel", "panel", "removed"], ["conversation", "composer", "removed"], @@ -281,7 +307,9 @@ describe("workspace area focus", () => { bringBack.textContent = "Bring back"; content.append(bringBack); panel.element.append(content); - fixture.area("conversation", 1, content); + const timeline = conversationScroller(50); + timeline.append(Object.assign(document.createElement("div"), { style: "height: 400px" })); + fixture.area("conversation", 1, content).element.append(timeline); const composer = fixture.area("composer", 2, content); const editor = document.createElement("div"); editor.contentEditable = "true"; diff --git a/apps/web/src/lib/workspaceFocus.ts b/apps/web/src/lib/workspaceFocus.ts index 38abacd9a82a6..05dd8281dca05 100644 --- a/apps/web/src/lib/workspaceFocus.ts +++ b/apps/web/src/lib/workspaceFocus.ts @@ -100,18 +100,22 @@ function revealInScrollers(target: HTMLElement) { export function createWorkspaceFocusController(doc: Document) { const lastFocused = new Map(); let lastArea: string | undefined; - // An area that took focus itself from the keyboard. Chromium's :focus-visible also matches - // a clicked area after any later key press, so the ring and window return read this mark. + // An area that took focus itself from the keyboard, or the non-tabbable default (the + // conversation's scroller) standing in for it. Chromium's :focus-visible also matches a + // clicked area after any later key press, so the ring and window return read this mark. let keyboardArea: HTMLElement | undefined; let previousTarget: EventTarget | null = null; const unmarkKeyboardArea = () => { keyboardArea?.removeAttribute("data-workspace-keyboard-focus"); keyboardArea = undefined; }; - const markKeyboardArea = (area: HTMLElement) => { - if (!area.matches(":focus-visible")) return; - keyboardArea = area; - area.setAttribute("data-workspace-keyboard-focus", ""); + const standsInFor = (element: HTMLElement, area: HTMLElement) => + element === area || + (element.tabIndex < 0 && element.hasAttribute("data-workspace-focus-default")); + const markKeyboardArea = (target: HTMLElement) => { + if (!target.matches(":focus-visible")) return; + keyboardArea = target; + target.setAttribute("data-workspace-keyboard-focus", ""); }; const remember = (event: FocusEvent) => { if (event.target !== keyboardArea) unmarkKeyboardArea(); @@ -121,7 +125,7 @@ export function createWorkspaceFocusController(doc: Document) { if (!(event.target instanceof HTMLElement)) return; const area = workspaceArea(event.target); if (!area?.dataset.workspaceArea) return; - if (arrived && event.target === area) markKeyboardArea(area); + if (arrived && standsInFor(event.target, area)) markKeyboardArea(event.target); lastArea = area.dataset.workspaceArea; const key = area.dataset.workspaceFocusKey ?? lastArea; lastFocused.delete(key); @@ -141,11 +145,14 @@ export function createWorkspaceFocusController(doc: Document) { const belongs = (element: HTMLElement) => workspaceArea(element) === area && usable(element); // A side chat's message input sits in its own area inside the panel. Opening the // panel focuses it, but F6 visits that area on its own, so it moves past it here. + // A nested timeline stands in only for its own area, never ahead of that input. const preferred = Array.from( area.querySelectorAll( '[data-workspace-focus-default], [data-sidebar-row][aria-current="page"], [data-settings-section][aria-current="page"], .xterm-helper-textarea, [contenteditable="true"]', ), - ).find(nested ? usable : belongs); + ).find((element) => + nested && !standsInFor(element, area) ? usable(element) : belongs(element), + ); const fallback = Array.from(area.querySelectorAll(FOCUSABLE_SELECTOR)).find( (element) => element.tabIndex >= 0 && belongs(element), ); @@ -172,10 +179,14 @@ export function createWorkspaceFocusController(doc: Document) { ) : undefined; // Moving the timeline to an old control would also stop it following a running turn, - // so the conversation reuses a control only while it is still in view. + // so the conversation reuses a control only while it is still in view. A remembered + // area is only the fallback it was then, so controls mounted since take precedence. const isConversation = area.dataset.workspaceArea === "conversation"; const targets = new Set([ - previous && belongs(previous) && (!isConversation || isInScrollView(previous)) + previous && + previous !== area && + belongs(previous) && + (!isConversation || isInScrollView(previous)) ? previous : undefined, isConversation ? undefined : restored, @@ -192,7 +203,7 @@ export function createWorkspaceFocusController(doc: Document) { if (active === target || (active && target.contains(active))) { if (target !== area && !isConversation) revealInScrollers(target); // Focusing the already focused area fires no focusin, as when F6 has nowhere else to go. - if (target === area) markKeyboardArea(area); + if (standsInFor(target, area)) markKeyboardArea(target); return true; } } From 8b23382191ba7c7db69978d6a310a4244c566741 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 19:29:36 -0800 Subject: [PATCH 006/390] fix(kiro): compaction is refused when Kiro does not advertise _kiro/session/compact (#35866) --- .../Adapters/KiroAdapterV2.test.ts | 40 ++++++++++++--- packages/provider-acp/src/server/adapter.ts | 30 +++++++---- packages/provider-kiro/src/server/adapter.ts | 50 ++++++++++++------- packages/provider-kiro/src/server/status.ts | 2 +- 4 files changed, 87 insertions(+), 35 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/KiroAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/KiroAdapterV2.test.ts index 30304449c0f06..235d9850fbce3 100644 --- a/apps/server/src/orchestration-v2/Adapters/KiroAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/KiroAdapterV2.test.ts @@ -18,6 +18,7 @@ import { } from "@t3tools/contracts"; import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime"; import { htmlRenderFromToolItem } from "@t3tools/shared/toolOutput"; +import * as Cause from "effect/Cause"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -38,8 +39,11 @@ import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSession import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import { + ProviderAdapterTurnStartError, type ProviderAdapterV2Error, type ProviderAdapterV2Event, + type ProviderAdapterV2TurnInput, + ProviderCompactionUnsupportedError, ProviderAdapterV2RuntimePolicy, } from "@t3tools/provider-core/server/ProviderAdapter"; import { @@ -316,6 +320,8 @@ const runKiroScript = Effect.fn("runKiroScript")(function* (input: { model: string, options?: ModelSelection["options"], ) => Effect.Effect; + /** Runs T3's compact action as turn `ordinal`. */ + readonly compact: (ordinal: number) => Effect.Effect; readonly interrupt: ( providerTurnId: ProviderTurnId, ) => Effect.Effect; @@ -390,10 +396,15 @@ const runKiroScript = Effect.fn("runKiroScript")(function* (input: { }); const providerThread = yield* session.ensureThread({ threadId, modelSelection, runtimePolicy }); const now = yield* DateTime.now; - const startTurnOn = (ordinal: number, model: string, options?: ModelSelection["options"]) => { + const turnInput = ( + ordinal: number, + model: string, + options?: ModelSelection["options"], + text = "Say hello", + ): ProviderAdapterV2TurnInput => { const suffix = `${threadId}:${ordinal}`; const turnSelection = { instanceId, model, ...(options === undefined ? {} : { options }) }; - return session.startTurn({ + return { appThread: { createdBy: "user", creationSource: "web", @@ -428,17 +439,26 @@ const runKiroScript = Effect.fn("runKiroScript")(function* (input: { createdBy: "user", creationSource: "web", messageId: MessageId.make(`message:${suffix}`), - text: "Say hello", + text, attachments: [], }, modelSelection: turnSelection, runtimePolicy, - }); + }; }; + const startTurnOn = (ordinal: number, model: string, options?: ModelSelection["options"]) => + session.startTurn(turnInput(ordinal, model, options)); + const compact = (ordinal: number) => + session.compactThread === undefined + ? Effect.die("Kiro adapter does not offer compactThread") + : session.compactThread( + turnInput(ordinal, modelSelection.model, input.options, "/compact"), + ); yield* input.drive({ events: session.events, startTurn: startTurnOn(1, modelSelection.model, input.options), startTurnOn, + compact, interrupt: (providerTurnId) => session.interruptTurn({ providerThread, providerTurnId }), respond: (requestId, decision) => session.respondToRuntimeRequest({ requestId, decision }), }); @@ -460,7 +480,7 @@ const terminalStatus = (events: ReadonlyArray) => { }; describe("KiroAdapterV2", () => { - it.effect("opens a session on a deployment that advertises no Kiro extensions", () => + it.effect("opens a session and refuses compaction on a deployment with no Kiro extensions", () => runKiroScript({ scenario: "no-extensions", frames: [ @@ -476,10 +496,18 @@ describe("KiroAdapterV2", () => { }), answer("session/prompt", { stopReason: "end_turn" }), ], - drive: ({ events, startTurn }) => + drive: ({ events, startTurn, compact }) => Effect.gen(function* () { yield* startTurn; assert.equal(terminalStatus(yield* collectTurn(events)), "completed"); + // `_kiro/session/compact` is not advertised, so T3 must not send it. + const compacted = yield* Effect.exit(compact(2)); + const error = Exit.isFailure(compacted) ? Cause.squash(compacted.cause) : undefined; + assert.instanceOf(error, ProviderAdapterTurnStartError); + assert.instanceOf( + (error as ProviderAdapterTurnStartError).cause, + ProviderCompactionUnsupportedError, + ); }), }).pipe(Effect.provide(testLayer), Effect.scoped), ); diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index 963e3dbedea24..98b1514543f0d 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -512,11 +512,19 @@ export interface AcpAdapterV2Flavor { nativeTurnId: string, ) => NonNullable; }; - /** Compacts with the agent's own request, for agents that answer a `/compact` prompt as chat. */ - readonly compactSession?: ( - runtime: AcpSessionRuntime.AcpSessionRuntime["Service"], - sessionId: string, - ) => Effect.Effect; + /** + * Compacts with the agent's own request, for agents that answer a `/compact` prompt as chat. + * Compaction is refused when `supported` rejects the running agent's `initialize` result. + */ + readonly compactSession?: { + readonly supported: ( + initializeResult: AcpSessionRuntime.AcpSessionRuntimeStartResult["initializeResult"], + ) => boolean; + readonly compact: ( + runtime: AcpSessionRuntime.AcpSessionRuntime["Service"], + sessionId: string, + ) => Effect.Effect; + }; readonly runtimeHarness?: string; readonly registerExtensions?: ( context: AcpAdapterV2ExtensionContext, @@ -8276,11 +8284,15 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( } } // Checked after activation: a loaded session advertises its own commands. + const compactionSetup = yield* Ref.get(activeSessionSetup); if ( - flavor.compactViaAdvertisedCommand === true && isCompactionMessage(turnInput.message) && - (yield* Ref.get(advertisedCommandNames)).get(requestedSessionId)?.has("compact") !== - true + ((flavor.compactViaAdvertisedCommand === true && + (yield* Ref.get(advertisedCommandNames)).get(requestedSessionId)?.has("compact") !== + true) || + (flavor.compactSession !== undefined && + (compactionSetup === null || + !flavor.compactSession.supported(compactionSetup.initializeResult)))) ) { return yield* new ProviderAdapter.ProviderCompactionUnsupportedError({ driver, @@ -8550,7 +8562,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( runtime.prompt({ prompt: promptParts!.prompt }, promptOptions), ) : Deferred.succeed(context.promptDispatched, undefined).pipe( - Effect.andThen(compactSession(runtime, requestedSessionId)), + Effect.andThen(compactSession.compact(runtime, requestedSessionId)), Effect.as({ stopReason: "end_turn" } satisfies EffectAcpSchema.PromptResponse), ) ).pipe( diff --git a/packages/provider-kiro/src/server/adapter.ts b/packages/provider-kiro/src/server/adapter.ts index 6815b60e8f227..67e837a35b354 100644 --- a/packages/provider-kiro/src/server/adapter.ts +++ b/packages/provider-kiro/src/server/adapter.ts @@ -93,25 +93,37 @@ const KiroProviderCapabilitiesV2 = { const KiroCompactResult = Schema.Struct({ success: Schema.Boolean }); const decodeKiroCompactResult = Schema.decodeUnknownOption(KiroCompactResult); -/** Kiro answers a `/compact` prompt as chat, so T3 compacts with Kiro's own request. */ -const compactKiroSession = ( - runtime: AcpSessionRuntime.AcpSessionRuntime["Service"], - sessionId: string, -) => - runtime.request(KIRO_COMPACT_METHOD, { sessionId }).pipe( - Effect.flatMap((result) => - Option.exists(decodeKiroCompactResult(result), (decoded) => decoded.success) - ? Effect.void - : Effect.fail( - new EffectAcpErrors.AcpRequestError({ - code: -32603, - errorMessage: "Kiro could not compact the conversation.", - method: KIRO_COMPACT_METHOD, - data: result, - }), - ), +const decodeKiroExtensionMethods = Schema.decodeUnknownOption( + Schema.Struct({ kiro: Schema.Struct({ extensionMethods: Schema.Array(Schema.String) }) }), +); + +/** + * Kiro answers a `/compact` prompt as chat, so T3 compacts with Kiro's own request, + * on deployments whose `initialize` result lists it among Kiro's extension methods. + */ +const kiroCompactSession = { + supported: ( + initializeResult: AcpSessionRuntime.AcpSessionRuntimeStartResult["initializeResult"], + ) => + Option.exists(decodeKiroExtensionMethods(initializeResult.agentCapabilities?._meta), (meta) => + meta.kiro.extensionMethods.includes(KIRO_COMPACT_METHOD), ), - ); + compact: (runtime: AcpSessionRuntime.AcpSessionRuntime["Service"], sessionId: string) => + runtime.request(KIRO_COMPACT_METHOD, { sessionId }).pipe( + Effect.flatMap((result) => + Option.exists(decodeKiroCompactResult(result), (decoded) => decoded.success) + ? Effect.void + : Effect.fail( + new EffectAcpErrors.AcpRequestError({ + code: -32603, + errorMessage: "Kiro could not compact the conversation.", + method: KIRO_COMPACT_METHOD, + data: result, + }), + ), + ), + ), +}; const KiroSteerResult = Schema.Struct({ queued: Schema.Literal(true), messageId: Schema.String }); const decodeKiroSteerResult = Schema.decodeUnknownOption(KiroSteerResult); @@ -568,7 +580,7 @@ function makeKiroAcpAdapterFlavor(options: KiroAdapterV2Options): AcpAdapterV2Fl cancelAfterPromptStarts: { isStart: isKiroPromptActivity, bound: KIRO_CANCEL_HOLD_BOUND }, permissionDisposition: kiroPermissionDisposition, approvalOptions: kiroApprovalOptions, - compactSession: compactKiroSession, + compactSession: kiroCompactSession, steering: kiroSteering, forkFromTurn: kiroForkFromTurn, // Kiro V3 advertises `promptCapabilities.image`; the shared adapter reads it. diff --git a/packages/provider-kiro/src/server/status.ts b/packages/provider-kiro/src/server/status.ts index 6c1549e27e6e3..46f3361147fe3 100644 --- a/packages/provider-kiro/src/server/status.ts +++ b/packages/provider-kiro/src/server/status.ts @@ -90,7 +90,7 @@ const snapshot = ( enabled: settings.enabled, checkedAt, models: kiroModels(settings, listed, agents), - // `/compact` runs `_kiro/session/compact`; see compactKiroSession. + // `/compact` runs `_kiro/session/compact`; see kiroCompactSession. slashCommands: [COMPACT_SLASH_COMMAND], probe, }); From d20d535e32ca1d77f25b6562167aabb5af25bfbe Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 19:29:48 -0800 Subject: [PATCH 007/390] fix(server): show self-resumed Claude subagents as running (#23831) --- .../Adapters/ClaudeAdapterV2.test.ts | 202 ++++++++++++++++++ .../Adapters/ClaudeAdapterV2.ts | 49 +++-- 2 files changed, 232 insertions(+), 19 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 12372ded44b03..894c8af4472bb 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -16478,6 +16478,208 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + // Claude reports a background subagent `completed` when its turn ends while + // its own background shell still runs. When that shell finishes, Claude + // resumes the subagent with a new task_started for the same task and launch + // tool_use_id while the root is idle. Frame order mirrors a live capture. + it.effect("reopens a completed background subagent that resumes while the root is idle", () => + Effect.scoped( + Effect.gen(function* () { + const TASK_ID = "ae3f32459af762a5f"; + const LAUNCH = "toolu_01LcidmwEK1Y6GC6LpX3kvxX"; + const OWN_SHELL = "toolu_01QAahgijM2KRBBSkWxnui4g"; + const RESUMED_BASH = "toolu_01CkA1juicPNCtQrqKL8h6Rz"; + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + let sequence = 0; + const system = (frame: object) => + claudeSdkFrame({ + type: "system", + ...frame, + uuid: `00000000-0000-4000-8000-0000000162${String(++sequence).padStart(2, "0")}`, + session_id: WAKE_NATIVE_SESSION, + }); + const agentEntry = (runId: string) => ({ + task_id: TASK_ID, + run_id: runId, + task_type: "local_agent", + subagent_type: "general-purpose", + description: "Audit recent commits", + }); + const shellEntry = (taskId: string) => ({ + task_id: taskId, + run_id: `run-${taskId}`, + task_type: "local_bash", + description: "Sleep", + parent_task_id: TASK_ID, + }); + const roster = (tasks: ReadonlyArray) => + system({ subtype: "background_tasks_changed", tasks }); + const shellStarted = (taskId: string, toolUseId: string) => + system({ + subtype: "task_started", + task_id: taskId, + tool_use_id: toolUseId, + task_type: "local_bash", + description: "Sleep", + }); + const startTurn = (ordinal: number, continuation: boolean) => + harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make(`attempt-claude-self-resume-${ordinal}`), + text: continuation ? "Background task completed." : "Launch a background agent.", + attachments: [], + providerTurnOrdinal: ordinal, + ...(continuation + ? { + messageCreatedBy: "agent" as const, + messageCreationSource: "provider" as const, + } + : {}), + }), + ); + const subagentUpdates = () => + harness.events.flatMap((event) => + event.type === "subagent.updated" ? [event.subagent] : [], + ); + + yield* startTurn(1, false); + for (const frame of [ + roster([agentEntry("run-1")]), + makeSubagentTaskStartedFrame({ + taskId: TASK_ID, + toolUseId: LAUNCH, + uuid: "00000000-0000-4000-8000-000000016101", + }), + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000016102", result: "LAUNCHED" }), + ]) { + yield* harness.offerAndWait(frame); + } + yield* Queue.take(harness.terminalReceipts); + + // The subagent starts its own background shell, ends its turn, and + // Claude reports it completed; the parent wakes, answers, goes idle. + for (const frame of [ + ...makeSubagentAssistantFrames({ + parentToolUseId: LAUNCH, + uuid: "00000000-0000-4000-8000-000000016103", + bashToolUseId: OWN_SHELL, + }), + roster([agentEntry("run-1"), shellEntry("b455x9pq5")]), + shellStarted("b455x9pq5", OWN_SHELL), + makeSubagentToolResultFrame({ + parentToolUseId: LAUNCH, + uuid: "00000000-0000-4000-8000-000000016104", + toolUseId: OWN_SHELL, + }), + makeSubagentNotificationFrame({ + taskId: TASK_ID, + toolUseId: LAUNCH, + summary: "WAITING", + uuid: "00000000-0000-4000-8000-000000016105", + }), + roster([shellEntry("b455x9pq5")]), + wakeTurnInit, + ]) { + yield* harness.offerAndWait(frame); + } + yield* Queue.take(harness.continuationReceipts); + yield* startTurn(2, true); + yield* harness.offerAndWait( + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000016106", result: "Noted." }), + ); + yield* Queue.take(harness.terminalReceipts); + + // Its shell finishes and Claude resumes it under a new native run. + for (const frame of [ + system({ + subtype: "task_notification", + task_id: "b455x9pq5", + tool_use_id: OWN_SHELL, + status: "completed", + summary: "Background command completed (exit code 0)", + output_file: "/tmp/b455x9pq5.output", + }), + roster([]), + roster([agentEntry("run-2")]), + makeSubagentTaskStartedFrame({ + taskId: TASK_ID, + toolUseId: LAUNCH, + uuid: "00000000-0000-4000-8000-000000016107", + }), + ]) { + yield* harness.offerAndWait(frame); + } + // Events reach the app only through a run, and the launching run ended + // with the interim completion. + assert.equal(harness.continuationRequests.length, 2); + + yield* Queue.take(harness.continuationReceipts); + yield* startTurn(3, true); + // Claude does not wake the root for this, so the root must not stay busy. + assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "completed"); + yield* startTurn(4, false); + yield* harness.offerAndWait( + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000016110", result: "PONG" }), + ); + assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "completed"); + for (const frame of [ + ...makeSubagentAssistantFrames({ + parentToolUseId: LAUNCH, + uuid: "00000000-0000-4000-8000-000000016108", + bashToolUseId: RESUMED_BASH, + }), + shellStarted("beyhxkdpc", RESUMED_BASH), + roster([agentEntry("run-2"), shellEntry("beyhxkdpc")]), + ]) { + yield* harness.offerAndWait(frame); + } + // Events are ordered: once this roster is recorded, so is every earlier event. + yield* harness.until(() => + harness.events.some( + (event) => + event.type === "provider_thread.updated" && + event.providerThread.pendingBackgroundTasks?.some( + (task) => task.taskId === "beyhxkdpc", + ) === true, + ), + ); + const resumed = subagentUpdates().at(-1); + assert.equal(resumed?.status, "running"); + assert.isNull(resumed?.completedAt); + assert.equal(resumed?.runId, "run-attempt-claude-self-resume-3"); + const routing = subagentRouting(harness.events, [RESUMED_BASH]); + assert.deepEqual( + [...(routing.toolThreadIds.get(RESUMED_BASH) ?? [])], + [routing.childThreadId], + ); + + yield* harness.offerAndWait( + makeSubagentNotificationFrame({ + taskId: TASK_ID, + toolUseId: LAUNCH, + summary: "DONE", + uuid: "00000000-0000-4000-8000-000000016109", + }), + ); + yield* harness.until(() => subagentUpdates().at(-1)?.result === "DONE"); + assert.equal(subagentUpdates().at(-1)?.status, "completed"); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ), + ); + it.effect("keeps a nested subagent's launch in order while the root turn is idle", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 020b2feca2126..16a697a7e1353 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -9234,31 +9234,41 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( message.type === "system" && message.subtype === "task_started" && (yield* Ref.get(sessionSubagentsByTaskId)).has(message.task_id); + // A completed subagent that Claude resumes on its own (under its launch + // call, unlike SendMessage) has no live run left to show it while the + // root is idle; only a continuation does. + let isResumedSubagentTaskStarted = false; if ( isKnownSubagentTaskStarted && message.type === "system" && message.subtype === "task_started" ) { const now = yield* DateTime.now; - yield* Ref.update(sessionSubagentsByTaskId, (current) => { - const registered = current.get(message.task_id); - if (registered === undefined || registered.task.status === "running") { - return current; - } - const { progress: _staleProgress, ...priorTask } = registered.task; - return new Map(current).set(message.task_id, { - ...registered, - confirmedOutcome: undefined, - task: { - ...priorTask, - status: "running", - result: null, - startedAt: now, - completedAt: null, - updatedAt: now, - }, - }); - }); + isResumedSubagentTaskStarted = yield* Ref.modify( + sessionSubagentsByTaskId, + (current) => { + const registered = current.get(message.task_id); + if (registered === undefined || registered.task.status === "running") { + return [false, current] as const; + } + const { progress: _staleProgress, ...priorTask } = registered.task; + return [ + registered.runToolUseId === message.tool_use_id, + new Map(current).set(message.task_id, { + ...registered, + confirmedOutcome: undefined, + task: { + ...priorTask, + status: "running", + result: null, + startedAt: now, + completedAt: null, + updatedAt: now, + }, + }), + ] as const; + }, + ); } const isWakeTurnStart = isClaudeTurnStartMessage(message); const isWakeEvidence = @@ -9373,6 +9383,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( parentToolUseIdFromSdkMessage(message) === null; if ( !isPendingSubagentNotification && + !isResumedSubagentTaskStarted && !isNativeOpaqueWakeFrame && !isWakeTurnStart && message.type !== "result" From f999dc1018d987a8c73895f17b3c1275c592ea31 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 19:30:00 -0800 Subject: [PATCH 008/390] fix(web): preserve single tildes in sent user messages (#35859) --- .../chat/MessagesTimeline.browser.test.tsx | 19 +++++++++++++++++++ .../chat/TimelineUserMessageRow.tsx | 10 ++++++++-- packages/shared/src/markdownPipeline.ts | 6 ++++++ packages/shared/src/threadFindText.test.ts | 8 ++++++++ packages/shared/src/threadFindText.ts | 2 ++ 5 files changed, 43 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx index 0b33414e80dca..77da83deffe2b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx @@ -861,6 +861,25 @@ describe("MessagesTimeline messages", () => { expect((globalThis as { __t3Xss?: number }).__t3Xss).toBeUndefined(); }); + it("keeps single tildes in user paths literal without changing double tildes or assistant text", async () => { + const source = + "Install the skill as ~/folder-a/skills/x, then follow the guide (~/folder-a/guide.md)."; + await using screen = await renderTimeline([ + message(source), + message("Drop ~~the old plan~~", {}, "message-2"), + message(source, { role: "assistant" }, "assistant-1"), + ]); + const [user, doubleTilde, assistant] = screen.container.querySelectorAll( + "[data-timeline-row-kind=message]", + ); + expect(user?.textContent).toContain(source); + expect(user?.querySelector("del")).toBeNull(); + expect(doubleTilde?.querySelector("del")?.textContent).toBe("the old plan"); + expect(assistant?.querySelector("del")?.textContent).toBe( + "/folder-a/skills/x, then follow the guide (", + ); + }); + it("renders sanitized raw HTML in assistant messages", async () => { await using screen = await renderTimeline([ message( diff --git a/apps/web/src/components/chat/TimelineUserMessageRow.tsx b/apps/web/src/components/chat/TimelineUserMessageRow.tsx index ae55a3214da9e..c4593d2f6c5e1 100644 --- a/apps/web/src/components/chat/TimelineUserMessageRow.tsx +++ b/apps/web/src/components/chat/TimelineUserMessageRow.tsx @@ -1,6 +1,9 @@ import { InlineAudioPreview } from "../files/InlineAudioPreview"; import { filePreviewKind } from "@t3tools/shared/filePreview"; -import { remarkEnableOrderedListNumbers } from "@t3tools/shared/markdownPipeline"; +import { + CHAT_USER_MARKDOWN_REMARK_PLUGINS, + remarkEnableOrderedListNumbers, +} from "@t3tools/shared/markdownPipeline"; import { findRevealRef } from "./markdownFindContext"; import { type EnvironmentId, @@ -1200,7 +1203,10 @@ const CollapsibleUserMessageBody = memo(function CollapsibleUserMessageBody(prop ); }); -const USER_MESSAGE_REMARK_PLUGINS = [remarkEnableOrderedListNumbers]; +const USER_MESSAGE_REMARK_PLUGINS = [ + ...CHAT_USER_MARKDOWN_REMARK_PLUGINS, + remarkEnableOrderedListNumbers, +]; const UserMessageBody = memo(function UserMessageBody(props: { text: string; diff --git a/packages/shared/src/markdownPipeline.ts b/packages/shared/src/markdownPipeline.ts index e8042ee6d4696..a549045259e49 100644 --- a/packages/shared/src/markdownPipeline.ts +++ b/packages/shared/src/markdownPipeline.ts @@ -274,6 +274,12 @@ export const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS: PluggableList = [ remarkNormalizeLinksAndTagInlineCode, ]; +// Users type paths like ~/a and (~/b, which GFM's single-tilde strikethrough hides as . +// Appended after the base list, this reconfigures its remark-gfm entry; ~~double~~ still strikes. +export const CHAT_USER_MARKDOWN_REMARK_PLUGINS: PluggableList = [ + [remarkGfm, { singleTilde: false }], +]; + // Start tags an HTML parser closes an open `

` for. const CLOSES_PARAGRAPH = new Set( "address article aside blockquote center details dialog dir div dl fieldset figcaption figure footer form h1 h2 h3 h4 h5 h6 header hgroup hr li listing main menu nav ol p plaintext pre search section summary table ul xmp dd dt".split( diff --git a/packages/shared/src/threadFindText.test.ts b/packages/shared/src/threadFindText.test.ts index d1929afd1ea1d..3dabff0d8b4d0 100644 --- a/packages/shared/src/threadFindText.test.ts +++ b/packages/shared/src/threadFindText.test.ts @@ -21,6 +21,14 @@ it("excludes review attachments rendered as context chips", () => { ]); }); +it("indexes single tildes in user paths as typed", () => { + const text = "Install as ~/a/x, then read (~/a/guide.md)."; + expect(searchableMessageSegments({ role: "user", text, streaming: false })?.join("")).toBe(text); + expect( + searchableMessageSegments({ role: "user", text: "Drop ~~old~~", streaming: false })?.join(""), + ).toBe("Drop old"); +}); + it("excludes structured context chips without joining text across them", () => { expect( searchableMessageSegments({ diff --git a/packages/shared/src/threadFindText.ts b/packages/shared/src/threadFindText.ts index 871f14562f9e5..7c2b9006a16d8 100644 --- a/packages/shared/src/threadFindText.ts +++ b/packages/shared/src/threadFindText.ts @@ -37,6 +37,7 @@ import { CHAT_MARKDOWN_REHYPE_PLUGINS, CHAT_MARKDOWN_REMARK_PLUGINS, CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS, + CHAT_USER_MARKDOWN_REMARK_PLUGINS, } from "./markdownPipeline.ts"; // Inline wrappers (including Shiki token spans) must not split a search phrase. @@ -94,6 +95,7 @@ const insightBreaksProcessor = assistantTextProcessor(true, true); const userProcessor = unified() .use(remarkParse) .use(CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS) + .use(CHAT_USER_MARKDOWN_REMARK_PLUGINS) .use(remarkRehype, { allowDangerousHtml: true }); interface TextTree { From e079ad9a85946fb1a7ac32df99b1d951fe7777e5 Mon Sep 17 00:00:00 2001 From: voida6 Date: Fri, 13 Nov 2026 14:30:14 +1100 Subject: [PATCH 009/390] fix(desktop): clicking inside the in-app browser closes open host menus (#23141) Co-authored-by: Julius Marminge --- apps/desktop/src/preview/GuestProtocol.ts | 3 +++ apps/desktop/src/preview/PickPreload.ts | 4 ++++ apps/web/src/browser/HostedBrowserWebview.tsx | 11 ++++++++++- 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/preview/GuestProtocol.ts b/apps/desktop/src/preview/GuestProtocol.ts index 09d4eff6d2013..0a9f4cb85c008 100644 --- a/apps/desktop/src/preview/GuestProtocol.ts +++ b/apps/desktop/src/preview/GuestProtocol.ts @@ -6,6 +6,9 @@ export const ANNOTATION_THEME_CHANNEL = "preview:annotation-theme"; export const ANNOTATION_SEND_ENABLED_CHANNEL = "preview:annotation-send-enabled"; export const HUMAN_INPUT_CHANNEL = "preview:human-input"; export const MOUSE_NAVIGATE_CHANNEL = "preview:mouse-navigate"; +// Sent to the hosting element (ipc-message). The web app mirrors this +// literal in HostedBrowserWebview because it cannot import from the desktop app. +export const POINTER_DOWN_HOST_CHANNEL = "preview:pointer-down"; export const PAGE_UNHANDLED_SHORTCUT_CHANNEL = "preview:page-unhandled-shortcut"; export const RECORDING_CURSOR_CHANNEL = "preview:recording-cursor"; export const RECORDING_POINTER_CHANNEL = "preview:recording-pointer"; diff --git a/apps/desktop/src/preview/PickPreload.ts b/apps/desktop/src/preview/PickPreload.ts index 88a7fc467e425..cbaf873e49332 100644 --- a/apps/desktop/src/preview/PickPreload.ts +++ b/apps/desktop/src/preview/PickPreload.ts @@ -40,6 +40,7 @@ import { ELEMENT_PICKED_CHANNEL, HUMAN_INPUT_CHANNEL, MOUSE_NAVIGATE_CHANNEL, + POINTER_DOWN_HOST_CHANNEL, PAGE_UNHANDLED_SHORTCUT_CHANNEL, RECORDING_CURSOR_CHANNEL, RECORDING_POINTER_CHANNEL, @@ -193,6 +194,9 @@ const applyAnnotationTheme = ( const reportHumanPointerInput = (event: PointerEvent): void => { if (!event.isTrusted) return; + // The guest does not always take focus on a click, so the host's webview + // `focus` event can't be relied on to dismiss open menus and popovers. + ipcRenderer.sendToHost(POINTER_DOWN_HOST_CHANNEL); ipcRenderer.send(HUMAN_INPUT_CHANNEL, { kind: "pointer", x: event.clientX, diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index 44de1196b2b7c..28edc42d406a8 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -184,16 +184,24 @@ export function HostedBrowserWebview(props: { if (!disposed) remountCrashedWebview(); }, recovery.delayMs); }; - // Guest clicks only emit focus here; replay pointerdown to dismiss host popups. + // Guest clicks never reach this document, so replay a pointerdown to dismiss host popups. + // The guest preload reports each press, since the guest does not always take focus. const dismissHostPopups = () => { webview.dispatchEvent( new PointerEvent("pointerdown", { bubbles: true, pointerType: "mouse" }), ); }; + const guestPointerDown = (event: Event) => { + // Mirrors POINTER_DOWN_HOST_CHANNEL in apps/desktop/src/preview/GuestProtocol.ts. + if ((event as Event & { channel?: string }).channel === "preview:pointer-down") { + dismissHostPopups(); + } + }; webview.addEventListener("did-attach", register); webview.addEventListener("dom-ready", register); webview.addEventListener("render-process-gone", recoverGuest); webview.addEventListener("focus", dismissHostPopups); + webview.addEventListener("ipc-message", guestPointerDown); register(); return () => { disposed = true; @@ -202,6 +210,7 @@ export function HostedBrowserWebview(props: { webview.removeEventListener("dom-ready", register); webview.removeEventListener("render-process-gone", recoverGuest); webview.removeEventListener("focus", dismissHostPopups); + webview.removeEventListener("ipc-message", guestPointerDown); }; }, [ clientSettingsHydrated, From d6fc871b5875df01c1e4cc7c2c6cac5d5cb6e246 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Fri, 13 Nov 2026 09:00:26 +0530 Subject: [PATCH 010/390] fix(desktop): update browser tab titles without navigation (#29452) Co-authored-by: Julius Marminge --- .../preview/usePreviewBridge.test.ts | 39 ++++++++++++++++++- .../components/preview/usePreviewBridge.ts | 15 +++++-- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/preview/usePreviewBridge.test.ts b/apps/web/src/components/preview/usePreviewBridge.test.ts index 736ac7d743c32..be98eebcd91b1 100644 --- a/apps/web/src/components/preview/usePreviewBridge.test.ts +++ b/apps/web/src/components/preview/usePreviewBridge.test.ts @@ -1,9 +1,9 @@ -import type { DesktopPreviewTabState } from "@t3tools/contracts"; +import { ThreadId, type DesktopPreviewTabState } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore"; -import { createCaptureActivity, projectDesktopState } from "./usePreviewBridge"; +import { buildReportInput, createCaptureActivity, projectDesktopState } from "./usePreviewBridge"; const favicon = { dataUrl: "data:image/png;base64,AAAA", @@ -30,6 +30,41 @@ function state(navStatus: DesktopPreviewTabState["navStatus"]): DesktopPreviewTa }; } +describe("buildReportInput", () => { + it("reports title changes at the same URL without repeating unrelated desktop updates", () => { + const threadId = ThreadId.make("thread-1"); + const tabId = "tab-1"; + const url = "http://localhost:3000/app"; + let lastReport = { + lastReportedUrl: null as string | null, + lastReportedKind: null as DesktopPreviewTabState["navStatus"]["kind"] | null, + lastReportedTitle: null as string | null, + }; + const report = (nextState: DesktopPreviewTabState) => { + const result = buildReportInput({ threadId, tabId, state: nextState, ...lastReport }); + if (result) { + lastReport = result; + } + return result?.input.navStatus ?? null; + }; + + const loaded = state({ kind: "Success", url, title: "Original title" }); + expect(report(loaded)).toEqual({ _tag: "Success", url, title: "Original title" }); + expect(report(loaded)).toBeNull(); + + const retitled = state({ kind: "Success", url, title: "Updated title" }); + expect(report(retitled)).toEqual({ _tag: "Success", url, title: "Updated title" }); + expect( + report({ + ...retitled, + audible: true, + controller: "human", + updatedAt: "2026-08-09T00:00:01.000Z", + }), + ).toBeNull(); + }); +}); + describe("projectDesktopState", () => { it("shows a retained icon only while the current document has the captured origin", () => { expect( diff --git a/apps/web/src/components/preview/usePreviewBridge.ts b/apps/web/src/components/preview/usePreviewBridge.ts index 92bc9b29b35c3..6973f22f7dd3b 100644 --- a/apps/web/src/components/preview/usePreviewBridge.ts +++ b/apps/web/src/components/preview/usePreviewBridge.ts @@ -54,6 +54,7 @@ export function usePreviewBridge(input: { const lastReportedUrl = useRef(null); const lastReportedKind = useRef(null); + const lastReportedTitle = useRef(null); const lastDesktopNavStatus = useRef(null); const captureActivity = useRef | null>(null); const handleStateChange = useEffectEvent( @@ -74,10 +75,12 @@ export function usePreviewBridge(input: { state, lastReportedUrl: lastReportedUrl.current, lastReportedKind: lastReportedKind.current, + lastReportedTitle: lastReportedTitle.current, }); if (!reported || serverDriven) return; lastReportedUrl.current = reported.lastReportedUrl; lastReportedKind.current = reported.lastReportedKind; + lastReportedTitle.current = reported.lastReportedTitle; void reportStatus({ environmentId: stableThreadRef.environmentId, input: reported.input, @@ -89,6 +92,7 @@ export function usePreviewBridge(input: { if (!previewBridge || typeof window === "undefined") return; lastReportedUrl.current = null; lastReportedKind.current = null; + lastReportedTitle.current = null; lastDesktopNavStatus.current = null; const activity = createCaptureActivity(runtimeTabId); captureActivity.current = activity; @@ -134,25 +138,28 @@ export function projectDesktopState(state: DesktopPreviewTabState): DesktopPrevi }; } -function buildReportInput(args: { +export function buildReportInput(args: { readonly threadId: ThreadId; readonly tabId: string; readonly state: DesktopPreviewTabState; readonly lastReportedUrl: string | null; readonly lastReportedKind: DesktopPreviewTabState["navStatus"]["kind"] | null; + readonly lastReportedTitle: string | null; }): { readonly input: PreviewReportStatusInput; readonly lastReportedUrl: string; readonly lastReportedKind: DesktopPreviewTabState["navStatus"]["kind"]; + readonly lastReportedTitle: string; } | null { - const { threadId, tabId, state, lastReportedUrl, lastReportedKind } = args; + const { threadId, tabId, state, lastReportedUrl, lastReportedKind, lastReportedTitle } = args; const status = state.navStatus; if (status.kind === "Idle") return null; const sameAsLast = status.kind !== "LoadFailed" && status.kind === lastReportedKind && - status.url === lastReportedUrl; + status.url === lastReportedUrl && + status.title === lastReportedTitle; if (sameAsLast) return null; const base = { @@ -175,6 +182,7 @@ function buildReportInput(args: { }, lastReportedUrl: status.url, lastReportedKind: "LoadFailed", + lastReportedTitle: status.title, }; } return { @@ -184,5 +192,6 @@ function buildReportInput(args: { }, lastReportedUrl: status.url, lastReportedKind: status.kind, + lastReportedTitle: status.title, }; } From d7a0b5371fda4e5a3e4c02c3e6eceec3c594b5eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ilkka=20Lappetel=C3=A4inen?= <38039015+pnupu@users.noreply.github.com> Date: Fri, 13 Nov 2026 05:30:39 +0200 Subject: [PATCH 011/390] fix(web,mobile): triage follows the draft when you switch machines (#33782) Co-authored-by: Julius Marminge --- .../threads/new-task-flow-provider.test.tsx | 84 ++++++++++++++++++- .../threads/new-task-flow-provider.tsx | 34 +++++++- .../src/features/triage/usePrepareTriage.ts | 19 +++++ .../src/features/triage/useStartTriage.ts | 11 +-- .../src/components/chat/DraftHeroHeadline.tsx | 2 +- .../chat/useDraftEnvironmentRouting.ts | 71 ++++++++++++---- apps/web/src/hooks/useStartTriage.ts | 5 +- docs/user/triage.md | 3 +- packages/shared/package.json | 4 + packages/shared/src/triagePrompt.test.ts | 54 ++++++++++++ packages/shared/src/triagePrompt.ts | 33 ++++++++ 11 files changed, 287 insertions(+), 33 deletions(-) create mode 100644 apps/mobile/src/features/triage/usePrepareTriage.ts create mode 100644 packages/shared/src/triagePrompt.test.ts create mode 100644 packages/shared/src/triagePrompt.ts diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.test.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.test.tsx index 3b91dbb122ffd..05ccf29b2ae70 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.test.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.test.tsx @@ -4,6 +4,7 @@ import { EnvironmentRegistry } from "@t3tools/client-runtime/connection"; import { createEnvironmentSubscriptionAtomFamily } from "@t3tools/client-runtime/state/runtime"; import { AuthFilesystemReadScope, + DEFAULT_SERVER_SETTINGS, EnvironmentId, ProjectId, type VcsListRefsResult, @@ -32,6 +33,9 @@ const mocks = vi.hoisted(() => ({ retargetDraft: vi.fn(), writeSettings: vi.fn(), writeText: vi.fn(), + serverConfig: vi.fn(), + openScratch: vi.fn(), + prepareTriage: vi.fn(), })); vi.mock("../../state/atom-registry", () => ({ appAtomRegistry: { @@ -43,7 +47,7 @@ vi.mock("../../state/atom-registry", () => ({ vi.mock("../../state/entities", () => ({ useProjects: mocks.projects, useThreadShells: () => [], - useEnvironmentServerConfig: () => null, + useEnvironmentServerConfig: () => mocks.serverConfig() ?? null, useServerConfigs: () => new Map(), })); vi.mock("../../state/vcs", () => ({ @@ -66,7 +70,8 @@ vi.mock("../../state/project-grouping", () => ({ useMobileProjectGroupingSettings: () => ({ sidebarProjectGroupingMode: "none" }), })); vi.mock("../../components/ui/Dialog", () => ({ alert: vi.fn() })); -vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); +vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => mocks.openScratch })); +vi.mock("../triage/usePrepareTriage", () => ({ usePrepareTriage: () => mocks.prepareTriage })); vi.mock("../../state/use-remote-environment-registry", () => ({ useSavedRemoteConnections: () => ({ savedConnectionsById: {} }), useRemoteConnectionStatus: () => ({ connectedEnvironments: [] }), @@ -362,3 +367,78 @@ it("uses cached repository capability and branch defaults while offline", async expect(screen.current.draftKey).toBe(draftKey); expect(screen.current.selectedBranchName).toBe("main"); }); + +const playbookPrompt = (machine: string) => + `Read [triage-playbook.md](/${machine}/triage-playbook.md) and follow it. `; + +/** A triage draft on machine one's No project home; machine two opens its own. */ +async function renderTriageDraft(screen: ReturnType) { + mocks.serverConfig.mockReturnValue({ + scratchWorkspaceRoot: "/a", + providers: [], + settings: DEFAULT_SERVER_SETTINGS, + }); + mocks.openScratch.mockResolvedValue({ _tag: "Success", value: screen.projects[2] }); + await screen.render(true); + act(() => screen.current.setPrompt(`${playbookPrompt("one")}It is slow.`)); +} + +it("takes a triage draft's session along when its machine changes", async () => { + using screen = setup(); + await renderTriageDraft(screen); + mocks.prepareTriage.mockResolvedValue({ + _tag: "Success", + value: { prompt: playbookPrompt("two") }, + }); + + await act(async () => { + await screen.current.switchEnvironment(EnvironmentId.make("two")); + }); + + expect(screen.current.prompt).toBe(`${playbookPrompt("two")}It is slow.`); + expect(screen.current.selectedProject).toEqual(screen.projects[2]); +}); + +it.each([ + [ + "a project is picked", + 1, + (flow: ReturnType, picked: EnvironmentProject) => + flow.setProject(picked), + ], + [ + "a send starts and ends", + 0, + (flow: ReturnType) => { + flow.setSubmitting(true); + flow.setSubmitting(false); + }, + ], +] as const)( + "leaves a triage draft alone when %s while its new machine prepares", + async (_name, expectedProject, interrupt) => { + using screen = setup(); + await renderTriageDraft(screen); + let finishPreparing!: (result: unknown) => void; + mocks.prepareTriage.mockReturnValue( + new Promise((resolve) => { + finishPreparing = resolve; + }), + ); + + let switching!: Promise; + act(() => { + switching = screen.current.switchEnvironment(EnvironmentId.make("two")); + }); + await act(async () => interrupt(screen.current, screen.projects[1]!)); + let switched = true; + await act(async () => { + finishPreparing({ _tag: "Success", value: { prompt: playbookPrompt("two") } }); + switched = await switching; + }); + + expect(switched).toBe(false); + expect(screen.current.prompt).toBe(`${playbookPrompt("one")}It is slow.`); + expect(screen.current.selectedProject).toEqual(screen.projects[expectedProject]); + }, +); diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 5a8d7a1aa78f4..d78dd73b18402 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -19,6 +19,7 @@ import { ThreadId, } from "@t3tools/contracts"; import { threadContextsOutsideEnvironment } from "@t3tools/shared/composerContextReferences"; +import { isTriagePrompt, replaceTriagePlaybookLink } from "@t3tools/shared/triagePrompt"; import * as Arr from "effect/Array"; import { pipe } from "effect/Function"; @@ -102,6 +103,7 @@ import { stampedEnvironmentProjectsLoading, updateNewTaskFlowState, } from "./new-task-flow-state"; +import { usePrepareTriage } from "../triage/usePrepareTriage"; import { useEnvironmentShellReadiness } from "../../state/shell"; import { useConnectionsReady, useWorkspaceEnvironments } from "../../state/workspace"; @@ -589,6 +591,7 @@ export function NewTaskFlowProvider( }; const openScratch = useAtomCommand(projectEnvironment.openScratch, { reportFailure: false }); + const prepareTriage = usePrepareTriage(); const [switchingToEnvironmentId, setSwitchingToEnvironmentId] = useState( null, ); @@ -606,11 +609,27 @@ export function NewTaskFlowProvider( const request = {}; latestSwitchRef.current = request; setSwitchingToEnvironmentId(environmentId); - const result = await openScratch({ environmentId, input: {} }); + // A triage draft takes its session along: the playbook and context files + // exist only on the machine that prepared them. + const { draftKey, projectKey } = readFlow(); + const triage = + draftKey !== null && isTriagePrompt(getComposerDraftSnapshot(draftKey).text) + ? await prepareTriage(environmentId) + : null; + const result = + triage?._tag === "Failure" ? triage : await openScratch({ environmentId, input: {} }); if (latestSwitchRef.current !== request) return false; latestSwitchRef.current = null; setSwitchingToEnvironmentId(null); + // A project picked while this waited stands. + if (readFlow().projectKey !== projectKey) return false; if (result._tag === "Success") { + if (triage?._tag === "Success" && draftKey !== null && readFlow().draftKey === draftKey) { + // Read now, so text typed while the machine prepared the session is kept. + const text = getComposerDraftSnapshot(draftKey).text; + const nextText = replaceTriagePlaybookLink(text, triage.value.prompt); + if (nextText !== text) setComposerDraftText(draftKey, nextText); + } setProject(result.value); return true; } @@ -620,7 +639,9 @@ export function NewTaskFlowProvider( "Could not switch machine", error instanceof Error ? error.message - : "The folder for threads without a project could not be created.", + : triage?._tag === "Failure" + ? "The server could not prepare triage." + : "The folder for threads without a project could not be created.", ); } return false; @@ -884,7 +905,14 @@ export function NewTaskFlowProvider( appendAttachments, removeAttachment, clearAttachments, - setSubmitting: (value: boolean) => updateFlow({ submitting: value }), + setSubmitting: (value: boolean) => { + // A send takes the draft as it is, so a machine switch still waiting is dropped. + if (value) { + latestSwitchRef.current = null; + setSwitchingToEnvironmentId(null); + } + updateFlow({ submitting: value }); + }, loadBranches, setRuntimeMode, setSelectedModelOptions, diff --git a/apps/mobile/src/features/triage/usePrepareTriage.ts b/apps/mobile/src/features/triage/usePrepareTriage.ts new file mode 100644 index 0000000000000..94194275b2cc6 --- /dev/null +++ b/apps/mobile/src/features/triage/usePrepareTriage.ts @@ -0,0 +1,19 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import Constants from "expo-constants"; + +import { authClientMetadata } from "../../lib/authClientMetadata"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; + +/** + * Asks a machine to prepare a triage session. The result carries the prompt + * that links its playbook; the caller reports a failure. + */ +export function usePrepareTriage() { + const prepare = useAtomCommand(serverEnvironment.prepareTriage, { reportFailure: false }); + return (environmentId: EnvironmentId) => + prepare({ + environmentId, + input: { client: authClientMetadata(Constants.expoConfig?.version) }, + }); +} diff --git a/apps/mobile/src/features/triage/useStartTriage.ts b/apps/mobile/src/features/triage/useStartTriage.ts index eb9eef24b2670..7f8851e39a4ed 100644 --- a/apps/mobile/src/features/triage/useStartTriage.ts +++ b/apps/mobile/src/features/triage/useStartTriage.ts @@ -6,12 +6,9 @@ import { import type { EnvironmentId } from "@t3tools/contracts"; import { useNavigation } from "@react-navigation/native"; import * as Clipboard from "expo-clipboard"; -import Constants from "expo-constants"; import * as Dialog from "../../components/ui/Dialog"; -import { authClientMetadata } from "../../lib/authClientMetadata"; import { projectEnvironment } from "../../state/projects"; -import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; import { createNewTaskDraft, @@ -19,6 +16,7 @@ import { waitForComposerDraftsLoaded, } from "../../state/use-composer-drafts"; import { useSelectWorkspaceDetail } from "../layout/workspace/useSelectWorkspaceDetail"; +import { usePrepareTriage } from "./usePrepareTriage"; const CLI_COMMAND = "npx t3 triage"; @@ -29,7 +27,7 @@ const CLI_COMMAND = "npx t3 triage"; export function useStartTriage() { const navigation = useNavigation(); const selectDetail = useSelectWorkspaceDetail(); - const prepare = useAtomCommand(serverEnvironment.prepareTriage, { reportFailure: false }); + const prepare = usePrepareTriage(); const openScratch = useAtomCommand(projectEnvironment.openScratch, { reportFailure: false }); const fail = (message: string) => @@ -77,10 +75,7 @@ export function useStartTriage() { } return; } - const prepared = await prepare({ - environmentId, - input: { client: authClientMetadata(Constants.expoConfig?.version) }, - }); + const prepared = await prepare(environmentId); if (prepared._tag === "Failure") { if (!isAtomCommandInterrupted(prepared)) { pipe( diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index 91bf642fcb32f..e621f491527da 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -14,7 +14,7 @@ import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; import { useOrderWhileOpen } from "~/hooks/useOrderWhileOpen"; import { useProjectPin } from "~/hooks/useProjectGroupUpdate"; import { useScratchProject } from "~/hooks/useScratchProject"; -import { isTriagePrompt } from "~/hooks/useStartTriage"; +import { isTriagePrompt } from "@t3tools/shared/triagePrompt"; import { useClientSettings } from "~/hooks/useSettings"; import { hasExplicitComposerModelSelection } from "~/lib/chatThreadActions"; import { diff --git a/apps/web/src/components/chat/useDraftEnvironmentRouting.ts b/apps/web/src/components/chat/useDraftEnvironmentRouting.ts index b59408a35a7cb..3f67e8c9c7987 100644 --- a/apps/web/src/components/chat/useDraftEnvironmentRouting.ts +++ b/apps/web/src/components/chat/useDraftEnvironmentRouting.ts @@ -14,16 +14,24 @@ import { scopeProjectRef, scopeThreadRef, } from "@t3tools/client-runtime/environment"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { isTriagePrompt, replaceTriagePlaybookLink } from "@t3tools/shared/triagePrompt"; import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { flushSync } from "react-dom"; -import { toastManager } from "../ui/toast"; +import { stackedThreadToast, toastManager } from "../ui/toast"; import { deriveLogicalProjectKeyFromSettings, selectProjectGroupingSettings, } from "@t3tools/client-runtime/state/project-grouping"; import { useComposerDraftStore, DraftId } from "../../composerDraftStore"; import { type EnvironmentPresentation, useEnvironmentProviders } from "../../state/environments"; +import { currentClientMetadata } from "../../connection/platform"; import { useProjects } from "../../state/entities"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; import type { DraftSessionState } from "../../composerDraftStore"; import { loadBalancingSendWarning } from "./composerSend.logic"; import { appAtomRegistry } from "../../rpc/atomRegistry"; @@ -53,7 +61,8 @@ interface DraftEnvironmentRoutingInput { /** * Which machine an unsent draft runs on: automatic load balancing across the * project's machines, or a manual switch, which may first open a scratch - * project on the target machine. + * project on the target machine. A triage draft takes its session along: the + * target machine prepares one, and the playbook link follows. */ export function useDraftEnvironmentRouting(input: DraftEnvironmentRoutingInput) { const { @@ -76,6 +85,9 @@ export function useDraftEnvironmentRouting(input: DraftEnvironmentRoutingInput) } = input; const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const getDraftSession = useComposerDraftStore((store) => store.getDraftSession); + const getComposerDraft = useComposerDraftStore((store) => store.getComposerDraft); + const setPrompt = useComposerDraftStore((store) => store.setPrompt); + const prepareTriage = useAtomCommand(serverEnvironment.prepareTriage, { reportFailure: false }); const setLogicalProjectDraftThreadId = useComposerDraftStore( (store) => store.setLogicalProjectDraftThreadId, ); @@ -284,7 +296,8 @@ export function useDraftEnvironmentRouting(input: DraftEnvironmentRoutingInput) const request = { environmentId: target.environmentId, scope: environmentChangeScope }; environmentChangeRef.current = request; setBusyEnvironmentChange(null); - const retarget = (project: (typeof allProjects)[number]) => { + // `triagePrompt` is the prompt the target machine prepared for a triage draft. + const retarget = (project: (typeof allProjects)[number], triagePrompt?: string) => { const currentDraft = getDraftSession(draftId); if ( environmentChangeRef.current !== request || @@ -296,6 +309,12 @@ export function useDraftEnvironmentRouting(input: DraftEnvironmentRoutingInput) currentDraft.projectId !== originalDraft.projectId ) return; + if (triagePrompt !== undefined) { + // Read now, so text typed while the machine prepared the session is kept. + const prompt = getComposerDraft(draftId)?.prompt ?? ""; + const nextPrompt = replaceTriagePlaybookLink(prompt, triagePrompt); + if (nextPrompt !== prompt) setPrompt(draftId, nextPrompt); + } const projectRef = scopeProjectRef(target.environmentId, project.id); if (activeProjectIsScratch) { setLogicalProjectDraftThreadId( @@ -316,21 +335,45 @@ export function useDraftEnvironmentRouting(input: DraftEnvironmentRoutingInput) if (environmentChangeRef.current === request) environmentChangeRef.current = null; setBusyEnvironmentChange((busy) => (busy === request ? null : busy)); }; - if (target.projectId !== null) { - const project = allProjects.find( - (project) => - project.environmentId === target.environmentId && project.id === target.projectId, - ); - if (project) retarget(project); + const knownProject = allProjects.find( + (project) => + project.environmentId === target.environmentId && project.id === target.projectId, + ); + // The playbook and context files exist only on the machine that prepared them. + const followsTriage = isTriagePrompt(getComposerDraft(draftId)?.prompt ?? ""); + if (target.projectId !== null && !(knownProject && followsTriage)) { + if (knownProject) retarget(knownProject); finish(); return; } + const prepareTriagePrompt = async () => { + const prepared = await prepareTriage({ + environmentId: target.environmentId, + input: { client: currentClientMetadata() }, + }); + if (prepared._tag === "Success") return prepared.value.prompt; + if (!isAtomCommandInterrupted(prepared)) { + const error = squashAtomCommandFailure(prepared); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not switch machine", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + return null; + }; + const switchMachine = async () => { + const triagePrompt = followsTriage ? await prepareTriagePrompt() : undefined; + if (triagePrompt === null) return; + const project = + knownProject ?? + (await openScratchProject(target.environmentId, "Could not switch machine")); + if (project) retarget(project, triagePrompt); + }; setBusyEnvironmentChange(request); - void openScratchProject(target.environmentId, "Could not switch machine") - .then((project) => { - if (project) retarget(project); - }) - .finally(finish); + void switchMachine().finally(finish); }; const onEnvironmentChangeStable = useStableCallback(onEnvironmentChange); diff --git a/apps/web/src/hooks/useStartTriage.ts b/apps/web/src/hooks/useStartTriage.ts index 7d6671bf3d22c..6ddc3904afdf1 100644 --- a/apps/web/src/hooks/useStartTriage.ts +++ b/apps/web/src/hooks/useStartTriage.ts @@ -4,7 +4,7 @@ import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import { type EnvironmentId, TRIAGE_PROMPT_FILE_NAME } from "@t3tools/contracts"; +import type { EnvironmentId } from "@t3tools/contracts"; import { useRouter } from "@tanstack/react-router"; import { stackedThreadToast, toastManager } from "~/components/ui/toast"; @@ -26,9 +26,6 @@ const copyTriageCliCommand = () => }), ); -/** A draft is a triage draft while its message still links the playbook file. */ -export const isTriagePrompt = (prompt: string) => prompt.includes(`[${TRIAGE_PROMPT_FILE_NAME}](`); - /** * In-app `t3 triage`: the server writes the session files and starts cloning * the matching source, then a No project draft opens with the playbook linked. diff --git a/docs/user/triage.md b/docs/user/triage.md index c66bac0738e28..6e159cc7a871e 100644 --- a/docs/user/triage.md +++ b/docs/user/triage.md @@ -15,7 +15,8 @@ source for the server's version while you write. Triage runs on the machine of the thread or draft that you have open. With nothing open, it uses your primary machine if that supports triage, or else the first connected machine that does. The new thread shows the machine name, and on -mobile the palette row shows it before you start. The agent runs inside that T3 +mobile the palette row shows it before you start. If you change the machine +before you send, triage moves to that machine. The agent runs inside that T3 Code server, so it gives you any restart steps and does not do them itself. ## In a terminal diff --git a/packages/shared/package.json b/packages/shared/package.json index 9df772f66d238..ba0b394f26d0c 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -267,6 +267,10 @@ "types": "./src/composerTrigger.ts", "import": "./src/composerTrigger.ts" }, + "./triagePrompt": { + "types": "./src/triagePrompt.ts", + "import": "./src/triagePrompt.ts" + }, "./pullRequestComposerTrigger": { "types": "./src/pullRequestComposerTrigger.ts", "import": "./src/pullRequestComposerTrigger.ts" diff --git a/packages/shared/src/triagePrompt.test.ts b/packages/shared/src/triagePrompt.test.ts new file mode 100644 index 0000000000000..e685baaada60c --- /dev/null +++ b/packages/shared/src/triagePrompt.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { serializeComposerFileLink } from "./composerTrigger.ts"; +import { isTriagePrompt, replaceTriagePlaybookLink } from "./triagePrompt.ts"; + +const appPrompt = (playbookPath: string) => + `Read ${serializeComposerFileLink(playbookPath)} and follow it. `; + +const MACHINE_A = "/Users/a/.t3/triage/1/triage-playbook.md"; +const MACHINE_B = "/home/b/.t3/triage/2/triage-playbook.md"; + +describe("replaceTriagePlaybookLink", () => { + it("replaces only the playbook link and keeps the text the user typed", () => { + const other = serializeComposerFileLink("/Users/a/notes (old)/log.md"); + const prompt = `Before. ${appPrompt(MACHINE_A)}The app crashes, see ${other} (twice).`; + + expect(replaceTriagePlaybookLink(prompt, appPrompt(MACHINE_B))).toBe( + `Before. ${appPrompt(MACHINE_B)}The app crashes, see ${other} (twice).`, + ); + }); + + it.each([ + ["spaces", "/Users/a b/My Data/triage-playbook.md"], + ["parentheses", "/Users/a/t3 (dev)/triage-playbook.md"], + ["hash and question mark", "/Users/a/#1/what?/triage-playbook.md"], + ["a Windows path", "C:\\Users\\a b\\t3 (dev)\\triage-playbook.md"], + ])("replaces a link whose path has %s", (_name, path) => { + expect(replaceTriagePlaybookLink(`${appPrompt(path)}It is slow.`, appPrompt(MACHINE_B))).toBe( + `${appPrompt(MACHINE_B)}It is slow.`, + ); + expect(replaceTriagePlaybookLink(`${appPrompt(MACHINE_A)}It is slow.`, appPrompt(path))).toBe( + `${appPrompt(path)}It is slow.`, + ); + }); + + it("replaces a link at the end of the prompt", () => { + const link = serializeComposerFileLink(MACHINE_A); + expect(replaceTriagePlaybookLink(`Follow ${link}`, appPrompt(MACHINE_B))).toBe( + `Follow ${serializeComposerFileLink(MACHINE_B)}`, + ); + }); + + it("returns the prompt unchanged when it has no playbook link", () => { + const prompt = `The app crashes, see ${serializeComposerFileLink("/Users/a/log.md")} please.`; + expect(isTriagePrompt(prompt)).toBe(false); + expect(replaceTriagePlaybookLink(prompt, appPrompt(MACHINE_B))).toBe(prompt); + }); + + it("returns the prompt unchanged when the prepared prompt has no playbook link", () => { + expect(replaceTriagePlaybookLink(appPrompt(MACHINE_A), "Read the playbook.")).toBe( + appPrompt(MACHINE_A), + ); + }); +}); diff --git a/packages/shared/src/triagePrompt.ts b/packages/shared/src/triagePrompt.ts new file mode 100644 index 0000000000000..0aed545ea3490 --- /dev/null +++ b/packages/shared/src/triagePrompt.ts @@ -0,0 +1,33 @@ +import { TRIAGE_PROMPT_FILE_NAME } from "@t3tools/contracts"; + +const PLAYBOOK_LINK_START = `[${TRIAGE_PROMPT_FILE_NAME}](`; + +/** A draft is a triage draft while its message still links the playbook file. */ +export const isTriagePrompt = (prompt: string) => prompt.includes(PLAYBOOK_LINK_START); + +// `serializeComposerFileLink` encodes `)` and whitespace, so the first `)` closes the link. +const playbookLinkEnd = (text: string, start: number) => { + const close = text.indexOf(")", start + PLAYBOOK_LINK_START.length); + return close === -1 ? null : close + 1; +}; + +/** + * Moves a triage draft to the session another machine prepared: each playbook + * link in `prompt` becomes the link in `preparedPrompt`, and the rest of the + * text stays. A prompt without the link comes back unchanged. + */ +export function replaceTriagePlaybookLink(prompt: string, preparedPrompt: string): string { + const preparedStart = preparedPrompt.indexOf(PLAYBOOK_LINK_START); + const preparedEnd = preparedStart === -1 ? null : playbookLinkEnd(preparedPrompt, preparedStart); + if (preparedEnd === null) return prompt; + const link = preparedPrompt.slice(preparedStart, preparedEnd); + let next = ""; + let cursor = 0; + while (true) { + const start = prompt.indexOf(PLAYBOOK_LINK_START, cursor); + const end = start === -1 ? null : playbookLinkEnd(prompt, start); + if (end === null) return next + prompt.slice(cursor); + next += prompt.slice(cursor, start) + link; + cursor = end; + } +} From a000973ccb852ad4ab63aaf16b5bf7b226ac9eb2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 19:30:52 -0800 Subject: [PATCH 012/390] fix(server): storage cleanup treats symlinked spellings of a worktree as one checkout (#25735) --- apps/server/src/storageCleanup.test.ts | 85 +++++++++++++++++-- apps/server/src/storageCleanup.ts | 111 ++++++++++++++++--------- 2 files changed, 152 insertions(+), 44 deletions(-) diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index 1bbaa450636e2..03f35f0a93cde 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -1212,6 +1212,52 @@ describe("subagent worktree ownership", () => { }).pipe(Effect.provide(cleanupTestLayer), Effect.scoped), ); + it.effect("keeps a checkout two top-level threads store through a symlinked parent", () => + Effect.gen(function* () { + yield* TestClock.setTime(NOW_MS); + const fixture = yield* cleanupFixture({ + ...DEFAULT_SERVER_SETTINGS.storageCleanup, + worktreeSettledAfterDays: 8, + }); + const linkedParent = `${yield* fixture.fs.makeTempDirectoryScoped({ prefix: "cleanup-link-" })}/worktrees`; + yield* fixture.fs.symlink(fixture.config.worktreesDir, linkedParent); + const settled = { branch: "feature", settledAt: at(-20 * DAY_MS) }; + fixture.setThreads([ + shell({ ...settled, worktreePath: fixture.worktree }), + shell({ ...settled, id: ThreadId.make("other"), worktreePath: `${linkedParent}/feature` }), + ]); + const report = yield* fixture.service.runNow; + expect(report.entries).toEqual([ + expect.objectContaining({ outcome: "kept", reason: "Shared by 2 threads" }), + ]); + expect(yield* fixture.fs.exists(fixture.worktree)).toBe(true); + }).pipe(Effect.provide(cleanupTestLayer), Effect.scoped), + ); + + it.effect("keeps a checkout two top-level threads store in different case on Windows", () => + Effect.gen(function* () { + yield* TestClock.setTime(NOW_MS); + const fixture = yield* cleanupFixture({ + ...DEFAULT_SERVER_SETTINGS.storageCleanup, + worktreeSettledAfterDays: 8, + }).pipe(Effect.provideService(HostProcess.Platform, "win32")); + const settled = { branch: "feature", settledAt: at(-20 * DAY_MS) }; + fixture.setThreads([ + shell({ ...settled, worktreePath: fixture.worktree }), + shell({ + ...settled, + id: ThreadId.make("other"), + worktreePath: fixture.worktree.toUpperCase(), + }), + ]); + const report = yield* fixture.service.runNow; + expect(report.entries).toEqual([ + expect.objectContaining({ outcome: "kept", reason: "Shared by 2 threads" }), + ]); + expect(yield* fixture.fs.exists(fixture.worktree)).toBe(true); + }).pipe(Effect.provide(cleanupTestLayer), Effect.scoped), + ); + it.live.each([ "running", "pending", @@ -2299,18 +2345,30 @@ describe("storage cleanup reports and local file policies", () => { }), ), ); - it.live.each(["before-check", "while-measuring"] as const)( - "keeps a worktree protected by a missing project root (%s)", - (timing) => + it.live.each([ + ["before-check", "stored"], + ["while-measuring", "stored"], + ["before-check", "symlinked parent"], + ["while-measuring", "symlinked parent"], + ] as const)( + "keeps a worktree protected by a missing project root (%s, %s spelling)", + ([timing, spelling]) => runCleanupTest( Effect.gen(function* () { const fixture = yield* pausedMeasurementFixture(); + let worktree = fixture.worktree; + if (spelling === "symlinked parent") { + const linkedParent = `${yield* fixture.fs.makeTempDirectoryScoped({ prefix: "cleanup-link-" })}/worktrees`; + yield* fixture.fs.symlink(fixture.config.worktreesDir, linkedParent); + worktree = `${linkedParent}/feature`; + fixture.setThreads([shell({ branch: "feature", worktreePath: worktree })]); + } if (timing === "before-check") - fixture.setProjectRoots([fixture.repo, `${fixture.worktree}/removed-checkout`]); + fixture.setProjectRoots([fixture.repo, `${worktree}/removed-checkout`]); const cleanup = yield* fixture.service.runNow.pipe(Effect.forkChild); if (timing === "while-measuring") { yield* Deferred.await(fixture.measuring); - fixture.setProjectRoots([fixture.repo, `${fixture.worktree}/removed-checkout`]); + fixture.setProjectRoots([fixture.repo, `${worktree}/removed-checkout`]); } yield* Deferred.succeed(fixture.resume, undefined); const report = yield* Fiber.join(cleanup); @@ -2615,6 +2673,23 @@ describe("storage cleanup reports and local file policies", () => { }), ), ); + it.live("keeps a worktree for a live session in a missing folder under its stored alias", () => + runCleanupTest( + Effect.gen(function* () { + const fixture = yield* cleanupFixture(); + const linkedParent = `${yield* fixture.fs.makeTempDirectoryScoped({ prefix: "cleanup-link-" })}/worktrees`; + yield* fixture.fs.symlink(fixture.config.worktreesDir, linkedParent); + fixture.setThreads([shell({ branch: "feature", worktreePath: `${linkedParent}/feature` })]); + fixture.setOpenSessionCwd(`${linkedParent}/feature/removed-folder`); + const report = yield* fixture.service.runNow; + expect(report.entries[0]).toMatchObject({ + outcome: "kept", + reason: "Provider session is still open", + }); + expect(yield* fixture.fs.exists(fixture.worktree)).toBe(true); + }), + ), + ); it.live("compares the checked-out branch without a full status", () => runCleanupTest( Effect.gen(function* () { diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index 6c2935d1c59d8..11f9a91787244 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -346,20 +346,6 @@ const make = Effect.gen(function* () { !path.isAbsolute(relative) ); }; - const hasTerminal = (worktreePath: string) => - [...liveTerminals.values()] - .flatMap((entries) => [...entries.values()]) - .some((terminal) => { - if (terminal.status !== "starting" && terminal.status !== "running") return false; - const cwd = path.resolve(terminal.cwd); - return ( - (terminal.worktreePath !== null && - pathKey(terminal.worktreePath) === pathKey(worktreePath)) || - pathKey(cwd) === pathKey(worktreePath) || - inside(worktreePath, cwd) - ); - }); - // pnpm imports packages by clone or reflink when its store shares a device // with the worktree. Those files have one link but share blocks with the // store, so removing them frees almost nothing. A store on another device @@ -500,18 +486,21 @@ const make = Effect.gen(function* () { }); const containsProjectRoot = Effect.fn("StorageCleanup.containsProjectRoot")(function* ( - worktreePath: string, + worktreePaths: ReadonlyArray, projects: ReadonlyArray<{ readonly workspaceRoot: string }>, ) { + const contains = (projectPath: string) => + worktreePaths.some( + (worktreePath) => + pathKey(projectPath) === pathKey(worktreePath) || inside(worktreePath, projectPath), + ); for (const project of projects) { const projectPath = path.resolve(project.workspaceRoot); - if (pathKey(projectPath) === pathKey(worktreePath) || inside(worktreePath, projectPath)) - return true; + if (contains(projectPath)) return true; const realPath = yield* fs .realPath(projectPath) .pipe(Effect.orElseSucceed(() => projectPath)); - if (pathKey(realPath) === pathKey(worktreePath) || inside(worktreePath, realPath)) - return true; + if (contains(realPath)) return true; } return false; }); @@ -1040,20 +1029,58 @@ const make = Effect.gen(function* () { const threadsById = new Map(snapshot.threads.map((thread) => [thread.id, thread])); const refreshedDefaultRefs = new Map>(); const defaultBranches = new Map(); - const groups = Map.groupBy( - snapshot.threads.filter((thread) => thread.worktreePath !== null), - (thread) => path.resolve(thread.worktreePath!), + // Threads may store one checkout through different symlinked spellings; compare real paths. + // Keys are only compared, never opened, so they take pathKey's Windows case folding. + const canonicalPaths = new Map(); + const canonical = Effect.fnUntraced(function* (target: string) { + const resolved = path.resolve(target); + const cached = canonicalPaths.get(resolved); + if (cached !== undefined) return cached; + const realPath = yield* fs.realPath(resolved).pipe(Effect.orElseSucceed(() => resolved)); + const key = pathKey(realPath); + canonicalPaths.set(resolved, key); + return key; + }); + // A cwd in a deleted folder cannot be resolved, so stored spellings are compared too. + const occupies = Effect.fnUntraced(function* ( + worktreePaths: ReadonlyArray, + target: string, + ) { + const targets = [path.resolve(target), yield* canonical(target)]; + return worktreePaths.some((worktree) => + targets.some((cwd) => pathKey(cwd) === pathKey(worktree) || inside(worktree, cwd)), + ); + }); + const hasTerminal = Effect.fnUntraced(function* (worktreePaths: ReadonlyArray) { + for (const terminal of [...liveTerminals.values()].flatMap((entries) => [ + ...entries.values(), + ])) { + if (terminal.status !== "starting" && terminal.status !== "running") continue; + if ( + (terminal.worktreePath !== null && + (yield* occupies(worktreePaths, terminal.worktreePath))) || + (yield* occupies(worktreePaths, terminal.cwd)) + ) + return true; + } + return false; + }); + const liveThreads = snapshot.threads.filter((thread) => thread.worktreePath !== null); + const liveKeys = yield* Effect.forEach(liveThreads, (thread) => + canonical(thread.worktreePath!), ); - const deletedByPath = Map.groupBy(deletedThreads, (thread) => - path.resolve(thread.worktreePath!), + const groups = Map.groupBy(liveThreads, (_thread, index) => liveKeys[index]!); + const deletedKeys = yield* Effect.forEach(deletedThreads, (thread) => + canonical(thread.worktreePath!), ); + const deletedByPath = Map.groupBy(deletedThreads, (_thread, index) => deletedKeys[index]!); const candidates = [ ...[...groups].map( - ([worktreePath, group]) => + ([worktreeKey, group]) => // A deleted thread still owns the worktree its remaining subagents share, // but only deletion cleanup acts on it; otherwise the live rules apply. deletedByPath - .get(worktreePath) + .get(worktreeKey) ?.find( (deleted) => resolveWorktreeCleanup(serverSettings, deleted.projectId).worktreeOnDelete && @@ -1062,13 +1089,15 @@ const make = Effect.gen(function* () { worktreeOwner(group) ?? group[0]!, ), - ...deletedThreads.filter((thread) => !groups.has(path.resolve(thread.worktreePath!))), + ...deletedThreads.filter((_thread, index) => !groups.has(deletedKeys[index]!)), ]; for (const thread of candidates) { const settings = resolveWorktreeCleanup(serverSettings, thread.projectId); if (!worktreeCleanupEnabled(settings)) continue; const worktreePath = path.resolve(thread.worktreePath!); - const group = groups.get(worktreePath) ?? []; + const worktreeKey = yield* canonical(worktreePath); + const worktreePaths = [worktreePath, worktreeKey]; + const group = groups.get(worktreeKey) ?? []; const activityAt = Math.max(...group.map(storageCleanupActivityAt)); const deleted = "workspaceRoot" in thread; const settlementChain = deleted @@ -1122,7 +1151,7 @@ const make = Effect.gen(function* () { return keep(waitForStop("thread is running or has pending work")); if (group.some((member) => member.branch !== thread.branch)) return keep("a subagent thread's branch differs from this worktree's branch"); - if (hasTerminal(worktreePath)) return keep(waitForStop("open terminal")); + if (yield* hasTerminal(worktreePaths)) return keep(waitForStop("open terminal")); if ( [...group, ...settlementChain.slice(1)].some( (member) => @@ -1168,7 +1197,7 @@ const make = Effect.gen(function* () { return keep(notDueReason); const pathReason = yield* worktreePathReason(worktreePath); if (pathReason !== null) return keep(pathReason); - if (yield* containsProjectRoot(worktreePath, [project, ...snapshot.projects])) + if (yield* containsProjectRoot(worktreePaths, [project, ...snapshot.projects])) return keep("contains a project checkout"); if (yield* branchChanged(worktreePath, thread.branch)) return keep("repository or branch changed"); @@ -1274,12 +1303,14 @@ const make = Effect.gen(function* () { // Re-read this path's threads and sessions so new activity cancels removal; full per-worktree snapshots take seconds. const blockedSinceCheck = Effect.fnUntraced(function* () { const latestProjects = yield* projectStore.listShells(); - if (yield* containsProjectRoot(worktreePath, [project, ...latestProjects])) + if (yield* containsProjectRoot(worktreePaths, [project, ...latestProjects])) return "contains a project checkout"; - const latest = (yield* projections.getThreadWorktreePaths({ - includeArchived: true, - })).filter((entry) => path.resolve(entry.worktreePath) === worktreePath); - if (hasTerminal(worktreePath)) return waitForStop("open terminal"); + const bindings = yield* projections.getThreadWorktreePaths({ includeArchived: true }); + const bindingKeys = yield* Effect.forEach(bindings, (binding) => + canonical(binding.worktreePath), + ); + const latest = bindings.filter((_binding, index) => bindingKeys[index] === worktreeKey); + if (yield* hasTerminal(worktreePaths)) return waitForStop("open terminal"); if (deleted) { if ( !resolveWorktreeCleanup(yield* settingsService.getSettings, thread.projectId) @@ -1300,12 +1331,15 @@ const make = Effect.gen(function* () { projections.getThreadShell(entry.threadId), ); const latestGroup = latestShells.filter((shell) => shell !== null); + const latestKeys = yield* Effect.forEach(latestGroup, (shell) => + shell.worktreePath === null ? Effect.succeed(null) : canonical(shell.worktreePath), + ); const latestOwner = worktreeOwner(deleted ? [thread, ...latestGroup] : latestGroup); if ( latestGroup.length !== group.length || latestOwner?.id !== thread.id || latestOwner?.branch !== thread.branch || - latestGroup.some((shell) => { + latestGroup.some((shell, index) => { const previous = group.find((member) => member.id === shell.id); return ( previous === undefined || @@ -1313,7 +1347,7 @@ const make = Effect.gen(function* () { !Equal.equals(shell.settledAt, previous.settledAt) || (shell.settledAt !== null && !Equal.equals(shell.updatedAt, previous.updatedAt)) || !storageCleanupThreadIdle(shell, now, serverSettings) || - path.resolve(shell.worktreePath!) !== worktreePath || + latestKeys[index] !== worktreeKey || storageCleanupActivityAt(shell) !== storageCleanupActivityAt(previous) ); }) @@ -1350,9 +1384,8 @@ const make = Effect.gen(function* () { ); // Ready persisted sessions may have released their runtime; only live sessions protect the path. for (const session of sessions) { - const cwd = path.resolve(session.cwd); if ( - (pathKey(cwd) === pathKey(worktreePath) || inside(worktreePath, cwd)) && + (yield* occupies(worktreePaths, session.cwd)) && (yield* providerSessions.isLive(session.id)) ) return waitForStop("provider session is still open"); From 1c44b29395e23bd1eb3a437443893b777b0e0a6d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 19:31:05 -0800 Subject: [PATCH 013/390] fix(server): migrate-dev-db refuses unimported legacy threads (#23191) --- apps/server/scripts/migrate-dev-db.test.ts | 68 +++++++++++- apps/server/scripts/migrate-dev-db.ts | 116 ++++++++++++++------- 2 files changed, 143 insertions(+), 41 deletions(-) diff --git a/apps/server/scripts/migrate-dev-db.test.ts b/apps/server/scripts/migrate-dev-db.test.ts index 91045459d281e..c81b01b0fe604 100644 --- a/apps/server/scripts/migrate-dev-db.test.ts +++ b/apps/server/scripts/migrate-dev-db.test.ts @@ -9,9 +9,9 @@ import { runMigrations } from "../src/persistence/Migrations.ts"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import { runMigrateDevDb } from "./migrate-dev-db.ts"; -const withDatabase = ( +const withDatabase = ( databasePath: string, - effect: Effect.Effect, + effect: Effect.Effect, ) => effect.pipe(Effect.provide(NodeSqliteClient.layer({ filename: databasePath }))); const insertPeerLink = (sql: SqlClient.SqlClient) => sql`INSERT INTO peer_environment_links @@ -111,9 +111,14 @@ const createFixtureSource = Effect.fn("createMigrateDevDbFixtureSource")(functio (run_id, thread_id, ordinal, provider, status, requested_at, payload_json) VALUES (${`run-${threadId}`}, ${threadId}, 1, 'codex', ${runStatus}, '2026-08-01', '{}')`; yield* sql`INSERT INTO orchestration_events - (event_id, aggregate_kind, stream_id, stream_version, event_type, occurred_at, actor_kind, payload_json, metadata_json) - VALUES (${`event-${threadId}`}, 'thread', ${threadId}, 0, 'thread.created', '2026-08-01', 'user', '{}', '{}')`; + (event_id, aggregate_kind, stream_id, stream_version, event_type, occurred_at, actor_kind, payload_json, metadata_json, application_event_version) + VALUES (${`event-${threadId}`}, 'thread', ${threadId}, 0, 'thread.created', '2026-08-01', 'user', '{}', '{}', 2)`; } + // A V1 thread the server already imported must not trip the legacy guard. + yield* sql`INSERT INTO projection_threads + (thread_id, project_id, title, model_selection_json, runtime_mode, created_at, updated_at) + VALUES ('stopped-thread', 'project-kept', 'stopped-thread', + '{"instanceId":"codex","model":"gpt-5.4"}', 'full-access', '2026-08-01', '2026-08-01')`; yield* sql`INSERT INTO orchestration_v2_projection_provider_sessions (provider_session_id, thread_id, provider, status, updated_at, payload_json) VALUES ('session-shared', 'running-thread', 'codex', 'stopped', '2026-08-01', '{}')`; @@ -324,6 +329,61 @@ it.layer(NodeServices.layer)("migrate-dev-db", (it) => { }), ); + // The server imports V1 threads at boot, not in a migration, so a source can + // reach the V2 schema with its legacy threads still unimported. + it.effect.each([ + { name: "a pre-V2 source", migrateToLatest: false }, + { name: "a V2 source whose legacy threads were never imported", migrateToLatest: true }, + ])("refuses $name", ({ migrateToLatest }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sourceDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-v1-data-" }); + const destDir = yield* fs.makeTempDirectoryScoped({ + prefix: "migrate-dev-db-v1-data-dest-", + }); + const stateDir = path.join(sourceDir, "userdata"); + const source = path.join(stateDir, "statev2.sqlite"); + yield* fs.makeDirectory(stateDir, { recursive: true }); + yield* withDatabase( + source, + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 54 }); + yield* sql`INSERT INTO projection_projects + (project_id, title, workspace_root, scripts_json, created_at, updated_at) + VALUES ('project-legacy', 'Legacy', '/tmp/legacy', '[]', '2026-08-01', '2026-08-01')`; + yield* sql`INSERT INTO projection_threads + (thread_id, project_id, title, model_selection_json, runtime_mode, created_at, updated_at) + VALUES ('thread-legacy', 'project-legacy', 'Legacy thread', + '{"instanceId":"codex","model":"gpt-5.4"}', 'full-access', '2026-08-01', '2026-08-01')`; + if (migrateToLatest) yield* runMigrations(); + }), + ); + + // A live destination connection keeps uncheckpointed rows in the WAL; + // the rejected run must leave those bytes alone. + const destination = yield* createFixtureSource(destDir); + yield* withDatabase( + destination, + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql.unsafe("PRAGMA journal_mode = WAL").unprepared; + yield* sql`UPDATE projection_projects SET title = 'Edited' WHERE project_id = 'project-kept'`; + const walBefore = yield* fs.readFile(`${destination}-wal`); + assert.isAbove(walBefore.length, 0); + + const error = yield* runMigrateDevDb( + { baseDir: destDir, source, projects: 5, threadsPerProject: 10 }, + { sharedHome: sourceDir }, + ).pipe(Effect.flip); + assert.equal(error._tag, "MigrateDevDbLegacySourceError"); + assert.deepStrictEqual(yield* fs.readFile(`${destination}-wal`), walBefore); + }), + ); + }), + ); + it.effect("seeds from a source that recorded another migration below V2", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/scripts/migrate-dev-db.ts b/apps/server/scripts/migrate-dev-db.ts index 211895c9b44fa..b44e7c52ea14f 100644 --- a/apps/server/scripts/migrate-dev-db.ts +++ b/apps/server/scripts/migrate-dev-db.ts @@ -60,6 +60,18 @@ export class MigrateDevDbSourceIsDestinationError extends Schema.TaggedError()( + "MigrateDevDbLegacySourceError", + { + sourcePath: Schema.String, + legacyThreads: Schema.Number, + }, +) { + override get message(): string { + return `Source database '${this.sourcePath}' holds ${this.legacyThreads} legacy threads the server has not imported into V2 yet, which this command cannot carry over. Start a server once on a T3 home holding a copy of it so it imports them, then pass the userdata/statev2.sqlite that server leaves behind as --source.`; + } +} + export class MigrateDevDbServerRunningError extends Schema.TaggedError()( "MigrateDevDbServerRunningError", { @@ -210,47 +222,71 @@ const rootIsVisible = (sql: SqlClient.SqlClient) => sql` AND json_extract(root.payload_json, '$.deletedAt') IS NULL AND json_extract(root.payload_json, '$.archivedAt') IS NULL`; -// A single read transaction keeps the copy consistent with concurrent server writes. +// One read transaction keeps the copy, and its legacy-thread guard, consistent with concurrent +// server writes. The server imports V1 threads at boot, and the copy keeps only threads with V2 +// rows, so unimported ones would vanish silently (see LegacyV1ThreadImporter.pendingThreadCount). const copySourceSlice = Effect.fn("copyDevDbSourceSlice")(function* (sourcePath: string) { const sql = yield* SqlClient.SqlClient; yield* sql`ATTACH DATABASE ${`${NodeURL.pathToFileURL(sourcePath).href}?mode=ro`} AS src`; - const schema = yield* sql<{ type: string; name: string; sql: string }>` - SELECT type, name, sql FROM src.sqlite_master - WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%'`; - const threadColumns = yield* sql<{ name: string; notnull: number }>` - SELECT m.name, c."notnull" FROM src.sqlite_master m, pragma_table_info(m.name, 'src') c - WHERE m.type = 'table' AND c.name = 'thread_id'`; - const threadIdNullable = new Map(threadColumns.map((row) => [row.name, row.notnull === 0])); - // FTS5 makes its own shadow tables and contentless rows cannot be copied: - // create the virtual table, skip its shadow tables and rows, and rebuild the - // index once the messages and triggers are in. - const virtualTables = schema - .filter((entry) => entry.type === "table" && /^CREATE VIRTUAL TABLE/i.test(entry.sql)) - .map((entry) => entry.name); - const isShadowTable = (name: string) => - virtualTables.some((virtual) => - FTS5_SHADOW_SUFFIXES.some((suffix) => name === `${virtual}${suffix}`), - ); - const tables = schema.filter((entry) => entry.type === "table" && !isShadowTable(entry.name)); - const filtered = tables.some((table) => table.name === "orchestration_v2_projection_threads"); - // A row that points into a cleared table cannot be copied, so whatever references one is - // cleared with it, however far down the chain a newer migration added it. - const references = yield* sql<{ name: string; parent: string }>` - SELECT m.name, f."table" AS parent FROM src.sqlite_master m, pragma_foreign_key_list(m.name, 'src') f - WHERE m.type = 'table'`; - const cleared = new Set(CLEARED_TABLES); - for (let grew = true; grew;) { - grew = false; - for (const { name, parent } of references) { - if (cleared.has(parent) && !cleared.has(name)) { - cleared.add(name); - grew = true; - } - } - } yield* sql.withTransaction( Effect.gen(function* () { + const schema = yield* sql<{ type: string; name: string; sql: string }>` + SELECT type, name, sql FROM src.sqlite_master + WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%'`; + const threadColumns = yield* sql<{ name: string; notnull: number }>` + SELECT m.name, c."notnull" FROM src.sqlite_master m, pragma_table_info(m.name, 'src') c + WHERE m.type = 'table' AND c.name = 'thread_id'`; + const threadIdNullable = new Map(threadColumns.map((row) => [row.name, row.notnull === 0])); + // FTS5 makes its own shadow tables and contentless rows cannot be copied: + // create the virtual table, skip its shadow tables and rows, and rebuild the + // index once the messages and triggers are in. + const virtualTables = schema + .filter((entry) => entry.type === "table" && /^CREATE VIRTUAL TABLE/i.test(entry.sql)) + .map((entry) => entry.name); + const isShadowTable = (name: string) => + virtualTables.some((virtual) => + FTS5_SHADOW_SUFFIXES.some((suffix) => name === `${virtual}${suffix}`), + ); + const tables = schema.filter((entry) => entry.type === "table" && !isShadowTable(entry.name)); + const filtered = tables.some((table) => table.name === "orchestration_v2_projection_threads"); + // A row that points into a cleared table cannot be copied, so whatever references one is + // cleared with it, however far down the chain a newer migration added it. + const references = yield* sql<{ name: string; parent: string }>` + SELECT m.name, f."table" AS parent FROM src.sqlite_master m, pragma_foreign_key_list(m.name, 'src') f + WHERE m.type = 'table'`; + const cleared = new Set(CLEARED_TABLES); + for (let grew = true; grew;) { + grew = false; + for (const { name, parent } of references) { + if (cleared.has(parent) && !cleared.has(name)) { + cleared.add(name); + grew = true; + } + } + } + + if (tables.some((table) => table.name === "projection_threads")) { + // The V2 tables, application_event_version, and the partial index arrive in + // the same migration. Without the hint SQLite scans each thread's stream. + const [pending] = filtered + ? yield* sql<{ count: number }>` + SELECT COUNT(*) AS count FROM src.projection_threads AS thread + WHERE NOT EXISTS ( + SELECT 1 + FROM src.orchestration_events AS event INDEXED BY orchestration_events_v2_created_threads_idx + WHERE event.application_event_version = 2 + AND event.aggregate_kind = 'thread' + AND event.stream_id = thread.thread_id + AND event.event_type = 'thread.created' + )` + : yield* sql<{ count: number }>`SELECT COUNT(*) AS count FROM src.projection_threads`; + const legacyThreads = Number(pending?.count ?? 0); + if (legacyThreads > 0) { + return yield* new MigrateDevDbLegacySourceError({ sourcePath, legacyThreads }); + } + } + for (const table of tables) yield* sql.unsafe(table.sql).unprepared; if (filtered) { yield* sql`CREATE TEMP TABLE copied_threads (thread_id TEXT PRIMARY KEY)`; @@ -496,7 +532,6 @@ export const runMigrateDevDb = Effect.fn("runMigrateDevDb")(function* ( } yield* fs.makeDirectory(stateDir, { recursive: true }); - yield* ensureNotInUse(databasePath); const wrapPhase = (phase: MigrateDevDbPhaseError["phase"], phaseDatabasePath: string) => @@ -512,8 +547,15 @@ export const runMigrateDevDb = Effect.fn("runMigrateDevDb")(function* ( yield* Console.log(`Copying visible threads from ${sourcePath} (read-only)...`); yield* copySourceSlice(sourcePath).pipe( Effect.provide(NodeSqliteClient.layer({ filename: snapshotPath })), - wrapPhase("snapshot", sourcePath), + Effect.catchTags({ + SqlError: (cause) => + Effect.fail( + new MigrateDevDbPhaseError({ phase: "snapshot", databasePath: sourcePath, cause }), + ), + }), ); + // Checkpoints the destination, so it runs after the copy's legacy guard. + yield* ensureNotInUse(databasePath); // Migrate first so prune queries can use columns missing from older sources. yield* Console.log("Running migrations on the snapshot..."); From 87a772a3c6afc8780f1b85b40599278f70c739a5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 19:31:17 -0800 Subject: [PATCH 014/390] fix(web): opening a thread on a touch tablet no longer focuses the composer (#29496) --- .../src/components/ChatView.browser.test.tsx | 47 ++++++++++++++++++- apps/web/src/components/ChatView.tsx | 19 ++++++-- 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.test.tsx b/apps/web/src/components/ChatView.browser.test.tsx index 5535e76953a28..46557548fec6b 100644 --- a/apps/web/src/components/ChatView.browser.test.tsx +++ b/apps/web/src/components/ChatView.browser.test.tsx @@ -24,7 +24,7 @@ import { threadWithHistory, type FakeDriver, } from "../../test/fakeEnvironment"; -import { act, render } from "../../test/render"; +import { act, fakeTimers, render } from "../../test/render"; // The socket is the only fake layer; see test/appScenarios.renders.tsx. vi.mock("../../../../packages/client-runtime/src/connection/driver.ts", async (importOriginal) => { @@ -258,3 +258,48 @@ it("Enter on an empty prompt resumes an interrupted thread", async () => { ), ); }); + +it("opening a thread or returning to the window on a touch tablet does not focus the composer", async () => { + const realMatchMedia = window.matchMedia.bind(window); + using _touch = vi + .spyOn(window, "matchMedia") + .mockImplementation((query) => realMatchMedia(query === "(pointer: coarse)" ? "all" : query)); + const first = threadWithHistory("thread-29469-a", 1); + const second = threadWithHistory("thread-29469-b", 1); + using _environment = installFakeEnvironment([first, second]); + // iPad Pro 11 in landscape: the desktop layout, above max-sm. + await page.viewport(1194, 834); + __setClientSettingsForTests({ + ...DEFAULT_CLIENT_SETTINGS, + onboardingCompletedAt: "2026-01-01T00:00:00.000Z", + }); + const router = getRouter( + createMemoryHistory({ initialEntries: [`/${ENVIRONMENT_ID}/${first.id}`] }), + ); + await router.load(); + await using _app = await render(, { + // AppRoot brings its own router. + router: false, + }); + const composer = page.getByTestId("composer-editor"); + await expect.element(page.getByText(`Answer 0 for ${first.id}.`)).toBeVisible(); + + // Fake frames hold the next thread entry's focus frame until the test advances it. + using _timers = fakeTimers(); + await act(() => + router.navigate({ + to: "/$environmentId/$threadId", + params: { environmentId: ENVIRONMENT_ID, threadId: second.id }, + }), + ); + await expect.element(page.getByText(`Answer 0 for ${second.id}.`)).toBeVisible(); + vi.advanceTimersToNextFrame(); + // Focusing the editor raises the on-screen keyboard, which shrinks the page on iPadOS. + expect(document.activeElement).not.toBe(composer.element()); + + // ChatView waits two frames after window focus before refocusing the composer. + window.dispatchEvent(new FocusEvent("focus")); + vi.advanceTimersToNextFrame(); + vi.advanceTimersToNextFrame(); + expect(document.activeElement).not.toBe(composer.element()); +}); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 79c4b8ceb2fc9..515a63b1d173c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -423,6 +423,8 @@ type ChatViewProps = const DismissedFallbackWindowSchema = Schema.NullOr(Schema.String); +const ignoreComposerFocus = () => {}; + export default function ChatView(props: ChatViewProps) { const { environmentId, @@ -713,6 +715,8 @@ export default function ChatView(props: ChatViewProps) { }, [isWorkspaceFileDragActive]); const prefersPlanSidebarSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); const isMobileViewport = useMediaQuery("max-sm"); + // Focusing the composer without a tap raises the on-screen keyboard on touch devices. + const isCoarsePointer = useMediaQuery({ pointer: "coarse" }); const [workspaceLayoutElement, setWorkspaceLayoutElement] = useState(null); const threadPanelPopoverAnchorRef = useRef(null); const [terminalUiLaunchContext, setTerminalUiLaunchContext] = @@ -2146,12 +2150,20 @@ export default function ChatView(props: ChatViewProps) { workspaceLayoutElement, !embedded, terminalUiState.terminalOpen, - focusComposerStable, + isCoarsePointer ? ignoreComposerFocus : focusComposerStable, ); - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- window focus listener that refocuses the composer after the window regains focus + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- window focus listener that refocuses the composer after the window regains focus on a fine pointer useEffect(() => { - if (embedded || !activeThread?.id || terminalUiState.terminalOpen || isMobileViewport) return; + if ( + embedded || + !activeThread?.id || + terminalUiState.terminalOpen || + isMobileViewport || + isCoarsePointer + ) { + return; + } let frame: number | null = null; const onWindowFocus = () => { if (frame !== null) window.cancelAnimationFrame(frame); @@ -2178,6 +2190,7 @@ export default function ChatView(props: ChatViewProps) { activeThread?.id, embedded, focusComposerStable, + isCoarsePointer, isMobileViewport, terminalUiState.terminalOpen, ]); From c967d98cea57c6a2245c29115086b42ba5d0f60d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 19:31:31 -0800 Subject: [PATCH 015/390] fix(acp): linked terminal output no longer reopens a completed tool call (#35844) --- .../Adapters/AcpAdapterV2.test.ts | 103 ++++++++++++++++++ packages/provider-acp/src/server/adapter.ts | 11 +- 2 files changed, 109 insertions(+), 5 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index eba10c4b55fb2..553ee22ebe156 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -1074,6 +1074,109 @@ const optionValues = (question: OrchestrationV2UserInputQuestion | undefined) => describe("AcpAdapterV2", () => { beforeAll(() => bundleAgents); + it.live("keeps a completed tool's status when its linked ACP terminal streams output", () => + Effect.gen(function* () { + const instanceId = ProviderInstanceId.make("acp-test-terminal-linked-tool"); + const threadId = ThreadId.make("thread-acp-terminal-linked-tool"); + let notify: Parameters< + AcpSessionRuntime.AcpSessionRuntime["Service"]["handleSessionUpdate"] + >[0]; + const adapter = yield* makeAcpAdapterV2({ + instanceId, + selfInvocation: yield* resolveSelfInvocation(), + flavor: { + driver: ACP_TEST_DRIVER, + capabilities: AcpProviderCapabilitiesV2, + makeRuntime: makeMockRuntime({ + childProcessSpawner: yield* ChildProcessSpawner.ChildProcessSpawner, + mockAgentPath: yield* bundledMockAgentPath, + wrapRuntime: (runtime) => ({ + ...runtime, + handleSessionUpdate: (handler) => + Effect.sync(() => { + notify = handler; + }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))), + prompt: () => + Effect.gen(function* () { + const send = (update: EffectAcpSchema.SessionUpdate) => + notify({ sessionId: "mock-session-1", update }); + yield* send({ sessionUpdate: "state_update", state: "running" }); + yield* send({ + sessionUpdate: "terminal_update", + terminalId: "term", + command: "npm run dev", + }); + yield* send({ + sessionUpdate: "tool_call_update", + toolCallId: "linked", + kind: "execute", + title: "Start dev server", + status: "completed", + content: [{ type: "terminal", terminalId: "term" }], + }); + yield* send({ + sessionUpdate: "terminal_output_chunk", + terminalId: "term", + data: Buffer.from("still running").toString("base64"), + }); + yield* send({ + sessionUpdate: "state_update", + state: "idle", + stopReason: "end_turn", + }); + return { stopReason: "end_turn" as const }; + }), + }), + }), + }, + }); + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + }); + const modelSelection = { instanceId, model: "default" } as const; + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-acp-terminal-linked-tool"), + modelSelection, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + yield* runtime.startTurn( + makeTurnInput({ + threadId, + providerThread, + instanceId, + runtimePolicy, + now: yield* DateTime.now, + ordinal: 1, + }), + ); + const items = Array.from( + yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + ), + ).flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.nativeItemRef?.nativeId === "linked" + ? [event.turnItem] + : [], + ); + const last = items.at(-1); + assert.equal(last?.type === "command_execution" ? last.output : null, "still running"); + assert.deepEqual( + items.map((item) => item.status), + items.map(() => "completed"), + "terminal output must not reopen a tool the agent completed", + ); + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + it.live("reports per-turn usage unavailable on a continuation turn that never prompts", () => Effect.gen(function* () { const instanceId = ProviderInstanceId.make("acp-test-usage-continuation"); diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index 98b1514543f0d..c24ed4e7395a6 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -4973,10 +4973,10 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( ? ("completed" as const) : ("failed" as const); const embeddedToolCallIds = toolCallIdsByAgentTerminalId.get(terminalKey); - const projectedToolCallIds = - embeddedToolCallIds === undefined || embeddedToolCallIds.size === 0 - ? [`acp-agent-terminal:${update.terminalId}`] - : [...embeddedToolCallIds]; + const linked = embeddedToolCallIds !== undefined && embeddedToolCallIds.size > 0; + const projectedToolCallIds = linked + ? [...embeddedToolCallIds] + : [`acp-agent-terminal:${update.terminalId}`]; const output = next.output.slice(-MAX_AGENT_TERMINAL_OUTPUT_CHARS); for (const toolCallId of projectedToolCallIds) { const existing = context.tools.get(toolCallId); @@ -4995,7 +4995,8 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( setToolOutputText( { ...seeded, - status, + // An agent-reported tool owns its status; terminal state only drives the synthetic one. + status: (linked ? existing?.status : undefined) ?? status, ...(next.command === undefined ? {} : { command: next.command }), }, output, From 15cf8b122c64710a763c39a61fff4ce614529a02 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 19:31:43 -0800 Subject: [PATCH 016/390] fix(acp): root tool content replacements replace streamed output (#35862) --- .../Adapters/AcpRegistryAdapterV2.test.ts | 63 +++++++++++++++++++ packages/provider-acp/src/server/adapter.ts | 57 +++++++++++------ 2 files changed, 101 insertions(+), 19 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts index 16f94c17e3ce1..a45b3fd5c66b8 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts @@ -2090,6 +2090,69 @@ describe("AcpRegistryAdapterV2", () => { }).pipe(Effect.provide(layerTest), Effect.scoped), ); + it.effect("replaces streamed root tool output with a later content list", () => + Effect.gen(function* () { + const { update } = claudeFrames; + const text = (value: string) => [{ type: "content", content: { type: "text", text: value } }]; + const session = yield* openClaudeReplaySession([ + ...claudeFrames.open, + ...claudeFrames.prompt(1), + update(CLAUDE_ROOT_SESSION, { + sessionUpdate: "tool_call_update", + toolCallId: "root-tool", + title: "Stream", + status: "in_progress", + content: text("one"), + }), + update(CLAUDE_ROOT_SESSION, { + sessionUpdate: "tool_call_content_chunk", + toolCallId: "root-tool", + content: { type: "content", content: { type: "text", text: "two" } }, + }), + update(CLAUDE_ROOT_SESSION, { + sessionUpdate: "tool_call_update", + toolCallId: "root-tool", + status: "completed", + content: text("replacement"), + }), + update(CLAUDE_ROOT_SESSION, { + sessionUpdate: "tool_call_content_chunk", + toolCallId: "root-tool", + content: { type: "content", content: { type: "text", text: "-more" } }, + }), + update(CLAUDE_ROOT_SESSION, { + sessionUpdate: "tool_call_content_chunk", + toolCallId: "root-tool", + content: { + type: "diff", + changes: [{ operation: "add", path: "/a.txt" }], + patch: { format: "git_patch", text: "PATCH" }, + }, + }), + claudeMarker("tools complete"), + claudeFrames.idle("end_turn"), + claudeFrames.closeRequest, + claudeFrames.closed, + ]); + yield* session.runtime.startTurn(session.turnInput(1)); + const events = yield* session.throughMarker("tools complete"); + const outputs = events.flatMap((event) => + event.type === "turn_item.updated" && + event.turnItem.nativeItemRef?.nativeId === "root-tool" && + "output" in event.turnItem + ? [event.turnItem.output] + : [], + ); + assert.deepEqual(outputs, [ + text("one"), + text("onetwo"), + text("replacement"), + text("replacement-more"), + { type: "Text", text: "replacement-morePATCH" }, + ]); + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + it.effect("keeps stopped child tools interrupted when a late output chunk arrives", () => Effect.gen(function* () { const { update } = claudeFrames; diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index c24ed4e7395a6..723ad6ec5e7c7 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -1013,6 +1013,25 @@ function makeProviderThread(input: { }; } +function appendToolContentText(content: unknown, text: string): unknown[] { + const next: unknown[] = Array.isArray(content) ? [...content] : []; + const last = unknownRecord(next.at(-1)); + const lastContent = unknownRecord(last?.content); + if ( + last?.type === "content" && + lastContent?.type === "text" && + typeof lastContent.text === "string" + ) { + next[next.length - 1] = { + ...last, + content: { ...lastContent, text: `${lastContent.text}${text}` }, + }; + } else { + next.push({ type: "content", content: { type: "text", text } }); + } + return next; +} + function acpMcpToolCallOutput(rawOutput: unknown): unknown { const record = unknownRecord(rawOutput); if (record === undefined) return rawOutput; @@ -3130,26 +3149,9 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( if (toolCall.content.type !== "content") return; const text = acpContentBlockDisplayText(toolCall.content.content); if (text === undefined || text.length === 0) return; - const content: unknown[] = Array.isArray(previous?.data.content) - ? [...previous.data.content] - : []; - const last = unknownRecord(content.at(-1)); - const lastContent = unknownRecord(last?.content); - if ( - last?.type === "content" && - lastContent?.type === "text" && - typeof lastContent.text === "string" - ) { - content[content.length - 1] = { - ...last, - content: { ...lastContent, text: `${lastContent.text}${text}` }, - }; - } else { - content.push({ type: "content", content: { type: "text", text } }); - } toolCall = { toolCallId: toolCall.toolCallId, - data: { content }, + data: { content: appendToolContentText(previous?.data.content, text) }, }; } const merged = mergeToolCallState(previous, toolCall); @@ -5583,7 +5585,24 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( status: "inProgress" as const, data: { toolCallId: update.toolCallId }, }; - yield* emitTool(context, appendToolOutputText(previous, text)); + // Text chunks extend the content list so a later content replacement still shows. + yield* emitTool( + context, + update.content.type === "content" && previous.data.rawOutput === undefined + ? { + ...previous, + data: { + ...previous.data, + content: appendToolContentText(previous.data.content, text), + }, + } + : previous.data.rawOutput === undefined + ? setToolOutputText( + previous, + `${textFromUnknown(previous.data.content) ?? ""}${text}`, + ) + : appendToolOutputText(previous, text), + ); } break; } From 5e18314b88369981fb6cde7ebc51c0c76ff31d2d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 19:31:56 -0800 Subject: [PATCH 017/390] fix(forgejo): fast-forward-only repositories can merge pull requests (#32738) --- .../pull-requests/pullRequestActions.ts | 4 + .../src/persistence/mobile-preferences.ts | 9 +- .../pullRequest/pullRequestDetail.logic.ts | 1 + apps/web/src/uiStateStore.test.ts | 6 + apps/web/src/uiStateStore.ts | 9 +- packages/contracts/src/pullRequest.test.ts | 2 +- packages/contracts/src/pullRequest.ts | 10 +- .../server/ForgejoPullRequestProvider.test.ts | 103 ++++++++++++++++++ .../src/server/ForgejoPullRequestProvider.ts | 12 +- .../src/server/forgejoPullRequestJson.ts | 1 + .../src/server/GitHubPullRequestApi.ts | 11 +- 11 files changed, 147 insertions(+), 21 deletions(-) diff --git a/apps/mobile/src/features/pull-requests/pullRequestActions.ts b/apps/mobile/src/features/pull-requests/pullRequestActions.ts index e6c04f192b417..132501b60a240 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestActions.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestActions.ts @@ -33,6 +33,10 @@ export const MERGE_METHODS = { description: "Keep the commits and add a merge commit.", }, rebase: { title: "Rebase and merge", description: "Add the commits without a merge commit." }, + "fast-forward": { + title: "Fast-forward merge", + description: "Add the commits as they are. Needs the branch to contain the base.", + }, } as const satisfies Record; export function pullRequestActionLabel( diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 852d96c608520..f29fbc8c119b4 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -11,7 +11,7 @@ import { PullRequestListFilters, PullRequestListState, type ProviderInstanceId, - type PullRequestMergeMethod, + PullRequestMergeMethod, type SidebarProjectGroupingMode, } from "@t3tools/contracts"; import { ToolCallVisibility } from "@t3tools/contracts/settings"; @@ -174,6 +174,7 @@ export class MobilePreferencesStore extends Context.Service< >()("@t3tools/mobile/persistence/mobile-preferences/MobilePreferencesStore") {} const isToolCallVisibility = Schema.is(ToolCallVisibility); +const isPullRequestMergeMethod = Schema.is(PullRequestMergeMethod); function sanitizePreferences(parsed: Preferences): Preferences { const preferences: { @@ -232,11 +233,7 @@ function sanitizePreferences(parsed: Preferences): Preferences { preferences.pullRequestHideTestFiles = parsed.pullRequestHideTestFiles; } - if ( - parsed.pullRequestMergeMethod === "squash" || - parsed.pullRequestMergeMethod === "merge" || - parsed.pullRequestMergeMethod === "rebase" - ) { + if (isPullRequestMergeMethod(parsed.pullRequestMergeMethod)) { preferences.pullRequestMergeMethod = parsed.pullRequestMergeMethod; } diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts index d2326814c9abb..569d987fe3080 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts @@ -54,6 +54,7 @@ export const PULL_REQUEST_MERGE_METHOD_LABELS: Record { }); }); + it("keeps a persisted fast-forward merge method", () => { + expect( + parsePersistedState({ pullRequestMergeMethod: "fast-forward" }).pullRequestMergeMethod, + ).toBe("fast-forward"); + }); + it.each([undefined, 1])("ignores changed-file expansion version %s", (version) => { const parsed = parsePersistedState({ ...(version === undefined ? {} : { threadChangedFilesExpansionVersion: version }), diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index a94905ef518d4..b2ac1a508c7a6 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -1,5 +1,6 @@ import { Debouncer } from "@tanstack/react-pacer"; -import type { PullRequestMergeMethod } from "@t3tools/contracts"; +import { PullRequestMergeMethod } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; import { create } from "zustand"; import { normalizeProjectPathForComparison } from "@t3tools/client-runtime/state/projects"; @@ -99,6 +100,8 @@ function sanitizeOptionalKey(value: unknown): string | null { return typeof value === "string" && value.length > 0 ? value : null; } +const isPullRequestMergeMethod = Schema.is(PullRequestMergeMethod); + function sanitizeTimestampRecord(value: unknown): Record { if (!value || typeof value !== "object") { return {}; @@ -114,10 +117,6 @@ function sanitizeTimestampRecord(value: unknown): Record { ); } -function isPullRequestMergeMethod(value: unknown): value is PullRequestMergeMethod { - return value === "merge" || value === "squash" || value === "rebase"; -} - export function parsePersistedState(parsed: PersistedUiState): UiState { const projectExpandedById = parsed.projectExpandedById === undefined diff --git a/packages/contracts/src/pullRequest.test.ts b/packages/contracts/src/pullRequest.test.ts index 7c56ca8b9df3c..91f22f20bfa5f 100644 --- a/packages/contracts/src/pullRequest.test.ts +++ b/packages/contracts/src/pullRequest.test.ts @@ -104,7 +104,7 @@ describe("PullRequestCapabilities", () => { capabilities: { ...base, actions: options.actions, - mergeMethods: ["squash", "fast-forward", "merge"], + mergeMethods: ["squash", "semi-linear", "merge"], updateMethods: options.updateMethods, review: { ...base.review, verdicts: options.verdicts }, }, diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index fb2c9b474268d..3f1198c1212e8 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -54,7 +54,13 @@ export type PullRequestChecksState = typeof PullRequestChecksState.Type; export const PullRequestMergeability = Schema.Literals(["mergeable", "conflicting", "unknown"]); export type PullRequestMergeability = typeof PullRequestMergeability.Type; -export const PullRequestMergeMethod = Schema.Literals(["merge", "squash", "rebase"]); +/** `fast-forward` refuses unless the head contains the base, where `rebase` rewrites the branch onto it. */ +export const PullRequestMergeMethod = Schema.Literals([ + "merge", + "squash", + "rebase", + "fast-forward", +]); export type PullRequestMergeMethod = typeof PullRequestMergeMethod.Type; export const PullRequestAction = Schema.Literals([ @@ -335,6 +341,8 @@ export const PullRequestMergeCapabilities = Schema.Struct({ merge: Schema.Boolean, squash: Schema.Boolean, rebase: Schema.Boolean, + /** Optional so servers that predate it still decode; absent means not allowed. */ + "fast-forward": Schema.optional(Schema.Boolean), }); export type PullRequestMergeCapabilities = typeof PullRequestMergeCapabilities.Type; diff --git a/packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.test.ts b/packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.test.ts index 22b1b0c08907c..3e118dfc691e2 100644 --- a/packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.test.ts +++ b/packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.test.ts @@ -121,3 +121,106 @@ it.effect("filters listed pull requests by the requested state", () => assert.deepStrictEqual(yield* numbers("open"), []); }), ); + +const fastForwardOnly = { + cwd: "/forgejo", + repository: "maria/project", + host: "forge.example:3000", + number: 3, +}; +const fastForwardOnlySettings = { + allow_merge_commits: false, + allow_squash_merge: false, + allow_rebase: false, + allow_rebase_explicit: false, + allow_fast_forward_only_merge: true, + default_merge_style: "fast-forward-only", +}; +const fastForwardOnlyProvider = ( + writes: Array<{ path: string; body: unknown }>, + mergeSettings: object = fastForwardOnlySettings, +) => + ForgejoPullRequestProvider.make.pipe( + Effect.provide( + Layer.mock(ForgejoCli.ForgejoCli)({ + api: (input) => { + const [path] = input.path.split("?"); + const reply = (value: unknown) => + Effect.succeed(TestSourceControlHost.processOutput(JSON.stringify(value))); + if (input.method === "POST") { + writes.push({ path: input.path, body: input.body }); + return reply(""); + } + switch (path) { + case "user": + return reply({ login: "maria" }); + case "repos/maria/project": + return reply({ + full_name: "maria/project", + permissions: { push: true, admin: true }, + ...mergeSettings, + }); + case "repos/maria/project/pulls/3": + return reply({ + number: 3, + title: "Fast-forward me", + body: "", + html_url: "https://forge.example:3000/maria/project/pulls/3", + user: { login: "maria" }, + state: "open", + merged: false, + mergeable: true, + head: { ref: "feature", sha: "head-sha", repo: null }, + base: { ref: "main", sha: "base-sha", repo: null }, + merge_base: "base-sha", + created_at: "2026-10-12T00:00:00Z", + updated_at: "2026-10-12T00:00:00Z", + closed_at: null, + merged_at: null, + labels: [], + }); + case "repos/maria/project/branches/main": + return reply({}); + default: + return reply([]); + } + }, + }), + ), + ); + +it.effect("offers a fast-forward merge on a repository that only allows fast-forward", () => + Effect.gen(function* () { + const provider = yield* fastForwardOnlyProvider([]); + const detail = yield* provider.getChangeRequest(fastForwardOnly); + const allowed = provider.capabilities.mergeMethods.filter( + (method) => detail.mergeCapabilities[method], + ); + assert.deepStrictEqual(allowed, ["fast-forward"]); + }), +); + +it.effect("does not offer fast-forward when Forgejo omits the setting", () => + Effect.gen(function* () { + const provider = yield* fastForwardOnlyProvider([], {}); + const detail = yield* provider.getChangeRequest(fastForwardOnly); + const allowed = provider.capabilities.mergeMethods.filter( + (method) => detail.mergeCapabilities[method], + ); + assert.deepStrictEqual(allowed, ["merge", "squash", "rebase"]); + }), +); + +it.effect("merges with Forgejo's fast-forward-only style", () => + Effect.gen(function* () { + const writes: Array<{ path: string; body: unknown }> = []; + const provider = yield* fastForwardOnlyProvider(writes); + yield* provider.runAction({ ...fastForwardOnly, action: "merge", mergeMethod: "fast-forward" }); + assert.deepStrictEqual(writes, [ + { + path: "repos/maria/project/pulls/3/merge", + body: { Do: "fast-forward-only", delete_branch_after_merge: false }, + }, + ]); + }), +); diff --git a/packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.ts b/packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.ts index 3a089e65c04bf..70114b9fe45dd 100644 --- a/packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.ts +++ b/packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.ts @@ -44,7 +44,7 @@ const CAPABILITIES: PullRequestCapabilities = { viewedFiles: "environment", comment: true, actions: [...definition.changeRequestActions], - mergeMethods: ["merge", "squash", "rebase"], + mergeMethods: ["merge", "squash", "rebase", "fast-forward"], updateMethods: ["merge", "rebase"], search: false, reactions: true, @@ -385,6 +385,8 @@ export const make = Effect.gen(function* () { merge: repo.allow_merge_commits ?? true, squash: repo.allow_squash_merge ?? true, rebase: repo.allow_rebase ?? true, + // Added in Forgejo 7; older servers cannot fast-forward-only merge. + "fast-forward": repo.allow_fast_forward_only_merge ?? false, }, } satisfies ProviderChangeRequestDetail; }), @@ -542,7 +544,13 @@ export const make = Effect.gen(function* () { ...input, path: `${pullPath(input)}/merge`, method: "POST", - body: { Do: input.mergeMethod ?? "merge", delete_branch_after_merge: deleteBranch }, + body: { + Do: + input.mergeMethod === "fast-forward" + ? "fast-forward-only" + : (input.mergeMethod ?? "merge"), + delete_branch_after_merge: deleteBranch, + }, }).pipe( Effect.catchTags({ // Forgejo merges before deleting the branch, so a refused deletion still merged. diff --git a/packages/source-control-forgejo/src/server/forgejoPullRequestJson.ts b/packages/source-control-forgejo/src/server/forgejoPullRequestJson.ts index 38ddae9ad40fa..a1976ee689290 100644 --- a/packages/source-control-forgejo/src/server/forgejoPullRequestJson.ts +++ b/packages/source-control-forgejo/src/server/forgejoPullRequestJson.ts @@ -32,6 +32,7 @@ export const ForgejoRepository = Schema.Struct({ allow_merge_commits: Schema.optional(Schema.Boolean), allow_squash_merge: Schema.optional(Schema.Boolean), allow_rebase: Schema.optional(Schema.Boolean), + allow_fast_forward_only_merge: Schema.optional(Schema.Boolean), allow_rebase_update: Schema.optional(Schema.Boolean), default_delete_branch_after_merge: Schema.optional(Schema.Boolean), }); diff --git a/packages/source-control-github/src/server/GitHubPullRequestApi.ts b/packages/source-control-github/src/server/GitHubPullRequestApi.ts index 6b8d22c17a12b..8eabb6a09518c 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestApi.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestApi.ts @@ -933,11 +933,10 @@ const decodeMergeBase = Schema.decodeUnknownOption( ), ); -const GRAPHQL_MERGE_METHODS = { - merge: "MERGE", - squash: "SQUASH", - rebase: "REBASE", -} as const satisfies Record; +// GitHub has no fast-forward-only merge; the service refuses methods a host does not advertise. +const GRAPHQL_MERGE_METHODS: Partial< + Record +> = { merge: "MERGE", squash: "SQUASH", rebase: "REBASE" }; const PullRequestGraphQLGroup = GraphQLGroup.merge( PullRequestBatchesGraphQLGroup, @@ -2752,7 +2751,7 @@ export const make = Effect.gen(function* () { !IMMEDIATELY_MERGEABLE.has(state.mergeStateStatus?.toUpperCase() ?? "")); const merge = { pullRequestId: state.id, - mergeMethod: GRAPHQL_MERGE_METHODS[input.mergeMethod ?? "merge"], + mergeMethod: GRAPHQL_MERGE_METHODS[input.mergeMethod ?? "merge"] ?? "MERGE", ...(expectedHead === undefined ? {} : { expectedHeadOid: expectedHead }), ...(body === undefined ? {} : { commitBody: body }), }; From b1bc5ce20f33442daa194002f338fcf52f4dbb62 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 19:32:09 -0800 Subject: [PATCH 018/390] fix(server): linking says why each address failed discovery (#30884) --- apps/server/src/peer/PeerLinks.test.ts | 25 +++++- apps/server/src/peer/PeerLinks.ts | 101 ++++++++++++++----------- 2 files changed, 82 insertions(+), 44 deletions(-) diff --git a/apps/server/src/peer/PeerLinks.test.ts b/apps/server/src/peer/PeerLinks.test.ts index f35e760bea4d7..8a55a0d119041 100644 --- a/apps/server/src/peer/PeerLinks.test.ts +++ b/apps/server/src/peer/PeerLinks.test.ts @@ -742,7 +742,7 @@ it.effect("linking a picked machine that answers nowhere names it and links noth .pipe(Effect.flip); expect(failed.reason).toBe("unreachable"); expect(failed.message).toBe( - `This environment could not reach Box at ${impostor.url}, http://192.168.1.20:3773.`, + `This environment could not reach Box: ${new URL(impostor.url).origin} answered as another environment, Impostor. http://192.168.1.20:3773 is plain http, which would send the link's credentials unencrypted. Use its https address, or its Tailscale address.`, ); expect(yield* a.links.list).toEqual([]); expect(yield* linkedSessions(impostor)).toEqual([]); @@ -763,6 +763,29 @@ it.effect("linking a picked machine that answers nowhere names it and links noth ).pipe(Effect.provide(NodeHttpServer.layerTest)), ); +it.live("linking an address with nothing listening says so, unlike one that is not T3 Code", () => + Effect.scoped( + Effect.gen(function* () { + const closed = yield* Effect.scoped( + serveRaw(() => Effect.succeed(HttpServerResponse.empty({ status: 404 }))), + ); + const notT3 = yield* serveRaw(() => + Effect.succeed( + HttpServerResponse.text("

Not Found

", { status: 404, contentType: "text/html" }), + ), + ); + const a = yield* makeA(laptop); + const linking = (url: string) => + a.links.link({ url, pairingCode: "unused", access: "auto" }).pipe(Effect.flip); + expect((yield* linking(closed)).message).toBe(`${closed} did not answer (ECONNREFUSED).`); + expect((yield* linking(notT3)).message).toBe( + `${notT3} answered, but not as a T3 Code environment this version understands.`, + ); + expect(yield* a.links.list).toEqual([]); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + /** An https listener whose certificate this machine does not trust. */ const serveUntrusted = Layer.build( Layer.fresh( diff --git a/apps/server/src/peer/PeerLinks.ts b/apps/server/src/peer/PeerLinks.ts index 550d3ed757f07..b9c70127971b6 100644 --- a/apps/server/src/peer/PeerLinks.ts +++ b/apps/server/src/peer/PeerLinks.ts @@ -5,7 +5,6 @@ import { EnvironmentHttpApi, EnvironmentId, type ExecutionEnvironmentCapabilities, - type ExecutionEnvironmentDescriptor, MCP_PEER_LINK_SOFTWARE_ID, type PeerLink, type PeerLinkCreateInput, @@ -13,6 +12,7 @@ import { type PeerLinkSummary, } from "@t3tools/contracts"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; +import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Digest from "effect/crypto/Digest"; import * as SecureRandom from "effect/crypto/SecureRandom"; @@ -31,6 +31,7 @@ import * as Schema from "effect/Schema"; import * as SqlClient from "effect/sql/SqlClient"; import * as FetchHttpClient from "effect/http/FetchHttpClient"; import * as HttpClient from "effect/http/HttpClient"; +import * as HttpClientError from "effect/http/HttpClientError"; import * as HttpApi from "effect/http-api/HttpApi"; import * as HttpApiClient from "effect/http-api/HttpApiClient"; import { isLocalLoopbackHost, isTailnetHost } from "@t3tools/shared/hostClassification"; @@ -176,6 +177,38 @@ const peerOrigin = (url: string) => { return Effect.succeed(parsed.origin); }; +/** The system error under a failed connection, such as ECONNREFUSED or ENOTFOUND. */ +const connectionCode = (cause: unknown) => + [cause, Predicate.hasProperty(cause, "cause") ? cause.cause : undefined] + .map((error) => (Predicate.hasProperty(error, "code") ? error.code : undefined)) + .find(Predicate.isString); + +/** Why `origin` did not answer as T3 Code, short enough for a toast. Never echoes a body. */ +const probeFailure = ( + origin: string, + error: HttpClientError.HttpClientError | Schema.SchemaError | Cause.TimeoutError, + timeout: Duration.Duration, +) => { + const unreachable = (message: string) => new PeerLinkError({ reason: "unreachable", message }); + const certificate = certificateErrorCode(error); + if (certificate !== undefined) { + return unreachable(`${origin} presented a certificate this machine rejected (${certificate}).`); + } + if (Cause.isTimeoutError(error)) { + return unreachable(`${origin} did not answer within ${Duration.toSeconds(timeout)} seconds.`); + } + if (HttpClientError.isHttpClientError(error) && error.reason._tag === "TransportError") { + const code = connectionCode(error.reason.cause); + return unreachable( + code === undefined ? `${origin} did not answer.` : `${origin} did not answer (${code}).`, + ); + } + return new PeerLinkError({ + reason: "not_a_t3_environment", + message: `${origin} answered, but not as a T3 Code environment this version understands.`, + }); +}; + /** Whether a host is this machine's own loopback. Tests serve their peers there, so they narrow it. */ export const LoopbackHost = Context.Reference<(host: string) => boolean>( "t3/peer/PeerLinks/LoopbackHost", @@ -217,13 +250,10 @@ const make = Effect.gen(function* () { Effect.provideService(HttpClient.HttpClient, peerHttpClient), ); - /** - * The environment answering at `origin`, or nothing when it does not answer - * as T3 Code. A certificate this machine rejects fails instead, since - * the peer may well be there. - */ - const describe = (origin: string) => - clientFor(origin).pipe( + /** The environment answering at `origin`, or why nothing there answers as T3 Code. */ + const probe = (origin: string) => { + const timeout = probeTimeout(origin); + return clientFor(origin).pipe( Effect.flatMap((client) => client.metadata.descriptor()), // A brief network drop must not list a live peer as down; the timeout still bounds both tries. Effect.retry({ @@ -231,27 +261,18 @@ const make = Effect.gen(function* () { schedule: Schedule.spaced("1 second"), while: (error) => certificateErrorCode(error) === undefined, }), - Effect.timeout(probeTimeout(origin)), - Effect.asSome, - Effect.catch((error) => { - const code = certificateErrorCode(error); - return code === undefined - ? Effect.succeed(Option.none()) - : Effect.fail( - new PeerLinkError({ - reason: "unreachable", - message: `${origin} presented a certificate this machine rejected (${code}).`, - }), - ); - }), + Effect.timeout(timeout), + // No cause: a decode error can carry the response body to the client. + Effect.mapError((error) => probeFailure(origin, error, timeout)), ); + }; - /** Whether `url` answers as `environmentId`, counting an untrusted certificate as no. */ + /** Whether `url` answers as `environmentId`, counting any failure as no. */ const answersAs = Effect.fn("PeerLinks.answersAs")(function* ( url: string, environmentId: EnvironmentId, ) { - const found = yield* describe(url).pipe(Effect.orElseSucceed(() => Option.none())); + const found = yield* probe(url).pipe(Effect.option); return Option.filter(found, (peer) => peer.environmentId === environmentId); }); @@ -404,7 +425,8 @@ const make = Effect.gen(function* () { /** * The addresses that answer as `environmentId`, in the order given, with * what the first one answered. Addresses this environment would not send - * credentials to are skipped; when every one is, the failure says why. + * credentials to are skipped; when none answers as it, the failure says why + * for each. */ const answeringAs = Effect.fn("PeerLinks.answeringAs")(function* ( urls: ReadonlyArray, @@ -423,28 +445,28 @@ const make = Effect.gen(function* () { message: `${origin} is this machine, not ${name}.`, }); } - return { origin, descriptor: yield* describe(origin) }; + const descriptor = yield* probe(origin); + if (descriptor.environmentId !== environmentId) { + return yield* new PeerLinkError({ + reason: "unreachable", + message: `${origin} answered as another environment, ${descriptor.label}.`, + }); + } + return { origin, descriptor }; }).pipe(Effect.result), { concurrency: "unbounded" }, ); const matching = checked.flatMap((result) => - Result.isSuccess(result) && - Option.isSome(result.success.descriptor) && - result.success.descriptor.value.environmentId === environmentId - ? [{ origin: result.success.origin, descriptor: result.success.descriptor.value }] - : [], + Result.isSuccess(result) ? [result.success] : [], ); const first = matching[0]; if (first === undefined) { - const refusals = checked.flatMap((result) => + const reasons = checked.flatMap((result) => Result.isFailure(result) ? [result.failure.message] : [], ); return yield* new PeerLinkError({ reason: "unreachable", - message: - refusals.length === checked.length - ? `This environment could not reach ${name}: ${refusals.join(" ")}` - : `This environment could not reach ${name} at ${urls.join(", ")}.`, + message: `This environment could not reach ${name}: ${reasons.join(" ")}`, }); } // Each address is kept once, even when two spellings share an origin. @@ -456,14 +478,7 @@ const make = Effect.gen(function* () { const answeringAt = Effect.fn("PeerLinks.answeringAt")(function* (urls: ReadonlyArray) { const origins = yield* Effect.forEach(urls, peerOrigin); const primary = origins[0]!; - const descriptor = yield* describe(primary); - if (Option.isNone(descriptor)) { - return yield* new PeerLinkError({ - reason: "not_a_t3_environment", - message: `Nothing at ${primary} answered as a T3 Code environment.`, - }); - } - return { primary, peer: descriptor.value, origins }; + return { primary, peer: yield* probe(primary), origins }; }); const link: PeerLinks["Service"]["link"] = Effect.fn("PeerLinks.link")(function* (input) { From 8a227eba4ea18917ebf56a5c4cece947424e7ee7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 19:32:21 -0800 Subject: [PATCH 019/390] fix(terminal): show recovery guidance when a project folder is missing (#35849) --- packages/contracts/src/terminal.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/contracts/src/terminal.ts b/packages/contracts/src/terminal.ts index 976c69cc5c36f..7dcd75cb9d94e 100644 --- a/packages/contracts/src/terminal.ts +++ b/packages/contracts/src/terminal.ts @@ -268,7 +268,7 @@ export class TerminalCwdNotFoundError extends Schema.TaggedError Date: Thu, 12 Nov 2026 19:32:35 -0800 Subject: [PATCH 020/390] fix(client-runtime): reopened files show host edits instead of the first read (#29409) --- apps/web/src/state/revisionedQuery.test.ts | 23 +++-- apps/web/src/state/revisionedQuery.ts | 14 ++- packages/client-runtime/src/state/assets.ts | 4 +- .../client-runtime/src/state/filesystem.ts | 1 - .../client-runtime/src/state/runtime.test.ts | 85 +++++++++++++++++++ packages/client-runtime/src/state/runtime.ts | 79 +++++++++++------ 6 files changed, 166 insertions(+), 40 deletions(-) diff --git a/apps/web/src/state/revisionedQuery.test.ts b/apps/web/src/state/revisionedQuery.test.ts index 7dda15de23009..7b21dd1d29aee 100644 --- a/apps/web/src/state/revisionedQuery.test.ts +++ b/apps/web/src/state/revisionedQuery.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it } from "vite-plus/test"; import { revisionedQuery } from "./revisionedQuery"; // A stand-in query that counts its fetches. -function setup() { +function setup(readQuery: (cache: Atom.Atom) => Atom.Atom = (cache) => cache) { let fetches = 0; // Node removal runs on the registry's scheduler; releasing flushes it so the // next mount of the same revision builds a fresh node, as a React remount does. @@ -15,10 +15,12 @@ function setup() { return () => {}; }, }); - const query = Atom.make(() => { - fetches += 1; - return fetches; - }).pipe(Atom.keepAlive); + const query = readQuery( + Atom.make(() => { + fetches += 1; + return fetches; + }).pipe(Atom.keepAlive), + ); const mount = async (revision: string | null | undefined) => { const release = registry.mount(revisionedQuery(query, revision)); // A stale read refetches in a microtask, after the render that read it. @@ -77,6 +79,17 @@ describe("revisionedQuery", () => { expect(fetches()).toBe(2); }); + it("counts a query as cached while the atom it reads through keeps the value", async () => { + // Like an environment query: its own node goes with its last reader, the cache stays. + const { mount, fetches } = setup((cache) => + Atom.transform(cache, (get) => get(cache), { initialValueTarget: cache }), + ); + + (await mount(undefined))(); + (await mount("mutation-1"))(); + expect(fetches()).toBe(2); + }); + it("does not refetch a cached query on a first read before any workspace change", async () => { const { mount, fetches } = setup(); diff --git a/apps/web/src/state/revisionedQuery.ts b/apps/web/src/state/revisionedQuery.ts index 73e18b36161d5..90c147b664741 100644 --- a/apps/web/src/state/revisionedQuery.ts +++ b/apps/web/src/state/revisionedQuery.ts @@ -6,8 +6,14 @@ import { Atom, type AtomRegistry } from "effect/reactivity"; const readRevisions = new WeakMap>(); // The registry keys serializable atoms' nodes by their key, not the atom. -const queryKey = (query: Atom.Atom) => - Atom.isSerializable(query) ? query[Atom.SerializableTypeId].key : query; +const hasNode = (registry: AtomRegistry.AtomRegistry, atom: Atom.Atom) => + registry.getNodes().has(Atom.isSerializable(atom) ? atom[Atom.SerializableTypeId].key : atom); + +// A query's own node can go with its last reader while the atom it takes its value from +// (an environment query's cache, on an idle TTL) still holds the value. +const isCached = (registry: AtomRegistry.AtomRegistry, query: Atom.Atom) => + hasNode(registry, query) || + (query.initialValueTarget !== undefined && hasNode(registry, query.initialValueTarget)); const makeRevisionedFamily = (query: Atom.Atom) => Atom.family((revision: string | null) => @@ -25,14 +31,14 @@ const makeRevisionedFamily = (query: Atom.Atom) => ? revisions.get(query) !== revision : // A reader without a revision may have cached it before the latest // workspace change; a query nobody has read yet fetches fresh anyway. - revision !== null && get.registry.getNodes().has(queryKey(query)); + revision !== null && isCached(get.registry, query); // Deferred: this builds while React renders, and a refresh would synchronously // re-render the query's other readers, which React rejects. if (stale) { const registry = get.registry; queueMicrotask(() => { // No node, as after the registry is disposed: nothing cached to refetch. - if (registry.getNodes().has(queryKey(query))) registry.refresh(query); + if (isCached(registry, query)) registry.refresh(query); }); } revisions.set(query, revision); diff --git a/packages/client-runtime/src/state/assets.ts b/packages/client-runtime/src/state/assets.ts index e382c15fb328a..e0e511325cbb1 100644 --- a/packages/client-runtime/src/state/assets.ts +++ b/packages/client-runtime/src/state/assets.ts @@ -28,7 +28,6 @@ import type { ProjectFaviconCache, ProjectFaviconTarget } from "../projectFavico import { createEnvironmentQueryAtomFamily } from "./runtime.ts"; const ASSET_URL_REFRESH_INTERVAL_MS = 30 * 60_000; -const ASSET_URL_STALE_TIME_MS = 5 * 60_000; const ASSET_URL_IDLE_TTL_MS = 60 * 60_000; export const ASSET_URL_MAX_IN_FLIGHT = 6; @@ -291,7 +290,8 @@ export function createAssetEnvironmentAtoms( const createUrl = createEnvironmentQueryAtomFamily(runtime, { label: "environment-data:assets:create-url", execute, - staleTimeMs: ASSET_URL_STALE_TIME_MS, + // Each mint signs a new URL, so a remounted reader reuses its URL until renewal is due. + staleTimeMs: ASSET_URL_REFRESH_INTERVAL_MS, idleTtlMs: ASSET_URL_IDLE_TTL_MS, refreshIntervalMs: ASSET_URL_REFRESH_INTERVAL_MS, refreshTrigger: ({ input }) => diff --git a/packages/client-runtime/src/state/filesystem.ts b/packages/client-runtime/src/state/filesystem.ts index 6feec971c7b30..0b380101a99de 100644 --- a/packages/client-runtime/src/state/filesystem.ts +++ b/packages/client-runtime/src/state/filesystem.ts @@ -154,7 +154,6 @@ export function createFilesystemEnvironmentAtoms( // lists it again. The short TTL only keeps the old listing on screen while it refreshes. idleTtlMs: BROWSE_IDLE_TTL_MS, staleTimeMs: 0, - revalidateOnRemount: true, }), }; } diff --git a/packages/client-runtime/src/state/runtime.test.ts b/packages/client-runtime/src/state/runtime.test.ts index 40188b2f81c8e..b8c795f5a6629 100644 --- a/packages/client-runtime/src/state/runtime.test.ts +++ b/packages/client-runtime/src/state/runtime.test.ts @@ -1,4 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; +import * as NodeV8 from "node:v8"; +import * as NodeVM from "node:vm"; +import { vi } from "vite-plus/test"; import { DEFAULT_MAX_TERMINAL_BUFFER_BYTES, EnvironmentId, @@ -163,6 +166,7 @@ const makeEnvironmentQueryHarness = Effect.fn("TestEnvironmentQuery.makeHarness" return { runtime, + family, atom: family({ environmentId: QUERY_ENVIRONMENT.environmentId, input: undefined }), supervisorSession, supervisorState, @@ -1132,6 +1136,87 @@ describe("environment query lifecycle", () => { ), ); + it.effect("refetches when mounted again after its stale time, while still cached", () => + Effect.scoped( + Effect.gen(function* () { + let now = 0; + using _now = vi.spyOn(Date, "now").mockImplementation(() => now); + let contents = "before edit"; + let fetches = 0; + const harness = yield* makeEnvironmentQueryHarness( + Effect.sync(() => { + fetches += 1; + return contents; + }), + ); + const registry = AtomRegistry.make(); + yield* Effect.addFinalizer(() => Effect.sync(() => registry.dispose())); + const read = readEnvironmentQuery(registry, harness.atom); + const remount = Effect.gen(function* () { + const unmount = registry.mount(harness.atom); + yield* Effect.yieldNow; + const value = yield* read; + unmount(); + yield* Effect.yieldNow; + return value; + }); + + expect(yield* remount).toBe("before edit"); + contents = "after edit"; + + // Inside the harness's 60s stale time: the cached value stands. + now = 30_000; + expect(yield* remount).toBe("before edit"); + expect(fetches).toBe(1); + + // Past the stale time, well inside the default 5 minute idle TTL. + now = 61_000; + expect(yield* remount).toBe("after edit"); + expect(fetches).toBe(2); + }), + ), + ); + + it.effect("keeps its cache when collected while unmounted, inside its stale time", () => + Effect.scoped( + Effect.gen(function* () { + NodeV8.setFlagsFromString("--expose-gc"); + const gc = NodeVM.runInNewContext("gc") as () => void; + using _now = vi.spyOn(Date, "now").mockReturnValue(0); + let fetches = 0; + const harness = yield* makeEnvironmentQueryHarness( + Effect.sync(() => { + fetches += 1; + return fetches; + }), + ); + const registry = AtomRegistry.make(); + yield* Effect.addFinalizer(() => Effect.sync(() => registry.dispose())); + // `Atom.family` holds members weakly, so the test must hold none: another environment + // than `harness.atom`'s, which the harness holds. + const environmentId = EnvironmentId.make("unheld-environment"); + const query = () => harness.family({ environmentId, input: undefined }); + const first = new WeakRef(query()); + const remount = Effect.gen(function* () { + const unmount = registry.mount(query()); + yield* Effect.yieldNow; + const value = yield* readEnvironmentQuery(registry, query()); + unmount(); + return value; + }); + // A WeakRef target survives until the current job ends, so collect after it. + const collect = Effect.yieldNow.pipe(Effect.andThen(Effect.sync(gc))); + + expect(yield* remount).toBe(1); + yield* collect; + expect(yield* remount).toBe(1); + yield* collect; + expect(first.deref()).toBe(query()); + expect(fetches).toBe(1); + }), + ), + ); + it.effect.each([true, false])("loads thread link metadata with server support %s", (supported) => Effect.scoped( Effect.gen(function* () { diff --git a/packages/client-runtime/src/state/runtime.ts b/packages/client-runtime/src/state/runtime.ts index 5b09e48bd73fe..736b10fdeeaca 100644 --- a/packages/client-runtime/src/state/runtime.ts +++ b/packages/client-runtime/src/state/runtime.ts @@ -11,7 +11,6 @@ import { import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; -import { identity } from "effect/Function"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; @@ -97,13 +96,6 @@ interface EnvironmentQueryAtomOptions extends Omit< readonly persistence?: QueryPersistence; readonly staleTimeMs?: number; readonly idleTtlMs?: number; - /** - * Check staleness each time the query is mounted again. By default the idle - * TTL keeps the stale-while-revalidate node warm, so a remount within it - * reads the cached value without checking. The cached value still shows - * while the query revalidates. - */ - readonly revalidateOnRemount?: boolean; readonly refreshIntervalMs?: number; readonly refreshTrigger?: (target: { readonly environmentId: EnvironmentIdType; @@ -567,6 +559,45 @@ const refreshEveryUntilFinal = { initialValueTarget: self }, ); +const isStale = (result: AsyncResult.AsyncResult, staleTimeMs: number) => { + if (result.waiting) return false; + const success = + result._tag === "Failure" ? Option.getOrUndefined(result.previousSuccess) : result; + if (success?._tag !== "Success") return result._tag === "Failure"; + // @effect-diagnostics-next-line globalDate:off - `AsyncResult` timestamps come from `Date.now`. + return Date.now() - success.timestamp >= staleTimeMs; +}; + +/** + * Stale-while-revalidate on every mount. `Atom.swr` checks only when its node builds, which an idle + * TTL skips on a remount; this layer has no TTL (a transform drops it) while `self` keeps the cache. + * It reads `self` as a dependency, not a subscription, so `self` goes idle with its last reader. + */ +const revalidateOnMount = + (staleTimeMs: number) => + ( + self: Atom.Atom>, + ): Atom.Atom> => + Atom.transform( + self, + (get) => { + const current = get(self); + if (Option.isNone(get.self()) && isStale(current, staleTimeMs)) { + let active = true; + get.addFinalizer(() => { + active = false; + }); + // Deferred out of the build, which runs while React renders. A plain read drops this + // node before the task runs, so only a mount refetches. + get.registry.schedulerAsync.makeDispatcher().scheduleTask(() => { + if (active && isStale(get.once(self), staleTimeMs)) get.refresh(self); + }, 0); + } + return current; + }, + { initialValueTarget: self }, + ); + /** * A query result's data alone. A refetch always publishes a new result (a new * timestamp, `waiting` on and off), while equal data keeps its identity, so a @@ -685,15 +716,12 @@ export function createEnvironmentQueryAtomFamily( readonly input: Input; }) => Atom.Atom> { const connectionAtom = environmentConnectionAtoms(runtime); + // `Atom.family` holds members weakly and the mounted layer has no TTL, so each member lives + // as long as its cache: keyed weakly, this lets both go once the cache's TTL ends. + const membersByCache = new WeakMap, Atom.Atom>(); const family = Atom.family((key: string) => { const target = parseEnvironmentRpcKey(key); const idleTtlMs = options.idleTtlMs ?? 5 * 60_000; - // `Atom.swr` checks staleness only when its node builds. To revalidate on - // remount, the wrappers drop on unmount and the source alone keeps the - // cached value warm. - const keepWarm = Atom.setIdleTTL(idleTtlMs); - const keepSourceWarm = options.revalidateOnRemount ? keepWarm : identity; - const keepWrapperWarm = options.revalidateOnRemount ? identity : keepWarm; const queryAtom = runtime .atom< A, @@ -817,14 +845,7 @@ export function createEnvironmentQueryAtomFamily( ); }); }) - .pipe( - keepSourceWarm, - Atom.swr({ - staleTime: options.staleTimeMs ?? 30_000, - revalidateOnMount: true, - }), - keepWrapperWarm, - ); + .pipe(Atom.setIdleTTL(idleTtlMs)); const isFinal = (result: AsyncResult.AsyncResult) => options.isFinal !== undefined && Option.exists(AsyncResult.value(result), options.isFinal); const intervalQuery = @@ -832,11 +853,17 @@ export function createEnvironmentQueryAtomFamily( ? queryAtom : queryAtom.pipe(refreshEveryUntilFinal(options.refreshIntervalMs, isFinal)); const refreshTrigger = options.refreshTrigger?.(target); - return ( + const cache = ( refreshTrigger === undefined ? intervalQuery : intervalQuery.pipe(refreshOnSignalWhileRead(refreshTrigger, isFinal)) - ).pipe(keepWrapperWarm, Atom.withLabel(`${options.label}:${key}`)); + ).pipe(Atom.setIdleTTL(idleTtlMs)); + const member = cache.pipe( + revalidateOnMount(options.staleTimeMs ?? 30_000), + Atom.withLabel(`${options.label}:${key}`), + ); + membersByCache.set(cache, member); + return member; }); return (target) => family(environmentRpcKey(target)); } @@ -934,7 +961,6 @@ export function createEnvironmentRpcQueryAtomFamily< readonly persistence?: QueryPersistence>; readonly staleTimeMs?: number; readonly idleTtlMs?: number; - readonly revalidateOnRemount?: boolean; readonly refreshIntervalMs?: number; readonly refreshTrigger?: (target: { readonly environmentId: EnvironmentIdType; @@ -949,9 +975,6 @@ export function createEnvironmentRpcQueryAtomFamily< ...(options.persistence === undefined ? {} : { persistence: options.persistence }), ...(options.staleTimeMs === undefined ? {} : { staleTimeMs: options.staleTimeMs }), ...(options.idleTtlMs === undefined ? {} : { idleTtlMs: options.idleTtlMs }), - ...(options.revalidateOnRemount === undefined - ? {} - : { revalidateOnRemount: options.revalidateOnRemount }), ...(options.refreshIntervalMs === undefined ? {} : { refreshIntervalMs: options.refreshIntervalMs }), From b23f94e4a1684ba28b12357d6eeb756faefc5500 Mon Sep 17 00:00:00 2001 From: Entrpi <133157614+Entrpi@users.noreply.github.com> Date: Fri, 13 Nov 2026 14:32:48 +1100 Subject: [PATCH 021/390] fix(server): preview resize and status report the real size of desktop tabs (#32854) Co-authored-by: Julius Marminge --- apps/server/src/preview/ServerBrowser.test.ts | 129 ++++++++++++++++++ apps/server/src/preview/ServerBrowser.ts | 85 ++++++++++-- packages/contracts/src/previewAutomation.ts | 4 +- 3 files changed, 207 insertions(+), 11 deletions(-) diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index c527bc93381ef..181f4904738f8 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -14,6 +14,7 @@ import { ProviderInstanceId, ThreadId, type PreviewAutomationLoadError, + type PreviewAutomationResizeResult, type PreviewAutomationSnapshot, type PreviewAutomationStatus, } from "@t3tools/contracts"; @@ -2405,6 +2406,134 @@ it.effect( ).pipe(Effect.provide(layer)), ); +it.effect("a desktop tab reports the size the desktop draws after a resize", () => + Effect.scoped( + Effect.gen(function* () { + const { broker } = yield* ready; + const manager = yield* Manager.PreviewManager; + const failReads = () => Promise.reject(new Error("Cannot find context with specified id")); + desktopSizeAnswer = failReads; + desktopRendersNext = true; + const opened = yield* broker.invoke({ + scope, + operation: "open", + input: { reuseExistingTab: false, show: false }, + }); + const tabId = PreviewTabId.make(opened.tabId!); + const { context } = desktopConnections.at(-1)!; + const cdp = context.sessions[0]!; + const events = yield* manager.subscribeEvents; + const status = broker + .invoke({ scope, tabId, operation: "status", input: {} }) + .pipe(Effect.map((result) => result.viewport)); + // The desktop draws the new size once the session update reaches it. + const resize = (input: Record, width: number, height: number) => + Effect.gen(function* () { + const result = yield* broker + .invoke({ scope, tabId, operation: "resize", input }) + .pipe(Effect.forkScoped); + let event = yield* PubSub.take(events); + while (event.type !== "resized" || event.tabId !== tabId) { + event = yield* PubSub.take(events); + } + Object.assign(context.cssViewport, { width, height }); + yield* Effect.promise(() => cdp.emitAsync("Page.frameResized")); + return (yield* Fiber.join(result)).viewport; + }); + + // A size no read measured after the resize is unknown, never a default or the last one. + expect( + yield* resize({ mode: "freeform", width: 900, height: 600 }, 900, 600), + ).toBeUndefined(); + desktopSizeAnswer = null; + expect(yield* resize({ mode: "freeform", width: 1280, height: 760 }, 1280, 760)).toEqual({ + width: 1280, + height: 760, + }); + desktopSizeAnswer = failReads; + expect(yield* status).toEqual({ width: 1280, height: 760 }); + expect( + yield* resize({ mode: "freeform", width: 1000, height: 700 }, 1000, 700), + ).toBeUndefined(); + desktopSizeAnswer = null; + // A page can keep another size than the fixed one asked for; both report the page's. + expect(yield* resize({ mode: "freeform", width: 844, height: 390 }, 560, 760)).toEqual({ + width: 560, + height: 760, + }); + expect(yield* status).toEqual({ width: 560, height: 760 }); + expect(yield* resize({ mode: "fill" }, 1234, 700)).toEqual({ width: 1234, height: 700 }); + // No page resize or load since that read, so status answers without reading again. + let reads = 0; + desktopSizeAnswer = async () => { + reads += 1; + return { result: { value: { width: 1, height: 1 } } }; + }; + expect(yield* status).toEqual({ width: 1234, height: 700 }); + expect(reads).toBe(0); + // A status during a read waits for it rather than answer the size before it. + const pending = yield* Queue.unbounded>>(); + const heldAnswer = () => { + const read = Promise.withResolvers>(); + Queue.offerUnsafe(pending, read); + return read.promise; + }; + desktopSizeAnswer = heldAnswer; + const answer = (width: number, height: number) => ({ result: { value: { width, height } } }); + const refill = broker + .invoke({ + scope, + tabId, + operation: "resize", + input: { mode: "fill" }, + }) + .pipe( + Effect.map((result) => result.viewport), + Effect.forkScoped, + ); + // Status reads the URL just before it picks the size. + const url = context.page.url(); + using urlRead = vi.spyOn(context.page, "url"); + const startStatus = Effect.gen(function* () { + const started = Promise.withResolvers(); + urlRead.mockImplementationOnce(() => { + started.resolve(); + return url; + }); + const fiber = yield* Effect.forkScoped(status); + yield* Effect.promise(() => started.promise); + return fiber; + }); + const filled = yield* refill; + const read = yield* Queue.take(pending); + const during = yield* startStatus; + read.resolve(answer(1100, 650)); + expect(yield* Fiber.join(during)).toEqual({ width: 1100, height: 650 }); + expect(yield* Fiber.join(filled)).toEqual({ width: 1100, height: 650 }); + // A page resize during that read makes its answer old: status reads again, and the + // resize result has no size. + const outdated = yield* refill; + const oldRead = yield* Queue.take(pending); + const later = yield* startStatus; + yield* Effect.promise(() => cdp.emitAsync("Page.frameResized")); + desktopSizeAnswer = async () => answer(1000, 600); + oldRead.resolve(answer(1100, 650)); + expect(yield* Fiber.join(later)).toEqual({ width: 1000, height: 600 }); + expect(yield* Fiber.join(outdated)).toBeUndefined(); + // So does a load during that read, which may have measured the page before it. + desktopSizeAnswer = heldAnswer; + const reloaded = yield* refill; + const pageRead = yield* Queue.take(pending); + const navigated = yield* startStatus; + yield* Effect.promise(() => cdp.emitAsync("Page.loadEventFired")); + desktopSizeAnswer = async () => answer(900, 560); + pageRead.resolve(answer(1000, 600)); + expect(yield* Fiber.join(navigated)).toEqual({ width: 900, height: 560 }); + expect(yield* Fiber.join(reloaded)).toBeUndefined(); + }), + ).pipe(Effect.provide(layer)), +); + it.effect("a stalled desktop viewer still gets its size, control and reconnect", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 5d67c8ddc33fc..e801aad619e55 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -120,6 +120,8 @@ const AGENT_CURSOR_MOVE_MS = 160; const AGENT_CURSOR_CLICK_LEAD_MS = 40; /** A dialog blocks page script, so a desktop size read gives up after this. */ const DESKTOP_VIEWPORT_READ_TIMEOUT = "1 second"; +/** A desktop page that keeps its size draws nothing on a resize, so the wait gives up. */ +const DESKTOP_RESIZE_WAIT_MS = 1000; // A revealed tab's client opens the panel and attaches its stream a few round trips later, remote ones included. const REVEAL_VIEWER_WAIT_MS = 1000; @@ -363,6 +365,12 @@ interface ServerTab { desktopViewportRead: Promise; /** A read queued behind `desktopViewportRead`, shared by every caller that waits for it. */ desktopViewportNext: Promise | null; + /** A page resize or load since the last read started; status measures again. */ + desktopViewportStale: boolean; + /** Counts page resizes, loads and dialog closes, so a caller can tell one landed during a read. */ + desktopViewportChanges: number; + /** Settles at the desktop page's next main-frame resize. */ + desktopResize: PromiseWithResolvers; loading: boolean; loadError: PreviewAutomationLoadError | undefined; closing: boolean; @@ -679,7 +687,7 @@ const make = Effect.gen(function* () { }; // Desktop pages have no Playwright viewport; an isolated world keeps page script - // from overriding the CSS size, and failed reads preserve the last usable size. + // from overriding the CSS size. A failed read answers null and keeps the last usable size. const readDesktopViewport = async (tab: ServerTab) => { const readSize = async () => { const { frameTree } = await tab.cdp.send("Page.getFrameTree"); @@ -704,20 +712,26 @@ const make = Effect.gen(function* () { const width = size?.width; const height = size?.height; if (typeof width === "number" && width > 0 && typeof height === "number" && height > 0) - tab.desktopViewport = { width, height }; - return tab.desktopViewport; + return (tab.desktopViewport = { width, height }); + tab.desktopViewportStale = true; + return null; }; // Reads run one at a time, and callers that arrive during a read share the next one. const measureDesktopViewport = (tab: ServerTab) => (tab.desktopViewportNext ??= tab.desktopViewportRead.then(() => { tab.desktopViewportNext = null; + tab.desktopViewportStale = false; return (tab.desktopViewportRead = readDesktopViewport(tab)); })); const layoutSize = async (tab: ServerTab) => { // A dialog blocks page script; its close measures again. - if (tab.desktop) return tab.dialog ? tab.desktopViewport : measureDesktopViewport(tab); + if (tab.desktop) { + return tab.dialog + ? tab.desktopViewport + : ((await measureDesktopViewport(tab)) ?? tab.desktopViewport); + } const size = tab.page.viewportSize(); if (!size) return size; return { @@ -726,6 +740,15 @@ const make = Effect.gen(function* () { }; }; + // Page resizes and loads mark the size stale; only then does status read the page. One + // during the read it waits for can leave that answer old, so it reads once more. + const desktopStatusViewport = async (tab: ServerTab) => { + const changes = tab.desktopViewportChanges; + await (tab.desktopViewportStale || tab.dialog ? layoutSize(tab) : tab.desktopViewportRead); + if (tab.desktopViewportChanges !== changes) await layoutSize(tab); + return tab.desktopViewport; + }; + const applyZoom = async (tab: ServerTab) => { if (tab.desktop) return; const size = tab.page.viewportSize(); @@ -975,6 +998,9 @@ const make = Effect.gen(function* () { desktopViewport: null, desktopViewportRead: Promise.resolve(null), desktopViewportNext: null, + desktopViewportStale: true, + desktopViewportChanges: 0, + desktopResize: Promise.withResolvers(), loading: false, loadError: undefined, closing: false, @@ -993,9 +1019,16 @@ const make = Effect.gen(function* () { if (desktop) { // The desktop resizes and zooms its webview itself; viewers map input with the new size. const remeasure = () => { + tab.desktopViewportStale = true; + tab.desktopViewportChanges += 1; if (tab.viewers.size > 0) return broadcastViewport(tab).catch(constVoid); }; - cdp.on("Page.frameResized", remeasure); + cdp.on("Page.frameResized", () => { + const drawn = tab.desktopResize; + tab.desktopResize = Promise.withResolvers(); + drawn.resolve(); + return remeasure(); + }); // A read a navigation broke has a fresh page to measure. cdp.on("Page.loadEventFired", remeasure); // Closing a dialog resizes nothing, and the host can close it without us. @@ -1804,7 +1837,7 @@ const make = Effect.gen(function* () { }; } const url = ServerBrowserPage.withoutDataPayload(tab.page.url()); - const viewport = tab.desktop ? await layoutSize(tab) : tab.page.viewportSize(); + const viewport = tab.desktop ? await desktopStatusViewport(tab) : tab.page.viewportSize(); const loadError = loadErrorOf(tab); const status = { available: true, @@ -2420,7 +2453,7 @@ const make = Effect.gen(function* () { message: "A browser dialog is pending. Read preview_status.", reason: "dialogPending", }); - return executeTabOperation(tab, request); + return executeTabOperation(tab, request, deadlineMs); }); } return tab.control.agent( @@ -2436,7 +2469,7 @@ const make = Effect.gen(function* () { tab.agentOperating = !READ_OPERATIONS.has(request.operation); updateFileChooserInterception(tab); try { - return await executeTabOperation(tab, request); + return await executeTabOperation(tab, request, deadlineMs); } finally { tab.agentOperating = false; updateFileChooserInterception(tab); @@ -2447,7 +2480,11 @@ const make = Effect.gen(function* () { ); }; - const executeTabOperation = async (tab: ServerTab, request: PreviewAutomationRequest) => { + const executeTabOperation = async ( + tab: ServerTab, + request: PreviewAutomationRequest, + deadlineMs: number, + ) => { const input = request.input; switch (request.operation) { case "navigate": { @@ -2464,14 +2501,40 @@ const make = Effect.gen(function* () { } case "resize": { const setting = resolvePreviewViewport(input as PreviewAutomationResizeInput); + const from = fixedViewportSize(tab.setting); + const to = fixedViewportSize(setting); + const drawn = tab.desktopResize.promise; await runPromise( manager.resize({ threadId: tab.threadId, tabId: tab.tabId, viewport: setting }), ); await applySetting(tab, setting); + if (!tab.desktop) { + return { + tabId: tab.tabId, + setting, + viewport: tab.page.viewportSize() ?? UNATTACHED_FILL_VIEWPORT, + }; + } + const changed = from?.width !== to?.width || from?.height !== to?.height; + const remainingMs = () => Math.max(0, deadlineMs - performance.now()); + const within = (promise: Promise, ms: number) => + runPromise( + Effect.promise(() => promise).pipe( + Effect.timeoutOption(ms), + Effect.map(Option.getOrNull), + ), + ); + // The desktop draws the new size after the session update; its page resize says it has. + // Half the time left keeps room for the read, which runs even if nothing was drawn, + // because a page can keep another size than the one asked for. + if (changed) await within(drawn, Math.min(DESKTOP_RESIZE_WAIT_MS, remainingMs() / 2)); + const changes = tab.desktopViewportChanges; + const measured = await within(measureDesktopViewport(tab), remainingMs()); + // Only a read after the draw, with no page resize or load during it, measures this resize. return { tabId: tab.tabId, setting, - viewport: tab.page.viewportSize() ?? UNATTACHED_FILL_VIEWPORT, + ...(measured && tab.desktopViewportChanges === changes ? { viewport: measured } : {}), }; } case "setColorScheme": { @@ -2592,6 +2655,8 @@ const make = Effect.gen(function* () { return; } if (event.type === "resized") { + // Desktop tabs only record the setting, so status need not wait behind an agent action. + if (tab.desktop) tab.setting = event.snapshot.viewport ?? FILL_PREVIEW_VIEWPORT; await tab.control .system(async () => { if ( diff --git a/packages/contracts/src/previewAutomation.ts b/packages/contracts/src/previewAutomation.ts index 6376b51019b43..2b8df15c42cba 100644 --- a/packages/contracts/src/previewAutomation.ts +++ b/packages/contracts/src/previewAutomation.ts @@ -262,7 +262,9 @@ export type PreviewAutomationResizeInput = typeof PreviewAutomationResizeInput.T export const PreviewAutomationResizeResult = Schema.Struct({ tabId: PreviewTabId, setting: PreviewViewportSetting, - viewport: PreviewRenderedViewportSize, + viewport: Schema.optional(PreviewRenderedViewportSize).annotate({ + description: "Viewport size after the resize. Omitted when it could not be measured.", + }), }); export type PreviewAutomationResizeResult = typeof PreviewAutomationResizeResult.Type; From 496856d8d7dadbc0e947b7d80ae40af1042c84fc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 19:33:00 -0800 Subject: [PATCH 022/390] fix(server): migration 64 skips thread payloads that are not valid JSON (#35884) --- ...CanonicalizeThreadPullRequestLinks.test.ts | 27 +++++++++++++++++++ .../064_CanonicalizeThreadPullRequestLinks.ts | 2 ++ 2 files changed, 29 insertions(+) diff --git a/apps/server/src/persistence/Migrations/064_CanonicalizeThreadPullRequestLinks.test.ts b/apps/server/src/persistence/Migrations/064_CanonicalizeThreadPullRequestLinks.test.ts index b53309c187ed3..3bdb3159213d5 100644 --- a/apps/server/src/persistence/Migrations/064_CanonicalizeThreadPullRequestLinks.test.ts +++ b/apps/server/src/persistence/Migrations/064_CanonicalizeThreadPullRequestLinks.test.ts @@ -123,9 +123,36 @@ layer("064_CanonicalizeThreadPullRequestLinks", (it) => { 'user', ${v1Payload}, '{}', 1) `; + yield* sql` + INSERT INTO orchestration_v2_projection_threads ( + thread_id, project_id, title, default_provider, runtime_mode, interaction_mode, + created_at, updated_at, payload_json + ) VALUES ( + 'damaged', 'project-1', 'damaged', 'codex', 'full-access', 'default', + ${updatedAt}, ${updatedAt}, '' + ) + `; + yield* sql` + INSERT INTO orchestration_events ( + event_id, aggregate_kind, stream_id, stream_version, event_type, occurred_at, + actor_kind, payload_json, metadata_json, application_event_version + ) VALUES ('event:damaged', 'thread', 'damaged', 0, 'thread.created', ${updatedAt}, + 'user', '', '{}', 2) + `; + assert.deepEqual(yield* runMigrations({ toMigrationInclusive: 64 }), [ [64, "CanonicalizeThreadPullRequestLinks"], ]); + const [damagedThread] = yield* sql<{ readonly payload_json: string }>` + SELECT payload_json FROM orchestration_v2_projection_threads WHERE thread_id = 'damaged' + `; + const [damagedEvent] = yield* sql<{ readonly payload_json: string }>` + SELECT payload_json FROM orchestration_events WHERE event_id = 'event:damaged' + `; + assert.equal(damagedThread?.payload_json, ""); + assert.equal(damagedEvent?.payload_json, ""); + yield* sql`DELETE FROM orchestration_v2_projection_threads WHERE thread_id = 'damaged'`; + yield* sql`DELETE FROM orchestration_events WHERE event_id = 'event:damaged'`; assert.deepEqual(yield* runMigrations({ toMigrationInclusive: 64 }), []); const rows = yield* sql<{ readonly thread_id: string; readonly payload_json: string }>` SELECT thread_id, payload_json FROM orchestration_v2_projection_threads diff --git a/apps/server/src/persistence/Migrations/064_CanonicalizeThreadPullRequestLinks.ts b/apps/server/src/persistence/Migrations/064_CanonicalizeThreadPullRequestLinks.ts index 9364a8d817c4b..a4d8fb9e7a016 100644 --- a/apps/server/src/persistence/Migrations/064_CanonicalizeThreadPullRequestLinks.ts +++ b/apps/server/src/persistence/Migrations/064_CanonicalizeThreadPullRequestLinks.ts @@ -34,6 +34,7 @@ export default Effect.gen(function* () { updated_at FROM orchestration_v2_projection_threads WHERE thread_id > ${afterThreadId} + AND json_valid(payload_json) AND json_type(payload_json, '$.pullRequests') IS NULL AND json_type(payload_json, '$.linkedPullRequest') = 'object' ORDER BY thread_id @@ -65,6 +66,7 @@ export default Effect.gen(function* () { WHERE sequence > ${afterSequence} AND application_event_version = 2 AND aggregate_kind = 'thread' AND event_type LIKE 'thread.%' + AND json_valid(payload_json) AND json_type(payload_json, '$.pullRequests') IS NULL AND json_type(payload_json, '$.linkedPullRequest') = 'object' ORDER BY sequence From 8a38c637069ff8c70f925dfc52220363f482944d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 19:33:13 -0800 Subject: [PATCH 023/390] fix(web): thread navigation shortcuts work from the terminal and macOS composer (#26433) --- ...rPromptEditorTiptap.input.browser.test.tsx | 39 ++++++++++++++++ .../components/ComposerPromptEditorTiptap.tsx | 10 +++++ .../ThreadTerminalDrawer.browser.test.tsx | 45 ++++++++++++++++++- .../src/components/ThreadTerminalDrawer.tsx | 9 +++- apps/web/src/keybindings.ts | 14 +++++- 5 files changed, 114 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx index bc2ea348b4d2a..cd01804f8efe3 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx @@ -26,6 +26,22 @@ import { type ComposerPromptEditorProps, } from "./ComposerPromptEditorTiptap"; +vi.mock(import("../state/server"), async (original) => { + const { Atom } = await import("effect/reactivity"); + const { compileResolvedKeybindingsConfig, DEFAULT_KEYBINDINGS } = + await import("@t3tools/shared/keybindings"); + return { + ...(await original()), + primaryServerKeybindingsAtom: Atom.make( + compileResolvedKeybindingsConfig([ + ...DEFAULT_KEYBINDINGS, + { key: "mod+arrowup", command: "thread.previous" }, + { key: "mod+arrowdown", command: "thread.next" }, + ]), + ), + }; +}); + const babysit: ServerProviderSkill = { name: "babysit", path: "/skills/babysit/SKILL.md", @@ -271,6 +287,29 @@ it.each([ }, ); +// The mocked server keybindings bind Command-Up and Command-Down to thread navigation. +it("leaves Command-arrow thread navigation bindings to the app on macOS", async () => { + using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("MacIntel"); + await using screen = await mountEditor("first\nsecond"); + const { editor } = screen; + await act(async () => { + editor.commands.focus("end"); + }); + await vi.waitFor(() => expect(document.activeElement).toBe(editor.view.dom)); + const reachedApp: string[] = []; + const onWindowKeyDown = (event: KeyboardEvent) => { + if (!event.defaultPrevented && event.key.startsWith("Arrow")) reachedApp.push(event.key); + }; + window.addEventListener("keydown", onWindowKeyDown); + using _listener = { + [Symbol.dispose]: () => window.removeEventListener("keydown", onWindowKeyDown), + }; + + await userEvent.keyboard("{Meta>}{ArrowUp}{ArrowDown}{/Meta}"); + + expect(reachedApp).toEqual(["ArrowUp", "ArrowDown"]); +}); + it.each([ ["e", false, false], ["i", false, false], diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index a974c9308844f..5a036e34611ed 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -114,6 +114,9 @@ import { collectInlineContextIds } from "~/lib/composerContextReferences"; import { resolveDiffThemeName } from "~/lib/diffRendering"; import { isRichTextFormattingShortcut } from "~/keybindings"; import { cn, isMacPlatform } from "~/lib/utils"; +import { isThreadNavigationShortcut } from "~/keybindings"; +import { appAtomRegistry } from "~/rpc/atomRegistry"; +import { primaryServerKeybindingsAtom } from "~/state/server"; import { hasCommandForKeyDown } from "~/shortcuts/dispatcher"; import { basenameOfPath } from "~/pierre-icons"; import { WorkspaceEntryTooltip } from "./chat/WorkspaceEntryIcon"; @@ -1143,6 +1146,13 @@ function ComposerPromptEditorTiptapInner( !event.ctrlKey && !event.isComposing ) { + if ( + isThreadNavigationShortcut(event, appAtomRegistry.get(primaryServerKeybindingsAtom), { + context: { editableFocus: true }, + }) + ) { + return false; + } const selection = window.getSelection(); if ( selection?.anchorNode && diff --git a/apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx b/apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx index 9fd2758747533..850b9cb8174f5 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx @@ -4,10 +4,14 @@ import { ThreadId, type ScopedThreadRef, type FilesystemEntryMetadata, + type ResolvedKeybindingsConfig, } from "@t3tools/contracts"; +import { compileResolvedKeybindingsConfig, DEFAULT_KEYBINDINGS } from "@t3tools/shared/keybindings"; import { Atom, AsyncResult } from "effect/reactivity"; import type { GhosttyTerminalSurfaceOptions } from "~/terminal/ghostty/surface"; import { AppAtomRegistryProvider, appAtomRegistry } from "../rpc/atomRegistry"; +import { useRightPanelStore } from "../rightPanelStore"; +import { useTerminalUiStateStore } from "../terminalUiStateStore"; import { extractTerminalLinks } from "../terminal-links"; import { expect, it, vi } from "vite-plus/test"; import { act, fakeTimers, render } from "../../test/render"; @@ -110,6 +114,7 @@ function Drawer(props: { threadRef: ScopedThreadRef; cwd?: string; onCloseTerminal: (terminalId: string, threadRef?: ScopedThreadRef) => void; + keybindings?: ResolvedKeybindingsConfig; }) { return ( {}} onAddTerminalContext={() => {}} - keybindings={[]} + keybindings={props.keybindings ?? []} /> ); } @@ -149,6 +154,8 @@ function setup() { fixture.pendingSurfaces = []; fixture.surfaceOptions = []; fixture.metadata.clear(); + useRightPanelStore.setState(useRightPanelStore.getInitialState()); + useTerminalUiStateStore.setState(useTerminalUiStateStore.getInitialState()); return { original, other }; } @@ -170,6 +177,42 @@ it("closes an exited terminal on its own thread after navigating to one reusing expect(onCloseTerminal.mock.calls).toEqual([["term-1", original]]); }); +// Thread navigation defaults have no `!terminalFocus`, so they apply in a focused terminal. +it("hands thread navigation shortcuts from a focused terminal back to the app", async () => { + const { other: threadRef } = setup(); + using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("Linux x86_64"); + useRightPanelStore.getState().openBrowser(threadRef, null); + await using _screen = await render( + {}} + keybindings={compileResolvedKeybindingsConfig([ + ...DEFAULT_KEYBINDINGS, + { key: "ctrl+r", command: "thread.next", when: "!previewOpen" }, + { key: "ctrl+g", command: "thread.previous", when: "terminalOpen" }, + ])} + />, + ); + await act(async () => { + fixture.pendingSurfaces.shift()?.(); + }); + const terminalHandles = (key: string, code: string, shiftKey = true) => + fixture.surfaceOptions + .at(-1)! + .beforeKey(new KeyboardEvent("keydown", { key, code, ctrlKey: true, shiftKey })); + + expect([terminalHandles("{", "BracketLeft"), terminalHandles("}", "BracketRight")]).toEqual([ + false, + false, + ]); + expect(terminalHandles("C", "KeyC")).toBe(true); + // Sidebar sees the open preview and the closed drawer and rejects these, so the shell keeps them. + expect([terminalHandles("r", "KeyR", false), terminalHandles("g", "KeyG", false)]).toEqual([ + true, + true, + ]); +}); + it("only activates terminal paths confirmed to exist on their environment", async () => { const { other: threadRef } = setup(); const key = (path: string) => JSON.stringify([threadRef.environmentId, path]); diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 6312f88bb2754..de2dc970466d9 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -66,6 +66,7 @@ import { isTerminalSplitShortcut, isTerminalSplitVerticalShortcut, isTerminalToggleShortcut, + isThreadNavigationShortcut, terminalDeleteShortcutData, terminalNavigationShortcutData, } from "../keybindings"; @@ -91,6 +92,7 @@ import { openTerminalLinkInPreview } from "./preview/openTerminalLinkInPreview"; import { useAtomCommand } from "../state/use-atom-command"; import { preventTerminalCloseShortcut } from "../lib/terminalCloseShortcut"; import { observeResize } from "../lib/observeResize"; +import { threadShortcutContext } from "./Sidebar.logic"; import { consumePendingTerminalFocus, focusTerminal, @@ -575,6 +577,7 @@ function TerminalSessionViewport({ const hasHandledExitRef = useRef(false); const selectionActionRequestIdRef = useRef(0); const readKeybindings = useEffectEvent(() => keybindings); + const readThreadShortcutContext = useEffectEvent(() => threadShortcutContext(threadRef)); const handleSessionExited = useEffectEvent(() => { if (hasTerminalWriteAccess()) onSessionExited(); }); @@ -938,7 +941,11 @@ function TerminalSessionViewport({ isTerminalSplitShortcut(event, currentKeybindings, options) || isTerminalSplitVerticalShortcut(event, currentKeybindings, options) || isTerminalNewShortcut(event, currentKeybindings, options) || - isDiffToggleShortcut(event, currentKeybindings, options) + isDiffToggleShortcut(event, currentKeybindings, options) || + // Match Sidebar's context, or a key it will reject skips the shell. + isThreadNavigationShortcut(event, currentKeybindings, { + context: { ...readThreadShortcutContext(), terminalFocus: true }, + }) ) { return false; } diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index d2f6a5e650417..849183b7a1a69 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -281,12 +281,24 @@ export function threadTraversalDirectionFromCommand( return null; } +export function isThreadNavigationShortcut( + event: ShortcutEventLike, + keybindings: ResolvedKeybindingsConfig, + options?: ShortcutMatchOptions, +): boolean { + const command = resolveShortcutCommand(event, keybindings, options); + return ( + threadTraversalDirectionFromCommand(command) !== null || + threadJumpIndexFromCommand(command ?? "") !== null + ); +} + export function shouldShowThreadJumpHintsForModifiers( modifiers: ShortcutModifierStateLike, keybindings: ResolvedKeybindingsConfig, options?: ShortcutMatchOptions, ): boolean { - // Ghostty can write keys to the shell before window shortcuts run, regardless of the when clause. + // Holding a modifier is routine in a shell (Ctrl+C, Ctrl+R), so don't flash hints over it. if (resolveContext(options).terminalFocus) { return false; } From 025942d76a53c739fa58e4e90a552c55606551f3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 19:34:17 -0800 Subject: [PATCH 024/390] fix(desktop): an unreadable settings file is no longer overwritten by a bounds save (#35890) --- .../src/settings/DesktopAppSettings.test.ts | 44 ++++++++++++++++ .../src/settings/DesktopAppSettings.ts | 52 ++++++++++++++----- 2 files changed, 83 insertions(+), 13 deletions(-) diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index 868e1c139fbdd..1d28ed1862385 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -4,6 +4,7 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as Tracer from "effect/Tracer"; @@ -160,6 +161,49 @@ describe("DesktopSettings", () => { ), ); + it.effect("keeps saved preferences when a read fails before a bounds save", () => + withSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + yield* writeSettingsPatch({ serverExposureMode: "network-accessible" }); + const cause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "readFileString", + pathOrDescriptor: environment.desktopSettingsPath, + }); + let failRead = true; + const settings = yield* DesktopAppSettings.make.pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.FileSystem.of({ + ...fileSystem, + readFileString: (path) => + Effect.suspend(() => { + if (failRead && path === environment.desktopSettingsPath) { + failRead = false; + return Effect.fail(cause); + } + return fileSystem.readFileString(path); + }), + }), + ), + ); + + yield* Effect.exit(settings.load); + yield* Effect.exit( + settings.setMainWindowBounds({ x: 10, y: 20, width: 900, height: 600 }, false), + ); + + const persisted = yield* decodeDesktopSettingsPatch( + yield* fileSystem.readFileString(environment.desktopSettingsPath), + ); + assert.equal(persisted.serverExposureMode, "network-accessible"); + }), + ), + ); + it.effect("loads lenient persisted desktop settings JSON", () => withSettings( Effect.gen(function* () { diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index 4c2ae8d1d1342..1bb8a2521d881 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -13,6 +13,8 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as SynchronizedRef from "effect/SynchronizedRef"; @@ -379,15 +381,20 @@ function applyWslWindowsFallback(settings: DesktopSettings): DesktopSettings { return setWslOnly(setWslBackendEnabled(settings, false), false); } +// Fails only when the file may exist but could not be read; a missing file means defaults. function readSettings( fileSystem: FileSystem.FileSystem, settingsPath: string, appVersion: string, -): Effect.Effect { +): Effect.Effect { const defaultSettings = resolveDefaultDesktopSettings(appVersion); return fileSystem.readFileString(settingsPath).pipe( - Effect.option, + Effect.asSome, + Effect.catchIf( + (cause) => cause.reason._tag === "NotFound", + () => Effect.succeed(Option.none()), + ), Effect.flatMap( Option.match({ onNone: () => Effect.succeed(defaultSettings), @@ -476,6 +483,8 @@ export const make = Effect.gen(function* () { const path = yield* Path.Path; const random = yield* SecureRandom.SecureRandom; const settingsRef = yield* SynchronizedRef.make(environment.defaultDesktopSettings); + // Writing over a settings file we could not read would erase the preferences it holds. + const persistenceEnabled = yield* Ref.make(true); const updateInMemory = (update: (settings: DesktopSettings) => DesktopSettings) => SynchronizedRef.modify(settingsRef, (settings) => { @@ -492,17 +501,23 @@ export const make = Effect.gen(function* () { return Effect.succeed([settingsChange(settings, false), settings] as const); } - return random.uuidV4().pipe( - Effect.map((uuid) => uuid.replace(/-/g, "")), - Effect.flatMap((suffix) => - writeSettings({ - fileSystem, - path, - settingsPath: environment.desktopSettingsPath, - settings: nextSettings, - defaultSettings: environment.defaultDesktopSettings, - suffix, - }), + return Ref.get(persistenceEnabled).pipe( + Effect.flatMap((enabled) => + enabled + ? random.uuidV4().pipe( + Effect.map((uuid) => uuid.replace(/-/g, "")), + Effect.flatMap((suffix) => + writeSettings({ + fileSystem, + path, + settingsPath: environment.desktopSettingsPath, + settings: nextSettings, + defaultSettings: environment.defaultDesktopSettings, + suffix, + }), + ), + ) + : Effect.void, ), Effect.as([settingsChange(nextSettings, true), nextSettings] as const), ); @@ -515,6 +530,17 @@ export const make = Effect.gen(function* () { fileSystem, environment.desktopSettingsPath, environment.appVersion, + ).pipe( + Effect.catch((cause) => + Effect.logWarning( + "Could not read desktop settings; using defaults without saving changes.", + cause, + ).pipe( + Effect.annotateLogs({ settingsPath: environment.desktopSettingsPath }), + Effect.andThen(Ref.set(persistenceEnabled, false)), + Effect.as(resolveDefaultDesktopSettings(environment.appVersion)), + ), + ), ); return yield* SynchronizedRef.setAndGet(settingsRef, settings); }).pipe(Effect.withSpan("desktop.settings.load")), From 6f108e8556194e135a714604f4c37f2a99c271c7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 19:35:04 -0800 Subject: [PATCH 025/390] fix(codex): accept hook event names newer than the pinned protocol (#35885) --- .../scripts/generate.ts | 2 + .../src/_generated/schema.gen.ts | 155 +++--------------- .../src/schema.test.ts | 25 +++ 3 files changed, 47 insertions(+), 135 deletions(-) diff --git a/packages/effect-codex-app-server/scripts/generate.ts b/packages/effect-codex-app-server/scripts/generate.ts index 343738c800c83..fcf0617095d0d 100644 --- a/packages/effect-codex-app-server/scripts/generate.ts +++ b/packages/effect-codex-app-server/scripts/generate.ts @@ -145,6 +145,8 @@ const ManualSchemas: Record = { // `account/read` decode and take the provider down with it. const DefinitionOverrides: Record = { PlanType: { type: "string" }, + // Newer Codex releases add hook events (e.g. `preModelRequest`); a closed enum drops their notifications. + HookEventName: { type: "string" }, // Newer custom Codex binaries emit partial answers before additional tools or text. MessagePhase: { description: diff --git a/packages/effect-codex-app-server/src/_generated/schema.gen.ts b/packages/effect-codex-app-server/src/_generated/schema.gen.ts index 50fa95918b430..61232b5132be2 100644 --- a/packages/effect-codex-app-server/src/_generated/schema.gen.ts +++ b/packages/effect-codex-app-server/src/_generated/schema.gen.ts @@ -2782,33 +2782,10 @@ export const ServerNotification__HookOutputEntryKind = Schema.Literals([ "error", ]).annotate({ identifier: "ServerNotification__HookOutputEntryKind" }); -export type ServerNotification__HookEventName = - | "preToolUse" - | "permissionRequest" - | "postToolUse" - | "preCompact" - | "postCompact" - | "sessionStart" - | "sessionEnd" - | "userPromptSubmit" - | "subagentStart" - | "subagentStop" - | "stop" - | "interrupt"; -export const ServerNotification__HookEventName = Schema.Literals([ - "preToolUse", - "permissionRequest", - "postToolUse", - "preCompact", - "postCompact", - "sessionStart", - "sessionEnd", - "userPromptSubmit", - "subagentStart", - "subagentStop", - "stop", - "interrupt", -]).annotate({ identifier: "ServerNotification__HookEventName" }); +export type ServerNotification__HookEventName = string; +export const ServerNotification__HookEventName = Schema.String.annotate({ + identifier: "ServerNotification__HookEventName", +}); export type ServerNotification__HookExecutionMode = "sync" | "async"; export const ServerNotification__HookExecutionMode = Schema.Literals(["sync", "async"]).annotate({ @@ -5653,33 +5630,10 @@ export const V2HookCompletedNotification__HookOutputEntryKind = Schema.Literals( "error", ]).annotate({ identifier: "V2HookCompletedNotification__HookOutputEntryKind" }); -export type V2HookCompletedNotification__HookEventName = - | "preToolUse" - | "permissionRequest" - | "postToolUse" - | "preCompact" - | "postCompact" - | "sessionStart" - | "sessionEnd" - | "userPromptSubmit" - | "subagentStart" - | "subagentStop" - | "stop" - | "interrupt"; -export const V2HookCompletedNotification__HookEventName = Schema.Literals([ - "preToolUse", - "permissionRequest", - "postToolUse", - "preCompact", - "postCompact", - "sessionStart", - "sessionEnd", - "userPromptSubmit", - "subagentStart", - "subagentStop", - "stop", - "interrupt", -]).annotate({ identifier: "V2HookCompletedNotification__HookEventName" }); +export type V2HookCompletedNotification__HookEventName = string; +export const V2HookCompletedNotification__HookEventName = Schema.String.annotate({ + identifier: "V2HookCompletedNotification__HookEventName", +}); export type V2HookCompletedNotification__HookExecutionMode = "sync" | "async"; export const V2HookCompletedNotification__HookExecutionMode = Schema.Literals([ @@ -5760,33 +5714,10 @@ export const V2HooksListResponse__HookErrorInfo = Schema.Struct({ path: Schema.String, }).annotate({ identifier: "V2HooksListResponse__HookErrorInfo" }); -export type V2HooksListResponse__HookEventName = - | "preToolUse" - | "permissionRequest" - | "postToolUse" - | "preCompact" - | "postCompact" - | "sessionStart" - | "sessionEnd" - | "userPromptSubmit" - | "subagentStart" - | "subagentStop" - | "stop" - | "interrupt"; -export const V2HooksListResponse__HookEventName = Schema.Literals([ - "preToolUse", - "permissionRequest", - "postToolUse", - "preCompact", - "postCompact", - "sessionStart", - "sessionEnd", - "userPromptSubmit", - "subagentStart", - "subagentStop", - "stop", - "interrupt", -]).annotate({ identifier: "V2HooksListResponse__HookEventName" }); +export type V2HooksListResponse__HookEventName = string; +export const V2HooksListResponse__HookEventName = Schema.String.annotate({ + identifier: "V2HooksListResponse__HookEventName", +}); export type V2HooksListResponse__HookSource = | "system" @@ -5843,33 +5774,10 @@ export const V2HookStartedNotification__HookOutputEntryKind = Schema.Literals([ "error", ]).annotate({ identifier: "V2HookStartedNotification__HookOutputEntryKind" }); -export type V2HookStartedNotification__HookEventName = - | "preToolUse" - | "permissionRequest" - | "postToolUse" - | "preCompact" - | "postCompact" - | "sessionStart" - | "sessionEnd" - | "userPromptSubmit" - | "subagentStart" - | "subagentStop" - | "stop" - | "interrupt"; -export const V2HookStartedNotification__HookEventName = Schema.Literals([ - "preToolUse", - "permissionRequest", - "postToolUse", - "preCompact", - "postCompact", - "sessionStart", - "sessionEnd", - "userPromptSubmit", - "subagentStart", - "subagentStop", - "stop", - "interrupt", -]).annotate({ identifier: "V2HookStartedNotification__HookEventName" }); +export type V2HookStartedNotification__HookEventName = string; +export const V2HookStartedNotification__HookEventName = Schema.String.annotate({ + identifier: "V2HookStartedNotification__HookEventName", +}); export type V2HookStartedNotification__HookExecutionMode = "sync" | "async"; export const V2HookStartedNotification__HookExecutionMode = Schema.Literals([ @@ -7530,33 +7438,10 @@ export const V2PluginReadResponse__AppSummary = Schema.Struct({ identifier: "V2PluginReadResponse__AppSummary", }); -export type V2PluginReadResponse__HookEventName = - | "preToolUse" - | "permissionRequest" - | "postToolUse" - | "preCompact" - | "postCompact" - | "sessionStart" - | "sessionEnd" - | "userPromptSubmit" - | "subagentStart" - | "subagentStop" - | "stop" - | "interrupt"; -export const V2PluginReadResponse__HookEventName = Schema.Literals([ - "preToolUse", - "permissionRequest", - "postToolUse", - "preCompact", - "postCompact", - "sessionStart", - "sessionEnd", - "userPromptSubmit", - "subagentStart", - "subagentStop", - "stop", - "interrupt", -]).annotate({ identifier: "V2PluginReadResponse__HookEventName" }); +export type V2PluginReadResponse__HookEventName = string; +export const V2PluginReadResponse__HookEventName = Schema.String.annotate({ + identifier: "V2PluginReadResponse__HookEventName", +}); export type V2PluginReadResponse__AbsolutePathBuf = string; export const V2PluginReadResponse__AbsolutePathBuf = Schema.String.annotate({ diff --git a/packages/effect-codex-app-server/src/schema.test.ts b/packages/effect-codex-app-server/src/schema.test.ts index dfbedf86af978..65e3cc8894f80 100644 --- a/packages/effect-codex-app-server/src/schema.test.ts +++ b/packages/effect-codex-app-server/src/schema.test.ts @@ -8,6 +8,8 @@ const isThreadReadResponse = Schema.is(CodexSchema.V2ThreadReadResponse); const isThreadResumeResponse = Schema.is(CodexSchema.V2ThreadResumeResponse); const isThreadForkResponse = Schema.is(CodexSchema.V2ThreadForkResponse); const isTurnCompletedNotification = Schema.is(CodexSchema.V2TurnCompletedNotification); +const isHookStartedNotification = Schema.is(CodexSchema.V2HookStartedNotification); +const isHookCompletedNotification = Schema.is(CodexSchema.V2HookCompletedNotification); const decodeThreadResumeResponse = Schema.decodeUnknownSync(CodexSchema.V2ThreadResumeResponse); it("keeps async questions in live notifications and thread history", () => { @@ -239,3 +241,26 @@ it("accepts account plan slugs newer than the pinned protocol", () => { assert.equal(isGetAccountResponse(accountResponse), true); } }); + +it("accepts Codex model-request hook runs", () => { + for (const eventName of ["preModelRequest", "postModelRequest"]) { + const notification = { + threadId: "thread-1", + turnId: "turn-1", + run: { + id: `hook-${eventName}`, + eventName, + executionMode: "sync", + handlerType: "command", + scope: "turn", + sourcePath: "/tmp/project/.codex/hooks.json", + startedAt: 1, + status: "stopped", + displayOrder: 0, + entries: [{ kind: "stop", text: "Stop reason supplied by hook" }], + }, + }; + assert.equal(isHookStartedNotification(notification), true); + assert.equal(isHookCompletedNotification(notification), true); + } +}); From e726a0898a53b8bf3a119357b8d5e34a45392580 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 19:37:23 -0800 Subject: [PATCH 026/390] fix(opencode): /goal clear with no goal set finishes as a no-op (#35893) --- .../Adapters/OpenCode2AdapterV2.test.ts | 24 +++++++++++++++++++ .../src/server/v2/adapter.ts | 13 +++++++--- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index ca382b39b6060..6044306771960 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -7526,6 +7526,30 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { }).pipe(Effect.scoped), ); + // regression: #35872 + it.effect("completes /goal clear when OpenCode has no goal to clear", () => + Effect.gen(function* () { + const { runtime, thread } = yield* resumed( + [ + out("command.list", ""), + reply("command.list", { location: { directory: WORK }, data: [{ name: "goal" }] }), + out("message.list", { sessionID: SESSION, order: "desc", limit: "1" }), + reply("message.list", { data: [], cursor: {} }), + out("session.command", { sessionID: SESSION, name: "goal", text: "clear" }), + reply("session.command", { + status: 500, + body: { _tag: "CommandExecutionError", command: "goal", message: "No goal is set." }, + }), + ], + { version: "2.2.5" }, + ); + const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped); + const input = turnInput(thread); + yield* runtime.startTurn({ ...input, message: { ...input.message, text: "/goal clear" } }); + assert.equal((yield* Fiber.join(terminal))?.status, "completed"); + }).pipe(Effect.scoped), + ); + it.effect("reads an existing OpenCode 2.2.5 goal when resuming a session", () => Effect.gen(function* () { const runtime = yield* openCode2ReplayRuntime( diff --git a/packages/provider-opencode/src/server/v2/adapter.ts b/packages/provider-opencode/src/server/v2/adapter.ts index 612dfd54d73ec..728d2cf68e0aa 100644 --- a/packages/provider-opencode/src/server/v2/adapter.ts +++ b/packages/provider-opencode/src/server/v2/adapter.ts @@ -4488,11 +4488,18 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr const newest = yield* client.message.list({ sessionID, order: "desc", limit: 1 }); yield* markBefore(sessionId, newest.data[0]?.id ?? null); if (!sending()) return; - yield* client.session.command({ sessionID, ...command }); if (goalCommand?.type === "pause" || goalCommand?.type === "clear") { - yield* finishControl; + // OpenCode rejects pausing or clearing a missing goal; Codex treats that as a no-op. + yield* client.session.command({ sessionID, ...command }).pipe( + Effect.catchIf( + (error) => + error._tag === "CommandExecutionError" && error.message === "No goal is set.", + () => Effect.void, + ), + ); + return yield* finishControl; } - return; + return yield* client.session.command({ sessionID, ...command }).pipe(Effect.asVoid); } } const mentions = skillMentions(text); From 6bc3726eb3ba9775dd912a5d76eaab234a64be24 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 19:37:36 -0800 Subject: [PATCH 027/390] fix(ssh): a slow reconnect no longer replaces a healthy port forward (#35874) --- packages/ssh/src/tunnel.test.ts | 30 +++++++++++++++++++++++++----- packages/ssh/src/tunnel.ts | 5 ++++- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index ad020c3bada01..67e3945f1aa5d 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -681,22 +681,31 @@ describe("ssh tunnel scripts", () => { }), ); - it.effect("connects through a tunnel whose responses take longer than a second", () => + it.effect("connects and reuses a tunnel whose responses take over a second", () => Effect.gen(function* () { // Over a ~1s RTT link each request through the forward takes a few round trips. const RESPONSE_MS = 2_500; const tunnelSpawned = yield* Deferred.make(); + const reconnectProbed = yield* Deferred.make(); + const forwardReplaced = yield* Deferred.make(); + let forwards = 0; + let connected = false; const spawner = ChildProcessSpawner.make((command) => commandArgs(command).includes("-N") - ? Deferred.succeed(tunnelSpawned, undefined).pipe( + ? Effect.sync(() => ++forwards).pipe( + Effect.flatMap((count) => + Deferred.succeed(count === 1 ? tunnelSpawned : forwardReplaced, undefined), + ), Effect.as(makeRunningProcess(() => undefined)), ) : Effect.succeed(makeSuccessfulProcess('{"remotePort":3773}\n')), ); const httpClient = HttpClient.make((request) => - Effect.sleep(Duration.millis(RESPONSE_MS)).pipe( - Effect.as(HttpClientResponse.fromWeb(request, new Response("", { status: 200 }))), - ), + Effect.gen(function* () { + if (connected) yield* Deferred.succeed(reconnectProbed, undefined); + yield* Effect.sleep(Duration.millis(RESPONSE_MS)); + return HttpClientResponse.fromWeb(request, new Response("", { status: 200 })); + }), ); const layer = Layer.mergeAll( NodeServices.layer, @@ -726,6 +735,17 @@ describe("ssh tunnel scripts", () => { Result.isSuccess(result), Result.isFailure(result) ? result.failure.message : undefined, ); + if (Result.isFailure(result)) return; + + connected = true; + const reconnect = yield* Effect.forkChild(manager.ensureEnvironment(result.success.target)); + yield* Deferred.await(reconnectProbed); + yield* TestClock.adjust(Duration.seconds(60)); + const outcome = yield* Effect.raceFirst( + Fiber.join(reconnect).pipe(Effect.map((environment) => environment.httpBaseUrl)), + Deferred.await(forwardReplaced).pipe(Effect.as("replaced the forward")), + ); + assert.equal(outcome, result.success.httpBaseUrl); }).pipe(Effect.provide(layer), Effect.scoped); }), ); diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index 8a7071ba1a405..aec0da3c9f77d 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -1859,7 +1859,10 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma remotePort: entry.remotePort, }); const readinessExit = yield* Effect.exit( - waitForHttpReady({ baseUrl: entry.httpBaseUrl, timeoutMs: 2_000 }), + waitForHttpReady({ + baseUrl: entry.httpBaseUrl, + timeoutMs: SSH_TUNNEL_READY_PROBE_TIMEOUT_MS, + }), ); if (Exit.isSuccess(readinessExit)) { yield* Effect.logDebug("ssh.environment.tunnel.reused", { From be03b93bac9b5f21ee1210e6e6f9faf5a551361a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 19:38:04 -0800 Subject: [PATCH 028/390] fix(desktop): one preview viewer or recording stopping no longer freezes the others (#31051) --- apps/desktop/src/preview/CdpRelay.test.ts | 1 + apps/desktop/src/preview/CdpRelay.ts | 13 +- .../src/preview/DesktopBrowserHost.test.ts | 204 +++++++++++++++++- .../desktop/src/preview/DesktopBrowserHost.ts | 61 ++++-- 4 files changed, 259 insertions(+), 20 deletions(-) diff --git a/apps/desktop/src/preview/CdpRelay.test.ts b/apps/desktop/src/preview/CdpRelay.test.ts index 6e657ba5fc4d7..ccc2b868720b4 100644 --- a/apps/desktop/src/preview/CdpRelay.test.ts +++ b/apps/desktop/src/preview/CdpRelay.test.ts @@ -16,6 +16,7 @@ const makeTarget = ( title: () => "Fixture", userAgent: () => "Electron", setDownloadDirectory, + receivesScreencast: () => true, }); const makeRelay = (target: CdpRelayTarget, write: (raw: string) => void) => diff --git a/apps/desktop/src/preview/CdpRelay.ts b/apps/desktop/src/preview/CdpRelay.ts index 94a78f47db686..658e1cf70623d 100644 --- a/apps/desktop/src/preview/CdpRelay.ts +++ b/apps/desktop/src/preview/CdpRelay.ts @@ -9,6 +9,8 @@ export interface CdpRelayTarget { owner?: string, ) => Promise; readonly sessionDetached?: (owner: string) => void; + /** Whether a page session, by owner as `send` names it, gets the page's screencast frames. */ + readonly receivesScreencast: (owner: string | undefined) => boolean; /** The page's own CDP target id; Playwright keys the main frame by it. */ readonly targetId: () => Promise; readonly url: () => string; @@ -260,8 +262,15 @@ export function createCdpRelayConnection( send({ method, params, sessionId }); return; } - for (const [id, kind] of sessions) - if (kind === "page") send({ method, params, sessionId: id }); + for (const [id, kind] of sessions) { + if (kind !== "page") continue; + if ( + method === "Page.screencastFrame" && + !target.receivesScreencast(id === PAGE_SESSION_ID ? undefined : id) + ) + continue; + send({ method, params, sessionId: id }); + } }, }; } diff --git a/apps/desktop/src/preview/DesktopBrowserHost.test.ts b/apps/desktop/src/preview/DesktopBrowserHost.test.ts index 94809f0d10cfa..fbb475be1424b 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.test.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.test.ts @@ -16,6 +16,15 @@ const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); const decodeCdpReply = Schema.decodeUnknownSync( Schema.fromJsonString(Schema.Struct({ id: Schema.Number })), ); +const decodeCdpMessage = Schema.decodeUnknownSync( + Schema.fromJsonString( + Schema.Struct({ + id: Schema.optional(Schema.Number), + method: Schema.optional(Schema.String), + sessionId: Schema.optional(Schema.String), + }), + ), +); const key = { threadId: "thread-1", tabId: "tab-1" }; /** Sends a page command whose id the fake debugger records as it receives it. */ const command = ( @@ -43,6 +52,8 @@ const makeDebuggee = ( const pending: Array<() => void> = []; const focusChanges: Array<() => void> = []; const sent: Array = []; + const methods: Array = []; + const rejects: Array<() => void> = []; const sendWaiters: Array<() => void> = []; const focusEmulation: Array = []; const debuggee = Object.assign(emitter, { @@ -67,6 +78,8 @@ const makeDebuggee = ( return; } sent.push(params?.id); + methods.push(method); + rejects.push(() => reject(new Error("Refused"))); pending.push(() => resolve({ method })); sendWaiters.shift()?.(); }), @@ -94,6 +107,10 @@ const makeDebuggee = ( webContents.emit("did-fail-load", {}, code, "Failed", "http://localhost/", true), /** Ids of the commands the debugger received, in order. */ sent, + /** Methods of the same commands, in order. */ + methods, + /** Refuses the pending command the debugger received with this id. */ + refuse: (id: number) => rejects[sent.indexOf(id)]?.(), focusEmulation, nextSend: () => new Promise((resolve) => sendWaiters.push(resolve)), release: () => pending.splice(0).forEach((resolve) => resolve()), @@ -116,16 +133,24 @@ const takeEvents = (host: DesktopBrowserHost.DesktopBrowserHost["Service"], coun /** * Ids of the replies the host sends, in order. A screenshot's reply leaves after its turn * has passed on, so by then the next waiting screenshot, if any, has been sent. Subscribe - * before sending commands: a new subscriber releases the tab's relay. + * before sending commands: a new subscriber releases the tab's relay. Screencast frames + * go to `frames` by the session they were sent to. */ -const watchReplies = (host: DesktopBrowserHost.DesktopBrowserHost["Service"]) => +const watchReplies = ( + host: DesktopBrowserHost.DesktopBrowserHost["Service"], + frames: Array = [], +) => Effect.gen(function* () { const replies = yield* Queue.unbounded(); yield* host.events.pipe( Stream.map((line) => decodeEvent(new TextDecoder().decode(line))), - Stream.runForEach((event) => - event.type === "cdp" ? Queue.offer(replies, decodeCdpReply(event.message).id) : Effect.void, - ), + Stream.runForEach((event) => { + if (event.type !== "cdp") return Effect.void; + const { id, method, sessionId } = decodeCdpMessage(event.message); + if (id !== undefined) return Queue.offer(replies, id); + if (method === "Page.screencastFrame") frames.push(sessionId); + return Effect.void; + }), Effect.forkScoped, ); yield* Effect.yieldNow; @@ -1040,6 +1065,175 @@ describe("DesktopBrowserHost", () => { }).pipe(Effect.scoped), ); + it.effect("keeps a guest's one screencast running until its last streaming session stops", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const frames: Array = []; + const replies = yield* watchReplies(host, frames); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + const nativeStops = () => + debuggee.methods.filter((method) => method === "Page.stopScreencast").length; + + // A viewer and a recording share Chromium's single screencast for the guest. + yield* raw({ id: 0, method: "Target.setAutoAttach", params: { autoAttach: true } }); + expect(yield* Queue.take(replies)).toBe(0); + yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + yield* raw({ id: 2, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect([yield* Queue.take(replies), yield* Queue.take(replies)]).toEqual([1, 2]); + yield* raw({ id: 3, method: "Page.startScreencast", sessionId: "t3-preview-page-1" }); + yield* raw({ id: 4, method: "Page.startScreencast", sessionId: "t3-preview-page-2" }); + yield* settle; + debuggee.release(); + yield* raw({ id: 5, method: "Page.stopScreencast", sessionId: "t3-preview-page-1" }); + yield* settle; + expect(nativeStops(), "stopped the screencast another session still streams").toBe(0); + debuggee.emit("Page.screencastFrame", { data: "frame", sessionId: 1 }); + yield* settle; + expect(frames, "sent a stopped or idle session the screencast's frames").toEqual([ + "t3-preview-page-2", + ]); + + yield* raw({ id: 6, method: "Page.stopScreencast", sessionId: "t3-preview-page-2" }); + yield* settle; + expect(nativeStops()).toBe(1); + }).pipe(Effect.scoped), + ); + + it.effect("keeps a session's screencast when another of its starts is refused", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const frames: Array = []; + const replies = yield* watchReplies(host, frames); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + + yield* raw({ id: 0, method: "Target.setAutoAttach", params: { autoAttach: true } }); + expect(yield* Queue.take(replies)).toBe(0); + yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect(yield* Queue.take(replies)).toBe(1); + yield* raw({ + id: 2, + method: "Page.startScreencast", + params: { id: 2 }, + sessionId: "t3-preview-page-1", + }); + yield* raw({ + id: 3, + method: "Page.startScreencast", + params: { id: 3 }, + sessionId: "t3-preview-page-1", + }); + yield* settle; + debuggee.refuse(2); + debuggee.release(); + expect([yield* Queue.take(replies), yield* Queue.take(replies)].toSorted()).toEqual([2, 3]); + yield* settle; + expect(debuggee.methods).not.toContain("Page.stopScreencast"); + debuggee.emit("Page.screencastFrame", { data: "frame", sessionId: 1 }); + yield* settle; + expect(frames).toContain("t3-preview-page-1"); + + // A refused replacement leaves the session's running stream, shared with another session. + yield* raw({ id: 4, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect(yield* Queue.take(replies)).toBe(4); + yield* raw({ + id: 5, + method: "Page.startScreencast", + params: { id: 5 }, + sessionId: "t3-preview-page-2", + }); + yield* raw({ + id: 6, + method: "Page.startScreencast", + params: { id: 6 }, + sessionId: "t3-preview-page-1", + }); + yield* settle; + debuggee.refuse(6); + debuggee.release(); + expect([yield* Queue.take(replies), yield* Queue.take(replies)].toSorted()).toEqual([5, 6]); + yield* settle; + frames.length = 0; + debuggee.emit("Page.screencastFrame", { data: "frame", sessionId: 2 }); + yield* settle; + expect(frames).toEqual(["t3-preview-page-1", "t3-preview-page-2"]); + }).pipe(Effect.scoped), + ); + + it.effect("stops a guest's screencast once its last streaming session detaches", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + const nativeStops = () => + debuggee.methods.filter((method) => method === "Page.stopScreencast").length; + + yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + yield* raw({ id: 2, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect([yield* Queue.take(replies), yield* Queue.take(replies)]).toEqual([1, 2]); + yield* raw({ id: 3, method: "Page.startScreencast", sessionId: "t3-preview-page-1" }); + yield* raw({ id: 4, method: "Page.startScreencast", sessionId: "t3-preview-page-2" }); + yield* settle; + debuggee.release(); + // Streamers go without stopping, such as viewers that closed. + yield* raw({ + id: 5, + method: "Target.detachFromTarget", + params: { sessionId: "t3-preview-page-1" }, + }); + yield* settle; + expect(nativeStops(), "stopped the screencast another session still streams").toBe(0); + yield* raw({ + id: 6, + method: "Target.detachFromTarget", + params: { sessionId: "t3-preview-page-2" }, + }); + const answered = []; + for (let reply = 0; reply < 4; reply++) answered.push(yield* Queue.take(replies)); + expect(answered.toSorted()).toEqual([3, 4, 5, 6]); + yield* settle; + expect(nativeStops()).toBe(1); + }).pipe(Effect.scoped), + ); + + it.effect("stops a screencast whose start lands after its session detached", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee({ deferFocus: true }); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + + yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect(yield* Queue.take(replies)).toBe(1); + // The start waits on focus emulation while the viewer closes. + yield* raw({ id: 2, method: "Page.startScreencast", sessionId: "t3-preview-page-1" }); + yield* raw({ + id: 3, + method: "Target.detachFromTarget", + params: { sessionId: "t3-preview-page-1" }, + }); + expect(yield* Queue.take(replies)).toBe(3); + debuggee.settleFocus(); + yield* settle; + debuggee.release(); + expect(yield* Queue.take(replies)).toBe(2); + yield* settle; + expect(debuggee.methods.at(-1), "left the late screencast running").toBe( + "Page.stopScreencast", + ); + }).pipe(Effect.scoped), + ); + it.effect("saves a server tab's download under its CDP guid where the server asked", () => Effect.gen(function* () { const host = yield* DesktopBrowserHost.make; diff --git a/apps/desktop/src/preview/DesktopBrowserHost.ts b/apps/desktop/src/preview/DesktopBrowserHost.ts index a53fc0e3e6ec2..e5f43634d204a 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.ts @@ -75,8 +75,8 @@ interface AttachedTab { */ agentUsed: boolean; focusReleasing: boolean; - /** The sessions streaming the guest; one stopping leaves the others' screencasts running. */ - readonly screencasts: Set; + /** The sessions sharing the guest's one Chromium screencast; it stops with the last of them. */ + readonly screencasts: Map; readonly onMessage: ( event: Electron.Event, method: string, @@ -289,9 +289,15 @@ export const make = Effect.gen(function* () { dropFocusHolds(tab); }; + /** An owner that leaves without stopping would otherwise keep the screencast running unseen. */ + const leaveScreencast = (tab: AttachedTab, owner: string | undefined) => { + if (!tab.screencasts.delete(owner) || tab.screencasts.size > 0) return; + void tab.debuggee.debugger.sendCommand("Page.stopScreencast").catch(() => {}); + }; + /** A detached session, such as a closed viewer, waits on nothing it sent. */ const dropSessionHolds = (tab: AttachedTab, owner: string) => { - tab.screencasts.delete(owner); + leaveScreencast(tab, owner); for (const [pending, holder] of tab.focusPending) { if (holder === owner) tab.focusPending.delete(pending); } @@ -330,13 +336,19 @@ export const make = Effect.gen(function* () { { send: (method, params, sessionId, owner) => { // A child session's screencast is not the page's, such as a late one on a detached session. - if (sessionId === undefined && method === "Page.startScreencast") { - tab.screencasts.add(owner); + const stream = + sessionId === undefined && method === "Page.startScreencast" + ? (tab.screencasts.get(owner) ?? { started: false, pending: 0 }) + : undefined; + if (stream) { + stream.pending += 1; + tab.screencasts.set(owner, stream); if (owner !== undefined) streamingOwners.add(owner); syncPainting(tab); } else if (sessionId === undefined && method === "Page.stopScreencast") { tab.screencasts.delete(owner); syncPainting(tab); + if (tab.screencasts.size > 0) return Promise.resolve({}); } // A person's input through a viewer, or Playwright's own housekeeping, is not the agent's. if ( @@ -365,19 +377,42 @@ export const make = Effect.gen(function* () { held ? holdFocus(tab, owner, () => forward(current)) : forward(current), ) : method === "Page.startScreencast" - ? holdFocus(tab, owner, forward).catch((cause: unknown) => { - // A viewer can outlive a refused stream; nothing is streaming for it. - if (sessionId === undefined && tab.relay === relay) tab.screencasts.delete(owner); - releaseFocusWhenIdle(tab); - syncPainting(tab); - throw cause; - }) + ? holdFocus(tab, owner, forward).then( + (result) => { + if (stream) { + stream.pending -= 1; + stream.started = true; + // A detach's stop can overtake a start still waiting on focus; nobody owns it now. + if (tab.screencasts.get(owner) !== stream && tab.screencasts.size === 0) { + void debuggee.sendCommand("Page.stopScreencast").catch(() => {}); + } + } + return result; + }, + (cause: unknown) => { + if (stream) stream.pending -= 1; + // A viewer can outlive a refused start; its running or pending stream still holds it. + if ( + stream && + !stream.started && + stream.pending === 0 && + tab.relay === relay && + tab.screencasts.get(owner) === stream + ) { + leaveScreencast(tab, owner); + } + releaseFocusWhenIdle(tab); + syncPainting(tab); + throw cause; + }, + ) : holdFocus(tab, owner, forward); }, sessionDetached: (owner) => { detachedOwners.add(owner); dropSessionHolds(tab, owner); }, + receivesScreencast: (owner) => tab.screencasts.has(owner), targetId: () => debuggee .sendCommand("Target.getTargetInfo") @@ -463,7 +498,7 @@ export const make = Effect.gen(function* () { painting: false, agentUsed: false, focusReleasing: false, - screencasts: new Set(), + screencasts: new Map(), onMessage: (_event, method, params, sessionId) => { if (method === "Page.downloadWillBegin") { const guid = (params as { guid?: unknown } | undefined)?.guid; From f39d38bcc10ae3a31169ebd34a3d1664d491e716 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 19:41:13 -0800 Subject: [PATCH 029/390] fix(server): Codex question answers keep their native question id (#35886) --- .../Adapters/CodexAdapterV2.test.ts | 89 +++++++++++++++++++ .../Adapters/CodexAdapterV2.ts | 26 +++--- 2 files changed, 104 insertions(+), 11 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index bba467deec417..f3f4f22da9dc5 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -2356,6 +2356,95 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); + it.effect("returns a Codex question's answer under its native question id", () => + Effect.scoped( + Effect.gen(function* () { + const nativeThreadId = "question-id-thread"; + const nativeTurnId = "question-id-turn"; + const transcript = makeCodexReplayTranscript({ + scenario: "codex-question-native-id", + entries: [ + ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "Pick one." }), + { + type: "emit_inbound", + label: "question", + frame: { + id: 100, + method: "item/tool/requestUserInput", + params: { + threadId: nativeThreadId, + turnId: nativeTurnId, + itemId: "question-id-item", + isBlocking: true, + questions: [ + { + id: " choice ", + header: "Choice", + question: "Which option?", + options: [{ label: "A", description: "Option A" }], + }, + ], + }, + }, + }, + { + type: "expect_outbound", + label: "answer", + frame: { id: 100, result: { answers: { " choice ": { answers: ["A"] } } } }, + }, + { + type: "emit_inbound", + label: "complete", + frame: { + method: "turn/completed", + params: { + threadId: nativeThreadId, + turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), + }, + }, + }, + ], + }); + const harness = yield* makeCodexReplayHarness(transcript); + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("question-id-attempt"), + text: "Pick one.", + }), + ); + const questionItem = () => + harness.events.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "user_input_request" + ? [event.turnItem] + : [], + )[0]; + const request = () => + harness.events.flatMap((event) => + event.type === "runtime_request.updated" ? [event.runtimeRequest] : [], + )[0]; + yield* harness.until(() => questionItem() !== undefined && request() !== undefined); + const publishedId = questionItem()!.questions[0]!.id; + yield* harness.runtime.respondToRuntimeRequest({ + requestId: request()!.id, + answers: { [publishedId]: ["A"] }, + }); + yield* harness.firstTerminal; + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ), + ); + it.effect("records the model Codex reports on the root provider turn", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index e9b41ac8a5b05..7e1e8e0151a02 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -668,14 +668,15 @@ function answerValueToStrings(value: unknown): ReadonlyArray { function toCodexUserInputAnswers( answers: ProviderUserInputAnswers, - allowedQuestionIds: ReadonlySet, + nativeQuestionIds: ReadonlyMap, ): CodexSchema.ToolRequestUserInputResponse["answers"] { return Object.fromEntries( - Object.entries(answers).flatMap(([questionId, value]) => - allowedQuestionIds.has(questionId) - ? [[questionId, { answers: [...answerValueToStrings(value)] }]] - : [], - ), + Object.entries(answers).flatMap(([questionId, value]) => { + const nativeId = nativeQuestionIds.get(questionId); + return nativeId === undefined + ? [] + : [[nativeId, { answers: [...answerValueToStrings(value)] }]]; + }), ); } @@ -4556,6 +4557,12 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( `Provider thread ${input.context.providerThread.id} is missing a provider session id.`, ); } + const nativeQuestionIds = new Map( + input.questions.map((question, index) => [ + nonEmptyText(question.id, `question-${index + 1}`), + question.id, + ]), + ); const questions = input.questions.map((question, index) => ({ id: nonEmptyText(question.id, `question-${index + 1}`), header: nonEmptyText(question.header, "Question"), @@ -4630,7 +4637,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( questions, ...(input.responseMode === undefined ? {} : { responseMode: input.responseMode }), }; - return { node, request, turnItem }; + return { node, request, turnItem, nativeQuestionIds }; }); yield* client.handleServerNotification("item/agentMessage/delta", (payload) => @@ -6103,10 +6110,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( ), ); return { - answers: toCodexUserInputAnswers( - resolved, - new Set(payload.questions.map((question) => question.id)), - ), + answers: toCodexUserInputAnswers(resolved, artifacts.nativeQuestionIds), } satisfies CodexSchema.ToolRequestUserInputResponse; }).pipe(Effect.orDie), ); From f5390209c2a0f9e6f1f858af460f2c8ce3bfc862 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 12 Nov 2026 19:44:10 -0800 Subject: [PATCH 030/390] test(server): windowed history pages are checked against the full index at the window edges (#35877) Co-Authored-By: Claude Fable 5.2 --- .../orchestration-v2/ProjectionStore.test.ts | 91 ++++++++++++------- 1 file changed, 58 insertions(+), 33 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 23d64f289623b..607b1ada040fd 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -5804,7 +5804,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { let windowReads = 0; let foldedGroups = 0; - for (let round = 0; round < 4; round += 1) { + for (let round = 0; round < 5; round += 1) { const threadId = ThreadId.make(`thread:window-equivalence:${round}`); const created = { id: EventId.make(`event:window-equivalence:${round}`), @@ -5836,35 +5836,41 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { }, } satisfies Parameters[0]; yield* projectionStore.apply(created); + // The last thread puts three things below a window: a user turn under a hidden queued + // turn, the start of a long run that folds, and the start of the run before it, which + // has later rows among the first rows of the long run. + const windowEdges = round === 4; // One run of each kind in a random order, then an active run. Every other thread has // one user turn and then only wake turns. - // The last thread is a long run that a late notification wakes, then enough one-row - // runs that a page stops at the raw turn limit only if it counts that wake as a turn. + // The fourth thread is a turn, many rolled-back rows, a late notification of that turn, + // then 149 one-row turns. A page stops at that notification only if it counts as a turn. const kinds = round === 3 - ? ["late wake", ...Array.from({ length: 148 }, () => "one row")] - : [ - "rolled back", - "cancelled queued turn", - "held queued turn", - "replaced attempt", - "stopped", - "stopped", - "folds work", - "folds work", - "short", - "short", - "short", - ] - .map((kind) => ({ kind, order: random() })) - .toSorted((left, right) => left.order - right.order) - .map(({ kind }) => kind); + ? ["rolled back", "late wake", ...Array.from({ length: 149 }, () => "one row")] + : windowEdges + ? ["short", "cancelled queued turn", "folds work", "long fold", "stopped"] + : [ + "rolled back", + "cancelled queued turn", + "held queued turn", + "replaced attempt", + "stopped", + "stopped", + "folds work", + "folds work", + "short", + "short", + "short", + ] + .map((kind) => ({ kind, order: random() })) + .toSorted((left, right) => left.order - right.order) + .map(({ kind }) => kind); const agentDriven = round === 1; const runs: Array = []; const attempts: Array = []; const items: Array = []; const tiedWithHidden: Array = []; - const repliesInStoppedRuns: Array = []; + const pageEnds: Array = []; let ordinal = 0; for (const [index, kind] of [...kinds, "active"].entries()) { const run = index + 1; @@ -5958,7 +5964,11 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { const wakes = kind === "one row" ? run % 10 !== 0 - : kind !== "late wake" && (agentDriven ? run > 1 : chance(0.2)); + : windowEdges + ? ["folds work", "long fold", "stopped"].includes(kind) + : kind !== "late wake" && (agentDriven ? run > 1 : chance(0.2)); + if (round === 3 && kind === "active") + pageEnds.push(`turn-item:window-equivalence:${round}:${items.length}`); if (wakes) notification(); else userMessage( @@ -5974,25 +5984,40 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { ? 320 + Math.floor(random() * 60) : kind === "folds work" ? 155 + Math.floor(random() * 60) - : kind === "one row" - ? 0 - : Math.floor(random() * 30); + : kind === "long fold" + ? 300 + : kind === "one row" + ? 0 + : Math.floor(random() * 30); for (let row = 0; row < work; row += 1) { - if (chance(0.5)) add("command_execution", { input: "echo", output: "ok", exitCode: 0 }); - else add("reasoning", { text: "", streaming: false }); + // The first rows of a long fold and the work of a late wake belong to the run before. + const owner = + (kind === "long fold" && row < 140) || kind === "late wake" + ? { + runId: `run:window-equivalence:${round}:${run - 1}`, + nodeId: `node:window-equivalence:${round}:${run - 1}`, + } + : {}; + if (chance(0.5)) + add("command_execution", { input: "echo", output: "ok", exitCode: 0, ...owner }); + else add("reasoning", { text: "", streaming: false, ...owner }); // A reply deep in a stopped run: a page that ends on it has enough work below it // to fold, and only the stop request above it says that it does not. const reply = kind === "stopped" && row === 200; - if (reply) - repliesInStoppedRuns.push(`turn-item:window-equivalence:${round}:${items.length}`); - if (reply || chance(0.02)) + if (reply) pageEnds.push(`turn-item:window-equivalence:${round}:${items.length}`); + // With these replies, the part of a long fold in one window is too small to fold, + // and a shown row lies below the notification of a late wake. + const replies = kind === "long fold" || kind === "late wake"; + if (reply || (replies && row % 5 === 0) || chance(0.02)) add("assistant_message", { messageId: `message:window-equivalence:${round}:${items.length}`, text: "Reply", streaming: false, }); - if (chance(0.01)) notification(); - if (chance(0.004) && !agentDriven) userMessage("steer"); + // A late wake has no other input above its turn start. + if (chance(0.01) && kind !== "late wake") notification(); + if (chance(0.004) && !agentDriven && !windowEdges && kind !== "late wake") + userMessage("steer"); } if (kind === "late wake") notification(); if (kind === "stopped") add("run_interrupt_request", { message: "Stop" }); @@ -6087,7 +6112,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { cursor = page.nextCursor; } } - for (const sourceItemId of repliesInStoppedRuns) { + for (const sourceItemId of pageEnds) { const cursor = encodeThreadHistoryCursor({ snapshotSequence: 0, sourceThreadId: threadId, From afff0d3efe6f9ad22df9aebb3acb07bb144b49cd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 19:45:55 -0800 Subject: [PATCH 031/390] fix(clients): answer history no longer shows provider context from review comments (#35897) --- .../src/work-log/userInput.test.ts | 39 +++++++++++++++++++ .../client-runtime/src/work-log/userInput.ts | 4 +- .../shared/src/composerContextReferences.ts | 27 +++++++++++++ 3 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 packages/client-runtime/src/work-log/userInput.test.ts diff --git a/packages/client-runtime/src/work-log/userInput.test.ts b/packages/client-runtime/src/work-log/userInput.test.ts new file mode 100644 index 0000000000000..dce5aa45e8ec9 --- /dev/null +++ b/packages/client-runtime/src/work-log/userInput.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { getQuestionAnswerPreview, getQuestionAnswerText } from "./userInput.js"; + +// Shape the web client submits for an answer holding a review-comment chip. +const answerWithReviewComment = [ + "See my note: [Review comment: notes.md L2; ref=review-comment_c1]", + "", + '', + '', + "file: notes.md", + "range: L2 (2-2)", + "comment:", + " Rename this", + "diff:", + " +line changed", + "", + "", +].join("\n"); + +describe("question answer display", () => { + it("keeps the provider context envelope out of answer history and preview", () => { + const history = getQuestionAnswerText(answerWithReviewComment); + const preview = getQuestionAnswerPreview({ + requestId: "request-1", + answers: { q1: answerWithReviewComment }, + attachmentsByQuestionId: {}, + questionTextById: { q1: "What should change?" }, + }); + + expect(history).toBe("See my note: [Review comment: notes.md L2]"); + expect(preview).toContain("See my note:"); + expect(preview).not.toContain("keep me'; + expect(getQuestionAnswerText(typedXml)).toBe(typedXml); + }); +}); diff --git a/packages/client-runtime/src/work-log/userInput.ts b/packages/client-runtime/src/work-log/userInput.ts index fe36126d70e3c..bd9a10c2f2681 100644 --- a/packages/client-runtime/src/work-log/userInput.ts +++ b/packages/client-runtime/src/work-log/userInput.ts @@ -1,4 +1,5 @@ import { type UserInputAttachmentAnswerPayload } from "@t3tools/contracts"; +import { composerContextProviderTextForDisplay } from "@t3tools/shared/composerContextReferences"; function record(value: unknown): Record | undefined { return value !== null && typeof value === "object" && !Array.isArray(value) @@ -7,7 +8,8 @@ function record(value: unknown): Record | undefined { } export function getQuestionAnswerText(value: unknown): string { - if (typeof value === "string") return value; + // Answers holding context chips are stored in provider form. + if (typeof value === "string") return composerContextProviderTextForDisplay(value); if (Array.isArray(value)) return value.map(getQuestionAnswerText).filter(Boolean).join(", "); const nested = record(value); return nested ? getQuestionAnswerText(nested.answers) : ""; diff --git a/packages/shared/src/composerContextReferences.ts b/packages/shared/src/composerContextReferences.ts index 4302a1d69aa0c..1c3094ef28848 100644 --- a/packages/shared/src/composerContextReferences.ts +++ b/packages/shared/src/composerContextReferences.ts @@ -427,6 +427,33 @@ export function projectComposerContextForProvider(input: { return `${body}\n\n<${CONTEXT_ENVELOPE_TAG} version="1">\n${entries.join("\n")}\n`; } +const ENVELOPE_CLOSE = ``; +const PROVIDER_MARKER = /\[([A-Z][^[\]:;\n]*): ([^[\];\n]*); ref=([^[\]\s]+)\]/g; +const MARKER_ENVELOPE_PREFIX = `\n\n<${CONTEXT_ENVELOPE_TAG} version="1">\n<${CONTEXT_ENTRY_TAG} kind="`; + +/** + * Readable form of a single-reference `projectComposerContextForProvider` output, as question + * answers store it. Only an envelope right after its own marker is removed; typed XML stays. + */ +export function composerContextProviderTextForDisplay(text: string): string { + let result = ""; + let cursor = 0; + for (const match of text.matchAll(PROVIDER_MARKER)) { + if (match.index < cursor) continue; + const markerEnd = match.index + match[0].length; + if (!text.startsWith(MARKER_ENVELOPE_PREFIX, markerEnd)) continue; + const kindEnd = text.indexOf('"', markerEnd + MARKER_ENVELOPE_PREFIX.length); + if (kindEnd === -1 || !text.startsWith(` id="${escapeAttribute(match[3]!)}"`, kindEnd + 1)) { + continue; + } + const close = text.indexOf(ENVELOPE_CLOSE, kindEnd); + if (close === -1) continue; + result += `${text.slice(cursor, match.index)}[${match[1]}: ${match[2]}]`; + cursor = close + ENVELOPE_CLOSE.length; + } + return result + text.slice(cursor); +} + // An agent reads threads on its own machine and on those it links to, even when a draft // moves between machines. export function threadContextsOutsideEnvironment(input: { From 274474dd19b459b339628b5c427b12c6664d2cb3 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Thu, 12 Nov 2026 21:53:36 -0600 Subject: [PATCH 032/390] fix(server): refresh sync status after agent merges (#35919) --- apps/server/src/git/GitWorkflowService.ts | 18 +++++++ .../src/vcs/VcsStatusBroadcaster.test.ts | 53 +++++++++++++++++++ apps/server/src/vcs/VcsStatusBroadcaster.ts | 38 ++++++++++++- 3 files changed, 107 insertions(+), 2 deletions(-) diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index 731c19dfa1a87..a614ebdfb5687 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -53,6 +53,14 @@ export class GitWorkflowService extends Context.Service< input: VcsStatusInput, ) => Effect.Effect; readonly currentBranch: (cwd: string) => Effect.Effect; + readonly upstreamDivergence: (cwd: string) => Effect.Effect< + { + readonly refName: string; + readonly aheadCount: number; + readonly behindCount: number; + } | null, + GitManagerServiceError + >; readonly remoteStatus: ( input: VcsStatusInput, options?: GitManager.GitRemoteStatusOptions, @@ -363,6 +371,16 @@ export const make = Effect.gen(function* () { detail: "Failed to read the checked-out branch.", }); }), + upstreamDivergence: Effect.fn("GitWorkflowService.upstreamDivergence")(function* (cwd) { + const details = yield* git.statusDetailsRemote(cwd, { refreshUpstream: false }); + return details.hasUpstream && details.branch !== null + ? { + refName: details.branch, + aheadCount: details.aheadCount, + behindCount: details.behindCount, + } + : null; + }), remoteStatus: Effect.fn("GitWorkflowService.remoteStatus")(function (input, options) { return detectGitRepositoryForStatus("GitWorkflowService.remoteStatus", input.cwd).pipe( Effect.flatMap((isGitRepository) => diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index 87620e5d4fc4f..d2badb178e893 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -71,6 +71,7 @@ const remoteStatusWithPr: VcsStatusRemoteResult = { function layerTestFor(state: { currentLocalStatus: VcsStatusLocalResult; currentRemoteStatus: VcsStatusRemoteResult | null; + currentDivergence?: Pick; localStatusCalls: number; remoteStatusCalls: number; localInvalidationCalls: number; @@ -133,6 +134,16 @@ function layerTestFor(state: { : (state.beforeCurrentBranch ?? Effect.void).pipe( Effect.andThen(Effect.sync(() => state.currentLocalStatus.refName)), ), + upstreamDivergence: () => + Effect.sync(() => + state.currentRemoteStatus?.hasUpstream && state.currentLocalStatus.refName !== null + ? { + refName: state.currentLocalStatus.refName, + aheadCount: (state.currentDivergence ?? state.currentRemoteStatus).aheadCount, + behindCount: (state.currentDivergence ?? state.currentRemoteStatus).behindCount, + } + : null, + ), remoteStatus: (_input, options) => Effect.suspend(() => { return (state.beforeRemoteStatus ?? Effect.void).pipe( @@ -528,6 +539,48 @@ describe("VcsStatusBroadcaster", () => { }, ); + it.effect.each([0, 2])( + "refreshes merge divergence while a lookup ends with %s commits behind", + (behindAfterLookup) => { + const state: Parameters[0] = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: { ...baseRemoteStatus, aheadCount: 2, behindCount: 3 }, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + backgroundWorkEnabled: false, + }; + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + yield* broadcaster.getStatus({ cwd: "/repo" }); + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + state.beforeRemoteStatus = Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(release)), + ); + const lookup = yield* broadcaster.refreshPullRequestStatus("/repo").pipe(Effect.forkScoped); + yield* Deferred.await(started); + state.currentRemoteStatus = { ...baseRemoteStatus, aheadCount: 4, behindCount: 0 }; + yield* broadcaster.refreshLocalStatus("/repo"); + const status = yield* broadcaster.getStatus({ cwd: "/repo" }); + assert.equal(status.aheadCount, 4); + assert.equal(status.behindCount, 0); + assert.equal(state.remoteStatusCalls, 1); + state.currentDivergence = { aheadCount: 4, behindCount: behindAfterLookup }; + state.currentRemoteStatus = + behindAfterLookup > 0 + ? { ...baseRemoteStatus, ...state.currentDivergence } + : { ...baseRemoteStatus, aheadCount: 2, behindCount: 3 }; + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(lookup); + const afterLookup = yield* broadcaster.getStatus({ cwd: "/repo" }); + assert.equal(afterLookup.aheadCount, 4); + assert.equal(afterLookup.behindCount, behindAfterLookup); + }).pipe(Effect.provide(layerTestFor(state)), Effect.scoped); + }, + ); + it.effect("a local-only refresh after a branch switch leaves the PR refresh able to read", () => { const state: Parameters[0] = { currentLocalStatus: baseLocalStatus, diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index 99b3a2374162a..28daef7d790ca 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -451,7 +451,30 @@ export const make = Effect.gen(function* () { "VcsStatusBroadcaster.refreshLocalStatus", )(function* (rawCwd) { const cwd = yield* withFileSystem(normalizeCwd(rawCwd)); - return yield* refreshLocalStatusCore(cwd); + const local = yield* refreshLocalStatusCore(cwd); + const cached = yield* getCachedStatus(cwd); + // Divergence is local Git data; a slow forge lookup must not delay turn-end updates. + if (cached?.remote?.value?.hasUpstream) { + const divergence = yield* workflow.upstreamDivergence(cwd); + const latest = yield* getCachedStatus(cwd); + if (divergence && latest?.remote?.value && latest.remote.branch === divergence.refName) { + const value = { + ...latest.remote.value, + aheadCount: divergence.aheadCount, + behindCount: divergence.behindCount, + }; + yield* updateCachedRemoteStatus( + cwd, + { + branch: divergence.refName, + value, + fingerprint: fingerprintStatusPart(value), + }, + { publish: true }, + ); + } + } + return local; }); const readRemoteStatus = Effect.fn("VcsStatusBroadcaster.readRemoteStatus")(function* ( @@ -461,7 +484,18 @@ export const make = Effect.gen(function* () { const branch = yield* workflow.currentBranch(cwd); // GitManager's remote TTL cache is keyed by cwd, not the checked-out branch. yield* workflow.invalidateRemoteStatus(cwd); - const value = yield* workflow.remoteStatus({ cwd }, options); + const previousRemote = (yield* getCachedStatus(cwd))?.remote; + const remote = yield* workflow.remoteStatus({ cwd }, options); + const latest = yield* getCachedStatus(cwd); + // An overlapping turn refresh and fetch can both move divergence during the forge lookup. + const refreshed = + remote?.hasUpstream && latest?.remote !== previousRemote && latest?.remote?.branch === branch + ? yield* workflow.upstreamDivergence(cwd) + : null; + const value = + remote && refreshed?.refName === branch + ? { ...remote, aheadCount: refreshed.aheadCount, behindCount: refreshed.behindCount } + : remote; const currentBranch = yield* workflow.currentBranch(cwd); const cached = yield* getCachedStatus(cwd); if (branch !== currentBranch || cached?.local?.value.refName !== currentBranch) { From 75560474189031f3f5553aee56386230fdaeff36 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 19:54:34 -0800 Subject: [PATCH 033/390] fix(web): copying an image with a spaced path keeps it an image (#35914) --- .../chat/ChatMarkdownMedia.browser.test.tsx | 15 ++++++++++++++- .../web/src/components/chat/ChatMarkdownMedia.tsx | 14 +++++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/ChatMarkdownMedia.browser.test.tsx b/apps/web/src/components/chat/ChatMarkdownMedia.browser.test.tsx index 3c49f7a88f45b..db46999708043 100644 --- a/apps/web/src/components/chat/ChatMarkdownMedia.browser.test.tsx +++ b/apps/web/src/components/chat/ChatMarkdownMedia.browser.test.tsx @@ -1,9 +1,11 @@ import type { PreparedConnection } from "@t3tools/client-runtime/connection"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import remarkParse from "remark-parse"; +import { unified } from "unified"; import { describe, expect, it, vi } from "vite-plus/test"; import { render } from "../../../test/render"; -import { ChatMarkdownAssetImage } from "./ChatMarkdownMedia"; +import { ChatMarkdownAssetImage, markdownImageCopy } from "./ChatMarkdownMedia"; // Every evaluation of the URL query is one mint: the first read, then one per refresh. const assetQuery = vi.hoisted(() => ({ mints: 0 })); @@ -84,3 +86,14 @@ describe("ChatMarkdownAssetImage", () => { expect(assetQuery.mints).toBe(2); }); }); + +describe("markdownImageCopy", () => { + it("copies an image whose path has a space as Markdown that is still that image", () => { + // Raw HTML and sources reach the copy with the space unencoded. + const copied = markdownImageCopy("Shot", "/work/my shot.png", undefined); + const [block] = unified().use(remarkParse).parse(copied).children; + const images = + block?.type === "paragraph" ? block.children.filter((node) => node.type === "image") : []; + expect(images.map((image) => decodeURI(image.url))).toEqual(["/work/my shot.png"]); + }); +}); diff --git a/apps/web/src/components/chat/ChatMarkdownMedia.tsx b/apps/web/src/components/chat/ChatMarkdownMedia.tsx index c810890ebf96b..ce1745a6e51fd 100644 --- a/apps/web/src/components/chat/ChatMarkdownMedia.tsx +++ b/apps/web/src/components/chat/ChatMarkdownMedia.tsx @@ -37,7 +37,19 @@ export function markdownImageCopy(alt: string, src: string, title: string | unde const escapedAlt = alt.replaceAll("\\", "\\\\").replaceAll("[", "\\[").replaceAll("]", "\\]"); const titleSuffix = title === undefined ? "" : ` "${title.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`; - return `![${escapedAlt}](${src}${titleSuffix})`; + return `![${escapedAlt}](${markdownDestination(src)}${titleSuffix})`; +} + +// Raw HTML sources keep spaces and parentheses that a bare Markdown destination cannot hold. +function markdownDestination(src: string): string { + if (!/[\s<>()]/.test(src)) return src; + const escaped = src + .replaceAll("\\", "\\\\") + .replaceAll("<", "\\<") + .replaceAll(">", "\\>") + .replaceAll("\n", "%0A") + .replaceAll("\r", "%0D"); + return `<${escaped}>`; } /** From 2e0bd97e86c700a59c9b5c35ea1b8bb85414161b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 19:54:47 -0800 Subject: [PATCH 034/390] fix(server): Force stop no longer tears down a queued follow-up that started during cleanup (#31038) --- .../ProviderSessionManager.test.ts | 68 +++++ .../ProviderSessionManager.ts | 113 +++++--- .../ThreadManagementService.ts | 27 +- .../src/orchestration-v2/ThreadStop.test.ts | 262 ++++++++++++++++++ packages/contracts/src/orchestrationV2.ts | 8 +- 5 files changed, 432 insertions(+), 46 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index 40c2d28a590a9..3dff759550c78 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -1626,6 +1626,73 @@ it.effect("ProviderSessionManagerV2 gives up on a provider that never confirms a }), ); +it.effect("ProviderSessionManagerV2 still idles out a session releaseUnlessBusy kept", () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + yield* Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const projectionStore = yield* ProjectionStore.ProjectionStoreV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread-provider-session-manager-kept-idles"); + const providerSessionId = idAllocator.derive.providerSession({ + providerInstanceId: modelSelection.instanceId, + }); + yield* eventSink.write({ + events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], + }); + const runtime = yield* manager.open({ + threadId, + providerSessionId, + modelSelection, + runtimePolicy, + }); + const providerThread = makeProviderThread({ + idAllocator, + threadId, + providerSessionId, + now, + nativeThreadId: `native-${threadId}`, + }); + const runId = idAllocator.derive.run({ threadId, ordinal: 1 }); + yield* runtime.startTurn({ + appThread: (yield* projectionStore.getThreadProjection(threadId)).thread, + threadId, + runId, + runOrdinal: 1, + providerTurnOrdinal: 1, + attemptId: idAllocator.derive.runAttempt({ runId, attemptOrdinal: 1 }), + rootNodeId: idAllocator.derive.rootNode({ runId }), + providerThread, + message: { + createdBy: "user", + creationSource: "web", + messageId: yield* idAllocator.allocate.message({ threadId, ordinal: 1 }), + text: "turn", + attachments: [], + }, + modelSelection, + runtimePolicy, + }); + + assert.isFalse( + yield* manager.releaseUnlessBusy({ + providerSessionId, + ownProviderThreadIds: [providerThread.id], + throughRunOrdinal: 1, + keepIf: Effect.succeed(true), + reason: "manual_shutdown", + }), + ); + assert.isTrue(yield* manager.isLive(providerSessionId)); + // The stopped run 1 never reports its end. + yield* TestClock.adjust("2 seconds"); + assert.isFalse(yield* manager.isLive(providerSessionId)); + }).pipe(Effect.provide(layerTest({ state, idleTimeoutMs: 1_000 }))); + }), +); + it.effect( "ProviderSessionManagerV2 keeps a shared session a sibling is busy on when releasing", () => @@ -1692,6 +1759,7 @@ it.effect( manager.releaseUnlessBusy({ providerSessionId, ownProviderThreadIds: [providerThreadOf(own).id], + throughRunOrdinal: 1, reason: "manual_shutdown", }); diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index ade4668541362..c2f7eb767945d 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -71,6 +71,17 @@ export const PROVIDER_INTERRUPT_TIMEOUT_MS = 15 * 1000; const busyTurnPrefix = (providerThreadId: ProviderThreadId) => `${providerThreadId}#`; const busyTurnKey = (providerThreadId: ProviderThreadId, runOrdinal: number) => `${busyTurnPrefix(providerThreadId)}${runOrdinal}`; +interface OwnBusyTurns { + readonly providerThreadIds: ReadonlyArray; + readonly throughRunOrdinal: number; +} +const isOwnBusyTurn = (turnKey: string, own: OwnBusyTurns) => + own.providerThreadIds.some((id) => { + const prefix = busyTurnPrefix(id); + return ( + turnKey.startsWith(prefix) && Number(turnKey.slice(prefix.length)) <= own.throughRunOrdinal + ); + }); const UNLOAD_THREAD_TIMEOUT_MS = 10 * 1000; // Providers can announce a retry and never resume. Longer than any adapter's own stall handling. const PROVIDER_RETRY_STALL_TIMEOUT = Duration.minutes(15); @@ -243,13 +254,16 @@ export interface ProviderSessionManagerV2Shape { readonly revokeMcpCredential?: boolean; }) => Effect.Effect; /** - * Releases the session unless, at the moment of removal, a turn of a provider thread outside - * `ownProviderThreadIds` is busy on it. Checked atomically with turn starts. Succeeds with - * whether it was released. + * Releases the session unless, at the moment of removal, a turn is busy on it other than one + * of `ownProviderThreadIds` from a run up to `throughRunOrdinal`. Checked atomically with turn + * starts. Succeeds with whether it was released. */ readonly releaseUnlessBusy: (input: { readonly providerSessionId: ProviderSessionId; readonly ownProviderThreadIds: ReadonlyArray; + readonly throughRunOrdinal: number; + /** Keeps the session when it succeeds with true, checked atomically with the removal. */ + readonly keepIf?: Effect.Effect; readonly reason: ProviderSessionReleaseReason; readonly detail?: string; }) => Effect.Effect; @@ -1125,7 +1139,7 @@ export const layerWithOptions = ( function* (input: { readonly providerSessionId: ProviderSessionId; readonly onlyIfIdleGeneration?: number; - readonly keepIfBusyOtherThan?: ReadonlyArray; + readonly keepIfBusyOtherThan?: OwnBusyTurns; readonly keepIf?: Effect.Effect; }): Effect.fn.Return, DateTime.Utc]> { const key = sessionKey(input.providerSessionId); @@ -1133,40 +1147,48 @@ export const layerWithOptions = ( if (candidate === undefined) { return [Option.none(), yield* DateTime.now] as const; } - const remove = Effect.zip( - Ref.modify(sessions, (current) => { - const existing = current.get(key); - if (existing !== candidate) { - return [existing === undefined ? "gone" : "changed", current] as const; - } - if ( - input.onlyIfIdleGeneration !== undefined && - (existing.busyTurns.size > 0 || - existing.idleGeneration !== input.onlyIfIdleGeneration) - ) { - return ["kept", current] as const; - } - const own = input.keepIfBusyOtherThan; - if ( - own !== undefined && - [...existing.busyTurns].some( - (turnKey) => !own.some((id) => turnKey.startsWith(busyTurnPrefix(id))), - ) - ) { - return ["kept", current] as const; - } - const updated = new Map(current); - updated.delete(key); - return ["removed", updated] as const; - }), - DateTime.now, + const remove = (keepRequested: boolean) => + Effect.zip( + Ref.modify(sessions, (current) => { + const existing = current.get(key); + if (existing !== candidate) { + return [existing === undefined ? "gone" : "changed", current] as const; + } + if ( + input.onlyIfIdleGeneration !== undefined && + (existing.busyTurns.size > 0 || + existing.idleGeneration !== input.onlyIfIdleGeneration) + ) { + return ["kept", current] as const; + } + const own = input.keepIfBusyOtherThan; + if ( + keepRequested || + (own !== undefined && + [...existing.busyTurns].some((turnKey) => !isOwnBusyTurn(turnKey, own))) + ) { + if (own === undefined) return ["kept", current] as const; + // The caller settled its own turns; one whose end never arrives must not keep + // the session from going idle once the turn it was kept for ends. + const kept = new Map(current); + kept.set(key, { + ...existing, + busyTurns: new Set( + [...existing.busyTurns].filter((turnKey) => !isOwnBusyTurn(turnKey, own)), + ), + }); + return ["kept", kept] as const; + } + const updated = new Map(current); + updated.delete(key); + return ["removed", updated] as const; + }), + DateTime.now, + ); + const [outcome, releasedAt] = yield* (input.keepIf ?? Effect.succeed(false)).pipe( + Effect.flatMap(remove), + candidate.requestEventPermit.withPermits(1), ); - const [outcome, releasedAt] = yield* Effect.gen(function* () { - if (input.keepIf !== undefined && (yield* input.keepIf)) { - return ["kept" as const, yield* DateTime.now] as const; - } - return yield* remove; - }).pipe(candidate.requestEventPermit.withPermits(1)); if (outcome === "changed") return yield* removeLiveEntry(input); return [ outcome === "removed" ? Option.some(candidate) : Option.none(), @@ -1218,7 +1240,7 @@ export const layerWithOptions = ( readonly detachedThreadId?: ThreadId; readonly cancelIdleFiber?: boolean; readonly onlyIfIdleGeneration?: number; - readonly keepIfBusyOtherThan?: ReadonlyArray; + readonly keepIfBusyOtherThan?: OwnBusyTurns; readonly keepIf?: Effect.Effect; readonly gracefulSubscribers?: boolean; readonly eventStreamCause?: Cause.Cause; @@ -3076,10 +3098,19 @@ export const layerWithOptions = ( providerSessionId: input.providerSessionId, reason: input.reason, ...(input.detail === undefined ? {} : { detail: input.detail }), - keepIfBusyOtherThan: input.ownProviderThreadIds, + keepIfBusyOtherThan: { + providerThreadIds: input.ownProviderThreadIds, + throughRunOrdinal: input.throughRunOrdinal, + }, + ...(input.keepIf === undefined ? {} : { keepIf: input.keepIf }), }); - // Identity, not presence: open can install a replacement under the same id meanwhile. - return before !== undefined && (yield* Ref.get(sessions)).get(key) !== before; + // Scope, not presence: open can install a replacement under the same id meanwhile, and + // a kept entry is rewritten with the same scope. + const released = + before !== undefined && (yield* Ref.get(sessions)).get(key)?.scope !== before.scope; + // The stopped turn's markBusy cancelled the idle timer and its markIdle may never come. + if (!released) yield* scheduleIdleReleaseInternal(input.providerSessionId); + return released; }), otherWorkingThreads: Effect.fn("ProviderSessionManager.otherWorkingThreads")( function* (input) { diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index 1dd01ae504ca5..a69d308884c72 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -1303,6 +1303,28 @@ const make = Effect.gen(function* () { const ownProviderThreadIds = records.providerThreads.flatMap((providerThread) => providerThread.appThreadId === input.threadId ? [providerThread.id] : [], ); + // Settling the target can promote a queued follow-up onto the same session; that run is + // not Force stop's to end. Only a newer run bound to a provider thread on this session + // counts. An unreadable thread counts as one, so its sessions are kept. + const throughRunOrdinal = target?.ordinal ?? 0; + const laterRunActive = (providerSessionId: ProviderSessionId) => + orchestrator.getThreadRecords(input.threadId, ["runs", "providerThreads"]).pipe( + Effect.map(({ runs, providerThreads }) => { + const onSession = new Set( + providerThreads.flatMap((providerThread) => + providerThread.providerSessionId === providerSessionId ? [providerThread.id] : [], + ), + ); + return runs.some( + (run) => + run.ordinal > throughRunOrdinal && + isActiveRun(run) && + run.providerThreadId !== null && + onSession.has(run.providerThreadId), + ); + }), + Effect.orElseSucceed(() => true), + ); const detach = (providerSessionId: ProviderSessionId) => sessions .detach({ providerSessionId, threadId: input.threadId, detail: FORCE_STOP_REASON }) @@ -1340,6 +1362,9 @@ const make = Effect.gen(function* () { .releaseUnlessBusy({ providerSessionId, ownProviderThreadIds, + throughRunOrdinal, + // A promoted follow-up may not have marked its turn busy yet. + keepIf: laterRunActive(providerSessionId), reason: "manual_shutdown", detail: FORCE_STOP_REASON, }) @@ -1354,7 +1379,7 @@ const make = Effect.gen(function* () { ); if (Option.isNone(released)) { releasing = true; - } else if (!released.value) { + } else if (!released.value && !(yield* laterRunActive(release.providerSessionId))) { shared = true; yield* detach(release.providerSessionId); } diff --git a/apps/server/src/orchestration-v2/ThreadStop.test.ts b/apps/server/src/orchestration-v2/ThreadStop.test.ts index 7c6bf40044537..d07cbc7867718 100644 --- a/apps/server/src/orchestration-v2/ThreadStop.test.ts +++ b/apps/server/src/orchestration-v2/ThreadStop.test.ts @@ -20,7 +20,9 @@ import { import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import * as SqlClient from "effect/sql/SqlClient"; import * as Stream from "effect/Stream"; @@ -38,8 +40,10 @@ import * as EffectWorker from "./EffectWorker.ts"; import * as EffectOutbox from "./EffectOutbox.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; +import * as ProviderSessionManager from "./ProviderSessionManager.ts"; import * as ThreadManagementService from "./ThreadManagementService.ts"; import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts"; +import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace"; import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; const instanceId = ProviderInstanceId.make("codex"); @@ -1834,3 +1838,261 @@ it.effect.each(["Stop", "Force stop"] as const)( ); }).pipe(Effect.provide(layerTest)), ); + +it.effect.each([ + { followUp: "started its turn", startedBeforeRelease: true }, + { followUp: "was promoted", startedBeforeRelease: false }, + { followUp: "switched provider instance", startedBeforeRelease: true, switchesInstance: true }, + { followUp: "has fresh context", startedBeforeRelease: true, freshContext: true }, +])( + "Force stop keeps only the session of a queued follow-up that $followUp", + ({ startedBeforeRelease, switchesInstance = false, freshContext = false }) => + Effect.scoped( + Effect.gen(function* () { + const cwd = yield* checkpointWorkspace("thread-stop-force-stop-follow-up"); + const driver = adapter.driver; + const otherInstanceId = ProviderInstanceId.make("codex-other"); + const eventQueues = new Map< + ProviderSessionId, + Queue.Queue + >(); + const started: Array = []; + const interrupted: Array = []; + let opened = 0; + // Fresh context on a one-thread-per-session provider puts the follow-up on its own session. + // The session itself reports shared threads so fresh-context cleanup leaves the stopped + // run's session to Force stop, as pending background work would. + const adapterCapabilities = freshContext + ? { + ...CodexProviderCapabilitiesV2, + sessions: { + ...CodexProviderCapabilitiesV2.sessions, + supportsMultipleProviderThreadsPerSession: false, + }, + } + : CodexProviderCapabilitiesV2; + const liveAdapter = ( + instanceId: ProviderInstanceId, + ): ProviderAdapter.ProviderAdapterV2["Service"] => ({ + ...adapter, + instanceId, + getCapabilities: () => Effect.succeed(adapterCapabilities), + openSession: (input) => + Effect.gen(function* () { + opened += 1; + const now = yield* DateTime.now; + const events = yield* Queue.unbounded(); + eventQueues.set(input.providerSessionId, events); + return { + instanceId, + driver, + providerSessionId: input.providerSessionId, + providerSession: { + id: input.providerSessionId, + driver, + providerInstanceId: instanceId, + status: "ready", + cwd, + model: modelSelection.model, + capabilities: CodexProviderCapabilitiesV2, + createdAt: now, + updatedAt: now, + lastError: null, + }, + events: Stream.fromQueue(events), + ensureThread: ({ threadId }) => + Effect.succeed({ + id: ProviderThreadId.make( + `provider-thread:${input.providerSessionId}:${threadId}`, + ), + driver, + providerInstanceId: instanceId, + providerSessionId: input.providerSessionId, + appThreadId: threadId, + ownerNodeId: null, + nativeThreadRef: { driver, nativeId: "native-thread", strength: "strong" }, + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: null, + lastRunOrdinal: null, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + }), + resumeThread: ({ providerThread }) => Effect.succeed(providerThread), + startTurn: (turn) => + Effect.gen(function* () { + started.push(turn); + yield* Queue.offer(events, { + type: "provider_turn.updated", + driver, + providerTurn: { + id: ProviderTurnId.make(`provider-turn:${turn.attemptId}`), + providerThreadId: turn.providerThread.id, + nodeId: turn.rootNodeId, + runAttemptId: turn.attemptId, + nativeTurnRef: { + driver, + nativeId: `native:${turn.attemptId}`, + strength: "strong", + }, + ordinal: turn.providerTurnOrdinal, + status: "running", + startedAt: now, + completedAt: null, + }, + }); + }), + steerTurn: () => Effect.die("unused"), + interruptTurn: (interrupt) => Effect.sync(() => interrupted.push(interrupt)), + respondToRuntimeRequest: () => Effect.die("unused"), + readThreadSnapshot: () => Effect.die("unused"), + rollbackThread: () => Effect.die("unused"), + forkThread: () => Effect.die("unused"), + }; + }), + }); + // The settlement promotes the follow-up; optionally its turn starts before Force stop releases. + const layerSessionsStartingFollowUpFirst = Layer.effect( + ProviderSessionManager.ProviderSessionManagerV2, + Effect.gen(function* () { + const sessions = yield* ProviderSessionManager.ProviderSessionManagerV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + return ProviderSessionManager.ProviderSessionManagerV2.of({ + ...sessions, + releaseUnlessBusy: (input) => + (startedBeforeRelease ? worker.drain().pipe(Effect.orDie) : Effect.void).pipe( + Effect.andThen(sessions.releaseUnlessBusy(input)), + ), + }); + }), + ); + const layerLive = ThreadManagementService.layer.pipe( + Layer.provide(layerSessionsStartingFollowUpFirst), + Layer.provideMerge( + Layer.mergeAll( + layerDatabase, + ProjectionStore.layer.pipe(Layer.provide(layerDatabase)), + EventStore.layer.pipe(Layer.provide(layerDatabase)), + EffectOutbox.layer.pipe(Layer.provide(layerDatabase)), + ProviderReplayHarness.layerWithRegistry( + { name: "thread-stop-force-stop-follow-up" }, + ProviderAdapterRegistry.layerFromAdapters([ + liveAdapter(instanceId), + liveAdapter(otherInstanceId), + ]), + { databaseLayer: layerDatabase, runEffectWorker: false }, + ), + ), + ), + Layer.provideMerge(ServerSettings.layerTest()), + Layer.provideMerge(CommandReceiptStore.layer.pipe(Layer.provide(layerDatabase))), + ); + + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const sessions = yield* ProviderSessionManager.ProviderSessionManagerV2; + const threads = yield* ThreadManagementService.ThreadManagementService; + const threadId = ThreadId.make("thread:force-stop-follow-up"); + const runningTurn = orchestrator.streamDomainEvents.pipe( + Stream.filter( + (event) => + event.type === "provider-turn.updated" && event.payload.status === "running", + ), + Stream.take(1), + Stream.runDrain, + Effect.forkScoped, + ); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create:force-stop-follow-up"), + threadId, + projectId: ProjectId.make("project:thread-stop"), + title: "Force stop follow-up", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + createdBy: "user", + creationSource: "web", + }); + const firstRunning = yield* runningTurn; + yield* send(threadId, "first", "start_immediately"); + yield* worker.drain(); + yield* Fiber.join(firstRunning); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("send:force-stop-follow-up"), + threadId, + messageId: MessageId.make("message:force-stop-follow-up"), + text: "follow-up", + attachments: [], + dispatchMode: { type: "queue_after_active" }, + ...(switchesInstance + ? { modelSelection: { ...modelSelection, instanceId: otherInstanceId } } + : {}), + ...(freshContext ? { freshContext } : {}), + createdBy: "user", + creationSource: "web", + }); + + const result = yield* threads.forceStopThread({ threadId }); + yield* worker.drain(); + + assert.deepEqual( + started.map((turn) => turn.runOrdinal), + [1, 2], + "the queued follow-up did not start its turn", + ); + assert.equal( + opened, + switchesInstance || freshContext ? 2 : 1, + "Force stop tore down the session the follow-up was promoted onto", + ); + const [stopped, followUp] = [started[0]!, started[1]!]; + const providerSessionId = followUp.providerThread.providerSessionId!; + assert.equal( + yield* sessions.isLive(stopped.providerThread.providerSessionId!), + !switchesInstance && !freshContext, + "Force stop kept the stopped run's session for a follow-up on another session", + ); + assert.isFalse( + interrupted.some((interrupt) => + String(interrupt.providerTurnId).includes(followUp.attemptId), + ), + "Force stop of the first run interrupted the follow-up's turn", + ); + assert.isTrue( + yield* sessions.isLive(providerSessionId), + "Force stop of the first run released the session the follow-up's turn is running on", + ); + assert.deepEqual( + result, + { outcome: "released", sharedWithThreadIds: [] }, + "Force stop detached the thread the follow-up runs on as if its session were shared", + ); + + // The first run's provider never confirms the stop; the follow-up's turn ends normally. + yield* Queue.offer(eventQueues.get(providerSessionId)!, { + type: "turn.terminal", + driver, + providerThreadId: followUp.providerThread.id, + providerTurnId: ProviderTurnId.make(`provider-turn:${followUp.attemptId}`), + runOrdinal: followUp.runOrdinal, + status: "completed", + failure: null, + threadDisposition: "reusable", + }); + yield* worker.drain(); + yield* TestClock.adjust("31 minutes"); + assert.isFalse( + yield* sessions.isLive(providerSessionId), + "the stopped run's unconfirmed turn kept the session from going idle", + ); + }).pipe(Effect.provide(layerLive)); + }), + ), +); diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 24e3a48ba76dc..35ac16fc063a4 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -4180,10 +4180,10 @@ export type OrchestrationV2ForceStopThreadInput = typeof OrchestrationV2ForceSto export const OrchestrationV2ForceStopThreadResult = Schema.Struct({ /** - * `killed`: the thread's own agent processes were killed. `released`: its provider session - * was torn down, which kills the agent's process group. `releasing`: that teardown has not - * finished yet and continues on the server. `shared`: the agent process also works for - * `sharedWithThreadIds`, so only this thread's turn was ended. + * `killed`: its own agent processes were killed. `released`: its provider session was torn + * down (killing the agent's process group) or handed to its newer run. `releasing`: teardown + * continues on the server. `shared`: the agent process also works for `sharedWithThreadIds`, + * so only this thread's turn was ended. */ outcome: Schema.Literals(["killed", "released", "releasing", "shared"]), sharedWithThreadIds: Schema.Array(ThreadId), From 5f7bfe565d25c7ef59e47486006598f186a6abdf Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 19:57:13 -0800 Subject: [PATCH 035/390] fix(azure-devops): TF400733 throttling fails diffs as rate-limited (#35929) --- .../src/server/AzureDevOpsCli.test.ts | 6 ++++++ .../src/server/AzureDevOpsCli.ts | 2 ++ 2 files changed, 8 insertions(+) diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsCli.test.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsCli.test.ts index 9b40406093155..e337dc4da8058 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsCli.test.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsCli.test.ts @@ -83,6 +83,12 @@ describe("AzureDevOpsCli.layer", () => { classifyFailure?.("TF401180: The requested pull request was not found."), "not-found", ); + assert.strictEqual( + classifyFailure?.( + "ERROR: TF400733: The request has been canceled: Request was blocked due to exceeding usage of resource 'Global' in namespace 'User'.", + ), + "rate-limited", + ); assert.deepStrictEqual(call, { operation: "AzureDevOpsCli.execute", command: "az", diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsCli.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsCli.ts index b730da1709b89..ce05f7281d419 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsCli.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsCli.ts @@ -357,6 +357,8 @@ const classifyAzFailure = (stderr: string): VcsProcessExitFailureKind | null => if (normalized.includes("az devops login") || /please run '?az login'?/.test(normalized)) { return "authentication"; } + // Azure DevOps reports usage-limit throttling as TF400733, often without an HTTP status. + if (normalized.includes("tf400733")) return "rate-limited"; return normalized.includes("pull request") && (normalized.includes("not found") || normalized.includes("does not exist")) ? "not-found" From ed5afae1792f5ef437b1970f98a8cb1046f63396 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 20:00:18 -0800 Subject: [PATCH 036/390] fix(acp): v2 tool updates clear nulled fields and replace raw input (#35869) --- packages/effect-acp/src/client.test.ts | 17 +++++-- packages/effect-acp/src/client.ts | 9 ++++ .../src/server/runtimeModel.test.ts | 44 +++++++++++++++++++ .../provider-acp/src/server/runtimeModel.ts | 19 ++++++-- packages/shared/src/toolActivity.test.ts | 4 +- packages/shared/src/toolActivity.ts | 20 ++++++--- 6 files changed, 97 insertions(+), 16 deletions(-) diff --git a/packages/effect-acp/src/client.test.ts b/packages/effect-acp/src/client.test.ts index fbf97d41617ac..83037a6936e32 100644 --- a/packages/effect-acp/src/client.test.ts +++ b/packages/effect-acp/src/client.test.ts @@ -1608,7 +1608,7 @@ it.layer(NodeServices.layer)("effect-acp client", (it) => { }), ); - it.effect("delivers ACP v1 session notices, which the pinned v1 schema predates", () => + it.effect("normalizes ACP v1 session notices and null tool names to v2 semantics", () => Effect.gen(function* () { const { stdio, input, output } = yield* makeInMemoryStdio(); const scope = yield* Scope.make(); @@ -1636,7 +1636,12 @@ it.layer(NodeServices.layer)("effect-acp client", (it) => { description: "Continuing without it.", } as const; const untitled = { sessionUpdate: "notice", severity: "info" } as const; - for (const update of [notice, untitled]) { + const toolUpdate = { + sessionUpdate: "tool_call_update", + toolCallId: "c", + status: "completed", + }; + for (const update of [notice, untitled, { ...toolUpdate, name: null }]) { yield* Queue.offer( input, yield* encodeJsonl(SessionUpdateNotification, { @@ -1646,12 +1651,16 @@ it.layer(NodeServices.layer)("effect-acp client", (it) => { }), ); } - const received = yield* acp.raw.notifications.pipe(Stream.take(2), Stream.runCollect); + const received = yield* acp.raw.notifications.pipe(Stream.take(3), Stream.runCollect); assert.deepEqual( Array.from(received, (event) => event._tag === "SessionUpdate" ? event.params.update : undefined, ), - [notice, { sessionUpdate: "_t3_unknown", originalSessionUpdate: "notice", raw: untitled }], + [ + notice, + { sessionUpdate: "_t3_unknown", originalSessionUpdate: "notice", raw: untitled }, + toolUpdate, + ], ); yield* Scope.close(scope, Exit.void); }), diff --git a/packages/effect-acp/src/client.ts b/packages/effect-acp/src/client.ts index 1d8f06c862def..0117835329381 100644 --- a/packages/effect-acp/src/client.ts +++ b/packages/effect-acp/src/client.ts @@ -528,6 +528,15 @@ function normalizeV1SessionUpdate( }, }; } + const update = notification.update; + // v1 defines a null tool name as unchanged; v2 consumers read null as a clear. + if ( + (update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update") && + update.name === null + ) { + const { name: _name, ...rest } = update; + return { ...notification, update: rest } as unknown as AcpSchema.SessionNotification; + } if (notification.update.sessionUpdate !== "available_commands_update") { return notification as unknown as AcpSchema.SessionNotification; } diff --git a/packages/provider-acp/src/server/runtimeModel.test.ts b/packages/provider-acp/src/server/runtimeModel.test.ts index 5b0e3327d9819..b41c95f6466b6 100644 --- a/packages/provider-acp/src/server/runtimeModel.test.ts +++ b/packages/provider-acp/src/server/runtimeModel.test.ts @@ -361,6 +361,50 @@ describe("AcpRuntimeModel", () => { ).toBe(true); }); + it("clears explicitly nulled ACP v2 tool call fields and replaces raw input", () => { + const toolCall = (update: Omit) => { + const parsed = parseSessionUpdateEvent({ + sessionId: "session-1", + update: { sessionUpdate: "tool_call_update", toolCallId: "call-1", ...update }, + } satisfies EffectAcpSchema.SessionNotification); + const event = parsed.events.find((candidate) => candidate._tag === "ToolCallUpdated"); + if (!event || event._tag !== "ToolCallUpdated") { + throw new Error("expected a tool call update"); + } + return event.toolCall; + }; + + const created = toolCall({ + name: "old-name", + title: "Old title", + kind: "read", + status: "in_progress", + content: [{ type: "content", content: { type: "text", text: "old output" } }], + rawInput: { path: "old.txt", obsolete: true }, + _meta: { old: true }, + }); + + expect( + mergeToolCallState(created, toolCall({ rawInput: { path: "new.txt" } })).data.rawInput, + ).toEqual({ path: "new.txt" }); + + const cleared = mergeToolCallState( + created, + toolCall({ + name: null, + title: null, + kind: null, + status: null, + content: null, + rawInput: null, + _meta: null, + }), + ); + const { name, title, kind, content, rawInput, meta } = cleared.data; + expect({ name, title, kind, content, rawInput, meta }).toEqual({}); + expect({ kind: cleared.kind, title: cleared.title, status: cleared.status }).toEqual({}); + }); + it("forwards an empty ACP v1 plan to clear previous entries", () => { const entries = [ [{ content: "Inspect state", priority: "high", status: "pending" }], diff --git a/packages/provider-acp/src/server/runtimeModel.ts b/packages/provider-acp/src/server/runtimeModel.ts index 6b9f0a01dde43..3b936a30b766f 100644 --- a/packages/provider-acp/src/server/runtimeModel.ts +++ b/packages/provider-acp/src/server/runtimeModel.ts @@ -842,6 +842,19 @@ function makeToolCallState( ? title : undefined; const data: Record = { toolCallId }; + // ACP v2 clears a field set to null; mergeToolCallState consumes these markers. + const clearable = { + name: input.name, + title: input.title, + kind: input.kind, + status: input.status, + content: input.content, + rawInput: input.rawInput, + meta: input._meta, + }; + for (const [key, value] of Object.entries(clearable)) { + if (value === null) data[key] = null; + } if (input.name != null) data.name = input.name; if (kind) { data.kind = kind; @@ -929,9 +942,9 @@ export function mergeToolCallState( next: AcpToolCallState, ): AcpToolCallState { const nextKind = next.kind ?? (typeof next.data.kind === "string" ? next.data.kind : undefined); - const kind = nextKind ?? previous?.kind; - const title = next.title ?? previous?.title; - const status = next.status ?? previous?.status; + const kind = next.data.kind === null ? nextKind : (nextKind ?? previous?.kind); + const title = next.data.title === null ? next.title : (next.title ?? previous?.title); + const status = next.data.status === null ? next.status : (next.status ?? previous?.status); const command = next.command ?? previous?.command; const detail = next.detail ?? previous?.detail; const data = mergeToolActivityData(previous?.data, next.data) ?? next.data; diff --git a/packages/shared/src/toolActivity.test.ts b/packages/shared/src/toolActivity.test.ts index 8b7bbe66215b1..dbcd0d9fb4c9a 100644 --- a/packages/shared/src/toolActivity.test.ts +++ b/packages/shared/src/toolActivity.test.ts @@ -22,7 +22,7 @@ describe("toolActivity", () => { ); }); - it("keeps the first non-empty rawInput when a later update is empty", () => { + it("keeps the previous rawInput when a later update is empty and replaces it otherwise", () => { expect( mergeToolActivityData({ rawInput: { path: "src/a.ts" } }, { rawInput: {}, kind: "read" }), ).toEqual({ @@ -31,7 +31,7 @@ describe("toolActivity", () => { }); expect( mergeToolActivityData({ rawInput: { path: "src/a.ts" } }, { rawInput: { startLine: 4 } }), - ).toEqual({ rawInput: { path: "src/a.ts", startLine: 4 } }); + ).toEqual({ rawInput: { startLine: 4 } }); }); it("recognizes computer-use REPL calls before result metadata exists", () => { diff --git a/packages/shared/src/toolActivity.ts b/packages/shared/src/toolActivity.ts index c2ab58c4576f5..5e10a52597e5d 100644 --- a/packages/shared/src/toolActivity.ts +++ b/packages/shared/src/toolActivity.ts @@ -194,7 +194,10 @@ function extractPrimaryPath(data: Record | undefined): string | return collectToolFilePaths(data)[0]; } -/** Preserve the first nonempty rawInput; later ACP updates often resend an empty object. */ +/** + * A null field clears it (ACP v2) and a nonempty rawInput replaces the previous one. + * An empty rawInput keeps the previous one; later ACP updates often resend an empty object. + */ export function mergeToolActivityData( previous: unknown, next: unknown, @@ -204,15 +207,18 @@ export function mergeToolActivityData( if (!nextRecord) { return previousRecord; } - if (!previousRecord) { - return nextRecord; + const merged = { ...previousRecord, ...nextRecord }; + for (const [key, value] of Object.entries(merged)) { + if (value === null) { + delete merged[key]; + } + } + if (!previousRecord || nextRecord.rawInput === null) { + return merged; } const previousInput = asRecord(previousRecord.rawInput); const nextInput = asRecord(nextRecord.rawInput); - const rawInput = recordHasKeys(nextInput) - ? { ...previousInput, ...nextInput } - : (previousInput ?? nextInput); - const merged = { ...previousRecord, ...nextRecord }; + const rawInput = recordHasKeys(nextInput) ? nextInput : (previousInput ?? nextInput); if (recordHasKeys(rawInput)) { merged.rawInput = rawInput; } else { From 004f7a768f3f6bba8a4c435b076b7c826448d7ae Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 20:01:50 -0800 Subject: [PATCH 037/390] fix(server): interrupted SSH device bootstrap no longer leaks its forward (#35928) --- apps/server/src/device/SshDeviceHost.test.ts | 93 ++++++++++++++++++++ apps/server/src/device/SshDeviceHost.ts | 16 +++- 2 files changed, 105 insertions(+), 4 deletions(-) diff --git a/apps/server/src/device/SshDeviceHost.test.ts b/apps/server/src/device/SshDeviceHost.test.ts index 5bba5d084c543..8249afb1e51c1 100644 --- a/apps/server/src/device/SshDeviceHost.test.ts +++ b/apps/server/src/device/SshDeviceHost.test.ts @@ -2,7 +2,9 @@ import { expect, it } from "@effect/vitest"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Net from "@t3tools/shared/Net"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as PlatformError from "effect/PlatformError"; @@ -196,6 +198,97 @@ it.effect("preserves installed status after probes and cleans failed agent activ }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); +it.effect("releases the forward of a bootstrap interrupted before its hub answered", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const home = yield* fs.makeTempDirectoryScoped(); + const probing = yield* Deferred.make(); + let holdReadiness = true; + let forwards = 0; + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + const forwarding = command._tag === "StandardCommand" && command.args.includes("-L"); + if (forwarding) { + forwards++; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + forwards--; + }), + ); + } + const output = forwarding + ? "" + : JSON.stringify({ + nodePath: "/node", + platforms: [{ platform: "ios", available: true }], + hubPort: 1234, + helpers: { serveSimAxSettings: null, serveSimCli: null }, + }); + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(123), + stdout: Stream.make(new TextEncoder().encode(output)), + stderr: Stream.empty, + all: Stream.empty, + exitCode: forwarding ? Effect.never : Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(forwarding), + kill: () => Effect.void, + stdin: Sink.drain, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + unref: Effect.succeed(Effect.void), + }); + }), + ); + const publishing = yield* Deferred.make(); + let holdReadyStatus = false; + const host = yield* SshDeviceHost.make( + { + kind: "ssh", + id: "test", + label: "Test", + target: "test.example", + }, + undefined, + (status) => + status === "ready" && holdReadyStatus + ? Deferred.succeed(publishing, undefined).pipe(Effect.andThen(Effect.never)) + : Effect.void, + ).pipe( + Effect.provide(Layer.mergeAll(ServerConfig.layerTest(home, home), Net.layer)), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.gen(function* () { + if (holdReadiness) { + yield* Deferred.succeed(probing, undefined); + return yield* Effect.never; + } + return HttpClientResponse.fromWeb(request, new Response("ok")); + }), + ), + ), + ); + const first = yield* host.ensureReady(() => Effect.void).pipe(Effect.forkChild); + yield* Deferred.await(probing); + yield* Fiber.interrupt(first); + expect(forwards).toBe(0); + holdReadiness = false; + yield* host.ensureReady(() => Effect.void); + yield* host.stop; + expect(forwards).toBe(0); + holdReadyStatus = true; + const second = yield* host.ensureReady(() => Effect.void).pipe(Effect.forkChild); + yield* Deferred.await(publishing); + yield* Fiber.interrupt(second); + holdReadyStatus = false; + yield* host.ensureReady(() => Effect.void); + expect(forwards).toBe(1); + yield* host.stop; + expect(forwards).toBe(0); + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), +); + it.effect("lists Android devices without probing the host again once it reports Android", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/device/SshDeviceHost.ts b/apps/server/src/device/SshDeviceHost.ts index d769a85ddb080..c1d57869ead95 100644 --- a/apps/server/src/device/SshDeviceHost.ts +++ b/apps/server/src/device/SshDeviceHost.ts @@ -473,13 +473,21 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( return next; }); + const closeConnection = Effect.suspend(() => { + const failedScope = connectionScope; + connectionScope = null; + ready = null; + return failedScope ? Scope.close(failedScope, Exit.void) : Effect.void; + }); + const connect = Effect.fn("SshDeviceHost.connect")(function* () { for (let attempt = 0; ; attempt++) { - const result = yield* connectOnce().pipe(Effect.result); + const result = yield* connectOnce().pipe( + Effect.onInterrupt(() => closeConnection), + Effect.result, + ); if (result._tag === "Success") return result.success; - const failedScope = connectionScope; - connectionScope = null; - if (failedScope) yield* Scope.close(failedScope, Exit.void); + yield* closeConnection; // SSH binds after the reservation is released, so a competing bind needs fresh ports. if ( attempt >= 2 || From bedc8658c313f4b870d826598b766408f9216ee6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 20:02:32 -0800 Subject: [PATCH 038/390] fix(mobile): Files search no longer carries into another workspace (#35902) --- apps/mobile/src/components/ui/AppBar.tsx | 22 +++++- .../files/thread-file-navigator-pane.test.tsx | 79 +++++++++++++++++++ .../files/thread-file-navigator-pane.tsx | 8 +- apps/mobile/vite.config.ts | 1 + 4 files changed, 104 insertions(+), 6 deletions(-) create mode 100644 apps/mobile/src/features/files/thread-file-navigator-pane.test.tsx diff --git a/apps/mobile/src/components/ui/AppBar.tsx b/apps/mobile/src/components/ui/AppBar.tsx index 7f76d91066130..7690b135f9e11 100644 --- a/apps/mobile/src/components/ui/AppBar.tsx +++ b/apps/mobile/src/components/ui/AppBar.tsx @@ -1,8 +1,8 @@ import { Toolbar } from "./Toolbar"; import { AppActivityIndicator } from "./AppActivityIndicator"; -import type { HeaderBarButtonMailSearchToolbarItem } from "react-native-screens"; +import type { HeaderBarButtonMailSearchToolbarItem, SearchBarCommands } from "react-native-screens"; import { NavigationContext } from "@react-navigation/native"; -import { use, useId, useLayoutEffect } from "react"; +import { use, useId, useLayoutEffect, useRef } from "react"; import { View } from "react-native"; import { createNativeHeaderMenu } from "./nativeHeaderMenu.ios"; import { ScreenHeaderButton } from "./ScreenHeaderButton"; @@ -78,6 +78,19 @@ export function AppBar(props: AppBarProps) { const usesNativeMailSearchToolbar = useNativeMailSearchToolbar(); const { search, menus } = props; const menu = menus?.[0]; + const searchBarRef = useRef(null); + const nativeSearchText = useRef(search?.value ?? ""); + const searchValue = search?.value; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- the native search bar keeps its own text; a value the caller changes (a reset) has to be pushed into it + useLayoutEffect(() => { + if (searchValue === undefined || searchValue === nativeSearchText.current) return; + nativeSearchText.current = searchValue; + searchBarRef.current?.setText(searchValue); + }, [searchValue]); + const reportSearchText = (text: string) => { + nativeSearchText.current = text; + search?.onChangeText(text); + }; const compactSearch = search !== undefined && (search.compactToolbar ?? !layout.usesSplitView) && @@ -142,8 +155,9 @@ export function AppBar(props: AppBarProps) { ? { obscureBackground: false } : undefined), placeholder: search.placeholder, - onChangeText: (event) => search.onChangeText(event.nativeEvent.text), - onCancelButtonPress: () => search.onChangeText(""), + ref: searchBarRef, + onChangeText: (event) => reportSearchText(event.nativeEvent.text), + onCancelButtonPress: () => reportSearchText(""), }, } : undefined), diff --git a/apps/mobile/src/features/files/thread-file-navigator-pane.test.tsx b/apps/mobile/src/features/files/thread-file-navigator-pane.test.tsx new file mode 100644 index 0000000000000..21f64998a3be9 --- /dev/null +++ b/apps/mobile/src/features/files/thread-file-navigator-pane.test.tsx @@ -0,0 +1,79 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { act, type ReactNode } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it, vi } from "vite-plus/test"; + +const queries = vi.hoisted(() => [] as { cwd: string | null; searchQuery: string }[]); + +vi.mock("@t3tools/client-runtime/state/filesystem", () => ({ + resolveFilesystemReadAccess: () => ({ canReadFiles: true, isPending: false }), +})); +vi.mock("../../state/session", () => ({ environmentSession: { sessionStateAtom: () => null } })); +vi.mock("../../state/query", () => ({ useEnvironmentQuery: () => ({ data: null, error: null }) })); +vi.mock("../../state/presentation", () => ({ + useEnvironmentPresentation: () => ({ isReady: true, presentation: null }), +})); +vi.mock("../settings/appearance/AppearancePreferencesProvider", () => ({ + useAppearancePreferences: () => ({ themeAppearance: "dark" }), +})); +vi.mock("./preload-workspace-file", () => ({ preloadWorkspaceFileContents: () => {} })); +vi.mock("./FileTreeBrowser", () => ({ FileTreeBrowser: () => null })); +vi.mock("./useFileTreeEntries", () => ({ + useFileTreeEntries: (input: { cwd: string | null; searchQuery: string }) => { + queries.push({ cwd: input.cwd, searchQuery: input.searchQuery }); + return { refresh: () => {}, selectPath: () => {} }; + }, +})); +vi.mock("../../components/ui/SheetPane", () => ({ + SheetPane: ({ children }: { children: ReactNode }) => children, +})); +vi.mock("../../components/ui/ScreenContent", () => ({ + ScreenContent: ({ children }: { children: ReactNode }) => children, +})); +vi.mock("../../components/ui/AppBar", () => ({ + AppBar: (props: { search: { value: string; onChangeText: (value: string) => void } }) => ( + props.search.onChangeText(event.target.value)} + /> + ), +})); + +import { ThreadFileNavigatorPane } from "./thread-file-navigator-pane"; + +it("starts unfiltered when the mounted pane switches to another workspace", () => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + const container = document.body.appendChild(document.createElement("div")); + const root = createRoot(container); + const render = (cwd: string) => + act(() => + root.render( + {}} + />, + ), + ); + try { + render("/first"); + const input = container.querySelector("input")!; + act(() => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call( + input, + "alpha", + ); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + expect(queries.at(-1)).toEqual({ cwd: "/first", searchQuery: "alpha" }); + + render("/second"); + expect(queries.at(-1)).toEqual({ cwd: "/second", searchQuery: "" }); + expect(container.querySelector("input")!.value).toBe(""); + } finally { + act(() => root.unmount()); + container.remove(); + } +}); diff --git a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx index 26cafcc764950..cc0c86e6d3e4b 100644 --- a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx +++ b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx @@ -20,7 +20,11 @@ export function ThreadFileNavigatorPane(props: { readonly selectedPath: string | null; readonly onSelectFile: (path: string) => void; }) { - const [searchQuery, setSearchQuery] = useState(""); + const workspaceKey = JSON.stringify([props.environmentId, props.cwd]); + // The pane stays mounted across thread switches; a query only filters the workspace it was typed in. + const [search, setSearch] = useState({ workspaceKey, query: "" }); + const searchQuery = search.workspaceKey === workspaceKey ? search.query : ""; + const setSearchQuery = (query: string) => setSearch({ workspaceKey, query }); const { themeAppearance: highlightTheme } = useAppearancePreferences(); const fileAccessSession = useEnvironmentQuery( environmentSession.sessionStateAtom(props.environmentId), @@ -48,7 +52,7 @@ export function ThreadFileNavigatorPane(props: { }; const fileTree = ( Date: Thu, 12 Nov 2026 20:06:26 -0800 Subject: [PATCH 039/390] fix(web): copying an alert callout keeps its alert Markdown (#35932) --- .../components/ChatMarkdown.browser.test.tsx | 25 +++++++++++++ apps/web/src/components/ChatMarkdown.tsx | 6 ++- apps/web/src/markdown-clipboard.test.ts | 32 ++++++++++++++++ apps/web/src/markdown-clipboard.ts | 37 +++++++++++++++++-- 4 files changed, 94 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.browser.test.tsx b/apps/web/src/components/ChatMarkdown.browser.test.tsx index b4bbdf186c3c9..d24b1c9299ac7 100644 --- a/apps/web/src/components/ChatMarkdown.browser.test.tsx +++ b/apps/web/src/components/ChatMarkdown.browser.test.tsx @@ -120,6 +120,7 @@ vi.mock(import("../hooks/useSettings"), async (original) => { }); import ChatMarkdown from "./ChatMarkdown"; +import { chatMarkdownClipboardPayload } from "../markdown-clipboard"; import { AudioPreview } from "./files/AudioPreview"; it("keeps compact audio playback loaded across signed URL renewals", async () => { @@ -617,6 +618,30 @@ describe("ChatMarkdown inline code selection", () => { }); }); +describe("ChatMarkdown alert copy", () => { + it("copies a fully selected callout as alert Markdown and a partial one as text", async () => { + setup(); + await using screen = await renderText("> [!WARNING]\n> Back up first."); + const [label, body] = screen.container.querySelectorAll('[role="note"] > p'); + const select = (startNode: Node, endNode: Node, endOffset: number) => { + const range = document.createRange(); + range.setStart(startNode, 0); + range.setEnd(endNode, endOffset); + const selection = window.getSelection()!; + selection.removeAllRanges(); + selection.addRange(range); + return chatMarkdownClipboardPayload(selection)?.text; + }; + const labelText = label!.lastChild!; + const bodyText = body!.firstChild!; + + expect(select(labelText, bodyText, bodyText.textContent!.length)).toBe( + "> [!WARNING]\n> Back up first.", + ); + expect(select(labelText, bodyText, "Back up".length)).toBe("Warning\n\nBack up"); + }); +}); + describe("ChatMarkdown file chip tooltips", () => { it("wraps a long path so its file name stays inside the tooltip", async () => { setup(); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index cc35c2907d46a..b19e815f5a8b0 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1055,19 +1055,21 @@ const CHAT_MARKDOWN_COMPONENTS = { ); }, blockquote: function MarkdownBlockquote({ node: _node, children, ...props }) { - const alert = - GITHUB_ALERT_PRESENTATIONS[String((props as Record)["data-alert"] ?? "")]; + const alertKind = String((props as Record)["data-alert"] ?? ""); + const alert = GITHUB_ALERT_PRESENTATIONS[alertKind]; if (!alert) { return
{children}
; } return (

diff --git a/apps/web/src/markdown-clipboard.test.ts b/apps/web/src/markdown-clipboard.test.ts index da6d331ef9c0d..e85e77b416f27 100644 --- a/apps/web/src/markdown-clipboard.test.ts +++ b/apps/web/src/markdown-clipboard.test.ts @@ -198,6 +198,38 @@ describe("serializeRenderedMarkdownFragment", () => { expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("first line\nsecond line"); }); + it("copies alert and insight callouts as their Markdown instead of the rendered label", () => { + // The shape ChatMarkdown renders for `> [!WARNING]\n> Back up first.`. + const callout = new FakeElement("DIV", [], { + role: "note", + "data-markdown-alert": "warning", + }).append( + new FakeElement("P", [], { + "data-thread-find-ignore": "true", + "data-markdown-alert-label": "", + }).append(new FakeElement("SVG", [], { "aria-hidden": "true" }), new FakeText("Warning")), + new FakeElement("P").append(new FakeText("Back up first.")), + ); + const container = new FakeElement("DIV").append(callout); + + expect(serializeRenderedMarkdownFragment(asNode(container))).toBe( + "> [!WARNING]\n> Back up first.", + ); + const insight = new FakeElement("DIV", [], { + role: "note", + "data-markdown-alert": "insight", + }).append( + new FakeElement("P", [], { + "data-thread-find-ignore": "true", + "data-markdown-alert-label": "", + }).append(new FakeText("Insight")), + new FakeElement("P").append(new FakeText("Caches are warm.")), + ); + expect(serializeRenderedMarkdownFragment(asNode(new FakeElement("DIV").append(insight)))).toBe( + `★ Insight ${"─".repeat(37)}\nCaches are warm.\n${"─".repeat(49)}`, + ); + }); + it("preserves explicit ordered-list values and continues from resets when copying", () => { const list = new FakeElement("OL", [], { start: "0" }).append( new FakeElement("LI").append(new FakeText("A")), diff --git a/apps/web/src/markdown-clipboard.ts b/apps/web/src/markdown-clipboard.ts index 10c83bf9a96bd..91f16c86535a6 100644 --- a/apps/web/src/markdown-clipboard.ts +++ b/apps/web/src/markdown-clipboard.ts @@ -10,6 +10,9 @@ const SANITIZED_HTML_SELECTOR = [ ...SKIPPED_CLASS_NAMES.map((className) => `.${className}`), ].join(", "); +const ALERT_ATTRIBUTE = "data-markdown-alert"; +const ALERT_LABEL = "data-markdown-alert-label"; + export interface MarkdownClipboardPayload { text: string; html: string; @@ -159,10 +162,21 @@ function serializeList(list: Element, ordered: boolean, inTableCell: boolean): s .join("\n")}\n\n`; } -function serializeBlockquote(quote: Element, inTableCell: boolean): string { - const content = outsideCode(serializeChildren(quote, inTableCell), (prose) => - prose.replace(/\n{3,}/g, "\n\n"), - ).trim(); +function serializeQuoteBody(quote: Element, inTableCell: boolean): string { + let content = ""; + for (const child of quote.childNodes) { + // A callout's rendered label stands for the marker its serializer writes. + if (child.nodeType === Node.ELEMENT_NODE && (child as Element).hasAttribute(ALERT_LABEL)) { + continue; + } + content += serializeNode(child, inTableCell); + } + return outsideCode(content, (prose) => prose.replace(/\n{3,}/g, "\n\n")).trim(); +} + +function serializeBlockquote(quote: Element, inTableCell: boolean, marker = ""): string { + const body = serializeQuoteBody(quote, inTableCell); + const content = marker && body ? `${marker}\n${body}` : marker || body; if (!content) return ""; const quoted = content .split("\n") @@ -171,6 +185,11 @@ function serializeBlockquote(quote: Element, inTableCell: boolean): string { return `${quoted}\n\n`; } +function serializeInsight(insight: Element, inTableCell: boolean): string { + const body = serializeQuoteBody(insight, inTableCell); + return `★ Insight ${"─".repeat(37)}\n${body ? `${body}\n` : ""}${"─".repeat(49)}\n\n`; +} + function serializeDetails(details: Element): string { const summary = details.querySelector(":scope > [data-markdown-details-summary]")?.textContent?.trim() ?? @@ -231,6 +250,9 @@ function serializeNode(node: Node, inTableCell = false): string { : escapeTablePipes(markdownCopy, inTableCell); } if (isSkippedElement(element)) return ""; + const alert = element.getAttribute(ALERT_ATTRIBUTE); + if (alert === "insight") return serializeInsight(element, inTableCell); + if (alert !== null) return serializeBlockquote(element, inTableCell, `[!${alert.toUpperCase()}]`); const headingLevel = /^H([1-6])$/.exec(element.tagName)?.[1]; if (headingLevel) { @@ -478,6 +500,13 @@ export function chatMarkdownClipboardPayload( } continue; } + // A range spanning exactly one callout's text clones its contents but not the callout. + const callout = ancestorElement?.closest(`[${ALERT_ATTRIBUTE}]`); + if (callout && range.toString().trim() === (callout.textContent ?? "").trim()) { + const wrapper = callout.cloneNode(false) as Element; + wrapper.append(...container.childNodes); + container.replaceChildren(wrapper); + } const text = serializeRenderedMarkdownFragment(container); if (!text) continue; texts.push(text); From 0a333a69e781be3208224006d344a5fb39355e76 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 20:06:38 -0800 Subject: [PATCH 040/390] fix(server): Codex shadow home shares the instance environment CODEX_HOME (#35912) --- .../src/provider/Drivers/CodexDriver.test.ts | 32 +++++++++++++++++++ .../src/provider/Drivers/CodexDriver.ts | 10 +++--- .../server/src/provider/Drivers/codexUsage.ts | 5 +-- apps/server/src/usage/UsageService.test.ts | 1 + 4 files changed, 39 insertions(+), 9 deletions(-) diff --git a/apps/server/src/provider/Drivers/CodexDriver.test.ts b/apps/server/src/provider/Drivers/CodexDriver.test.ts index 597be85623115..a5ecf3ccca541 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.test.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.test.ts @@ -580,6 +580,38 @@ it.layer(layerTest)("CodexDriver", (it) => { }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn), Effect.scoped), ); + it.effect.skipIf(windowsHost)( + "shares state from the environment CODEX_HOME in a shadow home", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const tempDir = yield* fs + .makeTempDirectoryScoped({ prefix: "t3-codex-driver-" }) + .pipe(Effect.flatMap((directory) => fs.realPath(directory))); + const codexHome = NodePath.join(tempDir, "custom-codex"); + const shadowHome = NodePath.join(tempDir, "shadow"); + yield* fs.makeDirectory(codexHome, { recursive: true }); + yield* fs.writeFileString(NodePath.join(codexHome, "config.toml"), 'model = "custom"\n'); + const instance = yield* CodexDriver.create({ + instanceId: ProviderInstanceId.make("env-shadow"), + displayName: "env-shadow", + enabled: false, + environment: [{ name: "CODEX_HOME", value: codexHome, sensitive: false }], + config: { ...CodexDriver.defaultConfig(), shadowHomePath: shadowHome }, + }).pipe(Effect.provideService(HostProcess.HomeDirectory, NodePath.join(tempDir, "user"))); + expect(instance.continuationIdentity.continuationKey).toBe(`codex:home:${codexHome}`); + expect(yield* fs.readLink(NodePath.join(shadowHome, "config.toml"))).toBe( + NodePath.join(codexHome, "config.toml"), + ); + expect(yield* fs.readLink(NodePath.join(shadowHome, "sessions"))).toBe( + NodePath.join(codexHome, "sessions"), + ); + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn), + Effect.scoped, + ), + ); + for (const fixture of [ { name: "leaves mise npm-backend installations manual-only", diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index f2bfa4082dcbf..f8a753169b54a 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -190,11 +190,11 @@ export const CodexDriver: ProviderDriver< yield* HostProcess.Environment, ); const environmentHome = processEnv.CODEX_HOME; - const homeLayout = yield* resolveCodexHomeLayout( - !config.homePath.trim() && !config.shadowHomePath.trim() && environmentHome?.trim() + const homeConfig = + !config.homePath.trim() && environmentHome?.trim() ? { ...config, homePath: environmentHome } - : config, - ); + : config; + const homeLayout = yield* resolveCodexHomeLayout(homeConfig); const continuationIdentity = codexContinuationIdentity(homeLayout); const stampIdentity = withInstanceIdentity({ instanceId, @@ -245,7 +245,7 @@ export const CodexDriver: ProviderDriver< accentColor, environment, enabled, - config, + config: homeConfig, }, { onUsageLimits: (update) => snapshot.applyUsageLimits(update), diff --git a/apps/server/src/provider/Drivers/codexUsage.ts b/apps/server/src/provider/Drivers/codexUsage.ts index c8c9fc9b4cae3..7f99d611c3de5 100644 --- a/apps/server/src/provider/Drivers/codexUsage.ts +++ b/apps/server/src/provider/Drivers/codexUsage.ts @@ -516,10 +516,7 @@ export const codexUsageReader: ProviderUsageReader< const path = yield* Path.Path; const environmentHome = environment.CODEX_HOME?.trim(); const layout = yield* resolveCodexHomeLayout( - config.setupMode !== "managed" && - !config.homePath.trim() && - !config.shadowHomePath.trim() && - environmentHome + config.setupMode !== "managed" && !config.homePath.trim() && environmentHome ? { ...config, homePath: environmentHome } : config, ); diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index e1562070cf28f..f030af29ea13f 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -2337,6 +2337,7 @@ describe("UsageService", () => { }, [ProviderInstanceId.make("codex-work")]: { driver: ProviderDriverKind.make("codex"), + config: { shadowHomePath: NodePath.join(home, "work-shadow") }, environment: [{ name: "CODEX_HOME", value: codexHome, sensitive: false }], }, [ProviderInstanceId.make("codex-alias")]: { From 97cfc07759fed2a67edef22de4370ed74ff40030 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 20:11:51 -0800 Subject: [PATCH 041/390] fix(preview): remote viewers recover from stalled first captures (#25381) --- .../features/browser/PreviewStreamWebView.tsx | 2 +- apps/server/src/preview/ServerBrowser.test.ts | 40 ++++++++++++++++++- apps/server/src/preview/ServerBrowser.ts | 20 ++++++++-- 3 files changed, 56 insertions(+), 6 deletions(-) diff --git a/apps/mobile/src/features/browser/PreviewStreamWebView.tsx b/apps/mobile/src/features/browser/PreviewStreamWebView.tsx index 3fae93f483bcc..d4a4690dbaf4b 100644 --- a/apps/mobile/src/features/browser/PreviewStreamWebView.tsx +++ b/apps/mobile/src/features/browser/PreviewStreamWebView.tsx @@ -405,7 +405,7 @@ function PreviewStreamDocumentView({ onPictureInPicture?.(message, message.detail); return; case "status": - clearBootstrapTimer(); + if (message.status !== "connecting") clearBootstrapTimer(); if (message.status === "error") { fail(message.detail ?? "Browser stream failed."); return; diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index 181f4904738f8..72cee7e5396fe 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -114,6 +114,7 @@ function makeSession( if (method === "Page.getNavigationHistory") return { currentIndex: 0, entries: [{}] }; if (method === "Page.getLayoutMetrics") return { cssVisualViewport: { pageX: 0, pageY: 0 } }; if (method === "Page.captureScreenshot") { + if ((input as { format: string }).format === "jpeg" && heldStill) await heldStill(); // A snapshot sizes its screenshot from the PNG header; viewers get JPEG frames as-is. return { data: (input as { format: string }).format === "png" ? PNG_HEADER : "ZnJhbWU=" }; } @@ -384,6 +385,7 @@ let encoderPageOverride: Page | undefined; const encoderStreams: Array<{ frameRate: number | undefined; bitsPerSecond: number }> = []; const contextRequests: Array<{ profileId: string; isolated: boolean }> = []; let contextGate: PromiseWithResolvers | null = null; +let heldStill: (() => Promise) | null = null; let pageCopy = ""; let keyCopy = ""; let focusedFrame: ReturnType | null = null; @@ -568,6 +570,7 @@ beforeEach(() => { encoderStreams.length = 0; contextRequests.length = 0; contextGate = null; + heldStill = null; contextFailure = null; pageSetupFailure = null; initialNavigation = null; @@ -1130,6 +1133,38 @@ it.live("enforces provider ownership and explicit targets when a session has mul ).pipe(Effect.provide(layer)), ); +it.effect("a first still that never returns does not stall the tab's later captures", () => + Effect.scoped( + Effect.gen(function* () { + const { browser, broker, tabId } = yield* ready; + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + yield* Effect.addFinalizer(() => Effect.sync(() => vi.useRealTimers())); + const capturing = Promise.withResolvers(); + const stuck = Promise.withResolvers(); + heldStill = () => { + heldStill = null; + capturing.resolve(); + return stuck.promise; + }; + yield* Effect.addFinalizer(() => Effect.sync(() => stuck.resolve())); + yield* browser.attachViewer(viewerInput(tabId, true)); + yield* Effect.promise(() => capturing.promise); + const snapshotting = yield* broker + .invoke({ scope, tabId, operation: "snapshot", input: {} }) + .pipe(Effect.forkScoped); + yield* Effect.promise(() => vi.advanceTimersByTimeAsync(2_000)); + const snapshot = yield* Fiber.join(snapshotting); + expect(snapshot.accessibilityTree).toContain("delete"); + const second = yield* browser.attachViewer(viewerInput(tabId, false)); + const frame = yield* Stream.fromQueue(second.output).pipe( + Stream.filter((output) => output._tag === "frame"), + Stream.runHead, + ); + expect(Option.isSome(frame)).toBe(true); + }), + ).pipe(Effect.provide(layer)), +); + it.live("streams to a read-only viewer without allowing takeover, input, or viewport changes", () => Effect.scoped( Effect.gen(function* () { @@ -1148,7 +1183,10 @@ it.live("streams to a read-only viewer without allowing takeover, input, or view yield* viewer.input(message); expect(page.setViewportSize).not.toHaveBeenCalled(); expect(session.send.mock.calls.some(([method]) => method.startsWith("Input."))).toBe(false); - const outputs = yield* Queue.takeAll(viewer.output); + const outputs = yield* Stream.fromQueue(viewer.output).pipe( + Stream.takeUntil((output) => output._tag === "frame"), + Stream.runCollect, + ); expect(outputs).toContainEqual( expect.objectContaining({ _tag: "frame", data: Buffer.from("frame") }), ); diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index e801aad619e55..b8dcb6e444178 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -111,6 +111,8 @@ const SCREENCAST_MOTION_WINDOW_MS = 300; const SCREENCAST_MOTION_QUALITY = 50; const HOST_RECONNECT_DELAY = "1 second"; const DESKTOP_ATTACH_TIMEOUT = "10 seconds"; +// A viewer still is best-effort; a capture Chromium never answers must not hold the capture lock. +const VIEWER_STILL_TIMEOUT_MS = 2_000; const VIEWER_OUTPUT_LIMIT = 64; const RECORDING_SCREENCAST = { format: "jpeg", quality: 90, everyNthFrame: 1 } as const; const RECORDING_BINARY_EXPORT_MIN_BYTES = 8 * 1024 * 1024; @@ -2945,9 +2947,19 @@ const make = Effect.gen(function* () { broadcastControl(tab); pushFileChooser(tab); const pushStill = async () => { - const data = await withCaptureLock(tab, () => - ServerBrowserPage.captureViewport(session, { format: "jpeg", quality }), - ).catch(() => null); + const data = await withCaptureLock(tab, async () => { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + ServerBrowserPage.captureViewport(session, { format: "jpeg", quality }), + new Promise((resolve) => { + timer = setTimeout(() => resolve(null), VIEWER_STILL_TIMEOUT_MS); + }), + ]); + } finally { + clearTimeout(timer); + } + }).catch(() => null); if (data) viewer.push({ _tag: "frame", data: Buffer.from(data, "base64"), ack: Effect.void }); }; let settleTimer: ReturnType | null = null; @@ -3005,7 +3017,7 @@ const make = Effect.gen(function* () { yield* Effect.promise(() => startScreencast(1)); // An idle page does not repaint for a new screencast, so the viewer // starts from a still unless a live frame beat it. - if (!screencastStarted) yield* Effect.promise(pushStill); + if (!screencastStarted) yield* Effect.forkScoped(Effect.promise(pushStill)); return { output, input: (raw: unknown) => From 0c4b1d8f277db542ac0142699748e42b80540814 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 20:31:38 -0800 Subject: [PATCH 042/390] fix(server): new turns wait for overlapping checkpoint file restores (#23671) --- .../CheckpointRollbackService.test.ts | 132 +++++++++++++++++- .../CheckpointRollbackService.ts | 17 ++- .../ProviderTurnStartService.test.ts | 91 +++++++++++- .../ProviderTurnStartService.ts | 20 ++- apps/server/src/workspace/workspaceLease.ts | 92 ++++++++++++ 5 files changed, 343 insertions(+), 9 deletions(-) diff --git a/apps/server/src/orchestration-v2/CheckpointRollbackService.test.ts b/apps/server/src/orchestration-v2/CheckpointRollbackService.test.ts index e5ef81ad46b54..224fafb02c92d 100644 --- a/apps/server/src/orchestration-v2/CheckpointRollbackService.test.ts +++ b/apps/server/src/orchestration-v2/CheckpointRollbackService.test.ts @@ -32,7 +32,7 @@ import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ServerConfig from "../config.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as GitWorkflow from "../git/GitWorkflowService.ts"; -import { withWorkspaceLease } from "../workspace/workspaceLease.ts"; +import { withTurnStartReservation, withWorkspaceLease } from "../workspace/workspaceLease.ts"; import { ProviderWorkspaceMissingError } from "../provider/Errors.ts"; import { resolveCodexRollbackTurnCount } from "./Adapters/CodexAdapterV2.ts"; import * as CheckpointService from "./CheckpointService.ts"; @@ -394,6 +394,136 @@ it.effect.each([ }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); +it.effect.each([{ restoreFiles: true }, { restoreFiles: false }])( + "holds turn starts inside the worktree during rollback with restoreFiles $restoreFiles", + ({ restoreFiles }) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspaceRoot = yield* fileSystem.realPath( + yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-rollback-reservation-" }), + ); + const worktreesDir = path.join(workspaceRoot, "worktrees"); + const worktreePath = path.join(worktreesDir, "repo", "feature"); + yield* fileSystem.makeDirectory(path.join(worktreePath, ".git"), { recursive: true }); + const threadId = ThreadId.make("rollback-reservation"); + const providerThreadId = ProviderThreadId.make("rollback-reservation-provider-thread"); + const providerInstanceId = ProviderInstanceId.make("rollback-reservation-provider"); + const checkpointId = CheckpointId.make("rollback-reservation-checkpoint"); + const scopeId = CheckpointScopeId.make("rollback-reservation-scope"); + const release = Deferred.makeUnsafe(); + const openEntered = Deferred.makeUnsafe(); + const restoreEntered = Deferred.makeUnsafe(); + const releaseRestore = Deferred.makeUnsafe(); + const calls: string[] = []; + const projection = { + thread: { + id: threadId, + projectId: ProjectId.make("rollback-reservation-project"), + worktreePath, + branch: "feature/rollback-reservation", + activeProviderThreadId: providerThreadId, + modelSelection: { instanceId: providerInstanceId, model: "test-model" }, + }, + providerThread: { + id: providerThreadId, + providerSessionId: ProviderSessionId.make("rollback-reservation-session"), + providerInstanceId, + }, + providerSession: undefined, + checkpoint: { id: checkpointId, scopeId, status: "ready", appRunOrdinal: 0 }, + scope: { id: scopeId, cwd: worktreePath }, + runsToRollback: [], + targetAttempt: undefined, + rolledBackAttemptIds: [], + staleCheckpoints: [], + rootNodes: [], + providerTurns: [], + } as unknown as ProjectionStore.ProjectionCheckpointRollbackContext; + const testLayer = CheckpointRollbackService.layer.pipe( + Layer.provide( + Layer.mergeAll( + ThreadCommandExecutor.layer, + NodeServices.layer, + Layer.mock(ServerConfig.ServerConfig)({ worktreesDir } as never), + ServerSettings.layerTest(), + Layer.mock(ProjectStore.ProjectStoreV2)({ + get: () => Effect.succeedSome({ workspaceRoot } as never), + }), + Layer.mock(GitWorkflow.GitWorkflowService)({}), + Layer.mock(CheckpointService.CheckpointServiceV2)({ + restore: () => + Deferred.succeed(restoreEntered, undefined).pipe( + Effect.andThen(Deferred.await(releaseRestore)), + Effect.andThen(Effect.sync(() => calls.push("files"))), + ), + }), + Layer.mock(EventSink.EventSinkV2)({ write: () => Effect.succeed([]) }), + IdAllocator.layer, + Layer.mock(ProjectionStore.ProjectionStoreV2)({ + getThread: () => Effect.succeed(projection.thread), + getCheckpointRollbackContext: () => Effect.succeed(projection), + getShellSnapshot: () => + Effect.succeed({ + schemaVersion: 1, + snapshotSequence: 0, + threads: [], + archivedThreads: [], + }), + }), + Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({ + open: () => + Deferred.succeed(openEntered, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.as({} as never), + ), + }), + Layer.mock(RuntimePolicy.RuntimePolicyV2)({ + resolve: () => Effect.succeed({ cwd: worktreePath } as never), + }), + ), + ), + ); + yield* Effect.gen(function* () { + const service = yield* CheckpointRollbackService.CheckpointRollbackServiceV2; + const rollback = yield* service + .execute({ threadId, providerThreadId, checkpointId, scopeId, restoreFiles }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(openEntered); + // A synchronous realPath lets the forked start reach the reservation + // before the next assertion, so "not entered" means "waiting". + const startTurn = withTurnStartReservation( + FileSystem.makeNoop({ realPath: (target) => Effect.succeed(target) }), + path, + path.join(worktreePath, "packages", "app"), + Effect.sync(() => calls.push("turn start")), + ).pipe(Effect.forkChild({ startImmediately: true })); + const turnStart = yield* startTurn; + if (restoreFiles) { + assert.deepEqual(calls, []); + yield* Deferred.succeed(release, undefined); + // The reservation must still be held while files are being restored, + // not only while the provider rollback is in flight. + yield* Deferred.await(restoreEntered); + const duringRestore = yield* startTurn; + assert.deepEqual(calls, []); + yield* Deferred.succeed(releaseRestore, undefined); + yield* Fiber.join(duringRestore); + } else { + // Nothing is rewritten, so the turn start must not wait for the provider. + yield* Fiber.join(turnStart); + yield* Deferred.succeed(release, undefined); + } + yield* Fiber.join(rollback); + yield* Fiber.join(turnStart); + assert.deepEqual( + calls, + restoreFiles ? ["files", "turn start", "turn start"] : ["turn start"], + ); + }).pipe(Effect.provide(testLayer)); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + const seedRollbackHistory = Effect.gen(function* () { const store = yield* ProjectionStore.ProjectionStoreV2; const now = yield* DateTime.now; diff --git a/apps/server/src/orchestration-v2/CheckpointRollbackService.ts b/apps/server/src/orchestration-v2/CheckpointRollbackService.ts index 16cbe8b101e28..8924a3eb9a78b 100644 --- a/apps/server/src/orchestration-v2/CheckpointRollbackService.ts +++ b/apps/server/src/orchestration-v2/CheckpointRollbackService.ts @@ -21,7 +21,10 @@ import * as GitWorkflowService from "../git/GitWorkflowService.ts"; import type * as ServerConfig from "../config.ts"; import type * as ServerSettings from "../serverSettings.ts"; import { makeIsManagedWorktreePath } from "../worktreesDirectory.ts"; -import { withWorkspaceLease } from "../workspace/workspaceLease.ts"; +import { + withCheckpointRestoreReservation, + withWorkspaceLease, +} from "../workspace/workspaceLease.ts"; import { isCheckpointRestoreIsolated, SHARED_WORKSPACE_RESTORE_MESSAGE, @@ -422,9 +425,15 @@ export const layer: Layer.Layer< function* (input) { // Storage cleanup removes worktrees under the same lease. const thread = yield* projections.getThread(input.threadId); - yield* thread.worktreePath === null - ? execute(input) - : withWorkspaceLease(path.resolve(thread.worktreePath), execute(input)); + if (thread.worktreePath === null) return yield* execute(input); + const worktreePath = path.resolve(thread.worktreePath); + const rollback = withWorkspaceLease(worktreePath, execute(input)); + // Shared-workspace restores are rejected and conversation-only rewinds + // touch no files; reserving for them would stall turn starts on the + // slow provider rollback for nothing. + yield* input.restoreFiles === false + ? rollback + : withCheckpointRestoreReservation(fileSystem, path, worktreePath, rollback); }, (effect, input) => effect.pipe( diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts index f7e165f4417c9..62f095b6fd913 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts @@ -35,7 +35,10 @@ import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as ServerConfig from "../config.ts"; -import { withWorkspaceLease } from "../workspace/workspaceLease.ts"; +import { + withCheckpointRestoreReservation, + withWorkspaceLease, +} from "../workspace/workspaceLease.ts"; import * as GitWorkflow from "../git/GitWorkflowService.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import { layerRealGit } from "./ProviderTurnStartService.testkit.ts"; @@ -1565,6 +1568,92 @@ effectIt.effect.each(["cleanup-first", "startup-first"])( }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); +// regression: #12341 a turn start overlapping a checkpoint restore wrote into the checkout +// The filesystem is synchronous so the turn start either reaches the provider or parks on the +// lease before the restore is released; realPath models the symlink alias and missing worktrees. +effectIt.effect.each([ + { + overlap: "a project-root thread during a restore of the project checkout", + restorePath: "/tmp/native-account-project", + worktreePath: undefined, + realPaths: {}, + missing: [], + }, + { + overlap: "a symlink alias of the restoring checkout", + restorePath: "/tmp/restore-overlap/link", + worktreePath: "/tmp/restore-overlap/checkout", + realPaths: { "/tmp/restore-overlap/link": "/tmp/restore-overlap/checkout" }, + missing: [], + }, + { + overlap: "a directory inside the restoring checkout", + restorePath: "/tmp/restore-overlap/checkout", + worktreePath: "/tmp/restore-overlap/checkout/packages/app", + realPaths: {}, + missing: [], + }, + { + overlap: "a not-yet-recreated worktree under a symlink alias of the restoring one", + restorePath: "/tmp/restore-overlap/link/missing-child", + worktreePath: "/tmp/restore-overlap/checkout/missing-child", + realPaths: { "/tmp/restore-overlap/link": "/tmp/restore-overlap/checkout" }, + missing: [ + "/tmp/restore-overlap/link/missing-child", + "/tmp/restore-overlap/checkout/missing-child", + ], + }, +] as const)( + "does not start provider work in $overlap until the restore finishes", + ({ restorePath, worktreePath, realPaths, missing }) => + Effect.gen(function* () { + const order: Array = []; + const releaseRestore = yield* Deferred.make(); + const restoreEntered = yield* Deferred.make(); + const fileSystem = FileSystem.makeNoop({ + exists: () => Effect.succeed(true), + realPath: (candidate) => + (missing as ReadonlyArray).includes(candidate) + ? Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "FileSystem", + method: "realPath", + pathOrDescriptor: candidate, + }), + ) + : Effect.succeed((realPaths as Record)[candidate] ?? candidate), + }); + const harness = makeLocalCommandHarness({ + text: "Continue", + ...(worktreePath === undefined ? {} : { worktreePath }), + fileSystem, + failReadsAfterRunning: true, + }); + const open = harness.open.getMockImplementation()!; + harness.open.mockImplementation(() => + Effect.sync(() => order.push("provider session opened")).pipe(Effect.andThen(open())), + ); + // The reservation CheckpointRollbackServiceV2.execute holds around its + // isolation check, provider rollback, and git restore of `restorePath`. + const restore = yield* withCheckpointRestoreReservation( + fileSystem, + yield* Path.Path, + restorePath, + Deferred.succeed(restoreEntered, undefined).pipe( + Effect.andThen(Deferred.await(releaseRestore)), + Effect.andThen(Effect.sync(() => order.push("checkpoint files restored"))), + ), + ).pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(restoreEntered); + const startup = yield* harness.start.pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.succeed(releaseRestore, undefined); + yield* Fiber.join(restore); + yield* Fiber.join(startup); + expect(order).toEqual(["checkpoint files restored", "provider session opened"]); + }).pipe(Effect.provide(Path.layer)), +); + effectIt.effect("reads current startup state after waiting for the workspace lease", () => Effect.gen(function* () { const worktreePath = "/tmp/provider-start-fresh-state"; diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index f8d78f708773d..8f01d117e1739 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -30,7 +30,7 @@ import { CLAUDE_PROVIDER, CLAUDE_QUERY_ENDED_BEFORE_TURN_DETAIL, } from "./Adapters/ClaudeAdapterV2.ts"; -import { withWorkspaceLease } from "../workspace/workspaceLease.ts"; +import { withTurnStartReservation, withWorkspaceLease } from "../workspace/workspaceLease.ts"; import * as GitWorkflowService from "../git/GitWorkflowService.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as ProviderAuthService from "../provider/ProviderAuthService.ts"; @@ -154,7 +154,9 @@ export const layer: Layer.Layer< const turnAnalytics = yield* ProviderUsageAnalytics.ProviderTurnAnalytics; const contextHandoffService = yield* ContextHandoffService.ContextHandoffServiceV2; const idAllocator = yield* IdAllocator.IdAllocatorV2; + const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const projects = yield* ProjectService.ProjectService; const providerAuth = yield* ProviderAuthService.ProviderAuthService; const projectionStore = yield* ProjectionStore.ProjectionStoreV2; const providerSessions = yield* ProviderSessionManager.ProviderSessionManagerV2; @@ -1763,9 +1765,21 @@ export const layer: Layer.Layer< // Storage cleanup removes worktrees under the same lease. Only startup // holds it; the provider turn runs after it is released. const { thread } = yield* projectionStore.getThreadRecords(input.threadId, []); - const providerTurn = yield* thread.worktreePath === null + const cwd = + thread.worktreePath ?? + (yield* projects + .getById(thread.projectId, { includeDeleted: true }) + .pipe(Effect.map((project) => Option.getOrUndefined(project)?.workspaceRoot))); + const providerTurn = yield* cwd === undefined ? start(input) - : withWorkspaceLease(path.resolve(thread.worktreePath), start(input)); + : withTurnStartReservation( + fileSystem, + path, + path.resolve(cwd), + thread.worktreePath === null + ? start(input) + : withWorkspaceLease(path.resolve(thread.worktreePath), start(input)), + ); if (providerTurn !== undefined) yield* providerTurn; }, (effect, input) => diff --git a/apps/server/src/workspace/workspaceLease.ts b/apps/server/src/workspace/workspaceLease.ts index 8ad6e41b859c8..90e6124bb1865 100644 --- a/apps/server/src/workspace/workspaceLease.ts +++ b/apps/server/src/workspace/workspaceLease.ts @@ -1,4 +1,7 @@ +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import type * as FileSystem from "effect/FileSystem"; +import type * as Path from "effect/Path"; import * as Semaphore from "effect/Semaphore"; const leases = new Map(); @@ -20,3 +23,92 @@ export const withWorkspaceLease = ( ), ); }); + +// A restore rewrites files under its directory, so it must exclude provider +// startup in any overlapping workspace: symlink aliases, parents, and children. +// Kept apart from withWorkspaceLease because that lease is non-reentrant and +// restores call Git work that leases the project root. +interface Reservation { + readonly cwd: string; + readonly kind: "restore" | "turn-start"; +} + +const reservations = new Set(); +let reservationReleased = Deferred.makeUnsafe(); + +// path.relative folds win32 drive-letter case and treats a POSIX backslash as +// part of a name, unlike a raw prefix check. +const contains = (path: Path.Path, parent: string, child: string) => { + const relative = path.relative(parent, child); + return ( + relative === "" || + (!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`)) + ); +}; + +const conflicts = (path: Path.Path, a: Reservation, b: Reservation) => + (a.kind === "restore" || b.kind === "restore") && + (contains(path, a.cwd, b.cwd) || contains(path, b.cwd, a.cwd)); + +const reserve = (path: Path.Path, reservation: Reservation): Effect.Effect => + Effect.suspend(() => { + for (const held of reservations) { + if (conflicts(path, held, reservation)) { + return Deferred.await(reservationReleased).pipe(Effect.andThen(reserve(path, reservation))); + } + } + reservations.add(reservation); + return Effect.void; + }); + +const release = (reservation: Reservation) => + Effect.sync(() => { + if (!reservations.delete(reservation)) return; + const released = reservationReleased; + reservationReleased = Deferred.makeUnsafe(); + Deferred.doneUnsafe(released, Effect.void); + }); + +// A worktree removed by storage cleanup is recreated inside the reserved +// section, so resolve its nearest existing ancestor: a symlink alias above it +// must still canonicalize the same on both sides. Walks at most the path depth. +const canonicalize = ( + fileSystem: FileSystem.FileSystem, + path: Path.Path, + target: string, +): Effect.Effect => + fileSystem.realPath(target).pipe( + Effect.catchReason("PlatformError", "NotFound", () => { + const parent = path.dirname(target); + return parent === target + ? Effect.succeed(target) + : canonicalize(fileSystem, path, parent).pipe( + Effect.map((resolved) => path.join(resolved, path.basename(target))), + ); + }), + Effect.orElseSucceed(() => target), + ); + +const withReservation = + (kind: Reservation["kind"]) => + ( + fileSystem: FileSystem.FileSystem, + path: Path.Path, + cwd: string, + effect: Effect.Effect, + ): Effect.Effect => + canonicalize(fileSystem, path, cwd).pipe( + Effect.flatMap((canonical) => { + const reservation: Reservation = { cwd: canonical, kind }; + return reserve(path, reservation).pipe( + Effect.andThen(effect), + Effect.ensuring(release(reservation)), + ); + }), + ); + +/** Exclusive over every overlapping workspace while checkpoint files are restored. */ +export const withCheckpointRestoreReservation = withReservation("restore"); + +/** Waits out overlapping restores; turn startups never wait on each other. */ +export const withTurnStartReservation = withReservation("turn-start"); From 67d91a0390d0cc189b783187a081d792fe3a731f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 20:32:54 -0800 Subject: [PATCH 043/390] fix(server): preview_navigate returns the committed page when load is slow (#26127) Co-authored-by: Elliot Drel --- apps/server/src/preview/ServerBrowser.test.ts | 131 +++++++++++++++++- apps/server/src/preview/ServerBrowser.ts | 54 +++++--- 2 files changed, 167 insertions(+), 18 deletions(-) diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index 72cee7e5396fe..b1b17334c30cd 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -204,12 +204,15 @@ function makeContext(onClose?: (context: BrowserContext) => void, initialUrl = " mainFrame: () => page, frames: () => [page], url: () => url, + commit: (next: string) => { + url = next; + }, title: vi.fn(async () => "test page"), viewportSize: vi.fn(() => viewport), setViewportSize: vi.fn(async (size: typeof viewport) => { viewport = size; }), - goto: vi.fn(async (next: string) => { + goto: vi.fn(async (next: string, _options?: { timeout?: number; waitUntil?: string }) => { url = next; events.emit("load"); const pending = initialNavigation; @@ -220,7 +223,7 @@ function makeContext(onClose?: (context: BrowserContext) => void, initialUrl = " goForward: vi.fn(async () => {}), reload: vi.fn(async () => {}), emulateMedia: vi.fn(async () => {}), - waitForLoadState: vi.fn(async () => {}), + waitForLoadState: vi.fn(async (_state?: string, _options?: { timeout?: number }) => {}), evaluate: vi.fn(async () => ({ url, title: "test page", @@ -693,6 +696,130 @@ it.live("an initial download stays available after preview_open reports the navi ).pipe(Effect.provide(layer)), ); +it.effect.each([ + { renderer: "idle", title: "Heavy page" }, + { renderer: "busy", title: null }, +])( + "navigate returns the committed page while its load event is still pending ($renderer renderer)", + ({ renderer, title }) => + Effect.scoped( + Effect.gen(function* () { + const { broker, tabId } = yield* ready; + const page = contexts[0]!.page; + const titleRequested = Promise.withResolvers(); + // A busy main thread never answers title(); drive its native deadline explicitly. + page.title.mockImplementation(() => { + titleRequested.resolve(); + return renderer === "busy" + ? new Promise(() => {}) + : Promise.resolve("Heavy page"); + }); + const heavy = "http://localhost:5173/heavy"; + const timedOut = (method: string, timeout: number | undefined) => + Object.assign(new Error(`${method}: Timeout ${timeout}ms exceeded.`), { + name: "TimeoutError", + }); + // The document commits and parses, but a stalled subresource holds back `load`. + page.goto.mockImplementation(async (next, options) => { + page.commit(next); + page.emit("request", { + url: () => next, + isNavigationRequest: () => true, + frame: () => page, + }); + if (options?.waitUntil === "load") throw timedOut("page.goto", options.timeout); + }); + page.waitForLoadState.mockImplementation(async (state, options) => { + if (state === undefined || state === "load") { + throw timedOut("page.waitForLoadState", options?.timeout); + } + }); + + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + const result = yield* broker + .invoke({ + scope, + tabId, + operation: "navigate", + input: { url: heavy, timeoutMs: 4_000 }, + }) + .pipe(Effect.forkScoped); + const titleRead = yield* Effect.raceFirst( + Effect.promise(() => titleRequested.promise).pipe(Effect.as(true)), + Fiber.await(result).pipe(Effect.as(false)), + ); + if (titleRead && renderer === "busy") { + yield* Effect.promise(() => vi.advanceTimersByTimeAsync(250)); + } + const status = yield* Fiber.join(result); + + expect(status).toMatchObject({ url: heavy, title, loading: true }); + }), + ).pipe(Effect.ensuring(Effect.sync(() => vi.useRealTimers())), Effect.provide(layer)), +); + +it.effect("a desktop status answers with the last viewport while the renderer cannot measure", () => + Effect.scoped( + Effect.gen(function* () { + const { broker } = yield* ready; + desktopRendersNext = true; + const opened = yield* broker.invoke({ + scope, + operation: "open", + input: { url: "https://example.com/", reuseExistingTab: false, show: false }, + }); + const measuring = Promise.withResolvers(); + desktopSizeAnswer = () => { + measuring.resolve(); + return new Promise(() => {}); + }; + // A resize makes the next status measure again. + yield* Effect.promise(() => + desktopConnections[0]!.context.sessions[0]!.emitAsync("Page.frameResized"), + ); + + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + const result = yield* broker + .invoke({ + scope, + tabId: opened.tabId!, + operation: "status", + input: {}, + }) + .pipe(Effect.forkScoped); + yield* Effect.promise(() => measuring.promise); + yield* Effect.promise(() => vi.advanceTimersByTimeAsync(250)); + const status = yield* Fiber.join(result); + + expect(status.viewport).toEqual({ width: 1920, height: 1080 }); + }), + ).pipe(Effect.ensuring(Effect.sync(() => vi.useRealTimers())), Effect.provide(layer)), +); + +it.live("navigate fails when the page never commits within its timeout", () => + Effect.scoped( + Effect.gen(function* () { + const { broker, tabId } = yield* ready; + contexts[0]!.page.goto.mockImplementation(async () => { + throw Object.assign(new Error("page.goto: Timeout 4000ms exceeded."), { + name: "TimeoutError", + }); + }); + + const error = yield* broker + .invoke({ + scope, + tabId, + operation: "navigate", + input: { url: "http://10.255.255.1/", timeoutMs: 4_000 }, + }) + .pipe(Effect.flip); + + expect(error).toMatchObject({ _tag: "PreviewAutomationTimeoutError", timeoutMs: 4_000 }); + }), + ).pipe(Effect.provide(layer)), +); + it.live.each(["desktop" as const, undefined])( "explicit desktop targets ($0 runtime) fail without creating a server tab", (runtime) => diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index b8dcb6e444178..fdffcc49414ed 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -160,6 +160,8 @@ const EDITABLE_AT_POINT_SCRIPT = `(x, y) => { }`; const UNATTACHED_FILL_VIEWPORT = { width: 1280, height: 800 } as const; const NAVIGATION_TIMEOUT_MS = 15_000; +// Status reads run in the renderer; a busy page must not hold a status past the broker's grace. +const STATUS_READ_MS = 250; const VIEWER_NAVIGATION_OPTIONS = { waitUntil: "commit", timeout: NAVIGATION_TIMEOUT_MS } as const; const ACTION_TIMELINE_LIMIT = 50; @@ -1823,6 +1825,14 @@ const make = Effect.gen(function* () { } }; + const withinStatusDeadline = (read: Promise, late: () => A): Promise => { + let timer: ReturnType | undefined; + const deadline = new Promise((resolve) => { + timer = setTimeout(() => resolve(late()), STATUS_READ_MS); + }); + return Promise.race([read, deadline]).finally(() => clearTimeout(timer)); + }; + const statusWithTitle = async ( tab: ServerTab | undefined, agentSessionId?: string, @@ -1839,14 +1849,26 @@ const make = Effect.gen(function* () { }; } const url = ServerBrowserPage.withoutDataPayload(tab.page.url()); - const viewport = tab.desktop ? await desktopStatusViewport(tab) : tab.page.viewportSize(); const loadError = loadErrorOf(tab); - const status = { + const statusUrl = loadError?.url ?? (url === "about:blank" ? null : url); + const [viewport, title] = await Promise.all([ + // A late measurement still lands in desktopViewport for the next status. + tab.desktop + ? withinStatusDeadline(desktopStatusViewport(tab), () => tab.desktopViewport) + : tab.page.viewportSize(), + statusUrl === null || tab.dialog + ? null + : withinStatusDeadline( + tab.page.title().catch(() => ""), + () => "", + ).then((title) => title || null), + ]); + return { available: true, visible: isVisible(tab), tabId: tab.tabId, - url: loadError?.url ?? (url === "about:blank" ? null : url), - title: null, + url: statusUrl, + title, loading: tab.loading, ...(loadError ? { loadError } : {}), control: { @@ -1884,8 +1906,6 @@ const make = Effect.gen(function* () { completedAt, })), }; - if (status.url === null || tab.dialog) return status; - return { ...status, title: (await tab.page.title().catch(() => "")) || null }; }; const resolveNavigationUrl = (input: PreviewAutomationNavigateInput) => { @@ -1903,20 +1923,22 @@ const make = Effect.gen(function* () { readiness: "load" | "domContentLoaded" | "none", timeout: number, ) => { - const navigation = tab.page.goto(url, { - timeout, - waitUntil: - readiness === "domContentLoaded" - ? "domcontentloaded" - : readiness === "none" - ? "commit" - : "load", - }); + const startedAt = performance.now(); + const navigation = tab.page.goto(url, { timeout, waitUntil: "commit" }); if (readiness === "none") { tab.control.track(navigation); return; } - await navigation.catch((cause: unknown) => { + // Once the target commits, a stalled subresource only delays readiness; report the loading tab. + const ready = () => + tab.page + .waitForLoadState(readiness === "domContentLoaded" ? "domcontentloaded" : "load", { + timeout: Math.max(1, timeout - (performance.now() - startedAt)), + }) + .catch((cause: unknown) => { + if (!(cause instanceof Error && cause.name === "TimeoutError")) throw cause; + }); + await navigation.then(ready).catch((cause: unknown) => { const message = cause instanceof Error ? cause.message : String(cause); if (message.includes("Download is starting")) { throw new ServerBrowserPage.ServerBrowserOperationError({ From ec918277f0a72b1e0c74a55f75f8a68e93405250 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 20:34:32 -0800 Subject: [PATCH 044/390] fix(server): unchanged-worktree cleanup waits for the thread to settle or archive (#35941) --- apps/server/src/storageCleanup.test.ts | 50 ++++++++++++++++++++++++-- apps/server/src/storageCleanup.ts | 25 ++++++++----- docs/user/project-settings.md | 5 +-- 3 files changed, 67 insertions(+), 13 deletions(-) diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index 03f35f0a93cde..c3ae4fd2f6044 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -2799,15 +2799,55 @@ describe("storage cleanup reports and local file policies", () => { }), ), ); + it.live("keeps an unsettled new thread's unchanged worktree after its first turn", () => + runCleanupTest( + Effect.gen(function* () { + const { service, fs, worktree, setThreads } = yield* cleanupFixture({ + ...DEFAULT_SERVER_SETTINGS.storageCleanup, + worktreeSettledAfterDays: 11, + worktreeOnMerge: true, + worktreeUnchanged: true, + }); + const now = yield* DateTime.now; + const minutesAgo = (minutes: number) => DateTime.subtract(now, { minutes }); + const thread = shell({ + branch: "feature", + worktreePath: worktree, + status: "completed", + createdAt: minutesAgo(40), + latestUserMessageAt: minutesAgo(40), + latestRunRequestedAt: minutesAgo(40), + latestRunStartedAt: minutesAgo(40), + latestRunCompletedAt: minutesAgo(20), + updatedAt: minutesAgo(20), + }); + setThreads([thread]); + + const report = yield* service.runNow; + expect(report.entries[0]).toMatchObject({ outcome: "kept" }); + expect(yield* fs.exists(worktree)).toBe(true); + + setThreads([{ ...thread, settledAt: minutesAgo(10), updatedAt: minutesAgo(10) }]); + const settled = yield* service.runNow; + expect(settled.entries[0]).toMatchObject({ + outcome: "removed", + reason: "No commits beyond the project branch", + }); + }), + ), + ); it.live( "discovers trunk without a remote HEAD or tracking ref and removes an integrated worktree", () => runCleanupTest( Effect.gen(function* () { - const { service, fs, repo, worktree, command } = yield* cleanupFixture({ + const { service, fs, repo, worktree, command, setThreads } = yield* cleanupFixture({ ...DEFAULT_SERVER_SETTINGS.storageCleanup, worktreeUnchanged: true, }); + setThreads([ + shell({ branch: "feature", worktreePath: worktree, archivedAt: at(-DAY_MS) }), + ]); const remote = yield* fs.makeTempDirectoryScoped({ prefix: "cleanup-remote-" }); yield* command(remote, ["init", "--bare", "-b", "trunk"]); yield* command(repo, ["remote", "add", "origin", remote]); @@ -2829,10 +2869,13 @@ describe("storage cleanup reports and local file policies", () => { () => runCleanupTest( Effect.gen(function* () { - const { service, fs, repo, worktree, command } = yield* cleanupFixture({ + const { service, fs, repo, worktree, command, setThreads } = yield* cleanupFixture({ ...DEFAULT_SERVER_SETTINGS.storageCleanup, worktreeUnchanged: true, }); + setThreads([ + shell({ branch: "feature", worktreePath: worktree, archivedAt: at(-DAY_MS) }), + ]); const remote = yield* fs.makeTempDirectoryScoped({ prefix: "cleanup-remote-" }); yield* command(remote, ["init", "--bare", "-b", "trunk"]); yield* command(repo, ["remote", "add", "origin", remote]); @@ -2857,10 +2900,11 @@ describe("storage cleanup reports and local file policies", () => { it.live("keeps the worktree when the remote default is unavailable even if main exists", () => runCleanupTest( Effect.gen(function* () { - const { service, fs, repo, worktree, command } = yield* cleanupFixture({ + const { service, fs, repo, worktree, command, setThreads } = yield* cleanupFixture({ ...DEFAULT_SERVER_SETTINGS.storageCleanup, worktreeUnchanged: true, }); + setThreads([shell({ branch: "feature", worktreePath: worktree, archivedAt: at(-DAY_MS) })]); const remote = yield* fs.makeTempDirectoryScoped({ prefix: "cleanup-remote-" }); yield* command(remote, ["init", "--bare", "-b", "trunk"]); yield* command(repo, ["remote", "add", "origin", remote]); diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index 11f9a91787244..0cf5eca0b80d0 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -1174,6 +1174,9 @@ const make = Effect.gen(function* () { settledFor !== null && settings.worktreeSettledAfterDays !== null && settledFor >= settings.worktreeSettledAfterDays * DAY_MS; + // A worktree is unchanged before its first commit, so only finished threads qualify. + const unchangedApplies = + settings.worktreeUnchanged && (settledThread !== undefined || thread.archivedAt !== null); let eligible = deleted || old || settled; let removalReason = deleted ? "thread was deleted" @@ -1189,12 +1192,13 @@ const make = Effect.gen(function* () { : `settled for ${settledDays} of ${settings.worktreeSettledAfterDays} days` : settings.worktreeOnMerge ? "not merged" - : settings.worktreeUnchanged + : unchangedApplies ? "has commits beyond the default branch" - : "no rules apply"; + : settings.worktreeUnchanged + ? "not settled" + : "no rules apply"; // Only Git-based rules can make a not-yet-due worktree eligible, so skip Git otherwise. - if (!eligible && !settings.worktreeUnchanged && !settings.worktreeOnMerge) - return keep(notDueReason); + if (!eligible && !unchangedApplies && !settings.worktreeOnMerge) return keep(notDueReason); const pathReason = yield* worktreePathReason(worktreePath); if (pathReason !== null) return keep(pathReason); if (yield* containsProjectRoot(worktreePaths, [project, ...snapshot.projects])) @@ -1218,8 +1222,12 @@ const make = Effect.gen(function* () { let branch: string | null | undefined; let defaultRef: string; if (remote === null) { - if (!settings.worktreeUnchanged) - return keep("no remote is configured for merged-worktree cleanup"); + if (!unchangedApplies) + return keep( + settings.worktreeUnchanged + ? notDueReason + : "no remote is configured for merged-worktree cleanup", + ); const projectHead = yield* git.execute({ operation: "StorageCleanup.resolveProjectBranch", cwd: repositoryCwd, @@ -1278,7 +1286,7 @@ const make = Effect.gen(function* () { allowNonZeroExit: true, }); const integrated = ancestor.exitCode === 0; - eligible = integrated && settings.worktreeUnchanged; + eligible = integrated && unchangedApplies; if (eligible) removalReason = remote === null @@ -1297,7 +1305,8 @@ const make = Effect.gen(function* () { }); if (eligible) removalReason = "pull request was merged"; } - if (!eligible && remote === null) return keep("has commits beyond the project branch"); + if (!eligible && remote === null) + return keep(integrated ? notDueReason : "has commits beyond the project branch"); } if (!eligible) return keep(notDueReason); // Re-read this path's threads and sessions so new activity cancels removal; full per-worktree snapshots take seconds. diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index 5ccb460146978..e0377c11938a5 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -227,8 +227,9 @@ manually. Custom applies separate worktree rules to the selected project or chec captures and log retention remain machine-wide. Worktrees can be removed after a chosen number of inactive days, after a thread settles, after -merging, or when they have no commits beyond the default branch. Set settled retention to 0 days -to remove a worktree at the next cleanup after settlement. In repositories without remotes, +merging, or when they have no commits beyond the default branch and their thread is settled or +archived. Set settled retention to 0 days to remove a worktree at the next cleanup after +settlement. In repositories without remotes, unchanged-worktree cleanup compares against the branch checked out in the project workspace; if that checkout has a detached HEAD, the worktree is kept. Only T3-managed worktrees are eligible. Active sessions and shared worktrees prevent removal. **Keep worktrees with local changes** defaults From ff1eead6f411e02a23288236ad7b16d72ff8b147 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 20:34:44 -0800 Subject: [PATCH 045/390] fix(web): zoom shortcuts work while a desktop browser page has focus (#35947) --- apps/desktop/src/preview/Manager.test.ts | 17 ++++++++++++++++- apps/desktop/src/preview/Manager.ts | 5 ++++- .../ReopenClosedViewShortcut.browser.test.tsx | 8 ++++++-- .../src/components/ReopenClosedViewShortcut.tsx | 3 +++ apps/web/src/components/preview/PreviewView.tsx | 3 +++ 5 files changed, 32 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 6db04b389ab6d..9e263eb6360c2 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -820,7 +820,7 @@ describe("PreviewManager", () => { ["linux", false], ["win32", false], ] as const)( - "lets the page and its iframes claim the find shortcut before the host opens find on %s", + "lets the page and its iframes claim the find and zoom shortcuts before the host acts on %s", ([platform, usesMeta]) => withManager( (manager) => @@ -838,8 +838,23 @@ describe("PreviewManager", () => { yield* manager.registerWebview("tab_page_find", 42); yield* manager.setForwardedShortcuts([ { command: "preview.find", shortcut: parseKeybindingShortcut("mod+f")! }, + { command: "preview.zoomIn", shortcut: parseKeybindingShortcut("mod+=")! }, ]); const beforeInput = preview.listeners.get("before-input-event")!; + const keepsZoom = vi.fn(); + beforeInput( + { preventDefault: keepsZoom } as never, + { + type: "keyDown", + key: "=", + code: "Equal", + meta: usesMeta, + control: !usesMeta, + shift: false, + alt: false, + } as never, + ); + expect(keepsZoom).not.toHaveBeenCalled(); const findKey = { type: "keyDown", key: "f", diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 189885b9b2178..686248bf8892d 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -444,6 +444,9 @@ type FindResultListener = (event: DesktopPreviewFindResultEvent) => Effect.Effec const PAGE_FIRST_FORWARDED_COMMANDS: ReadonlySet = new Set([ "preview.find", + "preview.zoomIn", + "preview.zoomOut", + "preview.resetZoom", ]); const decodePageShortcutKeys = Schema.decodeUnknownOption( @@ -1553,7 +1556,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); if (forwarded && host) { // The page keeps these keys. Only the main-frame preload can report one it - // left unhandled, so a focused iframe (e.g. Google Docs' input) keeps its own find. + // left unhandled, so a focused iframe (e.g. Google Docs' input) keeps its own find or zoom. if (PAGE_FIRST_FORWARDED_COMMANDS.has(forwarded.command)) return; event.preventDefault(); if (!input.isAutoRepeat) { diff --git a/apps/web/src/components/ReopenClosedViewShortcut.browser.test.tsx b/apps/web/src/components/ReopenClosedViewShortcut.browser.test.tsx index 5dc65e57f1ca3..dd12cedfbdefd 100644 --- a/apps/web/src/components/ReopenClosedViewShortcut.browser.test.tsx +++ b/apps/web/src/components/ReopenClosedViewShortcut.browser.test.tsx @@ -265,11 +265,15 @@ describe("root reopen shortcut", () => { expect(useClosedViewStore.getState().entries).toEqual([]); }, ); - it("forwards address-bar focus from a focused desktop page", async () => { + it("forwards address-bar focus and zoom from a focused desktop page", async () => { setup(); await using _shortcut = await renderShortcut(); expect(state.setShortcuts).toHaveBeenLastCalledWith( - expect.arrayContaining([expect.objectContaining({ command: "preview.focusUrl" })]), + expect.arrayContaining( + ["preview.focusUrl", "preview.zoomIn", "preview.zoomOut", "preview.resetZoom"].map( + (command) => expect.objectContaining({ command }), + ), + ), ); }); }); diff --git a/apps/web/src/components/ReopenClosedViewShortcut.tsx b/apps/web/src/components/ReopenClosedViewShortcut.tsx index 7ecc10110f1fc..dfb68fe27e80a 100644 --- a/apps/web/src/components/ReopenClosedViewShortcut.tsx +++ b/apps/web/src/components/ReopenClosedViewShortcut.tsx @@ -50,6 +50,9 @@ const PREVIEW_FORWARDED_COMMANDS = [ "rightPanel.toggle", "rightPanel.toggleMaximized", "preview.focusUrl", + "preview.zoomIn", + "preview.zoomOut", + "preview.resetZoom", "preview.find", ] as const; diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index 16807e878bb2f..6eb6d0ba91076 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -948,6 +948,9 @@ export function PreviewView({ // shortcuts from the guest as menu actions instead. const unsubscribeMenu = window.desktopBridge?.onMenuAction((action) => { if (action === "preview.focusUrl") onPreviewAction("focus-url"); + if (action === "preview.zoomIn") onPreviewAction("zoom-in"); + if (action === "preview.zoomOut") onPreviewAction("zoom-out"); + if (action === "preview.resetZoom") onPreviewAction("reset-zoom"); if (action === "preview.find") onPreviewAction("find"); }); return () => { From 0b127678b125520080eaef5ddc4f53b311430b3b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 20:37:59 -0800 Subject: [PATCH 046/390] fix(server): ACP threads accept a model change after their session detaches (#35951) --- .../ProviderSwitchService.test.ts | 18 ++++++++++++++++++ .../orchestration-v2/ProviderSwitchService.ts | 13 ++++++++----- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProviderSwitchService.test.ts b/apps/server/src/orchestration-v2/ProviderSwitchService.test.ts index f44ee01875075..cde112bb74b09 100644 --- a/apps/server/src/orchestration-v2/ProviderSwitchService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSwitchService.test.ts @@ -252,6 +252,24 @@ it.effect.each(["stopped", "error"] as const)( ), ); +it.effect("applies a model change on next turn after a settled thread's session detached", () => + Effect.gen(function* () { + const service = yield* ProviderSwitch.ProviderSwitchServiceV2; + const result = yield* service.plan({ + projection: { ...deadNativeThreadProjection("stopped"), providerSessions: [] }, + targetModelSelection: { instanceId: currentInstanceId, model: "gpt-5.2-codex" }, + }); + assert.equal(result.transition.type, "switch_model_in_session"); + assert.deepEqual(result.releaseProviderSessionIds, []); + }).pipe( + Effect.provide( + layerTest({ [currentInstanceId]: { continuationKey: "codex:account:primary" } }, (input) => + Effect.succeed(acpSelectionTransition(input)), + ), + ), + ), +); + it.effect("rejects a model change the dead record never negotiated support for", () => Effect.gen(function* () { const service = yield* ProviderSwitch.ProviderSwitchServiceV2; diff --git a/apps/server/src/orchestration-v2/ProviderSwitchService.ts b/apps/server/src/orchestration-v2/ProviderSwitchService.ts index aca19902ae06b..a31b5120964ed 100644 --- a/apps/server/src/orchestration-v2/ProviderSwitchService.ts +++ b/apps/server/src/orchestration-v2/ProviderSwitchService.ts @@ -104,11 +104,14 @@ export const layer: Layer.Layer< Option.isSome(targetAdapter) && Option.isSome(currentInstance) && (currentSession !== undefined || currentProviderThread !== undefined) - ? yield* targetAdapter.value.planSelectionTransition({ - current, - target: targetModelSelection, - sessionCapabilities: negotiatedCapabilities ?? currentInstance.value.capabilities, - }) + ? negotiatedCapabilities === undefined + ? // No session record left to switch in place; the next session starts with it. + ({ type: "apply_on_next_turn" } as const) + : yield* targetAdapter.value.planSelectionTransition({ + current, + target: targetModelSelection, + sessionCapabilities: negotiatedCapabilities, + }) : undefined; const transition = Option.isNone(targetInstance) || Option.isNone(targetAdapter) From b2078c2d0a944119f9c854bf0f4769bd6d148f3a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 20:38:12 -0800 Subject: [PATCH 047/390] fix(server): service launcher keeps a T3 home that ends in a space (#35958) --- apps/server/src/serviceLauncher.test.ts | 30 +++++++++++++++++++++++++ apps/server/src/serviceLauncher.ts | 4 ++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/apps/server/src/serviceLauncher.test.ts b/apps/server/src/serviceLauncher.test.ts index bd0da06157db6..a8b38126d9d85 100644 --- a/apps/server/src/serviceLauncher.test.ts +++ b/apps/server/src/serviceLauncher.test.ts @@ -392,6 +392,36 @@ process.exit(0); }), ); + it.effect("starts the runtime in a T3 home whose name ends in a space", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const parent = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-space-" }); + const root = path.join(parent, "T3 data "); + const versionDir = path.join(root, "runtime", "versions", "1.0.0"); + yield* writeFakeRuntime( + fs, + path, + versionDir, + ` +import { writeFileSync } from "node:fs"; +writeFileSync(new URL("./started", import.meta.url), ""); +process.exit(0); +`, + ); + yield* fs.writeFileString( + path.join(root, "runtime", "service-state.json"), + JSON.stringify({ protocol: 4, activeVersion: "1.0.0" }), + ); + + yield* Effect.promise(() => + expect(runLauncher(root)).rejects.toThrow("Active child exited unexpectedly (0)."), + ); + + assert.isTrue(yield* fs.exists(path.join(versionDir, "started"))); + }), + ); + it.effect("refuses non-install legacy documents without rewriting or spawning", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/serviceLauncher.ts b/apps/server/src/serviceLauncher.ts index db245e16485ce..206afe5d5434b 100644 --- a/apps/server/src/serviceLauncher.ts +++ b/apps/server/src/serviceLauncher.ts @@ -1375,8 +1375,8 @@ export interface LauncherOptions { } export async function main(options: LauncherOptions = {}): Promise { - const baseDir = (options.baseDir ?? process.env.T3CODE_HOME)?.trim(); - if (baseDir === undefined || baseDir === "") { + const baseDir = options.baseDir ?? process.env.T3CODE_HOME; + if (baseDir === undefined || baseDir.trim() === "") { throw new Error("T3CODE_HOME is required by the T3 Code service launcher."); } const output = From 99224f098d5d4d223ab1d04f02935288fe2651cc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 20:39:56 -0800 Subject: [PATCH 048/390] fix(server): launcher repoints the unit the systemd manager loads (#35966) --- apps/server/src/cloud/bootService.ts | 19 +++---- apps/server/src/cloud/serviceProtocol.ts | 14 ++++++ apps/server/src/serviceLauncher.test.ts | 64 ++++++++++++++++++++++++ apps/server/src/serviceLauncher.ts | 48 +++++++++++++++--- 4 files changed, 124 insertions(+), 21 deletions(-) diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index 1f5bc0c0d355d..ac426cf454c26 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -45,6 +45,7 @@ import { parseServiceState, serviceStateActiveVersion, serviceStateHasPendingUpdate, + systemdUnitDirectoryOf, type ServiceState, } from "./serviceProtocol.ts"; @@ -550,7 +551,7 @@ const decodeSystemdUnitPaths = Schema.decodeUnknownEffect( // The manager may not share the installer's environment; JSON preserves spaces in its paths. const readSystemdUnitDirectory = Effect.fn("cloud.boot_service.read_systemd_unit_directory")( - function* (runner: ProcessRunner.ProcessRunner["Service"], path: Path.Path) { + function* (runner: ProcessRunner.ProcessRunner["Service"]) { const result = yield* runner.run({ command: "busctl", args: [ @@ -566,14 +567,7 @@ const readSystemdUnitDirectory = Effect.fn("cloud.boot_service.read_systemd_unit }); if (result.code !== 0) return undefined; const { data } = yield* decodeSystemdUnitPaths(result.stdout); - // The persistent control directory identifies the manager's config root despite UnitPath overrides. - const controlDirectory = data.find( - (directory) => - path.isAbsolute(directory) && - directory.endsWith("/systemd/user.control") && - data.includes(directory.slice(0, -".control".length)), - ); - return controlDirectory?.slice(0, -".control".length); + return systemdUnitDirectoryOf(data); }, Effect.orElseSucceed(() => undefined), ); @@ -591,7 +585,7 @@ export const bootServiceUnitPath = Effect.fn("cloud.boot_service.unit_path")(fun } if (platform !== "linux") return undefined; const unitDir = - (yield* readSystemdUnitDirectory(yield* ProcessRunner.ProcessRunner, path)) ?? + (yield* readSystemdUnitDirectory(yield* ProcessRunner.ProcessRunner)) ?? defaultSystemdUnitDir(path, homeDir, (yield* HostProcess.Environment).XDG_CONFIG_HOME ?? ""); return path.join(unitDir, BOOT_SERVICE_UNIT_FILE); }); @@ -972,8 +966,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { }; return { manager, unitPath, plan }; }; - let systemdUnitDir = - platform === "linux" ? yield* readSystemdUnitDirectory(runner, path) : undefined; + let systemdUnitDir = platform === "linux" ? yield* readSystemdUnitDirectory(runner) : undefined; let { manager: detectedManager, unitPath, plan } = resolveTarget(systemdUnitDir); const statePath = path.join(input.baseDir, "runtime", SERVICE_STATE_FILE); const restartPendingPath = path.join(input.baseDir, "runtime", SERVICE_RESTART_PENDING_FILE); @@ -1205,7 +1198,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { // Without a session the manager was unreachable at construction, and the one lingering // started may use another config root than this shell's XDG_CONFIG_HOME. if (systemdUnitDir === undefined) { - systemdUnitDir = yield* readSystemdUnitDirectory(runner, path); + systemdUnitDir = yield* readSystemdUnitDirectory(runner); ({ manager: detectedManager, unitPath, plan } = resolveTarget(systemdUnitDir)); manager = yield* requireManager; yield* refuseOtherBaseDir; diff --git a/apps/server/src/cloud/serviceProtocol.ts b/apps/server/src/cloud/serviceProtocol.ts index f3264b841c516..c0a43bfe23e16 100644 --- a/apps/server/src/cloud/serviceProtocol.ts +++ b/apps/server/src/cloud/serviceProtocol.ts @@ -63,6 +63,20 @@ export function serviceUnitBaseDirOf(contents: string): string | undefined { return task === undefined ? undefined : unquoteWindowsArgument(task); } +/** + * The user unit directory a systemd manager reads, from its UnitPath property. The persistent + * control directory identifies the manager's config root despite UnitPath overrides. + */ +export function systemdUnitDirectoryOf(unitPath: ReadonlyArray): string | undefined { + const controlDirectory = unitPath.find( + (directory) => + directory.startsWith("/") && + directory.endsWith("/systemd/user.control") && + unitPath.includes(directory.slice(0, -".control".length)), + ); + return controlDirectory?.slice(0, -".control".length); +} + /** The file systemd or launchd appends the service's output to. */ export function serviceUnitLogPathOf(contents: string): string | undefined { const systemd = /^StandardOutput=append:(.*)$/m.exec(contents)?.[1]; diff --git a/apps/server/src/serviceLauncher.test.ts b/apps/server/src/serviceLauncher.test.ts index a8b38126d9d85..c7d6c5e5d8f3a 100644 --- a/apps/server/src/serviceLauncher.test.ts +++ b/apps/server/src/serviceLauncher.test.ts @@ -318,6 +318,70 @@ it.layer(NodeServices.layer)("service state persistence", (it) => { }), ); + it.effect( + "repoints the systemd unit the manager loads when environment.d moves XDG_CONFIG_HOME", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-xdg-" }); + const home = path.join(root, "home"); + const managerUnitDir = path.join(home, ".config", "systemd", "user"); + const unitPath = path.join(managerUnitDir, "t3code.service"); + const render = (version: string) => + renderBootServiceUnit({ ...unitPlan(root, version), unitPath }); + yield* fs.makeDirectory(managerUnitDir, { recursive: true }); + yield* fs.writeFileString(unitPath, render("1.0.0")); + // Fake manager: busctl reports the unit search path, systemctl reloads. + const bin = path.join(root, "bin"); + yield* fs.makeDirectory(bin, { recursive: true }); + const unitPathJson = JSON.stringify({ + type: "as", + data: [`${managerUnitDir}.control`, managerUnitDir, "/usr/lib/systemd/user"], + }); + yield* fs.writeFileString( + path.join(bin, "busctl"), + `#!/bin/sh\nprintf '%s\\n' '${unitPathJson}'\n`, + ); + yield* fs.writeFileString( + path.join(bin, "systemctl"), + `#!/bin/sh\necho reload >> '${path.join(root, "reloads")}'\n`, + ); + yield* fs.chmod(path.join(bin, "busctl"), 0o755); + yield* fs.chmod(path.join(bin, "systemctl"), 0o755); + yield* writeFakeRuntime( + fs, + path, + path.join(root, "runtime", "versions", "1.1.0"), + "process.exit(0);\n", + ); + yield* Effect.promise(() => + writeServiceState(path.join(root, "runtime", "service-state.json"), { + protocol: SERVICE_LAUNCHER_PROTOCOL, + activeVersion: "1.1.0", + update: { + id: "update-1", + fromVersion: "1.0.0", + targetVersion: "1.1.0", + status: "committed", + }, + }), + ); + vi.stubEnv("PATH", bin); + vi.stubEnv("HOME", home); + // environment.d changes what services inherit, not where the manager reads units. + vi.stubEnv("XDG_CONFIG_HOME", path.join(home, "service-config")); + vi.stubEnv("T3_BOOT_SERVICE_UNIT", "t3code.service"); + + yield* Effect.promise(() => + expect(runLauncher(root)).rejects.toThrow("Active child exited unexpectedly (0)."), + ); + + assert.equal(yield* fs.readFileString(unitPath), render("1.1.0")); + assert.equal(yield* fs.readFileString(path.join(root, "reloads")), "reload\n"); + }), + ); + it.effect("keeps the service manager's append log bounded when it starts", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/serviceLauncher.ts b/apps/server/src/serviceLauncher.ts index 206afe5d5434b..c07697007fe57 100644 --- a/apps/server/src/serviceLauncher.ts +++ b/apps/server/src/serviceLauncher.ts @@ -35,6 +35,7 @@ import { SERVICE_RESTART_PENDING_FILE, SERVICE_STOP_MARKER_FILE, SERVICE_UNIT_ENV, + systemdUnitDirectoryOf, } from "./cloud/serviceProtocol.ts"; import { legacyOwnerStartedBeforeRecord, @@ -123,7 +124,34 @@ export async function registerServiceTask( } } -function serviceUnitFromEnvironment(env: NodeJS.ProcessEnv = process.env): ServiceUnit | undefined { +// The manager's environment can differ from the one it gives services (environment.d). +async function readSystemdUnitDirectory(): Promise { + try { + const { stdout } = await NodeUtil.promisify(NodeChildProcess.execFile)( + "busctl", + [ + "--user", + "--json=short", + "get-property", + "org.freedesktop.systemd1", + "/org/freedesktop/systemd1", + "org.freedesktop.systemd1.Manager", + "UnitPath", + ], + { timeout: 5_000 }, + ); + const { data } = JSON.parse(stdout) as { readonly data?: unknown }; + return Array.isArray(data) && data.every((entry) => typeof entry === "string") + ? systemdUnitDirectoryOf(data) + : undefined; + } catch { + return undefined; + } +} + +async function serviceUnitFromEnvironment( + env: NodeJS.ProcessEnv = process.env, +): Promise { const unitName = env[SERVICE_UNIT_ENV]?.trim(); if (!unitName) return undefined; if (unitName.endsWith(".xml")) { @@ -141,14 +169,18 @@ function serviceUnitFromEnvironment(env: NodeJS.ProcessEnv = process.env): Servi reload: () => Promise.resolve(), }; } - // The manager's own environment, which decides where it reads user units. const xdgConfigHome = env.XDG_CONFIG_HOME?.trim(); - const configHome = - xdgConfigHome && NodePath.isAbsolute(xdgConfigHome) - ? xdgConfigHome - : NodePath.join(home, ".config"); + const unitDir = + (await readSystemdUnitDirectory()) ?? + NodePath.join( + xdgConfigHome && NodePath.isAbsolute(xdgConfigHome) + ? xdgConfigHome + : NodePath.join(home, ".config"), + "systemd", + "user", + ); return { - path: NodePath.join(configHome, "systemd", "user", unitName), + path: NodePath.join(unitDir, unitName), reload: () => new Promise((resolve, reject) => { NodeChildProcess.spawn("systemctl", ["--user", "daemon-reload"], { @@ -1403,7 +1435,7 @@ export async function main(options: LauncherOptions = {}): Promise { throw new Error(`Selected t3@${state.activeVersion} runtime is missing or incomplete.`); } } - const serviceUnit = serviceUnitFromEnvironment(managerEnvironment); + const serviceUnit = await serviceUnitFromEnvironment(managerEnvironment); const logPath = await serviceLogPath(options, serviceUnit); if (logPath !== undefined) { await boundServiceLog(logPath); From 0f5ecef91458454dc03b7d7e1adb43de414b20ee Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 20:44:13 -0800 Subject: [PATCH 049/390] fix(server): refuse a Linux service for a T3 home systemd cannot load (#35968) --- apps/server/src/cloud/bootService.test.ts | 51 +++++++++++++++++++++-- apps/server/src/cloud/bootService.ts | 26 ++++++++++++ 2 files changed, 73 insertions(+), 4 deletions(-) diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index c9f7d1a77c3da..cd4921ac452bb 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -119,7 +119,7 @@ it("protects the server unit from agents under CPU and memory pressure", () => { }); it.each([ - ["a unit", '/home/theo/T3 "Data"/100%\\x', BootService.renderBootServiceUnit], + ["a unit", "/home/theo/T3 Data/100%$x", BootService.renderBootServiceUnit], [ "a plist", "/Users/theo/a&b ", @@ -141,6 +141,41 @@ it.each([ ).toBe(program); }); +// systemd rejects a unit whose executable has a control character, quote or backslash, even quoted. +it.each([ + ["a double quote", '/home/theo/T3 "Data"'], + ["a single quote", "/home/theo/Theo's T3"], + ["a backslash", "/home/theo/T3\\Data"], + ["a newline", "/home/theo/T3\nData"], +])("renders a loadable unit, or refuses, for a T3 home with %s", (_, baseDir) => { + let unit: string; + try { + unit = BootService.renderBootServiceUnit({ + program: [`${baseDir}/runtime/versions/1.2.3/t3`, "__service-launcher"], + baseDir, + logPath: `${baseDir}/userdata/logs/boot-service.log`, + unitPath: "/home/theo/.config/systemd/user/t3code.service", + }); + } catch { + return; + } + for (const line of unit.split("\n")) { + expect(line === "" || line.startsWith("[") || line.includes("=")).toBe(true); + } + const execStart = unit.split("\n").find((line) => line.startsWith("ExecStart=")); + const executable = execStart + ?.slice("ExecStart=".length) + .match(/^"((?:\\.|[^"\\])*)"|^(\S+)/) + ?.slice(1) + .find((group) => group !== undefined) + ?.replace(/\\(.)/g, "$1"); + expect(executable).toBeDefined(); + const unsafe = [...(executable ?? "")].filter( + (char) => char.charCodeAt(0) < 0x20 || `"'\\`.includes(char), + ); + expect(unsafe).toEqual([]); +}); + const macInstallerPath = "/opt/homebrew/bin:/Users/theo/.npm-global/bin:/Users/theo/.nvm/versions/node/v22.16.0/bin:/usr/bin:/bin"; @@ -593,7 +628,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => { "preserves the custom install bin directory in the installed service on %s", (platform) => Effect.gen(function* () { - const installBinDir = '/custom/T3 & <100%>/"quoted"\\bin'; + const installBinDir = '/custom/T3\t& <100%>/"quoted"\\bin'; const { service, makeService, fs } = yield* makeHarness( platform, "", @@ -605,8 +640,8 @@ it.layer(NodeServices.layer)("boot service install", (it) => { const unit = yield* fs.readFileString(plan.unitPath); expect(unit).toContain( platform === "linux" - ? 'Environment=T3CODE_INSTALL_BIN_DIR="/custom/T3 & <100%%>/\\"quoted\\"\\\\bin"' - : 'T3CODE_INSTALL_BIN_DIR\n /custom/T3 & <100%>/"quoted"\\bin', + ? 'Environment=T3CODE_INSTALL_BIN_DIR="/custom/T3\t& <100%%>/\\"quoted\\"\\\\bin"' + : 'T3CODE_INSTALL_BIN_DIR\n /custom/T3\t& <100%>/"quoted"\\bin', ); expect((yield* service.status).current).toBe(true); @@ -1185,6 +1220,14 @@ process.disconnect(); expect(yield* fs.readFileString(plan.unitPath)).toBe(unit); expect(commands).toEqual([]); expect((yield* other.status).otherBaseDir).toBe(baseDir); + + const quoted = yield* makeService(undefined, "1.2.3", path.join(otherHome, 'T3 "Data"')); + commands.length = 0; + const unloadable = yield* quoted.install().pipe(Effect.flip); + expect(unloadable._tag).toBe("BootServiceBaseDirUnsupportedError"); + expect(CliError.getUserMessage(unloadable)).toBe(unloadable.message); + expect(yield* fs.readFileString(plan.unitPath)).toBe(unit); + expect(commands).toEqual([]); }), ); diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index ac426cf454c26..bea0f8de6ae88 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -91,8 +91,16 @@ export interface BootServicePlan { readonly handedOff?: boolean; } +// systemd refuses an executable with a quote, backslash or control character even when +// quoted. The executable lives under the T3 home, so this also catches a home with a newline. +const systemdUnsafeValue = (plan: BootServicePlan) => + /[\x00-\x1f\x7f"'\\]/.test(plan.program[0] ?? ""); + /** Service units cannot rely on the user's shell or PATH. */ export function renderBootServiceUnit(plan: BootServicePlan): string { + if (systemdUnsafeValue(plan)) { + throw new BootServiceBaseDirUnsupportedError({ baseDir: plan.baseDir }); + } // The user manager has no reliable network-online target; server networking retries itself. return [ "[Unit]", @@ -705,8 +713,22 @@ export class BootServiceOtherBaseDirError extends Schema.TaggedError()( + "BootServiceBaseDirUnsupportedError", + { baseDir: Schema.String }, +) { + override get message(): string { + return `systemd cannot run T3 Code from ${JSON.stringify(this.baseDir)}: the path contains a quote, backslash or control character. Choose another T3 home with --base-dir.`; + } + + override get [CliError.userMessage]() { + return this.message; + } +} + export type BootServiceError = | BootServiceUnsupportedError + | BootServiceBaseDirUnsupportedError | BootServiceOtherBaseDirError | BootServiceCommandError | BootServiceInstallError @@ -1174,6 +1196,9 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { readonly start?: boolean; }) { let manager = yield* requireManager; + if (manager.kind === "systemd" && systemdUnsafeValue(plan)) { + return yield* new BootServiceBaseDirUnsupportedError({ baseDir: input.baseDir }); + } // The unit is per user. Rewriting one that serves another home moves the // service onto this home's data, which looks like every thread vanished. const refuseOtherBaseDir = Effect.gen(function* () { @@ -1537,6 +1562,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { problems, current: problems.length === 0 && + !(manager.kind === "systemd" && systemdUnsafeValue(planForUnit(unit))) && normalizeUnit(unit) === normalizeUnit(manager.render(planForUnit(unit))) && runtimeEntryExists && Option.isSome(runtimeSentinel) && From b7a224c7ddcebabac8088196f446a865b6301b63 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 20:45:40 -0800 Subject: [PATCH 050/390] perf(server): sending, editing and steering queued messages no longer read a long thread's whole history (#29521) --- .../Orchestrator.control-reads.test.ts | 291 ++++++++++++++++++ .../src/orchestration-v2/Orchestrator.ts | 6 +- .../orchestration-v2/ProjectionStore.test.ts | 6 +- .../src/orchestration-v2/ProjectionStore.ts | 38 +-- apps/server/src/persistence/Migrations.ts | 2 + .../Migrations/088_TurnItemTypeStatusIndex.ts | 13 + .../reconcileV2PreviewMigration.test.ts | 2 + 7 files changed, 332 insertions(+), 26 deletions(-) create mode 100644 apps/server/src/persistence/Migrations/088_TurnItemTypeStatusIndex.ts diff --git a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts index f7773665aa9b0..21afe0b0574c8 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts @@ -28,6 +28,7 @@ import * as Result from "effect/Result"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import * as SqlClient from "effect/sql/SqlClient"; +import * as Statement from "effect/sql/Statement"; import { orchestrationCommandDuration, orchestrationCommandsTotal, @@ -2199,3 +2200,293 @@ it.effect("a user message dispatched first stays run 1 when its lineage read is ); }).pipe(Effect.provide(layerGatedLineageRead)); }); + +it.effect("queued-message commands read a long thread's history without walking all of it", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const sql = yield* SqlClient.SqlClient; + const threadId = ThreadId.make("thread:long-history-reads"); + const driver = ProviderDriverKind.make("codex"); + const now = yield* DateTime.now; + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create-long-history-reads"), + threadId, + projectId: ProjectId.make("project:long-history-reads"), + title: "Long history", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdBy: "user", + creationSource: "web", + }); + const providerThreadId = ProviderThreadId.make("provider-thread:long-history-reads"); + yield* projections.apply({ + id: EventId.make("event:long-history-reads:provider-thread"), + type: "provider-thread.updated", + threadId, + driver, + providerInstanceId: instanceId, + occurredAt: now, + payload: { + id: providerThreadId, + appThreadId: threadId, + ownerNodeId: null, + driver, + providerInstanceId: instanceId, + providerSessionId: null, + nativeThreadRef: null, + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: 1, + lastRunOrdinal: 4, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + pendingBackgroundTasks: [], + }, + }); + const dispatch = (name: string) => + orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`dispatch:long-history-reads:${name}`), + threadId, + messageId: MessageId.make(`message:long-history-reads:${name}`), + // A compacting target lets promote skip it without a live provider session. + text: name === "active" ? "/compact" : `Request ${name}`, + attachments: [], + dispatchMode: { type: "defer_start" }, + createdBy: "user", + creationSource: "web", + }); + const nextOrdinal = Effect.gen(function* () { + const [row] = yield* sql<{ readonly ordinal: number }>` + SELECT COALESCE(MAX(ordinal), 0) + 1 AS ordinal + FROM orchestration_v2_projection_turn_items WHERE thread_id = ${threadId}`; + return row!.ordinal; + }); + // Settled history: each run leaves completed leaf nodes, tool output and a reply. + for (const name of ["1", "2", "3", "active"]) { + yield* dispatch(name); + const run = (yield* projections.getThreadProjection(threadId)).runs.at(-1)!; + const active = name === "active"; + yield* projections.apply({ + id: EventId.make(`event:long-history-reads:${name}:run`), + type: "run.updated", + threadId, + runId: run.id, + occurredAt: now, + payload: { + ...run, + providerThreadId, + status: active ? "running" : "completed", + queuePosition: null, + startedAt: now, + completedAt: active ? null : now, + }, + }); + if (active) continue; + for (const kind of ["tool_call", "assistant_message"] as const) { + const nodeId = NodeId.make(`node:long-history-reads:${name}:${kind}`); + yield* projections.apply({ + id: EventId.make(`event:${nodeId}`), + type: "node.updated", + threadId, + runId: run.id, + nodeId, + driver, + occurredAt: now, + payload: { + id: nodeId, + threadId, + runId: run.id, + rootNodeId: run.rootNodeId!, + parentNodeId: run.rootNodeId, + kind, + status: "completed", + countsForRun: false, + providerThreadId, + providerTurnId: null, + nativeItemRef: null, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: now, + completedAt: now, + }, + }); + } + const replyId = MessageId.make(`message:long-history-reads:${name}:reply`); + yield* projections.apply({ + id: EventId.make(`event:${replyId}`), + type: "message.updated", + threadId, + occurredAt: now, + payload: { + id: replyId, + threadId, + runId: run.id, + nodeId: NodeId.make(`node:long-history-reads:${name}:assistant_message`), + createdBy: "agent", + creationSource: "server", + role: "assistant", + text: `Reply ${name}`, + attachments: [], + streaming: false, + createdAt: now, + updatedAt: now, + }, + }); + const itemBase = { + threadId, + runId: run.id, + providerThreadId, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + status: "completed" as const, + startedAt: now, + completedAt: now, + updatedAt: now, + }; + for (const item of [ + { + ...itemBase, + id: TurnItemId.make(`item:long-history-reads:${name}:command`), + nodeId: NodeId.make(`node:long-history-reads:${name}:tool_call`), + ordinal: yield* nextOrdinal, + title: "command", + type: "command_execution" as const, + input: "cat build.log", + }, + { + ...itemBase, + id: TurnItemId.make(`item:long-history-reads:${name}:reply`), + nodeId: NodeId.make(`node:long-history-reads:${name}:assistant_message`), + ordinal: yield* nextOrdinal, + title: null, + type: "assistant_message" as const, + messageId: replyId, + text: `Reply ${name}`, + streaming: false, + }, + ]) { + yield* projections.apply({ + id: EventId.make(`event:${item.id}`), + type: "turn-item.updated", + threadId, + runId: run.id, + nodeId: item.nodeId, + driver, + occurredAt: now, + payload: item, + }); + } + } + const activeRun = (yield* projections.getThreadProjection(threadId)).runs.at(-1)!; + // Stop needs a live provider turn to interrupt. + yield* projections.apply({ + id: EventId.make("event:long-history-reads:active-turn"), + type: "provider-turn.updated", + threadId, + runId: activeRun.id, + occurredAt: now, + payload: { + id: ProviderTurnId.make("provider-turn:long-history-reads"), + providerThreadId, + nodeId: activeRun.rootNodeId!, + runAttemptId: activeRun.activeAttemptId!, + nativeTurnRef: { driver, nativeId: "native:long-history-reads", strength: "strong" }, + ordinal: 4, + status: "running", + startedAt: now, + completedAt: null, + }, + }); + + const statements: Array["compile"]>> = []; + const observe: Statement.Transformer = (statement) => { + const compiled = statement.compile(); + // Row reads and the message-ordering pass; a MAX(ordinal) probe reads one index entry. + if (/_(nodes|turn_items)\b/.test(compiled[0]) && /payload_json|GROUP BY/.test(compiled[0])) { + statements.push(compiled); + } + return Effect.succeed(statement); + }; + const historyWalks = Effect.fnUntraced(function* (commandType: string) { + const walks: Array = []; + for (const [query, params] of statements.splice(0)) { + const plan = yield* sql.unsafe<{ detail: string }>(`EXPLAIN QUERY PLAN ${query}`, params); + for (const { detail } of plan) { + // A history-sized read: an ordering pass over every message item for a narrowed + // message read, or a node/turn-item row search bounded by nothing but the thread. + // Walking only a covering index's ids reads no payloads. Stop still loads every + // message, so its ordering pass is no larger than that read. + if ( + (detail.includes("TEMP B-TREE FOR GROUP BY") && query.includes("json_each")) || + (/^SEARCH .*(node|turn_item).* \(thread_id=\?\)$/.test(detail) && + !detail.includes("USING COVERING INDEX")) || + // Every active run in the environment, not only this thread's. + /^SEARCH .*runs.* \(status=\?\)$/.test(detail) || + // A status-filtered read that fetches every typed row to check its status. + (/^SEARCH .*turn_items USING INDEX .* \(thread_id=\? AND type=\?\)$/.test(detail) && + query.includes("status IN")) + ) { + walks.push(`${commandType}: ${detail}\n in: ${query.replace(/\s+/g, " ").trim()}`); + } + } + } + return walks; + }); + const observed = (effect: Effect.Effect) => + effect.pipe(Effect.provideService(Statement.CurrentTransformer, observe)); + + yield* observed(dispatch("queued")); + const walks = yield* historyWalks("message.dispatch"); + const queuedRun = (yield* projections.getThreadProjection(threadId)).runs.at(-1)!; + assert.equal(queuedRun.status, "queued"); + yield* observed( + orchestrator.dispatch({ + type: "queued-run.edit", + commandId: CommandId.make("edit:long-history-reads"), + threadId, + runId: queuedRun.id, + text: "Request queued, edited", + }), + ); + walks.push(...(yield* historyWalks("queued-run.edit"))); + yield* observed( + orchestrator.dispatch({ + type: "queued-message.promote-to-steer", + commandId: CommandId.make("promote:long-history-reads"), + threadId, + queuedRunId: queuedRun.id, + targetRunId: activeRun.id, + }), + ); + walks.push(...(yield* historyWalks("queued-message.promote-to-steer"))); + yield* observed( + orchestrator.dispatch({ + type: "run.interrupt", + commandId: CommandId.make("interrupt:long-history-reads"), + threadId, + runId: activeRun.id, + }), + ); + walks.push(...(yield* historyWalks("run.interrupt"))); + + const after = yield* projections.getThreadProjection(threadId); + assert.deepEqual( + after.messages.filter((message) => message.role === "user").map((message) => message.text), + ["Request 1", "Request 2", "Request 3", "/compact", "Request queued, edited"], + ); + assert.deepEqual( + after.providerThreads.map((providerThread) => providerThread.id), + [providerThreadId], + ); + assert.deepEqual(walks, [], walks.join("\n")); + }).pipe(Effect.provide(layerTest)), +); diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index bd06785814cf1..a3ad41ec9eb4a 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -10839,8 +10839,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio "runtimeRequests", "subagents", ], - // User messages measure a restart's model switch. - { turnItemTypes: ["user_message"], messageRoles: ["user"] }, + // User messages measure a restart's model switch. Steering reads root and live nodes only. + { turnItemTypes: ["user_message"], messageRoles: ["user"], omitSettledLeafNodes: true }, ); if (projection.thread.archivedAt !== null || projection.thread.deletedAt !== null) { return yield* new OrchestratorDispatchError({ @@ -12644,6 +12644,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio "reasoning", ], turnItemStatuses: ["pending", "running", "waiting"], + // Stop reads root nodes and ends open ones; a settled leaf has nothing left to end. + omitSettledLeafNodes: true, }, ); const run = projection.runs.find((candidate) => candidate.id === command.runId); diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 607b1ada040fd..3b34434017e9d 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -755,7 +755,7 @@ it.effect("idle compaction filters keep the latest run, live work and the newest }) .pipe(Effect.withTracer(command.tracer)); for (const [table, seeks] of [ - ["turn_items", ["USING INDEX orchestration_v2_projection_turn_items_conversation_idx"]], + ["turn_items", ["turn_items_thread_type_idx (thread_id=? AND type=?)"]], ["nodes", ["nodes_settled_leaf_idx (thread_id=? AND =?)"]], ["subagents", ["USING COVERING INDEX orchestration_v2_projection_subagents_settled_idx"]], [ @@ -1292,9 +1292,9 @@ it.effect("record reads filter notification sources without dropping matching me `EXPLAIN QUERY PLAN ${ordinalLookup}`, [threadId, "[]"], )).map((row) => row.detail); - // User-only reads must stay on the partial index instead of scanning every turn item. + // User-only reads must stay on a type-bounded index instead of scanning every turn item. assert.isTrue( - plan.some((detail) => detail.includes("turn_items_user_message_idx")), + plan.some((detail) => /turn_items_(user_message|thread_type)_idx/.test(detail)), plan.join("\n"), ); // With every user item loaded, the items give the order and the lookup does not run. diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 9ad158dd533bb..841cd0a0b5b7d 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -1151,15 +1151,6 @@ const encodeIdList = Schema.encodeSync(Schema.fromJsonString(Schema.Array(Schema const matchesNothing = (...filters: ReadonlyArray | undefined>) => filters.some((values) => values?.length === 0); -// A typed item read that names no tool type fits in migration 082's conversation index. A run or -// node filter is left alone: it already reads through a narrower index. -const readsConversationItemsOnly = (filter: ProjectionRecordFilter | undefined) => - filter?.turnItemTypes !== undefined && - filter.turnItemRunId === undefined && - filter.turnItemRunIds === undefined && - filter.turnItemNodeIds === undefined && - filter.turnItemTypes.every((type) => DEFERRED_WORK_ITEM_TYPES.every((tool) => tool !== type)); - const encodeThreadPayload = Schema.encodeEffect( Schema.fromJsonString(OrchestrationV2AppThreadJsonSchema), ); @@ -3490,6 +3481,16 @@ export const layer: Layer.Layer = `; }); + const turnItemMatch = (threadId: ThreadId, filter: ProjectionRecordFilter | undefined) => sql` + thread_id = ${threadId} + ${filter?.turnItemRequestIds === undefined ? sql`` : sql`AND json_extract(payload_json, '$.requestId') IN (SELECT value FROM json_each(${encodeIdList(filter.turnItemRequestIds)}))`} + ${filter?.turnItemTypes === undefined ? sql`` : sql`AND type IN (SELECT value FROM json_each(${encodeIdList(filter.turnItemTypes)}))`} + ${filter?.turnItemStatuses === undefined ? sql`` : sql`AND status IN (SELECT value FROM json_each(${encodeIdList(filter.turnItemStatuses)}))`} + ${filter?.turnItemRunId === undefined ? sql`` : sql`AND run_id = ${filter.turnItemRunId}`} + ${filter?.turnItemNodeIds === undefined ? sql`` : sql`AND node_id IN (SELECT value FROM json_each(${encodeIdList(filter.turnItemNodeIds)}))`} + ${filter?.turnItemRunIds === undefined ? sql`` : sql`AND (run_id IN (SELECT value FROM json_each(${encodeIdList(filter.turnItemRunIds.filter((id): id is RunId => id !== null))})) OR (${filter.turnItemRunIds.includes(null) ? 1 : 0} = 1 AND run_id IS NULL))`} + `; + // An id filter reads by primary key. `+` keeps SQLite off the thread index, which walks // every row of the thread to find the few ids. const threadColumn = (ids: ReadonlyArray | undefined, column = "thread_id") => @@ -3590,19 +3591,14 @@ export const layer: Layer.Layer = SELECT payload_json FROM orchestration_v2_projection_turn_items ${ - // A type list of conversation items reads them from the conversation index. - // Any other plan fetches every tool call of the thread to check its type. - readsConversationItemsOnly(filter) - ? sql`INDEXED BY orchestration_v2_projection_turn_items_conversation_idx - WHERE thread_id = ${threadId} AND type NOT IN ${CONVERSATION_INDEX_EXCLUDED_TYPES}` - : sql`WHERE thread_id = ${threadId}` + filter?.turnItemTypes === undefined + ? sql`WHERE ${turnItemMatch(threadId, filter)}` + : // Seeks the type index. Sorted, SQLite would walk the thread by ordinal instead. + sql`WHERE rowid IN ( + SELECT rowid FROM orchestration_v2_projection_turn_items + WHERE ${turnItemMatch(threadId, filter)} + )` } - ${filter?.turnItemRequestIds === undefined ? sql`` : sql`AND json_extract(payload_json, '$.requestId') IN (SELECT value FROM json_each(${encodeIdList(filter.turnItemRequestIds)}))`} - ${filter?.turnItemTypes === undefined ? sql`` : sql`AND type IN (SELECT value FROM json_each(${encodeIdList(filter.turnItemTypes)}))`} - ${filter?.turnItemStatuses === undefined ? sql`` : sql`AND status IN (SELECT value FROM json_each(${encodeIdList(filter.turnItemStatuses)}))`} - ${filter?.turnItemRunId === undefined ? sql`` : sql`AND run_id = ${filter.turnItemRunId}`} - ${filter?.turnItemNodeIds === undefined ? sql`` : sql`AND node_id IN (SELECT value FROM json_each(${encodeIdList(filter.turnItemNodeIds)}))`} - ${filter?.turnItemRunIds === undefined ? sql`` : sql`AND (run_id IN (SELECT value FROM json_each(${encodeIdList(filter.turnItemRunIds.filter((id): id is RunId => id !== null))})) OR (${filter.turnItemRunIds.includes(null) ? 1 : 0} = 1 AND run_id IS NULL))`} ORDER BY ordinal ASC, turn_item_id ASC ` : yield* sql` diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index ff799863c64c6..6399ee7b85950 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -95,6 +95,7 @@ import Migration0084 from "./Migrations/084_LastExecutedRunIndex.ts"; import Migration0085 from "./Migrations/085_TurnItemCounts.ts"; import Migration0086 from "./Migrations/086_QueuedTurnInputIndex.ts"; import Migration0087 from "./Migrations/087_ProjectionProjectsCustomTitle.ts"; +import Migration0088 from "./Migrations/088_TurnItemTypeStatusIndex.ts"; const migrationEntries = [ [1, "OrchestrationEvents", Migration0001], @@ -185,6 +186,7 @@ const migrationEntries = [ [85, "TurnItemCounts", Migration0085], [86, "QueuedTurnInputIndex", Migration0086], [87, "ProjectionProjectsCustomTitle", Migration0087], + [88, "TurnItemTypeStatusIndex", Migration0088], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/088_TurnItemTypeStatusIndex.ts b/apps/server/src/persistence/Migrations/088_TurnItemTypeStatusIndex.ts new file mode 100644 index 0000000000000..09e0b3273d6cf --- /dev/null +++ b/apps/server/src/persistence/Migrations/088_TurnItemTypeStatusIndex.ts @@ -0,0 +1,13 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + // Typed reads of tool items (Stop's active-item read) otherwise walk every item of the thread. + // Status keeps an active-item read from fetching every settled row to reject it. + yield* sql` + CREATE INDEX IF NOT EXISTS orchestration_v2_projection_turn_items_thread_type_idx + ON orchestration_v2_projection_turn_items(thread_id, type, status) + `; +}); diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts index e046cc4133dc6..a2bba7d790eaf 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts @@ -67,6 +67,7 @@ describe("V2 preview upgrade", () => { [85, "TurnItemCounts"], [86, "QueuedTurnInputIndex"], [87, "ProjectionProjectsCustomTitle"], + [88, "TurnItemTypeStatusIndex"], ]); assert.deepStrictEqual(yield* runMigrations(), []); assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports); @@ -177,6 +178,7 @@ describe("V2 preview upgrade", () => { [85, "TurnItemCounts"], [86, "QueuedTurnInputIndex"], [87, "ProjectionProjectsCustomTitle"], + [88, "TurnItemTypeStatusIndex"], ]); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); From 8fe745d7afc6cfa5758c0b0614d03a242ba0ecc2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 20:55:41 -0800 Subject: [PATCH 051/390] fix(server): Codex protocol records go to the log of the thread they belong to (#35975) --- .../Adapters/CodexAdapterV2.test.ts | 42 +++++++++- .../Adapters/CodexAdapterV2.ts | 77 ++++++++++++++++++- 2 files changed, 114 insertions(+), 5 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index f3f4f22da9dc5..34b2fa746ea34 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -310,7 +310,6 @@ describe("CodexAdapterV2 native protocol logging", () => { const providerSessionId = ProviderSessionId.make("provider-session-1"); const protocolLogger = CodexAdapterV2.makeCodexAppServerProtocolLogger({ nativeEventLogger: logger, - threadId, providerSessionId, }); @@ -319,12 +318,52 @@ describe("CodexAdapterV2 native protocol logging", () => { return; } + const startThread = (requestId: number, appThreadId: string, nativeThreadId: string) => + Effect.gen(function* () { + yield* protocolLogger({ + direction: "outgoing", + stage: "decoded", + payload: { + id: requestId, + method: "thread/start", + params: { config: { "shell_environment_policy.set.T3CODE_THREAD_ID": appThreadId } }, + }, + }); + yield* protocolLogger({ + direction: "incoming", + stage: "decoded", + payload: { id: requestId, result: { thread: { id: nativeThreadId } } }, + }); + }); + yield* startThread(1, "thread-1", "native-1"); + yield* startThread(2, "thread-2", "native-2"); + yield* protocolLogger({ + direction: "outgoing", + stage: "decoded", + payload: { id: 3, method: "turn/start", params: { threadId: "native-2" } }, + }); + yield* protocolLogger({ + direction: "incoming", + stage: "decoded", + payload: { id: 3, result: { turn: { id: "turn-2" } } }, + }); + yield* protocolLogger({ + direction: "incoming", + stage: "decoded", + payload: { id: 4, result: { userAgent: "codex/1.0.0" } }, + }); + assert.deepEqual( + writes.splice(0).map((write) => write.threadId), + ["thread-1", "thread-1", "thread-2", "thread-2", "thread-2", "thread-2", null], + ); + yield* protocolLogger({ direction: "incoming", stage: "decoded", payload: { method: "thread/event", params: { + threadId: "native-1", id: "evt-1", http_headers: { Authorization: "Bearer secret-codex-token" }, usage: { inputTokens: 12, outputTokens: 8, totalTokens: 20 }, @@ -351,6 +390,7 @@ describe("CodexAdapterV2 native protocol logging", () => { payload: { method: "thread/event", params: { + threadId: "native-1", id: "evt-1", http_headers: { Authorization: "[REDACTED]" }, usage: { inputTokens: 12, outputTokens: 8, totalTokens: 20 }, diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 7e1e8e0151a02..efa5d0a9c28f1 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -40,6 +40,7 @@ import { type ServerProviderCompatibilityAdvisory, type ServerProviderModel, isDelegatedAppThread, + ThreadId, } from "@t3tools/contracts"; import { SKILL_MENTION_PATTERN } from "@t3tools/shared/composerInlineTokens"; import { AgentScope } from "@t3tools/shared/AgentScope"; @@ -78,7 +79,6 @@ import type { ProviderTurnId, ProviderInstanceId, RuntimeMode, - ThreadId, } from "@t3tools/contracts"; import * as CodexClient from "effect-codex-app-server/client"; import * as CodexErrors from "effect-codex-app-server/errors"; @@ -1544,9 +1544,77 @@ export const makeCodexAppServerSpawnCommand = Effect.fn( }); }); +function readCodexProtocolPath(value: unknown, ...path: ReadonlyArray): unknown { + let current = value; + for (const key of path) { + if (current === null || typeof current !== "object") return undefined; + current = Reflect.get(current, key); + } + return current; +} + +const CODEX_THREAD_ENV_CONFIG_KEY = `shell_environment_policy.set.${PortScanner.AGENT_THREAD_ENV}`; + +/** + * One app-server serves every Codex thread on an instance, so each frame is routed to the + * T3 thread it belongs to. Frames tied to no thread, such as `initialize`, go to the global log. + */ +function makeCodexProtocolThreadRouter() { + const threadIdsByNativeId = new Map(); + const threadIdsByRequest = new Map(); + return (direction: "incoming" | "outgoing", frame: unknown): ThreadId | null => { + const id = readCodexProtocolPath(frame, "id"); + const method = readCodexProtocolPath(frame, "method"); + const isRequest = typeof method === "string"; + const requestKey = + typeof id === "string" || typeof id === "number" + ? `${isRequest === (direction === "outgoing") ? "client" : "server"}:${id}` + : undefined; + if (!isRequest) { + if (requestKey === undefined) return null; + const threadId = threadIdsByRequest.get(requestKey); + threadIdsByRequest.delete(requestKey); + const nativeThreadId = readCodexProtocolPath(frame, "result", "thread", "id"); + if (threadId !== undefined && typeof nativeThreadId === "string") { + threadIdsByNativeId.set(nativeThreadId, threadId); + } + return threadId ?? null; + } + + const params = readCodexProtocolPath(frame, "params"); + const configuredThreadId = readCodexProtocolPath(params, "config", CODEX_THREAD_ENV_CONFIG_KEY); + const nativeThreadId = + readCodexProtocolPath(params, "threadId") ?? readCodexProtocolPath(params, "thread", "id"); + let threadId = + typeof configuredThreadId === "string" + ? ThreadId.make(configuredThreadId) + : typeof nativeThreadId === "string" + ? threadIdsByNativeId.get(nativeThreadId) + : undefined; + if (threadId === undefined && typeof nativeThreadId === "string") { + const parentNativeThreadId = readCodexProtocolPath( + params, + "thread", + "source", + "subAgent", + "thread_spawn", + "parent_thread_id", + ); + threadId = + typeof parentNativeThreadId === "string" + ? threadIdsByNativeId.get(parentNativeThreadId) + : undefined; + if (threadId !== undefined) threadIdsByNativeId.set(nativeThreadId, threadId); + } + if (threadId !== undefined && requestKey !== undefined) { + threadIdsByRequest.set(requestKey, threadId); + } + return threadId ?? null; + }; +} + export function makeCodexAppServerProtocolLogger(input: { readonly nativeEventLogger: ProviderEventLoggers.EventNdjsonLogger | undefined; - readonly threadId: ThreadId; readonly providerSessionId: OrchestrationV2ProviderSession["id"]; }): CodexClient.CodexAppServerClientOptions["logger"] | undefined { const { nativeEventLogger } = input; @@ -1554,8 +1622,10 @@ export function makeCodexAppServerProtocolLogger(input: { return undefined; } + const routeThread = makeCodexProtocolThreadRouter(); return (event) => { if (!shouldPersistProviderEvent("native", event)) return Effect.void; + const threadId = event.stage === "decoded" ? routeThread(event.direction, event.payload) : null; return nativeEventLogger .write( { @@ -1565,7 +1635,7 @@ export function makeCodexAppServerProtocolLogger(input: { providerSessionId: input.providerSessionId, event: redactCodexProtocolValue(boundProviderEventForLogging(event)), }, - input.threadId, + threadId, ) .pipe(Effect.ignore); }; @@ -1667,7 +1737,6 @@ export const layerAppServerClientFactory: Layer.Layer< ); const protocolLogger = makeCodexAppServerProtocolLogger({ nativeEventLogger, - threadId: input.threadId, providerSessionId: input.providerSessionId, }); const clientOptions: CodexClient.CodexAppServerClientOptions = From c54e5b31cebb524bda9ad787bba43a08d2488c8c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 20:58:00 -0800 Subject: [PATCH 052/390] fix(cli): service.env updates keep quoted multiline values intact (#35976) --- apps/server/src/cli/service.test.ts | 6 +++++ apps/server/src/cli/service.ts | 35 ++++++++++++++++++++++++----- apps/server/src/cli/up.test.ts | 14 ++++++++++++ 3 files changed, 49 insertions(+), 6 deletions(-) diff --git a/apps/server/src/cli/service.test.ts b/apps/server/src/cli/service.test.ts index 900c41e661740..7dd4e852adba8 100644 --- a/apps/server/src/cli/service.test.ts +++ b/apps/server/src/cli/service.test.ts @@ -115,6 +115,12 @@ it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("service commands env: "T3CODE_HOST=::\n", expected: "T3CODE_HOST=::\n", }, + { + label: "a quoted multiline value", + installed: false, + env: 'CUSTOM="first\nT3CODE_LAN=example\nlast"\n', + expected: 'CUSTOM="first\nT3CODE_LAN=example\nlast"\nT3CODE_LAN=false\n', + }, ])("install keeps $label off the local network only when it is new", (input) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/cli/service.ts b/apps/server/src/cli/service.ts index dedc095f62ffd..2a34eb09dd08e 100644 --- a/apps/server/src/cli/service.ts +++ b/apps/server/src/cli/service.ts @@ -47,14 +47,37 @@ export const upsertServiceEnv = ( entries: Readonly>, ): string => { const written = new Set(); - const lines = contents.split("\n").flatMap((line) => { - const key = SERVICE_ENV_KEY.exec(line)?.[1]; - if (key === undefined || !Object.hasOwn(entries, key)) return [line]; + const source = contents.split("\n"); + const lines: string[] = []; + for (let index = 0, offset = 0; index < source.length;) { + const line = source[index]!; + const match = SERVICE_ENV_KEY.exec(line); + let end = index + 1; + if (match !== null) { + // util.parseEnv lets a quoted value run to its closing quote across lines, + // skipping only spaces, tabs and CRs before the opening quote. + const rest = line.slice(match[0].length).replace(/^[ \t\r]+/u, ""); + const quote = rest[0]; + if ((quote === '"' || quote === "'" || quote === "`") && !rest.includes(quote, 1)) { + const closing = contents.indexOf(quote, offset + line.length); + if (closing !== -1) { + end += contents.slice(offset + line.length, closing).split("\n").length - 1; + } + } + } + const span = source.slice(index, end); + offset += span.reduce((length, part) => length + part.length + 1, 0); + index = end; + const key = match?.[1]; + if (key === undefined || !Object.hasOwn(entries, key)) { + lines.push(span.join("\n")); + continue; + } const value = entries[key]; - if (value === undefined || written.has(key)) return []; + if (value === undefined || written.has(key)) continue; written.add(key); - return [`${key}=${value}`]; - }); + lines.push(`${key}=${value}`); + } while (lines.at(-1) === "") lines.pop(); for (const [key, value] of Object.entries(entries)) { if (value !== undefined && !written.has(key)) lines.push(`${key}=${value}`); diff --git a/apps/server/src/cli/up.test.ts b/apps/server/src/cli/up.test.ts index f97a540570b6c..05d4185593a37 100644 --- a/apps/server/src/cli/up.test.ts +++ b/apps/server/src/cli/up.test.ts @@ -413,4 +413,18 @@ it("rewrites only the named service.env keys and keeps the user's lines", () => ["# my settings", "T3CODE_PORT=4000", "T3CODE_LAN=true", ""].join("\n"), ); expect(upsertServiceEnv("", { T3CODE_LAN: "false" })).toBe("T3CODE_LAN=false\n"); + expect( + upsertServiceEnv('T3CODE_LAN="a\nb"\nCUSTOM="x\nT3CODE_LAN=y"\nT3CODE_LAN=true\n', { + T3CODE_LAN: "false", + }), + ).toBe('T3CODE_LAN=false\nCUSTOM="x\nT3CODE_LAN=y"\n'); + expect( + upsertServiceEnv('T3CODE_LAN=\u00a0"unused\nCUSTOM=keep\nEND="\nT3CODE_LAN=true\n', { + T3CODE_LAN: "false", + }), + ).toBe('T3CODE_LAN=false\nCUSTOM=keep\nEND="\n'); + const long = `CUSTOM="${"\n".repeat(130_000)}"\n`; + expect(upsertServiceEnv(`${long}T3CODE_LAN=true\n`, { T3CODE_LAN: "false" })).toBe( + `${long}T3CODE_LAN=false\n`, + ); }); From a621be8d15428234bbbc9fc88a8b447c5c75774e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 20:59:32 -0800 Subject: [PATCH 053/390] fix(web): open code blocks and diffs follow a changed word wrap default (#35922) --- apps/web/src/components/DiffPanel.tsx | 5 +-- .../chat/MarkdownCodeBlock.browser.test.tsx | 35 +++++++++++++++++++ .../src/components/chat/MarkdownCodeBlock.tsx | 8 +++-- .../pullRequest/PullRequestCodeTab.tsx | 7 ++-- 4 files changed, 47 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/components/chat/MarkdownCodeBlock.browser.test.tsx diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 4231b669aa76b..7c75bfea34338 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -232,7 +232,8 @@ export default function DiffPanel({ wordWrap: settings.wordWrap, })); const diffLayout = settings.diffLayout; - const [wordWrap, setWordWrap] = useState(settings.wordWrap); + const [wordWrapOverride, setWordWrapOverride] = useState(null); + const wordWrap = wordWrapOverride ?? settings.wordWrap; const diffIgnoreWhitespace = useDiffPanelSessionStore((state) => state.ignoreWhitespace) ?? settings.diffIgnoreWhitespace; const [fileTreeOpen, setFileTreeOpen] = useLocalStorage( @@ -1218,7 +1219,7 @@ export default function DiffPanel({ size="sm" pressed={wordWrap} onPressedChange={(pressed) => { - setWordWrap(Boolean(pressed)); + setWordWrapOverride(Boolean(pressed)); }} /> } diff --git a/apps/web/src/components/chat/MarkdownCodeBlock.browser.test.tsx b/apps/web/src/components/chat/MarkdownCodeBlock.browser.test.tsx new file mode 100644 index 0000000000000..903c6346f758b --- /dev/null +++ b/apps/web/src/components/chat/MarkdownCodeBlock.browser.test.tsx @@ -0,0 +1,35 @@ +import { expect, it } from "vite-plus/test"; + +import { act, render } from "../../../test/render"; +import "../../index.css"; + +import { ensureClientSettingsHydrated, persistClientSettingsPatch } from "../../hooks/useSettings"; +import { MarkdownCodeBlock } from "./MarkdownCodeBlock"; + +const setWordWrap = (wordWrap: boolean) => + act(() => persistClientSettingsPatch({ wordWrap }, async () => {})); + +it("follows a changed word wrap default while mounted", async () => { + await ensureClientSettingsHydrated(); + await setWordWrap(false); + await using screen = await render( + +

+        a long line
+      
+ , + { router: false }, // The code block reads no route state. + ); + const block = () => screen.container.querySelector(".chat-markdown-codeblock"); + expect(block()?.getAttribute("data-wrap")).toBe("false"); + + await setWordWrap(true); + await expect.element(screen.getByRole("button", { name: "Disable line wrap" })).toBeVisible(); + expect(block()?.getAttribute("data-wrap")).toBe("true"); +}); diff --git a/apps/web/src/components/chat/MarkdownCodeBlock.tsx b/apps/web/src/components/chat/MarkdownCodeBlock.tsx index 7992ce2e6f76b..24a7d083c7025 100644 --- a/apps/web/src/components/chat/MarkdownCodeBlock.tsx +++ b/apps/web/src/components/chat/MarkdownCodeBlock.tsx @@ -3,7 +3,7 @@ import { toHtml } from "hast-util-to-html"; import React, { use, useEffect, useState, type ReactNode } from "react"; import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; -import { getClientSettings } from "../../hooks/useSettings"; +import { useClientSettings } from "../../hooks/useSettings"; import type { DiffThemeName } from "../../lib/diffRendering"; import { createIncrementalHighlightedDocument, @@ -161,7 +161,9 @@ export function MarkdownCodeBlock({ cause, ), }); - const [wrapped, setWrapped] = useState(() => getClientSettings().wordWrap); + const defaultWrapped = useClientSettings((settings) => settings.wordWrap); + const [wrapOverride, setWrapOverride] = useState(null); + const wrapped = wrapOverride ?? defaultWrapped; const wrapLabel = wrapped ? "Disable line wrap" : "Wrap lines"; const copyLabel = copied ? "Copied" : "Copy code"; const command = code.trim(); @@ -227,7 +229,7 @@ export function MarkdownCodeBlock({ variant={wrapped ? "secondary" : "ghost-muted"} size="icon-xs" aria-pressed={wrapped} - onClick={() => setWrapped((value) => !value)} + onClick={() => setWrapOverride(!wrapped)} aria-label={wrapLabel} /> } diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index febf82441f514..c985a68045f64 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -395,7 +395,8 @@ function PullRequestCodeTab({ const effectiveFoldOverride = foldOverride ?? (settings.diffFilesCollapsed ? "folded" : "expanded"); const diffLayout = settings.diffLayout; - const [wordWrap, setWordWrap] = useState(settings.wordWrap); + const [wordWrapOverride, setWordWrapOverride] = useState(null); + const wordWrap = wordWrapOverride ?? settings.wordWrap; const [ignoreWhitespace, setIgnoreWhitespace] = useState(settings.diffIgnoreWhitespace); const [fileTreeOpen, setFileTreeOpen] = useLocalStorage( PULL_REQUEST_FILE_TREE_STORAGE_KEY, @@ -1315,7 +1316,7 @@ function PullRequestCodeTab({ setWordWrap(checked)} + onCheckedChange={(checked) => setWordWrapOverride(checked)} > Wrap lines @@ -1386,7 +1387,7 @@ function PullRequestCodeTab({ size="sm" pressed={wordWrap} onPressedChange={(pressed) => { - setWordWrap(Boolean(pressed)); + setWordWrapOverride(Boolean(pressed)); }} /> } From e01856366962ddaa6917a05b18c7431d39c88f0e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 21:00:19 -0800 Subject: [PATCH 054/390] fix(desktop): keep WSL distros whose names start or end with a dot or hyphen (#35971) --- apps/desktop/src/wsl/wslPathParsing.test.ts | 17 ++++++++++++++--- apps/desktop/src/wsl/wslPathParsing.ts | 3 ++- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/wsl/wslPathParsing.test.ts b/apps/desktop/src/wsl/wslPathParsing.test.ts index 0a814229665dd..ed491f58381ae 100644 --- a/apps/desktop/src/wsl/wslPathParsing.test.ts +++ b/apps/desktop/src/wsl/wslPathParsing.test.ts @@ -77,8 +77,15 @@ describe("wslUncPathToLinuxPath", () => { expect(wslUncPathToLinuxPath("\\\\wsl.localhost\\Debian\\")).toBe("/"); }); + it("maps UNC paths of distros whose names start or end with a dot or hyphen", () => { + for (const name of ["Ubuntu-", "Ubuntu.", ".Ubuntu", "-Ubuntu"]) { + expect(wslUncPathToLinuxPath(`\\\\wsl.localhost\\${name}\\home\\josh`)).toBe("/home/josh"); + } + }); + it("rejects invalid distro names and non-WSL paths", () => { expect(wslUncPathToLinuxPath("\\\\wsl.localhost\\bad!name\\home")).toBeNull(); + expect(wslUncPathToLinuxPath("\\\\wsl.localhost\\..\\home")).toBeNull(); expect(wslUncPathToLinuxPath("C:\\Users\\Josh\\repo")).toBeNull(); }); }); @@ -107,9 +114,13 @@ describe("resolveWslPickFolderDefaultPath", () => { }); describe("isValidDistroName", () => { - it("rejects names with trailing whitespace, hyphen, or dot", () => { - for (const name of ["Ubuntu ", "Ubuntu-", "Ubuntu."]) { - expect(isValidDistroName(name)).toBe(false); + it("rejects names with trailing whitespace", () => { + expect(isValidDistroName("Ubuntu ")).toBe(false); + }); + + it("accepts names WSL registers with a leading or trailing dot or hyphen", () => { + for (const name of ["Ubuntu-", "Ubuntu.", ".Ubuntu", "-Ubuntu"]) { + expect(isValidDistroName(name)).toBe(true); } }); diff --git a/apps/desktop/src/wsl/wslPathParsing.ts b/apps/desktop/src/wsl/wslPathParsing.ts index a1ce99c129d13..1df64f91dac0e 100644 --- a/apps/desktop/src/wsl/wslPathParsing.ts +++ b/apps/desktop/src/wsl/wslPathParsing.ts @@ -9,7 +9,8 @@ export interface WslConfig { } // Literal spaces only; \s would accept controls and corrupt UNC distro paths. -const DISTRO_NAME_PATTERN = /^\w(?:[\w \-.]*\w)?$/; +// "." and ".." would be path segments in a UNC path, not distro names. +const DISTRO_NAME_PATTERN = /^(?!\.{1,2}$)[\w.-](?:[\w .-]*[\w.-])?$/; export function parseWslDistroList(stdout: Buffer): readonly WslDistro[] { const hasUtf16Bom = stdout.length >= 2 && stdout[0] === 0xff && stdout[1] === 0xfe; From 43ce39595bfcea4af4b99b1fa77c7e1a73256da2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 21:07:09 -0800 Subject: [PATCH 055/390] test: run clock-free live tests on TestClock (#35868) Co-authored-by: Claude Opus 5.5 --- .../environment-cache-store.test.ts | 14 +- apps/server/src/auth/McpOAuth.test.ts | 6 +- .../src/mcp/OrchestratorMcpService.test.ts | 2 +- .../Adapters/ClaudeTaskOutputTail.test.ts | 2 +- apps/server/src/preview/ServerBrowser.test.ts | 541 +++++++++--------- .../server/src/provider/ModelManifest.test.ts | 53 +- .../provider/ProviderInstanceRegistry.test.ts | 6 +- apps/server/src/storageCleanup.test.ts | 33 +- apps/server/src/usage/UsageService.test.ts | 296 +++++----- apps/server/src/ws.test.ts | 198 +++---- .../src/state/pullRequests.test.ts | 341 +++++------ packages/shared/src/DrainableWorker.test.ts | 2 +- .../shared/src/KeyedCoalescingWorker.test.ts | 8 +- 13 files changed, 771 insertions(+), 731 deletions(-) diff --git a/apps/mobile/src/connection/environment-cache-store.test.ts b/apps/mobile/src/connection/environment-cache-store.test.ts index e9a24552ce369..1205c746e9fa1 100644 --- a/apps/mobile/src/connection/environment-cache-store.test.ts +++ b/apps/mobile/src/connection/environment-cache-store.test.ts @@ -24,6 +24,7 @@ import * as Fiber from "effect/Fiber"; import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; import * as MobileDatabase from "../persistence/mobile-database"; import { make } from "./environment-cache-store"; @@ -567,7 +568,7 @@ describe("mobile SQLite environment cache store", () => { }), ); - it.live("returns cached threads before their pull request links, which load after", () => + it.effect("returns cached threads before their pull request links, which load after", () => Effect.gen(function* () { const memory = makeDatabase(); const store = yield* make().pipe( @@ -592,12 +593,13 @@ describe("mobile SQLite environment cache store", () => { const shell = Option.getOrThrow(yield* store.loadShell(ENVIRONMENT_ID)); expect(shell.threads.map((thread) => thread.pullRequests)).toEqual([undefined, undefined]); - const links = yield* shell.loadPullRequests!; - expect([...links]).toEqual([[THREAD_ID, [link]]]); + const links = yield* Effect.forkChild(shell.loadPullRequests!); + yield* TestClock.adjust("1 millis"); + expect([...(yield* Fiber.join(links))]).toEqual([[THREAD_ID, [link]]]); }), ); - it.live("keeps cached threads when their pull request links cannot be read", () => { + it.effect("keeps cached threads when their pull request links cannot be read", () => { const messages: Array = []; const logger = Logger.make(({ message }) => { messages.push(message); @@ -633,7 +635,9 @@ describe("mobile SQLite environment cache store", () => { const shell = Option.getOrThrow(yield* store.loadShell(ENVIRONMENT_ID)); expect(shell.threads.map((thread) => thread.id)).toEqual([THREAD_ID, readable.id]); - expect([...(yield* shell.loadPullRequests!)]).toEqual([[readable.id, [link]]]); + const links = yield* Effect.forkChild(shell.loadPullRequests!); + yield* TestClock.adjust("1 millis"); + expect([...(yield* Fiber.join(links))]).toEqual([[readable.id, [link]]]); expect(messages).toHaveLength(1); expect(memory.removed).toEqual([]); }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); diff --git a/apps/server/src/auth/McpOAuth.test.ts b/apps/server/src/auth/McpOAuth.test.ts index c7cf59db93863..4cd276c51133b 100644 --- a/apps/server/src/auth/McpOAuth.test.ts +++ b/apps/server/src/auth/McpOAuth.test.ts @@ -193,7 +193,7 @@ const authorizeParams = (clientId: string, redirect = "http://localhost:51234/ca resource: `${ORIGIN}/mcp`, }); -it.live("derives discovery metadata from the origin the client reached", () => +it.effect("derives discovery metadata from the origin the client reached", () => withRoutes((handler) => Effect.gen(function* () { const resource = yield* handler(at("/.well-known/oauth-protected-resource/mcp")); @@ -215,7 +215,7 @@ it.live("derives discovery metadata from the origin the client reached", () => ), ); -it.live("registers loopback and https clients and never redirects for an unverified client", () => +it.effect("registers loopback and https clients and never redirects for an unverified client", () => withRoutes((handler) => Effect.gen(function* () { for (const refused of [ @@ -622,7 +622,7 @@ it.live("an app approval code approves only an app sign-in, at its own level", ( ), ); -it.live( +it.effect( "sends an app sign-in on the linked tunnel to the hosted app, and a link to its own page", () => Effect.gen(function* () { diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index 8f349f77b71aa..9cd3231efdc41 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -2202,7 +2202,7 @@ describe("OrchestratorMcpService provider resolution", () => { }), ); - it.live.each([ + it.effect.each([ { name: "parent instance", parentEnabled: true, model: "gpt-5.4" }, { name: "alternate instance", parentEnabled: false, model: "gpt-5.5" }, ])( diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.test.ts index a1a4212326868..4c29f71e1bda1 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.test.ts @@ -101,7 +101,7 @@ describe("tailClaudeTaskOutput", () => { ).pipe(Effect.provide(NodeServices.layer)), ); - it.live("reads what was written last when the tool result stops it", () => + it.effect("reads what was written last when the tool result stops it", () => Effect.scoped( Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index b1b17334c30cd..d2897987dc1f6 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -594,7 +594,7 @@ beforeEach(() => { desktopConnections.length = 0; }); -it.live("tells the desktop once per thread when its agent turns start and end", () => +it.effect("tells the desktop once per thread when its agent turns start and end", () => Effect.gen(function* () { yield* ServerBrowser.ServerBrowser; const activity = yield* PreviewAgentActivity.PreviewAgentActivity; @@ -616,7 +616,7 @@ it.live("tells the desktop once per thread when its agent turns start and end", }).pipe(Effect.scoped, Effect.provide(layer)), ); -it.live("preview_open reports its navigation timeout instead of the request budget", () => +it.effect("preview_open reports its navigation timeout instead of the request budget", () => Effect.scoped( Effect.gen(function* () { const { broker, tabId } = yield* ready; @@ -652,48 +652,54 @@ it.live("preview_open reports its navigation timeout instead of the request budg ).pipe(Effect.provide(layer)), ); -it.live("an initial download stays available after preview_open reports the navigation error", () => - Effect.scoped( - Effect.gen(function* () { - const { broker, tabId } = yield* ready; - const manager = yield* Manager.PreviewManager; - const released = Promise.withResolvers(); - initialNavigation = async () => { - contexts[1]!.page.emit("download", { - failure: async () => null, - delete: async () => released.resolve(), - saveAs: (path: string) => NodeFSP.writeFile(path, "a,b"), - suggestedFilename: () => "report.csv", - url: () => "http://localhost:1337/report.csv", - }); - throw new Error("page.goto: Download is starting"); - }; - const error = yield* broker - .invoke({ +it.effect( + "an initial download stays available after preview_open reports the navigation error", + () => + Effect.scoped( + Effect.gen(function* () { + const { broker, tabId } = yield* ready; + const manager = yield* Manager.PreviewManager; + const released = Promise.withResolvers(); + initialNavigation = async () => { + contexts[1]!.page.emit("download", { + failure: async () => null, + delete: async () => released.resolve(), + saveAs: (path: string) => NodeFSP.writeFile(path, "a,b"), + suggestedFilename: () => "report.csv", + url: () => "http://localhost:1337/report.csv", + }); + throw new Error("page.goto: Download is starting"); + }; + const error = yield* broker + .invoke({ + scope, + operation: "open", + input: { + url: "http://localhost:1337/report.csv", + reuseExistingTab: false, + open: false, + }, + }) + .pipe(Effect.flip); + expect(error.message).toContain("Download is starting"); + yield* Effect.promise(() => released.promise); + const { sessions } = yield* manager.list({ threadId: scope.thread.threadId }); + expect(sessions).toHaveLength(2); + const downloadTabId = PreviewTabId.make(sessions.find((tab) => tab.tabId !== tabId)!.tabId); + const status = yield* broker.invoke({ scope, - operation: "open", - input: { url: "http://localhost:1337/report.csv", reuseExistingTab: false, open: false }, - }) - .pipe(Effect.flip); - expect(error.message).toContain("Download is starting"); - yield* Effect.promise(() => released.promise); - const { sessions } = yield* manager.list({ threadId: scope.thread.threadId }); - expect(sessions).toHaveLength(2); - const downloadTabId = PreviewTabId.make(sessions.find((tab) => tab.tabId !== tabId)!.tabId); - const status = yield* broker.invoke({ - scope, - tabId: downloadTabId, - operation: "status", - input: {}, - }); - expect(status.downloads).toEqual([ - expect.objectContaining({ fileName: "report.csv", sizeBytes: 3 }), - ]); - expect(yield* (yield* FileSystem.FileSystem).readFileString(status.downloads![0]!.path)).toBe( - "a,b", - ); - }), - ).pipe(Effect.provide(layer)), + tabId: downloadTabId, + operation: "status", + input: {}, + }); + expect(status.downloads).toEqual([ + expect.objectContaining({ fileName: "report.csv", sizeBytes: 3 }), + ]); + expect( + yield* (yield* FileSystem.FileSystem).readFileString(status.downloads![0]!.path), + ).toBe("a,b"); + }), + ).pipe(Effect.provide(layer)), ); it.effect.each([ @@ -820,7 +826,7 @@ it.live("navigate fails when the page never commits within its timeout", () => ).pipe(Effect.provide(layer)), ); -it.live.each(["desktop" as const, undefined])( +it.effect.each(["desktop" as const, undefined])( "explicit desktop targets ($0 runtime) fail without creating a server tab", (runtime) => Effect.scoped( @@ -992,7 +998,7 @@ it.effect("a desktop page that loaded before the server attached reports its loa ).pipe(Effect.provide(layer)), ); -it.live.each([ +it.effect.each([ { method: "goto" as const, message: { type: "navigate", url: "http://10.255.255.1/" } }, { method: "goBack" as const, message: { type: "history", delta: -1 } }, { method: "goForward" as const, message: { type: "history", delta: 1 } }, @@ -1041,7 +1047,7 @@ it.live.each([ ); // The fake encoder decodes every frame at 1280x800, so 30 fps hits the bitrate floor. -it.live.each([ +it.effect.each([ { reports: [], frameRate: 30, bitsPerSecond: 2.5e6 }, { reports: [30], frameRate: 30, bitsPerSecond: 2.5e6 }, { reports: [60], frameRate: 60, bitsPerSecond: 1280 * 800 * 60 * 0.05 }, @@ -1071,7 +1077,7 @@ it.live.each([ ).pipe(Effect.provide(layer)), ); -it.live.each([{ truncated: false }, { truncated: true }])( +it.effect.each([{ truncated: false }, { truncated: true }])( "saves a large recording as a binary download and removes its staged file (truncated: $truncated)", ({ truncated }) => Effect.scoped( @@ -1129,7 +1135,7 @@ it.live.each([{ truncated: false }, { truncated: true }])( ).pipe(Effect.provide(layer)), ); -it.live.each([ +it.effect.each([ { refreshFails: false, expected: "final frame" }, { refreshFails: true, expected: "frame" }, ])( @@ -1160,7 +1166,7 @@ it.live.each([ ).pipe(Effect.provide(layer)), ); -it.live("enforces provider ownership and explicit targets when a session has multiple tabs", () => +it.effect("enforces provider ownership and explicit targets when a session has multiple tabs", () => Effect.scoped( Effect.gen(function* () { const { broker, tabId } = yield* ready; @@ -1292,49 +1298,51 @@ it.effect("a first still that never returns does not stall the tab's later captu ).pipe(Effect.provide(layer)), ); -it.live("streams to a read-only viewer without allowing takeover, input, or viewport changes", () => - Effect.scoped( - Effect.gen(function* () { - const { browser, tabId } = yield* ready; - const viewer = yield* browser.attachViewer(viewerInput(tabId, false)); - const page = contexts[0]!.page; - page.setViewportSize.mockClear(); - const session = contexts[0]!.sessions.at(-1)!; - session.send.mockClear(); - for (const message of [ - { type: "takeControl" }, - { type: "key", action: "down", key: "a", text: "a" }, - { type: "resize", width: 390, height: 844 }, - { type: "viewport", setting: { _tag: "freeform", width: 390, height: 844 } }, - ]) - yield* viewer.input(message); - expect(page.setViewportSize).not.toHaveBeenCalled(); - expect(session.send.mock.calls.some(([method]) => method.startsWith("Input."))).toBe(false); - const outputs = yield* Stream.fromQueue(viewer.output).pipe( - Stream.takeUntil((output) => output._tag === "frame"), - Stream.runCollect, - ); - expect(outputs).toContainEqual( - expect.objectContaining({ _tag: "frame", data: Buffer.from("frame") }), - ); - expect(outputs).toContainEqual(expect.objectContaining({ _tag: "viewport" })); - expect(outputs).toContainEqual( - expect.objectContaining({ _tag: "control", canOperate: false, controller: "agent" }), - ); - const operator = yield* browser.attachViewer(viewerInput(tabId, true)); - yield* operator.input({ type: "takeControl" }); - yield* operator.input({ type: "text", text: "typed" }); - yield* operator.input({ type: "resize", width: 390, height: 844 }); - expect(contexts[0]!.sessions.at(-1)!.send).toHaveBeenCalledWith("Input.insertText", { - text: "typed", - }); - expect(page.setViewportSize).toHaveBeenCalledWith({ width: 390, height: 844 }); - yield* operator.input({ type: "releaseControl" }); - }), - ).pipe(Effect.provide(layer)), +it.effect( + "streams to a read-only viewer without allowing takeover, input, or viewport changes", + () => + Effect.scoped( + Effect.gen(function* () { + const { browser, tabId } = yield* ready; + const viewer = yield* browser.attachViewer(viewerInput(tabId, false)); + const page = contexts[0]!.page; + page.setViewportSize.mockClear(); + const session = contexts[0]!.sessions.at(-1)!; + session.send.mockClear(); + for (const message of [ + { type: "takeControl" }, + { type: "key", action: "down", key: "a", text: "a" }, + { type: "resize", width: 390, height: 844 }, + { type: "viewport", setting: { _tag: "freeform", width: 390, height: 844 } }, + ]) + yield* viewer.input(message); + expect(page.setViewportSize).not.toHaveBeenCalled(); + expect(session.send.mock.calls.some(([method]) => method.startsWith("Input."))).toBe(false); + const outputs = yield* Stream.fromQueue(viewer.output).pipe( + Stream.takeUntil((output) => output._tag === "frame"), + Stream.runCollect, + ); + expect(outputs).toContainEqual( + expect.objectContaining({ _tag: "frame", data: Buffer.from("frame") }), + ); + expect(outputs).toContainEqual(expect.objectContaining({ _tag: "viewport" })); + expect(outputs).toContainEqual( + expect.objectContaining({ _tag: "control", canOperate: false, controller: "agent" }), + ); + const operator = yield* browser.attachViewer(viewerInput(tabId, true)); + yield* operator.input({ type: "takeControl" }); + yield* operator.input({ type: "text", text: "typed" }); + yield* operator.input({ type: "resize", width: 390, height: 844 }); + expect(contexts[0]!.sessions.at(-1)!.send).toHaveBeenCalledWith("Input.insertText", { + text: "typed", + }); + expect(page.setViewportSize).toHaveBeenCalledWith({ width: 390, height: 844 }); + yield* operator.input({ type: "releaseControl" }); + }), + ).pipe(Effect.provide(layer)), ); -it.live( +it.effect( "takeover waits for the running agent and revokes snapshot refs before returning control", () => Effect.scoped( @@ -1395,7 +1403,7 @@ it.live( ).pipe(Effect.provide(layer)), ); -it.live( +it.effect( "reports a valid CSS selector that matches several elements as ambiguous, not invalid", () => Effect.scoped( @@ -1429,7 +1437,7 @@ it.live( ).pipe(Effect.provide(layer)), ); -it.live("reports a pending dialog without evaluating the page and resolves it explicitly", () => +it.effect("reports a pending dialog without evaluating the page and resolves it explicitly", () => Effect.scoped( Effect.gen(function* () { const { broker, tabId } = yield* ready; @@ -1508,7 +1516,7 @@ it.live("reports a pending dialog without evaluating the page and resolves it ex ).pipe(Effect.provide(layer)), ); -it.live("closing an isolated tab closes its context only after the page closes", () => +it.effect("closing an isolated tab closes its context only after the page closes", () => Effect.scoped( Effect.gen(function* () { const { broker, tabId } = yield* ready; @@ -1527,7 +1535,7 @@ it.live("closing an isolated tab closes its context only after the page closes", ).pipe(Effect.provide(layer)), ); -it.live("closing a tab force-closes a page that never finishes unloading", () => +it.effect("closing a tab force-closes a page that never finishes unloading", () => Effect.scoped( Effect.gen(function* () { const { broker, tabId } = yield* ready; @@ -1548,7 +1556,7 @@ it.live("closing a tab force-closes a page that never finishes unloading", () => ).pipe(Effect.provide(layer)), ); -it.live("closing a desktop tab waits for its page to unload before the desktop removes it", () => +it.effect("closing a desktop tab waits for its page to unload before the desktop removes it", () => Effect.scoped( Effect.gen(function* () { const { broker } = yield* ready; @@ -1588,7 +1596,7 @@ it.live("closing a desktop tab waits for its page to unload before the desktop r ).pipe(Effect.provide(layer)), ); -it.live("closing a tab with an unsaved-changes guard still closes it", () => +it.effect("closing a tab with an unsaved-changes guard still closes it", () => Effect.scoped( Effect.gen(function* () { const browser = yield* ServerBrowser.ServerBrowser; @@ -1619,7 +1627,7 @@ it.live("closing a tab with an unsaved-changes guard still closes it", () => ).pipe(Effect.provide(layer)), ); -it.live("a tab opened to a file the browser cannot show reports the file to download", () => +it.effect("a tab opened to a file the browser cannot show reports the file to download", () => Effect.scoped( Effect.gen(function* () { const browser = yield* ServerBrowser.ServerBrowser; @@ -1683,7 +1691,7 @@ it.live("a tab opened to a file the browser cannot show reports the file to down ).pipe(Effect.provide(layer)), ); -it.live( +it.effect( "a late failure from a request that predates tracking leaves a newer navigation loading", () => Effect.scoped( @@ -1918,7 +1926,7 @@ it.effect.each([ ).pipe(Effect.provide(layer)), ); -it.live("only the controlling viewer's copy and cut keys reach its clipboard", () => +it.effect("only the controlling viewer's copy and cut keys reach its clipboard", () => Effect.scoped( Effect.gen(function* () { const { browser, tabId } = yield* ready; @@ -1955,7 +1963,7 @@ it.live("only the controlling viewer's copy and cut keys reach its clipboard", ( ).pipe(Effect.provide(layer)), ); -it.live("a busy embedded frame does not hold a copy key back from the page", () => +it.effect("a busy embedded frame does not hold a copy key back from the page", () => Effect.scoped( Effect.gen(function* () { const { browser, tabId } = yield* ready; @@ -2003,7 +2011,7 @@ it.live("a busy embedded frame does not hold a copy key back from the page", () ).pipe(Effect.provide(layer)), ); -it.live.each([false, true])( +it.effect.each([false, true])( "a page download is saved, offered to the controller, and listed for the agent (delete fails: %s)", (deleteFails) => Effect.scoped( @@ -2125,7 +2133,7 @@ it.effect("a tab showing a data: page reports its URL without the content", () = ).pipe(Effect.provide(layer)), ); -it.live.each(["save failed", "oversized"])( +it.effect.each(["save failed", "oversized"])( "a rejected download releases the original and keeps no saved copy (%s)", (outcome) => Effect.scoped( @@ -2166,7 +2174,7 @@ it.live.each(["save failed", "oversized"])( ).pipe(Effect.provide(layer)), ); -it.live("a restarted server removes the previous run's downloads and keeps its profiles", () => +it.effect("a restarted server removes the previous run's downloads and keeps its profiles", () => Effect.scoped( Effect.gen(function* () { const { stateDir } = yield* ServerConfig.ServerConfig; @@ -2187,7 +2195,7 @@ it.live("a restarted server removes the previous run's downloads and keeps its p ).pipe(Effect.provide(dependencies)), ); -it.live("a page's file picker goes to the controller and takes its uploaded files", () => +it.effect("a page's file picker goes to the controller and takes its uploaded files", () => Effect.scoped( Effect.gen(function* () { const { browser, tabId } = yield* ready; @@ -2222,7 +2230,7 @@ it.live("a page's file picker goes to the controller and takes its uploaded file ).pipe(Effect.provide(layer)), ); -it.live("overlapping file pickers keep the newer offer answerable", () => +it.effect("overlapping file pickers keep the newer offer answerable", () => Effect.scoped( Effect.gen(function* () { const { browser, tabId } = yield* ready; @@ -2262,7 +2270,7 @@ it.live("overlapping file pickers keep the newer offer answerable", () => ).pipe(Effect.provide(layer)), ); -it.live("a detached file picker stays open across same-document navigation", () => +it.effect("a detached file picker stays open across same-document navigation", () => Effect.scoped( Effect.gen(function* () { const { browser, tabId } = yield* ready; @@ -2327,7 +2335,7 @@ it.live("a detached file picker stays open across same-document navigation", () ).pipe(Effect.provide(layer)), ); -it.live.each(["main", "iframe"])( +it.effect.each(["main", "iframe"])( "a file picker closes at %s document commit before DOMContentLoaded", (frame) => Effect.scoped( @@ -2377,7 +2385,7 @@ it.live.each(["main", "iframe"])( ).pipe(Effect.provide(layer)), ); -it.live("a file picker closes when its page is replaced while control changes hands", () => +it.effect("a file picker closes when its page is replaced while control changes hands", () => Effect.scoped( Effect.gen(function* () { const { broker, browser, tabId } = yield* ready; @@ -2425,7 +2433,7 @@ it.live("a file picker closes when its page is replaced while control changes ha ).pipe(Effect.provide(layer)), ); -it.live("a file picker is not offered after its document changes while reading accept", () => +it.effect("a file picker is not offered after its document changes while reading accept", () => Effect.scoped( Effect.gen(function* () { const { broker, browser, tabId } = yield* ready; @@ -2469,7 +2477,7 @@ it.live("a file picker is not offered after its document changes while reading a ).pipe(Effect.provide(layer)), ); -it.live("only the current controller can answer a file picker", () => +it.effect("only the current controller can answer a file picker", () => Effect.scoped( Effect.gen(function* () { const { browser, tabId } = yield* ready; @@ -2768,7 +2776,7 @@ it.effect("a stalled desktop viewer still gets its size, control and reconnect", ).pipe(Effect.provide(layer)), ); -it.live("the tab the desktop shows is visible and picked for agents without a tab", () => +it.effect("the tab the desktop shows is visible and picked for agents without a tab", () => Effect.scoped( Effect.gen(function* () { const broker = yield* Broker.PreviewAutomationBroker; @@ -2809,85 +2817,87 @@ it.live("the tab the desktop shows is visible and picked for agents without a ta ).pipe(Effect.provide(layer)), ); -it.live("agents read a human's tab with no arguments and act on it only while nobody drives", () => - Effect.scoped( - Effect.gen(function* () { - const browser = yield* ServerBrowser.ServerBrowser; - const broker = yield* Broker.PreviewAutomationBroker; - const manager = yield* Manager.PreviewManager; - yield* Effect.yieldNow; - const opened = yield* manager.open({ - threadId: scope.thread.threadId, - url: "http://localhost:5173/mine", - runtime: "server", - }); - const tabId = PreviewTabId.make(opened.tabId); - const viewer = yield* browser.attachViewer(viewerInput(tabId, true)); - const status = yield* broker.invoke({ - scope, - operation: "status", - input: {}, - }); - expect(status).toMatchObject({ - tabId, - control: { owner: "human", ownedByCaller: false }, - }); - expect(status.tabs).toEqual([ - expect.objectContaining({ tabId, owner: "human", ownedByCaller: false, visible: true }), - ]); - const evaluated = yield* broker - .invoke({ scope, operation: "evaluate", input: { expression: "read()" } }) - .pipe(Effect.flip); - expect(evaluated).toMatchObject({ - _tag: "PreviewAutomationControlInterruptedError", - reason: "humanControl", - }); - yield* broker.invoke({ - scope, - tabId, - operation: "snapshot", - input: {}, - }); - const refused = yield* broker - .invoke({ +it.effect( + "agents read a human's tab with no arguments and act on it only while nobody drives", + () => + Effect.scoped( + Effect.gen(function* () { + const browser = yield* ServerBrowser.ServerBrowser; + const broker = yield* Broker.PreviewAutomationBroker; + const manager = yield* Manager.PreviewManager; + yield* Effect.yieldNow; + const opened = yield* manager.open({ + threadId: scope.thread.threadId, + url: "http://localhost:5173/mine", + runtime: "server", + }); + const tabId = PreviewTabId.make(opened.tabId); + const viewer = yield* browser.attachViewer(viewerInput(tabId, true)); + const status = yield* broker.invoke({ + scope, + operation: "status", + input: {}, + }); + expect(status).toMatchObject({ + tabId, + control: { owner: "human", ownedByCaller: false }, + }); + expect(status.tabs).toEqual([ + expect.objectContaining({ tabId, owner: "human", ownedByCaller: false, visible: true }), + ]); + const evaluated = yield* broker + .invoke({ scope, operation: "evaluate", input: { expression: "read()" } }) + .pipe(Effect.flip); + expect(evaluated).toMatchObject({ + _tag: "PreviewAutomationControlInterruptedError", + reason: "humanControl", + }); + yield* broker.invoke({ + scope, + tabId, + operation: "snapshot", + input: {}, + }); + const refused = yield* broker + .invoke({ + scope, + tabId, + operation: "navigate", + input: { url: "http://localhost:5173/agent" }, + }) + .pipe(Effect.flip); + expect(refused).toMatchObject({ + _tag: "PreviewAutomationControlInterruptedError", + reason: "humanControl", + }); + yield* viewer.input({ type: "releaseControl" }); + const released = yield* broker.invoke({ + scope, + tabId, + operation: "status", + input: {}, + }); + expect(released.control).toMatchObject({ owner: "unclaimed", ownedByCaller: false }); + yield* broker.invoke({ scope, tabId, operation: "navigate", input: { url: "http://localhost:5173/agent" }, - }) - .pipe(Effect.flip); - expect(refused).toMatchObject({ - _tag: "PreviewAutomationControlInterruptedError", - reason: "humanControl", - }); - yield* viewer.input({ type: "releaseControl" }); - const released = yield* broker.invoke({ - scope, - tabId, - operation: "status", - input: {}, - }); - expect(released.control).toMatchObject({ owner: "unclaimed", ownedByCaller: false }); - yield* broker.invoke({ - scope, - tabId, - operation: "navigate", - input: { url: "http://localhost:5173/agent" }, - }); - expect(contexts[0]!.page.goto).toHaveBeenCalledWith( - "http://localhost:5173/agent", - expect.anything(), - ); - yield* viewer.input({ type: "takeControl" }); - const retaken = yield* broker - .invoke({ scope, tabId, operation: "press", input: { key: "Enter" } }) - .pipe(Effect.flip); - expect(retaken).toMatchObject({ reason: "humanControl" }); - }), - ).pipe(Effect.provide(layer)), + }); + expect(contexts[0]!.page.goto).toHaveBeenCalledWith( + "http://localhost:5173/agent", + expect.anything(), + ); + yield* viewer.input({ type: "takeControl" }); + const retaken = yield* broker + .invoke({ scope, tabId, operation: "press", input: { key: "Enter" } }) + .pipe(Effect.flip); + expect(retaken).toMatchObject({ reason: "humanControl" }); + }), + ).pipe(Effect.provide(layer)), ); -it.live("preview_open reports a tab it shows as visible once the client attaches", () => +it.effect("preview_open reports a tab it shows as visible once the client attaches", () => Effect.scoped( Effect.gen(function* () { const browser = yield* ServerBrowser.ServerBrowser; @@ -2914,7 +2924,7 @@ it.live("preview_open reports a tab it shows as visible once the client attaches ).pipe(Effect.provide(layer)), ); -it.live("a session's own tab stays its default while other sessions' tabs are listed", () => +it.effect("a session's own tab stays its default while other sessions' tabs are listed", () => Effect.scoped( Effect.gen(function* () { const { broker, tabId } = yield* ready; @@ -2939,7 +2949,7 @@ it.live("a session's own tab stays its default while other sessions' tabs are li ).pipe(Effect.provide(layer)), ); -it.live( +it.effect( "preview_open picks a reported profile by id or name and defaults to the reported one", () => Effect.scoped( @@ -3023,83 +3033,86 @@ const gateSaveCleanup = Effect.gen(function* () { }; }); -it.live("an agent opens a reported profile after a restart, before any client reports again", () => - Effect.gen(function* () { - const gate = yield* gateSaveCleanup; - const persistent = (name: string) => - ({ id: `profile-${name.toLowerCase()}`, name, kind: "persistent" }) as const; - // Together the two clients report more profiles than one report may hold. - const fromA = [ - persistent("Work"), - ...Array.from({ length: 12 }, (_, n) => persistent(`A${n}`)), - ]; - const fromB = [ - persistent("Shared"), - ...Array.from({ length: 12 }, (_, n) => persistent(`B${n}`)), - ]; - yield* Effect.gen(function* () { - const browser = yield* ServerBrowser.ServerBrowser; - const clientA = yield* Scope.make(); - const reportFromA = yield* browser.profileReporter.pipe( - Effect.provideService(Scope.Scope, clientA), - ); - const reportFromB = yield* browser.profileReporter; - const reportFromC = yield* browser.profileReporter; - yield* reportFromA({ profiles: fromA, defaultProfileId: "profile-work" }); - gate.pauseNext(); - const savingB = yield* reportFromB({ - profiles: fromB, - defaultProfileId: "profile-shared", - }).pipe(Effect.forkScoped({ startImmediately: true })); - // A save that ends without reaching the gate fails the test instead of hanging it. - yield* Effect.raceFirst( - Deferred.await(gate.cleanupStarted), - Fiber.join(savingB).pipe( - Effect.andThen(Effect.die("save finished before the cleanup gate")), +it.effect( + "an agent opens a reported profile after a restart, before any client reports again", + () => + Effect.gen(function* () { + const gate = yield* gateSaveCleanup; + const persistent = (name: string) => + ({ id: `profile-${name.toLowerCase()}`, name, kind: "persistent" }) as const; + // Together the two clients report more profiles than one report may hold. + const fromA = [ + persistent("Work"), + ...Array.from({ length: 12 }, (_, n) => persistent(`A${n}`)), + ]; + const fromB = [ + persistent("Shared"), + ...Array.from({ length: 12 }, (_, n) => persistent(`B${n}`)), + ]; + yield* Effect.gen(function* () { + const browser = yield* ServerBrowser.ServerBrowser; + const clientA = yield* Scope.make(); + const reportFromA = yield* browser.profileReporter.pipe( + Effect.provideService(Scope.Scope, clientA), + ); + const reportFromB = yield* browser.profileReporter; + const reportFromC = yield* browser.profileReporter; + yield* reportFromA({ profiles: fromA, defaultProfileId: "profile-work" }); + gate.pauseNext(); + const savingB = yield* reportFromB({ + profiles: fromB, + defaultProfileId: "profile-shared", + }).pipe(Effect.forkScoped({ startImmediately: true })); + // A save that ends without reaching the gate fails the test instead of hanging it. + yield* Effect.raceFirst( + Deferred.await(gate.cleanupStarted), + Fiber.join(savingB).pipe( + Effect.andThen(Effect.die("save finished before the cleanup gate")), + ), + ); + // C's save waits behind B's while the client that reported Work disconnects, as at shutdown. + const savingC = yield* reportFromC({ + profiles: [], + defaultProfileId: "profile-shared", + }).pipe(Effect.forkScoped({ startImmediately: true })); + yield* Scope.close(clientA, Exit.void); + yield* Deferred.succeed(gate.releaseCleanup, undefined); + yield* Fiber.join(savingB); + yield* Fiber.join(savingC); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.fresh(ServerBrowser.layer).pipe( + Layer.provide(Layer.succeed(FileSystem.FileSystem, gate.fs)), + ), ), ); - // C's save waits behind B's while the client that reported Work disconnects, as at shutdown. - const savingC = yield* reportFromC({ profiles: [], defaultProfileId: "profile-shared" }).pipe( - Effect.forkScoped({ startImmediately: true }), - ); - yield* Scope.close(clientA, Exit.void); - yield* Deferred.succeed(gate.releaseCleanup, undefined); - yield* Fiber.join(savingB); - yield* Fiber.join(savingC); - }).pipe( - Effect.scoped, - Effect.provide( - Layer.fresh(ServerBrowser.layer).pipe( - Layer.provide(Layer.succeed(FileSystem.FileSystem, gate.fs)), - ), - ), - ); - const restarted = yield* Effect.gen(function* () { - const broker = yield* Broker.PreviewAutomationBroker; - yield* ServerBrowser.ServerBrowser; - yield* Effect.yieldNow; - return yield* broker.invoke({ - scope, - operation: "open", - input: { reuseExistingTab: false, show: false, profileId: "Work" }, - }); - }).pipe(Effect.provide(Layer.fresh(ServerBrowser.layer))); + const restarted = yield* Effect.gen(function* () { + const broker = yield* Broker.PreviewAutomationBroker; + yield* ServerBrowser.ServerBrowser; + yield* Effect.yieldNow; + return yield* broker.invoke({ + scope, + operation: "open", + input: { reuseExistingTab: false, show: false, profileId: "Work" }, + }); + }).pipe(Effect.provide(Layer.fresh(ServerBrowser.layer))); - expect(contextRequests).toEqual([{ profileId: "profile-work", isolated: false }]); - expect(restarted.tabs).toMatchObject([{ profileId: "profile-work" }]); - expect(restarted).toMatchObject({ - defaultProfileId: "profile-shared", - profiles: [ - { id: "default", name: "Default" }, - { id: "incognito", name: "Incognito", incognito: true }, - ...[...fromA, ...fromB].map(({ id, name }) => ({ id, name })), - ], - }); - }).pipe(Effect.provide(dependencies)), + expect(contextRequests).toEqual([{ profileId: "profile-work", isolated: false }]); + expect(restarted.tabs).toMatchObject([{ profileId: "profile-work" }]); + expect(restarted).toMatchObject({ + defaultProfileId: "profile-shared", + profiles: [ + { id: "default", name: "Default" }, + { id: "incognito", name: "Incognito", incognito: true }, + ...[...fromA, ...fromB].map(({ id, name }) => ({ id, name })), + ], + }); + }).pipe(Effect.provide(dependencies)), ); -it.live("warns at startup about a saved profile catalog it cannot read or decode", () => +it.effect("warns at startup about a saved profile catalog it cannot read or decode", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -3137,7 +3150,7 @@ it.live("warns at startup about a saved profile catalog it cannot read or decode }).pipe(Effect.provide(dependencies)), ); -it.live("a report interrupted while an earlier save holds the lock is still saved", () => +it.effect("a report interrupted while an earlier save holds the lock is still saved", () => Effect.gen(function* () { const gate = yield* gateSaveCleanup; const work = { id: "profile-work", name: "Work", kind: "persistent" } as const; @@ -3190,7 +3203,7 @@ it.live("a report interrupted while an earlier save holds the lock is still save }).pipe(Effect.provide(dependencies)), ); -it.live("profiles reported by one client survive another client's report", () => +it.effect("profiles reported by one client survive another client's report", () => Effect.scoped( Effect.gen(function* () { const { browser, broker } = yield* ready; @@ -3233,7 +3246,7 @@ it.live("profiles reported by one client survive another client's report", () => ).pipe(Effect.provide(layer)), ); -it.live.each([ +it.effect.each([ { budget: "agent", limit: 8 }, { budget: "server", limit: 32 }, ])("opens and popups reserve the $budget budget before session creation", ({ budget, limit }) => @@ -3341,7 +3354,7 @@ it.live.each([ ).pipe(Effect.provide(layer)), ); -it.live("failed session and browser launches release capacity, as does closing a tab", () => +it.effect("failed session and browser launches release capacity, as does closing a tab", () => Effect.scoped( Effect.gen(function* () { const { broker, tabId } = yield* ready; @@ -3401,7 +3414,7 @@ it.live("failed session and browser launches release capacity, as does closing a ).pipe(Effect.provide(layer)), ); -it.live("a full server takes back the slot of the longest idle agent tab nobody views", () => +it.effect("a full server takes back the slot of the longest idle agent tab nobody views", () => Effect.scoped( Effect.gen(function* () { const broker = yield* Broker.PreviewAutomationBroker; @@ -3477,7 +3490,7 @@ const certificateError = { }; const dataUrl = `data:text/html,${"PAYLOAD".repeat(300)}`; -it.live("reports why a page failed to load while the tab shows the error page", () => +it.effect("reports why a page failed to load while the tab shows the error page", () => Effect.scoped( Effect.gen(function* () { const { broker, tabId } = yield* ready; @@ -3584,7 +3597,7 @@ it.effect("preview_open rejects a desktop navigation that failed before automati ).pipe(Effect.provide(layer)), ); -it.live("reports a desktop tab's load failure from before the server connected", () => +it.effect("reports a desktop tab's load failure from before the server connected", () => Effect.scoped( Effect.gen(function* () { const browser = yield* ServerBrowser.ServerBrowser; diff --git a/apps/server/src/provider/ModelManifest.test.ts b/apps/server/src/provider/ModelManifest.test.ts index e94ce85bf533c..0b4c4c062dca5 100644 --- a/apps/server/src/provider/ModelManifest.test.ts +++ b/apps/server/src/provider/ModelManifest.test.ts @@ -266,7 +266,7 @@ const layerService = (input: { ); describe("ModelManifest service", () => { - it.live("prefers a fetched manifest over the bundle and caches it to disk", () => + it.effect("prefers a fetched manifest over the bundle and caches it to disk", () => Effect.gen(function* () { const service = yield* ModelManifest.make; const refreshed = yield* service.refresh; @@ -285,29 +285,32 @@ describe("ModelManifest service", () => { ), ); - it.live("ignores older remote edits without replacing the current manifest or disk cache", () => { - let remote = { ...REMOTE_MANIFEST, updatedAt: "2000-01-01T00:00:00Z" }; - return Effect.gen(function* () { - const service = yield* ModelManifest.make; - assert.deepStrictEqual(yield* service.refresh, ModelManifest.BUNDLED_MODEL_MANIFEST); - assert.deepStrictEqual(yield* service.current, ModelManifest.BUNDLED_MODEL_MANIFEST); + it.effect( + "ignores older remote edits without replacing the current manifest or disk cache", + () => { + let remote = { ...REMOTE_MANIFEST, updatedAt: "2000-01-01T00:00:00Z" }; + return Effect.gen(function* () { + const service = yield* ModelManifest.make; + assert.deepStrictEqual(yield* service.refresh, ModelManifest.BUNDLED_MODEL_MANIFEST); + assert.deepStrictEqual(yield* service.current, ModelManifest.BUNDLED_MODEL_MANIFEST); - remote = { ...REMOTE_MANIFEST, updatedAt: REMOTE_UPDATED_AT }; - assert.deepStrictEqual(yield* service.forceRefresh, REMOTE_MANIFEST); - remote = { ...REMOTE_MANIFEST, updatedAt: ModelManifest.BUNDLED_MODEL_MANIFEST.updatedAt! }; - assert.deepStrictEqual(yield* service.forceRefresh, REMOTE_MANIFEST); - const rebooted = yield* ModelManifest.make; - assert.deepStrictEqual(yield* rebooted.current, REMOTE_MANIFEST); - }).pipe( - Effect.scoped, - Effect.provide( - layerService({ - prefix: "model-manifest-stale-fetch-test", - response: () => Response.json(remote), - }), - ), - ); - }); + remote = { ...REMOTE_MANIFEST, updatedAt: REMOTE_UPDATED_AT }; + assert.deepStrictEqual(yield* service.forceRefresh, REMOTE_MANIFEST); + remote = { ...REMOTE_MANIFEST, updatedAt: ModelManifest.BUNDLED_MODEL_MANIFEST.updatedAt! }; + assert.deepStrictEqual(yield* service.forceRefresh, REMOTE_MANIFEST); + const rebooted = yield* ModelManifest.make; + assert.deepStrictEqual(yield* rebooted.current, REMOTE_MANIFEST); + }).pipe( + Effect.scoped, + Effect.provide( + layerService({ + prefix: "model-manifest-stale-fetch-test", + response: () => Response.json(remote), + }), + ), + ); + }, + ); it.effect("preserves the last-good remote cache when later payloads are invalid", () => { let responseIndex = 0; @@ -364,7 +367,7 @@ describe("ModelManifest service", () => { ); }); - it.live("drops a disk cache of a manifest older than the bundled one", () => + it.effect("drops a disk cache of a manifest older than the bundled one", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -400,7 +403,7 @@ describe("ModelManifest service", () => { ), ); - it.live("does not fetch when provider update checks are disabled", () => + it.effect("does not fetch when provider update checks are disabled", () => Effect.gen(function* () { let fetchCount = 0; const service = yield* ModelManifest.make.pipe( diff --git a/apps/server/src/provider/ProviderInstanceRegistry.test.ts b/apps/server/src/provider/ProviderInstanceRegistry.test.ts index ab35fe4d9ae59..68ccbe1b3bc83 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.test.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.test.ts @@ -346,7 +346,7 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { }).pipe(Effect.provide(layerTest)), ); - it.live("treats an explicit in-config enabled:false as disabling despite the envelope", () => + it.effect("treats an explicit in-config enabled:false as disabling despite the envelope", () => Effect.gen(function* () { const staleId = ProviderInstanceId.make("codex_stale"); const configMap: ProviderInstanceConfigMap = { @@ -370,7 +370,7 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { }).pipe(Effect.provide(layerTest)), ); - it.live("lists a blocked driver's instances as unavailable without running them", () => + it.effect("lists a blocked driver's instances as unavailable without running them", () => Effect.gen(function* () { const instanceId = ProviderInstanceId.make("codex"); const reason = "Example Corp's policy does not allow the Codex provider."; @@ -862,7 +862,7 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { ); } - it.live( + it.effect( "shadows instances whose driver is not registered in this build without failing boot", () => Effect.gen(function* () { diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index c3ae4fd2f6044..47220ba30fb5a 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -1008,7 +1008,7 @@ describe("subagent worktree ownership", () => { }).pipe(Effect.provide(cleanupTestLayer), Effect.scoped), ); - it.live.each([ + it.effect.each([ "parent-unsettled", "parent-resettled", "child-unsettled", @@ -1017,6 +1017,7 @@ describe("subagent worktree ownership", () => { ] as const)("cancels inherited settlement during measurement (%s)", (change) => runCleanupTest( Effect.gen(function* () { + yield* TestClock.setTime(NOW_MS); const fixture = yield* pausedMeasurementFixture({ ...DEFAULT_SERVER_SETTINGS.storageCleanup, worktreeSettledAfterDays: 0, @@ -1258,7 +1259,7 @@ describe("subagent worktree ownership", () => { }).pipe(Effect.provide(cleanupTestLayer), Effect.scoped), ); - it.live.each([ + it.effect.each([ "running", "pending", "activity", @@ -1270,6 +1271,7 @@ describe("subagent worktree ownership", () => { ] as const)("cancels removal when a child changes during measurement (%s)", (change) => runCleanupTest( Effect.gen(function* () { + yield* TestClock.setTime(NOW_MS); const fixture = yield* pausedMeasurementFixture(); const root = shell({ branch: "feature", worktreePath: fixture.worktree }); const child = { @@ -2502,12 +2504,13 @@ describe("storage cleanup reports and local file policies", () => { }), ), ); - it.live.each([ + it.effect.each([ { days: 8, reason: "Inactive for 1 of 8 days" }, { days: null, reason: "No rules apply" }, ])("keeps an ineligible age/delete-only worktree without Git ($days)", ({ days, reason }) => runCleanupTest( Effect.gen(function* () { + yield* TestClock.setTime(NOW_MS); const fixture = yield* cleanupFixture({ ...DEFAULT_SERVER_SETTINGS.storageCleanup, worktreeAfterDays: days, @@ -2517,9 +2520,7 @@ describe("storage cleanup reports and local file policies", () => { shell({ branch: "feature", worktreePath: fixture.worktree, - latestUserMessageAt: DateTime.makeUnsafe( - DateTime.toEpochMillis(yield* DateTime.now) - DAY_MS, - ), + latestUserMessageAt: at(-DAY_MS), }), ]); yield* fixture.fs.writeFileString(`${fixture.worktree}/tracked.txt`, "local edit\n"); @@ -2585,7 +2586,7 @@ describe("storage cleanup reports and local file policies", () => { }), ), ); - it.live.each([ + it.effect.each([ [ "its owner's branch is cleared", (worktree: string) => [shell({ branch: null, worktreePath: worktree, updatedAt: at(0) })], @@ -2622,6 +2623,7 @@ describe("storage cleanup reports and local file policies", () => { ] as const)("keeps a worktree when %s during size measurement", ([, threadsWhileMeasuring]) => runCleanupTest( Effect.gen(function* () { + yield* TestClock.setTime(NOW_MS); const fixture = yield* pausedMeasurementFixture(); const { fs, worktree } = fixture; fixture.setPolicy("tracked-changes"); @@ -2769,7 +2771,7 @@ describe("storage cleanup reports and local file policies", () => { }), ), ); - it.live.each([ + it.effect.each([ ["unchanged", "removed", "No commits beyond the project branch"], ["unmerged", "kept", "Has commits beyond the project branch"], ["detached", "kept", "Project branch is unavailable"], @@ -2777,6 +2779,7 @@ describe("storage cleanup reports and local file policies", () => { ] as const)("cleans archived local-only worktrees: %s", ([state, outcome, reason]) => runCleanupTest( Effect.gen(function* () { + yield* TestClock.setTime(NOW_MS); const { service, fs, repo, worktree, command, setThreads } = yield* cleanupFixture({ ...DEFAULT_SERVER_SETTINGS.storageCleanup, worktreeUnchanged: state !== "merged-only", @@ -3210,11 +3213,12 @@ describe("storage cleanup reports and local file policies", () => { }); describe("settled worktree rechecks", () => { - it.live.each(["autoResumeLimitedThreads", "snoozeLimitedThreads"] as const)( + it.effect.each(["autoResumeLimitedThreads", "snoozeLimitedThreads"] as const)( "keeps a usage-limited checkout when %s is enabled during measurement", (preference) => runCleanupTest( Effect.gen(function* () { + yield* TestClock.setTime(NOW_MS); const fixture = yield* pausedMeasurementFixture(); fixture.setSettings({ autoResumeLimitedThreads: false, snoozeLimitedThreads: false }); fixture.setThreads([ @@ -3232,16 +3236,20 @@ describe("settled worktree rechecks", () => { fixture.setSettings({ [preference]: true }); yield* Deferred.succeed(fixture.resume, undefined); const report = yield* Fiber.join(run); - expect(report.entries[0]?.outcome).toBe("kept"); + expect(report.entries[0]).toMatchObject({ + outcome: "kept", + reason: "Settings changed since check", + }); expect(yield* fixture.fs.exists(fixture.worktree)).toBe(true); }), ), ); - it.live.each([{ settledAt: null }, { settledAt: at(DAY_MS) }])( + it.effect.each([{ settledAt: null }, { settledAt: at(DAY_MS) }])( "keeps a checkout changed during measurement (%j)", (change) => runCleanupTest( Effect.gen(function* () { + yield* TestClock.setTime(NOW_MS); const fixture = yield* pausedMeasurementFixture(); const thread = shell({ branch: "feature", @@ -3269,9 +3277,10 @@ describe("settled worktree rechecks", () => { ); }); -it.live("keeps saved usage data when removing a settled checkout", () => +it.effect("keeps saved usage data when removing a settled checkout", () => runCleanupTest( Effect.gen(function* () { + yield* TestClock.setTime(NOW_MS); const fixture = yield* cleanupFixture(); fixture.setSettings({ storageCleanup: { ...DEFAULT_SERVER_SETTINGS.storageCleanup, worktreeSettledAfterDays: 0 }, diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index f030af29ea13f..4f1132bde4c61 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -521,7 +521,7 @@ describe("Codex usage history", () => { }); describe("UsageService", () => { - it.live("counts compressed Codex rollouts across refresh, rewrites, and cache restarts", () => + it.effect("counts compressed Codex rollouts across refresh, rewrites, and cache restarts", () => Effect.gen(function* () { const { home, settings } = yield* setup; const sessions = NodePath.join(home, "codex", "sessions"); @@ -634,7 +634,7 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); - it.live("keeps the first host identity across recreation and explicit override removal", () => + it.effect("keeps the first host identity across recreation and explicit override removal", () => Effect.gen(function* () { const { transcript, settings, home } = yield* setup; yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5))); @@ -665,7 +665,7 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); - it.live("concurrent initialization keeps one host identity across service recreation", () => + it.effect("concurrent initialization keeps one host identity across service recreation", () => Effect.gen(function* () { const { transcript, settings, home } = yield* setup; yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5))); @@ -721,7 +721,7 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); - it.live( + it.effect( "repairs initial Codex prices after unchanged-file retries, persists evidence and corrections, and honors later native tiers", () => Effect.gen(function* () { @@ -873,7 +873,7 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); - it.live( + it.effect( "keeps resolved Codex tiers when a grown pre-v15 cache entry re-parses without history", () => Effect.gen(function* () { @@ -969,7 +969,7 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); - it.live( + it.effect( "reads Pi per-model usage and auxiliary costs once across forks and repeated entries", () => Effect.gen(function* () { @@ -1158,7 +1158,7 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); - it.live( + it.effect( "reads Muse inference history once, persists appended usage, and reprices unknown models", () => Effect.gen(function* () { @@ -1380,7 +1380,7 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); - it.live("returns other providers' usage while Muse home discovery is still pending", () => + it.effect("returns other providers' usage while Muse home discovery is still pending", () => Effect.gen(function* () { const { transcript, settings, home } = yield* setup; yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5))); @@ -1440,70 +1440,72 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); - it.live("reports missing Pi history and skips malformed records without losing valid usage", () => - Effect.gen(function* () { - const { home, settings } = yield* setup; - yield* Effect.gen(function* () { - const service = yield* UsageService.make; - const missing = yield* service.readSummary(WINDOW); - assert.strictEqual( - missing.sources.find((source) => source.fingerprint.provider === "pi")?.status, - "missing", - ); - const dir = NodePath.join(home, "pi", "sessions"); - yield* Effect.promise(async () => { - await NodeFSP.mkdir(dir, { recursive: true }); - await NodeFSP.writeFile( - NodePath.join(dir, "session.jsonl"), - [ - "{broken", - "null", - encodeUnknownJsonString({ type: "session", id: "valid" }), - encodeUnknownJsonString({ - type: "message", - id: "bad", - timestamp: "invalid", - message: { - role: "assistant", - provider: "openai", - model: "model", - usage: { input: 100 }, - }, - }), - encodeUnknownJsonString({ - type: "message", - id: "valid", - timestamp: "2026-08-01T10:00:00Z", - message: { - role: "assistant", - provider: "openai", - model: "model", - usage: { - input: -1, - cacheRead: "100", - output: 7, - reasoning: 50, - cost: { total: -1 }, - }, - }, - }), - ].join("\n") + "\n", + it.effect( + "reports missing Pi history and skips malformed records without losing valid usage", + () => + Effect.gen(function* () { + const { home, settings } = yield* setup; + yield* Effect.gen(function* () { + const service = yield* UsageService.make; + const missing = yield* service.readSummary(WINDOW); + assert.strictEqual( + missing.sources.find((source) => source.fingerprint.provider === "pi")?.status, + "missing", ); - }); - const summary = yield* service.readSummary(WINDOW); - const pi = summary.buckets.filter((bucket) => bucket.provider === "pi"); - assert.strictEqual(pi.length, 1); - assert.strictEqual(pi[0] && totalTokens(pi[0].totals), 7); - assert.strictEqual(pi[0]?.totals.reasoningTokens, 7); - assert.strictEqual(pi[0]?.costSource, "unpriced"); - }).pipe( - Effect.scoped, - Effect.provide(layerService({ prefix: "usage-pi-malformed", home, settings })), - ); - }).pipe(Effect.scoped), + const dir = NodePath.join(home, "pi", "sessions"); + yield* Effect.promise(async () => { + await NodeFSP.mkdir(dir, { recursive: true }); + await NodeFSP.writeFile( + NodePath.join(dir, "session.jsonl"), + [ + "{broken", + "null", + encodeUnknownJsonString({ type: "session", id: "valid" }), + encodeUnknownJsonString({ + type: "message", + id: "bad", + timestamp: "invalid", + message: { + role: "assistant", + provider: "openai", + model: "model", + usage: { input: 100 }, + }, + }), + encodeUnknownJsonString({ + type: "message", + id: "valid", + timestamp: "2026-08-01T10:00:00Z", + message: { + role: "assistant", + provider: "openai", + model: "model", + usage: { + input: -1, + cacheRead: "100", + output: 7, + reasoning: 50, + cost: { total: -1 }, + }, + }, + }), + ].join("\n") + "\n", + ); + }); + const summary = yield* service.readSummary(WINDOW); + const pi = summary.buckets.filter((bucket) => bucket.provider === "pi"); + assert.strictEqual(pi.length, 1); + assert.strictEqual(pi[0] && totalTokens(pi[0].totals), 7); + assert.strictEqual(pi[0]?.totals.reasoningTokens, 7); + assert.strictEqual(pi[0]?.costSource, "unpriced"); + }).pipe( + Effect.scoped, + Effect.provide(layerService({ prefix: "usage-pi-malformed", home, settings })), + ); + }).pipe(Effect.scoped), ); - it.live("discovers Pi history in its default home and directory overrides", () => + it.effect("discovers Pi history in its default home and directory overrides", () => Effect.gen(function* () { const { home, settings } = yield* setup; for (const [agentDir, sessionDir, relativeDir, launchArgs, instanceHome, platform] of [ @@ -1610,7 +1612,7 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); - it.live("reads shared managed default and disabled extra account history once", () => + it.effect("reads shared managed default and disabled extra account history once", () => Effect.gen(function* () { const { home, settings } = yield* setup; const summary = yield* Effect.gen(function* () { @@ -1690,7 +1692,7 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); - it.live("restores the Cursor account cache after a restart", () => + it.effect("restores the Cursor account cache after a restart", () => Effect.gen(function* () { const { settings, home } = yield* setup; yield* TestClock.setTime(CURSOR_NOW); @@ -1729,10 +1731,10 @@ describe("UsageService", () => { }), ), ); - }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), + }).pipe(Effect.scoped), ); - it.live("reads the Cursor account of an API key or T3 sign-in instead of a CLI login", () => + it.effect("reads the Cursor account of an API key or T3 sign-in instead of a CLI login", () => Effect.gen(function* () { const { settings, home } = yield* setup; const signIn = new TextEncoder().encode( @@ -1784,7 +1786,7 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); - it.live("does not show a previous Cursor account's history after the API key changes", () => + it.effect("does not show a previous Cursor account's history after the API key changes", () => Effect.gen(function* () { const { settings, home } = yield* setup; yield* TestClock.setTime(CURSOR_NOW); @@ -1823,10 +1825,10 @@ describe("UsageService", () => { }), ), ); - }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), + }).pipe(Effect.scoped), ); - it.live("keeps a new Cursor key's refresh visible beside a failing key", () => + it.effect("keeps a new Cursor key's refresh visible beside a failing key", () => Effect.gen(function* () { const { settings, home } = yield* setup; const cursor = makeFakeCursor(); @@ -1911,7 +1913,7 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); - it.live("reports one Cursor source when two API keys belong to the same account", () => + it.effect("reports one Cursor source when two API keys belong to the same account", () => Effect.gen(function* () { const { settings, home } = yield* setup; yield* TestClock.setTime(CURSOR_NOW); @@ -1970,10 +1972,10 @@ describe("UsageService", () => { }), ), ); - }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), + }).pipe(Effect.scoped), ); - it.live( + it.effect( "includes OpenCode history but does not substitute desktop usage for an unavailable Cursor account", () => Effect.gen(function* () { @@ -2030,7 +2032,7 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); - it.live("counts unpriced tokens from the unpriced records of a mixed model", () => + it.effect("counts unpriced tokens from the unpriced records of a mixed model", () => Effect.gen(function* () { const { settings, home } = yield* setup; const message = (id: string, cost: number, input: number, output: number, minute: number) => @@ -2067,7 +2069,7 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); - it.live("includes host and merged OpenCode instance histories once across list aliases", () => + it.effect("includes host and merged OpenCode instance histories once across list aliases", () => Effect.gen(function* () { const { settings, home } = yield* setup; const hostRoot = NodePath.join(home, "opencode"); @@ -2146,7 +2148,7 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); - it.live("counts aliased OpenCode and Antigravity directories once", () => + it.effect("counts aliased OpenCode and Antigravity directories once", () => Effect.gen(function* () { const { settings, home } = yield* setup; const opencode = NodePath.join(home, "opencode-store"); @@ -2200,7 +2202,7 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); - it.live("reads Antigravity history from each instance's data directory", () => + it.effect("reads Antigravity history from each instance's data directory", () => Effect.gen(function* () { const { settings, home } = yield* setup; const first = NodePath.join(home, "antigravity-first"); @@ -2271,7 +2273,7 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); - it.live("reads configured and disabled accounts once across shared and aliased homes", () => + it.effect("reads configured and disabled accounts once across shared and aliased homes", () => Effect.gen(function* () { const { transcript, settings, home } = yield* setup; const codexHome = NodePath.join(home, "codex-account"); @@ -2390,7 +2392,7 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); - it.live( + it.effect( "uses explicit account settings before environment and default homes, then refreshes them", () => Effect.gen(function* () { @@ -2473,7 +2475,7 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); - it.live( + it.effect( "uses inherited home variables when explicit default accounts have no home settings", () => Effect.gen(function* () { @@ -2519,7 +2521,7 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); - it.live.skipIf(HostProcess.Platform.defaultValue() === "win32" || process.getuid?.() === 0)( + it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32" || process.getuid?.() === 0)( "reports unreadable transcripts as partial and recovers once they can be read", () => Effect.gen(function* () { @@ -2547,7 +2549,7 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); - it.live( + it.effect( "answers a provisional read from the last scan, then the exact read adds what changed", () => Effect.gen(function* () { @@ -2616,7 +2618,7 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); - it.live("filters original model IDs before aliases, pricing, buckets and session counts", () => + it.effect("filters original model IDs before aliases, pricing, buckets and session counts", () => Effect.gen(function* () { const { transcript, settings, home } = yield* setup; yield* Effect.promise(() => @@ -2721,7 +2723,7 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); - it.live("reprices unchanged transcripts when custom prices are added, edited, or removed", () => + it.effect("reprices unchanged transcripts when custom prices are added, edited, or removed", () => Effect.gen(function* () { const { transcript, settings, home } = yield* setup; yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5, "example-model"))); @@ -2765,7 +2767,7 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); - it.live( + it.effect( "retains Codex reconciliation and partial coverage through persisted restart, append and repair", () => Effect.gen(function* () { @@ -2888,7 +2890,7 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); - it.live( + it.effect( "merges partial Claude copies through tail replacement, newline completion and restart", () => Effect.gen(function* () { @@ -2947,7 +2949,7 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); - it.live( + it.effect( "keeps large-record totals and costs exact through append, dedupe, restart and cleanup", () => Effect.gen(function* () { @@ -3002,10 +3004,11 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); - it.live.each([4, 5, 6, 7, 8, 10, 11, 12, 13, 14])( + it.effect.each([4, 5, 6, 7, 8, 10, 11, 12, 13, 14])( "upgrades a v%s cache: reparses live Codex usage, keeps deleted rollouts, removes the old file", (version) => Effect.gen(function* () { + yield* TestClock.setTime(Date.parse("2026-08-02T12:00:00Z")); const { home, settings } = yield* setup; const sessions = NodePath.join(home, "codex", "sessions"); const rollout = (sessionId: string, outputTokens: number) => @@ -3132,7 +3135,7 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); - it.live("re-parses a clean v10 Codex rollout that grew after an interrupted request", () => + it.effect("re-parses a clean v10 Codex rollout that grew after an interrupted request", () => Effect.gen(function* () { const { home, settings } = yield* setup; const sessions = NodePath.join(home, "codex", "sessions"); @@ -3232,7 +3235,7 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); - it.live("preserves saved tokens, costs and sessions after transcript cleanup and restart", () => + it.effect("preserves saved tokens, costs and sessions after transcript cleanup and restart", () => Effect.gen(function* () { const { transcript, settings, home } = yield* setup; const alias = NodePath.join(home, "claude-alias"); @@ -3333,7 +3336,7 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); - it.live("credits the same copy of a duplicate after its transcripts are deleted", () => + it.effect("credits the same copy of a duplicate after its transcripts are deleted", () => Effect.gen(function* () { const { transcript, settings, home } = yield* setup; const dir = NodePath.dirname(transcript); @@ -3371,7 +3374,7 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); - it.live("credits the same copy of a duplicate from the last scan as an exact read does", () => + it.effect("credits the same copy of a duplicate from the last scan as an exact read does", () => Effect.gen(function* () { yield* TestClock.setTime(Date.parse("2026-08-15T00:00:00Z")); const { transcript, settings, home } = yield* setup; @@ -3426,10 +3429,10 @@ describe("UsageService", () => { }).pipe( Effect.provide(layerService({ prefix: "usage-service-narrow-order-test", home, settings })), ); - }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), + }).pipe(Effect.scoped), ); - it.live.skipIf(HostProcess.Platform.defaultValue() === "win32")( + it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( "keeps a newer cached read when a slower scan of another window finishes later", () => Effect.gen(function* () { @@ -3459,50 +3462,53 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); - it.live("keeps an absent directory missing when its cached files only contain diagnostics", () => - Effect.gen(function* () { - const { transcript, settings, home } = yield* setup; - yield* Effect.promise(() => - NodeFSP.rm(NodePath.join(home, "claude", "projects"), { recursive: true }), - ); - yield* Effect.gen(function* () { - const { stateDir } = yield* ServerConfig.ServerConfig; - const mtimeMs = yield* Clock.currentTimeMillis; - const cache = makeScanCacheWriter()( - new Map([ - [ - transcript, - { - size: 0, - mtimeMs, - provider: "claude" as const, - records: [], - tailRecords: [], - malformedRecords: 2, - position: { resumeOffset: 0, guardLength: 0, guardHash: 0, state: null }, - }, - ], - ]), - {}, + it.effect( + "keeps an absent directory missing when its cached files only contain diagnostics", + () => + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse("2026-08-02T12:00:00Z")); + const { transcript, settings, home } = yield* setup; + yield* Effect.promise(() => + NodeFSP.rm(NodePath.join(home, "claude", "projects"), { recursive: true }), ); - yield* Effect.promise(async () => { - await NodeFSP.mkdir(stateDir, { recursive: true }); - await NodeFSP.writeFile(NodePath.join(stateDir, SCAN_CACHE_FILE_NAME), cache); - }); - const summary = yield* (yield* UsageService.make).readSummary(WINDOW); - const source = summary.sources.find((source) => source.fingerprint.provider === "claude"); - assert.strictEqual(source?.status, "missing"); - assert.strictEqual(source?.scannedFiles, 0); - assert.strictEqual(source?.malformedRecords, 2); - }).pipe( - Effect.provide( - layerService({ prefix: "usage-service-missing-diagnostics", home, settings }), - ), - ); - }).pipe(Effect.scoped), + yield* Effect.gen(function* () { + const { stateDir } = yield* ServerConfig.ServerConfig; + const mtimeMs = yield* Clock.currentTimeMillis; + const cache = makeScanCacheWriter()( + new Map([ + [ + transcript, + { + size: 0, + mtimeMs, + provider: "claude" as const, + records: [], + tailRecords: [], + malformedRecords: 2, + position: { resumeOffset: 0, guardLength: 0, guardHash: 0, state: null }, + }, + ], + ]), + {}, + ); + yield* Effect.promise(async () => { + await NodeFSP.mkdir(stateDir, { recursive: true }); + await NodeFSP.writeFile(NodePath.join(stateDir, SCAN_CACHE_FILE_NAME), cache); + }); + const summary = yield* (yield* UsageService.make).readSummary(WINDOW); + const source = summary.sources.find((source) => source.fingerprint.provider === "claude"); + assert.strictEqual(source?.status, "missing"); + assert.strictEqual(source?.scannedFiles, 0); + assert.strictEqual(source?.malformedRecords, 2); + }).pipe( + Effect.provide( + layerService({ prefix: "usage-service-missing-diagnostics", home, settings }), + ), + ); + }).pipe(Effect.scoped), ); - it.live("reports saved usage of a removed directory only for windows it reaches", () => + it.effect("reports saved usage of a removed directory only for windows it reaches", () => Effect.gen(function* () { yield* TestClock.setTime(Date.parse("2026-08-12T00:00:00Z")); const { transcript, settings, home } = yield* setup; @@ -3533,10 +3539,10 @@ describe("UsageService", () => { assert.deepStrictEqual(later.buckets, []); assert.strictEqual(later.sources[0]?.status, "missing"); assert.strictEqual(later.sources[0]?.scannedFiles, 0); - }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), + }).pipe(Effect.scoped), ); - it.live("does not share an in-flight scan after custom prices change", () => + it.effect("does not share an in-flight scan after custom prices change", () => Effect.gen(function* () { const { transcript, settings, home } = yield* setup; yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5, "example-model"))); @@ -3605,7 +3611,7 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); - it.live.each(["network", "http", "invalidJson", "empty"] as const)( + it.effect.each(["network", "http", "invalidJson", "empty"] as const)( "recovers pricing through the GitHub API after a %s failure", (failure) => Effect.gen(function* () { @@ -3664,7 +3670,7 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); - it.live("does not orphan an in-flight scan when its first caller is interrupted", () => + it.effect("does not orphan an in-flight scan when its first caller is interrupted", () => Effect.gen(function* () { const { settings, home } = yield* setup; const service = yield* UsageService.make.pipe( diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index c36b935741e7d..f41caf9faa477 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -276,25 +276,23 @@ const ReplayTestLayer = OrchestrationEventStore.layer.pipe( Layer.provideMerge(SqlitePersistence.layerMemory), ); -it.live.each(["thread", "shell"] as const)( - "%s resume skips unknown types, synchronizes, and receives the live tail", - (scope) => - Effect.gen(function* () { - const store = yield* OrchestrationEventStore.OrchestrationEventStore; - const sql = yield* SqlClient.SqlClient; - const now = "2026-01-05T00:00:00.000Z"; - const threadId = ThreadId.make("thread:replay"); - const projectId = ProjectId.make("project:replay"); - const project = { - id: projectId, - title: "Replay", - workspaceRoot: "/tmp/replay", - defaultModelSelection: null, - scripts: [], - createdAt: now, - updatedAt: now, - } satisfies OrchestrationProjectShell; - const insert = (version: number, unknown = false) => sql<{ sequence: number }>` +const resumeSkipsUnknownTypes = (scope: "thread" | "shell") => + Effect.gen(function* () { + const store = yield* OrchestrationEventStore.OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const now = "2026-01-05T00:00:00.000Z"; + const threadId = ThreadId.make("thread:replay"); + const projectId = ProjectId.make("project:replay"); + const project = { + id: projectId, + title: "Replay", + workspaceRoot: "/tmp/replay", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + } satisfies OrchestrationProjectShell; + const insert = (version: number, unknown = false) => sql<{ sequence: number }>` INSERT INTO orchestration_events ( event_id, aggregate_kind, stream_id, stream_version, event_type, occurred_at, actor_kind, payload_json, metadata_json, application_event_version @@ -309,85 +307,91 @@ it.live.each(["thread", "shell"] as const)( )}, '{}', 2 ) RETURNING sequence `; - const [before] = yield* insert(0); - yield* insert(1, true); - const [after] = yield* insert(2); - const [highWater] = yield* insert(3, true); - const updates = yield* PubSub.unbounded(); - let liveSequence = 0; - const consume = ( - stream: Stream.Stream, - ) => - stream.pipe( - Stream.tap((item) => - Effect.gen(function* () { - if (item.kind !== "synchronized") return; - const [live] = yield* insert(4); - liveSequence = live!.sequence; - const events = yield* store - .readApplicationEvents({ - afterSequence: highWater!.sequence, - throughSequence: liveSequence, - }) - .pipe(Stream.runCollect); - yield* store.publishCommitted(events); - }), - ), - Stream.takeUntil((item) => item.sequence === liveSequence), - Stream.map((item) => ({ kind: item.kind as string, sequence: item.sequence })), - Stream.runCollect, - ); - const subscription = Effect.gen(function* () { - return scope === "thread" - ? yield* subscribeOrchestrationV2Thread({ - threadId, - afterSequence: before!.sequence, - requestCompletionMarker: true, - }).pipe(Effect.flatMap(consume)) - : yield* subscribeOrchestrationV2Shell({ - afterSequence: before!.sequence, - requestCompletionMarker: true, - }).pipe(Effect.flatMap(consume)); - }); - const items = yield* subscription.pipe( - Effect.provideService(ThreadManagement.ThreadManagementService, { - ensureLegacyTranscript: () => Effect.void, - getShellSnapshot: () => Effect.die("shell resume must not load a full snapshot"), - listActiveSubagentThreadIds: () => Effect.succeed([]), - streamStoredEventsFrom: ( - input: Parameters< - ThreadManagement.ThreadManagementService["Service"]["streamStoredEventsFrom"] - >[0], - ) => { - assert.equal(input?.afterSequence, highWater!.sequence); - return store - .streamApplicationEvents({ afterSequence: input?.afterSequence ?? 0 }) - .pipe( - Stream.filter((event): event is OrchestrationV2StoredEvent => "event" in event), - ); - }, - } as unknown as ThreadManagement.ThreadManagementService["Service"]), - Effect.provideService(ProjectStore.ProjectStoreV2, { - listShells: () => Effect.succeed([]), - } as unknown as ProjectStore.ProjectStoreV2["Service"]), - Effect.provideService(ProjectService.ProjectService, { - getShell: () => Effect.succeedSome(project), - } as unknown as ProjectService.ProjectService["Service"]), - Effect.provideService(ProjectEnrichment.ProjectEnrichmentService, { - subscribeChanges: PubSub.subscribe(updates), - } as ProjectEnrichment.ProjectEnrichmentService["Service"]), - ); - assert.deepEqual( - items.map((item) => item.kind), - scope === "thread" - ? ["event", "synchronized", "event"] - : ["project.updated", "synchronized", "project.updated"], - ); - assert.deepEqual( - items.flatMap((item) => (item.sequence === undefined ? [] : [item.sequence])), - [after!.sequence, liveSequence], + const [before] = yield* insert(0); + yield* insert(1, true); + const [after] = yield* insert(2); + const [highWater] = yield* insert(3, true); + const updates = yield* PubSub.unbounded(); + let liveSequence = 0; + const consume = ( + stream: Stream.Stream, + ) => + stream.pipe( + Stream.tap((item) => + Effect.gen(function* () { + if (item.kind !== "synchronized") return; + const [live] = yield* insert(4); + liveSequence = live!.sequence; + const events = yield* store + .readApplicationEvents({ + afterSequence: highWater!.sequence, + throughSequence: liveSequence, + }) + .pipe(Stream.runCollect); + yield* store.publishCommitted(events); + }), + ), + Stream.takeUntil((item) => item.sequence === liveSequence), + Stream.map((item) => ({ kind: item.kind as string, sequence: item.sequence })), + Stream.runCollect, ); - }).pipe(Effect.scoped, Effect.provide(ReplayTestLayer)), + const subscription = Effect.gen(function* () { + return scope === "thread" + ? yield* subscribeOrchestrationV2Thread({ + threadId, + afterSequence: before!.sequence, + requestCompletionMarker: true, + }).pipe(Effect.flatMap(consume)) + : yield* subscribeOrchestrationV2Shell({ + afterSequence: before!.sequence, + requestCompletionMarker: true, + }).pipe(Effect.flatMap(consume)); + }); + const items = yield* subscription.pipe( + Effect.provideService(ThreadManagement.ThreadManagementService, { + ensureLegacyTranscript: () => Effect.void, + getShellSnapshot: () => Effect.die("shell resume must not load a full snapshot"), + listActiveSubagentThreadIds: () => Effect.succeed([]), + streamStoredEventsFrom: ( + input: Parameters< + ThreadManagement.ThreadManagementService["Service"]["streamStoredEventsFrom"] + >[0], + ) => { + assert.equal(input?.afterSequence, highWater!.sequence); + return store + .streamApplicationEvents({ afterSequence: input?.afterSequence ?? 0 }) + .pipe(Stream.filter((event): event is OrchestrationV2StoredEvent => "event" in event)); + }, + } as unknown as ThreadManagement.ThreadManagementService["Service"]), + Effect.provideService(ProjectStore.ProjectStoreV2, { + listShells: () => Effect.succeed([]), + } as unknown as ProjectStore.ProjectStoreV2["Service"]), + Effect.provideService(ProjectService.ProjectService, { + getShell: () => Effect.succeedSome(project), + } as unknown as ProjectService.ProjectService["Service"]), + Effect.provideService(ProjectEnrichment.ProjectEnrichmentService, { + subscribeChanges: PubSub.subscribe(updates), + } as ProjectEnrichment.ProjectEnrichmentService["Service"]), + ); + assert.deepEqual( + items.map((item) => item.kind), + scope === "thread" + ? ["event", "synchronized", "event"] + : ["project.updated", "synchronized", "project.updated"], + ); + assert.deepEqual( + items.flatMap((item) => (item.sequence === undefined ? [] : [item.sequence])), + [after!.sequence, liveSequence], + ); + }).pipe(Effect.scoped, Effect.provide(ReplayTestLayer)); + +it.effect("thread resume skips unknown types, synchronizes, and receives the live tail", () => + resumeSkipsUnknownTypes("thread"), +); + +// Shell updates batch through `Stream.groupedWithin`, whose window needs real time to close. +it.live("shell resume skips unknown types, synchronizes, and receives the live tail", () => + resumeSkipsUnknownTypes("shell"), ); it.effect("coalesces streaming replay before synchronization and the live tail", () => diff --git a/packages/client-runtime/src/state/pullRequests.test.ts b/packages/client-runtime/src/state/pullRequests.test.ts index f4571c44b03a7..c0afa7d4bba26 100644 --- a/packages/client-runtime/src/state/pullRequests.test.ts +++ b/packages/client-runtime/src/state/pullRequests.test.ts @@ -15,6 +15,7 @@ import { expect, it } from "@effect/vitest"; import * as Schema from "effect/Schema"; import * as Deferred from "effect/Deferred"; import * as Fiber from "effect/Fiber"; +import * as TestClock from "effect/testing/TestClock"; import * as Effect from "effect/Effect"; import * as Latch from "effect/Latch"; import * as Layer from "effect/Layer"; @@ -57,7 +58,7 @@ const trustedRouting = { class MutationRefused extends Schema.TaggedError()("MutationRefused", {}) {} -for (const scenario of [ +it.effect.each([ "prefers the local environment with the same github account", "falls back before mutation when the local account differs", "never retries an ambiguous mutation failure", @@ -71,176 +72,176 @@ for (const scenario of [ "keeps mutations on the origin when the alternate is disabled", "does not dispatch after the alternate is disabled during the probe", "preserves a local source account rejection when alternate accounts differ", -] as const) { - (scenario === "returns a successful mutation when source invalidation stalls" - ? it.live - : it.effect)(scenario, () => - Effect.scoped( - Effect.gen(function* () { - const calls: string[] = []; - const inputs: unknown[] = []; - let trusted = true; - let disableAlternate = Effect.void; - const switchedAccount = - scenario === "preserves a local source account rejection when alternate accounts differ"; - const mismatch = - scenario === "falls back before mutation when the local account differs" || - switchedAccount; - const ambiguous = scenario === "never retries an ambiguous mutation failure"; - const reading = - scenario === "returns a fast local source read without checking alternate identities"; - const single = scenario === "keeps single-environment requests free of identity lookups"; - const localOrigin = - scenario === "keeps a local origin ahead of another local environment" || - switchedAccount || - reading; - const oldOrigin = - scenario === "keeps mutations on an old origin server without retrying them"; - const oldAlternate = - scenario === "skips an old alternate server before dispatching a mutation"; - const failure = new PullRequestOperationError({ - operation: switchedAccount ? "routeIdentity" : "runAction", - detail: "connection lost after dispatch", - }); - const clientFor = (local: boolean) => { - const name = local ? "local" : "origin"; - return { - [local ? WS_METHODS.pullRequestsRoutingIdentity : WS_METHODS.pullRequestsRouting]: ( - input: unknown, - ) => - Effect.gen(function* () { - if (local) expect(input).toEqual({ host: "github.com" }); - calls.push(`${name}:identity`); - if ( - local && - scenario === "does not dispatch after the alternate is disabled during the probe" - ) - yield* disableAlternate; - if ( - local && - scenario === - "does not dispatch after routing permission is revoked during the probe" - ) - trusted = false; - if ((!local && oldOrigin) || (local && oldAlternate)) { - return yield* Effect.die( - `Unknown request tag: ${WS_METHODS.pullRequestsRouting}`, - ); - } - return { - host: "github.com", - provider: "github", - viewer: "maria-rcks", - accountId: local && mismatch ? "456" : "123", - }; - }), - [WS_METHODS.pullRequestsRunAction]: (input: unknown) => - Effect.gen(function* () { - calls.push(`${name}:mutation`); - inputs.push(input); - if ( - !local && - switchedAccount && - (input as { expectedAccountId?: string }).expectedAccountId === "123" - ) - return yield* failure; - if (local && ambiguous) return yield* failure; - }), - [WS_METHODS.pullRequestsSummary]: (input: { allowStale?: boolean }) => - Effect.gen(function* () { - calls.push(`${name}:read`); - expect(input.allowStale).toBe(false); - if (local) return yield* failure; - return null; - }), - [WS_METHODS.pullRequestsInvalidate]: () => - Effect.gen(function* () { - calls.push(`${name}:invalidate`); - if ( - !local && - scenario === "returns a successful mutation when source invalidation stalls" - ) { - return yield* Effect.never; - } - }), - } as unknown as WsRpcProtocolClient; - }; - const { environmentRegistry, supervisor } = yield* makeTestRuntime( - clientFor(false), - single ? undefined : clientFor(true), - localOrigin, - ); - disableAlternate = SubscriptionRef.update( - environmentRegistry.entries, - (entries) => - new Map( - [...entries].map(([id, entry]) => [ - id, - id === TARGET.environmentId ? entry : { ...entry, enabled: false }, - ]), - ), - ); - const disabled = - scenario === "keeps mutations on the origin when the alternate is disabled"; - const disabledDuringProbe = - scenario === "does not dispatch after the alternate is disabled during the probe"; - if (disabled) yield* disableAlternate; - const input = { - projectId: ProjectId.make("project-1"), - host: "github.com", - repository: "acme/web", - number: 7, - action: "merge" as const, - }; - const route = createPullRequestRouter(); - const result = yield* ( - reading - ? route(WS_METHODS.pullRequestsSummary, input) - : route(WS_METHODS.pullRequestsRunAction, input) - ).pipe( - Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), - Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }), - Effect.provideService(PullRequestRoutingPermissions, { - ...trustedRouting, - get: () => Effect.succeed(trusted ? "read-write" : "off"), - }), - Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), - Effect.result, - ); - - if (switchedAccount) { - expect(result).toMatchObject({ _tag: "Failure", failure }); +] as const)("%s", (scenario) => + Effect.scoped( + Effect.gen(function* () { + const stalls = scenario === "returns a successful mutation when source invalidation stalls"; + const invalidationStalled = yield* Deferred.make(); + const calls: string[] = []; + const inputs: unknown[] = []; + let trusted = true; + let disableAlternate = Effect.void; + const switchedAccount = + scenario === "preserves a local source account rejection when alternate accounts differ"; + const mismatch = + scenario === "falls back before mutation when the local account differs" || switchedAccount; + const ambiguous = scenario === "never retries an ambiguous mutation failure"; + const reading = + scenario === "returns a fast local source read without checking alternate identities"; + const single = scenario === "keeps single-environment requests free of identity lookups"; + const localOrigin = + scenario === "keeps a local origin ahead of another local environment" || + switchedAccount || + reading; + const oldOrigin = + scenario === "keeps mutations on an old origin server without retrying them"; + const oldAlternate = + scenario === "skips an old alternate server before dispatching a mutation"; + const failure = new PullRequestOperationError({ + operation: switchedAccount ? "routeIdentity" : "runAction", + detail: "connection lost after dispatch", + }); + const clientFor = (local: boolean) => { + const name = local ? "local" : "origin"; + return { + [local ? WS_METHODS.pullRequestsRoutingIdentity : WS_METHODS.pullRequestsRouting]: ( + input: unknown, + ) => + Effect.gen(function* () { + if (local) expect(input).toEqual({ host: "github.com" }); + calls.push(`${name}:identity`); + if ( + local && + scenario === "does not dispatch after the alternate is disabled during the probe" + ) + yield* disableAlternate; + if ( + local && + scenario === + "does not dispatch after routing permission is revoked during the probe" + ) + trusted = false; + if ((!local && oldOrigin) || (local && oldAlternate)) { + return yield* Effect.die(`Unknown request tag: ${WS_METHODS.pullRequestsRouting}`); + } + return { + host: "github.com", + provider: "github", + viewer: "maria-rcks", + accountId: local && mismatch ? "456" : "123", + }; + }), + [WS_METHODS.pullRequestsRunAction]: (input: unknown) => + Effect.gen(function* () { + calls.push(`${name}:mutation`); + inputs.push(input); + if ( + !local && + switchedAccount && + (input as { expectedAccountId?: string }).expectedAccountId === "123" + ) + return yield* failure; + if (local && ambiguous) return yield* failure; + }), + [WS_METHODS.pullRequestsSummary]: (input: { allowStale?: boolean }) => + Effect.gen(function* () { + calls.push(`${name}:read`); + expect(input.allowStale).toBe(false); + if (local) return yield* failure; + return null; + }), + [WS_METHODS.pullRequestsInvalidate]: () => + Effect.gen(function* () { + calls.push(`${name}:invalidate`); + if (!local && stalls) { + yield* Deferred.succeed(invalidationStalled, undefined); + return yield* Effect.never; + } + }), + } as unknown as WsRpcProtocolClient; + }; + const { environmentRegistry, supervisor } = yield* makeTestRuntime( + clientFor(false), + single ? undefined : clientFor(true), + localOrigin, + ); + disableAlternate = SubscriptionRef.update( + environmentRegistry.entries, + (entries) => + new Map( + [...entries].map(([id, entry]) => [ + id, + id === TARGET.environmentId ? entry : { ...entry, enabled: false }, + ]), + ), + ); + const disabled = scenario === "keeps mutations on the origin when the alternate is disabled"; + const disabledDuringProbe = + scenario === "does not dispatch after the alternate is disabled during the probe"; + if (disabled) yield* disableAlternate; + const input = { + projectId: ProjectId.make("project-1"), + host: "github.com", + repository: "acme/web", + number: 7, + action: "merge" as const, + }; + const route = createPullRequestRouter(); + const routed = ( + reading + ? route(WS_METHODS.pullRequestsSummary, input) + : route(WS_METHODS.pullRequestsRunAction, input) + ).pipe( + Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), + Effect.provideService(RpcPermissionGuard, { authorize: () => Effect.void }), + Effect.provideService(PullRequestRoutingPermissions, { + ...trustedRouting, + get: () => Effect.succeed(trusted ? "read-write" : "off"), + }), + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.result, + ); + const result = stalls + ? yield* Effect.gen(function* () { + const fiber = yield* Effect.forkChild(routed); + yield* Deferred.await(invalidationStalled); + yield* TestClock.adjust("1 second"); + return yield* Fiber.join(fiber); + }) + : yield* routed; + + if (switchedAccount) { + expect(result).toMatchObject({ _tag: "Failure", failure }); + expect(calls.filter((call) => call.endsWith(":mutation"))).toEqual(["origin:mutation"]); + expect(inputs).toEqual([{ ...input, expectedAccountId: "123" }]); + } else if (ambiguous) { + expect(result).toMatchObject({ _tag: "Failure", failure }); + expect(calls.filter((call) => call.endsWith(":mutation"))).toEqual(["local:mutation"]); + } else { + expect(result._tag).toBe("Success"); + if (single || disabled) { + expect(calls.filter((call) => !call.endsWith(":invalidate"))).toEqual([ + "origin:mutation", + ]); + } else if (reading) expect(calls).toEqual(["origin:read"]); + else if ( + mismatch || + localOrigin || + oldOrigin || + oldAlternate || + !trusted || + disabledDuringProbe + ) expect(calls.filter((call) => call.endsWith(":mutation"))).toEqual(["origin:mutation"]); - expect(inputs).toEqual([{ ...input, expectedAccountId: "123" }]); - } else if (ambiguous) { - expect(result).toMatchObject({ _tag: "Failure", failure }); + else { expect(calls.filter((call) => call.endsWith(":mutation"))).toEqual(["local:mutation"]); - } else { - expect(result._tag).toBe("Success"); - if (single || disabled) { - expect(calls.filter((call) => !call.endsWith(":invalidate"))).toEqual([ - "origin:mutation", - ]); - } else if (reading) expect(calls).toEqual(["origin:read"]); - else if ( - mismatch || - localOrigin || - oldOrigin || - oldAlternate || - !trusted || - disabledDuringProbe - ) - expect(calls.filter((call) => call.endsWith(":mutation"))).toEqual(["origin:mutation"]); - else { - expect(calls.filter((call) => call.endsWith(":mutation"))).toEqual(["local:mutation"]); - expect(calls).toContain("origin:invalidate"); - expect(inputs).toEqual([{ ...input, expectedAccountId: "123" }]); - } + expect(calls).toContain("origin:invalidate"); + expect(inputs).toEqual([{ ...input, expectedAccountId: "123" }]); } - }), - ), - ); -} + } + }), + ), +); it.effect.each([ { probe: "origin", cancelled: "first" }, @@ -824,7 +825,7 @@ it.effect.each( ), ); -it.live.each([undefined, true, false])( +it.effect.each([undefined, true, false])( "keeps source workspace metadata and reports draft %s when an alternate answers a detail read", (isDraft) => Effect.scoped( @@ -888,7 +889,7 @@ it.live.each([undefined, true, false])( ), ); -it.live( +it.effect( "refreshes identity before writes and invalidates prior readers across router instances", () => Effect.scoped( diff --git a/packages/shared/src/DrainableWorker.test.ts b/packages/shared/src/DrainableWorker.test.ts index d6bad8114c5a8..2e30509501873 100644 --- a/packages/shared/src/DrainableWorker.test.ts +++ b/packages/shared/src/DrainableWorker.test.ts @@ -80,7 +80,7 @@ describe("makeDrainableWorker", () => { }).pipe(Effect.provide(loggerLayer)); }); - it.live("waits for work enqueued during active processing before draining", () => + it.effect("waits for work enqueued during active processing before draining", () => Effect.scoped( Effect.gen(function* () { const processed: string[] = []; diff --git a/packages/shared/src/KeyedCoalescingWorker.test.ts b/packages/shared/src/KeyedCoalescingWorker.test.ts index 8ce44069d2d5e..48f2563477eab 100644 --- a/packages/shared/src/KeyedCoalescingWorker.test.ts +++ b/packages/shared/src/KeyedCoalescingWorker.test.ts @@ -6,7 +6,7 @@ import * as Effect from "effect/Effect"; import { makeKeyedCoalescingWorker } from "./KeyedCoalescingWorker.ts"; describe("makeKeyedCoalescingWorker", () => { - it.live("lets a queued key drain before a continuously updated key finishes", () => + it.effect("lets a queued key drain before a continuously updated key finishes", () => Effect.scoped( Effect.gen(function* () { const processed: string[] = []; @@ -40,7 +40,7 @@ describe("makeKeyedCoalescingWorker", () => { ), ); - it.live("merges updates while waiting behind another key and drains the merged batch", () => + it.effect("merges updates while waiting behind another key and drains the merged batch", () => Effect.scoped( Effect.gen(function* () { const processed: string[] = []; @@ -98,7 +98,7 @@ describe("makeKeyedCoalescingWorker", () => { ), ); - it.live("waits for latest work enqueued during active processing before draining the key", () => + it.effect("waits for latest work enqueued during active processing before draining the key", () => Effect.scoped( Effect.gen(function* () { const processed: string[] = []; @@ -149,7 +149,7 @@ describe("makeKeyedCoalescingWorker", () => { ), ); - it.live("requeues pending work for a key after a processor failure and keeps draining", () => + it.effect("requeues pending work for a key after a processor failure and keeps draining", () => Effect.scoped( Effect.gen(function* () { const processed: string[] = []; From 939b2884f9e9617aab0b0c0ffda9357ef73ad0b9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 21:23:52 -0800 Subject: [PATCH 056/390] fix(server): SSH device test rejects Node below agent-device's 22.12 floor (#35991) --- apps/server/src/device/sshDeviceScript.test.ts | 15 +++++++++++++++ apps/server/src/device/sshDeviceScript.ts | 3 ++- docs/user/devices.md | 2 +- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/apps/server/src/device/sshDeviceScript.test.ts b/apps/server/src/device/sshDeviceScript.test.ts index d855320d3103c..f048b88bbfefc 100644 --- a/apps/server/src/device/sshDeviceScript.test.ts +++ b/apps/server/src/device/sshDeviceScript.test.ts @@ -46,6 +46,21 @@ it.effect("preserves shell metacharacters and newlines in remote arguments", () }), ); +it.effect("rejects a Node release below the pinned agent-device engine floor", () => + Effect.gen(function* () { + if ((yield* HostProcess.Platform) === "win32") return; + const probe = NodeChildProcess.spawnSync(process.execPath, [], { + input: + `Object.defineProperty(process.versions, "node", { value: "22.11.0" });\n` + + remoteDeviceScript("owner", "probe"), + env: { HOME: NodeOS.tmpdir(), PATH: NodePath.dirname(process.execPath) }, + encoding: "utf8", + }); + expect(probe.status).not.toBe(0); + expect(probe.stderr).toContain("Node 22.12 or newer"); + }), +); + it.effect("stops a daemon left for its owner when starting without agent tools", () => Effect.gen(function* () { if ((yield* HostProcess.Platform) === "win32") return; diff --git a/apps/server/src/device/sshDeviceScript.ts b/apps/server/src/device/sshDeviceScript.ts index 2e3e895293fa3..beb998e04008d 100644 --- a/apps/server/src/device/sshDeviceScript.ts +++ b/apps/server/src/device/sshDeviceScript.ts @@ -139,7 +139,8 @@ async function install(name, version, entry) { { platform: 'android', available: android, ...(!android ? { reason: 'Android SDK missing. Set ANDROID_HOME or put adb on the SSH PATH.' } : {}) }, ]; if (mode === 'probe') { - if (Number(process.versions.node.split('.')[0]) < 22) throw Error('Node 22 or newer is required on the device host.'); + const [nodeMajor, nodeMinor] = process.versions.node.split('.').map(Number); + if (nodeMajor < 22 || (nodeMajor === 22 && nodeMinor < 12)) throw Error('Node 22.12 or newer is required on the device host.'); if (run('npm', ['--version']).status !== 0) throw Error('npm is missing from the non-interactive SSH PATH.'); console.log(JSON.stringify({ nodePath: process.execPath, platforms, tools: versions() })); return; } diff --git a/docs/user/devices.md b/docs/user/devices.md index ac053b36c081f..3d5c66a9a307c 100644 --- a/docs/user/devices.md +++ b/docs/user/devices.md @@ -110,7 +110,7 @@ the environment’s own machine are skipped, since its devices are already local The first device listing installs pinned device tools on the host. SSH device hosts currently need macOS or Linux. To use devices on a Windows machine, run T3 Code on that machine and connect to its environment. -Node 22 or newer and npm must be available to non-interactive SSH commands. +Node 22.12 or newer and npm must be available to non-interactive SSH commands. T3 checks common Homebrew and Android SDK locations; custom installations need the appropriate PATH and ANDROID_HOME on the host. Android emulator video needs `ffmpeg` with libx264 on that host; physical Android devices use scrcpy and do From cc927400c7fdebb0ae4ca23cb297e5bd2b1843c8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 21:25:25 -0800 Subject: [PATCH 057/390] fix(server): Simlock renewals no longer exceed the gateway's lease TTL cap (#35988) --- .../src/device/SimlockDeviceHost.test.ts | 35 ++++++++++++++++++- apps/server/src/device/SimlockDeviceHost.ts | 5 ++- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/apps/server/src/device/SimlockDeviceHost.test.ts b/apps/server/src/device/SimlockDeviceHost.test.ts index 752a63c038b81..8726a7a7c03de 100644 --- a/apps/server/src/device/SimlockDeviceHost.test.ts +++ b/apps/server/src/device/SimlockDeviceHost.test.ts @@ -4,6 +4,7 @@ import { SecretProviderId, type SimlockDeviceHostConfig } from "@t3tools/contrac import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; +import * as Clock from "effect/Clock"; import * as Fiber from "effect/Fiber"; import * as Deferred from "effect/Deferred"; import * as Exit from "effect/Exit"; @@ -56,6 +57,8 @@ const fakePool = ( readonly entered: Deferred.Deferred; readonly proceed: Deferred.Deferred; }; + /** The gateway's `lease.maxTtlMs`: leases expire one TTL after their last renewal. */ + readonly maxTtlMs?: number; } = {}, ) => Effect.gen(function* () { @@ -66,8 +69,10 @@ const fakePool = ( let leases = 0; let renewStatus = 200; let catalog: unknown = CATALOG; + let leaseDeadline = Infinity; const http = HttpClient.make((request) => - Effect.sync(() => { + Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis; const url = new URL(request.url); requests.push(`${request.method} ${url.pathname}`); const json = (body: unknown, status = 200) => @@ -75,6 +80,17 @@ const fakePool = ( if (request.headers.authorization !== "Bearer agent-token") return json({ error: { code: "UNAUTHENTICATED", message: "Unknown bearer token" } }, 401); if (url.pathname === "/v1/catalog") return json(catalog); + if (url.pathname.endsWith("/renew") && options.maxTtlMs !== undefined) { + if (now >= leaseDeadline) return json({}, 404); + const body = + request.body._tag === "Uint8Array" + ? (JSON.parse(new TextDecoder().decode(request.body.body)) as { ttlMs?: number }) + : {}; + if ((body.ttlMs ?? 0) > options.maxTtlMs) + return json({ error: { code: "BAD_REQUEST", message: "ttlMs exceeds maxTtlMs" } }, 400); + leaseDeadline = now + (body.ttlMs ?? options.maxTtlMs); + return json({}); + } if (url.pathname.endsWith("/renew")) return json({}, renewStatus); if (request.method === "DELETE") return json({}, 202); return json({}, 404); @@ -114,6 +130,8 @@ const fakePool = ( path.join(dir, `${session}.json`), JSON.stringify({ leaseId: `lease-${++leases}` }), ); + if (options.maxTtlMs !== undefined) + leaseDeadline = (yield* Clock.currentTimeMillis) + options.maxTtlMs; } if (command === "screenshot") yield* fs.writeFile(args[1]!, new Uint8Array([1, 2, 3])); return { @@ -475,6 +493,21 @@ describe("Simlock device host", () => { }).pipe(Effect.scoped, Effect.provide(layer)), ); + it.effect("keeps a watched lease renewed when the gateway caps lease TTLs", () => + Effect.gen(function* () { + const pool = yield* fakePool({ maxTtlMs: 5 * 60_000 }); + const host = yield* makeHost(pool, config(), yield* Scope.make()); + const ready = yield* host.ensureReady(() => Effect.void); + const [iphone] = (yield* ready.devices.list).devices; + const leaseId = yield* ready.devices.boot(iphone!); + + yield* TestClock.adjust("6 minutes"); + expect((yield* ready.devices.list).devices.some((device) => device.id === leaseId)).toBe( + true, + ); + }).pipe(Effect.scoped, Effect.provide(layer)), + ); + it.effect("returns a lease whose open finishes after the host stopped", () => Effect.gen(function* () { const holdOpen = { diff --git a/apps/server/src/device/SimlockDeviceHost.ts b/apps/server/src/device/SimlockDeviceHost.ts index bab0558614dc2..417399c9114c8 100644 --- a/apps/server/src/device/SimlockDeviceHost.ts +++ b/apps/server/src/device/SimlockDeviceHost.ts @@ -42,9 +42,9 @@ const REQUEST_TIMEOUT = Duration.seconds(15); // The gateway queues a lease request for up to five minutes when every device is busy. const OPEN_TIMEOUT = Duration.minutes(7); const COMMAND_TIMEOUT = Duration.minutes(2); -// Renewal keeps a watched but idle device; a lease this server stops renewing ends after LEASE_TTL. +// Renewal keeps a watched but idle device. It sends no ttlMs: Simlock then keeps the lease's own +// length, while one above the gateway's lease.maxTtlMs is refused rather than shortened. const RENEW_INTERVAL = Duration.minutes(2); -const LEASE_TTL_MS = 10 * 60_000; const STREAM_PATH = /^\/vendor\/serve-sim\/helper\/([^/]+)\/stream\.avcc$/; const INPUT_PATH = "/vendor/serve-sim/helper/ws"; @@ -422,7 +422,6 @@ export const make = Effect.fn("SimlockDeviceHost.make")(function* ( lease.token, "POST", `/v1/leases/${encodeURIComponent(lease.leaseId)}/renew`, - { ttlMs: LEASE_TTL_MS }, ).pipe(Effect.result); if (renewed._tag === "Success") continue; const error = renewed.failure; From ee645553a970976f663c22d19a74871fd65a18b7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 21:27:29 -0800 Subject: [PATCH 058/390] test: drop Effect.scoped wrappers that @effect/vitest already applies (#35993) Co-authored-by: Claude Opus 5.5 --- apps/desktop/src/app/DesktopClerk.test.ts | 4 +- .../desktop/src/app/DesktopCliCommand.test.ts | 16 +- .../app/DesktopConnectionCatalogStore.test.ts | 4 +- .../src/app/DesktopLinuxUrlHandler.test.ts | 10 +- .../src/backend/DesktopBackendManager.test.ts | 1013 +- .../src/backend/DesktopBackendPool.test.ts | 258 +- .../src/electron/ElectronProtocol.test.ts | 2 +- .../src/electron/ElectronTheme.test.ts | 6 +- apps/desktop/src/ipc/DesktopIpc.test.ts | 2 +- .../BrowserImport/BrowserImport.test.ts | 4 +- .../BrowserImport/ChromiumCookies.test.ts | 14 +- .../BrowserImport/CookieDatabase.test.ts | 2 +- .../BrowserImport/FirefoxCookies.test.ts | 2 +- .../BrowserImport/LinuxBrowserSecret.test.ts | 2 +- .../BrowserImport/SafariCookies.test.ts | 2 +- .../BrowserImport/SafariPermission.test.ts | 4 +- .../src/preview/BrowserImport/Sources.test.ts | 2 +- .../src/preview/DesktopBrowserHost.test.ts | 50 +- apps/desktop/src/preview/Manager.test.ts | 8 +- .../settings/DesktopClientSettings.test.ts | 2 +- .../settings/DesktopSavedEnvironments.test.ts | 2 +- .../src/ssh/DesktopSshPasswordPrompts.test.ts | 6 +- .../src/wsl/DesktopWslServerTree.test.ts | 11 +- .../src/native/nativeWebSocket.ios.test.ts | 2 +- .../src/persistence/mobile-database.test.ts | 34 +- apps/server/src/assets/AssetAccess.test.ts | 5 +- apps/server/src/auth/RpcAuthorization.test.ts | 16 +- .../checkpointing/CheckpointDiffQuery.test.ts | 2 +- apps/server/src/cli/install.test.ts | 4 +- apps/server/src/cli/invocation.test.ts | 8 +- apps/server/src/cli/project.test.ts | 4 +- apps/server/src/cli/uninstall.test.ts | 2 +- apps/server/src/cli/update.test.ts | 15 +- .../src/cloud/serviceLauncherClient.test.ts | 6 +- apps/server/src/device/DeviceService.test.ts | 59 +- .../src/environment/DirectEndpoints.test.ts | 2 +- .../environment/staleWhileRevalidate.test.ts | 8 +- apps/server/src/environmentTheme.test.ts | 2 +- apps/server/src/htmlRender/HtmlRender.test.ts | 2 +- .../server/src/htmlRender/publicProxy.test.ts | 6 +- apps/server/src/mcp/McpHttpServer.test.ts | 1 - .../src/mcp/OrchestratorMcpService.test.ts | 562 +- ...OrchestratorMcpToolkit.integration.test.ts | 1131 +- .../src/mcp/PreviewAutomationBroker.test.ts | 1644 +- .../TaskCancelUsageLimit.integration.test.ts | 543 +- .../src/mcp/toolkits/preview/handlers.test.ts | 136 +- .../toolkits/previewControls/handlers.test.ts | 140 +- .../src/observability/DefectReporter.test.ts | 2 +- .../Adapters/AcpAdapterV2.test.ts | 160 +- .../Adapters/AcpRegistryAdapterV2.test.ts | 46 +- .../Adapters/AntigravityAdapterV2.test.ts | 14 +- .../Adapters/ClaudeAdapterV2.test.ts | 21789 ++++++++-------- .../ClaudeAgentSdkQueryRunner.test.ts | 580 +- .../Adapters/ClaudeTaskOutputTail.test.ts | 50 +- .../Adapters/CodexAdapterV2.test.ts | 8521 +++--- .../Adapters/FxAdapterV2.test.ts | 8 +- .../Adapters/KiroAdapterV2.test.ts | 24 +- .../Adapters/OpenCode2AdapterV2.test.ts | 202 +- .../BackgroundWorkStop.integration.test.ts | 1740 +- ...laudeAutomaticDelivery.integration.test.ts | 1225 +- .../orchestration-v2/CommandOutputHub.test.ts | 292 +- .../FoundationPersistence.test.ts | 548 +- .../orchestration-v2/IdleCompaction.test.ts | 332 +- .../orchestration-v2/LiveStreamBudget.test.ts | 386 +- ...penCode2OrchestratorV2.integration.test.ts | 6 +- .../ProviderCommitBatch.test.ts | 24 +- .../ProviderEventIngestor.test.ts | 498 +- .../PullRequestSyncReactor.test.ts | 2489 +- .../RunExecutionService.test.ts | 2 +- .../SelectionRestart.integration.test.ts | 1292 +- .../src/orchestration-v2/ShellStream.test.ts | 281 +- .../SteeringCompletion.integration.test.ts | 4444 ++-- .../ThreadLiveEventCoalescer.test.ts | 266 +- .../ThreadPullRequestService.test.ts | 1486 +- .../ThreadSettlementService.test.ts | 2129 +- .../src/orchestration-v2/ThreadStop.test.ts | 463 +- .../WorktreeBranchSync.test.ts | 1 - .../LegacyV1Cutover.integration.test.ts | 656 +- .../src/orchestration-v2/runtimeLayer.test.ts | 220 +- ...OpenCode2PendingSnapshots.evidence.test.ts | 5 +- ...estratorReplayRecovery.integration.test.ts | 598 +- ...yRestartBackgroundNote.integration.test.ts | 50 +- .../ProviderSwitch.integration.test.ts | 5985 +++-- .../workflowAgentTranscript.test.ts | 2 +- apps/server/src/peer/PeerLinks.test.ts | 60 +- apps/server/src/peer/PeerMcpClient.test.ts | 4 +- .../persistence/DatabaseMaintenance.test.ts | 10 +- .../OrchestrationEventStore.test.ts | 120 +- .../src/preview/ServerBrowserStream.test.ts | 10 +- .../src/provider/AcpRegistryProbe.test.ts | 1 - .../src/provider/AntigravityProvider.test.ts | 934 +- .../src/provider/CodexThreadRevert.test.ts | 2 - .../Drivers/AntigravityDriver.test.ts | 12 +- .../Drivers/ClaudeMaintenance.test.ts | 10 +- .../src/provider/Drivers/CodexDriver.test.ts | 33 +- apps/server/src/provider/GrokProvider.test.ts | 4 +- .../src/provider/ProviderAuthService.test.ts | 6 +- .../provider/ProviderInstanceRegistry.test.ts | 16 +- .../acp/AntigravitySessionFiles.test.ts | 2 +- .../acp/AntigravityTurnOutcome.test.ts | 4 +- .../provider/providerCompatibility.test.ts | 2 +- .../PullRequestReviewGuideService.test.ts | 368 +- .../pullRequest/PullRequestService.test.ts | 398 +- .../src/relay/AgentAwarenessRelay.test.ts | 10 +- .../ResourceTelemetry.test.ts | 224 +- apps/server/src/secrets/SecretCheck.test.ts | 10 +- .../src/serverRuntimeStartup.autoPull.test.ts | 312 +- apps/server/src/serverRuntimeStartup.test.ts | 442 +- .../SourceControlProviderRegistry.test.ts | 2 +- apps/server/src/speech/http.test.ts | 4 +- apps/server/src/storageCleanup.test.ts | 32 +- apps/server/src/terminal/Manager.test.ts | 10 +- .../src/terminal/OutputProtocol.test.ts | 12 +- .../AntigravityTextGeneration.test.ts | 30 +- .../OpenCode2TextGeneration.test.ts | 6 +- .../src/usage/UsageLimitSources.test.ts | 2 - apps/server/src/usage/UsageService.test.ts | 84 +- .../src/utils/subscribeBeforeSnapshot.test.ts | 108 +- apps/server/src/vcs/VcsProcess.test.ts | 2 +- .../src/vcs/VcsStatusBroadcaster.test.ts | 40 +- apps/server/src/ws.test.ts | 9 +- .../src/connection/storage.browser.test.ts | 2 +- apps/web/src/connection/storage.test.ts | 2 +- .../lib/backgroundActivityReporter.test.ts | 4 +- .../src/relay/discovery.test.ts | 76 +- .../client-runtime/src/rpc/client.test.ts | 4 +- .../client-runtime/src/rpc/session.test.ts | 964 +- .../client-runtime/src/state/assets.test.ts | 106 +- .../src/state/commandPermissions.test.ts | 729 +- .../src/state/connections.test.ts | 74 +- .../src/state/fileMetadata.test.ts | 24 +- .../src/state/projectCommands.test.ts | 8 +- .../src/state/pullRequests.test.ts | 3661 ++- .../client-runtime/src/state/runtime.test.ts | 1636 +- .../client-runtime/src/state/server.test.ts | 128 +- .../src/state/serverUsage.test.ts | 140 +- .../src/state/shell-sync.test.ts | 2 +- .../src/state/threadFamilies.test.ts | 216 +- packages/client-runtime/src/state/vcs.test.ts | 1084 +- .../src/state/vcsAction.test.ts | 263 +- packages/effect-acp/src/client.test.ts | 10 +- .../AcpRegistryRuntimeCoordinator.test.ts | 14 +- .../src/server/AcpRegistrySupport.test.ts | 2 +- .../src/server/clientTerminals.test.ts | 14 +- .../src/server/mcpOverAcpBridge.test.ts | 6 +- .../provider-cursor/src/server/auth.test.ts | 18 +- .../provider-cursor/src/server/driver.test.ts | 4 +- .../provider-fx/src/server/driver.test.ts | 2 +- .../provider-fx/src/server/skills.test.ts | 6 +- .../provider-grok/src/server/driver.test.ts | 6 +- .../provider-kiro/src/server/driver.test.ts | 2 +- .../provider-muse/src/server/driver.test.ts | 2 +- .../src/server/maintenance.test.ts | 72 +- .../provider-muse/src/server/status.test.ts | 144 +- .../src/server/adapter.test.ts | 145 +- .../src/server/driver.test.ts | 22 +- .../src/server/status.test.ts | 56 +- .../src/server/v2/OpenCode2Client.test.ts | 4 +- .../src/server/v2/OpenCode2Service.test.ts | 2 +- .../provider-pi/src/server/driver.test.ts | 8 +- packages/shared/src/DrainableWorker.test.ts | 72 +- .../shared/src/KeyedCoalescingWorker.test.ts | 332 +- packages/shared/src/observability.test.ts | 565 +- packages/shared/src/shell.test.ts | 58 +- .../src/server/BitbucketApi.test.ts | 2 +- .../src/server/GitHubRest.test.ts | 2 +- .../GitHubSourceControlProvider.test.ts | 5 +- .../src/server/gitHubMediaFetch.test.ts | 8 +- .../src/server/githubStackRebase.test.ts | 8 +- packages/ssh/src/auth.test.ts | 2 - packages/ssh/src/config.test.ts | 12 +- 171 files changed, 37554 insertions(+), 39085 deletions(-) diff --git a/apps/desktop/src/app/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopClerk.test.ts index 8a823cde8db79..de687d41a4e68 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -424,7 +424,7 @@ it.effect.each(["startup", "open-url", "early-second-instance"] as const)( ), Effect.provideService(DesktopWebLinks.DesktopWebLinks, ignoreWebLinks), ); - }).pipe(Effect.scoped), + }), ); it.effect("hands web and app links to the renderer and leaves other links alone", () => @@ -481,5 +481,5 @@ it.effect("hands web and app links to the renderer and leaves other links alone" }), ), ); - }).pipe(Effect.scoped), + }), ); diff --git a/apps/desktop/src/app/DesktopCliCommand.test.ts b/apps/desktop/src/app/DesktopCliCommand.test.ts index 2dc59c58899ee..dd61b7984ec1f 100644 --- a/apps/desktop/src/app/DesktopCliCommand.test.ts +++ b/apps/desktop/src/app/DesktopCliCommand.test.ts @@ -160,7 +160,7 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { expect(yield* fs.exists(upgradedLink)).toBe(false); expect((yield* upgraded.install).installedPath).toBe(upgradedLink); } - }).pipe(Effect.scoped), + }), ); it.effect("never replaces or removes a t3 it did not create", () => @@ -202,7 +202,7 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { expect((yield* Effect.flip(command.install)).message).toContain("Could not set up"); yield* command.uninstall; expect(yield* fs.readFileString(launcher)).toBe("npm's t3\n"); - }).pipe(Effect.scoped), + }), ); it.effect("finds and removes a link left by a previous T3 home", () => @@ -259,7 +259,7 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { ); expect(yield* fs.exists(path.join(home, ".local", "bin", "t3"))).toBe(false); expect(yield* fs.readFileString(path.join(home, "bin", "t3.staging"))).toBe("mine"); - }).pipe(Effect.scoped), + }), ); it.effect("refuses to install behind another t3 that runs first", () => @@ -309,7 +309,7 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { installedPath: path.join(home, ".local", "bin", "t3"), }); expect(installed.shadowedBy).toBeUndefined(); - }).pipe(Effect.scoped), + }), ); it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( @@ -343,7 +343,7 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { onPath: true, installedPath: path.join(home, ".local", "bin", "t3"), }); - }).pipe(Effect.scoped), + }), ); it.effect("does not treat its own launcher as another t3 when its folder is first on PATH", () => @@ -372,7 +372,7 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { expect(yield* command.state).toEqual(removed); expect(yield* fs.exists(installed.installedPath!)).toBe(false); expect(yield* fs.exists(launcher)).toBe(true); - }).pipe(Effect.scoped), + }), ); it.effect("leaves the Windows PATH alone when it cannot be read", () => @@ -411,7 +411,7 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { const reader = yield* commandIn({ home, platform: "win32" }, inherited.spawner); yield* reader.install; expect(inherited.registry).toMatchObject({ path: `${userPath};${launcherDir}`, writes: 1 }); - }).pipe(Effect.scoped), + }), ); it.effect("does not claim or remove a Windows PATH entry the user added", () => @@ -441,6 +441,6 @@ it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { yield* (yield* commandIn({ home: besideHome, platform: "win32" }, beside.spawner)) .installOnFirstLaunch; expect(beside.registry).toMatchObject({ path: "C:\\Tools", writes: 0 }); - }).pipe(Effect.scoped), + }), ); }); diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts index 98c75385afc51..5dfde9103f152 100644 --- a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts @@ -99,7 +99,7 @@ const withStore = ( prefix: "t3-desktop-connection-catalog-test-", }); return yield* effect.pipe(Effect.provide(layerFor(baseDir, encryptionAvailable))); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); + }).pipe(Effect.provide(NodeServices.layer)); describe("DesktopConnectionCatalogStore", () => { it.effect("persists and reads an encrypted connection catalog", () => @@ -298,6 +298,6 @@ describe("DesktopConnectionCatalogStore", () => { assert.notEqual(error.message, decryptError.message); yield* Ref.set(failDecrypt, false); assert.deepStrictEqual(yield* store.get, Option.some('{"schemaVersion":1,"targets":[]}')); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + }).pipe(Effect.provide(NodeServices.layer)), ); }); diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts index 7e685358865c7..e9c8336feaf4b 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts @@ -602,7 +602,7 @@ it.layer(NodeServices.layer)("DesktopLinuxUrlHandler theme icons", (it) => { } assert.isFalse(yield* fileSystem.exists(fixture.cachePath)); assert.equal(iconCacheRuns(run), 0); - }).pipe(Effect.scoped), + }), ); it.effect("replaces same-length stale icons and refreshes an existing cache once", () => @@ -634,7 +634,7 @@ it.layer(NodeServices.layer)("DesktopLinuxUrlHandler theme icons", (it) => { run.commands.filter(({ command }) => command === "gtk-update-icon-cache"), [{ command: "gtk-update-icon-cache", args: ["-f", "-t", fixture.hicolorDir] }], ); - }).pipe(Effect.scoped), + }), ); it.effect("refreshes when this launch changed an icon even if the cache looks newer", () => @@ -655,7 +655,7 @@ it.layer(NodeServices.layer)("DesktopLinuxUrlHandler theme icons", (it) => { assert.isTrue(yield* fileSystem.exists(path.join(fixture.appsDir, "com.t3tools.T3Code.png"))); assert.equal(iconCacheRuns(run), 1); - }).pipe(Effect.scoped), + }), ); it.effect("retries a failed icon cache refresh on the next launch", () => @@ -678,7 +678,7 @@ it.layer(NodeServices.layer)("DesktopLinuxUrlHandler theme icons", (it) => { yield* registerWithRealFileSystem(recovering); yield* registerWithRealFileSystem(recovering); assert.equal(iconCacheRuns(recovering), 1); - }).pipe(Effect.scoped), + }), ); it.effect("still registers the handler when the theme icon install fails", () => @@ -702,6 +702,6 @@ it.layer(NodeServices.layer)("DesktopLinuxUrlHandler theme icons", (it) => { missingTool.commands.map(({ command }) => command), ["gtk-update-icon-cache", "update-desktop-database", "xdg-mime"], ); - }).pipe(Effect.scoped), + }), ); }); diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index e96e036b0c88b..8a8389082ce89 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -199,129 +199,125 @@ function makeTestInstance(input: MakeInstanceInput) { describe("DesktopBackendManager", () => { it.effect("notifies readiness waiters on HTTP readiness and stop without clock advancement", () => - Effect.scoped( - Effect.gen(function* () { - const probe = yield* Deferred.make(); - const ready = yield* Deferred.make(); - const exit = yield* Deferred.make(); - const instance = yield* makeTestInstance({ - spawnerLayer: Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.acquireRelease( - Effect.succeed( - makeProcess({ - exitCode: Deferred.await(exit), - kill: () => - Deferred.succeed(exit, ChildProcessSpawner.ExitCode(0)).pipe(Effect.asVoid), - }), - ), - () => Deferred.succeed(exit, ChildProcessSpawner.ExitCode(0)), + Effect.gen(function* () { + const probe = yield* Deferred.make(); + const ready = yield* Deferred.make(); + const exit = yield* Deferred.make(); + const instance = yield* makeTestInstance({ + spawnerLayer: Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.acquireRelease( + Effect.succeed( + makeProcess({ + exitCode: Deferred.await(exit), + kill: () => + Deferred.succeed(exit, ChildProcessSpawner.ExitCode(0)).pipe(Effect.asVoid), + }), ), + () => Deferred.succeed(exit, ChildProcessSpawner.ExitCode(0)), ), ), - httpClientLayer: Layer.succeed( - HttpClient.HttpClient, - HttpClient.make((request) => - Deferred.succeed(probe, undefined).pipe( - Effect.andThen(Deferred.await(ready)), - Effect.as(HttpClientResponse.fromWeb(request, new Response("{}"))), - ), + ), + httpClientLayer: Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Deferred.succeed(probe, undefined).pipe( + Effect.andThen(Deferred.await(ready)), + Effect.as(HttpClientResponse.fromWeb(request, new Response("{}"))), ), ), - }); - yield* instance.start; - yield* Deferred.await(probe); - const waiter = yield* instance.waitForReady(Duration.seconds(15)).pipe(Effect.forkChild); - yield* Deferred.succeed(ready, undefined); - assert.isTrue(yield* Fiber.join(waiter)); - yield* instance.stop(); - assert.isFalse(yield* instance.waitForReady(Duration.seconds(15))); - }), - ), + ), + }); + yield* instance.start; + yield* Deferred.await(probe); + const waiter = yield* instance.waitForReady(Duration.seconds(15)).pipe(Effect.forkChild); + yield* Deferred.succeed(ready, undefined); + assert.isTrue(yield* Fiber.join(waiter)); + yield* instance.stop(); + assert.isFalse(yield* instance.waitForReady(Duration.seconds(15))); + }), ); it.effect("spawns the backend with fd3 bootstrap and fd4 telemetry", () => - Effect.scoped( - Effect.gen(function* () { - let spawnedCommand: ChildProcess.Command | undefined; - let bootstrapJson = ""; - let telemetryJson = ""; - let readyCount = 0; - const ready = yield* Deferred.make(); - const exited = yield* Queue.unbounded(); - - const layerSpawner = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make((command) => - Effect.gen(function* () { - spawnedCommand = command; - if (command._tag === "StandardCommand") { - const fd3 = command.options.additionalFds?.fd3; - if (fd3?.type === "input" && fd3.stream) { - bootstrapJson = yield* fd3.stream.pipe(Stream.decodeText(), Stream.mkString); - } - const fd4 = command.options.additionalFds?.fd4; - if (fd4?.type === "input" && fd4.stream) { - telemetryJson = yield* fd4.stream.pipe(Stream.decodeText(), Stream.mkString); - } + Effect.gen(function* () { + let spawnedCommand: ChildProcess.Command | undefined; + let bootstrapJson = ""; + let telemetryJson = ""; + let readyCount = 0; + const ready = yield* Deferred.make(); + const exited = yield* Queue.unbounded(); + + const layerSpawner = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => + Effect.gen(function* () { + spawnedCommand = command; + if (command._tag === "StandardCommand") { + const fd3 = command.options.additionalFds?.fd3; + if (fd3?.type === "input" && fd3.stream) { + bootstrapJson = yield* fd3.stream.pipe(Stream.decodeText(), Stream.mkString); } + const fd4 = command.options.additionalFds?.fd4; + if (fd4?.type === "input" && fd4.stream) { + telemetryJson = yield* fd4.stream.pipe(Stream.decodeText(), Stream.mkString); + } + } - return makeProcess({ - exitCode: Deferred.await(ready).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), - }); - }), - ), - ); + return makeProcess({ + exitCode: Deferred.await(ready).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), + }); + }), + ), + ); - const instance = yield* makeTestInstance({ - config: { - ...baseConfig, - bootstrap: configWithObservability, - }, - spawnerLayer: layerSpawner, - desktopTelemetryStream: Stream.encodeText( - Stream.make('{"version":1,"type":"desktopTelemetryHello","electronPid":123}\n'), - ), - onReady: Effect.sync(() => { - readyCount += 1; - }).pipe(Effect.andThen(Deferred.succeed(ready, void 0)), Effect.asVoid), - backendOutputLog: { - persistFailure: () => Queue.offer(exited, void 0).pipe(Effect.asVoid), - }, - }); + const instance = yield* makeTestInstance({ + config: { + ...baseConfig, + bootstrap: configWithObservability, + }, + spawnerLayer: layerSpawner, + desktopTelemetryStream: Stream.encodeText( + Stream.make('{"version":1,"type":"desktopTelemetryHello","electronPid":123}\n'), + ), + onReady: Effect.sync(() => { + readyCount += 1; + }).pipe(Effect.andThen(Deferred.succeed(ready, void 0)), Effect.asVoid), + backendOutputLog: { + persistFailure: () => Queue.offer(exited, void 0).pipe(Effect.asVoid), + }, + }); - yield* instance.start; - yield* Queue.take(exited); - - assert.equal(readyCount, 1); - assert.isDefined(spawnedCommand); - if (spawnedCommand._tag !== "StandardCommand") { - throw new Error("Expected backend to spawn a standard command."); - } - - assert.equal(spawnedCommand.command, "/electron"); - assert.deepEqual(spawnedCommand.args, ["/server/bin.mjs", "--bootstrap-fd", "3"]); - assert.equal(spawnedCommand.options.cwd, "/server"); - assert.equal(spawnedCommand.options.extendEnv, true); - assert.equal(spawnedCommand.options.stdout, "pipe"); - assert.equal(spawnedCommand.options.stderr, "pipe"); - assert.equal(spawnedCommand.options.killSignal, "SIGTERM"); - assert.isDefined(spawnedCommand.options.forceKillAfter); - assert.equal(spawnedCommand.options.additionalFds?.fd4?.type, "input"); - assert.equal(spawnedCommand.options.additionalFds?.fd5?.type, "output"); - assert.equal( - Duration.toMillis(Duration.fromInputUnsafe(spawnedCommand.options.forceKillAfter)), - 5_000, - ); - - assert.deepEqual(yield* decodeBootstrap(bootstrapJson), configWithObservability); - assert.equal( - telemetryJson, - '{"version":1,"type":"desktopTelemetryHello","electronPid":123}\n', - ); - }), - ), + yield* instance.start; + yield* Queue.take(exited); + + assert.equal(readyCount, 1); + assert.isDefined(spawnedCommand); + if (spawnedCommand._tag !== "StandardCommand") { + throw new Error("Expected backend to spawn a standard command."); + } + + assert.equal(spawnedCommand.command, "/electron"); + assert.deepEqual(spawnedCommand.args, ["/server/bin.mjs", "--bootstrap-fd", "3"]); + assert.equal(spawnedCommand.options.cwd, "/server"); + assert.equal(spawnedCommand.options.extendEnv, true); + assert.equal(spawnedCommand.options.stdout, "pipe"); + assert.equal(spawnedCommand.options.stderr, "pipe"); + assert.equal(spawnedCommand.options.killSignal, "SIGTERM"); + assert.isDefined(spawnedCommand.options.forceKillAfter); + assert.equal(spawnedCommand.options.additionalFds?.fd4?.type, "input"); + assert.equal(spawnedCommand.options.additionalFds?.fd5?.type, "output"); + assert.equal( + Duration.toMillis(Duration.fromInputUnsafe(spawnedCommand.options.forceKillAfter)), + 5_000, + ); + + assert.deepEqual(yield* decodeBootstrap(bootstrapJson), configWithObservability); + assert.equal( + telemetryJson, + '{"version":1,"type":"desktopTelemetryHello","electronPid":123}\n', + ); + }), ); it.effect("reports a SIGKILL exit without an exit code", () => @@ -349,285 +345,272 @@ describe("DesktopBackendManager", () => { ); it.effect("reports child exit before waiting for trailing output to drain", () => - Effect.scoped( - Effect.gen(function* () { - const exitObserved = yield* Deferred.make(); - const finishOutputDrain = yield* Deferred.make(); - const layerSpawner = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.succeed( - makeProcess({ - stdout: Stream.fromEffect( - Deferred.await(finishOutputDrain).pipe( - Effect.as(new TextEncoder().encode("trailing output\n")), - ), + Effect.gen(function* () { + const exitObserved = yield* Deferred.make(); + const finishOutputDrain = yield* Deferred.make(); + const layerSpawner = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + makeProcess({ + stdout: Stream.fromEffect( + Deferred.await(finishOutputDrain).pipe( + Effect.as(new TextEncoder().encode("trailing output\n")), ), - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)), - }), - ), + ), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)), + }), ), - ); + ), + ); - const runFiber = yield* DesktopBackendManager.runBackendProcess({ - ...baseConfig, - desktopTelemetryStream: Stream.empty, - onExitObserved: () => Deferred.succeed(exitObserved, void 0).pipe(Effect.asVoid), - }).pipe( - Effect.provide(Layer.merge(layerSpawner, layerHealthyHttpClient)), - Effect.forkChild, - ); - - yield* Deferred.await(exitObserved); - assert.isUndefined(runFiber.pollUnsafe()); - - yield* Deferred.succeed(finishOutputDrain, void 0); - assert.equal((yield* Fiber.join(runFiber)).code.pipe(Option.getOrUndefined), 1); - }), - ), + const runFiber = yield* DesktopBackendManager.runBackendProcess({ + ...baseConfig, + desktopTelemetryStream: Stream.empty, + onExitObserved: () => Deferred.succeed(exitObserved, void 0).pipe(Effect.asVoid), + }).pipe(Effect.provide(Layer.merge(layerSpawner, layerHealthyHttpClient)), Effect.forkChild); + + yield* Deferred.await(exitObserved); + assert.isUndefined(runFiber.pollUnsafe()); + + yield* Deferred.succeed(finishOutputDrain, void 0); + assert.equal((yield* Fiber.join(runFiber)).code.pipe(Option.getOrUndefined), 1); + }), ); it.effect("continues routing desktop telemetry control messages after an invalid line", () => - Effect.scoped( - Effect.gen(function* () { - const handled = yield* Deferred.make(); - const controlMessage = encodeDesktopTelemetryControl({ - version: 1, - type: "setDiagnosticsDemand", - enabled: true, - }); - const layerSpawner = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.succeed( - makeProcess({ - getOutputFd: (fd) => - fd === 5 - ? Stream.encodeText(Stream.make(`not-json\n${controlMessage}\n`)) - : Stream.empty, - exitCode: Deferred.await(handled).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), - }), - ), + Effect.gen(function* () { + const handled = yield* Deferred.make(); + const controlMessage = encodeDesktopTelemetryControl({ + version: 1, + type: "setDiagnosticsDemand", + enabled: true, + }); + const layerSpawner = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + makeProcess({ + getOutputFd: (fd) => + fd === 5 + ? Stream.encodeText(Stream.make(`not-json\n${controlMessage}\n`)) + : Stream.empty, + exitCode: Deferred.await(handled).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), + }), ), - ); - const instance = yield* makeTestInstance({ - spawnerLayer: layerSpawner, - desktopTelemetryPublisher: { - handleControlForSource: (_sourceId, message) => - message.type === "setDiagnosticsDemand" - ? Deferred.succeed(handled, message.enabled).pipe(Effect.asVoid) - : Effect.void, - }, - }); + ), + ); + const instance = yield* makeTestInstance({ + spawnerLayer: layerSpawner, + desktopTelemetryPublisher: { + handleControlForSource: (_sourceId, message) => + message.type === "setDiagnosticsDemand" + ? Deferred.succeed(handled, message.enabled).pipe(Effect.asVoid) + : Effect.void, + }, + }); - yield* instance.start; - assert.isTrue(yield* Deferred.await(handled)); - }), - ), + yield* instance.start; + assert.isTrue(yield* Deferred.await(handled)); + }), ); it.effect("stops the scoped process with output still pending", () => - Effect.scoped( - Effect.gen(function* () { - let startCount = 0; - let closedCount = 0; - const closed = yield* Deferred.make(); - const teardownStarted = yield* Deferred.make(); - const finishTeardown = yield* Deferred.make(); - const startedPids = yield* Queue.unbounded(); - const ready = yield* Deferred.make(); - const backendReadyFlag = yield* Ref.make(false); - let shutdownCount = 0; - let persistedFailureCount = 0; - let discardedSessionCount = 0; - let removedTelemetrySources = 0; - - const layerSpawner = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.gen(function* () { - const scope = yield* Scope.Scope; - startCount += 1; - yield* Queue.offer(startedPids, 123); - const close = Deferred.succeed(teardownStarted, undefined).pipe( - Effect.andThen(Deferred.await(finishTeardown)), - Effect.andThen( - Effect.sync(() => { - closedCount += 1; - }), - ), - Effect.andThen(Deferred.succeed(closed, void 0)), - Effect.asVoid, - ); + Effect.gen(function* () { + let startCount = 0; + let closedCount = 0; + const closed = yield* Deferred.make(); + const teardownStarted = yield* Deferred.make(); + const finishTeardown = yield* Deferred.make(); + const startedPids = yield* Queue.unbounded(); + const ready = yield* Deferred.make(); + const backendReadyFlag = yield* Ref.make(false); + let shutdownCount = 0; + let persistedFailureCount = 0; + let discardedSessionCount = 0; + let removedTelemetrySources = 0; - yield* Scope.addFinalizer(scope, close); + const layerSpawner = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.gen(function* () { + const scope = yield* Scope.Scope; + startCount += 1; + yield* Queue.offer(startedPids, 123); + const close = Deferred.succeed(teardownStarted, undefined).pipe( + Effect.andThen(Deferred.await(finishTeardown)), + Effect.andThen( + Effect.sync(() => { + closedCount += 1; + }), + ), + Effect.andThen(Deferred.succeed(closed, void 0)), + Effect.asVoid, + ); + + yield* Scope.addFinalizer(scope, close); + + return makeProcess({ + stdout: Stream.never, + exitCode: Deferred.await(closed).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), + kill: () => close, + }); + }), + ), + ); - return makeProcess({ - stdout: Stream.never, - exitCode: Deferred.await(closed).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), - kill: () => close, - }); + const instance = yield* makeTestInstance({ + spawnerLayer: layerSpawner, + onReady: Ref.set(backendReadyFlag, true).pipe( + Effect.andThen(Deferred.succeed(ready, void 0)), + Effect.asVoid, + ), + onShutdown: Ref.set(backendReadyFlag, false).pipe( + Effect.andThen( + Effect.sync(() => { + shutdownCount += 1; }), ), - ); - - const instance = yield* makeTestInstance({ - spawnerLayer: layerSpawner, - onReady: Ref.set(backendReadyFlag, true).pipe( - Effect.andThen(Deferred.succeed(ready, void 0)), - Effect.asVoid, - ), - onShutdown: Ref.set(backendReadyFlag, false).pipe( - Effect.andThen( - Effect.sync(() => { - shutdownCount += 1; - }), - ), - ), - backendOutputLog: { - persistFailure: () => - Effect.sync(() => { - persistedFailureCount += 1; - }), - discardSession: Effect.sync(() => { - discardedSessionCount += 1; + ), + backendOutputLog: { + persistFailure: () => + Effect.sync(() => { + persistedFailureCount += 1; }), - }, - desktopTelemetryPublisher: { - removeControlSource: () => - Effect.sync(() => { - removedTelemetrySources += 1; - }), - }, - }); - assert.isTrue(Option.isNone(yield* instance.currentConfig)); - - yield* instance.start; - assert.equal(yield* Queue.take(startedPids), 123); - yield* Deferred.await(ready); - assert.isTrue(yield* Ref.get(backendReadyFlag)); - assert.deepEqual(yield* instance.currentConfig, Option.some(baseConfig)); - - const runningSnapshot = yield* instance.snapshot; - assert.equal(runningSnapshot.ready, true); - assert.deepEqual(runningSnapshot.activePid, Option.some(123)); - - const stopFiber = yield* instance.stop().pipe(Effect.forkChild); - yield* Deferred.await(teardownStarted).pipe(Effect.timeout("1 second")); - assert.isFalse(yield* Ref.get(backendReadyFlag)); - assert.equal(shutdownCount, 1); - yield* Deferred.succeed(finishTeardown, undefined); - yield* Fiber.join(stopFiber).pipe(Effect.timeout("1 second")); - assert.equal(startCount, 1); - assert.equal(closedCount, 1); - assert.equal(persistedFailureCount, 0); - assert.equal(discardedSessionCount, 1); - assert.equal(removedTelemetrySources, 1); - - const stoppedSnapshot = yield* instance.snapshot; - assert.isFalse(yield* Ref.get(backendReadyFlag)); - assert.equal(shutdownCount, 1); - assert.equal(stoppedSnapshot.desiredRunning, false); - assert.equal(stoppedSnapshot.ready, false); - assert.equal(Option.isNone(stoppedSnapshot.activePid), true); - }), - ), + discardSession: Effect.sync(() => { + discardedSessionCount += 1; + }), + }, + desktopTelemetryPublisher: { + removeControlSource: () => + Effect.sync(() => { + removedTelemetrySources += 1; + }), + }, + }); + assert.isTrue(Option.isNone(yield* instance.currentConfig)); + + yield* instance.start; + assert.equal(yield* Queue.take(startedPids), 123); + yield* Deferred.await(ready); + assert.isTrue(yield* Ref.get(backendReadyFlag)); + assert.deepEqual(yield* instance.currentConfig, Option.some(baseConfig)); + + const runningSnapshot = yield* instance.snapshot; + assert.equal(runningSnapshot.ready, true); + assert.deepEqual(runningSnapshot.activePid, Option.some(123)); + + const stopFiber = yield* instance.stop().pipe(Effect.forkChild); + yield* Deferred.await(teardownStarted).pipe(Effect.timeout("1 second")); + assert.isFalse(yield* Ref.get(backendReadyFlag)); + assert.equal(shutdownCount, 1); + yield* Deferred.succeed(finishTeardown, undefined); + yield* Fiber.join(stopFiber).pipe(Effect.timeout("1 second")); + assert.equal(startCount, 1); + assert.equal(closedCount, 1); + assert.equal(persistedFailureCount, 0); + assert.equal(discardedSessionCount, 1); + assert.equal(removedTelemetrySources, 1); + + const stoppedSnapshot = yield* instance.snapshot; + assert.isFalse(yield* Ref.get(backendReadyFlag)); + assert.equal(shutdownCount, 1); + assert.equal(stoppedSnapshot.desiredRunning, false); + assert.equal(stoppedSnapshot.ready, false); + assert.equal(Option.isNone(stoppedSnapshot.activePid), true); + }), ); it.effect("restarts when start is requested during stop teardown", () => - Effect.scoped( - Effect.gen(function* () { - const starts = yield* Queue.unbounded(); - const teardownStarted = yield* Deferred.make(); - const finishTeardown = yield* Deferred.make(); - let startCount = 0; + Effect.gen(function* () { + const starts = yield* Queue.unbounded(); + const teardownStarted = yield* Deferred.make(); + const finishTeardown = yield* Deferred.make(); + let startCount = 0; - const layerSpawner = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.gen(function* () { - const scope = yield* Scope.Scope; - const closed = yield* Deferred.make(); - startCount += 1; - yield* Queue.offer(starts, startCount); - if (startCount === 1) { - yield* Scope.addFinalizer( - scope, - Deferred.succeed(teardownStarted, undefined).pipe( - Effect.andThen(Deferred.await(finishTeardown)), - Effect.andThen(Deferred.succeed(closed, undefined)), - Effect.asVoid, - ), - ); - } else { - yield* Scope.addFinalizer( - scope, - Deferred.succeed(closed, undefined).pipe(Effect.asVoid), - ); - } - return makeProcess({ - exitCode: Deferred.await(closed).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), - }); - }), - ), - ); + const layerSpawner = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.gen(function* () { + const scope = yield* Scope.Scope; + const closed = yield* Deferred.make(); + startCount += 1; + yield* Queue.offer(starts, startCount); + if (startCount === 1) { + yield* Scope.addFinalizer( + scope, + Deferred.succeed(teardownStarted, undefined).pipe( + Effect.andThen(Deferred.await(finishTeardown)), + Effect.andThen(Deferred.succeed(closed, undefined)), + Effect.asVoid, + ), + ); + } else { + yield* Scope.addFinalizer( + scope, + Deferred.succeed(closed, undefined).pipe(Effect.asVoid), + ); + } + return makeProcess({ + exitCode: Deferred.await(closed).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), + }); + }), + ), + ); - const instance = yield* makeTestInstance({ - spawnerLayer: layerSpawner, - httpClientLayer: layerHttpClient(() => Effect.never), - }); + const instance = yield* makeTestInstance({ + spawnerLayer: layerSpawner, + httpClientLayer: layerHttpClient(() => Effect.never), + }); - yield* instance.start; - assert.equal(yield* Queue.take(starts), 1); + yield* instance.start; + assert.equal(yield* Queue.take(starts), 1); - const stopFiber = yield* instance.stop().pipe(Effect.forkChild); - yield* Deferred.await(teardownStarted).pipe(Effect.timeout("1 second")); - yield* instance.start; - assert.equal((yield* instance.snapshot).desiredRunning, true); + const stopFiber = yield* instance.stop().pipe(Effect.forkChild); + yield* Deferred.await(teardownStarted).pipe(Effect.timeout("1 second")); + yield* instance.start; + assert.equal((yield* instance.snapshot).desiredRunning, true); - yield* Deferred.succeed(finishTeardown, undefined); - yield* Fiber.join(stopFiber).pipe(Effect.timeout("1 second")); - yield* TestClock.adjust(Duration.millis(500)); + yield* Deferred.succeed(finishTeardown, undefined); + yield* Fiber.join(stopFiber).pipe(Effect.timeout("1 second")); + yield* TestClock.adjust(Duration.millis(500)); - assert.equal(yield* Queue.take(starts).pipe(Effect.timeout("1 second")), 2); - const restarted = yield* instance.snapshot; - assert.equal(restarted.desiredRunning, true); - assert.deepEqual(restarted.activePid, Option.some(123)); - }).pipe(Effect.provide(TestClock.layer())), - ), + assert.equal(yield* Queue.take(starts).pipe(Effect.timeout("1 second")), 2); + const restarted = yield* instance.snapshot; + assert.equal(restarted.desiredRunning, true); + assert.deepEqual(restarted.activePid, Option.some(123)); + }).pipe(Effect.provide(TestClock.layer())), ); it.effect("reports state-directory ownership once without scheduling another backend", () => - Effect.scoped( - Effect.gen(function* () { - let startCount = 0; - const refused = yield* Deferred.make(); - const instance = yield* makeTestInstance({ - spawnerLayer: Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.sync(() => { - startCount += 1; - return makeProcess({ - exitCode: Effect.succeed( - ChildProcessSpawner.ExitCode(SERVER_EXIT_CODE_STATE_DIR_OWNED), - ), - }); - }), - ), + Effect.gen(function* () { + let startCount = 0; + const refused = yield* Deferred.make(); + const instance = yield* makeTestInstance({ + spawnerLayer: Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.sync(() => { + startCount += 1; + return makeProcess({ + exitCode: Effect.succeed( + ChildProcessSpawner.ExitCode(SERVER_EXIT_CODE_STATE_DIR_OWNED), + ), + }); + }), ), - httpClientLayer: layerHttpClient(() => Effect.never), - onStateDirOwned: Deferred.succeed(refused, undefined).pipe(Effect.asVoid), - }); - yield* instance.start; - yield* Deferred.await(refused); - const snapshot = yield* instance.snapshot; - assert.isFalse(snapshot.desiredRunning); - assert.isFalse(snapshot.restartScheduled); - yield* TestClock.adjust(Duration.minutes(1)); - assert.equal(startCount, 1); - }).pipe(Effect.provide(TestClock.layer())), - ), + ), + httpClientLayer: layerHttpClient(() => Effect.never), + onStateDirOwned: Deferred.succeed(refused, undefined).pipe(Effect.asVoid), + }); + yield* instance.start; + yield* Deferred.await(refused); + const snapshot = yield* instance.snapshot; + assert.isFalse(snapshot.desiredRunning); + assert.isFalse(snapshot.restartScheduled); + yield* TestClock.adjust(Duration.minutes(1)); + assert.equal(startCount, 1); + }).pipe(Effect.provide(TestClock.layer())), ); // A policy refusal is reported on the first exit, since retrying cannot fix it. @@ -637,177 +620,167 @@ describe("DesktopBackendManager", () => { ])( "reports exit $code with the server output after $exits exits", ({ code, exits, managedPolicy }) => - Effect.scoped( - Effect.gen(function* () { - const message = "Error: Cannot find module 'zod/v4' in /Users/René"; - let exitCount = 0; - const firstExit = yield* Deferred.make(); - const reports = yield* Queue.unbounded<{ - readonly exitCount: number; - readonly failure: DesktopBackendManager.BackendStartupFailure; - }>(); - const instance = yield* makeTestInstance({ - spawnerLayer: Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.succeed( - makeProcess({ - // "é" split across chunks must not decode as U+FFFD, and the 4,000-char cut - // lands inside the emoji's surrogate pair. - stderr: Stream.make( - new TextEncoder().encode( - `😀${"x".repeat(3_999 - message.length)}${message.slice(0, -1)}`, - ), - new Uint8Array([0xc3]), - new Uint8Array([0xa9]), + Effect.gen(function* () { + const message = "Error: Cannot find module 'zod/v4' in /Users/René"; + let exitCount = 0; + const firstExit = yield* Deferred.make(); + const reports = yield* Queue.unbounded<{ + readonly exitCount: number; + readonly failure: DesktopBackendManager.BackendStartupFailure; + }>(); + const instance = yield* makeTestInstance({ + spawnerLayer: Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + makeProcess({ + // "é" split across chunks must not decode as U+FFFD, and the 4,000-char cut + // lands inside the emoji's surrogate pair. + stderr: Stream.make( + new TextEncoder().encode( + `😀${"x".repeat(3_999 - message.length)}${message.slice(0, -1)}`, ), - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(code)), - }), - ), + new Uint8Array([0xc3]), + new Uint8Array([0xa9]), + ), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(code)), + }), ), ), - httpClientLayer: layerHttpClient(() => Effect.never), - backendOutputLog: { - persistFailure: () => { - exitCount += 1; - return Deferred.succeed(firstExit, undefined).pipe(Effect.asVoid); - }, + ), + httpClientLayer: layerHttpClient(() => Effect.never), + backendOutputLog: { + persistFailure: () => { + exitCount += 1; + return Deferred.succeed(firstExit, undefined).pipe(Effect.asVoid); }, - onStartupFailure: (failure) => - Queue.offer(reports, { exitCount, failure }).pipe(Effect.asVoid), - }); - yield* instance.start; - yield* Deferred.await(firstExit); - yield* TestClock.adjust(Duration.minutes(1)); - const { exitCount: exitsWhenReported, failure } = yield* Queue.take(reports); - assert.equal(exitsWhenReported, exits); - assert.equal(failure.reason, `The server could not start (code=${code}).`); - assert.equal(failure.managedPolicy, managedPolicy); - assert.isTrue(failure.output.endsWith(message)); - assert.isTrue(failure.output.isWellFormed()); - }).pipe(Effect.provide(TestClock.layer())), - ), + }, + onStartupFailure: (failure) => + Queue.offer(reports, { exitCount, failure }).pipe(Effect.asVoid), + }); + yield* instance.start; + yield* Deferred.await(firstExit); + yield* TestClock.adjust(Duration.minutes(1)); + const { exitCount: exitsWhenReported, failure } = yield* Queue.take(reports); + assert.equal(exitsWhenReported, exits); + assert.equal(failure.reason, `The server could not start (code=${code}).`); + assert.equal(failure.managedPolicy, managedPolicy); + assert.isTrue(failure.output.endsWith(message)); + assert.isTrue(failure.output.isWellFormed()); + }).pipe(Effect.provide(TestClock.layer())), ); it.effect("waits out a slow startup and reports one that never becomes ready", () => - Effect.scoped( - Effect.gen(function* () { - const reports = yield* Queue.unbounded(); - const startInstance = ( - status: Ref.Ref, - onReady?: Effect.Effect, - stdout = "", - ) => - makeTestInstance({ - spawnerLayer: Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.succeed( - makeProcess({ - exitCode: Effect.never, - stdout: Stream.make(new TextEncoder().encode(stdout)), - }), - ), + Effect.gen(function* () { + const reports = yield* Queue.unbounded(); + const startInstance = (status: Ref.Ref, onReady?: Effect.Effect, stdout = "") => + makeTestInstance({ + spawnerLayer: Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + makeProcess({ + exitCode: Effect.never, + stdout: Stream.make(new TextEncoder().encode(stdout)), + }), ), ), - httpClientLayer: layerHttpClient((request) => - Ref.get(status).pipe(Effect.map((code) => responseForRequest(request, code))), - ), - onStartupFailure: (failure) => Queue.offer(reports, failure).pipe(Effect.asVoid), - ...(onReady ? { onReady } : {}), - }).pipe(Effect.tap((instance) => instance.start)); - - const slowStatus = yield* Ref.make(503); - const ready = yield* Deferred.make(); - yield* startInstance(slowStatus, Deferred.succeed(ready, undefined).pipe(Effect.asVoid)); - yield* TestClock.adjust(Duration.seconds(66)); - yield* Ref.set(slowStatus, 200); - yield* TestClock.adjust(Duration.millis(100)); - yield* Deferred.await(ready); - assert.isTrue(Option.isNone(yield* Queue.poll(reports))); - - const migratingStatus = yield* Ref.make(503); - const migrated = yield* Deferred.make(); - yield* startInstance( - migratingStatus, - Deferred.succeed(migrated, undefined).pipe(Effect.asVoid), - // Enough progress lines that the start line scrolls out of the output tail. - `INFO ${DATABASE_MIGRATIONS_STARTED_MESSAGE}\n` + - Array.from( - { length: 80 }, - (_, index) => - `INFO ${DATABASE_MIGRATION_PROGRESS_MESSAGE} migration=${index}_Migration progress="${index + 1} of 80"\n`, - ).join(""), - ); - yield* TestClock.adjust(Duration.minutes(10)); - assert.isTrue(Option.isNone(yield* Queue.poll(reports))); - yield* Ref.set(migratingStatus, 200); - yield* TestClock.adjust(Duration.millis(100)); - yield* Deferred.await(migrated); - - yield* startInstance( - yield* Ref.make(503), - undefined, - `${DATABASE_MIGRATIONS_STARTED_MESSAGE}\n${DATABASE_MIGRATIONS_FINISHED_MESSAGE}\n`, - ); - yield* TestClock.adjust(Duration.seconds(179)); - assert.isTrue(Option.isNone(yield* Queue.poll(reports))); - yield* TestClock.adjust(Duration.seconds(1)); - const failure = yield* Queue.take(reports); - assert.equal(failure.reason, "The server started but did not become ready in time."); - }).pipe(Effect.provide(TestClock.layer())), - ), + ), + httpClientLayer: layerHttpClient((request) => + Ref.get(status).pipe(Effect.map((code) => responseForRequest(request, code))), + ), + onStartupFailure: (failure) => Queue.offer(reports, failure).pipe(Effect.asVoid), + ...(onReady ? { onReady } : {}), + }).pipe(Effect.tap((instance) => instance.start)); + + const slowStatus = yield* Ref.make(503); + const ready = yield* Deferred.make(); + yield* startInstance(slowStatus, Deferred.succeed(ready, undefined).pipe(Effect.asVoid)); + yield* TestClock.adjust(Duration.seconds(66)); + yield* Ref.set(slowStatus, 200); + yield* TestClock.adjust(Duration.millis(100)); + yield* Deferred.await(ready); + assert.isTrue(Option.isNone(yield* Queue.poll(reports))); + + const migratingStatus = yield* Ref.make(503); + const migrated = yield* Deferred.make(); + yield* startInstance( + migratingStatus, + Deferred.succeed(migrated, undefined).pipe(Effect.asVoid), + // Enough progress lines that the start line scrolls out of the output tail. + `INFO ${DATABASE_MIGRATIONS_STARTED_MESSAGE}\n` + + Array.from( + { length: 80 }, + (_, index) => + `INFO ${DATABASE_MIGRATION_PROGRESS_MESSAGE} migration=${index}_Migration progress="${index + 1} of 80"\n`, + ).join(""), + ); + yield* TestClock.adjust(Duration.minutes(10)); + assert.isTrue(Option.isNone(yield* Queue.poll(reports))); + yield* Ref.set(migratingStatus, 200); + yield* TestClock.adjust(Duration.millis(100)); + yield* Deferred.await(migrated); + + yield* startInstance( + yield* Ref.make(503), + undefined, + `${DATABASE_MIGRATIONS_STARTED_MESSAGE}\n${DATABASE_MIGRATIONS_FINISHED_MESSAGE}\n`, + ); + yield* TestClock.adjust(Duration.seconds(179)); + assert.isTrue(Option.isNone(yield* Queue.poll(reports))); + yield* TestClock.adjust(Duration.seconds(1)); + const failure = yield* Queue.take(reports); + assert.equal(failure.reason, "The server started but did not become ready in time."); + }).pipe(Effect.provide(TestClock.layer())), ); it.effect("the quit stop keeps waiting for a backend that outlasts its deadline", () => - Effect.scoped( - Effect.gen(function* () { - const spawned = yield* Deferred.make(); - const teardownStarted = yield* Deferred.make(); - const teardownFinished = yield* Deferred.make(); - const instance = yield* makeTestInstance({ - spawnerLayer: Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.gen(function* () { - const scope = yield* Scope.Scope; - yield* Deferred.succeed(spawned, undefined); - yield* Scope.addFinalizer( - scope, - Deferred.succeed(teardownStarted, undefined).pipe( - Effect.andThen(Effect.sleep(Duration.seconds(10))), - Effect.andThen(Deferred.succeed(teardownFinished, undefined)), - Effect.asVoid, - ), - ); - return makeProcess({ - exitCode: Deferred.await(teardownFinished).pipe( - Effect.as(ChildProcessSpawner.ExitCode(0)), - ), - }); - }), - ), + Effect.gen(function* () { + const spawned = yield* Deferred.make(); + const teardownStarted = yield* Deferred.make(); + const teardownFinished = yield* Deferred.make(); + const instance = yield* makeTestInstance({ + spawnerLayer: Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.gen(function* () { + const scope = yield* Scope.Scope; + yield* Deferred.succeed(spawned, undefined); + yield* Scope.addFinalizer( + scope, + Deferred.succeed(teardownStarted, undefined).pipe( + Effect.andThen(Effect.sleep(Duration.seconds(10))), + Effect.andThen(Deferred.succeed(teardownFinished, undefined)), + Effect.asVoid, + ), + ); + return makeProcess({ + exitCode: Deferred.await(teardownFinished).pipe( + Effect.as(ChildProcessSpawner.ExitCode(0)), + ), + }); + }), ), - httpClientLayer: layerHttpClient(() => Effect.never), - }); - yield* instance.start; - yield* Deferred.await(spawned); - - // App quit and the updater stop every backend with this deadline. - const quitDone = yield* Deferred.make(); - const quitFiber = yield* instance - .stop({ timeout: DesktopBackendManager.BACKEND_QUIT_STOP_TIMEOUT }) - .pipe(Effect.andThen(Deferred.succeed(quitDone, undefined)), Effect.forkChild); - - yield* Deferred.await(teardownStarted); - // Past the quit deadline, quit must still wait for the backend to exit. - yield* TestClock.adjust(Duration.seconds(9)); - assert.isFalse(yield* Deferred.isDone(quitDone)); - - yield* TestClock.adjust(Duration.seconds(1)); - yield* Fiber.join(quitFiber); - assert.isTrue(yield* Deferred.isDone(teardownFinished)); - }).pipe(Effect.provide(TestClock.layer())), - ), + ), + httpClientLayer: layerHttpClient(() => Effect.never), + }); + yield* instance.start; + yield* Deferred.await(spawned); + + // App quit and the updater stop every backend with this deadline. + const quitDone = yield* Deferred.make(); + const quitFiber = yield* instance + .stop({ timeout: DesktopBackendManager.BACKEND_QUIT_STOP_TIMEOUT }) + .pipe(Effect.andThen(Deferred.succeed(quitDone, undefined)), Effect.forkChild); + + yield* Deferred.await(teardownStarted); + // Past the quit deadline, quit must still wait for the backend to exit. + yield* TestClock.adjust(Duration.seconds(9)); + assert.isFalse(yield* Deferred.isDone(quitDone)); + + yield* TestClock.adjust(Duration.seconds(1)); + yield* Fiber.join(quitFiber); + assert.isTrue(yield* Deferred.isDone(teardownFinished)); + }).pipe(Effect.provide(TestClock.layer())), ); }); diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index 3f7d690eab3d3..8e9f4320c08c7 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -324,61 +324,6 @@ describe("DesktopBackendPool", () => { it.effect( "keeps the update marker after HTTP readiness until the backend publishes its runtime", () => - Effect.scoped( - Effect.gen(function* () { - const fixture = yield* makeUpdateMarkerFixture(); - yield* Effect.gen(function* () { - const pool = yield* DesktopBackendPool.DesktopBackendPool; - const primary = yield* pool.primary; - yield* primary.start; - yield* Deferred.succeed(fixture.allowReady, undefined); - yield* Queue.take(fixture.readyRuns); - assert.isTrue( - fixture.files.has(markerPath), - "a launcher could neither wait nor adopt between readiness and publication", - ); - yield* Deferred.await(fixture.runtimeChecked); - yield* TestClock.adjust(Duration.seconds(5)); - assert.isTrue(fixture.files.has(markerPath)); - fixture.publishRuntime(456); - yield* TestClock.adjust(Duration.seconds(1)); - assert.isTrue( - fixture.files.has(markerPath), - "another process's runtime is not the replacement", - ); - fixture.publishRuntime(123); - yield* TestClock.adjust(Duration.seconds(1)); - yield* Deferred.await(fixture.markerRemoved); - assert.deepEqual(fixture.removed, [markerPath]); - }).pipe(Effect.provide(fixture.layer)); - }), - ), - ); - - it.effect("leaves a replacement update marker written while an earlier wait is pending", () => - Effect.scoped( - Effect.gen(function* () { - const fixture = yield* makeUpdateMarkerFixture(); - yield* Effect.gen(function* () { - const pool = yield* DesktopBackendPool.DesktopBackendPool; - const primary = yield* pool.primary; - yield* primary.start; - yield* Deferred.succeed(fixture.allowReady, undefined); - yield* Queue.take(fixture.readyRuns); - yield* Deferred.await(fixture.runtimeChecked); - yield* TestClock.adjust(Duration.millis(DESKTOP_UPDATE_IN_PROGRESS_TTL_MS - 1_000)); - const replacement = JSON.stringify({ startedAt: 59_000, expiresAt: 119_000 }); - fixture.files.set(markerPath, replacement); - yield* TestClock.adjust(Duration.millis(DESKTOP_UPDATE_IN_PROGRESS_TTL_MS)); - assert.equal(fixture.files.get(markerPath), replacement); - assert.deepEqual(fixture.removed, []); - }).pipe(Effect.provide(fixture.layer)); - }), - ), - ); - - it.effect("a later backend run's readiness supersedes the earlier run's marker wait", () => - Effect.scoped( Effect.gen(function* () { const fixture = yield* makeUpdateMarkerFixture(); yield* Effect.gen(function* () { @@ -387,105 +332,150 @@ describe("DesktopBackendPool", () => { yield* primary.start; yield* Deferred.succeed(fixture.allowReady, undefined); yield* Queue.take(fixture.readyRuns); + assert.isTrue( + fixture.files.has(markerPath), + "a launcher could neither wait nor adopt between readiness and publication", + ); yield* Deferred.await(fixture.runtimeChecked); - yield* TestClock.adjust(Duration.seconds(30)); - yield* primary.stop(); - yield* primary.start; - yield* Queue.take(fixture.readyRuns); - yield* TestClock.adjust(Duration.seconds(31)); + yield* TestClock.adjust(Duration.seconds(5)); + assert.isTrue(fixture.files.has(markerPath)); + fixture.publishRuntime(456); + yield* TestClock.adjust(Duration.seconds(1)); assert.isTrue( fixture.files.has(markerPath), - "the earlier run's wait must not end the later run's handoff", + "another process's runtime is not the replacement", ); - assert.deepEqual(fixture.removed, []); - fixture.publishRuntime(124); - yield* TestClock.adjust(Duration.millis(100)); + fixture.publishRuntime(123); + yield* TestClock.adjust(Duration.seconds(1)); yield* Deferred.await(fixture.markerRemoved); assert.deepEqual(fixture.removed, [markerPath]); }).pipe(Effect.provide(fixture.layer)); }), - ), ); - it.effect("unregister during pool quit does not interrupt sibling stops", () => - Effect.scoped( - Effect.gen(function* () { - const backend = yield* makeFakeBackendProcess(); - yield* Effect.gen(function* () { - const pool = yield* DesktopBackendPool.DesktopBackendPool; - const id = DesktopBackendPool.BackendInstanceId("wsl:Ubuntu"); - const ready = yield* Deferred.make(); - const siblingStarted = yield* Deferred.make(); - const finishSibling = yield* Deferred.make(); - let siblingCompleted = false; - const instance = yield* pool.register({ - id, - label: Effect.succeed("WSL (Ubuntu)"), - configResolve: Effect.succeed(runningConfig), - onReady: () => Deferred.succeed(ready, undefined).pipe(Effect.asVoid), - }); - yield* instance.start; - yield* backend.started; - yield* Deferred.await(ready); - const quitFiber = yield* Effect.all( - [ - instance.stop({ timeout: Duration.seconds(5) }), - Deferred.succeed(siblingStarted, undefined).pipe( - Effect.andThen(Deferred.await(finishSibling)), - Effect.andThen( - Effect.sync(() => { - siblingCompleted = true; - }), - ), - ), - ], - { concurrency: "unbounded" }, - ).pipe(Effect.forkChild); - yield* Deferred.await(siblingStarted); - assert.equal(yield* Queue.take(backend.signals), "SIGTERM"); - const unregisterFiber = yield* pool.unregister(id).pipe(Effect.forkChild); - yield* TestClock.adjust(Duration.seconds(5)); - assert.equal(yield* Queue.take(backend.signals), "SIGKILL"); - assert.isUndefined(quitFiber.pollUnsafe()); - assert.isUndefined(unregisterFiber.pollUnsafe()); - assert.isTrue(yield* backend.isRunning); - yield* TestClock.adjust(backend.killWait); - yield* Fiber.join(unregisterFiber); - yield* Deferred.succeed(finishSibling, undefined); - yield* Fiber.join(quitFiber); + it.effect("leaves a replacement update marker written while an earlier wait is pending", () => + Effect.gen(function* () { + const fixture = yield* makeUpdateMarkerFixture(); + yield* Effect.gen(function* () { + const pool = yield* DesktopBackendPool.DesktopBackendPool; + const primary = yield* pool.primary; + yield* primary.start; + yield* Deferred.succeed(fixture.allowReady, undefined); + yield* Queue.take(fixture.readyRuns); + yield* Deferred.await(fixture.runtimeChecked); + yield* TestClock.adjust(Duration.millis(DESKTOP_UPDATE_IN_PROGRESS_TTL_MS - 1_000)); + const replacement = JSON.stringify({ startedAt: 59_000, expiresAt: 119_000 }); + fixture.files.set(markerPath, replacement); + yield* TestClock.adjust(Duration.millis(DESKTOP_UPDATE_IN_PROGRESS_TTL_MS)); + assert.equal(fixture.files.get(markerPath), replacement); + assert.deepEqual(fixture.removed, []); + }).pipe(Effect.provide(fixture.layer)); + }), + ); - assert.isTrue(siblingCompleted); - assert.isFalse(yield* backend.isRunning); - assert.isTrue(Option.isNone((yield* instance.snapshot).activePid)); - assert.isTrue(Option.isNone(yield* pool.get(id))); - }).pipe(Effect.provide(layerPool(Ref.makeUnsafe("Windows"), { backend }))); - }), - ), + it.effect("a later backend run's readiness supersedes the earlier run's marker wait", () => + Effect.gen(function* () { + const fixture = yield* makeUpdateMarkerFixture(); + yield* Effect.gen(function* () { + const pool = yield* DesktopBackendPool.DesktopBackendPool; + const primary = yield* pool.primary; + yield* primary.start; + yield* Deferred.succeed(fixture.allowReady, undefined); + yield* Queue.take(fixture.readyRuns); + yield* Deferred.await(fixture.runtimeChecked); + yield* TestClock.adjust(Duration.seconds(30)); + yield* primary.stop(); + yield* primary.start; + yield* Queue.take(fixture.readyRuns); + yield* TestClock.adjust(Duration.seconds(31)); + assert.isTrue( + fixture.files.has(markerPath), + "the earlier run's wait must not end the later run's handoff", + ); + assert.deepEqual(fixture.removed, []); + fixture.publishRuntime(124); + yield* TestClock.adjust(Duration.millis(100)); + yield* Deferred.await(fixture.markerRemoved); + assert.deepEqual(fixture.removed, [markerPath]); + }).pipe(Effect.provide(fixture.layer)); + }), ); - it.effect("unregister stops the registered instance", () => - Effect.scoped( - Effect.gen(function* () { - const id = DesktopBackendPool.BackendInstanceId("wsl:Ubuntu"); + it.effect("unregister during pool quit does not interrupt sibling stops", () => + Effect.gen(function* () { + const backend = yield* makeFakeBackendProcess(); + yield* Effect.gen(function* () { const pool = yield* DesktopBackendPool.DesktopBackendPool; + const id = DesktopBackendPool.BackendInstanceId("wsl:Ubuntu"); + const ready = yield* Deferred.make(); + const siblingStarted = yield* Deferred.make(); + const finishSibling = yield* Deferred.make(); + let siblingCompleted = false; const instance = yield* pool.register({ id, label: Effect.succeed("WSL (Ubuntu)"), - configResolve: Effect.succeed({ - ...runningConfig, - preflightFailure: Option.some({ reason: "WSL starting", fatal: false }), - }), + configResolve: Effect.succeed(runningConfig), + onReady: () => Deferred.succeed(ready, undefined).pipe(Effect.asVoid), }); yield* instance.start; - assert.equal((yield* instance.snapshot).restartScheduled, true); + yield* backend.started; + yield* Deferred.await(ready); + const quitFiber = yield* Effect.all( + [ + instance.stop({ timeout: Duration.seconds(5) }), + Deferred.succeed(siblingStarted, undefined).pipe( + Effect.andThen(Deferred.await(finishSibling)), + Effect.andThen( + Effect.sync(() => { + siblingCompleted = true; + }), + ), + ), + ], + { concurrency: "unbounded" }, + ).pipe(Effect.forkChild); + yield* Deferred.await(siblingStarted); + assert.equal(yield* Queue.take(backend.signals), "SIGTERM"); + const unregisterFiber = yield* pool.unregister(id).pipe(Effect.forkChild); + yield* TestClock.adjust(Duration.seconds(5)); + assert.equal(yield* Queue.take(backend.signals), "SIGKILL"); + assert.isUndefined(quitFiber.pollUnsafe()); + assert.isUndefined(unregisterFiber.pollUnsafe()); + assert.isTrue(yield* backend.isRunning); + yield* TestClock.adjust(backend.killWait); + yield* Fiber.join(unregisterFiber); + yield* Deferred.succeed(finishSibling, undefined); + yield* Fiber.join(quitFiber); - yield* pool.unregister(id); + assert.isTrue(siblingCompleted); + assert.isFalse(yield* backend.isRunning); + assert.isTrue(Option.isNone((yield* instance.snapshot).activePid)); + assert.isTrue(Option.isNone(yield* pool.get(id))); + }).pipe(Effect.provide(layerPool(Ref.makeUnsafe("Windows"), { backend }))); + }), + ); - const stopped = yield* instance.snapshot; - assert.equal(stopped.desiredRunning, false); - assert.equal(stopped.restartScheduled, false); - assert.equal(Option.isNone(yield* pool.get(id)), true); - }).pipe(Effect.provide(layerPool(Ref.makeUnsafe("Windows")))), - ), + it.effect("unregister stops the registered instance", () => + Effect.gen(function* () { + const id = DesktopBackendPool.BackendInstanceId("wsl:Ubuntu"); + const pool = yield* DesktopBackendPool.DesktopBackendPool; + const instance = yield* pool.register({ + id, + label: Effect.succeed("WSL (Ubuntu)"), + configResolve: Effect.succeed({ + ...runningConfig, + preflightFailure: Option.some({ reason: "WSL starting", fatal: false }), + }), + }); + yield* instance.start; + assert.equal((yield* instance.snapshot).restartScheduled, true); + + yield* pool.unregister(id); + + const stopped = yield* instance.snapshot; + assert.equal(stopped.desiredRunning, false); + assert.equal(stopped.restartScheduled, false); + assert.equal(Option.isNone(yield* pool.get(id)), true); + }).pipe(Effect.provide(layerPool(Ref.makeUnsafe("Windows")))), ); }); diff --git a/apps/desktop/src/electron/ElectronProtocol.test.ts b/apps/desktop/src/electron/ElectronProtocol.test.ts index aec3a2d5b45c0..b0cf4823126e2 100644 --- a/apps/desktop/src/electron/ElectronProtocol.test.ts +++ b/apps/desktop/src/electron/ElectronProtocol.test.ts @@ -63,7 +63,7 @@ describe("ElectronProtocol", () => { assert.equal((yield* request("/%invalid")).status, 400); assert.equal((yield* request("/", { method: "POST" })).status, 405); assert.equal(netFetchMock.mock.calls.length, 0); - }).pipe(Effect.provide(Layer.merge(layerProtocol, NodeServices.layer)), Effect.scoped), + }).pipe(Effect.provide(Layer.merge(layerProtocol, NodeServices.layer))), ); it.effect("proxies the stable renderer origin to the current app server", () => diff --git a/apps/desktop/src/electron/ElectronTheme.test.ts b/apps/desktop/src/electron/ElectronTheme.test.ts index 4239bfe4c5bca..47aa0ce1f8df8 100644 --- a/apps/desktop/src/electron/ElectronTheme.test.ts +++ b/apps/desktop/src/electron/ElectronTheme.test.ts @@ -42,7 +42,7 @@ describe("ElectronTheme", () => { assert.equal(nativeTheme.themeSource, "light"); portal.report(null); assert.equal(nativeTheme.themeSource, "system"); - }).pipe(Effect.scoped), + }), ); it.effect("keeps an explicit appearance over the desktop color scheme", () => @@ -57,7 +57,7 @@ describe("ElectronTheme", () => { yield* theme.setSource("system"); assert.equal(nativeTheme.themeSource, "dark"); - }).pipe(Effect.scoped), + }), ); it.effect("stops following the desktop when the layer is released", () => @@ -76,6 +76,6 @@ describe("ElectronTheme", () => { }); yield* theme.setSource("system"); assert.equal(nativeTheme.themeSource, "system"); - }).pipe(Effect.scoped), + }), ); }); diff --git a/apps/desktop/src/ipc/DesktopIpc.test.ts b/apps/desktop/src/ipc/DesktopIpc.test.ts index 45f63b951f366..1973ab49cefeb 100644 --- a/apps/desktop/src/ipc/DesktopIpc.test.ts +++ b/apps/desktop/src/ipc/DesktopIpc.test.ts @@ -63,5 +63,5 @@ it.effect("answers only the main window's top frame", () => appSubframe: ["rejected", undefined], previewGuest: ["rejected", undefined], }); - }).pipe(Effect.scoped), + }), ); diff --git a/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts b/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts index 6309c4ea993f1..484ba6f9358f6 100644 --- a/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts +++ b/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts @@ -89,7 +89,7 @@ describe("BrowserImport.importCookies", () => { assert.instanceOf(error, BrowserImport.BrowserImportFailedError); assert.equal(error.reason, "unknownSourceProfile"); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + }).pipe(Effect.provide(NodeServices.layer)), ); it.effect.skipIf(!symlinksSupported)( @@ -114,7 +114,7 @@ describe("BrowserImport.importCookies", () => { assert.instanceOf(error, BrowserImport.BrowserImportFailedError); assert.equal(error.reason, "browserRunning"); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + }).pipe(Effect.provide(NodeServices.layer)), ); }); diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts index e5aa05f268e43..92ab35320dab7 100644 --- a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts +++ b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts @@ -134,7 +134,7 @@ describe("readChromiumCookieDatabase", () => { const partitioned = yield* readChromiumCookieDatabase(filename, keys, "linux"); expect(partitioned.cookies).toEqual([]); expect(partitioned.undecryptable).toBe(1); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + }).pipe(Effect.provide(NodeServices.layer)), ); it.effect("reads plaintext, encrypted, and genuinely empty cookie values", () => @@ -186,7 +186,7 @@ describe("readChromiumCookieDatabase", () => { { name: "encrypted", value: "stored encrypted" }, { name: "empty", value: "" }, ]); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + }).pipe(Effect.provide(NodeServices.layer)), ); it.effect("enforces domain binding only for schema 24 and newer", () => @@ -227,7 +227,7 @@ describe("readChromiumCookieDatabase", () => { ]); expect(result.undecryptable).toBe(2); expect(result.undecryptableHosts).toEqual(["wrong.example", "short.example"]); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + }).pipe(Effect.provide(NodeServices.layer)), ); it.effect("decrypts mixed v10 and v11 cookies with their respective keys", () => @@ -273,7 +273,7 @@ describe("readChromiumCookieDatabase", () => { ]); expect(v10Only.undecryptable).toBe(1); expect(v10Only.undecryptableHosts).toEqual(["v11.example"]); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + }).pipe(Effect.provide(NodeServices.layer)), ); it.effect("recovers records written with the empty-passphrase key", () => @@ -320,7 +320,7 @@ describe("readChromiumCookieDatabase", () => { const noV11 = yield* readChromiumCookieDatabase(filename, { cbcV10, cbcEmpty }, "linux"); expect(noV11.cookies.map(({ name }) => name)).toEqual(["empty-v10"]); expect(noV11.undecryptableHosts).toEqual(["ev11.example"]); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + }).pipe(Effect.provide(NodeServices.layer)), ); it.effect("treats unversioned encrypted values as legacy plaintext on macOS and Linux", () => @@ -355,7 +355,7 @@ describe("readChromiumCookieDatabase", () => { expect(mac.undecryptable).toBe(0); expect(linux.cookies[0]?.value).toBe("legacy cleartext"); expect(linux.undecryptable).toBe(0); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + }).pipe(Effect.provide(NodeServices.layer)), ); it.effect("skips partitioned cookies without breaking pre-CHIPS schemas", () => @@ -409,6 +409,6 @@ describe("readChromiumCookieDatabase", () => { expect(chips.cookies.map(({ name }) => name)).toEqual(["plain"]); expect(chips.undecryptable).toBe(1); expect(chips.undecryptableHosts).toEqual(["partitioned.example"]); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + }).pipe(Effect.provide(NodeServices.layer)), ); }); diff --git a/apps/desktop/src/preview/BrowserImport/CookieDatabase.test.ts b/apps/desktop/src/preview/BrowserImport/CookieDatabase.test.ts index 402397d3fde60..ca0a86cedacd3 100644 --- a/apps/desktop/src/preview/BrowserImport/CookieDatabase.test.ts +++ b/apps/desktop/src/preview/BrowserImport/CookieDatabase.test.ts @@ -11,7 +11,7 @@ import { snapshotCookieDatabase } from "./CookieDatabase.ts"; const runNode = ( effect: Effect.Effect, -) => effect.pipe(Effect.provide(NodeServices.layer), Effect.scoped); +) => effect.pipe(Effect.provide(NodeServices.layer)); describe("snapshotCookieDatabase", () => { it.effect("includes committed WAL data in one consistent database", () => diff --git a/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts index be41318fa01e6..33e32ec030be3 100644 --- a/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts +++ b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts @@ -78,7 +78,7 @@ const writeFirefoxCookieDatabase = Effect.fnUntraced(function* ( }); const run = (effect: Effect.Effect) => - effect.pipe(Effect.provide(NodeServices.layer), Effect.scoped); + effect.pipe(Effect.provide(NodeServices.layer)); describe("readFirefoxCookies", () => { it.effect("converts millisecond expiries from schema 16 and newer", () => diff --git a/apps/desktop/src/preview/BrowserImport/LinuxBrowserSecret.test.ts b/apps/desktop/src/preview/BrowserImport/LinuxBrowserSecret.test.ts index 8358088bc1344..8ef376e4887bf 100644 --- a/apps/desktop/src/preview/BrowserImport/LinuxBrowserSecret.test.ts +++ b/apps/desktop/src/preview/BrowserImport/LinuxBrowserSecret.test.ts @@ -64,6 +64,6 @@ it.layer(NodeServices.layer)("Linux browser secret path", (it) => { assert.isUndefined(yield* resolve(false, "win32")); yield* fileSystem.remove(staged); assert.isUndefined(yield* resolve(false)); - }).pipe(Effect.scoped), + }), ); }); diff --git a/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts b/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts index 7e92334c957de..f82f2319baeb7 100644 --- a/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts +++ b/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts @@ -240,7 +240,7 @@ describe("safariAccessDenied", () => { yield* fileSystem.writeFile(jar, new Uint8Array([0x63, 0x6f, 0x6f, 0x6b])); assert.isFalse(yield* safariAccessDenied(jar)); assert.isFalse(yield* safariAccessDenied(`${directory}/absent.binarycookies`)); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + }).pipe(Effect.provide(NodeServices.layer)), ); }); diff --git a/apps/desktop/src/preview/BrowserImport/SafariPermission.test.ts b/apps/desktop/src/preview/BrowserImport/SafariPermission.test.ts index 3b33f872d124d..dc225b04bac72 100644 --- a/apps/desktop/src/preview/BrowserImport/SafariPermission.test.ts +++ b/apps/desktop/src/preview/BrowserImport/SafariPermission.test.ts @@ -39,7 +39,7 @@ it.effect("detects Safari access becoming available without reading or importing assert.isTrue(yield* Effect.promise(check)); yield* fs.remove(jar); assert.isFalse(yield* Effect.promise(check)); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + }).pipe(Effect.provide(NodeServices.layer)), ); it.effect("recognizes access when cookies exist only in a named Safari profile", () => @@ -55,5 +55,5 @@ it.effect("recognizes access when cookies exist only in a named Safari profile", yield* fs.makeDirectory(directory, { recursive: true }); yield* fs.writeFileString(`${directory}/Cookies.binarycookies`, "not parsed"); assert.isTrue(yield* Effect.promise(check)); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + }).pipe(Effect.provide(NodeServices.layer)), ); diff --git a/apps/desktop/src/preview/BrowserImport/Sources.test.ts b/apps/desktop/src/preview/BrowserImport/Sources.test.ts index 720a57e64a9dc..9b2c3cb26c8f7 100644 --- a/apps/desktop/src/preview/BrowserImport/Sources.test.ts +++ b/apps/desktop/src/preview/BrowserImport/Sources.test.ts @@ -51,7 +51,7 @@ const run = ( E, FileSystem.FileSystem | Path.Path | Scope.Scope | ChildProcessSpawner.ChildProcessSpawner >, -) => effect.pipe(Effect.provide(NodeServices.layer), Effect.scoped); +) => effect.pipe(Effect.provide(NodeServices.layer)); const writeCookieDatabase = (file: string, count: number) => Effect.sync(() => { diff --git a/apps/desktop/src/preview/DesktopBrowserHost.test.ts b/apps/desktop/src/preview/DesktopBrowserHost.test.ts index fbb475be1424b..393e4cccccf9c 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.test.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.test.ts @@ -308,7 +308,7 @@ describe("DesktopBrowserHost", () => { expect(reply).toMatchObject({ type: "cdp" }); expect(decodeCdpReply((reply as { message: string }).message).id).toBe(2); - }).pipe(Effect.scoped), + }), ); it.effect("sends a page one screenshot at a time", () => @@ -327,7 +327,7 @@ describe("DesktopBrowserHost", () => { debuggee.releaseFirst(); expect(yield* Queue.take(replies)).toBe(1); expect(debuggee.sent).toEqual([1, 3, 2]); - }).pipe(Effect.scoped), + }), ); it.effect("stops waiting on a screenshot the page navigated away from", () => @@ -352,7 +352,7 @@ describe("DesktopBrowserHost", () => { debuggee.releaseFirst(); expect(yield* Queue.take(replies)).toBe(2); expect(debuggee.sent).toEqual([1, 2, 3]); - }).pipe(Effect.scoped), + }), ); it.effect("drops screenshots a released relay left waiting", () => @@ -424,7 +424,7 @@ describe("DesktopBrowserHost", () => { expect(yield* Queue.take(replies)).toBe(1); yield* TestClock.adjust("1 minute"); expect(debuggee.focusEmulation).toEqual([true, false]); - }).pipe(Effect.scoped), + }), ); it.effect("lets a guest throttle once Chromium can no longer answer its pending command", () => @@ -612,7 +612,7 @@ describe("DesktopBrowserHost", () => { yield* turn(false); yield* TestClock.adjust("1 minute"); expect(debuggee.focusEmulation).toEqual([true, false, true]); - }).pipe(Effect.scoped), + }), ); it.effect("paints for a screenshot queued behind one its viewer abandoned mid-capture", () => @@ -650,7 +650,7 @@ describe("DesktopBrowserHost", () => { expect(yield* Queue.take(replies)).toBe(4); yield* settle; expect(painting).toEqual([true, false]); - }).pipe(Effect.scoped), + }), ); it.effect("stops painting for a queued screenshot once its session detaches", () => @@ -689,7 +689,7 @@ describe("DesktopBrowserHost", () => { expect(painting).toEqual([true, false]); yield* TestClock.adjust("1 minute"); expect(debuggee.focusEmulation).toEqual([true, false]); - }).pipe(Effect.scoped), + }), ); it.effect("never sends a screenshot whose session detached while focus was changing", () => @@ -713,7 +713,7 @@ describe("DesktopBrowserHost", () => { debuggee.settleFocus(); yield* settle; expect(debuggee.sent).toEqual([]); - }).pipe(Effect.scoped), + }), ); it.effect("holds no focus for a screenshot still queued when the agent's turn ends", () => @@ -743,7 +743,7 @@ describe("DesktopBrowserHost", () => { debuggee.release(); yield* settle; expect(painting).toEqual([true, false]); - }).pipe(Effect.scoped), + }), ); it.effect("lets a guest throttle when Chromium refuses a viewer's screencast", () => @@ -764,7 +764,7 @@ describe("DesktopBrowserHost", () => { expect(painting).toEqual([true, false]); yield* TestClock.adjust("1 minute"); expect(debuggee.focusEmulation).toEqual([true, false]); - }).pipe(Effect.scoped), + }), ); it.effect("lets a guest throttle after a person's input through a viewer that then closed", () => @@ -793,7 +793,7 @@ describe("DesktopBrowserHost", () => { yield* settle; yield* TestClock.adjust("1 minute"); expect(debuggee.focusEmulation).toEqual([true, false]); - }).pipe(Effect.scoped), + }), ); it.effect("keeps a page's screencast when a child session's screencast is refused", () => @@ -815,7 +815,7 @@ describe("DesktopBrowserHost", () => { expect(yield* Queue.take(replies)).toBe(2); yield* TestClock.adjust("1 minute"); expect(debuggee.focusEmulation).toEqual([true]); - }).pipe(Effect.scoped), + }), ); it.effect("ignores a screencast that arrives on a session after it detached", () => @@ -841,7 +841,7 @@ describe("DesktopBrowserHost", () => { yield* settle; yield* TestClock.adjust("1 minute"); expect(debuggee.focusEmulation).toEqual([true, false]); - }).pipe(Effect.scoped), + }), ); it.effect("lets a guest throttle once the session waiting on its command detaches", () => @@ -868,7 +868,7 @@ describe("DesktopBrowserHost", () => { }); yield* TestClock.adjust("1 minute"); expect(debuggee.focusEmulation).toEqual([true, false]); - }).pipe(Effect.scoped), + }), ); it.effect("never sends a screenshot queued by a session that has since detached", () => @@ -899,7 +899,7 @@ describe("DesktopBrowserHost", () => { yield* TestClock.adjust("1 minute"); expect(debuggee.focusEmulation).toEqual([true, false]); - }).pipe(Effect.scoped), + }), ); it.effect("keeps a streamed guest focused until its screencast ends", () => @@ -960,7 +960,7 @@ describe("DesktopBrowserHost", () => { }); yield* TestClock.adjust("1 minute"); expect(debuggee.focusEmulation).toEqual([true, false]); - }).pipe(Effect.scoped), + }), ); it.effect("paints a hidden guest only while the screenshot it is taking is pending", () => @@ -986,7 +986,7 @@ describe("DesktopBrowserHost", () => { debuggee.release(); yield* settle; expect(painting).toEqual([true, false]); - }).pipe(Effect.scoped), + }), ); it.effect("stops painting for a screenshot Chromium never answers once its document goes", () => @@ -1031,7 +1031,7 @@ describe("DesktopBrowserHost", () => { host.detach(key); yield* settle; expect(painting).toEqual([true, false, true, false, true, false, true, false]); - }).pipe(Effect.scoped), + }), ); it.effect("paints a streamed guest until its last screencast ends", () => @@ -1062,7 +1062,7 @@ describe("DesktopBrowserHost", () => { }); yield* settle; expect(painting).toEqual([true, false]); - }).pipe(Effect.scoped), + }), ); it.effect("keeps a guest's one screencast running until its last streaming session stops", () => @@ -1099,7 +1099,7 @@ describe("DesktopBrowserHost", () => { yield* raw({ id: 6, method: "Page.stopScreencast", sessionId: "t3-preview-page-2" }); yield* settle; expect(nativeStops()).toBe(1); - }).pipe(Effect.scoped), + }), ); it.effect("keeps a session's screencast when another of its starts is refused", () => @@ -1162,7 +1162,7 @@ describe("DesktopBrowserHost", () => { debuggee.emit("Page.screencastFrame", { data: "frame", sessionId: 2 }); yield* settle; expect(frames).toEqual(["t3-preview-page-1", "t3-preview-page-2"]); - }).pipe(Effect.scoped), + }), ); it.effect("stops a guest's screencast once its last streaming session detaches", () => @@ -1201,7 +1201,7 @@ describe("DesktopBrowserHost", () => { expect(answered.toSorted()).toEqual([3, 4, 5, 6]); yield* settle; expect(nativeStops()).toBe(1); - }).pipe(Effect.scoped), + }), ); it.effect("stops a screencast whose start lands after its session detached", () => @@ -1231,7 +1231,7 @@ describe("DesktopBrowserHost", () => { expect(debuggee.methods.at(-1), "left the late screencast running").toBe( "Page.stopScreencast", ); - }).pipe(Effect.scoped), + }), ); it.effect("saves a server tab's download under its CDP guid where the server asked", () => @@ -1266,7 +1266,7 @@ describe("DesktopBrowserHost", () => { expect(host.placeDownload(debuggee.tab.webContents, item)).toBe(true); expect(paths).toEqual(["/srv/downloads/guid-1"]); expect(host.placeDownload(debuggee.tab.webContents, item)).toBe(false); - }).pipe(Effect.scoped), + }), ); it.effect("tells a download the person clicked from one the agent's input started", () => @@ -1364,6 +1364,6 @@ describe("DesktopBrowserHost", () => { expect(JSON.parse((next as { message: string }).message)).toMatchObject({ method: "Page.loadEventFired", }); - }).pipe(Effect.scoped), + }), ); }); diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 9e263eb6360c2..b344b1ac93b2b 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -671,7 +671,7 @@ describe("PreviewManager", () => { if (existing) { expect(yield* fs.readFileString(`${downloads}/report.txt`)).toBe("existing download"); } - }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), Effect.scoped), + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); effectIt.effect.each([ @@ -2071,7 +2071,7 @@ describe("PreviewManager", () => { expect(yield* nextType).toBe("attached"); yield* manager.closeTab("tab_1"); expect(yield* nextType).toBe("detached"); - }).pipe(Effect.provide(managerLayer()), Effect.scoped), + }).pipe(Effect.provide(managerLayer())), ); effectIt.effect( @@ -2130,7 +2130,7 @@ describe("PreviewManager", () => { expect(yield* Queue.take(capturing)).toBe(true); screenshot.resolve({ data: "" }); expect(yield* Queue.take(capturing)).toBe(false); - }).pipe(Effect.provide(managerLayer()), Effect.scoped), + }).pipe(Effect.provide(managerLayer())), ); effectIt.effect("reports early and subsequent load failures for the current guest", () => @@ -2221,7 +2221,7 @@ describe("PreviewManager", () => { expect(JSON.parse(yield* Queue.take(lines))).toEqual({ type: "detached", ...serverTab }); expect(JSON.parse(yield* Queue.take(lines))).toEqual({ type: "attached", ...serverTab }); expect(statuses.at(-1)?.kind).toBe("Success"); - }).pipe(Effect.provide(managerLayer()), Effect.scoped), + }).pipe(Effect.provide(managerLayer())), ); effectIt.effect("discards a screenshot that resolves after its guest is replaced", () => diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 1ab9b3159e1f9..90c8740f83725 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -110,7 +110,7 @@ const withClientSettings = ( prefix: "t3-desktop-client-settings-test-", }); return yield* effect.pipe(Effect.provide(layer(baseDir))); - }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), Effect.scoped); + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))); describe("DesktopClientSettings", () => { it.effect("saves through a symlinked client settings file without replacing the link", () => diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts index 0001548f4534f..7be39dbe1562d 100644 --- a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts +++ b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts @@ -150,7 +150,7 @@ const withSavedEnvironments = ( prefix: "t3-desktop-saved-environments-test-", }); return yield* effect.pipe(Effect.provide(layer(baseDir, options))); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); + }).pipe(Effect.provide(NodeServices.layer)); describe("DesktopSavedEnvironments", () => { it.effect("loads lenient saved environment registry documents", () => diff --git a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts index 1e5b6dafc6cae..15977b34f398b 100644 --- a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts +++ b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts @@ -144,7 +144,7 @@ describe("DesktopSshPasswordPrompts", () => { yield* prompts.resolve({ requestId: request.requestId, password: "secret" }); assert.equal(yield* Fiber.join(fiber), "secret"); - }).pipe(Effect.provide(layer(testWindow.window)), Effect.scoped); + }).pipe(Effect.provide(layer(testWindow.window))); }); it.effect.each(["answered", "cancelled", "timed out", "interrupted"] as const)( @@ -189,7 +189,7 @@ describe("DesktopSshPasswordPrompts", () => { .map((message) => message.args), [[request.requestId]], ); - }).pipe(Effect.provide(layer(testWindow.window)), Effect.scoped); + }).pipe(Effect.provide(layer(testWindow.window))); }, ); @@ -219,6 +219,6 @@ describe("DesktopSshPasswordPrompts", () => { assert.equal(password, "secret"); assert.equal(testWindow.isFocused(), false); assert.equal(testWindow.closedListenerCount(), 0); - }).pipe(Effect.provide(layer(testWindow.window)), Effect.scoped); + }).pipe(Effect.provide(layer(testWindow.window))); }); }); diff --git a/apps/desktop/src/wsl/DesktopWslServerTree.test.ts b/apps/desktop/src/wsl/DesktopWslServerTree.test.ts index 7eaf362a326aa..34dcbea08a0ee 100644 --- a/apps/desktop/src/wsl/DesktopWslServerTree.test.ts +++ b/apps/desktop/src/wsl/DesktopWslServerTree.test.ts @@ -6,7 +6,6 @@ import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Ref from "effect/Ref"; -import * as Scope from "effect/Scope"; import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; @@ -42,20 +41,14 @@ const layerEnvironment = (input: { ), ); -const withTempDir = ( - run: (tempDir: string) => Effect.Effect, -): Effect.Effect< - A, - E | PlatformError.PlatformError, - FileSystem.FileSystem | Exclude -> => +const withTempDir = (run: (tempDir: string) => Effect.Effect) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const tempDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-wsl-server-tree-test-", }); return yield* run(tempDir); - }).pipe(Effect.scoped); + }); const ensureWith = (input: { readonly baseDir: string; diff --git a/apps/mobile/src/native/nativeWebSocket.ios.test.ts b/apps/mobile/src/native/nativeWebSocket.ios.test.ts index 0a1a38850b554..8b2b128b5f7f8 100644 --- a/apps/mobile/src/native/nativeWebSocket.ios.test.ts +++ b/apps/mobile/src/native/nativeWebSocket.ios.test.ts @@ -126,6 +126,6 @@ describe("NativeWebSocket", () => { code: 1011, closeReason: "boom", }); - }).pipe(Effect.scoped), + }), ); }); diff --git a/apps/mobile/src/persistence/mobile-database.test.ts b/apps/mobile/src/persistence/mobile-database.test.ts index a30717f7e7cb4..a0b9985cf05ba 100644 --- a/apps/mobile/src/persistence/mobile-database.test.ts +++ b/apps/mobile/src/persistence/mobile-database.test.ts @@ -39,23 +39,21 @@ describe("offline pull request cache", () => { } it.effect("restores saved records and clears them with the environment", () => - Effect.scoped( - Effect.gen(function* () { - yield* Effect.promise(sqliteDatabase); - const database = yield* MobileDatabase.make; - yield* database.saveCache(environmentId, "pull-request", "v1:list", 1, '{"entries":[]}'); - yield* database.saveCache(otherEnvironment, "pull-request", "v1:list", 1, "other"); - expect(yield* database.loadCache(environmentId, "pull-request", "v1:list")).toEqual( - Option.some('{"entries":[]}'), - ); - yield* database.clearEnvironmentCache(environmentId); - expect((yield* database.loadCache(environmentId, "pull-request", "v1:list"))._tag).toBe( - "None", - ); - expect((yield* database.loadCache(otherEnvironment, "pull-request", "v1:list"))._tag).toBe( - "Some", - ); - }), - ), + Effect.gen(function* () { + yield* Effect.promise(sqliteDatabase); + const database = yield* MobileDatabase.make; + yield* database.saveCache(environmentId, "pull-request", "v1:list", 1, '{"entries":[]}'); + yield* database.saveCache(otherEnvironment, "pull-request", "v1:list", 1, "other"); + expect(yield* database.loadCache(environmentId, "pull-request", "v1:list")).toEqual( + Option.some('{"entries":[]}'), + ); + yield* database.clearEnvironmentCache(environmentId); + expect((yield* database.loadCache(environmentId, "pull-request", "v1:list"))._tag).toBe( + "None", + ); + expect((yield* database.loadCache(otherEnvironment, "pull-request", "v1:list"))._tag).toBe( + "Some", + ); + }), ); }); diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index d762a15a830f8..681af43bdf347 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -1566,10 +1566,7 @@ describe("AssetAccess", () => { // A pull request's own repository wins over the checkout it is shown in. yield* sourceControlMediaResponse({ ...asset, repository: "enterprise/private" }, {}); expect(fetched.at(-1)?.repository).toBe("enterprise/private"); - }).pipe( - Effect.provide(Layer.mergeAll(layerTest, layerDrivers, layerIdentities)), - Effect.scoped, - ); + }).pipe(Effect.provide(Layer.mergeAll(layerTest, layerDrivers, layerIdentities))); }); it.effect("refuses media no host keeps behind a credential, without echoing the URL", () => diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index aab7445e9577e..88596577db3c6 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -135,7 +135,7 @@ describe("RPC scope middleware", () => { requiredPermission: missing, }); expect(handled).toEqual([]); - }).pipe(Effect.scoped), + }), ); }); @@ -191,7 +191,7 @@ it.effect.each([WS_METHODS.subscribeVcsBranch, WS_METHODS.subscribeVcsStatus] as : granted[method]({ cwd: "/repo" }).pipe(Stream.drain), ); expect(handled).toBe(1); - }).pipe(Effect.scoped), + }), ); describe("settings mutation authorization", () => { @@ -260,7 +260,7 @@ describe("settings mutation authorization", () => { }).pipe(Effect.flip), ).toMatchObject({ requiredPermission: AuthSettingsWriteScope }); expect(handled).toBe(2); - }).pipe(Effect.scoped), + }), ); it.effect("does not let a settings grant create or remove providers or secret providers", () => @@ -291,7 +291,7 @@ describe("settings mutation authorization", () => { }).pipe(Effect.flip), ).toMatchObject({ requiredPermission: AuthProvidersManageScope }); expect(handled).toBe(false); - }).pipe(Effect.scoped), + }), ); }); @@ -329,7 +329,7 @@ it.effect("requires task permission before attaching a prepared worktree to a th }).pipe(Effect.flip), ).toMatchObject({ requiredPermission: AuthOrchestrationOperateScope }); expect(handled).toBe(false); - }).pipe(Effect.scoped), + }), ); describe("thread context authorization", () => { @@ -381,7 +381,7 @@ describe("thread context authorization", () => { reader[WS_METHODS.gitRunStackedAction]({ ...withThread, actionId: "allowed" }), ); expect(handled.at(-1)).toBe("allowed"); - }).pipe(Effect.scoped), + }), ); }); @@ -429,7 +429,7 @@ it.effect("separates host file URLs from readable attachment URLs", () => }); } expect(handled).toBe(2); - }).pipe(Effect.scoped), + }), ); it.effect("denies manual cleanup before the handler without settings permission", () => @@ -470,5 +470,5 @@ it.effect("denies manual cleanup before the handler without settings permission" requiredPermission: AuthSettingsWriteScope, }); expect(handled).toBe(false); - }).pipe(Effect.scoped), + }), ); diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts index aae4a4a38b662..7c516e97fa730 100644 --- a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts @@ -300,7 +300,7 @@ it.effect("shares the turn and full-thread diff budget across real Git batches", turnPatchSizes.reduce((sum, size) => sum + size, 0), 10_000_001, ); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.effect("truncates a storage-cut UTF-8 character without replacement text", () => { diff --git a/apps/server/src/cli/install.test.ts b/apps/server/src/cli/install.test.ts index 36deef5df342f..80b3041d24e77 100644 --- a/apps/server/src/cli/install.test.ts +++ b/apps/server/src/cli/install.test.ts @@ -162,7 +162,7 @@ it.layer(NodeServices.layer)("t3 install", (it) => { ), ).pipe(Effect.flip); assert.include(unrunnable.message, "has % or non-ASCII characters"); - }).pipe(Effect.scoped), + }), ); it.effect("asks npx users without a t3 command once per minor release and installs on yes", () => @@ -267,7 +267,7 @@ it.layer(NodeServices.layer)("t3 install", (it) => { assert.isTrue(yield* Fiber.join(unanswered)); assert.isTrue(yield* fs.exists(declined)); assert.isFalse(yield* fs.exists(link)); - }).pipe(Effect.scoped), + }), ); it.effect("reads the terminal's foreground group from /proc and ps", () => diff --git a/apps/server/src/cli/invocation.test.ts b/apps/server/src/cli/invocation.test.ts index 08e4024d5bfe0..4d41bf40f1a07 100644 --- a/apps/server/src/cli/invocation.test.ts +++ b/apps/server/src/cli/invocation.test.ts @@ -53,7 +53,7 @@ it.layer(NodeServices.layer)("root CLI commands", (it) => { path: yield* pathWithT3, }), ).toBe('sudo env "PATH=$PATH" t3 browser setup'); - }).pipe(Effect.scoped), + }), ); it.effect("keeps the arguments, so root runs setup on the same T3 home", () => @@ -70,7 +70,7 @@ it.layer(NodeServices.layer)("root CLI commands", (it) => { expect( yield* rootCommand({ node: "/opt/t3/t3", entry: "/opt/t3/t3", executable: true, args }), ).toBe("sudo /opt/t3/t3 browser setup --base-dir '/srv/t3 home'"); - }).pipe(Effect.scoped), + }), ); it.effect("names this install's launcher when t3 is not on PATH", () => @@ -143,7 +143,7 @@ it.layer(NodeServices.layer)("root CLI commands", (it) => { expect( yield* rootCommand({ ...desktop, env: { T3CODE_CLI_PATH: path.join(home, "gone") } }), ).toBe('sudo env "PATH=$PATH" t3 browser setup'); - }).pipe(Effect.scoped), + }), ); }); @@ -184,7 +184,7 @@ it.layer(NodeServices.layer)("host CLI commands", (it) => { expect((yield* hostCommand("linux", { T3CODE_CLI_PATH: shim })).command).toMatch( / doctor --base-dir 'C:\\Users\\Jane Doe\\\.t3'$/, ); - }).pipe(Effect.scoped), + }), ); }); diff --git a/apps/server/src/cli/project.test.ts b/apps/server/src/cli/project.test.ts index 407905815f434..bca02daaf2916 100644 --- a/apps/server/src/cli/project.test.ts +++ b/apps/server/src/cli/project.test.ts @@ -191,7 +191,7 @@ it.effect.each(["snapshot", "mutation"] as const)( assert.equal(snapshotRequests, 2); assert.equal(mutations, 1); assert.deepEqual((yield* readProjects(baseDir)).projects, []); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + }).pipe(Effect.provide(NodeServices.layer)), ); it.effect("does not send a token to whatever took over a dead server's port", () => @@ -222,7 +222,7 @@ it.effect("does not send a token to whatever took over a dead server's port", () assert.equal(requests, 0); assert.equal((yield* readProjects(baseDir)).projects[0]?.workspaceRoot, workspaceRoot); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + }).pipe(Effect.provide(NodeServices.layer)), ); const makeProjectLookupFixture = Effect.fn("ProjectCliTest.makeProjectLookupFixture")(function* () { diff --git a/apps/server/src/cli/uninstall.test.ts b/apps/server/src/cli/uninstall.test.ts index c50a5a15aad40..3e1e37a274b4e 100644 --- a/apps/server/src/cli/uninstall.test.ts +++ b/apps/server/src/cli/uninstall.test.ts @@ -149,7 +149,7 @@ it.layer(NodeServices.layer)("t3 uninstall launcher", (it) => { ), [cmd], ); - }).pipe(Effect.scoped, Effect.provideService(HostProcess.Platform, "linux")), + }).pipe(Effect.provideService(HostProcess.Platform, "linux")), ); }); diff --git a/apps/server/src/cli/update.test.ts b/apps/server/src/cli/update.test.ts index 557dddef554a9..2fc2e29b6223c 100644 --- a/apps/server/src/cli/update.test.ts +++ b/apps/server/src/cli/update.test.ts @@ -219,7 +219,7 @@ it.layer(NodeServices.layer)("t3 update launcher", (it) => { assert.deepStrictEqual(Option.getOrUndefined(repointed), launcher); assert.equal(yield* fs.readLink(launcher), newExe); - }).pipe(Effect.scoped, Effect.provideService(HostProcess.Platform, "linux")), + }).pipe(Effect.provideService(HostProcess.Platform, "linux")), ); it.effect.each([ @@ -285,7 +285,7 @@ it.layer(NodeServices.layer)("t3 update launcher", (it) => { yield* fs.realPath(launcher), yield* fs.realPath(path.join(root, "runtime/versions/2.0.0/t3")), ); - }).pipe(Effect.scoped, Effect.provideService(HostProcess.Platform, "linux")), + }).pipe(Effect.provideService(HostProcess.Platform, "linux")), ); const launcherFixture = Effect.fnUntraced(function* () { @@ -316,7 +316,7 @@ it.layer(NodeServices.layer)("t3 update launcher", (it) => { }); assert.equal(yield* fs.readFileString(launchersFile), `\n${launcher}\n`); - }).pipe(Effect.scoped, Effect.provideService(HostProcess.Platform, "linux")), + }).pipe(Effect.provideService(HostProcess.Platform, "linux")), ); it.effect("fails instead of repointing a launcher it cannot record", () => @@ -334,7 +334,7 @@ it.layer(NodeServices.layer)("t3 update launcher", (it) => { assert.isTrue(Exit.isFailure(result)); assert.equal(yield* fs.readLink(launcher), oldExe); - }).pipe(Effect.scoped, Effect.provideService(HostProcess.Platform, "linux")), + }).pipe(Effect.provideService(HostProcess.Platform, "linux")), ); it.effect.each(["recording", "repointing"] as const)( @@ -370,7 +370,7 @@ it.layer(NodeServices.layer)("t3 update launcher", (it) => { .split("\n") .includes(launcher); assert.isFalse(repointed && !recorded); - }).pipe(Effect.scoped, Effect.provideService(HostProcess.Platform, "linux")), + }).pipe(Effect.provideService(HostProcess.Platform, "linux")), ); it.effect("leaves a plain copy or a foreign symlink alone", () => @@ -403,7 +403,7 @@ it.layer(NodeServices.layer)("t3 update launcher", (it) => { } assert.equal(yield* fs.readLink(foreign), elsewhere); assert.equal(yield* fs.readLink(otherLauncher), otherHome); - }).pipe(Effect.scoped, Effect.provideService(HostProcess.Platform, "linux")), + }).pipe(Effect.provideService(HostProcess.Platform, "linux")), ); it.effect("finds the launcher a bare command name resolved to on PATH", () => @@ -437,7 +437,7 @@ it.layer(NodeServices.layer)("t3 update launcher", (it) => { assert.equal(bare, launcher); assert.equal(relative, launcher); assert.equal(absent, undefined); - }).pipe(Effect.scoped), + }), ); }); @@ -527,7 +527,6 @@ it.layer(NodeServices.layer)("t3 update with a background service", (it) => { `Background service still running the version before ${version}. Run \`t3 service restart\` when you are ready to switch it to ${version}.`, ); }).pipe( - Effect.scoped, Effect.provideService(HostProcess.Platform, "linux"), Effect.provideService(HostProcess.Environment, { PATH: "" }), ), diff --git a/apps/server/src/cloud/serviceLauncherClient.test.ts b/apps/server/src/cloud/serviceLauncherClient.test.ts index 2c8dc31da24f6..f8de584ff2c8b 100644 --- a/apps/server/src/cloud/serviceLauncherClient.test.ts +++ b/apps/server/src/cloud/serviceLauncherClient.test.ts @@ -238,7 +238,7 @@ it.layer(NodeServices.layer)("self-update launchers", (it) => { assert.equal(yield* fs.readLink(installed), exe("2.0.0")); assert.equal(yield* fs.readLink(onPath), exe("2.0.0")); - }).pipe(Effect.scoped, Effect.provideService(HostProcess.Platform, "linux")), + }).pipe(Effect.provideService(HostProcess.Platform, "linux")), ); it.effect("still moves the PATH launcher when the install bin one cannot be rewritten", () => @@ -292,7 +292,7 @@ it.layer(NodeServices.layer)("self-update launchers", (it) => { assert.include(JSON.stringify(warnings), installed); assert.equal(yield* fs.readLink(installed), exe("1.0.0")); assert.equal(yield* fs.readLink(onPath), exe("2.0.0")); - }).pipe(Effect.scoped, Effect.provideService(HostProcess.Platform, "linux")), + }).pipe(Effect.provideService(HostProcess.Platform, "linux")), ); it.effect("leaves unowned install files and PATH symlinks untouched after self-update", () => @@ -321,6 +321,6 @@ it.layer(NodeServices.layer)("self-update launchers", (it) => { assert.equal(yield* fs.readFileString(installed), "user-owned file"); assert.equal(yield* fs.readLink(onPath), foreign); - }).pipe(Effect.scoped, Effect.provideService(HostProcess.Platform, "linux")), + }).pipe(Effect.provideService(HostProcess.Platform, "linux")), ); }); diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index f2e0bcdf0c725..e196b4cb7c3b4 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -379,7 +379,7 @@ describe("device setup consent", () => { status: "failed", detail: startupError.message, }); - }).pipe(Effect.scoped), + }), ); it.effect("names the missing npm in the host status when device support cannot install", () => @@ -473,7 +473,7 @@ describe("device setup consent", () => { detail: expected, }); } - }).pipe(Effect.scoped), + }), ); it.effect("listing and provider startup do not start helpers before consent", () => @@ -485,7 +485,7 @@ describe("device setup consent", () => { expect(readiness._tag).toBe("Failure"); expect(starts).toEqual([]); expect(requests).toEqual([]); - }).pipe(Effect.scoped), + }), ); it.effect( @@ -505,7 +505,7 @@ describe("device setup consent", () => { expect((yield* Ref.get(settings)).enableDeviceSupport).toBe(false); expect(yield* service.readinessIfSupported()).toBeNull(); expect(starts).toEqual(["start", "stop"]); - }).pipe(Effect.scoped), + }), ); it.effect("boots a stopped Android AVD and uses its emulator serial without duplicating it", () => @@ -522,7 +522,7 @@ describe("device setup consent", () => { const state = yield* service.state; expect(state.devices.map((device) => device.id)).toEqual(["emulator-5554"]); expect(state.bootingDevices).toEqual([]); - }).pipe(Effect.scoped), + }), ); it.effect("blocks only emulator video until ffmpeg is usable, then allows retry", () => @@ -604,7 +604,7 @@ describe("device setup consent", () => { const emulator = yield* openEmulator; expect(emulator.deviceId).toBe("emulator-5554"); expect(requests.filter((url) => url.endsWith("/boot"))).toHaveLength(1); - }).pipe(Effect.scoped), + }), ); it.effect("opens emulators on hosts that route their own streams without ffmpeg", () => @@ -627,7 +627,7 @@ describe("device setup consent", () => { }); expect(session.deviceId).toBe("emulator-5554"); expect(requests.filter((url) => url.endsWith("/boot"))).toHaveLength(1); - }).pipe(Effect.scoped), + }), ); it.effect("installs agent support only after the separate agent permission", () => @@ -646,7 +646,7 @@ describe("device setup consent", () => { expect(agentStops).toEqual(["stop"]); expect((yield* service.state).onboardingCompleted).toBe(true); expect((yield* Ref.get(settings)).deviceOnboardingCompleted).toBe(true); - }).pipe(Effect.scoped), + }), ); }); @@ -726,7 +726,6 @@ it.effect.each([1, 9009])( NetService.layer, ).pipe(Layer.provideMerge(NodeServices.layer)), ), - Effect.scoped, ), ); @@ -753,7 +752,7 @@ it.effect("publishes boot progress and does not restore sessions after support i expect(state.devices).toEqual([]); expect(state.sessions).toEqual([]); expect(state.bootingDevices).toEqual([]); - }).pipe(Effect.scoped), + }), ); it.effect.each([ @@ -783,7 +782,7 @@ it.effect.each([ endDevice(); yield* service.list; expect((yield* service.state).sessions).toHaveLength(kept); - }).pipe(Effect.scoped), + }), ); describe("device discovery after server restart", () => { @@ -796,7 +795,7 @@ describe("device discovery after server restart", () => { expect(capture.device.id).toBe("Pixel_API_35"); expect(Array.from(capture.png)).toEqual([137, 80, 78, 71]); expect(requests.some((url) => url.endsWith("/api/devices"))).toBe(true); - }).pipe(Effect.scoped), + }), ); }); @@ -817,7 +816,7 @@ it.effect("keeps other discovery warnings and clears them when only a deleted pl const refreshed = yield* service.list; expect(refreshed.hostStatusDetail).toBeUndefined(); expect(refreshed.hostStatuses.local?.detail).toBeUndefined(); - }).pipe(Effect.scoped), + }), ); it.effect("hides Android discovery failures when the host has no Android SDK", () => @@ -831,7 +830,7 @@ it.effect("hides Android discovery failures when the host has no Android SDK", ( yield* Ref.set(androidAvailable, true); const withSdk = yield* service.list; expect(withSdk.hostStatusDetail).toBe(`${avdmanagerFailure.message}\niOS discovery failed.`); - }).pipe(Effect.scoped), + }), ); // The hub reports `error` as a util.inspect of Node's exec error. @@ -882,7 +881,7 @@ it.effect("keeps the cause from the hub's error in discovery warnings, without t ); expect(state.hostStatusDetail).not.toContain(" at "); expect(state.hostStatusDetail).not.toContain("stderr:"); - }).pipe(Effect.scoped), + }), ); it.effect( @@ -916,7 +915,7 @@ it.effect( const recovered = yield* service.list; expect(recovered.devices.map((device) => device.id)).toEqual(["Pixel_API_35"]); expect(recovered.hostStatuses.local).toEqual({ status: "ready" }); - }).pipe(Effect.scoped), + }), ); it.effect.each([ @@ -955,7 +954,7 @@ it.effect.each([ detail: "The device hub did not answer within 40 seconds.", incompletePlatforms, }); - }).pipe(Effect.scoped), + }), ); it.effect("does not open a retained pre-boot entry when the post-boot listing is incomplete", () => @@ -977,7 +976,7 @@ it.effect("does not open a retained pre-boot entry when the post-boot listing is yield* TestClock.adjust("40 seconds"); expect((yield* Fiber.join(opening))._tag).toBe("DeviceNotFoundError"); expect((yield* android.service.state).sessions).toEqual([]); - }).pipe(Effect.scoped), + }), ); it.effect.each([ @@ -1012,7 +1011,7 @@ it.effect.each([ expect(error.message).toContain(message); expect(error.message).toContain(diagnostic); expect((yield* service.state).bootingDevices).toEqual([]); - }).pipe(Effect.scoped), + }), ); // regression: #22012 report Android emulator boot failures @@ -1040,7 +1039,7 @@ it.effect("includes nested hub boot diagnostics and bounds them to 2,000 charact const roundTrip = decodeBootError(encodeBootError(error)); expect(roundTrip.message).toBe(error.message); expect((yield* service.state).bootingDevices).toEqual([]); - }).pipe(Effect.scoped), + }), ); it.effect("keeps shutdown successful when subsequent discovery fails", () => @@ -1057,7 +1056,7 @@ it.effect("keeps shutdown successful when subsequent discovery fails", () => const state = yield* service.state; expect(state.sessions).toEqual([]); expect(state.devices.find((device) => device.id === session.deviceId)?.booted).toBe(false); - }).pipe(Effect.scoped), + }), ); it.effect.each(["shutdown", "close"] as const)( @@ -1239,7 +1238,7 @@ it.effect.each(["shutdown", "close"] as const)( yield* Fiber.join(binding); yield* Fiber.await(retiring); expect(launchers).toEqual(new Set()); - }).pipe(Effect.provide(ServerSettings.layerTest({ enableDeviceSupport: true })), Effect.scoped), + }).pipe(Effect.provide(ServerSettings.layerTest({ enableDeviceSupport: true }))), ); it.effect("leaves a session its host leases to every thread open when one thread closes", () => @@ -1335,7 +1334,6 @@ it.effect("leaves a session its host leases to every thread open when one thread Effect.provide( ServerSettings.layerTest({ enableDeviceSupport: true, enableAgentDeviceAccess: true }), ), - Effect.scoped, ), ); @@ -1441,7 +1439,7 @@ it.effect.each([ (yield* service.state).devices.find((device) => device.id === deviceId)?.booted, ).toBe(true); } - }).pipe(Effect.provide(ServerSettings.layerTest({ enableDeviceSupport: true })), Effect.scoped), + }).pipe(Effect.provide(ServerSettings.layerTest({ enableDeviceSupport: true }))), ); it.effect.each([LOCAL_DEVICE_HOST_ID, "ssh-fixture"])( @@ -1527,7 +1525,7 @@ it.effect.each([LOCAL_DEVICE_HOST_ID, "ssh-fixture"])( ]); expect(recovered.hostStatuses[hostId]).toEqual({ status: "ready" }); if (hostId === LOCAL_DEVICE_HOST_ID) expect(recovered.hostStatusDetail).toBeUndefined(); - }).pipe(Effect.provide(ServerSettings.layerTest({ enableDeviceSupport: true })), Effect.scoped), + }).pipe(Effect.provide(ServerSettings.layerTest({ enableDeviceSupport: true }))), ); it.effect("retry keeps device and agent consent unchanged", () => @@ -1543,7 +1541,7 @@ it.effect("retry keeps device and agent consent unchanged", () => const before = agentStarts.length; yield* service.retryHost(LOCAL_DEVICE_HOST_ID); expect(agentStarts.length).toBe(before + 1); - }).pipe(Effect.scoped), + }), ); it.effect("failed read-only discovery preserves lifecycle status and installed inventory", () => @@ -1555,7 +1553,7 @@ it.effect("failed read-only discovery preserves lifecycle status and installed i expect(state.hosts[0]?.hubInstalled).toBe(true); expect(state.hosts[0]?.toolInspectionError).toContain("Reconnect the host"); expect(starts).toEqual([]); - }).pipe(Effect.scoped), + }), ); it.effect( @@ -1586,7 +1584,7 @@ it.effect( expect(requests).toEqual([]); yield* service.updateTool("hub"); expect(installed).toEqual(["agent", "hub"]); - }).pipe(Effect.scoped), + }), ); it.effect("failed manual installation leaves lifecycle state unchanged and can be retried", () => @@ -1619,7 +1617,7 @@ it.effect("failed manual installation leaves lifecycle state unchanged and can b expect(attempts).toBe(2); expect(starts).toEqual([]); expect(agentStarts).toEqual([]); - }).pipe(Effect.scoped), + }), ); it.effect("reconciles SSH host settings without publishing no-op changes", () => @@ -1798,7 +1796,6 @@ it.effect("a failed tool update reports npm's exit code but not its output", () NetService.layer, ).pipe(Layer.provideMerge(NodeServices.layer)), ), - Effect.scoped, ), ); @@ -2021,7 +2018,7 @@ describe("agent session release on close", () => { ) .filter((label, index, labels) => label !== labels[index - 1]); expect(published).toEqual(["none", "reopened"]); - }).pipe(Effect.scoped), + }), ); }); diff --git a/apps/server/src/environment/DirectEndpoints.test.ts b/apps/server/src/environment/DirectEndpoints.test.ts index 2580aa7f67809..6548b2ccd8c0e 100644 --- a/apps/server/src/environment/DirectEndpoints.test.ts +++ b/apps/server/src/environment/DirectEndpoints.test.ts @@ -366,7 +366,7 @@ describe("DirectEndpoints Tailscale Serve", () => { yield* Deferred.succeed(finishRevalidation, undefined); expect(yield* Fiber.join(revalidating)).toEqual([bound]); expect(yield* service.resolveAvailable()).toEqual([bound]); - }).pipe(Effect.scoped), + }), ); effectIt.effect("leaves the tailnet name out when Serve is not forwarding", () => diff --git a/apps/server/src/environment/staleWhileRevalidate.test.ts b/apps/server/src/environment/staleWhileRevalidate.test.ts index 6fa0faa9c4cbe..03216a4febdfe 100644 --- a/apps/server/src/environment/staleWhileRevalidate.test.ts +++ b/apps/server/src/environment/staleWhileRevalidate.test.ts @@ -21,7 +21,7 @@ it.effect("computes once, then answers from memory until the ttl lapses", () => yield* TestClock.adjust("59 seconds"); assert.equal(yield* read, 1); assert.equal(yield* Ref.get(calls), 1); - }).pipe(Effect.scoped), + }), ); it.effect("serves the stale value while one background refresh replaces it", () => @@ -80,7 +80,7 @@ it.effect("shares one first scan that an interrupted caller does not cancel", () yield* Deferred.succeed(release, undefined); assert.equal(yield* Fiber.join(next), 1); assert.equal(yield* Ref.get(calls), 1); - }).pipe(Effect.scoped), + }), ); it.effect("starts over after a failed first scan", () => @@ -98,7 +98,7 @@ it.effect("starts over after a failed first scan", () => assert.isTrue(Exit.isFailure(yield* Effect.exit(read))); assert.equal(yield* read, 2); - }).pipe(Effect.scoped), + }), ); it.effect("abandons a hung refresh so a later call can start another", () => @@ -144,7 +144,7 @@ it.effect("abandons a hung refresh so a later call can start another", () => yield* Fiber.await(yield* Deferred.await(refreshed)); assert.equal(yield* Ref.get(calls), 3); assert.equal(yield* read, 3); - }).pipe(Effect.scoped), + }), ); it.effect("interrupts a background refresh when the owning scope closes", () => diff --git a/apps/server/src/environmentTheme.test.ts b/apps/server/src/environmentTheme.test.ts index bb3b74c668e6c..e44f32e60f5ab 100644 --- a/apps/server/src/environmentTheme.test.ts +++ b/apps/server/src/environmentTheme.test.ts @@ -59,7 +59,7 @@ const withEnvironmentThemes = ( ), ), ); - }).pipe(Effect.scoped); + }); const currentThemes = Effect.gen(function* () { const environmentTheme = yield* EnvironmentTheme.EnvironmentThemeService; diff --git a/apps/server/src/htmlRender/HtmlRender.test.ts b/apps/server/src/htmlRender/HtmlRender.test.ts index 9d84d29e43e1a..a5a89e3d19707 100644 --- a/apps/server/src/htmlRender/HtmlRender.test.ts +++ b/apps/server/src/htmlRender/HtmlRender.test.ts @@ -622,6 +622,6 @@ fs.createReadStream(null, { fd: 3 }).on("end", quit).on("data", (chunk) => { }).pipe(Effect.provide(layerHtmlRender(executable))); expect(requests).toEqual([]); expect(datagrams).toEqual([]); - }).pipe(Effect.scoped), + }), ); }); diff --git a/apps/server/src/htmlRender/publicProxy.test.ts b/apps/server/src/htmlRender/publicProxy.test.ts index f04b7fafb30b5..a268e4581b60a 100644 --- a/apps/server/src/htmlRender/publicProxy.test.ts +++ b/apps/server/src/htmlRender/publicProxy.test.ts @@ -121,7 +121,7 @@ describe("publicProxy", () => { const { code, socket } = yield* connectThrough(port, ipv4Target("1.1.1.1", 0)); socket.destroy(); expect(code).toBe(7); - }).pipe(Effect.scoped), + }), ); it.effect("refuses fake-IP names and records hosts whose every address is fake", () => @@ -133,7 +133,7 @@ describe("publicProxy", () => { expect(code).toBe(2); } expect([...fakeIpHosts]).toEqual([["cdn.fake-ip.test", "198.18.2.40"]]); - }).pipe(Effect.scoped), + }), ); it.effect("refuses every address this machine holds, even a public one", () => @@ -156,7 +156,7 @@ describe("publicProxy", () => { socket.destroy(); expect(code).toBe(2); } - }).pipe(Effect.scoped), + }), ); it.effect("closes every connection when its scope closes", () => diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index cdfeab7240a3c..395d9dfe04fe5 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -268,7 +268,6 @@ it.effect.each([ Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), ), - Effect.scoped, ), ); diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index 9cd3231efdc41..03810fbefe599 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -2208,170 +2208,165 @@ describe("OrchestratorMcpService provider resolution", () => { ])( "replays accepted creation on the $name without authorizing new disabled work", ({ parentEnabled, model }) => - Effect.scoped( - Effect.gen(function* () { - const altInstanceId = ProviderInstanceId.make("codex-alt"); - const driver = ProviderDriverKind.make("codex"); - const cwd = yield* checkpointWorkspace("mcp-create-retry"); - const layerRegistry = ProviderAdapterRegistry.layerFromAdapters( - [codexInstanceId, altInstanceId].map((instanceId) => ({ - instanceId, - driver, - getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2), - planSelectionTransition: () => - Effect.succeed({ type: "apply_on_next_turn" as const }), - openSession: () => - Effect.die("These tests exercise committed commands before provider execution."), - })), - ); - const layerDatabase = SqlitePersistence.layerMemory; - const layerOrchestrator = ProviderReplayHarness.layerWithRegistry( - { name: "mcp-create-retry", runtimePolicyOverride: { cwd } }, - layerRegistry, - { databaseLayer: layerDatabase, runEffectWorker: false }, - ); - const layerSettings = ServerSettings.layerTest({ - providerInstances: { - [codexInstanceId]: { driver: "codex", enabled: true }, - [altInstanceId]: { driver: "codex", enabled: true }, - }, - projectSettingsOverrides: { - [projectId]: { providerInstances: { [codexInstanceId]: { enabled: parentEnabled } } }, - }, - }); - const layerThreads = ThreadManagementService.layer.pipe( - Layer.provideMerge(layerOrchestrator), - Layer.provide( - LegacyV1ThreadImporter.layer.pipe( - Layer.provide(layerOrchestrator), - Layer.provide(layerDatabase), - ), + Effect.gen(function* () { + const altInstanceId = ProviderInstanceId.make("codex-alt"); + const driver = ProviderDriverKind.make("codex"); + const cwd = yield* checkpointWorkspace("mcp-create-retry"); + const layerRegistry = ProviderAdapterRegistry.layerFromAdapters( + [codexInstanceId, altInstanceId].map((instanceId) => ({ + instanceId, + driver, + getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2), + planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" as const }), + openSession: () => + Effect.die("These tests exercise committed commands before provider execution."), + })), + ); + const layerDatabase = SqlitePersistence.layerMemory; + const layerOrchestrator = ProviderReplayHarness.layerWithRegistry( + { name: "mcp-create-retry", runtimePolicyOverride: { cwd } }, + layerRegistry, + { databaseLayer: layerDatabase, runEffectWorker: false }, + ); + const layerSettings = ServerSettings.layerTest({ + providerInstances: { + [codexInstanceId]: { driver: "codex", enabled: true }, + [altInstanceId]: { driver: "codex", enabled: true }, + }, + projectSettingsOverrides: { + [projectId]: { providerInstances: { [codexInstanceId]: { enabled: parentEnabled } } }, + }, + }); + const layerThreads = ThreadManagementService.layer.pipe( + Layer.provideMerge(layerOrchestrator), + Layer.provide( + LegacyV1ThreadImporter.layer.pipe( + Layer.provide(layerOrchestrator), + Layer.provide(layerDatabase), ), - Layer.provide(CommandReceiptStore.layer.pipe(Layer.provide(layerDatabase))), - Layer.provideMerge(layerSettings), - ); - const layerTest = layerOrchestratorService.pipe( - Layer.provideMerge(layerThreads), - Layer.provide( - Layer.mergeAll( - NodeServices.layer, - NodeCrypto.layer, - layerRegistry, - layerSettings, - providerRegistryLayer([ - providerSnapshot({ instanceId: codexInstanceId, driver, model: "gpt-5.4" }), - providerSnapshot({ instanceId: altInstanceId, driver, model: "gpt-5.5" }), - ]), - Layer.mock(ProjectService.ProjectService)({}), - Layer.mock(SecretRequests.SecretRequests)({}), - Layer.mock(RemoteDelegation.RemoteDelegation)({}), - Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), - ), + ), + Layer.provide(CommandReceiptStore.layer.pipe(Layer.provide(layerDatabase))), + Layer.provideMerge(layerSettings), + ); + const layerTest = layerOrchestratorService.pipe( + Layer.provideMerge(layerThreads), + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + NodeCrypto.layer, + layerRegistry, + layerSettings, + providerRegistryLayer([ + providerSnapshot({ instanceId: codexInstanceId, driver, model: "gpt-5.4" }), + providerSnapshot({ instanceId: altInstanceId, driver, model: "gpt-5.5" }), + ]), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(RemoteDelegation.RemoteDelegation)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), ), - ); - yield* Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const service = yield* OrchestratorMcpService.OrchestratorMcpService; - const settings = yield* ServerSettings.ServerSettingsService; - yield* orchestrator.dispatch({ - type: "thread.create", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("parent:create"), - threadId: parentThreadId, - projectId, - title: "MCP parent", - modelSelection: { instanceId: codexInstanceId, model: "gpt-5.4" }, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: cwd, - }); - yield* orchestrator.dispatch({ - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("parent:start"), - threadId: parentThreadId, - messageId: MessageId.make("parent:message"), - text: "Create a thread.", - attachments: [], - dispatchMode: { type: "start_immediately" }, - }); - const request = { - threads: [{ target: { driverKind: driver, model } }], - clientRequestId: "create-retry", - }; - const first = yield* service.createThreads(scope, request); - const child = first.threads[0]!; - const selection = parentEnabled - ? { instanceId: codexInstanceId, model: "gpt-5.4" } - : { instanceId: altInstanceId, model: "gpt-5.5" }; - assert.equal(first.threads.length, 1); - assert.equal(child.providerInstanceId, selection.instanceId); - assert.equal(child.model, selection.model); - assert.deepEqual(yield* service.createThreads(scope, request), first); + ), + ); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + const settings = yield* ServerSettings.ServerSettingsService; + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("parent:create"), + threadId: parentThreadId, + projectId, + title: "MCP parent", + modelSelection: { instanceId: codexInstanceId, model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("parent:start"), + threadId: parentThreadId, + messageId: MessageId.make("parent:message"), + text: "Create a thread.", + attachments: [], + dispatchMode: { type: "start_immediately" }, + }); + const request = { + threads: [{ target: { driverKind: driver, model } }], + clientRequestId: "create-retry", + }; + const first = yield* service.createThreads(scope, request); + const child = first.threads[0]!; + const selection = parentEnabled + ? { instanceId: codexInstanceId, model: "gpt-5.4" } + : { instanceId: altInstanceId, model: "gpt-5.5" }; + assert.equal(first.threads.length, 1); + assert.equal(child.providerInstanceId, selection.instanceId); + assert.equal(child.model, selection.model); + assert.deepEqual(yield* service.createThreads(scope, request), first); - // The creation receipt exists, but this prompt has not been accepted yet. - const withPrompt = { - ...request, - threads: [{ ...request.threads[0]!, prompt: "Start work." }], - }; - const started = yield* service.createThreads(scope, withPrompt); - assert.equal(started.threads[0]?.threadId, child.threadId); - const before = yield* orchestrator.getThreadProjection(child.threadId); - assert.deepEqual(before.runs[0]?.modelSelection, selection); - assert.equal(before.messages.length, 1); - const empty = yield* service.createThreads(scope, { - ...request, - clientRequestId: "empty", - }); - const emojiAtLimit = yield* service.createThreads(scope, { - threads: [{ ...request.threads[0]!, prompt: `${"a".repeat(76)}😀 and more work.` }], - clientRequestId: "emoji-title", - }); - const titled = yield* orchestrator.getThreadProjection( - emojiAtLimit.threads[0]!.threadId, - ); - assert.equal(titled.thread.title, `${"a".repeat(76)}...`); + // The creation receipt exists, but this prompt has not been accepted yet. + const withPrompt = { + ...request, + threads: [{ ...request.threads[0]!, prompt: "Start work." }], + }; + const started = yield* service.createThreads(scope, withPrompt); + assert.equal(started.threads[0]?.threadId, child.threadId); + const before = yield* orchestrator.getThreadProjection(child.threadId); + assert.deepEqual(before.runs[0]?.modelSelection, selection); + assert.equal(before.messages.length, 1); + const empty = yield* service.createThreads(scope, { + ...request, + clientRequestId: "empty", + }); + const emojiAtLimit = yield* service.createThreads(scope, { + threads: [{ ...request.threads[0]!, prompt: `${"a".repeat(76)}😀 and more work.` }], + clientRequestId: "emoji-title", + }); + const titled = yield* orchestrator.getThreadProjection(emojiAtLimit.threads[0]!.threadId); + assert.equal(titled.thread.title, `${"a".repeat(76)}...`); - yield* settings.updateSettings({ - projectSettingsOverrides: { - [projectId]: { - providerInstances: { - [codexInstanceId]: { enabled: false }, - [altInstanceId]: { enabled: false }, - }, + yield* settings.updateSettings({ + projectSettingsOverrides: { + [projectId]: { + providerInstances: { + [codexInstanceId]: { enabled: false }, + [altInstanceId]: { enabled: false }, }, }, - }); - assert.deepEqual(yield* service.createThreads(scope, withPrompt), started); - const after = yield* orchestrator.getThreadProjection(child.threadId); - assert.deepEqual(after.thread.modelSelection, selection); - assert.deepEqual(after.runs, before.runs); - assert.deepEqual(after.messages, before.messages); + }, + }); + assert.deepEqual(yield* service.createThreads(scope, withPrompt), started); + const after = yield* orchestrator.getThreadProjection(child.threadId); + assert.deepEqual(after.thread.modelSelection, selection); + assert.deepEqual(after.runs, before.runs); + assert.deepEqual(after.messages, before.messages); - const freshError = yield* service - .createThreads(scope, { - ...request, - clientRequestId: "fresh", - }) - .pipe(Effect.flip); - assert.equal(freshError.code, "provider_unavailable"); - const promptError = yield* service - .createThreads(scope, { - ...withPrompt, - clientRequestId: "empty", - }) - .pipe(Effect.flip); - assert.equal(promptError.code, "invalid_request"); - assert.include(promptError.message, "disabled"); - const untouched = yield* orchestrator.getThreadProjection(empty.threads[0]!.threadId); - assert.equal(untouched.messages.length, 0); - assert.equal(untouched.runs.length, 0); - }).pipe(Effect.provide(layerTest)); - }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), - ), + const freshError = yield* service + .createThreads(scope, { + ...request, + clientRequestId: "fresh", + }) + .pipe(Effect.flip); + assert.equal(freshError.code, "provider_unavailable"); + const promptError = yield* service + .createThreads(scope, { + ...withPrompt, + clientRequestId: "empty", + }) + .pipe(Effect.flip); + assert.equal(promptError.code, "invalid_request"); + assert.include(promptError.message, "disabled"); + const untouched = yield* orchestrator.getThreadProjection(empty.threads[0]!.threadId); + assert.equal(untouched.messages.length, 0); + assert.equal(untouched.runs.length, 0); + }).pipe(Effect.provide(layerTest)); + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); it.effect.each(["local", "remote"] as const)( @@ -4193,140 +4188,137 @@ describe("OrchestratorMcpService thread stop", () => { stop: { reason: "Make room", message: "Stopped by an agent: Make room" }, }, ])("t3_thread_interrupt stop reports the run it interrupted on $name", ({ queued, stop }) => - Effect.scoped( - Effect.gen(function* () { - const cwd = yield* checkpointWorkspace("mcp-thread-stop"); - const layerAdapters = ProviderAdapterRegistry.layerSingle(liveAdapter(cwd)); - const layerDatabase = SqlitePersistence.layerMemory; - const layerOrchestrator = ProviderReplayHarness.layerWithRegistry( - { name: "mcp-thread-stop" }, - layerAdapters, - { databaseLayer: layerDatabase, runEffectWorker: false }, - ); - const layerThreads = ThreadManagementService.layer.pipe( - Layer.provideMerge(layerOrchestrator), - Layer.provide(ServerSettings.layerTest()), - Layer.provide(CommandReceiptStore.layer.pipe(Layer.provide(layerDatabase))), - ); - const layerTest = layerOrchestratorService.pipe( - Layer.provideMerge(layerThreads), - Layer.provide( - Layer.mergeAll( - NodeServices.layer, - NodeCrypto.layer, - layerAdapters, - ServerSettings.layerTest(), - Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), - Layer.mock(ProjectService.ProjectService)({}), - Layer.mock(SecretRequests.SecretRequests)({}), - Layer.mock(RemoteDelegation.RemoteDelegation)({}), - Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), - ), + Effect.gen(function* () { + const cwd = yield* checkpointWorkspace("mcp-thread-stop"); + const layerAdapters = ProviderAdapterRegistry.layerSingle(liveAdapter(cwd)); + const layerDatabase = SqlitePersistence.layerMemory; + const layerOrchestrator = ProviderReplayHarness.layerWithRegistry( + { name: "mcp-thread-stop" }, + layerAdapters, + { databaseLayer: layerDatabase, runEffectWorker: false }, + ); + const layerThreads = ThreadManagementService.layer.pipe( + Layer.provideMerge(layerOrchestrator), + Layer.provide(ServerSettings.layerTest()), + Layer.provide(CommandReceiptStore.layer.pipe(Layer.provide(layerDatabase))), + ); + const layerTest = layerOrchestratorService.pipe( + Layer.provideMerge(layerThreads), + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + NodeCrypto.layer, + layerAdapters, + ServerSettings.layerTest(), + Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(RemoteDelegation.RemoteDelegation)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), ), - ); - yield* Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; - const service = yield* OrchestratorMcpService.OrchestratorMcpService; - const send = (text: string, type: "start_immediately" | "queue_after_active") => - orchestrator.dispatch({ - type: "message.dispatch", - commandId: CommandId.make(`send:${text}`), - threadId, - messageId: MessageId.make(`message:${text}`), - text, - attachments: [], - dispatchMode: { type }, - createdBy: "user", - creationSource: "web", - }); - yield* orchestrator.dispatch({ - type: "thread.create", - commandId: CommandId.make("create"), + ), + ); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + const send = (text: string, type: "start_immediately" | "queue_after_active") => + orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`send:${text}`), threadId, - projectId: ProjectId.make("project:mcp-stop"), - title: "Stop", - modelSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: cwd, + messageId: MessageId.make(`message:${text}`), + text, + attachments: [], + dispatchMode: { type }, createdBy: "user", creationSource: "web", }); - const running = yield* orchestrator.streamDomainEvents.pipe( - Stream.filter( - (event) => - event.type === "provider-turn.updated" && event.payload.status === "running", - ), - Stream.take(1), - Stream.runDrain, - Effect.forkScoped, - ); - yield* send("work", "start_immediately"); - yield* worker.drain(); - yield* Fiber.join(running); - if (queued) yield* send("later", "queue_after_active"); - const before = yield* orchestrator.getThreadProjection(threadId); - const runningRun = before.runs.find((run) => run.status === "running")!; - assert.equal(before.runs.filter((run) => run.status === "queued").length, queued ? 1 : 0); - // A task the thread delegated, running when the thread is stopped. - const childRunning = yield* orchestrator.streamDomainEvents.pipe( - Stream.filter( - (event) => - event.type === "provider-turn.updated" && - event.threadId !== threadId && - event.payload.status === "running", - ), - Stream.take(1), - Stream.runDrain, - Effect.forkScoped, - ); - yield* orchestrator.dispatch({ - type: "delegated_task.request", - commandId: CommandId.make("delegate"), - parentThreadId: threadId, - parentRunId: runningRun.id, - parentNodeId: runningRun.rootNodeId!, - task: "child work", - modelSelection, - runtimeMode: "full-access", - interactionMode: "default", - completionWake: "always", - createdBy: "agent", - creationSource: "mcp", - }); - yield* worker.drain(); - yield* Fiber.join(childRunning); - const childThreadId = (yield* orchestrator.getThreadProjection(threadId)).subagents[0]! - .childThreadId!; + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create"), + threadId, + projectId: ProjectId.make("project:mcp-stop"), + title: "Stop", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + createdBy: "user", + creationSource: "web", + }); + const running = yield* orchestrator.streamDomainEvents.pipe( + Stream.filter( + (event) => event.type === "provider-turn.updated" && event.payload.status === "running", + ), + Stream.take(1), + Stream.runDrain, + Effect.forkScoped, + ); + yield* send("work", "start_immediately"); + yield* worker.drain(); + yield* Fiber.join(running); + if (queued) yield* send("later", "queue_after_active"); + const before = yield* orchestrator.getThreadProjection(threadId); + const runningRun = before.runs.find((run) => run.status === "running")!; + assert.equal(before.runs.filter((run) => run.status === "queued").length, queued ? 1 : 0); + // A task the thread delegated, running when the thread is stopped. + const childRunning = yield* orchestrator.streamDomainEvents.pipe( + Stream.filter( + (event) => + event.type === "provider-turn.updated" && + event.threadId !== threadId && + event.payload.status === "running", + ), + Stream.take(1), + Stream.runDrain, + Effect.forkScoped, + ); + yield* orchestrator.dispatch({ + type: "delegated_task.request", + commandId: CommandId.make("delegate"), + parentThreadId: threadId, + parentRunId: runningRun.id, + parentNodeId: runningRun.rootNodeId!, + task: "child work", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + completionWake: "always", + createdBy: "agent", + creationSource: "mcp", + }); + yield* worker.drain(); + yield* Fiber.join(childRunning); + const childThreadId = (yield* orchestrator.getThreadProjection(threadId)).subagents[0]! + .childThreadId!; - const stopped = yield* service.interruptThread(client, { - threadId, - stop: true, - clientRequestId: "stop", - ...(stop.reason === undefined ? {} : { reason: stop.reason }), - }); - assert.deepEqual(stopped, { - threadId, - runId: runningRun.id, - status: "interrupt_requested", - }); - // An agent's Stop must not read as the user's, on its thread or a task it delegated. - const interruptMessages = (id: ThreadId) => - orchestrator - .getThreadProjection(id) - .pipe( - Effect.map(({ turnItems }) => - turnItems.flatMap((item) => - item.type === "run_interrupt_request" ? [item.message] : [], - ), + const stopped = yield* service.interruptThread(client, { + threadId, + stop: true, + clientRequestId: "stop", + ...(stop.reason === undefined ? {} : { reason: stop.reason }), + }); + assert.deepEqual(stopped, { + threadId, + runId: runningRun.id, + status: "interrupt_requested", + }); + // An agent's Stop must not read as the user's, on its thread or a task it delegated. + const interruptMessages = (id: ThreadId) => + orchestrator + .getThreadProjection(id) + .pipe( + Effect.map(({ turnItems }) => + turnItems.flatMap((item) => + item.type === "run_interrupt_request" ? [item.message] : [], ), - ); - assert.deepEqual(yield* interruptMessages(threadId), [stop.message]); - assert.deepEqual(yield* interruptMessages(childThreadId), [stop.message]); - }).pipe(Effect.provide(layerTest)); - }), - ), + ), + ); + assert.deepEqual(yield* interruptMessages(threadId), [stop.message]); + assert.deepEqual(yield* interruptMessages(childThreadId), [stop.message]); + }).pipe(Effect.provide(layerTest)); + }), ); }); diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index 3439ed4121d1d..fef4fda0433c4 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -207,613 +207,602 @@ describe("orchestrator MCP toolkit", () => { latestSummary: "git safety ".repeat(120), }, ])("reports $label child results", ({ result, latestSummary }) => - Effect.scoped( - Effect.gen(function* () { - const cwd = yield* checkpointWorkspace("delegated-task-status"); - const rawTranscript = yield* readDelegatedTaskStatusTranscript(result); - const transcript = yield* CodexOrchestratorReplayHarness.decodeTranscript( - materializeReplayTranscriptWorkspace(rawTranscript, cwd), - ); - const layerOrchestrator = ProviderReplayHarness.layerProviderReplay( - { - name: "delegated-task-status/codex", - transcript, - commands: [], - runtimePolicyOverride: { cwd }, - }, - CodexOrchestratorReplayHarness, - ); - // SQL spans carry no bound parameters, so this records which thread each tool read. - const threadRecordReads: Array<{ - readonly threadId: ThreadId; - readonly fields: ReadonlyArray; - }> = []; - const layerOrchestration = Layer.merge( - layerOrchestrator, - Layer.effect( - ThreadManagementService.ThreadManagementService, - Effect.gen(function* () { - const service = yield* ThreadManagementService.ThreadManagementService; - return ThreadManagementService.ThreadManagementService.of({ - ...service, - getThreadRecords: (threadId, fields, filter) => - Effect.sync(() => threadRecordReads.push({ threadId, fields })).pipe( - Effect.andThen(service.getThreadRecords(threadId, fields, filter)), - ), - }); - }), - ).pipe( - Layer.provide( - ThreadManagementService.layer.pipe( - Layer.provide(layerOrchestrator), - Layer.provide(ServerSettings.layerTest()), - Layer.provide( - CommandReceiptStore.layer.pipe(Layer.provide(SqlitePersistence.layerMemory)), + Effect.gen(function* () { + const cwd = yield* checkpointWorkspace("delegated-task-status"); + const rawTranscript = yield* readDelegatedTaskStatusTranscript(result); + const transcript = yield* CodexOrchestratorReplayHarness.decodeTranscript( + materializeReplayTranscriptWorkspace(rawTranscript, cwd), + ); + const layerOrchestrator = ProviderReplayHarness.layerProviderReplay( + { + name: "delegated-task-status/codex", + transcript, + commands: [], + runtimePolicyOverride: { cwd }, + }, + CodexOrchestratorReplayHarness, + ); + // SQL spans carry no bound parameters, so this records which thread each tool read. + const threadRecordReads: Array<{ + readonly threadId: ThreadId; + readonly fields: ReadonlyArray; + }> = []; + const layerOrchestration = Layer.merge( + layerOrchestrator, + Layer.effect( + ThreadManagementService.ThreadManagementService, + Effect.gen(function* () { + const service = yield* ThreadManagementService.ThreadManagementService; + return ThreadManagementService.ThreadManagementService.of({ + ...service, + getThreadRecords: (threadId, fields, filter) => + Effect.sync(() => threadRecordReads.push({ threadId, fields })).pipe( + Effect.andThen(service.getThreadRecords(threadId, fields, filter)), ), - ), - ), - ), - ); - const layerProviderRegistry = ProviderRegistryMock.layer([ - makeProviderSnapshot({ - instanceId: codexInstanceId, - driver: ProviderDriverKind.make("codex"), - model: codexModel, + }); }), - ]); - const layerTest = layerOrchestratorToolkit.pipe( - Layer.provideMerge(McpHttpServer.layerInProcessMcpServer), - Layer.provideMerge(layerOrchestration), + ).pipe( Layer.provide( - CodexOrchestratorReplayHarness.makeProviderAdapterRegistryLayer(transcript).pipe( - Layer.provide(McpProviderSessions.layer), + ThreadManagementService.layer.pipe( + Layer.provide(layerOrchestrator), + Layer.provide(ServerSettings.layerTest()), + Layer.provide( + CommandReceiptStore.layer.pipe(Layer.provide(SqlitePersistence.layerMemory)), + ), ), ), - Layer.provide(layerProviderRegistry), - Layer.provide(ServerSettings.layerTest()), - Layer.provide(layerUnusedScheduledTaskStub), - Layer.provide(Layer.mock(PeerForwarding.PeerForwarding)({})), - Layer.provide(Layer.mock(PeerLinkRequests.PeerLinkRequests)({})), - Layer.provide(Layer.mock(PeerLinks.PeerLinks)({})), - Layer.provide(Layer.mock(ProjectService.ProjectService)({})), - Layer.provideMerge( - SecretRequests.layer.pipe( - Layer.provide(layerMemorySecretStore), - Layer.provide(Layer.mock(SecretResolver.SecretResolver)({})), - Layer.provide(layerOrchestration), - ), + ), + ); + const layerProviderRegistry = ProviderRegistryMock.layer([ + makeProviderSnapshot({ + instanceId: codexInstanceId, + driver: ProviderDriverKind.make("codex"), + model: codexModel, + }), + ]); + const layerTest = layerOrchestratorToolkit.pipe( + Layer.provideMerge(McpHttpServer.layerInProcessMcpServer), + Layer.provideMerge(layerOrchestration), + Layer.provide( + CodexOrchestratorReplayHarness.makeProviderAdapterRegistryLayer(transcript).pipe( + Layer.provide(McpProviderSessions.layer), ), - Layer.provide(Layer.mock(RemoteDelegation.RemoteDelegation)({})), - Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), - ); + ), + Layer.provide(layerProviderRegistry), + Layer.provide(ServerSettings.layerTest()), + Layer.provide(layerUnusedScheduledTaskStub), + Layer.provide(Layer.mock(PeerForwarding.PeerForwarding)({})), + Layer.provide(Layer.mock(PeerLinkRequests.PeerLinkRequests)({})), + Layer.provide(Layer.mock(PeerLinks.PeerLinks)({})), + Layer.provide(Layer.mock(ProjectService.ProjectService)({})), + Layer.provideMerge( + SecretRequests.layer.pipe( + Layer.provide(layerMemorySecretStore), + Layer.provide(Layer.mock(SecretResolver.SecretResolver)({})), + Layer.provide(layerOrchestration), + ), + ), + Layer.provide(Layer.mock(RemoteDelegation.RemoteDelegation)({})), + Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ); - yield* Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const server = yield* McpServer.McpServer; - const parentCreate = yield* orchestrator.dispatch({ - type: "thread.create", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:mcp-replay-parent:create"), + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const server = yield* McpServer.McpServer; + const parentCreate = yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:mcp-replay-parent:create"), + threadId: parentThreadId, + projectId, + title: "MCP replay parent", + modelSelection: codexSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:mcp-replay-parent:start"), + threadId: parentThreadId, + messageId: MessageId.make("message:mcp-replay-parent:start"), + text: parentPrompt, + attachments: [], + modelSelection: codexSelection, + dispatchMode: { type: "start_immediately" }, + }); + yield* orchestrator + .streamStoredEventsFrom({ threadId: parentThreadId, - projectId, - title: "MCP replay parent", - modelSelection: codexSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: cwd, - }); - yield* orchestrator.dispatch({ - type: "message.dispatch", - createdBy: "user", - creationSource: "web", - commandId: CommandId.make("command:mcp-replay-parent:start"), + afterSequence: parentCreate.sequence, + }) + .pipe( + Stream.filter( + (stored) => + stored.event.type === "provider-turn.updated" && + stored.event.payload.status === "running", + ), + Stream.runHead, + Effect.flatMap( + Option.match({ + onNone: () => Effect.die("Parent provider turn did not start."), + onSome: () => Effect.void, + }), + ), + ); + + const invocation: McpInvocationContext.McpInvocationScope = { + environmentId: EnvironmentId.make("environment:mcp-replay"), + requestNamespace: "mcp-provider-session-replay-parent", + thread: { threadId: parentThreadId, - messageId: MessageId.make("message:mcp-replay-parent:start"), - text: parentPrompt, - attachments: [], - modelSelection: codexSelection, - dispatchMode: { type: "start_immediately" }, - }); - yield* orchestrator - .streamStoredEventsFrom({ - threadId: parentThreadId, - afterSequence: parentCreate.sequence, - }) + providerSessionId: "mcp-provider-session-replay-parent", + providerInstanceId: codexInstanceId, + }, + client: undefined, + capabilities: new Set(["orchestration"]), + issuedAt: 1, + }; + const invoke = (name: string, args: Record, scope = invocation) => + server + .callTool({ name, arguments: args }) .pipe( - Stream.filter( - (stored) => - stored.event.type === "provider-turn.updated" && - stored.event.payload.status === "running", - ), - Stream.runHead, - Effect.flatMap( - Option.match({ - onNone: () => Effect.die("Parent provider turn did not start."), - onSome: () => Effect.void, - }), - ), + Effect.provideService(McpInvocationContext.McpInvocationContext, scope), + Effect.provideService(McpSchema.McpServerClient, client), ); - const invocation: McpInvocationContext.McpInvocationScope = { - environmentId: EnvironmentId.make("environment:mcp-replay"), - requestNamespace: "mcp-provider-session-replay-parent", - thread: { - threadId: parentThreadId, - providerSessionId: "mcp-provider-session-replay-parent", - providerInstanceId: codexInstanceId, - }, - client: undefined, - capabilities: new Set(["orchestration"]), - issuedAt: 1, - }; - const invoke = (name: string, args: Record, scope = invocation) => - server - .callTool({ name, arguments: args }) - .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, scope), - Effect.provideService(McpSchema.McpServerClient, client), - ); - - const delegationStartSequence = - yield* orchestrator.getThreadEventSequence(parentThreadId); - const delegatedCall = yield* invoke("delegate_task", { - task: delegatedPrompt, - target: { - providerInstanceId: codexInstanceId, - model: codexModel, - }, - mode: "async", - clientRequestId: "delegate-codex-replay-1", - }); - const delegatedStart = yield* decodeDelegateTaskResult( - delegatedCall.structuredContent, - ).pipe(Effect.orDie); - expect(delegatedStart.childRunId).not.toBeNull(); - yield* orchestrator - .streamStoredEventsFrom({ - threadId: parentThreadId, - afterSequence: delegationStartSequence, - }) - .pipe( - Stream.filter( - (stored) => - stored.event.type === "context-transfer.created" && - stored.event.payload.type === "subagent_result" && - stored.event.payload.sourceThreadId === delegatedStart.childThreadId && - stored.event.payload.sourcePoint.runId === delegatedStart.childRunId, - ), - Stream.runHead, - Effect.flatMap( - Option.match({ - onNone: () => Effect.die("Delegated result transfer was not created."), - onSome: () => Effect.void, - }), - ), - ); - const delegatedStatusCall = yield* invoke("task_status", { - taskId: delegatedStart.taskId, - }); - const delegated = yield* decodeDelegateTaskResult( - delegatedStatusCall.structuredContent, - ).pipe(Effect.orDie); - expect(delegated).toMatchObject({ - status: "completed", - summary: delegatedResult, + const delegationStartSequence = yield* orchestrator.getThreadEventSequence(parentThreadId); + const delegatedCall = yield* invoke("delegate_task", { + task: delegatedPrompt, + target: { providerInstanceId: codexInstanceId, - hasPendingChildRuns: false, - latestTerminalRunId: delegated.childRunId, - latestTerminalStatus: "completed", - latestTerminalSummary: null, - latestTerminalResultContextTransferId: null, - }); - expect(delegated.resultContextTransferId).not.toBeNull(); - - const delegatedChild = yield* orchestrator.getThreadProjection( - localChild(delegated.childThreadId), - ); - expect(delegatedChild.thread.lineage.relationshipToParent).toBe("subagent"); - expect(isProviderNativeSubagentThread(delegatedChild.thread)).toBe(false); - const followupStartSequence = yield* orchestrator.getThreadEventSequence( - localChild(delegated.childThreadId), + model: codexModel, + }, + mode: "async", + clientRequestId: "delegate-codex-replay-1", + }); + const delegatedStart = yield* decodeDelegateTaskResult( + delegatedCall.structuredContent, + ).pipe(Effect.orDie); + expect(delegatedStart.childRunId).not.toBeNull(); + yield* orchestrator + .streamStoredEventsFrom({ + threadId: parentThreadId, + afterSequence: delegationStartSequence, + }) + .pipe( + Stream.filter( + (stored) => + stored.event.type === "context-transfer.created" && + stored.event.payload.type === "subagent_result" && + stored.event.payload.sourceThreadId === delegatedStart.childThreadId && + stored.event.payload.sourcePoint.runId === delegatedStart.childRunId, + ), + Stream.runHead, + Effect.flatMap( + Option.match({ + onNone: () => Effect.die("Delegated result transfer was not created."), + onSome: () => Effect.void, + }), + ), ); - const runningFollowupCall = yield* invoke("t3_thread_send", { - threadId: localChild(delegated.childThreadId), - message: cancellationPrompt, - clientRequestId: "delegated-child-replay-running-1", - }); - const runningFollowup = yield* decodeThreadSendResult( - runningFollowupCall.structuredContent, - ).pipe(Effect.orDie); - yield* orchestrator - .streamStoredEventsFrom({ - threadId: localChild(delegated.childThreadId), - afterSequence: followupStartSequence, - }) - .pipe( - Stream.filter( - (stored) => - stored.event.type === "provider-turn.updated" && - stored.event.payload.status === "running", - ), - Stream.runHead, - Effect.flatMap( - Option.match({ - onNone: () => Effect.die("Follow-up provider turn did not start."), - onSome: () => Effect.void, - }), - ), - ); + const delegatedStatusCall = yield* invoke("task_status", { + taskId: delegatedStart.taskId, + }); + const delegated = yield* decodeDelegateTaskResult( + delegatedStatusCall.structuredContent, + ).pipe(Effect.orDie); + expect(delegated).toMatchObject({ + status: "completed", + summary: delegatedResult, + providerInstanceId: codexInstanceId, + hasPendingChildRuns: false, + latestTerminalRunId: delegated.childRunId, + latestTerminalStatus: "completed", + latestTerminalSummary: null, + latestTerminalResultContextTransferId: null, + }); + expect(delegated.resultContextTransferId).not.toBeNull(); - const queuedFollowupCall = yield* invoke("t3_thread_send", { + const delegatedChild = yield* orchestrator.getThreadProjection( + localChild(delegated.childThreadId), + ); + expect(delegatedChild.thread.lineage.relationshipToParent).toBe("subagent"); + expect(isProviderNativeSubagentThread(delegatedChild.thread)).toBe(false); + const followupStartSequence = yield* orchestrator.getThreadEventSequence( + localChild(delegated.childThreadId), + ); + const runningFollowupCall = yield* invoke("t3_thread_send", { + threadId: localChild(delegated.childThreadId), + message: cancellationPrompt, + clientRequestId: "delegated-child-replay-running-1", + }); + const runningFollowup = yield* decodeThreadSendResult( + runningFollowupCall.structuredContent, + ).pipe(Effect.orDie); + yield* orchestrator + .streamStoredEventsFrom({ threadId: localChild(delegated.childThreadId), - message: queuedFollowupPrompt, - mode: "queue", - clientRequestId: "delegated-child-replay-queued-1", - }); - const queuedFollowup = yield* decodeThreadSendResult( - queuedFollowupCall.structuredContent, - ).pipe(Effect.orDie); - expect(queuedFollowup).toMatchObject({ - status: "queued", - delivery: "queued", - }); - - const pendingProjection = yield* orchestrator.getThreadProjection( - localChild(delegated.childThreadId), - ); - const followupTasks = (yield* orchestrator.getThreadProjection( - parentThreadId, - )).subagents.filter( - (task) => - task.childThreadId === delegated.childThreadId && task.id !== delegated.taskId, - ); - expect(followupTasks).toHaveLength(2); - expect(followupTasks.every((task) => task.result === null)).toBe(true); - expect(followupTasks.map((task) => task.id)).toEqual([ - runningFollowup.taskId, - queuedFollowup.taskId, - ]); - expect( - pendingProjection.runs.find((run) => run.id === delegated.childRunId)?.status, - ).toBe("completed"); - expect( - pendingProjection.runs.find((run) => run.id === runningFollowup.runId)?.status, - ).toBe("running"); - expect( - pendingProjection.runs.find((run) => run.id === queuedFollowup.runId)?.status, - ).toBe("queued"); - expect( - (yield* orchestrator.getThreadProjection(parentThreadId)).runs.some( - (run) => run.status === "running", + afterSequence: followupStartSequence, + }) + .pipe( + Stream.filter( + (stored) => + stored.event.type === "provider-turn.updated" && + stored.event.payload.status === "running", + ), + Stream.runHead, + Effect.flatMap( + Option.match({ + onNone: () => Effect.die("Follow-up provider turn did not start."), + onSome: () => Effect.void, + }), ), - ).toBe(true); + ); - const pendingStatusCall = yield* invoke("task_status", { - taskId: delegated.taskId, - }); - const pendingStatus = yield* decodeDelegateTaskResult( - pendingStatusCall.structuredContent, - ).pipe(Effect.orDie); - expect(pendingStatus).toMatchObject({ - childRunId: delegated.childRunId, - status: "completed", - summary: delegatedResult, - resultContextTransferId: delegated.resultContextTransferId, - hasPendingChildRuns: true, - latestTerminalRunId: delegated.childRunId, - latestTerminalStatus: "completed", - latestTerminalSummary: null, - latestTerminalResultContextTransferId: null, - }); + const queuedFollowupCall = yield* invoke("t3_thread_send", { + threadId: localChild(delegated.childThreadId), + message: queuedFollowupPrompt, + mode: "queue", + clientRequestId: "delegated-child-replay-queued-1", + }); + const queuedFollowup = yield* decodeThreadSendResult( + queuedFollowupCall.structuredContent, + ).pipe(Effect.orDie); + expect(queuedFollowup).toMatchObject({ + status: "queued", + delivery: "queued", + }); - const finalSequence = yield* orchestrator.getThreadEventSequence( - localChild(delegated.childThreadId), - ); - const interruptCall = yield* invoke("t3_thread_interrupt", { - threadId: localChild(delegated.childThreadId), - runId: runningFollowup.runId, - reason: "Allow the queued replay follow-up to run.", - clientRequestId: "interrupt-delegated-child-replay-1", - }); - const interrupt = yield* decodeThreadInterruptResult( - interruptCall.structuredContent, - ).pipe(Effect.orDie); - expect(interrupt).toMatchObject({ - runId: runningFollowup.runId, - status: "interrupt_requested", - }); - yield* orchestrator - .streamStoredEventsFrom({ - threadId: localChild(delegated.childThreadId), - afterSequence: finalSequence, - }) - .pipe( - Stream.filter( - (stored) => - stored.event.type === "run.updated" && - stored.event.payload.id === queuedFollowup.runId && - stored.event.payload.status === "completed", - ), - Stream.runHead, - Effect.flatMap( - Option.match({ - onNone: () => Effect.die("Queued follow-up did not complete."), - onSome: () => Effect.void, - }), - ), - ); + const pendingProjection = yield* orchestrator.getThreadProjection( + localChild(delegated.childThreadId), + ); + const followupTasks = (yield* orchestrator.getThreadProjection( + parentThreadId, + )).subagents.filter( + (task) => task.childThreadId === delegated.childThreadId && task.id !== delegated.taskId, + ); + expect(followupTasks).toHaveLength(2); + expect(followupTasks.every((task) => task.result === null)).toBe(true); + expect(followupTasks.map((task) => task.id)).toEqual([ + runningFollowup.taskId, + queuedFollowup.taskId, + ]); + expect(pendingProjection.runs.find((run) => run.id === delegated.childRunId)?.status).toBe( + "completed", + ); + expect(pendingProjection.runs.find((run) => run.id === runningFollowup.runId)?.status).toBe( + "running", + ); + expect(pendingProjection.runs.find((run) => run.id === queuedFollowup.runId)?.status).toBe( + "queued", + ); + expect( + (yield* orchestrator.getThreadProjection(parentThreadId)).runs.some( + (run) => run.status === "running", + ), + ).toBe(true); - const finalProjection = yield* orchestrator.getThreadProjection( - localChild(delegated.childThreadId), - ); - expect(finalProjection.runs.find((run) => run.id === runningFollowup.runId)?.status).toBe( - "interrupted", - ); - expect(finalProjection.runs.find((run) => run.id === queuedFollowup.runId)?.status).toBe( - "completed", + const pendingStatusCall = yield* invoke("task_status", { + taskId: delegated.taskId, + }); + const pendingStatus = yield* decodeDelegateTaskResult( + pendingStatusCall.structuredContent, + ).pipe(Effect.orDie); + expect(pendingStatus).toMatchObject({ + childRunId: delegated.childRunId, + status: "completed", + summary: delegatedResult, + resultContextTransferId: delegated.resultContextTransferId, + hasPendingChildRuns: true, + latestTerminalRunId: delegated.childRunId, + latestTerminalStatus: "completed", + latestTerminalSummary: null, + latestTerminalResultContextTransferId: null, + }); + + const finalSequence = yield* orchestrator.getThreadEventSequence( + localChild(delegated.childThreadId), + ); + const interruptCall = yield* invoke("t3_thread_interrupt", { + threadId: localChild(delegated.childThreadId), + runId: runningFollowup.runId, + reason: "Allow the queued replay follow-up to run.", + clientRequestId: "interrupt-delegated-child-replay-1", + }); + const interrupt = yield* decodeThreadInterruptResult(interruptCall.structuredContent).pipe( + Effect.orDie, + ); + expect(interrupt).toMatchObject({ + runId: runningFollowup.runId, + status: "interrupt_requested", + }); + yield* orchestrator + .streamStoredEventsFrom({ + threadId: localChild(delegated.childThreadId), + afterSequence: finalSequence, + }) + .pipe( + Stream.filter( + (stored) => + stored.event.type === "run.updated" && + stored.event.payload.id === queuedFollowup.runId && + stored.event.payload.status === "completed", + ), + Stream.runHead, + Effect.flatMap( + Option.match({ + onNone: () => Effect.die("Queued follow-up did not complete."), + onSome: () => Effect.void, + }), + ), ); - expect( - finalProjection.turnItems.find( - (item) => - item.runId === runningFollowup.runId && item.type === "run_interrupt_result", + + const finalProjection = yield* orchestrator.getThreadProjection( + localChild(delegated.childThreadId), + ); + expect(finalProjection.runs.find((run) => run.id === runningFollowup.runId)?.status).toBe( + "interrupted", + ); + expect(finalProjection.runs.find((run) => run.id === queuedFollowup.runId)?.status).toBe( + "completed", + ); + expect( + finalProjection.turnItems.find( + (item) => item.runId === runningFollowup.runId && item.type === "run_interrupt_result", + ), + ).toMatchObject({ + message: "Stopped by an agent: Allow the queued replay follow-up to run.", + }); + yield* orchestrator + .streamStoredEventsFrom({ + threadId: parentThreadId, + afterSequence: finalSequence, + }) + .pipe( + Stream.filter( + (stored) => + stored.event.type === "subagent.updated" && + stored.event.payload.id === followupTasks[1]!.id && + stored.event.payload.status === "completed", ), - ).toMatchObject({ - message: "Stopped by an agent: Allow the queued replay follow-up to run.", - }); - yield* orchestrator - .streamStoredEventsFrom({ - threadId: parentThreadId, - afterSequence: finalSequence, - }) - .pipe( - Stream.filter( - (stored) => - stored.event.type === "subagent.updated" && - stored.event.payload.id === followupTasks[1]!.id && - stored.event.payload.status === "completed", - ), - Stream.runHead, - Effect.flatMap( - Option.match({ - onNone: () => Effect.die("Follow-up task did not settle."), - onSome: () => Effect.void, - }), - ), - ); - const finishedParent = yield* orchestrator.getThreadProjection(parentThreadId); - expect( - finishedParent.subagents.find((task) => task.id === followupTasks[0]!.id)?.status, - ).toBe("interrupted"); - expect( - finishedParent.subagents.find((task) => task.id === followupTasks[1]!.id), - ).toMatchObject({ - status: "completed", - result, - }); - expect( - finishedParent.contextTransfers.filter( - (transfer) => - transfer.type === "subagent_result" && - transfer.sourceThreadId === delegated.childThreadId, + Stream.runHead, + Effect.flatMap( + Option.match({ + onNone: () => Effect.die("Follow-up task did not settle."), + onSome: () => Effect.void, + }), ), - ).toHaveLength(3); - expect( - finishedParent.subagents.find((task) => task.id === followupTasks[1]!.id) - ?.completionDelivery?.state, - ).toBe("claimed"); - const finalStatusCall = yield* invoke("task_status", { - taskId: delegated.taskId, - }); - const finalStatus = yield* decodeDelegateTaskResult( - finalStatusCall.structuredContent, - ).pipe(Effect.orDie); - expect(finalStatus).toMatchObject({ - childRunId: delegated.childRunId, - status: "completed", - summary: delegatedResult, - resultContextTransferId: delegated.resultContextTransferId, - hasPendingChildRuns: false, - latestTerminalRunId: queuedFollowup.runId, - latestTerminalStatus: "completed", - latestTerminalSummary: latestSummary, - }); - const followupTransfer = finishedParent.contextTransfers.find( + ); + const finishedParent = yield* orchestrator.getThreadProjection(parentThreadId); + expect( + finishedParent.subagents.find((task) => task.id === followupTasks[0]!.id)?.status, + ).toBe("interrupted"); + expect( + finishedParent.subagents.find((task) => task.id === followupTasks[1]!.id), + ).toMatchObject({ + status: "completed", + result, + }); + expect( + finishedParent.contextTransfers.filter( (transfer) => transfer.type === "subagent_result" && - transfer.sourcePoint.runId === queuedFollowup.runId, - ); - expect(followupTransfer).toBeDefined(); - expect(finalStatus.latestTerminalResultContextTransferId).toBe(followupTransfer?.id); - expect(finalStatus.latestTerminalResultContextTransferId).not.toBe( - delegated.resultContextTransferId, - ); - const transferReads: Array = []; - const messageReads: Array = []; - const runReads: Array = []; - const projectionTableReads: Array = []; - const acknowledgementReads: Array = []; - const tracer = Tracer.make({ - span(options) { - const span = new Tracer.NativeSpan(options); - const end = span.end.bind(span); - span.end = (endTime, exit) => { - end(endTime, exit); - const query = span.attributes.get("db.query.text"); - if (typeof query !== "string") return; - let parent = Option.getOrUndefined(span.parent); - while (parent?._tag === "Span") { - if ( - parent.name === "Orchestrator.dispatchDelegatedTaskCompletionDeliveryResolution" - ) { - acknowledgementReads.push(query); - break; - } - parent = Option.getOrUndefined(parent.parent); - } - for (const [, table] of query.matchAll( - /FROM orchestration_v2_projection_(\w+)/gi, - )) { - projectionTableReads.push(table!); - } - if ( - /SELECT[\s\S]*FROM orchestration_v2_projection_context_transfers/i.test(query) - ) { - transferReads.push(query); - } - if (/SELECT\s+payload_json\s+FROM orchestration_v2_projection_runs/i.test(query)) { - runReads.push(query); - } + transfer.sourceThreadId === delegated.childThreadId, + ), + ).toHaveLength(3); + expect( + finishedParent.subagents.find((task) => task.id === followupTasks[1]!.id) + ?.completionDelivery?.state, + ).toBe("claimed"); + const finalStatusCall = yield* invoke("task_status", { + taskId: delegated.taskId, + }); + const finalStatus = yield* decodeDelegateTaskResult(finalStatusCall.structuredContent).pipe( + Effect.orDie, + ); + expect(finalStatus).toMatchObject({ + childRunId: delegated.childRunId, + status: "completed", + summary: delegatedResult, + resultContextTransferId: delegated.resultContextTransferId, + hasPendingChildRuns: false, + latestTerminalRunId: queuedFollowup.runId, + latestTerminalStatus: "completed", + latestTerminalSummary: latestSummary, + }); + const followupTransfer = finishedParent.contextTransfers.find( + (transfer) => + transfer.type === "subagent_result" && + transfer.sourcePoint.runId === queuedFollowup.runId, + ); + expect(followupTransfer).toBeDefined(); + expect(finalStatus.latestTerminalResultContextTransferId).toBe(followupTransfer?.id); + expect(finalStatus.latestTerminalResultContextTransferId).not.toBe( + delegated.resultContextTransferId, + ); + const transferReads: Array = []; + const messageReads: Array = []; + const runReads: Array = []; + const projectionTableReads: Array = []; + const acknowledgementReads: Array = []; + const tracer = Tracer.make({ + span(options) { + const span = new Tracer.NativeSpan(options); + const end = span.end.bind(span); + span.end = (endTime, exit) => { + end(endTime, exit); + const query = span.attributes.get("db.query.text"); + if (typeof query !== "string") return; + let parent = Option.getOrUndefined(span.parent); + while (parent?._tag === "Span") { if ( - /SELECT\s+payload_json\s+FROM orchestration_v2_projection_messages/i.test(query) + parent.name === "Orchestrator.dispatchDelegatedTaskCompletionDeliveryResolution" ) { - messageReads.push(query); + acknowledgementReads.push(query); + break; } - }; - return span; - }, - }); - const wait = yield* invoke( - "t3_thread_wait", - { threadId: delegated.childThreadId, runId: queuedFollowup.runId }, - { ...invocation, thread: undefined }, - ).pipe(Effect.withTracer(tracer)); - expect(wait.isError).not.toBe(true); - const expectedWait = { + parent = Option.getOrUndefined(parent.parent); + } + for (const [, table] of query.matchAll(/FROM orchestration_v2_projection_(\w+)/gi)) { + projectionTableReads.push(table!); + } + if (/SELECT[\s\S]*FROM orchestration_v2_projection_context_transfers/i.test(query)) { + transferReads.push(query); + } + if (/SELECT\s+payload_json\s+FROM orchestration_v2_projection_runs/i.test(query)) { + runReads.push(query); + } + if ( + /SELECT\s+payload_json\s+FROM orchestration_v2_projection_messages/i.test(query) + ) { + messageReads.push(query); + } + }; + return span; + }, + }); + const wait = yield* invoke( + "t3_thread_wait", + { threadId: delegated.childThreadId, runId: queuedFollowup.runId }, + { ...invocation, thread: undefined }, + ).pipe(Effect.withTracer(tracer)); + expect(wait.isError).not.toBe(true); + const expectedWait = { + threadId: delegated.childThreadId, + runId: queuedFollowup.runId, + status: "completed", + timedOut: false, + }; + expect(wait.structuredContent).toEqual(expectedWait); + expect(messageReads).toEqual([]); + expect(transferReads).toHaveLength(0); + const settledWait = yield* invoke( + "t3_thread_wait", + { threadId: delegated.childThreadId, runId: queuedFollowup.runId, - status: "completed", - timedOut: false, - }; - expect(wait.structuredContent).toEqual(expectedWait); - expect(messageReads).toEqual([]); - expect(transferReads).toHaveLength(0); - const settledWait = yield* invoke( - "t3_thread_wait", - { - threadId: delegated.childThreadId, - runId: queuedFollowup.runId, - untilDelegatedWorkSettled: true, - }, - { ...invocation, thread: undefined }, - ).pipe(Effect.withTracer(tracer)); - expect(settledWait.isError).not.toBe(true); - expect(settledWait.structuredContent).toMatchObject({ - ...expectedWait, - resultRun: { runId: queuedFollowup.runId, status: "completed" }, + untilDelegatedWorkSettled: true, + }, + { ...invocation, thread: undefined }, + ).pipe(Effect.withTracer(tracer)); + expect(settledWait.isError).not.toBe(true); + expect(settledWait.structuredContent).toMatchObject({ + ...expectedWait, + resultRun: { runId: queuedFollowup.runId, status: "completed" }, + }); + expect(messageReads.length).toBeGreaterThan(0); + expect( + messageReads.filter((query) => query.includes("$.notification.source.kind")), + ).toHaveLength(1); + // The orchestrator's restart-continuation guard still reads its own records. + expect( + messageReads.filter((query) => !query.includes("$.notification.source.kind")), + ).toHaveLength(1); + const parentlessRead = yield* invoke( + "t3_thread_read", + { + threadId: delegated.childThreadId, + limit: 100, + maxCharsPerItem: result === "😀" ? 1 : 20_000, + }, + { ...invocation, thread: undefined }, + ).pipe(Effect.withTracer(tracer)); + expect(parentlessRead.isError).not.toBe(true); + expect(transferReads).toHaveLength(0); + const afterParentlessRead = yield* orchestrator.getThreadProjection(parentThreadId); + expect( + afterParentlessRead.subagents.find((task) => task.id === queuedFollowup.taskId) + ?.completionDelivery?.state, + ).toBe("claimed"); + + const read = yield* invoke("t3_thread_read", { + threadId: delegated.childThreadId, + limit: 100, + ...(result === "😀" ? { maxCharsPerItem: 1 } : {}), + }).pipe(Effect.withTracer(tracer)); + expect(read.isError).not.toBe(true); + expect(transferReads.length).toBeGreaterThan(0); + const firstPage = yield* decodeThreadRead(read.structuredContent); + const finalItem = firstPage.items.find((item) => item.text === result.slice(0, 500)); + expect(finalItem).toBeDefined(); + if (result.length > 500) { + yield* invoke("t3_thread_read", { + threadId: delegated.childThreadId, + itemId: finalItem!.itemId, + textOffset: result.length, }); - expect(messageReads.length).toBeGreaterThan(0); + } else { + expect(read.structuredContent).toEqual(parentlessRead.structuredContent); + } + let text = finalItem!.text ?? ""; + let nextTextOffset = finalItem?.nextTextOffset ?? null; + while (nextTextOffset !== null) { + const beforeContinuation = yield* orchestrator.getThreadProjection(parentThreadId); expect( - messageReads.filter((query) => query.includes("$.notification.source.kind")), - ).toHaveLength(1); - // The orchestrator's restart-continuation guard still reads its own records. - expect( - messageReads.filter((query) => !query.includes("$.notification.source.kind")), - ).toHaveLength(1); - const parentlessRead = yield* invoke( - "t3_thread_read", - { - threadId: delegated.childThreadId, - limit: 100, - maxCharsPerItem: result === "😀" ? 1 : 20_000, - }, - { ...invocation, thread: undefined }, - ).pipe(Effect.withTracer(tracer)); - expect(parentlessRead.isError).not.toBe(true); - expect(transferReads).toHaveLength(0); - const afterParentlessRead = yield* orchestrator.getThreadProjection(parentThreadId); - expect( - afterParentlessRead.subagents.find((task) => task.id === queuedFollowup.taskId) + beforeContinuation.subagents.find((task) => task.id === queuedFollowup.taskId) ?.completionDelivery?.state, ).toBe("claimed"); - - const read = yield* invoke("t3_thread_read", { + const continuation = yield* invoke("t3_thread_read", { threadId: delegated.childThreadId, - limit: 100, - ...(result === "😀" ? { maxCharsPerItem: 1 } : {}), + itemId: finalItem!.itemId, + textOffset: nextTextOffset, }).pipe(Effect.withTracer(tracer)); - expect(read.isError).not.toBe(true); - expect(transferReads.length).toBeGreaterThan(0); - const firstPage = yield* decodeThreadRead(read.structuredContent); - const finalItem = firstPage.items.find((item) => item.text === result.slice(0, 500)); - expect(finalItem).toBeDefined(); - if (result.length > 500) { - yield* invoke("t3_thread_read", { - threadId: delegated.childThreadId, - itemId: finalItem!.itemId, - textOffset: result.length, - }); - } else { - expect(read.structuredContent).toEqual(parentlessRead.structuredContent); - } - let text = finalItem!.text ?? ""; - let nextTextOffset = finalItem?.nextTextOffset ?? null; - while (nextTextOffset !== null) { - const beforeContinuation = yield* orchestrator.getThreadProjection(parentThreadId); - expect( - beforeContinuation.subagents.find((task) => task.id === queuedFollowup.taskId) - ?.completionDelivery?.state, - ).toBe("claimed"); - const continuation = yield* invoke("t3_thread_read", { - threadId: delegated.childThreadId, - itemId: finalItem!.itemId, - textOffset: nextTextOffset, - }).pipe(Effect.withTracer(tracer)); - const nextPage = yield* decodeThreadRead(continuation.structuredContent); - text += nextPage.items[0]?.text ?? ""; - nextTextOffset = nextPage.items[0]?.nextTextOffset ?? null; - } - expect(text).toBe(result); - // The acknowledgement reads the task and its spawning run by id, not the parent's history. - expect(acknowledgementReads.length).toBeGreaterThan(0); - expect( - acknowledgementReads.filter( - (query) => - /FROM orchestration_v2_projection_(nodes|run_attempts)\b/.test(query) || - (/FROM orchestration_v2_projection_runs\b/.test(query) && - !query.includes("AND run_id IN")) || - (/FROM orchestration_v2_projection_subagents\b/.test(query) && - !query.includes("AND subagent_id IN")), - ), - ).toEqual([]); - const afterRead = yield* orchestrator.getThreadProjection(parentThreadId); - expect( - afterRead.subagents.find((task) => task.id === queuedFollowup.taskId) - ?.completionDelivery?.state, - ).toBe("acknowledged"); + const nextPage = yield* decodeThreadRead(continuation.structuredContent); + text += nextPage.items[0]?.text ?? ""; + nextTextOffset = nextPage.items[0]?.nextTextOffset ?? null; + } + expect(text).toBe(result); + // The acknowledgement reads the task and its spawning run by id, not the parent's history. + expect(acknowledgementReads.length).toBeGreaterThan(0); + expect( + acknowledgementReads.filter( + (query) => + /FROM orchestration_v2_projection_(nodes|run_attempts)\b/.test(query) || + (/FROM orchestration_v2_projection_runs\b/.test(query) && + !query.includes("AND run_id IN")) || + (/FROM orchestration_v2_projection_subagents\b/.test(query) && + !query.includes("AND subagent_id IN")), + ), + ).toEqual([]); + const afterRead = yield* orchestrator.getThreadProjection(parentThreadId); + expect( + afterRead.subagents.find((task) => task.id === queuedFollowup.taskId)?.completionDelivery + ?.state, + ).toBe("acknowledged"); - const messageReadsBeforeCapabilities = messageReads.length; - const tableReadsBeforeCapabilities = projectionTableReads.length; - const capabilities = yield* invoke("orchestrator_capabilities", {}).pipe( - Effect.withTracer(tracer), - ); - expect(capabilities.isError).not.toBe(true); - expect(messageReads).toHaveLength(messageReadsBeforeCapabilities); - // The caller check reads only the thread row, never its history. - expect(new Set(projectionTableReads.slice(tableReadsBeforeCapabilities))).toEqual( - new Set(["threads"]), - ); - const runReadsBeforeStatus = runReads.length; - const transferReadsBeforeStatus = transferReads.length; - const threadRecordReadsBeforeStatus = threadRecordReads.length; - const statusAgain = yield* invoke("task_status", { taskId: delegated.taskId }).pipe( - Effect.withTracer(tracer), - ); - expect(statusAgain.isError).not.toBe(true); - // A task wait reads result transfers and runs from the child; the parent's stay unread. - expect(runReads).toHaveLength(runReadsBeforeStatus + 1); - expect(transferReads).toHaveLength(transferReadsBeforeStatus + 1); - expect( - threadRecordReads - .slice(threadRecordReadsBeforeStatus) - .filter( - ({ fields }) => fields.includes("runs") || fields.includes("contextTransfers"), - ) - .map(({ threadId }) => threadId), - ).toEqual([delegated.childThreadId]); - }).pipe(Effect.provide(layerTest)); - }), - ), + const messageReadsBeforeCapabilities = messageReads.length; + const tableReadsBeforeCapabilities = projectionTableReads.length; + const capabilities = yield* invoke("orchestrator_capabilities", {}).pipe( + Effect.withTracer(tracer), + ); + expect(capabilities.isError).not.toBe(true); + expect(messageReads).toHaveLength(messageReadsBeforeCapabilities); + // The caller check reads only the thread row, never its history. + expect(new Set(projectionTableReads.slice(tableReadsBeforeCapabilities))).toEqual( + new Set(["threads"]), + ); + const runReadsBeforeStatus = runReads.length; + const transferReadsBeforeStatus = transferReads.length; + const threadRecordReadsBeforeStatus = threadRecordReads.length; + const statusAgain = yield* invoke("task_status", { taskId: delegated.taskId }).pipe( + Effect.withTracer(tracer), + ); + expect(statusAgain.isError).not.toBe(true); + // A task wait reads result transfers and runs from the child; the parent's stay unread. + expect(runReads).toHaveLength(runReadsBeforeStatus + 1); + expect(transferReads).toHaveLength(transferReadsBeforeStatus + 1); + expect( + threadRecordReads + .slice(threadRecordReadsBeforeStatus) + .filter(({ fields }) => fields.includes("runs") || fields.includes("contextTransfers")) + .map(({ threadId }) => threadId), + ).toEqual([delegated.childThreadId]); + }).pipe(Effect.provide(layerTest)); + }), ); }); diff --git a/apps/server/src/mcp/PreviewAutomationBroker.test.ts b/apps/server/src/mcp/PreviewAutomationBroker.test.ts index 1c193096db273..e800570b8ba57 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.test.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.test.ts @@ -66,230 +66,220 @@ const requestsFrom = ( ); it.effect("atomically registers a connected host and correlates its response", () => - Effect.scoped( - Effect.gen(function* () { - const broker = yield* makeBroker; - const requests = requestsFrom(yield* broker.connect(makeHost())); - yield* Stream.runForEach(requests, (request) => - broker.respond({ - clientId: "client-1", - connectionId: request.connectionId, - requestId: request.requestId, - ok: true, - result: { available: true }, - }), - ).pipe(Effect.forkScoped); - yield* Effect.yieldNow; + Effect.gen(function* () { + const broker = yield* makeBroker; + const requests = requestsFrom(yield* broker.connect(makeHost())); + yield* Stream.runForEach(requests, (request) => + broker.respond({ + clientId: "client-1", + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + result: { available: true }, + }), + ).pipe(Effect.forkScoped); + yield* Effect.yieldNow; - const result = yield* broker.invoke<{ available: boolean }>({ - scope, - operation: "open", - input: {}, - }); + const result = yield* broker.invoke<{ available: boolean }>({ + scope, + operation: "open", + input: {}, + }); - expect(result).toEqual({ available: true }); - }), - ), + expect(result).toEqual({ available: true }); + }), ); it.effect("targets multiple tabs explicitly while retaining a default tab", () => - Effect.scoped( - Effect.gen(function* () { - const broker = yield* makeBroker; - const appTabId = PreviewTabId.make("tab-web-app"); - const simulatorTabId = PreviewTabId.make("tab-ios-simulator"); - const openedTabIds = [appTabId, simulatorTabId]; - let openIndex = 0; - const routedRequests: RoutedRequest[] = []; - const requests = requestsFrom(yield* broker.connect(makeHost())); - yield* Stream.runForEach(requests, (request) => { - routedRequests.push(request); - return broker.respond({ - clientId: "client-1", - connectionId: request.connectionId, - requestId: request.requestId, - ok: true, - result: - request.operation === "open" - ? { available: true, tabId: openedTabIds[openIndex++] } - : { url: "http://localhost:3200" }, - }); - }).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - - yield* broker.invoke({ scope, operation: "open", input: { reuseExistingTab: false } }); - yield* broker.invoke({ scope, operation: "open", input: { reuseExistingTab: false } }); - yield* broker.invoke({ scope, operation: "snapshot", input: {} }); - yield* broker.invoke({ scope, operation: "snapshot", input: {}, tabId: appTabId }); - yield* broker.invoke({ scope, operation: "snapshot", input: {} }); - - expect(routedRequests).toHaveLength(5); - expect(routedRequests[0]?.tabId).toBeUndefined(); - expect(routedRequests[1]?.tabId).toBe(appTabId); - expect(routedRequests[2]?.tabId).toBe(simulatorTabId); - expect(routedRequests[2]?.tabIdExplicit).toBe(false); - expect(routedRequests[3]?.tabId).toBe(appTabId); - expect(routedRequests[3]?.tabIdExplicit).toBe(true); - expect(routedRequests[4]?.tabId).toBe(appTabId); - }), - ), + Effect.gen(function* () { + const broker = yield* makeBroker; + const appTabId = PreviewTabId.make("tab-web-app"); + const simulatorTabId = PreviewTabId.make("tab-ios-simulator"); + const openedTabIds = [appTabId, simulatorTabId]; + let openIndex = 0; + const routedRequests: RoutedRequest[] = []; + const requests = requestsFrom(yield* broker.connect(makeHost())); + yield* Stream.runForEach(requests, (request) => { + routedRequests.push(request); + return broker.respond({ + clientId: "client-1", + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + result: + request.operation === "open" + ? { available: true, tabId: openedTabIds[openIndex++] } + : { url: "http://localhost:3200" }, + }); + }).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + + yield* broker.invoke({ scope, operation: "open", input: { reuseExistingTab: false } }); + yield* broker.invoke({ scope, operation: "open", input: { reuseExistingTab: false } }); + yield* broker.invoke({ scope, operation: "snapshot", input: {} }); + yield* broker.invoke({ scope, operation: "snapshot", input: {}, tabId: appTabId }); + yield* broker.invoke({ scope, operation: "snapshot", input: {} }); + + expect(routedRequests).toHaveLength(5); + expect(routedRequests[0]?.tabId).toBeUndefined(); + expect(routedRequests[1]?.tabId).toBe(appTabId); + expect(routedRequests[2]?.tabId).toBe(simulatorTabId); + expect(routedRequests[2]?.tabIdExplicit).toBe(false); + expect(routedRequests[3]?.tabId).toBe(appTabId); + expect(routedRequests[3]?.tabIdExplicit).toBe(true); + expect(routedRequests[4]?.tabId).toBe(appTabId); + }), ); it.effect.each([true, false])( "keeps an older target stable while a newer explicit tab responds (implicit: %s)", (implicit) => - Effect.scoped( - Effect.gen(function* () { - const broker = yield* makeBroker; - const olderTabId = PreviewTabId.make("tab-older-request"); - const newerTabId = PreviewTabId.make("tab-newer-request"); - const releaseOlderResponse = yield* Deferred.make(); - const routedRequests: RoutedRequest[] = []; - const requests = requestsFrom(yield* broker.connect(makeHost())); - yield* Stream.runForEach(requests, (request) => { - routedRequests.push(request); - const response = Effect.gen(function* () { - if (request.tabId === olderTabId && request.operation === "snapshot") { - yield* Deferred.await(releaseOlderResponse); - } - yield* broker.respond({ - clientId: "client-1", - connectionId: request.connectionId, - requestId: request.requestId, - ok: true, - result: { url: "http://localhost:3200" }, - }); - if (request.tabId === newerTabId) { - yield* Deferred.succeed(releaseOlderResponse, undefined); - } - }); - return response.pipe(Effect.forkScoped, Effect.asVoid); - }).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - - yield* broker.invoke({ scope, operation: "status", input: {}, tabId: olderTabId }); - let capturedTabId: PreviewTabId | undefined; - const older = yield* broker - .invoke({ - scope, - operation: "snapshot", - input: {}, - ...(implicit ? {} : { tabId: olderTabId }), - onTargetTab: (tabId) => { - capturedTabId = tabId; - }, - }) - .pipe(Effect.forkScoped); - yield* Effect.yieldNow; - const newer = yield* broker - .invoke({ scope, operation: "snapshot", input: {}, tabId: newerTabId }) - .pipe(Effect.forkScoped); - yield* Fiber.join(newer); - yield* Fiber.join(older); - yield* broker.invoke({ - scope, - operation: "status", - input: {}, - tabId: olderTabId, - updateCurrentTab: false, - }); - yield* broker.invoke({ scope, operation: "snapshot", input: {} }); - - expect(routedRequests.at(-1)?.tabId).toBe(newerTabId); - expect(capturedTabId).toBe(olderTabId); - }), - ), -); - -it.effect("does not let a no-tab response suppress an earlier tab decision", () => - Effect.scoped( Effect.gen(function* () { const broker = yield* makeBroker; - const initialTabId = PreviewTabId.make("tab-initial"); - const openedTabId = PreviewTabId.make("tab-opened-late"); - const releaseOpenResponse = yield* Deferred.make(); + const olderTabId = PreviewTabId.make("tab-older-request"); + const newerTabId = PreviewTabId.make("tab-newer-request"); + const releaseOlderResponse = yield* Deferred.make(); const routedRequests: RoutedRequest[] = []; const requests = requestsFrom(yield* broker.connect(makeHost())); yield* Stream.runForEach(requests, (request) => { routedRequests.push(request); - const marker = - typeof request.input === "object" && request.input !== null && "marker" in request.input - ? request.input.marker - : undefined; const response = Effect.gen(function* () { - if (marker === "older") { - yield* Deferred.await(releaseOpenResponse); + if (request.tabId === olderTabId && request.operation === "snapshot") { + yield* Deferred.await(releaseOlderResponse); } yield* broker.respond({ clientId: "client-1", connectionId: request.connectionId, requestId: request.requestId, ok: true, - result: - request.operation === "open" - ? { available: true, tabId: marker === "older" ? openedTabId : initialTabId } - : { url: "http://localhost:3200" }, + result: { url: "http://localhost:3200" }, }); - if (marker === "newer") { - yield* Deferred.succeed(releaseOpenResponse, undefined); + if (request.tabId === newerTabId) { + yield* Deferred.succeed(releaseOlderResponse, undefined); } }); return response.pipe(Effect.forkScoped, Effect.asVoid); }).pipe(Effect.forkScoped); yield* Effect.yieldNow; - yield* broker.invoke({ scope, operation: "open", input: {} }); + yield* broker.invoke({ scope, operation: "status", input: {}, tabId: olderTabId }); + let capturedTabId: PreviewTabId | undefined; const older = yield* broker .invoke({ scope, - operation: "open", - input: { marker: "older", reuseExistingTab: false }, + operation: "snapshot", + input: {}, + ...(implicit ? {} : { tabId: olderTabId }), + onTargetTab: (tabId) => { + capturedTabId = tabId; + }, }) .pipe(Effect.forkScoped); yield* Effect.yieldNow; const newer = yield* broker - .invoke({ scope, operation: "snapshot", input: { marker: "newer" } }) + .invoke({ scope, operation: "snapshot", input: {}, tabId: newerTabId }) .pipe(Effect.forkScoped); yield* Fiber.join(newer); yield* Fiber.join(older); + yield* broker.invoke({ + scope, + operation: "status", + input: {}, + tabId: olderTabId, + updateCurrentTab: false, + }); yield* broker.invoke({ scope, operation: "snapshot", input: {} }); - expect(routedRequests.at(-1)?.tabId).toBe(openedTabId); + expect(routedRequests.at(-1)?.tabId).toBe(newerTabId); + expect(capturedTabId).toBe(olderTabId); }), - ), +); + +it.effect("does not let a no-tab response suppress an earlier tab decision", () => + Effect.gen(function* () { + const broker = yield* makeBroker; + const initialTabId = PreviewTabId.make("tab-initial"); + const openedTabId = PreviewTabId.make("tab-opened-late"); + const releaseOpenResponse = yield* Deferred.make(); + const routedRequests: RoutedRequest[] = []; + const requests = requestsFrom(yield* broker.connect(makeHost())); + yield* Stream.runForEach(requests, (request) => { + routedRequests.push(request); + const marker = + typeof request.input === "object" && request.input !== null && "marker" in request.input + ? request.input.marker + : undefined; + const response = Effect.gen(function* () { + if (marker === "older") { + yield* Deferred.await(releaseOpenResponse); + } + yield* broker.respond({ + clientId: "client-1", + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + result: + request.operation === "open" + ? { available: true, tabId: marker === "older" ? openedTabId : initialTabId } + : { url: "http://localhost:3200" }, + }); + if (marker === "newer") { + yield* Deferred.succeed(releaseOpenResponse, undefined); + } + }); + return response.pipe(Effect.forkScoped, Effect.asVoid); + }).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + + yield* broker.invoke({ scope, operation: "open", input: {} }); + const older = yield* broker + .invoke({ + scope, + operation: "open", + input: { marker: "older", reuseExistingTab: false }, + }) + .pipe(Effect.forkScoped); + yield* Effect.yieldNow; + const newer = yield* broker + .invoke({ scope, operation: "snapshot", input: { marker: "newer" } }) + .pipe(Effect.forkScoped); + yield* Fiber.join(newer); + yield* Fiber.join(older); + yield* broker.invoke({ scope, operation: "snapshot", input: {} }); + + expect(routedRequests.at(-1)?.tabId).toBe(openedTabId); + }), ); it.effect("announces a live replacement stream before delivering requests", () => - Effect.scoped( - Effect.gen(function* () { - const broker = yield* makeBroker; - const events = yield* broker.connect(makeHost()); - const receivedTypes: PreviewAutomationStreamEvent["type"][] = []; - const consumer = yield* events.pipe( - Stream.take(2), - Stream.runForEach((event) => { - receivedTypes.push(event.type); - return event.type === "connected" - ? Effect.void - : broker.respond({ - clientId: "client-1", - connectionId: event.connectionId, - requestId: event.request.requestId, - ok: true, - result: "ready", - }); - }), - Effect.forkScoped, - ); - yield* Effect.yieldNow; + Effect.gen(function* () { + const broker = yield* makeBroker; + const events = yield* broker.connect(makeHost()); + const receivedTypes: PreviewAutomationStreamEvent["type"][] = []; + const consumer = yield* events.pipe( + Stream.take(2), + Stream.runForEach((event) => { + receivedTypes.push(event.type); + return event.type === "connected" + ? Effect.void + : broker.respond({ + clientId: "client-1", + connectionId: event.connectionId, + requestId: event.request.requestId, + ok: true, + result: "ready", + }); + }), + Effect.forkScoped, + ); + yield* Effect.yieldNow; - const result = yield* broker.invoke({ scope, operation: "status", input: {} }); - yield* Fiber.join(consumer); + const result = yield* broker.invoke({ scope, operation: "status", input: {} }); + yield* Fiber.join(consumer); - expect(receivedTypes).toEqual(["connected", "request"]); - expect(result).toBe("ready"); - }), - ), + expect(receivedTypes).toEqual(["connected", "request"]); + expect(result).toBe("ready"); + }), ); it.effect.each([ @@ -309,101 +299,95 @@ it.effect.each([ message: new PreviewBrowserRootError().message, }, ])("preserves server browser failure guidance ($clientId)", ({ clientId, shown, message }) => - Effect.scoped( - Effect.gen(function* () { - const broker = yield* makeBroker; - const requests = requestsFrom(yield* broker.connect(makeHost({ clientId }))); - yield* Stream.runForEach(requests, (request) => - broker.respond({ - clientId, - connectionId: request.connectionId, - requestId: request.requestId, - ok: false, - error: { - _tag: "PreviewAutomationExecutionError", - message, - }, - }), - ).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - const error = yield* broker - .invoke({ scope, operation: "open", input: {} }) - .pipe(Effect.flip); - expect(error._tag).toBe("PreviewAutomationExecutionError"); - expect(error.message.includes(message)).toBe(shown); - }), - ), + Effect.gen(function* () { + const broker = yield* makeBroker; + const requests = requestsFrom(yield* broker.connect(makeHost({ clientId }))); + yield* Stream.runForEach(requests, (request) => + broker.respond({ + clientId, + connectionId: request.connectionId, + requestId: request.requestId, + ok: false, + error: { + _tag: "PreviewAutomationExecutionError", + message, + }, + }), + ).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + const error = yield* broker + .invoke({ scope, operation: "open", input: {} }) + .pipe(Effect.flip); + expect(error._tag).toBe("PreviewAutomationExecutionError"); + expect(error.message.includes(message)).toBe(shown); + }), ); it.effect("names the click target that matched nothing when the server browser times out", () => - Effect.scoped( - Effect.gen(function* () { - const broker = yield* makeBroker; - const clientId = SERVER_BROWSER_AUTOMATION_CLIENT_ID; - const requests = requestsFrom(yield* broker.connect(makeHost({ clientId }))); - yield* Stream.runForEach(requests, (request) => - broker.respond({ - clientId, - connectionId: request.connectionId, - requestId: request.requestId, - ok: false, - error: { - _tag: "PreviewAutomationTimeoutError", - message: "No element matches the target.", - detail: { reason: 'No element matches ".definitely-no-such-t3-target".' }, - }, - }), - ).pipe(Effect.forkScoped); - yield* Effect.yieldNow; + Effect.gen(function* () { + const broker = yield* makeBroker; + const clientId = SERVER_BROWSER_AUTOMATION_CLIENT_ID; + const requests = requestsFrom(yield* broker.connect(makeHost({ clientId }))); + yield* Stream.runForEach(requests, (request) => + broker.respond({ + clientId, + connectionId: request.connectionId, + requestId: request.requestId, + ok: false, + error: { + _tag: "PreviewAutomationTimeoutError", + message: "No element matches the target.", + detail: { reason: 'No element matches ".definitely-no-such-t3-target".' }, + }, + }), + ).pipe(Effect.forkScoped); + yield* Effect.yieldNow; - const error = yield* broker - .invoke({ - scope, - operation: "click", - input: { selector: ".definitely-no-such-t3-target", timeoutMs: 15_000 }, - }) - .pipe(Effect.flip); + const error = yield* broker + .invoke({ + scope, + operation: "click", + input: { selector: ".definitely-no-such-t3-target", timeoutMs: 15_000 }, + }) + .pipe(Effect.flip); - expect(error._tag).toBe("PreviewAutomationTimeoutError"); - expect(error.message).toBe( - 'Preview automation click timed out after 15000ms. No element matches ".definitely-no-such-t3-target".', - ); - }), - ), + expect(error._tag).toBe("PreviewAutomationTimeoutError"); + expect(error.message).toBe( + 'Preview automation click timed out after 15000ms. No element matches ".definitely-no-such-t3-target".', + ); + }), ); it.effect("distinguishes malformed remote failures", () => - Effect.scoped( - Effect.gen(function* () { - const broker = yield* makeBroker; - const requests = requestsFrom(yield* broker.connect(makeHost())); - yield* Stream.runForEach(requests, (request) => - broker.respond({ - clientId: "client-1", - connectionId: request.connectionId, - requestId: request.requestId, - ok: false, - }), - ).pipe(Effect.forkScoped); - yield* Effect.yieldNow; + Effect.gen(function* () { + const broker = yield* makeBroker; + const requests = requestsFrom(yield* broker.connect(makeHost())); + yield* Stream.runForEach(requests, (request) => + broker.respond({ + clientId: "client-1", + connectionId: request.connectionId, + requestId: request.requestId, + ok: false, + }), + ).pipe(Effect.forkScoped); + yield* Effect.yieldNow; - const error = yield* broker - .invoke({ scope, operation: "status", input: {}, timeoutMs: 2_000 }) - .pipe(Effect.flip); + const error = yield* broker + .invoke({ scope, operation: "status", input: {}, timeoutMs: 2_000 }) + .pipe(Effect.flip); - expect(error).toBeInstanceOf(PreviewAutomationMalformedResponseError); - expect(error).toMatchObject({ - operation: "status", - environmentId: scope.environmentId, - threadId: scope.thread.threadId, - providerSessionId: scope.thread.providerSessionId, - providerInstanceId: scope.thread.providerInstanceId, - clientId: "client-1", - requestId: "preview-0", - timeoutMs: 2_000, - }); - }), - ), + expect(error).toBeInstanceOf(PreviewAutomationMalformedResponseError); + expect(error).toMatchObject({ + operation: "status", + environmentId: scope.environmentId, + threadId: scope.thread.threadId, + providerSessionId: scope.thread.providerSessionId, + providerInstanceId: scope.thread.providerInstanceId, + clientId: "client-1", + requestId: "preview-0", + timeoutMs: 2_000, + }); + }), ); it.effect("does not create host state from focus updates without a live stream", () => @@ -424,665 +408,617 @@ it.effect("does not create host state from focus updates without a live stream", ); it.effect("removes host availability when the authoritative request stream disconnects", () => - Effect.scoped( - Effect.gen(function* () { - const broker = yield* makeBroker; - const requests = requestsFrom(yield* broker.connect(makeHost())); - const beforeAcquisition = yield* broker - .invoke({ scope, operation: "status", input: {} }) - .pipe(Effect.flip); - expect(beforeAcquisition).toBeInstanceOf(PreviewAutomationNoAvailableHostError); + Effect.gen(function* () { + const broker = yield* makeBroker; + const requests = requestsFrom(yield* broker.connect(makeHost())); + const beforeAcquisition = yield* broker + .invoke({ scope, operation: "status", input: {} }) + .pipe(Effect.flip); + expect(beforeAcquisition).toBeInstanceOf(PreviewAutomationNoAvailableHostError); - const consumer = yield* Stream.runDrain(requests).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - yield* Fiber.interrupt(consumer); + const consumer = yield* Stream.runDrain(requests).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + yield* Fiber.interrupt(consumer); - const error = yield* broker - .invoke({ scope, operation: "status", input: {} }) - .pipe(Effect.flip); - expect(error).toBeInstanceOf(PreviewAutomationNoAvailableHostError); - }), - ), + const error = yield* broker + .invoke({ scope, operation: "status", input: {} }) + .pipe(Effect.flip); + expect(error).toBeInstanceOf(PreviewAutomationNoAvailableHostError); + }), ); it.effect("routes requests for background threads through an environment-level host", () => - Effect.scoped( - Effect.gen(function* () { - const broker = yield* makeBroker; - const backgroundThreadId = ThreadId.make("thread-background"); - const requests = requestsFrom(yield* broker.connect(makeHost())); - let routedThreadId: string | undefined; - yield* Stream.runForEach(requests, (request) => { - routedThreadId = request.threadId; - return broker.respond({ - clientId: "client-1", - connectionId: request.connectionId, - requestId: request.requestId, - ok: true, - result: "background", - }); - }).pipe(Effect.forkScoped); - yield* Effect.yieldNow; + Effect.gen(function* () { + const broker = yield* makeBroker; + const backgroundThreadId = ThreadId.make("thread-background"); + const requests = requestsFrom(yield* broker.connect(makeHost())); + let routedThreadId: string | undefined; + yield* Stream.runForEach(requests, (request) => { + routedThreadId = request.threadId; + return broker.respond({ + clientId: "client-1", + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + result: "background", + }); + }).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + + const result = yield* broker.invoke({ + scope: { + ...scope, + thread: { + ...scope.thread, + threadId: backgroundThreadId, + providerSessionId: "provider-session-background", + }, + }, + operation: "status", + input: {}, + }); + + expect(result).toBe("background"); + expect(routedThreadId).toBe(backgroundThreadId); + }), +); + +it.effect("never routes a provider session to a host from another environment", () => + Effect.gen(function* () { + const broker = yield* makeBroker; + const matchingRequests = requestsFrom( + yield* broker.connect(makeHost({ clientId: "client-matching" })), + ); + const foreignRequests = requestsFrom( + yield* broker.connect( + makeHost({ + clientId: "client-foreign", + environmentId: EnvironmentId.make("environment-foreign"), + }), + ), + ); + yield* Stream.runForEach(matchingRequests, (request) => + broker.respond({ + clientId: "client-matching", + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + result: "matching", + }), + ).pipe(Effect.forkScoped); + yield* Stream.runForEach(foreignRequests, (request) => + broker.respond({ + clientId: "client-foreign", + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + result: "foreign", + }), + ).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + + expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe( + "matching", + ); + }), +); + +it.effect("pins a provider session to its initial host despite later focus changes", () => + Effect.gen(function* () { + const broker = yield* makeBroker; + let firstConnectionId = ""; + let secondConnectionId = ""; + const firstRequests = requestsFrom( + yield* broker.connect(makeHost({ clientId: "client-first" })), + (connectionId) => { + firstConnectionId = connectionId; + }, + ); + const secondRequests = requestsFrom( + yield* broker.connect(makeHost({ clientId: "client-second" })), + (connectionId) => { + secondConnectionId = connectionId; + }, + ); + yield* Stream.runForEach(firstRequests, (request) => + broker.respond({ + clientId: "client-first", + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + result: "first", + }), + ).pipe(Effect.forkScoped); + yield* Stream.runForEach(secondRequests, (request) => + broker.respond({ + clientId: "client-second", + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + result: "second", + }), + ).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + + yield* broker.focusHost({ + clientId: "client-first", + environmentId: scope.environmentId, + connectionId: "connection-stale", + focused: true, + liveTabs: [{ threadId: scope.thread.threadId, tabId: PreviewTabId.make("stale-tab") }], + }); + expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe("second"); + yield* broker.focusHost({ + clientId: "client-first", + environmentId: scope.environmentId, + connectionId: firstConnectionId, + focused: true, + }); - const result = yield* broker.invoke({ + const firstPinnedScope = { + ...scope, + thread: { ...scope.thread, providerSessionId: "provider-session-first-pinned" }, + }; + expect( + yield* broker.invoke({ scope: firstPinnedScope, operation: "status", input: {} }), + ).toBe("first"); + + yield* broker.focusHost({ + clientId: "client-second", + environmentId: scope.environmentId, + connectionId: secondConnectionId, + focused: true, + }); + + expect( + yield* broker.invoke({ scope: firstPinnedScope, operation: "status", input: {} }), + ).toBe("first"); + expect( + yield* broker.invoke({ scope: { ...scope, - thread: { - ...scope.thread, - threadId: backgroundThreadId, - providerSessionId: "provider-session-background", - }, + thread: { ...scope.thread, providerSessionId: "provider-session-second-pinned" }, }, operation: "status", input: {}, - }); - - expect(result).toBe("background"); - expect(routedThreadId).toBe(backgroundThreadId); - }), - ), + }), + ).toBe("second"); + }), ); -it.effect("never routes a provider session to a host from another environment", () => - Effect.scoped( - Effect.gen(function* () { - const broker = yield* makeBroker; - const matchingRequests = requestsFrom( - yield* broker.connect(makeHost({ clientId: "client-matching" })), - ); - const foreignRequests = requestsFrom( - yield* broker.connect( - makeHost({ - clientId: "client-foreign", - environmentId: EnvironmentId.make("environment-foreign"), - }), - ), +it.effect("prefers the live tab owner for new sessions without moving existing leases", () => + Effect.gen(function* () { + const broker = yield* makeBroker; + const connections = new Map(); + for (const clientId of ["owner", "other"]) { + const requests = requestsFrom(yield* broker.connect(makeHost({ clientId })), (connectionId) => + connections.set(clientId, connectionId), ); - yield* Stream.runForEach(matchingRequests, (request) => + yield* Stream.runForEach(requests, (request) => broker.respond({ - clientId: "client-matching", + clientId, connectionId: request.connectionId, requestId: request.requestId, ok: true, - result: "matching", + result: clientId, }), ).pipe(Effect.forkScoped); - yield* Stream.runForEach(foreignRequests, (request) => - broker.respond({ - clientId: "client-foreign", - connectionId: request.connectionId, - requestId: request.requestId, - ok: true, - result: "foreign", - }), - ).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - - expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe( - "matching", - ); - }), - ), -); - -it.effect("pins a provider session to its initial host despite later focus changes", () => - Effect.scoped( - Effect.gen(function* () { - const broker = yield* makeBroker; - let firstConnectionId = ""; - let secondConnectionId = ""; - const firstRequests = requestsFrom( - yield* broker.connect(makeHost({ clientId: "client-first" })), - (connectionId) => { - firstConnectionId = connectionId; + } + yield* Effect.yieldNow; + yield* broker.focusHost({ + clientId: "owner", + environmentId: scope.environmentId, + connectionId: connections.get("owner")!, + focused: false, + liveTabs: [ + { threadId: scope.thread.threadId, tabId: PreviewTabId.make("signed-in"), visible: true }, + ], + }); + yield* broker.focusHost({ + clientId: "other", + environmentId: scope.environmentId, + connectionId: connections.get("other")!, + focused: true, + liveTabs: [ + { + threadId: scope.thread.threadId, + tabId: PreviewTabId.make("signed-in"), + visible: false, }, - ); - const secondRequests = requestsFrom( - yield* broker.connect(makeHost({ clientId: "client-second" })), - (connectionId) => { - secondConnectionId = connectionId; + { + threadId: ThreadId.make("another-thread"), + tabId: PreviewTabId.make("different-tab"), + visible: true, }, - ); - yield* Stream.runForEach(firstRequests, (request) => - broker.respond({ - clientId: "client-first", - connectionId: request.connectionId, - requestId: request.requestId, - ok: true, - result: "first", - }), - ).pipe(Effect.forkScoped); - yield* Stream.runForEach(secondRequests, (request) => - broker.respond({ - clientId: "client-second", - connectionId: request.connectionId, - requestId: request.requestId, - ok: true, - result: "second", - }), - ).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - - yield* broker.focusHost({ - clientId: "client-first", - environmentId: scope.environmentId, - connectionId: "connection-stale", - focused: true, - liveTabs: [{ threadId: scope.thread.threadId, tabId: PreviewTabId.make("stale-tab") }], - }); - expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe( - "second", - ); - yield* broker.focusHost({ - clientId: "client-first", - environmentId: scope.environmentId, - connectionId: firstConnectionId, - focused: true, - }); - - const firstPinnedScope = { - ...scope, - thread: { ...scope.thread, providerSessionId: "provider-session-first-pinned" }, - }; - expect( - yield* broker.invoke({ scope: firstPinnedScope, operation: "status", input: {} }), - ).toBe("first"); - - yield* broker.focusHost({ - clientId: "client-second", - environmentId: scope.environmentId, - connectionId: secondConnectionId, - focused: true, - }); - - expect( - yield* broker.invoke({ scope: firstPinnedScope, operation: "status", input: {} }), - ).toBe("first"); - expect( - yield* broker.invoke({ - scope: { - ...scope, - thread: { ...scope.thread, providerSessionId: "provider-session-second-pinned" }, - }, - operation: "status", - input: {}, - }), - ).toBe("second"); - }), - ), -); + ], + }); + expect(yield* broker.invoke({ scope, operation: "evaluate", input: {} })).toBe("owner"); + expect( + yield* broker.invoke({ + scope: { ...scope, thread: { ...scope.thread, providerSessionId: "explicit-owner" } }, + tabId: PreviewTabId.make("signed-in"), + operation: "snapshot", + input: {}, + }), + ).toBe("owner"); + expect( + yield* broker.invoke({ + scope: { ...scope, thread: { ...scope.thread, providerSessionId: "other-tab" } }, + tabId: PreviewTabId.make("different-tab"), + operation: "evaluate", + input: {}, + }), + ).toBe("other"); -it.effect("prefers the live tab owner for new sessions without moving existing leases", () => - Effect.scoped( - Effect.gen(function* () { - const broker = yield* makeBroker; - const connections = new Map(); - for (const clientId of ["owner", "other"]) { - const requests = requestsFrom( - yield* broker.connect(makeHost({ clientId })), - (connectionId) => connections.set(clientId, connectionId), - ); - yield* Stream.runForEach(requests, (request) => - broker.respond({ - clientId, - connectionId: request.connectionId, - requestId: request.requestId, - ok: true, - result: clientId, - }), - ).pipe(Effect.forkScoped); - } - yield* Effect.yieldNow; - yield* broker.focusHost({ - clientId: "owner", - environmentId: scope.environmentId, - connectionId: connections.get("owner")!, - focused: false, - liveTabs: [ - { threadId: scope.thread.threadId, tabId: PreviewTabId.make("signed-in"), visible: true }, - ], - }); - yield* broker.focusHost({ - clientId: "other", - environmentId: scope.environmentId, - connectionId: connections.get("other")!, - focused: true, - liveTabs: [ - { - threadId: scope.thread.threadId, - tabId: PreviewTabId.make("signed-in"), - visible: false, - }, - { - threadId: ThreadId.make("another-thread"), - tabId: PreviewTabId.make("different-tab"), - visible: true, - }, - ], - }); - expect(yield* broker.invoke({ scope, operation: "evaluate", input: {} })).toBe( - "owner", - ); - expect( - yield* broker.invoke({ - scope: { ...scope, thread: { ...scope.thread, providerSessionId: "explicit-owner" } }, - tabId: PreviewTabId.make("signed-in"), - operation: "snapshot", - input: {}, - }), - ).toBe("owner"); - expect( - yield* broker.invoke({ - scope: { ...scope, thread: { ...scope.thread, providerSessionId: "other-tab" } }, - tabId: PreviewTabId.make("different-tab"), - operation: "evaluate", - input: {}, - }), - ).toBe("other"); - - yield* broker.focusHost({ - clientId: "owner", - environmentId: scope.environmentId, - connectionId: connections.get("owner")!, - focused: false, - liveTabs: [], - }); - expect(yield* broker.invoke({ scope, operation: "evaluate", input: {} })).toBe( - "owner", - ); - expect( - yield* broker.invoke({ - scope: { ...scope, thread: { ...scope.thread, providerSessionId: "after-tab-closed" } }, - operation: "evaluate", - input: {}, - }), - ).toBe("other"); - }), - ), + yield* broker.focusHost({ + clientId: "owner", + environmentId: scope.environmentId, + connectionId: connections.get("owner")!, + focused: false, + liveTabs: [], + }); + expect(yield* broker.invoke({ scope, operation: "evaluate", input: {} })).toBe("owner"); + expect( + yield* broker.invoke({ + scope: { ...scope, thread: { ...scope.thread, providerSessionId: "after-tab-closed" } }, + operation: "evaluate", + input: {}, + }), + ).toBe("other"); + }), ); it.effect("routes resize to a capable host instead of a newer legacy connection", () => - Effect.scoped( - Effect.gen(function* () { - const broker = yield* makeBroker; - const capableRequests = requestsFrom( - yield* broker.connect( - makeHost({ clientId: "client-capable", supportedOperations: ["resize"] }), - ), - ); - const legacyRequests = requestsFrom( - yield* broker.connect(makeHost({ clientId: "client-legacy" })), - ); - yield* Stream.runForEach(capableRequests, (request) => - broker.respond({ - clientId: "client-capable", - connectionId: request.connectionId, - requestId: request.requestId, - ok: true, - result: "capable", - }), - ).pipe(Effect.forkScoped); - yield* Stream.runForEach(legacyRequests, (request) => - broker.respond({ - clientId: "client-legacy", - connectionId: request.connectionId, - requestId: request.requestId, - ok: true, - result: "legacy", - }), - ).pipe(Effect.forkScoped); - yield* Effect.yieldNow; + Effect.gen(function* () { + const broker = yield* makeBroker; + const capableRequests = requestsFrom( + yield* broker.connect( + makeHost({ clientId: "client-capable", supportedOperations: ["resize"] }), + ), + ); + const legacyRequests = requestsFrom( + yield* broker.connect(makeHost({ clientId: "client-legacy" })), + ); + yield* Stream.runForEach(capableRequests, (request) => + broker.respond({ + clientId: "client-capable", + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + result: "capable", + }), + ).pipe(Effect.forkScoped); + yield* Stream.runForEach(legacyRequests, (request) => + broker.respond({ + clientId: "client-legacy", + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + result: "legacy", + }), + ).pipe(Effect.forkScoped); + yield* Effect.yieldNow; - expect( - yield* broker.invoke({ scope, operation: "resize", input: { mode: "fill" } }), - ).toBe("capable"); - }), - ), + expect( + yield* broker.invoke({ scope, operation: "resize", input: { mode: "fill" } }), + ).toBe("capable"); + }), ); it.effect("does not move a live legacy assignment to another runtime for resize", () => - Effect.scoped( - Effect.gen(function* () { - const broker = yield* makeBroker; - const legacyRequests = requestsFrom( - yield* broker.connect(makeHost({ clientId: "client-legacy" })), - ); - yield* Stream.runForEach(legacyRequests, (request) => - broker.respond({ - clientId: "client-legacy", - connectionId: request.connectionId, - requestId: request.requestId, - ok: true, - result: "legacy", - }), - ).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - - expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe( - "legacy", - ); - - const capableRequests = requestsFrom( - yield* broker.connect( - makeHost({ clientId: "client-capable", supportedOperations: ["resize"] }), - ), - ); - yield* Stream.runForEach(capableRequests, (request) => - broker.respond({ - clientId: "client-capable", - connectionId: request.connectionId, - requestId: request.requestId, - ok: true, - result: "capable", - }), - ).pipe(Effect.forkScoped); - yield* Effect.yieldNow; + Effect.gen(function* () { + const broker = yield* makeBroker; + const legacyRequests = requestsFrom( + yield* broker.connect(makeHost({ clientId: "client-legacy" })), + ); + yield* Stream.runForEach(legacyRequests, (request) => + broker.respond({ + clientId: "client-legacy", + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + result: "legacy", + }), + ).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + + expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe("legacy"); + + const capableRequests = requestsFrom( + yield* broker.connect( + makeHost({ clientId: "client-capable", supportedOperations: ["resize"] }), + ), + ); + yield* Stream.runForEach(capableRequests, (request) => + broker.respond({ + clientId: "client-capable", + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + result: "capable", + }), + ).pipe(Effect.forkScoped); + yield* Effect.yieldNow; - const error = yield* broker - .invoke({ scope, operation: "resize", input: { mode: "fill" } }) - .pipe(Effect.flip); - expect(error).toBeInstanceOf(PreviewAutomationNoAvailableHostError); - expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe( - "legacy", - ); - }), - ), + const error = yield* broker + .invoke({ scope, operation: "resize", input: { mode: "fill" } }) + .pipe(Effect.flip); + expect(error).toBeInstanceOf(PreviewAutomationNoAvailableHostError); + expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe("legacy"); + }), ); it.effect("ignores stale focus updates for a different environment", () => - Effect.scoped( - Effect.gen(function* () { - const broker = yield* makeBroker; - let firstConnectionId = ""; - const firstRequests = requestsFrom( - yield* broker.connect(makeHost({ clientId: "client-first" })), - (connectionId) => { - firstConnectionId = connectionId; - }, - ); - const secondRequests = requestsFrom( - yield* broker.connect(makeHost({ clientId: "client-second" })), - ); - yield* Stream.runForEach(firstRequests, (request) => - broker.respond({ - clientId: "client-first", - connectionId: request.connectionId, - requestId: request.requestId, - ok: true, - result: "first", - }), - ).pipe(Effect.forkScoped); - yield* Stream.runForEach(secondRequests, (request) => - broker.respond({ - clientId: "client-second", - connectionId: request.connectionId, - requestId: request.requestId, - ok: true, - result: "second", - }), - ).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - - yield* broker.focusHost({ + Effect.gen(function* () { + const broker = yield* makeBroker; + let firstConnectionId = ""; + const firstRequests = requestsFrom( + yield* broker.connect(makeHost({ clientId: "client-first" })), + (connectionId) => { + firstConnectionId = connectionId; + }, + ); + const secondRequests = requestsFrom( + yield* broker.connect(makeHost({ clientId: "client-second" })), + ); + yield* Stream.runForEach(firstRequests, (request) => + broker.respond({ clientId: "client-first", - environmentId: EnvironmentId.make("environment-stale"), - connectionId: firstConnectionId, - focused: true, - }); + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + result: "first", + }), + ).pipe(Effect.forkScoped); + yield* Stream.runForEach(secondRequests, (request) => + broker.respond({ + clientId: "client-second", + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + result: "second", + }), + ).pipe(Effect.forkScoped); + yield* Effect.yieldNow; - expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe( - "second", - ); - }), - ), + yield* broker.focusHost({ + clientId: "client-first", + environmentId: EnvironmentId.make("environment-stale"), + connectionId: firstConnectionId, + focused: true, + }); + + expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe("second"); + }), ); it.effect("fails over a pinned provider session only after its host disconnects", () => - Effect.scoped( - Effect.gen(function* () { - const broker = yield* makeBroker; - const firstTabId = PreviewTabId.make("tab-on-first-host"); - let firstConnectionId = ""; - let secondRoutedTabId: PreviewTabId | undefined; - const firstRequests = requestsFrom( - yield* broker.connect(makeHost({ clientId: "client-first" })), - (connectionId) => { - firstConnectionId = connectionId; - }, - ); - const secondRequests = requestsFrom( - yield* broker.connect(makeHost({ clientId: "client-second" })), - ); - const firstConsumer = yield* Stream.runForEach(firstRequests, (request) => - broker.respond({ - clientId: "client-first", - connectionId: request.connectionId, - requestId: request.requestId, - ok: true, - result: request.operation === "open" ? { host: "first", tabId: firstTabId } : "first", - }), - ).pipe(Effect.forkScoped); - yield* Stream.runForEach(secondRequests, (request) => { - secondRoutedTabId = request.tabId; - return broker.respond({ - clientId: "client-second", - connectionId: request.connectionId, - requestId: request.requestId, - ok: true, - result: "second", - }); - }).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - - yield* broker.focusHost({ + Effect.gen(function* () { + const broker = yield* makeBroker; + const firstTabId = PreviewTabId.make("tab-on-first-host"); + let firstConnectionId = ""; + let secondRoutedTabId: PreviewTabId | undefined; + const firstRequests = requestsFrom( + yield* broker.connect(makeHost({ clientId: "client-first" })), + (connectionId) => { + firstConnectionId = connectionId; + }, + ); + const secondRequests = requestsFrom( + yield* broker.connect(makeHost({ clientId: "client-second" })), + ); + const firstConsumer = yield* Stream.runForEach(firstRequests, (request) => + broker.respond({ clientId: "client-first", - environmentId: scope.environmentId, - connectionId: firstConnectionId, - focused: true, - liveTabs: [{ threadId: scope.thread.threadId, tabId: firstTabId }], - }); - expect(yield* broker.invoke({ scope, operation: "open", input: {} })).toEqual({ - host: "first", - tabId: firstTabId, + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + result: request.operation === "open" ? { host: "first", tabId: firstTabId } : "first", + }), + ).pipe(Effect.forkScoped); + yield* Stream.runForEach(secondRequests, (request) => { + secondRoutedTabId = request.tabId; + return broker.respond({ + clientId: "client-second", + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + result: "second", }); + }).pipe(Effect.forkScoped); + yield* Effect.yieldNow; - yield* Fiber.interrupt(firstConsumer); - yield* Effect.yieldNow; + yield* broker.focusHost({ + clientId: "client-first", + environmentId: scope.environmentId, + connectionId: firstConnectionId, + focused: true, + liveTabs: [{ threadId: scope.thread.threadId, tabId: firstTabId }], + }); + expect(yield* broker.invoke({ scope, operation: "open", input: {} })).toEqual({ + host: "first", + tabId: firstTabId, + }); - expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe( - "second", - ); - expect(secondRoutedTabId).toBeUndefined(); - }), - ), + yield* Fiber.interrupt(firstConsumer); + yield* Effect.yieldNow; + + expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe("second"); + expect(secondRoutedTabId).toBeUndefined(); + }), ); it.effect("keeps a replacement stream authoritative when the old stream finalizes", () => - Effect.scoped( - Effect.gen(function* () { - const broker = yield* makeBroker; - let firstConnectionId = ""; - let replacementConnectionId = ""; - const firstRequests = requestsFrom(yield* broker.connect(makeHost()), (connectionId) => { - firstConnectionId = connectionId; - }); - yield* Stream.runDrain(firstRequests).pipe(Effect.forkScoped); - yield* Effect.yieldNow; + Effect.gen(function* () { + const broker = yield* makeBroker; + let firstConnectionId = ""; + let replacementConnectionId = ""; + const firstRequests = requestsFrom(yield* broker.connect(makeHost()), (connectionId) => { + firstConnectionId = connectionId; + }); + yield* Stream.runDrain(firstRequests).pipe(Effect.forkScoped); + yield* Effect.yieldNow; - const replacementRequests = requestsFrom( - yield* broker.connect(makeHost()), - (connectionId) => { - replacementConnectionId = connectionId; - }, - ); - yield* Stream.runForEach(replacementRequests, (request) => - broker.respond({ - clientId: "client-1", - connectionId: request.connectionId, - requestId: request.requestId, - ok: true, - result: "replacement", - }), - ).pipe(Effect.forkScoped); - yield* Effect.yieldNow; + const replacementRequests = requestsFrom(yield* broker.connect(makeHost()), (connectionId) => { + replacementConnectionId = connectionId; + }); + yield* Stream.runForEach(replacementRequests, (request) => + broker.respond({ + clientId: "client-1", + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + result: "replacement", + }), + ).pipe(Effect.forkScoped); + yield* Effect.yieldNow; - expect(replacementConnectionId).not.toBe(firstConnectionId); - const result = yield* broker.invoke({ scope, operation: "status", input: {} }); - expect(result).toBe("replacement"); - }), - ), + expect(replacementConnectionId).not.toBe(firstConnectionId); + const result = yield* broker.invoke({ scope, operation: "status", input: {} }); + expect(result).toBe("replacement"); + }), ); it.effect("does not carry a tab id across a replacement automation stream", () => - Effect.scoped( - Effect.gen(function* () { - const broker = yield* makeBroker; - const openedTabId = PreviewTabId.make("tab-first-webcontents"); - const firstRequests = requestsFrom(yield* broker.connect(makeHost())); - yield* Stream.runForEach(firstRequests, (request) => - broker.respond({ - clientId: "client-1", - connectionId: request.connectionId, - requestId: request.requestId, - ok: true, - result: - request.operation === "open" - ? { host: "first", tabId: openedTabId } - : { host: "first" }, - }), - ).pipe(Effect.forkScoped); - yield* Effect.yieldNow; + Effect.gen(function* () { + const broker = yield* makeBroker; + const openedTabId = PreviewTabId.make("tab-first-webcontents"); + const firstRequests = requestsFrom(yield* broker.connect(makeHost())); + yield* Stream.runForEach(firstRequests, (request) => + broker.respond({ + clientId: "client-1", + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + result: + request.operation === "open" ? { host: "first", tabId: openedTabId } : { host: "first" }, + }), + ).pipe(Effect.forkScoped); + yield* Effect.yieldNow; - expect(yield* broker.invoke({ scope, operation: "open", input: {} })).toEqual({ - host: "first", - tabId: openedTabId, - }); + expect(yield* broker.invoke({ scope, operation: "open", input: {} })).toEqual({ + host: "first", + tabId: openedTabId, + }); - const routedRequests: RoutedRequest[] = []; - const replacementRequests = requestsFrom(yield* broker.connect(makeHost())); - yield* Stream.runForEach(replacementRequests, (request) => { - routedRequests.push(request); - return broker.respond({ - clientId: "client-1", - connectionId: request.connectionId, - requestId: request.requestId, - ok: true, - result: "replacement", - }); - }).pipe(Effect.forkScoped); - yield* Effect.yieldNow; + const routedRequests: RoutedRequest[] = []; + const replacementRequests = requestsFrom(yield* broker.connect(makeHost())); + yield* Stream.runForEach(replacementRequests, (request) => { + routedRequests.push(request); + return broker.respond({ + clientId: "client-1", + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + result: "replacement", + }); + }).pipe(Effect.forkScoped); + yield* Effect.yieldNow; - expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe( - "replacement", - ); - expect(routedRequests.at(-1)?.tabId).toBeUndefined(); - }), - ), + expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe( + "replacement", + ); + expect(routedRequests.at(-1)?.tabId).toBeUndefined(); + }), ); it.effect("fails requests assigned to the stream that is replaced", () => - Effect.scoped( - Effect.gen(function* () { - const broker = yield* makeBroker; - const requests = requestsFrom(yield* broker.connect(makeHost())); - yield* Stream.runDrain(requests).pipe(Effect.forkScoped); - const pending = yield* broker - .invoke({ scope, operation: "status", input: {} }) - .pipe(Effect.flip, Effect.forkScoped); - yield* Effect.yieldNow; + Effect.gen(function* () { + const broker = yield* makeBroker; + const requests = requestsFrom(yield* broker.connect(makeHost())); + yield* Stream.runDrain(requests).pipe(Effect.forkScoped); + const pending = yield* broker + .invoke({ scope, operation: "status", input: {} }) + .pipe(Effect.flip, Effect.forkScoped); + yield* Effect.yieldNow; - const replacementRequests = requestsFrom(yield* broker.connect(makeHost())); - yield* Stream.runDrain(replacementRequests).pipe(Effect.forkScoped); + const replacementRequests = requestsFrom(yield* broker.connect(makeHost())); + yield* Stream.runDrain(replacementRequests).pipe(Effect.forkScoped); - const error = yield* Fiber.join(pending); - expect(error).toBeInstanceOf(PreviewAutomationClientDisconnectedError); - expect(error).toMatchObject({ - operation: "status", - environmentId: scope.environmentId, - threadId: scope.thread.threadId, - providerSessionId: scope.thread.providerSessionId, - providerInstanceId: scope.thread.providerInstanceId, - clientId: "client-1", - requestId: "preview-0", - timeoutMs: 15_000, - }); - }), - ), + const error = yield* Fiber.join(pending); + expect(error).toBeInstanceOf(PreviewAutomationClientDisconnectedError); + expect(error).toMatchObject({ + operation: "status", + environmentId: scope.environmentId, + threadId: scope.thread.threadId, + providerSessionId: scope.thread.providerSessionId, + providerInstanceId: scope.thread.providerInstanceId, + clientId: "client-1", + requestId: "preview-0", + timeoutMs: 15_000, + }); + }), ); it.effect("accepts responses only from the host that received the request", () => - Effect.scoped( - Effect.gen(function* () { - const broker = yield* makeBroker; - const requests = requestsFrom(yield* broker.connect(makeHost())); - yield* Stream.runForEach(requests, (request) => - Effect.gen(function* () { - yield* broker.respond({ - clientId: "client-foreign", - connectionId: request.connectionId, - requestId: request.requestId, - ok: true, - result: "foreign", - }); - yield* broker.respond({ - clientId: "client-1", - connectionId: "connection-stale", - requestId: request.requestId, - ok: true, - result: "stale", - }); - yield* broker.respond({ - clientId: "client-1", - connectionId: request.connectionId, - requestId: request.requestId, - ok: true, - result: "owner", - }); - }), - ).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - - const result = yield* broker.invoke({ scope, operation: "status", input: {} }); - expect(result).toBe("owner"); - }), - ), -); - -it.effect("keeps each thread's current tab when threads share a provider session", () => - Effect.scoped( - Effect.gen(function* () { - const broker = yield* makeBroker; - const routed: Array = []; - const requests = requestsFrom(yield* broker.connect(makeHost(), { preferred: true })); - yield* Stream.runForEach(requests, (request) => { - routed.push(request); - return broker.respond({ + Effect.gen(function* () { + const broker = yield* makeBroker; + const requests = requestsFrom(yield* broker.connect(makeHost())); + yield* Stream.runForEach(requests, (request) => + Effect.gen(function* () { + yield* broker.respond({ + clientId: "client-foreign", + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + result: "foreign", + }); + yield* broker.respond({ + clientId: "client-1", + connectionId: "connection-stale", + requestId: request.requestId, + ok: true, + result: "stale", + }); + yield* broker.respond({ clientId: "client-1", connectionId: request.connectionId, requestId: request.requestId, ok: true, - result: { tabId: request.tabId ?? `tab-${request.threadId}` }, + result: "owner", }); - }).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - // Codex runs every thread of an instance in one provider session. - const otherThread = { - ...scope, - thread: { ...scope.thread, threadId: ThreadId.make("thread-2") }, - }; + }), + ).pipe(Effect.forkScoped); + yield* Effect.yieldNow; - yield* broker.invoke({ scope, operation: "open", input: {} }); - yield* broker.invoke({ scope: otherThread, operation: "snapshot", input: {} }); - yield* broker.invoke({ scope, operation: "snapshot", input: {} }); - yield* broker.invoke({ scope: otherThread, operation: "snapshot", input: {} }); - - expect(routed.map(({ threadId, tabId }) => [threadId, tabId])).toEqual([ - ["thread-1", undefined], - ["thread-2", undefined], - ["thread-1", "tab-thread-1"], - ["thread-2", "tab-thread-2"], - ]); - expect(routed[0]!.agentSessionId).not.toBe(routed[1]!.agentSessionId); - }), - ), + const result = yield* broker.invoke({ scope, operation: "status", input: {} }); + expect(result).toBe("owner"); + }), +); + +it.effect("keeps each thread's current tab when threads share a provider session", () => + Effect.gen(function* () { + const broker = yield* makeBroker; + const routed: Array = []; + const requests = requestsFrom(yield* broker.connect(makeHost(), { preferred: true })); + yield* Stream.runForEach(requests, (request) => { + routed.push(request); + return broker.respond({ + clientId: "client-1", + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + result: { tabId: request.tabId ?? `tab-${request.threadId}` }, + }); + }).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + // Codex runs every thread of an instance in one provider session. + const otherThread = { + ...scope, + thread: { ...scope.thread, threadId: ThreadId.make("thread-2") }, + }; + + yield* broker.invoke({ scope, operation: "open", input: {} }); + yield* broker.invoke({ scope: otherThread, operation: "snapshot", input: {} }); + yield* broker.invoke({ scope, operation: "snapshot", input: {} }); + yield* broker.invoke({ scope: otherThread, operation: "snapshot", input: {} }); + + expect(routed.map(({ threadId, tabId }) => [threadId, tabId])).toEqual([ + ["thread-1", undefined], + ["thread-2", undefined], + ["thread-1", "tab-thread-1"], + ["thread-2", "tab-thread-2"], + ]); + expect(routed[0]!.agentSessionId).not.toBe(routed[1]!.agentSessionId); + }), ); diff --git a/apps/server/src/mcp/TaskCancelUsageLimit.integration.test.ts b/apps/server/src/mcp/TaskCancelUsageLimit.integration.test.ts index 2812259af3379..c3e549d14dd75 100644 --- a/apps/server/src/mcp/TaskCancelUsageLimit.integration.test.ts +++ b/apps/server/src/mcp/TaskCancelUsageLimit.integration.test.ts @@ -58,298 +58,293 @@ it.effect.each([ ] as const)( "a delegated child whose task is cancelled $cancel resumes at the reset: $resumed", ({ cancel, cancelStatus, resumed }) => - Effect.scoped( - Effect.gen(function* () { - const cwd = yield* checkpointWorkspace("task-cancel-usage-limit"); - // Set while the provider's usage-limit result is already on its way. - let limitInFlight: Effect.Effect = Effect.void; - const turns: Array<{ - readonly turn: ProviderAdapter.ProviderAdapterV2TurnInput; - readonly events: Queue.Queue; - }> = []; - const adapter: ProviderAdapter.ProviderAdapterV2["Service"] = { - instanceId, - driver, - getCapabilities: () => Effect.succeed(ClaudeProviderCapabilitiesV2), - planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" }), - openSession: (input) => - Effect.gen(function* () { - const now = yield* DateTime.now; - const events = yield* Queue.unbounded(); - return { - instanceId, + Effect.gen(function* () { + const cwd = yield* checkpointWorkspace("task-cancel-usage-limit"); + // Set while the provider's usage-limit result is already on its way. + let limitInFlight: Effect.Effect = Effect.void; + const turns: Array<{ + readonly turn: ProviderAdapter.ProviderAdapterV2TurnInput; + readonly events: Queue.Queue; + }> = []; + const adapter: ProviderAdapter.ProviderAdapterV2["Service"] = { + instanceId, + driver, + getCapabilities: () => Effect.succeed(ClaudeProviderCapabilitiesV2), + planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" }), + openSession: (input) => + Effect.gen(function* () { + const now = yield* DateTime.now; + const events = yield* Queue.unbounded(); + return { + instanceId, + driver, + providerSessionId: input.providerSessionId, + providerSession: { + id: input.providerSessionId, driver, - providerSessionId: input.providerSessionId, - providerSession: { - id: input.providerSessionId, + providerInstanceId: instanceId, + status: "ready", + cwd, + model: modelSelection.model, + capabilities: ClaudeProviderCapabilitiesV2, + createdAt: now, + updatedAt: now, + lastError: null, + }, + events: Stream.fromQueue(events), + ensureThread: ({ threadId }) => + Effect.succeed({ + id: ProviderThreadId.make(`provider-thread:${threadId}`), driver, providerInstanceId: instanceId, - status: "ready", - cwd, - model: modelSelection.model, - capabilities: ClaudeProviderCapabilitiesV2, + providerSessionId: input.providerSessionId, + appThreadId: threadId, + ownerNodeId: null, + nativeThreadRef: { driver, nativeId: `native:${threadId}`, strength: "strong" }, + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: null, + lastRunOrdinal: null, + handoffIds: [], + forkedFrom: null, createdAt: now, updatedAt: now, - lastError: null, - }, - events: Stream.fromQueue(events), - ensureThread: ({ threadId }) => - Effect.succeed({ - id: ProviderThreadId.make(`provider-thread:${threadId}`), + }), + resumeThread: ({ providerThread }) => Effect.succeed(providerThread), + startTurn: (turn) => + Effect.gen(function* () { + turns.push({ turn, events }); + yield* Queue.offer(events, { + type: "provider_turn.updated", driver, - providerInstanceId: instanceId, - providerSessionId: input.providerSessionId, - appThreadId: threadId, - ownerNodeId: null, - nativeThreadRef: { driver, nativeId: `native:${threadId}`, strength: "strong" }, - nativeConversationHeadRef: null, - status: "idle", - firstRunOrdinal: null, - lastRunOrdinal: null, - handoffIds: [], - forkedFrom: null, - createdAt: now, - updatedAt: now, - }), - resumeThread: ({ providerThread }) => Effect.succeed(providerThread), - startTurn: (turn) => - Effect.gen(function* () { - turns.push({ turn, events }); - yield* Queue.offer(events, { - type: "provider_turn.updated", - driver, - providerTurn: { - id: ProviderTurnId.make(`provider-turn:${turn.attemptId}`), - providerThreadId: turn.providerThread.id, - nodeId: turn.rootNodeId, - runAttemptId: turn.attemptId, - nativeTurnRef: null, - ordinal: turn.providerTurnOrdinal, - status: "running", - startedAt: now, - completedAt: null, - }, - }); - }), - steerTurn: () => Effect.die("unused"), - interruptTurn: () => Effect.suspend(() => limitInFlight), - respondToRuntimeRequest: () => Effect.die("unused"), - readThreadSnapshot: () => Effect.die("unused"), - rollbackThread: () => Effect.die("unused"), - forkThread: () => Effect.die("unused"), - }; - }), - }; - const layerAdapters = ProviderAdapterRegistry.layerSingle(adapter); - const layerOrchestrator = ProviderReplayHarness.layerWithRegistry( - { name: "task-cancel-usage-limit" }, - layerAdapters, - { runEffectWorker: false }, - ); - const layerOrchestration = Layer.merge( - layerOrchestrator, - ThreadManagementService.layer.pipe( - Layer.provide(layerOrchestrator), - Layer.provide(ServerSettings.layerTest()), - Layer.provide( - CommandReceiptStore.layer.pipe(Layer.provide(SqlitePersistence.layerMemory)), - ), - ), - ); - const sweeps = new Map>(); - const layerRecovery = UsageLimitRecoveryWorker.layer.pipe( + providerTurn: { + id: ProviderTurnId.make(`provider-turn:${turn.attemptId}`), + providerThreadId: turn.providerThread.id, + nodeId: turn.rootNodeId, + runAttemptId: turn.attemptId, + nativeTurnRef: null, + ordinal: turn.providerTurnOrdinal, + status: "running", + startedAt: now, + completedAt: null, + }, + }); + }), + steerTurn: () => Effect.die("unused"), + interruptTurn: () => Effect.suspend(() => limitInFlight), + respondToRuntimeRequest: () => Effect.die("unused"), + readThreadSnapshot: () => Effect.die("unused"), + rollbackThread: () => Effect.die("unused"), + forkThread: () => Effect.die("unused"), + }; + }), + }; + const layerAdapters = ProviderAdapterRegistry.layerSingle(adapter); + const layerOrchestrator = ProviderReplayHarness.layerWithRegistry( + { name: "task-cancel-usage-limit" }, + layerAdapters, + { runEffectWorker: false }, + ); + const layerOrchestration = Layer.merge( + layerOrchestrator, + ThreadManagementService.layer.pipe( + Layer.provide(layerOrchestrator), + Layer.provide(ServerSettings.layerTest()), Layer.provide( - Layer.mergeAll( - Layer.succeed(Scheduler.Scheduler, { - register: (name: string, work: Effect.Effect) => - Effect.gen(function* () { - const context = yield* Effect.context(); - sweeps.set(name, work.pipe(Effect.provideContext(context), Effect.orDie)); - }), - }), - ProjectionStore.layer.pipe(Layer.provide(SqlitePersistence.layerMemory)), - ServerSettings.layerTest({ autoResumeLimitedThreads: true }), - ), + CommandReceiptStore.layer.pipe(Layer.provide(SqlitePersistence.layerMemory)), ), - ); - const layerTest = Layer.merge( - OrchestratorMcpService.layer.pipe( - Layer.provide(Layer.mock(ThreadLaunchService.ThreadLaunchService)({})), - Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), + ), + ); + const sweeps = new Map>(); + const layerRecovery = UsageLimitRecoveryWorker.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(Scheduler.Scheduler, { + register: (name: string, work: Effect.Effect) => + Effect.gen(function* () { + const context = yield* Effect.context(); + sweeps.set(name, work.pipe(Effect.provideContext(context), Effect.orDie)); + }), + }), + ProjectionStore.layer.pipe(Layer.provide(SqlitePersistence.layerMemory)), + ServerSettings.layerTest({ autoResumeLimitedThreads: true }), ), - layerRecovery, - ).pipe( - Layer.provideMerge(layerOrchestration), - Layer.provide( - Layer.mergeAll( - layerAdapters, - Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), - Layer.mock(ProjectService.ProjectService)({}), - Layer.mock(SecretRequests.SecretRequests)({}), - Layer.mock(RemoteDelegation.RemoteDelegation)({}), - ServerSettings.layerTest(), - Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), - NodeServices.layer, - NodeCrypto.layer, - ), + ), + ); + const layerTest = Layer.merge( + OrchestratorMcpService.layer.pipe( + Layer.provide(Layer.mock(ThreadLaunchService.ThreadLaunchService)({})), + Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), + ), + layerRecovery, + ).pipe( + Layer.provideMerge(layerOrchestration), + Layer.provide( + Layer.mergeAll( + layerAdapters, + Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(RemoteDelegation.RemoteDelegation)({}), + ServerSettings.layerTest(), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + NodeServices.layer, + NodeCrypto.layer, ), - ); + ), + ); - yield* Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; - const service = yield* OrchestratorMcpService.OrchestratorMcpService; - const sweep = Effect.andThen( - Effect.suspend(() => sweeps.get("usage-limit-recovery")!), - worker.drain(), + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + const sweep = Effect.andThen( + Effect.suspend(() => sweeps.get("usage-limit-recovery")!), + worker.drain(), + ); + const parentThreadId = ThreadId.make("thread:task-cancel-limit-parent"); + const watch = (predicate: (event: OrchestrationV2DomainEvent) => boolean) => + orchestrator.streamDomainEvents.pipe( + Stream.filter(predicate), + Stream.take(1), + Stream.runDrain, + Effect.forkScoped, ); - const parentThreadId = ThreadId.make("thread:task-cancel-limit-parent"); - const watch = (predicate: (event: OrchestrationV2DomainEvent) => boolean) => - orchestrator.streamDomainEvents.pipe( - Stream.filter(predicate), - Stream.take(1), - Stream.runDrain, - Effect.forkScoped, - ); - const turnRunning = () => - watch( - (event) => - event.type === "provider-turn.updated" && event.payload.status === "running", - ); - yield* orchestrator.dispatch({ - type: "thread.create", - commandId: CommandId.make("create-parent"), - threadId: parentThreadId, - projectId: ProjectId.make("project:task-cancel-limit"), - title: "Parent", - modelSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: cwd, - createdBy: "user", - creationSource: "web", - }); - const parentRunning = yield* turnRunning(); - yield* orchestrator.dispatch({ - type: "message.dispatch", - commandId: CommandId.make("start-parent"), - threadId: parentThreadId, - messageId: MessageId.make("message:start-parent"), - text: "Delegate a review", - attachments: [], - dispatchMode: { type: "start_immediately" }, - createdBy: "user", - creationSource: "web", - }); - yield* worker.drain(); - yield* Fiber.join(parentRunning); + const turnRunning = () => + watch( + (event) => event.type === "provider-turn.updated" && event.payload.status === "running", + ); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create-parent"), + threadId: parentThreadId, + projectId: ProjectId.make("project:task-cancel-limit"), + title: "Parent", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + createdBy: "user", + creationSource: "web", + }); + const parentRunning = yield* turnRunning(); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("start-parent"), + threadId: parentThreadId, + messageId: MessageId.make("message:start-parent"), + text: "Delegate a review", + attachments: [], + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + }); + yield* worker.drain(); + yield* Fiber.join(parentRunning); - const parentRun = (yield* orchestrator.getThreadProjection(parentThreadId)).runs[0]!; - const childRunning = yield* turnRunning(); - yield* orchestrator.dispatch({ - type: "delegated_task.request", - commandId: CommandId.make("delegate"), - parentThreadId, - parentRunId: parentRun.id, - parentNodeId: parentRun.rootNodeId!, - task: "Review the change", - modelSelection, - runtimeMode: "full-access", - interactionMode: "default", - completionWake: "always", - createdBy: "agent", - creationSource: "mcp", + const parentRun = (yield* orchestrator.getThreadProjection(parentThreadId)).runs[0]!; + const childRunning = yield* turnRunning(); + yield* orchestrator.dispatch({ + type: "delegated_task.request", + commandId: CommandId.make("delegate"), + parentThreadId, + parentRunId: parentRun.id, + parentNodeId: parentRun.rootNodeId!, + task: "Review the change", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + completionWake: "always", + createdBy: "agent", + creationSource: "mcp", + }); + yield* worker.drain(); + yield* Fiber.join(childRunning); + const task = (yield* orchestrator.getThreadProjection(parentThreadId)).subagents[0]!; + const childThreadId = task.childThreadId!; + const child = turns.find((entry) => entry.turn.threadId === childThreadId)!; + const childTurn = (yield* orchestrator.getThreadProjection(childThreadId)) + .providerTurns[0]!; + + const scope: McpInvocationScope = { + environmentId: EnvironmentId.make("environment:task-cancel-limit"), + requestNamespace: "provider-session:task-cancel-limit", + thread: { + threadId: parentThreadId, + providerSessionId: "provider-session:task-cancel-limit", + providerInstanceId: instanceId, + }, + client: undefined, + capabilities: new Set(["orchestration"]), + issuedAt: 1, + }; + const cancelTask = Effect.gen(function* () { + const result = yield* service.cancelTask(scope, { + taskId: task.id, + clientRequestId: "cancel-task", }); yield* worker.drain(); - yield* Fiber.join(childRunning); - const task = (yield* orchestrator.getThreadProjection(parentThreadId)).subagents[0]!; - const childThreadId = task.childThreadId!; - const child = turns.find((entry) => entry.turn.threadId === childThreadId)!; - const childTurn = (yield* orchestrator.getThreadProjection(childThreadId)) - .providerTurns[0]!; + return result.status; + }); - const scope: McpInvocationScope = { - environmentId: EnvironmentId.make("environment:task-cancel-limit"), - requestNamespace: "provider-session:task-cancel-limit", - thread: { - threadId: parentThreadId, - providerSessionId: "provider-session:task-cancel-limit", - providerInstanceId: instanceId, + const now = yield* DateTime.now; + const childFailed = yield* watch( + (event) => + event.type === "run.updated" && + event.payload.id === child.turn.runId && + event.payload.status === "failed", + ); + const hitLimit = Effect.forEach( + [ + { + type: "provider_turn.updated", + driver, + providerTurn: { ...childTurn, status: "failed", completedAt: now }, }, - client: undefined, - capabilities: new Set(["orchestration"]), - issuedAt: 1, - }; - const cancelTask = Effect.gen(function* () { - const result = yield* service.cancelTask(scope, { - taskId: task.id, - clientRequestId: "cancel-task", - }); - yield* worker.drain(); - return result.status; - }); - - const now = yield* DateTime.now; - const childFailed = yield* watch( - (event) => - event.type === "run.updated" && - event.payload.id === child.turn.runId && - event.payload.status === "failed", - ); - const hitLimit = Effect.forEach( - [ - { - type: "provider_turn.updated", - driver, - providerTurn: { ...childTurn, status: "failed", completedAt: now }, + { + type: "turn.terminal", + driver, + providerThreadId: childTurn.providerThreadId, + providerTurnId: childTurn.id, + runOrdinal: child.turn.runOrdinal, + failureItemOrdinal: 1, + status: "failed", + failure: { + class: "usage_limit", + message: "Claude usage limit reached.", + code: null, + retryable: null, + resetAt: DateTime.formatIso(DateTime.add(now, { minutes: 1 })), }, - { - type: "turn.terminal", - driver, - providerThreadId: childTurn.providerThreadId, - providerTurnId: childTurn.id, - runOrdinal: child.turn.runOrdinal, - failureItemOrdinal: 1, - status: "failed", - failure: { - class: "usage_limit", - message: "Claude usage limit reached.", - code: null, - retryable: null, - resetAt: DateTime.formatIso(DateTime.add(now, { minutes: 1 })), - }, - threadDisposition: "reusable", - }, - ] satisfies ReadonlyArray, - (event) => Queue.offer(child.events, event), - { discard: true }, - ); - let cancelled: string | null = null; - if (cancel === "as its limit lands") { - limitInFlight = hitLimit; - cancelled = yield* cancelTask; - } else { - yield* hitLimit; - } - yield* Fiber.join(childFailed); - yield* worker.drain(); + threadDisposition: "reusable", + }, + ] satisfies ReadonlyArray, + (event) => Queue.offer(child.events, event), + { discard: true }, + ); + let cancelled: string | null = null; + if (cancel === "as its limit lands") { + limitInFlight = hitLimit; + cancelled = yield* cancelTask; + } else { + yield* hitLimit; + } + yield* Fiber.join(childFailed); + yield* worker.drain(); - // The sweep runs every few seconds, so it arms the child before a parent reacts. - yield* sweep; - if (cancel === "after its limit") cancelled = yield* cancelTask; - assert.equal(cancelled, cancelStatus); + // The sweep runs every few seconds, so it arms the child before a parent reacts. + yield* sweep; + if (cancel === "after its limit") cancelled = yield* cancelTask; + assert.equal(cancelled, cancelStatus); - yield* TestClock.adjust("1 minute"); - yield* sweep; - const childAfter = yield* orchestrator.getThreadProjection(childThreadId); - assert.deepEqual( - childAfter.messages.flatMap((message) => - message.role === "user" ? [message.text] : [], - ), - resumed ? ["Review the change", "Continue where you left off."] : ["Review the change"], - ); - }).pipe(Effect.provide(layerTest)); - }), - ), + yield* TestClock.adjust("1 minute"); + yield* sweep; + const childAfter = yield* orchestrator.getThreadProjection(childThreadId); + assert.deepEqual( + childAfter.messages.flatMap((message) => (message.role === "user" ? [message.text] : [])), + resumed ? ["Review the change", "Continue where you left off."] : ["Review the change"], + ); + }).pipe(Effect.provide(layerTest)); + }), ); diff --git a/apps/server/src/mcp/toolkits/preview/handlers.test.ts b/apps/server/src/mcp/toolkits/preview/handlers.test.ts index 25eebc0c16f8e..496c9bcb1cdc2 100644 --- a/apps/server/src/mcp/toolkits/preview/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/preview/handlers.test.ts @@ -28,77 +28,75 @@ import { claimPreviewRecording, layerStandard } from "./handlers.ts"; import { PreviewStandardToolkit } from "./tools.ts"; it.effect("preview_open navigates the opened tab to a dev server in one tool call", () => - Effect.scoped( - Effect.gen(function* () { - const scope = yield* McpInvocationContext.McpInvocationContext; - const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; - const connected = yield* Deferred.make(); - const tabId = PreviewTabId.make("opened-tab"); - const target = { - kind: "environment-port" as const, - port: 3000, - path: "/settings?tab=account", - }; - const requests: PreviewAutomationRequest[] = []; - const status = { - available: true, - visible: false, - tabId, - url: "http://localhost:3000/settings?tab=account", - title: "Settings", - loading: false, - }; - const events = yield* broker.connect({ + Effect.gen(function* () { + const scope = yield* McpInvocationContext.McpInvocationContext; + const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; + const connected = yield* Deferred.make(); + const tabId = PreviewTabId.make("opened-tab"); + const target = { + kind: "environment-port" as const, + port: 3000, + path: "/settings?tab=account", + }; + const requests: PreviewAutomationRequest[] = []; + const status = { + available: true, + visible: false, + tabId, + url: "http://localhost:3000/settings?tab=account", + title: "Settings", + loading: false, + }; + const events = yield* broker.connect({ + clientId: "client-1", + environmentId: scope.environmentId, + }); + yield* Stream.runForEach(events, (event) => { + if (event.type === "connected") return Deferred.succeed(connected, undefined); + requests.push(event.request); + return broker.respond({ clientId: "client-1", - environmentId: scope.environmentId, + connectionId: event.connectionId, + requestId: event.request.requestId, + ok: true, + result: event.request.operation === "open" ? { ...status, url: "about:blank" } : status, }); - yield* Stream.runForEach(events, (event) => { - if (event.type === "connected") return Deferred.succeed(connected, undefined); - requests.push(event.request); - return broker.respond({ - clientId: "client-1", - connectionId: event.connectionId, - requestId: event.request.requestId, - ok: true, - result: event.request.operation === "open" ? { ...status, url: "about:blank" } : status, - }); - }).pipe(Effect.forkScoped); - yield* Deferred.await(connected); - const toolkit = yield* PreviewStandardToolkit.pipe( - Effect.provide(McpToolAccess.HandlersLayer.layer(layerStandard)), - ); - const results = yield* toolkit - .handle("preview_open", { - url: "http://localhost:3000", - target, - open: false, - reuseExistingTab: false, - }) - .pipe(Stream.unwrap, Stream.runCollect); - expect(results.at(-1)?.result).toEqual(status); - expect(requests.map(({ operation, input }) => ({ operation, input }))).toEqual([ - { operation: "open", input: { open: false, show: false, reuseExistingTab: false } }, - { operation: "navigate", input: { target } }, - ]); - expect(requests[1]?.tabId).toBe(tabId); - expect(requests[1]?.tabIdExplicit).toBe(true); - }).pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, { - environmentId: EnvironmentId.make("environment-1"), - requestNamespace: "provider-session-1", - thread: { - threadId: ThreadId.make("thread-1"), - providerSessionId: "provider-session-1", - providerInstanceId: ProviderInstanceId.make("codex"), - }, - client: undefined, - capabilities: new Set(["preview"] as const), - issuedAt: 1, - }), - Effect.provide( - Layer.mergeAll(PreviewAutomationBroker.layer, liveThreadsLayer).pipe( - Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), - ), + }).pipe(Effect.forkScoped); + yield* Deferred.await(connected); + const toolkit = yield* PreviewStandardToolkit.pipe( + Effect.provide(McpToolAccess.HandlersLayer.layer(layerStandard)), + ); + const results = yield* toolkit + .handle("preview_open", { + url: "http://localhost:3000", + target, + open: false, + reuseExistingTab: false, + }) + .pipe(Stream.unwrap, Stream.runCollect); + expect(results.at(-1)?.result).toEqual(status); + expect(requests.map(({ operation, input }) => ({ operation, input }))).toEqual([ + { operation: "open", input: { open: false, show: false, reuseExistingTab: false } }, + { operation: "navigate", input: { target } }, + ]); + expect(requests[1]?.tabId).toBe(tabId); + expect(requests[1]?.tabIdExplicit).toBe(true); + }).pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, { + environmentId: EnvironmentId.make("environment-1"), + requestNamespace: "provider-session-1", + thread: { + threadId: ThreadId.make("thread-1"), + providerSessionId: "provider-session-1", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, + capabilities: new Set(["preview"] as const), + issuedAt: 1, + }), + Effect.provide( + Layer.mergeAll(PreviewAutomationBroker.layer, liveThreadsLayer).pipe( + Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ), ), ), diff --git a/apps/server/src/mcp/toolkits/previewControls/handlers.test.ts b/apps/server/src/mcp/toolkits/previewControls/handlers.test.ts index 435ab1cd8b1bc..9aeda8ad13936 100644 --- a/apps/server/src/mcp/toolkits/previewControls/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/previewControls/handlers.test.ts @@ -27,79 +27,77 @@ it.effect.each([ { name: "project opt-in", globalAccess: false, projectAccess: true }, { name: "project opt-out", globalAccess: true, projectAccess: false }, ])("list and close respect the credential for a $name", ({ globalAccess, projectAccess }) => - Effect.scoped( - Effect.gen(function* () { - const projectId = ProjectId.make("preview-controls-project"); - const threadId = ThreadId.make("preview-controls-thread"); - const settings = { - ...DEFAULT_SERVER_SETTINGS, - enableAgentBrowserAccess: globalAccess, - projectSettingsOverrides: { - [projectId]: { enableAgentBrowserAccess: projectAccess }, - }, - }; - const effective = resolveProjectSettings(settings, projectId).settings; - const scope: McpInvocationContext.McpInvocationScope = { - environmentId: EnvironmentId.make("preview-controls-environment"), - requestNamespace: "preview-controls-provider-session", - thread: { - threadId, - providerSessionId: "preview-controls-provider-session", - providerInstanceId: ProviderInstanceId.make("codex"), - }, - client: undefined, - capabilities: new Set(effective.enableAgentBrowserAccess ? ["preview"] : []), - issuedAt: 0, - }; - const manager = yield* Preview.make.pipe( - Effect.provide( - Layer.merge( - ServerConfig.layerTest(process.cwd(), { prefix: "t3-preview-controls-" }).pipe( - Layer.provide(NodeServices.layer), - ), - NodeCrypto.layer, + Effect.gen(function* () { + const projectId = ProjectId.make("preview-controls-project"); + const threadId = ThreadId.make("preview-controls-thread"); + const settings = { + ...DEFAULT_SERVER_SETTINGS, + enableAgentBrowserAccess: globalAccess, + projectSettingsOverrides: { + [projectId]: { enableAgentBrowserAccess: projectAccess }, + }, + }; + const effective = resolveProjectSettings(settings, projectId).settings; + const scope: McpInvocationContext.McpInvocationScope = { + environmentId: EnvironmentId.make("preview-controls-environment"), + requestNamespace: "preview-controls-provider-session", + thread: { + threadId, + providerSessionId: "preview-controls-provider-session", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, + capabilities: new Set(effective.enableAgentBrowserAccess ? ["preview"] : []), + issuedAt: 0, + }; + const manager = yield* Preview.make.pipe( + Effect.provide( + Layer.merge( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-preview-controls-" }).pipe( + Layer.provide(NodeServices.layer), ), + NodeCrypto.layer, ), - ); - const tab = yield* manager.open({ threadId, url: "http://localhost:3000" }); - const layerDependencies = Layer.mergeAll( - PreviewAutomationBroker.layer.pipe( - Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), - ), - Layer.succeed(Preview.PreviewManager, manager), - Layer.succeed(McpInvocationContext.McpInvocationContext, scope), - McpToolAccessTestkit.liveThreadsLayer, - Layer.mock(ServerSettings.ServerSettingsService)({ - getSettings: Effect.succeed(settings), - }), - ); - const toolkit = yield* PreviewControlsToolkit.pipe( - Effect.provide( - McpToolAccess.HandlersLayer.layer(PreviewControlsHandlers.layer).pipe( - Layer.provide(layerDependencies), - ), + ), + ); + const tab = yield* manager.open({ threadId, url: "http://localhost:3000" }); + const layerDependencies = Layer.mergeAll( + PreviewAutomationBroker.layer.pipe( + Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ), + Layer.succeed(Preview.PreviewManager, manager), + Layer.succeed(McpInvocationContext.McpInvocationContext, scope), + McpToolAccessTestkit.liveThreadsLayer, + Layer.mock(ServerSettings.ServerSettingsService)({ + getSettings: Effect.succeed(settings), + }), + ); + const toolkit = yield* PreviewControlsToolkit.pipe( + Effect.provide( + McpToolAccess.HandlersLayer.layer(PreviewControlsHandlers.layer).pipe( + Layer.provide(layerDependencies), ), - ); - const listed = yield* toolkit - .handle("t3_preview_list", {}) - .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(layerDependencies)); - const closed = yield* toolkit - .handle("t3_preview_close", { tabId: tab.tabId }) - .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(layerDependencies)); - if (projectAccess) { - expect(listed.at(-1)?.result).toMatchObject({ sessions: [tab], nextCursor: null }); - expect(closed.at(-1)?.result).toEqual({}); - expect((yield* manager.list({ threadId })).sessions).toEqual([]); - } else { - for (const result of [listed, closed]) { - expect(result.at(-1)?.result).toMatchObject({ - _tag: "PreviewAutomationUnavailableError", - capability: "preview", - threadId, - }); - } - expect((yield* manager.list({ threadId })).sessions).toEqual([tab]); + ), + ); + const listed = yield* toolkit + .handle("t3_preview_list", {}) + .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(layerDependencies)); + const closed = yield* toolkit + .handle("t3_preview_close", { tabId: tab.tabId }) + .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(layerDependencies)); + if (projectAccess) { + expect(listed.at(-1)?.result).toMatchObject({ sessions: [tab], nextCursor: null }); + expect(closed.at(-1)?.result).toEqual({}); + expect((yield* manager.list({ threadId })).sessions).toEqual([]); + } else { + for (const result of [listed, closed]) { + expect(result.at(-1)?.result).toMatchObject({ + _tag: "PreviewAutomationUnavailableError", + capability: "preview", + threadId, + }); } - }), - ), + expect((yield* manager.list({ threadId })).sessions).toEqual([tab]); + } + }), ); diff --git a/apps/server/src/observability/DefectReporter.test.ts b/apps/server/src/observability/DefectReporter.test.ts index c749c1a845798..8649c9002f205 100644 --- a/apps/server/src/observability/DefectReporter.test.ts +++ b/apps/server/src/observability/DefectReporter.test.ts @@ -78,6 +78,6 @@ describe("DefectReporter", () => { assert.equal(next, 2); assert.equal(yield* Queue.size(logs), 0); }), - ).pipe(Effect.scoped), + ), ); }); diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index 553ee22ebe156..02d6f64c0be66 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -1174,7 +1174,7 @@ describe("AcpAdapterV2", () => { items.map(() => "completed"), "terminal output must not reopen a tool the agent completed", ); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.live("reports per-turn usage unavailable on a continuation turn that never prompts", () => @@ -1294,7 +1294,7 @@ describe("AcpAdapterV2", () => { outputTokens: 30, }); assert.equal(promptOrdinal, 2, "continuation turn must not dispatch session/prompt"); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.live("fails an end_turn the outcome check rejects and restarts before the next turn", () => Effect.gen(function* () { @@ -1391,7 +1391,7 @@ describe("AcpAdapterV2", () => { assert.include(promptTexts[1] ?? "", "T3 Code interaction mode: Default"); assert.equal(watchedSessions.length, 2); assert.equal(watchedSessions[0], watchedSessions[1]); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.live("retains returned usage when Stop finalizes before the prompt callback", () => Effect.gen(function* () { @@ -1480,7 +1480,7 @@ describe("AcpAdapterV2", () => { }, ); yield* Deferred.succeed(releaseCompletion, undefined); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.live("drops a late prompt response that lands after the turn finalized", () => @@ -1606,7 +1606,7 @@ describe("AcpAdapterV2", () => { }); stopCollector = true; yield* Fiber.interrupt(collector); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.live("keeps usage unavailable for undeclared agents even when they report usage", () => @@ -1674,7 +1674,7 @@ describe("AcpAdapterV2", () => { { usageScope: "main_agent", usageStatus: "unavailable", hasSubagents: false }, ); } - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.live.each(["details", "none"] as const)( @@ -1816,7 +1816,7 @@ describe("AcpAdapterV2", () => { const nextEvents = yield* runTurn(2); const next = nextEvents.at(-1); assert.equal(next?.type === "turn.terminal" ? next.status : undefined, "completed"); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); // Droid 0.243.0 and OpenCode 2.1 pair each choice with an optional free-form companion field. @@ -2158,7 +2158,7 @@ describe("AcpAdapterV2", () => { Stream.runHead, ); assert.equal(Option.getOrThrow(terminal).status, "completed"); - }).pipe(Effect.provide(layerTest), Effect.scoped); + }).pipe(Effect.provide(layerTest)); }); it.live.each(["failed", "recovered", "cancelled"] as const)( @@ -2242,7 +2242,7 @@ describe("AcpAdapterV2", () => { assert.equal(retries.at(-1)?.status, "completed"); assert.equal(retries.at(-1)?.title, "Provider recovered"); } - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.live.each([ @@ -2405,7 +2405,7 @@ describe("AcpAdapterV2", () => { }); assert.equal(switchedModel.textBlockCount, 2); assert.include(switchedModel.prompt, "as composer-2"); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.live.each(["cancelled", "refusal", "error"] as const)( @@ -2541,7 +2541,7 @@ describe("AcpAdapterV2", () => { yield* runTurn("Continue after the undelivered prompt."), "Continue after the undelivered prompt.", ); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.live( @@ -2570,7 +2570,7 @@ describe("AcpAdapterV2", () => { yield* runTurn("Continue after the focused compact."), "Continue after the focused compact.", ); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.live.each([false, true])( @@ -2613,7 +2613,7 @@ describe("AcpAdapterV2", () => { ), 1, ); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.live.each([ @@ -2644,7 +2644,7 @@ describe("AcpAdapterV2", () => { ProviderAdapter.ProviderCompactionUnsupportedError, ); } - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.live("reapplies a selection after a switch away from it failed partway", () => @@ -2729,7 +2729,7 @@ describe("AcpAdapterV2", () => { ["composer-2"], "the native model was left on B, so the turn on A must set it back first", ); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); // regression: #21499 turns blocked only on T3 thread waits must show as waiting @@ -2860,7 +2860,7 @@ describe("AcpAdapterV2", () => { ); } assert.deepEqual(states, [true, false, true, false]); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); const textChunk = ( @@ -3018,7 +3018,7 @@ describe("AcpAdapterV2", () => { assert.equal(new Set(snapshots.map((item) => DateTime.formatIso(item.startedAt!))).size, 1); assert.equal(snapshots.at(-1)?.status, "completed"); } - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.effect("ignores a text flush scheduled before an ACP segment resumed", () => @@ -3046,7 +3046,7 @@ describe("AcpAdapterV2", () => { ["ABCDE", true], ["ABCDE", false], ]); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.effect("starts a new replay message after ACP v2 plan boundaries", () => @@ -3146,7 +3146,7 @@ describe("AcpAdapterV2", () => { snapshot.reasoningMessageIds, snapshot.messages.slice(2).map((message) => message.id), ); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.effect("persists a bounded subset of streamed root and child tool updates", () => @@ -3315,7 +3315,7 @@ describe("AcpAdapterV2", () => { ); } } - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.effect("keeps Devin parent paragraphs intact while projecting native child work", () => @@ -3636,7 +3636,7 @@ describe("AcpAdapterV2", () => { ); assert.equal(parentTools.length, 2); assert.equal(parentTools[0]?.title, "Parent tool finished"); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.effect("routes interleaved Copilot subagent output into subagent threads", () => @@ -3831,7 +3831,7 @@ describe("AcpAdapterV2", () => { assert.isFalse( items.some((item) => item.threadId === threadId && item.type === "dynamic_tool"), ); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.effect("keeps protocol diagnostics off unless a protocol logger is supplied", () => @@ -3914,7 +3914,7 @@ describe("AcpAdapterV2", () => { })); assert.isTrue(explicit.some((e) => e.direction === "incoming" && e.stage === "raw")); assert.isTrue(explicit.some((e) => e.direction === "outgoing")); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.effect("advertises ACP session notices and shows them as system notices", () => @@ -3993,7 +3993,7 @@ describe("AcpAdapterV2", () => { ["before the notice, after the notice"], "a notice neither joins the reply nor splits it", ); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.effect("projects ACP v2 fidelity updates into first-class orchestration items", () => @@ -4188,7 +4188,7 @@ describe("AcpAdapterV2", () => { afterOmittedPatch.messages.some((message) => message.text === "late authoritative text"), "omitted late message content must preserve the authoritative text", ); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.effect("projects only the tail of long ACP terminal output", () => @@ -4249,7 +4249,7 @@ describe("AcpAdapterV2", () => { assert.isAtLeast(outputs.length, 2); assert.isAtMost(Math.max(...outputs.map((output) => output.length)), 320 * 1024); assert.equal(outputs.at(-1), fullOutput.slice(-320 * 1024)); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.live( @@ -4327,7 +4327,7 @@ describe("AcpAdapterV2", () => { nativeTurnId: "persisted-session:turn:1", }), ); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.live("creates a fresh ACP identity when recovering an exhausted target", () => @@ -4417,7 +4417,7 @@ describe("AcpAdapterV2", () => { ); const methods = yield* pollProtocolMethods(protocolEvents); assert.equal(methods.filter((method) => method === "session/new").length, 2); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.live("preserves new-session fallback when an eager ACP session load is stale", () => @@ -4515,7 +4515,7 @@ describe("AcpAdapterV2", () => { ), }), ); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.live("replaces an unexpectedly terminated ACP runtime before the next turn", () => @@ -4587,7 +4587,7 @@ describe("AcpAdapterV2", () => { ); assert.equal(runtimeOrdinalSeen, 2); assert.lengthOf(runtimeInputs, 2); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.effect("fails a prompt its agent leaves silent past the inactivity timeout", () => @@ -4623,7 +4623,7 @@ describe("AcpAdapterV2", () => { assert.include(silent?.failure?.message, "15 minutes"); assert.equal(next?.status, "completed"); assert.lengthOf(harness.natives, 2); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.effect( @@ -4661,7 +4661,7 @@ describe("AcpAdapterV2", () => { const [silent, next] = yield* harness.terminals(2); assert.equal(silent?.status, "failed"); assert.equal(next?.status, "completed"); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.effect("delivers a follow-up the agent rejects while it is still processing", () => @@ -4697,7 +4697,7 @@ describe("AcpAdapterV2", () => { const [delivered] = yield* Fiber.join(followUp); assert.equal(delivered?.status, "completed"); assert.equal(promptCalls, 3); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.effect("sends no more prompts once a follow-up waiting on a busy agent is stopped", () => @@ -4745,7 +4745,7 @@ describe("AcpAdapterV2", () => { yield* Fiber.join(interrupt); assert.equal(terminal?.status, "interrupted"); assert.equal(promptCalls, 2); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.effect("holds the inactivity timeout while the agent waits on an approval", () => @@ -4786,7 +4786,7 @@ describe("AcpAdapterV2", () => { const [approved] = yield* harness.terminals(1); assert.equal(approved?.status, "completed"); assert.lengthOf(harness.natives, 1); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.effect("fails a running prompt once when its agent process exits", () => @@ -4822,7 +4822,7 @@ describe("AcpAdapterV2", () => { assert.notEqual(silent?.providerTurnId, exited?.providerTurnId); assert.equal(next?.status, "completed"); assert.lengthOf(harness.natives, 3); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.live("does not poll the process ledger while a cgroup lease owns teardown", () => @@ -4919,7 +4919,7 @@ describe("AcpAdapterV2", () => { yield* Scope.close(sessionScope, Exit.void); assert.isFalse(isLive(agentPid!), "closing the session must still tear the agent down"); assert.isFalse(leaseExists, "closing the session must remove the lease"); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.effect("negotiates and executes optional native session forks through the ACP runtime", () => @@ -5117,7 +5117,7 @@ describe("AcpAdapterV2", () => { terminalOutput.output, "thread-acp-native-fork-target\nBearer target-thread-token", ); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.effect( @@ -5208,7 +5208,7 @@ describe("AcpAdapterV2", () => { { method: "fs/read_text_file", errorCode: -32601 }, ]); assert.isFalse(yield* fileSystem.exists(probePath)); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.effect("does not turn an unknown permission approval into an execute grant", () => @@ -5335,7 +5335,7 @@ describe("AcpAdapterV2", () => { providerTurnId: pending.runtimeRequest.providerTurnId, }); yield* Fiber.join(turnFiber); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.effect.each([ @@ -5506,7 +5506,7 @@ describe("AcpAdapterV2", () => { yield* runtime.interruptTurn({ providerThread, providerTurnId }); yield* Fiber.join(turnFiber); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.effect.each([ @@ -5637,7 +5637,7 @@ describe("AcpAdapterV2", () => { }); yield* Fiber.join(turnFiber); assert.deepEqual(outcome, { outcome: "selected", optionId }); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.effect("replaces the ACP session and clears conversation state on rollback", () => @@ -5820,7 +5820,7 @@ describe("AcpAdapterV2", () => { assert.lengthOf(prompts, 2); // The mock agent reuses its session id, so only the rollback reset resends these notes. assert.include(prompts[1]?.at(-1)?.text ?? "", ""); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.effect("resends T3 notes after rollback when the replaced runtime's prompt settles late", () => @@ -5948,7 +5948,7 @@ describe("AcpAdapterV2", () => { const replacementPrompt = yield* Deferred.await(replacementPromptText); assert.include(replacementPrompt, "T3 Code interaction mode: Default"); assert.include(replacementPrompt, ""); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.effect("quarantines callbacks from a failed rollback replacement before retrying", () => @@ -6097,7 +6097,7 @@ describe("AcpAdapterV2", () => { status: "running", }); assert.isFalse(yield* hasPendingBackgroundWork); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.live("settles a Grok background command that completed before an ACP v2 idle error", () => @@ -6160,7 +6160,7 @@ describe("AcpAdapterV2", () => { command?.type === "turn_item.updated" ? command.turnItem.status : undefined, "completed", ); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.effect("closes the ACP runtime when the agent never answers session/close", () => @@ -6303,7 +6303,7 @@ describe("AcpAdapterV2", () => { items.some((item) => item.nativeItemRef?.nativeId === "call-background-poll"), "command TaskOutput must retain its normal tool frame", ); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); // Grok expands only a leading `/name`, so a picked leading `$name` @@ -6383,7 +6383,7 @@ describe("AcpAdapterV2", () => { assert.isTrue(blocks.some((block) => block.text.includes(sent))); assert.isFalse(blocks.some((block) => block.text.trimStart().startsWith("/"))); } - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); // Command completion must release the turn even when the CLI keeps the prompt RPC and process @@ -6479,7 +6479,7 @@ describe("AcpAdapterV2", () => { items.some((item) => item.nativeItemRef?.nativeId === "kill-command"), "KillTask must retain its normal tool frame", ); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.effect("keeps an agent's empty-valued default choice selectable", () => @@ -6575,7 +6575,7 @@ describe("AcpAdapterV2", () => { ) .map((event) => rawProtocolRequestParam(event, "value")); assert.deepEqual(sentAgentValues, ["example", ""]); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.live("terminalizes an empty successful foreground Bash tool when the turn completes", () => @@ -6714,7 +6714,7 @@ describe("AcpAdapterV2", () => { Stream.runHead, ); assert.isTrue(Option.isSome(loadAfterRestart)); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.effect("drains native ACP cancellation before admitting the next prompt", () => @@ -6818,7 +6818,7 @@ describe("AcpAdapterV2", () => { ), ); assert.equal(nextTerminal.type === "turn.terminal" && nextTerminal.status, "completed"); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.effect("cancels pending permission requests while interrupting an ACP turn", () => @@ -6909,7 +6909,7 @@ describe("AcpAdapterV2", () => { ), ); assert.equal(terminal.type, "turn.terminal"); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.live("keeps hard teardown excluded until a permission response is enqueued", () => @@ -6998,7 +6998,7 @@ describe("AcpAdapterV2", () => { yield* Deferred.succeed(releaseResponseAcknowledgement, undefined); yield* Fiber.join(responseFiber); yield* Fiber.join(interruptFiber); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.live("asks form fields with their fixed choices and answers in the declared types", () => @@ -7117,7 +7117,7 @@ describe("AcpAdapterV2", () => { note: "hi", }, }); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.live("keeps explicit empty selections and checks selection counts", () => @@ -7167,7 +7167,7 @@ describe("AcpAdapterV2", () => { answer: () => ({ all: ["a"], some: ["a"] }), }); assert.deepStrictEqual(requiredOmitted.response, { action: "cancel" }); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.live("answers a field named __proto__ like any other field", () => @@ -7192,7 +7192,7 @@ describe("AcpAdapterV2", () => { action: "accept", content: { ["__proto__"]: "b", name: "hi" }, }); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.live("declines a form whose selection counts the client cannot answer, without asking", () => @@ -7214,7 +7214,7 @@ describe("AcpAdapterV2", () => { assert.isUndefined(questions); assert.deepStrictEqual(response, { action: "decline" }); } - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.live("cancels a form whose required answer does not fit, or that is dismissed", () => @@ -7236,7 +7236,7 @@ describe("AcpAdapterV2", () => { answer: () => undefined, }); assert.deepStrictEqual(dismissed.response, { action: "cancel" }); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.live("declines a form with a field it cannot ask for, without asking", () => @@ -7254,7 +7254,7 @@ describe("AcpAdapterV2", () => { }); assert.isUndefined(questions); assert.deepStrictEqual(response, { action: "decline" }); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.live("auto-approves tagged MCP elicitations under full-access policy", () => @@ -7312,7 +7312,7 @@ describe("AcpAdapterV2", () => { assert.isFalse(events.some((event) => event.type === "runtime_request.updated")); assert.isTrue(events.some((event) => event.type === "turn.terminal")); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.live("fails a held native response acknowledgement before normal session close", () => @@ -7410,7 +7410,7 @@ describe("AcpAdapterV2", () => { assert.include(responseLifecycle, "failed"); yield* Deferred.succeed(releaseResponseAcknowledgement, undefined); yield* Fiber.join(closeFiber); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.live("treats a second hard Stop as success when the turn is already gone", () => @@ -7499,7 +7499,7 @@ describe("AcpAdapterV2", () => { } } assert.equal(terminal, "interrupted"); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.effect( @@ -7635,7 +7635,7 @@ describe("AcpAdapterV2", () => { } assert.equal(carriedItemStatus, "completed"); assert.equal(secondTerminalStatus, "completed"); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.effect( @@ -7845,7 +7845,7 @@ describe("AcpAdapterV2", () => { "finalize-window terminal must clear the carryover pin", ); }).pipe(Effect.provideService(Clock.Clock, blockingClock)); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.effect("projects completed-root carryover eagerly and drain cannot resurrect it", () => @@ -8083,7 +8083,7 @@ describe("AcpAdapterV2", () => { "buffered spawn ACK must not create a continuation-owned turn item", ); assert.isFalse(yield* hasPendingBackgroundWork); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.effect( @@ -8374,7 +8374,7 @@ describe("AcpAdapterV2", () => { "queued continuation must drain the wake content after the user run", ); assert.isFalse(yield* hasPendingBackgroundWork); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.effect( @@ -8532,7 +8532,7 @@ describe("AcpAdapterV2", () => { 1, "settled soft steer must not respawn the ACP runtime process", ); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.live("preserveRuntimeOnSettledInterrupt does not soften a mid-prompt steering interrupt", () => @@ -8605,7 +8605,7 @@ describe("AcpAdapterV2", () => { assert.fail("mid-prompt steering interrupt must still take the hard teardown path"); } assert.include(Cause.pretty(interruptExit.cause), "session is poisoned"); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.live("direct Stop quarantine drops late background task mutations from the stopped run", () => @@ -8738,7 +8738,7 @@ describe("AcpAdapterV2", () => { yield* hasPendingBackgroundWork, "direct Stop quarantine must drop residual background task mutations from the stopped run", ); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.effect( @@ -8877,7 +8877,7 @@ describe("AcpAdapterV2", () => { yield* hasPendingBackgroundWork, "wake buffer must not stay non-empty and pin idle release after an already-handled re-report", ); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.effect("keeps a buffered continuation current when a user turn starts before dispatch", () => @@ -9076,7 +9076,7 @@ describe("AcpAdapterV2", () => { assert.equal(continuationTerminalStatus, "completed"); assert.isTrue(bufferedTextSeen, "continuation must drain the wake buffer after the user run"); assert.isFalse(yield* hasPendingBackgroundWork); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.effect( @@ -9280,7 +9280,7 @@ describe("AcpAdapterV2", () => { 1, "exactly one continuation when the last running task ends post-finalize with kept midTurn marks", ); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.effect( @@ -9641,7 +9641,7 @@ describe("AcpAdapterV2", () => { yield* hasPendingBackgroundWork, "wake buffer must not retain turn-1 or turn-2 in-turn-handled ack residue", ); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.effect("a wake names work that ended while the previous wake was queued", () => @@ -9785,7 +9785,7 @@ describe("AcpAdapterV2", () => { outcome: "completed", summary: 'Command "sleep b" finished', }); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.effect.each([ @@ -10003,7 +10003,7 @@ describe("AcpAdapterV2", () => { 1, "duplicate completions must not offer another continuation", ); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.live("durably poisons start and resume when required hard teardown is unavailable", () => @@ -10083,7 +10083,7 @@ describe("AcpAdapterV2", () => { .pipe(Effect.exit); if (Exit.isSuccess(resumeExit)) assert.fail("poisoned session must reject resumeThread"); assert.include(Cause.pretty(resumeExit.cause), "session is poisoned"); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.live("holds concurrent startTurn behind successful hard teardown and reloads once", () => @@ -10187,7 +10187,7 @@ describe("AcpAdapterV2", () => { Stream.runHead, ); } - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.live("lets a caller abandon Stop while hard teardown stalls, and finishes the teardown", () => @@ -10274,7 +10274,7 @@ describe("AcpAdapterV2", () => { assert.equal(runtimeOrdinalSeen, 2); const replacementMethods = yield* pollProtocolMethods(protocolEvents); assert.equal(replacementMethods.filter((method) => method === "session/resume").length, 1); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.effect( @@ -10420,7 +10420,7 @@ describe("AcpAdapterV2", () => { carriedCompleted, "Direct Stop must not carry a stopped subagent into the follow-up run", ); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); }); diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts index a45b3fd5c66b8..48d9dd82a6ef2 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts @@ -656,7 +656,7 @@ describe("AcpRegistryAdapterV2", () => { answer("session/set_mode", {}), ], storedModePick: "autoEdit", - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.effect("switches a mode config option under its own id", () => @@ -671,7 +671,7 @@ describe("AcpRegistryAdapterV2", () => { answer("session/set_config_option", { configOptions: [permissionModeOption("auto")] }), ], storedModePick: "auto", - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); const modelOption = (id: string, currentValue: string, grouped = false) => ({ @@ -801,7 +801,7 @@ describe("AcpRegistryAdapterV2", () => { { models: ["hermes", "auto", "default"], current: "hermes" }, ], ); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); // Copilot's session/new reports its built-in default, not the model it will run @@ -879,7 +879,7 @@ describe("AcpRegistryAdapterV2", () => { configOptions: [copilotModel("claude-sonnet-5.5"), effort("xhigh")], }), ], - }).pipe(Effect.provide(layerTest), Effect.scoped); + }).pipe(Effect.provide(layerTest)); }, ); @@ -941,7 +941,7 @@ describe("AcpRegistryAdapterV2", () => { }), ] : [], - }).pipe(Effect.provide(layerTest), Effect.scoped); + }).pipe(Effect.provide(layerTest)); }, ); @@ -992,7 +992,7 @@ describe("AcpRegistryAdapterV2", () => { configurations.map((configuration) => configuration?.currentModelId), ["hermes", "hermes", "hermes", "other", "other", "other", "other", "other"], ); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.effect( "fails configuration when an option write errors for a reason other than refusal", @@ -1040,7 +1040,7 @@ describe("AcpRegistryAdapterV2", () => { }, }, ], - }).pipe(Effect.provide(layerTest), Effect.scoped); + }).pipe(Effect.provide(layerTest)); }, ); const modelOptions = [ @@ -1124,7 +1124,7 @@ describe("AcpRegistryAdapterV2", () => { storedModel: model, }); assert.equal(opened.configurations.at(-1)?.currentModelId, "codex"); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); }); @@ -1220,7 +1220,7 @@ describe("AcpRegistryAdapterV2", () => { const terminal = completed.at(-1)!; if (terminal.type !== "turn.terminal") throw new Error("Missing fallback terminal"); assert.equal(terminal.status, "completed"); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); // regression: #26695 cancelling a Copilot prompt kills its background agents @@ -1256,7 +1256,7 @@ describe("AcpRegistryAdapterV2", () => { ...copilot, supportsSteeringByInterruptRestart: true, }); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.effect("gives Copilot T3's MCP server over HTTP when it advertises HTTP", () => @@ -1389,7 +1389,7 @@ describe("AcpRegistryAdapterV2", () => { new: stdio("Bearer opened-token"), load: stdio("Bearer rotated-token"), }); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.effect("the agent process env names the thread it serves", () => @@ -1468,7 +1468,7 @@ describe("AcpRegistryAdapterV2", () => { assert.equal(agentEnvironment?.T3CODE_THREAD_ID, "thread-acp-registry-thread-env"); assert.equal(agentEnvironment?.T3_ACP_MCP_ENDPOINT, endpoint); assert.equal(agentEnvironment?.T3_ACP_MCP_AUTHORIZATION, "Bearer thread-env-token"); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.effect.each(["before", "after"] as const)( @@ -1561,7 +1561,7 @@ describe("AcpRegistryAdapterV2", () => { ); } } - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.effect("settles Claude's accepted foreground reply while retaining child output", () => @@ -1765,7 +1765,7 @@ describe("AcpRegistryAdapterV2", () => { ), "autonomous root echoes only signal acceptance", ); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.effect.each([ @@ -1950,7 +1950,7 @@ describe("AcpRegistryAdapterV2", () => { ), "detached requests are cancelled before turn admission", ); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.effect("retains streamed child tool output through the generic Devin route", () => @@ -2087,7 +2087,7 @@ describe("AcpRegistryAdapterV2", () => { (event) => event.type === "message.updated" && event.message.text.includes("onetwo"), ), ); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.effect("replaces streamed root tool output with a later content list", () => @@ -2150,7 +2150,7 @@ describe("AcpRegistryAdapterV2", () => { text("replacement-more"), { type: "Text", text: "replacement-morePATCH" }, ]); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.effect("keeps stopped child tools interrupted when a late output chunk arrives", () => @@ -2257,7 +2257,7 @@ describe("AcpRegistryAdapterV2", () => { assert.deepEqual(tools.at(-1)?.output, [ { type: "content", content: { type: "text", text: "stoppingcancelled" } }, ]); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.effect("keeps native child tools interrupted after idle and late output", () => @@ -2297,7 +2297,7 @@ describe("AcpRegistryAdapterV2", () => { assert.deepEqual(tools.at(-1)?.output, [ { type: "content", content: { type: "text", text: "late" } }, ]); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.effect.each(["error", "end_turn"])( @@ -2593,7 +2593,7 @@ describe("AcpRegistryAdapterV2", () => { "a new execution does not append the previous execution's text", ); assert.isFalse(yield* session.runtime.hasPendingBackgroundWork!); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.effect("fails active Claude work and releases its children when the process exits", () => @@ -2663,7 +2663,7 @@ describe("AcpRegistryAdapterV2", () => { event.turnItem.status === "interrupted", ), ); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.effect("keeps Claude activity after a refused Stop and closes it on retry", () => @@ -2781,7 +2781,7 @@ describe("AcpRegistryAdapterV2", () => { assert.equal(queuedTerminal.status, "interrupted"); assert.isNull(queuedTerminal.failure); assert.isFalse(yield* queued.runtime.hasPendingBackgroundWork!); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); it.effect.each([ @@ -2950,6 +2950,6 @@ describe("AcpRegistryAdapterV2", () => { assert.equal(yield* runTurn(1), "failed"); assert.equal(yield* runTurn(2), "completed"); assert.equal(spawned, restarts ? 2 : 1); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }).pipe(Effect.provide(layerTest)), ); }); diff --git a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts index 7d8223842505c..b281ecc16ab84 100644 --- a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts @@ -214,7 +214,7 @@ describe("AntigravityAdapterV2 compaction", () => { assert.isUndefined(session.compactThread); assert.equal(session.providerSession.capabilities.turns.supportsNativeGoals, nativeGoals); assert.equal((yield* adapter.getCapabilities()).turns.supportsNativeGoals, nativeGoals); - }).pipe(Effect.provide(layerSession), Effect.scoped), + }).pipe(Effect.provide(layerSession)), ); }); @@ -270,7 +270,7 @@ describe("AntigravityAdapterV2 session open", () => { error._tag === "ProviderAdapterOpenSessionError" && error.requiresSignIn, requiresSignIn, ); - }).pipe(Effect.provide(layerSession), Effect.scoped), + }).pipe(Effect.provide(layerSession)), ); }); @@ -416,7 +416,7 @@ describe("AntigravityAdapterV2 client file system", () => { context("fs/read_text_file"), ); assert.equal(viaInsideLink.content, "inside"); - }).pipe(Effect.provide(layerSession), Effect.scoped), + }).pipe(Effect.provide(layerSession)), ); }); @@ -586,7 +586,7 @@ describe("AntigravityAdapterV2 workspace changes", () => { context, ).pipe(Effect.exit); assert.isTrue(Exit.isFailure(fromA), "the previous workspace is no longer readable"); - }).pipe(Effect.provide(layerSession), Effect.scoped), + }).pipe(Effect.provide(layerSession)), ); }); @@ -866,7 +866,7 @@ describe("AntigravityAdapterV2 html_render pages", () => { assert.strictEqual(yield* mcpSessions.takeHtmlRender(threadId, report), undefined); assert.strictEqual(yield* mcpSessions.takeHtmlRender(threadId, failed), undefined); assert.strictEqual(yield* mcpSessions.takeHtmlRender(threadId, errored), undefined); - }).pipe(Effect.provide(layerSession), Effect.scoped), + }).pipe(Effect.provide(layerSession)), ); }); @@ -1074,7 +1074,7 @@ describe("AntigravityAdapterV2 subagent reads", () => { assert.deepEqual(read("call_missing"), failed); assert.deepEqual(read("call_denied"), failed); assert.deepEqual(read("call_unseen"), failed); - }).pipe(Effect.provide(layerSession), Effect.scoped), + }).pipe(Effect.provide(layerSession)), ); }); @@ -1271,6 +1271,6 @@ describe("AntigravityAdapterV2 session system prompt", () => { assert.notInclude(append, "systemPrompt"); assert.include(sent, ""); } - }).pipe(Effect.provide(layerSession), Effect.scoped), + }).pipe(Effect.provide(layerSession)), ); }); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 894c8af4472bb..18a16e2f63102 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -972,131 +972,125 @@ describe("ClaudeAdapterV2 session permissions", () => { describe("ClaudeAdapterV2 Auto-accept edits", () => { it.effect("asks before a command instead of allowing it", () => - Effect.scoped( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-claude-accept-edits-", - }); - let openedOptions: ClaudeAdapterV2.ClaudeAgentSdkQueryOptions | undefined; - const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ - instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, - settings: DEFAULT_CLAUDE_SETTINGS, - environment: {}, - attachmentsDir, - fileSystem, - path: yield* Path.Path, - random: yield* SecureRandom.SecureRandom, - idAllocator, - queryRunner: { - allocateSessionId: Effect.succeed("native-thread-claude-accept-edits"), - open: (input) => - Effect.sync(() => { - openedOptions = input.options; - return { - messages: Stream.never, - offer: () => Effect.void, - setModel: () => Effect.void, - applyFlagSettings: () => Effect.void, - setPermissionMode: () => Effect.void, - backgroundTasks: () => Effect.succeed(false), - mcpServerStatus: Effect.succeed([]), - readMcpResource: () => Effect.die("unexpected MCP resource read"), - callMcpTool: () => Effect.die("unexpected MCP tool call"), - listPermissionRules: Effect.die("unexpected permission rules read"), - stopTask: () => Effect.void, - interrupt: Effect.void, - close: Effect.void, - }; - }), - forkSession: () => Effect.die("unused"), - subagentLaunchToolUseId: () => Effect.succeed(null), - subagentModel: () => Effect.succeed(null), - assertComplete: Effect.void, - }, - }); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "auto-accept-edits", - interactionMode: "default", - cwd: "/workspace", - }); - const threadId = ThreadId.make("thread-claude-accept-edits"); - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("provider-session-claude-accept-edits"), - modelSelection: CLAUDE_TEST_MODEL_SELECTION, - runtimePolicy, - }); - const providerThread = yield* runtime.ensureThread({ + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-claude-accept-edits-", + }); + let openedOptions: ClaudeAdapterV2.ClaudeAgentSdkQueryOptions | undefined; + const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ + instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, + settings: DEFAULT_CLAUDE_SETTINGS, + environment: {}, + attachmentsDir, + fileSystem, + path: yield* Path.Path, + random: yield* SecureRandom.SecureRandom, + idAllocator, + queryRunner: { + allocateSessionId: Effect.succeed("native-thread-claude-accept-edits"), + open: (input) => + Effect.sync(() => { + openedOptions = input.options; + return { + messages: Stream.never, + offer: () => Effect.void, + setModel: () => Effect.void, + applyFlagSettings: () => Effect.void, + setPermissionMode: () => Effect.void, + backgroundTasks: () => Effect.succeed(false), + mcpServerStatus: Effect.succeed([]), + readMcpResource: () => Effect.die("unexpected MCP resource read"), + callMcpTool: () => Effect.die("unexpected MCP tool call"), + listPermissionRules: Effect.die("unexpected permission rules read"), + stopTask: () => Effect.void, + interrupt: Effect.void, + close: Effect.void, + }; + }), + forkSession: () => Effect.die("unused"), + subagentLaunchToolUseId: () => Effect.succeed(null), + subagentModel: () => Effect.succeed(null), + assertComplete: Effect.void, + }, + }); + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "auto-accept-edits", + interactionMode: "default", + cwd: "/workspace", + }); + const threadId = ThreadId.make("thread-claude-accept-edits"); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-claude-accept-edits"), + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy, + }); + const now = yield* DateTime.now; + yield* runtime.startTurn( + makeClaudeTestTurnInput({ threadId, - modelSelection: CLAUDE_TEST_MODEL_SELECTION, + providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-accept-edits"), + text: "Run node.", + attachments: [], runtimePolicy, - }); - const now = yield* DateTime.now; - yield* runtime.startTurn( - makeClaudeTestTurnInput({ - threadId, - providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-accept-edits"), - text: "Run node.", - attachments: [], - runtimePolicy, - }), - ); - assert.equal(openedOptions?.permissionMode, "acceptEdits"); - const canUseTool = openedOptions?.canUseTool; - assert.isFunction(canUseTool); + }), + ); + assert.equal(openedOptions?.permissionMode, "acceptEdits"); + const canUseTool = openedOptions?.canUseTool; + assert.isFunction(canUseTool); - const requestEvent = yield* runtime.events.pipe( - Stream.takeUntil( - (event) => - event.type === "turn_item.updated" && event.turnItem.type === "approval_request", - ), - Stream.runCollect, - Effect.forkScoped, - ); - const command = { command: "node -e 'console.log(42)'" }; - const decision = yield* Effect.promise(() => - canUseTool!("Bash", command, { - signal: new AbortController().signal, - toolUseID: "tool-bash-accept-edits", - requestId: "request-bash-accept-edits", - description: "Check the Node runtime", - }), - ).pipe(Effect.forkScoped); - const first = yield* Effect.raceFirst( - Fiber.join(requestEvent).pipe( - Effect.map((event) => ({ type: "request", event }) as const), - ), - Fiber.join(decision).pipe( - Effect.map((result) => ({ type: "decision", result }) as const), - ), - ); - assert.equal(first.type, "request", "the command ran without asking"); - if (first.type !== "request") return; - const event = first.event.find((event) => event.type === "runtime_request.updated"); - assert.equal(event?.runtimeRequest.kind, "command"); - const approval = first.event.findLast((event) => event.type === "turn_item.updated"); - assert.equal( - approval?.turnItem.type === "approval_request" ? approval.turnItem.prompt : undefined, - command.command, - ); + const requestEvent = yield* runtime.events.pipe( + Stream.takeUntil( + (event) => + event.type === "turn_item.updated" && event.turnItem.type === "approval_request", + ), + Stream.runCollect, + Effect.forkScoped, + ); + const command = { command: "node -e 'console.log(42)'" }; + const decision = yield* Effect.promise(() => + canUseTool!("Bash", command, { + signal: new AbortController().signal, + toolUseID: "tool-bash-accept-edits", + requestId: "request-bash-accept-edits", + description: "Check the Node runtime", + }), + ).pipe(Effect.forkScoped); + const first = yield* Effect.raceFirst( + Fiber.join(requestEvent).pipe(Effect.map((event) => ({ type: "request", event }) as const)), + Fiber.join(decision).pipe(Effect.map((result) => ({ type: "decision", result }) as const)), + ); + assert.equal(first.type, "request", "the command ran without asking"); + if (first.type !== "request") return; + const event = first.event.find((event) => event.type === "runtime_request.updated"); + assert.equal(event?.runtimeRequest.kind, "command"); + const approval = first.event.findLast((event) => event.type === "turn_item.updated"); + assert.equal( + approval?.turnItem.type === "approval_request" ? approval.turnItem.prompt : undefined, + command.command, + ); - yield* runtime.respondToRuntimeRequest({ - requestId: event!.runtimeRequest.id, - decision: "accept", - }); - assert.equal((yield* Fiber.join(decision))?.behavior, "allow"); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + yield* runtime.respondToRuntimeRequest({ + requestId: event!.runtimeRequest.id, + decision: "accept", + }); + assert.equal((yield* Fiber.join(decision))?.behavior, "allow"); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), @@ -1180,33 +1174,31 @@ describe("ClaudeAdapterV2 append-system-prompt-file launch argument", () => { (options?.systemPrompt as { readonly append?: string } | undefined)?.append; it.effect("sends append launch arguments to Claude after the T3 instructions", () => - Effect.scoped( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-append-cwd-" }); - yield* fileSystem.writeFileString(NodePath.join(cwd, "rules.md"), "File sentinel\n"); - const plain = yield* startTurnWithLaunchArgs("", cwd); - const { exit, opened } = yield* startTurnWithLaunchArgs( - '--append-system-prompt "Inline sentinel" --append-system-prompt-file rules.md --system-prompt Replacement', - cwd, - ); - assert.isTrue(Exit.isSuccess(exit)); - assert.lengthOf(opened, 1); - assert.equal( - appendSystemPrompt(opened[0]), - `${appendSystemPrompt(plain.opened[0])}\n\nFile sentinel\n\nInline sentinel`, - ); - assert.isUndefined(opened[0]!.extraArgs?.["append-system-prompt"]); - assert.isUndefined(opened[0]!.extraArgs?.["append-system-prompt-file"]); - assert.equal(opened[0]!.extraArgs?.["system-prompt"], "Replacement"); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-append-cwd-" }); + yield* fileSystem.writeFileString(NodePath.join(cwd, "rules.md"), "File sentinel\n"); + const plain = yield* startTurnWithLaunchArgs("", cwd); + const { exit, opened } = yield* startTurnWithLaunchArgs( + '--append-system-prompt "Inline sentinel" --append-system-prompt-file rules.md --system-prompt Replacement', + cwd, + ); + assert.isTrue(Exit.isSuccess(exit)); + assert.lengthOf(opened, 1); + assert.equal( + appendSystemPrompt(opened[0]), + `${appendSystemPrompt(plain.opened[0])}\n\nFile sentinel\n\nInline sentinel`, + ); + assert.isUndefined(opened[0]!.extraArgs?.["append-system-prompt"]); + assert.isUndefined(opened[0]!.extraArgs?.["append-system-prompt-file"]); + assert.equal(opened[0]!.extraArgs?.["system-prompt"], "Replacement"); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), @@ -1217,29 +1209,27 @@ describe("ClaudeAdapterV2 append-system-prompt-file launch argument", () => { `--settings '{"skillOverrides":{"lint":"off"},"showThinkingSummaries":false}'`, "--settings user-settings.json", ])("merges %s beneath T3's settings", (launchArgs) => - Effect.scoped( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-settings-" }); - yield* fileSystem.writeFileString( - NodePath.join(cwd, "user-settings.json"), - '{"skillOverrides":{"lint":"off"},"showThinkingSummaries":false}', - ); - const { exit, opened } = yield* startTurnWithLaunchArgs(launchArgs, cwd); - assert.isTrue(Exit.isSuccess(exit)); - assert.deepInclude(opened[0]!.settings, { - skillOverrides: { lint: "off" }, - showThinkingSummaries: true, - }); - assert.isUndefined(opened[0]!.extraArgs?.["settings"]); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-settings-" }); + yield* fileSystem.writeFileString( + NodePath.join(cwd, "user-settings.json"), + '{"skillOverrides":{"lint":"off"},"showThinkingSummaries":false}', + ); + const { exit, opened } = yield* startTurnWithLaunchArgs(launchArgs, cwd); + assert.isTrue(Exit.isSuccess(exit)); + assert.deepInclude(opened[0]!.settings, { + skillOverrides: { lint: "off" }, + showThinkingSummaries: true, + }); + assert.isUndefined(opened[0]!.extraArgs?.["settings"]); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), @@ -1258,32 +1248,30 @@ describe("ClaudeAdapterV2 append-system-prompt-file launch argument", () => { assert.include(appendSystemPrompt(options), '{"skillOverrides":{"lint":"off"}}'), ], ] as const)("reads %s from the home directory", ([launchArgs, assertOptions]) => - Effect.scoped( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-home-" }); - const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-home-cwd-" }); - yield* fileSystem.makeDirectory(NodePath.join(home, ".claude")); - yield* fileSystem.writeFileString( - NodePath.join(home, ".claude", "settings.json"), - '{"skillOverrides":{"lint":"off"}}', - ); - const { exit, opened } = yield* startTurnWithLaunchArgs(launchArgs, cwd).pipe( - Effect.provideService(HostProcess.HomeDirectory, home), - ); - if (Exit.isFailure(exit)) { - assert.fail(makeProviderFailure({ cause: exit.cause, class: "provider_error" }).message); - } - assert.lengthOf(opened, 1); - assertOptions(opened[0]!); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-home-" }); + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-home-cwd-" }); + yield* fileSystem.makeDirectory(NodePath.join(home, ".claude")); + yield* fileSystem.writeFileString( + NodePath.join(home, ".claude", "settings.json"), + '{"skillOverrides":{"lint":"off"}}', + ); + const { exit, opened } = yield* startTurnWithLaunchArgs(launchArgs, cwd).pipe( + Effect.provideService(HostProcess.HomeDirectory, home), + ); + if (Exit.isFailure(exit)) { + assert.fail(makeProviderFailure({ cause: exit.cause, class: "provider_error" }).message); + } + assert.lengthOf(opened, 1); + assertOptions(opened[0]!); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), @@ -1333,26 +1321,24 @@ describe("ClaudeAdapterV2 append-system-prompt-file launch argument", () => { () => new ClaudeAdapterV2.ClaudeSettingsLaunchArgError({}).message, ], ] as const)("fails the turn before opening Claude for %s", ([launchArgs, expectedMessage]) => - Effect.scoped( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-append-cwd-" }); - const { exit, opened } = yield* startTurnWithLaunchArgs(launchArgs, cwd); - assert.lengthOf(opened, 0); - assert.isTrue(Exit.isFailure(exit)); - if (!Exit.isFailure(exit)) return; - assert.equal( - makeProviderFailure({ cause: exit.cause, class: "provider_error" }).message, - expectedMessage(cwd), - ); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-append-cwd-" }); + const { exit, opened } = yield* startTurnWithLaunchArgs(launchArgs, cwd); + assert.lengthOf(opened, 0); + assert.isTrue(Exit.isFailure(exit)); + if (!Exit.isFailure(exit)) return; + assert.equal( + makeProviderFailure({ cause: exit.cause, class: "provider_error" }).message, + expectedMessage(cwd), + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), @@ -1361,95 +1347,93 @@ describe("ClaudeAdapterV2 append-system-prompt-file launch argument", () => { describe("ClaudeAdapterV2 provider installation gate", () => { it.effect("opens each turn's Claude process inside the installation gate", () => - Effect.scoped( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const log: Array = []; - const instanceId = ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID; - const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ - instanceId, - settings: DEFAULT_CLAUDE_SETTINGS, - environment: {}, - attachmentsDir: yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-gate-" }), - fileSystem, - path: yield* Path.Path, - random: yield* SecureRandom.SecureRandom, - idAllocator: yield* IdAllocator.IdAllocatorV2, - installationGate: ProviderInstallationGate.ProviderInstallationGate.of({ - execute: (gatedInstanceId, effect) => - Effect.sync(() => log.push(`enter ${gatedInstanceId}`)).pipe( - Effect.andThen(effect), - Effect.ensuring(Effect.sync(() => log.push("exit"))), - ), - update: () => Effect.die("unused"), - }), - queryRunner: { - allocateSessionId: Effect.succeed("native-thread-claude-gate"), - open: () => - Effect.sync(() => { - log.push("open query"); - return { - messages: Stream.never, - offer: () => Effect.void, - setModel: () => Effect.void, - applyFlagSettings: () => Effect.void, - setPermissionMode: () => Effect.void, - backgroundTasks: () => Effect.succeed(false), - mcpServerStatus: Effect.succeed([]), - readMcpResource: () => Effect.die("unexpected MCP resource read"), - callMcpTool: () => Effect.die("unexpected MCP tool call"), - listPermissionRules: Effect.die("unexpected permission rules read"), - stopTask: () => Effect.void, - interrupt: Effect.void, - close: Effect.void, - }; - }), - forkSession: () => Effect.die("unused"), - subagentLaunchToolUseId: () => Effect.succeed(null), - subagentModel: () => Effect.succeed(null), - assertComplete: Effect.void, - }, - }); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-gate-cwd-" }), - }); - const threadId = ThreadId.make("thread-claude-gate"); - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("provider-session-claude-gate"), - modelSelection: CLAUDE_TEST_MODEL_SELECTION, - runtimePolicy, - }); - const providerThread = yield* runtime.ensureThread({ + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const log: Array = []; + const instanceId = ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID; + const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ + instanceId, + settings: DEFAULT_CLAUDE_SETTINGS, + environment: {}, + attachmentsDir: yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-gate-" }), + fileSystem, + path: yield* Path.Path, + random: yield* SecureRandom.SecureRandom, + idAllocator: yield* IdAllocator.IdAllocatorV2, + installationGate: ProviderInstallationGate.ProviderInstallationGate.of({ + execute: (gatedInstanceId, effect) => + Effect.sync(() => log.push(`enter ${gatedInstanceId}`)).pipe( + Effect.andThen(effect), + Effect.ensuring(Effect.sync(() => log.push("exit"))), + ), + update: () => Effect.die("unused"), + }), + queryRunner: { + allocateSessionId: Effect.succeed("native-thread-claude-gate"), + open: () => + Effect.sync(() => { + log.push("open query"); + return { + messages: Stream.never, + offer: () => Effect.void, + setModel: () => Effect.void, + applyFlagSettings: () => Effect.void, + setPermissionMode: () => Effect.void, + backgroundTasks: () => Effect.succeed(false), + mcpServerStatus: Effect.succeed([]), + readMcpResource: () => Effect.die("unexpected MCP resource read"), + callMcpTool: () => Effect.die("unexpected MCP tool call"), + listPermissionRules: Effect.die("unexpected permission rules read"), + stopTask: () => Effect.void, + interrupt: Effect.void, + close: Effect.void, + }; + }), + forkSession: () => Effect.die("unused"), + subagentLaunchToolUseId: () => Effect.succeed(null), + subagentModel: () => Effect.succeed(null), + assertComplete: Effect.void, + }, + }); + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-gate-cwd-" }), + }); + const threadId = ThreadId.make("thread-claude-gate"); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-claude-gate"), + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy, + }); + assert.deepStrictEqual(log, []); + + yield* runtime.startTurn( + makeClaudeTestTurnInput({ threadId, - modelSelection: CLAUDE_TEST_MODEL_SELECTION, + providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-claude-gate"), + text: "Hello", + attachments: [], runtimePolicy, - }); - assert.deepStrictEqual(log, []); - - yield* runtime.startTurn( - makeClaudeTestTurnInput({ - threadId, - providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("attempt-claude-gate"), - text: "Hello", - attachments: [], - runtimePolicy, - }), - ); + }), + ); - assert.deepStrictEqual(log, [`enter ${instanceId}`, "open query", "exit"]); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + assert.deepStrictEqual(log, [`enter ${instanceId}`, "open query", "exit"]); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), @@ -1748,189 +1732,154 @@ describe("ClaudeAdapterV2 resumeThread", () => { describe("ClaudeAdapterV2 resume compaction", () => { it.effect("resolves and cancels the SDK resume dialog through structured runtime input", () => - Effect.scoped( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-claude-resume-", - }); - let openedOptions: ClaudeAdapterV2.ClaudeAgentSdkQueryOptions | undefined; - const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ - instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, - settings: DEFAULT_CLAUDE_SETTINGS, - environment: {}, - attachmentsDir, - fileSystem, - path: yield* Path.Path, - random: yield* SecureRandom.SecureRandom, - idAllocator, - queryRunner: { - allocateSessionId: Effect.succeed("native-thread-claude-resume"), - open: (input) => - Effect.sync(() => { - openedOptions = input.options; - return { - messages: Stream.never, - offer: () => Effect.void, - setModel: () => Effect.void, - applyFlagSettings: () => Effect.void, - setPermissionMode: () => Effect.void, - backgroundTasks: () => Effect.succeed(false), - mcpServerStatus: Effect.succeed([]), - readMcpResource: () => Effect.die("unexpected MCP resource read"), - callMcpTool: () => Effect.die("unexpected MCP tool call"), - listPermissionRules: Effect.die("unexpected permission rules read"), - stopTask: () => Effect.void, - interrupt: Effect.void, - close: Effect.void, - }; - }), - forkSession: () => Effect.die("unused"), - subagentLaunchToolUseId: () => Effect.succeed(null), - subagentModel: () => Effect.succeed(null), - assertComplete: Effect.void, - }, - }); - const threadId = ThreadId.make("thread-claude-resume"); - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("provider-session-claude-resume"), - modelSelection: CLAUDE_TEST_MODEL_SELECTION, - runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, - }); - const providerThread = yield* runtime.ensureThread({ + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-claude-resume-", + }); + let openedOptions: ClaudeAdapterV2.ClaudeAgentSdkQueryOptions | undefined; + const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ + instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, + settings: DEFAULT_CLAUDE_SETTINGS, + environment: {}, + attachmentsDir, + fileSystem, + path: yield* Path.Path, + random: yield* SecureRandom.SecureRandom, + idAllocator, + queryRunner: { + allocateSessionId: Effect.succeed("native-thread-claude-resume"), + open: (input) => + Effect.sync(() => { + openedOptions = input.options; + return { + messages: Stream.never, + offer: () => Effect.void, + setModel: () => Effect.void, + applyFlagSettings: () => Effect.void, + setPermissionMode: () => Effect.void, + backgroundTasks: () => Effect.succeed(false), + mcpServerStatus: Effect.succeed([]), + readMcpResource: () => Effect.die("unexpected MCP resource read"), + callMcpTool: () => Effect.die("unexpected MCP tool call"), + listPermissionRules: Effect.die("unexpected permission rules read"), + stopTask: () => Effect.void, + interrupt: Effect.void, + close: Effect.void, + }; + }), + forkSession: () => Effect.die("unused"), + subagentLaunchToolUseId: () => Effect.succeed(null), + subagentModel: () => Effect.succeed(null), + assertComplete: Effect.void, + }, + }); + const threadId = ThreadId.make("thread-claude-resume"); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-claude-resume"), + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + const now = yield* DateTime.now; + yield* runtime.startTurn( + makeClaudeTestTurnInput({ threadId, - modelSelection: CLAUDE_TEST_MODEL_SELECTION, - runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, - }); - const now = yield* DateTime.now; - yield* runtime.startTurn( - makeClaudeTestTurnInput({ - threadId, - providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-resume"), - text: "continue", - attachments: [], - }), - ); - const callback = openedOptions?.onUserDialog; - assert.isFunction(callback); - const canUseTool = openedOptions?.canUseTool; - assert.isFunction(canUseTool); - const shellSafetyReason = - "Dangerous rm operation not ruled out — the shell may read this command differently than it was parsed"; - for (const [safetyIndex, { askRule, reason }] of [ - { askRule: false, reason: shellSafetyReason }, - { askRule: true, reason: shellSafetyReason }, - { - askRule: false, - reason: - "Dangerous rm operation on possibly-empty variable path: $TARGET\nUse a literal absolute path instead.", - }, - { askRule: false, reason: "This shell -c script runs rm and could not be checked" }, - { askRule: false, reason: "A permission rule denies a command in this shell -c script" }, - ].entries()) { - const safetyRequest = yield* runtime.events.pipe( - Stream.filter((event) => event.type === "runtime_request.updated"), - Stream.runHead, - Effect.forkScoped, - ); - const safetySignal = yield* Effect.abortSignal; - const safetyDecision = yield* Effect.promise(() => - canUseTool!( - "Bash", - { command: "for pair in a b; do echo $pair; done" }, - { - signal: safetySignal, - toolUseID: `tool-shell-safety-${safetyIndex}`, - requestId: `request-shell-safety-${safetyIndex}`, - decisionReason: `\u001b[33m${reason}\u001b[39m`, - suppressAlwaysAllowRule: true, - ...(askRule - ? { - matchedAskRule: { - source: "userSettings" as const, - toolName: "Bash", - ruleContent: "*", - }, - } - : {}), - }, - ), - ).pipe(Effect.forkScoped); - const safety = yield* Effect.raceFirst( - Fiber.join(safetyDecision).pipe( - Effect.map((result) => ({ type: "decision", result }) as const), - ), - Fiber.join(safetyRequest).pipe( - Effect.map((event) => ({ type: "approval", event }) as const), - ), - ); - assert.equal(safety.type, askRule ? "approval" : "decision"); - if (safety.type === "decision") { - assert.equal(safety.result?.behavior, "deny"); - if (safety.result?.behavior === "deny") { - assert.include(safety.result.message, reason); - assert.notInclude(safety.result.message, "\u001b["); - } - } else if ( - Option.isSome(safety.event) && - safety.event.value.type === "runtime_request.updated" - ) { - yield* runtime.respondToRuntimeRequest({ - requestId: safety.event.value.runtimeRequest.id, - decision: "decline", - }); - assert.equal((yield* Fiber.join(safetyDecision))?.behavior, "deny"); - } - yield* Fiber.interrupt(safetyRequest); - } - const longQuestion = `Choose a deployment target: ${"region ".repeat(80)}`; - const questionRequestEvent = yield* runtime.events.pipe( + providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-resume"), + text: "continue", + attachments: [], + }), + ); + const callback = openedOptions?.onUserDialog; + assert.isFunction(callback); + const canUseTool = openedOptions?.canUseTool; + assert.isFunction(canUseTool); + const shellSafetyReason = + "Dangerous rm operation not ruled out — the shell may read this command differently than it was parsed"; + for (const [safetyIndex, { askRule, reason }] of [ + { askRule: false, reason: shellSafetyReason }, + { askRule: true, reason: shellSafetyReason }, + { + askRule: false, + reason: + "Dangerous rm operation on possibly-empty variable path: $TARGET\nUse a literal absolute path instead.", + }, + { askRule: false, reason: "This shell -c script runs rm and could not be checked" }, + { askRule: false, reason: "A permission rule denies a command in this shell -c script" }, + ].entries()) { + const safetyRequest = yield* runtime.events.pipe( Stream.filter((event) => event.type === "runtime_request.updated"), Stream.runHead, Effect.forkScoped, ); - const questionResult = yield* Effect.promise(() => + const safetySignal = yield* Effect.abortSignal; + const safetyDecision = yield* Effect.promise(() => canUseTool!( - "AskUserQuestion", - { - questions: [ - { - header: "Target", - question: longQuestion, - options: [ - { label: "Production", description: "Deploy to production." }, - { label: "Staging", description: "Deploy to staging." }, - ], - multiSelect: true, - }, - ], - }, + "Bash", + { command: "for pair in a b; do echo $pair; done" }, { - signal: new AbortController().signal, - toolUseID: "tool-question-full-access", - requestId: "request-question-full-access", + signal: safetySignal, + toolUseID: `tool-shell-safety-${safetyIndex}`, + requestId: `request-shell-safety-${safetyIndex}`, + decisionReason: `\u001b[33m${reason}\u001b[39m`, + suppressAlwaysAllowRule: true, + ...(askRule + ? { + matchedAskRule: { + source: "userSettings" as const, + toolName: "Bash", + ruleContent: "*", + }, + } + : {}), }, ), ).pipe(Effect.forkScoped); - const questionEvent = yield* Fiber.join(questionRequestEvent); - assert.isTrue(Option.isSome(questionEvent)); - if ( - Option.isNone(questionEvent) || - questionEvent.value.type !== "runtime_request.updated" + const safety = yield* Effect.raceFirst( + Fiber.join(safetyDecision).pipe( + Effect.map((result) => ({ type: "decision", result }) as const), + ), + Fiber.join(safetyRequest).pipe( + Effect.map((event) => ({ type: "approval", event }) as const), + ), + ); + assert.equal(safety.type, askRule ? "approval" : "decision"); + if (safety.type === "decision") { + assert.equal(safety.result?.behavior, "deny"); + if (safety.result?.behavior === "deny") { + assert.include(safety.result.message, reason); + assert.notInclude(safety.result.message, "\u001b["); + } + } else if ( + Option.isSome(safety.event) && + safety.event.value.type === "runtime_request.updated" ) { - return; + yield* runtime.respondToRuntimeRequest({ + requestId: safety.event.value.runtimeRequest.id, + decision: "decline", + }); + assert.equal((yield* Fiber.join(safetyDecision))?.behavior, "deny"); } - yield* runtime.respondToRuntimeRequest({ - requestId: questionEvent.value.runtimeRequest.id, - answers: { [longQuestion]: ["Production", "Staging"] }, - }); - assert.deepEqual(yield* Fiber.join(questionResult), { - behavior: "allow", - updatedInput: { + yield* Fiber.interrupt(safetyRequest); + } + const longQuestion = `Choose a deployment target: ${"region ".repeat(80)}`; + const questionRequestEvent = yield* runtime.events.pipe( + Stream.filter((event) => event.type === "runtime_request.updated"), + Stream.runHead, + Effect.forkScoped, + ); + const questionResult = yield* Effect.promise(() => + canUseTool!( + "AskUserQuestion", + { questions: [ { header: "Target", @@ -1942,90 +1891,120 @@ describe("ClaudeAdapterV2 resume compaction", () => { multiSelect: true, }, ], - answers: { [longQuestion]: "Production, Staging" }, }, - toolUseID: "tool-question-full-access", - }); - const longPlan = `# Deployment plan\n\n${"Validate every region before promotion.\n".repeat(120)}`; - const proposedPlanEvent = yield* runtime.events.pipe( - Stream.filter((event) => event.type === "plan.updated"), - Stream.runHead, - Effect.forkScoped, - ); - const exitPlanResult = yield* Effect.promise(() => - canUseTool!( - "ExitPlanMode", - { plan: longPlan }, + { + signal: new AbortController().signal, + toolUseID: "tool-question-full-access", + requestId: "request-question-full-access", + }, + ), + ).pipe(Effect.forkScoped); + const questionEvent = yield* Fiber.join(questionRequestEvent); + assert.isTrue(Option.isSome(questionEvent)); + if (Option.isNone(questionEvent) || questionEvent.value.type !== "runtime_request.updated") { + return; + } + yield* runtime.respondToRuntimeRequest({ + requestId: questionEvent.value.runtimeRequest.id, + answers: { [longQuestion]: ["Production", "Staging"] }, + }); + assert.deepEqual(yield* Fiber.join(questionResult), { + behavior: "allow", + updatedInput: { + questions: [ { - signal: new AbortController().signal, - toolUseID: "tool-exit-plan-full-access", - requestId: "request-exit-plan-full-access", + header: "Target", + question: longQuestion, + options: [ + { label: "Production", description: "Deploy to production." }, + { label: "Staging", description: "Deploy to staging." }, + ], + multiSelect: true, }, - ), - ); - assert.deepEqual(exitPlanResult, { - behavior: "deny", - message: - "The client captured your proposed plan. Stop here and wait for the user's feedback or implementation request in a later turn.", - toolUseID: "tool-exit-plan-full-access", - }); - const planEvent = yield* Fiber.join(proposedPlanEvent); - assert.isTrue(Option.isSome(planEvent)); - if (Option.isSome(planEvent) && planEvent.value.type === "plan.updated") { - assert.equal(planEvent.value.plan.kind, "proposed_plan"); - if (planEvent.value.plan.kind === "proposed_plan") { - assert.equal(planEvent.value.plan.markdown, longPlan.trim()); - } + ], + answers: { [longQuestion]: "Production, Staging" }, + }, + toolUseID: "tool-question-full-access", + }); + const longPlan = `# Deployment plan\n\n${"Validate every region before promotion.\n".repeat(120)}`; + const proposedPlanEvent = yield* runtime.events.pipe( + Stream.filter((event) => event.type === "plan.updated"), + Stream.runHead, + Effect.forkScoped, + ); + const exitPlanResult = yield* Effect.promise(() => + canUseTool!( + "ExitPlanMode", + { plan: longPlan }, + { + signal: new AbortController().signal, + toolUseID: "tool-exit-plan-full-access", + requestId: "request-exit-plan-full-access", + }, + ), + ); + assert.deepEqual(exitPlanResult, { + behavior: "deny", + message: + "The client captured your proposed plan. Stop here and wait for the user's feedback or implementation request in a later turn.", + toolUseID: "tool-exit-plan-full-access", + }); + const planEvent = yield* Fiber.join(proposedPlanEvent); + assert.isTrue(Option.isSome(planEvent)); + if (Option.isSome(planEvent) && planEvent.value.type === "plan.updated") { + assert.equal(planEvent.value.plan.kind, "proposed_plan"); + if (planEvent.value.plan.kind === "proposed_plan") { + assert.equal(planEvent.value.plan.markdown, longPlan.trim()); } - const requestEvent = yield* runtime.events.pipe( - Stream.filter((event) => event.type === "runtime_request.updated"), - Stream.runHead, - Effect.forkScoped, - ); - const signal = yield* Effect.abortSignal; - const dialog = yield* Effect.promise(() => + } + const requestEvent = yield* runtime.events.pipe( + Stream.filter((event) => event.type === "runtime_request.updated"), + Stream.runHead, + Effect.forkScoped, + ); + const signal = yield* Effect.abortSignal; + const dialog = yield* Effect.promise(() => + callback!( + { + dialogKind: "resume_return", + payload: { sessionAgeMinutes: 90, estimatedTokens: 120000 }, + }, + { signal, requestId: "dialog-resume-1" }, + ), + ).pipe(Effect.forkScoped); + const event = yield* Fiber.join(requestEvent); + assert.isTrue(Option.isSome(event)); + if (Option.isNone(event) || event.value.type !== "runtime_request.updated") return; + const question = formatClaudeResumeCompactionQuestion({ + ageMinutes: 90, + estimatedTokens: 120000, + }); + yield* runtime.respondToRuntimeRequest({ + requestId: event.value.runtimeRequest.id, + answers: { [question]: "Compact and continue" }, + }); + assert.deepEqual(yield* Fiber.join(dialog), { behavior: "completed", result: "compact" }); + + assert.deepEqual( + yield* Effect.promise(() => callback!( { dialogKind: "resume_return", payload: { sessionAgeMinutes: 90, estimatedTokens: 120000 }, }, - { signal, requestId: "dialog-resume-1" }, - ), - ).pipe(Effect.forkScoped); - const event = yield* Fiber.join(requestEvent); - assert.isTrue(Option.isSome(event)); - if (Option.isNone(event) || event.value.type !== "runtime_request.updated") return; - const question = formatClaudeResumeCompactionQuestion({ - ageMinutes: 90, - estimatedTokens: 120000, - }); - yield* runtime.respondToRuntimeRequest({ - requestId: event.value.runtimeRequest.id, - answers: { [question]: "Compact and continue" }, - }); - assert.deepEqual(yield* Fiber.join(dialog), { behavior: "completed", result: "compact" }); - - assert.deepEqual( - yield* Effect.promise(() => - callback!( - { - dialogKind: "resume_return", - payload: { sessionAgeMinutes: 90, estimatedTokens: 120000 }, - }, - { signal: AbortSignal.abort(), requestId: "dialog-resume-2" }, - ), - ), - { behavior: "cancelled" }, - ); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, + { signal: AbortSignal.abort(), requestId: "dialog-resume-2" }, ), ), + { behavior: "cancelled" }, + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ); @@ -2090,555 +2069,543 @@ describe("ClaudeAdapterV2 attachments", () => { ])( "preserves $name with native PDFs, images, and paths on sends and steering", ({ prompt, expectedText, command, ultrathink, resumed, budget }) => - Effect.scoped( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const path = yield* Path.Path; - const claudeHome = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-claude-v2-skills-home-", - }); - yield* fileSystem.makeDirectory(path.join(claudeHome, "skills", "implement"), { - recursive: true, - }); - yield* fileSystem.writeFileString( - path.join(claudeHome, "skills", "implement", "SKILL.md"), - "---\ndescription: Implement the requested change\n---\nImplement it.\n", - ); - const modelSelection = { - ...CLAUDE_TEST_MODEL_SELECTION, - options: ultrathink ? CLAUDE_TEST_MODEL_SELECTION.options : [], - }; - const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-claude-v2-attachments-", - }); - const offeredMessages: Array = []; - let failOpen = false; - let failOffer = false; - let cancelAfterQueue = false; - const queued = yield* Deferred.make(); - const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ - instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, - settings: { ...DEFAULT_CLAUDE_SETTINGS, homePath: claudeHome }, - environment: {}, - attachmentsDir, - fileSystem, - path: yield* Path.Path, - random: yield* SecureRandom.SecureRandom, - idAllocator, - queryRunner: { - allocateSessionId: Effect.succeed("native-thread-claude-attachments"), - open: () => - Effect.gen(function* () { - if (failOpen) { - failOpen = false; - return yield* new ClaudeAdapterV2.ClaudeAgentSdkQueryRunnerError({ - method: "open", - cause: "rejected open", - }); - } - const messages = yield* Queue.unbounded(); - return { - messages: Stream.fromQueue(messages), - offer: (message, onQueued) => - Effect.suspend(() => { - if (failOffer) { - failOffer = false; - return Effect.fail( - new ClaudeAdapterV2.ClaudeAgentSdkQueryRunnerError({ - method: "offer", - cause: "rejected offer", - }), - ); - } - return Effect.sync(() => { - offeredMessages.push(message); - onQueued?.(); - }).pipe( - Effect.andThen(() => - cancelAfterQueue - ? Deferred.succeed(queued, undefined).pipe( - Effect.andThen(Effect.never), - ) - : Effect.void, - ), + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const path = yield* Path.Path; + const claudeHome = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-claude-v2-skills-home-", + }); + yield* fileSystem.makeDirectory(path.join(claudeHome, "skills", "implement"), { + recursive: true, + }); + yield* fileSystem.writeFileString( + path.join(claudeHome, "skills", "implement", "SKILL.md"), + "---\ndescription: Implement the requested change\n---\nImplement it.\n", + ); + const modelSelection = { + ...CLAUDE_TEST_MODEL_SELECTION, + options: ultrathink ? CLAUDE_TEST_MODEL_SELECTION.options : [], + }; + const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-claude-v2-attachments-", + }); + const offeredMessages: Array = []; + let failOpen = false; + let failOffer = false; + let cancelAfterQueue = false; + const queued = yield* Deferred.make(); + const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ + instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, + settings: { ...DEFAULT_CLAUDE_SETTINGS, homePath: claudeHome }, + environment: {}, + attachmentsDir, + fileSystem, + path: yield* Path.Path, + random: yield* SecureRandom.SecureRandom, + idAllocator, + queryRunner: { + allocateSessionId: Effect.succeed("native-thread-claude-attachments"), + open: () => + Effect.gen(function* () { + if (failOpen) { + failOpen = false; + return yield* new ClaudeAdapterV2.ClaudeAgentSdkQueryRunnerError({ + method: "open", + cause: "rejected open", + }); + } + const messages = yield* Queue.unbounded(); + return { + messages: Stream.fromQueue(messages), + offer: (message, onQueued) => + Effect.suspend(() => { + if (failOffer) { + failOffer = false; + return Effect.fail( + new ClaudeAdapterV2.ClaudeAgentSdkQueryRunnerError({ + method: "offer", + cause: "rejected offer", + }), ); - }), - setModel: () => Effect.void, - applyFlagSettings: () => Effect.void, - setPermissionMode: () => Effect.void, - backgroundTasks: () => Effect.succeed(false), - mcpServerStatus: Effect.succeed([]), - readMcpResource: () => Effect.die("unexpected MCP resource read"), - callMcpTool: () => Effect.die("unexpected MCP tool call"), - listPermissionRules: Effect.die("unexpected permission rules read"), - stopTask: () => Effect.void, - interrupt: Effect.void, - close: Queue.shutdown(messages), - }; - }), - forkSession: () => Effect.die("unused forkSession"), - subagentLaunchToolUseId: () => Effect.succeed(null), - subagentModel: () => Effect.succeed(null), - assertComplete: Effect.void, - }, - }); - const threadId = ThreadId.make("thread-claude-attachments"); - const providerSessionId = ProviderSessionId.make("provider-session-claude-attachments"); - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId, - modelSelection, - runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, - }); - const providerThread = yield* runtime.ensureThread({ + } + return Effect.sync(() => { + offeredMessages.push(message); + onQueued?.(); + }).pipe( + Effect.andThen(() => + cancelAfterQueue + ? Deferred.succeed(queued, undefined).pipe(Effect.andThen(Effect.never)) + : Effect.void, + ), + ); + }), + setModel: () => Effect.void, + applyFlagSettings: () => Effect.void, + setPermissionMode: () => Effect.void, + backgroundTasks: () => Effect.succeed(false), + mcpServerStatus: Effect.succeed([]), + readMcpResource: () => Effect.die("unexpected MCP resource read"), + callMcpTool: () => Effect.die("unexpected MCP tool call"), + listPermissionRules: Effect.die("unexpected permission rules read"), + stopTask: () => Effect.void, + interrupt: Effect.void, + close: Queue.shutdown(messages), + }; + }), + forkSession: () => Effect.die("unused forkSession"), + subagentLaunchToolUseId: () => Effect.succeed(null), + subagentModel: () => Effect.succeed(null), + assertComplete: Effect.void, + }, + }); + const threadId = ThreadId.make("thread-claude-attachments"); + const providerSessionId = ProviderSessionId.make("provider-session-claude-attachments"); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId, + modelSelection, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + const attachment = ChatImageAttachment.make({ + type: "image", + id: ChatAttachmentId.make( + "thread-claude-attachments-12345678-1234-1234-1234-123456789abc", + ), + name: "diagram.png", + mimeType: "image/png", + sizeBytes: 4, + }); + const document = ChatFileAttachment.make({ + type: "file", + id: ChatAttachmentId.make( + "thread-claude-attachments-abcdefab-1234-1234-1234-123456789abc", + ), + name: "requirements.pdf", + mimeType: "application/pdf", + sizeBytes: 4, + }); + yield* fileSystem.writeFile( + path.join(attachmentsDir, attachmentRelativePath(attachment)!), + Uint8Array.from([1, 2, 3, 4]), + ); + const pdfBytes = Buffer.from( + "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Count 1 /Kids [3 0 R] >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R >> endobj\n%%EOF", + ); + yield* fileSystem.writeFile( + path.join(attachmentsDir, attachmentRelativePath(document)!), + pdfBytes, + ); + const attemptId = RunAttemptId.make("attempt-claude-attachments"); + const now = yield* DateTime.now; + + const turnInput = { + ...makeClaudeTestTurnInput({ threadId, + providerThread, + now, + attemptId, modelSelection, - runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, - }); - const attachment = ChatImageAttachment.make({ - type: "image", - id: ChatAttachmentId.make( - "thread-claude-attachments-12345678-1234-1234-1234-123456789abc", - ), - name: "diagram.png", - mimeType: "image/png", - sizeBytes: 4, - }); - const document = ChatFileAttachment.make({ - type: "file", - id: ChatAttachmentId.make( - "thread-claude-attachments-abcdefab-1234-1234-1234-123456789abc", - ), - name: "requirements.pdf", - mimeType: "application/pdf", - sizeBytes: 4, - }); - yield* fileSystem.writeFile( - path.join(attachmentsDir, attachmentRelativePath(attachment)!), - Uint8Array.from([1, 2, 3, 4]), - ); - const pdfBytes = Buffer.from( - "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Count 1 /Kids [3 0 R] >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R >> endobj\n%%EOF", + text: prompt, + attachments: resumed ? [attachment] : [attachment, document], + }), + nativeThreadHasTurns: resumed, + }; + if (budget === "cancelled-send" || budget === "cancelled-steer") { + const documentPath = path.join(attachmentsDir, attachmentRelativePath(document)!); + const pages = Buffer.from( + `%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Count 60 >> endobj\n${Array.from({ length: 60 }, (_, i) => `${i + 3} 0 obj << /Type /Page /Parent 2 0 R >> endobj`).join("\n")}\n`, ); yield* fileSystem.writeFile( - path.join(attachmentsDir, attachmentRelativePath(document)!), - pdfBytes, + documentPath, + Buffer.concat([ + pages, + Buffer.alloc(19 * 1024 * 1024 - pages.length - 5, 32), + Buffer.from("%%EOF"), + ]), ); - const attemptId = RunAttemptId.make("attempt-claude-attachments"); - const now = yield* DateTime.now; - - const turnInput = { - ...makeClaudeTestTurnInput({ - threadId, - providerThread, - now, - attemptId, - modelSelection, - text: prompt, - attachments: resumed ? [attachment] : [attachment, document], - }), - nativeThreadHasTurns: resumed, - }; - if (budget === "cancelled-send" || budget === "cancelled-steer") { + const message = { ...turnInput.message, attachments: [document] }; + if (budget === "cancelled-steer") { + yield* runtime.startTurn({ ...turnInput, message: { ...message, attachments: [] } }); + } + const deliver = + budget === "cancelled-send" + ? runtime.startTurn({ ...turnInput, message }) + : runtime.steerTurn({ + threadId, + runId: turnInput.runId, + providerThread, + providerTurnId: idAllocator.derive.providerTurn({ + driver: ClaudeAdapterV2.CLAUDE_PROVIDER, + nativeTurnId: `turn:${attemptId}`, + }), + message, + }); + cancelAfterQueue = true; + const delivering = yield* deliver.pipe(Effect.forkChild); + yield* Deferred.await(queued); + yield* Fiber.interrupt(delivering); + const delivered = offeredMessages.at(-1)!.message.content; + assert.include(JSON.stringify(delivered), '"type":"document"'); + cancelAfterQueue = false; + if (budget === "cancelled-send") { + yield* runtime.interruptTurn({ + providerThread, + providerTurnId: idAllocator.derive.providerTurn({ + driver: ClaudeAdapterV2.CLAUDE_PROVIDER, + nativeTurnId: `turn:${attemptId}`, + }), + }); + } + yield* deliver; + const retried = offeredMessages.at(-1)!.message.content; + assert.notInclude(JSON.stringify(retried), '"type":"document"'); + assert.include(JSON.stringify(retried), JSON.stringify(documentPath).slice(1, -1)); + assert.isBelow(Buffer.byteLength(JSON.stringify(offeredMessages)), 32_000_000); + return; + } + if (budget !== "none") { + const providerTurnId = idAllocator.derive.providerTurn({ + driver: ClaudeAdapterV2.CLAUDE_PROVIDER, + nativeTurnId: `turn:${attemptId}`, + }); + const imagePath = path.join(attachmentsDir, attachmentRelativePath(attachment)!); + // The on-disk length deliberately disagrees with sizeBytes. + yield* fileSystem.writeFile(imagePath, Buffer.alloc(4 * 1024 * 1024)); + if (["later-images", "partial-images"].includes(budget)) { const documentPath = path.join(attachmentsDir, attachmentRelativePath(document)!); - const pages = Buffer.from( - `%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Count 60 >> endobj\n${Array.from({ length: 60 }, (_, i) => `${i + 3} 0 obj << /Type /Page /Parent 2 0 R >> endobj`).join("\n")}\n`, - ); yield* fileSystem.writeFile( documentPath, Buffer.concat([ - pages, - Buffer.alloc(19 * 1024 * 1024 - pages.length - 5, 32), + pdfBytes.subarray(0, -5), + Buffer.alloc( + (budget === "later-images" ? 19 : 16) * 1024 * 1024 - pdfBytes.length, + 32, + ), Buffer.from("%%EOF"), ]), ); - const message = { ...turnInput.message, attachments: [document] }; - if (budget === "cancelled-steer") { - yield* runtime.startTurn({ ...turnInput, message: { ...message, attachments: [] } }); - } - const deliver = - budget === "cancelled-send" - ? runtime.startTurn({ ...turnInput, message }) - : runtime.steerTurn({ - threadId, - runId: turnInput.runId, - providerThread, - providerTurnId: idAllocator.derive.providerTurn({ - driver: ClaudeAdapterV2.CLAUDE_PROVIDER, - nativeTurnId: `turn:${attemptId}`, - }), - message, - }); - cancelAfterQueue = true; - const delivering = yield* deliver.pipe(Effect.forkChild); - yield* Deferred.await(queued); - yield* Fiber.interrupt(delivering); - const delivered = offeredMessages.at(-1)!.message.content; - assert.include(JSON.stringify(delivered), '"type":"document"'); - cancelAfterQueue = false; - if (budget === "cancelled-send") { - yield* runtime.interruptTurn({ - providerThread, - providerTurnId: idAllocator.derive.providerTurn({ - driver: ClaudeAdapterV2.CLAUDE_PROVIDER, - nativeTurnId: `turn:${attemptId}`, - }), - }); - } - yield* deliver; - const retried = offeredMessages.at(-1)!.message.content; - assert.notInclude(JSON.stringify(retried), '"type":"document"'); - assert.include(JSON.stringify(retried), JSON.stringify(documentPath).slice(1, -1)); - assert.isBelow(Buffer.byteLength(JSON.stringify(offeredMessages)), 32_000_000); - return; - } - if (budget !== "none") { - const providerTurnId = idAllocator.derive.providerTurn({ - driver: ClaudeAdapterV2.CLAUDE_PROVIDER, - nativeTurnId: `turn:${attemptId}`, + yield* runtime.startTurn({ + ...turnInput, + message: { ...turnInput.message, attachments: [document] }, }); - const imagePath = path.join(attachmentsDir, attachmentRelativePath(attachment)!); - // The on-disk length deliberately disagrees with sizeBytes. - yield* fileSystem.writeFile(imagePath, Buffer.alloc(4 * 1024 * 1024)); - if (["later-images", "partial-images"].includes(budget)) { - const documentPath = path.join(attachmentsDir, attachmentRelativePath(document)!); - yield* fileSystem.writeFile( - documentPath, - Buffer.concat([ - pdfBytes.subarray(0, -5), - Buffer.alloc( - (budget === "later-images" ? 19 : 16) * 1024 * 1024 - pdfBytes.length, - 32, - ), - Buffer.from("%%EOF"), - ]), - ); - yield* runtime.startTurn({ - ...turnInput, - message: { ...turnInput.message, attachments: [document] }, - }); - assert.include( - JSON.stringify(offeredMessages[0]!.message.content), - '"type":"document"', - ); - const message = { ...turnInput.message, attachments: [attachment, attachment] }; - yield* runtime.interruptTurn({ providerThread, providerTurnId }); - const laterAttemptId = RunAttemptId.make("attempt-claude-attachments-later"); - yield* runtime.startTurn({ - ...turnInput, - attemptId: laterAttemptId, - providerTurnOrdinal: 2, - nativeThreadHasTurns: true, - message, - }); - const sent = offeredMessages.at(-1)!.message.content; - assert.isArray(sent); - assert.lengthOf( - typeof sent === "string" ? [] : sent.filter((block) => block.type === "image"), - budget === "partial-images" ? 1 : 0, - ); - assert.include(JSON.stringify(sent), JSON.stringify(imagePath).slice(1, -1)); - yield* runtime.steerTurn({ - threadId, - runId: RunId.make("run-claude-attachments"), - providerThread, - providerTurnId: idAllocator.derive.providerTurn({ - driver: ClaudeAdapterV2.CLAUDE_PROVIDER, - nativeTurnId: `turn:${laterAttemptId}`, - }), - message, - }); - const steered = offeredMessages.at(-1)!.message.content; - assert.isArray(steered); - assert.lengthOf( - typeof steered === "string" - ? [] - : steered.filter((block) => block.type === "image"), - 0, - ); - assert.include(JSON.stringify(steered), JSON.stringify(imagePath).slice(1, -1)); - assert.isBelow( - Buffer.byteLength( - JSON.stringify(offeredMessages.map((message) => message.message)), - ), - 32_000_000, - ); - return; - } - if (budget === "retained-documents") { - yield* fileSystem.writeFile( - path.join(attachmentsDir, attachmentRelativePath(document)!), - Buffer.concat([ - pdfBytes.subarray(0, pdfBytes.length - 5), - Buffer.alloc(10 * 1024 * 1024, 32), - Buffer.from("%%EOF"), - ]), - ); - } + assert.include( + JSON.stringify(offeredMessages[0]!.message.content), + '"type":"document"', + ); + const message = { ...turnInput.message, attachments: [attachment, attachment] }; + yield* runtime.interruptTurn({ providerThread, providerTurnId }); + const laterAttemptId = RunAttemptId.make("attempt-claude-attachments-later"); yield* runtime.startTurn({ ...turnInput, - message: { - ...turnInput.message, - text: budget === "retained-text" ? "x".repeat(10 * 1024 * 1024) : prompt, - attachments: - budget === "retained-text" - ? [] - : budget === "retained-documents" - ? [document] - : [attachment, attachment], - }, + attemptId: laterAttemptId, + providerTurnOrdinal: 2, + nativeThreadHasTurns: true, + message, }); - const largePdf = Buffer.concat([ - pdfBytes.subarray(0, pdfBytes.length - 5), - Buffer.alloc( - (budget === "retained-documents" ? 20 : 19) * 1024 * 1024 - pdfBytes.length, - 32, - ), - Buffer.from("%%EOF"), - ]); - const documentPath = path.join(attachmentsDir, attachmentRelativePath(document)!); - yield* fileSystem.writeFile(documentPath, largePdf); + const sent = offeredMessages.at(-1)!.message.content; + assert.isArray(sent); + assert.lengthOf( + typeof sent === "string" ? [] : sent.filter((block) => block.type === "image"), + budget === "partial-images" ? 1 : 0, + ); + assert.include(JSON.stringify(sent), JSON.stringify(imagePath).slice(1, -1)); yield* runtime.steerTurn({ threadId, runId: RunId.make("run-claude-attachments"), providerThread, - providerTurnId, - message: { - ...turnInput.message, - attachments: - budget === "same-message-images" - ? [document, attachment, attachment] - : [document], - }, + providerTurnId: idAllocator.derive.providerTurn({ + driver: ClaudeAdapterV2.CLAUDE_PROVIDER, + nativeTurnId: `turn:${laterAttemptId}`, + }), + message, }); - const sent = offeredMessages.at(-1)!.message.content; - assert.isArray(sent); + const steered = offeredMessages.at(-1)!.message.content; + assert.isArray(steered); assert.lengthOf( - typeof sent === "string" ? [] : sent.filter((block) => block.type === "document"), + typeof steered === "string" ? [] : steered.filter((block) => block.type === "image"), 0, - budget, ); - assert.include(JSON.stringify(sent), JSON.stringify(documentPath).slice(1, -1)); + assert.include(JSON.stringify(steered), JSON.stringify(imagePath).slice(1, -1)); + assert.isBelow( + Buffer.byteLength(JSON.stringify(offeredMessages.map((message) => message.message))), + 32_000_000, + ); return; } - if (!resumed) { - const undelivered = Buffer.from( - `%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Count 60 >> endobj\n${Array.from({ length: 60 }, (_, i) => `${i + 3} 0 obj << /Type /Page /Parent 2 0 R >> endobj`).join("\n")}\n%%EOF`, - ); - yield* fileSystem.writeFile( - path.join(attachmentsDir, attachmentRelativePath(document)!), - undelivered, - ); - failOpen = true; - assert.isTrue(Exit.isFailure(yield* runtime.startTurn(turnInput).pipe(Effect.exit))); - assert.lengthOf(offeredMessages, 0); + if (budget === "retained-documents") { yield* fileSystem.writeFile( path.join(attachmentsDir, attachmentRelativePath(document)!), - pdfBytes, + Buffer.concat([ + pdfBytes.subarray(0, pdfBytes.length - 5), + Buffer.alloc(10 * 1024 * 1024, 32), + Buffer.from("%%EOF"), + ]), ); } - yield* runtime.startTurn(turnInput); - - const expectedImageBlock = { - type: "image", - source: { - type: "base64", - media_type: "image/png", - data: "AQIDBA==", - }, - } as const; - const expectedDocumentBlock = { - type: "document", - source: { - type: "base64", - media_type: "application/pdf", - data: Buffer.from(pdfBytes).toString("base64"), + yield* runtime.startTurn({ + ...turnInput, + message: { + ...turnInput.message, + text: budget === "retained-text" ? "x".repeat(10 * 1024 * 1024) : prompt, + attachments: + budget === "retained-text" + ? [] + : budget === "retained-documents" + ? [document] + : [attachment, attachment], }, - } as const; - const expectedAttachmentPath = path.join( - attachmentsDir, - attachmentRelativePath(attachment)!, - ); - const expectedDocumentPath = path.join(attachmentsDir, attachmentRelativePath(document)!); - const attachmentNotes = `\n\n[Attached image "diagram.png" is saved at: ${expectedAttachmentPath}]${resumed ? "" : `\n\n[Attached file "requirements.pdf" is saved at: ${expectedDocumentPath}]`}`; - const requestBlock = { type: "text" as const, text: expectedText + attachmentNotes }; - const expectedContent = command - ? [ - requestBlock, - expectedImageBlock, - ...(!resumed ? [expectedDocumentBlock] : []), - { type: "text" as const, text: command + attachmentNotes }, - ] - : [expectedImageBlock, ...(!resumed ? [expectedDocumentBlock] : []), requestBlock]; - assert.deepEqual(offeredMessages[0]?.message.content, expectedContent); - - const providerTurnId = idAllocator.derive.providerTurn({ - driver: ClaudeAdapterV2.CLAUDE_PROVIDER, - nativeTurnId: `turn:${attemptId}`, }); + const largePdf = Buffer.concat([ + pdfBytes.subarray(0, pdfBytes.length - 5), + Buffer.alloc( + (budget === "retained-documents" ? 20 : 19) * 1024 * 1024 - pdfBytes.length, + 32, + ), + Buffer.from("%%EOF"), + ]); + const documentPath = path.join(attachmentsDir, attachmentRelativePath(document)!); + yield* fileSystem.writeFile(documentPath, largePdf); yield* runtime.steerTurn({ threadId, runId: RunId.make("run-claude-attachments"), providerThread, providerTurnId, message: { - createdBy: "user", - creationSource: "web", - messageId: MessageId.make("message-claude-attachments-steer"), - text: prompt, - attachments: resumed ? [attachment] : [attachment, document], + ...turnInput.message, + attachments: + budget === "same-message-images" ? [document, attachment, attachment] : [document], }, }); + const sent = offeredMessages.at(-1)!.message.content; + assert.isArray(sent); + assert.lengthOf( + typeof sent === "string" ? [] : sent.filter((block) => block.type === "document"), + 0, + budget, + ); + assert.include(JSON.stringify(sent), JSON.stringify(documentPath).slice(1, -1)); + return; + } + if (!resumed) { + const undelivered = Buffer.from( + `%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Count 60 >> endobj\n${Array.from({ length: 60 }, (_, i) => `${i + 3} 0 obj << /Type /Page /Parent 2 0 R >> endobj`).join("\n")}\n%%EOF`, + ); + yield* fileSystem.writeFile( + path.join(attachmentsDir, attachmentRelativePath(document)!), + undelivered, + ); + failOpen = true; + assert.isTrue(Exit.isFailure(yield* runtime.startTurn(turnInput).pipe(Effect.exit))); + assert.lengthOf(offeredMessages, 0); + yield* fileSystem.writeFile( + path.join(attachmentsDir, attachmentRelativePath(document)!), + pdfBytes, + ); + } + yield* runtime.startTurn(turnInput); - assert.equal(offeredMessages[1]?.priority, "now"); - assert.deepEqual(offeredMessages[1]?.message.content, expectedContent); - const documentPath = path.join(attachmentsDir, attachmentRelativePath(document)!); - for (const scenario of [ - "pages", - "bytes", - "invalid", - "invalid-page-tree", - "object-stream", - "encrypted", - "cyclic-tree", - "unterminated-string", - "missing-eof", - "escaped-name", - "incremental", - "indirect-count", - "indirect-type", - "indirect-kids", - "indirect-101-pages", - "aggregate-pages", - "retained-pages", - "delivery-failure", - "pasted-text", - "text-only", - ]) { - let attachments: ReadonlyArray = [document]; - if ( - ["pages", "aggregate-pages", "retained-pages", "delivery-failure"].includes(scenario) - ) { - const pages = scenario === "pages" ? 101 : scenario === "delivery-failure" ? 30 : 60; - const large = Buffer.from( - `%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Count ${pages} >> endobj\n${Array.from({ length: pages }, (_, i) => `${i + 3} 0 obj << /Type /Page /Parent 2 0 R >> endobj`).join("\n")}\n%%EOF`, - ); - yield* fileSystem.writeFile(documentPath, large); - if (scenario === "aggregate-pages") { - const second = { - ...document, - id: ChatAttachmentId.make("second-pdf"), - name: "second.pdf", - }; - yield* fileSystem.copyFile( - documentPath, - path.join(attachmentsDir, attachmentRelativePath(second)!), - ); - attachments = [document, second]; - } - } else if (scenario.startsWith("indirect-")) { - const raw = - scenario === "indirect-count" || scenario === "indirect-101-pages" - ? `%PDF-1.7\n1 0 obj ${scenario === "indirect-count" ? 1 : 101} endobj\n2 0 obj << /Type /Catalog /Pages 3 0 R >> endobj\n3 0 obj << /Type /Pages /Count 1 0 R /Kids [${Array.from({ length: scenario === "indirect-count" ? 1 : 101 }, (_, i) => `${i + 4} 0 R`).join(" ")}] >> endobj\n4 0 obj << /Type /Page /Parent 3 0 R >> endobj\n${scenario === "indirect-count" ? "" : Array.from({ length: 100 }, (_, i) => `${i + 5} 0 obj << /Type 105 0 R /Parent 3 0 R >> endobj`).join("\n")}\n105 0 obj /Page endobj\n%%EOF` - : scenario === "indirect-type" - ? pdfBytes - .toString() - .replace( - "%%EOF", - "4 0 obj << /Type 5 0 R >> endobj\n5 0 obj /Annot endobj\n%%EOF", - ) - : pdfBytes - .toString() - .replace("/Kids [3 0 R]", "/Kids 4 0 R") - .replace("%%EOF", "4 0 obj [3 0 R] endobj\n%%EOF"); - yield* fileSystem.writeFileString(documentPath, raw); - } else if (scenario === "bytes") { - yield* fileSystem.truncate(documentPath, 20 * 1024 * 1024 + 1); - } else if (scenario === "invalid") { - yield* fileSystem.writeFileString(documentPath, "This is not a PDF"); - } else if (scenario === "invalid-page-tree") { - const malformedBytes = Buffer.from( - "%PDF-1.7\n1 0 obj\n<< /Type /Catalog /Pages 42 >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF", - ); - yield* fileSystem.writeFile(documentPath, malformedBytes); - } else if (["object-stream", "encrypted", "cyclic-tree"].includes(scenario)) { - const suffix = - scenario === "encrypted" - ? "/Encrypt 9 0 R" - : scenario === "cyclic-tree" - ? "/Count 0 /Kids [2 0 R]" - : "/Type /ObjStm /Filter /FlateDecode /Length 1000000000"; - yield* fileSystem.writeFile( - documentPath, - Buffer.concat([ - pdfBytes.subarray(0, pdfBytes.length - 5), - Buffer.from(`\n4 0 obj << ${suffix} >> stream\nx\nendstream endobj\n%%EOF`), - ]), - ); - } else if ( - ["unterminated-string", "missing-eof", "escaped-name", "incremental"].includes( - scenario, - ) - ) { - const raw = pdfBytes.toString(); - yield* fileSystem.writeFileString( + const expectedImageBlock = { + type: "image", + source: { + type: "base64", + media_type: "image/png", + data: "AQIDBA==", + }, + } as const; + const expectedDocumentBlock = { + type: "document", + source: { + type: "base64", + media_type: "application/pdf", + data: Buffer.from(pdfBytes).toString("base64"), + }, + } as const; + const expectedAttachmentPath = path.join( + attachmentsDir, + attachmentRelativePath(attachment)!, + ); + const expectedDocumentPath = path.join(attachmentsDir, attachmentRelativePath(document)!); + const attachmentNotes = `\n\n[Attached image "diagram.png" is saved at: ${expectedAttachmentPath}]${resumed ? "" : `\n\n[Attached file "requirements.pdf" is saved at: ${expectedDocumentPath}]`}`; + const requestBlock = { type: "text" as const, text: expectedText + attachmentNotes }; + const expectedContent = command + ? [ + requestBlock, + expectedImageBlock, + ...(!resumed ? [expectedDocumentBlock] : []), + { type: "text" as const, text: command + attachmentNotes }, + ] + : [expectedImageBlock, ...(!resumed ? [expectedDocumentBlock] : []), requestBlock]; + assert.deepEqual(offeredMessages[0]?.message.content, expectedContent); + + const providerTurnId = idAllocator.derive.providerTurn({ + driver: ClaudeAdapterV2.CLAUDE_PROVIDER, + nativeTurnId: `turn:${attemptId}`, + }); + yield* runtime.steerTurn({ + threadId, + runId: RunId.make("run-claude-attachments"), + providerThread, + providerTurnId, + message: { + createdBy: "user", + creationSource: "web", + messageId: MessageId.make("message-claude-attachments-steer"), + text: prompt, + attachments: resumed ? [attachment] : [attachment, document], + }, + }); + + assert.equal(offeredMessages[1]?.priority, "now"); + assert.deepEqual(offeredMessages[1]?.message.content, expectedContent); + const documentPath = path.join(attachmentsDir, attachmentRelativePath(document)!); + for (const scenario of [ + "pages", + "bytes", + "invalid", + "invalid-page-tree", + "object-stream", + "encrypted", + "cyclic-tree", + "unterminated-string", + "missing-eof", + "escaped-name", + "incremental", + "indirect-count", + "indirect-type", + "indirect-kids", + "indirect-101-pages", + "aggregate-pages", + "retained-pages", + "delivery-failure", + "pasted-text", + "text-only", + ]) { + let attachments: ReadonlyArray = [document]; + if ( + ["pages", "aggregate-pages", "retained-pages", "delivery-failure"].includes(scenario) + ) { + const pages = scenario === "pages" ? 101 : scenario === "delivery-failure" ? 30 : 60; + const large = Buffer.from( + `%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Count ${pages} >> endobj\n${Array.from({ length: pages }, (_, i) => `${i + 3} 0 obj << /Type /Page /Parent 2 0 R >> endobj`).join("\n")}\n%%EOF`, + ); + yield* fileSystem.writeFile(documentPath, large); + if (scenario === "aggregate-pages") { + const second = { + ...document, + id: ChatAttachmentId.make("second-pdf"), + name: "second.pdf", + }; + yield* fileSystem.copyFile( documentPath, - scenario === "missing-eof" - ? raw.slice(0, -5) - : scenario === "escaped-name" - ? raw.replace("/Type /Page ", "/Type /Pa#67e ") - : raw.replace( - "%%EOF", - scenario === "incremental" ? "/Prev 42\n%%EOF" : "(unfinished\n%%EOF", - ), + path.join(attachmentsDir, attachmentRelativePath(second)!), ); - } else if (scenario === "pasted-text") { - yield* fileSystem.writeFile(documentPath, pdfBytes); - attachments = [{ ...document, source: { _tag: "pasted-text" } }]; - } else { - attachments = []; - yield* fileSystem.remove(documentPath); - } - const steering = { - threadId, - runId: RunId.make("run-claude-attachments"), - providerThread, - providerTurnId, - message: { - createdBy: "user", - creationSource: "web", - messageId: MessageId.make(`message-${scenario}`), - text: "Continue.", - attachments, - }, - } satisfies ProviderAdapter.ProviderAdapterV2SteerInput; - if (scenario === "delivery-failure") { - failOffer = true; - assert.isTrue(Exit.isFailure(yield* runtime.steerTurn(steering).pipe(Effect.exit))); + attachments = [document, second]; } - yield* runtime.steerTurn(steering); - - const sent = offeredMessages.at(-1)!.message.content; - const documents = - typeof sent === "string" ? [] : sent.filter((block) => block.type === "document"); - assert.lengthOf( - documents, - ["aggregate-pages", "delivery-failure"].includes(scenario) ? 1 : 0, - scenario, + } else if (scenario.startsWith("indirect-")) { + const raw = + scenario === "indirect-count" || scenario === "indirect-101-pages" + ? `%PDF-1.7\n1 0 obj ${scenario === "indirect-count" ? 1 : 101} endobj\n2 0 obj << /Type /Catalog /Pages 3 0 R >> endobj\n3 0 obj << /Type /Pages /Count 1 0 R /Kids [${Array.from({ length: scenario === "indirect-count" ? 1 : 101 }, (_, i) => `${i + 4} 0 R`).join(" ")}] >> endobj\n4 0 obj << /Type /Page /Parent 3 0 R >> endobj\n${scenario === "indirect-count" ? "" : Array.from({ length: 100 }, (_, i) => `${i + 5} 0 obj << /Type 105 0 R /Parent 3 0 R >> endobj`).join("\n")}\n105 0 obj /Page endobj\n%%EOF` + : scenario === "indirect-type" + ? pdfBytes + .toString() + .replace( + "%%EOF", + "4 0 obj << /Type 5 0 R >> endobj\n5 0 obj /Annot endobj\n%%EOF", + ) + : pdfBytes + .toString() + .replace("/Kids [3 0 R]", "/Kids 4 0 R") + .replace("%%EOF", "4 0 obj [3 0 R] endobj\n%%EOF"); + yield* fileSystem.writeFileString(documentPath, raw); + } else if (scenario === "bytes") { + yield* fileSystem.truncate(documentPath, 20 * 1024 * 1024 + 1); + } else if (scenario === "invalid") { + yield* fileSystem.writeFileString(documentPath, "This is not a PDF"); + } else if (scenario === "invalid-page-tree") { + const malformedBytes = Buffer.from( + "%PDF-1.7\n1 0 obj\n<< /Type /Catalog /Pages 42 >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF", + ); + yield* fileSystem.writeFile(documentPath, malformedBytes); + } else if (["object-stream", "encrypted", "cyclic-tree"].includes(scenario)) { + const suffix = + scenario === "encrypted" + ? "/Encrypt 9 0 R" + : scenario === "cyclic-tree" + ? "/Count 0 /Kids [2 0 R]" + : "/Type /ObjStm /Filter /FlateDecode /Length 1000000000"; + yield* fileSystem.writeFile( + documentPath, + Buffer.concat([ + pdfBytes.subarray(0, pdfBytes.length - 5), + Buffer.from(`\n4 0 obj << ${suffix} >> stream\nx\nendstream endobj\n%%EOF`), + ]), + ); + } else if ( + ["unterminated-string", "missing-eof", "escaped-name", "incremental"].includes(scenario) + ) { + const raw = pdfBytes.toString(); + yield* fileSystem.writeFileString( + documentPath, + scenario === "missing-eof" + ? raw.slice(0, -5) + : scenario === "escaped-name" + ? raw.replace("/Type /Page ", "/Type /Pa#67e ") + : raw.replace( + "%%EOF", + scenario === "incremental" ? "/Prev 42\n%%EOF" : "(unfinished\n%%EOF", + ), ); - if (scenario === "text-only") - assert.equal(sent, ultrathink ? "Ultrathink:\nContinue." : "Continue."); - else assert.include(JSON.stringify(sent), JSON.stringify(documentPath).slice(1, -1)); + } else if (scenario === "pasted-text") { + yield* fileSystem.writeFile(documentPath, pdfBytes); + attachments = [{ ...document, source: { _tag: "pasted-text" } }]; + } else { + attachments = []; + yield* fileSystem.remove(documentPath); } - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + const steering = { + threadId, + runId: RunId.make("run-claude-attachments"), + providerThread, + providerTurnId, + message: { + createdBy: "user", + creationSource: "web", + messageId: MessageId.make(`message-${scenario}`), + text: "Continue.", + attachments, + }, + } satisfies ProviderAdapter.ProviderAdapterV2SteerInput; + if (scenario === "delivery-failure") { + failOffer = true; + assert.isTrue(Exit.isFailure(yield* runtime.steerTurn(steering).pipe(Effect.exit))); + } + yield* runtime.steerTurn(steering); + + const sent = offeredMessages.at(-1)!.message.content; + const documents = + typeof sent === "string" ? [] : sent.filter((block) => block.type === "document"); + assert.lengthOf( + documents, + ["aggregate-pages", "delivery-failure"].includes(scenario) ? 1 : 0, + scenario, + ); + if (scenario === "text-only") + assert.equal(sent, ultrathink ? "Ultrathink:\nContinue." : "Continue."); + else assert.include(JSON.stringify(sent), JSON.stringify(documentPath).slice(1, -1)); + } + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), @@ -2647,238 +2614,236 @@ describe("ClaudeAdapterV2 attachments", () => { describe("ClaudeAdapterV2 native fork", () => { it.effect("forks a session from its original directory after the workspace root changes", () => - Effect.scoped( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-claude-v2-fork-attachments-", - }); - const configDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-claude-root-change-", - }); - const sourceSessionId = "11111111-1111-4111-8111-111111111111"; - const sourceCursor = "aaaaaaaa-0000-4000-8000-000000000002"; - const projectDir = NodePath.join(configDir, "projects", "-old-workspace"); - yield* fileSystem.makeDirectory(projectDir, { recursive: true }); - const entry = (uuid: string, parentUuid: string | null, message: unknown) => ({ - type: parentUuid === null ? "user" : "assistant", - uuid, - parentUuid, - sessionId: sourceSessionId, - cwd: "/old-workspace", - isSidechain: false, - timestamp: "2026-10-01T00:00:00.000Z", - message, - }); - yield* fileSystem.writeFileString( - NodePath.join(projectDir, `${sourceSessionId}.jsonl`), - [ - entry("aaaaaaaa-0000-4000-8000-000000000001", null, { role: "user", content: "hello" }), - entry(sourceCursor, "aaaaaaaa-0000-4000-8000-000000000001", { - role: "assistant", - content: [{ type: "text", text: "inherited answer" }], - }), - ] - .map((line) => JSON.stringify(line)) - .join("\n"), - ); - const realRunner = yield* ClaudeAdapterV2.ClaudeAgentSdkQueryRunner.pipe( - Effect.provide( - ClaudeAdapterV2.layerQueryRunner.pipe( - Layer.provide(PortScanner.layerTest), - Layer.provide( - Layer.succeed(ProviderEventLoggers.ProviderEventLoggers, { - native: undefined, - canonical: undefined, - }), - ), + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-claude-v2-fork-attachments-", + }); + const configDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-claude-root-change-", + }); + const sourceSessionId = "11111111-1111-4111-8111-111111111111"; + const sourceCursor = "aaaaaaaa-0000-4000-8000-000000000002"; + const projectDir = NodePath.join(configDir, "projects", "-old-workspace"); + yield* fileSystem.makeDirectory(projectDir, { recursive: true }); + const entry = (uuid: string, parentUuid: string | null, message: unknown) => ({ + type: parentUuid === null ? "user" : "assistant", + uuid, + parentUuid, + sessionId: sourceSessionId, + cwd: "/old-workspace", + isSidechain: false, + timestamp: "2026-10-01T00:00:00.000Z", + message, + }); + yield* fileSystem.writeFileString( + NodePath.join(projectDir, `${sourceSessionId}.jsonl`), + [ + entry("aaaaaaaa-0000-4000-8000-000000000001", null, { role: "user", content: "hello" }), + entry(sourceCursor, "aaaaaaaa-0000-4000-8000-000000000001", { + role: "assistant", + content: [{ type: "text", text: "inherited answer" }], + }), + ] + .map((line) => JSON.stringify(line)) + .join("\n"), + ); + const realRunner = yield* ClaudeAdapterV2.ClaudeAgentSdkQueryRunner.pipe( + Effect.provide( + ClaudeAdapterV2.layerQueryRunner.pipe( + Layer.provide(PortScanner.layerTest), + Layer.provide( + Layer.succeed(ProviderEventLoggers.ProviderEventLoggers, { + native: undefined, + canonical: undefined, + }), ), ), - ); - const openedQueries: Array = []; - const forkCalls: Array = []; - const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ - instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, - settings: DEFAULT_CLAUDE_SETTINGS, - environment: { CLAUDE_CONFIG_DIR: configDir }, - attachmentsDir, - fileSystem, - path: yield* Path.Path, - random: yield* SecureRandom.SecureRandom, - idAllocator, - queryRunner: { - allocateSessionId: Effect.succeed(sourceSessionId), - open: (input) => - Effect.sync(() => { - openedQueries.push(input); - return { - messages: Stream.never, - offer: () => Effect.void, - setModel: () => Effect.void, - applyFlagSettings: () => Effect.void, - setPermissionMode: () => Effect.void, - backgroundTasks: () => Effect.succeed(false), - mcpServerStatus: Effect.succeed([]), - readMcpResource: () => Effect.die("unexpected MCP resource read"), - callMcpTool: () => Effect.die("unexpected MCP tool call"), - listPermissionRules: Effect.die("unexpected permission rules read"), - stopTask: () => Effect.void, - interrupt: Effect.void, - close: Effect.void, - }; - }), - forkSession: (input) => - Effect.gen(function* () { - forkCalls.push(input); - return yield* realRunner.forkSession(input); - }), - subagentLaunchToolUseId: () => Effect.succeed(null), - subagentModel: () => Effect.succeed(null), - assertComplete: Effect.void, - }, - }); - const providerSessionId = ProviderSessionId.make("provider-session-claude-fork"); - const sourceThreadId = ThreadId.make("thread-claude-fork-source"); - const targetThreadId = ThreadId.make("thread-claude-fork-target"); - const runtime = yield* adapter.openSession({ - threadId: sourceThreadId, - providerSessionId, - modelSelection: { - instanceId: ProviderInstanceId.make(ClaudeAdapterV2.CLAUDE_PROVIDER), - model: "claude-sonnet-4-6", - }, - runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: "/workspace", - }), - }); - const sourceProviderThread = yield* runtime.ensureThread({ - threadId: sourceThreadId, - modelSelection: { - instanceId: ProviderInstanceId.make(ClaudeAdapterV2.CLAUDE_PROVIDER), - model: "claude-sonnet-4-6", - }, - runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: "/workspace", - }), - }); - const now = yield* DateTime.now; - const providerTurnId = ProviderTurnId.make("provider-turn-claude-source"); - const forkedProviderThread = yield* runtime.forkThread({ - sourceProviderThread, - sourceProviderTurns: [ - { - id: providerTurnId, - providerThreadId: sourceProviderThread.id, - nodeId: NodeId.make("node-claude-source"), - runAttemptId: RunAttemptId.make("run-attempt-claude-source"), - nativeTurnRef: { - driver: ClaudeAdapterV2.CLAUDE_PROVIDER, - nativeId: sourceCursor, - strength: "weak", - }, - ordinal: 1, - status: "completed", - startedAt: now, - completedAt: now, - }, - ], - providerTurnId, - targetThreadId, - }); - - assert.deepEqual(forkCalls, [ + ), + ); + const openedQueries: Array = []; + const forkCalls: Array = []; + const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ + instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, + settings: DEFAULT_CLAUDE_SETTINGS, + environment: { CLAUDE_CONFIG_DIR: configDir }, + attachmentsDir, + fileSystem, + path: yield* Path.Path, + random: yield* SecureRandom.SecureRandom, + idAllocator, + queryRunner: { + allocateSessionId: Effect.succeed(sourceSessionId), + open: (input) => + Effect.sync(() => { + openedQueries.push(input); + return { + messages: Stream.never, + offer: () => Effect.void, + setModel: () => Effect.void, + applyFlagSettings: () => Effect.void, + setPermissionMode: () => Effect.void, + backgroundTasks: () => Effect.succeed(false), + mcpServerStatus: Effect.succeed([]), + readMcpResource: () => Effect.die("unexpected MCP resource read"), + callMcpTool: () => Effect.die("unexpected MCP tool call"), + listPermissionRules: Effect.die("unexpected permission rules read"), + stopTask: () => Effect.void, + interrupt: Effect.void, + close: Effect.void, + }; + }), + forkSession: (input) => + Effect.gen(function* () { + forkCalls.push(input); + return yield* realRunner.forkSession(input); + }), + subagentLaunchToolUseId: () => Effect.succeed(null), + subagentModel: () => Effect.succeed(null), + assertComplete: Effect.void, + }, + }); + const providerSessionId = ProviderSessionId.make("provider-session-claude-fork"); + const sourceThreadId = ThreadId.make("thread-claude-fork-source"); + const targetThreadId = ThreadId.make("thread-claude-fork-target"); + const runtime = yield* adapter.openSession({ + threadId: sourceThreadId, + providerSessionId, + modelSelection: { + instanceId: ProviderInstanceId.make(ClaudeAdapterV2.CLAUDE_PROVIDER), + model: "claude-sonnet-4-6", + }, + runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: "/workspace", + }), + }); + const sourceProviderThread = yield* runtime.ensureThread({ + threadId: sourceThreadId, + modelSelection: { + instanceId: ProviderInstanceId.make(ClaudeAdapterV2.CLAUDE_PROVIDER), + model: "claude-sonnet-4-6", + }, + runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: "/workspace", + }), + }); + const now = yield* DateTime.now; + const providerTurnId = ProviderTurnId.make("provider-turn-claude-source"); + const forkedProviderThread = yield* runtime.forkThread({ + sourceProviderThread, + sourceProviderTurns: [ { - sessionId: sourceSessionId, - options: { - upToMessageId: sourceCursor, + id: providerTurnId, + providerThreadId: sourceProviderThread.id, + nodeId: NodeId.make("node-claude-source"), + runAttemptId: RunAttemptId.make("run-attempt-claude-source"), + nativeTurnRef: { + driver: ClaudeAdapterV2.CLAUDE_PROVIDER, + nativeId: sourceCursor, + strength: "weak", }, - environment: { CLAUDE_CONFIG_DIR: configDir }, - threadId: targetThreadId, - providerSessionId, + ordinal: 1, + status: "completed", + startedAt: now, + completedAt: now, }, - ]); - const forkedSessionId = forkedProviderThread.nativeThreadRef?.nativeId; - assert.isString(forkedSessionId); - assert.notEqual(forkedSessionId, sourceSessionId); - const forkedTranscript = yield* fileSystem.readFileString( - NodePath.join(projectDir, `${forkedSessionId}.jsonl`), - ); - assert.include(forkedTranscript, "inherited answer"); - assert.equal(forkedProviderThread.forkedFrom?.providerThreadId, sourceProviderThread.id); - assert.equal(forkedProviderThread.forkedFrom?.providerTurnId, providerTurnId); - - yield* runtime.startTurn({ - appThread: { - createdBy: "user", - creationSource: "web", - id: targetThreadId, - projectId: ProjectId.make("project-claude-fork-target"), - title: "Claude fork target", - providerInstanceId: ProviderInstanceId.make(ClaudeAdapterV2.CLAUDE_PROVIDER), - modelSelection: { - instanceId: ProviderInstanceId.make(ClaudeAdapterV2.CLAUDE_PROVIDER), - model: "claude-sonnet-4-6", - }, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - activeProviderThreadId: forkedProviderThread.id, - lineage: { - parentThreadId: sourceThreadId, - relationshipToParent: "fork", - rootThreadId: sourceThreadId, - }, - forkedFrom: null, - createdAt: now, - updatedAt: now, - archivedAt: null, - settledOverride: null, - settledAt: null, - lastVisitedAt: null, - deletedAt: null, + ], + providerTurnId, + targetThreadId, + }); + + assert.deepEqual(forkCalls, [ + { + sessionId: sourceSessionId, + options: { + upToMessageId: sourceCursor, }, + environment: { CLAUDE_CONFIG_DIR: configDir }, threadId: targetThreadId, - runId: RunId.make("run-claude-fork-target"), - runOrdinal: 1, - providerTurnOrdinal: 1, - attemptId: RunAttemptId.make("run-attempt-claude-fork-target"), - rootNodeId: NodeId.make("node-claude-fork-target-root"), - providerThread: forkedProviderThread, - message: { - createdBy: "user", - creationSource: "web", - messageId: MessageId.make("message-claude-fork-target"), - text: "Respond with fork ok", - attachments: [], - }, + providerSessionId, + }, + ]); + const forkedSessionId = forkedProviderThread.nativeThreadRef?.nativeId; + assert.isString(forkedSessionId); + assert.notEqual(forkedSessionId, sourceSessionId); + const forkedTranscript = yield* fileSystem.readFileString( + NodePath.join(projectDir, `${forkedSessionId}.jsonl`), + ); + assert.include(forkedTranscript, "inherited answer"); + assert.equal(forkedProviderThread.forkedFrom?.providerThreadId, sourceProviderThread.id); + assert.equal(forkedProviderThread.forkedFrom?.providerTurnId, providerTurnId); + + yield* runtime.startTurn({ + appThread: { + createdBy: "user", + creationSource: "web", + id: targetThreadId, + projectId: ProjectId.make("project-claude-fork-target"), + title: "Claude fork target", + providerInstanceId: ProviderInstanceId.make(ClaudeAdapterV2.CLAUDE_PROVIDER), modelSelection: { instanceId: ProviderInstanceId.make(ClaudeAdapterV2.CLAUDE_PROVIDER), model: "claude-sonnet-4-6", }, - runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: "/workspace", - }), - }); + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: forkedProviderThread.id, + lineage: { + parentThreadId: sourceThreadId, + relationshipToParent: "fork", + rootThreadId: sourceThreadId, + }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }, + threadId: targetThreadId, + runId: RunId.make("run-claude-fork-target"), + runOrdinal: 1, + providerTurnOrdinal: 1, + attemptId: RunAttemptId.make("run-attempt-claude-fork-target"), + rootNodeId: NodeId.make("node-claude-fork-target-root"), + providerThread: forkedProviderThread, + message: { + createdBy: "user", + creationSource: "web", + messageId: MessageId.make("message-claude-fork-target"), + text: "Respond with fork ok", + attachments: [], + }, + modelSelection: { + instanceId: ProviderInstanceId.make(ClaudeAdapterV2.CLAUDE_PROVIDER), + model: "claude-sonnet-4-6", + }, + runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: "/workspace", + }), + }); - assert.equal(openedQueries[0]?.options.resume, forkedSessionId); - assert.equal(openedQueries[0]?.options.sessionId, undefined); - assert.equal(openedQueries[0]?.options.cwd, NodePath.resolve("/workspace")); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + assert.equal(openedQueries[0]?.options.resume, forkedSessionId); + assert.equal(openedQueries[0]?.options.sessionId, undefined); + assert.equal(openedQueries[0]?.options.cwd, NodePath.resolve("/workspace")); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), @@ -6511,89 +6476,87 @@ describe("ClaudeAdapterV2 background wake turns", () => { it.effect( "applies model, effort and Fast changes to a process running a background shell, but not thinking or a cleared effort", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - const normal = { - instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, - model: "claude-opus-5-5", - } satisfies ModelSelection; - const turn = (ordinal: number, modelSelection: ModelSelection) => - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make(`attempt-normal-background:${ordinal}`), - text: `Request ${ordinal}`, - attachments: [], - providerTurnOrdinal: ordinal, - modelSelection, - }); - const settle = Effect.gen(function* () { - yield* harness.offerAndWait(turnOneResult); - yield* Queue.take(harness.terminalReceipts); + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const normal = { + instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, + model: "claude-opus-5-5", + } satisfies ModelSelection; + const turn = (ordinal: number, modelSelection: ModelSelection) => + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make(`attempt-normal-background:${ordinal}`), + text: `Request ${ordinal}`, + attachments: [], + providerTurnOrdinal: ordinal, + modelSelection, }); - yield* harness.runtime.startTurn(turn(1, normal)); - const originalOptions = harness.getOpenedOptions(); - yield* harness.offerAndWait(wakeTaskStarted); - yield* settle; - assert.isTrue(yield* harness.hasPendingBackgroundWork); + const settle = Effect.gen(function* () { + yield* harness.offerAndWait(turnOneResult); + yield* Queue.take(harness.terminalReceipts); + }); + yield* harness.runtime.startTurn(turn(1, normal)); + const originalOptions = harness.getOpenedOptions(); + yield* harness.offerAndWait(wakeTaskStarted); + yield* settle; + assert.isTrue(yield* harness.hasPendingBackgroundWork); - yield* harness.runtime.startTurn( - turn(2, { ...normal, options: [{ id: "fastMode", value: false }] }), - ); - yield* settle; - assert.deepEqual(harness.liveSelectionChanges, []); + yield* harness.runtime.startTurn( + turn(2, { ...normal, options: [{ id: "fastMode", value: false }] }), + ); + yield* settle; + assert.deepEqual(harness.liveSelectionChanges, []); - yield* harness.runtime.startTurn( - turn(3, { - ...normal, - options: [ - { id: "effort", value: "high" }, - { id: "ultracode", value: true }, - { id: "fastMode", value: true }, - ], - }), - ); - yield* settle; - yield* harness.runtime.startTurn(turn(4, { ...normal, model: "claude-sonnet-4-6" })); - yield* settle; - assert.deepEqual(harness.liveSelectionChanges, [ - { effortLevel: "high", fastMode: true, ultracode: true }, - { model: "claude-sonnet-4-6" }, - { effortLevel: "high", fastMode: null, ultracode: null }, - ]); - assert.strictEqual(harness.getOpenedOptions(), originalOptions); - assert.lengthOf(harness.offeredMessages, 4); - - for (const [ordinal, selection] of [ - [5, CLAUDE_TEST_RESTART_MODEL_SELECTION], - [6, { ...normal, model: "claude-haiku-4-5-20251001" }], - ] as const) { - const refused = yield* harness.runtime - .startTurn(turn(ordinal, selection)) - .pipe(Effect.result); - assert.equal(refused._tag, "Failure"); - if (refused._tag === "Failure") { - assert.instanceOf( - refused.failure.cause, - ClaudeAdapterV2.ClaudeBackgroundWorkBlocksQueryReplacementError, - ); - } + yield* harness.runtime.startTurn( + turn(3, { + ...normal, + options: [ + { id: "effort", value: "high" }, + { id: "ultracode", value: true }, + { id: "fastMode", value: true }, + ], + }), + ); + yield* settle; + yield* harness.runtime.startTurn(turn(4, { ...normal, model: "claude-sonnet-4-6" })); + yield* settle; + assert.deepEqual(harness.liveSelectionChanges, [ + { effortLevel: "high", fastMode: true, ultracode: true }, + { model: "claude-sonnet-4-6" }, + { effortLevel: "high", fastMode: null, ultracode: null }, + ]); + assert.strictEqual(harness.getOpenedOptions(), originalOptions); + assert.lengthOf(harness.offeredMessages, 4); + + for (const [ordinal, selection] of [ + [5, CLAUDE_TEST_RESTART_MODEL_SELECTION], + [6, { ...normal, model: "claude-haiku-4-5-20251001" }], + ] as const) { + const refused = yield* harness.runtime + .startTurn(turn(ordinal, selection)) + .pipe(Effect.result); + assert.equal(refused._tag, "Failure"); + if (refused._tag === "Failure") { + assert.instanceOf( + refused.failure.cause, + ClaudeAdapterV2.ClaudeBackgroundWorkBlocksQueryReplacementError, + ); } - assert.strictEqual(harness.getOpenedOptions(), originalOptions); - assert.lengthOf(harness.offeredMessages, 4); - assert.lengthOf(harness.liveSelectionChanges, 3); - assert.isTrue(yield* harness.hasPendingBackgroundWork); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + } + assert.strictEqual(harness.getOpenedOptions(), originalOptions); + assert.lengthOf(harness.offeredMessages, 4); + assert.lengthOf(harness.liveSelectionChanges, 3); + assert.isTrue(yield* harness.hasPendingBackgroundWork); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), @@ -6601,25 +6564,83 @@ describe("ClaudeAdapterV2 background wake turns", () => { // regression: #34549 a model change sent with a steer was shown but never applied to the turn. it.effect("applies a model change sent with a steer to the running turn", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const opus = { - instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, - model: "claude-opus-5-5", - } satisfies ModelSelection; - const input = makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("attempt-steer-model-change"), - text: "Run the slow build.", + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const opus = { + instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, + model: "claude-opus-5-5", + } satisfies ModelSelection; + const input = makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-steer-model-change"), + text: "Run the slow build.", + attachments: [], + modelSelection: opus, + }); + yield* harness.runtime.startTurn(input); + yield* harness.runtime.steerTurn({ + threadId: harness.threadId, + runId: input.runId, + providerThread: harness.providerThread, + providerTurnId: idAllocator.derive.providerTurn({ + driver: ClaudeAdapterV2.CLAUDE_PROVIDER, + nativeTurnId: `turn:${input.attemptId}`, + }), + modelSelection: { ...opus, model: "claude-sonnet-4-6" }, + message: { + createdBy: "user", + creationSource: "web", + messageId: MessageId.make("steer-model-change"), + text: "Which model are you?", attachments: [], - modelSelection: opus, - }); - yield* harness.runtime.startTurn(input); - yield* harness.runtime.steerTurn({ + }, + }); + assert.deepEqual(harness.liveSelectionChanges, [ + { model: "claude-sonnet-4-6" }, + { effortLevel: "high", fastMode: null, ultracode: null }, + ]); + assert.lengthOf(harness.offeredMessages, 2); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + + it.effect("fails a model-changing steer when the turn ends during the selection change", () => + Effect.gen(function* () { + let duringFlags: Effect.Effect = Effect.void; + const harness = yield* makeWakeHarnessWithOptions({ + applyFlagSettings: () => Effect.suspend(() => duringFlags), + }); + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const opus = { + instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, + model: "claude-opus-5-5", + } satisfies ModelSelection; + const input = makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-steer-model-change-race"), + text: "Run the slow build.", + attachments: [], + modelSelection: opus, + }); + yield* harness.runtime.startTurn(input); + duringFlags = harness + .offerAndWait(turnOneResult) + .pipe(Effect.andThen(Queue.take(harness.terminalReceipts)), Effect.asVoid); + const steer = yield* harness.runtime + .steerTurn({ threadId: harness.threadId, runId: input.runId, providerThread: harness.providerThread, @@ -6631,146 +6652,82 @@ describe("ClaudeAdapterV2 background wake turns", () => { message: { createdBy: "user", creationSource: "web", - messageId: MessageId.make("steer-model-change"), + messageId: MessageId.make("steer-model-change-race"), text: "Which model are you?", attachments: [], }, - }); - assert.deepEqual(harness.liveSelectionChanges, [ - { model: "claude-sonnet-4-6" }, - { effortLevel: "high", fastMode: null, ultracode: null }, - ]); - assert.lengthOf(harness.offeredMessages, 2); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + }) + .pipe(Effect.result); + assert.equal(steer._tag, "Failure"); + assert.lengthOf(harness.offeredMessages, 1); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), ); - it.effect("fails a model-changing steer when the turn ends during the selection change", () => - Effect.scoped( - Effect.gen(function* () { - let duringFlags: Effect.Effect = Effect.void; - const harness = yield* makeWakeHarnessWithOptions({ - applyFlagSettings: () => Effect.suspend(() => duringFlags), - }); - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const opus = { - instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, - model: "claude-opus-5-5", - } satisfies ModelSelection; - const input = makeClaudeTestTurnInput({ + it.effect("re-sends the model on the turn after a live selection change fails", () => + Effect.gen(function* () { + let failFlags = true; + const harness = yield* makeWakeHarnessWithOptions({ + applyFlagSettings: () => + Effect.suspend(() => { + if (!failFlags) return Effect.void; + failFlags = false; + return Effect.fail( + new ClaudeAdapterV2.ClaudeAgentSdkQueryRunnerError({ + method: "applyFlagSettings", + cause: new Error("control request failed"), + }), + ); + }), + }); + const now = yield* DateTime.now; + const opus = { + instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, + model: "claude-opus-5-5", + } satisfies ModelSelection; + const turn = (ordinal: number, modelSelection: ModelSelection) => + makeClaudeTestTurnInput({ threadId: harness.threadId, providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("attempt-steer-model-change-race"), - text: "Run the slow build.", + now, + attemptId: RunAttemptId.make(`attempt-failed-live-change:${ordinal}`), + text: `Request ${ordinal}`, attachments: [], - modelSelection: opus, - }); - yield* harness.runtime.startTurn(input); - duringFlags = harness - .offerAndWait(turnOneResult) - .pipe(Effect.andThen(Queue.take(harness.terminalReceipts)), Effect.asVoid); - const steer = yield* harness.runtime - .steerTurn({ - threadId: harness.threadId, - runId: input.runId, - providerThread: harness.providerThread, - providerTurnId: idAllocator.derive.providerTurn({ - driver: ClaudeAdapterV2.CLAUDE_PROVIDER, - nativeTurnId: `turn:${input.attemptId}`, - }), - modelSelection: { ...opus, model: "claude-sonnet-4-6" }, - message: { - createdBy: "user", - creationSource: "web", - messageId: MessageId.make("steer-model-change-race"), - text: "Which model are you?", - attachments: [], - }, - }) - .pipe(Effect.result); - assert.equal(steer._tag, "Failure"); - assert.lengthOf(harness.offeredMessages, 1); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), - ), - ), - ), - ); - - it.effect("re-sends the model on the turn after a live selection change fails", () => - Effect.scoped( - Effect.gen(function* () { - let failFlags = true; - const harness = yield* makeWakeHarnessWithOptions({ - applyFlagSettings: () => - Effect.suspend(() => { - if (!failFlags) return Effect.void; - failFlags = false; - return Effect.fail( - new ClaudeAdapterV2.ClaudeAgentSdkQueryRunnerError({ - method: "applyFlagSettings", - cause: new Error("control request failed"), - }), - ); - }), + providerTurnOrdinal: ordinal, + modelSelection, }); - const now = yield* DateTime.now; - const opus = { - instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, - model: "claude-opus-5-5", - } satisfies ModelSelection; - const turn = (ordinal: number, modelSelection: ModelSelection) => - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make(`attempt-failed-live-change:${ordinal}`), - text: `Request ${ordinal}`, - attachments: [], - providerTurnOrdinal: ordinal, - modelSelection, - }); - yield* harness.runtime.startTurn(turn(1, opus)); - yield* harness.offerAndWait(turnOneResult); - yield* Queue.take(harness.terminalReceipts); - - const failed = yield* harness.runtime - .startTurn(turn(2, { ...opus, model: "claude-sonnet-4-6" })) - .pipe(Effect.result); - assert.equal(failed._tag, "Failure"); + yield* harness.runtime.startTurn(turn(1, opus)); + yield* harness.offerAndWait(turnOneResult); + yield* Queue.take(harness.terminalReceipts); - // The process may now run Sonnet, so returning to Opus must send it. - yield* harness.runtime.startTurn(turn(3, opus)); - assert.deepEqual(harness.liveSelectionChanges, [ - { model: "claude-sonnet-4-6" }, - { effortLevel: "high", fastMode: null, ultracode: null }, - { model: "claude-opus-5-5" }, - { effortLevel: "medium", fastMode: false, ultracode: null }, - ]); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + const failed = yield* harness.runtime + .startTurn(turn(2, { ...opus, model: "claude-sonnet-4-6" })) + .pipe(Effect.result); + assert.equal(failed._tag, "Failure"); + + // The process may now run Sonnet, so returning to Opus must send it. + yield* harness.runtime.startTurn(turn(3, opus)); + assert.deepEqual(harness.liveSelectionChanges, [ + { model: "claude-sonnet-4-6" }, + { effortLevel: "high", fastMode: null, ultracode: null }, + { model: "claude-opus-5-5" }, + { effortLevel: "medium", fastMode: false, ultracode: null }, + ]); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), @@ -6996,86 +6953,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { it.effect.each(["cancelled", "interrupted", "denied", "permission_denied", undefined])( "preserves native tool non-execution metadata %s without inferring a denial from text", (kind) => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("attempt-tool-non-execution"), - text: "Run the tool.", - attachments: [], - }), - ); - yield* harness.offerAndWait( - claudeSdkFrame({ - type: "user", - uuid: "tool-non-execution", - session_id: WAKE_NATIVE_SESSION, - parent_tool_use_id: null, - message: { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "tool-error", - is_error: true, - content: "STOP and wait for the user.", - }, - { type: "tool_result", tool_use_id: "tool-ok", is_error: false, content: "OK" }, - ], - }, - ...(kind === undefined - ? {} - : { - tool_result_meta: [ - { id: "tool-error", non_execution_kind: kind }, - { id: "tool-ok", non_execution_kind: null }, - ], - }), - }), - ); - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ uuid: "result-non-execution", result: "Done" }), - ); - yield* Queue.take(harness.terminalReceipts); - const items = harness.events.flatMap((event) => - event.type === "turn_item.updated" && event.turnItem.type === "dynamic_tool" - ? [event.turnItem] - : [], - ); - const failed = items.findLast((item) => item.nativeItemRef?.nativeId === "tool-error")!; - assert.equal( - failed.status, - kind === "cancelled" || kind === "interrupted" ? kind : "failed", - ); - assert.equal(failed.toolNonExecutionKind, kind); - const ok = items.findLast((item) => item.nativeItemRef?.nativeId === "tool-ok")!; - assert.equal(ok.status, "completed"); - assert.equal(ok.toolNonExecutionKind, undefined); - const node = harness.events.findLast( - (event) => - event.type === "node.updated" && event.node.nativeItemRef?.nativeId === "tool-error", - ); - assert.equal(node?.type === "node.updated" ? node.node.status : undefined, failed.status); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), - ), - ), - ), - ); - - it.effect("completes a turn after the resume dialog without a failed tool row", () => - Effect.scoped( Effect.gen(function* () { const harness = yield* makeWakeHarness; yield* harness.runtime.startTurn( @@ -7083,52 +6960,63 @@ describe("ClaudeAdapterV2 background wake turns", () => { threadId: harness.threadId, providerThread: harness.providerThread, now: yield* DateTime.now, - attemptId: RunAttemptId.make("attempt-resume-dialog"), - text: "continue", + attemptId: RunAttemptId.make("attempt-tool-non-execution"), + text: "Run the tool.", attachments: [], }), ); - const dialog = yield* Effect.promise(() => - harness.getOpenedOptions()!.onUserDialog!( - { - dialogKind: "resume_return", - payload: { sessionAgeMinutes: 90, estimatedTokens: 120000 }, + yield* harness.offerAndWait( + claudeSdkFrame({ + type: "user", + uuid: "tool-non-execution", + session_id: WAKE_NATIVE_SESSION, + parent_tool_use_id: null, + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool-error", + is_error: true, + content: "STOP and wait for the user.", + }, + { type: "tool_result", tool_use_id: "tool-ok", is_error: false, content: "OK" }, + ], }, - { signal: new AbortController().signal, requestId: "dialog-resume" }, - ), - ).pipe(Effect.forkScoped); - const request = yield* Queue.take(harness.runtimeRequestReceipts); - assert.deepEqual( - harness.events.flatMap((event) => - event.type === "node.updated" && event.node.id === request.runtimeRequest.nodeId - ? [event.node.parentNodeId] - : [], - ), - [NodeId.make("node-attempt-resume-dialog")], + ...(kind === undefined + ? {} + : { + tool_result_meta: [ + { id: "tool-error", non_execution_kind: kind }, + { id: "tool-ok", non_execution_kind: null }, + ], + }), + }), ); - yield* harness.runtime.respondToRuntimeRequest({ - requestId: request.runtimeRequest.id, - answers: { - [formatClaudeResumeCompactionQuestion({ ageMinutes: 90, estimatedTokens: 120000 })]: - "Keep full history", - }, - }); - assert.deepEqual(yield* Fiber.join(dialog), { behavior: "completed", result: "continue" }); yield* Queue.offer( harness.sdkMessages, - makeResultFrame({ uuid: "result-resume-dialog", result: "Done" }), + makeResultFrame({ uuid: "result-non-execution", result: "Done" }), ); - assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "completed"); - assert.deepEqual( - harness.events.flatMap((event) => - event.type === "turn_item.updated" && - event.turnItem.type === "dynamic_tool" && - event.turnItem.nativeItemRef?.nativeId === "dialog-resume" - ? [event.turnItem.status] - : [], - ), - [], + yield* Queue.take(harness.terminalReceipts); + const items = harness.events.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "dynamic_tool" + ? [event.turnItem] + : [], + ); + const failed = items.findLast((item) => item.nativeItemRef?.nativeId === "tool-error")!; + assert.equal( + failed.status, + kind === "cancelled" || kind === "interrupted" ? kind : "failed", + ); + assert.equal(failed.toolNonExecutionKind, kind); + const ok = items.findLast((item) => item.nativeItemRef?.nativeId === "tool-ok")!; + assert.equal(ok.status, "completed"); + assert.equal(ok.toolNonExecutionKind, undefined); + const node = harness.events.findLast( + (event) => + event.type === "node.updated" && event.node.nativeItemRef?.nativeId === "tool-error", ); + assert.equal(node?.type === "node.updated" ? node.node.status : undefined, failed.status); }).pipe( Effect.provide( Layer.mergeAll( @@ -7139,6 +7027,71 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ), ), + ); + + it.effect("completes a turn after the resume dialog without a failed tool row", () => + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-resume-dialog"), + text: "continue", + attachments: [], + }), + ); + const dialog = yield* Effect.promise(() => + harness.getOpenedOptions()!.onUserDialog!( + { + dialogKind: "resume_return", + payload: { sessionAgeMinutes: 90, estimatedTokens: 120000 }, + }, + { signal: new AbortController().signal, requestId: "dialog-resume" }, + ), + ).pipe(Effect.forkScoped); + const request = yield* Queue.take(harness.runtimeRequestReceipts); + assert.deepEqual( + harness.events.flatMap((event) => + event.type === "node.updated" && event.node.id === request.runtimeRequest.nodeId + ? [event.node.parentNodeId] + : [], + ), + [NodeId.make("node-attempt-resume-dialog")], + ); + yield* harness.runtime.respondToRuntimeRequest({ + requestId: request.runtimeRequest.id, + answers: { + [formatClaudeResumeCompactionQuestion({ ageMinutes: 90, estimatedTokens: 120000 })]: + "Keep full history", + }, + }); + assert.deepEqual(yield* Fiber.join(dialog), { behavior: "completed", result: "continue" }); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ uuid: "result-resume-dialog", result: "Done" }), + ); + assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "completed"); + assert.deepEqual( + harness.events.flatMap((event) => + event.type === "turn_item.updated" && + event.turnItem.type === "dynamic_tool" && + event.turnItem.nativeItemRef?.nativeId === "dialog-resume" + ? [event.turnItem.status] + : [], + ), + [], + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), ), ); @@ -7151,136 +7104,131 @@ describe("ClaudeAdapterV2 background wake turns", () => { ].map((scenario) => ({ terminalReason, ...scenario })), ), )("handles $terminalReason (steered=$steered, exits=$queryExits)", (scenario) => - Effect.scoped( - Effect.gen(function* () { - const { terminalReason, steered, queryExits } = scenario; - const harness = yield* makeWakeHarness; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const attemptId = RunAttemptId.make("attempt-steering-abort"); - const input = makeClaudeTestTurnInput({ + Effect.gen(function* () { + const { terminalReason, steered, queryExits } = scenario; + const harness = yield* makeWakeHarness; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const attemptId = RunAttemptId.make("attempt-steering-abort"); + const input = makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId, + text: "Audit the settings pages.", + attachments: [], + }); + yield* harness.runtime.startTurn(input); + if (steered) { + yield* harness.runtime.steerTurn({ threadId: harness.threadId, + runId: input.runId, providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId, - text: "Audit the settings pages.", - attachments: [], + providerTurnId: idAllocator.derive.providerTurn({ + driver: ClaudeAdapterV2.CLAUDE_PROVIDER, + nativeTurnId: `turn:${attemptId}`, + }), + message: { + createdBy: "user", + creationSource: "web", + messageId: MessageId.make("message-steering-abort"), + text: "Include the hierarchy mock.", + attachments: [], + }, }); - yield* harness.runtime.startTurn(input); - if (steered) { - yield* harness.runtime.steerTurn({ - threadId: harness.threadId, - runId: input.runId, - providerThread: harness.providerThread, - providerTurnId: idAllocator.derive.providerTurn({ - driver: ClaudeAdapterV2.CLAUDE_PROVIDER, - nativeTurnId: `turn:${attemptId}`, - }), - message: { - createdBy: "user", - creationSource: "web", - messageId: MessageId.make("message-steering-abort"), - text: "Include the hierarchy mock.", - attachments: [], - }, - }); - assert.equal(harness.offeredMessages[1]?.priority, "now"); - } - const partialText = "The audit covers the `integ"; - if (!queryExits) - yield* Queue.offer( - harness.sdkMessages, - makeAssistantTextFrame({ - uuid: "00000000-0000-4000-8000-000000000900", - text: partialText, - }), - ); - yield* harness.offerAndWait( + assert.equal(harness.offeredMessages[1]?.priority, "now"); + } + const partialText = "The audit covers the `integ"; + if (!queryExits) + yield* Queue.offer( + harness.sdkMessages, + makeAssistantTextFrame({ + uuid: "00000000-0000-4000-8000-000000000900", + text: partialText, + }), + ); + yield* harness.offerAndWait( + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000901", + result: "", + terminalReason, + usage: { + input_tokens: 600, + output_tokens: 40, + cache_creation_input_tokens: 500, + cache_read_input_tokens: 2000, + }, + }), + ); + if (queryExits) { + yield* Queue.shutdown(harness.sdkMessages); + } else if (steered) { + yield* Queue.offer(harness.sdkMessages, wakeAssistant); + yield* Queue.offer( + harness.sdkMessages, makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000901", - result: "", - terminalReason, + uuid: "00000000-0000-4000-8000-000000000902", + result: "Audit finished after the steer.", usage: { - input_tokens: 600, - output_tokens: 40, - cache_creation_input_tokens: 500, - cache_read_input_tokens: 2000, + input_tokens: 3, + output_tokens: 6, + cache_creation_input_tokens: 2347, + cache_read_input_tokens: 9455, }, }), ); - if (queryExits) { - yield* Queue.shutdown(harness.sdkMessages); - } else if (steered) { - yield* Queue.offer(harness.sdkMessages, wakeAssistant); - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000902", - result: "Audit finished after the steer.", - usage: { - input_tokens: 3, - output_tokens: 6, - cache_creation_input_tokens: 2347, - cache_read_input_tokens: 9455, - }, - }), - ); - } - const terminal = yield* Queue.take(harness.terminalReceipts); + } + const terminal = yield* Queue.take(harness.terminalReceipts); + assert.equal(terminal.status, queryExits ? "failed" : steered ? "completed" : "interrupted"); + if (steered && !queryExits && terminalReason === "aborted_streaming") { + // The steer cut this reply off mid-stream; it must not read as a finished answer. + const partial = harness.events.findLast( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "assistant_message" && + event.turnItem.text === partialText, + ); assert.equal( - terminal.status, - queryExits ? "failed" : steered ? "completed" : "interrupted", + partial?.type === "turn_item.updated" ? partial.turnItem.status : undefined, + "interrupted", ); - if (steered && !queryExits && terminalReason === "aborted_streaming") { - // The steer cut this reply off mid-stream; it must not read as a finished answer. - const partial = harness.events.findLast( - (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "assistant_message" && - event.turnItem.text === partialText, - ); - assert.equal( - partial?.type === "turn_item.updated" ? partial.turnItem.status : undefined, - "interrupted", - ); - } - if (steered) { - if (!queryExits) - assert.isTrue( - harness.events.some( - (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "assistant_message" && - event.turnItem.text === WAKE_ASSISTANT_TEXT, - ), - ); - const completed = harness.events.findLast( - (event) => event.type === "provider_turn.updated", - ); - assert.deepEqual( - completed?.type === "provider_turn.updated" - ? completed.providerTurn.turnTokenUsage - : undefined, - { - usageScope: "main_agent", - usageStatus: queryExits ? "partial" : "complete", - hasSubagents: false, - inputTokens: queryExits ? 3100 : 603 + 2847 + 11455, - outputTokens: queryExits ? 40 : 46, - cachedInputTokens: queryExits ? 2000 : 11455, - cacheCreationTokens: queryExits ? 500 : 2847, - ...(queryExits ? {} : { models: ["claude-sonnet-4-6"] }), - }, + } + if (steered) { + if (!queryExits) + assert.isTrue( + harness.events.some( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "assistant_message" && + event.turnItem.text === WAKE_ASSISTANT_TEXT, + ), ); - } - assert.lengthOf(harness.terminalEvents(), 1); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + const completed = harness.events.findLast( + (event) => event.type === "provider_turn.updated", + ); + assert.deepEqual( + completed?.type === "provider_turn.updated" + ? completed.providerTurn.turnTokenUsage + : undefined, + { + usageScope: "main_agent", + usageStatus: queryExits ? "partial" : "complete", + hasSubagents: false, + inputTokens: queryExits ? 3100 : 603 + 2847 + 11455, + outputTokens: queryExits ? 40 : 46, + cachedInputTokens: queryExits ? 2000 : 11455, + cacheCreationTokens: queryExits ? 500 : 2847, + ...(queryExits ? {} : { models: ["claude-sonnet-4-6"] }), + }, + ); + } + assert.lengthOf(harness.terminalEvents(), 1); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), @@ -10036,248 +9984,243 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); it.effect("preserves typed Claude plans and todos through generic tool completion", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - const assistantTools = ( - uuid: string, - tools: ReadonlyArray>, - parentToolUseId: string | null = null, - ) => - claudeSdkFrame({ - type: "assistant", - message: { - model: "claude-sonnet-4-6", - id: `msg_${uuid}`, - type: "message", - role: "assistant", - content: tools.map((tool) => ({ type: "tool_use", ...tool })), - stop_reason: "tool_use", - stop_sequence: null, - usage: { - input_tokens: 1, - output_tokens: 1, - cache_creation_input_tokens: 0, - cache_read_input_tokens: 0, - }, - }, - parent_tool_use_id: parentToolUseId, - uuid, - session_id: WAKE_NATIVE_SESSION, - }); - const toolResults = (uuid: string, toolUseIds: ReadonlyArray) => - claudeSdkFrame({ - type: "user", - message: { - role: "user", - content: toolUseIds.map((toolUseId) => ({ - type: "tool_result", - tool_use_id: toolUseId, - content: "ok", - })), - }, - parent_tool_use_id: null, - uuid, - session_id: WAKE_NATIVE_SESSION, - }); - - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-plan-lifecycle-1"), - text: "Plan the work.", - attachments: [], - }), - ); - yield* Queue.offer( - harness.sdkMessages, - assistantTools("00000000-0000-4000-8000-000000000501", [ - { - id: "tool-todo-1", - name: "TodoWrite", - input: { todos: [{ content: "Inspect", status: "in_progress" }] }, + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const assistantTools = ( + uuid: string, + tools: ReadonlyArray>, + parentToolUseId: string | null = null, + ) => + claudeSdkFrame({ + type: "assistant", + message: { + model: "claude-sonnet-4-6", + id: `msg_${uuid}`, + type: "message", + role: "assistant", + content: tools.map((tool) => ({ type: "tool_use", ...tool })), + stop_reason: "tool_use", + stop_sequence: null, + usage: { + input_tokens: 1, + output_tokens: 1, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, }, - ]), - ); - yield* Queue.offer( - harness.sdkMessages, - assistantTools("00000000-0000-4000-8000-000000000501", [ - { - id: "tool-todo-1", - name: "TodoWrite", - input: { todos: [{ content: "Inspect", status: "in_progress" }] }, - }, - ]), - ); - yield* Queue.offer( - harness.sdkMessages, - toolResults("00000000-0000-4000-8000-000000000502", ["tool-todo-1"]), - ); - yield* harness.offerAndWait( - claudeSdkFrame({ ...wakeTurnInit, permissionMode: "bypassPermissions" }), - ); - yield* Queue.offer( - harness.sdkMessages, - claudeSdkFrame({ - type: "system", - subtype: "status", - status: null, - permissionMode: "plan", - uuid: "00000000-0000-4000-8000-000000000508", - session_id: WAKE_NATIVE_SESSION, - }), - ); - yield* harness.offerAndWait( - claudeSdkFrame({ - ...wakeTurnInit, - uuid: "00000000-0000-4000-8000-000000000509", - permissionMode: "plan", - }), - ); - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000503", - result: "Todo recorded.", - }), - ); - yield* Queue.take(harness.terminalReceipts); + }, + parent_tool_use_id: parentToolUseId, + uuid, + session_id: WAKE_NATIVE_SESSION, + }); + const toolResults = (uuid: string, toolUseIds: ReadonlyArray) => + claudeSdkFrame({ + type: "user", + message: { + role: "user", + content: toolUseIds.map((toolUseId) => ({ + type: "tool_result", + tool_use_id: toolUseId, + content: "ok", + })), + }, + parent_tool_use_id: null, + uuid, + session_id: WAKE_NATIVE_SESSION, + }); - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-plan-lifecycle-2"), - providerTurnOrdinal: 2, - text: "Finish the plan.", - attachments: [], - }), - ); - const canUseTool = harness.getOpenedOptions()?.canUseTool; - assert.isFunction(canUseTool); - const planMarkdown = "# Ready to implement\n\n1. Ship it."; - yield* Effect.promise(() => - canUseTool!( - "ExitPlanMode", - { plan: planMarkdown }, - { - signal: new AbortController().signal, - toolUseID: "tool-exit-plan-1", - requestId: "request-exit-plan-1", - }, - ), - ); - yield* Queue.offer( - harness.sdkMessages, - assistantTools("00000000-0000-4000-8000-000000000504", [ - { - id: "tool-todo-2", - name: "TodoWrite", - input: { todos: [{ content: "Inspect", status: "completed" }] }, - }, - { id: "tool-exit-plan-1", name: "ExitPlanMode", input: { plan: planMarkdown } }, - ]), - ); - yield* Queue.offer( - harness.sdkMessages, - assistantTools( - "00000000-0000-4000-8000-000000000507", - [ - { - id: "tool-subagent-todo", - name: "TodoWrite", - input: { todos: [{ content: "Child-only work", status: "in_progress" }] }, - }, - ], - "tool-parent-agent", - ), - ); - yield* Queue.offer( - harness.sdkMessages, - toolResults("00000000-0000-4000-8000-000000000505", ["tool-todo-2", "tool-exit-plan-1"]), - ); - yield* Queue.offer( - harness.sdkMessages, - assistantTools("00000000-0000-4000-8000-000000000509", [ + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-plan-lifecycle-1"), + text: "Plan the work.", + attachments: [], + }), + ); + yield* Queue.offer( + harness.sdkMessages, + assistantTools("00000000-0000-4000-8000-000000000501", [ + { + id: "tool-todo-1", + name: "TodoWrite", + input: { todos: [{ content: "Inspect", status: "in_progress" }] }, + }, + ]), + ); + yield* Queue.offer( + harness.sdkMessages, + assistantTools("00000000-0000-4000-8000-000000000501", [ + { + id: "tool-todo-1", + name: "TodoWrite", + input: { todos: [{ content: "Inspect", status: "in_progress" }] }, + }, + ]), + ); + yield* Queue.offer( + harness.sdkMessages, + toolResults("00000000-0000-4000-8000-000000000502", ["tool-todo-1"]), + ); + yield* harness.offerAndWait( + claudeSdkFrame({ ...wakeTurnInit, permissionMode: "bypassPermissions" }), + ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "system", + subtype: "status", + status: null, + permissionMode: "plan", + uuid: "00000000-0000-4000-8000-000000000508", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* harness.offerAndWait( + claudeSdkFrame({ + ...wakeTurnInit, + uuid: "00000000-0000-4000-8000-000000000509", + permissionMode: "plan", + }), + ); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000503", + result: "Todo recorded.", + }), + ); + yield* Queue.take(harness.terminalReceipts); + + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-plan-lifecycle-2"), + providerTurnOrdinal: 2, + text: "Finish the plan.", + attachments: [], + }), + ); + const canUseTool = harness.getOpenedOptions()?.canUseTool; + assert.isFunction(canUseTool); + const planMarkdown = "# Ready to implement\n\n1. Ship it."; + yield* Effect.promise(() => + canUseTool!( + "ExitPlanMode", + { plan: planMarkdown }, + { + signal: new AbortController().signal, + toolUseID: "tool-exit-plan-1", + requestId: "request-exit-plan-1", + }, + ), + ); + yield* Queue.offer( + harness.sdkMessages, + assistantTools("00000000-0000-4000-8000-000000000504", [ + { + id: "tool-todo-2", + name: "TodoWrite", + input: { todos: [{ content: "Inspect", status: "completed" }] }, + }, + { id: "tool-exit-plan-1", name: "ExitPlanMode", input: { plan: planMarkdown } }, + ]), + ); + yield* Queue.offer( + harness.sdkMessages, + assistantTools( + "00000000-0000-4000-8000-000000000507", + [ { - id: "tool-todo-3", + id: "tool-subagent-todo", name: "TodoWrite", - input: { todos: [{ content: "Implement", status: "pending" }] }, + input: { todos: [{ content: "Child-only work", status: "in_progress" }] }, }, - ]), - ); - yield* Queue.offer( - harness.sdkMessages, - toolResults("00000000-0000-4000-8000-000000000510", ["tool-todo-3"]), - ); - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000506", - result: "Plan captured.", - }), - ); - yield* Queue.take(harness.terminalReceipts); + ], + "tool-parent-agent", + ), + ); + yield* Queue.offer( + harness.sdkMessages, + toolResults("00000000-0000-4000-8000-000000000505", ["tool-todo-2", "tool-exit-plan-1"]), + ); + yield* Queue.offer( + harness.sdkMessages, + assistantTools("00000000-0000-4000-8000-000000000509", [ + { + id: "tool-todo-3", + name: "TodoWrite", + input: { todos: [{ content: "Implement", status: "pending" }] }, + }, + ]), + ); + yield* Queue.offer( + harness.sdkMessages, + toolResults("00000000-0000-4000-8000-000000000510", ["tool-todo-3"]), + ); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000506", + result: "Plan captured.", + }), + ); + yield* Queue.take(harness.terminalReceipts); - const items = new Map( - harness.events.flatMap((event) => - event.type === "turn_item.updated" ? [[String(event.turnItem.id), event.turnItem]] : [], - ), - ); - const plans = new Map( - harness.events.flatMap((event) => - event.type === "plan.updated" ? [[String(event.plan.id), event.plan]] : [], - ), - ); - const todoItems = [...items.values()].filter((item) => item.type === "todo_list"); - const proposedItems = [...items.values()].filter((item) => item.type === "proposed_plan"); - assert.lengthOf(todoItems, 3); - assert.equal(todoItems[1]?.status, "completed"); - assert.deepEqual(todoItems[1]?.steps, [ - { id: "todo-0", text: "Inspect", status: "completed" }, - ]); - assert.deepEqual(todoItems[2]?.steps, [ - { id: "todo-0", text: "Implement", status: "pending" }, - ]); - assert.equal(todoItems[1]?.runId, todoItems[2]?.runId); - assert.lengthOf(proposedItems, 1); - assert.equal( - proposedItems[0]?.type === "proposed_plan" && proposedItems[0].markdown, - planMarkdown, - ); - assert.isTrue( - [...items.values()].some( - (item) => - item.type === "dynamic_tool" && item.nativeItemRef?.nativeId === "tool-todo-2", - ), - ); - assert.isTrue( - [...items.values()].some( - (item) => - item.type === "dynamic_tool" && item.nativeItemRef?.nativeId === "tool-exit-plan-1", - ), - ); - const proposedPlan = [...plans.values()].find((plan) => plan.kind === "proposed_plan"); - assert.equal(proposedPlan?.status, "active"); - assert.deepEqual(harness.permissionModeChanges, ["bypassPermissions"]); - assert.deepEqual( - [...plans.values()] - .filter((plan) => plan.kind === "todo_list") - .map((plan) => plan.status), - ["superseded", "superseded", "active"], - ); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + const items = new Map( + harness.events.flatMap((event) => + event.type === "turn_item.updated" ? [[String(event.turnItem.id), event.turnItem]] : [], + ), + ); + const plans = new Map( + harness.events.flatMap((event) => + event.type === "plan.updated" ? [[String(event.plan.id), event.plan]] : [], + ), + ); + const todoItems = [...items.values()].filter((item) => item.type === "todo_list"); + const proposedItems = [...items.values()].filter((item) => item.type === "proposed_plan"); + assert.lengthOf(todoItems, 3); + assert.equal(todoItems[1]?.status, "completed"); + assert.deepEqual(todoItems[1]?.steps, [ + { id: "todo-0", text: "Inspect", status: "completed" }, + ]); + assert.deepEqual(todoItems[2]?.steps, [ + { id: "todo-0", text: "Implement", status: "pending" }, + ]); + assert.equal(todoItems[1]?.runId, todoItems[2]?.runId); + assert.lengthOf(proposedItems, 1); + assert.equal( + proposedItems[0]?.type === "proposed_plan" && proposedItems[0].markdown, + planMarkdown, + ); + assert.isTrue( + [...items.values()].some( + (item) => item.type === "dynamic_tool" && item.nativeItemRef?.nativeId === "tool-todo-2", + ), + ); + assert.isTrue( + [...items.values()].some( + (item) => + item.type === "dynamic_tool" && item.nativeItemRef?.nativeId === "tool-exit-plan-1", + ), + ); + const proposedPlan = [...plans.values()].find((plan) => plan.kind === "proposed_plan"); + assert.equal(proposedPlan?.status, "active"); + assert.deepEqual(harness.permissionModeChanges, ["bypassPermissions"]); + assert.deepEqual( + [...plans.values()].filter((plan) => plan.kind === "todo_list").map((plan) => plan.status), + ["superseded", "superseded", "active"], + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), @@ -10843,86 +10786,140 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); it.effect("resolves API retries on resumed assistant activity", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-api-retry"), - text: "Open github.com.", - attachments: [], - }), - ); - - yield* Queue.offer( - harness.sdkMessages, - claudeSdkFrame({ - type: "system", - subtype: "api_retry", - attempt: 2, - max_retries: 10, - retry_delay_ms: 1_500, - error_status: 529, - error: "overloaded", - uuid: "00000000-0000-4000-8000-000000000201", - session_id: WAKE_NATIVE_SESSION, - }), - ); - const retryItems = () => - harness.events.flatMap((event) => - event.type === "turn_item.updated" && - event.turnItem.type === "error" && - event.turnItem.retry !== undefined - ? [event.turnItem] - : [], - ); - yield* harness.until(() => retryItems().length === 1); - const runningRetry = retryItems()[0]; - assert.equal(runningRetry?.status, "running"); - assert.equal(runningRetry?.failure.code, "api_error_529"); - assert.deepEqual(runningRetry?.retry, { - attempt: 2, - maxAttempts: 10, - retryDelayMs: 1_500, - }); + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-api-retry"), + text: "Open github.com.", + attachments: [], + }), + ); - // The stream starting again settles the retry before any assistant message completes. - yield* Queue.offer( - harness.sdkMessages, - claudeSdkFrame({ - type: "stream_event", - event: { type: "message_start", message: { id: "api-retry-recovered-message" } }, - parent_tool_use_id: null, - session_id: WAKE_NATIVE_SESSION, - uuid: "00000000-0000-4000-8000-000000000204", - }), + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "system", + subtype: "api_retry", + attempt: 2, + max_retries: 10, + retry_delay_ms: 1_500, + error_status: 529, + error: "overloaded", + uuid: "00000000-0000-4000-8000-000000000201", + session_id: WAKE_NATIVE_SESSION, + }), + ); + const retryItems = () => + harness.events.flatMap((event) => + event.type === "turn_item.updated" && + event.turnItem.type === "error" && + event.turnItem.retry !== undefined + ? [event.turnItem] + : [], ); - yield* harness.until(() => retryItems().length === 2); - assert.lengthOf(harness.terminalEvents(), 0); - const recoveredRetry = retryItems()[1]; - assert.equal(recoveredRetry?.id, runningRetry?.id); - assert.equal(recoveredRetry?.status, "completed"); - assert.equal(recoveredRetry?.title, "Provider recovered"); - yield* Queue.offer( - harness.sdkMessages, - makeAssistantTextFrame({ - uuid: "00000000-0000-4000-8000-000000000202", - text: "Opening GitHub.", + yield* harness.until(() => retryItems().length === 1); + const runningRetry = retryItems()[0]; + assert.equal(runningRetry?.status, "running"); + assert.equal(runningRetry?.failure.code, "api_error_529"); + assert.deepEqual(runningRetry?.retry, { + attempt: 2, + maxAttempts: 10, + retryDelayMs: 1_500, + }); + + // The stream starting again settles the retry before any assistant message completes. + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "stream_event", + event: { type: "message_start", message: { id: "api-retry-recovered-message" } }, + parent_tool_use_id: null, + session_id: WAKE_NATIVE_SESSION, + uuid: "00000000-0000-4000-8000-000000000204", + }), + ); + yield* harness.until(() => retryItems().length === 2); + assert.lengthOf(harness.terminalEvents(), 0); + const recoveredRetry = retryItems()[1]; + assert.equal(recoveredRetry?.id, runningRetry?.id); + assert.equal(recoveredRetry?.status, "completed"); + assert.equal(recoveredRetry?.title, "Provider recovered"); + yield* Queue.offer( + harness.sdkMessages, + makeAssistantTextFrame({ + uuid: "00000000-0000-4000-8000-000000000202", + text: "Opening GitHub.", + }), + ); + + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000203", + result: "Opened GitHub.", + }), + ); + yield* harness.awaitTerminal(1); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + + it.effect.each(["api_error", "overloaded_status"] as const)( + "fails a success-shaped Claude result with %s", + (terminalReason) => + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-structured-terminal-failure"), + text: "Complete the task.", + attachments: [], }), ); - yield* Queue.offer( harness.sdkMessages, makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000203", - result: "Opened GitHub.", + uuid: "00000000-0000-4000-8000-000000000205", + result: "Provider failure details.", + isError: false, + ...(terminalReason === "overloaded_status" + ? { apiErrorStatus: 529 } + : { terminalReason }), }), ); - yield* harness.awaitTerminal(1); + const terminal = yield* Queue.take(harness.terminalReceipts); + assert.equal(terminal.status, "failed"); + if (terminal.status !== "failed") return; + assert.isNotEmpty(terminal.failure.message); + assert.equal(terminal.failure.class, "provider_error"); + assert.isFalse( + harness.events.some( + (event) => + event.type === "message.updated" && + event.message.text === "Provider failure details.", + ), + ); + assert.equal( + terminal.failure.code, + terminalReason === "overloaded_status" ? "api_error_529" : terminalReason, + ); }).pipe( Effect.provide( Layer.mergeAll( @@ -10933,64 +10930,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ), ), - ), - ); - - it.effect.each(["api_error", "overloaded_status"] as const)( - "fails a success-shaped Claude result with %s", - (terminalReason) => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-structured-terminal-failure"), - text: "Complete the task.", - attachments: [], - }), - ); - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000205", - result: "Provider failure details.", - isError: false, - ...(terminalReason === "overloaded_status" - ? { apiErrorStatus: 529 } - : { terminalReason }), - }), - ); - const terminal = yield* Queue.take(harness.terminalReceipts); - assert.equal(terminal.status, "failed"); - if (terminal.status !== "failed") return; - assert.isNotEmpty(terminal.failure.message); - assert.equal(terminal.failure.class, "provider_error"); - assert.isFalse( - harness.events.some( - (event) => - event.type === "message.updated" && - event.message.text === "Provider failure details.", - ), - ); - assert.equal( - terminal.failure.code, - terminalReason === "overloaded_status" ? "api_error_529" : terminalReason, - ); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), - ), - ), - ), ); // Context exhaustion reports blocking_limit even when the rate-limit window is allowed. @@ -11076,402 +11015,108 @@ describe("ClaudeAdapterV2 background wake turns", () => { it.effect( "uses task_started as an incremental roster fallback, keeps its tool call, and clears internal work on empty snapshot", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - const emptyRoster = claudeSdkFrame({ - type: "system", - subtype: "background_tasks_changed", - tasks: [], - uuid: "00000000-0000-4000-8000-000000000202", - session_id: WAKE_NATIVE_SESSION, - }); + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const emptyRoster = claudeSdkFrame({ + type: "system", + subtype: "background_tasks_changed", + tasks: [], + uuid: "00000000-0000-4000-8000-000000000202", + session_id: WAKE_NATIVE_SESSION, + }); - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-roster-fallback"), - text: "Run the build in the background.", - attachments: [], - }), - ); - yield* Queue.offer( - harness.sdkMessages, - claudeSdkFrame({ - type: "assistant", - message: { - model: "claude-sonnet-4-6", - id: "msg_background_command", - type: "message", - role: "assistant", - content: [ - { - type: "tool_use", - id: WAKE_TOOL_USE_ID, - name: "Bash", - input: { - command: "python3 audit.py > audit.log 2>&1", - run_in_background: true, - }, + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-roster-fallback"), + text: "Run the build in the background.", + attachments: [], + }), + ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "assistant", + message: { + model: "claude-sonnet-4-6", + id: "msg_background_command", + type: "message", + role: "assistant", + content: [ + { + type: "tool_use", + id: WAKE_TOOL_USE_ID, + name: "Bash", + input: { + command: "python3 audit.py > audit.log 2>&1", + run_in_background: true, }, - ], - }, - parent_tool_use_id: null, - uuid: "background-command-launch", - session_id: WAKE_NATIVE_SESSION, - }), - ); - yield* Queue.offer(harness.sdkMessages, wakeTaskStarted); - yield* Queue.offer(harness.sdkMessages, turnOneResult); - yield* harness.awaitTerminal(1); + }, + ], + }, + parent_tool_use_id: null, + uuid: "background-command-launch", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* Queue.offer(harness.sdkMessages, wakeTaskStarted); + yield* Queue.offer(harness.sdkMessages, turnOneResult); + yield* harness.awaitTerminal(1); - const afterStart = providerThreadRosterEvents(harness.events).filter( - (event) => (event.providerThread.pendingBackgroundTasks?.length ?? 0) > 0, - ); - assert.isAtLeast(afterStart.length, 1); - const started = (afterStart.at(-1)?.providerThread.pendingBackgroundTasks ?? [])[0]; - assert.equal(started?.taskId, WAKE_TASK_ID); - assert.equal(started?.nativeItemId, WAKE_TOOL_USE_ID); + const afterStart = providerThreadRosterEvents(harness.events).filter( + (event) => (event.providerThread.pendingBackgroundTasks?.length ?? 0) > 0, + ); + assert.isAtLeast(afterStart.length, 1); + const started = (afterStart.at(-1)?.providerThread.pendingBackgroundTasks ?? [])[0]; + assert.equal(started?.taskId, WAKE_TASK_ID); + assert.equal(started?.nativeItemId, WAKE_TOOL_USE_ID); - const command = harness.events.find( - (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "command_execution" && - event.turnItem.nativeItemRef?.nativeId === WAKE_TOOL_USE_ID, - ); - assert.equal(command?.type, "turn_item.updated"); - if (command?.type !== "turn_item.updated") return; - const details = { - command: "python3 audit.py > audit.log 2>&1", - itemId: command.turnItem.id, - }; - assert.deepInclude( - afterStart.at(-1)?.providerThread.pendingBackgroundTasks?.[0], - details, - ); - yield* harness.offerAndWait( - claudeSdkFrame({ - type: "system", - subtype: "background_tasks_changed", - tasks: [{ task_id: WAKE_TASK_ID, task_type: "local_bash", description: "Audit" }], - uuid: "background-command-snapshot", - session_id: WAKE_NATIVE_SESSION, - }), - ); - assert.deepInclude( - providerThreadRosterEvents(harness.events).at(-1)?.providerThread - .pendingBackgroundTasks?.[0], - details, - ); + const command = harness.events.find( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "command_execution" && + event.turnItem.nativeItemRef?.nativeId === WAKE_TOOL_USE_ID, + ); + assert.equal(command?.type, "turn_item.updated"); + if (command?.type !== "turn_item.updated") return; + const details = { + command: "python3 audit.py > audit.log 2>&1", + itemId: command.turnItem.id, + }; + assert.deepInclude(afterStart.at(-1)?.providerThread.pendingBackgroundTasks?.[0], details); + yield* harness.offerAndWait( + claudeSdkFrame({ + type: "system", + subtype: "background_tasks_changed", + tasks: [{ task_id: WAKE_TASK_ID, task_type: "local_bash", description: "Audit" }], + uuid: "background-command-snapshot", + session_id: WAKE_NATIVE_SESSION, + }), + ); + assert.deepInclude( + providerThreadRosterEvents(harness.events).at(-1)?.providerThread + .pendingBackgroundTasks?.[0], + details, + ); - // A foreground command that later moves to the background keeps its tool call too. - yield* Queue.offer( - harness.sdkMessages, - claudeSdkFrame({ - type: "system", - subtype: "task_started", - task_id: "moved-shell", - tool_use_id: "toolu_moved_shell", - description: "Foreground build", - is_backgrounded: false, - task_type: "local_bash", - uuid: "00000000-0000-4000-8000-000000000204", - session_id: WAKE_NATIVE_SESSION, - }), - ); - yield* Queue.offer( - harness.sdkMessages, - claudeSdkFrame({ - type: "system", - subtype: "background_tasks_changed", - tasks: [ - { - task_id: "subagent-shell", - task_type: "local_bash", - description: "sleep 150", - parent_task_id: "agent-1", - }, - { - task_id: WAKE_TASK_ID, - task_type: "local_bash", - description: WAKE_TASK_DESCRIPTION, - }, - { - task_id: "moved-shell", - task_type: "local_bash", - description: "Foreground build", - }, - ], - uuid: "00000000-0000-4000-8000-000000000203", - session_id: WAKE_NATIVE_SESSION, - }), - ); - yield* harness.until(() => - providerThreadRosterEvents(harness.events).some( - (event) => - event.providerThread.pendingBackgroundTasks?.[0]?.parentTaskId === "agent-1" && - event.providerThread.pendingBackgroundTasks[1]?.nativeItemId === WAKE_TOOL_USE_ID && - event.providerThread.pendingBackgroundTasks[2]?.nativeItemId === - "toolu_moved_shell", - ), - ); - - yield* harness.offerAndWait(emptyRoster); - assert.include( - ( - providerThreadRosterEvents(harness.events).at(-1)?.providerThread - .pendingBackgroundTasks ?? [] - ).map((task) => task.taskId), - WAKE_TASK_ID, - ); - assert.isFalse(yield* harness.hasPendingBackgroundWork); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), - ), - ), - ), - ); - - it.effect("holds only the background tasks the agent marks awaited, until they end", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - const awaitBackgroundTasks = harness.runtime.awaitBackgroundTasks; - if (awaitBackgroundTasks === undefined) { - throw new Error("Claude adapter runtime must expose awaitBackgroundTasks."); - } - const roster = (tasks: ReadonlyArray, uuid: string) => + // A foreground command that later moves to the background keeps its tool call too. + yield* Queue.offer( + harness.sdkMessages, claudeSdkFrame({ type: "system", - subtype: "background_tasks_changed", - tasks: tasks.map((taskId) => ({ - task_id: taskId, - task_type: "local_bash", - description: taskId, - })), - uuid, + subtype: "task_started", + task_id: "moved-shell", + tool_use_id: "toolu_moved_shell", + description: "Foreground build", + is_backgrounded: false, + task_type: "local_bash", + uuid: "00000000-0000-4000-8000-000000000204", session_id: WAKE_NATIVE_SESSION, - }); - const nextRoster = Effect.fn("nextRoster")(function* ( - matches: (tasks: ReadonlyArray) => boolean, - ) { - while (true) { - const { providerThread } = yield* Queue.take(harness.providerThreadReceipts); - const tasks = providerThread.pendingBackgroundTasks ?? []; - if (matches(tasks)) return tasks; - } - }); - const marks = (tasks: ReadonlyArray) => - tasks.map((task) => [task.taskId, task.awaited === true]); - - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-awaited-roster"), - text: "Run the tests in the background and the dev server.", - attachments: [], - }), - ); - yield* Queue.offer( - harness.sdkMessages, - roster([WAKE_TASK_ID, "dev-server"], "00000000-0000-4000-8000-000000000301"), - ); - yield* nextRoster((tasks) => tasks.length === 2); - - const marked = yield* awaitBackgroundTasks({ - providerThread: harness.providerThread, - taskIds: [WAKE_TASK_ID, "already-finished"], - awaited: true, - }); - assert.deepStrictEqual(marked.matched, [WAKE_TASK_ID]); - assert.deepStrictEqual( - marks(yield* nextRoster((tasks) => tasks.some((task) => task.awaited === true))), - [ - [WAKE_TASK_ID, true], - ["dev-server", false], - ], - ); - // A replacing level rebuilds every entry; the mark must survive it. - yield* Queue.offer( - harness.sdkMessages, - roster(["dev-server", WAKE_TASK_ID], "00000000-0000-4000-8000-000000000302"), - ); - assert.deepStrictEqual( - marks(yield* nextRoster((tasks) => tasks[0]?.taskId === "dev-server")), - [ - ["dev-server", false], - [WAKE_TASK_ID, true], - ], - ); - - // Once the task ends, a reused id starts unmarked. - yield* Queue.offer( - harness.sdkMessages, - claudeSdkFrame({ ...wakeNotification, uuid: "00000000-0000-4000-8000-000000000303" }), - ); - yield* nextRoster((tasks) => tasks.length === 1); - yield* Queue.offer( - harness.sdkMessages, - roster(["dev-server", WAKE_TASK_ID], "00000000-0000-4000-8000-000000000304"), - ); - assert.deepStrictEqual(marks(yield* nextRoster((tasks) => tasks.length === 2)), [ - ["dev-server", false], - [WAKE_TASK_ID, false], - ]); - - yield* awaitBackgroundTasks({ - providerThread: harness.providerThread, - taskIds: ["dev-server"], - awaited: true, - }); - assert.deepStrictEqual( - marks(yield* nextRoster((tasks) => tasks.some((task) => task.awaited === true))), - [ - ["dev-server", true], - [WAKE_TASK_ID, false], - ], - ); - const released = yield* awaitBackgroundTasks({ - providerThread: harness.providerThread, - taskIds: ["dev-server"], - awaited: false, - }); - assert.deepStrictEqual(released.matched, ["dev-server"]); - assert.deepStrictEqual( - marks(yield* nextRoster((tasks) => !tasks.some((task) => task.awaited === true))), - [ - ["dev-server", false], - [WAKE_TASK_ID, false], - ], - ); - yield* Queue.offer(harness.sdkMessages, turnOneResult); - yield* Queue.take(harness.terminalReceipts); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), - ), - ), - ), - ); - - it.effect("keeps a running Claude monitor typed after many newer monitors end", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - let frameNumber = 0; - const nextUuid = () => `00000000-0000-4000-8000-${String(++frameNumber).padStart(12, "0")}`; - const monitorFrames = (index: number) => { - const taskId = `monitor-task-${index}`; - const toolUseId = `toolu_monitor_${index}`; - const description = `Monitor ${index}`; - return { - taskId, - start: [ - claudeSdkFrame({ - type: "assistant", - message: { - model: "claude-sonnet-4-6", - id: `msg_monitor_${index}`, - type: "message", - role: "assistant", - content: [ - { - type: "tool_use", - id: toolUseId, - name: "Monitor", - input: { description, command: "sleep 8 && echo MONITOR_DONE" }, - }, - ], - stop_reason: null, - stop_sequence: null, - usage: { input_tokens: 1, output_tokens: 1 }, - }, - parent_tool_use_id: null, - uuid: nextUuid(), - session_id: WAKE_NATIVE_SESSION, - }), - claudeSdkFrame({ - type: "system", - subtype: "task_started", - task_id: taskId, - tool_use_id: toolUseId, - description, - is_backgrounded: true, - task_type: "local_bash", - uuid: nextUuid(), - session_id: WAKE_NATIVE_SESSION, - }), - claudeSdkFrame({ - type: "user", - message: { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: toolUseId, - content: `Monitor started (task ${taskId}).`, - }, - ], - }, - parent_tool_use_id: null, - uuid: nextUuid(), - session_id: WAKE_NATIVE_SESSION, - tool_use_result: { taskId, persistent: false }, - }), - ], - end: claudeSdkFrame({ - type: "system", - subtype: "task_notification", - task_id: taskId, - tool_use_id: toolUseId, - status: "completed", - output_file: `/tmp/claude-replay/tasks/${taskId}.output`, - summary: `Monitor "${description}" stream ended`, - uuid: nextUuid(), - session_id: WAKE_NATIVE_SESSION, - }), - }; - }; - - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-many-monitors"), - text: "Watch the deploy, then re-arm short watches.", - attachments: [], }), ); - const longRunning = monitorFrames(0); - for (const frame of longRunning.start) { - yield* Queue.offer(harness.sdkMessages, frame); - } - for (let index = 1; index <= 65; index++) { - const monitor = monitorFrames(index); - for (const frame of monitor.start) { - yield* Queue.offer(harness.sdkMessages, frame); - } - yield* Queue.offer(harness.sdkMessages, monitor.end); - } yield* Queue.offer( harness.sdkMessages, claudeSdkFrame({ @@ -11479,31 +11124,44 @@ describe("ClaudeAdapterV2 background wake turns", () => { subtype: "background_tasks_changed", tasks: [ { - task_id: longRunning.taskId, + task_id: "subagent-shell", + task_type: "local_bash", + description: "sleep 150", + parent_task_id: "agent-1", + }, + { + task_id: WAKE_TASK_ID, task_type: "local_bash", - description: "Monitor 0", + description: WAKE_TASK_DESCRIPTION, + }, + { + task_id: "moved-shell", + task_type: "local_bash", + description: "Foreground build", }, ], - uuid: nextUuid(), + uuid: "00000000-0000-4000-8000-000000000203", session_id: WAKE_NATIVE_SESSION, }), ); - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ uuid: nextUuid(), result: "Watching." }), + yield* harness.until(() => + providerThreadRosterEvents(harness.events).some( + (event) => + event.providerThread.pendingBackgroundTasks?.[0]?.parentTaskId === "agent-1" && + event.providerThread.pendingBackgroundTasks[1]?.nativeItemId === WAKE_TOOL_USE_ID && + event.providerThread.pendingBackgroundTasks[2]?.nativeItemId === "toolu_moved_shell", + ), ); - yield* harness.awaitTerminal(1); - const roster = providerThreadRosterEvents(harness.events).at(-1)?.providerThread - .pendingBackgroundTasks; - assert.deepEqual(roster, [ - { - taskId: longRunning.taskId, - kind: "monitor", - description: "Monitor 0", - nativeItemId: "toolu_monitor_0", - }, - ]); + yield* harness.offerAndWait(emptyRoster); + assert.include( + ( + providerThreadRosterEvents(harness.events).at(-1)?.providerThread + .pendingBackgroundTasks ?? [] + ).map((task) => task.taskId), + WAKE_TASK_ID, + ); + assert.isFalse(yield* harness.hasPendingBackgroundWork); }).pipe( Effect.provide( Layer.mergeAll( @@ -11514,51 +11172,322 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ), ), - ), ); - const scheduleWakeupFrames = (input: { - readonly toolUseId: string; - readonly uuids: readonly [string, string]; - readonly toolInput: Record; - readonly scheduledFor: number; - }) => [ - claudeSdkFrame({ - type: "assistant", - message: { - model: "claude-sonnet-4-6", - id: `msg_${input.toolUseId}`, - type: "message", - role: "assistant", - content: [ - { type: "tool_use", id: input.toolUseId, name: "ScheduleWakeup", input: input.toolInput }, - ], - stop_reason: null, - stop_sequence: null, - usage: { input_tokens: 1, output_tokens: 1 }, - }, - parent_tool_use_id: null, - uuid: input.uuids[0], - session_id: WAKE_NATIVE_SESSION, - }), - claudeSdkFrame({ - type: "user", - message: { - role: "user", - content: [{ type: "tool_result", tool_use_id: input.toolUseId, content: "Scheduled." }], - }, - parent_tool_use_id: null, - uuid: input.uuids[1], - session_id: WAKE_NATIVE_SESSION, - tool_use_result: { - scheduledFor: input.scheduledFor, - clampedDelaySeconds: 60, - wasClamped: false, - }, - }), - ]; + it.effect("holds only the background tasks the agent marks awaited, until they end", () => + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const awaitBackgroundTasks = harness.runtime.awaitBackgroundTasks; + if (awaitBackgroundTasks === undefined) { + throw new Error("Claude adapter runtime must expose awaitBackgroundTasks."); + } + const roster = (tasks: ReadonlyArray, uuid: string) => + claudeSdkFrame({ + type: "system", + subtype: "background_tasks_changed", + tasks: tasks.map((taskId) => ({ + task_id: taskId, + task_type: "local_bash", + description: taskId, + })), + uuid, + session_id: WAKE_NATIVE_SESSION, + }); + const nextRoster = Effect.fn("nextRoster")(function* ( + matches: (tasks: ReadonlyArray) => boolean, + ) { + while (true) { + const { providerThread } = yield* Queue.take(harness.providerThreadReceipts); + const tasks = providerThread.pendingBackgroundTasks ?? []; + if (matches(tasks)) return tasks; + } + }); + const marks = (tasks: ReadonlyArray) => + tasks.map((task) => [task.taskId, task.awaited === true]); - const startScheduleWakeupTurn = Effect.fnUntraced(function* ( + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-awaited-roster"), + text: "Run the tests in the background and the dev server.", + attachments: [], + }), + ); + yield* Queue.offer( + harness.sdkMessages, + roster([WAKE_TASK_ID, "dev-server"], "00000000-0000-4000-8000-000000000301"), + ); + yield* nextRoster((tasks) => tasks.length === 2); + + const marked = yield* awaitBackgroundTasks({ + providerThread: harness.providerThread, + taskIds: [WAKE_TASK_ID, "already-finished"], + awaited: true, + }); + assert.deepStrictEqual(marked.matched, [WAKE_TASK_ID]); + assert.deepStrictEqual( + marks(yield* nextRoster((tasks) => tasks.some((task) => task.awaited === true))), + [ + [WAKE_TASK_ID, true], + ["dev-server", false], + ], + ); + // A replacing level rebuilds every entry; the mark must survive it. + yield* Queue.offer( + harness.sdkMessages, + roster(["dev-server", WAKE_TASK_ID], "00000000-0000-4000-8000-000000000302"), + ); + assert.deepStrictEqual( + marks(yield* nextRoster((tasks) => tasks[0]?.taskId === "dev-server")), + [ + ["dev-server", false], + [WAKE_TASK_ID, true], + ], + ); + + // Once the task ends, a reused id starts unmarked. + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ ...wakeNotification, uuid: "00000000-0000-4000-8000-000000000303" }), + ); + yield* nextRoster((tasks) => tasks.length === 1); + yield* Queue.offer( + harness.sdkMessages, + roster(["dev-server", WAKE_TASK_ID], "00000000-0000-4000-8000-000000000304"), + ); + assert.deepStrictEqual(marks(yield* nextRoster((tasks) => tasks.length === 2)), [ + ["dev-server", false], + [WAKE_TASK_ID, false], + ]); + + yield* awaitBackgroundTasks({ + providerThread: harness.providerThread, + taskIds: ["dev-server"], + awaited: true, + }); + assert.deepStrictEqual( + marks(yield* nextRoster((tasks) => tasks.some((task) => task.awaited === true))), + [ + ["dev-server", true], + [WAKE_TASK_ID, false], + ], + ); + const released = yield* awaitBackgroundTasks({ + providerThread: harness.providerThread, + taskIds: ["dev-server"], + awaited: false, + }); + assert.deepStrictEqual(released.matched, ["dev-server"]); + assert.deepStrictEqual( + marks(yield* nextRoster((tasks) => !tasks.some((task) => task.awaited === true))), + [ + ["dev-server", false], + [WAKE_TASK_ID, false], + ], + ); + yield* Queue.offer(harness.sdkMessages, turnOneResult); + yield* Queue.take(harness.terminalReceipts); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + + it.effect("keeps a running Claude monitor typed after many newer monitors end", () => + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + let frameNumber = 0; + const nextUuid = () => `00000000-0000-4000-8000-${String(++frameNumber).padStart(12, "0")}`; + const monitorFrames = (index: number) => { + const taskId = `monitor-task-${index}`; + const toolUseId = `toolu_monitor_${index}`; + const description = `Monitor ${index}`; + return { + taskId, + start: [ + claudeSdkFrame({ + type: "assistant", + message: { + model: "claude-sonnet-4-6", + id: `msg_monitor_${index}`, + type: "message", + role: "assistant", + content: [ + { + type: "tool_use", + id: toolUseId, + name: "Monitor", + input: { description, command: "sleep 8 && echo MONITOR_DONE" }, + }, + ], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 1, output_tokens: 1 }, + }, + parent_tool_use_id: null, + uuid: nextUuid(), + session_id: WAKE_NATIVE_SESSION, + }), + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: taskId, + tool_use_id: toolUseId, + description, + is_backgrounded: true, + task_type: "local_bash", + uuid: nextUuid(), + session_id: WAKE_NATIVE_SESSION, + }), + claudeSdkFrame({ + type: "user", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: toolUseId, + content: `Monitor started (task ${taskId}).`, + }, + ], + }, + parent_tool_use_id: null, + uuid: nextUuid(), + session_id: WAKE_NATIVE_SESSION, + tool_use_result: { taskId, persistent: false }, + }), + ], + end: claudeSdkFrame({ + type: "system", + subtype: "task_notification", + task_id: taskId, + tool_use_id: toolUseId, + status: "completed", + output_file: `/tmp/claude-replay/tasks/${taskId}.output`, + summary: `Monitor "${description}" stream ended`, + uuid: nextUuid(), + session_id: WAKE_NATIVE_SESSION, + }), + }; + }; + + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-many-monitors"), + text: "Watch the deploy, then re-arm short watches.", + attachments: [], + }), + ); + const longRunning = monitorFrames(0); + for (const frame of longRunning.start) { + yield* Queue.offer(harness.sdkMessages, frame); + } + for (let index = 1; index <= 65; index++) { + const monitor = monitorFrames(index); + for (const frame of monitor.start) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* Queue.offer(harness.sdkMessages, monitor.end); + } + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "system", + subtype: "background_tasks_changed", + tasks: [ + { + task_id: longRunning.taskId, + task_type: "local_bash", + description: "Monitor 0", + }, + ], + uuid: nextUuid(), + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ uuid: nextUuid(), result: "Watching." }), + ); + yield* harness.awaitTerminal(1); + + const roster = providerThreadRosterEvents(harness.events).at(-1)?.providerThread + .pendingBackgroundTasks; + assert.deepEqual(roster, [ + { + taskId: longRunning.taskId, + kind: "monitor", + description: "Monitor 0", + nativeItemId: "toolu_monitor_0", + }, + ]); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + + const scheduleWakeupFrames = (input: { + readonly toolUseId: string; + readonly uuids: readonly [string, string]; + readonly toolInput: Record; + readonly scheduledFor: number; + }) => [ + claudeSdkFrame({ + type: "assistant", + message: { + model: "claude-sonnet-4-6", + id: `msg_${input.toolUseId}`, + type: "message", + role: "assistant", + content: [ + { type: "tool_use", id: input.toolUseId, name: "ScheduleWakeup", input: input.toolInput }, + ], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 1, output_tokens: 1 }, + }, + parent_tool_use_id: null, + uuid: input.uuids[0], + session_id: WAKE_NATIVE_SESSION, + }), + claudeSdkFrame({ + type: "user", + message: { + role: "user", + content: [{ type: "tool_result", tool_use_id: input.toolUseId, content: "Scheduled." }], + }, + parent_tool_use_id: null, + uuid: input.uuids[1], + session_id: WAKE_NATIVE_SESSION, + tool_use_result: { + scheduledFor: input.scheduledFor, + clampedDelaySeconds: 60, + wasClamped: false, + }, + }), + ]; + + const startScheduleWakeupTurn = Effect.fnUntraced(function* ( harness: Effect.Success, result = makeResultFrame({ uuid: "00000000-0000-4000-8000-000000002103", @@ -11599,97 +11528,52 @@ describe("ClaudeAdapterV2 background wake turns", () => { ] as const)( "a fired ScheduleWakeup holds completion only while its wake turn reschedules (rescheduled=$rescheduled, fired after $firedAfter)", ({ rescheduled, firedAfter }) => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - yield* startScheduleWakeupTurn(harness); - assert.isTrue(backgroundWorkHoldsCompletion(publishedRoster(harness))); - assert.isTrue( - yield* harness.hasPendingBackgroundWork, - "a wakeup past the idle timeout must keep the session from being released", - ); - - yield* TestClock.adjust(firedAfter); - yield* harness.offerAndWait(wakeTurnInit); - yield* harness.awaitContinuation(1); - const now = yield* DateTime.now; - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-schedule-wakeup-2"), - text: "say WOKE", - attachments: [], - providerTurnOrdinal: 2, - messageCreatedBy: "agent", - messageCreationSource: "provider", - }), - ); - if (rescheduled) { - for (const frame of scheduleWakeupFrames({ - toolUseId: "toolu_schedule_wakeup_2", - uuids: [ - "00000000-0000-4000-8000-000000002201", - "00000000-0000-4000-8000-000000002202", - ], - toolInput: { delaySeconds: 60, prompt: "say WOKE", reason: "loop", noop: false }, - scheduledFor: DateTime.toEpochMillis(now) + 60_000, - })) { - yield* harness.offerAndWait(frame); - } - } - yield* harness.offerAndWait( - makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000002203", text: "WOKE" }), - ); - yield* harness.offerAndWait( - makeResultFrame({ uuid: "00000000-0000-4000-8000-000000002204", result: "WOKE" }), - ); - yield* harness.awaitTerminal(2); - assert.equal(harness.terminalEvents()[1]?.status, "completed"); - assert.equal(backgroundWorkHoldsCompletion(publishedRoster(harness)), rescheduled); - assert.equal(yield* harness.hasPendingBackgroundWork, rescheduled); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), - ), - ), - ), - ); - - it.effect("a user turn before the wakeup fires keeps the pending wakeup's hold", () => - Effect.scoped( Effect.gen(function* () { const harness = yield* makeWakeHarness; yield* startScheduleWakeupTurn(harness); - yield* TestClock.adjust("10 seconds"); + assert.isTrue(backgroundWorkHoldsCompletion(publishedRoster(harness))); + assert.isTrue( + yield* harness.hasPendingBackgroundWork, + "a wakeup past the idle timeout must keep the session from being released", + ); + + yield* TestClock.adjust(firedAfter); + yield* harness.offerAndWait(wakeTurnInit); + yield* harness.awaitContinuation(1); + const now = yield* DateTime.now; yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ threadId: harness.threadId, providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("attempt-claude-schedule-wakeup-user"), - text: "What time is it?", + now, + attemptId: RunAttemptId.make("attempt-claude-schedule-wakeup-2"), + text: "say WOKE", attachments: [], providerTurnOrdinal: 2, + messageCreatedBy: "agent", + messageCreationSource: "provider", }), ); - yield* harness.offerAndWait(wakeTurnInit); + if (rescheduled) { + for (const frame of scheduleWakeupFrames({ + toolUseId: "toolu_schedule_wakeup_2", + uuids: ["00000000-0000-4000-8000-000000002201", "00000000-0000-4000-8000-000000002202"], + toolInput: { delaySeconds: 60, prompt: "say WOKE", reason: "loop", noop: false }, + scheduledFor: DateTime.toEpochMillis(now) + 60_000, + })) { + yield* harness.offerAndWait(frame); + } + } yield* harness.offerAndWait( - makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000002401", text: "Noon." }), + makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000002203", text: "WOKE" }), ); yield* harness.offerAndWait( - makeResultFrame({ uuid: "00000000-0000-4000-8000-000000002402", result: "Noon." }), + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000002204", result: "WOKE" }), ); yield* harness.awaitTerminal(2); assert.equal(harness.terminalEvents()[1]?.status, "completed"); - assert.isTrue(backgroundWorkHoldsCompletion(publishedRoster(harness))); - assert.isTrue(yield* harness.hasPendingBackgroundWork); + assert.equal(backgroundWorkHoldsCompletion(publishedRoster(harness)), rescheduled); + assert.equal(yield* harness.hasPendingBackgroundWork, rescheduled); }).pipe( Effect.provide( Layer.mergeAll( @@ -11700,11 +11584,129 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ), ), + ); + + it.effect("a user turn before the wakeup fires keeps the pending wakeup's hold", () => + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + yield* startScheduleWakeupTurn(harness); + yield* TestClock.adjust("10 seconds"); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-claude-schedule-wakeup-user"), + text: "What time is it?", + attachments: [], + providerTurnOrdinal: 2, + }), + ); + yield* harness.offerAndWait(wakeTurnInit); + yield* harness.offerAndWait( + makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000002401", text: "Noon." }), + ); + yield* harness.offerAndWait( + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000002402", result: "Noon." }), + ); + yield* harness.awaitTerminal(2); + assert.equal(harness.terminalEvents()[1]?.status, "completed"); + assert.isTrue(backgroundWorkHoldsCompletion(publishedRoster(harness))); + assert.isTrue(yield* harness.hasPendingBackgroundWork); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), ), ); it.effect("a task notification waking Claude just before the wakeup keeps its hold", () => - Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-schedule-wakeup-task"), + text: "Run the build in the background and check back in two minutes.", + attachments: [], + }), + ); + yield* Queue.offer(harness.sdkMessages, wakeTaskStarted); + for (const frame of scheduleWakeupFrames({ + toolUseId: "toolu_schedule_wakeup_fallback", + uuids: ["00000000-0000-4000-8000-000000002501", "00000000-0000-4000-8000-000000002502"], + toolInput: { delaySeconds: 120, prompt: "check the build", reason: "fallback" }, + scheduledFor: DateTime.toEpochMillis(now) + 120_000, + })) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* Queue.offer(harness.sdkMessages, turnOneResult); + yield* harness.awaitTerminal(1); + + yield* TestClock.adjust("60 seconds"); + // Claude runs an empty turn for the notification and opens the real one right after, + // both before the wake run attaches. + yield* harness.offerAndWait(wakeNotification); + yield* Queue.offer(harness.sdkMessages, wakeTurnInit); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000002503", + result: "", + numTurns: 0, + origin: { kind: "task-notification" }, + terminalReason: null, + }), + ); + yield* harness.offerAndWait( + claudeSdkFrame({ ...wakeTurnInit, uuid: "00000000-0000-4000-8000-000000002504" }), + ); + yield* harness.awaitContinuation(1); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-claude-schedule-wakeup-task-2"), + text: "Background task completed.", + attachments: [], + providerTurnOrdinal: 2, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + yield* harness.offerAndWait(wakeAssistant); + yield* harness.offerAndWait(wakeResult); + yield* harness.awaitTerminal(2); + assert.equal(harness.terminalEvents()[1]?.status, "completed"); + assert.isTrue( + backgroundWorkHoldsCompletion(publishedRoster(harness)), + "the wakeup is still pending after the task notification's wake turn", + ); + assert.isTrue(yield* harness.hasPendingBackgroundWork); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + + it.effect.each([{ emptyResult: true }, { emptyResult: false }])( + "an early wakeup after an idle task notification releases its hold (empty result: $emptyResult)", + ({ emptyResult }) => Effect.gen(function* () { const harness = yield* makeWakeHarness; const now = yield* DateTime.now; @@ -11713,15 +11715,15 @@ describe("ClaudeAdapterV2 background wake turns", () => { threadId: harness.threadId, providerThread: harness.providerThread, now, - attemptId: RunAttemptId.make("attempt-claude-schedule-wakeup-task"), + attemptId: RunAttemptId.make("attempt-claude-schedule-wakeup-notified"), text: "Run the build in the background and check back in two minutes.", attachments: [], }), ); yield* Queue.offer(harness.sdkMessages, wakeTaskStarted); for (const frame of scheduleWakeupFrames({ - toolUseId: "toolu_schedule_wakeup_fallback", - uuids: ["00000000-0000-4000-8000-000000002501", "00000000-0000-4000-8000-000000002502"], + toolUseId: "toolu_schedule_wakeup_notified", + uuids: ["00000000-0000-4000-8000-000000002601", "00000000-0000-4000-8000-000000002602"], toolInput: { delaySeconds: 120, prompt: "check the build", reason: "fallback" }, scheduledFor: DateTime.toEpochMillis(now) + 120_000, })) { @@ -11730,47 +11732,49 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* Queue.offer(harness.sdkMessages, turnOneResult); yield* harness.awaitTerminal(1); - yield* TestClock.adjust("60 seconds"); - // Claude runs an empty turn for the notification and opens the real one right after, - // both before the wake run attaches. + yield* TestClock.adjust("10 seconds"); yield* harness.offerAndWait(wakeNotification); - yield* Queue.offer(harness.sdkMessages, wakeTurnInit); - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000002503", - result: "", - numTurns: 0, - origin: { kind: "task-notification" }, - terminalReason: null, - }), - ); - yield* harness.offerAndWait( - claudeSdkFrame({ ...wakeTurnInit, uuid: "00000000-0000-4000-8000-000000002504" }), - ); + if (emptyResult) { + yield* harness.offerAndWait( + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000002603", + result: "", + numTurns: 0, + origin: { kind: "task-notification" }, + terminalReason: null, + }), + ); + } + + yield* TestClock.adjust("80 seconds"); + yield* harness.offerAndWait(wakeTurnInit); yield* harness.awaitContinuation(1); yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ threadId: harness.threadId, providerThread: harness.providerThread, now: yield* DateTime.now, - attemptId: RunAttemptId.make("attempt-claude-schedule-wakeup-task-2"), - text: "Background task completed.", + attemptId: RunAttemptId.make("attempt-claude-schedule-wakeup-notified-2"), + text: "check the build", attachments: [], providerTurnOrdinal: 2, messageCreatedBy: "agent", messageCreationSource: "provider", }), ); - yield* harness.offerAndWait(wakeAssistant); - yield* harness.offerAndWait(wakeResult); + yield* harness.offerAndWait( + makeAssistantTextFrame({ + uuid: "00000000-0000-4000-8000-000000002604", + text: "Built.", + }), + ); + yield* harness.offerAndWait( + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000002605", result: "Built." }), + ); yield* harness.awaitTerminal(2); assert.equal(harness.terminalEvents()[1]?.status, "completed"); - assert.isTrue( - backgroundWorkHoldsCompletion(publishedRoster(harness)), - "the wakeup is still pending after the task notification's wake turn", - ); - assert.isTrue(yield* harness.hasPendingBackgroundWork); + assert.isFalse(backgroundWorkHoldsCompletion(publishedRoster(harness))); + assert.isFalse(yield* harness.hasPendingBackgroundWork); }).pipe( Effect.provide( Layer.mergeAll( @@ -11781,205 +11785,113 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ), ), - ), ); - it.effect.each([{ emptyResult: true }, { emptyResult: false }])( - "an early wakeup after an idle task notification releases its hold (empty result: $emptyResult)", - ({ emptyResult }) => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-schedule-wakeup-notified"), - text: "Run the build in the background and check back in two minutes.", - attachments: [], - }), - ); - yield* Queue.offer(harness.sdkMessages, wakeTaskStarted); - for (const frame of scheduleWakeupFrames({ - toolUseId: "toolu_schedule_wakeup_notified", - uuids: ["00000000-0000-4000-8000-000000002601", "00000000-0000-4000-8000-000000002602"], - toolInput: { delaySeconds: 120, prompt: "check the build", reason: "fallback" }, - scheduledFor: DateTime.toEpochMillis(now) + 120_000, - })) { - yield* Queue.offer(harness.sdkMessages, frame); - } - yield* Queue.offer(harness.sdkMessages, turnOneResult); - yield* harness.awaitTerminal(1); - - yield* TestClock.adjust("10 seconds"); - yield* harness.offerAndWait(wakeNotification); - if (emptyResult) { - yield* harness.offerAndWait( - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000002603", - result: "", - numTurns: 0, - origin: { kind: "task-notification" }, - terminalReason: null, - }), - ); - } - - yield* TestClock.adjust("80 seconds"); - yield* harness.offerAndWait(wakeTurnInit); - yield* harness.awaitContinuation(1); - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("attempt-claude-schedule-wakeup-notified-2"), - text: "check the build", - attachments: [], - providerTurnOrdinal: 2, - messageCreatedBy: "agent", - messageCreationSource: "provider", - }), - ); - yield* harness.offerAndWait( - makeAssistantTextFrame({ - uuid: "00000000-0000-4000-8000-000000002604", - text: "Built.", - }), - ); - yield* harness.offerAndWait( - makeResultFrame({ uuid: "00000000-0000-4000-8000-000000002605", result: "Built." }), - ); - yield* harness.awaitTerminal(2); - assert.equal(harness.terminalEvents()[1]?.status, "completed"); - assert.isFalse(backgroundWorkHoldsCompletion(publishedRoster(harness))); - assert.isFalse(yield* harness.hasPendingBackgroundWork); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), - ), + it.effect("a failed turn on a live Claude process keeps the pending wakeup's hold", () => + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + yield* startScheduleWakeupTurn( + harness, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000002103", + result: "API Error: 529 overloaded", + subtype: "error_during_execution", + isError: true, + errors: ["API Error: 529 overloaded"], + terminalReason: "model_error", + }), + ); + assert.equal(harness.terminalEvents()[0]?.status, "failed"); + assert.isTrue(backgroundWorkHoldsCompletion(publishedRoster(harness))); + assert.isTrue(yield* harness.hasPendingBackgroundWork); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), + ), ); - it.effect("a failed turn on a live Claude process keeps the pending wakeup's hold", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - yield* startScheduleWakeupTurn( - harness, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000002103", - result: "API Error: 529 overloaded", - subtype: "error_during_execution", - isError: true, - errors: ["API Error: 529 overloaded"], - terminalReason: "model_error", - }), - ); - assert.equal(harness.terminalEvents()[0]?.status, "failed"); - assert.isTrue(backgroundWorkHoldsCompletion(publishedRoster(harness))); - assert.isTrue(yield* harness.hasPendingBackgroundWork); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), - ), - ), - ), - ); - - it.effect("ScheduleWakeup stop:true releases the pending wakeup's hold", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-schedule-wakeup-stop"), - text: "Schedule a wakeup, then cancel it.", - attachments: [], - }), - ); - const frames = [ - ...scheduleWakeupFrames({ - toolUseId: "toolu_schedule_wakeup_set", - uuids: ["00000000-0000-4000-8000-000000002301", "00000000-0000-4000-8000-000000002302"], - toolInput: { delaySeconds: 60, prompt: "say WOKE", reason: "wake", noop: false }, - scheduledFor: DateTime.toEpochMillis(now) + 60_000, - }), - ...scheduleWakeupFrames({ - toolUseId: "toolu_schedule_wakeup_stop", - uuids: ["00000000-0000-4000-8000-000000002303", "00000000-0000-4000-8000-000000002304"], - toolInput: { stop: true }, - scheduledFor: 0, - }), - makeResultFrame({ uuid: "00000000-0000-4000-8000-000000002305", result: "Cancelled." }), - ]; - for (const frame of frames) yield* Queue.offer(harness.sdkMessages, frame); - yield* harness.awaitTerminal(1); - assert.isFalse(backgroundWorkHoldsCompletion(publishedRoster(harness))); - assert.isFalse(yield* harness.hasPendingBackgroundWork); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + it.effect("ScheduleWakeup stop:true releases the pending wakeup's hold", () => + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-schedule-wakeup-stop"), + text: "Schedule a wakeup, then cancel it.", + attachments: [], + }), + ); + const frames = [ + ...scheduleWakeupFrames({ + toolUseId: "toolu_schedule_wakeup_set", + uuids: ["00000000-0000-4000-8000-000000002301", "00000000-0000-4000-8000-000000002302"], + toolInput: { delaySeconds: 60, prompt: "say WOKE", reason: "wake", noop: false }, + scheduledFor: DateTime.toEpochMillis(now) + 60_000, + }), + ...scheduleWakeupFrames({ + toolUseId: "toolu_schedule_wakeup_stop", + uuids: ["00000000-0000-4000-8000-000000002303", "00000000-0000-4000-8000-000000002304"], + toolInput: { stop: true }, + scheduledFor: 0, + }), + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000002305", result: "Cancelled." }), + ]; + for (const frame of frames) yield* Queue.offer(harness.sdkMessages, frame); + yield* harness.awaitTerminal(1); + assert.isFalse(backgroundWorkHoldsCompletion(publishedRoster(harness))); + assert.isFalse(yield* harness.hasPendingBackgroundWork); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), ); it.effect("Stop on a thread waiting for a ScheduleWakeup closes Claude and drops the hold", () => - Effect.scoped( - Effect.gen(function* () { - let closes = 0; - const harness = yield* makeWakeHarnessWithOptions({ - close: (sdkMessages) => - Effect.sync(() => { - closes++; - }).pipe(Effect.andThen(Queue.shutdown(sdkMessages))), - }); - yield* startScheduleWakeupTurn(harness); - const settledThread = providerThreadRosterEvents(harness.events).at(-1)?.providerThread; - assert.isTrue(backgroundWorkHoldsCompletion(settledThread?.pendingBackgroundTasks ?? [])); - // The wakeup alone must not refuse a model or mode change. - assert.isFalse(yield* harness.runtime.hasPendingBackgroundWorkForThread!(settledThread!)); + Effect.gen(function* () { + let closes = 0; + const harness = yield* makeWakeHarnessWithOptions({ + close: (sdkMessages) => + Effect.sync(() => { + closes++; + }).pipe(Effect.andThen(Queue.shutdown(sdkMessages))), + }); + yield* startScheduleWakeupTurn(harness); + const settledThread = providerThreadRosterEvents(harness.events).at(-1)?.providerThread; + assert.isTrue(backgroundWorkHoldsCompletion(settledThread?.pendingBackgroundTasks ?? [])); + // The wakeup alone must not refuse a model or mode change. + assert.isFalse(yield* harness.runtime.hasPendingBackgroundWorkForThread!(settledThread!)); - yield* harness.runtime.interruptTurn({ - providerThread: settledThread ?? harness.providerThread, - providerTurnId: harness.terminalEvents()[0]!.providerTurnId, - requestRuntimeRestart: true, - }); + yield* harness.runtime.interruptTurn({ + providerThread: settledThread ?? harness.providerThread, + providerTurnId: harness.terminalEvents()[0]!.providerTurnId, + requestRuntimeRestart: true, + }); - assert.equal(closes, 1, "the wakeup lives in the CLI process, so Stop must close it"); - yield* harness.until(() => publishedRoster(harness).length === 0); - assert.isFalse(yield* harness.hasPendingBackgroundWork); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + assert.equal(closes, 1, "the wakeup lives in the CLI process, so Stop must close it"); + yield* harness.until(() => publishedRoster(harness).length === 0); + assert.isFalse(yield* harness.hasPendingBackgroundWork); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), @@ -11988,173 +11900,105 @@ describe("ClaudeAdapterV2 background wake turns", () => { it.effect.skipIf(process.getuid === undefined)( "tails foreground Bash output under the physical cwd the query launched in", () => - Effect.scoped( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-claude-task-output-cwd-", - }); - const physicalCwd = path.join(root, "real", "project"); - const launchCwd = path.join(root, "link"); - yield* fileSystem.makeDirectory(physicalCwd, { recursive: true }); - yield* fileSystem.symlink(physicalCwd, launchCwd); - // The session opens in /workspace; a relative TMPDIR puts Claude's - // task files under whichever cwd the CLI was launched in. Linux, - // because Claude Code's macOS build ignores TMPDIR. - const harness = yield* makeWakeHarnessWithOptions({ environment: { TMPDIR: "../tmp" } }); - const now = yield* DateTime.now; - const toolUseId = "toolu_foreground_bash"; - const taskId = "foreground-bash-task"; - const tasks = path.join( - root, - "real", - "tmp", - `claude-${process.getuid?.()}`, - "project", - WAKE_NATIVE_SESSION, - "tasks", - ); - yield* fileSystem.makeDirectory(tasks, { recursive: true }); - yield* fileSystem.writeFileString(path.join(tasks, `${taskId}.output`), "line 1\n"); - - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-task-output-cwd"), - text: "Run the build.", - attachments: [], - runtimePolicy: { ...CLAUDE_TEST_RUNTIME_POLICY, cwd: launchCwd }, - }), - ); - assert.equal(harness.getOpenedOptions()?.cwd, launchCwd); - for (const frame of [ - claudeSdkFrame({ - type: "assistant", - message: { - model: "claude-sonnet-4-6", - id: "msg_foreground_bash", - type: "message", - role: "assistant", - content: [ - { - type: "tool_use", - id: toolUseId, - name: "Bash", - input: { command: "vp run build", description: "Build" }, - }, - ], - stop_reason: null, - stop_sequence: null, - usage: { input_tokens: 1, output_tokens: 1 }, - }, - parent_tool_use_id: null, - uuid: "00000000-0000-4000-8000-000000000a01", - session_id: WAKE_NATIVE_SESSION, - }), - claudeSdkFrame({ - type: "system", - subtype: "task_started", - task_id: taskId, - tool_use_id: toolUseId, - description: "vp run build", - is_backgrounded: false, - task_type: "local_bash", - uuid: "00000000-0000-4000-8000-000000000a02", - session_id: WAKE_NATIVE_SESSION, - }), - claudeSdkFrame({ - type: "user", - message: { - role: "user", - content: [{ type: "tool_result", tool_use_id: toolUseId, content: "line 1" }], - }, - parent_tool_use_id: null, - uuid: "00000000-0000-4000-8000-000000000a03", - session_id: WAKE_NATIVE_SESSION, - tool_use_result: { stdout: "line 1\n", stderr: "", interrupted: false }, - }), - makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000a04", result: "Built." }), - ]) { - yield* Queue.offer(harness.sdkMessages, frame); - } - yield* harness.awaitTerminal(1); - - const chunks = harness.events.flatMap((event) => - event.type === "command_output.delta" ? [event.chunk] : [], - ); - assert.deepEqual(chunks, ["line 1\n"]); - }).pipe( - Effect.provideService(HostProcess.Platform, "linux"), - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), - ), - ), - ), - ); - - it.effect("lets the native SDK acknowledge Stop before closing its own query", () => - Effect.scoped( Effect.gen(function* () { - let closes = 0; - let acknowledge = Effect.void; - const harness = yield* makeWakeHarnessWithOptions({ - interrupt: Effect.suspend(() => { - assert.equal(closes, 0); - return acknowledge; - }), - close: (messages) => - Effect.sync(() => { - closes++; - }).pipe(Effect.andThen(Queue.shutdown(messages))), + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-claude-task-output-cwd-", }); + const physicalCwd = path.join(root, "real", "project"); + const launchCwd = path.join(root, "link"); + yield* fileSystem.makeDirectory(physicalCwd, { recursive: true }); + yield* fileSystem.symlink(physicalCwd, launchCwd); + // The session opens in /workspace; a relative TMPDIR puts Claude's + // task files under whichever cwd the CLI was launched in. Linux, + // because Claude Code's macOS build ignores TMPDIR. + const harness = yield* makeWakeHarnessWithOptions({ environment: { TMPDIR: "../tmp" } }); + const now = yield* DateTime.now; + const toolUseId = "toolu_foreground_bash"; + const taskId = "foreground-bash-task"; + const tasks = path.join( + root, + "real", + "tmp", + `claude-${process.getuid?.()}`, + "project", + WAKE_NATIVE_SESSION, + "tasks", + ); + yield* fileSystem.makeDirectory(tasks, { recursive: true }); + yield* fileSystem.writeFileString(path.join(tasks, `${taskId}.output`), "line 1\n"); + yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ threadId: harness.threadId, providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("graceful-stop"), - text: "Work until stopped", + now, + attemptId: RunAttemptId.make("attempt-claude-task-output-cwd"), + text: "Run the build.", attachments: [], + runtimePolicy: { ...CLAUDE_TEST_RUNTIME_POLICY, cwd: launchCwd }, }), ); - acknowledge = harness.offerAndWait( - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000620", - result: "", - subtype: "error_during_execution", - errors: ["Error: Request was aborted."], - terminalReason: "aborted_streaming", + assert.equal(harness.getOpenedOptions()?.cwd, launchCwd); + for (const frame of [ + claudeSdkFrame({ + type: "assistant", + message: { + model: "claude-sonnet-4-6", + id: "msg_foreground_bash", + type: "message", + role: "assistant", + content: [ + { + type: "tool_use", + id: toolUseId, + name: "Bash", + input: { command: "vp run build", description: "Build" }, + }, + ], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 1, output_tokens: 1 }, + }, + parent_tool_use_id: null, + uuid: "00000000-0000-4000-8000-000000000a01", + session_id: WAKE_NATIVE_SESSION, }), + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: taskId, + tool_use_id: toolUseId, + description: "vp run build", + is_backgrounded: false, + task_type: "local_bash", + uuid: "00000000-0000-4000-8000-000000000a02", + session_id: WAKE_NATIVE_SESSION, + }), + claudeSdkFrame({ + type: "user", + message: { + role: "user", + content: [{ type: "tool_result", tool_use_id: toolUseId, content: "line 1" }], + }, + parent_tool_use_id: null, + uuid: "00000000-0000-4000-8000-000000000a03", + session_id: WAKE_NATIVE_SESSION, + tool_use_result: { stdout: "line 1\n", stderr: "", interrupted: false }, + }), + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000a04", result: "Built." }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* harness.awaitTerminal(1); + + const chunks = harness.events.flatMap((event) => + event.type === "command_output.delta" ? [event.chunk] : [], ); - yield* harness.until(() => - harness.events.some( - (event) => - event.type === "provider_turn.updated" && event.providerTurn.status === "running", - ), - ); - const active = harness.events.find( - (event) => - event.type === "provider_turn.updated" && event.providerTurn.status === "running", - ); - if (active?.type !== "provider_turn.updated") - return yield* Effect.die("Missing running native turn"); - yield* harness.runtime.interruptTurn({ - providerThread: harness.providerThread, - providerTurnId: active.providerTurn.id, - requestRuntimeRestart: true, - }); - assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "interrupted"); - assert.equal(closes, 1); - assert.lengthOf(harness.terminalEvents(), 1); + assert.deepEqual(chunks, ["line 1\n"]); }).pipe( + Effect.provideService(HostProcess.Platform, "linux"), Effect.provide( Layer.mergeAll( IdAllocator.layer, @@ -12164,146 +12008,204 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ), ), - ), ); - it.effect("bounds an unacknowledged native Stop and settles its turn once", () => - Effect.scoped( - Effect.gen(function* () { - let closes = 0; - const interruptStarted = yield* Deferred.make(); - const harness = yield* makeWakeHarnessWithOptions({ - interrupt: Deferred.succeed(interruptStarted, undefined).pipe( - Effect.andThen(Effect.never), - ), - close: (messages) => - Effect.sync(() => { - closes++; - }).pipe(Effect.andThen(Queue.shutdown(messages))), - }); - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("unacknowledged-stop"), - text: "Work until stopped", - attachments: [], - }), - ); - yield* harness.until(() => - harness.events.some( - (event) => - event.type === "provider_turn.updated" && event.providerTurn.status === "running", - ), - ); - const active = harness.events.find( + it.effect("lets the native SDK acknowledge Stop before closing its own query", () => + Effect.gen(function* () { + let closes = 0; + let acknowledge = Effect.void; + const harness = yield* makeWakeHarnessWithOptions({ + interrupt: Effect.suspend(() => { + assert.equal(closes, 0); + return acknowledge; + }), + close: (messages) => + Effect.sync(() => { + closes++; + }).pipe(Effect.andThen(Queue.shutdown(messages))), + }); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("graceful-stop"), + text: "Work until stopped", + attachments: [], + }), + ); + acknowledge = harness.offerAndWait( + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000620", + result: "", + subtype: "error_during_execution", + errors: ["Error: Request was aborted."], + terminalReason: "aborted_streaming", + }), + ); + yield* harness.until(() => + harness.events.some( (event) => event.type === "provider_turn.updated" && event.providerTurn.status === "running", - ); - if (active?.type !== "provider_turn.updated") - return yield* Effect.die("Missing running native turn"); - const stop = yield* harness.runtime - .interruptTurn({ - providerThread: harness.providerThread, - providerTurnId: active.providerTurn.id, - requestRuntimeRestart: true, - }) - .pipe(Effect.forkChild); - yield* Deferred.await(interruptStarted); - assert.equal(closes, 0); - yield* TestClock.adjust("11 seconds"); - yield* Fiber.join(stop); - assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "interrupted"); - assert.equal(closes, 1); - assert.lengthOf(harness.terminalEvents(), 1); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + ), + ); + const active = harness.events.find( + (event) => + event.type === "provider_turn.updated" && event.providerTurn.status === "running", + ); + if (active?.type !== "provider_turn.updated") + return yield* Effect.die("Missing running native turn"); + yield* harness.runtime.interruptTurn({ + providerThread: harness.providerThread, + providerTurnId: active.providerTurn.id, + requestRuntimeRestart: true, + }); + assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "interrupted"); + assert.equal(closes, 1); + assert.lengthOf(harness.terminalEvents(), 1); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + + it.effect("bounds an unacknowledged native Stop and settles its turn once", () => + Effect.gen(function* () { + let closes = 0; + const interruptStarted = yield* Deferred.make(); + const harness = yield* makeWakeHarnessWithOptions({ + interrupt: Deferred.succeed(interruptStarted, undefined).pipe(Effect.andThen(Effect.never)), + close: (messages) => + Effect.sync(() => { + closes++; + }).pipe(Effect.andThen(Queue.shutdown(messages))), + }); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("unacknowledged-stop"), + text: "Work until stopped", + attachments: [], + }), + ); + yield* harness.until(() => + harness.events.some( + (event) => + event.type === "provider_turn.updated" && event.providerTurn.status === "running", + ), + ); + const active = harness.events.find( + (event) => + event.type === "provider_turn.updated" && event.providerTurn.status === "running", + ); + if (active?.type !== "provider_turn.updated") + return yield* Effect.die("Missing running native turn"); + const stop = yield* harness.runtime + .interruptTurn({ + providerThread: harness.providerThread, + providerTurnId: active.providerTurn.id, + requestRuntimeRestart: true, + }) + .pipe(Effect.forkChild); + yield* Deferred.await(interruptStarted); + assert.equal(closes, 0); + yield* TestClock.adjust("11 seconds"); + yield* Fiber.join(stop); + assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "interrupted"); + assert.equal(closes, 1); + assert.lengthOf(harness.terminalEvents(), 1); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), ); it.effect("preserves settled background work until an explicit stop", () => - Effect.scoped( - Effect.gen(function* () { - let closes = 0; - const harness = yield* makeWakeHarnessWithOptions({ - close: (sdkMessages) => - Effect.sync(() => { - closes++; - }).pipe(Effect.andThen(Queue.shutdown(sdkMessages))), - }); - const now = yield* DateTime.now; - const attemptId = RunAttemptId.make("attempt-claude-settled-stop"); - yield* harness.runtime.startTurn( + Effect.gen(function* () { + let closes = 0; + const harness = yield* makeWakeHarnessWithOptions({ + close: (sdkMessages) => + Effect.sync(() => { + closes++; + }).pipe(Effect.andThen(Queue.shutdown(sdkMessages))), + }); + const now = yield* DateTime.now; + const attemptId = RunAttemptId.make("attempt-claude-settled-stop"); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId, + text: "Run the build in the background.", + attachments: [], + }), + ); + yield* Queue.offer(harness.sdkMessages, wakeTaskStarted); + yield* Queue.offer(harness.sdkMessages, turnOneResult); + yield* harness.awaitTerminal(1); + const settledThread = providerThreadRosterEvents(harness.events).at(-1)?.providerThread; + assert.equal(settledThread?.pendingBackgroundTasks?.[0]?.taskId, WAKE_TASK_ID); + assert.isTrue(yield* harness.hasPendingBackgroundWork); + + const replacementAttempt = yield* harness.runtime + .startTurn( makeClaudeTestTurnInput({ threadId: harness.threadId, - providerThread: harness.providerThread, + providerThread: { + ...harness.providerThread, + nativeThreadRef: { + ...harness.providerThread.nativeThreadRef!, + nativeId: "replacement-session", + }, + }, now, - attemptId, - text: "Run the build in the background.", + attemptId: RunAttemptId.make("attempt-claude-background-replacement"), + text: "Continue in a fresh session.", attachments: [], + providerTurnOrdinal: 2, }), - ); - yield* Queue.offer(harness.sdkMessages, wakeTaskStarted); - yield* Queue.offer(harness.sdkMessages, turnOneResult); - yield* harness.awaitTerminal(1); - const settledThread = providerThreadRosterEvents(harness.events).at(-1)?.providerThread; - assert.equal(settledThread?.pendingBackgroundTasks?.[0]?.taskId, WAKE_TASK_ID); - assert.isTrue(yield* harness.hasPendingBackgroundWork); - - const replacementAttempt = yield* harness.runtime - .startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: { - ...harness.providerThread, - nativeThreadRef: { - ...harness.providerThread.nativeThreadRef!, - nativeId: "replacement-session", - }, - }, - now, - attemptId: RunAttemptId.make("attempt-claude-background-replacement"), - text: "Continue in a fresh session.", - attachments: [], - providerTurnOrdinal: 2, - }), - ) - .pipe(Effect.result); - assert.equal(replacementAttempt._tag, "Failure"); - assert.equal(closes, 0); - assert.isTrue(yield* harness.hasPendingBackgroundWork); + ) + .pipe(Effect.result); + assert.equal(replacementAttempt._tag, "Failure"); + assert.equal(closes, 0); + assert.isTrue(yield* harness.hasPendingBackgroundWork); - yield* harness.runtime.interruptTurn({ - providerThread: settledThread ?? harness.providerThread, - providerTurnId: harness.terminalEvents()[0]!.providerTurnId, - requestRuntimeRestart: true, - }); + yield* harness.runtime.interruptTurn({ + providerThread: settledThread ?? harness.providerThread, + providerTurnId: harness.terminalEvents()[0]!.providerTurnId, + requestRuntimeRestart: true, + }); - assert.equal(closes, 1, "Stop must close the CLI process that owns the task"); - yield* harness.until( - () => - (providerThreadRosterEvents(harness.events).at(-1)?.providerThread - .pendingBackgroundTasks?.length ?? 0) === 0, - ); - assert.isFalse(yield* harness.hasPendingBackgroundWork); - assert.lengthOf(harness.continuationRequests, 0); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + assert.equal(closes, 1, "Stop must close the CLI process that owns the task"); + yield* harness.until( + () => + (providerThreadRosterEvents(harness.events).at(-1)?.providerThread.pendingBackgroundTasks + ?.length ?? 0) === 0, + ); + assert.isFalse(yield* harness.hasPendingBackgroundWork); + assert.lengthOf(harness.continuationRequests, 0); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), @@ -12383,192 +12285,190 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); it.effect("a settled Stop leaves a turn that replaced the closing process alone", () => - Effect.scoped( - Effect.gen(function* () { - const changes = yield* makeChangeSignal; - const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-claude-v2-settled-stop-replaced-", - }); - const processQueues: Array> = []; - const firstCloseRequested = yield* Deferred.make(); - const events: Array = []; - const continuationRequests: Array = - []; - const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ - instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, - settings: DEFAULT_CLAUDE_SETTINGS, - environment: {}, - attachmentsDir, - fileSystem, - path: yield* Path.Path, - random: yield* SecureRandom.SecureRandom, - idAllocator, - continuationRequests: { - offer: (request) => - Effect.sync(() => { - continuationRequests.push(request); - }).pipe(Effect.andThen(changes.notify)), - }, - queryRunner: { - allocateSessionId: Effect.succeed(WAKE_NATIVE_SESSION), - open: () => - Effect.gen(function* () { - const sdkMessages = yield* Queue.unbounded(); - const isFirstProcess = processQueues.length === 0; - processQueues.push(sdkMessages); - return { - messages: Stream.fromQueue(sdkMessages), - offer: () => Effect.void, - setModel: () => Effect.void, - applyFlagSettings: () => Effect.void, - setPermissionMode: () => Effect.void, - backgroundTasks: () => Effect.succeed(false), - mcpServerStatus: Effect.succeed([]), - readMcpResource: () => Effect.die("unexpected MCP resource read"), - callMcpTool: () => Effect.die("unexpected MCP tool call"), - listPermissionRules: Effect.die("unexpected permission rules read"), - stopTask: () => Effect.void, - interrupt: Effect.void, - close: isFirstProcess - ? Deferred.succeed(firstCloseRequested, undefined).pipe(Effect.asVoid) - : Queue.shutdown(sdkMessages), - }; - }), - forkSession: () => Effect.die("unused forkSession"), - subagentLaunchToolUseId: () => Effect.succeed(null), - subagentModel: () => Effect.succeed(null), - assertComplete: Effect.void, - }, - }); - const threadId = ThreadId.make("thread-claude-settled-stop-replaced"); - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("provider-session-claude-settled-stop"), - modelSelection: CLAUDE_TEST_MODEL_SELECTION, - runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, - }); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection: CLAUDE_TEST_MODEL_SELECTION, - runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, - }); - yield* runtime.events.pipe( - Stream.runForEach((event) => + Effect.gen(function* () { + const changes = yield* makeChangeSignal; + const fileSystem = yield* FileSystem.FileSystem; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-claude-v2-settled-stop-replaced-", + }); + const processQueues: Array> = []; + const firstCloseRequested = yield* Deferred.make(); + const events: Array = []; + const continuationRequests: Array = + []; + const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ + instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, + settings: DEFAULT_CLAUDE_SETTINGS, + environment: {}, + attachmentsDir, + fileSystem, + path: yield* Path.Path, + random: yield* SecureRandom.SecureRandom, + idAllocator, + continuationRequests: { + offer: (request) => Effect.sync(() => { - events.push(event); + continuationRequests.push(request); }).pipe(Effect.andThen(changes.notify)), - ), - Effect.forkScoped, - ); - if (runtime.hasPendingBackgroundWork === undefined) { - return yield* Effect.die("Claude adapter runtime must expose hasPendingBackgroundWork."); - } - const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; - const terminals = () => events.filter((event) => event.type === "turn.terminal"); - const now = yield* DateTime.now; - - yield* runtime.startTurn( - makeClaudeTestTurnInput({ - threadId, - providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-settled-stop-replaced-a"), - text: "Run the build in the background.", - attachments: [], - }), - ); - yield* Queue.offer(processQueues[0]!, wakeTaskStarted); - yield* Queue.offer(processQueues[0]!, turnOneResult); - yield* changes.until(() => terminals().length === 1); - const settledThread = providerThreadRosterEvents(events).at(-1)?.providerThread; - const settledTurn = terminals()[0]; - assert.equal(settledThread?.pendingBackgroundTasks?.[0]?.taskId, WAKE_TASK_ID); - - const stop = yield* runtime - .interruptTurn({ - providerThread: settledThread ?? providerThread, - providerTurnId: settledTurn!.providerTurnId, - requestRuntimeRestart: true, - }) - .pipe(Effect.forkScoped); - yield* Deferred.await(firstCloseRequested); - - yield* runtime.startTurn( - makeClaudeTestTurnInput({ - threadId, - providerThread: { ...providerThread, status: "active" }, - now, - attemptId: RunAttemptId.make("attempt-claude-settled-stop-replaced-b"), - text: "Start another background build.", - attachments: [], - providerTurnOrdinal: 2, - modelSelection: { - ...CLAUDE_TEST_MODEL_SELECTION, - model: "claude-haiku-4-5-20251001", - }, - }), - ); - assert.lengthOf(processQueues, 2); - const replacementTaskId = "replacement-task"; - yield* Queue.offer( - processQueues[1]!, - claudeSdkFrame({ - ...wakeTaskStarted, - task_id: replacementTaskId, - uuid: "00000000-0000-4000-8000-000000000905", - }), - ); - yield* changes.until( - () => - providerThreadRosterEvents(events).at(-1)?.providerThread.pendingBackgroundTasks?.[0] - ?.taskId === replacementTaskId, - ); + }, + queryRunner: { + allocateSessionId: Effect.succeed(WAKE_NATIVE_SESSION), + open: () => + Effect.gen(function* () { + const sdkMessages = yield* Queue.unbounded(); + const isFirstProcess = processQueues.length === 0; + processQueues.push(sdkMessages); + return { + messages: Stream.fromQueue(sdkMessages), + offer: () => Effect.void, + setModel: () => Effect.void, + applyFlagSettings: () => Effect.void, + setPermissionMode: () => Effect.void, + backgroundTasks: () => Effect.succeed(false), + mcpServerStatus: Effect.succeed([]), + readMcpResource: () => Effect.die("unexpected MCP resource read"), + callMcpTool: () => Effect.die("unexpected MCP tool call"), + listPermissionRules: Effect.die("unexpected permission rules read"), + stopTask: () => Effect.void, + interrupt: Effect.void, + close: isFirstProcess + ? Deferred.succeed(firstCloseRequested, undefined).pipe(Effect.asVoid) + : Queue.shutdown(sdkMessages), + }; + }), + forkSession: () => Effect.die("unused forkSession"), + subagentLaunchToolUseId: () => Effect.succeed(null), + subagentModel: () => Effect.succeed(null), + assertComplete: Effect.void, + }, + }); + const threadId = ThreadId.make("thread-claude-settled-stop-replaced"); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-claude-settled-stop"), + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + yield* runtime.events.pipe( + Stream.runForEach((event) => + Effect.sync(() => { + events.push(event); + }).pipe(Effect.andThen(changes.notify)), + ), + Effect.forkScoped, + ); + if (runtime.hasPendingBackgroundWork === undefined) { + return yield* Effect.die("Claude adapter runtime must expose hasPendingBackgroundWork."); + } + const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; + const terminals = () => events.filter((event) => event.type === "turn.terminal"); + const now = yield* DateTime.now; - yield* Queue.shutdown(processQueues[0]!); - yield* Fiber.join(stop); - assert.isTrue(yield* hasPendingBackgroundWork); - yield* yieldTicks(50); - const latestThread = providerThreadRosterEvents(events).at(-1)?.providerThread; - assert.equal(latestThread?.status, "active"); - assert.deepEqual( - latestThread?.pendingBackgroundTasks?.map((task) => task.taskId), - [replacementTaskId], - ); + yield* runtime.startTurn( + makeClaudeTestTurnInput({ + threadId, + providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-settled-stop-replaced-a"), + text: "Run the build in the background.", + attachments: [], + }), + ); + yield* Queue.offer(processQueues[0]!, wakeTaskStarted); + yield* Queue.offer(processQueues[0]!, turnOneResult); + yield* changes.until(() => terminals().length === 1); + const settledThread = providerThreadRosterEvents(events).at(-1)?.providerThread; + const settledTurn = terminals()[0]; + assert.equal(settledThread?.pendingBackgroundTasks?.[0]?.taskId, WAKE_TASK_ID); - yield* Queue.offer( - processQueues[1]!, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000906", - result: "Started another build.", - }), - ); - yield* changes.until(() => terminals().length === 2); - yield* Queue.offer( - processQueues[1]!, - claudeSdkFrame({ - ...wakeNotification, - task_id: replacementTaskId, - uuid: "00000000-0000-4000-8000-000000000907", - }), - ); - yield* Queue.offer( - processQueues[1]!, - makeAssistantTextFrame({ - uuid: "00000000-0000-4000-8000-000000000908", - text: WAKE_ASSISTANT_TEXT, - }), - ); - yield* changes.until(() => continuationRequests.length === 1); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + const stop = yield* runtime + .interruptTurn({ + providerThread: settledThread ?? providerThread, + providerTurnId: settledTurn!.providerTurnId, + requestRuntimeRestart: true, + }) + .pipe(Effect.forkScoped); + yield* Deferred.await(firstCloseRequested); + + yield* runtime.startTurn( + makeClaudeTestTurnInput({ + threadId, + providerThread: { ...providerThread, status: "active" }, + now, + attemptId: RunAttemptId.make("attempt-claude-settled-stop-replaced-b"), + text: "Start another background build.", + attachments: [], + providerTurnOrdinal: 2, + modelSelection: { + ...CLAUDE_TEST_MODEL_SELECTION, + model: "claude-haiku-4-5-20251001", + }, + }), + ); + assert.lengthOf(processQueues, 2); + const replacementTaskId = "replacement-task"; + yield* Queue.offer( + processQueues[1]!, + claudeSdkFrame({ + ...wakeTaskStarted, + task_id: replacementTaskId, + uuid: "00000000-0000-4000-8000-000000000905", + }), + ); + yield* changes.until( + () => + providerThreadRosterEvents(events).at(-1)?.providerThread.pendingBackgroundTasks?.[0] + ?.taskId === replacementTaskId, + ); + + yield* Queue.shutdown(processQueues[0]!); + yield* Fiber.join(stop); + assert.isTrue(yield* hasPendingBackgroundWork); + yield* yieldTicks(50); + const latestThread = providerThreadRosterEvents(events).at(-1)?.providerThread; + assert.equal(latestThread?.status, "active"); + assert.deepEqual( + latestThread?.pendingBackgroundTasks?.map((task) => task.taskId), + [replacementTaskId], + ); + + yield* Queue.offer( + processQueues[1]!, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000906", + result: "Started another build.", + }), + ); + yield* changes.until(() => terminals().length === 2); + yield* Queue.offer( + processQueues[1]!, + claudeSdkFrame({ + ...wakeNotification, + task_id: replacementTaskId, + uuid: "00000000-0000-4000-8000-000000000907", + }), + ); + yield* Queue.offer( + processQueues[1]!, + makeAssistantTextFrame({ + uuid: "00000000-0000-4000-8000-000000000908", + text: WAKE_ASSISTANT_TEXT, + }), + ); + yield* changes.until(() => continuationRequests.length === 1); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), @@ -12663,41 +12563,37 @@ describe("ClaudeAdapterV2 background wake turns", () => { }); it.effect("fails a turn whose Claude query ends before its first frame", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* openAdapterWhoseFirstQueryEndsBeforeItsFirstFrame( - "claude-query-dies-before-frame", - ); - const started = yield* harness.runtime - .startTurn(harness.turnInput("a", 1)) - .pipe(Effect.exit); - assert.isTrue( - Exit.isFailure(started), - "a turn whose Claude query ended before its first frame started running on the dead query", - ); - if (Exit.isFailure(started)) { - assert.include( - Cause.pretty(started.cause), - "Claude provider query ended before the turn could start.", - ); - } - const followUp = yield* harness.runtime - .startTurn(harness.turnInput("b", 2)) - .pipe(Effect.exit); - assert.isTrue( - Exit.isSuccess(followUp), - `the turn whose query ended before its first frame is still active: ${ - Exit.isFailure(followUp) ? Cause.pretty(followUp.cause) : "" - }`, + Effect.gen(function* () { + const harness = yield* openAdapterWhoseFirstQueryEndsBeforeItsFirstFrame( + "claude-query-dies-before-frame", + ); + const started = yield* harness.runtime.startTurn(harness.turnInput("a", 1)).pipe(Effect.exit); + assert.isTrue( + Exit.isFailure(started), + "a turn whose Claude query ended before its first frame started running on the dead query", + ); + if (Exit.isFailure(started)) { + assert.include( + Cause.pretty(started.cause), + "Claude provider query ended before the turn could start.", ); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + } + const followUp = yield* harness.runtime + .startTurn(harness.turnInput("b", 2)) + .pipe(Effect.exit); + assert.isTrue( + Exit.isSuccess(followUp), + `the turn whose query ended before its first frame is still active: ${ + Exit.isFailure(followUp) ? Cause.pretty(followUp.cause) : "" + }`, + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), @@ -12706,716 +12602,259 @@ describe("ClaudeAdapterV2 background wake turns", () => { it.effect( "clears the replaced sibling native thread roster when openQuery switches processes", () => - Effect.scoped( - Effect.gen(function* () { - const changes = yield* makeChangeSignal; - const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-claude-v2-sibling-replace-", - }); - const nativeIds = ["native-thread-roster-a", "native-thread-roster-b"] as const; - let allocateIndex = 0; - // A shared queue would hide sibling process death on replacement. - const processQueues: Array<{ - readonly nativeThreadId: string; - readonly queue: Queue.Queue; - }> = []; - const events: Array = []; - const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ - instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, - settings: DEFAULT_CLAUDE_SETTINGS, - environment: {}, - attachmentsDir, - fileSystem, - path: yield* Path.Path, - random: yield* SecureRandom.SecureRandom, - idAllocator, - continuationRequests: { - offer: () => Effect.void, - }, - queryRunner: { - allocateSessionId: Effect.sync(() => { - const next = - nativeIds[allocateIndex] ?? `native-thread-roster-extra-${allocateIndex}`; - allocateIndex += 1; - return next; - }), - open: (openInput) => - Effect.gen(function* () { - const nativeThreadId = openInput.options.sessionId ?? openInput.options.resume; - if (typeof nativeThreadId !== "string" || nativeThreadId.length === 0) { - return yield* Effect.die("openQuery must supply a native session id"); - } - const queue = yield* Queue.unbounded(); - processQueues.push({ nativeThreadId, queue }); - return { - messages: Stream.fromQueue(queue), - offer: () => Effect.void, - setModel: () => Effect.void, - applyFlagSettings: () => Effect.void, - setPermissionMode: () => Effect.void, - backgroundTasks: () => Effect.succeed(false), - mcpServerStatus: Effect.succeed([]), - readMcpResource: () => Effect.die("unexpected MCP resource read"), - callMcpTool: () => Effect.die("unexpected MCP tool call"), - listPermissionRules: Effect.die("unexpected permission rules read"), - stopTask: () => Effect.void, - interrupt: Effect.void, - close: Queue.shutdown(queue), - }; - }), - forkSession: () => Effect.die("unused forkSession"), - subagentLaunchToolUseId: () => Effect.succeed(null), - subagentModel: () => Effect.succeed(null), - assertComplete: Effect.void, - }, - }); - const appThreadA = ThreadId.make("thread-claude-roster-a"); - const appThreadB = ThreadId.make("thread-claude-roster-b"); - const runtime = yield* adapter.openSession({ - threadId: appThreadA, - providerSessionId: ProviderSessionId.make("provider-session-claude-sibling-replace"), - modelSelection: CLAUDE_TEST_MODEL_SELECTION, - runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, - }); - const providerThreadA = yield* runtime.ensureThread({ - threadId: appThreadA, - modelSelection: CLAUDE_TEST_MODEL_SELECTION, - runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, - }); - const providerThreadB = yield* runtime.ensureThread({ - threadId: appThreadB, - modelSelection: CLAUDE_TEST_MODEL_SELECTION, - runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, - }); - assert.notEqual( - providerThreadA.nativeThreadRef?.nativeId, - providerThreadB.nativeThreadRef?.nativeId, - ); - yield* runtime.events.pipe( - Stream.runForEach((event) => - Effect.sync(() => { - events.push(event); - }).pipe(Effect.andThen(changes.notify)), - ), - Effect.forkScoped, - ); - if (runtime.hasPendingBackgroundWork === undefined) { - return yield* Effect.die( - "Claude adapter runtime must expose hasPendingBackgroundWork.", - ); - } - if (runtime.hasPendingBackgroundWorkForThread === undefined) { - return yield* Effect.die( - "Claude adapter runtime must expose hasPendingBackgroundWorkForThread.", - ); - } - const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; - const hasPendingBackgroundWorkForThread = runtime.hasPendingBackgroundWorkForThread; - const now = yield* DateTime.now; - const taskA = "task-roster-a"; - const taskB = "task-roster-b"; - - yield* runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: appThreadA, - providerThread: providerThreadA, - now, - attemptId: RunAttemptId.make("attempt-roster-iso-a"), - text: "Background work on A.", - attachments: [], - }), - ); - assert.equal(processQueues.length, 1); - const processA = processQueues[0]!; - yield* Queue.offer( - processA.queue, - claudeSdkFrame({ - type: "system", - subtype: "task_started", - task_id: taskA, - tool_use_id: "toolu-roster-a", - description: "work on A", - is_backgrounded: true, - task_type: "local_bash", - uuid: "00000000-0000-4000-8000-000000000301", - session_id: nativeIds[0], - }), - ); - yield* Queue.offer( - processA.queue, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000302", - result: "A settled with background work.", + Effect.gen(function* () { + const changes = yield* makeChangeSignal; + const fileSystem = yield* FileSystem.FileSystem; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-claude-v2-sibling-replace-", + }); + const nativeIds = ["native-thread-roster-a", "native-thread-roster-b"] as const; + let allocateIndex = 0; + // A shared queue would hide sibling process death on replacement. + const processQueues: Array<{ + readonly nativeThreadId: string; + readonly queue: Queue.Queue; + }> = []; + const events: Array = []; + const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ + instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, + settings: DEFAULT_CLAUDE_SETTINGS, + environment: {}, + attachmentsDir, + fileSystem, + path: yield* Path.Path, + random: yield* SecureRandom.SecureRandom, + idAllocator, + continuationRequests: { + offer: () => Effect.void, + }, + queryRunner: { + allocateSessionId: Effect.sync(() => { + const next = + nativeIds[allocateIndex] ?? `native-thread-roster-extra-${allocateIndex}`; + allocateIndex += 1; + return next; }), - ); - yield* changes.until(() => - events.some( - (event) => - event.type === "turn.terminal" && - event.providerThreadId === providerThreadA.id && - event.status === "completed", - ), - ); - const rosterAAfterSettle = providerThreadRosterEvents(events).findLast( - (event) => event.providerThread.id === providerThreadA.id, - )?.providerThread.pendingBackgroundTasks; - assert.deepEqual(rosterAAfterSettle ?? [], [ - { - taskId: taskA, - description: "work on A", - kind: "command", - nativeItemId: "toolu-roster-a", - }, - ]); - assert.isTrue(yield* hasPendingBackgroundWork); - assert.isTrue(yield* hasPendingBackgroundWorkForThread(providerThreadA)); - assert.isFalse(yield* hasPendingBackgroundWorkForThread(providerThreadB)); - - yield* runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: appThreadB, - providerThread: { ...providerThreadB, status: "active" }, - now, - attemptId: RunAttemptId.make("attempt-roster-iso-b"), - text: "Background work on B.", - attachments: [], - }), - ); - assert.equal(processQueues.length, 2); - yield* changes.until(() => - providerThreadRosterEvents(events).some( - (event) => - event.providerThread.id === providerThreadA.id && - event.providerThread.status === "idle" && - (event.providerThread.pendingBackgroundTasks?.length ?? 0) === 0, - ), - ); - assert.isFalse(yield* hasPendingBackgroundWorkForThread(providerThreadA)); - - const processB = processQueues[1]!; - yield* Queue.offer( - processB.queue, - claudeSdkFrame({ - type: "system", - subtype: "task_started", - task_id: taskB, - tool_use_id: "toolu-roster-b", - description: "work on B", - is_backgrounded: true, - task_type: "local_bash", - uuid: "00000000-0000-4000-8000-000000000303", - session_id: nativeIds[1], - }), - ); - yield* changes.until(() => - providerThreadRosterEvents(events).some( - (event) => - event.providerThread.id === providerThreadB.id && - (event.providerThread.pendingBackgroundTasks?.length ?? 0) > 0, - ), - ); - assert.isTrue(yield* hasPendingBackgroundWorkForThread(providerThreadB)); - assert.isTrue(yield* hasPendingBackgroundWork); - assert.isFalse(yield* hasPendingBackgroundWorkForThread(providerThreadA)); - - yield* Queue.offer( - processB.queue, - claudeSdkFrame({ - type: "result", - subtype: "error_during_execution", - duration_ms: 10, - duration_api_ms: 10, - is_error: true, - num_turns: 1, - result: "B failed", - stop_reason: "end_turn", - total_cost_usd: 0, - usage: { - input_tokens: 1, - output_tokens: 1, - cache_creation_input_tokens: 0, - cache_read_input_tokens: 0, - }, - modelUsage: {}, - permission_denials: [], - errors: ["B failed"], - terminal_reason: "model_error", - uuid: "00000000-0000-4000-8000-000000000304", - session_id: nativeIds[1], - }), - ); - yield* changes.until(() => - events.some( - (event) => - event.type === "turn.terminal" && - event.providerThreadId === providerThreadB.id && - event.status === "failed", - ), - ); - - const rosterBAfterFail = providerThreadRosterEvents(events).findLast( - (event) => event.providerThread.id === providerThreadB.id, - )?.providerThread.pendingBackgroundTasks; - assert.deepEqual(rosterBAfterFail ?? [], []); - assert.isFalse(yield* hasPendingBackgroundWorkForThread(providerThreadA)); - assert.isFalse(yield* hasPendingBackgroundWorkForThread(providerThreadB)); - assert.isFalse(yield* hasPendingBackgroundWork); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), - ), - ), - ), - ); - - it.effect.each(["full-access", "approval-required"] as const)( - "keeps a queued wake turn's tool callback with its continuation in %s mode", - (runtimeMode) => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode, - interactionMode: "default", - cwd: "/workspace", - }); - const firstAttempt = RunAttemptId.make("attempt-claude-wake-callback-1"); - const userAttempt = RunAttemptId.make("attempt-claude-wake-callback-2"); - const continuationAttempt = RunAttemptId.make("attempt-claude-wake-callback-3"); - const planToolUseId = "toolu_01WakePlanExitPlanMode"; - const planMarkdown = "# Wake plan\n\n1. Report the background result."; - const stamp = (frame: SDKMessage, promptIndex: number) => - claudeSdkFrame({ - ...frame, - user_message_uuid: harness.promptUuid(promptIndex), - }); - const runOf = (attemptId: RunAttemptId) => RunId.make(`run-${attemptId}`); - const planToolUse = claudeSdkFrame({ - type: "assistant", - message: { - model: "claude-sonnet-4-6", - id: "msg_wake_plan", - type: "message", - role: "assistant", - content: [ - { - type: "tool_use", - id: planToolUseId, - name: "ExitPlanMode", - input: { plan: planMarkdown }, - }, - ], - }, - parent_tool_use_id: null, - uuid: "00000000-0000-4000-8000-000000000741", - session_id: WAKE_NATIVE_SESSION, - }); - const planToolResult = claudeSdkFrame({ - type: "user", - message: { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: planToolUseId, - content: - "The client captured your proposed plan. Stop here and wait for the user's feedback or implementation request in a later turn.", - is_error: true, - }, - ], - }, - parent_tool_use_id: null, - uuid: "00000000-0000-4000-8000-000000000742", - session_id: WAKE_NATIVE_SESSION, - }); - - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: firstAttempt, - text: "Run the build in the background.", - attachments: [], - runtimePolicy, - }), - ); - yield* Queue.offer(harness.sdkMessages, stamp(wakeTaskStarted, 0)); - yield* Queue.offer( - harness.sdkMessages, - stamp( - makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000740", result: "STARTED" }), - 0, - ), - ); - yield* harness.awaitTerminal(1); - - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: userAttempt, - text: "Reply with exactly: USER_REPLY", - attachments: [], - runtimePolicy, - providerTurnOrdinal: 2, - }), - ); - yield* Queue.offer(harness.sdkMessages, wakeNotification); - // The SDK asks for permission only after streaming the tool_use. - yield* harness.offerAndWait(planToolUse); - const canUseTool = harness.getOpenedOptions()?.canUseTool; - assert.isFunction(canUseTool); - const callback = yield* Effect.promise(() => - canUseTool!( - "ExitPlanMode", - { plan: planMarkdown }, - { - signal: new AbortController().signal, - toolUseID: planToolUseId, - requestId: "request-wake-plan", - }, - ), - ); - assert.equal(callback?.behavior, "deny"); - assert.include( - callback?.behavior === "deny" ? callback.message : "", - "The client captured your proposed plan", - ); - yield* Queue.offer(harness.sdkMessages, planToolResult); - yield* Queue.offer(harness.sdkMessages, wakeResult); - yield* harness.awaitContinuation(1); - - yield* Queue.offer( - harness.sdkMessages, - stamp( - makeAssistantTextFrame({ - uuid: "00000000-0000-4000-8000-000000000743", - text: "USER_REPLY", - }), - 1, - ), - ); - yield* Queue.offer( - harness.sdkMessages, - stamp( - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000744", - result: "USER_REPLY", + open: (openInput) => + Effect.gen(function* () { + const nativeThreadId = openInput.options.sessionId ?? openInput.options.resume; + if (typeof nativeThreadId !== "string" || nativeThreadId.length === 0) { + return yield* Effect.die("openQuery must supply a native session id"); + } + const queue = yield* Queue.unbounded(); + processQueues.push({ nativeThreadId, queue }); + return { + messages: Stream.fromQueue(queue), + offer: () => Effect.void, + setModel: () => Effect.void, + applyFlagSettings: () => Effect.void, + setPermissionMode: () => Effect.void, + backgroundTasks: () => Effect.succeed(false), + mcpServerStatus: Effect.succeed([]), + readMcpResource: () => Effect.die("unexpected MCP resource read"), + callMcpTool: () => Effect.die("unexpected MCP tool call"), + listPermissionRules: Effect.die("unexpected permission rules read"), + stopTask: () => Effect.void, + interrupt: Effect.void, + close: Queue.shutdown(queue), + }; }), - 1, - ), - ); - yield* harness.awaitTerminal(2); - - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: continuationAttempt, - text: "Background task completed.", - attachments: [], - runtimePolicy, - providerTurnOrdinal: 3, - messageCreatedBy: "agent", - messageCreationSource: "provider", - }), - ); - yield* harness.awaitTerminal(3); - - const latestItems = new Map( - harness.events.flatMap((event) => - event.type === "turn_item.updated" - ? [[String(event.turnItem.id), event.turnItem]] - : [], - ), - ); - const planTool = [...latestItems.values()].find( - (item) => - item.type === "dynamic_tool" && item.nativeItemRef?.nativeId === planToolUseId, - ); - const proposedPlans = [...latestItems.values()].filter( - (item) => item.type === "proposed_plan", - ); - const plans = harness.events.flatMap((event) => - event.type === "plan.updated" && event.plan.kind === "proposed_plan" - ? [event.plan] - : [], - ); - const planToolRuns = harness.events.flatMap((event) => - event.type === "turn_item.updated" && - event.turnItem.nativeItemRef?.nativeId === planToolUseId && - event.turnItem.type === "dynamic_tool" - ? [event.turnItem.runId] - : [], - ); - assert.isNotEmpty(planToolRuns); - assert.isTrue(planToolRuns.every((runId) => runId === runOf(continuationAttempt))); - assert.equal(planTool?.runId, runOf(continuationAttempt)); - assert.lengthOf(proposedPlans, 1); - assert.equal(proposedPlans[0]?.runId, runOf(continuationAttempt)); - assert.isTrue(plans.length > 0); - assert.isTrue(plans.every((plan) => plan.runId === runOf(continuationAttempt))); - const userRunItems = [...latestItems.values()].filter( - (item) => item.runId === runOf(userAttempt), - ); - assert.deepEqual( - userRunItems.map((item) => item.type), - ["assistant_message"], - ); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + forkSession: () => Effect.die("unused forkSession"), + subagentLaunchToolUseId: () => Effect.succeed(null), + subagentModel: () => Effect.succeed(null), + assertComplete: Effect.void, + }, + }); + const appThreadA = ThreadId.make("thread-claude-roster-a"); + const appThreadB = ThreadId.make("thread-claude-roster-b"); + const runtime = yield* adapter.openSession({ + threadId: appThreadA, + providerSessionId: ProviderSessionId.make("provider-session-claude-sibling-replace"), + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + const providerThreadA = yield* runtime.ensureThread({ + threadId: appThreadA, + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + const providerThreadB = yield* runtime.ensureThread({ + threadId: appThreadB, + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + assert.notEqual( + providerThreadA.nativeThreadRef?.nativeId, + providerThreadB.nativeThreadRef?.nativeId, + ); + yield* runtime.events.pipe( + Stream.runForEach((event) => + Effect.sync(() => { + events.push(event); + }).pipe(Effect.andThen(changes.notify)), ), - ), - ), - ); - - it.effect("stores a Bash result's stdout and stderr as command output", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWakeHarness; + Effect.forkScoped, + ); + if (runtime.hasPendingBackgroundWork === undefined) { + return yield* Effect.die("Claude adapter runtime must expose hasPendingBackgroundWork."); + } + if (runtime.hasPendingBackgroundWorkForThread === undefined) { + return yield* Effect.die( + "Claude adapter runtime must expose hasPendingBackgroundWorkForThread.", + ); + } + const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; + const hasPendingBackgroundWorkForThread = runtime.hasPendingBackgroundWorkForThread; const now = yield* DateTime.now; - const attemptId = RunAttemptId.make("attempt-claude-bash-output"); - const bashToolUseId = "toolu_01BashOutput"; + const taskA = "task-roster-a"; + const taskB = "task-roster-b"; - yield* harness.runtime.startTurn( + yield* runtime.startTurn( makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, + threadId: appThreadA, + providerThread: providerThreadA, now, - attemptId, - text: "Run it.", + attemptId: RunAttemptId.make("attempt-roster-iso-a"), + text: "Background work on A.", attachments: [], }), ); + assert.equal(processQueues.length, 1); + const processA = processQueues[0]!; yield* Queue.offer( - harness.sdkMessages, + processA.queue, claudeSdkFrame({ - type: "assistant", - message: { - model: "claude-sonnet-4-6", - id: "msg_bash_output", - type: "message", - role: "assistant", - content: [ - { - type: "tool_use", - id: bashToolUseId, - name: "Bash", - input: { command: "git status" }, - }, - ], - }, - parent_tool_use_id: null, - uuid: "00000000-0000-4000-8000-000000000790", - session_id: WAKE_NATIVE_SESSION, + type: "system", + subtype: "task_started", + task_id: taskA, + tool_use_id: "toolu-roster-a", + description: "work on A", + is_backgrounded: true, + task_type: "local_bash", + uuid: "00000000-0000-4000-8000-000000000301", + session_id: nativeIds[0], }), ); yield* Queue.offer( - harness.sdkMessages, - claudeSdkFrame({ - type: "user", - message: { - role: "user", - content: [ - { type: "tool_result", tool_use_id: bashToolUseId, content: "On branch main" }, - ], - }, - parent_tool_use_id: null, - uuid: "00000000-0000-4000-8000-000000000791", - session_id: WAKE_NATIVE_SESSION, - tool_use_result: { - stdout: "On branch main", - stderr: "warning: dirty", - interrupted: false, - isImage: false, - }, + processA.queue, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000302", + result: "A settled with background work.", }), ); - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000792", result: "Done." }), - ); - yield* harness.awaitTerminal(1); - - const bash = harness.events.findLast( - (event) => - event.type === "turn_item.updated" && - event.turnItem.nativeItemRef?.nativeId === bashToolUseId, - ); - assert.equal( - bash?.type === "turn_item.updated" && bash.turnItem.type === "command_execution" - ? bash.turnItem.output - : undefined, - "On branch main\nwarning: dirty", - ); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, + yield* changes.until(() => + events.some( + (event) => + event.type === "turn.terminal" && + event.providerThreadId === providerThreadA.id && + event.status === "completed", ), - ), - ), - ), - ); - - it.effect.each([ - { - case: "an Edit", - toolName: "Edit", - toolInput: { - file_path: "/workspace/src/math.ts", - old_string: " return a - b;", - new_string: " return a + b;", - }, - toolUseResult: { - filePath: "/workspace/src/math.ts", - oldString: " return a - b;", - newString: " return a + b;", - originalFile: "export function add(a: number, b: number) {\n return a - b;\n}\n", - structuredPatch: [ + ); + const rosterAAfterSettle = providerThreadRosterEvents(events).findLast( + (event) => event.providerThread.id === providerThreadA.id, + )?.providerThread.pendingBackgroundTasks; + assert.deepEqual(rosterAAfterSettle ?? [], [ { - oldStart: 1, - oldLines: 3, - newStart: 1, - newLines: 3, - lines: [ - " export function add(a: number, b: number) {", - "- return a - b;", - "+ return a + b;", - " }", - ], + taskId: taskA, + description: "work on A", + kind: "command", + nativeItemId: "toolu-roster-a", }, - ], - userModified: false, - replaceAll: false, - }, - diffStr: - "--- /workspace/src/math.ts\n+++ /workspace/src/math.ts\n@@ -1,3 +1,3 @@\n export function add(a: number, b: number) {\n- return a - b;\n+ return a + b;\n }\n", - }, - { - case: "a Write that creates a file", - toolName: "Write", - toolInput: { file_path: "/workspace/notes.md", content: "# Notes\n\nShip it.\n" }, - toolUseResult: { - type: "create", - filePath: "/workspace/notes.md", - content: "# Notes\n\nShip it.\n", - structuredPatch: [], - originalFile: null, - userModified: false, - }, - diffStr: "--- /dev/null\n+++ /workspace/notes.md\n@@ -0,0 +1,3 @@\n+# Notes\n+\n+Ship it.\n", - }, - { - case: "a Write that creates an empty file", - toolName: "Write", - toolInput: { file_path: "/workspace/.keep", content: "" }, - toolUseResult: { - type: "create", - filePath: "/workspace/.keep", - content: "", - structuredPatch: [], - originalFile: null, - userModified: false, - }, - diffStr: undefined, - }, - ])("stores the diff panel patch for $case", (testCase) => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - const toolUseId = `toolu_01${testCase.toolName}Patch`; + ]); + assert.isTrue(yield* hasPendingBackgroundWork); + assert.isTrue(yield* hasPendingBackgroundWorkForThread(providerThreadA)); + assert.isFalse(yield* hasPendingBackgroundWorkForThread(providerThreadB)); - yield* harness.runtime.startTurn( + yield* runtime.startTurn( makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make(`attempt-claude-${testCase.toolName}-patch`), - text: "Change it.", + threadId: appThreadB, + providerThread: { ...providerThreadB, status: "active" }, + now, + attemptId: RunAttemptId.make("attempt-roster-iso-b"), + text: "Background work on B.", attachments: [], }), ); + assert.equal(processQueues.length, 2); + yield* changes.until(() => + providerThreadRosterEvents(events).some( + (event) => + event.providerThread.id === providerThreadA.id && + event.providerThread.status === "idle" && + (event.providerThread.pendingBackgroundTasks?.length ?? 0) === 0, + ), + ); + assert.isFalse(yield* hasPendingBackgroundWorkForThread(providerThreadA)); + + const processB = processQueues[1]!; yield* Queue.offer( - harness.sdkMessages, + processB.queue, claudeSdkFrame({ - type: "assistant", - message: { - model: "claude-sonnet-4-6", - id: `msg_${testCase.toolName}_patch`, - type: "message", - role: "assistant", - content: [ - { - type: "tool_use", - id: toolUseId, - name: testCase.toolName, - input: testCase.toolInput, - }, - ], - }, - parent_tool_use_id: null, - uuid: "00000000-0000-4000-8000-000000000793", - session_id: WAKE_NATIVE_SESSION, + type: "system", + subtype: "task_started", + task_id: taskB, + tool_use_id: "toolu-roster-b", + description: "work on B", + is_backgrounded: true, + task_type: "local_bash", + uuid: "00000000-0000-4000-8000-000000000303", + session_id: nativeIds[1], }), ); + yield* changes.until(() => + providerThreadRosterEvents(events).some( + (event) => + event.providerThread.id === providerThreadB.id && + (event.providerThread.pendingBackgroundTasks?.length ?? 0) > 0, + ), + ); + assert.isTrue(yield* hasPendingBackgroundWorkForThread(providerThreadB)); + assert.isTrue(yield* hasPendingBackgroundWork); + assert.isFalse(yield* hasPendingBackgroundWorkForThread(providerThreadA)); + yield* Queue.offer( - harness.sdkMessages, + processB.queue, claudeSdkFrame({ - type: "user", - message: { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: toolUseId, - content: `The file ${testCase.toolInput.file_path} has been updated successfully.`, - }, - ], + type: "result", + subtype: "error_during_execution", + duration_ms: 10, + duration_api_ms: 10, + is_error: true, + num_turns: 1, + result: "B failed", + stop_reason: "end_turn", + total_cost_usd: 0, + usage: { + input_tokens: 1, + output_tokens: 1, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, }, - parent_tool_use_id: null, - uuid: "00000000-0000-4000-8000-000000000794", - session_id: WAKE_NATIVE_SESSION, - tool_use_result: testCase.toolUseResult, + modelUsage: {}, + permission_denials: [], + errors: ["B failed"], + terminal_reason: "model_error", + uuid: "00000000-0000-4000-8000-000000000304", + session_id: nativeIds[1], }), ); - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000795", result: "Done." }), + yield* changes.until(() => + events.some( + (event) => + event.type === "turn.terminal" && + event.providerThreadId === providerThreadB.id && + event.status === "failed", + ), ); - yield* harness.awaitTerminal(1); - const fileChange = harness.events.findLast( - (event) => - event.type === "turn_item.updated" && - event.turnItem.nativeItemRef?.nativeId === toolUseId, - ); - assert.deepEqual( - fileChange?.type === "turn_item.updated" && fileChange.turnItem.type === "file_change" - ? { diffStr: fileChange.turnItem.diffStr } - : undefined, - { diffStr: testCase.diffStr }, - ); + const rosterBAfterFail = providerThreadRosterEvents(events).findLast( + (event) => event.providerThread.id === providerThreadB.id, + )?.providerThread.pendingBackgroundTasks; + assert.deepEqual(rosterBAfterFail ?? [], []); + assert.isFalse(yield* hasPendingBackgroundWorkForThread(providerThreadA)); + assert.isFalse(yield* hasPendingBackgroundWorkForThread(providerThreadB)); + assert.isFalse(yield* hasPendingBackgroundWork); }).pipe( Effect.provide( Layer.mergeAll( @@ -13426,27 +12865,68 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ), ), - ), ); - it.effect("answers an approval a held wake turn raises without waiting for the echo", () => - Effect.scoped( + it.effect.each(["full-access", "approval-required"] as const)( + "keeps a queued wake turn's tool callback with its continuation in %s mode", + (runtimeMode) => Effect.gen(function* () { const harness = yield* makeWakeHarness; const now = yield* DateTime.now; - const approvalPolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "approval-required", + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode, interactionMode: "default", cwd: "/workspace", }); - const firstAttempt = RunAttemptId.make("attempt-claude-wake-approval-1"); - const userAttempt = RunAttemptId.make("attempt-claude-wake-approval-2"); - const bashToolUseId = "toolu_01WakeApprovalBash"; + const firstAttempt = RunAttemptId.make("attempt-claude-wake-callback-1"); + const userAttempt = RunAttemptId.make("attempt-claude-wake-callback-2"); + const continuationAttempt = RunAttemptId.make("attempt-claude-wake-callback-3"); + const planToolUseId = "toolu_01WakePlanExitPlanMode"; + const planMarkdown = "# Wake plan\n\n1. Report the background result."; const stamp = (frame: SDKMessage, promptIndex: number) => claudeSdkFrame({ ...frame, user_message_uuid: harness.promptUuid(promptIndex), }); + const runOf = (attemptId: RunAttemptId) => RunId.make(`run-${attemptId}`); + const planToolUse = claudeSdkFrame({ + type: "assistant", + message: { + model: "claude-sonnet-4-6", + id: "msg_wake_plan", + type: "message", + role: "assistant", + content: [ + { + type: "tool_use", + id: planToolUseId, + name: "ExitPlanMode", + input: { plan: planMarkdown }, + }, + ], + }, + parent_tool_use_id: null, + uuid: "00000000-0000-4000-8000-000000000741", + session_id: WAKE_NATIVE_SESSION, + }); + const planToolResult = claudeSdkFrame({ + type: "user", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: planToolUseId, + content: + "The client captured your proposed plan. Stop here and wait for the user's feedback or implementation request in a later turn.", + is_error: true, + }, + ], + }, + parent_tool_use_id: null, + uuid: "00000000-0000-4000-8000-000000000742", + session_id: WAKE_NATIVE_SESSION, + }); yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ @@ -13454,22 +12934,16 @@ describe("ClaudeAdapterV2 background wake turns", () => { providerThread: harness.providerThread, now, attemptId: firstAttempt, - text: "First.", + text: "Run the build in the background.", attachments: [], - runtimePolicy: approvalPolicy, + runtimePolicy, }), ); + yield* Queue.offer(harness.sdkMessages, stamp(wakeTaskStarted, 0)); yield* Queue.offer( harness.sdkMessages, stamp( - makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000000780", text: "One." }), - 0, - ), - ); - yield* Queue.offer( - harness.sdkMessages, - stamp( - makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000781", result: "One." }), + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000740", result: "STARTED" }), 0, ), ); @@ -13481,381 +12955,110 @@ describe("ClaudeAdapterV2 background wake turns", () => { providerThread: harness.providerThread, now, attemptId: userAttempt, - text: "Second.", + text: "Reply with exactly: USER_REPLY", attachments: [], + runtimePolicy, providerTurnOrdinal: 2, - runtimePolicy: approvalPolicy, - }), - ); - yield* harness.offerAndWait( - claudeSdkFrame({ - type: "assistant", - message: { - model: "claude-sonnet-4-6", - id: "msg_wake_bash", - type: "message", - role: "assistant", - content: [ - { - type: "tool_use", - id: bashToolUseId, - name: "Bash", - input: { command: "git status" }, - }, - ], - }, - parent_tool_use_id: null, - uuid: "00000000-0000-4000-8000-000000000782", - session_id: WAKE_NATIVE_SESSION, }), ); - const permission = yield* Effect.promise(() => - harness.getOpenedOptions()!.canUseTool!( - "Bash", - { command: "git status" }, + yield* Queue.offer(harness.sdkMessages, wakeNotification); + // The SDK asks for permission only after streaming the tool_use. + yield* harness.offerAndWait(planToolUse); + const canUseTool = harness.getOpenedOptions()?.canUseTool; + assert.isFunction(canUseTool); + const callback = yield* Effect.promise(() => + canUseTool!( + "ExitPlanMode", + { plan: planMarkdown }, { signal: new AbortController().signal, - toolUseID: bashToolUseId, - requestId: "request-wake-bash", + toolUseID: planToolUseId, + requestId: "request-wake-plan", }, ), - ).pipe(Effect.forkScoped); - - yield* harness.until(() => - harness.events.some((event) => event.type === "runtime_request.updated"), ); - const request = harness.events.findLast( - (event) => event.type === "runtime_request.updated", + assert.equal(callback?.behavior, "deny"); + assert.include( + callback?.behavior === "deny" ? callback.message : "", + "The client captured your proposed plan", ); - const bashItems = harness.events.flatMap((event) => - event.type === "turn_item.updated" && - event.turnItem.nativeItemRef?.nativeId === bashToolUseId - ? [event.turnItem.runId] - : [], - ); - assert.isNotEmpty(bashItems); - assert.isTrue(bashItems.every((runId) => runId === RunId.make(`run-${userAttempt}`))); - if (request?.type !== "runtime_request.updated") return; - yield* harness.runtime.respondToRuntimeRequest({ - requestId: request.runtimeRequest.id, - decision: "accept", - }); - const result = yield* Fiber.join(permission); - assert.equal(result?.behavior, "allow"); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), - ), - ), - ), - ); - - it.effect.each([ - { card: "an approval", toolName: "Bash", toolInput: { command: "echo probe" } }, - { - card: "a question", - toolName: "AskUserQuestion", - toolInput: { - questions: [ - { - header: "Target", - question: "Where should this deploy?", - options: [ - { label: "Production", description: "Deploy to production." }, - { label: "Staging", description: "Deploy to staging." }, - ], - multiSelect: false, - }, - ], - }, - }, - ])("closes $card card when Claude withdraws its request", ({ toolName, toolInput }) => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("attempt-claude-withdrawn-approval"), - text: "Run the probe.", - attachments: [], - runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "approval-required", - interactionMode: "default", - cwd: "/workspace", - }), - }), - ); - // The CLI cancels a request once a PermissionRequest hook has answered it. - // @effect-diagnostics-next-line abortControllerInEffect:off - Claude, not the test fiber, withdraws the request. - const withdrawal = new AbortController(); - const permission = yield* Effect.promise(() => - harness.getOpenedOptions()!.canUseTool!(toolName, toolInput, { - signal: withdrawal.signal, - toolUseID: "toolu_01WithdrawnApproval", - requestId: "request-withdrawn-approval", - }), - ).pipe(Effect.forkScoped); - const pending = (yield* Queue.take(harness.runtimeRequestReceipts)).runtimeRequest; - assert.equal(pending.status, "pending"); - - // The turn can end right after the withdrawal, before the callback returns. - withdrawal.abort(); - yield* Queue.offer( - harness.sdkMessages, - claudeSdkFrame({ - ...makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000790", result: "Done." }), - user_message_uuid: harness.promptUuid(0), - }), - ); - yield* harness.awaitTerminal(1); - assert.equal((yield* Fiber.join(permission))?.behavior, "deny"); - // The cancellation has to land before the turn's end finalizes the run. - const beforeTerminal = harness.events.slice( - 0, - harness.events.findIndex((event) => event.type === "turn.terminal"), - ); - const statusOf = (type: "runtime_request.updated" | "node.updated" | "turn_item.updated") => - beforeTerminal - .flatMap((event): Array<{ readonly type: string; readonly status: string }> => - event.type === "runtime_request.updated" && event.runtimeRequest.id === pending.id - ? [{ type: event.type, status: event.runtimeRequest.status }] - : event.type === "node.updated" && event.node.id === pending.nodeId - ? [{ type: event.type, status: event.node.status }] - : event.type === "turn_item.updated" && event.turnItem.nodeId === pending.nodeId - ? [{ type: event.type, status: event.turnItem.status }] - : [], - ) - .findLast((entry) => entry.type === type)?.status; - assert.equal(statusOf("runtime_request.updated"), "cancelled"); - assert.equal(statusOf("node.updated"), "cancelled"); - assert.equal(statusOf("turn_item.updated"), "cancelled"); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), - ), - ), - ), - ); - - it.effect("releases output held for a prompt echo when the stream ends", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWakeHarnessWithOptions({ - close: (sdkMessages) => Queue.shutdown(sdkMessages), - }); - const now = yield* DateTime.now; - const firstAttempt = RunAttemptId.make("attempt-claude-echo-1"); - const secondAttempt = RunAttemptId.make("attempt-claude-echo-2"); - const stamp = (frame: SDKMessage, promptIndex: number) => - claudeSdkFrame({ - ...frame, - user_message_uuid: harness.promptUuid(promptIndex), - }); - const assistantTexts = () => - harness.events.flatMap((event) => - event.type === "message.updated" && event.message.role === "assistant" - ? [event.message.text] - : [], - ); - - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: firstAttempt, - text: "First.", - attachments: [], - }), - ); - yield* Queue.offer( - harness.sdkMessages, - stamp( - makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000000701", text: "One." }), - 0, - ), + yield* Queue.offer(harness.sdkMessages, planToolResult); + yield* Queue.offer(harness.sdkMessages, wakeResult); + yield* harness.awaitContinuation(1); + + yield* Queue.offer( + harness.sdkMessages, + stamp( + makeAssistantTextFrame({ + uuid: "00000000-0000-4000-8000-000000000743", + text: "USER_REPLY", + }), + 1, + ), ); yield* Queue.offer( harness.sdkMessages, stamp( - makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000702", result: "One." }), - 0, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000744", + result: "USER_REPLY", + }), + 1, ), ); - yield* harness.awaitTerminal(1); + yield* harness.awaitTerminal(2); yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ threadId: harness.threadId, providerThread: harness.providerThread, now, - attemptId: secondAttempt, - text: "Second.", + attemptId: continuationAttempt, + text: "Background task completed.", attachments: [], - providerTurnOrdinal: 2, + runtimePolicy, + providerTurnOrdinal: 3, + messageCreatedBy: "agent", + messageCreationSource: "provider", }), ); - yield* harness.offerAndWait( - makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000000703", text: "Two." }), - ); - assert.deepEqual(assistantTexts(), ["One."]); + yield* harness.awaitTerminal(3); - yield* Queue.shutdown(harness.sdkMessages); - yield* harness.awaitTerminal(2); - assert.deepEqual(assistantTexts(), ["One.", "Two."]); - assert.equal(harness.terminalEvents()[1]?.status, "failed"); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, + const latestItems = new Map( + harness.events.flatMap((event) => + event.type === "turn_item.updated" ? [[String(event.turnItem.id), event.turnItem]] : [], ), - ), - ), - ), - ); - - it.effect("fails a prompt Claude completes without starting a turn for it", () => - Effect.scoped( - Effect.gen(function* () { - // Claude answers previously recorded prompt uuids with only a completed lifecycle frame. - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-duplicate-prompt"), - text: "Bump this PR to the latest main.", - attachments: [], - }), ); - yield* Queue.offer( - harness.sdkMessages, - claudeSdkFrame({ - type: "command_lifecycle", - command_uuid: harness.promptUuid(0), - state: "completed", - uuid: "00000000-0000-4000-8000-000000000790", - session_id: WAKE_NATIVE_SESSION, - }), + const planTool = [...latestItems.values()].find( + (item) => item.type === "dynamic_tool" && item.nativeItemRef?.nativeId === planToolUseId, ); - yield* harness.awaitTerminal(1); - const [terminal] = harness.terminalEvents(); - assert.equal(terminal?.status, "failed"); - if (terminal?.status !== "failed") return; - assert.equal(terminal.threadDisposition, "reusable"); - assert.include(terminal.failure.message, "never started a turn"); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), - ), - ), - ), - ); - - // regression: #17531 a mod that holds a tool behind a focused pane must fail the turn, not hang it - it.effect.each([ - { case: "fails a turn whose mod requests focus for a pane T3 cannot show", focus: true }, - { case: "keeps a turn running beside a passive mod pane", focus: false }, - ] as const)("$case", ({ focus }) => - Effect.scoped( - Effect.gen(function* () { - const closed = yield* Deferred.make(); - const harness = yield* makeWakeHarnessWithOptions({ - close: () => Deferred.succeed(closed, undefined), - }); - const now = yield* DateTime.now; - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-mod-pane-focus"), - text: "Run rm -r build", - attachments: [], - }), + const proposedPlans = [...latestItems.values()].filter( + (item) => item.type === "proposed_plan", ); - // Captured from Claude 2.1.303 with the blast-radius mod holding a Bash - // call: the mod waits for keyboard input in its pane and nothing else - // arrives until it gives up. - yield* Queue.offer( - harness.sdkMessages, - claudeSdkFrame({ - type: "system", - subtype: "ui_panes", - panes: [ - { id: "blast-radius", title: "Blast Radius", plugin: "blast-radius", rows: 10 }, - ], - shown_id: "blast-radius", - focused_id: null, - focus_requested_id: focus ? "blast-radius" : null, - uuid: "00000000-0000-4000-8000-000000000795", - session_id: WAKE_NATIVE_SESSION, - }), + const plans = harness.events.flatMap((event) => + event.type === "plan.updated" && event.plan.kind === "proposed_plan" ? [event.plan] : [], ); - yield* Queue.offer( - harness.sdkMessages, - claudeSdkFrame({ - type: "system", - subtype: "ui_invalidate", - event: "ui.render", - uuid: "00000000-0000-4000-8000-000000000796", - session_id: WAKE_NATIVE_SESSION, - }), + const planToolRuns = harness.events.flatMap((event) => + event.type === "turn_item.updated" && + event.turnItem.nativeItemRef?.nativeId === planToolUseId && + event.turnItem.type === "dynamic_tool" + ? [event.turnItem.runId] + : [], ); - // Frames are handled in order, so a later notice proves the pane frame - // was handled without the turn ending. - yield* Queue.offer( - harness.sdkMessages, - claudeSdkFrame({ - type: "system", - subtype: "informational", - content: "Processing barrier.", - level: "warning", - uuid: "00000000-0000-4000-8000-000000000797", - session_id: WAKE_NATIVE_SESSION, - }), + assert.isNotEmpty(planToolRuns); + assert.isTrue(planToolRuns.every((runId) => runId === runOf(continuationAttempt))); + assert.equal(planTool?.runId, runOf(continuationAttempt)); + assert.lengthOf(proposedPlans, 1); + assert.equal(proposedPlans[0]?.runId, runOf(continuationAttempt)); + assert.isTrue(plans.length > 0); + assert.isTrue(plans.every((plan) => plan.runId === runOf(continuationAttempt))); + const userRunItems = [...latestItems.values()].filter( + (item) => item.runId === runOf(userAttempt), ); - yield* harness.until( - () => - harness.terminalEvents().length > 0 || - harness.events.some( - (event) => - event.type === "turn_item.updated" && event.turnItem.type === "system_notice", - ), + assert.deepEqual( + userRunItems.map((item) => item.type), + ["assistant_message"], ); - const [terminal] = harness.terminalEvents(); - if (!focus) { - assert.equal(terminal, undefined); - return; - } - assert.equal(terminal?.status, "failed"); - if (terminal?.status !== "failed") return; - assert.include(terminal.failure.message, "blast-radius"); - // The held tool call is abandoned by closing the query, never answered. - yield* Deferred.await(closed); }).pipe( Effect.provide( Layer.mergeAll( @@ -13866,949 +13069,1446 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ), ), - ), ); - // regression: #17531 a follow-up sent while the failed mod turn's process closes gets a new process - it.effect("runs a follow-up in a fresh query while the mod-blocked query is closing", () => - Effect.scoped( - Effect.gen(function* () { - const releaseClose = yield* Deferred.make(); - const replacementClosedOld = yield* Deferred.make(); - let oldCloses = 0; - const harness = yield* makeWakeHarnessWithOptions({ - // The held CLI never answers an interrupt; closure must not need one. - interrupt: Effect.never, - close: (sdkMessages) => - Effect.suspend(() => - ++oldCloses === 2 ? Deferred.succeed(replacementClosedOld, undefined) : Effect.void, - ).pipe( - Effect.andThen(Deferred.await(releaseClose)), - Effect.andThen(Queue.shutdown(sdkMessages)), - ), - freshQueueOnReopen: true, - }); - // Added after the harness so it runs first: a failed assertion must not - // leave teardown waiting on the held close. - yield* Effect.addFinalizer(() => Deferred.succeed(releaseClose, undefined)); - const now = yield* DateTime.now; - const turnInput = makeClaudeTestTurnInput({ + it.effect("stores a Bash result's stdout and stderr as command output", () => + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const attemptId = RunAttemptId.make("attempt-claude-bash-output"); + const bashToolUseId = "toolu_01BashOutput"; + + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ threadId: harness.threadId, providerThread: harness.providerThread, now, - attemptId: RunAttemptId.make("attempt-claude-mod-pane-blocked"), - text: "Run rm -r build", + attemptId, + text: "Run it.", attachments: [], - }); - yield* harness.runtime.startTurn(turnInput); - yield* Queue.offer( - harness.sdkMessages, - claudeSdkFrame({ - type: "system", - subtype: "ui_panes", - panes: [ - { id: "blast-radius", title: "Blast Radius", plugin: "blast-radius", rows: 10 }, + }), + ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "assistant", + message: { + model: "claude-sonnet-4-6", + id: "msg_bash_output", + type: "message", + role: "assistant", + content: [ + { + type: "tool_use", + id: bashToolUseId, + name: "Bash", + input: { command: "git status" }, + }, ], - shown_id: "blast-radius", - focused_id: null, - focus_requested_id: "blast-radius", - uuid: "00000000-0000-4000-8000-000000000798", - session_id: WAKE_NATIVE_SESSION, - }), - ); - assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "failed"); + }, + parent_tool_use_id: null, + uuid: "00000000-0000-4000-8000-000000000790", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "user", + message: { + role: "user", + content: [ + { type: "tool_result", tool_use_id: bashToolUseId, content: "On branch main" }, + ], + }, + parent_tool_use_id: null, + uuid: "00000000-0000-4000-8000-000000000791", + session_id: WAKE_NATIVE_SESSION, + tool_use_result: { + stdout: "On branch main", + stderr: "warning: dirty", + interrupted: false, + isImage: false, + }, + }), + ); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000792", result: "Done." }), + ); + yield* harness.awaitTerminal(1); - const followUp = yield* harness.runtime - .startTurn({ - ...turnInput, - attemptId: RunAttemptId.make("attempt-claude-after-mod-pane"), - providerTurnOrdinal: 2, - }) - .pipe(Effect.forkScoped); - // Reusing the closing process would offer the prompt to it instead. - yield* Effect.raceFirst( - Deferred.await(replacementClosedOld), - harness.until(() => harness.offeredMessages.length >= 2), - ); - assert.isTrue(yield* Deferred.isDone(replacementClosedOld)); - yield* Deferred.succeed(releaseClose, undefined); - yield* Fiber.join(followUp); - assert.equal(harness.processQueues.length, 2); - yield* harness.offerAndWait( - makeResultFrame({ uuid: "after-mod-pane-result", result: "Done." }), - ); - assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "completed"); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + const bash = harness.events.findLast( + (event) => + event.type === "turn_item.updated" && + event.turnItem.nativeItemRef?.nativeId === bashToolUseId, + ); + assert.equal( + bash?.type === "turn_item.updated" && bash.turnItem.type === "command_execution" + ? bash.turnItem.output + : undefined, + "On branch main\nwarning: dirty", + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), ); - // regression: #16897 a prompt whose uuid the session already holds must not hang or end early - it.effect("keeps a started prompt running until its result", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-started-prompt"), - text: "Bump this PR to the latest main.", - attachments: [], - }), - ); - const lifecycle = (state: string, uuid: string) => - claudeSdkFrame({ - type: "command_lifecycle", - command_uuid: harness.promptUuid(0), - state, - uuid, - session_id: WAKE_NATIVE_SESSION, - }); - yield* Queue.offer( - harness.sdkMessages, - lifecycle("queued", "00000000-0000-4000-8000-000000000791"), - ); - yield* Queue.offer( - harness.sdkMessages, - lifecycle("started", "00000000-0000-4000-8000-000000000792"), - ); - // The CLI can report the prompt completed before its turn's result. - yield* Queue.offer( - harness.sdkMessages, - lifecycle("completed", "00000000-0000-4000-8000-000000000793"), - ); - yield* Queue.offer( - harness.sdkMessages, - claudeSdkFrame({ - ...makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000794", result: "Done." }), - user_message_uuid: harness.promptUuid(0), - }), - ); - yield* harness.awaitTerminal(1); - assert.equal(harness.terminalEvents()[0]?.status, "completed"); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + it.effect.each([ + { + case: "an Edit", + toolName: "Edit", + toolInput: { + file_path: "/workspace/src/math.ts", + old_string: " return a - b;", + new_string: " return a + b;", + }, + toolUseResult: { + filePath: "/workspace/src/math.ts", + oldString: " return a - b;", + newString: " return a + b;", + originalFile: "export function add(a: number, b: number) {\n return a - b;\n}\n", + structuredPatch: [ + { + oldStart: 1, + oldLines: 3, + newStart: 1, + newLines: 3, + lines: [ + " export function add(a: number, b: number) {", + "- return a - b;", + "+ return a + b;", + " }", + ], + }, + ], + userModified: false, + replaceAll: false, + }, + diffStr: + "--- /workspace/src/math.ts\n+++ /workspace/src/math.ts\n@@ -1,3 +1,3 @@\n export function add(a: number, b: number) {\n- return a - b;\n+ return a + b;\n }\n", + }, + { + case: "a Write that creates a file", + toolName: "Write", + toolInput: { file_path: "/workspace/notes.md", content: "# Notes\n\nShip it.\n" }, + toolUseResult: { + type: "create", + filePath: "/workspace/notes.md", + content: "# Notes\n\nShip it.\n", + structuredPatch: [], + originalFile: null, + userModified: false, + }, + diffStr: "--- /dev/null\n+++ /workspace/notes.md\n@@ -0,0 +1,3 @@\n+# Notes\n+\n+Ship it.\n", + }, + { + case: "a Write that creates an empty file", + toolName: "Write", + toolInput: { file_path: "/workspace/.keep", content: "" }, + toolUseResult: { + type: "create", + filePath: "/workspace/.keep", + content: "", + structuredPatch: [], + originalFile: null, + userModified: false, + }, + diffStr: undefined, + }, + ])("stores the diff panel patch for $case", (testCase) => + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const toolUseId = `toolu_01${testCase.toolName}Patch`; + + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make(`attempt-claude-${testCase.toolName}-patch`), + text: "Change it.", + attachments: [], + }), + ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "assistant", + message: { + model: "claude-sonnet-4-6", + id: `msg_${testCase.toolName}_patch`, + type: "message", + role: "assistant", + content: [ + { + type: "tool_use", + id: toolUseId, + name: testCase.toolName, + input: testCase.toolInput, + }, + ], + }, + parent_tool_use_id: null, + uuid: "00000000-0000-4000-8000-000000000793", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "user", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: toolUseId, + content: `The file ${testCase.toolInput.file_path} has been updated successfully.`, + }, + ], + }, + parent_tool_use_id: null, + uuid: "00000000-0000-4000-8000-000000000794", + session_id: WAKE_NATIVE_SESSION, + tool_use_result: testCase.toolUseResult, + }), + ); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000795", result: "Done." }), + ); + yield* harness.awaitTerminal(1); + + const fileChange = harness.events.findLast( + (event) => + event.type === "turn_item.updated" && + event.turnItem.nativeItemRef?.nativeId === toolUseId, + ); + assert.deepEqual( + fileChange?.type === "turn_item.updated" && fileChange.turnItem.type === "file_change" + ? { diffStr: fileChange.turnItem.diffStr } + : undefined, + { diffStr: testCase.diffStr }, + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), ); - it.effect("buffers wake output and requests a single continuation run", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-wake-1"), - text: "Run the build in the background.", - attachments: [], - }), - ); - yield* Queue.offer(harness.sdkMessages, wakeTaskStarted); - yield* Queue.offer(harness.sdkMessages, turnOneResult); - yield* harness.awaitTerminal(1); - assert.equal(harness.terminalEvents()[0]?.status, "completed"); - assert.isTrue(yield* harness.hasPendingBackgroundWork); - assert.lengthOf(harness.continuationRequests, 0); - - yield* harness.offerAndWait(wakeNotification); - assert.lengthOf(harness.continuationRequests, 0); - assert.isFalse(yield* harness.hasPendingBackgroundWork); + it.effect("answers an approval a held wake turn raises without waiting for the echo", () => + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const approvalPolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "approval-required", + interactionMode: "default", + cwd: "/workspace", + }); + const firstAttempt = RunAttemptId.make("attempt-claude-wake-approval-1"); + const userAttempt = RunAttemptId.make("attempt-claude-wake-approval-2"); + const bashToolUseId = "toolu_01WakeApprovalBash"; + const stamp = (frame: SDKMessage, promptIndex: number) => + claudeSdkFrame({ + ...frame, + user_message_uuid: harness.promptUuid(promptIndex), + }); - yield* Queue.offer(harness.sdkMessages, wakeAssistant); - yield* harness.awaitContinuation(1); - assert.equal(harness.continuationRequests[0]?.threadId, harness.threadId); - assert.equal(harness.continuationRequests[0]?.providerThreadId, harness.providerThread.id); - assert.equal(harness.continuationRequests[0]?.driver, ClaudeAdapterV2.CLAUDE_PROVIDER); - assert.equal(harness.continuationRequests[0]?.detail, WAKE_SUMMARY); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: firstAttempt, + text: "First.", + attachments: [], + runtimePolicy: approvalPolicy, + }), + ); + yield* Queue.offer( + harness.sdkMessages, + stamp( + makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000000780", text: "One." }), + 0, + ), + ); + yield* Queue.offer( + harness.sdkMessages, + stamp(makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000781", result: "One." }), 0), + ); + yield* harness.awaitTerminal(1); - yield* harness.offerAndWait(wakeResult); - assert.lengthOf(harness.continuationRequests, 1); - assert.lengthOf(harness.terminalEvents(), 1); - assert.isTrue(yield* harness.hasPendingBackgroundWork); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: userAttempt, + text: "Second.", + attachments: [], + providerTurnOrdinal: 2, + runtimePolicy: approvalPolicy, + }), + ); + yield* harness.offerAndWait( + claudeSdkFrame({ + type: "assistant", + message: { + model: "claude-sonnet-4-6", + id: "msg_wake_bash", + type: "message", + role: "assistant", + content: [ + { + type: "tool_use", + id: bashToolUseId, + name: "Bash", + input: { command: "git status" }, + }, + ], + }, + parent_tool_use_id: null, + uuid: "00000000-0000-4000-8000-000000000782", + session_id: WAKE_NATIVE_SESSION, + }), + ); + const permission = yield* Effect.promise(() => + harness.getOpenedOptions()!.canUseTool!( + "Bash", + { command: "git status" }, + { + signal: new AbortController().signal, + toolUseID: bashToolUseId, + requestId: "request-wake-bash", + }, + ), + ).pipe(Effect.forkScoped); + + yield* harness.until(() => + harness.events.some((event) => event.type === "runtime_request.updated"), + ); + const request = harness.events.findLast((event) => event.type === "runtime_request.updated"); + const bashItems = harness.events.flatMap((event) => + event.type === "turn_item.updated" && + event.turnItem.nativeItemRef?.nativeId === bashToolUseId + ? [event.turnItem.runId] + : [], + ); + assert.isNotEmpty(bashItems); + assert.isTrue(bashItems.every((runId) => runId === RunId.make(`run-${userAttempt}`))); + if (request?.type !== "runtime_request.updated") return; + yield* harness.runtime.respondToRuntimeRequest({ + requestId: request.runtimeRequest.id, + decision: "accept", + }); + const result = yield* Fiber.join(permission); + assert.equal(result?.behavior, "allow"); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), ); - it.effect("starts the wake run when Claude opens the wake turn, before its output", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-wake-init-1"), - text: "Run the build in the background.", - attachments: [], - }), - ); - yield* Queue.offer(harness.sdkMessages, wakeTaskStarted); - yield* Queue.offer(harness.sdkMessages, turnOneResult); - yield* harness.awaitTerminal(1); - - yield* harness.offerAndWait(wakeNotification); - assert.lengthOf(harness.continuationRequests, 0); - yield* harness.offerAndWait(wakeTurnInit); - assert.lengthOf(harness.continuationRequests, 1); - assert.equal(harness.continuationRequests[0]?.detail, WAKE_SUMMARY); - - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-wake-init-2"), - text: "Background task completed.", - attachments: [], - providerTurnOrdinal: 2, - messageCreatedBy: "agent", - messageCreationSource: "provider", + it.effect.each([ + { card: "an approval", toolName: "Bash", toolInput: { command: "echo probe" } }, + { + card: "a question", + toolName: "AskUserQuestion", + toolInput: { + questions: [ + { + header: "Target", + question: "Where should this deploy?", + options: [ + { label: "Production", description: "Deploy to production." }, + { label: "Staging", description: "Deploy to staging." }, + ], + multiSelect: false, + }, + ], + }, + }, + ])("closes $card card when Claude withdraws its request", ({ toolName, toolInput }) => + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-claude-withdrawn-approval"), + text: "Run the probe.", + attachments: [], + runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "approval-required", + interactionMode: "default", + cwd: "/workspace", }), - ); - assert.lengthOf(harness.terminalEvents(), 1); + }), + ); + // The CLI cancels a request once a PermissionRequest hook has answered it. + // @effect-diagnostics-next-line abortControllerInEffect:off - Claude, not the test fiber, withdraws the request. + const withdrawal = new AbortController(); + const permission = yield* Effect.promise(() => + harness.getOpenedOptions()!.canUseTool!(toolName, toolInput, { + signal: withdrawal.signal, + toolUseID: "toolu_01WithdrawnApproval", + requestId: "request-withdrawn-approval", + }), + ).pipe(Effect.forkScoped); + const pending = (yield* Queue.take(harness.runtimeRequestReceipts)).runtimeRequest; + assert.equal(pending.status, "pending"); - yield* harness.offerAndWait(wakeAssistant); - yield* harness.offerAndWait(wakeResult); - yield* harness.awaitTerminal(2); - assert.equal(harness.terminalEvents()[1]?.status, "completed"); - assert.lengthOf(harness.continuationRequests, 1); - assert.isTrue( - harness.events.some( - (event) => event.type === "message.updated" && event.message.text === WAKE_RESULT_TEXT, - ), - ); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + // The turn can end right after the withdrawal, before the callback returns. + withdrawal.abort(); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + ...makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000790", result: "Done." }), + user_message_uuid: harness.promptUuid(0), + }), + ); + yield* harness.awaitTerminal(1); + assert.equal((yield* Fiber.join(permission))?.behavior, "deny"); + // The cancellation has to land before the turn's end finalizes the run. + const beforeTerminal = harness.events.slice( + 0, + harness.events.findIndex((event) => event.type === "turn.terminal"), + ); + const statusOf = (type: "runtime_request.updated" | "node.updated" | "turn_item.updated") => + beforeTerminal + .flatMap((event): Array<{ readonly type: string; readonly status: string }> => + event.type === "runtime_request.updated" && event.runtimeRequest.id === pending.id + ? [{ type: event.type, status: event.runtimeRequest.status }] + : event.type === "node.updated" && event.node.id === pending.nodeId + ? [{ type: event.type, status: event.node.status }] + : event.type === "turn_item.updated" && event.turnItem.nodeId === pending.nodeId + ? [{ type: event.type, status: event.turnItem.status }] + : [], + ) + .findLast((entry) => entry.type === type)?.status; + assert.equal(statusOf("runtime_request.updated"), "cancelled"); + assert.equal(statusOf("node.updated"), "cancelled"); + assert.equal(statusOf("turn_item.updated"), "cancelled"); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), ); - it.effect("offers a later wake after an earlier wake's continuation dispatch failed", () => - Effect.scoped( - Effect.gen(function* () { - const SECOND_TASK_ID = "b2ndwake"; - const SECOND_TOOL_USE_ID = "toolu_second_wake"; - const SECOND_SUMMARY = 'Background command "Second sleep" completed (exit code 0)'; - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-wake-dispatch-failed-1"), - text: "Run two builds in the background.", - attachments: [], - }), - ); - yield* Queue.offer(harness.sdkMessages, wakeTaskStarted); - yield* Queue.offer( - harness.sdkMessages, - claudeSdkFrame({ - ...wakeTaskStarted, - task_id: SECOND_TASK_ID, - tool_use_id: SECOND_TOOL_USE_ID, - description: "Second sleep", - uuid: "00000000-0000-4000-8000-000000000121", - }), + it.effect("releases output held for a prompt echo when the stream ends", () => + Effect.gen(function* () { + const harness = yield* makeWakeHarnessWithOptions({ + close: (sdkMessages) => Queue.shutdown(sdkMessages), + }); + const now = yield* DateTime.now; + const firstAttempt = RunAttemptId.make("attempt-claude-echo-1"); + const secondAttempt = RunAttemptId.make("attempt-claude-echo-2"); + const stamp = (frame: SDKMessage, promptIndex: number) => + claudeSdkFrame({ + ...frame, + user_message_uuid: harness.promptUuid(promptIndex), + }); + const assistantTexts = () => + harness.events.flatMap((event) => + event.type === "message.updated" && event.message.role === "assistant" + ? [event.message.text] + : [], ); - yield* Queue.offer(harness.sdkMessages, turnOneResult); - yield* harness.awaitTerminal(1); - - yield* harness.offerAndWait(wakeNotification); - yield* harness.offerAndWait(wakeTurnInit); - assert.lengthOf(harness.continuationRequests, 1); - yield* harness.offerAndWait(wakeAssistant); - yield* harness.offerAndWait(wakeResult); - // A failed dispatch never attaches the wake turn; clearIfCurrent is the only release. - const failed = harness.continuationRequests[0]!; - assert.isDefined(failed.clearIfCurrent); - yield* failed.clearIfCurrent!(); - yield* harness.until( - () => - !( - providerThreadRosterEvents(harness.events).at(-1)?.providerThread - .pendingBackgroundTasks ?? [] - ).some((task) => task.taskId === WAKE_TASK_ID), - ); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: firstAttempt, + text: "First.", + attachments: [], + }), + ); + yield* Queue.offer( + harness.sdkMessages, + stamp( + makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000000701", text: "One." }), + 0, + ), + ); + yield* Queue.offer( + harness.sdkMessages, + stamp(makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000702", result: "One." }), 0), + ); + yield* harness.awaitTerminal(1); - yield* harness.offerAndWait( - claudeSdkFrame({ - ...wakeNotification, - task_id: SECOND_TASK_ID, - tool_use_id: SECOND_TOOL_USE_ID, - output_file: `/tmp/claude-replay/tasks/${SECOND_TASK_ID}.output`, - summary: SECOND_SUMMARY, - uuid: "00000000-0000-4000-8000-000000000123", - }), - ); - yield* harness.offerAndWait( - claudeSdkFrame({ ...wakeTurnInit, uuid: "00000000-0000-4000-8000-000000000124" }), - ); - assert.lengthOf(harness.continuationRequests, 2); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: secondAttempt, + text: "Second.", + attachments: [], + providerTurnOrdinal: 2, + }), + ); + yield* harness.offerAndWait( + makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000000703", text: "Two." }), + ); + assert.deepEqual(assistantTexts(), ["One."]); + + yield* Queue.shutdown(harness.sdkMessages); + yield* harness.awaitTerminal(2); + assert.deepEqual(assistantTexts(), ["One.", "Two."]); + assert.equal(harness.terminalEvents()[1]?.status, "failed"); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), ); - it.effect("keeps a newer wake's output when a stale continuation request is cleared", () => - Effect.scoped( - Effect.gen(function* () { - const SECOND_TASK_ID = "b2ndwake"; - const SECOND_TOOL_USE_ID = "toolu_second_wake"; - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-wake-stale-clear-1"), - text: "Run two builds in the background.", - attachments: [], - }), - ); - yield* Queue.offer(harness.sdkMessages, wakeTaskStarted); - yield* Queue.offer( - harness.sdkMessages, - claudeSdkFrame({ - ...wakeTaskStarted, - task_id: SECOND_TASK_ID, - tool_use_id: SECOND_TOOL_USE_ID, - description: "Second sleep", - uuid: "00000000-0000-4000-8000-000000000131", - }), - ); - yield* Queue.offer(harness.sdkMessages, turnOneResult); - yield* harness.awaitTerminal(1); - - yield* harness.offerAndWait(wakeNotification); - yield* harness.offerAndWait(wakeTurnInit); - assert.lengthOf(harness.continuationRequests, 1); - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-wake-stale-clear-2"), - text: "Background task completed.", - attachments: [], - providerTurnOrdinal: 2, - messageCreatedBy: "agent", - messageCreationSource: "provider", - }), - ); - yield* harness.offerAndWait(wakeAssistant); - yield* harness.offerAndWait(wakeResult); - yield* harness.awaitTerminal(2); - - yield* harness.offerAndWait( - claudeSdkFrame({ - ...wakeNotification, - task_id: SECOND_TASK_ID, - tool_use_id: SECOND_TOOL_USE_ID, - output_file: `/tmp/claude-replay/tasks/${SECOND_TASK_ID}.output`, - uuid: "00000000-0000-4000-8000-000000000133", - }), - ); - yield* harness.offerAndWait( - claudeSdkFrame({ ...wakeTurnInit, uuid: "00000000-0000-4000-8000-000000000134" }), - ); - assert.lengthOf(harness.continuationRequests, 2); + it.effect("fails a prompt Claude completes without starting a turn for it", () => + Effect.gen(function* () { + // Claude answers previously recorded prompt uuids with only a completed lifecycle frame. + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-duplicate-prompt"), + text: "Bump this PR to the latest main.", + attachments: [], + }), + ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "command_lifecycle", + command_uuid: harness.promptUuid(0), + state: "completed", + uuid: "00000000-0000-4000-8000-000000000790", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* harness.awaitTerminal(1); + const [terminal] = harness.terminalEvents(); + assert.equal(terminal?.status, "failed"); + if (terminal?.status !== "failed") return; + assert.equal(terminal.threadDisposition, "reusable"); + assert.include(terminal.failure.message, "never started a turn"); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); - yield* harness.continuationRequests[0]!.onRunEndedWithoutStarting!(); - yield* harness.offerAndWait( - makeAssistantTextFrame({ - uuid: "00000000-0000-4000-8000-000000000135", - text: "Second build done.", - }), - ); - yield* harness.offerAndWait( - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000136", - result: "Second build done.", - origin: { kind: "task-notification" }, - }), - ); - assert.lengthOf(harness.continuationRequests, 2); - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-wake-stale-clear-3"), - text: "Background task completed.", - attachments: [], - providerTurnOrdinal: 3, - messageCreatedBy: "agent", - messageCreationSource: "provider", - }), - ); - yield* harness.awaitTerminal(3); - assert.isTrue( + // regression: #17531 a mod that holds a tool behind a focused pane must fail the turn, not hang it + it.effect.each([ + { case: "fails a turn whose mod requests focus for a pane T3 cannot show", focus: true }, + { case: "keeps a turn running beside a passive mod pane", focus: false }, + ] as const)("$case", ({ focus }) => + Effect.gen(function* () { + const closed = yield* Deferred.make(); + const harness = yield* makeWakeHarnessWithOptions({ + close: () => Deferred.succeed(closed, undefined), + }); + const now = yield* DateTime.now; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-mod-pane-focus"), + text: "Run rm -r build", + attachments: [], + }), + ); + // Captured from Claude 2.1.303 with the blast-radius mod holding a Bash + // call: the mod waits for keyboard input in its pane and nothing else + // arrives until it gives up. + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "system", + subtype: "ui_panes", + panes: [{ id: "blast-radius", title: "Blast Radius", plugin: "blast-radius", rows: 10 }], + shown_id: "blast-radius", + focused_id: null, + focus_requested_id: focus ? "blast-radius" : null, + uuid: "00000000-0000-4000-8000-000000000795", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "system", + subtype: "ui_invalidate", + event: "ui.render", + uuid: "00000000-0000-4000-8000-000000000796", + session_id: WAKE_NATIVE_SESSION, + }), + ); + // Frames are handled in order, so a later notice proves the pane frame + // was handled without the turn ending. + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "system", + subtype: "informational", + content: "Processing barrier.", + level: "warning", + uuid: "00000000-0000-4000-8000-000000000797", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* harness.until( + () => + harness.terminalEvents().length > 0 || harness.events.some( (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "assistant_message" && - event.turnItem.text === "Second build done.", - ), - "clearing the first wake's request must not discard the second wake's output", - ); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, + event.type === "turn_item.updated" && event.turnItem.type === "system_notice", ), + ); + const [terminal] = harness.terminalEvents(); + if (!focus) { + assert.equal(terminal, undefined); + return; + } + assert.equal(terminal?.status, "failed"); + if (terminal?.status !== "failed") return; + assert.include(terminal.failure.message, "blast-radius"); + // The held tool call is abandoned by closing the query, never answered. + yield* Deferred.await(closed); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), ); - it.effect("offers the next wake when a cancelled wake's release arrives after it opened", () => - Effect.scoped( - Effect.gen(function* () { - const SECOND_TASK_ID = "b2ndwake"; - const SECOND_TOOL_USE_ID = "toolu_second_wake"; - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-wake-late-release"), - text: "Run two builds in the background.", - attachments: [], - }), - ); - yield* harness.offerAndWait(wakeTaskStarted); - yield* harness.offerAndWait( - claudeSdkFrame({ - ...wakeTaskStarted, - task_id: SECOND_TASK_ID, - tool_use_id: SECOND_TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000141", - }), - ); - yield* harness.offerAndWait(turnOneResult); - yield* harness.awaitTerminal(1); + // regression: #17531 a follow-up sent while the failed mod turn's process closes gets a new process + it.effect("runs a follow-up in a fresh query while the mod-blocked query is closing", () => + Effect.gen(function* () { + const releaseClose = yield* Deferred.make(); + const replacementClosedOld = yield* Deferred.make(); + let oldCloses = 0; + const harness = yield* makeWakeHarnessWithOptions({ + // The held CLI never answers an interrupt; closure must not need one. + interrupt: Effect.never, + close: (sdkMessages) => + Effect.suspend(() => + ++oldCloses === 2 ? Deferred.succeed(replacementClosedOld, undefined) : Effect.void, + ).pipe( + Effect.andThen(Deferred.await(releaseClose)), + Effect.andThen(Queue.shutdown(sdkMessages)), + ), + freshQueueOnReopen: true, + }); + // Added after the harness so it runs first: a failed assertion must not + // leave teardown waiting on the held close. + yield* Effect.addFinalizer(() => Deferred.succeed(releaseClose, undefined)); + const now = yield* DateTime.now; + const turnInput = makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-mod-pane-blocked"), + text: "Run rm -r build", + attachments: [], + }); + yield* harness.runtime.startTurn(turnInput); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "system", + subtype: "ui_panes", + panes: [{ id: "blast-radius", title: "Blast Radius", plugin: "blast-radius", rows: 10 }], + shown_id: "blast-radius", + focused_id: null, + focus_requested_id: "blast-radius", + uuid: "00000000-0000-4000-8000-000000000798", + session_id: WAKE_NATIVE_SESSION, + }), + ); + assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "failed"); - yield* harness.offerAndWait(wakeNotification); - yield* harness.offerAndWait(wakeTurnInit); - yield* harness.offerAndWait(wakeAssistant); - yield* harness.offerAndWait(wakeResult); - yield* harness.offerAndWait( - claudeSdkFrame({ - ...wakeNotification, - task_id: SECOND_TASK_ID, - tool_use_id: SECOND_TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000142", - }), - ); - yield* harness.offerAndWait( - claudeSdkFrame({ ...wakeTurnInit, uuid: "00000000-0000-4000-8000-000000000143" }), - ); - assert.lengthOf(harness.continuationRequests, 1); - - yield* harness.continuationRequests[0]!.onRunEndedWithoutStarting!(); - yield* harness.offerAndWait( - claudeSdkFrame({ ...wakeAssistant, uuid: "00000000-0000-4000-8000-000000000144" }), - ); - yield* harness.offerAndWait( - makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000145", result: "Done." }), - ); - assert.lengthOf( - harness.continuationRequests, - 2, - "the wake that opened before the cancelled wake's release must get its own continuation", - ); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + const followUp = yield* harness.runtime + .startTurn({ + ...turnInput, + attemptId: RunAttemptId.make("attempt-claude-after-mod-pane"), + providerTurnOrdinal: 2, + }) + .pipe(Effect.forkScoped); + // Reusing the closing process would offer the prompt to it instead. + yield* Effect.raceFirst( + Deferred.await(replacementClosedOld), + harness.until(() => harness.offeredMessages.length >= 2), + ); + assert.isTrue(yield* Deferred.isDone(replacementClosedOld)); + yield* Deferred.succeed(releaseClose, undefined); + yield* Fiber.join(followUp); + assert.equal(harness.processQueues.length, 2); + yield* harness.offerAndWait( + makeResultFrame({ uuid: "after-mod-pane-result", result: "Done." }), + ); + assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "completed"); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), ); - it.effect("does not offer a wake again when it is cancelled before its first root frame", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-wake-early-cancel"), - text: "Start an agent in the background.", - attachments: [], - }), - ); - yield* harness.offerAndWait( - claudeSdkFrame({ ...wakeTaskStarted, task_type: "local_agent" }), - ); - yield* harness.offerAndWait(turnOneResult); - yield* harness.awaitTerminal(1); + // regression: #16897 a prompt whose uuid the session already holds must not hang or end early + it.effect("keeps a started prompt running until its result", () => + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-started-prompt"), + text: "Bump this PR to the latest main.", + attachments: [], + }), + ); + const lifecycle = (state: string, uuid: string) => + claudeSdkFrame({ + type: "command_lifecycle", + command_uuid: harness.promptUuid(0), + state, + uuid, + session_id: WAKE_NATIVE_SESSION, + }); + yield* Queue.offer( + harness.sdkMessages, + lifecycle("queued", "00000000-0000-4000-8000-000000000791"), + ); + yield* Queue.offer( + harness.sdkMessages, + lifecycle("started", "00000000-0000-4000-8000-000000000792"), + ); + // The CLI can report the prompt completed before its turn's result. + yield* Queue.offer( + harness.sdkMessages, + lifecycle("completed", "00000000-0000-4000-8000-000000000793"), + ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + ...makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000794", result: "Done." }), + user_message_uuid: harness.promptUuid(0), + }), + ); + yield* harness.awaitTerminal(1); + assert.equal(harness.terminalEvents()[0]?.status, "completed"); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); - yield* harness.offerAndWait(wakeNotification); - assert.lengthOf(harness.continuationRequests, 1); - yield* harness.continuationRequests[0]!.onRunEndedWithoutStarting!(); - yield* harness.offerAndWait(wakeTurnInit); - yield* harness.offerAndWait(wakeAssistant); - yield* harness.offerAndWait(wakeResult); - assert.lengthOf( - harness.continuationRequests, - 1, - "the cancelled wake must not be offered again", - ); + it.effect("buffers wake output and requests a single continuation run", () => + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; - yield* harness.offerAndWait( - claudeSdkFrame({ ...wakeTurnInit, uuid: "00000000-0000-4000-8000-000000000146" }), - ); - assert.lengthOf(harness.continuationRequests, 2, "the next wake must be offered"); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-wake-1"), + text: "Run the build in the background.", + attachments: [], + }), + ); + yield* Queue.offer(harness.sdkMessages, wakeTaskStarted); + yield* Queue.offer(harness.sdkMessages, turnOneResult); + yield* harness.awaitTerminal(1); + assert.equal(harness.terminalEvents()[0]?.status, "completed"); + assert.isTrue(yield* harness.hasPendingBackgroundWork); + assert.lengthOf(harness.continuationRequests, 0); + + yield* harness.offerAndWait(wakeNotification); + assert.lengthOf(harness.continuationRequests, 0); + assert.isFalse(yield* harness.hasPendingBackgroundWork); + + yield* Queue.offer(harness.sdkMessages, wakeAssistant); + yield* harness.awaitContinuation(1); + assert.equal(harness.continuationRequests[0]?.threadId, harness.threadId); + assert.equal(harness.continuationRequests[0]?.providerThreadId, harness.providerThread.id); + assert.equal(harness.continuationRequests[0]?.driver, ClaudeAdapterV2.CLAUDE_PROVIDER); + assert.equal(harness.continuationRequests[0]?.detail, WAKE_SUMMARY); + + yield* harness.offerAndWait(wakeResult); + assert.lengthOf(harness.continuationRequests, 1); + assert.lengthOf(harness.terminalEvents(), 1); + assert.isTrue(yield* harness.hasPendingBackgroundWork); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), ); - it.effect("offers a later task's wake when a cancelled wake never opens", () => - Effect.scoped( - Effect.gen(function* () { - const SECOND_TASK_ID = "b2ndwake"; - const SECOND_TOOL_USE_ID = "toolu_second_wake"; - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; + it.effect("starts the wake run when Claude opens the wake turn, before its output", () => + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-wake-never-opens"), - text: "Start an agent and a build in the background.", - attachments: [], - }), - ); - yield* harness.offerAndWait( - claudeSdkFrame({ ...wakeTaskStarted, task_type: "local_agent" }), - ); - yield* harness.offerAndWait( - claudeSdkFrame({ - ...wakeTaskStarted, - task_id: SECOND_TASK_ID, - tool_use_id: SECOND_TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000151", - }), - ); - yield* harness.offerAndWait(turnOneResult); - yield* harness.awaitTerminal(1); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-wake-init-1"), + text: "Run the build in the background.", + attachments: [], + }), + ); + yield* Queue.offer(harness.sdkMessages, wakeTaskStarted); + yield* Queue.offer(harness.sdkMessages, turnOneResult); + yield* harness.awaitTerminal(1); - // Claude never opens a wake for the agent's notification. - yield* harness.offerAndWait(wakeNotification); - assert.lengthOf(harness.continuationRequests, 1); - yield* harness.continuationRequests[0]!.onRunEndedWithoutStarting!(); + yield* harness.offerAndWait(wakeNotification); + assert.lengthOf(harness.continuationRequests, 0); + yield* harness.offerAndWait(wakeTurnInit); + assert.lengthOf(harness.continuationRequests, 1); + assert.equal(harness.continuationRequests[0]?.detail, WAKE_SUMMARY); - yield* harness.offerAndWait( - claudeSdkFrame({ - ...wakeNotification, - task_id: SECOND_TASK_ID, - tool_use_id: SECOND_TOOL_USE_ID, - output_file: `/tmp/claude-replay/tasks/${SECOND_TASK_ID}.output`, - uuid: "00000000-0000-4000-8000-000000000152", - }), - ); - yield* harness.offerAndWait( - claudeSdkFrame({ ...wakeTurnInit, uuid: "00000000-0000-4000-8000-000000000153" }), - ); - yield* harness.offerAndWait( - makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000000154", text: "Built." }), - ); - yield* harness.offerAndWait( - makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000155", result: "Built." }), - ); - assert.lengthOf( - harness.continuationRequests, - 2, - "the build's wake must get its own continuation", - ); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-wake-init-2"), + text: "Background task completed.", + attachments: [], + providerTurnOrdinal: 2, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + assert.lengthOf(harness.terminalEvents(), 1); + + yield* harness.offerAndWait(wakeAssistant); + yield* harness.offerAndWait(wakeResult); + yield* harness.awaitTerminal(2); + assert.equal(harness.terminalEvents()[1]?.status, "completed"); + assert.lengthOf(harness.continuationRequests, 1); + assert.isTrue( + harness.events.some( + (event) => event.type === "message.updated" && event.message.text === WAKE_RESULT_TEXT, + ), + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), ); - it.effect.each([ - { ending: "notification", cancelled: "after its result" }, - { ending: "tool result", cancelled: "after its result" }, - { ending: "tool result", cancelled: "while the subagent ran" }, - ] as const)( - "does not replay a subagent that finished inside a cancelled wake (ended by $ending, cancelled $cancelled)", - ({ ending, cancelled }) => - Effect.scoped( - Effect.gen(function* () { - const SECOND_TASK_ID = "b2ndwake"; - const SECOND_TOOL_USE_ID = "toolu_second_wake"; - const SUBAGENT_TASK_ID = "fg-agent"; - const SUBAGENT_TOOL_USE_ID = "toolu-fg-agent"; - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; + it.effect("offers a later wake after an earlier wake's continuation dispatch failed", () => + Effect.gen(function* () { + const SECOND_TASK_ID = "b2ndwake"; + const SECOND_TOOL_USE_ID = "toolu_second_wake"; + const SECOND_SUMMARY = 'Background command "Second sleep" completed (exit code 0)'; + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-wake-finished-subagent"), - text: "Run two builds in the background.", - attachments: [], - }), - ); - yield* harness.offerAndWait(wakeTaskStarted); - yield* harness.offerAndWait( - claudeSdkFrame({ - ...wakeTaskStarted, - task_id: SECOND_TASK_ID, - tool_use_id: SECOND_TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000161", - }), - ); - yield* harness.offerAndWait(turnOneResult); - yield* harness.awaitTerminal(1); - - // The first wake runs a subagent to completion before its result. - yield* harness.offerAndWait(wakeNotification); - yield* harness.offerAndWait(wakeTurnInit); - assert.lengthOf(harness.continuationRequests, 1); - yield* harness.offerAndWait(wakeAssistant); - yield* harness.offerAndWait( - claudeSdkFrame({ - type: "system", - subtype: "task_started", - task_id: SUBAGENT_TASK_ID, - tool_use_id: SUBAGENT_TOOL_USE_ID, - description: "Inspect build output", - subagent_type: "general-purpose", - task_type: "local_agent", - prompt: "Inspect the build output.", - uuid: "00000000-0000-4000-8000-000000000162", - session_id: WAKE_NATIVE_SESSION, - }), - ); - yield* harness.offerAndWait( - claudeSdkFrame({ - ...makeAssistantTextFrame({ - uuid: "00000000-0000-4000-8000-000000000168", - text: "Reading the build log.", - }), - parent_tool_use_id: SUBAGENT_TOOL_USE_ID, - }), - ); - if (cancelled === "while the subagent ran") { - yield* harness.continuationRequests[0]!.onRunEndedWithoutStarting!(); - } - yield* harness.offerAndWait( - ending === "notification" - ? claudeSdkFrame({ - type: "system", - subtype: "task_notification", - task_id: SUBAGENT_TASK_ID, - tool_use_id: SUBAGENT_TOOL_USE_ID, - status: "completed", - output_file: "/tmp/fg-agent.output", - summary: "Inspected", - uuid: "00000000-0000-4000-8000-000000000163", - session_id: WAKE_NATIVE_SESSION, - }) - : claudeSdkFrame({ - type: "user", - uuid: "00000000-0000-4000-8000-000000000163", - session_id: WAKE_NATIVE_SESSION, - parent_tool_use_id: null, - message: { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: SUBAGENT_TOOL_USE_ID, - content: "Inspected", - }, - ], - }, - }), - ); - yield* harness.offerAndWait(wakeResult); - if (cancelled === "after its result") { - yield* harness.continuationRequests[0]!.onRunEndedWithoutStarting!(); - } + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-wake-dispatch-failed-1"), + text: "Run two builds in the background.", + attachments: [], + }), + ); + yield* Queue.offer(harness.sdkMessages, wakeTaskStarted); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + ...wakeTaskStarted, + task_id: SECOND_TASK_ID, + tool_use_id: SECOND_TOOL_USE_ID, + description: "Second sleep", + uuid: "00000000-0000-4000-8000-000000000121", + }), + ); + yield* Queue.offer(harness.sdkMessages, turnOneResult); + yield* harness.awaitTerminal(1); - yield* harness.offerAndWait( - claudeSdkFrame({ - ...wakeNotification, - task_id: SECOND_TASK_ID, - tool_use_id: SECOND_TOOL_USE_ID, - output_file: `/tmp/claude-replay/tasks/${SECOND_TASK_ID}.output`, - uuid: "00000000-0000-4000-8000-000000000164", - }), - ); - yield* harness.offerAndWait( - claudeSdkFrame({ ...wakeTurnInit, uuid: "00000000-0000-4000-8000-000000000165" }), - ); - assert.lengthOf(harness.continuationRequests, 2); - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-wake-finished-subagent-2"), - text: "Background task completed.", - attachments: [], - providerTurnOrdinal: 2, - messageCreatedBy: "agent", - messageCreationSource: "provider", - }), - ); - yield* harness.offerAndWait( - makeAssistantTextFrame({ - uuid: "00000000-0000-4000-8000-000000000166", - text: "Second build done.", - }), - ); - yield* harness.offerAndWait( - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000167", - result: "Second build done.", - origin: { kind: "task-notification" }, - }), - ); - yield* harness.awaitTerminal(2); + yield* harness.offerAndWait(wakeNotification); + yield* harness.offerAndWait(wakeTurnInit); + assert.lengthOf(harness.continuationRequests, 1); + yield* harness.offerAndWait(wakeAssistant); + yield* harness.offerAndWait(wakeResult); + + // A failed dispatch never attaches the wake turn; clearIfCurrent is the only release. + const failed = harness.continuationRequests[0]!; + assert.isDefined(failed.clearIfCurrent); + yield* failed.clearIfCurrent!(); + yield* harness.until( + () => + !( + providerThreadRosterEvents(harness.events).at(-1)?.providerThread + .pendingBackgroundTasks ?? [] + ).some((task) => task.taskId === WAKE_TASK_ID), + ); - const subagentStatuses = harness.events.flatMap((event) => - event.type === "subagent.updated" && - event.subagent.nativeTaskRef?.nativeId === SUBAGENT_TASK_ID - ? [event.subagent.status] - : [], - ); - assert.notInclude( - subagentStatuses, - "running", - "a subagent that finished in the cancelled wake must not be replayed as running", - ); - assert.isFalse(yield* harness.hasPendingBackgroundWork); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), - ), + yield* harness.offerAndWait( + claudeSdkFrame({ + ...wakeNotification, + task_id: SECOND_TASK_ID, + tool_use_id: SECOND_TOOL_USE_ID, + output_file: `/tmp/claude-replay/tasks/${SECOND_TASK_ID}.output`, + summary: SECOND_SUMMARY, + uuid: "00000000-0000-4000-8000-000000000123", + }), + ); + yield* harness.offerAndWait( + claudeSdkFrame({ ...wakeTurnInit, uuid: "00000000-0000-4000-8000-000000000124" }), + ); + assert.lengthOf(harness.continuationRequests, 2); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), + ), ); - it.effect("offers a cancelled wake once when an empty lifecycle result precedes its turn", () => - Effect.scoped( - Effect.gen(function* () { - const SECOND_TASK_ID = "b2ndwake"; - const SECOND_TOOL_USE_ID = "toolu_second_wake"; - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; + it.effect("keeps a newer wake's output when a stale continuation request is cleared", () => + Effect.gen(function* () { + const SECOND_TASK_ID = "b2ndwake"; + const SECOND_TOOL_USE_ID = "toolu_second_wake"; + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-wake-empty-lifecycle"), - text: "Run two builds in the background.", - attachments: [], - }), - ); - yield* harness.offerAndWait(wakeTaskStarted); - yield* harness.offerAndWait( - claudeSdkFrame({ - ...wakeTaskStarted, - task_id: SECOND_TASK_ID, - tool_use_id: SECOND_TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000171", - }), - ); - yield* harness.offerAndWait(turnOneResult); - yield* harness.awaitTerminal(1); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-wake-stale-clear-1"), + text: "Run two builds in the background.", + attachments: [], + }), + ); + yield* Queue.offer(harness.sdkMessages, wakeTaskStarted); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + ...wakeTaskStarted, + task_id: SECOND_TASK_ID, + tool_use_id: SECOND_TOOL_USE_ID, + description: "Second sleep", + uuid: "00000000-0000-4000-8000-000000000131", + }), + ); + yield* Queue.offer(harness.sdkMessages, turnOneResult); + yield* harness.awaitTerminal(1); - yield* harness.offerAndWait(wakeNotification); - yield* harness.offerAndWait(wakeTurnInit); - assert.lengthOf(harness.continuationRequests, 1); - yield* harness.offerAndWait( - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000172", - result: "", - numTurns: 0, - origin: { kind: "task-notification" }, - terminalReason: null, - }), - ); - yield* harness.continuationRequests[0]!.onRunEndedWithoutStarting!(); - yield* harness.offerAndWait( - claudeSdkFrame({ ...wakeTurnInit, uuid: "00000000-0000-4000-8000-000000000173" }), - ); - yield* harness.offerAndWait(wakeAssistant); - yield* harness.offerAndWait(wakeResult); - assert.lengthOf( - harness.continuationRequests, - 1, - "the cancelled wake must not be offered again", - ); + yield* harness.offerAndWait(wakeNotification); + yield* harness.offerAndWait(wakeTurnInit); + assert.lengthOf(harness.continuationRequests, 1); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-wake-stale-clear-2"), + text: "Background task completed.", + attachments: [], + providerTurnOrdinal: 2, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + yield* harness.offerAndWait(wakeAssistant); + yield* harness.offerAndWait(wakeResult); + yield* harness.awaitTerminal(2); - yield* harness.offerAndWait( - claudeSdkFrame({ - ...wakeNotification, - task_id: SECOND_TASK_ID, - tool_use_id: SECOND_TOOL_USE_ID, - output_file: `/tmp/claude-replay/tasks/${SECOND_TASK_ID}.output`, - uuid: "00000000-0000-4000-8000-000000000174", - }), - ); - yield* harness.offerAndWait( - claudeSdkFrame({ ...wakeTurnInit, uuid: "00000000-0000-4000-8000-000000000175" }), - ); - assert.lengthOf(harness.continuationRequests, 2, "the next wake must be offered"); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + yield* harness.offerAndWait( + claudeSdkFrame({ + ...wakeNotification, + task_id: SECOND_TASK_ID, + tool_use_id: SECOND_TOOL_USE_ID, + output_file: `/tmp/claude-replay/tasks/${SECOND_TASK_ID}.output`, + uuid: "00000000-0000-4000-8000-000000000133", + }), + ); + yield* harness.offerAndWait( + claudeSdkFrame({ ...wakeTurnInit, uuid: "00000000-0000-4000-8000-000000000134" }), + ); + assert.lengthOf(harness.continuationRequests, 2); + + yield* harness.continuationRequests[0]!.onRunEndedWithoutStarting!(); + yield* harness.offerAndWait( + makeAssistantTextFrame({ + uuid: "00000000-0000-4000-8000-000000000135", + text: "Second build done.", + }), + ); + yield* harness.offerAndWait( + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000136", + result: "Second build done.", + origin: { kind: "task-notification" }, + }), + ); + assert.lengthOf(harness.continuationRequests, 2); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-wake-stale-clear-3"), + text: "Background task completed.", + attachments: [], + providerTurnOrdinal: 3, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + yield* harness.awaitTerminal(3); + assert.isTrue( + harness.events.some( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "assistant_message" && + event.turnItem.text === "Second build done.", + ), + "clearing the first wake's request must not discard the second wake's output", + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), ); - it.effect("does not offer a cancelled wake again for its trailing empty lifecycle result", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-wake-trailing-lifecycle"), - text: "Run a build in the background.", - attachments: [], - }), - ); - yield* harness.offerAndWait(wakeTaskStarted); - yield* harness.offerAndWait(turnOneResult); - yield* harness.awaitTerminal(1); + it.effect("offers the next wake when a cancelled wake's release arrives after it opened", () => + Effect.gen(function* () { + const SECOND_TASK_ID = "b2ndwake"; + const SECOND_TOOL_USE_ID = "toolu_second_wake"; + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-wake-late-release"), + text: "Run two builds in the background.", + attachments: [], + }), + ); + yield* harness.offerAndWait(wakeTaskStarted); + yield* harness.offerAndWait( + claudeSdkFrame({ + ...wakeTaskStarted, + task_id: SECOND_TASK_ID, + tool_use_id: SECOND_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000141", + }), + ); + yield* harness.offerAndWait(turnOneResult); + yield* harness.awaitTerminal(1); + + yield* harness.offerAndWait(wakeNotification); + yield* harness.offerAndWait(wakeTurnInit); + yield* harness.offerAndWait(wakeAssistant); + yield* harness.offerAndWait(wakeResult); + yield* harness.offerAndWait( + claudeSdkFrame({ + ...wakeNotification, + task_id: SECOND_TASK_ID, + tool_use_id: SECOND_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000142", + }), + ); + yield* harness.offerAndWait( + claudeSdkFrame({ ...wakeTurnInit, uuid: "00000000-0000-4000-8000-000000000143" }), + ); + assert.lengthOf(harness.continuationRequests, 1); + + yield* harness.continuationRequests[0]!.onRunEndedWithoutStarting!(); + yield* harness.offerAndWait( + claudeSdkFrame({ ...wakeAssistant, uuid: "00000000-0000-4000-8000-000000000144" }), + ); + yield* harness.offerAndWait( + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000145", result: "Done." }), + ); + assert.lengthOf( + harness.continuationRequests, + 2, + "the wake that opened before the cancelled wake's release must get its own continuation", + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + + it.effect("does not offer a wake again when it is cancelled before its first root frame", () => + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-wake-early-cancel"), + text: "Start an agent in the background.", + attachments: [], + }), + ); + yield* harness.offerAndWait(claudeSdkFrame({ ...wakeTaskStarted, task_type: "local_agent" })); + yield* harness.offerAndWait(turnOneResult); + yield* harness.awaitTerminal(1); + + yield* harness.offerAndWait(wakeNotification); + assert.lengthOf(harness.continuationRequests, 1); + yield* harness.continuationRequests[0]!.onRunEndedWithoutStarting!(); + yield* harness.offerAndWait(wakeTurnInit); + yield* harness.offerAndWait(wakeAssistant); + yield* harness.offerAndWait(wakeResult); + assert.lengthOf( + harness.continuationRequests, + 1, + "the cancelled wake must not be offered again", + ); + + yield* harness.offerAndWait( + claudeSdkFrame({ ...wakeTurnInit, uuid: "00000000-0000-4000-8000-000000000146" }), + ); + assert.lengthOf(harness.continuationRequests, 2, "the next wake must be offered"); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + + it.effect("offers a later task's wake when a cancelled wake never opens", () => + Effect.gen(function* () { + const SECOND_TASK_ID = "b2ndwake"; + const SECOND_TOOL_USE_ID = "toolu_second_wake"; + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-wake-never-opens"), + text: "Start an agent and a build in the background.", + attachments: [], + }), + ); + yield* harness.offerAndWait(claudeSdkFrame({ ...wakeTaskStarted, task_type: "local_agent" })); + yield* harness.offerAndWait( + claudeSdkFrame({ + ...wakeTaskStarted, + task_id: SECOND_TASK_ID, + tool_use_id: SECOND_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000151", + }), + ); + yield* harness.offerAndWait(turnOneResult); + yield* harness.awaitTerminal(1); + + // Claude never opens a wake for the agent's notification. + yield* harness.offerAndWait(wakeNotification); + assert.lengthOf(harness.continuationRequests, 1); + yield* harness.continuationRequests[0]!.onRunEndedWithoutStarting!(); + + yield* harness.offerAndWait( + claudeSdkFrame({ + ...wakeNotification, + task_id: SECOND_TASK_ID, + tool_use_id: SECOND_TOOL_USE_ID, + output_file: `/tmp/claude-replay/tasks/${SECOND_TASK_ID}.output`, + uuid: "00000000-0000-4000-8000-000000000152", + }), + ); + yield* harness.offerAndWait( + claudeSdkFrame({ ...wakeTurnInit, uuid: "00000000-0000-4000-8000-000000000153" }), + ); + yield* harness.offerAndWait( + makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000000154", text: "Built." }), + ); + yield* harness.offerAndWait( + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000155", result: "Built." }), + ); + assert.lengthOf( + harness.continuationRequests, + 2, + "the build's wake must get its own continuation", + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + + it.effect.each([ + { ending: "notification", cancelled: "after its result" }, + { ending: "tool result", cancelled: "after its result" }, + { ending: "tool result", cancelled: "while the subagent ran" }, + ] as const)( + "does not replay a subagent that finished inside a cancelled wake (ended by $ending, cancelled $cancelled)", + ({ ending, cancelled }) => + Effect.gen(function* () { + const SECOND_TASK_ID = "b2ndwake"; + const SECOND_TOOL_USE_ID = "toolu_second_wake"; + const SUBAGENT_TASK_ID = "fg-agent"; + const SUBAGENT_TOOL_USE_ID = "toolu-fg-agent"; + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-wake-finished-subagent"), + text: "Run two builds in the background.", + attachments: [], + }), + ); + yield* harness.offerAndWait(wakeTaskStarted); + yield* harness.offerAndWait( + claudeSdkFrame({ + ...wakeTaskStarted, + task_id: SECOND_TASK_ID, + tool_use_id: SECOND_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000161", + }), + ); + yield* harness.offerAndWait(turnOneResult); + yield* harness.awaitTerminal(1); + + // The first wake runs a subagent to completion before its result. yield* harness.offerAndWait(wakeNotification); yield* harness.offerAndWait(wakeTurnInit); + assert.lengthOf(harness.continuationRequests, 1); yield* harness.offerAndWait(wakeAssistant); + yield* harness.offerAndWait( + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: SUBAGENT_TASK_ID, + tool_use_id: SUBAGENT_TOOL_USE_ID, + description: "Inspect build output", + subagent_type: "general-purpose", + task_type: "local_agent", + prompt: "Inspect the build output.", + uuid: "00000000-0000-4000-8000-000000000162", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* harness.offerAndWait( + claudeSdkFrame({ + ...makeAssistantTextFrame({ + uuid: "00000000-0000-4000-8000-000000000168", + text: "Reading the build log.", + }), + parent_tool_use_id: SUBAGENT_TOOL_USE_ID, + }), + ); + if (cancelled === "while the subagent ran") { + yield* harness.continuationRequests[0]!.onRunEndedWithoutStarting!(); + } + yield* harness.offerAndWait( + ending === "notification" + ? claudeSdkFrame({ + type: "system", + subtype: "task_notification", + task_id: SUBAGENT_TASK_ID, + tool_use_id: SUBAGENT_TOOL_USE_ID, + status: "completed", + output_file: "/tmp/fg-agent.output", + summary: "Inspected", + uuid: "00000000-0000-4000-8000-000000000163", + session_id: WAKE_NATIVE_SESSION, + }) + : claudeSdkFrame({ + type: "user", + uuid: "00000000-0000-4000-8000-000000000163", + session_id: WAKE_NATIVE_SESSION, + parent_tool_use_id: null, + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: SUBAGENT_TOOL_USE_ID, + content: "Inspected", + }, + ], + }, + }), + ); yield* harness.offerAndWait(wakeResult); + if (cancelled === "after its result") { + yield* harness.continuationRequests[0]!.onRunEndedWithoutStarting!(); + } + + yield* harness.offerAndWait( + claudeSdkFrame({ + ...wakeNotification, + task_id: SECOND_TASK_ID, + tool_use_id: SECOND_TOOL_USE_ID, + output_file: `/tmp/claude-replay/tasks/${SECOND_TASK_ID}.output`, + uuid: "00000000-0000-4000-8000-000000000164", + }), + ); + yield* harness.offerAndWait( + claudeSdkFrame({ ...wakeTurnInit, uuid: "00000000-0000-4000-8000-000000000165" }), + ); + assert.lengthOf(harness.continuationRequests, 2); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-wake-finished-subagent-2"), + text: "Background task completed.", + attachments: [], + providerTurnOrdinal: 2, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); yield* harness.offerAndWait( - claudeSdkFrame({ ...wakeTurnInit, uuid: "00000000-0000-4000-8000-000000000176" }), + makeAssistantTextFrame({ + uuid: "00000000-0000-4000-8000-000000000166", + text: "Second build done.", + }), ); yield* harness.offerAndWait( makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000177", - result: "", - numTurns: 0, + uuid: "00000000-0000-4000-8000-000000000167", + result: "Second build done.", origin: { kind: "task-notification" }, - terminalReason: null, }), ); - assert.lengthOf(harness.continuationRequests, 1); - yield* harness.continuationRequests[0]!.onRunEndedWithoutStarting!(); - assert.lengthOf( - harness.continuationRequests, - 1, - "a lifecycle result after the cancelled wake must not open another continuation", + yield* harness.awaitTerminal(2); + + const subagentStatuses = harness.events.flatMap((event) => + event.type === "subagent.updated" && + event.subagent.nativeTaskRef?.nativeId === SUBAGENT_TASK_ID + ? [event.subagent.status] + : [], + ); + assert.notInclude( + subagentStatuses, + "running", + "a subagent that finished in the cancelled wake must not be replayed as running", ); + assert.isFalse(yield* harness.hasPendingBackgroundWork); }).pipe( Effect.provide( Layer.mergeAll( @@ -14819,80 +14519,209 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ), ), - ), ); - it.effect.each([ - "before the cancelled wake's result", - "after the cancelled wake's result", - ] as const)( - "offers a subagent's wake that arrives behind a cancelled wake (notified %s)", - (notified) => - Effect.scoped( - Effect.gen(function* () { - const SUBAGENT_TASK_ID = "bg-agent"; - const SUBAGENT_TOOL_USE_ID = "toolu-bg-agent"; - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-wake-later-subagent"), - text: "Run a build and an agent in the background.", - attachments: [], - }), - ); - yield* harness.offerAndWait(wakeTaskStarted); - yield* harness.offerAndWait( - claudeSdkFrame({ - ...wakeTaskStarted, - task_id: SUBAGENT_TASK_ID, - tool_use_id: SUBAGENT_TOOL_USE_ID, - task_type: "local_agent", - uuid: "00000000-0000-4000-8000-000000000181", - }), - ); - yield* harness.offerAndWait(turnOneResult); - yield* harness.awaitTerminal(1); + it.effect("offers a cancelled wake once when an empty lifecycle result precedes its turn", () => + Effect.gen(function* () { + const SECOND_TASK_ID = "b2ndwake"; + const SECOND_TOOL_USE_ID = "toolu_second_wake"; + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; - const subagentNotification = claudeSdkFrame({ - ...wakeNotification, + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-wake-empty-lifecycle"), + text: "Run two builds in the background.", + attachments: [], + }), + ); + yield* harness.offerAndWait(wakeTaskStarted); + yield* harness.offerAndWait( + claudeSdkFrame({ + ...wakeTaskStarted, + task_id: SECOND_TASK_ID, + tool_use_id: SECOND_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000171", + }), + ); + yield* harness.offerAndWait(turnOneResult); + yield* harness.awaitTerminal(1); + + yield* harness.offerAndWait(wakeNotification); + yield* harness.offerAndWait(wakeTurnInit); + assert.lengthOf(harness.continuationRequests, 1); + yield* harness.offerAndWait( + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000172", + result: "", + numTurns: 0, + origin: { kind: "task-notification" }, + terminalReason: null, + }), + ); + yield* harness.continuationRequests[0]!.onRunEndedWithoutStarting!(); + yield* harness.offerAndWait( + claudeSdkFrame({ ...wakeTurnInit, uuid: "00000000-0000-4000-8000-000000000173" }), + ); + yield* harness.offerAndWait(wakeAssistant); + yield* harness.offerAndWait(wakeResult); + assert.lengthOf( + harness.continuationRequests, + 1, + "the cancelled wake must not be offered again", + ); + + yield* harness.offerAndWait( + claudeSdkFrame({ + ...wakeNotification, + task_id: SECOND_TASK_ID, + tool_use_id: SECOND_TOOL_USE_ID, + output_file: `/tmp/claude-replay/tasks/${SECOND_TASK_ID}.output`, + uuid: "00000000-0000-4000-8000-000000000174", + }), + ); + yield* harness.offerAndWait( + claudeSdkFrame({ ...wakeTurnInit, uuid: "00000000-0000-4000-8000-000000000175" }), + ); + assert.lengthOf(harness.continuationRequests, 2, "the next wake must be offered"); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + + it.effect("does not offer a cancelled wake again for its trailing empty lifecycle result", () => + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-wake-trailing-lifecycle"), + text: "Run a build in the background.", + attachments: [], + }), + ); + yield* harness.offerAndWait(wakeTaskStarted); + yield* harness.offerAndWait(turnOneResult); + yield* harness.awaitTerminal(1); + + yield* harness.offerAndWait(wakeNotification); + yield* harness.offerAndWait(wakeTurnInit); + yield* harness.offerAndWait(wakeAssistant); + yield* harness.offerAndWait(wakeResult); + yield* harness.offerAndWait( + claudeSdkFrame({ ...wakeTurnInit, uuid: "00000000-0000-4000-8000-000000000176" }), + ); + yield* harness.offerAndWait( + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000177", + result: "", + numTurns: 0, + origin: { kind: "task-notification" }, + terminalReason: null, + }), + ); + assert.lengthOf(harness.continuationRequests, 1); + yield* harness.continuationRequests[0]!.onRunEndedWithoutStarting!(); + assert.lengthOf( + harness.continuationRequests, + 1, + "a lifecycle result after the cancelled wake must not open another continuation", + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + + it.effect.each([ + "before the cancelled wake's result", + "after the cancelled wake's result", + ] as const)( + "offers a subagent's wake that arrives behind a cancelled wake (notified %s)", + (notified) => + Effect.gen(function* () { + const SUBAGENT_TASK_ID = "bg-agent"; + const SUBAGENT_TOOL_USE_ID = "toolu-bg-agent"; + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-wake-later-subagent"), + text: "Run a build and an agent in the background.", + attachments: [], + }), + ); + yield* harness.offerAndWait(wakeTaskStarted); + yield* harness.offerAndWait( + claudeSdkFrame({ + ...wakeTaskStarted, task_id: SUBAGENT_TASK_ID, tool_use_id: SUBAGENT_TOOL_USE_ID, - output_file: `/tmp/claude-replay/tasks/${SUBAGENT_TASK_ID}.output`, - summary: "Agent finished", - uuid: "00000000-0000-4000-8000-000000000182", - }); - yield* harness.offerAndWait(wakeNotification); - yield* harness.offerAndWait(wakeTurnInit); - yield* harness.offerAndWait(wakeAssistant); - assert.lengthOf(harness.continuationRequests, 1); - if (notified === "before the cancelled wake's result") { - yield* harness.continuationRequests[0]!.onRunEndedWithoutStarting!(); - yield* harness.offerAndWait(subagentNotification); - yield* harness.offerAndWait(wakeResult); - } else { - yield* harness.offerAndWait(wakeResult); - yield* harness.offerAndWait(subagentNotification); - yield* harness.continuationRequests[0]!.onRunEndedWithoutStarting!(); - } - assert.lengthOf( - harness.continuationRequests, - 2, - "the subagent's wake must get its own continuation", - ); - assert.equal(harness.continuationRequests[1]?.detail, "Agent finished"); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + task_type: "local_agent", + uuid: "00000000-0000-4000-8000-000000000181", + }), + ); + yield* harness.offerAndWait(turnOneResult); + yield* harness.awaitTerminal(1); + + const subagentNotification = claudeSdkFrame({ + ...wakeNotification, + task_id: SUBAGENT_TASK_ID, + tool_use_id: SUBAGENT_TOOL_USE_ID, + output_file: `/tmp/claude-replay/tasks/${SUBAGENT_TASK_ID}.output`, + summary: "Agent finished", + uuid: "00000000-0000-4000-8000-000000000182", + }); + yield* harness.offerAndWait(wakeNotification); + yield* harness.offerAndWait(wakeTurnInit); + yield* harness.offerAndWait(wakeAssistant); + assert.lengthOf(harness.continuationRequests, 1); + if (notified === "before the cancelled wake's result") { + yield* harness.continuationRequests[0]!.onRunEndedWithoutStarting!(); + yield* harness.offerAndWait(subagentNotification); + yield* harness.offerAndWait(wakeResult); + } else { + yield* harness.offerAndWait(wakeResult); + yield* harness.offerAndWait(subagentNotification); + yield* harness.continuationRequests[0]!.onRunEndedWithoutStarting!(); + } + assert.lengthOf( + harness.continuationRequests, + 2, + "the subagent's wake must get its own continuation", + ); + assert.equal(harness.continuationRequests[1]?.detail, "Agent finished"); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), @@ -14901,164 +14730,160 @@ describe("ClaudeAdapterV2 background wake turns", () => { it.effect.each(["none", "before", "after", "sibling"] as const)( "keeps an exited background command on the roster until its wake run starts (empty level %s notification)", (emptyLevel) => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - const secondTaskId = "second-owed-wake"; - const taskIds = emptyLevel === "sibling" ? [WAKE_TASK_ID, secondTaskId] : [WAKE_TASK_ID]; - const wakeAttemptId = RunAttemptId.make("attempt-claude-wake-roster-2"); - const emptyRoster = claudeSdkFrame({ - type: "system", - subtype: "background_tasks_changed", - tasks: [], - uuid: "00000000-0000-4000-8000-000000000204", - session_id: WAKE_NATIVE_SESSION, - }); + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const secondTaskId = "second-owed-wake"; + const taskIds = emptyLevel === "sibling" ? [WAKE_TASK_ID, secondTaskId] : [WAKE_TASK_ID]; + const wakeAttemptId = RunAttemptId.make("attempt-claude-wake-roster-2"); + const emptyRoster = claudeSdkFrame({ + type: "system", + subtype: "background_tasks_changed", + tasks: [], + uuid: "00000000-0000-4000-8000-000000000204", + session_id: WAKE_NATIVE_SESSION, + }); - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-wake-roster-1"), - text: "Run the build in the background.", - attachments: [], + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-wake-roster-1"), + text: "Run the build in the background.", + attachments: [], + }), + ); + yield* harness.offerAndWait(wakeTaskStarted); + if (emptyLevel === "sibling") + yield* harness.offerAndWait( + claudeSdkFrame({ + ...wakeTaskStarted, + task_id: secondTaskId, + tool_use_id: "toolu_second_owed_wake", + uuid: "00000000-0000-4000-8000-000000000205", }), ); - yield* harness.offerAndWait(wakeTaskStarted); - if (emptyLevel === "sibling") - yield* harness.offerAndWait( - claudeSdkFrame({ - ...wakeTaskStarted, - task_id: secondTaskId, - tool_use_id: "toolu_second_owed_wake", - uuid: "00000000-0000-4000-8000-000000000205", - }), - ); - const awaitBackgroundTasks = harness.runtime.awaitBackgroundTasks; - assert.isDefined(awaitBackgroundTasks); - yield* awaitBackgroundTasks!({ + const awaitBackgroundTasks = harness.runtime.awaitBackgroundTasks; + assert.isDefined(awaitBackgroundTasks); + yield* awaitBackgroundTasks!({ + providerThread: harness.providerThread, + taskIds, + awaited: true, + }); + yield* Queue.offer(harness.sdkMessages, turnOneResult); + yield* harness.awaitTerminal(1); + const afterFirstTurn = harness.events.length; + if (emptyLevel === "before" || emptyLevel === "sibling") + yield* harness.offerAndWait(emptyRoster); + yield* harness.offerAndWait(wakeNotification); + if (emptyLevel === "after") yield* harness.offerAndWait(emptyRoster); + // Query replacement reads the internal roster, which the exit already cleared. + assert.isFalse(yield* harness.hasPendingBackgroundWork); + yield* harness.offerAndWait(wakeTurnInit); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, providerThread: harness.providerThread, - taskIds, - awaited: true, - }); - yield* Queue.offer(harness.sdkMessages, turnOneResult); - yield* harness.awaitTerminal(1); - const afterFirstTurn = harness.events.length; - if (emptyLevel === "before" || emptyLevel === "sibling") - yield* harness.offerAndWait(emptyRoster); - yield* harness.offerAndWait(wakeNotification); - if (emptyLevel === "after") yield* harness.offerAndWait(emptyRoster); - // Query replacement reads the internal roster, which the exit already cleared. - assert.isFalse(yield* harness.hasPendingBackgroundWork); - yield* harness.offerAndWait(wakeTurnInit); + now, + attemptId: wakeAttemptId, + text: "Background task completed.", + attachments: [], + providerTurnOrdinal: 2, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + yield* harness.offerAndWait(wakeAssistant); + yield* harness.offerAndWait(wakeResult); + yield* harness.awaitTerminal(2); + + const wakeStart = harness.events.findIndex( + (event) => + event.type === "provider_turn.updated" && + event.providerTurn.runAttemptId === wakeAttemptId, + ); + assert.isAbove(wakeStart, -1); + const rosters = harness.events.flatMap((event, index) => + event.type === "provider_thread.updated" && + event.providerThread.id === harness.providerThread.id && + index >= afterFirstTurn + ? [ + { + beforeWake: index < wakeStart, + pendingWork: event.pendingBackgroundWork === true, + taskIds: (event.providerThread.pendingBackgroundTasks ?? []).map((task) => [ + task.taskId, + task.awaited === true, + ]), + }, + ] + : [], + ); + assert.deepEqual( + rosters.filter((roster) => roster.beforeWake).map((roster) => roster.taskIds), + rosters.filter((roster) => roster.beforeWake).map(() => taskIds.map((id) => [id, true])), + ); + assert.isTrue( + rosters.filter((roster) => roster.beforeWake).every((roster) => roster.pendingWork), + ); + assert.deepEqual( + rosters.at(-1)?.taskIds, + emptyLevel === "sibling" ? [[secondTaskId, true]] : [], + ); + if (emptyLevel === "sibling") { + assert.isTrue(rosters.at(-1)?.pendingWork); + yield* harness.offerAndWait( + claudeSdkFrame({ + ...wakeNotification, + task_id: secondTaskId, + tool_use_id: "toolu_second_owed_wake", + uuid: "00000000-0000-4000-8000-000000000206", + }), + ); + yield* harness.offerAndWait( + claudeSdkFrame({ ...wakeTurnInit, uuid: "00000000-0000-4000-8000-000000000207" }), + ); yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ threadId: harness.threadId, providerThread: harness.providerThread, now, - attemptId: wakeAttemptId, - text: "Background task completed.", + attemptId: RunAttemptId.make("attempt-claude-owed-sibling-3"), + text: "Second command completed.", attachments: [], - providerTurnOrdinal: 2, + providerTurnOrdinal: 3, messageCreatedBy: "agent", messageCreationSource: "provider", }), ); - yield* harness.offerAndWait(wakeAssistant); - yield* harness.offerAndWait(wakeResult); - yield* harness.awaitTerminal(2); - - const wakeStart = harness.events.findIndex( - (event) => - event.type === "provider_turn.updated" && - event.providerTurn.runAttemptId === wakeAttemptId, - ); - assert.isAbove(wakeStart, -1); - const rosters = harness.events.flatMap((event, index) => - event.type === "provider_thread.updated" && - event.providerThread.id === harness.providerThread.id && - index >= afterFirstTurn - ? [ - { - beforeWake: index < wakeStart, - pendingWork: event.pendingBackgroundWork === true, - taskIds: (event.providerThread.pendingBackgroundTasks ?? []).map((task) => [ - task.taskId, - task.awaited === true, - ]), - }, - ] - : [], - ); - assert.deepEqual( - rosters.filter((roster) => roster.beforeWake).map((roster) => roster.taskIds), - rosters - .filter((roster) => roster.beforeWake) - .map(() => taskIds.map((id) => [id, true])), + yield* harness.offerAndWait( + makeAssistantTextFrame({ + uuid: "00000000-0000-4000-8000-000000000208", + text: "Second command finished.", + }), ); - assert.isTrue( - rosters.filter((roster) => roster.beforeWake).every((roster) => roster.pendingWork), + yield* harness.offerAndWait( + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000209", + result: "Second command finished.", + origin: { kind: "task-notification" }, + }), ); + yield* harness.awaitTerminal(3); assert.deepEqual( - rosters.at(-1)?.taskIds, - emptyLevel === "sibling" ? [[secondTaskId, true]] : [], + providerThreadRosterEvents(harness.events).at(-1)?.providerThread + .pendingBackgroundTasks, + [], ); - if (emptyLevel === "sibling") { - assert.isTrue(rosters.at(-1)?.pendingWork); - yield* harness.offerAndWait( - claudeSdkFrame({ - ...wakeNotification, - task_id: secondTaskId, - tool_use_id: "toolu_second_owed_wake", - uuid: "00000000-0000-4000-8000-000000000206", - }), - ); - yield* harness.offerAndWait( - claudeSdkFrame({ ...wakeTurnInit, uuid: "00000000-0000-4000-8000-000000000207" }), - ); - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-owed-sibling-3"), - text: "Second command completed.", - attachments: [], - providerTurnOrdinal: 3, - messageCreatedBy: "agent", - messageCreationSource: "provider", - }), - ); - yield* harness.offerAndWait( - makeAssistantTextFrame({ - uuid: "00000000-0000-4000-8000-000000000208", - text: "Second command finished.", - }), - ); - yield* harness.offerAndWait( - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000209", - result: "Second command finished.", - origin: { kind: "task-notification" }, - }), - ); - yield* harness.awaitTerminal(3); - assert.deepEqual( - providerThreadRosterEvents(harness.events).at(-1)?.providerThread - .pendingBackgroundTasks, - [], - ); - } - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + } + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), @@ -15067,115 +14892,113 @@ describe("ClaudeAdapterV2 background wake turns", () => { it.effect.each([true, false])( "carries a rejected wake rate limit into the continuation failure (event=%s)", (standaloneEvent) => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - const resetsAt = 1790298600; + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const resetsAt = 1790298600; - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-wake-limit-1"), - text: "Run the build in the background.", - attachments: [], - }), - ); - yield* Queue.offer(harness.sdkMessages, wakeTaskStarted); - yield* Queue.offer(harness.sdkMessages, turnOneResult); - yield* Queue.take(harness.terminalReceipts); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-wake-limit-1"), + text: "Run the build in the background.", + attachments: [], + }), + ); + yield* Queue.offer(harness.sdkMessages, wakeTaskStarted); + yield* Queue.offer(harness.sdkMessages, turnOneResult); + yield* Queue.take(harness.terminalReceipts); - yield* Queue.offer(harness.sdkMessages, wakeNotification); - if (standaloneEvent) { - yield* harness.offerAndWait( - claudeSdkFrame({ - type: "rate_limit_event", - rate_limit_info: { - status: "rejected", - rateLimitType: "five_hour", - resetsAt, - overageStatus: "rejected", - }, - uuid: "00000000-0000-4000-8000-000000000630", - session_id: WAKE_NATIVE_SESSION, - }), - ); - } - assert.lengthOf(harness.continuationRequests, 0); + yield* Queue.offer(harness.sdkMessages, wakeNotification); + if (standaloneEvent) { yield* harness.offerAndWait( - makeAssistantErrorFrame({ - uuid: "00000000-0000-4000-8000-000000000631", - error: "rate_limit", - apiErrorParams: { - rate_limit_info: { - status: "rejected", - rateLimitType: "five_hour", - // A conflicting embedded time must not replace the event for this window. - resetsAt: standaloneEvent ? resetsAt + 3_600 : resetsAt, - }, + claudeSdkFrame({ + type: "rate_limit_event", + rate_limit_info: { + status: "rejected", + rateLimitType: "five_hour", + resetsAt, + overageStatus: "rejected", }, + uuid: "00000000-0000-4000-8000-000000000630", + session_id: WAKE_NATIVE_SESSION, }), ); - assert.lengthOf(harness.continuationRequests, 1); - yield* harness.offerAndWait( - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000632", - result: "You've hit your session limit · resets 11:10am (Australia/Sydney)", - isError: true, - apiErrorStatus: 429, - terminalReason: "api_error", - origin: { kind: "task-notification" }, - }), - ); - assert.lengthOf(harness.continuationRequests, 1); + } + assert.lengthOf(harness.continuationRequests, 0); + yield* harness.offerAndWait( + makeAssistantErrorFrame({ + uuid: "00000000-0000-4000-8000-000000000631", + error: "rate_limit", + apiErrorParams: { + rate_limit_info: { + status: "rejected", + rateLimitType: "five_hour", + // A conflicting embedded time must not replace the event for this window. + resetsAt: standaloneEvent ? resetsAt + 3_600 : resetsAt, + }, + }, + }), + ); + assert.lengthOf(harness.continuationRequests, 1); + yield* harness.offerAndWait( + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000632", + result: "You've hit your session limit · resets 11:10am (Australia/Sydney)", + isError: true, + apiErrorStatus: 429, + terminalReason: "api_error", + origin: { kind: "task-notification" }, + }), + ); + assert.lengthOf(harness.continuationRequests, 1); - // An empty session-task lifecycle result arriving before T3 opens the - // wake run must not replace the root's terminal usage-limit result. - yield* harness.offerAndWait(wakeTurnInit); - yield* harness.offerAndWait( - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000633", - result: "", - numTurns: 0, - origin: { kind: "task-notification" }, - terminalReason: null, - }), - ); + // An empty session-task lifecycle result arriving before T3 opens the + // wake run must not replace the root's terminal usage-limit result. + yield* harness.offerAndWait(wakeTurnInit); + yield* harness.offerAndWait( + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000633", + result: "", + numTurns: 0, + origin: { kind: "task-notification" }, + terminalReason: null, + }), + ); - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-wake-limit-2"), - text: "Background task completed.", - attachments: [], - providerTurnOrdinal: 2, - messageCreatedBy: "agent", - messageCreationSource: "provider", - }), - ); - const terminal = yield* Queue.take(harness.terminalReceipts); - assert.equal(terminal?.status, "failed"); - if (terminal === undefined || terminal.status !== "failed") return; - assert.equal(terminal.failure.class, "usage_limit"); - assert.equal(terminal.failure.resetAt, "2026-09-25T01:10:00.000Z"); - if (standaloneEvent) { - const pause = yield* Queue.take(harness.systemNoticeReceipts); - assert.equal(pause.turnItem.type, "system_notice"); - if (pause.turnItem.type !== "system_notice") return; - assert.include(pause.turnItem.message, "This turn is paused until the 5-hour limit"); - } - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-wake-limit-2"), + text: "Background task completed.", + attachments: [], + providerTurnOrdinal: 2, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + const terminal = yield* Queue.take(harness.terminalReceipts); + assert.equal(terminal?.status, "failed"); + if (terminal === undefined || terminal.status !== "failed") return; + assert.equal(terminal.failure.class, "usage_limit"); + assert.equal(terminal.failure.resetAt, "2026-09-25T01:10:00.000Z"); + if (standaloneEvent) { + const pause = yield* Queue.take(harness.systemNoticeReceipts); + assert.equal(pause.turnItem.type, "system_notice"); + if (pause.turnItem.type !== "system_notice") return; + assert.include(pause.turnItem.message, "This turn is paused until the 5-hour limit"); + } + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), @@ -15184,87 +15007,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { it.effect( "dates a buffered wake gateway limit from its arrival, not the continuation replay", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-wake-gateway-1"), - text: "Run the build in the background.", - attachments: [], - }), - ); - yield* Queue.offer(harness.sdkMessages, wakeTaskStarted); - yield* Queue.offer(harness.sdkMessages, turnOneResult); - yield* Queue.take(harness.terminalReceipts); - - yield* Queue.offer(harness.sdkMessages, wakeNotification); - yield* harness.offerAndWait( - makeAssistantErrorFrame({ - uuid: "00000000-0000-4000-8000-000000000634", - error: "server_error", - }), - ); - assert.lengthOf(harness.continuationRequests, 1); - const arrivedAt = yield* DateTime.now; - yield* harness.offerAndWait( - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000635", - result: - 'API Error: 503 [cpa/claude-opus-5-5] [429]: {"type":"error","error":{"type":"rate_limit_error","message":"All credentials are cooling down"}} (reset after 2h54m21s)', - isError: true, - apiErrorStatus: 503, - terminalReason: "api_error", - origin: { kind: "task-notification" }, - }), - ); - - yield* TestClock.adjust("1 hour"); - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("attempt-claude-wake-gateway-2"), - text: "Background task completed.", - attachments: [], - providerTurnOrdinal: 2, - messageCreatedBy: "agent", - messageCreationSource: "provider", - }), - ); - const terminal = yield* Queue.take(harness.terminalReceipts); - assert.equal(terminal.status, "failed"); - if (terminal.status !== "failed") return; - assert.equal(terminal.failure.class, "usage_limit"); - assert.equal( - terminal.failure.resetAt, - DateTime.formatIso( - DateTime.makeUnsafe( - DateTime.toEpochMillis(arrivedAt) + (2 * 3_600 + 54 * 60 + 21) * 1_000, - ), - ), - ); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), - ), - ), - ), - ); - - it.effect("leaves buffered wake messages for the continuation queued behind a user turn", () => - Effect.scoped( Effect.gen(function* () { const harness = yield* makeWakeHarness; const now = yield* DateTime.now; @@ -15274,120 +15016,61 @@ describe("ClaudeAdapterV2 background wake turns", () => { threadId: harness.threadId, providerThread: harness.providerThread, now, - attemptId: RunAttemptId.make("attempt-claude-wake-4a"), + attemptId: RunAttemptId.make("attempt-claude-wake-gateway-1"), text: "Run the build in the background.", attachments: [], }), ); yield* Queue.offer(harness.sdkMessages, wakeTaskStarted); yield* Queue.offer(harness.sdkMessages, turnOneResult); - yield* harness.awaitTerminal(1); + yield* Queue.take(harness.terminalReceipts); + yield* Queue.offer(harness.sdkMessages, wakeNotification); - yield* Queue.offer(harness.sdkMessages, wakeResult); - yield* harness.awaitContinuation(1); + yield* harness.offerAndWait( + makeAssistantErrorFrame({ + uuid: "00000000-0000-4000-8000-000000000634", + error: "server_error", + }), + ); + assert.lengthOf(harness.continuationRequests, 1); + const arrivedAt = yield* DateTime.now; + yield* harness.offerAndWait( + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000635", + result: + 'API Error: 503 [cpa/claude-opus-5-5] [429]: {"type":"error","error":{"type":"rate_limit_error","message":"All credentials are cooling down"}} (reset after 2h54m21s)', + isError: true, + apiErrorStatus: 503, + terminalReason: "api_error", + origin: { kind: "task-notification" }, + }), + ); + yield* TestClock.adjust("1 hour"); yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ threadId: harness.threadId, providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-wake-4b"), - text: "How is the build going?", + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-claude-wake-gateway-2"), + text: "Background task completed.", attachments: [], providerTurnOrdinal: 2, - }), - ); - - yield* harness.until(() => harness.offeredMessages.length === 2); - assert.lengthOf(harness.terminalEvents(), 1); - assert.isTrue(yield* harness.hasPendingBackgroundWork); - - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000105", - result: "The build passed; nothing else pending.", - }), - ); - yield* harness.awaitTerminal(2); - assert.equal(harness.terminalEvents()[1]?.status, "completed"); - assert.isFalse( - harness.events.some( - (event) => event.type === "message.updated" && event.message.text === WAKE_RESULT_TEXT, - ), - ); - - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-wake-4c"), - text: "Background task completed.", - attachments: [], - providerTurnOrdinal: 3, messageCreatedBy: "agent", messageCreationSource: "provider", }), ); - yield* harness.awaitTerminal(3); - assert.equal(harness.terminalEvents()[2]?.status, "completed"); - assert.lengthOf(harness.offeredMessages, 2); - assert.isTrue( - harness.events.some( - (event) => event.type === "message.updated" && event.message.text === WAKE_RESULT_TEXT, - ), - ); - assert.isFalse( - harness.events.some( - (event) => - event.type !== "provider_thread.updated" && - JSON.stringify(event).includes(WAKE_TASK_ID), - ), - ); - assert.isFalse(yield* harness.hasPendingBackgroundWork); - - // The continuation settled with no result of its own, so a late suggestion belongs to B. - yield* harness.offerAndWait( - claudeSdkFrame({ - type: "prompt_suggestion", - suggestion: "Check the build logs", - uuid: "00000000-0000-4000-8000-000000000692", - session_id: WAKE_NATIVE_SESSION, - }), - ); - const userTurn = makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-wake-4d"), - text: "Ship it.", - attachments: [], - providerTurnOrdinal: 4, - }); - yield* harness.runtime.startTurn(userTurn); - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000693", result: "Shipped." }), - ); - yield* harness.awaitTerminal(4); - yield* Queue.offer( - harness.sdkMessages, - claudeSdkFrame({ - type: "prompt_suggestion", - suggestion: "Open the release notes", - uuid: "00000000-0000-4000-8000-000000000694", - session_id: WAKE_NATIVE_SESSION, - }), - ); - yield* harness.until(() => - harness.events.some((event) => event.type === "prompt_suggestion.updated"), - ); - assert.deepEqual( - harness.events.flatMap((event) => - event.type === "prompt_suggestion.updated" ? [[event.runId, event.text]] : [], + const terminal = yield* Queue.take(harness.terminalReceipts); + assert.equal(terminal.status, "failed"); + if (terminal.status !== "failed") return; + assert.equal(terminal.failure.class, "usage_limit"); + assert.equal( + terminal.failure.resetAt, + DateTime.formatIso( + DateTime.makeUnsafe( + DateTime.toEpochMillis(arrivedAt) + (2 * 3_600 + 54 * 60 + 21) * 1_000, + ), ), - [[userTurn.runId, "Open the release notes"]], ); }).pipe( Effect.provide( @@ -15399,12 +15082,228 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ), ), + ); + + it.effect("leaves buffered wake messages for the continuation queued behind a user turn", () => + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-wake-4a"), + text: "Run the build in the background.", + attachments: [], + }), + ); + yield* Queue.offer(harness.sdkMessages, wakeTaskStarted); + yield* Queue.offer(harness.sdkMessages, turnOneResult); + yield* harness.awaitTerminal(1); + yield* Queue.offer(harness.sdkMessages, wakeNotification); + yield* Queue.offer(harness.sdkMessages, wakeResult); + yield* harness.awaitContinuation(1); + + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-wake-4b"), + text: "How is the build going?", + attachments: [], + providerTurnOrdinal: 2, + }), + ); + + yield* harness.until(() => harness.offeredMessages.length === 2); + assert.lengthOf(harness.terminalEvents(), 1); + assert.isTrue(yield* harness.hasPendingBackgroundWork); + + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000105", + result: "The build passed; nothing else pending.", + }), + ); + yield* harness.awaitTerminal(2); + assert.equal(harness.terminalEvents()[1]?.status, "completed"); + assert.isFalse( + harness.events.some( + (event) => event.type === "message.updated" && event.message.text === WAKE_RESULT_TEXT, + ), + ); + + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-wake-4c"), + text: "Background task completed.", + attachments: [], + providerTurnOrdinal: 3, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + yield* harness.awaitTerminal(3); + assert.equal(harness.terminalEvents()[2]?.status, "completed"); + assert.lengthOf(harness.offeredMessages, 2); + assert.isTrue( + harness.events.some( + (event) => event.type === "message.updated" && event.message.text === WAKE_RESULT_TEXT, + ), + ); + assert.isFalse( + harness.events.some( + (event) => + event.type !== "provider_thread.updated" && + JSON.stringify(event).includes(WAKE_TASK_ID), + ), + ); + assert.isFalse(yield* harness.hasPendingBackgroundWork); + + // The continuation settled with no result of its own, so a late suggestion belongs to B. + yield* harness.offerAndWait( + claudeSdkFrame({ + type: "prompt_suggestion", + suggestion: "Check the build logs", + uuid: "00000000-0000-4000-8000-000000000692", + session_id: WAKE_NATIVE_SESSION, + }), + ); + const userTurn = makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-wake-4d"), + text: "Ship it.", + attachments: [], + providerTurnOrdinal: 4, + }); + yield* harness.runtime.startTurn(userTurn); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000693", result: "Shipped." }), + ); + yield* harness.awaitTerminal(4); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "prompt_suggestion", + suggestion: "Open the release notes", + uuid: "00000000-0000-4000-8000-000000000694", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* harness.until(() => + harness.events.some((event) => event.type === "prompt_suggestion.updated"), + ); + assert.deepEqual( + harness.events.flatMap((event) => + event.type === "prompt_suggestion.updated" ? [[event.runId, event.text]] : [], + ), + [[userTurn.runId, "Open the release notes"]], + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), ), ); it.effect("settles a continuation whose wake turn a user turn absorbed", () => - Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-wake-absorbed-1"), + text: "Run the build in the background.", + attachments: [], + }), + ); + yield* Queue.offer(harness.sdkMessages, wakeTaskStarted); + yield* Queue.offer(harness.sdkMessages, wakeNotification); + yield* Queue.offer(harness.sdkMessages, turnOneResult); + yield* harness.awaitTerminal(1); + yield* harness.offerAndWait(wakeTurnInit); + yield* harness.offerAndWait(wakeAssistant); + yield* harness.awaitContinuation(1); + + // Claude folds this prompt into the wake turn it already opened, so one result ends both. + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-wake-absorbed-2"), + text: "Any update on the pull request?", + attachments: [], + providerTurnOrdinal: 2, + }), + ); + yield* harness.until(() => harness.offeredMessages.length === 2); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000140", + result: "Checks passed and the pull request is up to date.", + }), + ); + yield* harness.awaitTerminal(2); + + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-wake-absorbed-3"), + text: "Background task completed.", + attachments: [], + providerTurnOrdinal: 3, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + yield* harness.awaitTerminal(3); + assert.equal(harness.terminalEvents()[2]?.status, "completed"); + assert.isTrue( + harness.events.some( + (event) => event.type === "message.updated" && event.message.text === WAKE_ASSISTANT_TEXT, + ), + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + + it.effect.each(["before", "after"] as const)( + "offers a later wake when a wake a user turn absorbed is cancelled %s that turn ends", + (cancelled) => Effect.gen(function* () { + const SECOND_TASK_ID = "b2ndwake"; + const SECOND_TOOL_USE_ID = "toolu_second_wake"; const harness = yield* makeWakeHarness; const now = yield* DateTime.now; @@ -15413,12 +15312,21 @@ describe("ClaudeAdapterV2 background wake turns", () => { threadId: harness.threadId, providerThread: harness.providerThread, now, - attemptId: RunAttemptId.make("attempt-claude-wake-absorbed-1"), - text: "Run the build in the background.", + attemptId: RunAttemptId.make("attempt-claude-wake-absorbed-cancel-1"), + text: "Run two builds in the background.", attachments: [], }), ); yield* Queue.offer(harness.sdkMessages, wakeTaskStarted); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + ...wakeTaskStarted, + task_id: SECOND_TASK_ID, + tool_use_id: SECOND_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000191", + }), + ); yield* Queue.offer(harness.sdkMessages, wakeNotification); yield* Queue.offer(harness.sdkMessages, turnOneResult); yield* harness.awaitTerminal(1); @@ -15426,49 +15334,46 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* harness.offerAndWait(wakeAssistant); yield* harness.awaitContinuation(1); - // Claude folds this prompt into the wake turn it already opened, so one result ends both. yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ threadId: harness.threadId, providerThread: harness.providerThread, now, - attemptId: RunAttemptId.make("attempt-claude-wake-absorbed-2"), + attemptId: RunAttemptId.make("attempt-claude-wake-absorbed-cancel-2"), text: "Any update on the pull request?", attachments: [], providerTurnOrdinal: 2, }), ); yield* harness.until(() => harness.offeredMessages.length === 2); + if (cancelled === "before") { + yield* harness.continuationRequests[0]!.onRunEndedWithoutStarting!(); + } yield* Queue.offer( harness.sdkMessages, makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000140", + uuid: "00000000-0000-4000-8000-000000000192", result: "Checks passed and the pull request is up to date.", }), ); yield* harness.awaitTerminal(2); + if (cancelled === "after") { + yield* harness.continuationRequests[0]!.onRunEndedWithoutStarting!(); + } - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-wake-absorbed-3"), - text: "Background task completed.", - attachments: [], - providerTurnOrdinal: 3, - messageCreatedBy: "agent", - messageCreationSource: "provider", + yield* harness.offerAndWait( + claudeSdkFrame({ + ...wakeNotification, + task_id: SECOND_TASK_ID, + tool_use_id: SECOND_TOOL_USE_ID, + output_file: `/tmp/claude-replay/tasks/${SECOND_TASK_ID}.output`, + uuid: "00000000-0000-4000-8000-000000000193", }), ); - yield* harness.awaitTerminal(3); - assert.equal(harness.terminalEvents()[2]?.status, "completed"); - assert.isTrue( - harness.events.some( - (event) => - event.type === "message.updated" && event.message.text === WAKE_ASSISTANT_TEXT, - ), + yield* harness.offerAndWait( + claudeSdkFrame({ ...wakeTurnInit, uuid: "00000000-0000-4000-8000-000000000194" }), ); + assert.lengthOf(harness.continuationRequests, 2, "the later wake must be offered"); }).pipe( Effect.provide( Layer.mergeAll( @@ -15479,171 +15384,141 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ), ), - ), ); - it.effect.each(["before", "after"] as const)( - "offers a later wake when a wake a user turn absorbed is cancelled %s that turn ends", - (cancelled) => - Effect.scoped( - Effect.gen(function* () { - const SECOND_TASK_ID = "b2ndwake"; - const SECOND_TOOL_USE_ID = "toolu_second_wake"; - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; + it.effect("ignores a zero-turn task-notification origin result during a normal user turn", () => + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const probeAssistantText = "Probe after stale task-notification result."; + const recoveryAssistantText = "Recovered after the interrupt; continuing."; + const staleResultText = STALE_TASK_NOTIFICATION_RESULT_TEXT; + const hasMessageText = (text: string) => + harness.events.some( + (event) => event.type === "message.updated" && event.message.text === text, + ); - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-wake-absorbed-cancel-1"), - text: "Run two builds in the background.", - attachments: [], - }), - ); - yield* Queue.offer(harness.sdkMessages, wakeTaskStarted); - yield* Queue.offer( - harness.sdkMessages, - claudeSdkFrame({ - ...wakeTaskStarted, - task_id: SECOND_TASK_ID, - tool_use_id: SECOND_TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000191", - }), - ); - yield* Queue.offer(harness.sdkMessages, wakeNotification); - yield* Queue.offer(harness.sdkMessages, turnOneResult); - yield* harness.awaitTerminal(1); - yield* harness.offerAndWait(wakeTurnInit); - yield* harness.offerAndWait(wakeAssistant); - yield* harness.awaitContinuation(1); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-stale-notif-1"), + text: "Continue after interrupt.", + attachments: [], + }), + ); + yield* harness.until(() => harness.offeredMessages.length === 1); - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-wake-absorbed-cancel-2"), - text: "Any update on the pull request?", - attachments: [], - providerTurnOrdinal: 2, - }), - ); - yield* harness.until(() => harness.offeredMessages.length === 2); - if (cancelled === "before") { - yield* harness.continuationRequests[0]!.onRunEndedWithoutStarting!(); - } - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000192", - result: "Checks passed and the pull request is up to date.", - }), - ); - yield* harness.awaitTerminal(2); - if (cancelled === "after") { - yield* harness.continuationRequests[0]!.onRunEndedWithoutStarting!(); - } + // After interrupt recovery, stale task notifications and their results can precede the root stream. + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "system", + subtype: "task_notification", + task_id: "task-stale-stopped", + tool_use_id: "toolu-stale-stopped", + status: "stopped", + output_file: "/tmp/task-stale-stopped.log", + summary: "", + uuid: "00000000-0000-4000-8000-000000000107", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* Queue.offer(harness.sdkMessages, staleTaskNotificationResult); + yield* Queue.offer( + harness.sdkMessages, + makeAssistantTextFrame({ + uuid: "00000000-0000-4000-8000-00000000010a", + text: probeAssistantText, + }), + ); - yield* harness.offerAndWait( - claudeSdkFrame({ - ...wakeNotification, - task_id: SECOND_TASK_ID, - tool_use_id: SECOND_TOOL_USE_ID, - output_file: `/tmp/claude-replay/tasks/${SECOND_TASK_ID}.output`, - uuid: "00000000-0000-4000-8000-000000000193", - }), - ); - yield* harness.offerAndWait( - claudeSdkFrame({ ...wakeTurnInit, uuid: "00000000-0000-4000-8000-000000000194" }), - ); - assert.lengthOf(harness.continuationRequests, 2, "the later wake must be offered"); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), - ), + yield* harness.until(() => hasMessageText(probeAssistantText)); + assert.lengthOf(harness.terminalEvents(), 0); + assert.isFalse(hasMessageText(staleResultText)); + + yield* Queue.offer( + harness.sdkMessages, + makeAssistantTextFrame({ + uuid: "00000000-0000-4000-8000-000000000108", + text: recoveryAssistantText, + }), + ); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000109", + result: recoveryAssistantText, + }), + ); + + yield* harness.awaitTerminal(1); + assert.equal(harness.terminalEvents()[0]?.status, "completed"); + assert.isTrue(hasMessageText(recoveryAssistantText)); + assert.isFalse(hasMessageText(staleResultText)); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), + ), ); - it.effect("ignores a zero-turn task-notification origin result during a normal user turn", () => - Effect.scoped( + it.effect( + "emits one interrupted terminal for a positive task-notification result racing interrupt", + () => Effect.gen(function* () { - const harness = yield* makeWakeHarness; + const closeGate = yield* Deferred.make(); + const testScope = yield* Scope.Scope; + yield* Scope.addFinalizer(testScope, Deferred.succeed(closeGate, undefined)); + const interruptStarted = yield* Deferred.make(); + const harness = yield* makeWakeHarnessWithOptions({ + close: (sdkMessages) => + Deferred.await(closeGate).pipe(Effect.andThen(Queue.shutdown(sdkMessages))), + stopTask: () => Effect.void, + interrupt: Deferred.succeed(interruptStarted, undefined), + }); + const idAllocator = yield* IdAllocator.IdAllocatorV2; const now = yield* DateTime.now; - const probeAssistantText = "Probe after stale task-notification result."; - const recoveryAssistantText = "Recovered after the interrupt; continuing."; - const staleResultText = STALE_TASK_NOTIFICATION_RESULT_TEXT; - const hasMessageText = (text: string) => - harness.events.some( - (event) => event.type === "message.updated" && event.message.text === text, - ); + const attemptId = RunAttemptId.make("attempt-claude-interrupt-positive-notif"); + const providerTurnId = idAllocator.derive.providerTurn({ + driver: ClaudeAdapterV2.CLAUDE_PROVIDER, + nativeTurnId: `turn:${attemptId}`, + }); yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ threadId: harness.threadId, providerThread: harness.providerThread, now, - attemptId: RunAttemptId.make("attempt-claude-stale-notif-1"), - text: "Continue after interrupt.", + attemptId, + text: "Stop this task.", attachments: [], }), ); - yield* harness.until(() => harness.offeredMessages.length === 1); - - // After interrupt recovery, stale task notifications and their results can precede the root stream. - yield* Queue.offer( - harness.sdkMessages, - claudeSdkFrame({ - type: "system", - subtype: "task_notification", - task_id: "task-stale-stopped", - tool_use_id: "toolu-stale-stopped", - status: "stopped", - output_file: "/tmp/task-stale-stopped.log", - summary: "", - uuid: "00000000-0000-4000-8000-000000000107", - session_id: WAKE_NATIVE_SESSION, - }), - ); - yield* Queue.offer(harness.sdkMessages, staleTaskNotificationResult); - yield* Queue.offer( - harness.sdkMessages, - makeAssistantTextFrame({ - uuid: "00000000-0000-4000-8000-00000000010a", - text: probeAssistantText, - }), - ); - - yield* harness.until(() => hasMessageText(probeAssistantText)); - assert.lengthOf(harness.terminalEvents(), 0); - assert.isFalse(hasMessageText(staleResultText)); - - yield* Queue.offer( - harness.sdkMessages, - makeAssistantTextFrame({ - uuid: "00000000-0000-4000-8000-000000000108", - text: recoveryAssistantText, - }), - ); + yield* harness.runtime + .interruptTurn({ providerThread: harness.providerThread, providerTurnId }) + .pipe(Effect.forkScoped); + yield* Deferred.await(interruptStarted); yield* Queue.offer( harness.sdkMessages, makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000109", - result: recoveryAssistantText, + uuid: "00000000-0000-4000-8000-000000000115", + result: "Late result after interrupt.", + numTurns: 7, + origin: { kind: "task-notification" }, }), ); - - yield* harness.awaitTerminal(1); - assert.equal(harness.terminalEvents()[0]?.status, "completed"); - assert.isTrue(hasMessageText(recoveryAssistantText)); - assert.isFalse(hasMessageText(staleResultText)); + const terminalized = Exit.isSuccess(yield* harness.awaitTerminal(1).pipe(Effect.exit)); + yield* Deferred.succeed(closeGate, undefined); + assert.isTrue(terminalized); + assert.equal(harness.terminalEvents()[0]?.status, "interrupted"); + assert.lengthOf(harness.terminalEvents(), 1); }).pipe( Effect.provide( Layer.mergeAll( @@ -15654,103 +15529,36 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ), ), - ), - ); - - it.effect( - "emits one interrupted terminal for a positive task-notification result racing interrupt", - () => - Effect.scoped( - Effect.gen(function* () { - const closeGate = yield* Deferred.make(); - const testScope = yield* Scope.Scope; - yield* Scope.addFinalizer(testScope, Deferred.succeed(closeGate, undefined)); - const interruptStarted = yield* Deferred.make(); - const harness = yield* makeWakeHarnessWithOptions({ - close: (sdkMessages) => - Deferred.await(closeGate).pipe(Effect.andThen(Queue.shutdown(sdkMessages))), - stopTask: () => Effect.void, - interrupt: Deferred.succeed(interruptStarted, undefined), - }); - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const now = yield* DateTime.now; - const attemptId = RunAttemptId.make("attempt-claude-interrupt-positive-notif"); - const providerTurnId = idAllocator.derive.providerTurn({ - driver: ClaudeAdapterV2.CLAUDE_PROVIDER, - nativeTurnId: `turn:${attemptId}`, - }); - - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId, - text: "Stop this task.", - attachments: [], - }), - ); - yield* harness.runtime - .interruptTurn({ providerThread: harness.providerThread, providerTurnId }) - .pipe(Effect.forkScoped); - yield* Deferred.await(interruptStarted); - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000115", - result: "Late result after interrupt.", - numTurns: 7, - origin: { kind: "task-notification" }, - }), - ); - const terminalized = Exit.isSuccess(yield* harness.awaitTerminal(1).pipe(Effect.exit)); - yield* Deferred.succeed(closeGate, undefined); - assert.isTrue(terminalized); - assert.equal(harness.terminalEvents()[0]?.status, "interrupted"); - assert.lengthOf(harness.terminalEvents(), 1); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), - ), - ), - ), ); it.effect("settles a continuation turn immediately when no wake output is buffered", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-wake-3"), - text: "Background task completed.", - attachments: [], - messageCreatedBy: "agent", - messageCreationSource: "provider", - }), - ); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-wake-3"), + text: "Background task completed.", + attachments: [], + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); - yield* harness.awaitTerminal(1); - assert.equal(harness.terminalEvents()[0]?.status, "completed"); - assert.lengthOf(harness.offeredMessages, 0); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + yield* harness.awaitTerminal(1); + assert.equal(harness.terminalEvents()[0]?.status, "completed"); + assert.lengthOf(harness.offeredMessages, 0); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), @@ -15875,128 +15683,126 @@ describe("ClaudeAdapterV2 background wake turns", () => { ])( "keeps the wake run open for the turn Claude opens right after $label", ({ quotaFailureFirst }) => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - const subagents = [ - { taskId: "task-wake-pair-a", toolUseId: "toolu_wake_pair_a" }, - { taskId: "task-wake-pair-b", toolUseId: "toolu_wake_pair_b" }, - ]; - const wakeAttempt = RunAttemptId.make("attempt-claude-wake-pair-2"); + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const subagents = [ + { taskId: "task-wake-pair-a", toolUseId: "toolu_wake_pair_a" }, + { taskId: "task-wake-pair-b", toolUseId: "toolu_wake_pair_b" }, + ]; + const wakeAttempt = RunAttemptId.make("attempt-claude-wake-pair-2"); - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-wake-pair-1"), - text: "Audit both in the background.", - attachments: [], - }), - ); - for (const [index, subagent] of subagents.entries()) { - yield* Queue.offer( - harness.sdkMessages, - makeSubagentTaskStartedFrame({ - ...subagent, - uuid: `00000000-0000-4000-8000-00000000095${index}`, - }), - ); - } + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-wake-pair-1"), + text: "Audit both in the background.", + attachments: [], + }), + ); + for (const [index, subagent] of subagents.entries()) { yield* Queue.offer( harness.sdkMessages, - makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000952", result: "Started." }), + makeSubagentTaskStartedFrame({ + ...subagent, + uuid: `00000000-0000-4000-8000-00000000095${index}`, + }), ); - yield* harness.awaitTerminal(1); - - // Both subagents end together. Claude runs an empty turn for the first - // notification and opens the real one a millisecond later, all before - // the wake run attaches. - for (const [index, subagent] of subagents.entries()) { - yield* Queue.offer( - harness.sdkMessages, - makeSubagentNotificationFrame({ - ...subagent, - summary: "AUDITED", - uuid: `00000000-0000-4000-8000-00000000096${index}`, - }), - ); - } - // A rejected wake that precedes the empty turn still must not settle - // the run the live turn owns. - if (quotaFailureFirst) { - yield* harness.offerAndWait( - makeAssistantErrorFrame({ - uuid: "00000000-0000-4000-8000-000000000964", - error: "rate_limit", - }), - ); - yield* harness.offerAndWait( - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000965", - result: "You've hit your session limit · resets 11:10am (Australia/Sydney)", - isError: true, - apiErrorStatus: 429, - terminalReason: "api_error", - origin: { kind: "task-notification" }, - }), - ); - } - yield* Queue.offer(harness.sdkMessages, wakeTurnInit); + } + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000952", result: "Started." }), + ); + yield* harness.awaitTerminal(1); + + // Both subagents end together. Claude runs an empty turn for the first + // notification and opens the real one a millisecond later, all before + // the wake run attaches. + for (const [index, subagent] of subagents.entries()) { yield* Queue.offer( harness.sdkMessages, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000962", - result: "", - numTurns: 0, - origin: { kind: "task-notification" }, - terminalReason: null, + makeSubagentNotificationFrame({ + ...subagent, + summary: "AUDITED", + uuid: `00000000-0000-4000-8000-00000000096${index}`, }), ); + } + // A rejected wake that precedes the empty turn still must not settle + // the run the live turn owns. + if (quotaFailureFirst) { yield* harness.offerAndWait( - claudeSdkFrame({ ...wakeTurnInit, uuid: "00000000-0000-4000-8000-000000000963" }), + makeAssistantErrorFrame({ + uuid: "00000000-0000-4000-8000-000000000964", + error: "rate_limit", + }), ); - assert.lengthOf(harness.continuationRequests, 1); - - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: wakeAttempt, - text: "Background task completed.", - attachments: [], - providerTurnOrdinal: 2, - messageCreatedBy: "agent", - messageCreationSource: "provider", + yield* harness.offerAndWait( + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000965", + result: "You've hit your session limit · resets 11:10am (Australia/Sydney)", + isError: true, + apiErrorStatus: 429, + terminalReason: "api_error", + origin: { kind: "task-notification" }, }), ); - // Claude is still thinking, so the run stays working rather than - // settling with nothing to show. - assert.lengthOf(harness.terminalEvents(), 1); + } + yield* Queue.offer(harness.sdkMessages, wakeTurnInit); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000962", + result: "", + numTurns: 0, + origin: { kind: "task-notification" }, + terminalReason: null, + }), + ); + yield* harness.offerAndWait( + claudeSdkFrame({ ...wakeTurnInit, uuid: "00000000-0000-4000-8000-000000000963" }), + ); + assert.lengthOf(harness.continuationRequests, 1); - yield* harness.offerAndWait(wakeAssistant); - yield* harness.offerAndWait(wakeResult); - yield* harness.awaitTerminal(2); - assert.equal(harness.terminalEvents()[1]?.status, "completed"); - assert.lengthOf(harness.continuationRequests, 1); - assert.isTrue( - harness.events.some( - (event) => - event.type === "message.updated" && - event.message.runId === RunId.make(`run-${wakeAttempt}`) && - event.message.text === WAKE_RESULT_TEXT, - ), - ); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: wakeAttempt, + text: "Background task completed.", + attachments: [], + providerTurnOrdinal: 2, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + // Claude is still thinking, so the run stays working rather than + // settling with nothing to show. + assert.lengthOf(harness.terminalEvents(), 1); + + yield* harness.offerAndWait(wakeAssistant); + yield* harness.offerAndWait(wakeResult); + yield* harness.awaitTerminal(2); + assert.equal(harness.terminalEvents()[1]?.status, "completed"); + assert.lengthOf(harness.continuationRequests, 1); + assert.isTrue( + harness.events.some( + (event) => + event.type === "message.updated" && + event.message.runId === RunId.make(`run-${wakeAttempt}`) && + event.message.text === WAKE_RESULT_TEXT, + ), + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), @@ -16163,39 +15969,37 @@ describe("ClaudeAdapterV2 background wake turns", () => { // regression: #28331 a subagent whose process is gone must not stay running forever it.effect("ends a subagent whose process a Stop closed while a new turn replaced it", () => - Effect.scoped( - Effect.gen(function* () { - const { processQueues, changes, terminals, subagentStatus, exitFirstProcess } = - yield* stopRacedByReplacementTurn("raced-stop-replacement-close"); - yield* exitFirstProcess; - // The replacement's result follows the stopped process's exit on the event stream. - yield* Queue.offer( - processQueues[1]!, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000002803", - result: "Resumed.", - }), - ); - yield* changes.until(() => terminals().length === 2); + Effect.gen(function* () { + const { processQueues, changes, terminals, subagentStatus, exitFirstProcess } = + yield* stopRacedByReplacementTurn("raced-stop-replacement-close"); + yield* exitFirstProcess; + // The replacement's result follows the stopped process's exit on the event stream. + yield* Queue.offer( + processQueues[1]!, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000002803", + result: "Resumed.", + }), + ); + yield* changes.until(() => terminals().length === 2); - assert.strictEqual( - subagentStatus("raced-agent"), - "cancelled", - "the Stop ended the subagent's process, but the subagent is not cancelled", - ); - assert.strictEqual( - subagentStatus("own-agent"), - "running", - "the replacement process still runs its own subagent", - ); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + assert.strictEqual( + subagentStatus("raced-agent"), + "cancelled", + "the Stop ended the subagent's process, but the subagent is not cancelled", + ); + assert.strictEqual( + subagentStatus("own-agent"), + "running", + "the replacement process still runs its own subagent", + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), @@ -16203,31 +16007,29 @@ describe("ClaudeAdapterV2 background wake turns", () => { // regression: #28331 Stop must clear a subagent whose process is gone it.effect("stops a subagent whose process a Stop closed while a new turn replaced it", () => - Effect.scoped( - Effect.gen(function* () { - const { runtime, providerThread, exitFirstProcess } = yield* stopRacedByReplacementTurn( - "raced-stop-replacement-stop-subagent", - ); - yield* exitFirstProcess; - const stopped = yield* runtime.stopSubagent!({ - providerThread: { ...providerThread, status: "active" }, - nativeTaskId: "raced-agent", - }).pipe( - Effect.map((outcome) => outcome?.status ?? "no outcome"), - Effect.catch((error) => - Effect.succeed(`refused: ${"detail" in error ? error.detail : error.message}`), - ), - ); - - assert.strictEqual(stopped, "cancelled", "Stop of a subagent whose process is gone"); + Effect.gen(function* () { + const { runtime, providerThread, exitFirstProcess } = yield* stopRacedByReplacementTurn( + "raced-stop-replacement-stop-subagent", + ); + yield* exitFirstProcess; + const stopped = yield* runtime.stopSubagent!({ + providerThread: { ...providerThread, status: "active" }, + nativeTaskId: "raced-agent", }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + Effect.map((outcome) => outcome?.status ?? "no outcome"), + Effect.catch((error) => + Effect.succeed(`refused: ${"detail" in error ? error.detail : error.message}`), + ), + ); + + assert.strictEqual(stopped, "cancelled", "Stop of a subagent whose process is gone"); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), @@ -16235,244 +16037,239 @@ describe("ClaudeAdapterV2 background wake turns", () => { // regression: #28331 a process that exits late ends only the subagents it ran it.effect("leaves a stopping replacement's subagent to that replacement's exit", () => - Effect.scoped( - Effect.gen(function* () { - const { - runtime, - threadId, - providerThread, - processQueues, - closeRequested, - changes, - terminals, - subagentStatus, - exitFirstProcess, - exitProcess, - now, - } = yield* stopRacedByReplacementTurn("raced-stop-replacement-chain"); - const activeThread = { ...providerThread, status: "active" as const }; - const finishTurn = (index: number, turns: number, uuid: string) => - Queue.offer(processQueues[index]!, makeResultFrame({ uuid, result: "Done." })).pipe( - Effect.andThen(changes.until(() => terminals().length === turns)), - ); - const startTurn = (label: string, providerTurnOrdinal: number) => - runtime.startTurn( - makeClaudeTestTurnInput({ - threadId, - providerThread: activeThread, - now, - attemptId: RunAttemptId.make(`attempt-claude-raced-stop-replacement-chain-${label}`), - text: "Carry on.", - attachments: [], - providerTurnOrdinal, - }), - ); - - yield* finishTurn(1, 2, "00000000-0000-4000-8000-000000002805"); - const stopSecond = yield* runtime - .interruptTurn({ + Effect.gen(function* () { + const { + runtime, + threadId, + providerThread, + processQueues, + closeRequested, + changes, + terminals, + subagentStatus, + exitFirstProcess, + exitProcess, + now, + } = yield* stopRacedByReplacementTurn("raced-stop-replacement-chain"); + const activeThread = { ...providerThread, status: "active" as const }; + const finishTurn = (index: number, turns: number, uuid: string) => + Queue.offer(processQueues[index]!, makeResultFrame({ uuid, result: "Done." })).pipe( + Effect.andThen(changes.until(() => terminals().length === turns)), + ); + const startTurn = (label: string, providerTurnOrdinal: number) => + runtime.startTurn( + makeClaudeTestTurnInput({ + threadId, providerThread: activeThread, - providerTurnId: terminals()[1]!.providerTurnId, - requestRuntimeRestart: true, - }) - .pipe(Effect.forkScoped); - yield* Deferred.await(closeRequested[1]!); - yield* startTurn("c", 3); - assert.lengthOf(processQueues, 3); - - yield* exitFirstProcess; - yield* finishTurn(2, 3, "00000000-0000-4000-8000-000000002806"); - assert.strictEqual( - subagentStatus("raced-agent"), - "cancelled", - "the first process exited after a Stop, but its subagent is not cancelled", - ); - assert.strictEqual( - subagentStatus("own-agent"), - "running", - "the first process ended a subagent the second process still ran", + now, + attemptId: RunAttemptId.make(`attempt-claude-raced-stop-replacement-chain-${label}`), + text: "Carry on.", + attachments: [], + providerTurnOrdinal, + }), ); - yield* exitProcess(1, stopSecond); - yield* startTurn("d", 4); - yield* finishTurn(2, 4, "00000000-0000-4000-8000-000000002807"); - assert.strictEqual( - subagentStatus("own-agent"), - "cancelled", - "the second process exited after a Stop, but its subagent is not cancelled", - ); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + yield* finishTurn(1, 2, "00000000-0000-4000-8000-000000002805"); + const stopSecond = yield* runtime + .interruptTurn({ + providerThread: activeThread, + providerTurnId: terminals()[1]!.providerTurnId, + requestRuntimeRestart: true, + }) + .pipe(Effect.forkScoped); + yield* Deferred.await(closeRequested[1]!); + yield* startTurn("c", 3); + assert.lengthOf(processQueues, 3); + + yield* exitFirstProcess; + yield* finishTurn(2, 3, "00000000-0000-4000-8000-000000002806"); + assert.strictEqual( + subagentStatus("raced-agent"), + "cancelled", + "the first process exited after a Stop, but its subagent is not cancelled", + ); + assert.strictEqual( + subagentStatus("own-agent"), + "running", + "the first process ended a subagent the second process still ran", + ); + + yield* exitProcess(1, stopSecond); + yield* startTurn("d", 4); + yield* finishTurn(2, 4, "00000000-0000-4000-8000-000000002807"); + assert.strictEqual( + subagentStatus("own-agent"), + "cancelled", + "the second process exited after a Stop, but its subagent is not cancelled", + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), ); it.effect("streams a background subagent's work while the root turn is idle", () => - Effect.scoped( - Effect.gen(function* () { - const TASK_ID = "task-live-idle"; - const TOOL_USE_ID = "toolu-live-idle"; - const STRADDLING_BASH = "toolu-live-straddling-bash"; - const IDLE_BASH = "toolu-live-idle-bash"; - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - const latestToolItem = (nativeId: string) => - harness.events.findLast( - (event) => - event.type === "turn_item.updated" && - event.turnItem.nativeItemRef?.nativeId === nativeId, - ); - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-live-idle-1"), - text: "Audit in the background.", - attachments: [], - }), + Effect.gen(function* () { + const TASK_ID = "task-live-idle"; + const TOOL_USE_ID = "toolu-live-idle"; + const STRADDLING_BASH = "toolu-live-straddling-bash"; + const IDLE_BASH = "toolu-live-idle-bash"; + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const latestToolItem = (nativeId: string) => + harness.events.findLast( + (event) => + event.type === "turn_item.updated" && + event.turnItem.nativeItemRef?.nativeId === nativeId, ); - for (const frame of [ - makeSubagentTaskStartedFrame({ - taskId: TASK_ID, - toolUseId: TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000951", - }), - ...makeSubagentAssistantFrames({ - parentToolUseId: TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000952", - bashToolUseId: STRADDLING_BASH, - }), - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000953", - result: "Auditing in the background.", - }), - ]) { - yield* Queue.offer(harness.sdkMessages, frame); - } - yield* harness.awaitTerminal(1); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-live-idle-1"), + text: "Audit in the background.", + attachments: [], + }), + ); + for (const frame of [ + makeSubagentTaskStartedFrame({ + taskId: TASK_ID, + toolUseId: TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000951", + }), + ...makeSubagentAssistantFrames({ + parentToolUseId: TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000952", + bashToolUseId: STRADDLING_BASH, + }), + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000953", + result: "Auditing in the background.", + }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* harness.awaitTerminal(1); - for (const frame of [ - makeSubagentToolResultFrame({ - parentToolUseId: TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000954", - toolUseId: STRADDLING_BASH, - }), - ...makeSubagentAssistantFrames({ - parentToolUseId: TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000955", - text: "Still auditing.", - bashToolUseId: IDLE_BASH, - }), - claudeSdkFrame({ - type: "system", - subtype: "task_progress", - task_id: TASK_ID, - description: "Reading the last commits", - uuid: "00000000-0000-4000-8000-000000000956", - session_id: WAKE_NATIVE_SESSION, - }), - ]) { - yield* Queue.offer(harness.sdkMessages, frame); - } - yield* harness.until(() => { - const event = latestToolItem(IDLE_BASH); - return event?.type === "turn_item.updated" && event.turnItem.status === "running"; - }); - yield* harness.until(() => - harness.events.some( - (event) => - event.type === "subagent.updated" && - event.subagent.progress === "Reading the last commits", - ), - ); - const routing = subagentRouting(harness.events, [STRADDLING_BASH, IDLE_BASH]); - assert.deepEqual(routing.assistantTexts(routing.childThreadId), ["Still auditing."]); - const straddling = latestToolItem(STRADDLING_BASH); - const straddlingItem = - straddling?.type === "turn_item.updated" ? straddling.turnItem : undefined; - assert.equal(straddlingItem?.status, "completed"); - assert.equal( - straddlingItem?.type === "command_execution" ? straddlingItem.input : undefined, - "git log -5", - ); - assert.equal(harness.continuationRequests.length, 0); + for (const frame of [ + makeSubagentToolResultFrame({ + parentToolUseId: TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000954", + toolUseId: STRADDLING_BASH, + }), + ...makeSubagentAssistantFrames({ + parentToolUseId: TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000955", + text: "Still auditing.", + bashToolUseId: IDLE_BASH, + }), + claudeSdkFrame({ + type: "system", + subtype: "task_progress", + task_id: TASK_ID, + description: "Reading the last commits", + uuid: "00000000-0000-4000-8000-000000000956", + session_id: WAKE_NATIVE_SESSION, + }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* harness.until(() => { + const event = latestToolItem(IDLE_BASH); + return event?.type === "turn_item.updated" && event.turnItem.status === "running"; + }); + yield* harness.until(() => + harness.events.some( + (event) => + event.type === "subagent.updated" && + event.subagent.progress === "Reading the last commits", + ), + ); + const routing = subagentRouting(harness.events, [STRADDLING_BASH, IDLE_BASH]); + assert.deepEqual(routing.assistantTexts(routing.childThreadId), ["Still auditing."]); + const straddling = latestToolItem(STRADDLING_BASH); + const straddlingItem = + straddling?.type === "turn_item.updated" ? straddling.turnItem : undefined; + assert.equal(straddlingItem?.status, "completed"); + assert.equal( + straddlingItem?.type === "command_execution" ? straddlingItem.input : undefined, + "git log -5", + ); + assert.equal(harness.continuationRequests.length, 0); - for (const frame of [ - makeSubagentToolResultFrame({ - parentToolUseId: TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000957", - toolUseId: IDLE_BASH, - }), - makeSubagentNotificationFrame({ - taskId: TASK_ID, - toolUseId: TOOL_USE_ID, - summary: "Still auditing.", - uuid: "00000000-0000-4000-8000-000000000958", - }), - ]) { - yield* Queue.offer(harness.sdkMessages, frame); - } - yield* harness.awaitContinuation(1); - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000959", - result: "The auditor finished.", - }), - ); - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-live-idle-2"), - text: "Background task completed.", - attachments: [], - providerTurnOrdinal: 2, - messageCreatedBy: "agent", - messageCreationSource: "provider", - }), - ); - yield* harness.awaitTerminal(2); + for (const frame of [ + makeSubagentToolResultFrame({ + parentToolUseId: TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000957", + toolUseId: IDLE_BASH, + }), + makeSubagentNotificationFrame({ + taskId: TASK_ID, + toolUseId: TOOL_USE_ID, + summary: "Still auditing.", + uuid: "00000000-0000-4000-8000-000000000958", + }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* harness.awaitContinuation(1); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000959", + result: "The auditor finished.", + }), + ); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-live-idle-2"), + text: "Background task completed.", + attachments: [], + providerTurnOrdinal: 2, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + yield* harness.awaitTerminal(2); - const finalRouting = subagentRouting(harness.events, [STRADDLING_BASH, IDLE_BASH]); - assert.deepEqual(finalRouting.assistantTexts(finalRouting.childThreadId), [ - "Still auditing.", - ]); - for (const nativeId of [STRADDLING_BASH, IDLE_BASH]) { - assert.deepEqual( - [...(finalRouting.toolThreadIds.get(nativeId) ?? [])], - [finalRouting.childThreadId], - ); - const statuses = harness.events.flatMap((event) => - event.type === "turn_item.updated" && - event.turnItem.nativeItemRef?.nativeId === nativeId - ? [`${event.turnItem.type}:${event.turnItem.status}`] - : [], - ); - assert.deepEqual(statuses, ["command_execution:running", "command_execution:completed"]); - } - const finalSubagent = harness.events.findLast((event) => event.type === "subagent.updated"); - assert.equal( - finalSubagent?.type === "subagent.updated" && finalSubagent.subagent.status, - "completed", + const finalRouting = subagentRouting(harness.events, [STRADDLING_BASH, IDLE_BASH]); + assert.deepEqual(finalRouting.assistantTexts(finalRouting.childThreadId), [ + "Still auditing.", + ]); + for (const nativeId of [STRADDLING_BASH, IDLE_BASH]) { + assert.deepEqual( + [...(finalRouting.toolThreadIds.get(nativeId) ?? [])], + [finalRouting.childThreadId], ); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + const statuses = harness.events.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.nativeItemRef?.nativeId === nativeId + ? [`${event.turnItem.type}:${event.turnItem.status}`] + : [], + ); + assert.deepEqual(statuses, ["command_execution:running", "command_execution:completed"]); + } + const finalSubagent = harness.events.findLast((event) => event.type === "subagent.updated"); + assert.equal( + finalSubagent?.type === "subagent.updated" && finalSubagent.subagent.status, + "completed", + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), @@ -16483,257 +16280,868 @@ describe("ClaudeAdapterV2 background wake turns", () => { // resumes the subagent with a new task_started for the same task and launch // tool_use_id while the root is idle. Frame order mirrors a live capture. it.effect("reopens a completed background subagent that resumes while the root is idle", () => - Effect.scoped( - Effect.gen(function* () { - const TASK_ID = "ae3f32459af762a5f"; - const LAUNCH = "toolu_01LcidmwEK1Y6GC6LpX3kvxX"; - const OWN_SHELL = "toolu_01QAahgijM2KRBBSkWxnui4g"; - const RESUMED_BASH = "toolu_01CkA1juicPNCtQrqKL8h6Rz"; - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - let sequence = 0; - const system = (frame: object) => - claudeSdkFrame({ - type: "system", - ...frame, - uuid: `00000000-0000-4000-8000-0000000162${String(++sequence).padStart(2, "0")}`, - session_id: WAKE_NATIVE_SESSION, - }); - const agentEntry = (runId: string) => ({ - task_id: TASK_ID, - run_id: runId, - task_type: "local_agent", - subagent_type: "general-purpose", - description: "Audit recent commits", + Effect.gen(function* () { + const TASK_ID = "ae3f32459af762a5f"; + const LAUNCH = "toolu_01LcidmwEK1Y6GC6LpX3kvxX"; + const OWN_SHELL = "toolu_01QAahgijM2KRBBSkWxnui4g"; + const RESUMED_BASH = "toolu_01CkA1juicPNCtQrqKL8h6Rz"; + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + let sequence = 0; + const system = (frame: object) => + claudeSdkFrame({ + type: "system", + ...frame, + uuid: `00000000-0000-4000-8000-0000000162${String(++sequence).padStart(2, "0")}`, + session_id: WAKE_NATIVE_SESSION, }); - const shellEntry = (taskId: string) => ({ + const agentEntry = (runId: string) => ({ + task_id: TASK_ID, + run_id: runId, + task_type: "local_agent", + subagent_type: "general-purpose", + description: "Audit recent commits", + }); + const shellEntry = (taskId: string) => ({ + task_id: taskId, + run_id: `run-${taskId}`, + task_type: "local_bash", + description: "Sleep", + parent_task_id: TASK_ID, + }); + const roster = (tasks: ReadonlyArray) => + system({ subtype: "background_tasks_changed", tasks }); + const shellStarted = (taskId: string, toolUseId: string) => + system({ + subtype: "task_started", task_id: taskId, - run_id: `run-${taskId}`, + tool_use_id: toolUseId, task_type: "local_bash", description: "Sleep", - parent_task_id: TASK_ID, }); - const roster = (tasks: ReadonlyArray) => - system({ subtype: "background_tasks_changed", tasks }); - const shellStarted = (taskId: string, toolUseId: string) => - system({ - subtype: "task_started", - task_id: taskId, - tool_use_id: toolUseId, - task_type: "local_bash", - description: "Sleep", - }); - const startTurn = (ordinal: number, continuation: boolean) => - harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make(`attempt-claude-self-resume-${ordinal}`), - text: continuation ? "Background task completed." : "Launch a background agent.", - attachments: [], - providerTurnOrdinal: ordinal, - ...(continuation - ? { - messageCreatedBy: "agent" as const, - messageCreationSource: "provider" as const, - } - : {}), - }), - ); - const subagentUpdates = () => - harness.events.flatMap((event) => - event.type === "subagent.updated" ? [event.subagent] : [], - ); - - yield* startTurn(1, false); - for (const frame of [ - roster([agentEntry("run-1")]), - makeSubagentTaskStartedFrame({ - taskId: TASK_ID, - toolUseId: LAUNCH, - uuid: "00000000-0000-4000-8000-000000016101", - }), - makeResultFrame({ uuid: "00000000-0000-4000-8000-000000016102", result: "LAUNCHED" }), - ]) { - yield* harness.offerAndWait(frame); - } - yield* Queue.take(harness.terminalReceipts); - - // The subagent starts its own background shell, ends its turn, and - // Claude reports it completed; the parent wakes, answers, goes idle. - for (const frame of [ - ...makeSubagentAssistantFrames({ - parentToolUseId: LAUNCH, - uuid: "00000000-0000-4000-8000-000000016103", - bashToolUseId: OWN_SHELL, - }), - roster([agentEntry("run-1"), shellEntry("b455x9pq5")]), - shellStarted("b455x9pq5", OWN_SHELL), - makeSubagentToolResultFrame({ - parentToolUseId: LAUNCH, - uuid: "00000000-0000-4000-8000-000000016104", - toolUseId: OWN_SHELL, - }), - makeSubagentNotificationFrame({ - taskId: TASK_ID, - toolUseId: LAUNCH, - summary: "WAITING", - uuid: "00000000-0000-4000-8000-000000016105", + const startTurn = (ordinal: number, continuation: boolean) => + harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make(`attempt-claude-self-resume-${ordinal}`), + text: continuation ? "Background task completed." : "Launch a background agent.", + attachments: [], + providerTurnOrdinal: ordinal, + ...(continuation + ? { + messageCreatedBy: "agent" as const, + messageCreationSource: "provider" as const, + } + : {}), }), - roster([shellEntry("b455x9pq5")]), - wakeTurnInit, - ]) { - yield* harness.offerAndWait(frame); - } - yield* Queue.take(harness.continuationReceipts); - yield* startTurn(2, true); - yield* harness.offerAndWait( - makeResultFrame({ uuid: "00000000-0000-4000-8000-000000016106", result: "Noted." }), ); - yield* Queue.take(harness.terminalReceipts); + const subagentUpdates = () => + harness.events.flatMap((event) => + event.type === "subagent.updated" ? [event.subagent] : [], + ); - // Its shell finishes and Claude resumes it under a new native run. - for (const frame of [ - system({ - subtype: "task_notification", - task_id: "b455x9pq5", - tool_use_id: OWN_SHELL, - status: "completed", - summary: "Background command completed (exit code 0)", - output_file: "/tmp/b455x9pq5.output", - }), - roster([]), - roster([agentEntry("run-2")]), - makeSubagentTaskStartedFrame({ - taskId: TASK_ID, - toolUseId: LAUNCH, - uuid: "00000000-0000-4000-8000-000000016107", - }), - ]) { - yield* harness.offerAndWait(frame); - } - // Events reach the app only through a run, and the launching run ended - // with the interim completion. - assert.equal(harness.continuationRequests.length, 2); + yield* startTurn(1, false); + for (const frame of [ + roster([agentEntry("run-1")]), + makeSubagentTaskStartedFrame({ + taskId: TASK_ID, + toolUseId: LAUNCH, + uuid: "00000000-0000-4000-8000-000000016101", + }), + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000016102", result: "LAUNCHED" }), + ]) { + yield* harness.offerAndWait(frame); + } + yield* Queue.take(harness.terminalReceipts); - yield* Queue.take(harness.continuationReceipts); - yield* startTurn(3, true); - // Claude does not wake the root for this, so the root must not stay busy. - assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "completed"); - yield* startTurn(4, false); - yield* harness.offerAndWait( - makeResultFrame({ uuid: "00000000-0000-4000-8000-000000016110", result: "PONG" }), - ); - assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "completed"); - for (const frame of [ - ...makeSubagentAssistantFrames({ - parentToolUseId: LAUNCH, - uuid: "00000000-0000-4000-8000-000000016108", - bashToolUseId: RESUMED_BASH, - }), - shellStarted("beyhxkdpc", RESUMED_BASH), - roster([agentEntry("run-2"), shellEntry("beyhxkdpc")]), - ]) { - yield* harness.offerAndWait(frame); - } - // Events are ordered: once this roster is recorded, so is every earlier event. - yield* harness.until(() => - harness.events.some( - (event) => - event.type === "provider_thread.updated" && - event.providerThread.pendingBackgroundTasks?.some( - (task) => task.taskId === "beyhxkdpc", - ) === true, - ), - ); - const resumed = subagentUpdates().at(-1); - assert.equal(resumed?.status, "running"); - assert.isNull(resumed?.completedAt); - assert.equal(resumed?.runId, "run-attempt-claude-self-resume-3"); - const routing = subagentRouting(harness.events, [RESUMED_BASH]); - assert.deepEqual( - [...(routing.toolThreadIds.get(RESUMED_BASH) ?? [])], - [routing.childThreadId], - ); + // The subagent starts its own background shell, ends its turn, and + // Claude reports it completed; the parent wakes, answers, goes idle. + for (const frame of [ + ...makeSubagentAssistantFrames({ + parentToolUseId: LAUNCH, + uuid: "00000000-0000-4000-8000-000000016103", + bashToolUseId: OWN_SHELL, + }), + roster([agentEntry("run-1"), shellEntry("b455x9pq5")]), + shellStarted("b455x9pq5", OWN_SHELL), + makeSubagentToolResultFrame({ + parentToolUseId: LAUNCH, + uuid: "00000000-0000-4000-8000-000000016104", + toolUseId: OWN_SHELL, + }), + makeSubagentNotificationFrame({ + taskId: TASK_ID, + toolUseId: LAUNCH, + summary: "WAITING", + uuid: "00000000-0000-4000-8000-000000016105", + }), + roster([shellEntry("b455x9pq5")]), + wakeTurnInit, + ]) { + yield* harness.offerAndWait(frame); + } + yield* Queue.take(harness.continuationReceipts); + yield* startTurn(2, true); + yield* harness.offerAndWait( + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000016106", result: "Noted." }), + ); + yield* Queue.take(harness.terminalReceipts); - yield* harness.offerAndWait( - makeSubagentNotificationFrame({ - taskId: TASK_ID, - toolUseId: LAUNCH, - summary: "DONE", - uuid: "00000000-0000-4000-8000-000000016109", - }), - ); - yield* harness.until(() => subagentUpdates().at(-1)?.result === "DONE"); - assert.equal(subagentUpdates().at(-1)?.status, "completed"); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + // Its shell finishes and Claude resumes it under a new native run. + for (const frame of [ + system({ + subtype: "task_notification", + task_id: "b455x9pq5", + tool_use_id: OWN_SHELL, + status: "completed", + summary: "Background command completed (exit code 0)", + output_file: "/tmp/b455x9pq5.output", + }), + roster([]), + roster([agentEntry("run-2")]), + makeSubagentTaskStartedFrame({ + taskId: TASK_ID, + toolUseId: LAUNCH, + uuid: "00000000-0000-4000-8000-000000016107", + }), + ]) { + yield* harness.offerAndWait(frame); + } + // Events reach the app only through a run, and the launching run ended + // with the interim completion. + assert.equal(harness.continuationRequests.length, 2); + + yield* Queue.take(harness.continuationReceipts); + yield* startTurn(3, true); + // Claude does not wake the root for this, so the root must not stay busy. + assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "completed"); + yield* startTurn(4, false); + yield* harness.offerAndWait( + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000016110", result: "PONG" }), + ); + assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "completed"); + for (const frame of [ + ...makeSubagentAssistantFrames({ + parentToolUseId: LAUNCH, + uuid: "00000000-0000-4000-8000-000000016108", + bashToolUseId: RESUMED_BASH, + }), + shellStarted("beyhxkdpc", RESUMED_BASH), + roster([agentEntry("run-2"), shellEntry("beyhxkdpc")]), + ]) { + yield* harness.offerAndWait(frame); + } + // Events are ordered: once this roster is recorded, so is every earlier event. + yield* harness.until(() => + harness.events.some( + (event) => + event.type === "provider_thread.updated" && + event.providerThread.pendingBackgroundTasks?.some( + (task) => task.taskId === "beyhxkdpc", + ) === true, + ), + ); + const resumed = subagentUpdates().at(-1); + assert.equal(resumed?.status, "running"); + assert.isNull(resumed?.completedAt); + assert.equal(resumed?.runId, "run-attempt-claude-self-resume-3"); + const routing = subagentRouting(harness.events, [RESUMED_BASH]); + assert.deepEqual( + [...(routing.toolThreadIds.get(RESUMED_BASH) ?? [])], + [routing.childThreadId], + ); + + yield* harness.offerAndWait( + makeSubagentNotificationFrame({ + taskId: TASK_ID, + toolUseId: LAUNCH, + summary: "DONE", + uuid: "00000000-0000-4000-8000-000000016109", + }), + ); + yield* harness.until(() => subagentUpdates().at(-1)?.result === "DONE"); + assert.equal(subagentUpdates().at(-1)?.status, "completed"); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), ); it.effect("keeps a nested subagent's launch in order while the root turn is idle", () => - Effect.scoped( - Effect.gen(function* () { - const OUTER_TASK_ID = "task-idle-outer"; - const OUTER_TOOL_USE_ID = "toolu-idle-outer"; - const NESTED_TASK_ID = "task-idle-nested"; - const NESTED_TOOL_USE_ID = "toolu-idle-nested"; - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - yield* harness.runtime.startTurn( + Effect.gen(function* () { + const OUTER_TASK_ID = "task-idle-outer"; + const OUTER_TOOL_USE_ID = "toolu-idle-outer"; + const NESTED_TASK_ID = "task-idle-nested"; + const NESTED_TOOL_USE_ID = "toolu-idle-nested"; + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-idle-nested-1"), + text: "Audit in the background.", + attachments: [], + }), + ); + for (const frame of [ + makeSubagentTaskStartedFrame({ + taskId: OUTER_TASK_ID, + toolUseId: OUTER_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000971", + }), + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000972", + result: "Auditing in the background.", + }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* harness.awaitTerminal(1); + + for (const frame of [ + claudeSdkFrame({ + type: "assistant", + message: { + model: "claude-sonnet-4-6", + id: "msg_idle_nested_launch", + type: "message", + role: "assistant", + content: [ + { + type: "tool_use", + id: NESTED_TOOL_USE_ID, + name: "Agent", + input: { description: "Nested check", prompt: "Reply with NESTED_OK" }, + }, + ], + }, + parent_tool_use_id: OUTER_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000973", + session_id: WAKE_NATIVE_SESSION, + }), + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: NESTED_TASK_ID, + tool_use_id: NESTED_TOOL_USE_ID, + description: "Nested check", + subagent_type: "general-purpose", + is_backgrounded: false, + spawn_depth: 2, + task_type: "local_agent", + prompt: "Reply with NESTED_OK", + uuid: "00000000-0000-4000-8000-000000000974", + session_id: WAKE_NATIVE_SESSION, + }), + claudeSdkFrame({ + type: "system", + subtype: "task_progress", + task_id: OUTER_TASK_ID, + description: "Waiting on the nested check", + uuid: "00000000-0000-4000-8000-000000000984", + session_id: WAKE_NATIVE_SESSION, + }), + ...makeSubagentAssistantFrames({ + parentToolUseId: OUTER_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000988", + messageId: "msg_idle_outer_working", + text: "OUTER_WORKING", + }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* harness.until(() => + harness.events.some( + (event) => event.type === "message.updated" && event.message.text === "OUTER_WORKING", + ), + ); + assert.lengthOf(harness.continuationRequests, 0); + for (const frame of [ + ...makeSubagentAssistantFrames({ + parentToolUseId: NESTED_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000975", + text: "NESTED_OK", + }), + makeSubagentNotificationFrame({ + taskId: NESTED_TASK_ID, + toolUseId: NESTED_TOOL_USE_ID, + summary: "NESTED_OK", + uuid: "00000000-0000-4000-8000-000000000976", + }), + makeSubagentToolResultFrame({ + parentToolUseId: OUTER_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000977", + toolUseId: NESTED_TOOL_USE_ID, + }), + ...makeSubagentAssistantFrames({ + parentToolUseId: OUTER_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000978", + text: "OUTER_DONE", + }), + makeSubagentNotificationFrame({ + taskId: OUTER_TASK_ID, + toolUseId: OUTER_TOOL_USE_ID, + summary: "OUTER_DONE", + uuid: "00000000-0000-4000-8000-000000000979", + }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* harness.awaitContinuation(1); + yield* harness.until(() => + harness.events.some( + (event) => + event.type === "subagent.updated" && + event.subagent.progress === "Waiting on the nested check", + ), + ); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000980", + result: "The audit finished.", + }), + ); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-idle-nested-2"), + text: "Background task completed.", + attachments: [], + providerTurnOrdinal: 2, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + yield* harness.awaitTerminal(2); + + assert.isFalse( + harness.events.some( + (event) => + event.type === "turn_item.updated" && + event.turnItem.nativeItemRef?.nativeId === NESTED_TOOL_USE_ID, + ), + ); + const finalSubagents = new Map( + harness.events.flatMap((event) => + event.type === "subagent.updated" + ? [[event.subagent.nativeTaskRef?.nativeId, event.subagent] as const] + : [], + ), + ); + const outer = finalSubagents.get(OUTER_TASK_ID); + const nested = finalSubagents.get(NESTED_TASK_ID); + assert.equal(outer?.status, "completed"); + assert.equal(nested?.status, "completed"); + assert.equal(nested?.parentNodeId, outer?.id); + assert.deepEqual( + subagentRouting(harness.events, []).assistantTexts(outer?.childThreadId ?? undefined), + ["OUTER_WORKING", "OUTER_DONE"], + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + + it.effect("ends an earlier turn's nested subagent while the root turn is idle", () => + Effect.gen(function* () { + const OUTER_TASK_ID = "task-earlier-outer"; + const OUTER_TOOL_USE_ID = "toolu-earlier-outer"; + const NESTED_TASK_ID = "task-earlier-nested"; + const NESTED_TOOL_USE_ID = "toolu-earlier-nested"; + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const userTurn = (attemptId: string, providerTurnOrdinal: number) => + harness.runtime.startTurn( makeClaudeTestTurnInput({ threadId: harness.threadId, providerThread: harness.providerThread, now, - attemptId: RunAttemptId.make("attempt-claude-idle-nested-1"), - text: "Audit in the background.", + attemptId: RunAttemptId.make(attemptId), + text: "Keep auditing.", attachments: [], + providerTurnOrdinal, }), ); - for (const frame of [ - makeSubagentTaskStartedFrame({ - taskId: OUTER_TASK_ID, - toolUseId: OUTER_TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000971", - }), - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000972", - result: "Auditing in the background.", - }), - ]) { - yield* Queue.offer(harness.sdkMessages, frame); - } - yield* harness.awaitTerminal(1); + yield* userTurn("attempt-claude-earlier-nested-1", 1); + for (const frame of [ + makeSubagentTaskStartedFrame({ + taskId: OUTER_TASK_ID, + toolUseId: OUTER_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000a01", + }), + claudeSdkFrame({ + type: "assistant", + message: { + model: "claude-sonnet-4-6", + id: "msg_earlier_nested_launch", + type: "message", + role: "assistant", + content: [ + { + type: "tool_use", + id: NESTED_TOOL_USE_ID, + name: "Agent", + input: { description: "Nested check", prompt: "Reply with NESTED_OK" }, + }, + ], + }, + parent_tool_use_id: OUTER_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000a02", + session_id: WAKE_NATIVE_SESSION, + }), + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: NESTED_TASK_ID, + tool_use_id: NESTED_TOOL_USE_ID, + description: "Nested check", + subagent_type: "general-purpose", + is_backgrounded: false, + spawn_depth: 2, + task_type: "local_agent", + prompt: "Reply with NESTED_OK", + uuid: "00000000-0000-4000-8000-000000000a03", + session_id: WAKE_NATIVE_SESSION, + }), + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000a04", + result: "Auditing in the background.", + }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* harness.awaitTerminal(1); + yield* userTurn("attempt-claude-earlier-nested-2", 2); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000a05", result: "Still on it." }), + ); + yield* harness.awaitTerminal(2); + + for (const frame of [ + makeSubagentNotificationFrame({ + taskId: NESTED_TASK_ID, + toolUseId: NESTED_TOOL_USE_ID, + summary: "NESTED_OK", + uuid: "00000000-0000-4000-8000-000000000a06", + }), + makeSubagentToolResultFrame({ + parentToolUseId: OUTER_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000a07", + toolUseId: NESTED_TOOL_USE_ID, + }), + ...makeSubagentAssistantFrames({ + parentToolUseId: OUTER_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000a08", + text: "OUTER_AFTER_NESTED", + }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* harness.until(() => + harness.events.some( + (event) => + event.type === "message.updated" && event.message.text === "OUTER_AFTER_NESTED", + ), + ); - for (const frame of [ - claudeSdkFrame({ - type: "assistant", - message: { - model: "claude-sonnet-4-6", - id: "msg_idle_nested_launch", - type: "message", - role: "assistant", - content: [ - { - type: "tool_use", - id: NESTED_TOOL_USE_ID, - name: "Agent", - input: { description: "Nested check", prompt: "Reply with NESTED_OK" }, - }, - ], - }, - parent_tool_use_id: OUTER_TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000973", - session_id: WAKE_NATIVE_SESSION, + assert.isFalse( + harness.events.some( + (event) => + event.type === "turn_item.updated" && + event.turnItem.nativeItemRef?.nativeId === NESTED_TOOL_USE_ID, + ), + ); + const nested = harness.events.findLast( + (event) => + event.type === "subagent.updated" && + event.subagent.nativeTaskRef?.nativeId === NESTED_TASK_ID, + ); + assert.equal(nested?.type === "subagent.updated" && nested.subagent.status, "completed"); + assert.lengthOf(harness.continuationRequests, 0); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + + it.effect("keeps an idle-launched nested subagent completed through a sibling's wake", () => + Effect.gen(function* () { + const OUTER_TASK_ID = "task-16355-outer"; + const OUTER_TOOL_USE_ID = "toolu-16355-outer"; + const NESTED_TASK_ID = "task-16355-nested"; + const NESTED_TOOL_USE_ID = "toolu-16355-nested"; + const SIBLING_TASK_ID = "task-16355-sibling"; + const SIBLING_TOOL_USE_ID = "toolu-16355-sibling"; + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const nestedStatuses = () => + harness.events.flatMap((event) => + event.type === "subagent.updated" && + event.subagent.nativeTaskRef?.nativeId === NESTED_TASK_ID + ? [event.subagent.status] + : [], + ); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-16355-1"), + text: "Run two audits in the background.", + attachments: [], + }), + ); + for (const frame of [ + makeSubagentTaskStartedFrame({ + taskId: OUTER_TASK_ID, + toolUseId: OUTER_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000016301", + }), + makeSubagentTaskStartedFrame({ + taskId: SIBLING_TASK_ID, + toolUseId: SIBLING_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000016302", + }), + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000016303", + result: "Both audits run in the background.", + }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* harness.awaitTerminal(1); + + // The background subagent launches a foreground nested one while the root is idle. + for (const frame of [ + claudeSdkFrame({ + type: "assistant", + message: { + model: "claude-sonnet-4-6", + id: "msg_16355_nested_launch", + type: "message", + role: "assistant", + content: [ + { + type: "tool_use", + id: NESTED_TOOL_USE_ID, + name: "Agent", + input: { description: "Nested check", prompt: "Sleep, then reply NESTED_OK" }, + }, + ], + }, + parent_tool_use_id: OUTER_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000016304", + session_id: WAKE_NATIVE_SESSION, + }), + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: NESTED_TASK_ID, + tool_use_id: NESTED_TOOL_USE_ID, + description: "Nested check", + subagent_type: "general-purpose", + is_backgrounded: false, + spawn_depth: 2, + task_type: "local_agent", + prompt: "Sleep, then reply NESTED_OK", + uuid: "00000000-0000-4000-8000-000000016305", + session_id: WAKE_NATIVE_SESSION, + }), + claudeSdkFrame({ + type: "system", + subtype: "task_progress", + task_id: NESTED_TASK_ID, + description: "Sleeping", + uuid: "00000000-0000-4000-8000-000000016306", + session_id: WAKE_NATIVE_SESSION, + }), + ]) { + yield* harness.offerAndWait(frame); + } + + // A user turn runs while the nested and then the outer subagent finish. + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-16355-2"), + text: "Sleep for a while.", + attachments: [], + providerTurnOrdinal: 2, + }), + ); + yield* harness.until(() => harness.offeredMessages.length === 2); + for (const frame of [ + makeSubagentNotificationFrame({ + taskId: NESTED_TASK_ID, + toolUseId: NESTED_TOOL_USE_ID, + summary: "NESTED_OK", + uuid: "00000000-0000-4000-8000-000000016307", + }), + makeSubagentToolResultFrame({ + parentToolUseId: OUTER_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000016308", + toolUseId: NESTED_TOOL_USE_ID, + }), + ...makeSubagentAssistantFrames({ + parentToolUseId: OUTER_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000016309", + text: "OUTER_DONE", + }), + makeSubagentNotificationFrame({ + taskId: OUTER_TASK_ID, + toolUseId: OUTER_TOOL_USE_ID, + summary: "OUTER_DONE", + uuid: "00000000-0000-4000-8000-000000016310", + }), + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000016311", + result: "Slept.", + }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* harness.awaitTerminal(2); + + // The sibling's completion wakes a continuation while the root is idle. + for (const frame of [ + makeSubagentNotificationFrame({ + taskId: SIBLING_TASK_ID, + toolUseId: SIBLING_TOOL_USE_ID, + summary: "SIBLING_DONE", + uuid: "00000000-0000-4000-8000-000000016312", + }), + makeAssistantTextFrame({ + uuid: "00000000-0000-4000-8000-000000016313", + text: "Both audits finished.", + }), + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000016314", + result: "Both audits finished.", + origin: { kind: "task-notification" }, + }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* harness.awaitContinuation(1); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-16355-3"), + text: "Background task completed.", + attachments: [], + providerTurnOrdinal: 3, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + yield* harness.awaitTerminal(3); + + assert.equal(nestedStatuses().at(-1), "completed"); + assert.isFalse(yield* harness.hasPendingBackgroundWork); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + + it.effect.each(["exit", "Stop"] as const)( + "settles a plain background subagent and its open call on %s after the root turn completes", + (reason) => + Effect.gen(function* () { + const TOOL_USE_ID = "toolu-idle-exit"; + const BASH = "toolu-idle-exit-bash"; + const harness = yield* makeWakeHarnessWithOptions({ close: Queue.shutdown }); + const now = yield* DateTime.now; + const bashStatuses = () => + harness.events.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.nativeItemRef?.nativeId === BASH + ? [`${event.turnItem.type}:${event.turnItem.status}`] + : [], + ); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-idle-exit"), + text: "Audit in the background.", + attachments: [], + }), + ); + yield* harness.offerAndWait( + makeSubagentTaskStartedFrame({ + taskId: "task-idle-exit", + toolUseId: TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000981", + }), + ); + const started = yield* Queue.take(harness.subagentReceipts); + assert.equal(started.subagent.status, "running"); + for (const frame of makeSubagentAssistantFrames({ + parentToolUseId: TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000982", + bashToolUseId: BASH, + })) { + yield* harness.offerAndWait(frame); + } + yield* harness.offerAndWait( + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000983", + result: "Auditing in the background.", + }), + ); + const terminal = yield* Queue.take(harness.terminalReceipts); + assert.equal(terminal.status, "completed"); + assert.isTrue(yield* harness.hasPendingBackgroundWork); + assert.deepEqual(bashStatuses(), ["command_execution:running"]); + + if (reason === "Stop") { + yield* harness.runtime.interruptTurn({ + providerThread: harness.providerThread, + providerTurnId: terminal.providerTurnId, + requestRuntimeRestart: true, + }); + } else { + yield* Queue.shutdown(harness.sdkMessages); + } + let ended = yield* Queue.take(harness.subagentReceipts); + while (ended.subagent.status === "running") { + ended = yield* Queue.take(harness.subagentReceipts); + } + assert.equal(ended.subagent.nativeTaskRef?.nativeId, "task-idle-exit"); + assert.equal(ended.subagent.status, reason === "Stop" ? "cancelled" : "failed"); + assert.isFalse(yield* harness.hasPendingBackgroundWork); + assert.deepEqual(bashStatuses(), [ + "command_execution:running", + reason === "Stop" ? "command_execution:interrupted" : "command_execution:failed", + ]); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + + it.effect.each(["before", "after"] as const)( + "keeps a subagent call's result when the CLI exits before the drain (result %s the nested launch's)", + (bashResultOrder) => + Effect.gen(function* () { + const OUTER_TASK_ID = "task-exit-buffered-outer"; + const OUTER_TOOL_USE_ID = "toolu-exit-buffered-outer"; + const NESTED_TASK_ID = "task-exit-buffered-nested"; + const NESTED_TOOL_USE_ID = "toolu-exit-buffered-nested"; + const BASH = "toolu-exit-buffered-bash"; + const harness = yield* makeWakeHarnessWithOptions({ freshQueueOnReopen: true }); + const now = yield* DateTime.now; + const bashItems = () => + harness.events.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.nativeItemRef?.nativeId === BASH + ? [ + `${event.turnItem.type}:${event.turnItem.status}:${JSON.stringify("input" in event.turnItem ? event.turnItem.input : null)}`, + ] + : [], + ); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-exit-buffered-1"), + text: "Audit in the background.", + attachments: [], + }), + ); + for (const frame of [ + makeSubagentTaskStartedFrame({ + taskId: OUTER_TASK_ID, + toolUseId: OUTER_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000001001", + }), + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000001002", + result: "Auditing in the background.", + }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* harness.awaitTerminal(1); + + for (const frame of makeSubagentAssistantFrames({ + parentToolUseId: OUTER_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000001003", + bashToolUseId: BASH, + })) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* harness.until(() => bashItems().length === 1); + + const bashResultFrame = makeSubagentToolResultFrame({ + parentToolUseId: OUTER_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000001006", + toolUseId: BASH, + }); + for (const frame of [ + claudeSdkFrame({ + type: "assistant", + message: { + model: "claude-sonnet-4-6", + id: "msg_exit_buffered_nested_launch", + type: "message", + role: "assistant", + content: [ + { + type: "tool_use", + id: NESTED_TOOL_USE_ID, + name: "Agent", + input: { description: "Nested check", prompt: "Reply with NESTED_OK" }, + }, + ], + }, + parent_tool_use_id: OUTER_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000001004", + session_id: WAKE_NATIVE_SESSION, }), claudeSdkFrame({ type: "system", @@ -16746,84 +17154,46 @@ describe("ClaudeAdapterV2 background wake turns", () => { spawn_depth: 2, task_type: "local_agent", prompt: "Reply with NESTED_OK", - uuid: "00000000-0000-4000-8000-000000000974", - session_id: WAKE_NATIVE_SESSION, - }), - claudeSdkFrame({ - type: "system", - subtype: "task_progress", - task_id: OUTER_TASK_ID, - description: "Waiting on the nested check", - uuid: "00000000-0000-4000-8000-000000000984", + uuid: "00000000-0000-4000-8000-000000001005", session_id: WAKE_NATIVE_SESSION, }), - ...makeSubagentAssistantFrames({ - parentToolUseId: OUTER_TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000988", - messageId: "msg_idle_outer_working", - text: "OUTER_WORKING", - }), - ]) { - yield* Queue.offer(harness.sdkMessages, frame); - } - yield* harness.until(() => - harness.events.some( - (event) => event.type === "message.updated" && event.message.text === "OUTER_WORKING", - ), - ); - assert.lengthOf(harness.continuationRequests, 0); - for (const frame of [ + ...(bashResultOrder === "before" ? [bashResultFrame] : []), ...makeSubagentAssistantFrames({ parentToolUseId: NESTED_TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000975", + uuid: "00000000-0000-4000-8000-000000001007", text: "NESTED_OK", }), makeSubagentNotificationFrame({ taskId: NESTED_TASK_ID, toolUseId: NESTED_TOOL_USE_ID, summary: "NESTED_OK", - uuid: "00000000-0000-4000-8000-000000000976", + uuid: "00000000-0000-4000-8000-000000001008", }), makeSubagentToolResultFrame({ parentToolUseId: OUTER_TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000977", + uuid: "00000000-0000-4000-8000-000000001009", toolUseId: NESTED_TOOL_USE_ID, }), - ...makeSubagentAssistantFrames({ - parentToolUseId: OUTER_TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000978", - text: "OUTER_DONE", - }), + ...(bashResultOrder === "after" ? [bashResultFrame] : []), makeSubagentNotificationFrame({ taskId: OUTER_TASK_ID, toolUseId: OUTER_TOOL_USE_ID, summary: "OUTER_DONE", - uuid: "00000000-0000-4000-8000-000000000979", + uuid: "00000000-0000-4000-8000-000000001010", }), ]) { yield* Queue.offer(harness.sdkMessages, frame); } yield* harness.awaitContinuation(1); - yield* harness.until(() => - harness.events.some( - (event) => - event.type === "subagent.updated" && - event.subagent.progress === "Waiting on the nested check", - ), - ); - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000980", - result: "The audit finished.", - }), - ); + + yield* Queue.shutdown(harness.sdkMessages); + yield* yieldTicks(50); yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ threadId: harness.threadId, providerThread: harness.providerThread, now, - attemptId: RunAttemptId.make("attempt-claude-idle-nested-2"), + attemptId: RunAttemptId.make("attempt-claude-exit-buffered-2"), text: "Background task completed.", attachments: [], providerTurnOrdinal: 2, @@ -16831,162 +17201,21 @@ describe("ClaudeAdapterV2 background wake turns", () => { messageCreationSource: "provider", }), ); - yield* harness.awaitTerminal(2); - - assert.isFalse( - harness.events.some( - (event) => - event.type === "turn_item.updated" && - event.turnItem.nativeItemRef?.nativeId === NESTED_TOOL_USE_ID, - ), - ); - const finalSubagents = new Map( - harness.events.flatMap((event) => - event.type === "subagent.updated" - ? [[event.subagent.nativeTaskRef?.nativeId, event.subagent] as const] - : [], - ), - ); - const outer = finalSubagents.get(OUTER_TASK_ID); - const nested = finalSubagents.get(NESTED_TASK_ID); - assert.equal(outer?.status, "completed"); - assert.equal(nested?.status, "completed"); - assert.equal(nested?.parentNodeId, outer?.id); - assert.deepEqual( - subagentRouting(harness.events, []).assistantTexts(outer?.childThreadId ?? undefined), - ["OUTER_WORKING", "OUTER_DONE"], - ); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), - ), - ), - ), - ); - - it.effect("ends an earlier turn's nested subagent while the root turn is idle", () => - Effect.scoped( - Effect.gen(function* () { - const OUTER_TASK_ID = "task-earlier-outer"; - const OUTER_TOOL_USE_ID = "toolu-earlier-outer"; - const NESTED_TASK_ID = "task-earlier-nested"; - const NESTED_TOOL_USE_ID = "toolu-earlier-nested"; - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - const userTurn = (attemptId: string, providerTurnOrdinal: number) => - harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make(attemptId), - text: "Keep auditing.", - attachments: [], - providerTurnOrdinal, - }), - ); - yield* userTurn("attempt-claude-earlier-nested-1", 1); - for (const frame of [ - makeSubagentTaskStartedFrame({ - taskId: OUTER_TASK_ID, - toolUseId: OUTER_TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000a01", - }), - claudeSdkFrame({ - type: "assistant", - message: { - model: "claude-sonnet-4-6", - id: "msg_earlier_nested_launch", - type: "message", - role: "assistant", - content: [ - { - type: "tool_use", - id: NESTED_TOOL_USE_ID, - name: "Agent", - input: { description: "Nested check", prompt: "Reply with NESTED_OK" }, - }, - ], - }, - parent_tool_use_id: OUTER_TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000a02", - session_id: WAKE_NATIVE_SESSION, - }), - claudeSdkFrame({ - type: "system", - subtype: "task_started", - task_id: NESTED_TASK_ID, - tool_use_id: NESTED_TOOL_USE_ID, - description: "Nested check", - subagent_type: "general-purpose", - is_backgrounded: false, - spawn_depth: 2, - task_type: "local_agent", - prompt: "Reply with NESTED_OK", - uuid: "00000000-0000-4000-8000-000000000a03", - session_id: WAKE_NATIVE_SESSION, - }), + assert.equal(harness.processQueues.length, 2); + yield* Queue.offer( + harness.processQueues[1]!, makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000a04", - result: "Auditing in the background.", + uuid: "00000000-0000-4000-8000-000000001011", + result: "The audit finished.", }), - ]) { - yield* Queue.offer(harness.sdkMessages, frame); - } - yield* harness.awaitTerminal(1); - yield* userTurn("attempt-claude-earlier-nested-2", 2); - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000a05", result: "Still on it." }), ); yield* harness.awaitTerminal(2); - for (const frame of [ - makeSubagentNotificationFrame({ - taskId: NESTED_TASK_ID, - toolUseId: NESTED_TOOL_USE_ID, - summary: "NESTED_OK", - uuid: "00000000-0000-4000-8000-000000000a06", - }), - makeSubagentToolResultFrame({ - parentToolUseId: OUTER_TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000a07", - toolUseId: NESTED_TOOL_USE_ID, - }), - ...makeSubagentAssistantFrames({ - parentToolUseId: OUTER_TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000a08", - text: "OUTER_AFTER_NESTED", - }), - ]) { - yield* Queue.offer(harness.sdkMessages, frame); - } - yield* harness.until(() => - harness.events.some( - (event) => - event.type === "message.updated" && event.message.text === "OUTER_AFTER_NESTED", - ), - ); - - assert.isFalse( - harness.events.some( - (event) => - event.type === "turn_item.updated" && - event.turnItem.nativeItemRef?.nativeId === NESTED_TOOL_USE_ID, - ), - ); - const nested = harness.events.findLast( - (event) => - event.type === "subagent.updated" && - event.subagent.nativeTaskRef?.nativeId === NESTED_TASK_ID, - ); - assert.equal(nested?.type === "subagent.updated" && nested.subagent.status, "completed"); - assert.lengthOf(harness.continuationRequests, 0); + const input = JSON.stringify("git log -5"); + assert.deepEqual(bashItems(), [ + `command_execution:running:${input}`, + `command_execution:completed:${input}`, + ]); }).pipe( Effect.provide( Layer.mergeAll( @@ -16997,25 +17226,26 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ), ), - ), ); - it.effect("keeps an idle-launched nested subagent completed through a sibling's wake", () => - Effect.scoped( + it.effect( + "ends a subagent's SendMessage with its result when the CLI exits while the resume waits", + () => Effect.gen(function* () { - const OUTER_TASK_ID = "task-16355-outer"; - const OUTER_TOOL_USE_ID = "toolu-16355-outer"; - const NESTED_TASK_ID = "task-16355-nested"; - const NESTED_TOOL_USE_ID = "toolu-16355-nested"; - const SIBLING_TASK_ID = "task-16355-sibling"; - const SIBLING_TOOL_USE_ID = "toolu-16355-sibling"; - const harness = yield* makeWakeHarness; + const SENDER_TASK_ID = "task-exit-resume-sender"; + const SENDER_TOOL_USE_ID = "toolu-exit-resume-sender"; + const RESUMED_TASK_ID = "task-exit-resume-target"; + const RESUMED_TOOL_USE_ID = "toolu-exit-resume-target"; + const SEND = "toolu-exit-resume-sendmessage"; + const SEND_INPUT = { to: RESUMED_TASK_ID, message: "Check the last commit again." }; + const harness = yield* makeWakeHarnessWithOptions({ freshQueueOnReopen: true }); const now = yield* DateTime.now; - const nestedStatuses = () => + const sendItems = () => harness.events.flatMap((event) => - event.type === "subagent.updated" && - event.subagent.nativeTaskRef?.nativeId === NESTED_TASK_ID - ? [event.subagent.status] + event.type === "turn_item.updated" && event.turnItem.nativeItemRef?.nativeId === SEND + ? [ + `${event.turnItem.type}:${event.turnItem.status}:${JSON.stringify("input" in event.turnItem ? event.turnItem.input : null)}`, + ] : [], ); yield* harness.runtime.startTurn( @@ -17023,162 +17253,121 @@ describe("ClaudeAdapterV2 background wake turns", () => { threadId: harness.threadId, providerThread: harness.providerThread, now, - attemptId: RunAttemptId.make("attempt-claude-16355-1"), - text: "Run two audits in the background.", + attemptId: RunAttemptId.make("attempt-claude-exit-resume-1"), + text: "Run two auditors in the background.", attachments: [], }), ); for (const frame of [ makeSubagentTaskStartedFrame({ - taskId: OUTER_TASK_ID, - toolUseId: OUTER_TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000016301", + taskId: SENDER_TASK_ID, + toolUseId: SENDER_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000001101", }), makeSubagentTaskStartedFrame({ - taskId: SIBLING_TASK_ID, - toolUseId: SIBLING_TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000016302", + taskId: RESUMED_TASK_ID, + toolUseId: RESUMED_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000001102", + }), + makeSubagentNotificationFrame({ + taskId: RESUMED_TASK_ID, + toolUseId: RESUMED_TOOL_USE_ID, + summary: "First answer.", + uuid: "00000000-0000-4000-8000-000000001103", }), makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000016303", - result: "Both audits run in the background.", + uuid: "00000000-0000-4000-8000-000000001104", + result: "Auditing in the background.", }), ]) { yield* Queue.offer(harness.sdkMessages, frame); } yield* harness.awaitTerminal(1); - // The background subagent launches a foreground nested one while the root is idle. - for (const frame of [ + yield* Queue.offer( + harness.sdkMessages, claudeSdkFrame({ type: "assistant", message: { model: "claude-sonnet-4-6", - id: "msg_16355_nested_launch", + id: "msg_exit_resume_sendmessage", type: "message", role: "assistant", - content: [ - { - type: "tool_use", - id: NESTED_TOOL_USE_ID, - name: "Agent", - input: { description: "Nested check", prompt: "Sleep, then reply NESTED_OK" }, - }, - ], + content: [{ type: "tool_use", id: SEND, name: "SendMessage", input: SEND_INPUT }], }, - parent_tool_use_id: OUTER_TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000016304", + parent_tool_use_id: SENDER_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000001105", session_id: WAKE_NATIVE_SESSION, }), + ); + yield* harness.until(() => sendItems().length >= 1); + + for (const frame of [ claudeSdkFrame({ type: "system", subtype: "task_started", - task_id: NESTED_TASK_ID, - tool_use_id: NESTED_TOOL_USE_ID, - description: "Nested check", - subagent_type: "general-purpose", - is_backgrounded: false, - spawn_depth: 2, + task_id: RESUMED_TASK_ID, + tool_use_id: SEND, + description: "Audit recent commits", + is_backgrounded: true, task_type: "local_agent", - prompt: "Sleep, then reply NESTED_OK", - uuid: "00000000-0000-4000-8000-000000016305", + prompt: SEND_INPUT.message, + uuid: "00000000-0000-4000-8000-000000001106", session_id: WAKE_NATIVE_SESSION, }), claudeSdkFrame({ - type: "system", - subtype: "task_progress", - task_id: NESTED_TASK_ID, - description: "Sleeping", - uuid: "00000000-0000-4000-8000-000000016306", + type: "user", + message: { + role: "user", + content: [{ type: "tool_result", tool_use_id: SEND, content: "Message sent." }], + }, + parent_tool_use_id: SENDER_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000001107", session_id: WAKE_NATIVE_SESSION, }), - ]) { - yield* harness.offerAndWait(frame); - } - - // A user turn runs while the nested and then the outer subagent finish. - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-16355-2"), - text: "Sleep for a while.", - attachments: [], - providerTurnOrdinal: 2, - }), - ); - yield* harness.until(() => harness.offeredMessages.length === 2); - for (const frame of [ - makeSubagentNotificationFrame({ - taskId: NESTED_TASK_ID, - toolUseId: NESTED_TOOL_USE_ID, - summary: "NESTED_OK", - uuid: "00000000-0000-4000-8000-000000016307", - }), - makeSubagentToolResultFrame({ - parentToolUseId: OUTER_TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000016308", - toolUseId: NESTED_TOOL_USE_ID, - }), - ...makeSubagentAssistantFrames({ - parentToolUseId: OUTER_TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000016309", - text: "OUTER_DONE", - }), - makeSubagentNotificationFrame({ - taskId: OUTER_TASK_ID, - toolUseId: OUTER_TOOL_USE_ID, - summary: "OUTER_DONE", - uuid: "00000000-0000-4000-8000-000000016310", - }), - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000016311", - result: "Slept.", - }), - ]) { - yield* Queue.offer(harness.sdkMessages, frame); - } - yield* harness.awaitTerminal(2); - - // The sibling's completion wakes a continuation while the root is idle. - for (const frame of [ makeSubagentNotificationFrame({ - taskId: SIBLING_TASK_ID, - toolUseId: SIBLING_TOOL_USE_ID, - summary: "SIBLING_DONE", - uuid: "00000000-0000-4000-8000-000000016312", - }), - makeAssistantTextFrame({ - uuid: "00000000-0000-4000-8000-000000016313", - text: "Both audits finished.", - }), - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000016314", - result: "Both audits finished.", - origin: { kind: "task-notification" }, + taskId: RESUMED_TASK_ID, + toolUseId: SEND, + summary: "Second answer.", + uuid: "00000000-0000-4000-8000-000000001108", }), ]) { yield* Queue.offer(harness.sdkMessages, frame); } yield* harness.awaitContinuation(1); + const beforeExit = sendItems(); + + yield* Queue.shutdown(harness.sdkMessages); + yield* yieldTicks(50); yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ threadId: harness.threadId, providerThread: harness.providerThread, now, - attemptId: RunAttemptId.make("attempt-claude-16355-3"), + attemptId: RunAttemptId.make("attempt-claude-exit-resume-2"), text: "Background task completed.", attachments: [], - providerTurnOrdinal: 3, + providerTurnOrdinal: 2, messageCreatedBy: "agent", messageCreationSource: "provider", }), ); - yield* harness.awaitTerminal(3); + assert.equal(harness.processQueues.length, 2); + yield* Queue.offer( + harness.processQueues[1]!, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000001109", + result: "The audit finished.", + }), + ); + yield* harness.awaitTerminal(2); - assert.equal(nestedStatuses().at(-1), "completed"); - assert.isFalse(yield* harness.hasPendingBackgroundWork); + const input = JSON.stringify(SEND_INPUT); + assert.deepEqual(beforeExit, [`dynamic_tool:running:${input}`]); + assert.deepEqual(sendItems(), [ + `dynamic_tool:running:${input}`, + `dynamic_tool:completed:${input}`, + ]); }).pipe( Effect.provide( Layer.mergeAll( @@ -17189,672 +17378,709 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ), ), - ), ); - it.effect.each(["exit", "Stop"] as const)( - "settles a plain background subagent and its open call on %s after the root turn completes", - (reason) => - Effect.scoped( - Effect.gen(function* () { - const TOOL_USE_ID = "toolu-idle-exit"; - const BASH = "toolu-idle-exit-bash"; - const harness = yield* makeWakeHarnessWithOptions({ close: Queue.shutdown }); - const now = yield* DateTime.now; - const bashStatuses = () => - harness.events.flatMap((event) => - event.type === "turn_item.updated" && event.turnItem.nativeItemRef?.nativeId === BASH - ? [`${event.turnItem.type}:${event.turnItem.status}`] - : [], - ); - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-idle-exit"), - text: "Audit in the background.", - attachments: [], - }), - ); - yield* harness.offerAndWait( - makeSubagentTaskStartedFrame({ - taskId: "task-idle-exit", - toolUseId: TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000981", - }), + it.effect.each([ + ["", "none"], + [" behind another subagent's start without one", "before"], + [" when another subagent's start without one arrives between its frames", "between"], + ] as const)( + "keeps a subagent's frames in order when a late progress names its tool use while the root is idle%s", + ([, otherStart]) => + Effect.gen(function* () { + const TASK_ID = "task-late-owner-idle"; + const TOOL_USE_ID = "toolu-late-owner-idle"; + const BASH = "toolu-late-owner-idle-bash"; + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const bashStatuses = () => + harness.events.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.nativeItemRef?.nativeId === BASH + ? [`${event.turnItem.type}:${event.turnItem.status}`] + : [], ); - const started = yield* Queue.take(harness.subagentReceipts); - assert.equal(started.subagent.status, "running"); - for (const frame of makeSubagentAssistantFrames({ + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-late-owner-idle-1"), + text: "Audit in the background.", + attachments: [], + }), + ); + for (const frame of [ + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: TASK_ID, + description: "Audit recent commits", + subagent_type: "general-purpose", + is_backgrounded: true, + task_type: "local_agent", + prompt: "Audit the last five commits.", + uuid: "00000000-0000-4000-8000-000000000a01", + session_id: WAKE_NATIVE_SESSION, + }), + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000a02", + result: "Auditing in the background.", + }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* harness.awaitTerminal(1); + + const otherStartFrame = claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: "task-late-owner-other", + description: "Check the changelog", + subagent_type: "general-purpose", + is_backgrounded: true, + task_type: "local_agent", + prompt: "Check the changelog.", + uuid: "00000000-0000-4000-8000-000000000a09", + session_id: WAKE_NATIVE_SESSION, + }); + const progressFrame = claudeSdkFrame({ + type: "system", + subtype: "task_progress", + task_id: TASK_ID, + tool_use_id: TOOL_USE_ID, + description: "Reading the last commits", + usage: { total_tokens: 1, tool_uses: 1, duration_ms: 1 }, + uuid: "00000000-0000-4000-8000-000000000a04", + session_id: WAKE_NATIVE_SESSION, + }); + const bashResultFrame = makeSubagentToolResultFrame({ + parentToolUseId: TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000a05", + toolUseId: BASH, + }); + for (const frame of [ + ...(otherStart === "before" ? [otherStartFrame] : []), + ...makeSubagentAssistantFrames({ parentToolUseId: TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000982", + uuid: "00000000-0000-4000-8000-000000000a03", + text: "First.", bashToolUseId: BASH, - })) { - yield* harness.offerAndWait(frame); - } - yield* harness.offerAndWait( - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000983", - result: "Auditing in the background.", - }), - ); - const terminal = yield* Queue.take(harness.terminalReceipts); - assert.equal(terminal.status, "completed"); - assert.isTrue(yield* harness.hasPendingBackgroundWork); - assert.deepEqual(bashStatuses(), ["command_execution:running"]); + }), + ...(otherStart === "between" + ? [otherStartFrame, bashResultFrame, progressFrame] + : [progressFrame, bashResultFrame]), + ...makeSubagentAssistantFrames({ + parentToolUseId: TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000a06", + text: "Second.", + }), + makeSubagentNotificationFrame({ + taskId: TASK_ID, + toolUseId: TOOL_USE_ID, + summary: "Second.", + uuid: "00000000-0000-4000-8000-000000000a07", + }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* harness.awaitContinuation(1); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000a08", + result: "The auditor finished.", + }), + ); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-late-owner-idle-2"), + text: "Background task completed.", + attachments: [], + providerTurnOrdinal: 2, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + yield* harness.awaitTerminal(2); - if (reason === "Stop") { - yield* harness.runtime.interruptTurn({ - providerThread: harness.providerThread, - providerTurnId: terminal.providerTurnId, - requestRuntimeRestart: true, - }); - } else { - yield* Queue.shutdown(harness.sdkMessages); - } - let ended = yield* Queue.take(harness.subagentReceipts); - while (ended.subagent.status === "running") { - ended = yield* Queue.take(harness.subagentReceipts); - } - assert.equal(ended.subagent.nativeTaskRef?.nativeId, "task-idle-exit"); - assert.equal(ended.subagent.status, reason === "Stop" ? "cancelled" : "failed"); - assert.isFalse(yield* harness.hasPendingBackgroundWork); - assert.deepEqual(bashStatuses(), [ - "command_execution:running", - reason === "Stop" ? "command_execution:interrupted" : "command_execution:failed", - ]); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + const routing = subagentRouting(harness.events, [BASH]); + const childTexts = routing.assistantTexts(routing.childThreadId); + const statuses = bashStatuses(); + const trace = harness.events.flatMap((event) => + event.type === "message.updated" && + event.message.role === "assistant" && + event.message.threadId === routing.childThreadId + ? [`text:${event.message.text}#${event.message.id}`] + : event.type === "turn_item.updated" && event.turnItem.nativeItemRef?.nativeId === BASH + ? [`bash:${event.turnItem.status}`] + : [], + ); + assert.deepEqual(childTexts, ["First.", "Second."], `trace: ${JSON.stringify(trace)}`); + assert.deepEqual( + [...(routing.toolThreadIds.get(BASH) ?? [])], + [routing.childThreadId], + `trace: ${JSON.stringify(trace)}`, + ); + assert.deepEqual( + statuses, + ["command_execution:running", "command_execution:completed"], + `trace: ${JSON.stringify(trace)}`, + ); + assert.isTrue( + harness.events.some( + (event) => + event.type === "subagent.updated" && + event.subagent.progress === "Reading the last commits", + ), + ); + assert.lengthOf(harness.continuationRequests, 1); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), ); - it.effect.each(["before", "after"] as const)( - "keeps a subagent call's result when the CLI exits before the drain (result %s the nested launch's)", - (bashResultOrder) => - Effect.scoped( - Effect.gen(function* () { - const OUTER_TASK_ID = "task-exit-buffered-outer"; - const OUTER_TOOL_USE_ID = "toolu-exit-buffered-outer"; - const NESTED_TASK_ID = "task-exit-buffered-nested"; - const NESTED_TOOL_USE_ID = "toolu-exit-buffered-nested"; - const BASH = "toolu-exit-buffered-bash"; - const harness = yield* makeWakeHarnessWithOptions({ freshQueueOnReopen: true }); - const now = yield* DateTime.now; - const bashItems = () => - harness.events.flatMap((event) => - event.type === "turn_item.updated" && event.turnItem.nativeItemRef?.nativeId === BASH - ? [ - `${event.turnItem.type}:${event.turnItem.status}:${JSON.stringify("input" in event.turnItem ? event.turnItem.input : null)}`, - ] - : [], - ); - yield* harness.runtime.startTurn( + it.effect("holds subagent frames that precede task_started and shows its result once", () => + Effect.gen(function* () { + const TASK_ID = "task-early-subagent"; + const TOOL_USE_ID = "toolu-early-subagent"; + const FINAL_REPORT = "Early auditor done."; + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-early-subagent"), + text: "Run an auditor.", + attachments: [], + }), + ); + const frames = [ + // Child frames can precede the task_started that registers their subagent. + ...makeSubagentAssistantFrames({ + parentToolUseId: TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000321", + text: "Starting early.", + bashToolUseId: "toolu-early-bash", + }), + makeSubagentToolResultFrame({ + parentToolUseId: TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000322", + toolUseId: "toolu-early-bash", + }), + makeSubagentTaskStartedFrame({ + taskId: TASK_ID, + toolUseId: TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000323", + }), + ...makeSubagentAssistantFrames({ + parentToolUseId: TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000324", + text: "Still working.", + }), + claudeSdkFrame({ + type: "system", + subtype: "task_progress", + task_id: TASK_ID, + description: "Checking the diffs", + uuid: "00000000-0000-4000-8000-000000000325", + session_id: WAKE_NATIVE_SESSION, + }), + ...makeSubagentAssistantFrames({ + parentToolUseId: TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000326", + text: FINAL_REPORT, + }), + makeSubagentNotificationFrame({ + taskId: TASK_ID, + toolUseId: TOOL_USE_ID, + summary: FINAL_REPORT, + uuid: "00000000-0000-4000-8000-000000000327", + }), + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000328", + result: "The auditor finished.", + }), + ]; + for (const frame of frames) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* harness.awaitTerminal(1); + + const routing = subagentRouting(harness.events, ["toolu-early-bash"]); + assert.isDefined(routing.childThreadId); + // Agents quote this id back to t3_thread_send/t3_thread_read. + const childThreadId = routing.childThreadId ?? ""; + assert.notInclude(childThreadId, "%"); + assert.isAtMost(childThreadId.length, 64); + assert.deepEqual( + [...(routing.toolThreadIds.get("toolu-early-bash") ?? [])], + [routing.childThreadId], + ); + assert.deepEqual(routing.assistantTexts(routing.childThreadId), [ + "Starting early.", + "Still working.", + FINAL_REPORT, + ]); + assert.deepEqual(routing.assistantTexts(harness.threadId), ["The auditor finished."]); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + + it.effect.each([false, true])( + "drops an idle start superseded by live completion, later resume %s", + (resume) => + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const startTurn = (ordinal: number, continuation = false) => + harness.runtime.startTurn( makeClaudeTestTurnInput({ threadId: harness.threadId, providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-exit-buffered-1"), - text: "Audit in the background.", + now: DateTime.makeUnsafe(0), + attemptId: RunAttemptId.make(`attempt-stale-start-${ordinal}`), + providerTurnOrdinal: ordinal, + text: "Continue work.", attachments: [], + ...(continuation + ? { + messageCreatedBy: "agent" as const, + messageCreationSource: "provider" as const, + } + : {}), }), ); - for (const frame of [ - makeSubagentTaskStartedFrame({ - taskId: OUTER_TASK_ID, - toolUseId: OUTER_TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000001001", - }), - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000001002", - result: "Auditing in the background.", - }), - ]) { - yield* Queue.offer(harness.sdkMessages, frame); - } - yield* harness.awaitTerminal(1); - - for (const frame of makeSubagentAssistantFrames({ - parentToolUseId: OUTER_TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000001003", - bashToolUseId: BASH, - })) { - yield* Queue.offer(harness.sdkMessages, frame); - } - yield* harness.until(() => bashItems().length === 1); - - const bashResultFrame = makeSubagentToolResultFrame({ - parentToolUseId: OUTER_TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000001006", - toolUseId: BASH, + const started = (taskId: string, suffix: string) => + makeSubagentTaskStartedFrame({ + taskId, + toolUseId: `toolu-${taskId}`, + uuid: `00000000-0000-4000-8000-${suffix}`, }); - for (const frame of [ - claudeSdkFrame({ - type: "assistant", - message: { - model: "claude-sonnet-4-6", - id: "msg_exit_buffered_nested_launch", - type: "message", - role: "assistant", - content: [ - { - type: "tool_use", - id: NESTED_TOOL_USE_ID, - name: "Agent", - input: { description: "Nested check", prompt: "Reply with NESTED_OK" }, - }, - ], - }, - parent_tool_use_id: OUTER_TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000001004", - session_id: WAKE_NATIVE_SESSION, - }), - claudeSdkFrame({ - type: "system", - subtype: "task_started", - task_id: NESTED_TASK_ID, - tool_use_id: NESTED_TOOL_USE_ID, - description: "Nested check", - subagent_type: "general-purpose", - is_backgrounded: false, - spawn_depth: 2, - task_type: "local_agent", - prompt: "Reply with NESTED_OK", - uuid: "00000000-0000-4000-8000-000000001005", - session_id: WAKE_NATIVE_SESSION, - }), - ...(bashResultOrder === "before" ? [bashResultFrame] : []), - ...makeSubagentAssistantFrames({ - parentToolUseId: NESTED_TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000001007", - text: "NESTED_OK", - }), - makeSubagentNotificationFrame({ - taskId: NESTED_TASK_ID, - toolUseId: NESTED_TOOL_USE_ID, - summary: "NESTED_OK", - uuid: "00000000-0000-4000-8000-000000001008", - }), - makeSubagentToolResultFrame({ - parentToolUseId: OUTER_TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000001009", - toolUseId: NESTED_TOOL_USE_ID, - }), - ...(bashResultOrder === "after" ? [bashResultFrame] : []), - makeSubagentNotificationFrame({ - taskId: OUTER_TASK_ID, - toolUseId: OUTER_TOOL_USE_ID, - summary: "OUTER_DONE", - uuid: "00000000-0000-4000-8000-000000001010", - }), - ]) { - yield* Queue.offer(harness.sdkMessages, frame); - } - yield* harness.awaitContinuation(1); + const finished = (taskId: string, suffix: string) => + makeSubagentNotificationFrame({ + taskId, + toolUseId: `toolu-${taskId}`, + summary: "Finished audit.", + uuid: `00000000-0000-4000-8000-${suffix}`, + }); + yield* startTurn(1); + yield* harness.offerAndWait(started("stale-agent", "000000004001")); + yield* harness.offerAndWait(started("wake-agent", "000000004002")); + yield* harness.offerAndWait( + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000004003", + result: "Agents launched.", + }), + ); + yield* Queue.take(harness.terminalReceipts); + yield* harness.offerAndWait(started("stale-agent", "000000004004")); + yield* startTurn(2); + yield* harness.offerAndWait(finished("stale-agent", "000000004005")); + yield* harness.offerAndWait( + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000004006", + result: "User turn finished.", + }), + ); + yield* Queue.take(harness.terminalReceipts); + if (resume) yield* harness.offerAndWait(started("stale-agent", "000000004007")); + yield* harness.offerAndWait(finished("wake-agent", "000000004008")); + yield* Queue.take(harness.continuationReceipts); + yield* startTurn(3, true); + yield* harness.offerAndWait( + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000004009", + result: "Wake finished.", + }), + ); + yield* Queue.take(harness.terminalReceipts); + const latest = harness.events.findLast( + (event) => + event.type === "subagent.updated" && + event.subagent.nativeTaskRef?.nativeId === "stale-agent", + ); + assert.equal(latest?.type, "subagent.updated"); + if (latest?.type !== "subagent.updated") return; + assert.equal(latest.subagent.status, resume ? "running" : "completed"); + assert.equal(latest.subagent.result, resume ? null : "Finished audit."); + assert.equal(latest.subagent.completedAt === null, resume); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); - yield* Queue.shutdown(harness.sdkMessages); - yield* yieldTicks(50); - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-exit-buffered-2"), - text: "Background task completed.", - attachments: [], - providerTurnOrdinal: 2, - messageCreatedBy: "agent", - messageCreationSource: "provider", - }), - ); - assert.equal(harness.processQueues.length, 2); - yield* Queue.offer( - harness.processQueues[1]!, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000001011", - result: "The audit finished.", + const makeStopFixture = (options?: Parameters[0]) => + Effect.gen(function* () { + const requests = yield* Queue.unbounded(); + const harness = yield* makeWakeHarnessWithOptions({ + ...options, + stopTask: (id) => + Queue.offer(requests, undefined).pipe( + Effect.andThen(options?.stopTask?.(id) ?? Effect.void), + ), + }); + let sequence = 4500; + const uuid = () => `00000000-0000-4000-8000-${String(++sequence).padStart(12, "0")}`; + const startTurn = (continuation = false) => + harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: DateTime.makeUnsafe(0), + attemptId: RunAttemptId.make(`attempt-stop-${continuation ? 2 : 1}`), + providerTurnOrdinal: continuation ? 2 : 1, + text: "Run an audit.", + attachments: [], + ...(continuation + ? { messageCreatedBy: "agent" as const, messageCreationSource: "provider" as const } + : {}), + }), + ); + const start = (toolUseId = "toolu-stop-agent") => + harness.offerAndWait( + makeSubagentTaskStartedFrame({ + taskId: "stop-agent", + toolUseId, + uuid: uuid(), + }), + ); + const notify = ( + status: "completed" | "failed" | "stopped", + summary = "Native outcome.", + toolUseId = "toolu-stop-agent", + ) => + harness.offerAndWait( + claudeSdkFrame({ + ...makeSubagentNotificationFrame({ + taskId: "stop-agent", + toolUseId, + summary, + uuid: uuid(), }), - ); - yield* harness.awaitTerminal(2); + status, + }), + ); + const stop = (requestedAt?: DateTime.Utc) => + harness.runtime.stopSubagent!({ + providerThread: harness.providerThread, + nativeTaskId: "stop-agent", + ...(requestedAt === undefined ? {} : { requestedAt }), + }); + const finishParent = () => + harness + .offerAndWait(makeResultFrame({ uuid: uuid(), result: "Launched." })) + .pipe(Effect.andThen(Queue.take(harness.terminalReceipts))); + yield* startTurn(); + yield* start(); + yield* Queue.take(harness.subagentReceipts); + return { + ...harness, + start, + notify, + stop, + finishParent, + replay: () => startTurn(true), + uuid, + requests, + }; + }); - const input = JSON.stringify("git log -5"); - assert.deepEqual(bashItems(), [ - `command_execution:running:${input}`, - `command_execution:completed:${input}`, - ]); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), - ), + it.effect("retains native confirmation after the synchronous Agent tool result", () => + Effect.gen(function* () { + const fixture = yield* makeStopFixture({ + stopTask: () => Effect.die("Task already completed"), + }); + yield* fixture.notify("completed"); + const completed = yield* Queue.take(fixture.subagentReceipts); + yield* fixture.offerAndWait( + claudeSdkFrame({ + type: "user", + message: { + role: "user", + content: [ + { type: "tool_result", tool_use_id: "toolu-stop-agent", content: "Tool result." }, + ], + }, + parent_tool_use_id: null, + uuid: fixture.uuid(), + session_id: WAKE_NATIVE_SESSION, + }), + ); + assert.equal((yield* Queue.take(fixture.subagentReceipts)).subagent.result, "Tool result."); + assert.deepEqual(yield* fixture.stop(), { + status: "completed", + completedAt: completed.subagent.completedAt!, + result: "Native outcome.", + }); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), + ), ); - it.effect( - "ends a subagent's SendMessage with its result when the CLI exits while the resume waits", - () => - Effect.scoped( - Effect.gen(function* () { - const SENDER_TASK_ID = "task-exit-resume-sender"; - const SENDER_TOOL_USE_ID = "toolu-exit-resume-sender"; - const RESUMED_TASK_ID = "task-exit-resume-target"; - const RESUMED_TOOL_USE_ID = "toolu-exit-resume-target"; - const SEND = "toolu-exit-resume-sendmessage"; - const SEND_INPUT = { to: RESUMED_TASK_ID, message: "Check the last commit again." }; - const harness = yield* makeWakeHarnessWithOptions({ freshQueueOnReopen: true }); - const now = yield* DateTime.now; - const sendItems = () => - harness.events.flatMap((event) => - event.type === "turn_item.updated" && event.turnItem.nativeItemRef?.nativeId === SEND - ? [ - `${event.turnItem.type}:${event.turnItem.status}:${JSON.stringify("input" in event.turnItem ? event.turnItem.input : null)}`, - ] - : [], - ); - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-exit-resume-1"), - text: "Run two auditors in the background.", - attachments: [], + it.effect.each([ + [false, "stopped"], + [true, "stopped"], + [false, "completed"], + ] as const)( + "stops one native subagent while its sibling keeps running, owner settled %s, outcome %s", + ([ownerSettled, outcome]) => + Effect.gen(function* () { + const stopped: string[] = []; + const stopRequested = yield* Deferred.make(); + let closes = 0; + const harness = yield* makeWakeHarnessWithOptions({ + stopTask: (taskId) => + Effect.sync(() => { + stopped.push(taskId); + }).pipe(Effect.andThen(Deferred.succeed(stopRequested, undefined))), + interrupt: Effect.die("A child stop must not interrupt its owner"), + close: () => + Effect.sync(() => { + closes++; }), + }); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-native-child-stop"), + text: "Start two agents.", + attachments: [], + }), + ); + for (const [taskId, toolUseId, uuid] of [ + ["agent-stop", "toolu-stop", "00000000-0000-4000-8000-000000000801"], + ["agent-keep", "toolu-keep", "00000000-0000-4000-8000-000000000802"], + ]) { + yield* harness.offerAndWait( + makeSubagentTaskStartedFrame({ taskId: taskId!, toolUseId: toolUseId!, uuid: uuid! }), ); - for (const frame of [ - makeSubagentTaskStartedFrame({ - taskId: SENDER_TASK_ID, - toolUseId: SENDER_TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000001101", - }), - makeSubagentTaskStartedFrame({ - taskId: RESUMED_TASK_ID, - toolUseId: RESUMED_TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000001102", - }), - makeSubagentNotificationFrame({ - taskId: RESUMED_TASK_ID, - toolUseId: RESUMED_TOOL_USE_ID, - summary: "First answer.", - uuid: "00000000-0000-4000-8000-000000001103", - }), + yield* Queue.take(harness.subagentReceipts); + } + if (ownerSettled) { + yield* harness.offerAndWait( makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000001104", - result: "Auditing in the background.", - }), - ]) { - yield* Queue.offer(harness.sdkMessages, frame); - } - yield* harness.awaitTerminal(1); - - yield* Queue.offer( - harness.sdkMessages, - claudeSdkFrame({ - type: "assistant", - message: { - model: "claude-sonnet-4-6", - id: "msg_exit_resume_sendmessage", - type: "message", - role: "assistant", - content: [{ type: "tool_use", id: SEND, name: "SendMessage", input: SEND_INPUT }], - }, - parent_tool_use_id: SENDER_TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000001105", - session_id: WAKE_NATIVE_SESSION, + uuid: "00000000-0000-4000-8000-000000000803", + result: "Agents are working.", }), ); - yield* harness.until(() => sendItems().length >= 1); - - for (const frame of [ - claudeSdkFrame({ - type: "system", - subtype: "task_started", - task_id: RESUMED_TASK_ID, - tool_use_id: SEND, - description: "Audit recent commits", - is_backgrounded: true, - task_type: "local_agent", - prompt: SEND_INPUT.message, - uuid: "00000000-0000-4000-8000-000000001106", - session_id: WAKE_NATIVE_SESSION, - }), - claudeSdkFrame({ - type: "user", - message: { - role: "user", - content: [{ type: "tool_result", tool_use_id: SEND, content: "Message sent." }], - }, - parent_tool_use_id: SENDER_TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000001107", - session_id: WAKE_NATIVE_SESSION, - }), - makeSubagentNotificationFrame({ - taskId: RESUMED_TASK_ID, - toolUseId: SEND, - summary: "Second answer.", - uuid: "00000000-0000-4000-8000-000000001108", + yield* Queue.take(harness.terminalReceipts); + } + const stop = harness.runtime.stopSubagent; + assert.isDefined(stop); + const stopping = yield* stop!({ + providerThread: harness.providerThread, + nativeTaskId: "agent-stop", + }).pipe(Effect.forkScoped); + yield* Deferred.await(stopRequested); + assert.deepEqual(stopped, ["agent-stop"]); + yield* harness.offerAndWait( + claudeSdkFrame({ + ...makeSubagentNotificationFrame({ + taskId: "agent-stop", + toolUseId: "toolu-stop", + summary: "Stopped by user", + uuid: "00000000-0000-4000-8000-000000000804", }), - ]) { - yield* Queue.offer(harness.sdkMessages, frame); - } - yield* harness.awaitContinuation(1); - const beforeExit = sendItems(); - - yield* Queue.shutdown(harness.sdkMessages); - yield* yieldTicks(50); + status: outcome, + }), + ); + if (ownerSettled) { yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ threadId: harness.threadId, providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-exit-resume-2"), - text: "Background task completed.", + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-native-child-stop-wake"), + text: "Background task stopped.", attachments: [], providerTurnOrdinal: 2, messageCreatedBy: "agent", messageCreationSource: "provider", }), ); - assert.equal(harness.processQueues.length, 2); - yield* Queue.offer( - harness.processQueues[1]!, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000001109", - result: "The audit finished.", - }), - ); - yield* harness.awaitTerminal(2); - - const input = JSON.stringify(SEND_INPUT); - assert.deepEqual(beforeExit, [`dynamic_tool:running:${input}`]); - assert.deepEqual(sendItems(), [ - `dynamic_tool:running:${input}`, - `dynamic_tool:completed:${input}`, - ]); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + } + yield* Fiber.join(stopping); + const stoppedEvent = yield* Queue.take(harness.subagentReceipts); + assert.equal( + stoppedEvent.subagent.status, + outcome === "stopped" ? "cancelled" : "completed", + ); + const tasks = harness.events.filter((event) => event.type === "subagent.updated"); + assert.equal( + tasks.findLast((event) => event.subagent.nativeTaskRef?.nativeId === "agent-stop") + ?.subagent.status, + outcome === "stopped" ? "cancelled" : "completed", + ); + assert.equal( + tasks.findLast((event) => event.subagent.nativeTaskRef?.nativeId === "agent-keep") + ?.subagent.status, + "running", + ); + assert.lengthOf(harness.terminalEvents(), ownerSettled ? 1 : 0); + yield* stop!({ providerThread: harness.providerThread, nativeTaskId: "agent-stop" }); + assert.deepEqual(stopped, ["agent-stop"]); + assert.equal(closes, 0); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), ); it.effect.each([ - ["", "none"], - [" behind another subagent's start without one", "before"], - [" when another subagent's start without one arrives between its frames", "between"], + "requested", + "observed-before", + "observed-after", + "synthetic-after", + "inherit", + "unknown", ] as const)( - "keeps a subagent's frames in order when a late progress names its tool use while the root is idle%s", - ([, otherStart]) => - Effect.scoped( - Effect.gen(function* () { - const TASK_ID = "task-late-owner-idle"; - const TOOL_USE_ID = "toolu-late-owner-idle"; - const BASH = "toolu-late-owner-idle-bash"; - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - const bashStatuses = () => - harness.events.flatMap((event) => - event.type === "turn_item.updated" && event.turnItem.nativeItemRef?.nativeId === BASH - ? [`${event.turnItem.type}:${event.turnItem.status}`] - : [], - ); - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-late-owner-idle-1"), - text: "Audit in the background.", - attachments: [], - }), - ); - for (const frame of [ - claudeSdkFrame({ - type: "system", - subtype: "task_started", - task_id: TASK_ID, - description: "Audit recent commits", - subagent_type: "general-purpose", - is_backgrounded: true, - task_type: "local_agent", - prompt: "Audit the last five commits.", - uuid: "00000000-0000-4000-8000-000000000a01", - session_id: WAKE_NATIVE_SESSION, - }), - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000a02", - result: "Auditing in the background.", - }), - ]) { - yield* Queue.offer(harness.sdkMessages, frame); - } - yield* harness.awaitTerminal(1); - - const otherStartFrame = claudeSdkFrame({ - type: "system", - subtype: "task_started", - task_id: "task-late-owner-other", - description: "Check the changelog", - subagent_type: "general-purpose", - is_backgrounded: true, - task_type: "local_agent", - prompt: "Check the changelog.", - uuid: "00000000-0000-4000-8000-000000000a09", - session_id: WAKE_NATIVE_SESSION, - }); - const progressFrame = claudeSdkFrame({ - type: "system", - subtype: "task_progress", - task_id: TASK_ID, - tool_use_id: TOOL_USE_ID, - description: "Reading the last commits", - usage: { total_tokens: 1, tool_uses: 1, duration_ms: 1 }, - uuid: "00000000-0000-4000-8000-000000000a04", - session_id: WAKE_NATIVE_SESSION, - }); - const bashResultFrame = makeSubagentToolResultFrame({ - parentToolUseId: TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000a05", - toolUseId: BASH, - }); - for (const frame of [ - ...(otherStart === "before" ? [otherStartFrame] : []), - ...makeSubagentAssistantFrames({ - parentToolUseId: TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000a03", - text: "First.", - bashToolUseId: BASH, - }), - ...(otherStart === "between" - ? [otherStartFrame, bashResultFrame, progressFrame] - : [progressFrame, bashResultFrame]), - ...makeSubagentAssistantFrames({ - parentToolUseId: TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000a06", - text: "Second.", - }), - makeSubagentNotificationFrame({ - taskId: TASK_ID, - toolUseId: TOOL_USE_ID, - summary: "Second.", - uuid: "00000000-0000-4000-8000-000000000a07", - }), - ]) { - yield* Queue.offer(harness.sdkMessages, frame); - } - yield* harness.awaitContinuation(1); - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000a08", - result: "The auditor finished.", - }), - ); - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-late-owner-idle-2"), - text: "Background task completed.", - attachments: [], - providerTurnOrdinal: 2, - messageCreatedBy: "agent", - messageCreationSource: "provider", - }), - ); - yield* harness.awaitTerminal(2); - - const routing = subagentRouting(harness.events, [BASH]); - const childTexts = routing.assistantTexts(routing.childThreadId); - const statuses = bashStatuses(); - const trace = harness.events.flatMap((event) => - event.type === "message.updated" && - event.message.role === "assistant" && - event.message.threadId === routing.childThreadId - ? [`text:${event.message.text}#${event.message.id}`] - : event.type === "turn_item.updated" && - event.turnItem.nativeItemRef?.nativeId === BASH - ? [`bash:${event.turnItem.status}`] - : [], - ); - assert.deepEqual(childTexts, ["First.", "Second."], `trace: ${JSON.stringify(trace)}`); - assert.deepEqual( - [...(routing.toolThreadIds.get(BASH) ?? [])], - [routing.childThreadId], - `trace: ${JSON.stringify(trace)}`, - ); - assert.deepEqual( - statuses, - ["command_execution:running", "command_execution:completed"], - `trace: ${JSON.stringify(trace)}`, - ); - assert.isTrue( - harness.events.some( - (event) => - event.type === "subagent.updated" && - event.subagent.progress === "Reading the last commits", - ), - ); - assert.lengthOf(harness.continuationRequests, 1); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), - ), - ), - ), - ); - - it.effect("holds subagent frames that precede task_started and shows its result once", () => - Effect.scoped( + "records the subagent model from %s without inheriting the parent override", + (source) => Effect.gen(function* () { - const TASK_ID = "task-early-subagent"; - const TOOL_USE_ID = "toolu-early-subagent"; - const FINAL_REPORT = "Early auditor done."; const harness = yield* makeWakeHarness; const now = yield* DateTime.now; + const toolUseId = "toolu-subagent-model"; + const parentModel = "claude-opus-4-6"; + // Claude stamps its error frames as "", regardless of the subagent model. + const observedModel = + source === "synthetic-after" ? "" : "claude-haiku-4-5-20251001"; yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ threadId: harness.threadId, providerThread: harness.providerThread, now, - attemptId: RunAttemptId.make("attempt-claude-early-subagent"), - text: "Run an auditor.", + attemptId: RunAttemptId.make("attempt-subagent-model"), + text: "Spawn a Haiku subagent.", attachments: [], + modelSelection: { ...CLAUDE_TEST_MODEL_SELECTION, model: parentModel }, }), ); - const frames = [ - // Child frames can precede the task_started that registers their subagent. - ...makeSubagentAssistantFrames({ - parentToolUseId: TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000321", - text: "Starting early.", - bashToolUseId: "toolu-early-bash", - }), - makeSubagentToolResultFrame({ - parentToolUseId: TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000322", - toolUseId: "toolu-early-bash", - }), - makeSubagentTaskStartedFrame({ - taskId: TASK_ID, - toolUseId: TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000323", - }), - ...makeSubagentAssistantFrames({ - parentToolUseId: TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000324", - text: "Still working.", + const observed = claudeSdkFrame({ + type: "assistant", + parent_tool_use_id: toolUseId, + message: { + model: observedModel, + id: "msg_subagent_model_observed", + type: "message", + role: "assistant", + content: [{ type: "text", text: "Solving." }], + }, + uuid: "00000000-0000-4000-8000-000000000206", + session_id: WAKE_NATIVE_SESSION, + }); + if (source === "observed-before") yield* Queue.offer(harness.sdkMessages, observed); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "assistant", + parent_tool_use_id: null, + message: { + model: parentModel, + id: "msg_subagent_model_launch", + type: "message", + role: "assistant", + content: [ + { + type: "tool_use", + id: toolUseId, + name: "Agent", + input: { + description: "Haiku puzzle", + subagent_type: "general-purpose", + ...(source === "unknown" + ? {} + : { model: source === "inherit" ? "inherit" : "haiku" }), + prompt: "Solve the puzzle.", + }, + }, + ], + }, + uuid: "00000000-0000-4000-8000-000000000209", + session_id: WAKE_NATIVE_SESSION, }), + ); + yield* Queue.offer( + harness.sdkMessages, claudeSdkFrame({ type: "system", - subtype: "task_progress", - task_id: TASK_ID, - description: "Checking the diffs", - uuid: "00000000-0000-4000-8000-000000000325", + subtype: "task_started", + task_id: "task-subagent-model", + tool_use_id: toolUseId, + description: "Haiku puzzle", + task_type: "local_agent", + uuid: "00000000-0000-4000-8000-000000000207", session_id: WAKE_NATIVE_SESSION, }), - ...makeSubagentAssistantFrames({ - parentToolUseId: TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000326", - text: FINAL_REPORT, - }), - makeSubagentNotificationFrame({ - taskId: TASK_ID, - toolUseId: TOOL_USE_ID, - summary: FINAL_REPORT, - uuid: "00000000-0000-4000-8000-000000000327", - }), + ); + if (source === "observed-after" || source === "synthetic-after") { + yield* Queue.offer(harness.sdkMessages, observed); + } + yield* Queue.offer( + harness.sdkMessages, makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000328", - result: "The auditor finished.", + uuid: "00000000-0000-4000-8000-000000000208", + result: "Spawned the subagent.", }), - ]; - for (const frame of frames) { - yield* Queue.offer(harness.sdkMessages, frame); - } - yield* harness.awaitTerminal(1); - - const routing = subagentRouting(harness.events, ["toolu-early-bash"]); - assert.isDefined(routing.childThreadId); - // Agents quote this id back to t3_thread_send/t3_thread_read. - const childThreadId = routing.childThreadId ?? ""; - assert.notInclude(childThreadId, "%"); - assert.isAtMost(childThreadId.length, 64); - assert.deepEqual( - [...(routing.toolThreadIds.get("toolu-early-bash") ?? [])], - [routing.childThreadId], ); - assert.deepEqual(routing.assistantTexts(routing.childThreadId), [ - "Starting early.", - "Still working.", - FINAL_REPORT, - ]); - assert.deepEqual(routing.assistantTexts(harness.threadId), ["The auditor finished."]); + yield* Queue.take(harness.terminalReceipts); + const subagents = harness.events.filter((event) => event.type === "subagent.updated"); + const observedSlug = "claude-haiku-4-5"; + const initialModel = + source === "observed-before" + ? observedSlug + : source === "inherit" + ? parentModel + : source === "unknown" + ? null + : "claude-haiku-5-5"; + assert.equal(subagents[0]?.subagent.model, initialModel); + assert.equal( + subagents.at(-1)?.subagent.model, + source.startsWith("observed") ? observedSlug : initialModel, + ); + const child = harness.events.find((event) => event.type === "app_thread.created"); + assert.equal(child?.appThread.modelSelection?.model, initialModel ?? parentModel); }).pipe( Effect.provide( Layer.mergeAll( @@ -17865,204 +18091,139 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ), ), + ); + + it.effect("records a forked skill's model from its transcript instead of the parent's", () => + Effect.gen(function* () { + const harness = yield* makeWakeHarnessWithOptions({ + subagentModel: "claude-haiku-4-5-20251001", + }); + const now = yield* DateTime.now; + const parentModel = "claude-opus-4-6"; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-forked-skill-model"), + text: "/fork-probe go", + attachments: [], + modelSelection: { ...CLAUDE_TEST_MODEL_SELECTION, model: parentModel }, + }), + ); + // A `context: fork` skill has no Agent call and streams none of its own replies: + // task_started carries no tool_use_id, and only its prompt echo names the fork. + for (const frame of [ + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: "task-forked-skill", + description: "/fork-probe", + prompt: "go", + subagent_type: "general-purpose", + task_type: "local_agent", + skip_transcript: true, + ambient: true, + uuid: "00000000-0000-4000-8000-000000000231", + session_id: WAKE_NATIVE_SESSION, + }), + claudeSdkFrame({ + type: "system", + subtype: "task_notification", + task_id: "task-forked-skill", + status: "completed", + summary: "/fork-probe", + output_file: "", + uuid: "00000000-0000-4000-8000-000000000232", + session_id: WAKE_NATIVE_SESSION, + }), + claudeSdkFrame({ + type: "user", + parent_tool_use_id: "forked-command-fork-probe", + message: { role: "user", content: [{ type: "text", text: "go" }] }, + uuid: "00000000-0000-4000-8000-000000000233", + session_id: WAKE_NATIVE_SESSION, + }), + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000234", result: "OK" }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* Queue.take(harness.terminalReceipts); + + const fork = harness.events + .filter((event) => event.type === "subagent.updated") + .at(-1)?.subagent; + assert.equal(fork?.status, "completed"); + assert.equal(fork?.model, "claude-haiku-4-5"); + assert.equal(fork?.modelSelection?.model, "claude-haiku-4-5"); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), ), ); - it.effect.each([false, true])( - "drops an idle start superseded by live completion, later resume %s", - (resume) => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - const startTurn = (ordinal: number, continuation = false) => - harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: DateTime.makeUnsafe(0), - attemptId: RunAttemptId.make(`attempt-stale-start-${ordinal}`), - providerTurnOrdinal: ordinal, - text: "Continue work.", - attachments: [], - ...(continuation - ? { - messageCreatedBy: "agent" as const, - messageCreationSource: "provider" as const, - } - : {}), - }), - ); - const started = (taskId: string, suffix: string) => - makeSubagentTaskStartedFrame({ - taskId, - toolUseId: `toolu-${taskId}`, - uuid: `00000000-0000-4000-8000-${suffix}`, - }); - const finished = (taskId: string, suffix: string) => - makeSubagentNotificationFrame({ - taskId, - toolUseId: `toolu-${taskId}`, - summary: "Finished audit.", - uuid: `00000000-0000-4000-8000-${suffix}`, - }); - yield* startTurn(1); - yield* harness.offerAndWait(started("stale-agent", "000000004001")); - yield* harness.offerAndWait(started("wake-agent", "000000004002")); - yield* harness.offerAndWait( - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000004003", - result: "Agents launched.", - }), - ); - yield* Queue.take(harness.terminalReceipts); - yield* harness.offerAndWait(started("stale-agent", "000000004004")); - yield* startTurn(2); - yield* harness.offerAndWait(finished("stale-agent", "000000004005")); - yield* harness.offerAndWait( - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000004006", - result: "User turn finished.", - }), - ); - yield* Queue.take(harness.terminalReceipts); - if (resume) yield* harness.offerAndWait(started("stale-agent", "000000004007")); - yield* harness.offerAndWait(finished("wake-agent", "000000004008")); - yield* Queue.take(harness.continuationReceipts); - yield* startTurn(3, true); - yield* harness.offerAndWait( - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000004009", - result: "Wake finished.", - }), - ); - yield* Queue.take(harness.terminalReceipts); - const latest = harness.events.findLast( - (event) => - event.type === "subagent.updated" && - event.subagent.nativeTaskRef?.nativeId === "stale-agent", - ); - assert.equal(latest?.type, "subagent.updated"); - if (latest?.type !== "subagent.updated") return; - assert.equal(latest.subagent.status, resume ? "running" : "completed"); - assert.equal(latest.subagent.result, resume ? null : "Finished audit."); - assert.equal(latest.subagent.completedAt === null, resume); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), - ), - ), - ), - ); - - const makeStopFixture = (options?: Parameters[0]) => - Effect.gen(function* () { - const requests = yield* Queue.unbounded(); - const harness = yield* makeWakeHarnessWithOptions({ - ...options, - stopTask: (id) => - Queue.offer(requests, undefined).pipe( - Effect.andThen(options?.stopTask?.(id) ?? Effect.void), + it.effect.each([ + { + source: "custom model", + model: "claude-custom-tuned", + harness: { settings: ULTRACODE_CUSTOM_MODEL_CLAUDE_SETTINGS }, + }, + { + source: "downloaded catalog", + model: "claude-sonnet-5", + harness: { + modelCatalog: Effect.succeed({ + models: BUNDLED_CLAUDE_MODEL_CATALOG.models.map((entry) => + entry.model.slug === "claude-sonnet-5" + ? { + ...entry, + model: { + ...entry.model, + capabilities: { + optionDescriptors: [ + ...(entry.model.capabilities?.optionDescriptors ?? []), + { id: "ultracode", label: "Ultracode", type: "boolean" as const }, + ], + }, + }, + } + : entry, ), - }); - let sequence = 4500; - const uuid = () => `00000000-0000-4000-8000-${String(++sequence).padStart(12, "0")}`; - const startTurn = (continuation = false) => - harness.runtime.startTurn( + }), + }, + }, + ])( + "sends Ultracode with any effort on a model from the $source", + ({ harness: harnessOptions, model }) => + Effect.gen(function* () { + const harness = yield* makeWakeHarnessWithOptions(harnessOptions); + yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ threadId: harness.threadId, providerThread: harness.providerThread, - now: DateTime.makeUnsafe(0), - attemptId: RunAttemptId.make(`attempt-stop-${continuation ? 2 : 1}`), - providerTurnOrdinal: continuation ? 2 : 1, - text: "Run an audit.", + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-custom-ultracode"), + text: "Plan the work.", attachments: [], - ...(continuation - ? { messageCreatedBy: "agent" as const, messageCreationSource: "provider" as const } - : {}), - }), - ); - const start = (toolUseId = "toolu-stop-agent") => - harness.offerAndWait( - makeSubagentTaskStartedFrame({ - taskId: "stop-agent", - toolUseId, - uuid: uuid(), - }), - ); - const notify = ( - status: "completed" | "failed" | "stopped", - summary = "Native outcome.", - toolUseId = "toolu-stop-agent", - ) => - harness.offerAndWait( - claudeSdkFrame({ - ...makeSubagentNotificationFrame({ - taskId: "stop-agent", - toolUseId, - summary, - uuid: uuid(), - }), - status, - }), - ); - const stop = (requestedAt?: DateTime.Utc) => - harness.runtime.stopSubagent!({ - providerThread: harness.providerThread, - nativeTaskId: "stop-agent", - ...(requestedAt === undefined ? {} : { requestedAt }), - }); - const finishParent = () => - harness - .offerAndWait(makeResultFrame({ uuid: uuid(), result: "Launched." })) - .pipe(Effect.andThen(Queue.take(harness.terminalReceipts))); - yield* startTurn(); - yield* start(); - yield* Queue.take(harness.subagentReceipts); - return { - ...harness, - start, - notify, - stop, - finishParent, - replay: () => startTurn(true), - uuid, - requests, - }; - }); - - it.effect("retains native confirmation after the synchronous Agent tool result", () => - Effect.scoped( - Effect.gen(function* () { - const fixture = yield* makeStopFixture({ - stopTask: () => Effect.die("Task already completed"), - }); - yield* fixture.notify("completed"); - const completed = yield* Queue.take(fixture.subagentReceipts); - yield* fixture.offerAndWait( - claudeSdkFrame({ - type: "user", - message: { - role: "user", - content: [ - { type: "tool_result", tool_use_id: "toolu-stop-agent", content: "Tool result." }, + modelSelection: { + instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, + model, + options: [ + { id: "effort", value: "high" }, + { id: "ultracode", value: true }, ], }, - parent_tool_use_id: null, - uuid: fixture.uuid(), - session_id: WAKE_NATIVE_SESSION, }), ); - assert.equal((yield* Queue.take(fixture.subagentReceipts)).subagent.result, "Tool result."); - assert.deepEqual(yield* fixture.stop(), { - status: "completed", - completedAt: completed.subagent.completedAt!, - result: "Native outcome.", - }); + const options = harness.getOpenedOptions()!; + assert.equal(options.effort, "high"); + assert.equal((options.settings as { ultracode?: boolean }).ultracode, true); }).pipe( Effect.provide( Layer.mergeAll( @@ -18073,328 +18234,227 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ), ), + ); + + it.effect("resolves a subagent's model and effort against the instance's custom models", () => + Effect.gen(function* () { + const TUNED_TOOL_USE_ID = "toolu-effort-custom-tuned"; + const SHADOW_TOOL_USE_ID = "toolu-effort-custom-shadow"; + const harness = yield* makeWakeHarnessWithOptions({ + settings: CUSTOM_MODEL_CLAUDE_SETTINGS, + }); + const now = yield* DateTime.now; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-subagent-custom-effort"), + text: "Spawn subagents on custom models.", + attachments: [], + }), + ); + for (const frame of [ + claudeSdkFrame({ + type: "assistant", + parent_tool_use_id: null, + message: { + model: CLAUDE_TEST_MODEL_SELECTION.model, + id: "msg_custom_effort_launch", + type: "message", + role: "assistant", + content: [ + { + type: "tool_use", + id: TUNED_TOOL_USE_ID, + name: "Agent", + input: { + description: "Tuned", + subagent_type: "general-purpose", + model: "claude-custom-tuned", + effort: "brutal", + prompt: "Check one thing.", + }, + }, + { + type: "tool_use", + id: SHADOW_TOOL_USE_ID, + name: "Agent", + input: { + description: "Shadow", + subagent_type: "general-purpose", + model: "opus", + prompt: "Check another thing.", + }, + }, + ], + }, + uuid: "00000000-0000-4000-8000-000000000231", + session_id: WAKE_NATIVE_SESSION, + }), + ...[TUNED_TOOL_USE_ID, SHADOW_TOOL_USE_ID].map((id, index) => + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: `task-${id}`, + tool_use_id: id, + description: id, + task_type: "local_agent", + uuid: `00000000-0000-4000-8000-00000000023${index + 2}`, + session_id: WAKE_NATIVE_SESSION, + }), + ), + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000234", + result: "Spawned the subagents.", + }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* Queue.take(harness.terminalReceipts); + + const selections = Object.fromEntries( + harness.events.flatMap((event) => + event.type === "subagent.updated" + ? [[event.subagent.nativeTaskRef?.nativeId, event.subagent.modelSelection] as const] + : [], + ), + ); + const instanceId = CLAUDE_TEST_MODEL_SELECTION.instanceId; + assert.deepEqual(selections, { + [`task-${TUNED_TOOL_USE_ID}`]: { + instanceId, + model: "claude-custom-tuned", + options: [{ id: "effort", value: "brutal" }], + }, + [`task-${SHADOW_TOOL_USE_ID}`]: { instanceId, model: "opus" }, + }); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), ), ); - it.effect.each([ - [false, "stopped"], - [true, "stopped"], - [false, "completed"], - ] as const)( - "stops one native subagent while its sibling keeps running, owner settled %s, outcome %s", - ([ownerSettled, outcome]) => - Effect.scoped( - Effect.gen(function* () { - const stopped: string[] = []; - const stopRequested = yield* Deferred.make(); - let closes = 0; - const harness = yield* makeWakeHarnessWithOptions({ - stopTask: (taskId) => - Effect.sync(() => { - stopped.push(taskId); - }).pipe(Effect.andThen(Deferred.succeed(stopRequested, undefined))), - interrupt: Effect.die("A child stop must not interrupt its owner"), - close: () => - Effect.sync(() => { - closes++; - }), - }); - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("attempt-native-child-stop"), - text: "Start two agents.", - attachments: [], - }), - ); - for (const [taskId, toolUseId, uuid] of [ - ["agent-stop", "toolu-stop", "00000000-0000-4000-8000-000000000801"], - ["agent-keep", "toolu-keep", "00000000-0000-4000-8000-000000000802"], - ]) { - yield* harness.offerAndWait( - makeSubagentTaskStartedFrame({ taskId: taskId!, toolUseId: toolUseId!, uuid: uuid! }), - ); - yield* Queue.take(harness.subagentReceipts); - } - if (ownerSettled) { - yield* harness.offerAndWait( - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000803", - result: "Agents are working.", - }), - ); - yield* Queue.take(harness.terminalReceipts); - } - const stop = harness.runtime.stopSubagent; - assert.isDefined(stop); - const stopping = yield* stop!({ - providerThread: harness.providerThread, - nativeTaskId: "agent-stop", - }).pipe(Effect.forkScoped); - yield* Deferred.await(stopRequested); - assert.deepEqual(stopped, ["agent-stop"]); - yield* harness.offerAndWait( - claudeSdkFrame({ - ...makeSubagentNotificationFrame({ - taskId: "agent-stop", - toolUseId: "toolu-stop", - summary: "Stopped by user", - uuid: "00000000-0000-4000-8000-000000000804", - }), - status: outcome, - }), - ); - if (ownerSettled) { - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("attempt-native-child-stop-wake"), - text: "Background task stopped.", - attachments: [], - providerTurnOrdinal: 2, - messageCreatedBy: "agent", - messageCreationSource: "provider", - }), - ); - } - yield* Fiber.join(stopping); - const stoppedEvent = yield* Queue.take(harness.subagentReceipts); - assert.equal( - stoppedEvent.subagent.status, - outcome === "stopped" ? "cancelled" : "completed", - ); - const tasks = harness.events.filter((event) => event.type === "subagent.updated"); - assert.equal( - tasks.findLast((event) => event.subagent.nativeTaskRef?.nativeId === "agent-stop") - ?.subagent.status, - outcome === "stopped" ? "cancelled" : "completed", - ); - assert.equal( - tasks.findLast((event) => event.subagent.nativeTaskRef?.nativeId === "agent-keep") - ?.subagent.status, - "running", - ); - assert.lengthOf(harness.terminalEvents(), ownerSettled ? 1 : 0); - yield* stop!({ providerThread: harness.providerThread, nativeTaskId: "agent-stop" }); - assert.deepEqual(stopped, ["agent-stop"]); - assert.equal(closes, 0); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), - ), - ), - ), - ); - - it.effect.each([ - "requested", - "observed-before", - "observed-after", - "synthetic-after", - "inherit", - "unknown", - ] as const)( - "records the subagent model from %s without inheriting the parent override", - (source) => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - const toolUseId = "toolu-subagent-model"; - const parentModel = "claude-opus-4-6"; - // Claude stamps its error frames as "", regardless of the subagent model. - const observedModel = - source === "synthetic-after" ? "" : "claude-haiku-4-5-20251001"; - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-subagent-model"), - text: "Spawn a Haiku subagent.", - attachments: [], - modelSelection: { ...CLAUDE_TEST_MODEL_SELECTION, model: parentModel }, - }), - ); - const observed = claudeSdkFrame({ - type: "assistant", - parent_tool_use_id: toolUseId, - message: { - model: observedModel, - id: "msg_subagent_model_observed", - type: "message", - role: "assistant", - content: [{ type: "text", text: "Solving." }], - }, - uuid: "00000000-0000-4000-8000-000000000206", - session_id: WAKE_NATIVE_SESSION, - }); - if (source === "observed-before") yield* Queue.offer(harness.sdkMessages, observed); - yield* Queue.offer( - harness.sdkMessages, - claudeSdkFrame({ - type: "assistant", - parent_tool_use_id: null, - message: { - model: parentModel, - id: "msg_subagent_model_launch", - type: "message", - role: "assistant", - content: [ - { - type: "tool_use", - id: toolUseId, - name: "Agent", - input: { - description: "Haiku puzzle", - subagent_type: "general-purpose", - ...(source === "unknown" - ? {} - : { model: source === "inherit" ? "inherit" : "haiku" }), - prompt: "Solve the puzzle.", - }, - }, - ], - }, - uuid: "00000000-0000-4000-8000-000000000209", - session_id: WAKE_NATIVE_SESSION, - }), - ); - yield* Queue.offer( - harness.sdkMessages, - claudeSdkFrame({ - type: "system", - subtype: "task_started", - task_id: "task-subagent-model", - tool_use_id: toolUseId, - description: "Haiku puzzle", - task_type: "local_agent", - uuid: "00000000-0000-4000-8000-000000000207", - session_id: WAKE_NATIVE_SESSION, - }), - ); - if (source === "observed-after" || source === "synthetic-after") { - yield* Queue.offer(harness.sdkMessages, observed); - } - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000208", - result: "Spawned the subagent.", - }), - ); - yield* Queue.take(harness.terminalReceipts); - const subagents = harness.events.filter((event) => event.type === "subagent.updated"); - const observedSlug = "claude-haiku-4-5"; - const initialModel = - source === "observed-before" - ? observedSlug - : source === "inherit" - ? parentModel - : source === "unknown" - ? null - : "claude-haiku-5-5"; - assert.equal(subagents[0]?.subagent.model, initialModel); - assert.equal( - subagents.at(-1)?.subagent.model, - source.startsWith("observed") ? observedSlug : initialModel, - ); - const child = harness.events.find((event) => event.type === "app_thread.created"); - assert.equal(child?.appThread.modelSelection?.model, initialModel ?? parentModel); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), - ), - ), - ), - ); - - it.effect("records a forked skill's model from its transcript instead of the parent's", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWakeHarnessWithOptions({ - subagentModel: "claude-haiku-4-5-20251001", + it.effect("records only explicit effort for nested subagents", () => + Effect.gen(function* () { + const OUTER_TOOL_USE_ID = "toolu-effort-outer"; + const UNSPECIFIED_TOOL_USE_ID = "toolu-effort-nested-unspecified"; + const OVERRIDING_TOOL_USE_ID = "toolu-effort-nested-overriding"; + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const parentSelection = { + instanceId: CLAUDE_TEST_MODEL_SELECTION.instanceId, + model: "claude-opus-4-6", + options: [{ id: "effort", value: "high" }], + } satisfies ModelSelection; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-subagent-nested-effort"), + text: "Spawn a subagent that spawns two more.", + attachments: [], + modelSelection: parentSelection, + }), + ); + const agentCall = (id: string, effort?: string) => ({ + type: "tool_use" as const, + id, + name: "Agent", + input: { + description: id, + subagent_type: "general-purpose", + ...(effort === undefined ? {} : { effort }), + prompt: "Check one thing.", + }, + }); + const taskStarted = (id: string, uuid: string) => + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: `task-${id}`, + tool_use_id: id, + description: id, + task_type: "local_agent", + uuid, + session_id: WAKE_NATIVE_SESSION, }); - const now = yield* DateTime.now; - const parentModel = "claude-opus-4-6"; - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-forked-skill-model"), - text: "/fork-probe go", - attachments: [], - modelSelection: { ...CLAUDE_TEST_MODEL_SELECTION, model: parentModel }, - }), - ); - // A `context: fork` skill has no Agent call and streams none of its own replies: - // task_started carries no tool_use_id, and only its prompt echo names the fork. - for (const frame of [ - claudeSdkFrame({ - type: "system", - subtype: "task_started", - task_id: "task-forked-skill", - description: "/fork-probe", - prompt: "go", - subagent_type: "general-purpose", - task_type: "local_agent", - skip_transcript: true, - ambient: true, - uuid: "00000000-0000-4000-8000-000000000231", - session_id: WAKE_NATIVE_SESSION, - }), - claudeSdkFrame({ - type: "system", - subtype: "task_notification", - task_id: "task-forked-skill", - status: "completed", - summary: "/fork-probe", - output_file: "", - uuid: "00000000-0000-4000-8000-000000000232", - session_id: WAKE_NATIVE_SESSION, - }), - claudeSdkFrame({ - type: "user", - parent_tool_use_id: "forked-command-fork-probe", - message: { role: "user", content: [{ type: "text", text: "go" }] }, - uuid: "00000000-0000-4000-8000-000000000233", - session_id: WAKE_NATIVE_SESSION, - }), - makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000234", result: "OK" }), - ]) { - yield* Queue.offer(harness.sdkMessages, frame); - } - yield* Queue.take(harness.terminalReceipts); + for (const frame of [ + claudeSdkFrame({ + type: "assistant", + parent_tool_use_id: null, + message: { + model: parentSelection.model, + id: "msg_nested_effort_outer_launch", + type: "message", + role: "assistant", + content: [agentCall(OUTER_TOOL_USE_ID, "low")], + }, + uuid: "00000000-0000-4000-8000-000000000221", + session_id: WAKE_NATIVE_SESSION, + }), + taskStarted(OUTER_TOOL_USE_ID, "00000000-0000-4000-8000-000000000222"), + claudeSdkFrame({ + type: "assistant", + parent_tool_use_id: OUTER_TOOL_USE_ID, + message: { + model: parentSelection.model, + id: "msg_nested_effort_inner_launch", + type: "message", + role: "assistant", + content: [agentCall(UNSPECIFIED_TOOL_USE_ID), agentCall(OVERRIDING_TOOL_USE_ID, "max")], + }, + uuid: "00000000-0000-4000-8000-000000000223", + session_id: WAKE_NATIVE_SESSION, + }), + taskStarted(UNSPECIFIED_TOOL_USE_ID, "00000000-0000-4000-8000-000000000224"), + taskStarted(OVERRIDING_TOOL_USE_ID, "00000000-0000-4000-8000-000000000225"), + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000226", + result: "Spawned the subagents.", + }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* Queue.take(harness.terminalReceipts); - const fork = harness.events - .filter((event) => event.type === "subagent.updated") - .at(-1)?.subagent; - assert.equal(fork?.status, "completed"); - assert.equal(fork?.model, "claude-haiku-4-5"); - assert.equal(fork?.modelSelection?.model, "claude-haiku-4-5"); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + const efforts = new Map( + harness.events.flatMap((event) => + event.type === "subagent.updated" + ? [ + [ + event.subagent.nativeTaskRef?.nativeId, + getModelSelectionStringOptionValue(event.subagent.modelSelection, "effort"), + ] as const, + ] + : [], + ), + ); + assert.deepEqual(Object.fromEntries(efforts), { + [`task-${OUTER_TOOL_USE_ID}`]: "low", + [`task-${UNSPECIFIED_TOOL_USE_ID}`]: undefined, + [`task-${OVERRIDING_TOOL_USE_ID}`]: "max", + }); + const childEfforts = harness.events.flatMap((event) => + event.type === "app_thread.created" && event.appThread.id !== harness.threadId + ? [getModelSelectionStringOptionValue(event.appThread.modelSelection, "effort")] + : [], + ); + assert.deepEqual(childEfforts, ["low", undefined, "max"]); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), @@ -18402,850 +18462,717 @@ describe("ClaudeAdapterV2 background wake turns", () => { it.effect.each([ { - source: "custom model", - model: "claude-custom-tuned", - harness: { settings: ULTRACODE_CUSTOM_MODEL_CLAUDE_SETTINGS }, + label: "extracts text from direct content-block subagent results", + content: [ + { type: "text", text: "First line." }, + { type: "text", text: "Second line." }, + ], + expected: "First line.\nSecond line.", }, { - source: "downloaded catalog", - model: "claude-sonnet-5", - harness: { - modelCatalog: Effect.succeed({ - models: BUNDLED_CLAUDE_MODEL_CATALOG.models.map((entry) => - entry.model.slug === "claude-sonnet-5" - ? { - ...entry, - model: { - ...entry.model, - capabilities: { - optionDescriptors: [ - ...(entry.model.capabilities?.optionDescriptors ?? []), - { id: "ultracode", label: "Ultracode", type: "boolean" as const }, - ], - }, - }, - } - : entry, - ), - }), - }, + label: "drops Claude Code's tools-used note from a subagent result", + content: [ + { + type: "text", + text: "Read\n\nadd (line 1)\nmean (line 6)", + }, + ], + expected: "add (line 1)\nmean (line 6)", }, - ])( - "sends Ultracode with any effort on a model from the $source", - ({ harness: harnessOptions, model }) => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWakeHarnessWithOptions(harnessOptions); - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("attempt-custom-ultracode"), - text: "Plan the work.", - attachments: [], - modelSelection: { - instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, - model, - options: [ - { id: "effort", value: "high" }, - { id: "ultracode", value: true }, - ], + ])("$label", ({ content, expected }) => + Effect.gen(function* () { + const SUBAGENT_TASK_ID = "task-direct-content-blocks"; + const SUBAGENT_TOOL_USE_ID = "toolu-direct-content-blocks"; + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const subagentEvents = () => + harness.events.filter( + ( + event, + ): event is Extract< + ProviderAdapter.ProviderAdapterV2Event, + { type: "subagent.updated" } + > => event.type === "subagent.updated", + ); + + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-direct-content-blocks"), + text: "Delegate this task.", + attachments: [], + }), + ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: SUBAGENT_TASK_ID, + tool_use_id: SUBAGENT_TOOL_USE_ID, + description: "Delegated task", + subagent_type: "general-purpose", + task_type: "local_agent", + prompt: "Return the result.", + uuid: "00000000-0000-4000-8000-000000000206", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* harness.until(() => subagentEvents().length === 1); + + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "user", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: SUBAGENT_TOOL_USE_ID, + content, }, - }), - ); - const options = harness.getOpenedOptions()!; - assert.equal(options.effort, "high"); - assert.equal((options.settings as { ultracode?: boolean }).ultracode, true); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), - ), + ], + }, + parent_tool_use_id: null, + uuid: "00000000-0000-4000-8000-000000000207", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* harness.until(() => subagentEvents().at(-1)?.subagent.status === "completed"); + + assert.equal(subagentEvents().at(-1)?.subagent.result, expected); + + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000208", + result: "Delegation completed.", + }), + ); + yield* harness.awaitTerminal(1); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), + ), ); - it.effect("resolves a subagent's model and effort against the instance's custom models", () => - Effect.scoped( - Effect.gen(function* () { - const TUNED_TOOL_USE_ID = "toolu-effort-custom-tuned"; - const SHADOW_TOOL_USE_ID = "toolu-effort-custom-shadow"; - const harness = yield* makeWakeHarnessWithOptions({ - settings: CUSTOM_MODEL_CLAUDE_SETTINGS, - }); - const now = yield* DateTime.now; - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-subagent-custom-effort"), - text: "Spawn subagents on custom models.", - attachments: [], - }), - ); - for (const frame of [ - claudeSdkFrame({ - type: "assistant", - parent_tool_use_id: null, - message: { - model: CLAUDE_TEST_MODEL_SELECTION.model, - id: "msg_custom_effort_launch", - type: "message", - role: "assistant", - content: [ - { - type: "tool_use", - id: TUNED_TOOL_USE_ID, - name: "Agent", - input: { - description: "Tuned", - subagent_type: "general-purpose", - model: "claude-custom-tuned", - effort: "brutal", - prompt: "Check one thing.", - }, - }, - { - type: "tool_use", - id: SHADOW_TOOL_USE_ID, - name: "Agent", - input: { - description: "Shadow", - subagent_type: "general-purpose", - model: "opus", - prompt: "Check another thing.", - }, - }, - ], - }, - uuid: "00000000-0000-4000-8000-000000000231", - session_id: WAKE_NATIVE_SESSION, - }), - ...[TUNED_TOOL_USE_ID, SHADOW_TOOL_USE_ID].map((id, index) => - claudeSdkFrame({ - type: "system", - subtype: "task_started", - task_id: `task-${id}`, - tool_use_id: id, - description: id, - task_type: "local_agent", - uuid: `00000000-0000-4000-8000-00000000023${index + 2}`, - session_id: WAKE_NATIVE_SESSION, - }), - ), - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000234", - result: "Spawned the subagents.", - }), - ]) { - yield* Queue.offer(harness.sdkMessages, frame); - } - yield* Queue.take(harness.terminalReceipts); - - const selections = Object.fromEntries( - harness.events.flatMap((event) => - event.type === "subagent.updated" - ? [[event.subagent.nativeTaskRef?.nativeId, event.subagent.modelSelection] as const] - : [], - ), - ); - const instanceId = CLAUDE_TEST_MODEL_SELECTION.instanceId; - assert.deepEqual(selections, { - [`task-${TUNED_TOOL_USE_ID}`]: { - instanceId, - model: "claude-custom-tuned", - options: [{ id: "effort", value: "brutal" }], + it.effect("keeps a foreground fork running past the placeholder result for its own launch", () => + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-claude-foreground-fork"), + text: "Fork this.", + attachments: [], + }), + ); + yield* harness.offerAndWait( + makeSubagentTaskStartedFrame({ + taskId: "agent-fork", + toolUseId: "toolu-fork", + uuid: "00000000-0000-4000-8000-000000000901", + }), + ); + // Claude Code opens a foreground fork's own stream with a placeholder result for its launch. + yield* harness.offerAndWait( + claudeSdkFrame({ + type: "user", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "toolu-fork", + content: "Fork started — that fork is you.", + }, + ], }, - [`task-${SHADOW_TOOL_USE_ID}`]: { instanceId, model: "opus" }, - }); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + parent_tool_use_id: "toolu-fork", + uuid: "00000000-0000-4000-8000-000000000902", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* harness.offerAndWait( + makeSubagentNotificationFrame({ + taskId: "agent-fork", + toolUseId: "toolu-fork", + summary: "FORK-DONE", + uuid: "00000000-0000-4000-8000-000000000903", + }), + ); + let settled = yield* Queue.take(harness.subagentReceipts); + while (settled.subagent.status === "running") { + settled = yield* Queue.take(harness.subagentReceipts); + } + assert.equal(settled.subagent.result, "FORK-DONE"); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), ); - it.effect("records only explicit effort for nested subagents", () => - Effect.scoped( - Effect.gen(function* () { - const OUTER_TOOL_USE_ID = "toolu-effort-outer"; - const UNSPECIFIED_TOOL_USE_ID = "toolu-effort-nested-unspecified"; - const OVERRIDING_TOOL_USE_ID = "toolu-effort-nested-overriding"; - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - const parentSelection = { - instanceId: CLAUDE_TEST_MODEL_SELECTION.instanceId, - model: "claude-opus-4-6", - options: [{ id: "effort", value: "high" }], - } satisfies ModelSelection; - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-subagent-nested-effort"), - text: "Spawn a subagent that spawns two more.", - attachments: [], - modelSelection: parentSelection, - }), + it.effect("releases the idle pin when a post-settle subagent stops without completing", () => + Effect.gen(function* () { + const SUBAGENT_TASK_ID = "task-wake-subagent-stopped"; + const subagentTaskStarted = claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: SUBAGENT_TASK_ID, + tool_use_id: "toolu-wake-subagent-stopped", + description: "Long-running research task", + subagent_type: "general-purpose", + task_type: "local_agent", + prompt: "Investigate the flaky test.", + uuid: "00000000-0000-4000-8000-000000000301", + session_id: WAKE_NATIVE_SESSION, + }); + const subagentStoppedNotification = claudeSdkFrame({ + type: "system", + subtype: "task_notification", + task_id: SUBAGENT_TASK_ID, + tool_use_id: "toolu-wake-subagent-stopped", + status: "stopped", + output_file: "/tmp/task-wake-subagent-stopped.output", + summary: "Agent was stopped before finishing.", + uuid: "00000000-0000-4000-8000-000000000302", + session_id: WAKE_NATIVE_SESSION, + }); + + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const subagentEvents = () => + harness.events.filter( + ( + event, + ): event is Extract< + ProviderAdapter.ProviderAdapterV2Event, + { type: "subagent.updated" } + > => event.type === "subagent.updated", ); - const agentCall = (id: string, effort?: string) => ({ - type: "tool_use" as const, - id, - name: "Agent", - input: { - description: id, - subagent_type: "general-purpose", - ...(effort === undefined ? {} : { effort }), - prompt: "Check one thing.", - }, - }); - const taskStarted = (id: string, uuid: string) => - claudeSdkFrame({ - type: "system", - subtype: "task_started", - task_id: `task-${id}`, - tool_use_id: id, - description: id, - task_type: "local_agent", - uuid, - session_id: WAKE_NATIVE_SESSION, - }); - for (const frame of [ - claudeSdkFrame({ - type: "assistant", - parent_tool_use_id: null, - message: { - model: parentSelection.model, - id: "msg_nested_effort_outer_launch", - type: "message", - role: "assistant", - content: [agentCall(OUTER_TOOL_USE_ID, "low")], - }, - uuid: "00000000-0000-4000-8000-000000000221", - session_id: WAKE_NATIVE_SESSION, - }), - taskStarted(OUTER_TOOL_USE_ID, "00000000-0000-4000-8000-000000000222"), - claudeSdkFrame({ - type: "assistant", - parent_tool_use_id: OUTER_TOOL_USE_ID, - message: { - model: parentSelection.model, - id: "msg_nested_effort_inner_launch", - type: "message", - role: "assistant", - content: [ - agentCall(UNSPECIFIED_TOOL_USE_ID), - agentCall(OVERRIDING_TOOL_USE_ID, "max"), - ], - }, - uuid: "00000000-0000-4000-8000-000000000223", - session_id: WAKE_NATIVE_SESSION, - }), - taskStarted(UNSPECIFIED_TOOL_USE_ID, "00000000-0000-4000-8000-000000000224"), - taskStarted(OVERRIDING_TOOL_USE_ID, "00000000-0000-4000-8000-000000000225"), - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000226", - result: "Spawned the subagents.", - }), - ]) { - yield* Queue.offer(harness.sdkMessages, frame); - } - yield* Queue.take(harness.terminalReceipts); - - const efforts = new Map( - harness.events.flatMap((event) => - event.type === "subagent.updated" - ? [ - [ - event.subagent.nativeTaskRef?.nativeId, - getModelSelectionStringOptionValue(event.subagent.modelSelection, "effort"), - ] as const, - ] - : [], - ), - ); - assert.deepEqual(Object.fromEntries(efforts), { - [`task-${OUTER_TOOL_USE_ID}`]: "low", - [`task-${UNSPECIFIED_TOOL_USE_ID}`]: undefined, - [`task-${OVERRIDING_TOOL_USE_ID}`]: "max", - }); - const childEfforts = harness.events.flatMap((event) => - event.type === "app_thread.created" && event.appThread.id !== harness.threadId - ? [getModelSelectionStringOptionValue(event.appThread.modelSelection, "effort")] - : [], - ); - assert.deepEqual(childEfforts, ["low", undefined, "max"]); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), - ), - ), - ), - ); - - it.effect.each([ - { - label: "extracts text from direct content-block subagent results", - content: [ - { type: "text", text: "First line." }, - { type: "text", text: "Second line." }, - ], - expected: "First line.\nSecond line.", - }, - { - label: "drops Claude Code's tools-used note from a subagent result", - content: [ - { - type: "text", - text: "Read\n\nadd (line 1)\nmean (line 6)", - }, - ], - expected: "add (line 1)\nmean (line 6)", - }, - ])("$label", ({ content, expected }) => - Effect.scoped( - Effect.gen(function* () { - const SUBAGENT_TASK_ID = "task-direct-content-blocks"; - const SUBAGENT_TOOL_USE_ID = "toolu-direct-content-blocks"; - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - const subagentEvents = () => - harness.events.filter( - ( - event, - ): event is Extract< - ProviderAdapter.ProviderAdapterV2Event, - { type: "subagent.updated" } - > => event.type === "subagent.updated", - ); - - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-direct-content-blocks"), - text: "Delegate this task.", - attachments: [], - }), - ); - yield* Queue.offer( - harness.sdkMessages, - claudeSdkFrame({ - type: "system", - subtype: "task_started", - task_id: SUBAGENT_TASK_ID, - tool_use_id: SUBAGENT_TOOL_USE_ID, - description: "Delegated task", - subagent_type: "general-purpose", - task_type: "local_agent", - prompt: "Return the result.", - uuid: "00000000-0000-4000-8000-000000000206", - session_id: WAKE_NATIVE_SESSION, - }), - ); - yield* harness.until(() => subagentEvents().length === 1); - yield* Queue.offer( - harness.sdkMessages, - claudeSdkFrame({ - type: "user", - message: { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: SUBAGENT_TOOL_USE_ID, - content, - }, - ], - }, - parent_tool_use_id: null, - uuid: "00000000-0000-4000-8000-000000000207", - session_id: WAKE_NATIVE_SESSION, - }), - ); - yield* harness.until(() => subagentEvents().at(-1)?.subagent.status === "completed"); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-wake-7a"), + text: "Spawn a background subagent and stop.", + attachments: [], + }), + ); + yield* Queue.offer(harness.sdkMessages, subagentTaskStarted); + yield* harness.until(() => subagentEvents().length >= 1); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000303", + result: "Spawned the subagent in the background.", + }), + ); + yield* harness.awaitTerminal(1); + assert.isTrue(yield* harness.hasPendingBackgroundWork); - assert.equal(subagentEvents().at(-1)?.subagent.result, expected); + yield* Queue.offer(harness.sdkMessages, subagentStoppedNotification); + yield* harness.awaitContinuation(1); - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000208", - result: "Delegation completed.", - }), - ); - yield* harness.awaitTerminal(1); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), - ), - ), - ), - ); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000304", + result: "The subagent was stopped.", + }), + ); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-wake-7b"), + text: "Background task completed.", + attachments: [], + providerTurnOrdinal: 2, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + yield* harness.awaitTerminal(2); - it.effect("keeps a foreground fork running past the placeholder result for its own launch", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("attempt-claude-foreground-fork"), - text: "Fork this.", - attachments: [], - }), - ); - yield* harness.offerAndWait( - makeSubagentTaskStartedFrame({ - taskId: "agent-fork", - toolUseId: "toolu-fork", - uuid: "00000000-0000-4000-8000-000000000901", - }), - ); - // Claude Code opens a foreground fork's own stream with a placeholder result for its launch. - yield* harness.offerAndWait( - claudeSdkFrame({ - type: "user", - message: { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "toolu-fork", - content: "Fork started — that fork is you.", - }, - ], - }, - parent_tool_use_id: "toolu-fork", - uuid: "00000000-0000-4000-8000-000000000902", - session_id: WAKE_NATIVE_SESSION, - }), - ); - yield* harness.offerAndWait( - makeSubagentNotificationFrame({ - taskId: "agent-fork", - toolUseId: "toolu-fork", - summary: "FORK-DONE", - uuid: "00000000-0000-4000-8000-000000000903", - }), - ); - let settled = yield* Queue.take(harness.subagentReceipts); - while (settled.subagent.status === "running") { - settled = yield* Queue.take(harness.subagentReceipts); - } - assert.equal(settled.subagent.result, "FORK-DONE"); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + assert.equal(subagentEvents().at(-1)?.subagent.status, "cancelled"); + assert.isFalse(yield* harness.hasPendingBackgroundWork); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), ); - it.effect("releases the idle pin when a post-settle subagent stops without completing", () => - Effect.scoped( - Effect.gen(function* () { - const SUBAGENT_TASK_ID = "task-wake-subagent-stopped"; - const subagentTaskStarted = claudeSdkFrame({ - type: "system", - subtype: "task_started", - task_id: SUBAGENT_TASK_ID, - tool_use_id: "toolu-wake-subagent-stopped", - description: "Long-running research task", - subagent_type: "general-purpose", - task_type: "local_agent", - prompt: "Investigate the flaky test.", - uuid: "00000000-0000-4000-8000-000000000301", - session_id: WAKE_NATIVE_SESSION, - }); - const subagentStoppedNotification = claudeSdkFrame({ - type: "system", - subtype: "task_notification", - task_id: SUBAGENT_TASK_ID, - tool_use_id: "toolu-wake-subagent-stopped", - status: "stopped", - output_file: "/tmp/task-wake-subagent-stopped.output", - summary: "Agent was stopped before finishing.", - uuid: "00000000-0000-4000-8000-000000000302", - session_id: WAKE_NATIVE_SESSION, - }); - - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - const subagentEvents = () => - harness.events.filter( - ( - event, - ): event is Extract< - ProviderAdapter.ProviderAdapterV2Event, - { type: "subagent.updated" } - > => event.type === "subagent.updated", - ); + it.effect("re-opens a resumed subagent whose task_started races past settle", () => + Effect.gen(function* () { + const SUBAGENT_TASK_ID = "task-resume-postsettle"; + const SUBAGENT_TOOL_USE_ID = "toolu-resume-postsettle"; + const RESUME_TOOL_USE_ID = "toolu-resume-postsettle-sendmessage"; + const FIRST_SUMMARY = "Answered early."; + const SECOND_SUMMARY = "RESUME_SETTLE_DONE"; + const subagentTaskStarted = claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: SUBAGENT_TASK_ID, + tool_use_id: SUBAGENT_TOOL_USE_ID, + description: "Sleep then echo done token", + subagent_type: "general-purpose", + task_type: "local_agent", + prompt: "Run the shell command, then return exactly RESUME_SETTLE_DONE.", + uuid: "00000000-0000-4000-8000-000000000501", + session_id: WAKE_NATIVE_SESSION, + }); + const firstNotification = claudeSdkFrame({ + type: "system", + subtype: "task_notification", + task_id: SUBAGENT_TASK_ID, + tool_use_id: SUBAGENT_TOOL_USE_ID, + status: "completed", + output_file: "/tmp/task-resume-postsettle.output", + summary: FIRST_SUMMARY, + uuid: "00000000-0000-4000-8000-000000000502", + session_id: WAKE_NATIVE_SESSION, + }); + const resumeTaskStarted = claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: SUBAGENT_TASK_ID, + tool_use_id: RESUME_TOOL_USE_ID, + description: "Sleep then echo done token", + is_backgrounded: true, + task_type: "local_agent", + uuid: "00000000-0000-4000-8000-000000000505", + session_id: WAKE_NATIVE_SESSION, + }); + const secondNotification = claudeSdkFrame({ + type: "system", + subtype: "task_notification", + task_id: SUBAGENT_TASK_ID, + tool_use_id: RESUME_TOOL_USE_ID, + status: "completed", + output_file: "/tmp/task-resume-postsettle.output", + summary: SECOND_SUMMARY, + uuid: "00000000-0000-4000-8000-000000000506", + session_id: WAKE_NATIVE_SESSION, + }); - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-wake-7a"), - text: "Spawn a background subagent and stop.", - attachments: [], - }), - ); - yield* Queue.offer(harness.sdkMessages, subagentTaskStarted); - yield* harness.until(() => subagentEvents().length >= 1); - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000303", - result: "Spawned the subagent in the background.", - }), + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const subagentEvents = () => + harness.events.filter( + ( + event, + ): event is Extract< + ProviderAdapter.ProviderAdapterV2Event, + { type: "subagent.updated" } + > => event.type === "subagent.updated", ); - yield* harness.awaitTerminal(1); - assert.isTrue(yield* harness.hasPendingBackgroundWork); - yield* Queue.offer(harness.sdkMessages, subagentStoppedNotification); - yield* harness.awaitContinuation(1); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-wake-9a"), + text: "Spawn a background subagent and stop.", + attachments: [], + }), + ); + yield* Queue.offer(harness.sdkMessages, subagentTaskStarted); + yield* harness.until(() => subagentEvents().length >= 1); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000503", + result: "Spawned the subagent in the background.", + }), + ); + yield* harness.awaitTerminal(1); - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000304", - result: "The subagent was stopped.", - }), - ); - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-wake-7b"), - text: "Background task completed.", - attachments: [], - providerTurnOrdinal: 2, - messageCreatedBy: "agent", - messageCreationSource: "provider", - }), - ); - yield* harness.awaitTerminal(2); + yield* Queue.offer(harness.sdkMessages, firstNotification); + yield* harness.awaitContinuation(1); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000504", + result: "The subagent answered early.", + }), + ); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-wake-9b"), + text: "Background task completed.", + attachments: [], + providerTurnOrdinal: 2, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + yield* harness.awaitTerminal(2); + assert.equal(subagentEvents().at(-1)?.subagent.status, "completed"); + assert.equal(subagentEvents().at(-1)?.subagent.result, FIRST_SUMMARY); + assert.isFalse(yield* harness.hasPendingBackgroundWork); - assert.equal(subagentEvents().at(-1)?.subagent.status, "cancelled"); - assert.isFalse(yield* harness.hasPendingBackgroundWork); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "assistant", + message: { + model: "claude-sonnet-4-6", + id: "msg_resume_postsettle_sendmessage", + type: "message", + role: "assistant", + content: [ + { + type: "tool_use", + id: RESUME_TOOL_USE_ID, + name: "SendMessage", + input: { + to: SUBAGENT_TASK_ID, + summary: "Resume the subagent", + message: "Continue and return the token.", + }, + }, + ], + }, + parent_tool_use_id: null, + uuid: "00000000-0000-4000-8000-000000000508", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* Queue.offer(harness.sdkMessages, resumeTaskStarted); + const resumeAck = { + success: true, + message: `Resuming agent ${SUBAGENT_TASK_ID.slice(0, 7)}`, + resumedAgentId: SUBAGENT_TASK_ID, + pin: { id: SUBAGENT_TASK_ID, name: SUBAGENT_TASK_ID, ref: "42ab31" }, + }; + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "user", + message: { + role: "user", + content: [ + { + tool_use_id: RESUME_TOOL_USE_ID, + type: "tool_result", + content: [ + { + type: "text", + text: encodeJsonString(resumeAck), + }, + ], + }, + ], + }, + parent_tool_use_id: null, + uuid: "00000000-0000-4000-8000-000000000509", + session_id: WAKE_NATIVE_SESSION, + tool_use_result: resumeAck, + }), + ); + for (const frame of [ + ...makeSubagentAssistantFrames({ + parentToolUseId: SUBAGENT_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000961", + text: "RESUMED_WORK", + bashToolUseId: "toolu-resume-postsettle-bash", + }), + makeSubagentToolResultFrame({ + parentToolUseId: SUBAGENT_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000962", + toolUseId: "toolu-resume-postsettle-bash", + }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* Queue.offer(harness.sdkMessages, secondNotification); + yield* harness.awaitContinuation(2); + assert.equal(harness.continuationRequests[1]?.detail, SECOND_SUMMARY); + assert.isTrue(yield* harness.hasPendingBackgroundWork); + + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000507", + result: "The subagent finished with RESUME_SETTLE_DONE.", + }), + ); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-wake-9c"), + text: "Background task completed.", + attachments: [], + providerTurnOrdinal: 3, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + yield* harness.awaitTerminal(3); + + const statuses = subagentEvents().map((event) => event.subagent.status); + const firstCompleted = statuses.indexOf("completed"); + const reopenedIndex = statuses.lastIndexOf("running"); + assert.isAbove(reopenedIndex, firstCompleted); + assert.isNull(subagentEvents()[reopenedIndex]?.subagent.result); + assert.equal(subagentEvents()[reopenedIndex]?.subagent.runId, "run-attempt-claude-wake-9c"); + const nodeStatuses = harness.events + .filter( + ( + event, + ): event is Extract => + event.type === "node.updated" && + event.node.kind === "subagent" && + event.node.nativeItemRef?.nativeId === SUBAGENT_TASK_ID, + ) + .map((event) => event.node.status); + assert.isAbove(nodeStatuses.lastIndexOf("running"), nodeStatuses.indexOf("completed")); + const finalSubagent = subagentEvents().at(-1)?.subagent; + assert.equal(finalSubagent?.status, "completed"); + assert.equal(finalSubagent?.result, SECOND_SUMMARY); + assert.equal(finalSubagent?.runId, "run-attempt-claude-wake-9c"); + const reopenIndex = harness.events.findIndex( + (event) => + event.type === "subagent.updated" && + event.subagent.status === "running" && + event.subagent.runId === "run-attempt-claude-wake-9c", + ); + assert.isAtLeast(reopenIndex, 0); + const resumedWorkIndexes = harness.events.flatMap((event, index) => + event.type === "message.updated" && event.message.text === "RESUMED_WORK" ? [index] : [], + ); + assert.lengthOf(resumedWorkIndexes, 1); + assert.isAbove(resumedWorkIndexes[0] ?? -1, reopenIndex); + assert.isFalse(yield* harness.hasPendingBackgroundWork); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), ); - it.effect("re-opens a resumed subagent whose task_started races past settle", () => - Effect.scoped( - Effect.gen(function* () { - const SUBAGENT_TASK_ID = "task-resume-postsettle"; - const SUBAGENT_TOOL_USE_ID = "toolu-resume-postsettle"; - const RESUME_TOOL_USE_ID = "toolu-resume-postsettle-sendmessage"; - const FIRST_SUMMARY = "Answered early."; - const SECOND_SUMMARY = "RESUME_SETTLE_DONE"; - const subagentTaskStarted = claudeSdkFrame({ - type: "system", - subtype: "task_started", - task_id: SUBAGENT_TASK_ID, - tool_use_id: SUBAGENT_TOOL_USE_ID, - description: "Sleep then echo done token", - subagent_type: "general-purpose", - task_type: "local_agent", - prompt: "Run the shell command, then return exactly RESUME_SETTLE_DONE.", - uuid: "00000000-0000-4000-8000-000000000501", - session_id: WAKE_NATIVE_SESSION, - }); - const firstNotification = claudeSdkFrame({ - type: "system", - subtype: "task_notification", - task_id: SUBAGENT_TASK_ID, - tool_use_id: SUBAGENT_TOOL_USE_ID, - status: "completed", - output_file: "/tmp/task-resume-postsettle.output", - summary: FIRST_SUMMARY, - uuid: "00000000-0000-4000-8000-000000000502", - session_id: WAKE_NATIVE_SESSION, - }); - const resumeTaskStarted = claudeSdkFrame({ - type: "system", - subtype: "task_started", - task_id: SUBAGENT_TASK_ID, - tool_use_id: RESUME_TOOL_USE_ID, - description: "Sleep then echo done token", - is_backgrounded: true, - task_type: "local_agent", - uuid: "00000000-0000-4000-8000-000000000505", - session_id: WAKE_NATIVE_SESSION, - }); - const secondNotification = claudeSdkFrame({ + it.effect("resets Waiting roster and wake eligibility when the CLI process is replaced", () => + Effect.gen(function* () { + const changes = yield* makeChangeSignal; + const fileSystem = yield* FileSystem.FileSystem; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-claude-v2-process-reset-", + }); + const processQueues: Array> = []; + const events: Array = []; + const continuationRequests: Array = + []; + const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ + instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, + settings: DEFAULT_CLAUDE_SETTINGS, + environment: {}, + attachmentsDir, + fileSystem, + path: yield* Path.Path, + random: yield* SecureRandom.SecureRandom, + idAllocator, + continuationRequests: { + offer: (request) => + Effect.sync(() => { + continuationRequests.push(request); + }).pipe(Effect.andThen(changes.notify)), + }, + queryRunner: { + allocateSessionId: Effect.succeed(WAKE_NATIVE_SESSION), + open: () => + Effect.gen(function* () { + const sdkMessages = yield* Queue.unbounded(); + processQueues.push(sdkMessages); + return { + messages: Stream.fromQueue(sdkMessages), + offer: () => Effect.void, + setModel: () => Effect.void, + applyFlagSettings: () => Effect.void, + setPermissionMode: () => Effect.void, + backgroundTasks: () => Effect.succeed(false), + mcpServerStatus: Effect.succeed([]), + readMcpResource: () => Effect.die("unexpected MCP resource read"), + callMcpTool: () => Effect.die("unexpected MCP tool call"), + listPermissionRules: Effect.die("unexpected permission rules read"), + stopTask: () => Effect.void, + interrupt: Effect.void, + close: Queue.shutdown(sdkMessages), + }; + }), + forkSession: () => Effect.die("unused forkSession"), + subagentLaunchToolUseId: () => Effect.succeed(null), + subagentModel: () => Effect.succeed(null), + assertComplete: Effect.void, + }, + }); + const threadId = ThreadId.make("thread-claude-process-reset"); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-claude-process-reset"), + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + yield* runtime.events.pipe( + Stream.runForEach((event) => + Effect.sync(() => { + events.push(event); + }).pipe(Effect.andThen(changes.notify)), + ), + Effect.forkScoped, + ); + if (runtime.hasPendingBackgroundWork === undefined) { + return yield* Effect.die("Claude adapter runtime must expose hasPendingBackgroundWork."); + } + const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; + const now = yield* DateTime.now; + + yield* runtime.startTurn( + makeClaudeTestTurnInput({ + threadId, + providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-process-reset-a"), + text: "Run the build in the background.", + attachments: [], + }), + ); + assert.equal(processQueues.length, 1); + const firstProcess = processQueues[0]!; + yield* Queue.offer(firstProcess, wakeTaskStarted); + yield* Queue.offer( + firstProcess, + claudeSdkFrame({ type: "system", - subtype: "task_notification", - task_id: SUBAGENT_TASK_ID, - tool_use_id: RESUME_TOOL_USE_ID, - status: "completed", - output_file: "/tmp/task-resume-postsettle.output", - summary: SECOND_SUMMARY, - uuid: "00000000-0000-4000-8000-000000000506", + subtype: "background_tasks_changed", + tasks: [], + uuid: "00000000-0000-4000-8000-000000000603", session_id: WAKE_NATIVE_SESSION, - }); - - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - const subagentEvents = () => - harness.events.filter( - ( - event, - ): event is Extract< - ProviderAdapter.ProviderAdapterV2Event, - { type: "subagent.updated" } - > => event.type === "subagent.updated", - ); - - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-wake-9a"), - text: "Spawn a background subagent and stop.", - attachments: [], - }), - ); - yield* Queue.offer(harness.sdkMessages, subagentTaskStarted); - yield* harness.until(() => subagentEvents().length >= 1); - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000503", - result: "Spawned the subagent in the background.", - }), - ); - yield* harness.awaitTerminal(1); - - yield* Queue.offer(harness.sdkMessages, firstNotification); - yield* harness.awaitContinuation(1); - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000504", - result: "The subagent answered early.", - }), - ); - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-wake-9b"), - text: "Background task completed.", - attachments: [], - providerTurnOrdinal: 2, - messageCreatedBy: "agent", - messageCreationSource: "provider", - }), - ); - yield* harness.awaitTerminal(2); - assert.equal(subagentEvents().at(-1)?.subagent.status, "completed"); - assert.equal(subagentEvents().at(-1)?.subagent.result, FIRST_SUMMARY); - assert.isFalse(yield* harness.hasPendingBackgroundWork); + }), + ); + yield* Queue.offer(firstProcess, turnOneResult); + yield* changes.until(() => events.some((event) => event.type === "turn.terminal")); + assert.isFalse(yield* hasPendingBackgroundWork); + + const alternateModel = CLAUDE_TEST_RESTART_MODEL_SELECTION; + const activeProviderThread = { + ...providerThread, + status: "active" as const, + } satisfies OrchestrationV2ProviderThread; + yield* runtime.startTurn( + makeClaudeTestTurnInput({ + threadId, + providerThread: activeProviderThread, + now, + attemptId: RunAttemptId.make("attempt-claude-process-reset-b"), + text: "Continue after process restart.", + attachments: [], + providerTurnOrdinal: 2, + modelSelection: alternateModel, + }), + ); + assert.equal(processQueues.length, 2); - yield* Queue.offer( - harness.sdkMessages, - claudeSdkFrame({ - type: "assistant", - message: { - model: "claude-sonnet-4-6", - id: "msg_resume_postsettle_sendmessage", - type: "message", - role: "assistant", - content: [ - { - type: "tool_use", - id: RESUME_TOOL_USE_ID, - name: "SendMessage", - input: { - to: SUBAGENT_TASK_ID, - summary: "Resume the subagent", - message: "Continue and return the token.", - }, - }, - ], - }, - parent_tool_use_id: null, - uuid: "00000000-0000-4000-8000-000000000508", - session_id: WAKE_NATIVE_SESSION, - }), - ); - yield* Queue.offer(harness.sdkMessages, resumeTaskStarted); - const resumeAck = { - success: true, - message: `Resuming agent ${SUBAGENT_TASK_ID.slice(0, 7)}`, - resumedAgentId: SUBAGENT_TASK_ID, - pin: { id: SUBAGENT_TASK_ID, name: SUBAGENT_TASK_ID, ref: "42ab31" }, - }; - yield* Queue.offer( - harness.sdkMessages, - claudeSdkFrame({ - type: "user", - message: { - role: "user", - content: [ - { - tool_use_id: RESUME_TOOL_USE_ID, - type: "tool_result", - content: [ - { - type: "text", - text: encodeJsonString(resumeAck), - }, - ], - }, - ], - }, - parent_tool_use_id: null, - uuid: "00000000-0000-4000-8000-000000000509", - session_id: WAKE_NATIVE_SESSION, - tool_use_result: resumeAck, - }), - ); - for (const frame of [ - ...makeSubagentAssistantFrames({ - parentToolUseId: SUBAGENT_TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000961", - text: "RESUMED_WORK", - bashToolUseId: "toolu-resume-postsettle-bash", - }), - makeSubagentToolResultFrame({ - parentToolUseId: SUBAGENT_TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000962", - toolUseId: "toolu-resume-postsettle-bash", - }), - ]) { - yield* Queue.offer(harness.sdkMessages, frame); - } - yield* Queue.offer(harness.sdkMessages, secondNotification); - yield* harness.awaitContinuation(2); - assert.equal(harness.continuationRequests[1]?.detail, SECOND_SUMMARY); - assert.isTrue(yield* harness.hasPendingBackgroundWork); + yield* changes.until(() => + providerThreadRosterEvents(events).some( + (event) => + event.providerThread.status === "active" && + (event.providerThread.pendingBackgroundTasks?.length ?? 0) === 0 && + event.providerThread.updatedAt !== undefined, + ), + ); + assert.isFalse(yield* hasPendingBackgroundWork); + const emptyActiveRosterEvents = providerThreadRosterEvents(events).filter( + (event) => + event.providerThread.status === "active" && + (event.providerThread.pendingBackgroundTasks?.length ?? 0) === 0, + ); + assert.isAtLeast(emptyActiveRosterEvents.length, 1); + assert.deepEqual( + emptyActiveRosterEvents.at(-1)?.providerThread.pendingBackgroundTasks ?? [], + [], + ); + assert.equal(emptyActiveRosterEvents.at(-1)?.providerThread.status, "active"); - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000507", - result: "The subagent finished with RESUME_SETTLE_DONE.", - }), - ); - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-wake-9c"), - text: "Background task completed.", - attachments: [], - providerTurnOrdinal: 3, - messageCreatedBy: "agent", - messageCreationSource: "provider", - }), - ); - yield* harness.awaitTerminal(3); + const secondProcess = processQueues[1]!; + yield* Queue.offer(secondProcess, wakeNotification); + yield* yieldTicks(50); + assert.lengthOf(continuationRequests, 0); - const statuses = subagentEvents().map((event) => event.subagent.status); - const firstCompleted = statuses.indexOf("completed"); - const reopenedIndex = statuses.lastIndexOf("running"); - assert.isAbove(reopenedIndex, firstCompleted); - assert.isNull(subagentEvents()[reopenedIndex]?.subagent.result); - assert.equal(subagentEvents()[reopenedIndex]?.subagent.runId, "run-attempt-claude-wake-9c"); - const nodeStatuses = harness.events - .filter( - ( - event, - ): event is Extract => - event.type === "node.updated" && - event.node.kind === "subagent" && - event.node.nativeItemRef?.nativeId === SUBAGENT_TASK_ID, - ) - .map((event) => event.node.status); - assert.isAbove(nodeStatuses.lastIndexOf("running"), nodeStatuses.indexOf("completed")); - const finalSubagent = subagentEvents().at(-1)?.subagent; - assert.equal(finalSubagent?.status, "completed"); - assert.equal(finalSubagent?.result, SECOND_SUMMARY); - assert.equal(finalSubagent?.runId, "run-attempt-claude-wake-9c"); - const reopenIndex = harness.events.findIndex( - (event) => - event.type === "subagent.updated" && - event.subagent.status === "running" && - event.subagent.runId === "run-attempt-claude-wake-9c", - ); - assert.isAtLeast(reopenIndex, 0); - const resumedWorkIndexes = harness.events.flatMap((event, index) => - event.type === "message.updated" && event.message.text === "RESUMED_WORK" ? [index] : [], - ); - assert.lengthOf(resumedWorkIndexes, 1); - assert.isAbove(resumedWorkIndexes[0] ?? -1, reopenIndex); - assert.isFalse(yield* harness.hasPendingBackgroundWork); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + yield* Queue.offer( + secondProcess, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000602", + result: "Process restart turn finished.", + }), + ); + yield* changes.until( + () => events.filter((event) => event.type === "turn.terminal").length === 2, + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), ); - it.effect("resets Waiting roster and wake eligibility when the CLI process is replaced", () => - Effect.scoped( + it.effect( + "preserves buffered local_bash notification classification across model/policy query replacement", + () => Effect.gen(function* () { const changes = yield* makeChangeSignal; const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-claude-v2-process-reset-", + prefix: "t3-claude-v2-buffer-replace-", }); const processQueues: Array> = []; const events: Array = []; @@ -19294,10 +19221,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { assertComplete: Effect.void, }, }); - const threadId = ThreadId.make("thread-claude-process-reset"); + const threadId = ThreadId.make("thread-claude-buffer-replace"); const runtime = yield* adapter.openSession({ threadId, - providerSessionId: ProviderSessionId.make("provider-session-claude-process-reset"), + providerSessionId: ProviderSessionId.make("provider-session-claude-buffer-replace"), modelSelection: CLAUDE_TEST_MODEL_SELECTION, runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, }); @@ -19325,7 +19252,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { threadId, providerThread, now, - attemptId: RunAttemptId.make("attempt-claude-process-reset-a"), + attemptId: RunAttemptId.make("attempt-claude-buffer-replace-a"), text: "Run the build in the background.", attachments: [], }), @@ -19333,75 +19260,82 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.equal(processQueues.length, 1); const firstProcess = processQueues[0]!; yield* Queue.offer(firstProcess, wakeTaskStarted); - yield* Queue.offer( - firstProcess, - claudeSdkFrame({ - type: "system", - subtype: "background_tasks_changed", - tasks: [], - uuid: "00000000-0000-4000-8000-000000000603", - session_id: WAKE_NATIVE_SESSION, - }), - ); yield* Queue.offer(firstProcess, turnOneResult); yield* changes.until(() => events.some((event) => event.type === "turn.terminal")); - assert.isFalse(yield* hasPendingBackgroundWork); + assert.isTrue(yield* hasPendingBackgroundWork); + + yield* Queue.offer(firstProcess, wakeNotification); + yield* yieldTicks(50); + assert.lengthOf(continuationRequests, 0); const alternateModel = CLAUDE_TEST_RESTART_MODEL_SELECTION; - const activeProviderThread = { - ...providerThread, - status: "active" as const, - } satisfies OrchestrationV2ProviderThread; yield* runtime.startTurn( makeClaudeTestTurnInput({ threadId, - providerThread: activeProviderThread, + providerThread: { ...providerThread, status: "active" }, now, - attemptId: RunAttemptId.make("attempt-claude-process-reset-b"), - text: "Continue after process restart.", + attemptId: RunAttemptId.make("attempt-claude-buffer-replace-user"), + text: "Switch model while background work completes.", attachments: [], providerTurnOrdinal: 2, modelSelection: alternateModel, }), ); assert.equal(processQueues.length, 2); - - yield* changes.until(() => - providerThreadRosterEvents(events).some( - (event) => - event.providerThread.status === "active" && - (event.providerThread.pendingBackgroundTasks?.length ?? 0) === 0 && - event.providerThread.updatedAt !== undefined, - ), - ); - assert.isFalse(yield* hasPendingBackgroundWork); - const emptyActiveRosterEvents = providerThreadRosterEvents(events).filter( - (event) => - event.providerThread.status === "active" && - (event.providerThread.pendingBackgroundTasks?.length ?? 0) === 0, - ); - assert.isAtLeast(emptyActiveRosterEvents.length, 1); - assert.deepEqual( - emptyActiveRosterEvents.at(-1)?.providerThread.pendingBackgroundTasks ?? [], - [], - ); - assert.equal(emptyActiveRosterEvents.at(-1)?.providerThread.status, "active"); - const secondProcess = processQueues[1]!; - yield* Queue.offer(secondProcess, wakeNotification); - yield* yieldTicks(50); - assert.lengthOf(continuationRequests, 0); - yield* Queue.offer( secondProcess, makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000602", - result: "Process restart turn finished.", + uuid: "00000000-0000-4000-8000-000000000701", + result: "User turn finished after model switch.", }), ); yield* changes.until( () => events.filter((event) => event.type === "turn.terminal").length === 2, ); + assert.isFalse(yield* hasPendingBackgroundWork); + assert.lengthOf(continuationRequests, 0); + + yield* Queue.offer(secondProcess, wakeResult); + yield* changes.until(() => continuationRequests.length === 1); + assert.equal(continuationRequests[0]?.detail, WAKE_SUMMARY); + yield* runtime.startTurn( + makeClaudeTestTurnInput({ + threadId, + providerThread: { ...providerThread, status: "active" }, + now, + attemptId: RunAttemptId.make("attempt-claude-buffer-replace-cont"), + text: "Background task completed.", + attachments: [], + providerTurnOrdinal: 3, + modelSelection: alternateModel, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + yield* changes.until( + () => events.filter((event) => event.type === "turn.terminal").length === 3, + ); + assert.isTrue( + events.some( + (event) => event.type === "message.updated" && event.message.text === WAKE_RESULT_TEXT, + ), + ); + assert.isFalse( + events.some( + (event) => + event.type === "subagent.updated" || + (event.type === "node.updated" && event.node.kind === "subagent"), + ), + ); + assert.isFalse( + events.some( + (event) => + event.type !== "provider_thread.updated" && + JSON.stringify(event).includes(WAKE_TASK_ID), + ), + ); + assert.isFalse(yield* hasPendingBackgroundWork); }).pipe( Effect.provide( Layer.mergeAll( @@ -19412,425 +19346,378 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ), ), - ), ); - it.effect( - "preserves buffered local_bash notification classification across model/policy query replacement", - () => - Effect.scoped( - Effect.gen(function* () { - const changes = yield* makeChangeSignal; - const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-claude-v2-buffer-replace-", - }); - const processQueues: Array> = []; - const events: Array = []; - const continuationRequests: Array = - []; - const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ - instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, - settings: DEFAULT_CLAUDE_SETTINGS, - environment: {}, - attachmentsDir, - fileSystem, - path: yield* Path.Path, - random: yield* SecureRandom.SecureRandom, - idAllocator, - continuationRequests: { - offer: (request) => - Effect.sync(() => { - continuationRequests.push(request); - }).pipe(Effect.andThen(changes.notify)), - }, - queryRunner: { - allocateSessionId: Effect.succeed(WAKE_NATIVE_SESSION), - open: () => - Effect.gen(function* () { - const sdkMessages = yield* Queue.unbounded(); - processQueues.push(sdkMessages); - return { - messages: Stream.fromQueue(sdkMessages), - offer: () => Effect.void, - setModel: () => Effect.void, - applyFlagSettings: () => Effect.void, - setPermissionMode: () => Effect.void, - backgroundTasks: () => Effect.succeed(false), - mcpServerStatus: Effect.succeed([]), - readMcpResource: () => Effect.die("unexpected MCP resource read"), - callMcpTool: () => Effect.die("unexpected MCP tool call"), - listPermissionRules: Effect.die("unexpected permission rules read"), - stopTask: () => Effect.void, - interrupt: Effect.void, - close: Queue.shutdown(sdkMessages), - }; - }), - forkSession: () => Effect.die("unused forkSession"), - subagentLaunchToolUseId: () => Effect.succeed(null), - subagentModel: () => Effect.succeed(null), - assertComplete: Effect.void, - }, - }); - const threadId = ThreadId.make("thread-claude-buffer-replace"); - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("provider-session-claude-buffer-replace"), - modelSelection: CLAUDE_TEST_MODEL_SELECTION, - runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, - }); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection: CLAUDE_TEST_MODEL_SELECTION, - runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, - }); - yield* runtime.events.pipe( - Stream.runForEach((event) => - Effect.sync(() => { - events.push(event); - }).pipe(Effect.andThen(changes.notify)), - ), - Effect.forkScoped, - ); - if (runtime.hasPendingBackgroundWork === undefined) { - return yield* Effect.die( - "Claude adapter runtime must expose hasPendingBackgroundWork.", - ); - } - const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; - const now = yield* DateTime.now; - - yield* runtime.startTurn( - makeClaudeTestTurnInput({ - threadId, - providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-buffer-replace-a"), - text: "Run the build in the background.", - attachments: [], - }), - ); - assert.equal(processQueues.length, 1); - const firstProcess = processQueues[0]!; - yield* Queue.offer(firstProcess, wakeTaskStarted); - yield* Queue.offer(firstProcess, turnOneResult); - yield* changes.until(() => events.some((event) => event.type === "turn.terminal")); - assert.isTrue(yield* hasPendingBackgroundWork); - - yield* Queue.offer(firstProcess, wakeNotification); - yield* yieldTicks(50); - assert.lengthOf(continuationRequests, 0); - - const alternateModel = CLAUDE_TEST_RESTART_MODEL_SELECTION; - yield* runtime.startTurn( - makeClaudeTestTurnInput({ - threadId, - providerThread: { ...providerThread, status: "active" }, - now, - attemptId: RunAttemptId.make("attempt-claude-buffer-replace-user"), - text: "Switch model while background work completes.", - attachments: [], - providerTurnOrdinal: 2, - modelSelection: alternateModel, - }), - ); - assert.equal(processQueues.length, 2); - const secondProcess = processQueues[1]!; - yield* Queue.offer( - secondProcess, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000701", - result: "User turn finished after model switch.", - }), - ); - yield* changes.until( - () => events.filter((event) => event.type === "turn.terminal").length === 2, - ); - assert.isFalse(yield* hasPendingBackgroundWork); - assert.lengthOf(continuationRequests, 0); - - yield* Queue.offer(secondProcess, wakeResult); - yield* changes.until(() => continuationRequests.length === 1); - assert.equal(continuationRequests[0]?.detail, WAKE_SUMMARY); - yield* runtime.startTurn( - makeClaudeTestTurnInput({ - threadId, - providerThread: { ...providerThread, status: "active" }, - now, - attemptId: RunAttemptId.make("attempt-claude-buffer-replace-cont"), - text: "Background task completed.", - attachments: [], - providerTurnOrdinal: 3, - modelSelection: alternateModel, - messageCreatedBy: "agent", - messageCreationSource: "provider", + it.effect("refuses a thinking change that would kill a running background subagent", () => + Effect.gen(function* () { + const changes = yield* makeChangeSignal; + const SUBAGENT_TASK_ID = "task-model-change-running-subagent"; + const SUBAGENT_TOOL_USE_ID = "toolu-model-change-running-subagent"; + const fileSystem = yield* FileSystem.FileSystem; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-claude-v2-model-change-running-subagent-", + }); + const processQueues: Array> = []; + const events: Array = []; + const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ + instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, + settings: DEFAULT_CLAUDE_SETTINGS, + environment: {}, + attachmentsDir, + fileSystem, + path: yield* Path.Path, + random: yield* SecureRandom.SecureRandom, + idAllocator, + continuationRequests: { offer: () => Effect.void }, + queryRunner: { + allocateSessionId: Effect.succeed(WAKE_NATIVE_SESSION), + open: () => + Effect.gen(function* () { + const sdkMessages = yield* Queue.unbounded(); + processQueues.push(sdkMessages); + return { + messages: Stream.fromQueue(sdkMessages), + offer: () => Effect.void, + setModel: () => Effect.void, + applyFlagSettings: () => Effect.void, + setPermissionMode: () => Effect.void, + backgroundTasks: () => Effect.succeed(false), + mcpServerStatus: Effect.succeed([]), + readMcpResource: () => Effect.die("unexpected MCP resource read"), + callMcpTool: () => Effect.die("unexpected MCP tool call"), + listPermissionRules: Effect.die("unexpected permission rules read"), + stopTask: () => Effect.void, + interrupt: Effect.void, + close: Queue.shutdown(sdkMessages), + }; }), - ); - yield* changes.until( - () => events.filter((event) => event.type === "turn.terminal").length === 3, - ); - assert.isTrue( - events.some( - (event) => - event.type === "message.updated" && event.message.text === WAKE_RESULT_TEXT, - ), - ); - assert.isFalse( - events.some( - (event) => - event.type === "subagent.updated" || - (event.type === "node.updated" && event.node.kind === "subagent"), - ), - ); - assert.isFalse( - events.some( - (event) => - event.type !== "provider_thread.updated" && - JSON.stringify(event).includes(WAKE_TASK_ID), - ), - ); - assert.isFalse(yield* hasPendingBackgroundWork); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), - ), + forkSession: () => Effect.die("unused forkSession"), + subagentLaunchToolUseId: () => Effect.succeed(null), + subagentModel: () => Effect.succeed(null), + assertComplete: Effect.void, + }, + }); + const threadId = ThreadId.make("thread-claude-model-change-running-subagent"); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make( + "provider-session-claude-model-change-running-subagent", ), - ), - ); + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + yield* runtime.events.pipe( + Stream.runForEach((event) => + Effect.sync(() => { + events.push(event); + }).pipe(Effect.andThen(changes.notify)), + ), + Effect.forkScoped, + ); + const terminals = () => events.filter((event) => event.type === "turn.terminal"); + const now = yield* DateTime.now; - it.effect("refuses a thinking change that would kill a running background subagent", () => - Effect.scoped( - Effect.gen(function* () { - const changes = yield* makeChangeSignal; - const SUBAGENT_TASK_ID = "task-model-change-running-subagent"; - const SUBAGENT_TOOL_USE_ID = "toolu-model-change-running-subagent"; - const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-claude-v2-model-change-running-subagent-", - }); - const processQueues: Array> = []; - const events: Array = []; - const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ - instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, - settings: DEFAULT_CLAUDE_SETTINGS, - environment: {}, - attachmentsDir, - fileSystem, - path: yield* Path.Path, - random: yield* SecureRandom.SecureRandom, - idAllocator, - continuationRequests: { offer: () => Effect.void }, - queryRunner: { - allocateSessionId: Effect.succeed(WAKE_NATIVE_SESSION), - open: () => - Effect.gen(function* () { - const sdkMessages = yield* Queue.unbounded(); - processQueues.push(sdkMessages); - return { - messages: Stream.fromQueue(sdkMessages), - offer: () => Effect.void, - setModel: () => Effect.void, - applyFlagSettings: () => Effect.void, - setPermissionMode: () => Effect.void, - backgroundTasks: () => Effect.succeed(false), - mcpServerStatus: Effect.succeed([]), - readMcpResource: () => Effect.die("unexpected MCP resource read"), - callMcpTool: () => Effect.die("unexpected MCP tool call"), - listPermissionRules: Effect.die("unexpected permission rules read"), - stopTask: () => Effect.void, - interrupt: Effect.void, - close: Queue.shutdown(sdkMessages), - }; - }), - forkSession: () => Effect.die("unused forkSession"), - subagentLaunchToolUseId: () => Effect.succeed(null), - subagentModel: () => Effect.succeed(null), - assertComplete: Effect.void, - }, - }); - const threadId = ThreadId.make("thread-claude-model-change-running-subagent"); - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make( - "provider-session-claude-model-change-running-subagent", - ), - modelSelection: CLAUDE_TEST_MODEL_SELECTION, - runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, - }); - const providerThread = yield* runtime.ensureThread({ + yield* runtime.startTurn( + makeClaudeTestTurnInput({ threadId, - modelSelection: CLAUDE_TEST_MODEL_SELECTION, - runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, - }); - yield* runtime.events.pipe( - Stream.runForEach((event) => - Effect.sync(() => { - events.push(event); - }).pipe(Effect.andThen(changes.notify)), - ), - Effect.forkScoped, - ); - const terminals = () => events.filter((event) => event.type === "turn.terminal"); - const now = yield* DateTime.now; + providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-model-change-running-subagent-a"), + text: "Spawn a background subagent and stop.", + attachments: [], + }), + ); + const firstProcess = processQueues[0]!; + yield* Queue.offer( + firstProcess, + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: SUBAGENT_TASK_ID, + tool_use_id: SUBAGENT_TOOL_USE_ID, + description: "Background research", + subagent_type: "general-purpose", + task_type: "local_agent", + prompt: "Research, then report.", + uuid: "00000000-0000-4000-8000-000000000901", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* Queue.offer( + firstProcess, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000902", + result: "Spawned the subagent in the background.", + }), + ); + yield* changes.until(() => terminals().length === 1); + const settledTurn = terminals()[0]!; - yield* runtime.startTurn( + assert.isFalse(yield* runtime.hasPendingBackgroundWorkForThread!(providerThread)); + assert.isTrue( + yield* runtime.hasPendingBackgroundWorkForThread!(providerThread, { + includeSubagents: true, + }), + ); + assert.isFalse( + yield* runtime.hasPendingBackgroundWorkForThread!( + { + ...providerThread, + nativeThreadRef: { + ...providerThread.nativeThreadRef!, + nativeId: "sibling-native-session", + }, + }, + { includeSubagents: true }, + ), + ); + + const alternateModel = CLAUDE_TEST_RESTART_MODEL_SELECTION; + const switchTurn = (attempt: string) => + runtime.startTurn( makeClaudeTestTurnInput({ threadId, - providerThread, + providerThread: { ...providerThread, status: "active" }, now, - attemptId: RunAttemptId.make("attempt-claude-model-change-running-subagent-a"), - text: "Spawn a background subagent and stop.", + attemptId: RunAttemptId.make(attempt), + text: "Switch model while the subagent runs.", attachments: [], + providerTurnOrdinal: 2, + modelSelection: alternateModel, }), ); - const firstProcess = processQueues[0]!; - yield* Queue.offer( - firstProcess, - claudeSdkFrame({ - type: "system", - subtype: "task_started", - task_id: SUBAGENT_TASK_ID, - tool_use_id: SUBAGENT_TOOL_USE_ID, - description: "Background research", - subagent_type: "general-purpose", - task_type: "local_agent", - prompt: "Research, then report.", - uuid: "00000000-0000-4000-8000-000000000901", - session_id: WAKE_NATIVE_SESSION, - }), - ); - yield* Queue.offer( - firstProcess, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000902", - result: "Spawned the subagent in the background.", - }), - ); - yield* changes.until(() => terminals().length === 1); - const settledTurn = terminals()[0]!; + const refused = yield* switchTurn("attempt-claude-model-change-running-subagent-b").pipe( + Effect.flip, + ); + assert.equal( + makeProviderFailure({ cause: refused, class: "provider_error" }).message, + new ClaudeAdapterV2.ClaudeBackgroundWorkBlocksQueryReplacementError().message, + ); + assert.lengthOf(processQueues, 1); - assert.isFalse(yield* runtime.hasPendingBackgroundWorkForThread!(providerThread)); - assert.isTrue( - yield* runtime.hasPendingBackgroundWorkForThread!(providerThread, { - includeSubagents: true, - }), - ); - assert.isFalse( - yield* runtime.hasPendingBackgroundWorkForThread!( - { - ...providerThread, - nativeThreadRef: { - ...providerThread.nativeThreadRef!, - nativeId: "sibling-native-session", - }, - }, - { includeSubagents: true }, - ), - ); + yield* runtime.interruptTurn({ + providerThread, + providerTurnId: settledTurn.providerTurnId, + requestRuntimeRestart: true, + }); + yield* switchTurn("attempt-claude-model-change-running-subagent-c"); + assert.lengthOf(processQueues, 2); - const alternateModel = CLAUDE_TEST_RESTART_MODEL_SELECTION; - const switchTurn = (attempt: string) => - runtime.startTurn( - makeClaudeTestTurnInput({ - threadId, - providerThread: { ...providerThread, status: "active" }, - now, - attemptId: RunAttemptId.make(attempt), - text: "Switch model while the subagent runs.", - attachments: [], - providerTurnOrdinal: 2, - modelSelection: alternateModel, + yield* Queue.offer( + processQueues[1]!, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000903", + result: "Switched model.", + }), + ); + yield* changes.until(() => terminals().length === 2); + yield* runtime.startTurn( + makeClaudeTestTurnInput({ + threadId, + providerThread: { ...providerThread, status: "active" }, + now, + attemptId: RunAttemptId.make("attempt-claude-model-change-running-subagent-d"), + text: "Switch back.", + attachments: [], + providerTurnOrdinal: 3, + }), + ); + assert.lengthOf(processQueues, 3); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + + // regression: #20413 an unreadable append file must not tear down the running process + it.effect("keeps the live process when its replacement cannot read the append file", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-claude-append-replacement-", + }); + const appendFile = NodePath.join(cwd, "rules.md"); + yield* fileSystem.writeFileString(appendFile, "File sentinel\n"); + const processQueues: Array> = []; + let closes = 0; + const firstTurnTerminal = yield* Deferred.make(); + const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ + instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, + settings: { + ...DEFAULT_CLAUDE_SETTINGS, + launchArgs: "--append-system-prompt-file rules.md", + }, + environment: {}, + attachmentsDir: yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-claude-append-replacement-attachments-", + }), + fileSystem, + path: yield* Path.Path, + random: yield* SecureRandom.SecureRandom, + idAllocator: yield* IdAllocator.IdAllocatorV2, + continuationRequests: { offer: () => Effect.void }, + queryRunner: { + allocateSessionId: Effect.succeed(WAKE_NATIVE_SESSION), + open: () => + Effect.gen(function* () { + const sdkMessages = yield* Queue.unbounded(); + processQueues.push(sdkMessages); + return { + messages: Stream.fromQueue(sdkMessages), + offer: () => Effect.void, + setModel: () => Effect.void, + applyFlagSettings: () => Effect.void, + setPermissionMode: () => Effect.void, + backgroundTasks: () => Effect.succeed(false), + mcpServerStatus: Effect.succeed([]), + readMcpResource: () => Effect.die("unexpected MCP resource read"), + callMcpTool: () => Effect.die("unexpected MCP tool call"), + listPermissionRules: Effect.die("unexpected permission rules read"), + stopTask: () => Effect.void, + interrupt: Effect.void, + close: Effect.sync(() => { + closes += 1; + }).pipe(Effect.andThen(Queue.shutdown(sdkMessages))), + }; }), - ); - const refused = yield* switchTurn("attempt-claude-model-change-running-subagent-b").pipe( - Effect.flip, - ); - assert.equal( - makeProviderFailure({ cause: refused, class: "provider_error" }).message, - new ClaudeAdapterV2.ClaudeBackgroundWorkBlocksQueryReplacementError().message, - ); - assert.lengthOf(processQueues, 1); + forkSession: () => Effect.die("unused forkSession"), + subagentLaunchToolUseId: () => Effect.succeed(null), + subagentModel: () => Effect.succeed(null), + assertComplete: Effect.void, + }, + }); + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd, + }); + const threadId = ThreadId.make("thread-claude-append-replacement"); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-claude-append-replacement"), + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy, + }); + yield* runtime.events.pipe( + Stream.runForEach((event) => + event.type === "turn.terminal" + ? Deferred.succeed(firstTurnTerminal, undefined) + : Effect.void, + ), + Effect.forkScoped, + ); + const now = yield* DateTime.now; - yield* runtime.interruptTurn({ + yield* runtime.startTurn( + makeClaudeTestTurnInput({ + threadId, providerThread, - providerTurnId: settledTurn.providerTurnId, - requestRuntimeRestart: true, - }); - yield* switchTurn("attempt-claude-model-change-running-subagent-c"); - assert.lengthOf(processQueues, 2); + now, + attemptId: RunAttemptId.make("attempt-claude-append-replacement-a"), + text: "What is the sentinel?", + attachments: [], + runtimePolicy, + }), + ); + yield* Queue.offer( + processQueues[0]!, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000a01", + result: "File sentinel", + }), + ); + yield* Deferred.await(firstTurnTerminal); - yield* Queue.offer( - processQueues[1]!, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000903", - result: "Switched model.", - }), - ); - yield* changes.until(() => terminals().length === 2); - yield* runtime.startTurn( + yield* fileSystem.remove(appendFile); + // A thinking change cannot apply live, so this turn must replace the process. + const refused = yield* runtime + .startTurn( makeClaudeTestTurnInput({ threadId, providerThread: { ...providerThread, status: "active" }, now, - attemptId: RunAttemptId.make("attempt-claude-model-change-running-subagent-d"), - text: "Switch back.", + attemptId: RunAttemptId.make("attempt-claude-append-replacement-b"), + text: "Switch model.", attachments: [], - providerTurnOrdinal: 3, + providerTurnOrdinal: 2, + modelSelection: CLAUDE_TEST_RESTART_MODEL_SELECTION, + runtimePolicy, }), - ); - assert.lengthOf(processQueues, 3); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + ) + .pipe(Effect.flip); + assert.equal( + makeProviderFailure({ cause: refused, class: "provider_error" }).message, + new ClaudeAdapterV2.ClaudeAppendSystemPromptFileError({ path: appendFile }).message, + ); + assert.lengthOf(processQueues, 1); + assert.equal(closes, 0); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), ); - // regression: #20413 an unreadable append file must not tear down the running process - it.effect("keeps the live process when its replacement cannot read the append file", () => - Effect.scoped( + it.effect( + "keeps the process and its roster when a model change meets a running background shell", + () => Effect.gen(function* () { + const changes = yield* makeChangeSignal; const fileSystem = yield* FileSystem.FileSystem; - const cwd = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-claude-append-replacement-", + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-claude-v2-replace-open-fail-", }); - const appendFile = NodePath.join(cwd, "rules.md"); - yield* fileSystem.writeFileString(appendFile, "File sentinel\n"); + let openCount = 0; const processQueues: Array> = []; - let closes = 0; - const firstTurnTerminal = yield* Deferred.make(); + const events: Array = []; const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, - settings: { - ...DEFAULT_CLAUDE_SETTINGS, - launchArgs: "--append-system-prompt-file rules.md", - }, + settings: DEFAULT_CLAUDE_SETTINGS, environment: {}, - attachmentsDir: yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-claude-append-replacement-attachments-", - }), + attachmentsDir, fileSystem, path: yield* Path.Path, random: yield* SecureRandom.SecureRandom, - idAllocator: yield* IdAllocator.IdAllocatorV2, - continuationRequests: { offer: () => Effect.void }, + idAllocator, + continuationRequests: { + offer: () => Effect.void, + }, queryRunner: { allocateSessionId: Effect.succeed(WAKE_NATIVE_SESSION), - open: () => - Effect.gen(function* () { + open: () => { + openCount += 1; + if (openCount === 2) { + return Effect.fail( + new ClaudeAdapterV2.ClaudeAgentSdkQueryRunnerError({ + method: "open", + cause: "forced replacement open failure", + }), + ); + } + return Effect.gen(function* () { const sdkMessages = yield* Queue.unbounded(); processQueues.push(sdkMessages); return { @@ -19846,42 +19733,40 @@ describe("ClaudeAdapterV2 background wake turns", () => { listPermissionRules: Effect.die("unexpected permission rules read"), stopTask: () => Effect.void, interrupt: Effect.void, - close: Effect.sync(() => { - closes += 1; - }).pipe(Effect.andThen(Queue.shutdown(sdkMessages))), + close: Queue.shutdown(sdkMessages), }; - }), + }); + }, forkSession: () => Effect.die("unused forkSession"), subagentLaunchToolUseId: () => Effect.succeed(null), subagentModel: () => Effect.succeed(null), assertComplete: Effect.void, }, }); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd, - }); - const threadId = ThreadId.make("thread-claude-append-replacement"); + const threadId = ThreadId.make("thread-claude-replace-open-fail"); const runtime = yield* adapter.openSession({ threadId, - providerSessionId: ProviderSessionId.make("provider-session-claude-append-replacement"), + providerSessionId: ProviderSessionId.make("provider-session-claude-replace-open-fail"), modelSelection: CLAUDE_TEST_MODEL_SELECTION, - runtimePolicy, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, }); const providerThread = yield* runtime.ensureThread({ threadId, modelSelection: CLAUDE_TEST_MODEL_SELECTION, - runtimePolicy, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, }); yield* runtime.events.pipe( Stream.runForEach((event) => - event.type === "turn.terminal" - ? Deferred.succeed(firstTurnTerminal, undefined) - : Effect.void, + Effect.sync(() => { + events.push(event); + }).pipe(Effect.andThen(changes.notify)), ), Effect.forkScoped, ); + if (runtime.hasPendingBackgroundWork === undefined) { + return yield* Effect.die("Claude adapter runtime must expose hasPendingBackgroundWork."); + } + const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; const now = yield* DateTime.now; yield* runtime.startTurn( @@ -19889,44 +19774,35 @@ describe("ClaudeAdapterV2 background wake turns", () => { threadId, providerThread, now, - attemptId: RunAttemptId.make("attempt-claude-append-replacement-a"), - text: "What is the sentinel?", + attemptId: RunAttemptId.make("attempt-claude-replace-open-fail-a"), + text: "Run the build in the background.", attachments: [], - runtimePolicy, - }), - ); - yield* Queue.offer( - processQueues[0]!, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000a01", - result: "File sentinel", }), ); - yield* Deferred.await(firstTurnTerminal); + assert.equal(processQueues.length, 1); + yield* Queue.offer(processQueues[0]!, wakeTaskStarted); + yield* Queue.offer(processQueues[0]!, turnOneResult); + yield* changes.until(() => events.some((event) => event.type === "turn.terminal")); + assert.isTrue(yield* hasPendingBackgroundWork); - yield* fileSystem.remove(appendFile); - // A thinking change cannot apply live, so this turn must replace the process. - const refused = yield* runtime + const alternateModel = CLAUDE_TEST_RESTART_MODEL_SELECTION; + const failedStart = yield* runtime .startTurn( makeClaudeTestTurnInput({ threadId, providerThread: { ...providerThread, status: "active" }, now, - attemptId: RunAttemptId.make("attempt-claude-append-replacement-b"), - text: "Switch model.", + attemptId: RunAttemptId.make("attempt-claude-replace-open-fail-b"), + text: "Replace process but fail open.", attachments: [], providerTurnOrdinal: 2, - modelSelection: CLAUDE_TEST_RESTART_MODEL_SELECTION, - runtimePolicy, + modelSelection: alternateModel, }), ) - .pipe(Effect.flip); - assert.equal( - makeProviderFailure({ cause: refused, class: "provider_error" }).message, - new ClaudeAdapterV2.ClaudeAppendSystemPromptFileError({ path: appendFile }).message, - ); - assert.lengthOf(processQueues, 1); - assert.equal(closes, 0); + .pipe(Effect.exit); + assert.isTrue(Exit.isFailure(failedStart)); + assert.equal(openCount, 1); + assert.isTrue(yield* hasPendingBackgroundWork); }).pipe( Effect.provide( Layer.mergeAll( @@ -19937,270 +19813,125 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ), ), - ), ); - it.effect( - "keeps the process and its roster when a model change meets a running background shell", - () => - Effect.scoped( - Effect.gen(function* () { - const changes = yield* makeChangeSignal; - const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-claude-v2-replace-open-fail-", - }); - let openCount = 0; - const processQueues: Array> = []; - const events: Array = []; - const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ - instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, - settings: DEFAULT_CLAUDE_SETTINGS, - environment: {}, - attachmentsDir, - fileSystem, - path: yield* Path.Path, - random: yield* SecureRandom.SecureRandom, - idAllocator, - continuationRequests: { + // regression: #26082 a killed Claude process names its signal on the failed turn + it.effect("names the signal that killed the Claude process on the failed turn", () => + Effect.gen(function* () { + const changes = yield* makeChangeSignal; + const fileSystem = yield* FileSystem.FileSystem; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-claude-v2-process-signal-", + }); + const sdkMessages = yield* Queue.unbounded< + SDKMessage, + ClaudeAdapterV2.ClaudeAgentSdkQueryRunnerError + >(); + const events: Array = []; + const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ + instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, + settings: DEFAULT_CLAUDE_SETTINGS, + environment: {}, + attachmentsDir, + fileSystem, + path: yield* Path.Path, + random: yield* SecureRandom.SecureRandom, + idAllocator, + queryRunner: { + allocateSessionId: Effect.succeed(WAKE_NATIVE_SESSION), + open: () => + Effect.succeed({ + messages: Stream.fromQueue(sdkMessages), offer: () => Effect.void, - }, - queryRunner: { - allocateSessionId: Effect.succeed(WAKE_NATIVE_SESSION), - open: () => { - openCount += 1; - if (openCount === 2) { - return Effect.fail( - new ClaudeAdapterV2.ClaudeAgentSdkQueryRunnerError({ - method: "open", - cause: "forced replacement open failure", - }), - ); - } - return Effect.gen(function* () { - const sdkMessages = yield* Queue.unbounded(); - processQueues.push(sdkMessages); - return { - messages: Stream.fromQueue(sdkMessages), - offer: () => Effect.void, - setModel: () => Effect.void, - applyFlagSettings: () => Effect.void, - setPermissionMode: () => Effect.void, - backgroundTasks: () => Effect.succeed(false), - mcpServerStatus: Effect.succeed([]), - readMcpResource: () => Effect.die("unexpected MCP resource read"), - callMcpTool: () => Effect.die("unexpected MCP tool call"), - listPermissionRules: Effect.die("unexpected permission rules read"), - stopTask: () => Effect.void, - interrupt: Effect.void, - close: Queue.shutdown(sdkMessages), - }; - }); - }, - forkSession: () => Effect.die("unused forkSession"), - subagentLaunchToolUseId: () => Effect.succeed(null), - subagentModel: () => Effect.succeed(null), - assertComplete: Effect.void, - }, - }); - const threadId = ThreadId.make("thread-claude-replace-open-fail"); - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("provider-session-claude-replace-open-fail"), - modelSelection: CLAUDE_TEST_MODEL_SELECTION, - runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, - }); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection: CLAUDE_TEST_MODEL_SELECTION, - runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, - }); - yield* runtime.events.pipe( - Stream.runForEach((event) => - Effect.sync(() => { - events.push(event); - }).pipe(Effect.andThen(changes.notify)), - ), - Effect.forkScoped, - ); - if (runtime.hasPendingBackgroundWork === undefined) { - return yield* Effect.die( - "Claude adapter runtime must expose hasPendingBackgroundWork.", - ); - } - const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; - const now = yield* DateTime.now; - - yield* runtime.startTurn( - makeClaudeTestTurnInput({ - threadId, - providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-replace-open-fail-a"), - text: "Run the build in the background.", - attachments: [], + setModel: () => Effect.void, + applyFlagSettings: () => Effect.void, + setPermissionMode: () => Effect.void, + backgroundTasks: () => Effect.succeed(false), + mcpServerStatus: Effect.succeed([]), + readMcpResource: () => Effect.die("unexpected MCP resource read"), + callMcpTool: () => Effect.die("unexpected MCP tool call"), + listPermissionRules: Effect.die("unexpected permission rules read"), + stopTask: () => Effect.void, + interrupt: Effect.void, + close: Queue.shutdown(sdkMessages), }), - ); - assert.equal(processQueues.length, 1); - yield* Queue.offer(processQueues[0]!, wakeTaskStarted); - yield* Queue.offer(processQueues[0]!, turnOneResult); - yield* changes.until(() => events.some((event) => event.type === "turn.terminal")); - assert.isTrue(yield* hasPendingBackgroundWork); - - const alternateModel = CLAUDE_TEST_RESTART_MODEL_SELECTION; - const failedStart = yield* runtime - .startTurn( - makeClaudeTestTurnInput({ - threadId, - providerThread: { ...providerThread, status: "active" }, - now, - attemptId: RunAttemptId.make("attempt-claude-replace-open-fail-b"), - text: "Replace process but fail open.", - attachments: [], - providerTurnOrdinal: 2, - modelSelection: alternateModel, - }), - ) - .pipe(Effect.exit); - assert.isTrue(Exit.isFailure(failedStart)); - assert.equal(openCount, 1); - assert.isTrue(yield* hasPendingBackgroundWork); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), - ), + forkSession: () => Effect.die("unused forkSession"), + subagentLaunchToolUseId: () => Effect.succeed(null), + subagentModel: () => Effect.succeed(null), + assertComplete: Effect.void, + }, + }); + const threadId = ThreadId.make("thread-claude-process-signal"); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-claude-process-signal"), + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection: CLAUDE_TEST_MODEL_SELECTION, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + yield* runtime.events.pipe( + Stream.runForEach((event) => + Effect.sync(() => { + events.push(event); + }).pipe(Effect.andThen(changes.notify)), ), - ), - ); - - // regression: #26082 a killed Claude process names its signal on the failed turn - it.effect("names the signal that killed the Claude process on the failed turn", () => - Effect.scoped( - Effect.gen(function* () { - const changes = yield* makeChangeSignal; - const fileSystem = yield* FileSystem.FileSystem; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-claude-v2-process-signal-", - }); - const sdkMessages = yield* Queue.unbounded< - SDKMessage, - ClaudeAdapterV2.ClaudeAgentSdkQueryRunnerError - >(); - const events: Array = []; - const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ - instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, - settings: DEFAULT_CLAUDE_SETTINGS, - environment: {}, - attachmentsDir, - fileSystem, - path: yield* Path.Path, - random: yield* SecureRandom.SecureRandom, - idAllocator, - queryRunner: { - allocateSessionId: Effect.succeed(WAKE_NATIVE_SESSION), - open: () => - Effect.succeed({ - messages: Stream.fromQueue(sdkMessages), - offer: () => Effect.void, - setModel: () => Effect.void, - applyFlagSettings: () => Effect.void, - setPermissionMode: () => Effect.void, - backgroundTasks: () => Effect.succeed(false), - mcpServerStatus: Effect.succeed([]), - readMcpResource: () => Effect.die("unexpected MCP resource read"), - callMcpTool: () => Effect.die("unexpected MCP tool call"), - listPermissionRules: Effect.die("unexpected permission rules read"), - stopTask: () => Effect.void, - interrupt: Effect.void, - close: Queue.shutdown(sdkMessages), - }), - forkSession: () => Effect.die("unused forkSession"), - subagentLaunchToolUseId: () => Effect.succeed(null), - subagentModel: () => Effect.succeed(null), - assertComplete: Effect.void, - }, - }); - const threadId = ThreadId.make("thread-claude-process-signal"); - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("provider-session-claude-process-signal"), - modelSelection: CLAUDE_TEST_MODEL_SELECTION, - runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, - }); - const providerThread = yield* runtime.ensureThread({ + Effect.forkScoped, + ); + yield* runtime.startTurn( + makeClaudeTestTurnInput({ threadId, - modelSelection: CLAUDE_TEST_MODEL_SELECTION, - runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, - }); - yield* runtime.events.pipe( - Stream.runForEach((event) => - Effect.sync(() => { - events.push(event); - }).pipe(Effect.andThen(changes.notify)), - ), - Effect.forkScoped, - ); - yield* runtime.startTurn( - makeClaudeTestTurnInput({ - threadId, - providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("attempt-claude-process-signal"), - text: "Write a long reply.", - attachments: [], - }), - ); - yield* Queue.offer( - sdkMessages, - makeAssistantTextFrame({ - uuid: "00000000-0000-4000-8000-000000026082", - text: "Here is the first part", - }), - ); - // Mirrors ProcessTransport.getProcessExitError in @anthropic-ai/claude-agent-sdk. - const processExit = Object.assign( - new Error( - "Claude Code process terminated by signal SIGABRT. stderr: fork failed: Resource temporarily unavailable", - ), - { - telemetryMessage: "Claude Code process terminated by signal SIGABRT", - errorClass: "process_killed_by_signal", - signal: "SIGABRT", - }, - ); - yield* Queue.fail( - sdkMessages, - new ClaudeAdapterV2.ClaudeAgentSdkQueryRunnerError({ - method: "messages", - cause: processExit, - }), - ); + providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-claude-process-signal"), + text: "Write a long reply.", + attachments: [], + }), + ); + yield* Queue.offer( + sdkMessages, + makeAssistantTextFrame({ + uuid: "00000000-0000-4000-8000-000000026082", + text: "Here is the first part", + }), + ); + // Mirrors ProcessTransport.getProcessExitError in @anthropic-ai/claude-agent-sdk. + const processExit = Object.assign( + new Error( + "Claude Code process terminated by signal SIGABRT. stderr: fork failed: Resource temporarily unavailable", + ), + { + telemetryMessage: "Claude Code process terminated by signal SIGABRT", + errorClass: "process_killed_by_signal", + signal: "SIGABRT", + }, + ); + yield* Queue.fail( + sdkMessages, + new ClaudeAdapterV2.ClaudeAgentSdkQueryRunnerError({ + method: "messages", + cause: processExit, + }), + ); - yield* changes.until(() => events.some((event) => event.type === "turn.terminal")); - const terminal = events.find((event) => event.type === "turn.terminal"); - assert.equal(terminal?.status, "failed"); - if (terminal?.status !== "failed") return; - assert.include( - terminal.failure.message, - "Claude Code process terminated by signal SIGABRT", - ); - assert.notInclude(terminal.failure.message, "stderr"); - assert.equal(terminal.failure.code, "process_killed_by_signal"); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + yield* changes.until(() => events.some((event) => event.type === "turn.terminal")); + const terminal = events.find((event) => event.type === "turn.terminal"); + assert.equal(terminal?.status, "failed"); + if (terminal?.status !== "failed") return; + assert.include(terminal.failure.message, "Claude Code process terminated by signal SIGABRT"); + assert.notInclude(terminal.failure.message, "stderr"); + assert.equal(terminal.failure.code, "process_killed_by_signal"); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), @@ -20400,552 +20131,79 @@ describe("ClaudeAdapterV2 background wake turns", () => { it.effect.each(["Agent", "Workflow"] as const)( "keeps a workflow running after its %s launch reply without an async flag", (toolName) => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWorkflowHarness; - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make(`attempt-workflow-launch-${toolName}`), - text: "Run the workflow.", - attachments: [], - }), - ); - yield* harness.offerAndWait( - claudeSdkFrame({ - ...workflowToolUse, - message: { - model: "claude-sonnet-4-6", - content: [ - { type: "tool_use", id: WORKFLOW_TOOL_USE_ID, name: toolName, input: {} }, - ], - }, - }), - ); - yield* harness.offerAndWait(workflowTaskStarted); - yield* harness.offerAndWait(workflowLaunchAck(harness.transcriptDir)); - yield* harness.offerAndWait( - workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001200", state: "start" }), - ); - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ uuid: "00000000-0000-4000-8000-000000001201", result: "Launched." }), - ); - yield* Queue.take(harness.terminalReceipts); - assert.equal( - workflowCoordinatorEvents(harness.events).at(-1)?.subagent.status, - "running", - ); - assert.equal(workflowMemberEvents(harness.events, 1).at(-1)?.subagent.status, "running"); - assert.isTrue(yield* harness.hasPendingBackgroundWork); - const toolItems = harness.events.flatMap((event) => - event.type === "turn_item.updated" && - event.turnItem.type === "dynamic_tool" && - event.turnItem.nativeItemRef?.nativeId === WORKFLOW_TOOL_USE_ID - ? [event.turnItem.status] - : [], - ); - assert.deepEqual(toolItems, toolName === "Workflow" ? ["running", "completed"] : []); - yield* harness.offerAndWait( - workflowNotification({ - uuid: "00000000-0000-4000-8000-000000001202", - status: "completed", - }), - ); - yield* Queue.take(harness.continuationReceipts); - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make(`attempt-workflow-complete-${toolName}`), - text: "Workflow finished.", - attachments: [], - providerTurnOrdinal: 2, - messageCreatedBy: "agent", - messageCreationSource: "provider", - }), - ); - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ uuid: "00000000-0000-4000-8000-000000001205", result: "Finished." }), - ); - yield* Queue.take(harness.terminalReceipts); - assert.equal( - workflowCoordinatorEvents(harness.events).at(-1)?.subagent.status, - "completed", - ); - assert.equal( - workflowMemberEvents(harness.events, 1).at(-1)?.subagent.status, - "completed", - ); - assert.isFalse(yield* harness.hasPendingBackgroundWork); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), - ), - ), - ), - ); - - it.effect("marks a workflow coordinator's script prompt and leaves a subagent brief plain", () => - Effect.scoped( Effect.gen(function* () { const harness = yield* makeWorkflowHarness; - const script = - 'export const meta = { name: "probe-wf", description: "Run the alpha workflow" };'; yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ threadId: harness.threadId, providerThread: harness.providerThread, now: yield* DateTime.now, - attemptId: RunAttemptId.make("attempt-workflow-script-prompt"), - text: "Run the workflow and an auditor.", + attemptId: RunAttemptId.make(`attempt-workflow-launch-${toolName}`), + text: "Run the workflow.", attachments: [], }), ); - yield* harness.offerAndWait(workflowToolUse); - yield* harness.offerAndWait(claudeSdkFrame({ ...workflowTaskStarted, prompt: script })); - yield* harness.offerAndWait(workflowLaunchAck(harness.transcriptDir)); - yield* harness.offerAndWait( - workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001289", state: "start" }), - ); yield* harness.offerAndWait( claudeSdkFrame({ - type: "system", - subtype: "task_started", - task_id: "task-plain-auditor", - description: "Audit recent commits", - task_type: "local_agent", - prompt: "Audit the last five commits.", - uuid: "00000000-0000-4000-8000-000000001290", - session_id: WAKE_NATIVE_SESSION, - }), - ); - const subagentPrompt = (nativeTaskId: string) => { - const subagent = harness.events.flatMap((event) => - event.type === "subagent.updated" && - event.subagent.nativeTaskRef?.nativeId === nativeTaskId - ? [event.subagent] - : [], - )[0]; - const message = threadMessages(harness.events, subagent?.childThreadId).find( - (event) => event.message.role === "user", - )?.message; - const turnItem = harness.events.findLast( - (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "user_message" && - event.turnItem.messageId === message?.id, - ); - return { - text: message?.text, - messageMark: message?.workflowScript, - itemMark: - turnItem?.type === "turn_item.updated" && turnItem.turnItem.type === "user_message" - ? turnItem.turnItem.workflowScript - : undefined, - }; - }; - // The user message item lands after the subagent node, so wait on the item itself. - yield* harness.until( - () => - workflowCoordinatorEvents(harness.events).length > 0 && - subagentPrompt(WORKFLOW_TASK_ID).itemMark !== undefined && - subagentPrompt("task-plain-auditor").itemMark !== undefined, - ); - assert.deepEqual(subagentPrompt(WORKFLOW_TASK_ID), { - text: script, - messageMark: true, - itemMark: true, - }); - assert.deepEqual(subagentPrompt("task-plain-auditor"), { - text: "Audit the last five commits.", - messageMark: false, - itemMark: false, - }); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), - ), - ), - ), - ); - - it.effect("stops only a live workflow task and waits for its stopped notification", () => - Effect.scoped( - Effect.gen(function* () { - const stoppedTasks: Array = []; - let interrupted = false; - let closed = false; - const harness = yield* makeWorkflowHarnessWithOptions({ - stopTask: (taskId) => - Effect.sync(() => { - stoppedTasks.push(taskId); - }), - interrupt: Effect.sync(() => { - interrupted = true; - }), - close: () => - Effect.sync(() => { - closed = true; - }), - }); - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("attempt-workflow-stop"), - text: "Run the workflow.", - attachments: [], + ...workflowToolUse, + message: { + model: "claude-sonnet-4-6", + content: [{ type: "tool_use", id: WORKFLOW_TOOL_USE_ID, name: toolName, input: {} }], + }, }), ); - yield* harness.offerAndWait(workflowToolUse); yield* harness.offerAndWait(workflowTaskStarted); yield* harness.offerAndWait(workflowLaunchAck(harness.transcriptDir)); yield* harness.offerAndWait( - workflowSnapshot({ - uuid: "00000000-0000-4000-8000-000000001100", - state: "start", - }), + workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001200", state: "start" }), ); - let member = yield* Queue.take(harness.subagentReceipts); - while (!member.subagent.nativeTaskRef?.nativeId?.endsWith(":agent:2")) { - member = yield* Queue.take(harness.subagentReceipts); - } - const stopTask = harness.runtime.stopTask!; - for (const taskId of ["missing-task", `${WORKFLOW_TASK_ID}:agent:1`]) { - const exit = yield* stopTask({ providerThread: harness.providerThread, taskId }).pipe( - Effect.exit, - ); - assert.isTrue(Exit.isFailure(exit)); - } - const wrongThread = { - ...harness.providerThread, - nativeThreadRef: { - driver: harness.providerThread.driver, - nativeId: "wrong-session", - strength: "strong" as const, - }, - }; - assert.isTrue( - Exit.isFailure( - yield* stopTask({ - providerThread: wrongThread, - taskId: WORKFLOW_TASK_ID, - }).pipe(Effect.exit), - ), + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000001201", result: "Launched." }), ); - assert.deepEqual(stoppedTasks, []); - yield* stopTask({ providerThread: harness.providerThread, taskId: WORKFLOW_TASK_ID }); - assert.deepEqual(stoppedTasks, [WORKFLOW_TASK_ID]); - assert.isFalse(interrupted); - assert.isFalse(closed); + yield* Queue.take(harness.terminalReceipts); assert.equal(workflowCoordinatorEvents(harness.events).at(-1)?.subagent.status, "running"); + assert.equal(workflowMemberEvents(harness.events, 1).at(-1)?.subagent.status, "running"); + assert.isTrue(yield* harness.hasPendingBackgroundWork); + const toolItems = harness.events.flatMap((event) => + event.type === "turn_item.updated" && + event.turnItem.type === "dynamic_tool" && + event.turnItem.nativeItemRef?.nativeId === WORKFLOW_TOOL_USE_ID + ? [event.turnItem.status] + : [], + ); + assert.deepEqual(toolItems, toolName === "Workflow" ? ["running", "completed"] : []); yield* harness.offerAndWait( workflowNotification({ - uuid: "00000000-0000-4000-8000-000000001101", - status: "stopped", + uuid: "00000000-0000-4000-8000-000000001202", + status: "completed", }), ); - member = yield* Queue.take(harness.subagentReceipts); - while (!member.subagent.nativeTaskRef?.nativeId?.endsWith(":agent:2")) { - member = yield* Queue.take(harness.subagentReceipts); - } - assert.equal( - workflowCoordinatorEvents(harness.events).at(-1)?.subagent.status, - "cancelled", - ); - for (const index of [1, 2]) { - assert.equal( - workflowMemberEvents(harness.events, index).at(-1)?.subagent.status, - "cancelled", - ); - } - assert.isTrue( - Exit.isFailure( - yield* stopTask({ - providerThread: harness.providerThread, - taskId: WORKFLOW_TASK_ID, - }).pipe(Effect.exit), - ), - ); - assert.deepEqual(stoppedTasks, [WORKFLOW_TASK_ID]); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), - ), - ), - ), - ); - - it.effect("projects each workflow member as its own subagent thread", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWorkflowHarness; - const now = yield* DateTime.now; - const coordinatorTurnItems = () => - harness.events.filter( - (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "subagent" && - event.turnItem.nativeItemRef?.nativeId === WORKFLOW_TASK_ID, - ); - + yield* Queue.take(harness.continuationReceipts); yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ threadId: harness.threadId, providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-workflow-members"), - text: "Run the alpha workflow.", + now: yield* DateTime.now, + attemptId: RunAttemptId.make(`attempt-workflow-complete-${toolName}`), + text: "Workflow finished.", attachments: [], + providerTurnOrdinal: 2, + messageCreatedBy: "agent", + messageCreationSource: "provider", }), ); - yield* launchWorkflow({ - harness, - snapshotUuid: "00000000-0000-4000-8000-000000001004", - state: "queued", - }); - - const coordinator = workflowCoordinatorEvents(harness.events).at(-1)?.subagent; - const first = workflowMemberEvents(harness.events, 1).at(-1)?.subagent; - const second = workflowMemberEvents(harness.events, 2).at(-1)?.subagent; - assert.equal(first?.status, "pending"); - assert.isNull(first?.startedAt); - assert.equal(second?.status, "pending"); - assert.isNull(second?.startedAt); - const coordinatorThread = harness.events.find( - (event) => - event.type === "app_thread.created" && - event.appThread.id === coordinator?.childThreadId, - ); - assert.equal( - coordinatorThread?.type === "app_thread.created" - ? coordinatorThread.appThread.title - : null, - "Workflow · probe-wf", - ); - assert.equal(first?.title, "alpha:one"); - assert.equal(first?.prompt, "Reply with exactly: A1"); - assert.equal(first?.model, "claude-opus-5[1m]"); - assert.equal(first?.threadId, coordinator?.childThreadId); - assert.equal(second?.threadId, coordinator?.childThreadId); - assert.notEqual(first?.childThreadId, second?.childThreadId); - - assert.equal( - harness.events.find( - ( - event, - ): event is Extract< - ProviderAdapter.ProviderAdapterV2Event, - { type: "app_thread.created" } - > => event.type === "app_thread.created" && event.appThread.id === first?.childThreadId, - )?.appThread.title, - "alpha:one", - ); - const memberNodes = harness.events.filter( - ( - event, - ): event is Extract => - event.type === "node.updated" && - event.node.nativeItemRef?.nativeId === first?.nativeTaskRef?.nativeId, - ); - assert.deepEqual( - memberNodes.map((event) => event.node.kind), - ["subagent", "root_turn"], - ); - assert.deepEqual( - memberNodes.map((event) => [event.node.status, event.node.startedAt]), - [ - ["pending", null], - ["pending", null], - ], - ); - assert.deepEqual( - threadMessages(harness.events, first?.childThreadId).map((event) => event.message.text), - ["Reply with exactly: A1"], - ); - assert.deepEqual( - threadMessages(harness.events, second?.childThreadId).map((event) => event.message.text), - ["Reply with exactly: A2"], - ); - - yield* TestClock.adjust("60 seconds"); - const memberStartedAt = DateTime.add(now, { seconds: 30 }); - yield* harness.offerAndWait( - workflowSnapshot({ - uuid: "00000000-0000-4000-8000-000000001007", - state: "start", - secondState: "queued", - startedAt: DateTime.toEpochMillis(memberStartedAt), - }), - ); - let started = yield* Queue.take(harness.subagentReceipts); - while (started.subagent.id !== first?.id || started.subagent.status !== "running") { - started = yield* Queue.take(harness.subagentReceipts); - } - assert.deepEqual(started.subagent.startedAt, memberStartedAt); - const startedRoot = harness.events.findLast( - (event) => event.type === "node.updated" && event.node.threadId === first?.childThreadId, - ); - assert.equal( - startedRoot?.type === "node.updated" ? startedRoot.node.status : null, - "running", - ); - assert.deepEqual( - startedRoot?.type === "node.updated" ? startedRoot.node.startedAt : null, - memberStartedAt, - ); - - const turnItemsBeforeRepeat = coordinatorTurnItems().length; - yield* Queue.offer( - harness.sdkMessages, - workflowSnapshot({ - uuid: "00000000-0000-4000-8000-000000001005", - state: "start", - secondState: "queued", - startedAt: DateTime.toEpochMillis(memberStartedAt), - }), - ); - yield* harness.until(() => coordinatorTurnItems().length > turnItemsBeforeRepeat); - assert.lengthOf(workflowMemberEvents(harness.events, 1), 2); - assert.lengthOf(workflowMemberEvents(harness.events, 2), 1); - - const firstThreadId = first?.childThreadId; - const firstThreadEvents = () => - harness.events.filter( - ( - event, - ): event is Extract< - ProviderAdapter.ProviderAdapterV2Event, - { type: "app_thread.created" } - > => event.type === "app_thread.created" && event.appThread.id === firstThreadId, - ); - const turnItemsBeforeMetadata = coordinatorTurnItems().length; - yield* Queue.offer( - harness.sdkMessages, - workflowSnapshot({ - uuid: "00000000-0000-4000-8000-000000001017", - state: "start", - secondState: "queued", - startedAt: DateTime.toEpochMillis(memberStartedAt), - label: "alpha:review", - prompt: "Review the change", - model: "claude-sonnet-4-6", - }), - ); - // The member's node and app thread pick up the metadata after the coordinator item. - yield* harness.until( - () => - coordinatorTurnItems().length > turnItemsBeforeMetadata && - workflowMemberEvents(harness.events, 1).at(-1)?.subagent.title === "alpha:review" && - firstThreadEvents().at(-1)?.appThread.title === "alpha:review", - ); - assert.equal( - workflowMemberEvents(harness.events, 1).at(-1)?.subagent.title, - "alpha:review", - ); - assert.equal( - workflowMemberEvents(harness.events, 1).at(-1)?.subagent.prompt, - "Review the change", - ); - assert.equal( - workflowMemberEvents(harness.events, 1).at(-1)?.subagent.model, - "claude-sonnet-4-6", - ); - assert.equal(firstThreadEvents().at(-1)?.appThread.title, "alpha:review"); - assert.equal( - firstThreadEvents().at(-1)?.appThread.modelSelection.model, - "claude-sonnet-4-6", - ); - assert.deepEqual( - firstThreadEvents().at(-1)?.appThread.createdAt, - firstThreadEvents()[0]?.appThread.createdAt, - ); - assert.equal( - threadMessages(harness.events, firstThreadId).at(-1)?.message.text, - "Review the change", - ); - - yield* TestClock.adjust("10 seconds"); - yield* harness.offerAndWait( - workflowSnapshot({ - uuid: "00000000-0000-4000-8000-000000001018", - state: "done", - secondState: "queued", - omitResult: true, - }), - ); - let finished = yield* Queue.take(harness.subagentReceipts); - while (finished.subagent.id !== first?.id || finished.subagent.status !== "completed") { - finished = yield* Queue.take(harness.subagentReceipts); - } - const completedAt = finished.subagent.completedAt; - assert.deepEqual(completedAt, DateTime.add(now, { seconds: 70 })); - yield* TestClock.adjust("60 seconds"); - yield* harness.offerAndWait( - workflowSnapshot({ - uuid: "00000000-0000-4000-8000-000000001019", - state: "done", - secondState: "queued", - }), - ); - let excerpt = yield* Queue.take(harness.subagentReceipts); - while (excerpt.subagent.id !== first?.id || excerpt.subagent.result !== "A1 excerpt") { - excerpt = yield* Queue.take(harness.subagentReceipts); - } - assert.deepEqual(excerpt.subagent.completedAt, completedAt); - const finishedRoot = harness.events.findLast( - (event) => event.type === "node.updated" && event.node.threadId === firstThreadId, - ); - assert.deepEqual( - finishedRoot?.type === "node.updated" ? finishedRoot.node.completedAt : undefined, - completedAt, - ); - - yield* harness.offerAndWait( - workflowNotification({ uuid: "00000000-0000-4000-8000-000000001008", status: "stopped" }), - ); yield* Queue.offer( harness.sdkMessages, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000001006", - result: "Launched the workflow.", - }), + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000001205", result: "Finished." }), ); yield* Queue.take(harness.terminalReceipts); - const stopped = workflowMemberEvents(harness.events, 2).at(-1)?.subagent; - assert.equal(stopped?.status, "cancelled"); - assert.isNull(stopped?.startedAt); - const stoppedRoot = harness.events.findLast( - (event) => event.type === "node.updated" && event.node.threadId === second?.childThreadId, - ); assert.equal( - stoppedRoot?.type === "node.updated" ? stoppedRoot.node.status : null, - "cancelled", - ); - assert.isNull( - stoppedRoot?.type === "node.updated" ? stoppedRoot.node.startedAt : undefined, + workflowCoordinatorEvents(harness.events).at(-1)?.subagent.status, + "completed", ); + assert.equal(workflowMemberEvents(harness.events, 1).at(-1)?.subagent.status, "completed"); + assert.isFalse(yield* harness.hasPendingBackgroundWork); }).pipe( Effect.provide( Layer.mergeAll( @@ -20956,585 +20214,584 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ), ), + ); + + it.effect("marks a workflow coordinator's script prompt and leaves a subagent brief plain", () => + Effect.gen(function* () { + const harness = yield* makeWorkflowHarness; + const script = + 'export const meta = { name: "probe-wf", description: "Run the alpha workflow" };'; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-workflow-script-prompt"), + text: "Run the workflow and an auditor.", + attachments: [], + }), + ); + yield* harness.offerAndWait(workflowToolUse); + yield* harness.offerAndWait(claudeSdkFrame({ ...workflowTaskStarted, prompt: script })); + yield* harness.offerAndWait(workflowLaunchAck(harness.transcriptDir)); + yield* harness.offerAndWait( + workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001289", state: "start" }), + ); + yield* harness.offerAndWait( + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: "task-plain-auditor", + description: "Audit recent commits", + task_type: "local_agent", + prompt: "Audit the last five commits.", + uuid: "00000000-0000-4000-8000-000000001290", + session_id: WAKE_NATIVE_SESSION, + }), + ); + const subagentPrompt = (nativeTaskId: string) => { + const subagent = harness.events.flatMap((event) => + event.type === "subagent.updated" && + event.subagent.nativeTaskRef?.nativeId === nativeTaskId + ? [event.subagent] + : [], + )[0]; + const message = threadMessages(harness.events, subagent?.childThreadId).find( + (event) => event.message.role === "user", + )?.message; + const turnItem = harness.events.findLast( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "user_message" && + event.turnItem.messageId === message?.id, + ); + return { + text: message?.text, + messageMark: message?.workflowScript, + itemMark: + turnItem?.type === "turn_item.updated" && turnItem.turnItem.type === "user_message" + ? turnItem.turnItem.workflowScript + : undefined, + }; + }; + // The user message item lands after the subagent node, so wait on the item itself. + yield* harness.until( + () => + workflowCoordinatorEvents(harness.events).length > 0 && + subagentPrompt(WORKFLOW_TASK_ID).itemMark !== undefined && + subagentPrompt("task-plain-auditor").itemMark !== undefined, + ); + assert.deepEqual(subagentPrompt(WORKFLOW_TASK_ID), { + text: script, + messageMark: true, + itemMark: true, + }); + assert.deepEqual(subagentPrompt("task-plain-auditor"), { + text: "Audit the last five commits.", + messageMark: false, + itemMark: false, + }); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), ), ); - it.effect("projects workflow member threads after resuming a restarted session", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWorkflowHarnessWithOptions({ - subagentLaunchToolUseId: WORKFLOW_TOOL_USE_ID, - }); - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("attempt-claude-workflow-resume"), - text: "Continue the workflow.", - attachments: [], - }), - ); - yield* harness.offerAndWait( - claudeSdkFrame({ - ...workflowToolUse, - message: { - model: "claude-sonnet-4-6", - content: [ - { type: "tool_use", id: "toolu-resume-workflow", name: "SendMessage", input: {} }, - ], - }, + it.effect("stops only a live workflow task and waits for its stopped notification", () => + Effect.gen(function* () { + const stoppedTasks: Array = []; + let interrupted = false; + let closed = false; + const harness = yield* makeWorkflowHarnessWithOptions({ + stopTask: (taskId) => + Effect.sync(() => { + stoppedTasks.push(taskId); }), - ); - yield* harness.offerAndWait( - claudeSdkFrame({ - ...workflowTaskStarted, - tool_use_id: "toolu-resume-workflow", - prompt: "Continue the workflow.", + interrupt: Effect.sync(() => { + interrupted = true; + }), + close: () => + Effect.sync(() => { + closed = true; }), + }); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-workflow-stop"), + text: "Run the workflow.", + attachments: [], + }), + ); + yield* harness.offerAndWait(workflowToolUse); + yield* harness.offerAndWait(workflowTaskStarted); + yield* harness.offerAndWait(workflowLaunchAck(harness.transcriptDir)); + yield* harness.offerAndWait( + workflowSnapshot({ + uuid: "00000000-0000-4000-8000-000000001100", + state: "start", + }), + ); + let member = yield* Queue.take(harness.subagentReceipts); + while (!member.subagent.nativeTaskRef?.nativeId?.endsWith(":agent:2")) { + member = yield* Queue.take(harness.subagentReceipts); + } + const stopTask = harness.runtime.stopTask!; + for (const taskId of ["missing-task", `${WORKFLOW_TASK_ID}:agent:1`]) { + const exit = yield* stopTask({ providerThread: harness.providerThread, taskId }).pipe( + Effect.exit, ); - yield* harness.offerAndWait(workflowLaunchAck(harness.transcriptDir)); - yield* harness.offerAndWait( - workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001040", state: "start" }), - ); - yield* harness.offerAndWait( - makeResultFrame({ uuid: "00000000-0000-4000-8000-000000001041", result: "Resumed." }), - ); - yield* Queue.take(harness.terminalReceipts); - const member = workflowMemberEvents(harness.events, 1).at(-1)?.subagent; - const coordinator = workflowCoordinatorEvents(harness.events).at(-1)?.subagent; - assert.isDefined(member); - assert.equal(member?.status, "running"); - assert.isTrue( - harness.events.some( - (event) => - event.type === "app_thread.created" && event.appThread.id === member?.childThreadId, - ), - ); - assert.deepEqual( - threadMessages(harness.events, member?.childThreadId).map((event) => event.message.text), - ["Reply with exactly: A1", "A1 from the transcript"], - ); - assert.isDefined(coordinator?.childThreadId); - const recoveredThreads = harness.events.filter( - (event) => - event.type === "app_thread.created" && - event.appThread.id === coordinator?.childThreadId, - ); - assert.lengthOf(recoveredThreads, 1); - const recovered = recoveredThreads[0]; - assert.equal(recovered?.type, "app_thread.created"); - if (recovered?.type !== "app_thread.created") throw new Error("Expected recovered thread"); - assert.isTrue(recovered.createOnly); - assert.deepEqual(recovered.appThread.lineage, { - parentThreadId: harness.threadId, - relationshipToParent: "subagent", - rootThreadId: harness.threadId, - }); - assert.equal(recovered.appThread.createdBy, "agent"); - assert.equal(recovered.appThread.creationSource, "provider"); - assert.isBelow( - harness.events.indexOf(recovered), - harness.events.findIndex( - (event) => - event.type === "subagent.updated" && - event.subagent.childThreadId === coordinator?.childThreadId, - ), + assert.isTrue(Exit.isFailure(exit)); + } + const wrongThread = { + ...harness.providerThread, + nativeThreadRef: { + driver: harness.providerThread.driver, + nativeId: "wrong-session", + strength: "strong" as const, + }, + }; + assert.isTrue( + Exit.isFailure( + yield* stopTask({ + providerThread: wrongThread, + taskId: WORKFLOW_TASK_ID, + }).pipe(Effect.exit), + ), + ); + assert.deepEqual(stoppedTasks, []); + yield* stopTask({ providerThread: harness.providerThread, taskId: WORKFLOW_TASK_ID }); + assert.deepEqual(stoppedTasks, [WORKFLOW_TASK_ID]); + assert.isFalse(interrupted); + assert.isFalse(closed); + assert.equal(workflowCoordinatorEvents(harness.events).at(-1)?.subagent.status, "running"); + yield* harness.offerAndWait( + workflowNotification({ + uuid: "00000000-0000-4000-8000-000000001101", + status: "stopped", + }), + ); + member = yield* Queue.take(harness.subagentReceipts); + while (!member.subagent.nativeTaskRef?.nativeId?.endsWith(":agent:2")) { + member = yield* Queue.take(harness.subagentReceipts); + } + assert.equal(workflowCoordinatorEvents(harness.events).at(-1)?.subagent.status, "cancelled"); + for (const index of [1, 2]) { + assert.equal( + workflowMemberEvents(harness.events, index).at(-1)?.subagent.status, + "cancelled", ); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + } + assert.isTrue( + Exit.isFailure( + yield* stopTask({ + providerThread: harness.providerThread, + taskId: WORKFLOW_TASK_ID, + }).pipe(Effect.exit), + ), + ); + assert.deepEqual(stoppedTasks, [WORKFLOW_TASK_ID]); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), ); - it.effect.each([ - "root result", - "resume after root result", - "completion before buffered start", - ] as const)("orders %s arriving during workflow transcript replay", (arrival) => - Effect.scoped( - Effect.gen(function* () { - const digestStarted = yield* Deferred.make(); - const releaseDigest = yield* Deferred.make(); - const liveFrameOffered = yield* Deferred.make(); - let pauseDigest = false; - yield* Effect.gen(function* () { - const harness = yield* makeWorkflowHarnessWithOptions({ close: Queue.shutdown }); - const taskId = "task-resumed-during-workflow-replay"; - const taskStart = (toolUseId: string, prompt: string, uuid: string) => - claudeSdkFrame({ - ...workflowTaskStarted, - task_id: taskId, - task_type: "local_agent", - is_backgrounded: true, - subagent_type: "general-purpose", - spawn_depth: 1, - workflow_name: undefined, - tool_use_id: toolUseId, - prompt, - uuid, - }); - const taskFinished = (toolUseId: string, summary: string, uuid: string) => - claudeSdkFrame({ - ...workflowNotification({ uuid, status: "completed" }), - task_id: taskId, - tool_use_id: toolUseId, - summary, - }); - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("attempt-workflow-replay-launch"), - text: "Run the workflow and a background subagent.", - attachments: [], - }), - ); - yield* harness.offerAndWait(workflowToolUse); - yield* harness.offerAndWait(workflowTaskStarted); - yield* harness.offerAndWait(workflowLaunchAck(harness.transcriptDir)); - yield* harness.offerAndWait( - workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000003001", state: "start" }), - ); - if (arrival !== "completion before buffered start") { - yield* harness.offerAndWait( - taskStart("toolu-initial", "Initial work.", "00000000-0000-4000-8000-000000003002"), - ); - } - yield* harness.offerAndWait( - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000003003", - result: "Launched both tasks.", - }), - ); - yield* Queue.take(harness.terminalReceipts); - yield* harness.offerAndWait( - workflowNotification({ - uuid: "00000000-0000-4000-8000-000000003004", - status: "completed", - }), - ); - yield* Queue.take(harness.continuationReceipts); - if (arrival === "completion before buffered start") { - yield* harness.offerAndWait( - taskStart("toolu-initial", "Initial work.", "00000000-0000-4000-8000-000000003005"), - ); - } else { - yield* harness.offerAndWait( - taskFinished( - "toolu-initial", - "Initial result.", - "00000000-0000-4000-8000-000000003005", - ), - ); - } - if (arrival === "root result") { - yield* harness.offerAndWait( - claudeSdkFrame({ - type: "system", - subtype: "init", - session_id: WAKE_NATIVE_SESSION, - uuid: "00000000-0000-4000-8000-000000003006", - }), - ); - } - pauseDigest = true; - const replay = yield* harness.runtime - .startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("attempt-workflow-replay-continuation"), - providerTurnOrdinal: 2, - text: "Continue.", - attachments: [], - messageCreatedBy: "agent", - messageCreationSource: "provider", - }), - ) - .pipe(Effect.forkScoped); - yield* Deferred.await(digestStarted); - const liveFrames = yield* Effect.gen(function* () { - if (arrival === "completion before buffered start") { - yield* harness.offerAndWait( - taskFinished( - "toolu-initial", - "Initial result.", - "00000000-0000-4000-8000-000000003007", - ), - liveFrameOffered, - ); - return; - } - yield* harness.offerAndWait( - claudeSdkFrame({ - ...makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000003008", - result: "Native wake turn finished.", - }), - origin: { kind: "task-notification" }, - num_turns: 1, - }), - liveFrameOffered, - ); - if (arrival === "resume after root result") { - yield* harness.offerAndWait( - claudeSdkFrame({ - type: "system", - subtype: "init", - session_id: WAKE_NATIVE_SESSION, - uuid: "00000000-0000-4000-8000-000000003009", - }), - ); - yield* harness.offerAndWait( - taskStart( - "toolu-latest", - "Latest live resume.", - "00000000-0000-4000-8000-000000003010", - ), - ); - } - }).pipe(Effect.forkScoped); - yield* Deferred.await(liveFrameOffered); - yield* Deferred.succeed(releaseDigest, undefined); - yield* Fiber.join(replay); - yield* Fiber.join(liveFrames); - if (arrival === "resume after root result") { - yield* Queue.take(harness.terminalReceipts); - yield* Queue.take(harness.continuationReceipts); - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("attempt-workflow-replay-resume-continuation"), - providerTurnOrdinal: 3, - text: "Continue.", - attachments: [], - messageCreatedBy: "agent", - messageCreationSource: "provider", - }), - ); - } - if (arrival !== "root result") { - yield* harness.offerAndWait( - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000003011", - result: "Workflow answer delivered.", - }), - ); - } - yield* Queue.take(harness.terminalReceipts); - const latest = harness.events - .flatMap((event) => - event.type === "subagent.updated" && event.subagent.nativeTaskRef?.nativeId === taskId - ? [event.subagent] - : [], - ) - .at(-1); - if (arrival === "resume after root result") { - assert.equal(latest?.status, "running"); - assert.equal(latest?.prompt, "Latest live resume."); - assert.isNull(latest?.result); - assert.isTrue(yield* harness.hasPendingBackgroundWork); - } else { - assert.equal(latest?.status, "completed"); - assert.equal(latest?.result, "Initial result."); - assert.isFalse(yield* harness.hasPendingBackgroundWork); - assert.lengthOf(harness.continuationRequests, 1); - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("attempt-workflow-replay-next-user"), - providerTurnOrdinal: 3, - text: "Next task.", - attachments: [], - }), - ); - } - }).pipe( - Effect.provideService(WorkflowAgentTranscriptReadObserver, () => - Effect.gen(function* () { - if (pauseDigest) { - pauseDigest = false; - yield* Deferred.succeed(digestStarted, undefined); - yield* Deferred.await(releaseDigest); - } - }), - ), + it.effect("projects each workflow member as its own subagent thread", () => + Effect.gen(function* () { + const harness = yield* makeWorkflowHarness; + const now = yield* DateTime.now; + const coordinatorTurnItems = () => + harness.events.filter( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "subagent" && + event.turnItem.nativeItemRef?.nativeId === WORKFLOW_TASK_ID, ); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), - ), - ), - ), - ); - it.effect("preserves buffered workflow outcomes when interrupted during replay", () => - Effect.scoped( - Effect.gen(function* () { - const digestStarted = yield* Deferred.make(); - const releaseDigest = yield* Deferred.make(); - let pauseDigest = false; - yield* Effect.gen(function* () { - const harness = yield* makeWorkflowHarnessWithOptions({ close: Queue.shutdown }); - const secondTaskId = "task-workflow-buffered-completion"; - const secondToolUseId = "toolu-workflow-buffered-completion"; - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("attempt-workflow-buffered-launch"), - text: "Run both workflows.", - attachments: [], - }), - ); - yield* launchWorkflow({ - harness, - snapshotUuid: "00000000-0000-4000-8000-000000003100", - }); - yield* harness.offerAndWait( - claudeSdkFrame({ - ...workflowTaskStarted, - task_id: secondTaskId, - tool_use_id: secondToolUseId, - uuid: "00000000-0000-4000-8000-000000003101", - }), - ); - yield* harness.offerAndWait( - claudeSdkFrame({ - ...workflowSnapshot({ - uuid: "00000000-0000-4000-8000-000000003102", - state: "start", - }), - task_id: secondTaskId, - tool_use_id: secondToolUseId, - }), - ); - yield* harness.offerAndWait( - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000003103", - result: "Launched both workflows.", - }), - ); - yield* Queue.take(harness.terminalReceipts); - yield* harness.offerAndWait( - workflowNotification({ - uuid: "00000000-0000-4000-8000-000000003104", - status: "completed", - }), - ); - yield* Queue.take(harness.continuationReceipts); - yield* harness.offerAndWait( - claudeSdkFrame({ - ...workflowNotification({ - uuid: "00000000-0000-4000-8000-000000003105", - status: "completed", - }), - task_id: secondTaskId, - tool_use_id: secondToolUseId, - }), - ); - const continuationInput = makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("attempt-workflow-buffered-replay"), - providerTurnOrdinal: 2, - text: "Continue.", - attachments: [], - messageCreatedBy: "agent", - messageCreationSource: "provider", - }); - pauseDigest = true; - const replay = yield* harness.runtime - .startTurn(continuationInput) - .pipe(Effect.forkScoped); - yield* Deferred.await(digestStarted); - yield* harness.runtime.interruptTurn({ - providerThread: harness.providerThread, - providerTurnId: (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({ - driver: ClaudeAdapterV2.CLAUDE_PROVIDER, - nativeTurnId: `turn:${continuationInput.attemptId}`, - }), - }); - yield* Deferred.succeed(releaseDigest, undefined); - yield* Fiber.join(replay); - const second = harness.events - .flatMap((event) => - event.type === "subagent.updated" && - event.subagent.nativeTaskRef?.nativeId === secondTaskId - ? [event.subagent] - : [], - ) - .at(-1); - assert.equal(second?.status, "completed"); - assert.equal(second?.result, "Workflow finished."); - }).pipe( - Effect.provideService(WorkflowAgentTranscriptReadObserver, () => - Effect.gen(function* () { - if (pauseDigest) { - pauseDigest = false; - yield* Deferred.succeed(digestStarted, undefined); - yield* Deferred.await(releaseDigest); - } - }), - ), + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-workflow-members"), + text: "Run the alpha workflow.", + attachments: [], + }), + ); + yield* launchWorkflow({ + harness, + snapshotUuid: "00000000-0000-4000-8000-000000001004", + state: "queued", + }); + + const coordinator = workflowCoordinatorEvents(harness.events).at(-1)?.subagent; + const first = workflowMemberEvents(harness.events, 1).at(-1)?.subagent; + const second = workflowMemberEvents(harness.events, 2).at(-1)?.subagent; + assert.equal(first?.status, "pending"); + assert.isNull(first?.startedAt); + assert.equal(second?.status, "pending"); + assert.isNull(second?.startedAt); + const coordinatorThread = harness.events.find( + (event) => + event.type === "app_thread.created" && event.appThread.id === coordinator?.childThreadId, + ); + assert.equal( + coordinatorThread?.type === "app_thread.created" ? coordinatorThread.appThread.title : null, + "Workflow · probe-wf", + ); + assert.equal(first?.title, "alpha:one"); + assert.equal(first?.prompt, "Reply with exactly: A1"); + assert.equal(first?.model, "claude-opus-5[1m]"); + assert.equal(first?.threadId, coordinator?.childThreadId); + assert.equal(second?.threadId, coordinator?.childThreadId); + assert.notEqual(first?.childThreadId, second?.childThreadId); + + assert.equal( + harness.events.find( + ( + event, + ): event is Extract< + ProviderAdapter.ProviderAdapterV2Event, + { type: "app_thread.created" } + > => event.type === "app_thread.created" && event.appThread.id === first?.childThreadId, + )?.appThread.title, + "alpha:one", + ); + const memberNodes = harness.events.filter( + ( + event, + ): event is Extract => + event.type === "node.updated" && + event.node.nativeItemRef?.nativeId === first?.nativeTaskRef?.nativeId, + ); + assert.deepEqual( + memberNodes.map((event) => event.node.kind), + ["subagent", "root_turn"], + ); + assert.deepEqual( + memberNodes.map((event) => [event.node.status, event.node.startedAt]), + [ + ["pending", null], + ["pending", null], + ], + ); + assert.deepEqual( + threadMessages(harness.events, first?.childThreadId).map((event) => event.message.text), + ["Reply with exactly: A1"], + ); + assert.deepEqual( + threadMessages(harness.events, second?.childThreadId).map((event) => event.message.text), + ["Reply with exactly: A2"], + ); + + yield* TestClock.adjust("60 seconds"); + const memberStartedAt = DateTime.add(now, { seconds: 30 }); + yield* harness.offerAndWait( + workflowSnapshot({ + uuid: "00000000-0000-4000-8000-000000001007", + state: "start", + secondState: "queued", + startedAt: DateTime.toEpochMillis(memberStartedAt), + }), + ); + let started = yield* Queue.take(harness.subagentReceipts); + while (started.subagent.id !== first?.id || started.subagent.status !== "running") { + started = yield* Queue.take(harness.subagentReceipts); + } + assert.deepEqual(started.subagent.startedAt, memberStartedAt); + const startedRoot = harness.events.findLast( + (event) => event.type === "node.updated" && event.node.threadId === first?.childThreadId, + ); + assert.equal( + startedRoot?.type === "node.updated" ? startedRoot.node.status : null, + "running", + ); + assert.deepEqual( + startedRoot?.type === "node.updated" ? startedRoot.node.startedAt : null, + memberStartedAt, + ); + + const turnItemsBeforeRepeat = coordinatorTurnItems().length; + yield* Queue.offer( + harness.sdkMessages, + workflowSnapshot({ + uuid: "00000000-0000-4000-8000-000000001005", + state: "start", + secondState: "queued", + startedAt: DateTime.toEpochMillis(memberStartedAt), + }), + ); + yield* harness.until(() => coordinatorTurnItems().length > turnItemsBeforeRepeat); + assert.lengthOf(workflowMemberEvents(harness.events, 1), 2); + assert.lengthOf(workflowMemberEvents(harness.events, 2), 1); + + const firstThreadId = first?.childThreadId; + const firstThreadEvents = () => + harness.events.filter( + ( + event, + ): event is Extract< + ProviderAdapter.ProviderAdapterV2Event, + { type: "app_thread.created" } + > => event.type === "app_thread.created" && event.appThread.id === firstThreadId, ); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + const turnItemsBeforeMetadata = coordinatorTurnItems().length; + yield* Queue.offer( + harness.sdkMessages, + workflowSnapshot({ + uuid: "00000000-0000-4000-8000-000000001017", + state: "start", + secondState: "queued", + startedAt: DateTime.toEpochMillis(memberStartedAt), + label: "alpha:review", + prompt: "Review the change", + model: "claude-sonnet-4-6", + }), + ); + // The member's node and app thread pick up the metadata after the coordinator item. + yield* harness.until( + () => + coordinatorTurnItems().length > turnItemsBeforeMetadata && + workflowMemberEvents(harness.events, 1).at(-1)?.subagent.title === "alpha:review" && + firstThreadEvents().at(-1)?.appThread.title === "alpha:review", + ); + assert.equal(workflowMemberEvents(harness.events, 1).at(-1)?.subagent.title, "alpha:review"); + assert.equal( + workflowMemberEvents(harness.events, 1).at(-1)?.subagent.prompt, + "Review the change", + ); + assert.equal( + workflowMemberEvents(harness.events, 1).at(-1)?.subagent.model, + "claude-sonnet-4-6", + ); + assert.equal(firstThreadEvents().at(-1)?.appThread.title, "alpha:review"); + assert.equal(firstThreadEvents().at(-1)?.appThread.modelSelection.model, "claude-sonnet-4-6"); + assert.deepEqual( + firstThreadEvents().at(-1)?.appThread.createdAt, + firstThreadEvents()[0]?.appThread.createdAt, + ); + assert.equal( + threadMessages(harness.events, firstThreadId).at(-1)?.message.text, + "Review the change", + ); + + yield* TestClock.adjust("10 seconds"); + yield* harness.offerAndWait( + workflowSnapshot({ + uuid: "00000000-0000-4000-8000-000000001018", + state: "done", + secondState: "queued", + omitResult: true, + }), + ); + let finished = yield* Queue.take(harness.subagentReceipts); + while (finished.subagent.id !== first?.id || finished.subagent.status !== "completed") { + finished = yield* Queue.take(harness.subagentReceipts); + } + const completedAt = finished.subagent.completedAt; + assert.deepEqual(completedAt, DateTime.add(now, { seconds: 70 })); + yield* TestClock.adjust("60 seconds"); + yield* harness.offerAndWait( + workflowSnapshot({ + uuid: "00000000-0000-4000-8000-000000001019", + state: "done", + secondState: "queued", + }), + ); + let excerpt = yield* Queue.take(harness.subagentReceipts); + while (excerpt.subagent.id !== first?.id || excerpt.subagent.result !== "A1 excerpt") { + excerpt = yield* Queue.take(harness.subagentReceipts); + } + assert.deepEqual(excerpt.subagent.completedAt, completedAt); + const finishedRoot = harness.events.findLast( + (event) => event.type === "node.updated" && event.node.threadId === firstThreadId, + ); + assert.deepEqual( + finishedRoot?.type === "node.updated" ? finishedRoot.node.completedAt : undefined, + completedAt, + ); + + yield* harness.offerAndWait( + workflowNotification({ uuid: "00000000-0000-4000-8000-000000001008", status: "stopped" }), + ); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000001006", + result: "Launched the workflow.", + }), + ); + yield* Queue.take(harness.terminalReceipts); + const stopped = workflowMemberEvents(harness.events, 2).at(-1)?.subagent; + assert.equal(stopped?.status, "cancelled"); + assert.isNull(stopped?.startedAt); + const stoppedRoot = harness.events.findLast( + (event) => event.type === "node.updated" && event.node.threadId === second?.childThreadId, + ); + assert.equal( + stoppedRoot?.type === "node.updated" ? stoppedRoot.node.status : null, + "cancelled", + ); + assert.isNull(stoppedRoot?.type === "node.updated" ? stoppedRoot.node.startedAt : undefined); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), ); - it.effect.each([ - ["api_error", false, false], - ["aborted_streaming", false, false], - ["api_error", true, false], - ["api_error", false, true], - ["aborted_streaming", false, true], - ] as const)( - "stops the run's live workflow after root result %s, continuation=%s, pending Stop=%s", - ([terminalReason, continuation, pendingStop]) => - Effect.scoped( - Effect.gen(function* () { - const stoppedTasks: Array = []; - const stopRequested = yield* Deferred.make(); - const harness = yield* makeWorkflowHarnessWithOptions({ - stopTask: (taskId) => - Effect.sync(() => void stoppedTasks.push(taskId)).pipe( - Effect.andThen(Deferred.succeed(stopRequested, undefined)), - Effect.asVoid, - ), - }); - const now = yield* DateTime.now; - const turnInput = { - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-workflow-root-failure"), - text: "Run the workflow.", - attachments: [], - }; - yield* harness.runtime.startTurn(makeClaudeTestTurnInput(turnInput)); - yield* launchWorkflow({ harness, snapshotUuid: "00000000-0000-4000-8000-000000001050" }); - const stopping = pendingStop - ? yield* harness.runtime.stopSubagent!({ - providerThread: harness.providerThread, - nativeTaskId: WORKFLOW_TASK_ID, - }).pipe(Effect.exit, Effect.forkScoped) - : undefined; - if (pendingStop) yield* Deferred.await(stopRequested); - if (continuation) { - yield* harness.offerAndWait( - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000001051", - result: "Launched.", - }), - ); - yield* Queue.take(harness.terminalReceipts); - yield* harness.offerAndWait( - claudeSdkFrame({ - ...workflowTaskStarted, - uuid: "00000000-0000-4000-8000-000000001052", - }), - ); - } - yield* harness.offerAndWait( - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000001053", - result: "Root turn ended.", - terminalReason, - isError: terminalReason === "api_error", - }), - ); - if (continuation) { - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - ...turnInput, - attemptId: RunAttemptId.make("attempt-claude-workflow-root-failure-wake"), - providerTurnOrdinal: 2, - messageCreatedBy: "agent", - messageCreationSource: "provider", - }), - ); - } - const terminal = yield* Queue.take(harness.terminalReceipts); - assert.equal(terminal.status, terminalReason === "api_error" ? "failed" : "interrupted"); - assert.equal(terminal.threadDisposition, "reusable"); - const settled = terminalReason === "api_error" ? "failed" : "cancelled"; - assert.deepEqual( - stoppedTasks, - pendingStop ? [WORKFLOW_TASK_ID, WORKFLOW_TASK_ID] : [WORKFLOW_TASK_ID], - ); - if (stopping !== undefined) { - yield* TestClock.adjust("2 seconds"); - assert.isTrue(Exit.isFailure(yield* Fiber.join(stopping))); - const retry = yield* harness.runtime.stopSubagent!({ - providerThread: harness.providerThread, - nativeTaskId: WORKFLOW_TASK_ID, - }).pipe(Effect.exit, Effect.forkScoped); - yield* TestClock.adjust("2 seconds"); - assert.isTrue(Exit.isFailure(yield* Fiber.join(retry))); - } - assert.isFalse(yield* harness.hasPendingBackgroundWork); - assert.equal(workflowCoordinatorEvents(harness.events).at(-1)?.subagent.status, settled); - for (const index of [1, 2]) { - assert.equal( - workflowMemberEvents(harness.events, index).at(-1)?.subagent.status, - settled, - ); - } - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), - ), + it.effect("projects workflow member threads after resuming a restarted session", () => + Effect.gen(function* () { + const harness = yield* makeWorkflowHarnessWithOptions({ + subagentLaunchToolUseId: WORKFLOW_TOOL_USE_ID, + }); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-claude-workflow-resume"), + text: "Continue the workflow.", + attachments: [], + }), + ); + yield* harness.offerAndWait( + claudeSdkFrame({ + ...workflowToolUse, + message: { + model: "claude-sonnet-4-6", + content: [ + { type: "tool_use", id: "toolu-resume-workflow", name: "SendMessage", input: {} }, + ], + }, + }), + ); + yield* harness.offerAndWait( + claudeSdkFrame({ + ...workflowTaskStarted, + tool_use_id: "toolu-resume-workflow", + prompt: "Continue the workflow.", + }), + ); + yield* harness.offerAndWait(workflowLaunchAck(harness.transcriptDir)); + yield* harness.offerAndWait( + workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001040", state: "start" }), + ); + yield* harness.offerAndWait( + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000001041", result: "Resumed." }), + ); + yield* Queue.take(harness.terminalReceipts); + const member = workflowMemberEvents(harness.events, 1).at(-1)?.subagent; + const coordinator = workflowCoordinatorEvents(harness.events).at(-1)?.subagent; + assert.isDefined(member); + assert.equal(member?.status, "running"); + assert.isTrue( + harness.events.some( + (event) => + event.type === "app_thread.created" && event.appThread.id === member?.childThreadId, + ), + ); + assert.deepEqual( + threadMessages(harness.events, member?.childThreadId).map((event) => event.message.text), + ["Reply with exactly: A1", "A1 from the transcript"], + ); + assert.isDefined(coordinator?.childThreadId); + const recoveredThreads = harness.events.filter( + (event) => + event.type === "app_thread.created" && event.appThread.id === coordinator?.childThreadId, + ); + assert.lengthOf(recoveredThreads, 1); + const recovered = recoveredThreads[0]; + assert.equal(recovered?.type, "app_thread.created"); + if (recovered?.type !== "app_thread.created") throw new Error("Expected recovered thread"); + assert.isTrue(recovered.createOnly); + assert.deepEqual(recovered.appThread.lineage, { + parentThreadId: harness.threadId, + relationshipToParent: "subagent", + rootThreadId: harness.threadId, + }); + assert.equal(recovered.appThread.createdBy, "agent"); + assert.equal(recovered.appThread.creationSource, "provider"); + assert.isBelow( + harness.events.indexOf(recovered), + harness.events.findIndex( + (event) => + event.type === "subagent.updated" && + event.subagent.childThreadId === coordinator?.childThreadId, + ), + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), + ), ); - it.effect("clears workflow member metadata only after a successful transcript read", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWorkflowHarness; - const now = yield* DateTime.now; + it.effect.each([ + "root result", + "resume after root result", + "completion before buffered start", + ] as const)("orders %s arriving during workflow transcript replay", (arrival) => + Effect.gen(function* () { + const digestStarted = yield* Deferred.make(); + const releaseDigest = yield* Deferred.make(); + const liveFrameOffered = yield* Deferred.make(); + let pauseDigest = false; + yield* Effect.gen(function* () { + const harness = yield* makeWorkflowHarnessWithOptions({ close: Queue.shutdown }); + const taskId = "task-resumed-during-workflow-replay"; + const taskStart = (toolUseId: string, prompt: string, uuid: string) => + claudeSdkFrame({ + ...workflowTaskStarted, + task_id: taskId, + task_type: "local_agent", + is_backgrounded: true, + subagent_type: "general-purpose", + spawn_depth: 1, + workflow_name: undefined, + tool_use_id: toolUseId, + prompt, + uuid, + }); + const taskFinished = (toolUseId: string, summary: string, uuid: string) => + claudeSdkFrame({ + ...workflowNotification({ uuid, status: "completed" }), + task_id: taskId, + tool_use_id: toolUseId, + summary, + }); yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ threadId: harness.threadId, providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-workflow-metadata"), - text: "Run the alpha workflow in the background.", + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-workflow-replay-launch"), + text: "Run the workflow and a background subagent.", attachments: [], }), ); @@ -21542,309 +20799,408 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* harness.offerAndWait(workflowTaskStarted); yield* harness.offerAndWait(workflowLaunchAck(harness.transcriptDir)); yield* harness.offerAndWait( - workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001040", state: "done" }), + workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000003001", state: "start" }), ); - yield* harness.until( - () => - workflowCoordinatorEvents(harness.events).at(-1)?.subagent.workflow?.agents[0] - ?.effort === "xhigh", - ); - let coordinator = workflowCoordinatorEvents(harness.events).at(-1)!.subagent; - assert.equal(coordinator.workflow?.agents[0]?.speed, "standard"); - - const transcriptPath = NodePath.join(harness.transcriptDir, "agent-a1.jsonl"); - NodeFS.renameSync(transcriptPath, `${transcriptPath}.saved`); + if (arrival !== "completion before buffered start") { + yield* harness.offerAndWait( + taskStart("toolu-initial", "Initial work.", "00000000-0000-4000-8000-000000003002"), + ); + } yield* harness.offerAndWait( - workflowSnapshot({ - uuid: "00000000-0000-4000-8000-000000001041", - state: "done", - label: "alpha:unreadable", + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000003003", + result: "Launched both tasks.", }), ); - yield* harness.until( - () => - workflowCoordinatorEvents(harness.events).at(-1)?.subagent.workflow?.agents[0] - ?.label === "alpha:unreadable", - ); - coordinator = workflowCoordinatorEvents(harness.events).at(-1)!.subagent; - assert.equal(coordinator.workflow?.agents[0]?.effort, "xhigh"); - assert.equal(coordinator.workflow?.agents[0]?.speed, "standard"); - - NodeFS.renameSync(`${transcriptPath}.saved`, transcriptPath); - NodeFS.appendFileSync( - transcriptPath, - '{"type":"assistant","uuid":"wf-metadata-cleared","message":{"role":"assistant","stop_reason":"end_turn","id":"msg_new","content":[{"type":"text","text":"New answer without metadata"}]}}\n', - ); + yield* Queue.take(harness.terminalReceipts); yield* harness.offerAndWait( - workflowSnapshot({ - uuid: "00000000-0000-4000-8000-000000001042", - state: "done", - label: "alpha:new-response", + workflowNotification({ + uuid: "00000000-0000-4000-8000-000000003004", + status: "completed", }), ); - yield* harness.until(() => { - const member = workflowCoordinatorEvents(harness.events).at(-1)?.subagent.workflow - ?.agents[0]; - return ( - member?.label === "alpha:new-response" && - member.effort === undefined && - member.speed === undefined + yield* Queue.take(harness.continuationReceipts); + if (arrival === "completion before buffered start") { + yield* harness.offerAndWait( + taskStart("toolu-initial", "Initial work.", "00000000-0000-4000-8000-000000003005"), ); - }); - yield* harness.offerAndWait( - makeResultFrame({ uuid: "00000000-0000-4000-8000-000000001043", result: "Done." }), - ); + } else { + yield* harness.offerAndWait( + taskFinished( + "toolu-initial", + "Initial result.", + "00000000-0000-4000-8000-000000003005", + ), + ); + } + if (arrival === "root result") { + yield* harness.offerAndWait( + claudeSdkFrame({ + type: "system", + subtype: "init", + session_id: WAKE_NATIVE_SESSION, + uuid: "00000000-0000-4000-8000-000000003006", + }), + ); + } + pauseDigest = true; + const replay = yield* harness.runtime + .startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-workflow-replay-continuation"), + providerTurnOrdinal: 2, + text: "Continue.", + attachments: [], + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ) + .pipe(Effect.forkScoped); + yield* Deferred.await(digestStarted); + const liveFrames = yield* Effect.gen(function* () { + if (arrival === "completion before buffered start") { + yield* harness.offerAndWait( + taskFinished( + "toolu-initial", + "Initial result.", + "00000000-0000-4000-8000-000000003007", + ), + liveFrameOffered, + ); + return; + } + yield* harness.offerAndWait( + claudeSdkFrame({ + ...makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000003008", + result: "Native wake turn finished.", + }), + origin: { kind: "task-notification" }, + num_turns: 1, + }), + liveFrameOffered, + ); + if (arrival === "resume after root result") { + yield* harness.offerAndWait( + claudeSdkFrame({ + type: "system", + subtype: "init", + session_id: WAKE_NATIVE_SESSION, + uuid: "00000000-0000-4000-8000-000000003009", + }), + ); + yield* harness.offerAndWait( + taskStart( + "toolu-latest", + "Latest live resume.", + "00000000-0000-4000-8000-000000003010", + ), + ); + } + }).pipe(Effect.forkScoped); + yield* Deferred.await(liveFrameOffered); + yield* Deferred.succeed(releaseDigest, undefined); + yield* Fiber.join(replay); + yield* Fiber.join(liveFrames); + if (arrival === "resume after root result") { + yield* Queue.take(harness.terminalReceipts); + yield* Queue.take(harness.continuationReceipts); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-workflow-replay-resume-continuation"), + providerTurnOrdinal: 3, + text: "Continue.", + attachments: [], + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + } + if (arrival !== "root result") { + yield* harness.offerAndWait( + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000003011", + result: "Workflow answer delivered.", + }), + ); + } yield* Queue.take(harness.terminalReceipts); - const member = workflowCoordinatorEvents(harness.events).at(-1)?.subagent.workflow - ?.agents[0]; - assert.isUndefined(member?.effort); - assert.isUndefined(member?.speed); - const childThreadId = member?.childThreadId; - assert.deepEqual( - threadMessages(harness.events, childThreadId) - .filter((event) => event.message.role === "assistant") - .map((event) => event.message.text), - ["A1 from the transcript", "New answer without metadata"], - ); + const latest = harness.events + .flatMap((event) => + event.type === "subagent.updated" && event.subagent.nativeTaskRef?.nativeId === taskId + ? [event.subagent] + : [], + ) + .at(-1); + if (arrival === "resume after root result") { + assert.equal(latest?.status, "running"); + assert.equal(latest?.prompt, "Latest live resume."); + assert.isNull(latest?.result); + assert.isTrue(yield* harness.hasPendingBackgroundWork); + } else { + assert.equal(latest?.status, "completed"); + assert.equal(latest?.result, "Initial result."); + assert.isFalse(yield* harness.hasPendingBackgroundWork); + assert.lengthOf(harness.continuationRequests, 1); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-workflow-replay-next-user"), + providerTurnOrdinal: 3, + text: "Next task.", + attachments: [], + }), + ); + } }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + Effect.provideService(WorkflowAgentTranscriptReadObserver, () => + Effect.gen(function* () { + if (pauseDigest) { + pauseDigest = false; + yield* Deferred.succeed(digestStarted, undefined); + yield* Deferred.await(releaseDigest); + } + }), + ), + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), ); - it.effect("answers settled workflow members from a transcript, or the snapshot excerpt", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWorkflowHarness; - const now = yield* DateTime.now; - - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-claude-workflow-settled"), - text: "Run the alpha workflow in the background.", - attachments: [], - }), - ); - yield* launchWorkflow({ harness, snapshotUuid: "00000000-0000-4000-8000-000000001007" }); - const firstThreadId = workflowMemberEvents(harness.events, 1)[0]?.subagent.childThreadId; - const secondThreadId = workflowMemberEvents(harness.events, 2)[0]?.subagent.childThreadId; - const currentAssistantAnswers = () => [ - ...new Map( - threadMessages(harness.events, firstThreadId) - .filter((event) => event.message.role === "assistant") - .map((event) => [event.message.id, event.message.text]), - ).values(), - ]; - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000001008", - result: "Launched the workflow.", - }), - ); - yield* Queue.take(harness.terminalReceipts); + it.effect("preserves buffered workflow outcomes when interrupted during replay", () => + Effect.gen(function* () { + const digestStarted = yield* Deferred.make(); + const releaseDigest = yield* Deferred.make(); + let pauseDigest = false; + yield* Effect.gen(function* () { + const harness = yield* makeWorkflowHarnessWithOptions({ close: Queue.shutdown }); + const secondTaskId = "task-workflow-buffered-completion"; + const secondToolUseId = "toolu-workflow-buffered-completion"; yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ threadId: harness.threadId, providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-unrelated-failure"), - text: "Do another task.", + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-workflow-buffered-launch"), + text: "Run both workflows.", attachments: [], - providerTurnOrdinal: 2, - }), - ); - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000001014", - result: "Another task failed.", - terminalReason: "api_error", - isError: true, }), ); - yield* Queue.take(harness.terminalReceipts); - assert.equal(workflowCoordinatorEvents(harness.events).at(-1)?.subagent.status, "running"); - assert.equal(workflowMemberEvents(harness.events, 1).at(-1)?.subagent.status, "running"); - - yield* Queue.offer( - harness.sdkMessages, - workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001009", state: "done" }), - ); - yield* harness.until(() => threadMessages(harness.events, secondThreadId).length === 2); - - yield* harness.until( - () => - workflowCoordinatorEvents(harness.events).at(-1)?.subagent.workflow?.agents[0] - ?.effort === "xhigh", - ); - const coordinator = workflowCoordinatorEvents(harness.events).at(-1)!.subagent; - assert.deepEqual( - coordinator.workflow?.agents.map((member) => member.state), - ["completed", "completed"], - ); - assert.equal(coordinator.workflow?.agents[0]?.speed, "standard"); - assert.isUndefined(coordinator.workflow?.agents[1]?.effort); - assert.isUndefined(coordinator.workflow?.agents[1]?.speed); - assert.equal(workflowMemberEvents(harness.events, 1).at(-1)?.subagent.status, "completed"); - assert.equal(workflowMemberEvents(harness.events, 2).at(-1)?.subagent.status, "completed"); - assert.deepEqual( - workflowMemberEvents(harness.events, 1).at(-1)?.subagent.completedAt, - yield* DateTime.now, - ); - assert.deepEqual( - threadMessages(harness.events, firstThreadId).map((event) => event.message.text), - ["Reply with exactly: A1", "A1 from the transcript"], - ); - assert.deepEqual( - threadMessages(harness.events, secondThreadId).map((event) => event.message.text), - ["Reply with exactly: A2", "Partial answer from workflow progress:\n\nA2 excerpt"], - ); - const coordinatorEventsBeforeRepeat = workflowCoordinatorEvents(harness.events).length; - const memberEventsBeforeRepeat = workflowMemberEvents(harness.events, 2).length; - NodeFS.appendFileSync( - NodePath.join(harness.transcriptDir, "agent-a1.jsonl"), - '{"type":"assistant","uuid":"wf-line-4","message":{"role":"assistant","stop_reason":"end_turn","id":"msg_a1","content":[{"type":"text","text":"A1 continued"}]}}\n', - ); - yield* harness.offerAndWait( - workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001015", state: "done" }), - ); - yield* harness.until( - () => workflowCoordinatorEvents(harness.events).length > coordinatorEventsBeforeRepeat, - ); - yield* harness.until( - () => - threadMessages(harness.events, firstThreadId).at(-1)?.message.text === "A1 continued", - ); - assert.lengthOf(workflowMemberEvents(harness.events, 2), memberEventsBeforeRepeat); - assert.lengthOf(threadMessages(harness.events, secondThreadId), 2); - assert.deepEqual(currentAssistantAnswers(), ["A1 from the transcript", "A1 continued"]); - NodeFS.writeFileSync( - NodePath.join(harness.transcriptDir, "agent-a2.jsonl"), - '{"type":"assistant","uuid":"wf-line-5","message":{"role":"assistant","stop_reason":"end_turn","id":"msg_a2","content":[{"type":"text","text":"A2 from the transcript"}]}}\n', - ); - yield* harness.offerAndWait( - workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001016", state: "done" }), - ); - yield* harness.until(() => threadMessages(harness.events, secondThreadId).length === 3); - assert.lengthOf(workflowMemberEvents(harness.events, 2), memberEventsBeforeRepeat); - assert.equal( - threadMessages(harness.events, secondThreadId).at(-1)?.message.text, - "A2 from the transcript", - ); - const previousStartedAt = workflowMemberEvents(harness.events, 1).at(-1)?.subagent - .startedAt; - yield* TestClock.adjust(1_000); - NodeFS.writeFileSync( - NodePath.join(harness.transcriptDir, "agent-a1.jsonl"), - [ - '{"type":"assistant","uuid":"wf-line-6","message":{"role":"assistant","stop_reason":"end_turn","id":"msg_first","content":[{"type":"text","text":"First answer"}]}}', - '{"type":"assistant","uuid":"wf-line-7","message":{"role":"assistant","stop_reason":"end_turn","id":"msg_second","content":[{"type":"text","text":"Second answer"}]}}', - ].join("\n") + "\n", - ); + yield* launchWorkflow({ + harness, + snapshotUuid: "00000000-0000-4000-8000-000000003100", + }); yield* harness.offerAndWait( - workflowSnapshot({ - uuid: "00000000-0000-4000-8000-000000001018", - state: "done", - attempt: 2, - startedAt: 1000, - durationMs: 500, - lastProgressAt: 4432, - }), - ); - yield* harness.until(() => - currentAssistantAnswers().join("\n\n").includes("Second answer"), - ); - assert.deepEqual(currentAssistantAnswers().slice(-2), ["First answer", "Second answer"]); - const completedMember = workflowMemberEvents(harness.events, 1).at(-1)?.subagent; - assert.equal( - completedMember?.completedAt && DateTime.toEpochMillis(completedMember.completedAt), - 4432, - ); - assert.isUndefined( - workflowCoordinatorEvents(harness.events).at(-1)?.subagent.workflow?.agents[0]?.effort, - ); - assert.isUndefined( - workflowCoordinatorEvents(harness.events).at(-1)?.subagent.workflow?.agents[0]?.speed, - ); - assert.notDeepEqual( - workflowMemberEvents(harness.events, 1).at(-1)?.subagent.startedAt, - previousStartedAt, + claudeSdkFrame({ + ...workflowTaskStarted, + task_id: secondTaskId, + tool_use_id: secondToolUseId, + uuid: "00000000-0000-4000-8000-000000003101", + }), ); yield* harness.offerAndWait( - workflowSnapshot({ - uuid: "00000000-0000-4000-8000-000000001019", - state: "start", - attempt: 3, + claudeSdkFrame({ + ...workflowSnapshot({ + uuid: "00000000-0000-4000-8000-000000003102", + state: "start", + }), + task_id: secondTaskId, + tool_use_id: secondToolUseId, }), ); - assert.equal(workflowMemberEvents(harness.events, 1).at(-1)?.subagent.status, "running"); - assert.isNull(workflowMemberEvents(harness.events, 1).at(-1)?.subagent.completedAt); - yield* Queue.offer( - harness.sdkMessages, - workflowSnapshot({ - uuid: "00000000-0000-4000-8000-000000001020", - state: "start", - attempt: 3, + yield* harness.offerAndWait( + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000003103", + result: "Launched both workflows.", }), ); - NodeFS.writeFileSync( - NodePath.join(harness.transcriptDir, "agent-a1.jsonl"), - '{"type":"assistant","uuid":"wf-line-8","message":{"role":"assistant","stop_reason":"end_turn","id":"msg_retry","content":[{"type":"text","text":"A1 retry answer"}]}}\n', - ); - yield* Queue.offer( - harness.sdkMessages, - workflowSnapshot({ - uuid: "00000000-0000-4000-8000-000000001021", - state: "done", - attempt: 3, + yield* Queue.take(harness.terminalReceipts); + yield* harness.offerAndWait( + workflowNotification({ + uuid: "00000000-0000-4000-8000-000000003104", + status: "completed", }), ); - yield* harness.until( - () => - threadMessages(harness.events, firstThreadId).at(-1)?.message.text === - "A1 retry answer", - ); - assert.equal(currentAssistantAnswers().at(-1), "A1 retry answer"); - NodeFS.rmSync(NodePath.join(harness.transcriptDir, "agent-a1.jsonl")); - yield* Queue.offer( - harness.sdkMessages, - workflowSnapshot({ - uuid: "00000000-0000-4000-8000-000000001022", - state: "start", - attempt: 4, + yield* Queue.take(harness.continuationReceipts); + yield* harness.offerAndWait( + claudeSdkFrame({ + ...workflowNotification({ + uuid: "00000000-0000-4000-8000-000000003105", + status: "completed", + }), + task_id: secondTaskId, + tool_use_id: secondToolUseId, }), ); - yield* harness.until( - () => - workflowCoordinatorEvents(harness.events).at(-1)?.subagent.workflow?.agents[0] - ?.attempt === 4, - ); - const answersBeforeEmptyAttempt = currentAssistantAnswers(); + const continuationInput = makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-workflow-buffered-replay"), + providerTurnOrdinal: 2, + text: "Continue.", + attachments: [], + messageCreatedBy: "agent", + messageCreationSource: "provider", + }); + pauseDigest = true; + const replay = yield* harness.runtime.startTurn(continuationInput).pipe(Effect.forkScoped); + yield* Deferred.await(digestStarted); + yield* harness.runtime.interruptTurn({ + providerThread: harness.providerThread, + providerTurnId: (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({ + driver: ClaudeAdapterV2.CLAUDE_PROVIDER, + nativeTurnId: `turn:${continuationInput.attemptId}`, + }), + }); + yield* Deferred.succeed(releaseDigest, undefined); + yield* Fiber.join(replay); + const second = harness.events + .flatMap((event) => + event.type === "subagent.updated" && + event.subagent.nativeTaskRef?.nativeId === secondTaskId + ? [event.subagent] + : [], + ) + .at(-1); + assert.equal(second?.status, "completed"); + assert.equal(second?.result, "Workflow finished."); + }).pipe( + Effect.provideService(WorkflowAgentTranscriptReadObserver, () => + Effect.gen(function* () { + if (pauseDigest) { + pauseDigest = false; + yield* Deferred.succeed(digestStarted, undefined); + yield* Deferred.await(releaseDigest); + } + }), + ), + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + + it.effect.each([ + ["api_error", false, false], + ["aborted_streaming", false, false], + ["api_error", true, false], + ["api_error", false, true], + ["aborted_streaming", false, true], + ] as const)( + "stops the run's live workflow after root result %s, continuation=%s, pending Stop=%s", + ([terminalReason, continuation, pendingStop]) => + Effect.gen(function* () { + const stoppedTasks: Array = []; + const stopRequested = yield* Deferred.make(); + const harness = yield* makeWorkflowHarnessWithOptions({ + stopTask: (taskId) => + Effect.sync(() => void stoppedTasks.push(taskId)).pipe( + Effect.andThen(Deferred.succeed(stopRequested, undefined)), + Effect.asVoid, + ), + }); + const now = yield* DateTime.now; + const turnInput = { + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-workflow-root-failure"), + text: "Run the workflow.", + attachments: [], + }; + yield* harness.runtime.startTurn(makeClaudeTestTurnInput(turnInput)); + yield* launchWorkflow({ harness, snapshotUuid: "00000000-0000-4000-8000-000000001050" }); + const stopping = pendingStop + ? yield* harness.runtime.stopSubagent!({ + providerThread: harness.providerThread, + nativeTaskId: WORKFLOW_TASK_ID, + }).pipe(Effect.exit, Effect.forkScoped) + : undefined; + if (pendingStop) yield* Deferred.await(stopRequested); + if (continuation) { + yield* harness.offerAndWait( + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000001051", + result: "Launched.", + }), + ); + yield* Queue.take(harness.terminalReceipts); + yield* harness.offerAndWait( + claudeSdkFrame({ + ...workflowTaskStarted, + uuid: "00000000-0000-4000-8000-000000001052", + }), + ); + } yield* harness.offerAndWait( - workflowSnapshot({ - uuid: "00000000-0000-4000-8000-000000001023", - state: "done", - attempt: 4, - omitResult: true, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000001053", + result: "Root turn ended.", + terminalReason, + isError: terminalReason === "api_error", }), ); - assert.equal(workflowMemberEvents(harness.events, 1).at(-1)?.subagent.status, "completed"); + if (continuation) { + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + ...turnInput, + attemptId: RunAttemptId.make("attempt-claude-workflow-root-failure-wake"), + providerTurnOrdinal: 2, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + } + const terminal = yield* Queue.take(harness.terminalReceipts); + assert.equal(terminal.status, terminalReason === "api_error" ? "failed" : "interrupted"); + assert.equal(terminal.threadDisposition, "reusable"); + const settled = terminalReason === "api_error" ? "failed" : "cancelled"; assert.deepEqual( - currentAssistantAnswers(), - answersBeforeEmptyAttempt, - "an attempt with no transcript and no excerpt adds nothing", + stoppedTasks, + pendingStop ? [WORKFLOW_TASK_ID, WORKFLOW_TASK_ID] : [WORKFLOW_TASK_ID], ); + if (stopping !== undefined) { + yield* TestClock.adjust("2 seconds"); + assert.isTrue(Exit.isFailure(yield* Fiber.join(stopping))); + const retry = yield* harness.runtime.stopSubagent!({ + providerThread: harness.providerThread, + nativeTaskId: WORKFLOW_TASK_ID, + }).pipe(Effect.exit, Effect.forkScoped); + yield* TestClock.adjust("2 seconds"); + assert.isTrue(Exit.isFailure(yield* Fiber.join(retry))); + } + assert.isFalse(yield* harness.hasPendingBackgroundWork); + assert.equal(workflowCoordinatorEvents(harness.events).at(-1)?.subagent.status, settled); + for (const index of [1, 2]) { + assert.equal( + workflowMemberEvents(harness.events, index).at(-1)?.subagent.status, + settled, + ); + } }).pipe( Effect.provide( Layer.mergeAll( @@ -21855,6 +21211,330 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ), ), + ); + + it.effect("clears workflow member metadata only after a successful transcript read", () => + Effect.gen(function* () { + const harness = yield* makeWorkflowHarness; + const now = yield* DateTime.now; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-workflow-metadata"), + text: "Run the alpha workflow in the background.", + attachments: [], + }), + ); + yield* harness.offerAndWait(workflowToolUse); + yield* harness.offerAndWait(workflowTaskStarted); + yield* harness.offerAndWait(workflowLaunchAck(harness.transcriptDir)); + yield* harness.offerAndWait( + workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001040", state: "done" }), + ); + yield* harness.until( + () => + workflowCoordinatorEvents(harness.events).at(-1)?.subagent.workflow?.agents[0]?.effort === + "xhigh", + ); + let coordinator = workflowCoordinatorEvents(harness.events).at(-1)!.subagent; + assert.equal(coordinator.workflow?.agents[0]?.speed, "standard"); + + const transcriptPath = NodePath.join(harness.transcriptDir, "agent-a1.jsonl"); + NodeFS.renameSync(transcriptPath, `${transcriptPath}.saved`); + yield* harness.offerAndWait( + workflowSnapshot({ + uuid: "00000000-0000-4000-8000-000000001041", + state: "done", + label: "alpha:unreadable", + }), + ); + yield* harness.until( + () => + workflowCoordinatorEvents(harness.events).at(-1)?.subagent.workflow?.agents[0]?.label === + "alpha:unreadable", + ); + coordinator = workflowCoordinatorEvents(harness.events).at(-1)!.subagent; + assert.equal(coordinator.workflow?.agents[0]?.effort, "xhigh"); + assert.equal(coordinator.workflow?.agents[0]?.speed, "standard"); + + NodeFS.renameSync(`${transcriptPath}.saved`, transcriptPath); + NodeFS.appendFileSync( + transcriptPath, + '{"type":"assistant","uuid":"wf-metadata-cleared","message":{"role":"assistant","stop_reason":"end_turn","id":"msg_new","content":[{"type":"text","text":"New answer without metadata"}]}}\n', + ); + yield* harness.offerAndWait( + workflowSnapshot({ + uuid: "00000000-0000-4000-8000-000000001042", + state: "done", + label: "alpha:new-response", + }), + ); + yield* harness.until(() => { + const member = workflowCoordinatorEvents(harness.events).at(-1)?.subagent.workflow + ?.agents[0]; + return ( + member?.label === "alpha:new-response" && + member.effort === undefined && + member.speed === undefined + ); + }); + yield* harness.offerAndWait( + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000001043", result: "Done." }), + ); + yield* Queue.take(harness.terminalReceipts); + const member = workflowCoordinatorEvents(harness.events).at(-1)?.subagent.workflow?.agents[0]; + assert.isUndefined(member?.effort); + assert.isUndefined(member?.speed); + const childThreadId = member?.childThreadId; + assert.deepEqual( + threadMessages(harness.events, childThreadId) + .filter((event) => event.message.role === "assistant") + .map((event) => event.message.text), + ["A1 from the transcript", "New answer without metadata"], + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + + it.effect("answers settled workflow members from a transcript, or the snapshot excerpt", () => + Effect.gen(function* () { + const harness = yield* makeWorkflowHarness; + const now = yield* DateTime.now; + + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-workflow-settled"), + text: "Run the alpha workflow in the background.", + attachments: [], + }), + ); + yield* launchWorkflow({ harness, snapshotUuid: "00000000-0000-4000-8000-000000001007" }); + const firstThreadId = workflowMemberEvents(harness.events, 1)[0]?.subagent.childThreadId; + const secondThreadId = workflowMemberEvents(harness.events, 2)[0]?.subagent.childThreadId; + const currentAssistantAnswers = () => [ + ...new Map( + threadMessages(harness.events, firstThreadId) + .filter((event) => event.message.role === "assistant") + .map((event) => [event.message.id, event.message.text]), + ).values(), + ]; + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000001008", + result: "Launched the workflow.", + }), + ); + yield* Queue.take(harness.terminalReceipts); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-unrelated-failure"), + text: "Do another task.", + attachments: [], + providerTurnOrdinal: 2, + }), + ); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000001014", + result: "Another task failed.", + terminalReason: "api_error", + isError: true, + }), + ); + yield* Queue.take(harness.terminalReceipts); + assert.equal(workflowCoordinatorEvents(harness.events).at(-1)?.subagent.status, "running"); + assert.equal(workflowMemberEvents(harness.events, 1).at(-1)?.subagent.status, "running"); + + yield* Queue.offer( + harness.sdkMessages, + workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001009", state: "done" }), + ); + yield* harness.until(() => threadMessages(harness.events, secondThreadId).length === 2); + + yield* harness.until( + () => + workflowCoordinatorEvents(harness.events).at(-1)?.subagent.workflow?.agents[0]?.effort === + "xhigh", + ); + const coordinator = workflowCoordinatorEvents(harness.events).at(-1)!.subagent; + assert.deepEqual( + coordinator.workflow?.agents.map((member) => member.state), + ["completed", "completed"], + ); + assert.equal(coordinator.workflow?.agents[0]?.speed, "standard"); + assert.isUndefined(coordinator.workflow?.agents[1]?.effort); + assert.isUndefined(coordinator.workflow?.agents[1]?.speed); + assert.equal(workflowMemberEvents(harness.events, 1).at(-1)?.subagent.status, "completed"); + assert.equal(workflowMemberEvents(harness.events, 2).at(-1)?.subagent.status, "completed"); + assert.deepEqual( + workflowMemberEvents(harness.events, 1).at(-1)?.subagent.completedAt, + yield* DateTime.now, + ); + assert.deepEqual( + threadMessages(harness.events, firstThreadId).map((event) => event.message.text), + ["Reply with exactly: A1", "A1 from the transcript"], + ); + assert.deepEqual( + threadMessages(harness.events, secondThreadId).map((event) => event.message.text), + ["Reply with exactly: A2", "Partial answer from workflow progress:\n\nA2 excerpt"], + ); + const coordinatorEventsBeforeRepeat = workflowCoordinatorEvents(harness.events).length; + const memberEventsBeforeRepeat = workflowMemberEvents(harness.events, 2).length; + NodeFS.appendFileSync( + NodePath.join(harness.transcriptDir, "agent-a1.jsonl"), + '{"type":"assistant","uuid":"wf-line-4","message":{"role":"assistant","stop_reason":"end_turn","id":"msg_a1","content":[{"type":"text","text":"A1 continued"}]}}\n', + ); + yield* harness.offerAndWait( + workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001015", state: "done" }), + ); + yield* harness.until( + () => workflowCoordinatorEvents(harness.events).length > coordinatorEventsBeforeRepeat, + ); + yield* harness.until( + () => threadMessages(harness.events, firstThreadId).at(-1)?.message.text === "A1 continued", + ); + assert.lengthOf(workflowMemberEvents(harness.events, 2), memberEventsBeforeRepeat); + assert.lengthOf(threadMessages(harness.events, secondThreadId), 2); + assert.deepEqual(currentAssistantAnswers(), ["A1 from the transcript", "A1 continued"]); + NodeFS.writeFileSync( + NodePath.join(harness.transcriptDir, "agent-a2.jsonl"), + '{"type":"assistant","uuid":"wf-line-5","message":{"role":"assistant","stop_reason":"end_turn","id":"msg_a2","content":[{"type":"text","text":"A2 from the transcript"}]}}\n', + ); + yield* harness.offerAndWait( + workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001016", state: "done" }), + ); + yield* harness.until(() => threadMessages(harness.events, secondThreadId).length === 3); + assert.lengthOf(workflowMemberEvents(harness.events, 2), memberEventsBeforeRepeat); + assert.equal( + threadMessages(harness.events, secondThreadId).at(-1)?.message.text, + "A2 from the transcript", + ); + const previousStartedAt = workflowMemberEvents(harness.events, 1).at(-1)?.subagent.startedAt; + yield* TestClock.adjust(1_000); + NodeFS.writeFileSync( + NodePath.join(harness.transcriptDir, "agent-a1.jsonl"), + [ + '{"type":"assistant","uuid":"wf-line-6","message":{"role":"assistant","stop_reason":"end_turn","id":"msg_first","content":[{"type":"text","text":"First answer"}]}}', + '{"type":"assistant","uuid":"wf-line-7","message":{"role":"assistant","stop_reason":"end_turn","id":"msg_second","content":[{"type":"text","text":"Second answer"}]}}', + ].join("\n") + "\n", + ); + yield* harness.offerAndWait( + workflowSnapshot({ + uuid: "00000000-0000-4000-8000-000000001018", + state: "done", + attempt: 2, + startedAt: 1000, + durationMs: 500, + lastProgressAt: 4432, + }), + ); + yield* harness.until(() => currentAssistantAnswers().join("\n\n").includes("Second answer")); + assert.deepEqual(currentAssistantAnswers().slice(-2), ["First answer", "Second answer"]); + const completedMember = workflowMemberEvents(harness.events, 1).at(-1)?.subagent; + assert.equal( + completedMember?.completedAt && DateTime.toEpochMillis(completedMember.completedAt), + 4432, + ); + assert.isUndefined( + workflowCoordinatorEvents(harness.events).at(-1)?.subagent.workflow?.agents[0]?.effort, + ); + assert.isUndefined( + workflowCoordinatorEvents(harness.events).at(-1)?.subagent.workflow?.agents[0]?.speed, + ); + assert.notDeepEqual( + workflowMemberEvents(harness.events, 1).at(-1)?.subagent.startedAt, + previousStartedAt, + ); + yield* harness.offerAndWait( + workflowSnapshot({ + uuid: "00000000-0000-4000-8000-000000001019", + state: "start", + attempt: 3, + }), + ); + assert.equal(workflowMemberEvents(harness.events, 1).at(-1)?.subagent.status, "running"); + assert.isNull(workflowMemberEvents(harness.events, 1).at(-1)?.subagent.completedAt); + yield* Queue.offer( + harness.sdkMessages, + workflowSnapshot({ + uuid: "00000000-0000-4000-8000-000000001020", + state: "start", + attempt: 3, + }), + ); + NodeFS.writeFileSync( + NodePath.join(harness.transcriptDir, "agent-a1.jsonl"), + '{"type":"assistant","uuid":"wf-line-8","message":{"role":"assistant","stop_reason":"end_turn","id":"msg_retry","content":[{"type":"text","text":"A1 retry answer"}]}}\n', + ); + yield* Queue.offer( + harness.sdkMessages, + workflowSnapshot({ + uuid: "00000000-0000-4000-8000-000000001021", + state: "done", + attempt: 3, + }), + ); + yield* harness.until( + () => + threadMessages(harness.events, firstThreadId).at(-1)?.message.text === "A1 retry answer", + ); + assert.equal(currentAssistantAnswers().at(-1), "A1 retry answer"); + NodeFS.rmSync(NodePath.join(harness.transcriptDir, "agent-a1.jsonl")); + yield* Queue.offer( + harness.sdkMessages, + workflowSnapshot({ + uuid: "00000000-0000-4000-8000-000000001022", + state: "start", + attempt: 4, + }), + ); + yield* harness.until( + () => + workflowCoordinatorEvents(harness.events).at(-1)?.subagent.workflow?.agents[0] + ?.attempt === 4, + ); + const answersBeforeEmptyAttempt = currentAssistantAnswers(); + yield* harness.offerAndWait( + workflowSnapshot({ + uuid: "00000000-0000-4000-8000-000000001023", + state: "done", + attempt: 4, + omitResult: true, + }), + ); + assert.equal(workflowMemberEvents(harness.events, 1).at(-1)?.subagent.status, "completed"); + assert.deepEqual( + currentAssistantAnswers(), + answersBeforeEmptyAttempt, + "an attempt with no transcript and no excerpt adds nothing", + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), ), ); @@ -21941,289 +21621,276 @@ describe("ClaudeAdapterV2 background wake turns", () => { : `${item.type} ${item.status}`; it.effect("streams a running workflow member's conversation from its transcript", () => - Effect.scoped( - Effect.gen(function* () { - const reads: Array<{ - readonly startOffset: number; - readonly endOffset: number; - readonly lineCount: number; - }> = []; - yield* Effect.gen(function* () { - const harness = yield* makeWorkflowHarness; - const transcriptPath = NodePath.join(harness.transcriptDir, "agent-a1.jsonl"); - NodeFS.writeFileSync(transcriptPath, memberTranscript); - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("attempt-claude-workflow-live-stream"), - text: "Run the alpha workflow.", - attachments: [], - }), - ); - yield* launchWorkflow({ harness, snapshotUuid: "00000000-0000-4000-8000-000000001360" }); - const threadId = workflowMemberEvents(harness.events, 1)[0]?.subagent.childThreadId; - const running = (uuid: string) => workflowSnapshot({ uuid, state: "start" }); - - assert.deepEqual(latestMemberThreadItems(harness.events, threadId).map(describeItem), [ - "user_message", - "reasoning: List the files first.", - "command completed: README.md", - "assistant_message", - ]); - assert.deepEqual( - threadMessages(harness.events, threadId).map((event) => event.message.text), - ["Reply with exactly: A1", "A1 found README.md"], - ); - assert.deepEqual(reads.at(-1), { - startOffset: 0, - endOffset: Buffer.byteLength(memberTranscript), - lineCount: 5, - }); - - const itemsBefore = memberThreadItems(harness.events, threadId).length; - const more = memberAssistantLine("line-more", "msg_a1_more", [ - { - type: "tool_use", - id: "toolu_a1_cat", - name: "Bash", - input: { command: "cat README.md" }, - }, - ]); - const half = memberToolResultLine("line-cat-result", "toolu_a1_cat", "hello"); - NodeFS.appendFileSync(transcriptPath, more + half.slice(0, 40)); - yield* harness.offerAndWait(running("00000000-0000-4000-8000-000000001361")); - assert.deepEqual(reads.at(-1), { - startOffset: Buffer.byteLength(memberTranscript), - endOffset: Buffer.byteLength(memberTranscript + more), - lineCount: 1, - }); - assert.deepEqual( - memberThreadItems(harness.events, threadId).slice(itemsBefore).map(describeItem), - ["command running: "], - "only the new line is projected; the half-written one waits", - ); - - NodeFS.appendFileSync(transcriptPath, half.slice(40)); - yield* harness.offerAndWait(running("00000000-0000-4000-8000-000000001362")); - assert.deepEqual(reads.at(-1), { - startOffset: Buffer.byteLength(memberTranscript + more), - endOffset: Buffer.byteLength(memberTranscript + more + half), - lineCount: 1, - }); - assert.deepEqual( - memberThreadItems(harness.events, threadId).slice(itemsBefore).map(describeItem), - ["command running: ", "command completed: hello"], - ); - - const itemsBeforeIdle = memberThreadItems(harness.events, threadId).length; - yield* harness.offerAndWait(running("00000000-0000-4000-8000-000000001363")); - assert.equal(reads.at(-1)?.lineCount, 0); - assert.lengthOf(memberThreadItems(harness.events, threadId), itemsBeforeIdle); - - yield* harness.offerAndWait( - workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001364", state: "done" }), - ); - assert.equal( - workflowMemberEvents(harness.events, 1).at(-1)?.subagent.status, - "completed", - ); - assert.deepEqual( - threadMessages(harness.events, threadId).map((event) => event.message.text), - ["Reply with exactly: A1", "A1 found README.md"], - "the settled member adds no second copy of its answer", - ); - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ uuid: "00000000-0000-4000-8000-000000001365", result: "Done." }), - ); - yield* Queue.take(harness.terminalReceipts); - }).pipe( - Effect.provideService(WorkflowAgentTranscriptReadObserver, (read) => - Effect.sync(() => { - if (read.agentId === "a1") { - reads.push({ - startOffset: read.startOffset, - endOffset: read.endOffset, - lineCount: read.lineCount, - }); - } - }), - ), - ); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), - ), - ), - ), - ); - - it.effect("interrupts the open tool calls of a workflow member that failed", () => - Effect.scoped( - Effect.gen(function* () { + Effect.gen(function* () { + const reads: Array<{ + readonly startOffset: number; + readonly endOffset: number; + readonly lineCount: number; + }> = []; + yield* Effect.gen(function* () { const harness = yield* makeWorkflowHarness; - NodeFS.writeFileSync( - NodePath.join(harness.transcriptDir, "agent-a1.jsonl"), - memberAssistantLine("line-bash", "msg_a1", [ - { type: "tool_use", id: "toolu_a1_ls", name: "Bash", input: { command: "ls" } }, - ]), - ); + const transcriptPath = NodePath.join(harness.transcriptDir, "agent-a1.jsonl"); + NodeFS.writeFileSync(transcriptPath, memberTranscript); yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ threadId: harness.threadId, providerThread: harness.providerThread, now: yield* DateTime.now, - attemptId: RunAttemptId.make("attempt-claude-workflow-member-fails"), + attemptId: RunAttemptId.make("attempt-claude-workflow-live-stream"), text: "Run the alpha workflow.", attachments: [], }), ); - yield* launchWorkflow({ harness, snapshotUuid: "00000000-0000-4000-8000-000000001370" }); + yield* launchWorkflow({ harness, snapshotUuid: "00000000-0000-4000-8000-000000001360" }); const threadId = workflowMemberEvents(harness.events, 1)[0]?.subagent.childThreadId; + const running = (uuid: string) => workflowSnapshot({ uuid, state: "start" }); + assert.deepEqual(latestMemberThreadItems(harness.events, threadId).map(describeItem), [ "user_message", - "command running: ", + "reasoning: List the files first.", + "command completed: README.md", + "assistant_message", ]); - yield* harness.offerAndWait( - workflowNotification({ uuid: "00000000-0000-4000-8000-000000001371", status: "failed" }), + assert.deepEqual( + threadMessages(harness.events, threadId).map((event) => event.message.text), + ["Reply with exactly: A1", "A1 found README.md"], ); - assert.equal(workflowMemberEvents(harness.events, 1).at(-1)?.subagent.status, "failed"); - assert.deepEqual(latestMemberThreadItems(harness.events, threadId).map(describeItem), [ - "user_message", - "command interrupted: ", + assert.deepEqual(reads.at(-1), { + startOffset: 0, + endOffset: Buffer.byteLength(memberTranscript), + lineCount: 5, + }); + + const itemsBefore = memberThreadItems(harness.events, threadId).length; + const more = memberAssistantLine("line-more", "msg_a1_more", [ + { + type: "tool_use", + id: "toolu_a1_cat", + name: "Bash", + input: { command: "cat README.md" }, + }, ]); + const half = memberToolResultLine("line-cat-result", "toolu_a1_cat", "hello"); + NodeFS.appendFileSync(transcriptPath, more + half.slice(0, 40)); + yield* harness.offerAndWait(running("00000000-0000-4000-8000-000000001361")); + assert.deepEqual(reads.at(-1), { + startOffset: Buffer.byteLength(memberTranscript), + endOffset: Buffer.byteLength(memberTranscript + more), + lineCount: 1, + }); + assert.deepEqual( + memberThreadItems(harness.events, threadId).slice(itemsBefore).map(describeItem), + ["command running: "], + "only the new line is projected; the half-written one waits", + ); + + NodeFS.appendFileSync(transcriptPath, half.slice(40)); + yield* harness.offerAndWait(running("00000000-0000-4000-8000-000000001362")); + assert.deepEqual(reads.at(-1), { + startOffset: Buffer.byteLength(memberTranscript + more), + endOffset: Buffer.byteLength(memberTranscript + more + half), + lineCount: 1, + }); + assert.deepEqual( + memberThreadItems(harness.events, threadId).slice(itemsBefore).map(describeItem), + ["command running: ", "command completed: hello"], + ); + + const itemsBeforeIdle = memberThreadItems(harness.events, threadId).length; + yield* harness.offerAndWait(running("00000000-0000-4000-8000-000000001363")); + assert.equal(reads.at(-1)?.lineCount, 0); + assert.lengthOf(memberThreadItems(harness.events, threadId), itemsBeforeIdle); + + yield* harness.offerAndWait( + workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001364", state: "done" }), + ); + assert.equal(workflowMemberEvents(harness.events, 1).at(-1)?.subagent.status, "completed"); + assert.deepEqual( + threadMessages(harness.events, threadId).map((event) => event.message.text), + ["Reply with exactly: A1", "A1 found README.md"], + "the settled member adds no second copy of its answer", + ); yield* Queue.offer( harness.sdkMessages, - makeResultFrame({ uuid: "00000000-0000-4000-8000-000000001372", result: "Done." }), + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000001365", result: "Done." }), ); yield* Queue.take(harness.terminalReceipts); }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + Effect.provideService(WorkflowAgentTranscriptReadObserver, (read) => + Effect.sync(() => { + if (read.agentId === "a1") { + reads.push({ + startOffset: read.startOffset, + endOffset: read.endOffset, + lineCount: read.lineCount, + }); + } + }), + ), + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + + it.effect("interrupts the open tool calls of a workflow member that failed", () => + Effect.gen(function* () { + const harness = yield* makeWorkflowHarness; + NodeFS.writeFileSync( + NodePath.join(harness.transcriptDir, "agent-a1.jsonl"), + memberAssistantLine("line-bash", "msg_a1", [ + { type: "tool_use", id: "toolu_a1_ls", name: "Bash", input: { command: "ls" } }, + ]), + ); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-claude-workflow-member-fails"), + text: "Run the alpha workflow.", + attachments: [], + }), + ); + yield* launchWorkflow({ harness, snapshotUuid: "00000000-0000-4000-8000-000000001370" }); + const threadId = workflowMemberEvents(harness.events, 1)[0]?.subagent.childThreadId; + assert.deepEqual(latestMemberThreadItems(harness.events, threadId).map(describeItem), [ + "user_message", + "command running: ", + ]); + yield* harness.offerAndWait( + workflowNotification({ uuid: "00000000-0000-4000-8000-000000001371", status: "failed" }), + ); + assert.equal(workflowMemberEvents(harness.events, 1).at(-1)?.subagent.status, "failed"); + assert.deepEqual(latestMemberThreadItems(harness.events, threadId).map(describeItem), [ + "user_message", + "command interrupted: ", + ]); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000001372", result: "Done." }), + ); + yield* Queue.take(harness.terminalReceipts); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), ); it.effect("reads a settled workflow member's transcript to its end past the read cap", () => - Effect.scoped( - Effect.gen(function* () { + Effect.gen(function* () { + const harness = yield* makeWorkflowHarness; + // Exceeds the launch and settle reads combined, at 512 KB each. + const filler = "x".repeat(16 * 1024); + NodeFS.writeFileSync( + NodePath.join(harness.transcriptDir, "agent-a1.jsonl"), + Array.from({ length: 80 }, (_, index) => + memberAssistantLine(`line-early-${index}`, `msg_early_${index}`, [ + { type: "text", text: `${index} ${filler}` }, + ]), + ).join("") + + memberAssistantLine("line-final", "msg_final", [{ type: "text", text: "FINAL ANSWER" }]), + ); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-claude-workflow-member-long-transcript"), + text: "Run the alpha workflow.", + attachments: [], + }), + ); + yield* launchWorkflow({ harness, snapshotUuid: "00000000-0000-4000-8000-000000001390" }); + const threadId = workflowMemberEvents(harness.events, 1)[0]?.subagent.childThreadId; + yield* harness.offerAndWait( + workflowNotification({ + uuid: "00000000-0000-4000-8000-000000001391", + status: "completed", + }), + ); + const texts = threadMessages(harness.events, threadId) + .filter((event) => event.message.role === "assistant") + .map((event) => event.message.text); + assert.lengthOf(texts, 81); + assert.equal(texts.at(-1), "FINAL ANSWER", "the answer past the first read is shown"); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000001392", result: "Done." }), + ); + yield* Queue.take(harness.terminalReceipts); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + + it.effect("re-reads a workflow member's transcript after a restart without new items", () => + Effect.gen(function* () { + const runOnce = Effect.fnUntraced(function* (attemptId: string) { const harness = yield* makeWorkflowHarness; - // Exceeds the launch and settle reads combined, at 512 KB each. - const filler = "x".repeat(16 * 1024); NodeFS.writeFileSync( NodePath.join(harness.transcriptDir, "agent-a1.jsonl"), - Array.from({ length: 80 }, (_, index) => - memberAssistantLine(`line-early-${index}`, `msg_early_${index}`, [ - { type: "text", text: `${index} ${filler}` }, - ]), - ).join("") + - memberAssistantLine("line-final", "msg_final", [ - { type: "text", text: "FINAL ANSWER" }, - ]), + memberTranscript, ); yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ threadId: harness.threadId, providerThread: harness.providerThread, now: yield* DateTime.now, - attemptId: RunAttemptId.make("attempt-claude-workflow-member-long-transcript"), + attemptId: RunAttemptId.make(attemptId), text: "Run the alpha workflow.", attachments: [], }), ); - yield* launchWorkflow({ harness, snapshotUuid: "00000000-0000-4000-8000-000000001390" }); + yield* launchWorkflow({ harness, snapshotUuid: "00000000-0000-4000-8000-000000001380" }); const threadId = workflowMemberEvents(harness.events, 1)[0]?.subagent.childThreadId; - yield* harness.offerAndWait( - workflowNotification({ - uuid: "00000000-0000-4000-8000-000000001391", - status: "completed", - }), - ); - const texts = threadMessages(harness.events, threadId) - .filter((event) => event.message.role === "assistant") - .map((event) => event.message.text); - assert.lengthOf(texts, 81); - assert.equal(texts.at(-1), "FINAL ANSWER", "the answer past the first read is shown"); yield* Queue.offer( harness.sdkMessages, - makeResultFrame({ uuid: "00000000-0000-4000-8000-000000001392", result: "Done." }), + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000001381", result: "Done." }), ); yield* Queue.take(harness.terminalReceipts); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), - ), - ), - ), - ); - - it.effect("re-reads a workflow member's transcript after a restart without new items", () => - Effect.scoped( - Effect.gen(function* () { - const runOnce = Effect.fnUntraced(function* (attemptId: string) { - const harness = yield* makeWorkflowHarness; - NodeFS.writeFileSync( - NodePath.join(harness.transcriptDir, "agent-a1.jsonl"), - memberTranscript, - ); - yield* harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make(attemptId), - text: "Run the alpha workflow.", - attachments: [], - }), - ); - yield* launchWorkflow({ harness, snapshotUuid: "00000000-0000-4000-8000-000000001380" }); - const threadId = workflowMemberEvents(harness.events, 1)[0]?.subagent.childThreadId; - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ uuid: "00000000-0000-4000-8000-000000001381", result: "Done." }), - ); - yield* Queue.take(harness.terminalReceipts); - assert.deepEqual(latestMemberThreadItems(harness.events, threadId).map(describeItem), [ - "user_message", - "reasoning: List the files first.", - "command completed: README.md", - "assistant_message", - ]); - return new Set(memberThreadItems(harness.events, threadId).map((item) => item.id)); - }); - const before = yield* runOnce("attempt-claude-workflow-before-restart"); - const after = yield* runOnce("attempt-claude-workflow-after-restart"); - assert.isAtLeast(before.size, 4); - assert.deepEqual( - [...after].filter((id) => !before.has(id)), - [], - ); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + assert.deepEqual(latestMemberThreadItems(harness.events, threadId).map(describeItem), [ + "user_message", + "reasoning: List the files first.", + "command completed: README.md", + "assistant_message", + ]); + return new Set(memberThreadItems(harness.events, threadId).map((item) => item.id)); + }); + const before = yield* runOnce("attempt-claude-workflow-before-restart"); + const after = yield* runOnce("attempt-claude-workflow-after-restart"); + assert.isAtLeast(before.size, 4); + assert.deepEqual( + [...after].filter((id) => !before.has(id)), + [], + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts index 4716ec06175a6..9d340a8c7f1c4 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts @@ -232,70 +232,68 @@ const resumedMcpQueryInput = { } satisfies ClaudeAgentSdkQueryOpenInput; it.effect("reopens a stalled MCP registration before delivering the resumed prompt", () => - Effect.scoped( - Effect.gen(function* () { - const registrationStarted = Promise.withResolvers(); - const disposalStarted = Promise.withResolvers(); - const finishDisposal = Promise.withResolvers(); - const prompts: AsyncIterable[] = []; - const firstClose = vi.fn(); - const secondClose = vi.fn(); - const firstRegistration = vi.fn(() => { - registrationStarted.resolve(); - return new Promise(() => {}); - }); - const secondRegistration = vi.fn(async () => ({ - added: ["t3-code"], - removed: [], - errors: {}, - })); - const querySpy = vi.spyOn(ClaudeSdk, "query").mockImplementation(({ prompt }) => { - assert.notTypeOf(prompt, "string"); - if (typeof prompt === "string") throw new Error("Expected streaming input"); - prompts.push(prompt); - return { - initializationResult: async () => ({}), - setMcpServers: prompts.length === 1 ? firstRegistration : secondRegistration, - close: prompts.length === 1 ? firstClose : secondClose, - [Symbol.asyncDispose]: async () => { - firstClose(); - disposalStarted.resolve(); - await finishDisposal.promise; - }, - } as unknown as ClaudeSdk.Query; - }); - yield* Effect.addFinalizer(() => Effect.sync(() => querySpy.mockRestore())); - const runner = yield* ClaudeAgentSdkQueryRunner; - const opening = yield* runner.open(resumedMcpQueryInput).pipe(Effect.forkChild); - yield* Effect.promise(() => registrationStarted.promise); - yield* TestClock.adjust("90 seconds"); - yield* Effect.promise(() => disposalStarted.promise); - assert.equal(querySpy.mock.calls.length, 1); - finishDisposal.resolve(); - const session = yield* Fiber.join(opening); - yield* Effect.addFinalizer(() => session.close.pipe(Effect.orDie)); - - assert.equal(firstClose.mock.calls.length, 1); - assert.equal(secondClose.mock.calls.length, 0); - assert.equal(querySpy.mock.calls.length, 2); - for (const [call] of querySpy.mock.calls) { - assert.equal(call.options?.resume, FORK_SESSION_ID); - assert.isUndefined(call.options?.mcpServers); - } - assert.deepEqual(secondRegistration.mock.calls, [[resumedMcpQueryInput.options.mcpServers]]); - const message = { - type: "user", - message: { role: "user", content: "Continue where you left off." }, - parent_tool_use_id: null, - session_id: FORK_SESSION_ID, - } satisfies ClaudeSdk.SDKUserMessage; - yield* session.offer(message); - assert.isTrue((yield* Effect.promise(() => prompts[0]![Symbol.asyncIterator]().next())).done); - const delivered = yield* Effect.promise(() => prompts[1]![Symbol.asyncIterator]().next()); - assert.deepEqual(delivered.value, message); - assert.isFalse(delivered.done); - }).pipe(Effect.provide(realQueryRunner)), - ), + Effect.gen(function* () { + const registrationStarted = Promise.withResolvers(); + const disposalStarted = Promise.withResolvers(); + const finishDisposal = Promise.withResolvers(); + const prompts: AsyncIterable[] = []; + const firstClose = vi.fn(); + const secondClose = vi.fn(); + const firstRegistration = vi.fn(() => { + registrationStarted.resolve(); + return new Promise(() => {}); + }); + const secondRegistration = vi.fn(async () => ({ + added: ["t3-code"], + removed: [], + errors: {}, + })); + const querySpy = vi.spyOn(ClaudeSdk, "query").mockImplementation(({ prompt }) => { + assert.notTypeOf(prompt, "string"); + if (typeof prompt === "string") throw new Error("Expected streaming input"); + prompts.push(prompt); + return { + initializationResult: async () => ({}), + setMcpServers: prompts.length === 1 ? firstRegistration : secondRegistration, + close: prompts.length === 1 ? firstClose : secondClose, + [Symbol.asyncDispose]: async () => { + firstClose(); + disposalStarted.resolve(); + await finishDisposal.promise; + }, + } as unknown as ClaudeSdk.Query; + }); + yield* Effect.addFinalizer(() => Effect.sync(() => querySpy.mockRestore())); + const runner = yield* ClaudeAgentSdkQueryRunner; + const opening = yield* runner.open(resumedMcpQueryInput).pipe(Effect.forkChild); + yield* Effect.promise(() => registrationStarted.promise); + yield* TestClock.adjust("90 seconds"); + yield* Effect.promise(() => disposalStarted.promise); + assert.equal(querySpy.mock.calls.length, 1); + finishDisposal.resolve(); + const session = yield* Fiber.join(opening); + yield* Effect.addFinalizer(() => session.close.pipe(Effect.orDie)); + + assert.equal(firstClose.mock.calls.length, 1); + assert.equal(secondClose.mock.calls.length, 0); + assert.equal(querySpy.mock.calls.length, 2); + for (const [call] of querySpy.mock.calls) { + assert.equal(call.options?.resume, FORK_SESSION_ID); + assert.isUndefined(call.options?.mcpServers); + } + assert.deepEqual(secondRegistration.mock.calls, [[resumedMcpQueryInput.options.mcpServers]]); + const message = { + type: "user", + message: { role: "user", content: "Continue where you left off." }, + parent_tool_use_id: null, + session_id: FORK_SESSION_ID, + } satisfies ClaudeSdk.SDKUserMessage; + yield* session.offer(message); + assert.isTrue((yield* Effect.promise(() => prompts[0]![Symbol.asyncIterator]().next())).done); + const delivered = yield* Effect.promise(() => prompts[1]![Symbol.asyncIterator]().next()); + assert.deepEqual(delivered.value, message); + assert.isFalse(delivered.done); + }).pipe(Effect.provide(realQueryRunner)), ); it.effect.each([ @@ -303,262 +301,248 @@ it.effect.each([ { case: "a settings file Claude skipped", errors: [{ file: "settings.json" }], readable: false }, { case: "a reply without a rule list", response: {}, readable: false }, ])("reads Claude's permission rules only when no settings file was skipped: $case", (testCase) => - Effect.scoped( - Effect.gen(function* () { - const rules = [{ behavior: "deny" as const, rule: "mcp__probe__redeploy" }]; - const querySpy = vi.spyOn(ClaudeSdk, "query").mockImplementation( - () => - ({ - close: vi.fn(), - request: async () => ({ - response: - "response" in testCase - ? testCase.response - : { state: { rules, errors: testCase.errors } }, - }), - }) as unknown as ClaudeSdk.Query, - ); - yield* Effect.addFinalizer(() => Effect.sync(() => querySpy.mockRestore())); - const runner = yield* ClaudeAgentSdkQueryRunner; - const { mcpServers: _mcpServers, ...options } = resumedMcpQueryInput.options; - const session = yield* runner.open({ ...resumedMcpQueryInput, options }); - yield* Effect.addFinalizer(() => session.close.pipe(Effect.orDie)); - const exit = yield* Effect.exit(session.listPermissionRules); - if (testCase.readable) assert.deepEqual(exit, Exit.succeed(rules)); - else assert.isTrue(Exit.isFailure(exit)); - }).pipe(Effect.provide(realQueryRunner)), - ), + Effect.gen(function* () { + const rules = [{ behavior: "deny" as const, rule: "mcp__probe__redeploy" }]; + const querySpy = vi.spyOn(ClaudeSdk, "query").mockImplementation( + () => + ({ + close: vi.fn(), + request: async () => ({ + response: + "response" in testCase + ? testCase.response + : { state: { rules, errors: testCase.errors } }, + }), + }) as unknown as ClaudeSdk.Query, + ); + yield* Effect.addFinalizer(() => Effect.sync(() => querySpy.mockRestore())); + const runner = yield* ClaudeAgentSdkQueryRunner; + const { mcpServers: _mcpServers, ...options } = resumedMcpQueryInput.options; + const session = yield* runner.open({ ...resumedMcpQueryInput, options }); + yield* Effect.addFinalizer(() => session.close.pipe(Effect.orDie)); + const exit = yield* Effect.exit(session.listPermissionRules); + if (testCase.readable) assert.deepEqual(exit, Exit.succeed(rules)); + else assert.isTrue(Exit.isFailure(exit)); + }).pipe(Effect.provide(realQueryRunner)), ); it.effect.each([true, false])("only retries after confirmed process exit: %s", (exits) => - Effect.scoped( - Effect.gen(function* () { - const registrationStarted = Promise.withResolvers(); - const cleanupProgress = Promise.withResolvers<"kill" | "retry">(); - const events = new NodeEvents.EventEmitter(); - const stdin = new NodeStream.PassThrough(); - const stdout = new NodeStream.PassThrough(); - const child = { - stdin, - stdout, - killed: false, - exitCode: null, - kill: vi.fn(() => { - cleanupProgress.resolve("kill"); - return true; - }), - on: events.on.bind(events), - once: events.once.bind(events), - off: events.off.bind(events), - } satisfies ClaudeSdk.SpawnedProcess; - const signal = yield* Effect.abortSignal; - const disposed = vi.fn(async () => {}); - const querySpy = vi.spyOn(ClaudeSdk, "query").mockImplementation(({ options }) => { - const first = querySpy.mock.calls.length === 1; - if (first) { - options?.spawnClaudeCodeProcess?.({ command: "claude", args: [], env: {}, signal }); - } else { - cleanupProgress.resolve("retry"); - } - return { + Effect.gen(function* () { + const registrationStarted = Promise.withResolvers(); + const cleanupProgress = Promise.withResolvers<"kill" | "retry">(); + const events = new NodeEvents.EventEmitter(); + const stdin = new NodeStream.PassThrough(); + const stdout = new NodeStream.PassThrough(); + const child = { + stdin, + stdout, + killed: false, + exitCode: null, + kill: vi.fn(() => { + cleanupProgress.resolve("kill"); + return true; + }), + on: events.on.bind(events), + once: events.once.bind(events), + off: events.off.bind(events), + } satisfies ClaudeSdk.SpawnedProcess; + const signal = yield* Effect.abortSignal; + const disposed = vi.fn(async () => {}); + const querySpy = vi.spyOn(ClaudeSdk, "query").mockImplementation(({ options }) => { + const first = querySpy.mock.calls.length === 1; + if (first) { + options?.spawnClaudeCodeProcess?.({ command: "claude", args: [], env: {}, signal }); + } else { + cleanupProgress.resolve("retry"); + } + return { + initializationResult: async () => ({}), + setMcpServers: () => { + registrationStarted.resolve(); + return first + ? new Promise(() => {}) + : Promise.resolve({ added: ["t3-code"], removed: [], errors: {} }); + }, + close: vi.fn(), + [Symbol.asyncDispose]: disposed, + } as unknown as ClaudeSdk.Query; + }); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + querySpy.mockRestore(); + stdin.destroy(); + stdout.destroy(); + }), + ); + const runner = yield* ClaudeAgentSdkQueryRunner; + const opening = yield* runner + .open({ + ...resumedMcpQueryInput, + options: { ...resumedMcpQueryInput.options, spawnClaudeCodeProcess: () => child }, + }) + .pipe(Effect.exit, Effect.forkChild); + yield* Effect.promise(() => registrationStarted.promise); + yield* TestClock.adjust("90 seconds"); + + assert.equal(yield* Effect.promise(() => cleanupProgress.promise), "kill"); + assert.equal(querySpy.mock.calls.length, 1); + assert.deepEqual(child.kill.mock.calls, [["SIGKILL"]]); + if (exits) events.emit("exit", null, "SIGKILL"); + else yield* TestClock.adjust("10 seconds"); + const result = yield* Fiber.join(opening); + assert.equal(Exit.isSuccess(result), exits); + if (Exit.isSuccess(result)) yield* result.value.close; + assert.equal(querySpy.mock.calls.length, exits ? 2 : 1); + assert.equal(disposed.mock.calls.length, 1); + }).pipe(Effect.provide(realQueryRunner)), +); + +it.effect("stops retrying MCP registration after two timeouts and reports the cause", () => + Effect.gen(function* () { + const firstRegistration = Promise.withResolvers(); + const secondRegistration = Promise.withResolvers(); + const disposed = vi.fn(async () => {}); + const querySpy = vi.spyOn(ClaudeSdk, "query").mockImplementation(() => { + const started = querySpy.mock.calls.length === 1 ? firstRegistration : secondRegistration; + return { + initializationResult: async () => ({}), + setMcpServers: () => { + started.resolve(); + return new Promise(() => {}); + }, + [Symbol.asyncDispose]: disposed, + } as unknown as ClaudeSdk.Query; + }); + yield* Effect.addFinalizer(() => Effect.sync(() => querySpy.mockRestore())); + const runner = yield* ClaudeAgentSdkQueryRunner; + const opening = yield* runner.open(resumedMcpQueryInput).pipe(Effect.result, Effect.forkChild); + yield* Effect.promise(() => firstRegistration.promise); + yield* TestClock.adjust("90 seconds"); + yield* Effect.promise(() => secondRegistration.promise); + yield* TestClock.adjust("90 seconds"); + const result = yield* Fiber.join(opening); + + assert.equal(result._tag, "Failure"); + if (result._tag !== "Failure") return; + assert.equal(querySpy.mock.calls.length, 2); + assert.equal(disposed.mock.calls.length, 2); + assert.equal( + makeProviderFailure({ + cause: { _tag: "ProviderAdapterTurnStartError", cause: result.failure }, + }).message, + "Claude timed out while registering its MCP tools. Retry the turn; if it keeps failing, check Claude and the server logs.", + ); + }).pipe(Effect.provide(realQueryRunner)), +); + +it.effect("cancels a stalled registration without retrying or retaining its prompt queue", () => + Effect.gen(function* () { + const registrationStarted = Promise.withResolvers(); + const disposed = vi.fn(async () => {}); + const querySpy = vi.spyOn(ClaudeSdk, "query").mockImplementation( + () => + ({ initializationResult: async () => ({}), setMcpServers: () => { registrationStarted.resolve(); - return first - ? new Promise(() => {}) - : Promise.resolve({ added: ["t3-code"], removed: [], errors: {} }); + return new Promise(() => {}); }, - close: vi.fn(), [Symbol.asyncDispose]: disposed, - } as unknown as ClaudeSdk.Query; - }); - yield* Effect.addFinalizer(() => - Effect.sync(() => { - querySpy.mockRestore(); - stdin.destroy(); - stdout.destroy(); - }), - ); - const runner = yield* ClaudeAgentSdkQueryRunner; - const opening = yield* runner - .open({ - ...resumedMcpQueryInput, - options: { ...resumedMcpQueryInput.options, spawnClaudeCodeProcess: () => child }, - }) - .pipe(Effect.exit, Effect.forkChild); - yield* Effect.promise(() => registrationStarted.promise); - yield* TestClock.adjust("90 seconds"); - - assert.equal(yield* Effect.promise(() => cleanupProgress.promise), "kill"); - assert.equal(querySpy.mock.calls.length, 1); - assert.deepEqual(child.kill.mock.calls, [["SIGKILL"]]); - if (exits) events.emit("exit", null, "SIGKILL"); - else yield* TestClock.adjust("10 seconds"); - const result = yield* Fiber.join(opening); - assert.equal(Exit.isSuccess(result), exits); - if (Exit.isSuccess(result)) yield* result.value.close; - assert.equal(querySpy.mock.calls.length, exits ? 2 : 1); - assert.equal(disposed.mock.calls.length, 1); - }).pipe(Effect.provide(realQueryRunner)), - ), + }) as unknown as ClaudeSdk.Query, + ); + yield* Effect.addFinalizer(() => Effect.sync(() => querySpy.mockRestore())); + const runner = yield* ClaudeAgentSdkQueryRunner; + const opening = yield* runner.open(resumedMcpQueryInput).pipe(Effect.forkChild); + yield* Effect.promise(() => registrationStarted.promise); + yield* Fiber.interrupt(opening); + + assert.equal(querySpy.mock.calls.length, 1); + assert.equal(disposed.mock.calls.length, 1); + const prompt = querySpy.mock.calls[0]![0].prompt; + if (typeof prompt === "string") throw new Error("Expected streaming input"); + assert.isTrue((yield* Effect.promise(() => prompt[Symbol.asyncIterator]().next())).done); + }).pipe(Effect.provide(realQueryRunner)), ); -it.effect("stops retrying MCP registration after two timeouts and reports the cause", () => - Effect.scoped( - Effect.gen(function* () { - const firstRegistration = Promise.withResolvers(); - const secondRegistration = Promise.withResolvers(); - const disposed = vi.fn(async () => {}); - const querySpy = vi.spyOn(ClaudeSdk, "query").mockImplementation(() => { - const started = querySpy.mock.calls.length === 1 ? firstRegistration : secondRegistration; - return { +it.effect("does not reuse a fresh session ID after MCP registration times out", () => + Effect.gen(function* () { + const registrationStarted = Promise.withResolvers(); + const disposed = vi.fn(async () => {}); + const querySpy = vi.spyOn(ClaudeSdk, "query").mockImplementation( + () => + ({ initializationResult: async () => ({}), setMcpServers: () => { - started.resolve(); + registrationStarted.resolve(); return new Promise(() => {}); }, [Symbol.asyncDispose]: disposed, - } as unknown as ClaudeSdk.Query; - }); - yield* Effect.addFinalizer(() => Effect.sync(() => querySpy.mockRestore())); - const runner = yield* ClaudeAgentSdkQueryRunner; - const opening = yield* runner - .open(resumedMcpQueryInput) - .pipe(Effect.result, Effect.forkChild); - yield* Effect.promise(() => firstRegistration.promise); - yield* TestClock.adjust("90 seconds"); - yield* Effect.promise(() => secondRegistration.promise); - yield* TestClock.adjust("90 seconds"); - const result = yield* Fiber.join(opening); - - assert.equal(result._tag, "Failure"); - if (result._tag !== "Failure") return; - assert.equal(querySpy.mock.calls.length, 2); - assert.equal(disposed.mock.calls.length, 2); - assert.equal( - makeProviderFailure({ - cause: { _tag: "ProviderAdapterTurnStartError", cause: result.failure }, - }).message, - "Claude timed out while registering its MCP tools. Retry the turn; if it keeps failing, check Claude and the server logs.", - ); - }).pipe(Effect.provide(realQueryRunner)), - ), -); - -it.effect("cancels a stalled registration without retrying or retaining its prompt queue", () => - Effect.scoped( - Effect.gen(function* () { - const registrationStarted = Promise.withResolvers(); - const disposed = vi.fn(async () => {}); - const querySpy = vi.spyOn(ClaudeSdk, "query").mockImplementation( - () => - ({ - initializationResult: async () => ({}), - setMcpServers: () => { - registrationStarted.resolve(); - return new Promise(() => {}); - }, - [Symbol.asyncDispose]: disposed, - }) as unknown as ClaudeSdk.Query, - ); - yield* Effect.addFinalizer(() => Effect.sync(() => querySpy.mockRestore())); - const runner = yield* ClaudeAgentSdkQueryRunner; - const opening = yield* runner.open(resumedMcpQueryInput).pipe(Effect.forkChild); - yield* Effect.promise(() => registrationStarted.promise); - yield* Fiber.interrupt(opening); - - assert.equal(querySpy.mock.calls.length, 1); - assert.equal(disposed.mock.calls.length, 1); - const prompt = querySpy.mock.calls[0]![0].prompt; - if (typeof prompt === "string") throw new Error("Expected streaming input"); - assert.isTrue((yield* Effect.promise(() => prompt[Symbol.asyncIterator]().next())).done); - }).pipe(Effect.provide(realQueryRunner)), - ), -); - -it.effect("does not reuse a fresh session ID after MCP registration times out", () => - Effect.scoped( - Effect.gen(function* () { - const registrationStarted = Promise.withResolvers(); - const disposed = vi.fn(async () => {}); - const querySpy = vi.spyOn(ClaudeSdk, "query").mockImplementation( - () => - ({ - initializationResult: async () => ({}), - setMcpServers: () => { - registrationStarted.resolve(); - return new Promise(() => {}); - }, - [Symbol.asyncDispose]: disposed, - }) as unknown as ClaudeSdk.Query, - ); - yield* Effect.addFinalizer(() => Effect.sync(() => querySpy.mockRestore())); - const runner = yield* ClaudeAgentSdkQueryRunner; - const { resume, ...options } = resumedMcpQueryInput.options; - const opening = yield* runner - .open({ - ...resumedMcpQueryInput, - options: { ...options, sessionId: resume }, - }) - .pipe(Effect.result, Effect.forkChild); - yield* Effect.promise(() => registrationStarted.promise); - yield* TestClock.adjust("90 seconds"); - const result = yield* Fiber.join(opening); - - assert.equal(result._tag, "Failure"); - assert.equal(querySpy.mock.calls.length, 1); - assert.equal(disposed.mock.calls.length, 1); - }).pipe(Effect.provide(realQueryRunner)), - ), + }) as unknown as ClaudeSdk.Query, + ); + yield* Effect.addFinalizer(() => Effect.sync(() => querySpy.mockRestore())); + const runner = yield* ClaudeAgentSdkQueryRunner; + const { resume, ...options } = resumedMcpQueryInput.options; + const opening = yield* runner + .open({ + ...resumedMcpQueryInput, + options: { ...options, sessionId: resume }, + }) + .pipe(Effect.result, Effect.forkChild); + yield* Effect.promise(() => registrationStarted.promise); + yield* TestClock.adjust("90 seconds"); + const result = yield* Fiber.join(opening); + + assert.equal(result._tag, "Failure"); + assert.equal(querySpy.mock.calls.length, 1); + assert.equal(disposed.mock.calls.length, 1); + }).pipe(Effect.provide(realQueryRunner)), ); it.effect.each([ ["opens after a slow CLI boot", "120 seconds", true], ["times out a CLI that never boots", "5 minutes", false], ] as const)("%s", ([, bootWait, boots]) => - Effect.scoped( - Effect.gen(function* () { - const waiting = Promise.withResolvers(); - const booted = Promise.withResolvers(); - const querySpy = vi.spyOn(ClaudeSdk, "query").mockImplementation( - () => - ({ - initializationResult: () => { - waiting.resolve(); - return booted.promise; - }, - // The CLI answers control requests only after it has booted. - setMcpServers: async () => { - waiting.resolve(); - await booted.promise; - return { added: ["t3-code"], removed: [], errors: {} }; - }, - close: vi.fn(), - [Symbol.asyncDispose]: async () => {}, - }) as unknown as ClaudeSdk.Query, + Effect.gen(function* () { + const waiting = Promise.withResolvers(); + const booted = Promise.withResolvers(); + const querySpy = vi.spyOn(ClaudeSdk, "query").mockImplementation( + () => + ({ + initializationResult: () => { + waiting.resolve(); + return booted.promise; + }, + // The CLI answers control requests only after it has booted. + setMcpServers: async () => { + waiting.resolve(); + await booted.promise; + return { added: ["t3-code"], removed: [], errors: {} }; + }, + close: vi.fn(), + [Symbol.asyncDispose]: async () => {}, + }) as unknown as ClaudeSdk.Query, + ); + yield* Effect.addFinalizer(() => Effect.sync(() => querySpy.mockRestore())); + const runner = yield* ClaudeAgentSdkQueryRunner; + const { resume, ...options } = resumedMcpQueryInput.options; + const opening = yield* runner + .open({ ...resumedMcpQueryInput, options: { ...options, sessionId: resume } }) + .pipe(Effect.result, Effect.forkChild); + yield* Effect.promise(() => waiting.promise); + yield* TestClock.adjust(bootWait); + if (boots) booted.resolve(); + const result = yield* Fiber.join(opening); + + assert.equal(result._tag, boots ? "Success" : "Failure"); + if (result._tag === "Success") yield* result.success.close; + else + assert.equal( + makeProviderFailure({ + cause: { _tag: "ProviderAdapterTurnStartError", cause: result.failure }, + }).message, + "Claude timed out while starting up. Retry the turn; if it keeps failing, check Claude and the server logs.", ); - yield* Effect.addFinalizer(() => Effect.sync(() => querySpy.mockRestore())); - const runner = yield* ClaudeAgentSdkQueryRunner; - const { resume, ...options } = resumedMcpQueryInput.options; - const opening = yield* runner - .open({ ...resumedMcpQueryInput, options: { ...options, sessionId: resume } }) - .pipe(Effect.result, Effect.forkChild); - yield* Effect.promise(() => waiting.promise); - yield* TestClock.adjust(bootWait); - if (boots) booted.resolve(); - const result = yield* Fiber.join(opening); - - assert.equal(result._tag, boots ? "Success" : "Failure"); - if (result._tag === "Success") yield* result.success.close; - else - assert.equal( - makeProviderFailure({ - cause: { _tag: "ProviderAdapterTurnStartError", cause: result.failure }, - }).message, - "Claude timed out while starting up. Retry the turn; if it keeps failing, check Claude and the server logs.", - ); - }).pipe(Effect.provide(realQueryRunner)), - ), + }).pipe(Effect.provide(realQueryRunner)), ); function fakeClaudeCli(script: string): string { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.test.ts index 4c29f71e1bda1..51c9a7848f100 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeTaskOutputTail.test.ts @@ -102,32 +102,30 @@ describe("tailClaudeTaskOutput", () => { ); it.effect("reads what was written last when the tool result stops it", () => - Effect.scoped( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const { root, tasks, file } = yield* makeTaskFile; - yield* fileSystem.makeDirectory(tasks, { recursive: true }); - yield* fileSystem.writeFileString(file, "one\n"); - const chunks: Array = []; - const stop = yield* Deferred.make(); - const first = yield* Deferred.make(); - const fiber = yield* tailClaudeTaskOutput({ - root, - sessionId: SESSION_ID, - taskId: TASK_ID, - stop, - onChunk: (chunk) => - Effect.sync(() => chunks.push(chunk)).pipe( - Effect.andThen(Deferred.succeed(first, undefined)), - ), - }).pipe(Effect.forkScoped); - yield* Deferred.await(first); - yield* fileSystem.writeFileString(file, "one\ntwo\n"); - yield* Deferred.succeed(stop, undefined); - yield* Fiber.join(fiber); - assert.deepEqual(chunks, ["one\n", "two\n"]); - }).pipe(Effect.provide(NodeServices.layer)), - ), + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const { root, tasks, file } = yield* makeTaskFile; + yield* fileSystem.makeDirectory(tasks, { recursive: true }); + yield* fileSystem.writeFileString(file, "one\n"); + const chunks: Array = []; + const stop = yield* Deferred.make(); + const first = yield* Deferred.make(); + const fiber = yield* tailClaudeTaskOutput({ + root, + sessionId: SESSION_ID, + taskId: TASK_ID, + stop, + onChunk: (chunk) => + Effect.sync(() => chunks.push(chunk)).pipe( + Effect.andThen(Deferred.succeed(first, undefined)), + ), + }).pipe(Effect.forkScoped); + yield* Deferred.await(first); + yield* fileSystem.writeFileString(file, "one\ntwo\n"); + yield* Deferred.succeed(stop, undefined); + yield* Fiber.join(fiber); + assert.deepEqual(chunks, ["one\n", "two\n"]); + }).pipe(Effect.provide(NodeServices.layer)), ); }); diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 34b2fa746ea34..09ec3034a9bc9 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -1930,7 +1930,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { ); }).pipe(Effect.provide(CodexReplay.layerReplayWithDriver(driver))); } - }).pipe(Effect.scoped), + }), ); it.effect("waits for native start before interrupting an acknowledged queued turn", () => @@ -2251,55 +2251,351 @@ describe("CodexAdapterV2 post-settle continuation", () => { ); it.effect("keeps an asynchronous Codex question actionable after the turn completes", () => - Effect.scoped( - Effect.gen(function* () { - const nativeThreadId = "async-question-thread"; - const nativeTurnId = "async-question-turn"; - const usage = { - totalTokens: 15, + Effect.gen(function* () { + const nativeThreadId = "async-question-thread"; + const nativeTurnId = "async-question-turn"; + const usage = { + totalTokens: 15, + inputTokens: 10, + cachedInputTokens: 2, + outputTokens: 5, + reasoningOutputTokens: 1, + }; + const transcript = makeCodexReplayTranscript({ + scenario: "async-question-and-billed-usage", + entries: [ + ...codexReplayPreamble({ + nativeThreadId, + nativeTurnId, + prompt: "Continue while I decide.", + }), + { + type: "emit_inbound", + label: "question", + frame: { + method: "item/completed", + params: { + threadId: nativeThreadId, + turnId: nativeTurnId, + item: { + type: "agentMessage", + id: "async-question-item", + text: "Which branch?", + delivery: "async", + questions: [{ title: "Which branch?", options: ["main", "dev"] }], + }, + }, + }, + }, + { + type: "emit_inbound", + label: "usage", + frame: { + method: "thread/tokenUsage/updated", + params: { + threadId: nativeThreadId, + turnId: nativeTurnId, + tokenUsage: { total: usage, last: usage, modelContextWindow: 200_000 }, + }, + }, + }, + { + type: "emit_inbound", + label: "complete", + frame: { + method: "turn/completed", + params: { + threadId: nativeThreadId, + turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), + }, + }, + }, + ], + }); + const harness = yield* makeCodexReplayHarness(transcript); + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("async-question-attempt"), + text: "Continue while I decide.", + }), + ); + yield* harness.firstTerminal; + const requests = harness.events.flatMap((event) => + event.type === "runtime_request.updated" ? [event.runtimeRequest] : [], + ); + assert.lengthOf(requests, 1); + assert.equal(requests[0]?.status, "pending"); + assert.deepEqual(requests[0]?.responseCapability, { type: "message" }); + const questionItem = harness.events.find( + (event) => + event.type === "turn_item.updated" && event.turnItem.type === "user_input_request", + ); + assert.equal(questionItem?.type, "turn_item.updated"); + if ( + questionItem?.type === "turn_item.updated" && + questionItem.turnItem.type === "user_input_request" + ) { + assert.deepEqual( + questionItem.turnItem.questions[0]?.options.map((option) => option.label), + ["main", "dev"], + ); + assert.equal(questionItem.turnItem.responseMode, "message"); + } + const questionNode = harness.events.find( + (event) => event.type === "node.updated" && event.node.id === requests[0]?.nodeId, + ); + assert.equal(questionNode?.type === "node.updated" && questionNode.node.countsForRun, false); + const contextReport = harness.events.find( + (event) => + event.type === "provider_turn.updated" && event.providerTurn.tokenUsage !== undefined, + ); + assert.equal(contextReport?.type, "provider_turn.updated"); + if (contextReport?.type === "provider_turn.updated") { + assert.equal( + contextReport.providerTurn.runAttemptId, + RunAttemptId.make("async-question-attempt"), + ); + assert.equal(contextReport.providerTurn.providerThreadId, harness.providerThread.id); + assert.equal(contextReport.providerTurn.tokenUsage?.usedTokens, 15); + assert.equal(contextReport.providerTurn.tokenUsage?.maxTokens, 200_000); + } + const completed = harness.events.find( + (event) => + event.type === "provider_turn.updated" && event.providerTurn.status === "completed", + ); + assert.equal(completed?.type, "provider_turn.updated"); + if (completed?.type === "provider_turn.updated") { + assert.deepEqual(completed.providerTurn.turnTokenUsage, { + usageStatus: "complete", + usageScope: "main_agent", + hasSubagents: false, inputTokens: 10, cachedInputTokens: 2, outputTokens: 5, - reasoningOutputTokens: 1, - }; - const transcript = makeCodexReplayTranscript({ - scenario: "async-question-and-billed-usage", - entries: [ - ...codexReplayPreamble({ - nativeThreadId, - nativeTurnId, - prompt: "Continue while I decide.", - }), - { - type: "emit_inbound", - label: "question", - frame: { - method: "item/completed", - params: { - threadId: nativeThreadId, - turnId: nativeTurnId, - item: { - type: "agentMessage", - id: "async-question-item", - text: "Which branch?", - delivery: "async", - questions: [{ title: "Which branch?", options: ["main", "dev"] }], + reasoningTokens: 1, + }); + } + assert.isEmpty(assistantMessages(harness.events)); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + + it.effect("returns a Codex question's answer under its native question id", () => + Effect.gen(function* () { + const nativeThreadId = "question-id-thread"; + const nativeTurnId = "question-id-turn"; + const transcript = makeCodexReplayTranscript({ + scenario: "codex-question-native-id", + entries: [ + ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "Pick one." }), + { + type: "emit_inbound", + label: "question", + frame: { + id: 100, + method: "item/tool/requestUserInput", + params: { + threadId: nativeThreadId, + turnId: nativeTurnId, + itemId: "question-id-item", + isBlocking: true, + questions: [ + { + id: " choice ", + header: "Choice", + question: "Which option?", + options: [{ label: "A", description: "Option A" }], }, - }, + ], }, }, - { - type: "emit_inbound", - label: "usage", - frame: { - method: "thread/tokenUsage/updated", - params: { - threadId: nativeThreadId, - turnId: nativeTurnId, - tokenUsage: { total: usage, last: usage, modelContextWindow: 200_000 }, - }, + }, + { + type: "expect_outbound", + label: "answer", + frame: { id: 100, result: { answers: { " choice ": { answers: ["A"] } } } }, + }, + { + type: "emit_inbound", + label: "complete", + frame: { + method: "turn/completed", + params: { + threadId: nativeThreadId, + turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), + }, + }, + }, + ], + }); + const harness = yield* makeCodexReplayHarness(transcript); + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("question-id-attempt"), + text: "Pick one.", + }), + ); + const questionItem = () => + harness.events.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "user_input_request" + ? [event.turnItem] + : [], + )[0]; + const request = () => + harness.events.flatMap((event) => + event.type === "runtime_request.updated" ? [event.runtimeRequest] : [], + )[0]; + yield* harness.until(() => questionItem() !== undefined && request() !== undefined); + const publishedId = questionItem()!.questions[0]!.id; + yield* harness.runtime.respondToRuntimeRequest({ + requestId: request()!.id, + answers: { [publishedId]: ["A"] }, + }); + yield* harness.firstTerminal; + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + + it.effect("records the model Codex reports on the root provider turn", () => + Effect.gen(function* () { + const nativeThreadId = "reported-model-thread"; + const nativeTurnId = "reported-model-turn"; + const transcript = makeCodexReplayTranscript({ + scenario: "codex-reported-model", + entries: [ + ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "say hi" }), + { + type: "emit_inbound", + label: "rerouted", + frame: { + method: "model/rerouted", + params: { + threadId: nativeThreadId, + turnId: nativeTurnId, + fromModel: "gpt-6-astra", + toModel: "gpt-5.6-sol", + reason: "highRiskCyberActivity", + }, + }, + }, + { + type: "emit_inbound", + label: "complete", + frame: { + method: "turn/completed", + params: { + threadId: nativeThreadId, + turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), + }, + }, + }, + ], + }); + const harness = yield* makeCodexReplayHarness(transcript); + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("reported-model-attempt"), + text: "say hi", + }), + ); + yield* harness.firstTerminal; + const completed = harness.events.find( + (event) => + event.type === "provider_turn.updated" && event.providerTurn.status === "completed", + ); + assert.equal(completed?.type, "provider_turn.updated"); + if (completed?.type !== "provider_turn.updated") return; + assert.deepEqual(completed.providerTurn.turnTokenUsage?.models, ["gpt-5.6-sol"]); + assert.equal(completed.providerTurn.model, "gpt-5.6-sol"); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + + it.effect.each(["before", "during"] as const)( + "surfaces a user MCP server that fails to start %s the turn", + (timing) => + Effect.gen(function* () { + const nativeThreadId = "mcp-startup-failed-thread"; + const nativeTurnId = "mcp-startup-failed-turn"; + const startupError = + "MCP client for `example_mcp` failed to start: MCP startup failed: handshaking with MCP server failed"; + const startup: Array = [ + { + type: "emit_inbound", + label: "mcp-starting", + frame: { + method: "mcpServer/startupStatus/updated", + params: { + threadId: nativeThreadId, + name: "example_mcp", + status: "starting", + error: null, + failureReason: null, + }, + }, + }, + { + type: "emit_inbound", + label: "mcp-failed", + frame: { + method: "mcpServer/startupStatus/updated", + params: { + threadId: nativeThreadId, + name: "example_mcp", + status: "failed", + error: startupError, + failureReason: null, }, }, + }, + ]; + const preamble = codexReplayPreamble({ + nativeThreadId, + nativeTurnId, + prompt: "use example_mcp", + }); + const turnStart = preamble.findIndex( + (entry) => entry.type === "expect_outbound" && entry.label === "turn/start", + ); + const transcript = makeCodexReplayTranscript({ + scenario: "codex-mcp-startup-failed", + entries: [ + ...(timing === "before" + ? [...preamble.slice(0, turnStart), ...startup, ...preamble.slice(turnStart)] + : [...preamble, ...startup]), { type: "emit_inbound", label: "complete", @@ -2319,70 +2615,16 @@ describe("CodexAdapterV2 post-settle continuation", () => { threadId: harness.threadId, providerThread: harness.providerThread, now: yield* DateTime.now, - attemptId: RunAttemptId.make("async-question-attempt"), - text: "Continue while I decide.", + attemptId: RunAttemptId.make("mcp-startup-failed-attempt"), + text: "use example_mcp", }), ); yield* harness.firstTerminal; - const requests = harness.events.flatMap((event) => - event.type === "runtime_request.updated" ? [event.runtimeRequest] : [], - ); - assert.lengthOf(requests, 1); - assert.equal(requests[0]?.status, "pending"); - assert.deepEqual(requests[0]?.responseCapability, { type: "message" }); - const questionItem = harness.events.find( - (event) => - event.type === "turn_item.updated" && event.turnItem.type === "user_input_request", - ); - assert.equal(questionItem?.type, "turn_item.updated"); - if ( - questionItem?.type === "turn_item.updated" && - questionItem.turnItem.type === "user_input_request" - ) { - assert.deepEqual( - questionItem.turnItem.questions[0]?.options.map((option) => option.label), - ["main", "dev"], - ); - assert.equal(questionItem.turnItem.responseMode, "message"); - } - const questionNode = harness.events.find( - (event) => event.type === "node.updated" && event.node.id === requests[0]?.nodeId, - ); - assert.equal( - questionNode?.type === "node.updated" && questionNode.node.countsForRun, - false, - ); - const contextReport = harness.events.find( - (event) => - event.type === "provider_turn.updated" && event.providerTurn.tokenUsage !== undefined, - ); - assert.equal(contextReport?.type, "provider_turn.updated"); - if (contextReport?.type === "provider_turn.updated") { - assert.equal( - contextReport.providerTurn.runAttemptId, - RunAttemptId.make("async-question-attempt"), - ); - assert.equal(contextReport.providerTurn.providerThreadId, harness.providerThread.id); - assert.equal(contextReport.providerTurn.tokenUsage?.usedTokens, 15); - assert.equal(contextReport.providerTurn.tokenUsage?.maxTokens, 200_000); - } - const completed = harness.events.find( - (event) => - event.type === "provider_turn.updated" && event.providerTurn.status === "completed", - ); - assert.equal(completed?.type, "provider_turn.updated"); - if (completed?.type === "provider_turn.updated") { - assert.deepEqual(completed.providerTurn.turnTokenUsage, { - usageStatus: "complete", - usageScope: "main_agent", - hasSubagents: false, - inputTokens: 10, - cachedInputTokens: 2, - outputTokens: 5, - reasoningTokens: 1, - }); - } - assert.isEmpty(assistantMessages(harness.events)); + const surfaced = harness.events.filter((event) => { + const serialized = JSON.stringify(event); + return serialized.includes("example_mcp") && serialized.includes("failed to start"); + }); + assert.isNotEmpty(surfaced); }).pipe( Effect.provide( Layer.mergeAll( @@ -2393,618 +2635,582 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ), ), - ), ); - it.effect("returns a Codex question's answer under its native question id", () => - Effect.scoped( - Effect.gen(function* () { - const nativeThreadId = "question-id-thread"; - const nativeTurnId = "question-id-turn"; - const transcript = makeCodexReplayTranscript({ - scenario: "codex-question-native-id", - entries: [ - ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "Pick one." }), - { - type: "emit_inbound", - label: "question", - frame: { - id: 100, - method: "item/tool/requestUserInput", - params: { - threadId: nativeThreadId, - turnId: nativeTurnId, - itemId: "question-id-item", - isBlocking: true, - questions: [ - { - id: " choice ", - header: "Choice", - question: "Which option?", - options: [{ label: "A", description: "Option A" }], - }, - ], - }, - }, - }, - { - type: "expect_outbound", - label: "answer", - frame: { id: 100, result: { answers: { " choice ": { answers: ["A"] } } } }, + it.effect("stops asking for commands the user allowed for the session", () => + Effect.gen(function* () { + const nativeThreadId = "session-approval-thread"; + const turnIds = [ + "session-approval-turn-1", + "session-approval-turn-2", + "session-approval-turn-3", + ]; + const turnPolicies = { + "approval-required": { + approvalPolicy: "untrusted", + approvalsReviewer: "user", + sandboxPolicy: { type: "readOnly" }, + }, + "full-access": { + approvalPolicy: "never", + approvalsReviewer: "user", + sandboxPolicy: { type: "dangerFullAccess" }, + }, + } as const; + const turnModes = ["approval-required", "full-access", "approval-required"] as const; + const turnText = (index: number) => `Review round ${index + 1}.`; + const startTurn = (index: number): ReadonlyArray => [ + { + type: "expect_outbound", + label: `turn ${index + 1} turn/start`, + frame: { + id: 3 + index, + method: "turn/start", + params: { + threadId: nativeThreadId, + input: [{ type: "text", text: turnText(index) }], + cwd: "/workspace", + model: "gpt-5.4", + ...turnPolicies[turnModes[index]!], + summary: "auto", + collaborationMode: { mode: "default", settings: { model: "gpt-5.4" } }, }, - { - type: "emit_inbound", - label: "complete", - frame: { - method: "turn/completed", - params: { - threadId: nativeThreadId, - turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), - }, - }, + }, + }, + { + type: "emit_inbound", + label: `turn ${index + 1} turn/start`, + frame: { + id: 3 + index, + result: { turn: makeCodexReplayTurn({ id: turnIds[index]!, status: "inProgress" }) }, + }, + }, + turnEvent("turn/started", turnIds[index]!), + ]; + const approval = (input: { + readonly id: number; + readonly turnId: string; + readonly kind: "commandExecution" | "fileChange"; + readonly itemId: string; + readonly decision: string; + }): ReadonlyArray => [ + { + type: "emit_inbound", + label: `${input.itemId} approval`, + frame: { + id: input.id, + method: `item/${input.kind}/requestApproval`, + params: { + threadId: nativeThreadId, + turnId: input.turnId, + itemId: input.itemId, + startedAtMs: 1782622441000, + ...(input.kind === "commandExecution" ? { command: `cat ${input.itemId}` } : {}), }, - ], - }); - const harness = yield* makeCodexReplayHarness(transcript); - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("question-id-attempt"), - text: "Pick one.", + }, + }, + { + type: "expect_outbound", + label: `${input.itemId} decision`, + frame: { id: input.id, result: { decision: input.decision } }, + }, + ]; + const turnEvent = ( + method: "turn/started" | "turn/completed", + turnId: string, + ): CodexReplay.CodexAppServerReplayEntry => ({ + type: "emit_inbound", + label: `${turnId} ${method}`, + frame: { + method, + params: { + threadId: nativeThreadId, + turn: makeCodexReplayTurn({ + id: turnId, + status: method === "turn/started" ? "inProgress" : "completed", + }), + }, + }, + }); + const [patchRequest, patchDecision] = approval({ + id: 102, + turnId: turnIds[0]!, + kind: "fileChange", + itemId: "first-patch", + decision: "acceptForSession", + }); + const transcript = makeCodexReplayTranscript({ + scenario: "codex-session-approvals", + entries: [ + ...codexReplayPreamble({ + nativeThreadId, + nativeTurnId: turnIds[0]!, + prompt: turnText(0), + }).slice(0, -3), + ...startTurn(0), + ...approval({ + id: 100, + turnId: turnIds[0]!, + kind: "commandExecution", + itemId: "first-command", + decision: "acceptForSession", }), - ); - const questionItem = () => + patchRequest!, + ...approval({ + id: 101, + turnId: turnIds[0]!, + kind: "commandExecution", + itemId: "second-command", + decision: "acceptForSession", + }), + turnEvent("turn/completed", turnIds[0]!), + ...startTurn(1), + patchDecision!, + turnEvent("turn/completed", turnIds[1]!), + ...startTurn(2), + ...approval({ + id: 103, + turnId: turnIds[2]!, + kind: "commandExecution", + itemId: "third-command", + decision: "accept", + }), + ...approval({ + id: 104, + turnId: turnIds[2]!, + kind: "fileChange", + itemId: "second-patch", + decision: "accept", + }), + turnEvent("turn/completed", turnIds[2]!), + ], + }); + const harness = yield* makeCodexReplayHarness(transcript); + const requests = () => [ + ...new Map( harness.events.flatMap((event) => - event.type === "turn_item.updated" && event.turnItem.type === "user_input_request" - ? [event.turnItem] + event.type === "runtime_request.updated" + ? [[event.runtimeRequest.id, event.runtimeRequest] as const] : [], - )[0]; - const request = () => - harness.events.flatMap((event) => - event.type === "runtime_request.updated" ? [event.runtimeRequest] : [], - )[0]; - yield* harness.until(() => questionItem() !== undefined && request() !== undefined); - const publishedId = questionItem()!.questions[0]!.id; - yield* harness.runtime.respondToRuntimeRequest({ - requestId: request()!.id, - answers: { [publishedId]: ["A"] }, - }); - yield* harness.firstTerminal; - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, ), - ), - ), - ), - ); - - it.effect("records the model Codex reports on the root provider turn", () => - Effect.scoped( - Effect.gen(function* () { - const nativeThreadId = "reported-model-thread"; - const nativeTurnId = "reported-model-turn"; - const transcript = makeCodexReplayTranscript({ - scenario: "codex-reported-model", - entries: [ - ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "say hi" }), - { - type: "emit_inbound", - label: "rerouted", - frame: { - method: "model/rerouted", - params: { - threadId: nativeThreadId, - turnId: nativeTurnId, - fromModel: "gpt-6-astra", - toModel: "gpt-5.6-sol", - reason: "highRiskCyberActivity", - }, - }, - }, - { - type: "emit_inbound", - label: "complete", - frame: { - method: "turn/completed", - params: { - threadId: nativeThreadId, - turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), - }, - }, - }, - ], + ).values(), + ]; + const answer = ( + count: number, + kind: "command" | "file-change", + decision: ProviderApprovalDecision, + ) => + Effect.gen(function* () { + yield* harness.until(() => requests().length === count); + const request = requests()[count - 1]!; + assert.equal(request.kind, kind); + yield* harness.runtime.respondToRuntimeRequest({ requestId: request.id, decision }); }); - const harness = yield* makeCodexReplayHarness(transcript); - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("reported-model-attempt"), - text: "say hi", - }), - ); - yield* harness.firstTerminal; - const completed = harness.events.find( - (event) => - event.type === "provider_turn.updated" && event.providerTurn.status === "completed", - ); - assert.equal(completed?.type, "provider_turn.updated"); - if (completed?.type !== "provider_turn.updated") return; - assert.deepEqual(completed.providerTurn.turnTokenUsage?.models, ["gpt-5.6-sol"]); - assert.equal(completed.providerTurn.model, "gpt-5.6-sol"); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), - ), - ), - ), - ); - it.effect.each(["before", "during"] as const)( - "surfaces a user MCP server that fails to start %s the turn", - (timing) => - Effect.scoped( + const runTurn = (index: number) => Effect.gen(function* () { - const nativeThreadId = "mcp-startup-failed-thread"; - const nativeTurnId = "mcp-startup-failed-turn"; - const startupError = - "MCP client for `example_mcp` failed to start: MCP startup failed: handshaking with MCP server failed"; - const startup: Array = [ - { - type: "emit_inbound", - label: "mcp-starting", - frame: { - method: "mcpServer/startupStatus/updated", - params: { - threadId: nativeThreadId, - name: "example_mcp", - status: "starting", - error: null, - failureReason: null, - }, - }, - }, - { - type: "emit_inbound", - label: "mcp-failed", - frame: { - method: "mcpServer/startupStatus/updated", - params: { - threadId: nativeThreadId, - name: "example_mcp", - status: "failed", - error: startupError, - failureReason: null, - }, - }, - }, - ]; - const preamble = codexReplayPreamble({ - nativeThreadId, - nativeTurnId, - prompt: "use example_mcp", - }); - const turnStart = preamble.findIndex( - (entry) => entry.type === "expect_outbound" && entry.label === "turn/start", - ); - const transcript = makeCodexReplayTranscript({ - scenario: "codex-mcp-startup-failed", - entries: [ - ...(timing === "before" - ? [...preamble.slice(0, turnStart), ...startup, ...preamble.slice(turnStart)] - : [...preamble, ...startup]), - { - type: "emit_inbound", - label: "complete", - frame: { - method: "turn/completed", - params: { - threadId: nativeThreadId, - turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), - }, - }, - }, - ], - }); - const harness = yield* makeCodexReplayHarness(transcript); - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ + yield* harness.runtime.startTurn({ + ...makeCodexTestTurnInput({ threadId: harness.threadId, providerThread: harness.providerThread, now: yield* DateTime.now, - attemptId: RunAttemptId.make("mcp-startup-failed-attempt"), - text: "use example_mcp", + attemptId: RunAttemptId.make(`session-approvals-${index + 1}`), + text: turnText(index), }), - ); - yield* harness.firstTerminal; - const surfaced = harness.events.filter((event) => { - const serialized = JSON.stringify(event); - return serialized.includes("example_mcp") && serialized.includes("failed to start"); + runOrdinal: index + 1, + providerTurnOrdinal: index + 1, + runtimePolicy: { ...CODEX_TEST_RUNTIME_POLICY, runtimeMode: turnModes[index]! }, }); - assert.isNotEmpty(surfaced); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), - ), + }); + + yield* runTurn(0); + yield* answer(1, "command", "acceptForSession"); + yield* harness.until(() => requests().length === 2); + yield* harness.awaitTerminal(1); + yield* runTurn(1); + yield* answer(2, "file-change", "acceptForSession"); + yield* harness.awaitTerminal(2); + yield* runTurn(2); + yield* answer(3, "command", "accept"); + yield* answer(4, "file-change", "accept"); + yield* harness.awaitTerminal(3); + assert.lengthOf(requests(), 4); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), + ), ); - it.effect("stops asking for commands the user allowed for the session", () => - Effect.scoped( - Effect.gen(function* () { - const nativeThreadId = "session-approval-thread"; - const turnIds = [ - "session-approval-turn-1", - "session-approval-turn-2", - "session-approval-turn-3", - ]; - const turnPolicies = { - "approval-required": { - approvalPolicy: "untrusted", - approvalsReviewer: "user", - sandboxPolicy: { type: "readOnly" }, - }, - "full-access": { - approvalPolicy: "never", - approvalsReviewer: "user", - sandboxPolicy: { type: "dangerFullAccess" }, - }, - } as const; - const turnModes = ["approval-required", "full-access", "approval-required"] as const; - const turnText = (index: number) => `Review round ${index + 1}.`; - const startTurn = (index: number): ReadonlyArray => [ + it.effect("preserves T3 context on the wire and restores it after compaction", () => + Effect.gen(function* () { + const nativeThreadId = "context-thread"; + const nativeTurnId = "context-turn"; + // An explicit Standard must reach Codex, or Codex falls back to Daybreak. + const modelSelection = { + ...CODEX_TEST_MODEL_SELECTION, + options: [{ id: "cyberAccessProgram", value: "standard" }], + }; + const params = yield* CodexAdapterV2.buildCodexTurnStartParams({ + nativeThreadId, + codexInput: [{ type: "text", text: "work" }], + runtimePolicy: CODEX_TEST_RUNTIME_POLICY, + modelSelection, + hasT3Mcp: true, + cyberAccess: { defaultProgram: "daybreakBlue" }, + }); + assert.include( + params.additionalContext?.t3_code_orchestration_01?.value ?? "", + "delegate_task", + ); + const entries = codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "work" }); + const transcript = makeCodexReplayTranscript({ + scenario: "restore-context", + entries: [ + ...entries.slice(0, 5), { type: "expect_outbound", - label: `turn ${index + 1} turn/start`, + label: "context turn", + frame: { id: 3, method: "turn/start", params }, + }, + ...entries.slice(6), + { + type: "emit_inbound", + label: "compacted", frame: { - id: 3 + index, - method: "turn/start", + method: "item/completed", params: { threadId: nativeThreadId, - input: [{ type: "text", text: turnText(index) }], - cwd: "/workspace", - model: "gpt-5.4", - ...turnPolicies[turnModes[index]!], - summary: "auto", - collaborationMode: { mode: "default", settings: { model: "gpt-5.4" } }, + turnId: nativeTurnId, + item: { type: "contextCompaction", id: "compact-context" }, }, }, }, { type: "emit_inbound", - label: `turn ${index + 1} turn/start`, + label: "done", frame: { - id: 3 + index, - result: { turn: makeCodexReplayTurn({ id: turnIds[index]!, status: "inProgress" }) }, + method: "turn/completed", + params: { + threadId: nativeThreadId, + turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), + }, }, }, - turnEvent("turn/started", turnIds[index]!), - ]; - const approval = (input: { - readonly id: number; - readonly turnId: string; - readonly kind: "commandExecution" | "fileChange"; - readonly itemId: string; - readonly decision: string; - }): ReadonlyArray => [ { - type: "emit_inbound", - label: `${input.itemId} approval`, + type: "expect_outbound", + label: "restore context", frame: { - id: input.id, - method: `item/${input.kind}/requestApproval`, + id: 4, + method: "thread/inject_items", params: { threadId: nativeThreadId, - turnId: input.turnId, - itemId: input.itemId, - startedAtMs: 1782622441000, - ...(input.kind === "commandExecution" ? { command: `cat ${input.itemId}` } : {}), + items: Object.entries(params.additionalContext ?? {}).map(([key, entry]) => ({ + type: "message", + role: "developer", + content: [{ type: "input_text", text: `<${key}>${entry.value}` }], + })), }, }, }, - { - type: "expect_outbound", - label: `${input.itemId} decision`, - frame: { id: input.id, result: { decision: input.decision } }, - }, - ]; - const turnEvent = ( - method: "turn/started" | "turn/completed", - turnId: string, - ): CodexReplay.CodexAppServerReplayEntry => ({ - type: "emit_inbound", - label: `${turnId} ${method}`, - frame: { - method, - params: { - threadId: nativeThreadId, - turn: makeCodexReplayTurn({ - id: turnId, - status: method === "turn/started" ? "inProgress" : "completed", - }), + { type: "emit_inbound", label: "restored", frame: { id: 4, result: {} } }, + ], + }); + // Codex queues items injected mid-turn as pending input and samples again. + const wireOrder: Array = []; + const restoreRequested = yield* Deferred.make(); + const harness = yield* makeCodexReplayHarness( + transcript, + undefined, + (method) => + method === "thread/inject_items" + ? Effect.sync(() => wireOrder.push(method)).pipe( + Effect.andThen(Deferred.succeed(restoreRequested, undefined)), + ) + : Effect.void, + undefined, + undefined, + undefined, + undefined, + (method) => + Effect.sync(() => { + if (method === "item/completed" || method === "turn/completed") wireOrder.push(method); + }), + Effect.succeed>([ + { + slug: modelSelection.model, + name: modelSelection.model, + isCustom: false, + capabilities: { + optionDescriptors: [ + { + id: "cyberAccessProgram", + label: "Cyber Access", + type: "select", + options: [ + { id: "standard", label: "Standard" }, + { id: "daybreakBlue", label: "Daybreak Blue", isDefault: true }, + ], + currentValue: "daybreakBlue", + }, + ], }, }, - }); - const [patchRequest, patchDecision] = approval({ - id: 102, - turnId: turnIds[0]!, - kind: "fileChange", - itemId: "first-patch", - decision: "acceptForSession", - }); - const transcript = makeCodexReplayTranscript({ - scenario: "codex-session-approvals", - entries: [ - ...codexReplayPreamble({ - nativeThreadId, - nativeTurnId: turnIds[0]!, - prompt: turnText(0), - }).slice(0, -3), - ...startTurn(0), - ...approval({ - id: 100, - turnId: turnIds[0]!, - kind: "commandExecution", - itemId: "first-command", - decision: "acceptForSession", - }), - patchRequest!, - ...approval({ - id: 101, - turnId: turnIds[0]!, - kind: "commandExecution", - itemId: "second-command", - decision: "acceptForSession", - }), - turnEvent("turn/completed", turnIds[0]!), - ...startTurn(1), - patchDecision!, - turnEvent("turn/completed", turnIds[1]!), - ...startTurn(2), - ...approval({ - id: 103, - turnId: turnIds[2]!, - kind: "commandExecution", - itemId: "third-command", - decision: "accept", - }), - ...approval({ - id: 104, - turnId: turnIds[2]!, - kind: "fileChange", - itemId: "second-patch", - decision: "accept", - }), - turnEvent("turn/completed", turnIds[2]!), - ], - }); - const harness = yield* makeCodexReplayHarness(transcript); - const requests = () => [ - ...new Map( - harness.events.flatMap((event) => - event.type === "runtime_request.updated" - ? [[event.runtimeRequest.id, event.runtimeRequest] as const] - : [], - ), - ).values(), - ]; - const answer = ( - count: number, - kind: "command" | "file-change", - decision: ProviderApprovalDecision, - ) => - Effect.gen(function* () { - yield* harness.until(() => requests().length === count); - const request = requests()[count - 1]!; - assert.equal(request.kind, kind); - yield* harness.runtime.respondToRuntimeRequest({ requestId: request.id, decision }); - }); - - const runTurn = (index: number) => - Effect.gen(function* () { - yield* harness.runtime.startTurn({ - ...makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make(`session-approvals-${index + 1}`), - text: turnText(index), - }), - runOrdinal: index + 1, - providerTurnOrdinal: index + 1, - runtimePolicy: { ...CODEX_TEST_RUNTIME_POLICY, runtimeMode: turnModes[index]! }, - }); - }); + ]), + ); + yield* (yield* McpProviderSessions.McpProviderSessions).set({ + environmentId: EnvironmentId.make("test"), + threadId: harness.threadId, + providerSessionId: "context-session", + providerInstanceId: ProviderInstanceId.make("codex"), + endpoint: "http://127.0.0.1:43123/mcp", + authorizationHeader: "Bearer test", + browserToolsAvailable: true, + }); + yield* harness.runtime.startTurn({ + ...makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("context-attempt"), + text: "work", + }), + modelSelection, + }); + yield* harness.firstTerminal; + yield* Deferred.await(restoreRequested); + assert.equal(harness.terminalEvents()[0]?.status, "completed"); + assert.deepEqual( + wireOrder, + ["item/completed", "turn/completed", "thread/inject_items"], + "restored T3 context while the compacting native turn was still running", + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); - yield* runTurn(0); - yield* answer(1, "command", "acceptForSession"); - yield* harness.until(() => requests().length === 2); - yield* harness.awaitTerminal(1); - yield* runTurn(1); - yield* answer(2, "file-change", "acceptForSession"); - yield* harness.awaitTerminal(2); - yield* runTurn(2); - yield* answer(3, "command", "accept"); - yield* answer(4, "file-change", "accept"); - yield* harness.awaitTerminal(3); - assert.lengthOf(requests(), 4); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + it.effect("compacts Codex with the native RPC and completes the compaction turn", () => + Effect.gen(function* () { + const nativeThreadId = "compact-thread"; + const nativeTurnId = "compact-turn"; + const item = { type: "contextCompaction", id: "compact-item" }; + const transcript = makeCodexReplayTranscript({ + scenario: "native-compaction", + entries: [ + ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "unused" }).slice(0, 5), + { + type: "expect_outbound", + label: "compact", + frame: { + id: 3, + method: "thread/compact/start", + params: { threadId: nativeThreadId }, + }, + }, + { type: "emit_inbound", label: "compact", frame: { id: 3, result: {} } }, + { + type: "emit_inbound", + label: "start", + frame: { + method: "turn/started", + params: { + threadId: nativeThreadId, + turn: makeCodexReplayTurn({ id: nativeTurnId, status: "inProgress" }), + }, + }, + }, + ...(["item/started", "item/completed"] as const).map((method) => ({ + type: "emit_inbound" as const, + label: method, + frame: { method, params: { threadId: nativeThreadId, turnId: nativeTurnId, item } }, + })), + { + type: "emit_inbound", + label: "complete", + frame: { + method: "turn/completed", + params: { + threadId: nativeThreadId, + turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), + }, + }, + }, + ], + }); + const harness = yield* makeCodexReplayHarness(transcript); + assert.isDefined(harness.runtime.compactThread); + yield* harness.runtime.compactThread!( + makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("compact-attempt"), + text: "/compact", + }), + ); + yield* harness.firstTerminal; + const items = harness.events.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "compaction" + ? [event.turnItem] + : [], + ); + assert.deepEqual( + items.map((entry) => entry.status), + ["running", "completed"], + ); + assert.equal(items[0]?.id, items[1]?.id); + assert.equal(harness.terminalEvents()[0]?.status, "completed"); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), ); - it.effect("preserves T3 context on the wire and restores it after compaction", () => - Effect.scoped( + it.effect.each(["failed", "interrupted", "Stop"] as const)( + "settles unfinished compaction on %s without changing completed compaction", + (outcome) => Effect.gen(function* () { - const nativeThreadId = "context-thread"; - const nativeTurnId = "context-turn"; - // An explicit Standard must reach Codex, or Codex falls back to Daybreak. - const modelSelection = { - ...CODEX_TEST_MODEL_SELECTION, - options: [{ id: "cyberAccessProgram", value: "standard" }], + const nativeThreadId = "compact-thread"; + const nativeTurnId = "compact-turn"; + const error = { + message: "Remote compaction stream disconnected", + codexErrorInfo: "other", + additionalDetails: null, }; - const params = yield* CodexAdapterV2.buildCodexTurnStartParams({ - nativeThreadId, - codexInput: [{ type: "text", text: "work" }], - runtimePolicy: CODEX_TEST_RUNTIME_POLICY, - modelSelection, - hasT3Mcp: true, - cyberAccess: { defaultProgram: "daybreakBlue" }, - }); - assert.include( - params.additionalContext?.t3_code_orchestration_01?.value ?? "", - "delegate_task", - ); - const entries = codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "work" }); const transcript = makeCodexReplayTranscript({ - scenario: "restore-context", + scenario: `automatic-compaction-${outcome}`, entries: [ - ...entries.slice(0, 5), - { - type: "expect_outbound", - label: "context turn", - frame: { id: 3, method: "turn/start", params }, - }, - ...entries.slice(6), - { - type: "emit_inbound", - label: "compacted", + ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "Continue." }), + ...( + [ + { method: "item/started", id: "completed-item", startedAtMs: 1782622441000 }, + { method: "item/completed", id: "completed-item", completedAtMs: 1782622442000 }, + { method: "item/started", id: "unfinished-item", startedAtMs: 1782622443000 }, + ] as const + ).map(({ method, id, ...timestamps }) => ({ + type: "emit_inbound" as const, + label: `${method}/${id}`, frame: { - method: "item/completed", + method, params: { threadId: nativeThreadId, turnId: nativeTurnId, - item: { type: "contextCompaction", id: "compact-context" }, - }, - }, - }, - { - type: "emit_inbound", - label: "done", - frame: { - method: "turn/completed", - params: { - threadId: nativeThreadId, - turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), - }, - }, - }, - { - type: "expect_outbound", - label: "restore context", - frame: { - id: 4, - method: "thread/inject_items", - params: { - threadId: nativeThreadId, - items: Object.entries(params.additionalContext ?? {}).map(([key, entry]) => ({ - type: "message", - role: "developer", - content: [{ type: "input_text", text: `<${key}>${entry.value}` }], - })), + item: { type: "contextCompaction", id }, + ...timestamps, }, }, - }, - { type: "emit_inbound", label: "restored", frame: { id: 4, result: {} } }, - ], - }); - // Codex queues items injected mid-turn as pending input and samples again. - const wireOrder: Array = []; - const restoreRequested = yield* Deferred.make(); - const harness = yield* makeCodexReplayHarness( - transcript, - undefined, - (method) => - method === "thread/inject_items" - ? Effect.sync(() => wireOrder.push(method)).pipe( - Effect.andThen(Deferred.succeed(restoreRequested, undefined)), - ) - : Effect.void, - undefined, - undefined, - undefined, - undefined, - (method) => - Effect.sync(() => { - if (method === "item/completed" || method === "turn/completed") - wireOrder.push(method); - }), - Effect.succeed>([ - { - slug: modelSelection.model, - name: modelSelection.model, - isCustom: false, - capabilities: { - optionDescriptors: [ + })), + ...(outcome === "Stop" + ? [ { - id: "cyberAccessProgram", - label: "Cyber Access", - type: "select", - options: [ - { id: "standard", label: "Standard" }, - { id: "daybreakBlue", label: "Daybreak Blue", isDefault: true }, - ], - currentValue: "daybreakBlue", + type: "expect_outbound" as const, + label: "turn/interrupt", + frame: { + id: 4, + method: "turn/interrupt", + params: { threadId: nativeThreadId, turnId: nativeTurnId }, + }, }, - ], - }, - }, - ]), - ); - yield* (yield* McpProviderSessions.McpProviderSessions).set({ - environmentId: EnvironmentId.make("test"), - threadId: harness.threadId, - providerSessionId: "context-session", - providerInstanceId: ProviderInstanceId.make("codex"), - endpoint: "http://127.0.0.1:43123/mcp", - authorizationHeader: "Bearer test", - browserToolsAvailable: true, + { + type: "emit_inbound" as const, + label: "turn/interrupt/failed", + frame: { id: 4, error: { code: -32_000, message: "Interrupt failed" } }, + }, + ] + : [ + { + type: "emit_inbound" as const, + label: "turn/completed", + frame: { + method: "turn/completed", + params: { + threadId: nativeThreadId, + turn: { + ...makeCodexReplayTurn({ id: nativeTurnId, status: outcome }), + error: outcome === "failed" ? error : null, + }, + }, + }, + }, + ]), + ], }); - yield* harness.runtime.startTurn({ - ...makeCodexTestTurnInput({ + const compactionStarted = yield* Deferred.make(); + const harness = yield* makeCodexReplayHarness(transcript, (event) => + event.type === "turn_item.updated" && + event.turnItem.nativeItemRef?.nativeId === "unfinished-item" && + event.turnItem.status === "running" && + event.turnItem.providerTurnId !== null + ? Deferred.succeed(compactionStarted, event.turnItem.providerTurnId) + : Effect.void, + ); + yield* TestClock.setTime(1782622445000); + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ threadId: harness.threadId, providerThread: harness.providerThread, now: yield* DateTime.now, - attemptId: RunAttemptId.make("context-attempt"), - text: "work", + attemptId: RunAttemptId.make(`compact-${outcome}-attempt`), + text: "Continue.", }), - modelSelection, - }); + ); + if (outcome === "Stop") { + const providerTurnId = yield* Deferred.await(compactionStarted); + const interrupt = yield* harness.runtime + .interruptTurn({ providerThread: harness.providerThread, providerTurnId }) + .pipe(Effect.exit); + assert.equal(interrupt._tag, "Failure"); + } yield* harness.firstTerminal; - yield* Deferred.await(restoreRequested); - assert.equal(harness.terminalEvents()[0]?.status, "completed"); + const items = harness.events.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "compaction" + ? [event.turnItem] + : [], + ); + const completed = items.filter((item) => item.nativeItemRef?.nativeId === "completed-item"); + const unfinished = items.filter( + (item) => item.nativeItemRef?.nativeId === "unfinished-item", + ); + const status = outcome === "failed" ? "failed" : "interrupted"; + assert.deepEqual( + unfinished.map((item) => item.status), + ["running", status], + ); + assert.equal(unfinished[0]?.id, unfinished[1]?.id); + assert.deepEqual(unfinished[1]?.startedAt, DateTime.makeUnsafe(1782622443000)); assert.deepEqual( - wireOrder, - ["item/completed", "turn/completed", "thread/inject_items"], - "restored T3 context while the compacting native turn was still running", + unfinished[1]?.completedAt, + DateTime.makeUnsafe(outcome === "Stop" ? 1782622445000 : 1782622450000), ); + assert.deepEqual( + completed.map((item) => item.status), + ["running", "completed"], + ); + assert.deepEqual(completed[1]?.startedAt, DateTime.makeUnsafe(1782622441000)); + assert.deepEqual(completed[1]?.completedAt, DateTime.makeUnsafe(1782622442000)); + assert.equal(harness.terminalEvents()[0]?.status, status); + if (outcome === "failed") { + assert.equal(harness.terminalEvents()[0]?.failure?.message, error.message); + } }).pipe( Effect.provide( Layer.mergeAll( @@ -3015,234 +3221,6 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ), ), - ), - ); - - it.effect("compacts Codex with the native RPC and completes the compaction turn", () => - Effect.scoped( - Effect.gen(function* () { - const nativeThreadId = "compact-thread"; - const nativeTurnId = "compact-turn"; - const item = { type: "contextCompaction", id: "compact-item" }; - const transcript = makeCodexReplayTranscript({ - scenario: "native-compaction", - entries: [ - ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "unused" }).slice(0, 5), - { - type: "expect_outbound", - label: "compact", - frame: { - id: 3, - method: "thread/compact/start", - params: { threadId: nativeThreadId }, - }, - }, - { type: "emit_inbound", label: "compact", frame: { id: 3, result: {} } }, - { - type: "emit_inbound", - label: "start", - frame: { - method: "turn/started", - params: { - threadId: nativeThreadId, - turn: makeCodexReplayTurn({ id: nativeTurnId, status: "inProgress" }), - }, - }, - }, - ...(["item/started", "item/completed"] as const).map((method) => ({ - type: "emit_inbound" as const, - label: method, - frame: { method, params: { threadId: nativeThreadId, turnId: nativeTurnId, item } }, - })), - { - type: "emit_inbound", - label: "complete", - frame: { - method: "turn/completed", - params: { - threadId: nativeThreadId, - turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), - }, - }, - }, - ], - }); - const harness = yield* makeCodexReplayHarness(transcript); - assert.isDefined(harness.runtime.compactThread); - yield* harness.runtime.compactThread!( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("compact-attempt"), - text: "/compact", - }), - ); - yield* harness.firstTerminal; - const items = harness.events.flatMap((event) => - event.type === "turn_item.updated" && event.turnItem.type === "compaction" - ? [event.turnItem] - : [], - ); - assert.deepEqual( - items.map((entry) => entry.status), - ["running", "completed"], - ); - assert.equal(items[0]?.id, items[1]?.id); - assert.equal(harness.terminalEvents()[0]?.status, "completed"); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), - ), - ), - ), - ); - - it.effect.each(["failed", "interrupted", "Stop"] as const)( - "settles unfinished compaction on %s without changing completed compaction", - (outcome) => - Effect.scoped( - Effect.gen(function* () { - const nativeThreadId = "compact-thread"; - const nativeTurnId = "compact-turn"; - const error = { - message: "Remote compaction stream disconnected", - codexErrorInfo: "other", - additionalDetails: null, - }; - const transcript = makeCodexReplayTranscript({ - scenario: `automatic-compaction-${outcome}`, - entries: [ - ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "Continue." }), - ...( - [ - { method: "item/started", id: "completed-item", startedAtMs: 1782622441000 }, - { method: "item/completed", id: "completed-item", completedAtMs: 1782622442000 }, - { method: "item/started", id: "unfinished-item", startedAtMs: 1782622443000 }, - ] as const - ).map(({ method, id, ...timestamps }) => ({ - type: "emit_inbound" as const, - label: `${method}/${id}`, - frame: { - method, - params: { - threadId: nativeThreadId, - turnId: nativeTurnId, - item: { type: "contextCompaction", id }, - ...timestamps, - }, - }, - })), - ...(outcome === "Stop" - ? [ - { - type: "expect_outbound" as const, - label: "turn/interrupt", - frame: { - id: 4, - method: "turn/interrupt", - params: { threadId: nativeThreadId, turnId: nativeTurnId }, - }, - }, - { - type: "emit_inbound" as const, - label: "turn/interrupt/failed", - frame: { id: 4, error: { code: -32_000, message: "Interrupt failed" } }, - }, - ] - : [ - { - type: "emit_inbound" as const, - label: "turn/completed", - frame: { - method: "turn/completed", - params: { - threadId: nativeThreadId, - turn: { - ...makeCodexReplayTurn({ id: nativeTurnId, status: outcome }), - error: outcome === "failed" ? error : null, - }, - }, - }, - }, - ]), - ], - }); - const compactionStarted = yield* Deferred.make(); - const harness = yield* makeCodexReplayHarness(transcript, (event) => - event.type === "turn_item.updated" && - event.turnItem.nativeItemRef?.nativeId === "unfinished-item" && - event.turnItem.status === "running" && - event.turnItem.providerTurnId !== null - ? Deferred.succeed(compactionStarted, event.turnItem.providerTurnId) - : Effect.void, - ); - yield* TestClock.setTime(1782622445000); - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make(`compact-${outcome}-attempt`), - text: "Continue.", - }), - ); - if (outcome === "Stop") { - const providerTurnId = yield* Deferred.await(compactionStarted); - const interrupt = yield* harness.runtime - .interruptTurn({ providerThread: harness.providerThread, providerTurnId }) - .pipe(Effect.exit); - assert.equal(interrupt._tag, "Failure"); - } - yield* harness.firstTerminal; - const items = harness.events.flatMap((event) => - event.type === "turn_item.updated" && event.turnItem.type === "compaction" - ? [event.turnItem] - : [], - ); - const completed = items.filter( - (item) => item.nativeItemRef?.nativeId === "completed-item", - ); - const unfinished = items.filter( - (item) => item.nativeItemRef?.nativeId === "unfinished-item", - ); - const status = outcome === "failed" ? "failed" : "interrupted"; - assert.deepEqual( - unfinished.map((item) => item.status), - ["running", status], - ); - assert.equal(unfinished[0]?.id, unfinished[1]?.id); - assert.deepEqual(unfinished[1]?.startedAt, DateTime.makeUnsafe(1782622443000)); - assert.deepEqual( - unfinished[1]?.completedAt, - DateTime.makeUnsafe(outcome === "Stop" ? 1782622445000 : 1782622450000), - ); - assert.deepEqual( - completed.map((item) => item.status), - ["running", "completed"], - ); - assert.deepEqual(completed[1]?.startedAt, DateTime.makeUnsafe(1782622441000)); - assert.deepEqual(completed[1]?.completedAt, DateTime.makeUnsafe(1782622442000)); - assert.equal(harness.terminalEvents()[0]?.status, status); - if (outcome === "failed") { - assert.equal(harness.terminalEvents()[0]?.failure?.message, error.message); - } - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), - ), - ), - ), ); it.effect.each([ @@ -3361,186 +3339,307 @@ describe("CodexAdapterV2 post-settle continuation", () => { ); it.effect("resumes a provider thread without requesting or decoding its history", () => - Effect.scoped( - Effect.gen(function* () { - const scenario = "codex-resume-metadata"; - const nativeThreadId = `native-${scenario}-thread`; - const preamble = codexReplayPreamble({ - nativeThreadId, - nativeTurnId: "unused-turn", - prompt: "unused-prompt", - }).slice(0, 5); - const transcript = makeCodexReplayTranscript({ - scenario, - entries: [ - ...preamble, - { - type: "expect_outbound", - label: "thread/resume", - frame: { - id: 3, - method: "thread/resume", - params: { - threadId: nativeThreadId, - excludeTurns: true, - config: CodexAdapterV2.CODEX_THREAD_CONFIG, - }, - }, - }, - { - type: "emit_inbound", - label: "thread/resume", - frame: { id: 3, result: { thread: { id: nativeThreadId, updatedAt: 1782622450 } } }, - }, - { - type: "expect_outbound", - label: "thread/resume without an app thread", - frame: { - id: 4, - method: "thread/resume", - params: { - threadId: nativeThreadId, - excludeTurns: true, - config: CodexAdapterV2.CODEX_THREAD_CONFIG, - }, - }, - }, - { - type: "emit_inbound", - label: "thread/resume without an app thread", - frame: { id: 4, result: { thread: { id: nativeThreadId, updatedAt: 1782622450 } } }, - }, - { - type: "expect_outbound", - label: "thread/resume with an explicit app thread", - frame: { - id: 5, - method: "thread/resume", - params: { - threadId: nativeThreadId, - excludeTurns: true, - config: CodexAdapterV2.CODEX_THREAD_CONFIG, - }, + Effect.gen(function* () { + const scenario = "codex-resume-metadata"; + const nativeThreadId = `native-${scenario}-thread`; + const preamble = codexReplayPreamble({ + nativeThreadId, + nativeTurnId: "unused-turn", + prompt: "unused-prompt", + }).slice(0, 5); + const transcript = makeCodexReplayTranscript({ + scenario, + entries: [ + ...preamble, + { + type: "expect_outbound", + label: "thread/resume", + frame: { + id: 3, + method: "thread/resume", + params: { + threadId: nativeThreadId, + excludeTurns: true, + config: CodexAdapterV2.CODEX_THREAD_CONFIG, }, }, - { - type: "emit_inbound", - label: "thread/resume with an explicit app thread", - frame: { id: 5, result: { thread: { id: nativeThreadId, updatedAt: 1782622450 } } }, - }, - ], - }); - const resumeThreadEnvs: Array = []; - const harness = yield* makeCodexReplayHarness( - transcript, - () => Effect.void, - (method, params) => - Effect.sync(() => { - if (method === "thread/resume") resumeThreadEnvs.push(threadEnvOf(params)); - }), - ); - const resumed = yield* harness.runtime.resumeThread({ - providerThread: harness.providerThread, - modelSelection: CODEX_TEST_MODEL_SELECTION, - runtimePolicy: CODEX_TEST_RUNTIME_POLICY, - }); - yield* harness.runtime.resumeThread({ - providerThread: { ...harness.providerThread, appThreadId: null }, - modelSelection: CODEX_TEST_MODEL_SELECTION, - runtimePolicy: CODEX_TEST_RUNTIME_POLICY, - }); - const explicitThreadId = ThreadId.make(`thread-${scenario}-explicit`); - yield* harness.runtime.resumeThread({ - threadId: explicitThreadId, - providerThread: harness.providerThread, - modelSelection: CODEX_TEST_MODEL_SELECTION, - runtimePolicy: CODEX_TEST_RUNTIME_POLICY, - }); - - assert.equal(resumed.nativeThreadRef?.nativeId, nativeThreadId); - assert.equal(resumed.status, "idle"); - assert.equal(DateTime.toEpochMillis(resumed.updatedAt), 1782622450000); - assert.deepEqual(resumeThreadEnvs, [harness.threadId, undefined, explicitThreadId]); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), - ), - ), - ), - ); - - // regression: #15389 archived native Codex sessions resume via thread/unarchive - it.effect.each( - ( - [ - ["recovers archived-session errors", "session saved-thread is archived", 0, ""], - ["preserves missing-thread errors", "thread not found", 3, "thread not found"], - [ - "propagates unarchive missing-thread errors", - "session saved-thread is archived", - 4, - "thread not found", - ], - [ - "does not retry archived errors twice", - "session saved-thread is archived", - 5, - "session saved-thread is archived", - ], - ] as const - ).map(([name, resumeError, failAt, finalError]) => ({ name, resumeError, failAt, finalError })), - )("$name", ({ name, resumeError, failAt, finalError }) => - Effect.scoped( - Effect.gen(function* () { - const nativeThreadId = "saved-thread"; - const params = { - threadId: nativeThreadId, - excludeTurns: true, - cwd: CODEX_TEST_RUNTIME_POLICY.cwd, - model: CODEX_TEST_MODEL_SELECTION.model, - config: CodexAdapterV2.CODEX_THREAD_CONFIG, - }; - const entries: Array = [ - ...codexReplayPreamble({ - nativeThreadId, - nativeTurnId: "unused-turn", - prompt: "unused-prompt", - }).slice(0, 5), + }, + { + type: "emit_inbound", + label: "thread/resume", + frame: { id: 3, result: { thread: { id: nativeThreadId, updatedAt: 1782622450 } } }, + }, { type: "expect_outbound", - label: "resume archived thread", - frame: { id: 3, method: "thread/resume", params }, + label: "thread/resume without an app thread", + frame: { + id: 4, + method: "thread/resume", + params: { + threadId: nativeThreadId, + excludeTurns: true, + config: CodexAdapterV2.CODEX_THREAD_CONFIG, + }, + }, }, { type: "emit_inbound", - label: "resume error", - frame: { id: 3, error: { code: -32600, message: resumeError } }, + label: "thread/resume without an app thread", + frame: { id: 4, result: { thread: { id: nativeThreadId, updatedAt: 1782622450 } } }, }, - ]; - if (failAt !== 3) { - entries.push( + { + type: "expect_outbound", + label: "thread/resume with an explicit app thread", + frame: { + id: 5, + method: "thread/resume", + params: { + threadId: nativeThreadId, + excludeTurns: true, + config: CodexAdapterV2.CODEX_THREAD_CONFIG, + }, + }, + }, + { + type: "emit_inbound", + label: "thread/resume with an explicit app thread", + frame: { id: 5, result: { thread: { id: nativeThreadId, updatedAt: 1782622450 } } }, + }, + ], + }); + const resumeThreadEnvs: Array = []; + const harness = yield* makeCodexReplayHarness( + transcript, + () => Effect.void, + (method, params) => + Effect.sync(() => { + if (method === "thread/resume") resumeThreadEnvs.push(threadEnvOf(params)); + }), + ); + const resumed = yield* harness.runtime.resumeThread({ + providerThread: harness.providerThread, + modelSelection: CODEX_TEST_MODEL_SELECTION, + runtimePolicy: CODEX_TEST_RUNTIME_POLICY, + }); + yield* harness.runtime.resumeThread({ + providerThread: { ...harness.providerThread, appThreadId: null }, + modelSelection: CODEX_TEST_MODEL_SELECTION, + runtimePolicy: CODEX_TEST_RUNTIME_POLICY, + }); + const explicitThreadId = ThreadId.make(`thread-${scenario}-explicit`); + yield* harness.runtime.resumeThread({ + threadId: explicitThreadId, + providerThread: harness.providerThread, + modelSelection: CODEX_TEST_MODEL_SELECTION, + runtimePolicy: CODEX_TEST_RUNTIME_POLICY, + }); + + assert.equal(resumed.nativeThreadRef?.nativeId, nativeThreadId); + assert.equal(resumed.status, "idle"); + assert.equal(DateTime.toEpochMillis(resumed.updatedAt), 1782622450000); + assert.deepEqual(resumeThreadEnvs, [harness.threadId, undefined, explicitThreadId]); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + + // regression: #15389 archived native Codex sessions resume via thread/unarchive + it.effect.each( + ( + [ + ["recovers archived-session errors", "session saved-thread is archived", 0, ""], + ["preserves missing-thread errors", "thread not found", 3, "thread not found"], + [ + "propagates unarchive missing-thread errors", + "session saved-thread is archived", + 4, + "thread not found", + ], + [ + "does not retry archived errors twice", + "session saved-thread is archived", + 5, + "session saved-thread is archived", + ], + ] as const + ).map(([name, resumeError, failAt, finalError]) => ({ name, resumeError, failAt, finalError })), + )("$name", ({ name, resumeError, failAt, finalError }) => + Effect.gen(function* () { + const nativeThreadId = "saved-thread"; + const params = { + threadId: nativeThreadId, + excludeTurns: true, + cwd: CODEX_TEST_RUNTIME_POLICY.cwd, + model: CODEX_TEST_MODEL_SELECTION.model, + config: CodexAdapterV2.CODEX_THREAD_CONFIG, + }; + const entries: Array = [ + ...codexReplayPreamble({ + nativeThreadId, + nativeTurnId: "unused-turn", + prompt: "unused-prompt", + }).slice(0, 5), + { + type: "expect_outbound", + label: "resume archived thread", + frame: { id: 3, method: "thread/resume", params }, + }, + { + type: "emit_inbound", + label: "resume error", + frame: { id: 3, error: { code: -32600, message: resumeError } }, + }, + ]; + if (failAt !== 3) { + entries.push( + { + type: "expect_outbound", + label: "unarchive same thread", + frame: { id: 4, method: "thread/unarchive", params: { threadId: nativeThreadId } }, + }, + { + type: "emit_inbound", + label: "unarchive result", + frame: + failAt === 4 + ? { id: 4, error: { code: -32600, message: finalError } } + : { id: 4, result: { thread: { turns: [{ type: "unknown-history-item" }] } } }, + }, + ); + } + if (failAt === 0 || failAt === 5) { + entries.push( + { + type: "expect_outbound", + label: "retry identical resume", + frame: { id: 5, method: "thread/resume", params }, + }, + { + type: "emit_inbound", + label: "retry result", + frame: + failAt === 5 + ? { id: 5, error: { code: -32600, message: finalError } } + : { + id: 5, + result: { + thread: { + id: nativeThreadId, + updatedAt: 1782622450, + turns: [{ type: "unknown-history-item" }], + }, + }, + }, + }, + ); + } + const harness = yield* makeCodexReplayHarness( + makeCodexReplayTranscript({ scenario: name, entries }), + ); + const resume = harness.runtime.resumeThread({ + providerThread: harness.providerThread, + modelSelection: CODEX_TEST_MODEL_SELECTION, + runtimePolicy: CODEX_TEST_RUNTIME_POLICY, + }); + if (failAt !== 0) { + const error = yield* Effect.flip(resume); + assert.equal(error._tag, "ProviderAdapterResumeThreadError"); + assert.nestedPropertyVal(error, "cause.errorMessage", finalError); + assert.nestedPropertyVal( + error, + "cause.method", + failAt === 4 ? "thread/unarchive" : "thread/resume", + ); + assert.nestedPropertyVal(error, "cause.requestId", String(failAt)); + return; + } + const resumed = yield* resume; + assert.equal(resumed.id, harness.providerThread.id); + assert.equal(resumed.nativeThreadRef?.nativeId, nativeThreadId); + assert.deepEqual( + resumed.nativeConversationHeadRef, + harness.providerThread.nativeConversationHeadRef, + ); + assert.equal(resumed.status, "idle"); + assert.equal(DateTime.toEpochMillis(resumed.updatedAt), 1782622450000); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + + // regression: #33747 Codex refuses thread/resume until an unsubscribed thread finishes closing + it.effect.each([ + { name: "waits for thread/closed after a closing error", closedFirst: false }, + { name: "retries at once when the thread already closed", closedFirst: true }, + ])("$name", ({ name, closedFirst }) => + Effect.gen(function* () { + const nativeThreadId = "closing-thread"; + const params = { + threadId: nativeThreadId, + excludeTurns: true, + cwd: CODEX_TEST_RUNTIME_POLICY.cwd, + model: CODEX_TEST_MODEL_SELECTION.model, + config: CodexAdapterV2.CODEX_THREAD_CONFIG, + }; + const closed: CodexReplay.CodexAppServerReplayEntry = { + type: "emit_inbound", + label: "thread closed", + frame: { method: "thread/closed", params: { threadId: nativeThreadId } }, + }; + const closingError: CodexReplay.CodexAppServerReplayEntry = { + type: "emit_inbound", + label: "resume closing error", + frame: { + id: 4, + error: { + code: -32600, + message: `thread ${nativeThreadId} is closing; retry thread/resume after the thread is closed`, + }, + }, + }; + const harness = yield* makeCodexReplayHarness( + makeCodexReplayTranscript({ + scenario: name, + entries: [ + ...codexReplayPreamble({ + nativeThreadId, + nativeTurnId: "unused-turn", + prompt: "unused-prompt", + }).slice(0, 5), { type: "expect_outbound", - label: "unarchive same thread", - frame: { id: 4, method: "thread/unarchive", params: { threadId: nativeThreadId } }, + label: "unsubscribe", + frame: { + id: 3, + method: "thread/unsubscribe", + params: { threadId: nativeThreadId }, + }, }, { type: "emit_inbound", - label: "unarchive result", - frame: - failAt === 4 - ? { id: 4, error: { code: -32600, message: finalError } } - : { id: 4, result: { thread: { turns: [{ type: "unknown-history-item" }] } } }, + label: "unsubscribe result", + frame: { id: 3, result: { status: "unsubscribed" } }, }, - ); - } - if (failAt === 0 || failAt === 5) { - entries.push( + { + type: "expect_outbound", + label: "resume closing thread", + frame: { id: 4, method: "thread/resume", params }, + }, + ...(closedFirst ? [closed, closingError] : [closingError, closed]), { type: "expect_outbound", label: "retry identical resume", @@ -3549,326 +3648,233 @@ describe("CodexAdapterV2 post-settle continuation", () => { { type: "emit_inbound", label: "retry result", - frame: - failAt === 5 - ? { id: 5, error: { code: -32600, message: finalError } } - : { - id: 5, - result: { - thread: { - id: nativeThreadId, - updatedAt: 1782622450, - turns: [{ type: "unknown-history-item" }], - }, - }, - }, + frame: { + id: 5, + result: { + thread: { + id: nativeThreadId, + updatedAt: 1782622450, + turns: [{ type: "unknown-history-item" }], + }, + }, + }, }, - ); - } - const harness = yield* makeCodexReplayHarness( - makeCodexReplayTranscript({ scenario: name, entries }), - ); - const resume = harness.runtime.resumeThread({ - providerThread: harness.providerThread, - modelSelection: CODEX_TEST_MODEL_SELECTION, - runtimePolicy: CODEX_TEST_RUNTIME_POLICY, - }); - if (failAt !== 0) { - const error = yield* Effect.flip(resume); - assert.equal(error._tag, "ProviderAdapterResumeThreadError"); - assert.nestedPropertyVal(error, "cause.errorMessage", finalError); - assert.nestedPropertyVal( - error, - "cause.method", - failAt === 4 ? "thread/unarchive" : "thread/resume", - ); - assert.nestedPropertyVal(error, "cause.requestId", String(failAt)); - return; - } - const resumed = yield* resume; - assert.equal(resumed.id, harness.providerThread.id); - assert.equal(resumed.nativeThreadRef?.nativeId, nativeThreadId); - assert.deepEqual( - resumed.nativeConversationHeadRef, - harness.providerThread.nativeConversationHeadRef, - ); - assert.equal(resumed.status, "idle"); - assert.equal(DateTime.toEpochMillis(resumed.updatedAt), 1782622450000); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + ], + }), + ); + yield* harness.runtime.unloadThread!({ providerThread: harness.providerThread }); + const resumed = yield* harness.runtime.resumeThread({ + providerThread: harness.providerThread, + modelSelection: CODEX_TEST_MODEL_SELECTION, + runtimePolicy: CODEX_TEST_RUNTIME_POLICY, + }); + assert.equal(resumed.nativeThreadRef?.nativeId, nativeThreadId); + assert.equal(resumed.status, "idle"); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), ); - // regression: #33747 Codex refuses thread/resume until an unsubscribed thread finishes closing it.effect.each([ - { name: "waits for thread/closed after a closing error", closedFirst: false }, - { name: "retries at once when the thread already closed", closedFirst: true }, - ])("$name", ({ name, closedFirst }) => - Effect.scoped( - Effect.gen(function* () { - const nativeThreadId = "closing-thread"; - const params = { - threadId: nativeThreadId, - excludeTurns: true, - cwd: CODEX_TEST_RUNTIME_POLICY.cwd, - model: CODEX_TEST_MODEL_SELECTION.model, - config: CodexAdapterV2.CODEX_THREAD_CONFIG, - }; - const closed: CodexReplay.CodexAppServerReplayEntry = { + "recover", + "completion-only", + "exhaust", + "stop", + "stop-continued", + "stop-starting", + "started-first", + "completed-first", + "new-run", + "steer", + "start-fails", + ] as const)("handles terminal Codex capacity failures when retries %s", (outcome) => + Effect.gen(function* () { + const scenario = `capacity-${outcome}`; + const nativeThreadId = `native-${scenario}`; + const nativeTurnId = (attempt: number) => `${nativeThreadId}-${attempt}`; + const prompt = "Continue the task."; + const capacityError = { + message: "Selected model is at capacity. Please try a different model.", + codexErrorInfo: "serverOverloaded", + }; + const failureEntries = (attempt: number): Array => [ + { type: "emit_inbound", - label: "thread closed", - frame: { method: "thread/closed", params: { threadId: nativeThreadId } }, - }; - const closingError: CodexReplay.CodexAppServerReplayEntry = { + frame: { + method: "error", + params: { + threadId: nativeThreadId, + turnId: nativeTurnId(attempt), + willRetry: false, + error: capacityError, + }, + }, + }, + { type: "emit_inbound", - label: "resume closing error", frame: { - id: 4, - error: { - code: -32600, - message: `thread ${nativeThreadId} is closing; retry thread/resume after the thread is closed`, + method: "turn/completed", + params: { + threadId: nativeThreadId, + turn: { + ...makeCodexReplayTurn({ id: nativeTurnId(attempt), status: "failed" }), + error: capacityError, + }, }, }, - }; - const harness = yield* makeCodexReplayHarness( - makeCodexReplayTranscript({ - scenario: name, - entries: [ - ...codexReplayPreamble({ - nativeThreadId, - nativeTurnId: "unused-turn", - prompt: "unused-prompt", - }).slice(0, 5), - { - type: "expect_outbound", - label: "unsubscribe", - frame: { - id: 3, - method: "thread/unsubscribe", - params: { threadId: nativeThreadId }, - }, - }, - { - type: "emit_inbound", - label: "unsubscribe result", - frame: { id: 3, result: { status: "unsubscribed" } }, - }, - { - type: "expect_outbound", - label: "resume closing thread", - frame: { id: 4, method: "thread/resume", params }, - }, - ...(closedFirst ? [closed, closingError] : [closingError, closed]), - { - type: "expect_outbound", - label: "retry identical resume", - frame: { id: 5, method: "thread/resume", params }, - }, - { - type: "emit_inbound", - label: "retry result", - frame: { - id: 5, - result: { - thread: { - id: nativeThreadId, - updatedAt: 1782622450, - turns: [{ type: "unknown-history-item" }], - }, - }, - }, - }, - ], - }), - ); - yield* harness.runtime.unloadThread!({ providerThread: harness.providerThread }); - const resumed = yield* harness.runtime.resumeThread({ - providerThread: harness.providerThread, - modelSelection: CODEX_TEST_MODEL_SELECTION, - runtimePolicy: CODEX_TEST_RUNTIME_POLICY, - }); - assert.equal(resumed.nativeThreadRef?.nativeId, nativeThreadId); - assert.equal(resumed.status, "idle"); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), - ), - ), - ), - ); - - it.effect.each([ - "recover", - "completion-only", - "exhaust", - "stop", - "stop-continued", - "stop-starting", - "started-first", - "completed-first", - "new-run", - "steer", - "start-fails", - ] as const)("handles terminal Codex capacity failures when retries %s", (outcome) => - Effect.scoped( - Effect.gen(function* () { - const scenario = `capacity-${outcome}`; - const nativeThreadId = `native-${scenario}`; - const nativeTurnId = (attempt: number) => `${nativeThreadId}-${attempt}`; - const prompt = "Continue the task."; - const capacityError = { - message: "Selected model is at capacity. Please try a different model.", - codexErrorInfo: "serverOverloaded", - }; - const failureEntries = (attempt: number): Array => [ - { - type: "emit_inbound", - frame: { - method: "error", - params: { - threadId: nativeThreadId, - turnId: nativeTurnId(attempt), - willRetry: false, - error: capacityError, - }, - }, - }, - { - type: "emit_inbound", - frame: { - method: "turn/completed", - params: { - threadId: nativeThreadId, - turn: { - ...makeCodexReplayTurn({ id: nativeTurnId(attempt), status: "failed" }), - error: capacityError, - }, - }, - }, - }, - ]; - const entries = codexReplayPreamble({ + }, + ]; + const entries = codexReplayPreamble({ + nativeThreadId, + nativeTurnId: nativeTurnId(0), + prompt, + }); + entries.push( + ...(outcome === "completion-only" ? failureEntries(0).slice(1) : failureEntries(0)), + ); + if (outcome === "completion-only") entries.push(...failureEntries(0).slice(1)); + const retries = outcome === "exhaust" ? 5 : outcome === "stop" ? 0 : 1; + const steeredText = "Use the smaller change."; + for (let attempt = 1; attempt <= retries; attempt++) { + const start = codexReplayPreamble({ nativeThreadId, - nativeTurnId: nativeTurnId(0), + nativeTurnId: nativeTurnId(attempt), prompt, - }); - entries.push( - ...(outcome === "completion-only" ? failureEntries(0).slice(1) : failureEntries(0)), - ); - if (outcome === "completion-only") entries.push(...failureEntries(0).slice(1)); - const retries = outcome === "exhaust" ? 5 : outcome === "stop" ? 0 : 1; - const steeredText = "Use the smaller change."; - for (let attempt = 1; attempt <= retries; attempt++) { - const start = codexReplayPreamble({ - nativeThreadId, - nativeTurnId: nativeTurnId(attempt), - prompt, - }).slice(-3); - if ( - outcome === "started-first" || - outcome === "stop-starting" || - outcome === "completed-first" || - outcome === "new-run" - ) { - start.splice(1, 0, ...start.splice(2, 1)); - } - for (const entry of start) { - if (entry.type === "expect_outbound") { + }).slice(-3); + if ( + outcome === "started-first" || + outcome === "stop-starting" || + outcome === "completed-first" || + outcome === "new-run" + ) { + start.splice(1, 0, ...start.splice(2, 1)); + } + for (const entry of start) { + if (entry.type === "expect_outbound") { + entries.push({ + ...entry, + frame: { + id: 3 + attempt, + method: "turn/start", + params: { + threadId: nativeThreadId, + input: [], + cwd: "/workspace", + model: "gpt-5.4", + approvalPolicy: "never", + approvalsReviewer: "user", + sandboxPolicy: { type: "dangerFullAccess" }, + summary: "auto", + collaborationMode: { mode: "default", settings: { model: "gpt-5.4" } }, + }, + }, + }); + } else if (entry.type === "emit_inbound" && entry.label === "turn/start") { + if (outcome === "completed-first" || outcome === "new-run") entries.push({ - ...entry, + type: "emit_inbound", frame: { - id: 3 + attempt, - method: "turn/start", + method: "turn/completed", params: { threadId: nativeThreadId, - input: [], - cwd: "/workspace", - model: "gpt-5.4", - approvalPolicy: "never", - approvalsReviewer: "user", - sandboxPolicy: { type: "dangerFullAccess" }, - summary: "auto", - collaborationMode: { mode: "default", settings: { model: "gpt-5.4" } }, + turn: makeCodexReplayTurn({ id: nativeTurnId(attempt), status: "completed" }), }, }, }); - } else if (entry.type === "emit_inbound" && entry.label === "turn/start") { - if (outcome === "completed-first" || outcome === "new-run") - entries.push({ - type: "emit_inbound", - frame: { - method: "turn/completed", - params: { - threadId: nativeThreadId, - turn: makeCodexReplayTurn({ id: nativeTurnId(attempt), status: "completed" }), - }, - }, - }); - if (outcome === "new-run") { - entries.push( - ...codexReplayPreamble({ - nativeThreadId, - nativeTurnId: nativeTurnId(2), - prompt: "Next task.", - }) - .slice(-3) - .map((next, index) => (index < 2 ? withReplayRequestId(next, 5) : next)), - ...failureEntries(2), - ); - } + if (outcome === "new-run") { entries.push( - outcome === "start-fails" - ? { - type: "emit_inbound", - frame: { - id: 3 + attempt, - error: { code: -32603, message: "Start failed" }, - }, - } - : withReplayRequestId(entry, 3 + attempt), - ); - } else if (outcome !== "start-fails") { - entries.push( - outcome === "stop-starting" && entry.type === "emit_inbound" - ? { ...entry, afterMs: 1_000 } - : entry, + ...codexReplayPreamble({ + nativeThreadId, + nativeTurnId: nativeTurnId(2), + prompt: "Next task.", + }) + .slice(-3) + .map((next, index) => (index < 2 ? withReplayRequestId(next, 5) : next)), + ...failureEntries(2), ); - if (outcome === "stop-starting") { - entries.push( - { - type: "expect_outbound", + } + entries.push( + outcome === "start-fails" + ? { + type: "emit_inbound", frame: { - id: 5, - method: "turn/interrupt", - params: { threadId: nativeThreadId, turnId: nativeTurnId(attempt) }, + id: 3 + attempt, + error: { code: -32603, message: "Start failed" }, }, + } + : withReplayRequestId(entry, 3 + attempt), + ); + } else if (outcome !== "start-fails") { + entries.push( + outcome === "stop-starting" && entry.type === "emit_inbound" + ? { ...entry, afterMs: 1_000 } + : entry, + ); + if (outcome === "stop-starting") { + entries.push( + { + type: "expect_outbound", + frame: { + id: 5, + method: "turn/interrupt", + params: { threadId: nativeThreadId, turnId: nativeTurnId(attempt) }, }, - { type: "emit_inbound", frame: { id: 5, result: {} } }, - ); - } + }, + { type: "emit_inbound", frame: { id: 5, result: {} } }, + ); } } - if (outcome === "exhaust") entries.push(...failureEntries(attempt)); - if ( - outcome === "recover" || - outcome === "completion-only" || - outcome === "started-first" || - outcome === "stop-starting" - ) { - entries.push({ + } + if (outcome === "exhaust") entries.push(...failureEntries(attempt)); + if ( + outcome === "recover" || + outcome === "completion-only" || + outcome === "started-first" || + outcome === "stop-starting" + ) { + entries.push({ + type: "emit_inbound", + frame: { + method: "turn/completed", + params: { + threadId: nativeThreadId, + turn: makeCodexReplayTurn({ + id: nativeTurnId(attempt), + status: outcome === "stop-starting" ? "interrupted" : "completed", + }), + }, + }, + }); + } + if (outcome === "stop-continued" || outcome === "steer") { + const steering = outcome === "steer"; + entries.push( + { + type: "expect_outbound", + frame: { + id: 5, + method: steering ? "turn/steer" : "turn/interrupt", + params: steering + ? { + threadId: nativeThreadId, + expectedTurnId: nativeTurnId(attempt), + input: [{ type: "text", text: steeredText }], + } + : { threadId: nativeThreadId, turnId: nativeTurnId(attempt) }, + }, + }, + { + type: "emit_inbound", + frame: { id: 5, result: steering ? { turnId: nativeTurnId(attempt) } : {} }, + }, + { type: "emit_inbound", frame: { method: "turn/completed", @@ -3876,565 +3882,681 @@ describe("CodexAdapterV2 post-settle continuation", () => { threadId: nativeThreadId, turn: makeCodexReplayTurn({ id: nativeTurnId(attempt), - status: outcome === "stop-starting" ? "interrupted" : "completed", + status: steering ? "completed" : "interrupted", }), }, }, - }); - } - if (outcome === "stop-continued" || outcome === "steer") { - const steering = outcome === "steer"; - entries.push( - { - type: "expect_outbound", - frame: { - id: 5, - method: steering ? "turn/steer" : "turn/interrupt", - params: steering - ? { - threadId: nativeThreadId, - expectedTurnId: nativeTurnId(attempt), - input: [{ type: "text", text: steeredText }], - } - : { threadId: nativeThreadId, turnId: nativeTurnId(attempt) }, - }, - }, - { - type: "emit_inbound", - frame: { id: 5, result: steering ? { turnId: nativeTurnId(attempt) } : {} }, - }, - { - type: "emit_inbound", - frame: { - method: "turn/completed", - params: { - threadId: nativeThreadId, - turn: makeCodexReplayTurn({ - id: nativeTurnId(attempt), - status: steering ? "completed" : "interrupted", - }), - }, - }, - }, - ); - } + }, + ); } - const milestones = yield* Queue.unbounded(); - const starts: Array = []; - const retryStarted = yield* Deferred.make(); - const harness = yield* makeCodexReplayHarness( - makeCodexReplayTranscript({ scenario, entries }), - (event) => - event.type === "turn.terminal" || - (event.type === "turn_item.updated" && - event.turnItem.type === "error" && - event.turnItem.status === "running") - ? Queue.offer(milestones, event) - : Effect.void, - (method, params) => - Effect.gen(function* () { - if (method === "turn/start") { - starts.push(params); - if (starts.length === 2) yield* Deferred.succeed(retryStarted, undefined); - } - }), - ); - const turnInput = makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make(scenario), - text: prompt, + } + const milestones = yield* Queue.unbounded(); + const starts: Array = []; + const retryStarted = yield* Deferred.make(); + const harness = yield* makeCodexReplayHarness( + makeCodexReplayTranscript({ scenario, entries }), + (event) => + event.type === "turn.terminal" || + (event.type === "turn_item.updated" && + event.turnItem.type === "error" && + event.turnItem.status === "running") + ? Queue.offer(milestones, event) + : Effect.void, + (method, params) => + Effect.gen(function* () { + if (method === "turn/start") { + starts.push(params); + if (starts.length === 2) yield* Deferred.succeed(retryStarted, undefined); + } + }), + ); + const turnInput = makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make(scenario), + text: prompt, + }); + yield* harness.runtime.startTurn(turnInput); + for (let attempt = 1; attempt <= Math.max(retries, 1); attempt++) { + const milestone = yield* Queue.take(milestones); + assert.equal(milestone.type, "turn_item.updated"); + if (milestone.type !== "turn_item.updated" || milestone.turnItem.type !== "error") return; + const providerTurnId = milestone.turnItem.providerTurnId; + assert.isNotNull(providerTurnId); + if (providerTurnId === null) return; + assert.deepEqual(milestone.turnItem.retry, { + attempt, + maxAttempts: 5, + retryDelayMs: Math.min(2_000 * 2 ** (attempt - 1), 30_000), }); - yield* harness.runtime.startTurn(turnInput); - for (let attempt = 1; attempt <= Math.max(retries, 1); attempt++) { - const milestone = yield* Queue.take(milestones); - assert.equal(milestone.type, "turn_item.updated"); - if (milestone.type !== "turn_item.updated" || milestone.turnItem.type !== "error") return; - const providerTurnId = milestone.turnItem.providerTurnId; - assert.isNotNull(providerTurnId); - if (providerTurnId === null) return; - assert.deepEqual(milestone.turnItem.retry, { - attempt, - maxAttempts: 5, - retryDelayMs: Math.min(2_000 * 2 ** (attempt - 1), 30_000), + assert.isEmpty(harness.terminalEvents()); + if (outcome === "stop") { + yield* harness.runtime.interruptTurn({ + providerThread: harness.providerThread, + providerTurnId, }); - assert.isEmpty(harness.terminalEvents()); - if (outcome === "stop") { - yield* harness.runtime.interruptTurn({ - providerThread: harness.providerThread, - providerTurnId, - }); - } - const steering = - outcome === "steer" - ? yield* harness.runtime - .steerTurn({ - ...turnInput, - providerTurnId, - message: { ...turnInput.message, text: steeredText }, - }) - .pipe(Effect.forkScoped) - : undefined; - yield* TestClock.adjust(Math.min(2_000 * 2 ** (attempt - 1), 30_000)); - if (outcome === "stop-starting") { - yield* Deferred.await(retryStarted); - const stopping = yield* harness.runtime - .interruptTurn({ providerThread: harness.providerThread, providerTurnId }) - .pipe(Effect.forkScoped); - yield* TestClock.adjust("1 second"); - yield* Fiber.join(stopping); - } - if (steering !== undefined) yield* Fiber.join(steering); - if (outcome === "stop-continued") { - yield* harness.until(() => - harness.events.some( - (event) => - event.type === "provider_turn.updated" && - event.providerTurn.nativeTurnRef?.nativeId === nativeTurnId(1), - ), - ); - yield* harness.runtime.interruptTurn({ - providerThread: harness.providerThread, - providerTurnId, - }); - } } - yield* harness.awaitTerminal(1); - if (outcome === "new-run") { - assert.equal((yield* Queue.take(milestones)).type, "turn.terminal"); - yield* harness.runtime.startTurn({ - ...turnInput, - attemptId: RunAttemptId.make("capacity-next-task"), - message: { ...turnInput.message, text: "Next task." }, + const steering = + outcome === "steer" + ? yield* harness.runtime + .steerTurn({ + ...turnInput, + providerTurnId, + message: { ...turnInput.message, text: steeredText }, + }) + .pipe(Effect.forkScoped) + : undefined; + yield* TestClock.adjust(Math.min(2_000 * 2 ** (attempt - 1), 30_000)); + if (outcome === "stop-starting") { + yield* Deferred.await(retryStarted); + const stopping = yield* harness.runtime + .interruptTurn({ providerThread: harness.providerThread, providerTurnId }) + .pipe(Effect.forkScoped); + yield* TestClock.adjust("1 second"); + yield* Fiber.join(stopping); + } + if (steering !== undefined) yield* Fiber.join(steering); + if (outcome === "stop-continued") { + yield* harness.until(() => + harness.events.some( + (event) => + event.type === "provider_turn.updated" && + event.providerTurn.nativeTurnRef?.nativeId === nativeTurnId(1), + ), + ); + yield* harness.runtime.interruptTurn({ + providerThread: harness.providerThread, + providerTurnId, }); - const nextRetry = yield* Queue.take(milestones); - assert.equal(nextRetry.type, "turn_item.updated"); - if (nextRetry.type !== "turn_item.updated" || nextRetry.turnItem.type !== "error") return; - assert.equal(nextRetry.turnItem.retry?.attempt, 1); - return; } - const terminal = harness.terminalEvents()[0]; - assert.equal( - terminal?.status, - outcome === "recover" || - outcome === "completion-only" || - outcome === "steer" || - outcome === "started-first" || - outcome === "completed-first" - ? "completed" - : outcome === "stop" || outcome === "stop-continued" || outcome === "stop-starting" - ? "interrupted" - : "failed", - ); - assert.lengthOf(starts, 1 + retries); - assert.lengthOf(harness.terminalEvents(), 1); - assert.lengthOf( - harness.events.filter( - (event) => - event.type === "provider_turn.updated" && event.providerTurn.status === "running", - ), - outcome === "start-fails" ? 1 : 1 + retries, - ); - const retryItems = harness.events.flatMap((event) => - event.type === "turn_item.updated" && event.turnItem.type === "error" - ? [event.turnItem] - : [], - ); - assert.isTrue( - ["completed", "failed", "interrupted"].includes(retryItems.at(-1)?.status ?? ""), - ); - assert.lengthOf(new Set(retryItems.map((item) => item.id)), 1); + } + yield* harness.awaitTerminal(1); + if (outcome === "new-run") { + assert.equal((yield* Queue.take(milestones)).type, "turn.terminal"); + yield* harness.runtime.startTurn({ + ...turnInput, + attemptId: RunAttemptId.make("capacity-next-task"), + message: { ...turnInput.message, text: "Next task." }, + }); + const nextRetry = yield* Queue.take(milestones); + assert.equal(nextRetry.type, "turn_item.updated"); + if (nextRetry.type !== "turn_item.updated" || nextRetry.turnItem.type !== "error") return; + assert.equal(nextRetry.turnItem.retry?.attempt, 1); + return; + } + const terminal = harness.terminalEvents()[0]; + assert.equal( + terminal?.status, + outcome === "recover" || + outcome === "completion-only" || + outcome === "steer" || + outcome === "started-first" || + outcome === "completed-first" + ? "completed" + : outcome === "stop" || outcome === "stop-continued" || outcome === "stop-starting" + ? "interrupted" + : "failed", + ); + assert.lengthOf(starts, 1 + retries); + assert.lengthOf(harness.terminalEvents(), 1); + assert.lengthOf( + harness.events.filter( + (event) => + event.type === "provider_turn.updated" && event.providerTurn.status === "running", + ), + outcome === "start-fails" ? 1 : 1 + retries, + ); + const retryItems = harness.events.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "error" + ? [event.turnItem] + : [], + ); + assert.isTrue( + ["completed", "failed", "interrupted"].includes(retryItems.at(-1)?.status ?? ""), + ); + assert.lengthOf(new Set(retryItems.map((item) => item.id)), 1); - if (outcome === "recover") { - const fs = yield* FileSystem.FileSystem; - const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-capacity-retry-" }); - const localTranscript = yield* decodeReplayTranscriptJson( - (yield* encodeReplayTranscriptJson( - makeCodexReplayTranscript({ scenario, entries }), - )).replaceAll(yield* encodeStringJson("/workspace"), yield* encodeStringJson(cwd)), + if (outcome === "recover") { + const fs = yield* FileSystem.FileSystem; + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-capacity-retry-" }); + const localTranscript = yield* decodeReplayTranscriptJson( + (yield* encodeReplayTranscriptJson( + makeCodexReplayTranscript({ scenario, entries }), + )).replaceAll(yield* encodeStringJson("/workspace"), yield* encodeStringJson(cwd)), + ); + const driver = yield* CodexReplay.makeReplayDriver(withConfigReads(localTranscript)); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const threadId = ThreadId.make("thread:capacity-retry"); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create-capacity-retry"), + threadId, + projectId: ProjectId.make("project:capacity-retry"), + title: "Capacity retry", + modelSelection: CODEX_TEST_MODEL_SELECTION, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + createdBy: "user", + creationSource: "web", + }); + const retrying = yield* orchestrator.streamDomainEvents.pipe( + Stream.filter( + (event) => + event.type === "turn-item.updated" && + event.payload.type === "error" && + event.payload.retry != null && + event.payload.status === "running", + ), + Stream.runHead, + Effect.forkChild({ startImmediately: true }), ); - const driver = yield* CodexReplay.makeReplayDriver(withConfigReads(localTranscript)); - yield* Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; - const threadId = ThreadId.make("thread:capacity-retry"); - yield* orchestrator.dispatch({ - type: "thread.create", - commandId: CommandId.make("create-capacity-retry"), - threadId, - projectId: ProjectId.make("project:capacity-retry"), - title: "Capacity retry", - modelSelection: CODEX_TEST_MODEL_SELECTION, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: cwd, - createdBy: "user", - creationSource: "web", - }); - const retrying = yield* orchestrator.streamDomainEvents.pipe( - Stream.filter( - (event) => - event.type === "turn-item.updated" && - event.payload.type === "error" && - event.payload.retry != null && - event.payload.status === "running", - ), - Stream.runHead, - Effect.forkChild({ startImmediately: true }), - ); - yield* orchestrator.dispatch({ - type: "message.dispatch", - commandId: CommandId.make("start-capacity-retry"), - threadId, - messageId: MessageId.make("message:capacity-retry"), - text: prompt, - attachments: [], - createdBy: "user", - creationSource: "web", - dispatchMode: { type: "start_immediately" }, - }); - yield* worker.drain(); - yield* Fiber.join(retrying); - assert.equal( - (yield* orchestrator.getThreadProjection(threadId)).runs.at(-1)?.status, - "running", - ); - const completed = yield* orchestrator.streamDomainEvents.pipe( - Stream.filter( - (event) => event.type === "run.updated" && event.payload.status === "waiting", - ), - Stream.runHead, - Effect.forkChild({ startImmediately: true }), - ); - yield* TestClock.adjust("2 seconds"); - yield* Fiber.join(completed); - yield* worker.drain(); - const projection = yield* orchestrator.getThreadProjection(threadId); - assert.equal(projection.runs.at(-1)?.status, "completed"); - assert.deepEqual( - projection.providerTurns.map((turn) => turn.status), - ["failed", "completed"], - ); - assert.lengthOf( - projection.messages.filter((message) => message.role === "user"), - 1, - ); - assert.isNull((yield* Ref.get(driver.state)).failure); - }).pipe( - Effect.provide( - ProviderReplayHarness.layerWithRegistry( - { name: "codex-capacity-retry", runtimePolicyOverride: { cwd } }, - CodexAdapterV2Testkit.layer({ transcript: localTranscript, driver }), - { runEffectWorker: false }, - ), + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("start-capacity-retry"), + threadId, + messageId: MessageId.make("message:capacity-retry"), + text: prompt, + attachments: [], + createdBy: "user", + creationSource: "web", + dispatchMode: { type: "start_immediately" }, + }); + yield* worker.drain(); + yield* Fiber.join(retrying); + assert.equal( + (yield* orchestrator.getThreadProjection(threadId)).runs.at(-1)?.status, + "running", + ); + const completed = yield* orchestrator.streamDomainEvents.pipe( + Stream.filter( + (event) => event.type === "run.updated" && event.payload.status === "waiting", ), + Stream.runHead, + Effect.forkChild({ startImmediately: true }), ); - } - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, + yield* TestClock.adjust("2 seconds"); + yield* Fiber.join(completed); + yield* worker.drain(); + const projection = yield* orchestrator.getThreadProjection(threadId); + assert.equal(projection.runs.at(-1)?.status, "completed"); + assert.deepEqual( + projection.providerTurns.map((turn) => turn.status), + ["failed", "completed"], + ); + assert.lengthOf( + projection.messages.filter((message) => message.role === "user"), + 1, + ); + assert.isNull((yield* Ref.get(driver.state)).failure); + }).pipe( + Effect.provide( + ProviderReplayHarness.layerWithRegistry( + { name: "codex-capacity-retry", runtimePolicyOverride: { cwd } }, + CodexAdapterV2Testkit.layer({ transcript: localTranscript, driver }), + { runEffectWorker: false }, + ), ), + ); + } + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), ); it.effect("reports a thread another Codex client is writing to as in use", () => - Effect.scoped( - Effect.gen(function* () { - const nativeThreadId = "thread-open-elsewhere"; - const harness = yield* makeCodexReplayHarness( - makeCodexReplayTranscript({ - scenario: "codex-resume-active-writer", - entries: [ - ...codexReplayPreamble({ - nativeThreadId, - nativeTurnId: "unused-turn", - prompt: "unused-prompt", - }).slice(0, 5), - { - type: "expect_outbound", - label: "thread/resume", - frame: { - id: 3, - method: "thread/resume", - params: { - threadId: nativeThreadId, - excludeTurns: true, - cwd: CODEX_TEST_RUNTIME_POLICY.cwd, - model: CODEX_TEST_MODEL_SELECTION.model, - config: CodexAdapterV2.CODEX_THREAD_CONFIG, - }, + Effect.gen(function* () { + const nativeThreadId = "thread-open-elsewhere"; + const harness = yield* makeCodexReplayHarness( + makeCodexReplayTranscript({ + scenario: "codex-resume-active-writer", + entries: [ + ...codexReplayPreamble({ + nativeThreadId, + nativeTurnId: "unused-turn", + prompt: "unused-prompt", + }).slice(0, 5), + { + type: "expect_outbound", + label: "thread/resume", + frame: { + id: 3, + method: "thread/resume", + params: { + threadId: nativeThreadId, + excludeTurns: true, + cwd: CODEX_TEST_RUNTIME_POLICY.cwd, + model: CODEX_TEST_MODEL_SELECTION.model, + config: CodexAdapterV2.CODEX_THREAD_CONFIG, }, }, - { - type: "emit_inbound", - label: "active writer", - frame: { - id: 3, - error: { - code: -32600, - message: `thread ${nativeThreadId} already has an active writer`, - }, + }, + { + type: "emit_inbound", + label: "active writer", + frame: { + id: 3, + error: { + code: -32600, + message: `thread ${nativeThreadId} already has an active writer`, }, }, - ], - }), - ); - - const error = yield* Effect.flip( - harness.runtime.resumeThread({ - providerThread: harness.providerThread, - modelSelection: CODEX_TEST_MODEL_SELECTION, - runtimePolicy: CODEX_TEST_RUNTIME_POLICY, - }), - ); + }, + ], + }), + ); - assert.equal(error._tag, "ProviderAdapterThreadInUseError"); - assert.nestedPropertyVal(error, "providerThreadId", harness.providerThread.id); - assert.include(error.message, "open in another Codex client"); - assert.nestedPropertyVal( - error, - "cause.errorMessage", - `thread ${nativeThreadId} already has an active writer`, - ); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + const error = yield* Effect.flip( + harness.runtime.resumeThread({ + providerThread: harness.providerThread, + modelSelection: CODEX_TEST_MODEL_SELECTION, + runtimePolicy: CODEX_TEST_RUNTIME_POLICY, + }), + ); + + assert.equal(error._tag, "ProviderAdapterThreadInUseError"); + assert.nestedPropertyVal(error, "providerThreadId", harness.providerThread.id); + assert.include(error.message, "open in another Codex client"); + assert.nestedPropertyVal( + error, + "cause.errorMessage", + `thread ${nativeThreadId} already has an active writer`, + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), ); it.effect("resolves retryable app-server errors on resumed provider activity", () => - Effect.scoped( - Effect.gen(function* () { - const scenario = "codex-provider-api-retry"; - const nativeThreadId = `native-${scenario}-thread`; - const nativeTurnId = `native-${scenario}-turn`; - const transcript = makeCodexReplayTranscript({ - scenario, - entries: [ - ...codexReplayPreamble({ - nativeThreadId, - nativeTurnId, - prompt: "Open github.com.", - }), - { - type: "emit_inbound", - label: "error/retry", - frame: { - method: "error", - params: { - threadId: nativeThreadId, - turnId: nativeTurnId, - willRetry: true, - error: { - message: "Reconnecting... 2/5", - additionalDetails: "The response stream disconnected.", - codexErrorInfo: { - responseStreamDisconnected: { httpStatusCode: 529 }, - }, + Effect.gen(function* () { + const scenario = "codex-provider-api-retry"; + const nativeThreadId = `native-${scenario}-thread`; + const nativeTurnId = `native-${scenario}-turn`; + const transcript = makeCodexReplayTranscript({ + scenario, + entries: [ + ...codexReplayPreamble({ + nativeThreadId, + nativeTurnId, + prompt: "Open github.com.", + }), + { + type: "emit_inbound", + label: "error/retry", + frame: { + method: "error", + params: { + threadId: nativeThreadId, + turnId: nativeTurnId, + willRetry: true, + error: { + message: "Reconnecting... 2/5", + additionalDetails: "The response stream disconnected.", + codexErrorInfo: { + responseStreamDisconnected: { httpStatusCode: 529 }, }, }, }, }, - { - type: "emit_inbound", - label: "error/retry/waiting-for-network", - frame: { - method: "error", - params: { - threadId: nativeThreadId, - turnId: nativeTurnId, - willRetry: true, - error: { - message: "Reconnecting... waiting for network", - additionalDetails: "Connection failed: error sending request", - codexErrorInfo: { - responseStreamDisconnected: { httpStatusCode: null }, - }, + }, + { + type: "emit_inbound", + label: "error/retry/waiting-for-network", + frame: { + method: "error", + params: { + threadId: nativeThreadId, + turnId: nativeTurnId, + willRetry: true, + error: { + message: "Reconnecting... waiting for network", + additionalDetails: "Connection failed: error sending request", + codexErrorInfo: { + responseStreamDisconnected: { httpStatusCode: null }, }, }, }, }, - { - type: "emit_inbound", - label: "item/started/after-retry", - frame: { - method: "item/started", - params: { - threadId: nativeThreadId, - turnId: nativeTurnId, - startedAtMs: 1782622445000, - item: { - type: "commandExecution", - id: "command-after-provider-retry", - command: "pwd", - cwd: "/workspace", - processId: "42", - source: "unifiedExecStartup", - status: "inProgress", - commandActions: [{ type: "unknown", command: "pwd" }], - aggregatedOutput: null, - exitCode: null, - durationMs: null, - }, + }, + { + type: "emit_inbound", + label: "item/started/after-retry", + frame: { + method: "item/started", + params: { + threadId: nativeThreadId, + turnId: nativeTurnId, + startedAtMs: 1782622445000, + item: { + type: "commandExecution", + id: "command-after-provider-retry", + command: "pwd", + cwd: "/workspace", + processId: "42", + source: "unifiedExecStartup", + status: "inProgress", + commandActions: [{ type: "unknown", command: "pwd" }], + aggregatedOutput: null, + exitCode: null, + durationMs: null, }, }, }, - { - type: "emit_inbound", - label: "item/completed/after-retry", - frame: { - method: "item/completed", - params: { - threadId: nativeThreadId, - turnId: nativeTurnId, - completedAtMs: 1782622445010, - item: { - type: "commandExecution", - id: "command-after-provider-retry", - command: "pwd", - cwd: "/workspace", - processId: "42", - source: "unifiedExecStartup", - status: "completed", - commandActions: [{ type: "unknown", command: "pwd" }], - aggregatedOutput: "/workspace\n", - exitCode: 0, - durationMs: 10, - }, + }, + { + type: "emit_inbound", + label: "item/completed/after-retry", + frame: { + method: "item/completed", + params: { + threadId: nativeThreadId, + turnId: nativeTurnId, + completedAtMs: 1782622445010, + item: { + type: "commandExecution", + id: "command-after-provider-retry", + command: "pwd", + cwd: "/workspace", + processId: "42", + source: "unifiedExecStartup", + status: "completed", + commandActions: [{ type: "unknown", command: "pwd" }], + aggregatedOutput: "/workspace\n", + exitCode: 0, + durationMs: 10, }, }, }, - { - type: "emit_inbound", - label: "turn/completed", - frame: { - method: "turn/completed", - params: { - threadId: nativeThreadId, - turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), - }, + }, + { + type: "emit_inbound", + label: "turn/completed", + frame: { + method: "turn/completed", + params: { + threadId: nativeThreadId, + turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), }, }, - ], - }); - const harness = yield* makeCodexReplayHarness(transcript); - const now = yield* DateTime.now; - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-codex-provider-api-retry"), - text: "Open github.com.", - }), - ); - yield* harness.awaitTerminal(1); + }, + ], + }); + const harness = yield* makeCodexReplayHarness(transcript); + const now = yield* DateTime.now; + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-codex-provider-api-retry"), + text: "Open github.com.", + }), + ); + yield* harness.awaitTerminal(1); + + const retryItems = harness.events.flatMap((event) => + event.type === "turn_item.updated" && + event.turnItem.type === "error" && + event.turnItem.retry !== undefined + ? [event.turnItem] + : [], + ); + assert.lengthOf(retryItems, 3); + assert.equal(retryItems[0]?.status, "running"); + assert.equal(retryItems[0]?.failure.code, "responseStreamDisconnected"); + assert.deepEqual(retryItems[0]?.retry, { + attempt: 2, + maxAttempts: 5, + retryDelayMs: null, + }); + assert.deepEqual(retryItems[1]?.retry, { + attempt: 3, + maxAttempts: null, + retryDelayMs: null, + }); + assert.equal(retryItems[2]?.id, retryItems[0]?.id); + assert.equal(retryItems[2]?.status, "completed"); + assert.equal(retryItems[2]?.title, "Provider recovered"); - const retryItems = harness.events.flatMap((event) => + const recoveredIndex = harness.events.findIndex( + (event) => event.type === "turn_item.updated" && event.turnItem.type === "error" && - event.turnItem.retry !== undefined - ? [event.turnItem] - : [], - ); - assert.lengthOf(retryItems, 3); - assert.equal(retryItems[0]?.status, "running"); - assert.equal(retryItems[0]?.failure.code, "responseStreamDisconnected"); - assert.deepEqual(retryItems[0]?.retry, { - attempt: 2, - maxAttempts: 5, - retryDelayMs: null, - }); - assert.deepEqual(retryItems[1]?.retry, { - attempt: 3, - maxAttempts: null, - retryDelayMs: null, - }); - assert.equal(retryItems[2]?.id, retryItems[0]?.id); - assert.equal(retryItems[2]?.status, "completed"); - assert.equal(retryItems[2]?.title, "Provider recovered"); + event.turnItem.status === "completed", + ); + const resumedCommandIndex = harness.events.findIndex( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "command_execution" && + event.turnItem.input === "pwd", + ); + const terminalIndex = harness.events.findIndex((event) => event.type === "turn.terminal"); + assert.isAtLeast(recoveredIndex, 0); + assert.isAbove(resumedCommandIndex, recoveredIndex); + assert.isAbove(terminalIndex, resumedCommandIndex); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); - const recoveredIndex = harness.events.findIndex( - (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "error" && - event.turnItem.status === "completed", - ); - const resumedCommandIndex = harness.events.findIndex( - (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "command_execution" && - event.turnItem.input === "pwd", - ); - const terminalIndex = harness.events.findIndex((event) => event.type === "turn.terminal"); - assert.isAtLeast(recoveredIndex, 0); - assert.isAbove(resumedCommandIndex, recoveredIndex); - assert.isAbove(terminalIndex, resumedCommandIndex); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + it.effect("fails a stalled 429 provider retry as a usage limit and stops the Codex turn", () => + Effect.gen(function* () { + const scenario = "codex-provider-retry-stall"; + const nativeThreadId = `native-${scenario}-thread`; + const nativeTurnId = `native-${scenario}-turn`; + const transcript = makeCodexReplayTranscript({ + scenario, + entries: [ + ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "Continue." }), + { + type: "emit_inbound", + label: "error/retry", + frame: { + method: "error", + params: { + threadId: nativeThreadId, + turnId: nativeTurnId, + willRetry: true, + error: { + message: "Reconnecting... 1/5", + additionalDetails: "exceeded retry limit, last status: 429 Too Many Requests", + codexErrorInfo: { responseStreamDisconnected: { httpStatusCode: 429 } }, + }, + }, + }, + }, + { + type: "expect_outbound", + label: "turn/interrupt", + frame: { + id: 4, + method: "turn/interrupt", + params: { threadId: nativeThreadId, turnId: nativeTurnId }, + }, + }, + { type: "emit_inbound", label: "turn/interrupt", frame: { id: 4, result: {} } }, + { + type: "emit_inbound", + label: "turn/completed", + frame: { + method: "turn/completed", + params: { + threadId: nativeThreadId, + turn: makeCodexReplayTurn({ id: nativeTurnId, status: "interrupted" }), + }, + }, + }, + ], + }); + const retryShown = yield* Deferred.make(); + const interruptSent = yield* Deferred.make(); + const harness = yield* makeCodexReplayHarness( + transcript, + (event) => + event.type === "turn_item.updated" && event.turnItem.type === "error" + ? Deferred.succeed(retryShown, undefined) + : Effect.void, + (method) => + method === "turn/interrupt" ? Deferred.succeed(interruptSent, undefined) : Effect.void, + ); + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + text: "Continue.", + attemptId: RunAttemptId.make(`attempt-${scenario}`), + }), + ); + yield* Deferred.await(retryShown); + yield* TestClock.adjust("9 minutes"); + assert.isEmpty(harness.terminalEvents()); + yield* TestClock.adjust("1 minute"); + yield* harness.firstTerminal; + yield* Deferred.await(interruptSent); + const terminal = harness.terminalEvents()[0]; + assert.equal(terminal?.status, "failed"); + if (terminal?.status !== "failed") return; + assert.equal(terminal.failure.class, "usage_limit"); + assert.equal(terminal.failure.code, "responseStreamDisconnected"); + assert.equal( + terminal.failure.message, + "exceeded retry limit, last status: 429 Too Many Requests", + ); + assert.equal(terminal.retry?.attempt, 1); + assert.equal(terminal.threadDisposition, "reusable"); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), ); - it.effect("fails a stalled 429 provider retry as a usage limit and stops the Codex turn", () => - Effect.scoped( + it.effect.each(["completed", "interrupted"] as const)( + "retains Codex reasoning parts when the turn is %s", + (terminalStatus) => Effect.gen(function* () { - const scenario = "codex-provider-retry-stall"; + const scenario = `codex-reasoning-${terminalStatus}`; const nativeThreadId = `native-${scenario}-thread`; const nativeTurnId = `native-${scenario}-turn`; + const prompt = "Explain the check."; const transcript = makeCodexReplayTranscript({ scenario, entries: [ - ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "Continue." }), - { - type: "emit_inbound", - label: "error/retry", - frame: { - method: "error", + ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt }), + ...[ + { + method: "item/reasoning/summaryTextDelta", + params: { itemId: "thought", summaryIndex: 0, delta: "Summary " }, + }, + { + method: "item/reasoning/summaryTextDelta", + params: { itemId: "thought", summaryIndex: 0, delta: "one" }, + }, + { + method: "item/reasoning/summaryTextDelta", + params: { itemId: "thought", summaryIndex: 1, delta: "Summary two" }, + }, + { + method: "item/reasoning/textDelta", + params: { itemId: "thought", contentIndex: 0, delta: "Raw trace" }, + }, + { + method: "item/completed", params: { - threadId: nativeThreadId, - turnId: nativeTurnId, - willRetry: true, - error: { - message: "Reconnecting... 1/5", - additionalDetails: "exceeded retry limit, last status: 429 Too Many Requests", - codexErrorInfo: { responseStreamDisconnected: { httpStatusCode: 429 } }, + item: { + type: "commandExecution", + id: "after-thought", + command: "pwd", + cwd: "/workspace", + processId: "42", + source: "unifiedExecStartup", + status: "completed", + commandActions: [{ type: "unknown", command: "pwd" }], + aggregatedOutput: "/workspace", + exitCode: 0, + durationMs: 1, }, }, }, - }, - { - type: "expect_outbound", - label: "turn/interrupt", + ...(terminalStatus === "completed" + ? [ + { + method: "item/completed", + params: { + item: { + type: "reasoning", + id: "thought", + summary: ["Final summary one", "Summary two"], + content: ["Raw trace"], + }, + }, + }, + { + method: "item/completed", + params: { + item: { + type: "reasoning", + id: "completion-only", + summary: ["Completion without deltas"], + content: [], + }, + }, + }, + { + method: "item/reasoning/textDelta", + params: { + itemId: "delta-only", + contentIndex: 0, + delta: "Retained when completion omits content", + }, + }, + { + method: "item/completed", + params: { + item: { type: "reasoning", id: "delta-only", summary: [], content: [] }, + }, + }, + ] + : []), + ].map((event, index) => ({ + type: "emit_inbound" as const, + label: `reasoning-${index}`, frame: { - id: 4, - method: "turn/interrupt", - params: { threadId: nativeThreadId, turnId: nativeTurnId }, + method: event.method, + params: { threadId: nativeThreadId, turnId: nativeTurnId, ...event.params }, }, - }, - { type: "emit_inbound", label: "turn/interrupt", frame: { id: 4, result: {} } }, + })), { type: "emit_inbound", label: "turn/completed", @@ -4442,49 +4564,61 @@ describe("CodexAdapterV2 post-settle continuation", () => { method: "turn/completed", params: { threadId: nativeThreadId, - turn: makeCodexReplayTurn({ id: nativeTurnId, status: "interrupted" }), + turn: makeCodexReplayTurn({ id: nativeTurnId, status: terminalStatus }), }, }, }, ], }); - const retryShown = yield* Deferred.make(); - const interruptSent = yield* Deferred.make(); - const harness = yield* makeCodexReplayHarness( - transcript, - (event) => - event.type === "turn_item.updated" && event.turnItem.type === "error" - ? Deferred.succeed(retryShown, undefined) - : Effect.void, - (method) => - method === "turn/interrupt" ? Deferred.succeed(interruptSent, undefined) : Effect.void, - ); + const harness = yield* makeCodexReplayHarness(transcript); yield* harness.runtime.startTurn( makeCodexTestTurnInput({ threadId: harness.threadId, providerThread: harness.providerThread, now: yield* DateTime.now, - text: "Continue.", attemptId: RunAttemptId.make(`attempt-${scenario}`), + text: prompt, }), ); - yield* Deferred.await(retryShown); - yield* TestClock.adjust("9 minutes"); - assert.isEmpty(harness.terminalEvents()); - yield* TestClock.adjust("1 minute"); yield* harness.firstTerminal; - yield* Deferred.await(interruptSent); - const terminal = harness.terminalEvents()[0]; - assert.equal(terminal?.status, "failed"); - if (terminal?.status !== "failed") return; - assert.equal(terminal.failure.class, "usage_limit"); - assert.equal(terminal.failure.code, "responseStreamDisconnected"); - assert.equal( - terminal.failure.message, - "exceeded retry limit, last status: 429 Too Many Requests", + const latest = new Map( + harness.events.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "reasoning" + ? [[event.turnItem.id, event.turnItem] as const] + : [], + ), ); - assert.equal(terminal.retry?.attempt, 1); - assert.equal(terminal.threadDisposition, "reusable"); + assert.deepEqual( + [...latest.values()].map((item) => item.text), + terminalStatus === "completed" + ? [ + "Final summary one", + "Summary two", + "Raw trace", + "Completion without deltas", + "Retained when completion omits content", + ] + : ["Summary one", "Summary two", "Raw trace"], + ); + assert.isTrue([...latest.values()].every((item) => item.status === terminalStatus)); + assert.isTrue([...latest.values()].every((item) => item.streaming === false)); + assert.isTrue( + [...latest.values()].every((item) => item.runId !== null && item.providerTurnId !== null), + ); + assert.equal(new Set([...latest.values()].map((item) => item.ordinal)).size, latest.size); + const command = harness.events.find( + (event) => + event.type === "turn_item.updated" && event.turnItem.type === "command_execution", + ); + assert.isDefined(command); + if (command?.type === "turn_item.updated") { + assert.isTrue( + [...latest.values()] + .slice(0, 3) + .every((item) => item.ordinal < command.turnItem.ordinal), + ); + } + assert.deepEqual(assistantMessages(harness.events), []); }).pipe( Effect.provide( Layer.mergeAll( @@ -4495,180 +4629,6 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ), ), - ), - ); - - it.effect.each(["completed", "interrupted"] as const)( - "retains Codex reasoning parts when the turn is %s", - (terminalStatus) => - Effect.scoped( - Effect.gen(function* () { - const scenario = `codex-reasoning-${terminalStatus}`; - const nativeThreadId = `native-${scenario}-thread`; - const nativeTurnId = `native-${scenario}-turn`; - const prompt = "Explain the check."; - const transcript = makeCodexReplayTranscript({ - scenario, - entries: [ - ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt }), - ...[ - { - method: "item/reasoning/summaryTextDelta", - params: { itemId: "thought", summaryIndex: 0, delta: "Summary " }, - }, - { - method: "item/reasoning/summaryTextDelta", - params: { itemId: "thought", summaryIndex: 0, delta: "one" }, - }, - { - method: "item/reasoning/summaryTextDelta", - params: { itemId: "thought", summaryIndex: 1, delta: "Summary two" }, - }, - { - method: "item/reasoning/textDelta", - params: { itemId: "thought", contentIndex: 0, delta: "Raw trace" }, - }, - { - method: "item/completed", - params: { - item: { - type: "commandExecution", - id: "after-thought", - command: "pwd", - cwd: "/workspace", - processId: "42", - source: "unifiedExecStartup", - status: "completed", - commandActions: [{ type: "unknown", command: "pwd" }], - aggregatedOutput: "/workspace", - exitCode: 0, - durationMs: 1, - }, - }, - }, - ...(terminalStatus === "completed" - ? [ - { - method: "item/completed", - params: { - item: { - type: "reasoning", - id: "thought", - summary: ["Final summary one", "Summary two"], - content: ["Raw trace"], - }, - }, - }, - { - method: "item/completed", - params: { - item: { - type: "reasoning", - id: "completion-only", - summary: ["Completion without deltas"], - content: [], - }, - }, - }, - { - method: "item/reasoning/textDelta", - params: { - itemId: "delta-only", - contentIndex: 0, - delta: "Retained when completion omits content", - }, - }, - { - method: "item/completed", - params: { - item: { type: "reasoning", id: "delta-only", summary: [], content: [] }, - }, - }, - ] - : []), - ].map((event, index) => ({ - type: "emit_inbound" as const, - label: `reasoning-${index}`, - frame: { - method: event.method, - params: { threadId: nativeThreadId, turnId: nativeTurnId, ...event.params }, - }, - })), - { - type: "emit_inbound", - label: "turn/completed", - frame: { - method: "turn/completed", - params: { - threadId: nativeThreadId, - turn: makeCodexReplayTurn({ id: nativeTurnId, status: terminalStatus }), - }, - }, - }, - ], - }); - const harness = yield* makeCodexReplayHarness(transcript); - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make(`attempt-${scenario}`), - text: prompt, - }), - ); - yield* harness.firstTerminal; - const latest = new Map( - harness.events.flatMap((event) => - event.type === "turn_item.updated" && event.turnItem.type === "reasoning" - ? [[event.turnItem.id, event.turnItem] as const] - : [], - ), - ); - assert.deepEqual( - [...latest.values()].map((item) => item.text), - terminalStatus === "completed" - ? [ - "Final summary one", - "Summary two", - "Raw trace", - "Completion without deltas", - "Retained when completion omits content", - ] - : ["Summary one", "Summary two", "Raw trace"], - ); - assert.isTrue([...latest.values()].every((item) => item.status === terminalStatus)); - assert.isTrue([...latest.values()].every((item) => item.streaming === false)); - assert.isTrue( - [...latest.values()].every( - (item) => item.runId !== null && item.providerTurnId !== null, - ), - ); - assert.equal(new Set([...latest.values()].map((item) => item.ordinal)).size, latest.size); - const command = harness.events.find( - (event) => - event.type === "turn_item.updated" && event.turnItem.type === "command_execution", - ); - assert.isDefined(command); - if (command?.type === "turn_item.updated") { - assert.isTrue( - [...latest.values()] - .slice(0, 3) - .every((item) => item.ordinal < command.turnItem.ordinal), - ); - } - assert.deepEqual(assistantMessages(harness.events), []); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), - ), - ), - ), ); const finalAnswerTranscript = ( @@ -4774,106 +4734,104 @@ describe("CodexAdapterV2 post-settle continuation", () => { it.effect.each([false, true])( "preserves a final answer that repeats a preceding partial answer (streamed: %s)", (streamed) => - Effect.scoped( - Effect.gen(function* () { - const transcript = finalAnswerTranscript("codex-partial-then-final", [ - { - id: "status", - text: "Checking the files", - phase: "commentary", - streamed, - }, - { - id: "partial", - text: "CODEX_RECOVERY_OK", - phase: "partial_answer", - streamed, - }, - { - id: "final", - text: "CODEX_RECOVERY_OK", - phase: "final_answer", - streamed, - }, - ]); - const gates = yield* Effect.forEach(["status", "partial", "final"], (id) => - Effect.gen(function* () { - return { - id, - ready: yield* Deferred.make(), - release: yield* Deferred.make(), - }; - }), - ); - const harness = yield* makeCodexReplayHarness( - transcript, - undefined, - undefined, - undefined, - (entry) => { - const gate = streamed - ? gates.find((gate) => entry.label === `item/completed/${gate.id}`) - : undefined; - return gate === undefined - ? Effect.void - : Deferred.succeed(gate.ready, undefined).pipe( - Effect.andThen(Deferred.await(gate.release)), - ); - }, - ); - const now = yield* DateTime.now; - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-codex-partial-then-final"), - text: "Reply with the requested recovery marker.", - }), - ); - if (streamed) { - for (const gate of gates) { - yield* Deferred.await(gate.ready); - yield* TestClock.adjust("50 millis"); - yield* harness.until(() => - harness.events.some( - (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "assistant_message" && - event.turnItem.nativeItemRef?.nativeId === gate.id && - event.turnItem.streaming, - ), - ); - yield* Deferred.succeed(gate.release, undefined); - } - } - yield* harness.awaitTerminal(1); - assert.deepEqual( - assistantMessages(harness.events).map((event) => event.message.text), - ["Checking the files", "CODEX_RECOVERY_OK", "CODEX_RECOVERY_OK"], - ); - const updates = harness.events.flatMap((event) => - event.type === "turn_item.updated" && event.turnItem.type === "assistant_message" - ? [event.turnItem] - : [], - ); - for (const [text, phase] of [ - ["Checking the files", "commentary"], - ["CODEX_RECOVERY_OK", "partial_answer"], - ["CODEX_RECOVERY_OK", "final_answer"], - ]) { - const matching = updates.filter((item) => item.text === text && item.phase === phase); - assert.isTrue(matching.some((item) => !item.streaming)); - if (streamed) assert.isTrue(matching.some((item) => item.streaming)); + Effect.gen(function* () { + const transcript = finalAnswerTranscript("codex-partial-then-final", [ + { + id: "status", + text: "Checking the files", + phase: "commentary", + streamed, + }, + { + id: "partial", + text: "CODEX_RECOVERY_OK", + phase: "partial_answer", + streamed, + }, + { + id: "final", + text: "CODEX_RECOVERY_OK", + phase: "final_answer", + streamed, + }, + ]); + const gates = yield* Effect.forEach(["status", "partial", "final"], (id) => + Effect.gen(function* () { + return { + id, + ready: yield* Deferred.make(), + release: yield* Deferred.make(), + }; + }), + ); + const harness = yield* makeCodexReplayHarness( + transcript, + undefined, + undefined, + undefined, + (entry) => { + const gate = streamed + ? gates.find((gate) => entry.label === `item/completed/${gate.id}`) + : undefined; + return gate === undefined + ? Effect.void + : Deferred.succeed(gate.ready, undefined).pipe( + Effect.andThen(Deferred.await(gate.release)), + ); + }, + ); + const now = yield* DateTime.now; + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-codex-partial-then-final"), + text: "Reply with the requested recovery marker.", + }), + ); + if (streamed) { + for (const gate of gates) { + yield* Deferred.await(gate.ready); + yield* TestClock.adjust("50 millis"); + yield* harness.until(() => + harness.events.some( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "assistant_message" && + event.turnItem.nativeItemRef?.nativeId === gate.id && + event.turnItem.streaming, + ), + ); + yield* Deferred.succeed(gate.release, undefined); } - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + } + yield* harness.awaitTerminal(1); + assert.deepEqual( + assistantMessages(harness.events).map((event) => event.message.text), + ["Checking the files", "CODEX_RECOVERY_OK", "CODEX_RECOVERY_OK"], + ); + const updates = harness.events.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "assistant_message" + ? [event.turnItem] + : [], + ); + for (const [text, phase] of [ + ["Checking the files", "commentary"], + ["CODEX_RECOVERY_OK", "partial_answer"], + ["CODEX_RECOVERY_OK", "final_answer"], + ]) { + const matching = updates.filter((item) => item.text === text && item.phase === phase); + assert.isTrue(matching.some((item) => !item.streaming)); + if (streamed) assert.isTrue(matching.some((item) => item.streaming)); + } + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), @@ -5159,38 +5117,36 @@ describe("CodexAdapterV2 post-settle continuation", () => { ); it.effect("suppresses a trailing empty final answer after a non-empty final answer", () => - Effect.scoped( - Effect.gen(function* () { - const transcript = finalAnswerTranscript("codex-redundant-empty-final", [ - { id: "answer-non-empty", text: "CODEX_RECOVERY_OK" }, - { id: "answer-empty", text: "" }, - ]); - const harness = yield* makeCodexReplayHarness(transcript); - const now = yield* DateTime.now; + Effect.gen(function* () { + const transcript = finalAnswerTranscript("codex-redundant-empty-final", [ + { id: "answer-non-empty", text: "CODEX_RECOVERY_OK" }, + { id: "answer-empty", text: "" }, + ]); + const harness = yield* makeCodexReplayHarness(transcript); + const now = yield* DateTime.now; - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-codex-redundant-empty-final"), - text: "Reply with the requested recovery marker.", - }), - ); - yield* harness.awaitTerminal(1); + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-codex-redundant-empty-final"), + text: "Reply with the requested recovery marker.", + }), + ); + yield* harness.awaitTerminal(1); - assert.deepEqual( - assistantMessages(harness.events).map((event) => event.message.text), - ["CODEX_RECOVERY_OK"], - ); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + assert.deepEqual( + assistantMessages(harness.events).map((event) => event.message.text), + ["CODEX_RECOVERY_OK"], + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), @@ -5332,52 +5288,50 @@ describe("CodexAdapterV2 post-settle continuation", () => { ); it.effect("wakes with well-formed text when the cuts split an emoji", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeCodexReplayHarness( - makeCodexReplayTranscript({ - scenario: "codex-bg-wake-surrogate", - entries: [ - ...backgroundExecTranscript.entries.slice(0, -1), - { - type: "emit_inbound", - label: "item/completed/command-late", - frame: { - method: "item/completed", - params: { - item: { - ...backgroundCommandItem("completed"), - command: `${"c".repeat(199)}🚀`, - aggregatedOutput: `🚀${"x".repeat(999)}\n`, - }, - threadId: BG_NATIVE_THREAD, - turnId: BG_NATIVE_TURN, - completedAtMs: 1782622465500, + Effect.gen(function* () { + const harness = yield* makeCodexReplayHarness( + makeCodexReplayTranscript({ + scenario: "codex-bg-wake-surrogate", + entries: [ + ...backgroundExecTranscript.entries.slice(0, -1), + { + type: "emit_inbound", + label: "item/completed/command-late", + frame: { + method: "item/completed", + params: { + item: { + ...backgroundCommandItem("completed"), + command: `${"c".repeat(199)}🚀`, + aggregatedOutput: `🚀${"x".repeat(999)}\n`, }, + threadId: BG_NATIVE_THREAD, + turnId: BG_NATIVE_TURN, + completedAtMs: 1782622465500, }, }, - ], - }), - ); - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("attempt-codex-bg-wake-surrogate"), - text: BG_PROMPT, - }), - ); - yield* harness.until(() => harness.continuationRequests.length > 0); - assert.isTrue(harness.continuationRequests[0]?.detail?.isWellFormed()); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + }, + ], + }), + ); + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-codex-bg-wake-surrogate"), + text: BG_PROMPT, + }), + ); + yield* harness.until(() => harness.continuationRequests.length > 0); + assert.isTrue(harness.continuationRequests[0]?.detail?.isWellFormed()); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), @@ -5612,67 +5566,65 @@ describe("CodexAdapterV2 post-settle continuation", () => { (terminated) => { const stillRunning = terminated === "still_running"; const transcript = makeBackgroundStopTranscript(terminated); - return Effect.scoped( - Effect.gen(function* () { - const stopped = yield* Deferred.make(); - const harness = yield* makeCodexReplayHarness(transcript, (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "command_execution" && - event.turnItem.status === "interrupted" - ? Deferred.succeed(stopped, undefined) - : Effect.void, - ); - const now = yield* DateTime.now; - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, + return Effect.gen(function* () { + const stopped = yield* Deferred.make(); + const harness = yield* makeCodexReplayHarness(transcript, (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "command_execution" && + event.turnItem.status === "interrupted" + ? Deferred.succeed(stopped, undefined) + : Effect.void, + ); + const now = yield* DateTime.now; + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-codex-bg-stop"), + text: BG_PROMPT, + }), + ); + yield* harness.firstTerminal; + const terminal = harness.terminalEvents()[0]!; + assert.equal(terminal.status, "completed"); + assert.isTrue(yield* harness.hasPendingBackgroundWork); + assert.isFalse( + yield* harness.runtime.hasPendingBackgroundWorkForThread!({ + ...harness.providerThread, + id: ProviderThreadId.make("unrelated-provider-thread"), + }), + ); + if (stillRunning) { + const failed = yield* harness.runtime + .interruptTurn({ providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-codex-bg-stop"), - text: BG_PROMPT, - }), - ); - yield* harness.firstTerminal; - const terminal = harness.terminalEvents()[0]!; - assert.equal(terminal.status, "completed"); + providerTurnId: terminal.providerTurnId, + requestRuntimeRestart: true, + }) + .pipe(Effect.exit); + assert.equal(failed._tag, "Failure"); assert.isTrue(yield* harness.hasPendingBackgroundWork); - assert.isFalse( - yield* harness.runtime.hasPendingBackgroundWorkForThread!({ - ...harness.providerThread, - id: ProviderThreadId.make("unrelated-provider-thread"), - }), - ); - if (stillRunning) { - const failed = yield* harness.runtime - .interruptTurn({ - providerThread: harness.providerThread, - providerTurnId: terminal.providerTurnId, - requestRuntimeRestart: true, - }) - .pipe(Effect.exit); - assert.equal(failed._tag, "Failure"); - assert.isTrue(yield* harness.hasPendingBackgroundWork); - } - yield* harness.runtime.interruptTurn({ - providerThread: harness.providerThread, - providerTurnId: stillRunning - ? ProviderTurnId.make("later-completed-turn") - : terminal.providerTurnId, - requestRuntimeRestart: true, - }); - yield* Deferred.await(stopped); - assert.isFalse(yield* harness.hasPendingBackgroundWork); - assert.lengthOf(harness.terminalEvents(), 1); - assert.equal(harness.terminalEvents()[0]?.status, "completed"); - assert.lengthOf(harness.continuationRequests, 0); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + } + yield* harness.runtime.interruptTurn({ + providerThread: harness.providerThread, + providerTurnId: stillRunning + ? ProviderTurnId.make("later-completed-turn") + : terminal.providerTurnId, + requestRuntimeRestart: true, + }); + yield* Deferred.await(stopped); + assert.isFalse(yield* harness.hasPendingBackgroundWork); + assert.lengthOf(harness.terminalEvents(), 1); + assert.equal(harness.terminalEvents()[0]?.status, "completed"); + assert.lengthOf(harness.continuationRequests, 0); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ); @@ -5680,246 +5632,242 @@ describe("CodexAdapterV2 post-settle continuation", () => { ); it.effect("stops a completed run's background command without a client-supplied run", () => { const transcript = makeBackgroundStopTranscript(true); - return Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-bg-stop-workspace-" }); - const localTranscript = yield* decodeReplayTranscriptJson( - (yield* encodeReplayTranscriptJson(transcript)).replaceAll( - yield* encodeStringJson("/workspace"), - yield* encodeStringJson(cwd), + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-bg-stop-workspace-" }); + const localTranscript = yield* decodeReplayTranscriptJson( + (yield* encodeReplayTranscriptJson(transcript)).replaceAll( + yield* encodeStringJson("/workspace"), + yield* encodeStringJson(cwd), + ), + ); + const replayDriver = yield* CodexReplay.makeReplayDriver(withConfigReads(localTranscript)); + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + assert.equal( + Number(yield* spawner.exitCode(ChildProcess.make("git", ["init", "--quiet"], { cwd }))), + 0, + ); + assert.equal( + Number( + yield* spawner.exitCode( + ChildProcess.make( + "git", + [ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "commit", + "--allow-empty", + "--quiet", + "-m", + "Initial commit", + ], + { cwd }, + ), ), + ), + 0, + ); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const threadId = ThreadId.make("thread:background-stop"); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create-background-stop"), + threadId, + projectId: ProjectId.make("project:background-stop"), + title: "Background stop", + modelSelection: CODEX_TEST_MODEL_SELECTION, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + createdBy: "user", + creationSource: "web", + }); + const waiting = yield* orchestrator.streamDomainEvents.pipe( + Stream.filter( + (event) => event.type === "run.updated" && event.payload.status === "waiting", + ), + Stream.runHead, + Effect.forkChild({ startImmediately: true }), ); - const replayDriver = yield* CodexReplay.makeReplayDriver(withConfigReads(localTranscript)); - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - assert.equal( - Number(yield* spawner.exitCode(ChildProcess.make("git", ["init", "--quiet"], { cwd }))), - 0, - ); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("start-background-stop"), + threadId, + messageId: MessageId.make("message:background-stop"), + text: BG_PROMPT, + attachments: [], + createdBy: "user", + creationSource: "web", + dispatchMode: { type: "start_immediately" }, + }); + yield* worker.drain(); + assert.isNull((yield* Ref.get(replayDriver.state)).failure); + yield* Fiber.join(waiting); + yield* worker.drain(); + const projection = yield* orchestrator.getThreadProjection(threadId); + const run = projection.runs.at(-1)!; + assert.equal(run.status, "completed"); assert.equal( - Number( - yield* spawner.exitCode( - ChildProcess.make( - "git", - [ - "-c", - "user.name=Test", - "-c", - "user.email=test@example.com", - "commit", - "--allow-empty", - "--quiet", - "-m", - "Initial commit", - ], - { cwd }, - ), - ), - ), - 0, + (yield* orchestrator.getThreadShell(threadId))?.pendingBackgroundTasks?.length, + 1, ); - yield* Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; - const threadId = ThreadId.make("thread:background-stop"); - yield* orchestrator.dispatch({ - type: "thread.create", - commandId: CommandId.make("create-background-stop"), - threadId, - projectId: ProjectId.make("project:background-stop"), - title: "Background stop", - modelSelection: CODEX_TEST_MODEL_SELECTION, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: cwd, - createdBy: "user", - creationSource: "web", - }); - const waiting = yield* orchestrator.streamDomainEvents.pipe( - Stream.filter( - (event) => event.type === "run.updated" && event.payload.status === "waiting", - ), - Stream.runHead, - Effect.forkChild({ startImmediately: true }), - ); - yield* orchestrator.dispatch({ - type: "message.dispatch", - commandId: CommandId.make("start-background-stop"), - threadId, - messageId: MessageId.make("message:background-stop"), - text: BG_PROMPT, - attachments: [], - createdBy: "user", - creationSource: "web", - dispatchMode: { type: "start_immediately" }, - }); - yield* worker.drain(); - assert.isNull((yield* Ref.get(replayDriver.state)).failure); - yield* Fiber.join(waiting); - yield* worker.drain(); - const projection = yield* orchestrator.getThreadProjection(threadId); - const run = projection.runs.at(-1)!; - assert.equal(run.status, "completed"); - assert.equal( - (yield* orchestrator.getThreadShell(threadId))?.pendingBackgroundTasks?.length, - 1, - ); - const stopped = yield* orchestrator.streamDomainEvents.pipe( - Stream.filter( - (event) => - event.type === "turn-item.updated" && - event.payload.type === "command_execution" && - event.payload.status === "interrupted", - ), - Stream.runHead, - Effect.forkChild({ startImmediately: true }), - ); - yield* orchestrator.dispatch({ - type: "thread.stop", - commandId: CommandId.make("stop-background-command"), - threadId, - }); - yield* worker.drain(); - yield* Fiber.join(stopped); - assert.equal( - (yield* orchestrator.getThreadProjection(threadId)).runs.at(-1)?.status, - "completed", - ); - assert.deepEqual( - (yield* orchestrator.getThreadShell(threadId))?.pendingBackgroundTasks, - [], - ); - }).pipe( - Effect.provide( - ProviderReplayHarness.layerWithRegistry( - { name: "codex-background-stop", runtimePolicyOverride: { cwd } }, - CodexAdapterV2Testkit.layer({ - transcript: localTranscript, - driver: replayDriver, - }), - { runEffectWorker: false }, - ), + const stopped = yield* orchestrator.streamDomainEvents.pipe( + Stream.filter( + (event) => + event.type === "turn-item.updated" && + event.payload.type === "command_execution" && + event.payload.status === "interrupted", ), + Stream.runHead, + Effect.forkChild({ startImmediately: true }), ); - }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), - ); + yield* orchestrator.dispatch({ + type: "thread.stop", + commandId: CommandId.make("stop-background-command"), + threadId, + }); + yield* worker.drain(); + yield* Fiber.join(stopped); + assert.equal( + (yield* orchestrator.getThreadProjection(threadId)).runs.at(-1)?.status, + "completed", + ); + assert.deepEqual( + (yield* orchestrator.getThreadShell(threadId))?.pendingBackgroundTasks, + [], + ); + }).pipe( + Effect.provide( + ProviderReplayHarness.layerWithRegistry( + { name: "codex-background-stop", runtimePolicyOverride: { cwd } }, + CodexAdapterV2Testkit.layer({ + transcript: localTranscript, + driver: replayDriver, + }), + { runEffectWorker: false }, + ), + ), + ); + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))); }); it.effect("Stop ends a background command no Codex process tracks any more", () => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-bg-stale-workspace-" }); - const staleTranscript = makeCodexReplayTranscript({ - scenario: "codex-bg-stop-untracked", - entries: [ - ...backgroundExecTranscript.entries.slice(0, -1), - { type: "runtime_exit", status: "success" }, - ], + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-bg-stale-workspace-" }); + const staleTranscript = makeCodexReplayTranscript({ + scenario: "codex-bg-stop-untracked", + entries: [ + ...backgroundExecTranscript.entries.slice(0, -1), + { type: "runtime_exit", status: "success" }, + ], + }); + const localTranscript = yield* decodeReplayTranscriptJson( + (yield* encodeReplayTranscriptJson(staleTranscript)).replaceAll( + yield* encodeStringJson("/workspace"), + yield* encodeStringJson(cwd), + ), + ); + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + for (const args of [ + ["init", "--quiet"], + [ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "commit", + "--allow-empty", + "--quiet", + "-m", + "Initial commit", + ], + ]) { + assert.equal(Number(yield* spawner.exitCode(ChildProcess.make("git", args, { cwd }))), 0); + } + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const threadId = ThreadId.make("thread:background-stop-untracked"); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create-background-stop-untracked"), + threadId, + projectId: ProjectId.make("project:background-stop-untracked"), + title: "Background stop untracked", + modelSelection: CODEX_TEST_MODEL_SELECTION, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + createdBy: "user", + creationSource: "web", }); - const localTranscript = yield* decodeReplayTranscriptJson( - (yield* encodeReplayTranscriptJson(staleTranscript)).replaceAll( - yield* encodeStringJson("/workspace"), - yield* encodeStringJson(cwd), + const settled = yield* orchestrator.streamDomainEvents.pipe( + Stream.filter( + (event) => + event.type === "run.updated" && + (event.payload.status === "waiting" || event.payload.status === "completed"), ), + Stream.runHead, + Effect.forkChild({ startImmediately: true }), ); - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - for (const args of [ - ["init", "--quiet"], - [ - "-c", - "user.name=Test", - "-c", - "user.email=test@example.com", - "commit", - "--allow-empty", - "--quiet", - "-m", - "Initial commit", - ], - ]) { - assert.equal(Number(yield* spawner.exitCode(ChildProcess.make("git", args, { cwd }))), 0); - } - yield* Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; - const threadId = ThreadId.make("thread:background-stop-untracked"); - yield* orchestrator.dispatch({ - type: "thread.create", - commandId: CommandId.make("create-background-stop-untracked"), - threadId, - projectId: ProjectId.make("project:background-stop-untracked"), - title: "Background stop untracked", - modelSelection: CODEX_TEST_MODEL_SELECTION, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: cwd, - createdBy: "user", - creationSource: "web", - }); - const settled = yield* orchestrator.streamDomainEvents.pipe( - Stream.filter( - (event) => - event.type === "run.updated" && - (event.payload.status === "waiting" || event.payload.status === "completed"), - ), - Stream.runHead, - Effect.forkChild({ startImmediately: true }), - ); - yield* orchestrator.dispatch({ - type: "message.dispatch", - commandId: CommandId.make("start-background-stop-untracked"), - threadId, - messageId: MessageId.make("message:background-stop-untracked"), - text: BG_PROMPT, - attachments: [], - createdBy: "user", - creationSource: "web", - dispatchMode: { type: "start_immediately" }, - }); - yield* worker.drain(); - yield* Fiber.join(settled); - yield* worker.drain(); - const before = yield* orchestrator.getThreadShell(threadId); - assert.deepEqual( - before?.pendingBackgroundTasks?.map((task) => task.kind), - ["command"], - "the thread still shows the command the gone process never finished", - ); - const run = (yield* orchestrator.getThreadProjection(threadId)).runs.at(-1)!; - yield* orchestrator.dispatch({ - type: "run.interrupt", - commandId: CommandId.make("stop-background-untracked"), - threadId, - runId: run.id, - holdQueue: true, - }); - yield* worker.drain(); - const projection = yield* orchestrator.getThreadProjection(threadId); - assert.deepEqual( - projection.turnItems.flatMap((item) => - item.type === "command_execution" ? [item.status] : [], - ), - ["interrupted"], - ); - assert.equal(projection.runs.at(-1)?.status, "completed"); - assert.deepEqual( - (yield* orchestrator.getThreadShell(threadId))?.pendingBackgroundTasks, - [], - ); - }).pipe( - Effect.provide( - ProviderReplayHarness.layerWithRegistry( - { name: "codex-background-stop-untracked", runtimePolicyOverride: { cwd } }, - CodexAdapterV2Testkit.layer({ transcript: withConfigReads(localTranscript) }), - { runEffectWorker: false }, - ), + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("start-background-stop-untracked"), + threadId, + messageId: MessageId.make("message:background-stop-untracked"), + text: BG_PROMPT, + attachments: [], + createdBy: "user", + creationSource: "web", + dispatchMode: { type: "start_immediately" }, + }); + yield* worker.drain(); + yield* Fiber.join(settled); + yield* worker.drain(); + const before = yield* orchestrator.getThreadShell(threadId); + assert.deepEqual( + before?.pendingBackgroundTasks?.map((task) => task.kind), + ["command"], + "the thread still shows the command the gone process never finished", + ); + const run = (yield* orchestrator.getThreadProjection(threadId)).runs.at(-1)!; + yield* orchestrator.dispatch({ + type: "run.interrupt", + commandId: CommandId.make("stop-background-untracked"), + threadId, + runId: run.id, + holdQueue: true, + }); + yield* worker.drain(); + const projection = yield* orchestrator.getThreadProjection(threadId); + assert.deepEqual( + projection.turnItems.flatMap((item) => + item.type === "command_execution" ? [item.status] : [], ), + ["interrupted"], ); - }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), - ), + assert.equal(projection.runs.at(-1)?.status, "completed"); + assert.deepEqual( + (yield* orchestrator.getThreadShell(threadId))?.pendingBackgroundTasks, + [], + ); + }).pipe( + Effect.provide( + ProviderReplayHarness.layerWithRegistry( + { name: "codex-background-stop-untracked", runtimePolicyOverride: { cwd } }, + CodexAdapterV2Testkit.layer({ transcript: withConfigReads(localTranscript) }), + { runEffectWorker: false }, + ), + ), + ); + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); const INTERRUPT_SCENARIO = "codex-interrupt-mid-command"; @@ -6080,133 +6028,131 @@ describe("CodexAdapterV2 post-settle continuation", () => { }); it.effect("contains commands that start before and after the interrupt response", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeCodexReplayHarness(interruptMidCommandTranscript); - const now = yield* DateTime.now; + Effect.gen(function* () { + const harness = yield* makeCodexReplayHarness(interruptMidCommandTranscript); + const now = yield* DateTime.now; - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-codex-interrupt-mid-command"), - text: INTERRUPT_PROMPT, - }), - ); + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-codex-interrupt-mid-command"), + text: INTERRUPT_PROMPT, + }), + ); - yield* harness.until(() => - harness.events.some( - (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "command_execution" && - event.turnItem.status === "running", - ), - ); + yield* harness.until(() => + harness.events.some( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "command_execution" && + event.turnItem.status === "running", + ), + ); - const providerTurnId = harness.events.find( - ( - event, - ): event is Extract< - ProviderAdapter.ProviderAdapterV2Event, - { type: "provider_turn.updated" } - > => event.type === "provider_turn.updated", - )?.providerTurn.id; - assert.isDefined(providerTurnId); + const providerTurnId = harness.events.find( + ( + event, + ): event is Extract< + ProviderAdapter.ProviderAdapterV2Event, + { type: "provider_turn.updated" } + > => event.type === "provider_turn.updated", + )?.providerTurn.id; + assert.isDefined(providerTurnId); + + yield* harness.runtime.interruptTurn({ + providerThread: harness.providerThread, + providerTurnId, + }); - yield* harness.runtime.interruptTurn({ - providerThread: harness.providerThread, - providerTurnId, - }); + yield* harness.awaitTerminal(1); + assert.equal(harness.terminalEvents()[0]?.status, "interrupted"); - yield* harness.awaitTerminal(1); - assert.equal(harness.terminalEvents()[0]?.status, "interrupted"); + const terminalIndex = harness.events.findIndex((event) => event.type === "turn.terminal"); + assert.isAtLeast(terminalIndex, 0); - const terminalIndex = harness.events.findIndex((event) => event.type === "turn.terminal"); - assert.isAtLeast(terminalIndex, 0); - - let lastCommandBeforeTerminal: - | Extract - | undefined; - for (let index = 0; index < terminalIndex; index++) { - const event = harness.events[index]; - if (event?.type === "turn_item.updated" && event.turnItem.type === "command_execution") { - lastCommandBeforeTerminal = event; - } + let lastCommandBeforeTerminal: + | Extract + | undefined; + for (let index = 0; index < terminalIndex; index++) { + const event = harness.events[index]; + if (event?.type === "turn_item.updated" && event.turnItem.type === "command_execution") { + lastCommandBeforeTerminal = event; } - assert.isDefined(lastCommandBeforeTerminal); - assert.equal(lastCommandBeforeTerminal.turnItem.status, "interrupted"); - assert.isNotNull(lastCommandBeforeTerminal.turnItem.completedAt); + } + assert.isDefined(lastCommandBeforeTerminal); + assert.equal(lastCommandBeforeTerminal.turnItem.status, "interrupted"); + assert.isNotNull(lastCommandBeforeTerminal.turnItem.completedAt); - const interruptedCommandsBeforeTerminal = harness.events - .slice(0, terminalIndex) - .flatMap((event) => - event.type === "turn_item.updated" && - event.turnItem.type === "command_execution" && - event.turnItem.status === "interrupted" - ? [event.turnItem.input] - : [], - ) - .sort(); - assert.deepEqual( - interruptedCommandsBeforeTerminal, - [INTERRUPT_COMMAND, INTERRUPT_COMMAND_TWO].sort(), - ); + const interruptedCommandsBeforeTerminal = harness.events + .slice(0, terminalIndex) + .flatMap((event) => + event.type === "turn_item.updated" && + event.turnItem.type === "command_execution" && + event.turnItem.status === "interrupted" + ? [event.turnItem.input] + : [], + ) + .sort(); + assert.deepEqual( + interruptedCommandsBeforeTerminal, + [INTERRUPT_COMMAND, INTERRUPT_COMMAND_TWO].sort(), + ); - const interruptedCommandIndex = harness.events.findIndex( - (event, index) => - index < terminalIndex && - event.type === "turn_item.updated" && - event.turnItem.type === "command_execution" && - event.turnItem.status === "interrupted", - ); - assert.isAbove( - terminalIndex, - interruptedCommandIndex, - "command terminalization must precede turn.terminal", - ); + const interruptedCommandIndex = harness.events.findIndex( + (event, index) => + index < terminalIndex && + event.type === "turn_item.updated" && + event.turnItem.type === "command_execution" && + event.turnItem.status === "interrupted", + ); + assert.isAbove( + terminalIndex, + interruptedCommandIndex, + "command terminalization must precede turn.terminal", + ); - assert.isFalse(yield* harness.hasPendingBackgroundWork); - assert.lengthOf(harness.continuationRequests, 0); + assert.isFalse(yield* harness.hasPendingBackgroundWork); + assert.lengthOf(harness.continuationRequests, 0); - yield* TestClock.adjust("30 seconds"); - for (let attempt = 0; attempt < 100; attempt++) { - yield* Effect.yieldNow; - } - assert.lengthOf(harness.continuationRequests, 0); - assert.isFalse(yield* harness.hasPendingBackgroundWork); - assert.lengthOf(harness.terminalEvents(), 1); + yield* TestClock.adjust("30 seconds"); + for (let attempt = 0; attempt < 100; attempt++) { + yield* Effect.yieldNow; + } + assert.lengthOf(harness.continuationRequests, 0); + assert.isFalse(yield* harness.hasPendingBackgroundWork); + assert.lengthOf(harness.terminalEvents(), 1); - const postTerminalCommandUpdates = harness.events.filter( - (event, index) => - index > terminalIndex && - event.type === "turn_item.updated" && - event.turnItem.type === "command_execution", - ); - assert.lengthOf( - postTerminalCommandUpdates, - 0, - "late item/completed after interrupt must not project", - ); + const postTerminalCommandUpdates = harness.events.filter( + (event, index) => + index > terminalIndex && + event.type === "turn_item.updated" && + event.turnItem.type === "command_execution", + ); + assert.lengthOf( + postTerminalCommandUpdates, + 0, + "late item/completed after interrupt must not project", + ); - const commandUpdates = harness.events.filter( - ( - event, - ): event is Extract< - ProviderAdapter.ProviderAdapterV2Event, - { type: "turn_item.updated" } - > => event.type === "turn_item.updated" && event.turnItem.type === "command_execution", - ); - assert.isAtLeast(commandUpdates.length, 2, "start + interrupt terminalization"); - assert.equal(commandUpdates[commandUpdates.length - 1]?.turnItem.status, "interrupted"); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + const commandUpdates = harness.events.filter( + ( + event, + ): event is Extract< + ProviderAdapter.ProviderAdapterV2Event, + { type: "turn_item.updated" } + > => event.type === "turn_item.updated" && event.turnItem.type === "command_execution", + ); + assert.isAtLeast(commandUpdates.length, 2, "start + interrupt terminalization"); + assert.equal(commandUpdates[commandUpdates.length - 1]?.turnItem.status, "interrupted"); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), @@ -6372,82 +6318,80 @@ describe("CodexAdapterV2 post-settle continuation", () => { "child provider turn must terminalize before the root run", ); assert.isAbove( - terminalIndex, - childProviderThreadIndex, - "child provider thread must become idle before the root run", - ); - }; - - it.effect("contains descendant commands and keeps Stop authoritative", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeCodexReplayHarness(interruptSubagentCommandTranscript); - const now = yield* DateTime.now; - - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-codex-interrupt-subagent-command"), - text: INTERRUPT_PROMPT, - }), - ); - yield* harness.until(() => - harness.events.some( - (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "command_execution" && - event.turnItem.nativeItemRef?.nativeId === INTERRUPT_CHILD_COMMAND_ITEM && - event.turnItem.status === "running", - ), - ); - const providerTurnId = harness.events.find( - ( - event, - ): event is Extract< - ProviderAdapter.ProviderAdapterV2Event, - { type: "provider_turn.updated" } - > => event.type === "provider_turn.updated" && event.threadId === harness.threadId, - )?.providerTurn.id; - assert.isDefined(providerTurnId); + terminalIndex, + childProviderThreadIndex, + "child provider thread must become idle before the root run", + ); + }; - yield* harness.runtime.interruptTurn({ - providerThread: harness.providerThread, - providerTurnId, - }); + it.effect("contains descendant commands and keeps Stop authoritative", () => + Effect.gen(function* () { + const harness = yield* makeCodexReplayHarness(interruptSubagentCommandTranscript); + const now = yield* DateTime.now; - yield* harness.awaitTerminal(1); - assert.equal(harness.terminalEvents()[0]?.status, "interrupted"); - const childCommandUpdates = harness.events.filter( - ( - event, - ): event is Extract< - ProviderAdapter.ProviderAdapterV2Event, - { type: "turn_item.updated" } - > => + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-codex-interrupt-subagent-command"), + text: INTERRUPT_PROMPT, + }), + ); + yield* harness.until(() => + harness.events.some( + (event) => event.type === "turn_item.updated" && event.turnItem.type === "command_execution" && - event.turnItem.nativeItemRef?.nativeId === INTERRUPT_CHILD_COMMAND_ITEM, - ); - assert.equal(childCommandUpdates.at(-1)?.turnItem.status, "interrupted"); - assert.equal(harness.subagentUpdates().at(-1)?.subagent.status, "interrupted"); - assert.isDefined( - harness - .subagentUpdates() - .find((event) => event.subagent.status === "running" && event.subagent.progress), - "a running native subagent reports its child's current command as progress", - ); - assertChildProviderTerminalBeforeRoot(harness.events, harness.threadId); - assert.isFalse(yield* harness.hasPendingBackgroundWork); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + event.turnItem.nativeItemRef?.nativeId === INTERRUPT_CHILD_COMMAND_ITEM && + event.turnItem.status === "running", + ), + ); + const providerTurnId = harness.events.find( + ( + event, + ): event is Extract< + ProviderAdapter.ProviderAdapterV2Event, + { type: "provider_turn.updated" } + > => event.type === "provider_turn.updated" && event.threadId === harness.threadId, + )?.providerTurn.id; + assert.isDefined(providerTurnId); + + yield* harness.runtime.interruptTurn({ + providerThread: harness.providerThread, + providerTurnId, + }); + + yield* harness.awaitTerminal(1); + assert.equal(harness.terminalEvents()[0]?.status, "interrupted"); + const childCommandUpdates = harness.events.filter( + ( + event, + ): event is Extract< + ProviderAdapter.ProviderAdapterV2Event, + { type: "turn_item.updated" } + > => + event.type === "turn_item.updated" && + event.turnItem.type === "command_execution" && + event.turnItem.nativeItemRef?.nativeId === INTERRUPT_CHILD_COMMAND_ITEM, + ); + assert.equal(childCommandUpdates.at(-1)?.turnItem.status, "interrupted"); + assert.equal(harness.subagentUpdates().at(-1)?.subagent.status, "interrupted"); + assert.isDefined( + harness + .subagentUpdates() + .find((event) => event.subagent.status === "running" && event.subagent.progress), + "a running native subagent reports its child's current command as progress", + ); + assertChildProviderTerminalBeforeRoot(harness.events, harness.threadId); + assert.isFalse(yield* harness.hasPendingBackgroundWork); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), @@ -6497,73 +6441,71 @@ describe("CodexAdapterV2 post-settle continuation", () => { }); it.effect("interrupts descendants that start after the initial Stop snapshot", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeCodexReplayHarness(interruptLateSubagentTurnTranscript); - const now = yield* DateTime.now; - - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-codex-interrupt-late-subagent-turn"), - text: INTERRUPT_PROMPT, - }), - ); - yield* harness.until(() => - harness.events.some( - (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "command_execution" && - event.turnItem.nativeItemRef?.nativeId === INTERRUPT_CHILD_COMMAND_ITEM && - event.turnItem.status === "running", - ), - ); - const providerTurnId = harness.events.find( - ( - event, - ): event is Extract< - ProviderAdapter.ProviderAdapterV2Event, - { type: "provider_turn.updated" } - > => event.type === "provider_turn.updated" && event.threadId === harness.threadId, - )?.providerTurn.id; - assert.isDefined(providerTurnId); - - const interruptFiber = yield* harness.runtime - .interruptTurn({ providerThread: harness.providerThread, providerTurnId }) - .pipe(Effect.forkScoped); - yield* harness.until(() => - harness.events.some( - (event) => - event.type === "provider_turn.updated" && - event.providerTurn.nativeTurnRef?.nativeId === INTERRUPT_LATE_CHILD_NATIVE_TURN, - ), - ); - yield* Fiber.join(interruptFiber); + Effect.gen(function* () { + const harness = yield* makeCodexReplayHarness(interruptLateSubagentTurnTranscript); + const now = yield* DateTime.now; - yield* harness.awaitTerminal(1); - const lateChildUpdates = harness.events.filter( - ( - event, - ): event is Extract< - ProviderAdapter.ProviderAdapterV2Event, - { type: "provider_turn.updated" } - > => + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-codex-interrupt-late-subagent-turn"), + text: INTERRUPT_PROMPT, + }), + ); + yield* harness.until(() => + harness.events.some( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "command_execution" && + event.turnItem.nativeItemRef?.nativeId === INTERRUPT_CHILD_COMMAND_ITEM && + event.turnItem.status === "running", + ), + ); + const providerTurnId = harness.events.find( + ( + event, + ): event is Extract< + ProviderAdapter.ProviderAdapterV2Event, + { type: "provider_turn.updated" } + > => event.type === "provider_turn.updated" && event.threadId === harness.threadId, + )?.providerTurn.id; + assert.isDefined(providerTurnId); + + const interruptFiber = yield* harness.runtime + .interruptTurn({ providerThread: harness.providerThread, providerTurnId }) + .pipe(Effect.forkScoped); + yield* harness.until(() => + harness.events.some( + (event) => event.type === "provider_turn.updated" && event.providerTurn.nativeTurnRef?.nativeId === INTERRUPT_LATE_CHILD_NATIVE_TURN, - ); - assert.equal(lateChildUpdates.at(-1)?.providerTurn.status, "interrupted"); - assertChildProviderTerminalBeforeRoot(harness.events, harness.threadId); - assert.isFalse(yield* harness.hasPendingBackgroundWork); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + ), + ); + yield* Fiber.join(interruptFiber); + + yield* harness.awaitTerminal(1); + const lateChildUpdates = harness.events.filter( + ( + event, + ): event is Extract< + ProviderAdapter.ProviderAdapterV2Event, + { type: "provider_turn.updated" } + > => + event.type === "provider_turn.updated" && + event.providerTurn.nativeTurnRef?.nativeId === INTERRUPT_LATE_CHILD_NATIVE_TURN, + ); + assert.equal(lateChildUpdates.at(-1)?.providerTurn.status, "interrupted"); + assertChildProviderTerminalBeforeRoot(harness.events, harness.threadId); + assert.isFalse(yield* harness.hasPendingBackgroundWork); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), @@ -6632,69 +6574,67 @@ describe("CodexAdapterV2 post-settle continuation", () => { it.effect( "keeps reading notifications while interrupting a child turn that starts after Stop", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeCodexReplayHarness(interruptSubagentTurnAfterStopTranscript); - const now = yield* DateTime.now; - - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-codex-interrupt-subagent-turn-after-stop"), - text: INTERRUPT_PROMPT, - }), - ); - yield* harness.until(() => - harness.events.some( - (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "command_execution" && - event.turnItem.nativeItemRef?.nativeId === INTERRUPT_CHILD_COMMAND_ITEM && - event.turnItem.status === "running", - ), - ); - const providerTurnId = harness.events.find( - ( - event, - ): event is Extract< - ProviderAdapter.ProviderAdapterV2Event, - { type: "provider_turn.updated" } - > => event.type === "provider_turn.updated" && event.threadId === harness.threadId, - )?.providerTurn.id; - assert.isDefined(providerTurnId); + Effect.gen(function* () { + const harness = yield* makeCodexReplayHarness(interruptSubagentTurnAfterStopTranscript); + const now = yield* DateTime.now; - yield* harness.runtime.interruptTurn({ + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ + threadId: harness.threadId, providerThread: harness.providerThread, - providerTurnId, - }); - yield* harness.awaitTerminal(1); + now, + attemptId: RunAttemptId.make("attempt-codex-interrupt-subagent-turn-after-stop"), + text: INTERRUPT_PROMPT, + }), + ); + yield* harness.until(() => + harness.events.some( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "command_execution" && + event.turnItem.nativeItemRef?.nativeId === INTERRUPT_CHILD_COMMAND_ITEM && + event.turnItem.status === "running", + ), + ); + const providerTurnId = harness.events.find( + ( + event, + ): event is Extract< + ProviderAdapter.ProviderAdapterV2Event, + { type: "provider_turn.updated" } + > => event.type === "provider_turn.updated" && event.threadId === harness.threadId, + )?.providerTurn.id; + assert.isDefined(providerTurnId); - // The TestClock never reaches the interrupt's timeout, so this arrives only - // if the reader is free to read the interrupt response and the frame after it. - yield* harness.until(() => - harness.events.some( - (event) => - event.type === "provider_thread.updated" && - event.providerThread.goal?.status === "paused", - ), - ); - assert.isFalse( - harness.events.some( - (event) => - event.type === "provider_turn.updated" && - event.providerTurn.nativeTurnRef?.nativeId === INTERRUPT_LATE_CHILD_NATIVE_TURN, - ), - ); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + yield* harness.runtime.interruptTurn({ + providerThread: harness.providerThread, + providerTurnId, + }); + yield* harness.awaitTerminal(1); + + // The TestClock never reaches the interrupt's timeout, so this arrives only + // if the reader is free to read the interrupt response and the frame after it. + yield* harness.until(() => + harness.events.some( + (event) => + event.type === "provider_thread.updated" && + event.providerThread.goal?.status === "paused", + ), + ); + assert.isFalse( + harness.events.some( + (event) => + event.type === "provider_turn.updated" && + event.providerTurn.nativeTurnRef?.nativeId === INTERRUPT_LATE_CHILD_NATIVE_TURN, + ), + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), @@ -6797,311 +6737,309 @@ describe("CodexAdapterV2 post-settle continuation", () => { expectedClass: "transport_error", }, ] as const)("classifies Codex terminal failures from $name evidence", (scenario) => - Effect.scoped( - Effect.gen(function* () { - const nativeThreadId = `native-limit-${scenario.name}`; - const nativeTurnId = `turn-limit-${scenario.name}`; - const message = "message" in scenario ? scenario.message : "Provider stopped this request."; - const errorInfo = "errorInfo" in scenario ? scenario.errorInfo : scenario.code; - const resetAt = "2033-05-19T07:20:00.000Z"; - const snapshot = { - type: "emit_inbound" as const, - label: "account/rateLimits/updated", - frame: { - method: "account/rateLimits/updated", - params: { - rateLimits: { - limitId: "codex", - primary: { usedPercent: 100, resetsAt: 2000100000, windowDurationMins: 300 }, - }, - }, - }, - }; - const readsReset = - scenario.name === "first-request-reset" || scenario.name === "newer-reset-during-read"; - // Usage limits with no known reset read the account before publishing their terminal. - const readsAccount = - scenario.expectedClass === "usage_limit" && - !("expectedCode" in scenario) && - scenario.name !== "deferred-reset"; - const premium = { - ...snapshot, - frame: { - method: "account/rateLimits/updated", - params: { rateLimits: { limitId: "premium", primary: null, secondary: null } }, - }, - }; - const newerSnapshot = { - ...snapshot, - frame: { - method: "account/rateLimits/updated", - params: { - rateLimits: { - limitId: "codex", - primary: { usedPercent: 100, resetsAt: 2000200000, windowDurationMins: 300 }, - }, + Effect.gen(function* () { + const nativeThreadId = `native-limit-${scenario.name}`; + const nativeTurnId = `turn-limit-${scenario.name}`; + const message = "message" in scenario ? scenario.message : "Provider stopped this request."; + const errorInfo = "errorInfo" in scenario ? scenario.errorInfo : scenario.code; + const resetAt = "2033-05-19T07:20:00.000Z"; + const snapshot = { + type: "emit_inbound" as const, + label: "account/rateLimits/updated", + frame: { + method: "account/rateLimits/updated", + params: { + rateLimits: { + limitId: "codex", + primary: { usedPercent: 100, resetsAt: 2000100000, windowDurationMins: 300 }, }, }, - }; - const readRequest = { - type: "expect_outbound" as const, - label: "account/rateLimits/read", - frame: { id: 4, method: "account/rateLimits/read", params: null }, - }; - const transcript = makeCodexReplayTranscript({ - scenario: `codex-limit-${scenario.name}`, - entries: [ - ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "Continue." }), - ...("usedBefore" in scenario - ? [ - { - ...snapshot, - frame: { - method: "account/rateLimits/updated", - params: { - rateLimits: { - limitId: "codex", - primary: { - usedPercent: scenario.usedBefore, - resetsAt: 2000100000, - windowDurationMins: 300, - }, + }, + }; + const readsReset = + scenario.name === "first-request-reset" || scenario.name === "newer-reset-during-read"; + // Usage limits with no known reset read the account before publishing their terminal. + const readsAccount = + scenario.expectedClass === "usage_limit" && + !("expectedCode" in scenario) && + scenario.name !== "deferred-reset"; + const premium = { + ...snapshot, + frame: { + method: "account/rateLimits/updated", + params: { rateLimits: { limitId: "premium", primary: null, secondary: null } }, + }, + }; + const newerSnapshot = { + ...snapshot, + frame: { + method: "account/rateLimits/updated", + params: { + rateLimits: { + limitId: "codex", + primary: { usedPercent: 100, resetsAt: 2000200000, windowDurationMins: 300 }, + }, + }, + }, + }; + const readRequest = { + type: "expect_outbound" as const, + label: "account/rateLimits/read", + frame: { id: 4, method: "account/rateLimits/read", params: null }, + }; + const transcript = makeCodexReplayTranscript({ + scenario: `codex-limit-${scenario.name}`, + entries: [ + ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "Continue." }), + ...("usedBefore" in scenario + ? [ + { + ...snapshot, + frame: { + method: "account/rateLimits/updated", + params: { + rateLimits: { + limitId: "codex", + primary: { + usedPercent: scenario.usedBefore, + resetsAt: 2000100000, + windowDurationMins: 300, }, }, }, }, - ] - : []), - ...(readsReset ? [premium] : []), - ...(scenario.name === "deferred-reset" ? [snapshot] : []), - ...(scenario.name === "deferred-reset" - ? [ - { - type: "emit_inbound" as const, - label: "item/completed/subAgentActivity-started", - frame: { - method: "item/completed", - params: { - threadId: nativeThreadId, - turnId: nativeTurnId, - item: { - type: "subAgentActivity", - id: "limit-child-spawn", - kind: "started", - agentThreadId: "native-limit-child", - agentPath: "/root/limit_child", - }, + }, + ] + : []), + ...(readsReset ? [premium] : []), + ...(scenario.name === "deferred-reset" ? [snapshot] : []), + ...(scenario.name === "deferred-reset" + ? [ + { + type: "emit_inbound" as const, + label: "item/completed/subAgentActivity-started", + frame: { + method: "item/completed", + params: { + threadId: nativeThreadId, + turnId: nativeTurnId, + item: { + type: "subAgentActivity", + id: "limit-child-spawn", + kind: "started", + agentThreadId: "native-limit-child", + agentPath: "/root/limit_child", }, }, }, - { - type: "emit_inbound" as const, - label: "turn/started/child", - frame: { - method: "turn/started", - params: { - threadId: "native-limit-child", - turn: makeCodexReplayTurn({ - id: "limit-child-turn", - status: "inProgress", - }), - }, + }, + { + type: "emit_inbound" as const, + label: "turn/started/child", + frame: { + method: "turn/started", + params: { + threadId: "native-limit-child", + turn: makeCodexReplayTurn({ + id: "limit-child-turn", + status: "inProgress", + }), }, }, - ] - : []), - ...(scenario.notification - ? [ - { - type: "emit_inbound" as const, - label: "error", - frame: { - method: "error", - params: { - threadId: nativeThreadId, - turnId: nativeTurnId, - willRetry: scenario.name === "retry" || "willRetry" in scenario, - error: { - message, - codexErrorInfo: errorInfo, - additionalDetails: - "details" in scenario - ? scenario.details - : scenario.name === "matching-details" - ? "Detailed provider allowance explanation." - : null, - }, + }, + ] + : []), + ...(scenario.notification + ? [ + { + type: "emit_inbound" as const, + label: "error", + frame: { + method: "error", + params: { + threadId: nativeThreadId, + turnId: nativeTurnId, + willRetry: scenario.name === "retry" || "willRetry" in scenario, + error: { + message, + codexErrorInfo: errorInfo, + additionalDetails: + "details" in scenario + ? scenario.details + : scenario.name === "matching-details" + ? "Detailed provider allowance explanation." + : null, }, }, }, - ] - : []), - { - type: "emit_inbound", - label: "turn/completed", - frame: { - method: "turn/completed", - params: { - threadId: nativeThreadId, - turn: { - ...makeCodexReplayTurn({ id: nativeTurnId, status: "failed" }), - error: { - message: scenario.name === "replacement" ? "A different failure." : message, - ...(scenario.notification && - scenario.name !== "matching-details" && - !("details" in scenario) - ? {} - : { codexErrorInfo: errorInfo }), - }, + }, + ] + : []), + { + type: "emit_inbound", + label: "turn/completed", + frame: { + method: "turn/completed", + params: { + threadId: nativeThreadId, + turn: { + ...makeCodexReplayTurn({ id: nativeTurnId, status: "failed" }), + error: { + message: scenario.name === "replacement" ? "A different failure." : message, + ...(scenario.notification && + scenario.name !== "matching-details" && + !("details" in scenario) + ? {} + : { codexErrorInfo: errorInfo }), }, }, }, }, - ...(readsAccount ? [readRequest] : []), - ...(scenario.name === "newer-reset-during-read" ? [newerSnapshot] : []), - ...(readsReset - ? [ - { - type: "emit_inbound" as const, - label: "account/rateLimits/read", - frame: { - id: 4, - result: { - ...premium.frame.params, - rateLimitsByLimitId: { codex: snapshot.frame.params.rateLimits }, - }, + }, + ...(readsAccount ? [readRequest] : []), + ...(scenario.name === "newer-reset-during-read" ? [newerSnapshot] : []), + ...(readsReset + ? [ + { + type: "emit_inbound" as const, + label: "account/rateLimits/read", + frame: { + id: 4, + result: { + ...premium.frame.params, + rateLimitsByLimitId: { codex: snapshot.frame.params.rateLimits }, }, }, - ] - : []), - ...(readsAccount && !readsReset - ? [ - { - type: "emit_inbound" as const, - label: "account/rateLimits/read", - frame: { id: 4, error: { code: -32600, message: "Not signed in." } }, - }, - ] - : []), - ...(scenario.name === "late-reset" - ? [{ ...snapshot, label: "account/rateLimits/updated/late" }] - : []), - ...("resetAfter" in scenario ? [snapshot] : []), - ...(scenario.name === "deferred-reset" - ? [ - { - ...snapshot, - frame: { - method: "account/rateLimits/updated", - params: { - rateLimits: { - limitId: "codex", - primary: { - usedPercent: 100, - resetsAt: 2000200000, - windowDurationMins: 300, - }, + }, + ] + : []), + ...(readsAccount && !readsReset + ? [ + { + type: "emit_inbound" as const, + label: "account/rateLimits/read", + frame: { id: 4, error: { code: -32600, message: "Not signed in." } }, + }, + ] + : []), + ...(scenario.name === "late-reset" + ? [{ ...snapshot, label: "account/rateLimits/updated/late" }] + : []), + ...("resetAfter" in scenario ? [snapshot] : []), + ...(scenario.name === "deferred-reset" + ? [ + { + ...snapshot, + frame: { + method: "account/rateLimits/updated", + params: { + rateLimits: { + limitId: "codex", + primary: { + usedPercent: 100, + resetsAt: 2000200000, + windowDurationMins: 300, }, }, }, }, - { - type: "emit_inbound" as const, - label: "turn/completed/child", - frame: { - method: "turn/completed", - params: { - threadId: "native-limit-child", - turn: makeCodexReplayTurn({ - id: "limit-child-turn", - status: "completed", - }), - }, + }, + { + type: "emit_inbound" as const, + label: "turn/completed/child", + frame: { + method: "turn/completed", + params: { + threadId: "native-limit-child", + turn: makeCodexReplayTurn({ + id: "limit-child-turn", + status: "completed", + }), }, }, - ] - : []), - ], - }); - const resetReceipt = yield* Deferred.make(); - const snapshotReceipt = yield* Deferred.make(); - const terminalReceipt = yield* Deferred.make(); - const harness = yield* makeCodexReplayHarness( - transcript, - (event) => - event.type === "turn.terminal" - ? Deferred.succeed(terminalReceipt, undefined) - : event.type === "turn_item.updated" && - event.turnItem.type === "error" && - event.turnItem.failure.resetAt === resetAt - ? Deferred.succeed(resetReceipt, undefined) - : Effect.void, - undefined, - undefined, - // The late reset must land after the terminal its failed read released. - (entry) => - entry.label === "account/rateLimits/updated/late" - ? Deferred.await(terminalReceipt) - : Effect.void, - () => Deferred.succeed(snapshotReceipt, undefined), - ); - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - text: "Continue.", - attemptId: RunAttemptId.make(`attempt-limit-${scenario.name}`), - }), - ); - yield* harness.firstTerminal; - const terminal = harness.terminalEvents()[0]; - assert.equal(terminal?.status, "failed"); - if (terminal?.status !== "failed") return; - assert.equal(terminal.failure.class, scenario.expectedClass); - assert.equal(terminal.threadDisposition, "reusable"); - if (scenario.name === "deferred-reset" || scenario.name === "first-request-reset") - assert.equal(terminal.failure.resetAt, resetAt); - if (scenario.name === "newer-reset-during-read") - assert.equal(terminal.failure.resetAt, "2033-05-20T11:06:40.000Z"); - if (scenario.name === "matching-details") - assert.equal(terminal.failure.message, "Detailed provider allowance explanation."); - if ("resetAfter" in scenario) { - yield* Deferred.await(snapshotReceipt); - assert.isFalse( - harness.events.some( - (event) => - event.type === "turn_item.updated" && + }, + ] + : []), + ], + }); + const resetReceipt = yield* Deferred.make(); + const snapshotReceipt = yield* Deferred.make(); + const terminalReceipt = yield* Deferred.make(); + const harness = yield* makeCodexReplayHarness( + transcript, + (event) => + event.type === "turn.terminal" + ? Deferred.succeed(terminalReceipt, undefined) + : event.type === "turn_item.updated" && event.turnItem.type === "error" && - event.turnItem.failure.resetAt != null, - ), - ); - } - if (scenario.name === "late-reset") { - yield* Deferred.await(resetReceipt); - const item = harness.events.find( + event.turnItem.failure.resetAt === resetAt + ? Deferred.succeed(resetReceipt, undefined) + : Effect.void, + undefined, + undefined, + // The late reset must land after the terminal its failed read released. + (entry) => + entry.label === "account/rateLimits/updated/late" + ? Deferred.await(terminalReceipt) + : Effect.void, + () => Deferred.succeed(snapshotReceipt, undefined), + ); + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + text: "Continue.", + attemptId: RunAttemptId.make(`attempt-limit-${scenario.name}`), + }), + ); + yield* harness.firstTerminal; + const terminal = harness.terminalEvents()[0]; + assert.equal(terminal?.status, "failed"); + if (terminal?.status !== "failed") return; + assert.equal(terminal.failure.class, scenario.expectedClass); + assert.equal(terminal.threadDisposition, "reusable"); + if (scenario.name === "deferred-reset" || scenario.name === "first-request-reset") + assert.equal(terminal.failure.resetAt, resetAt); + if (scenario.name === "newer-reset-during-read") + assert.equal(terminal.failure.resetAt, "2033-05-20T11:06:40.000Z"); + if (scenario.name === "matching-details") + assert.equal(terminal.failure.message, "Detailed provider allowance explanation."); + if ("resetAfter" in scenario) { + yield* Deferred.await(snapshotReceipt); + assert.isFalse( + harness.events.some( (event) => event.type === "turn_item.updated" && event.turnItem.type === "error" && - event.turnItem.failure.resetAt === resetAt, - ); - assert.isDefined(item); - } - if (scenario.name === "retry" || "willRetry" in scenario) - assert.equal(terminal.retry?.attempt, 1); - if ("expectedCode" in scenario && "expectedMessage" in scenario) { - assert.equal(terminal.failure.code, scenario.expectedCode); - assert.include(terminal.failure.message, scenario.expectedMessage); - assert.equal(terminal.failure.resetAt, null); - } - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, + event.turnItem.failure.resetAt != null, ), + ); + } + if (scenario.name === "late-reset") { + yield* Deferred.await(resetReceipt); + const item = harness.events.find( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "error" && + event.turnItem.failure.resetAt === resetAt, + ); + assert.isDefined(item); + } + if (scenario.name === "retry" || "willRetry" in scenario) + assert.equal(terminal.retry?.attempt, 1); + if ("expectedCode" in scenario && "expectedMessage" in scenario) { + assert.equal(terminal.failure.code, scenario.expectedCode); + assert.include(terminal.failure.message, scenario.expectedMessage); + assert.equal(terminal.failure.resetAt, null); + } + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), @@ -7136,142 +7074,51 @@ describe("CodexAdapterV2 post-settle continuation", () => { ] as const)( "honors completion HTTP status from $notificationStatus to $completionStatus (retry: $willRetry)", ({ notificationStatus, completionStatus, expectedClass, willRetry }) => - Effect.scoped( - Effect.gen(function* () { - const name = `${notificationStatus}-${completionStatus}-${willRetry}`; - const nativeThreadId = `native-exhaustion-status-${name}`; - const nativeTurnId = `turn-exhaustion-status-${name}`; - const message = "Provider stopped this request."; - const additionalDetails = "Detailed notification evidence."; - const transcript = makeCodexReplayTranscript({ - scenario: `codex-exhaustion-status-${name}`, - entries: [ - ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "Continue." }), - { - type: "emit_inbound", - label: "error", - frame: { - method: "error", - params: { - threadId: nativeThreadId, - turnId: nativeTurnId, - willRetry, - error: { - message, - codexErrorInfo: { - responseTooManyFailedAttempts: { httpStatusCode: notificationStatus }, - }, - additionalDetails, - }, - }, - }, - }, - { - type: "emit_inbound", - label: "turn/completed", - frame: { - method: "turn/completed", - params: { - threadId: nativeThreadId, - turn: { - ...makeCodexReplayTurn({ id: nativeTurnId, status: "failed" }), - error: { - message, - codexErrorInfo: { - responseTooManyFailedAttempts: { httpStatusCode: completionStatus }, - }, - }, - }, - }, - }, - }, - ], - }); - const harness = yield* makeCodexReplayHarness(transcript); - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - text: "Continue.", - attemptId: RunAttemptId.make(`attempt-exhaustion-status-${name}`), - }), - ); - yield* harness.firstTerminal; - const terminal = harness.terminalEvents()[0]; - assert.equal(terminal?.status, "failed"); - if (terminal?.status !== "failed") return; - assert.equal(terminal.failure.class, expectedClass); - assert.equal(terminal.failure.code, "responseTooManyFailedAttempts"); - assert.equal(terminal.threadDisposition, "reusable"); - const notificationClass = notificationStatus === 429 ? "usage_limit" : "provider_error"; - assert.equal( - terminal.failure.message, - expectedClass === notificationClass ? additionalDetails : message, - ); - if (willRetry) assert.equal(terminal.retry?.attempt, 1); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), - ), - ), - ), - ); - - // regression: #28032 an unlisted codexErrorInfo dropped the failed turn/completed - it.effect("fails the turn when Codex reports an error info the schema does not list", () => - Effect.scoped( Effect.gen(function* () { - const nativeThreadId = "native-unlisted-error-info"; - const nativeTurnId = "turn-unlisted-error-info"; - const message = - "The 'gpt-5-codex' model is not supported when using Codex with a ChatGPT account."; - const error = { message, codexErrorInfo: "modelNotFound", additionalDetails: null }; - const usage = { - totalTokens: 1, - inputTokens: 1, - cachedInputTokens: 0, - outputTokens: 0, - reasoningOutputTokens: 0, - }; + const name = `${notificationStatus}-${completionStatus}-${willRetry}`; + const nativeThreadId = `native-exhaustion-status-${name}`; + const nativeTurnId = `turn-exhaustion-status-${name}`; + const message = "Provider stopped this request."; + const additionalDetails = "Detailed notification evidence."; const transcript = makeCodexReplayTranscript({ - scenario: "codex-unlisted-error-info", + scenario: `codex-exhaustion-status-${name}`, entries: [ - ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "say hi" }), - { - type: "emit_inbound", - label: "error", - frame: { - method: "error", - params: { threadId: nativeThreadId, turnId: nativeTurnId, willRetry: false, error }, - }, - }, + ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "Continue." }), { type: "emit_inbound", - label: "turn/completed", + label: "error", frame: { - method: "turn/completed", + method: "error", params: { threadId: nativeThreadId, - turn: { ...makeCodexReplayTurn({ id: nativeTurnId, status: "failed" }), error }, + turnId: nativeTurnId, + willRetry, + error: { + message, + codexErrorInfo: { + responseTooManyFailedAttempts: { httpStatusCode: notificationStatus }, + }, + additionalDetails, + }, }, }, }, - // Notifications dispatch in order, so this marks turn/completed as handled. { type: "emit_inbound", - label: "usage", + label: "turn/completed", frame: { - method: "thread/tokenUsage/updated", + method: "turn/completed", params: { threadId: nativeThreadId, - turnId: nativeTurnId, - tokenUsage: { total: usage, last: usage, modelContextWindow: 200_000 }, + turn: { + ...makeCodexReplayTurn({ id: nativeTurnId, status: "failed" }), + error: { + message, + codexErrorInfo: { + responseTooManyFailedAttempts: { httpStatusCode: completionStatus }, + }, + }, + }, }, }, }, @@ -7283,22 +7130,23 @@ describe("CodexAdapterV2 post-settle continuation", () => { threadId: harness.threadId, providerThread: harness.providerThread, now: yield* DateTime.now, - text: "say hi", - attemptId: RunAttemptId.make("attempt-unlisted-error-info"), + text: "Continue.", + attemptId: RunAttemptId.make(`attempt-exhaustion-status-${name}`), }), ); - yield* harness.until( - () => - harness.terminalEvents().length > 0 || - harness.events.some( - (event) => - event.type === "provider_turn.updated" && event.providerTurn.tokenUsage != null, - ), - ); + yield* harness.firstTerminal; const terminal = harness.terminalEvents()[0]; assert.equal(terminal?.status, "failed"); if (terminal?.status !== "failed") return; - assert.equal(terminal.failure.message, message); + assert.equal(terminal.failure.class, expectedClass); + assert.equal(terminal.failure.code, "responseTooManyFailedAttempts"); + assert.equal(terminal.threadDisposition, "reusable"); + const notificationClass = notificationStatus === 429 ? "usage_limit" : "provider_error"; + assert.equal( + terminal.failure.message, + expectedClass === notificationClass ? additionalDetails : message, + ); + if (willRetry) assert.equal(terminal.retry?.attempt, 1); }).pipe( Effect.provide( Layer.mergeAll( @@ -7309,6 +7157,92 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ), ), + ); + + // regression: #28032 an unlisted codexErrorInfo dropped the failed turn/completed + it.effect("fails the turn when Codex reports an error info the schema does not list", () => + Effect.gen(function* () { + const nativeThreadId = "native-unlisted-error-info"; + const nativeTurnId = "turn-unlisted-error-info"; + const message = + "The 'gpt-5-codex' model is not supported when using Codex with a ChatGPT account."; + const error = { message, codexErrorInfo: "modelNotFound", additionalDetails: null }; + const usage = { + totalTokens: 1, + inputTokens: 1, + cachedInputTokens: 0, + outputTokens: 0, + reasoningOutputTokens: 0, + }; + const transcript = makeCodexReplayTranscript({ + scenario: "codex-unlisted-error-info", + entries: [ + ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "say hi" }), + { + type: "emit_inbound", + label: "error", + frame: { + method: "error", + params: { threadId: nativeThreadId, turnId: nativeTurnId, willRetry: false, error }, + }, + }, + { + type: "emit_inbound", + label: "turn/completed", + frame: { + method: "turn/completed", + params: { + threadId: nativeThreadId, + turn: { ...makeCodexReplayTurn({ id: nativeTurnId, status: "failed" }), error }, + }, + }, + }, + // Notifications dispatch in order, so this marks turn/completed as handled. + { + type: "emit_inbound", + label: "usage", + frame: { + method: "thread/tokenUsage/updated", + params: { + threadId: nativeThreadId, + turnId: nativeTurnId, + tokenUsage: { total: usage, last: usage, modelContextWindow: 200_000 }, + }, + }, + }, + ], + }); + const harness = yield* makeCodexReplayHarness(transcript); + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + text: "say hi", + attemptId: RunAttemptId.make("attempt-unlisted-error-info"), + }), + ); + yield* harness.until( + () => + harness.terminalEvents().length > 0 || + harness.events.some( + (event) => + event.type === "provider_turn.updated" && event.providerTurn.tokenUsage != null, + ), + ); + const terminal = harness.terminalEvents()[0]; + assert.equal(terminal?.status, "failed"); + if (terminal?.status !== "failed") return; + assert.equal(terminal.failure.message, message); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), ), ); @@ -7373,47 +7307,45 @@ describe("CodexAdapterV2 post-settle continuation", () => { }); it.effect("terminalizes running command items before turn.terminal on failed turns", () => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeCodexReplayHarness(failedMidCommandTranscript); - const now = yield* DateTime.now; + Effect.gen(function* () { + const harness = yield* makeCodexReplayHarness(failedMidCommandTranscript); + const now = yield* DateTime.now; - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-codex-failed-mid-command"), - text: FAILED_PROMPT, - }), - ); - yield* harness.awaitTerminal(1); - assert.equal(harness.terminalEvents()[0]?.status, "failed"); + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-codex-failed-mid-command"), + text: FAILED_PROMPT, + }), + ); + yield* harness.awaitTerminal(1); + assert.equal(harness.terminalEvents()[0]?.status, "failed"); - const terminalIndex = harness.events.findIndex((event) => event.type === "turn.terminal"); - const failedCommandIndex = harness.events.findIndex( - (event, index) => - index < terminalIndex && - event.type === "turn_item.updated" && - event.turnItem.type === "command_execution" && - event.turnItem.status === "failed", - ); - assert.isAtLeast(failedCommandIndex, 0); - assert.isAbove( - terminalIndex, - failedCommandIndex, - "failed-turn command terminalization must precede turn.terminal", - ); - assert.isFalse(yield* harness.hasPendingBackgroundWork); - assert.lengthOf(harness.continuationRequests, 0); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), + const terminalIndex = harness.events.findIndex((event) => event.type === "turn.terminal"); + const failedCommandIndex = harness.events.findIndex( + (event, index) => + index < terminalIndex && + event.type === "turn_item.updated" && + event.turnItem.type === "command_execution" && + event.turnItem.status === "failed", + ); + assert.isAtLeast(failedCommandIndex, 0); + assert.isAbove( + terminalIndex, + failedCommandIndex, + "failed-turn command terminalization must precede turn.terminal", + ); + assert.isFalse(yield* harness.hasPendingBackgroundWork); + assert.lengthOf(harness.continuationRequests, 0); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, ), ), ), @@ -7514,74 +7446,72 @@ describe("CodexAdapterV2 post-settle continuation", () => { }); it.effect("captures an MCP app's resource and holds the turn open until it lands", () => - Effect.scoped( - Effect.gen(function* () { - const appCaptured = yield* Deferred.make(); - const harness = yield* makeCodexReplayHarness(mcpAppTranscript, (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "dynamic_tool" && - event.turnItem.status === "completed" - ? Deferred.succeed(appCaptured, event.turnItem) - : Effect.void, - ); - const now = yield* DateTime.now; + Effect.gen(function* () { + const appCaptured = yield* Deferred.make(); + const harness = yield* makeCodexReplayHarness(mcpAppTranscript, (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "dynamic_tool" && + event.turnItem.status === "completed" + ? Deferred.succeed(appCaptured, event.turnItem) + : Effect.void, + ); + const now = yield* DateTime.now; - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make("attempt-codex-mcp-app"), - text: MCP_APP_PROMPT, - }), - ); - const item = yield* Deferred.await(appCaptured); - assert.equal(harness.terminalEvents()[0]?.status, "completed"); + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-codex-mcp-app"), + text: MCP_APP_PROMPT, + }), + ); + const item = yield* Deferred.await(appCaptured); + assert.equal(harness.terminalEvents()[0]?.status, "completed"); - const completedRows = harness.events.filter( - (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "dynamic_tool" && - event.turnItem.status === "completed", - ); - assert.lengthOf(completedRows, 1); + const completedRows = harness.events.filter( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "dynamic_tool" && + event.turnItem.status === "completed", + ); + assert.lengthOf(completedRows, 1); - assert.equal(item.type, "dynamic_tool"); - const output = item.type === "dynamic_tool" ? (item.output as Record) : {}; - const reference = readMcpAppReference(output[MCP_APP_OUTPUT_KEY]); - assert.deepEqual( - { ...reference, attachmentId: undefined }, - { - attachmentId: undefined, - server: "weather", - tool: "get_weather", - resourceUri: MCP_APP_RESOURCE, - csp: { connectDomains: ["https://api.weather.test"] }, - }, - ); - assert.deepEqual(output.result, { - content: [{ type: "text", text: "Sunny" }], - structuredContent: { temp: 21 }, - }); + assert.equal(item.type, "dynamic_tool"); + const output = item.type === "dynamic_tool" ? (item.output as Record) : {}; + const reference = readMcpAppReference(output[MCP_APP_OUTPUT_KEY]); + assert.deepEqual( + { ...reference, attachmentId: undefined }, + { + attachmentId: undefined, + server: "weather", + tool: "get_weather", + resourceUri: MCP_APP_RESOURCE, + csp: { connectDomains: ["https://api.weather.test"] }, + }, + ); + assert.deepEqual(output.result, { + content: [{ type: "text", text: "Sunny" }], + structuredContent: { temp: 21 }, + }); - const fileSystem = yield* FileSystem.FileSystem; - const stored = yield* fileSystem.readFileString( - resolveAttachmentPathById({ - attachmentsDir: harness.serverConfig.attachmentsDir, - attachmentId: reference!.attachmentId, - })!, - ); - assert.include(stored, "connect-src https://api.weather.test"); - assert.isBelow(stored.indexOf("Content-Security-Policy"), stored.indexOf("`); + + await ServerBrowserPage.drag(page, { source: "#source", target: "#target" }); + + const events = (await page.evaluate("window.events")) as Array; + expect(events.slice(0, 2)).toEqual(["dragstart", "mousemove"]); + expect(events.at(-1)).toBe("mouseup"); + } finally { + await browser.close(); + } + }); }); describe("server browser evaluate", () => { diff --git a/apps/server/src/preview/ServerBrowserPage.ts b/apps/server/src/preview/ServerBrowserPage.ts index 0d5ed3d1ff893..65a283dc3cca4 100644 --- a/apps/server/src/preview/ServerBrowserPage.ts +++ b/apps/server/src/preview/ServerBrowserPage.ts @@ -888,7 +888,8 @@ export const drag = async ( const target = targetLocator(page, { locator: input.target })!; await pointer(await targetPoint(page, source, {}, timeout), "move"); // Observe drag rejection immediately; an unhandled rejection during cursor movement exits the server. - const dragFailure = source.dragTo(target, { timeout }).then( + // Chromium delivers a single move before dragstart; steps give the page mousemoves during the drag. + const dragFailure = source.dragTo(target, { timeout, steps: 5 }).then( () => null, (error: unknown) => ({ error }), ); From bd7d264401e581687d0055891a8fb536f8ca9543 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 01:49:00 -0800 Subject: [PATCH 101/390] fix(server): a fresh-session scheduled run no longer takes over the thread's conversation (#36103) --- .../src/orchestration-v2/Orchestrator.ts | 103 +++++++++++--- .../ProviderSwitch.integration.test.ts | 132 ++++++++++++++++++ 2 files changed, 217 insertions(+), 18 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 45edf2c74282e..0fe744c9a9131 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -1267,22 +1267,35 @@ function appendContextHandoffId( return handoffId === null ? handoffIds : Array.from(new Set([...handoffIds, handoffId])); } +/** A fresh-session schedule run starts a side conversation; the thread's own one continues past it. */ +function isScheduledFreshRun( + run: Pick, + turnItems: OrchestrationV2ThreadProjection["turnItems"], +): boolean { + // Turn items, unlike messages, are not narrowed to the runs since the latest compaction. + return ( + run.freshContext === true && + turnItems.some( + (item) => + item.type === "user_message" && + item.messageId === run.userMessageId && + item.scheduledTaskId !== undefined, + ) + ); +} + /** Ordinal of the latest started fresh-context run. Runs before it never reach a provider again. */ function contextBoundaryOrdinal( - runs: ReadonlyArray< - Pick< - OrchestrationV2Run, - "ordinal" | "freshContext" | "status" | "startedAt" | "restartContinuationOfRunId" - > - >, + projection: Pick, ): number { let boundary = 0; - for (const run of runs) { + for (const run of projection.runs) { if ( run.freshContext === true && run.status !== "queued" && !cancelledBeforeStart(run) && - run.ordinal > boundary + run.ordinal > boundary && + !isScheduledFreshRun(run, projection.turnItems) ) { boundary = run.ordinal; } @@ -1290,6 +1303,57 @@ function contextBoundaryOrdinal( return boundary; } +/** The provider thread a message continues: the active one, unless a fresh-session schedule run started it. */ +function conversationProviderThread( + projection: Pick< + OrchestrationV2ThreadProjection, + "thread" | "providerThreads" | "runs" | "turnItems" + >, +): OrchestrationV2ProviderThread | undefined { + const startedByScheduledFreshRun = (providerThread: OrchestrationV2ProviderThread) => + projection.runs.some( + (run) => + run.ordinal === providerThread.firstRunOrdinal && + isScheduledFreshRun(run, projection.turnItems), + ); + const active = projection.providerThreads.find( + (candidate) => candidate.id === projection.thread.activeProviderThreadId, + ); + if (active === undefined || !startedByScheduledFreshRun(active)) return active; + const boundary = contextBoundaryOrdinal(projection); + return ( + projection.providerThreads + .filter( + (candidate) => + candidate.appThreadId === projection.thread.id && + candidate.ownerNodeId === null && + candidate.lastRunOrdinal !== null && + !providerThreadPredatesBoundary(candidate, boundary) && + !startedByScheduledFreshRun(candidate), + ) + .toSorted((left, right) => (right.lastRunOrdinal ?? 0) - (left.lastRunOrdinal ?? 0))[0] ?? + active + ); +} + +/** Wakes and fresh starts act on the active session; a message continues the conversation. */ +function dispatchProviderThread( + projection: Pick< + OrchestrationV2ThreadProjection, + "thread" | "providerThreads" | "runs" | "turnItems" + >, + message: Pick, + freshContext: boolean, +): OrchestrationV2ProviderThread | undefined { + return freshContext || + message.notification !== undefined || + message.delegatedCompletion !== undefined + ? projection.providerThreads.find( + (candidate) => candidate.id === projection.thread.activeProviderThreadId, + ) + : conversationProviderThread(projection); +} + /** Threads with no started run (imported, or reserved by another queued run) never cross it. */ function providerThreadPredatesBoundary( providerThread: Pick, @@ -1325,10 +1389,13 @@ function uniqueSessions( } function rootProviderThreadsForProvider( - projection: Pick, + projection: Pick< + OrchestrationV2ThreadProjection, + "providerThreads" | "thread" | "runs" | "turnItems" + >, providerInstanceId: ModelSelection["instanceId"], ): ReadonlyArray { - const boundary = contextBoundaryOrdinal(projection.runs); + const boundary = contextBoundaryOrdinal(projection); return projection.providerThreads .filter( (providerThread) => @@ -2390,12 +2457,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }), ), ); - const activeProviderThread = projection.providerThreads.find( - (candidate) => candidate.id === projection.thread.activeProviderThreadId, - ); const followsCurrent = followsCurrentConversation(queuedMessage); const freshContext = queuedRun.freshContext === true; - const contextBoundary = freshContext ? ordinal : contextBoundaryOrdinal(projection.runs); + const activeProviderThread = dispatchProviderThread(projection, queuedMessage, freshContext); + const contextBoundary = freshContext ? ordinal : contextBoundaryOrdinal(projection); // A run queued before a fresh run started is bound to the conversation that run left behind. const followsLatestContext = !freshContext && @@ -6116,7 +6181,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio type: "provider_handoff", }), ); - const contextBoundary = contextBoundaryOrdinal(input.projection.runs); + const contextBoundary = contextBoundaryOrdinal(input.projection); restartHandoff = yield* contextHandoffService .prepareProviderHandoff({ threadId: input.command.threadId, @@ -7080,8 +7145,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio return; } - const activeProviderThread = projection.providerThreads.find( - (candidate) => candidate.id === projection.thread.activeProviderThreadId, + const activeProviderThread = dispatchProviderThread( + projection, + { ...command, delegatedCompletion }, + freshContext, ); const activeRun = projection.runs.find(isActiveRun); // Messages after failed or stopped worktree setup must wait for a worktree, @@ -7544,7 +7611,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio } const ordinal = nextRunOrdinal(projection); const runId = idAllocator.derive.run({ threadId: command.threadId, ordinal }); - const contextBoundary = freshContext ? ordinal : contextBoundaryOrdinal(projection.runs); + const contextBoundary = freshContext ? ordinal : contextBoundaryOrdinal(projection); const latestCompletedRun = projection.runs.findLast((run) => run.status === "completed"); const latestHandoffRun = projection.runs.findLast(isHandoffSourceRun); const legacyImportItems = diff --git a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts index 90207b34f1048..e4371b54ee4b5 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts @@ -3417,6 +3417,138 @@ describe("orchestration v2 provider switching", () => { }), ); + it.live("keeps the user's conversation after a scheduled fresh run in the same thread", () => + Effect.gen(function* () { + const cwd = yield* checkpointWorkspace("scheduled-fresh-user-chat"); + const capturedTurns = yield* Ref.make>([]); + const layerRegistry = ProviderAdapterRegistry.layerFromAdapters([ + makeTestAdapter({ + instanceId: CLAUDE_MODEL_SELECTION.instanceId, + driver: CLAUDE_DRIVER, + capabilities: ClaudeProviderCapabilitiesV2, + modelSelection: CLAUDE_MODEL_SELECTION, + responseByRunOrdinal: {}, + capturedTurns, + nativeThreadGeneration: yield* Ref.make(0), + }), + ]); + const threadId = ThreadId.make("thread:scheduled-fresh-user-chat"); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const send = (name: string, scheduled: boolean) => + orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`command:scheduled-fresh-user-chat:${name}`), + threadId, + messageId: MessageId.make(`message:scheduled-fresh-user-chat:${name}`), + text: name, + attachments: [], + modelSelection: CLAUDE_MODEL_SELECTION, + dispatchMode: { type: "queue_after_active" }, + ...(scheduled + ? { + scheduledTaskId: ScheduledTaskId.make("scheduled-fresh-user-chat"), + freshContext: true, + } + : {}), + }); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:scheduled-fresh-user-chat:create"), + threadId, + projectId: ProjectId.make("project:scheduled-fresh-user-chat"), + title: "User chat with a fresh-session schedule", + modelSelection: CLAUDE_MODEL_SELECTION, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + }); + const eventSink = yield* EventSink.EventSinkV2; + for (const [name, scheduled] of [ + ["user-first", false], + ["scheduled", true], + ["user-second", false], + ["compaction", false], + ["scheduled-after-compaction", true], + ["user-third", false], + ] as const) { + if (name === "compaction") { + // The user's run compacts, so later dispatches no longer read the first scheduled run's message. + const run = (yield* orchestrator.getThreadProjection(threadId)).runs.at(-1)!; + const compactedAt = yield* DateTime.now; + yield* eventSink.write({ + events: [ + { + id: EventId.make("event:scheduled-fresh-user-chat:compaction"), + type: "turn-item.updated", + threadId, + runId: run.id, + nodeId: run.rootNodeId!, + occurredAt: compactedAt, + payload: { + id: TurnItemId.make("item:scheduled-fresh-user-chat:compaction"), + threadId, + runId: run.id, + nodeId: run.rootNodeId, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 1_000, + status: "completed", + title: null, + startedAt: compactedAt, + completedAt: compactedAt, + updatedAt: compactedAt, + type: "compaction", + driver: CLAUDE_DRIVER, + }, + }, + ], + }); + continue; + } + yield* send(name, scheduled); + yield* worker.drain(); + yield* waitForIdle(threadId); + } + const turns = yield* Ref.get(capturedTurns); + const turnFor = (name: string) => turns.find((turn) => turn.text.endsWith(name)); + assert.notEqual( + turnFor("scheduled")!.nativeThreadId, + turnFor("user-first")!.nativeThreadId, + ); + // The scheduled run's fresh session must not replace the user's conversation. + assert.equal(turnFor("user-second")!.nativeThreadId, turnFor("user-first")!.nativeThreadId); + assert.equal(turnFor("user-third")!.nativeThreadId, turnFor("user-first")!.nativeThreadId); + }).pipe( + Effect.provide( + ProviderReplayHarness.layerWithRegistry( + { + name: "scheduled-fresh-user-chat", + runtimePolicyOverride: { + cwd, + approvalPolicy: "never", + sandboxPolicy: { + type: "readOnly", + access: { type: "fullAccess" }, + networkAccess: false, + }, + }, + }, + layerRegistry, + ), + ), + ); + }), + ); + it.live("keeps the thread a queued fresh run reserved for that run", () => Effect.gen(function* () { const cwd = yield* checkpointWorkspace("fresh-context-reserved"); From 10fbff5ab6d31c494b5c6cfd759d78c12aa731f6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 01:49:27 -0800 Subject: [PATCH 102/390] fix(server): deferred-steer check and queued cancel no longer read a thread's whole history (#36100) --- .../Orchestrator.control-reads.test.ts | 122 +++++++++++++++++- .../src/orchestration-v2/Orchestrator.ts | 12 +- 2 files changed, 131 insertions(+), 3 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts index 21afe0b0574c8..f36ce5bdc564b 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts @@ -2468,6 +2468,19 @@ it.effect("queued-message commands read a long thread's history without walking }), ); walks.push(...(yield* historyWalks("queued-message.promote-to-steer"))); + yield* dispatch("cancelled"); + const cancelledRun = (yield* projections.getThreadProjection(threadId)).runs.at(-1)!; + assert.equal(cancelledRun.status, "queued"); + statements.splice(0); + yield* observed( + orchestrator.dispatch({ + type: "queued-run.cancel", + commandId: CommandId.make("cancel:long-history-reads"), + threadId, + runId: cancelledRun.id, + }), + ); + walks.push(...(yield* historyWalks("queued-run.cancel"))); yield* observed( orchestrator.dispatch({ type: "run.interrupt", @@ -2481,7 +2494,14 @@ it.effect("queued-message commands read a long thread's history without walking const after = yield* projections.getThreadProjection(threadId); assert.deepEqual( after.messages.filter((message) => message.role === "user").map((message) => message.text), - ["Request 1", "Request 2", "Request 3", "/compact", "Request queued, edited"], + [ + "Request 1", + "Request 2", + "Request 3", + "/compact", + "Request cancelled", + "Request queued, edited", + ], ); assert.deepEqual( after.providerThreads.map((providerThread) => providerThread.id), @@ -2490,3 +2510,103 @@ it.effect("queued-message commands read a long thread's history without walking assert.deepEqual(walks, [], walks.join("\n")); }).pipe(Effect.provide(layerTest)), ); + +it.effect("a running provider-turn update reads only the thread's queued and running runs", () => + Effect.gen(function* () { + const runsReads = yield* Deferred.make["compile"]>>(); + let armed = false; + // The deferred-steer reactor runs in a fiber forked by the layer, so observe at layer level. + const observe: Statement.Transformer = (statement) => { + const compiled = statement.compile(); + return armed && /FROM orchestration_v2_projection_runs\b/.test(compiled[0]) + ? Deferred.succeed(runsReads, compiled).pipe(Effect.as(statement)) + : Effect.succeed(statement); + }; + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const eventSink = yield* EventSink.EventSinkV2; + const sql = yield* SqlClient.SqlClient; + const threadId = ThreadId.make("thread:deferred-steer-reads"); + const driver = ProviderDriverKind.make("codex"); + const now = yield* DateTime.now; + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create-deferred-steer-reads"), + threadId, + projectId: ProjectId.make("project:deferred-steer-reads"), + title: "Deferred steer reads", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdBy: "user", + creationSource: "web", + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("dispatch:deferred-steer-reads"), + threadId, + messageId: MessageId.make("message:deferred-steer-reads"), + text: "Request", + attachments: [], + dispatchMode: { type: "defer_start" }, + createdBy: "user", + creationSource: "web", + }); + const run = (yield* projections.getThreadProjection(threadId)).runs.at(-1)!; + const providerThreadId = ProviderThreadId.make("provider-thread:deferred-steer-reads"); + yield* eventSink.write({ + events: [ + { + id: EventId.make("event:deferred-steer-reads:run"), + type: "run.updated", + threadId, + runId: run.id, + occurredAt: now, + payload: { ...run, providerThreadId, status: "running", queuePosition: null }, + }, + ], + }); + armed = true; + yield* eventSink.write({ + events: [ + { + id: EventId.make("event:deferred-steer-reads:turn"), + type: "provider-turn.updated", + threadId, + runId: run.id, + occurredAt: now, + payload: { + id: ProviderTurnId.make("provider-turn:deferred-steer-reads"), + providerThreadId, + nodeId: run.rootNodeId!, + runAttemptId: run.activeAttemptId!, + nativeTurnRef: { + driver, + nativeId: "native:deferred-steer-reads", + strength: "strong", + }, + ordinal: 1, + status: "running", + startedAt: now, + completedAt: null, + }, + }, + ], + }); + const [query, params] = yield* Deferred.await(runsReads); + const plan = yield* sql.unsafe<{ detail: string }>(`EXPLAIN QUERY PLAN ${query}`, params); + // Every run the thread ever had, when only queued steers and the running run matter. + assert.isFalse( + plan.some(({ detail }) => /^SEARCH .*runs.* \(thread_id=\?\)$/.test(detail)), + `${plan.map(({ detail }) => detail).join("\n")}\n in: ${query.replace(/\s+/g, " ").trim()}`, + ); + }).pipe( + Effect.provide( + layerTest.pipe(Layer.provide(Layer.succeed(Statement.CurrentTransformer, observe))), + ), + ); + }), +); diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 0fe744c9a9131..defb1a42c892e 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -11236,7 +11236,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const projection = yield* loadProjectionForCommand( command, ["runs", "messages", "nodes", "attempts", "subagents", "turnItems"], - { turnItemTypes: [], messageRoles: ["user"] }, + // Cancelling reads the queued run's own message and root node, never a settled leaf. + { + turnItemTypes: [], + messageRoles: ["user"], + messageRunIds: [command.runId], + omitSettledLeafNodes: true, + }, ); const queuedRun = projection.runs.find((candidate) => candidate.id === command.runId); if (queuedRun === undefined || queuedRun.status !== "queued") { @@ -15348,7 +15354,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio if (stored.event.type !== "provider-turn.updated") return; const threadId = stored.event.threadId; const turn = stored.event.payload; - const projection = yield* projectionStore.getThreadRecords(threadId, ["runs"]); + const projection = yield* projectionStore.getThreadRecords(threadId, ["runs"], { + runStatuses: ["queued", "running"], + }); const run = projection.runs.find( (candidate) => candidate.activeAttemptId === turn.runAttemptId && candidate.status === "running", From 21b0b7a0573ea4d00b45c0346bec178f367d7550 Mon Sep 17 00:00:00 2001 From: Simon Maggini Date: Fri, 13 Nov 2026 11:06:23 +0100 Subject: [PATCH 103/390] fix(composer): keep reviews from different Git hosts in suggestions (#36104) Co-authored-by: Julius Marminge --- .../threads/use-composer-command-menu.ts | 3 +- apps/mobile/src/state/queries.test.ts | 3 ++ apps/mobile/src/state/queries.ts | 22 ++++++++-- .../components/chat/useComposerMenuItems.ts | 16 ++++++- apps/web/src/composer-logic.test.ts | 10 +++++ .../src/composerPullRequestMatches.test.ts | 44 +++++++++++++++++-- .../shared/src/composerPullRequestMatches.ts | 17 ++++--- 7 files changed, 96 insertions(+), 19 deletions(-) diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.ts b/apps/mobile/src/features/threads/use-composer-command-menu.ts index 1e66bf4ee2c8e..a77f7089c97bd 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -1,3 +1,4 @@ +import { threadPullRequestKeyOf } from "@t3tools/client-runtime/thread-pull-requests"; import * as Dialog from "../../components/ui/Dialog"; import type { EnvironmentId, @@ -358,7 +359,7 @@ export function useComposerCommandMenu({ if (trigger.kind === "pull-request") { return pullRequestSearch.entries.map((entry) => ({ - id: `pr:${entry.projectId}:${entry.repository}:${entry.number}`, + id: `pr:${entry.projectId}:${threadPullRequestKeyOf(entry)}`, type: "pull-request", pullRequest: { number: entry.number, diff --git a/apps/mobile/src/state/queries.test.ts b/apps/mobile/src/state/queries.test.ts index 12f36dfe2e242..dba11b2e56ea7 100644 --- a/apps/mobile/src/state/queries.test.ts +++ b/apps/mobile/src/state/queries.test.ts @@ -1,8 +1,10 @@ +import { threadPullRequestKeyOf } from "@t3tools/client-runtime/thread-pull-requests"; import { filterComposerPullRequestMatches } from "@t3tools/shared/composerPullRequestMatches"; import { expect, it } from "@effect/vitest"; it("keeps an older exact PR in the mobile menu ahead of twenty newer substring matches", () => { const exact = { + host: "github.com", number: 42, projectId: "project", repository: "example/repo", @@ -14,6 +16,7 @@ it("keeps an older exact PR in the mobile menu ahead of twenty newer substring m updatedAt: "2026-01-01", })); const matches = filterComposerPullRequestMatches({ + keyOf: threadPullRequestKeyOf, entries: [exact, ...recent, exact], projectId: exact.projectId, repository: exact.repository, diff --git a/apps/mobile/src/state/queries.ts b/apps/mobile/src/state/queries.ts index 837d77ce851cf..a4cdbc6244204 100644 --- a/apps/mobile/src/state/queries.ts +++ b/apps/mobile/src/state/queries.ts @@ -1,3 +1,7 @@ +import { + legacyThreadPullRequestKey, + threadPullRequestKeyOf, +} from "@t3tools/client-runtime/thread-pull-requests"; import { composerPullRequestsFromLinks, filterComposerPullRequestMatches, @@ -148,11 +152,16 @@ export function useComposerPullRequestSearch(input: { }) : null, ); + // Only the host: the URL's full repository path would not match the project's selector. + const exactEntry = exact.data + ? { ...exact.data, host: legacyThreadPullRequestKey(exact.data).host } + : null; const entries = (() => { if (input.query === null || input.projectId === null || input.repository === null) return []; if (/^\d*$/.test(input.query)) { return filterComposerPullRequestMatches({ - entries: ready ? [...(exact.data ? [exact.data] : []), ...(list.data?.entries ?? [])] : [], + keyOf: threadPullRequestKeyOf, + entries: ready ? [...(exactEntry ? [exactEntry] : []), ...(list.data?.entries ?? [])] : [], linked, projectId: input.projectId, repository: input.repository, @@ -162,6 +171,7 @@ export function useComposerPullRequestSearch(input: { } if (!ready) { return withLinkedComposerPullRequestsFirst({ + keyOf: threadPullRequestKeyOf, matches: [], linked, query: input.query, @@ -169,7 +179,7 @@ export function useComposerPullRequestSearch(input: { }); } const words = (query ?? "").toLowerCase().split(/\s+/).filter(Boolean); - const found = [...(exact.data ? [exact.data] : []), ...(list.data?.entries ?? [])].filter( + const found = [...(exactEntry ? [exactEntry] : []), ...(list.data?.entries ?? [])].filter( (entry) => entry.projectId === input.projectId && entry.repository.toLowerCase() === input.repository?.toLowerCase() && @@ -177,9 +187,13 @@ export function useComposerPullRequestSearch(input: { `${entry.title} ${entry.headBranch} ${entry.baseBranch}`.toLowerCase().includes(word), ), ); - const unique = new Map(); - for (const entry of found) if (!unique.has(entry.number)) unique.set(entry.number, entry); + const unique = new Map(); + for (const entry of found) { + const key = threadPullRequestKeyOf(entry); + if (!unique.has(key)) unique.set(key, entry); + } return withLinkedComposerPullRequestsFirst({ + keyOf: threadPullRequestKeyOf, matches: [...unique.values()].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt), ), diff --git a/apps/web/src/components/chat/useComposerMenuItems.ts b/apps/web/src/components/chat/useComposerMenuItems.ts index 7620bf8611020..8090157604986 100644 --- a/apps/web/src/components/chat/useComposerMenuItems.ts +++ b/apps/web/src/components/chat/useComposerMenuItems.ts @@ -1,3 +1,7 @@ +import { + legacyThreadPullRequestKey, + threadPullRequestKeyOf, +} from "@t3tools/client-runtime/thread-pull-requests"; import { matchComposerEnvironmentItems, matchComposerThreadItems, @@ -327,10 +331,17 @@ export function useComposerMenuItems({ ) { const exactPullRequest = exactPullRequestLookup.data?.number === pullRequestTriggerNumber - ? [exactPullRequestLookup.data] + ? [ + { + ...exactPullRequestLookup.data, + // Only the host: the URL's full repository path would not match the project's selector. + host: legacyThreadPullRequestKey(exactPullRequestLookup.data).host, + }, + ] : []; const matches = /^\d*$/u.test(composerTrigger.query) ? filterComposerPullRequestMatches({ + keyOf: threadPullRequestKeyOf, entries: [...exactPullRequest, ...(pullRequestLookup.data?.entries ?? [])], linked: linkedPullRequests, projectId: pullRequestProjectId, @@ -339,6 +350,7 @@ export function useComposerMenuItems({ limit: COMPOSER_PULL_REQUEST_RESULT_LIMIT, }) : withLinkedComposerPullRequestsFirst({ + keyOf: threadPullRequestKeyOf, matches: rankPullRequestMatches( (pullRequestLookup.data?.entries ?? []).filter((entry) => { if ( @@ -363,7 +375,7 @@ export function useComposerMenuItems({ limit: COMPOSER_PULL_REQUEST_RESULT_LIMIT, }); return matches.map((pullRequest) => ({ - id: `pull-request:${pullRequest.projectId}:${pullRequest.repository}:${pullRequest.number}`, + id: `pull-request:${pullRequest.projectId}:${threadPullRequestKeyOf(pullRequest)}`, type: "pull-request", pullRequest: { number: pullRequest.number, diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts index 3a93b2efa201c..e7ec86822a547 100644 --- a/apps/web/src/composer-logic.test.ts +++ b/apps/web/src/composer-logic.test.ts @@ -1,3 +1,4 @@ +import { threadPullRequestKeyOf } from "@t3tools/client-runtime/thread-pull-requests"; import { filterComposerPullRequestMatches } from "@t3tools/shared/composerPullRequestMatches"; import { EnvironmentId, MessageId, ThreadId, type AssistantCitation } from "@t3tools/contracts"; import { @@ -331,30 +332,35 @@ describe("detectComposerTrigger", () => { describe("filterComposerPullRequestMatches", () => { const entries = [ { + host: "github.com", number: 7, projectId: "project-1", repository: "t3tools/t3code", updatedAt: "2026-09-01T12:00:00.000Z", }, { + host: "github.com", number: 8987, projectId: "project-1", repository: "T3Tools/T3Code", updatedAt: "2026-09-03T12:00:00.000Z", }, { + host: "github.com", number: 27, projectId: "project-1", repository: "t3tools/t3code", updatedAt: "2026-09-02T12:00:00.000Z", }, { + host: "github.com", number: 27, projectId: "project-1", repository: "t3tools/t3code", updatedAt: "2026-09-01T13:00:00.000Z", }, { + host: "github.com", number: 70, projectId: "project-2", repository: "t3tools/other", @@ -365,6 +371,7 @@ describe("filterComposerPullRequestMatches", () => { it("matches and de-duplicates number fragments within one repository, exact match first", () => { expect( filterComposerPullRequestMatches({ + keyOf: threadPullRequestKeyOf, entries, projectId: "project-1", repository: "t3tools/t3code", @@ -376,18 +383,21 @@ describe("filterComposerPullRequestMatches", () => { it("keeps an older exact match when newer substring matches would fill the limit", () => { const exact = { + host: "github.com", number: 7, projectId: "project-1", repository: "t3tools/t3code", updatedAt: "2020-01-01T00:00:00.000Z", }; const newerSubstringMatches = Array.from({ length: 12 }, (_unused, index) => ({ + host: "github.com", number: 700 + index, projectId: "project-1", repository: "t3tools/t3code", updatedAt: `2026-09-${String(index + 1).padStart(2, "0")}T12:00:00.000Z`, })); const matches = filterComposerPullRequestMatches({ + keyOf: threadPullRequestKeyOf, entries: [...newerSubstringMatches, exact], projectId: "project-1", repository: "t3tools/t3code", diff --git a/packages/shared/src/composerPullRequestMatches.test.ts b/packages/shared/src/composerPullRequestMatches.test.ts index 64fdb946acfa2..7ed872d4804bc 100644 --- a/packages/shared/src/composerPullRequestMatches.test.ts +++ b/packages/shared/src/composerPullRequestMatches.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import type { ThreadPullRequestLink } from "@t3tools/contracts"; +import type { ThreadPullRequestKey, ThreadPullRequestLink } from "@t3tools/contracts"; import { composerPullRequestsFromLinks, @@ -8,8 +8,12 @@ import { withLinkedComposerPullRequestsFirst, } from "./composerPullRequestMatches.ts"; +const keyOf = (entry: ThreadPullRequestKey) => + `${entry.host.trim().toLowerCase()}/${entry.repository.trim().toLowerCase()}#${entry.number}`; + const entry = (number: number, updatedAt: string) => ({ number, + host: "github.com", projectId: "p1", repository: "owner/repo", updatedAt, @@ -18,6 +22,7 @@ const entry = (number: number, updatedAt: string) => ({ describe("filterComposerPullRequestMatches", () => { it("ranks the exact number above newer substring matches", () => { const result = filterComposerPullRequestMatches({ + keyOf, entries: [entry(1234, "2026-01-03"), entry(123, "2026-01-01"), entry(1230, "2026-01-02")], projectId: "p1", repository: "owner/repo", @@ -29,6 +34,7 @@ describe("filterComposerPullRequestMatches", () => { it("orders by update instant when timestamps carry different UTC offsets", () => { const result = filterComposerPullRequestMatches({ + keyOf, entries: [entry(401, "2026-10-25T02:50:00+02:00"), entry(499, "2026-10-25T02:10:00+01:00")], projectId: "p1", repository: "owner/repo", @@ -66,6 +72,7 @@ describe("linked pull requests", () => { it("offers a linked pull request from another repository above the project's own", () => { const result = filterComposerPullRequestMatches({ + keyOf, entries: [entry(4, "2026-01-03"), { ...entry(4, "2026-01-04"), repository: "owner/other" }], linked: composerPullRequestsFromLinks([link("owner/repo-b", 4)], "p1"), projectId: "p1", @@ -79,8 +86,27 @@ describe("linked pull requests", () => { ]); }); + it("keeps linked reviews with the same repository and number on different hosts", () => { + const github = link("wireshark/wireshark", 1); + const gitlab = link("wireshark/wireshark", 1, { + host: "gitlab.com", + url: "https://gitlab.com/wireshark/wireshark/-/merge_requests/1", + }); + const result = filterComposerPullRequestMatches({ + keyOf, + entries: [], + linked: composerPullRequestsFromLinks([github, gitlab], "p1"), + projectId: "p1", + repository: "wireshark/wireshark", + query: "1", + limit: 10, + }); + expect(result.map((match) => match.url)).toEqual([github.url, gitlab.url]); + }); + it("keeps one row for a linked pull request the project listing already has", () => { const result = filterComposerPullRequestMatches({ + keyOf, entries: [entry(4, "2026-01-03")], linked: composerPullRequestsFromLinks([link("Owner/Repo", 4)], "p1"), projectId: "p1", @@ -98,22 +124,34 @@ describe("linked pull requests", () => { snapshot: { ...link("owner/repo", 7).snapshot!, title: "Fix login" }, }), link("owner/repo", 8), + link("owner/repo", 8, { + host: "gitlab.com", + url: "https://gitlab.com/owner/repo/-/merge_requests/8", + snapshot: { ...link("owner/repo", 8).snapshot!, title: "Fix CI" }, + }), ], "p1", ); const searching = withLinkedComposerPullRequestsFirst({ + keyOf, matches: [], linked, query: "fix", limit: 10, }); - expect(searching.map((match) => match.number)).toEqual([7]); + expect(searching.map((match) => match.number)).toEqual([7, 8]); const searched = withLinkedComposerPullRequestsFirst({ + keyOf, matches: [entry(9, "2026-01-05"), entry(8, "2026-01-03")], linked, query: "fix", limit: 10, }); - expect(searched).toEqual([linked[0], entry(8, "2026-01-03"), entry(9, "2026-01-05")]); + expect(searched).toEqual([ + linked[0], + entry(8, "2026-01-03"), + linked[2], + entry(9, "2026-01-05"), + ]); }); }); diff --git a/packages/shared/src/composerPullRequestMatches.ts b/packages/shared/src/composerPullRequestMatches.ts index 7c6b3955a4500..6eb38cf34ee66 100644 --- a/packages/shared/src/composerPullRequestMatches.ts +++ b/packages/shared/src/composerPullRequestMatches.ts @@ -1,11 +1,9 @@ -import type { ThreadPullRequestLink } from "@t3tools/contracts"; +import type { ThreadPullRequestKey, ThreadPullRequestLink } from "@t3tools/contracts"; import { visibleThreadPullRequests } from "./threadPullRequests.ts"; -export interface ComposerPullRequestMatch { - readonly number: number; +export interface ComposerPullRequestMatch extends ThreadPullRequestKey { readonly projectId: string; - readonly repository: string; readonly updatedAt: string; } @@ -19,6 +17,7 @@ export function composerPullRequestsFromLinks( : [ { projectId, + host: link.host, repository: link.repository, number: link.number, url: link.url, @@ -43,10 +42,10 @@ export function filterComposerPullRequestMatches< readonly repository: string; readonly query: string; readonly limit: number; + readonly keyOf: (entry: Entry | Linked) => string; }): ReadonlyArray { const repository = input.repository.trim().toLowerCase(); - const keyOf = (entry: ComposerPullRequestMatch) => - `${entry.repository.trim().toLowerCase()}#${entry.number}`; + const { keyOf } = input; const linked = (input.linked ?? []).filter((entry) => String(entry.number).includes(input.query)); const linkedKeys = new Set(linked.map(keyOf)); const matchingEntries = input.entries.filter( @@ -62,7 +61,7 @@ export function filterComposerPullRequestMatches< } } const isExactMatch = (entry: ComposerPullRequestMatch) => String(entry.number) === input.query; - const isLinked = (entry: ComposerPullRequestMatch) => linkedKeys.has(keyOf(entry)); + const isLinked = (entry: Entry | Linked) => linkedKeys.has(keyOf(entry)); const updatedAtMs = (entry: ComposerPullRequestMatch) => Date.parse(entry.updatedAt) || 0; // Hermes has no ES2023 array methods, so sort a fresh array. return [...uniqueEntries.values()] @@ -87,9 +86,9 @@ export function withLinkedComposerPullRequestsFirst< readonly linked: ReadonlyArray; readonly query: string; readonly limit: number; + readonly keyOf: (entry: Entry | Linked) => string; }): ReadonlyArray { - const keyOf = (entry: ComposerPullRequestMatch) => - `${entry.repository.trim().toLowerCase()}#${entry.number}`; + const { keyOf } = input; const matchesByKey = new Map(input.matches.map((entry) => [keyOf(entry), entry])); const words = input.query.toLowerCase().split(/\s+/u).filter(Boolean); const linked = input.linked.flatMap((entry): Array => { From ed7e5010baafd73d0164ee1d5adbd21e26f8f9cb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 02:20:04 -0800 Subject: [PATCH 104/390] fix(web): terminal "Add to chat" now adds to a queued message being edited (#36117) --- .../chat/useQueuedRunEdit.browser.test.tsx | 27 +++++++++++++++++++ .../src/components/chat/useQueuedRunEdit.ts | 14 ++++------ apps/web/src/composerDraftStore.ts | 20 +++++++++++++- 3 files changed, 51 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/chat/useQueuedRunEdit.browser.test.tsx b/apps/web/src/components/chat/useQueuedRunEdit.browser.test.tsx index 0320dd6d6bb18..3118cd3ec7100 100644 --- a/apps/web/src/components/chat/useQueuedRunEdit.browser.test.tsx +++ b/apps/web/src/components/chat/useQueuedRunEdit.browser.test.tsx @@ -18,6 +18,7 @@ import { toastManager } from "../ui/toast"; import { act, render } from "../../../test/render"; import { useComposerDraftStore, useComposerThreadDraft } from "../../composerDraftStore"; import { formatInlineContextReference } from "../../lib/composerContextReferences"; +import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "../../lib/terminalContext"; import { environmentContextRecord, environmentContextReference, @@ -327,6 +328,32 @@ describe("useQueuedRunEdit", () => { ); }); + it("adds terminal context to the queued message being edited", async () => { + const { hook } = setup(); + await using _screen = await render(view(THREAD_A, "queued", hook)); + await beginChangedEdit(hook); + const target = requireHook(hook).draftTarget; + const inserted = useComposerDraftStore.getState().insertTerminalContext( + target, + `changed${INLINE_TERMINAL_CONTEXT_PLACEHOLDER}`, + { + id: "terminal-context-1", + threadId: THREAD_A.threadId, + createdAt: "2026-01-01T00:00:00.000Z", + terminalId: "default", + terminalLabel: "Terminal 1", + lineStart: 3, + lineEnd: 3, + text: "hello", + }, + 0, + ); + expect(inserted).toBe(true); + expect( + useComposerDraftStore.getState().getComposerDraft(target)?.terminalContexts, + ).toHaveLength(1); + }); + it("keeps an open edit when the user leaves the thread and comes back", async () => { const { hook } = setup(); await using screen = await render(view(THREAD_A, "queued", hook)); diff --git a/apps/web/src/components/chat/useQueuedRunEdit.ts b/apps/web/src/components/chat/useQueuedRunEdit.ts index e5e5c11dff755..0ed3f9228940f 100644 --- a/apps/web/src/components/chat/useQueuedRunEdit.ts +++ b/apps/web/src/components/chat/useQueuedRunEdit.ts @@ -28,7 +28,7 @@ import { Atom } from "effect/reactivity"; import type { RefObject } from "react"; import { useAssetUrls } from "../../assets/assetUrls"; import { - DraftId, + queuedRunEditDraftId, useComposerDraftStore, type ComposerFileAttachment, type ComposerImageAttachment, @@ -95,10 +95,6 @@ interface QueuedRunEditContent { const isDraftOwnedRecord = (record: ComposerContextRecord) => record.kind === "thread" || record.kind === "environment"; -function queuedEditDraftTarget(routeThreadRef: ScopedThreadRef, runId: RunId) { - return DraftId.make(`queued-edit:${scopedThreadKey(routeThreadRef)}:${runId}`); -} - async function submitQueuedRunEdit(input: { readonly environmentId: EnvironmentId; readonly draftTarget: ComposerThreadTarget; @@ -216,7 +212,7 @@ export function useQueuedRunEdit(input: { const editing = activeThreadId === null ? null : storedEdit; const isSaving = useAtomValue(queuedRunEdits.savingAtom)[editKey] === true; const draftTarget = - editing === null ? threadDraftTarget : queuedEditDraftTarget(routeThreadRef, editing.runId); + editing === null ? threadDraftTarget : queuedRunEditDraftId(routeThreadRef, editing.runId); const imageResources = (editing?.existingAttachments ?? []) .filter((attachment) => attachment.type === "image") @@ -239,12 +235,12 @@ export function useQueuedRunEdit(input: { const clearEditDraft = (runId: RunId) => useComposerDraftStore .getState() - .clearComposerContent(queuedEditDraftTarget(routeThreadRef, runId)); + .clearComposerContent(queuedRunEditDraftId(routeThreadRef, runId)); const beginEdit = useStableCallback((request: EditQueuedRunRequest) => { if (activeThreadId === null) return; if (editing !== null && editing.runId !== request.runId) clearEditDraft(editing.runId); - const target = queuedEditDraftTarget(routeThreadRef, request.runId); + const target = queuedRunEditDraftId(routeThreadRef, request.runId); const sentText = stripAttachmentOnlyBootstrapPrompt(request.text); const context = serverProjection?.messages.find( (message) => message.id === request.messageId, @@ -294,7 +290,7 @@ export function useQueuedRunEdit(input: { // unscoped and names the thread: the user may be anywhere by now. onRunLeftQueue: (_, edit) => { const recovery = recoverQueuedMessageEdit({ - editTarget: queuedEditDraftTarget(routeThreadRef, edit.runId), + editTarget: queuedRunEditDraftId(routeThreadRef, edit.runId), threadTarget: threadDraftTarget, originalText: edit.originalText, removedExistingAttachment: edit.removedExistingAttachment === true, diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index f719780357a2f..68fdc2b2bca36 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -1545,6 +1545,21 @@ export function resolveComposerDraftKey( return threadKey.length > 0 ? threadKey : null; } +const QUEUED_RUN_EDIT_DRAFT_PREFIX = "queued-edit:"; + +// Thread and run ids both contain colons, so the run id is encoded to keep the thread key parseable. +export function queuedRunEditDraftId(threadRef: ScopedThreadRef, runId: string): DraftId { + return DraftId.make( + `${QUEUED_RUN_EDIT_DRAFT_PREFIX}${scopedThreadKey(threadRef)}:${encodeURIComponent(runId)}`, + ); +} + +function queuedRunEditThreadId(draftId: string): ThreadId | null { + if (!draftId.startsWith(QUEUED_RUN_EDIT_DRAFT_PREFIX)) return null; + const rest = draftId.slice(QUEUED_RUN_EDIT_DRAFT_PREFIX.length); + return parseScopedThreadKey(rest.slice(0, rest.lastIndexOf(":")))?.threadId ?? null; +} + function resolveComposerThreadId( state: ComposerThreadLookupState, target: ComposerThreadTarget, @@ -1556,7 +1571,10 @@ function resolveComposerThreadId( if (typeof normalizedTarget !== "string") { return normalizedTarget.threadId; } - return state.draftThreadsByThreadKey[normalizedTarget]?.threadId ?? null; + return ( + state.draftThreadsByThreadKey[normalizedTarget]?.threadId ?? + queuedRunEditThreadId(normalizedTarget) + ); } function getComposerDraftState( From a044f0c0b30c216341fc144d01bb0c7892679c69 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 02:27:15 -0800 Subject: [PATCH 105/390] fix(mobile): a final answer followed by more work in the same run stays visible (#36116) --- apps/mobile/src/lib/threadActivity.test.ts | 10 ++++++++++ apps/mobile/src/lib/threadActivity.ts | 22 +++++++++------------- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index d932a417d5f43..6de60afc1cb26 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -1497,6 +1497,16 @@ describe("buildThreadFeed", () => { phase === "final_answer", ); } + const confirmed = { ...answer, phase: "final_answer" as const }; + const continued = buildThreadFeed( + [user, confirmed, passed, final].map((item, index) => projected(item, index)), + ); + expect( + deriveThreadFeedPresentation(continued, null, new Set()).some( + (row) => row.type === "message" && row.message.id === answer.messageId, + ), + ).toBe(true); + expect(rows([user, confirmed, passed, final], "zen")).toEqual(["user", "answer", "final"]); expect(rows([user, steer, step, passed, final], "zen")).toEqual(["user", "steer", "final"]); expect(rows([user, steer, answer, step], "zen", running)).toEqual([ "user", diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 67d96c0f8845e..ec755ce12a2c7 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1103,21 +1103,17 @@ function feedEntryFoldRunId(entry: ThreadFeedEntry, runlessKey: RunId | null): R : null; } -function answersBeforeSteers(feed: ReadonlyArray): ReadonlySet { +function finalAnswerIds(feed: ReadonlyArray): ReadonlySet { const ids = new Set(); - let previous: ThreadFeedEntry | undefined; for (const entry of feed) { if ( entry.type === "message" && - isSteerMessage(entry.message) && - previous?.type === "message" && - previous.message.role === "assistant" && - previous.message.projectedItem?.item.type === "assistant_message" && - previous.message.projectedItem.item.phase === "final_answer" + entry.message.role === "assistant" && + entry.message.projectedItem?.item.type === "assistant_message" && + entry.message.projectedItem.item.phase === "final_answer" ) { - ids.add(previous.message.id); + ids.add(entry.message.id); } - previous = entry; } return ids; } @@ -1129,7 +1125,7 @@ function deriveThreadFeedRunFolds( feedFailedRunIds: ReadonlySet, ): ReadonlyMap { const terminalAssistantMessageIdByRun = new Map(); - const retainedAnswerIds = answersBeforeSteers(feed); + const retainedAnswerIds = finalAnswerIds(feed); const interruptedRunIds = new Set(); const failedRunIds = new Set(feedFailedRunIds); const promptIndexByRunId = new Map(); @@ -1523,7 +1519,7 @@ function zenAnswerMessageIds( ): ReadonlySet { const replies: Array<{ readonly message: ThreadFeedMessage; readonly index: number }> = []; const lastReplyByResponse = new Map(); - const steerAnswers = new Set(answersBeforeSteers(feed)); + const keptAnswers = new Set(finalAnswerIds(feed)); let userMessages = 0; let awaitingReply = false; let candidate: MessageId | null = null; @@ -1538,7 +1534,7 @@ function zenAnswerMessageIds( } const reply = entry.type === "message" && entry.message.role === "assistant" ? entry.message : null; - if (candidate !== null && reply !== null) steerAnswers.add(candidate); + if (candidate !== null && reply !== null) keptAnswers.add(candidate); candidate = null; if (answering && startsResponse(entry)) answeringFrom = index + 1; if (entry.type === "message" && entry.message.role === "user") { @@ -1558,7 +1554,7 @@ function zenAnswerMessageIds( replies .filter( ({ message, index }) => - steerAnswers.has(message.id) || + keptAnswers.has(message.id) || (terminal.has(message.id) && index < answeringFrom && (answeringRunId === null || message.runId !== answeringRunId)), From fa9d85fad55bf42c807e46e6a9a2e7ca44f4c161 Mon Sep 17 00:00:00 2001 From: Bilal Bakr <62337003+Bil0000@users.noreply.github.com> Date: Fri, 13 Nov 2026 13:32:47 +0300 Subject: [PATCH 106/390] fix(web): a server updated with the app reconnects as soon as it is ready (#35327) Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com> --- .../sidebar/updateEverythingRun.test.ts | 26 ++++++++++++++++++- .../sidebar/updateEverythingRun.tsx | 25 ++++++++++++++++-- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/sidebar/updateEverythingRun.test.ts b/apps/web/src/components/sidebar/updateEverythingRun.test.ts index f451eadc13b2d..c06ff106c1c30 100644 --- a/apps/web/src/components/sidebar/updateEverythingRun.test.ts +++ b/apps/web/src/components/sidebar/updateEverythingRun.test.ts @@ -1,7 +1,9 @@ +import { AVAILABLE_CONNECTION_STATE } from "@t3tools/client-runtime/connection"; import { EnvironmentId, type ServerConfig } from "@t3tools/contracts"; -import { Atom } from "effect/reactivity"; +import { AsyncResult, Atom } from "effect/reactivity"; import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { environmentCatalog } from "~/connection/catalog"; import { appAtomRegistry } from "~/rpc/atomRegistry"; import { serverEnvironment } from "~/state/server"; import type { ServerUpdateOutcome, ServerUpdateTarget } from "../ServerUpdateAction"; @@ -60,6 +62,28 @@ describe("startDesktopUpdateLaunchNotice", () => { } }); + it("retries the connection to a restarting server instead of waiting out its backoff", async () => { + const environmentId = EnvironmentId.make("backing-off-machine"); + using _connection = vi + .spyOn(environmentCatalog, "stateAtom") + .mockReturnValue( + Atom.make( + AsyncResult.success({ ...AVAILABLE_CONNECTION_STATE, phase: "backoff" as const }), + ) as unknown as ReturnType, + ); + using retryNow = vi + .spyOn(environmentCatalog.retryNow, "run") + .mockResolvedValue(AsyncResult.success(undefined)); + const stop = relaunchWhileRestarting(environmentId); + try { + await vi.advanceTimersByTimeAsync(1_000); + expect(retryNow).toHaveBeenCalledWith(appAtomRegistry, environmentId); + } finally { + await vi.advanceTimersByTimeAsync(32 * 60 * 1000); + stop(); + } + }); + it("clears the restored state when the server never comes back", async () => { const environmentId = EnvironmentId.make("lost-machine"); const stateAtom = serverEnvironment.updateStateAtom(environmentId); diff --git a/apps/web/src/components/sidebar/updateEverythingRun.tsx b/apps/web/src/components/sidebar/updateEverythingRun.tsx index ff12f9093571e..ac07c82e91b83 100644 --- a/apps/web/src/components/sidebar/updateEverythingRun.tsx +++ b/apps/web/src/components/sidebar/updateEverythingRun.tsx @@ -1,11 +1,18 @@ import { useAtomValue } from "@effect/atom-react"; -import { serverUpdateStateAtom } from "@t3tools/client-runtime/state/server"; +import { AVAILABLE_CONNECTION_STATE } from "@t3tools/client-runtime/connection"; +import { + nudgeReconnectDuringUpdateRestart, + serverUpdateStateAtom, +} from "@t3tools/client-runtime/state/server"; import { EnvironmentId, type ServerConfig } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -import { AsyncResult, Atom } from "effect/reactivity"; +import * as Stream from "effect/Stream"; +import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; import { APP_VERSION } from "~/branding"; +import { environmentCatalog } from "~/connection/catalog"; import { appAtomRegistry } from "~/rpc/atomRegistry"; import { desktopUpdateStateAtom } from "~/state/desktopUpdate"; import { serverEnvironment } from "~/state/server"; @@ -308,6 +315,7 @@ function followHandedOffServers(servers: ReadonlyArray): void { if (settled) return; settled = true; clearTimeout(timer); + stopReconnectNudge(); queueMicrotask(() => unsubscribe()); appAtomRegistry.set(stateAtom, { status: "idle" }); releaseState(); @@ -329,6 +337,19 @@ function followHandedOffServers(servers: ReadonlyArray): void { () => settle(`Not back after ${HANDOFF_TIMEOUT_MS / 60_000} minutes`), server.startedAt + HANDOFF_TIMEOUT_MS - Date.now(), ); + const stopReconnectNudge = Effect.runCallback( + nudgeReconnectDuringUpdateRestart({ + stateChanges: AtomRegistry.toStream( + appAtomRegistry, + environmentCatalog.stateAtom(server.environmentId), + ).pipe( + Stream.map((state) => AsyncResult.getOrElse(state, () => AVAILABLE_CONNECTION_STATE)), + ), + retryNow: Effect.promise(() => + environmentCatalog.retryNow.run(appAtomRegistry, server.environmentId), + ).pipe(Effect.asVoid), + }), + ); const unsubscribe = appAtomRegistry.subscribe( serverEnvironment.configValueAtom(server.environmentId), (config) => { From 7903b487badeba1e53ba0cc5dc8be76a6c3ae0f4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 02:34:55 -0800 Subject: [PATCH 107/390] fix(acp): Copilot runs end when its reply is done, not when its async shells exit (#36122) --- .../Adapters/AcpRegistryAdapterV2.test.ts | 89 ++++++++++ .../src/server/copilotAcp.ts | 11 ++ .../src/server/AcpSessionRuntime.ts | 152 +++++++++++------- packages/provider-acp/src/server/adapter.ts | 37 ++++- 4 files changed, 228 insertions(+), 61 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts index 48d9dd82a6ef2..70f45bc8a46c0 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts @@ -1768,6 +1768,95 @@ describe("AcpRegistryAdapterV2", () => { }).pipe(Effect.provide(layerTest)), ); + // Copilot holds session/prompt while an attached async shell runs (github/copilot-cli#5293); + // its documented foreground-done signal is the opt-in assistant.idle session event. + it.effect("settles Copilot's reply on assistant.idle while an async shell holds the prompt", () => + Effect.gen(function* () { + const sessionId = "copilot-session"; + const sessionEvent = (type: string) => ({ + type: "emit_inbound", + frame: { + kind: "notification", + method: "github.com/copilot/sessionEvent", + params: { sessionId, type, data: {} }, + }, + }); + const update = (sessionUpdate: Record) => + claudeFrames.update(sessionId, sessionUpdate); + const session = yield* openClaudeReplaySession( + [ + { + type: "expect_outbound", + frame: { kind: "request", method: "initialize", params: "" }, + }, + { + type: "emit_inbound", + frame: { + kind: "response", + method: "initialize", + result: { + protocolVersion: 1, + agentInfo: { name: "Copilot", version: "1.0.110" }, + agentCapabilities: { loadSession: true }, + authMethods: [], + }, + }, + }, + { + type: "expect_outbound", + frame: { kind: "request", method: "session/new", params: "" }, + }, + { + type: "emit_inbound", + frame: { kind: "response", method: "session/new", result: { sessionId } }, + }, + { + type: "expect_outbound", + frame: { kind: "request", method: "session/prompt", params: "" }, + }, + // A previous prompt's idle can land before this prompt's turn starts. + sessionEvent("assistant.idle"), + update({ sessionUpdate: "session_info_update", title: "after stale idle" }), + sessionEvent("assistant.turn_start"), + update({ + sessionUpdate: "tool_call", + toolCallId: "shell-1", + title: "sleep 120", + kind: "execute", + status: "completed", + rawInput: { command: "sleep 120", mode: "async", detach: false }, + }), + update({ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "ok" } }), + sessionEvent("assistant.idle"), + update({ sessionUpdate: "session_info_update", title: "after foreground idle" }), + // The shell never exits, so the first session/prompt is never answered. + { + type: "expect_outbound", + frame: { kind: "request", method: "session/prompt", params: "" }, + }, + sessionEvent("assistant.turn_start"), + update({ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "two" } }), + sessionEvent("assistant.idle"), + update({ sessionUpdate: "session_info_update", title: "after second idle" }), + ], + undefined, + false, + "github-copilot-cli", + ); + yield* session.runtime.startTurn(session.turnInput(1)); + const stale = yield* session.throughMarker("after stale idle"); + assert.isFalse(stale.some((event) => event.type === "turn.terminal")); + const foreground = yield* session.throughMarker("after foreground idle"); + const terminal = foreground.find((event) => event.type === "turn.terminal"); + assert.isDefined(terminal, "the run settles once Copilot's foreground work is idle"); + assert.equal(terminal?.type === "turn.terminal" ? terminal.status : null, "completed"); + yield* session.runtime.startTurn(session.turnInput(2)); + const second = yield* session.throughMarker("after second idle"); + const secondTerminal = second.find((event) => event.type === "turn.terminal"); + assert.equal(secondTerminal?.type === "turn.terminal" ? secondTerminal.runOrdinal : null, 2); + }).pipe(Effect.provide(layerTest)), + ); + it.effect.each([ { kind: "approval", stopReason: "end_turn", status: "completed" }, { kind: "question", stopReason: "error", status: "failed" }, diff --git a/packages/provider-acp-registry/src/server/copilotAcp.ts b/packages/provider-acp-registry/src/server/copilotAcp.ts index aeb893496e5d0..8482886892985 100644 --- a/packages/provider-acp-registry/src/server/copilotAcp.ts +++ b/packages/provider-acp-registry/src/server/copilotAcp.ts @@ -22,6 +22,8 @@ export const copilotClientCapabilitiesMeta = { "assistant.message_delta", "assistant.reasoning_delta", "session.compaction_complete", + "assistant.turn_start", + "assistant.idle", ], }, }; @@ -131,6 +133,15 @@ export function makeCopilotSubagentRouting() { } return context.markSessionCompacted(event.sessionId); } + // Copilot holds session/prompt while an attached async shell runs (github/copilot-cli#5293). + case "assistant.turn_start": + case "assistant.idle": { + if (event.agentId !== undefined || data.aborted === true) return Effect.void; + return context.reportForegroundWork({ + sessionId: event.sessionId, + state: event.type === "assistant.idle" ? "idle" : "running", + }); + } default: return Effect.void; } diff --git a/packages/provider-acp/src/server/AcpSessionRuntime.ts b/packages/provider-acp/src/server/AcpSessionRuntime.ts index bfd9cc150f7af..b216d6cc88b47 100644 --- a/packages/provider-acp/src/server/AcpSessionRuntime.ts +++ b/packages/provider-acp/src/server/AcpSessionRuntime.ts @@ -1163,10 +1163,16 @@ export class AcpSessionRuntime extends Context.Service< providerId: string, ) => Effect.Effect; readonly logout: Effect.Effect; - /** options.dispatched settles when cancel can target this prompt. */ + /** + * options.dispatched settles when cancel can target this prompt. options.released lets the + * next prompt go out while this one's response is still held; it still resolves this call. + */ readonly prompt: ( payload: Omit, - options?: { readonly dispatched?: Deferred.Deferred }, + options?: { + readonly dispatched?: Deferred.Deferred; + readonly released?: Deferred.Deferred; + }, ) => Effect.Effect; readonly cancel: Effect.Effect; readonly terminateProcessGroup?: Effect.Effect; @@ -2376,6 +2382,17 @@ export const make = Effect.fn("AcpSessionRuntime.make")(function* ( yield* child.kill({ forceKillAfter: "1 second" }).pipe(Effect.ignore); }); + const joinPrompt = ( + fiber: Fiber.Fiber, + ) => + Fiber.join(fiber).pipe( + Effect.catchCauseIf( + (cause) => options.cancelBehavior !== "wait-for-prompt" && Cause.hasInterruptsOnly(cause), + (_cause) => + Effect.succeed({ stopReason: "cancelled" } satisfies EffectAcpSchema.PromptResponse), + ), + ); + const cancel = Effect.gen(function* () { const started = yield* getStartedState; const activePrompt = yield* Ref.get(activePromptRef); @@ -2616,66 +2633,81 @@ export const make = Effect.fn("AcpSessionRuntime.make")(function* ( Effect.andThen(runLoggedRequest("logout", {}, acp.agent.logout({}))), ), prompt: (payload, promptOptions?) => - promptSerializationSemaphore.withPermit( - Effect.acquireUseRelease( - promptDispatchSemaphore.withPermit( - Effect.gen(function* () { - const started = yield* getStartedState; - yield* closeActiveAssistantSegment({ queue: eventQueue, assistantSegmentRef }); - yield* Ref.set(assistantUpdatesOpenRef, true); - const requestPayload = { - sessionId: started.sessionId, - ...payload, - } satisfies EffectAcpSchema.PromptRequest; - const completed = yield* Deferred.make(); - const fiber = yield* runLoggedRequest( - "session/prompt", - requestPayload, - acp.agent.prompt(requestPayload), - ).pipe(Effect.forkIn(runtimeScope)); - const active = { fiber, completed } satisfies AcpActivePrompt; - yield* Ref.set(activePromptRef, Option.some(active)); - if (promptOptions?.dispatched) { - yield* Deferred.succeed(promptOptions.dispatched, undefined); - } - return active; - }).pipe(notificationSemaphore.withPermit), - ), - (activePrompt) => - Fiber.join(activePrompt.fiber).pipe( - Effect.catchCauseIf( - (cause) => - options.cancelBehavior !== "wait-for-prompt" && Cause.hasInterruptsOnly(cause), - (_cause) => - Effect.succeed({ - stopReason: "cancelled", - } satisfies EffectAcpSchema.PromptResponse), - ), - Effect.tap(() => - closeActiveAssistantSegment({ queue: eventQueue, assistantSegmentRef }), - ), + promptSerializationSemaphore + .withPermit( + Effect.acquireUseRelease( + promptDispatchSemaphore.withPermit( + Effect.gen(function* () { + const started = yield* getStartedState; + yield* closeActiveAssistantSegment({ queue: eventQueue, assistantSegmentRef }); + yield* Ref.set(assistantUpdatesOpenRef, true); + const requestPayload = { + sessionId: started.sessionId, + ...payload, + } satisfies EffectAcpSchema.PromptRequest; + const completed = yield* Deferred.make(); + const fiber = yield* runLoggedRequest( + "session/prompt", + requestPayload, + acp.agent.prompt(requestPayload), + ).pipe(Effect.forkIn(runtimeScope)); + const active = { fiber, completed } satisfies AcpActivePrompt; + yield* Ref.set(activePromptRef, Option.some(active)); + if (promptOptions?.dispatched) { + yield* Deferred.succeed(promptOptions.dispatched, undefined); + } + return active; + }).pipe(notificationSemaphore.withPermit), ), - (activePrompt, result) => - Effect.gen(function* () { - if ( - options.cancelBehavior === "wait-for-prompt" && - Exit.isFailure(result) && - Cause.hasInterrupts(result.cause) - ) { - yield* retireRuntime( - new EffectAcpErrors.AcpTransportError({ - method: "session/prompt", - detail: "The ACP prompt stopped before the agent confirmed completion.", - cause: undefined, - }), - ); - } - yield* Fiber.interrupt(activePrompt.fiber).pipe(Effect.ignore); - yield* Ref.set(activePromptRef, Option.none()); - yield* Deferred.succeed(activePrompt.completed, undefined); - }), + (activePrompt) => { + const settled = joinPrompt(activePrompt.fiber).pipe( + Effect.tap(() => + closeActiveAssistantSegment({ queue: eventQueue, assistantSegmentRef }), + ), + Effect.map((response) => ({ _tag: "Settled" as const, response })), + ); + return promptOptions?.released === undefined + ? settled + : Effect.raceFirst( + settled, + Deferred.await(promptOptions.released).pipe( + Effect.as({ _tag: "Released" as const, fiber: activePrompt.fiber }), + ), + ); + }, + (activePrompt, result) => + Effect.gen(function* () { + if (Exit.isSuccess(result) && result.value._tag === "Released") { + yield* Ref.set(activePromptRef, Option.none()); + yield* Deferred.succeed(activePrompt.completed, undefined); + return; + } + if ( + options.cancelBehavior === "wait-for-prompt" && + Exit.isFailure(result) && + Cause.hasInterrupts(result.cause) + ) { + yield* retireRuntime( + new EffectAcpErrors.AcpTransportError({ + method: "session/prompt", + detail: "The ACP prompt stopped before the agent confirmed completion.", + cause: undefined, + }), + ); + } + yield* Fiber.interrupt(activePrompt.fiber).pipe(Effect.ignore); + yield* Ref.set(activePromptRef, Option.none()); + yield* Deferred.succeed(activePrompt.completed, undefined); + }), + ), + ) + .pipe( + Effect.flatMap((outcome) => + outcome._tag === "Settled" + ? Effect.succeed(outcome.response) + : joinPrompt(outcome.fiber), + ), ), - ), cancel: options.cancelBehavior === "wait-for-prompt" ? promptDispatchSemaphore.withPermit(cancel) diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index 723ad6ec5e7c7..24cde9fd967ba 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -251,6 +251,14 @@ export interface AcpAdapterV2ExtensionContext { }) => Effect.Effect; /** For compactions reported outside ACP `compaction_update`. */ readonly markSessionCompacted: (sessionId: string) => Effect.Effect; + /** + * For agents that hold `session/prompt` past their foreground work on ACP v1. An idle settles + * the run only after a `running` report during it, so an earlier prompt's idle cannot. + */ + readonly reportForegroundWork: (notice: { + readonly sessionId: string; + readonly state: "running" | "idle"; + }) => Effect.Effect; readonly requestUserInput: ( input: AcpAdapterV2UserInputRequest, requestContext: EffectAcpProtocol.AcpRequestContext, @@ -1482,6 +1490,8 @@ interface ActiveAcpTurn { readonly promptWireSettled: Deferred.Deferred; /** Completed once `session/prompt` is the runtime's active prompt. */ readonly promptDispatched: Deferred.Deferred; + /** Frees the runtime for the next prompt while the agent still holds this one's response. */ + readonly foregroundIdle: Deferred.Deferred; /** Clock millis just before `session/prompt` was handed to the runtime. */ promptSentAt: number | null; /** Completed when an update after dispatch matches `cancelAfterPromptStarts.isStart`. */ @@ -6967,6 +6977,27 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( handlerGeneration, markSessionCompacted(sessionId), ).pipe(Effect.asVoid), + reportForegroundWork: (notice) => + runRuntimeCallbackAtGeneration( + handlerGeneration, + Effect.gen(function* () { + if (yield* Ref.get(stoppedRunQuarantine)) return; + const context = yield* Ref.get(activeTurn); + if ( + context === null || + context.finalized || + context.interrupted || + context.nativeThreadId !== notice.sessionId + ) + return; + if (notice.state === "running") { + context.workAccepted = true; + } else if (context.workAccepted) { + yield* Deferred.succeed(context.foregroundIdle, undefined); + yield* finalizeTurn(context, "completed"); + } + }), + ).pipe(Effect.asVoid), applyBackgroundTaskMutation: (incoming) => runRuntimeCallbackAtGeneration( handlerGeneration, @@ -8418,6 +8449,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( workAccepted: false, promptWireSettled, promptDispatched, + foregroundIdle: yield* Deferred.make(), promptSentAt: null, promptStarted, promptUsage: undefined, @@ -8564,7 +8596,10 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( ? undefined : yield* flavor.watchPromptOutcome(requestedSessionId); const promptGeneration = yield* Ref.get(runtimeCallbackGeneration); - const promptOptions = { dispatched: context.promptDispatched }; + const promptOptions = { + dispatched: context.promptDispatched, + released: context.foregroundIdle, + }; context.promptSentAt = yield* Clock.currentTimeMillis; if (promptInactivityTimeoutMillis !== undefined) { context.inactivityWatchdog = yield* watchPromptInactivity( From 08be7829df166d6758ff651a993ee3e2ec2de422 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 03:18:36 -0800 Subject: [PATCH 108/390] fix(server): a link forgotten mid-resolve says it is not linked (#36132) --- apps/server/src/peer/PeerLinks.test.ts | 31 ++++++++++++++++++++++++++ apps/server/src/peer/PeerLinks.ts | 13 ++++++----- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/apps/server/src/peer/PeerLinks.test.ts b/apps/server/src/peer/PeerLinks.test.ts index d29a33c1119bb..fcb682ab03faf 100644 --- a/apps/server/src/peer/PeerLinks.test.ts +++ b/apps/server/src/peer/PeerLinks.test.ts @@ -565,6 +565,37 @@ it.effect("a linked peer that is renamed shows its new name", () => ).pipe(Effect.provide(NodeHttpServer.layerTest)), ); +it.effect( + "a link forgotten while it resolves says it is not linked, not that it lost its credential", + () => + Effect.scoped( + Effect.gen(function* () { + const b = yield* serveB(box); + const beforeGet = yield* Ref.make>(Effect.void); + const racing = Layer.effect( + ServerSecretStore.ServerSecretStore, + Effect.map(ServerSecretStore.make, (store) => + ServerSecretStore.ServerSecretStore.of({ + ...store, + get: (name) => + Ref.getAndSet(beforeGet, Effect.void).pipe( + Effect.flatten, + Effect.andThen(store.get(name)), + ), + }), + ), + ); + const a = yield* makeA(laptop, racing); + const pairing = yield* b.auth.issuePairingCredential(); + yield* a.links.link({ url: b.url, pairingCode: pairing.credential, access: "auto" }); + + yield* Ref.set(beforeGet, Effect.orDie(Effect.asVoid(a.links.unlink(box.environmentId)))); + const forgotten = yield* probe(a).pipe(Effect.flip); + expect(forgotten.message).toContain("not linked to"); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + it.effect("tells the agent to link again once the peer revokes the link or it expires", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/peer/PeerLinks.ts b/apps/server/src/peer/PeerLinks.ts index b9c70127971b6..2fa08d5b43671 100644 --- a/apps/server/src/peer/PeerLinks.ts +++ b/apps/server/src/peer/PeerLinks.ts @@ -635,13 +635,12 @@ const make = Effect.gen(function* () { const resolve: PeerLinks["Service"]["resolve"] = Effect.fn("PeerLinks.resolve")( function* (environmentId) { + const notLinked = new PeerLinkError({ + reason: "unknown_link", + message: `This environment is not linked to ${environmentId}. t3_environment_links lists the linked ones.`, + }); const row = yield* readRow(environmentId); - if (Option.isNone(row)) { - return yield* new PeerLinkError({ - reason: "unknown_link", - message: `This environment is not linked to ${environmentId}. t3_environment_links lists the linked ones.`, - }); - } + if (Option.isNone(row)) return yield* notLinked; const link = yield* toLink(row.value); if (DateTime.isLessThanOrEqualTo(link.expiresAt, yield* DateTime.now)) { return yield* new PeerLinkError({ @@ -651,6 +650,8 @@ const make = Effect.gen(function* () { } const stored = yield* secrets.get(row.value.secret_name).pipe(Effect.catch(failStorage)); if (Option.isNone(stored)) { + // An unlink between the two reads removes the row before its secret. + if (Option.isNone(yield* readRow(environmentId))) return yield* notLinked; return yield* new PeerLinkError({ reason: "unknown_link", message: `The link to ${link.label} lost its credential. Link it again.`, From 4aec277f79b2d4dabd88f13cfab264d9f80cbcef Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 03:21:14 -0800 Subject: [PATCH 109/390] fix(server): t3_queue and handoff analytics no longer read a thread's whole history (#36130) --- apps/server/src/mcp/McpHttpServer.ts | 23 ++--- apps/server/src/mcp/threadAccess.ts | 8 +- apps/server/src/mcp/toolkits/core.test.ts | 96 +++++++++++++++++++ .../src/mcp/toolkits/thread/handlers.ts | 11 ++- .../src/orchestration-v2/Orchestrator.ts | 12 +++ .../ThreadManagementService.ts | 8 ++ .../src/relay/AgentAwarenessRelay.test.ts | 1 + 7 files changed, 142 insertions(+), 17 deletions(-) diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 39f679154bcfe..eaf54214ba07b 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -1005,21 +1005,16 @@ const withToolAnalytics = const scheduledRunOf = (threadId: string, runId: RunId | null) => Option.isNone(threads) || runId === null ? Effect.succeed(false) - : threads.value - .getThreadRecords(ThreadId.make(threadId), ["runs", "messages"], { + : Effect.gen(function* () { + const id = ThreadId.make(threadId); + const { runs } = yield* threads.value.getThreadRecords(id, ["runs"], { runIds: [runId], - messageRoles: ["user"], - }) - .pipe( - Effect.map((records) => { - const run = records.runs.find((candidate) => candidate.id === runId); - return records.messages.some( - (message) => - message.id === run?.userMessageId && message.scheduledTaskId !== undefined, - ); - }), - Effect.orElseSucceed(() => false), - ); + }); + const run = runs.find((candidate) => candidate.id === runId); + if (run === undefined) return false; + const messages = yield* threads.value.getMessagesById(id, [run.userMessageId]); + return messages.get(run.userMessageId)?.scheduledTaskId !== undefined; + }).pipe(Effect.orElseSucceed(() => false)); const record = /* Request instrumentation already owns the span; keep this helper in that context. */ Effect.fnUntraced( diff --git a/apps/server/src/mcp/threadAccess.ts b/apps/server/src/mcp/threadAccess.ts index 48d972c5be2fd..416592a57fe79 100644 --- a/apps/server/src/mcp/threadAccess.ts +++ b/apps/server/src/mcp/threadAccess.ts @@ -1,4 +1,7 @@ -import type { ProjectionRecordField } from "../orchestration-v2/ProjectionStore.ts"; +import type { + ProjectionRecordField, + ProjectionRecordFilter, +} from "../orchestration-v2/ProjectionStore.ts"; import { CommandId, OrchestratorMcpFailure, @@ -182,7 +185,7 @@ const resolveThreadId = (context: Caller, threadId: ThreadId | undefined) => export const readThread = Effect.fn("mcp.readThread")(function* < K extends ProjectionRecordField = never, ->(threadId?: ThreadId, fields: ReadonlyArray = []) { +>(threadId?: ThreadId, fields: ReadonlyArray = [], filter?: ProjectionRecordFilter) { const context = yield* readCaller(); const targetId = yield* resolveThreadId(context, threadId); const shell = yield* context.threads.getThreadShell(targetId).pipe(Effect.mapError(unavailable)); @@ -192,6 +195,7 @@ export const readThread = Effect.fn("mcp.readThread")(function* < const projection = yield* context.threads .getProjectThreadRecords({ projectId: shell.projectId, threadId: targetId }, fields, { turnItemTypes: ["user_input_request"], + ...filter, }) .pipe( Effect.mapError((error) => diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index 5e80cf41b36b5..b2aaed70322cf 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -45,6 +45,7 @@ import * as ProviderAdapterRegistry from "../../orchestration-v2/ProviderAdapter import * as ThreadLaunchService from "../../orchestration-v2/ThreadLaunchService.ts"; import * as GitVcsDriver from "../../vcs/GitVcsDriver.ts"; import * as ThreadManagement from "../../orchestration-v2/ThreadManagementService.ts"; +import type * as ProjectionStore from "../../orchestration-v2/ProjectionStore.ts"; import * as ThreadSearch from "../../orchestration-v2/ThreadSearch.ts"; import * as PreviewBrowser from "../../preview/PreviewBrowser.ts"; import * as ProjectService from "../../project/ProjectService.ts"; @@ -467,6 +468,101 @@ it.effect("records only failed calls of tools that hand off no work", () => { ); }); +// A thread read whose runs or messages are bounded by nothing but the thread. +const historyReads = (reads: Array) => { + const record = ( + fields: ReadonlyArray, + filter: ProjectionStore.ProjectionRecordFilter | undefined, + ) => { + if ( + fields.includes("runs") && + filter?.runIds === undefined && + filter?.runStatuses === undefined + ) + reads.push("every run"); + if ( + fields.includes("messages") && + filter?.messageIds === undefined && + filter?.messageRunIds === undefined + ) + reads.push("every message"); + }; + const projection = McpToolAccessTestkit.idleThreadProjection( + McpToolAccessTestkit.liveThreadShell(threadId), + ); + return Layer.mock(ThreadManagement.ThreadManagementService)({ + getThreadShell: (id) => Effect.succeed(McpToolAccessTestkit.liveThreadShell(id)), + getProjectThreadRecords: (_target, fields, filter) => + Effect.sync(() => record(fields, filter)).pipe(Effect.as(projection as never)), + getThreadRecords: (_id, fields, filter) => + Effect.sync(() => record(fields, filter)).pipe(Effect.as(projection as never)), + getMessagesById: () => Effect.succeed(new Map()), + dispatch: () => Effect.succeed({ sequence: 1 } as never), + }); +}; + +it.effect("t3_queue list and read load only the queued runs and their messages", () => { + const reads: Array = []; + return Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const call = (args: Record) => + server + .callTool({ name: "t3_queue", arguments: args }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, scope), + Effect.provideService(McpSchema.McpServerClient, client), + ); + yield* call({ action: "list" }); + yield* call({ action: "read", queuedRunId: "run:queued" }); + expect(reads).toEqual([]); + }).pipe( + Effect.provide( + layerThreadToolkit.pipe( + Layer.provideMerge(McpHttpServer.layerInProcessMcpServer), + Layer.provide(NodeCrypto.layer), + Layer.provide(historyReads(reads)), + ), + ), + ); +}); + +it.effect("handoff analytics read only the caller's active run and its message", () => { + const reads: Array = []; + const recorded = Deferred.makeUnsafe(); + return Effect.gen(function* () { + const server = yield* McpServer.McpServer; + yield* server + .callTool({ + name: "t3_queue", + arguments: { action: "edit", queuedRunId: "run:queued", text: "Edited" }, + }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, scope), + Effect.provideService(McpSchema.McpServerClient, client), + ); + yield* Deferred.await(recorded); + expect(reads).toEqual([]); + }).pipe( + Effect.provide( + layerThreadToolkit.pipe( + Layer.provideMerge(McpHttpServer.layerInProcessMcpServer), + Layer.provide(NodeCrypto.layer), + Layer.provide(historyReads(reads)), + Layer.provide( + Layer.succeed( + AnalyticsService.AnalyticsService, + AnalyticsService.AnalyticsService.of({ + record: () => Deferred.succeed(recorded, undefined).pipe(Effect.asVoid), + flush: Effect.void, + recordOwners: () => Effect.void, + }), + ), + ), + ), + ), + ); +}); + it.effect("rejects an unknown organize target instead of archiving the calling thread", () => { const dispatched: Array = []; return Effect.gen(function* () { diff --git a/apps/server/src/mcp/toolkits/thread/handlers.ts b/apps/server/src/mcp/toolkits/thread/handlers.ts index 62023dd986b55..88bb91bf62b77 100644 --- a/apps/server/src/mcp/toolkits/thread/handlers.ts +++ b/apps/server/src/mcp/toolkits/thread/handlers.ts @@ -269,7 +269,16 @@ export const layer = McpToolAccess.toLayer(ThreadToolkit, { (input) => input.action === "list" || input.action === "read", McpToolAccess.reads((input) => Effect.gen(function* () { - const { projection } = yield* readThread(input.threadId, ["runs", "messages"]); + const { threads, projection: queued } = yield* readThread(input.threadId, ["runs"], { + runStatuses: ["queued"], + }); + const messages = yield* threads + .getMessagesById( + queued.thread.id, + queued.runs.map((run) => run.userMessageId), + ) + .pipe(Effect.mapError(unavailable)); + const projection = { runs: queued.runs, messages: [...messages.values()] }; if (input.action === "list") { const runs = queuedRunsInDeliveryOrder(projection); const cursor = input.cursor ?? 0; diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index defb1a42c892e..26482feb129fd 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -430,6 +430,13 @@ export interface OrchestratorV2Shape { options: ProjectionTimelinePageOptions, ) => Effect.Effect; readonly getMessageCount: (threadId: ThreadId) => Effect.Effect; + readonly getMessagesById: ( + threadId: ThreadId, + messageIds: ReadonlyArray, + ) => Effect.Effect< + ReadonlyMap, + OrchestratorV2Error + >; readonly getTranscriptByteFloor: ( threadId: ThreadId, limit: number, @@ -15765,6 +15772,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio .getMessageCount(threadId) .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))); }), + getMessagesById: Effect.fn("OrchestratorV2.getMessagesById")(function (threadId, messageIds) { + return projectionStore + .getMessagesById(threadId, messageIds) + .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))); + }), getTranscriptByteFloor: Effect.fn("OrchestratorV2.getTranscriptByteFloor")( function (threadId, limit) { return projectionStore diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index a69d308884c72..e821096f6c1d7 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -384,6 +384,7 @@ export interface ThreadManagementServiceShape { readonly getThreadHistoryPage: Orchestrator.OrchestratorV2["Service"]["getThreadHistoryPage"]; readonly getTimelinePage: Orchestrator.OrchestratorV2["Service"]["getTimelinePage"]; readonly getMessageCount: Orchestrator.OrchestratorV2["Service"]["getMessageCount"]; + readonly getMessagesById: Orchestrator.OrchestratorV2["Service"]["getMessagesById"]; readonly getTurnItem: (input: { readonly threadId: ThreadId; readonly itemId: TurnItemId; @@ -1652,6 +1653,13 @@ const make = Effect.gen(function* () { Effect.andThen(orchestrator.getMessageCount(threadId)), ); }), + getMessagesById: Effect.fn("ThreadManagementService.getMessagesById")( + function (threadId, messageIds) { + return ensureProjectionTranscript(threadId).pipe( + Effect.andThen(orchestrator.getMessagesById(threadId, messageIds)), + ); + }, + ), getTurnItem: Effect.fn("ThreadManagementService.getTurnItem")(function (input) { return ensureProjectionTranscript(input.threadId).pipe( Effect.andThen(orchestrator.getTurnItem(input)), diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 9fd349cc26185..68e2289524c09 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -238,6 +238,7 @@ const makeTestRelay = Effect.fnUntraced(function* ( getThreadHistoryPage: () => Effect.die("unused"), getTimelinePage: () => Effect.die("Unused timeline read"), getMessageCount: () => Effect.die("unused message count"), + getMessagesById: () => Effect.die("unused messages by id"), getTurnItem: () => Effect.die("unused turn item read"), getThreadRecords: () => Effect.die("unused record read"), getThreadProjection: unused, From 2d8a843e6be9ca170f3bce98cc92ad76617b00c4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 04:32:55 -0800 Subject: [PATCH 110/390] perf(server): shell reads of a worktree or family no longer parse every thread payload (#36146) --- .../orchestration-v2/ProjectionStore.test.ts | 20 +++++++++++++- .../src/orchestration-v2/ProjectionStore.ts | 25 +++++++++++------- apps/server/src/persistence/Migrations.ts | 2 ++ .../Migrations/089_ShellThreadReadIndexes.ts | 26 +++++++++++++++++++ .../reconcileV2PreviewMigration.test.ts | 2 ++ 5 files changed, 64 insertions(+), 11 deletions(-) create mode 100644 apps/server/src/persistence/Migrations/089_ShellThreadReadIndexes.ts diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 3b34434017e9d..6886912fc4cca 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -2807,11 +2807,25 @@ it.effect("SQL shells read a subagent family and a worktree's threads", () => assert.deepEqual([...(yield* family(id("missing")))], []); // Includes another root's idle child on the same path, which a shell may leave out. - const onWorktree = yield* store.getShellSnapshot({ location: "active", worktreePath }); + const { statements, tracer } = traceSqlStatements(); + const onWorktree = yield* store + .getShellSnapshot({ location: "active", worktreePath }) + .pipe(Effect.withTracer(tracer)); assert.sameMembers( onWorktree.threads.map((thread) => thread.id), [id("root"), id("child"), id("other-child")], ); + // A path names a few threads; scanning every thread parses every payload. + const sql = yield* SqlClient.SqlClient; + const threadQuery = statements.find((statement) => + statement.includes("AS latest_run_stop_requested"), + ); + assert.isDefined(threadQuery); + const threadPlan = (yield* sql.unsafe<{ readonly detail: string }>( + `EXPLAIN QUERY PLAN ${threadQuery}`, + [], + )).map((row) => row.detail); + assert.notInclude(threadPlan, "SCAN t", threadPlan.join("\n")); // A fork keeps its deleted source's root, so the deleted root still names the live tree. yield* store.apply({ id: EventId.make("event:family:root-deleted"), @@ -6629,6 +6643,10 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { assert.isNotEmpty(countQueries); for (const countQuery of countQueries) { for (const detail of yield* planOf(countQuery)) { + // A thread's counts read only each run's status, never its row and payload. + if (/^SEARCH (r|run) /.test(detail)) { + assert.include(detail, "COVERING INDEX", detail); + } if (detail.includes("orchestration_v2_projection_turn_items")) { // Only held input is read from the sparse index; transcript history stays unread. assert.include( diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 841cd0a0b5b7d..721006481e83e 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -7666,12 +7666,11 @@ export const layer: Layer.Layer = ( SELECT item.payload_json FROM orchestration_v2_projection_turn_items item - INNER JOIN orchestration_v2_projection_runs r ON r.run_id = item.run_id - WHERE r.run_id = presented.run_id - AND r.status = 'failed' + WHERE presented.status = 'failed' AND item.thread_id = t.thread_id + AND item.run_id = presented.run_id AND item.type = 'error' AND item.status = 'failed' - AND item.node_id IS json_extract(r.payload_json, '$.rootNodeId') + AND item.node_id IS json_extract(presented.payload_json, '$.rootNodeId') ORDER BY item.updated_at DESC, item.ordinal DESC, item.turn_item_id DESC LIMIT 1 ) AS terminal_failure_payload_json, @@ -7789,9 +7788,9 @@ export const layer: Layer.Layer = projectId === undefined ? sql`` : sql` AND t.project_id = ${projectId}` }${ location === "active" - ? sql` AND json_extract(t.payload_json, '$.archivedAt') IS NULL` + ? sql` AND t.archived_at IS NULL` : location === "archive" - ? sql` AND json_extract(t.payload_json, '$.archivedAt') IS NOT NULL` + ? sql` AND t.archived_at IS NOT NULL` : sql`` }${ unsettledOnly @@ -7800,7 +7799,8 @@ export const layer: Layer.Layer = }${ familyOf === undefined ? sql`` - : sql` AND json_extract(t.payload_json, '$.lineage.rootThreadId') = ( + : sql` AND CASE WHEN json_valid(t.payload_json) + THEN json_extract(t.payload_json, '$.lineage.rootThreadId') END = ( SELECT json_extract(member.payload_json, '$.lineage.rootThreadId') FROM orchestration_v2_projection_threads member WHERE member.thread_id = ${familyOf} @@ -7808,7 +7808,8 @@ export const layer: Layer.Layer = }${ worktreePath === undefined ? sql`` - : sql` AND json_extract(t.payload_json, '$.worktreePath') = ${worktreePath}` + : sql` AND CASE WHEN json_valid(t.payload_json) + THEN json_extract(t.payload_json, '$.worktreePath') END = ${worktreePath}` }${threadIds === undefined ? sql`` : sql` AND t.thread_id IN (SELECT value FROM json_each(${encodeIdList(threadIds)}))`} ORDER BY t.updated_at ASC, t.thread_id ASC `; @@ -7836,6 +7837,7 @@ export const layer: Layer.Layer = WHEN EXISTS ( SELECT 1 FROM orchestration_v2_projection_runs AS run + INDEXED BY orchestration_v2_projection_runs_run_status_idx WHERE run.run_id = per_run.run_key AND ( run.status IN ('queued', 'cancelled') @@ -7882,7 +7884,10 @@ export const layer: Layer.Layer = SUM(CASE WHEN counts.run_key = '' THEN counts.item_count ELSE 0 END) AS runless_item_count FROM orchestration_v2_projection_turn_item_counts counts - LEFT JOIN orchestration_v2_projection_runs r ON r.run_id = counts.run_key + -- The primary key would load each run's whole row just for its status. + LEFT JOIN orchestration_v2_projection_runs r + INDEXED BY orchestration_v2_projection_runs_run_status_idx + ON r.run_id = counts.run_key WHERE counts.thread_id IN (SELECT value FROM json_each(${encodeIdList(threadIds)})) GROUP BY counts.thread_id `; @@ -8742,7 +8747,7 @@ export const layer: Layer.Layer = ) AS has_remote_tasks FROM orchestration_v2_projection_threads t WHERE t.deleted_at IS NULL - AND json_extract(t.payload_json, '$.archivedAt') IS NULL + AND t.archived_at IS NULL ORDER BY t.updated_at ASC, t.thread_id ASC `; return rows.map((row): ActiveShellIndexEntry => ({ diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 6399ee7b85950..c076c0119d2f9 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -96,6 +96,7 @@ import Migration0085 from "./Migrations/085_TurnItemCounts.ts"; import Migration0086 from "./Migrations/086_QueuedTurnInputIndex.ts"; import Migration0087 from "./Migrations/087_ProjectionProjectsCustomTitle.ts"; import Migration0088 from "./Migrations/088_TurnItemTypeStatusIndex.ts"; +import Migration0089 from "./Migrations/089_ShellThreadReadIndexes.ts"; const migrationEntries = [ [1, "OrchestrationEvents", Migration0001], @@ -187,6 +188,7 @@ const migrationEntries = [ [86, "QueuedTurnInputIndex", Migration0086], [87, "ProjectionProjectsCustomTitle", Migration0087], [88, "TurnItemTypeStatusIndex", Migration0088], + [89, "ShellThreadReadIndexes", Migration0089], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/089_ShellThreadReadIndexes.ts b/apps/server/src/persistence/Migrations/089_ShellThreadReadIndexes.ts new file mode 100644 index 0000000000000..25133aa84be65 --- /dev/null +++ b/apps/server/src/persistence/Migrations/089_ShellThreadReadIndexes.ts @@ -0,0 +1,26 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + // Keep these expressions identical to ProjectionStore's shell worktree and family filters. + // json_valid keeps a malformed payload writable, as the other payload indexes do. + yield* sql` + CREATE INDEX IF NOT EXISTS orchestration_v2_projection_threads_worktree_path_idx + ON orchestration_v2_projection_threads( + CASE WHEN json_valid(payload_json) THEN json_extract(payload_json, '$.worktreePath') END + ) + `; + yield* sql` + CREATE INDEX IF NOT EXISTS orchestration_v2_projection_threads_root_thread_idx + ON orchestration_v2_projection_threads( + CASE WHEN json_valid(payload_json) THEN json_extract(payload_json, '$.lineage.rootThreadId') END + ) + `; + // Shell item counts read only a run's status; the primary key would load the whole row. + yield* sql` + CREATE INDEX IF NOT EXISTS orchestration_v2_projection_runs_run_status_idx + ON orchestration_v2_projection_runs(run_id, status) + `; +}); diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts index a2bba7d790eaf..d1158a15e2cbe 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts @@ -68,6 +68,7 @@ describe("V2 preview upgrade", () => { [86, "QueuedTurnInputIndex"], [87, "ProjectionProjectsCustomTitle"], [88, "TurnItemTypeStatusIndex"], + [89, "ShellThreadReadIndexes"], ]); assert.deepStrictEqual(yield* runMigrations(), []); assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports); @@ -179,6 +180,7 @@ describe("V2 preview upgrade", () => { [86, "QueuedTurnInputIndex"], [87, "ProjectionProjectsCustomTitle"], [88, "TurnItemTypeStatusIndex"], + [89, "ShellThreadReadIndexes"], ]); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); From c7a3f02e3851923ced8ebd000db79f641e11b038 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 05:32:16 -0800 Subject: [PATCH 111/390] fix(desktop): Local environment off survives an unreadable value in desktop-settings.json (#36168) --- .../src/settings/DesktopAppSettings.test.ts | 22 +++++++++++++ .../src/settings/DesktopAppSettings.ts | 31 +++++++++++-------- 2 files changed, 40 insertions(+), 13 deletions(-) diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index 1d28ed1862385..04406f63c6b3d 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -240,6 +240,28 @@ describe("DesktopSettings", () => { ), ); + it.effect("keeps Local environment off when another value in the file is unreadable", () => + withSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.writeFileString( + environment.desktopSettingsPath, + `{ "localEnvironmentEnabled": false, "serverExposureMode": "tailnet" }\n`, + ); + + assert.isFalse((yield* settings.load).localEnvironmentEnabled); + yield* settings.setMainWindowBounds({ x: 10, y: 20, width: 800, height: 600 }, false); + assert.include( + yield* fileSystem.readFileString(environment.desktopSettingsPath), + `"localEnvironmentEnabled":false`, + ); + }), + ), + ); + it.effect("rejects window bounds that do not satisfy the domain schema", () => withSettings( Effect.gen(function* () { diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index 1bb8a2521d881..e81da70660648 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -90,20 +90,25 @@ const DesktopWindowBoundsDocument = Schema.Struct({ height: Schema.Number, }); +// A value this build cannot read drops only its own key, so the rest of the file +// still loads and is written back instead of every setting resetting to defaults. +const optionalReadableKey = (schema: S) => + Schema.optionalKey(schema.pipe(Schema.catchDecoding(() => Effect.succeedNone))); + const DesktopSettingsDocument = Schema.Struct({ - localEnvironmentEnabled: Schema.optionalKey(Schema.Boolean), - linuxPasswordStore: Schema.optionalKey(Schema.Unknown), - mainWindowBounds: Schema.optionalKey(Schema.NullOr(DesktopWindowBoundsDocument)), - mainWindowMaximized: Schema.optionalKey(Schema.Boolean), - serverExposureMode: Schema.optionalKey(DesktopServerExposureModeSchema), - tailscaleServeEnabled: Schema.optionalKey(Schema.Boolean), - tailscaleServePort: Schema.optionalKey(Schema.Number), - updateChannel: Schema.optionalKey(DesktopUpdateChannelSchema), - updateChannelConfiguredByUser: Schema.optionalKey(Schema.Boolean), - wslBackendEnabled: Schema.optionalKey(Schema.Boolean), - wslMode: Schema.optionalKey(Schema.Literals(["local", "wsl"])), - wslDistro: Schema.optionalKey(Schema.NullOr(Schema.String)), - wslOnly: Schema.optionalKey(Schema.Boolean), + localEnvironmentEnabled: optionalReadableKey(Schema.Boolean), + linuxPasswordStore: optionalReadableKey(Schema.Unknown), + mainWindowBounds: optionalReadableKey(Schema.NullOr(DesktopWindowBoundsDocument)), + mainWindowMaximized: optionalReadableKey(Schema.Boolean), + serverExposureMode: optionalReadableKey(DesktopServerExposureModeSchema), + tailscaleServeEnabled: optionalReadableKey(Schema.Boolean), + tailscaleServePort: optionalReadableKey(Schema.Number), + updateChannel: optionalReadableKey(DesktopUpdateChannelSchema), + updateChannelConfiguredByUser: optionalReadableKey(Schema.Boolean), + wslBackendEnabled: optionalReadableKey(Schema.Boolean), + wslMode: optionalReadableKey(Schema.Literals(["local", "wsl"])), + wslDistro: optionalReadableKey(Schema.NullOr(Schema.String)), + wslOnly: optionalReadableKey(Schema.Boolean), }); type DesktopSettingsDocument = typeof DesktopSettingsDocument.Type; From 095611d1b55930ba23e22068b6659df4c4b53f83 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 05:59:15 -0800 Subject: [PATCH 112/390] fix(mobile): a sub-agent opened from its parent on a narrow iPad keeps Back (#36175) --- apps/mobile/src/components/ui/AppBar.tsx | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/components/ui/AppBar.tsx b/apps/mobile/src/components/ui/AppBar.tsx index 7690b135f9e11..b691faa300dae 100644 --- a/apps/mobile/src/components/ui/AppBar.tsx +++ b/apps/mobile/src/components/ui/AppBar.tsx @@ -1,8 +1,8 @@ import { Toolbar } from "./Toolbar"; import { AppActivityIndicator } from "./AppActivityIndicator"; import type { HeaderBarButtonMailSearchToolbarItem, SearchBarCommands } from "react-native-screens"; -import { NavigationContext } from "@react-navigation/native"; -import { use, useId, useLayoutEffect, useRef } from "react"; +import { NavigationContext, NavigationRouteContext } from "@react-navigation/native"; +import { use, useId, useLayoutEffect, useRef, useSyncExternalStore } from "react"; import { View } from "react-native"; import { createNativeHeaderMenu } from "./nativeHeaderMenu.ios"; import { ScreenHeaderButton } from "./ScreenHeaderButton"; @@ -63,6 +63,17 @@ export function AppBar(props: AppBarProps) { ? { ...props.backInSplitView, onPress: splitBackPress } : null; const navigation = use(NavigationContext); + const route = use(NavigationRouteContext); + // A thread opened from another thread sits on it, not beside the list, so it keeps Back. + const threadBelow = useSyncExternalStore( + (onChange) => navigation?.addListener("state", onChange) ?? (() => {}), + () => { + const routes = navigation?.getState()?.routes; + const index = routes?.findIndex((candidate) => candidate.key === route?.key) ?? -1; + return route?.name === "Thread" && index > 0 && routes?.[index - 1]?.name === "Thread"; + }, + ); + const listBeside = layout.usesSplitView && props.onBackToList !== undefined && !threadBelow; const rootBack = !layout.usesSplitView && props.onBackToList && navigation?.canGoBack() === false ? props.onBackToList @@ -113,7 +124,7 @@ export function AppBar(props: AppBarProps) { headerBackVisible: props.onClose === undefined && splitBack === null && - !(layout.usesSplitView && props.onBackToList) && + !listBeside && !(props.guardBack && props.onBack), ...(props.guardBack ? { gestureEnabled: false } : undefined), title: props.title, From 8610b31ee8df481f8f53bfb9827fe0abe5d0fab2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 06:19:22 -0800 Subject: [PATCH 113/390] fix(server): Codex usage answering after three seconds is no longer dropped (#36189) --- apps/server/src/provider/CodexProvider.ts | 7 +- .../src/provider/ProviderRegistry.test.ts | 81 +++++++++++++++++++ 2 files changed, 82 insertions(+), 6 deletions(-) diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index ab2cda61e53ee..f3bbf514a83e1 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -57,7 +57,6 @@ import { import * as HostProcess from "@t3tools/shared/HostProcess"; import packageJson from "../../package.json" with { type: "json" }; const isCodexAppServerSpawnError = Schema.is(CodexErrors.CodexAppServerSpawnError); -const RATE_LIMITS_PROBE_TIMEOUT_MS = 3_000; type CodexRateLimitsProbe = | { @@ -670,11 +669,7 @@ const readCodexRateLimits = ( client: CodexClient.CodexAppServerClient["Service"], deadlineMs: number, ) => - withCodexDiscoveryDeadline( - client.request("account/rateLimits/read", null), - deadlineMs, - RATE_LIMITS_PROBE_TIMEOUT_MS, - ).pipe( + withCodexDiscoveryDeadline(client.request("account/rateLimits/read", null), deadlineMs).pipe( Effect.map((response): CodexRateLimitsProbe => response ? { diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index 0601714dd618e..564a90f40e6ce 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -17,6 +17,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as PubSub from "effect/PubSub"; +import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; @@ -788,6 +789,86 @@ it.layer( }), ); + it.effect("shows Codex usage that answers after three seconds inside the probe budget", () => + Effect.gen(function* () { + const usageRequested = yield* Deferred.make(); + const spawner = ChildProcessSpawner.make(() => + Effect.gen(function* () { + const scope = yield* Effect.scope; + const output = yield* Queue.unbounded(); + const send = (message: unknown) => + Queue.offer(output, encoder.encode(`${JSON.stringify(message)}\n`)); + const results: Record = { + initialize: { + userAgent: "codex_cli_rs/0.167.0", + codexHome: "/nonexistent-codex-home", + platformFamily: "unix", + platformOs: "linux", + }, + "account/read": { + account: { type: "chatgpt", email: "test@example.com", planType: "pro" }, + requiresOpenaiAuth: true, + }, + "config/read": { config: {}, origins: {} }, + "skills/list": { data: [] }, + "model/list": { data: [] }, + }; + const answer = (line: string) => { + const { id, method } = JSON.parse(line) as { id?: number; method?: string }; + if (id === undefined || method === undefined) return Effect.void; + if (method !== "account/rateLimits/read") { + return send({ id, result: results[method] ?? {} }); + } + return Deferred.succeed(usageRequested, undefined).pipe( + Effect.andThen(Effect.sleep("4 seconds")), + Effect.andThen( + send({ + id, + result: { + rateLimits: { + limitId: "codex", + planType: "pro", + primary: { usedPercent: 42, windowDurationMins: 300, resetsAt: 2e9 }, + }, + rateLimitsByLimitId: null, + rateLimitResetCredits: null, + accountId: null, + }, + }), + ), + Effect.forkIn(scope), + ); + }; + const decoder = new TextDecoder(); + let buffered = ""; + return ChildProcessSpawner.makeHandle({ + ...mockHandle({ stdout: "", stderr: "", code: 0 }), + exitCode: Effect.never, + isRunning: Effect.succeed(true), + stdin: Sink.forEach((chunk: Uint8Array) => { + buffered += decoder.decode(chunk, { stream: true }); + const lines = buffered.split("\n"); + buffered = lines.pop() ?? ""; + return Effect.forEach(lines.filter(Boolean), answer, { discard: true }); + }), + stdout: Stream.fromQueue(output), + }); + }), + ); + const fiber = yield* checkCodexProviderStatus(defaultCodexSettings).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.forkChild, + ); + yield* Deferred.await(usageRequested); + yield* TestClock.adjust("25 seconds"); + const status = yield* Fiber.join(fiber); + + assert.strictEqual(status.auth.status, "authenticated"); + assert.isUndefined(status.usageLimits?.unavailable); + assert.strictEqual(status.usageLimits?.windows[0]?.usedPercent, 42); + }), + ); + it.effect("does not run --version when the Codex executable cannot start", () => Effect.gen(function* () { if ((yield* HostProcess.Platform) === "win32") return; From 9a35ab0afeae64ac7c3bf92d674ead365cf55e10 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Fri, 13 Nov 2026 19:57:05 +0530 Subject: [PATCH 114/390] fix(mobile): iOS terminal font and theme changes no longer replay output (#36115) Co-authored-by: Claude Haiku 5.5 --- .../t3-terminal/ios/T3TerminalView.swift | 38 +++++++++++++++---- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift index d8f0b29ebce52..1ed02e6a16cbf 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift @@ -285,7 +285,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate, UIEditMenuInte didSet { guard oldValue != fontSize else { return } inputField.font = UIFont.monospacedSystemFont(ofSize: max(fontSize, 13), weight: .regular) - resetSurface() + applyConfigInPlace() } } @@ -321,14 +321,14 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate, UIEditMenuInte didSet { guard oldValue != appearanceScheme else { return } appearance = TerminalAppearanceScheme(value: appearanceScheme) - resetSurface() + applyConfigInPlace() } } var themeConfig: String = "" { didSet { guard oldValue != themeConfig else { return } - resetSurface() + applyConfigInPlace() } } @@ -663,7 +663,8 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate, UIEditMenuInte surfaceCreationFailed = true return } - loadThemeConfig(into: config) + // Creation proceeds without the file, as before; font_size is also set on the surface config. + _ = loadThemeConfig(into: config) ghostty_config_finalize(config) defer { ghostty_config_free(config) } @@ -710,6 +711,27 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate, UIEditMenuInte setNeedsLayout() } + // Font and theme change in place: a new surface would replay output and re-answer its queries. + private func applyConfigInPlace() { + guard let app, let surface else { + resetSurface() + return + } + guard let config = ghostty_config_new() else { return } + defer { ghostty_config_free(config) } + let loaded = loadThemeConfig(into: config) + ghostty_config_finalize(config) + ghostty_app_set_color_scheme(app, appearance.ghosttyColorScheme) + ghostty_surface_set_color_scheme(surface, appearance.ghosttyColorScheme) + tickApp() + // Without the file, the config lacks font-size and would reset the surface to defaults. + if loaded { + ghostty_surface_update_config(surface, config) + } + resizeSurface() + setNeedsLayout() + } + private func destroySurface() { // Unblock the io thread before ghostty_surface_free joins it. tickApp() @@ -933,16 +955,16 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate, UIEditMenuInte terminalViewport.backgroundColor = backgroundColorValue } - private func loadThemeConfig(into config: ghostty_config_t) { - guard let path = writeThemeConfigFile() else { return } + private func loadThemeConfig(into config: ghostty_config_t) -> Bool { + guard let path = writeThemeConfigFile() else { return false } path.withCString { cString in ghostty_config_load_file(config, cString) } + return true } private func writeThemeConfigFile() -> String? { - guard !themeConfig.isEmpty else { return nil } - let configContents = themeConfig + let configContents = "\(themeConfig)\nfont-size = \(String(format: "%g", Double(fontSize)))\n" let url = URL(fileURLWithPath: NSTemporaryDirectory()) .appendingPathComponent("t3-terminal-theme-\(appearance.rawValue).ghostty") From f313d0a6cb843551fb60e57a454a71e71a3a8c04 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Fri, 13 Nov 2026 20:10:55 +0530 Subject: [PATCH 115/390] fix(server): stop device daemon alongside slow hub shutdown (#33566) Co-authored-by: maria-rcks --- .../server/src/device/LocalDeviceHost.test.ts | 68 ++++++++++++++----- apps/server/src/device/LocalDeviceHost.ts | 13 +++- 2 files changed, 61 insertions(+), 20 deletions(-) diff --git a/apps/server/src/device/LocalDeviceHost.test.ts b/apps/server/src/device/LocalDeviceHost.test.ts index e133b4f9db36c..5f7cef54f5277 100644 --- a/apps/server/src/device/LocalDeviceHost.test.ts +++ b/apps/server/src/device/LocalDeviceHost.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "@effect/vitest"; import * as NodePath from "@effect/platform-node/NodePath"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; @@ -45,7 +47,8 @@ const agentFixture = Effect.fn("agentFixture")(function* ( const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-device-agent-start-" }); - const hub = { running: true, spawnFails: false }; + const hub = { running: true, spawnFails: false, cleanup: Effect.void }; + const daemonStopRequested = yield* Deferred.make>(); const stateDir = agentDeviceStateDir(path, path.join(baseDir, "userdata")); const daemonHealthy = yield* Ref.make(true); const runs: Array = []; @@ -80,20 +83,22 @@ const agentFixture = Effect.fn("agentFixture")(function* ( description: "Hub entry missing", }), ) - : Effect.succeed( - ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(123), - stdout: Stream.empty, - stderr: Stream.empty, - all: Stream.empty, - exitCode: Effect.never, - isRunning: Effect.sync(() => hub.running), - kill: () => Effect.void, - stdin: Sink.drain, - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - unref: Effect.succeed(Effect.void), - }), + : Effect.addFinalizer(() => hub.cleanup).pipe( + Effect.as( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(123), + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + exitCode: Effect.never, + isRunning: Effect.sync(() => hub.running), + kill: () => Effect.void, + stdin: Sink.drain, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + unref: Effect.succeed(Effect.void), + }), + ), ), ), ), @@ -102,7 +107,9 @@ const agentFixture = Effect.fn("agentFixture")(function* ( runs.push(input); return input.args.includes("devices") ? startup(stateDir) - : Effect.succeed(input.args.includes("-list-avds") ? avdOutput : processOutput(0)); + : input.args.includes("daemon") + ? Deferred.succeed(daemonStopRequested, input.args).pipe(Effect.as(processOutput(0))) + : Effect.succeed(input.args.includes("-list-avds") ? avdOutput : processOutput(0)); }, }), Effect.provideService( @@ -122,7 +129,7 @@ const agentFixture = Effect.fn("agentFixture")(function* ( ), ); yield* host.ensureReady(() => Effect.void); - return { host, fs, baseDir, stateDir, hub, daemonHealthy, runs }; + return { host, fs, baseDir, stateDir, hub, daemonHealthy, daemonStopRequested, runs }; }); describe("AgentDevice startup", () => { @@ -353,6 +360,33 @@ describe("AgentDevice startup", () => { expect(yield* fs.exists(path.join(stateDir, "daemon.json"))).toBe(false); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + + it.effect("stops the agent daemon while the hub is still shutting down", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { host, hub, stateDir, daemonStopRequested } = yield* agentFixture((stateDir) => + fs + .writeFileString( + `${stateDir}/daemon.json`, + JSON.stringify({ httpPort: 12345, token: "test-token" }), + ) + .pipe(Effect.orDie, Effect.as(processOutput(0))), + ); + yield* host.ensureAgentReady(() => Effect.void); + const hubExited = yield* Deferred.make(); + hub.cleanup = Deferred.await(hubExited); + const stopping = yield* Effect.forkChild(host.stop); + expect((yield* Deferred.await(daemonStopRequested)).slice(1)).toEqual([ + "daemon", + "stop", + "--state-dir", + stateDir, + ]); + yield* Deferred.succeed(hubExited, undefined); + yield* Fiber.join(stopping); + expect(yield* fs.exists(`${stateDir}/hub.json`)).toBe(false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); }); const diagnose = ( diff --git a/apps/server/src/device/LocalDeviceHost.ts b/apps/server/src/device/LocalDeviceHost.ts index c5097509dcade..e657d0dcdfc13 100644 --- a/apps/server/src/device/LocalDeviceHost.ts +++ b/apps/server/src/device/LocalDeviceHost.ts @@ -870,9 +870,16 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { const stop: DeviceHost.DeviceHost["Service"]["stop"] = startLock.withPermits(1)( Effect.gen(function* () { const running = yield* Ref.getAndSet(runningRef, null); - yield* stopHub(running?.hub); - yield* fs.remove(hubStatePath(), { force: true }).pipe(Effect.ignore); - yield* stopAgentDeviceDaemon; + // Hub cleanup waits for its exit unbounded; it must not strand the detached agent daemon. + yield* Effect.all( + [ + stopHub(running?.hub).pipe( + Effect.andThen(fs.remove(hubStatePath(), { force: true }).pipe(Effect.ignore)), + ), + stopAgentDeviceDaemon, + ], + { concurrency: "unbounded", discard: true }, + ); }), ); From 3ac8b1f587ed34ed038030055a66e2ac52a35216 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 08:43:07 -0800 Subject: [PATCH 116/390] fix(acp): bound unread notification retention by size (#23217) --- packages/effect-acp/src/protocol.test.ts | 42 ++++++++++++++++++++++++ packages/effect-acp/src/protocol.ts | 17 ++++++---- 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/packages/effect-acp/src/protocol.test.ts b/packages/effect-acp/src/protocol.test.ts index 27f949c3a7807..19f58292ac469 100644 --- a/packages/effect-acp/src/protocol.test.ts +++ b/packages/effect-acp/src/protocol.test.ts @@ -239,6 +239,48 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { }), ); + it.effect("evicts unread raw notifications while a slow reader handles an earlier one", () => + Effect.gen(function* () { + const { stdio, input } = yield* makeInMemoryStdio(); + const handled = yield* Queue.unbounded(); + const transport = yield* AcpProtocol.makeAcpPatchedProtocol({ + stdio, + serverRequestMethods: new Set(), + onNotification: (notification) => + Queue.offer(handled, decodeIndex(notification.params).index).pipe(Effect.asVoid), + }); + const send = (index: number, textLength: number) => + Queue.offer(input, largeNotification(index, textLength)).pipe( + Effect.andThen(Queue.take(handled)), + ); + + yield* send(1, 6 * MIB); + yield* send(2, 6 * MIB); + const entered = yield* Deferred.make(); + const release = yield* Deferred.make(); + const reader = yield* transport.incoming.pipe( + Stream.map((notification) => decodeIndex(notification.params).index), + Stream.tap((index) => + index === 1 + ? Deferred.succeed(entered, undefined).pipe(Effect.andThen(Deferred.await(release))) + : Effect.void, + ), + Stream.take(4), + Stream.runCollect, + Effect.forkScoped, + ); + yield* Deferred.await(entered); + + // Notification 2 is still unread, so 3 and 4 push it past the budget. + yield* send(3, 6 * MIB); + yield* send(4, 6 * MIB); + yield* send(5, 1); + yield* Deferred.succeed(release, undefined); + + assert.deepEqual(yield* Fiber.join(reader), [1, 3, 4, 5]); + }), + ); + it.effect("delivers every raw notification to a reader that keeps up, then ends", () => Effect.gen(function* () { const { stdio, input } = yield* makeInMemoryStdio(); diff --git a/packages/effect-acp/src/protocol.ts b/packages/effect-acp/src/protocol.ts index 8ae077e4c5cd9..490c27d8e0b08 100644 --- a/packages/effect-acp/src/protocol.ts +++ b/packages/effect-acp/src/protocol.ts @@ -313,9 +313,8 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi const serverQueue = yield* Queue.unbounded(); const clientQueue = yield* Queue.unbounded(); const rawNotifications = yield* Queue.unbounded>(); - // JSON length of the notifications offered since the queue was last empty. - // `incoming` takes the whole backlog on every pull, so an empty queue holds - // nothing, and a read can only make this an overestimate. + // JSON length of the notifications still in the queue. `incoming` takes one + // at a time, so a slow reader holds at most one outside this budget. let rawNotificationsJsonLength = 0; // Raw readers finish with the connection: termination ends the queue, and so // does closing the scope, which interrupts the reader before it terminates. @@ -494,7 +493,6 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi // so readers never see more than the limits allow. const bufferRawNotification = (notification: AcpIncomingNotification, lineLength: number) => Effect.sync(() => { - if (Queue.sizeUnsafe(rawNotifications) === 0) rawNotificationsJsonLength = 0; if (!Queue.offerUnsafe(rawNotifications, { notification, lineLength })) return; rawNotificationsJsonLength += lineLength; while ( @@ -1101,8 +1099,15 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi clientProtocol, serverProtocol, get incoming() { - return Stream.fromQueue(rawNotifications).pipe( - Stream.map(({ notification }) => notification), + return Stream.fromPull( + Effect.succeed( + Queue.take(rawNotifications).pipe( + Effect.map(({ notification, lineLength }) => { + rawNotificationsJsonLength -= lineLength; + return [notification] as const; + }), + ), + ), ); }, request: sendRequest, From 00f617db4e97dbfc4d713f69d9f90394e7d3b12a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 08:43:22 -0800 Subject: [PATCH 117/390] fix(client-runtime): recover subscriptions that stop on a healthy connection (#24400) --- .../client-runtime/src/rpc/client.test.ts | 138 +++++++++++++++++- packages/client-runtime/src/rpc/client.ts | 56 ++++++- .../client-runtime/src/state/runtime.test.ts | 104 +++++++++++++ 3 files changed, 289 insertions(+), 9 deletions(-) diff --git a/packages/client-runtime/src/rpc/client.test.ts b/packages/client-runtime/src/rpc/client.test.ts index 1d0005e35f0a4..de99737a28dd0 100644 --- a/packages/client-runtime/src/rpc/client.test.ts +++ b/packages/client-runtime/src/rpc/client.test.ts @@ -7,15 +7,17 @@ import { } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; -import * as TestClock from "effect/testing/TestClock"; import * as SubscriptionRef from "effect/SubscriptionRef"; +import * as TestClock from "effect/testing/TestClock"; import { RpcClientError } from "effect/rpc"; import { @@ -45,6 +47,8 @@ const INSTALL_DOWNLOADING: RelayClientInstallProgressEvent = { stage: "downloading", }; +const decodeNumber = Schema.decodeUnknownEffect(Schema.Number); + function session(client: WsRpcProtocolClient): RpcSession.RpcSession { return { client, @@ -265,6 +269,8 @@ describe("environment RPC", () => { while (subscriptions.length < 2) { yield* Queue.take(subscriptionChanges); } + // The pending same-session retry must not resubscribe the replaced session. + yield* TestClock.adjust("1 minute"); yield* Fiber.interrupt(subscriptionFiber); expect(subscriptions).toEqual(["first", "second"]); @@ -272,6 +278,136 @@ describe("environment RPC", () => { }), ); + // The session stays healthy (`closed` never resolves), as when one stream chunk fails to decode, + // so the supervisor will not replace it and only resubscribing restores delivery. + const resumesOnTheSameSession = Effect.fn("TestEnvironmentRpc.resumesOnTheSameSession")( + function* (streamFailure: Stream.Stream) { + const resumedValue = { source: "resumed" } as unknown as ServerLifecycleStreamEvent; + const firstSubscribed = yield* Deferred.make(); + let subscriptions = 0; + const client = { + [WS_METHODS.subscribeServerLifecycle]: () => { + subscriptions += 1; + return subscriptions === 1 + ? Stream.fromEffect(Deferred.succeed(firstSubscribed, undefined)).pipe( + Stream.drain, + Stream.concat(streamFailure), + ) + : Stream.make(resumedValue).pipe(Stream.concat(Stream.never)); + }, + } as unknown as WsRpcProtocolClient; + const { activeSession, retryCount, supervisor } = yield* makeHarness(); + + yield* SubscriptionRef.set(activeSession, Option.some(session(client))); + const resultFiber = yield* subscribe(WS_METHODS.subscribeServerLifecycle, {}).pipe( + Stream.runHead, + Effect.timeoutOption("2 seconds"), + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.forkChild, + ); + yield* Deferred.await(firstSubscribed); + yield* TestClock.adjust("2 seconds"); + + expect( + yield* Fiber.join(resultFiber), + "durable subscription stopped delivering after its stream ended on a healthy session", + ).toEqual(Option.some(Option.some(resumedValue))); + expect(subscriptions).toBe(2); + expect(yield* Ref.get(retryCount)).toBe(0); + }, + ); + + it.effect("resubscribes on the same session after a transport failure", () => + resumesOnTheSameSession( + Stream.fail( + new RpcClientError.RpcClientError({ + reason: new RpcClientError.RpcClientDefect({ + message: "stream failed", + cause: new Error("stream failed"), + }), + }), + ), + ), + ); + + it.effect("resubscribes on the same session after a stream chunk fails to decode", () => + // Mirrors RpcClient, which decodes each chunk with `Effect.orDie`. + resumesOnTheSameSession( + Stream.fromEffect(decodeNumber("not a number").pipe(Effect.orDie)).pipe(Stream.drain), + ), + ); + + it.effect( + "backs off a same-session stream that keeps failing to decode after its first value", + () => + Effect.gen(function* () { + // Like the shell stream: a valid value (metadata) arrives, then a chunk fails to decode. + const value = { source: "metadata" } as unknown as ServerLifecycleStreamEvent; + const decodeDefect = decodeNumber("not a number").pipe(Effect.orDie); + const subscriptions = yield* Queue.unbounded<{ + readonly session: "first" | "second"; + readonly at: number; + }>(); + let secondSubscriptions = 0; + const failingClient = (name: "first" | "second") => + ({ + [WS_METHODS.subscribeServerLifecycle]: () => + Stream.unwrap( + Effect.gen(function* () { + yield* Queue.offer(subscriptions, { + session: name, + at: yield* Clock.currentTimeMillis, + }); + if (name === "second" && ++secondSubscriptions > 2) return Stream.never; + return Stream.make(value).pipe( + Stream.concat(Stream.fromEffect(decodeDefect).pipe(Stream.drain)), + ); + }), + ), + }) as unknown as WsRpcProtocolClient; + const { activeSession, retryCount, supervisor } = yield* makeHarness(); + + yield* SubscriptionRef.set(activeSession, Option.some(session(failingClient("first")))); + const subscriptionFiber = yield* subscribe( + WS_METHODS.subscribeServerLifecycle, + {}, + { + onExpectedFailure: () => Effect.void, + retryExpectedFailureAfter: "250 millis", + }, + ).pipe( + Stream.runDrain, + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.forkChild, + ); + + const expectedFirstSessionDelays = [250, 500, 1_000, 2_000, 4_000, 8_000, 16_000, 30_000]; + const firstSessionDelays: number[] = []; + let previous = yield* Queue.take(subscriptions); + for (const delay of expectedFirstSessionDelays) { + yield* TestClock.adjust(delay); + const next = yield* Queue.take(subscriptions); + firstSessionDelays.push(next.at - previous.at); + previous = next; + } + + // A replacement session starts a fresh backoff. + yield* SubscriptionRef.set(activeSession, Option.some(session(failingClient("second")))); + const replaced = yield* Queue.take(subscriptions); + yield* TestClock.adjust(250); + const retried = yield* Queue.take(subscriptions); + yield* Fiber.interrupt(subscriptionFiber); + + expect( + firstSessionDelays, + "same-session decode retries reset to the base delay after every first value", + ).toEqual(expectedFirstSessionDelays); + expect([replaced.session, retried.session]).toEqual(["second", "second"]); + expect(retried.at - replaced.at).toBe(250); + expect(yield* Ref.get(retryCount)).toBe(0); + }), + ); + it.effect("surfaces domain subscription failures without reconnecting", () => Effect.gen(function* () { const domainError = new Error("terminal subscription rejected"); diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index 4e3dad24e7a12..ee70400ca47a5 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -114,6 +114,7 @@ export const isAuthorizationFailure = (cause: Cause.Cause) => ); const MAX_EXPECTED_FAILURE_RETRY_DELAY_MS = 30_000; +const STREAM_FAILURE_RETRY_DELAY = Duration.seconds(1); export const subscriptionRetryDelay = (base: Duration.Input, retries: number) => Duration.millis( @@ -318,6 +319,8 @@ function subscribeDynamicMapped( EnvironmentRpcStreamFailure >; let expectedFailureRetries = 0; + // Not reset by a first value: a stream can emit before every chunk failure. + let streamFailureRetries = 0; const subscribeToSession = (): Stream.Stream> => Stream.suspend(() => Stream.unwrap( @@ -354,6 +357,11 @@ function subscribeDynamicMapped( : Effect.void, ), Stream.catchCause((cause) => { + const retryAfter = (base: Duration.Input, retries: number) => + Stream.fromEffect(Effect.sleep(subscriptionRetryDelay(base, retries))).pipe( + Stream.drain, + Stream.concat(subscribeToSession()), + ); const hasOnlyExpectedFailures = cause.reasons.length > 0 && cause.reasons.every((reason) => reason._tag === "Fail"); @@ -362,7 +370,22 @@ function subscribeDynamicMapped( cause.reasons.every( (reason) => reason._tag === "Fail" && isRpcClientError(reason.error), ); - if (isTransportFailure) { + // The RPC client dies with a SchemaError when one stream chunk fails to + // decode, ending only that request while the socket stays open. A caller + // with `onDefect` owns its defect recovery. + const isChunkDecodeDefect = + options?.onDefect === undefined && + cause.reasons.length > 0 && + cause.reasons.every( + (reason) => reason._tag === "Die" && Schema.isSchemaError(reason.defect), + ); + // Both end one stream while the session can stay healthy, so nothing else + // would replace it. A dead session is replaced by the supervisor, which + // interrupts this retry through switchMap. A finite search must settle + // rather than restart, so it keeps waiting for the next session. + const resubscribesOnSameSession = + tag !== ORCHESTRATION_V2_WS_METHODS.searchThreadStream; + if (isTransportFailure && !resubscribesOnSameSession) { return Stream.fromEffect( Effect.logWarning( "Durable RPC subscription lost its transport; waiting for the next session.", @@ -374,6 +397,29 @@ function subscribeDynamicMapped( ), ).pipe(Stream.drain); } + if ( + resubscribesOnSameSession && + (isTransportFailure || isChunkDecodeDefect) + ) { + return Stream.fromEffect( + Effect.logWarning( + "Durable RPC subscription stream ended; resubscribing.", + { + cause: Cause.pretty(cause), + method: tag, + environmentId: supervisor.target.environmentId, + }, + ), + ).pipe( + Stream.drain, + Stream.concat( + retryAfter( + options?.retryExpectedFailureAfter ?? STREAM_FAILURE_RETRY_DELAY, + streamFailureRetries++, + ), + ), + ); + } if (hasOnlyExpectedFailures && options?.onExpectedFailure !== undefined) { const handled = Stream.fromEffect(options.onExpectedFailure(cause)).pipe( Stream.drain, @@ -384,16 +430,10 @@ function subscribeDynamicMapped( ) { return handled; } - const retryDelay = subscriptionRetryDelay( - options.retryExpectedFailureAfter, - expectedFailureRetries, - ); - expectedFailureRetries += 1; return handled.pipe( Stream.concat( - Stream.fromEffect(Effect.sleep(retryDelay)).pipe(Stream.drain), + retryAfter(options.retryExpectedFailureAfter, expectedFailureRetries++), ), - Stream.concat(subscribeToSession()), ); } // A reported defect parks until the next session or resubscribe. Failing would diff --git a/packages/client-runtime/src/state/runtime.test.ts b/packages/client-runtime/src/state/runtime.test.ts index 0d2c1ba56088d..63f00b28fb173 100644 --- a/packages/client-runtime/src/state/runtime.test.ts +++ b/packages/client-runtime/src/state/runtime.test.ts @@ -18,6 +18,7 @@ import { WS_METHODS, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -29,6 +30,7 @@ import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; +import * as TestClock from "effect/testing/TestClock"; import type { HttpClient } from "effect/http"; import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; @@ -43,6 +45,7 @@ import { import * as EnvironmentRegistry from "../connection/registry.ts"; import * as EnvironmentSupervisor from "../connection/supervisor.ts"; import { EnvironmentRpcUnavailableError } from "../rpc/client.ts"; +import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; import type * as RpcSession from "../rpc/session.ts"; import { createOrchestrationEnvironmentAtoms } from "./orchestration.ts"; import { createReviewEnvironmentAtoms } from "./review.ts"; @@ -54,6 +57,7 @@ import { createTerminalEnvironmentAtoms } from "./terminal.ts"; import { createAtomCommandScheduler, createEnvironmentQueryAtomFamily, + createEnvironmentRpcSubscriptionAtomFamily, selectEnvironmentQueryData, createEnvironmentSubscriptionAtomFamily, createRuntimeCommand, @@ -70,6 +74,8 @@ const QUERY_ENVIRONMENT = new PrimaryConnectionTarget({ wsBaseUrl: "wss://query.example.test", }); +const decodeNumber = Schema.decodeUnknownEffect(Schema.Number); + const QUERY_RPC_SESSION = {} as RpcSession.RpcSession; class TestQueryError extends Schema.TaggedError()("TestQueryError", { @@ -393,6 +399,104 @@ describe("finite environment subscription lifecycle", () => { expect(observed.some((result) => AsyncResult.isFailure(result))).toBe(false); }), ); + it.effect( + "surfaces a decode defect from a finite RPC subscription instead of resubscribing", + () => + Effect.scoped( + Effect.gen(function* () { + const firstSubscribed = yield* Deferred.make(); + const resubscribed = yield* Deferred.make(); + const surfaced = yield* Deferred.make>(); + let subscriptions = 0; + // Mirrors RpcClient, which decodes each chunk with `Effect.orDie`. + const decodeDefect = decodeNumber("not a number").pipe(Effect.orDie); + const client = { + [ORCHESTRATION_V2_WS_METHODS.searchThreadStream]: () => { + subscriptions += 1; + Deferred.doneUnsafe(subscriptions === 1 ? firstSubscribed : resubscribed, Exit.void); + return Stream.fromEffect(decodeDefect).pipe(Stream.drain); + }, + } as unknown as WsRpcProtocolClient; + const rpcSession = { + client, + initialConfig: Effect.never, + subscribeServerConfig: () => Stream.never, + ready: Effect.void, + probe: Effect.void, + closed: Effect.never, + } as unknown as RpcSession.RpcSession; + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: QUERY_ENVIRONMENT, + state: yield* SubscriptionRef.make(queryConnectionState()), + session: yield* SubscriptionRef.make(Option.some(rpcSession)), + prepared: yield* SubscriptionRef.make>(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const environmentRegistry = EnvironmentRegistry.EnvironmentRegistry.of({ + followStream: (_environmentId: EnvironmentId, stream: Stream.Stream) => + Stream.provideService( + stream, + EnvironmentSupervisor.EnvironmentSupervisor, + supervisor, + ), + } as unknown as EnvironmentRegistry.EnvironmentRegistry["Service"]); + // Atoms run their own fibers; share the test clock so a pending retry is driven by it. + const runtime = Atom.runtime( + Layer.mergeAll( + Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), + Layer.succeed(Clock.Clock, yield* Clock.Clock), + ), + ); + // Same options as the progressive thread find in orchestration.ts. + const family = createEnvironmentRpcSubscriptionAtomFamily(runtime, { + label: "test.finite-rpc-subscription", + tag: ORCHESTRATION_V2_WS_METHODS.searchThreadStream, + completeWhen: (result) => result.complete !== false, + idleTtlMs: 0, + }); + const atom = family({ + environmentId: QUERY_ENVIRONMENT.environmentId, + input: {} as never, + }); + const registry = AtomRegistry.make(); + const unsubscribe = registry.subscribe( + atom, + (result) => { + if (AsyncResult.isFailure(result)) + Deferred.doneUnsafe(surfaced, Exit.succeed(result.cause)); + }, + { immediate: true }, + ); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + unsubscribe(); + registry.dispose(); + }), + ); + yield* Deferred.await(firstSubscribed); + // Lets any same-session retry run, so its resubscription can win the race below. + yield* TestClock.adjust("1 minute"); + const outcome = yield* Effect.raceFirst( + Deferred.await(surfaced).pipe( + Effect.map((cause) => ({ _tag: "surfaced", cause }) as const), + ), + Deferred.await(resubscribed).pipe(Effect.as({ _tag: "resubscribed" } as const)), + ); + + expect( + outcome._tag, + "finite subscription resubscribed after a decode defect instead of failing its consumer", + ).toBe("surfaced"); + if (outcome._tag === "surfaced") { + expect(Cause.hasDies(outcome.cause)).toBe(true); + expect(Schema.isSchemaError(Cause.squash(outcome.cause))).toBe(true); + } + expect(subscriptions).toBe(1); + }), + ), + ); it.effect.each([ { completedTtlMs: undefined, subscriptions: 2 }, { completedTtlMs: 60_000, subscriptions: 1 }, From e41941779a6cebb6fc7048466607a394ce2871f5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 08:45:15 -0800 Subject: [PATCH 118/390] fix(server): stopping the service during the pre-update backup ends the copy (#34854) --- apps/server/src/serviceLauncher.test.ts | 50 +++++++++++++++++++++++++ apps/server/src/serviceLauncher.ts | 32 +++++++++++----- 2 files changed, 73 insertions(+), 9 deletions(-) diff --git a/apps/server/src/serviceLauncher.test.ts b/apps/server/src/serviceLauncher.test.ts index c7d6c5e5d8f3a..ac699b9c7d6cb 100644 --- a/apps/server/src/serviceLauncher.test.ts +++ b/apps/server/src/serviceLauncher.test.ts @@ -1586,6 +1586,56 @@ process.send({ type: "request-update", targetVersion: "1.1.0", dbPath: paths.dat }), ); + it.effect("starts no trial when stopped during the pre-update backup", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-backup-stop-" }); + const statePath = path.join(root, "runtime", "service-state.json"); + const childSource = "setInterval(() => {}, 1000);\n"; + for (const version of ["1.0.0", "1.1.0"]) { + yield* writeFakeRuntime( + fs, + path, + path.join(root, "runtime", "versions", version), + childSource, + ); + } + const dbPath = path.join(root, "userdata", "state.sqlite"); + yield* fs.makeDirectory(path.dirname(dbPath), { recursive: true }); + yield* fs.writeFileString(dbPath, "database"); + const pending = { + protocol: SERVICE_LAUNCHER_PROTOCOL, + activeVersion: "1.0.0", + update: { + id: "update-1", + fromVersion: "1.0.0", + targetVersion: "1.1.0", + dbPath, + status: "pending", + }, + } as const; + yield* Effect.promise(() => writeServiceState(statePath, pending)); + const childCount = spawnedChildren.length; + const launcher = new Launcher(root, pending); + + // Recovery is already queued, so this stop lands while the backup runs. + const running = launcher.run(); + yield* Effect.promise(() => launcher.stop("SIGTERM")); + yield* Effect.promise(() => running); + + // A trial started here arms its 30-minute prepared timer, which keeps the stopped launcher alive. + assert.deepEqual( + spawnedChildren.slice(childCount).map((child) => child.spawnargs.join(" ")), + [], + ); + assert.deepEqual(yield* Effect.promise(() => readServiceState(statePath)), pending); + const backupDir = path.join(root, "runtime", "db-backup", "update-1"); + assert.isFalse(yield* fs.exists(backupDir)); + assert.isFalse(yield* fs.exists(`${backupDir}.staging`)); + }), + ); + it.effect("records no update when the server exits during the backup check", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/serviceLauncher.ts b/apps/server/src/serviceLauncher.ts index c07697007fe57..1a6c07cf43659 100644 --- a/apps/server/src/serviceLauncher.ts +++ b/apps/server/src/serviceLauncher.ts @@ -326,14 +326,18 @@ async function syncDirectory(directory: string): Promise { } } -function runCp(args: ReadonlyArray): Promise { +function runCp(args: ReadonlyArray, signal?: AbortSignal): Promise { return new Promise((resolve) => { - NodeChildProcess.execFile("cp", args, (error) => resolve(error === null)); + NodeChildProcess.execFile("cp", args, { signal }, (error) => resolve(error === null)); }); } // macOS fs.copyFile clone flags fall back to full copies. -async function copyDatabaseFile(source: string, destination: string): Promise { +async function copyDatabaseFile( + source: string, + destination: string, + signal?: AbortSignal, +): Promise { // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone launcher has no Effect runtime. const platform = process.platform; // cp -c replaces files and breaks hard links; copy into hard-linked destinations instead. @@ -346,7 +350,9 @@ async function copyDatabaseFile(source: string, destination: string): Promise { +async function backupDatabaseOnce( + baseDir: string, + pending: PendingServiceUpdate, + signal: AbortSignal, +): Promise { const backupDir = databaseBackupDir(baseDir, pending.id); if (await pathExists(backupDir)) return; @@ -534,10 +544,11 @@ async function backupDatabaseOnce(baseDir: string, pending: PendingServiceUpdate await NodeFSP.mkdir(stagingDir, { recursive: true, mode: 0o700 }); try { for (const suffix of DB_FILE_SUFFIXES) { + signal.throwIfAborted(); const source = `${pending.dbPath}${suffix}`; if (suffix !== "" && !(await pathExists(source))) continue; const destination = databaseBackupFile(stagingDir, suffix); - await copyDatabaseFile(source, destination); + await copyDatabaseFile(source, destination, signal); await syncFile(destination); } await NodeFSP.rename(stagingDir, backupDir); @@ -830,6 +841,7 @@ export class Launcher { #timer: NodeJS.Timeout | undefined; #transitions: Promise = Promise.resolve(); #stopRequested = false; + readonly #stopSignal = new AbortController(); #stopping = false; #done = false; readonly #completion = Promise.withResolvers(); @@ -948,6 +960,7 @@ export class Launcher { return; } this.#stopRequested = true; + this.#stopSignal.abort(); this.#clearTimer(); this.#enqueue(async () => { // Let in-progress updates start replacements first; replacements observe the marker and own pre-activation tunnel cleanup. @@ -1043,11 +1056,14 @@ export class Launcher { async #startTrial(pending: PendingServiceUpdate): Promise { try { - await backupDatabaseOnce(this.#baseDir, pending); + await backupDatabaseOnce(this.#baseDir, pending, this.#stopSignal.signal); } catch { + // A stop aborts the copy and leaves the update pending for the next start. + if (this.#stopRequested) return; await this.#returnToPrevious(pending, "failed", "db-backup-failed"); return; } + if (this.#stopRequested) return; try { await this.#startChild(pending.targetVersion, "trial", pending); } catch { @@ -1202,8 +1218,6 @@ export class Launcher { this.#child = null; await this.#terminate(child.process); await finishOnlineBackup?.(); - // A stop during a minutes-long copy should not start a trial; restart resumes the pending update. - if (this.#stopRequested) return; await this.#startTrial(pending); } From 928dd049e1af868bc93ce12498270bd9ec9292c2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 08:45:32 -0800 Subject: [PATCH 119/390] fix(server): preview browser downloads Chrome 157.0.8103.78 with its security fixes (#34962) --- apps/server/src/preview/PreviewBrowser.ts | 26 +++++++++++------------ 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/apps/server/src/preview/PreviewBrowser.ts b/apps/server/src/preview/PreviewBrowser.ts index 50ba7b91c56fa..203ec112cccb7 100644 --- a/apps/server/src/preview/PreviewBrowser.ts +++ b/apps/server/src/preview/PreviewBrowser.ts @@ -25,31 +25,31 @@ import { openZipArchive } from "../zipArchive.ts"; // https://googlechromelabs.github.io/chrome-for-testing/known-good-versions-with-downloads.json // Never pin below 156.0.8078.70: the shell has no CRLSet and older SPKI blocklists trust // compromised eMudhra intermediates (crrev.com/c/8627687). -const VERSION = "157.0.8103.38"; +const VERSION = "157.0.8103.78"; const ARCHIVES = { linux64: { - bytes: 128_168_542, - sha256: "deafffa74660b34853a4119c361fd427ac8c9fcb62db51ddb8a797cd83a4ba2d", + bytes: 127_965_683, + sha256: "f90329720fb61e5a76da9b6b509b6ad12a484c9c5df0359403e935177c6d153a", }, "linux-arm64": { - bytes: 128_171_139, - sha256: "b9b910a57b2a15eb3e55000a2339f93a973934a413ebfc6e585a50ec6c42fd6c", + bytes: 128_047_138, + sha256: "912fbf877e53de34d7b3cca5104f3b95607d41bd35412ccc6cd2a599ff0c0dbf", }, "mac-arm64": { - bytes: 104_773_711, - sha256: "e6a6b13551aa12f951d0891d79282606b13742837c8fa0505e1272bae116ce82", + bytes: 104_822_985, + sha256: "17bf66d53c1b1104dcb641c5b331cd4ac5d200c778f5feed0934a0b92deaf8d6", }, "mac-x64": { - bytes: 110_442_262, - sha256: "79821745d0a72d2ba752d981284ed0a9e6fa320dad133596941a17dba6186de3", + bytes: 110_132_547, + sha256: "f1a6a9561eaf1f409849538de663695d36d72fbf1ebca4d42e47c7b5f2a157f3", }, win32: { - bytes: 120_390_416, - sha256: "35e0d45c0136448f7b587f6fb7195a0686127a4dfdc23ef3b0e04b5e9ffe16cd", + bytes: 120_403_126, + sha256: "d9829c59dcc79d9e6e920f8ab841f2a9a055dcf54229d756ac0b42dfec303910", }, win64: { - bytes: 126_513_060, - sha256: "1ff4540855478ba0848e03f1ef8d455f77d058b38285df91bc34273788a73446", + bytes: 126_047_001, + sha256: "2138c1583d25d6f3abec5269943b2f430207d87a539895aa9e90cc4697ec0e1f", }, } as const; From d0957bc4b0495789e28769bad62834f5f912d94e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 08:47:03 -0800 Subject: [PATCH 120/390] fix(opencode): restarted server stops an orphaned OpenCode server started through a wrapper (#34959) --- .../src/server/OpenCodeServerLedger.test.ts | 51 +++++++++++++++---- .../src/server/OpenCodeServerLedger.ts | 17 +++++-- 2 files changed, 53 insertions(+), 15 deletions(-) diff --git a/packages/provider-opencode/src/server/OpenCodeServerLedger.test.ts b/packages/provider-opencode/src/server/OpenCodeServerLedger.test.ts index 1310cdd8b36e0..3fcb52c898d68 100644 --- a/packages/provider-opencode/src/server/OpenCodeServerLedger.test.ts +++ b/packages/provider-opencode/src/server/OpenCodeServerLedger.test.ts @@ -56,7 +56,6 @@ const spawnGroupLeader = (args: ReadonlyArray, script: string) => if (pid === undefined) return yield* Effect.die("spawn failed"); const grouped = yield* Deferred.make(); const exited = yield* Deferred.make(); - const printed = yield* Deferred.make(); child.once("exit", (_code, signal) => Deferred.doneUnsafe(exited, Effect.succeed(signal))); // "close" fires after stdout drains, so a guard line always lands first. It only fires before // "grouped" when the shell died without starting the script, such as from a signal. @@ -77,12 +76,9 @@ const spawnGroupLeader = (args: ReadonlyArray, script: string) => Effect.succeed(`\`ps -o pgid= -p ${pid}\` printed ${JSON.stringify(output)}`), ); } - if (line === "ready") Deferred.doneUnsafe(printed, Effect.void); }); const result = yield* Deferred.await(grouped); - return result === true - ? { pid, exited: Deferred.await(exited), printed: Deferred.await(printed) } - : result; + return result === true ? { pid, exited: Deferred.await(exited) } : result; }); // A pid colliding with a live group id is rare, so a few retries clear it. Repeated failures @@ -152,18 +148,21 @@ describe.each(observedPlatforms)("OpenCodeServerLedger observing as %s", (platfo const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const stateDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-ledger-" }); - // The inner shell prints once it has exec'd. Until then it still carries the wrapper's - // command line, and recording that would make the reaper skip the group as recycled. + const gate = path.join(stateDir, "gate"); + const ready = path.join(stateDir, "ready"); + NodeChildProcess.execFileSync("mkfifo", [gate, ready]); + // The wrapper's child is recorded before it execs and stopped after. const wrapper = yield* spawnGroup( SERVE_ARGS, - '/bin/sh -c "echo ready; sleep 600 & wait" "$0" "$@" & exit 0', + `(read _ < '${gate}'; exec /bin/sh -c 'echo > "${ready}"; sleep 600 & wait' "$0" "$@") & exit 0`, ); yield* wrapper.exited; - yield* wrapper.printed; expect(groupExists(wrapper.pid)).toBe(true); yield* recordFromDeadServer(stateDir, wrapper); expect(yield* fs.readDirectory(path.join(stateDir, "opencode-servers"))).toHaveLength(1); + yield* fs.writeFileString(gate, "\n"); + yield* fs.readFileString(ready); const restarted = yield* OpenCodeServerLedger.make({ stateDir }); yield* restarted.reapOrphans; @@ -173,6 +172,38 @@ describe.each(observedPlatforms)("OpenCodeServerLedger observing as %s", (platfo }).pipe(provideHost), ); + it.live("stops a group recorded by an older server without serve arguments", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const stateDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-ledger-" }); + const orphan = yield* spawnGroup(SERVE_ARGS); + yield* recordFromDeadServer(stateDir, orphan); + const entryPath = path.join(stateDir, "opencode-servers", `${orphan.pid}.json`); + const { args: _args, ...legacy } = JSON.parse(yield* fs.readFileString(entryPath)); + yield* fs.writeFileString(entryPath, JSON.stringify(legacy)); + + const restarted = yield* OpenCodeServerLedger.make({ stateDir }); + yield* restarted.reapOrphans; + + expect(groupExists(orphan.pid)).toBe(false); + }).pipe(provideHost), + ); + + it.live("stops a group whose wrapper appends its own options", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const stateDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-ledger-" }); + const orphan = yield* spawnGroup([...SERVE_ARGS, "--log-level=ERROR"]); + yield* recordFromDeadServer(stateDir, orphan); + + const restarted = yield* OpenCodeServerLedger.make({ stateDir }); + yield* restarted.reapOrphans; + + expect(groupExists(orphan.pid)).toBe(false); + }).pipe(provideHost), + ); + it.live("leaves a recycled pid alone when its start time does not match", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -201,7 +232,7 @@ describe.each(observedPlatforms)("OpenCodeServerLedger observing as %s", (platfo yield* recordFromDeadServer(stateDir, unrelated); const entryPath = path.join(stateDir, "opencode-servers", `${unrelated.pid}.json`); const entry = yield* fs.readFileString(entryPath); - yield* fs.writeFileString(entryPath, entry.replace("--port=4096", "--port=4097")); + yield* fs.writeFileString(entryPath, entry.replaceAll("--port=4096", "--port=4097")); const restarted = yield* OpenCodeServerLedger.make({ stateDir }); yield* restarted.reapOrphans; diff --git a/packages/provider-opencode/src/server/OpenCodeServerLedger.ts b/packages/provider-opencode/src/server/OpenCodeServerLedger.ts index af90d19acd67d..fb3ea7645e9bf 100644 --- a/packages/provider-opencode/src/server/OpenCodeServerLedger.ts +++ b/packages/provider-opencode/src/server/OpenCodeServerLedger.ts @@ -21,6 +21,7 @@ const OpenCodeServerEntry = Schema.Struct({ pid: Schema.Int, startTime: Schema.String, command: Schema.String, + args: Schema.optionalKey(Schema.Array(Schema.String)), port: Schema.Int, stateDir: Schema.String, owner: ProcessIdentity, @@ -73,6 +74,11 @@ const parseDarwinPs = (output: string): ReadonlyArray => ]; }); +// A wrapper's forked child keeps the wrapper's command line until it execs; the +// serve arguments survive the exec, so they identify the server either way. +const runsServe = (observed: ObservedProcess, args: ReadonlyArray) => + observed.command.endsWith(` ${args.join(" ")}`); + const signalGroup = (pgid: number, signal: NodeJS.Signals) => { try { signalProcessGroup(pgid, signal); @@ -193,9 +199,7 @@ export const make = Effect.fn("OpenCodeServerLedger.make")(function* (input: { leader !== undefined && !leader.zombie && leader.pgid === pgid ? [leader] : (yield* observeGroup(pgid)).filter((candidate) => !candidate.zombie); - const member = - members.find((candidate) => candidate.command.endsWith(` ${server.args.join(" ")}`)) ?? - members[0]; + const member = members.find((candidate) => runsServe(candidate, server.args)) ?? members[0]; if (owner === undefined || member === undefined) { if (groupExists(pgid)) { yield* Effect.logWarning( @@ -212,6 +216,7 @@ export const make = Effect.fn("OpenCodeServerLedger.make")(function* (input: { pid: member.pid, startTime: member.startTime, command: member.command, + args: server.args, port: server.port, stateDir: input.stateDir, owner, @@ -239,7 +244,8 @@ export const make = Effect.fn("OpenCodeServerLedger.make")(function* (input: { ); // Signals only a group whose recorded member still has the same pid, start - // time, group, and full command line, so a recycled pid is never touched. + // time, and group and still has the same command line or serve arguments, so + // a recycled pid is never touched. const stopOrphan = Effect.fn("OpenCodeServerLedger.stopOrphan")(function* ( entry: OpenCodeServerEntry, ) { @@ -249,7 +255,8 @@ export const make = Effect.fn("OpenCodeServerLedger.make")(function* (input: { observed.zombie || observed.startTime !== entry.startTime || observed.pgid !== entry.pgid || - observed.command !== entry.command + (observed.command !== entry.command && + (entry.args === undefined || !runsServe(observed, entry.args))) ) { return; } From c398b46af0ec2aa866c5d92f48ed65c0c385cb78 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 08:47:22 -0800 Subject: [PATCH 121/390] fix(server): Codex text generation runs when launch args define an MCP server (#36223) --- .../CodexTextGeneration.test.ts | 77 ++++++++++++++++++- .../src/textGeneration/CodexTextGeneration.ts | 21 ++++- 2 files changed, 93 insertions(+), 5 deletions(-) diff --git a/apps/server/src/textGeneration/CodexTextGeneration.test.ts b/apps/server/src/textGeneration/CodexTextGeneration.test.ts index ae5c32bf57432..e6e43d606e27f 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.test.ts @@ -58,6 +58,7 @@ interface FakeCodexInput { mcpServers?: ReadonlyArray<{ name: string; enabled: boolean }> | number; requiredCwd?: string; requireInstructions?: string; + requireMcpServersOff?: ReadonlyArray; } function makeFakeCodexBinary(dir: string, input: FakeCodexInput) { @@ -73,6 +74,7 @@ function makeFakeCodexBinary(dir: string, input: FakeCodexInput) { mcpServers: input.mcpServers ?? [], requiredCwd: input.requiredCwd ?? null, requireInstructions: input.requireInstructions ?? null, + requireMcpServersOff: input.requireMcpServersOff ?? [], stderr: input.stderr ?? null, output: input.output, exitCode: input.exitCode ?? 0, @@ -129,6 +131,33 @@ function makeFakeCodexBinary(dir: string, input: FakeCodexInput) { ' process.stderr.write(message + "\\n");', " process.exit(code);", "}", + "// Like Codex: a later mcp_servers={...} override replaces servers earlier -c flags defined.", + "const servers = new Map();", + "const offServers = new Set();", + "for (let index = 0; index < args.length; index += 1) {", + ' if (args[index] !== "-c" && args[index] !== "--config") continue;', + ' const value = args[index + 1] ?? "";', + ' if (value.startsWith("mcp_servers={")) {', + ' const named = [...value.matchAll(/"([^"]+)"=\\{enabled=false\\}/g)].map((match) => match[1]);', + " for (const name of named) offServers.add(name);", + " for (const name of [...servers.keys()]) {", + " if (named.includes(name)) servers.set(name, false);", + " else servers.delete(name);", + " }", + ' } else if (value.startsWith("mcp_servers.")) {', + ' const [name, key] = value.slice("mcp_servers.".length).split(/[.=]/);', + " if (value === `mcp_servers.${name}.enabled=false`) offServers.add(name);", + " // An enabled flag alone toggles a server another layer (config.toml) defines.", + ' if (key === "enabled" && !servers.has(name)) continue;', + ' servers.set(name, servers.get(name) === true || key === "url" || key === "command");', + " }", + "}", + "for (const [name, hasTransport] of servers) {", + ' if (!hasTransport) fail("Error loading config.toml: invalid transport in `mcp_servers." + name + "`", 12);', + "}", + "for (const name of check.requireMcpServersOff) {", + ' if (!offServers.has(name)) fail("MCP server left on: " + name, 13);', + "}", 'if (check.requiredCwd !== null && process.cwd() !== check.requiredCwd) fail("cannot inspect checkout", 10);', 'if (check.requireInstructions !== null && !instructions.includes(check.requireInstructions)) fail("missing writer task", 11);', "if (check.requireArg !== null && !originalArgs.includes(` ${check.requireArg} `)) {", @@ -365,7 +394,7 @@ it.layer(layerCodexTextGenerationTest)("CodexTextGeneration", (it) => { { name: "already-off", enabled: false }, ], requireArg: - '--config features.plugins=false --config features.apps=false --config mcp_servers={"augmenter"={enabled=false},"my.server"={enabled=false}}', + 'exec --config mcp_servers={"augmenter"={enabled=false},"my.server"={enabled=false}} --config developer_instructions="Use', }, (textGeneration) => Effect.gen(function* () { @@ -384,6 +413,52 @@ it.layer(layerCodexTextGenerationTest)("CodexTextGeneration", (it) => { ), ); + it.effect("turns off an MCP server that launch args define without dropping its transport", () => + withFakeCodexEnv( + { + output: JSON.stringify({ title: "Remove default shapes" }), + launchArgs: `-c 'mcp_servers.t3-code.url="http://127.0.0.1:1/mcp"' -c 'mcp_servers.t3-code.enabled=true'`, + mcpServers: [{ name: "t3-code", enabled: true }], + requireMcpServersOff: ["t3-code"], + requireArg: + "--config features.apps=false --config mcp_servers.t3-code.enabled=false --ephemeral", + }, + (textGeneration) => + Effect.gen(function* () { + const result = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Remove default shapes from characters", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + expect(result.title).toBe("Remove default shapes"); + }), + ), + ); + + it.effect("keeps MCP servers off when launch args set their own mcp_servers table", () => + withFakeCodexEnv( + { + output: JSON.stringify({ title: "Remove default shapes" }), + launchArgs: `-c 'mcp_servers={"my.server"={command="echo",enabled=true}}'`, + mcpServers: [ + { name: "my.server", enabled: true }, + { name: "my server", enabled: true }, + ], + requireArg: + '--config features.apps=false --config mcp_servers={"my.server"={enabled=false},"my server"={enabled=false}} --config mcp_servers.my server.enabled=false --ephemeral', + }, + (textGeneration) => + Effect.gen(function* () { + const result = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Remove default shapes from characters", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + expect(result.title).toBe("Remove default shapes"); + }), + ), + ); + it.effect("still generates when Codex cannot list its MCP servers", () => withFakeCodexEnv( { diff --git a/apps/server/src/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts index 4de5392502c8c..49c60166111ba 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -55,6 +55,11 @@ const codexMcpServersOff = (names: ReadonlyArray): string | null => names.length === 0 ? null : `mcp_servers={${names.map((name) => `${JSON.stringify(name)}={enabled=false}`).join(",")}}`; +// Codex splits -c keys at the first "=" and on every dot, so only those names take a dotted flag. +const codexMcpServerOffFlags = (names: ReadonlyArray) => + names + .filter((name) => !name.includes(".") && !name.includes("=")) + .flatMap((name) => ["--config", `mcp_servers.${name}.enabled=false`]); const encodeJsonString = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(function* ( codexConfig: CodexSettings, @@ -291,20 +296,28 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func } : {}), }; - const mcpServersOff = yield* listEnabledMcpServers({ + const enabledMcpServers = yield* listEnabledMcpServers({ binary, cwd, env, launchArgs: codexMcpListLaunchArgs(launchArgs), - }).pipe(Effect.map(codexMcpServersOff)); + }); + const mcpServersOff = codexMcpServersOff(enabledMcpServers); + const execLaunchArgs = codexExecLaunchArgs(launchArgs); + // A launch-arg mcp_servers={...} table replaces ours and can re-enable dotted names. + const launchArgsSetMcpTable = execLaunchArgs.some((arg) => /\bmcp_servers\s*=/.test(arg)); const spawnCommand = yield* resolveSpawnCommand( binary, [ "exec", - ...codexExecLaunchArgs(launchArgs), + // Before launch args: a later table would replace servers their -c flags define. + ...(mcpServersOff ? ["--config", mcpServersOff] : []), + ...execLaunchArgs, // A user's MCP server could act on the text-generation prompt. ...CODEX_NO_PLUGIN_ARGS, - ...(mcpServersOff ? ["--config", mcpServersOff] : []), + ...(mcpServersOff && launchArgsSetMcpTable ? ["--config", mcpServersOff] : []), + // After launch args, so a launch arg's enabled=true cannot turn a server back on. + ...codexMcpServerOffFlags(enabledMcpServers), "--ephemeral", "--skip-git-repo-check", "-s", From 759e649ea8b306ae3ea8f97ed14e69158204ef8f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 08:49:14 -0800 Subject: [PATCH 122/390] fix(server): agent recordings keep up with the page instead of the encoder (#36215) --- apps/server/src/preview/ServerBrowser.test.ts | 38 +++++++++++++--- apps/server/src/preview/ServerBrowser.ts | 45 ++++++++++++------- apps/server/src/preview/ServerBrowserPage.ts | 3 +- 3 files changed, 63 insertions(+), 23 deletions(-) diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index d2897987dc1f6..83f6db232cca6 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -360,12 +360,10 @@ function makeEncoderPage() { document: { createElement: (tag: string) => (tag === "video" ? video : canvas) }, Path2D: function Path2D() {}, MediaRecorder: FakeMediaRecorder, - createImageBitmap: async (blob: Blob) => ({ - width: 1280, - height: 800, - pixels: await blob.text(), - close() {}, - }), + createImageBitmap: async (blob: Blob) => { + await decodeGate?.promise; + return { width: 1280, height: 800, pixels: await blob.text(), close() {} }; + }, atob, btoa, Blob, @@ -386,6 +384,7 @@ function makeEncoderPage() { const contexts: ReturnType[] = []; let encoderPageOverride: Page | undefined; const encoderStreams: Array<{ frameRate: number | undefined; bitsPerSecond: number }> = []; +let decodeGate: PromiseWithResolvers | null = null; const contextRequests: Array<{ profileId: string; isolated: boolean }> = []; let contextGate: PromiseWithResolvers | null = null; let heldStill: (() => Promise) | null = null; @@ -571,6 +570,7 @@ beforeEach(() => { contexts.length = 0; encoderPageOverride = undefined; encoderStreams.length = 0; + decodeGate = null; contextRequests.length = 0; contextGate = null; heldStill = null; @@ -1077,6 +1077,32 @@ it.effect.each([ ).pipe(Effect.provide(layer)), ); +// Chrome stops sending screencast frames while too many are unacked. +it.effect("recordings ack screencast frames without waiting for the encoder to decode them", () => + Effect.scoped( + Effect.gen(function* () { + const { broker, tabId } = yield* ready; + yield* broker.invoke({ scope, tabId, operation: "recordingStart", input: {} }); + const session = contexts[0]!.sessions.find((candidate) => + candidate.send.mock.calls.some(([method]) => method === "Page.startScreencast"), + )!; + const gate = Promise.withResolvers(); + decodeGate = gate; + yield* Effect.addFinalizer(() => Effect.sync(() => gate.resolve())); + for (const sessionId of [1, 2, 3]) { + session.emit("Page.screencastFrame", { + data: Buffer.from(`frame ${sessionId}`).toString("base64"), + sessionId, + metadata: { deviceWidth: 1280 }, + }); + } + expect( + session.send.mock.calls.filter(([method]) => method === "Page.screencastFrameAck"), + ).toHaveLength(3); + }), + ).pipe(Effect.provide(layer)), +); + it.effect.each([{ truncated: false }, { truncated: true }])( "saves a large recording as a binary download and removes its staged file (truncated: $truncated)", ({ truncated }) => diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index fdffcc49414ed..ee9339c0a9ce3 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -1986,24 +1986,37 @@ const make = Effect.gen(function* () { const opened = await tab.page.context().newCDPSession(tab.page); session = opened; const framesInFlight = new Set>(); + // Chrome withholds frames while acks are outstanding, so ack on arrival and let a + // slow encoder skip to the newest frame instead of throttling the capture. + let pendingFrame: readonly [string, number] | null = null; + const deliverPending = async () => { + while (pendingFrame) { + const next = pendingFrame; + pendingFrame = null; + const accepted = await encoder + .evaluate( + ([data, width]) => + (globalThis as unknown as EncoderWindow).__t3Recorder.frame(data, width), + next, + ) + .catch(() => true); + if (!accepted) { + pendingFrame = null; + await opened.send("Page.stopScreencast").catch(constVoid); + } + } + }; opened.on("Page.screencastFrame", (frame) => { + void opened + .send("Page.screencastFrameAck", { sessionId: frame.sessionId }) + .catch(constVoid); const cssWidth = tab.page.viewportSize()?.width ?? frame.metadata.deviceWidth; - const delivered: Promise = encoder - .evaluate( - ([data, width]) => - (globalThis as unknown as EncoderWindow).__t3Recorder.frame(data, width), - [frame.data, cssWidth] as const, - ) - .then(async (accepted) => { - if (!accepted) await opened.send("Page.stopScreencast"); - }) - .catch(constVoid) - .finally(() => { - framesInFlight.delete(delivered); - void opened - .send("Page.screencastFrameAck", { sessionId: frame.sessionId }) - .catch(constVoid); - }); + const delivering = framesInFlight.size > 0; + pendingFrame = [frame.data, cssWidth]; + if (delivering) return; + const delivered: Promise = deliverPending().finally(() => + framesInFlight.delete(delivered), + ); framesInFlight.add(delivered); }); await opened.send("Page.startScreencast", RECORDING_SCREENCAST); diff --git a/apps/server/src/preview/ServerBrowserPage.ts b/apps/server/src/preview/ServerBrowserPage.ts index 65a283dc3cca4..418b644f52fb7 100644 --- a/apps/server/src/preview/ServerBrowserPage.ts +++ b/apps/server/src/preview/ServerBrowserPage.ts @@ -1103,7 +1103,8 @@ export const recordingEncoderScript = (frameRate: BrowserRecordingFrameRate) => window.__t3Recorder = { async frame(base64, cssWidth) { if (tooLarge) return false; - const bytes = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0)); + // A per-character atob copy costs ~30 ms on a 3200x1800 frame and throttles recordings. + const bytes = Uint8Array.fromBase64(base64); const bitmap = await createImageBitmap(new Blob([bytes], { type: "image/jpeg" })); if (tooLarge) { bitmap.close(); return false; } if (!recorder) { From 9b6dde9a2384b883dda71fd31cab21616caf436c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 08:49:28 -0800 Subject: [PATCH 123/390] fix(server): session import finds compressed Codex rollouts (#34947) --- .../src/project/AgentSessionScanner.test.ts | 102 ++++++++++++++++++ .../server/src/project/AgentSessionScanner.ts | 73 +++++++++++-- 2 files changed, 166 insertions(+), 9 deletions(-) diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index 30db62847e8e1..48b52d97ad132 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -5,6 +5,7 @@ import * as NodeChildProcess from "node:child_process"; import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; import * as NodeSqlite from "node:sqlite"; +import * as NodeZlib from "node:zlib"; import { describe, expect, it } from "@effect/vitest"; import { AgentSessionImportSource, @@ -174,6 +175,21 @@ const writeTranscript = Effect.fn("AgentSessionScanner.test.writeTranscript")(fu yield* fileSystem.utimes(input.filePath, seconds, seconds); }); +const writeCompressedTranscript = Effect.fn("AgentSessionScanner.test.writeCompressedTranscript")( + function* (input: { + readonly filePath: string; + readonly contents: string; + readonly mtimeMs: number; + }) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fileSystem.makeDirectory(path.dirname(input.filePath), { recursive: true }); + yield* fileSystem.writeFile(input.filePath, NodeZlib.zstdCompressSync(input.contents)); + const seconds = input.mtimeMs / 1000; + yield* fileSystem.utimes(input.filePath, seconds, seconds); + }, +); + const claudeSessionLine = (cwd: string) => `${JSON.stringify({ type: "user", cwd, sessionId: "s1" })}\n${JSON.stringify({ type: "assistant" })}\n`; @@ -1244,6 +1260,32 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { const result = yield* runScan({ claudeHomePath, codexHomePath }); + expect(result.candidates.map(({ path, threadCount }) => ({ path, threadCount }))).toEqual([ + { path: workspace, threadCount: 1 }, + ]); + }), + ); + it.effect("discovers projects whose Codex rollouts are zstd-compressed", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + yield* writeCompressedTranscript({ + filePath: path.join( + codexHomePath, + "sessions", + "2026", + "05", + "03", + "rollout-2026-05-03T00-00-00-019a5f00-0000-7000-8000-00000000000b.jsonl.zst", + ), + contents: codexRolloutLine(workspace), + mtimeMs: Date.parse("2026-05-03T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + expect(result.candidates.map(({ path, threadCount }) => ({ path, threadCount }))).toEqual([ { path: workspace, threadCount: 1 }, ]); @@ -1252,6 +1294,66 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { }); describe("recentThreads", () => { + it.effect("imports Codex conversations stored only as zstd-compressed rollouts", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + yield* writeCompressedTranscript({ + filePath: path.join( + codexHomePath, + "sessions", + "2026", + "08", + "20", + "rollout-2026-08-20T10-00-00-019a5f00-0000-7000-8000-00000000000c.jsonl.zst", + ), + contents: [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "019a5f00-0000-7000-8000-00000000000c", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + timestamp: "2026-08-20T10:00:00.000Z", + payload: { type: "user_message", message: "Compressed prompt" }, + }), + encodeTranscriptRecord({ + type: "response_item", + timestamp: "2026-08-20T10:01:00.000Z", + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "Compressed answer" }], + }, + }), + ].join("\n"), + mtimeMs: nowMs - 4 * 24 * 60 * 60 * 1000, + }); + + const threads = yield* runRecentThreads({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }); + + expect( + threads.map((thread) => ({ + id: thread.providerSessionId, + messages: thread.messages.map((message) => message.text), + })), + ).toEqual([ + { + id: "019a5f00-0000-7000-8000-00000000000c", + messages: ["Compressed prompt", "Compressed answer"], + }, + ]); + }), + ); + it.effect("imports recent Claude, Codex, and Pi sessions for the selected project only", () => Effect.gen(function* () { const path = yield* Path.Path; diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index d9ca41b8f26b1..b47cef441c925 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -1,5 +1,6 @@ import * as NodeOS from "node:os"; import * as NodeSqlite from "node:sqlite"; +import * as NodeZlib from "node:zlib"; import { AgentSessionImportSource, @@ -17,11 +18,13 @@ import { import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; +import * as NodeStream from "@effect/platform-node/NodeStream"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as Pull from "effect/Pull"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; @@ -177,7 +180,11 @@ const decodeCodexHistoryBase = Schema.decodeUnknownOption( ); // Original rollouts end in their thread ID, rewound ones in `_`. const CODEX_ROLLOUT_ID_PATTERN = - /-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:_([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}))?\.jsonl$/i; + /-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:_([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}))?\.jsonl(?:\.zst)?$/i; +// Codex zstd-compresses older rollouts; their byte limits and offsets count decompressed bytes. +const COMPRESSED_TRANSCRIPT_SUFFIX = ".jsonl.zst"; +const isCompressedTranscript = (filePath: string) => + filePath.endsWith(COMPRESSED_TRANSCRIPT_SUFFIX); // Codex records a chat's project only here; moving a chat leaves its rollout cwd unchanged. const CODEX_STATE_DATABASE = "state_5.sqlite"; const decodeCodexSessionIndexEntry = Schema.decodeUnknownOption( @@ -861,6 +868,33 @@ export const make = Effect.gen(function* () { const statOption = (target: string) => fileSystem.stat(target).pipe(Effect.asSome, Effect.orElseSucceed(Option.none)); + const transcriptChunkReader = Effect.fn("AgentSessionScanner.transcriptChunkReader")(function* ( + file: FileSystem.File, + filePath: string, + ) { + if (!isCompressedTranscript(filePath)) return (size: number) => file.readAlloc(size); + const pull = yield* Stream.toPull( + fileSystem + .stream(filePath) + .pipe(NodeStream.pipeThroughSimple(() => NodeZlib.createZstdDecompress())), + ); + let pending: Array = []; + let done = false; + // Untraced: called once per chunk inside the caller's traced read. + return Effect.fnUntraced(function* (size: number) { + while (pending.length === 0) { + if (done) return Option.none(); + const next = yield* pull.pipe(Pull.catchDone(() => Effect.undefined)); + if (next === undefined) done = true; + else pending = [...next]; + } + const chunk = pending.shift(); + if (chunk === undefined) return Option.none(); + if (chunk.byteLength > size) pending.unshift(chunk.subarray(size)); + return Option.some(chunk.subarray(0, size)); + }); + }); + const directoryIdentity = Effect.fn("AgentSessionScanner.directoryIdentity")(function* ( target: string, knownStats?: FileSystem.File.Info, @@ -1037,11 +1071,15 @@ export const make = Effect.gen(function* () { fileSystem.open(transcript.filePath, { flag: "r" }).pipe( Effect.flatMap((file) => Effect.gen(function* () { + const readChunk = yield* transcriptChunkReader(file, transcript.filePath); + const size = isCompressedTranscript(transcript.filePath) + ? Number.POSITIVE_INFINITY + : transcript.identity.size; const decoder = new TextDecoder(); let remaining = ""; let bytesRead = 0; let recordsRead = 0; - const maxBytes = Math.min(MAX_TRANSCRIPT_SCAN_BYTES, transcript.identity.size); + const maxBytes = Math.min(MAX_TRANSCRIPT_SCAN_BYTES, size); const reserveRecord = () => { if ( recordsRead === MAX_METADATA_RECORDS_PER_TRANSCRIPT || @@ -1073,7 +1111,7 @@ export const make = Effect.gen(function* () { ); budget.operationsRemaining -= 1; budget.bytesRemaining -= readSize; - const next = yield* file.readAlloc(readSize); + const next = yield* readChunk(readSize); if (Option.isNone(next)) { return readLastRecord(); } @@ -1090,7 +1128,7 @@ export const make = Effect.gen(function* () { } } - if (bytesRead < transcript.identity.size) { + if (bytesRead < size) { budget.truncated = true; return null; } @@ -1107,8 +1145,12 @@ export const make = Effect.gen(function* () { source: AgentSessionSource, prefix?: { readonly endByteOffset: number; readonly historyBytes: number }, ) { - const readBytes = prefix?.endByteOffset ?? expected.size; - if (readBytes > expected.size || readBytes > MAX_IMPORTED_TRANSCRIPT_BYTES) return null; + const compressed = isCompressedTranscript(filePath); + const readBytes = + prefix?.endByteOffset ?? (compressed ? MAX_IMPORTED_TRANSCRIPT_BYTES : expected.size); + if ((!compressed && readBytes > expected.size) || readBytes > MAX_IMPORTED_TRANSCRIPT_BYTES) { + return null; + } return yield* Effect.scoped( fileSystem.open(filePath, { flag: "r" }).pipe( @@ -1117,6 +1159,7 @@ export const make = Effect.gen(function* () { if (!sameTranscriptIdentity(expected, transcriptIdentity(filePath, yield* file.stat))) { return null; } + const readChunk = yield* transcriptChunkReader(file, filePath); const records: Array = []; const claudeHistory = createClaudeHistoryWindow(); let cwd = Option.none(); @@ -1177,10 +1220,12 @@ export const make = Effect.gen(function* () { }; while (bytesRead < readBytes) { - const next = yield* file.readAlloc( + const next = yield* readChunk( Math.min(TRANSCRIPT_PREFIX_BYTES, readBytes - bytesRead), ); if (Option.isNone(next)) { + // A compressed rollout's decompressed length is only known at its end. + if (compressed && prefix === undefined) break; return null; } @@ -1398,7 +1443,12 @@ export const make = Effect.gen(function* () { } const directory = path.join(sessionsDir, year, month, day); for (const entry of (yield* readDirectory(directory)).toSorted().toReversed()) { - if (!entry.startsWith("rollout-") || !entry.endsWith(".jsonl")) continue; + if ( + !entry.startsWith("rollout-") || + !(entry.endsWith(".jsonl") || isCompressedTranscript(entry)) + ) { + continue; + } if (operationsRemaining <= 0) { truncated = true; break; @@ -2024,7 +2074,12 @@ export const make = Effect.gen(function* () { { source: candidate.source, providerInstanceId: candidate.providerInstanceId, - fallbackSessionId: path.basename(transcript.filePath, ".jsonl"), + fallbackSessionId: path.basename( + transcript.filePath, + isCompressedTranscript(transcript.filePath) + ? COMPRESSED_TRANSCRIPT_SUFFIX + : ".jsonl", + ), lastActiveAtMs: transcript.mtimeMs, }, inheritedRecords === null From 148612e181446ed6b1e87dd013f5ad7f488ba01d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 08:50:54 -0800 Subject: [PATCH 124/390] fix(opencode): usage scans reuse unchanged rows and run off the event loop (#34897) --- .../src/server/usage.test.ts | 93 ++++++++++++++++++ .../provider-opencode/src/server/usage.ts | 98 ++++++++++++++++--- 2 files changed, 179 insertions(+), 12 deletions(-) diff --git a/packages/provider-opencode/src/server/usage.test.ts b/packages/provider-opencode/src/server/usage.test.ts index d211563738ad5..02d1e5dc8cc28 100644 --- a/packages/provider-opencode/src/server/usage.test.ts +++ b/packages/provider-opencode/src/server/usage.test.ts @@ -1,3 +1,4 @@ +import type * as NodeWorkerThreads from "node:worker_threads"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import { DEFAULT_SERVER_SETTINGS, ProviderInstanceId } from "@t3tools/contracts"; @@ -13,6 +14,27 @@ import { vi } from "vite-plus/test"; import { openCodeUsageReader, readOpenCodeUsage } from "./usage.ts"; +const readerWorkers = vi.hoisted(() => ({ started: 0, enabled: false })); + +// Statement spies only see the main thread, so reader workers fail to start unless a test enables them. +vi.mock("node:worker_threads", async (importOriginal) => { + const actual = await importOriginal(); + class ReaderWorker extends actual.Worker { + constructor(...args: ConstructorParameters) { + super( + ...((readerWorkers.enabled + ? args + : [ + "throw new Error('reader workers disabled');", + { eval: true }, + ]) as ConstructorParameters), + ); + readerWorkers.started++; + } + } + return { ...actual, Worker: ReaderWorker }; +}); + const openDatabase = (filename: string) => Layer.build(NodeSqliteClient.layer({ filename })).pipe( Effect.map(Context.get(SqlClient.SqlClient)), @@ -141,6 +163,7 @@ describe("readOpenCodeUsage", () => { ); } let statements = 0; + let bodyReads = 0; const originalAll = NodeSqlite.StatementSync.prototype.all; yield* Effect.acquireRelease( Effect.sync(() => @@ -149,6 +172,7 @@ describe("readOpenCodeUsage", () => { ...params ) { statements++; + if (this.sourceSQL.includes("data ->")) bodyReads++; return originalAll.apply(this, params); }), ), @@ -161,6 +185,51 @@ describe("readOpenCodeUsage", () => { ["opencode:a", "opencode:b", "opencode:c"], ); assert.isBelow(statements, 10); + + bodyReads = 0; + const repeated = yield* readOpenCodeUsage(dir, sinceMs); + assert.deepStrictEqual( + repeated.files.flatMap((file) => file.records).map((record) => record.dedupeKey), + ["opencode:a", "opencode:b", "opencode:c"], + ); + // An unchanged database must not have its message bodies read and parsed again. + assert.strictEqual(bodyReads, 0); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("does not reuse rows from a replaced or restored database file", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "usage-replaced-" }); + const write = (name: string, input: number) => + Effect.scoped( + Effect.gen(function* () { + const db = yield* openDatabase(path.join(dir, name)); + yield* db.unsafe("PRAGMA journal_mode = WAL"); + yield* db.unsafe( + "CREATE TABLE message (id TEXT, session_id TEXT, time_created INTEGER, time_updated INTEGER, data TEXT)", + ); + yield* db.unsafe( + "INSERT INTO message(rowid, id, session_id, time_created, time_updated, data) VALUES (1, 'a', 's', 1, 1, ?)", + [JSON.stringify({ modelID: "m", tokens: { input } })], + ); + }), + ); + const input = Effect.map( + readOpenCodeUsage(dir, 0), + (result) => result.files[0]?.records[0]?.totals.uncachedInputTokens, + ); + yield* write("opencode.db", 100); + assert.strictEqual(yield* input, 100); + yield* write("replacement.db", 900); + yield* fileSystem.rename(path.join(dir, "replacement.db"), path.join(dir, "opencode.db")); + assert.strictEqual(yield* input, 900); + // A backup restored in place keeps the inode and birth time, like `cp -p`. + yield* write("backup.db", 500); + yield* fileSystem.copyFile(path.join(dir, "backup.db"), path.join(dir, "opencode.db")); + yield* fileSystem.utimes(path.join(dir, "opencode.db"), 0, 0); + assert.strictEqual(yield* input, 500); }).pipe(Effect.provide(NodeServices.layer)), ); @@ -188,6 +257,27 @@ describe("readOpenCodeUsage", () => { const legacy = path.join(dir, "storage", "message", message.sessionID); yield* fileSystem.makeDirectory(legacy, { recursive: true }); yield* fileSystem.writeFileString(path.join(legacy, "msg-1.json"), JSON.stringify(message)); + yield* Effect.acquireRelease( + Effect.sync(() => { + readerWorkers.enabled = true; + readerWorkers.started = 0; + }), + () => Effect.sync(() => (readerWorkers.enabled = false)), + ); + let mainThreadBodyReads = 0; + const originalAll = NodeSqlite.StatementSync.prototype.all; + yield* Effect.acquireRelease( + Effect.sync(() => + vi.spyOn(NodeSqlite.StatementSync.prototype, "all").mockImplementation(function ( + this: NodeSqlite.StatementSync, + ...params + ) { + if (this.sourceSQL.includes("data ->")) mainThreadBodyReads++; + return originalAll.apply(this, params); + }), + ), + (spy) => Effect.sync(() => spy.mockRestore()), + ); const first = yield* readOpenCodeUsage(dir, 0); assert.isFalse(first.error); const records = first.files.flatMap((file) => file.records); @@ -212,6 +302,9 @@ describe("readOpenCodeUsage", () => { ["opencode:msg-2"], ); assert.isAbove(Number((yield* fileSystem.stat(path.join(dir, "opencode.db-wal"))).size), 0); + // A large database must not be read on the server's event loop. + assert.isAbove(readerWorkers.started, 0); + assert.strictEqual(mainThreadBodyReads, 0); }).pipe(Effect.provide(NodeServices.layer)), ); diff --git a/packages/provider-opencode/src/server/usage.ts b/packages/provider-opencode/src/server/usage.ts index 4ae7b6df358e9..8d9bb28c70b34 100644 --- a/packages/provider-opencode/src/server/usage.ts +++ b/packages/provider-opencode/src/server/usage.ts @@ -11,6 +11,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import type * as PlatformError from "effect/PlatformError"; +import * as Semaphore from "effect/Semaphore"; import * as SqlClient from "effect/sql/SqlClient"; import type { OpenCodeSettings } from "../settings.ts"; @@ -93,6 +94,18 @@ export interface OpenCodeUsageReadResult { readonly error: boolean; } +interface CachedRow { + readonly updated: unknown; + readonly size: unknown; + readonly created: unknown; + readonly record: UsageRecord | null; +} + +// Parsed rows by `${databasePath}\0${table}`, then rowid, so unchanged rows are not read again. +const rowCache = new Map }>(); +// Concurrent scans of the same database would each read every uncached row. +const scanLock = Semaphore.makeUnsafe(1); + const isNotFound = (cause: PlatformError.PlatformError) => cause.reason._tag === "NotFound"; const MESSAGE_PAGE_SIZE = 64; @@ -116,6 +129,7 @@ const LEGACY_STAT_BATCH = 256; export const readOpenCodeUsage = Effect.fn("readOpenCodeUsage")(function* ( root: string, sinceMs: number, + retentionCutoffMs = sinceMs, ): Effect.fn.Return { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -164,6 +178,22 @@ export const readOpenCodeUsage = Effect.fn("readOpenCodeUsage")(function* ( found = true; const file = { path: path.join(root, name), records: [] as UsageRecord[] }; files.push(file); + // Taken before the snapshot opens, so a write that races the scan invalidates its rows. + const [main, wal] = yield* Effect.forEach([file.path, `${file.path}-wal`], (target) => + Effect.option(fileSystem.stat(target)), + ); + const stamp = (info: Option.Option) => + Option.match(info, { + onNone: () => "none", + onSome: (stat) => `${stat.size}:${Option.getOrNull(stat.mtime)?.getTime()}`, + }); + // A replaced or restored database can repeat rowids and change markers, so every key includes + // the main file; WAL commits leave it alone until a checkpoint. + const identity = Option.match(main!, { + onNone: () => "none", + onSome: (stat) => + `${stat.dev}:${Option.getOrNull(stat.ino)}:${Option.getOrNull(stat.birthtime)?.getTime()}/${stamp(main!)}`, + }); yield* Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; // Fail promptly on live-writer locks instead of stalling the server. @@ -182,30 +212,53 @@ export const readOpenCodeUsage = Effect.fn("readOpenCodeUsage")(function* ( ), ); const timestamp = columns.has("time_created") ? "time_created" : "NULL"; + const updated = columns.has("time_updated") ? "time_updated" : "NULL"; + // Without time_updated an in-place edit can keep the length, so any database write counts. + const version = updated === "NULL" ? `${identity}/${stamp(wal!)}` : identity; const predicates = table === "session_message" ? ["type = 'assistant'"] : []; if (timestamp !== "NULL") predicates.push("time_created >= ?"); - // List only rowids so SQLite can use time indexes without reading message bodies. - const listed = (yield* sql.unsafe<{ readonly row_id: number }>( - `SELECT rowid AS row_id FROM ${table} + // List rowids and change markers so SQLite can use time indexes without reading message + // bodies; octet_length comes from the record header. + const listed = (yield* sql.unsafe<{ + readonly row_id: number; + readonly updated: unknown; + readonly size: unknown; + readonly created: unknown; + }>( + `SELECT rowid AS row_id, ${updated} AS updated, octet_length(data) AS size, + ${timestamp} AS created + FROM ${table} ${predicates.length ? `WHERE ${predicates.join(" AND ")}` : ""}`, timestamp === "NULL" ? [] : [sinceMs], )).toSorted((a, b) => a.row_id - b.row_id); databaseRows += listed.length; + const key = `${file.path}\0${table}`; + const previous = rowCache.get(key); + const reusable = previous?.version === version ? previous.rows : undefined; + const cached = new Map(); + const misses: typeof listed = []; + for (const row of listed) { + const hit = reusable?.get(row.row_id); + if (hit && hit.updated === row.updated && hit.size === row.size) + cached.set(row.row_id, hit); + else misses.push(row); + } // Filter and project in SQLite: user diffs and assistant bodies can be hundreds of MB. const validAssistant = `CASE WHEN typeof(data) = 'text' AND json_valid(data) THEN json_type(data, '$.role') IS NULL OR data ->> '$.role' = 'assistant' END`; // Omit per-page spans; the enclosing scan records row and page counts. - for (let start = 0; start < listed.length; start += MESSAGE_PAGE_SIZE) { - const page = listed.slice(start, start + MESSAGE_PAGE_SIZE); + for (let start = 0; start < misses.length; start += MESSAGE_PAGE_SIZE) { + const page = misses.slice(start, start + MESSAGE_PAGE_SIZE); databasePages++; const rows = yield* sql .unsafe<{ + readonly row_id: number; readonly id: unknown; readonly session_id: unknown; readonly data: unknown; readonly created: unknown; }>( - `SELECT id, session_id, ${timestamp} AS created, json_object( + `SELECT rowid AS row_id, id, session_id, ${timestamp} AS created, json_object( 'tokens', data -> '$.tokens', 'cost', data -> '$.cost', 'model', data -> '$.model', 'modelID', data -> '$.modelID', 'providerID', data -> '$.providerID', @@ -217,9 +270,10 @@ export const readOpenCodeUsage = Effect.fn("readOpenCodeUsage")(function* ( page.map((row) => row.row_id), ) .pipe(Effect.withTracerEnabled(false)); + const parsed = new Map(); for (const row of rows) { - append( - file.records, + parsed.set( + row.row_id, parseOpenCodeMessage(text(row.data), { id: text(row.id), sessionId: text(row.session_id), @@ -227,12 +281,28 @@ export const readOpenCodeUsage = Effect.fn("readOpenCodeUsage")(function* ( }), ); } + // Rows the SQL filter dropped are cached as empty so they are not read again either. + for (const row of page) { + cached.set(row.row_id, { ...row, record: parsed.get(row.row_id) ?? null }); + } yield* Effect.yieldNow; } + for (const row of listed) append(file.records, cached.get(row.row_id)!.record); + // Rows before this window were not listed; keep them for a wider window until retention. + for (const [rowId, entry] of reusable ?? []) { + const created = entry.created; + if (typeof created === "number" && created < sinceMs && created >= retentionCutoffMs) + cached.set(rowId, entry); + } + rowCache.set(key, { version, rows: cached }); } }).pipe( (read) => Effect.flatMap(SqlClient.SqlClient, (sql) => sql.withTransaction(read)), - Effect.provide(NodeSqliteClient.layer({ filename: file.path, readonly: true })), + // Run the scan on a reader worker so large databases do not block the server's event loop. + NodeSqliteClient.readOnly, + Effect.provide( + NodeSqliteClient.layer({ filename: file.path, readonly: true, readerWorker: true }), + ), Effect.catchTags({ SqlError: () => { error = true; @@ -312,7 +382,7 @@ export const readOpenCodeUsage = Effect.fn("readOpenCodeUsage")(function* ( "usage.database_pages": databasePages, }); return { files, missing: !found && !error, error }; -}); +}, scanLock.withPermit); const resolveOpenCodeDataDirs = Effect.fn("resolveOpenCodeDataDirs")(function* ( environment: NodeJS.ProcessEnv, @@ -346,7 +416,11 @@ export type OpenCodeUsageReaderEnv = FileSystem.FileSystem | Path.Path; export const openCodeUsageReader: ProviderUsageReader = { kind: "scan", provider: "opencode", - scan: Effect.fn("openCodeUsageReader.scan")(function* ({ instances, windowStartMs }) { + scan: Effect.fn("openCodeUsageReader.scan")(function* ({ + instances, + windowStartMs, + retentionCutoffMs, + }) { const roots = new Set(); for (const instance of instances) { for (const root of yield* resolveOpenCodeDataDirs(instance.environment)) roots.add(root); @@ -354,7 +428,7 @@ export const openCodeUsageReader: ProviderUsageReader - readOpenCodeUsage(dir, windowStartMs).pipe( + readOpenCodeUsage(dir, windowStartMs, retentionCutoffMs).pipe( Effect.map((result) => ({ dir, files: result.missing && !result.error ? null : result.files, From d3ef7e8e527ce2076ee858fc4c5360955da54b8a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 08:51:25 -0800 Subject: [PATCH 125/390] fix(cursor): send the catalog's default model parameters on every Cursor run (#28722) --- .../provider-cursor/src/server/adapter.ts | 9 ++- .../provider-cursor/src/server/driver.test.ts | 2 +- packages/provider-cursor/src/server/driver.ts | 1 + .../src/server/sdkModel.test.ts | 73 +++++++++++++++++++ .../provider-cursor/src/server/sdkModel.ts | 20 ++++- .../src/server/textGeneration.ts | 9 ++- 6 files changed, 106 insertions(+), 8 deletions(-) create mode 100644 packages/provider-cursor/src/server/sdkModel.test.ts diff --git a/packages/provider-cursor/src/server/adapter.ts b/packages/provider-cursor/src/server/adapter.ts index 1e160be188f08..6b3be72833b97 100644 --- a/packages/provider-cursor/src/server/adapter.ts +++ b/packages/provider-cursor/src/server/adapter.ts @@ -307,6 +307,7 @@ const CURSOR_AGENT_SETTING_SOURCES = [ export function makeCursorAgentOptions(input: { readonly apiKey?: string; readonly modelSelection: ModelSelection; + readonly models?: ReadonlyArray; readonly runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy; readonly threadId: ThreadId; readonly mcpSession: McpProviderSession.McpProviderSessionConfig | undefined; @@ -315,7 +316,7 @@ export function makeCursorAgentOptions(input: { const policy = cursorRuntimeAgentPolicy(input.runtimePolicy, input.platform); const mcpServers = cursorMcpServers(input.mcpSession); return { - model: cursorSdkModelSelection(input.modelSelection), + model: cursorSdkModelSelection(input.modelSelection, input.models), name: `T3 Code ${input.threadId}`, mode: input.runtimePolicy.interactionMode === "plan" ? "plan" : "agent", ...(input.apiKey === undefined ? {} : { apiKey: input.apiKey }), @@ -2296,6 +2297,7 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( const options = makeCursorAgentOptions({ ...(apiKey === undefined ? {} : { apiKey }), modelSelection: openInput.modelSelection, + models: adapterOptions.getModels?.() ?? [], runtimePolicy: openInput.runtimePolicy, platform, threadId: openInput.threadId, @@ -2486,7 +2488,10 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( message, summarize: compacted, options: { - model: cursorSdkModelSelection(turnInput.modelSelection), + model: cursorSdkModelSelection( + turnInput.modelSelection, + adapterOptions.getModels?.() ?? [], + ), mode: turnInput.runtimePolicy.interactionMode === "plan" ? "plan" : "agent", ...(Object.keys(turnMcpServers).length === 0 ? {} diff --git a/packages/provider-cursor/src/server/driver.test.ts b/packages/provider-cursor/src/server/driver.test.ts index 6ff8bd7685648..213d413b6dee2 100644 --- a/packages/provider-cursor/src/server/driver.test.ts +++ b/packages/provider-cursor/src/server/driver.test.ts @@ -389,7 +389,7 @@ it.layer(layerTest)("CursorDriver", (it) => { allowance: DEFAULT_HANDOFF_TOKEN_CAP, }, catalogDefault: { - model: { id: "claude-opus-5-5" }, + model: { id: "claude-opus-5-5", params: [{ id: "context", value: "1m" }] }, window: 1_000_000, allowance: DEFAULT_HANDOFF_TOKEN_CAP, }, diff --git a/packages/provider-cursor/src/server/driver.ts b/packages/provider-cursor/src/server/driver.ts index ef50abb0a93b4..85ab45b107d71 100644 --- a/packages/provider-cursor/src/server/driver.ts +++ b/packages/provider-cursor/src/server/driver.ts @@ -208,6 +208,7 @@ export const CursorDriver: ProviderDriver< processEnv, auth.requireApiKey, auth.withAccess, + () => catalogModels, ); const checkProvider = auth.readApiKey.pipe( diff --git a/packages/provider-cursor/src/server/sdkModel.test.ts b/packages/provider-cursor/src/server/sdkModel.test.ts new file mode 100644 index 0000000000000..4ad129e1ae239 --- /dev/null +++ b/packages/provider-cursor/src/server/sdkModel.test.ts @@ -0,0 +1,73 @@ +import { ProviderInstanceId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { cursorSdkModelSelection } from "./sdkModel.ts"; +import { buildCursorDiscoveredModelsFromSdk } from "./status.ts"; + +const instanceId = ProviderInstanceId.make("cursor"); +const models = buildCursorDiscoveredModelsFromSdk([ + { + id: "grok-4.5", + displayName: "Grok 4.5", + parameters: [ + { + id: "effort", + displayName: "Effort", + values: [{ value: "low" }, { value: "medium" }, { value: "high" }], + }, + { id: "fast", displayName: "Fast", values: [{ value: "false" }, { value: "true" }] }, + ], + variants: [ + { + displayName: "Grok 4.5 High Fast", + isDefault: true, + params: [ + { id: "effort", value: "high" }, + { id: "fast", value: "true" }, + ], + }, + ], + }, +]); + +describe("cursorSdkModelSelection", () => { + it.each([ + { + name: "fills unset catalog parameters and keeps Fast off", + options: undefined, + params: [ + { id: "effort", value: "high" }, + { id: "fast", value: "false" }, + ], + }, + { + name: "keeps explicit options", + options: [ + { id: "effort", value: "low" }, + { id: "fastMode", value: true }, + ], + params: [ + { id: "effort", value: "low" }, + { id: "fast", value: "true" }, + ], + }, + ])("$name", ({ options, params }) => { + const selection = cursorSdkModelSelection( + { instanceId, model: "grok-4.5", ...(options === undefined ? {} : { options }) }, + models, + ); + expect({ + ...selection, + params: selection.params?.toSorted((a, b) => a.id.localeCompare(b.id)), + }).toEqual({ + id: "grok-4.5", + params, + }); + }); + + it("sends no parameters for a model missing from the catalog", () => { + expect(cursorSdkModelSelection({ instanceId, model: "auto" }, models)).toEqual({ + id: "default", + }); + }); +}); diff --git a/packages/provider-cursor/src/server/sdkModel.ts b/packages/provider-cursor/src/server/sdkModel.ts index fec0bafc45eee..ec2a53b6aea71 100644 --- a/packages/provider-cursor/src/server/sdkModel.ts +++ b/packages/provider-cursor/src/server/sdkModel.ts @@ -55,13 +55,27 @@ export function cursorContextWindowTokens( return Number.isSafeInteger(tokens) && tokens > 0 ? tokens : undefined; } -export function cursorSdkModelSelection(modelSelection: ModelSelection): CursorSdkModelSelection { +export function cursorSdkModelSelection( + modelSelection: ModelSelection, + models: ReadonlyArray = [], +): CursorSdkModelSelection { + const explicit = modelSelection.options ?? []; + const descriptors = + models.find((model) => model.slug === modelSelection.model)?.capabilities?.optionDescriptors ?? + []; + // Some Cursor plans reject runs that leave a catalog parameter unset; T3 keeps Fast off until picked. + const defaults = descriptors.flatMap((descriptor) => { + if (explicit.some((option) => option.id === descriptor.id)) return []; + const value = descriptor.id === "fastMode" ? false : getProviderOptionCurrentValue(descriptor); + return value === undefined ? [] : [{ id: descriptor.id, value }]; + }); + const options = [...explicit, ...defaults]; return { id: modelSelection.model === "auto" ? "default" : modelSelection.model, - ...(modelSelection.options === undefined || modelSelection.options.length === 0 + ...(options.length === 0 ? {} : { - params: modelSelection.options.map((option): ModelParameterValue => ({ + params: options.map((option): ModelParameterValue => ({ id: cursorSdkParameterId(option.id), value: String(option.value), })), diff --git a/packages/provider-cursor/src/server/textGeneration.ts b/packages/provider-cursor/src/server/textGeneration.ts index 278839558ee92..82982dfabbb5f 100644 --- a/packages/provider-cursor/src/server/textGeneration.ts +++ b/packages/provider-cursor/src/server/textGeneration.ts @@ -5,7 +5,11 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -import { type ProviderSetupError, TextGenerationError } from "@t3tools/contracts"; +import { + type ProviderSetupError, + type ServerProviderModel, + TextGenerationError, +} from "@t3tools/contracts"; import type { CursorSettings } from "../settings.ts"; import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations"; @@ -51,6 +55,7 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu environment?: NodeJS.ProcessEnv, resolveApiKey?: Effect.Effect, withAccess?: CursorAuth["withAccess"], + getModels?: () => ReadonlyArray, ) { const fs = yield* FileSystem.FileSystem; const sdk = yield* CursorSdk.CursorSdk; @@ -117,7 +122,7 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu const agentOptions = { apiKey, mode: "plan", - model: cursorSdkModelSelection(modelSelection), + model: cursorSdkModelSelection(modelSelection, getModels?.() ?? []), local: { cwd, store, From 0568a63aac2d8c7617917686ceb93701dd37fcdc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 08:52:07 -0800 Subject: [PATCH 126/390] =?UTF-8?q?fix(server):=20agents=20point=20to=20Se?= =?UTF-8?q?ttings=20=E2=86=92=20Labs=20when=20the=20link=20tools=20are=20h?= =?UTF-8?q?idden=20(#30371)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/server/src/provider/agentContextBudget.json | 4 ++-- evals/mcp-tool-use/cases/orchestration.ndjson | 2 ++ .../provider-core/src/server/orchestrationInstructions.ts | 4 ++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index 65f2b6b6c451e..e7901a8656ebe 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { - "claudeSystemAppendBytes": 5597, - "codexAdditionalContextBytes": 7327, + "claudeSystemAppendBytes": 5594, + "codexAdditionalContextBytes": 7324, "mcpToolListJsonBytes": 48094 } diff --git a/evals/mcp-tool-use/cases/orchestration.ndjson b/evals/mcp-tool-use/cases/orchestration.ndjson index 28e89df5597e8..ead48236bd922 100644 --- a/evals/mcp-tool-use/cases/orchestration.ndjson +++ b/evals/mcp-tool-use/cases/orchestration.ndjson @@ -34,6 +34,8 @@ {"id": "two-children-same-model", "area": "orchestration", "kind": "bad", "pattern": "named-model rule read as a cap", "experience": "Asked for two Sol xhigh subagents, an Astra parent started one and refused the second, citing 'One child per named model' (#24967).", "rule": "Before delegate_task ... One child per named model. (intended: every named model gets a child; read as a cap of one child per model)", "privacy": "commit-safe", "setup": {"role": "top", "mocks": {"orchestrator_capabilities": {"parentThreadId": "33333333-3333-4333-8333-333333333333", "inheritedProviderInstanceId": "claudeAgent", "inheritedModel": "claude-sonnet-5-5", "inheritedOptions": [{"id": "effort", "value": "high"}], "runtimeMode": "full-access", "interactionMode": "default", "features": {"appOwnedSubagents": true, "asyncPolling": true, "cancellation": true, "batchThreadCreation": true, "threadManagement": true, "incrementalThreadRead": true, "scheduledTasks": true}, "providers": [{"providerInstanceId": "claudeAgent", "driverKind": "claudeAgent", "displayName": "Claude", "models": [{"id": "claude-opus-5-5", "label": "Claude Opus 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}, {"id": "fastMode", "type": "boolean", "default": false}]}, {"id": "claude-sonnet-5-5", "label": "Claude Sonnet 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}]}, {"id": "claude-haiku-5-5", "label": "Claude Haiku 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}]}]}, {"providerInstanceId": "codex", "driverKind": "codex", "displayName": "Codex", "models": [{"id": "gpt-6.1-sol", "label": "GPT-6.1 Sol", "options": [{"id": "reasoningEffort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}, {"id": "serviceTier", "values": ["default", "priority"], "default": "default"}]}]}, {"providerInstanceId": "muse", "driverKind": "muse", "displayName": "Muse Code", "runtimeModes": ["approval-required", "full-access"], "models": [{"id": "muse-wonder-full", "label": "Muse Wonder Full", "options": [{"id": "reasoningEffort", "values": ["minimal", "low", "medium", "high", "xhigh", "max"], "default": "high"}]}]}, {"providerInstanceId": "grok", "driverKind": "grok", "displayName": "Grok", "models": [{"id": "grok-4.7", "label": "Grok 4.7", "options": []}]}]}, "delegate_task": "default"}}, "prompt": "Launch two Sol xhigh subagents. Give each a trivial task that only replies OK.", "expect": {"calls": [{"tool": "delegate_task", "args": {"target.model": "gpt-6.1-sol", "target.options.reasoningEffort": "xhigh"}}], "minCalls": {"delegate_task": 2}, "callsMustNot": [{"tool": "delegate_task", "args": {"target.model": "~muse"}}, {"tool": "delegate_task", "args": {"target.model": "~claude"}}]}, "notes": "From #24967. One child plus the parent doing the work fails, and so does a second model in place of Sol."} {"id": "worker-counts-per-model", "area": "orchestration", "kind": "bad", "pattern": "named-model rule read as a cap", "experience": "Asked for 20 Sol workers and 20 Luna workers, the agent started one worker per model (#25359).", "rule": "Before delegate_task ... One child per named model. (intended: every named model gets a child; read as a cap of one child per model)", "privacy": "commit-safe", "setup": {"role": "top", "mocks": {"orchestrator_capabilities": {"parentThreadId": "33333333-3333-4333-8333-333333333333", "inheritedProviderInstanceId": "claudeAgent", "inheritedModel": "claude-sonnet-5-5", "inheritedOptions": [{"id": "effort", "value": "high"}], "runtimeMode": "full-access", "interactionMode": "default", "features": {"appOwnedSubagents": true, "asyncPolling": true, "cancellation": true, "batchThreadCreation": true, "threadManagement": true, "incrementalThreadRead": true, "scheduledTasks": true}, "providers": [{"providerInstanceId": "claudeAgent", "driverKind": "claudeAgent", "displayName": "Claude", "models": [{"id": "claude-opus-5-5", "label": "Claude Opus 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}, {"id": "fastMode", "type": "boolean", "default": false}]}, {"id": "claude-sonnet-5-5", "label": "Claude Sonnet 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}]}, {"id": "claude-haiku-5-5", "label": "Claude Haiku 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}]}]}, {"providerInstanceId": "codex", "driverKind": "codex", "displayName": "Codex", "models": [{"id": "gpt-6.1-sol", "label": "GPT-6.1 Sol", "options": [{"id": "reasoningEffort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}, {"id": "serviceTier", "values": ["default", "priority"], "default": "default"}]}]}, {"providerInstanceId": "muse", "driverKind": "muse", "displayName": "Muse Code", "runtimeModes": ["approval-required", "full-access"], "models": [{"id": "muse-wonder-full", "label": "Muse Wonder Full", "options": [{"id": "reasoningEffort", "values": ["minimal", "low", "medium", "high", "xhigh", "max"], "default": "high"}]}]}, {"providerInstanceId": "grok", "driverKind": "grok", "displayName": "Grok", "models": [{"id": "grok-4.7", "label": "Grok 4.7", "options": []}]}]}, "delegate_task": "default"}, "world": {"files": {"tests/flaky/retry.test.ts": "it('retry is flaky', async () => { /* fails 1 in 5 runs */ });\n", "tests/flaky/upload.test.ts": "it('upload is flaky', async () => { /* fails 1 in 5 runs */ });\n", "tests/flaky/session.test.ts": "it('session is flaky', async () => { /* fails 1 in 5 runs */ });\n", "tests/flaky/search.test.ts": "it('search is flaky', async () => { /* fails 1 in 5 runs */ });\n", "tests/flaky/billing.test.ts": "it('billing is flaky', async () => { /* fails 1 in 5 runs */ });\n", "tests/flaky/webhook.test.ts": "it('webhook is flaky', async () => { /* fails 1 in 5 runs */ });\n"}}}, "prompt": "Six tests in tests/flaky/ fail now and then. Start 3 Muse Wonder Full workers and 3 Grok workers, one test file each, to find and fix the flake.", "expect": {"calls": [{"tool": "delegate_task", "args": {"target.model": "muse-wonder-full"}}, {"tool": "delegate_task", "args": {"target.model": "grok-4.7"}}], "minCalls": {"delegate_task": 6}}, "notes": "Scaled down from the real 20 Sol + 20 Luna ask. Muse and Grok need delegate_task from every parent, so a native Sol helper cannot pass or fail it."} {"id": "linked-thread-chip-reads-there", "area": "orchestration", "kind": "guard", "pattern": "thread chip from a linked machine read on this one", "experience": "A thread chip from a linked machine names its environmentId; an agent that reads it without passing that id reads this machine, where the thread does not exist.", "rule": "The thread chip names its environmentId; t3_thread_read takes it.", "privacy": "commit-safe", "setup": {"role": "top", "linkedEnvironments": true, "mocks": {"t3_environment_links": {"environmentId": "environment-laptop", "links": [{"environmentId": "environment-vps", "label": "Hetzner VPS", "status": "reachable", "access": "auto-accept-edits", "expiresAt": "2026-12-01T00:00:00.000Z", "lastError": null}], "notListed": "Not listed: not linked yet. Call t3_environment_link with a hint from the user's words."}, "t3_thread_read": {"thread": {"threadId": "thread-deploy-api", "title": "Deploy the API", "status": "idle"}, "items": [{"position": 1, "createdBy": "user", "text": "Deploy the API to the VPS and check the health endpoint."}, {"position": 2, "createdBy": "assistant", "text": "Deployed. /health returns 200, but /health/db times out after 5s: the API can't reach Postgres on 10.0.0.5:5432."}], "nextPosition": 3, "hasMore": false}}}, "prompt": "Pick up where [Deploy the API](t3-context://v1/thread/thread_deploy-api) left off. What is still failing?", "promptContext": {"records": [{"version": 1, "kind": "thread", "contextId": "thread_deploy-api", "label": "Deploy the API", "environmentId": "environment-vps", "threadId": "thread-deploy-api", "title": "Deploy the API"}]}, "expect": {"calls": [{"tool": "t3_thread_read", "args": {"threadId": "thread-deploy-api", "environmentId": "environment-vps"}}], "notCalls": ["t3_environment_link"]}, "source": {"request": "akj on #17130: attach a linked machine's thread without dragging"}} +{"id": "vps-delegate-links-off-names-setting", "area": "orchestration", "kind": "bad", "pattern": "hidden link tools, setting never named", "experience": "With Linked environments off, the default, an agent asked to delegate to the user's VPS searched for t3_environment_link, then said the tool was not in this build and never named the setting that turns it on.", "rule": "The link tools are absent while Settings → Labs → Linked environments is off; the agent tells the user to turn it on there.", "privacy": "commit-safe", "setup": {"role": "top"}, "prompt": "Delegate this to my vps: run `uname -a` there and tell me the result. Do not edit any files here.", "expect": {"textMust": ["Labs", "Linked environments"], "notCalls": ["delegate_task", "t3_environment_links", "t3_environment_link"]}, "source": {"issue": "pingdotgg/t3code#30358"}} +{"id": "vps-delegate-links-on-calls-link", "area": "orchestration", "kind": "control", "pattern": "unlinked machine with Linked environments on", "experience": "With Linked environments on, a machine the user names that is not linked gets a t3_environment_link card, not advice to turn the setting on.", "rule": "A machine the user names that is not linked: call t3_environment_link with a hint from their words.", "privacy": "commit-safe", "setup": {"role": "top", "linkedEnvironments": true, "mocks": {"t3_environment_links": {"environmentId": "environment-laptop", "links": [], "notListed": "Not listed: not linked yet. Call t3_environment_link with a hint from the user's words."}, "t3_environment_link": {"status": "pending", "turnItemId": "turn-item-link-card-1"}}}, "prompt": "Delegate this to my vps: run `uname -a` there and tell me the result. Do not edit any files here.", "expect": {"calls": [{"tool": "t3_environment_link"}], "textMustNot": ["Labs", "([Tt]urn on|[Ee]nable)[^.\\n]*[Ll]inked environments"]}, "source": {"issue": "pingdotgg/t3code#30358"}} {"id": "background-wait-unmatched-id", "area": "orchestration", "kind": "guard", "pattern": "waits on a task ID that matches no background work, then ends the turn as if waiting", "experience": "One agent called t3_background_wait with the literal ID 'placeholder', another with a Claude subagent ID. The tool returned a normal result listing the ID under `unmatched`, so the agent said it would continue when the work came back and ended its turn. Nothing ever woke it.", "rule": "To wait for a background command or Monitor you started (tests, a build), call `t3_background_wait` with its task ID before you end your turn.", "privacy": "commit-safe", "setup": {"role": "top", "harness": "claude", "priorTurns": [{"role": "user", "text": "Run the full test suite in the background and fix whatever fails. It takes about 15 minutes."}, {"role": "assistant", "toolCalls": [{"tool": "Bash", "input": {"command": "vp test run", "description": "Run the full test suite", "run_in_background": true}, "result": "Command running in background with ID: bq7m2x9k. Output is being written to: /tmp/claude/tasks/bq7m2x9k.output"}, {"tool": "t3_background_wait", "input": {"taskIds": ["placeholder"]}, "result": "default"}]}], "world": {"backgroundTasks": ["bq7m2x9k"], "tests": "The suite started a minute ago and runs for about 15 minutes. Until then /tmp/claude/tasks/bq7m2x9k.output lists 38 passing test files and no failures yet."}}, "prompt": "I'm heading out for a bit. Tell me what failed when it's done.", "expect": {"calls": [{"tool": "t3_background_wait", "args": {"taskIds": ["bq7m2x9k"]}}]}, "source": {"issue": "#29772"}, "notes": "The seeded wait gets T3's real result for an ID the session does not know. The tests are still running, so the only way to keep the thread waiting is a wait on bq7m2x9k. In replay, Sonnet 5.5 and Opus 5.5 also recover from main's success result, so this guards the recovery rather than reproducing the incident. Claude only: t3_background_wait is a Claude tool.", "maxSteps": 8} {"id": "background-wait-matched-id", "area": "orchestration", "kind": "control", "pattern": "waits on the right task ID, then ends the turn", "experience": "With the background command's own ID registered, ending the turn is correct: T3 keeps the thread working and wakes it when the tests finish.", "rule": "To wait for a background command or Monitor you started (tests, a build), call `t3_background_wait` with its task ID before you end your turn.", "privacy": "commit-safe", "setup": {"role": "top", "harness": "claude", "priorTurns": [{"role": "user", "text": "Run the full test suite in the background and fix whatever fails. It takes about 15 minutes."}, {"role": "assistant", "toolCalls": [{"tool": "Bash", "input": {"command": "vp test run", "description": "Run the full test suite", "run_in_background": true}, "result": "Command running in background with ID: bq7m2x9k. Output is being written to: /tmp/claude/tasks/bq7m2x9k.output"}, {"tool": "t3_background_wait", "input": {"taskIds": ["bq7m2x9k"]}, "result": "default"}]}], "world": {"backgroundTasks": ["bq7m2x9k"], "tests": "The suite started a minute ago and runs for about 15 minutes. Until then /tmp/claude/tasks/bq7m2x9k.output lists 38 passing test files and no failures yet."}}, "prompt": "I'm heading out for a bit. Tell me what failed when it's done.", "expect": {"maxCalls": {"t3_background_wait": 1, "Bash": 3}, "notCalls": ["ScheduleWakeup"]}, "source": {"issue": "#29772"}, "notes": "Same thread as background-wait-unmatched-id, with a wait T3 matched. Guards against a fix that makes agents distrust a good wait and poll; a few looks at the output are ordinary. Claude only.", "maxSteps": 8} {"id": "settle-this-thread-after-cleanup", "area": "orchestration", "kind": "bad", "pattern": "settle read as plain English for finish", "experience": "Asked to clean up scratch files and settle this thread, the agent deleted the files and ended the turn without calling t3_thread_organize (#26749).", "rule": "A request to settle this thread calls t3_thread_organize with action settle.", "privacy": "commit-safe", "notes": "From #26749. Claude defers MCP tools, so only the tool's name is visible until ToolSearch loads it; 'settle' appears only in the t3_thread_organize description.", "setup": {"role": "top", "world": {"files": {"scratch/run-1.log": "ok\n", "scratch/run-2.log": "ok\n", "src/index.ts": "export const x = 1;\n"}}, "mocks": {"t3_thread_organize": {"settlesWhenTurnEnds": true}}}, "prompt": "Clean up the scratch files and settle this thread.", "expect": {"calls": [{"tool": "t3_thread_organize", "args": {"action": "settle"}}], "notCalls": ["t3_thread_update"]}} diff --git a/packages/provider-core/src/server/orchestrationInstructions.ts b/packages/provider-core/src/server/orchestrationInstructions.ts index bb4d33d2c56e2..4ce08d71119fe 100644 --- a/packages/provider-core/src/server/orchestrationInstructions.ts +++ b/packages/provider-core/src/server/orchestrationInstructions.ts @@ -13,8 +13,8 @@ Ordinary coding tasks need no T3 tools. - As a subagent or child, do the work yourself. Start no subagents unless your task says to.`, `- \`t3_thread_launch\` and \`create_threads\` make new top-level threads, only when the user asks for a new thread. "Subagent", "delegate", and "parallel" never mean one. Worktree thread: set \`workspaceStrategy\`. Honor a requested base. Otherwise independent work uses the default branch as \`remote/branch\` with \`startFromOrigin:true\`; with no remote, use the local default. Required unpublished commits and dependent work use their local base. Shell worktrees don't bind it, and no checkout setup in its prompt. No \`workspaceStrategy\`: project root, not your worktree. - Given a thread id, \`t3_thread_read\` it before you answer about it. Asked to settle a thread, this one too: \`t3_thread_organize\`. -- Another machine: \`t3_environment_links\` lists linked ones. Pass its \`environmentId\` to a T3 tool to act there. Ids are then that machine's, so pass \`threadId\`. -- A machine the user names that isn't linked ("delegate this to my vps"): call \`t3_environment_link\` with a \`hint\` from their words. Never ask for a pairing code in chat. +- Another machine: \`t3_environment_links\` lists linked ones. Pass its \`environmentId\` and that machine's \`threadId\` to T3 tools. +- Unlinked ("delegate this to my vps"): \`t3_environment_link\` with a \`hint\` from the user's words, never a pairing code in chat. No link tools: say to enable Settings → Labs → Linked environments. - In your reply, mention a thread by its \`link\`, verbatim, never a plain title or bare id. - A login, token, PIN, pairing code or sudo password only the user can give: call \`request_secret\`, not a chat ask or a handoff. Not for access you have. Sudo: if \`sudo -n \` needs a password, use \`delivery: "file"\` and run \`sudo -S -p '' < "$file"\`; the password takes stdin, so give other input as a file argument. Asked only to schedule: \`schedule_task\` without running its prompt. - T3 tools may load late. Try the name once before you call it absent.`, From 139ec8080f37781bf4576f63186022b0a8472b71 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 08:52:21 -0800 Subject: [PATCH 127/390] fix(server): Claude no longer dispatches skill mentions from handoff history (#34497) --- .../Adapters/ClaudeAdapterV2.test.ts | 50 +++++++++++++++++++ .../Adapters/ClaudeAdapterV2.ts | 30 ++++++++--- .../ProviderTurnStartService.ts | 7 ++- .../provider/Drivers/ClaudeSkillDispatch.ts | 4 +- .../src/server/ProviderAdapter.ts | 2 + 5 files changed, 81 insertions(+), 12 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 18a16e2f63102..155370da1757d 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -109,6 +109,7 @@ import * as ClaudeAdapterV2 from "./ClaudeAdapterV2.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderInstallationGate from "../../provider/ProviderInstallationGate.ts"; import { ClaudeOrchestratorReplayHarness } from "./ClaudeAdapterV2.testkit.ts"; +import { withInlineContext } from "../ContextHandoffDelivery.ts"; import { provideDeterministicTestRuntime } from "../testkit/DeterministicRuntime.ts"; import { runOrchestratorV2Scenario } from "../testkit/OrchestratorScenario.ts"; import { layerProviderReplay } from "../testkit/ProviderReplayHarness.ts"; @@ -5483,6 +5484,55 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + // regression: #34393 a provider switch replayed a $skill from the handoff history + it.effect("dispatches only skills from the user's text, not from inline handoff history", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const homePath = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skill-handoff-" }); + const directory = path.join(homePath, "skills", "review"); + yield* fs.makeDirectory(directory, { recursive: true }); + yield* fs.writeFileString( + path.join(directory, "SKILL.md"), + "---\nname: review\n---\nFollow the review instructions.\n", + ); + const harness = yield* makeWakeHarnessWithOptions({ + settings: { ...DEFAULT_CLAUDE_SETTINGS, homePath }, + }); + const history = "Context handoff:\n[Historical user; user_message]\n$review the diff"; + const text = withInlineContext(history, "continue"); + yield* harness.runtime.startTurn({ + ...makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("skill-handoff"), + text, + attachments: [], + runtimePolicy: { ...CLAUDE_TEST_RUNTIME_POLICY, cwd: homePath }, + }), + inlineContextLength: text.length - "continue".length, + }); + + const content = harness.offeredMessages[0]?.message.content; + const blocks = + typeof content === "string" + ? [content] + : (content ?? []).map((block) => (block.type === "text" ? block.text : "")); + assert.isFalse(blocks.some((text) => text.startsWith("/review"))); + assert.include(blocks.join("\n"), "$review the diff"); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + // regression: #15475 MCP title arriving after the tool starts must survive to completion it.effect.each([ { isError: false, title: "Check weather" }, diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 64537c7e5fa8f..b3c1fd1c1fed4 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -2631,12 +2631,15 @@ export function makeClaudeUserMessage(input: { readonly text: string; readonly priority?: SDKUserMessage["priority"]; readonly skills?: ReadonlyMap; + readonly skillMentionsFrom?: number; readonly uuid?: SDKUserMessage["uuid"]; }): SDKUserMessage { // Claude expands a skill only when `/name` starts the last text block. // Keep the whole request before that block; see ClaudeSkillDispatch. const dispatch = - input.skills === undefined ? undefined : planClaudeSkillDispatch(input.text, input.skills); + input.skills === undefined + ? undefined + : planClaudeSkillDispatch(input.text, input.skills, input.skillMentionsFrom); // Claude runs `/goal` only when it opens the prompt, so a goal stays one block. const isGoalCommand = parseGoalCommand({ text: input.text }) !== null; return { @@ -2801,11 +2804,15 @@ const makeClaudeUserMessageWithAttachments = Effect.fnUntraced(function* (input: readonly fileSystem: FileSystem.FileSystem; readonly pdfReservation: Effect.Success>; readonly skills?: ReadonlyMap; + readonly skillMentionsFrom?: number; }) { if (input.attachments.length === 0) { const message = makeClaudeUserMessage({ text: input.text, ...(input.skills === undefined ? {} : { skills: input.skills }), + ...(input.skillMentionsFrom === undefined + ? {} + : { skillMentionsFrom: input.skillMentionsFrom }), ...(input.priority === undefined ? {} : { priority: input.priority }), ...(input.uuid === undefined ? {} : { uuid: input.uuid }), }); @@ -2829,7 +2836,7 @@ const makeClaudeUserMessageWithAttachments = Effect.fnUntraced(function* (input: const dispatch = input.skills === undefined ? undefined - : planClaudeSkillDispatch(textWithAttachmentPaths, input.skills); + : planClaudeSkillDispatch(textWithAttachmentPaths, input.skills, input.skillMentionsFrom); const content: Array = []; // Base64 adds a third to the payload. Leave room below Anthropic's 32 MB // request limit for text, instructions, and SDK framing. The SDK does not @@ -12422,15 +12429,24 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( pdfBudgets.get(nativeThreadId)!, persistAttachmentBudget(nativeThreadId), ); + const promptText = applyClaudePromptEffortPrefix( + turnInput.message.text, + compileClaudeModelSelection(turnInput.modelSelection, modelCatalog).promptEffort, + ); const userMessage = promptUuid === null ? null : yield* makeClaudeUserMessageWithAttachments({ - text: applyClaudePromptEffortPrefix( - turnInput.message.text, - compileClaudeModelSelection(turnInput.modelSelection, modelCatalog) - .promptEffort, - ), + text: promptText, + // Mentions in inline handoff history are history, not this turn's skill. + ...(turnInput.inlineContextLength === undefined + ? {} + : { + skillMentionsFrom: + promptText.length - + turnInput.message.text.slice(turnInput.inlineContextLength).trimEnd() + .length, + }), attachments: turnInput.message.attachments, attachmentsDir, fileSystem, diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index 8f01d117e1739..ddb9416682710 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -1639,12 +1639,11 @@ export const layer: Layer.Layer< .filter((part) => part !== "") .join("\n\n"); const { restartContinuationOfRunId: _resumedRunId, ...promptedInput } = turnInput; + const text = goalCommand ? userText : withInlineContext(context, userText); yield* start({ ...(noteContinuation ? promptedInput : turnInput), - message: { - ...turnInput.message, - text: goalCommand ? userText : withInlineContext(context, userText), - }, + message: { ...turnInput.message, text }, + ...(text === userText ? {} : { inlineContextLength: text.length - userText.length }), }).pipe( // Clear pending handoff markers for refused prompts so the next turn retains the native session. Effect.tapError((error) => diff --git a/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts index 6ce141eec733e..bdf2ed1cae61a 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts @@ -22,12 +22,14 @@ export interface ClaudeSkillDispatch { export function planClaudeSkillDispatch( prompt: string, skills: ReadonlyMap, + /** Mentions before this offset, such as inline handoff history, stay plain text. */ + mentionsFrom = 0, ): ClaudeSkillDispatch | undefined { const mentions = [...prompt.matchAll(SKILL_MENTION_PATTERN)].flatMap((match) => { const name = match[2] ?? ""; const skill = skills.get(name); - if (!skill) return []; const start = (match.index ?? 0) + (match[1]?.length ?? 0); + if (!skill || start < mentionsFrom) return []; return [{ name, skill, start, end: (match.index ?? 0) + match[0].length }]; }); const last = mentions.at(-1); diff --git a/packages/provider-core/src/server/ProviderAdapter.ts b/packages/provider-core/src/server/ProviderAdapter.ts index fc83ac128324e..5b1b6301fa48b 100644 --- a/packages/provider-core/src/server/ProviderAdapter.ts +++ b/packages/provider-core/src/server/ProviderAdapter.ts @@ -535,6 +535,8 @@ export interface ProviderAdapterV2TurnInput { readonly rootNodeId: NodeId; readonly providerThread: OrchestrationV2ProviderThread; readonly message: ProviderAdapterV2TurnMessage; + /** Leading characters of `message.text` that are inline handoff context, not the user's words. */ + readonly inlineContextLength?: number; readonly modelSelection: ModelSelection; readonly runtimePolicy: ProviderAdapterV2RuntimePolicy; readonly appContext?: ReadonlyArray<{ readonly key: string; readonly text: string }>; From 4b316fd5445b8921985b9ac2b1b8075fbe08a688 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 08:52:45 -0800 Subject: [PATCH 128/390] fix(web): clear deleted checkout labels from the sidebar (#25063) --- apps/server/src/ws.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 66512ab6a22c0..1f2a148734d4e 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -3157,7 +3157,15 @@ export const layerWsRpc = ( [WS_METHODS.vcsCreateWorktree]: (input) => gitWorkflow.createWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), [WS_METHODS.vcsRemoveWorktree]: (input) => - gitWorkflow.removeWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + gitWorkflow.removeWorktree(input).pipe( + Effect.tap(() => refreshGitStatus(input.cwd)), + // Subscribers of the removed checkout learn it is no longer a repository. + Effect.tap(() => + vcsStatusBroadcaster + .refreshLocalStatus(input.path) + .pipe(Effect.ignoreCause({ log: true }), Effect.forkDetach, Effect.asVoid), + ), + ), [WS_METHODS.vcsCreateRef]: (input) => gitWorkflow.createRef(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), [WS_METHODS.vcsSwitchRef]: (input) => From 4582a3bc682d1258ae79ae4c0024a054dd5e64f2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 08:55:29 -0800 Subject: [PATCH 129/390] fix(server): MacPorts-owned agent CLIs are no longer updated through npm or their own updater (#36229) --- .../src/server/maintenanceResolver.test.ts | 49 +++++++++++++++++++ .../src/server/maintenanceResolver.ts | 33 +++++++++++++ 2 files changed, 82 insertions(+) diff --git a/packages/provider-core/src/server/maintenanceResolver.test.ts b/packages/provider-core/src/server/maintenanceResolver.test.ts index 0d4ad83f88e61..3475881732fae 100644 --- a/packages/provider-core/src/server/maintenanceResolver.test.ts +++ b/packages/provider-core/src/server/maintenanceResolver.test.ts @@ -1634,6 +1634,55 @@ it.layer( }), ); + for (const fixture of [ + { + name: "an npm-shaped package", + definition: packageToolUpdate, + target: ["lib", "node_modules", "@example", "package-tool", "bin", "package-tool.exe"], + }, + { + name: "a launcher script", + definition: nativePackageToolUpdate, + target: ["share", "native-package-tool", "native-package-tool"], + }, + ]) { + it.effect.skipIf(!symlinksSupported || windowsHost)( + `leaves ${fixture.name} owned by MacPorts to MacPorts`, + () => + Effect.gen(function* () { + const prefix = yield* makeTempDir("t3-macports"); + writeExecutable(NodePath.join(prefix, "bin", "port")); + const target = NodePath.join(prefix, ...fixture.target); + writeExecutable(target); + const launcher = NodePath.join(prefix, "bin", NodePath.basename(target, ".exe")); + NodeFS.symlinkSync(target, launcher); + + const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( + fixture.definition, + { binaryPath: launcher, env: { PATH: NodePath.join(prefix, "bin") } }, + ).pipe( + Effect.provideService(HostProcess.Platform, "darwin"), + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + stdoutSpawner((command, args) => + NodePath.basename(command) === "port" && args.includes("provides") + ? args.includes("-q") + ? "package-tool\n" + : args + .filter((arg) => arg.startsWith("/")) + .map((file) => `${file} is provided by: package-tool\n`) + .join("") + : "", + ), + ), + ); + + expect(capabilities.update).toBeNull(); + expect(capabilities.manualCommand).toBe("sudo port upgrade package-tool"); + }), + ); + } + it("quotes copyable command words the shell would split", () => { const posix = makeProviderMaintenanceCapabilities({ provider: driver("packageTool"), diff --git a/packages/provider-core/src/server/maintenanceResolver.ts b/packages/provider-core/src/server/maintenanceResolver.ts index 88730be615d78..1d7125325381e 100644 --- a/packages/provider-core/src/server/maintenanceResolver.ts +++ b/packages/provider-core/src/server/maintenanceResolver.ts @@ -802,6 +802,15 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( if (commandPaths.some(isNixStoreCommandPath)) { return manual; } + const macPortsName = yield* resolveMacPortsOwner(context); + if (macPortsName !== null) { + return makeManualOnlyProviderMaintenanceCapabilities({ + provider: definition.provider, + packageName, + installPath: context.realCommandPath, + ...(macPortsName ? { manualCommand: `sudo port upgrade ${macPortsName}` } : {}), + }); + } const nativeUpdate = definition.nativeUpdate; const nativeEnv = nativeUpdate?.env; @@ -1020,6 +1029,30 @@ const isMiseWrapperScript = Effect.fn("isMiseWrapperScript")(function* ( return script.startsWith("#!") && /\bmise\s+(?:x|exec)\b/.test(script); }); +/** + * The port that installed the executable, "" when MacPorts owns it under an unusable name, + * or null when it does not. MacPorts prefixes are root-owned and its Claude port disables + * `claude update`, so neither npm nor the native updater can upgrade these installs. + */ +const resolveMacPortsOwner = Effect.fn("resolveMacPortsOwner")(function* ( + context: ProviderMaintenanceResolutionContext, +) { + if (context.platform === "win32") return null; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const siblingPort = path.join(path.dirname(context.resolvedCommandPath), "port"); + const portPath = (yield* fileSystem.exists(siblingPort).pipe(Effect.orElseSucceed(() => false))) + ? siblingPort + : yield* resolveCommandPath("port", { env: context.env }); + if (!portPath) return null; + const prefix = `${normalizeCommandPath(path.dirname(path.dirname(portPath)))}/`; + if (!normalizeCommandPath(context.realCommandPath).startsWith(prefix)) return null; + const output = yield* runProbe(portPath, ["provides", context.realCommandPath], context.env); + const name = output ? /\bis provided by: (\S+)/.exec(output)?.[1] : undefined; + if (name === undefined) return null; + return /^[\w.+-]+$/.test(name) ? name : ""; +}); + // Volta bin links only identify its shim; the package image proves ownership. const isVoltaPackageInstall = Effect.fn("isVoltaPackageInstall")(function* ( context: ProviderMaintenanceResolutionContext, From 286eeea19f8b9b06f84c61c6a2f46fba65c52313 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 08:56:04 -0800 Subject: [PATCH 130/390] test(desktop): AppImage shim test no longer writes into its temp dir after the shim exits (#36235) --- apps/desktop/src/app/DesktopCliShim.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/app/DesktopCliShim.test.ts b/apps/desktop/src/app/DesktopCliShim.test.ts index d43e7a8388b83..3e8b4967dfde5 100644 --- a/apps/desktop/src/app/DesktopCliShim.test.ts +++ b/apps/desktop/src/app/DesktopCliShim.test.ts @@ -56,7 +56,7 @@ const fakeAppImage = (canMount = true, app = REPORTER) => { `echo $$ >'${root}/mounter.pid'`, canMount ? `echo '${image}'` : 'echo "Cannot mount AppImage"; exit 1', // Like the FUSE runtime, any of these unmounts. fd 3 reports to the test when it is open. - `trap 'echo unmounted >&3 2>/dev/null; touch "${root}/unmounted"; exit 0' TERM HUP INT`, + "trap 'echo unmounted >&3 2>/dev/null; exit 0' TERM HUP INT", "trap 'echo mounted >&3 2>/dev/null' WINCH", "while :; do sleep 0.05; done", "", From f62fa296f065b6399fb68edf1863e6f4c831641b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 09:01:05 -0800 Subject: [PATCH 131/390] fix(web): Add to another machine can find ignored files past the first 200 (#36228) --- .../src/environment/ServerEnvironment.ts | 1 + .../src/workspace/WorkspaceEntries.test.ts | 14 +++ apps/server/src/workspace/WorkspaceEntries.ts | 6 +- .../components/AddProjectToMachineDialog.tsx | 116 ++++++++++++------ apps/web/src/lib/addProjectToMachine.ts | 3 + packages/contracts/src/environment.ts | 2 + packages/contracts/src/project.ts | 2 + 7 files changed, 108 insertions(+), 36 deletions(-) diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 912a30f563684..2c79189878e8a 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -274,6 +274,7 @@ export const make = Effect.gen(function* () { projectCloneTracking: true, projectOrganization: true, ignoredFileListing: true, + ignoredFileSearch: true, triage: true, oneTimeSchedules: true, freshContext: true, diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index 348b5f877ac24..d20e378bae053 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -433,6 +433,20 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { }); }), ); + + it.effect("finds ignored files that sort past the listing limit", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ git: true }); + yield* writeTextFile(cwd, ".gitignore", "*.secret\n"); + for (let index = 1; index <= 205; index += 1) { + yield* writeTextFile(cwd, `file-${String(index).padStart(3, "0")}.secret`, "s"); + } + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const listed = yield* workspaceEntries.listIgnoredFiles({ cwd, query: "file-205" }); + expect(listed.files).toContainEqual({ path: "file-205.secret", byteLength: 1 }); + }), + ); }); describe("search", () => { diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index cac706f7ca4a2..43c8fae4dfcaf 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -576,9 +576,13 @@ export const make = Effect.gen(function* () { }) .pipe(Effect.orElseSucceed(() => undefined)); if (!result || result.exitCode !== 0) return { files: [], truncated: false }; + // Filter before the cap so files sorting past it stay reachable by search. + const query = input.query?.toLowerCase() ?? ""; const paths = result.stdout .split("\0") - .filter((entry) => entry.length > 0 && !entry.endsWith("/")) + .filter( + (entry) => entry.length > 0 && !entry.endsWith("/") && entry.toLowerCase().includes(query), + ) .toSorted(); const files = yield* Effect.promise(() => Promise.all( diff --git a/apps/web/src/components/AddProjectToMachineDialog.tsx b/apps/web/src/components/AddProjectToMachineDialog.tsx index 3d9c064cfcb23..3bb70c8b609eb 100644 --- a/apps/web/src/components/AddProjectToMachineDialog.tsx +++ b/apps/web/src/components/AddProjectToMachineDialog.tsx @@ -26,6 +26,7 @@ import { useAddProjectTargets } from "../hooks/useAddProjectTargets"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { useLogicalProjectEnvironments } from "../hooks/useLogicalProjectEnvironments"; import { + COPIED_FILES_MAX_COUNT, COPYABLE_FILE_MAX_BYTES, addProjectToMachine, isDefaultCopiedFile, @@ -35,6 +36,7 @@ import { newProjectId } from "../lib/utils"; import { readProject, subscribeProject, useProject } from "../state/entities"; import { useEnvironments } from "../state/environments"; import { projectEnvironment } from "../state/projects"; +import { useDebouncedValue } from "../state/queries"; import { useEnvironmentQuery } from "../state/query"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { Button } from "./ui/button"; @@ -121,8 +123,10 @@ function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRe const [pathOverride, setPathOverride] = useState(null); const destinationPath = pathOverride ?? defaultPath; - const canListFiles = source?.serverConfig?.environment.capabilities.ignoredFileListing === true; - const ignoredFiles = useEnvironmentQuery( + const capabilities = source?.serverConfig?.environment.capabilities; + const canListFiles = capabilities?.ignoredFileListing === true; + const canSearchFiles = canListFiles && capabilities?.ignoredFileSearch === true; + const allIgnoredFiles = useEnvironmentQuery( project && canListFiles ? projectEnvironment.listIgnoredFiles({ environmentId: project.environmentId, @@ -130,13 +134,29 @@ function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRe }) : null, ); + const [fileQuery, setFileQuery] = useState(""); + const sentFileQuery = useDebouncedValue(fileQuery.trim(), 120); + const searching = canSearchFiles && sentFileQuery.length > 0; + const searchedFiles = useEnvironmentQuery( + project && searching + ? projectEnvironment.listIgnoredFiles({ + environmentId: project.environmentId, + input: { cwd: project.workspaceRoot, query: sentFileQuery }, + }) + : null, + ); + const ignoredFiles = searching ? searchedFiles : allIgnoredFiles; const files = ignoredFiles.data?.files ?? []; - const [selected, setSelected] = useState | null>(null); - const selectedFiles = selected ?? defaultSelection(files); + // Keyed by path with the file kept, so picks made under one search survive the next. + const [selected, setSelected] = useState | null>(null); + const selectedFiles = selected ?? defaultSelection(allIgnoredFiles.data?.files ?? []); const copyable = files.filter((file) => file.byteLength <= COPYABLE_FILE_MAX_BYTES); - const selectedBytes = files - .filter((file) => selectedFiles.has(file.path)) - .reduce((total, file) => total + file.byteLength, 0); + const allCopyableSelected = copyable.every((file) => selectedFiles.has(file.path)); + const atSelectionLimit = selectedFiles.size >= COPIED_FILES_MAX_COUNT; + const selectedBytes = [...selectedFiles.values()].reduce( + (total, file) => total + file.byteLength, + 0, + ); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); @@ -146,11 +166,19 @@ function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRe const unavailable = project === null || target === null || - (canListFiles && ignoredFiles.data === null && ignoredFiles.error === null); - const toggleFile = (path: string, checked: boolean) => { - const next = new Set(selectedFiles); - if (checked) next.add(path); - else next.delete(path); + (canListFiles && allIgnoredFiles.data === null && allIgnoredFiles.error === null); + const toggleFile = (file: ProjectIgnoredFile, checked: boolean) => { + const next = new Map(selectedFiles); + if (!checked) next.delete(file.path); + else if (!atSelectionLimit) next.set(file.path, file); + setSelected(next); + }; + const toggleShownFiles = () => { + const next = new Map(selectedFiles); + for (const file of copyable) { + if (allCopyableSelected) next.delete(file.path); + else if (next.size < COPIED_FILES_MAX_COUNT) next.set(file.path, file); + } setSelected(next); }; @@ -198,7 +226,7 @@ function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRe title: project.title, remoteUrl, destinationPath: destinationPath.trim(), - files: files.filter((file) => selectedFiles.has(file.path)).map((file) => file.path), + files: [...selectedFiles.keys()], }); close(); openClonedProject(scopeProjectRef(target.environmentId, projectId)); @@ -334,33 +362,41 @@ function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRe {copyable.length > 0 ? ( <> - {selectedFiles.size} of {files.length} + {selectedFiles.size} of {allIgnoredFiles.data?.files.length ?? 0} {selectedBytes > 0 ? ` · ${formatBytes(selectedBytes)}` : ""} - ) : null} + {canSearchFiles && (allIgnoredFiles.data?.truncated || fileQuery.length > 0) ? ( + setFileQuery(event.target.value)} + // Enter would otherwise submit the clone. + onKeyDown={(event) => { + if (event.key === "Enter") event.preventDefault(); + }} + spellCheck={false} + autoComplete="off" + /> + ) : null} {ignoredFiles.error ? (

{ignoredFiles.error}

) : files.length === 0 ? (

- {ignoredFiles.isSuccess - ? "No ignored files to copy." - : "Finding ignored files…"} + {!ignoredFiles.isSuccess + ? "Finding ignored files…" + : searching + ? "No ignored files match." + : "No ignored files to copy."}

) : (
@@ -369,7 +405,8 @@ function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRe key={file.path} file={file} checked={selectedFiles.has(file.path)} - onCheckedChange={(checked) => toggleFile(file.path, checked)} + disabled={atSelectionLimit && !selectedFiles.has(file.path)} + onCheckedChange={(checked) => toggleFile(file, checked)} /> ))}
@@ -377,6 +414,7 @@ function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRe {ignoredFiles.data?.truncated ? (

Showing the first {files.length} files. + {canSearchFiles ? " Search to find the rest." : ""}

) : null}

@@ -415,29 +453,37 @@ function projectSubdirectory(repositoryRoot: string | undefined, workspaceRoot: : ""; } -function defaultSelection(files: readonly ProjectIgnoredFile[]): ReadonlySet { - return new Set( +function defaultSelection( + files: readonly ProjectIgnoredFile[], +): ReadonlyMap { + return new Map( files .filter( (file) => file.byteLength <= COPYABLE_FILE_MAX_BYTES && isDefaultCopiedFile(file.path), ) - .map((file) => file.path), + .map((file) => [file.path, file]), ); } function IgnoredFileRow({ file, checked, + disabled, onCheckedChange, }: { file: ProjectIgnoredFile; checked: boolean; + disabled: boolean; onCheckedChange: (checked: boolean) => void; }) { const tooLarge = file.byteLength > COPYABLE_FILE_MAX_BYTES; return (