When the LLM sends an assistant message with empty content[] and stopReason "aborted", this is a non-fatal agent stop — not a crash. The abort handler now checks for empty content and missing errorMessage before deciding to pause. Empty-content aborts are routed to resolveAgentEnd instead, breaking the stuck re-dispatch loop. Closes #2695 Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
0b36977804
commit
466c7dea18
2 changed files with 96 additions and 0 deletions
|
|
@ -68,6 +68,28 @@ export async function handleAgentEnd(
|
|||
|
||||
const lastMsg = event.messages[event.messages.length - 1];
|
||||
if (lastMsg && "stopReason" in lastMsg && lastMsg.stopReason === "aborted") {
|
||||
// Empty content with aborted stopReason is a non-fatal agent stop (the LLM
|
||||
// chose to end without producing output). Only pause on genuine fatal aborts
|
||||
// that carry error context — e.g. errorMessage field or non-empty content
|
||||
// indicating a mid-stream failure. (#2695)
|
||||
const content = "content" in lastMsg ? lastMsg.content : undefined;
|
||||
const hasEmptyContent = Array.isArray(content) && content.length === 0;
|
||||
const hasErrorMessage = "errorMessage" in lastMsg && !!lastMsg.errorMessage;
|
||||
|
||||
if (hasEmptyContent && !hasErrorMessage) {
|
||||
// Non-fatal: treat as a normal agent end so the loop can continue
|
||||
// instead of entering a stuck re-dispatch cycle.
|
||||
try {
|
||||
resetRetryState(retryState);
|
||||
resolveAgentEnd(event);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
ctx.ui.notify(`Auto-mode error after empty-content abort: ${message}. Stopping auto-mode.`, "error");
|
||||
try { await pauseAuto(ctx, pi); } catch { /* best-effort */ }
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await pauseAuto(ctx, pi);
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,74 @@
|
|||
/**
|
||||
* empty-content-abort-loop.test.ts — Regression test for #2695.
|
||||
*
|
||||
* When the LLM sends an assistant message with empty `content: []` and
|
||||
* `stopReason: "aborted"`, this is NOT a fatal abort — it is a non-fatal
|
||||
* end-of-turn. The abort handler in agent-end-recovery.ts must distinguish
|
||||
* this case and NOT pause auto-mode, allowing the loop to continue via
|
||||
* resolveAgentEnd instead of entering a stuck re-dispatch loop.
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const RECOVERY_PATH = join(__dirname, "..", "bootstrap", "agent-end-recovery.ts");
|
||||
|
||||
function getRecoverySource(): string {
|
||||
return readFileSync(RECOVERY_PATH, "utf-8");
|
||||
}
|
||||
|
||||
test("agent-end-recovery.ts does not pause on aborted messages with empty content (#2695)", () => {
|
||||
const source = getRecoverySource();
|
||||
|
||||
// The abort handler at `stopReason === "aborted"` must check for empty content
|
||||
// before deciding to pause. An empty content array is a non-fatal agent stop.
|
||||
const abortIdx = source.indexOf('stopReason === "aborted"');
|
||||
assert.ok(abortIdx > -1, "abort handler must exist in agent-end-recovery.ts");
|
||||
|
||||
// Extract the region around the abort handler (enough to see the guard logic)
|
||||
const abortRegion = source.slice(Math.max(0, abortIdx - 200), abortIdx + 600);
|
||||
|
||||
// Must check for empty content before pausing
|
||||
assert.ok(
|
||||
abortRegion.includes("content") && (abortRegion.includes("length") || abortRegion.includes("?.length")),
|
||||
"abort handler must inspect content array length to distinguish empty-content aborts from fatal aborts (#2695)",
|
||||
);
|
||||
});
|
||||
|
||||
test("agent-end-recovery.ts routes empty-content aborted messages to resolveAgentEnd (#2695)", () => {
|
||||
const source = getRecoverySource();
|
||||
|
||||
// The abort block must have a path that calls resolveAgentEnd for empty-content messages
|
||||
// instead of unconditionally calling pauseAuto
|
||||
const abortIdx = source.indexOf('stopReason === "aborted"');
|
||||
assert.ok(abortIdx > -1, "abort handler must exist");
|
||||
|
||||
// Get the full abort handling block (from the if to the next stopReason check or success path)
|
||||
const afterAbort = source.slice(abortIdx, abortIdx + 800);
|
||||
|
||||
// The abort block must have a code path that calls resolveAgentEnd (for empty-content case)
|
||||
assert.ok(
|
||||
afterAbort.includes("resolveAgentEnd"),
|
||||
"abort handler must route empty-content aborted messages to resolveAgentEnd instead of always pausing (#2695)",
|
||||
);
|
||||
});
|
||||
|
||||
test("agent-end-recovery.ts checks for errorMessage presence in abort handler (#2695)", () => {
|
||||
const source = getRecoverySource();
|
||||
|
||||
const abortIdx = source.indexOf('stopReason === "aborted"');
|
||||
assert.ok(abortIdx > -1, "abort handler must exist");
|
||||
|
||||
const abortRegion = source.slice(abortIdx, abortIdx + 600);
|
||||
|
||||
// Fatal aborts should have error context (errorMessage field).
|
||||
// The handler should check for this to distinguish fatal from non-fatal aborts.
|
||||
assert.ok(
|
||||
abortRegion.includes("errorMessage"),
|
||||
"abort handler must check for errorMessage to distinguish fatal aborts from empty-content non-fatal stops (#2695)",
|
||||
);
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue