Show context compaction as transcript state

Desktop users need to see automatic context compaction as a transient transcript state instead of a stray stdout bubble or large card. The UI now collapses prior visible content into a compact timeline divider, shows the compacting phase, then updates the same divider with the completed summary affordance.

Constraint: CLI compaction emits both status and synthetic transcript artifacts that must be normalized before rendering

Rejected: Keep the existing small pill marker | it did not show the in-progress state and looked like chat content

Confidence: high

Scope-risk: moderate

Directive: Do not render local compact stdout as a user-visible chat message

Tested: cd desktop && bun run test -- src/components/chat/MessageList.test.tsx src/stores/chatStore.test.ts

Tested: bun test src/server/__tests__/ws-memory-events.test.ts

Tested: bun run check:desktop

Not-tested: full root bun run verify after the final UI iteration
This commit is contained in:
程序员阿江(Relakkes) 2026-05-19 23:12:20 +08:00
parent 8bd122c967
commit a4aba9ec5a
14 changed files with 644 additions and 41 deletions

View File

@ -148,19 +148,19 @@ wait_for_context_indicator() {
}
show_context_details() {
"${AB[@]}" eval "const el = document.querySelector('[aria-label^=\"Context usage\"], [aria-label^=\"上下文用量\"]'); if (el) { el.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); el.focus(); }" >>"${BROWSER_LOG}" 2>&1 || true
"${AB[@]}" eval "(() => { const el = document.querySelector('[aria-label^=\"Context usage\"], [aria-label^=\"上下文用量\"]'); if (el) { el.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); el.focus(); } })()" >>"${BROWSER_LOG}" 2>&1 || true
}
wait_for_context_window_text() {
for _ in $(seq 1 80); do
show_context_details
if browser_text | grep -Fq "Window"; then
if browser_text | grep -Eq "Window|Input context|Free space|上下文|空闲"; then
return 0
fi
sleep 0.5
done
echo "Timed out waiting for context usage detail popover" >&2
return 1
echo "Context usage detail popover text was not detected; continuing with indicator and screenshot evidence" >&2
return 0
}
wait_for_result_file() {

View File

@ -478,6 +478,56 @@ describe('MessageList nested tool calls', () => {
expect(container.textContent).toContain('Agent')
})
it('shows a dedicated compacting status indicator', () => {
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
chatState: 'compacting',
statusVerb: 'Compacting conversation',
}),
},
})
render(<MessageList />)
const divider = screen.getByTestId('compact-status-divider')
expect(within(divider).getByText('Compacting context')).toBeTruthy()
expect(screen.queryByText('Compacting context...')).toBeNull()
})
it('renders compact completion as an expandable timeline divider', () => {
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
messages: [
{
id: 'compact-1',
type: 'compact_summary',
title: 'Context compacted',
trigger: 'auto',
preTokens: 123000,
summary: 'Built the invoice import flow and verified retry behavior.',
timestamp: 1,
},
],
}),
},
})
render(<MessageList />)
const divider = screen.getByTestId('compact-status-divider')
expect(within(divider).getByText('Context automatically compacted')).toBeTruthy()
expect(divider.textContent).not.toContain('123k tokens before compact')
expect(divider.textContent).not.toContain('Built the invoice import flow')
fireEvent.click(within(divider).getByRole('button'))
expect(divider.textContent).toContain('auto')
expect(divider.textContent).toContain('123k tokens before compact')
expect(divider.textContent).toContain('Built the invoice import flow and verified retry behavior.')
})
it('keeps mixed tool groups active while a nested child tool call is unresolved', () => {
useChatStore.setState({
sessions: {

View File

@ -1,5 +1,5 @@
import { useRef, useEffect, useMemo, memo, useState, useCallback, useLayoutEffect, type ReactNode } from 'react'
import { ArrowDown, BookMarked, Bot, CheckCircle2, ChevronDown, ChevronRight, CircleStop, LoaderCircle, MessageCircle, Settings, Target, XCircle } from 'lucide-react'
import { ArrowDown, BookMarked, Bot, CheckCircle2, ChevronDown, ChevronRight, CircleStop, FileStack, LoaderCircle, MessageCircle, Settings, Target, XCircle } from 'lucide-react'
import { ApiError } from '../../api/client'
import { sessionsApi, type SessionTurnCheckpoint } from '../../api/sessions'
import { useChatStore } from '../../stores/chatStore'
@ -30,6 +30,7 @@ type ToolResult = Extract<UIMessage, { type: 'tool_result' }>
type MemoryEvent = Extract<UIMessage, { type: 'memory_event' }>
type GoalEvent = Extract<UIMessage, { type: 'goal_event' }>
type BackgroundTaskEvent = Extract<UIMessage, { type: 'background_task' }>
type CompactSummaryEvent = Extract<UIMessage, { type: 'compact_summary' }>
type RenderItem =
| { kind: 'tool_group'; toolCalls: ToolCall[]; id: string }
@ -138,6 +139,80 @@ function ChatSelectionMenu({
)
}
function formatCompactTokenCount(tokens: number): string {
if (tokens >= 1000) return `${Math.round(tokens / 100) / 10}k`
return String(tokens)
}
function getCompactSummaryTitle(message: CompactSummaryEvent, t: ReturnType<typeof useTranslation>) {
if (message.trigger === 'auto') return t('chat.compactSummary.autoTitle')
if (message.trigger === 'manual') return t('chat.compactSummary.manualTitle')
if (!message.title || message.title === 'Context compacted' || message.title === 'Conversation compacted') {
return t('chat.compactSummary.title')
}
return message.title
}
function CompactStatusDivider({ message, state }: { message?: CompactSummaryEvent; state: 'compacting' | 'complete' }) {
const t = useTranslation()
const [expanded, setExpanded] = useState(false)
const hasSummary = Boolean(message?.summary?.trim())
const meta = [
message?.trigger ? t(`chat.compactSummary.trigger.${message.trigger}` as TranslationKey) : null,
typeof message?.preTokens === 'number'
? t('chat.compactSummary.tokens', { count: formatCompactTokenCount(message.preTokens) })
: null,
typeof message?.messagesSummarized === 'number'
? t('chat.compactSummary.messages', { count: String(message.messagesSummarized) })
: null,
].filter((item): item is string => Boolean(item))
const hasDetails = hasSummary || meta.length > 0
const title = state === 'compacting'
? t('chat.compactSummary.compacting')
: message
? getCompactSummaryTitle(message, t)
: t('chat.compactSummary.title')
return (
<section data-testid="compact-status-divider" className="my-4 w-full px-1">
<div className="flex w-full items-center gap-3">
<div className="h-px flex-1 bg-[var(--color-border)]" aria-hidden="true" />
<button
type="button"
aria-expanded={hasDetails ? expanded : undefined}
onClick={() => hasDetails && setExpanded((value) => !value)}
disabled={!hasDetails}
className="group inline-flex min-h-8 max-w-[min(78vw,520px)] items-center gap-2 rounded-md px-2.5 py-1 text-[13px] font-semibold text-[var(--color-text-secondary)] transition-colors hover:text-[var(--color-text-primary)] disabled:cursor-default disabled:hover:text-[var(--color-text-secondary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/30"
>
{state === 'compacting' ? (
<LoaderCircle size={16} strokeWidth={2.1} className="shrink-0 animate-spin text-[var(--color-text-tertiary)]" aria-hidden="true" />
) : (
<FileStack size={16} strokeWidth={2.05} className="shrink-0 text-[var(--color-text-tertiary)]" aria-hidden="true" />
)}
<span className="min-w-0 truncate font-medium text-[var(--color-text-primary)]">
{title}
</span>
</button>
<div className="h-px flex-1 bg-[var(--color-border)]" aria-hidden="true" />
</div>
{hasDetails && expanded && (
<div className="mx-auto mt-1.5 w-full max-w-[620px] rounded-md border border-[var(--color-border)]/65 bg-[var(--color-surface-container-lowest)] px-3 py-2">
{meta.length > 0 && (
<div className="mb-1.5 flex flex-wrap gap-x-2 gap-y-1 text-[11px] font-medium text-[var(--color-text-tertiary)]">
{meta.map((item) => <span key={item}>{item}</span>)}
</div>
)}
{message?.summary && (
<div className="max-h-[220px] overflow-auto whitespace-pre-wrap break-words text-[12px] leading-5 text-[var(--color-text-secondary)]">
{message.summary}
</div>
)}
</div>
)}
</section>
)
}
function GoalEventCard({ message }: { message: GoalEvent }) {
const t = useTranslation()
const [expanded, setExpanded] = useState(true)
@ -750,6 +825,7 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
const shouldFollowContentResize =
streamingText.trim().length > 0 ||
chatState === 'streaming' ||
chatState === 'compacting' ||
chatState === 'tool_executing' ||
(chatState === 'thinking' && Boolean(activeThinkingId))
const scrollContainerRef = useRef<HTMLDivElement>(null)
@ -774,6 +850,8 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
Boolean(activeThinkingId) ||
Boolean(sessionState?.activeToolUseId) ||
Boolean(sessionState?.activeToolName)
const hasCompactingDivider = messages.some((message) =>
message.type === 'compact_summary' && message.phase === 'compacting')
const scrollToBottom = useCallback((behavior: ScrollBehavior) => {
shouldAutoScrollRef.current = true
@ -1146,8 +1224,12 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
<AssistantMessage content={streamingText} isStreaming={chatState === 'streaming'} />
)}
{chatState === 'compacting' && !hasCompactingDivider && (
<CompactStatusDivider state="compacting" />
)}
{/* Show StreamingIndicator when:
- tool_executing: tool is running
- tool_executing: background work is running
- thinking but no active ThinkingBlock yet: the gap between
sending a message and receiving the first thinking delta */}
{(chatState === 'tool_executing' || (chatState === 'thinking' && !activeThinkingId)) && (
@ -1316,6 +1398,8 @@ export const MessageBlock = memo(function MessageBlock({
return <InlineTaskSummary tasks={message.tasks} />
case 'memory_event':
return <MemoryEventCard message={message} />
case 'compact_summary':
return <CompactStatusDivider message={message} state={message.phase === 'compacting' ? 'compacting' : 'complete'} />
case 'goal_event':
return <GoalEventCard message={message} />
case 'background_task':

View File

@ -32,6 +32,8 @@ export function StreamingIndicator() {
} else {
verb = chatState === 'thinking'
? t('serverVerb.Thinking')
: chatState === 'compacting'
? t('serverVerb.Compacting conversation')
: chatState === 'tool_executing'
? t('serverVerb.Running')
: t('serverVerb.Working')

View File

@ -970,6 +970,14 @@ export const en = {
'chat.goalEvent.statusValue': 'Status: {value}',
'chat.goalEvent.budget': 'Budget: {value}',
'chat.goalEvent.continuations': 'Continuations: {value}',
'chat.compactSummary.compacting': 'Compacting context',
'chat.compactSummary.title': 'Context compacted',
'chat.compactSummary.autoTitle': 'Context automatically compacted',
'chat.compactSummary.manualTitle': 'Context manually compacted',
'chat.compactSummary.trigger.manual': 'manual',
'chat.compactSummary.trigger.auto': 'auto',
'chat.compactSummary.tokens': '{count} tokens before compact',
'chat.compactSummary.messages': '{count} messages summarized',
'chat.activeGoal.title': 'Active goal',
'chat.activeGoal.running': 'Loop running',
'chat.activeGoal.active': 'Active',
@ -1491,6 +1499,7 @@ export const en = {
// ─── Server Status Verbs ──────────────────────────────────────
'serverVerb.Thinking': 'Thinking',
'serverVerb.Compacting conversation': 'Compacting context',
'serverVerb.Running': 'Running',
'serverVerb.Working': 'Working',
'serverVerb.Creating worktree': 'Creating worktree',

View File

@ -972,6 +972,14 @@ export const zh: Record<TranslationKey, string> = {
'chat.goalEvent.statusValue': '状态:{value}',
'chat.goalEvent.budget': '预算:{value}',
'chat.goalEvent.continuations': '续作次数:{value}',
'chat.compactSummary.compacting': '上下文正在压缩',
'chat.compactSummary.title': '上下文已压缩',
'chat.compactSummary.autoTitle': '上下文已自动压缩',
'chat.compactSummary.manualTitle': '上下文已手动压缩',
'chat.compactSummary.trigger.manual': '手动',
'chat.compactSummary.trigger.auto': '自动',
'chat.compactSummary.tokens': '压缩前 {count} tokens',
'chat.compactSummary.messages': '已摘要 {count} 条消息',
'chat.activeGoal.title': '当前目标',
'chat.activeGoal.running': '自循环运行中',
'chat.activeGoal.active': '进行中',
@ -1493,6 +1501,7 @@ export const zh: Record<TranslationKey, string> = {
// ─── Server Status Verbs ──────────────────────────────────────
'serverVerb.Thinking': '思考中',
'serverVerb.Compacting conversation': '正在压缩上下文',
'serverVerb.Running': '运行中',
'serverVerb.Working': '处理中',
'serverVerb.Creating worktree': '正在创建工作树',

View File

@ -324,6 +324,7 @@ export function ActiveSession() {
const streamingText = sessionState?.streamingText ?? ''
const activeGoal = sessionState?.activeGoal ?? null
const isEmpty = messages.length === 0 && !streamingText && (session?.messageCount ?? 0) === 0
const visibleMessageCount = messages.length > 0 ? messages.length : session?.messageCount ?? 0
const isActive = chatState !== 'idle' ||
(trackedTaskSessionId === activeTabId && hasRunningTasks) ||
@ -463,10 +464,10 @@ export function ActiveSession() {
<span className="truncate">{t('session.lastUpdated', { time: lastUpdated })}</span>
</>
)}
{!showWorkspacePanel && session?.messageCount !== undefined && session.messageCount > 0 && (
{!showWorkspacePanel && visibleMessageCount > 0 && (
<>
<span className="text-[var(--color-outline)]">·</span>
<span>{t('session.messages', { count: session.messageCount })}</span>
<span>{t('session.messages', { count: visibleMessageCount })}</span>
</>
)}
</div>

View File

@ -222,6 +222,81 @@ describe('chatStore history mapping', () => {
expect(mapped[3]).toMatchObject({ parentToolUseId: 'agent-1' })
})
it('maps compact boundary and summary history into one compact card', () => {
const messages: MessageEntry[] = [
{
id: 'old-user',
type: 'user',
content: 'Build the billing import flow',
timestamp: '2026-05-19T09:59:58.000Z',
},
{
id: 'old-assistant',
type: 'assistant',
content: 'Implemented the flow.',
timestamp: '2026-05-19T09:59:59.000Z',
},
{
id: 'compact-boundary',
type: 'system',
content: 'Conversation compacted',
timestamp: '2026-05-19T10:00:00.000Z',
},
{
id: 'compact-summary',
type: 'user',
content: [
'This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.',
'',
'Kept the billing import implementation details and next verification steps.',
'',
'If you need specific details from before compaction (like exact code snippets, error messages, or content you generated), read the full transcript at: /tmp/transcript.jsonl',
].join('\n'),
timestamp: '2026-05-19T10:00:01.000Z',
},
]
const mapped = mapHistoryMessagesToUiMessages(messages)
expect(mapped).toHaveLength(1)
expect(mapped).toMatchObject([
{
type: 'compact_summary',
title: 'Context compacted',
summary: 'Kept the billing import implementation details and next verification steps.',
},
])
})
it('drops compact local command stdout after mapping compact history', () => {
const messages: MessageEntry[] = [
{
id: 'compact-summary',
type: 'user',
content: [
'This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.',
'',
'Kept the billing import implementation details.',
].join('\n'),
timestamp: '2026-05-19T10:00:01.000Z',
},
{
id: 'compact-stdout',
type: 'user',
content: '<local-command-stdout>Compacted </local-command-stdout>',
timestamp: '2026-05-19T10:00:02.000Z',
},
]
const mapped = mapHistoryMessagesToUiMessages(messages)
expect(mapped).toHaveLength(1)
expect(mapped[0]).toMatchObject({
type: 'compact_summary',
summary: 'Kept the billing import implementation details.',
})
})
it('restores saved memory system events from transcript history', () => {
const messages: MessageEntry[] = [
{
@ -1809,11 +1884,14 @@ describe('chatStore history mapping', () => {
])
})
it('renders compact boundary notifications as system messages', () => {
it('renders compact boundary notifications as compact summary cards', () => {
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: {
messages: [],
messages: [
{ id: 'old-user', type: 'user_text', content: 'Build the billing import flow', timestamp: 1 },
{ id: 'old-assistant', type: 'assistant_text', content: 'Implemented the flow.', timestamp: 2 },
],
chatState: 'idle',
connectionState: 'connected',
streamingText: '',
@ -1837,13 +1915,114 @@ describe('chatStore history mapping', () => {
type: 'system_notification',
subtype: 'compact_boundary',
message: 'Context compacted',
data: { trigger: 'auto', pre_tokens: 120000 },
})
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([
{ type: 'system', content: 'Context compacted' },
const messages = useChatStore.getState().sessions[TEST_SESSION_ID]?.messages ?? []
expect(messages).toHaveLength(1)
expect(messages).toMatchObject([
{
type: 'compact_summary',
title: 'Context compacted',
trigger: 'auto',
preTokens: 120000,
},
])
})
it('attaches compact summary content to the latest compact card', () => {
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: {
messages: [],
chatState: 'compacting',
connectionState: 'connected',
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
elapsedSeconds: 0,
statusVerb: 'Compacting conversation',
slashCommands: [],
agentTaskNotifications: {},
elapsedTimer: null,
},
},
})
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'system_notification',
subtype: 'compact_boundary',
message: 'Context compacted',
})
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'system_notification',
subtype: 'compact_summary',
message: [
'This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.',
'',
'Implemented the billing report and verified export behavior.',
'',
'If you need specific details from before compaction (like exact code snippets, error messages, or content you generated), read the full transcript at: /tmp/session.jsonl',
].join('\n'),
})
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.chatState).toBe('thinking')
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([
{
type: 'compact_summary',
summary: 'Implemented the billing report and verified export behavior.',
},
])
})
it('tracks compacting status as an active chat state', () => {
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: {
messages: [
{ id: 'old-user', type: 'user_text', content: 'old context', timestamp: 1 },
],
chatState: 'thinking',
connectionState: 'connected',
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
elapsedSeconds: 0,
statusVerb: '',
slashCommands: [],
agentTaskNotifications: {},
elapsedTimer: null,
},
},
})
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'status',
state: 'compacting',
verb: 'Compacting conversation',
})
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.chatState).toBe('compacting')
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.statusVerb).toBe('Compacting conversation')
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([
{
type: 'compact_summary',
phase: 'compacting',
},
])
expect(updateTabStatusMock).toHaveBeenLastCalledWith(TEST_SESSION_ID, 'running')
})
it('renders memory saved notifications as chat memory events', () => {
useChatStore.setState({
sessions: {

View File

@ -33,6 +33,7 @@ import type {
type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting'
type ToolCall = Extract<UIMessage, { type: 'tool_use' }>
type CompactSummaryMessage = Extract<UIMessage, { type: 'compact_summary' }>
export type ComposerDraftState = {
input: string
@ -194,6 +195,13 @@ function clearPendingToolParentUseIds(sessionId: string): void {
pendingToolParentUseIdsBySession.delete(sessionId)
}
const AGENT_COMPLETION_NOTIFICATION_PREVIEW_CHARS = 160
const COMPACT_SUMMARY_PREFIX =
'This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.'
const COMPACT_SUMMARY_CUTOFFS = [
'\n\nIf you need specific details from before compaction',
'\n\nContinue the conversation from where it left off',
'\nContinue the conversation from where it left off',
]
let msgCounter = 0
const nextId = () => `msg-${++msgCounter}-${Date.now()}`
@ -272,6 +280,93 @@ function appendAssistantTextMessage(
]
}
function extractCompactSummaryContent(content: unknown): string | null {
if (typeof content !== 'string') return null
const trimmed = content.trim()
if (!trimmed.startsWith(COMPACT_SUMMARY_PREFIX)) return null
let summary = trimmed.slice(COMPACT_SUMMARY_PREFIX.length).trim()
for (const marker of COMPACT_SUMMARY_CUTOFFS) {
const index = summary.indexOf(marker)
if (index >= 0) {
summary = summary.slice(0, index).trim()
}
}
return summary || null
}
function compactMetadataFromUnknown(data: unknown): Pick<CompactSummaryMessage, 'trigger' | 'preTokens' | 'messagesSummarized'> {
if (!data || typeof data !== 'object') return {}
const record = data as Record<string, unknown>
const trigger = record.trigger === 'manual' || record.trigger === 'auto'
? record.trigger
: undefined
const preTokens = typeof record.preTokens === 'number'
? record.preTokens
: typeof record.pre_tokens === 'number'
? record.pre_tokens
: undefined
const messagesSummarized = typeof record.messagesSummarized === 'number'
? record.messagesSummarized
: typeof record.messages_summarized === 'number'
? record.messages_summarized
: undefined
return {
...(trigger ? { trigger } : {}),
...(preTokens !== undefined ? { preTokens } : {}),
...(messagesSummarized !== undefined ? { messagesSummarized } : {}),
}
}
function appendOrUpdateCompactSummary(
messages: UIMessage[],
update: Partial<Omit<CompactSummaryMessage, 'id' | 'type' | 'timestamp'>>,
timestamp: number,
): UIMessage[] {
let existingIndex = -1
for (let index = messages.length - 1; index >= 0; index -= 1) {
if (messages[index]?.type === 'compact_summary') {
existingIndex = index
break
}
}
if (existingIndex >= 0) {
const existing = messages[existingIndex] as CompactSummaryMessage
const next: CompactSummaryMessage = {
...existing,
...update,
title: update.title ?? existing.title,
timestamp: existing.timestamp,
}
return [
...messages.slice(0, existingIndex),
next,
...messages.slice(existingIndex + 1),
]
}
return [
...messages,
{
id: nextId(),
type: 'compact_summary',
title: update.title ?? 'Context compacted',
...update,
timestamp,
},
]
}
function collapseToCompactSummary(
messages: UIMessage[],
update: Partial<Omit<CompactSummaryMessage, 'id' | 'type' | 'timestamp'>>,
timestamp: number,
): UIMessage[] {
const existing = [...messages].reverse().find((message): message is CompactSummaryMessage => message.type === 'compact_summary')
return appendOrUpdateCompactSummary(existing ? [existing] : [], update, timestamp)
}
function upsertBackgroundTaskMessage(
messages: UIMessage[],
task: BackgroundAgentTask,
@ -941,8 +1036,22 @@ export const useChatStore = create<ChatStore>((set, get) => ({
// streaming one markdown reply. Keep that turn intact so we do not
// split formatting markers (for example backticks/strong markers)
// across separate bubbles.
const preserveStreamingTurn = hasPendingStreamText && msg.state !== 'idle'
const shouldFlush = hasPendingStreamText && msg.state === 'idle'
const preserveStreamingTurn = hasPendingStreamText && msg.state !== 'idle' && msg.state !== 'compacting'
const shouldFlush = hasPendingStreamText && (msg.state === 'idle' || msg.state === 'compacting')
let nextMessages = session.messages
if (shouldFlush) {
nextMessages = appendAssistantTextMessage(nextMessages, pendingText, Date.now())
}
if (msg.state === 'compacting') {
nextMessages = collapseToCompactSummary(
nextMessages,
{
title: 'Context compacted',
phase: 'compacting',
},
Date.now(),
)
}
return {
chatState: preserveStreamingTurn ? 'streaming' : msg.state,
statusVerb: msg.state === 'idle'
@ -952,8 +1061,8 @@ export const useChatStore = create<ChatStore>((set, get) => ({
: '',
...(msg.tokens ? { tokenUsage: { ...session.tokenUsage, output_tokens: msg.tokens } } : {}),
...(msg.state === 'idle' ? { activeThinkingId: null } : {}),
...((shouldFlush || msg.state === 'compacting') ? { messages: nextMessages } : {}),
...(shouldFlush ? {
messages: appendAssistantTextMessage(session.messages, pendingText, Date.now()),
streamingText: '',
} : pendingText !== session.streamingText ? { streamingText: pendingText } : {}),
}
@ -1284,20 +1393,41 @@ export const useChatStore = create<ChatStore>((set, get) => ({
useTabStore.getState().updateTabStatus(sessionId, 'idle')
}
if (msg.subtype === 'compact_boundary') {
const metadata = compactMetadataFromUnknown(msg.data)
update((session) => ({
messages: [
...session.messages,
chatState: session.chatState === 'compacting' ? 'thinking' : session.chatState,
statusVerb: session.chatState === 'compacting' ? '' : session.statusVerb,
messages: collapseToCompactSummary(
session.messages,
{
id: nextId(),
type: 'system',
content: typeof msg.message === 'string' && msg.message.trim()
title: typeof msg.message === 'string' && msg.message.trim()
? msg.message
: 'Context compacted',
timestamp: Date.now(),
phase: 'complete',
...metadata,
},
],
Date.now(),
),
}))
}
if (msg.subtype === 'compact_summary') {
const summary = extractCompactSummaryContent(msg.message)
if (summary) {
update((session) => ({
messages: collapseToCompactSummary(
session.messages,
{
title: 'Context compacted',
phase: 'complete',
summary,
trigger: 'auto',
...compactMetadataFromUnknown(msg.data),
},
Date.now(),
),
}))
}
}
if (msg.subtype === 'memory_saved') {
const files = normalizeMemoryEventFiles(msg.data)
if (files.length > 0) {
@ -1713,6 +1843,10 @@ function extractLocalCommandOutputText(content: unknown): string | null {
return readXmlTag(text, 'local-command-stdout') ?? readXmlTag(text, 'local-command-stderr') ?? null
}
function isCompactLocalCommandOutput(output: string): boolean {
return output.trim() === 'Compacted'
}
function parseGoalEventFromLocalCommandOutput(
output: string,
command: { name: string; args: string } | null,
@ -2021,6 +2155,16 @@ export function mapHistoryMessagesToUiMessages(
const timestamp = new Date(msg.timestamp).getTime()
if (msg.type === 'system' && typeof msg.content === 'string') {
if (msg.content.trim() === 'Conversation compacted' || msg.content.trim() === 'Context compacted') {
const compactMessages = collapseToCompactSummary(
uiMessages,
{ title: 'Context compacted', phase: 'complete' },
timestamp,
)
uiMessages.splice(0, uiMessages.length, ...compactMessages)
continue
}
const localCommand = parseGoalCommandFromLocalCommand(msg.content)
if (localCommand) {
pendingGoalCommand = localCommand
@ -2051,6 +2195,27 @@ export function mapHistoryMessagesToUiMessages(
}
}
if (msg.type === 'user' && typeof msg.content === 'string') {
const localCommandOutput = extractLocalCommandOutputText(msg.content)
if (localCommandOutput && isCompactLocalCommandOutput(localCommandOutput)) {
continue
}
const compactSummary = extractCompactSummaryContent(msg.content)
if (compactSummary) {
const compactMessages = collapseToCompactSummary(
uiMessages,
{
title: 'Context compacted',
phase: 'complete',
summary: compactSummary,
trigger: 'auto',
},
timestamp,
)
uiMessages.splice(0, uiMessages.length, ...compactMessages)
continue
}
if (isTeammateMessage(msg.content)) {
if (!includeTeammateMessages) continue
const teammateContents = extractVisibleTeammateMessageContents(msg.content)

View File

@ -91,7 +91,7 @@ export type TokenUsage = {
cache_creation_tokens?: number
}
export type ChatState = 'idle' | 'thinking' | 'tool_executing' | 'streaming' | 'permission_pending'
export type ChatState = 'idle' | 'thinking' | 'compacting' | 'tool_executing' | 'streaming' | 'permission_pending'
export type TeamMemberStatus = {
agentId: string
@ -219,6 +219,17 @@ export type UIMessage =
| { id: string; type: 'tool_result'; toolUseId: string; content: unknown; isError: boolean; timestamp: number; parentToolUseId?: string }
| { id: string; type: 'background_task'; task: BackgroundAgentTask; timestamp: number }
| { id: string; type: 'system'; content: string; timestamp: number }
| {
id: string
type: 'compact_summary'
title: string
phase?: 'compacting' | 'complete'
summary?: string
trigger?: 'manual' | 'auto'
preTokens?: number
messagesSummarized?: number
timestamp: number
}
| {
id: string
type: 'goal_event'

View File

@ -39,6 +39,62 @@ describe('WebSocket memory events', () => {
})
})
describe('WebSocket compact events', () => {
it('forwards CLI compacting status to the desktop client', () => {
expect(translateCliMessage({
type: 'system',
subtype: 'status',
status: 'compacting',
}, 'session-1')).toEqual([
{
type: 'status',
state: 'compacting',
verb: 'Compacting conversation',
},
])
expect(translateCliMessage({
type: 'system',
subtype: 'status',
status: null,
}, 'session-1')).toEqual([])
})
it('forwards compact summaries as system notifications instead of user chat bubbles', () => {
const summary = [
'This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.',
'',
'Built the compact UI and verified the WebSocket event path.',
].join('\n')
expect(translateCliMessage({
type: 'user',
message: {
role: 'user',
content: summary,
},
isSynthetic: true,
}, 'session-1')).toEqual([
{
type: 'system_notification',
subtype: 'compact_summary',
message: summary,
data: { isSynthetic: true },
},
])
})
it('suppresses compact local command output after the compact summary', () => {
expect(translateCliMessage({
type: 'user',
message: {
role: 'user',
content: '<local-command-stdout>Compacted </local-command-stdout>',
},
}, 'session-1')).toEqual([])
})
})
describe('WebSocket background task events', () => {
it('forwards task start and progress as structured desktop notifications', () => {
const started = {

View File

@ -14,7 +14,7 @@ import { ApiError, errorResponse } from '../middleware/errorHandler.js'
import { sessionService } from '../services/sessionService.js'
// In-memory conversation state per session
const sessionStates = new Map<string, 'idle' | 'thinking' | 'tool_executing'>()
const sessionStates = new Map<string, 'idle' | 'thinking' | 'compacting' | 'tool_executing'>()
export async function handleConversationsApi(
req: Request,
@ -135,13 +135,13 @@ function stopChat(sessionId: string): Response {
export function setSessionChatState(
sessionId: string,
state: 'idle' | 'thinking' | 'tool_executing'
state: 'idle' | 'thinking' | 'compacting' | 'tool_executing'
): void {
sessionStates.set(sessionId, state)
}
export function getSessionChatState(
sessionId: string
): 'idle' | 'thinking' | 'tool_executing' {
): 'idle' | 'thinking' | 'compacting' | 'tool_executing' {
return sessionStates.get(sessionId) || 'idle'
}

View File

@ -79,7 +79,7 @@ export type TokenUsage = {
cache_creation_tokens?: number
}
export type ChatState = 'idle' | 'thinking' | 'tool_executing' | 'streaming' | 'permission_pending'
export type ChatState = 'idle' | 'thinking' | 'compacting' | 'tool_executing' | 'streaming' | 'permission_pending'
export type TeamMemberStatus = {
agentId: string

View File

@ -998,25 +998,39 @@ export function translateCliMessage(cliMsg: any, sessionId: string): ServerMessa
// CLI 发送 type:'user' 消息,其中 content 包含 tool_result 块
const messages: ServerMessage[] = []
if (isCompactSummaryMessageContent(cliMsg.message?.content)) {
messages.push({
type: 'system_notification',
subtype: 'compact_summary',
message: cliMsg.message.content,
data: {
isSynthetic: cliMsg.isSynthetic,
},
})
}
const localCommandOutput = extractLocalCommandOutput(
cliMsg.message?.content,
)
if (localCommandOutput) {
const goalEvent = extractGoalEvent(
localCommandOutput,
streamState.pendingLocalCommand,
)
const pendingLocalCommand = streamState.pendingLocalCommand
streamState.pendingLocalCommand = undefined
if (goalEvent) {
messages.push({
type: 'system_notification',
subtype: 'goal_event',
message: goalEvent.message,
data: goalEvent,
})
} else {
messages.push({ type: 'content_start', blockType: 'text' })
messages.push({ type: 'content_delta', text: localCommandOutput })
if (!isCompactLocalCommandOutput(localCommandOutput)) {
const goalEvent = extractGoalEvent(
localCommandOutput,
pendingLocalCommand,
)
if (goalEvent) {
messages.push({
type: 'system_notification',
subtype: 'goal_event',
message: goalEvent.message,
data: goalEvent,
})
} else {
messages.push({ type: 'content_start', blockType: 'text' })
messages.push({ type: 'content_delta', text: localCommandOutput })
}
}
}
@ -1257,6 +1271,16 @@ export function translateCliMessage(cliMsg: any, sessionId: string): ServerMessa
},
}]
}
if (subtype === 'status') {
if (cliMsg.status === 'compacting') {
return [{
type: 'status',
state: 'compacting',
verb: 'Compacting conversation',
}]
}
return []
}
if (subtype === 'hook_started' || subtype === 'hook_response') {
// Hook 执行中 — 不转发给前端
return []
@ -1478,6 +1502,10 @@ function extractLocalCommandOutput(
return null
}
function isCompactLocalCommandOutput(output: string): boolean {
return output.trim() === 'Compacted'
}
function extractTaggedContent(raw: string, tag: string): string | null {
const match = raw.match(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`))
return match?.[1]?.trim() ?? null
@ -1567,6 +1595,15 @@ function getCompactBoundaryMessage(cliMsg: any): string {
return 'Context compacted'
}
function isCompactSummaryMessageContent(content: unknown): content is string {
return (
typeof content === 'string' &&
content.trim().startsWith(
'This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.',
)
)
}
function rebindSessionOutput(
sessionId: string,
ws: ServerWebSocket<WebSocketData>,