niceeval 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/README.md +8 -8
  2. package/README.zh.md +13 -13
  3. package/docs/README.md +119 -0
  4. package/docs/adapters/README.md +60 -0
  5. package/docs/adapters/authoring.md +179 -0
  6. package/docs/adapters/coding-agent-skills-plugins.md +324 -0
  7. package/docs/adapters/collection.md +128 -0
  8. package/docs/adapters/contract.md +263 -0
  9. package/docs/adapters/reference/agent-eval.md +215 -0
  10. package/docs/adapters/reference/agent-loop-apis.md +69 -0
  11. package/docs/adapters/reference/claude-code-otel-telemetry.md +100 -0
  12. package/docs/adapters/reference/eve-protocol.md +127 -0
  13. package/docs/adapters/reference/otel-genai.md +107 -0
  14. package/docs/adapters/reference/otel-instrumentation.md +65 -0
  15. package/docs/adapters/targets.md +99 -0
  16. package/docs/architecture.md +140 -0
  17. package/docs/assertions.md +388 -0
  18. package/docs/capabilities-by-construction.md +48 -0
  19. package/docs/cli.md +171 -0
  20. package/docs/concepts.md +106 -0
  21. package/docs/e2e-ci.md +295 -0
  22. package/docs/eval-authoring.md +319 -0
  23. package/docs/experiments.md +154 -0
  24. package/docs/getting-started.md +224 -0
  25. package/docs/multi-agent.md +145 -0
  26. package/docs/observability.md +333 -0
  27. package/docs/origin-integration.md +195 -0
  28. package/docs/references.md +35 -0
  29. package/docs/results-format.md +228 -0
  30. package/docs/runner.md +104 -0
  31. package/docs/sandbox.md +276 -0
  32. package/docs/scoring.md +119 -0
  33. package/docs/source-map.md +106 -0
  34. package/docs/tier-sync.md +177 -0
  35. package/docs/view.md +157 -0
  36. package/docs/vision.md +88 -0
  37. package/package.json +35 -5
  38. package/src/agents/ai-sdk-otel.ts +0 -0
  39. package/src/agents/ai-sdk.test.ts +377 -0
  40. package/src/agents/ai-sdk.ts +577 -0
  41. package/src/agents/bub.ts +30 -37
  42. package/src/agents/claude-code.ts +7 -4
  43. package/src/agents/codex.ts +15 -10
  44. package/src/agents/index.ts +43 -1
  45. package/src/agents/sdk-streams.test.ts +145 -0
  46. package/src/agents/sdk-streams.ts +457 -0
  47. package/src/agents/shared.ts +77 -39
  48. package/src/agents/streaming.test.ts +152 -0
  49. package/src/agents/streaming.ts +195 -0
  50. package/src/agents/types.ts +218 -0
  51. package/src/agents/ui-message-stream.test.ts +249 -0
  52. package/src/agents/ui-message-stream.ts +372 -0
  53. package/src/cli.ts +124 -77
  54. package/src/context/context.test.ts +203 -7
  55. package/src/context/context.ts +158 -49
  56. package/src/context/session.test.ts +96 -0
  57. package/src/context/session.ts +119 -9
  58. package/src/context/types.ts +205 -0
  59. package/src/define.ts +4 -14
  60. package/src/expect/index.ts +8 -71
  61. package/src/i18n/core.ts +28 -0
  62. package/src/i18n/en.ts +47 -5
  63. package/src/i18n/index.ts +5 -17
  64. package/src/i18n/zh-CN.ts +47 -5
  65. package/src/index.ts +12 -0
  66. package/src/loaders/index.ts +8 -39
  67. package/src/o11y/otlp/canonical.ts +7 -6
  68. package/src/o11y/otlp/mappers/codex.ts +10 -8
  69. package/src/o11y/otlp/mappers/index.ts +9 -21
  70. package/src/o11y/otlp/receiver.ts +25 -7
  71. package/src/o11y/otlp/sandbox-receiver.ts +57 -21
  72. package/src/o11y/otlp/turn-otel.test.ts +110 -0
  73. package/src/o11y/otlp/turn-otel.ts +125 -0
  74. package/src/o11y/parsers/bub.ts +13 -11
  75. package/src/o11y/parsers/claude-code.ts +27 -51
  76. package/src/o11y/parsers/codex.ts +29 -46
  77. package/src/o11y/parsers/index.ts +5 -14
  78. package/src/o11y/tool-names.test.ts +61 -0
  79. package/src/o11y/tool-names.ts +74 -0
  80. package/src/o11y/types.ts +135 -0
  81. package/src/runner/attempt.ts +456 -0
  82. package/src/runner/fingerprint.ts +59 -0
  83. package/src/runner/remote-sandbox.ts +53 -0
  84. package/src/runner/report.ts +53 -0
  85. package/src/runner/reporters/artifacts.ts +25 -4
  86. package/src/runner/reporters/console.ts +2 -8
  87. package/src/runner/reporters/live.ts +2 -8
  88. package/src/runner/reporters/shared.ts +15 -0
  89. package/src/runner/reporters/table.ts +10 -62
  90. package/src/runner/run.ts +114 -619
  91. package/src/runner/types.ts +237 -0
  92. package/src/sandbox/checkpoint.ts +15 -13
  93. package/src/sandbox/docker-stream.ts +87 -0
  94. package/src/sandbox/docker.ts +42 -120
  95. package/src/sandbox/e2b.ts +24 -75
  96. package/src/sandbox/local-files.ts +33 -0
  97. package/src/sandbox/paths.test.ts +85 -0
  98. package/src/sandbox/paths.ts +45 -0
  99. package/src/sandbox/resolve.ts +10 -34
  100. package/src/sandbox/shell.ts +17 -0
  101. package/src/sandbox/source-files.ts +42 -2
  102. package/src/sandbox/types.ts +158 -0
  103. package/src/sandbox/vercel.ts +55 -71
  104. package/src/scoring/collector.ts +3 -2
  105. package/src/scoring/judge.ts +72 -146
  106. package/src/scoring/match.ts +79 -0
  107. package/src/scoring/types.ts +76 -0
  108. package/src/shared/aggregate.ts +48 -0
  109. package/src/shared/format.ts +20 -0
  110. package/src/shared/outcome.ts +48 -0
  111. package/src/shared/types.ts +37 -0
  112. package/src/types.ts +20 -911
  113. package/src/view/aggregate.ts +103 -0
  114. package/src/view/app/App.tsx +26 -5
  115. package/src/view/app/components/AttemptModal.tsx +12 -3
  116. package/src/view/app/components/CodeView.tsx +9 -5
  117. package/src/view/app/components/CopyControls.tsx +4 -4
  118. package/src/view/app/components/ExperimentTable.tsx +5 -5
  119. package/src/view/app/i18n.ts +31 -7
  120. package/src/view/app/lib/format.ts +17 -20
  121. package/src/view/app/lib/outcome.ts +4 -16
  122. package/src/view/app/main.tsx +3 -5
  123. package/src/view/app/pages/RunsPage.tsx +2 -2
  124. package/src/view/app/pages/TracesPage.tsx +2 -2
  125. package/src/view/app/shared.ts +2 -1
  126. package/src/view/app/types.ts +6 -43
  127. package/src/view/client-dist/app.css +1 -1
  128. package/src/view/client-dist/app.js +18 -18
  129. package/src/view/index.ts +24 -401
  130. package/src/view/loader.ts +234 -0
  131. package/src/view/server.ts +136 -0
  132. package/src/view/shared/types.ts +64 -0
  133. package/src/view/styles.css +60 -9
