fix(core): close 6 review threads on partial-tool_use repair (PR #4176)

Six follow-ups from the qwen-latest-series-invite-beta-v28 + gpt-5.5
passes on commit 2880de577:

[A] CORRECTNESS — `repairOrphanedToolUseTurns` now HOISTS a real
`functionResponse` from a non-adjacent later user turn into the
IMMEDIATE next user turn (placed before non-fr parts), in addition to
the forward-scan dedup added in 2880de577. The shape `model[fc],
user[text], user[fr_real]` arises when a user aborts a long-running
tool, types a follow-up text turn, and the React scheduler's late
`submitQuery` appends the real `tool_result` as a SEPARATE user entry.
Forward-scan alone correctly skipped the synthesis duplicate, but the
wire layout still serialized `model[tool_use] → user[text] →
user[tool_result]`, which Anthropic-compatible backends reject with
"tool_use_id ... must have a corresponding tool_use block in the
previous message". The hoist physically moves the real fr part out of
its original turn into history[i+1], drops the source turn if it
becomes empty, and reuses the existing front-of-user-turn
insertion logic so caller-supplied fr ordering stays preserved.
Hoisted ids are NOT added to `injected` — the real fr is already in
history (just relocated), so the React scheduler's history-based
dedup handles them naturally without an extra entry that would
double-trigger the dedup-drop log.

Three new tests in `geminiChat.test.ts`:
  - "hoists the real functionResponse from a non-adjacent later user
    turn into the adjacent one" — pure relocate case (source turn had
    only the fr → removed).
  - "synthesizes missing fr AND hoists real fr in a parallel tool_use
    mismatch" — parallel `model[fc_a, fc_b]` with real fr_a in a
    non-adjacent turn: cid_b synthesized, cid_a hoisted, source turn
    removed.
  - "hoists real fr but preserves the source user turn when it
    carries other content" — pins the empty-turn cleanup so it only
    drops turns whose parts list goes to zero (mixed text + fr source
    keeps its text after the fr is extracted).

[B] CRITICAL — moved the deferred chat-recording flush from BEFORE the
max-tokens escalation block into the outer `finally`. The escalated
`makeApiCallAndProcessStream → processStreamResponse` can set a NEW
`pendingPartialAssistantRecord` if it errors mid-tool_use, and that
throw escapes through the for-await without touching the (now-passed)
retry-loop catch. Before this fix the new record was never appended
to JSONL: live history retained the partial `model[functionCall]`
that the escalated processStreamResponse pushed, but the durable
transcript silently dropped it; on `--resume` the transcript-load
path didn't see the partial, `repairOrphanedToolUseTurnsInHistory`
found nothing to repair, and the React scheduler's late real result
became a permanent orphan — reproducing the exact wedge this PR
prevents. Putting the flush in `finally` covers all throw paths
(escalation, post-retry-loop `throw lastError`, consumer `.return()`)
and the normal completion path with one statement, while keeping the
"marker and stash cleared in lockstep" invariant.

One new regression test: "flushes the JSONL record when escalated
stream throws mid-tool_use" — pins the flush behavior under the
specific shape (initial MAX_TOKENS success → escalation cuts mid-
tool_use → throw). Asserts both that the partial survives in
`this.history` and that `recordAssistantTurn` got called with the
partial functionCall id.

[C] DOC — updated `repairOrphanedToolUseTurns` doc comment to spell
out both fix-ups (synthesize + hoist), the wire-format consequence of
removal ("tool_use_id ... must have a corresponding tool_use block"),
and the rule that hoisted ids are not in the returned `injected` list.
The previous "immediately following user turn" wording contradicted
the forward-scan implementation; the new wording matches the actual
behavior across both fix-up paths.

[D] OBSERVABILITY — inline `repairOrphanedToolUseTurns` call in
`sendMessageStream` now logs `[REPAIR] sendMessageStream inline pass
synthesized N functionResponse(s) ...` when synthesis fires. The
startChat() path already logs via `repairOrphanedToolUseTurnsInHistory`
(`[REPAIR] Synthesized ...`), and `useGeminiStream.handleCompletedTools`
logs `[REPAIR] Dropping ...` on dedup. Without a tagged log at the
inline call site, an investigator looking at a dedup-drop had no way
to tell whether the synthetic was planted at session-load or at the
per-send pass.

