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
package/src/cli.ts CHANGED
@@ -8,6 +8,7 @@ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
8
8
  import { existsSync } from "node:fs";
9
9
  import { join } from "node:path";
10
10
  import { pathToFileURL } from "node:url";
11
+ import { parseArgs as nodeParseArgs } from "node:util";
11
12
  import { discoverEvals, discoverExperiments, makeFilter } from "./runner/discover.ts";
12
13
  import { runEvals, type AgentRun } from "./runner/run.ts";
13
14
  import { stopAllSandboxes, liveSandboxCount } from "./sandbox/registry.ts";
@@ -16,13 +17,22 @@ import { Console as ConsoleReporter } from "./runner/reporters/console.ts";
16
17
  import { JUnit } from "./runner/reporters/json.ts";
17
18
  import { Live as LiveReporter, type LiveRow } from "./runner/reporters/live.ts";
18
19
  import { Artifacts as ArtifactsReporter } from "./runner/reporters/artifacts.ts";
19
- import { buildView, startViewServer, loadMostRecentResults } from "./view/index.ts";
20
+ import { buildView, startViewServer, loadMostRecentResults, IncompatibleResultsError } from "./view/index.ts";
20
21
  import { t } from "./i18n/index.ts";
22
+ import { formatThrown } from "./util.ts";
21
23
  import type { Config, DiscoveredExperiment, Reporter } from "./types.ts";
22
24
 
25
+ /** `niceeval view <summary.json>` 指向版本不同的报告时:打印 npx 提示后退出,不抛堆栈。 */
26
+ function exitOnIncompatibleResults(e: unknown): never {
27
+ if (e instanceof IncompatibleResultsError) {
28
+ process.stderr.write(e.message);
29
+ process.exit(1);
30
+ }
31
+ throw e;
32
+ }
33
+
23
34
  interface Flags {
24
35
  agent?: string;
25
- sandbox?: string;
26
36
  model?: string;
27
37
  runs?: number;
28
38
  maxConcurrency?: number;
@@ -38,85 +48,111 @@ interface Flags {
38
48
  open?: boolean;
39
49
  out?: string;
40
50
  port?: number;
51
+ help: boolean;
41
52
  }
42
53
 
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
- ]);
54
+ // 表驱动的 flag 定义(node:util parseArgs)。--no-x 显式声明,不依赖 allowNegative(需 Node 20.14+,
55
+ // engines 是 >=18)。未知 flag 由 strict 模式报清晰错误,不再静默吞掉后面的位置参数。
56
+ const FLAG_OPTIONS = {
57
+ agent: { type: "string" },
58
+ model: { type: "string" },
59
+ runs: { type: "string" },
60
+ "max-concurrency": { type: "string" },
61
+ timeout: { type: "string" },
62
+ budget: { type: "string" },
63
+ tag: { type: "string" },
64
+ junit: { type: "string" },
65
+ out: { type: "string" },
66
+ port: { type: "string" },
67
+ // --sandbox 已移除(sandbox 归 config/experiment);留着解析是为了给出迁移提示而非「未知 flag」。
68
+ sandbox: { type: "string" },
69
+ // --watch / --json:文档曾接受、尚未实现的 flag。容忍(no-op)而不是硬报「未知 flag」,
70
+ // 免得照旧文档写的脚本直接崩;真正实现前不接任何行为。
71
+ watch: { type: "boolean" },
72
+ json: { type: "boolean" },
73
+ dry: { type: "boolean" },
74
+ quiet: { type: "boolean" },
75
+ force: { type: "boolean" },
76
+ strict: { type: "boolean" },
77
+ "early-exit": { type: "boolean" },
78
+ "no-early-exit": { type: "boolean" },
79
+ open: { type: "boolean" },
80
+ "no-open": { type: "boolean" },
81
+ help: { type: "boolean", short: "h" },
82
+ } as const;
83
+
84
+ function numberFlag(name: string, raw: string | undefined): number | undefined {
85
+ if (raw === undefined) return undefined;
86
+ const n = Number(raw);
87
+ if (!Number.isFinite(n)) {
88
+ process.stderr.write(t("cli.flag.invalidNumber", { flag: name, value: raw }));
89
+ process.exit(1);
90
+ }
91
+ return n;
92
+ }
56
93
 