@@ -0,0 +1,457 @@
1
+ // SDK 原生事件流 → 标准 StreamEvent 的官方转换器(+ 通用 SSE 读帧器)。
2
+ //
3
+ // 定位:各 agent SDK 的流式协议(Claude Agent SDK 的 `SDKMessage`、pi-agent-core 的
4
+ // `AgentEvent`、Codex SDK 的 `ThreadEvent`)是 SDK 定义的通用协议,不是某个应用的私有格式——
5
+ // 这层映射知识属于 niceeval 官方包,adapter 里只该剩传输粘合(应用把流放在哪个端点、
6
+ // 审批走什么端点)。类型全部用结构化的 *Like 声明(同 fromAiSdk 的先例),不依赖任何 SDK 包。
7
+ //
8
+ // 用法(以 Claude Agent SDK 为例):
9
+ //
10
+ // ```typescript
11
+ // import { sseJsonFrames, fromClaudeSdkMessages } from "niceeval/adapter";
12
+ //
13
+ // const frames = sseJsonFrames<SDKMessage>(res.body);
14
+ // const stream = fromClaudeSdkMessages();
15
+ // for (;;) {
16
+ // const frame = await frames.next();
17
+ // if (frame === null) break;
18
+ // events.push(...stream.add(frame)); // 逐帧翻译;认不出的帧返回 []
19
+ // }
20
+ // return { status: stream.failed ? "failed" : "completed", events, usage: stream.usage };
21
+ // ```
22
+
23
+ import type { JsonValue, StreamEvent, Usage } from "../types.ts";
24
+
25
+ // ───────────────────────── 通用 SSE 读帧器 ─────────────────────────
26
+
27
+ export interface SseFrameCursor<T> {
28
+ /** 下一个 `data:` JSON 帧;流结束返回 null。`data: [DONE]` 哨兵帧自动跳过。 */
29
+ next(): Promise<T | null>;
30
+ }
31
+
32
+ /** 标准 SSE(`data: {...}\n\n`)→ 逐帧 JSON。各 adapter 不用再手写 buffer 状态机。 */
33
+ export function sseJsonFrames<T>(body: ReadableStream<Uint8Array>): SseFrameCursor<T> {
34
+ const reader = body.getReader();
35
+ const decoder = new TextDecoder();
36
+ let buffer = "";
37
+
38
+ async function next(): Promise<T | null> {
39
+ for (;;) {
40
+ const sepIndex = buffer.indexOf("\n\n");
41
+ if (sepIndex !== -1) {
42
+ const rawEvent = buffer.slice(0, sepIndex);
43
+ buffer = buffer.slice(sepIndex + 2);
44
+ const line = rawEvent.split("\n").find((l) => l.startsWith("data: "));
45
+ if (!line) continue;
46
+ const payload = line.slice("data: ".length);
47
+ if (payload === "[DONE]") continue;
48
+ return JSON.parse(payload) as T;
49
+ }
50
+ const { value, done } = await reader.read();
51
+ if (done) return null;
52
+ buffer += decoder.decode(value, { stream: true });
53
+ }
54
+ }
55
+
56
+ return { next };
57
+ }
58
+
59
+ function isRecord(v: unknown): v is Record<string, unknown> {
60
+ return typeof v === "object" && v !== null;
61
+ }
62
+
63
+ // ───────────────────────── Claude Agent SDK:SDKMessage ─────────────────────────
64
+
65
+ /** 只声明转换器要读的字段;真实的 SDKMessage 直接喂进来即可。 */
66
+ export interface ClaudeSdkMessageLike {
67
+ type: string;
68
+ subtype?: string;
69
+ session_id?: string;
70
+ tool_use_id?: string;
71
+ is_error?: boolean;
72
+ num_turns?: number;
73
+ total_cost_usd?: number;
74
+ usage?: { input_tokens: number; output_tokens: number; cache_read_input_tokens?: number | null; cache_creation_input_tokens?: number | null };
75
+ message?: { content?: unknown };
76
+ [key: string]: unknown;
77
+ }
78
+
79
+ export interface ClaudeSdkStream {
80
+ /** 逐帧喂 `SDKMessage`,返回这一帧派生的标准事件;认不出的帧(stream_event 等)返回 []。 */
81
+ add(message: ClaudeSdkMessageLike): StreamEvent[];
82
+ /** `system`/`init` 帧带回的 session_id(首帧之后可读),回写 `ctx.session.id` 用。 */
83
+ readonly sessionId: string | undefined;
84
+ /** `result` 帧的聚合用量。 */
85
+ readonly usage: Usage | undefined;
86
+ /** `result` 帧报了 is_error。 */
87
+ readonly failed: boolean;
88
+ /**
89
+ * 拒绝审批后续读前登记:该 tool_use 的 `tool_result` / `permission_denied` 落成
90
+ * `status: "rejected"`(而不是 failed),且两种帧只产一条 action.result。
91
+ */
92
+ markRejected(toolUseId: string): void;
93
+ }
94
+
95
+ /**
96
+ * Claude Agent SDK 消息流(`system` / `assistant` / `user` / `result`)→ 标准事件。
97
+ * `assistant` 的 text/tool_use 块 → message / action.called;`user` 的 tool_result 块 →
98
+ * action.result;`system`/`permission_denied` → rejected;`stream_event`(逐 token 渲染)
99
+ * 整个忽略。HITL 的停轮判定(哪个工具被门控)是应用侧的知识,不在这里——扫描 add()
100
+ * 返回的 action.called 自行决定。
101
+ */
102
+ export function fromClaudeSdkMessages(): ClaudeSdkStream {
103
+ let sessionId: string | undefined;
104
+ let usage: Usage | undefined;
105
+ let failed = false;
106
+ const resolvedCallIds = new Set<string>();
107
+ const rejected = new Set<string>();
108
+
109
+ const toolResultText = (content: unknown): string | undefined => {
110
+ if (typeof content === "string") return content;
111
+ if (Array.isArray(content)) {
112
+ return content
113
+ .filter((b): b is { type: "text"; text: string } => isRecord(b) && b.type === "text" && typeof b.text === "string")
114
+ .map((b) => b.text)
115
+ .join("");
116
+ }
117
+ return undefined;
118
+ };
119
+
120
+ return {
121
+ get sessionId() {
122
+ return sessionId;
123
+ },
124
+ get usage() {
125
+ return usage;
126
+ },
127
+ get failed() {
128
+ return failed;
129
+ },
130
+ markRejected(toolUseId) {
131
+ rejected.add(toolUseId);
132
+ },
133
+ add(frame) {
134
+ const events: StreamEvent[] = [];
135
+ switch (frame.type) {
136
+ case "system": {
137
+ if (frame.subtype === "init" && typeof frame.session_id === "string") {
138
+ sessionId ??= frame.session_id;
139
+ } else if (frame.subtype === "permission_denied" && typeof frame.tool_use_id === "string") {
140
+ if (!resolvedCallIds.has(frame.tool_use_id)) {
141
+ resolvedCallIds.add(frame.tool_use_id);
142
+ events.push({ type: "action.result", callId: frame.tool_use_id, status: "rejected" });
143
+ }
144
+ }
145
+ break;
146
+ }
147
+ case "assistant": {
148
+ const content: unknown[] = Array.isArray(frame.message?.content) ? frame.message.content : [];
149
+ for (const block of content) {
150
+ if (!isRecord(block)) continue;
151
+ if (block.type === "text" && typeof block.text === "string" && block.text) {
152
+ events.push({ type: "message", role: "assistant", text: block.text });
153
+ } else if (block.type === "tool_use" && typeof block.id === "string" && typeof block.name === "string") {
154
+ events.push({ type: "action.called", callId: block.id, name: block.name, input: block.input as JsonValue });
155
+ }
156
+ }
157
+ break;
158
+ }
159
+ case "user": {
160
+ const content: unknown[] = Array.isArray(frame.message?.content) ? frame.message.content : [];
161
+ for (const block of content) {
162
+ if (!isRecord(block) || block.type !== "tool_result" || typeof block.tool_use_id !== "string") continue;
163
+ if (resolvedCallIds.has(block.tool_use_id)) continue;
164
+ resolvedCallIds.add(block.tool_use_id);
165
+ events.push({
166
+ type: "action.result",
167
+ callId: block.tool_use_id,
168
+ output: toolResultText(block.content) as JsonValue,
169
+ status: rejected.has(block.tool_use_id) ? "rejected" : block.is_error ? "failed" : "completed",
170
+ });
171
+ }
172
+ break;
173
+ }
174
+ case "result": {
175
+ failed = frame.is_error === true;
176
+ if (frame.usage) {
177
+ usage = {
178
+ inputTokens: frame.usage.input_tokens,
179
+ outputTokens: frame.usage.output_tokens,
180
+ cacheReadTokens: frame.usage.cache_read_input_tokens ?? undefined,
181
+ cacheWriteTokens: frame.usage.cache_creation_input_tokens ?? undefined,
182
+ requests: frame.num_turns,
183
+ costUSD: frame.total_cost_usd,
184
+ };
185
+ }
186
+ break;
187
+ }
188
+ // stream_event(逐 token 渲染)与其它系统/生命周期消息:无对应 StreamEvent。
189
+ default:
190
+ break;
191
+ }
192
+ return events;
193
+ },
194
+ };
195
+ }
196
+
197
+ // ───────────────────────── pi-agent-core:AgentEvent ─────────────────────────
198
+
199
+ export interface PiAgentEventLike {
200
+ type: string;
201
+ toolCallId?: string;
202
+ toolName?: string;
203
+ args?: unknown;
204
+ result?: unknown;
205
+ isError?: boolean;
206
+ /** content 在 user 消息上是 string、assistant 消息上是 parts 数组——按 unknown 收,防御式解析。 */
207
+ message?: {
208
+ role?: string;
209
+ content?: unknown;
210
+ usage?: { input: number; output: number; cacheRead: number; cacheWrite: number; cost: { total: number } };
211
+ };
212
+ [key: string]: unknown;
213
+ }
214
+
215
+ export interface PiAgentStream {
216
+ /** 逐帧喂 `AgentEvent`,返回这一帧派生的标准事件;生命周期帧(turn_start 等)返回 []。 */
217
+ add(event: PiAgentEventLike): StreamEvent[];
218
+ /** assistant `message_end` 逐条累加的用量。 */
219
+ readonly usage: Usage | undefined;
220
+ /** 拒绝审批后续读前登记:该调用 `tool_execution_end` 的 isError 判成 "rejected" 而非 "failed"。 */
221
+ markRejected(toolCallId: string): void;
222
+ }
223
+
224
+ /**
225
+ * pi-agent-core 事件流(`message_end` / `tool_execution_start` / `tool_execution_end`)→
226
+ * 标准事件。message_end 抠 assistant 文本与 thinking、累加 usage;
227
+ * tool_execution_start/end → action.called / action.result。
228
+ */
229
+ export function fromPiAgentEvents(): PiAgentStream {
230
+ let usage: Usage | undefined;
231
+ const rejected = new Set<string>();
232
+
233
+ const partText = (event: PiAgentEventLike, type: "text" | "thinking"): string => {
234
+ if (event.message?.role !== "assistant" || !Array.isArray(event.message.content)) return "";
235
+ return event.message.content
236
+ .filter((part): part is Record<string, unknown> => isRecord(part) && part.type === type)
237
+ .map((part) => {
238
+ const v = type === "text" ? part.text : part.thinking;
239
+ return typeof v === "string" ? v : "";
240
+ })
241
+ .join("");
242
+ };
243
+
244
+ return {
245
+ get usage() {
246
+ return usage;
247
+ },
248
+ markRejected(toolCallId) {
249
+ rejected.add(toolCallId);
250
+ },
251
+ add(event) {
252
+ const events: StreamEvent[] = [];
253
+ switch (event.type) {
254
+ case "message_end": {
255
+ const text = partText(event, "text");
256
+ if (text) events.push({ type: "message", role: "assistant", text });
257
+ const thinking = partText(event, "thinking");
258
+ if (thinking) events.push({ type: "thinking", text: thinking });
259
+ const u = event.message?.role === "assistant" ? event.message.usage : undefined;
260
+ if (u) {
261
+ usage = {
262
+ inputTokens: (usage?.inputTokens ?? 0) + u.input,
263
+ outputTokens: (usage?.outputTokens ?? 0) + u.output,
264
+ cacheReadTokens: (usage?.cacheReadTokens ?? 0) + u.cacheRead,
265
+ cacheWriteTokens: (usage?.cacheWriteTokens ?? 0) + u.cacheWrite,
266
+ requests: (usage?.requests ?? 0) + 1,
267
+ costUSD: (usage?.costUSD ?? 0) + u.cost.total,
268
+ };
269
+ }
270
+ break;
271
+ }
272
+ case "tool_execution_start": {
273
+ if (typeof event.toolCallId === "string" && typeof event.toolName === "string") {
274
+ events.push({ type: "action.called", callId: event.toolCallId, name: event.toolName, input: event.args as JsonValue });
275
+ }
276
+ break;
277
+ }
278
+ case "tool_execution_end": {
279
+ if (typeof event.toolCallId === "string") {
280
+ const status = event.isError ? (rejected.has(event.toolCallId) ? "rejected" : "failed") : "completed";
281
+ events.push({ type: "action.result", callId: event.toolCallId, output: event.result as JsonValue, status });
282
+ }
283
+ break;
284
+ }
285
+ // agent_start / turn_start / turn_end / message_start / message_update /
286
+ // tool_execution_update / agent_end:无对应 StreamEvent(message_end 已带完整文本)。
287
+ default:
288
+ break;
289
+ }
290
+ return events;
291
+ },
292
+ };
293
+ }
294
+
295
+ // ───────────────────────── Codex SDK:ThreadEvent ─────────────────────────
296
+
297
+ export interface CodexThreadEventLike {
298
+ type: string;
299
+ thread_id?: string;
300
+ item?: { type?: string; text?: string; message?: string; [key: string]: unknown };
301
+ error?: { message: string };
302
+ [key: string]: unknown;
303
+ }
304
+
305
+ export interface CodexThreadStream {
306
+ /**
307
+ * 逐帧喂 `ThreadEvent`,返回标准事件:消息类(agent_message → message、reasoning → thinking、
308
+ * error item / turn.failed → error)+ 工具项(command_execution / mcp_tool_call / web_search /
309
+ * file_change → action.called + action.result)。usage 从 `turn.completed` 聚合,经 `usage` 读。
310
+ * 断言依据全部来自这条流;codex CLI 的原生 OTLP span 只用于瀑布图(spanMapper: mapCodexSpans)。
311
+ */
312
+ add(event: CodexThreadEventLike): StreamEvent[];
313
+ /** `thread.started` 带回的 thread_id,回写 `ctx.session.id` 用。 */
314
+ readonly threadId: string | undefined;
315
+ /** `turn.completed` 聚合的用量(input/cached/output tokens)。 */
316
+ readonly usage: Usage | undefined;
317
+ /** turn.failed / error item 出现过。 */
318
+ readonly failed: boolean;
319
+ }
320
+
321
+ /** Codex SDK 线程事件流(`thread.started` / `item.*` / `turn.completed` / `turn.failed`)→ 标准事件。 */
322
+ export function fromCodexThreadEvents(): CodexThreadStream {
323
+ let threadId: string | undefined;
324
+ let usage: Usage | undefined;
325
+ let failed = false;
326
+ // item.started 已发 action.called 的 id;item.completed 时不重发,只补 result。
327
+ const startedCallIds = new Set<string>();
328
+ let synth = 0;
329
+
330
+ const callIdOf = (item: Record<string, unknown>, prefix: string): string => {
331
+ const id = item.id;
332
+ return typeof id === "string" || typeof id === "number" ? String(id) : `${prefix}_${++synth}`;
333
+ };
334
+
335
+ // 工具项 → action.called(+completed 时的 action.result)。与 o11y/parsers/codex.ts 的
336
+ // transcript 映射保持同一套语义(字段名以 codex exec --json 的 ThreadItem 为准)。
337
+ const handleToolItem = (item: Record<string, unknown>, isCompleted: boolean): StreamEvent[] => {
338
+ const events: StreamEvent[] = [];
339
+ const emitCall = (callId: string, name: string, input: JsonValue): void => {
340
+ if (startedCallIds.has(callId)) return;
341
+ startedCallIds.add(callId);
342
+ events.push({ type: "action.called", callId, name, input });
343
+ };
344
+
345
+ switch (item.type) {
346
+ case "command_execution": {
347
+ const callId = callIdOf(item, "cmd");
348
+ emitCall(callId, "command_execution", { command: item.command ?? null } as JsonValue);
349
+ if (isCompleted) {
350
+ const exit = item.exit_code;
351
+ const success = exit === 0 || (exit == null && item.status !== "failed" && item.status !== "error");
352
+ events.push({
353
+ type: "action.result",
354
+ callId,
355
+ output: { output: (item.aggregated_output ?? null) as JsonValue, exit_code: (exit ?? null) as JsonValue },
356
+ status: success ? "completed" : "failed",
357
+ });
358
+ }
359
+ return events;
360
+ }
361
+ case "mcp_tool_call": {
362
+ const callId = callIdOf(item, "mcp");
363
+ const tool = String(item.tool ?? "unknown");
364
+ const name = typeof item.server === "string" ? `${item.server}.${tool}` : tool;
365
+ emitCall(callId, name, (item.arguments ?? null) as JsonValue);
366
+ if (isCompleted) {
367
+ const success = !item.error && item.status !== "failed";
368
+ events.push({ type: "action.result", callId, output: (item.result ?? null) as JsonValue, status: success ? "completed" : "failed" });
369
+ }
370
+ return events;
371
+ }
372
+ case "web_search": {
373
+ const callId = callIdOf(item, "web");
374
+ emitCall(callId, "web_search", { query: item.query ?? null } as JsonValue);
375
+ if (isCompleted) events.push({ type: "action.result", callId, output: null, status: "completed" });
376
+ return events;
377
+ }
378
+ case "file_change": {
379
+ // 补丁类只在 completed 落一次:changes 里每个文件一对 called/result。
380
+ if (!isCompleted) return events;
381
+ const baseId = callIdOf(item, "patch");
382
+ const changes = Array.isArray(item.changes) ? item.changes : [];
383
+ changes.forEach((ch, i) => {
384
+ const callId = `${baseId}#${i}`;
385
+ const change = isRecord(ch) ? { path: ch.path ?? null, kind: ch.kind ?? null } : {};
386
+ events.push({ type: "action.called", callId, name: "file_change", input: change as JsonValue });
387
+ events.push({ type: "action.result", callId, output: change as JsonValue, status: "completed" });
388
+ });
389
+ return events;
390
+ }
391
+ default:
392
+ return events;
393
+ }
394
+ };
395
+
396
+ return {
397
+ get threadId() {
398
+ return threadId;
399
+ },
400
+ get usage() {
401
+ return usage;
402
+ },
403
+ get failed() {
404
+ return failed;
405
+ },
406
+ add(event) {
407
+ const events: StreamEvent[] = [];
408
+ switch (event.type) {
409
+ case "thread.started": {
410
+ if (typeof event.thread_id === "string") threadId ??= event.thread_id;
411
+ break;
412
+ }
413
+ case "item.started": {
414
+ if (event.item) events.push(...handleToolItem(event.item, false));
415
+ break;
416
+ }
417
+ case "item.completed": {
418
+ const item = event.item;
419
+ if (!item) break;
420
+ if (item.type === "agent_message" && typeof item.text === "string" && item.text) {
421
+ events.push({ type: "message", role: "assistant", text: item.text });
422
+ } else if (item.type === "reasoning" && typeof item.text === "string" && item.text) {
423
+ events.push({ type: "thinking", text: item.text });
424
+ } else if (item.type === "error" && typeof item.message === "string") {
425
+ failed = true;
426
+ events.push({ type: "error", message: item.message });
427
+ } else {
428
+ events.push(...handleToolItem(item, true));
429
+ }
430
+ break;
431
+ }
432
+ case "turn.completed": {
433
+ const u = event.usage;
434
+ if (isRecord(u)) {
435
+ const num = (v: unknown): number => (typeof v === "number" ? v : 0);
436
+ usage = {
437
+ inputTokens: (usage?.inputTokens ?? 0) + num(u.input_tokens),
438
+ outputTokens: (usage?.outputTokens ?? 0) + num(u.output_tokens),
439
+ cacheReadTokens: (usage?.cacheReadTokens ?? 0) + num(u.cached_input_tokens),
440
+ requests: (usage?.requests ?? 0) + 1,
441
+ };
442
+ }
443
+ break;
444
+ }
445
+ case "turn.failed": {
446
+ failed = true;
447
+ if (event.error?.message) events.push({ type: "error", message: event.error.message });
448
+ break;
449
+ }
450
+ // item.updated(中间态)/ turn.started:忽略,等 completed。
451
+ default:
452
+ break;
453
+ }
454
+ return events;
455
+ },
456
+ };
457
+ }
@@ -10,8 +10,9 @@ import {
10
10
  parseClaudeCodeTranscript,
11
11
  parseBubTranscript,
12
12
  } from "../o11y/parsers/index.ts";
