@zhin.js/adapter-sandbox 7.0.12 → 8.0.0

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,646 @@
1
+ import { getSandboxApiBase, getSandboxAuthHeaders } from './sandboxTransport.js';
2
+
3
+ export type SandboxScope = 'private' | 'group' | 'channel';
4
+
5
+ export interface AgentTraceEvent {
6
+ readonly runtimeId?: string;
7
+ readonly sequence: number;
8
+ readonly recordedAt: number;
9
+ readonly sessionKey: string;
10
+ readonly turnId: string;
11
+ readonly type: string;
12
+ readonly data: Record<string, unknown>;
13
+ }
14
+
15
+ export interface AgentTraceSnapshot {
16
+ readonly runtimeId?: string;
17
+ readonly sessionKey: string;
18
+ readonly events: readonly AgentTraceEvent[];
19
+ readonly latestSequence: number;
20
+ readonly activeTurnIds: readonly string[];
21
+ }
22
+
23
+ export interface AgentTraceSummary {
24
+ readonly eventCount: number;
25
+ readonly toolCount: number;
26
+ readonly tokenCount: number;
27
+ readonly problemCount: number;
28
+ readonly activeTurns: number;
29
+ }
30
+
31
+ export interface AgentTraceStorage {
32
+ getItem(key: string): string | null;
33
+ setItem(key: string, value: string): void;
34
+ }
35
+
36
+ export type AgentTaskStatus = 'running' | 'completed' | 'failed' | 'cancelled';
37
+
38
+ export interface AgentRunIdentity {
39
+ readonly runtimeId?: string;
40
+ readonly turnId: string;
41
+ }
42
+
43
+ export interface AgentTaskRun extends AgentRunIdentity {
44
+ readonly id: string;
45
+ readonly sourceMessageId?: string;
46
+ readonly status: AgentTaskStatus;
47
+ readonly startedAt: number;
48
+ readonly endedAt?: number;
49
+ readonly durationMs?: number;
50
+ readonly eventCount: number;
51
+ readonly toolCount: number;
52
+ readonly tokenCount: number;
53
+ readonly problemCount: number;
54
+ }
55
+
56
+ export interface WorkbenchArtifact {
57
+ readonly id: string;
58
+ readonly runtimeId?: string;
59
+ readonly turnId: string;
60
+ readonly kind: 'file-change' | 'test' | 'command';
61
+ readonly title: string;
62
+ readonly path?: string;
63
+ readonly status: 'running' | 'completed' | 'failed' | 'denied' | 'cancelled';
64
+ readonly detail?: string;
65
+ readonly diff?: string;
66
+ readonly durationMs?: number;
67
+ readonly recordedAt: number;
68
+ }
69
+
70
+ export type AgentRunStepStatus = 'running' | 'completed' | 'failed' | 'denied' | 'cancelled';
71
+
72
+ export interface AgentRunStep {
73
+ readonly id: string;
74
+ readonly title: string;
75
+ readonly detail?: string;
76
+ readonly status: AgentRunStepStatus;
77
+ readonly recordedAt: number;
78
+ readonly durationMs?: number;
79
+ }
80
+
81
+ export interface AgentRunReportContext {
82
+ readonly run?: AgentRunIdentity;
83
+ readonly sessionName?: string;
84
+ readonly taskPrompt?: string;
85
+ readonly workingDirectory?: string;
86
+ readonly safetyMode?: string;
87
+ readonly approvalMode?: string;
88
+ readonly networkAccess?: boolean;
89
+ }
90
+
91
+ const problemTypes = new Set(['tool_denied', 'tool_failed', 'tool_cancelled', 'turn_cancelled', 'budget_exceeded', 'error']);
92
+ const toolTypes = new Set(['tool_call', 'mcp_tool_call']);
93
+ const terminalTypes = new Set(['turn_end', 'turn_cancelled', 'budget_exceeded', 'error']);
94
+ const toolTerminalTypes = new Set(['tool_result', 'tool_denied', 'tool_failed', 'tool_cancelled']);
95
+ const traceCachePrefix = 'zhin.sandbox.agent-trace.v1:';
96
+
97
+ export function buildSandboxSessionKey(endpointId: string, scope: SandboxScope, sceneId: string): string {
98
+ return `sandbox:${endpointId || 'sandbox-bot'}:${scope}:${sceneId}`;
99
+ }
100
+
101
+ export function agentStudioPath(sessionKey: string): string {
102
+ const params = new URLSearchParams({ sessionKey });
103
+ return `/agent/studio?${params}`;
104
+ }
105
+
106
+ export function mergeTraceSnapshot(
107
+ previous: AgentTraceSnapshot | null,
108
+ incoming: AgentTraceSnapshot,
109
+ ): AgentTraceSnapshot {
110
+ if (!previous || previous.sessionKey !== incoming.sessionKey) return incoming;
111
+ const events = [...previous.events, ...incoming.events]
112
+ .filter((event, index, all) => all.findIndex((candidate) => (
113
+ candidate.runtimeId === event.runtimeId
114
+ && candidate.turnId === event.turnId
115
+ && candidate.sequence === event.sequence
116
+ )) === index)
117
+ .sort((left, right) => left.recordedAt - right.recordedAt || left.sequence - right.sequence)
118
+ .slice(-300);
119
+ return { ...incoming, events };
120
+ }
121
+
122
+ export function loadCachedAgentTrace(
123
+ sessionKey: string,
124
+ storage: AgentTraceStorage | undefined = browserTraceStorage(),
125
+ ): AgentTraceSnapshot | null {
126
+ if (!storage) return null;
127
+ try {
128
+ const raw = storage.getItem(`${traceCachePrefix}${sessionKey}`);
129
+ if (!raw) return null;
130
+ const parsed = JSON.parse(raw) as Record<string, unknown>;
131
+ if (parsed.sessionKey !== sessionKey || !Array.isArray(parsed.events)) return null;
132
+ const events = parsed.events.map(parseCachedTraceEvent)
133
+ .filter((event): event is AgentTraceEvent => event != null)
134
+ .slice(-300);
135
+ const activeTurnIds = Array.isArray(parsed.activeTurnIds)
136
+ ? parsed.activeTurnIds.filter((value): value is string => typeof value === 'string').slice(-20)
137
+ : [];
138
+ return {
139
+ ...(typeof parsed.runtimeId === 'string' ? { runtimeId: parsed.runtimeId } : {}),
140
+ sessionKey,
141
+ events,
142
+ latestSequence: nonNegativeNumber(parsed.latestSequence),
143
+ activeTurnIds,
144
+ };
145
+ } catch {
146
+ return null;
147
+ }
148
+ }
149
+
150
+ export function saveCachedAgentTrace(
151
+ snapshot: AgentTraceSnapshot,
152
+ storage: AgentTraceStorage | undefined = browserTraceStorage(),
153
+ ): boolean {
154
+ if (!storage) return false;
155
+ try {
156
+ storage.setItem(`${traceCachePrefix}${snapshot.sessionKey}`, JSON.stringify({
157
+ ...snapshot,
158
+ events: snapshot.events.slice(-300),
159
+ activeTurnIds: snapshot.activeTurnIds.slice(-20),
160
+ }));
161
+ return true;
162
+ } catch {
163
+ return false;
164
+ }
165
+ }
166
+
167
+ export function summarizeTrace(snapshot: AgentTraceSnapshot | null): AgentTraceSummary {
168
+ if (!snapshot) return { eventCount: 0, toolCount: 0, tokenCount: 0, problemCount: 0, activeTurns: 0 };
169
+ return {
170
+ eventCount: snapshot.latestSequence,
171
+ toolCount: snapshot.events.filter((event) => toolTypes.has(event.type)).length,
172
+ tokenCount: snapshot.events
173
+ .filter((event) => event.type === 'usage')
174
+ .reduce((total, event) => total + usageTotal(event.data), 0),
175
+ problemCount: snapshot.events.filter((event) => problemTypes.has(event.type)).length,
176
+ activeTurns: snapshot.activeTurnIds.length,
177
+ };
178
+ }
179
+
180
+ export function deriveTaskRuns(snapshot: AgentTraceSnapshot | null): AgentTaskRun[] {
181
+ if (!snapshot) return [];
182
+ const active = new Set(snapshot.activeTurnIds);
183
+ const byRun = new Map<string, AgentTraceEvent[]>();
184
+ for (const event of snapshot.events) {
185
+ const key = runCorrelationKey(event);
186
+ const events = byRun.get(key) ?? [];
187
+ events.push(event);
188
+ byRun.set(key, events);
189
+ }
190
+ return [...byRun.entries()].map(([id, events]) => {
191
+ const identity = events[0]!;
192
+ const turnId = identity.turnId;
193
+ const runtimeId = identity.runtimeId;
194
+ const startedAt = events.find((event) => event.type === 'turn_start')?.recordedAt ?? events[0]?.recordedAt ?? 0;
195
+ const terminal = [...events].reverse().find((event) => terminalTypes.has(event.type));
196
+ const sourceMessageId = stringValue(events.find((event) => event.type === 'turn_start')?.data.sourceMessageId);
197
+ const belongsToCurrentRuntime = snapshot.runtimeId === undefined || runtimeId === snapshot.runtimeId;
198
+ const status: AgentTaskStatus = belongsToCurrentRuntime && active.has(turnId)
199
+ ? 'running'
200
+ : terminal?.type === 'turn_end'
201
+ ? 'completed'
202
+ : terminal?.type === 'turn_cancelled'
203
+ ? 'cancelled'
204
+ : terminal
205
+ ? 'failed'
206
+ : 'failed';
207
+ const endedAt = terminal?.recordedAt;
208
+ return {
209
+ id,
210
+ ...(runtimeId ? { runtimeId } : {}),
211
+ ...(sourceMessageId ? { sourceMessageId } : {}),
212
+ turnId,
213
+ status,
214
+ startedAt,
215
+ ...(endedAt !== undefined ? { endedAt, durationMs: Math.max(0, endedAt - startedAt) } : {}),
216
+ eventCount: events.length,
217
+ toolCount: events.filter((event) => event.type === 'tool_call' || event.type === 'mcp_tool_call').length,
218
+ tokenCount: events.filter((event) => event.type === 'usage').reduce((total, event) => total + usageTotal(event.data), 0),
219
+ problemCount: events.filter((event) => problemTypes.has(event.type)).length,
220
+ };
221
+ }).sort((left, right) => right.startedAt - left.startedAt);
222
+ }
223
+
224
+ export function deriveWorkbenchArtifacts(snapshot: AgentTraceSnapshot | null): WorkbenchArtifact[] {
225
+ if (!snapshot) return [];
226
+ const outcomes = new Map<string, AgentTraceEvent>();
227
+ for (const event of snapshot.events) {
228
+ if (!toolTerminalTypes.has(event.type)) continue;
229
+ const toolUseId = stringValue(event.data.toolUseId);
230
+ if (toolUseId) outcomes.set(toolCorrelationKey(event, toolUseId), event);
231
+ }
232
+ return snapshot.events.flatMap((event): WorkbenchArtifact[] => {
233
+ if (event.type !== 'tool_call') return [];
234
+ const toolName = stringValue(event.data.toolName);
235
+ const toolUseId = stringValue(event.data.toolUseId) || `tool-${event.sequence}`;
236
+ const args = recordValue(event.data.args);
237
+ const artifactId = toolCorrelationKey(event, toolUseId);
238
+ const outcome = outcomes.get(artifactId);
239
+ const status = artifactStatus(outcome);
240
+ const durationMs = numberValue(outcome?.data.durationMs);
241
+ const common = {
242
+ id: artifactId,
243
+ ...(event.runtimeId ? { runtimeId: event.runtimeId } : {}),
244
+ turnId: event.turnId,
245
+ status,
246
+ ...(durationMs !== undefined ? { durationMs } : {}),
247
+ recordedAt: event.recordedAt,
248
+ } as const;
249
+ if (toolName === 'write_file' || toolName === 'edit_file') {
250
+ const filePath = stringValue(args.file_path ?? args.path) || 'unknown file';
251
+ return [{
252
+ ...common,
253
+ kind: 'file-change',
254
+ title: toolName === 'write_file' ? '写入文件' : '编辑文件',
255
+ path: filePath,
256
+ detail: artifactOutputDetail(outcome),
257
+ diff: fileDiff(toolName, args, filePath),
258
+ }];
259
+ }
260
+ if (toolName === 'bash') {
261
+ const command = stringValue(args.command);
262
+ if (!command) return [];
263
+ return [{
264
+ ...common,
265
+ kind: isTestCommand(command) ? 'test' : 'command',
266
+ title: command.slice(0, 160),
267
+ detail: artifactOutputDetail(outcome),
268
+ }];
269
+ }
270
+ return [];
271
+ }).sort((left, right) => right.recordedAt - left.recordedAt);
272
+ }
273
+
274
+ export function deriveAgentRunSteps(snapshot: AgentTraceSnapshot | null, run: AgentRunIdentity | undefined): AgentRunStep[] {
275
+ if (!snapshot || !run) return [];
276
+ const { turnId } = run;
277
+ const events = snapshot.events.filter((event) => matchesRun(event, run));
278
+ const outcomes = new Map<string, AgentTraceEvent>();
279
+ for (const event of events) {
280
+ if (!toolTerminalTypes.has(event.type)) continue;
281
+ const toolUseId = stringValue(event.data.toolUseId);
282
+ if (toolUseId) outcomes.set(toolCorrelationKey(event, toolUseId), event);
283
+ }
284
+
285
+ const steps = events.flatMap((event): AgentRunStep[] => {
286
+ const common = { id: `${event.runtimeId ?? 'legacy'}:${turnId}:${event.sequence}`, recordedAt: event.recordedAt } as const;
287
+ if (event.type === 'turn_start') {
288
+ return [{ ...common, title: '接收任务', detail: shortTurn(turnId), status: 'completed' }];
289
+ }
290
+ if (event.type === 'capability_resolution') {
291
+ return [{
292
+ ...common,
293
+ title: '准备运行能力',
294
+ detail: `${arrayLength(event.data.tools)} tools · ${arrayLength(event.data.skills)} skills`,
295
+ status: 'completed',
296
+ }];
297
+ }
298
+ if (event.type === 'iteration_start') {
299
+ return [{
300
+ ...common,
301
+ title: `推理迭代 ${String(event.data.iteration ?? '')}`.trim(),
302
+ detail: event.data.maxIterations == null ? undefined : `上限 ${String(event.data.maxIterations)}`,
303
+ status: 'completed',
304
+ }];
305
+ }
306
+ if (event.type === 'tool_call') {
307
+ const toolUseId = stringValue(event.data.toolUseId) || `tool-${event.sequence}`;
308
+ const outcome = outcomes.get(toolCorrelationKey(event, toolUseId));
309
+ const args = recordValue(event.data.args);
310
+ const toolName = stringValue(event.data.toolName) || 'tool';
311
+ const detail = toolStepDetail(toolName, args, toolUseId);
312
+ const durationMs = numberValue(outcome?.data.durationMs);
313
+ return [{
314
+ ...common,
315
+ id: toolCorrelationKey(event, toolUseId),
316
+ title: `运行 ${toolName}`,
317
+ ...(detail ? { detail } : {}),
318
+ status: artifactStatus(outcome),
319
+ ...(durationMs !== undefined ? { durationMs } : {}),
320
+ }];
321
+ }
322
+ if (event.type === 'subagent_start') {
323
+ return [{
324
+ ...common,
325
+ title: `委派 ${stringValue(event.data.agentName) || '子 Agent'}`,
326
+ detail: preview(event.data.description) || undefined,
327
+ status: 'running',
328
+ }];
329
+ }
330
+ if (event.type === 'subagent_end') {
331
+ const failed = event.data.status === 'error';
332
+ return [{
333
+ ...common,
334
+ title: failed ? '子 Agent 失败' : '子 Agent 返回',
335
+ detail: preview(event.data.summary ?? event.data.error) || undefined,
336
+ status: failed ? 'failed' : 'completed',
337
+ }];
338
+ }
339
+ if (event.type === 'turn_end') return [{ ...common, title: '任务完成', detail: 'Agent 已返回结果', status: 'completed' }];
340
+ if (event.type === 'turn_cancelled') return [{ ...common, title: '任务已停止', detail: preview(event.data.reason) || undefined, status: 'cancelled' }];
341
+ if (event.type === 'budget_exceeded') return [{ ...common, title: '达到预算上限', detail: preview(event.data.budget) || undefined, status: 'failed' }];
342
+ if (event.type === 'error') return [{ ...common, title: '任务失败', detail: preview(event.data.error) || undefined, status: 'failed' }];
343
+ return [];
344
+ });
345
+
346
+ const task = deriveTaskRuns(snapshot).find((candidate) => candidate.id === runIdentityKey(run));
347
+ for (let index = 0; index < steps.length; index += 1) {
348
+ const step = steps[index]!;
349
+ if (step.status !== 'running') continue;
350
+ if (task?.status === 'cancelled') steps[index] = { ...step, status: 'cancelled' };
351
+ else if (task?.status === 'failed') steps[index] = { ...step, status: 'failed' };
352
+ else if (task?.status === 'completed' || index < steps.length - 1) steps[index] = { ...step, status: 'completed' };
353
+ }
354
+ if (task?.status === 'running' && steps.length > 0 && steps.at(-1)?.status !== 'running') {
355
+ steps.push({
356
+ id: `${task.id}:waiting`,
357
+ title: '等待 Agent 返回',
358
+ detail: '任务仍在运行',
359
+ status: 'running',
360
+ recordedAt: events.at(-1)?.recordedAt ?? task.startedAt,
361
+ });
362
+ }
363
+ return steps;
364
+ }
365
+
366
+ export function buildAgentRunReport(snapshot: AgentTraceSnapshot | null, context: AgentRunReportContext = {}): string {
367
+ const runs = deriveTaskRuns(snapshot);
368
+ const run = context.run ? runs.find((candidate) => candidate.id === runIdentityKey(context.run)) : runs[0];
369
+ const identity = run ?? context.run;
370
+ const turnId = identity?.turnId;
371
+ const steps = deriveAgentRunSteps(snapshot, identity);
372
+ const artifacts = deriveWorkbenchArtifacts(snapshot).filter((artifact) => identity && matchesRun(artifact, identity));
373
+ const status = run ? taskStatusText(run.status) : '无运行记录';
374
+ const lines = [
375
+ '# Agent 运行报告',
376
+ '',
377
+ `- **会话:** ${markdownInline(context.sessionName || snapshot?.sessionKey || '未命名会话')}`,
378
+ `- **任务 ID:** ${markdownInline(turnId || '—')}`,
379
+ `- **状态:** ${status}`,
380
+ `- **开始时间:** ${run ? new Date(run.startedAt).toISOString() : '—'}`,
381
+ `- **耗时:** ${run?.durationMs === undefined ? (run?.status === 'running' ? '进行中' : '—') : `${run.durationMs.toLocaleString()} ms`}`,
382
+ `- **工作目录:** ${markdownInline(context.workingDirectory || 'Host project root')}`,
383
+ `- **安全策略:** ${markdownInline(context.safetyMode || '—')} / ${markdownInline(context.approvalMode || '—')} / network ${context.networkAccess ? 'enabled' : 'disabled'}`,
384
+ `- **用量:** ${run?.toolCount ?? 0} tools / ${(run?.tokenCount ?? 0).toLocaleString()} tokens / ${run?.problemCount ?? 0} problems`,
385
+ ];
386
+
387
+ if (context.taskPrompt?.trim()) {
388
+ lines.push('', '## 任务', '', ...markdownBlock('text', context.taskPrompt.trim()));
389
+ }
390
+ lines.push('', '## 执行轨迹', '');
391
+ if (steps.length === 0) lines.push('- 暂无可用轨迹');
392
+ else for (const step of steps) {
393
+ lines.push(`- ${stepStatusMark(step.status)} **${reportStepTitle(step.title)}**${step.detail ? ` — ${markdownInline(step.detail)}` : ''}${step.durationMs === undefined ? '' : ` (${step.durationMs.toLocaleString()} ms)`}`);
394
+ }
395
+
396
+ lines.push('', '## 变更与产物', '');
397
+ if (artifacts.length === 0) lines.push('- 本次运行没有文件、命令或测试产物。');
398
+ else artifacts.forEach((artifact, index) => {
399
+ lines.push(`### ${index + 1}. ${markdownInline(artifact.title)}`, '');
400
+ lines.push(`- 类型:${artifact.kind}`, `- 状态:${artifact.status}`);
401
+ if (artifact.path) lines.push(`- 路径:${markdownInlineCode(artifact.path)}`);
402
+ if (artifact.durationMs !== undefined) lines.push(`- 耗时:${artifact.durationMs.toLocaleString()} ms`);
403
+ if (artifact.diff) lines.push('', ...markdownBlock('diff', artifact.diff));
404
+ else if (artifact.detail) lines.push('', ...markdownBlock('text', artifact.detail));
405
+ lines.push('');
406
+ });
407
+
408
+ lines.push('---', `由 Zhin Agent 试验台于 ${new Date().toISOString()} 导出。`);
409
+ return `${lines.join('\n').trim()}\n`;
410
+ }
411
+
412
+ export function presentTraceEvent(event: AgentTraceEvent): { readonly title: string; readonly detail: string; readonly tone: string } {
413
+ const data = event.data;
414
+ const tool = String(data.toolName ?? 'tool');
415
+ switch (event.type) {
416
+ case 'turn_start': return { title: '开始处理', detail: shortTurn(event.turnId), tone: 'running' };
417
+ case 'capability_resolution': return { title: '能力已解析', detail: `${arrayLength(data.tools)} tools · ${arrayLength(data.skills)} skills`, tone: 'capability' };
418
+ case 'iteration_start': return { title: `推理迭代 ${String(data.iteration ?? '')}`, detail: `上限 ${String(data.maxIterations ?? '—')}`, tone: 'thinking' };
419
+ case 'thinking': return { title: '模型思考', detail: preview(data.text), tone: 'thinking' };
420
+ case 'tool_call': return { title: `调用 ${tool}`, detail: preview(data.toolUseId), tone: 'tool' };
421
+ case 'tool_result': return { title: `${tool} 完成`, detail: duration(data.durationMs), tone: 'success' };
422
+ case 'tool_denied': return { title: `${tool} 被拒绝`, detail: preview(data.reason ?? data.policy), tone: 'problem' };
423
+ case 'tool_failed': return { title: `${tool} 失败`, detail: preview(data.error), tone: 'problem' };
424
+ case 'mcp_connect': return { title: `${String(data.serverName ?? 'MCP')} 连接`, detail: String(data.status ?? ''), tone: 'capability' };
425
+ case 'mcp_tool_call': return { title: String(data.toolName ?? 'MCP tool'), detail: `via ${String(data.serverName ?? 'MCP')}`, tone: 'tool' };
426
+ case 'subagent_start': return { title: `委派 ${String(data.agentName ?? 'subagent')}`, detail: preview(data.description), tone: 'capability' };
427
+ case 'subagent_progress': return { title: '子 Agent 更新', detail: preview(data.summary), tone: 'running' };
428
+ case 'subagent_end': return { title: '子 Agent 返回', detail: String(data.status ?? 'done'), tone: data.status === 'error' ? 'problem' : 'success' };
429
+ case 'usage': return { title: `${usageTotal(data).toLocaleString()} tokens`, detail: usageDetail(data), tone: 'usage' };
430
+ case 'turn_end': return { title: '本轮完成', detail: 'Agent 已返回结果', tone: 'success' };
431
+ case 'turn_cancelled': return { title: '本轮取消', detail: preview(data.reason), tone: 'muted' };
432
+ case 'budget_exceeded': return { title: '达到预算上限', detail: preview(data.budget), tone: 'problem' };
433
+ case 'error': return { title: '执行失败', detail: preview(data.error), tone: 'problem' };
434
+ default: return { title: event.type.replaceAll('_', ' '), detail: '', tone: 'muted' };
435
+ }
436
+ }
437
+
438
+ export async function fetchAgentTrace(sessionKey: string, afterSequence = 0): Promise<AgentTraceSnapshot> {
439
+ const base = getSandboxApiBase() || window.location.origin;
440
+ const url = new URL('/api/agent/traces', `${base}/`);
441
+ url.searchParams.set('sessionKey', sessionKey);
442
+ url.searchParams.set('limit', '300');
443
+ if (afterSequence > 0) url.searchParams.set('after', String(afterSequence));
444
+ const response = await fetch(url, { headers: getSandboxAuthHeaders() });
445
+ const body = await response.json().catch(() => ({})) as { success?: boolean; data?: AgentTraceSnapshot; error?: string };
446
+ if (!response.ok || body.success === false || !body.data) {
447
+ throw new Error(response.status === 503
448
+ ? 'Agent Trace 尚未启用'
449
+ : body.error || '无法读取 Agent Trace');
450
+ }
451
+ return body.data;
452
+ }
453
+
454
+ export async function cancelAgentTask(sessionKey: string): Promise<boolean> {
455
+ const base = getSandboxApiBase() || window.location.origin;
456
+ const response = await fetch(new URL('/api/agent/tasks/cancel', `${base}/`), {
457
+ method: 'POST',
458
+ headers: { ...getSandboxAuthHeaders(), 'content-type': 'application/json' },
459
+ body: JSON.stringify({ sessionKey }),
460
+ });
461
+ const body = await response.json().catch(() => ({})) as {
462
+ success?: boolean;
463
+ data?: { cancelled?: boolean };
464
+ error?: string;
465
+ };
466
+ if (!response.ok || body.success === false) {
467
+ throw new Error(body.error || '无法停止 Agent 任务');
468
+ }
469
+ return body.data?.cancelled === true;
470
+ }
471
+
472
+ function usageRecord(data: Record<string, unknown>): Record<string, unknown> {
473
+ return data.usage && typeof data.usage === 'object' ? data.usage as Record<string, unknown> : {};
474
+ }
475
+
476
+ function usageTotal(data: Record<string, unknown>): number {
477
+ return Number(usageRecord(data).totalTokens ?? 0);
478
+ }
479
+
480
+ function usageDetail(data: Record<string, unknown>): string {
481
+ const usage = usageRecord(data);
482
+ return `输入 ${Number(usage.promptTokens ?? 0).toLocaleString()} · 输出 ${Number(usage.completionTokens ?? 0).toLocaleString()}`;
483
+ }
484
+
485
+ function arrayLength(value: unknown): number {
486
+ return Array.isArray(value) ? value.length : 0;
487
+ }
488
+
489
+ function preview(value: unknown): string {
490
+ if (value == null) return '';
491
+ if (typeof value === 'string') return value.slice(0, 96);
492
+ try { return JSON.stringify(value).slice(0, 96); } catch { return String(value).slice(0, 96); }
493
+ }
494
+
495
+ function duration(value: unknown): string {
496
+ const ms = Number(value);
497
+ return Number.isFinite(ms) ? `${ms.toLocaleString()} ms` : '执行完成';
498
+ }
499
+
500
+ function shortTurn(value: string): string {
501
+ return value.length > 12 ? value.slice(0, 10) : value;
502
+ }
503
+
504
+ function artifactStatus(event: AgentTraceEvent | undefined): WorkbenchArtifact['status'] {
505
+ if (!event) return 'running';
506
+ if (event.type === 'tool_result') return 'completed';
507
+ if (event.type === 'tool_denied') return 'denied';
508
+ if (event.type === 'tool_cancelled') return 'cancelled';
509
+ return 'failed';
510
+ }
511
+
512
+ function artifactOutputDetail(event: AgentTraceEvent | undefined): string | undefined {
513
+ if (!event) return undefined;
514
+ const value = event.data.output ?? event.data.error ?? event.data.reason;
515
+ if (typeof value === 'string') return value.slice(0, 8_000) || undefined;
516
+ if (value == null) return undefined;
517
+ try { return JSON.stringify(value, null, 2).slice(0, 8_000); } catch { return String(value).slice(0, 8_000); }
518
+ }
519
+
520
+ function toolStepDetail(toolName: string, args: Record<string, unknown>, fallback: string): string {
521
+ if (toolName === 'bash') return stringValue(args.command).slice(0, 160) || fallback;
522
+ if (toolName === 'write_file' || toolName === 'edit_file' || toolName === 'read_file') {
523
+ return stringValue(args.file_path ?? args.path).slice(0, 160) || fallback;
524
+ }
525
+ return fallback;
526
+ }
527
+
528
+ function taskStatusText(status: AgentTaskStatus): string {
529
+ if (status === 'running') return '运行中';
530
+ if (status === 'completed') return '已完成';
531
+ if (status === 'cancelled') return '已取消';
532
+ return '失败';
533
+ }
534
+
535
+ function stepStatusMark(status: AgentRunStepStatus): string {
536
+ if (status === 'completed') return '✓';
537
+ if (status === 'running') return '◉';
538
+ if (status === 'cancelled') return '■';
539
+ return '×';
540
+ }
541
+
542
+ function markdownInline(value: string): string {
543
+ return value.replace(/[\\`*_[\]<>]/gu, '\\$&').replace(/\r?\n/gu, ' ');
544
+ }
545
+
546
+ function reportStepTitle(value: string): string {
547
+ const tool = /^运行 (.+)$/u.exec(value)?.[1];
548
+ return tool ? `运行 ${markdownInlineCode(tool)}` : markdownInline(value);
549
+ }
550
+
551
+ function markdownInlineCode(value: string): string {
552
+ const normalized = value.replace(/\r?\n/gu, ' ');
553
+ const longestTicks = Math.max(0, ...[...normalized.matchAll(/`+/gu)].map((match) => match[0].length));
554
+ const fence = '`'.repeat(Math.max(1, longestTicks + 1));
555
+ const padding = /^\s|\s$|^`|`$/u.test(normalized) ? ' ' : '';
556
+ return `${fence}${padding}${normalized}${padding}${fence}`;
557
+ }
558
+
559
+ function markdownBlock(language: string, value: string): string[] {
560
+ const longestTicks = Math.max(0, ...[...value.matchAll(/`+/gu)].map((match) => match[0].length));
561
+ const fence = '`'.repeat(Math.max(3, longestTicks + 1));
562
+ return [`${fence}${language}`, value, fence];
563
+ }
564
+
565
+ function fileDiff(toolName: string, args: Record<string, unknown>, filePath: string): string | undefined {
566
+ if (toolName !== 'edit_file') return undefined;
567
+ const diffPath = filePath.replace(/^\/+/, '');
568
+ const before = stringValue(args.old_string);
569
+ const after = stringValue(args.new_string);
570
+ if (!before && !after) return undefined;
571
+ return [
572
+ `--- a/${diffPath}`,
573
+ `+++ b/${diffPath}`,
574
+ '@@',
575
+ ...before.split('\n').map((line) => `- ${line}`),
576
+ ...after.split('\n').map((line) => `+ ${line}`),
577
+ ].join('\n').slice(0, 8_000);
578
+ }
579
+
580
+ function isTestCommand(command: string): boolean {
581
+ return /(?:^|\s)(?:test|vitest|jest|pytest|cargo\s+test|go\s+test|pnpm\s+(?:run\s+)?test|npm\s+(?:run\s+)?test|yarn\s+test)(?:\s|$)/iu.test(command);
582
+ }
583
+
584
+ function recordValue(value: unknown): Record<string, unknown> {
585
+ return value && typeof value === 'object' && !Array.isArray(value)
586
+ ? value as Record<string, unknown>
587
+ : {};
588
+ }
589
+
590
+ function toolCorrelationKey(event: AgentTraceEvent, toolUseId: string): string {
591
+ return `${event.runtimeId ?? 'legacy'}:${event.turnId}:${toolUseId}`;
592
+ }
593
+
594
+ function runCorrelationKey(event: AgentTraceEvent): string {
595
+ return runIdentityKey(event);
596
+ }
597
+
598
+ function runIdentityKey(run: AgentRunIdentity): string {
599
+ return `${run.runtimeId ?? 'legacy'}:${run.turnId}`;
600
+ }
601
+
602
+ function matchesRun(value: AgentRunIdentity, run: AgentRunIdentity): boolean {
603
+ return value.turnId === run.turnId && value.runtimeId === run.runtimeId;
604
+ }
605
+
606
+ function stringValue(value: unknown): string {
607
+ return typeof value === 'string' ? value : '';
608
+ }
609
+
610
+ function numberValue(value: unknown): number | undefined {
611
+ const number = Number(value);
612
+ return Number.isFinite(number) ? number : undefined;
613
+ }
614
+
615
+ function parseCachedTraceEvent(value: unknown): AgentTraceEvent | undefined {
616
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
617
+ const item = value as Record<string, unknown>;
618
+ if (
619
+ typeof item.sequence !== 'number'
620
+ || typeof item.recordedAt !== 'number'
621
+ || typeof item.sessionKey !== 'string'
622
+ || typeof item.turnId !== 'string'
623
+ || typeof item.type !== 'string'
624
+ || !item.data
625
+ || typeof item.data !== 'object'
626
+ || Array.isArray(item.data)
627
+ ) return undefined;
628
+ return {
629
+ ...(typeof item.runtimeId === 'string' ? { runtimeId: item.runtimeId } : {}),
630
+ sequence: item.sequence,
631
+ recordedAt: item.recordedAt,
632
+ sessionKey: item.sessionKey,
633
+ turnId: item.turnId,
634
+ type: item.type,
635
+ data: item.data as Record<string, unknown>,
636
+ };
637
+ }
638
+
639
+ function nonNegativeNumber(value: unknown): number {
640
+ const number = Number(value);
641
+ return Number.isSafeInteger(number) && number >= 0 ? number : 0;
642
+ }
643
+
644
+ function browserTraceStorage(): AgentTraceStorage | undefined {
645
+ return typeof window === 'undefined' ? undefined : window.localStorage;
646
+ }
package/pages/index.js CHANGED
@@ -3,14 +3,14 @@ import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import { definePage } from '@zhin.js/console-contract';
4
4
  import SandboxChat from './SandboxChat';
5
5
  export const meta = definePage({
6
- title: '沙盒',
6
+ title: 'Agent 试验台',
7
7
  icon: 'Box',
8
8
  order: 10,
9
9
  });
10
10
  /**
11
11
  * Convention page entry (ADR 0046).
12
12
  * `pages/index.tsx` → `/sandbox` (plugin path; no `/p-` leaf).
13
- * Restores the pre-runtime-migration Sandbox console UI (channels + rich text + faces).
13
+ * Agent workbench for testing scoped conversations, rich messages and execution traces.
14
14
  * WebSocket targets Host `/sandbox` via zhin_api_base + token (see sandboxTransport.ts).
15
15
  */
16
16
  export default function SandboxPage() {
package/pages/index.tsx CHANGED
@@ -2,7 +2,7 @@ import { definePage } from '@zhin.js/console-contract';
2
2
  import SandboxChat from './SandboxChat';
3
3
 
4
4
  export const meta = definePage({
5
- title: '沙盒',
5
+ title: 'Agent 试验台',
6
6
  icon: 'Box',
7
7
  order: 10,
8
8
  });
@@ -10,7 +10,7 @@ export const meta = definePage({
10
10
  /**
11
11
  * Convention page entry (ADR 0046).
12
12
  * `pages/index.tsx` → `/sandbox` (plugin path; no `/p-` leaf).
13
- * Restores the pre-runtime-migration Sandbox console UI (channels + rich text + faces).
13
+ * Agent workbench for testing scoped conversations, rich messages and execution traces.
14
14
  * WebSocket targets Host `/sandbox` via zhin_api_base + token (see sandboxTransport.ts).
15
15
  */
16
16
  export default function SandboxPage() {