57
94
  function parseArgs(argv: string[]): { command: string; positionals: string[]; flags: Flags } {
58
95
  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
96
 
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;
97
+ let values: Record<string, string | boolean | undefined>;
98
+ let rawPositionals: string[];
99
+ try {
100
+ const parsed = nodeParseArgs({ args: argv, options: FLAG_OPTIONS, allowPositionals: true, strict: true });
101
+ values = parsed.values as Record<string, string | boolean | undefined>;
102
+ rawPositionals = parsed.positionals;
103
+ } catch (e) {
104
+ process.stderr.write(t("cli.flag.parseError", { message: e instanceof Error ? e.message : String(e) }));
105
+ process.exit(1);
69
106
  }
70
107
 
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
- }
108
+ if (values.sandbox !== undefined) {
109
+ process.stderr.write(t("cli.sandboxFlagRemoved"));
110
+ process.exit(1);
111
+ }
112
+
113
+ // 第一个位置参数若是已知命令,则为命令;其余是 eval id 前缀 / view 输入。
114
+ const commands = new Set(["exp", "list", "view", "clean", "init", "watch", "run"]);
115
+ let command = "run";
116
+ let positionals = rawPositionals;
117
+ if (rawPositionals[0] && commands.has(rawPositionals[0])) {
118
+ command = rawPositionals[0];
119
+ positionals = rawPositionals.slice(1);
116
120
  }
121
+
122
+ const flags: Flags = {
123
+ agent: values.agent as string | undefined,
124
+ model: values.model as string | undefined,
125
+ runs: numberFlag("runs", values.runs as string | undefined),
126
+ maxConcurrency: numberFlag("max-concurrency", values["max-concurrency"] as string | undefined),
127
+ timeout: numberFlag("timeout", values.timeout as string | undefined),
128
+ budget: numberFlag("budget", values.budget as string | undefined),
129
+ tag: values.tag as string | undefined,
130
+ junit: values.junit as string | undefined,
131
+ out: values.out as string | undefined,
132
+ port: numberFlag("port", values.port as string | undefined),
133
+ dry: values.dry === true,
134
+ quiet: values.quiet === true,
135
+ force: values.force === true,
136
+ strict: values.strict === true,
137
+ earlyExit: values["no-early-exit"] === true ? false : values["early-exit"] === true ? true : undefined,
138
+ open: values["no-open"] === true ? false : values.open === true ? true : undefined,
139
+ help: values.help === true,
140
+ };
117
141
  return { command, positionals, flags };
118
142
  }
119
143
 
144
+ /** 调度项的环境变量层(标志 > 环境变量 > config > 默认,见 docs/cli.md「配置优先级」)。 */
145
+ function envNumber(name: string): number | undefined {
146
+ const raw = process.env[name]?.trim();
147
+ if (!raw) return undefined;
148
+ const n = Number(raw);
149
+ if (!Number.isFinite(n)) {
150
+ process.stderr.write(t("cli.envInvalidNumber", { name, value: raw }));
151
+ process.exit(1);
152
+ }
153
+ return n;
154
+ }
155
+
120
156
  /** 加载 cwd/.env(不覆盖已有环境变量)。 */
