cc-haha/desktop/src/stores/chatStore.test.ts
程序员阿江(Relakkes) bc93493ed1 Prevent desktop rewind from trimming the wrong turn
Desktop rewind previously used the visible user-message index as the primary selector. That can drift from the persisted active chain when hidden or non-rendered user messages exist, so the API now prefers a stable user message id and checks the selected prompt text before mutating transcript or files.

Constraint: Desktop UI can hide transcript entries that still exist in the persisted session chain
Rejected: Keep index-only rewind | can target an earlier user message after UI/server chain drift
Confidence: high
Scope-risk: moderate
Directive: Do not remove targetUserMessageId or expectedContent guards without reproducing shifted visible-index sessions
Tested: bun test src/server/__tests__/sessions.test.ts --timeout 20000
Tested: cd desktop && bun run test MessageList.test.tsx chatStore.test.ts -- --run
Tested: desktop/scripts/e2e-rewind-agent-browser.sh
Tested: desktop/scripts/e2e-rewind-complex-agent-browser.sh
Not-tested: Full desktop Vitest suite still has unrelated locale-sensitive failures
2026-04-28 16:38:16 +08:00

1003 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { MessageEntry } from '../types/session'
import { useSessionRuntimeStore } from './sessionRuntimeStore'
const {
sendMock,
getMemberBySessionIdMock,
sendMessageToMemberMock,
handleTeamCreatedMock,
handleTeamUpdateMock,
handleTeamDeletedMock,
fetchSessionTasksMock,
clearTasksMock,
setTasksFromTodosMock,
markCompletedAndDismissedMock,
resetCompletedTasksMock,
refreshTasksMock,
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(),
cliTaskStoreSnapshot: {
tasks: [] as Array<{ id: string; subject: string; status: string; activeForm?: string }>,
sessionId: null as string | null,
},
}))
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: vi.fn(),
updateTabTitle: vi.fn(),
}),
},
}))
vi.mock('./sessionStore', () => ({
useSessionStore: {
getState: () => ({
updateSessionTitle: vi.fn(),
}),
},
}))
vi.mock('./cliTaskStore', () => ({
useCLITaskStore: {
getState: () => ({
fetchSessionTasks: fetchSessionTasksMock,
tasks: cliTaskStoreSnapshot.tasks,
sessionId: cliTaskStoreSnapshot.sessionId,
clearTasks: clearTasksMock,
setTasksFromTodos: setTasksFromTodosMock,
markCompletedAndDismissed: markCompletedAndDismissedMock,
resetCompletedTasks: resetCompletedTasksMock,
refreshTasks: refreshTasksMock,
}),
},
}))
import { mapHistoryMessagesToUiMessages, useChatStore } from './chatStore'
const TEST_SESSION_ID = 'test-session-1'
const initialState = useChatStore.getState()
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()
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('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('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('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('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',
})
})
it('sends permission mode updates to the active session only', () => {
useChatStore.getState().setSessionPermissionMode('nonexistent-session', 'acceptEdits')
expect(sendMock).not.toHaveBeenCalled()
useChatStore.setState({
sessions: {
'session-1': {
messages: [],
chatState: 'idle',
connectionState: 'connected',
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
elapsedSeconds: 0,
statusVerb: '',
slashCommands: [],
agentTaskNotifications: {},
elapsedTimer: null,
},
},
})
useChatStore.getState().setSessionPermissionMode('session-1', 'acceptEdits')
expect(sendMock).toHaveBeenCalledWith('session-1', {
type: 'set_permission_mode',
mode: 'acceptEdits',
})
})
it('stores terminal task notifications for agent tool cards', () => {
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: {
messages: [],
chatState: 'idle',
connectionState: 'connected',
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
elapsedSeconds: 0,
statusVerb: '',
slashCommands: [],
agentTaskNotifications: {},
elapsedTimer: null,
},
},
})
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'system_notification',
subtype: 'task_notification',
data: {
task_id: 'agent-task-1',
tool_use_id: 'agent-tool-1',
status: 'completed',
summary: 'Agent "修复异常处理" completed',
output_file: '/tmp/agent-output.txt',
},
})
expect(
useChatStore.getState().sessions[TEST_SESSION_ID]?.agentTaskNotifications[
'agent-tool-1'
],
).toMatchObject({
taskId: 'agent-task-1',
toolUseId: 'agent-tool-1',
status: 'completed',
summary: 'Agent "修复异常处理" completed',
outputFile: '/tmp/agent-output.txt',
})
})
it('clears local desktop chat state when the server confirms /clear', () => {
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: '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).toHaveBeenCalled()
})
it('renders compact boundary notifications as system messages', () => {
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: {
messages: [],
chatState: 'idle',
connectionState: 'connected',
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
elapsedSeconds: 0,
statusVerb: '',
slashCommands: [],
agentTaskNotifications: {},
elapsedTimer: null,
},
},
})
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'system_notification',
subtype: 'compact_boundary',
message: 'Context compacted',
})
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([
{ type: 'system', content: 'Context compacted' },
])
})
it('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).toHaveBeenCalledTimes(1)
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('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')
})
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('does not split one streamed markdown reply when task progress arrives mid-stream', () => {
vi.useFakeTimers()
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: {
messages: [],
chatState: 'idle',
connectionState: 'connected',
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
elapsedSeconds: 0,
statusVerb: '',
slashCommands: [],
agentTaskNotifications: {},
elapsedTimer: null,
},
},
})
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'content_start',
blockType: 'text',
})
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'content_delta',
text: '1. **`core/audio/waveform.py:19-31`** — 同步阻塞 I/O。',
})
vi.advanceTimersByTime(60)
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'status',
state: 'tool_executing',
verb: 'Task in progress',
})
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'content_delta',
text: ' 建议直接用 `subprocess.PIPE` 流式处理。',
})
vi.advanceTimersByTime(60)
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'message_complete',
usage: { input_tokens: 1, output_tokens: 2 },
})
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([
{
type: 'assistant_text',
content:
'1. **`core/audio/waveform.py:19-31`** — 同步阻塞 I/O。 建议直接用 `subprocess.PIPE` 流式处理。',
},
])
vi.runOnlyPendingTimers()
vi.useRealTimers()
})
it('sends 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)
})
})