mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-09-03 06:44:35 +00:00
fix(core,cli): close tool_use↔tool_result invariant at failure points
Extends the partial-history fix infe35e3778to 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 fromfe35e3778, 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.
This commit is contained in:
parent
b2d332e005
commit
3de3241a2c
5 changed files with 496 additions and 2 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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* () {
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
* 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
|
||||
|
|
@ -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;
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -3312,6 +3312,277 @@ describe('GeminiChat', async () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('repairOrphanedToolUseTurns', () => {
|
||||
// Verifies the inverse-of-strip pass: every `model[functionCall]`
|
||||
// without a matching `user[functionResponse]` in the next turn gets
|
||||
// a synthesized error functionResponse. This closes the
|
||||
// tool_use ↔ tool_result wire invariant for the residual races
|
||||
// (`--resume` of a crashed session, Ctrl+Y before in-flight tool
|
||||
// finishes, scheduler abort before submitQuery, manual JSONL edits).
|
||||
|
||||
it('injects a synthetic functionResponse for a trailing tool_use (Race B/C)', () => {
|
||||
// --resume of a session that crashed after the partial-tool_use push
|
||||
// in `processStreamResponse` but before the scheduler submitted the
|
||||
// tool_result. First API call would 400 without repair.
|
||||
chat.setHistory([
|
||||
{ role: 'user', parts: [{ text: 'open /tmp/a.txt' }] },
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
functionCall: {
|
||||
id: 'call_crash_A',
|
||||
name: 'read_file',
|
||||
args: { path: '/tmp/a.txt' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const result = chat.repairOrphanedToolUseTurns();
|
||||
|
||||
expect(result.injected).toEqual([
|
||||
{ callId: 'call_crash_A', name: 'read_file' },
|
||||
]);
|
||||
const history = chat.getHistory();
|
||||
expect(history.length).toBe(3);
|
||||
expect(history[2]!.role).toBe('user');
|
||||
const fr = history[2]!.parts![0]!.functionResponse;
|
||||
expect(fr?.id).toBe('call_crash_A');
|
||||
expect(fr?.name).toBe('read_file');
|
||||
expect((fr?.response as { error?: string })?.error).toMatch(
|
||||
/interrupted/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('appends synthetic functionResponse onto an existing user turn (Race A)', () => {
|
||||
// Ctrl+Y race: the user retried while the in-flight tool was still
|
||||
// running. `stripOrphanedUserEntriesFromHistory` leaves the
|
||||
// model[functionCall] in place (trailing entry is model), then the
|
||||
// Retry pushes a fresh user turn with the user prompt. Repair must
|
||||
// splice the synthetic response onto that user turn so it sits
|
||||
// immediately after the model[tool_use] — NOT create a stray
|
||||
// synthetic user turn between them.
|
||||
chat.setHistory([
|
||||
{ role: 'user', parts: [{ text: 'open /tmp/a.txt' }] },
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
functionCall: {
|
||||
id: 'call_race_A',
|
||||
name: 'read_file',
|
||||
args: { path: '/tmp/a.txt' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: 'user', parts: [{ text: 'retry prompt' }] },
|
||||
]);
|
||||
|
||||
const result = chat.repairOrphanedToolUseTurns();
|
||||
|
||||
expect(result.injected.map((e) => e.callId)).toEqual(['call_race_A']);
|
||||
const history = chat.getHistory();
|
||||
// No new turn inserted — synthetic merges into existing user turn.
|
||||
expect(history.length).toBe(3);
|
||||
expect(history[2]!.role).toBe('user');
|
||||
expect(history[2]!.parts!.length).toBe(2);
|
||||
expect(history[2]!.parts![0]).toEqual({ text: 'retry prompt' });
|
||||
expect(history[2]!.parts![1]!.functionResponse?.id).toBe('call_race_A');
|
||||
});
|
||||
|
||||
it('handles parallel tool_use turns with only some responses present', () => {
|
||||
// Common shape after #4176's partial-history push: the stream
|
||||
// emitted multiple `content_block_stop`s for parallel tool_uses,
|
||||
// but the React scheduler only submitted some before the user hit
|
||||
// Ctrl+Y. The Retry path's repair must close every missing pair —
|
||||
// the present `functionResponse` for A must NOT be duplicated.
|
||||
chat.setHistory([
|
||||
{ role: 'user', parts: [{ text: 'batch read' }] },
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
functionCall: {
|
||||
id: 'call_A',
|
||||
name: 'read_file',
|
||||
args: { path: '/a' },
|
||||
},
|
||||
},
|
||||
{
|
||||
functionCall: {
|
||||
id: 'call_B',
|
||||
name: 'read_file',
|
||||
args: { path: '/b' },
|
||||
},
|
||||
},
|
||||
{
|
||||
functionCall: {
|
||||
id: 'call_C',
|
||||
name: 'read_file',
|
||||
args: { path: '/c' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: 'call_A',
|
||||
name: 'read_file',
|
||||
response: { output: 'a-content' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const result = chat.repairOrphanedToolUseTurns();
|
||||
|
||||
const injectedIds = result.injected.map((e) => e.callId);
|
||||
expect(injectedIds.sort()).toEqual(['call_B', 'call_C']);
|
||||
const history = chat.getHistory();
|
||||
// Same shape — synthetics merge into the existing user turn.
|
||||
expect(history.length).toBe(3);
|
||||
const fr = history[2]!.parts!.map((p) => p.functionResponse?.id);
|
||||
expect(fr).toEqual(['call_A', 'call_B', 'call_C']);
|
||||
// The pre-existing `call_A` response is untouched (real result kept).
|
||||
expect(
|
||||
(
|
||||
history[2]!.parts![0]!.functionResponse?.response as {
|
||||
output?: string;
|
||||
}
|
||||
)?.output,
|
||||
).toBe('a-content');
|
||||
});
|
||||
|
||||
it('is a no-op when every tool_use already has a matching response', () => {
|
||||
// Happy path: don't churn history when the invariant already holds.
|
||||
const happy = [
|
||||
{ role: 'user' as const, parts: [{ text: 'q' }] },
|
||||
{
|
||||
role: 'model' as const,
|
||||
parts: [
|
||||
{
|
||||
functionCall: {
|
||||
id: 'call_ok',
|
||||
name: 'read_file',
|
||||
args: {},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'user' as const,
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: 'call_ok',
|
||||
name: 'read_file',
|
||||
response: { output: 'fine' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
chat.setHistory(structuredClone(happy));
|
||||
|
||||
const result = chat.repairOrphanedToolUseTurns();
|
||||
|
||||
expect(result.injected).toEqual([]);
|
||||
expect(chat.getHistory()).toEqual(happy);
|
||||
});
|
||||
|
||||
it('repairs multiple non-adjacent dangling tool_uses across history', () => {
|
||||
// Stress case for the forward-walk algorithm: dangling turn near the
|
||||
// start AND another near the end. Both should be repaired and the
|
||||
// outer loop must not re-scan synthetic user turns it just inserted.
|
||||
chat.setHistory([
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
functionCall: {
|
||||
id: 'early_orphan',
|
||||
name: 'glob',
|
||||
args: {},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: 'user', parts: [{ text: 'second user prompt' }] },
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
functionCall: {
|
||||
id: 'late_orphan',
|
||||
name: 'read_file',
|
||||
args: { path: '/x' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const result = chat.repairOrphanedToolUseTurns();
|
||||
|
||||
const injectedIds = result.injected.map((e) => e.callId);
|
||||
expect(injectedIds.sort()).toEqual(['early_orphan', 'late_orphan']);
|
||||
const history = chat.getHistory();
|
||||
// early_orphan got the synthetic spliced into the existing user turn
|
||||
// between the two model entries; late_orphan got a brand-new
|
||||
// trailing user turn appended after the second model entry.
|
||||
expect(history.length).toBe(4);
|
||||
expect(history[0]!.role).toBe('model');
|
||||
expect(history[1]!.role).toBe('user');
|
||||
expect(
|
||||
history[1]!.parts!.some(
|
||||
(p) => p.functionResponse?.id === 'early_orphan',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(history[2]!.role).toBe('model');
|
||||
expect(history[3]!.role).toBe('user');
|
||||
expect(history[3]!.parts![0]!.functionResponse?.id).toBe('late_orphan');
|
||||
});
|
||||
|
||||
it('ignores model turns with no functionCall parts', () => {
|
||||
const plain = [
|
||||
{ role: 'user' as const, parts: [{ text: 'hi' }] },
|
||||
{ role: 'model' as const, parts: [{ text: 'hello' }] },
|
||||
];
|
||||
chat.setHistory(structuredClone(plain));
|
||||
|
||||
const result = chat.repairOrphanedToolUseTurns();
|
||||
|
||||
expect(result.injected).toEqual([]);
|
||||
expect(chat.getHistory()).toEqual(plain);
|
||||
});
|
||||
|
||||
it('uses caller-provided reason text', () => {
|
||||
chat.setHistory([
|
||||
{ role: 'user', parts: [{ text: 'q' }] },
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
functionCall: { id: 'cid', name: 'read_file', args: {} },
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
chat.repairOrphanedToolUseTurns('custom reason');
|
||||
|
||||
const fr = chat.getHistory()[2]!.parts![0]!.functionResponse;
|
||||
expect((fr?.response as { error?: string })?.error).toBe('custom reason');
|
||||
});
|
||||
});
|
||||
|
||||
describe('output token recovery', () => {
|
||||
function makeChunk(
|
||||
parts: Array<{ text?: string; functionCall?: unknown }>,
|
||||
|
|
|
|||
|
|
@ -378,6 +378,101 @@ export class InvalidStreamError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default error text used when a synthesized `functionResponse` has to stand
|
||||
* in for a real tool result that never made it back into history (e.g. the
|
||||
* process crashed between the partial-tool_use push and tool completion, or
|
||||
* the user hit Ctrl+Y before the in-flight tool finished and the scheduler's
|
||||
* `onAllToolCallsComplete` was a single-shot that already fired into an
|
||||
* `isResponding` early-return).
|
||||
*/
|
||||
const ORPHAN_TOOL_USE_REPAIR_REASON =
|
||||
'Tool execution result was not recorded — likely interrupted by network ' +
|
||||
'failure, abort, or process exit. Treat as failure and retry if needed.';
|
||||
|
||||
/**
|
||||
* Walk `history` left-to-right and close every dangling tool_use ↔ tool_result
|
||||
* pair by synthesizing a `functionResponse` with an `error` field for any
|
||||
* `functionCall` part whose `id` is not echoed back in the immediately
|
||||
* following user turn.
|
||||
*
|
||||
* Mutates `history` in place and returns the set of injected `(callId, name)`
|
||||
* tuples so callers (the React tool scheduler) can dedupe a real `tool_result`
|
||||
* if the in-flight tool completes after the repair.
|
||||
*
|
||||
* The synthesis target follows this rule:
|
||||
* - If the next entry is a `user` turn → append synthetic parts to it.
|
||||
* - If the next entry is a `model` turn or end-of-history → insert a new
|
||||
* `user` turn between them carrying just the synthetic parts.
|
||||
*
|
||||
* This is the qwen-code analogue of upstream Claude Code's
|
||||
* `yieldMissingToolResultBlocks` (`query.ts:123-149`). Upstream can call it
|
||||
* unconditionally at every error path because their `StreamingToolExecutor`
|
||||
* is in-band — they atomically `.discard()` in-flight tools at the synthesis
|
||||
* point. Our React scheduler runs out-of-band, so the caller pairs this with
|
||||
* dedup in `handleCompletedTools` (which skips submission for any callId
|
||||
* already present in history). See PR review thread on #4176 for the full
|
||||
* race-class analysis.
|
||||
*/
|
||||
export function repairOrphanedToolUseTurns(
|
||||
history: Content[],
|
||||
reason: string = ORPHAN_TOOL_USE_REPAIR_REASON,
|
||||
): { injected: Array<{ callId: string; name: string }> } {
|
||||
const injected: Array<{ callId: string; name: string }> = [];
|
||||
|
||||
// Forward walk: i mutates as we splice, so use index-based iteration
|
||||
// and skip the freshly-inserted user turn to avoid re-scanning it.
|
||||
for (let i = 0; i < history.length; i++) {
|
||||
const turn = history[i];
|
||||
if (turn.role !== 'model') continue;
|
||||
|
||||
// Collect (id → name) for every functionCall in this model turn.
|
||||
const expected = new Map<string, string>();
|
||||
for (const part of turn.parts ?? []) {
|
||||
const fc = part.functionCall;
|
||||
if (fc?.id) {
|
||||
expected.set(fc.id, fc.name ?? 'unknown');
|
||||
}
|
||||
}
|
||||
if (expected.size === 0) continue;
|
||||
|
||||
const next = history[i + 1];
|
||||
const matched = new Set<string>();
|
||||
if (next?.role === 'user') {
|
||||
for (const part of next.parts ?? []) {
|
||||
const id = part.functionResponse?.id;
|
||||
if (id) matched.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
const missing = [...expected.entries()].filter(([id]) => !matched.has(id));
|
||||
if (missing.length === 0) continue;
|
||||
|
||||
const syntheticParts: Part[] = missing.map(([callId, name]) => ({
|
||||
functionResponse: {
|
||||
id: callId,
|
||||
name,
|
||||
response: { error: reason },
|
||||
},
|
||||
}));
|
||||
|
||||
if (next?.role === 'user') {
|
||||
next.parts = [...(next.parts ?? []), ...syntheticParts];
|
||||
} else {
|
||||
history.splice(i + 1, 0, { role: 'user', parts: syntheticParts });
|
||||
// Skip the freshly-inserted user turn so the outer loop doesn't
|
||||
// visit it as a model turn (it isn't) and stays linear-time.
|
||||
i++;
|
||||
}
|
||||
|
||||
for (const [callId, name] of missing) {
|
||||
injected.push({ callId, name });
|
||||
}
|
||||
}
|
||||
|
||||
return { injected };
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat session that enables sending messages to the model with previous
|
||||
* conversation context.
|
||||
|
|
@ -1208,6 +1303,22 @@ export class GeminiChat {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Repair the inverse of `stripOrphanedUserEntriesFromHistory`: close every
|
||||
* dangling `model[functionCall]` whose corresponding `user[functionResponse]`
|
||||
* never landed (e.g. process crash between the partial-tool_use push and
|
||||
* tool completion, or Ctrl+Y race before in-flight scheduler completed).
|
||||
*
|
||||
* Returns the list of synthesized `(callId, name)` tuples so the React
|
||||
* tool scheduler can dedupe its eventual real `tool_result` for those
|
||||
* callIds (see `handleCompletedTools` in `useGeminiStream.ts`).
|
||||
*/
|
||||
repairOrphanedToolUseTurns(reason?: string): {
|
||||
injected: Array<{ callId: string; name: string }>;
|
||||
} {
|
||||
return repairOrphanedToolUseTurns(this.history, reason);
|
||||
}
|
||||
|
||||
setTools(tools: Tool[]): void {
|
||||
this.generationConfig.tools = tools;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue