Skip to content

Commit 9dfc86a

Browse files
authored
Merge pull request #90 from coletebou/pr/acpx-stop-reason
fix(acpx-provider): surface stopReason as typed error codes
2 parents d364822 + 8ebca97 commit 9dfc86a

3 files changed

Lines changed: 169 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
- Added Maven project mapping for root, nested, and multi-module Java/Kotlin projects with Spring role slices, Maven validation defaults, and `pom.xml` detection, thanks @julianshess.
2121
- Added a release-prep checklist for auditing changelog, package metadata, and dry-run package contents without publishing.
2222
- Improved bounded source grouping so large flat directories split repeated filename families like command, plugin, doctor, and runtime files into more coherent review slices.
23+
- 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`, thanks @coletebou.
2324
- Improved OpenCode malformed JSON diagnostics with output length, event kinds, and a bounded preview, thanks @rohitjavvadi.
2425
- Fixed finding signatures so equivalent evidence remains stable across re-reviews, thanks @rohitjavvadi.
2526
- Fixed provider exit-code classification for stdout-only authentication and quota failures, thanks @rohitjavvadi.

src/provider.test.ts

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

106+
function terminalEnvelope(stopReason: string, id = 2): string {
107+
return JSON.stringify({
108+
jsonrpc: "2.0",
109+
id,
110+
result: { stopReason, usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 } },
111+
});
112+
}
113+
114+
function expectStopReasonError(
115+
fn: () => unknown,
116+
expected: { code: string; exitCode: number; stopReason: string },
117+
): void {
118+
try {
119+
fn();
120+
} catch (err) {
121+
expect(err).toBeInstanceOf(ClawpatchError);
122+
expect((err as ClawpatchError).code).toBe(expected.code);
123+
expect((err as ClawpatchError).exitCode).toBe(expected.exitCode);
124+
expect((err as Error).message).toContain(`stopReason="${expected.stopReason}"`);
125+
return;
126+
}
127+
throw new Error(`expected ClawpatchError with code ${expected.code}`);
128+
}
129+
106130
describe("extractJson", () => {
107131
it("parses strict JSON directly", () => {
108132
const input = '{"findings":[],"inspected":{"files":[],"symbols":[],"notes":[]}}';
@@ -571,6 +595,81 @@ describe("extractAcpxJson", () => {
571595
expect(extractAcpxJson(stdout)).toEqual({ ok: true });
572596
});
573597

598+
it("preserves end_turn happy path with message chunks", () => {
599+
const stdout = [
600+
textChunk("agent_message_chunk", '{"ok":'),
601+
textChunk("agent_message_chunk", "true}"),
602+
terminalEnvelope("end_turn"),
603+
].join("\n");
604+
605+
expect(extractAcpxJson(stdout)).toEqual({ ok: true });
606+
});
607+
608+
it("surfaces stopReason cancelled as agent-cancelled", () => {
609+
const stdout = [
610+
updateEnvelope({ sessionUpdate: "usage_update", usage: { inputTokens: 1, outputTokens: 0 } }),
611+
terminalEnvelope("cancelled"),
612+
].join("\n");
613+
614+
expectStopReasonError(() => extractAcpxJson(stdout), {
615+
code: "agent-cancelled",
616+
exitCode: 1,
617+
stopReason: "cancelled",
618+
});
619+
});
620+
621+
it("surfaces stopReason refusal as agent-refused", () => {
622+
const stdout = terminalEnvelope("refusal");
623+
624+
expectStopReasonError(() => extractAcpxJson(stdout), {
625+
code: "agent-refused",
626+
exitCode: 1,
627+
stopReason: "refusal",
628+
});
629+
});
630+
631+
it("surfaces stopReason max_tokens as agent-truncated", () => {
632+
const stdout = [
633+
textChunk("agent_message_chunk", '{"partial":'),
634+
terminalEnvelope("max_tokens"),
635+
].join("\n");
636+
637+
expectStopReasonError(() => extractAcpxJson(stdout), {
638+
code: "agent-truncated",
639+
exitCode: 8,
640+
stopReason: "max_tokens",
641+
});
642+
});
643+
644+
it("surfaces stopReason max_turn_requests as agent-truncated", () => {
645+
const stdout = terminalEnvelope("max_turn_requests");
646+
647+
expectStopReasonError(() => extractAcpxJson(stdout), {
648+
code: "agent-truncated",
649+
exitCode: 8,
650+
stopReason: "max_turn_requests",
651+
});
652+
});
653+
654+
it("maps unknown stopReason defensively to agent-cancelled", () => {
655+
const stdout = terminalEnvelope("future_reason_xyz");
656+
657+
expectStopReasonError(() => extractAcpxJson(stdout), {
658+
code: "agent-cancelled",
659+
exitCode: 8,
660+
stopReason: "future_reason_xyz",
661+
});
662+
});
663+
664+
it("falls back to current behavior with no terminal envelope", () => {
665+
const stdout = [
666+
textChunk("agent_message_chunk", '{"legacy":'),
667+
textChunk("agent_message_chunk", "true}"),
668+
].join("\n");
669+
670+
expect(extractAcpxJson(stdout)).toEqual({ legacy: true });
671+
});
672+
574673
it("survives a 256-line NDJSON fixture over 8KB", () => {
575674
const filler = Array.from({ length: 255 }, (_, idx) =>
576675
updateEnvelope({

src/provider.ts

Lines changed: 69 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1047,31 +1047,64 @@ function buildAcpxPrompt(prompt: string, schema: object, permission: "read" | "a
10471047
);
10481048
}
10491049

1050+
// Map acpx promptResult.stopReason -> ClawpatchError code/exit pair.
1051+
// `end_turn` is the only successful reason; everything else surfaces as a
1052+
// typed error so callers can distinguish cancellation / refusal / truncation
1053+
// from an actual envelope-shape regression.
1054+
//
1055+
// Source: acpx/src/runtime/engine/manager.ts emits the terminal JSON-RPC
1056+
// response `{"jsonrpc":"2.0","id":N,"result":{"stopReason":<reason>,...}}`
1057+
// for every `session/prompt`. Known reasons in acpx 0.8.0 / claude-agent-acp
1058+
// 0.31.4 are `end_turn | cancelled | refusal | max_tokens | max_turn_requests`
1059+
// (plus the older `max_turns_exceeded` spelling seen in agent-driven turn loops).
1060+
const ACPX_STOP_REASON_CODES: Record<string, string> = {
1061+
cancelled: "agent-cancelled",
1062+
refusal: "agent-refused",
1063+
max_tokens: "agent-truncated",
1064+
max_turn_requests: "agent-truncated",
1065+
max_turns_exceeded: "agent-truncated",
1066+
};
1067+
const ACPX_STOP_EXIT_CODES: Record<string, number> = {
1068+
cancelled: 1,
1069+
refusal: 1,
1070+
max_tokens: 8,
1071+
max_turn_requests: 8,
1072+
max_turns_exceeded: 8,
1073+
};
1074+
10501075
export function extractAcpxJson(stdout: string): unknown {
1051-
const { candidates, observedKinds } = acpxJsonCandidates(stdout, false);
1052-
return parseAcpxJsonCandidates(candidates, observedKinds, (output) => output);
1076+
const { candidates, observedKinds, terminalStopReason } = acpxJsonCandidates(stdout, false);
1077+
return parseAcpxJsonCandidates(candidates, observedKinds, terminalStopReason, (output) => output);
10531078
}
10541079

10551080
function parseAcpxJsonOutput<T>(stdout: string, parseOutput: (output: unknown) => T): T {
1056-
const { candidates, observedKinds } = acpxJsonCandidates(stdout, true);
1057-
return parseAcpxJsonCandidates(candidates, observedKinds, parseOutput);
1081+
const { candidates, observedKinds, terminalStopReason } = acpxJsonCandidates(stdout, true);
1082+
return parseAcpxJsonCandidates(candidates, observedKinds, terminalStopReason, parseOutput);
10581083
}
10591084

10601085
function acpxJsonCandidates(
10611086
stdout: string,
10621087
retrySafe: boolean,
1063-
): { candidates: string[]; observedKinds: Set<string> } {
1088+
): { candidates: string[]; observedKinds: Set<string>; terminalStopReason: string | undefined } {
10641089
const toolCandidates: string[] = [];
10651090
const messageChunks: string[] = [];
10661091
const thoughtChunks: string[] = [];
10671092
const observedKinds = new Set<string>();
1093+
// Last-seen terminal JSON-RPC response envelope: `{id, result: {stopReason, ...}}`.
1094+
// acpx emits exactly one per `session/prompt` turn (see
1095+
// acpx/src/runtime/engine/manager.ts). If this is anything other than
1096+
// "end_turn" the agent is telling us the turn produced no answer, and we
1097+
// should surface a typed error instead of trying to parse chunks.
1098+
let terminalStopReason: string | undefined;
10681099
for (const line of stdout.split("\n")) {
10691100
const trimmed = line.trim();
10701101
if (trimmed.length === 0) {
10711102
continue;
10721103
}
10731104
let env: {
10741105
method?: string;
1106+
id?: unknown;
1107+
result?: { stopReason?: unknown };
10751108
params?: {
10761109
update?: {
10771110
sessionUpdate?: string;
@@ -1085,6 +1118,17 @@ function acpxJsonCandidates(
10851118
} catch {
10861119
continue;
10871120
}
1121+
if (
1122+
env !== null &&
1123+
typeof env === "object" &&
1124+
Object.prototype.hasOwnProperty.call(env, "id") &&
1125+
env.result !== undefined &&
1126+
env.result !== null &&
1127+
typeof env.result === "object" &&
1128+
typeof env.result.stopReason === "string"
1129+
) {
1130+
terminalStopReason = env.result.stopReason;
1131+
}
10881132
if (env.method !== "session/update") {
10891133
continue;
10901134
}
@@ -1120,18 +1164,35 @@ function acpxJsonCandidates(
11201164
...toolCandidates.toReversed(),
11211165
...(thoughtChunks.length > 0 ? [thoughtChunks.join("")] : []),
11221166
];
1123-
return { candidates, observedKinds };
1167+
return { candidates, observedKinds, terminalStopReason };
11241168
}
11251169

11261170
function parseAcpxJsonCandidates<T>(
11271171
candidates: string[],
11281172
observedKinds: Set<string>,
1173+
terminalStopReason: string | undefined,
11291174
parseOutput: (output: unknown) => T,
11301175
): T {
1176+
if (terminalStopReason !== undefined && terminalStopReason !== "end_turn") {
1177+
const code = ACPX_STOP_REASON_CODES[terminalStopReason] ?? "agent-cancelled";
1178+
const exit = ACPX_STOP_EXIT_CODES[terminalStopReason] ?? 8;
1179+
throw new ClawpatchError(
1180+
`acpx prompt did not complete: stopReason="${terminalStopReason}". ` +
1181+
`Observed envelope kinds: [${[...observedKinds].join(", ")}].`,
1182+
exit,
1183+
code,
1184+
);
1185+
}
1186+
11311187
if (candidates.length === 0) {
1188+
const stopReasonNote =
1189+
terminalStopReason === "end_turn"
1190+
? `acpx reported stopReason=end_turn but emitted no message chunks. ` +
1191+
`This is a clawpatch parser bug or a prompt that produced only tool calls. `
1192+
: ``;
11321193
throw new ClawpatchError(
1133-
`acpx provider produced no extractable text. Observed envelope kinds: ` +
1134-
`[${[...observedKinds].join(", ")}]. ` +
1194+
`acpx provider produced no extractable text. ${stopReasonNote}` +
1195+
`Observed envelope kinds: [${[...observedKinds].join(", ")}]. ` +
11351196
`acpx envelope shape may have changed since clawpatch was tested ` +
11361197
`against ${ACPX_TESTED_VERSIONS}. Check the installed acpx version.`,
11371198
8,

0 commit comments

Comments
 (0)