From db40d2dc35a2e6a5ac9b8614ee55be21600e3cdf Mon Sep 17 00:00:00 2001 From: Joob1n Date: Fri, 4 Sep 2026 00:16:25 +0800 Subject: [PATCH] fix(runtime): reach the standalone compaction call site with the retreat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #4667 wired the proven-boundary retreat into the mid-turn and pre-turn call site and missed the standalone one, which is the site manual compaction uses. Without `runHeaders` and `acceptedRoute`, `acceptedInputBoundary` returns nothing on its first line, so the first `input_too_large` fails open. That path retreated before: its coverage gate admitted every halving step, so the loop walked down until a span was accepted. Four entries reach it — CLI `/compact`, Desktop `sessions:compact`, sub-agent compaction from supervisor wake, and the pre-turn fallback — and its first attempt covers the whole prior session with no reserved tail, which is the span most likely to be rejected. So the regression lands on the ordinary long session, not an edge. Both values are already in hand at that call site. The test drives `compactHistory` rather than the planner: the planner tests hand the route in directly, so they would have stayed green with both call sites deleted, which is exactly how this got through. Reported by @Astro-Han on #4667. Refs #4559 Generated-by: Claude Code Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J --- .../src/__tests__/ai-sdk-backend.test.ts | 76 +++++++++++++++++++ packages/runtime/src/ai-sdk-compaction.ts | 7 ++ 2 files changed, 83 insertions(+) diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index f9376cbee2..f30aaa1494 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -4378,6 +4378,82 @@ describe('AiSdkBackend model history', () => { assert.equal(prompt.includes(oldResult.body), false); }); + test('manual compactHistory retreats to a span this route has accepted', async () => { + // The retreat needs the run headers and the route to find the newest reply + // this model produced. The planner tests hand those in directly, so they + // would stay green if the call site stopped passing them; this drives the + // entry `/compact` actually uses. + const attemptedCoverage: string[][] = []; + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: { ...header(), llmConnectionId: 'test-connection-id', model: 'mock-model-id' }, + appendMessage: async () => {}, + connection: connection(), + apiKey: 'sk-test', + modelId: 'mock-model-id', + modelFactory: () => completionModel(), + tools: [], + newId: idGenerator(), + now: monotonicClock(), + contextBudget: { name: 'standalone-retreat-test', charsPerToken: 1 }, + summarizeHistoryCompact: async ({ source }) => { + attemptedCoverage.push(source.foldedRuntimeEvents.map((event) => event.id)); + if (attemptedCoverage.length === 1) { + throw new HistoryCompactSummarizerError('input_too_large'); + } + return structuredSummary('STANDALONE_RETREAT_SENTINEL'); + }, + recordHistoryCompactCheckpoint: () => {}, + }); + + const result = await backend.compactHistory({ + turnId: 'turn-compact', + runId: 'run-1', + runtimeContextRunHeaders: [ + priorModelRunHeader({ connectionId: 'test-connection-id', modelId: 'mock-model-id' }), + ], + runtimeContext: [ + runtimeTextEvent({ + id: 'old-user', + turnId: 'turn-old', + role: 'user', + author: 'user', + text: 'old alpha '.repeat(100), + }), + runtimeTextEvent({ + id: 'old-model', + turnId: 'turn-old', + role: 'model', + author: 'agent', + text: 'old beta '.repeat(100), + }), + runtimeTextEvent({ + id: 'recent-user', + turnId: 'turn-recent', + role: 'user', + author: 'user', + text: 'recent alpha '.repeat(100), + }), + runtimeTextEvent({ + id: 'recent-model', + turnId: 'turn-recent', + role: 'model', + author: 'agent', + text: 'recent beta '.repeat(100), + }), + ], + }); + + assert.equal(result.outcome.kind, 'compacted'); + // The first attempt covers everything; the retreat stops where the newest + // reply this route produced begins. Without the route reaching the planner + // there is no second attempt at all. + assert.deepEqual(attemptedCoverage, [ + ['old-user', 'old-model', 'recent-user', 'recent-model'], + ['old-user', 'old-model', 'recent-user'], + ]); + }); + test('manual compactHistory writes a V2 checkpoint without the legacy artifact writer', async () => { const recorded: HistoryCompactCheckpoint[] = []; let memoryDispatches = 0; diff --git a/packages/runtime/src/ai-sdk-compaction.ts b/packages/runtime/src/ai-sdk-compaction.ts index 256b4fd0e0..36dc271e8f 100644 --- a/packages/runtime/src/ai-sdk-compaction.ts +++ b/packages/runtime/src/ai-sdk-compaction.ts @@ -366,6 +366,13 @@ export class AiSdkCompaction { sessionId: this.sessionId, phase: 'standalone', orderedEvents: runtimeContext, + ...(input.runtimeContextRunHeaders ? { runHeaders: input.runtimeContextRunHeaders } : {}), + acceptedRoute: { + modelId: this.input.modelId, + ...(this.targetConnectionId !== undefined + ? { connectionId: this.targetConnectionId } + : {}), + }, reserveTailEvents: 0, charsPerToken, now: this.now(),