13
- import type { Sandbox } from "../types.ts";
13
+ import type { Sandbox, StreamEvent } from "../types.ts";
14
14
  import { t } from "../i18n/index.ts";
15
+ import { shellQuote } from "../sandbox/shell.ts";
15
16
 
16
17
  /** 每个沙箱里「已经装过的全局包」去重,避免每轮 send 重复 npm i -g。 */
17
18
  const installedBySandbox = new WeakMap<Sandbox, Set<string>>();
@@ -36,12 +37,26 @@ async function ensureInstalled(sandbox: Sandbox, cmd: string, args: string[]): P
36
37
  set.add(key);
37
38
  }
38
39
 
39
- /** 在 dir(可含 ~)下找最新的 *.jsonl,读回其内容;没有则 undefined。 */
40
+ /**
41
+ * 在 dir(可含 ~)下找 mtime 最新的 *.jsonl,读回其内容;没有则 undefined。
42
+ * 「最新」是对的:同一沙箱内 send 严格串行,刚跑完的那次一定写在最后 —— 不要改成按
43
+ * session id 精确定位(claude --resume 会 fork 出新 session id 的新文件,旧 id 的
44
+ * 文件还在,精确匹配会读到过期 transcript,负断言静默假通过)。
45
+ * 用沙箱里的 node 递归找全局最新:不依赖 GNU find 的 -printf(BSD / 精简镜像没有),
46
+ * 也没有 `-exec ls -t +` 的 ARG_MAX 分批陷阱(每批各自排序,head -1 不是全局最新)。
47
+ * 沙箱必然有 node(agent CLI 靠 npm 装,预制镜像也带)。
48
+ */
40
49
  async function captureLatestJsonl(sandbox: Sandbox, dir: string): Promise<string | undefined> {
50
+ const script =
51
+ 'const fs=require("fs"),p=require("path");let best=null;' +
52
+ "const walk=(d)=>{let es;try{es=fs.readdirSync(d,{withFileTypes:true})}catch{return}" +
53
+ "for(const e of es){const f=p.join(d,e.name);" +
54
+ "if(e.isDirectory())walk(f);" +
55
+ 'else if(e.name.endsWith(".jsonl")){try{const m=fs.statSync(f).mtimeMs;if(!best||m>best.m)best={f,m}}catch{}}}};' +
56
+ "walk(process.argv[1]);if(best)process.stdout.write(best.f);";
41
57
  try {
42
- const find = await sandbox.runShell(
43
- `find ${dir} -type f -name '*.jsonl' -printf '%T@\\t%p\\n' 2>/dev/null | sort -nr | head -1 | cut -f2-`,
44
- );
58
+ // dir 不加引号以便 shell 展开 ~;受信内部路径(同 writeFile 的约定)。
59
+ const find = await sandbox.runShell(`node -e '${script}' ${dir}`);
45
60
  const path = find.stdout.trim();
46
61
  if (!path) return undefined;
47
62
  return await sandbox.readFile(path);
@@ -57,22 +72,36 @@ async function writeFile(sandbox: Sandbox, path: string, content: string): Promi
57
72
  await sandbox.runShell(`mkdir -p $(dirname ${path}) && cat > ${path} <<'${delim}'\n${content}\n${delim}\n`);
58
73
  }
59
74
 
60
- /** Claude Code 的 transcript JSONL 里抠 sessionId(取最后一个,供下一轮 --resume)。 */
61
- function sessionIdFromClaudeTranscript(raw: string | undefined): string | undefined {
75
+ /**
76
+ * JSONL 逐行扫描的唯一骨架:逐行 parse,把每行对象交给 pick 抠值,
77
+ * 按 mode 取第一个或最后一个非空命中。非 JSON 行跳过。
78
+ */
79
+ function scanJsonl(
80
+ raw: string | undefined,
81
+ pick: (obj: Record<string, unknown>) => string | undefined,
82
+ mode: "first" | "last" = "first",
83
+ ): string | undefined {
62
84
  if (!raw) return undefined;
63
- let last: string | undefined;
85
+ let hit: string | undefined;
64
86
  for (const line of raw.split("\n")) {
65
- const t = line.trim();
66
- if (!t) continue;
87
+ const trimmed = line.trim();
88
+ if (!trimmed) continue;
67
89
  try {
68
- const obj = JSON.parse(t) as { sessionId?: unknown; session_id?: unknown };
69
- const id = (obj.sessionId ?? obj.session_id) as unknown;
70
- if (typeof id === "string" && id) last = id;
90
+ const v = pick(JSON.parse(trimmed) as Record<string, unknown>);
91
+ if (typeof v === "string" && v) {
92
+ if (mode === "first") return v;
93
+ hit = v;
94
+ }
71
95
  } catch {
72
96
  // 跳过非 JSON 行
73
97
  }
74
98
  }
75
- return last;
99
+ return hit;
100
+ }
101
+
102
+ /** Claude Code 的 transcript JSONL 里抠 sessionId(取最后一个,供下一轮 --resume)。 */
103
+ function sessionIdFromClaudeTranscript(raw: string | undefined): string | undefined {
104
+ return scanJsonl(raw, (obj) => (obj.sessionId ?? obj.session_id) as string | undefined, "last");
76
105
  }
77
106
 
78
107
  /** 从混了普通日志的 stdout 里抠出 JSONL(只留看起来是 JSON 对象的行)。 */
@@ -87,35 +116,41 @@ function extractJsonlFromStdout(stdout: string | undefined): string | undefined
87
116
 
88
117
  /** 从 codex 的 --json stdout 里取 thread_id(thread.started 事件),供下一轮 resume。 */
89
118
  function codexThreadId(stdout: string | undefined): string | undefined {
90
- if (!stdout) return undefined;
91
- for (const line of stdout.split("\n")) {
92
- const t = line.trim();
93
- if (!t) continue;
94
- try {
95
- const e = JSON.parse(t) as { type?: string; thread_id?: unknown };
96
- if (e.type === "thread.started" && typeof e.thread_id === "string") return e.thread_id;
97
- } catch {
98
- // 跳过
99
- }
100
- }
101
- return undefined;
119
+ return scanJsonl(stdout, (obj) =>
120
+ obj.type === "thread.started" ? (obj.thread_id as string | undefined) : undefined,
121
+ );
102
122
  }
103
123
 
104
124
  /** 扫 JSONL,返回第一个出现的 field 的字符串值。 */
105
125
  function firstJsonField(raw: string | undefined, field: string): string | undefined {
106
- if (!raw) return undefined;
107
- for (const line of raw.split("\n")) {
108
- const t = line.trim();
109
- if (!t) continue;
110
- try {
111
- const obj = JSON.parse(t) as Record<string, unknown>;
112
- const v = obj[field];
113
- if (typeof v === "string" && v) return v;
114
- } catch {
115
- // 跳过
116
- }
117
- }
118
- return undefined;
126
+ return scanJsonl(raw, (obj) => obj[field] as string | undefined);
127
+ }
128
+
129
+
130
+ /**
131
+ * 非零退出的通用诊断:退出码、transcript 有无、事件数、最后一条 error、输出末尾,
132
+ * 拼成一条可读信息。adapter 在 status=failed 时把它塞进 error 事件——
133
+ * 否则 transcript 为空时失败原因彻底丢失,用户只能干瞪眼。
134
+ */
135
+ function diagnoseFailure(
136
+ res: { exitCode: number; stdout: string; stderr: string },
137
+ events: readonly StreamEvent[],
138
+ rawTranscript: string | undefined,
139
+ ): string {
140
+ const parts: string[] = [t("agent.diagnose.exitCode", { code: res.exitCode })];
141
+ if (rawTranscript === undefined) parts.push(t("agent.diagnose.noTranscript"));
142
+ else if (events.length === 0) parts.push(t("agent.diagnose.zeroEvents"));
143
+ const lastErr = [...events].reverse().find((e) => e.type === "error") as
144
+ | { type: "error"; message: string }
145
+ | undefined;
146
+ if (lastErr) parts.push(t("agent.diagnose.lastError", { message: lastErr.message }));
147
+ const errTail = outputTail(res.stderr) || outputTail(res.stdout);
148
+ if (errTail) parts.push(t("agent.diagnose.outputTail", { tail: errTail }));
149
+ return parts.join(" · ");
150
+ }
151
+
152
+ function outputTail(s: string, n = 6): string {
153
+ return s.trim().split("\n").filter(Boolean).slice(-n).join(" ⏎ ").slice(0, 600);
119
154
  }
120
155
 
121
156
  /**
@@ -130,6 +165,9 @@ export const shared = {
130
165
  extractJsonlFromStdout,
131
166
  codexThreadId,
132
167
  firstJsonField,
168
+ /** 把文本包成 shell 单引号字面量(含转义)。与 sandbox 后端同一份实现,别在 adapter 里手写。 */
169
+ shellQuote,
170
+ diagnoseFailure,
133
171
  /** 原始 codex JSONL → 标准事件流 + 用量 + 压缩计数。 */
134
172
  parseCodex(raw: string | undefined): ParsedTranscript {
135
173
  return parseCodexTranscript(raw);