@steerable/agent-shell-web 0.6.17 → 0.6.19

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.
Files changed (56) hide show
  1. package/package.json +12 -19
  2. package/src/brand.test.ts +22 -0
  3. package/src/components/AgentSidebar.test.tsx +791 -0
  4. package/src/components/AgentSidebar.tsx +5 -2
  5. package/src/components/ChatHeader.test.tsx +230 -0
  6. package/src/components/LocalLlmSettingsModal.test.tsx +372 -0
  7. package/src/components/chat/ApprovalModal.test.tsx +83 -0
  8. package/src/components/chat/AskUserModalHost.test.tsx +176 -0
  9. package/src/components/chat/AskUserQuestionMenu.test.tsx +190 -0
  10. package/src/components/chat/AssistantMessage.test.tsx +68 -0
  11. package/src/components/chat/ChatInput.test.tsx +609 -0
  12. package/src/components/chat/ExecPolicyPicker.test.tsx +26 -0
  13. package/src/components/chat/Markdown.test.tsx +170 -0
  14. package/src/components/chat/ModelPicker.test.tsx +149 -0
  15. package/src/components/chat/SessionTreeModal.test.tsx +161 -0
  16. package/src/components/chat/TaskOutcomeCards.test.tsx +97 -0
  17. package/src/components/chat/TaskPanelModal.test.tsx +198 -0
  18. package/src/components/chat/TaskProcessPanel.test.tsx +111 -0
  19. package/src/components/chat/TurnProcessGroup.test.tsx +161 -0
  20. package/src/components/chat/elapsed.test.ts +42 -0
  21. package/src/components/chat/executed-actions-model.test.ts +134 -0
  22. package/src/components/chat/orchestration-children-model.test.ts +115 -0
  23. package/src/components/chat/turn-timeline.test.ts +97 -0
  24. package/src/components/chat/useChatTasks.test.ts +136 -0
  25. package/src/components/settings/AgentsSettingsPanel.test.tsx +229 -0
  26. package/src/components/settings/InsightsSettingsPanel.test.tsx +132 -0
  27. package/src/components/settings/LlmSettingsPanel.test.tsx +461 -0
  28. package/src/components/settings/ModelIdCombobox.test.tsx +88 -0
  29. package/src/components/settings/SecuritySettingsPanel.test.tsx +122 -0
  30. package/src/components/settings/SkillsSettingsPanel.test.tsx +62 -0
  31. package/src/components/settings/WebSearchSettingsPanel.test.tsx +90 -0
  32. package/src/components/settings/compat-flags-model.test.ts +71 -0
  33. package/src/components/settings/llm-vendors.test.ts +171 -0
  34. package/src/components/settings/model-capabilities.test.ts +88 -0
  35. package/src/components/settings/preset-choice-model.test.ts +96 -0
  36. package/src/hooks/useChatsAndAgents.test.ts +165 -0
  37. package/src/lib/attachments.test.ts +127 -0
  38. package/src/lib/chat-title.test.ts +53 -0
  39. package/src/lib/chat-transport.test.ts +282 -0
  40. package/src/lib/chat-transport.ts +1 -2
  41. package/src/lib/date-groups.test.ts +62 -0
  42. package/src/lib/demo-flag.test.ts +17 -0
  43. package/src/lib/electron-bridge.test.ts +54 -0
  44. package/src/lib/electron-bridge.ts +2 -2
  45. package/src/lib/exec-policy.test.ts +71 -0
  46. package/src/lib/http-bridge.test.ts +283 -0
  47. package/src/lib/http-bridge.ts +18 -4
  48. package/src/lib/insights.test.ts +56 -0
  49. package/src/lib/local-api.test.ts +353 -0
  50. package/src/lib/pending-first-message.test.ts +17 -0
  51. package/src/lib/slash-sources.test.ts +161 -0
  52. package/src/pages/AgentPage.test.tsx +627 -0
  53. package/src/pages/SettingsPage.test.tsx +102 -0
  54. package/vite.base.ts +94 -0
  55. package/vite.base.d.ts +0 -7
  56. package/vite.base.js +0 -83
