diff --git a/src/resources/extensions/claude-code-cli/stream-adapter.ts b/src/resources/extensions/claude-code-cli/stream-adapter.ts index ab106b1dc..0be1512b6 100644 --- a/src/resources/extensions/claude-code-cli/stream-adapter.ts +++ b/src/resources/extensions/claude-code-cli/stream-adapter.ts @@ -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({ diff --git a/src/resources/extensions/claude-code-cli/tests/stream-adapter.test.ts b/src/resources/extensions/claude-code-cli/tests/stream-adapter.test.ts new file mode 100644 index 000000000..052823590 --- /dev/null +++ b/src/resources/extensions/claude-code-cli/tests/stream-adapter.test.ts @@ -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/); + }); +}); diff --git a/src/resources/extensions/gsd/provider-error-pause.ts b/src/resources/extensions/gsd/provider-error-pause.ts index 7a5414999..67e9e1d37 100644 --- a/src/resources/extensions/gsd/provider-error-pause.ts +++ b/src/resources/extensions/gsd/provider-error-pause.ts @@ -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); diff --git a/src/resources/extensions/gsd/tests/provider-errors.test.ts b/src/resources/extensions/gsd/tests/provider-errors.test.ts index 0512b4d90..291909d27 100644 --- a/src/resources/extensions/gsd/tests/provider-errors.test.ts +++ b/src/resources/extensions/gsd/tests/provider-errors.test.ts @@ -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);