@zhushanwen/pi-canvas 0.0.0-stage → 0.2.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.
- package/README.md +11 -2
- package/index.ts +1 -0
- package/package.json +60 -3
- package/skills/canvas-ext-config/SKILL.md +75 -0
- package/src/__tests__/available-tools.test.ts +84 -0
- package/src/__tests__/helpers/canvas-harness.ts +75 -0
- package/src/__tests__/host.test.ts +44 -0
- package/src/__tests__/review.test.ts +300 -0
- package/src/__tests__/tool-execute.test.ts +381 -0
- package/src/__tests__/tool-registration.test.ts +59 -0
- package/src/host.ts +25 -0
- package/src/index.ts +174 -0
- package/src/marker-channel.ts +116 -0
- package/src/prompts/canvas-reviewer.ts +104 -0
- package/src/review.ts +229 -0
- package/src/tool-edit.ts +211 -0
- package/src/tool-enter.ts +147 -0
- package/src/tool-export.ts +72 -0
- package/src/tool-list.ts +36 -0
- package/src/tool-read.ts +44 -0
- package/src/tool-review.ts +201 -0
package/README.md
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @zhushanwen/pi-canvas
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
问题解决画布的 pi extension 工具面(taiji 组,role=taiji,tier=feature)。
|
|
4
|
+
|
|
5
|
+
六个 `canvas_*` 工具(全部 `exposure: "deferred"`,tool_search 检索激活):`canvas_enter`(创建/打开)、`canvas_edit`(grow/patch/select/transition/comment-respond 五 action)、`canvas_read`、`canvas_review`(run/append/resolve)、`canvas_export`、`canvas_list`。
|
|
6
|
+
|
|
7
|
+
架构(设计 `.tmp/tech-design/problem-solve-canvas.md` §3.1(2)):
|
|
8
|
+
|
|
9
|
+
- 画布状态唯一权威在 taiji runtime 的 CanvasService;本扩展是薄工具面——每工具 execute 把调用转译为 `CANVAS_MARKER` 的 `{action, params}` 嵌套载荷,经 `ctx.ui.select` 挂起,由 runtime marker 显式分支路由(session-manager 同款通道;契约 SSOT 在 `@zhushanwen/extension-protocol` 的 `src/extensions/canvas/` 契约模块)。
|
|
10
|
+
- 宿主检测:`TAIJI_AGENT_EXT_LOG === "1"` 且 `ctx.mode === "rpc"`(对齐 plan 的 `isTaijiGuiHost` 完整谓词)——非 taiji GUI 宿主返回结构化错误,不进入 select,禁超时探测。
|
|
11
|
+
- 审查编排:`canvas_review(run)` 经 subagent-workflow 的 `SubagentService`(globalThis service 槽)派单个 reviewer(六维度模板 + 画布全量内联,reviewer 零工具——无改图通道),报告条目经 marker `review-append` 写回(runtime 按 U1 权限矩阵映射 actor=review)。
|
|
12
|
+
- 无墙钟超时:marker select 与 reviewer 派发均不设时限(任务级正常路径不设限;session-manager watch 先例)。
|
package/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./src/index.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,63 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-canvas",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Problem-solving canvas toolface for pi: six deferred canvas_* tools bridging the agent to the taiji runtime CanvasService via the CANVAS_MARKER select channel (problem-solve-canvas)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.ts",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"pi-package",
|
|
9
|
+
"pi",
|
|
10
|
+
"pi-coding-agent",
|
|
11
|
+
"extension",
|
|
12
|
+
"canvas"
|
|
13
|
+
],
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"files": [
|
|
16
|
+
"index.ts",
|
|
17
|
+
"src/**/*.ts",
|
|
18
|
+
"README.md",
|
|
19
|
+
"skills/"
|
|
20
|
+
],
|
|
21
|
+
"taiji": {
|
|
22
|
+
"role": "taiji"
|
|
23
|
+
},
|
|
24
|
+
"pi": {
|
|
25
|
+
"extensions": [
|
|
26
|
+
"./index.ts"
|
|
27
|
+
],
|
|
28
|
+
"skills": [
|
|
29
|
+
"./skills"
|
|
30
|
+
]
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"@earendil-works/pi-ai": "^1.0.0",
|
|
34
|
+
"@earendil-works/pi-coding-agent": "^1.0.0",
|
|
35
|
+
"typebox": "1.3.27"
|
|
36
|
+
},
|
|
37
|
+
"peerDependenciesMeta": {
|
|
38
|
+
"@earendil-works/pi-ai": {
|
|
39
|
+
"optional": true
|
|
40
|
+
},
|
|
41
|
+
"@earendil-works/pi-coding-agent": {
|
|
42
|
+
"optional": true
|
|
43
|
+
},
|
|
44
|
+
"typebox": {
|
|
45
|
+
"optional": true
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@types/node": "^24.0.0",
|
|
50
|
+
"@vitest/coverage-v8": "^4.1.9",
|
|
51
|
+
"vitest": "^4.1.8"
|
|
52
|
+
},
|
|
53
|
+
"dependencies": {
|
|
54
|
+
"@zhushanwen/extension-protocol": "0.21.0",
|
|
55
|
+
"@zhushanwen/pi-ext-guards": "0.5.0",
|
|
56
|
+
"@zhushanwen/pi-extension-logger": "0.6.2",
|
|
57
|
+
"@zhushanwen/subagent-core": "1.5.2"
|
|
58
|
+
},
|
|
59
|
+
"scripts": {
|
|
60
|
+
"typecheck": "npx tsc --noEmit",
|
|
61
|
+
"test": "vitest run"
|
|
62
|
+
}
|
|
6
63
|
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: canvas-ext-config
|
|
3
|
+
description: "使用或排查 @zhushanwen/pi-canvas(问题解决画布:六个 canvas_* deferred 工具,人机共编论证图)时加载。含 select 通道挂起等待 GUI 裁决的语义(无墙钟超时,裁决经事件通知回推)、宿主检测(canvas_unavailable_host 的两种成因)、events.jsonl/canvas.json 存储概览、排障入口(TAIJI_AGENT_EXT_LOG 日志位置与宿主要求)。触发词:画布、canvas、canvas_enter、canvas_edit、canvas_review、canvas_export、select 挂起、等待裁决、canvas_unavailable_host、画布日志、画布排障、events.jsonl、canvas.json。"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# canvas 画布使用与排障指南
|
|
7
|
+
|
|
8
|
+
> @zhushanwen/pi-canvas:问题解决画布的工具面。六个 `canvas_*` 工具(全部 deferred,需先 `tool_search` 检索激活):`canvas_enter`(创建/打开)、`canvas_edit`(grow/patch/select/transition/comment-respond)、`canvas_read`、`canvas_review`(run/append/resolve)、`canvas_export`、`canvas_list`。画布是用户在 taiji GUI 里实时观看并裁决的人机共编论证图。
|
|
9
|
+
|
|
10
|
+
**本扩展无磁盘配置文件**——没有 `<agentDir>/config/canvas-ext-config.json`,行为不可经文件配置。它的「配置面」就是宿主注入的环境变量(见下文排障节);不要去找或创建配置 JSON。
|
|
11
|
+
|
|
12
|
+
## 通道语义:select 挂起等待 GUI 裁决
|
|
13
|
+
|
|
14
|
+
每个 `canvas_*` 调用的路径:工具 execute 把调用转译为 `CANVAS_MARKER` 的 `{action, params}` 嵌套载荷,经 `ctx.ui.select` **挂起**,由 taiji runtime 的 marker 显式分支路由到 CanvasService;应答回来后经 `extension_ui_response` 按 id 精确解挂。
|
|
15
|
+
|
|
16
|
+
关键行为(agent 必须知道的三点):
|
|
17
|
+
|
|
18
|
+
1. **调用会挂起等待,不是即时返回**:用户正在 GUI 画布上看图、圈选、写批注,应答要等 runtime 侧处理完才回来。这是设计内形态,不是卡死。
|
|
19
|
+
2. **无墙钟超时**:挂起不设时限(任务级正常路径不设限),任意晚的应答都会按 id 解挂。等待期间不要重试同一调用——重试会产生第二个挂起请求。
|
|
20
|
+
3. **用户的 GUI 裁决(圈选 / 否决 / 批注)以画布事件通知回推**:收到通知后**必须先 `canvas_read` 读当前状态再行动**(read-before-act 纪律),不要凭记忆编辑。
|
|
21
|
+
|
|
22
|
+
## 宿主检测:canvas_unavailable_host
|
|
23
|
+
|
|
24
|
+
画布工具只在 taiji GUI 托管的 pi 里可用。前置门在 select 之前(`isTaijiGuiHost` 谓词:`TAIJI_AGENT_EXT_LOG === "1"` 且 `ctx.mode === "rpc"`),不满足时直接返回结构化错误、不进入 select:
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
canvas_unavailable_host: 画布需要 taiji 宿主。…
|
|
28
|
+
恢复动作:在 taiji 桌面应用的会话中使用画布工具;纯 pi 环境请直接以对话方式规划。
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
成因两种:① 本来就在独立 pi CLI / TUI 里跑(画布不可用,属正常拒绝);② env 泄漏或 mode 异常(在 taiji 里却报此错——检查 pi 进程 env 是否带 `TAIJI_AGENT_EXT_LOG=1`)。
|
|
32
|
+
|
|
33
|
+
## 错误闭环(失败可区分,按恢复动作重试)
|
|
34
|
+
|
|
35
|
+
工具报错分三类,全部 throw(isError 消息可见),agent 可据此决定重试或放弃:
|
|
36
|
+
|
|
37
|
+
| 错误形态 | 含义 | 处置 |
|
|
38
|
+
|---|---|---|
|
|
39
|
+
| `canvas <action>: cancelled or channel error` | 通道失败(取消/断连) | 重试一次;连续失败转排障节查日志 |
|
|
40
|
+
| `non-JSON response from runtime` / 「应答不是合法 JSON(runtime 契约漂移)」 | runtime 与 extension 版本错配 | 核对两侧同版本后重试 |
|
|
41
|
+
| `canvas <action> 被拒绝 [<错误码>] … 恢复动作:…` | 领域拒绝(不变量拦截,如 not_converged) | 按消息里的恢复动作修正后重试,不要原样重发 |
|
|
42
|
+
|
|
43
|
+
## 存储概览(只读理解用,agent 不直改文件)
|
|
44
|
+
|
|
45
|
+
画布数据在 taiji runtime 数据目录 `<dataDir>/canvases/<canvasId>/` 下:
|
|
46
|
+
|
|
47
|
+
| 文件 | 角色 |
|
|
48
|
+
|---|---|
|
|
49
|
+
| `events.jsonl` | **唯一持久权威**:append-only 领域事件流,每行一个 JSON 事件(seq 从 1 起 per-canvas 单调)。状态疑义以此文件为准 |
|
|
50
|
+
| `canvas.json` | 加速快照 + 锚点(锚点 eventId ≤ 事件流末条,永不超前)。损坏时从 events.jsonl 全量重放重建 |
|
|
51
|
+
| `exports/` | 导出产物(planning-doc / requirement-card) |
|
|
52
|
+
|
|
53
|
+
`<dataDir>` 按环境:taiji dev = `~/.taiji-dev`,taiji prod = `~/.taiji`(`TAIJI_AGENT_DATA_DIR` 可隔离)。修改画布只经 `canvas_edit` / `canvas_review` 工具走领域校验,**禁止手改这两个文件**。
|
|
54
|
+
|
|
55
|
+
## 排障入口:扩展日志
|
|
56
|
+
|
|
57
|
+
文件日志由 pi-extension-logger 双开关分级写 `<agentDir>/logs/pi-canvas-YYYY-MM-DD.log`:
|
|
58
|
+
|
|
59
|
+
| 开关 | 行为 |
|
|
60
|
+
|---|---|
|
|
61
|
+
| `TAIJI_AGENT_EXT_LOG=1` | INFO 档落盘(taiji 托管环境 runtime spawn pi 时恒注入)+ 7 天保留期清理 |
|
|
62
|
+
| `TAIJI_AGENT_DEBUG=1` | DEBUG 全量(两变量同时注入按更详细的生效) |
|
|
63
|
+
| 均未注入 | no-op——零磁盘、无日志文件(裸 pi 默认即此) |
|
|
64
|
+
|
|
65
|
+
**宿主要求**:`TAIJI_AGENT_EXT_LOG=1` 在 taiji 里由 runtime 统一注入,无需手配;裸 pi 独立用户要文件日志须自己 export(但画布工具本身在裸 pi 下不可用,日志只有通道报错可看)。
|
|
66
|
+
|
|
67
|
+
`<agentDir>` 按环境(`PI_CODING_AGENT_DIR` 决定,taiji 隔离到 `<dataDir>/agent/`):
|
|
68
|
+
|
|
69
|
+
| 环境 | 日志路径 | 画布数据 |
|
|
70
|
+
|---|---|---|
|
|
71
|
+
| 独立 pi CLI | `~/.pi/agent/logs/` | 不可用(canvas_unavailable_host) |
|
|
72
|
+
| taiji dev | `~/.taiji-dev/agent/logs/` | `~/.taiji-dev/canvases/` |
|
|
73
|
+
| taiji prod | `~/.taiji/agent/logs/` | `~/.taiji/canvases/` |
|
|
74
|
+
|
|
75
|
+
排障顺序:先看工具 isError 消息里的错误码与恢复动作(多数问题到此为止)→ 需要通道级细节再读当日 `pi-canvas-*.log`(marker 调用失败会带 action 名落日志)→ 数据疑义以 `events.jsonl` 为准核对。
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// available-tools.test.ts —— <available_tools> 常驻存在性段(D3-A1 验收实证的
|
|
2
|
+
// 入口可见性修复:pi 1.0.0 下 deferred 工具对模型零感知,name + description 须
|
|
3
|
+
// 常驻 system prompt 并写明 tool_search 激活路径)。
|
|
4
|
+
|
|
5
|
+
import { describe, expect, it } from "vitest";
|
|
6
|
+
|
|
7
|
+
import { buildAvailableToolsSection, CANVAS_AVAILABLE_TOOLS } from "../index.js";
|
|
8
|
+
import { CANVAS_TOOL_NAMES } from "@zhushanwen/extension-protocol";
|
|
9
|
+
|
|
10
|
+
import { mountCanvas, withExtLogEnv, type BeforeAgentStartShape } from "./helpers/canvas-harness.js";
|
|
11
|
+
|
|
12
|
+
describe("CANVAS_AVAILABLE_TOOLS 单源", () => {
|
|
13
|
+
it("清单 = 六工具全集,与 U1 契约 CANVAS_TOOL_NAMES 一致", () => {
|
|
14
|
+
expect(CANVAS_AVAILABLE_TOOLS.map((t) => t.name).sort()).toEqual([...CANVAS_TOOL_NAMES].sort());
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("每条 description 与注册进 pi 的工具 description 逐字一致(注册配置从本单源引用)", () => {
|
|
18
|
+
// 一致性由 index.ts 注册配置直接引用本清单保证;此处锁单源形态:
|
|
19
|
+
// 每条非空、无换行(列表形态依赖)
|
|
20
|
+
for (const tool of CANVAS_AVAILABLE_TOOLS) {
|
|
21
|
+
expect(tool.description.trim().length).toBeGreaterThan(40);
|
|
22
|
+
expect(tool.description).not.toContain("\n");
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("buildAvailableToolsSection(纯函数)", () => {
|
|
28
|
+
it("段内含每个工具的 name 与完整 description 及 tool_search 激活指引", () => {
|
|
29
|
+
const section = buildAvailableToolsSection(CANVAS_AVAILABLE_TOOLS);
|
|
30
|
+
expect(section.startsWith("<available_tools>\n")).toBe(true);
|
|
31
|
+
expect(section.endsWith("\n</available_tools>")).toBe(true);
|
|
32
|
+
expect(section).toContain("tool_search");
|
|
33
|
+
for (const tool of CANVAS_AVAILABLE_TOOLS) {
|
|
34
|
+
expect(section).toContain(`- ${tool.name}: ${tool.description}`);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("description 多行文本被单行化(列表项内换行破坏列表形态)", () => {
|
|
39
|
+
const section = buildAvailableToolsSection([{ name: "t", description: "a\n\nb c" }]);
|
|
40
|
+
expect(section).toContain("- t: a b c");
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("before_agent_start 注入钩子", () => {
|
|
45
|
+
it("taiji GUI 宿主下返回追加了 <available_tools> 段的 systemPrompt,段内含六工具与激活指引", async () => {
|
|
46
|
+
const { beforeAgentStartHandlers } = mountCanvas();
|
|
47
|
+
expect(beforeAgentStartHandlers).toHaveLength(1);
|
|
48
|
+
const handler = beforeAgentStartHandlers[0];
|
|
49
|
+
const result = await withExtLogEnv("1", () =>
|
|
50
|
+
handler({ systemPrompt: "BASE PROMPT" } as BeforeAgentStartShape, { mode: "rpc" }),
|
|
51
|
+
);
|
|
52
|
+
expect(result).toBeDefined();
|
|
53
|
+
expect(result?.systemPrompt.startsWith("BASE PROMPT\n\n<available_tools>")).toBe(true);
|
|
54
|
+
expect(result?.systemPrompt).toContain("</available_tools>");
|
|
55
|
+
expect(result?.systemPrompt).toContain("tool_search");
|
|
56
|
+
for (const name of CANVAS_TOOL_NAMES) {
|
|
57
|
+
expect(result?.systemPrompt).toContain(`- ${name}: `);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("env 信号缺失或非 rpc 模式均不注入(与工具 execute 宿主守卫同谓词)", async () => {
|
|
62
|
+
const { beforeAgentStartHandlers } = mountCanvas();
|
|
63
|
+
const handler = beforeAgentStartHandlers[0];
|
|
64
|
+
const envMissing = await withExtLogEnv(undefined, () =>
|
|
65
|
+
handler({ systemPrompt: "B" } as BeforeAgentStartShape, { mode: "rpc" }),
|
|
66
|
+
);
|
|
67
|
+
expect(envMissing).toBeUndefined();
|
|
68
|
+
const notRpc = await withExtLogEnv("1", () =>
|
|
69
|
+
handler({ systemPrompt: "B" } as BeforeAgentStartShape, { mode: "tui" }),
|
|
70
|
+
);
|
|
71
|
+
expect(notRpc).toBeUndefined();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("同输入两次调用输出一致(每轮注入相同文本,KV-cache 前缀稳定)", async () => {
|
|
75
|
+
const { beforeAgentStartHandlers } = mountCanvas();
|
|
76
|
+
const handler = beforeAgentStartHandlers[0];
|
|
77
|
+
const [a, b] = await withExtLogEnv("1", () => {
|
|
78
|
+
const event = { systemPrompt: "BASE" } as BeforeAgentStartShape;
|
|
79
|
+
const ctx = { mode: "rpc" };
|
|
80
|
+
return [handler(event, ctx), handler(event, ctx)];
|
|
81
|
+
});
|
|
82
|
+
expect(a?.systemPrompt).toBe(b?.systemPrompt);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// canvas-harness.ts —— pi-canvas 测试共享装配 harness(session-manager 同款样板:
|
|
2
|
+
// registerTool 捕获 + before_agent_start 捕获 + rpc ctx 桩 + select 桩;断言全部留在各测试文件)。
|
|
3
|
+
|
|
4
|
+
import { expect, vi, type Mock } from "vitest";
|
|
5
|
+
|
|
6
|
+
import registerExtension from "../../index.js";
|
|
7
|
+
|
|
8
|
+
/** registerTool 捕获的注册工具形态(execute 以固定五参形态调用)。 */
|
|
9
|
+
export interface RegisteredTool { // oe-exempt:20261015:framework:画布域类型契约(数据形状/端口/测试 harness,非抽象接口单实现)
|
|
10
|
+
name: string;
|
|
11
|
+
exposure?: string;
|
|
12
|
+
parameters: unknown;
|
|
13
|
+
execute: (...args: unknown[]) => Promise<unknown>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** before_agent_start handler 的事件/ctx 最小形态(index.ts 只消费 systemPrompt 与 ctx.mode)。 */
|
|
17
|
+
export interface BeforeAgentStartShape { // oe-exempt:20261015:framework:画布域类型契约(数据形状/端口/测试 harness,非抽象接口单实现)
|
|
18
|
+
systemPrompt: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type BeforeAgentStartHandler = (
|
|
22
|
+
event: BeforeAgentStartShape,
|
|
23
|
+
ctx: { mode: string },
|
|
24
|
+
) => { systemPrompt: string } | undefined;
|
|
25
|
+
|
|
26
|
+
export interface ToolHarness { // oe-exempt:20261015:framework:画布域类型契约(数据形状/端口/测试 harness,非抽象接口单实现)
|
|
27
|
+
registered: RegisteredTool[];
|
|
28
|
+
selectMock: Mock;
|
|
29
|
+
/** 捕获的 before_agent_start handler(index.ts 只注册一个;空数组 = 未注册)。 */
|
|
30
|
+
beforeAgentStartHandlers: BeforeAgentStartHandler[];
|
|
31
|
+
/** rpc 模式 ctx 桩(ui.select → selectMock)。 */
|
|
32
|
+
ctx: { mode: "rpc"; hasUI: true; ui: { select: Mock } };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 挂载被测 extension:捕获六工具 + before_agent_start + select 桩。 */
|
|
36
|
+
export function mountCanvas(): ToolHarness {
|
|
37
|
+
const registered: RegisteredTool[] = [];
|
|
38
|
+
const beforeAgentStartHandlers: BeforeAgentStartHandler[] = [];
|
|
39
|
+
registerExtension({
|
|
40
|
+
registerTool: (tool: RegisteredTool) => registered.push(tool),
|
|
41
|
+
on: (event: string, handler: BeforeAgentStartHandler) => {
|
|
42
|
+
if (event === "before_agent_start") beforeAgentStartHandlers.push(handler);
|
|
43
|
+
},
|
|
44
|
+
} as never);
|
|
45
|
+
const selectMock = vi.fn();
|
|
46
|
+
const ctx = { mode: "rpc" as const, hasUI: true as const, ui: { select: selectMock } };
|
|
47
|
+
return { registered, selectMock, beforeAgentStartHandlers, ctx };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 按名取已注册工具(缺名 = 注册面回归,直接红)。 */
|
|
51
|
+
export function toolOf(harness: ToolHarness, name: string): RegisteredTool {
|
|
52
|
+
const tool = harness.registered.find((t) => t.name === name);
|
|
53
|
+
expect(tool, `tool ${name} must be registered`).toBeDefined();
|
|
54
|
+
return tool as RegisteredTool;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** select 桩的一次 marker 调用解包((marker, [payload], opts) 形态 → {marker, payload, opts};取最近一次)。 */
|
|
58
|
+
export function lastMarkerCall(selectMock: Mock): { marker: string; payload: Record<string, unknown>; opts: unknown } {
|
|
59
|
+
const call = selectMock.mock.calls[selectMock.mock.calls.length - 1] as [string, [string], unknown];
|
|
60
|
+
return { marker: call[0], payload: JSON.parse(call[1][0]) as Record<string, unknown>, opts: call[2] };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** env 信号保全切换(TAIJI_AGENT_EXT_LOG 非数据目录,无副作用面;用后即还)。
|
|
64
|
+
* async 等待式:await fn() 完成后才恢复——env 变更覆盖被测代码的全部 await 点。 */
|
|
65
|
+
export async function withExtLogEnv<T>(value: string | undefined, fn: () => Promise<T> | T): Promise<T> {
|
|
66
|
+
const prev = process.env.TAIJI_AGENT_EXT_LOG;
|
|
67
|
+
if (value === undefined) delete process.env.TAIJI_AGENT_EXT_LOG;
|
|
68
|
+
else process.env.TAIJI_AGENT_EXT_LOG = value;
|
|
69
|
+
try {
|
|
70
|
+
return await fn();
|
|
71
|
+
} finally {
|
|
72
|
+
if (prev === undefined) delete process.env.TAIJI_AGENT_EXT_LOG;
|
|
73
|
+
else process.env.TAIJI_AGENT_EXT_LOG = prev;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// host.test.ts —— 宿主检测真值表(设计:env 信号 + ctx.mode === "rpc" 完整谓词;
|
|
2
|
+
// 非 taiji GUI 宿主不进入 select)。
|
|
3
|
+
|
|
4
|
+
import { isTaijiHost } from "@zhushanwen/pi-ext-guards";
|
|
5
|
+
import { describe, expect, it } from "vitest";
|
|
6
|
+
|
|
7
|
+
// isTaijiGuiHost 经 host.js re-export(canvas 工具门实走同一引用);isTaijiHost
|
|
8
|
+
// env 腿无 canvas 侧业务消费方,直接从共享谓词单点 import。
|
|
9
|
+
import { hostGateError, isTaijiGuiHost } from "../host.js";
|
|
10
|
+
import { withExtLogEnv } from "./helpers/canvas-harness.js";
|
|
11
|
+
|
|
12
|
+
describe("isTaijiHost / isTaijiGuiHost", () => {
|
|
13
|
+
it("env 信号缺失 = 非宿主(mode 无关)", () =>
|
|
14
|
+
withExtLogEnv(undefined, () => {
|
|
15
|
+
expect(isTaijiHost()).toBe(false);
|
|
16
|
+
expect(isTaijiGuiHost({ mode: "rpc" })).toBe(false);
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
it("env=1 但 mode 非 rpc(TUI / json / print)= 非 GUI 宿主(env 泄漏形态,禁入 select)", () =>
|
|
20
|
+
withExtLogEnv("1", () => {
|
|
21
|
+
expect(isTaijiHost()).toBe(true);
|
|
22
|
+
expect(isTaijiGuiHost({ mode: "tui" })).toBe(false);
|
|
23
|
+
expect(isTaijiGuiHost({ mode: "json" })).toBe(false);
|
|
24
|
+
expect(isTaijiGuiHost({ mode: "print" })).toBe(false);
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
it("env=1 + rpc = taiji GUI 宿主", () =>
|
|
28
|
+
withExtLogEnv("1", () => {
|
|
29
|
+
expect(isTaijiGuiHost({ mode: "rpc" })).toBe(true);
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
it("env 非 '1' 值 = 非宿主(严格等值判定)", () =>
|
|
33
|
+
withExtLogEnv("0", () => {
|
|
34
|
+
expect(isTaijiHost()).toBe(false);
|
|
35
|
+
}));
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("hostGateError", () => {
|
|
39
|
+
it("结构化错误含「画布需要 taiji 宿主」与恢复动作", () => {
|
|
40
|
+
const err = hostGateError();
|
|
41
|
+
expect(err.message).toContain("画布需要 taiji 宿主");
|
|
42
|
+
expect(err.message).toContain("恢复动作");
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
// review.test.ts —— 审查编排单元(review.ts):派发端口解析 / 轮次推进 / 条目校验
|
|
2
|
+
// 与写回 / 失败显式上报。编排链的 marker 往返(review-request / read / append 的
|
|
3
|
+
// 载荷形状)由 tool-execute.test.ts 通道层覆盖,此处聚焦编排纯逻辑。
|
|
4
|
+
|
|
5
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
6
|
+
|
|
7
|
+
import type { CanvasState } from "@zhushanwen/extension-protocol";
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
REVIEWER_REPORT_SCHEMA,
|
|
11
|
+
buildReviewerTask,
|
|
12
|
+
collectValidEntries,
|
|
13
|
+
currentReviewRound,
|
|
14
|
+
isCanvasStateReply,
|
|
15
|
+
renderResolutionTable,
|
|
16
|
+
resolveReviewerDispatcher,
|
|
17
|
+
runReviewRound,
|
|
18
|
+
type ReviewerDispatchPort,
|
|
19
|
+
} from "../review.js";
|
|
20
|
+
import { withExtLogEnv } from "./helpers/canvas-harness.js";
|
|
21
|
+
|
|
22
|
+
const SLOT_KEY = Symbol.for("@zhushanwen/subagent-core.service");
|
|
23
|
+
|
|
24
|
+
function makeState(overrides: Partial<CanvasState> = {}): CanvasState {
|
|
25
|
+
return {
|
|
26
|
+
canvasId: "c1",
|
|
27
|
+
name: "多引擎支持",
|
|
28
|
+
root: { nodeId: "root", statement: "多引擎支持怎么落地", criterias: ["c"], scopeExclusions: [] },
|
|
29
|
+
direction: null,
|
|
30
|
+
nodes: [],
|
|
31
|
+
edges: [],
|
|
32
|
+
comments: [],
|
|
33
|
+
dirtyPaths: [],
|
|
34
|
+
review: null,
|
|
35
|
+
converged: false,
|
|
36
|
+
createdAt: "2026-10-14T00:00:00Z",
|
|
37
|
+
updatedAt: "2026-10-14T00:00:00Z",
|
|
38
|
+
...overrides,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
delete (globalThis as Record<symbol, unknown>)[SLOT_KEY];
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("resolveReviewerDispatcher(SubagentService 访问器)", () => {
|
|
47
|
+
it("槽缺位(current=null)→ 结构化错误(review_dispatch_unavailable + 恢复动作)", () =>
|
|
48
|
+
withExtLogEnv(undefined, () => {
|
|
49
|
+
expect(() => resolveReviewerDispatcher()).toThrow(/review_dispatch_unavailable/);
|
|
50
|
+
expect(() => resolveReviewerDispatcher()).toThrow(/恢复动作/);
|
|
51
|
+
}));
|
|
52
|
+
|
|
53
|
+
it("current 形态漂移(executeAndAwait 非函数)→ 同款结构化错误", () => {
|
|
54
|
+
(globalThis as Record<symbol, unknown>)[SLOT_KEY] = { current: { nope: true } };
|
|
55
|
+
expect(() => resolveReviewerDispatcher()).toThrow(/review_dispatch_unavailable/);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("current 形态合法 → 经访问器返回 service 本体(D3-A4:旧 mock 把本体直放槽里,与断链的生产形态不同构)", () => {
|
|
59
|
+
const port: ReviewerDispatchPort = { executeAndAwait: async () => ({}) };
|
|
60
|
+
// 生产形状 = {current} 持有者(service-bootstrap.ts setSubagentService 只置 .current)
|
|
61
|
+
(globalThis as Record<symbol, unknown>)[SLOT_KEY] = { current: port };
|
|
62
|
+
expect(resolveReviewerDispatcher()).toBe(port);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("轮次与收窄输入", () => {
|
|
67
|
+
it("currentReviewRound:read 回包的 review.round 即本轮轮次(run 链路 read 发生在 review-request 之后);从未审查 = R1", () => {
|
|
68
|
+
// 从未审查(read 无 review 聚合)= R1
|
|
69
|
+
expect(currentReviewRound(makeState())).toBe(1);
|
|
70
|
+
// GUI 双跳第二跳:user 发起 R1(尚无报告条目)→ agent run 的 read = round 1(不再 +1)
|
|
71
|
+
expect(
|
|
72
|
+
currentReviewRound(
|
|
73
|
+
makeState({
|
|
74
|
+
review: { status: "in-progress", round: 1, maxRounds: 5, entries: [] },
|
|
75
|
+
}),
|
|
76
|
+
),
|
|
77
|
+
).toBe(1);
|
|
78
|
+
// runtime 推进到 R2 后的 read = round 2
|
|
79
|
+
expect(
|
|
80
|
+
currentReviewRound(
|
|
81
|
+
makeState({
|
|
82
|
+
review: { status: "in-progress", round: 2, maxRounds: 5, entries: [] },
|
|
83
|
+
}),
|
|
84
|
+
),
|
|
85
|
+
).toBe(2);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("renderResolutionTable:条目 × 处置状态逐条渲染;未处置显式标注", () => {
|
|
89
|
+
const table = renderResolutionTable(
|
|
90
|
+
makeState({
|
|
91
|
+
review: {
|
|
92
|
+
status: "in-progress",
|
|
93
|
+
round: 1,
|
|
94
|
+
maxRounds: 5,
|
|
95
|
+
entries: [
|
|
96
|
+
{
|
|
97
|
+
id: "rv-1",
|
|
98
|
+
round: 1,
|
|
99
|
+
dimension: "support",
|
|
100
|
+
severity: "must-fix",
|
|
101
|
+
targetId: "n1",
|
|
102
|
+
note: "悬空断言",
|
|
103
|
+
resolution: { disposition: "accepted", by: "agent", note: "补了论据" },
|
|
104
|
+
},
|
|
105
|
+
{ id: "rv-2", round: 1, dimension: "conflict", severity: "suggestion", targetId: "n2", note: "候选矛盾" },
|
|
106
|
+
],
|
|
107
|
+
},
|
|
108
|
+
}),
|
|
109
|
+
);
|
|
110
|
+
expect(table).toContain("[rv-1]");
|
|
111
|
+
expect(table).toContain("accepted(by agent):补了论据");
|
|
112
|
+
expect(table).toContain("[rv-2]");
|
|
113
|
+
expect(table).toContain("未处置");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("buildReviewerTask:R1 含六维度词表与画布 JSON;R2+ 注入 scope 与处置表", () => {
|
|
117
|
+
const state = makeState();
|
|
118
|
+
const r1 = buildReviewerTask(state, 1);
|
|
119
|
+
expect(r1).toContain("completeness");
|
|
120
|
+
expect(r1).toContain("depth");
|
|
121
|
+
expect(r1).toContain('"canvasId":"c1"');
|
|
122
|
+
expect(r1).not.toContain("本轮审查范围");
|
|
123
|
+
const r2 = buildReviewerTask(state, 2, "上轮处置表 + 攻击点");
|
|
124
|
+
expect(r2).toContain("R2 复审");
|
|
125
|
+
expect(r2).toContain("上轮处置表 + 攻击点");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("reviewer schema:entries 必填、六维度 enum、severity 两值、supportRating 三值", () => {
|
|
129
|
+
const entries = REVIEWER_REPORT_SCHEMA.properties as { entries: { items: { properties: Record<string, { enum?: string[]; type?: string }> }; type: string } };
|
|
130
|
+
expect(entries.entries.type).toBe("array");
|
|
131
|
+
expect(entries.entries.items.properties.dimension.enum).toEqual([
|
|
132
|
+
"completeness",
|
|
133
|
+
"necessity",
|
|
134
|
+
"support",
|
|
135
|
+
"authenticity",
|
|
136
|
+
"conflict",
|
|
137
|
+
"depth",
|
|
138
|
+
]);
|
|
139
|
+
expect(entries.entries.items.properties.severity.enum).toEqual(["must-fix", "suggestion"]);
|
|
140
|
+
expect(entries.entries.items.properties.supportRating.enum).toEqual(["sufficient", "gap", "invalid"]);
|
|
141
|
+
expect(entries.entries.items.properties.supportGaps.type).toBe("array");
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("collectValidEntries(reviewer 产出不信任校验)", () => {
|
|
146
|
+
it("合法条目收集(借契约守卫逐条校验),畸形条目丢弃并计数", () => {
|
|
147
|
+
const valid = { id: "rv-1", dimension: "support", severity: "must-fix", targetId: "n1", note: "x" };
|
|
148
|
+
const badWord = { id: "rv-2", dimension: "style", severity: "must-fix", targetId: "n1", note: "x" };
|
|
149
|
+
const badShape = { id: "rv-3", dimension: "support", severity: "must-fix", note: "缺 targetId" };
|
|
150
|
+
const { entries, dropped } = collectValidEntries("c1", 1, { entries: [valid, badWord, badShape] });
|
|
151
|
+
expect(entries).toEqual([{ ...valid, round: 1 }]);
|
|
152
|
+
expect(dropped).toBe(2);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("支撑度评价透传:gap 附清单合法收集;gap 无清单 / 词表外 rating / 孤儿清单丢弃计数", () => {
|
|
156
|
+
const rated = { id: "rv-1", dimension: "support", severity: "must-fix", targetId: "e-o1", note: "悬空", supportRating: "gap", supportGaps: ["缺性能论据"] };
|
|
157
|
+
const noGaps = { id: "rv-2", dimension: "support", severity: "must-fix", targetId: "e-o1", note: "x", supportRating: "gap" };
|
|
158
|
+
const badWord = { id: "rv-3", dimension: "support", severity: "must-fix", targetId: "e-o1", note: "x", supportRating: "strong" };
|
|
159
|
+
const orphan = { id: "rv-4", dimension: "support", severity: "must-fix", targetId: "e-o1", note: "x", supportGaps: ["孤儿"] };
|
|
160
|
+
const sufficient = { id: "rv-5", dimension: "support", severity: "suggestion", targetId: "e-o1", note: "支撑成立", supportRating: "sufficient" };
|
|
161
|
+
const { entries, dropped } = collectValidEntries("c1", 2, { entries: [rated, noGaps, badWord, orphan, sufficient] });
|
|
162
|
+
expect(entries).toEqual([
|
|
163
|
+
{ ...rated, round: 2 },
|
|
164
|
+
{ ...sufficient, round: 2 },
|
|
165
|
+
]);
|
|
166
|
+
expect(dropped).toBe(3);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("entries 非数组(LLM 畸形输出)→ 全丢弃、dropped=1", () => {
|
|
170
|
+
const { entries, dropped } = collectValidEntries("c1", 1, { entries: "nope" });
|
|
171
|
+
expect(entries).toEqual([]);
|
|
172
|
+
expect(dropped).toBe(1);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe("isCanvasStateReply(read 回包收窄)", () => {
|
|
177
|
+
it("CanvasState 形状放行;缺 nodes / 非对象拒绝", () => {
|
|
178
|
+
expect(isCanvasStateReply(makeState())).toBe(true);
|
|
179
|
+
expect(isCanvasStateReply({ canvasId: "c1", name: "n", root: {} })).toBe(false);
|
|
180
|
+
expect(isCanvasStateReply("text")).toBe(false);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe("runReviewRound(编排主体)", () => {
|
|
185
|
+
// 每用例独立写回收集器(不做跨用例共享状态)
|
|
186
|
+
const makeAppend = () => {
|
|
187
|
+
const calls: unknown[] = [];
|
|
188
|
+
return {
|
|
189
|
+
calls,
|
|
190
|
+
append: async (params: unknown): Promise<void> => {
|
|
191
|
+
calls.push(params);
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
it("写回链:reviewer 合法条目经 appendWriteBack 落 round 与 canvasId,结果汇总条数", async () => {
|
|
197
|
+
const append = makeAppend();
|
|
198
|
+
const calls: unknown[] = [];
|
|
199
|
+
const dispatch: ReviewerDispatchPort = {
|
|
200
|
+
executeAndAwait: async (opts) => {
|
|
201
|
+
calls.push(opts);
|
|
202
|
+
return {
|
|
203
|
+
parsedOutput: {
|
|
204
|
+
entries: [{ id: "rv-1", dimension: "support", severity: "must-fix", targetId: "n1", note: "悬空" }],
|
|
205
|
+
summary: "2 处支撑缺口",
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
const outcome = await runReviewRound({
|
|
211
|
+
state: makeState(),
|
|
212
|
+
dispatch,
|
|
213
|
+
appendWriteBack: append.append,
|
|
214
|
+
});
|
|
215
|
+
expect(outcome.round).toBe(1);
|
|
216
|
+
expect(outcome.appended).toBe(1);
|
|
217
|
+
expect(outcome.dropped).toBe(0);
|
|
218
|
+
expect(outcome.summary).toBe("2 处支撑缺口");
|
|
219
|
+
expect(append.calls).toEqual([
|
|
220
|
+
{
|
|
221
|
+
canvasId: "c1",
|
|
222
|
+
round: 1,
|
|
223
|
+
entries: [{ id: "rv-1", dimension: "support", severity: "must-fix", targetId: "n1", note: "悬空", round: 1 }],
|
|
224
|
+
},
|
|
225
|
+
]);
|
|
226
|
+
// 派发参数:task 内联画布 JSON + reviewer schema;slug 固定
|
|
227
|
+
expect(calls).toHaveLength(1);
|
|
228
|
+
const opts = calls[0] as { slug: string; task: string; schema: unknown; signal?: undefined };
|
|
229
|
+
expect(opts.slug).toBe("canvas-review");
|
|
230
|
+
expect(opts.task).toContain('"canvasId":"c1"');
|
|
231
|
+
expect(opts.schema).toBe(REVIEWER_REPORT_SCHEMA);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("R1 run(GUI 双跳第二跳:round=1 且尚无报告条目)→ 报告标 R1(不超前一轮),prompt 为全面审", async () => {
|
|
235
|
+
const append = makeAppend();
|
|
236
|
+
let capturedTask = "";
|
|
237
|
+
const dispatch: ReviewerDispatchPort = {
|
|
238
|
+
executeAndAwait: async (opts) => {
|
|
239
|
+
capturedTask = opts.task;
|
|
240
|
+
return { parsedOutput: { entries: [] } };
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
const outcome = await runReviewRound({
|
|
244
|
+
// run 链路时序:read 发生在 review-request 之后——input.state 是发起后形态
|
|
245
|
+
//(user 发起 R1 → 回推 → agent run;R1 尚无报告条目)
|
|
246
|
+
state: makeState({
|
|
247
|
+
review: { status: "in-progress", round: 1, maxRounds: 5, entries: [] },
|
|
248
|
+
}),
|
|
249
|
+
dispatch,
|
|
250
|
+
appendWriteBack: append.append,
|
|
251
|
+
});
|
|
252
|
+
expect(outcome.round).toBe(1);
|
|
253
|
+
expect(capturedTask).toContain("R1 全面审查");
|
|
254
|
+
// 全 clean 轮:零条目不写回(append 不被调用)
|
|
255
|
+
expect(append.calls).toHaveLength(0);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("R2 run(runtime 已推进 round=2)→ 报告标 R2,scope 收窄 + 上轮处置表进 task", async () => {
|
|
259
|
+
const append = makeAppend();
|
|
260
|
+
let capturedTask = "";
|
|
261
|
+
const dispatch: ReviewerDispatchPort = {
|
|
262
|
+
executeAndAwait: async (opts) => {
|
|
263
|
+
capturedTask = opts.task;
|
|
264
|
+
return { parsedOutput: { entries: [] } };
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
const outcome = await runReviewRound({
|
|
268
|
+
state: makeState({
|
|
269
|
+
review: {
|
|
270
|
+
status: "in-progress",
|
|
271
|
+
round: 2,
|
|
272
|
+
maxRounds: 5,
|
|
273
|
+
entries: [{ id: "rv-1", round: 1, dimension: "support", severity: "must-fix", targetId: "n1", note: "悬空断言" }],
|
|
274
|
+
},
|
|
275
|
+
}),
|
|
276
|
+
scope: "只审处置表",
|
|
277
|
+
dispatch,
|
|
278
|
+
appendWriteBack: append.append,
|
|
279
|
+
});
|
|
280
|
+
expect(outcome.round).toBe(2);
|
|
281
|
+
expect(capturedTask).toContain("R2 复审");
|
|
282
|
+
expect(capturedTask).toContain("只审处置表");
|
|
283
|
+
expect(capturedTask).toContain("[rv-1]");
|
|
284
|
+
expect(append.calls).toHaveLength(0);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("reviewer 执行失败(error 信封)→ 显式 throw(review_dispatch_failed + 恢复动作)", async () => {
|
|
288
|
+
const append = makeAppend();
|
|
289
|
+
const dispatch: ReviewerDispatchPort = {
|
|
290
|
+
executeAndAwait: async () => ({ error: "engine spawn failed" }),
|
|
291
|
+
};
|
|
292
|
+
await expect(
|
|
293
|
+
runReviewRound({ state: makeState(), dispatch, appendWriteBack: append.append }),
|
|
294
|
+
).rejects.toThrow(/review_dispatch_failed/);
|
|
295
|
+
await expect(
|
|
296
|
+
runReviewRound({ state: makeState(), dispatch, appendWriteBack: append.append }),
|
|
297
|
+
).rejects.toThrow(/恢复动作/);
|
|
298
|
+
expect(append.calls).toHaveLength(0);
|
|
299
|
+
});
|
|
300
|
+
});
|