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
Prev Previous commit
Next Next commit
fix(core,cli): close tool_use↔tool_result invariant at failure points
Extends the partial-history fix in fe35e37 to cover the residual race
paths surfaced in PR #4176 review:

  - Race A: Ctrl+Y while in-flight tool hasn't finished. History is
    [user, model(tool_use)] — `stripOrphanedUserEntriesFromHistory` only
    pops trailing user entries, so the retry payload lands as a fresh
    user turn after the orphan tool_use and API rejects. Meanwhile the
    scheduler's `onAllToolCallsComplete` is single-shot and gated on
    `isResponding`, so the eventual tool_result is silently swallowed.
  - Race B: process crash / OOM / SIGKILL between the partial-tool_use
    push and the React scheduler's tool_result submission. On `--resume`
    the dangling model(tool_use) wedges the first API call.
  - Race C: external tooling / manual JSONL edits leaving the same
    dangling shape.

The fix has three pieces working together:

1. `repairOrphanedToolUseTurns(history)` in geminiChat.ts walks history
   left-to-right and synthesizes an `error`-typed functionResponse for
   every functionCall whose id is not echoed back in the next user
   turn. Appends to an existing user turn when present, otherwise
   inserts a new one. Returns the injected (callId, name) list.

2. `GeminiClient.repairOrphanedToolUseTurnsInHistory()` wraps the helper
   and is called from three points:
     - `startChat()` after loading the transcript (Race B/C, --resume).
     - `sendMessageStream` Retry branch after stripOrphans (Race A).
     - `sendMessageStream` UserQuery/Cron branch (defensive belt-and-
       suspenders for anything that slipped past 1 and 2).

3. `handleCompletedTools` in useGeminiStream.ts dedupes against
   chat.history before submitting tool_results — if a synthetic
   functionResponse for the same callId is already present (planted by
   the repair pass), the in-flight scheduler's late result is dropped
   and the call is `markToolsAsSubmitted` so the UI advances. Same
   trade-off upstream Claude Code's `StreamingToolExecutor.discard()`
   makes — late real results are dropped on the wire after synthesis,
   the model sees the synthetic error and can retry the tool if it
   still wants the result.

Together with the partial-history push from fe35e37, every tool_use
that ever streamed to the consumer is guaranteed to have a matching
tool_result on the wire — regardless of whether the stream errored,
the user retried mid-flight, the process crashed, or the session is
later resumed. This is the qwen-code analogue of upstream Claude Code's
`yieldMissingToolResultBlocks` (query.ts:123-149), but split across
the core/cli boundary because the React tool scheduler runs out-of-band
from the stream loop (so the synthesis path can't atomically discard
in-flight tools the way upstream's StreamingToolExecutor can; the
history-dedup at handleCompletedTools fills that gap instead).

Tests:
  - 8 new repair-helper tests in geminiChat.test.ts cover Race A,
    Race B, partial coverage of parallel tool_use, idempotence on
    already-paired history, no-op on tool-free history, caller-
    supplied reason text, multiple non-adjacent dangling rounds, and
    routes through the GeminiChat instance-method wrapper.
  - client.test.ts mocks updated for the new GeminiChat method.
  - All 88 geminiChat tests pass; 131 client tests pass; 91
    useGeminiStream tests pass.
  • Loading branch information
wenshao committed May 15, 2026
commit 3de3241a2c2cb82560db01558c81b09103b80c5e
49 changes: 47 additions & 2 deletions packages/cli/src/ui/hooks/useGeminiStream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2019,17 +2019,62 @@ export const useGeminiStream = (
);
}