[E] OBSERVABILITY — `popPartialIfPushed` now logs `[PARTIAL_POP]
Splice skipped` when the marker is set but `history.length <= idx` or
`history[idx]?.role !== 'model'`. This can't happen today (every
history-mutation method clears the marker in lockstep), but the warn
makes any future regression observable rather than silent: a
mutation path that forgets to clear would otherwise leave a stale
partial in history with no diagnostic trace.

[F] CLARITY — added a comment at the post-`tryCompress`
`popPartialIfPushed()` call explaining the intentional defense-in-
depth no-op. `tryCompress()` succeeds → `setHistory()` →
`clearPendingPartialState()`, so the marker is null by the time the
pop runs. The call is kept (not removed) so a future refactor that
switches `tryCompress` to in-place mutation would still drop the
stale partial before `requestContents` is rebuilt.

Tests: 336/336 (108 in geminiChat.test.ts + 4 new = +4; 95
useGeminiStream + 133 client unchanged). tsc + eslint + prettier
clean on changed files.
This commit is contained in:
高铁 2026-05-20 09:50:15 +08:00
parent 2880de5778
commit ce68749b51
2 changed files with 432 additions and 107 deletions

View file

@ -4093,21 +4093,22 @@ describe('GeminiChat', async () => {
expect((fr?.response as { error?: string })?.error).toBe('custom reason');
});
it('does NOT synthesize when the real functionResponse lives in a non-adjacent later user turn', () => {
// Regression for the qwen-latest-series-invite-beta-v28 thread on
// PR #4176: shape `[user, model[fc], user[text], user[fr_real]]`
// arises when the user aborts a long-running tool, types a
// follow-up text turn, and then the React scheduler's late
// submitQuery appends the real tool_result as a SEPARATE user
// entry. The repair pass previously checked only history[i+1]
// (the immediate next turn) — the real fr at history[i+2] was
// invisible, so the repair synthesized a duplicate `error` fr for
// a callId that already had a real result. The downstream dedup
// in `useGeminiStream.handleCompletedTools` then dropped the REAL
// tool_result on the next submission (its callId was now
// "already in history" because of the synthetic), and the model
// only ever saw the error placeholder — silently swapping a real
// success for an error.
it('hoists the real functionResponse from a non-adjacent later user turn into the adjacent one', () => {
// Regression for the gpt-5.5 thread on PR #4176: shape
// `[user, model[fc], user[text], user[fr_real]]` arises when the
// user aborts a long-running tool, types a follow-up text turn,
// and then the React scheduler's late submitQuery appends the
// real tool_result as a SEPARATE user entry.
//
// Forward scanning alone (qwen-latest-series-invite-beta-v28
// thread, fixed in commit 2880de577) prevents the *synthesis*
// duplicate, but the wire layout is still
// `model[tool_use] → user[text] → user[tool_result]`, which
// Anthropic-compatible backends reject because the tool_result
// is not at the head of the IMMEDIATELY following user message.
// The repair must MOVE the real fr from history[3] into
// history[2] (before the text part) so the wire format becomes
// `model[tool_use] → user[tool_result, text]`.
chat.setHistory([
{ role: 'user', parts: [{ text: 'open /tmp/long.txt' }] },
{
@ -4139,29 +4140,33 @@ describe('GeminiChat', async () => {
const result = chat.repairOrphanedToolUseTurns();
// No synthesis: the real fr at history[3] satisfies the model[fc]
// at history[1].
// No synthesis (the fr is real, just relocated) — `injected`
// stays empty so the React scheduler dedup doesn't see it as a
// synthesized callId.
expect(result.injected).toEqual([]);
const history = chat.getHistory();
// History shape is unchanged: 4 entries, no inserted synthetic.
expect(history.length).toBe(4);
expect(history[3]!.parts![0]!.functionResponse?.response).toEqual({
// History is now 3 entries: the source turn for the hoisted fr
// had only the one fr part, so it becomes empty and is removed.
expect(history.length).toBe(3);
// Real fr now at the head of the immediate next user turn,
// before the text part, satisfying the wire-format invariant.
expect(history[2]!.parts![0]!.functionResponse?.id).toBe(
'call_nonadjacent_real',
);
expect(history[2]!.parts![0]!.functionResponse?.response).toEqual({
output: 'real file contents',
});
// The follow-up text turn is untouched (no synthetic fr hoisted
// into it — confirms the forward-scan fix, not just a no-op
// synthesis).
expect(history[2]!.parts).toEqual([
{ text: 'never mind, do something else' },
]);
expect(history[2]!.parts![1]).toEqual({
text: 'never mind, do something else',
});
});
it('still synthesizes for a partial mismatch when forward scan misses one callId', () => {
// Counterpart to the above: when the real fr only covers SOME
// of the callIds in a parallel tool_use, the missing ones must
// still get synthetic error fr's hoisted onto the immediate next
// user turn (preserving the existing parallel-tool_use behavior).
// Confirms the forward-scan didn't accidentally over-collect.
it('synthesizes missing fr AND hoists real fr in a parallel tool_use mismatch', () => {
// Counterpart to the hoist case: when the real fr only covers
// SOME callIds in a parallel tool_use, and the real one is in a
// non-adjacent later user turn, BOTH fix-ups apply on the same
// model turn — synthesize the missing callId AND hoist the real
// fr from the non-adjacent location into the adjacent turn.
chat.setHistory([
{ role: 'user', parts: [{ text: 'fan out two reads' }] },
{
@ -4192,17 +4197,73 @@ describe('GeminiChat', async () => {
const result = chat.repairOrphanedToolUseTurns();
// cid_a satisfied by real fr at history[3]; cid_b missing →
// synthetic for cid_b only.
// cid_b synthesized (no real fr anywhere). cid_a is hoisted, not
// synthesized — `injected` only contains the synthetic.
expect(result.injected).toEqual([{ callId: 'cid_b', name: 'read_file' }]);
const history = chat.getHistory();
// The non-adjacent turn that held cid_a's real fr is now empty
// and removed → 3 entries instead of the original 4.
expect(history.length).toBe(3);
// Adjacent user turn now leads with the synthesized fr_b, then
// the hoisted real fr_a, then the text. Both tool_results sit
// at the head, satisfying the Anthropic wire-format invariant.
const adjacentParts = history[2]!.parts!;
expect(adjacentParts[0]!.functionResponse?.id).toBe('cid_b');
expect(
(adjacentParts[0]!.functionResponse?.response as { error?: string })
?.error,
).toBeDefined();
expect(adjacentParts[1]!.functionResponse?.id).toBe('cid_a');
expect(adjacentParts[1]!.functionResponse?.response).toEqual({
output: 'real for a',
});
expect(adjacentParts[2]).toEqual({ text: 'follow up' });
});
it('hoists real fr but preserves the source user turn when it carries other content', () => {
// Edge case for the hoist path: if the source turn for the real
// fr ALSO carries text (or any non-fr part), removing the fr
// alone must NOT delete the turn — the remaining text is the
// user's real message and must be preserved at its original
// position. Confirms the empty-turn cleanup only deletes turns
// whose parts list goes to zero after the splice.
chat.setHistory([
{ role: 'user', parts: [{ text: 'kick off' }] },
{
role: 'model',
parts: [
{
functionCall: { id: 'cid_mix', name: 'read_file', args: {} },
},
],
},
{ role: 'user', parts: [{ text: 'never mind' }] },
{
role: 'user',
parts: [
{
functionResponse: {
id: 'cid_mix',
name: 'read_file',
response: { output: 'data' },
},
},
{ text: 'thanks anyway' },
],
},
]);
const result = chat.repairOrphanedToolUseTurns();
expect(result.injected).toEqual([]);
const history = chat.getHistory();
// The source turn lost its fr but kept its trailing text, so
// history is still 4 entries — the source turn survives as a
// text-only user message.
expect(history.length).toBe(4);
// Synthetic for cid_b hoisted into history[2] (immediate next
// user turn) before the text part.
expect(history[2]!.parts![0]!.functionResponse?.id).toBe('cid_b');
expect(history[2]!.parts![1]).toEqual({ text: 'follow up' });
// Real fr for cid_a still at history[3], untouched.
expect(history[3]!.parts![0]!.functionResponse?.id).toBe('cid_a');
expect(history[2]!.parts![0]!.functionResponse?.id).toBe('cid_mix');
expect(history[2]!.parts![1]).toEqual({ text: 'never mind' });
expect(history[3]!.parts).toEqual([{ text: 'thanks anyway' }]);
});
});
@ -4503,6 +4564,112 @@ describe('GeminiChat', async () => {
.join('');
expect(mergedText).toBe('BCD');
});
it('flushes the JSONL record when escalated stream throws mid-tool_use (qwen-latest-series-invite-beta-v28 thread on PR #4176)', async () => {
// Critical regression for the max-tokens escalation path:
// 1) initial stream succeeds with text + MAX_TOKENS → triggers
// escalation, no partial set, deferred record clean.
// 2) escalated stream throws AFTER yielding a functionCall chunk
// → processStreamResponse pushes a partial model[fc] into
// `this.history` and stashes a NEW `pendingPartialAssistantRecord`.
// 3) The throw escapes through the for-await on the escalated
// stream, propagates past the (now-passed) retry loop, and
// lands in the outer `finally` block.
//
// BEFORE the fix: the flush only ran BEFORE the escalation block,
// so the new record set in step 2 was never appended to JSONL —
// live history disagreed with disk; `--resume` rehydrated a
// truncated transcript and `repairOrphanedToolUseTurnsInHistory`
// had nothing to repair, leaving the React scheduler's late real
// result as a permanent orphan.
//
// AFTER the fix: the flush is in `finally`, so the record lands
// on disk regardless of which stream raised.
const recordAssistantTurn = vi.fn();
const chatWithRecording = new GeminiChat(
mockConfig,
config,
[],
{
recordAssistantTurn,
recordChatCompression: vi.fn(),
} as unknown as ConstructorParameters<typeof GeminiChat>[3],
uiTelemetryService,
);
// Stream 1: text + MAX_TOKENS (success, triggers escalation).
// Stream 2: yields a functionCall chunk THEN throws — simulates a
// mid-tool_use stream cut on the escalated request.
const streams = [
makeStream([makeChunk([{ text: 'partial answer' }], 'MAX_TOKENS')]),
(async function* () {
yield {
candidates: [
{
content: {
parts: [
{
functionCall: {
id: 'call_escalation_throw',
name: 'read_file',
args: { path: '/tmp/escalated.txt' },
},
},
],
},
},
],
} as unknown as GenerateContentResponse;
throw new Error('synthetic mid-tool_use cut on escalated stream');
})(),
];
let callIndex = 0;
vi.mocked(mockContentGenerator.generateContentStream).mockImplementation(
async () => streams[callIndex++]!,
);
const stream = await chatWithRecording.sendMessageStream(
'gemini-3-pro',
{ message: 'kick off' },
'prompt-escalation-flush',
);
// Consume the stream and expect the synthetic mid-tool_use error
// to escape (escalation errors do not retry).
await expect(
(async () => {
for await (const _ of stream) {
/* consume */
}
})(),
).rejects.toThrow(/synthetic mid-tool_use cut/);
// In-memory: the partial functionCall pushed by the escalated
// processStreamResponse must be in history.
const history = chatWithRecording.getHistory();
const partialModel = history.findLast((h) => h.role === 'model');
expect(
partialModel?.parts?.some(
(p) => p.functionCall?.id === 'call_escalation_throw',
),
).toBe(true);
// JSONL: at least one record must mention the partial functionCall
// (the escalation throw flushed it). Without the finally-block
// flush, this assertion would fail and the durable transcript
// would silently lose a tool_use that's still live in memory.
const recordedHasPartial = recordAssistantTurn.mock.calls.some((call) => {
const message = (
call[0] as {
message?: Array<{ functionCall?: { id?: string } }>;
}
)?.message;
return message?.some(
(p) => p.functionCall?.id === 'call_escalation_throw',
);
});
expect(recordedHasPartial).toBe(true);
});
});
describe('redactStructuredOutputArgsForRecording', () => {

View file

@ -397,16 +397,38 @@ const ORPHAN_TOOL_USE_REPAIR_REASON =
/**
* 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.
* pair so the wire format the next API call sees is always
* `model[fc] → user[fr]` with the `fr` blocks at the head of the immediately
* following user turn. Two fix-ups can run for each `model[functionCall]`:
*
* - SYNTHESIZE: for any `functionCall.id` not echoed back by ANY of the
* consecutive user turns that follow it (up to the next model turn or
* end-of-history), insert a synthetic `functionResponse` carrying an
* `error` field the close analogue of upstream Claude Code's
* `yieldMissingToolResultBlocks` (`query.ts:123-149`).
* - HOIST: for any `functionCall.id` whose real `functionResponse` lives in
* a non-adjacent following user turn (typical shape:
* `model[fc], user[text], user[fr_real]` produced when a user aborts a
* long-running tool, types a follow-up, and the React scheduler's late
* `submitQuery` appends the real `fr` as a SEPARATE user entry), MOVE the
* real `fr` part out of its original turn into the adjacent one. Without
* hoisting, the synthesis pass correctly skips the call (a real `fr`
* exists somewhere later) but the wire layout still serializes
* `model[tool_use] → user[text] → user[tool_result]`, which
* Anthropic-compatible backends reject with "tool_use_id ... must have a
* corresponding tool_use block in the previous message". gpt-5.5 review
* thread on PR #4176.
*
* 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.
* if the in-flight tool completes after the repair. Hoisted ids are NOT in
* the returned list the real `fr` is already present in history, so the
* scheduler's existing history-based dedup handles them without extra entries.
*
* The synthesis target follows this rule:
* - If the next entry is a `user` turn append synthetic parts to it.
* The injection target for synthesized parts follows this rule:
* - If the next entry is a `user` turn insert synthetic parts at the head
* (before any non-`functionResponse` parts; after any pre-existing real
* `functionResponse` parts so caller-supplied ordering is preserved).
* - If the next entry is a `model` turn or end-of-history insert a new
* `user` turn between them carrying just the synthetic parts.
*
@ -442,81 +464,149 @@ export function repairOrphanedToolUseTurns(
if (expected.size === 0) continue;
// Scan forward across EVERY consecutive user turn until the next
// non-user (model) entry, gathering all functionResponse ids. The
// real tool_result for this model[fc] may live in a non-adjacent
// user turn — common shape: user aborts a long-running tool, types
// a follow-up text turn, then the React scheduler's late
// submitQuery appends the real `user[fr]` as a SEPARATE turn,
// producing `model[fc], user[text], user[fr_real]`. Without
// forward scanning, `matched` would be empty (only `user[text]`
// visited), the repair would synthesize an `error` `functionResponse`
// for a callId that already has a real result downstream, and the
// `handleCompletedTools` dedup would then drop the REAL result on
// the next submission (callId already in history → submit skipped),
// so the model only ever sees the synthetic error placeholder.
// (qwen-latest-series-invite-beta-v28 thread on PR #4176.)
const matched = new Set<string>();
// non-user (model) entry, recording (id → location) for every
// functionResponse part. The real tool_result for this model[fc]
// may live in a non-adjacent user turn — common shape: user aborts
// a long-running tool, types a follow-up text turn, then the React
// scheduler's late submitQuery appends the real `user[fr]` as a
// SEPARATE user entry, producing `model[fc], user[text], user[fr_real]`.
//
// We need the full location (turn index + part index) for two
// reasons:
// - synthesis: skip ids that already have a real fr SOMEWHERE so we
// don't plant a duplicate `error` placeholder. Without this, the
// `handleCompletedTools` history-based dedup would then drop the
// REAL result on the next submission (callId already in history
// via the synthetic), and the model would only ever see the error.
// - hoist: ids matched in a NON-adjacent later user turn must be
// physically moved into history[i+1] (before non-fr parts) so the
// wire format stays `model[tool_use] → user[tool_result, ...]`.
// Anthropic-compatible backends require the tool_result blocks at
// the head of the immediately following user message, otherwise
// they reject with "tool_use_id ... must have a corresponding
// tool_use block in the previous message". gpt-5.5 review thread
// on PR #4176.
const matched = new Map<
string,
{ turnIdx: number; partIdx: number; part: Part }
>();
let scanIdx = i + 1;
while (scanIdx < history.length && history[scanIdx]?.role === 'user') {
for (const part of history[scanIdx].parts ?? []) {
const parts = history[scanIdx].parts ?? [];
for (let pIdx = 0; pIdx < parts.length; pIdx++) {
const part = parts[pIdx];
const id = part.functionResponse?.id;
if (id) matched.add(id);
if (id && !matched.has(id)) {
matched.set(id, { turnIdx: scanIdx, partIdx: pIdx, part });
}
}
scanIdx++;
}
const missing = [...expected.entries()].filter(([id]) => !matched.has(id));
if (missing.length === 0) continue;
const synthesizeIds: Array<[string, string]> = [];
const hoistLocations: Array<{
turnIdx: number;
partIdx: number;
part: Part;
}> = [];
for (const [id, name] of expected) {
const loc = matched.get(id);
if (!loc) {
synthesizeIds.push([id, name]);
} else if (loc.turnIdx !== i + 1) {
hoistLocations.push(loc);
}
// else: already in the immediate next user turn — wire format ok.
}
if (synthesizeIds.length === 0 && hoistLocations.length === 0) continue;
const next = history[i + 1];
const syntheticParts: Part[] = missing.map(([callId, name]) => ({
const syntheticParts: Part[] = synthesizeIds.map(([callId, name]) => ({
functionResponse: {
id: callId,
name,
response: { error: reason },
},
}));
// Hoisted parts come from REAL tool_results elsewhere in history.
// We capture references first, then splice them out below in
// descending order so earlier removals don't shift later indices.
const hoistedParts: Part[] = hoistLocations.map((loc) => loc.part);
// Synthetics first, then hoisted. Order between synthetic and
// hoisted is internal — backends just need ALL tool_results at the
// head of the user turn, regardless of order among themselves.
const partsToInject: Part[] = [...syntheticParts, ...hoistedParts];
// Remove hoisted parts from their original turns. Sort by
// (turnIdx desc, partIdx desc) so each splice operates on stable
// indices for everything still to be removed.
const removalOrder = [...hoistLocations].sort((a, b) => {
if (a.turnIdx !== b.turnIdx) return b.turnIdx - a.turnIdx;
return b.partIdx - a.partIdx;
});
for (const loc of removalOrder) {
const turnParts = history[loc.turnIdx].parts;
if (turnParts) turnParts.splice(loc.partIdx, 1);
}
// Drop any user turn within the scan range (i+2 .. scanIdx-1) that
// is now empty because we extracted its only fr part(s). Walk back
// to front so removals don't shift remaining indices. The
// immediately-adjacent turn at i+1 is preserved even if empty —
// we'll rewrite its parts below. After this, scanIdx is no longer
// accurate; we rebind via `next` directly.
for (let j = scanIdx - 1; j > i + 1; j--) {
if (
history[j]?.role === 'user' &&
(history[j].parts?.length ?? 0) === 0
) {
history.splice(j, 1);
}
}
const next = history[i + 1];
if (next?.role === 'user') {
// Synthetic functionResponse parts MUST be placed before any
// non-functionResponse parts in the user turn. Anthropic-compatible
// backends reject a user message whose first content block isn't
// the tool_result that answers the immediately preceding tool_use
// ("tool result must follow tool use" / "tool_use_id ... must have
// a corresponding tool_use block in the previous message"). Common
// case after a Ctrl+Y race: the user's retry-prompt text was just
// pushed, so `next.parts = [text]`; appending the synthetic to the
// end would produce `[text, fr]` and re-trigger the wedge this PR
// Synthesized + hoisted functionResponse parts MUST be placed
// before any non-functionResponse parts in the user turn.
// Anthropic-compatible backends reject a user message whose first
// content block isn't the tool_result that answers the
// immediately preceding tool_use ("tool result must follow tool
// use" / "tool_use_id ... must have a corresponding tool_use
// block in the previous message"). Common case after a Ctrl+Y
// race: the user's retry-prompt text was just pushed, so
// `next.parts = [text]`; appending the synthetic to the end
// would produce `[text, fr]` and re-trigger the wedge this PR
// is supposed to escape. Mirrors upstream Claude Code's
// `hoistToolResults` (`utils/messages.ts`).
//
// CONSEQUENCE OF REMOVAL: dropping this hoist (e.g. naively
// `next.parts = [...existing, ...syntheticParts]`) re-introduces
// `next.parts = [...existing, ...partsToInject]`) re-introduces
// the exact 400 "tool_use_id ... must have a corresponding tool_use
// block in the previous message" the synthesis pass exists to
// prevent. The whole repair becomes a no-op and the session stays
// wedged. Do not "simplify" this branch.
//
// Place synthetics AFTER any pre-existing functionResponse parts
// (real tool_results the user is supplying in the same turn) so
// their original ordering is preserved.
// Place new parts AFTER any pre-existing functionResponse parts
// (real tool_results the user already had at the head of this
// turn) so caller-supplied ordering is preserved.
const existing = next.parts ?? [];
const firstNonFr = existing.findIndex((part) => !part.functionResponse);
const insertAt = firstNonFr === -1 ? existing.length : firstNonFr;
next.parts = [
...existing.slice(0, insertAt),
...syntheticParts,
...partsToInject,
...existing.slice(insertAt),
];
} else {
history.splice(i + 1, 0, { role: 'user', parts: syntheticParts });
history.splice(i + 1, 0, { role: 'user', parts: partsToInject });
// 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) {
// Only synthesized ids feed dedup — hoisted ids reference real frs
// that were ALREADY in history before this pass and remain present
// (just relocated). The scheduler's history-based dedup will
// continue to handle those naturally on its next pass.
for (const [callId, name] of synthesizeIds) {
injected.push({ callId, name });
}
}
@ -930,7 +1020,25 @@ export class GeminiChat {
// The React scheduler's late real result is then dedup'd against
// chat.history in `useGeminiStream.handleCompletedTools` so the
// synthetic doesn't collide with it on the wire.
repairOrphanedToolUseTurns(this.history);
//
// Diagnostic: log non-empty inline-repair results. The startChat()
// path logs synthesis events through `repairOrphanedToolUseTurnsInHistory`
// (`[REPAIR] Synthesized N functionResponse(s) ...`) and dedup events
// through `useGeminiStream.handleCompletedTools` (`[REPAIR] Dropping ...`),
// but this inline call site was previously silent — when a dedup-drop
// log shows up, investigators had no way to tell whether the
// synthetic was planted at session-load or at this per-send pass.
// Tag the log site so the lifecycle anchor is unambiguous.
const inlineRepair = repairOrphanedToolUseTurns(this.history);
if (inlineRepair.injected.length > 0) {
debugLogger.warn(
`[REPAIR] sendMessageStream inline pass synthesized ` +
`${inlineRepair.injected.length} functionResponse(s): ` +
inlineRepair.injected
.map((entry) => `${entry.name}(${entry.callId})`)
.join(', '),
);
}
requestContents = this.getHistory(true);
} catch (error) {
if (userContentAdded) {
@ -1034,6 +1142,28 @@ export class GeminiChat {
self.history[idx]?.role === 'model'
) {
self.history.splice(idx, 1);
} else {
// Marker was set but the entry it pointed at is gone or
// is no longer a `model` turn. Today this can't happen:
// every history-mutation path (clearHistory, addHistory,
// setHistory, truncateHistory, stripThoughtsFromHistory,
// stripOrphanedUserEntriesFromHistory) calls
// clearPendingPartialState() in lockstep, so the marker
// is null whenever the index basis is invalidated.
// Logging the mismatch makes the invariant observable —
// without this, a future caller that mutates history
// without resetting the marker would silently leave a
// stale partial in `this.history` (popPartialIfPushed
// skipping the splice) AND the field-level invariant
// that "marker non-null ⇒ a real partial sits at idx"
// would be quietly violated. With the warn, anyone
// investigating a stale-partial wedge sees a log line
// pointing straight at the offending caller.
debugLogger.warn(
`[PARTIAL_POP] Splice skipped: idx=${idx}, ` +
`historyLength=${self.history.length}, ` +
`roleAtIdx=${self.history[idx]?.role ?? 'undefined'}`,
);
}
// Drop both markers in lockstep — the deferred chat-
// recording record must be discarded alongside the
@ -1125,6 +1255,21 @@ export class GeminiChat {
reactiveInfo.compressionStatus ===
CompressionStatus.COMPRESSED
) {
// Defense-in-depth no-op: tryCompress() succeeded
// means it has already replaced this.history via
// setHistory(), which calls clearPendingPartialState()
// — so by the time we reach this line, the marker is
// null and popPartialIfPushed splices nothing. We
// keep the call as a uniformity assertion against
// future refactors that might switch tryCompress to
// an in-place mutation: in that world, the marker
// would NOT be reset by setHistory and this call
// becomes the only thing that drops the stale
// partial before requestContents is rebuilt below.
// Removing it would couple correctness to the
// implementation detail "setHistory always clears
// the marker", which the other retry branches don't
// share.
popPartialIfPushed();
requestContents = self.getHistory(true);
debugLogger.info(
@ -1237,29 +1382,6 @@ export class GeminiChat {
}
}
// The retry loop has settled: any partial that was rolled back
// had its stash cleared by `popPartialIfPushed`; any partial that
// survived (success break with no partial set, or unretryable
// break with the partial kept) is now durable in memory, so the
// deferred chat-recording append must finally land on disk.
// Without this flush the unretryable-break path persists the
// partial in `this.history` but the JSONL transcript silently
// drops it — `--resume` then loads a truncated transcript that
// doesn't match the live session shape, and the orphan-tool_use
// repair pass at session-load has nothing to repair.
if (self.pendingPartialAssistantRecord) {
self.chatRecordingService?.recordAssistantTurn(
self.pendingPartialAssistantRecord,
);
// Clear both fields in lockstep. The marker is no longer
// load-bearing past this point (its consumer is the for-loop
// catch above, which has exited), and the next
// sendMessageStream entry would clear it anyway — but pairing
// the reset preserves the "marker and stash are always set or
// cleared together" invariant the helper enforces.
self.clearPendingPartialState();
}
// Max output tokens escalation: if the retry loop succeeded with
// the capped default (8K) but hit MAX_TOKENS, retry once at the
// model's full output limit. This ensures models with large output
@ -1432,6 +1554,42 @@ export class GeminiChat {
}
} finally {
streamDoneResolver!();
// Flush any deferred partial-tool_use record into the JSONL
// transcript. The retry loop and the post-loop max-tokens
// escalation block can BOTH leave one of these on the chat:
//
// - Retry loop: any partial rolled back by popPartialIfPushed
// has its stash cleared; any partial that survived (success
// break with no partial set, or unretryable break with the
// partial kept) leaves its record set so we record-and-clear
// it here.
// - Max-tokens escalation: the escalated stream re-enters
// `processStreamResponse`, which sets a NEW
// `pendingPartialAssistantRecord` if it errors mid-tool_use.
// That throw propagates through the for-await above without
// touching the (now-passed) retry-loop catch, so without a
// flush in `finally` the partial would be live in
// `this.history` (the escalated processStreamResponse already
// pushed it) but absent from the JSONL transcript. `--resume`
// would then rehydrate a truncated transcript whose live
// history disagrees with disk, and
// `repairOrphanedToolUseTurnsInHistory` would find nothing to
// repair on load — the React scheduler's late real result
// becomes a permanent orphan, reproducing the exact wedge
// this PR prevents.
//
// Putting the flush in `finally` covers ALL throw paths
// (escalation, post-retry-loop `throw lastError`, the for-await
// consumer's `.return()` if it abandons the generator) and the
// normal completion path with a single statement. The marker
// and stash are dropped together to preserve the
// "marker non-null ⇔ stash non-null" invariant.
if (self.pendingPartialAssistantRecord) {
self.chatRecordingService?.recordAssistantTurn(
self.pendingPartialAssistantRecord,
);
self.clearPendingPartialState();
}
}
})();
}