diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index b7808ef897ee..88505e9749d6 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -2456,12 +2456,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( diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts index 70f45bc8a46c..d0014aa6574d 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts @@ -2755,6 +2755,65 @@ describe("AcpRegistryAdapterV2", () => { }).pipe(Effect.provide(layerTest)), ); + it.effect.each([ + { stopReason: "refusal", childUpdate: "state_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 }) => + Effect.gen(function* () { + const { update, idle, prompt } = claudeFrames; + const session = yield* openClaudeReplaySession([ + ...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 }) + : 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"); + 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", + ); + if (tool?.type !== "turn_item.updated") throw new Error("Missing tool update"); + assert.equal(tool.turnItem.status, "failed"); + }).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-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 332c4bee95ec..77c68669eea0 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -1021,6 +1021,22 @@ function makeProviderThread(input: { }; } +/** Stop reasons where the agent ended without finishing. ACP asks clients to show refusals. */ +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."; + 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 appendToolContentText(content: unknown, text: string): unknown[] { const next: unknown[] = Array.isArray(content) ? [...content] : []; const last = unknownRecord(next.at(-1)); @@ -3441,8 +3457,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 ) { @@ -3812,16 +3830,18 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( const update = notification.update; if (update.sessionUpdate === "state_update") { const child = nativeChild(notification.sessionId); + const unfinishedMessage = + update.state === "idle" ? acpUnfinishedStopMessage(update.stopReason) : undefined; setNativeChildState( child, update.state !== "idle" ? "running" - : update.stopReason === "error" + : update.stopReason === "error" || unfinishedMessage !== undefined ? "failed" : update.stopReason === "cancelled" ? "cancelled" : "completed", - update.state === "idle" ? (update.error?.message ?? null) : null, + update.state === "idle" ? (update.error?.message ?? unfinishedMessage ?? null) : null, ); } if (context !== null) yield* syncNativeChildren(context); @@ -5027,6 +5047,17 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( class: "provider_error", }); + const stopReasonFailure = ( + stopReason: EffectAcpSchema.StopReason | 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 { @@ -5043,7 +5074,10 @@ 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 = context.interrupted + ? undefined + : stopReasonFailure(work.stopReason, work.error); + if (failure !== undefined) yield* terminalizeOpenForegroundTools(context, "failed"); yield* finalizeTurn( context, failure !== undefined @@ -8671,8 +8705,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(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; + // ACP v2 reports a failed prompt through its idle stop reason, not an RPC error. const hiddenFailure = checkPromptOutcome === undefined || context.interrupted || @@ -8684,7 +8717,10 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( yield* Ref.set(runtimeRestartRequired, true); } const failure = - hiddenFailure ?? (idleError ? idleErrorFailure(result.error) : undefined); + hiddenFailure ?? + (context.interrupted + ? undefined + : stopReasonFailure(result.stopReason, result.error)); const status = failure !== undefined ? "failed"