{
+ const MAX_ATTEMPTS = 3;
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
+ try {
+ await this.deps.store.appendMessage(sessionId, note);
+ return true;
+ } catch {
+ if (attempt === MAX_ATTEMPTS) return false;
+ }
+ }
+ return false;
+ }
+
private async requireContextCompactionBackend(
sessionId: string,
header: SessionHeader,
diff --git a/packages/runtime/src/session-event-runtime-mapper.ts b/packages/runtime/src/session-event-runtime-mapper.ts
index 63e251c84e..03a08fe695 100644
--- a/packages/runtime/src/session-event-runtime-mapper.ts
+++ b/packages/runtime/src/session-event-runtime-mapper.ts
@@ -135,6 +135,14 @@ export function mapSessionEventToRuntimeEvent(
// ingress drops them, so reaching this line bypassed that authority boundary.
throw new Error(`${event.type} is not a backend event`);
}
+ if (event.type === 'context_compaction_started') {
+ // Presentation-only: synthesized by the Runtime Host session projector for
+ // the renderer's live "compacting" row. Never produced by a backend or the
+ // kernel, and excluded from BackendSessionEvent like queue_update.
+ throw new Error(
+ 'context_compaction_started is not a backend event: the Host projector is its only producer',
+ );
+ }
if (isLegacyPermissionSessionEvent(event)) {
throw new Error(`${event.type} is a legacy permission event and is not backend-mappable`);
}
@@ -146,6 +154,7 @@ export function isLiveBackendSessionEvent(event: SessionEvent): event is Backend
return (
event.type !== 'queue_update' &&
event.type !== 'message_admission' &&
+ event.type !== 'context_compaction_started' &&
!isLegacyPermissionSessionEvent(event)
);
}
diff --git a/packages/ui/src/__tests__/chat-view-empty-compaction.test.tsx b/packages/ui/src/__tests__/chat-view-empty-compaction.test.tsx
new file mode 100644
index 0000000000..61edf716a8
--- /dev/null
+++ b/packages/ui/src/__tests__/chat-view-empty-compaction.test.tsx
@@ -0,0 +1,70 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { renderToStaticMarkup } from 'react-dom/server';
+import type { SessionSummary } from '@maka/core/session';
+import { ChatSurfaceLayout } from '../chat-surface-layout.js';
+import { ChatView } from '../chat-view.js';
+import type { LiveTurnProjection } from '../live-turn-projection.js';
+import { LocaleProvider } from '../locale-context.js';
+
+const activeSession = {
+ id: 'session-1',
+ name: 'Session',
+ status: 'running',
+ labels: [] as string[],
+} as unknown as SessionSummary;
+
+function renderChat(liveTurn?: LiveTurnProjection): string {
+ return renderToStaticMarkup(
+
+
+ undefined}
+ />
+
+ ,
+ );
+}
+
+test('renders the live compaction row in a session with no settled messages', () => {
+ const markup = renderChat({
+ turnId: 'turn-compact',
+ phase: 'waiting',
+ rootExecutionKind: 'context_compact',
+ startedAt: 0,
+ steps: [],
+ });
+
+ // Before the fix, showEmptyState hid this overlaid row behind the empty hero
+ // because it keyed off chat.length (0) and never saw the synthesized turn.
+ assert.match(markup, /Compacting context/);
+});
+
+test('renders the empty hero when an empty session has no live compaction row', () => {
+ const markup = renderChat(undefined);
+
+ assert.doesNotMatch(markup, /Compacting context/);
+});
diff --git a/packages/ui/src/__tests__/live-turn-projection.test.ts b/packages/ui/src/__tests__/live-turn-projection.test.ts
index 63d788c85f..9317abe575 100644
--- a/packages/ui/src/__tests__/live-turn-projection.test.ts
+++ b/packages/ui/src/__tests__/live-turn-projection.test.ts
@@ -1073,3 +1073,108 @@ function previewedSubagentTurn(): LiveTurnProjection {
ts: 101,
});
}
+
+describe('context-compaction live row', () => {
+ it('arms a rootExecutionKind projection from a context_compaction_started event', () => {
+ const projection = applyLiveTurnEvent(undefined, {
+ type: 'context_compaction_started',
+ id: 'compaction-started-1',
+ turnId: 'turn-compact',
+ ts: 1,
+ });
+ assert.ok(projection);
+ assert.equal(projection.turnId, 'turn-compact');
+ assert.equal(projection.rootExecutionKind, 'context_compact');
+ assert.equal(projection.steps.length, 0);
+ });
+
+ it('overlays exactly one localized "compacting" system row while running', () => {
+ const projection = applyLiveTurnEvent(undefined, {
+ type: 'context_compaction_started',
+ id: 'compaction-started-1',
+ turnId: 'turn-compact',
+ ts: 1,
+ });
+ const turns = overlayLiveTurn([], projection, 'en');
+ assert.equal(turns.length, 1);
+ assert.equal(turns[0]?.turnId, 'turn-compact');
+ assert.equal(turns[0]?.status, 'running');
+ assert.equal(turns[0]?.notes.length, 1);
+ assert.equal(
+ turns[0]?.notes[0]?.text,
+ getConversationCopy('en').messages.systemNotes.contextCompacting,
+ );
+ });
+
+ it('merges the compacting note into an already-persisted running turn', () => {
+ // Production persists a `turn_state:running` row for the compaction turn, so
+ // materializeTurns yields an empty running turn before the live row arrives.
+ const settled = [
+ {
+ turnId: 'turn-compact',
+ status: 'running' as const,
+ statusSource: 'recorded' as const,
+ partialOutputRetained: false,
+ tools: [],
+ notes: [],
+ timeline: [],
+ startedAt: 5,
+ },
+ ];
+ const projection = applyLiveTurnEvent(undefined, {
+ type: 'context_compaction_started',
+ id: 'compaction-started-1',
+ turnId: 'turn-compact',
+ ts: 7,
+ });
+ const turns = overlayLiveTurn(settled, projection, 'en');
+ assert.equal(turns.length, 1);
+ assert.equal(turns[0]?.turnId, 'turn-compact');
+ assert.equal(turns[0]?.notes.length, 1);
+ assert.equal(
+ turns[0]?.notes[0]?.text,
+ getConversationCopy('en').messages.systemNotes.contextCompacting,
+ );
+ assert.equal(turns[0]?.notes[0]?.id, 'context-compaction:turn-compact');
+ // Deterministic ts (no Date.now()): the note borrows the settled turn's start.
+ assert.equal(turns[0]?.notes[0]?.ts, 5);
+ // Idempotent across reprojection — no duplicate note.
+ const again = overlayLiveTurn(turns, projection, 'en');
+ assert.equal(again[0]?.notes.length, 1);
+ });
+
+ it('localizes the compacting row per locale', () => {
+ const projection = applyLiveTurnEvent(undefined, {
+ type: 'context_compaction_started',
+ id: 'compaction-started-1',
+ turnId: 'turn-compact',
+ ts: 1,
+ });
+ assert.equal(
+ overlayLiveTurn([], projection, 'zh')[0]?.notes[0]?.text,
+ getConversationCopy('zh').messages.systemNotes.contextCompacting,
+ );
+ assert.notEqual(
+ getConversationCopy('zh').messages.systemNotes.contextCompacting,
+ getConversationCopy('en').messages.systemNotes.contextCompacting,
+ );
+ });
+
+ it('drops the row when the compaction turn completes with no content', () => {
+ let projection = applyLiveTurnEvent(undefined, {
+ type: 'context_compaction_started',
+ id: 'compaction-started-1',
+ turnId: 'turn-compact',
+ ts: 1,
+ });
+ projection = applyLiveTurnEvent(projection, {
+ type: 'complete',
+ id: 'complete-1',
+ turnId: 'turn-compact',
+ ts: 2,
+ stopReason: 'end_turn',
+ });
+ assert.equal(projection, undefined);
+ assert.deepEqual(overlayLiveTurn([], projection, 'en'), []);
+ });
+});
diff --git a/packages/ui/src/__tests__/transcript-projection.test.ts b/packages/ui/src/__tests__/transcript-projection.test.ts
index 559ce849fe..90ba24242e 100644
--- a/packages/ui/src/__tests__/transcript-projection.test.ts
+++ b/packages/ui/src/__tests__/transcript-projection.test.ts
@@ -100,6 +100,24 @@ describe('incremental transcript projection', () => {
assert.notStrictEqual(chinese, english);
});
+ test('a locale change updates the live context-compaction row text', () => {
+ const projection = createTranscriptProjection();
+ // Empty messages keep the settled turns reference stable (NO_TURNS) across
+ // the locale switch, so only the overlay locale guard can re-localize the
+ // live "compacting" row.
+ const liveTurn: LiveTurnProjection = {
+ turnId: 'turn-compact',
+ phase: 'waiting',
+ steps: [],
+ rootExecutionKind: 'context_compact',
+ startedAt: 1,
+ };
+ const english = projection.project({ sessionId: SESSION, messages: [], liveTurn, locale: 'en' });
+ const chinese = projection.project({ sessionId: SESSION, messages: [], liveTurn, locale: 'zh' });
+ assert.equal(english[0]?.notes[0]?.text, 'Compacting context…');
+ assert.equal(chinese[0]?.notes[0]?.text, '正在压缩上下文…');
+ });
+
test('a shell-run update whose semantics are unchanged affects nothing', () => {
const projection = createTranscriptProjection();
const messages = history();
diff --git a/packages/ui/src/chat-view.tsx b/packages/ui/src/chat-view.tsx
index af4ec6cf23..f483cd9cfe 100644
--- a/packages/ui/src/chat-view.tsx
+++ b/packages/ui/src/chat-view.tsx
@@ -399,8 +399,20 @@ export function ChatView(props: {
// being in-flight are separate signals. Wait indicators alone still mark
// streaming, but delayed flags can lag one frame past complete; terminal
// evidence must outrank them so copy/regenerate stay actionable.
- const liveInFlight = !!(props.liveTurn && !props.liveTurn.terminal);
- const streamingActive = liveInFlight || (!props.liveTurn?.terminal && !!props.runningStatus);
+ // A live context-compaction Turn is not an assistant stream: it renders one
+ // system row (see overlayLiveTurn), not a streaming tail. Keeping it out of
+ // liveInFlight/streamingActive stops chat-turn from adding an empty assistant
+ // article, the generic "pondering" spinner, and a footer placeholder on top.
+ const isCompactionLive = props.liveTurn?.rootExecutionKind === 'context_compact';
+ // overlayLiveTurn renders one "compacting" system row for a live compaction
+ // Turn that has no assistant steps — including in a session with no settled
+ // chat messages yet. The empty-state decision (below) keys off `chat.length`,
+ // which does not see that overlaid row, so it must treat this as visible
+ // content or the row is hidden behind the empty hero.
+ const hasLiveCompactionRow = isCompactionLive && (props.liveTurn?.steps.length ?? 0) === 0;
+ const liveInFlight = !!(props.liveTurn && !props.liveTurn.terminal) && !isCompactionLive;
+ const streamingActive =
+ liveInFlight || (!props.liveTurn?.terminal && !!props.runningStatus && !isCompactionLive);
const tailTurnId = liveInFlight
? props.liveTurn!.turnId
: (streamingActive ? turns[turns.length - 1]?.turnId : undefined);
@@ -605,7 +617,8 @@ export function ChatView(props: {
chat.length === 0
&& transientMessages.length === 0
&& !streamingActive
- && !hasVisibleConversationItem;
+ && !hasVisibleConversationItem
+ && !hasLiveCompactionRow;
const emptyContent = props.messageLoading
? (
diff --git a/packages/ui/src/conversation-copy.ts b/packages/ui/src/conversation-copy.ts
index 7c6773e24f..8b5ff2ae30 100644
--- a/packages/ui/src/conversation-copy.ts
+++ b/packages/ui/src/conversation-copy.ts
@@ -307,6 +307,7 @@ export interface ConversationCopy {
aborted: string;
abortedByStop: string;
systemNotes: {
+ contextCompacting: string;
contextCompacted: string;
contextCompactionFailedOpen: string;
stepLimit: string;
@@ -502,6 +503,7 @@ const CONVERSATION_COPY = {
userAriaLabel: '你发送的消息', systemAriaLabel: '系统消息', assistantAriaLabel: 'Maka 的回答', answerActionsAriaLabel: (context) => `回答操作${context ? `:${context}` : ''}`, answerActionAriaLabel: (action, context) => `${action}回答${context ? `:${context}` : ''}`, messageActionAriaLabel: (action, context) => `${action}消息${context ? `:${context}` : ''}`, sourceAriaLabel: '本轮回答的来源', derivativesAriaLabel: '本轮回答的衍生', scheduledTaskTriggered: '定时任务触发', scheduledTaskTitle: (id) => `由定时任务触发 · ${id}`, legacyAutomationTriggered: '旧版自动化(仅历史)', legacyAutomationTitle: (id) => `由旧版自动化触发 · ${id} · 仅保留历史,不会再次执行`, goalContinued: 'Goal 自动继续', goalTitle: (id) => `由 Goal 继续执行 · ${id}`, agentGraphTriggered: 'Agent Graph 自动继续', agentGraphTitle: (graphId) => `由 Agent Graph 调度器触发 · ${graphId}`,
thinkingTruncatedTitle: '部分 reasoning 已截断;显示的是最近的内容', outputTruncatedTitle: '助手输出已超过单次回合上限,超出部分未渲染。如需完整内容请重新生成或查看持久化的任务日志。', removeAttachmentAriaLabel: (name) => `移除 ${name}`, quoteLabel: '引用', quoteExpandAriaLabel: '展开引用全文', quoteCollapseAriaLabel: '收起引用', removeQuoteAriaLabel: '移除引用', aborted: '已中断', abortedByStop: '已中断 · 由停止按钮触发',
systemNotes: {
+ contextCompacting: '正在压缩上下文…',
contextCompacted: '已压缩较早的对话内容,以适应模型上下文窗口。',
contextCompactionFailedOpen: '上下文摘要失败;本轮已在未生成新摘要的情况下继续。',
stepLimit: '已达到本轮工具步骤上限,任务可能尚未完成。发送“继续”即可接着处理。',
@@ -650,6 +652,7 @@ const CONVERSATION_COPY = {
userAriaLabel: 'Your message', systemAriaLabel: 'System message', assistantAriaLabel: "Maka's response", answerActionsAriaLabel: (context) => `Response actions${context ? `: ${context}` : ''}`, answerActionAriaLabel: (action, context) => `${action} response${context ? `: ${context}` : ''}`, messageActionAriaLabel: (action, context) => `${action} message${context ? `: ${context}` : ''}`, sourceAriaLabel: 'Source of this response', derivativesAriaLabel: 'Responses derived from this one', scheduledTaskTriggered: 'Triggered by scheduled task', scheduledTaskTitle: (id) => `Triggered by scheduled task · ${id}`, legacyAutomationTriggered: 'Legacy Automation (history only)', legacyAutomationTitle: (id) => `Triggered by legacy Automation · ${id} · Historical only; it will not run again`, goalContinued: 'Continued by Goal', goalTitle: (id) => `Continued by Goal · ${id}`, agentGraphTriggered: 'Continued by Agent Graph', agentGraphTitle: (graphId) => `Triggered by the Agent Graph scheduler · ${graphId}`,
thinkingTruncatedTitle: 'Some reasoning was truncated; showing the most recent content', outputTruncatedTitle: 'The assistant output exceeded the per-turn limit. Regenerate it or inspect the persisted task log for the complete content.', removeAttachmentAriaLabel: (name) => `Remove ${name}`, quoteLabel: 'Quote', quoteExpandAriaLabel: 'Show the full quoted excerpt', quoteCollapseAriaLabel: 'Collapse the quoted excerpt', removeQuoteAriaLabel: 'Remove quote', aborted: 'Interrupted', abortedByStop: 'Interrupted · Stop button',
systemNotes: {
+ contextCompacting: 'Compacting context…',
contextCompacted: 'Context compacted to keep this session within the model window.',
contextCompactionFailedOpen: 'Context summary failed; the session continued without a new summary.',
stepLimit: 'Reached the configured step limit. The task may be incomplete. Send “continue” to resume.',
diff --git a/packages/ui/src/live-turn-projection.ts b/packages/ui/src/live-turn-projection.ts
index fd5f74afb2..8dad5c3a62 100644
--- a/packages/ui/src/live-turn-projection.ts
+++ b/packages/ui/src/live-turn-projection.ts
@@ -93,6 +93,16 @@ export interface LiveTurnProjection {
turnId: string;
phase: 'waiting' | 'streamed';
terminal?: true;
+ /**
+ * Set when this live Turn is a host-owned explicit context-compaction run.
+ * A `context_compact` Turn emits no assistant content, so `overlayLiveTurn`
+ * renders a single "compacting" system row from this flag while the Turn is in
+ * flight; the row disappears when the Turn settles (no durable turn state).
+ */
+ rootExecutionKind?: 'context_compact';
+ /** Event ts of the first authority word about this Turn; a stable ts for the
+ * synthesized "compacting" row so reprojection does not churn identity. */
+ startedAt?: number;
/** Steering acknowledged after the current content and awaiting its next provider step. */
pendingSteering?: LiveSteeringProjection[];
/**
@@ -248,6 +258,13 @@ export function applyLiveTurnEvent(
steps: terminalizeLiveSteps(current.steps),
};
}
+ if (event.type === 'context_compaction_started') {
+ const prior =
+ current?.turnId === event.turnId
+ ? current
+ : { turnId: event.turnId, phase: 'waiting' as const, steps: [] };
+ return { ...confirmed(prior), rootExecutionKind: 'context_compact', startedAt: event.ts };
+ }
if (
event.type !== 'thinking_delta'
&& event.type !== 'thinking_complete'
diff --git a/packages/ui/src/materialize.ts b/packages/ui/src/materialize.ts
index 13c15cff93..cef6c692fb 100644
--- a/packages/ui/src/materialize.ts
+++ b/packages/ui/src/materialize.ts
@@ -413,11 +413,56 @@ export interface TurnViewModel {
export function overlayLiveTurn(
turns: readonly TurnViewModel[],
liveTurn: LiveTurnProjection | undefined,
+ locale: UiLocale = "en",
): readonly TurnViewModel[] {
if (!liveTurn) return turns;
const targetIndex = turns.findIndex(
(turn) => turn.turnId === liveTurn.turnId,
);
+ // A running host-owned context-compaction Turn emits no assistant content.
+ // The Runtime persists a `turn_state:running` row for it, so a settled turn
+ // with this turnId usually already exists (empty). Surface a single
+ // "compacting" system row: merge the note into that existing turn, or
+ // synthesize one if it has not settled yet. The note is deduped by id so
+ // reprojection stays idempotent, and it disappears when the Turn settles
+ // (the live projection drops to undefined and the durable `context_compacted`
+ // note takes over).
+ if (liveTurn.rootExecutionKind === "context_compact" && liveTurn.steps.length === 0) {
+ const noteId = `context-compaction:${liveTurn.turnId}`;
+ if (targetIndex >= 0) {
+ const existing = turns[targetIndex]!;
+ if (existing.notes.some((note) => note.id === noteId)) return turns;
+ const note: ChatItem = {
+ id: noteId,
+ role: "system",
+ text: getConversationCopy(locale).messages.systemNotes.contextCompacting,
+ ts: existing.startedAt,
+ };
+ return turns.map((turn, index) =>
+ index === targetIndex ? { ...turn, notes: [...turn.notes, note] } : turn,
+ );
+ }
+ const startedAt = liveTurn.startedAt ?? 0;
+ return [
+ ...turns,
+ {
+ turnId: liveTurn.turnId,
+ status: "running" as const,
+ partialOutputRetained: false,
+ tools: [],
+ notes: [
+ {
+ id: noteId,
+ role: "system",
+ text: getConversationCopy(locale).messages.systemNotes.contextCompacting,
+ ts: startedAt,
+ },
+ ],
+ timeline: [],
+ startedAt,
+ } satisfies TurnViewModel,
+ ];
+ }
if (
targetIndex >= 0
&& liveTurn.steps.length === 0
diff --git a/packages/ui/src/transcript-projection.ts b/packages/ui/src/transcript-projection.ts
index 08ab6cf75a..03c216fa78 100644
--- a/packages/ui/src/transcript-projection.ts
+++ b/packages/ui/src/transcript-projection.ts
@@ -89,6 +89,10 @@ export function createTranscriptProjection(): TranscriptProjection {
// Tracked separately from `lastMessages` because a refresh can leave the
// settled projection untouched, which must not force the live overlay to run.
let liveTurnsFrom: readonly TurnViewModel[] | undefined;
+ // The locale the overlay last ran with. The live "compacting" row is localized
+ // inside overlayLiveTurn, so a locale switch that leaves the settled turns
+ // reference unchanged (identity reconciliation) must still re-run the overlay.
+ let lastOverlayLocale: UiLocale | undefined;
let overlayEntries: ReadonlyMap = new Map();
let lastTurns: readonly TurnViewModel[] = NO_TURNS;
@@ -101,6 +105,7 @@ export function createTranscriptProjection(): TranscriptProjection {
settledTurns = NO_TURNS;
liveTurns = NO_TURNS;
liveTurnsFrom = undefined;
+ lastOverlayLocale = undefined;
overlayEntries = new Map();
lastTurns = NO_TURNS;
}
@@ -137,10 +142,15 @@ export function createTranscriptProjection(): TranscriptProjection {
lastMessages = input.messages;
lastLocale = input.locale;
}
- if (liveTurnsFrom !== settledTurns || input.liveTurn !== lastLiveTurn) {
- liveTurns = overlayLiveTurn(settledTurns, input.liveTurn);
+ if (
+ liveTurnsFrom !== settledTurns ||
+ input.liveTurn !== lastLiveTurn ||
+ input.locale !== lastOverlayLocale
+ ) {
+ liveTurns = overlayLiveTurn(settledTurns, input.liveTurn, input.locale);
liveTurnsFrom = settledTurns;
lastLiveTurn = input.liveTurn;
+ lastOverlayLocale = input.locale;
}
if (updatesMoved) {
overlayEntries = foldShellRunUpdates(updates);