fix: surface exhausted Claude SDK streams as errors (#2719)
Treat Claude SDK generator exhaustion without a terminal result as a stream interruption instead of a successful completion. This prevents phantom-success auto-mode advances, keeps the failure classifiable as transient provider recovery, and adds regression tests for the fallback message plus provider classification. Closes #2575
This commit is contained in:
parent
0e07c647c5
commit
f2113f1353
4 changed files with 50 additions and 21 deletions
|
|
@ -113,6 +113,20 @@ function makeErrorMessage(model: string, errorMsg: string): AssistantMessage {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generator exhaustion without a terminal result means the SDK stream was
|
||||
* interrupted mid-turn. Surface it as an error so downstream recovery logic
|
||||
* can classify and retry it instead of treating it as a clean completion.
|
||||
*/
|
||||
export function makeStreamExhaustedErrorMessage(model: string, lastTextContent: string): AssistantMessage {
|
||||
const errorMsg = "stream_exhausted_without_result";
|
||||
const message = makeErrorMessage(model, errorMsg);
|
||||
if (lastTextContent) {
|
||||
message.content = [{ type: "text", text: lastTextContent }];
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// streamSimple implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -339,26 +353,11 @@ async function pumpSdkMessages(
|
|||
}
|
||||
}
|
||||
|
||||
// Generator exhausted without a result message (unexpected)
|
||||
const fallbackContent: AssistantMessage["content"] = [];
|
||||
if (lastTextContent) {
|
||||
fallbackContent.push({ type: "text", text: lastTextContent });
|
||||
}
|
||||
if (fallbackContent.length === 0) {
|
||||
fallbackContent.push({ type: "text", text: "(Claude Code session ended without a response)" });
|
||||
}
|
||||
|
||||
const fallback: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: fallbackContent,
|
||||
api: "anthropic-messages",
|
||||
provider: "claude-code",
|
||||
model: modelId,
|
||||
usage: { ...ZERO_USAGE },
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
stream.push({ type: "done", reason: "stop", message: fallback });
|
||||
// Generator exhaustion without a terminal result is a stream interruption,
|
||||
// not a successful completion. Emitting an error lets GSD classify it as a
|
||||
// transient provider failure instead of advancing auto-mode state.
|
||||
const fallback = makeStreamExhaustedErrorMessage(modelId, lastTextContent);
|
||||
stream.push({ type: "error", reason: "error", error: fallback });
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
stream.push({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
import { describe, test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { makeStreamExhaustedErrorMessage } from "../stream-adapter.ts";
|
||||
|
||||
describe("stream-adapter — exhausted stream fallback (#2575)", () => {
|
||||
test("generator exhaustion becomes an error message instead of clean completion", () => {
|
||||
const message = makeStreamExhaustedErrorMessage("claude-sonnet-4-20250514", "partial answer");
|
||||
|
||||
assert.equal(message.stopReason, "error");
|
||||
assert.equal(message.errorMessage, "stream_exhausted_without_result");
|
||||
assert.deepEqual(message.content, [{ type: "text", text: "partial answer" }]);
|
||||
});
|
||||
|
||||
test("generator exhaustion without prior text still exposes a classifiable error", () => {
|
||||
const message = makeStreamExhaustedErrorMessage("claude-sonnet-4-20250514", "");
|
||||
|
||||
assert.equal(message.stopReason, "error");
|
||||
assert.equal(message.errorMessage, "stream_exhausted_without_result");
|
||||
assert.match(String((message.content[0] as any)?.text ?? ""), /Claude Code error: stream_exhausted_without_result/);
|
||||
});
|
||||
});
|
||||
|
|
@ -22,7 +22,7 @@ export function classifyProviderError(errorMsg: string): {
|
|||
// Connection/process errors — transient, auto-resume after brief backoff (#2309).
|
||||
// These indicate the process was killed, the connection was reset, or a network
|
||||
// blip occurred. They are NOT permanent failures.
|
||||
const isConnectionError = /terminated|connection.?reset|connection.?refused|other side closed|fetch failed|network.?(?:is\s+)?unavailable|ECONNREFUSED|ECONNRESET|EPIPE/i.test(errorMsg);
|
||||
const isConnectionError = /terminated|connection.?reset|connection.?refused|other side closed|fetch failed|network.?(?:is\s+)?unavailable|ECONNREFUSED|ECONNRESET|EPIPE|stream_exhausted(?:_without_result)?/i.test(errorMsg);
|
||||
|
||||
// Permanent errors — never auto-resume
|
||||
const isPermanent = /auth|unauthorized|forbidden|invalid.*key|invalid.*api|billing|quota exceeded|account/i.test(errorMsg);
|
||||
|
|
|
|||
|
|
@ -42,6 +42,15 @@ test("classifyProviderError defaults to 60s for rate limit without reset", () =>
|
|||
assert.equal(result.suggestedDelayMs, 60_000);
|
||||
});
|
||||
|
||||
test("classifyProviderError treats stream_exhausted_without_result as transient connection failure", () => {
|
||||
const result = classifyProviderError("stream_exhausted_without_result");
|
||||
assert.deepStrictEqual(result, {
|
||||
isTransient: true,
|
||||
isRateLimit: false,
|
||||
suggestedDelayMs: 15_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("classifyProviderError detects Anthropic internal server error", () => {
|
||||
const msg = '{"type":"error","error":{"details":null,"type":"api_error","message":"Internal server error"}}';
|
||||
const result = classifyProviderError(msg);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue