-
Notifications
You must be signed in to change notification settings - Fork 16.5k
fix: 检测网关 SSE 空闲超时导致的流中途截断,避免误判为 end_turn #1362
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
jianYanZhiX7
wants to merge
1
commit into
claude-code-best:main
Choose a base branch
from
jianYanZhiX7:fix/sse-silent-truncation-detection
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+405
−3
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,130 @@ | ||
| import { describe, expect, test } from 'bun:test' | ||
| import { | ||
| hasCompletedToolUse, | ||
| isPrematureStreamTruncation, | ||
| type CompletionMessageLike, | ||
| type StreamTruncationInput, | ||
| } from '../streamCompletion' | ||
|
|
||
| function msg(content: unknown): CompletionMessageLike { | ||
| return { message: { content } } | ||
| } | ||
|
|
||
| describe('hasCompletedToolUse', () => { | ||
| test('returns false for an empty message list', () => { | ||
| expect(hasCompletedToolUse([])).toBe(false) | ||
| }) | ||
|
|
||
| test('returns false when messages contain only text blocks', () => { | ||
| expect(hasCompletedToolUse([msg([{ type: 'text', text: 'hello' }])])).toBe( | ||
| false, | ||
| ) | ||
| }) | ||
|
|
||
| test('returns false when a thinking block completed but no tool_use', () => { | ||
| expect( | ||
| hasCompletedToolUse([ | ||
| msg([{ type: 'thinking', thinking: '...' }]), | ||
| msg([{ type: 'text', text: 'done' }]), | ||
| ]), | ||
| ).toBe(false) | ||
| }) | ||
|
|
||
| test('returns true when a tool_use block completed', () => { | ||
| expect( | ||
| hasCompletedToolUse([ | ||
| msg([{ type: 'text', text: 'ok' }]), | ||
| msg([{ type: 'tool_use', name: 'Write', input: {} }]), | ||
| ]), | ||
| ).toBe(true) | ||
| }) | ||
|
|
||
| test('returns true for a server_tool_use block', () => { | ||
| expect( | ||
| hasCompletedToolUse([ | ||
| msg([{ type: 'server_tool_use', name: 'advisor' }]), | ||
| ]), | ||
| ).toBe(true) | ||
| }) | ||
|
|
||
| test('tolerates missing/non-array content without throwing', () => { | ||
| expect(hasCompletedToolUse([{ message: {} }])).toBe(false) | ||
| expect(hasCompletedToolUse([{}])).toBe(false) | ||
| expect(hasCompletedToolUse([msg('not-an-array')])).toBe(false) | ||
| }) | ||
| }) | ||
|
|
||
| describe('isPrematureStreamTruncation', () => { | ||
| const base: StreamTruncationInput = { | ||
| hasPartialMessage: true, | ||
| stopReason: null, | ||
| startedBlockCount: 2, | ||
| completedMessageCount: 1, | ||
| hasCompletedToolUse: false, | ||
| } | ||
|
|
||
| test('detects the gateway idle-timeout signature (open block, no stop_reason)', () => { | ||
| // text block closed (completedMessageCount=1), tool_use opened but never | ||
| // closed (startedBlockCount=2), no message_delta (stopReason=null). | ||
| expect(isPrematureStreamTruncation(base)).toBe(true) | ||
| }) | ||
|
|
||
| test('does not fire when a terminal stop_reason arrived', () => { | ||
| expect( | ||
| isPrematureStreamTruncation({ ...base, stopReason: 'end_turn' }), | ||
| ).toBe(false) | ||
| expect( | ||
| isPrematureStreamTruncation({ ...base, stopReason: 'tool_use' }), | ||
| ).toBe(false) | ||
| }) | ||
|
|
||
| test('does not fire when every opened block was closed', () => { | ||
| // startedBlockCount === completedMessageCount → no dangling block. | ||
| expect( | ||
| isPrematureStreamTruncation({ | ||
| ...base, | ||
| startedBlockCount: 1, | ||
| completedMessageCount: 1, | ||
| }), | ||
| ).toBe(false) | ||
| }) | ||
|
|
||
| test('does not fire when no message_start was received', () => { | ||
| expect( | ||
| isPrematureStreamTruncation({ ...base, hasPartialMessage: false }), | ||
| ).toBe(false) | ||
| }) | ||
|
|
||
| test('does not fire when a tool_use already completed (inc-4258 guard)', () => { | ||
| // Even with an open trailing block and null stop_reason, a completed | ||
| // tool_use means the non-streaming fallback could double-execute it. | ||
| expect( | ||
| isPrematureStreamTruncation({ ...base, hasCompletedToolUse: true }), | ||
| ).toBe(false) | ||
| }) | ||
|
|
||
| test('does not fire on a legitimate empty response (no blocks at all)', () => { | ||
| // Structured-output turn 2: end_turn with zero content blocks. Here | ||
| // stopReason would be set, but even with stopReason=null a zero-block | ||
| // response has startedBlockCount === completedMessageCount === 0. | ||
| expect( | ||
| isPrematureStreamTruncation({ | ||
| ...base, | ||
| startedBlockCount: 0, | ||
| completedMessageCount: 0, | ||
| }), | ||
| ).toBe(false) | ||
| }) | ||
|
|
||
| test('fires when multiple blocks completed but a later block was cut', () => { | ||
| expect( | ||
| isPrematureStreamTruncation({ | ||
| hasPartialMessage: true, | ||
| stopReason: null, | ||
| startedBlockCount: 3, | ||
| completedMessageCount: 2, | ||
| hasCompletedToolUse: false, | ||
| }), | ||
| ).toBe(true) | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| /** | ||
| * Stream-completion classification helpers for the streaming API client. | ||
| * | ||
| * Extracted as pure functions so the tricky "was this stream truncated?" | ||
| * decision can be unit-tested without standing up a full network stream. | ||
| * | ||
| * Background: on gateway-fronted providers a long | ||
| * extended-thinking pause or a large tool_use `input_json_delta` (e.g. a big | ||
| * Write payload) can leave the SSE idle past the gateway's proxy_read_timeout | ||
| * (~180s). The gateway then closes the connection gracefully — the async | ||
| * iterator just ends with no exception, no `content_block_stop` for the open | ||
| * block and no `message_delta` carrying a terminal `stop_reason`. Without | ||
| * explicit detection the partial output is misreported as a completed | ||
| * `end_turn` and the turn silently stops half-done. | ||
| */ | ||
|
|
||
| /** | ||
| * Minimal shape needed to decide whether a completed assistant message already | ||
| * contains a tool_use block. Kept structural so callers can pass their richer | ||
| * AssistantMessage type without conversion. | ||
| */ | ||
| export interface CompletionMessageLike { | ||
| message?: { | ||
| content?: unknown | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * True if any completed message already carries a (server_)tool_use block. | ||
| * | ||
| * Used to guard against inc-4258: a tool_use that already closed may have begun | ||
| * executing via the streaming tool executor, so re-issuing the request through | ||
| * the non-streaming fallback would double-execute it. When a tool_use has | ||
| * completed we must NOT treat a subsequent truncation as recoverable here. | ||
| */ | ||
| export function hasCompletedToolUse( | ||
| messages: readonly CompletionMessageLike[], | ||
| ): boolean { | ||
| return messages.some(m => { | ||
| const content = m.message?.content | ||
| return ( | ||
| Array.isArray(content) && | ||
| content.some(b => { | ||
| const t = (b as { type?: string }).type | ||
| return t === 'tool_use' || t === 'server_tool_use' | ||
| }) | ||
| ) | ||
| }) | ||
| } | ||
|
|
||
| /** Inputs describing the final state of a streamed response. */ | ||
| export interface StreamTruncationInput { | ||
| /** Whether a `message_start` event was ever received (partialMessage set). */ | ||
| hasPartialMessage: boolean | ||
| /** Terminal stop_reason from `message_delta`, or null if none arrived. */ | ||
| stopReason: string | null | ||
| /** Count of content blocks that were opened via `content_block_start`. */ | ||
| startedBlockCount: number | ||
| /** Count of assistant messages emitted (one per `content_block_stop`). */ | ||
| completedMessageCount: number | ||
| /** Whether any completed message already contains a tool_use block. */ | ||
| hasCompletedToolUse: boolean | ||
| } | ||
|
|
||
| /** | ||
| * Detect a stream that was cut mid-turn by an intermediary (gateway idle | ||
| * timeout), as opposed to a legitimately completed or legitimately empty | ||
| * response. | ||
| * | ||
| * Signature of the truncation we recover from: | ||
| * - a `message_start` was received (`hasPartialMessage`), AND | ||
| * - no terminal `stop_reason` ever arrived (`stopReason === null`), AND | ||
| * - at least one content block was opened but never closed | ||
| * (`startedBlockCount > completedMessageCount`), AND | ||
| * - no tool_use block has already completed (avoids inc-4258 double execution). | ||
| * | ||
| * The unclosed-block check is the key discriminator: a well-behaved provider | ||
| * that merely omits `stop_reason` still emits `content_block_stop` for every | ||
| * block it opened, so it won't false-positive here — only a genuinely truncated | ||
| * stream leaves a block open. | ||
| */ | ||
| export function isPrematureStreamTruncation( | ||
| input: StreamTruncationInput, | ||
| ): boolean { | ||
| return ( | ||
| input.hasPartialMessage && | ||
| input.stopReason === null && | ||
| input.startedBlockCount > input.completedMessageCount && | ||
| !input.hasCompletedToolUse | ||
| ) | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: claude-code-best/claude-code
Length of output: 27712
🏁 Script executed:
Repository: claude-code-best/claude-code
Length of output: 28354
Do not suppress truncation detection after a completed tool use.
A stream can complete one
tool_use, open a later block, and then truncate.isPrematureStreamTruncationreturnsfalsebecausehasCompletedToolUseis true.src/services/api/claude.tsthen skips both the fallback and truncation error, so the partial response can follow the normal completion path.Separate truncation detection from fallback eligibility. Detect every dangling block. If a tool already completed, report the truncation without entering the non-streaming fallback. Otherwise, use the fallback. Update the guarded test for a completed tool followed by an open block.
🤖 Prompt for AI Agents