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);