niceeval 0.1.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.
Files changed (94) hide show
  1. package/README.md +164 -0
  2. package/README.zh.md +160 -0
  3. package/bin/niceeval.js +11 -0
  4. package/package.json +96 -0
  5. package/src/agents/bub.ts +223 -0
  6. package/src/agents/builtin.ts +6 -0
  7. package/src/agents/claude-code.ts +96 -0
  8. package/src/agents/codex.ts +122 -0
  9. package/src/agents/index.ts +25 -0
  10. package/src/agents/shared.ts +145 -0
  11. package/src/cli.ts +421 -0
  12. package/src/context/context.test.ts +96 -0
  13. package/src/context/context.ts +427 -0
  14. package/src/context/control-flow.ts +27 -0
  15. package/src/context/session.ts +124 -0
  16. package/src/define.ts +112 -0
  17. package/src/expect/index.ts +243 -0
  18. package/src/i18n/en.ts +131 -0
  19. package/src/i18n/index.ts +39 -0
  20. package/src/i18n/zh-CN.ts +132 -0
  21. package/src/index.ts +39 -0
  22. package/src/loaders/index.ts +56 -0
  23. package/src/o11y/cost.ts +57 -0
  24. package/src/o11y/derive.ts +304 -0
  25. package/src/o11y/otlp/canonical.ts +112 -0
  26. package/src/o11y/otlp/mappers/bub.ts +30 -0
  27. package/src/o11y/otlp/mappers/codex.ts +38 -0
  28. package/src/o11y/otlp/mappers/index.ts +25 -0
  29. package/src/o11y/otlp/parse.ts +330 -0
  30. package/src/o11y/otlp/receiver.ts +100 -0
  31. package/src/o11y/otlp/sandbox-receiver.ts +113 -0
  32. package/src/o11y/otlp/select.ts +82 -0
  33. package/src/o11y/parsers/bub.ts +240 -0
  34. package/src/o11y/parsers/claude-code.ts +270 -0
  35. package/src/o11y/parsers/codex.ts +480 -0
  36. package/src/o11y/parsers/index.ts +37 -0
  37. package/src/o11y/prices.json +9873 -0
  38. package/src/runner/discover.ts +77 -0
  39. package/src/runner/reporters/artifacts.ts +81 -0
  40. package/src/runner/reporters/console.ts +76 -0
  41. package/src/runner/reporters/index.ts +5 -0
  42. package/src/runner/reporters/json.ts +52 -0
  43. package/src/runner/reporters/live.ts +178 -0
  44. package/src/runner/reporters/table.ts +328 -0
  45. package/src/runner/run.ts +860 -0
  46. package/src/runner/sandbox-prep.ts +91 -0
  47. package/src/sandbox/checkpoint.ts +39 -0
  48. package/src/sandbox/docker.ts +539 -0
  49. package/src/sandbox/e2b.ts +203 -0
  50. package/src/sandbox/index.ts +25 -0
  51. package/src/sandbox/registry.ts +60 -0
  52. package/src/sandbox/resolve.ts +131 -0
  53. package/src/sandbox/source-files.ts +30 -0
  54. package/src/sandbox/vercel.ts +236 -0
  55. package/src/scoring/collector.ts +113 -0
  56. package/src/scoring/judge.ts +236 -0
  57. package/src/scoring/scoped.ts +289 -0
  58. package/src/scoring/verdict.ts +20 -0
  59. package/src/source-loc.ts +53 -0
  60. package/src/types.ts +913 -0
  61. package/src/util.test.ts +31 -0
  62. package/src/util.ts +50 -0
  63. package/src/view/app/App.tsx +189 -0
  64. package/src/view/app/components/AttemptModal.tsx +66 -0
  65. package/src/view/app/components/CodeView.tsx +272 -0
  66. package/src/view/app/components/CopyControls.tsx +89 -0
  67. package/src/view/app/components/ExperimentTable.tsx +266 -0
  68. package/src/view/app/components/GroupSelector.tsx +61 -0
  69. package/src/view/app/components/LazyArtifact.tsx +50 -0
  70. package/src/view/app/components/Trace.tsx +100 -0
  71. package/src/view/app/components/Transcript.tsx +130 -0
  72. package/src/view/app/components/primitives.tsx +43 -0
  73. package/src/view/app/components/ui/badge.tsx +21 -0
  74. package/src/view/app/components/ui/dialog.tsx +34 -0
  75. package/src/view/app/components/ui/tabs.tsx +30 -0
  76. package/src/view/app/i18n.ts +341 -0
  77. package/src/view/app/index.html +13 -0
  78. package/src/view/app/lib/cn.ts +7 -0
  79. package/src/view/app/lib/format.ts +73 -0
  80. package/src/view/app/lib/guards.ts +61 -0
  81. package/src/view/app/lib/outcome.ts +96 -0
  82. package/src/view/app/lib/rows.ts +63 -0
  83. package/src/view/app/lib/transcript-data.tsx +121 -0
  84. package/src/view/app/main.tsx +17 -0
  85. package/src/view/app/pages/RunsPage.tsx +83 -0
  86. package/src/view/app/pages/TracesPage.tsx +40 -0
  87. package/src/view/app/shared.ts +10 -0
  88. package/src/view/app/types.ts +114 -0
  89. package/src/view/app/vite.config.ts +26 -0
  90. package/src/view/client-dist/app.css +2 -0
  91. package/src/view/client-dist/app.js +56 -0
  92. package/src/view/index.ts +406 -0
  93. package/src/view/styles.css +1074 -0
  94. package/src/view/template.html +15 -0
