Skip to content

Commit ceec900

Browse files
committed
fix(acpx-provider): surface stopReason as typed error codes
Read acpx's terminal JSON-RPC result envelope (`{id, result: {stopReason, ...}}`) inside `extractAcpxJson` and map non-`end_turn` reasons to typed `ClawpatchError` codes: cancelled -> agent-cancelled (exit 6) refusal -> agent-refused (exit 7) max_tokens -> agent-truncated (exit 8) max_turns_exceeded -> agent-truncated (exit 8) unknown reason -> agent-cancelled (exit 8) defensively Previously, prompts that ended in `cancelled` / `refusal` / `max_tokens` emitted no `agent_message_chunk`, so they fell into the same `code: "malformed-output"` bucket as a real envelope-shape regression (see 20260517T184420-d52a72: four parser errors of three different shapes, all opaque `malformed-output`). On-call had to read raw NDJSON to triage. Now `malformed-output` keeps its narrow meaning: clawpatch could not parse a payload acpx claims is `end_turn`. The `end_turn`-with-no-chunks path also gets a clearer diagnostic noting the parser bug vs envelope break. Verified live against acpx 0.8.0 + claude-agent-acp 0.31.4. Terminal envelope shape: {"jsonrpc":"2.0","id":2,"result":{"stopReason":"end_turn","usage":{...}}} `ACPX_TESTED_VERSIONS` stays at `^0.8.0`; the stopReason envelope is present in this version.
1 parent 398b752 commit ceec900

3 files changed

Lines changed: 152 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
- Fixed first-time `clawpatch open-pr` branch creation to start from the recorded patch base.
1212
- Fixed command execution so providers that exit before reading stdin do not surface benign `EPIPE` errors.
1313
- Fixed `clawpatch ci --since` empty-review output so it reports `reviewed: 0`.
14+
- Fixed acpx provider error reporting by reading the terminal `result.stopReason` envelope and surfacing non-`end_turn` reasons as typed `ClawpatchError` codes (`agent-cancelled`, `agent-refused`, `agent-truncated`) instead of opaque `malformed-output`.
1415
- Improved OpenCode malformed JSON diagnostics with output length, event kinds, and a bounded preview, thanks @rohitjavvadi.
1516
- Fixed Express route mapping for aliased Router imports that follow block comment banners, thanks @rohitjavvadi.
1617
- Fixed Bun package-manager detection to recognize the text `bun.lock` lockfile, thanks @austinm911.

src/provider.test.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,30 @@ function escapeRegExp(value: string): string {
6060
return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
6161
}
6262

