cc-haha/desktop/src/stores/chatStore.test.ts
程序员阿江(Relakkes) d668a8e0d1 Restore completed goals from resumed transcripts
Resumed desktop sessions keep the /goal lifecycle in transcript files, but the CLI status command only checked the in-memory goal map. The desktop history mapper also treated the local command breadcrumb as internal state, so the original /goal prompt disappeared when reopening a session.

This hydrates /goal command state from the current transcript before lifecycle operations and renders historical /goal command breadcrumbs as visible user messages. Query-only negative status output is kept informational so an old broken "No active goal." response cannot erase an earlier completed goal.

Constraint: Goal state is process memory at runtime, while session resume relies on persisted JSONL transcript records.

Rejected: Persist a second goal database | the transcript already contains the authoritative command lifecycle and avoids a new storage migration.

Confidence: high

Scope-risk: narrow

Directive: Treat /goal status output as a query result, not as lifecycle mutation; only explicit clear output should remove restored state.

Tested: bun test src/commands/goal/goal.test.tsx src/goals/goalState.test.ts src/goals/goalEvaluator.test.ts

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

Tested: bun run check:desktop

Tested: real transcript hydrate for 3e9117d5-b792-43c9-bf57-7aec2b124f7e returned Goal: complete

Tested: bun test src/server/__tests__/h5-access-auth.test.ts -t 'blocks remote browser SDK requests even under explicit server auth'

Not-tested: Full check:server completed 726/727 tests; one unrelated H5 auth hook timed out in the full suite and passed on isolated rerun.
2026-05-16 00:43:32 +08:00