const geminiTools = completedAndReadyToSubmitTools.filter(
const geminiToolsRaw = completedAndReadyToSubmitTools.filter(
(t) => !t.request.isClientInitiated,
);

for (const toolCall of geminiTools) {
for (const toolCall of geminiToolsRaw) {
geminiClient?.recordCompletedToolCall(
toolCall.request.name,
toolCall.request.args as Record<string, unknown>,
);
}

// History-based dedup: if a synthetic `functionResponse` for this
// callId is already in chat.history (planted by
// `client.repairOrphanedToolUseTurnsInHistory()` on session-load or
// Retry), the in-flight scheduler result would land as a duplicate
// `tool_result` and produce two consecutive user turns where the
// second is orphaned (no preceding tool_use — the synthetic ate it).
//
// For dedup hits: mark the tool as submitted so the UI advances and
// `useReactToolScheduler.allToolCallsCompleteHandler` (single-shot)
// doesn't leave the call permanently stuck in `completed-but-not-
// submitted`. The real result is dropped on the wire — same trade-off
// upstream Claude Code makes when its `StreamingToolExecutor.discard()`
// is followed by a `yieldMissingToolResultBlocks` synthesis
// (`query.ts:733` + `:984`). The model sees the synthetic error and
// can retry the tool if it still wants the result.
const historyCallIdsWithResponse = new Set<string>();
// Guard the call: some test harnesses build a partial GeminiClient
// mock without `getHistory`. Skipping dedup in that case is safe —
// it just means tests that never set up the repair pre-condition
// run with the original (pre-dedup) submission shape.
if (geminiClient && typeof geminiClient.getHistory === 'function') {
for (const entry of geminiClient.getHistory()) {
if (entry.role !== 'user') continue;
for (const part of entry.parts ?? []) {
const id = part.functionResponse?.id;
if (id) historyCallIdsWithResponse.add(id);
}
}
}

const dedupedCallIds = geminiToolsRaw
.filter((tc) => historyCallIdsWithResponse.has(tc.request.callId))
.map((tc) => tc.request.callId);
if (dedupedCallIds.length > 0) {
debugLogger.warn(
`[REPAIR] Dropping ${dedupedCallIds.length} late tool result(s) ` +
`whose callId already has a synthetic functionResponse in ` +
`history: ${dedupedCallIds.join(', ')}`,
);
markToolsAsSubmitted(dedupedCallIds);
}
const geminiTools = geminiToolsRaw.filter(
(tc) => !historyCallIdsWithResponse.has(tc.request.callId),
);

if (geminiTools.length === 0) {
return;
}
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/core/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1453,6 +1453,7 @@ describe('Gemini Client (client.ts)', () => {
getHistory: vi.fn().mockReturnValue([]),
getHistoryLength,
stripOrphanedUserEntriesFromHistory,
repairOrphanedToolUseTurns: vi.fn().mockReturnValue({ injected: [] }),
} as unknown as GeminiChat;
mockTurnRunFn.mockReturnValue(
(async function* () {
Expand Down Expand Up @@ -4205,6 +4206,7 @@ Other open files:
getHistoryLength: vi.fn().mockReturnValueOnce(3).mockReturnValue(2),
setHistory: vi.fn(),
stripOrphanedUserEntriesFromHistory: vi.fn(),
repairOrphanedToolUseTurns: vi.fn().mockReturnValue({ injected: [] }),
};
client['chat'] = mockChat as GeminiChat;

Expand Down Expand Up @@ -4237,6 +4239,7 @@ Other open files:
getHistoryLength: vi.fn().mockReturnValue(0),
setHistory: vi.fn(),
stripOrphanedUserEntriesFromHistory: vi.fn(),
repairOrphanedToolUseTurns: vi.fn().mockReturnValue({ injected: [] }),
};
client['chat'] = mockChat as GeminiChat;

Expand Down
64 changes: 64 additions & 0 deletions packages/core/src/core/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,44 @@ export class GeminiClient {
this.forceFullIdeContext = true;
}

/**
* Synthesize a `functionResponse` for every dangling `model[functionCall]`
* in chat history whose corresponding tool_result never landed. Inverse of
* {@link stripOrphanedUserEntriesFromHistory}, which only handles trailing
* `user` entries.
*
* Called from three points:
Comment thread
wenshao marked this conversation as resolved.
Outdated
* 1. After {@link startChat} loads transcript (covers `--resume` of a
* session that crashed between partial-tool_use push and tool
* completion).
* 2. After `stripOrphanedUserEntriesFromHistory` on the Retry submit path
* (covers Ctrl+Y race — user retries while an in-flight tool's
* `tool_result` has not yet been submitted, leaving a trailing
* `model[functionCall]` without matching `functionResponse`).
* 3. Defensively at the start of UserQuery / Cron sends, so any state
* that slipped past 1+2 still gets fixed before hitting the wire.
*
* Synthesizes an `error` `functionResponse`. The React tool scheduler
* (`useGeminiStream.handleCompletedTools`) MUST dedupe by `callId` against
* the live history before submitting its own `tool_result` — otherwise a
* late real result lands as a second `user[tool_result]` block (orphan
* because the synthetic already consumed the matching `tool_use`).
*/
repairOrphanedToolUseTurnsInHistory(reason?: string): {
injected: Array<{ callId: string; name: string }>;
} {
const result = this.getChat().repairOrphanedToolUseTurns(reason);
if (result.injected.length > 0) {
debugLogger.warn(
`[REPAIR] Synthesized ${result.injected.length} functionResponse(s) ` +
`for dangling tool_use(s): ${result.injected
.map((e) => `${e.name}(${e.callId})`)
.join(', ')}`,
);
}
return result;
}

setHistory(history: Content[]) {
this.getChat().setHistory(history);
// Replacing history wholesale drops any prior read_file tool
Expand Down Expand Up @@ -656,6 +694,16 @@ export class GeminiClient {
uiTelemetryService,
);

// Repair any dangling `model[functionCall]` whose `functionResponse`
// never made it back into the transcript before we wrote the JSONL.
// The common cause is a process crash / OOM / SIGKILL between the
// partial-tool_use push (see `processStreamResponse`) and the React
// scheduler's tool_result submission. Without this pass, the first
// API call on a resumed session would 400 with the same
// `tool_use_id ... corresponding tool_use` error this whole subsystem
// is trying to escape.
this.chat.repairOrphanedToolUseTurns();

const sessionStartAdditionalContext =
await this.fireSessionStartHook(sessionStartSource);
this.lastSessionStartContext = sessionStartAdditionalContext;
Expand Down Expand Up @@ -1043,6 +1091,22 @@ export class GeminiClient {

if (messageType === SendMessageType.Retry) {
this.stripOrphanedUserEntriesFromHistory();
// Close any dangling `model[functionCall]` whose tool_result never
// landed before composing the retry payload. Ctrl+Y race: the user
// retried while a tool was still running on a partial-tool_use turn
// pushed by `processStreamResponse`'s mid-stream error path. The
// scheduler's `onAllToolCallsComplete` is single-shot and gated on
// `isResponding` (`useGeminiStream:1971`), so the eventual
// `tool_result` would otherwise be silently swallowed and the next
// API call would 400 with "tool_use_id ... corresponding tool_use"
// anyway. The synthesized `error` `functionResponse` keeps the wire
// invariant intact; the live scheduler dedupes against history in
// `handleCompletedTools` before submitting its real result so the
// synthetic doesn't collide with a late real one.
//
// Restricted to the Retry branch to mirror `stripOrphanedUserEntries`
// scope. Crash-resume's path is covered separately in `startChat()`.
this.repairOrphanedToolUseTurnsInHistory();
}

// Fire UserPromptSubmit hook through MessageBus (only if hooks are enabled)
Expand Down
Loading
Loading