mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
Desktop already receives tool_use start and input deltas before the final tool_use_complete event, but the chat transcript only rendered a visible tool card after the complete event. This makes long Write payloads look frozen while the model is still generating the tool input. The store now upserts a pending tool_use message on stream start, updates a lightweight input preview from deltas, and resolves that same message when the complete event arrives. Constraint: Long Write calls spend most perceived time streaming JSON tool input, not executing the filesystem write Rejected: Show only the global active tool label | it does not anchor progress in the transcript where the user is looking Confidence: high Scope-risk: moderate Directive: Keep pending tool_use messages keyed by toolUseId so the complete event updates in place instead of duplicating cards Tested: bun run check:desktop Not-tested: Live provider smoke with a real 10k-word Write stream
3365 lines
100 KiB
TypeScript
3365 lines
100 KiB
TypeScript
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: '',
|
||
apiRetry: null,
|
||
slashCommands: [],
|
||
agentTaskNotifications: {},
|
||
backgroundAgentTasks: {},
|
||
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()
|
||
vi.mocked(sessionsApi.getMessages).mockReset()
|
||
vi.mocked(sessionsApi.getMessages).mockResolvedValue({ messages: [] })
|
||
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('maps AskUserQuestion transcript answers from toolUseResult metadata', () => {
|
||
const messages: MessageEntry[] = [
|
||
{
|
||
id: 'assistant-ask',
|
||
type: 'assistant',
|
||
timestamp: '2026-04-06T00:00:00.000Z',
|
||
content: [
|
||
{
|
||
type: 'tool_use',
|
||
name: 'AskUserQuestion',
|
||
id: 'ask-1',
|
||
input: {
|
||
questions: [
|
||
{
|
||
question: 'Pick one?',
|
||
options: [{ label: 'A' }, { label: 'B' }],
|
||
},
|
||
],
|
||
},
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: 'user-answer',
|
||
type: 'tool_result',
|
||
timestamp: '2026-04-06T00:00:01.000Z',
|
||
content: [
|
||
{
|
||
type: 'tool_result',
|
||
tool_use_id: 'ask-1',
|
||
content: 'User has answered your questions: "Pick one?"="A". You can now continue with the user\'s answers in mind.',
|
||
},
|
||
],
|
||
toolUseResult: {
|
||
questions: [
|
||
{
|
||
question: 'Pick one?',
|
||
options: [{ label: 'A' }, { label: 'B' }],
|
||
},
|
||
],
|
||
answers: { 'Pick one?': 'A' },
|
||
},
|
||
},
|
||
]
|
||
|
||
const mapped = mapHistoryMessagesToUiMessages(messages)
|
||
|
||
expect(mapped).toHaveLength(2)
|
||
expect(mapped[1]).toMatchObject({
|
||
type: 'tool_result',
|
||
toolUseId: 'ask-1',
|
||
content: {
|
||
answers: { 'Pick one?': 'A' },
|
||
},
|
||
})
|
||
})
|
||
|
||
it('maps compact boundary and summary history without hiding pre-compact messages', () => {
|
||
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(3)
|
||
expect(mapped).toMatchObject([
|
||
{
|
||
id: 'old-user',
|
||
type: 'user_text',
|
||
content: 'Build the billing import flow',
|
||
},
|
||
{
|
||
id: 'old-assistant',
|
||
type: 'assistant_text',
|
||
content: 'Implemented the flow.',
|
||
},
|
||
{
|
||
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[] = [
|
||
{
|
||
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('preserves transcript message ids on natural-language history messages', () => {
|
||
const messages: MessageEntry[] = [
|
||
{
|
||
id: 'transcript-user-1',
|
||
type: 'user',
|
||
timestamp: '2026-04-06T00:00:00.000Z',
|
||
content: '请从这里继续',
|
||
},
|
||
{
|
||
id: 'transcript-assistant-1',
|
||
type: 'assistant',
|
||
timestamp: '2026-04-06T00:00:01.000Z',
|
||
model: 'opus',
|
||
content: [
|
||
{ type: 'text', text: '这里是答复。' },
|
||
{ type: 'tool_use', name: 'Read', id: 'tool-1', input: { file_path: 'src/App.tsx' } },
|
||
],
|
||
},
|
||
]
|
||
|
||
const mapped = mapHistoryMessagesToUiMessages(messages)
|
||
|
||
expect(mapped).toMatchObject([
|
||
{
|
||
id: 'transcript-user-1',
|
||
type: 'user_text',
|
||
transcriptMessageId: 'transcript-user-1',
|
||
},
|
||
{
|
||
type: 'assistant_text',
|
||
transcriptMessageId: 'transcript-assistant-1',
|
||
},
|
||
{
|
||
type: 'tool_use',
|
||
},
|
||
])
|
||
})
|
||
|
||
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>ship the smoke test</command-args>',
|
||
},
|
||
{
|
||
id: 'goal-output',
|
||
type: 'system',
|
||
timestamp: '2026-04-06T00:00:01.000Z',
|
||
content: '<local-command-stdout>Goal set: ship the smoke test</local-command-stdout>',
|
||
},
|
||
]
|
||
|
||
expect(mapHistoryMessagesToUiMessages(messages)).toMatchObject([
|
||
{
|
||
id: 'goal-command',
|
||
type: 'user_text',
|
||
content: '/goal ship the smoke test',
|
||
},
|
||
{
|
||
id: 'goal-output',
|
||
type: 'goal_event',
|
||
action: 'created',
|
||
status: 'active',
|
||
objective: 'ship the smoke test',
|
||
},
|
||
])
|
||
})
|
||
|
||
it('restores repeated /goal set output as the current created 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 set: ship the replacement target</local-command-stdout>',
|
||
},
|
||
]
|
||
|
||
expect(mapHistoryMessagesToUiMessages(messages)).toMatchObject([
|
||
{
|
||
id: 'goal-command',
|
||
type: 'user_text',
|
||
content: '/goal ship the replacement target',
|
||
},
|
||
{
|
||
id: 'goal-output',
|
||
type: 'goal_event',
|
||
action: 'created',
|
||
status: 'active',
|
||
objective: 'ship the replacement target',
|
||
},
|
||
])
|
||
})
|
||
|
||
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 set: ship the smoke test</local-command-stdout>',
|
||
},
|
||
{
|
||
id: 'goal-complete',
|
||
type: 'system',
|
||
timestamp: '2026-04-06T00:00:02.000Z',
|
||
content: '<local-command-stdout>Goal marked complete.</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.',
|
||
},
|
||
])
|
||
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.activeGoal).toMatchObject({
|
||
action: 'completed',
|
||
status: 'complete',
|
||
objective: 'ship the smoke test',
|
||
})
|
||
})
|
||
|
||
it('uses transcript terminal events to repair stale live goal and background task state', 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 set: ship the smoke test</local-command-stdout>',
|
||
},
|
||
{
|
||
id: 'goal-complete',
|
||
type: 'system',
|
||
timestamp: '2026-04-06T00:00:02.000Z',
|
||
content: '<local-command-stdout>Goal marked complete.</local-command-stdout>',
|
||
},
|
||
],
|
||
taskNotifications: [
|
||
{
|
||
taskId: 'agent-task-1',
|
||
toolUseId: 'agent-tool-1',
|
||
status: 'completed',
|
||
summary: 'Agent completed',
|
||
timestamp: '2026-04-06T00:00:03.000Z',
|
||
},
|
||
],
|
||
})
|
||
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[TEST_SESSION_ID]: makeSession({
|
||
messages: [{ id: 'visible-message', type: 'assistant_text', content: 'already rendered', timestamp: 1 }],
|
||
activeGoal: {
|
||
action: 'created',
|
||
status: 'active',
|
||
objective: 'ship the smoke test',
|
||
updatedAt: 1,
|
||
},
|
||
backgroundAgentTasks: {
|
||
'agent-tool-1': {
|
||
taskId: 'agent-tool-1',
|
||
toolUseId: 'agent-tool-1',
|
||
status: 'running',
|
||
taskType: 'local_agent',
|
||
description: 'Review app',
|
||
startedAt: 1,
|
||
updatedAt: 2,
|
||
},
|
||
},
|
||
}),
|
||
},
|
||
})
|
||
|
||
await useChatStore.getState().loadHistory(TEST_SESSION_ID)
|
||
|
||
const session = useChatStore.getState().sessions[TEST_SESSION_ID]
|
||
expect(session?.messages).toMatchObject([
|
||
{ id: 'visible-message', type: 'assistant_text', content: 'already rendered' },
|
||
{
|
||
id: 'goal-complete',
|
||
type: 'goal_event',
|
||
action: 'completed',
|
||
message: 'Goal marked complete.',
|
||
},
|
||
])
|
||
expect(session?.activeGoal).toMatchObject({
|
||
action: 'completed',
|
||
status: 'complete',
|
||
objective: 'ship the smoke test',
|
||
})
|
||
expect(session?.backgroundAgentTasks?.['agent-tool-1']).toBeUndefined()
|
||
expect(session?.backgroundAgentTasks?.['agent-task-1']).toMatchObject({
|
||
taskId: 'agent-task-1',
|
||
toolUseId: 'agent-tool-1',
|
||
status: 'completed',
|
||
description: 'Review app',
|
||
summary: 'Agent completed',
|
||
})
|
||
})
|
||
|
||
it('hydrates transcript ids for a just-completed live turn', async () => {
|
||
vi.mocked(sessionsApi.getMessages).mockResolvedValueOnce({
|
||
messages: [
|
||
{
|
||
id: 'transcript-user-1',
|
||
type: 'user',
|
||
timestamp: '2026-04-06T00:00:00.000Z',
|
||
content: 'live prompt',
|
||
},
|
||
{
|
||
id: 'transcript-assistant-1',
|
||
type: 'assistant',
|
||
timestamp: '2026-04-06T00:00:01.000Z',
|
||
content: 'live answer',
|
||
},
|
||
],
|
||
})
|
||
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[TEST_SESSION_ID]: makeSession({
|
||
messages: [
|
||
{
|
||
id: 'live-user',
|
||
type: 'user_text',
|
||
content: 'live prompt',
|
||
timestamp: 1,
|
||
},
|
||
],
|
||
streamingText: 'live answer',
|
||
chatState: 'streaming',
|
||
}),
|
||
},
|
||
})
|
||
|
||
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||
type: 'message_complete',
|
||
usage: { input_tokens: 1, output_tokens: 2 },
|
||
})
|
||
|
||
await vi.waitFor(() => {
|
||
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([
|
||
{
|
||
type: 'user_text',
|
||
transcriptMessageId: 'transcript-user-1',
|
||
},
|
||
{
|
||
type: 'assistant_text',
|
||
transcriptMessageId: 'transcript-assistant-1',
|
||
},
|
||
])
|
||
})
|
||
})
|
||
|
||
it('retries transcript id hydration after the assistant message is persisted', async () => {
|
||
vi.useFakeTimers()
|
||
vi.mocked(sessionsApi.getMessages)
|
||
.mockResolvedValueOnce({
|
||
messages: [
|
||
{
|
||
id: 'transcript-user-1',
|
||
type: 'user',
|
||
timestamp: '2026-04-06T00:00:00.000Z',
|
||
content: 'live prompt',
|
||
},
|
||
],
|
||
})
|
||
.mockResolvedValueOnce({
|
||
messages: [
|
||
{
|
||
id: 'transcript-user-1',
|
||
type: 'user',
|
||
timestamp: '2026-04-06T00:00:00.000Z',
|
||
content: 'live prompt',
|
||
},
|
||
{
|
||
id: 'transcript-assistant-1',
|
||
type: 'assistant',
|
||
timestamp: '2026-04-06T00:00:01.000Z',
|
||
content: 'live answer',
|
||
},
|
||
],
|
||
})
|
||
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[TEST_SESSION_ID]: makeSession({
|
||
messages: [
|
||
{
|
||
id: 'live-user',
|
||
type: 'user_text',
|
||
content: 'live prompt',
|
||
timestamp: 1,
|
||
},
|
||
],
|
||
streamingText: 'live answer',
|
||
chatState: 'streaming',
|
||
}),
|
||
},
|
||
})
|
||
|
||
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||
type: 'message_complete',
|
||
usage: { input_tokens: 1, output_tokens: 2 },
|
||
})
|
||
|
||
await Promise.resolve()
|
||
await Promise.resolve()
|
||
|
||
expect(sessionsApi.getMessages).toHaveBeenCalledTimes(1)
|
||
const firstHydrationMessages = useChatStore.getState().sessions[TEST_SESSION_ID]?.messages ?? []
|
||
expect(firstHydrationMessages[0]).toMatchObject({
|
||
type: 'user_text',
|
||
transcriptMessageId: 'transcript-user-1',
|
||
})
|
||
expect(firstHydrationMessages[1]).toMatchObject({
|
||
type: 'assistant_text',
|
||
})
|
||
expect(firstHydrationMessages[1]).not.toHaveProperty('transcriptMessageId')
|
||
|
||
await vi.advanceTimersByTimeAsync(750)
|
||
|
||
expect(sessionsApi.getMessages).toHaveBeenCalledTimes(2)
|
||
const secondHydrationMessages = useChatStore.getState().sessions[TEST_SESSION_ID]?.messages ?? []
|
||
expect(secondHydrationMessages[0]).toMatchObject({
|
||
type: 'user_text',
|
||
transcriptMessageId: 'transcript-user-1',
|
||
})
|
||
expect(secondHydrationMessages[1]).toMatchObject({
|
||
type: 'assistant_text',
|
||
transcriptMessageId: 'transcript-assistant-1',
|
||
})
|
||
|
||
vi.runOnlyPendingTimers()
|
||
vi.useRealTimers()
|
||
})
|
||
|
||
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 & agent done</summary>\n<result>Detailed result & next step</result>\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',
|
||
result: 'Detailed result & next step',
|
||
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('retains live parent linkage when only content_start carries the parent id', () => {
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[TEST_SESSION_ID]: makeSession(),
|
||
},
|
||
})
|
||
|
||
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||
type: 'content_start',
|
||
blockType: 'tool_use',
|
||
toolName: 'Read',
|
||
toolUseId: 'tool-1',
|
||
parentToolUseId: 'agent-1',
|
||
})
|
||
|
||
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||
type: 'tool_use_complete',
|
||
toolName: 'Read',
|
||
toolUseId: 'tool-1',
|
||
input: { file_path: 'src/App.tsx' },
|
||
})
|
||
|
||
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||
type: 'tool_result',
|
||
toolUseId: 'tool-1',
|
||
content: 'ok',
|
||
isError: false,
|
||
})
|
||
|
||
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('renders a pending tool call as soon as the tool stream starts', () => {
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[TEST_SESSION_ID]: makeSession(),
|
||
},
|
||
})
|
||
|
||
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||
type: 'content_start',
|
||
blockType: 'tool_use',
|
||
toolName: 'Write',
|
||
toolUseId: 'write-1',
|
||
})
|
||
|
||
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([
|
||
{
|
||
type: 'tool_use',
|
||
toolName: 'Write',
|
||
toolUseId: 'write-1',
|
||
input: {},
|
||
isPending: true,
|
||
},
|
||
])
|
||
|
||
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||
type: 'content_delta',
|
||
toolInput: '{"file_path":"/private/tmp/ai-code-novel.md","content":"第一章',
|
||
})
|
||
|
||
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([
|
||
{
|
||
type: 'tool_use',
|
||
toolName: 'Write',
|
||
toolUseId: 'write-1',
|
||
input: { file_path: '/private/tmp/ai-code-novel.md' },
|
||
isPending: true,
|
||
partialInput: '{"file_path":"/private/tmp/ai-code-novel.md","content":"第一章',
|
||
},
|
||
])
|
||
|
||
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||
type: 'tool_use_complete',
|
||
toolName: 'Write',
|
||
toolUseId: 'write-1',
|
||
input: {
|
||
file_path: '/private/tmp/ai-code-novel.md',
|
||
content: '第一章\n正文',
|
||
},
|
||
})
|
||
|
||
const messages = useChatStore.getState().sessions[TEST_SESSION_ID]?.messages ?? []
|
||
const toolMessages = messages.filter((message) => message.type === 'tool_use')
|
||
expect(toolMessages).toHaveLength(1)
|
||
expect(toolMessages[0]).toMatchObject({
|
||
type: 'tool_use',
|
||
toolName: 'Write',
|
||
toolUseId: 'write-1',
|
||
input: {
|
||
file_path: '/private/tmp/ai-code-novel.md',
|
||
content: '第一章\n正文',
|
||
},
|
||
isPending: false,
|
||
})
|
||
expect(toolMessages[0]).not.toHaveProperty('partialInput')
|
||
})
|
||
|
||
it('refreshes merged slash commands when a live CLI update omits project commands', async () => {
|
||
const cliCommand = { name: 'builtin-help', description: 'Built-in command' }
|
||
const projectCommand = { name: 'project-probe', description: 'Project custom command' }
|
||
|
||
vi.mocked(sessionsApi.getSlashCommands).mockClear()
|
||
vi.mocked(sessionsApi.getSlashCommands).mockResolvedValueOnce({
|
||
commands: [cliCommand, projectCommand],
|
||
})
|
||
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[TEST_SESSION_ID]: makeSession({
|
||
slashCommands: [projectCommand],
|
||
}),
|
||
},
|
||
})
|
||
|
||
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||
type: 'system_notification',
|
||
subtype: 'slash_commands',
|
||
data: [cliCommand],
|
||
})
|
||
|
||
await Promise.resolve()
|
||
|
||
expect(sessionsApi.getSlashCommands).toHaveBeenCalledTimes(1)
|
||
expect(sessionsApi.getSlashCommands).toHaveBeenCalledWith(TEST_SESSION_ID)
|
||
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.slashCommands).toEqual([
|
||
cliCommand,
|
||
projectCommand,
|
||
])
|
||
})
|
||
|
||
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',
|
||
result: '修复了异常处理并补充了回归覆盖。',
|
||
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',
|
||
result: '修复了异常处理并补充了回归覆盖。',
|
||
outputFile: '/tmp/agent-output.txt',
|
||
})
|
||
})
|
||
|
||
it('tracks background agent task lifecycle without duplicating transcript cards', () => {
|
||
vi.useFakeTimers()
|
||
vi.setSystemTime(new Date('2026-04-06T00:00:01.000Z'))
|
||
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[TEST_SESSION_ID]: makeSession({
|
||
chatState: 'tool_executing',
|
||
}),
|
||
},
|
||
})
|
||
|
||
vi.setSystemTime(new Date('2026-04-06T00:00:02.000Z'))
|
||
|
||
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||
type: 'system_notification',
|
||
subtype: 'task_started',
|
||
data: {
|
||
task_id: 'agent-task-1',
|
||
tool_use_id: 'agent-tool-1',
|
||
description: 'Verify the todo app',
|
||
task_type: 'local_agent',
|
||
prompt: 'Run E2E verification',
|
||
},
|
||
})
|
||
|
||
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.backgroundAgentTasks?.['agent-task-1']).toMatchObject({
|
||
taskId: 'agent-task-1',
|
||
toolUseId: 'agent-tool-1',
|
||
status: 'running',
|
||
description: 'Verify the todo app',
|
||
taskType: 'local_agent',
|
||
prompt: 'Run E2E verification',
|
||
})
|
||
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toHaveLength(0)
|
||
|
||
vi.setSystemTime(new Date('2026-04-06T00:00:03.000Z'))
|
||
|
||
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||
type: 'system_notification',
|
||
subtype: 'task_progress',
|
||
data: {
|
||
task_id: 'agent-task-1',
|
||
tool_use_id: 'agent-tool-1',
|
||
description: 'Verify the todo app',
|
||
summary: 'Running Playwright checks',
|
||
last_tool_name: 'Bash',
|
||
usage: {
|
||
total_tokens: 1200,
|
||
tool_uses: 4,
|
||
duration_ms: 45000,
|
||
},
|
||
},
|
||
})
|
||
|
||
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.backgroundAgentTasks?.['agent-task-1']).toMatchObject({
|
||
status: 'running',
|
||
summary: 'Running Playwright checks',
|
||
lastToolName: 'Bash',
|
||
usage: {
|
||
totalTokens: 1200,
|
||
toolUses: 4,
|
||
durationMs: 45000,
|
||
},
|
||
})
|
||
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toHaveLength(0)
|
||
|
||
vi.setSystemTime(new Date('2026-04-06T00:00:04.000Z'))
|
||
|
||
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: 'Found and fixed localStorage corruption.',
|
||
result: 'Root cause was a stale session cache entry.',
|
||
output_file: '/tmp/agent-output.txt',
|
||
usage: {
|
||
total_tokens: 2400,
|
||
tool_uses: 9,
|
||
duration_ms: 120000,
|
||
},
|
||
},
|
||
})
|
||
|
||
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.backgroundAgentTasks?.['agent-task-1']).toMatchObject({
|
||
status: 'completed',
|
||
summary: 'Found and fixed localStorage corruption.',
|
||
outputFile: '/tmp/agent-output.txt',
|
||
usage: {
|
||
totalTokens: 2400,
|
||
toolUses: 9,
|
||
durationMs: 120000,
|
||
},
|
||
})
|
||
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.agentTaskNotifications['agent-tool-1']).toMatchObject({
|
||
status: 'completed',
|
||
summary: 'Found and fixed localStorage corruption.',
|
||
result: 'Root cause was a stale session cache entry.',
|
||
outputFile: '/tmp/agent-output.txt',
|
||
})
|
||
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toHaveLength(0)
|
||
vi.useRealTimers()
|
||
})
|
||
|
||
it('keeps non-agent background tasks visible and updates the existing transcript card', () => {
|
||
vi.useFakeTimers()
|
||
vi.setSystemTime(new Date('2026-04-06T00:00:01.000Z'))
|
||
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[TEST_SESSION_ID]: makeSession(),
|
||
},
|
||
})
|
||
|
||
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||
type: 'system_notification',
|
||
subtype: 'task_started',
|
||
data: {
|
||
task_id: 'shell-task-1',
|
||
tool_use_id: 'shell-tool-1',
|
||
description: 'Run desktop checks',
|
||
task_type: 'local_bash',
|
||
},
|
||
})
|
||
|
||
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([
|
||
{
|
||
type: 'background_task',
|
||
task: {
|
||
taskId: 'shell-task-1',
|
||
toolUseId: 'shell-tool-1',
|
||
status: 'running',
|
||
taskType: 'local_bash',
|
||
description: 'Run desktop checks',
|
||
},
|
||
},
|
||
])
|
||
const insertedTaskTimestamp = useChatStore.getState().sessions[TEST_SESSION_ID]?.messages[0]?.timestamp
|
||
|
||
vi.setSystemTime(new Date('2026-04-06T00:00:02.000Z'))
|
||
|
||
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||
type: 'system_notification',
|
||
subtype: 'task_progress',
|
||
data: {
|
||
task_id: 'shell-task-1',
|
||
tool_use_id: 'shell-tool-1',
|
||
description: 'Run desktop checks',
|
||
summary: 'Running Vitest',
|
||
last_tool_name: 'Bash',
|
||
task_type: 'local_bash',
|
||
},
|
||
})
|
||
|
||
const session = useChatStore.getState().sessions[TEST_SESSION_ID]
|
||
expect(session?.messages).toHaveLength(1)
|
||
expect(session?.messages[0]).toMatchObject({
|
||
type: 'background_task',
|
||
task: {
|
||
taskId: 'shell-task-1',
|
||
status: 'running',
|
||
summary: 'Running Vitest',
|
||
lastToolName: 'Bash',
|
||
},
|
||
})
|
||
expect(session?.messages[0]?.timestamp).toBe(insertedTaskTimestamp)
|
||
vi.useRealTimers()
|
||
})
|
||
|
||
it('marks a background shell task stopped when TaskStop returns before a task notification', () => {
|
||
vi.useFakeTimers()
|
||
vi.setSystemTime(new Date('2026-05-19T13:34:19.000Z'))
|
||
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[TEST_SESSION_ID]: makeSession(),
|
||
},
|
||
})
|
||
|
||
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||
type: 'system_notification',
|
||
subtype: 'task_started',
|
||
data: {
|
||
task_id: 'shell-task-1',
|
||
tool_use_id: 'shell-tool-1',
|
||
description: 'Start tap proxy',
|
||
task_type: 'local_bash',
|
||
},
|
||
})
|
||
|
||
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||
type: 'tool_use_complete',
|
||
toolName: 'TaskStop',
|
||
toolUseId: 'task-stop-1',
|
||
input: { task_id: 'shell-task-1' },
|
||
})
|
||
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||
type: 'tool_result',
|
||
toolUseId: 'task-stop-1',
|
||
isError: false,
|
||
content: JSON.stringify({
|
||
message: 'Successfully stopped task: shell-task-1 (tap proxy)',
|
||
task_id: 'shell-task-1',
|
||
task_type: 'local_bash',
|
||
command: 'tap proxy',
|
||
}),
|
||
})
|
||
|
||
const session = useChatStore.getState().sessions[TEST_SESSION_ID]
|
||
expect(session?.backgroundAgentTasks?.['shell-task-1']).toMatchObject({
|
||
status: 'stopped',
|
||
taskType: 'local_bash',
|
||
description: 'tap proxy',
|
||
})
|
||
expect(session?.messages.find((message) => message.type === 'background_task')).toMatchObject({
|
||
type: 'background_task',
|
||
task: {
|
||
taskId: 'shell-task-1',
|
||
status: 'stopped',
|
||
taskType: 'local_bash',
|
||
description: 'tap proxy',
|
||
},
|
||
})
|
||
|
||
vi.useRealTimers()
|
||
})
|
||
|
||
it('removes stale agent task transcript cards by matching tool use id', () => {
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[TEST_SESSION_ID]: makeSession({
|
||
messages: [{
|
||
id: 'background-task-old-agent-task',
|
||
type: 'background_task',
|
||
timestamp: 1,
|
||
task: {
|
||
taskId: 'old-agent-task',
|
||
toolUseId: 'agent-tool-1',
|
||
status: 'running',
|
||
taskType: 'local_agent',
|
||
startedAt: 1,
|
||
updatedAt: 1,
|
||
},
|
||
}],
|
||
}),
|
||
},
|
||
})
|
||
|
||
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||
type: 'system_notification',
|
||
subtype: 'task_started',
|
||
data: {
|
||
task_id: 'new-agent-task',
|
||
tool_use_id: 'agent-tool-1',
|
||
task_type: 'local_agent',
|
||
description: 'Review app',
|
||
},
|
||
})
|
||
|
||
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toHaveLength(0)
|
||
})
|
||
|
||
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 compact summary cards', () => {
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[TEST_SESSION_ID]: {
|
||
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: '',
|
||
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',
|
||
data: { trigger: 'auto', pre_tokens: 120000 },
|
||
})
|
||
|
||
const messages = useChatStore.getState().sessions[TEST_SESSION_ID]?.messages ?? []
|
||
expect(messages).toHaveLength(3)
|
||
expect(messages).toMatchObject([
|
||
{ id: 'old-user', type: 'user_text', content: 'Build the billing import flow' },
|
||
{ id: 'old-assistant', type: 'assistant_text', content: 'Implemented the flow.' },
|
||
{
|
||
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',
|
||
data: { trigger: 'manual' },
|
||
})
|
||
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',
|
||
trigger: 'manual',
|
||
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([
|
||
{
|
||
id: 'old-user',
|
||
type: 'user_text',
|
||
content: 'old context',
|
||
},
|
||
{
|
||
type: 'compact_summary',
|
||
phase: 'compacting',
|
||
},
|
||
])
|
||
expect(updateTabStatusMock).toHaveBeenLastCalledWith(TEST_SESSION_ID, 'running')
|
||
})
|
||
|
||
it('removes the transient compacting card when compaction is canceled', () => {
|
||
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',
|
||
})
|
||
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||
type: 'error',
|
||
message: 'Compaction canceled.',
|
||
code: 'aborted',
|
||
})
|
||
|
||
const session = useChatStore.getState().sessions[TEST_SESSION_ID]
|
||
expect(session?.statusVerb).toBe('')
|
||
expect(session?.messages).toMatchObject([
|
||
{
|
||
id: 'old-user',
|
||
type: 'user_text',
|
||
content: 'old context',
|
||
},
|
||
{
|
||
type: 'error',
|
||
message: 'Compaction canceled.',
|
||
},
|
||
])
|
||
expect(session?.messages.some((message) => message.type === 'compact_summary' && message.phase === 'compacting')).toBe(false)
|
||
expect(updateTabStatusMock).toHaveBeenLastCalledWith(TEST_SESSION_ID, 'error')
|
||
})
|
||
|
||
it('removes the transient compacting card when compacting status ends without a boundary', () => {
|
||
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',
|
||
})
|
||
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||
type: 'status',
|
||
state: 'thinking',
|
||
verb: 'Thinking',
|
||
})
|
||
|
||
const session = useChatStore.getState().sessions[TEST_SESSION_ID]
|
||
expect(session?.chatState).toBe('thinking')
|
||
expect(session?.messages).toMatchObject([
|
||
{
|
||
id: 'old-user',
|
||
type: 'user_text',
|
||
content: 'old context',
|
||
},
|
||
])
|
||
expect(session?.messages.some((message) => message.type === 'compact_summary' && message.phase === 'compacting')).toBe(false)
|
||
expect(updateTabStatusMock).toHaveBeenLastCalledWith(TEST_SESSION_ID, 'running')
|
||
})
|
||
|
||
it('tracks API retry status until the request finishes', () => {
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[TEST_SESSION_ID]: makeSession({
|
||
messages: [],
|
||
chatState: 'thinking',
|
||
statusVerb: 'Thinking',
|
||
}),
|
||
},
|
||
})
|
||
|
||
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||
type: 'api_retry',
|
||
attempt: 1,
|
||
maxRetries: 10,
|
||
retryDelayMs: 2500,
|
||
errorStatus: 503,
|
||
errorType: 'server_error',
|
||
})
|
||
|
||
const retryingSession = useChatStore.getState().sessions[TEST_SESSION_ID]
|
||
expect(retryingSession?.chatState).toBe('thinking')
|
||
expect(retryingSession?.statusVerb).toBe('')
|
||
expect(retryingSession?.apiRetry).toMatchObject({
|
||
attempt: 1,
|
||
maxRetries: 10,
|
||
retryDelayMs: 2500,
|
||
errorStatus: 503,
|
||
errorType: 'server_error',
|
||
})
|
||
expect(updateTabStatusMock).toHaveBeenLastCalledWith(TEST_SESSION_ID, 'running')
|
||
|
||
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||
type: 'message_complete',
|
||
usage: { input_tokens: 1, output_tokens: 0 },
|
||
})
|
||
|
||
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.apiRetry).toBeNull()
|
||
})
|
||
|
||
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 set: ship the smoke test',
|
||
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 set: ship the replacement target',
|
||
data: {
|
||
action: 'created',
|
||
status: 'active',
|
||
objective: 'ship the replacement target',
|
||
budget: '0 / unlimited tokens',
|
||
continuations: '0',
|
||
},
|
||
})
|
||
|
||
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.activeGoal).toMatchObject({
|
||
action: 'created',
|
||
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('marks the tab idle when a message completes', () => {
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[TEST_SESSION_ID]: makeSession({ chatState: 'thinking' }),
|
||
},
|
||
})
|
||
|
||
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||
type: 'message_complete',
|
||
usage: { input_tokens: 1, output_tokens: 2 },
|
||
})
|
||
|
||
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.chatState).toBe('idle')
|
||
expect(updateTabStatusMock).toHaveBeenCalledWith(TEST_SESSION_ID, 'idle')
|
||
})
|
||
|
||
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('clears transient worktree startup text when normal thinking resumes', () => {
|
||
useChatStore.setState({
|
||
sessions: {
|
||
[TEST_SESSION_ID]: makeSession({
|
||
chatState: 'idle',
|
||
}),
|
||
},
|
||
})
|
||
|
||
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||
type: 'status',
|
||
state: 'thinking',
|
||
verb: 'Creating worktree',
|
||
})
|
||
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.statusVerb).toBe('Creating worktree')
|
||
|
||
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||
type: 'status',
|
||
state: 'thinking',
|
||
verb: 'Thinking',
|
||
})
|
||
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.statusVerb).toBe('')
|
||
|
||
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||
type: 'status',
|
||
state: 'thinking',
|
||
verb: 'Creating worktree',
|
||
})
|
||
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||
type: 'status',
|
||
state: 'thinking',
|
||
})
|
||
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.statusVerb).toBe('')
|
||
})
|
||
|
||
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,
|
||
})
|
||
})
|
||
})
|