From 0d70bfe9f4e691e91ed39cd24e444ead44bf0c14 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sun, 1 Nov 2026 16:39:40 +0000 Subject: [PATCH 1/7] fix(acp): refused and limit-stopped ACP turns no longer show as completed ACP agents report refusal, max_tokens and max_turn_requests as stop reasons, but the adapter recorded all three as a completed turn. The prompt-response, idle state_update and native child mappings now fail the turn with a provider failure naming the reason, so the user sees the refusal or limit instead of an apparently successful turn. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/AcpAdapterV2.test.ts | 66 +++++++++++++++++++ .../Adapters/AcpRegistryAdapterV2.test.ts | 29 ++++++++ packages/provider-acp/src/server/adapter.ts | 56 ++++++++++++---- 3 files changed, 138 insertions(+), 13 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index bb84c1a2c749..228a62c17aae 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -1158,6 +1158,72 @@ describe("AcpAdapterV2", () => { }).pipe(Effect.provide(layerTest), Effect.scoped), ); + it.live.each([ + { stopReason: "refusal", message: "The agent refused this request." }, + { stopReason: "max_tokens", message: "output token limit" }, + { stopReason: "max_turn_requests", message: "request limit" }, + ] as const)("fails a prompt that stops with $stopReason", ({ stopReason, message }) => + Effect.gen(function* () { + const path = yield* Path.Path; + const instanceId = ProviderInstanceId.make(`acp-test-stop-${stopReason}`); + const threadId = ThreadId.make(`thread-acp-stop-${stopReason}`); + const adapter = yield* makeAcpAdapterV2({ + instanceId, + selfInvocation: yield* resolveSelfInvocation(), + flavor: { + driver: ACP_TEST_DRIVER, + capabilities: AcpProviderCapabilitiesV2, + makeRuntime: makeMockRuntime({ + childProcessSpawner: yield* ChildProcessSpawner.ChildProcessSpawner, + mockAgentPath: yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ), + wrapRuntime: (runtime) => ({ + ...runtime, + prompt: () => Effect.succeed({ stopReason }), + }), + }), + }, + }); + 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-stop-${stopReason}`), + modelSelection, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + yield* runtime.startTurn( + makeTurnInput({ + threadId, + providerThread, + instanceId, + runtimePolicy, + now: yield* DateTime.now, + }), + ); + const terminal = yield* runtime.events.pipe( + Stream.filter((event) => event.type === "turn.terminal"), + Stream.runHead, + ); + if (Option.isNone(terminal) || terminal.value.type !== "turn.terminal") { + return assert.fail("expected a turn terminal"); + } + assert.equal(terminal.value.status, "failed"); + assert.equal(terminal.value.failure?.code, stopReason); + assert.include(terminal.value.failure?.message ?? "", message); + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + it.live("keeps usage unavailable for undeclared agents even when they report usage", () => Effect.gen(function* () { const path = yield* Path.Path; diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts index fa5b15bbc5fd..2aa7628e6585 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts @@ -2052,6 +2052,35 @@ describe("AcpRegistryAdapterV2", () => { }).pipe(Effect.provide(layerTest), Effect.scoped), ); + it.effect.each([ + { stopReason: "refusal", message: "The agent refused this request." }, + { stopReason: "max_tokens", message: "output token limit" }, + { stopReason: "max_turn_requests", message: "request limit" }, + ])("fails Claude's turn and child when they stop with $stopReason", ({ stopReason, message }) => + Effect.gen(function* () { + const { update, idle, prompt } = claudeFrames; + const session = yield* openClaudeReplaySession([ + ...claudeFrames.open, + ...prompt(1), + ...claudeChild, + update("child-1", { sessionUpdate: "state_update", state: "running" }), + update("child-1", { sessionUpdate: "state_update", state: "idle", stopReason }), + idle(stopReason), + ]); + yield* session.runtime.startTurn(session.turnInput(1)); + const events = yield* session.until((event) => event.type === "turn.terminal"); + const terminal = events.at(-1)!; + if (terminal.type !== "turn.terminal") throw new Error("Missing root terminal"); + assert.equal(terminal.status, "failed"); + assert.equal(terminal.failure?.code, stopReason); + assert.include(terminal.failure?.message ?? "", message); + const child = events.findLast((event) => event.type === "subagent.updated"); + if (child?.type !== "subagent.updated") throw new Error("Missing child update"); + assert.equal(child.subagent.status, "failed"); + assert.include(child.subagent.result ?? "", message); + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + it.effect("keeps Claude activity after a refused Stop and closes it on retry", () => Effect.gen(function* () { const gate = makeProviderReplayGate(["close-response", "queued-close-response"]); diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index bc34b9fd65ff..730bc357cde0 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -764,6 +764,20 @@ function makeProviderThread(input: { }; } +/** Stop reasons where the agent ended without finishing. ACP asks clients to show refusals. */ +function acpUnfinishedStopMessage(stopReason: string | null | undefined): string | undefined { + switch (stopReason) { + case "refusal": + return "The agent refused this request. The agent's next turn won't include this message or its reply."; + case "max_tokens": + return "The agent stopped because its reply reached the output token limit."; + case "max_turn_requests": + return "The agent stopped because it reached its request limit for this turn."; + default: + return undefined; + } +} + function acpMcpToolCallOutput(rawOutput: unknown): unknown { const record = unknownRecord(rawOutput); if (record === undefined) return rawOutput; @@ -3336,16 +3350,18 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( const update = notification.update; if (update.sessionUpdate === "state_update") { const child = nativeChild(notification.sessionId); + const unfinished = + update.state === "idle" ? acpUnfinishedStopMessage(update.stopReason) : undefined; setNativeChildState( child, update.state !== "idle" ? "running" - : update.stopReason === "error" + : update.stopReason === "error" || unfinished !== undefined ? "failed" : update.stopReason === "cancelled" ? "cancelled" : "completed", - update.state === "idle" ? (update.error?.message ?? null) : null, + update.state === "idle" ? (update.error?.message ?? unfinished ?? null) : null, ); } if (context !== null) yield* syncNativeChildren(context); @@ -4540,6 +4556,17 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( class: "provider_error", }); + const stopReasonFailure = ( + stopReason: string | null | undefined, + error: EffectAcpSchema.PromptResponse["error"], + ) => { + if (stopReason === "error") return idleErrorFailure(error); + const message = acpUnfinishedStopMessage(stopReason); + return message === undefined + ? undefined + : makeProviderFailure({ message, code: stopReason, class: "provider_error" }); + }; + const settleIdleWork = Effect.fnUntraced(function* ( context: ActiveAcpTurn, ): Effect.fn.Return { @@ -4556,7 +4583,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( if (work.stopReason === "cancelled" && pendingStopCloseTurn === context) { context.interrupted = true; } - const failure = work.stopReason === "error" ? idleErrorFailure(work.error) : undefined; + const failure = stopReasonFailure(work.stopReason, work.error); yield* finalizeTurn( context, failure !== undefined @@ -7793,14 +7820,17 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( Effect.gen(function* () { if (context.finalized || followsAgentWorkState) return; // ACP v2 reports a failed prompt as an idle `error`, not an RPC error. - const idleError = result.stopReason === "error" && !context.interrupted; - const status = idleError - ? "failed" - : result.stopReason === "cancelled" - ? context.interrupted - ? "interrupted" - : "cancelled" - : "completed"; + const failure = context.interrupted + ? undefined + : stopReasonFailure(result.stopReason, result.error); + const status = + failure !== undefined + ? "failed" + : result.stopReason === "cancelled" + ? context.interrupted + ? "interrupted" + : "cancelled" + : "completed"; if (flavor.deferFinalizeForBackgroundWork === true && !context.interrupted) { context.promptSettled = true; context.promptSettledStatus = status; @@ -7809,11 +7839,11 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( yield* finishRegisteredBackgroundTool({ taskId, ...completion }); } } - if (idleError) { + if (failure !== undefined) { yield* terminalizeOpenForegroundTools(context, "failed"); // Unlike an RPC failure, the agent survives an idle error, so its // background tools and subagents keep reporting to this run. - yield* finalizeTurn(context, "failed", idleErrorFailure(result.error), { + yield* finalizeTurn(context, "failed", failure, { childTurnsContinue: hasSurvivingBackgroundWork(context), }); return; From e9e97c279c2d73b6d3ff1d761646798ab847912d Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sun, 1 Nov 2026 17:02:22 +0000 Subject: [PATCH 2/7] fix(acp): Claude child sessions report refusal and limit stops too Claude Agent also reports a child's stop through its subagent_update extension, which still mapped refusal, max_tokens and max_turn_requests to completed. Reuse the shared stop message there, type the helper with ACP's StopReason, and assert stop codes rather than copy text in tests. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/AcpAdapterV2.test.ts | 119 +++++++++--------- .../Adapters/AcpRegistryAdapterV2.test.ts | 60 +++++---- .../src/server/claudeAcp.ts | 12 +- packages/provider-acp/src/server/adapter.ts | 14 ++- 4 files changed, 107 insertions(+), 98 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index 228a62c17aae..c6386e19e70e 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -1158,70 +1158,67 @@ describe("AcpAdapterV2", () => { }).pipe(Effect.provide(layerTest), Effect.scoped), ); - it.live.each([ - { stopReason: "refusal", message: "The agent refused this request." }, - { stopReason: "max_tokens", message: "output token limit" }, - { stopReason: "max_turn_requests", message: "request limit" }, - ] as const)("fails a prompt that stops with $stopReason", ({ stopReason, message }) => - Effect.gen(function* () { - const path = yield* Path.Path; - const instanceId = ProviderInstanceId.make(`acp-test-stop-${stopReason}`); - const threadId = ThreadId.make(`thread-acp-stop-${stopReason}`); - const adapter = yield* makeAcpAdapterV2({ - instanceId, - selfInvocation: yield* resolveSelfInvocation(), - flavor: { - driver: ACP_TEST_DRIVER, - capabilities: AcpProviderCapabilitiesV2, - makeRuntime: makeMockRuntime({ - childProcessSpawner: yield* ChildProcessSpawner.ChildProcessSpawner, - mockAgentPath: yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ), - wrapRuntime: (runtime) => ({ - ...runtime, - prompt: () => Effect.succeed({ stopReason }), + it.live.each(["refusal", "max_tokens", "max_turn_requests"] as const)( + "fails a prompt that stops with %s", + (stopReason) => + Effect.gen(function* () { + const path = yield* Path.Path; + const instanceId = ProviderInstanceId.make(`acp-test-stop-${stopReason}`); + const threadId = ThreadId.make(`thread-acp-stop-${stopReason}`); + const adapter = yield* makeAcpAdapterV2({ + instanceId, + selfInvocation: yield* resolveSelfInvocation(), + flavor: { + driver: ACP_TEST_DRIVER, + capabilities: AcpProviderCapabilitiesV2, + makeRuntime: makeMockRuntime({ + childProcessSpawner: yield* ChildProcessSpawner.ChildProcessSpawner, + mockAgentPath: yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ), + wrapRuntime: (runtime) => ({ + ...runtime, + prompt: () => Effect.succeed({ stopReason }), + }), }), - }), - }, - }); - 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-stop-${stopReason}`), - modelSelection, - runtimePolicy, - }); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy, - }); - yield* runtime.startTurn( - makeTurnInput({ + }, + }); + 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, - providerThread, - instanceId, + providerSessionId: ProviderSessionId.make(`provider-session-acp-stop-${stopReason}`), + modelSelection, runtimePolicy, - now: yield* DateTime.now, - }), - ); - const terminal = yield* runtime.events.pipe( - Stream.filter((event) => event.type === "turn.terminal"), - Stream.runHead, - ); - if (Option.isNone(terminal) || terminal.value.type !== "turn.terminal") { - return assert.fail("expected a turn terminal"); - } - assert.equal(terminal.value.status, "failed"); - assert.equal(terminal.value.failure?.code, stopReason); - assert.include(terminal.value.failure?.message ?? "", message); - }).pipe(Effect.provide(layerTest), Effect.scoped), + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + yield* runtime.startTurn( + makeTurnInput({ + threadId, + providerThread, + instanceId, + runtimePolicy, + now: yield* DateTime.now, + }), + ); + const terminal = yield* runtime.events.pipe( + Stream.filter((event) => event.type === "turn.terminal"), + Stream.runHead, + ); + if (Option.isNone(terminal) || terminal.value.type !== "turn.terminal") { + return assert.fail("expected a turn terminal"); + } + assert.equal(terminal.value.status, "failed"); + assert.equal(terminal.value.failure?.code, stopReason); + }).pipe(Effect.provide(layerTest), Effect.scoped), ); it.live("keeps usage unavailable for undeclared agents even when they report usage", () => diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts index 2aa7628e6585..e7605af9155e 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts @@ -2053,32 +2053,40 @@ describe("AcpRegistryAdapterV2", () => { ); it.effect.each([ - { stopReason: "refusal", message: "The agent refused this request." }, - { stopReason: "max_tokens", message: "output token limit" }, - { stopReason: "max_turn_requests", message: "request limit" }, - ])("fails Claude's turn and child when they stop with $stopReason", ({ stopReason, message }) => - Effect.gen(function* () { - const { update, idle, prompt } = claudeFrames; - const session = yield* openClaudeReplaySession([ - ...claudeFrames.open, - ...prompt(1), - ...claudeChild, - update("child-1", { sessionUpdate: "state_update", state: "running" }), - update("child-1", { sessionUpdate: "state_update", state: "idle", stopReason }), - idle(stopReason), - ]); - yield* session.runtime.startTurn(session.turnInput(1)); - const events = yield* session.until((event) => event.type === "turn.terminal"); - const terminal = events.at(-1)!; - if (terminal.type !== "turn.terminal") throw new Error("Missing root terminal"); - assert.equal(terminal.status, "failed"); - assert.equal(terminal.failure?.code, stopReason); - assert.include(terminal.failure?.message ?? "", message); - const child = events.findLast((event) => event.type === "subagent.updated"); - if (child?.type !== "subagent.updated") throw new Error("Missing child update"); - assert.equal(child.subagent.status, "failed"); - assert.include(child.subagent.result ?? "", message); - }).pipe(Effect.provide(layerTest), Effect.scoped), + { stopReason: "refusal", childUpdate: "state_update" }, + { stopReason: "refusal", childUpdate: "subagent_update" }, + { stopReason: "max_tokens", childUpdate: "state_update" }, + { stopReason: "max_turn_requests", childUpdate: "subagent_update" }, + ])( + "fails Claude's turn and child when they stop with $stopReason via $childUpdate", + ({ stopReason, childUpdate }) => + Effect.gen(function* () { + const { update, idle, prompt } = claudeFrames; + const session = yield* openClaudeReplaySession([ + ...claudeFrames.open, + ...prompt(1), + ...claudeChild, + update("child-1", { sessionUpdate: "state_update", state: "running" }), + childUpdate === "state_update" + ? update("child-1", { sessionUpdate: "state_update", state: "idle", stopReason }) + : update(CLAUDE_ROOT_SESSION, { + sessionUpdate: "subagent_update", + sessionId: "child-1", + state: { state: "idle", stopReason }, + }), + idle(stopReason), + ]); + yield* session.runtime.startTurn(session.turnInput(1)); + const events = yield* session.until((event) => event.type === "turn.terminal"); + const terminal = events.at(-1)!; + if (terminal.type !== "turn.terminal") throw new Error("Missing root terminal"); + assert.equal(terminal.status, "failed"); + assert.equal(terminal.failure?.code, stopReason); + const child = events.findLast((event) => event.type === "subagent.updated"); + if (child?.type !== "subagent.updated") throw new Error("Missing child update"); + assert.equal(child.subagent.status, "failed"); + assert.isNotNull(child.subagent.result, "the child shows why it stopped"); + }).pipe(Effect.provide(layerTest), Effect.scoped), ); it.effect("keeps Claude activity after a refused Stop and closes it on retry", () => diff --git a/packages/provider-acp-registry/src/server/claudeAcp.ts b/packages/provider-acp-registry/src/server/claudeAcp.ts index 4c875ce597f5..7804a323accc 100644 --- a/packages/provider-acp-registry/src/server/claudeAcp.ts +++ b/packages/provider-acp-registry/src/server/claudeAcp.ts @@ -2,9 +2,10 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import type * as AcpSchema from "effect-acp/compat"; import type { AcpToolCallState } from "@t3tools/provider-acp/server/runtimeModel"; -import type { - AcpAdapterV2SubagentUpdate, - AcpSubagentSessionEvent, +import { + acpUnfinishedStopMessage, + type AcpAdapterV2SubagentUpdate, + type AcpSubagentSessionEvent, } from "@t3tools/provider-acp/server/adapter"; const decodeAgentInput = Schema.decodeUnknownOption( @@ -86,6 +87,7 @@ export function extractClaudeSubagentSessionEvent( const child = decodeChildState(update.raw); if (Option.isNone(child)) return; const { sessionId, state } = child.value; + const unfinishedMessage = acpUnfinishedStopMessage(state.stopReason); return { type: "state", childSessionId: sessionId, @@ -93,12 +95,12 @@ export function extractClaudeSubagentSessionEvent( status: state.state === "running" || state.state === "requires_action" ? "running" - : state.stopReason === "error" + : state.stopReason === "error" || unfinishedMessage !== undefined ? "failed" : state.stopReason === "cancelled" || state.state === "unknown" ? "cancelled" : "completed", - result: state.error?.message ?? null, + result: state.error?.message ?? unfinishedMessage ?? null, }; } if (update.originalSessionUpdate === "session_message") { diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index 730bc357cde0..6012e6fc7fd3 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -765,7 +765,9 @@ function makeProviderThread(input: { } /** Stop reasons where the agent ended without finishing. ACP asks clients to show refusals. */ -function acpUnfinishedStopMessage(stopReason: string | null | undefined): string | undefined { +export function acpUnfinishedStopMessage( + stopReason: EffectAcpSchema.StopReason | null | undefined, +): string | undefined { switch (stopReason) { case "refusal": return "The agent refused this request. The agent's next turn won't include this message or its reply."; @@ -3350,18 +3352,18 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( const update = notification.update; if (update.sessionUpdate === "state_update") { const child = nativeChild(notification.sessionId); - const unfinished = + const unfinishedMessage = update.state === "idle" ? acpUnfinishedStopMessage(update.stopReason) : undefined; setNativeChildState( child, update.state !== "idle" ? "running" - : update.stopReason === "error" || unfinished !== undefined + : update.stopReason === "error" || unfinishedMessage !== undefined ? "failed" : update.stopReason === "cancelled" ? "cancelled" : "completed", - update.state === "idle" ? (update.error?.message ?? unfinished ?? null) : null, + update.state === "idle" ? (update.error?.message ?? unfinishedMessage ?? null) : null, ); } if (context !== null) yield* syncNativeChildren(context); @@ -4557,7 +4559,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( }); const stopReasonFailure = ( - stopReason: string | null | undefined, + stopReason: EffectAcpSchema.StopReason | null | undefined, error: EffectAcpSchema.PromptResponse["error"], ) => { if (stopReason === "error") return idleErrorFailure(error); @@ -7819,7 +7821,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( promptGeneration, Effect.gen(function* () { if (context.finalized || followsAgentWorkState) return; - // ACP v2 reports a failed prompt as an idle `error`, not an RPC error. + // ACP v2 reports a failed prompt through its idle stop reason, not an RPC error. const failure = context.interrupted ? undefined : stopReasonFailure(result.stopReason, result.error); From a3eeb247377937b376d2f5333fe758b6823884c0 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sun, 8 Nov 2026 17:44:06 +0000 Subject: [PATCH 3/7] fix(acp): refused and limit-stopped Claude turns close their running tools Claude reports the end of a turn through its idle state. Refusal and limit stops now settle that turn as failed, but only completed turns closed their running foreground tools, so those tools stayed running after the turn ended. Close them as failed first, as the prompt-response path already does. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/AcpRegistryAdapterV2.test.ts | 17 ++++++++++++++++- packages/provider-acp/src/server/adapter.ts | 3 +++ 2 files changed, 19 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 e7605af9155e..2bd40419934f 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts @@ -2058,7 +2058,7 @@ describe("AcpRegistryAdapterV2", () => { { stopReason: "max_tokens", childUpdate: "state_update" }, { stopReason: "max_turn_requests", childUpdate: "subagent_update" }, ])( - "fails Claude's turn and child when they stop with $stopReason via $childUpdate", + "fails Claude's turn, child and running tool when they stop with $stopReason via $childUpdate", ({ stopReason, childUpdate }) => Effect.gen(function* () { const { update, idle, prompt } = claudeFrames; @@ -2066,6 +2066,15 @@ describe("AcpRegistryAdapterV2", () => { ...claudeFrames.open, ...prompt(1), ...claudeChild, + update(CLAUDE_ROOT_SESSION, { + sessionUpdate: "tool_call_update", + toolCallId: "root-tool", + name: "Bash", + title: "Read package", + kind: "execute", + status: "in_progress", + rawInput: { command: "cat package.json" }, + }), update("child-1", { sessionUpdate: "state_update", state: "running" }), childUpdate === "state_update" ? update("child-1", { sessionUpdate: "state_update", state: "idle", stopReason }) @@ -2086,6 +2095,12 @@ describe("AcpRegistryAdapterV2", () => { if (child?.type !== "subagent.updated") throw new Error("Missing child update"); assert.equal(child.subagent.status, "failed"); assert.isNotNull(child.subagent.result, "the child shows why it stopped"); + const tool = events.findLast( + (event) => + event.type === "turn_item.updated" && event.turnItem.type === "command_execution", + ); + if (tool?.type !== "turn_item.updated") throw new Error("Missing tool update"); + assert.equal(tool.turnItem.status, "failed"); }).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 6012e6fc7fd3..4f67f0f73f81 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -4586,6 +4586,9 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( context.interrupted = true; } const failure = stopReasonFailure(work.stopReason, work.error); + if (failure !== undefined && !context.interrupted) { + yield* terminalizeOpenForegroundTools(context, "failed"); + } yield* finalizeTurn( context, failure !== undefined From 1925f0323d610d5edb0cbd3e412a2d3796401ddc Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sun, 8 Nov 2026 17:51:40 +0000 Subject: [PATCH 4/7] refactor(acp): settle Claude's idle failure the same way as a prompt failure An interrupted turn gets no stop-reason failure on either path, so the running-tool close reads the same in both. The extended test keeps its original name prefix. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/AcpRegistryAdapterV2.test.ts | 2 +- packages/provider-acp/src/server/adapter.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts index 1921de923520..ceac330bfa7e 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts @@ -2465,7 +2465,7 @@ describe("AcpRegistryAdapterV2", () => { { stopReason: "max_tokens", childUpdate: "state_update" }, { stopReason: "max_turn_requests", childUpdate: "subagent_update" }, ])( - "fails Claude's turn, child and running tool when they stop with $stopReason via $childUpdate", + "fails Claude's turn and child and closes its running tool when they stop with $stopReason via $childUpdate", ({ stopReason, childUpdate }) => Effect.gen(function* () { const { update, idle, prompt } = claudeFrames; diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index 888582344efb..b8a4e2188f8b 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -4938,10 +4938,10 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( if (work.stopReason === "cancelled" && pendingStopCloseTurn === context) { context.interrupted = true; } - const failure = stopReasonFailure(work.stopReason, work.error); - if (failure !== undefined && !context.interrupted) { - yield* terminalizeOpenForegroundTools(context, "failed"); - } + const failure = context.interrupted + ? undefined + : stopReasonFailure(work.stopReason, work.error); + if (failure !== undefined) yield* terminalizeOpenForegroundTools(context, "failed"); yield* finalizeTurn( context, failure !== undefined From 9f1670c319096b54084e6967330acd73847ed629 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sun, 8 Nov 2026 18:24:05 +0000 Subject: [PATCH 5/7] fix(acp): a failed subagent's stop explanation no longer appears as its reply A child that failed without sending text had its result (an error or the stop explanation) emitted as its own assistant message. The result stays on the subagent, where clients already show it as the failure reason. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/AcpRegistryAdapterV2.test.ts | 9 +++++++++ packages/provider-acp/src/server/adapter.ts | 2 ++ 2 files changed, 11 insertions(+) diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts index ceac330bfa7e..5972581d8670 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts @@ -2502,6 +2502,15 @@ describe("AcpRegistryAdapterV2", () => { if (child?.type !== "subagent.updated") throw new Error("Missing child update"); assert.equal(child.subagent.status, "failed"); assert.isNotNull(child.subagent.result, "the child shows why it stopped"); + assert.isFalse( + events.some( + (event) => + event.type === "message.updated" && + event.message.threadId === child.subagent.childThreadId && + event.message.role === "assistant", + ), + "the child's conversation has no reply it didn't send", + ); const tool = events.findLast( (event) => event.type === "turn_item.updated" && event.turnItem.type === "command_execution", diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index b8a4e2188f8b..8a7f5545859f 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -3329,8 +3329,10 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( ); } + // A failed child's result explains the failure; it is not the child's reply. if ( taskStatus !== "running" && + reported.status !== "failed" && subagent.assistantText.length === 0 && update.result !== null ) { From 9976c11c62b11b087d8100264279a522fe7a91e1 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 13 Nov 2026 04:14:45 +0000 Subject: [PATCH 6/7] test(acp): the reinjection case expects a refused prompt to end failed Main's #26959 test expected a refused prompt to end completed, which is the behavior this PR changes. --- .../src/orchestration-v2/Adapters/AcpAdapterV2.test.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index 5e4560c1679c..036afc6b5a1b 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -2488,12 +2488,7 @@ describe("AcpAdapterV2", () => { }, stopReason === "error", ); - const expectedStatus = - stopReason === "error" - ? "failed" - : stopReason === "cancelled" - ? "cancelled" - : "completed"; + const expectedStatus = stopReason === "cancelled" ? "cancelled" : "failed"; yield* reportNextPromptStopped(stopReason); assertReinjectedInstructions(yield* runTurn("Start work.", expectedStatus), "Start work."); assertReinjectedInstructions( From d394e753ff69272d1fc51a5d3cf06f068250442d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 13:07:56 +0000 Subject: [PATCH 7/7] test(acp): keep one stop-reason case per code path The reinjection table already fails a refused prompt through the generic adapter, and the Claude child table only needs one row per update kind. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/AcpAdapterV2.test.ts | 63 ------------------- .../Adapters/AcpRegistryAdapterV2.test.ts | 4 +- 2 files changed, 1 insertion(+), 66 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index fb78feccf9e1..88505e9749d6 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -1640,69 +1640,6 @@ describe("AcpAdapterV2", () => { }).pipe(Effect.provide(layerTest)), ); - it.live.each(["refusal", "max_tokens", "max_turn_requests"] as const)( - "fails a prompt that stops with %s", - (stopReason) => - Effect.gen(function* () { - const path = yield* Path.Path; - const instanceId = ProviderInstanceId.make(`acp-test-stop-${stopReason}`); - const threadId = ThreadId.make(`thread-acp-stop-${stopReason}`); - const adapter = yield* makeAcpAdapterV2({ - instanceId, - selfInvocation: yield* resolveSelfInvocation(), - flavor: { - driver: ACP_TEST_DRIVER, - capabilities: AcpProviderCapabilitiesV2, - makeRuntime: makeMockRuntime({ - childProcessSpawner: yield* ChildProcessSpawner.ChildProcessSpawner, - mockAgentPath: yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ), - wrapRuntime: (runtime) => ({ - ...runtime, - prompt: () => Effect.succeed({ stopReason }), - }), - }), - }, - }); - 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-stop-${stopReason}`), - modelSelection, - runtimePolicy, - }); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy, - }); - yield* runtime.startTurn( - makeTurnInput({ - threadId, - providerThread, - instanceId, - runtimePolicy, - now: yield* DateTime.now, - }), - ); - const terminal = yield* runtime.events.pipe( - Stream.filter((event) => event.type === "turn.terminal"), - Stream.runHead, - ); - if (Option.isNone(terminal) || terminal.value.type !== "turn.terminal") { - return assert.fail("expected a turn terminal"); - } - assert.equal(terminal.value.status, "failed"); - assert.equal(terminal.value.failure?.code, stopReason); - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); - it.live("keeps usage unavailable for undeclared agents even when they report usage", () => Effect.gen(function* () { const instanceId = ProviderInstanceId.make("acp-test-usage-undeclared"); diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts index a54f56ad37ff..d0014aa6574d 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts @@ -2757,9 +2757,7 @@ describe("AcpRegistryAdapterV2", () => { it.effect.each([ { stopReason: "refusal", childUpdate: "state_update" }, - { stopReason: "refusal", childUpdate: "subagent_update" }, - { stopReason: "max_tokens", childUpdate: "state_update" }, - { stopReason: "max_turn_requests", childUpdate: "subagent_update" }, + { stopReason: "max_tokens", childUpdate: "subagent_update" }, ])( "fails Claude's turn and child and closes its running tool when they stop with $stopReason via $childUpdate", ({ stopReason, childUpdate }) =>