Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion src/chrome/src/agent/conversation-persistence.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -96,6 +96,28 @@ 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);
}
}
keepRecentFrom = Math.max(1, out.length - 14);
}
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;
Expand Down
24 changes: 23 additions & 1 deletion src/firefox/src/agent/conversation-persistence.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -96,6 +96,28 @@ 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);
}
}
keepRecentFrom = Math.max(1, out.length - 14);
}
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;
Expand Down
38 changes: 38 additions & 0 deletions test/run.js
Original file line number Diff line number Diff line change
Expand Up @@ -76876,6 +76876,44 @@ 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: 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')
.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;
Expand Down
Loading