Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
fe35e37
fix(core): persist partial assistant turn when stream errors mid tool…
wenshao May 15, 2026
b2d332e
test(core): cover thinking+tool_use mid-stream throw in partial-histo…
wenshao May 15, 2026
3de3241
fix(core,cli): close tool_use↔tool_result invariant at failure points
wenshao May 15, 2026
b2fed61
fix(core,cli): close tool_use↔tool_result invariant at failure points
wenshao May 15, 2026
fc4d57b
fix(core,cli): close tool_use↔tool_result invariant at failure points
wenshao May 16, 2026
db344a4
fix(core): roll back partial assistant push on retryable mid-stream e…
wenshao May 16, 2026
29859cd
Merge remote-tracking branch 'origin/main' into fix/persist-partial-t…
wenshao May 16, 2026
7f1c561
fix(core): clear pendingPartialAssistantTurnIndex on history replacement
wenshao May 17, 2026
08216d9
fix(core,cli): close mimo-v2.5-pro review gaps on partial-tool_use re…
wenshao May 17, 2026
2dbfc4e
fix(core): defer chat-recording flush until partial-turn rollback dec…
wenshao May 18, 2026
fd12639
fix(core,cli): close 4 deepseek-v4-pro review threads on PR #4176
wenshao May 18, 2026
2880de5
fix(core): close 3 review threads on partial-tool_use repair (PR #4176)
wenshao May 18, 2026
ce68749
fix(core): close 6 review threads on partial-tool_use repair (PR #4176)
wenshao May 20, 2026
c8fa314
fix(core,cli): close 10 review threads on partial-tool_use repair (PR…
wenshao May 20, 2026
c30bba6
fix(core,cli): close 3 review threads on partial-tool_use repair (PR …
wenshao May 20, 2026
06a6951
test(cli): route existing dedup tests through fast-path accessor (PR …
wenshao May 20, 2026
b27085a
refactor(core,cli): address yiliang114 review observations (PR #4176)
wenshao May 20, 2026
a8ac579
Merge remote-tracking branch 'origin/main' into fix/persist-partial-t…
wenshao May 21, 2026
2985881
refactor(core): consolidate partial-tool_use repair docs into one des…
wenshao May 21, 2026
e2033ec
refactor(core): further trim partial-tool_use repair comments (PR #4176)
wenshao May 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions packages/core/src/core/geminiChat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -699,6 +699,116 @@ describe('GeminiChat', async () => {
).resolves.not.toThrow();
});

it('persists partial assistant turn when stream throws after a tool_use chunk', async () => {
// Weak-network scenario: Anthropic-compatible providers emit the
// `functionCall` part on `content_block_stop`; the SSE may then drop
// before `message_stop`. The yielded chunk is enough for `Turn.run`
// to queue a `ToolCallRequest`, the tool scheduler will eventually
// submit a `functionResponse` user turn — without a matching
// tool_use in history, the next request body shows
// `user → user[tool_result]` and DeepSeek/Anthropic rejects with
// "tool_use_id ... must have a corresponding tool_use block in the
// previous message". `processStreamResponse` must persist the
// partial model turn before re-throwing so the pairing is intact.
mockRetryWithBackoff.mockImplementation(async (apiCall) => apiCall());
const networkError = new Error('SSE connection reset by peer');
const streamThatThrowsAfterToolCall = (async function* () {
yield {
candidates: [
{
content: {
role: 'model',
parts: [
{
functionCall: {
id: 'call_00_CeJrKJB0PSmXUZTCWHET7332',
name: 'read_file',
args: { path: '/tmp/x.txt' },
},
},
],
},
},
],
} as unknown as GenerateContentResponse;
throw networkError;
})();
vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(
streamThatThrowsAfterToolCall,
);

const stream = await chat.sendMessageStream(
'test-model',
{ message: 'open /tmp/x.txt please' },
'prompt-weak-network-tool',
);
await expect(
(async () => {
for await (const _ of stream) {
/* drain */
}
})(),
).rejects.toBe(networkError);

const history = chat.getHistory();
expect(history.length).toBe(2);
expect(history[0]!.role).toBe('user');
const modelTurn = history[1]!;
expect(modelTurn.role).toBe('model');
expect(modelTurn.parts).toBeDefined();
const functionCallPart = modelTurn.parts!.find((p) => p.functionCall);
expect(functionCallPart?.functionCall?.id).toBe(
'call_00_CeJrKJB0PSmXUZTCWHET7332',
);
expect(functionCallPart?.functionCall?.name).toBe('read_file');
});

it('does NOT persist partial assistant turn when stream throws before any tool_use chunk', async () => {
// Plain-text partial responses are deliberately dropped on stream
// error: the Retry path pops the trailing user prompt and re-issues
// it, so a stale partial-text model turn between them would bias
// the retry or surface as duplicate output. Only tool_use turns
// need the partial-history bridge to preserve the tool_use →
// tool_result invariant — text alone has no such invariant.
mockRetryWithBackoff.mockImplementation(async (apiCall) => apiCall());
const networkError = new Error('connection reset');
const streamThatThrowsAfterText = (async function* () {
yield {
candidates: [
{
content: {
role: 'model',
parts: [{ text: 'partial reply that will be lost' }],
},
},
],
} as unknown as GenerateContentResponse;
throw networkError;
})();
vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(
streamThatThrowsAfterText,
);

const stream = await chat.sendMessageStream(
'test-model',
{ message: 'hello' },
'prompt-weak-network-text',
);
await expect(
(async () => {
for await (const _ of stream) {
/* drain */
}
})(),
).rejects.toBe(networkError);

const history = chat.getHistory();
// Only the user turn is in history — the partial-text model turn is
// intentionally not persisted.
expect(history.length).toBe(1);
expect(history[0]!.role).toBe('user');
});

it('should throw InvalidStreamError when no tool call and no finish reason', async () => {
vi.useFakeTimers();
try {
Expand Down
126 changes: 90 additions & 36 deletions packages/core/src/core/geminiChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1257,48 +1257,62 @@ export class GeminiChat {

let hasToolCall = false;
let hasFinishReason = false;
// Captured if the upstream stream throws mid-iteration (typical on weak
// networks: SSE drops between `content_block_stop` of a tool_use and the
// terminal `message_stop`). We still build / record / push a partial
// assistant turn below before re-throwing — see the dedicated branch in
// the post-loop block for why this is needed to keep tool_use/tool_result
// pairing intact across the failure.
let streamError: unknown = null;

for await (const chunk of streamResponse) {
// Use ||= to avoid later usage-only chunks (no candidates) overwriting
// a finishReason that was already seen in an earlier chunk.
hasFinishReason ||=
chunk?.candidates?.some((candidate) => candidate.finishReason) ?? false;

if (isValidResponse(chunk)) {
const content = chunk.candidates?.[0]?.content;
if (content?.parts) {
if (content.parts.some((part) => part.functionCall)) {
hasToolCall = true;
}
try {
for await (const chunk of streamResponse) {
// Use ||= to avoid later usage-only chunks (no candidates) overwriting
// a finishReason that was already seen in an earlier chunk.
hasFinishReason ||=
chunk?.candidates?.some((candidate) => candidate.finishReason) ??
false;

if (isValidResponse(chunk)) {
const content = chunk.candidates?.[0]?.content;
if (content?.parts) {
if (content.parts.some((part) => part.functionCall)) {
hasToolCall = true;
}

// Collect all parts for recording
allModelParts.push(...content.parts);
// Collect all parts for recording
allModelParts.push(...content.parts);
}
}
}

// Collect token usage for consolidated recording
if (chunk.usageMetadata) {
usageMetadata = chunk.usageMetadata;
// Context usage tracks prompt size; output isn't in history yet.
const lastPromptTokenCount =
usageMetadata.promptTokenCount || usageMetadata.totalTokenCount;
if (lastPromptTokenCount) {
// Always update the per-chat counter so this chat (including
// subagents) can make its own compaction decisions.
this.lastPromptTokenCount = lastPromptTokenCount;
// Mirror to the global telemetry only when wired — subagents
// pass `telemetryService=undefined` to keep their context usage
// out of the main session's UI counters.
this.telemetryService?.setLastPromptTokenCount(lastPromptTokenCount);
}
if (usageMetadata.cachedContentTokenCount && this.telemetryService) {
this.telemetryService.setLastCachedContentTokenCount(
usageMetadata.cachedContentTokenCount,
);
// Collect token usage for consolidated recording
if (chunk.usageMetadata) {
usageMetadata = chunk.usageMetadata;
// Context usage tracks prompt size; output isn't in history yet.
const lastPromptTokenCount =
usageMetadata.promptTokenCount || usageMetadata.totalTokenCount;
if (lastPromptTokenCount) {
// Always update the per-chat counter so this chat (including
// subagents) can make its own compaction decisions.
this.lastPromptTokenCount = lastPromptTokenCount;
// Mirror to the global telemetry only when wired — subagents
// pass `telemetryService=undefined` to keep their context usage
// out of the main session's UI counters.
this.telemetryService?.setLastPromptTokenCount(
lastPromptTokenCount,
);
}
if (usageMetadata.cachedContentTokenCount && this.telemetryService) {
this.telemetryService.setLastCachedContentTokenCount(
usageMetadata.cachedContentTokenCount,
);
}
}
}

yield chunk; // Yield every chunk to the UI immediately.
yield chunk; // Yield every chunk to the UI immediately.
}
} catch (e) {
streamError = e;
}

let thoughtContentPart: Part | undefined;
Expand Down Expand Up @@ -1369,6 +1383,46 @@ export class GeminiChat {
});
Comment thread
wenshao marked this conversation as resolved.
Outdated
}

// Mid-stream failure recovery: if the upstream stream threw (typical on
// weak networks — SSE cut between a tool_use `content_block_stop` and
// the terminal `message_stop`) AND any `functionCall` chunk was already
// yielded to consumers, we must persist the partial assistant turn here.
//
// The content generator (Anthropic / OpenAI) emits a `functionCall` part
// only at the end of a tool_use block. Once yielded, `Turn.run` registers
// a `ToolCallRequest` event, the React tool scheduler queues the call,
// and `handleCompletedTools` will fire `submitQuery(..., ToolResult)` —
// pushing a user message with `functionResponse` into history — even
// though the parent stream errored. Without preserving the matching
// tool_use on the model side, the next request body would have
// `user → user[tool_result]` with no tool_use in between, and the
// Anthropic-compatible API (DeepSeek, Anthropic, etc.) rejects with
// "tool_use_id ... must have a corresponding tool_use block in the
// previous message"
// — an unrecoverable state because Ctrl+Y's `stripOrphanedUserEntries`
// only strips trailing user entries; the lost tool_use can't be
// resurrected.
//
// Plain-text partial turns (no functionCall yielded) are deliberately
// NOT persisted — the Retry path pops the trailing user prompt and
// re-issues it; a stale partial-text model turn between them would
// either bias the retry or surface as a duplicate.
if (streamError !== null) {
if (
hasToolCall &&
Comment thread
wenshao marked this conversation as resolved.
Outdated
(thoughtContentPart || consolidatedHistoryParts.length > 0)
) {
this.history.push({
role: 'model',
parts: [
...(thoughtContentPart ? [thoughtContentPart] : []),
...consolidatedHistoryParts,
],
});
}
Comment thread
wenshao marked this conversation as resolved.
throw streamError;
}
Comment thread
wenshao marked this conversation as resolved.
Outdated
Comment thread
wenshao marked this conversation as resolved.
Outdated

// Stream validation logic: A stream is considered successful if:
// 1. There's a tool call (tool calls can end without explicit finish reasons), OR
// 2. There's a finish reason AND we have non-empty response text or thought text
Expand Down
Loading