PicoBot/web/src/hooks/useChat.test.ts
oudecheng a9429a5657 fix(web): 输入框/发送按钮状态按话题隔离,避免处理中仍可发送
将单一全局 isLoading 布尔值重构为按 topic_id 跟踪的 processingTopicIds
集合,isLoading 派生自当前选中话题是否在集合中。导航响应不再清空处理
状态,切换话题后切回原话题仍能正确禁用输入。重连时通过新增的
/api/executions 端点对账后端权威执行状态,修正断连期间丢失的
execution_completed 信号导致的状态漂移。
2026-08-07 11:45:47 +08:00

581 lines
21 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 { 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();
// 选中话题后 handleMessage 才会标记该话题为处理中(与生产使用场景一致)
act(() => result.current.setSelectedTopic('topic-1'));
// 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('16. scheduler view active: realtime stream_delta and topic-tagged messages do NOT leak into schedulerView', () => {
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();
// 实时流式消息不应进入历史视图(即使 schedulerView 激活)
const realtimeStream: StreamDelta = {
type: 'stream_delta',
id: 'leak1',
delta: 'leaking content',
topic_id: 't-realtime',
};
act(() => result.current.handleServerMessage(realtimeStream));
expect(result.current.schedulerView?.messages.find((m) => m.id === 'leak1')).toBeUndefined();
// 带 topic_id 的 assistant_response 也不应进入历史视图
const taggedAssistant: AssistantResponse = {
type: 'assistant_response',
id: 'leak2',
content: 'tagged response',
role: 'assistant',
topic_id: 't-realtime',
};
act(() => result.current.handleServerMessage(taggedAssistant));
expect(result.current.schedulerView?.messages.find((m) => m.id === 'leak2')).toBeUndefined();
// 无 topic_id 的历史消息仍正常进入(保持用例 11 的行为)
act(() => result.current.handleServerMessage(assistantResponse));
expect(result.current.schedulerView?.messages).toHaveLength(1);
expect(result.current.schedulerView?.messages[0].content).toBe('Hello world');
});
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();
});
it('14. multiple stream_delta in one act() batch accumulate correctly', () => {
const { result } = renderUseChat();
// 模拟同一同步批次内连续到达多个 delta验证 chunks 累加 + index 跟踪)
act(() => {
result.current.handleServerMessage({ type: 'stream_delta', id: 'batch1', delta: 'A' });
result.current.handleServerMessage({ type: 'stream_delta', id: 'batch1', delta: 'B' });
result.current.handleServerMessage({ type: 'stream_delta', id: 'batch1', delta: 'C' });
});
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0].id).toBe('batch1');
expect(result.current.messages[0].content).toBe('ABC');
});
it('15. stream_end then assistant_response replaces streamed content including reasoning', () => {
const { result } = renderUseChat();
act(() => {
result.current.handleServerMessage({
type: 'stream_delta',
id: 'r1',
delta: 'text',
reasoning_delta: 'think',
});
});
expect(result.current.messages[0].content).toBe('text');
expect(result.current.messages[0].reasoningContent).toBe('think');
// stream_end 触发 finishStreamingflush pending + clear
act(() => result.current.handleServerMessage({ type: 'stream_end', id: 'r1' }));
// assistant_response 替换整条消息
act(() =>
result.current.handleServerMessage({
type: 'assistant_response',
id: 'r1',
content: 'final text',
role: 'assistant',
reasoning_content: 'final reasoning',
}),
);
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0].content).toBe('final text');
expect(result.current.messages[0].reasoningContent).toBe('final reasoning');
});
it('17. finishStreaming (onDisconnect) mid-stream resets streaming state, preventing post-reconnect leakage', () => {
const { result } = renderUseChat();
// 流式进行中
act(() => {
result.current.handleServerMessage({ type: 'stream_delta', id: 's1', delta: 'part1' });
result.current.handleServerMessage({ type: 'stream_delta', id: 's1', delta: 'part2' });
});
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0].content).toBe('part1part2');
// 模拟 WebSocket 断连onDisconnect 回调触发 finishStreaming
act(() => result.current.finishStreaming());
// 重连后,新 delta不同 id应作为新消息出现不应追加到已断连的旧消息
act(() =>
result.current.handleServerMessage({ type: 'stream_delta', id: 's2', delta: 'fresh' }),
);
const s2 = result.current.messages.find((m) => m.id === 's2');
expect(s2).toBeDefined();
expect(s2?.content).toBe('fresh');
// 旧消息内容保持不变(未被新 delta 污染)
const s1 = result.current.messages.find((m) => m.id === 's1');
expect(s1?.content).toBe('part1part2');
});
it('18. stream_end arriving after onDisconnect is idempotent (no crash, no side effect)', () => {
const { result } = renderUseChat();
act(() => {
result.current.handleServerMessage({ type: 'stream_delta', id: 'e1', delta: 'partial' });
});
expect(result.current.messages[0].content).toBe('partial');
// 断连finishStreaming 重置 streamingRef
act(() => result.current.finishStreaming());
// 重连后 stream_end 到达(可能在断连前已发出,重连后才收到)
expect(() => {
act(() => result.current.handleServerMessage({ type: 'stream_end', id: 'e1' }));
}).not.toThrow();
// stream_end 不应改变已 flush 的内容
const e1 = result.current.messages.find((m) => m.id === 'e1');
expect(e1?.content).toBe('partial');
// 紧接着 assistant_response 到达,应正常按 id 替换(断连后的正常恢复路径)
act(() =>
result.current.handleServerMessage({
type: 'assistant_response',
id: 'e1',
content: 'final',
role: 'assistant',
}),
);
expect(result.current.messages.find((m) => m.id === 'e1')?.content).toBe('final');
});
it('19. multiple disconnect-reconnect cycles: finishStreaming is idempotent across cycles', () => {
const { result } = renderUseChat();
// 周期 1
act(() => result.current.handleServerMessage({ type: 'stream_delta', id: 'c1', delta: 'a' }));
act(() => result.current.finishStreaming());
expect(result.current.messages.find((m) => m.id === 'c1')?.content).toBe('a');
// 周期 2新流式消息
act(() => result.current.handleServerMessage({ type: 'stream_delta', id: 'c2', delta: 'b' }));
act(() => result.current.finishStreaming());
expect(result.current.messages.find((m) => m.id === 'c2')?.content).toBe('b');
// c1 不受影响
expect(result.current.messages.find((m) => m.id === 'c1')?.content).toBe('a');
// 周期 3连续两次 finishStreaming无 delta 间隔),验证幂等
act(() => result.current.finishStreaming());
act(() => result.current.finishStreaming());
// 不崩溃,消息状态稳定
expect(result.current.messages).toHaveLength(2);
});
});