63+
function terminalEnvelope(stopReason: string, id = 2): string {
64+
return JSON.stringify({
65+
jsonrpc: "2.0",
66+
id,
67+
result: { stopReason, usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 } },
68+
});
69+
}
70+
71+
function expectStopReasonError(
72+
fn: () => unknown,
73+
expected: { code: string; exitCode: number; stopReason: string },
74+
): void {
75+
try {
76+
fn();
77+
} catch (err) {
78+
expect(err).toBeInstanceOf(ClawpatchError);
79+
expect((err as ClawpatchError).code).toBe(expected.code);
80+
expect((err as ClawpatchError).exitCode).toBe(expected.exitCode);
81+
expect((err as Error).message).toContain(`stopReason="${expected.stopReason}"`);
82+
return;
83+
}
84+
throw new Error(`expected ClawpatchError with code ${expected.code}`);
85+
}
86+
6387
describe("extractJson", () => {
6488
it("parses strict JSON directly", () => {
6589
const input = '{"findings":[],"inspected":{"files":[],"symbols":[],"notes":[]}}';
@@ -398,6 +422,71 @@ describe("extractAcpxJson", () => {
398422
expect(extractAcpxJson(stdout)).toEqual({ ok: true });
399423
});
400424

425+
it("preserves end_turn happy path with message chunks", () => {
426+
const stdout = [
427+
textChunk("agent_message_chunk", '{"ok":'),
428+
textChunk("agent_message_chunk", "true}"),
429+
terminalEnvelope("end_turn"),
430+
].join("\n");
431+
432+
expect(extractAcpxJson(stdout)).toEqual({ ok: true });
433+
});
434+
435+
it("surfaces stopReason cancelled as agent-cancelled", () => {
436+
const stdout = [
437+
updateEnvelope({ sessionUpdate: "usage_update", usage: { inputTokens: 1, outputTokens: 0 } }),
438+
terminalEnvelope("cancelled"),
439+
].join("\n");
440+
441+
expectStopReasonError(() => extractAcpxJson(stdout), {
442+
code: "agent-cancelled",
443+
exitCode: 6,
444+
stopReason: "cancelled",
445+
});
446+
});
447+
448+
it("surfaces stopReason refusal as agent-refused", () => {
449+
const stdout = terminalEnvelope("refusal");
450+
451+
expectStopReasonError(() => extractAcpxJson(stdout), {
452+
code: "agent-refused",
453+
exitCode: 7,
454+
stopReason: "refusal",
455+
});
456+
});
457+
458+
it("surfaces stopReason max_tokens as agent-truncated", () => {
459+
const stdout = [
460+
textChunk("agent_message_chunk", '{"partial":'),
461+
terminalEnvelope("max_tokens"),
462+
].join("\n");
463+
464+
expectStopReasonError(() => extractAcpxJson(stdout), {
465+
code: "agent-truncated",
466+
exitCode: 8,
467+
stopReason: "max_tokens",
468+
});
469+
});
470+
471+
it("maps unknown stopReason defensively to agent-cancelled", () => {
472+
const stdout = terminalEnvelope("future_reason_xyz");
473+
474+
expectStopReasonError(() => extractAcpxJson(stdout), {
475+
code: "agent-cancelled",
476+
exitCode: 8,
477+
stopReason: "future_reason_xyz",
478+
});
479+
});
480+
481+
it("falls back to current behavior with no terminal envelope", () => {
482+
const stdout = [
483+
textChunk("agent_message_chunk", '{"legacy":'),
484+
textChunk("agent_message_chunk", "true}"),
485+
].join("\n");
486+
487+
expect(extractAcpxJson(stdout)).toEqual({ legacy: true });
488+
});
489+
401490
it("survives a 256-line NDJSON fixture over 8KB", () => {
402491
const filler = Array.from({ length: 255 }, (_, idx) =>
403492
updateEnvelope({

src/provider.ts

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -784,18 +784,49 @@ function buildAcpxPrompt(prompt: string, schema: object, permission: "read" | "a
784784
);
785785
}
786786

787+
// Map acpx promptResult.stopReason -> ClawpatchError code/exit pair.
788+
// `end_turn` is the only successful reason; everything else surfaces as a
789+
// typed error so callers can distinguish cancellation / refusal / truncation
790+
// from an actual envelope-shape regression.
791+
//
792+
// Source: acpx/src/runtime/engine/manager.ts emits the terminal JSON-RPC
793+
// response `{"jsonrpc":"2.0","id":N,"result":{"stopReason":<reason>,...}}`
794+
// for every `session/prompt`. Known reasons in acpx 0.8.0 / claude-agent-acp
795+
// 0.31.4 are `end_turn | cancelled | refusal | max_tokens` (plus
796+
// `max_turns_exceeded`, surfaced for the agent-driven turn loop).
797+
const ACPX_STOP_REASON_CODES: Record<string, string> = {
798+
cancelled: "agent-cancelled",
799+
refusal: "agent-refused",
800+
max_tokens: "agent-truncated",
801+
max_turns_exceeded: "agent-truncated",
802+
};
803+
const ACPX_STOP_EXIT_CODES: Record<string, number> = {
804+
cancelled: 6,
805+
refusal: 7,
806+
max_tokens: 8,
807+
max_turns_exceeded: 8,
808+
};
809+
787810
export function extractAcpxJson(stdout: string): unknown {
788811
const toolCandidates: string[] = [];
789812
const messageChunks: string[] = [];
790813
const thoughtChunks: string[] = [];
791814
const observedKinds = new Set<string>();
815+
// Last-seen terminal JSON-RPC response envelope: `{id, result: {stopReason, ...}}`.
816+
// acpx emits exactly one per `session/prompt` turn (see
817+
// acpx/src/runtime/engine/manager.ts). If this is anything other than
818+
// "end_turn" the agent is telling us the turn produced no answer, and we
819+
// should surface a typed error instead of trying to parse chunks.
820+
let terminalStopReason: string | undefined;
792821
for (const line of stdout.split("\n")) {
793822
const trimmed = line.trim();
794823
if (trimmed.length === 0) {
795824
continue;
796825
}
797826
let env: {
798827
method?: string;
828+
id?: unknown;
829+
result?: { stopReason?: unknown };
799830
params?: {
800831
update?: {
801832
sessionUpdate?: string;
@@ -809,6 +840,17 @@ export function extractAcpxJson(stdout: string): unknown {
809840
} catch {
810841
continue;
811842
}
843+
if (
844+
env !== null &&
845+
typeof env === "object" &&
846+
Object.prototype.hasOwnProperty.call(env, "id") &&
847+
env.result !== undefined &&
848+
env.result !== null &&
849+
typeof env.result === "object" &&
850+
typeof env.result.stopReason === "string"
851+
) {
852+
terminalStopReason = env.result.stopReason;
853+
}
812854
if (env.method !== "session/update") {
813855
continue;
814856
}
@@ -833,15 +875,33 @@ export function extractAcpxJson(stdout: string): unknown {
833875
toolCandidates.push(update.output);
834876
}
835877
}
878+
// Step 1: if acpx terminated the turn with anything other than end_turn,
879+
// surface that directly. No chunk-parsing — the agent already told us
880+
// there is no answer in this turn.
881+
if (terminalStopReason !== undefined && terminalStopReason !== "end_turn") {
882+
const code = ACPX_STOP_REASON_CODES[terminalStopReason] ?? "agent-cancelled";
883+
const exit = ACPX_STOP_EXIT_CODES[terminalStopReason] ?? 8;
884+
throw new ClawpatchError(
885+
`acpx prompt did not complete: stopReason="${terminalStopReason}". ` +
886+
`Observed envelope kinds: [${[...observedKinds].join(", ")}].`,
887+
exit,
888+
code,
889+
);
890+
}
836891
const candidates = [
837892
...(messageChunks.length > 0 ? [messageChunks.join("")] : []),
838893
...toolCandidates.toReversed(),
839894
...(thoughtChunks.length > 0 ? [thoughtChunks.join("")] : []),
840895
];
841896
if (candidates.length === 0) {
897+
const stopReasonNote =
898+
terminalStopReason === "end_turn"
899+
? `acpx reported stopReason=end_turn but emitted no message chunks. ` +
900+
`This is a clawpatch parser bug or a prompt that produced only tool calls. `
901+
: ``;
842902
throw new ClawpatchError(
843-
`acpx provider produced no extractable text. Observed envelope kinds: ` +
844-
`[${[...observedKinds].join(", ")}]. ` +
903+
`acpx provider produced no extractable text. ${stopReasonNote}` +
904+
`Observed envelope kinds: [${[...observedKinds].join(", ")}]. ` +
845905
`acpx envelope shape may have changed since clawpatch was tested ` +
846906
`against ${ACPX_TESTED_VERSIONS}. Check the installed acpx version.`,
847907
8,

0 commit comments

Comments
 (0)