From 872960cdb58923d42b8c5bcecf43b39bfa01ba6b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 9 Nov 2026 03:54:41 +0000 Subject: [PATCH 01/12] test(server): reproduce #33186, Factory Droid answers after end_turn are dropped Factory Droid (ACP v1) finishes a background Task after session/prompt returns end_turn and then streams a completed tool_call_update and the follow-up answer on the root session. This test replays that sequence for the factory-droid registry agent and expects a continuation request and a continuation run carrying the late answer. It fails on main because the factory-droid flavor does not enable post-settle continuation. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/AcpRegistryAdapterV2.test.ts | 87 ++++++++++++++++++- 1 file changed, 86 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts index 1767c0dfceca4..dc81cfee5f3c7 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts @@ -275,7 +275,7 @@ const openClaudeReplaySession = Effect.fn("openClaudeReplaySession")(function* ( runtimePolicy, }); yield* Effect.addFinalizer(() => Effect.sync(() => replayGate?.releaseAll())); - if (continuationEnabled) { + if (continuationEnabled && agentId === "claude-acp") { assert.isDefined( runtime.hasPendingBackgroundWork, "Claude ACP v2 must report background work after foreground idle", @@ -2093,6 +2093,91 @@ describe("AcpRegistryAdapterV2", () => { }).pipe(Effect.provide(layerTest), Effect.scoped), ); + it.effect("continues Factory Droid when its background Task finishes after end_turn", () => + Effect.gen(function* () { + const { update } = claudeFrames; + const gate = makeProviderReplayGate(["task-completed"]); + const session = yield* openClaudeReplaySession( + [ + ...claudeFrames.open.map((entry) => + entry.frame.method === "initialize" && entry.type === "emit_inbound" + ? { + ...entry, + frame: { + ...entry.frame, + result: { protocolVersion: 1, agentCapabilities: {}, authMethods: [] }, + }, + } + : entry, + ), + { + type: "expect_outbound", + frame: { kind: "request", method: "session/prompt", params: "" }, + }, + update(CLAUDE_ROOT_SESSION, { + sessionUpdate: "tool_call", + toolCallId: "task-1", + title: "Task", + kind: "other", + status: "completed", + rawInput: { subagent_type: "explorer", prompt: "sleep 20; ls /tmp | head -3" }, + rawOutput: { type: "Text", text: "Task launched in background" }, + }), + update(CLAUDE_ROOT_SESSION, { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "waiting" }, + }), + { + type: "emit_inbound", + frame: { + kind: "response", + method: "session/prompt", + result: { stopReason: "end_turn" }, + }, + }, + { + ...update(CLAUDE_ROOT_SESSION, { + sessionUpdate: "tool_call_update", + toolCallId: "task-1", + status: "completed", + rawOutput: { type: "Text", text: "The command ran and exited with code 0." }, + }), + label: "task-completed", + }, + update(CLAUDE_ROOT_SESSION, { sessionUpdate: "usage_update", used: 2_000, size: 10_000 }), + update(CLAUDE_ROOT_SESSION, { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "The background task exited with code 0." }, + }), + claudeMarker("late answer"), + claudeFrames.closeRequest, + claudeFrames.closed, + ], + gate, + true, + "factory-droid", + ); + yield* session.runtime.startTurn(session.turnInput(1)); + assert.equal((yield* session.terminal).status, "completed"); + gate.release("task-completed"); + yield* session.throughMarker("late answer"); + assert.lengthOf( + session.offers, + 1, + "Droid's late root-session answer must offer a continuation run", + ); + yield* session.offers[0]!.dispatchIfCurrent!(Effect.void); + yield* session.runtime.startTurn(session.turnInput(2, { continuation: true })); + const answer = (yield* session.until( + (event) => + event.type === "message.updated" && + event.message.text === "The background task exited with code 0.", + )).at(-1); + if (answer?.type !== "message.updated") throw new Error("Missing late answer"); + assert.equal(answer.message.runId, session.turnInput(2).runId); + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + it.effect.each(["error", "end_turn"])( "hands live Claude child tools to the next run after %s and keeps nested output in the child", (stopReason) => From 03db0c02a701190f0c715db2edb2cac2478aed0a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 9 Nov 2026 04:00:06 +0000 Subject: [PATCH 02/12] fix(acp): show Factory Droid's follow-up after a background Task Factory Droid wakes its main agent after a background Task subagent ends, after session/prompt already returned end_turn: it streams a completed root-session tool_call_update and then the follow-up answer with no prompt in flight. The factory-droid flavor did not enable post-settle continuation, so those frames were dropped. Enable the existing continuation for it so the late output lands in the thread as a continuation run. Fixes pingdotgg/t3code#33186 Co-Authored-By: Claude Opus 5.5 --- packages/provider-acp-registry/src/server/adapter.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/provider-acp-registry/src/server/adapter.ts b/packages/provider-acp-registry/src/server/adapter.ts index 1b8b4240fd00e..5e426d61a2af3 100644 --- a/packages/provider-acp-registry/src/server/adapter.ts +++ b/packages/provider-acp-registry/src/server/adapter.ts @@ -221,7 +221,11 @@ export const makeAcpRegistryAdapterV2 = Effect.fn("makeAcpRegistryAdapterV2")(fu customModels: readCustomModelEntries(options.settings.customModels).map((entry) => entry.slug), promptFailure: (cause) => acpRegistryPromptFailure(registryAgentId, cause), ...(registryAgentId === "factory-droid" - ? { extractElicitationForm: extractDroidElicitationForm } + ? { + extractElicitationForm: extractDroidElicitationForm, + // Droid wakes itself with root-session output after a background Task ends. + enablePostSettleContinuation: true, + } : {}), // glm-acp-agent is the one agent whose per-turn usage a live probe has verified. ...(registryAgentId === "glm-acp-agent" ? { reportsPerTurnPromptUsage: true } : {}), From 31470be69d58e15e453159e41c0eb2262c4357f9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 9 Nov 2026 04:21:25 +0000 Subject: [PATCH 03/12] fix(acp): settle Factory Droid's self-wake continuation after its quiet window A continuation run sends no session/prompt, so without deferred finalize it drained Droid's late answer and never reached a terminal. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/AcpRegistryAdapterV2.test.ts | 8 +++++++- packages/provider-acp-registry/src/server/adapter.ts | 4 +++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts index dc81cfee5f3c7..e9dc8918c1977 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts @@ -17,6 +17,7 @@ import { pendingBackgroundTurnItems } from "@t3tools/shared/orchestrationV2Pendi import { resolveSelfInvocation, selfInvocationArgs } from "@t3tools/shared/nodeRuntime"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; +import * as TestClock from "effect/testing/TestClock"; import * as DateTime from "effect/DateTime"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; @@ -275,7 +276,7 @@ const openClaudeReplaySession = Effect.fn("openClaudeReplaySession")(function* ( runtimePolicy, }); yield* Effect.addFinalizer(() => Effect.sync(() => replayGate?.releaseAll())); - if (continuationEnabled && agentId === "claude-acp") { + if (continuationEnabled) { assert.isDefined( runtime.hasPendingBackgroundWork, "Claude ACP v2 must report background work after foreground idle", @@ -2175,6 +2176,11 @@ describe("AcpRegistryAdapterV2", () => { )).at(-1); if (answer?.type !== "message.updated") throw new Error("Missing late answer"); assert.equal(answer.message.runId, session.turnInput(2).runId); + const continuationTerminal = yield* Effect.forkScoped(session.terminal); + yield* TestClock.adjust("3 seconds"); + const settled = yield* Fiber.join(continuationTerminal); + assert.equal(settled.runOrdinal, 2); + assert.equal(settled.status, "completed"); }).pipe(Effect.provide(layerTest), Effect.scoped), ); diff --git a/packages/provider-acp-registry/src/server/adapter.ts b/packages/provider-acp-registry/src/server/adapter.ts index 5e426d61a2af3..8210f634c82d6 100644 --- a/packages/provider-acp-registry/src/server/adapter.ts +++ b/packages/provider-acp-registry/src/server/adapter.ts @@ -223,8 +223,10 @@ export const makeAcpRegistryAdapterV2 = Effect.fn("makeAcpRegistryAdapterV2")(fu ...(registryAgentId === "factory-droid" ? { extractElicitationForm: extractDroidElicitationForm, - // Droid wakes itself with root-session output after a background Task ends. + // Droid wakes itself with root-session output after a background Task ends; with + // no prompt in flight, that continuation settles after the quiet window. enablePostSettleContinuation: true, + deferFinalizeForBackgroundWork: true, } : {}), // glm-acp-agent is the one agent whose per-turn usage a live probe has verified. From fa4e0995e6eb14d95594929b502a7ac0948ac1ad Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 9 Nov 2026 04:43:21 +0000 Subject: [PATCH 04/12] test(server): keep the Claude-only background-work assertion off Droid's repro Co-Authored-By: Claude Opus 5.5 --- .../src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts index e9dc8918c1977..486026dc24a65 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts @@ -276,7 +276,7 @@ const openClaudeReplaySession = Effect.fn("openClaudeReplaySession")(function* ( runtimePolicy, }); yield* Effect.addFinalizer(() => Effect.sync(() => replayGate?.releaseAll())); - if (continuationEnabled) { + if (continuationEnabled && agentId === "claude-acp") { assert.isDefined( runtime.hasPendingBackgroundWork, "Claude ACP v2 must report background work after foreground idle", From 3d2dfcc057ae2676de8a7bf3508acb4b7d63c15b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 08:46:31 +0000 Subject: [PATCH 05/12] fix(server): Droid and Grok continuations wait for open questions and tools After a self-woken continuation, the deferred finalizer completed the run 3 seconds after the last update even when the agent was still waiting on an unanswered question or approval, or a foreground tool was still running. The server then cancelled the live question while the agent stayed blocked on it. The deferred finalize now also waits for open agent requests and pending, running or waiting foreground tools, through one predicate shared with the prompt inactivity check. Answering or closing a request re-arms the quiet window. Persistent background tasks still never block settlement. The Factory Droid regression test now follows the reported timing, where the answer lands after the quiet window that settled the Task completion. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/AcpRegistryAdapterV2.test.ts | 235 ++++++++++++++---- packages/provider-acp/src/server/adapter.ts | 61 +++-- 2 files changed, 229 insertions(+), 67 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts index 486026dc24a65..b9f84dd5d4d0f 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts @@ -380,6 +380,29 @@ const claudeMarker = (title: string) => sessionUpdate: "session_info_update", title, }); +// Factory Droid speaks ACP v1, so its prompt ends with a `session/prompt` response. +const droidFirstTurn = (...frames: ReadonlyArray) => [ + ...claudeFrames.open.map((entry) => + entry.frame.method === "initialize" && entry.type === "emit_inbound" + ? { + ...entry, + frame: { + ...entry.frame, + result: { protocolVersion: 1, agentCapabilities: {}, authMethods: [] }, + }, + } + : entry, + ), + { + type: "expect_outbound", + frame: { kind: "request", method: "session/prompt", params: "" }, + }, + ...frames, + { + type: "emit_inbound", + frame: { kind: "response", method: "session/prompt", result: { stopReason: "end_turn" } }, + }, +]; const claudeChild = [ claudeFrames.update(CLAUDE_ROOT_SESSION, { sessionUpdate: "tool_call_update", @@ -2097,45 +2120,24 @@ describe("AcpRegistryAdapterV2", () => { it.effect("continues Factory Droid when its background Task finishes after end_turn", () => Effect.gen(function* () { const { update } = claudeFrames; - const gate = makeProviderReplayGate(["task-completed"]); + const gate = makeProviderReplayGate(["task-completed", "answer"]); const session = yield* openClaudeReplaySession( [ - ...claudeFrames.open.map((entry) => - entry.frame.method === "initialize" && entry.type === "emit_inbound" - ? { - ...entry, - frame: { - ...entry.frame, - result: { protocolVersion: 1, agentCapabilities: {}, authMethods: [] }, - }, - } - : entry, + ...droidFirstTurn( + update(CLAUDE_ROOT_SESSION, { + sessionUpdate: "tool_call", + toolCallId: "task-1", + title: "Task", + kind: "other", + status: "completed", + rawInput: { subagent_type: "explorer", prompt: "sleep 20; ls /tmp | head -3" }, + rawOutput: { type: "Text", text: "Task launched in background" }, + }), + update(CLAUDE_ROOT_SESSION, { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "waiting" }, + }), ), - { - type: "expect_outbound", - frame: { kind: "request", method: "session/prompt", params: "" }, - }, - update(CLAUDE_ROOT_SESSION, { - sessionUpdate: "tool_call", - toolCallId: "task-1", - title: "Task", - kind: "other", - status: "completed", - rawInput: { subagent_type: "explorer", prompt: "sleep 20; ls /tmp | head -3" }, - rawOutput: { type: "Text", text: "Task launched in background" }, - }), - update(CLAUDE_ROOT_SESSION, { - sessionUpdate: "agent_message_chunk", - content: { type: "text", text: "waiting" }, - }), - { - type: "emit_inbound", - frame: { - kind: "response", - method: "session/prompt", - result: { stopReason: "end_turn" }, - }, - }, { ...update(CLAUDE_ROOT_SESSION, { sessionUpdate: "tool_call_update", @@ -2145,7 +2147,15 @@ describe("AcpRegistryAdapterV2", () => { }), label: "task-completed", }, - update(CLAUDE_ROOT_SESSION, { sessionUpdate: "usage_update", used: 2_000, size: 10_000 }), + claudeMarker("task done"), + { + ...update(CLAUDE_ROOT_SESSION, { + sessionUpdate: "usage_update", + used: 2_000, + size: 10_000, + }), + label: "answer", + }, update(CLAUDE_ROOT_SESSION, { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "The background task exited with code 0." }, @@ -2161,26 +2171,155 @@ describe("AcpRegistryAdapterV2", () => { yield* session.runtime.startTurn(session.turnInput(1)); assert.equal((yield* session.terminal).status, "completed"); gate.release("task-completed"); - yield* session.throughMarker("late answer"); - assert.lengthOf( - session.offers, - 1, - "Droid's late root-session answer must offer a continuation run", - ); + yield* session.throughMarker("task done"); + assert.lengthOf(session.offers, 1, "Droid's Task completion must offer a continuation run"); yield* session.offers[0]!.dispatchIfCurrent!(Effect.void); yield* session.runtime.startTurn(session.turnInput(2, { continuation: true })); + const taskSettled = yield* Effect.forkScoped(session.terminal); + yield* TestClock.adjust("3 seconds"); + assert.include(yield* Fiber.join(taskSettled), { runOrdinal: 2, status: "completed" }); + // Droid answers ~4 s after the Task result, past the quiet window. + gate.release("answer"); + yield* session.throughMarker("late answer"); + assert.lengthOf(session.offers, 2, "Droid's late answer must offer another continuation"); + yield* session.offers[1]!.dispatchIfCurrent!(Effect.void); + yield* session.runtime.startTurn(session.turnInput(3, { continuation: true })); const answer = (yield* session.until( (event) => event.type === "message.updated" && event.message.text === "The background task exited with code 0.", )).at(-1); if (answer?.type !== "message.updated") throw new Error("Missing late answer"); - assert.equal(answer.message.runId, session.turnInput(2).runId); - const continuationTerminal = yield* Effect.forkScoped(session.terminal); + assert.equal(answer.message.runId, session.turnInput(3).runId); + const answerSettled = yield* Effect.forkScoped(session.terminal); + yield* TestClock.adjust("3 seconds"); + assert.include(yield* Fiber.join(answerSettled), { runOrdinal: 3, status: "completed" }); + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + + it.effect.each([ + { + blocker: "an unanswered question", + frames: [ + { + type: "emit_inbound", + label: "blocker", + frame: { + kind: "request", + method: "elicitation/create", + params: { + sessionId: CLAUDE_ROOT_SESSION, + mode: "form", + message: "Which file?", + requestedSchema: { + type: "object", + properties: { + question_1: { + type: "string", + title: "File", + description: "Which file should I inspect?", + oneOf: [ + { const: "option_0", title: "a.ts" }, + { const: "other", title: "Other" }, + ], + }, + question_1_other: { type: "string" }, + }, + required: ["question_1"], + }, + }, + }, + }, + { + type: "expect_outbound", + frame: { + kind: "response", + method: "elicitation/create", + result: { action: "accept", content: { question_1: "option_0" } }, + }, + }, + ], + }, + { + blocker: "a running tool", + frames: [ + { + ...claudeFrames.update(CLAUDE_ROOT_SESSION, { + sessionUpdate: "tool_call", + toolCallId: "slow-tool", + title: "Execute", + kind: "execute", + status: "in_progress", + rawInput: { command: "sleep 20; echo done" }, + }), + label: "blocker", + }, + claudeMarker("blocked"), + { + ...claudeFrames.update(CLAUDE_ROOT_SESSION, { + sessionUpdate: "tool_call_update", + toolCallId: "slow-tool", + status: "completed", + rawOutput: { type: "Text", text: "done" }, + }), + label: "resolve", + }, + ], + }, + ])("keeps a Factory Droid continuation open past the quiet window for $blocker", ({ frames }) => + Effect.gen(function* () { + const gate = makeProviderReplayGate(["wake", "blocker", "resolve"]); + const session = yield* openClaudeReplaySession( + [ + ...droidFirstTurn(), + { + ...claudeFrames.update(CLAUDE_ROOT_SESSION, { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "Checking the background result" }, + }), + label: "wake", + }, + claudeMarker("woke"), + ...frames, + claudeMarker("resolved"), + claudeFrames.closeRequest, + claudeFrames.closed, + ], + gate, + true, + "factory-droid", + ); + yield* session.runtime.startTurn(session.turnInput(1)); + assert.equal((yield* session.terminal).status, "completed"); + gate.release("wake"); + yield* session.throughMarker("woke"); + yield* session.offers[0]!.dispatchIfCurrent!(Effect.void); + yield* session.runtime.startTurn(session.turnInput(2, { continuation: true })); + gate.release("blocker"); + const blocked = (yield* session.until( + (event) => + (event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending") || + (event.type === "provider_thread.updated" && + event.providerThread.nativeMetadata?.title === "blocked"), + )).at(-1)!; + yield* TestClock.adjust("3 seconds"); + if (blocked.type === "runtime_request.updated") { + yield* session.runtime.respondToRuntimeRequest({ + requestId: blocked.runtimeRequest.id, + answers: { question_1: "option_0" }, + }); + } else { + gate.release("resolve"); + } + const beforeResolved = yield* session.throughMarker("resolved"); + assert.notInclude( + beforeResolved.map((event) => event.type), + "turn.terminal", + "the continuation must not settle while Droid still waits", + ); + const settled = yield* Effect.forkScoped(session.terminal); yield* TestClock.adjust("3 seconds"); - const settled = yield* Fiber.join(continuationTerminal); - assert.equal(settled.runOrdinal, 2); - assert.equal(settled.status, "completed"); + assert.include(yield* Fiber.join(settled), { runOrdinal: 2, status: "completed" }); }).pipe(Effect.provide(layerTest), Effect.scoped), ); diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index a2c2c429fa88c..cae06e3992297 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -2309,7 +2309,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( const closeAgentRequest = (agentRequests: Set, requestId: string) => Effect.suspend(() => agentRequests.delete(requestId) && agentRequests === openAgentRequests - ? notePromptProgress() + ? notePromptProgress().pipe(Effect.andThen(rearmActiveDeferredFinalize)) : Effect.void, ); const handleRuntimeTerminationAtGeneration = (runtimeGeneration: number) => @@ -3771,6 +3771,31 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( acpSubagentStatusBlocksTurnSettlement(subagent.task.status), ); + const awaitsRequestOrTool = Effect.fnUntraced(function* ( + context: ActiveAcpTurn, + countsTool: (tool: AcpToolCallState) => boolean, + ) { + if (openAgentRequests.size > 0) return true; + if ((yield* Ref.get(pendingRuntimeRequests)).size > 0) return true; + for (const tool of context.tools.values()) { + const status = toolStatus(tool.status); + if (status !== "pending" && status !== "running" && status !== "waiting") continue; + if (countsTool(tool)) return true; + } + return false; + }); + + // Background tools and subagents are `hasDeferredBackgroundWork`'s, which skips + // persistent tasks that never end. + const isForegroundTool = (tool: AcpToolCallState) => + flavor.extractBackgroundTaskId?.(tool) === undefined && + extractSubagentUpdate(tool) === undefined; + + const blocksDeferredFinalize = Effect.fnUntraced(function* (context: ActiveAcpTurn) { + if (hasDeferredBackgroundWork(context)) return true; + return yield* awaitsRequestOrTool(context, isForegroundTool); + }); + const rearmDeferredFinalize = /* Provider events include streaming deltas; avoid a span for each update. */ Effect.fnUntraced(function* (context: ActiveAcpTurn) { @@ -3779,6 +3804,17 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( if (hasDeferredBackgroundWork(context)) return; yield* scheduleDeferredFinalize(context); }); + const rearmActiveDeferredFinalize = Ref.get(activeTurn).pipe( + Effect.flatMap((context) => + context === null ? Effect.void : rearmDeferredFinalize(context), + ), + ); + const forgetPendingRuntimeRequest = (requestId: string) => + Ref.update(pendingRuntimeRequests, (current) => { + const updated = new Map(current); + updated.delete(requestId); + return updated; + }).pipe(Effect.andThen(rearmActiveDeferredFinalize)); // let breaks circular inference from monitor hydration re-entry. let emitTool: ( @@ -5868,11 +5904,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( Effect.ensuring( runRuntimeCallbackAtGeneration( generation, - Ref.update(pendingRuntimeRequests, (current) => { - const updated = new Map(current); - updated.delete(String(requestId)); - return updated; - }), + forgetPendingRuntimeRequest(String(requestId)), ).pipe(Effect.asVoid), ), ); @@ -6609,11 +6641,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( Effect.ensuring( runRuntimeCallbackAtGeneration( handlerGeneration, - Ref.update(pendingRuntimeRequests, (current) => { - const updated = new Map(current); - updated.delete(String(requestId)); - return updated; - }), + forgetPendingRuntimeRequest(String(requestId)), ).pipe(Effect.asVoid), ), ); @@ -7791,7 +7819,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( scheduleDeferredFinalize = Effect.fn("scheduleDeferredFinalize")(function* (context) { if (!flavor.deferFinalizeForBackgroundWork) return; if (!context.promptSettled || context.finalized || context.interrupted) return; - if (hasDeferredBackgroundWork(context)) return; + if (yield* blocksDeferredFinalize(context)) return; context.backgroundFinalizeGeneration += 1; const generation = context.backgroundFinalizeGeneration; yield* Effect.gen(function* () { @@ -7803,7 +7831,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( ) { return; } - if (hasDeferredBackgroundWork(context)) return; + if (yield* blocksDeferredFinalize(context)) return; const status = context.promptSettledStatus ?? "completed"; yield* finalizeTurn(context, status); }).pipe(Effect.forkIn(sessionScope), Effect.asVoid); @@ -7816,12 +7844,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( const promptInactivityPaused = Effect.fn("AcpAdapterV2.promptInactivityPaused")(function* ( context: ActiveAcpTurn, ) { - if (openAgentRequests.size > 0) return true; - if ((yield* Ref.get(pendingRuntimeRequests)).size > 0) return true; - for (const tool of context.tools.values()) { - const status = toolStatus(tool.status); - if (status === "pending" || status === "running" || status === "waiting") return true; - } + if (yield* awaitsRequestOrTool(context, () => true)) return true; for (const subagent of context.subagents.values()) { if (acpSubagentStatusBlocksTurnSettlement(subagent.task.status)) return true; } From 4e06d3193cb7a8ae8a8e4cd3e197af59a8e764a4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 08:48:47 +0000 Subject: [PATCH 06/12] fix(server): close stale foreground tools when a deferred turn settles A foreground tool still open when session/prompt returns end_turn with background work pending would now hold the deferred finalize open until Stop. Close it at settlement, as the immediate-finalize path already does, so only tools started after settlement keep a continuation open. Co-Authored-By: Claude Opus 5.5 --- packages/provider-acp/src/server/adapter.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index cae06e3992297..e45a94db3b35d 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -8486,6 +8486,9 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( !context.interrupted && hasDeferredBackgroundWork(context) ) { + // A foreground tool still open at end_turn is stale; left open it would + // hold the deferred finalize until Stop. + yield* terminalizeOpenForegroundTools(context); yield* closeTextStreams(context); yield* ( options.testHooks?.afterPromptSettledWithBackgroundWork?.() ?? Effect.void From 121644b907a50d8817a201823677a1c710338e0f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 09:07:19 +0000 Subject: [PATCH 07/12] fix(server): stored status closes stale foreground tools at deferred settle Terminalizing a stale foreground tool at end_turn only changed the projected status; the tool state kept its native in_progress, so the deferred finalize still counted it as open and a Grok turn with background work stayed open until Stop. Store the terminal status too, and cover the stale tool in the existing Grok early-completion test. Co-Authored-By: Claude Opus 5.5 --- .../src/orchestration-v2/Adapters/AcpAdapterV2.test.ts | 9 +++++++++ packages/provider-acp/src/server/adapter.ts | 3 ++- 2 files changed, 11 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 f63a220fb5191..4f6991e3a2abb 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -9328,6 +9328,15 @@ describe("AcpAdapterV2", () => { assert.equal(backgroundItem.turnItem.output ?? "", output); } }); + // A foreground tool still open at end_turn must not hold the deferred finalize. + yield* update({ + sessionUpdate: "tool_call", + toolCallId: "stale-foreground", + title: "Read file", + kind: "read", + status: "in_progress", + rawInput: { path: "a.ts" }, + }); yield* Deferred.succeed(promptGate, { stopReason: "end_turn" }); yield* Deferred.await(promptHeld); yield* assertCommand(status, "EARLY_BG_DONE\n"); diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index e45a94db3b35d..97b97253438b3 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -7587,7 +7587,8 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( for (const tool of context.tools.values()) { if (!acpCompletedTurnShouldTerminalizeTool(tool, { ...flavor, extractSubagentUpdate })) continue; - yield* emitTool(context, tool, status); + // Store the status too, so the deferred finalize stops counting the tool as open. + yield* emitTool(context, { ...tool, status }, status); } }); From 66f6f6d1723e399a4b34b1e56b67f159b72fdadc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 10:47:50 +0000 Subject: [PATCH 08/12] fix(server): Droid's late Task completion keeps the card's title and input A post-settle continuation starts with an empty tool map, so Droid's sparse `tool_call_update` for a Task launched in the previous run merged against nothing and replaced the card's "Task" title and input with a bare "Tool". A run finalized with post-settle continuation enabled now hands its tool states to the next continuation on the same session, and emitTool falls back to them only when the current run has not seen the tool. They are never copied into the run's own tool map, so they are not counted as open or terminalized again, and only one run's states are held at a time. The Droid quiet-window table gains an approval row answered with no later update, proving the permission path's request cleanup rearms the finalize. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/AcpRegistryAdapterV2.test.ts | 169 ++++++++++++------ packages/provider-acp/src/server/adapter.ts | 28 ++- 2 files changed, 138 insertions(+), 59 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts index b9f84dd5d4d0f..d4ef81074eb5e 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts @@ -2175,9 +2175,21 @@ describe("AcpRegistryAdapterV2", () => { assert.lengthOf(session.offers, 1, "Droid's Task completion must offer a continuation run"); yield* session.offers[0]!.dispatchIfCurrent!(Effect.void); yield* session.runtime.startTurn(session.turnInput(2, { continuation: true })); - const taskSettled = yield* Effect.forkScoped(session.terminal); + const taskSettled = yield* Effect.forkScoped( + session.until((event) => event.type === "turn.terminal"), + ); yield* TestClock.adjust("3 seconds"); - assert.include(yield* Fiber.join(taskSettled), { runOrdinal: 2, status: "completed" }); + const taskRun = yield* Fiber.join(taskSettled); + assert.include(taskRun.at(-1), { runOrdinal: 2, status: "completed" }); + const task = taskRun.findLast( + (event) => + event.type === "turn_item.updated" && event.turnItem.nativeItemRef?.nativeId === "task-1", + ); + if (task?.type !== "turn_item.updated") throw new Error("Missing Task update"); + assert.include(task.turnItem, { title: "Task", status: "completed" }); + assert.deepInclude(task.turnItem, { + input: { subagent_type: "explorer", prompt: "sleep 20; ls /tmp | head -3" }, + }); // Droid answers ~4 s after the Task result, past the quiet window. gate.release("answer"); yield* session.throughMarker("late answer"); @@ -2230,6 +2242,7 @@ describe("AcpRegistryAdapterV2", () => { }, }, }, + { ...claudeMarker("still waiting"), label: "check" }, { type: "expect_outbound", frame: { @@ -2239,6 +2252,41 @@ describe("AcpRegistryAdapterV2", () => { }, }, ], + response: { answers: { question_1: "option_0" } }, + }, + { + blocker: "an unanswered approval", + frames: [ + { + type: "emit_inbound", + label: "blocker", + frame: { + kind: "request", + method: "session/request_permission", + params: { + sessionId: CLAUDE_ROOT_SESSION, + toolCall: { + toolCallId: "approve-command", + title: "Run tests", + kind: "execute", + status: "pending", + rawInput: { command: "vp test run" }, + }, + options: [{ optionId: "allow", name: "Allow", kind: "allow_once" }], + }, + }, + }, + { ...claudeMarker("still waiting"), label: "check" }, + { + type: "expect_outbound", + frame: { + kind: "response", + method: "session/request_permission", + result: { outcome: { outcome: "selected", optionId: "allow" } }, + }, + }, + ], + response: { decision: "accept" as const }, }, { blocker: "a running tool", @@ -2255,6 +2303,7 @@ describe("AcpRegistryAdapterV2", () => { label: "blocker", }, claudeMarker("blocked"), + { ...claudeMarker("still waiting"), label: "check" }, { ...claudeFrames.update(CLAUDE_ROOT_SESSION, { sessionUpdate: "tool_call_update", @@ -2264,63 +2313,69 @@ describe("AcpRegistryAdapterV2", () => { }), label: "resolve", }, + claudeMarker("resolved"), ], + response: undefined, }, - ])("keeps a Factory Droid continuation open past the quiet window for $blocker", ({ frames }) => - Effect.gen(function* () { - const gate = makeProviderReplayGate(["wake", "blocker", "resolve"]); - const session = yield* openClaudeReplaySession( - [ - ...droidFirstTurn(), - { - ...claudeFrames.update(CLAUDE_ROOT_SESSION, { - sessionUpdate: "agent_message_chunk", - content: { type: "text", text: "Checking the background result" }, - }), - label: "wake", - }, - claudeMarker("woke"), - ...frames, - claudeMarker("resolved"), - claudeFrames.closeRequest, - claudeFrames.closed, - ], - gate, - true, - "factory-droid", - ); - yield* session.runtime.startTurn(session.turnInput(1)); - assert.equal((yield* session.terminal).status, "completed"); - gate.release("wake"); - yield* session.throughMarker("woke"); - yield* session.offers[0]!.dispatchIfCurrent!(Effect.void); - yield* session.runtime.startTurn(session.turnInput(2, { continuation: true })); - gate.release("blocker"); - const blocked = (yield* session.until( - (event) => - (event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending") || - (event.type === "provider_thread.updated" && - event.providerThread.nativeMetadata?.title === "blocked"), - )).at(-1)!; - yield* TestClock.adjust("3 seconds"); - if (blocked.type === "runtime_request.updated") { - yield* session.runtime.respondToRuntimeRequest({ - requestId: blocked.runtimeRequest.id, - answers: { question_1: "option_0" }, - }); - } else { - gate.release("resolve"); - } - const beforeResolved = yield* session.throughMarker("resolved"); - assert.notInclude( - beforeResolved.map((event) => event.type), - "turn.terminal", - "the continuation must not settle while Droid still waits", - ); - const settled = yield* Effect.forkScoped(session.terminal); - yield* TestClock.adjust("3 seconds"); - assert.include(yield* Fiber.join(settled), { runOrdinal: 2, status: "completed" }); - }).pipe(Effect.provide(layerTest), Effect.scoped), + ])( + "keeps a Factory Droid continuation open past the quiet window for $blocker", + ({ frames, response }) => + Effect.gen(function* () { + const gate = makeProviderReplayGate(["wake", "blocker", "check", "resolve"]); + const session = yield* openClaudeReplaySession( + [ + ...droidFirstTurn(), + { + ...claudeFrames.update(CLAUDE_ROOT_SESSION, { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "Checking the background result" }, + }), + label: "wake", + }, + claudeMarker("woke"), + ...frames, + claudeFrames.closeRequest, + claudeFrames.closed, + ], + gate, + true, + "factory-droid", + ); + yield* session.runtime.startTurn(session.turnInput(1)); + assert.equal((yield* session.terminal).status, "completed"); + gate.release("wake"); + yield* session.throughMarker("woke"); + yield* session.offers[0]!.dispatchIfCurrent!(Effect.void); + yield* session.runtime.startTurn(session.turnInput(2, { continuation: true })); + gate.release("blocker"); + const blocked = (yield* session.until( + (event) => + (event.type === "runtime_request.updated" && + event.runtimeRequest.status === "pending") || + (event.type === "provider_thread.updated" && + event.providerThread.nativeMetadata?.title === "blocked"), + )).at(-1)!; + yield* TestClock.adjust("3 seconds"); + gate.release("check"); + assert.notInclude( + (yield* session.throughMarker("still waiting")).map((event) => event.type), + "turn.terminal", + "the continuation must not settle while Droid still waits", + ); + // Requests are answered with no later update, so only their resolution rearms the finalize. + if (blocked.type === "runtime_request.updated" && response !== undefined) { + yield* session.runtime.respondToRuntimeRequest({ + requestId: blocked.runtimeRequest.id, + ...response, + }); + } else { + gate.release("resolve"); + yield* session.throughMarker("resolved"); + } + const settled = yield* Effect.forkScoped(session.terminal); + yield* TestClock.adjust("3 seconds"); + assert.include(yield* Fiber.join(settled), { runOrdinal: 2, status: "completed" }); + }).pipe(Effect.provide(layerTest), Effect.scoped), ); it.effect.each(["error", "end_turn"])( diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index 97b97253438b3..92d475eedbf73 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -1369,6 +1369,8 @@ interface ActiveAcpTurn { readonly tools: Map; readonly toolUpdatesSkipped: Map; readonly toolStartedAt: Map; + /** A continuation's sparse tool updates merge from the finalized run before it. */ + readonly precedingRunTools: AcpRunTools | null; readonly subagents: Map; readonly subagentsBySessionId: Map; readonly pendingSubagentNotifications: Map>; @@ -1612,6 +1614,8 @@ function acpSubagentHasPendingBackgroundWork(subagent: ActiveAcpSubagent): boole ); } +type AcpRunTools = Pick; + type AcpCarryoverSubagents = { readonly sessionId: string; /** The root's subscriber keeps ingesting, so late child terminals can still project. */ @@ -2208,6 +2212,9 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( const stoppedRunQuarantine = yield* Ref.make(false); // Native subagents can outlive interrupts; carry lineage forward to finish their original turn items. const carryoverSubagents = yield* Ref.make(null); + const finalizedRunTools = yield* Ref.make< + (AcpRunTools & { readonly sessionId: string }) | null + >(null); const handledBackgroundTaskIdsInActiveTurn = yield* Ref.make>( new Set(), ); @@ -3879,7 +3886,9 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( if (context.reasoning.current?.sourceMessageId == null) yield* closeTextStream(context, "reasoning"); yield* closeTextStream(context, "user"); - const previous = context.tools.get(incoming.toolCallId); + const previous = + context.tools.get(incoming.toolCallId) ?? + context.precedingRunTools?.tools.get(incoming.toolCallId); const merged = mergeToolCallState(previous, incoming); const normalized = flavor.normalizeToolCall?.(merged) ?? merged; const backgroundTaskId = flavor.extractBackgroundTaskId?.(normalized); @@ -4049,7 +4058,10 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( nativeId: toolCall.toolCallId, strength: "strong" as const, }; - const startedAt = context.toolStartedAt.get(toolCall.toolCallId) ?? now; + const startedAt = + context.toolStartedAt.get(toolCall.toolCallId) ?? + context.precedingRunTools?.toolStartedAt.get(toolCall.toolCallId) ?? + now; context.toolStartedAt.set(toolCall.toolCallId, startedAt); const completedAt = completedAtForStatus(status, now); const title = toolCall.title ?? null; @@ -7793,6 +7805,13 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( }); } } + if (postSettleContinuationEnabled && settledStatus === "completed") { + yield* Ref.set(finalizedRunTools, { + sessionId: context.nativeThreadId, + tools: context.tools, + toolStartedAt: context.toolStartedAt, + }); + } yield* Ref.set(activeTurn, null); steerInjections.clear(); if ( @@ -8195,6 +8214,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( new Map(current).set(requestedSessionId, initialNativeMetadata), ); } + const precedingRunTools = yield* Ref.getAndSet(finalizedRunTools, null); const context: ActiveAcpTurn = { input: turnInput, providerTurnId, @@ -8215,6 +8235,10 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( tools: new Map(), toolUpdatesSkipped: new Map(), toolStartedAt: new Map(), + precedingRunTools: + isContinuationTurn && precedingRunTools?.sessionId === requestedSessionId + ? precedingRunTools + : null, subagents: new Map(), subagentsBySessionId: new Map(), pendingSubagentNotifications: new Map(), From f5b4f8d57dcf27c53071c0f7a75a7008fb6b3258 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 11:00:14 +0000 Subject: [PATCH 09/12] fix(server): a Task finished two continuations later keeps its card's title Each finalized continuation now hands on the tool states it inherited along with its own, so a background Task launched in run 1 but completed in run 3 still merges with its launch state instead of becoming a bare "Tool". Co-Authored-By: Claude Opus 5.5 --- .../Adapters/AcpRegistryAdapterV2.test.ts | 65 ++++++++++++------- packages/provider-acp/src/server/adapter.ts | 7 +- 2 files changed, 47 insertions(+), 25 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts index d4ef81074eb5e..ec11d37b6cad6 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts @@ -2121,18 +2121,37 @@ describe("AcpRegistryAdapterV2", () => { Effect.gen(function* () { const { update } = claudeFrames; const gate = makeProviderReplayGate(["task-completed", "answer"]); + const launchTask = (toolCallId: string) => + update(CLAUDE_ROOT_SESSION, { + sessionUpdate: "tool_call", + toolCallId, + title: "Task", + kind: "other", + status: "completed", + rawInput: { subagent_type: "explorer", prompt: `sleep 20; echo ${toolCallId}` }, + rawOutput: { type: "Text", text: "Task launched in background" }, + }); + // A Task the previous run never saw must still merge with the run that launched it. + const assertTaskCard = ( + events: ReadonlyArray, + toolCallId: string, + ) => { + const task = events.findLast( + (event) => + event.type === "turn_item.updated" && + event.turnItem.nativeItemRef?.nativeId === toolCallId, + ); + if (task?.type !== "turn_item.updated") throw new Error(`Missing ${toolCallId} update`); + assert.include(task.turnItem, { title: "Task", status: "completed" }); + assert.deepInclude(task.turnItem, { + input: { subagent_type: "explorer", prompt: `sleep 20; echo ${toolCallId}` }, + }); + }; const session = yield* openClaudeReplaySession( [ ...droidFirstTurn( - update(CLAUDE_ROOT_SESSION, { - sessionUpdate: "tool_call", - toolCallId: "task-1", - title: "Task", - kind: "other", - status: "completed", - rawInput: { subagent_type: "explorer", prompt: "sleep 20; ls /tmp | head -3" }, - rawOutput: { type: "Text", text: "Task launched in background" }, - }), + launchTask("task-1"), + launchTask("task-2"), update(CLAUDE_ROOT_SESSION, { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "waiting" }, @@ -2150,12 +2169,18 @@ describe("AcpRegistryAdapterV2", () => { claudeMarker("task done"), { ...update(CLAUDE_ROOT_SESSION, { - sessionUpdate: "usage_update", - used: 2_000, - size: 10_000, + sessionUpdate: "tool_call_update", + toolCallId: "task-2", + status: "completed", + rawOutput: { type: "Text", text: "The second task exited with code 0." }, }), label: "answer", }, + update(CLAUDE_ROOT_SESSION, { + sessionUpdate: "usage_update", + used: 2_000, + size: 10_000, + }), update(CLAUDE_ROOT_SESSION, { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "The background task exited with code 0." }, @@ -2181,26 +2206,20 @@ describe("AcpRegistryAdapterV2", () => { yield* TestClock.adjust("3 seconds"); const taskRun = yield* Fiber.join(taskSettled); assert.include(taskRun.at(-1), { runOrdinal: 2, status: "completed" }); - const task = taskRun.findLast( - (event) => - event.type === "turn_item.updated" && event.turnItem.nativeItemRef?.nativeId === "task-1", - ); - if (task?.type !== "turn_item.updated") throw new Error("Missing Task update"); - assert.include(task.turnItem, { title: "Task", status: "completed" }); - assert.deepInclude(task.turnItem, { - input: { subagent_type: "explorer", prompt: "sleep 20; ls /tmp | head -3" }, - }); + assertTaskCard(taskRun, "task-1"); // Droid answers ~4 s after the Task result, past the quiet window. gate.release("answer"); yield* session.throughMarker("late answer"); assert.lengthOf(session.offers, 2, "Droid's late answer must offer another continuation"); yield* session.offers[1]!.dispatchIfCurrent!(Effect.void); yield* session.runtime.startTurn(session.turnInput(3, { continuation: true })); - const answer = (yield* session.until( + const answerRun = yield* session.until( (event) => event.type === "message.updated" && event.message.text === "The background task exited with code 0.", - )).at(-1); + ); + assertTaskCard(answerRun, "task-2"); + const answer = answerRun.at(-1); if (answer?.type !== "message.updated") throw new Error("Missing late answer"); assert.equal(answer.message.runId, session.turnInput(3).runId); const answerSettled = yield* Effect.forkScoped(session.terminal); diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index 92d475eedbf73..dad651f3c90cc 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -7808,8 +7808,11 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( if (postSettleContinuationEnabled && settledStatus === "completed") { yield* Ref.set(finalizedRunTools, { sessionId: context.nativeThreadId, - tools: context.tools, - toolStartedAt: context.toolStartedAt, + tools: new Map([...(context.precedingRunTools?.tools ?? []), ...context.tools]), + toolStartedAt: new Map([ + ...(context.precedingRunTools?.toolStartedAt ?? []), + ...context.toolStartedAt, + ]), }); } yield* Ref.set(activeTurn, null); From 6b4dbf3a1f09d8a2e128911213dfca17bfa1b213 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 11:08:45 +0000 Subject: [PATCH 10/12] fix(server): a question Droid asks after its run settled opens a continuation Requests from agents without ACP v2 work-state tracking were handled only inside an active turn, so a question or approval Droid sent after a continuation had settled failed with no turn to attach to. Agents with post-settle continuation now offer a continuation run and wait for it, as v2 agents already do. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/AcpRegistryAdapterV2.test.ts | 68 +++++++++++++++++++ packages/provider-acp/src/server/adapter.ts | 3 +- 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts index ec11d37b6cad6..67b87ee366ace 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts @@ -2397,6 +2397,74 @@ describe("AcpRegistryAdapterV2", () => { }).pipe(Effect.provide(layerTest), Effect.scoped), ); + it.effect("opens a continuation for a Factory Droid question asked after its run settled", () => + Effect.gen(function* () { + const gate = makeProviderReplayGate(["question"]); + const session = yield* openClaudeReplaySession( + [ + ...droidFirstTurn(), + { + type: "emit_inbound", + label: "question", + frame: { + kind: "request", + method: "elicitation/create", + params: { + sessionId: CLAUDE_ROOT_SESSION, + mode: "form", + message: "Which file?", + requestedSchema: { + type: "object", + properties: { + question_1: { + type: "string", + title: "File", + oneOf: [{ const: "option_0", title: "a.ts" }], + }, + }, + required: ["question_1"], + }, + }, + }, + }, + { + type: "expect_outbound", + frame: { + kind: "response", + method: "elicitation/create", + result: { action: "accept", content: { question_1: "option_0" } }, + }, + }, + claudeMarker("answered"), + claudeFrames.closeRequest, + claudeFrames.closed, + ], + gate, + true, + "factory-droid", + ); + yield* session.runtime.startTurn(session.turnInput(1)); + assert.equal((yield* session.terminal).status, "completed"); + gate.release("question"); + yield* Queue.take(session.offered); + yield* session.offers[0]!.dispatchIfCurrent!(Effect.void); + yield* session.runtime.startTurn(session.turnInput(2, { continuation: true })); + const pending = (yield* session.until( + (event) => + event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending", + )).at(-1); + if (pending?.type !== "runtime_request.updated") throw new Error("Missing question"); + yield* session.runtime.respondToRuntimeRequest({ + requestId: pending.runtimeRequest.id, + answers: { question_1: "option_0" }, + }); + yield* session.throughMarker("answered"); + const settled = yield* Effect.forkScoped(session.terminal); + yield* TestClock.adjust("3 seconds"); + assert.include(yield* Fiber.join(settled), { runOrdinal: 2, status: "completed" }); + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + it.effect.each(["error", "end_turn"])( "hands live Claude child tools to the next run after %s and keeps nested output in the child", (stopReason) => diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index dad651f3c90cc..bfa959fc93851 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -4970,7 +4970,8 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( }); const awaitRequestTurn = Effect.fnUntraced(function* (generation: number) { - if (!followsAgentWorkState) return true; + // A post-settle agent can ask after its run settled; the request opens a continuation. + if (!followsAgentWorkState && !postSettleContinuationEnabled) return true; while (true) { const waiting = yield* runRuntimeCallbackAtGeneration( generation, From 61f731081f797ab6c346cc5241b08a4e57bf044d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 11:27:44 +0000 Subject: [PATCH 11/12] fix(server): an approval Droid requests after its run settled opens a continuation The permission handler still looked up the active turn directly for agents without ACP v2 work-state tracking, so an approval Droid sent after its run settled failed instead of reaching the user. It now uses the same admission as questions; automatic decisions still answer without opening a run. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/AcpRegistryAdapterV2.test.ts | 105 ++++++++++++------ packages/provider-acp/src/server/adapter.ts | 11 +- 2 files changed, 75 insertions(+), 41 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts index 67b87ee366ace..3cd728d4f7d94 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts @@ -2397,44 +2397,70 @@ describe("AcpRegistryAdapterV2", () => { }).pipe(Effect.provide(layerTest), Effect.scoped), ); - it.effect("opens a continuation for a Factory Droid question asked after its run settled", () => + const permissionRequest = (toolCallId: string, kind: string) => ({ + kind: "request", + method: "session/request_permission", + params: { + sessionId: CLAUDE_ROOT_SESSION, + toolCall: { toolCallId, title: toolCallId, kind, status: "pending", rawInput: {} }, + options: [{ optionId: "allow", name: "Allow", kind: "allow_once" }], + }, + }); + it.effect.each([ + { + request: "a question", + frame: { + kind: "request", + method: "elicitation/create", + params: { + sessionId: CLAUDE_ROOT_SESSION, + mode: "form", + message: "Which file?", + requestedSchema: { + type: "object", + properties: { + question_1: { + type: "string", + title: "File", + oneOf: [{ const: "option_0", title: "a.ts" }], + }, + }, + required: ["question_1"], + }, + }, + }, + reply: { + method: "elicitation/create", + result: { action: "accept", content: { question_1: "option_0" } }, + }, + response: { answers: { question_1: "option_0" } }, + }, + { + request: "an approval", + frame: permissionRequest("run-tests", "execute"), + reply: { + method: "session/request_permission", + result: { outcome: { outcome: "selected", optionId: "allow" } }, + }, + response: { decision: "accept" as const }, + }, + { + request: "an auto-approved read", + frame: permissionRequest("read-file", "read"), + reply: { + method: "session/request_permission", + result: { outcome: { outcome: "selected", optionId: "allow" } }, + }, + response: undefined, + }, + ])("admits $request Factory Droid sends after its run settled", ({ frame, reply, response }) => Effect.gen(function* () { - const gate = makeProviderReplayGate(["question"]); + const gate = makeProviderReplayGate(["request"]); const session = yield* openClaudeReplaySession( [ ...droidFirstTurn(), - { - type: "emit_inbound", - label: "question", - frame: { - kind: "request", - method: "elicitation/create", - params: { - sessionId: CLAUDE_ROOT_SESSION, - mode: "form", - message: "Which file?", - requestedSchema: { - type: "object", - properties: { - question_1: { - type: "string", - title: "File", - oneOf: [{ const: "option_0", title: "a.ts" }], - }, - }, - required: ["question_1"], - }, - }, - }, - }, - { - type: "expect_outbound", - frame: { - kind: "response", - method: "elicitation/create", - result: { action: "accept", content: { question_1: "option_0" } }, - }, - }, + { type: "emit_inbound", label: "request", frame }, + { type: "expect_outbound", frame: { kind: "response", ...reply } }, claudeMarker("answered"), claudeFrames.closeRequest, claudeFrames.closed, @@ -2445,7 +2471,12 @@ describe("AcpRegistryAdapterV2", () => { ); yield* session.runtime.startTurn(session.turnInput(1)); assert.equal((yield* session.terminal).status, "completed"); - gate.release("question"); + gate.release("request"); + if (response === undefined) { + yield* session.throughMarker("answered"); + assert.lengthOf(session.offers, 0, "an automatic decision needs no continuation"); + return; + } yield* Queue.take(session.offered); yield* session.offers[0]!.dispatchIfCurrent!(Effect.void); yield* session.runtime.startTurn(session.turnInput(2, { continuation: true })); @@ -2453,10 +2484,10 @@ describe("AcpRegistryAdapterV2", () => { (event) => event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending", )).at(-1); - if (pending?.type !== "runtime_request.updated") throw new Error("Missing question"); + if (pending?.type !== "runtime_request.updated") throw new Error("Missing request"); yield* session.runtime.respondToRuntimeRequest({ requestId: pending.runtimeRequest.id, - answers: { question_1: "option_0" }, + ...response, }); yield* session.throughMarker("answered"); const settled = yield* Effect.forkScoped(session.terminal); diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index bfa959fc93851..dda573d1d1dfc 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -4969,9 +4969,12 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( ); }); + // A post-settle agent can ask after its run settled; the request opens a continuation. + const admitsRequestsOutsideTurn = () => + followsAgentWorkState || postSettleContinuationEnabled; + const awaitRequestTurn = Effect.fnUntraced(function* (generation: number) { - // A post-settle agent can ask after its run settled; the request opens a continuation. - if (!followsAgentWorkState && !postSettleContinuationEnabled) return true; + if (!admitsRequestsOutsideTurn()) return true; while (true) { const waiting = yield* runRuntimeCallbackAtGeneration( generation, @@ -6569,14 +6572,14 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( policy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, request: EffectAcpSchema.RequestPermissionRequest = params, ) => (flavor.permissionDisposition ?? acpPermissionDisposition)(policy, request); - if (followsAgentWorkState && dispositionFor(latestRuntimePolicy) === "ask") { + if (admitsRequestsOutsideTurn() && dispositionFor(latestRuntimePolicy) === "ask") { if (!(yield* awaitRequestTurn(handlerGeneration))) return { outcome: { outcome: "cancelled" } } as const; } const admitted = yield* runRuntimeCallbackAtGeneration( handlerGeneration, Effect.gen(function* () { - const context = followsAgentWorkState + const context = admitsRequestsOutsideTurn() ? yield* Ref.get(activeTurn) : yield* activeContext; // Kiro sends the tool's kind on its `tool_call` and leaves it From 61184e56fef2c92ec8c06f02a2e8cbe5f0081b13 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 11:30:05 +0000 Subject: [PATCH 12/12] refactor(server): drop a finalize rearm no deferred-finalize agent reaches openAgentRequests only fills for agents with a prompt inactivity timeout, and none of those defer their finalize. Co-Authored-By: Claude Opus 5.5 --- packages/provider-acp/src/server/adapter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index dda573d1d1dfc..9425df837216c 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -2316,7 +2316,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( const closeAgentRequest = (agentRequests: Set, requestId: string) => Effect.suspend(() => agentRequests.delete(requestId) && agentRequests === openAgentRequests - ? notePromptProgress().pipe(Effect.andThen(rearmActiveDeferredFinalize)) + ? notePromptProgress() : Effect.void, ); const handleRuntimeTerminationAtGeneration = (runtimeGeneration: number) =>