Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
Expand Down
12 changes: 7 additions & 5 deletions packages/provider-acp-registry/src/server/claudeAcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -86,19 +87,20 @@ 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,
parentSessionId: notification.sessionId,
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") {
Expand Down
48 changes: 42 additions & 6 deletions packages/provider-acp/src/server/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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
) {
Expand Down Expand Up @@ -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,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
);
}
if (context !== null) yield* syncNativeChildren(context);
Expand Down Expand Up @@ -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<void> {
Expand All @@ -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
Expand Down Expand Up @@ -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 ||
Expand All @@ -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"
Expand Down
Loading