@steerable/agent-shell-web 0.6.19 → 0.6.21

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 (76) hide show
  1. package/package.json +15 -8
  2. package/src/components/chat/ApprovalModal.tsx +21 -41
  3. package/src/components/chat/ApprovalPromptProvider.tsx +98 -0
  4. package/src/components/chat/AskUserPromptProvider.tsx +99 -0
  5. package/src/components/chat/AssistantMessage.tsx +25 -2
  6. package/src/components/chat/ChatInput.tsx +116 -13
  7. package/src/components/chat/FilePathCode.tsx +65 -0
  8. package/src/components/chat/LocalChatPanel.tsx +19 -0
  9. package/src/components/chat/Markdown.tsx +54 -25
  10. package/src/components/chat/MessageList.tsx +66 -18
  11. package/src/components/chat/ModelPicker.tsx +5 -3
  12. package/src/components/chat/SuggestedReplies.tsx +34 -0
  13. package/src/components/chat/TurnFilesCard.tsx +113 -0
  14. package/src/components/chat/TurnProcessGroup.tsx +7 -2
  15. package/src/components/chat/path-mentions.ts +148 -0
  16. package/src/components/chat/suggested-replies-model.ts +40 -0
  17. package/src/components/chat/turn-files.ts +50 -0
  18. package/src/layouts/AgentLayout.tsx +15 -26
  19. package/src/lib/browser-dev-electron-mock.ts +15 -0
  20. package/src/lib/electron-bridge.ts +13 -7
  21. package/src/lib/http-bridge.ts +8 -2
  22. package/src/lib/local-api.ts +31 -0
  23. package/src/pages/AgentPage.tsx +126 -1
  24. package/src/pages/ChatPanelPreviewPage.tsx +4 -0
  25. package/vite.base.d.ts +7 -0
  26. package/vite.base.js +83 -0
  27. package/src/brand.test.ts +0 -22
  28. package/src/components/AgentSidebar.test.tsx +0 -791
  29. package/src/components/ChatHeader.test.tsx +0 -230
  30. package/src/components/LocalLlmSettingsModal.test.tsx +0 -372
  31. package/src/components/chat/ApprovalModal.test.tsx +0 -83
  32. package/src/components/chat/AskUserModalHost.test.tsx +0 -176
  33. package/src/components/chat/AskUserModalHost.tsx +0 -61
  34. package/src/components/chat/AskUserQuestionMenu.test.tsx +0 -190
  35. package/src/components/chat/AssistantMessage.test.tsx +0 -68
  36. package/src/components/chat/ChatInput.test.tsx +0 -609
  37. package/src/components/chat/ExecPolicyPicker.test.tsx +0 -26
  38. package/src/components/chat/Markdown.test.tsx +0 -170
  39. package/src/components/chat/ModelPicker.test.tsx +0 -149
  40. package/src/components/chat/SessionTreeModal.test.tsx +0 -161
  41. package/src/components/chat/TaskOutcomeCards.test.tsx +0 -97
  42. package/src/components/chat/TaskPanelModal.test.tsx +0 -198
  43. package/src/components/chat/TaskProcessPanel.test.tsx +0 -111
  44. package/src/components/chat/TurnProcessGroup.test.tsx +0 -161
  45. package/src/components/chat/elapsed.test.ts +0 -42
  46. package/src/components/chat/executed-actions-model.test.ts +0 -134
  47. package/src/components/chat/orchestration-children-model.test.ts +0 -115
  48. package/src/components/chat/turn-timeline.test.ts +0 -97
  49. package/src/components/chat/useChatTasks.test.ts +0 -136
  50. package/src/components/settings/AgentsSettingsPanel.test.tsx +0 -229
  51. package/src/components/settings/InsightsSettingsPanel.test.tsx +0 -132
  52. package/src/components/settings/LlmSettingsPanel.test.tsx +0 -461
  53. package/src/components/settings/ModelIdCombobox.test.tsx +0 -88
  54. package/src/components/settings/SecuritySettingsPanel.test.tsx +0 -122
  55. package/src/components/settings/SkillsSettingsPanel.test.tsx +0 -62
  56. package/src/components/settings/WebSearchSettingsPanel.test.tsx +0 -90
  57. package/src/components/settings/compat-flags-model.test.ts +0 -71
  58. package/src/components/settings/llm-vendors.test.ts +0 -171
  59. package/src/components/settings/model-capabilities.test.ts +0 -88
  60. package/src/components/settings/preset-choice-model.test.ts +0 -96
  61. package/src/hooks/useChatsAndAgents.test.ts +0 -165
  62. package/src/lib/attachments.test.ts +0 -127
  63. package/src/lib/chat-title.test.ts +0 -53
  64. package/src/lib/chat-transport.test.ts +0 -282
  65. package/src/lib/date-groups.test.ts +0 -62
  66. package/src/lib/demo-flag.test.ts +0 -17
  67. package/src/lib/electron-bridge.test.ts +0 -54
  68. package/src/lib/exec-policy.test.ts +0 -71
  69. package/src/lib/http-bridge.test.ts +0 -283
  70. package/src/lib/insights.test.ts +0 -56
  71. package/src/lib/local-api.test.ts +0 -353
  72. package/src/lib/pending-first-message.test.ts +0 -17
  73. package/src/lib/slash-sources.test.ts +0 -161
  74. package/src/pages/AgentPage.test.tsx +0 -627
  75. package/src/pages/SettingsPage.test.tsx +0 -102
  76. package/vite.base.ts +0 -94
