mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
8bd122c967
commit
a4aba9ec5a
@ -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() {
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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':
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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': '正在创建工作树',
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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'
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user