diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index 1f2a04efe0..06f7152ba6 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; -import { describe, test } from 'node:test'; +import { before, describe, test } from 'node:test'; import { visibleWidth } from '@earendil-works/pi-tui'; +import { _setColorLevelForTesting } from '../tui-ansi.js'; import type { PipeShellOutput, PtyShellOutput, ShellRunToolResult } from '@maka/core'; import type { SessionEvent, ToolResultContent } from '@maka/core/events'; import type { StoredMessage } from '@maka/core/session'; @@ -22,6 +23,13 @@ import { togglePendingPermissionDetails, } from '../pi-transcript.js'; +// Pin the color level so ANSI-escape assertions are hermetic. Detection reads +// process.env.TERM/COLORTERM at module load, so ambient terminal capability +// (truecolor locally, unset/dumb on CI runners) would otherwise decide whether +// color escapes appear. Level 3 (truecolor) is the development default these +// tests lock (#1064/#1066); matches tui-ansi.test.ts's reset convention. +before(() => _setColorLevelForTesting(3)); + describe('Maka Pi TUI transcript', () => { test('greets on a fresh empty session and drops the welcome once a prompt lands', () => { const state = createMakaPiTranscriptState(); diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index f3a27906e8..9516ea05ac 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; import { once } from 'node:events'; import { setTimeout as delay } from 'node:timers/promises'; -import { describe, test } from 'node:test'; +import { before, describe, test } from 'node:test'; import { visibleWidth } from '@earendil-works/pi-tui'; import { SHELL_RUN_UPDATE_BUFFER_MAX_ENTRIES, @@ -23,6 +23,7 @@ import type { SessionResumeAvailability, } from '../session-driver.js'; import { runMakaPiTui } from '../pi-tui-runner.js'; +import { _setColorLevelForTesting } from '../tui-ansi.js'; import { BUSY_SPINNER_FRAMES } from '../tui-attention.js'; import { arrangeAutocompleteAboveEditor } from '../tui-autocomplete-layout.js'; import { @@ -34,6 +35,11 @@ import { waitFor, } from './tui-terminal-mock.js'; +// Pin truecolor so the accent-chrome escape assertion ("uses logo blue") is +// hermetic. Color level is detected from process.env.TERM/COLORTERM at module +// load, which varies between local (truecolor) and CI (unset/dumb) terminals. +before(() => _setColorLevelForTesting(3)); + describe('Maka Pi TUI runner', () => { test('restores the terminal before exiting on SIGTERM', async () => { const { code, signal, stdout } = await runSignalExitProbe('SIGTERM'); diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index 4b61b88e58..b059eb375e 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -316,13 +316,23 @@ function activeToolResultArchiveKey( /** * Tool results from the newest completed step have not crossed the provider * boundary yet: prepareStep is invoked immediately before the first request - * that could show those results to the model. Active pruning may archive only - * calls from older completed steps, after the model has had one request in - * which to consume their exact output. + * that could show those results to the model. By default active pruning defers + * the newest step and archives only older completed steps, after the model has + * had one request in which to consume their exact output. + * + * `includeNewestStep` widens eligibility to every completed step, including the + * newest. The caller sets it when mid-turn capacity compaction is active: the + * final-payload verdict may need an oversized newest result pruned to a + * placeholder before declaring exhaustion, and capacity/recovery rebuilds + * re-materialize raw bodies from the ledger that must be re-archived. */ -function collectPrunablePrepareStepToolCallIds(steps: PrepareStepLike['steps']): Set { +function collectPrunablePrepareStepToolCallIds( + steps: PrepareStepLike['steps'], + includeNewestStep: boolean, +): Set { const out = new Set(); - for (const step of steps.slice(0, -1)) { + const prunableSteps = includeNewestStep ? steps : steps.slice(0, -1); + for (const step of prunableSteps) { for (const call of step.toolCalls ?? []) { if (typeof call.toolCallId === 'string' && call.toolCallId.length > 0) { out.add(call.toolCallId); @@ -1447,12 +1457,19 @@ export class AiSdkBackend implements AgentBackend { midTurnSystemPromptChars, onMidTurnDiagnosticPatch, ); - const activeToolResultPruneHook = this.buildActiveToolResultPrunePrepareStep(turnId, (patch) => { - activeToolResultPruneDiagnosticPatch = mergeActiveToolResultPruneDiagnosticPatches( - activeToolResultPruneDiagnosticPatch, - patch, - ); - }); + // When mid-turn capacity compaction is active, the prune must also cover + // the newest completed step; see collectPrunablePrepareStepToolCallIds. + const activeToolResultPruneIncludesNewestStep = midTurnState !== undefined; + const activeToolResultPruneHook = this.buildActiveToolResultPrunePrepareStep( + turnId, + activeToolResultPruneIncludesNewestStep, + (patch) => { + activeToolResultPruneDiagnosticPatch = mergeActiveToolResultPruneDiagnosticPatches( + activeToolResultPruneDiagnosticPatch, + patch, + ); + }, + ); const shapedPrepareStep = composePrepareStep( plan.prepareStep, midTurnCapacityHook, @@ -2408,6 +2425,7 @@ export class AiSdkBackend implements AgentBackend { private buildActiveToolResultPrunePrepareStep( turnId: string, + includeNewestStep: boolean, onDiagnosticPatch?: (patch: ActiveToolResultPruneDiagnosticPatch) => void, ): PrepareStepFunctionLike | undefined { const policy = this.input.contextBudget?.activeToolResultPrune; @@ -2415,7 +2433,7 @@ export class AiSdkBackend implements AgentBackend { const archivedPlaceholders = new Map(); return async (options) => { - const eligibleToolCallIds = collectPrunablePrepareStepToolCallIds(options.steps); + const eligibleToolCallIds = collectPrunablePrepareStepToolCallIds(options.steps, includeNewestStep); if (eligibleToolCallIds.size === 0) return undefined; const rewritten = await rewriteActiveToolResultsInMessages({ messages: options.messages,