agentChatEvents.test.js
6.26 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
/**
* 回归测试:9b1c3f1 修的"多聊天串台"不变式(40 号文档方案三)。
*
* 不变式:handleEvent 收到的每一条事件都要无条件写进它自己的 st(每个 agent 一份),
* 但只有在事件所属的 requestAgentId 仍等于"用户当前选中的 agent"时,才允许触碰
* 全局副作用(路由跳转 / 对话列表 / DOM 滚动)。这条测试不需要挂载整个 Vue 组件——
* handleEvent 已经被抽成不依赖组件实例的纯函数(agentChatEvents.js),直接喂事件、
* 断言 st 内容和副作用回调的调用次数即可。
*/
import { describe, it, expect, vi } from 'vitest';
import { createAgentChatEventHandler, createTypewriterQueue, flushTypewriter } from './agentChatEvents.js';
function makeState() {
return {
messages: [], activeConvId: null,
sending: false, streaming: false, streamContent: '',
subAgents: {}, subLogList: [], acpLog: [],
thinkingText: '', thinkingContent: '', showThinking: true,
};
}
describe('agentChatEvents · 串台加固', () => {
it('切走之后,旧 agent 的事件继续更新它自己的 st,但不再触发全局副作用', () => {
let current = 'agent-A';
const scrollToBottom = vi.fn();
const scrollAcpLog = vi.fn();
const onNewConversation = vi.fn();
const handleEvent = createAgentChatEventHandler({
getCurrentAgentId: () => current,
onNewConversation,
scrollToBottom,
scrollAcpLog,
scrollSubLogs: vi.fn(),
pushChunk: (st, text) => { st.streamContent += text; }, // 跳过打字机定时器,测同步内容
});
const stA = makeState();
handleEvent(stA, 'agent-A', 'session', { conversationId: 'conv-A' });
handleEvent(stA, 'agent-A', 'agent_output', { message: '你好' });
expect(scrollToBottom).toHaveBeenCalledTimes(1); // 只有 agent_output 会调 scrollToBottom
expect(onNewConversation).toHaveBeenCalledTimes(1); // session 走的是 onNewConversation
// 用户切到另一个 agent
current = 'agent-B';
scrollToBottom.mockClear();
scrollAcpLog.mockClear();
handleEvent(stA, 'agent-A', 'agent_output', { message: ',世界' });
handleEvent(stA, 'agent-A', 'tool_call', { tool: 'bash' });
// st 照常被写:内容没丢
expect(stA.streamContent).toBe('你好,世界');
expect(stA.acpLog).toHaveLength(1);
// 但不再是当前 agent,全局副作用一次都不该触发
expect(scrollToBottom).not.toHaveBeenCalled();
expect(scrollAcpLog).not.toHaveBeenCalled();
// 切回来之后,新事件恢复触发副作用,且历史内容完好
current = 'agent-A';
handleEvent(stA, 'agent-A', 'tool_call', { tool: 'grep' });
expect(scrollAcpLog).toHaveBeenCalledTimes(1);
expect(stA.acpLog).toHaveLength(2);
});
it('两个 agent 同时在跑,事件不会互相串台到对方的 st 里', () => {
let current = 'agent-A';
const handleEvent = createAgentChatEventHandler({
getCurrentAgentId: () => current,
onNewConversation: vi.fn(),
scrollToBottom: vi.fn(),
scrollAcpLog: vi.fn(),
scrollSubLogs: vi.fn(),
pushChunk: (st, text) => { st.streamContent += text; },
});
const stA = makeState();
const stB = makeState();
// 交替喂 A、B 两条流的事件,模拟后台并发
handleEvent(stA, 'agent-A', 'agent_output', { message: 'A1' });
handleEvent(stB, 'agent-B', 'agent_output', { message: 'B1' });
current = 'agent-B'; // 用户切到 B
handleEvent(stA, 'agent-A', 'agent_output', { message: 'A2' });
handleEvent(stB, 'agent-B', 'agent_output', { message: 'B2' });
expect(stA.streamContent).toBe('A1A2');
expect(stB.streamContent).toBe('B1B2');
});
});
describe('agentChatEvents · 排队等待提示(429 排队等待改造)', () => {
const handleEvent = createAgentChatEventHandler({
getCurrentAgentId: () => 'agent-A',
onNewConversation: vi.fn(),
scrollToBottom: vi.fn(),
scrollAcpLog: vi.fn(),
scrollSubLogs: vi.fn(),
});
it('queued 事件把思考文案换成带等待预算的排队提示,而不是通用的"思考中"', () => {
const st = makeState();
handleEvent(st, 'agent-A', 'queued', { activeConvId: 'conv-busy', waitBudgetMs: 60000 });
expect(st.thinkingText).toContain('排队等待中');
expect(st.thinkingText).toContain('60');
});
it('queue_timeout 事件落一条可见的助手消息,而不是像原来 429 那样静默无回复', () => {
const st = makeState();
handleEvent(st, 'agent-A', 'queued', { activeConvId: 'conv-busy', waitBudgetMs: 500 });
handleEvent(st, 'agent-A', 'queue_timeout', { error: 'Agent 正在执行另一个会话 (conv-busy),请稍后重试' });
expect(st.messages).toHaveLength(1);
expect(st.messages[0].role).toBe('assistant');
expect(st.messages[0].content).toContain('conv-busy');
expect(st.thinkingText).toBe(''); // 收尾清空,不留一个卡住的"排队等待中"
});
});
describe('agentChatEvents · 打字机渲染缓冲', () => {
it('文本按固定节奏匀速吐出,而不是一次性整段落地', () => {
vi.useFakeTimers();
try {
const st = makeState();
const pushChunk = createTypewriterQueue({ tickMs: 10, charsPerTick: 2 });
pushChunk(st, 'abcdef');
expect(st.streamContent).toBe(''); // 入队即刻不落地,等定时器吐
vi.advanceTimersByTime(10);
expect(st.streamContent).toBe('ab');
vi.advanceTimersByTime(10);
expect(st.streamContent).toBe('abcd');
vi.advanceTimersByTime(10);
expect(st.streamContent).toBe('abcdef');
vi.advanceTimersByTime(10); // 队列空了,定时器该自己停
expect(st._typeTimer).toBeNull();
} finally {
vi.useRealTimers();
}
});
it('flushTypewriter 立刻吐光剩余队列(打断/结束时用)', () => {
vi.useFakeTimers();
try {
const st = makeState();
const pushChunk = createTypewriterQueue({ tickMs: 10, charsPerTick: 1 });
pushChunk(st, 'hello');
vi.advanceTimersByTime(10); // 只吐了 1 个字符
expect(st.streamContent).toBe('h');
flushTypewriter(st);
expect(st.streamContent).toBe('hello');
expect(st._typeTimer).toBeNull();
expect(st._typeQueue).toBe('');
} finally {
vi.useRealTimers();
}
});
});