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.
- package/README.md +8 -8
- package/README.zh.md +13 -13
- package/docs/README.md +119 -0
- package/docs/adapters/README.md +60 -0
- package/docs/adapters/authoring.md +179 -0
- package/docs/adapters/coding-agent-skills-plugins.md +324 -0
- package/docs/adapters/collection.md +128 -0
- package/docs/adapters/contract.md +263 -0
- package/docs/adapters/reference/agent-eval.md +215 -0
- package/docs/adapters/reference/agent-loop-apis.md +69 -0
- package/docs/adapters/reference/claude-code-otel-telemetry.md +100 -0
- package/docs/adapters/reference/eve-protocol.md +127 -0
- package/docs/adapters/reference/otel-genai.md +107 -0
- package/docs/adapters/reference/otel-instrumentation.md +65 -0
- package/docs/adapters/targets.md +99 -0
- package/docs/architecture.md +140 -0
- package/docs/assertions.md +388 -0
- package/docs/capabilities-by-construction.md +48 -0
- package/docs/cli.md +171 -0
- package/docs/concepts.md +106 -0
- package/docs/e2e-ci.md +295 -0
- package/docs/eval-authoring.md +319 -0
- package/docs/experiments.md +154 -0
- package/docs/getting-started.md +224 -0
- package/docs/multi-agent.md +145 -0
- package/docs/observability.md +333 -0
- package/docs/origin-integration.md +195 -0
- package/docs/references.md +35 -0
- package/docs/results-format.md +228 -0
- package/docs/runner.md +104 -0
- package/docs/sandbox.md +276 -0
- package/docs/scoring.md +119 -0
- package/docs/source-map.md +106 -0
- package/docs/tier-sync.md +177 -0
- package/docs/view.md +157 -0
- package/docs/vision.md +88 -0
- package/package.json +35 -5
- package/src/agents/ai-sdk-otel.ts +0 -0
- package/src/agents/ai-sdk.test.ts +377 -0
- package/src/agents/ai-sdk.ts +577 -0
- package/src/agents/bub.ts +30 -37
- package/src/agents/claude-code.ts +7 -4
- package/src/agents/codex.ts +15 -10
- package/src/agents/index.ts +43 -1
- package/src/agents/sdk-streams.test.ts +145 -0
- package/src/agents/sdk-streams.ts +457 -0
- package/src/agents/shared.ts +77 -39
- package/src/agents/streaming.test.ts +152 -0
- package/src/agents/streaming.ts +195 -0
- package/src/agents/types.ts +218 -0
- package/src/agents/ui-message-stream.test.ts +249 -0
- package/src/agents/ui-message-stream.ts +372 -0
- package/src/cli.ts +124 -77
- package/src/context/context.test.ts +203 -7
- package/src/context/context.ts +158 -49
- package/src/context/session.test.ts +96 -0
- package/src/context/session.ts +119 -9
- package/src/context/types.ts +205 -0
- package/src/define.ts +4 -14
- package/src/expect/index.ts +8 -71
- package/src/i18n/core.ts +28 -0
- package/src/i18n/en.ts +47 -5
- package/src/i18n/index.ts +5 -17
- package/src/i18n/zh-CN.ts +47 -5
- package/src/index.ts +12 -0
- package/src/loaders/index.ts +8 -39
- package/src/o11y/otlp/canonical.ts +7 -6
- package/src/o11y/otlp/mappers/codex.ts +10 -8
- package/src/o11y/otlp/mappers/index.ts +9 -21
- package/src/o11y/otlp/receiver.ts +25 -7
- package/src/o11y/otlp/sandbox-receiver.ts +57 -21
- package/src/o11y/otlp/turn-otel.test.ts +110 -0
- package/src/o11y/otlp/turn-otel.ts +125 -0
- package/src/o11y/parsers/bub.ts +13 -11
- package/src/o11y/parsers/claude-code.ts +27 -51
- package/src/o11y/parsers/codex.ts +29 -46
- package/src/o11y/parsers/index.ts +5 -14
- package/src/o11y/tool-names.test.ts +61 -0
- package/src/o11y/tool-names.ts +74 -0
- package/src/o11y/types.ts +135 -0
- package/src/runner/attempt.ts +456 -0
- package/src/runner/fingerprint.ts +59 -0
- package/src/runner/remote-sandbox.ts +53 -0
- package/src/runner/report.ts +53 -0
- package/src/runner/reporters/artifacts.ts +25 -4
- package/src/runner/reporters/console.ts +2 -8
- package/src/runner/reporters/live.ts +2 -8
- package/src/runner/reporters/shared.ts +15 -0
- package/src/runner/reporters/table.ts +10 -62
- package/src/runner/run.ts +114 -619
- package/src/runner/types.ts +237 -0
- package/src/sandbox/checkpoint.ts +15 -13
- package/src/sandbox/docker-stream.ts +87 -0
- package/src/sandbox/docker.ts +42 -120
- package/src/sandbox/e2b.ts +24 -75
- package/src/sandbox/local-files.ts +33 -0
- package/src/sandbox/paths.test.ts +85 -0
- package/src/sandbox/paths.ts +45 -0
- package/src/sandbox/resolve.ts +10 -34
- package/src/sandbox/shell.ts +17 -0
- package/src/sandbox/source-files.ts +42 -2
- package/src/sandbox/types.ts +158 -0
- package/src/sandbox/vercel.ts +55 -71
- package/src/scoring/collector.ts +3 -2
- package/src/scoring/judge.ts +72 -146
- package/src/scoring/match.ts +79 -0
- package/src/scoring/types.ts +76 -0
- package/src/shared/aggregate.ts +48 -0
- package/src/shared/format.ts +20 -0
- package/src/shared/outcome.ts +48 -0
- package/src/shared/types.ts +37 -0
- package/src/types.ts +20 -911
- package/src/view/aggregate.ts +103 -0
- package/src/view/app/App.tsx +26 -5
- package/src/view/app/components/AttemptModal.tsx +12 -3
- package/src/view/app/components/CodeView.tsx +9 -5
- package/src/view/app/components/CopyControls.tsx +4 -4
- package/src/view/app/components/ExperimentTable.tsx +5 -5
- package/src/view/app/i18n.ts +31 -7
- package/src/view/app/lib/format.ts +17 -20
- package/src/view/app/lib/outcome.ts +4 -16
- package/src/view/app/main.tsx +3 -5
- package/src/view/app/pages/RunsPage.tsx +2 -2
- package/src/view/app/pages/TracesPage.tsx +2 -2
- package/src/view/app/shared.ts +2 -1
- package/src/view/app/types.ts +6 -43
- package/src/view/client-dist/app.css +1 -1
- package/src/view/client-dist/app.js +18 -18
- package/src/view/index.ts +24 -401
- package/src/view/loader.ts +234 -0
- package/src/view/server.ts +136 -0
- package/src/view/shared/types.ts +64 -0
- package/src/view/styles.css +60 -9
package/docs/vision.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Vision
|
|
2
|
+
|
|
3
|
+
niceeval 应该让人感觉是**一个评测工具**,而不是"一堆按被测对象形状各写一遍的脚本"。无论你评的是一个远程 HTTP agent,还是一个塞进 Docker 里跑编码任务的 Claude Code,写法和读法都应该是同一套。
|
|
4
|
+
|
|
5
|
+
为了在差异巨大的被测对象之间维持这种一致体验,代码库守一条规则:
|
|
6
|
+
|
|
7
|
+
> **核心(core)拥有通用的"评测控制面";"连到哪个 AI"由 `Agent`(自实现的 `Adapter`)拥有,"在哪里跑"由 `Sandbox` 拥有。**
|
|
8
|
+
|
|
9
|
+
这条规则是从 crabbox(一个面向 ~27 个云 provider 的远程执行层)学来的同一种纪律:核心永远不按名字分支,它对着能力(capability)分发。
|
|
10
|
+
|
|
11
|
+
## 三种"快"
|
|
12
|
+
|
|
13
|
+
niceeval 这个名字是一份承诺。把它拆开,就是三条要落到设计上的约束。
|
|
14
|
+
|
|
15
|
+
### 写得快
|
|
16
|
+
|
|
17
|
+
写一个 eval 的成本必须趋近于"写一句断言"。为此:
|
|
18
|
+
|
|
19
|
+
- **路径即身份。** `evals/weather/brooklyn.eval.ts` 的 id 自动是 `weather/brooklyn`,禁止手写 id/name。改名即改路径,不会有"id 和文件名对不上"的腐烂。
|
|
20
|
+
- **一文件一 eval,数组即扇出。** 默认导出一个 eval;默认导出一个数组就扇出成 `weather/0000`、`weather/0001`…… 数据集就是 `rows.map(row => defineEval(...))`。
|
|
21
|
+
- **线性书写,就地断言。** `async test(t)` 里顺着写 `t.send` / `t.check`,把中间结果赋给局部变量再断言。没有回调金字塔。
|
|
22
|
+
- **gate 与 atLeast 两档。** 硬性要求用 `gate`(不过即 fail),带阈值的质量分用 `atLeast`(默认记录为软失败,只在 `--strict` 下才 fail)。作者不必为"这条算不算致命"反复纠结。
|
|
23
|
+
|
|
24
|
+
### 跑得快
|
|
25
|
+
|
|
26
|
+
评测天然是 I/O 密集且昂贵(每个 case 可能是一次 LLM 调用、一次沙箱启动、一次完整测试套件)。运行器必须把这份昂贵摊薄:
|
|
27
|
+
|
|
28
|
+
- **有界并发。** 可配置的最大并发,池满则等任一完成再补位。
|
|
29
|
+
- **指纹缓存。** 用 `(eval 代码 + 配置)` 的指纹标识一次运行;已通过且指纹未变的,默认跳过。重跑只重算改动过的。
|
|
30
|
+
- **沙箱复用与预热。** 沙箱启动是大头;支持预热池、跨 case 复用,把冷启动从关键路径上挪走。
|
|
31
|
+
- **早停(earlyExit)。** 一个任务跑 N 次取通过率时,一旦先过了一次,就中止该任务剩余的重试(可关)。
|
|
32
|
+
|
|
33
|
+
### 看得快
|
|
34
|
+
|
|
35
|
+
评测的产出不只是"过/挂",而是"为什么"。失败必须能被快速归因:
|
|
36
|
+
|
|
37
|
+
- **流式控制台。** 每个 case 完成即出一行,失败的断言内联展开,不用等全部跑完。
|
|
38
|
+
- **结构化工件。** 事件流、transcript、文件 diff、断言结果都落盘成机器可读格式,可回放、可二次分析。
|
|
39
|
+
- **统一 trace。** 不同 agent 的原始 transcript 被归一化成同一套事件模型(工具调用、文件读写、shell 命令、思考块),"agent 到底干了什么"一眼可见;OTLP traces 则统一归一到 **OpenTelemetry GenAI 语义约定**(每 agent 一个薄 mapper),view 只认这套 canonical,跨 agent 瀑布图天然可比。详见 [Observability](observability.md)。
|
|
40
|
+
|
|
41
|
+
## 两个正交概念,一条承重墙
|
|
42
|
+
|
|
43
|
+
被测对象的差异被收进两个互相正交的轴。理解它们的边界,就理解了整个库。
|
|
44
|
+
|
|
45
|
+
### `Agent` —— 连到哪个 AI(以及谁来定协议)
|
|
46
|
+
|
|
47
|
+
`Agent` 回答"我把任务发给谁、怎么收结果"。这里有一个**关键的、区别于 eve 的前提**:
|
|
48
|
+
|
|
49
|
+
> **niceeval 不定义任何 agent 协议。** 每一条连到 AI 的连接 —— 你自己的 agent、你的后端服务、Claude Code / Codex —— 都是自己实现的 **Adapter**。experiment 引用一个 agent,而不是给一个 url。
|
|
50
|
+
|
|
51
|
+
eve 能用一个 url 当 target,是因为它定义了一套协议、被测 agent 恰好会说。niceeval 没有这个假设,所以**没有 `--url`、没有通用的 http target**:连你自己的服务也是你写一个 agent,URL 是它的内部配置。
|
|
52
|
+
|
|
53
|
+
- **Agent** 是抽象(niceeval 眼里"一条连到 AI 的连接")。`Agent.kind` 只有两类。
|
|
54
|
+
- **Adapter** 是它的实现,由用户编写;按 `kind` 分两类:远程(`defineAgent`,按你服务的协议发请求)、沙箱(`defineSandboxAgent`,在 `Sandbox` 里 spawn coding agent 的 CLI)。进程内直调你的函数不是独立的第三类——它只是远程型 `send` 内部的一种实现方式,而且不推荐:测函数绕过了用户实际走的链路,见[接入你的 Agent · 为什么不直调](../docs-site/zh/guides/connect-your-agent.mdx)。
|
|
55
|
+
|
|
56
|
+
核心通过 `Agent.kind` 和 `send` 的构造证据(而不是声明式能力位)决定 `t` 暴露哪些动作:会话型暴露 `t.send` / `t.calledTool`;沙箱型额外暴露 `t.sandbox`,里面按语义分成文件 IO、命令执行、结果断言 / diff。接一个新 agent(无论是你的 agent 还是 bub)= 实现一个 Adapter,**不动核心一行**。详见 [Adapter 契约](adapters/contract.md)。
|
|
57
|
+
|
|
58
|
+
### `Sandbox` —— 沙箱型 agent 在哪里跑
|
|
59
|
+
|
|
60
|
+
一个 `Sandbox` 把"隔离环境"的全部特殊性关进一个盒子:跑命令、读写文件、上传/下载、用 `cwd` 指定命令目录、起停。Docker 是一个实现,Vercel Sandbox 是一个实现,其它三方各是一个实现。后端按环境自动选择(有云 token 用云,否则用 Docker),也可显式指定。它与 Agent 正交:任意沙箱型 agent × 任意 sandbox 后端自由组合。详见 [Sandbox](sandbox.md)。
|
|
61
|
+
|
|
62
|
+
## 边界画在"行为"上
|
|
63
|
+
|
|
64
|
+
哪些地方允许出现 agent 名字 / sandbox 名字?**路由可以,行为不行。**
|
|
65
|
+
|
|
66
|
+
- **允许:** 配置 schema、CLI 标志、experiment 引用 agent / sandbox 这种路由。这是核心的活。
|
|
67
|
+
- **不允许:** 一旦代码要决定"CLI 参数怎么拼""transcript 在哪""命令怎么在容器里多路复用",这个决定就属于对应的 Adapter / Sandbox,**绝不**以 `if (name === ...)` 的形式穿过运行器、评分、报告这些核心路径。
|
|
68
|
+
|
|
69
|
+
健康的形状是这样:
|
|
70
|
+
|
|
71
|
+
- **核心** 发现 eval、收集断言、计算判决、调度并发、做缓存、写报告与工件。它对每个被测对象一视同仁。
|
|
72
|
+
- **Agent(Adapter) / Sandbox** 各自解决自己那一层的特殊性,通过接口把能力交还核心。
|
|
73
|
+
|
|
74
|
+
当一个修复看起来"需要在核心里加一个 agent-specific 分支"时,先去对应适配器加或复用一个 hook。一个中性的小 hook,几乎总比把 `name == ...` 的分支线穿过核心要干净。
|
|
75
|
+
|
|
76
|
+
## 与参考项目的关系
|
|
77
|
+
|
|
78
|
+
- **eve evals** 给了我们 DX 形状:`defineEval`、路径即身份、gate/soft 断言、LLM-as-judge、reporter 插件、有界并发运行器。niceeval 的会话型 eval 基本是这套模型的再实现。
|
|
79
|
+
- **Vercel agent-eval** 给了我们 agent 评测的工程形状:`Adapter` 接口、`Sandbox` 抽象与多后端、transcript 归一化与注入、失败分类、指纹缓存。它的 fixture(目录约定自动发现 PROMPT + EVAL 测试)没有照搬——niceeval 里沙箱要放什么文件,是 `test()` 里的手工调用,不靠目录约定隐式发现。
|
|
80
|
+
- **crabbox** 给了我们这条"核心 vs 适配器"的纪律,以及"文档是用户面真相、source-map 把行为映射回代码"的文档观。
|
|
81
|
+
|
|
82
|
+
niceeval 的新意不在任何单一机制,而在于**把这两种本来分裂的评测范式(会话型 / 沙箱型)收敛进同一套 `defineEval` + 评分器 + 运行器 + 报告器**,让"评我自己的函数"和"评一个塞进容器的 Claude Code"读起来是同一种东西。
|
|
83
|
+
|
|
84
|
+
## 相关阅读
|
|
85
|
+
|
|
86
|
+
- [Architecture](architecture.md) —— 模块分层与数据流。
|
|
87
|
+
- [Concepts](concepts.md) —— 术语表。
|
|
88
|
+
- [Agents 与 Adapters](adapters/README.md)、[Sandbox](sandbox.md) —— 连接与沙箱契约。
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "niceeval",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "Agent-native eval tool — eval agents, services, functions, and coding-agent fixtures",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|
|
@@ -43,7 +43,8 @@
|
|
|
43
43
|
},
|
|
44
44
|
"files": [
|
|
45
45
|
"src",
|
|
46
|
-
"bin"
|
|
46
|
+
"bin",
|
|
47
|
+
"docs"
|
|
47
48
|
],
|
|
48
49
|
"dependencies": {
|
|
49
50
|
"autoevals": "0.0.132",
|
|
@@ -57,6 +58,9 @@
|
|
|
57
58
|
"e2b": "^2.31.0"
|
|
58
59
|
},
|
|
59
60
|
"devDependencies": {
|
|
61
|
+
"@ai-sdk/otel": "^1.0.9",
|
|
62
|
+
"@opentelemetry/exporter-trace-otlp-http": "^0.219.0",
|
|
63
|
+
"@opentelemetry/sdk-trace-node": "^2.8.0",
|
|
60
64
|
"@radix-ui/react-collapsible": "^1.1.14",
|
|
61
65
|
"@radix-ui/react-dialog": "^1.1.17",
|
|
62
66
|
"@radix-ui/react-tabs": "^1.1.15",
|
|
@@ -70,24 +74,50 @@
|
|
|
70
74
|
"class-variance-authority": "^0.7.1",
|
|
71
75
|
"clsx": "^2.1.1",
|
|
72
76
|
"lucide-react": "^1.21.0",
|
|
77
|
+
"mixpanel-browser": "^2.80.0",
|
|
78
|
+
"next": "16.2.10",
|
|
73
79
|
"prism-react-renderer": "^2.4.1",
|
|
74
80
|
"react": "^19.2.7",
|
|
75
81
|
"react-dom": "^19.2.7",
|
|
76
82
|
"react-grab": "^0.1.47",
|
|
83
|
+
"shiki": "^4.3.0",
|
|
77
84
|
"tailwind-merge": "^3.6.0",
|
|
78
85
|
"tailwindcss": "^4.3.1",
|
|
79
86
|
"typescript": "^5.6.0",
|
|
80
87
|
"vite": "^8.1.0",
|
|
81
88
|
"vitest": "^4.1.9"
|
|
82
89
|
},
|
|
90
|
+
"peerDependencies": {
|
|
91
|
+
"@ai-sdk/otel": ">=1.0.0",
|
|
92
|
+
"@opentelemetry/exporter-trace-otlp-http": ">=0.200.0",
|
|
93
|
+
"@opentelemetry/sdk-trace-node": ">=2.0.0",
|
|
94
|
+
"ai": ">=5.0.0"
|
|
95
|
+
},
|
|
96
|
+
"peerDependenciesMeta": {
|
|
97
|
+
"@ai-sdk/otel": {
|
|
98
|
+
"optional": true
|
|
99
|
+
},
|
|
100
|
+
"ai": {
|
|
101
|
+
"optional": true
|
|
102
|
+
},
|
|
103
|
+
"@opentelemetry/exporter-trace-otlp-http": {
|
|
104
|
+
"optional": true
|
|
105
|
+
},
|
|
106
|
+
"@opentelemetry/sdk-trace-node": {
|
|
107
|
+
"optional": true
|
|
108
|
+
}
|
|
109
|
+
},
|
|
83
110
|
"scripts": {
|
|
84
111
|
"typecheck": "tsc --noEmit",
|
|
85
112
|
"test": "vitest run",
|
|
86
113
|
"test:watch": "vitest",
|
|
87
114
|
"niceeval": "node bin/niceeval.js",
|
|
115
|
+
"gen:diff-code": "tsx scripts/gen-diff-code.ts",
|
|
116
|
+
"tiers:sync": "node scripts/sync-tiers.mjs sync",
|
|
117
|
+
"tiers:check": "node scripts/sync-tiers.mjs check",
|
|
88
118
|
"view:build": "vite build --config src/view/app/vite.config.ts",
|
|
89
|
-
"site:dev": "
|
|
90
|
-
"site:build": "
|
|
119
|
+
"site:dev": "cd site && node scripts/dev.mjs",
|
|
120
|
+
"site:build": "cd site && next build",
|
|
91
121
|
"docs:dev": "cd docs-site && npx --yes mint@latest dev",
|
|
92
122
|
"docs:validate": "cd docs-site && npx --yes mint@latest validate",
|
|
93
123
|
"docs:links": "cd docs-site && npx --yes mint@latest broken-links --check-anchors --check-redirects"
|
|
Binary file
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { aiSdkAgent, fromAiSdk } from "./ai-sdk.ts";
|
|
4
|
+
import type { AiSdkGenerateContext, AiSdkResultLike } from "./ai-sdk.ts";
|
|
5
|
+
import type { AgentContext } from "../types.ts";
|
|
6
|
+
import { createAgentSession } from "../context/session.ts";
|
|
7
|
+
|
|
8
|
+
describe("fromAiSdk", () => {
|
|
9
|
+
it("v5+ content parts:保留真实顺序,tool-error 映射成 failed", () => {
|
|
10
|
+
const { events } = fromAiSdk({
|
|
11
|
+
steps: [
|
|
12
|
+
{
|
|
13
|
+
content: [
|
|
14
|
+
{ type: "reasoning", text: "先查天气" },
|
|
15
|
+
{ type: "tool-call", toolCallId: "call_1", toolName: "get_weather", input: { city: "Brooklyn" } },
|
|
16
|
+
{ type: "tool-result", toolCallId: "call_1", output: { temp: 21 } },
|
|
17
|
+
{ type: "tool-call", toolCallId: "call_2", toolName: "web_search", input: { query: "穿衣" } },
|
|
18
|
+
{ type: "tool-error", toolCallId: "call_2", error: new Error("rate limited") },
|
|
19
|
+
],
|
|
20
|
+
},
|
|
21
|
+
{ content: [{ type: "text", text: "布鲁克林 21 度。" }] },
|
|
22
|
+
],
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
expect(events.map((e) => e.type)).toEqual([
|
|
26
|
+
"thinking",
|
|
27
|
+
"action.called",
|
|
28
|
+
"action.result",
|
|
29
|
+
"action.called",
|
|
30
|
+
"action.result",
|
|
31
|
+
"message",
|
|
32
|
+
]);
|
|
33
|
+
expect(events[1]).toMatchObject({ callId: "call_1", name: "get_weather", tool: "unknown" });
|
|
34
|
+
expect(events[3]).toMatchObject({ callId: "call_2", name: "web_search", tool: "web_search" });
|
|
35
|
+
expect(events[4]).toMatchObject({ status: "failed", output: { error: "rate limited" } });
|
|
36
|
+
expect(events[5]).toMatchObject({ role: "assistant", text: "布鲁克林 21 度。" });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("v4 退路:认 args / result / promptTokens 旧命名", () => {
|
|
40
|
+
const { events, usage } = fromAiSdk({
|
|
41
|
+
steps: [
|
|
42
|
+
{
|
|
43
|
+
text: "算好了,是 42。",
|
|
44
|
+
toolCalls: [{ toolCallId: "c1", toolName: "calculate", args: { expression: "6*7" } }],
|
|
45
|
+
toolResults: [{ toolCallId: "c1", result: { value: 42 } }],
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
usage: { promptTokens: 100, completionTokens: 20 },
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
expect(events).toEqual([
|
|
52
|
+
{ type: "action.called", callId: "c1", name: "calculate", input: { expression: "6*7" }, tool: "unknown" },
|
|
53
|
+
{ type: "action.result", callId: "c1", output: { value: 42 }, status: "completed" },
|
|
54
|
+
{ type: "message", role: "assistant", text: "算好了,是 42。" },
|
|
55
|
+
]);
|
|
56
|
+
expect(usage).toEqual({ inputTokens: 100, outputTokens: 20, requests: 1 });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("没有 steps:退回顶层 text / toolCalls / toolResults", () => {
|
|
60
|
+
const { events } = fromAiSdk({
|
|
61
|
+
text: "你好!",
|
|
62
|
+
toolCalls: [{ toolCallId: "c1", toolName: "read_file", input: { path: "a.ts" } }],
|
|
63
|
+
toolResults: [{ toolCallId: "c1", output: "content" }],
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
expect(events.map((e) => e.type)).toEqual(["action.called", "action.result", "message"]);
|
|
67
|
+
expect(events[0]).toMatchObject({ tool: "file_read" });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("usage:totalUsage 优先于 usage,cachedInputTokens 进 cacheReadTokens,requests = step 数", () => {
|
|
71
|
+
const { usage } = fromAiSdk({
|
|
72
|
+
steps: [{ text: "a" }, { text: "b" }],
|
|
73
|
+
totalUsage: { inputTokens: 300, outputTokens: 50, cachedInputTokens: 120 },
|
|
74
|
+
usage: { inputTokens: 1, outputTokens: 1 },
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
expect(usage).toEqual({ inputTokens: 300, outputTokens: 50, requests: 2, cacheReadTokens: 120 });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("全零 usage 视为缺失(别让 maxTokens 拿 0 假通过时看起来像有数据)", () => {
|
|
81
|
+
const { usage } = fromAiSdk({ steps: [{ text: "hi" }], usage: {} });
|
|
82
|
+
expect(usage).toBeUndefined();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("空文本 / 空白 step 不产 message 事件", () => {
|
|
86
|
+
const { events } = fromAiSdk({ steps: [{ text: " " }, { content: [{ type: "text", text: "" }] }] });
|
|
87
|
+
expect(events).toEqual([]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("无 approval 请求时 status 是 completed", () => {
|
|
91
|
+
const { status } = fromAiSdk({ steps: [{ content: [{ type: "text", text: "你好" }] }] });
|
|
92
|
+
expect(status).toBe("completed");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("v7 tool-approval-request:input.requested + waiting,与 tool-call part 按 callId 去重", () => {
|
|
96
|
+
const { events, status } = fromAiSdk({
|
|
97
|
+
steps: [
|
|
98
|
+
{
|
|
99
|
+
content: [
|
|
100
|
+
{ type: "tool-call", toolCallId: "call_1", toolName: "send_email", input: { to: "a@b.c" } },
|
|
101
|
+
{
|
|
102
|
+
type: "tool-approval-request",
|
|
103
|
+
approvalId: "appr_1",
|
|
104
|
+
toolCall: { toolCallId: "call_1", toolName: "send_email", input: { to: "a@b.c" } },
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
},
|
|
108
|
+
],
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
expect(status).toBe("waiting");
|
|
112
|
+
expect(events.map((e) => e.type)).toEqual(["action.called", "input.requested"]);
|
|
113
|
+
expect(events[0]).toMatchObject({ callId: "call_1", name: "send_email" });
|
|
114
|
+
expect(events[1]).toMatchObject({
|
|
115
|
+
request: {
|
|
116
|
+
id: "appr_1",
|
|
117
|
+
action: "send_email",
|
|
118
|
+
input: { to: "a@b.c" },
|
|
119
|
+
options: [{ id: "approve" }, { id: "deny" }],
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("automatic 的 approval 请求不吐 input.requested(SDK 自动裁决,不等人)", () => {
|
|
125
|
+
const { events, status } = fromAiSdk({
|
|
126
|
+
steps: [
|
|
127
|
+
{
|
|
128
|
+
content: [
|
|
129
|
+
{
|
|
130
|
+
type: "tool-approval-request",
|
|
131
|
+
approvalId: "appr_1",
|
|
132
|
+
isAutomatic: true,
|
|
133
|
+
toolCall: { toolCallId: "call_1", toolName: "send_email", input: {} },
|
|
134
|
+
},
|
|
135
|
+
],
|
|
136
|
+
},
|
|
137
|
+
],
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
expect(status).toBe("completed");
|
|
141
|
+
expect(events.map((e) => e.type)).toEqual(["action.called"]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("approval resume:responseMessages 里的执行结果补成 action.result,排在本轮事件之前", () => {
|
|
145
|
+
const { events, status } = fromAiSdk({
|
|
146
|
+
steps: [{ content: [{ type: "text", text: "邮件已发送。" }] }],
|
|
147
|
+
responseMessages: [
|
|
148
|
+
{
|
|
149
|
+
role: "tool",
|
|
150
|
+
content: [
|
|
151
|
+
{
|
|
152
|
+
type: "tool-result",
|
|
153
|
+
toolCallId: "call_1",
|
|
154
|
+
toolName: "send_email",
|
|
155
|
+
output: { type: "json", value: { delivered: true } },
|
|
156
|
+
},
|
|
157
|
+
],
|
|
158
|
+
},
|
|
159
|
+
{ role: "assistant", content: [{ type: "text", text: "邮件已发送。" }] },
|
|
160
|
+
],
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
expect(status).toBe("completed");
|
|
164
|
+
expect(events).toEqual([
|
|
165
|
+
{ type: "action.result", callId: "call_1", output: { delivered: true }, status: "completed" },
|
|
166
|
+
{ type: "message", role: "assistant", text: "邮件已发送。" },
|
|
167
|
+
]);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("approval 拒绝:execution-denied 映射成 rejected", () => {
|
|
171
|
+
const { events } = fromAiSdk({
|
|
172
|
+
steps: [{ content: [{ type: "text", text: "好的,不发了。" }] }],
|
|
173
|
+
responseMessages: [
|
|
174
|
+
{
|
|
175
|
+
role: "tool",
|
|
176
|
+
content: [
|
|
177
|
+
{
|
|
178
|
+
type: "tool-result",
|
|
179
|
+
toolCallId: "call_1",
|
|
180
|
+
toolName: "send_email",
|
|
181
|
+
output: { type: "execution-denied", reason: "用户拒绝" },
|
|
182
|
+
},
|
|
183
|
+
],
|
|
184
|
+
},
|
|
185
|
+
],
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
expect(events[0]).toEqual({
|
|
189
|
+
type: "action.result",
|
|
190
|
+
callId: "call_1",
|
|
191
|
+
output: { reason: "用户拒绝" },
|
|
192
|
+
status: "rejected",
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("steps 里已有的 tool-result 不因 responseMessages 重复(普通工具循环两边都有)", () => {
|
|
197
|
+
const { events } = fromAiSdk({
|
|
198
|
+
steps: [
|
|
199
|
+
{
|
|
200
|
+
content: [
|
|
201
|
+
{ type: "tool-call", toolCallId: "c1", toolName: "get_weather", input: { city: "北京" } },
|
|
202
|
+
{ type: "tool-result", toolCallId: "c1", output: { temp: 26 } },
|
|
203
|
+
{ type: "text", text: "北京 26 度。" },
|
|
204
|
+
],
|
|
205
|
+
},
|
|
206
|
+
],
|
|
207
|
+
responseMessages: [
|
|
208
|
+
{
|
|
209
|
+
role: "tool",
|
|
210
|
+
content: [
|
|
211
|
+
{ type: "tool-result", toolCallId: "c1", toolName: "get_weather", output: { type: "json", value: { temp: 26 } } },
|
|
212
|
+
],
|
|
213
|
+
},
|
|
214
|
+
],
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
expect(events.filter((e) => e.type === "action.result")).toHaveLength(1);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("aiSdkAgent:ctx.session.id 未记录时开新会话线,同 id 续接同一份历史", async () => {
|
|
221
|
+
const seen: unknown[][] = [];
|
|
222
|
+
const agent = aiSdkAgent({
|
|
223
|
+
generate: async ({ messages }: AiSdkGenerateContext) => {
|
|
224
|
+
seen.push([...messages]);
|
|
225
|
+
const result: AiSdkResultLike = {
|
|
226
|
+
steps: [{ content: [{ type: "text", text: `回复#${seen.length}` }] }],
|
|
227
|
+
responseMessages: [{ role: "assistant", content: `回复#${seen.length}` }],
|
|
228
|
+
};
|
|
229
|
+
return result;
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
const ctx = fakeCtx();
|
|
234
|
+
await agent.send({ text: "第一轮" }, ctx);
|
|
235
|
+
expect(ctx.session.id).toBeDefined();
|
|
236
|
+
const turn2 = await agent.send({ text: "第二轮" }, fakeCtx({ id: ctx.session.id }));
|
|
237
|
+
|
|
238
|
+
expect(turn2.status).toBe("completed");
|
|
239
|
+
// 第二轮的历史 = 用户#1 + 助手#1 + 用户#2
|
|
240
|
+
expect(seen[1]).toEqual([
|
|
241
|
+
{ role: "user", content: "第一轮" },
|
|
242
|
+
{ role: "assistant", content: "回复#1" },
|
|
243
|
+
{ role: "user", content: "第二轮" },
|
|
244
|
+
]);
|
|
245
|
+
|
|
246
|
+
// 新会话线:历史必须是干净的
|
|
247
|
+
const ctx3 = fakeCtx();
|
|
248
|
+
await agent.send({ text: "新会话" }, ctx3);
|
|
249
|
+
expect(ctx3.session.id).not.toBe(ctx.session.id);
|
|
250
|
+
expect(seen[2]).toEqual([{ role: "user", content: "新会话" }]);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("aiSdkAgent:approval 停轮 → waiting;下一轮输入翻译成 tool-approval-response", async () => {
|
|
254
|
+
const seen: unknown[][] = [];
|
|
255
|
+
let call = 0;
|
|
256
|
+
const agent = aiSdkAgent({
|
|
257
|
+
generate: async ({ messages }: AiSdkGenerateContext) => {
|
|
258
|
+
seen.push([...messages]);
|
|
259
|
+
call++;
|
|
260
|
+
if (call === 1) {
|
|
261
|
+
return {
|
|
262
|
+
steps: [
|
|
263
|
+
{
|
|
264
|
+
content: [
|
|
265
|
+
{ type: "tool-call", toolCallId: "c1", toolName: "send_email", input: { to: "a@b.c" } },
|
|
266
|
+
{ type: "tool-approval-request", approvalId: "appr_1", toolCall: { toolCallId: "c1", toolName: "send_email", input: { to: "a@b.c" } } },
|
|
267
|
+
],
|
|
268
|
+
},
|
|
269
|
+
],
|
|
270
|
+
responseMessages: [{ role: "assistant", content: [] }],
|
|
271
|
+
} satisfies AiSdkResultLike;
|
|
272
|
+
}
|
|
273
|
+
return {
|
|
274
|
+
steps: [{ content: [{ type: "text", text: "邮件已发送。" }] }],
|
|
275
|
+
responseMessages: [],
|
|
276
|
+
} satisfies AiSdkResultLike;
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
const ctx = fakeCtx();
|
|
281
|
+
const first = await agent.send({ text: "发邮件" }, ctx);
|
|
282
|
+
expect(first.status).toBe("waiting");
|
|
283
|
+
expect(first.events.some((e) => e.type === "input.requested")).toBe(true);
|
|
284
|
+
|
|
285
|
+
const second = await agent.send({ text: "approve" }, fakeCtx({ id: ctx.session.id }));
|
|
286
|
+
expect(second.status).toBe("completed");
|
|
287
|
+
// 第二轮历史的最后一条是翻译好的裁决(tool 消息),不是用户文本
|
|
288
|
+
const last = (seen[1] as { role: string; content: unknown }[]).at(-1);
|
|
289
|
+
expect(last).toEqual({
|
|
290
|
+
role: "tool",
|
|
291
|
+
content: [{ type: "tool-approval-response", approvalId: "appr_1", approved: true }],
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("aiSdkAgent:approval resume 优先按 requestId 从 input.responses 读裁决,而不是猜 input.text", async () => {
|
|
296
|
+
const seen: unknown[][] = [];
|
|
297
|
+
let call = 0;
|
|
298
|
+
const agent = aiSdkAgent({
|
|
299
|
+
generate: async ({ messages }: AiSdkGenerateContext) => {
|
|
300
|
+
seen.push([...messages]);
|
|
301
|
+
call++;
|
|
302
|
+
if (call === 1) {
|
|
303
|
+
return {
|
|
304
|
+
steps: [
|
|
305
|
+
{
|
|
306
|
+
content: [
|
|
307
|
+
{ type: "tool-call", toolCallId: "c1", toolName: "send_email", input: { to: "a@b.c" } },
|
|
308
|
+
{ type: "tool-approval-request", approvalId: "appr_1", toolCall: { toolCallId: "c1", toolName: "send_email", input: { to: "a@b.c" } } },
|
|
309
|
+
],
|
|
310
|
+
},
|
|
311
|
+
],
|
|
312
|
+
responseMessages: [{ role: "assistant", content: [] }],
|
|
313
|
+
} satisfies AiSdkResultLike;
|
|
314
|
+
}
|
|
315
|
+
return {
|
|
316
|
+
steps: [{ content: [{ type: "text", text: "好的,没有发送。" }] }],
|
|
317
|
+
responseMessages: [],
|
|
318
|
+
} satisfies AiSdkResultLike;
|
|
319
|
+
},
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
const ctx = fakeCtx();
|
|
323
|
+
await agent.send({ text: "发邮件" }, ctx);
|
|
324
|
+
|
|
325
|
+
// input.text 读起来像批准,但 responses 结构化裁决说 deny——必须以 responses 为准。
|
|
326
|
+
const second = await agent.send(
|
|
327
|
+
{ text: "approve", responses: [{ requestId: "appr_1", optionId: "deny" }] },
|
|
328
|
+
fakeCtx({ id: ctx.session.id }),
|
|
329
|
+
);
|
|
330
|
+
expect(second.status).toBe("completed");
|
|
331
|
+
const last = (seen[1] as { role: string; content: unknown }[]).at(-1);
|
|
332
|
+
expect(last).toEqual({
|
|
333
|
+
role: "tool",
|
|
334
|
+
content: [{ type: "tool-approval-response", approvalId: "appr_1", approved: false }],
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it("aiSdkAgent:generate 抛错 / 空结果 → failed + error 事件", async () => {
|
|
339
|
+
const boom = aiSdkAgent({ generate: async () => { throw new Error("上游超时"); } });
|
|
340
|
+
const failed = await boom.send({ text: "hi" }, fakeCtx());
|
|
341
|
+
expect(failed.status).toBe("failed");
|
|
342
|
+
expect(failed.events[0]).toMatchObject({ type: "error", message: "上游超时" });
|
|
343
|
+
|
|
344
|
+
const empty = aiSdkAgent({ generate: async () => ({ steps: [{ text: "" }] }) });
|
|
345
|
+
const emptyTurn = await empty.send({ text: "hi" }, fakeCtx());
|
|
346
|
+
expect(emptyTurn.status).toBe("failed");
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("v7 usage:cache tokens 从 inputTokenDetails 读", () => {
|
|
350
|
+
const { usage } = fromAiSdk({
|
|
351
|
+
steps: [{ text: "hi" }],
|
|
352
|
+
usage: { inputTokens: 100, outputTokens: 20, inputTokenDetails: { cacheReadTokens: 40, cacheWriteTokens: 8 } },
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
expect(usage).toEqual({
|
|
356
|
+
inputTokens: 100,
|
|
357
|
+
outputTokens: 20,
|
|
358
|
+
requests: 1,
|
|
359
|
+
cacheReadTokens: 40,
|
|
360
|
+
cacheWriteTokens: 8,
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
/** 省略 `id`(或不给参数)= 全新会话线;传 `id` = 这条线已经 capture 过这个 id(续接轮)。 */
|
|
366
|
+
function fakeCtx(opts: { id?: string } = {}): AgentContext {
|
|
367
|
+
const session = createAgentSession();
|
|
368
|
+
if (opts.id) session.capture(opts.id);
|
|
369
|
+
return {
|
|
370
|
+
signal: new AbortController().signal,
|
|
371
|
+
model: undefined,
|
|
372
|
+
flags: {},
|
|
373
|
+
sandbox: undefined as unknown as AgentContext["sandbox"],
|
|
374
|
+
session,
|
|
375
|
+
log: () => {},
|
|
376
|
+
};
|
|
377
|
+
}
|