diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx index f10c8d958a..6008ab5f98 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -571,6 +571,51 @@ export const RunningStatusDuringToolRun: 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: () => ( + + ), +}; + // Real path: Desktop Computer Use is exposed through the Runtime Host Client // Capability bridge. The settled observation establishes the confirmed target; // the following sequence inherits it while live progress replaces the generic diff --git a/packages/core/src/backend-types.ts b/packages/core/src/backend-types.ts index 46262ca9f2..70c4325468 100644 --- a/packages/core/src/backend-types.ts +++ b/packages/core/src/backend-types.ts @@ -192,6 +192,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 f73952184f..81a9a0fd67 100644 --- a/packages/core/src/events.ts +++ b/packages/core/src/events.ts @@ -510,7 +510,8 @@ export type SessionEvent = | ProviderRetryEvent | ErrorEvent | CompleteEvent - | AbortEvent; + | AbortEvent + | ContextCompactionStartedEvent; export interface TextDeltaEvent extends BaseEvent { type: 'text_delta'; @@ -1224,6 +1225,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 c4f2fad83b..fe607c5e5a 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -242,6 +242,10 @@ describe('Runtime Host bootstrap protocol', () => { assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 43); }); + test('publishes a new compatibility epoch for context-compaction transcript state', () => { + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 50); + }); + 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 b0a5ac001b..666e90dc67 100644 --- a/packages/runtime-host/src/__tests__/session-projector.test.ts +++ b/packages/runtime-host/src/__tests__/session-projector.test.ts @@ -562,3 +562,93 @@ function assistant(id: string, text: string): Extract { + 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('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 08b14a9604..2430b0f744 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, @@ -167,6 +171,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()) { @@ -410,6 +419,13 @@ export class RuntimeHostSessionProjector { const startedTurn = root && (!previousRoot || root.runId !== previousRoot.runId) ? root : undefined; if (startedTurn) this.#accumulators.clear(); + if ( + startedTurn && + !isRuntimeHostTerminalTurn(startedTurn) && + startedTurn.rootExecutionKind === 'context_compact' + ) { + events.push(contextCompactionStartedEvent(startedTurn, this.#now())); + } const retry = liveProviderRetryEvent(previousRoot, root, this.#now()); if (retry) events.push(retry); const terminalTurn = @@ -446,6 +462,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({ @@ -511,6 +534,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 30f01b2cb7..af637c9b64 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -92,7 +92,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 = 50 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 51 as const; +// 51: Live Turn snapshots carry an optional `rootExecutionKind:'context_compact'` +// so a running context-compaction Turn can render a transcript row. Epoch-50 +// peers reject the added optional field on the strict live snapshot shape. // 50: WorkHub can append durable coordination summaries and admit tool-free // answers through its reserved Coordination Session authority. // 49: WorkHub resolves one durable Coordination Session per Runtime Host. diff --git a/packages/runtime-host/src/protocol/turn.ts b/packages/runtime-host/src/protocol/turn.ts index 100cb6c230..7e773127fd 100644 --- a/packages/runtime-host/src/protocol/turn.ts +++ b/packages/runtime-host/src/protocol/turn.ts @@ -179,6 +179,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 = @@ -627,6 +635,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 = { @@ -699,7 +714,7 @@ export function decodeTurnSnapshot(value: unknown): TurnSnapshot { record, 'non-terminal Turn snapshot', ['sessionId', 'turnId', 'runId', 'status'], - ['providerRetry'], + ['providerRetry', 'rootExecutionKind'], ); return { ...base, @@ -707,6 +722,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 24cf1f3e7a..146600c6dc 100644 --- a/packages/runtime-host/src/server/canonical-turn-snapshot.ts +++ b/packages/runtime-host/src/server/canonical-turn-snapshot.ts @@ -107,7 +107,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 readContextCompactionOutcome(value: unknown): ContextCompactionOutcome | undefined { diff --git a/packages/runtime/src/__tests__/context-budget.test.ts b/packages/runtime/src/__tests__/context-budget.test.ts index c360aea827..01d89b818e 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 '../context-budget-helpers.js'; import { buildHistoryCompactCheckpoint } from '../history-compact-checkpoint.js'; @@ -127,3 +133,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 ea3fd51b75..d781b12431 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -4082,6 +4082,42 @@ describe('SessionManager manual compaction and quiescent session changes', () => expect(warnings).toHaveLength(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. + expect(notes).toHaveLength(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', + ); + expect(failed).toHaveLength(0); + }); + test('manual compaction stopped before backend start does not write compact artifacts', async () => { const store = new MemorySessionStore(); const readGate = makeGate(); diff --git a/packages/runtime/src/context-budget.ts b/packages/runtime/src/context-budget.ts index 13d56438ed..3aa414f44f 100644 --- a/packages/runtime/src/context-budget.ts +++ b/packages/runtime/src/context-budget.ts @@ -330,15 +330,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 ); } @@ -347,11 +373,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 64a94e63f2..5a67d7c1e4 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -1081,6 +1081,20 @@ export class RuntimeKernel implements RuntimeKernelLike { kind: 'context_compaction_failed_open', }; await this.deps.store.appendMessage(sessionId, note).catch(() => {}); + } 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 — it + // appears the moment compaction ends. The next user send passively + // replays this standalone checkpoint, which `shouldAppendContextCompactedNote` + // now suppresses, so there is no duplicate. `unchanged` writes nothing. + const note: SystemNoteMessage = { + type: 'system_note', + id: this.deps.newId(), + turnId: run.turnId, + ts: this.deps.now(), + kind: 'context_compacted', + }; + await this.deps.store.appendMessage(sessionId, note).catch(() => {}); } yield tokenUsageEvent; if (run.isStopped()) return; diff --git a/packages/runtime/src/session-event-runtime-mapper.ts b/packages/runtime/src/session-event-runtime-mapper.ts index 3044d110f9..0829ad2e05 100644 --- a/packages/runtime/src/session-event-runtime-mapper.ts +++ b/packages/runtime/src/session-event-runtime-mapper.ts @@ -134,6 +134,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`); } @@ -145,6 +153,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__/live-turn-projection.test.ts b/packages/ui/src/__tests__/live-turn-projection.test.ts index 66fd53ca77..85217cb9aa 100644 --- a/packages/ui/src/__tests__/live-turn-projection.test.ts +++ b/packages/ui/src/__tests__/live-turn-projection.test.ts @@ -1076,3 +1076,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/conversation-copy.ts b/packages/ui/src/conversation-copy.ts index 6be75b128b..de86d9ccbe 100644 --- a/packages/ui/src/conversation-copy.ts +++ b/packages/ui/src/conversation-copy.ts @@ -305,6 +305,7 @@ export interface ConversationCopy { aborted: string; abortedByStop: string; systemNotes: { + contextCompacting: string; contextCompacted: string; contextCompactionFailedOpen: string; stepLimit: string; @@ -500,6 +501,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: '已达到本轮工具步骤上限,任务可能尚未完成。发送“继续”即可接着处理。', @@ -648,6 +650,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 7d43cd7f34..9f2032bbfa 100644 --- a/packages/ui/src/live-turn-projection.ts +++ b/packages/ui/src/live-turn-projection.ts @@ -79,6 +79,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[]; /** @@ -234,6 +244,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 2f26a26174..c708c0da84 100644 --- a/packages/ui/src/materialize.ts +++ b/packages/ui/src/materialize.ts @@ -417,11 +417,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);