diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx index bc49fb088d..13d15d52a4 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -2512,3 +2512,48 @@ export const RailStaysOnTheVisiblePrompt: Story = { ).toBe(true); }, }; + +// 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 18f4383c2a..0e1d9794e6 100644 --- a/packages/core/src/backend-types.ts +++ b/packages/core/src/backend-types.ts @@ -216,6 +216,7 @@ export type BackendSessionEvent = Exclude< | 'message_admission' | 'client_capability_request' | 'client_capability_decision_ack' + | '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 a30d3999d4..8ca77add7c 100644 --- a/packages/core/src/events.ts +++ b/packages/core/src/events.ts @@ -579,7 +579,8 @@ export type SessionEvent = | ProviderRetryEvent | ErrorEvent | CompleteEvent - | AbortEvent; + | AbortEvent + | ContextCompactionStartedEvent; export interface TextDeltaEvent extends BaseEvent { type: 'text_delta'; @@ -1293,6 +1294,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 9ac1cfde4f..f297b2e674 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -437,6 +437,10 @@ describe('Runtime Host bootstrap protocol', () => { assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 99); }); + test('publishes a new compatibility epoch for context-compaction transcript state', () => { + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 112); + }); + 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 01fba0aa9f..555e5c15ee 100644 --- a/packages/runtime-host/src/__tests__/session-projector.test.ts +++ b/packages/runtime-host/src/__tests__/session-projector.test.ts @@ -912,3 +912,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 3687f6d389..d328022e2c 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,10 @@ 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 } : {}), @@ -541,6 +568,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 b58467c6ee..ac0fdb98f5 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -101,7 +101,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 = 112 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 113 as const; +// 113: Live Turn snapshots carry an optional `rootExecutionKind:'context_compact'` +// so a running context-compaction Turn can render a transcript row. Epoch-112 +// peers reject the added optional field on the strict live snapshot shape. // 112: Owners can query the Host execution environment through an extensible, // bounded resource-envelope contract. Older Hosts do not implement the query. // 111: Client Capability tool schemas may use draft-07 tuple additionalItems. diff --git a/packages/runtime-host/src/protocol/turn.ts b/packages/runtime-host/src/protocol/turn.ts index dafa428649..7e76eb4ef6 100644 --- a/packages/runtime-host/src/protocol/turn.ts +++ b/packages/runtime-host/src/protocol/turn.ts @@ -188,6 +188,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 = @@ -653,6 +661,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 = { @@ -725,7 +740,7 @@ export function decodeTurnSnapshot(value: unknown): TurnSnapshot { record, 'non-terminal Turn snapshot', ['sessionId', 'turnId', 'runId', 'status'], - ['providerRetry'], + ['providerRetry', 'rootExecutionKind'], ); return { ...base, @@ -733,6 +748,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 1ba5b1968c..27cbc80d9c 100644 --- a/packages/runtime-host/src/server/canonical-turn-snapshot.ts +++ b/packages/runtime-host/src/server/canonical-turn-snapshot.ts @@ -102,7 +102,15 @@ export async function readCanonicalTurnSnapshot( // No terminal event means the run is still open. Whether it is parked is the // pending-interaction store's answer, not something the run restates. const parked = await hasPendingInteraction(stores, sessionId, runId); - return { sessionId, turnId, runId, status: parked ? 'waiting_for_user' : 'running' }; + return { + sessionId, + turnId, + runId, + status: parked ? 'waiting_for_user' : 'running', + ...(run.opening.root.kind === 'context_compact' + ? { rootExecutionKind: 'context_compact' as const } + : {}), + }; } /** Is this run waiting on a request the user has not answered? */ diff --git a/packages/runtime/src/__tests__/context-budget.test.ts b/packages/runtime/src/__tests__/context-budget.test.ts index b8e0fdf874..11b8d2e0d4 100644 --- a/packages/runtime/src/__tests__/context-budget.test.ts +++ b/packages/runtime/src/__tests__/context-budget.test.ts @@ -138,7 +138,10 @@ test('compaction notes fire for a fold made by the request hook, not only for a ], }); assert.equal(shouldAppendContextCompactedNote(decision('activeStep', 'replaced')), true); - assert.equal(shouldAppendContextCompactedNote(decision('priorReplay', 'replaced')), true); + // A `priorReplay` `replaced` is a passive re-application of a checkpoint that was + // already noted on its own turn (explicit compaction writes a kernel note there), + // and it re-emits on every later matching send — so it must NOT re-note (#3587). + assert.equal(shouldAppendContextCompactedNote(decision('priorReplay', 'replaced')), false); assert.equal(shouldAppendContextCompactedNote(decision('activeStep', 'failedOpen')), false); assert.equal( shouldAppendContextCompactionFailedOpenNote(decision('activeStep', 'failedOpen')), diff --git a/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts b/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts index 36a3b8e2e9..66d51d0366 100644 --- a/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts +++ b/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts @@ -1285,6 +1285,33 @@ describe('reactive overflow recovery in the streaming backend', () => { ]); }); + test('a passive replay of a held checkpoint writes no context_compacted note (#3587)', async () => { + // Explicit compaction writes its own `context_compacted` note on the + // compaction turn (the kernel). A later normal send passively replays that + // checkpoint — `priorReplay / replaced`, re-emitted on every matching send — + // and must NOT re-note it, or the row duplicates once per send after a + // compaction. The compaction turn's own note is the single retained row. + let carried: HistoryCompactCheckpoint | undefined; + const fixture = buildReactiveFixture({ + script: ['done'], + midTurnEnabled: false, + bigPriors: true, + loadCheckpoint: () => carried, + }); + carried = buildHistoryCompactCheckpoint({ + sessionId: 'session-1', + coveredRuntimeEvents: fixture.priorEvents, + summary: sectionedSummary('EARLIER_TURN_SUMMARY'), + }); + await runTurn(fixture); + const compactedNotes = fixture.messages.filter( + (message) => + (message as { type?: string }).type === 'system_note' && + (message as { kind?: string }).kind === 'context_compacted', + ); + assert.equal(compactedNotes.length, 0, 'a passive replay must not re-note the checkpoint'); + }); + test('a loaded checkpoint the projection refused is not reported as the boundary', async () => { // The difference between the checkpoint a session HOLDS and the one a // prompt was BUILT from. This one covers an event the ledger does not diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index 4aff33d521..ba278fa7e9 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -3603,6 +3603,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(); @@ -3656,6 +3770,16 @@ describe('SessionManager manual compaction and quiescent session changes', () => (run) => run.turnId === 'turn-compact', ); assert.strictEqual(compactRun && runtimeInvocationOutcome(compactRun), '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 () => { @@ -3815,6 +3939,18 @@ describe('SessionManager manual compaction and quiescent session changes', () => (run) => run.turnId === 'turn-compact', ); assert.strictEqual(compactRun && runtimeInvocationOutcome(compactRun), '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 () => { @@ -12043,6 +12179,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 b4eb92a7bd..b656cd3c8b 100644 --- a/packages/runtime/src/context-budget.ts +++ b/packages/runtime/src/context-budget.ts @@ -215,12 +215,16 @@ export function mergeContextBudgetDiagnosticPatches( return mergeContextBudgetDiagnostic(left as ContextBudgetDiagnostic, right); } -// A history fold reaches the user as one note per send, whichever stage -// performed it: the replay of an existing checkpoint at turn start -// (`priorReplay`) or a fold the request-projection hook made before a request -// of this send (`activeStep`, pre_turn or mid_turn). Since #4486 every new fold -// happens in the hook, so a note keyed on replay alone would arrive one turn -// late — the turn that was compacted would show nothing (#4559). +// A history fold reaches the user as one note per send. Since #4486 every fresh +// fold a send performs is an `activeStep` (the request-projection hook), so a +// `replaced` decision warrants a note only when it is that fresh fold. A +// `priorReplay` `replaced` is a passive re-application of a checkpoint that some +// other path already noted on its own turn — explicit compaction writes its own +// note there (`runtime-kernel`) — and `history-compaction.ts` re-emits it on +// every later send whose history still matches, so counting it here would +// duplicate that note again and again (#3587). A `failedOpen` replay is instead +// a genuine event of this send (the fold was dropped and the full history went +// out), so it keeps both stages. function hasHistoryCompactDecision( contextBudget: ContextBudgetDiagnostic | undefined, decision: 'replaced' | 'failedOpen', @@ -228,9 +232,10 @@ function hasHistoryCompactDecision( return ( contextBudget?.compactionDecisions?.some( (candidate) => - (candidate.stage === 'priorReplay' || candidate.stage === 'activeStep') && candidate.boundaryKind === 'historyCompact' && - candidate.decision === decision, + candidate.decision === decision && + (candidate.stage === 'activeStep' || + (decision === 'failedOpen' && candidate.stage === 'priorReplay')), ) === true ); } diff --git a/packages/runtime/src/runtime-kernel.ts b/packages/runtime/src/runtime-kernel.ts index d18cce8d65..851e1a73a1 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -1049,6 +1049,7 @@ export class RuntimeKernel implements RuntimeKernelLike { return; } + let notedTerminal = false; try { if (run.isStopped()) return; if (!begin.backend.compactHistory) { @@ -1093,6 +1094,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', @@ -1101,19 +1114,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(); @@ -1123,6 +1159,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 d40f028623..11591b5a68 100644 --- a/packages/runtime/src/session-event-runtime-mapper.ts +++ b/packages/runtime/src/session-event-runtime-mapper.ts @@ -135,6 +135,9 @@ export function mapSessionEventToRuntimeEvent( if (isHostProjectionSessionEvent(event)) { // These are Host/kernel projection facts, not backend events. The live // ingress drops them, so reaching this line bypassed that authority boundary. + // `context_compaction_started` is one of these: synthesized by the Runtime + // Host session projector for the renderer's live "compacting" row, never + // produced by a backend or the kernel. throw new Error(`${event.type} is not a backend event`); } if (isLegacyPermissionSessionEvent(event)) { @@ -154,6 +157,7 @@ function isHostProjectionSessionEvent(event: SessionEvent): event is Extract< type: | 'queue_update' | 'message_admission' + | 'context_compaction_started' | 'client_capability_request' | 'client_capability_decision_ack'; } @@ -161,6 +165,7 @@ function isHostProjectionSessionEvent(event: SessionEvent): event is Extract< return ( event.type === 'queue_update' || event.type === 'message_admission' || + event.type === 'context_compaction_started' || event.type === 'client_capability_request' || event.type === 'client_capability_decision_ack' ); 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 a196486f51..c0a9ab1630 100644 --- a/packages/ui/src/__tests__/live-turn-projection.test.ts +++ b/packages/ui/src/__tests__/live-turn-projection.test.ts @@ -494,7 +494,7 @@ describe('applyLiveTurnEvent', () => { assert.equal(projection.steps.flatMap((step) => step.tools).length, 1); assert.deepEqual( - overlayLiveTurn([], projection)[0]?.timeline.map((item) => + overlayLiveTurn([], projection, 'en')[0]?.timeline.map((item) => item.kind === 'user' ? `user:${item.message.text}` : item.kind), ['user:before tool', 'text', 'tools', 'user:after tool'], ); @@ -521,7 +521,7 @@ describe('applyLiveTurnEvent', () => { ts: 101, }); - const timeline = overlayLiveTurn([], withLateThinking)[0]?.timeline; + const timeline = overlayLiveTurn([], withLateThinking, 'en')[0]?.timeline; assert.deepEqual(timeline?.map((item) => item.kind), ['tools', 'thinking']); }); @@ -1036,7 +1036,7 @@ describe('tool_result_preview live projection', () => { isError: false, content: { kind: 'text', text: '' }, ts: 5, }); - assert.deepEqual(overlayLiveTurn(turns, settled)[0]?.tools[0]?.result, { + assert.deepEqual(overlayLiveTurn(turns, settled, 'en')[0]?.tools[0]?.result, { kind: 'text', text: '', }); @@ -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-CN')[0]?.notes[0]?.text, + getConversationCopy('zh-CN').messages.systemNotes.contextCompacting, + ); + assert.notEqual( + getConversationCopy('zh-CN').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__/materialize.test.ts b/packages/ui/src/__tests__/materialize.test.ts index 68e2a12f8f..d1339e3b32 100644 --- a/packages/ui/src/__tests__/materialize.test.ts +++ b/packages/ui/src/__tests__/materialize.test.ts @@ -103,7 +103,7 @@ describe("steering timeline", () => { content: { text: "inserted instruction" }, }); - const [overlaid] = overlayLiveTurn(settled, live); + const [overlaid] = overlayLiveTurn(settled, live, "en"); assert.deepEqual(timelineText(overlaid), ["text:before", "user:inserted instruction"]); const persisted = materializeTurns([ @@ -111,7 +111,7 @@ describe("steering timeline", () => { beforeAssistant, { type: "user", id: "steer-1", turnId: "t1", ts: 2, text: "inserted instruction" }, ], "en"); - const [deduplicated] = overlayLiveTurn(persisted, live); + const [deduplicated] = overlayLiveTurn(persisted, live, "en"); assert.deepEqual(timelineText(deduplicated), ["text:before", "user:inserted instruction"]); }); @@ -136,7 +136,7 @@ describe("steering timeline", () => { text: "before", }); - const [overlaid] = overlayLiveTurn(persisted, live); + const [overlaid] = overlayLiveTurn(persisted, live, "en"); assert.deepEqual(timelineText(overlaid), ["text:before", "user:inserted instruction"]); }); @@ -182,7 +182,7 @@ describe("steering timeline", () => { text: "after", }); - const [overlaid] = overlayLiveTurn(persisted, live); + const [overlaid] = overlayLiveTurn(persisted, live, "en"); assert.deepEqual(timelineText(overlaid), ["tools:", "user:steer", "text:after"]); assert.deepEqual( overlaid?.timeline.flatMap((item) => @@ -369,7 +369,7 @@ describe("flat timeline under tool projection (#1307 P1 regression)", () => { ], }, ], - }); + }, "en"); const liveTurn = turns.find((turn) => turn.turnId === "t2"); assert.deepEqual( liveTurn?.timeline.map((item: TurnTimelineItem) => item.kind), @@ -380,7 +380,7 @@ describe("flat timeline under tool projection (#1307 P1 regression)", () => { describe("live content over persisted partial rows", () => { test("does not create an empty renderer turn for a waiting send", () => { - assert.deepEqual(overlayLiveTurn([], armLiveTurn("t1")), []); + assert.deepEqual(overlayLiveTurn([], armLiveTurn("t1"), "en"), []); }); test("replaces persisted thinking with its live projection instead of rendering it twice", () => { @@ -420,7 +420,7 @@ describe("live content over persisted partial rows", () => { tools: [], }, ], - }); + }, "en"); const thinking = turns[0]?.timeline.filter( (item) => item.kind === "thinking", ); @@ -527,7 +527,7 @@ describe("live tool status over persisted", () => { ], }, ], - }); + }, "en"); const tools = turns .find((turn) => turn.turnId === "t1") ?.timeline.find((item: TurnTimelineItem) => item.kind === "tools"); @@ -583,7 +583,7 @@ describe("live tool status over persisted", () => { ], }, ], - }); + }, "en"); const tools = turns .find((turn) => turn.turnId === "t1") ?.timeline.find((item: TurnTimelineItem) => item.kind === "tools"); @@ -631,7 +631,7 @@ describe("live tool status over persisted", () => { args: undefined, ts: 5, }); - const turns = overlayLiveTurn(settled, live); + const turns = overlayLiveTurn(settled, live, "en"); const toolGroup = turns .find((turn) => turn.turnId === "t1") @@ -704,7 +704,7 @@ describe("live tool status over persisted", () => { reason: "user_stop", ts: 6, }); - const tools = overlayLiveTurn(settled, aborted!) + const tools = overlayLiveTurn(settled, aborted!, "en") .find((turn) => turn.turnId === "t1") ?.timeline.find((item: TurnTimelineItem) => item.kind === "tools"); assert.equal( diff --git a/packages/ui/src/__tests__/transcript-projection.test.ts b/packages/ui/src/__tests__/transcript-projection.test.ts index f8e1e17ae7..34df3968da 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-CN' }); + 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 0e2e146298..8582d9ad93 100644 --- a/packages/ui/src/chat-view.tsx +++ b/packages/ui/src/chat-view.tsx @@ -434,8 +434,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); @@ -695,7 +707,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 12ed2c76d6..0268f4907e 100644 --- a/packages/ui/src/conversation-copy.ts +++ b/packages/ui/src/conversation-copy.ts @@ -329,6 +329,7 @@ export interface ConversationCopy { aborted: string; abortedByStop: string; systemNotes: { + contextCompacting: string; contextCompacted: string; contextCompactionFailedOpen: string; contextProviderDropping: (used: number, prior: number) => string; @@ -553,6 +554,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: '上下文摘要失败;本轮已在未生成新摘要的情况下继续。', contextProviderDropping: (used, prior) => @@ -705,6 +707,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: '上下文摘要失敗;本輪已在未生成新摘要的情況下繼續。', contextProviderDropping: (used, prior) => @@ -883,6 +886,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.', contextProviderDropping: (used, prior) => diff --git a/packages/ui/src/live-turn-projection.ts b/packages/ui/src/live-turn-projection.ts index 504dbcb106..879b1777e3 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 204338117c..ec810ef2f0 100644 --- a/packages/ui/src/materialize.ts +++ b/packages/ui/src/materialize.ts @@ -455,11 +455,56 @@ export interface TurnViewModel { export function overlayLiveTurn( turns: readonly TurnViewModel[], liveTurn: LiveTurnProjection | undefined, + locale: UiLocale, ): 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 19575cad6f..e6a80e5378 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);