[Bug]: Provider handoffs serialize command output that cannot fit the history budget#23820

opened1d agobyAAksharP5

Area

apps/server / packages/provider-core

Steps to reproduce

  1. Use main at 22840ab943b8418744b48223411e46ba4fdcef1a on Linux x64, Node 26.12.1.
  2. Build handoff history containing 100 command outputs of 32 KiB each, plus an original brief and recent messages. This represents a long tool-heavy thread.
  3. Select history with the default 16,000-byte cap using selectHistory in packages/provider-core/src/server/handoffBudget.ts. The reproduction below uses the exact production function.

Expected behavior

Avoid encoding a candidate whose text alone already exceeds the remaining budget.

Actual behavior

Each oversized candidate is rendered and JSON-serialized twice before it is rejected. historicalMessage includes full command output, so provider switches and other context handoffs pay for output that is omitted anyway.

Same 100 command outputs, 32 KiB each, 16,000-byte budgetBaselinePatch
Median (min–max), 21 trials5.292 ms (5.096–5.661)0.012 ms (0.009–0.018)

An early UTF-16-length lower-bound check selects the exact same three messages and omits the same 100 command items. The comparison includes the same wrapper, metadata and budget in both versions.

Impact and environment

Extra selector CPU and temporary serialization allocations during handoffs of long tool-heavy threads. Linux x64, Intel i5-14400F, Node 26.12.1. These are warmed synthetic selector measurements, not overall provider-switch latency. Small histories are inexpensive.

Self-contained before/after reproduction

Run from the PR checkout with Node 26.12.1. This loads the exact baseline and patched production functions and checks output equality. No third-party benchmark dependency is needed.

sh
1node --input-type=module <<'JS'
2import * as assert from 'node:assert/strict';
3import * as fs from 'node:fs';
4import { execFileSync } from 'node:child_process';
5import { stripTypeScriptTypes } from 'node:module';
6import { performance } from 'node:perf_hooks';
7
8const base = '22840ab943b8418744b48223411e46ba4fdcef1a';
9const file = 'packages/provider-core/src/server/handoffBudget.ts';
10async function load(source, name) {
11 const start = source.indexOf('export function historicalMessage(');
12 const end = source.indexOf('export function handoffCoverage(', start);
13 assert.ok(start >= 0 && end > start);
14 const js = stripTypeScriptTypes(source.slice(start, end));
15 return import(`data:text/javascript;base64,${Buffer.from(js + `\n//# sourceURL=${name}`).toString('base64')}`);
16}
17const baseline = await load(execFileSync('git', ['show', `${base}:${file}`], { encoding: 'utf8' }), 'before');
18const patched = await load(fs.readFileSync(file, 'utf8'), 'after');
19const message = (id, role, text) => ({ itemId: `item:${id}`, role, text, kind: `${role}_message`, threadId: 'thread:fixture', runId: `run:${id}`, providerThreadId: 'provider:fixture', status: 'completed' });
20const logs = Array.from({ length: 100 }, (_, i) => {
21 const line = `test src/component-${i}.test.ts: expected output differs at line ${i + 1}\n`;
22 return baseline.historicalMessage({ id: `item:command:${i}`, threadId: 'thread:fixture', runId: `run:${Math.floor(i / 5)}`, providerThreadId: 'provider:fixture', status: 'completed', type: 'command_execution', input: `vp test run src/component-${i}.test.ts`, output: line.repeat(Math.ceil(32768 / line.length)).slice(0, 32768), exitCode: 1 });
23});
24const input = {
25 messages: [message(0, 'user', 'Keep the API compatible and run the focused tests.'), ...logs, message(10000, 'user', 'Continue investigating the test failures.'), message(10001, 'assistant', 'The failing test is reproduced. Check the captured command output.')],
26 coverage: 'Provider handoff; omitted history remains retrievable through t3_thread_read.', budget: 16000,
27};
28const expected = baseline.selectHistory(input);
29assert.deepEqual(patched.selectHistory(input), expected);
30for (let i = 0; i < 20; i++) { baseline.selectHistory(input); patched.selectHistory(input); }
31const samples = { baseline: [], patched: [] };
32let sink = 0;
33for (let i = 0; i < 21; i++) {
34 const order = i % 2 ? [['patched', patched], ['baseline', baseline]] : [['baseline', baseline], ['patched', patched]];
35 for (const [label, parser] of order) {
36 const start = performance.now();
37 for (let j = 0; j < 10; j++) sink += parser.selectHistory(input).omittedItems;
38 samples[label].push((performance.now() - start) / 10);
39 }
40}
41const stats = values => {
42 const sorted = values.toSorted((a, b) => a - b);
43 return { minMs: sorted[0], medianMs: sorted[10], maxMs: sorted.at(-1), samples: values };
44};
45console.log(JSON.stringify({ node: process.version, selectedItems: expected.messages.length, omittedItems: expected.omittedItems, baseline: stats(samples.baseline), patched: stats(samples.patched), sink }, null, 2));
46JS