[Bug]: Provider handoffs serialize command output that cannot fit the history budget#23820
Area
apps/server / packages/provider-core
Steps to reproduce
- Use main at
22840ab943b8418744b48223411e46ba4fdcef1aon Linux x64, Node 26.12.1. - 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.
- Select history with the default 16,000-byte cap using
selectHistoryinpackages/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 budget | Baseline | Patch |
|---|---|---|
| Median (min–max), 21 trials | 5.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.
| 1 | node --input-type=module <<'JS' |
| 2 | import * as assert from 'node:assert/strict'; |
| 3 | import * as fs from 'node:fs'; |
| 4 | import { execFileSync } from 'node:child_process'; |
| 5 | import { stripTypeScriptTypes } from 'node:module'; |
| 6 | import { performance } from 'node:perf_hooks'; |
| 7 | |
| 8 | const base = '22840ab943b8418744b48223411e46ba4fdcef1a'; |
| 9 | const file = 'packages/provider-core/src/server/handoffBudget.ts'; |
| 10 | async 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 | } |
| 17 | const baseline = await load(execFileSync('git', ['show', `${base}:${file}`], { encoding: 'utf8' }), 'before'); |
| 18 | const patched = await load(fs.readFileSync(file, 'utf8'), 'after'); |
| 19 | const message = (id, role, text) => ({ itemId: `item:${id}`, role, text, kind: `${role}_message`, threadId: 'thread:fixture', runId: `run:${id}`, providerThreadId: 'provider:fixture', status: 'completed' }); |
| 20 | const 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 | }); |
| 24 | const 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 | }; |
| 28 | const expected = baseline.selectHistory(input); |
| 29 | assert.deepEqual(patched.selectHistory(input), expected); |
| 30 | for (let i = 0; i < 20; i++) { baseline.selectHistory(input); patched.selectHistory(input); } |
| 31 | const samples = { baseline: [], patched: [] }; |
| 32 | let sink = 0; |
| 33 | for (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 | } |
| 41 | const 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 | }; |
| 45 | console.log(JSON.stringify({ node: process.version, selectedItems: expected.messages.length, omittedItems: expected.omittedItems, baseline: stats(samples.baseline), patched: stats(samples.patched), sink }, null, 2)); |
| 46 | JS |