@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,198 +0,0 @@
1
- /**
2
- * TaskPanelModal 的接线契约(4.6a/4.6c):
3
- * - 打开时经 localBackend 拉 GET /chats/:id/tasks,按状态渲染
4
- * 运行中/已完成/失败徽标;
5
- * - 展开行显示 answer / error;
6
- * - worktree 任务(worktreeState=pending 且已完成)显示「合并到主仓」
7
- * /「丢弃」,点击走 POST /tasks/:id/merge|discard 并刷新;
8
- * - 主进程 task-updated 推送触发重拉;
9
- * - Escape 关闭。
10
- */
11
- import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
12
- import { afterEach, describe, expect, it, vi } from 'vitest';
13
- import type { LocalTask } from '@/lib/local-api';
14
- import { TaskPanelModal } from './TaskPanelModal';
15
-
16
- afterEach(() => {
17
- cleanup();
18
- delete (window as { electron?: unknown }).electron;
19
- });
20
-
21
- function makeTask(overrides: Partial<LocalTask> = {}): LocalTask {
22
- return {
23
- id: 'task-1',
24
- chatId: 'chat_1',
25
- task: '统计仓库行数',
26
- status: 'completed',
27
- answer: '共 42 行。',
28
- error: null,
29
- worktreePath: null,
30
- worktreeBranch: null,
31
- worktreeState: null,
32
- createdAt: '2026-09-08T01:00:00.000Z',
33
- updatedAt: '2026-09-08T01:01:00.000Z',
34
- ...overrides,
35
- };
36
- }
37
-
38
- function installBridge(tasks: LocalTask[]) {
39
- const request = vi.fn((input: { method: string; path: string; body?: unknown }) => {
40
- if (input.method === 'GET' && input.path.endsWith('/tasks')) {
41
- return Promise.resolve({ tasks });
42
- }
43
- if (input.method === 'POST' && /\/tasks\/[^/]+\/(merge|discard)$/.test(input.path)) {
44
- return Promise.resolve({ success: true, task: tasks[0] });
45
- }
46
- return Promise.reject(new Error(`unexpected request: ${input.method} ${input.path}`));
47
- });
48
- const taskUpdatedListeners: Array<
49
- (payload: { chatId: string; taskId: string }) => void
50
- > = [];
51
- (window as { electron?: unknown }).electron = {
52
- localBackend: { request },
53
- onTaskUpdated: (
54
- cb: (payload: { chatId: string; taskId: string }) => void,
55
- ) => {
56
- taskUpdatedListeners.push(cb);
57
- return () => {
58
- const i = taskUpdatedListeners.indexOf(cb);
59
- if (i >= 0) taskUpdatedListeners.splice(i, 1);
60
- };
61
- },
62
- };
63
- return {
64
- request,
65
- emitTaskUpdated: (payload: { chatId: string; taskId: string }) => {
66
- for (const cb of taskUpdatedListeners) cb(payload);
67
- },
68
- };
69
- }
70
-
71
- describe('TaskPanelModal', () => {
72
- it('渲染任务列表:状态徽标 + 任务文本', async () => {
73
- installBridge([
74
- makeTask({ id: 't1', task: '跑测试', status: 'running', answer: null }),
75
- makeTask({ id: 't2', task: '写文档', status: 'completed' }),
76
- makeTask({ id: 't3', task: '改代码', status: 'failed', error: 'sidecar 断开' }),
77
- ]);
78
- render(<TaskPanelModal chatId="chat_1" onClose={() => {}} />);
79
-
80
- await waitFor(() => expect(screen.queryByText('正在加载任务…')).toBeNull());
81
- const rows = screen
82
- .getAllByRole('button', { hidden: true })
83
- .map((b) => b.closest('[data-task-row]'))
84
- .filter((el): el is HTMLElement => el !== null);
85
- const uniqueRows = [...new Set(rows)];
86
- expect(uniqueRows).toHaveLength(3);
87
- expect(uniqueRows.map((r) => r.getAttribute('data-status'))).toEqual([
88
- 'running',
89
- 'completed',
90
- 'failed',
91
- ]);
92
- expect(screen.getByText('跑测试')).toBeTruthy();
93
- expect(screen.getByText(/3 个任务,1 个运行中/)).toBeTruthy();
94
- });
95
-
96
- it('空态文案', async () => {
97
- installBridge([]);
98
- render(<TaskPanelModal chatId="chat_1" onClose={() => {}} />);
99
- await waitFor(() => screen.getByText(/暂无后台任务/));
100
- });
101
-
102
- it('展开行显示答案;worktree pending 任务显示合并/丢弃并走对应路由', async () => {
103
- const { request } = installBridge([
104
- makeTask({
105
- id: 'wt-1',
106
- worktreePath: '/repo/.steerable/worktrees/demo',
107
- worktreeBranch: 'steerable/demo',
108
- worktreeState: 'pending',
109
- }),
110
- ]);
111
- render(<TaskPanelModal chatId="chat_1" onClose={() => {}} />);
112
- await waitFor(() => screen.getByText('统计仓库行数'));
113
-
114
- // 展开 → 答案 + worktree 操作。
115
- fireEvent.click(screen.getByRole('button', { name: '展开' }));
116
- await waitFor(() => screen.getByText('共 42 行。'));
117
- expect(screen.getByText('steerable/demo')).toBeTruthy();
118
-
119
- fireEvent.click(screen.getByText('合并到主仓'));
120
- await waitFor(() =>
121
- expect(request).toHaveBeenCalledWith({
122
- method: 'POST',
123
- path: '/api/v2/tasks/wt-1/merge',
124
- }),
125
- );
126
- // 合并后重拉列表。
127
- await waitFor(() =>
128
- expect(
129
- request.mock.calls.filter(
130
- ([input]) =>
131
- (input as { method: string }).method === 'GET' &&
132
- (input as { path: string }).path.endsWith('/tasks'),
133
- ).length,
134
- ).toBeGreaterThanOrEqual(2),
135
- );
136
- });
137
-
138
- it('丢弃走 discard 路由', async () => {
139
- const { request } = installBridge([
140
- makeTask({
141
- id: 'wt-2',
142
- worktreePath: '/repo/.steerable/worktrees/x',
143
- worktreeBranch: 'steerable/x',
144
- worktreeState: 'pending',
145
- }),
146
- ]);
147
- render(<TaskPanelModal chatId="chat_1" onClose={() => {}} />);
148
- await waitFor(() => screen.getByText('统计仓库行数'));
149
-
150
- fireEvent.click(screen.getByRole('button', { name: '展开' }));
151
- fireEvent.click(await screen.findByText('丢弃'));
152
- await waitFor(() =>
153
- expect(request).toHaveBeenCalledWith({
154
- method: 'POST',
155
- path: '/api/v2/tasks/wt-2/discard',
156
- }),
157
- );
158
- });
159
-
160
- it('task-updated 推送触发重拉;其他 chat 的推送忽略', async () => {
161
- const { request, emitTaskUpdated } = installBridge([makeTask()]);
162
- render(<TaskPanelModal chatId="chat_1" onClose={() => {}} />);
163
- await waitFor(() => screen.getByText('统计仓库行数'));
164
- const getsBefore = request.mock.calls.filter(
165
- ([input]) => (input as { method: string }).method === 'GET',
166
- ).length;
167
-
168
- emitTaskUpdated({ chatId: 'chat_2', taskId: 'x' });
169
- emitTaskUpdated({ chatId: 'chat_1', taskId: 'task-1' });
170
-
171
- await waitFor(() => {
172
- const gets = request.mock.calls.filter(
173
- ([input]) => (input as { method: string }).method === 'GET',
174
- ).length;
175
- expect(gets).toBe(getsBefore + 1);
176
- });
177
- });
178
-
179
- it('点击任务名调用 onInspect', async () => {
180
- const onInspect = vi.fn();
181
- installBridge([makeTask({ id: 't1', task: '跑测试', status: 'running', answer: null })]);
182
- render(<TaskPanelModal chatId="chat_1" onClose={() => {}} onInspect={onInspect} />);
183
- await waitFor(() => screen.getByText('跑测试'));
184
- fireEvent.click(screen.getByRole('button', { name: '跑测试' }));
185
- expect(onInspect).toHaveBeenCalledTimes(1);
186
- expect(onInspect.mock.calls[0][0]).toMatchObject({ id: 't1', task: '跑测试' });
187
- });
188
-
189
- it('Escape 关闭模态', async () => {
190
- const onClose = vi.fn();
191
- installBridge([makeTask()]);
192
- render(<TaskPanelModal chatId="chat_1" onClose={onClose} />);
193
- await waitFor(() => screen.getByText('统计仓库行数'));
194
-
195
- fireEvent.keyDown(document, { key: 'Escape' });
196
- expect(onClose).toHaveBeenCalledTimes(1);
197
- });
198
- });
@@ -1,111 +0,0 @@
1
- /**
2
- * TaskProcessPanel:拉 GET /tasks/:id/process 渲染时间线;
3
- * task-process 广播更新 live 块。
4
- */
5
- import { cleanup, render, screen, waitFor } from '@testing-library/react';
6
- import { afterEach, describe, expect, it, vi } from 'vitest';
7
- import { TaskProcessPanel } from './TaskProcessPanel';
8
-
9
- afterEach(() => {
10
- cleanup();
11
- delete (window as { electron?: unknown }).electron;
12
- vi.restoreAllMocks();
13
- });
14
-
15
- function installBridge(timeline: unknown[], live = false, stale = false) {
16
- const request = vi.fn((input: { method: string; path: string }) => {
17
- if (input.method === 'GET' && input.path.endsWith('/process')) {
18
- return Promise.resolve({
19
- task: {
20
- id: 'task-1',
21
- chatId: 'chat_1',
22
- task: '问好循环',
23
- status: live ? 'running' : 'completed',
24
- },
25
- timeline,
26
- live,
27
- stale,
28
- });
29
- }
30
- return Promise.reject(new Error(`unexpected ${input.method} ${input.path}`));
31
- });
32
- const listeners: Array<
33
- (payload: { chatId: string; taskId: string; timeline: unknown; live: boolean }) => void
34
- > = [];
35
- (window as { electron?: unknown }).electron = {
36
- localBackend: { request },
37
- onTaskProcess: (
38
- cb: (payload: { chatId: string; taskId: string; timeline: unknown; live: boolean }) => void,
39
- ) => {
40
- listeners.push(cb);
41
- return () => {
42
- const i = listeners.indexOf(cb);
43
- if (i >= 0) listeners.splice(i, 1);
44
- };
45
- },
46
- };
47
- return {
48
- emit: (payload: { chatId: string; taskId: string; timeline: unknown; live: boolean }) => {
49
- for (const cb of listeners) cb(payload);
50
- },
51
- };
52
- }
53
-
54
- describe('TaskProcessPanel', () => {
55
- it('渲染历史推理与工具调用', async () => {
56
- installBridge([
57
- { type: 'reasoning', content: '先跑命令。' },
58
- { type: 'tools', actions: [{ tool: 'local_exec_shell', success: true }] },
59
- { type: 'text', content: '问好完成。' },
60
- ]);
61
- render(
62
- <TaskProcessPanel
63
- inspected={{ id: 'task-1', chatId: 'chat_1', title: '问好循环' }}
64
- onClose={() => {}}
65
- />,
66
- );
67
- await waitFor(() => screen.getByText('问好完成。'));
68
- expect(screen.getByText(/1 次工具调用/)).toBeTruthy();
69
- expect(screen.getByText(/已结束/)).toBeTruthy();
70
- });
71
-
72
- it('task-process 推送更新时间线', async () => {
73
- const { emit } = installBridge([], true, false);
74
- render(
75
- <TaskProcessPanel
76
- inspected={{ id: 'task-1', chatId: 'chat_1', title: '问好循环' }}
77
- onClose={() => {}}
78
- />,
79
- );
80
- await waitFor(() => screen.getByText(/正在推理/));
81
- emit({
82
- chatId: 'chat_1',
83
- taskId: 'task-1',
84
- live: true,
85
- timeline: [{ type: 'reasoning', content: '开始第一轮。' }],
86
- });
87
- await waitFor(() => screen.getByText('开始第一轮。'));
88
- });
89
-
90
- it('live 增量时把滚动条钉在底部', async () => {
91
- const scrollTo = vi.spyOn(HTMLElement.prototype, 'scrollTo').mockImplementation(() => {});
92
- const { emit } = installBridge([], true, false);
93
- render(
94
- <TaskProcessPanel
95
- inspected={{ id: 'task-1', chatId: 'chat_1', title: '问好循环' }}
96
- onClose={() => {}}
97
- />,
98
- );
99
- await waitFor(() => screen.getByText(/正在推理/));
100
- scrollTo.mockClear();
101
-
102
- emit({
103
- chatId: 'chat_1',
104
- taskId: 'task-1',
105
- live: true,
106
- timeline: [{ type: 'reasoning', content: '开始第一轮。' }],
107
- });
108
- await waitFor(() => screen.getByText('开始第一轮。'));
109
- expect(scrollTo).toHaveBeenCalled();
110
- });
111
- });
@@ -1,161 +0,0 @@
1
- import { cleanup, fireEvent, render, screen } from '@testing-library/react';
2
- import { afterEach, describe, expect, it, vi } from 'vitest';
3
- import type { TurnBlock } from './turn-timeline';
4
-
5
- vi.mock('./Markdown', () => ({
6
- Markdown: ({ children }: { children: string }) => <span>{children}</span>,
7
- }));
8
-
9
- vi.mock('./ExecutedActionsCard', () => ({
10
- ToolsFlow: ({ actions }: { actions: Array<{ tool: string }> }) => (
11
- <div data-testid="tools-flow">{actions.map((action) => action.tool).join(',')}</div>
12
- ),
13
- }));
14
-
15
- const { TurnProcessGroup } = await import('./TurnProcessGroup');
16
-
17
- afterEach(() => {
18
- cleanup();
19
- vi.useRealTimers();
20
- });
21
-
22
- const tool = (name: string): TurnBlock => ({
23
- type: 'tools',
24
- actions: [{ tool: name, arguments: {} }],
25
- });
26
-
27
- const blocks: TurnBlock[] = [
28
- { type: 'reasoning', content: '先读配置' },
29
- tool('csv_get_config'),
30
- { type: 'reasoning', content: '再查天气' },
31
- tool('web_fetch'),
32
- { type: 'text', content: '本地 CSV 配置' },
33
- ];
34
-
35
- function renderGroup(overrides: {
36
- blocks?: TurnBlock[];
37
- isStreaming?: boolean;
38
- startedAtMs?: number;
39
- durationMs?: number;
40
- } = {}) {
41
- return render(
42
- <TurnProcessGroup
43
- blocks={overrides.blocks ?? blocks}
44
- isStreaming={overrides.isStreaming ?? false}
45
- startedAtMs={overrides.startedAtMs}
46
- durationMs={overrides.durationMs}
47
- agents={[]}
48
- chats={[]}
49
- emptyFallback={<div>empty</div>}
50
- renderAnswer={(block) => <div data-testid="answer">{block.content}</div>}
51
- />,
52
- );
53
- }
54
-
55
- describe('TurnProcessGroup', () => {
56
- it('collapses think+tool process after a finished summary, leaving the answer visible', () => {
57
- renderGroup({ isStreaming: false });
58
-
59
- const toggle = screen.getByRole('button');
60
- expect(toggle.getAttribute('aria-expanded')).toBe('false');
61
- expect(toggle.textContent).toContain('2 次工具调用 · 已思考');
62
- expect(screen.queryByText('先读配置')).toBeNull();
63
- expect(screen.queryByTestId('tools-flow')).toBeNull();
64
- expect(screen.getByTestId('answer').textContent).toBe('本地 CSV 配置');
65
- });
66
-
67
- it('keeps the process expanded while streaming, then auto-collapses when the summary lands', () => {
68
- const view = renderGroup({
69
- isStreaming: true,
70
- blocks: blocks.slice(0, 4),
71
- });
72
-
73
- expect(screen.getByRole('button').textContent).toContain('正在执行…');
74
- expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('true');
75
- expect(screen.getByText('先读配置')).toBeTruthy();
76
- expect(screen.queryByTestId('answer')).toBeNull();
77
-
78
- view.rerender(
79
- <TurnProcessGroup
80
- blocks={blocks}
81
- isStreaming={false}
82
- agents={[]}
83
- chats={[]}
84
- emptyFallback={<div>empty</div>}
85
- renderAnswer={(block) => <div data-testid="answer">{block.content}</div>}
86
- />,
87
- );
88
-
89
- expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false');
90
- expect(screen.getByRole('button').textContent).toContain('2 次工具调用 · 已思考');
91
- expect(screen.queryByText('先读配置')).toBeNull();
92
- expect(screen.getByTestId('answer').textContent).toBe('本地 CSV 配置');
93
- });
94
-
95
- it('expands the process again when the disclosure is clicked', () => {
96
- renderGroup({ isStreaming: false });
97
- fireEvent.click(screen.getByRole('button'));
98
- expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('true');
99
- expect(screen.getByText('再查天气')).toBeTruthy();
100
- expect(screen.getAllByTestId('tools-flow')).toHaveLength(2);
101
- });
102
-
103
- it('does not fold a tools-only turn that never produced a summary', () => {
104
- renderGroup({
105
- isStreaming: false,
106
- blocks: [tool('csv_get_config')],
107
- });
108
- expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('true');
109
- expect(screen.getByTestId('tools-flow').textContent).toBe('csv_get_config');
110
- expect(screen.queryByTestId('answer')).toBeNull();
111
- });
112
-
113
- it('shows Codex-style elapsed while streaming', () => {
114
- vi.useFakeTimers();
115
- vi.setSystemTime(new Date('2026-09-08T12:00:12.000Z'));
116
- renderGroup({
117
- isStreaming: true,
118
- blocks: blocks.slice(0, 4),
119
- startedAtMs: Date.parse('2026-09-08T12:00:00.000Z'),
120
- });
121
- expect(screen.getByRole('button').textContent).toContain('正在执行… 12s');
122
- });
123
-
124
- it('appends worked duration after the turn finishes', () => {
125
- renderGroup({ isStreaming: false, durationMs: 83_000 });
126
- expect(screen.getByRole('button').textContent).toContain(
127
- '2 次工具调用 · 已思考 · 工作了 1m 23s',
128
- );
129
- });
130
-
131
- it('omits worked duration for sub-second finished turns', () => {
132
- renderGroup({ isStreaming: false, durationMs: 400 });
133
- const label = screen.getByRole('button').textContent ?? '';
134
- expect(label).toContain('2 次工具调用 · 已思考');
135
- expect(label).not.toContain('工作了');
136
- });
137
-
138
- it('freezes live elapsed when the stream ends before durationMs lands', () => {
139
- vi.useFakeTimers();
140
- vi.setSystemTime(new Date('2026-09-08T12:00:12.000Z'));
141
- const startedAtMs = Date.parse('2026-09-08T12:00:00.000Z');
142
- const view = renderGroup({
143
- isStreaming: true,
144
- blocks: blocks.slice(0, 4),
145
- startedAtMs,
146
- });
147
- view.rerender(
148
- <TurnProcessGroup
149
- blocks={blocks}
150
- isStreaming={false}
151
- agents={[]}
152
- chats={[]}
153
- emptyFallback={<div>empty</div>}
154
- renderAnswer={(block) => <div data-testid="answer">{block.content}</div>}
155
- />,
156
- );
157
- expect(screen.getByRole('button').textContent).toContain(
158
- '2 次工具调用 · 已思考 · 工作了 12s',
159
- );
160
- });
161
- });
@@ -1,42 +0,0 @@
1
- import { describe, expect, it } from 'vitest';
2
- import {
3
- formatElapsedCompact,
4
- inferDurationMs,
5
- readPersistedDurationMs,
6
- } from './elapsed';
7
-
8
- describe('formatElapsedCompact', () => {
9
- it('matches Codex compact elapsed', () => {
10
- expect(formatElapsedCompact(0)).toBe('0s');
11
- expect(formatElapsedCompact(999)).toBe('0s');
12
- expect(formatElapsedCompact(1_000)).toBe('1s');
13
- expect(formatElapsedCompact(12_000)).toBe('12s');
14
- expect(formatElapsedCompact(59_000)).toBe('59s');
15
- expect(formatElapsedCompact(60_000)).toBe('1m 00s');
16
- expect(formatElapsedCompact(61_000)).toBe('1m 01s');
17
- expect(formatElapsedCompact(83_000)).toBe('1m 23s');
18
- expect(formatElapsedCompact(3_605_000)).toBe('1h 00m 05s');
19
- expect(formatElapsedCompact(90_122_000)).toBe('25h 02m 02s');
20
- });
21
- });
22
-
23
- describe('readPersistedDurationMs', () => {
24
- it('reads a finite non-negative duration from assistant metadata', () => {
25
- expect(readPersistedDurationMs(JSON.stringify({ durationMs: 83000 }))).toBe(83000);
26
- expect(readPersistedDurationMs(JSON.stringify({ durationMs: 0 }))).toBe(0);
27
- expect(readPersistedDurationMs(JSON.stringify({}))).toBeUndefined();
28
- expect(readPersistedDurationMs(JSON.stringify({ durationMs: -1 }))).toBeUndefined();
29
- expect(readPersistedDurationMs('{')).toBeUndefined();
30
- expect(readPersistedDurationMs(null)).toBeUndefined();
31
- });
32
- });
33
-
34
- describe('inferDurationMs', () => {
35
- it('subtracts the user timestamp from the assistant timestamp', () => {
36
- expect(
37
- inferDurationMs('2026-09-08T12:00:00.000Z', '2026-09-08T12:01:23.000Z'),
38
- ).toBe(83_000);
39
- expect(inferDurationMs('2026-09-08T12:00:00.000Z', '2026-09-08T11:59:00.000Z')).toBeUndefined();
40
- expect(inferDurationMs(undefined, '2026-09-08T12:00:00.000Z')).toBeUndefined();
41
- });
42
- });
@@ -1,134 +0,0 @@
1
- import { describe, expect, it } from 'vitest';
2
- import {
3
- expandRunCodeActions,
4
- summarizeRunCodeAction,
5
- summarizeWebAction,
6
- } from './executed-actions-model';
7
-
8
- // W5-2 工具卡中文摘要的纯映射层测试:web_search 出查询词与结果计数,
9
- // web_fetch 出短 URL、HTTP 状态、体量与截断标记;失败时只出意图头,
10
- // 不编造结果字段。
11
-
12
- describe('summarizeWebAction / web_search', () => {
13
- it('成功时出查询词与结果计数', () => {
14
- const summary = summarizeWebAction(
15
- 'web_search',
16
- { query: 'KV cache 量化' },
17
- { success: true, data: { result_count: 8, query: 'KV cache 量化', results: [] } },
18
- );
19
- expect(summary).toBe('搜索“KV cache 量化” → 8 条结果');
20
- });
21
-
22
- it('长查询词截断到 40 字符', () => {
23
- const summary = summarizeWebAction(
24
- 'web_search',
25
- { query: 'a'.repeat(60) },
26
- { success: true, data: { result_count: 1 } },
27
- );
28
- expect(summary).toBe(`搜索“${'a'.repeat(37)}…” → 1 条结果`);
29
- });
30
-
31
- it('失败时只出意图头,不编造计数', () => {
32
- const summary = summarizeWebAction(
33
- 'web_search',
34
- { query: 'x' },
35
- { success: false, error: 'web search timed out: ReadTimeout' },
36
- );
37
- expect(summary).toBe('搜索“x”');
38
- });
39
-
40
- it('data 缺失(被截断吃掉)时退化为意图头', () => {
41
- expect(summarizeWebAction('web_search', { query: 'x' }, { success: true })).toBe(
42
- '搜索“x”',
43
- );
44
- });
45
- });
46
-
47
- describe('summarizeWebAction / web_fetch', () => {
48
- it('成功时出短 URL、状态码与体量', () => {
49
- const summary = summarizeWebAction(
50
- 'web_fetch',
51
- { url: 'https://example.com/docs/spec.html' },
52
- {
53
- success: true,
54
- data: {
55
- url: 'https://example.com/docs/spec.html',
56
- status: 200,
57
- bytes: 12_288,
58
- truncated: false,
59
- },
60
- },
61
- );
62
- expect(summary).toBe('抓取 example.com/docs/spec.html → 200 · 12.0 KB');
63
- });
64
-
65
- it('截断的抓取带"已截断"标记', () => {
66
- const summary = summarizeWebAction(
67
- 'web_fetch',
68
- { url: 'https://example.com/big' },
69
- { success: true, data: { status: 200, bytes: 1_000_000, truncated: true } },
70
- );
71
- expect(summary).toBe('抓取 example.com/big → 200 · 976.6 KB · 已截断');
72
- });
73
-
74
- it('失败时只出意图头(SSRF 拒绝 / 超时 / 跨域重定向)', () => {
75
- const summary = summarizeWebAction(
76
- 'web_fetch',
77
- { url: 'http://169.254.169.254/latest' },
78
- { success: false, error: 'refusing to fetch a non-public address' },
79
- );
80
- expect(summary).toBe('抓取 169.254.169.254/latest');
81
- });
82
-
83
- it('无法解析的 URL 原样截断展示', () => {
84
- const summary = summarizeWebAction(
85
- 'web_fetch',
86
- { url: 'not a url at all' },
87
- { success: false, error: 'unsupported scheme' },
88
- );
89
- expect(summary).toBe('抓取 not a url at all');
90
- });
91
- });
92
-
93
- describe('summarizeWebAction / 其他工具', () => {
94
- it('非 web 工具返回 null(调用方回落通用摘要)', () => {
95
- expect(
96
- summarizeWebAction('local_exec_shell', { command: 'ls' }, { success: true }),
97
- ).toBeNull();
98
- });
99
- });
100
-
101
- describe('summarizeRunCodeAction / expandRunCodeActions', () => {
102
- it('摘要写出描述和内层工具数,而不是整段 code', () => {
103
- const summary = summarizeRunCodeAction(
104
- 'run_code',
105
- { code: 'return tools.call("stub_a")', description: 'two stubs' },
106
- {
107
- success: true,
108
- data: {
109
- calls: [
110
- { tool: 'stub_a', arguments: {}, result: { success: true } },
111
- { tool: 'stub_b', arguments: {}, result: { success: true } },
112
- ],
113
- },
114
- },
115
- );
116
- expect(summary).toBe('程序「two stubs」· 2 个内层工具');
117
- });
118
-
119
- it('展开为程序行 + 内层工具行', () => {
120
- const rows = expandRunCodeActions([
121
- {
122
- tool: 'run_code',
123
- arguments: { description: 'two stubs' },
124
- result: {
125
- success: true,
126
- data: {
127
- calls: [{ tool: 'stub_a', arguments: { x: 1 }, result: { success: true } }],
128
- },
129
- },
130
- },
131
- ]);
132
- expect(rows.map((r) => r.tool)).toEqual(['run_code', 'stub_a']);
133
- });
134
- });