@steerable/agent-shell-web 0.6.16 → 0.6.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +9 -11
- package/src/brand.test.ts +22 -0
- package/src/components/AgentSidebar.test.tsx +791 -0
- package/src/components/AgentSidebar.tsx +5 -2
- package/src/components/ChatHeader.test.tsx +230 -0
- package/src/components/LocalLlmSettingsModal.test.tsx +372 -0
- package/src/components/chat/ApprovalModal.test.tsx +83 -0
- package/src/components/chat/AskUserModalHost.test.tsx +176 -0
- package/src/components/chat/AskUserQuestionMenu.test.tsx +190 -0
- package/src/components/chat/AssistantMessage.test.tsx +68 -0
- package/src/components/chat/ChatInput.test.tsx +609 -0
- package/src/components/chat/ExecPolicyPicker.test.tsx +26 -0
- package/src/components/chat/Markdown.test.tsx +170 -0
- package/src/components/chat/ModelPicker.test.tsx +149 -0
- package/src/components/chat/SessionTreeModal.test.tsx +161 -0
- package/src/components/chat/TaskOutcomeCards.test.tsx +97 -0
- package/src/components/chat/TaskPanelModal.test.tsx +198 -0
- package/src/components/chat/TaskProcessPanel.test.tsx +111 -0
- package/src/components/chat/TurnProcessGroup.test.tsx +161 -0
- package/src/components/chat/elapsed.test.ts +42 -0
- package/src/components/chat/executed-actions-model.test.ts +134 -0
- package/src/components/chat/orchestration-children-model.test.ts +115 -0
- package/src/components/chat/turn-timeline.test.ts +97 -0
- package/src/components/chat/useChatTasks.test.ts +136 -0
- package/src/components/settings/AgentsSettingsPanel.test.tsx +229 -0
- package/src/components/settings/InsightsSettingsPanel.test.tsx +132 -0
- package/src/components/settings/LlmSettingsPanel.test.tsx +461 -0
- package/src/components/settings/ModelIdCombobox.test.tsx +88 -0
- package/src/components/settings/SecuritySettingsPanel.test.tsx +122 -0
- package/src/components/settings/SkillsSettingsPanel.test.tsx +62 -0
- package/src/components/settings/WebSearchSettingsPanel.test.tsx +90 -0
- package/src/components/settings/compat-flags-model.test.ts +71 -0
- package/src/components/settings/llm-vendors.test.ts +171 -0
- package/src/components/settings/model-capabilities.test.ts +88 -0
- package/src/components/settings/preset-choice-model.test.ts +96 -0
- package/src/hooks/useChatsAndAgents.test.ts +165 -0
- package/src/lib/attachments.test.ts +127 -0
- package/src/lib/chat-title.test.ts +53 -0
- package/src/lib/chat-transport.test.ts +277 -0
- package/src/lib/date-groups.test.ts +62 -0
- package/src/lib/demo-flag.test.ts +17 -0
- package/src/lib/electron-bridge.test.ts +54 -0
- package/src/lib/exec-policy.test.ts +71 -0
- package/src/lib/http-bridge.test.ts +246 -0
- package/src/lib/insights.test.ts +56 -0
- package/src/lib/local-api.test.ts +353 -0
- package/src/lib/pending-first-message.test.ts +17 -0
- package/src/lib/slash-sources.test.ts +161 -0
- package/src/pages/AgentPage.test.tsx +627 -0
- package/src/pages/SettingsPage.test.tsx +102 -0
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentPage — 对话主页面(落地页 /agent 与会话页 /agent/:chatId):
|
|
3
|
+
* - 落地页:输入即建会话并跳转,首条消息经 pending-first-message 接力自动发出;
|
|
4
|
+
* 创建失败展示错误;浏览器预览模式禁用输入并提示;
|
|
5
|
+
* - 水合层:加载态 → 历史消息按时间正序渲染(后端返回 DESC,前端翻正);
|
|
6
|
+
* 失败时横幅提示、输入框仍可用;预览模式跳过水合;
|
|
7
|
+
* - 发送流程:真实 useChatStream + mock transport——用户消息与流式回复都渲染,
|
|
8
|
+
* 流式中停止按钮经 transport.cancelActive 真正取消后端回合,错误落进消息;
|
|
9
|
+
* - W7-1 中断恢复卡(继续走 resume 通道 / 忽略仅本次挂载隐藏);
|
|
10
|
+
* - plan 模式回合结束后的「开始执行计划」操作条;
|
|
11
|
+
* - 切走再切回时用 live-stream 快照叠出远端运行中的回合。
|
|
12
|
+
* 子组件自身的交互(ChatInput 排队、ChatHeader 分支菜单、ModelPicker 目录)
|
|
13
|
+
* 由各自的测试覆盖,这里只验页面级接线。
|
|
14
|
+
*/
|
|
15
|
+
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
16
|
+
import { MemoryRouter, Outlet, Route, Routes, useLocation } from 'react-router-dom';
|
|
17
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
18
|
+
import type { SSEEvent } from '@steerable/agent-protocol';
|
|
19
|
+
import type { AgentOutletContext } from '@/layouts/AgentLayout';
|
|
20
|
+
import type { LocalChat, LocalChatAgent } from '@/lib/local-api';
|
|
21
|
+
|
|
22
|
+
const electronState = { active: true };
|
|
23
|
+
const bridgeRequest = vi.fn();
|
|
24
|
+
const captureScreenshot = vi.fn();
|
|
25
|
+
const streamMock = vi.fn();
|
|
26
|
+
const steerMock = vi.fn();
|
|
27
|
+
const cancelActiveMock = vi.fn();
|
|
28
|
+
const regenerateChatMessage = vi.fn();
|
|
29
|
+
const getChatLiveStream = vi.fn();
|
|
30
|
+
const listProjects = vi.fn();
|
|
31
|
+
const listChatTasks = vi.fn();
|
|
32
|
+
const getLlmModels = vi.fn();
|
|
33
|
+
const getLlmSettings = vi.fn();
|
|
34
|
+
const trackBehavior = vi.fn();
|
|
35
|
+
|
|
36
|
+
vi.mock('@/lib/electron-bridge', () => ({
|
|
37
|
+
isElectron: () => electronState.active,
|
|
38
|
+
getElectronBridge: () =>
|
|
39
|
+
electronState.active
|
|
40
|
+
? {
|
|
41
|
+
runtime: 'local',
|
|
42
|
+
platform: 'darwin',
|
|
43
|
+
local: { captureScreenshot: (rect?: unknown) => captureScreenshot(rect) },
|
|
44
|
+
localBackend: {
|
|
45
|
+
request: (input: { method: string; path: string; body?: unknown }) =>
|
|
46
|
+
bridgeRequest(input),
|
|
47
|
+
startStream: vi.fn(async () => null),
|
|
48
|
+
cancelStream: vi.fn(),
|
|
49
|
+
steerChat: (_chatId: string, content: string) => steerMock(content),
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
: null,
|
|
53
|
+
}));
|
|
54
|
+
|
|
55
|
+
vi.mock('@/lib/local-api', () => ({
|
|
56
|
+
getChatLiveStream: (chatId: string) => getChatLiveStream(chatId),
|
|
57
|
+
listProjects: () => listProjects(),
|
|
58
|
+
listChatTasks: (chatId: string) => listChatTasks(chatId),
|
|
59
|
+
getLlmModels: (draft?: unknown) => getLlmModels(draft),
|
|
60
|
+
getLlmSettings: () => getLlmSettings(),
|
|
61
|
+
setLlmSettings: vi.fn(async (s: unknown) => s),
|
|
62
|
+
getCompatFlags: vi.fn(async () => ({ flags: [] })),
|
|
63
|
+
getProviderPresets: vi.fn(async () => ({ presets: [] })),
|
|
64
|
+
getCatalogProviders: vi.fn(async () => ({ providers: [] })),
|
|
65
|
+
resolveProviderPreset: vi.fn(async () => ({ preset: null })),
|
|
66
|
+
getChatProjectContext: vi.fn(async () => ({ project: null, ruleFileCount: 0 })),
|
|
67
|
+
setProjectTrusted: vi.fn(async () => ({ success: true })),
|
|
68
|
+
updateChatProject: vi.fn(async () => ({ id: 'chat-1', projectId: null })),
|
|
69
|
+
updateProject: vi.fn(async () => ({ success: true })),
|
|
70
|
+
getChatBranches: vi.fn(async () => ({ activeRecordId: 'r1', lineage: [], children: [] })),
|
|
71
|
+
activateChatBranch: vi.fn(async () => ({ activeRecordId: 'r1', messageCount: 0 })),
|
|
72
|
+
getChatBranchTree: vi.fn(async () => ({
|
|
73
|
+
activeRecordId: 'r1',
|
|
74
|
+
tree: null,
|
|
75
|
+
nodeCount: 0,
|
|
76
|
+
truncated: false,
|
|
77
|
+
})),
|
|
78
|
+
mergeTaskWorktree: vi.fn(),
|
|
79
|
+
discardTaskWorktree: vi.fn(),
|
|
80
|
+
}));
|
|
81
|
+
|
|
82
|
+
vi.mock('@/lib/chat-transport', () => ({
|
|
83
|
+
createElectronChatTransport: () => ({
|
|
84
|
+
stream: (
|
|
85
|
+
input: { content: string; metadata?: Record<string, unknown> },
|
|
86
|
+
onEvent: (event: SSEEvent) => void,
|
|
87
|
+
) => streamMock(input, onEvent),
|
|
88
|
+
steer: (content: string) => steerMock(content),
|
|
89
|
+
cancelActive: () => cancelActiveMock(),
|
|
90
|
+
}),
|
|
91
|
+
regenerateChatMessage: (chatId: string, messageId: string) =>
|
|
92
|
+
regenerateChatMessage(chatId, messageId),
|
|
93
|
+
}));
|
|
94
|
+
|
|
95
|
+
vi.mock('@/lib/insights', () => ({
|
|
96
|
+
trackBehavior: (name: string, props?: Record<string, unknown>) => trackBehavior(name, props),
|
|
97
|
+
}));
|
|
98
|
+
|
|
99
|
+
const { AgentPage } = await import('./AgentPage');
|
|
100
|
+
|
|
101
|
+
const AGENT: LocalChatAgent = {
|
|
102
|
+
id: 'local-assistant',
|
|
103
|
+
slug: 'local-assistant',
|
|
104
|
+
name: '本地助手',
|
|
105
|
+
icon: null,
|
|
106
|
+
color: '#4f46e5',
|
|
107
|
+
description: null,
|
|
108
|
+
rolePrompt: null,
|
|
109
|
+
isBuiltin: true,
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const CHAT: LocalChat = {
|
|
113
|
+
id: 'chat-1',
|
|
114
|
+
projectId: null,
|
|
115
|
+
userId: 'local',
|
|
116
|
+
title: '示例对话',
|
|
117
|
+
agentId: AGENT.id,
|
|
118
|
+
createdAt: '2026-09-01T08:00:00.000Z',
|
|
119
|
+
updatedAt: '2026-09-01T08:05:00.000Z',
|
|
120
|
+
isPinned: false,
|
|
121
|
+
systemPrompt: null,
|
|
122
|
+
pinnedRefs: null,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
function makeCtx(overrides: Partial<AgentOutletContext> = {}): AgentOutletContext {
|
|
126
|
+
return {
|
|
127
|
+
chats: [CHAT],
|
|
128
|
+
agents: [AGENT],
|
|
129
|
+
isLoading: false,
|
|
130
|
+
error: null,
|
|
131
|
+
selectedAgentId: AGENT.id,
|
|
132
|
+
setSelectedAgentId: vi.fn(),
|
|
133
|
+
refreshChats: vi.fn(async () => {}),
|
|
134
|
+
refreshAgents: vi.fn(async () => {}),
|
|
135
|
+
createChat: vi.fn(async () => 'chat-new'),
|
|
136
|
+
deleteChat: vi.fn(async () => true),
|
|
137
|
+
patchChatTitle: vi.fn(),
|
|
138
|
+
isLoadingMoreChats: false,
|
|
139
|
+
hasMoreChats: false,
|
|
140
|
+
loadMoreChats: vi.fn(async () => {}),
|
|
141
|
+
registerChatMessageSender: vi.fn(),
|
|
142
|
+
sendChatMessage: vi.fn(),
|
|
143
|
+
inspectTask: vi.fn(),
|
|
144
|
+
...overrides,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function LocationProbe() {
|
|
149
|
+
const loc = useLocation();
|
|
150
|
+
return <div data-testid="loc">{loc.pathname}{loc.search}</div>;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function renderPage(entry: string, ctx: AgentOutletContext) {
|
|
154
|
+
return render(
|
|
155
|
+
<MemoryRouter initialEntries={[entry]}>
|
|
156
|
+
<Routes>
|
|
157
|
+
<Route
|
|
158
|
+
element={
|
|
159
|
+
<>
|
|
160
|
+
<Outlet context={ctx} />
|
|
161
|
+
<LocationProbe />
|
|
162
|
+
</>
|
|
163
|
+
}
|
|
164
|
+
>
|
|
165
|
+
<Route path="/agent" element={<AgentPage />} />
|
|
166
|
+
<Route path="/agent/:chatId" element={<AgentPage />} />
|
|
167
|
+
</Route>
|
|
168
|
+
</Routes>
|
|
169
|
+
</MemoryRouter>,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** 历史消息水合的默认应答;各用例可按路径覆盖。 */
|
|
174
|
+
function defaultBridgeRequest(input: { method: string; path: string }): Promise<unknown> {
|
|
175
|
+
if (input.path.includes('/messages')) {
|
|
176
|
+
return Promise.resolve({ messages: [], interrupted: false });
|
|
177
|
+
}
|
|
178
|
+
if (input.path.endsWith('/chat-agents/skills')) return Promise.resolve({ skills: [] });
|
|
179
|
+
if (input.path.endsWith('/chat-agents/mcp-tools')) return Promise.resolve({ mcpTools: [] });
|
|
180
|
+
return Promise.resolve({});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* 模拟真实 transport 的事件序列(见 lib/chat-transport.ts 的 LocalBackendSseAdapter):
|
|
185
|
+
* 每个 content 增量都会同步合成一条 turn_timeline 事件,助手气泡的正文靠它渲染。
|
|
186
|
+
*/
|
|
187
|
+
function emitTextReply(onEvent: (event: SSEEvent) => void, text: string) {
|
|
188
|
+
onEvent({ type: 'content', content: text });
|
|
189
|
+
onEvent({
|
|
190
|
+
type: 'agent',
|
|
191
|
+
event: 'turn_timeline',
|
|
192
|
+
payload: { blocks: [{ type: 'text', content: text }] },
|
|
193
|
+
});
|
|
194
|
+
onEvent({ type: 'done' });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* 往输入框键入文本并等受控值同步完成(发送按钮可用即同步完成)。
|
|
199
|
+
* 必须先 focus:ChatInput 的布局副作用只在编辑器聚焦时才跳过重写 DOM,
|
|
200
|
+
* 不聚焦时任何重渲染都会把键入内容清掉(真实用户打字时编辑器必然聚焦)。
|
|
201
|
+
*
|
|
202
|
+
* 全量并行跑时,水合/skills/直播快照等异步应答落地触发的重渲染可能恰好
|
|
203
|
+
* 打在 fireEvent.input 与 setTimeout(0) 同步之间,把 textContent 重置回
|
|
204
|
+
* 受控空值——键入就此丢失。真实用户遇到会重打,测试也按重试建模:
|
|
205
|
+
* 最多 3 次,消除这类时序抖动。
|
|
206
|
+
*/
|
|
207
|
+
async function typeComposer(text: string) {
|
|
208
|
+
const editor = screen.getByRole('textbox') as HTMLElement;
|
|
209
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
210
|
+
editor.focus();
|
|
211
|
+
editor.textContent = text;
|
|
212
|
+
fireEvent.input(editor);
|
|
213
|
+
// 编辑器同步经 setTimeout(0) 推受控值;全量并行跑时定时器调度较慢,给足轮询预算。
|
|
214
|
+
const synced = await waitFor(
|
|
215
|
+
() => {
|
|
216
|
+
const send = screen.getByTestId('chat-send') as HTMLButtonElement;
|
|
217
|
+
expect(send.disabled).toBe(false);
|
|
218
|
+
},
|
|
219
|
+
{ timeout: 2000 },
|
|
220
|
+
)
|
|
221
|
+
.then(() => true)
|
|
222
|
+
.catch(() => false);
|
|
223
|
+
if (synced) return;
|
|
224
|
+
}
|
|
225
|
+
// 三次都被重渲染清掉:按最终状态断言失败,把 DOM 打出来便于诊断。
|
|
226
|
+
const send = screen.getByTestId('chat-send') as HTMLButtonElement;
|
|
227
|
+
expect(send.disabled).toBe(false);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** 预览模式(输入禁用)下键入:发送按钮永不放行,只等一个事件循环让同步落地。 */
|
|
231
|
+
async function typeComposerDisabled(text: string) {
|
|
232
|
+
const editor = screen.getByRole('textbox') as HTMLElement;
|
|
233
|
+
editor.focus();
|
|
234
|
+
editor.textContent = text;
|
|
235
|
+
fireEvent.input(editor);
|
|
236
|
+
await act(async () => {
|
|
237
|
+
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function pressEnter() {
|
|
242
|
+
fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Enter' });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
beforeEach(() => {
|
|
246
|
+
vi.clearAllMocks();
|
|
247
|
+
localStorage.clear();
|
|
248
|
+
electronState.active = true;
|
|
249
|
+
bridgeRequest.mockImplementation(defaultBridgeRequest);
|
|
250
|
+
getChatLiveStream.mockResolvedValue({ active: false });
|
|
251
|
+
listProjects.mockResolvedValue({ projects: [] });
|
|
252
|
+
listChatTasks.mockResolvedValue({ tasks: [] });
|
|
253
|
+
getLlmModels.mockResolvedValue({ models: [], catalogStatus: 'offline' });
|
|
254
|
+
getLlmSettings.mockResolvedValue({ provider: 'openai-compat', model: 'acme-chat' });
|
|
255
|
+
streamMock.mockImplementation(
|
|
256
|
+
async (_input: unknown, onEvent: (event: SSEEvent) => void) => {
|
|
257
|
+
emitTextReply(onEvent, '默认回复');
|
|
258
|
+
},
|
|
259
|
+
);
|
|
260
|
+
steerMock.mockResolvedValue(true);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
afterEach(cleanup);
|
|
264
|
+
|
|
265
|
+
describe('AgentPage 落地页(EmptyChatGate)', () => {
|
|
266
|
+
it('无 chatId 时渲染落地页:品牌标题、副标题与输入框', async () => {
|
|
267
|
+
renderPage('/agent', makeCtx());
|
|
268
|
+
await screen.findByTestId('empty-chat-home');
|
|
269
|
+
expect(screen.getByText('Steerable Shell')).toBeTruthy();
|
|
270
|
+
expect(screen.getByText('输入消息,直接开始一段新对话。')).toBeTruthy();
|
|
271
|
+
expect(screen.getByRole('textbox')).toBeTruthy();
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('输入消息:创建会话、暂存首条消息并跳转,新页面自动接力发出', async () => {
|
|
275
|
+
const ctx = makeCtx();
|
|
276
|
+
renderPage('/agent', ctx);
|
|
277
|
+
await screen.findByTestId('empty-chat-home');
|
|
278
|
+
await typeComposer('你好,帮我看看日志');
|
|
279
|
+
pressEnter();
|
|
280
|
+
|
|
281
|
+
await waitFor(() =>
|
|
282
|
+
expect(ctx.createChat).toHaveBeenCalledWith(expect.objectContaining({ agentId: AGENT.id })),
|
|
283
|
+
);
|
|
284
|
+
await waitFor(() => expect(screen.getByTestId('loc').textContent).toBe('/agent/chat-new'));
|
|
285
|
+
expect(trackBehavior).toHaveBeenCalledWith(
|
|
286
|
+
'composer_send',
|
|
287
|
+
expect.objectContaining({ home: true, mode: 'agent' }),
|
|
288
|
+
);
|
|
289
|
+
// 接力:新会话视图挂载后自动把暂存的首条消息发出去。
|
|
290
|
+
await waitFor(() =>
|
|
291
|
+
expect(streamMock).toHaveBeenCalledWith(
|
|
292
|
+
expect.objectContaining({ content: '你好,帮我看看日志' }),
|
|
293
|
+
expect.any(Function),
|
|
294
|
+
),
|
|
295
|
+
);
|
|
296
|
+
expect(await screen.findByText('默认回复')).toBeTruthy();
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('空输入不创建会话', async () => {
|
|
300
|
+
const ctx = makeCtx();
|
|
301
|
+
renderPage('/agent', ctx);
|
|
302
|
+
await screen.findByTestId('empty-chat-home');
|
|
303
|
+
pressEnter();
|
|
304
|
+
expect(ctx.createChat).not.toHaveBeenCalled();
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('创建会话失败时展示错误并恢复可交互', async () => {
|
|
308
|
+
const ctx = makeCtx({
|
|
309
|
+
createChat: vi.fn(async () => {
|
|
310
|
+
throw new Error('磁盘已满');
|
|
311
|
+
}),
|
|
312
|
+
});
|
|
313
|
+
renderPage('/agent', ctx);
|
|
314
|
+
await screen.findByTestId('empty-chat-home');
|
|
315
|
+
await typeComposer('hello');
|
|
316
|
+
pressEnter();
|
|
317
|
+
expect((await screen.findByRole('alert')).textContent).toContain('磁盘已满');
|
|
318
|
+
expect(screen.queryByText('正在创建对话…')).toBeNull();
|
|
319
|
+
// 未跳转
|
|
320
|
+
expect(screen.getByTestId('loc').textContent).toBe('/agent');
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it('创建会话返回空 id 时提示重试', async () => {
|
|
324
|
+
const ctx = makeCtx({ createChat: vi.fn(async () => null) });
|
|
325
|
+
renderPage('/agent', ctx);
|
|
326
|
+
await screen.findByTestId('empty-chat-home');
|
|
327
|
+
await typeComposer('hello');
|
|
328
|
+
pressEnter();
|
|
329
|
+
expect((await screen.findByRole('alert')).textContent).toContain('创建对话失败,请重试');
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('带 ?projectId= 进入时创建会话携带项目', async () => {
|
|
333
|
+
const ctx = makeCtx();
|
|
334
|
+
renderPage('/agent?projectId=proj-9', ctx);
|
|
335
|
+
await screen.findByTestId('empty-chat-home');
|
|
336
|
+
await typeComposer('看下构建');
|
|
337
|
+
pressEnter();
|
|
338
|
+
await waitFor(() =>
|
|
339
|
+
expect(ctx.createChat).toHaveBeenCalledWith(expect.objectContaining({ projectId: 'proj-9' })),
|
|
340
|
+
);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it('浏览器预览模式提示无 IPC 桥接,输入不创建会话', async () => {
|
|
344
|
+
electronState.active = false;
|
|
345
|
+
const ctx = makeCtx();
|
|
346
|
+
renderPage('/agent', ctx);
|
|
347
|
+
await screen.findByTestId('empty-chat-home');
|
|
348
|
+
expect(screen.getByText(/浏览器预览模式/)).toBeTruthy();
|
|
349
|
+
await typeComposerDisabled('hello');
|
|
350
|
+
pressEnter();
|
|
351
|
+
expect(ctx.createChat).not.toHaveBeenCalled();
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
describe('AgentPage 水合层(AgentChatLoader)', () => {
|
|
356
|
+
it('水合期间显示加载态,完成后按时间正序渲染历史消息', async () => {
|
|
357
|
+
let resolveMessages: (value: unknown) => void = () => {};
|
|
358
|
+
bridgeRequest.mockImplementation((input: { method: string; path: string }) => {
|
|
359
|
+
if (input.path.includes('/messages')) {
|
|
360
|
+
return new Promise((resolve) => {
|
|
361
|
+
resolveMessages = resolve;
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
return defaultBridgeRequest(input);
|
|
365
|
+
});
|
|
366
|
+
renderPage('/agent/chat-1', makeCtx());
|
|
367
|
+
expect(await screen.findByText('加载对话历史…')).toBeTruthy();
|
|
368
|
+
|
|
369
|
+
// 后端按 createdAt DESC 返回(最新在前),页面应翻正为 ASC。
|
|
370
|
+
resolveMessages({
|
|
371
|
+
messages: [
|
|
372
|
+
{
|
|
373
|
+
id: 'm2',
|
|
374
|
+
chatId: 'chat-1',
|
|
375
|
+
role: 'assistant',
|
|
376
|
+
content: '助手回复',
|
|
377
|
+
createdAt: '2026-09-01T08:01:00.000Z',
|
|
378
|
+
},
|
|
379
|
+
{
|
|
380
|
+
id: 'm1',
|
|
381
|
+
chatId: 'chat-1',
|
|
382
|
+
role: 'user',
|
|
383
|
+
content: '用户提问',
|
|
384
|
+
createdAt: '2026-09-01T08:00:00.000Z',
|
|
385
|
+
},
|
|
386
|
+
],
|
|
387
|
+
interrupted: false,
|
|
388
|
+
});
|
|
389
|
+
const userBubble = await screen.findByText('用户提问');
|
|
390
|
+
const assistantBubble = await screen.findByText('助手回复');
|
|
391
|
+
expect(
|
|
392
|
+
userBubble.compareDocumentPosition(assistantBubble) & Node.DOCUMENT_POSITION_FOLLOWING,
|
|
393
|
+
).toBeTruthy();
|
|
394
|
+
// 水合请求打在会话消息端点上
|
|
395
|
+
expect(
|
|
396
|
+
bridgeRequest.mock.calls.some(([input]) =>
|
|
397
|
+
String(input.path).includes('/api/v2/chats/chat-1/messages'),
|
|
398
|
+
),
|
|
399
|
+
).toBe(true);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it('历史消息加载失败时显示错误横幅,输入框仍可用', async () => {
|
|
403
|
+
bridgeRequest.mockImplementation((input: { method: string; path: string }) => {
|
|
404
|
+
if (input.path.includes('/messages')) return Promise.reject(new Error('数据库锁定'));
|
|
405
|
+
return defaultBridgeRequest(input);
|
|
406
|
+
});
|
|
407
|
+
renderPage('/agent/chat-1', makeCtx());
|
|
408
|
+
expect((await screen.findByText(/加载对话历史失败/)).textContent).toContain('数据库锁定');
|
|
409
|
+
expect(screen.getByRole('textbox')).toBeTruthy();
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it('浏览器预览模式跳过水合直接渲染输入框', async () => {
|
|
413
|
+
electronState.active = false;
|
|
414
|
+
renderPage('/agent/chat-1', makeCtx());
|
|
415
|
+
expect(await screen.findByRole('textbox')).toBeTruthy();
|
|
416
|
+
expect(screen.queryByText('加载对话历史…')).toBeNull();
|
|
417
|
+
expect(bridgeRequest).not.toHaveBeenCalled();
|
|
418
|
+
});
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
describe('AgentPage 发送流程', () => {
|
|
422
|
+
it('发送消息:transport 收到内容,用户消息与流式回复都渲染', async () => {
|
|
423
|
+
streamMock.mockImplementation(
|
|
424
|
+
async (_input: unknown, onEvent: (event: SSEEvent) => void) => {
|
|
425
|
+
emitTextReply(onEvent, '这是回复');
|
|
426
|
+
},
|
|
427
|
+
);
|
|
428
|
+
renderPage('/agent/chat-1', makeCtx());
|
|
429
|
+
await screen.findByRole('textbox');
|
|
430
|
+
await typeComposer('你好');
|
|
431
|
+
pressEnter();
|
|
432
|
+
|
|
433
|
+
await waitFor(() =>
|
|
434
|
+
expect(streamMock).toHaveBeenCalledWith(
|
|
435
|
+
expect.objectContaining({ content: '你好' }),
|
|
436
|
+
expect.any(Function),
|
|
437
|
+
),
|
|
438
|
+
);
|
|
439
|
+
expect(await screen.findByText('这是回复')).toBeTruthy();
|
|
440
|
+
// 用户气泡落地(输入框已清空,剩下的文本节点即消息)
|
|
441
|
+
expect(screen.getByText('你好')).toBeTruthy();
|
|
442
|
+
expect(trackBehavior).toHaveBeenCalledWith(
|
|
443
|
+
'composer_send',
|
|
444
|
+
expect.objectContaining({ mode: 'agent', empty: false }),
|
|
445
|
+
);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it('流式中显示停止按钮,点击经 cancelActive 取消后端回合', async () => {
|
|
449
|
+
let finishStream: () => void = () => {};
|
|
450
|
+
streamMock.mockImplementation((_input: unknown, onEvent: (event: SSEEvent) => void) => {
|
|
451
|
+
onEvent({ type: 'content', content: '部分回复' });
|
|
452
|
+
onEvent({
|
|
453
|
+
type: 'agent',
|
|
454
|
+
event: 'turn_timeline',
|
|
455
|
+
payload: { blocks: [{ type: 'text', content: '部分回复' }] },
|
|
456
|
+
});
|
|
457
|
+
return new Promise<void>((resolve) => {
|
|
458
|
+
finishStream = resolve;
|
|
459
|
+
});
|
|
460
|
+
});
|
|
461
|
+
renderPage('/agent/chat-1', makeCtx());
|
|
462
|
+
await screen.findByRole('textbox');
|
|
463
|
+
await typeComposer('讲个故事');
|
|
464
|
+
pressEnter();
|
|
465
|
+
|
|
466
|
+
const stop = await screen.findByRole('button', { name: '停止生成' });
|
|
467
|
+
expect(await screen.findByText('部分回复')).toBeTruthy();
|
|
468
|
+
fireEvent.click(stop);
|
|
469
|
+
expect(cancelActiveMock).toHaveBeenCalled();
|
|
470
|
+
// 前端状态复位:停止按钮回到发送按钮
|
|
471
|
+
await screen.findByRole('button', { name: '发送消息' });
|
|
472
|
+
await act(async () => {
|
|
473
|
+
finishStream();
|
|
474
|
+
});
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
it('流式传输出错时错误落进助手消息', async () => {
|
|
478
|
+
streamMock.mockImplementation(async () => {
|
|
479
|
+
throw new Error('网关超时');
|
|
480
|
+
});
|
|
481
|
+
renderPage('/agent/chat-1', makeCtx());
|
|
482
|
+
await screen.findByRole('textbox');
|
|
483
|
+
await typeComposer('你好');
|
|
484
|
+
pressEnter();
|
|
485
|
+
// useChatStream 把错误打成 "[stream error] ..." 内容,AssistantMessage 的
|
|
486
|
+
// readTurnFailure 剥掉前缀后渲染成错误气泡「请求失败:…」。
|
|
487
|
+
expect((await screen.findByText(/请求失败/)).textContent).toContain('网关超时');
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it('挂载时向布局注册发送器,卸载时注销', async () => {
|
|
491
|
+
const ctx = makeCtx();
|
|
492
|
+
const { unmount } = renderPage('/agent/chat-1', ctx);
|
|
493
|
+
await screen.findByRole('textbox');
|
|
494
|
+
expect(ctx.registerChatMessageSender).toHaveBeenCalledWith(expect.any(Function));
|
|
495
|
+
unmount();
|
|
496
|
+
expect(ctx.registerChatMessageSender).toHaveBeenLastCalledWith(null);
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it('点击分享调用主进程截图', async () => {
|
|
500
|
+
captureScreenshot.mockResolvedValue({ success: true, width: 100, height: 80 });
|
|
501
|
+
bridgeRequest.mockImplementation((input: { method: string; path: string }) => {
|
|
502
|
+
if (input.path.includes('/messages')) {
|
|
503
|
+
return Promise.resolve({
|
|
504
|
+
messages: [
|
|
505
|
+
{
|
|
506
|
+
id: 'm1',
|
|
507
|
+
chatId: 'chat-1',
|
|
508
|
+
role: 'assistant',
|
|
509
|
+
content: '历史回复',
|
|
510
|
+
createdAt: '2026-09-01T08:00:00.000Z',
|
|
511
|
+
},
|
|
512
|
+
],
|
|
513
|
+
interrupted: false,
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
return defaultBridgeRequest(input);
|
|
517
|
+
});
|
|
518
|
+
renderPage('/agent/chat-1', makeCtx());
|
|
519
|
+
fireEvent.click(await screen.findByRole('button', { name: '分享对话截图' }));
|
|
520
|
+
await waitFor(() => expect(captureScreenshot).toHaveBeenCalled());
|
|
521
|
+
});
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
describe('AgentPage 中断恢复卡(W7-1)', () => {
|
|
525
|
+
function hydrateInterrupted() {
|
|
526
|
+
bridgeRequest.mockImplementation((input: { method: string; path: string }) => {
|
|
527
|
+
if (input.path.includes('/messages')) {
|
|
528
|
+
return Promise.resolve({
|
|
529
|
+
messages: [
|
|
530
|
+
{
|
|
531
|
+
id: 'm1',
|
|
532
|
+
chatId: 'chat-1',
|
|
533
|
+
role: 'user',
|
|
534
|
+
content: '之前的问题',
|
|
535
|
+
createdAt: '2026-09-01T08:00:00.000Z',
|
|
536
|
+
},
|
|
537
|
+
],
|
|
538
|
+
interrupted: true,
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
return defaultBridgeRequest(input);
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
it('上一轮中断时展示恢复卡,「继续上次回复」走 resume 通道', async () => {
|
|
546
|
+
hydrateInterrupted();
|
|
547
|
+
renderPage('/agent/chat-1', makeCtx());
|
|
548
|
+
fireEvent.click(await screen.findByRole('button', { name: '继续上次回复' }));
|
|
549
|
+
await waitFor(() =>
|
|
550
|
+
expect(streamMock).toHaveBeenCalledWith(
|
|
551
|
+
expect.objectContaining({
|
|
552
|
+
content: '',
|
|
553
|
+
metadata: expect.objectContaining({ resume: true }),
|
|
554
|
+
}),
|
|
555
|
+
expect.any(Function),
|
|
556
|
+
),
|
|
557
|
+
);
|
|
558
|
+
// resume 不追加用户消息:列表里仍只有「之前的问题」一条用户消息
|
|
559
|
+
expect(screen.getAllByText('之前的问题')).toHaveLength(1);
|
|
560
|
+
await waitFor(() =>
|
|
561
|
+
expect(screen.queryByRole('button', { name: '继续上次回复' })).toBeNull(),
|
|
562
|
+
);
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
it('「忽略」仅本次挂载隐藏中断卡,不发起 resume', async () => {
|
|
566
|
+
hydrateInterrupted();
|
|
567
|
+
renderPage('/agent/chat-1', makeCtx());
|
|
568
|
+
fireEvent.click(await screen.findByRole('button', { name: '忽略' }));
|
|
569
|
+
await waitFor(() =>
|
|
570
|
+
expect(screen.queryByRole('button', { name: '继续上次回复' })).toBeNull(),
|
|
571
|
+
);
|
|
572
|
+
expect(streamMock).not.toHaveBeenCalled();
|
|
573
|
+
});
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
describe('AgentPage plan 模式操作条', () => {
|
|
577
|
+
it('plan 回合结束后展示「开始执行计划」,点击切回 agent 并自动发执行指令', async () => {
|
|
578
|
+
renderPage('/agent/chat-1', makeCtx());
|
|
579
|
+
await screen.findByRole('textbox');
|
|
580
|
+
fireEvent.click(screen.getByTestId('mode-plan'));
|
|
581
|
+
await typeComposer('先出个计划');
|
|
582
|
+
pressEnter();
|
|
583
|
+
|
|
584
|
+
await waitFor(() =>
|
|
585
|
+
expect(streamMock).toHaveBeenCalledWith(
|
|
586
|
+
expect.objectContaining({ metadata: expect.objectContaining({ mode: 'plan' }) }),
|
|
587
|
+
expect.any(Function),
|
|
588
|
+
),
|
|
589
|
+
);
|
|
590
|
+
const execute = await screen.findByTestId('plan-execute');
|
|
591
|
+
fireEvent.click(execute);
|
|
592
|
+
await waitFor(() =>
|
|
593
|
+
expect(streamMock).toHaveBeenCalledWith(
|
|
594
|
+
expect.objectContaining({ content: '请按照上面的计划开始执行。' }),
|
|
595
|
+
expect.any(Function),
|
|
596
|
+
),
|
|
597
|
+
);
|
|
598
|
+
await waitFor(() => expect(screen.queryByTestId('plan-execute')).toBeNull());
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
it('「忽略」收起操作条且不再发送', async () => {
|
|
602
|
+
renderPage('/agent/chat-1', makeCtx());
|
|
603
|
+
await screen.findByRole('textbox');
|
|
604
|
+
fireEvent.click(screen.getByTestId('mode-plan'));
|
|
605
|
+
await typeComposer('先出个计划');
|
|
606
|
+
pressEnter();
|
|
607
|
+
await screen.findByTestId('plan-execute');
|
|
608
|
+
streamMock.mockClear();
|
|
609
|
+
fireEvent.click(screen.getByTestId('plan-dismiss'));
|
|
610
|
+
await waitFor(() => expect(screen.queryByTestId('plan-execute')).toBeNull());
|
|
611
|
+
expect(streamMock).not.toHaveBeenCalled();
|
|
612
|
+
});
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
describe('AgentPage 远端回合恢复', () => {
|
|
616
|
+
it('切回时远端回合仍在运行:叠加显示运行中的部分内容', async () => {
|
|
617
|
+
getChatLiveStream.mockResolvedValue({
|
|
618
|
+
active: true,
|
|
619
|
+
status: 'running',
|
|
620
|
+
content: '远端正在输出',
|
|
621
|
+
});
|
|
622
|
+
renderPage('/agent/chat-1', makeCtx());
|
|
623
|
+
expect(await screen.findByText('远端正在输出')).toBeTruthy();
|
|
624
|
+
// 远端运行中等同流式:输入区显示停止按钮
|
|
625
|
+
expect(await screen.findByRole('button', { name: '停止生成' })).toBeTruthy();
|
|
626
|
+
});
|
|
627
|
+
});
|