@@ -1,283 +0,0 @@
1
- /**
2
- * http-bridge:BS 模式下与 Electron preload 同形的宿主桥,传输换成 HTTP。
3
- * 锁定:request 的方法 / 头 / body 形状与错误语义(detail 优先、status 挂上)、
4
- * steer / approval / askUser / attachments 的端点逐字形状、startStream 的
5
- * data→end 事件序列与取消语义(本地取消按正常结束上报)、
6
- * 浏览器形态下 local.* 的降级回答(目录选择取消、截图不支持)。
7
- * fetch 用 vi.stubGlobal 替身,不起真实服务器。
8
- */
9
- import { afterEach, describe, expect, it, vi } from 'vitest';
10
- import { createHttpBridge } from './http-bridge';
11
- import type { LocalBackendStreamEvent } from './electron-bridge';
12
-
13
- afterEach(() => {
14
- vi.unstubAllGlobals();
15
- });
16
-
17
- function stubFetch(handler: (path: string, init?: RequestInit) => unknown) {
18
- const fetchMock = vi.fn((input: unknown, init?: RequestInit) =>
19
- Promise.resolve(handler(String(input), init)),
20
- );
21
- vi.stubGlobal('fetch', fetchMock);
22
- return fetchMock;
23
- }
24
-
25
- function jsonResponse(payload: unknown, status = 200) {
26
- return {
27
- ok: status >= 200 && status < 300,
28
- status,
29
- json: () => Promise.resolve(payload),
30
- };
31
- }
32
-
33
- /** 逐 chunk 产出字符串的可读流替身(body.getReader 的最小实现)。 */
34
- function streamResponse(chunks: string[], status = 200) {
35
- const encoder = new TextEncoder();
36
- let i = 0;
37
- return {
38
- ok: status >= 200 && status < 300,
39
- status,
40
- body: {
41
- getReader: () => ({
42
- read: () =>
43
- Promise.resolve(
44
- i < chunks.length
45
- ? { done: false as const, value: encoder.encode(chunks[i++]) }
46
- : { done: true as const, value: undefined },
47
- ),
48
- }),
49
- },
50
- };
51
- }
52
-
53
- describe('localBackend.request', () => {
54
- it('GET 不带 body 时不设 Content-Type、不带 body', async () => {
55
- const fetchMock = stubFetch(() => jsonResponse({ ok: 1 }));
56
- const bridge = createHttpBridge();
57
- await bridge.localBackend.request({ method: 'GET', path: '/api/v2/chats' });
58
- expect(fetchMock).toHaveBeenCalledWith('/api/v2/chats', {
59
- method: 'GET',
60
- headers: {},
61
- body: undefined,
62
- });
63
- });
64
-
65
- it('POST 带 body 时加 JSON 头并序列化', async () => {
66
- const fetchMock = stubFetch(() => jsonResponse({}));
67
- const bridge = createHttpBridge();
68
- await bridge.localBackend.request({
69
- method: 'POST',
70
- path: '/api/v2/chats/new',
71
- body: { agentId: 'a1' },
72
- });
73
- expect(fetchMock).toHaveBeenCalledWith('/api/v2/chats/new', {
74
- method: 'POST',
75
- headers: { 'Content-Type': 'application/json' },
76
- body: '{"agentId":"a1"}',
77
- });
78
- });
79
-
80
- it('非 2xx 且响应带 detail 时抛 detail,status 挂在错误上', async () => {
81
- stubFetch(() => jsonResponse({ detail: '参数错误' }, 422));
82
- const bridge = createHttpBridge();
83
- const err = await bridge.localBackend
84
- .request({ method: 'GET', path: '/x' })
85
- .catch((e: unknown) => e);
86
- expect(err).toBeInstanceOf(Error);
87
- expect((err as Error).message).toBe('参数错误');
88
- expect((err as Error & { status?: number }).status).toBe(422);
89
- });
90
-
91
- it('非 2xx 且响应非 JSON 时退化为状态码描述', async () => {
92
- stubFetch(() => ({
93
- ok: false,
94
- status: 500,
95
- json: () => Promise.reject(new Error('not json')),
96
- }));
97
- const bridge = createHttpBridge();
98
- await expect(
99
- bridge.localBackend.request({ method: 'GET', path: '/x' }),
100
- ).rejects.toThrow('Request failed (500)');
101
- });
102
- });
103
-
104
- describe('host 端点形状', () => {
105
- it('steerChat 只在 ok===true 时为真', async () => {
106
- const fetchMock = stubFetch(() => jsonResponse({ ok: true }));
107
- const bridge = createHttpBridge();
108
- await expect(bridge.localBackend.steerChat!('c1', '停一下')).resolves.toBe(true);
109
- expect(fetchMock).toHaveBeenCalledWith(
110
- '/host/steer',
111
- expect.objectContaining({ method: 'POST', body: '{"chatId":"c1","content":"停一下"}' }),
112
- );
113
- });
114
-
115
- it('approval.decide 与 askUser.answer 打到各自端点', async () => {
116
- const fetchMock = stubFetch(() => jsonResponse({}));
117
- const bridge = createHttpBridge();
118
- await bridge.approval!.decide({ requestId: 'r1', kind: 'allow_once' });
119
- await bridge.askUser!.answer({ requestId: 'r2', answers: { q: 'a' } });
120
- expect(fetchMock).toHaveBeenCalledWith(
121
- '/host/approval/decide',
122
- expect.objectContaining({ method: 'POST' }),
123
- );
124
- expect(fetchMock).toHaveBeenCalledWith(
125
- '/host/ask-user/answer',
126
- expect.objectContaining({ method: 'POST' }),
127
- );
128
- });
129
-
130
- it('attachments.save 透传到落盘端点', async () => {
131
- const fetchMock = stubFetch(() => jsonResponse({ files: [] }));
132
- const bridge = createHttpBridge();
133
- await bridge.attachments!.save({ chatId: 'c1', files: [{ name: 'a.txt', data: 'SGk=' }] });
134
- expect(fetchMock).toHaveBeenCalledWith(
135
- '/host/attachments/save',
136
- expect.objectContaining({
137
- method: 'POST',
138
- body: '{"chatId":"c1","files":[{"name":"a.txt","data":"SGk="}]}',
139
- }),
140
- );
141
- });
142
- });
143
-
144
- describe('startStream / cancelStream', () => {
145
- it('按 chunk 发 data 事件,流尽发 end(带状态码),返回 streamId', async () => {
146
- stubFetch(() => streamResponse(['data: {"content":"a"}\n\n', 'data: [DONE]\n\n']));
147
- const bridge = createHttpBridge();
148
- const events: LocalBackendStreamEvent[] = [];
149
- const streamId = await bridge.localBackend.startStream(
150
- { method: 'POST', path: '/api/v2/chats/c1/run', body: { message: 'hi' } },
151
- (e) => events.push(e),
152
- );
153
- expect(typeof streamId).toBe('string');
154
- await vi.waitFor(() => {
155
- expect(events).toEqual([
156
- { type: 'data', chunk: 'data: {"content":"a"}\n\n' },
157
- { type: 'data', chunk: 'data: [DONE]\n\n' },
158
- { type: 'end', status: 200 },
159
- ]);
160
- });
161
- });
162
-
163
- it('fetch 本身失败时发 error 事件并返回 null', async () => {
164
- const fetchMock = vi.fn().mockRejectedValue(new Error('connection refused'));
165
- vi.stubGlobal('fetch', fetchMock);
166
- const bridge = createHttpBridge();
167
- const events: LocalBackendStreamEvent[] = [];
168
- const streamId = await bridge.localBackend.startStream(
169
- { method: 'POST', path: '/x' },
170
- (e) => events.push(e),
171
- );
172
- expect(streamId).toBeNull();
173
- expect(events).toEqual([{ type: 'error', error: 'connection refused' }]);
174
- });
175
-
176
- it('非 2xx 响应发 error 事件并返回 null', async () => {
177
- stubFetch(() => ({ ok: false, status: 503, body: null }));
178
- const bridge = createHttpBridge();
179
- const events: LocalBackendStreamEvent[] = [];
180
- const streamId = await bridge.localBackend.startStream(
181
- { method: 'POST', path: '/x' },
182
- (e) => events.push(e),
183
- );
184
- expect(streamId).toBeNull();
185
- expect(events).toEqual([{ type: 'error', error: 'start stream failed (503)' }]);
186
- });
187
-
188
- it('取消进行中的流按正常结束(end 200)上报', async () => {
189
- const encoder = new TextEncoder();
190
- let rejectRead: ((err: Error) => void) | null = null;
191
- let readCount = 0;
192
- stubFetch(() => ({
193
- ok: true,
194
- status: 200,
195
- body: {
196
- getReader: () => ({
197
- read: () => {
198
- readCount += 1;
199
- if (readCount === 1) {
200
- return Promise.resolve({ done: false, value: encoder.encode('chunk-1') });
201
- }
202
- return new Promise((_, reject) => {
203
- rejectRead = reject;
204
- });
205
- },
206
- }),
207
- },
208
- }));
209
- const bridge = createHttpBridge();
210
- const events: LocalBackendStreamEvent[] = [];
211
- const streamId = await bridge.localBackend.startStream(
212
- { method: 'POST', path: '/x' },
213
- (e) => events.push(e),
214
- );
215
- expect(streamId).not.toBeNull();
216
- await vi.waitFor(() => expect(events).toHaveLength(1));
217
- bridge.localBackend.cancelStream(streamId!);
218
- rejectRead!(new Error('The operation was aborted'));
219
- await vi.waitFor(() => {
220
- expect(events).toEqual([
221
- { type: 'data', chunk: 'chunk-1' },
222
- { type: 'end', status: 200 },
223
- ]);
224
- });
225
- // 重复取消是 no-op,不抛错。
226
- expect(() => bridge.localBackend.cancelStream(streamId!)).not.toThrow();
227
- });
228
- });
229
-
230
- describe('BS token(bootstrap 注入的 Bearer)', () => {
231
- const BOOT = { platform: 'darwin', flavor: 'test', brandName: 'T', token: 'tok 1' };
232
-
233
- it('request 与 startStream 带 Authorization 头', async () => {
234
- vi.stubGlobal('__DEEPPATH_BS__', BOOT);
235
- const fetchMock = stubFetch(() => jsonResponse({}));
236
- const bridge = createHttpBridge();
237
- await bridge.localBackend.request({ method: 'GET', path: '/api/v2/chats' });
238
- expect(fetchMock).toHaveBeenCalledWith('/api/v2/chats', {
239
- method: 'GET',
240
- headers: { Authorization: 'Bearer tok 1' },
241
- body: undefined,
242
- });
243
- await bridge.localBackend.startStream({ method: 'POST', path: '/x' }, () => {});
244
- expect(fetchMock).toHaveBeenCalledWith('/x', expect.objectContaining({
245
- headers: { 'Content-Type': 'application/json', Authorization: 'Bearer tok 1' },
246
- }));
247
- });
248
-
249
- it('EventSource 不能设头,token 走 query(URL 编码)', () => {
250
- vi.stubGlobal('__DEEPPATH_BS__', BOOT);
251
- const urls: string[] = [];
252
- vi.stubGlobal(
253
- 'EventSource',
254
- class {
255
- constructor(url: string) {
256
- urls.push(url);
257
- }
258
- addEventListener(): void {}
259
- },
260
- );
261
- const bridge = createHttpBridge();
262
- bridge.onChatCreated?.(() => {});
263
- expect(urls).toEqual(['/api/v2/events?token=tok%201']);
264
- });
265
- });
266
-
267
- describe('浏览器形态的 local.* 降级', () => {
268
- it('目录选择直接取消(浏览器给不了路径)', async () => {
269
- stubFetch(() => jsonResponse({}));
270
- const bridge = createHttpBridge();
271
- await expect(bridge.local!.selectDirectory()).resolves.toEqual({
272
- canceled: true,
273
- filePaths: [],
274
- });
275
- });
276
-
277
- it('窗口截图明确不支持', async () => {
278
- stubFetch(() => jsonResponse({}));
279
- const bridge = createHttpBridge();
280
- const res = await bridge.local!.captureScreenshot();
281
- expect(res.success).toBe(false);
282
- });
283
- });
@@ -1,56 +0,0 @@
1
- /**
2
- * insights.trackBehavior:fire-and-forget 的行为事件入队。
3
- * 锁定三条契约:桥缺失时静默返回(浏览器演示态不产生遥测)、
4
- * 请求形状逐字锁定(端点 / 方法 / body 键)、后端失败绝不抛进 UI。
5
- * 桥走真实的 window.electron 路径,不 mock 模块。
6
- */
7
- import { afterEach, describe, expect, it, vi } from 'vitest';
8
- import { trackBehavior } from './insights';
9
-
10
- type RequestInput = { method: string; path: string; body?: unknown };
11
-
12
- function installBridge() {
13
- const request = vi.fn<(input: RequestInput) => Promise<unknown>>();
14
- (window as { electron?: unknown }).electron = { localBackend: { request } };
15
- return request;
16
- }
17
-
18
- afterEach(() => {
19
- delete (window as { electron?: unknown }).electron;
20
- });
21
-
22
- describe('trackBehavior', () => {
23
- it('桥缺失时静默返回,不抛错', () => {
24
- expect(() => trackBehavior('chat_opened')).not.toThrow();
25
- });
26
-
27
- it('桥存在时 POST 到事件端点,body 带事件名与属性', () => {
28
- const request = installBridge();
29
- request.mockResolvedValue({});
30
- trackBehavior('chat_opened', { source: 'sidebar' });
31
- expect(request).toHaveBeenCalledWith({
32
- method: 'POST',
33
- path: '/api/v2/insights/events',
34
- body: { eventName: 'chat_opened', properties: { source: 'sidebar' } },
35
- });
36
- });
37
-
38
- it('属性缺省为空对象', () => {
39
- const request = installBridge();
40
- request.mockResolvedValue({});
41
- trackBehavior('app_launch');
42
- expect(request).toHaveBeenCalledWith({
43
- method: 'POST',
44
- path: '/api/v2/insights/events',
45
- body: { eventName: 'app_launch', properties: {} },
46
- });
47
- });
48
-
49
- it('后端拒绝时吞掉错误,不抛进 UI', async () => {
50
- const request = installBridge();
51
- request.mockRejectedValue(new Error('backend down'));
52
- expect(() => trackBehavior('chat_opened')).not.toThrow();
53
- // 让内部 promise 链跑完,确认没有未处理拒绝冒出来。
54
- await new Promise((resolve) => setTimeout(resolve, 0));
55
- });
56
- });
@@ -1,353 +0,0 @@
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
- });