package/src/cli.ts ADDED
@@ -0,0 +1,421 @@
1
+ // niceeval CLI 入口。执行 eval 必须以 experiment 为单位;位置参数只在 exp 后筛 eval id 前缀。
2
+ // niceeval exp [组|配置] [pattern] 跑实验
3
+ // niceeval list 只列出发现到的 eval
4
+ // niceeval clean 删除 .niceeval/ 历史运行工件
5
+
6
+ import { spawn } from "node:child_process";
7
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
8
+ import { existsSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { pathToFileURL } from "node:url";
11
+ import { discoverEvals, discoverExperiments, makeFilter } from "./runner/discover.ts";
12
+ import { runEvals, type AgentRun } from "./runner/run.ts";
13
+ import { stopAllSandboxes, liveSandboxCount } from "./sandbox/registry.ts";
14
+ import { sandboxRecommendedConcurrency } from "./sandbox/resolve.ts";
15
+ import { Console as ConsoleReporter } from "./runner/reporters/console.ts";
16
+ import { JUnit } from "./runner/reporters/json.ts";
17
+ import { Live as LiveReporter, type LiveRow } from "./runner/reporters/live.ts";
18
+ import { Artifacts as ArtifactsReporter } from "./runner/reporters/artifacts.ts";
19
+ import { buildView, startViewServer, loadMostRecentResults } from "./view/index.ts";
20
+ import { t } from "./i18n/index.ts";
21
+ import type { Config, DiscoveredExperiment, Reporter } from "./types.ts";
22
+
23
+ interface Flags {
24
+ agent?: string;
25
+ sandbox?: string;
26
+ model?: string;
27
+ runs?: number;
28
+ maxConcurrency?: number;
29
+ timeout?: number;
30
+ earlyExit?: boolean;
31
+ dry: boolean;
32
+ quiet: boolean;
33
+ force: boolean;
34
+ strict: boolean;
35
+ budget?: number;
36
+ tag?: string;
37
+ junit?: string;
38
+ open?: boolean;
39
+ out?: string;
40
+ port?: number;
41
+ }
42
+
43
+ const BOOL_FLAGS = new Set([
44
+ "dry",
45
+ "quiet",
46
+ "force",
47
+ "strict",
48
+ "early-exit",
49
+ "no-early-exit",
50
+ "open",
51
+ "no-open",
52
+ "force",
53
+ "watch",
54
+ "json",
55
+ ]);
56
+
57
+ function parseArgs(argv: string[]): { command: string; positionals: string[]; flags: Flags } {
58
+ if (argv[0] === "--") argv = argv.slice(1);
59
+ const positionals: string[] = [];
60
+ const flags: Flags = { dry: false, quiet: false, force: false, strict: false };
61
+ let command = "run";
62
+ let i = 0;
63
+
64
+ // 第一个非 flag token 若是已知命令,则为命令
65
+ const commands = new Set(["exp", "list", "view", "clean", "init", "watch", "run"]);
66
+ if (argv[0] && !argv[0].startsWith("-") && commands.has(argv[0])) {
67
+ command = argv[0];
68
+ i = 1;
69
+ }
70
+
71
+ for (; i < argv.length; i++) {
72
+ const tok = argv[i]!;
73
+ if (tok.startsWith("--")) {
74
+ const name = tok.slice(2);
75
+ if (name === "no-early-exit") {
76
+ flags.earlyExit = false;
77
+ continue;
78
+ }
79
+ if (name === "early-exit") {
80
+ flags.earlyExit = true;
81
+ continue;
82
+ }
83
+ if (name === "no-open") {
84
+ flags.open = false;
85
+ continue;
86
+ }
87
+ if (name === "open") {
88
+ flags.open = true;
89
+ continue;
90
+ }
91
+ if (BOOL_FLAGS.has(name)) {
92
+ if (name === "dry") flags.dry = true;
93
+ else if (name === "quiet") flags.quiet = true;
94
+ else if (name === "force") flags.force = true;
95
+ else if (name === "strict") flags.strict = true;
96
+ continue;
97
+ }
98
+ const value = argv[++i];
99
+ switch (name) {
100
+ case "agent": flags.agent = value; break;
101
+ case "sandbox": flags.sandbox = value; break;
102
+ case "model": flags.model = value; break;
103
+ case "runs": flags.runs = Number(value); break;
104
+ case "max-concurrency": flags.maxConcurrency = Number(value); break;
105
+ case "timeout": flags.timeout = Number(value); break;
106
+ case "budget": flags.budget = Number(value); break;
107
+ case "tag": flags.tag = value; break;
108
+ case "junit": flags.junit = value; break;
109
+ case "out": flags.out = value; break;
110
+ case "port": flags.port = Number(value); break;
111
+ default: break; // 未知 flag 忽略
112
+ }
113
+ } else {
114
+ positionals.push(tok);
115
+ }
116
+ }
117
+ return { command, positionals, flags };
118
+ }
119
+
120
+ /** 加载 cwd/.env(不覆盖已有环境变量)。 */
121
+ async function loadDotenv(cwd: string): Promise<void> {
122
+ const path = join(cwd, ".env");
123
+ if (!existsSync(path)) return;
124
+ const raw = await readFile(path, "utf-8");
125
+ for (const line of raw.split("\n")) {
126
+ const t = line.trim();
127
+ if (!t || t.startsWith("#")) continue;
128
+ const eq = t.indexOf("=");
129
+ if (eq === -1) continue;
130
+ const key = t.slice(0, eq).trim();
131
+ let value = t.slice(eq + 1).trim();
132
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
133
+ value = value.slice(1, -1);
134
+ }
135
+ if (process.env[key] === undefined) process.env[key] = value;
136
+ }
137
+ }
138
+
139
+ async function loadConfig(cwd: string): Promise<Config> {
140
+ const path = join(cwd, "niceeval.config.ts");
141
+ if (!existsSync(path)) {
142
+ throw new Error(t("cli.config.missing"));
143
+ }
144
+ const mod = (await import(pathToFileURL(path).href)) as { default?: Config };
145
+ if (!mod.default) throw new Error(t("cli.config.noDefault"));
146
+ return mod.default;
147
+ }
148
+
149
+ async function initProject(cwd: string): Promise<void> {
150
+ await mkdir(join(cwd, "evals"), { recursive: true });
151
+ const configPath = join(cwd, "niceeval.config.ts");
152
+ if (!existsSync(configPath)) {
153
+ await writeFile(
154
+ configPath,
155
+ [
156
+ 'import { defineConfig } from "niceeval";',
157
+ "",
158
+ "export default defineConfig({",
159
+ " // Add experiments/ with defineExperiment(...) to run evals.",
160
+ "});",
161
+ "",
162
+ ].join("\n"),
163
+ "utf-8",
164
+ );
165
+ }
166
+ }
167
+
168
+ async function openBrowser(url: string): Promise<boolean> {
169
+ const command =
170
+ process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
171
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
172
+
173
+ return new Promise((resolveOpen) => {
174
+ let done = false;
175
+ const finish = (ok: boolean) => {
176
+ if (done) return;
177
+ done = true;
178
+ clearTimeout(timer);
179
+ resolveOpen(ok);
180
+ };
181
+
182
+ const child = spawn(command, args, { detached: true, stdio: "ignore" });
183
+ const timer = setTimeout(() => finish(true), 1500);
184
+ child.once("error", () => finish(false));
185
+ child.once("exit", (code) => finish(code === 0));
186
+ child.unref();
187
+ });
188
+ }
189
+
190
+ function evalsFilterFromExperiment(
191
+ evals: DiscoveredExperiment["evals"],
192
+ patterns: string[],
193
+ ): (id: string) => boolean {
194
+ const patternFilter = makeFilter(patterns);
195
+ let expFilter: (id: string) => boolean = () => true;
196
+ if (Array.isArray(evals)) expFilter = (id) => evals.includes(id) || evals.some((e) => id.startsWith(e + "/"));
197
+ else if (typeof evals === "function") expFilter = evals;
198
+ return (id) => expFilter(id) && patternFilter(id);
199
+ }
200
+
201
+ async function main(): Promise<void> {
202
+ const cwd = process.cwd();
203
+ await loadDotenv(cwd);
204
+ const { command, positionals, flags } = parseArgs(process.argv.slice(2));
205
+
206
+ if (command === "view") {
207
+ if (flags.out) {
208
+ const out = await buildView({ input: positionals[0], out: flags.out });
209
+ process.stdout.write(t("cli.view.exported", { out }));
210
+ process.exit(0);
211
+ }
212
+ const server = await startViewServer({ input: positionals[0], port: flags.port });
213
+ process.stdout.write(t("cli.view.url", { url: server.url }));
214
+ if (flags.open !== false) {
215
+ const opened = await openBrowser(server.url);
216
+ if (!opened) process.stderr.write(t("cli.browserOpenFailed", { url: server.url }));
217
+ }
218
+ process.stdout.write(t("cli.pressCtrlC"));
219
+ await new Promise(() => {});
220
+ }
221
+
222
+ if (command === "clean") {
223
+ await rm(join(cwd, ".niceeval"), { recursive: true, force: true });
224
+ process.stdout.write(t("cli.clean.done"));
225
+ process.exit(0);
226
+ }
227
+
228
+ if (command === "init") {
229
+ await initProject(cwd);
230
+ process.stdout.write(t("cli.init.done"));
231
+ process.exit(0);
232
+ }
233
+
234
+ if (command === "watch") {
235
+ process.stdout.write(t("cli.unimplemented", { command }));
236
+ process.exit(0);
237
+ }
238
+
239
+ const config = await loadConfig(cwd);
240
+ const allEvals = await discoverEvals(cwd);
241
+ const evals = flags.tag ? allEvals.filter((e) => e.tags?.includes(flags.tag as string)) : allEvals;
242
+
243
+ if (command === "list") {
244
+ process.stdout.write(t("cli.list.header", { count: evals.length }));
245
+ for (const e of evals) process.stdout.write(` ${e.id}${e.description ? ` — ${e.description}` : ""}\n`);
246
+ process.exit(0);
247
+ }
248
+
249
+ const agentRuns: AgentRun[] = [];
250
+ let expMaxConcurrency: number | undefined;
251
+
252
+ if (command === "exp") {
253
+ if (flags.agent || flags.model) {
254
+ process.stderr.write(t("cli.exp.agentModelFlagUnsupported"));
255
+ process.exit(1);
256
+ }
257
+ const experiments = await discoverExperiments(cwd);
258
+ const expArg = positionals[0];
259
+ const extraPatterns = positionals.slice(1);
260
+ const selected = expArg
261
+ ? experiments.filter((e) => e.group === expArg || e.id === expArg || e.id.startsWith(expArg + "/"))
262
+ : experiments;
263
+ if (selected.length === 0) {
264
+ process.stderr.write(t("cli.experiment.noMatch", {
265
+ arg: expArg ?? t("cli.all"),
266
+ experiments: experiments.map((e) => e.id).join(", ") || t("cli.none"),
267
+ }));
268
+ process.exit(1);
269
+ }
270
+ for (const exp of selected) {
271
+ // 一个实验 = 一个配置(单 model)。跨模型对比写多个实验文件,各钉一个 model。
272
+ agentRuns.push({
273
+ agent: exp.agent,
274
+ model: exp.model,
275
+ flags: exp.flags ?? {},
276
+ runs: flags.runs ?? exp.runs ?? 1,
277
+ earlyExit: flags.earlyExit ?? exp.earlyExit ?? true,
278
+ sandbox: flags.sandbox ?? exp.sandbox ?? config.sandbox,
279
+ timeoutMs: flags.timeout ?? exp.timeoutMs ?? config.timeoutMs,
280
+ budget: flags.budget ?? exp.budget,
281
+ evalFilter: evalsFilterFromExperiment(exp.evals, extraPatterns),
282
+ experimentId: exp.id,
283
+ strict: flags.strict,
284
+ });
285
+ }
286
+ const vals = selected.map((e) => e.maxConcurrency).filter((v): v is number => v !== undefined);
287
+ if (vals.length > 0) expMaxConcurrency = Math.min(...vals);
288
+ } else {
289
+ // 裸 run / `niceeval <eval>` 不再执行。运行配置必须来自 experiments/,
290
+ // 这样 agent/model/flags/runs/budget 与结果聚合都有可签入的身份。
291
+ const experiments = await discoverExperiments(cwd);
292
+ const asExp = experiments.filter((e) =>
293
+ positionals.some((p) => e.group === p || e.id === p || e.id.startsWith(p + "/")),
294
+ );
295
+ process.stderr.write(t("cli.run.experimentRequired"));
296
+ if (asExp.length > 0) {
297
+ process.stderr.write(t("cli.run.experimentRequiredHint", {
298
+ pattern: positionals[0] ?? "",
299
+ kind: asExp.length > 1 ? t("cli.experimentGroup") : "",
300
+ }));
301
+ } else {
302
+ process.stderr.write(t("cli.run.experimentRequiredKnown", {
303
+ experiments: experiments.map((e) => e.id).join(", ") || t("cli.none"),
304
+ }));
305
+ }
306
+ process.exit(1);
307
+ }
308
+
309
+ if (flags.dry) {
310
+ process.stdout.write(t("cli.dry.header", { evals: evals.length, configs: agentRuns.length }));
311
+ for (const run of agentRuns) {
312
+ const matched = evals.filter((e) => run.evalFilter(e.id));
313
+ const who = run.model ? `${run.agent.name}/${run.model}` : run.agent.name;
314
+ process.stdout.write(t("cli.dry.row", {
315
+ who,
316
+ experiment: run.experimentId ? ` (exp ${run.experimentId})` : "",
317
+ evals: matched.map((e) => e.id).join(", ") || t("cli.dry.noMatches"),
318
+ runs: run.runs,
319
+ }));
320
+ }
321
+ process.exit(0);
322
+ }
323
+
324
+ const reporters: Reporter[] = [];
325
+ let onProgress: ((evalId: string, who: string, msg: string) => void) | undefined;
326
+
327
+ if (!flags.quiet) {
328
+ if (process.stderr.isTTY) {
329
+ // TTY 模式:用 live display 替换 Console reporter,把 attempt log 路由到状态表行尾
330
+ const liveRows: LiveRow[] = [];
331
+ for (const agentRun of agentRuns) {
332
+ const who = agentRun.model
333
+ ? `${agentRun.agent.name}/${agentRun.model}`
334
+ : agentRun.agent.name;
335
+ const matched = evals.filter((e) => agentRun.evalFilter(e.id));
336
+ for (const evalDef of matched) {
337
+ liveRows.push({ evalId: evalDef.id, who, total: agentRun.runs });
338
+ }
339
+ }
340
+ const totalAttempts = liveRows.reduce((s, r) => s + r.total, 0);
341
+ const live = LiveReporter(liveRows, totalAttempts);
342
+ reporters.push(live);
343
+ onProgress = (evalId, who, msg) => live.progress(evalId, who, msg);
344
+ } else {
345
+ reporters.push(ConsoleReporter());
346
+ }
347
+ }
348
+ reporters.push(ArtifactsReporter());
349
+ if (flags.junit) reporters.push(JUnit(flags.junit));
350
+ reporters.push(...(config.reporters ?? []));
351
+
352
+ // Ctrl+C / kill 的三级响应,核心目标:任何情况下都不留下孤儿沙箱。
353
+ // 1 次:abort controller → runEvals 把它喂给 Effect signal → 各 attempt 的 Scope 跑 release
354
+ // 停容器(graceful)。同时起一个看门狗:graceful 若迟迟不收口(如 vsb.stop() 挂),
355
+ // 到点直接走兜底强清,不干等。
356
+ // 2 次:用户等不及 —— 立刻兜底强清(带超时)再退,而不是裸 process.exit 把进程连同
357
+ // 在飞的 stop 一起杀掉(那正是之前漏掉孤儿的根因)。
358
+ // 3 次:真不耐烦了,硬退(此时多半已无可清理的)。
359
+ const ctrl = new AbortController();
360
+ let signalCount = 0;
361
+ // 兜底强清 + 退出:只跑一次,带超时(stopAllSandboxes 内每个 stop 各自有超时)。
362
+ let forcing = false;
363
+ const forceCleanupAndExit = (code: number) => {
364
+ if (forcing) return;
365
+ forcing = true;
366
+ void stopAllSandboxes().finally(() => process.exit(code));
367
+ };
368
+ for (const sig of ["SIGINT", "SIGTERM"] as const) {
369
+ process.on(sig, () => {
370
+ signalCount += 1;
371
+ if (signalCount === 1) {
372
+ process.stderr.write(t("cli.interruptCleanup"));
373
+ ctrl.abort();
374
+ // 看门狗:graceful 清理 12s 还没让进程自己收口,就强清兜底。
375
+ setTimeout(() => {
376
+ if (liveSandboxCount() > 0) {
377
+ process.stderr.write(t("cli.fallbackCleanupTimeout"));
378
+ forceCleanupAndExit(130);
379
+ }
380
+ }, 12_000).unref();
381
+ } else if (signalCount === 2) {
382
+ process.stderr.write(t("cli.forceCleanupExit"));
383
+ forceCleanupAndExit(130);
384
+ } else {
385
+ process.exit(130); // 第三次:硬退
386
+ }
387
+ });
388
+ }
389
+
390
+ // 无全局默认:并发上限由 sandbox 后端的推荐值决定。
391
+ // 多个 agentRun 各有 sandbox 时取最小值(最保守的后端决定上限)。
392
+ const sandboxRecs = agentRuns.map((r) => sandboxRecommendedConcurrency(r.sandbox));
393
+ const sandboxDefaultConcurrency = sandboxRecs.length > 0 ? Math.min(...sandboxRecs) : 10;
394
+
395
+ const priorResults = flags.force ? undefined : await loadMostRecentResults(join(cwd, ".niceeval"));
396
+
397
+ const summary = await runEvals({
398
+ config,
399
+ evals,
400
+ agentRuns,
401
+ reporters,
402
+ maxConcurrency: flags.maxConcurrency ?? expMaxConcurrency ?? config.maxConcurrency ?? sandboxDefaultConcurrency,
403
+ signal: ctrl.signal,
404
+ onProgress,
405
+ priorResults,
406
+ });
407
+
408
+ // 正常返回(含被中断后走部分汇总)后再兜一刀:Scope finalizer 没停掉的残留沙箱在这里强清。
409
+ // 跑顺利时登记表已空,是 no-op。
410
+ await stopAllSandboxes();
411
+
412
+ const failedExit = summary.failed > 0 || summary.errored > 0;
413
+ process.exit(failedExit ? 1 : 0);
414
+ }
415
+
416
+ main().catch(async (e) => {
417
+ process.stderr.write(t("cli.error", { error: e instanceof Error ? e.stack ?? e.message : String(e) }));
418
+ // 真·崩溃路径也别留孤儿:强清还活着的沙箱(带超时),再退。
419
+ await stopAllSandboxes();
420
+ process.exit(2);
421
+ });
@@ -0,0 +1,96 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createEvalContext } from "./context.ts";
3
+ import { includes } from "../expect/index.ts";
4
+ import type { Agent, AgentContext, Sandbox, StreamEvent, Turn, TurnInput } from "../types.ts";
5
+
6
+ // 计算工具 + 最终回复"1 + 1 = **2** 哦!😊"——复现截图里的场景:助手回复明明包含 "2",
7
+ // 但 t.check(t.reply, includes("2")) 却失败。
8
+ function calculatorAgent(): Agent {
9
+ return {
10
+ name: "calculator",
11
+ capabilities: { conversation: true, toolObservability: true },
12
+ async send(_input: TurnInput, ctx: AgentContext): Promise<Turn> {
13
+ ctx.session.id = "sess-1";
14
+ const events: StreamEvent[] = [
15
+ { type: "action.called", callId: "c1", name: "calculate", input: { expr: "1+1" }, tool: undefined },
16
+ { type: "action.result", callId: "c1", output: { result: 2 }, status: "completed" },
17
+ { type: "message", role: "assistant", text: "1 + 1 = **2** 哦!😊" },
18
+ ];
19
+ return { events, status: "completed", usage: { inputTokens: 10, outputTokens: 5, requests: 1 } };
20
+ },
21
+ };
22
+ }
23
+
24
+ function fakeSandbox(): Sandbox {
25
+ return {
26
+ runCommand: async () => { throw new Error("not implemented"); },
27
+ runShell: async () => { throw new Error("not implemented"); },
28
+ readFile: async () => "",
29
+ fileExists: async () => false,
30
+ readSourceFiles: async () => Object.assign([], {
31
+ text: () => "",
32
+ code: () => "",
33
+ fileMatching: () => undefined,
34
+ fileMatchingAll: () => undefined,
35
+ hasPath: () => false,
36
+ }),
37
+ writeFiles: async () => {},
38
+ uploadFiles: async () => {},
39
+ uploadDirectory: async () => {},
40
+ stop: async () => {},
41
+ sandboxId: "fake",
42
+ otlpHost: null,
43
+ downloadFile: async () => Buffer.from(""),
44
+ uploadFile: async () => {},
45
+ };
46
+ }
47
+
48
+ function makeContext(agent: Agent) {
49
+ return createEvalContext({
50
+ agent,
51
+ sandbox: fakeSandbox(),
52
+ flags: {},
53
+ signal: new AbortController().signal,
54
+ log: () => {},
55
+ judge: undefined,
56
+ });
57
+ }
58
+
59
+ describe("createEvalContext / TestContext live state", () => {
60
+ it("t.reply reflects the assistant's reply after send(), not the empty initial value", async () => {
61
+ const { context } = makeContext(calculatorAgent());
62
+ await context.send("1+1=?");
63
+ expect(context.reply).toBe("1 + 1 = **2** 哦!😊");
64
+ });
65
+
66
+ it("t.check(t.reply, includes(...)) passes when the reply contains the needle", async () => {
67
+ const { context, state } = makeContext(calculatorAgent());
68
+ await context.send("1+1=?");
69
+ context.check(context.reply, includes("2"));
70
+
71
+ const [result] = await state.collector.finalize({
72
+ events: [],
73
+ facts: { toolCalls: [], subagentCalls: [], inputRequests: [], parked: false, messageCount: 0, compactions: 0 },
74
+ diff: state.late.diff,
75
+ scripts: state.late.scripts,
76
+ usage: { inputTokens: 0, outputTokens: 0 },
77
+ status: "completed",
78
+ readFile: async () => undefined,
79
+ });
80
+ expect(result.passed).toBe(true);
81
+ expect(result.score).toBe(1);
82
+ });
83
+
84
+ it("t.events reflects the turn's events after send(), not an empty snapshot", async () => {
85
+ const { context } = makeContext(calculatorAgent());
86
+ await context.send("1+1=?");
87
+ expect(context.events.length).toBeGreaterThan(0);
88
+ expect(context.events.some((e) => e.type === "message" && e.role === "assistant")).toBe(true);
89
+ });
90
+
91
+ it("t.sessionId reflects the id the agent assigned during send()", async () => {
92
+ const { context } = makeContext(calculatorAgent());
93
+ await context.send("1+1=?");
94
+ expect(context.sessionId).toBe("sess-1");
95
+ });
96
+ });