@zhin.js/adapter-sandbox 7.0.11 → 7.0.15

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.
@@ -0,0 +1,235 @@
1
+ // Generated by build-plugin-runtime-entries.mjs. Do not edit.
2
+ import { buildSandboxSessionKey, buildAgentRunReport, deriveAgentRunSteps, deriveTaskRuns, deriveWorkbenchArtifacts, loadCachedAgentTrace, mergeTraceSnapshot, presentTraceEvent, summarizeTrace, saveCachedAgentTrace, } from './agentTrace.js';
3
+ const snapshot = {
4
+ sessionKey: 'sandbox:bot:private:user',
5
+ latestSequence: 3,
6
+ activeTurnIds: ['turn-1'],
7
+ events: [
8
+ { sequence: 1, recordedAt: 1, sessionKey: 'sandbox:bot:private:user', turnId: 'turn-1', type: 'turn_start', data: {} },
9
+ { sequence: 2, recordedAt: 2, sessionKey: 'sandbox:bot:private:user', turnId: 'turn-1', type: 'tool_call', data: { toolName: 'lookup' } },
10
+ { sequence: 3, recordedAt: 3, sessionKey: 'sandbox:bot:private:user', turnId: 'turn-1', type: 'usage', data: { usage: { totalTokens: 218 } } },
11
+ ],
12
+ };
13
+ class MemoryTraceStorage {
14
+ values = new Map();
15
+ getItem(key) { return this.values.get(key) ?? null; }
16
+ setItem(key, value) { this.values.set(key, value); }
17
+ }
18
+ describe('sandbox agent trace helpers', () => {
19
+ it('builds the canonical IM session key', () => {
20
+ expect(buildSandboxSessionKey('sandbox-bot', 'group', 'planning-room')).toBe('sandbox:sandbox-bot:group:planning-room');
21
+ });
22
+ it('merges incremental trace snapshots without duplicates', () => {
23
+ const merged = mergeTraceSnapshot(snapshot, {
24
+ ...snapshot,
25
+ latestSequence: 4,
26
+ activeTurnIds: [],
27
+ events: [snapshot.events[2], { ...snapshot.events[0], sequence: 4, recordedAt: 4, type: 'turn_end' }],
28
+ });
29
+ expect(merged.events.map((event) => event.sequence)).toEqual([1, 2, 3, 4]);
30
+ expect(merged.activeTurnIds).toEqual([]);
31
+ });
32
+ it('persists bounded task history across browser and Host restarts', () => {
33
+ const storage = new MemoryTraceStorage();
34
+ expect(saveCachedAgentTrace(snapshot, storage)).toBe(true);
35
+ const restored = loadCachedAgentTrace(snapshot.sessionKey, storage);
36
+ expect(restored).toEqual(snapshot);
37
+ const afterHostRestart = mergeTraceSnapshot(restored, {
38
+ sessionKey: snapshot.sessionKey,
39
+ latestSequence: 1,
40
+ activeTurnIds: ['turn-new'],
41
+ events: [{
42
+ sequence: 1,
43
+ recordedAt: 100,
44
+ sessionKey: snapshot.sessionKey,
45
+ turnId: 'turn-new',
46
+ type: 'turn_start',
47
+ data: {},
48
+ }],
49
+ });
50
+ expect(afterHostRestart.events.map((event) => event.turnId)).toEqual([
51
+ 'turn-1', 'turn-1', 'turn-1', 'turn-new',
52
+ ]);
53
+ expect(afterHostRestart.latestSequence).toBe(1);
54
+ });
55
+ it('summarizes operational metrics and presents tool events', () => {
56
+ expect(summarizeTrace(snapshot)).toEqual({ eventCount: 3, toolCount: 1, tokenCount: 218, problemCount: 0, activeTurns: 1 });
57
+ expect(presentTraceEvent(snapshot.events[1])).toMatchObject({ title: '调用 lookup', tone: 'tool' });
58
+ });
59
+ it('derives stable task lifecycle states from trace terminals', () => {
60
+ const runs = deriveTaskRuns({
61
+ sessionKey: snapshot.sessionKey,
62
+ latestSequence: 9,
63
+ activeTurnIds: ['turn-running'],
64
+ events: [
65
+ { sequence: 1, recordedAt: 10, sessionKey: snapshot.sessionKey, turnId: 'turn-done', type: 'turn_start', data: { sourceMessageId: 'msg-done' } },
66
+ { sequence: 2, recordedAt: 20, sessionKey: snapshot.sessionKey, turnId: 'turn-done', type: 'tool_call', data: { toolName: 'write_file' } },
67
+ { sequence: 3, recordedAt: 30, sessionKey: snapshot.sessionKey, turnId: 'turn-done', type: 'usage', data: { usage: { totalTokens: 144 } } },
68
+ { sequence: 4, recordedAt: 40, sessionKey: snapshot.sessionKey, turnId: 'turn-done', type: 'turn_end', data: {} },
69
+ { sequence: 5, recordedAt: 50, sessionKey: snapshot.sessionKey, turnId: 'turn-failed', type: 'turn_start', data: {} },
70
+ { sequence: 6, recordedAt: 60, sessionKey: snapshot.sessionKey, turnId: 'turn-failed', type: 'error', data: { error: { message: 'boom' } } },
71
+ { sequence: 7, recordedAt: 70, sessionKey: snapshot.sessionKey, turnId: 'turn-running', type: 'turn_start', data: {} },
72
+ { sequence: 8, recordedAt: 80, sessionKey: snapshot.sessionKey, turnId: 'turn-running', type: 'iteration_start', data: {} },
73
+ { sequence: 9, recordedAt: 45, sessionKey: snapshot.sessionKey, turnId: 'turn-incomplete', type: 'turn_start', data: {} },
74
+ ],
75
+ });
76
+ expect(runs.map((run) => ({ id: run.turnId, status: run.status }))).toEqual([
77
+ { id: 'turn-running', status: 'running' },
78
+ { id: 'turn-failed', status: 'failed' },
79
+ { id: 'turn-incomplete', status: 'failed' },
80
+ { id: 'turn-done', status: 'completed' },
81
+ ]);
82
+ expect(runs[3]).toMatchObject({ sourceMessageId: 'msg-done', toolCount: 1, tokenCount: 144, durationMs: 30 });
83
+ });
84
+ it('projects file edits and test commands into workbench artifacts', () => {
85
+ const artifacts = deriveWorkbenchArtifacts({
86
+ sessionKey: snapshot.sessionKey,
87
+ latestSequence: 4,
88
+ activeTurnIds: [],
89
+ events: [
90
+ { sequence: 1, recordedAt: 1, sessionKey: snapshot.sessionKey, turnId: 'turn-1', type: 'tool_call', data: {
91
+ toolName: 'edit_file', toolUseId: 'edit-1', args: {
92
+ file_path: '/workspace/src/app.ts', old_string: 'const draft = true', new_string: 'const draft = false',
93
+ },
94
+ } },
95
+ { sequence: 2, recordedAt: 2, sessionKey: snapshot.sessionKey, turnId: 'turn-1', type: 'tool_result', data: {
96
+ toolName: 'edit_file', toolUseId: 'edit-1', output: 'Edited /workspace/src/app.ts', durationMs: 8,
97
+ } },
98
+ { sequence: 3, recordedAt: 3, sessionKey: snapshot.sessionKey, turnId: 'turn-1', type: 'tool_call', data: {
99
+ toolName: 'bash', toolUseId: 'test-1', args: { command: 'pnpm test' },
100
+ } },
101
+ { sequence: 4, recordedAt: 4, sessionKey: snapshot.sessionKey, turnId: 'turn-1', type: 'tool_result', data: {
102
+ toolName: 'bash', toolUseId: 'test-1', output: '12 tests passed', durationMs: 42,
103
+ } },
104
+ ],
105
+ });
106
+ expect(artifacts).toHaveLength(2);
107
+ const file = artifacts.find((artifact) => artifact.kind === 'file-change');
108
+ const test = artifacts.find((artifact) => artifact.kind === 'test');
109
+ expect(file).toMatchObject({ status: 'completed', path: '/workspace/src/app.ts' });
110
+ expect(file?.diff).toContain('+++ b/workspace/src/app.ts');
111
+ expect(file?.diff).toContain('- const draft = true');
112
+ expect(file?.diff).toContain('+ const draft = false');
113
+ expect(test).toMatchObject({ status: 'completed', title: 'pnpm test', detail: '12 tests passed' });
114
+ });
115
+ it('does not cross-wire reused tool ids across turns or runtime generations', () => {
116
+ const events = [
117
+ { runtimeId: 'runtime-old', sequence: 1, recordedAt: 1, sessionKey: snapshot.sessionKey, turnId: 'turn-reused', type: 'tool_call', data: { toolName: 'bash', toolUseId: 'call_1', args: { command: 'pnpm test' } } },
118
+ { runtimeId: 'runtime-old', sequence: 2, recordedAt: 2, sessionKey: snapshot.sessionKey, turnId: 'turn-reused', type: 'tool_result', data: { toolName: 'bash', toolUseId: 'call_1', output: 'old result' } },
119
+ { runtimeId: 'runtime-new', sequence: 1, recordedAt: 3, sessionKey: snapshot.sessionKey, turnId: 'turn-reused', type: 'tool_call', data: { toolName: 'bash', toolUseId: 'call_1', args: { command: 'pnpm build' } } },
120
+ { runtimeId: 'runtime-new', sequence: 2, recordedAt: 4, sessionKey: snapshot.sessionKey, turnId: 'turn-reused', type: 'tool_result', data: { toolName: 'bash', toolUseId: 'call_1', output: 'new result' } },
121
+ ];
122
+ const artifacts = deriveWorkbenchArtifacts({
123
+ runtimeId: 'runtime-new',
124
+ sessionKey: snapshot.sessionKey,
125
+ latestSequence: 2,
126
+ activeTurnIds: [],
127
+ events,
128
+ });
129
+ expect(artifacts.map((artifact) => artifact.detail)).toEqual(['new result', 'old result']);
130
+ expect(new Set(artifacts.map((artifact) => artifact.id)).size).toBe(2);
131
+ expect(deriveTaskRuns({
132
+ runtimeId: 'runtime-new', sessionKey: snapshot.sessionKey, latestSequence: 2, activeTurnIds: [], events,
133
+ }).map((run) => run.id)).toEqual(['runtime-new:turn-reused', 'runtime-old:turn-reused']);
134
+ });
135
+ it('projects a turn into readable, correlated workbench steps', () => {
136
+ const runSnapshot = {
137
+ sessionKey: snapshot.sessionKey,
138
+ latestSequence: 8,
139
+ activeTurnIds: [],
140
+ events: [
141
+ { sequence: 1, recordedAt: 10, sessionKey: snapshot.sessionKey, turnId: 'turn-report', type: 'turn_start', data: {} },
142
+ { sequence: 2, recordedAt: 12, sessionKey: snapshot.sessionKey, turnId: 'turn-report', type: 'capability_resolution', data: { tools: ['bash'], skills: [] } },
143
+ { sequence: 3, recordedAt: 14, sessionKey: snapshot.sessionKey, turnId: 'turn-report', type: 'iteration_start', data: { iteration: 1, maxIterations: 8 } },
144
+ { sequence: 4, recordedAt: 16, sessionKey: snapshot.sessionKey, turnId: 'turn-report', type: 'tool_call', data: { toolName: 'bash', toolUseId: 'call-1', args: { command: 'pnpm test' } } },
145
+ { sequence: 5, recordedAt: 30, sessionKey: snapshot.sessionKey, turnId: 'turn-report', type: 'tool_result', data: { toolName: 'bash', toolUseId: 'call-1', output: '18 tests passed', durationMs: 14 } },
146
+ { sequence: 6, recordedAt: 32, sessionKey: snapshot.sessionKey, turnId: 'turn-report', type: 'tool_call', data: { toolName: 'bash', toolUseId: 'call-2', args: { command: 'pnpm lint' } } },
147
+ { sequence: 7, recordedAt: 40, sessionKey: snapshot.sessionKey, turnId: 'turn-report', type: 'tool_failed', data: { toolName: 'bash', toolUseId: 'call-2', error: 'lint failed', durationMs: 8 } },
148
+ { sequence: 8, recordedAt: 42, sessionKey: snapshot.sessionKey, turnId: 'turn-report', type: 'error', data: { error: 'lint failed' } },
149
+ ],
150
+ };
151
+ expect(deriveAgentRunSteps(runSnapshot, { turnId: 'turn-report' })).toEqual([
152
+ expect.objectContaining({ title: '接收任务', status: 'completed' }),
153
+ expect.objectContaining({ title: '准备运行能力', detail: '1 tools · 0 skills', status: 'completed' }),
154
+ expect.objectContaining({ title: '推理迭代 1', status: 'completed' }),
155
+ expect.objectContaining({ title: '运行 bash', detail: 'pnpm test', status: 'completed', durationMs: 14 }),
156
+ expect.objectContaining({ title: '运行 bash', detail: 'pnpm lint', status: 'failed', durationMs: 8 }),
157
+ expect.objectContaining({ title: '任务失败', detail: 'lint failed', status: 'failed' }),
158
+ ]);
159
+ });
160
+ it('does not leave interrupted work looking active after a terminal event', () => {
161
+ const interrupted = {
162
+ sessionKey: snapshot.sessionKey,
163
+ latestSequence: 3,
164
+ activeTurnIds: [],
165
+ events: [
166
+ { sequence: 1, recordedAt: 1, sessionKey: snapshot.sessionKey, turnId: 'turn-stop', type: 'turn_start', data: {} },
167
+ { sequence: 2, recordedAt: 2, sessionKey: snapshot.sessionKey, turnId: 'turn-stop', type: 'tool_call', data: { toolName: 'bash', toolUseId: 'slow', args: { command: 'pnpm test' } } },
168
+ { sequence: 3, recordedAt: 3, sessionKey: snapshot.sessionKey, turnId: 'turn-stop', type: 'turn_cancelled', data: { reason: 'user stopped' } },
169
+ ],
170
+ };
171
+ expect(deriveAgentRunSteps(interrupted, { turnId: 'turn-stop' })).toEqual([
172
+ expect.objectContaining({ title: '接收任务', status: 'completed' }),
173
+ expect.objectContaining({ title: '运行 bash', status: 'cancelled' }),
174
+ expect.objectContaining({ title: '任务已停止', status: 'cancelled' }),
175
+ ]);
176
+ });
177
+ it('preserves a completed tool result while a live turn waits for the model', () => {
178
+ const live = {
179
+ sessionKey: snapshot.sessionKey,
180
+ latestSequence: 3,
181
+ activeTurnIds: ['turn-live'],
182
+ events: [
183
+ { sequence: 1, recordedAt: 1, sessionKey: snapshot.sessionKey, turnId: 'turn-live', type: 'turn_start', data: {} },
184
+ { sequence: 2, recordedAt: 2, sessionKey: snapshot.sessionKey, turnId: 'turn-live', type: 'tool_call', data: { toolName: 'bash', toolUseId: 'done', args: { command: 'pnpm test' } } },
185
+ { sequence: 3, recordedAt: 3, sessionKey: snapshot.sessionKey, turnId: 'turn-live', type: 'tool_result', data: { toolName: 'bash', toolUseId: 'done', durationMs: 1 } },
186
+ ],
187
+ };
188
+ const steps = deriveAgentRunSteps(live, { turnId: 'turn-live' });
189
+ expect(steps.at(-2)).toMatchObject({ title: '运行 bash', status: 'completed' });
190
+ expect(steps.at(-1)).toMatchObject({ title: '等待 Agent 返回', status: 'running' });
191
+ });
192
+ it('builds a portable Markdown report for the selected run', () => {
193
+ const reportSnapshot = {
194
+ sessionKey: snapshot.sessionKey,
195
+ latestSequence: 4,
196
+ activeTurnIds: [],
197
+ events: [
198
+ { sequence: 1, recordedAt: 1_000, sessionKey: snapshot.sessionKey, turnId: 'turn-export', type: 'turn_start', data: {} },
199
+ { sequence: 2, recordedAt: 1_100, sessionKey: snapshot.sessionKey, turnId: 'turn-export', type: 'tool_call', data: { toolName: 'edit_file', toolUseId: 'edit-1', args: { file_path: '/workspace/app.ts', old_string: 'false', new_string: 'true' } } },
200
+ { sequence: 3, recordedAt: 1_120, sessionKey: snapshot.sessionKey, turnId: 'turn-export', type: 'tool_result', data: { toolName: 'edit_file', toolUseId: 'edit-1', output: 'done', durationMs: 20 } },
201
+ { sequence: 4, recordedAt: 1_140, sessionKey: snapshot.sessionKey, turnId: 'turn-export', type: 'turn_end', data: {} },
202
+ ],
203
+ };
204
+ const report = buildAgentRunReport(reportSnapshot, {
205
+ run: { turnId: 'turn-export' },
206
+ sessionName: '代码审查',
207
+ taskPrompt: '请修复 `app.ts`',
208
+ workingDirectory: '/workspace',
209
+ safetyMode: 'workspace-write',
210
+ approvalMode: 'ask',
211
+ networkAccess: false,
212
+ });
213
+ expect(report).toContain('# Agent 运行报告');
214
+ expect(report).toContain('**状态:** 已完成');
215
+ expect(report).toContain('请修复 `app.ts`');
216
+ expect(report).toContain('运行 `edit_file`');
217
+ expect(report).toContain('`/workspace/app.ts`');
218
+ expect(report).toContain('```diff');
219
+ expect(report).toContain('+ true');
220
+ });
221
+ it('uses longer Markdown fences for untrusted report content', () => {
222
+ const report = buildAgentRunReport({
223
+ sessionKey: snapshot.sessionKey,
224
+ latestSequence: 3,
225
+ activeTurnIds: [],
226
+ events: [
227
+ { sequence: 1, recordedAt: 1, sessionKey: snapshot.sessionKey, turnId: 'turn-fence', type: 'turn_start', data: {} },
228
+ { sequence: 2, recordedAt: 2, sessionKey: snapshot.sessionKey, turnId: 'turn-fence', type: 'tool_call', data: { toolName: 'bash', toolUseId: 'fence', args: { command: 'echo report' } } },
229
+ { sequence: 3, recordedAt: 3, sessionKey: snapshot.sessionKey, turnId: 'turn-fence', type: 'tool_result', data: { toolName: 'bash', toolUseId: 'fence', output: '```\n<img src=x onerror=alert(1)>\n```' } },
230
+ ],
231
+ }, { run: { turnId: 'turn-fence' }, taskPrompt: '```\n# injected\n```' });
232
+ expect(report).toContain('````text\n```\n# injected\n```\n````');
233
+ expect(report).toContain('````text\n```\n<img src=x onerror=alert(1)>\n```\n````');
234
+ });
235
+ });
@@ -0,0 +1,265 @@
1
+ import {
2
+ buildSandboxSessionKey,
3
+ buildAgentRunReport,
4
+ deriveAgentRunSteps,
5
+ deriveTaskRuns,
6
+ deriveWorkbenchArtifacts,
7
+ loadCachedAgentTrace,
8
+ mergeTraceSnapshot,
9
+ presentTraceEvent,
10
+ summarizeTrace,
11
+ saveCachedAgentTrace,
12
+ type AgentTraceStorage,
13
+ type AgentTraceSnapshot,
14
+ } from './agentTrace.js';
15
+
16
+ const snapshot: AgentTraceSnapshot = {
17
+ sessionKey: 'sandbox:bot:private:user',
18
+ latestSequence: 3,
19
+ activeTurnIds: ['turn-1'],
20
+ events: [
21
+ { sequence: 1, recordedAt: 1, sessionKey: 'sandbox:bot:private:user', turnId: 'turn-1', type: 'turn_start', data: {} },
22
+ { sequence: 2, recordedAt: 2, sessionKey: 'sandbox:bot:private:user', turnId: 'turn-1', type: 'tool_call', data: { toolName: 'lookup' } },
23
+ { sequence: 3, recordedAt: 3, sessionKey: 'sandbox:bot:private:user', turnId: 'turn-1', type: 'usage', data: { usage: { totalTokens: 218 } } },
24
+ ],
25
+ };
26
+
27
+ class MemoryTraceStorage implements AgentTraceStorage {
28
+ readonly values = new Map<string, string>();
29
+ getItem(key: string): string | null { return this.values.get(key) ?? null; }
30
+ setItem(key: string, value: string): void { this.values.set(key, value); }
31
+ }
32
+
33
+ describe('sandbox agent trace helpers', () => {
34
+ it('builds the canonical IM session key', () => {
35
+ expect(buildSandboxSessionKey('sandbox-bot', 'group', 'planning-room')).toBe('sandbox:sandbox-bot:group:planning-room');
36
+ });
37
+
38
+ it('merges incremental trace snapshots without duplicates', () => {
39
+ const merged = mergeTraceSnapshot(snapshot, {
40
+ ...snapshot,
41
+ latestSequence: 4,
42
+ activeTurnIds: [],
43
+ events: [snapshot.events[2], { ...snapshot.events[0], sequence: 4, recordedAt: 4, type: 'turn_end' }],
44
+ });
45
+ expect(merged.events.map((event) => event.sequence)).toEqual([1, 2, 3, 4]);
46
+ expect(merged.activeTurnIds).toEqual([]);
47
+ });
48
+
49
+ it('persists bounded task history across browser and Host restarts', () => {
50
+ const storage = new MemoryTraceStorage();
51
+ expect(saveCachedAgentTrace(snapshot, storage)).toBe(true);
52
+ const restored = loadCachedAgentTrace(snapshot.sessionKey, storage);
53
+ expect(restored).toEqual(snapshot);
54
+
55
+ const afterHostRestart = mergeTraceSnapshot(restored, {
56
+ sessionKey: snapshot.sessionKey,
57
+ latestSequence: 1,
58
+ activeTurnIds: ['turn-new'],
59
+ events: [{
60
+ sequence: 1,
61
+ recordedAt: 100,
62
+ sessionKey: snapshot.sessionKey,
63
+ turnId: 'turn-new',
64
+ type: 'turn_start',
65
+ data: {},
66
+ }],
67
+ });
68
+ expect(afterHostRestart.events.map((event) => event.turnId)).toEqual([
69
+ 'turn-1', 'turn-1', 'turn-1', 'turn-new',
70
+ ]);
71
+ expect(afterHostRestart.latestSequence).toBe(1);
72
+ });
73
+
74
+ it('summarizes operational metrics and presents tool events', () => {
75
+ expect(summarizeTrace(snapshot)).toEqual({ eventCount: 3, toolCount: 1, tokenCount: 218, problemCount: 0, activeTurns: 1 });
76
+ expect(presentTraceEvent(snapshot.events[1])).toMatchObject({ title: '调用 lookup', tone: 'tool' });
77
+ });
78
+
79
+ it('derives stable task lifecycle states from trace terminals', () => {
80
+ const runs = deriveTaskRuns({
81
+ sessionKey: snapshot.sessionKey,
82
+ latestSequence: 9,
83
+ activeTurnIds: ['turn-running'],
84
+ events: [
85
+ { sequence: 1, recordedAt: 10, sessionKey: snapshot.sessionKey, turnId: 'turn-done', type: 'turn_start', data: { sourceMessageId: 'msg-done' } },
86
+ { sequence: 2, recordedAt: 20, sessionKey: snapshot.sessionKey, turnId: 'turn-done', type: 'tool_call', data: { toolName: 'write_file' } },
87
+ { sequence: 3, recordedAt: 30, sessionKey: snapshot.sessionKey, turnId: 'turn-done', type: 'usage', data: { usage: { totalTokens: 144 } } },
88
+ { sequence: 4, recordedAt: 40, sessionKey: snapshot.sessionKey, turnId: 'turn-done', type: 'turn_end', data: {} },
89
+ { sequence: 5, recordedAt: 50, sessionKey: snapshot.sessionKey, turnId: 'turn-failed', type: 'turn_start', data: {} },
90
+ { sequence: 6, recordedAt: 60, sessionKey: snapshot.sessionKey, turnId: 'turn-failed', type: 'error', data: { error: { message: 'boom' } } },
91
+ { sequence: 7, recordedAt: 70, sessionKey: snapshot.sessionKey, turnId: 'turn-running', type: 'turn_start', data: {} },
92
+ { sequence: 8, recordedAt: 80, sessionKey: snapshot.sessionKey, turnId: 'turn-running', type: 'iteration_start', data: {} },
93
+ { sequence: 9, recordedAt: 45, sessionKey: snapshot.sessionKey, turnId: 'turn-incomplete', type: 'turn_start', data: {} },
94
+ ],
95
+ });
96
+ expect(runs.map((run) => ({ id: run.turnId, status: run.status }))).toEqual([
97
+ { id: 'turn-running', status: 'running' },
98
+ { id: 'turn-failed', status: 'failed' },
99
+ { id: 'turn-incomplete', status: 'failed' },
100
+ { id: 'turn-done', status: 'completed' },
101
+ ]);
102
+ expect(runs[3]).toMatchObject({ sourceMessageId: 'msg-done', toolCount: 1, tokenCount: 144, durationMs: 30 });
103
+ });
104
+
105
+ it('projects file edits and test commands into workbench artifacts', () => {
106
+ const artifacts = deriveWorkbenchArtifacts({
107
+ sessionKey: snapshot.sessionKey,
108
+ latestSequence: 4,
109
+ activeTurnIds: [],
110
+ events: [
111
+ { sequence: 1, recordedAt: 1, sessionKey: snapshot.sessionKey, turnId: 'turn-1', type: 'tool_call', data: {
112
+ toolName: 'edit_file', toolUseId: 'edit-1', args: {
113
+ file_path: '/workspace/src/app.ts', old_string: 'const draft = true', new_string: 'const draft = false',
114
+ },
115
+ } },
116
+ { sequence: 2, recordedAt: 2, sessionKey: snapshot.sessionKey, turnId: 'turn-1', type: 'tool_result', data: {
117
+ toolName: 'edit_file', toolUseId: 'edit-1', output: 'Edited /workspace/src/app.ts', durationMs: 8,
118
+ } },
119
+ { sequence: 3, recordedAt: 3, sessionKey: snapshot.sessionKey, turnId: 'turn-1', type: 'tool_call', data: {
120
+ toolName: 'bash', toolUseId: 'test-1', args: { command: 'pnpm test' },
121
+ } },
122
+ { sequence: 4, recordedAt: 4, sessionKey: snapshot.sessionKey, turnId: 'turn-1', type: 'tool_result', data: {
123
+ toolName: 'bash', toolUseId: 'test-1', output: '12 tests passed', durationMs: 42,
124
+ } },
125
+ ],
126
+ });
127
+ expect(artifacts).toHaveLength(2);
128
+ const file = artifacts.find((artifact) => artifact.kind === 'file-change');
129
+ const test = artifacts.find((artifact) => artifact.kind === 'test');
130
+ expect(file).toMatchObject({ status: 'completed', path: '/workspace/src/app.ts' });
131
+ expect(file?.diff).toContain('+++ b/workspace/src/app.ts');
132
+ expect(file?.diff).toContain('- const draft = true');
133
+ expect(file?.diff).toContain('+ const draft = false');
134
+ expect(test).toMatchObject({ status: 'completed', title: 'pnpm test', detail: '12 tests passed' });
135
+ });
136
+
137
+ it('does not cross-wire reused tool ids across turns or runtime generations', () => {
138
+ const events = [
139
+ { runtimeId: 'runtime-old', sequence: 1, recordedAt: 1, sessionKey: snapshot.sessionKey, turnId: 'turn-reused', type: 'tool_call', data: { toolName: 'bash', toolUseId: 'call_1', args: { command: 'pnpm test' } } },
140
+ { runtimeId: 'runtime-old', sequence: 2, recordedAt: 2, sessionKey: snapshot.sessionKey, turnId: 'turn-reused', type: 'tool_result', data: { toolName: 'bash', toolUseId: 'call_1', output: 'old result' } },
141
+ { runtimeId: 'runtime-new', sequence: 1, recordedAt: 3, sessionKey: snapshot.sessionKey, turnId: 'turn-reused', type: 'tool_call', data: { toolName: 'bash', toolUseId: 'call_1', args: { command: 'pnpm build' } } },
142
+ { runtimeId: 'runtime-new', sequence: 2, recordedAt: 4, sessionKey: snapshot.sessionKey, turnId: 'turn-reused', type: 'tool_result', data: { toolName: 'bash', toolUseId: 'call_1', output: 'new result' } },
143
+ ] satisfies AgentTraceSnapshot['events'];
144
+ const artifacts = deriveWorkbenchArtifacts({
145
+ runtimeId: 'runtime-new',
146
+ sessionKey: snapshot.sessionKey,
147
+ latestSequence: 2,
148
+ activeTurnIds: [],
149
+ events,
150
+ });
151
+ expect(artifacts.map((artifact) => artifact.detail)).toEqual(['new result', 'old result']);
152
+ expect(new Set(artifacts.map((artifact) => artifact.id)).size).toBe(2);
153
+ expect(deriveTaskRuns({
154
+ runtimeId: 'runtime-new', sessionKey: snapshot.sessionKey, latestSequence: 2, activeTurnIds: [], events,
155
+ }).map((run) => run.id)).toEqual(['runtime-new:turn-reused', 'runtime-old:turn-reused']);
156
+ });
157
+
158
+ it('projects a turn into readable, correlated workbench steps', () => {
159
+ const runSnapshot: AgentTraceSnapshot = {
160
+ sessionKey: snapshot.sessionKey,
161
+ latestSequence: 8,
162
+ activeTurnIds: [],
163
+ events: [
164
+ { sequence: 1, recordedAt: 10, sessionKey: snapshot.sessionKey, turnId: 'turn-report', type: 'turn_start', data: {} },
165
+ { sequence: 2, recordedAt: 12, sessionKey: snapshot.sessionKey, turnId: 'turn-report', type: 'capability_resolution', data: { tools: ['bash'], skills: [] } },
166
+ { sequence: 3, recordedAt: 14, sessionKey: snapshot.sessionKey, turnId: 'turn-report', type: 'iteration_start', data: { iteration: 1, maxIterations: 8 } },
167
+ { sequence: 4, recordedAt: 16, sessionKey: snapshot.sessionKey, turnId: 'turn-report', type: 'tool_call', data: { toolName: 'bash', toolUseId: 'call-1', args: { command: 'pnpm test' } } },
168
+ { sequence: 5, recordedAt: 30, sessionKey: snapshot.sessionKey, turnId: 'turn-report', type: 'tool_result', data: { toolName: 'bash', toolUseId: 'call-1', output: '18 tests passed', durationMs: 14 } },
169
+ { sequence: 6, recordedAt: 32, sessionKey: snapshot.sessionKey, turnId: 'turn-report', type: 'tool_call', data: { toolName: 'bash', toolUseId: 'call-2', args: { command: 'pnpm lint' } } },
170
+ { sequence: 7, recordedAt: 40, sessionKey: snapshot.sessionKey, turnId: 'turn-report', type: 'tool_failed', data: { toolName: 'bash', toolUseId: 'call-2', error: 'lint failed', durationMs: 8 } },
171
+ { sequence: 8, recordedAt: 42, sessionKey: snapshot.sessionKey, turnId: 'turn-report', type: 'error', data: { error: 'lint failed' } },
172
+ ],
173
+ };
174
+
175
+ expect(deriveAgentRunSteps(runSnapshot, { turnId: 'turn-report' })).toEqual([
176
+ expect.objectContaining({ title: '接收任务', status: 'completed' }),
177
+ expect.objectContaining({ title: '准备运行能力', detail: '1 tools · 0 skills', status: 'completed' }),
178
+ expect.objectContaining({ title: '推理迭代 1', status: 'completed' }),
179
+ expect.objectContaining({ title: '运行 bash', detail: 'pnpm test', status: 'completed', durationMs: 14 }),
180
+ expect.objectContaining({ title: '运行 bash', detail: 'pnpm lint', status: 'failed', durationMs: 8 }),
181
+ expect.objectContaining({ title: '任务失败', detail: 'lint failed', status: 'failed' }),
182
+ ]);
183
+ });
184
+
185
+ it('does not leave interrupted work looking active after a terminal event', () => {
186
+ const interrupted: AgentTraceSnapshot = {
187
+ sessionKey: snapshot.sessionKey,
188
+ latestSequence: 3,
189
+ activeTurnIds: [],
190
+ events: [
191
+ { sequence: 1, recordedAt: 1, sessionKey: snapshot.sessionKey, turnId: 'turn-stop', type: 'turn_start', data: {} },
192
+ { sequence: 2, recordedAt: 2, sessionKey: snapshot.sessionKey, turnId: 'turn-stop', type: 'tool_call', data: { toolName: 'bash', toolUseId: 'slow', args: { command: 'pnpm test' } } },
193
+ { sequence: 3, recordedAt: 3, sessionKey: snapshot.sessionKey, turnId: 'turn-stop', type: 'turn_cancelled', data: { reason: 'user stopped' } },
194
+ ],
195
+ };
196
+ expect(deriveAgentRunSteps(interrupted, { turnId: 'turn-stop' })).toEqual([
197
+ expect.objectContaining({ title: '接收任务', status: 'completed' }),
198
+ expect.objectContaining({ title: '运行 bash', status: 'cancelled' }),
199
+ expect.objectContaining({ title: '任务已停止', status: 'cancelled' }),
200
+ ]);
201
+ });
202
+
203
+ it('preserves a completed tool result while a live turn waits for the model', () => {
204
+ const live: AgentTraceSnapshot = {
205
+ sessionKey: snapshot.sessionKey,
206
+ latestSequence: 3,
207
+ activeTurnIds: ['turn-live'],
208
+ events: [
209
+ { sequence: 1, recordedAt: 1, sessionKey: snapshot.sessionKey, turnId: 'turn-live', type: 'turn_start', data: {} },
210
+ { sequence: 2, recordedAt: 2, sessionKey: snapshot.sessionKey, turnId: 'turn-live', type: 'tool_call', data: { toolName: 'bash', toolUseId: 'done', args: { command: 'pnpm test' } } },
211
+ { sequence: 3, recordedAt: 3, sessionKey: snapshot.sessionKey, turnId: 'turn-live', type: 'tool_result', data: { toolName: 'bash', toolUseId: 'done', durationMs: 1 } },
212
+ ],
213
+ };
214
+ const steps = deriveAgentRunSteps(live, { turnId: 'turn-live' });
215
+ expect(steps.at(-2)).toMatchObject({ title: '运行 bash', status: 'completed' });
216
+ expect(steps.at(-1)).toMatchObject({ title: '等待 Agent 返回', status: 'running' });
217
+ });
218
+
219
+ it('builds a portable Markdown report for the selected run', () => {
220
+ const reportSnapshot: AgentTraceSnapshot = {
221
+ sessionKey: snapshot.sessionKey,
222
+ latestSequence: 4,
223
+ activeTurnIds: [],
224
+ events: [
225
+ { sequence: 1, recordedAt: 1_000, sessionKey: snapshot.sessionKey, turnId: 'turn-export', type: 'turn_start', data: {} },
226
+ { sequence: 2, recordedAt: 1_100, sessionKey: snapshot.sessionKey, turnId: 'turn-export', type: 'tool_call', data: { toolName: 'edit_file', toolUseId: 'edit-1', args: { file_path: '/workspace/app.ts', old_string: 'false', new_string: 'true' } } },
227
+ { sequence: 3, recordedAt: 1_120, sessionKey: snapshot.sessionKey, turnId: 'turn-export', type: 'tool_result', data: { toolName: 'edit_file', toolUseId: 'edit-1', output: 'done', durationMs: 20 } },
228
+ { sequence: 4, recordedAt: 1_140, sessionKey: snapshot.sessionKey, turnId: 'turn-export', type: 'turn_end', data: {} },
229
+ ],
230
+ };
231
+
232
+ const report = buildAgentRunReport(reportSnapshot, {
233
+ run: { turnId: 'turn-export' },
234
+ sessionName: '代码审查',
235
+ taskPrompt: '请修复 `app.ts`',
236
+ workingDirectory: '/workspace',
237
+ safetyMode: 'workspace-write',
238
+ approvalMode: 'ask',
239
+ networkAccess: false,
240
+ });
241
+
242
+ expect(report).toContain('# Agent 运行报告');
243
+ expect(report).toContain('**状态:** 已完成');
244
+ expect(report).toContain('请修复 `app.ts`');
245
+ expect(report).toContain('运行 `edit_file`');
246
+ expect(report).toContain('`/workspace/app.ts`');
247
+ expect(report).toContain('```diff');
248
+ expect(report).toContain('+ true');
249
+ });
250
+
251
+ it('uses longer Markdown fences for untrusted report content', () => {
252
+ const report = buildAgentRunReport({
253
+ sessionKey: snapshot.sessionKey,
254
+ latestSequence: 3,
255
+ activeTurnIds: [],
256
+ events: [
257
+ { sequence: 1, recordedAt: 1, sessionKey: snapshot.sessionKey, turnId: 'turn-fence', type: 'turn_start', data: {} },
258
+ { sequence: 2, recordedAt: 2, sessionKey: snapshot.sessionKey, turnId: 'turn-fence', type: 'tool_call', data: { toolName: 'bash', toolUseId: 'fence', args: { command: 'echo report' } } },
259
+ { sequence: 3, recordedAt: 3, sessionKey: snapshot.sessionKey, turnId: 'turn-fence', type: 'tool_result', data: { toolName: 'bash', toolUseId: 'fence', output: '```\n<img src=x onerror=alert(1)>\n```' } },
260
+ ],
261
+ }, { run: { turnId: 'turn-fence' }, taskPrompt: '```\n# injected\n```' });
262
+ expect(report).toContain('````text\n```\n# injected\n```\n````');
263
+ expect(report).toContain('````text\n```\n<img src=x onerror=alert(1)>\n```\n````');
264
+ });
265
+ });