From 0d70bfe9f4e691e91ed39cd24e444ead44bf0c14 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sun, 1 Nov 2026 16:39:40 +0000 Subject: [PATCH 1/2] 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 bb84c1a2c7492..228a62c17aae3 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 fa5b15bbc5fdf..2aa7628e65857 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 bc34b9fd65ff6..730bc357cde01 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/2] 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 228a62c17aae3..c6386e19e70e5 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 2aa7628e65857..e7605af9155e8 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 4c875ce597f57..7804a323accca 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 730bc357cde01..6012e6fc7fd37 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);