mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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
1003 lines
29 KiB
TypeScript
1003 lines
29 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,
|
||
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)
|
||
})
|
||
})
|