mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
Desktop chat was rendering AskUserQuestion twice: once as an inline question card and again as a generic permission request. That split also dropped the structured answers on the floor because the websocket permission_response shape only carried allow/deny state. This change keeps AskUserQuestion on the permission pipeline end-to-end. The desktop websocket contract now carries toolUseId and updatedInput, AskUserQuestion submits answers through permission_response, and the generic permission card is suppressed for that tool so the user sees a single question flow. Constraint: AskUserQuestion answers must round-trip through updatedInput.answers for the CLI tool contract to complete Rejected: Leave AskUserQuestion as a plain chat reply in desktop | the tool never receives structured answers and the pending approval UI remains stuck Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep AskUserQuestion bound to the permission-response path unless the desktop protocol grows a separate structured elicitation channel Tested: desktop lint; vitest src/components/chat/AskUserQuestion.test.tsx src/stores/chatStore.test.ts; bun test src/server/__tests__/conversations.test.ts Not-tested: Manual desktop click-through against a live plan-mode session after bundling
619 lines
18 KiB
TypeScript
619 lines
18 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import type { MessageEntry } from '../types/session'
|
|
|
|
const {
|
|
sendMock,
|
|
getMemberBySessionIdMock,
|
|
sendMessageToMemberMock,
|
|
handleTeamCreatedMock,
|
|
handleTeamUpdateMock,
|
|
handleTeamDeletedMock,
|
|
fetchSessionTasksMock,
|
|
clearTasksMock,
|
|
setTasksFromTodosMock,
|
|
markCompletedAndDismissedMock,
|
|
refreshTasksMock,
|
|
} = 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(),
|
|
refreshTasksMock: vi.fn(),
|
|
}))
|
|
|
|
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: [],
|
|
sessionId: null,
|
|
clearTasks: clearTasksMock,
|
|
setTasksFromTodos: setTasksFromTodosMock,
|
|
markCompletedAndDismissed: markCompletedAndDismissedMock,
|
|
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()
|
|
refreshTasksMock.mockReset()
|
|
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('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('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: [],
|
|
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('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('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('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('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)
|
|
})
|
|
})
|