121
157
  async function loadDotenv(cwd: string): Promise<void> {
122
158
  const path = join(cwd, ".env");
@@ -203,13 +239,19 @@ async function main(): Promise<void> {
203
239
  await loadDotenv(cwd);
204
240
  const { command, positionals, flags } = parseArgs(process.argv.slice(2));
205
241
 
242
+ // --help 不需要 config,先于一切命令处理。
243
+ if (flags.help) {
244
+ process.stdout.write(t("cli.help"));
245
+ process.exit(0);
246
+ }
247
+
206
248
  if (command === "view") {
207
249
  if (flags.out) {
208
- const out = await buildView({ input: positionals[0], out: flags.out });
250
+ const out = await buildView({ input: positionals[0], out: flags.out }).catch(exitOnIncompatibleResults);
209
251
  process.stdout.write(t("cli.view.exported", { out }));
210
252
  process.exit(0);
211
253
  }
212
- const server = await startViewServer({ input: positionals[0], port: flags.port });
254
+ const server = await startViewServer({ input: positionals[0], port: flags.port }).catch(exitOnIncompatibleResults);
213
255
  process.stdout.write(t("cli.view.url", { url: server.url }));
214
256
  if (flags.open !== false) {
215
257
  const opened = await openBrowser(server.url);
@@ -273,11 +315,11 @@ async function main(): Promise<void> {
273
315
  agent: exp.agent,
274
316
  model: exp.model,
275
317
  flags: exp.flags ?? {},
276
- runs: flags.runs ?? exp.runs ?? 1,
318
+ runs: flags.runs ?? envNumber("NICEEVAL_RUNS") ?? exp.runs ?? 1,
277
319
  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,
320
+ sandbox: exp.sandbox ?? config.sandbox,
321
+ timeoutMs: flags.timeout ?? envNumber("NICEEVAL_TIMEOUT") ?? exp.timeoutMs ?? config.timeoutMs,
322
+ budget: flags.budget ?? envNumber("NICEEVAL_BUDGET") ?? exp.budget,
281
323
  evalFilter: evalsFilterFromExperiment(exp.evals, extraPatterns),
282
324
  experimentId: exp.id,
283
325
  strict: flags.strict,
@@ -399,7 +441,12 @@ async function main(): Promise<void> {
399
441
  evals,
400
442
  agentRuns,
401
443
  reporters,
402
- maxConcurrency: flags.maxConcurrency ?? expMaxConcurrency ?? config.maxConcurrency ?? sandboxDefaultConcurrency,
444
+ maxConcurrency:
445
+ flags.maxConcurrency ??
446
+ envNumber("NICEEVAL_MAX_CONCURRENCY") ??
447
+ expMaxConcurrency ??
448
+ config.maxConcurrency ??
449
+ sandboxDefaultConcurrency,
403
450
  signal: ctrl.signal,
404
451
  onProgress,
405
452
  priorResults,
@@ -414,7 +461,7 @@ async function main(): Promise<void> {
414
461
  }
415
462
 
416
463
  main().catch(async (e) => {
417
- process.stderr.write(t("cli.error", { error: e instanceof Error ? e.stack ?? e.message : String(e) }));
464
+ process.stderr.write(t("cli.error", { error: formatThrown(e) }));
418
465
  // 真·崩溃路径也别留孤儿:强清还活着的沙箱(带超时),再退。
419
466
  await stopAllSandboxes();
420
467
  process.exit(2);
@@ -1,16 +1,17 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import { createEvalContext } from "./context.ts";
3
3
  import { includes } from "../expect/index.ts";
4
- import type { Agent, AgentContext, Sandbox, StreamEvent, Turn, TurnInput } from "../types.ts";
4
+ import type { Agent, AgentContext, InputRequest, Sandbox, StreamEvent, Turn, TurnInput } from "../types.ts";
5
5
 
6
6
  // 计算工具 + 最终回复"1 + 1 = **2** 哦!😊"——复现截图里的场景:助手回复明明包含 "2",
7
7
  // 但 t.check(t.reply, includes("2")) 却失败。
8
8
  function calculatorAgent(): Agent {
9
9
  return {
10
10
  name: "calculator",
11
- capabilities: { conversation: true, toolObservability: true },
11
+ // 测试注入了真实的 fake sandbox,kind: "sandbox" 让 t.sandbox 过沙箱能力守卫。
12
+ kind: "sandbox",
12
13
  async send(_input: TurnInput, ctx: AgentContext): Promise<Turn> {
13
- ctx.session.id = "sess-1";
14
+ ctx.session.capture("sess-1");
14
15
  const events: StreamEvent[] = [
15
16
  { type: "action.called", callId: "c1", name: "calculate", input: { expr: "1+1" }, tool: undefined },
16
17
  { type: "action.result", callId: "c1", output: { result: 2 }, status: "completed" },
@@ -21,8 +22,12 @@ function calculatorAgent(): Agent {
21
22
  };
22
23
  }
23
24
 
24
- function fakeSandbox(): Sandbox {
25
+ type FakeSandbox = Sandbox & { calls: { uploadDirectory: [string, string | undefined][] } };
26
+
27
+ function fakeSandbox(): FakeSandbox {
28
+ const calls: { uploadDirectory: [string, string | undefined][] } = { uploadDirectory: [] };
25
29
  return {
30
+ workdir: "/sandbox/work",
26
31
  runCommand: async () => { throw new Error("not implemented"); },
27
32
  runShell: async () => { throw new Error("not implemented"); },
28
33
  readFile: async () => "",
@@ -36,23 +41,27 @@ function fakeSandbox(): Sandbox {
36
41
  }),
37
42
  writeFiles: async () => {},
38
43
  uploadFiles: async () => {},
39
- uploadDirectory: async () => {},
44
+ uploadDirectory: async (localDir, targetDir) => {
45
+ calls.uploadDirectory.push([localDir, targetDir]);
46
+ },
40
47
  stop: async () => {},
41
48
  sandboxId: "fake",
42
49
  otlpHost: null,
43
50
  downloadFile: async () => Buffer.from(""),
44
51
  uploadFile: async () => {},
52
+ calls,
45
53
  };
46
54
  }
47
55
 
48
- function makeContext(agent: Agent) {
56
+ function makeContext(agent: Agent, sandbox = fakeSandbox(), evalBaseDir?: string) {
49
57
  return createEvalContext({
50
58
  agent,
51
- sandbox: fakeSandbox(),
59
+ sandbox,
52
60
  flags: {},
53
61
  signal: new AbortController().signal,
54
62
  log: () => {},
55
63
  judge: undefined,
64
+ evalBaseDir,
56
65
  });
57
66
  }
58
67
 
@@ -79,6 +88,25 @@ describe("createEvalContext / TestContext live state", () => {
79
88
  });
80
89
  expect(result.passed).toBe(true);
81
90
  expect(result.score).toBe(1);
91
+ expect(result.evidence).toBeUndefined();
92
+ });
93
+
94
+ it("t.check(...) attaches the actually-checked value as evidence when it fails", async () => {
95
+ const { context, state } = makeContext(calculatorAgent());
96
+ await context.send("1+1=?");
97
+ context.check(context.reply, includes("banana"));
98
+
99
+ const [result] = await state.collector.finalize({
100
+ events: [],
101
+ facts: { toolCalls: [], subagentCalls: [], inputRequests: [], parked: false, messageCount: 0, compactions: 0 },
102
+ diff: state.late.diff,
103
+ scripts: state.late.scripts,
104
+ usage: { inputTokens: 0, outputTokens: 0 },
105
+ status: "completed",
106
+ readFile: async () => undefined,
107
+ });
108
+ expect(result.passed).toBe(false);
109
+ expect(result.evidence).toBe("1 + 1 = **2** 哦!😊");
82
110
  });
83
111
 
84
112
  it("t.events reflects the turn's events after send(), not an empty snapshot", async () => {
@@ -93,4 +121,172 @@ describe("createEvalContext / TestContext live state", () => {
93
121
  await context.send("1+1=?");
94
122
  expect(context.sessionId).toBe("sess-1");
95
123
  });
124
+
125
+ it("exposes sandbox workdir to eval authors", () => {
126
+ const { context } = makeContext(calculatorAgent());
127
+ expect(context.sandbox.workdir).toBe("/sandbox/work");
128
+ });
129
+
130
+ it("resolves uploadDirectory local paths relative to the eval file directory", async () => {
131
+ const sandbox = fakeSandbox();
132
+ const { context } = makeContext(calculatorAgent(), sandbox, "/repo/evals/nested");
133
+
134
+ await context.sandbox.uploadDirectory("../fixtures/app", "src");
135
+
136
+ expect(sandbox.calls.uploadDirectory).toEqual([
137
+ ["/repo/evals/fixtures/app", "src"],
138
+ ]);
139
+ });
140
+ });
141
+
142
+ // agent 按调用次数依次吐出预设的 Turn 序列,同时记下每次收到的 TurnInput——
143
+ // 用来断言 t.respond()/t.respondAll() 到 adapter 的 input.responses 长什么样。
144
+ function scriptedAgent(turns: Turn[]): Agent & { received: TurnInput[] } {
145
+ const received: TurnInput[] = [];
146
+ let i = 0;
147
+ const agent: Agent = {
148
+ name: "scripted",
149
+ kind: "remote",
150
+ async send(input: TurnInput) {
151
+ received.push(input);
152
+ const turn = turns[Math.min(i, turns.length - 1)] as Turn;
153
+ i++;
154
+ return turn;
155
+ },
156
+ };
157
+ return Object.assign(agent, { received });
158
+ }
159
+
160
+ function waitingTurn(...requests: InputRequest[]): Turn {
161
+ return {
162
+ status: "waiting",
163
+ events: requests.map((request) => ({ type: "input.requested" as const, request })),
164
+ };
165
+ }
166
+
167
+ function completedTurn(text = "ok"): Turn {
168
+ return { status: "completed", events: [{ type: "message", role: "assistant", text }] };
169
+ }
170
+
171
+ describe("t.respond() / t.respondAll(): structured InputResponse", () => {
172
+ it("string arg hitting a pending request's option becomes { requestId, optionId }", async () => {
173
+ const agent = scriptedAgent([
174
+ waitingTurn({ id: "req_1", action: "send_email", options: [{ id: "approve" }, { id: "deny" }] }),
175
+ completedTurn(),
176
+ ]);
177
+ const { context } = makeContext(agent);
178
+ await context.send("draft an email");
179
+ await context.respond("approve");
180
+
181
+ expect(agent.received[1]?.text).toBe("approve");
182
+ expect(agent.received[1]?.responses).toEqual([{ requestId: "req_1", optionId: "approve" }]);
183
+ });
184
+
185
+ it("string arg that matches no option becomes free-text { requestId, text }", async () => {
186
+ const agent = scriptedAgent([
187
+ waitingTurn({ id: "req_1", action: "send_email", options: [{ id: "approve" }, { id: "deny" }] }),
188
+ completedTurn(),
189
+ ]);
190
+ const { context } = makeContext(agent);
191
+ await context.send("draft an email");
192
+ await context.respond("change the recipient to ceo@corp.com");
193
+
194
+ expect(agent.received[1]?.responses).toEqual([
195
+ { requestId: "req_1", text: "change the recipient to ceo@corp.com" },
196
+ ]);
197
+ });
198
+
199
+ it("string arg throws a clear error when multiple requests are pending (needs object form to disambiguate)", async () => {
200
+ const agent = scriptedAgent([
201
+ waitingTurn(
202
+ { id: "req_1", action: "edit_a", options: [{ id: "approve" }, { id: "deny" }] },
203
+ { id: "req_2", action: "edit_b", options: [{ id: "approve" }, { id: "deny" }] },
204
+ ),
205
+ completedTurn(),
206
+ ]);
207
+ const { context } = makeContext(agent);
208
+ await context.send("apply two edits");
209
+
210
+ await expect(context.respond("approve")).rejects.toThrow(/字符串回答无法对位|cannot be matched/);
211
+ });
212
+
213
+ it("object form { request, optionId } disambiguates when multiple requests are pending", async () => {
214
+ const agent = scriptedAgent([
215
+ waitingTurn(
216
+ { id: "req_1", action: "edit_a", options: [{ id: "approve" }, { id: "deny" }] },
217
+ { id: "req_2", action: "edit_b", options: [{ id: "approve" }, { id: "deny" }] },
218
+ ),
219
+ completedTurn(),
220
+ ]);
221
+ const { context } = makeContext(agent);
222
+ await context.send("apply two edits");
223
+ const req2 = context.requireInputRequest({ action: "edit_b" });
224
+ await context.respond({ request: req2, optionId: "deny" });
225
+
226
+ expect(agent.received[1]?.text).toBe("deny");
227
+ expect(agent.received[1]?.responses).toEqual([{ requestId: "req_2", optionId: "deny" }]);
228
+ });
229
+
230
+ it("object form with an optionId not present in the request's options throws instead of silently forwarding", async () => {
231
+ const agent = scriptedAgent([
232
+ waitingTurn({ id: "req_1", action: "send_email", options: [{ id: "approve" }, { id: "deny" }] }),
233
+ completedTurn(),
234
+ ]);
235
+ const { context } = makeContext(agent);
236
+ await context.send("draft an email");
237
+ const req = context.requireInputRequest();
238
+
239
+ await expect(context.respond({ request: req, optionId: "yolo" })).rejects.toThrow(/req_1/);
240
+ // 校验先于发送:没有第二次 send 发生。
241
+ expect(agent.received.length).toBe(1);
242
+ });
243
+
244
+ it("respondAll(optionId) answers every pending request and joins input.text with \\n", async () => {
245
+ const agent = scriptedAgent([
246
+ waitingTurn(
247
+ { id: "req_1", action: "edit_a", options: [{ id: "approve" }, { id: "deny" }] },
248
+ { id: "req_2", action: "edit_b", options: [{ id: "approve" }, { id: "deny" }] },
249
+ ),
250
+ completedTurn(),
251
+ ]);
252
+ const { context } = makeContext(agent);
253
+ await context.send("apply two edits");
254
+ await context.respondAll("approve");
255
+
256
+ expect(agent.received[1]?.text).toBe("approve\napprove");
257
+ expect(agent.received[1]?.responses).toEqual([
258
+ { requestId: "req_1", optionId: "approve" },
259
+ { requestId: "req_2", optionId: "approve" },
260
+ ]);
261
+ });
262
+
263
+ it("respondAll(optionId) validates the option against every pending request before sending anything", async () => {
264
+ const agent = scriptedAgent([
265
+ waitingTurn(
266
+ { id: "req_1", action: "edit_a", options: [{ id: "approve" }, { id: "deny" }] },
267
+ { id: "req_2", action: "edit_b", options: [{ id: "yes" }, { id: "no" }] },
268
+ ),
269
+ completedTurn(),
270
+ ]);
271
+ const { context } = makeContext(agent);
272
+ await context.send("apply two edits");
273
+
274
+ await expect(context.respondAll("approve")).rejects.toThrow(/req_2/);
275
+ expect(agent.received.length).toBe(1);
276
+ });
277
+
278
+ it("TurnInput.responses reaches the adapter unchanged (not derived/guessed on the adapter side)", async () => {
279
+ const agent = scriptedAgent([
280
+ waitingTurn({ id: "req_1", action: "send_email", options: [{ id: "approve" }, { id: "deny" }] }),
281
+ completedTurn(),
282
+ ]);
283
+ const { context } = makeContext(agent);
284
+ await context.send("draft an email");
285
+ const req = context.requireInputRequest();
286
+ await context.respond({ request: req, text: "wait, add a subject line first" });
287
+
288
+ expect(agent.received[1]?.responses).toEqual([
289
+ { requestId: "req_1", text: "wait, add a subject line first" },
290
+ ]);
291
+ });
96
292
  });