From 27fd91a5b13b41e3f80b416f8da95b1cb304edd6 Mon Sep 17 00:00:00 2001 From: AnzoBenjamin Date: Tue, 1 Sep 2026 17:32:10 +0300 Subject: [PATCH 1/2] feat(compaction): surface live compaction passes and gate continuation Compaction was only observable after the fact, so a pass that ran and reclaimed nothing left no trace and the request-time emergency trim was invisible entirely. The SDK now reports that trim through a new context_request_trim event and an optional onRequestContextTrimmed callback, and context_window carries the model-aware compaction trigger/target so the status bar can mark where compaction will fire. The CLI renders passes as typed compaction blocks with a terminal declined status, subagent labelling for nested passes, and trimSource 'request', collapsing consecutive declined passes of a run into one card and tracking live status per run via pendingRunIds so nested runs cannot cross-settle each other. Separately, a passing validation/reviewer gate was being read as a stopping point mid-workflow. The base2 gate-pass finalization notice is now built per call and appends a soft continuation directive while declared write_todos work remains, and the DEFAULT / EXECUTE_PLAN step prompts say the same, without hard-blocking finalization. All public surface additions are additive and optional: a new event variant, optional event fields, and an optional observational callback. Unknown event types remain no-ops, replayed events without the new fields still validate, and existing callers need no migration. Validated: 184 agents/__tests__/base2.test.ts tests, 144 CLI tests (sdk-event-handlers, sweep-boxes, status-bar-chips), 58 agent-runtime/sdk tests (loop-agent-steps, llm-context-window); typechecks clean in cli, agents, packages/agent-runtime, sdk, common. --- agents/__tests__/base2.test.ts | 161 ++++++ agents/base2/base2.ts | 61 +- cli/CHANGELOG.md | 8 +- cli/src/chat.tsx | 14 +- .../components/__tests__/sweep-boxes.test.tsx | 66 +++ .../components/renderers/compaction-box.tsx | 61 +- cli/src/components/status-bar.tsx | 10 +- cli/src/hooks/use-send-message.ts | 7 +- cli/src/types/chat.ts | 83 ++- .../__tests__/sdk-event-handlers.test.ts | 530 +++++++++++++++++- .../utils/__tests__/status-bar-chips.test.ts | 208 +++++++ cli/src/utils/sdk-event-handlers.ts | 366 +++++++++--- cli/src/utils/status-bar-chips.ts | 150 ++++- common/src/types/contracts/llm.ts | 30 + common/src/types/print-mode.ts | 119 ++++ docs/agents-and-tools.md | 96 +++- .../src/__tests__/loop-agent-steps.test.ts | 195 +++++++ .../agent-runtime/src/prompt-agent-stream.ts | 4 + packages/agent-runtime/src/run-agent-step.ts | 37 ++ sdk/CHANGELOG.md | 3 + .../impl/__tests__/llm-context-window.test.ts | 103 ++++ sdk/src/impl/llm.ts | 32 ++ 22 files changed, 2173 insertions(+), 171 deletions(-) diff --git a/agents/__tests__/base2.test.ts b/agents/__tests__/base2.test.ts index fbb62cf08e..ca7d3debf0 100644 --- a/agents/__tests__/base2.test.ts +++ b/agents/__tests__/base2.test.ts @@ -10609,3 +10609,164 @@ describe('base2 inline formatGateStateBlock delimiter safety', () => { expect(parsed?.advisories).toEqual([hostileAdvisory]) }) }) + +describe('base2 gate-pass continuation directive', () => { + /** write_todos tool-call + successful tool result, the fixture shape the + * existing workflow-todo-progress cases use. */ + function writeTodosHistory( + todos: Array<{ content: string; status: string }>, + ) { + return [ + { + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'todos-1', + toolName: 'write_todos', + input: { todos }, + }, + ], + }, + { + role: 'tool', + toolCallId: 'todos-1', + toolName: 'write_todos', + content: [{ type: 'json', value: { success: true } }], + }, + ] + } + + /** Drive one edit through validation + code-reviewer to the gate-pass + * add_message and return its content. */ + function driveToGatePassMessage(agentState: Record): string { + const base2 = createBase2('default') + const gen = base2.handleSteps!({ + agentState, + prompt: 'Make the requested change now please', + params: {}, + config: base2.programmaticConfig, + } as any) + + expect(gen.next().value).toMatchObject({ toolName: 'git_status' }) + expect(gen.next(feedJson({ status: '' })).value).toMatchObject({ + toolName: 'spawn_agent_inline', + }) + const maybePinned = gen.next().value + if (maybePinned !== 'STEP') { + expect(maybePinned).toMatchObject({ toolName: 'add_message' }) + expect(gen.next().value).toBe('STEP') + } + expect( + gen.next(finishStepWithToolResult(editReceipt('src/a.ts'))).value, + ).toMatchObject({ toolName: 'git_status' }) + expect(gen.next(feedJson({ status: ' M src/a.ts' })).value).toMatchObject({ + toolName: 'run_file_change_hooks', + }) + expect(gen.next(feedJson([])).value).toMatchObject({ + toolName: 'git_status', + }) + const reviewCall = gen.next(feedJson({ status: ' M src/a.ts' })) + .value as any + expect(reviewCall).toMatchObject({ + toolName: 'spawn_agents', + input: { agents: [{ agent_type: 'code-reviewer' }] }, + }) + expect( + gen.next(attestedReviewerResult(reviewCall) as any).value, + ).toMatchObject({ toolName: 'git_status' }) + const gatePassed = gen.next(feedJson({ status: ' M src/a.ts' })) + expect(gatePassed.value).toMatchObject({ + toolName: 'add_message', + input: { role: 'user' }, + }) + return (gatePassed.value as any).input.content as string + } + + test('with no incomplete workflow todos the gate-pass notice is unchanged', () => { + const content = driveToGatePassMessage({ agentId: 'base2-custom' }) + + // The original notice text is load-bearing for prompt/gate snapshots. + expect(content).toContain( + 'Provide your single user-visible completion summary now', + ) + expect(content).toContain( + 'Do not make more edits unless absolutely necessary; any new edits will rerun the gate.', + ) + // ...and no continuation directive is emitted. + expect(content).not.toContain('Next workflow action:') + expect(content).not.toContain('declared workflow still has remaining items') + expect(content).not.toContain('Default behavior: continue with that next') + }) + + test('with an incomplete workflow todo the gate-pass notice tells the agent to continue this turn', () => { + const content = driveToGatePassMessage({ + agentId: 'base2-custom', + messageHistory: writeTodosHistory([ + { content: 'Implement wave 1 of the refactor', status: 'completed' }, + { content: 'Implement wave 2 of the refactor', status: 'pending' }, + ]), + }) + + expect(content).toContain( + 'The gate passed for the current edits, but your declared workflow still has remaining items: Completed 1/2.', + ) + expect(content).toContain( + 'Next workflow action: Implement wave 2 of the refactor', + ) + expect(content).toContain( + 'Default behavior: continue with that next workflow item in this same turn instead of finalizing.', + ) + // A re-armed gate is the concern that currently makes the model stop, so it + // is addressed explicitly. + expect(content).toContain( + 'New edits will re-arm the validation/reviewer gate.', + ) + // Stopping early is allowed but must be stated with a concrete reason. + expect(content).toContain( + 'you MUST say so explicitly in your completion summary and state the concrete reason', + ) + expect(content).toContain( + 'Silently finalizing with incomplete declared todos is not acceptable.', + ) + // The original single-summary / suggest_followups-last ordering still + // applies when it does finalize. + expect(content).toContain('Write at most one completion summary per turn.') + expect(content).toContain( + 'Call suggest_followups only as the absolute last tool after that summary', + ) + }) + + test('DEFAULT step prompt carries the write_todos continuation directive; fast mode does not', () => { + const base2 = createBase2('default') + expect(base2.stepPrompt).toContain( + 'a passing validation/reviewer gate is not a stopping point', + ) + expect(base2.stepPrompt).toContain( + 'continue through the remaining declared items in this same turn', + ) + + const fast = createBase2('fast') + expect(fast.stepPrompt).not.toContain( + 'a passing validation/reviewer gate is not a stopping point', + ) + }) + + test('EXECUTE_PLAN step prompt continues to the next plan task without relaxing one-in_progress', () => { + const executePlan = createBase2('default', { executePlan: true }) + + expect(executePlan.stepPrompt).toContain( + 'claim the next actionable task and keep executing in this same turn', + ) + expect(executePlan.stepPrompt).toContain( + 'one in_progress at a time, never claiming several at once', + ) + expect(executePlan.stepPrompt).toContain( + 'naming the task ID you reached and what remains', + ) + // It also inherits the shared DEFAULT-mode directive. + expect(executePlan.stepPrompt).toContain( + 'a passing validation/reviewer gate is not a stopping point', + ) + }) +}) diff --git a/agents/base2/base2.ts b/agents/base2/base2.ts index e22916baff..c271e631eb 100644 --- a/agents/base2/base2.ts +++ b/agents/base2/base2.ts @@ -1046,15 +1046,11 @@ ${guideSections} ? Math.min(Math.floor(configuredMaxSpecialistRepairRounds), 20) : Number.POSITIVE_INFINITY const MAX_SPECIALIST_NO_VERDICT_RETRIES = 1 - // Single source of truth for the post-gate finalization instruction used - // by every gate-pass path (fresh pass, conversation reuse, durable - // fingerprint reuse). Worded idempotently so a model that already wrote - // a summary earlier in the turn adds only follow-up suggestions instead - // of repeating the summary. Declared inline because handleSteps is - // serialized via .toString() and reconstructed with new Function(...), - // so a module-scope binding would be undefined at reconstruction time. - const GATE_PASS_FINALIZATION_NOTICE = - 'Provide your single user-visible completion summary now if you have not already written one this turn; if you already have, add only the follow-up suggestions instead of repeating it. Write at most one completion summary per turn. Call suggest_followups only as the absolute last tool after that summary (and after git-committer if committing this turn); never mid-turn and never before remaining work. Do not make more edits unless absolutely necessary; any new edits will rerun the gate.' + // The post-gate finalization instruction shared by every gate-pass path + // is built by buildGatePassFinalizationNotice() in the inline-helper + // region below (see that function's comment for why it must stay a + // hoisted inline `function` declaration and why reading activeWorkState + // at call time is safe). const existingActiveWorkState = mutableAgentState.base2ActiveWork const hadPendingGateFiles = !!existingActiveWorkState && @@ -3680,7 +3676,7 @@ ${guideSections} role: 'user', content: [ `Previous validation and reviewer gate already passed in this conversation with ${conversationReviewerVerdict} for pending files: ${currentPendingGateFiles.join(', ')}.`, - `Reusing that unchanged gate result; ${GATE_PASS_FINALIZATION_NOTICE}`, + `Reusing that unchanged gate result; ${buildGatePassFinalizationNotice()}`, formatGateStateBlock( 'validation/reviewer', 'passed', @@ -3741,7 +3737,7 @@ ${guideSections} role: 'user', content: [ `Previous validation and reviewer gate already passed with ${durableReviewerVerdict} for pending files: ${currentPendingGateFiles.join(', ')}.`, - GATE_PASS_FINALIZATION_NOTICE, + buildGatePassFinalizationNotice(), formatGateStateBlock( 'validation/reviewer', 'passed', @@ -5623,7 +5619,7 @@ ${guideSections} passedPendingFiles.length > 0 ? 'The preceding Change review diff is the user-visible filesystem evidence for this gate. Use /diff for the full current working-tree diff, /changes for the file list, or /diff -- to inspect one file.' : '', - GATE_PASS_FINALIZATION_NOTICE, + buildGatePassFinalizationNotice(), formatGateStateBlock( 'validation/reviewer', 'passed', @@ -5697,6 +5693,44 @@ ${guideSections} activeWorkState.lastPinnedStateMessage = '' } + // Single source of truth for the post-gate finalization instruction used + // by every gate-pass path (fresh pass, conversation reuse, durable + // fingerprint reuse). Worded idempotently so a model that already wrote + // a summary earlier in the turn adds only follow-up suggestions instead + // of repeating the summary. + // + // Inline because handleSteps is serialized via .toString() and + // reconstructed with new Function(...), so a module-scope binding would + // be undefined at reconstruction time. It MUST stay a hoisted `function` + // declaration and never become a `const` arrow: all three call sites + // appear EARLIER in the source than this declaration, and only a + // function declaration hoists above them. Reading `activeWorkState` at + // call time is safe because every call site executes inside the gate + // loop, long after activeWorkState is initialized. + // + // SOFT continuation directive: when the agent declared multi-phase work + // with write_todos and an incomplete item remains, the notice tells it to + // keep going in the same turn instead of finalizing, and to state an + // explicit reason if it stops early. Nothing here hard-blocks + // finalization — no gate state is read or written, the directive lives + // entirely in the emitted text. With no incomplete declared work the + // original notice is returned BYTE-FOR-BYTE, because prompt/gate + // snapshots and e2e tests pin that exact string. + function buildGatePassFinalizationNotice(): string { + const finalizationNotice = + 'Provide your single user-visible completion summary now if you have not already written one this turn; if you already have, add only the follow-up suggestions instead of repeating it. Write at most one completion summary per turn. Call suggest_followups only as the absolute last tool after that summary (and after git-committer if committing this turn); never mid-turn and never before remaining work. Do not make more edits unless absolutely necessary; any new edits will rerun the gate.' + const progress = activeWorkState.workflowTodoProgress + const nextWorkflowAction = (progress?.nextWorkflowAction ?? '').trim() + if (!progress || !nextWorkflowAction) return finalizationNotice + return [ + `The gate passed for the current edits, but your declared workflow still has remaining items: Completed ${progress.completedCount}/${progress.totalCount}. Next workflow action: ${nextWorkflowAction}`, + 'Default behavior: continue with that next workflow item in this same turn instead of finalizing. The user asked for the whole declared workflow, not just the current wave, so asking permission between your own self-declared waves is redundant while the remaining items are part of the same request.', + 'New edits will re-arm the validation/reviewer gate. That is expected and acceptable for continuing declared work, so a re-armed gate is not a reason to stop.', + 'Finalizing is still permitted. If you stop before the declared workflow is complete you MUST say so explicitly in your completion summary and state the concrete reason (blocked on a decision, needs user input, remaining work is genuinely out of scope, or repeated failure) plus what remains. Silently finalizing with incomplete declared todos is not acceptable.', + 'When you do finalize: provide your single user-visible completion summary now if you have not already written one this turn; if you already have, add only the follow-up suggestions instead of repeating it. Write at most one completion summary per turn. Call suggest_followups only as the absolute last tool after that summary (and after git-committer if committing this turn); never mid-turn and never before remaining work.', + ].join('\n') + } + // Durable one-line mid-turn gate-progress note. Rendered by // buildPinnedActiveWorkMessage as a "Gate progress:" line inside the // pinned active-work message. When that line is the only change since @@ -10050,6 +10084,8 @@ function buildImplementationStepPrompt({ gateActive ? 'Write your completion summary exactly once per turn. For edited code, write it in the final message after the automated validation/reviewer gate has passed — do not summarize the finished work before the gate runs.' : `After completing the user request, summarize your changes in a sentence${isFast ? '' : ' or a few short bullet points'}.`, + isDefault && + 'When you declared multi-step work with write_todos, a passing validation/reviewer gate is not a stopping point: continue through the remaining declared items in this same turn. Stop early only with an explicitly stated reason and a note of what still remains.', isDefault && 'Do not manually spawn code-reviewer for the same edited file set that the automated runtime gate will review. Manual review is only for user-requested extra review or pre-edit/advisory review. Spawn security-reviewer for auth, crypto, secrets, permissions, injection, sandboxing, supply-chain, or production-risk changes.', isDefault && @@ -10071,6 +10107,7 @@ function buildExecutePlanStepPrompt({}: {}) { 'You are in EXECUTE_PLAN mode. Execute or resume durable plan artifacts, using the project source editing tools when implementation work is required. Unlike PLAN mode, you may edit project source files to complete planned tasks.', 'Treat SPEC.md, PLAN.md, STATUS.md, and LESSONS.md under the durable plan session as authoritative. Use any artifact contents already present in the conversation as the initial source of truth, confirm the next incomplete or blocked item from that context, and read artifacts directly only when contents are missing, truncated, stale, or have changed. Do not repeatedly re-read unchanged artifacts or source files after confirming the next item; continue from it unless the artifacts say completed work must be revisited.', 'Honor the deterministic preflight included with resumed artifacts. Do not edit source when preflight reports errors. Use stable task IDs for updates, keep at most one task in_progress, respect dependencies, and do not mark a task done until its Validate gate passes and the checkpoint is recorded.', + 'Completing one plan task and passing its validation gate is not the end of the turn: claim the next actionable task and keep executing in this same turn. This does not relax the at-most-one-task-in_progress rule above — advance through the tasks sequentially, one in_progress at a time, never claiming several at once. If you stop before the plan is complete, say so explicitly and state the reason, naming the task ID you reached and what remains.', 'Keep STATUS.md current as you progress: update completed/pending/blocked items, current state, validation results, and the next checkpoint. Keep LESSONS.md current with gotchas, decisions, reusable findings, and follow-up notes discovered during execution. Prefer update_plan_status for incremental STATUS.md / LESSONS.md updates; use create_plan for SPEC.md / PLAN.md revisions, substantial rewrites, or creating missing artifacts.', 'Use normal implementation behavior for source changes: gather context before editing, follow project conventions, validate meaningful changes when appropriate, and summarize the completed work concisely. Do not let plan artifacts drift behind actual implementation state.', ).join('\n') diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index bbe4236f35..3beb78d828 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -13,6 +13,11 @@ All notable changes to the `@openbuff/cli` package will be documented in this fi ### Added +- **Additive public-surface changes on the `handleEvent`/SDK contract consumed by this CLI.** All three are optional or new-variant additions, so no consumer migration is required and an unknown `event.type` remains a no-op for `handleEvent` consumers: + - New `context_request_trim` `PrintModeEvent` variant (`common/src/types/print-mode.ts`) reporting the SDK's request-time emergency trim — the last-line-of-defense drop applied at dispatch when a request's messages still exceed the provider-safe budget after every runtime brake ran. It carries the required `messageBudgetTokens`, `beforeTokens`, `afterTokens`, `beforeMessages`, and `afterMessages`, plus optional `runId`, `ancestorRunIds`, `agentId`, `resolvedContextWindowTokens`, and `model`. It is a DIFFERENT brake from `context_compaction`, so the two must not be merged or counted as one pass. The CLI renders it as a `compaction` block marked `trimSource: 'request'` that never consumes an announced pending card and always degrades the turn's compaction chip. + - New optional `compactionTriggerTokens` / `compactionTargetTokens` on the existing `context_window` variant, reporting the runtime's model-aware semantic-compaction budget for the active model so a UI can show where compaction will fire. Both are optional, so replayed events emitted before they existed still validate and a consumer that ignores them keeps its previous behavior; the CLI uses the trigger value to tone the context chip. + - New optional `onRequestContextTrimmed` callback and its `RequestContextTrimInfo` payload type (`common/src/types/contracts/llm.ts`, implemented in `sdk/src/impl/llm.ts`) on the published `promptAiSdk` / `promptAiSdkStream` / `promptAiSdkStructured` signatures. Purely observational: it fires only when the request-time trim actually dropped messages, can never affect the trim result, and a throwing consumer is caught and logged rather than aborting dispatch. Existing callers that omit it are unaffected. Published API details in `sdk/CHANGELOG.md`. + - New `/memory` command (alias `/mem`) for the persisted cross-session task memory of the current project. `/memory status` (the default) reports the record's revision and age, its goal and per-list counts, and how much of its evidence still verifies against disk, listing up to five stale paths. `/memory prune` drops evidence that no longer verifies. Prune reports the store's outcome faithfully: an absent record, a no-op ("nothing to prune") on a fully fresh record, the removal and remaining counts on success, and an explicit failure naming its cause (schema reject, a concurrent task-memory save, or an unwritable target such as a permissions error or a filesystem without atomic renames) together with the stale entries still present. A failed prune is never presented as "nothing to prune" or as a missing record. Published API and type surface documented in `sdk/CHANGELOG.md`. - Optional gate repair budget caps on `createBase2` / env (see Changed). Shared resolve/format helpers live in `common/src/util/gate-repair-budgets.ts` and are re-exported from `agents/base2/base2.ts`. Documented in `docs/configuration.md` and `docs/environment-variables.md`. - `/context` (alias `/ctx`) now always prints the effective **Gate repair budgets** section (validation / reviewer / specialist), resolved from env/defaults even when no context-budget ledger exists yet. Ledger output, when present, is shown first with budgets appended after a blank line. @@ -27,9 +32,10 @@ All notable changes to the `@openbuff/cli` package will be documented in this fi - Inline reviewers now receive the orchestrator history as read context without copying their private file reads, tool results, and `set_output` transcript back into the parent prompt. Deliberate `set_messages` control-plane rewrites and context-pruner compaction still propagate. - Structured reviewer results are bounded before entering parent history, while retaining verdicts, snapshots, findings, corrections, dimensions, and representative evidence. - Semantic compaction preserves a larger beginning-and-end task contract and next action so trailing instructions survive long pasted diagnostics. The pinned next action's baseline bound is now **1,400** characters (previously 1,000) and the pinned goal's is **2,400**. Both are baselines at the legacy 100k semantic target, not fixed absolutes: they are scaled by `clamp(targetTokens / 100_000, 0.5, 3.0)` (see the scaled-retention entry below), so the default 200k-class window resolves a 72k target and caps the goal/next action at ~1,728/~1,008 characters. `docs/agents-and-tools.md` documents the baseline caps and the scaling. +- **Persisted `compaction` block format widened (additive).** `CompactionContentBlock` in `cli/src/types/chat.ts` — persisted to `chat-messages.json` and replayed on reload — gains a fourth terminal `status` value `'declined'` alongside `pending`/`complete`/`interrupted`, plus the optional `subagent` and `trimSource` fields. `'declined'` is a pass that RAN and reclaimed nothing (the runtime settled it without ever reporting a result), deliberately distinct from `'interrupted'` (the run ended before the pass could report anything): the CLI now rewrites a settled-without-result pending card to it instead of dropping the card, and consecutive declined cards of the same run collapse into one. `subagent: true` marks a pass performed by a foreground subagent or inline agent run, and `trimSource: 'request'` marks the SDK's request-time emergency trim. Forward replay is unchanged: every added field is optional and its absent value is the previous behavior, so blocks persisted by an earlier CLI round-trip as-is. Backward replay is lossy but non-fatal: an older CLI knows only the first three `status` values and falls through to its completed-pass branch, so a `'declined'` block — whose result fields are the zeroed placeholders of a pass that never reported one — renders there as a completed pass reporting `→ 0 tokens (−0%)`, and a dropped `subagent`/`trimSource` presents a nested or request-time trim as a root-level runtime pass. Nothing fails to parse and the session still loads; the mis-rendering is confined to the transcript card. Documented in `docs/agents-and-tools.md` under "Context-window-aware compaction budgets". - **Consumer-visible (additive) compaction contract changes.** `context_compaction` events on the public `handleEvent` surface gained optional telemetry fields (`resolvedContextWindowTokens`, `triggerBudgetTokens`, `targetBudgetTokens`, `compactionCount`, `consecutiveNoProgressCompactions`, `shortfallTokens`, `fitsBudget`, `escalated` in `common/src/types/print-mode.ts`). All are optional, so persisted/replayed events emitted before the telemetry existed still validate and consumers that ignore them keep their previous behavior. The CLI now records each compaction pass as a typed `compaction` content block (`CompactionContentBlock` in `cli/src/types/chat.ts`, rendered by `CompactionBox`) instead of appending the previous concatenated free-text `text` block; replayed sessions that still hold the old notice keep rendering as plain text. Full contract in `docs/agents-and-tools.md` under "Context-window-aware compaction budgets". - Pinned `` retention now scales with the resolved semantic target budget (`targetTokens / 100_000`, clamped to `[0.5, 3.0]`) instead of using fixed per-field caps, so a ~1m-token window retains up to 3x the decisions, inspected files, edits, validation results, review receipts, post-edit anchors, and blockers, and a small BYOK window retains half. The same factor scales every per-field **character** cap, including the pinned 2,400-character goal and 1,400-character next action, so those documented numbers are baselines at a 100k target rather than fixed absolutes — the default 200k-class window resolves a 72k target (scale `0.72`) and caps them at ~1,728 and ~1,008 characters. The 100k target remains the scale-`1.0` baseline, but retention at that baseline is **not** byte-identical to the previous release: this change also raised three of the baseline caps — decisions (8 → 12), blockers (8 → 12), and the next action (1,000 → 1,400 characters). Every other baseline cap is unchanged (2,400-character goal, 25 files inspected, 25 edits, 12 validation results, 12 review receipts, 16 post-edit anchors, 480-character entries). Because the block is pinned verbatim and exempt from the normal budget cutoff, it is now bounded by a hard ceiling of `max(1_500, floor(targetTokens * 0.25))` estimated tokens, enforced by oldest-first eviction in a fixed field order; `Goal:` and `Next Action:` are truncated toward 480/240-character floors rather than dropped. Documented in `docs/agents-and-tools.md` under "Context-window-aware compaction budgets". -- **Additive live compaction status event.** A new `context_compaction_status` variant on the public `handleEvent` surface (`common/src/types/print-mode.ts`) reports `state: 'started' | 'settled'` with the required agent/run correlation `runId` and `ancestorRunIds` (plus optional `agentId`) and optional `contextTokens`, `resolvedContextWindowTokens`, `triggerBudgetTokens`, and `targetBudgetTokens`. `started` fires before a programmatic step whose window-derived semantic trigger is exceeded (never for an explicit `maxContextLength` override), and `settled` with the same `runId` always follows it — including from the run's exit path when a step throws or is cancelled first — so a pass that decides not to compact cannot leave a pending state on screen. Because every agent loop (root turn, foreground subagents, inline agents) emits these events, both the event and the `context_compaction` result now carry run correlation and the protocol is scoped by run: `ancestorRunIds` is empty only for the root run, `started`/`settled` pair by `runId`, and `compactionCount` counts the emitting run's own passes rather than a per-turn total. `runId`/`ancestorRunIds` survive every forwarding hop; the optional `agentId` does not, because the `spawn_agents` forwarding path rewrites it to the direct child's agent id, so at nesting depth >= 2 it identifies the nearest forwarding child rather than the emitter and must not be used as a per-agent key. The CLI renders only a root-run `started` as a pending `compaction` block (stamped with that `runId`) plus a live status chip, replaces it in place with the terminal `context_compaction` result of the same run, drops only that run's pending block on its `settled`, and clears any stray pending block at the turn boundary; a subagent's compaction can no longer render as a root-level "Compacting context…" card, cross-settle the root run's live pass, or overwrite the root turn's compaction count. Because a user-initiated abort makes the SDK drop every post-abort event, the transient pending block also carries an optional `liveSessionId` (`CLI_LIVE_SESSION_ID` in `cli/src/types/chat.ts`) stamping the producing CLI process: a replayed pending block from a session the user aborted mid-compaction renders as "Compaction interrupted" instead of a permanent "Compacting context…" card, and the live status chip only honors `pending` while the run is active. Both this event and the scaled retention above are additive and require no consumer migration: unknown event variants are no-ops for `handleEvent` consumers, the result event's correlation fields are optional so replayed events without them stay root-attributed, replayed sessions whose compaction blocks carry no `status` field render as completed passes, and blocks persisted without `liveSessionId`/`runId` round-trip unchanged. +- **Additive live compaction status event.** A new `context_compaction_status` variant on the public `handleEvent` surface (`common/src/types/print-mode.ts`) reports `state: 'started' | 'settled'` with the required agent/run correlation `runId` and `ancestorRunIds` (plus optional `agentId`) and optional `contextTokens`, `resolvedContextWindowTokens`, `triggerBudgetTokens`, and `targetBudgetTokens`. `started` fires before a programmatic step whose window-derived semantic trigger is exceeded (never for an explicit `maxContextLength` override), and `settled` with the same `runId` always follows it — including from the run's exit path when a step throws or is cancelled first — so a pass that decides not to compact cannot leave a pending state on screen. Because every agent loop (root turn, foreground subagents, inline agents) emits these events, both the event and the `context_compaction` result now carry run correlation and the protocol is scoped by run: `ancestorRunIds` is empty only for the root run, `started`/`settled` pair by `runId`, and `compactionCount` counts the emitting run's own passes rather than a per-turn total. `runId`/`ancestorRunIds` survive every forwarding hop; the optional `agentId` does not, because the `spawn_agents` forwarding path rewrites it to the direct child's agent id, so at nesting depth >= 2 it identifies the nearest forwarding child rather than the emitter and must not be used as a per-agent key. The CLI renders only a root-run `started` as a pending `compaction` block (stamped with that `runId`) plus a live status chip, replaces it in place with the terminal `context_compaction` result of the same run, rewrites only that run's still-pending block in place to a terminal `declined` card on its `settled` (a pass that ran and reclaimed nothing keeps an honest trace instead of being deleted; consecutive declined passes of the same run collapse into one card), and clears any stray pending block at the turn boundary; a subagent's compaction can no longer render as a root-level "Compacting context…" card, cross-settle the root run's live pass, or overwrite the root turn's compaction count, though its live pass IS recorded in the notice's `pendingRunIds` set and therefore reported by the shared root-level status chip until that run settles. Because a user-initiated abort makes the SDK drop every post-abort event, the transient pending block also carries an optional `liveSessionId` (`CLI_LIVE_SESSION_ID` in `cli/src/types/chat.ts`) stamping the producing CLI process: a replayed pending block from a session the user aborted mid-compaction renders as "Compaction interrupted" instead of a permanent "Compacting context…" card, and the live status chip only honors `pending` while the run is active. Both this event and the scaled retention above are additive and require no consumer migration: unknown event variants are no-ops for `handleEvent` consumers, the result event's correlation fields are optional so replayed events without them stay root-attributed, replayed sessions whose compaction blocks carry no `status` field render as completed passes, and blocks persisted without `liveSessionId`/`runId` round-trip unchanged. - `edit_transaction` now strongly requests real edit arrays, continues to repair complete legacy JSON encodings, and reports truncated encodings at the `edits` field with safe recovery guidance instead of a misleading `edits[0]` object error. ## [1.1.11] - 2026-07-07 diff --git a/cli/src/chat.tsx b/cli/src/chat.tsx index 81790038b3..19a0060936 100644 --- a/cli/src/chat.tsx +++ b/cli/src/chat.tsx @@ -100,7 +100,10 @@ import type { MultilineInputHandle } from './components/multiline-input' import type { MatchedSlashCommand } from './hooks/use-suggestion-engine' import { AGENT_MODE_TO_ID, type AgentMode } from './utils/constants' import type { FileTreeNode } from '@codebuff/common/util/file' -import type { CompactionNotice } from './utils/sdk-event-handlers' +import type { + CompactionNotice, + StatusBarContextUsage, +} from './utils/sdk-event-handlers' import type { ScrollBoxRenderable } from '@opentui/core' export const Chat = ({ @@ -387,10 +390,11 @@ export const Chat = ({ // M4.3: Context-window usage for the status bar (updated via context_window // PrintModeEvent from the agent runtime). - const [contextWindowUsage, setContextWindowUsage] = useState<{ - used: number - max: number - } | null>(null) + // Canonical shape (StatusBarContextUsage): its optional + // `compactionTriggerTokens` is only present once the runtime reports its + // model-aware compaction trigger budget on the context_window event. + const [contextWindowUsage, setContextWindowUsage] = + useState(null) // Accumulated context-compaction notice for the turn in progress (reset to // null when a new run starts, inside useSendMessage). Drives the status-bar diff --git a/cli/src/components/__tests__/sweep-boxes.test.tsx b/cli/src/components/__tests__/sweep-boxes.test.tsx index 2a201c63e3..93b092b6c1 100644 --- a/cli/src/components/__tests__/sweep-boxes.test.tsx +++ b/cli/src/components/__tests__/sweep-boxes.test.tsx @@ -196,6 +196,72 @@ describe('CompactionBox', () => { expect(markup).not.toContain('target') }) + test('renders a declined pass with the current size only and no result lines', () => { + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('Compaction pass — nothing reclaimed') + expect(markup).toContain('152k tokens → target 70k') + // A declined pass ran: it is not an interrupted one, and it has no result. + expect(markup).not.toContain('Compaction interrupted') + expect(markup).not.toContain( + 'Interrupted before this pass reported a result.', + ) + expect(markup).not.toContain('Compacting context…') + expect(markup).not.toContain('tokens (−0%)') + expect(markup).not.toContain('0 → 0 messages') + expect(markup).not.toContain('No knowledge memory retained') + expect(markup).not.toContain('Re-read exact files before editing.') + }) + + test('renders a request-time trim with its own title, distinct from the runtime emergency trim', () => { + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('Context trimmed at request time') + expect(markup).not.toContain('Context trimmed (emergency)') + expect(markup).toContain('Request-time emergency brake.') + expect(markup).toContain('Reduce pinned state or start a fresh turn.') + + // A runtime mechanical trim (no trimSource) keeps its existing title. + const runtime = renderToStaticMarkup( + , + ) + expect(runtime).toContain('Context trimmed (emergency)') + expect(runtime).not.toContain('Context trimmed at request time') + }) + + test('prefixes a subagent pass title', () => { + const markup = renderToStaticMarkup( + , + ) + expect(markup).toContain('Subagent: Context compacted') + }) + test('renders the emergency title, shortfall line and missing-memory line', () => { const markup = renderToStaticMarkup( const INTERRUPTED_TEXT = 'Interrupted before this pass reported a result.' /** - * The pending/interrupted/unsettled triple that both the tone and every - * rendered line depend on. + * The pending/interrupted/declined triple that both the tone and every rendered + * line depend on. */ type CompactionPresentation = { unsettled: boolean pending: boolean interrupted: boolean + declined: boolean } /** - * Single source of truth for pending vs interrupted, consumed by both - * {@link deriveTone} and the render path so the chosen tone and the rendered - * lines cannot drift apart. + * Single source of truth for pending vs interrupted vs declined, consumed by + * both {@link deriveTone} and the render path so the chosen tone and the + * rendered lines cannot drift apart. * - * `unsettled` covers a live pass, a terminated one and a replayed pending one: - * none has result fields, so the result lines stay suppressed for all. A - * pending block that is not live in THIS process (absent or foreign - * `liveSessionId`, i.e. a replayed transcript) is deliberately presented as - * interrupted — see {@link isLiveCompaction}. + * `unsettled` covers a live pass, a terminated one, a declined one and a + * replayed pending one: none has result fields, so the result lines stay + * suppressed for all. A pending block that is not live in THIS process (absent + * or foreign `liveSessionId`, i.e. a replayed transcript) is deliberately + * presented as interrupted — see {@link isLiveCompaction}. A `declined` pass is + * terminal but reclaimed nothing, so it is never presented as interrupted. */ const derivePresentation = ( block: CompactionContentBlock, ): CompactionPresentation => { - const unsettled = block.status === 'pending' || block.status === 'interrupted' + const declined = block.status === 'declined' + const unsettled = + declined || block.status === 'pending' || block.status === 'interrupted' const pending = block.status === 'pending' && isLiveCompaction(block) - return { unsettled, pending, interrupted: unsettled && !pending } + return { + unsettled, + pending, + interrupted: unsettled && !pending && !declined, + declined, + } } const deriveTone = ( @@ -91,9 +100,12 @@ const deriveTone = ( ): Tone => { // A pass that is still running has no result to judge yet, so it always // reads as neutral regardless of `action`; a pass that never settled reads - // as a warning. + // as a warning, and a declined one is merely uneventful. if (presentation.interrupted) return 'warning' - if (presentation.pending) return 'secondary' + if (presentation.pending || presentation.declined) return 'secondary' + // The request-time emergency brake fired only because every runtime brake was + // already exceeded. + if (block.trimSource === 'request') return 'error' if (block.fitsBudget === false) return 'error' const noProgress = block.consecutiveNoProgressCompactions if ( @@ -123,15 +135,22 @@ interface CompactionBoxProps { export const CompactionBox = memo(({ block }: CompactionBoxProps) => { const theme = useTheme() - const { unsettled, pending, interrupted } = derivePresentation(block) - const tone = deriveTone(block, { unsettled, pending, interrupted }) - const title = pending + const presentation = derivePresentation(block) + const { unsettled, pending, interrupted, declined } = presentation + const tone = deriveTone(block, presentation) + const baseTitle = pending ? 'Compacting context…' : interrupted ? 'Compaction interrupted' - : block.action === 'mechanical_trim' - ? 'Context trimmed (emergency)' - : 'Context compacted' + : declined + ? 'Compaction pass — nothing reclaimed' + : block.trimSource === 'request' + ? 'Context trimmed at request time' + : block.action === 'mechanical_trim' + ? 'Context trimmed (emergency)' + : 'Context compacted' + // A nested run's pass is labelled so it is not mistaken for the root turn's. + const title = block.subagent === true ? `Subagent: ${baseTitle}` : baseTitle const beforeTokens = sanitizeCount(block.beforeTokens) const afterTokens = sanitizeCount(block.afterTokens) @@ -188,7 +207,7 @@ export const CompactionBox = memo(({ block }: CompactionBoxProps) => { {pendingText} diff --git a/cli/src/components/status-bar.tsx b/cli/src/components/status-bar.tsx index 5a02f318ca..b468a1dd90 100644 --- a/cli/src/components/status-bar.tsx +++ b/cli/src/components/status-bar.tsx @@ -17,6 +17,7 @@ import { import { selectStatusBarChips, type StatusBarChipTone, + type StatusBarContextUsage, } from '../utils/status-bar-chips' import type { CompactionNotice } from '../types/chat' import type { StatusIndicatorState } from '../utils/status-indicator-state' @@ -74,7 +75,14 @@ interface StatusBarProps { isAtBottom: boolean scrollToLatest: () => void statusIndicatorState: StatusIndicatorState - contextWindowUsage?: { used: number; max: number } | null + /** + * Context usage for the chip, in the canonical + * {@link StatusBarContextUsage} shape the chip selector consumes: its + * optional `compactionTriggerTokens` is only present once the runtime + * reports its model-aware compaction trigger budget, and the chip renders as + * before without it. + */ + contextWindowUsage?: StatusBarContextUsage | null /** * Accumulated context-compaction notice for the current turn (null once the * next turn starts). Rendered as a chip beside the context usage. diff --git a/cli/src/hooks/use-send-message.ts b/cli/src/hooks/use-send-message.ts index a7f178f6d7..4a9ecf196f 100644 --- a/cli/src/hooks/use-send-message.ts +++ b/cli/src/hooks/use-send-message.ts @@ -45,14 +45,17 @@ import type { ChatMessage } from '../types/chat' import type { SendMessageFn } from '../types/contracts/send-message' import type { AgentMode } from '../utils/constants' import type { SendMessageTimerEvent } from '../utils/send-message-timer' -import type { SetCompactionNoticeFn } from '../utils/sdk-event-handlers' +import type { + SetCompactionNoticeFn, + SetContextWindowUsageFn, +} from '../utils/sdk-event-handlers' import type { AgentDefinition, MessageContent, RunState } from '@openbuff/sdk' interface UseSendMessageOptions { inputRef: React.MutableRefObject activeSubagentsRef: React.MutableRefObject> isChainInProgressRef: React.MutableRefObject setStreamStatus: (status: StreamStatus) => void - setContextWindowUsage: (usage: { used: number; max: number } | null) => void + setContextWindowUsage: SetContextWindowUsageFn setCompactionNotice: SetCompactionNoticeFn setCanProcessQueue: (can: boolean) => void abortControllerRef: React.MutableRefObject diff --git a/cli/src/types/chat.ts b/cli/src/types/chat.ts index ced218d230..3079d8694d 100644 --- a/cli/src/types/chat.ts +++ b/cli/src/types/chat.ts @@ -216,8 +216,38 @@ export type CompactionContentBlock = { * reported a result (the user aborted mid-compaction, or the turn ended * abnormally): the abort/teardown path rewrites 'pending' to it, so a block * that reaches persistence never claims to still be running. + * 'declined' is the terminal state of a pass that RAN and reclaimed nothing + * (the runtime settled it without ever reporting a result), which is distinct + * from 'interrupted': the pass completed, it simply had nothing to reclaim. + * + * Backward replay is lossy but non-fatal, and is documented as such in + * `docs/agents-and-tools.md`: an older CLI enumerates only + * pending/complete/interrupted and falls through to its completed-pass branch + * for an unknown value, so a 'declined' block written here renders there as a + * completed pass reporting `→ 0 tokens (−0%)` (its result fields are the + * zeroed placeholders of a pass that never reported one). Nothing fails to + * parse and the session still loads. + */ + status?: 'pending' | 'complete' | 'interrupted' | 'declined' + /** + * True when this pass was performed by a foreground subagent or inline agent + * run (non-empty `ancestorRunIds`) rather than the root turn, so the card can + * be labelled as a nested pass. Absent on root passes and on + * persisted/replayed blocks written by an older CLI (treated as root), which + * also means an older CLI replaying a block written here drops the label and + * presents a nested pass as a root one. */ - status?: 'pending' | 'complete' | 'interrupted' + subagent?: boolean + /** + * Which brake produced this block. Absent for the runtime-owned passes + * (`context_compaction`, including its mechanical emergency trim), which is + * also what every persisted/replayed block written by an older CLI holds. + * 'request' marks the SDK's request-time emergency trim + * (`context_request_trim`), a strictly later and more severe brake that must + * not be presented as a runtime pass. An older CLI drops the field on replay + * and therefore presents such a trim as an ordinary runtime pass. + */ + trimSource?: 'runtime' | 'request' /** * Set to {@link CLI_LIVE_SESSION_ID} while `status: 'pending'` is live in the * producing process. Absent on a completed pass, on an 'interrupted' one (the @@ -264,13 +294,28 @@ export type CompactionContentBlock = { * status-bar component, so a later additive field cannot go silently missing * from one consumer. * - * Turn-scoped and root-level: every agent loop (root, foreground subagents, - * inline agents) reports its own compaction events, so the producer counts a - * nested run's completed pass but only ever adopts the ROOT run's own - * `compactionCount` as the total, and only the root run's live pass sets - * `pending`. + * Turn-scoped, and shared across nesting levels: every agent loop (root, + * foreground subagents, inline agents) reports its own compaction events, so + * the producer counts a nested run's completed pass but only ever adopts the + * ROOT run's own `compactionCount` as the turn total. + * + * Live state is NOT root-only. A live pass of ANY run — root or nested — is + * recorded in {@link CompactionNotice.pendingRunIds} and therefore sets + * `pending`, so a subagent's compaction keeps the shared status-bar chip live + * even though it renders no root-level card of its own. Only the ROOT run's + * live pass additionally gets a pending transcript card. */ export type CompactionNotice = { + /** + * Passes that COMPLETED in this turn (the root run's own reported + * `compactionCount` when it reports one, plus each nested run's passes). + * + * A settled notice never stays at 0: the shared chip selector renders nothing + * for a notice that is neither pending nor `count > 0`, so the producer + * clears such a notice to null instead of retaining unobservable state. A + * pass that ran and reclaimed nothing is reported by its terminal + * `status: 'declined'` transcript card, not by the notice. + */ count: number /** * Action of the most recently COMPLETED pass. A pass that has only started @@ -280,8 +325,32 @@ export type CompactionNotice = { action: CompactionContentBlock['action'] /** The pass did not fit the budget, or stopped reclaiming space. */ degraded: boolean - /** A compaction pass is running right now in the root agent run. */ + /** + * A compaction pass is running right now. Derived from {@link pendingRunIds} + * (true exactly when it is non-empty) so consumers that only read this flag + * need no change. The one exception is the tolerated legacy shape described + * on {@link pendingRunIds}: a `pending: true` with no `pendingRunIds` is + * carried forward verbatim by the producers instead of being recomputed away, + * so such a notice keeps its live flag until a settling event clears it. + */ pending?: boolean + /** + * Runs with a live (announced but unsettled) compaction pass. Tracked per run + * so nested or concurrent agent loops cannot cross-settle each other's live + * state: `started` adds the emitting `runId`, `settled` removes it, and a + * `settled` for a run that was never recorded is tolerated as a no-op. Root + * and nested runs alike are recorded here — a subagent pass renders no + * root-level card, but it does keep the shared root-level chip live until its + * own run settles. + * + * Absent on notices produced before this field existed. Such a notice can + * still carry `pending: true`, and that live flag is HONORED: the producers + * keep it on events that settle no announced pass (a request-time trim, a + * nested compaction result) and clear it on the events that do settle one (a + * `settled` status, a root compaction result), because an uncorrelated live + * pass has no run id to match against. + */ + pendingRunIds?: string[] } export type AskUserContentBlock = { diff --git a/cli/src/utils/__tests__/sdk-event-handlers.test.ts b/cli/src/utils/__tests__/sdk-event-handlers.test.ts index dc3e591985..13306df28a 100644 --- a/cli/src/utils/__tests__/sdk-event-handlers.test.ts +++ b/cli/src/utils/__tests__/sdk-event-handlers.test.ts @@ -1,4 +1,6 @@ import { describe, expect, test } from 'bun:test' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' import { createMessageUpdater } from '../message-updater' import { @@ -6,17 +8,29 @@ import { createStreamChunkHandler, } from '../sdk-event-handlers' -import type { ChatMessage, CompactionNotice } from '../../types/chat' +import type { + ChatMessage, + CompactionContentBlock, + CompactionNotice, +} from '../../types/chat' import { CLI_LIVE_SESSION_ID } from '../../types/chat' import type { EventHandlerState } from '../sdk-event-handlers' +import type { StatusBarContextUsage } from '../status-bar-chips' -import { printModeEventSchema } from '@codebuff/common/types/print-mode' +import { + printModeContextRequestTrimSchema, + printModeEventSchema, +} from '@codebuff/common/types/print-mode' import type { Logger } from '@codebuff/common/types/contracts/logger' import type { PrintModeContextCompaction, PrintModeEvent, PrintModeJobUpdate, } from '@codebuff/common/types/print-mode' +// Named through the PUBLISHED entry point rather than the internal common +// module: `dist/index.d.ts` is generated from `sdk/src/index.ts` alone, so this +// is the exact resolution path a consumer of the bundled types takes. +import type { RequestContextTrimInfo } from '@openbuff/sdk' const createTestContext = () => { let messages: ChatMessage[] = [ @@ -663,7 +677,7 @@ describe('sdk-event-handlers', () => { }) test('handles context_window event by calling setContextWindowUsage', () => { - const captured: { usage: { used: number; max: number } | null } = { + const captured: { usage: StatusBarContextUsage | null } = { usage: null, } const { ctx } = createTestContext() @@ -677,8 +691,41 @@ describe('sdk-event-handlers', () => { expect(captured.usage).toEqual({ used: 50000, max: 200000 }) }) + test('forwards the compaction trigger on context_window only when the event supplies it', () => { + // The canonical status-bar shape rather than a restated structural copy, so + // this pins the forwarded payload to the type the chip selector consumes. + const captured: Array = [] + const { ctx } = createTestContext() + ctx.streaming.setContextWindowUsage = (usage) => captured.push(usage) + const handleEvent = createEventHandler(ctx) + + handleEvent({ + type: 'context_window', + used: 150_000, + max: 200_000, + compactionTriggerTokens: 140_000, + compactionTargetTokens: 100_000, + }) + // A persisted/replayed event emitted before the fields existed. + handleEvent({ type: 'context_window', used: 10_000, max: 200_000 }) + + expect(captured[0]).toEqual({ + used: 150_000, + max: 200_000, + compactionTriggerTokens: 140_000, + }) + // The post-compaction target is not user-actionable at a glance, so it is + // deliberately not forwarded to the chip; it stays on the event for other + // consumers. + expect(captured[0]).not.toHaveProperty('compactionTargetTokens') + // Absent on the event: the key is omitted entirely rather than set to + // undefined, so older CLI state keeps working unchanged. + expect(captured[1]).toEqual({ used: 10_000, max: 200_000 }) + expect(Object.keys(captured[1] ?? {})).toEqual(['used', 'max']) + }) + test('keeps the last context usage after finish', () => { - const captured: Array<{ used: number; max: number } | null> = [] + const captured: Array = [] const { ctx } = createTestContext() ctx.streaming.setContextWindowUsage = (usage) => captured.push(usage) const handleEvent = createEventHandler(ctx) @@ -1819,6 +1866,7 @@ describe('sdk-event-handlers', () => { action: 'semantic_compaction', degraded: false, pending: true, + pendingRunIds: ['root-run'], }) }) @@ -1923,7 +1971,7 @@ describe('sdk-event-handlers', () => { }) }) - test('settled with no result drops the pending block and clears the notice', () => { + test('settled with no result rewrites the pending block as a declined pass and clears the notice', () => { const { ctx, getMessages } = createTestContext() const notices: Array = [] let notice: CompactionNotice | null = null @@ -1947,13 +1995,431 @@ describe('sdk-event-handlers', () => { ancestorRunIds: [], }) + // The pass RAN and reclaimed nothing, so the transcript keeps an honest + // terminal trace of it instead of deleting the card. + const compactionBlocks = (getMessages()[0].blocks ?? []).filter( + (block) => block.type === 'compaction', + ) + expect(compactionBlocks).toHaveLength(1) + expect(compactionBlocks[0]).toMatchObject({ + type: 'compaction', + status: 'declined', + runId: 'root-run', + beforeTokens: 152_000, + }) + // The live stamp is meaningless once the pass is terminal. + expect(compactionBlocks[0]).not.toHaveProperty('liveSessionId') + // The declined pass is reported by the transcript card above. The chip + // selector renders nothing for a settled notice at count 0, so the notice + // is cleared instead of being retained as unobservable state. + expect(notices.at(-1)).toBeNull() + }) + + test('consecutive declined passes on the same run collapse into a single card', () => { + const { ctx, getMessages } = createTestContext() + const handleEvent = createEventHandler(ctx) + + for (const contextTokens of [152_000, 153_000]) { + dispatchValidEvent(handleEvent, { + type: 'context_compaction_status', + state: 'started', + runId: 'root-run', + ancestorRunIds: [], + contextTokens, + }) + dispatchValidEvent(handleEvent, { + type: 'context_compaction_status', + state: 'settled', + runId: 'root-run', + ancestorRunIds: [], + }) + } + + // Over-trigger iterations that each decline must not accumulate a column of + // identical cards. + const compactionBlocks = (getMessages()[0].blocks ?? []).filter( + (block) => block.type === 'compaction', + ) + expect(compactionBlocks).toHaveLength(1) + expect(compactionBlocks[0]).toMatchObject({ + status: 'declined', + runId: 'root-run', + beforeTokens: 153_000, + }) + }) + + test('the widened persisted compaction block stays plain JSON and round-trips prior sessions', () => { + const { ctx, getMessages } = createTestContext() + const handleEvent = createEventHandler(ctx) + + // A block written by an OLDER CLI: no status, no subagent, no trimSource. + // Replaying it must not rewrite or enrich it, so prior sessions round-trip. + const legacyBlock: CompactionContentBlock = { + type: 'compaction', + action: 'semantic_compaction', + beforeTokens: 190_000, + afterTokens: 120_000, + beforeMessages: 20, + afterMessages: 12, + reductionPercent: 37, + retainedKnowledgeMemory: true, + recovery: 'Re-read exact files before editing.', + categoryDeltas: [], + } + ctx.message.updater.updateAiMessageBlocks((blocks) => [ + ...blocks, + legacyBlock, + ]) + + // Both new terminal/label fields are produced by live events: a declined + // root pass and a nested request-time trim. + dispatchValidEvent(handleEvent, { + type: 'context_compaction_status', + state: 'started', + runId: 'root-run', + ancestorRunIds: [], + contextTokens: 152_000, + }) + dispatchValidEvent(handleEvent, { + type: 'context_compaction_status', + state: 'settled', + runId: 'root-run', + ancestorRunIds: [], + }) + dispatchValidEvent(handleEvent, { + type: 'context_request_trim', + runId: 'child-run', + ancestorRunIds: ['root-run'], + messageBudgetTokens: 90_000, + beforeTokens: 100_000, + afterTokens: 80_000, + beforeMessages: 12, + afterMessages: 9, + }) + + const blocks = (getMessages()[0].blocks ?? []).filter( + (block) => block.type === 'compaction', + ) as CompactionContentBlock[] + expect(blocks).toHaveLength(3) + + // The legacy block is byte-identical after replay: no status was invented + // for it, so an older session keeps rendering as a completed pass. + expect(blocks[0]).toEqual(legacyBlock) + expect(blocks[0]).not.toHaveProperty('status') + expect(blocks[0]).not.toHaveProperty('subagent') + expect(blocks[0]).not.toHaveProperty('trimSource') + + // The widened fields are the documented terminal value plus the two optional + // labels, and nothing else was added to the persisted shape. + expect(blocks[1]).toMatchObject({ status: 'declined', runId: 'root-run' }) + expect(blocks[2]).toMatchObject({ + status: 'complete', + trimSource: 'request', + subagent: true, + }) + + // Persistence is JSON: every block survives a chat-messages.json round trip + // unchanged, so no field is a function, class instance, or undefined hole. + expect(JSON.parse(JSON.stringify(blocks))).toEqual(blocks) + }) + + test('context_request_trim renders a request-time card and degrades the notice', () => { + const { ctx, getMessages } = createTestContext() + const notices: Array = [] + let notice: CompactionNotice | null = null + ctx.streaming.setCompactionNotice = (update) => { + notice = update(notice) + notices.push(notice) + } + const handleEvent = createEventHandler(ctx) + + dispatchValidEvent(handleEvent, { + type: 'context_compaction_status', + state: 'started', + runId: 'root-run', + ancestorRunIds: [], + contextTokens: 152_000, + }) + dispatchValidEvent(handleEvent, { + type: 'context_request_trim', + runId: 'root-run', + ancestorRunIds: [], + resolvedContextWindowTokens: 200_000, + messageBudgetTokens: 150_000, + beforeTokens: 180_000, + afterTokens: 140_000, + beforeMessages: 30, + afterMessages: 22, + model: 'anthropic/claude', + }) + + const compactionBlocks = (getMessages()[0].blocks ?? []).filter( + (block) => block.type === 'compaction', + ) + // The request-time trim is an ADDITIONAL pass, so it appends rather than + // consuming the root run's still-live card. + expect(compactionBlocks).toHaveLength(2) + expect(compactionBlocks[0]).toMatchObject({ + status: 'pending', + runId: 'root-run', + }) + expect(compactionBlocks[1]).toMatchObject({ + type: 'compaction', + status: 'complete', + action: 'mechanical_trim', + trimSource: 'request', + runId: 'root-run', + beforeTokens: 180_000, + afterTokens: 140_000, + beforeMessages: 30, + afterMessages: 22, + // (180000 - 140000) / 180000 = 22.2% -> 22 + reductionPercent: 22, + retainedKnowledgeMemory: false, + triggerBudgetTokens: 150_000, + targetBudgetTokens: 150_000, + resolvedContextWindowTokens: 200_000, + categoryDeltas: [], + }) + expect(compactionBlocks[1]).not.toHaveProperty('subagent') + // Reaching the request-time brake means the runtime brakes failed. + expect(notices.at(-1)).toEqual({ + count: 1, + action: 'mechanical_trim', + degraded: true, + pending: true, + pendingRunIds: ['root-run'], + }) + }) + + test('a nested context_request_trim is marked as a subagent pass', () => { + const { ctx, getMessages } = createTestContext() + const handleEvent = createEventHandler(ctx) + + dispatchValidEvent(handleEvent, { + type: 'context_request_trim', + runId: 'child-run', + ancestorRunIds: ['root-run'], + agentId: 'child-agent', + messageBudgetTokens: 90_000, + beforeTokens: 100_000, + afterTokens: 80_000, + beforeMessages: 12, + afterMessages: 9, + }) + expect( - (getMessages()[0].blocks ?? []).filter( + (getMessages()[0].blocks ?? []).find( (block) => block.type === 'compaction', ), - ).toHaveLength(0) - // Nothing ever completed, so no '⇲ compacted ×0' chip is left behind. - expect(notices.at(-1)).toBeNull() + ).toMatchObject({ + status: 'complete', + trimSource: 'request', + runId: 'child-run', + subagent: true, + }) + }) + + test('printModeContextRequestTrimSchema parses a minimal payload and requires messageBudgetTokens', () => { + const minimal = { + type: 'context_request_trim' as const, + messageBudgetTokens: 150_000, + beforeTokens: 180_000, + afterTokens: 140_000, + beforeMessages: 30, + afterMessages: 22, + } + expect(printModeContextRequestTrimSchema.parse(minimal)).toEqual(minimal) + // The correlation fields are optional, so the minimal payload above also + // parses through the discriminated union. + expect(printModeEventSchema.parse(minimal)).toMatchObject({ + type: 'context_request_trim', + }) + + const { messageBudgetTokens: _omitted, ...withoutBudget } = minimal + expect( + printModeContextRequestTrimSchema.safeParse(withoutBudget).success, + ).toBe(false) + }) + + test('RequestContextTrimInfo is nameable from the published SDK type surface', () => { + // `RequestContextTrimInfo` is the payload type of the `promptAiSdk*` + // `onRequestContextTrimmed` callback. The type-only import above names it + // through `@openbuff/sdk`, so this file fails to compile if the published + // entry point stops exporting it. + const info: RequestContextTrimInfo = { + contextWindowTokens: 200_000, + messageBudgetTokens: 150_000, + beforeTokens: 180_000, + afterTokens: 140_000, + beforeMessages: 30, + afterMessages: 22, + model: 'anthropic/claude', + } + + // Runtime evidence for the BUNDLED surface, which a type-only import cannot + // give on its own: `sdk/scripts/build.ts` generates `dist/index.d.ts` from + // `sdk/src/index.ts` with `exportReferencedTypes: false`, so a type only + // survives bundling if the entry point publishes the module that DECLARES + // it. Assert both halves of that chain in the live sources: the entry point + // re-exports `print-mode` wholesale, and `print-mode` declares the type + // itself instead of forwarding it out of the unpublished contracts module. + const repoRoot = join(import.meta.dir, '..', '..', '..', '..') + const sdkEntryPoint = readFileSync( + join(repoRoot, 'sdk', 'src', 'index.ts'), + 'utf8', + ) + const printModeSource = readFileSync( + join(repoRoot, 'common', 'src', 'types', 'print-mode.ts'), + 'utf8', + ) + expect(sdkEntryPoint).toContain( + "export type * from '@codebuff/common/types/print-mode'", + ) + expect(printModeSource).toContain('export type RequestContextTrimInfo = {') + expect(printModeSource).not.toContain( + "export type { RequestContextTrimInfo } from './contracts/llm'", + ) + + // The callback payload maps field-for-field onto the published event, so a + // consumer that names the type can forward it as `context_request_trim`. + const { contextWindowTokens, ...trimMeasurements } = info + expect( + printModeContextRequestTrimSchema.parse({ + type: 'context_request_trim', + resolvedContextWindowTokens: contextWindowTokens, + ...trimMeasurements, + }), + ).toEqual({ + type: 'context_request_trim', + resolvedContextWindowTokens: 200_000, + messageBudgetTokens: 150_000, + beforeTokens: 180_000, + afterTokens: 140_000, + beforeMessages: 30, + afterMessages: 22, + model: 'anthropic/claude', + }) + }) + + test('a legacy notice with pending but no pendingRunIds keeps its live flag until a settling event', () => { + const { ctx } = createTestContext() + const notices: Array = [] + // The tolerated pre-per-run shape documented on + // `CompactionNotice.pendingRunIds`: a live pass with no recorded run. + let notice: CompactionNotice | null = { + count: 1, + action: 'semantic_compaction', + degraded: false, + pending: true, + } + ctx.streaming.setCompactionNotice = (update) => { + notice = update(notice) + notices.push(notice) + } + const handleEvent = createEventHandler(ctx) + const categories = { + toolResults: { tokens: 10, percent: 10, messages: 1 }, + todos: { tokens: 10, percent: 10, messages: 1 }, + fileReads: { tokens: 20, percent: 20, messages: 2 }, + subagents: { tokens: 20, percent: 20, messages: 2 }, + userAssistantMessages: { tokens: 40, percent: 40, messages: 4 }, + } + + // A request-time trim settles no announced pass, so the uncorrelated live + // flag survives it instead of being silently recomputed away. + dispatchValidEvent(handleEvent, { + type: 'context_request_trim', + runId: 'root-run', + ancestorRunIds: [], + messageBudgetTokens: 150_000, + beforeTokens: 180_000, + afterTokens: 140_000, + beforeMessages: 30, + afterMessages: 22, + }) + expect(notices.at(-1)).toEqual({ + count: 2, + action: 'mechanical_trim', + degraded: true, + pending: true, + }) + + // A NESTED compaction result changes no live state either. + dispatchValidEvent(handleEvent, { + type: 'context_compaction', + action: 'semantic_compaction', + runId: 'child-run', + ancestorRunIds: ['root-run'], + before: { tokens: 100_000, messages: 20, categories }, + after: { tokens: 60_000, messages: 8, categories }, + removedCategories: [], + retainedKnowledgeMemory: true, + recovery: 'Resume from .', + }) + expect(notices.at(-1)).toEqual({ + count: 3, + action: 'semantic_compaction', + degraded: false, + pending: true, + }) + + // A `settled` is the only signal that can clear a live flag whose run is + // unknown, so it does — the chip must not stay live for the rest of the turn. + dispatchValidEvent(handleEvent, { + type: 'context_compaction_status', + state: 'settled', + runId: 'root-run', + ancestorRunIds: [], + }) + expect(notices.at(-1)).toEqual({ + count: 3, + action: 'semantic_compaction', + degraded: false, + }) + }) + + test('a legacy pending flag is consumed by the root compaction result it stands for', () => { + const { ctx } = createTestContext() + const notices: Array = [] + let notice: CompactionNotice | null = { + count: 0, + action: 'semantic_compaction', + degraded: false, + pending: true, + } + ctx.streaming.setCompactionNotice = (update) => { + notice = update(notice) + notices.push(notice) + } + const handleEvent = createEventHandler(ctx) + const categories = { + toolResults: { tokens: 10, percent: 10, messages: 1 }, + todos: { tokens: 10, percent: 10, messages: 1 }, + fileReads: { tokens: 20, percent: 20, messages: 2 }, + subagents: { tokens: 20, percent: 20, messages: 2 }, + userAssistantMessages: { tokens: 40, percent: 40, messages: 4 }, + } + + dispatchValidEvent(handleEvent, { + type: 'context_compaction', + action: 'semantic_compaction', + runId: 'root-run', + ancestorRunIds: [], + before: { tokens: 152_000, messages: 20, categories }, + after: { tokens: 60_000, messages: 8, categories }, + removedCategories: [], + retainedKnowledgeMemory: true, + recovery: 'Resume from .', + }) + + // The root run reported its result, so the pass the uncorrelated flag stood + // for is over and the notice settles. + expect(notices.at(-1)).toEqual({ + count: 1, + action: 'semantic_compaction', + degraded: false, + }) }) test('a started pass keeps the completed action so an aborted turn labels the chip by what finished', () => { @@ -2006,10 +2472,11 @@ describe('sdk-event-handlers', () => { action: 'mechanical_trim', degraded: false, pending: true, + pendingRunIds: ['root-run'], }) }) - test('a subagent compaction status neither renders nor cross-settles the root run state', () => { + test('a subagent compaction status renders no card but still reports a live pass', () => { const { ctx, getMessages } = createTestContext() const notices: Array = [] let notice: CompactionNotice | null = null @@ -2027,7 +2494,8 @@ describe('sdk-event-handlers', () => { contextTokens: 152_000, }) // A foreground subagent / inline agent loop compacts its own context. Its - // lineage is non-empty, so it must not add a second root-level card. + // lineage is non-empty, so it must not add a second root-level card, but + // the chip still reports that a pass is live. dispatchValidEvent(handleEvent, { type: 'context_compaction_status', state: 'started', @@ -2036,6 +2504,18 @@ describe('sdk-event-handlers', () => { agentId: 'child-agent', contextTokens: 90_000, }) + expect( + (getMessages()[0].blocks ?? []).filter( + (block) => block.type === 'compaction', + ), + ).toHaveLength(1) + expect(notices.at(-1)).toEqual({ + count: 0, + action: 'semantic_compaction', + degraded: false, + pending: true, + pendingRunIds: ['root-run', 'child-run'], + }) // Nor may its settle clear the root run's still-live card. dispatchValidEvent(handleEvent, { type: 'context_compaction_status', @@ -2055,20 +2535,24 @@ describe('sdk-event-handlers', () => { action: 'semantic_compaction', degraded: false, pending: true, + pendingRunIds: ['root-run'], }) - // Only the root run's own settle ends the live state. + // Only the root run's own settle ends the live state, and it terminates the + // card as a declined pass rather than deleting it. dispatchValidEvent(handleEvent, { type: 'context_compaction_status', state: 'settled', runId: 'root-run', ancestorRunIds: [], }) - expect( - (getMessages()[0].blocks ?? []).filter( - (block) => block.type === 'compaction', - ), - ).toHaveLength(0) + const settled = (getMessages()[0].blocks ?? []).filter( + (block) => block.type === 'compaction', + ) + expect(settled).toHaveLength(1) + expect(settled[0]).toMatchObject({ status: 'declined', runId: 'root-run' }) + // No pass completed in the turn, so the settled notice is cleared: the chip + // renders nothing for a notice that is neither pending nor count > 0. expect(notices.at(-1)).toBeNull() }) @@ -2118,12 +2602,18 @@ describe('sdk-event-handlers', () => { ) expect(blocks).toHaveLength(2) expect(blocks[0]).toMatchObject({ status: 'pending', runId: 'root-run' }) - expect(blocks[1]).toMatchObject({ status: 'complete', runId: 'child-run' }) + expect(blocks[1]).toMatchObject({ + status: 'complete', + runId: 'child-run', + // A nested run's result is labelled as a subagent pass. + subagent: true, + }) expect(notices.at(-1)).toEqual({ count: 1, action: 'semantic_compaction', degraded: false, pending: true, + pendingRunIds: ['root-run'], }) // The root run's own result adopts its reported count and clears the live @@ -2214,6 +2704,7 @@ describe('sdk-event-handlers', () => { action: 'semantic_compaction', degraded: false, pending: true, + pendingRunIds: ['root-run'], }) // Its result is recorded under its own emitting run, even though the @@ -2240,6 +2731,8 @@ describe('sdk-event-handlers', () => { expect(blocks[1]).toMatchObject({ status: 'complete', runId: 'grandchild-run', + // Rendered as its own nested pass rather than being card-suppressed. + subagent: true, }) // The nested run's own count never becomes the root turn's total, and the // root run's live pass is still live. @@ -2248,6 +2741,7 @@ describe('sdk-event-handlers', () => { action: 'semantic_compaction', degraded: false, pending: true, + pendingRunIds: ['root-run'], }) }) diff --git a/cli/src/utils/__tests__/status-bar-chips.test.ts b/cli/src/utils/__tests__/status-bar-chips.test.ts index dd8dc852ce..4d140b2d5e 100644 --- a/cli/src/utils/__tests__/status-bar-chips.test.ts +++ b/cli/src/utils/__tests__/status-bar-chips.test.ts @@ -2,6 +2,8 @@ import { describe, expect, test } from 'bun:test' import stringWidth from 'string-width' import { + buildContextLabel, + contextLabelFallbacks, formatStatusTokenCount, SCROLL_BUTTON_COMPACT_RESERVATION, SCROLL_BUTTON_RESERVATION, @@ -12,6 +14,7 @@ import { STOP_BUTTON_WIDTH, type SelectStatusBarChipsInput, type StatusBarChip, + type StatusBarContextUsage, } from '../status-bar-chips' const full = { @@ -726,6 +729,162 @@ describe('selectStatusBarChips', () => { ).toEqual(['context', 'timer']) }) + test('marks the compaction trigger cell in the bar without widening the label', () => { + const contextFor = ( + contextWindowUsage: SelectStatusBarChipsInput['contextWindowUsage'], + ) => + byId( + selectStatusBarChips({ + ...full, + widthSize: 'lg', + terminalWidth: 400, + contextWindowUsage, + }).chips, + ).context + + // 48% usage in a 200k window with the trigger at 140k (70%): the marker + // lands on cell 7 of the 10-cell bar and replaces the glyph that cell + // would otherwise have rendered. + const withTrigger = contextFor({ + used: 96_400, + max: 200_000, + compactionTriggerTokens: 140_000, + }) + const withoutTrigger = contextFor({ used: 96_400, max: 200_000 }) + + expect(withTrigger?.label).toBe('█████░░│░░ 48%') + expect(withoutTrigger?.label).toBe('█████░░░░░ 48%') + // The marker replaces a cell rather than adding one, so the chip cannot + // get wider for the same size and usage. + expect(stringWidth(withTrigger?.label ?? '')).toBe( + stringWidth(withoutTrigger?.label ?? ''), + ) + + // Marker on the very last cell with usage already past it: the crossing is + // the useful signal, so the marker is still rendered. + const crossed = contextFor({ + used: 200_000, + max: 200_000, + compactionTriggerTokens: 190_000, + }) + expect(crossed?.label).toBe('200k/200k ⇲190k █████████│ 100%') + expect(crossed?.tone).toBe('error') + }) + + test('suppresses a meaningless trigger and keeps the fixed-70 warning tone', () => { + // A trigger at or above the window is misleading (the unknown-window + // fallback budget can exceed a small configured window), and a non-finite, + // zero, or negative value carries no information at all. + for (const compactionTriggerTokens of [ + 200_000, + 240_000, + Number.NaN, + Number.POSITIVE_INFINITY, + 0, + -5, + ]) { + const warning = byId( + selectStatusBarChips({ + ...full, + widthSize: 'lg', + terminalWidth: 400, + contextWindowUsage: { + used: 150_000, + max: 200_000, + compactionTriggerTokens, + }, + }).chips, + ).context + + expect(warning?.label).toBe('150k/200k ████████░░ 75%') + expect(warning?.label).not.toContain('│') + expect(warning?.label).not.toContain('⇲') + // No trigger is known, so the tone falls back to the fixed 70. + expect(warning?.tone).toBe('warning') + + const below = byId( + selectStatusBarChips({ + ...full, + widthSize: 'lg', + terminalWidth: 400, + contextWindowUsage: { + used: 138_000, // 69% + max: 200_000, + compactionTriggerTokens, + }, + }).chips, + ).context + expect(below?.tone).toBe('secondary') + } + }) + + test('warns from the trigger percent when it is below the fixed 70', () => { + // A 32k window with the model-aware trigger at 16.8k puts the warning at + // 53% — the point at which the next step may compact. + const toneAt = (used: number) => + byId( + selectStatusBarChips({ + ...full, + widthSize: 'lg', + terminalWidth: 400, + contextWindowUsage: { + used, + max: 32_000, + compactionTriggerTokens: 16_800, + }, + }).chips, + ).context + + expect(toneAt(15_000)?.tone).toBe('secondary') // 47% + const atTrigger = toneAt(16_800) // 53% + expect(atTrigger?.tone).toBe('warning') + expect(atTrigger?.label).toBe('█████│░░░░ 53%') + // The 90 error threshold stays fixed regardless of the trigger. + expect(toneAt(28_800)?.tone).toBe('error') // 90% + }) + + test('lg shows the trigger suffix from 70% and drops it before the bar', () => { + const usage = { + used: 150_000, // 75% + max: 200_000, + compactionTriggerTokens: 140_000, + } + const contextAt = (terminalWidth: number) => + byId( + selectStatusBarChips({ + ...full, + widthSize: 'lg', + terminalWidth, + contextWindowUsage: usage, + }).chips, + ).context + + const widest = contextAt(400)?.label ?? '' + expect(widest).toBe('150k/200k ⇲140k ███████│░░ 75%') + + const timerLabel = '12s' + const budgetFor = (contextLabel: string) => + statusBarClusterWidth([ + { id: 'context', label: contextLabel, tone: 'warning' }, + { id: 'timer', label: timerLabel, tone: 'secondary' }, + ]) + + // One step down the ladder: the trigger suffix goes before the counts. + const withoutSuffix = '150k/200k ███████│░░ 75%' + expect(contextAt(widthForBudget(budgetFor(widest) - 1, full.showStop)) + ?.label).toBe(withoutSuffix) + + // Then the counts, and only then the bar. + const barOnly = '███████│░░ 75%' + expect( + contextAt(widthForBudget(budgetFor(withoutSuffix) - 1, full.showStop)) + ?.label, + ).toBe(barOnly) + expect( + contextAt(widthForBudget(budgetFor(barOnly) - 1, full.showStop))?.label, + ).toBe('75%') + }) + test('xs keeps the timer when the stop hint is hidden', () => { const { chips } = selectStatusBarChips({ ...full, @@ -1142,6 +1301,55 @@ describe('selectStatusBarChips', () => { }) }) +describe('contextLabelFallbacks', () => { + test('its first entry is exactly the widest form buildContextLabel renders', () => { + // Load-bearing invariant: the overflow loop steps down from + // buildContextLabel's output, so a wider form added to one and not the + // other silently breaks shortening. Checked with and without a known + // compaction trigger, at every size and on both sides of the + // token-count thresholds. + // + // The sizes that can render token counts only spend the columns on them + // from their threshold up ('lg' 70%, 'md' 80%), so below it + // buildContextLabel deliberately renders the narrower bar+percent form + // while the fallback ladder always starts from the counts form. Equality + // therefore holds only at or above the threshold; below it the invariant is + // that the ladder can still reach the form actually rendered (the narrower + // output appears in the list) and that its widest entry is never narrower + // than what is rendered. + const usages: StatusBarContextUsage[] = [ + { used: 150_000, max: 200_000 }, + { used: 150_000, max: 200_000, compactionTriggerTokens: 140_000 }, + // A meaningless trigger renders no suffix, so the two must still agree. + { used: 150_000, max: 200_000, compactionTriggerTokens: 200_000 }, + ] + + for (const widthSize of ['xs', 'sm', 'md', 'lg'] as const) { + // Mirrors contextCountsThreshold in the module under test. + const threshold = widthSize === 'lg' ? 70 : widthSize === 'md' ? 80 : null + + for (const usage of usages) { + for (const pct of [0, 48, 69, 70, 75, 85, 100]) { + const fallbacks = contextLabelFallbacks(widthSize, usage, pct) + const rendered = buildContextLabel(widthSize, usage, pct) + + if (threshold == null || pct >= threshold) { + expect(fallbacks[0]).toBe(rendered) + continue + } + + // Narrower form by design: the ladder must still contain it, and its + // widest entry must remain at least as wide as what is rendered. + expect(fallbacks).toContain(rendered) + expect(stringWidth(fallbacks[0])).toBeGreaterThanOrEqual( + stringWidth(rendered), + ) + } + } + } + }) +}) + describe('statusBarChipBudget', () => { test('reserves the stop hint but still leaves room for one chip', () => { expect(statusBarChipBudget(60, false)).toBe(24) diff --git a/cli/src/utils/sdk-event-handlers.ts b/cli/src/utils/sdk-event-handlers.ts index bbd87c067c..6ccde51f70 100644 --- a/cli/src/utils/sdk-event-handlers.ts +++ b/cli/src/utils/sdk-event-handlers.ts @@ -47,6 +47,7 @@ import { CLI_LIVE_SESSION_ID } from '../types/chat' import type { AgentMode } from './constants' import type { MessageUpdater } from './message-updater' +import type { StatusBarContextUsage } from './status-bar-chips' import type { StreamController } from '../hooks/stream-state' import type { StreamStatus } from '../hooks/use-message-queue' import type { @@ -63,6 +64,7 @@ import type { PrintModeContextWindow, PrintModeContextCompaction, PrintModeContextCompactionStatus, + PrintModeContextRequestTrim, PrintModeEvent as SDKEvent, PrintModeJobUpdate, PrintModeFinish, @@ -82,14 +84,31 @@ export type SetStreamingAgentsFn = ( export type SetStreamStatusFn = (status: StreamStatus) => void +/** + * Forwards the `context_window` event's usage to the status bar. The payload is + * the canonical {@link StatusBarContextUsage} the chip selector consumes, so a + * later additive field cannot go silently missing from one hop. + * + * `compactionTriggerTokens` — the runtime's model-aware semantic-compaction + * trigger budget — is forwarded only when the event supplies it: persisted or + * replayed events emitted before the field existed omit it, and the chip then + * renders exactly as it did before. It is forwarded verbatim and therefore NOT + * bounded by `max` — see `printModeContextWindowSchema`; the status-bar chip + * suppresses a trigger that reaches `max`. + */ export type SetContextWindowUsageFn = ( - usage: { used: number; max: number } | null, + usage: StatusBarContextUsage | null, ) => void // Re-exported from its canonical declaration in ../types/chat so existing // importers of this module keep working while there is only one shape. export type { CompactionNotice } +// Same reason, for the canonical context-usage shape declared in +// ./status-bar-chips: consumers of this module's setter get the shape from the +// same place rather than restating it. +export type { StatusBarContextUsage } + /** * Accumulating setter for the status-bar compaction chip: receives an updater so * repeated compactions within one turn can keep counting from the previous @@ -1258,10 +1277,17 @@ const handleContextWindow = ( event: PrintModeContextWindow, ) => { // Context-window events carry the current token usage and max so the - // CLI status bar can display how full the context window is. + // CLI status bar can display how full the context window is, plus the + // model-aware compaction budget so the chip can also show where compaction + // will fire. `compactionTargetTokens` is deliberately NOT forwarded: the + // post-compaction target is not user-actionable at a glance, and it stays + // available on the event for other consumers. state.streaming.setContextWindowUsage({ used: event.used, max: event.max, + ...(event.compactionTriggerTokens !== undefined && { + compactionTriggerTokens: event.compactionTriggerTokens, + }), }) } @@ -1321,47 +1347,130 @@ const findLastPendingCompactionIndex = ( } /** - * Drops the still-running compaction blocks produced by `runId`, returning the - * original array reference when there was nothing to drop so React skips a - * re-render. Used by the `settled` path only: a pass that reported no result - * never compacted anything, so its card has nothing to report and should - * disappear entirely. An abnormal turn end instead rewrites the block via - * {@link markPendingCompactionInterrupted}, which keeps an honest terminal - * record. Only that run's blocks are dropped, so one agent loop's `settled` - * cannot clear another loop's live card. `runId` is required on + * Rewrites the still-running compaction blocks produced by `runId` to the + * terminal `status: 'declined'` state, returning the ORIGINAL array reference + * when there was nothing to rewrite so React skips a re-render. Used by the + * `settled` path only: such a pass RAN and reclaimed nothing, so the transcript + * keeps an honest trace of it instead of deleting the card. That is distinct + * from {@link markPendingCompactionInterrupted}, which reports a pass whose run + * ended (abort/teardown) before it could report anything at all. + * + * Consecutive over-trigger iterations can each announce and decline a pass, so + * a rewritten card that is immediately preceded by an identical declined card + * of the same run collapses into it rather than accumulating a column of + * duplicates. Only the immediately preceding block is considered, so an + * unrelated block or another run's card between them keeps both. + * + * Only that run's blocks are rewritten, so one agent loop's `settled` cannot + * touch another loop's live card. `runId` is required on * `printModeContextCompactionStatusSchema`, so unlike * {@link findLastPendingCompactionIndex} — reached from the `context_compaction` * result path, where the correlation fields are optional — there is no * uncorrelated case to pair here. Root-level only: compaction blocks are never * nested under an agent block. */ -const dropPendingCompactionBlocks = ( +const declinePendingCompactionBlocks = ( blocks: ContentBlock[], runId: string, ): ContentBlock[] => { - const next = blocks.filter( - (block) => + let changed = false + const next: ContentBlock[] = [] + for (const block of blocks) { + if ( !( block.type === 'compaction' && block.status === 'pending' && block.runId === runId - ), - ) - return next.length === blocks.length ? blocks : next + ) + ) { + next.push(block) + continue + } + changed = true + // The live stamp is meaningless once the pass is terminal. + const { liveSessionId: _liveSessionId, ...rest } = block + const declined: CompactionContentBlock = { ...rest, status: 'declined' } + const previous = next[next.length - 1] + if ( + previous && + previous.type === 'compaction' && + previous.status === 'declined' && + previous.runId === runId + ) { + next[next.length - 1] = declined + continue + } + next.push(declined) + } + return changed ? next : blocks } +/** + * Live-pass bookkeeping for {@link CompactionNotice}, keyed by the emitting + * `runId` so nested or concurrent agent loops cannot cross-settle each other. + */ +const addPendingRunId = ( + pendingRunIds: string[] | undefined, + runId: string, +): string[] => + pendingRunIds?.includes(runId) + ? pendingRunIds + : [...(pendingRunIds ?? []), runId] + +/** Tolerates a `settled` for a run that was never recorded (a post-reset event). */ +const removePendingRunId = ( + pendingRunIds: string[] | undefined, + runId: string, +): string[] => (pendingRunIds ?? []).filter((id) => id !== runId) + +/** + * True for the compatibility case {@link CompactionNotice.pendingRunIds} + * documents: a notice produced before per-run tracking existed carries + * `pending: true` with no `pendingRunIds` at all. Its live pass belongs to an + * unknown run, so it cannot be matched per run — but dropping it would silently + * lose a live flag the type promises to tolerate, so it is carried forward until + * a settling event for it arrives. + */ +const hasUncorrelatedPending = ( + previous: CompactionNotice | null | undefined, +): boolean => previous?.pending === true && previous.pendingRunIds === undefined + +/** + * `pending` is kept as the DERIVED value of `pendingRunIds` so the status-bar + * chip needs no logic change. Both are omitted once nothing is live, so a + * settled notice has the exact shape it had before per-run tracking existed and + * cannot outlive the turn as a stale `pending: true`. + * + * `legacyPending` covers the tolerated uncorrelated case (see + * {@link hasUncorrelatedPending}): a live flag with no run set stays a bare + * `pending: true`, since inventing a run id for it would let an unrelated + * `settled` clear it. + */ +const pendingNoticeFields = ( + pendingRunIds: string[], + legacyPending = false, +): { pending?: true; pendingRunIds?: string[] } => + pendingRunIds.length > 0 + ? { pending: true, pendingRunIds } + : legacyPending + ? { pending: true } + : {} + /** * Live compaction state. The pruner agent runs inline and is hidden from the - * CLI, so `started` renders a pending compaction card (and a live status-bar - * chip) that the terminal `context_compaction` result settles in place. - * `settled` is the runtime's guarantee that a pass which decided not to compact - * cannot leave that pending state on screen. + * CLI, so `started` marks the status-bar chip live and — for the ROOT run only + * — appends a pending compaction card that the terminal `context_compaction` + * result settles in place. A non-root run gets no card: its pass is short-lived + * and a live nested card would only flicker, but its live state is still + * reported on the chip. `settled` is the runtime's guarantee that a pass which + * decided not to compact cannot leave that pending state on screen; the card is + * rewritten as a declined pass rather than deleted, so the transcript keeps an + * honest trace of a pass that reclaimed nothing. * - * Every agent loop emits this event, so it is filtered and paired by run: - * only the root run (empty `ancestorRunIds`) drives this root-level card and - * chip, and a `settled` only clears the pending card its own `runId` started. - * Without both rules a subagent's compaction would render as a root-level - * 'Compacting context…' card and cross-settle the root run's live pass. + * Every agent loop emits this event, so live state is paired by run: + * `pendingRunIds` records each run with an unsettled `started`, and a `settled` + * only clears its own `runId`. Without that a subagent's compaction would + * cross-settle the root run's live pass. * * Neither cleanup path is reachable when the user aborts mid-compaction: the * SDK drops every post-abort event, and the blocks of the turn are persisted to @@ -1374,72 +1483,164 @@ const handleContextCompactionStatus = ( state: EventHandlerState, event: PrintModeContextCompactionStatus, ) => { - if (!isRootCompactionEvent(event)) return + const rootScoped = isRootCompactionEvent(event) if (event.state === 'started') { - const pendingBlock: CompactionContentBlock = { - type: 'compaction', - status: 'pending', - // Stamped so a persisted/replayed copy of this transient block (the user - // aborted the turn before `settled` or `context_compaction` arrived) - // renders as an interrupted pass instead of a permanently live card. - liveSessionId: CLI_LIVE_SESSION_ID, - // Correlated so only this run's own `settled`/result can consume the card. - runId: event.runId, - action: 'semantic_compaction', - beforeTokens: event.contextTokens ?? 0, - afterTokens: 0, - beforeMessages: 0, - afterMessages: 0, - reductionPercent: 0, - retainedKnowledgeMemory: false, - recovery: '', - categoryDeltas: [], - ...(event.resolvedContextWindowTokens !== undefined && { - resolvedContextWindowTokens: event.resolvedContextWindowTokens, - }), - ...(event.triggerBudgetTokens !== undefined && { - triggerBudgetTokens: event.triggerBudgetTokens, - }), - ...(event.targetBudgetTokens !== undefined && { - targetBudgetTokens: event.targetBudgetTokens, - }), + if (rootScoped) { + const pendingBlock: CompactionContentBlock = { + type: 'compaction', + status: 'pending', + // Stamped so a persisted/replayed copy of this transient block (the user + // aborted the turn before `settled` or `context_compaction` arrived) + // renders as an interrupted pass instead of a permanently live card. + liveSessionId: CLI_LIVE_SESSION_ID, + // Correlated so only this run's own `settled`/result can consume the card. + runId: event.runId, + action: 'semantic_compaction', + beforeTokens: event.contextTokens ?? 0, + afterTokens: 0, + beforeMessages: 0, + afterMessages: 0, + reductionPercent: 0, + retainedKnowledgeMemory: false, + recovery: '', + categoryDeltas: [], + ...(event.resolvedContextWindowTokens !== undefined && { + resolvedContextWindowTokens: event.resolvedContextWindowTokens, + }), + ...(event.triggerBudgetTokens !== undefined && { + triggerBudgetTokens: event.triggerBudgetTokens, + }), + ...(event.targetBudgetTokens !== undefined && { + targetBudgetTokens: event.targetBudgetTokens, + }), + } + state.message.updater.updateAiMessageBlocks((blocks) => [ + ...blocks, + pendingBlock, + ]) } - state.message.updater.updateAiMessageBlocks((blocks) => [ - ...blocks, - pendingBlock, - ]) state.streaming.setCompactionNotice((previous) => ({ count: previous?.count ?? 0, // The live chip label does not read `action`, and a pass that has only // started has not decided its own action yet, so the accumulated action // of the last COMPLETED pass is carried forward. Overwriting it would - // mislabel the settled chip after an abort mid-compaction: a turn whose + // mislead the settled chip after an abort mid-compaction: a turn whose // only completed pass was a mechanical trim would read '⇲ compacted ×N'. action: previous?.action ?? 'semantic_compaction', degraded: previous?.degraded ?? false, - pending: true, + ...pendingNoticeFields( + addPendingRunId(previous?.pendingRunIds, event.runId), + ), })) return } state.message.updater.updateAiMessageBlocks((blocks) => - dropPendingCompactionBlocks(blocks, event.runId), + declinePendingCompactionBlocks(blocks, event.runId), ) state.streaming.setCompactionNotice((previous) => { if (!previous) return null - // A start fired but nothing ever completed: clearing the notice keeps a - // '⇲ compacted ×0' chip from ever being rendered. - if (previous.count === 0) return null - if (!previous.pending) return previous + const pendingRunIds = removePendingRunId( + previous.pendingRunIds, + event.runId, + ) + // A pass that was announced and settled without a result RAN and reclaimed + // nothing; its honest trace is the terminal `declined` card rewritten + // above, not the notice. `selectStatusBarChips` renders nothing for a + // notice that is neither pending nor `count > 0`, so once no pass is live + // and none has completed the notice is cleared rather than retained as + // state no consumer can observe (which also keeps a '⇲ compacted ×0' chip + // unreachable). An uncorrelated legacy `pending: true` is settled here + // rather than carried: a `settled` is the only event that can ever clear a + // live flag whose run is unknown, so keeping it would strand the chip. + if (previous.count === 0 && pendingRunIds.length === 0) return null return { count: previous.count, action: previous.action, degraded: previous.degraded, + ...pendingNoticeFields(pendingRunIds), } }) } +/** + * Request-time emergency trim (`context_request_trim`): the SDK dropped + * messages at dispatch time because the request still exceeded the + * provider-safe budget after every runtime brake ran. A DIFFERENT brake from + * the runtime's own `mechanical_trim` result, so the block is marked + * `trimSource: 'request'` and never consumes a pending card — it is an + * additional pass, not the settlement of an announced one — and it always + * degrades the notice, because reaching it means the earlier brakes failed. + */ +const handleContextRequestTrim = ( + state: EventHandlerState, + event: PrintModeContextRequestTrim, +) => { + // Same clamp as handleContextCompaction: a trim that somehow grew the request + // (or reported a zero baseline) renders 0% rather than an out-of-range value. + const reductionPercent = + event.beforeTokens > 0 + ? Math.min( + 100, + Math.max( + 0, + Math.round( + ((event.beforeTokens - event.afterTokens) / event.beforeTokens) * + 100, + ), + ), + ) + : 0 + + const trimBlock: CompactionContentBlock = { + type: 'compaction', + status: 'complete', + action: 'mechanical_trim', + trimSource: 'request', + ...(event.runId !== undefined && { runId: event.runId }), + ...(isRootCompactionEvent(event) ? {} : { subagent: true }), + beforeTokens: event.beforeTokens, + afterTokens: event.afterTokens, + beforeMessages: event.beforeMessages, + afterMessages: event.afterMessages, + reductionPercent, + retainedKnowledgeMemory: false, + recovery: + 'The runtime compaction brakes were exceeded: reduce pinned state (fewer keepDuringTruncation blocks, or /compact) or start a fresh turn.', + categoryDeltas: [], + reason: + 'Request-time emergency brake: messages still exceeded the provider-safe request budget at dispatch time.', + triggerBudgetTokens: event.messageBudgetTokens, + targetBudgetTokens: event.messageBudgetTokens, + ...(event.resolvedContextWindowTokens !== undefined && { + resolvedContextWindowTokens: event.resolvedContextWindowTokens, + }), + } + + // Always appended: this trim settles no announced pass, so a live root card + // stays live until its own result or `settled` arrives. + state.message.updater.updateAiMessageBlocks((blocks) => [ + ...blocks, + trimBlock, + ]) + + state.streaming.setCompactionNotice((previous) => ({ + count: (previous?.count ?? 0) + 1, + action: 'mechanical_trim', + // A request-time trim means the runtime brakes failed, so the turn is + // degraded regardless of how the earlier passes reported. + degraded: true, + // This trim settles no announced pass, so every live pass is carried + // forward — including a legacy notice's uncorrelated `pending: true`, + // which would otherwise silently lose its live flag here. + ...pendingNoticeFields( + previous?.pendingRunIds ?? [], + hasUncorrelatedPending(previous), + ), + })) +} + const handleContextCompaction = ( state: EventHandlerState, event: PrintModeContextCompaction, @@ -1487,6 +1688,9 @@ const handleContextCompaction = ( status: 'complete', action: event.action, ...(event.runId !== undefined && { runId: event.runId }), + // A nested run's pass is labelled as such: it reports another agent's + // context, not the root turn's. + ...(isRootCompactionEvent(event) ? {} : { subagent: true }), beforeTokens: event.before.tokens, afterTokens: event.after.tokens, beforeMessages: event.before.messages, @@ -1535,14 +1739,29 @@ const handleContextCompaction = ( // replace the turn total; a subagent/inline result contributes one pass // instead of overwriting it, and never settles the root run's live pass. const rootScoped = isRootCompactionEvent(event) - state.streaming.setCompactionNotice((previous) => ({ - count: rootScoped - ? (event.compactionCount ?? (previous?.count ?? 0) + 1) - : (previous?.count ?? 0) + 1, - action: event.action, - degraded, - ...(!rootScoped && previous?.pending === true ? { pending: true } : {}), - })) + state.streaming.setCompactionNotice((previous) => { + // A root result consumes this run's announced pass; a nested result leaves + // every live pass (including the root's) exactly as it was. A legacy + // uncorrelated root result carries no runId, so it clears the live set the + // way it did before per-run tracking existed. + const pendingRunIds = !rootScoped + ? (previous?.pendingRunIds ?? []) + : event.runId === undefined + ? [] + : removePendingRunId(previous?.pendingRunIds, event.runId) + // A nested result changes no live state, so an uncorrelated legacy + // `pending: true` is carried forward; a root result consumes the announced + // pass that flag stands for and therefore clears it. + const legacyPending = !rootScoped && hasUncorrelatedPending(previous) + return { + count: rootScoped + ? (event.compactionCount ?? (previous?.count ?? 0) + 1) + : (previous?.count ?? 0) + 1, + action: event.action, + degraded, + ...pendingNoticeFields(pendingRunIds, legacyPending), + } + }) } const handleFinish = (state: EventHandlerState, event: PrintModeFinish) => { @@ -1705,6 +1924,9 @@ export const createEventHandler = .with({ type: 'context_compaction_status' }, (e) => handleContextCompactionStatus(state, e), ) + .with({ type: 'context_request_trim' }, (e) => + handleContextRequestTrim(state, e), + ) .with({ type: 'job_update' }, (e) => handleJobUpdate(state, e)) .otherwise(() => undefined) } diff --git a/cli/src/utils/status-bar-chips.ts b/cli/src/utils/status-bar-chips.ts index 57cf143bd6..5b8476733b 100644 --- a/cli/src/utils/status-bar-chips.ts +++ b/cli/src/utils/status-bar-chips.ts @@ -23,10 +23,33 @@ export type StatusBarChip = { export type StatusBarWidthSize = 'xs' | 'sm' | 'md' | 'lg' +/** + * Canonical context-window usage for the context chip. Declared once here and + * reused by the SDK event handler that produces it (`SetContextWindowUsageFn`, + * which re-exports it), the chat state that holds it, the send-message hook + * that threads the setter, and the status-bar component, so a later additive + * field cannot go silently missing from one consumer. + * + * `compactionTriggerTokens` is the runtime's model-aware semantic-compaction + * trigger budget and is optional: it is absent for events emitted before the + * field existed, and the chip then renders exactly as it did before. + * + * It is NOT bounded by `max`: the event derives it from the raw model window + * while `max` is clamped by an explicit `maxContextLength` override, and an + * unknown window yields a flat fallback budget (see + * `printModeContextWindowSchema`). `contextTriggerTokens` below is the single + * place that reconciles the two for rendering. + */ +export type StatusBarContextUsage = { + used: number + max: number + compactionTriggerTokens?: number +} + export type SelectStatusBarChipsInput = { widthSize: StatusBarWidthSize terminalWidth: number - contextWindowUsage?: { used: number; max: number } | null + contextWindowUsage?: StatusBarContextUsage | null sessionCostCents?: number | null modelName?: string | null diffStats?: { modified: number; added: number; deleted: number } | null @@ -48,9 +71,13 @@ export type SelectStatusBarChipsInput = { * the budget or that stopped reclaiming space, and `pending` marks a pass * that is running right now (the chip renders even at `count: 0`). `pending` * is only honored while `isActive`: an aborted turn never delivers the - * settling event, so an idle run must not keep claiming a live pass. The - * producer only sets `pending` for the root agent run, so a subagent's - * compaction never renders here as a live root-level pass. + * settling event, so an idle run must not keep claiming a live pass. `pending` + * is derived from the notice's `pendingRunIds` set, which records EVERY run + * with an unsettled pass — root and nested alike — so a subagent's compaction + * keeps this shared chip live (without ever rendering a root-level card of its + * own) until that run settles. A notice produced before that set existed can + * carry a bare `pending: true` instead; the producers keep that flag, and this + * selector reads `pending` alone, so the legacy shape renders identically. */ compactionNotice?: CompactionNotice | null elapsedSeconds: number @@ -138,9 +165,40 @@ export function shortenStatusModelName( return truncateStatusLabel(modelName.replace(PROVIDER_PREFIX, ''), maxChars) } -const contextTone = (pct: number): StatusBarChipTone => { +/** + * Sanitized compaction trigger in tokens, or null when there is nothing + * meaningful to report. Numbers arriving from persisted state are coerced + * defensively before being divided by `max`, and a trigger at or above `max` is + * suppressed rather than pinned to the end of the bar: the unknown-window + * fallback budget can exceed a small configured window, where a marker at 100% + * would be actively misleading. + */ +const contextTriggerTokens = (usage: StatusBarContextUsage): number | null => { + const trigger = usage.compactionTriggerTokens + if (typeof trigger !== 'number' || !Number.isFinite(trigger)) return null + if (trigger <= 0) return null + if (!(usage.max > 0) || trigger >= usage.max) return null + return trigger +} + +/** Trigger position as a 0..100 percent of the window, or null when unknown. */ +const contextTriggerPct = (usage: StatusBarContextUsage): number | null => { + const trigger = contextTriggerTokens(usage) + if (trigger == null) return null + return Math.min(100, Math.max(0, Math.round((trigger / usage.max) * 100))) +} + +/** + * Pure in its arguments: the warning threshold is the compaction trigger when + * one is known (that is the point at which the next step may compact) and the + * fixed 70 otherwise. The 90 error threshold is fixed either way. + */ +const contextTone = ( + pct: number, + triggerPct?: number | null, +): StatusBarChipTone => { if (pct >= 90) return 'error' - if (pct >= 70) return 'warning' + if (pct >= (triggerPct ?? 70)) return 'warning' return 'secondary' } @@ -153,14 +211,46 @@ const percentLabel = (pct: number): string => `${pct}%` */ const contextPercentLabel = (pct: number): string => `ctx ${pct}%` -/** '/' prefix for the sizes wide enough to render token counts. */ -const contextCountsPrefix = (usage: { used: number; max: number }): string => - `${formatStatusTokenCount(usage.used)}/${formatStatusTokenCount(usage.max)}` +/** + * '/' prefix for the sizes wide enough to render token counts, plus + * a '⇲' suffix when `triggerTokens` is supplied. The glyph is the one + * the compaction chip already uses, so it reads as the same concept. + */ +const contextCountsPrefix = ( + usage: StatusBarContextUsage, + triggerTokens?: number | null, +): string => { + const counts = `${formatStatusTokenCount(usage.used)}/${formatStatusTokenCount(usage.max)}` + return triggerTokens == null + ? counts + : `${counts} ⇲${formatStatusTokenCount(triggerTokens)}` +} -/** `pct` must already be clamped to 0..100 by the caller. */ -const buildUsageBar = (pct: number, length: number): string => { +/** + * `pct` must already be clamped to 0..100 by the caller. A supplied + * `triggerPct` marks its cell with '│' INSTEAD of the glyph that cell would + * otherwise get, so the rendered width is unchanged. The marker is drawn even + * when usage has already passed it: that the threshold was crossed is the + * useful signal. + */ +const buildUsageBar = ( + pct: number, + length: number, + triggerPct?: number | null, +): string => { const filled = Math.round((pct / 100) * length) - return `${'█'.repeat(filled)}${'░'.repeat(length - filled)}` + const markerIndex = + triggerPct == null + ? -1 + : Math.min( + length - 1, + Math.max(0, Math.floor((triggerPct / 100) * length)), + ) + let bar = '' + for (let index = 0; index < length; index++) { + bar += index === markerIndex ? '│' : index < filled ? '█' : '░' + } + return bar } /** Bar cell count, or null for the sizes that render the percent only. */ @@ -174,11 +264,12 @@ const contextBarLength = (widthSize: StatusBarWidthSize): number | null => { const barPercentLabel = ( widthSize: StatusBarWidthSize, pct: number, + triggerPct?: number | null, ): string => { const barLength = contextBarLength(widthSize) return barLength == null ? percentLabel(pct) - : `${buildUsageBar(pct, barLength)} ${percentLabel(pct)}` + : `${buildUsageBar(pct, barLength, triggerPct)} ${percentLabel(pct)}` } /** @@ -197,19 +288,25 @@ const contextCountsThreshold = ( /** * Progressively shorter context labels for the overflow loop, widest first, so - * a token-count label gives up its counts before its bar instead of collapsing - * straight to the bare percent. Sizes that render neither counts nor a bar have - * fewer entries rather than repeating one. The first entry is also the widest - * form buildContextLabel renders, so the two cannot drift. + * a token-count label gives up its compaction-trigger suffix, then its counts, + * before its bar instead of collapsing straight to the bare percent. Sizes that + * render neither counts nor a bar have fewer entries rather than repeating one. + * + * INVARIANT: the first entry is exactly the widest form buildContextLabel + * renders, so the two cannot drift; adding a wider form to one and not the + * other silently breaks overflow shortening. Both are exported so a test can + * pin that invariant directly. */ -const contextLabelFallbacks = ( +export const contextLabelFallbacks = ( widthSize: StatusBarWidthSize, - usage: { used: number; max: number }, + usage: StatusBarContextUsage, pct: number, ): [string, ...string[]] => { - const barPercent = barPercentLabel(widthSize, pct) + const triggerTokens = contextTriggerTokens(usage) + const barPercent = barPercentLabel(widthSize, pct, contextTriggerPct(usage)) if (contextCountsThreshold(widthSize) != null) { return [ + `${contextCountsPrefix(usage, triggerTokens)} ${barPercent}`, `${contextCountsPrefix(usage)} ${barPercent}`, barPercent, percentLabel(pct), @@ -221,17 +318,18 @@ const contextLabelFallbacks = ( return [percentLabel(pct)] } -const buildContextLabel = ( +/** Widest context label for a size; see the invariant on contextLabelFallbacks. */ +export const buildContextLabel = ( widthSize: StatusBarWidthSize, - usage: { used: number; max: number }, + usage: StatusBarContextUsage, pct: number, ): string => { - const barPercent = barPercentLabel(widthSize, pct) + const barPercent = barPercentLabel(widthSize, pct, contextTriggerPct(usage)) const countsThreshold = contextCountsThreshold(widthSize) if (countsThreshold != null) { return pct >= countsThreshold - ? `${contextCountsPrefix(usage)} ${barPercent}` + ? `${contextCountsPrefix(usage, contextTriggerTokens(usage))} ${barPercent}` : barPercent } @@ -361,7 +459,7 @@ export function selectStatusBarChips(input: SelectStatusBarChipsInput): { const chips: StatusBarChip[] = [] let contextPct: number | null = null - let contextUsage: { used: number; max: number } | null = null + let contextUsage: StatusBarContextUsage | null = null const hasIndexError = indexChip?.tone === 'error' const omitContextForIndexError = widthSize === 'xs' && hasIndexError @@ -383,7 +481,7 @@ export function selectStatusBarChips(input: SelectStatusBarChipsInput): { chips.push({ id: 'context', label: buildContextLabel(widthSize, contextWindowUsage, contextPct), - tone: contextTone(contextPct), + tone: contextTone(contextPct, contextTriggerPct(contextWindowUsage)), }) contextUsage = contextWindowUsage } diff --git a/common/src/types/contracts/llm.ts b/common/src/types/contracts/llm.ts index 2174fc51f9..0cc268dc95 100644 --- a/common/src/types/contracts/llm.ts +++ b/common/src/types/contracts/llm.ts @@ -8,6 +8,7 @@ import type { ParamsExcluding } from '../function-params' import type { Logger } from './logger' import type { Model } from '../../old-constants' import type { Message } from '../messages/codebuff-message' +import type { RequestContextTrimInfo } from '../print-mode' import type { PromptResult } from '../../util/error' import type { generateText, streamText, ToolCallPart } from 'ai' import type z from 'zod/v4' @@ -35,6 +36,25 @@ export type CacheDebugUsageData = { totalTokens: number } +/** + * Report of the SDK's request-time emergency-brake trim (the last line of + * defense, applied when a dispatched request's messages still exceed the + * provider-safe budget after the runtime brakes). Emitted only when the trim + * actually dropped messages. + * + * `contextWindowTokens` is the resolved model window when known; + * `messageBudgetTokens` is the message-only budget applied after reserving the + * counted system + tool surface. + * + * PUBLISHED PAYLOAD TYPE for the optional `onRequestContextTrimmed` callback + * below. It is DECLARED in `../print-mode` — the module the SDK entry point + * publishes wholesale, and the only surface the generated `dist/index.d.ts` is + * built from — and re-exported here so the runtime and SDK call sites that + * already import it from this contracts module keep working unchanged. + * Widening it is therefore a public-contract change: only add optional fields. + */ +export type { RequestContextTrimInfo } + export type PromptAiSdkStreamFn = ( params: { apiKey: string @@ -58,6 +78,12 @@ export type PromptAiSdkStreamFn = ( onCacheDebugUsageReceived?: (usage: CacheDebugUsageData) => void /** Reports the post-routing model context window to the runtime. */ onModelContextResolved?: (contextWindowTokens: number | undefined) => void + /** + * Reports a request-time emergency trim that actually dropped messages. + * Purely observational: the callback can never affect the trim result, and + * a throwing consumer is caught and logged rather than aborting dispatch. + */ + onRequestContextTrimmed?: (info: RequestContextTrimInfo) => void includeCacheControl?: boolean cacheDebugCorrelation?: string agentProviderOptions?: OpenRouterProviderRoutingOptions @@ -97,6 +123,8 @@ export type PromptAiSdkFn = ( normalizedBody?: unknown }) => void onCacheDebugUsageReceived?: (usage: CacheDebugUsageData) => void + /** See {@link PromptAiSdkStreamFn}'s `onRequestContextTrimmed`. */ + onRequestContextTrimmed?: (info: RequestContextTrimInfo) => void includeCacheControl?: boolean cacheDebugCorrelation?: string agentProviderOptions?: OpenRouterProviderRoutingOptions @@ -134,6 +162,8 @@ export type PromptAiSdkStructuredInput = { normalizedBody?: unknown }) => void onCacheDebugUsageReceived?: (usage: CacheDebugUsageData) => void + /** See {@link PromptAiSdkStreamFn}'s `onRequestContextTrimmed`. */ + onRequestContextTrimmed?: (info: RequestContextTrimInfo) => void includeCacheControl?: boolean cacheDebugCorrelation?: string agentProviderOptions?: OpenRouterProviderRoutingOptions diff --git a/common/src/types/print-mode.ts b/common/src/types/print-mode.ts index ca8de88e9f..80bad6f811 100644 --- a/common/src/types/print-mode.ts +++ b/common/src/types/print-mode.ts @@ -167,10 +167,47 @@ export const printModePhaseSchema = z.object({ }) export type PrintModePhase = z.infer +/** + * Live context-window usage for the status line. + * + * ADDITIVE, non-breaking public-contract change: the required `used`/`max` + * pair is unchanged, and `compactionTriggerTokens`/`compactionTargetTokens` + * were added afterwards. They report the runtime's model-aware + * semantic-compaction budget for the ACTIVE model (exactly the values + * `getSemanticCompactionBudget` returns for the resolved context window), so a + * UI can show at what point compaction will fire instead of surprising the + * user with it. Both are optional for compatibility with persisted or replayed + * events emitted before the fields existed, and a consumer that ignores them + * keeps its previous behavior — no consumer migration is required. + * + * RELATION TO `max` — the two are computed from DIFFERENT inputs, so a + * consumer must NOT assume the trigger sits inside the reported window: + * - `max` is the status window: the resolved model context window, clamped + * by an explicit `maxContextLength` override when one is configured + * (`min(maxContextLength, contextWindow)`), and falling back to the flat + * 190k default only when neither is known. + * - `compactionTriggerTokens`/`compactionTargetTokens` are derived from the + * RAW resolved model window alone and are deliberately NOT clamped by + * `maxContextLength` (an override does not move the window-derived + * trigger), and when that window is unknown they are the conservative + * 140k/100k fallback budgets rather than anything derived from `max`. + * + * So `compactionTriggerTokens > max` is a legitimate, expected payload: an + * override that shrinks `max` below the model's own trigger, or the + * unknown-window fallback against a small configured window, both produce it. + * The ONLY ordering guaranteed between the new fields themselves is + * `1 <= compactionTargetTokens <= compactionTriggerTokens`. A consumer that + * renders trigger against `max` (a marker on a usage bar, a percentage, a + * warning threshold) must therefore clamp or suppress it for itself; the CLI + * status bar drops the marker entirely once the trigger reaches `max`, since + * pinning it to 100% would claim compaction fires exactly at the window edge. + */ export const printModeContextWindowSchema = z.object({ type: z.literal('context_window'), used: z.number(), max: z.number(), + compactionTriggerTokens: z.number().optional(), + compactionTargetTokens: z.number().optional(), }) export type PrintModeContextWindow = z.infer< typeof printModeContextWindowSchema @@ -362,6 +399,87 @@ export type PrintModeContextCompactionStatus = z.infer< typeof printModeContextCompactionStatusSchema > +/** + * Request-time emergency context trim. ADDITIVE, non-breaking public-contract + * change: this is a NEW member of the {@link printModeEventSchema} + * discriminated union — no existing event variant is removed, renamed, or + * retyped, so no consumer migration or deprecation is required. In particular + * {@link printModeContextCompactionSchema} keeps its exact shape. + * + * PRODUCER CONTRACT. This reports the LAST-LINE-OF-DEFENSE trim performed at + * request dispatch time, when the messages of an outgoing provider request + * still exceed the provider-safe message budget after every runtime brake has + * run. It is emitted only when that trim actually dropped messages. It is a + * DIFFERENT event from `context_compaction`, which reports the runtime-owned + * semantic and mechanical passes: a `context_request_trim` means those earlier + * brakes were exceeded, so the two must not be merged or counted as one pass. + * `messageBudgetTokens` is the message-only budget actually applied (the + * resolved request budget minus the counted system + tool surface), and + * `resolvedContextWindowTokens` is the post-routing model window when known. + * + * Agent/run correlation mirrors {@link printModeContextCompactionStatusSchema}: + * `runId` identifies the emitting run and `ancestorRunIds` is its lineage + * (empty ONLY for the root run); both are forwarded verbatim by every hop. + * `agentId` is a display hint only — the `spawn_agents` forwarding path + * rewrites it on every forwarded event that is not text/tool/subagent, so at + * nesting depth >= 2 the delivered value names the nearest forwarding child + * rather than the emitter. All three are optional so a persisted or replayed + * payload emitted without correlation still validates. + * + * Forward-compatibility contract for `handleEvent` consumers: an exhaustive + * `switch`/match over `event.type` should treat unknown variants as no-ops + * (the SDK's own default handler only branches on `error`, and the CLI handler + * uses a catch-all `.otherwise`). Consumers that only care about runtime-owned + * compaction can safely ignore `context_request_trim` entirely. + */ +export const printModeContextRequestTrimSchema = z.object({ + type: z.literal('context_request_trim'), + runId: z.string().optional(), + ancestorRunIds: z.string().array().optional(), + agentId: z.string().optional(), + resolvedContextWindowTokens: z.number().optional(), + messageBudgetTokens: z.number(), + beforeTokens: z.number(), + afterTokens: z.number(), + beforeMessages: z.number(), + afterMessages: z.number(), + model: z.string().optional(), +}) +export type PrintModeContextRequestTrim = z.infer< + typeof printModeContextRequestTrimSchema +> + +/** + * Request-time sibling of {@link printModeContextRequestTrimSchema}: the + * payload type of the optional `onRequestContextTrimmed` callback on the + * published `promptAiSdk`/`promptAiSdkStream`/`promptAiSdkStructured` + * signatures. It carries the same trim measurements as the event, minus the + * run correlation the runtime stamps on when it forwards the trim as an event. + * + * DECLARED HERE rather than re-exported from `./contracts/llm` (which now + * re-exports it back, so the runtime and SDK call sites that consume the + * callback keep importing it from the same place): the SDK entry point + * publishes THIS module's types wholesale and the published `dist/index.d.ts` + * is generated from that entry point alone, so a declaration the published + * module owns cannot be lost by a bundler resolving a re-export chain into an + * unpublished internal module. `cli/src/utils/__tests__/sdk-event-handlers.test.ts` + * names the type through `@openbuff/sdk` to pin that published path. + * + * `contextWindowTokens` is the resolved model window when known; + * `messageBudgetTokens` is the message-only budget applied after reserving the + * counted system + tool surface. Widening this is a public-contract change: + * only add optional fields. + */ +export type RequestContextTrimInfo = { + contextWindowTokens?: number + messageBudgetTokens: number + beforeTokens: number + afterTokens: number + beforeMessages: number + afterMessages: number + model?: string +} + /** * Live background-job update (M5). ADDITIVE, non-breaking public-contract * change: this is a NEW member of the {@link printModeEventSchema} @@ -412,6 +530,7 @@ export const printModeEventSchema = z.discriminatedUnion('type', [ printModeContextCompactionSchema, printModeContextCompactionStatusSchema, + printModeContextRequestTrimSchema, printModeContextWindowSchema, printModeJobUpdateSchema, printModeReasoningDeltaSchema, diff --git a/docs/agents-and-tools.md b/docs/agents-and-tools.md index a6e44df9eb..681d6c4068 100644 --- a/docs/agents-and-tools.md +++ b/docs/agents-and-tools.md @@ -1507,6 +1507,48 @@ renderer sanitizes missing, non-finite, negative, or unknown-category values coming back from an older session, and a replayed session that still holds the old free-text notice keeps rendering as plain text. +The persisted block's `status` field enumerates four values: + +- absent or `'complete'` — a finished pass. Absent is what every block written + before the field existed holds, so an older replayed session still renders as + a completed pass. +- `'pending'` — live in the producing process only, identified by the + `liveSessionId` stamp described below. +- `'interrupted'` — terminal: the run ended (abort or turn teardown) before the + pass reported anything, so that path rewrites `'pending'` to it and a block + that reaches persistence never claims to still be running. +- `'declined'` — terminal: the pass RAN and reclaimed nothing, because the + runtime settled it without ever reporting a result. Distinct from + `'interrupted'` (the pass completed); the card reads "Compaction pass — + nothing reclaimed" and its result lines stay suppressed. + +Two optional fields are persisted alongside it. `subagent: true` marks a pass +performed by a foreground subagent or inline agent run (non-empty +`ancestorRunIds`) so the card is labelled as a nested pass; it is absent on root +passes and on blocks written by an older CLI, which are therefore read as root +passes. `trimSource: 'request'` marks the SDK's request-time emergency trim +(`context_request_trim`), a strictly later and more severe brake than the +runtime-owned passes; it is absent for the runtime passes and for every block +written by an older CLI. + +The format stays additive, but forward replay is not symmetric with backward +replay, so both directions are stated explicitly: + +- Forward (older block, current CLI): every added field is optional and its + absent value is exactly the previous behavior, so prior sessions round-trip + unchanged. +- Backward (block written by this version, replayed by an older CLI): the older + renderer knows only `pending`/`complete`/`interrupted`, so it falls through to + its completed-pass branch for an unknown `status`. A `'declined'` block — + whose result fields are the zeroed placeholders of a pass that never reported + one — therefore renders there as a completed pass claiming `→ 0 tokens + (−0%)`, and `subagent`/`trimSource` are dropped, so a nested or request-time + trim is presented as a root-level runtime pass. That mis-rendering is + cosmetic and confined to the transcript card: no persisted field is + reinterpreted, nothing fails to parse, and the session still loads. Consumers + that must stay readable by an older CLI should treat an unknown `status` as + non-terminal-unknown rather than as `complete`. + The pinned `` block's per-field caps scale with the resolved semantic target budget instead of being fixed. The scale factor is `targetTokens / 100_000`, clamped to `[0.5, 3.0]`, so the legacy 100k target is @@ -1597,12 +1639,54 @@ best-effort display hint. In the CLI, a root-run `started` appends a pending `compaction` block stamped with that `runId` (`CompactionContentBlock.runId`) that the terminal `context_compaction` result of the same run replaces in place (a second result -in the same iteration appends instead), a root-run `settled` drops only that -run's still-pending block, and `handleFinish` clears any stray pending block — -including uncorrelated ones replayed from an older session — at the turn -boundary. A subagent's status event is ignored for root-level state, and a -subagent result contributes one completed pass to the status-bar count without -overwriting the root turn's total or clearing its live chip. +in the same iteration appends instead), a root-run `settled` rewrites only that +run's still-pending block to the terminal `status: 'declined'` card — the pass +ran and reclaimed nothing, so the transcript keeps an honest trace of it instead +of the card being deleted, and consecutive declined cards of the same run +collapse into one — and `handleFinish` clears any stray pending block, +including uncorrelated ones replayed from an older session, at the turn +boundary. + +A subagent's status event never adds, settles, or declines a root-level card, +but it is not ignored for root-level state: its `runId` is recorded in the +notice's `pendingRunIds` set, and the `pending` flag behind the root-level +status chip is derived from that set. The chip therefore reports a live pass +while any run — root or nested — has an unsettled `started`, and only that run's +own `settled` removes its entry. A subagent result likewise contributes one +completed pass to the status-bar count without overwriting the root turn's total +or clearing its live chip. + +The request-time brake reports itself through its own additive +`context_request_trim` event on the public `handleEvent` surface +(`common/src/types/print-mode.ts`). It fires only when the dispatch-time trim +actually dropped messages, and it is a DIFFERENT event from +`context_compaction`: reaching it means every runtime brake above it was already +exceeded, so the two must never be merged or counted as one pass. Its required +fields are `messageBudgetTokens` (the message-only budget actually applied, i.e. +the resolved request budget minus the counted system + tool surface), +`beforeTokens`/`afterTokens`, and `beforeMessages`/`afterMessages`; `runId`, +`ancestorRunIds`, `agentId`, `resolvedContextWindowTokens`, and `model` are +optional, with the same correlation semantics (and the same `agentId` caveat) as +the status event above. The CLI renders it as a `compaction` block marked +`trimSource: 'request'` with its own "Context trimmed at request time" title: it +never consumes an announced pending card, because it settles no announced pass, +and it always degrades the turn's compaction chip. Consumers that only care +about runtime-owned compaction can ignore the variant entirely. + +Two more additive surfaces accompany it. The `context_window` event gained +optional `compactionTriggerTokens` and `compactionTargetTokens`, which report +the runtime's model-aware semantic-compaction budget for the ACTIVE model +(exactly what `getSemanticCompactionBudget` returns for the resolved window), so +a UI can show where compaction will fire instead of surprising the user with it; +both are optional, so replayed events emitted before they existed still validate +and a consumer that ignores them renders exactly as before. On the SDK side, the +published `promptAiSdk`, `promptAiSdkStream`, and `promptAiSdkStructured` +signatures gained an optional `onRequestContextTrimmed` callback and its +`RequestContextTrimInfo` payload type (`common/src/types/contracts/llm.ts`, +implemented in `sdk/src/impl/llm.ts`). It is purely observational: it can never +affect the trim result, a throwing consumer is caught and logged rather than +aborting dispatch, and existing callers that omit it are unaffected. All three +additions are additive and require no consumer migration. One cleanup path stays unreachable by construction: a user-initiated abort makes the SDK drop every post-abort event, so neither `settled` nor `handleFinish` diff --git a/packages/agent-runtime/src/__tests__/loop-agent-steps.test.ts b/packages/agent-runtime/src/__tests__/loop-agent-steps.test.ts index fb71938501..36cfb3deed 100644 --- a/packages/agent-runtime/src/__tests__/loop-agent-steps.test.ts +++ b/packages/agent-runtime/src/__tests__/loop-agent-steps.test.ts @@ -244,10 +244,205 @@ describe('loopAgentSteps', () => { type: 'context_window', used: expect.any(Number), max: 32_000, + // Model-aware semantic-compaction budget for this window, published so + // the CLI status chip can show where compaction will fire. + compactionTriggerTokens: 16_800, + compactionTargetTokens: 8_400, }) expect(result.agentState.contextWindowTokens).toBe(32_000) }) + it('forwards a request-time SDK trim as a context_request_trim event', async () => { + setup() + const events: any[] = [] + // The SDK reports the trim while the request is being dispatched, so the + // callback has to be invoked from inside the generator body — a call made + // outside it would never run. + const promptAiSdkStream = mock(async function* (params) { + params.onRequestContextTrimmed({ + contextWindowTokens: 32_000, + messageBudgetTokens: 22_400, + beforeTokens: 30_000, + afterTokens: 12_000, + beforeMessages: 6, + afterMessages: 2, + model: 'claude-3-5-sonnet-20241022', + }) + yield { type: 'text' as const, text: 'LLM response\n\n' } + yield createToolCallChunk('end_turn', {}) + return promptSuccess('mock-message-id') + }) + + await loopAgentSteps({ + ...baseParams, + promptAiSdkStream, + localAgentTemplates: { 'test-agent': agentTemplate }, + onResponseChunk: (event) => events.push(event), + }) + + // Reaching this trim means the runtime-owned brakes were already exceeded, + // so it is published as its own event with the measurements untouched. + expect(events).toContainEqual( + expect.objectContaining({ + type: 'context_request_trim', + // Root run: empty lineage, so root-level live UI may render it. + ancestorRunIds: [], + resolvedContextWindowTokens: 32_000, + messageBudgetTokens: 22_400, + beforeTokens: 30_000, + afterTokens: 12_000, + beforeMessages: 6, + afterMessages: 2, + model: 'claude-3-5-sonnet-20241022', + }), + ) + // The run id is minted by the runtime during the run rather than by this + // fixture, so pin that it is present instead of a literal value. + const trim = events.find((event) => event.type === 'context_request_trim') + expect(typeof trim.runId).toBe('string') + expect(trim.runId.length).toBeGreaterThan(0) + }) + + it('stamps a nested run lineage on the context_request_trim event', async () => { + setup() + const events: any[] = [] + // Same nested-lineage mechanism as the compaction lineage case: a non-empty + // `ancestorRunIds` on the agent state entering the loop. + agentState.ancestorRunIds = ['parent-run'] + const promptAiSdkStream = mock(async function* (params) { + params.onRequestContextTrimmed({ + contextWindowTokens: 32_000, + messageBudgetTokens: 22_400, + beforeTokens: 30_000, + afterTokens: 12_000, + beforeMessages: 6, + afterMessages: 2, + model: 'claude-3-5-sonnet-20241022', + }) + yield { type: 'text' as const, text: 'LLM response\n\n' } + yield createToolCallChunk('end_turn', {}) + return promptSuccess('mock-message-id') + }) + + await loopAgentSteps({ + ...baseParams, + agentState, + promptAiSdkStream, + localAgentTemplates: { 'test-agent': agentTemplate }, + onResponseChunk: (event) => events.push(event), + }) + + // `runId` and `ancestorRunIds` — not `agentId` — are the correlation keys a + // consumer may rely on: subagent forwarding rewrites `agentId` at nesting + // depth >= 2, so a nested trim must be identified by its lineage. + expect(events).toContainEqual( + expect.objectContaining({ + type: 'context_request_trim', + ancestorRunIds: ['parent-run'], + resolvedContextWindowTokens: 32_000, + messageBudgetTokens: 22_400, + beforeTokens: 30_000, + afterTokens: 12_000, + beforeMessages: 6, + afterMessages: 2, + model: 'claude-3-5-sonnet-20241022', + }), + ) + const trim = events.find((event) => event.type === 'context_request_trim') + expect(typeof trim.runId).toBe('string') + expect(trim.runId.length).toBeGreaterThan(0) + }) + + it('publishes the same compaction budget on context_window as on the compaction event', async () => { + setup() + const events: any[] = [] + agentState.messageHistory = [ + userMessage('small-window evidence '.repeat(8_000)), + userMessage('Continue from the retained goal.'), + ] + agentTemplate.handleSteps = + contextPruner.handleSteps as AgentTemplate['handleSteps'] + + await loopAgentSteps({ + ...baseParams, + agentState, + resolveModelContextWindow: mock(() => 32_000), + localAgentTemplates: { 'test-agent': agentTemplate }, + onResponseChunk: (event) => events.push(event), + }) + + // Same fixture and the same expected numbers as the semantic-compaction + // case for a 32k window, so the reported status-line budget and the budget + // the compaction branches actually used cannot drift. + expect(events).toContainEqual( + expect.objectContaining({ + type: 'context_compaction', + action: 'semantic_compaction', + triggerBudgetTokens: 16_800, + targetBudgetTokens: 8_400, + }), + ) + expect(events).toContainEqual( + expect.objectContaining({ + type: 'context_window', + max: 32_000, + compactionTriggerTokens: 16_800, + compactionTargetTokens: 8_400, + }), + ) + }) + + it('keeps the window-derived compaction trigger when maxContextLength clamps max below it', async () => { + setup() + const events: any[] = [] + + await loopAgentSteps({ + ...baseParams, + // The explicit override clamps the reported status window... + maxContextLength: 50_000, + spawnParams: { maxContextLength: 50_000 }, + resolveModelContextWindow: mock(() => 200_000), + onResponseChunk: (event) => events.push(event), + }) + + // ...while the trigger/target stay derived from the RAW 200k model window, + // because an override does not move the window-derived trigger. So a + // published `compactionTriggerTokens` above `max` is expected, exactly as + // printModeContextWindowSchema documents; a consumer that renders the two + // together must clamp or suppress it itself. + expect(events).toContainEqual({ + type: 'context_window', + used: expect.any(Number), + max: 50_000, + compactionTriggerTokens: 140_000, + compactionTargetTokens: 72_000, + }) + }) + + it('keeps the unknown-window fallback trigger above a small configured window', async () => { + setup() + const events: any[] = [] + + await loopAgentSteps({ + ...baseParams, + // No resolveModelContextWindow: the window is unknown, so the budgets are + // the conservative 140k/100k fallback rather than anything derived from + // the configured ceiling reported as `max`. + maxContextLength: 50_000, + spawnParams: { maxContextLength: 50_000 }, + onResponseChunk: (event) => events.push(event), + }) + + expect(events).toContainEqual( + expect.objectContaining({ + type: 'context_window', + max: 50_000, + compactionTriggerTokens: 140_000, + compactionTargetTokens: 100_000, + }), + ) + }) + it('runs semantic programmatic compaction before the mechanical brake', async () => { setup() const events: any[] = [] diff --git a/packages/agent-runtime/src/prompt-agent-stream.ts b/packages/agent-runtime/src/prompt-agent-stream.ts index a4d8ce5d3a..40478f42d0 100644 --- a/packages/agent-runtime/src/prompt-agent-stream.ts +++ b/packages/agent-runtime/src/prompt-agent-stream.ts @@ -7,6 +7,7 @@ import type { SendActionFn } from '@codebuff/common/types/contracts/client' import type { CacheDebugUsageData, PromptAiSdkStreamFn, + RequestContextTrimInfo, } from '@codebuff/common/types/contracts/llm' import type { Logger } from '@codebuff/common/types/contracts/logger' import type { ParamsOf } from '@codebuff/common/types/function-params' @@ -39,6 +40,7 @@ export const getAgentStreamFromTemplate = (params: { }) => void onCacheDebugUsageReceived?: (usage: CacheDebugUsageData) => void onModelContextResolved?: (contextWindowTokens: number | undefined) => void + onRequestContextTrimmed?: (info: RequestContextTrimInfo) => void onCostCalculated?: (providerCostCents: number) => Promise promptAiSdkStream: PromptAiSdkStreamFn @@ -65,6 +67,7 @@ export const getAgentStreamFromTemplate = (params: { onCacheDebugProviderRequestBuilt, onCacheDebugUsageReceived, onModelContextResolved, + onRequestContextTrimmed, sendAction, onCostCalculated, @@ -107,6 +110,7 @@ export const getAgentStreamFromTemplate = (params: { onCacheDebugProviderRequestBuilt, onCacheDebugUsageReceived, onModelContextResolved, + onRequestContextTrimmed, onCostCalculated, sendAction, diff --git a/packages/agent-runtime/src/run-agent-step.ts b/packages/agent-runtime/src/run-agent-step.ts index 8c51c6846e..cd2d52cfa4 100644 --- a/packages/agent-runtime/src/run-agent-step.ts +++ b/packages/agent-runtime/src/run-agent-step.ts @@ -100,6 +100,7 @@ import type { import type { CacheDebugUsageData, PromptAiSdkFn, + RequestContextTrimInfo, } from '@codebuff/common/types/contracts/llm' import type { Logger } from '@codebuff/common/types/contracts/logger' import type { ParamsExcluding } from '@codebuff/common/types/function-params' @@ -633,6 +634,28 @@ export const runAgentStep = async ( const onModelContextResolved = (contextWindowTokens: number | undefined) => { agentState.contextWindowTokens = contextWindowTokens } + // Request-time emergency trim (the SDK's last line of defense). Reported as + // its own event rather than folded into `context_compaction`: reaching it + // means the runtime-owned semantic/mechanical brakes were already exceeded. + // Correlation is keyed off the emitting run (`runId`/`ancestorRunIds`), never + // `agentId`, which subagent forwarding rewrites at nesting depth >= 2. + const onRequestContextTrimmed = (info: RequestContextTrimInfo) => { + onResponseChunk({ + type: 'context_request_trim', + runId: agentState.runId, + agentId: agentState.agentId, + ancestorRunIds: [...agentState.ancestorRunIds], + ...(info.contextWindowTokens !== undefined && { + resolvedContextWindowTokens: info.contextWindowTokens, + }), + messageBudgetTokens: info.messageBudgetTokens, + beforeTokens: info.beforeTokens, + afterTokens: info.afterTokens, + beforeMessages: info.beforeMessages, + afterMessages: info.afterMessages, + ...(info.model !== undefined && { model: info.model }), + }) + } logger.debug( { @@ -764,6 +787,7 @@ export const runAgentStep = async ( onCacheDebugProviderRequestBuilt, onCacheDebugUsageReceived, onModelContextResolved, + onRequestContextTrimmed, template: agentTemplate, onCostCalculated, }) @@ -2334,6 +2358,19 @@ export async function loopAgentSteps( type: 'context_window', used: currentAgentState.contextTokenCount, max: activeContextWindowForStatus, + // Reuses the single hoisted `semanticBudget` for this iteration (see + // its declaration above) rather than recomputing it, so the status + // line reports exactly the budget the compaction branches used and + // the two can never drift. + // + // `max` is the OVERRIDE-CLAMPED status window while these two come + // from the raw model window (or the unknown-window fallback), so + // `compactionTriggerTokens > max` is a legitimate payload here. That + // relation is part of the published contract — see + // `printModeContextWindowSchema` in common/src/types/print-mode.ts — + // so neither value is reconciled against the other before emission. + compactionTriggerTokens: semanticBudget.triggerBudgetTokens, + compactionTargetTokens: semanticBudget.targetBudgetTokens, }) // Check if output is required but missing diff --git a/sdk/CHANGELOG.md b/sdk/CHANGELOG.md index c15743d177..3bd7e5788e 100644 --- a/sdk/CHANGELOG.md +++ b/sdk/CHANGELOG.md @@ -10,6 +10,9 @@ All notable changes to the @openbuff/sdk package will be documented in this file - `pruneStaleTaskMemoryEvidence` returns a discriminated `TaskMemoryPruneOutcome` rather than a nullable count pair, so "there is no record" (`status: 'no-record'`) is distinguishable from "the prune could not be committed" (`status: 'failed'`, with `reason: 'invalid-record' | 'concurrent-write' | 'write-failed'` and the `removed`/`remaining` counts the prune would have written). A successful prune is `status: 'pruned'`, including the no-op `removed: 0` case for a fully fresh record. - `task-memory.json` has two writers, so `revision` stays monotonic and unique across both. `saveMergedTaskMemory` re-reads the on-disk record and merges against whichever of it and the caller's `priorMemory` is newer, emitting one revision past both; pruning refuses to write when the record advanced while it was reconciling. A session that hydrated a pre-prune record therefore cannot resurrect pruned evidence under an already-published revision. - Persistence requires the optional `renameFile` capability on a supplied `CodebuffFileSystem`: without it a save degrades to a skipped write (`undefined`) and a prune reports `reason: 'write-failed'`, rather than writing non-atomically. +- New additive `context_request_trim` `PrintModeEvent` variant on the public `handleEvent` surface (`common/src/types/print-mode.ts`), reporting the SDK's request-time emergency trim — the last line of defense applied at dispatch when an outgoing request's messages still exceed the provider-safe budget after every runtime brake ran. It is emitted only when that trim actually dropped messages, and it is a **different** event from `context_compaction` (which reports the runtime-owned semantic and mechanical passes): a `context_request_trim` means those earlier brakes were exceeded, so the two must not be merged or counted as one pass. Required fields are `messageBudgetTokens` (the message-only budget actually applied: the resolved request budget minus the counted system + tool surface), `beforeTokens`/`afterTokens`, and `beforeMessages`/`afterMessages`. `runId`, `ancestorRunIds`, `agentId`, `resolvedContextWindowTokens`, and `model` are optional, with the same correlation semantics as `context_compaction_status`: `runId`/`ancestorRunIds` are forwarded verbatim (`ancestorRunIds` empty only for the root run) while `agentId` is a display hint that subagent forwarding rewrites at nesting depth >= 2. Adding an event variant is non-breaking: consumers should treat unknown `event.type` values as no-ops, and consumers that only read runtime-owned compaction can ignore it entirely. No migration is required. Full contract in `docs/agents-and-tools.md` under "Context-window-aware compaction budgets". +- New optional `compactionTriggerTokens` and `compactionTargetTokens` on the existing `context_window` `PrintModeEvent` variant (`common/src/types/print-mode.ts`). They report the runtime's model-aware semantic-compaction budget for the active model — exactly the values `getSemanticCompactionBudget` returns for the resolved context window — so a UI can show at what point compaction will fire. The required `used`/`max` pair is unchanged and both new fields are optional, so this is additive and non-breaking: persisted or replayed events emitted before the fields existed still validate, and a consumer that ignores them keeps its previous behavior. No migration is required. +- New optional `onRequestContextTrimmed` callback on the published `promptAiSdk`, `promptAiSdkStream`, and `promptAiSdkStructured` signatures, together with its exported payload type `RequestContextTrimInfo` (`common/src/types/contracts/llm.ts`, implemented in `sdk/src/impl/llm.ts`). It reports the same request-time trim as the `context_request_trim` event, with `messageBudgetTokens`, `beforeTokens`/`afterTokens`, `beforeMessages`/`afterMessages`, and optional `contextWindowTokens`/`model`, and fires only when the trim actually dropped messages. It is purely observational: the callback can never affect the trim result, and a throwing consumer is caught and logged rather than aborting dispatch. The parameter is optional, so existing callers that omit it are unaffected and no migration is required. - New optional `CodebuffFileSystem` capability `streamDirectory` for lazy (bounded-memory) directory iteration, exported alongside its `CodebuffStreamDirectory` type. `list_directory` uses it to stop one entry past the entry cap instead of materializing whole directories. Implementers must (a) release the directory handle from the iterator's `return()`, which breaking out of `for await` invokes, and (b) set `streamDirectory.readdirView` to the adapter's own `readdir`. Callers ignore the capability when `readdirView` is not the adapter's current `readdir`, so an adapter that decorates `createNodeFileSystem()` (spread or `Object.create()`) and overrides `readdir` keeps serving its own view instead of the host filesystem. The capability is deliberately not named `opendir` so adapters inheriting from `fs.promises` are never auto-detected. - New exported predicate `supportsStreamDirectory(fs)` for detecting the `streamDirectory` capability. It is the supported way to observe whether an adapter provides streaming directory iteration, because presence of the member is only half the contract: the predicate also applies the `readdirView` pairing, so it always agrees with the path `list_directory` takes. `detectFilesystemCapabilities()` is unchanged and still reports member-presence capabilities only. - New exported constant `MAX_LIST_DIRECTORY_ENTRIES`, the `list_directory` entry cap. It is the supported way to obtain the cap now that the over-cap `errorMessage` no longer reports an observed entry count, so consumers that parsed the count no longer need to parse the message at all. diff --git a/sdk/src/impl/__tests__/llm-context-window.test.ts b/sdk/src/impl/__tests__/llm-context-window.test.ts index 8556000d7e..559aa5700b 100644 --- a/sdk/src/impl/__tests__/llm-context-window.test.ts +++ b/sdk/src/impl/__tests__/llm-context-window.test.ts @@ -12,6 +12,7 @@ import { getProviderContextLimitFromError, } from '../llm' +import type { RequestContextTrimInfo } from '@codebuff/common/types/contracts/llm' import type { Logger } from '@codebuff/common/types/contracts/logger' import type { Message } from '@codebuff/common/types/messages/codebuff-message' @@ -188,6 +189,108 @@ describe('getMessagesForModelContext', () => { } }) + test('reports a request-time trim to the onTrimmed consumer', () => { + // Deliberately the same fixture and window/system shape as the + // trigger/target telemetry case above, which already pins + // `effectiveMessageBudgetTokens: 600` for exactly these inputs: asserting + // the same 600 here is what keeps the callback payload and the emitted + // telemetry from drifting to two different message-only budgets. + const messages: Message[] = [ + userMessage('old context '.repeat(10_000)), + userMessage('middle context '.repeat(10_000)), + userMessage('recent context '.repeat(10_000)), + ] + const trimInfos: RequestContextTrimInfo[] = [] + const warnSpy = spyOn(logger, 'warn').mockImplementation(() => {}) + + try { + const result = getMessagesForModelContext({ + messages, + contextWindowTokens: 2_000, + systemTokens: 400, + model: 'anthropic/claude-4-sonnet', + logger, + onTrimmed: (info) => trimInfos.push(info), + }) + + // The callback reports the trim once, never per dropped message. + expect(trimInfos).toHaveLength(1) + const info = trimInfos[0] + expect(info.messageBudgetTokens).toBe(600) + expect(info.contextWindowTokens).toBe(2_000) + // The model is forwarded so a consumer can attribute the trim to the + // model whose window it was measured against. + expect(info.model).toBe('anthropic/claude-4-sonnet') + // before/after are measured against the request that was actually sent, + // so they must match the input history and the returned one. + expect(info.beforeMessages).toBe(messages.length) + expect(info.afterMessages).toBe(result.length) + expect(info.afterTokens).toBeLessThan(info.beforeTokens) + } finally { + warnSpy.mockRestore() + } + }) + + test('does not invoke onTrimmed when no trim occurs', () => { + const messages: Message[] = [userMessage('short context')] + const trimInfos: RequestContextTrimInfo[] = [] + + const result = getMessagesForModelContext({ + messages, + contextWindowTokens: 200_000, + logger, + onTrimmed: (info) => trimInfos.push(info), + }) + + // Returning the very same array reference is what "no trim" means here, so + // reference identity and callback silence have to agree. + expect(result).toBe(messages) + expect(trimInfos).toEqual([]) + }) + + test('absorbs a throwing onTrimmed consumer without changing the trim', () => { + const messages: Message[] = [ + userMessage('old context '.repeat(10_000)), + userMessage('middle context '.repeat(10_000)), + userMessage('recent context '.repeat(10_000)), + ] + const warnSpy = spyOn(logger, 'warn').mockImplementation(() => {}) + + try { + let result: Message[] | undefined + // A UI/telemetry consumer must never be able to abort the request the + // trim just made sendable. + expect(() => { + result = getMessagesForModelContext({ + messages, + contextWindowTokens: 2_000, + logger, + onTrimmed: () => { + throw new Error('onTrimmed consumer exploded') + }, + }) + }).not.toThrow() + + expect(result).not.toBe(messages) + expect(JSON.stringify(result)).toContain(COMPACTED_CONTEXT_POINTER) + + // The emergency-trim telemetry warns from the same block, so a bare call + // count would not distinguish the swallow: key off the swallow's own + // message and the reported error instead. + const swallowed = warnSpy.mock.calls.filter( + (call) => + typeof call[1] === 'string' && + call[1].includes('Ignoring request-time context-trim consumer error'), + ) + expect(swallowed).toHaveLength(1) + expect( + (swallowed[0][0] as { error?: { message?: string } }).error?.message, + ).toBe('onTrimmed consumer exploded') + } finally { + warnSpy.mockRestore() + } + }) + test('keeps trigger/target equal to the request budget when no overhead is reserved', () => { // Callers that reserve no system/tool surface see the previous values, so // the field pair is a pure clarification for them rather than a break. diff --git a/sdk/src/impl/llm.ts b/sdk/src/impl/llm.ts index 39803b4668..2b5e2844cc 100644 --- a/sdk/src/impl/llm.ts +++ b/sdk/src/impl/llm.ts @@ -54,6 +54,7 @@ import type { PromptAiSdkStreamFn, PromptAiSdkStructuredInput, PromptAiSdkStructuredOutput, + RequestContextTrimInfo, } from '@codebuff/common/types/contracts/llm' import type { ParamsOf } from '@codebuff/common/types/function-params' import type { JSONObject } from '@codebuff/common/types/json' @@ -605,6 +606,12 @@ export function getMessagesForModelContext(params: { userId?: string userInputId?: string model?: string + /** + * Observational report of a trim that actually dropped messages. It can + * never change the returned messages, and a throwing consumer is caught and + * logged so a UI/telemetry callback cannot abort the request. + */ + onTrimmed?: (info: RequestContextTrimInfo) => void }): Message[] { const resolvedMessageLimit = getModelContextMessageLimit( params.contextWindowTokens, @@ -696,6 +703,28 @@ export function getMessagesForModelContext(params: { properties: telemetryProperties, logger: params.logger, }) + + if (params.onTrimmed) { + // Never allowed to abort the trim: the messages are already computed, so + // a throwing consumer is absorbed here and the trimmed result is + // returned unchanged. + try { + params.onTrimmed({ + contextWindowTokens: params.contextWindowTokens, + messageBudgetTokens: effectiveMessageBudgetTokens, + beforeTokens: inputTokens, + afterTokens: outputTokens, + beforeMessages: params.messages.length, + afterMessages: trimmed.length, + model: params.model, + }) + } catch (error) { + params.logger.warn( + { error: getErrorObject(error) }, + 'Ignoring request-time context-trim consumer error; the trim itself is unaffected', + ) + } + } } return trimmed @@ -1139,6 +1168,7 @@ export async function* promptAiSdkStream( userId, userInputId, model: effectiveModel, + onTrimmed: params.onRequestContextTrimmed, }), includeCacheControl: isChatGptOAuth && compatibility.stripCacheControl === false, @@ -1784,6 +1814,7 @@ export async function promptAiSdk( userId: params.userId, userInputId: params.userInputId, model: effectiveModelSdk, + onTrimmed: params.onRequestContextTrimmed, }), includeCacheControl: compatibility.stripCacheControl === false, }), @@ -1916,6 +1947,7 @@ export async function promptAiSdkStructured( userId: params.userId, userInputId: params.userInputId, model: effectiveModelStructured, + onTrimmed: params.onRequestContextTrimmed, }), includeCacheControl: compatibility.stripCacheControl === false, }), From 41a96290cc2d7d6d8a39acdcba2a2243c945b065 Mon Sep 17 00:00:00 2001 From: AnzoBenjamin Date: Tue, 1 Sep 2026 20:11:10 +0300 Subject: [PATCH 2/2] fix(gate): drive reviewer skip from the durable receipt ledger The reviewer-skip fast path required reviewedReviewableFingerprint to equal the current reviewable fingerprint, but that is a single slot overwritten on every gate pass: after an early wave reviewed one reviewable set and a later wave reviewed another, the scalar held only the later wave's fingerprint, so a cycle that re-armed on the unchanged earlier set re-spawned the reviewer even though a matching LOOKS_GOOD receipt for those exact bytes was still on file. The decision now reads the durable reviewReceipts ledger, matching a receipt on its gate-computed gateId, reviewer:expectedFingerprint, plus the reviewed file set. That match is strictly more specific than the scalar it replaces: same reviewer family, same file set, same bytes base2 itself hashed. Two guards keep it fail closed. The current reviewable fingerprint must be attestable, so the stable unreadable:no-crypto marker, under which two unrelated snapshots compare equal, can never grant a skip. And the reviewer-REPORTED snapshotFingerprint on a receipt is deliberately not read as content evidence, because the attestation path drift-tolerates it; only the fingerprint base2 computed for that review may buy a skip. reviewedReviewableFingerprint is soft-deprecated rather than removed. It is now write-only with no production reader, documented with an explicit reader inventory and a three-step removal path, and still written so state serialized by this base2 round-trips unchanged for older readers. Separately, a gate that passes while declared work remains was only discouraged in prose. The gate-state block gained an optional workflow key carrying completedCount, totalCount, and nextWorkflowAction, emitted on the three gate-PASS paths and only when declared write_todos work actually remains. base2's boundWorkflowProgress bounds the action to 240 sanitized chars and omits the key whole otherwise, and the CLI's parseGateStateWorkflow re-enforces every bound independently because it also parses hand-authored assistant text. The key is additive and optional, so no consumer migration is required and blocks persisted before it existed replay unchanged. It is observability only: no gate phase, finalization decision, or follow-up permission reads it. The published schema enumeration is now in step across the producer comment, GateStateContentBlock, the parseGateStateBlock docblock, GateStateBox, and cli/knowledge.md. Test-only fixes: makeProjectTempDir ensures the shared .base2-test-scratch root at use time, because the e2e files' afterAll can remove it between module load and use, and boundWorkflowProgress uses a type alias so both annotations stay bracket-free, since extractInlineFunctionSource cannot parse a return annotation opening with a leading union pipe. common/knowledge.md documents the context_request_trim event, the optional compactionTriggerTokens and compactionTargetTokens on context_window, and the onRequestContextTrimmed callback from the preceding commit, which the memory-drift staleness guard required. Validated: agents unit and e2e 1120 pass / 0 fail; CLI message-block-helpers and gate-state-box 168 pass / 0 fail; repo-wide, cli, and agents typechecks clean; guard:memory-drift reports 0 findings across 12 checkers; the runtime validation/reviewer gate passed with LOOKS_GOOD. --- agents/__tests__/base2.test.ts | 521 ++++++++++++++++++ agents/base2/base2.ts | 160 +++++- agents/base2/gate-state.ts | 40 +- cli/knowledge.md | 2 +- .../__tests__/gate-state-box.test.tsx | 41 ++ .../components/renderers/gate-state-box.tsx | 31 ++ cli/src/types/chat.ts | 33 ++ .../__tests__/message-block-helpers.test.ts | 212 +++++++ cli/src/utils/message-block-helpers.ts | 78 ++- common/knowledge.md | 2 + 10 files changed, 1107 insertions(+), 13 deletions(-) diff --git a/agents/__tests__/base2.test.ts b/agents/__tests__/base2.test.ts index ca7d3debf0..36136dac76 100644 --- a/agents/__tests__/base2.test.ts +++ b/agents/__tests__/base2.test.ts @@ -33,6 +33,12 @@ afterAll(() => { }) function makeProjectTempDir(prefix: string): string { + // Ensure the root at USE time, not only at module load. `.base2-test-scratch` + // is shared with agents/e2e/*.e2e.test.ts, whose afterAll removes the parent + // once it looks empty; when bun runs those files alongside this one the root + // can vanish between module load and this call, and mkdtemp then fails with + // ENOENT. recursive: true makes this a no-op in the common case. + mkdirSync(TEST_TMP_ROOT, { recursive: true }) return mkdtempSync(join(TEST_TMP_ROOT, prefix)) } @@ -158,6 +164,11 @@ function parseGateStateBlock(text: string): repairRound?: number maxRepairRounds?: number advisories?: string[] + workflow?: { + completedCount: number + totalCount: number + nextWorkflowAction: string + } } | undefined { const match = text.match(/([\s\S]*?)<\/gate-state>/) @@ -181,6 +192,17 @@ function parseGateStateBlock(text: string): advisories: parsed.advisories.map((advisory) => String(advisory)), } : {}), + // Declared-workflow progress is emitted on gate-PASS only, so it is read + // back verbatim here rather than re-derived. + ...(parsed.workflow && typeof parsed.workflow === 'object' + ? { + workflow: parsed.workflow as { + completedCount: number + totalCount: number + nextWorkflowAction: string + }, + } + : {}), } } catch { return undefined @@ -10527,6 +10549,11 @@ type InlineGateStateBlockHelpers = { details: string, repairRound?: number, advisories?: string[], + workflow?: { + completedCount: number + totalCount: number + nextWorkflowAction: string + }, ) => string extractGateStateBlocksFromMessage: (message: unknown) => Array<{ gate: string @@ -10554,6 +10581,12 @@ function loadInlineGateStateBlockHelpers(): InlineGateStateBlockHelpers { // which the reviewer/security/specialist add_message surfaces also call. // Reconstructing the producer without it throws at call time. extractInlineFunctionSource(base2Source, 'boundAdvisoryLines'), + // Same contract for the declared-workflow bounds helper: the producer + // calls it unconditionally (it is what decides whether to emit the + // optional `workflow` key at all), so omitting it here throws + // `boundWorkflowProgress is not defined` on every call, not only on the + // calls that pass workflow progress. + extractInlineFunctionSource(base2Source, 'boundWorkflowProgress'), extractInlineFunctionSource( base2Source, 'extractGateStateBlocksFromMessage', @@ -10769,4 +10802,492 @@ describe('base2 gate-pass continuation directive', () => { 'a passing validation/reviewer gate is not a stopping point', ) }) + + // The soft continuation directive above is prompt prose; the machine-readable + // block must ALSO report the outstanding declared work, so a + // consumer can tell a finalized-with-work-remaining turn from a clean one. + test('the gate-pass block carries workflow progress when declared work remains', () => { + const content = driveToGatePassMessage({ + agentId: 'base2-custom', + messageHistory: writeTodosHistory([ + { content: 'Implement wave 1 of the refactor', status: 'completed' }, + { content: 'Implement wave 2 of the refactor', status: 'pending' }, + { content: 'Add focused tests', status: 'pending' }, + ]), + }) + + const gateState = parseGateStateBlock(content) + expect(gateState).toMatchObject({ + gate: 'validation/reviewer', + status: 'passed', + }) + expect(gateState!.workflow).toEqual({ + completedCount: 1, + totalCount: 3, + nextWorkflowAction: 'Implement wave 2 of the refactor', + }) + }) + + // completedCount < totalCount is the load-bearing guard: emitting on equality + // would report a finished workflow as incomplete on every clean turn. The + // payload must stay byte-identical to the pre-field output here. + test('the workflow key is absent when every declared todo is complete', () => { + const content = driveToGatePassMessage({ + agentId: 'base2-custom', + messageHistory: writeTodosHistory([ + { content: 'Implement wave 1 of the refactor', status: 'completed' }, + { content: 'Implement wave 2 of the refactor', status: 'completed' }, + ]), + }) + + expect(parseGateStateBlock(content)!.workflow).toBeUndefined() + expect(content).not.toContain('"workflow"') + }) + + test('the workflow key is absent when no declared workflow progress exists', () => { + const content = driveToGatePassMessage({ agentId: 'base2-custom' }) + + expect(parseGateStateBlock(content)!.workflow).toBeUndefined() + expect(content).not.toContain('"workflow"') + }) + + test('the workflow key is absent when the next workflow action is blank', () => { + // Seeded rather than driven through write_todos: a whitespace-only todo + // content is dropped by the extractor, so a progress record that is + // genuinely incomplete yet carries a blank action can only arrive from + // normalized serialized state. + const content = driveToGatePassMessage({ + agentId: 'base2-custom', + base2ActiveWork: { + touchedFiles: [], + changedFiles: [], + pendingGateFiles: [], + currentPhase: 'idle', + latestWorkSummary: '', + openReviewerBlockers: [], + lastValidationSummary: '', + nextRequiredAction: '', + lastPinnedStateMessage: '', + workflowTodoProgress: { + todos: [ + { content: 'Wave 1', status: 'completed', completed: true }, + { content: ' ', status: 'pending', completed: false }, + ], + completedCount: 1, + totalCount: 2, + nextWorkflowAction: ' ', + }, + }, + }) + + expect(parseGateStateBlock(content)!.workflow).toBeUndefined() + expect(content).not.toContain('"workflow"') + }) + + test('the workflow action is truncated at 240 characters', () => { + const content = driveToGatePassMessage({ + agentId: 'base2-custom', + messageHistory: writeTodosHistory([ + { content: 'Implement wave 1 of the refactor', status: 'completed' }, + { content: 'x'.repeat(300), status: 'pending' }, + ]), + }) + + const workflow = parseGateStateBlock(content)!.workflow + expect(workflow).toEqual({ + completedCount: 1, + totalCount: 2, + nextWorkflowAction: `${'x'.repeat(237)}...`, + }) + expect(workflow!.nextWorkflowAction).toHaveLength(240) + }) + + // Model-authored text flows into the CLI's renderer, so an unstripped + // ESC could spoof terminal output. + test('control characters are stripped from the workflow action', () => { + const content = driveToGatePassMessage({ + agentId: 'base2-custom', + messageHistory: writeTodosHistory([ + { content: 'Implement wave 1 of the refactor', status: 'completed' }, + { + content: 'continue \u001b[31mwave 2\u0000\tnow\u007f', + status: 'pending', + }, + ]), + }) + + const workflow = parseGateStateBlock(content)!.workflow + expect(workflow!.nextWorkflowAction).toBe('continue [31mwave 2 now') + expect(/[\u0000-\u001f\u007f]/.test(workflow!.nextWorkflowAction)).toBe( + false, + ) + }) +}) + +describe('base2 reviewer skip via the durable receipt ledger', () => { + /** Durable review receipt in the shape recordSuccessfulReviewReceipt writes. */ + function reviewReceiptFor(params: { + reviewer: string + snapshotFingerprint: string + reviewedFiles: string[] + verdict?: 'LOOKS_GOOD' | 'NON_BLOCKING' + /** + * Defaults to the gate-computed `${reviewer}:${snapshotFingerprint}` id + * recordSuccessfulReviewReceipt writes. Overridden to model a receipt whose + * reviewer-REPORTED `snapshotFingerprint` drifted from the fingerprint base2 + * computed for that review, which the attestation path tolerates. + */ + gateId?: string + }) { + const { + reviewer, + snapshotFingerprint, + reviewedFiles, + verdict = 'LOOKS_GOOD', + gateId = `${reviewer}:${snapshotFingerprint}`, + } = params + return { + gateId, + reviewer, + verdict, + snapshotFingerprint, + reviewedFiles, + reviewedFileCount: reviewedFiles.length, + dimensions: {}, + findings: [], + findingCount: 0, + requirementCoverage: [], + requirementCoverageCount: 0, + recordedAt: '2025-01-01T00:00:00.000Z', + } + } + + /** + * Gate state parked mid-gate on one pending reviewable file, with the aux + * gates already credited so only the FINAL reviewer decision runs. + */ + function reviewerSkipSeedState( + gateFile: string, + overrides: Partial>, + ) { + return { + touchedFiles: [gateFile], + changedFiles: [gateFile], + pendingGateFiles: [gateFile], + currentPhase: 'awaiting_validation', + latestWorkSummary: '', + openReviewerBlockers: [], + openReviewerFindings: [], + lastValidationSummary: '', + nextRequiredAction: '', + lastPinnedStateMessage: '', + gatePassedFiles: [], + gatePassedFileMarkers: {}, + gatePassedPendingFiles: [], + gatePassedReviewerVerdict: '', + gatePassedValidationSummary: '', + gatePassedFingerprint: '', + reviewedReviewableFingerprint: '', + lastReviewerGateSkipReason: '', + reviewReceipts: [], + testWriterGateDone: true, + docWriterGateDone: true, + securityReviewGateDone: true, + preEditSecurityReviewDone: true, + specialistReviewGatesDone: [], + auxGatesLastPendingFiles: [gateFile], + ...overrides, + } + } + + /** Drive a seeded turn to the yield that follows the reviewer-skip decision. */ + function driveToReviewerDecision( + gateFile: string, + activeWork: Record, + ) { + const base2 = createBase2('default') + const agentState = { agentId: 'base2-custom', base2ActiveWork: activeWork } + const gen = base2.handleSteps!({ + agentState, + prompt: 'Finish the pending review.', + params: {}, + config: base2.programmaticConfig, + } as any) + + expect(gen.next().value).toMatchObject({ toolName: 'git_status' }) + expect( + gen.next(feedJson({ status: ` M ${gateFile}` })).value, + ).toMatchObject({ toolName: 'spawn_agent_inline' }) + const maybePinned = gen.next().value + if (maybePinned !== 'STEP') { + expect(maybePinned).toMatchObject({ toolName: 'add_message' }) + expect(gen.next().value).toBe('STEP') + } + expect(gen.next(finishStepWithToolResult({})).value).toMatchObject({ + toolName: 'git_status', + }) + expect( + gen.next(feedJson({ status: ` M ${gateFile}` })).value, + ).toMatchObject({ toolName: 'run_file_change_hooks' }) + expect(gen.next(feedJson([])).value).toMatchObject({ + toolName: 'git_status', + }) + const decision = gen.next(feedJson({ status: ` M ${gateFile}` })) + return { gen, agentState, decision } + } + + function seedReviewableFile(prefix: string) { + const tmpDir = makeProjectTempDir(prefix) + const tmpFile = join(tmpDir, 'a.ts') + writeFileSync(tmpFile, 'export const value = 1\n') + const gateFile = normalizeGateFilePath(tmpFile) + return { + tmpDir, + gateFile, + // Same construction as the gate's reviewable fingerprint: + // hashGateSnapshotDetails(buildGateSnapshotDetails(reviewable, '')). + reviewableFingerprint: buildFingerprint( + [{ file: gateFile, contentMarker: buildContentMarker(tmpFile) }], + '', + ), + } + } + + test('a matching receipt skips the reviewer even when the scalar holds a later-wave fingerprint', () => { + // Wave 1 reviewed {A}, wave 2 reviewed {C}, so the single scalar holds only + // fingerprint({C}). A later cycle that re-arms on the unchanged {A} set + // must reuse the durable receipt instead of re-spawning the reviewer. + const { tmpDir, gateFile, reviewableFingerprint } = seedReviewableFile( + 'base2-reviewer-skip-receipt-', + ) + const telemetry: Array> = [] + const originalInfo = console.info + console.info = (...args: unknown[]) => { + const [first] = args + if (typeof first === 'string' && first.includes('"base2.gate"')) { + telemetry.push(JSON.parse(first) as Record) + } + } + try { + const { gen, agentState, decision } = driveToReviewerDecision( + gateFile, + reviewerSkipSeedState(gateFile, { + reviewedReviewableFingerprint: `v3:${'a'.repeat(64)}`, + reviewReceipts: [ + reviewReceiptFor({ + reviewer: 'code-reviewer', + snapshotFingerprint: reviewableFingerprint, + reviewedFiles: [gateFile], + }), + ], + }), + ) + + // The reviewer was NOT spawned; the gate reported the skip instead. + expect(decision.value).toMatchObject({ + toolName: 'add_message', + input: { role: 'user' }, + }) + const content = (decision.value as any).input.content as string + expect(content).toContain( + 'Reviewer gate skipped (reviewer skip: reviewable source set unchanged since last review)', + ) + const gateState = parseGateStateBlock(content) + expect(gateState).toMatchObject({ gate: 'reviewer', status: 'skipped' }) + expect(gateState!.details).toContain( + 'reviewer-skip-reviewable-set-unchanged', + ) + expect(gateState!.details).toContain(gateFile) + expect( + telemetry.some( + (event) => + event.reviewerStatus === 'skipped' && + event.skipReason === 'reviewer-skip-reviewable-set-unchanged', + ), + ).toBe(true) + + // The rest of the gate finalizes directly: no code-reviewer spawn at all. + const afterSkip = gen.next() + expect(afterSkip.value).toMatchObject({ toolName: 'git_status' }) + const gatePassed = gen.next(feedJson({ status: ` M ${gateFile}` })) + expect(gatePassed.value).toMatchObject({ + toolName: 'add_message', + input: { role: 'user' }, + }) + expect((gatePassed.value as any).input.content).toMatch( + /reviewer gate passed with LOOKS_GOOD/i, + ) + expect((agentState as any).base2ActiveWork.currentPhase).toBe( + 'final_response_allowed', + ) + } finally { + console.info = originalInfo + rmSync(tmpDir, { recursive: true, force: true }) + } + }) + + test('a matching receipt plus a matching scalar still skips the reviewer (no regression)', () => { + const { tmpDir, gateFile, reviewableFingerprint } = seedReviewableFile( + 'base2-reviewer-skip-scalar-', + ) + try { + const { decision } = driveToReviewerDecision( + gateFile, + reviewerSkipSeedState(gateFile, { + reviewedReviewableFingerprint: reviewableFingerprint, + reviewReceipts: [ + reviewReceiptFor({ + reviewer: 'code-reviewer', + snapshotFingerprint: reviewableFingerprint, + reviewedFiles: [gateFile], + }), + ], + }), + ) + + expect(decision.value).toMatchObject({ + toolName: 'add_message', + input: { role: 'user' }, + }) + const content = (decision.value as any).input.content as string + expect(content).toContain( + 'reviewer skip: reviewable source set unchanged since last review', + ) + expect(parseGateStateBlock(content)!.details).toContain( + 'reviewer-skip-reviewable-set-unchanged', + ) + } finally { + rmSync(tmpDir, { recursive: true, force: true }) + } + }) + + test('a non-attestable reviewable fingerprint never grants a receipt-based skip (fail closed)', () => { + // With no collision-resistant hash available, hashGateSnapshotDetails + // returns the STABLE sentinel 'unreadable:no-crypto'. Every receipt + // predicate then matches trivially, so only the attestability guard keeps + // the gate from treating a stable error string as content evidence. + const tmpDir = makeProjectTempDir('base2-reviewer-skip-no-crypto-') + const originalGetBuiltinModule = (process as any).getBuiltinModule + const originalRequire = (globalThis as any).require + try { + const tmpFile = join(tmpDir, 'a.ts') + writeFileSync(tmpFile, 'export const value = 1\n') + const gateFile = normalizeGateFilePath(tmpFile) + const nonAttestableFingerprint = 'unreadable:no-crypto' + // Remove both module loaders the inline hasher probes. + ;(process as any).getBuiltinModule = undefined + ;(globalThis as any).require = undefined + const { decision } = driveToReviewerDecision( + gateFile, + reviewerSkipSeedState(gateFile, { + reviewedReviewableFingerprint: nonAttestableFingerprint, + reviewReceipts: [ + reviewReceiptFor({ + reviewer: 'code-reviewer', + snapshotFingerprint: nonAttestableFingerprint, + reviewedFiles: [gateFile], + }), + ], + }), + ) + + expect(decision.value).toMatchObject({ + toolName: 'spawn_agents', + input: { agents: [{ agent_type: 'code-reviewer' }] }, + }) + // Self-check that the current fingerprint really is the non-attestable + // marker, so the spawn is caused by the attestability guard. + expect((decision.value as any).input.agents[0].prompt).toContain( + `Snapshot fingerprint (echo exactly): ${nonAttestableFingerprint}`, + ) + } finally { + ;(process as any).getBuiltinModule = originalGetBuiltinModule + ;(globalThis as any).require = originalRequire + rmSync(tmpDir, { recursive: true, force: true }) + } + }) + + // The persisted `snapshotFingerprint` is REVIEWER-REPORTED and drift-tolerated + // by the attestation path, so it is not content evidence on its own. Only the + // gate-computed `gateId` (`${reviewer}:${expectedFingerprint}`) may buy a skip. + test('a receipt whose gate-computed gateId does not match still spawns the reviewer', () => { + const { tmpDir, gateFile, reviewableFingerprint } = seedReviewableFile( + 'base2-reviewer-skip-reported-only-', + ) + try { + const { decision } = driveToReviewerDecision( + gateFile, + reviewerSkipSeedState(gateFile, { + reviewReceipts: [ + reviewReceiptFor({ + reviewer: 'code-reviewer', + // The reviewer REPORTED the current reviewable fingerprint... + snapshotFingerprint: reviewableFingerprint, + reviewedFiles: [gateFile], + // ...but base2 computed a different fingerprint for that review, + // so the gate-computed receipt id does not attest these bytes. + gateId: `code-reviewer:v3:${'b'.repeat(64)}`, + }), + ], + }), + ) + + expect(decision.value).toMatchObject({ + toolName: 'spawn_agents', + input: { agents: [{ agent_type: 'code-reviewer' }] }, + }) + expect((decision.value as any).input.agents[0].prompt).toContain( + `Snapshot fingerprint (echo exactly): ${reviewableFingerprint}`, + ) + } finally { + rmSync(tmpDir, { recursive: true, force: true }) + } + }) + + test('a receipt whose reviewedFiles or reviewer family differs still spawns the reviewer', () => { + const { tmpDir, gateFile, reviewableFingerprint } = seedReviewableFile( + 'base2-reviewer-skip-mismatch-', + ) + try { + // Same fingerprint and count, different reviewed file set. + const mismatchedFiles = driveToReviewerDecision( + gateFile, + reviewerSkipSeedState(gateFile, { + reviewedReviewableFingerprint: reviewableFingerprint, + reviewReceipts: [ + reviewReceiptFor({ + reviewer: 'code-reviewer', + snapshotFingerprint: reviewableFingerprint, + reviewedFiles: ['src/other.ts'], + }), + ], + }), + ) + expect(mismatchedFiles.decision.value).toMatchObject({ + toolName: 'spawn_agents', + input: { agents: [{ agent_type: 'code-reviewer' }] }, + }) + + // A security-reviewer receipt can never satisfy the code-reviewer gate. + const mismatchedFamily = driveToReviewerDecision( + gateFile, + reviewerSkipSeedState(gateFile, { + reviewedReviewableFingerprint: reviewableFingerprint, + reviewReceipts: [ + reviewReceiptFor({ + reviewer: 'security-reviewer', + snapshotFingerprint: reviewableFingerprint, + reviewedFiles: [gateFile], + }), + ], + }), + ) + expect(mismatchedFamily.decision.value).toMatchObject({ + toolName: 'spawn_agents', + input: { agents: [{ agent_type: 'code-reviewer' }] }, + }) + } finally { + rmSync(tmpDir, { recursive: true, force: true }) + } + }) }) diff --git a/agents/base2/base2.ts b/agents/base2/base2.ts index c271e631eb..f20d30a2bb 100644 --- a/agents/base2/base2.ts +++ b/agents/base2/base2.ts @@ -3681,6 +3681,9 @@ ${guideSections} 'validation/reviewer', 'passed', `conversation gate-state reuse; reviewer verdict ${conversationReviewerVerdict}; pending files: ${currentPendingGateFiles.join(', ')}`, + undefined, + undefined, + activeWorkState.workflowTodoProgress, ), ].join('\n'), }, @@ -3742,6 +3745,9 @@ ${guideSections} 'validation/reviewer', 'passed', `durable gate-pass reuse via fingerprint match; reviewer verdict ${durableReviewerVerdict}; pending files: ${currentPendingGateFiles.join(', ')}`, + undefined, + undefined, + activeWorkState.workflowTodoProgress, ), ].join('\n'), }, @@ -4279,19 +4285,55 @@ ${guideSections} // working tree is not review evidence: committed bytes still require a // snapshot-bound review unless a matching receipt survived the prior // pass. + // Content evidence is the GATE-COMPUTED receipt id. `gateId` is + // `${reviewer}:${expectedFingerprint}`, and `expectedFingerprint` is + // the fingerprint base2 itself hashed for that review, so an equal + // gateId means the receipt was recorded against these exact bytes. + const expectedReceiptGateId = `${requiredReviewerAgentType}:${reviewableFingerprint}` const matchingReviewReceipt = activeWorkState.reviewReceipts.some( (receipt) => receipt.reviewer === requiredReviewerAgentType && receipt.verdict === 'LOOKS_GOOD' && - receipt.snapshotFingerprint === reviewableFingerprint && + receipt.gateId === expectedReceiptGateId && receipt.reviewedFileCount === reviewableGateScopeFiles.length && gateFileSetsEqual(receipt.reviewedFiles, reviewableGateScopeFiles), ) + // Provenance matters here. The receipt ALSO carries a reviewer-reported + // `snapshotFingerprint`, which is drift-tolerated by + // `collectReviewerAttestationIssues` (a coverage-complete review that + // reports any well-formed `v3:` fingerprint is credited even when it + // does not equal the id base2 computed). That field is therefore NOT + // content evidence and is deliberately not read above. The gate-computed + // `gateId` is: `recordSuccessfulReviewReceipt` builds it from base2's own + // `expectedFingerprint`, which is + // `hashGateSnapshotDetails(buildGateSnapshotDetails(reviewableGateScopeFiles, ''))` + // and folds in every file's working-tree content marker. So an equal + // gateId over an equal file set is the writer-guaranteed proof that these + // exact bytes were already reviewed LOOKS_GOOD by this reviewer family. + // `reviewedReviewableFingerprint` used to be a second required + // conjunct, but it is ONE scalar overwritten on every gate pass while + // `reviewReceipts` is a durable bounded ledger: after wave 1 reviewed + // {A,B} and wave 2 reviewed {C}, the scalar held only fingerprint({C}), + // so a later cycle re-arming on the unchanged {A,B} set re-spawned the + // reviewer even though a matching LOOKS_GOOD receipt for those exact + // bytes was still on file. The scalar added no safety (the gateId match + // is strictly more specific — same family, same file set, same + // gate-computed bytes), only false misses. It is now WRITE-ONLY state + // kept for serialized-state compatibility with older sessions and + // scheduled for removal; see its docblock in agents/base2/gate-state.ts + // for the reader inventory and removal path. + // + // The attestability check is what keeps this fail-closed, and it is why + // widening the rule is safe: a non-attestable marker such as + // `unreadable:no-crypto` is a STABLE error string, not content + // evidence, so two unrelated snapshots compare equal under it and a + // stale receipt could otherwise buy a skip. Ordering mirrors + // `hasFreshGateFingerprintForPendingFiles`: bail on an empty set, then + // bail on a non-attestable fingerprint, then consult the recorded + // evidence. const reviewableSetAlreadyReviewed = reviewableGateScopeFiles.length > 0 && - !!activeWorkState.reviewedReviewableFingerprint && - activeWorkState.reviewedReviewableFingerprint === - reviewableFingerprint && + isAttestableSnapshotFingerprint(reviewableFingerprint) && matchingReviewReceipt const skipReviewerForReviewableScope = runReviewerGate && @@ -5525,9 +5567,11 @@ ${guideSections} passedPendingFiles, validationSummary, ) - // R5: record the reviewable subset's fingerprint so a later - // git-action turn (no new source edits) that reopens the gate on - // an unchanged reviewable set can skip re-review. + // Soft-deprecated WRITE-ONLY field: nothing in production source + // reads it (the reviewer skip reads the reviewReceipts ledger). It + // is still written so state serialized by this base2 stays + // round-trip identical for older readers; see its docblock in + // agents/base2/gate-state.ts for the removal path. activeWorkState.reviewedReviewableFingerprint = reviewableFingerprint activeWorkState.lastReviewerGateSkipReason = '' @@ -5626,6 +5670,7 @@ ${guideSections} passDetails, undefined, passAdvisories, + activeWorkState.workflowTodoProgress, ), ].join('\n'), }, @@ -5794,12 +5839,26 @@ ${guideSections} // reconstructed with new Function(...), so module-scope closures are // not available at reconstruction time. Keep these deterministic and // single-line so the CLI can promote them into GateStateBox blocks. + // + // PUBLISHED BLOCK SCHEMA emitted by this function (the producer half of + // the parse contract documented on `parseGateStateBlock` / + // `GateStateContentBlock` in the CLI): `gate` and `status` are always + // present; `details` is always present (possibly empty); `repairRound`, + // `maxRepairRounds`, `advisories`, and `workflow` are optional and + // additive. `origin` is not emitted — the CLI defaults it to "Base2". + // Adding a key here REQUIRES updating both published CLI enumerations + // (cli/src/types/chat.ts and cli/src/utils/message-block-helpers.ts). function formatGateStateBlock( gate: 'validation' | 'reviewer' | 'validation/reviewer', status: 'passed' | 'failed' | 'skipped', details: string, repairRound?: number, advisories?: string[], + workflow?: { + completedCount: number + totalCount: number + nextWorkflowAction: string + }, ): string { // Order matters: collapse whitespace FIRST so tabs/newlines/CRs become // spaces, then strip the remaining C0/DEL control bytes (ESC, NUL, @@ -5816,6 +5875,11 @@ ${guideSections} repairRound?: number maxRepairRounds?: number advisories?: string[] + workflow?: { + completedCount: number + totalCount: number + nextWorkflowAction: string + } } = { gate, status, details: normalizedDetails } if ( typeof repairRound === 'number' && @@ -5837,6 +5901,15 @@ ${guideSections} if (boundedAdvisories.length > 0) { payload.advisories = boundedAdvisories } + // Declared-workflow observability: the gate-PASS paths pass + // activeWorkState.workflowTodoProgress here so a turn that finalizes + // with declared write_todos work outstanding is machine-distinguishable + // from a genuinely complete one. Bounded (and omitted whole) by the + // shared helper below; nothing downstream may branch on it. + const boundedWorkflow = boundWorkflowProgress(workflow) + if (boundedWorkflow) { + payload.workflow = boundedWorkflow + } // Delimiter safety: this payload carries reviewer-authored text // (`details`, `advisories`), so a literal `` inside it // would terminate this tag-delimited block early and every non-greedy @@ -5874,6 +5947,79 @@ ${guideSections} ) } + // Declared-workflow progress for the payload. It lives next + // to boundAdvisoryLines for the same reason: both carry model-authored + // bytes into the CLI's renderer, so the 240-char cap and the + // control-byte strip must stay in lockstep across the two fields. + // + // Returns undefined — the key is then OMITTED ENTIRELY, never emitted as + // a partial or zeroed object — unless every condition holds: + // - both counts are finite non-negative integers (Number.isInteger + // already rejects NaN/Infinity/floats). Corrupt counts would render + // nonsense progress math like `3/NaN` in the CLI. + // - totalCount > 0. A turn with no declared todos legitimately reports + // 0/0; that is not "work remains". + // - completedCount <= totalCount. An over-count is corrupt state. + // - completedCount < totalCount. LOAD-BEARING: emitting on equality + // would report a FINISHED workflow as incomplete on every clean + // turn, which is precisely the false signal this field exists to + // avoid. + // - the sanitized action is non-empty. An action that survives + // sanitization as '' carries no continuation target. + // + // Sanitization ORDER matches the `details` field above and the CLI-side + // `sanitizeGateStateText`: collapse whitespace FIRST (so tabs/newlines + // become spaces instead of vanishing), then strip C0/DEL (an unstripped + // ESC in model-authored text could spoof terminal output), then trim, + // then cap length so the 240-char bound describes the text actually + // shown. The parser sanitizes independently because it also reads + // hand-authored/non-base2 assistant text. + // Type-only alias, declared so BOTH the parameter and the return + // annotation stay SIMPLE (bracket-free) tokens. + // `extractInlineFunctionSource` — which the delimiter-safety test uses to + // reconstruct this inline helper out of the serialized handleSteps body — + // cannot walk a return annotation that opens with a leading `|` union: + // its annotation scan ends at the first whitespace, the body scan then + // mistakes the union member's `{` for the function body, and the slice is + // a body-less signature that TypeScript erases as an overload + // declaration. The helper would then be missing at runtime and every + // formatGateStateBlock call would throw `boundWorkflowProgress is not + // defined`. Types are erased before handleSteps is reconstructed, so this + // alias costs nothing at runtime. + type BoundedWorkflowProgress = { + completedCount: number + totalCount: number + nextWorkflowAction: string + } + function boundWorkflowProgress( + progress?: BoundedWorkflowProgress, + ): BoundedWorkflowProgress | undefined { + if (!progress) return undefined + const { completedCount, totalCount } = progress + if ( + !Number.isInteger(completedCount) || + !Number.isInteger(totalCount) || + completedCount < 0 || + totalCount <= 0 || + completedCount >= totalCount + ) { + return undefined + } + const normalizedAction = String(progress.nextWorkflowAction ?? '') + .replace(/\s+/g, ' ') + .replace(/[\x00-\x1f\x7f]/g, '') + .trim() + if (normalizedAction.length === 0) return undefined + return { + completedCount, + totalCount, + nextWorkflowAction: + normalizedAction.length > 240 + ? `${normalizedAction.slice(0, 237).trimEnd()}...` + : normalizedAction, + } + } + function emitGateTelemetry(payload: Record): void { try { const phase = payload.currentPhase diff --git a/agents/base2/gate-state.ts b/agents/base2/gate-state.ts index 1772dbe7a1..94cd7c0e9d 100644 --- a/agents/base2/gate-state.ts +++ b/agents/base2/gate-state.ts @@ -90,10 +90,42 @@ export type Base2GateState = { gatePassedFileMarkers?: Record /** * Content fingerprint of the reviewable-source subset the last time the - * final code-reviewer gate passed. Used to skip re-review when a - * subsequent turn (e.g. a git-action turn with no new source edits) - * reopens the gate on an unchanged reviewable set. Backward-compatible: - * older serialized state lacks this field (treated as unset). + * final code-reviewer gate passed. + * + * SOFT-DEPRECATED and WRITE-ONLY as of the receipt-driven reviewer skip. + * + * Readers: NONE. There is no production reader of this field anywhere — + * base2.ts only writes it on the gate-pass path and defaults it to `''` when + * hydrating serialized state, and no CLI/renderer/telemetry surface reads it + * (the pinned active-work message and the gate telemetry payload are built + * from `gatePassedFingerprint`, `gatePassedFiles`, `pendingGateFiles`, + * `currentPhase`, and `reviewReceipts`). It is referenced only by test + * fixtures that seed serialized state. + * + * Why it lost its reader: it used to be a required conjunct of the reviewer + * skip, but a single scalar is overwritten on every gate pass, so an earlier + * wave's reviewable set re-arming produced false misses. That decision now + * reads the durable `reviewReceipts` ledger, matching a LOOKS_GOOD receipt by + * its GATE-COMPUTED `gateId` (`${reviewer}:${expectedFingerprint}`) plus the + * reviewed file set, and an attestability check on the current fingerprint. + * The reviewer-reported `snapshotFingerprint` on a receipt is drift-tolerated + * and is deliberately NOT used as content evidence. + * + * Migration/removal path for consumers: + * 1. Do not add new readers. Anything that needs "was this reviewable set + * already reviewed?" must match a `reviewReceipts` entry on `gateId` + + * `reviewedFiles`, exactly like base2's reviewer-skip rule. + * 2. The field stays written for one deprecation window so a session + * serialized by an older base2 keeps round-tripping unchanged (no + * migration step, no rollback risk: it is additive and optional). + * 3. Removal: once no serialized state in circulation is read by a base2 that + * still declares it, drop the write in base2.ts's gate-pass path, drop the + * `??= ''` default, drop this field, and drop the test-fixture seeds. Older + * serialized state stays loadable because unknown persisted keys are + * ignored. + * + * Backward-compatible: older serialized state lacks this field (treated as + * unset). */ reviewedReviewableFingerprint?: string lastReviewerGateSkipReason: string diff --git a/cli/knowledge.md b/cli/knowledge.md index 7c2dd6016b..f5371fc2ae 100644 --- a/cli/knowledge.md +++ b/cli/knowledge.md @@ -890,7 +890,7 @@ Streaming markdown renders as plain text until the message or agent finishes. Th - Release validation runs `bun --cwd=scripts run guard:memory-drift`; if CLI `src/` or tmux interaction behavior changes, refresh `cli/knowledge.md` and/or `cli/tmux.knowledge.md` in the same commit so the staleness guard stays green. - Gate vs Specialist routing canonical matrix lives in `agents/base2/quality-prompt-section.ts` (`specialistRoutingSection`) and `agents/guides/specialist-routing.md`; `docs/agents-and-tools.md` links there for the Params Contract (`snapshot_id` vs `snapshot_fingerprint`). - Index workspace watching now classifies file changes before notifying the index manager: ignored top-level build/cache directories and the configured cache dir are skipped, file updates and deletes are batched as path-specific deltas, ambiguous directory/watch errors mark the index stale, and at most four project roots keep active recursive watchers. -- `cli/src/components/renderers/gate-state-box.tsx` renders `` blocks as a bordered box supporting exactly the four `GateStateStatus` values from `cli/src/types/chat.ts` — `pending` (`…`, warning), `passed` (`✓`, success), `failed` (`✗`, error), and `skipped` (`–`, warning) — with the heading format ` · · `; keep `STATUS_LABEL`, `STATUS_ICON`, and `statusColor` exhaustive when adding a status. The block schema is `gate`/`status` (required) plus optional `details`, `origin`, and `advisories`: `advisories` is an additive array of non-empty reviewer observations that never changes the gate status, is dropped entirely by `parseGateStateBlock` unless every entry is a non-empty string, and renders under an "Advisory (non-blocking):" bulleted list after `details`. base2 bounds it to 8 entries of ≤240 chars per block, so downstream consumers must treat it as optional and already-truncated. +- `cli/src/components/renderers/gate-state-box.tsx` renders `` blocks as a bordered box supporting exactly the four `GateStateStatus` values from `cli/src/types/chat.ts` — `pending` (`…`, warning), `passed` (`✓`, success), `failed` (`✗`, error), and `skipped` (`–`, warning) — with the heading format ` · · `; keep `STATUS_LABEL`, `STATUS_ICON`, and `statusColor` exhaustive when adding a status. The block schema is `gate`/`status` (required) plus optional `details`, `origin`, `advisories`, and `workflow`: `advisories` is an additive array of non-empty reviewer observations that never changes the gate status, is dropped entirely by `parseGateStateBlock` unless every entry is a non-empty string, and renders under an "Advisory (non-blocking):" bulleted list after `details`. base2 bounds it to 8 entries of ≤240 chars per block, so downstream consumers must treat it as optional and already-truncated. `workflow` is an additive `{ completedCount, totalCount, nextWorkflowAction }` object reporting declared `write_todos` progress that was still incomplete when the gate PASSED, so a turn finalizing with outstanding declared work is machine-detectable rather than only discouraged in prose; like `advisories` it is observability only and never changes the gate status. base2 emits it ONLY on the three gate-pass paths (fresh pass, conversation reuse, durable-fingerprint reuse) and only when the counts are finite non-negative integers with `totalCount > 0` and `completedCount < totalCount` and the sanitized action is non-empty — a completed workflow omits the key entirely, so its absence never means "no todos were declared". `nextWorkflowAction` is model-authored text bounded to 240 chars with whitespace collapsed and C0/DEL stripped; `parseGateStateWorkflow` re-enforces every one of those bounds independently (it also parses hand-authored assistant text) and drops the whole object when any fails. It is JSON-payload-only: the legacy `key: value` line form never carries it. - `cli/src/commands/git-command-args.ts` parses user-supplied `/diff` and `/status` arguments with `parseSafeGitArgs`, which rejects shell operators and expansions (newline, `;`, `$`, backtick, `|`, `&`, `<`, `>`, `\`) and unclosed quotes while intentionally allowing `()[]{}` so git pathspec magic such as `:(exclude)` still works; `quoteShellArgument` single-quotes each argument (escaping embedded single quotes) and `buildSafeGitCommand` assembles the final command with a fallback argument list, so route any new git-backed slash command through these helpers instead of interpolating raw input. - `cli/src/utils/sdk-event-handlers.ts` consumes the additive `job_update` print-mode event through a catch-all branch, so unknown future event variants stay no-ops rather than throwing. - `cli/src/data/initial-agent-type-sources.generated.ts` is regenerated by the repository-root `bun scripts/generate-tool-definitions.ts`, so any new public tool schema (e.g. the `occurrence` selector on `replace_range`, or the `windows`/`around`/`symbol` selectors on `read_files`) must land with a regenerated, committed copy of that file — CI verifies it is current. The same freshness check gates the three `tools.ts` type sources (`agents/`, `.agents/`, `common/src/templates/`), so a `list_jobs` description/schema change also requires regenerating and committing those. diff --git a/cli/src/components/__tests__/gate-state-box.test.tsx b/cli/src/components/__tests__/gate-state-box.test.tsx index 1f91134166..bdc87c70c9 100644 --- a/cli/src/components/__tests__/gate-state-box.test.tsx +++ b/cli/src/components/__tests__/gate-state-box.test.tsx @@ -176,6 +176,47 @@ describe('GateStateBox', () => { expect(markup).not.toContain('Advisory') }) + // Declared work remaining while the gate passed is a caution, not an error, + // so both lines render in the warning tone between details and advisories. + test('renders both declared-workflow lines when workflow progress is present', () => { + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('Declared workflow: 1/3 complete') + expect(markup).toContain('2 remaining') + expect(markup).toContain('Next: Implement wave 2 of the refactor') + expect(markup).toContain(theme.warning) + // Ordering: after details, before the advisories section. + expect(markup.indexOf('no blockers')).toBeLessThan( + markup.indexOf('Declared workflow'), + ) + expect(markup.indexOf('Declared workflow')).toBeLessThan( + markup.indexOf('Advisory (non-blocking)'), + ) + }) + + test('renders no declared-workflow lines when workflow is absent', () => { + const block = makeBlock({ gateStatus: 'passed', details: 'no blockers' }) + delete block.workflow + const markup = renderToStaticMarkup() + + expect(markup).toContain('no blockers') + expect(markup).not.toContain('Declared workflow') + expect(markup).not.toContain('Next:') + }) + test('uses error color for failed status', () => { const failedMarkup = renderToStaticMarkup( , diff --git a/cli/src/components/renderers/gate-state-box.tsx b/cli/src/components/renderers/gate-state-box.tsx index e87daf6db2..e1cb6db587 100644 --- a/cli/src/components/renderers/gate-state-box.tsx +++ b/cli/src/components/renderers/gate-state-box.tsx @@ -33,6 +33,17 @@ const STATUS_TONE: Record< skipped: 'secondary', } +/** + * Renders a parsed `` block. + * + * PUBLISHED BLOCK SCHEMA (the rendered surface of the consumer contract + * declared on `GateStateContentBlock` in cli/src/types/chat.ts): `gate` and + * `status` are required; `details`, `origin`, `advisories`, and `workflow` are + * optional and additive. Rendered order is details -> `workflow` (declared + * write_todos progress remaining on a PASSING gate, warning tone) -> + * `advisories` (non-blocking reviewer observations, secondary tone). Keep this + * list, the type, and the parser docblock in step when the producer adds a key. + */ export const GateStateBox = memo(({ block }: GateStateBoxProps) => { const theme = useTheme() const color = theme[STATUS_TONE[block.gateStatus]] @@ -65,6 +76,26 @@ export const GateStateBox = memo(({ block }: GateStateBoxProps) => { {block.details} ) : null} + {block.workflow ? ( + <> + + {`Declared workflow: ${block.workflow.completedCount}/${block.workflow.totalCount} complete — ${block.workflow.totalCount - block.workflow.completedCount} remaining`} + + + {`Next: ${block.workflow.nextWorkflowAction}`} + + + ) : null} {block.advisories && block.advisories.length > 0 ? ( <> ` block. + * + * PUBLISHED BLOCK SCHEMA (canonical consumer contract, kept in step with the + * producer `formatGateStateBlock` in agents/base2/base2.ts and the parser + * `parseGateStateBlock` in cli/src/utils/message-block-helpers.ts): `gate` and + * `status` are required; `details`, `origin`, `advisories`, and `workflow` are + * optional and additive, so a block persisted before one of them existed + * replays unchanged. Any new producer key MUST be added here, to the parser + * docblock, and to the renderer, or downstream consumers parse a format this + * contract does not describe. The prose summary in cli/knowledge.md is a + * pointer to this contract, not a second source of truth; refresh it whenever + * this enumeration changes. + */ export type GateStateContentBlock = { type: 'gate-state' gate: string @@ -163,6 +177,25 @@ export type GateStateContentBlock = { * cannot terminate the tag-delimited block early. */ advisories?: string[] + /** + * Declared write_todos workflow progress reported alongside the gate result. + * Present ONLY when the gate PASSED while declared workflow work still + * remained, so a turn that finalizes with outstanding declared items is + * distinguishable from a genuinely complete one. Observability only: no gate + * phase, finalization decision, or follow-up permission reads it. + * + * Bounded by contract: base2's `formatGateStateBlock` emits a + * `nextWorkflowAction` of at most 240 characters and emits the field at all + * only when `completedCount < totalCount` (work actually remains), and the + * CLI parser (`parseGateStateWorkflow`) enforces the same bounds, dropping + * the field whole when arbitrary assistant text violates them. Optional, so + * blocks persisted before it existed replay unchanged. + */ + workflow?: { + completedCount: number + totalCount: number + nextWorkflowAction: string + } } export type CompletionSummaryContentBlock = { diff --git a/cli/src/utils/__tests__/message-block-helpers.test.ts b/cli/src/utils/__tests__/message-block-helpers.test.ts index ada39ffdae..a54e40a612 100644 --- a/cli/src/utils/__tests__/message-block-helpers.test.ts +++ b/cli/src/utils/__tests__/message-block-helpers.test.ts @@ -2221,6 +2221,218 @@ describe('parseGateStateBlock', () => { }) }) + test('parses declared workflow progress from the JSON payload form', () => { + const buffer = `${JSON.stringify({ + gate: 'validation/reviewer', + status: 'passed', + details: 'no blockers', + workflow: { + completedCount: 1, + totalCount: 3, + nextWorkflowAction: 'Implement wave 2 of the refactor', + }, + })}` + expect(parseGateStateBlock(buffer)).toEqual({ + type: 'gate-state', + gate: 'validation/reviewer', + gateStatus: 'passed', + details: 'no blockers', + workflow: { + completedCount: 1, + totalCount: 3, + nextWorkflowAction: 'Implement wave 2 of the refactor', + }, + origin: 'Base2', + }) + }) + + // Published consumer contract: the documented `` schema is + // gate/status plus optional details, origin, advisories, and workflow. A + // payload carrying EVERY documented key must parse into exactly those keys, + // and producer-only keys the contract declares as ignored (repairRound / + // maxRepairRounds) must not leak into the block. + test('parses every key of the published gate-state schema and ignores producer-only keys', () => { + const buffer = `${JSON.stringify({ + gate: 'validation/reviewer', + status: 'passed', + details: 'no blockers', + origin: 'Base2', + advisories: ['naming nit in helper'], + workflow: { + completedCount: 1, + totalCount: 3, + nextWorkflowAction: 'Implement wave 2 of the refactor', + }, + repairRound: 2, + maxRepairRounds: 5, + })}` + const parsed = parseGateStateBlock(buffer) + expect(parsed).toEqual({ + type: 'gate-state', + gate: 'validation/reviewer', + gateStatus: 'passed', + details: 'no blockers', + advisories: ['naming nit in helper'], + workflow: { + completedCount: 1, + totalCount: 3, + nextWorkflowAction: 'Implement wave 2 of the refactor', + }, + origin: 'Base2', + }) + expect(Object.keys(parsed!).sort()).toEqual([ + 'advisories', + 'details', + 'gate', + 'gateStatus', + 'origin', + 'type', + 'workflow', + ]) + }) + + test('sanitizes control characters in the workflow action', () => { + const buffer = `${JSON.stringify({ + gate: 'validation/reviewer', + status: 'passed', + workflow: { + completedCount: 1, + totalCount: 2, + nextWorkflowAction: 'continue \u001b[31mwave 2\u0000\tnow\u007f', + }, + })}` + const parsed = parseGateStateBlock(buffer) + expect(parsed!.workflow).toEqual({ + completedCount: 1, + totalCount: 2, + nextWorkflowAction: 'continue [31mwave 2 now', + }) + expect( + /[\u0000-\u001f\u007f]/.test(parsed!.workflow!.nextWorkflowAction), + ).toBe(false) + }) + + // Fail-closed drop-whole rule, mirroring parseGateStateAdvisories: a partial + // or zeroed object would render nonsense progress math, and admitting + // completedCount === totalCount would report a finished workflow as + // incomplete on every clean turn. + test('drops the workflow field whole when the payload violates the producer bounds', () => { + const withWorkflow = (workflow: unknown) => + `${JSON.stringify({ + gate: 'ci', + status: 'passed', + details: 'ok', + workflow, + })}` + const expected = { + type: 'gate-state' as const, + gate: 'ci', + gateStatus: 'passed' as const, + details: 'ok', + origin: 'Base2', + } + const invalidWorkflows: unknown[] = [ + // Not a record. + 'incomplete', + ['incomplete'], + 42, + // Missing counts. + { nextWorkflowAction: 'Implement wave 2' }, + { completedCount: 1, nextWorkflowAction: 'Implement wave 2' }, + { totalCount: 3, nextWorkflowAction: 'Implement wave 2' }, + // Non-finite / non-integer / negative counts. + { + completedCount: Number.NaN, + totalCount: 3, + nextWorkflowAction: 'Implement wave 2', + }, + { + completedCount: 1, + totalCount: Number.POSITIVE_INFINITY, + nextWorkflowAction: 'Implement wave 2', + }, + { + completedCount: 1.5, + totalCount: 3, + nextWorkflowAction: 'Implement wave 2', + }, + { + completedCount: -1, + totalCount: 3, + nextWorkflowAction: 'Implement wave 2', + }, + // No declared todos at all. + { + completedCount: 0, + totalCount: 0, + nextWorkflowAction: 'Implement wave 2', + }, + // Work no longer remains. + { + completedCount: 3, + totalCount: 3, + nextWorkflowAction: 'Implement wave 2', + }, + // Corrupt over-count. + { + completedCount: 4, + totalCount: 3, + nextWorkflowAction: 'Implement wave 2', + }, + // Empty / whitespace-only / controls-only action. + { completedCount: 1, totalCount: 3, nextWorkflowAction: '' }, + { completedCount: 1, totalCount: 3, nextWorkflowAction: ' ' }, + { + completedCount: 1, + totalCount: 3, + nextWorkflowAction: '\u0000\u001b\u007f', + }, + // Non-string action. + { completedCount: 1, totalCount: 3, nextWorkflowAction: 42 }, + // Over the 240-char bound. + { + completedCount: 1, + totalCount: 3, + nextWorkflowAction: 'a'.repeat(241), + }, + ] + for (const workflow of invalidWorkflows) { + expect(parseGateStateBlock(withWorkflow(workflow))).toEqual(expected) + } + }) + + test('keeps a workflow action exactly at the producer cap', () => { + const nextWorkflowAction = 'b'.repeat(240) + const buffer = `${JSON.stringify({ + gate: 'ci', + status: 'passed', + workflow: { completedCount: 0, totalCount: 1, nextWorkflowAction }, + })}` + expect(parseGateStateBlock(buffer)!.workflow).toEqual({ + completedCount: 0, + totalCount: 1, + nextWorkflowAction, + }) + }) + + // Parse contract: workflow is a JSON-payload-only field, exactly like + // advisories. The legacy line form never carried it. + test('does not read workflow from the legacy line form', () => { + const buffer = [ + '', + 'gate: ci', + 'status: passed', + 'workflow: 1/3 complete', + '', + ].join('\n') + expect(parseGateStateBlock(buffer)).toEqual({ + type: 'gate-state', + gate: 'ci', + gateStatus: 'passed', + origin: 'Base2', + }) + }) + test('returns null for JSON that is not a gate-state object', () => { expect( parseGateStateBlock('["gate","status"]'), diff --git a/cli/src/utils/message-block-helpers.ts b/cli/src/utils/message-block-helpers.ts index 11909179f0..1b80f53353 100644 --- a/cli/src/utils/message-block-helpers.ts +++ b/cli/src/utils/message-block-helpers.ts @@ -67,6 +67,13 @@ const sanitizeGateStateText = (value: string): string => */ const MAX_GATE_STATE_ADVISORIES = 8 const MAX_GATE_STATE_ADVISORY_LENGTH = 240 +/** + * Mirrors base2's `boundWorkflowProgress`, which caps the emitted + * `nextWorkflowAction` at 240 characters. Same reason as the advisory bounds: + * this parser also reads hand-authored/non-base2 assistant text, so it enforces + * the producer's cap itself instead of trusting the payload. + */ +const MAX_GATE_STATE_WORKFLOW_ACTION_LENGTH = 240 /** * Accept advisories only when they are an array of non-empty strings within the @@ -93,6 +100,52 @@ const parseGateStateAdvisories = (value: unknown): string[] => { : [] } +/** + * Accept declared-workflow progress only when the producer's contract holds: + * a record whose `completedCount`/`totalCount` are finite non-negative integers + * (`Number.isInteger` already rejects NaN/Infinity/floats) with + * `totalCount > 0` and `completedCount < totalCount` — work must actually + * remain — and whose `nextWorkflowAction` is a non-empty string within + * MAX_GATE_STATE_WORKFLOW_ACTION_LENGTH characters AFTER sanitization. + * + * Anything else drops the field WHOLE, fail-closed exactly like + * `parseGateStateAdvisories`: a partial or zeroed object would render nonsense + * progress math, and admitting `completedCount === totalCount` would report a + * finished workflow as incomplete on every clean turn — the false signal this + * field exists to avoid. + * + * The action is sanitized BEFORE the non-empty and length checks (whitespace + * collapsed first, then C0/DEL stripped), so a hand-authored payload cannot + * smuggle ANSI escapes into the opentui `` renderer, and an action made + * only of control characters becomes empty and drops the field. + */ +const parseGateStateWorkflow = ( + value: unknown, +): GateStateContentBlock['workflow'] => { + if (!isRecordValue(value)) return undefined + const { completedCount, totalCount } = value + if ( + typeof completedCount !== 'number' || + typeof totalCount !== 'number' || + !Number.isInteger(completedCount) || + !Number.isInteger(totalCount) || + completedCount < 0 || + totalCount <= 0 || + completedCount >= totalCount + ) { + return undefined + } + if (typeof value.nextWorkflowAction !== 'string') return undefined + const nextWorkflowAction = sanitizeGateStateText(value.nextWorkflowAction) + if ( + nextWorkflowAction.length === 0 || + nextWorkflowAction.length > MAX_GATE_STATE_WORKFLOW_ACTION_LENGTH + ) { + return undefined + } + return { completedCount, totalCount, nextWorkflowAction } +} + /** * Build a gate-state block from an already-parsed JSON payload whose `gate` is * a non-empty string. Returns null when the status is not one of the pinned @@ -114,6 +167,7 @@ const buildGateStateFromJson = ( : '' const origin = typeof payload.origin === 'string' ? payload.origin.trim() : '' const advisories = parseGateStateAdvisories(payload.advisories) + const workflow = parseGateStateWorkflow(payload.workflow) return { type: 'gate-state', @@ -121,6 +175,7 @@ const buildGateStateFromJson = ( gateStatus: status, ...(details ? { details } : {}), ...(advisories.length > 0 ? { advisories } : {}), + ...(workflow ? { workflow } : {}), origin: origin || 'Base2', } } @@ -128,10 +183,26 @@ const buildGateStateFromJson = ( /** * Parse the pinned Base2 gate-state shape from a message buffer. * + * PUBLISHED BLOCK SCHEMA (canonical parse contract for downstream consumers; + * this docblock and `GateStateContentBlock` in cli/src/types/chat.ts are the + * published enumeration, and both must list every key the producer emits): + * - `gate` (string, required) + * - `status` (required): pending | passed | failed | skipped + * - `details` (string, optional) + * - `origin` (string, optional; defaults to "Base2") + * - `advisories` (string[], optional; non-blocking reviewer observations) + * - `workflow` (object, optional; declared write_todos progress on a PASSING + * gate, as `{ completedCount, totalCount, nextWorkflowAction }`) + * Producers may also emit `repairRound`/`maxRepairRounds`, which this parser + * ignores. Every optional key is additive: a block persisted before a key + * existed replays unchanged. + * * Base2 emits a JSON payload, which is tried first: * * {"gate":"validation/reviewer","status":"passed", - * "details":"...","origin":"Base2","advisories":["..."]} + * "details":"...","origin":"Base2","advisories":["..."], + * "workflow":{"completedCount":1,"totalCount":3, + * "nextWorkflowAction":"..."}} * * The legacy line form remains supported (case-insensitive on keys/status, * narrow on purpose): @@ -161,6 +232,11 @@ const buildGateStateFromJson = ( * line form and the JSON payload form agree on gate/status/details/origin. * - `advisories` is a JSON-payload-only field. The line form never carried it, * so an `advisories:` line stays an unrecognized key and yields no advisories. + * - `workflow` (declared write_todos progress on a PASSING gate) is likewise + * JSON-payload-only, and is admitted only within the producer's bounds: a + * 240-char sanitized `nextWorkflowAction` plus finite non-negative integer + * counts with `totalCount > 0` and `completedCount < totalCount`. A violating + * payload drops the field whole rather than rendering partial progress math. * - Advisories are admitted only within the producer's bounds * (MAX_GATE_STATE_ADVISORIES entries, MAX_GATE_STATE_ADVISORY_LENGTH chars per * entry); an out-of-bounds or otherwise malformed list is dropped whole and diff --git a/common/knowledge.md b/common/knowledge.md index 8185805bec..a0d76f722e 100644 --- a/common/knowledge.md +++ b/common/knowledge.md @@ -65,6 +65,8 @@ This package contains code shared across the Openbuff monorepo, especially the l - _Knowledge refresh 2026-08-31 (external read roots): `common/src/util/project-path-containment.ts` gained the read-only `external-read` scope, its default-closed configure-once registry (`configureExternalReadRoots` / `ensureExternalReadRootsConfigured` / `getExternalReadRoots` / `resetExternalReadRootsForTesting`), the `isExternalReadPath` predicate, and the `resolveProjectPathForRead` / `resolveProjectPathForFileSystemRead` entry points; `common/src/util/sensitive-paths.ts` gained the openbuff credential basenames plus the path-aware credential carriers. Consumers: the four SDK read handlers (`read-files`, `read-logs`, `read-image`, `list-directory`) via `sdk/src/tools/path-utils.ts` read-only resolvers, the `readableRoots` config field and its provenance-based trust gate in `sdk/src/provider-config.ts` + `sdk/src/run.ts` (`selectTrustedReadableRoots`, gated by `OPENBUFF_TRUST_PROJECT_READABLE_ROOTS` because project config wins the config merge), and the tool-scoped backstop exemption in `packages/agent-runtime/src/tools/tool-executor.ts` (`EXTERNAL_READ_EXEMPT_TOOLS`)._ +- _Knowledge refresh 2026-09-01 (request-time context trim): `common/src/types/print-mode.ts` gained the additive `context_request_trim` variant reporting the SDK's request-time emergency trim — the last-line-of-defense drop applied at dispatch when a request's messages still exceed the provider-safe budget after every runtime brake ran. It carries required `messageBudgetTokens`/`beforeTokens`/`afterTokens`/`beforeMessages`/`afterMessages` plus optional `runId`, `ancestorRunIds`, `agentId`, `resolvedContextWindowTokens`, and `model`. It is a DIFFERENT brake from `context_compaction` and its `context_compaction_status` pair, so the two must never be merged or counted as one pass. The existing `context_window` variant gained optional `compactionTriggerTokens`/`compactionTargetTokens` reporting the runtime's model-aware semantic-compaction budget; both are derived from the RAW resolved model window and are deliberately NOT clamped by `maxContextLength`, so `compactionTriggerTokens > max` is a legitimate payload and a consumer rendering trigger against `max` must clamp or suppress it itself. `common/src/types/contracts/llm.ts` re-exports the `RequestContextTrimInfo` payload type (declared in `print-mode`) for the optional `onRequestContextTrimmed` callback on the published `promptAiSdk`/`promptAiSdkStream`/`promptAiSdkStructured` signatures: purely observational, fires only when the trim actually dropped messages, can never affect the trim result, and a throwing consumer is caught and logged rather than aborting dispatch. All three additions are additive/optional, so callers that omit them and consumers that ignore unknown `event.type` values keep their previous behavior._ + ## Scope Notes Openbuff is CLI/SDK-focused and local/BYOK. Do not add new dependencies from `common/` to hosted web, billing, credit, subscription, or BigQuery product surfaces. Provider-owned billing, quota, token usage, and OAuth flows may still be documented when they refer to the user's configured provider rather than an Openbuff-hosted product.