@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,283 @@
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
+ });
@@ -28,6 +28,8 @@ interface BsBootstrap {
28
28
  platform: NodeJS.Platform;
29
29
  flavor: string;
30
30
  brandName: string;
31
+ /** 每次启动生成的 Bearer token;/api/v2/* 与 /host/* 全部要求携带。 */
32
+ token?: string;
31
33
  }
32
34
 
33
35
  function bootstrap(): BsBootstrap {
@@ -37,10 +39,18 @@ function bootstrap(): BsBootstrap {
37
39
  : { platform: 'linux', flavor: 'generic', brandName: '' };
38
40
  }
39
41
 
42
+ function authHeaders(): Record<string, string> {
43
+ const token = bootstrap().token;
44
+ return token ? { Authorization: `Bearer ${token}` } : {};
45
+ }
46
+
40
47
  async function http<T>(method: string, path: string, body?: unknown): Promise<T> {
41
48
  const res = await fetch(path, {
42
49
  method,
43
- headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
50
+ headers: {
51
+ ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
52
+ ...authHeaders(),
53
+ },
44
54
  body: body !== undefined ? JSON.stringify(body) : undefined,
45
55
  });
46
56
  const data = (await res.json().catch(() => null)) as T & { detail?: string };
@@ -63,7 +73,11 @@ let eventSource: EventSource | null = null;
63
73
 
64
74
  function ensureEventSource(): EventSource {
65
75
  if (eventSource) return eventSource;
66
- eventSource = new EventSource('/api/v2/events');
76
+ // EventSource 不能设请求头,token 走 query(server 端两种都收)。
77
+ const token = bootstrap().token;
78
+ eventSource = new EventSource(
79
+ `/api/v2/events${token ? `?token=${encodeURIComponent(token)}` : ''}`,
80
+ );
67
81
  return eventSource;
68
82
  }
69
83
 
@@ -112,7 +126,7 @@ async function startStream(
112
126
  try {
113
127
  res = await fetch(input.path, {
114
128
  method: input.method,
115
- headers: { 'Content-Type': 'application/json' },
129
+ headers: { 'Content-Type': 'application/json', ...authHeaders() },
116
130
  body: input.body !== undefined ? JSON.stringify(input.body) : undefined,
117
131
  signal: controller.signal,
118
132
  });
@@ -231,7 +245,7 @@ export function createHttpBridge(): ElectronBridge {
231
245
  subscribeChannel<{ chatId: string; agentId?: string | null }>('chat-created', callback),
232
246
 
233
247
  onTaskUpdated: (callback) =>
234
- subscribeChannel<{ chatId: string; taskId: string; status: string }>(
248
+ subscribeChannel<{ chatId: string; taskId: string }>(
235
249
  'task-updated',
236
250
  callback,
237
251
  ),
@@ -0,0 +1,56 @@
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
+ });