2294 lines
67 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { MessageEntry } from '../types/session'
import { useSessionRuntimeStore } from './sessionRuntimeStore'
const {
sendMock,
getMemberBySessionIdMock,
sendMessageToMemberMock,
handleTeamCreatedMock,
handleTeamUpdateMock,
handleTeamDeletedMock,
fetchSessionTasksMock,
clearTasksMock,
setTasksFromTodosMock,
markCompletedAndDismissedMock,
resetCompletedTasksMock,
refreshTasksMock,
notifyDesktopMock,
updateTabTitleMock,
updateTabStatusMock,
updateSessionTitleMock,
sessionStoreSnapshot,
cliTaskStoreSnapshot,
} = vi.hoisted(() => ({
sendMock: vi.fn(),
getMemberBySessionIdMock: vi.fn<(sessionId: string) => any>(() => null),
sendMessageToMemberMock: vi.fn(async () => {}),
handleTeamCreatedMock: vi.fn(),
handleTeamUpdateMock: vi.fn(),
handleTeamDeletedMock: vi.fn(),
fetchSessionTasksMock: vi.fn(),
clearTasksMock: vi.fn(),
setTasksFromTodosMock: vi.fn(),
markCompletedAndDismissedMock: vi.fn(),
resetCompletedTasksMock: vi.fn(async () => {}),
refreshTasksMock: vi.fn(),
notifyDesktopMock: vi.fn(),
updateTabTitleMock: vi.fn(),
updateTabStatusMock: vi.fn(),
updateSessionTitleMock: vi.fn(),
sessionStoreSnapshot: {
sessions: [] as Array<{
id: string
title: string
createdAt: string
modifiedAt: string
messageCount: number
projectPath: string
workDir: string | null
workDirExists: boolean
}>,
},
cliTaskStoreSnapshot: {
tasks: [] as Array<{ id: string; subject: string; status: string; activeForm?: string }>,
sessionId: null as string | null,
},
}))
vi.mock('../lib/desktopNotifications', () => ({
notifyDesktop: notifyDesktopMock,
}))
vi.mock('../api/websocket', () => ({
wsManager: {
connect: vi.fn(),
disconnect: vi.fn(),
onMessage: vi.fn(() => () => {}),
clearHandlers: vi.fn(),
send: sendMock,
},
}))
vi.mock('../api/sessions', () => ({
sessionsApi: {
getMessages: vi.fn(async () => ({ messages: [] })),
getSlashCommands: vi.fn(async () => ({ commands: [] })),
},
}))
vi.mock('./teamStore', () => ({
useTeamStore: {
getState: () => ({
getMemberBySessionId: getMemberBySessionIdMock,
sendMessageToMember: sendMessageToMemberMock,
handleTeamCreated: handleTeamCreatedMock,
handleTeamUpdate: handleTeamUpdateMock,
handleTeamDeleted: handleTeamDeletedMock,
}),
},
}))
vi.mock('./tabStore', () => ({
useTabStore: {
getState: () => ({
updateTabStatus: updateTabStatusMock,
updateTabTitle: updateTabTitleMock,
}),
},
}))
vi.mock('./sessionStore', () => ({
useSessionStore: {
getState: () => ({
sessions: sessionStoreSnapshot.sessions,
updateSessionTitle: updateSessionTitleMock,
}),
},
}))
vi.mock('./cliTaskStore', () => ({
useCLITaskStore: {
getState: () => ({
fetchSessionTasks: fetchSessionTasksMock,
tasks: cliTaskStoreSnapshot.tasks,
sessionId: cliTaskStoreSnapshot.sessionId,
clearTasks: clearTasksMock,
setTasksFromTodos: setTasksFromTodosMock,
markCompletedAndDismissed: markCompletedAndDismissedMock,
resetCompletedTasks: resetCompletedTasksMock,
refreshTasks: refreshTasksMock,
}),
},
}))
import { sessionsApi } from '../api/sessions'
import {
mapHistoryMessagesToUiMessages,
reconstructAgentNotifications,
type PerSessionState,
useChatStore,
} from './chatStore'
const TEST_SESSION_ID = 'test-session-1'
const initialState = useChatStore.getState()
function makeSession(overrides: Partial<PerSessionState> = {}): PerSessionState {
return {
messages: [],
chatState: 'streaming',
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,
...overrides,
}
}
describe('chatStore history mapping', () => {
beforeEach(() => {
sendMock.mockReset()
getMemberBySessionIdMock.mockReset()
getMemberBySessionIdMock.mockReturnValue(null)
sendMessageToMemberMock.mockReset()
fetchSessionTasksMock.mockReset()
clearTasksMock.mockReset()
setTasksFromTodosMock.mockReset()
markCompletedAndDismissedMock.mockReset()
resetCompletedTasksMock.mockReset()
refreshTasksMock.mockReset()
notifyDesktopMock.mockReset()
updateTabTitleMock.mockReset()
updateTabStatusMock.mockReset()
updateSessionTitleMock.mockReset()
sessionStoreSnapshot.sessions = []
cliTaskStoreSnapshot.tasks = []
cliTaskStoreSnapshot.sessionId = null
useSessionRuntimeStore.setState({ selections: {} })
localStorage.clear()
useChatStore.setState({
...initialState,
sessions: {},
})
})
it('preserves thinking blocks when restoring transcript history', () => {
const messages: MessageEntry[] = [
{
id: 'assistant-1',
type: 'assistant',
timestamp: '2026-04-06T00:00:00.000Z',
model: 'opus',
parentToolUseId: 'agent-1',
content: [
{ type: 'thinking', thinking: 'internal reasoning' },
{ type: 'text', text: '目录结构分析' },
{ type: 'tool_use', name: 'Read', id: 'tool-1', input: { file_path: 'src/App.tsx' } },
],
},
{
id: 'user-1',
type: 'user',
timestamp: '2026-04-06T00:00:01.000Z',
parentToolUseId: 'agent-1',
content: [
{ type: 'tool_result', tool_use_id: 'tool-1', content: 'ok', is_error: false },
],
},
]
const mapped = mapHistoryMessagesToUiMessages(messages)
expect(mapped.map((message) => message.type)).toEqual([
'thinking',
'assistant_text',
'tool_use',
'tool_result',
])
expect(mapped[2]).toMatchObject({ parentToolUseId: 'agent-1' })
expect(mapped[3]).toMatchObject({ parentToolUseId: 'agent-1' })
})
it('restores saved memory system events from transcript history', () => {
const messages: MessageEntry[] = [
{
id: 'memory-1',
type: 'system',
timestamp: '2026-04-06T00:00:00.000Z',
content: {
subtype: 'memory_saved',
writtenPaths: ['/Users/test/.claude/projects/example/memory/preferences.md'],
teamCount: 0,
},
},
]
const mapped = mapHistoryMessagesToUiMessages(messages)
expect(mapped).toMatchObject([
{
id: 'memory-1',
type: 'memory_event',
event: 'saved',
files: [
{
path: '/Users/test/.claude/projects/example/memory/preferences.md',
action: 'saved',
},
],
},
])
})
it('restores /goal local command output from transcript history', () => {
const messages: MessageEntry[] = [
{
id: 'goal-command',
type: 'system',
timestamp: '2026-04-06T00:00:00.000Z',
content: '<command-name>/goal</command-name>\n<command-args>--tokens 2k ship the smoke test</command-args>',
},
{
id: 'goal-output',
type: 'system',
timestamp: '2026-04-06T00:00:01.000Z',
content: [
'<local-command-stdout>',
'Goal created.',
'Goal: active',
'Objective: ship the smoke test',
'Budget: 0 / 2,000 tokens',
'Elapsed: 0s',
'Continuations: 0',
'</local-command-stdout>',
].join('\n'),
},
]
expect(mapHistoryMessagesToUiMessages(messages)).toMatchObject([
{
id: 'goal-command',
type: 'user_text',
content: '/goal --tokens 2k ship the smoke test',
},
{
id: 'goal-output',
type: 'goal_event',
action: 'created',
status: 'active',
objective: 'ship the smoke test',
budget: '0 / 2,000 tokens',
continuations: '0',
},
])
})
it('restores replacement /goal output as a replacement event', () => {
const messages: MessageEntry[] = [
{
id: 'goal-command',
type: 'system',
timestamp: '2026-04-06T00:00:00.000Z',
content: '<command-name>/goal</command-name>\n<command-args>ship the replacement target</command-args>',
},
{
id: 'goal-output',
type: 'system',
timestamp: '2026-04-06T00:00:01.000Z',
content: [
'<local-command-stdout>',
'Goal replaced.',
'Goal: active',
'Objective: ship the replacement target',
'Budget: 0 / unlimited tokens',
'Elapsed: 0s',
'Continuations: 0',
'</local-command-stdout>',
].join('\n'),
},
]
expect(mapHistoryMessagesToUiMessages(messages)).toMatchObject([
{
id: 'goal-command',
type: 'user_text',
content: '/goal ship the replacement target',
},
{
id: 'goal-output',
type: 'goal_event',
action: 'replaced',
status: 'active',
objective: 'ship the replacement target',
budget: '0 / unlimited tokens',
},
])
})
it('restores completed /goal state from transcript history after app restart', async () => {
vi.mocked(sessionsApi.getMessages).mockResolvedValueOnce({
messages: [
{
id: 'goal-command',
type: 'system',
timestamp: '2026-04-06T00:00:00.000Z',
content: '<command-name>/goal</command-name>\n<command-args>ship the smoke test</command-args>',
},
{
id: 'goal-output',
type: 'system',
timestamp: '2026-04-06T00:00:01.000Z',
content: [
'<local-command-stdout>',
'Goal created.',
'Goal: active',
'Objective: ship the smoke test',
'Budget: 0 / 2,000 tokens',
'Elapsed: 0s',
'Continuations: 0',
'</local-command-stdout>',
].join('\n'),
},
{
id: 'goal-complete',
type: 'system',
timestamp: '2026-04-06T00:00:02.000Z',
content: '<local-command-stdout>Goal marked complete.</local-command-stdout>',
},
{
id: 'goal-status-command',
type: 'system',
timestamp: '2026-04-06T00:00:03.000Z',
content: '<command-name>/goal</command-name>\n<command-args>status</command-args>',
},
{
id: 'goal-stale-empty-output',
type: 'system',
timestamp: '2026-04-06T00:00:04.000Z',
content: '<local-command-stdout>No active goal.</local-command-stdout>',
},
],
})
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: makeSession({ messages: [] }),
},
})
await useChatStore.getState().loadHistory(TEST_SESSION_ID)
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([
{
id: 'goal-command',
type: 'user_text',
content: '/goal ship the smoke test',
},
{
id: 'goal-output',
type: 'goal_event',
action: 'created',
objective: 'ship the smoke test',
},
{
id: 'goal-complete',
type: 'goal_event',
action: 'completed',
message: 'Goal marked complete.',
},
{
id: 'goal-status-command',
type: 'user_text',
content: '/goal status',
},
{
id: 'goal-stale-empty-output',
type: 'goal_event',
action: 'message',
message: 'No active goal.',
},
])
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.activeGoal).toMatchObject({
action: 'completed',
status: 'complete',
objective: 'ship the smoke test',
})
})
it('merges consecutive assistant text blocks when restoring transcript history', () => {
const messages: MessageEntry[] = [
{
id: 'assistant-merge-1',
type: 'assistant',
timestamp: '2026-04-06T00:00:00.000Z',
model: 'opus',
content: [
{ type: 'text', text: '第一段Windows 下的桌面端输出。' },
{ type: 'text', text: '\r\n第二段刷新后也不应该被拆开。' },
],
},
]
const mapped = mapHistoryMessagesToUiMessages(messages)
expect(mapped).toMatchObject([
{
type: 'assistant_text',
content: '第一段Windows 下的桌面端输出。\r\n第二段刷新后也不应该被拆开。',
},
])
})
it('skips whitespace-only assistant transcript messages', () => {
const messages: MessageEntry[] = [
{
id: 'assistant-empty',
type: 'assistant',
timestamp: '2026-04-06T00:00:00.000Z',
model: 'opus',
content: '\n\n ',
},
{
id: 'assistant-real',
type: 'assistant',
timestamp: '2026-04-06T00:00:01.000Z',
model: 'opus',
content: '可见回复',
},
]
const mapped = mapHistoryMessagesToUiMessages(messages)
expect(mapped).toMatchObject([
{
id: 'assistant-real',
type: 'assistant_text',
content: '可见回复',
},
])
})
it('filters task-notification turns and resumes at the next real user message', () => {
const messages: MessageEntry[] = [
{
id: 'user-real-1',
type: 'user',
timestamp: '2026-04-06T00:00:00.000Z',
content: '创建项目',
},
{
id: 'assistant-real-1',
type: 'assistant',
timestamp: '2026-04-06T00:00:01.000Z',
content: [{ type: 'text', text: '项目创建好了' }],
},
{
id: 'task-notification',
type: 'user',
timestamp: '2026-04-06T00:00:02.000Z',
content: '<task-notification>\n<task-id>bg-1</task-id>\n<tool-use-id>toolu_bg</tool-use-id>\n<status>completed</status>\n<summary>Background command completed</summary>\n</task-notification>',
},
{
id: 'assistant-task-response',
type: 'assistant',
timestamp: '2026-04-06T00:00:03.000Z',
content: [{ type: 'text', text: '旧后台任务通知,无需处理' }],
},
{
id: 'user-real-2',
type: 'user',
timestamp: '2026-04-06T00:00:04.000Z',
content: '继续真实问题',
},
]
const mapped = mapHistoryMessagesToUiMessages(messages)
expect(mapped).toMatchObject([
{
id: 'user-real-1',
type: 'user_text',
content: '创建项目',
},
{
type: 'assistant_text',
content: '项目创建好了',
},
{
id: 'user-real-2',
type: 'user_text',
content: '继续真实问题',
},
])
expect(JSON.stringify(mapped)).not.toContain('<task-notification>')
expect(JSON.stringify(mapped)).not.toContain('旧后台任务通知')
})
it('reconstructs task notifications from transcript XML before filtering it from UI', () => {
const restored = reconstructAgentNotifications([
{
id: 'task-notification',
type: 'user',
timestamp: '2026-04-06T00:00:00.000Z',
content: '<task-notification>\n<task-id>bg-1</task-id>\n<tool-use-id>toolu_bg</tool-use-id>\n<status>completed</status>\n<summary>Background command &amp; agent done</summary>\n<output-file>C:\\Temp\\bg.output</output-file>\n</task-notification>',
},
])
expect(restored).toEqual({
toolu_bg: {
taskId: 'bg-1',
toolUseId: 'toolu_bg',
status: 'completed',
summary: 'Background command & agent done',
outputFile: 'C:\\Temp\\bg.output',
},
})
})
it('surfaces teammate prompt content when mapping member transcript history', () => {
const messages: MessageEntry[] = [
{
id: 'user-1',
type: 'user',
timestamp: '2026-04-06T00:00:00.000Z',
content: '<teammate-message teammate_id="security-reviewer">Review the auth diff and call out risks.</teammate-message>',
},
]
const mapped = mapHistoryMessagesToUiMessages(messages, {
includeTeammateMessages: true,
})
expect(mapped).toMatchObject([
{
type: 'user_text',
content: 'Review the auth diff and call out risks.',
},
])
})
it('preserves source user ids when restoring array-content user prompts', () => {
const messages: MessageEntry[] = [
{
id: 'user-with-attachment',
type: 'user',
timestamp: '2026-04-06T00:00:00.000Z',
content: [
{ type: 'text', text: '请看这个文件' },
{ type: 'file', name: 'report.md' },
],
},
]
const mapped = mapHistoryMessagesToUiMessages(messages)
expect(mapped).toMatchObject([
{
id: 'user-with-attachment',
type: 'user_text',
content: '请看这个文件',
attachments: [{ type: 'file', name: 'report.md' }],
},
])
})
it('restores CLI file mentions as visible attachment chips from transcript history', () => {
const messages: MessageEntry[] = [
{
id: 'user-with-file-mention',
type: 'user',
timestamp: '2026-04-06T00:00:00.000Z',
content: '@"/private/tmp/example/src/sentinel.ts" 这个常量是什么?',
},
]
const mapped = mapHistoryMessagesToUiMessages(messages)
expect(mapped).toMatchObject([
{
id: 'user-with-file-mention',
type: 'user_text',
content: '这个常量是什么?',
modelContent: '@"/private/tmp/example/src/sentinel.ts" 这个常量是什么?',
attachments: [{
type: 'file',
name: 'sentinel.ts',
path: '/private/tmp/example/src/sentinel.ts',
}],
},
])
})
it('keeps workspace reference chips visible while sending CLI attachment paths', () => {
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: {
messages: [],
chatState: 'idle',
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().sendMessage(
TEST_SESSION_ID,
'Referenced workspace context:\n@"src/App.tsx:L4":\nComment: tighten this\n```tsx\nconst value = 1\n```',
[{
type: 'file',
name: 'App.tsx',
path: '/repo/src/App.tsx',
lineStart: 4,
lineEnd: 4,
note: 'tighten this',
quote: 'const value = 1',
}],
{
displayContent: '改这里',
displayAttachments: [{
type: 'file',
name: 'App.tsx',
path: 'src/App.tsx',
lineStart: 4,
lineEnd: 4,
note: 'tighten this',
quote: 'const value = 1',
}],
},
)
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([
{
type: 'user_text',
content: '改这里',
modelContent: '@"/repo/src/App.tsx" Referenced workspace context:\n@"src/App.tsx:L4":\nComment: tighten this\n```tsx\nconst value = 1\n```',
attachments: [{
type: 'file',
name: 'App.tsx',
path: 'src/App.tsx',
lineStart: 4,
lineEnd: 4,
note: 'tighten this',
quote: 'const value = 1',
}],
},
])
expect(sendMock).toHaveBeenCalledWith(
TEST_SESSION_ID,
{
type: 'user_message',
content: 'Referenced workspace context:\n@"src/App.tsx:L4":\nComment: tighten this\n```tsx\nconst value = 1\n```',
attachments: [{
type: 'file',
name: 'App.tsx',
path: '/repo/src/App.tsx',
lineStart: 4,
lineEnd: 4,
note: 'tighten this',
quote: 'const value = 1',
}],
},
)
})
it('stores server-materialized attachment prefixes for rewind matching', () => {
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: {
messages: [],
chatState: 'idle',
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().sendMessage(
TEST_SESSION_ID,
'记一下这个文件讲了什么东西。',
[{ type: 'file', name: 'conditions.py', path: '/repo/backend/conditions.py' }],
{
displayContent: '记一下这个文件讲了什么东西。',
displayAttachments: [{ type: 'file', name: 'conditions.py', path: 'backend/conditions.py' }],
},
)
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([
{
type: 'user_text',
content: '记一下这个文件讲了什么东西。',
modelContent: '@"/repo/backend/conditions.py" 记一下这个文件讲了什么东西。',
attachments: [{
type: 'file',
name: 'conditions.py',
path: 'backend/conditions.py',
}],
},
])
})
it('hydrates TodoWrite history into the currently tracked task store only', async () => {
const todos = [{ content: 'Session task', status: 'in_progress' }]
vi.mocked(sessionsApi.getMessages).mockResolvedValueOnce({
messages: [
{
id: 'assistant-todo',
type: 'assistant',
timestamp: '2026-04-06T00:00:00.000Z',
content: [
{ type: 'tool_use', name: 'TodoWrite', id: 'todo-1', input: { todos } },
],
},
],
})
cliTaskStoreSnapshot.sessionId = TEST_SESSION_ID
cliTaskStoreSnapshot.tasks = []
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: makeSession({ messages: [] }),
},
})
await useChatStore.getState().loadHistory(TEST_SESSION_ID)
expect(setTasksFromTodosMock).toHaveBeenCalledWith(todos, TEST_SESSION_ID)
})
it('marks history task completion dismissed when the user already continued', async () => {
vi.mocked(sessionsApi.getMessages).mockResolvedValueOnce({
messages: [
{
id: 'assistant-task',
type: 'assistant',
timestamp: '2026-04-06T00:00:00.000Z',
content: [
{ type: 'tool_use', name: 'TaskCreate', id: 'task-1', input: { subject: 'Done' } },
],
},
{
id: 'user-next',
type: 'user',
timestamp: '2026-04-06T00:00:01.000Z',
content: '继续下一步',
},
],
})
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: makeSession({ messages: [] }),
},
})
await useChatStore.getState().loadHistory(TEST_SESSION_ID)
expect(setTasksFromTodosMock).toHaveBeenCalledWith([], TEST_SESSION_ID)
expect(markCompletedAndDismissedMock).toHaveBeenCalledWith(TEST_SESSION_ID)
})
it('reloads history task state for the requested session', async () => {
const todos = [{ content: 'Reloaded task', status: 'pending' }]
vi.mocked(sessionsApi.getMessages).mockResolvedValueOnce({
messages: [
{
id: 'assistant-todo',
type: 'assistant',
timestamp: '2026-04-06T00:00:00.000Z',
content: [
{ type: 'tool_use', name: 'TodoWrite', id: 'todo-1', input: { todos } },
],
},
],
})
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: makeSession({ messages: [{ id: 'old', type: 'assistant_text', content: 'old', timestamp: 1 }] }),
},
})
await useChatStore.getState().reloadHistory(TEST_SESSION_ID)
expect(setTasksFromTodosMock).toHaveBeenCalledWith(todos, TEST_SESSION_ID)
})
it('clears reloaded task state after completed history is followed by a user turn', async () => {
vi.mocked(sessionsApi.getMessages).mockResolvedValueOnce({
messages: [
{
id: 'assistant-task',
type: 'assistant',
timestamp: '2026-04-06T00:00:00.000Z',
content: [
{ type: 'tool_use', name: 'TaskUpdate', id: 'task-1', input: { subject: 'Done' } },
],
},
{
id: 'user-next',
type: 'user',
timestamp: '2026-04-06T00:00:01.000Z',
content: '新的问题',
},
],
})
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: makeSession({ messages: [{ id: 'old', type: 'assistant_text', content: 'old', timestamp: 1 }] }),
},
})
await useChatStore.getState().reloadHistory(TEST_SESSION_ID)
expect(setTasksFromTodosMock).toHaveBeenCalledWith([], TEST_SESSION_ID)
expect(markCompletedAndDismissedMock).toHaveBeenCalledWith(TEST_SESSION_ID)
})
it('keeps parent tool linkage for live tool events', () => {
// Initialize the session first
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: {
messages: [],
chatState: 'idle',
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: [{ name: 'old-command', description: 'Old command' }],
agentTaskNotifications: {},
elapsedTimer: null,
},
},
})
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'tool_use_complete',
toolName: 'Read',
toolUseId: 'tool-1',
input: { file_path: 'src/App.tsx' },
parentToolUseId: 'agent-1',
})
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'tool_result',
toolUseId: 'tool-1',
content: 'ok',
isError: false,
parentToolUseId: 'agent-1',
})
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([
{
type: 'tool_use',
toolUseId: 'tool-1',
parentToolUseId: 'agent-1',
},
{
type: 'tool_result',
toolUseId: 'tool-1',
parentToolUseId: 'agent-1',
},
])
})
it('syncs live TodoWrite tool input into the task store for that session', () => {
const todos = [{ content: 'Live todo', status: 'in_progress' }]
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: makeSession({ chatState: 'tool_executing' }),
},
})
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'tool_use_complete',
toolName: 'TodoWrite',
toolUseId: 'todo-live',
input: { todos },
})
expect(setTasksFromTodosMock).toHaveBeenCalledWith(todos, TEST_SESSION_ID)
})
it('replays saved runtime selection when reconnecting a session', () => {
useSessionRuntimeStore.getState().setSelection(TEST_SESSION_ID, {
providerId: 'provider-1',
modelId: 'kimi-k2.6',
})
useChatStore.getState().connectToSession(TEST_SESSION_ID)
expect(sendMock).toHaveBeenCalledWith(TEST_SESSION_ID, {
type: 'set_runtime_config',
providerId: 'provider-1',
modelId: 'kimi-k2.6',
})
expect(sendMock.mock.calls.slice(0, 2)).toEqual([
[
TEST_SESSION_ID,
{
type: 'set_runtime_config',
providerId: 'provider-1',
modelId: 'kimi-k2.6',
},
],
[TEST_SESSION_ID, { type: 'prewarm_session' }],
])
})
it('prewarms regular desktop sessions when connecting', () => {
useChatStore.getState().connectToSession(TEST_SESSION_ID)
expect(sendMock).toHaveBeenCalledWith(TEST_SESSION_ID, {
type: 'prewarm_session',
})
})
it('does not prewarm team member sessions', () => {
getMemberBySessionIdMock.mockReturnValue({
agentId: 'reviewer@test-team',
role: 'reviewer',
status: 'running',
})
useChatStore.getState().connectToSession(TEST_SESSION_ID)
expect(sendMock).not.toHaveBeenCalledWith(TEST_SESSION_ID, {
type: 'prewarm_session',
})
})
it('does not prewarm synthetic app tabs', () => {
useChatStore.getState().connectToSession('__settings__')
expect(sendMock).not.toHaveBeenCalledWith('__settings__', {
type: 'prewarm_session',
})
})
it('sends explicit runtime overrides over websocket', () => {
useChatStore.getState().setSessionRuntime(TEST_SESSION_ID, {
providerId: null,
modelId: 'claude-opus-4-7',
})
expect(sendMock).toHaveBeenCalledWith(TEST_SESSION_ID, {
type: 'set_runtime_config',
providerId: null,
modelId: 'claude-opus-4-7',
})
})
it('keeps AskUserQuestion permission requests out of the message list while tracking the pending request', () => {
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: {
messages: [
{
id: 'ask-1',
type: 'tool_use',
toolName: 'AskUserQuestion',
toolUseId: 'tool-ask-1',
input: {
questions: [
{
question: 'Should we persist data?',
options: [{ label: 'No' }, { label: 'Yes' }],
},
],
},
timestamp: 1,
},
],
chatState: 'idle',
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: 'permission_request',
requestId: 'perm-ask-1',
toolName: 'AskUserQuestion',
toolUseId: 'tool-ask-1',
input: {
questions: [
{
question: 'Should we persist data?',
options: [{ label: 'No' }, { label: 'Yes' }],
},
],
},
})
const session = useChatStore.getState().sessions[TEST_SESSION_ID]
expect(session?.pendingPermission).toMatchObject({
requestId: 'perm-ask-1',
toolName: 'AskUserQuestion',
toolUseId: 'tool-ask-1',
})
expect(session?.messages).toHaveLength(1)
expect(session?.messages[0]).toMatchObject({
type: 'tool_use',
toolUseId: 'tool-ask-1',
})
expect(notifyDesktopMock).toHaveBeenCalledWith({
dedupeKey: 'permission:perm-ask-1',
cooldownScope: 'permission-prompt',
requestAttention: true,
title: 'Claude Code Haha 需要你的确认',
body: 'AskUserQuestion 请求执行,正在等待允许。',
target: { type: 'session', sessionId: TEST_SESSION_ID },
})
})
it('sends permission mode updates to the active session only', () => {
useChatStore.getState().setSessionPermissionMode('nonexistent-session', 'acceptEdits')
expect(sendMock).not.toHaveBeenCalled()
useChatStore.setState({
sessions: {
'session-1': {
messages: [],
chatState: 'idle',
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().setSessionPermissionMode('session-1', 'acceptEdits')
expect(sendMock).toHaveBeenCalledWith('session-1', {
type: 'set_permission_mode',
mode: 'acceptEdits',
})
})
it('stores terminal task notifications for agent tool cards', () => {
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: {
messages: [],
chatState: 'idle',
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: 'system_notification',
subtype: 'task_notification',
data: {
task_id: 'agent-task-1',
tool_use_id: 'agent-tool-1',
status: 'completed',
summary: 'Agent "修复异常处理" completed',
output_file: '/tmp/agent-output.txt',
},
})
expect(
useChatStore.getState().sessions[TEST_SESSION_ID]?.agentTaskNotifications[
'agent-tool-1'
],
).toMatchObject({
taskId: 'agent-task-1',
toolUseId: 'agent-tool-1',
status: 'completed',
summary: 'Agent "修复异常处理" completed',
outputFile: '/tmp/agent-output.txt',
})
})
it('clears local desktop chat state when the server confirms /clear', () => {
vi.useFakeTimers()
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: {
messages: [
{ id: 'u1', type: 'user_text', content: '/clear', timestamp: Date.now() },
{ id: 'a1', type: 'assistant_text', content: 'old context', timestamp: Date.now() },
],
chatState: 'thinking',
connectionState: 'connected',
streamingText: 'pending',
streamingToolInput: 'tool',
activeToolUseId: 'tool-1',
activeToolName: 'Read',
activeThinkingId: 'thinking-1',
pendingPermission: null,
pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 12, output_tokens: 34 },
elapsedSeconds: 5,
statusVerb: 'Thinking',
slashCommands: [],
agentTaskNotifications: {},
elapsedTimer: null,
},
},
})
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'content_delta',
text: 'stale throttled delta',
})
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'system_notification',
subtype: 'session_cleared',
message: 'Conversation cleared',
})
const session = useChatStore.getState().sessions[TEST_SESSION_ID]
expect(session?.messages).toEqual([])
expect(session?.streamingText).toBe('')
expect(session?.chatState).toBe('idle')
expect(session?.tokenUsage).toEqual({ input_tokens: 0, output_tokens: 0 })
expect(session?.slashCommands).toEqual([])
expect(clearTasksMock).toHaveBeenCalledWith(TEST_SESSION_ID)
vi.advanceTimersByTime(60)
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.streamingText).toBe('')
vi.useRealTimers()
})
it('clears local message state for only the requested session', () => {
useChatStore.setState({
sessions: {
'session-a': makeSession({
messages: [{ id: 'a1', type: 'assistant_text', content: 'A old', timestamp: 1 }],
streamingText: 'A pending',
}),
'session-b': makeSession({
messages: [{ id: 'b1', type: 'assistant_text', content: 'B old', timestamp: 1 }],
streamingText: 'B pending',
}),
},
})
useChatStore.getState().clearMessages('session-a')
expect(useChatStore.getState().sessions['session-a']?.messages).toEqual([])
expect(useChatStore.getState().sessions['session-a']?.streamingText).toBe('')
expect(useChatStore.getState().sessions['session-b']?.messages).toMatchObject([
{ content: 'B old' },
])
})
it('renders compact boundary notifications as system messages', () => {
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: {
messages: [],
chatState: 'idle',
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: 'system_notification',
subtype: 'compact_boundary',
message: 'Context compacted',
})
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([
{ type: 'system', content: 'Context compacted' },
])
})
it('renders memory saved notifications as chat memory events', () => {
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: makeSession({
messages: [],
chatState: 'idle',
}),
},
})
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'system_notification',
subtype: 'memory_saved',
message: 'Saved 2 memories',
data: {
writtenPaths: [
'/Users/test/.claude/projects/example/memory/preferences.md',
'/Users/test/.claude/projects/example/memory/team/MEMORY.md',
],
teamCount: 1,
},
})
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([
{
type: 'memory_event',
event: 'saved',
message: 'Saved 2 memories',
teamCount: 1,
files: [
{ path: '/Users/test/.claude/projects/example/memory/preferences.md', action: 'saved' },
{ path: '/Users/test/.claude/projects/example/memory/team/MEMORY.md', action: 'saved' },
],
},
])
})
it('renders live goal notifications as visible goal events', () => {
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: makeSession({
messages: [],
chatState: 'idle',
}),
},
})
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'system_notification',
subtype: 'goal_event',
message: 'Goal: active',
data: {
action: 'created',
status: 'active',
objective: 'ship the smoke test',
budget: '0 / 2,000 tokens',
continuations: '0',
},
})
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([
{
type: 'goal_event',
action: 'created',
status: 'active',
objective: 'ship the smoke test',
budget: '0 / 2,000 tokens',
continuations: '0',
},
])
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.activeGoal).toMatchObject({
action: 'created',
status: 'active',
objective: 'ship the smoke test',
budget: '0 / 2,000 tokens',
continuations: '0',
})
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'system_notification',
subtype: 'goal_event',
message: 'Goal replaced.',
data: {
action: 'replaced',
status: 'active',
objective: 'ship the replacement target',
budget: '0 / unlimited tokens',
continuations: '0',
},
})
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.activeGoal).toMatchObject({
action: 'replaced',
status: 'active',
objective: 'ship the replacement target',
budget: '0 / unlimited tokens',
continuations: '0',
})
})
it('keeps the active goal panel state in sync with /goal lifecycle events', () => {
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: makeSession({
messages: [],
activeGoal: {
action: 'created',
status: 'active',
objective: 'ship the smoke test',
budget: '0 / 2,000 tokens',
continuations: '0',
updatedAt: 1,
},
}),
},
})
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'system_notification',
subtype: 'goal_event',
data: {
action: 'paused',
status: 'paused',
},
})
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.activeGoal).toMatchObject({
action: 'paused',
status: 'paused',
objective: 'ship the smoke test',
})
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'system_notification',
subtype: 'goal_event',
data: {
action: 'completed',
message: 'Goal marked complete.',
},
})
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.activeGoal).toMatchObject({
action: 'completed',
status: 'complete',
objective: 'ship the smoke test',
})
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'system_notification',
subtype: 'goal_event',
data: {
action: 'cleared',
message: 'Goal cleared.',
},
})
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.activeGoal).toBeNull()
})
it('flushes the previous assistant draft before starting a new user turn', () => {
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: {
messages: [],
chatState: 'streaming',
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().sendMessage(TEST_SESSION_ID, '你是什么模型?')
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([
{
type: 'assistant_text',
content: '上一次分析结果 **还在流式区域**',
},
{
type: 'user_text',
content: '你是什么模型?',
},
])
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.streamingText).toBe('')
})
it('resets completed CLI tasks before continuing the next user turn', () => {
cliTaskStoreSnapshot.sessionId = TEST_SESSION_ID
cliTaskStoreSnapshot.tasks = [
{ id: '1', subject: 'Existing completed task', status: 'completed' },
{ id: '2', subject: 'Another completed task', status: 'completed' },
]
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: {
messages: [],
chatState: 'idle',
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().sendMessage(TEST_SESSION_ID, '继续下一轮')
expect(resetCompletedTasksMock).toHaveBeenCalledWith(TEST_SESSION_ID)
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([
{
type: 'task_summary',
tasks: [
{ id: '1', subject: 'Existing completed task', status: 'completed' },
{ id: '2', subject: 'Another completed task', status: 'completed' },
],
},
{
type: 'user_text',
content: '继续下一轮',
},
])
})
it('does not attach completed tasks from another tracked session to a new user turn', () => {
cliTaskStoreSnapshot.sessionId = 'session-b'
cliTaskStoreSnapshot.tasks = [
{ id: '1', subject: 'Session B completed task', status: 'completed' },
]
useChatStore.setState({
sessions: {
'session-a': makeSession({ chatState: 'idle' }),
'session-b': makeSession({ chatState: 'idle' }),
},
})
useChatStore.getState().sendMessage('session-a', '继续 A 会话')
expect(resetCompletedTasksMock).not.toHaveBeenCalled()
expect(useChatStore.getState().sessions['session-a']?.messages).toMatchObject([
{
type: 'user_text',
content: '继续 A 会话',
},
])
})
it('tracks task tool results independently per session even when tool IDs collide', () => {
useChatStore.setState({
sessions: {
'session-a': makeSession({
activeToolUseId: 'tool-same',
activeToolName: 'TaskCreate',
}),
'session-b': makeSession({
activeToolUseId: 'tool-same',
activeToolName: 'TaskCreate',
}),
},
})
for (const sessionId of ['session-a', 'session-b']) {
useChatStore.getState().handleServerMessage(sessionId, {
type: 'tool_use_complete',
toolName: 'TaskCreate',
toolUseId: 'tool-same',
input: { subject: sessionId },
})
}
useChatStore.getState().handleServerMessage('session-a', {
type: 'tool_result',
toolUseId: 'tool-same',
content: 'created A',
isError: false,
})
useChatStore.getState().handleServerMessage('session-b', {
type: 'tool_result',
toolUseId: 'tool-same',
content: 'created B',
isError: false,
})
expect(refreshTasksMock).toHaveBeenCalledTimes(2)
expect(refreshTasksMock).toHaveBeenNthCalledWith(1, 'session-a')
expect(refreshTasksMock).toHaveBeenNthCalledWith(2, 'session-b')
})
it('tracks Computer Use approval requests separately from generic tool permissions', () => {
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: {
messages: [],
chatState: 'idle',
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: 'computer_use_permission_request',
requestId: 'cu-1',
request: {
requestId: 'cu-1',
reason: 'Open Finder and inspect a file',
apps: [
{
requestedName: 'Finder',
resolved: {
bundleId: 'com.apple.finder',
displayName: 'Finder',
},
isSentinel: false,
alreadyGranted: false,
proposedTier: 'full',
},
],
requestedFlags: { clipboardRead: true },
screenshotFiltering: 'native',
},
})
expect(
useChatStore.getState().sessions[TEST_SESSION_ID]?.pendingComputerUsePermission,
).toMatchObject({
requestId: 'cu-1',
request: {
reason: 'Open Finder and inspect a file',
},
})
expect(
useChatStore.getState().sessions[TEST_SESSION_ID]?.chatState,
).toBe('permission_pending')
expect(notifyDesktopMock).toHaveBeenCalledWith({
dedupeKey: 'computer-use-permission:cu-1',
cooldownScope: 'permission-prompt',
requestAttention: true,
title: 'Claude Code Haha 需要你的确认',
body: 'Open Finder and inspect a file',
target: { type: 'session', sessionId: TEST_SESSION_ID },
})
})
it('keeps delayed text blocks from one streamed assistant turn in a single message', () => {
vi.useFakeTimers()
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: {
messages: [],
chatState: 'idle',
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: 'content_start',
blockType: 'text',
})
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'content_delta',
text: '第一段:先到达。',
})
vi.advanceTimersByTime(60)
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'content_start',
blockType: 'text',
})
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'content_delta',
text: '\r\n第二段稍后到达但仍属于同一轮回复。',
})
vi.advanceTimersByTime(60)
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'message_complete',
usage: { input_tokens: 1, output_tokens: 2 },
})
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([
{
type: 'assistant_text',
content: '第一段:先到达。\r\n第二段稍后到达但仍属于同一轮回复。',
},
])
vi.runOnlyPendingTimers()
vi.useRealTimers()
})
it('keeps throttled streaming deltas isolated per session', () => {
vi.useFakeTimers()
useChatStore.setState({
sessions: {
'session-a': makeSession(),
'session-b': makeSession(),
},
})
useChatStore.getState().handleServerMessage('session-a', {
type: 'content_start',
blockType: 'text',
})
useChatStore.getState().handleServerMessage('session-a', {
type: 'content_delta',
text: 'A-only response',
})
useChatStore.getState().handleServerMessage('session-b', {
type: 'content_start',
blockType: 'text',
})
useChatStore.getState().handleServerMessage('session-b', {
type: 'content_delta',
text: 'B-only response',
})
useChatStore.getState().handleServerMessage('session-a', {
type: 'message_complete',
usage: { input_tokens: 1, output_tokens: 1 },
})
useChatStore.getState().handleServerMessage('session-b', {
type: 'message_complete',
usage: { input_tokens: 1, output_tokens: 1 },
})
expect(useChatStore.getState().sessions['session-a']?.messages).toMatchObject([
{ type: 'assistant_text', content: 'A-only response' },
])
expect(useChatStore.getState().sessions['session-b']?.messages).toMatchObject([
{ type: 'assistant_text', content: 'B-only response' },
])
vi.runOnlyPendingTimers()
vi.useRealTimers()
})
it('flushes pending text before appending a thinking block', () => {
vi.useFakeTimers()
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: makeSession({ chatState: 'streaming' }),
},
})
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'content_delta',
text: 'visible answer before thinking',
})
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'thinking',
text: 'internal note',
})
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([
{ type: 'assistant_text', content: 'visible answer before thinking' },
{ type: 'thinking', content: 'internal note' },
])
vi.runOnlyPendingTimers()
vi.useRealTimers()
})
it('flushes pending text before appending an error message', () => {
vi.useFakeTimers()
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: makeSession({ chatState: 'streaming' }),
},
})
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'content_delta',
text: 'partial answer before error',
})
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'error',
message: 'provider failed',
code: 'provider_error',
})
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([
{ type: 'assistant_text', content: 'partial answer before error' },
{ type: 'error', message: 'provider failed' },
])
vi.runOnlyPendingTimers()
vi.useRealTimers()
})
it('flushes throttled deltas only for the stopped session', () => {
vi.useFakeTimers()
useChatStore.setState({
sessions: {
'session-a': makeSession(),
'session-b': makeSession(),
},
})
useChatStore.getState().handleServerMessage('session-a', {
type: 'content_delta',
text: 'A-only response',
})
useChatStore.getState().handleServerMessage('session-b', {
type: 'content_delta',
text: 'B-only response',
})
useChatStore.getState().stopGeneration('session-a')
expect(useChatStore.getState().sessions['session-a']?.streamingText).toBe('A-only response')
expect(useChatStore.getState().sessions['session-b']?.streamingText).toBe('')
useChatStore.getState().handleServerMessage('session-b', {
type: 'message_complete',
usage: { input_tokens: 1, output_tokens: 1 },
})
expect(useChatStore.getState().sessions['session-b']?.messages).toMatchObject([
{ type: 'assistant_text', content: 'B-only response' },
])
vi.runOnlyPendingTimers()
vi.useRealTimers()
})
it('does not flush one session throttled delta into another disconnected session', () => {
vi.useFakeTimers()
useChatStore.setState({
sessions: {
'session-a': makeSession(),
'session-b': makeSession(),
},
})
useChatStore.getState().handleServerMessage('session-a', {
type: 'content_delta',
text: 'A-only response',
})
useChatStore.getState().handleServerMessage('session-b', {
type: 'content_delta',
text: 'B-only response',
})
useChatStore.getState().disconnectSession('session-a')
expect(useChatStore.getState().sessions['session-a']).toBeUndefined()
expect(useChatStore.getState().sessions['session-b']?.streamingText).toBe('')
useChatStore.getState().handleServerMessage('session-b', {
type: 'message_complete',
usage: { input_tokens: 1, output_tokens: 1 },
})
expect(useChatStore.getState().sessions['session-b']?.messages).toMatchObject([
{ type: 'assistant_text', content: 'B-only response' },
])
vi.runOnlyPendingTimers()
vi.useRealTimers()
})
it('ignores late throttled deltas after a session has disconnected', () => {
vi.useFakeTimers()
useChatStore.setState({
sessions: {
'session-a': makeSession(),
},
})
useChatStore.getState().handleServerMessage('session-a', {
type: 'content_delta',
text: 'before disconnect',
})
useChatStore.getState().disconnectSession('session-a')
useChatStore.getState().handleServerMessage('session-a', {
type: 'content_delta',
text: 'late stale delta',
})
useChatStore.setState({
sessions: {
'session-a': makeSession({ chatState: 'idle' }),
},
})
useChatStore.getState().sendMessage('session-a', 'fresh turn')
expect(useChatStore.getState().sessions['session-a']?.messages).toMatchObject([
{ type: 'user_text', content: 'fresh turn' },
])
vi.runOnlyPendingTimers()
expect(useChatStore.getState().sessions['session-a']?.streamingText).toBe('')
vi.useRealTimers()
})
it('does not split one streamed markdown reply when task progress arrives mid-stream', () => {
vi.useFakeTimers()
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: {
messages: [],
chatState: 'idle',
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: 'content_start',
blockType: 'text',
})
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'content_delta',
text: '1. **`core/audio/waveform.py:19-31`** — 同步阻塞 I/O。',
})
vi.advanceTimersByTime(60)
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'status',
state: 'tool_executing',
verb: 'Task in progress',
})
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'content_delta',
text: ' 建议直接用 `subprocess.PIPE` 流式处理。',
})
vi.advanceTimersByTime(60)
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'message_complete',
usage: { input_tokens: 1, output_tokens: 2 },
})
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([
{
type: 'assistant_text',
content:
'1. **`core/audio/waveform.py:19-31`** — 同步阻塞 I/O。 建议直接用 `subprocess.PIPE` 流式处理。',
},
])
vi.runOnlyPendingTimers()
vi.useRealTimers()
})
it('sends a desktop notification when the agent finishes a markdown reply', () => {
vi.useFakeTimers()
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: {
messages: [
{ id: 'user-1', type: 'user_text', content: '总结一下', timestamp: Date.now() },
],
chatState: 'streaming',
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: 'content_start',
blockType: 'text',
})
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'content_delta',
text: '## 结果\n\n- **修复完成**\n- `bun test` 已通过',
})
vi.advanceTimersByTime(60)
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'message_complete',
usage: { input_tokens: 1, output_tokens: 2 },
})
expect(notifyDesktopMock).toHaveBeenCalledWith(expect.objectContaining({
cooldownScope: 'agent-completion',
title: 'Claude Code Haha 已完成回复',
body: '结果 修复完成 bun test 已通过',
target: { type: 'session', sessionId: TEST_SESSION_ID },
}))
expect(notifyDesktopMock.mock.calls[0]?.[0].dedupeKey).toMatch(
/^agent-completion:test-session-1:msg-/,
)
vi.runOnlyPendingTimers()
vi.useRealTimers()
})
it('does not notify when completion has no assistant text', () => {
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: {
messages: [],
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: 'message_complete',
usage: { input_tokens: 1, output_tokens: 0 },
})
expect(notifyDesktopMock).not.toHaveBeenCalled()
})
it('does not notify when a completion arrives after the session is already idle', () => {
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: {
messages: [],
chatState: 'idle',
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: 'message_complete',
usage: { input_tokens: 1, output_tokens: 1 },
})
expect(notifyDesktopMock).not.toHaveBeenCalled()
})
it('sends Computer Use approval payloads back over websocket', () => {
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: {
messages: [],
chatState: 'permission_pending',
connectionState: 'connected',
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
pendingComputerUsePermission: {
requestId: 'cu-1',
request: {
requestId: 'cu-1',
reason: 'Open Finder',
apps: [],
requestedFlags: {},
screenshotFiltering: 'native',
},
},
tokenUsage: { input_tokens: 0, output_tokens: 0 },
elapsedSeconds: 0,
statusVerb: '',
slashCommands: [],
agentTaskNotifications: {},
elapsedTimer: null,
},
},
})
useChatStore.getState().respondToComputerUsePermission(TEST_SESSION_ID, 'cu-1', {
granted: [],
denied: [],
flags: {
clipboardRead: true,
clipboardWrite: false,
systemKeyCombos: false,
},
userConsented: true,
})
expect(sendMock).toHaveBeenCalledWith(TEST_SESSION_ID, {
type: 'computer_use_permission_response',
requestId: 'cu-1',
response: {
granted: [],
denied: [],
flags: {
clipboardRead: true,
clipboardWrite: false,
systemKeyCombos: false,
},
userConsented: true,
},
})
expect(
useChatStore.getState().sessions[TEST_SESSION_ID]?.pendingComputerUsePermission,
).toBeNull()
expect(
useChatStore.getState().sessions[TEST_SESSION_ID]?.chatState,
).toBe('tool_executing')
})
it('routes member-session messages through team mailbox delivery instead of websocket', async () => {
const memberSessionId = 'team-member:security-reviewer@test-team'
getMemberBySessionIdMock.mockReturnValue({
agentId: 'security-reviewer@test-team',
role: 'security-reviewer',
status: 'running',
})
useChatStore.setState({
sessions: {
[memberSessionId]: {
messages: [],
chatState: 'idle',
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().sendMessage(memberSessionId, 'Check the latest regression')
await Promise.resolve()
expect(sendMessageToMemberMock).toHaveBeenCalledWith(
memberSessionId,
'Check the latest regression',
)
expect(sendMock).not.toHaveBeenCalled()
const sessionMessages = useChatStore.getState().sessions[memberSessionId]?.messages ?? []
expect(sessionMessages[sessionMessages.length - 1]).toMatchObject({
type: 'user_text',
content: 'Check the latest regression',
pending: true,
})
})
it('refreshes CLI tasks when switching to an already-connected session', () => {
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: {
messages: [],
chatState: 'idle',
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().connectToSession(TEST_SESSION_ID)
expect(fetchSessionTasksMock).toHaveBeenCalledWith(TEST_SESSION_ID)
})
it('optimistically titles a new placeholder session from the first user message', () => {
sessionStoreSnapshot.sessions = [{
id: TEST_SESSION_ID,
title: 'New Session',
createdAt: '2026-05-07T00:00:00.000Z',
modifiedAt: '2026-05-07T00:00:00.000Z',
messageCount: 0,
projectPath: '',
workDir: '/workspace/project',
workDirExists: true,
}]
useChatStore.getState().sendMessage(TEST_SESSION_ID, '开始优化UI')
expect(updateSessionTitleMock).toHaveBeenCalledWith(TEST_SESSION_ID, '开始优化UI')
expect(updateTabTitleMock).toHaveBeenCalledWith(TEST_SESSION_ID, '开始优化UI')
expect(sendMock).toHaveBeenCalledWith(TEST_SESSION_ID, {
type: 'user_message',
content: '开始优化UI',
attachments: undefined,
})
})
})