@@ -0,0 +1,353 @@
1
+ /**
2
+ * local-api 传输契约:每个 helper 只是把调用翻译成
3
+ * `window.electron.localBackend.request({ method, path, body? })`,自身不含
4
+ * 业务逻辑。这里逐字锁定方法 / 路径 / 查询串 / body 的形状,以及:
5
+ * - 桥缺失时 fail-loud(调用方应先 isElectron() 判断降级);
6
+ * - 路径参数一律 encodeURIComponent(id 里的 `/`、空格不破坏路由);
7
+ * - request 拒绝时错误原样抛出(不包装、不吞);
8
+ * - 响应按原样透传(泛型只是编译期标注)。
9
+ * 桥走真实的 getElectronBridge() → window.electron 路径,不 mock 模块。
10
+ */
11
+ import { afterEach, describe, expect, it, vi } from 'vitest';
12
+ import * as api from './local-api';
13
+
14
+ type RequestInput = { method: string; path: string; body?: unknown };
15
+
16
+ /** 安装最小可用的 window.electron 桥,返回可断言的 request 替身。 */
17
+ function installBridge() {
18
+ const request = vi.fn<(input: RequestInput) => Promise<unknown>>();
19
+ (window as { electron?: unknown }).electron = { localBackend: { request } };
20
+ return request;
21
+ }
22
+
23
+ afterEach(() => {
24
+ delete (window as { electron?: unknown }).electron;
25
+ });
26
+
27
+ describe('local-api 桥接前置', () => {
28
+ it('桥缺失时 GET / POST helper 都拒绝并提示只能在桌面壳内使用', async () => {
29
+ await expect(api.listChats()).rejects.toThrow(/Electron bridge unavailable/);
30
+ await expect(api.createChat()).rejects.toThrow(/Electron bridge unavailable/);
31
+ });
32
+
33
+ it('request 拒绝时错误原样抛出,不包装', async () => {
34
+ const request = installBridge();
35
+ request.mockRejectedValue(new Error('主进程已退出'));
36
+ await expect(api.listProjects()).rejects.toThrow('主进程已退出');
37
+ });
38
+
39
+ it('响应按原样透传给调用方', async () => {
40
+ const request = installBridge();
41
+ const payload = {
42
+ chats: [{ id: 'chat-1' }],
43
+ pagination: { page: 1, limit: 50, total: 1, totalPages: 1, hasMore: false },
44
+ };
45
+ request.mockResolvedValue(payload);
46
+ await expect(api.listChats()).resolves.toEqual(payload);
47
+ });
48
+ });
49
+
50
+ describe('local-api 会话', () => {
51
+ it('listChats 缺省 page=1&limit=50,自定义分页透传', async () => {
52
+ const request = installBridge();
53
+ request.mockResolvedValue({});
54
+ await api.listChats();
55
+ expect(request).toHaveBeenLastCalledWith({ method: 'GET', path: '/api/v2/chats?page=1&limit=50' });
56
+ await api.listChats({ page: 3, limit: 10 });
57
+ expect(request).toHaveBeenLastCalledWith({ method: 'GET', path: '/api/v2/chats?page=3&limit=10' });
58
+ });
59
+
60
+ it('createChat 缺省发空对象 body,带参时透传 agentId / projectId', async () => {
61
+ const request = installBridge();
62
+ request.mockResolvedValue({});
63
+ await api.createChat();
64
+ expect(request).toHaveBeenLastCalledWith({ method: 'POST', path: '/api/v2/chats/new', body: {} });
65
+ await api.createChat({ agentId: 'agent-a', projectId: 'proj-1' });
66
+ expect(request).toHaveBeenLastCalledWith({
67
+ method: 'POST',
68
+ path: '/api/v2/chats/new',
69
+ body: { agentId: 'agent-a', projectId: 'proj-1' },
70
+ });
71
+ });
72
+
73
+ it('deleteChat 对 chatId 做 URL 编码', async () => {
74
+ const request = installBridge();
75
+ request.mockResolvedValue({});
76
+ await api.deleteChat('chat/1?x');
77
+ expect(request).toHaveBeenCalledWith({ method: 'DELETE', path: '/api/v2/chats/chat%2F1%3Fx' });
78
+ });
79
+
80
+ it('deleteChatIfEmpty 追加 onlyIfEmpty=1 查询串', async () => {
81
+ const request = installBridge();
82
+ request.mockResolvedValue({});
83
+ await api.deleteChatIfEmpty('chat-1');
84
+ expect(request).toHaveBeenCalledWith({
85
+ method: 'DELETE',
86
+ path: '/api/v2/chats/chat-1?onlyIfEmpty=1',
87
+ });
88
+ });
89
+
90
+ it('pruneEmptyChats 缺省把 exceptChatId 归一为 null,带参时透传', async () => {
91
+ const request = installBridge();
92
+ request.mockResolvedValue({ deletedChatIds: [] });
93
+ await api.pruneEmptyChats();
94
+ expect(request).toHaveBeenLastCalledWith({
95
+ method: 'POST',
96
+ path: '/api/v2/chats/prune-empty',
97
+ body: { exceptChatId: null },
98
+ });
99
+ await api.pruneEmptyChats('chat-9');
100
+ expect(request).toHaveBeenLastCalledWith({
101
+ method: 'POST',
102
+ path: '/api/v2/chats/prune-empty',
103
+ body: { exceptChatId: 'chat-9' },
104
+ });
105
+ });
106
+ });
107
+
108
+ describe('local-api 会话分支', () => {
109
+ it('getChatBranches / activateChatBranch / getChatBranchTree 的路径与 body', async () => {
110
+ const request = installBridge();
111
+ request.mockResolvedValue({});
112
+ await api.getChatBranches('chat-1');
113
+ expect(request).toHaveBeenLastCalledWith({ method: 'GET', path: '/api/v2/chats/chat-1/branches' });
114
+ await api.activateChatBranch('chat-1', 'rec-2');
115
+ expect(request).toHaveBeenLastCalledWith({
116
+ method: 'POST',
117
+ path: '/api/v2/chats/chat-1/branches/activate',
118
+ body: { recordId: 'rec-2' },
119
+ });
120
+ await api.getChatBranchTree('chat-1');
121
+ expect(request).toHaveBeenLastCalledWith({
122
+ method: 'GET',
123
+ path: '/api/v2/chats/chat-1/branches/tree',
124
+ });
125
+ });
126
+ });
127
+
128
+ describe('local-api 智能体', () => {
129
+ it('listChatAgents 缺省不含已归档,true 时追加 include_archived=true', async () => {
130
+ const request = installBridge();
131
+ request.mockResolvedValue({ agents: [], total: 0 });
132
+ await api.listChatAgents();
133
+ expect(request).toHaveBeenLastCalledWith({ method: 'GET', path: '/api/v2/chat-agents' });
134
+ await api.listChatAgents(true);
135
+ expect(request).toHaveBeenLastCalledWith({
136
+ method: 'GET',
137
+ path: '/api/v2/chat-agents?include_archived=true',
138
+ });
139
+ });
140
+
141
+ it('listChatAgentSkills / listChatAgentTools 走各自目录端点', async () => {
142
+ const request = installBridge();
143
+ request.mockResolvedValue({});
144
+ await api.listChatAgentSkills();
145
+ await api.listChatAgentTools();
146
+ expect(request).toHaveBeenNthCalledWith(1, { method: 'GET', path: '/api/v2/chat-agents/skills' });
147
+ expect(request).toHaveBeenNthCalledWith(2, { method: 'GET', path: '/api/v2/chat-agents/tools' });
148
+ });
149
+
150
+ it('createChatAgent 透传嵌套 body(技能 / 工具策略)', async () => {
151
+ const request = installBridge();
152
+ request.mockResolvedValue({});
153
+ const input = {
154
+ name: '调研员',
155
+ skillIds: ['skill-a'],
156
+ toolPolicy: { mode: 'allowlist' as const, tools: ['tool-a'] },
157
+ allowExternalSkills: false,
158
+ };
159
+ await api.createChatAgent(input);
160
+ expect(request).toHaveBeenCalledWith({ method: 'POST', path: '/api/v2/chat-agents', body: input });
161
+ });
162
+
163
+ it('updateChatAgent 用 PATCH 且编码 agentId,archiveChatAgent 走 DELETE', async () => {
164
+ const request = installBridge();
165
+ request.mockResolvedValue({});
166
+ await api.updateChatAgent('agent/1', { name: '新名字' });
167
+ expect(request).toHaveBeenLastCalledWith({
168
+ method: 'PATCH',
169
+ path: '/api/v2/chat-agents/agent%2F1',
170
+ body: { name: '新名字' },
171
+ });
172
+ await api.archiveChatAgent('agent-1');
173
+ expect(request).toHaveBeenLastCalledWith({
174
+ method: 'DELETE',
175
+ path: '/api/v2/chat-agents/agent-1',
176
+ });
177
+ });
178
+ });
179
+
180
+ describe('local-api 运行中回合', () => {
181
+ it('getChatLiveStream 拉快照,cancelChatTurn 以空 body POST 取消端点', async () => {
182
+ const request = installBridge();
183
+ request.mockResolvedValue({});
184
+ await api.getChatLiveStream('chat-1');
185
+ expect(request).toHaveBeenLastCalledWith({
186
+ method: 'GET',
187
+ path: '/api/v2/chats/chat-1/live-stream',
188
+ });
189
+ await api.cancelChatTurn('chat-1');
190
+ expect(request).toHaveBeenLastCalledWith({
191
+ method: 'POST',
192
+ path: '/api/v2/chats/chat-1/cancel',
193
+ body: {},
194
+ });
195
+ });
196
+ });
197
+
198
+ describe('local-api 项目', () => {
199
+ it('listProjects / createProject / updateProject / deleteProject 各走 REST 语义', async () => {
200
+ const request = installBridge();
201
+ request.mockResolvedValue({});
202
+ await api.listProjects();
203
+ expect(request).toHaveBeenLastCalledWith({ method: 'GET', path: '/api/v2/projects' });
204
+ await api.createProject({ name: '项目甲', folderPath: '/tmp/proj-a' });
205
+ expect(request).toHaveBeenLastCalledWith({
206
+ method: 'POST',
207
+ path: '/api/v2/projects',
208
+ body: { name: '项目甲', folderPath: '/tmp/proj-a' },
209
+ });
210
+ await api.updateProject('proj-1', { name: '项目乙' });
211
+ expect(request).toHaveBeenLastCalledWith({
212
+ method: 'PUT',
213
+ path: '/api/v2/projects/proj-1',
214
+ body: { name: '项目乙' },
215
+ });
216
+ await api.deleteProject('proj-1');
217
+ expect(request).toHaveBeenLastCalledWith({ method: 'DELETE', path: '/api/v2/projects/proj-1' });
218
+ });
219
+
220
+ it('getChatProjectContext 拉取会话的项目上下文', async () => {
221
+ const request = installBridge();
222
+ request.mockResolvedValue({ project: null });
223
+ await api.getChatProjectContext('chat-1');
224
+ expect(request).toHaveBeenCalledWith({
225
+ method: 'GET',
226
+ path: '/api/v2/chats/chat-1/project-context',
227
+ });
228
+ });
229
+
230
+ it('setProjectTrusted 以 PUT body 携带信任旗标', async () => {
231
+ const request = installBridge();
232
+ request.mockResolvedValue({});
233
+ await api.setProjectTrusted('proj-1', true);
234
+ expect(request).toHaveBeenCalledWith({
235
+ method: 'PUT',
236
+ path: '/api/v2/projects/proj-1/trust',
237
+ body: { trusted: true },
238
+ });
239
+ });
240
+
241
+ it('updateChatProject 走 chat settings PATCH,null 表示移出项目', async () => {
242
+ const request = installBridge();
243
+ request.mockResolvedValue({});
244
+ await api.updateChatProject('chat-1', 'proj-1');
245
+ expect(request).toHaveBeenLastCalledWith({
246
+ method: 'PATCH',
247
+ path: '/api/v2/chats/chat-1/settings',
248
+ body: { projectId: 'proj-1' },
249
+ });
250
+ await api.updateChatProject('chat-1', null);
251
+ expect(request).toHaveBeenLastCalledWith({
252
+ method: 'PATCH',
253
+ path: '/api/v2/chats/chat-1/settings',
254
+ body: { projectId: null },
255
+ });
256
+ });
257
+ });
258
+
259
+ describe('local-api 任务', () => {
260
+ it('listChatTasks / getTaskProcess / merge / discard 的路径形状', async () => {
261
+ const request = installBridge();
262
+ request.mockResolvedValue({});
263
+ await api.listChatTasks('chat-1');
264
+ expect(request).toHaveBeenLastCalledWith({ method: 'GET', path: '/api/v2/chats/chat-1/tasks' });
265
+ await api.getTaskProcess('task-1');
266
+ expect(request).toHaveBeenLastCalledWith({ method: 'GET', path: '/api/v2/tasks/task-1/process' });
267
+ await api.mergeTaskWorktree('task-1');
268
+ expect(request).toHaveBeenLastCalledWith({ method: 'POST', path: '/api/v2/tasks/task-1/merge' });
269
+ await api.discardTaskWorktree('task-1');
270
+ expect(request).toHaveBeenLastCalledWith({ method: 'POST', path: '/api/v2/tasks/task-1/discard' });
271
+ });
272
+ });
273
+
274
+ describe('local-api LLM 设置与目录', () => {
275
+ it('getCompatFlags / getProviderPresets / getCatalogProviders 各走只读端点', async () => {
276
+ const request = installBridge();
277
+ request.mockResolvedValue({});
278
+ await api.getCompatFlags();
279
+ expect(request).toHaveBeenLastCalledWith({ method: 'GET', path: '/api/v2/compat/flags' });
280
+ await api.getProviderPresets();
281
+ expect(request).toHaveBeenLastCalledWith({ method: 'GET', path: '/api/v2/llm/presets' });
282
+ await api.getCatalogProviders();
283
+ expect(request).toHaveBeenLastCalledWith({ method: 'GET', path: '/api/v2/llm/catalog' });
284
+ });
285
+
286
+ it('resolveProviderPreset 无参时路径以空查询串结尾,带参时做查询编码', async () => {
287
+ const request = installBridge();
288
+ request.mockResolvedValue({ preset: null });
289
+ // 无参时尾部多一个空 `?`(模板字符串无条件拼接)——锁定现状,见报告。
290
+ await api.resolveProviderPreset();
291
+ expect(request).toHaveBeenLastCalledWith({ method: 'GET', path: '/api/v2/llm/presets/resolve?' });
292
+ await api.resolveProviderPreset('https://llm.example.test/v1', 'model x');
293
+ expect(request).toHaveBeenLastCalledWith({
294
+ method: 'GET',
295
+ path: '/api/v2/llm/presets/resolve?baseUrl=https%3A%2F%2Fllm.example.test%2Fv1&model=model+x',
296
+ });
297
+ });
298
+
299
+ it('getLlmModels 无 draft / 空 draft 时不带查询串', async () => {
300
+ const request = installBridge();
301
+ request.mockResolvedValue({});
302
+ await api.getLlmModels();
303
+ expect(request).toHaveBeenLastCalledWith({ method: 'GET', path: '/api/v2/llm/models' });
304
+ await api.getLlmModels({});
305
+ expect(request).toHaveBeenLastCalledWith({ method: 'GET', path: '/api/v2/llm/models' });
306
+ });
307
+
308
+ it('getLlmModels 透传 draft 凭证与 refresh 旗标,refresh=false 不下发', async () => {
309
+ const request = installBridge();
310
+ request.mockResolvedValue({});
311
+ await api.getLlmModels({
312
+ baseUrl: 'https://llm.example.test/v1',
313
+ apiKey: 'sk-test',
314
+ provider: 'ollama',
315
+ refresh: true,
316
+ });
317
+ expect(request).toHaveBeenLastCalledWith({
318
+ method: 'GET',
319
+ path: '/api/v2/llm/models?baseUrl=https%3A%2F%2Fllm.example.test%2Fv1&apiKey=sk-test&provider=ollama&refresh=1',
320
+ });
321
+ await api.getLlmModels({ refresh: false });
322
+ expect(request).toHaveBeenLastCalledWith({ method: 'GET', path: '/api/v2/llm/models' });
323
+ });
324
+
325
+ it('getSidecarSandboxPosture 拉取沙箱态势', async () => {
326
+ const request = installBridge();
327
+ request.mockResolvedValue({ posture: null });
328
+ await api.getSidecarSandboxPosture();
329
+ expect(request).toHaveBeenCalledWith({ method: 'GET', path: '/api/v2/sidecar/sandbox-posture' });
330
+ });
331
+
332
+ it('getLlmSettings / setLlmSettings 读写同一端点', async () => {
333
+ const request = installBridge();
334
+ request.mockResolvedValue({});
335
+ await api.getLlmSettings();
336
+ expect(request).toHaveBeenLastCalledWith({ method: 'GET', path: '/api/v2/local-settings/llm' });
337
+ const settings = { provider: 'ollama' as const, model: 'test-model' };
338
+ await api.setLlmSettings(settings);
339
+ expect(request).toHaveBeenLastCalledWith({
340
+ method: 'POST',
341
+ path: '/api/v2/local-settings/llm',
342
+ body: settings,
343
+ });
344
+ });
345
+
346
+ it('diagnoseLlmConnection 以 POST body 携带连接参数', async () => {
347
+ const request = installBridge();
348
+ request.mockResolvedValue({});
349
+ const input = { baseUrl: 'https://llm.example.test/v1', apiKey: 'sk-test', model: 'test-model' };
350
+ await api.diagnoseLlmConnection(input);
351
+ expect(request).toHaveBeenCalledWith({ method: 'POST', path: '/api/v2/llm/diagnose', body: input });
352
+ });
353
+ });
@@ -0,0 +1,17 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ setPendingFirstMessage,
4
+ takePendingFirstMessage,
5
+ } from './pending-first-message';
6
+
7
+ describe('pending-first-message(首页首条消息接力)', () => {
8
+ it('按 chatId 取出一次后清空,错配的 chatId 不消费', () => {
9
+ setPendingFirstMessage({ chatId: 'chat-a', content: '秋天到了,随便聊一句吧。' });
10
+ expect(takePendingFirstMessage('chat-b')).toBeNull();
11
+ expect(takePendingFirstMessage('chat-a')).toEqual({
12
+ chatId: 'chat-a',
13
+ content: '秋天到了,随便聊一句吧。',
14
+ });
15
+ expect(takePendingFirstMessage('chat-a')).toBeNull();
16
+ });
17
+ });
@@ -0,0 +1,161 @@
1
+ /**
2
+ * slash-sources:`/` 令牌背后的技能 + MCP 工具目录。
3
+ * 锁定:隐藏技能的三种命中方式(内建名 / 目录 id / 包注册表)、
4
+ * SLASH_TOKEN_PATTERN 的真实匹配边界(行首或空白后、不含内层斜杠——
5
+ * 路径排除发生在 resolve 阶段而不是正则阶段)、resolveSlashTool 的
6
+ * 优先级(MCP 精确 token > mcp__ 前缀直通 > 技能名 / displayName,
7
+ * 隐藏技能永不解析)、refreshSlashSources 的降级(MCP 失败不影响技能)。
8
+ */
9
+ import { afterEach, describe, expect, it, vi } from 'vitest';
10
+ import { registerPackRenderer } from '../packs/registry';
11
+ import {
12
+ isHiddenSlashSkill,
13
+ refreshSlashSources,
14
+ resolveSlashTool,
15
+ SLASH_TOKEN_PATTERN,
16
+ type McpToolItem,
17
+ type SkillItem,
18
+ } from './slash-sources';
19
+
20
+ afterEach(() => {
21
+ delete (window as { electron?: unknown }).electron;
22
+ vi.restoreAllMocks();
23
+ });
24
+
25
+ describe('isHiddenSlashSkill', () => {
26
+ it('内建技能按 name 或目录 id 命中', () => {
27
+ expect(isHiddenSlashSkill({ name: 'plan-mode', description: '' })).toBe(true);
28
+ expect(isHiddenSlashSkill({ name: 'identity', description: '' })).toBe(true);
29
+ expect(
30
+ isHiddenSlashSkill({ name: '计划', id: '70-plan-mode', description: '' }),
31
+ ).toBe(true);
32
+ });
33
+
34
+ it('业务技能不隐藏', () => {
35
+ expect(isHiddenSlashSkill({ name: 'commit', description: '' })).toBe(false);
36
+ });
37
+
38
+ it('包渲染层声明的隐藏技能也命中', () => {
39
+ registerPackRenderer('test-pack-slash', { hiddenSlashSkills: ['pack-secret'] });
40
+ expect(isHiddenSlashSkill({ name: 'pack-secret', description: '' })).toBe(true);
41
+ });
42
+ });
43
+
44
+ describe('SLASH_TOKEN_PATTERN 匹配边界', () => {
45
+ /** 消费方用的是捕获组 1(完整匹配带前导空白),这里同样取捕获组。 */
46
+ const tokens = (s: string) => [...s.matchAll(SLASH_TOKEN_PATTERN)].map((m) => m[1]);
47
+
48
+ it('匹配行首或空白后的 /token', () => {
49
+ expect(tokens('hello /foo world')).toEqual(['/foo']);
50
+ expect(tokens('/foo /bar')).toEqual(['/foo', '/bar']);
51
+ });
52
+
53
+ it('紧跟在非空白后的斜杠不匹配(分数、相对路径、URL)', () => {
54
+ expect(tokens('a/b 1/2')).toEqual([]);
55
+ expect(tokens('https://a.com/b')).toEqual([]);
56
+ });
57
+
58
+ it('绝对路径里的内层斜杠会截断 token——路径排除靠 resolve 阶段', () => {
59
+ // 正则只保证「空白后 + 无内层斜杠」;/mnt/c 被截成 /mnt,
60
+ // 再由 resolveSlashTool 查目录落空来排除。
61
+ expect(tokens('see /mnt/c')).toEqual(['/mnt']);
62
+ });
63
+ });
64
+
65
+ describe('resolveSlashTool', () => {
66
+ const skills: SkillItem[] = [
67
+ { name: 'commit', description: '提交' },
68
+ { name: 'x', displayName: '提交', description: '' },
69
+ { name: 'plan-mode', description: '' },
70
+ ];
71
+ const mcpTools: McpToolItem[] = [
72
+ { token: 'mcp__fs__read', toolName: 'read', serverKey: 'fs', serverName: 'FS', description: '' },
73
+ ];
74
+
75
+ it('MCP 精确 token 命中(大小写不敏感),label 保留用户输入', () => {
76
+ expect(resolveSlashTool('mcp__fs__read', skills, mcpTools)).toEqual({
77
+ type: 'mcp',
78
+ id: 'mcp__fs__read',
79
+ label: 'mcp__fs__read',
80
+ });
81
+ expect(resolveSlashTool('MCP__FS__READ', skills, mcpTools)?.id).toBe('mcp__fs__read');
82
+ });
83
+
84
+ it('mcp__ 前缀即使目录里没有也直通(服务器可能暂时断开)', () => {
85
+ expect(resolveSlashTool('mcp__down__tool', [], [])).toEqual({
86
+ type: 'mcp',
87
+ id: 'mcp__down__tool',
88
+ label: 'mcp__down__tool',
89
+ });
90
+ });
91
+
92
+ it('技能按 name 或 displayName 命中,大小写不敏感', () => {
93
+ expect(resolveSlashTool('Commit', skills, mcpTools)).toEqual({
94
+ type: 'skill',
95
+ id: 'commit',
96
+ label: 'Commit',
97
+ });
98
+ expect(resolveSlashTool('提交', skills, mcpTools)).toEqual({
99
+ type: 'skill',
100
+ id: 'x',
101
+ label: '提交',
102
+ });
103
+ });
104
+
105
+ it('隐藏技能即使在目录里也不解析', () => {
106
+ expect(resolveSlashTool('plan-mode', skills, mcpTools)).toBeNull();
107
+ });
108
+
109
+ it('什么都不匹配返回 null', () => {
110
+ expect(resolveSlashTool('nosuch', skills, mcpTools)).toBeNull();
111
+ });
112
+ });
113
+
114
+ describe('refreshSlashSources', () => {
115
+ it('非 Electron 环境直接返回当前缓存,不发请求', async () => {
116
+ const sources = await refreshSlashSources();
117
+ expect(sources).toEqual({ skills: [], mcpTools: [] });
118
+ });
119
+
120
+ it('拉取两个端点并过滤隐藏技能', async () => {
121
+ const request = vi.fn((input: { path: string }) => {
122
+ if (input.path === '/api/v2/chat-agents/skills') {
123
+ return Promise.resolve({
124
+ skills: [
125
+ { name: 'commit', description: '提交' },
126
+ { name: 'plan-mode', description: '' },
127
+ ],
128
+ });
129
+ }
130
+ return Promise.resolve({ mcpTools: [{ token: 'mcp__fs__read' }] });
131
+ });
132
+ (window as { electron?: unknown }).electron = { localBackend: { request } };
133
+ const sources = await refreshSlashSources();
134
+ expect(sources.skills).toEqual([{ name: 'commit', description: '提交' }]);
135
+ expect(sources.mcpTools).toEqual([{ token: 'mcp__fs__read' }]);
136
+ expect(request).toHaveBeenCalledWith({
137
+ method: 'GET',
138
+ path: '/api/v2/chat-agents/skills',
139
+ });
140
+ expect(request).toHaveBeenCalledWith({
141
+ method: 'GET',
142
+ path: '/api/v2/chat-agents/mcp-tools',
143
+ });
144
+ });
145
+
146
+ it('MCP 端点失败时保留旧缓存、技能结果照常更新', async () => {
147
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
148
+ const request = vi.fn((input: { path: string }) => {
149
+ if (input.path === '/api/v2/chat-agents/skills') {
150
+ return Promise.resolve({ skills: [{ name: 'lint', description: '' }] });
151
+ }
152
+ return Promise.reject(new Error('mcp down'));
153
+ });
154
+ (window as { electron?: unknown }).electron = { localBackend: { request } };
155
+ const sources = await refreshSlashSources();
156
+ expect(sources.skills).toEqual([{ name: 'lint', description: '' }]);
157
+ // 模块级缓存的语义是「保留最近一次成功」:上一用例拉到的 MCP 工具还在。
158
+ expect(sources.mcpTools).toEqual([{ token: 'mcp__fs__read' }]);
159
+ expect(errorSpy).toHaveBeenCalled();
160
+ });
161
+ });