test: 添加 useChat handleServerMessage 特征测试
新增 13 条特征测试,覆盖 handleServerMessage 全部 20 个 case 与 3 层路由(调度器→子智能体→主视图),锚定行为基线以保护后续组合根重写的回归风险。
This commit is contained in:
parent
7038d58207
commit
f3e3ebf55f
407
web/src/hooks/useChat.test.ts
Normal file
407
web/src/hooks/useChat.test.ts
Normal file
@ -0,0 +1,407 @@
|
|||||||
|
import { renderHook, act } from '@testing-library/react'
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { useChat } from './useChat'
|
||||||
|
import type {
|
||||||
|
WsInbound,
|
||||||
|
SessionEstablished,
|
||||||
|
SessionList,
|
||||||
|
SessionSummary,
|
||||||
|
TopicList,
|
||||||
|
TopicSummary,
|
||||||
|
StreamDelta,
|
||||||
|
AssistantResponse,
|
||||||
|
ToolCall,
|
||||||
|
ToolResult,
|
||||||
|
ToolPending,
|
||||||
|
WsError,
|
||||||
|
TaskStarted,
|
||||||
|
TaskMessagesLoaded,
|
||||||
|
MemoryList,
|
||||||
|
MemorySummary,
|
||||||
|
SkillList,
|
||||||
|
SkillSummary,
|
||||||
|
TodoList,
|
||||||
|
TodoItemSummary,
|
||||||
|
ChannelList,
|
||||||
|
Channel,
|
||||||
|
SchedulerJobList,
|
||||||
|
SchedulerJobSummary,
|
||||||
|
SchedulerJobSessionLookup,
|
||||||
|
ExecutionCancelled,
|
||||||
|
} from '../types/protocol'
|
||||||
|
|
||||||
|
// ---- helpers ----
|
||||||
|
|
||||||
|
function renderUseChat() {
|
||||||
|
const sendMessage = vi.fn((_msg: WsInbound) => true)
|
||||||
|
const { result } = renderHook(() => useChat())
|
||||||
|
act(() => {
|
||||||
|
result.current.setSendMessage(sendMessage)
|
||||||
|
})
|
||||||
|
return { result, sendMessage }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取出 sendMessage 收到的最后一条 command payload(已 JSON.parse) */
|
||||||
|
function lastCommand(sendMessage: ReturnType<typeof vi.fn>): unknown {
|
||||||
|
const calls = sendMessage.mock.calls
|
||||||
|
const last = calls.length > 0 ? calls[calls.length - 1][0] as WsInbound : undefined
|
||||||
|
if (last && last.type === 'command') {
|
||||||
|
return JSON.parse(last.payload)
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- fixtures ----
|
||||||
|
|
||||||
|
const sessionEstablished: SessionEstablished = {
|
||||||
|
type: 'session_established',
|
||||||
|
session_id: 'sess-1',
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSession(id: string): SessionSummary {
|
||||||
|
return {
|
||||||
|
session_id: id,
|
||||||
|
title: `Session ${id}`,
|
||||||
|
channel_name: 'websocket',
|
||||||
|
chat_id: `chat-${id}`,
|
||||||
|
message_count: 0,
|
||||||
|
last_active_at: 1000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionList: SessionList = {
|
||||||
|
type: 'session_list',
|
||||||
|
sessions: [makeSession('s1'), makeSession('s2')],
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeTopicSummary(id: string, sessionId = 's1'): TopicSummary {
|
||||||
|
return {
|
||||||
|
topic_id: id,
|
||||||
|
session_id: sessionId,
|
||||||
|
title: `Topic ${id}`,
|
||||||
|
message_count: 0,
|
||||||
|
created_at: 1000,
|
||||||
|
last_active_at: 2000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const topicList: TopicList = {
|
||||||
|
type: 'topic_list',
|
||||||
|
topics: [makeTopicSummary('t1'), makeTopicSummary('t2')],
|
||||||
|
session_id: 's1',
|
||||||
|
}
|
||||||
|
|
||||||
|
const streamDelta1: StreamDelta = {
|
||||||
|
type: 'stream_delta',
|
||||||
|
id: 'm1',
|
||||||
|
delta: 'Hello',
|
||||||
|
}
|
||||||
|
const streamDelta2: StreamDelta = {
|
||||||
|
type: 'stream_delta',
|
||||||
|
id: 'm1',
|
||||||
|
delta: ' world',
|
||||||
|
}
|
||||||
|
|
||||||
|
const assistantResponse: AssistantResponse = {
|
||||||
|
type: 'assistant_response',
|
||||||
|
id: 'm1',
|
||||||
|
content: 'Hello world',
|
||||||
|
role: 'assistant',
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolCall: ToolCall = {
|
||||||
|
type: 'tool_call',
|
||||||
|
id: 'tc1',
|
||||||
|
tool_call_id: 'tc1',
|
||||||
|
tool_name: 'calculator',
|
||||||
|
arguments: { x: 1 },
|
||||||
|
content: 'calling calculator',
|
||||||
|
role: 'tool',
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolResult: ToolResult = {
|
||||||
|
type: 'tool_result',
|
||||||
|
id: 'tr1',
|
||||||
|
tool_call_id: 'tc1',
|
||||||
|
tool_name: 'calculator',
|
||||||
|
content: '42',
|
||||||
|
role: 'tool',
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolPending: ToolPending = {
|
||||||
|
type: 'tool_pending',
|
||||||
|
id: 'tp1',
|
||||||
|
tool_call_id: 'tp1',
|
||||||
|
tool_name: 'bash',
|
||||||
|
content: 'waiting',
|
||||||
|
resume_hint: 'resume later',
|
||||||
|
role: 'tool',
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorMsg: WsError = {
|
||||||
|
type: 'error',
|
||||||
|
code: 'ERR',
|
||||||
|
message: 'something broke',
|
||||||
|
}
|
||||||
|
|
||||||
|
const executionCancelled: ExecutionCancelled = {
|
||||||
|
type: 'execution_cancelled',
|
||||||
|
message: 'stopped by user',
|
||||||
|
}
|
||||||
|
|
||||||
|
const memoryList: MemoryList = {
|
||||||
|
type: 'memory_list',
|
||||||
|
memories: [
|
||||||
|
{ id: 'mem1', namespace: 'ns', memory_key: 'k', content: 'c', created_at: 1, updated_at: 2 },
|
||||||
|
] as MemorySummary[],
|
||||||
|
}
|
||||||
|
|
||||||
|
const skillList: SkillList = {
|
||||||
|
type: 'skill_list',
|
||||||
|
skills: [{ name: 'skill1', description: 'd', source: 'builtin' }] as SkillSummary[],
|
||||||
|
}
|
||||||
|
|
||||||
|
const todoList: TodoList = {
|
||||||
|
type: 'todo_list',
|
||||||
|
todos: [
|
||||||
|
{ id: 'todo1', content: 'task', status: 'pending', priority: 'high', created_at: 1, updated_at: 2 },
|
||||||
|
] as TodoItemSummary[],
|
||||||
|
scope_key: 'main',
|
||||||
|
}
|
||||||
|
|
||||||
|
const channelList: ChannelList = {
|
||||||
|
type: 'channel_list',
|
||||||
|
channels: [
|
||||||
|
{ id: 'websocket', name: 'WebSocket', isWritable: true },
|
||||||
|
{ id: 'cli', name: 'CLI', isWritable: false },
|
||||||
|
] as Channel[],
|
||||||
|
}
|
||||||
|
|
||||||
|
const schedulerJobList: SchedulerJobList = {
|
||||||
|
type: 'scheduler_job_list',
|
||||||
|
jobs: [
|
||||||
|
{ id: 'job1', kind: 'one_off', schedule: {}, enabled: true, state: 'idle', run_count: 0, created_at: 1 } as SchedulerJobSummary,
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- tests ----
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useChat - handleServerMessage characterization', () => {
|
||||||
|
it('1. session_established sets connectionId and isConnected', () => {
|
||||||
|
const { result } = renderUseChat()
|
||||||
|
expect(result.current.isConnected).toBe(false)
|
||||||
|
act(() => result.current.handleServerMessage(sessionEstablished))
|
||||||
|
expect(result.current.connectionId).toBe('sess-1')
|
||||||
|
expect(result.current.isConnected).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('2. session_list fills sessions and auto-selects the first', () => {
|
||||||
|
const { result } = renderUseChat()
|
||||||
|
act(() => result.current.handleServerMessage(sessionList))
|
||||||
|
expect(result.current.sessions).toHaveLength(2)
|
||||||
|
expect(result.current.selectedSessionId).toBe('s1')
|
||||||
|
expect(result.current.session?.session_id).toBe('s1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('3. topic_list maps topics; after createTopic it auto-focuses the first (newest)', () => {
|
||||||
|
const { result } = renderUseChat()
|
||||||
|
// establish session + topic list to set baseline
|
||||||
|
act(() => result.current.handleServerMessage(sessionEstablished))
|
||||||
|
act(() => result.current.handleServerMessage(sessionList))
|
||||||
|
// first topic_list (without createTopic) sets topics but does NOT auto-select
|
||||||
|
act(() => result.current.handleServerMessage(topicList))
|
||||||
|
expect(result.current.topics).toHaveLength(2)
|
||||||
|
expect(result.current.selectedTopic).toBeNull()
|
||||||
|
|
||||||
|
// simulate createTopic flow: pendingNewTopicRef set true, then new topic_list arrives
|
||||||
|
act(() => result.current.createTopic('new topic'))
|
||||||
|
const newTopicList: TopicList = {
|
||||||
|
type: 'topic_list',
|
||||||
|
topics: [makeTopicSummary('t3'), makeTopicSummary('t1'), makeTopicSummary('t2')],
|
||||||
|
session_id: 's1',
|
||||||
|
}
|
||||||
|
act(() => result.current.handleServerMessage(newTopicList))
|
||||||
|
expect(result.current.selectedTopic).toBe('t3')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('4. stream_delta creates a message then accumulates into it by id', () => {
|
||||||
|
const { result } = renderUseChat()
|
||||||
|
act(() => result.current.handleServerMessage(streamDelta1))
|
||||||
|
expect(result.current.messages).toHaveLength(1)
|
||||||
|
expect(result.current.messages[0].content).toBe('Hello')
|
||||||
|
act(() => result.current.handleServerMessage(streamDelta2))
|
||||||
|
expect(result.current.messages).toHaveLength(1)
|
||||||
|
expect(result.current.messages[0].content).toBe('Hello world')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('5. assistant_response replaces the streamed message by id', () => {
|
||||||
|
const { result } = renderUseChat()
|
||||||
|
act(() => result.current.handleServerMessage(streamDelta1))
|
||||||
|
act(() => result.current.handleServerMessage(streamDelta2))
|
||||||
|
act(() => result.current.handleServerMessage(assistantResponse))
|
||||||
|
expect(result.current.messages).toHaveLength(1)
|
||||||
|
expect(result.current.messages[0].content).toBe('Hello world')
|
||||||
|
expect(result.current.messages[0].id).toBe('m1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('6. tool_call / tool_result / tool_pending append corresponding message types', () => {
|
||||||
|
const { result } = renderUseChat()
|
||||||
|
act(() => result.current.handleServerMessage(toolCall))
|
||||||
|
act(() => result.current.handleServerMessage(toolResult))
|
||||||
|
act(() => result.current.handleServerMessage(toolPending))
|
||||||
|
expect(result.current.messages).toHaveLength(3)
|
||||||
|
expect(result.current.messages[0].type).toBe('tool_call')
|
||||||
|
expect(result.current.messages[0].toolName).toBe('calculator')
|
||||||
|
expect(result.current.messages[1].type).toBe('tool_result')
|
||||||
|
expect(result.current.messages[2].type).toBe('tool_pending')
|
||||||
|
expect(result.current.messages[2].content).toContain('resume later')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('7. error and execution_cancelled append a message and clear isLoading', () => {
|
||||||
|
const { result } = renderUseChat()
|
||||||
|
// set isLoading true via handleMessage
|
||||||
|
act(() => result.current.handleMessage('hi'))
|
||||||
|
expect(result.current.isLoading).toBe(true)
|
||||||
|
act(() => result.current.handleServerMessage(errorMsg))
|
||||||
|
expect(result.current.isLoading).toBe(false)
|
||||||
|
const errMsg = result.current.messages[result.current.messages.length - 1]
|
||||||
|
expect(errMsg?.content).toBe('Error: something broke')
|
||||||
|
|
||||||
|
// reset isLoading + cleared, then test execution_cancelled
|
||||||
|
act(() => result.current.handleMessage('hi again'))
|
||||||
|
expect(result.current.isLoading).toBe(true)
|
||||||
|
act(() => result.current.handleServerMessage(executionCancelled))
|
||||||
|
expect(result.current.isLoading).toBe(false)
|
||||||
|
const cancelMsg = result.current.messages[result.current.messages.length - 1]
|
||||||
|
expect(cancelMsg?.content).toBe('stopped by user')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('8. memory_list / skill_list / todo_list / channel_list / scheduler_job_list set corresponding state', () => {
|
||||||
|
const { result } = renderUseChat()
|
||||||
|
act(() => result.current.handleServerMessage(memoryList))
|
||||||
|
act(() => result.current.handleServerMessage(skillList))
|
||||||
|
act(() => result.current.handleServerMessage(todoList))
|
||||||
|
act(() => result.current.handleServerMessage(channelList))
|
||||||
|
act(() => result.current.handleServerMessage(schedulerJobList))
|
||||||
|
expect(result.current.memories).toHaveLength(1)
|
||||||
|
expect(result.current.skills).toHaveLength(1)
|
||||||
|
expect(result.current.todos).toHaveLength(1)
|
||||||
|
expect(result.current.channels).toHaveLength(2)
|
||||||
|
expect(result.current.schedulerJobs).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('9. task_started (main view, no parent) backfills navigateToTaskId on matching task tool_call', () => {
|
||||||
|
const { result } = renderUseChat()
|
||||||
|
const taskToolCall: ToolCall = {
|
||||||
|
type: 'tool_call',
|
||||||
|
id: 'tc-task',
|
||||||
|
tool_call_id: 'tc-task',
|
||||||
|
tool_name: 'task',
|
||||||
|
arguments: { prompt: 'do sub' },
|
||||||
|
content: 'spawning sub',
|
||||||
|
role: 'tool',
|
||||||
|
}
|
||||||
|
act(() => result.current.handleServerMessage(taskToolCall))
|
||||||
|
expect(result.current.messages[result.current.messages.length - 1]?.navigateToTaskId).toBeUndefined()
|
||||||
|
|
||||||
|
const taskStarted: TaskStarted = {
|
||||||
|
type: 'task_started',
|
||||||
|
task_id: 'sub-1',
|
||||||
|
description: 'sub agent',
|
||||||
|
subagent_type: 'general',
|
||||||
|
tool_call_id: 'tc-task',
|
||||||
|
}
|
||||||
|
act(() => result.current.handleServerMessage(taskStarted))
|
||||||
|
expect(result.current.messages[result.current.messages.length - 1]?.navigateToTaskId).toBe('sub-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('10. sub-agent view: task_messages_loaded updates stack top; tagged messages route to sub view not main', () => {
|
||||||
|
const { result } = renderUseChat()
|
||||||
|
// enter sub-agent view for task "sub-1"
|
||||||
|
act(() => result.current.enterSubAgentView('sub-1', 'sub agent', 'general'))
|
||||||
|
expect(result.current.subAgentView?.taskId).toBe('sub-1')
|
||||||
|
|
||||||
|
// task_messages_loaded updates top metadata
|
||||||
|
const loaded: TaskMessagesLoaded = {
|
||||||
|
type: 'task_messages_loaded',
|
||||||
|
task_id: 'sub-1',
|
||||||
|
description: 'sub agent',
|
||||||
|
subagent_type: 'general',
|
||||||
|
status: 'running',
|
||||||
|
summary: 'working',
|
||||||
|
}
|
||||||
|
act(() => result.current.handleServerMessage(loaded))
|
||||||
|
expect(result.current.subAgentView?.status).toBe('running')
|
||||||
|
expect(result.current.subAgentView?.summary).toBe('working')
|
||||||
|
|
||||||
|
// a stream_delta tagged with subagent_task_id === 'sub-1' goes to sub view, not main
|
||||||
|
const subStream: StreamDelta = {
|
||||||
|
type: 'stream_delta',
|
||||||
|
id: 'sub-m1',
|
||||||
|
delta: 'sub hello',
|
||||||
|
subagent_task_id: 'sub-1',
|
||||||
|
}
|
||||||
|
act(() => result.current.handleServerMessage(subStream))
|
||||||
|
expect(result.current.subAgentView?.messages).toHaveLength(1)
|
||||||
|
expect(result.current.subAgentView?.messages[0].content).toBe('sub hello')
|
||||||
|
|
||||||
|
// exit back to main: main messages should not contain the sub-agent message
|
||||||
|
act(() => result.current.exitSubAgentView())
|
||||||
|
expect(result.current.messages.find(m => m.id === 'sub-m1')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('11. scheduler view: chat messages route into schedulerView.messages, not main', () => {
|
||||||
|
const { result } = renderUseChat()
|
||||||
|
const lookup: SchedulerJobSessionLookup = { channel: 'scheduler', chat_id: 'job-chat' }
|
||||||
|
act(() => result.current.enterSchedulerJobView(lookup, 'job1', 'job desc'))
|
||||||
|
expect(result.current.schedulerView).not.toBeNull()
|
||||||
|
|
||||||
|
act(() => result.current.handleServerMessage(assistantResponse))
|
||||||
|
expect(result.current.schedulerView?.messages).toHaveLength(1)
|
||||||
|
expect(result.current.schedulerView?.messages[0].content).toBe('Hello world')
|
||||||
|
|
||||||
|
// exit scheduler view: main messages should not contain the routed message
|
||||||
|
act(() => result.current.exitSchedulerJobView())
|
||||||
|
expect(result.current.messages.find(m => m.id === 'm1')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('12. tool_result with tool_name=todo_write triggers a list_todos command in main view', () => {
|
||||||
|
const { result, sendMessage } = renderUseChat()
|
||||||
|
const todoWriteResult: ToolResult = {
|
||||||
|
type: 'tool_result',
|
||||||
|
id: 'tr-todo',
|
||||||
|
tool_call_id: 'tc-todo',
|
||||||
|
tool_name: 'todo_write',
|
||||||
|
content: 'updated',
|
||||||
|
role: 'tool',
|
||||||
|
}
|
||||||
|
act(() => result.current.handleServerMessage(todoWriteResult))
|
||||||
|
const cmd = lastCommand(sendMessage)
|
||||||
|
expect(cmd).toEqual({ type: 'list_todos' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('13. stream_delta whose topic_id does not match selectedTopic is discarded', () => {
|
||||||
|
const { result } = renderUseChat()
|
||||||
|
act(() => {
|
||||||
|
result.current.handleServerMessage(sessionEstablished)
|
||||||
|
result.current.handleServerMessage(sessionList)
|
||||||
|
result.current.handleServerMessage(topicList)
|
||||||
|
})
|
||||||
|
// topic_list without createTopic does NOT auto-select; manually select t1
|
||||||
|
act(() => result.current.selectTopic('t1'))
|
||||||
|
expect(result.current.selectedTopic).toBe('t1')
|
||||||
|
|
||||||
|
const otherTopicStream: StreamDelta = {
|
||||||
|
type: 'stream_delta',
|
||||||
|
id: 'm-other',
|
||||||
|
delta: 'should be dropped',
|
||||||
|
topic_id: 't-other',
|
||||||
|
}
|
||||||
|
act(() => result.current.handleServerMessage(otherTopicStream))
|
||||||
|
expect(result.current.messages.find(m => m.id === 'm-other')).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
x
Reference in New Issue
Block a user