@zhin.js/adapter-sandbox 1.1.0 → 1.1.3

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.
package/src/protocol.ts CHANGED
@@ -1,8 +1,7 @@
1
1
  /** Sandbox WebSocket wire protocol helpers (no legacy Adapter/Endpoint). */
2
2
 
3
3
  import { readFileSync } from 'node:fs';
4
- import { isMediaRef } from '@zhin.js/core';
5
- import type { ConversationKind, ConversationRef } from '@zhin.js/im-contract';
4
+ import { isMediaRef, type ConversationKind, type ConversationRef } from '@zhin.js/im-contract';
6
5
  import { formatCompact, getLogger } from '@zhin.js/logger';
7
6
  import {
8
7
  normalizeSandboxAgentRunConfig,
@@ -43,33 +42,17 @@ export type ResolvedSandboxBot = {
43
42
  readonly randomNamePerConnection: boolean;
44
43
  };
45
44
 
46
- export interface SandboxAdapterConfig {
47
- /** Runtime expands `endpoints[i]` onto the top level — prefer these. */
48
- readonly context?: string;
45
+ /** One endpoint config after AdapterIndex expands `plugins.<instanceKey>.endpoints`. */
46
+ export interface SandboxEndpointConfig {
49
47
  readonly id?: string;
50
48
  readonly owner?: string;
51
- /** Legacy shape: endpoint entries nested under `endpoints[]`. */
52
- readonly endpoints?: ReadonlyArray<{
53
- readonly context?: string;
54
- readonly id?: string;
55
- readonly owner?: string;
56
- }>;
57
49
  }
58
50
 
59
51
  export function resolveSandboxEndpoint(
60
- appConfig: SandboxAdapterConfig,
52
+ config: SandboxEndpointConfig,
61
53
  ): ResolvedSandboxBot {
62
- const entry = appConfig.endpoints?.find((item) => item.context === 'sandbox');
63
- const fixedName = typeof appConfig.id === 'string' && appConfig.id
64
- ? appConfig.id
65
- : typeof entry?.id === 'string' && entry.id
66
- ? entry.id
67
- : undefined;
68
- const id = fixedName || process.env.SANDBOX_BOT_NAME || 'sandbox-bot';
69
- const owner = (typeof appConfig.owner === 'string' && appConfig.owner)
70
- || (typeof entry?.owner === 'string' && entry.owner)
71
- || process.env.SANDBOX_BOT_OWNER
72
- || 'sandbox-user';
54
+ const id = optionalEndpointField(config.id) ?? 'sandbox-bot';
55
+ const owner = optionalEndpointField(config.owner) ?? 'sandbox-user';
73
56
  return {
74
57
  context: 'sandbox',
75
58
  id,
@@ -81,6 +64,10 @@ export function resolveSandboxEndpoint(
81
64
  };
82
65
  }
83
66
 
67
+ function optionalEndpointField(value: unknown): string | undefined {
68
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined;
69
+ }
70
+
84
71
  export function bindSandboxWsSocket(
85
72
  ws: SandboxWsSocket,
86
73
  handlers: {
package/src/run-config.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export type SandboxSafetyMode = 'read-only' | 'workspace-write' | 'danger-full-access';
2
- export type SandboxApprovalMode = 'ask' | 'deny' | 'allow';
2
+ export type SandboxApprovalMode = 'ask' | 'auto' | 'bypass';
3
3
 
4
4
  export interface SandboxAgentRunConfig {
5
5
  readonly workingDirectory: string;
@@ -16,7 +16,7 @@ export const DEFAULT_SANDBOX_AGENT_RUN_CONFIG: SandboxAgentRunConfig = Object.fr
16
16
  });
17
17
 
18
18
  const SAFETY_MODES = new Set<SandboxSafetyMode>(['read-only', 'workspace-write', 'danger-full-access']);
19
- const APPROVAL_MODES = new Set<SandboxApprovalMode>(['ask', 'deny', 'allow']);
19
+ const APPROVAL_MODES = new Set<SandboxApprovalMode>(['ask', 'auto', 'bypass']);
20
20
 
21
21
  export function normalizeSandboxAgentRunConfig(value: unknown): SandboxAgentRunConfig | undefined {
22
22
  if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
@@ -1,33 +0,0 @@
1
- ---
2
- name: sandbox
3
- platforms:
4
- - sandbox
5
- description: >-
6
- 沙箱测试适配器:基于 WebSocket 的本地测试环境,提供浏览器内聊天 UI,
7
- 无需第三方平台即可调试插件和命令。开发调试专用,无额外 AI 工具。
8
- keywords:
9
- - sandbox
10
- - adapter:sandbox
11
- - test
12
- - 测试
13
- - 沙箱
14
- - development
15
- - 开发
16
- - debug
17
- - 调试
18
- tags:
19
- - sandbox
20
- - testing
21
- - development
22
- tools: []
23
- ---
24
-
25
- # 沙箱测试适配器
26
-
27
- 本地开发调试专用。通过浏览器 Web UI 模拟聊天,无需第三方平台凭据。无 AI 工具可调用。
28
-
29
- ## 使用场景
30
-
31
- - 开发新插件时在本地模拟消息收发
32
- - 调试命令路由和中间件逻辑
33
- - 依赖 HTTP 服务和 Console 插件提供 Web 界面
@@ -1,235 +0,0 @@
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
- });
@@ -1,265 +0,0 @@
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
- });