diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx index 858aa5867a..2296727100 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -1432,3 +1432,48 @@ export const GoalDialogOpen: Story = { ), }; + +// Real path (#3587): an explicit compaction runs as its own host Turn. The +// transcript shows a live "正在压缩上下文…" row driven by the live Turn snapshot +// (rootExecutionKind: 'context_compact'), with no assistant content of its own. +export const ContextCompactionRunning: Story = { + render: () => ( + + ), +}; + +// Real path (#3587): the compaction Turn ends. The live row settles into the +// durable `context_compacted` system note, rendered in transcript order. +export const ContextCompactionCompacted: Story = { + render: () => ( + + ), +}; diff --git a/packages/core/src/backend-types.ts b/packages/core/src/backend-types.ts index 8fbdc0bda5..1c3c4baf20 100644 --- a/packages/core/src/backend-types.ts +++ b/packages/core/src/backend-types.ts @@ -201,6 +201,7 @@ export type BackendSessionEvent = Exclude< type: | 'queue_update' | 'message_admission' + | 'context_compaction_started' | 'permission_request' | 'permission_answer_ack' | 'permission_closure_ack' diff --git a/packages/core/src/events.ts b/packages/core/src/events.ts index 94d57b5efa..3e3125cf4d 100644 --- a/packages/core/src/events.ts +++ b/packages/core/src/events.ts @@ -535,7 +535,8 @@ export type SessionEvent = | ProviderRetryEvent | ErrorEvent | CompleteEvent - | AbortEvent; + | AbortEvent + | ContextCompactionStartedEvent; export interface TextDeltaEvent extends BaseEvent { type: 'text_delta'; @@ -1239,6 +1240,16 @@ export interface AbortEvent extends BaseEvent { reason: 'user_stop' | 'redirect' | 'timeout' | 'crash'; } +/** + * A host-owned explicit context-compaction Turn has started. Synthesized by the + * Runtime Host session projector (not the kernel) purely so a client can render + * a "compacting" transcript row while the Turn is in flight; it carries no + * durable state and is excluded from `BackendSessionEvent` like `queue_update`. + */ +export interface ContextCompactionStartedEvent extends BaseEvent { + type: 'context_compaction_started'; +} + // ============================================================================ // UI → Backend commands // ============================================================================ diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index 8027113f06..696031adfd 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -397,6 +397,10 @@ describe('Runtime Host bootstrap protocol', () => { assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 79); }); + test('publishes a new compatibility epoch for context-compaction transcript state', () => { + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 83); + }); + test('selects the highest mutually supported protocol and rejects a gap', () => { assert.equal(negotiateProtocol({ min: 0, max: 0 }, { min: 0, max: 0 }), 0); assert.equal(negotiateProtocol({ min: 1, max: 3 }, { min: 2, max: 4 }), 3); diff --git a/packages/runtime-host/src/__tests__/session-projector.test.ts b/packages/runtime-host/src/__tests__/session-projector.test.ts index 5fbcce8395..dd9672c655 100644 --- a/packages/runtime-host/src/__tests__/session-projector.test.ts +++ b/packages/runtime-host/src/__tests__/session-projector.test.ts @@ -834,3 +834,128 @@ test('live tool_start keeps intent and argsPreview, and never fabricates args', assert.deepEqual(event.argsPreview, { command: 'git status --porcelain' }); assert.equal(event.args, undefined); }); + +test('seeds a context-compaction-started event for a running compaction Turn', () => { + const projector = new RuntimeHostSessionProjector( + snapshot({ + rootTurn: { + sessionId: 'session-1', + turnId: 'turn-compact', + runId: 'run-compact', + status: 'running', + rootExecutionKind: 'context_compact', + }, + }), + createRuntimeHostSessionProjectionSeed([], snapshot()), + () => 10, + ); + const seeded = projector.seedActive(true); + assert.equal(seeded.length, 1); + assert.equal(seeded[0]?.type, 'context_compaction_started'); + assert.equal(seeded[0]?.turnId, 'turn-compact'); +}); + +test('emits a context-compaction-started event when a compaction Turn starts', () => { + const projector = new RuntimeHostSessionProjector( + snapshot(), + createRuntimeHostSessionProjectionSeed([], snapshot()), + () => 10, + ); + const events = projector.accept({ + kind: 'subscription.session_projection', + hostEpoch: 'host-1', + subscriptionId: 'subscription-1', + sequence: 1, + snapshot: snapshot({ + projectionRevision: 2, + rootTurn: { + sessionId: 'session-1', + turnId: 'turn-compact', + runId: 'run-compact', + status: 'running', + rootExecutionKind: 'context_compact', + }, + }), + }).events; + assert.ok( + events.some( + (event) => event.type === 'context_compaction_started' && event.turnId === 'turn-compact', + ), + ); +}); + +test('emits context-compaction-started on the admitted → running transition at one runId', () => { + // The real lifecycle keeps the same runId: `admitted` (no rootExecutionKind) + // then `running` / context_compact. Gating on a runId change would miss this + // and only surface the row on reconnect. + const projector = new RuntimeHostSessionProjector( + snapshot({ + rootTurn: { + sessionId: 'session-1', + turnId: 'turn-compact', + runId: 'run-compact', + status: 'admitted', + }, + }), + createRuntimeHostSessionProjectionSeed([], snapshot()), + () => 10, + ); + const events = projector.accept({ + kind: 'subscription.session_projection', + hostEpoch: 'host-1', + subscriptionId: 'subscription-1', + sequence: 1, + snapshot: snapshot({ + projectionRevision: 2, + rootTurn: { + sessionId: 'session-1', + turnId: 'turn-compact', + runId: 'run-compact', + status: 'running', + rootExecutionKind: 'context_compact', + }, + }), + }).events; + assert.equal(events.filter((event) => event.type === 'context_compaction_started').length, 1); +}); + +test('projects the typed context-compaction outcome onto the completed Turn event', () => { + const projector = new RuntimeHostSessionProjector( + snapshot({ + rootTurn: { + sessionId: 'session-1', + turnId: 'turn-compact', + runId: 'run-compact', + status: 'running', + rootExecutionKind: 'context_compact', + }, + }), + createRuntimeHostSessionProjectionSeed([], snapshot()), + () => 10, + ); + const events = projector.accept({ + kind: 'subscription.session_projection', + hostEpoch: 'host-1', + subscriptionId: 'subscription-1', + sequence: 1, + snapshot: snapshot({ + projectionRevision: 2, + rootTurn: { + sessionId: 'session-1', + turnId: 'turn-compact', + runId: 'run-compact', + status: 'completed', + terminalEventId: 'terminal-1', + contextCompactionOutcome: { kind: 'compacted', checkpointId: 'checkpoint-1' }, + }, + }), + }).events; + const complete = events.find((event) => event.type === 'complete'); + assert.ok(complete); + assert.deepEqual( + complete && 'contextCompactionOutcome' in complete + ? complete.contextCompactionOutcome + : undefined, + { kind: 'compacted', checkpointId: 'checkpoint-1' }, + ); +}); diff --git a/packages/runtime-host/src/adapter/session-projector.ts b/packages/runtime-host/src/adapter/session-projector.ts index 0288be35ef..230bbf2933 100644 --- a/packages/runtime-host/src/adapter/session-projector.ts +++ b/packages/runtime-host/src/adapter/session-projector.ts @@ -18,7 +18,11 @@ */ import { isDeepStrictEqual } from 'node:util'; -import type { ActiveInteractionRequestEvent, SessionEvent } from '@maka/core/events'; +import type { + ActiveInteractionRequestEvent, + ContextCompactionStartedEvent, + SessionEvent, +} from '@maka/core/events'; import type { StoredMessage, TurnRecord } from '@maka/core/session'; import type { InteractionPendingSnapshot, @@ -172,6 +176,11 @@ export class RuntimeHostSessionProjector { ); } if (isRuntimeHostTerminalTurn(root)) return events; + // Re-derive the running compaction row on reconnect / restart: the Host keeps + // the compaction Turn alive, so a reconnecting client learns of it here. + if (root.rootExecutionKind === 'context_compact') { + events.push(contextCompactionStartedEvent(root, this.#now())); + } let seededAssistantText = false; if (includeAssistantText) { for (const accumulator of this.#accumulators.values()) { @@ -437,6 +446,20 @@ export class RuntimeHostSessionProjector { events.push(projectQueueUpdate(next.queue, root.turnId, this.#now())); } if (startedTurn) this.#accumulators.clear(); + // Emit the presentation-only compaction-started event when the root Turn + // FIRST becomes a `context_compact` run, not only when the runId changes. + // The real lifecycle is `admitted (no rootExecutionKind) → running/ + // context_compact` at the SAME runId, so gating on startedTurn would miss + // the live transition and only surface the row on reconnect via seedActive. + const rootIsCompaction = + !!root && !isRuntimeHostTerminalTurn(root) && root.rootExecutionKind === 'context_compact'; + const previousWasCompaction = + !!previousRoot && + !isRuntimeHostTerminalTurn(previousRoot) && + previousRoot.rootExecutionKind === 'context_compact'; + if (root && rootIsCompaction && !previousWasCompaction) { + events.push(contextCompactionStartedEvent(root, this.#now())); + } const retry = liveProviderRetryEvent(previousRoot, root, this.#now()); if (retry) events.push(retry); const terminalTurn = @@ -473,6 +496,13 @@ export class RuntimeHostSessionProjector { turnId: root.turnId, ts: this.#now(), stopReason: 'end_turn', + // Forward the typed compaction outcome already carried by the canonical + // Turn snapshot so the renderer can settle the running toast and show the + // terminal state. This projects an existing snapshot field (no turn-state + // persistence), so checkpointId stays a string. + ...(root.contextCompactionOutcome + ? { contextCompactionOutcome: root.contextCompactionOutcome } + : {}), }); } else if (root.status === 'failed') { events.push({ @@ -541,6 +571,24 @@ function projectMessageRetractionEvents( })); } +/** + * Presentation-only event that drives the renderer's live "compacting" row. + * Emitted on both the live transition (`accept`) and reconnect (`seedActive`) + * with a deterministic id keyed on the run, so a reconnect re-emits it + * idempotently. + */ +function contextCompactionStartedEvent( + turn: { runId: string; turnId: string }, + now: number, +): ContextCompactionStartedEvent { + return { + type: 'context_compaction_started', + id: `host-compaction-started:${turn.runId}`, + turnId: turn.turnId, + ts: now, + }; +} + export function projectRuntimeHostInteractionRequest( interaction: InteractionPendingSnapshot, now: number, diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 9de1adca70..4a151965c0 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -95,7 +95,10 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 83 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 84 as const; +// 84: Live Turn snapshots carry an optional `rootExecutionKind:'context_compact'` +// so a running context-compaction Turn can render a transcript row. Epoch-83 +// peers reject the added optional field on the strict live snapshot shape. // 83: WorkHub Coordination actions add linked replacement proposals, // destructive user confirmation, and replacement results. Older peers reject // these closed action and result shapes. diff --git a/packages/runtime-host/src/protocol/turn.ts b/packages/runtime-host/src/protocol/turn.ts index def4e1a4bc..661e63ddc7 100644 --- a/packages/runtime-host/src/protocol/turn.ts +++ b/packages/runtime-host/src/protocol/turn.ts @@ -189,6 +189,14 @@ export type TurnProviderRetry = export type LiveTurnSnapshot = TurnSnapshotBase & { status: Exclude; providerRetry?: TurnProviderRetry; + /** + * Set when this live Turn is a host-owned explicit context-compaction run, so + * the renderer can show a "compacting" transcript row while it is in flight. + * Sourced from `AgentRunHeader.rootExecutionKind`; a `context_compact` Turn + * emits no assistant text, and this survives a Desktop reconnect because the + * Host re-projects the live snapshot. + */ + rootExecutionKind?: 'context_compact'; }; export type TurnSnapshot = @@ -652,6 +660,13 @@ function requirePositiveCount(value: unknown, label: string): number { return count; } +function requireContextCompactRootExecutionKind(value: unknown): 'context_compact' { + if (value !== 'context_compact') { + throw invalidProtocolFrame('Invalid Turn rootExecutionKind'); + } + return value; +} + export function decodeTurnSnapshot(value: unknown): TurnSnapshot { const record = requireRecord(value, 'Turn snapshot'); const base = { @@ -731,7 +746,7 @@ export function decodeTurnSnapshot(value: unknown): TurnSnapshot { record, 'non-terminal Turn snapshot', ['sessionId', 'turnId', 'runId', 'status'], - ['providerRetry'], + ['providerRetry', 'rootExecutionKind'], ); return { ...base, @@ -739,6 +754,9 @@ export function decodeTurnSnapshot(value: unknown): TurnSnapshot { ...(record.providerRetry !== undefined ? { providerRetry: decodeTurnProviderRetry(record.providerRetry) } : {}), + ...(record.rootExecutionKind !== undefined + ? { rootExecutionKind: requireContextCompactRootExecutionKind(record.rootExecutionKind) } + : {}), }; } diff --git a/packages/runtime-host/src/server/canonical-turn-snapshot.ts b/packages/runtime-host/src/server/canonical-turn-snapshot.ts index de00dfbace..af13941e10 100644 --- a/packages/runtime-host/src/server/canonical-turn-snapshot.ts +++ b/packages/runtime-host/src/server/canonical-turn-snapshot.ts @@ -115,7 +115,15 @@ export async function readCanonicalTurnSnapshot( if (run.status !== 'created' && !runEvents.some((event) => event.type === 'run_started')) { throw new Error('Non-created Run has no durable start fact'); } - return { sessionId, turnId, runId, status: run.status }; + return { + sessionId, + turnId, + runId, + status: run.status, + ...(run.rootExecutionKind === 'context_compact' + ? { rootExecutionKind: run.rootExecutionKind } + : {}), + }; } function readContextBudgetExhaustedDetail( diff --git a/packages/runtime/src/__tests__/context-budget.test.ts b/packages/runtime/src/__tests__/context-budget.test.ts index d5ad6e5b8c..98a8bad414 100644 --- a/packages/runtime/src/__tests__/context-budget.test.ts +++ b/packages/runtime/src/__tests__/context-budget.test.ts @@ -21,7 +21,13 @@ import assert from 'node:assert/strict'; import { createHash } from 'node:crypto'; import { test } from 'node:test'; import type { RuntimeEvent } from '@maka/core/runtime-event'; -import { applyRuntimeEventContextBudget } from '../context-budget.js'; +import type { CompactionDecisionDiagnostic } from '@maka/core/usage-stats/types'; +import { + applyRuntimeEventContextBudget, + minimalContextBudgetDiagnostic, + shouldAppendContextCompactedNote, + shouldAppendContextCompactionFailedOpenNote, +} from '../context-budget.js'; import { estimateRuntimeEventsTokens } from '../model-history.js'; import { buildHistoryCompactCheckpoint } from '../history-compact-checkpoint.js'; @@ -109,3 +115,79 @@ function toolResultEvent(id: string, result: string): RuntimeEvent { content: { kind: 'function_response', id: 'tool-call', name: 'Bash', result }, }; } + +function budgetWith(decision: Partial) { + return { + ...minimalContextBudgetDiagnostic(), + compactionDecisions: [ + { + sourceKind: 'runtimeEvents', + boundaryKind: 'historyCompact', + ...decision, + } as CompactionDecisionDiagnostic, + ], + }; +} + +test('context-compacted note fires only for a fold performed this turn', () => { + // Fresh folds this turn → write the note. + assert.equal( + shouldAppendContextCompactedNote( + budgetWith({ stage: 'priorReplay', decision: 'replaced', phase: 'pre_turn' }), + ), + true, + ); + assert.equal( + shouldAppendContextCompactedNote( + budgetWith({ stage: 'activeStep', decision: 'replaced', phase: 'mid_turn' }), + ), + true, + ); + assert.equal( + shouldAppendContextCompactedNote( + budgetWith({ stage: 'activeStep', decision: 'replaced', phase: 'pre_turn' }), + ), + true, + ); + // Passive replay of an already-recorded checkpoint on a later turn → suppress. + assert.equal( + shouldAppendContextCompactedNote(budgetWith({ stage: 'priorReplay', decision: 'replaced' })), + false, + ); + assert.equal( + shouldAppendContextCompactedNote( + budgetWith({ stage: 'priorReplay', decision: 'replaced', phase: 'mid_turn' }), + ), + false, + ); + // Non-replaced / non-historyCompact decisions never write the note. + assert.equal( + shouldAppendContextCompactedNote( + budgetWith({ stage: 'priorReplay', decision: 'unchanged', phase: 'pre_turn' }), + ), + false, + ); + assert.equal(shouldAppendContextCompactedNote(undefined), false); +}); + +test('context-compaction failed-open note fires only for a fold attempted this turn', () => { + assert.equal( + shouldAppendContextCompactionFailedOpenNote( + budgetWith({ stage: 'priorReplay', decision: 'failedOpen', phase: 'pre_turn' }), + ), + true, + ); + assert.equal( + shouldAppendContextCompactionFailedOpenNote( + budgetWith({ stage: 'activeStep', decision: 'failedOpen', phase: 'mid_turn' }), + ), + true, + ); + // Passive replay-failedOpen (no phase) must not re-emit every turn. + assert.equal( + shouldAppendContextCompactionFailedOpenNote( + budgetWith({ stage: 'priorReplay', decision: 'failedOpen' }), + ), + false, + ); +}); diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index 4d77f850d0..ff246ff14d 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -3565,6 +3565,120 @@ describe('SessionManager manual compaction and quiescent session changes', () => assert.strictEqual(warnings.length, 1); }); + test('persists exactly one context_compacted note when manual compaction succeeds', async () => { + const store = new MemorySessionStore(); + const runStore = new MemoryAgentRunStore(); + const backends = new BackendRegistry(); + const compactCalls: Array<{ turnId: string; runtimeContextCount: number }> = []; + backends.register('ai-sdk', (ctx) => new CompactingTestBackend(ctx, compactCalls)); + const manager = new SessionManager({ + store, + runStore, + runtimeEventStore: runStore, + backends, + newId: nextId(), + now: nextNow(12_000), + }); + const session = await manager.createSession(makeInput({ permissionMode: 'bypass' })); + + await drain(manager.sendMessage(session.id, { turnId: 'turn-1', text: 'hello' })); + await drain(manager.compactSession(session.id, { turnId: 'turn-compact' })); + + const notes = (await store.readMessages(session.id)).filter( + (message) => + message.type === 'system_note' && + message.turnId === 'turn-compact' && + message.kind === 'context_compacted', + ); + // The kernel writes the durable note on the compaction turn itself, so the + // row appears the moment compaction ends — not one send later. + assert.strictEqual(notes.length, 1); + // And no duplicate failed-open note on a successful compaction. + const failed = (await store.readMessages(session.id)).filter( + (message) => + message.type === 'system_note' && message.kind === 'context_compaction_failed_open', + ); + assert.strictEqual(failed.length, 0); + }); + + test('recovers the context_compacted note when its first durable write transiently fails', async () => { + const store = new MemorySessionStore(); + const runStore = new MemoryAgentRunStore(); + const backends = new BackendRegistry(); + const compactCalls: Array<{ turnId: string; runtimeContextCount: number }> = []; + backends.register('ai-sdk', (ctx) => new CompactingTestBackend(ctx, compactCalls)); + const manager = new SessionManager({ + store, + runStore, + runtimeEventStore: runStore, + backends, + newId: nextId(), + now: nextNow(12_000), + }); + const session = await manager.createSession(makeInput({ permissionMode: 'bypass' })); + + await drain(manager.sendMessage(session.id, { turnId: 'turn-1', text: 'hello' })); + // Fail the FIRST durable write of the compacted note. The terminal RuntimeEvent + // is already committed by then, so a single silent attempt would leave a + // completed compaction with no transcript row and no later repair (passive + // checkpoint replay suppresses it). The kernel's bounded retry must re-land it. + store.failNextAppendMessage = (message) => + message.type === 'system_note' && message.kind === 'context_compacted'; + + await drain(manager.compactSession(session.id, { turnId: 'turn-compact' })); + + assert.strictEqual( + store.failNextAppendMessage, + undefined, + 'the injected transient failure fired exactly once', + ); + const notes = (await store.readMessages(session.id)).filter( + (message) => + message.type === 'system_note' && + message.turnId === 'turn-compact' && + message.kind === 'context_compacted', + ); + // Distinguishes the recovered write from the intended pre-terminal stop case, + // which leaves no note at all. + assert.strictEqual(notes.length, 1); + }); + + test('persists a fail-open note when the backend throws during manual compaction', async () => { + const store = new MemorySessionStore(); + const runStore = new MemoryAgentRunStore(); + const backends = new BackendRegistry(); + backends.register('ai-sdk', (ctx) => new ThrowingCompactingBackend(ctx)); + const manager = new SessionManager({ + store, + runStore, + runtimeEventStore: runStore, + backends, + newId: nextId(), + now: nextNow(12_000), + }); + const session = await manager.createSession(makeInput({ permissionMode: 'bypass' })); + + await drain(manager.sendMessage(session.id, { turnId: 'turn-1', text: 'hello' })); + let threw = false; + try { + await drain(manager.compactSession(session.id, { turnId: 'turn-compact' })); + } catch { + threw = true; + } + assert.ok(threw, 'a backend that throws surfaces the failure to the caller'); + + // A thrown compaction still leaves one durable fail-open row: the internal + // compaction turn has no assistant content, so recordFailure alone would + // render an empty turn. + const notes = (await store.readMessages(session.id)).filter( + (message) => + message.type === 'system_note' && + message.turnId === 'turn-compact' && + message.kind === 'context_compaction_failed_open', + ); + assert.strictEqual(notes.length, 1); + }); + test('manual compaction stopped before backend start does not write compact artifacts', async () => { const store = new MemorySessionStore(); const readGate = makeGate(); @@ -3618,6 +3732,16 @@ describe('SessionManager manual compaction and quiescent session changes', () => (run) => run.turnId === 'turn-compact', ); assert.strictEqual(compactRun?.status, 'cancelled'); + // An interrupted compaction leaves no durable transcript row. + assert.deepStrictEqual( + (await store.readMessages(session.id)).filter( + (message) => + message.type === 'system_note' && + (message.kind === 'context_compacted' || + message.kind === 'context_compaction_failed_open'), + ), + [], + ); }); test('cold manual compaction normalizes only its execution cancellation reason', async () => { @@ -3777,6 +3901,18 @@ describe('SessionManager manual compaction and quiescent session changes', () => (run) => run.turnId === 'turn-compact', ); assert.strictEqual(compactRun?.status, 'cancelled'); + // A compaction stopped mid-run leaves no durable transcript row: the note is + // written only after the terminal completeEvent commits, and the catch path + // skips it while the run is stopped. + assert.deepStrictEqual( + (await store.readMessages(session.id)).filter( + (message) => + message.type === 'system_note' && + (message.kind === 'context_compacted' || + message.kind === 'context_compaction_failed_open'), + ), + [], + ); }); test('compactSession rejects while a turn is running and writes no compact artifacts', async () => { @@ -12467,6 +12603,15 @@ class FailOpenCompactingBackend extends TestBackend { } } +class ThrowingCompactingBackend extends TestBackend { + async compactHistory(_input: { + turnId: string; + runtimeContext: readonly RuntimeEvent[]; + }): Promise { + throw new Error('backend compaction exploded'); + } +} + class ActiveTurnBackend extends TestBackend { constructor( ctx: BackendFactoryContext, diff --git a/packages/runtime/src/context-budget.ts b/packages/runtime/src/context-budget.ts index 1657d47f9d..b6decc3d95 100644 --- a/packages/runtime/src/context-budget.ts +++ b/packages/runtime/src/context-budget.ts @@ -227,15 +227,41 @@ export function mergeContextBudgetDiagnosticPatches( return mergeContextBudgetDiagnostic(left as ContextBudgetDiagnostic, right); } +/** + * A durable history-compact transcript note must be written only when a fold + * actually happened on THIS turn — not when a later turn passively replays an + * already-recorded checkpoint. A fresh fold is reported either as + * `stage: 'activeStep'` (a mid-turn / reactive-overflow fold performed during + * the current send) or as `stage: 'priorReplay'` with an explicit + * `phase: 'pre_turn'` (a fresh pre-turn fold this send). Passive replay of a + * standalone checkpoint carries no phase, and passive replay of a `mid_turn` + * checkpoint carries `phase: 'mid_turn'`; both are suppressed here so the note + * is not re-emitted every subsequent turn. + * + * NOTE: this leans on the asymmetry that passive replays never stamp + * `phase: 'pre_turn'`. A future change that stamps a phase on passive replays of + * standalone checkpoints would silently reintroduce a duplicate-note bug — keep + * passive-replay decisions phase-less. + */ +function isFreshHistoryCompactFold( + decision: CompactionDecisionDiagnostic, + kind: 'replaced' | 'failedOpen', +): boolean { + if (decision.boundaryKind !== 'historyCompact' || decision.decision !== kind) { + return false; + } + return ( + decision.stage === 'activeStep' || + (decision.stage === 'priorReplay' && decision.phase === 'pre_turn') + ); +} + export function shouldAppendContextCompactedNote( contextBudget: ContextBudgetDiagnostic | undefined, ): boolean { return ( - contextBudget?.compactionDecisions?.some( - (decision) => - decision.stage === 'priorReplay' && - decision.boundaryKind === 'historyCompact' && - decision.decision === 'replaced', + contextBudget?.compactionDecisions?.some((decision) => + isFreshHistoryCompactFold(decision, 'replaced'), ) === true ); } @@ -244,11 +270,8 @@ export function shouldAppendContextCompactionFailedOpenNote( contextBudget: ContextBudgetDiagnostic | undefined, ): boolean { return ( - contextBudget?.compactionDecisions?.some( - (decision) => - decision.stage === 'priorReplay' && - decision.boundaryKind === 'historyCompact' && - decision.decision === 'failedOpen', + contextBudget?.compactionDecisions?.some((decision) => + isFreshHistoryCompactFold(decision, 'failedOpen'), ) === true ); } diff --git a/packages/runtime/src/runtime-kernel.ts b/packages/runtime/src/runtime-kernel.ts index 1da029c273..2489fb4831 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -1031,6 +1031,7 @@ export class RuntimeKernel implements RuntimeKernelLike { return; } + let notedTerminal = false; try { if (run.isStopped()) return; if (!begin.backend.compactHistory) { @@ -1075,6 +1076,18 @@ export class RuntimeKernel implements RuntimeKernelLike { if (run.isStopped()) return; await run.recordStoredSessionEvent(tokenUsageEvent); if (run.isStopped()) return; + yield tokenUsageEvent; + if (run.isStopped()) return; + await run.acceptMappedEvent( + completeEvent, + mapSessionEventToRuntimeEvent(completeEvent, eventContext), + { requireTerminalWrite: true }, + ); + // The terminal RuntimeEvent is now durably committed, so this compaction + // Turn is authoritatively `completed` — a later stop cannot turn it into a + // cancelled Turn. Only now is the durable note written: a stop that wins + // before the terminal commit returns above, leaving no note, so an + // interrupted compaction leaves no durable row. `unchanged` writes nothing. if (result.outcome.kind === 'failed') { const note: SystemNoteMessage = { type: 'system_note', @@ -1083,19 +1096,42 @@ export class RuntimeKernel implements RuntimeKernelLike { ts: this.deps.now(), kind: 'context_compaction_failed_open', }; - await this.deps.store.appendMessage(sessionId, note).catch(() => {}); + await this.appendDurableCompactionNote(sessionId, note); + notedTerminal = true; + } else if (result.outcome.kind === 'compacted') { + // Explicit compaction runs on its own turn and never enters the + // send-flow note block, so write the durable "compacted" note here. The + // next user send passively replays this standalone checkpoint, which + // `shouldAppendContextCompactedNote` now suppresses, so there is no + // duplicate. + const note: SystemNoteMessage = { + type: 'system_note', + id: this.deps.newId(), + turnId: run.turnId, + ts: this.deps.now(), + kind: 'context_compacted', + }; + await this.appendDurableCompactionNote(sessionId, note); + notedTerminal = true; } - yield tokenUsageEvent; - if (run.isStopped()) return; - await run.acceptMappedEvent( - completeEvent, - mapSessionEventToRuntimeEvent(completeEvent, eventContext), - { requireTerminalWrite: true }, - ); - if (run.isStopped()) return; yield completeEvent; } catch (error) { await run.recordFailure(error); + // A thrown compaction still owns a durable fail-open row — but not when the + // throw is a stop / cancellation, which must leave no durable row (matches + // the terminal-committed guard above). recordFailure writes the failed + // turn_state either way; the internal compaction Turn has no user/timeline + // for a failure banner, so append the note unless already written or stopped. + if (!notedTerminal && !run.isStopped()) { + const note: SystemNoteMessage = { + type: 'system_note', + id: this.deps.newId(), + turnId: run.turnId, + ts: this.deps.now(), + kind: 'context_compaction_failed_open', + }; + await this.appendDurableCompactionNote(sessionId, note); + } throw error; } finally { const failures = new FailureCollector(); @@ -1105,6 +1141,33 @@ export class RuntimeKernel implements RuntimeKernelLike { } } + /** + * Append a terminal context-compaction transcript note durably. + * + * The terminal RuntimeEvent (the canonical outcome) is already committed by + * the time this runs, so a note-write failure must NOT fail the successful + * compaction. But a single silent attempt could leave a completed compaction + * with no transcript row and no later repair — terminal transcript reads carry + * no live overlay and passive checkpoint replay suppresses the note. So retry + * transient store failures here, reusing the SAME note (stable id) so a + * recovered attempt can never duplicate the row. Returns whether it landed. + */ + private async appendDurableCompactionNote( + sessionId: string, + note: SystemNoteMessage, + ): Promise { + const MAX_ATTEMPTS = 3; + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + try { + await this.deps.store.appendMessage(sessionId, note); + return true; + } catch { + if (attempt === MAX_ATTEMPTS) return false; + } + } + return false; + } + private async requireContextCompactionBackend( sessionId: string, header: SessionHeader, diff --git a/packages/runtime/src/session-event-runtime-mapper.ts b/packages/runtime/src/session-event-runtime-mapper.ts index 63e251c84e..03a08fe695 100644 --- a/packages/runtime/src/session-event-runtime-mapper.ts +++ b/packages/runtime/src/session-event-runtime-mapper.ts @@ -135,6 +135,14 @@ export function mapSessionEventToRuntimeEvent( // ingress drops them, so reaching this line bypassed that authority boundary. throw new Error(`${event.type} is not a backend event`); } + if (event.type === 'context_compaction_started') { + // Presentation-only: synthesized by the Runtime Host session projector for + // the renderer's live "compacting" row. Never produced by a backend or the + // kernel, and excluded from BackendSessionEvent like queue_update. + throw new Error( + 'context_compaction_started is not a backend event: the Host projector is its only producer', + ); + } if (isLegacyPermissionSessionEvent(event)) { throw new Error(`${event.type} is a legacy permission event and is not backend-mappable`); } @@ -146,6 +154,7 @@ export function isLiveBackendSessionEvent(event: SessionEvent): event is Backend return ( event.type !== 'queue_update' && event.type !== 'message_admission' && + event.type !== 'context_compaction_started' && !isLegacyPermissionSessionEvent(event) ); } diff --git a/packages/ui/src/__tests__/chat-view-empty-compaction.test.tsx b/packages/ui/src/__tests__/chat-view-empty-compaction.test.tsx new file mode 100644 index 0000000000..61edf716a8 --- /dev/null +++ b/packages/ui/src/__tests__/chat-view-empty-compaction.test.tsx @@ -0,0 +1,70 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { renderToStaticMarkup } from 'react-dom/server'; +import type { SessionSummary } from '@maka/core/session'; +import { ChatSurfaceLayout } from '../chat-surface-layout.js'; +import { ChatView } from '../chat-view.js'; +import type { LiveTurnProjection } from '../live-turn-projection.js'; +import { LocaleProvider } from '../locale-context.js'; + +const activeSession = { + id: 'session-1', + name: 'Session', + status: 'running', + labels: [] as string[], +} as unknown as SessionSummary; + +function renderChat(liveTurn?: LiveTurnProjection): string { + return renderToStaticMarkup( + + + undefined} + /> + + , + ); +} + +test('renders the live compaction row in a session with no settled messages', () => { + const markup = renderChat({ + turnId: 'turn-compact', + phase: 'waiting', + rootExecutionKind: 'context_compact', + startedAt: 0, + steps: [], + }); + + // Before the fix, showEmptyState hid this overlaid row behind the empty hero + // because it keyed off chat.length (0) and never saw the synthesized turn. + assert.match(markup, /Compacting context/); +}); + +test('renders the empty hero when an empty session has no live compaction row', () => { + const markup = renderChat(undefined); + + assert.doesNotMatch(markup, /Compacting context/); +}); diff --git a/packages/ui/src/__tests__/live-turn-projection.test.ts b/packages/ui/src/__tests__/live-turn-projection.test.ts index 63d788c85f..9317abe575 100644 --- a/packages/ui/src/__tests__/live-turn-projection.test.ts +++ b/packages/ui/src/__tests__/live-turn-projection.test.ts @@ -1073,3 +1073,108 @@ function previewedSubagentTurn(): LiveTurnProjection { ts: 101, }); } + +describe('context-compaction live row', () => { + it('arms a rootExecutionKind projection from a context_compaction_started event', () => { + const projection = applyLiveTurnEvent(undefined, { + type: 'context_compaction_started', + id: 'compaction-started-1', + turnId: 'turn-compact', + ts: 1, + }); + assert.ok(projection); + assert.equal(projection.turnId, 'turn-compact'); + assert.equal(projection.rootExecutionKind, 'context_compact'); + assert.equal(projection.steps.length, 0); + }); + + it('overlays exactly one localized "compacting" system row while running', () => { + const projection = applyLiveTurnEvent(undefined, { + type: 'context_compaction_started', + id: 'compaction-started-1', + turnId: 'turn-compact', + ts: 1, + }); + const turns = overlayLiveTurn([], projection, 'en'); + assert.equal(turns.length, 1); + assert.equal(turns[0]?.turnId, 'turn-compact'); + assert.equal(turns[0]?.status, 'running'); + assert.equal(turns[0]?.notes.length, 1); + assert.equal( + turns[0]?.notes[0]?.text, + getConversationCopy('en').messages.systemNotes.contextCompacting, + ); + }); + + it('merges the compacting note into an already-persisted running turn', () => { + // Production persists a `turn_state:running` row for the compaction turn, so + // materializeTurns yields an empty running turn before the live row arrives. + const settled = [ + { + turnId: 'turn-compact', + status: 'running' as const, + statusSource: 'recorded' as const, + partialOutputRetained: false, + tools: [], + notes: [], + timeline: [], + startedAt: 5, + }, + ]; + const projection = applyLiveTurnEvent(undefined, { + type: 'context_compaction_started', + id: 'compaction-started-1', + turnId: 'turn-compact', + ts: 7, + }); + const turns = overlayLiveTurn(settled, projection, 'en'); + assert.equal(turns.length, 1); + assert.equal(turns[0]?.turnId, 'turn-compact'); + assert.equal(turns[0]?.notes.length, 1); + assert.equal( + turns[0]?.notes[0]?.text, + getConversationCopy('en').messages.systemNotes.contextCompacting, + ); + assert.equal(turns[0]?.notes[0]?.id, 'context-compaction:turn-compact'); + // Deterministic ts (no Date.now()): the note borrows the settled turn's start. + assert.equal(turns[0]?.notes[0]?.ts, 5); + // Idempotent across reprojection — no duplicate note. + const again = overlayLiveTurn(turns, projection, 'en'); + assert.equal(again[0]?.notes.length, 1); + }); + + it('localizes the compacting row per locale', () => { + const projection = applyLiveTurnEvent(undefined, { + type: 'context_compaction_started', + id: 'compaction-started-1', + turnId: 'turn-compact', + ts: 1, + }); + assert.equal( + overlayLiveTurn([], projection, 'zh')[0]?.notes[0]?.text, + getConversationCopy('zh').messages.systemNotes.contextCompacting, + ); + assert.notEqual( + getConversationCopy('zh').messages.systemNotes.contextCompacting, + getConversationCopy('en').messages.systemNotes.contextCompacting, + ); + }); + + it('drops the row when the compaction turn completes with no content', () => { + let projection = applyLiveTurnEvent(undefined, { + type: 'context_compaction_started', + id: 'compaction-started-1', + turnId: 'turn-compact', + ts: 1, + }); + projection = applyLiveTurnEvent(projection, { + type: 'complete', + id: 'complete-1', + turnId: 'turn-compact', + ts: 2, + stopReason: 'end_turn', + }); + assert.equal(projection, undefined); + assert.deepEqual(overlayLiveTurn([], projection, 'en'), []); + }); +}); diff --git a/packages/ui/src/__tests__/transcript-projection.test.ts b/packages/ui/src/__tests__/transcript-projection.test.ts index 559ce849fe..90ba24242e 100644 --- a/packages/ui/src/__tests__/transcript-projection.test.ts +++ b/packages/ui/src/__tests__/transcript-projection.test.ts @@ -100,6 +100,24 @@ describe('incremental transcript projection', () => { assert.notStrictEqual(chinese, english); }); + test('a locale change updates the live context-compaction row text', () => { + const projection = createTranscriptProjection(); + // Empty messages keep the settled turns reference stable (NO_TURNS) across + // the locale switch, so only the overlay locale guard can re-localize the + // live "compacting" row. + const liveTurn: LiveTurnProjection = { + turnId: 'turn-compact', + phase: 'waiting', + steps: [], + rootExecutionKind: 'context_compact', + startedAt: 1, + }; + const english = projection.project({ sessionId: SESSION, messages: [], liveTurn, locale: 'en' }); + const chinese = projection.project({ sessionId: SESSION, messages: [], liveTurn, locale: 'zh' }); + assert.equal(english[0]?.notes[0]?.text, 'Compacting context…'); + assert.equal(chinese[0]?.notes[0]?.text, '正在压缩上下文…'); + }); + test('a shell-run update whose semantics are unchanged affects nothing', () => { const projection = createTranscriptProjection(); const messages = history(); diff --git a/packages/ui/src/chat-view.tsx b/packages/ui/src/chat-view.tsx index af4ec6cf23..f483cd9cfe 100644 --- a/packages/ui/src/chat-view.tsx +++ b/packages/ui/src/chat-view.tsx @@ -399,8 +399,20 @@ export function ChatView(props: { // being in-flight are separate signals. Wait indicators alone still mark // streaming, but delayed flags can lag one frame past complete; terminal // evidence must outrank them so copy/regenerate stay actionable. - const liveInFlight = !!(props.liveTurn && !props.liveTurn.terminal); - const streamingActive = liveInFlight || (!props.liveTurn?.terminal && !!props.runningStatus); + // A live context-compaction Turn is not an assistant stream: it renders one + // system row (see overlayLiveTurn), not a streaming tail. Keeping it out of + // liveInFlight/streamingActive stops chat-turn from adding an empty assistant + // article, the generic "pondering" spinner, and a footer placeholder on top. + const isCompactionLive = props.liveTurn?.rootExecutionKind === 'context_compact'; + // overlayLiveTurn renders one "compacting" system row for a live compaction + // Turn that has no assistant steps — including in a session with no settled + // chat messages yet. The empty-state decision (below) keys off `chat.length`, + // which does not see that overlaid row, so it must treat this as visible + // content or the row is hidden behind the empty hero. + const hasLiveCompactionRow = isCompactionLive && (props.liveTurn?.steps.length ?? 0) === 0; + const liveInFlight = !!(props.liveTurn && !props.liveTurn.terminal) && !isCompactionLive; + const streamingActive = + liveInFlight || (!props.liveTurn?.terminal && !!props.runningStatus && !isCompactionLive); const tailTurnId = liveInFlight ? props.liveTurn!.turnId : (streamingActive ? turns[turns.length - 1]?.turnId : undefined); @@ -605,7 +617,8 @@ export function ChatView(props: { chat.length === 0 && transientMessages.length === 0 && !streamingActive - && !hasVisibleConversationItem; + && !hasVisibleConversationItem + && !hasLiveCompactionRow; const emptyContent = props.messageLoading ? (
diff --git a/packages/ui/src/conversation-copy.ts b/packages/ui/src/conversation-copy.ts index 7c6773e24f..8b5ff2ae30 100644 --- a/packages/ui/src/conversation-copy.ts +++ b/packages/ui/src/conversation-copy.ts @@ -307,6 +307,7 @@ export interface ConversationCopy { aborted: string; abortedByStop: string; systemNotes: { + contextCompacting: string; contextCompacted: string; contextCompactionFailedOpen: string; stepLimit: string; @@ -502,6 +503,7 @@ const CONVERSATION_COPY = { userAriaLabel: '你发送的消息', systemAriaLabel: '系统消息', assistantAriaLabel: 'Maka 的回答', answerActionsAriaLabel: (context) => `回答操作${context ? `:${context}` : ''}`, answerActionAriaLabel: (action, context) => `${action}回答${context ? `:${context}` : ''}`, messageActionAriaLabel: (action, context) => `${action}消息${context ? `:${context}` : ''}`, sourceAriaLabel: '本轮回答的来源', derivativesAriaLabel: '本轮回答的衍生', scheduledTaskTriggered: '定时任务触发', scheduledTaskTitle: (id) => `由定时任务触发 · ${id}`, legacyAutomationTriggered: '旧版自动化(仅历史)', legacyAutomationTitle: (id) => `由旧版自动化触发 · ${id} · 仅保留历史,不会再次执行`, goalContinued: 'Goal 自动继续', goalTitle: (id) => `由 Goal 继续执行 · ${id}`, agentGraphTriggered: 'Agent Graph 自动继续', agentGraphTitle: (graphId) => `由 Agent Graph 调度器触发 · ${graphId}`, thinkingTruncatedTitle: '部分 reasoning 已截断;显示的是最近的内容', outputTruncatedTitle: '助手输出已超过单次回合上限,超出部分未渲染。如需完整内容请重新生成或查看持久化的任务日志。', removeAttachmentAriaLabel: (name) => `移除 ${name}`, quoteLabel: '引用', quoteExpandAriaLabel: '展开引用全文', quoteCollapseAriaLabel: '收起引用', removeQuoteAriaLabel: '移除引用', aborted: '已中断', abortedByStop: '已中断 · 由停止按钮触发', systemNotes: { + contextCompacting: '正在压缩上下文…', contextCompacted: '已压缩较早的对话内容,以适应模型上下文窗口。', contextCompactionFailedOpen: '上下文摘要失败;本轮已在未生成新摘要的情况下继续。', stepLimit: '已达到本轮工具步骤上限,任务可能尚未完成。发送“继续”即可接着处理。', @@ -650,6 +652,7 @@ const CONVERSATION_COPY = { userAriaLabel: 'Your message', systemAriaLabel: 'System message', assistantAriaLabel: "Maka's response", answerActionsAriaLabel: (context) => `Response actions${context ? `: ${context}` : ''}`, answerActionAriaLabel: (action, context) => `${action} response${context ? `: ${context}` : ''}`, messageActionAriaLabel: (action, context) => `${action} message${context ? `: ${context}` : ''}`, sourceAriaLabel: 'Source of this response', derivativesAriaLabel: 'Responses derived from this one', scheduledTaskTriggered: 'Triggered by scheduled task', scheduledTaskTitle: (id) => `Triggered by scheduled task · ${id}`, legacyAutomationTriggered: 'Legacy Automation (history only)', legacyAutomationTitle: (id) => `Triggered by legacy Automation · ${id} · Historical only; it will not run again`, goalContinued: 'Continued by Goal', goalTitle: (id) => `Continued by Goal · ${id}`, agentGraphTriggered: 'Continued by Agent Graph', agentGraphTitle: (graphId) => `Triggered by the Agent Graph scheduler · ${graphId}`, thinkingTruncatedTitle: 'Some reasoning was truncated; showing the most recent content', outputTruncatedTitle: 'The assistant output exceeded the per-turn limit. Regenerate it or inspect the persisted task log for the complete content.', removeAttachmentAriaLabel: (name) => `Remove ${name}`, quoteLabel: 'Quote', quoteExpandAriaLabel: 'Show the full quoted excerpt', quoteCollapseAriaLabel: 'Collapse the quoted excerpt', removeQuoteAriaLabel: 'Remove quote', aborted: 'Interrupted', abortedByStop: 'Interrupted · Stop button', systemNotes: { + contextCompacting: 'Compacting context…', contextCompacted: 'Context compacted to keep this session within the model window.', contextCompactionFailedOpen: 'Context summary failed; the session continued without a new summary.', stepLimit: 'Reached the configured step limit. The task may be incomplete. Send “continue” to resume.', diff --git a/packages/ui/src/live-turn-projection.ts b/packages/ui/src/live-turn-projection.ts index fd5f74afb2..8dad5c3a62 100644 --- a/packages/ui/src/live-turn-projection.ts +++ b/packages/ui/src/live-turn-projection.ts @@ -93,6 +93,16 @@ export interface LiveTurnProjection { turnId: string; phase: 'waiting' | 'streamed'; terminal?: true; + /** + * Set when this live Turn is a host-owned explicit context-compaction run. + * A `context_compact` Turn emits no assistant content, so `overlayLiveTurn` + * renders a single "compacting" system row from this flag while the Turn is in + * flight; the row disappears when the Turn settles (no durable turn state). + */ + rootExecutionKind?: 'context_compact'; + /** Event ts of the first authority word about this Turn; a stable ts for the + * synthesized "compacting" row so reprojection does not churn identity. */ + startedAt?: number; /** Steering acknowledged after the current content and awaiting its next provider step. */ pendingSteering?: LiveSteeringProjection[]; /** @@ -248,6 +258,13 @@ export function applyLiveTurnEvent( steps: terminalizeLiveSteps(current.steps), }; } + if (event.type === 'context_compaction_started') { + const prior = + current?.turnId === event.turnId + ? current + : { turnId: event.turnId, phase: 'waiting' as const, steps: [] }; + return { ...confirmed(prior), rootExecutionKind: 'context_compact', startedAt: event.ts }; + } if ( event.type !== 'thinking_delta' && event.type !== 'thinking_complete' diff --git a/packages/ui/src/materialize.ts b/packages/ui/src/materialize.ts index 13c15cff93..cef6c692fb 100644 --- a/packages/ui/src/materialize.ts +++ b/packages/ui/src/materialize.ts @@ -413,11 +413,56 @@ export interface TurnViewModel { export function overlayLiveTurn( turns: readonly TurnViewModel[], liveTurn: LiveTurnProjection | undefined, + locale: UiLocale = "en", ): readonly TurnViewModel[] { if (!liveTurn) return turns; const targetIndex = turns.findIndex( (turn) => turn.turnId === liveTurn.turnId, ); + // A running host-owned context-compaction Turn emits no assistant content. + // The Runtime persists a `turn_state:running` row for it, so a settled turn + // with this turnId usually already exists (empty). Surface a single + // "compacting" system row: merge the note into that existing turn, or + // synthesize one if it has not settled yet. The note is deduped by id so + // reprojection stays idempotent, and it disappears when the Turn settles + // (the live projection drops to undefined and the durable `context_compacted` + // note takes over). + if (liveTurn.rootExecutionKind === "context_compact" && liveTurn.steps.length === 0) { + const noteId = `context-compaction:${liveTurn.turnId}`; + if (targetIndex >= 0) { + const existing = turns[targetIndex]!; + if (existing.notes.some((note) => note.id === noteId)) return turns; + const note: ChatItem = { + id: noteId, + role: "system", + text: getConversationCopy(locale).messages.systemNotes.contextCompacting, + ts: existing.startedAt, + }; + return turns.map((turn, index) => + index === targetIndex ? { ...turn, notes: [...turn.notes, note] } : turn, + ); + } + const startedAt = liveTurn.startedAt ?? 0; + return [ + ...turns, + { + turnId: liveTurn.turnId, + status: "running" as const, + partialOutputRetained: false, + tools: [], + notes: [ + { + id: noteId, + role: "system", + text: getConversationCopy(locale).messages.systemNotes.contextCompacting, + ts: startedAt, + }, + ], + timeline: [], + startedAt, + } satisfies TurnViewModel, + ]; + } if ( targetIndex >= 0 && liveTurn.steps.length === 0 diff --git a/packages/ui/src/transcript-projection.ts b/packages/ui/src/transcript-projection.ts index 08ab6cf75a..03c216fa78 100644 --- a/packages/ui/src/transcript-projection.ts +++ b/packages/ui/src/transcript-projection.ts @@ -89,6 +89,10 @@ export function createTranscriptProjection(): TranscriptProjection { // Tracked separately from `lastMessages` because a refresh can leave the // settled projection untouched, which must not force the live overlay to run. let liveTurnsFrom: readonly TurnViewModel[] | undefined; + // The locale the overlay last ran with. The live "compacting" row is localized + // inside overlayLiveTurn, so a locale switch that leaves the settled turns + // reference unchanged (identity reconciliation) must still re-run the overlay. + let lastOverlayLocale: UiLocale | undefined; let overlayEntries: ReadonlyMap = new Map(); let lastTurns: readonly TurnViewModel[] = NO_TURNS; @@ -101,6 +105,7 @@ export function createTranscriptProjection(): TranscriptProjection { settledTurns = NO_TURNS; liveTurns = NO_TURNS; liveTurnsFrom = undefined; + lastOverlayLocale = undefined; overlayEntries = new Map(); lastTurns = NO_TURNS; } @@ -137,10 +142,15 @@ export function createTranscriptProjection(): TranscriptProjection { lastMessages = input.messages; lastLocale = input.locale; } - if (liveTurnsFrom !== settledTurns || input.liveTurn !== lastLiveTurn) { - liveTurns = overlayLiveTurn(settledTurns, input.liveTurn); + if ( + liveTurnsFrom !== settledTurns || + input.liveTurn !== lastLiveTurn || + input.locale !== lastOverlayLocale + ) { + liveTurns = overlayLiveTurn(settledTurns, input.liveTurn, input.locale); liveTurnsFrom = settledTurns; lastLiveTurn = input.liveTurn; + lastOverlayLocale = input.locale; } if (updatesMoved) { overlayEntries = foldShellRunUpdates(updates);