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:
mastertyko 2026-03-26 23:11:23 +01:00 committed by GitHub
parent 0e07c647c5
commit f2113f1353
4 changed files with 50 additions and 21 deletions

View file

@ -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({

View file

@ -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/);
});
});

View file

@ -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);

View file

@ -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);