From f6ba26473b4c90641730b793344e8dd494beae82 Mon Sep 17 00:00:00 2001 From: Ayush7614 Date: Thu, 13 Aug 2026 19:49:16 +0530 Subject: [PATCH 1/2] Drop tool results orphaned by assistant compaction in session snapshots reduceToBudget compacts older assistant messages into a placeholder that keeps only role + content, discarding their tool_calls. The paired tool result messages survived with their tool_call_id intact, so a restored large tool-heavy conversation contained tool messages with no matching assistant tool_calls entry, which OpenAI-style APIs reject with 'tool call ID not found'. After compaction, drop every tool message whose tool_call_id is no longer referenced by any remaining assistant tool_calls. Both Chrome and Firefox copies, with a regression test. Fixes #2781 --- .../src/agent/conversation-persistence.js | 21 +++++++++++ .../src/agent/conversation-persistence.js | 21 +++++++++++ test/run.js | 37 +++++++++++++++++++ 3 files changed, 79 insertions(+) diff --git a/src/chrome/src/agent/conversation-persistence.js b/src/chrome/src/agent/conversation-persistence.js index ee60ad90b..fa0a1df20 100644 --- a/src/chrome/src/agent/conversation-persistence.js +++ b/src/chrome/src/agent/conversation-persistence.js @@ -96,6 +96,27 @@ function reduceToBudget(messages, maxBytes, state) { content: '[Earlier message omitted from bounded session recovery snapshot.]', }; } + // A compacted assistant message loses its tool_calls, which orphans the + // paired `tool` result messages that carry the same tool_call_id. Restoring + // such a snapshot into the live conversation makes the next provider call + // fail with "tool call ID not found", so drop every `tool` message whose id + // is no longer referenced by any remaining assistant tool_calls. + if (state.compacted) { + const presentCallIds = new Set(); + for (const message of out) { + if (message?.role !== 'assistant' || !Array.isArray(message.tool_calls)) continue; + for (const call of message.tool_calls) { + if (call && typeof call.id === 'string') presentCallIds.add(call.id); + } + } + for (let index = out.length - 1; index >= 0; index--) { + const message = out[index]; + if (message?.role === 'tool' && typeof message.tool_call_id === 'string' && !presentCallIds.has(message.tool_call_id)) { + state.compacted = true; + out.splice(index, 1); + } + } + } for (let index = keepRecentFrom; index < out.length && byteLength(out) > maxBytes; index++) { const message = out[index]; if (!message || typeof message.content !== 'string' || message.content.length <= 4_000) continue; diff --git a/src/firefox/src/agent/conversation-persistence.js b/src/firefox/src/agent/conversation-persistence.js index ee60ad90b..fa0a1df20 100644 --- a/src/firefox/src/agent/conversation-persistence.js +++ b/src/firefox/src/agent/conversation-persistence.js @@ -96,6 +96,27 @@ function reduceToBudget(messages, maxBytes, state) { content: '[Earlier message omitted from bounded session recovery snapshot.]', }; } + // A compacted assistant message loses its tool_calls, which orphans the + // paired `tool` result messages that carry the same tool_call_id. Restoring + // such a snapshot into the live conversation makes the next provider call + // fail with "tool call ID not found", so drop every `tool` message whose id + // is no longer referenced by any remaining assistant tool_calls. + if (state.compacted) { + const presentCallIds = new Set(); + for (const message of out) { + if (message?.role !== 'assistant' || !Array.isArray(message.tool_calls)) continue; + for (const call of message.tool_calls) { + if (call && typeof call.id === 'string') presentCallIds.add(call.id); + } + } + for (let index = out.length - 1; index >= 0; index--) { + const message = out[index]; + if (message?.role === 'tool' && typeof message.tool_call_id === 'string' && !presentCallIds.has(message.tool_call_id)) { + state.compacted = true; + out.splice(index, 1); + } + } + } for (let index = keepRecentFrom; index < out.length && byteLength(out) > maxBytes; index++) { const message = out[index]; if (!message || typeof message.content !== 'string' || message.content.length <= 4_000) continue; diff --git a/test/run.js b/test/run.js index 79b3d6a0c..ce34b51d0 100644 --- a/test/run.js +++ b/test/run.js @@ -76876,6 +76876,43 @@ test('session conversation snapshots strip binary payloads and cap large tool re } }); +test('session conversation snapshots drop tool results orphaned by assistant compaction', async () => { + for (const build of ['chrome', 'firefox']) { + const persistence = await import(pathToFileURL(path.join(ROOT, `src/${build}/src/agent/conversation-persistence.js`)).href); + const messages = []; + for (let i = 0; i < 40; i++) { + messages.push({ + role: 'assistant', + content: null, + tool_calls: [{ id: `tc${i}`, type: 'function', function: { name: 'x', arguments: '{"big":"' + 'a'.repeat(5000) + '"}' } }], + }); + messages.push({ role: 'tool', tool_call_id: `tc${i}`, content: 'result '.repeat(2000) }); + } + const serialized = persistence.serializeConversationForSession(messages, { maxBytes: 120000 }); + assert.equal(serialized.compacted, true, `${build}: oversized conversation was not compacted`); + const toolCallIds = new Set( + serialized.messages + .filter(m => m.role === 'assistant') + .flatMap(m => Array.isArray(m.tool_calls) ? m.tool_calls.map(c => c.id) : []), + ); + const orphaned = serialized.messages.filter( + m => m.role === 'tool' && !toolCallIds.has(m.tool_call_id), + ); + assert.equal(orphaned.length, 0, + `${build}: snapshot left tool results with no matching assistant tool_calls`); + + // A conversation that fits the budget is returned verbatim. + const small = [ + { role: 'system', content: 'system' }, + { role: 'assistant', content: null, tool_calls: [{ id: 'keep', type: 'function', function: { name: 'f', arguments: '{}' } }] }, + { role: 'tool', tool_call_id: 'keep', content: 'ok' }, + ]; + const untouched = persistence.serializeConversationForSession(small); + assert.equal(untouched.compacted, false, `${build}: in-budget conversation was modified`); + assert.equal(untouched.messages.length, 3, `${build}: in-budget conversation lost messages`); + } +}); + test('quota exhaustion degrades recovery once, continues live, and disables automatic replay', async () => { const previousChrome = globalThis.chrome; const previousBrowser = globalThis.browser; From dbe55e8d5d003c6a82d76efe4d9cd65bd81cbefa Mon Sep 17 00:00:00 2001 From: Barack Sokullu Date: Fri, 14 Aug 2026 03:43:15 +0300 Subject: [PATCH 2/2] fix(session): recompute recent boundary after filtering --- src/chrome/src/agent/conversation-persistence.js | 3 ++- src/firefox/src/agent/conversation-persistence.js | 3 ++- test/run.js | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/chrome/src/agent/conversation-persistence.js b/src/chrome/src/agent/conversation-persistence.js index fa0a1df20..6bc0247c0 100644 --- a/src/chrome/src/agent/conversation-persistence.js +++ b/src/chrome/src/agent/conversation-persistence.js @@ -85,7 +85,7 @@ function sanitizeMessage(message, state, caps) { function reduceToBudget(messages, maxBytes, state) { if (byteLength(messages) <= maxBytes) return messages; const out = messages.map(message => ({ ...message })); - const keepRecentFrom = Math.max(1, out.length - 14); + let keepRecentFrom = Math.max(1, out.length - 14); for (let index = 1; index < keepRecentFrom && byteLength(out) > maxBytes; index++) { const message = out[index]; if (!message || message.role === 'system') continue; @@ -116,6 +116,7 @@ function reduceToBudget(messages, maxBytes, state) { out.splice(index, 1); } } + keepRecentFrom = Math.max(1, out.length - 14); } for (let index = keepRecentFrom; index < out.length && byteLength(out) > maxBytes; index++) { const message = out[index]; diff --git a/src/firefox/src/agent/conversation-persistence.js b/src/firefox/src/agent/conversation-persistence.js index fa0a1df20..6bc0247c0 100644 --- a/src/firefox/src/agent/conversation-persistence.js +++ b/src/firefox/src/agent/conversation-persistence.js @@ -85,7 +85,7 @@ function sanitizeMessage(message, state, caps) { function reduceToBudget(messages, maxBytes, state) { if (byteLength(messages) <= maxBytes) return messages; const out = messages.map(message => ({ ...message })); - const keepRecentFrom = Math.max(1, out.length - 14); + let keepRecentFrom = Math.max(1, out.length - 14); for (let index = 1; index < keepRecentFrom && byteLength(out) > maxBytes; index++) { const message = out[index]; if (!message || message.role === 'system') continue; @@ -116,6 +116,7 @@ function reduceToBudget(messages, maxBytes, state) { out.splice(index, 1); } } + keepRecentFrom = Math.max(1, out.length - 14); } for (let index = keepRecentFrom; index < out.length && byteLength(out) > maxBytes; index++) { const message = out[index]; diff --git a/test/run.js b/test/run.js index ce34b51d0..5cb3162e6 100644 --- a/test/run.js +++ b/test/run.js @@ -76888,8 +76888,9 @@ test('session conversation snapshots drop tool results orphaned by assistant com }); messages.push({ role: 'tool', tool_call_id: `tc${i}`, content: 'result '.repeat(2000) }); } - const serialized = persistence.serializeConversationForSession(messages, { maxBytes: 120000 }); + const serialized = persistence.serializeConversationForSession(messages, { maxBytes: 100_000 }); assert.equal(serialized.compacted, true, `${build}: oversized conversation was not compacted`); + assert.ok(serialized.bytes <= 100_000, `${build}: serialized conversation exceeded its requested bound`); const toolCallIds = new Set( serialized.messages .filter(m => m.role === 'assistant')