mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-09-01 10:46:02 +00:00
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 commit2880de577: [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 in2880de577. 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:
parent
2880de5778
commit
ce68749b51
2 changed files with 432 additions and 107 deletions
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue