@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.
@@ -0,0 +1,381 @@
1
+ // tool-execute.test.ts —— 工具 execute 的 marker 往返与错误闭环(设计 §3.1(2) 通道):
2
+ // 宿主门 / 参数分派 / marker 载荷形状 / enter 引导协议 / mutate 拒绝与通道错误 throw。
3
+
4
+ import { beforeEach, describe, expect, it, vi } from "vitest";
5
+
6
+ import { CANVAS_MARKER } from "@zhushanwen/extension-protocol";
7
+
8
+ import {
9
+ lastMarkerCall,
10
+ mountCanvas,
11
+ toolOf,
12
+ withExtLogEnv,
13
+ type ToolHarness,
14
+ } from "./helpers/canvas-harness.js";
15
+
16
+ describe("宿主门(select 前置,禁超时探测)", () => {
17
+ let harness: ToolHarness;
18
+ beforeEach(() => {
19
+ harness = mountCanvas();
20
+ });
21
+
22
+ it("env 缺失:抛「画布需要 taiji 宿主」,select 未被调用", async () => {
23
+ await withExtLogEnv(undefined, async () => {
24
+ const enter = toolOf(harness, "canvas_enter");
25
+ await expect(
26
+ enter.execute("t1", { statement: "s", criterias: ["c"], scope: [] }, undefined, undefined, harness.ctx),
27
+ ).rejects.toThrow("画布需要 taiji 宿主");
28
+ expect(harness.selectMock).not.toHaveBeenCalled();
29
+ });
30
+ });
31
+
32
+ it("env=1 但 mode=tui:拒绝进入 select(env 泄漏形态)", async () => {
33
+ await withExtLogEnv("1", async () => {
34
+ const read = toolOf(harness, "canvas_read");
35
+ await expect(
36
+ read.execute("t1", { canvasId: "c1" }, undefined, undefined, {
37
+ ...harness.ctx,
38
+ mode: "tui" as const,
39
+ }),
40
+ ).rejects.toThrow("画布需要 taiji 宿主");
41
+ expect(harness.selectMock).not.toHaveBeenCalled();
42
+ });
43
+ });
44
+ });
45
+
46
+ describe("canvas_enter 双形态 marker 往返", () => {
47
+ let harness: ToolHarness;
48
+ beforeEach(() => {
49
+ harness = mountCanvas();
50
+ });
51
+
52
+ it("创建形态:payload={action:'create', params:{statement,criterias,scopeExclusions}}(scope→scopeExclusions 映射),结果文本带工具检索引导", async () => {
53
+ await withExtLogEnv("1", async () => {
54
+ harness.selectMock.mockResolvedValue(
55
+ JSON.stringify({ ok: true, eventId: "e1", evType: "CanvasCreated", summary: "created" }),
56
+ );
57
+ const enter = toolOf(harness, "canvas_enter");
58
+ const result = (await enter.execute(
59
+ "t1",
60
+ { statement: "多引擎支持怎么落地", criterias: ["c1"], scope: ["不做 UI"] },
61
+ undefined,
62
+ undefined,
63
+ harness.ctx,
64
+ )) as { content: Array<{ type: string; text: string }> };
65
+ const call = lastMarkerCall(harness.selectMock);
66
+ expect(call.marker).toBe(CANVAS_MARKER);
67
+ expect(call.payload).toEqual({
68
+ action: "create",
69
+ params: { statement: "多引擎支持怎么落地", criterias: ["c1"], scopeExclusions: ["不做 UI"] },
70
+ });
71
+ expect(result.content[0].text).toContain("tool_search");
72
+ expect(result.content[0].text).toContain("canvas_edit / canvas_read / canvas_review / canvas_export");
73
+ });
74
+ });
75
+
76
+ it("创建形态 name 可选透传;打开形态 payload={action:'enter', params:{canvasId}}", async () => {
77
+ await withExtLogEnv("1", async () => {
78
+ harness.selectMock.mockResolvedValue(JSON.stringify({ canvasId: "c9", name: "n", nodes: [] }));
79
+ const enter = toolOf(harness, "canvas_enter");
80
+ await enter.execute("t1", { canvasId: "c9" }, undefined, undefined, harness.ctx);
81
+ expect(lastMarkerCall(harness.selectMock).payload).toEqual({ action: "enter", params: { canvasId: "c9" } });
82
+ });
83
+ });
84
+
85
+ it("两形态互斥:canvasId 与 statement 同传 → 结构化错误,不进入 select", async () => {
86
+ await withExtLogEnv("1", async () => {
87
+ const enter = toolOf(harness, "canvas_enter");
88
+ await expect(
89
+ enter.execute(
90
+ "t1",
91
+ { canvasId: "c1", statement: "s", criterias: ["c"], scope: [] },
92
+ undefined,
93
+ undefined,
94
+ harness.ctx,
95
+ ),
96
+ ).rejects.toThrow("invalid_params");
97
+ expect(harness.selectMock).not.toHaveBeenCalled();
98
+ });
99
+ });
100
+
101
+ it("缺创建三要素 → 结构化错误(invalid_params + 恢复动作)", async () => {
102
+ await withExtLogEnv("1", async () => {
103
+ const enter = toolOf(harness, "canvas_enter");
104
+ await expect(
105
+ enter.execute("t1", {}, undefined, undefined, harness.ctx),
106
+ ).rejects.toThrow("恢复动作");
107
+ await expect(
108
+ enter.execute("t1", { statement: "s" }, undefined, undefined, harness.ctx),
109
+ ).rejects.toThrow("invalid_params");
110
+ expect(harness.selectMock).not.toHaveBeenCalled();
111
+ });
112
+ });
113
+ });
114
+
115
+ describe("canvas_edit 五 action 分派", () => {
116
+ let harness: ToolHarness;
117
+ beforeEach(() => {
118
+ harness = mountCanvas();
119
+ });
120
+
121
+ it("grow:payload 按契约组装(batch/reason 透传)", async () => {
122
+ await withExtLogEnv("1", async () => {
123
+ harness.selectMock.mockResolvedValue(JSON.stringify({ ok: true, eventId: "e2", evType: "NodeCreated", summary: "grown" }));
124
+ const edit = toolOf(harness, "canvas_edit");
125
+ const batch = [{ id: "n1", type: "question", content: "q", attachTo: "root", edge: "decompose" }];
126
+ await edit.execute(
127
+ "t1",
128
+ { action: "grow", growParams: { canvasId: "c1", batch, reason: "MECE 分解" } },
129
+ undefined,
130
+ undefined,
131
+ harness.ctx,
132
+ );
133
+ expect(lastMarkerCall(harness.selectMock).payload).toEqual({
134
+ action: "grow",
135
+ params: { canvasId: "c1", batch, reason: "MECE 分解" },
136
+ });
137
+ });
138
+ });
139
+
140
+ it("grow:argument 条目携带 materiality='fact' → payload 透传且过契约守卫(素材性声明通道)", async () => {
141
+ await withExtLogEnv("1", async () => {
142
+ harness.selectMock.mockResolvedValue(JSON.stringify({ ok: true, eventId: "e2b", evType: "NodeCreated", summary: "grown" }));
143
+ const edit = toolOf(harness, "canvas_edit");
144
+ const batch = [
145
+ {
146
+ id: "arg-1",
147
+ type: "argument",
148
+ content: "实测数据支撑",
149
+ attachTo: "opt-1",
150
+ edge: "argue",
151
+ edgePolarity: "support",
152
+ materiality: "fact",
153
+ },
154
+ ];
155
+ await edit.execute(
156
+ "t1",
157
+ { action: "grow", growParams: { canvasId: "c1", batch, reason: "补充证据" } },
158
+ undefined,
159
+ undefined,
160
+ harness.ctx,
161
+ );
162
+ expect(lastMarkerCall(harness.selectMock).payload).toEqual({
163
+ action: "grow",
164
+ params: { canvasId: "c1", batch, reason: "补充证据" },
165
+ });
166
+ });
167
+ });
168
+
169
+ it("action 与参数块缺失 → 结构化错误(五 action 各自报块名)", async () => {
170
+ await withExtLogEnv("1", async () => {
171
+ const edit = toolOf(harness, "canvas_edit");
172
+ for (const [action, block] of [
173
+ ["grow", "growParams"],
174
+ ["patch", "patchParams"],
175
+ ["select", "selectParams"],
176
+ ["transition", "transitionParams"],
177
+ ["comment-respond", "commentRespondParams"],
178
+ ] as const) {
179
+ await expect(
180
+ edit.execute("t1", { action }, undefined, undefined, harness.ctx),
181
+ ).rejects.toThrow(block);
182
+ }
183
+ expect(harness.selectMock).not.toHaveBeenCalled();
184
+ });
185
+ });
186
+ });
187
+
188
+ describe("canvas_review / canvas_export / canvas_list 转译", () => {
189
+ let harness: ToolHarness;
190
+ beforeEach(() => {
191
+ harness = mountCanvas();
192
+ });
193
+
194
+ it("review resolve:payload={action:'review-resolve', params 透传}", async () => {
195
+ await withExtLogEnv("1", async () => {
196
+ harness.selectMock.mockResolvedValue(JSON.stringify({ ok: true, eventId: "e3", evType: "ReviewEntryResolved", summary: "r" }));
197
+ const review = toolOf(harness, "canvas_review");
198
+ await review.execute(
199
+ "t1",
200
+ { action: "resolve", resolveParams: { canvasId: "c1", entryId: "rv-1", disposition: "accepted", note: "已修" } },
201
+ undefined,
202
+ undefined,
203
+ harness.ctx,
204
+ );
205
+ expect(lastMarkerCall(harness.selectMock).payload).toEqual({
206
+ action: "review-resolve",
207
+ params: { canvasId: "c1", entryId: "rv-1", disposition: "accepted", note: "已修" },
208
+ });
209
+ });
210
+ });
211
+
212
+ it("review append:payload={action:'review-append', params 透传}", async () => {
213
+ await withExtLogEnv("1", async () => {
214
+ harness.selectMock.mockResolvedValue(JSON.stringify({ ok: true, eventId: "e4", evType: "ReviewEntryAppended", summary: "a" }));
215
+ const review = toolOf(harness, "canvas_review");
216
+ await review.execute(
217
+ "t1",
218
+ {
219
+ action: "append",
220
+ appendParams: {
221
+ canvasId: "c1",
222
+ round: 1,
223
+ entries: [{ id: "rv-1", dimension: "support", severity: "must-fix", targetId: "n1", note: "悬空" }],
224
+ },
225
+ },
226
+ undefined,
227
+ undefined,
228
+ harness.ctx,
229
+ );
230
+ expect(lastMarkerCall(harness.selectMock).payload).toEqual({
231
+ action: "review-append",
232
+ params: {
233
+ canvasId: "c1",
234
+ round: 1,
235
+ // 契约收口:逐条注入 round(CanvasReviewEntryInput 幂等锚点)
236
+ entries: [{ id: "rv-1", dimension: "support", severity: "must-fix", targetId: "n1", note: "悬空", round: 1 }],
237
+ },
238
+ });
239
+ });
240
+ });
241
+
242
+ it("review run:达上限停回(read 后 review.status=max-rounds)→ 短路返回停回说明,不派 reviewer", async () => {
243
+ await withExtLogEnv("1", async () => {
244
+ // marker 两跳:review-request(达上限 = 合法终态转移,mutate 成功)→ read(max-rounds 态)
245
+ harness.selectMock
246
+ .mockResolvedValueOnce(JSON.stringify({ ok: true, eventId: "e6", evType: "ReviewRequested", summary: "已达轮次上限(5 轮):已置终态 max-rounds 停回用户" }))
247
+ .mockResolvedValueOnce(
248
+ JSON.stringify({
249
+ canvasId: "c1",
250
+ name: "多引擎支持",
251
+ root: { nodeId: "root", statement: "s", criterias: ["c"], scopeExclusions: [] },
252
+ direction: null,
253
+ nodes: [],
254
+ edges: [],
255
+ comments: [],
256
+ dirtyPaths: [],
257
+ review: {
258
+ status: "max-rounds",
259
+ round: 5,
260
+ maxRounds: 5,
261
+ entries: [],
262
+ stopNote: "审查已达轮次上限(5 轮)未收敛,停回用户裁决",
263
+ recoverHint: "升级裁决(扩大审查范围或修改完成判据)后重新发起审查(画布「发起审查」入口,用户操作)",
264
+ },
265
+ converged: false,
266
+ createdAt: "2026-10-15T00:00:00Z",
267
+ updatedAt: "2026-10-15T00:00:00Z",
268
+ }),
269
+ );
270
+ // service 槽 = 陷阱:停回态下 reviewer 不得被派发(本轮没有开始)
271
+ const SLOT_KEY = Symbol.for("@zhushanwen/subagent-core.service");
272
+ const dispatch = vi.fn();
273
+ (globalThis as Record<symbol, unknown>)[SLOT_KEY] = { executeAndAwait: dispatch };
274
+ try {
275
+ const review = toolOf(harness, "canvas_review");
276
+ const result = (await review.execute("t1", { action: "run", runParams: { canvasId: "c1" } }, undefined, undefined, harness.ctx)) as {
277
+ content: Array<{ type: string; text: string }>;
278
+ };
279
+ const payload = JSON.parse(result.content[0]!.text) as Record<string, unknown>;
280
+ expect(payload.stopped).toBe(true);
281
+ expect(payload.status).toBe("max-rounds");
282
+ expect(String(payload.stopNote)).toContain("轮次上限");
283
+ expect(String(payload.recoverHint)).toContain("升级裁决");
284
+ expect(dispatch).not.toHaveBeenCalled();
285
+ } finally {
286
+ delete (globalThis as Record<symbol, unknown>)[SLOT_KEY];
287
+ }
288
+ });
289
+ });
290
+
291
+ it("export 需求卡缺 leafId → 结构化错误;planning-doc 正常映射", async () => {
292
+ await withExtLogEnv("1", async () => {
293
+ harness.selectMock.mockResolvedValue(JSON.stringify({ ok: true, eventId: "e5", evType: "CanvasExported", summary: "x" }));
294
+ const exp = toolOf(harness, "canvas_export");
295
+ await expect(
296
+ exp.execute("t1", { canvasId: "c1", target: "requirement-card" }, undefined, undefined, harness.ctx),
297
+ ).rejects.toThrow("invalid_params");
298
+ await exp.execute("t1", { canvasId: "c1", target: "planning-doc" }, undefined, undefined, harness.ctx);
299
+ expect(lastMarkerCall(harness.selectMock).payload).toEqual({
300
+ action: "export",
301
+ params: { canvasId: "c1", target: { target: "planning-doc" } },
302
+ });
303
+ });
304
+ });
305
+
306
+ it("list:空参数 payload,marker 一致", async () => {
307
+ await withExtLogEnv("1", async () => {
308
+ harness.selectMock.mockResolvedValue(JSON.stringify({ canvases: [] }));
309
+ const list = toolOf(harness, "canvas_list");
310
+ await list.execute("t1", {}, undefined, undefined, harness.ctx);
311
+ const call = lastMarkerCall(harness.selectMock);
312
+ expect(call.marker).toBe(CANVAS_MARKER);
313
+ expect(call.payload).toEqual({ action: "list", params: {} });
314
+ });
315
+ });
316
+ });
317
+
318
+ describe("错误闭环(禁止错误成功模式)", () => {
319
+ let harness: ToolHarness;
320
+ beforeEach(() => {
321
+ harness = mountCanvas();
322
+ });
323
+
324
+ it("mutate 领域拒绝(ok:false):throw 且消息含错误码与恢复动作", async () => {
325
+ await withExtLogEnv("1", async () => {
326
+ harness.selectMock.mockResolvedValue(
327
+ JSON.stringify({
328
+ ok: false,
329
+ error: { code: "selection_conflict", message: "该问题已有选定方案", recover: "换选走 replace 参数" },
330
+ }),
331
+ );
332
+ const edit = toolOf(harness, "canvas_edit");
333
+ await expect(
334
+ edit.execute(
335
+ "t1",
336
+ {
337
+ action: "select",
338
+ selectParams: { canvasId: "c1", questionId: "q1", optionId: "o2", reason: "r" },
339
+ },
340
+ undefined,
341
+ undefined,
342
+ harness.ctx,
343
+ ),
344
+ ).rejects.toThrow(/\[selection_conflict\].*恢复动作/s);
345
+ });
346
+ });
347
+
348
+ it("runtime 结构化错误回包({error,hint}):throw formatChannelErrorText 形态", async () => {
349
+ await withExtLogEnv("1", async () => {
350
+ harness.selectMock.mockResolvedValue(JSON.stringify({ error: "boom", hint: "check runtime" }));
351
+ const list = toolOf(harness, "canvas_list");
352
+ await expect(list.execute("t1", {}, undefined, undefined, harness.ctx)).rejects.toThrow("boom");
353
+ });
354
+ });
355
+
356
+ it("select resolve undefined(取消/超时折叠):throw,不静默成功", async () => {
357
+ await withExtLogEnv("1", async () => {
358
+ harness.selectMock.mockResolvedValue(undefined);
359
+ const list = toolOf(harness, "canvas_list");
360
+ await expect(list.execute("t1", {}, undefined, undefined, harness.ctx)).rejects.toThrow(/cancelled or channel error/);
361
+ });
362
+ });
363
+
364
+ it("非 JSON 回包:throw 协议漂移提示", async () => {
365
+ await withExtLogEnv("1", async () => {
366
+ harness.selectMock.mockResolvedValue("<html>gateway</html>");
367
+ const list = toolOf(harness, "canvas_list");
368
+ await expect(list.execute("t1", {}, undefined, undefined, harness.ctx)).rejects.toThrow(/non-JSON/);
369
+ });
370
+ });
371
+
372
+ it("无墙钟超时:select 选项不携带 timeout(任务级正常路径不设时限)", async () => {
373
+ await withExtLogEnv("1", async () => {
374
+ harness.selectMock.mockResolvedValue(JSON.stringify({ canvases: [] }));
375
+ const list = toolOf(harness, "canvas_list");
376
+ await list.execute("t1", {}, undefined, undefined, harness.ctx);
377
+ const opts = harness.selectMock.mock.calls[0][2] as { timeout?: number } | undefined;
378
+ expect(opts?.timeout).toBeUndefined();
379
+ });
380
+ });
381
+ });
@@ -0,0 +1,59 @@
1
+ // tool-registration.test.ts —— 六工具注册面(设计 §3.1(2):动词族 6 工具,全 deferred)。
2
+
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ import { CANVAS_TOOL_NAMES } from "@zhushanwen/extension-protocol";
6
+
7
+ import { mountCanvas, toolOf } from "./helpers/canvas-harness.js";
8
+
9
+ describe("六工具注册面", () => {
10
+ it("注册 6 个工具,名集与 U1 契约 CANVAS_TOOL_NAMES 一致", () => {
11
+ const { registered } = mountCanvas();
12
+ expect(registered).toHaveLength(6);
13
+ expect([...registered].map((t) => t.name).sort()).toEqual([...CANVAS_TOOL_NAMES].sort());
14
+ });
15
+
16
+ it("全部 exposure=deferred(画布低频入口,不设常驻 direct)", () => {
17
+ const { registered } = mountCanvas();
18
+ for (const tool of registered) {
19
+ expect(tool.exposure, `${tool.name} must be deferred`).toBe("deferred");
20
+ }
21
+ });
22
+
23
+ it("每工具带非空 promptSnippet(deferred 形态下激活后工具索引的精简条目)", () => {
24
+ // 入口可见性(未激活时的存在信号)由 <available_tools> 常驻段承载
25
+ // (available-tools.test.ts);snippet 是激活后工具索引的精简条目
26
+ const { registered } = mountCanvas();
27
+ for (const tool of registered) {
28
+ expect(tool.promptSnippet, `${tool.name} must carry a promptSnippet`).toMatch(/\S/);
29
+ }
30
+ });
31
+
32
+ it("每工具持有独立 typebox schema(object 形态,非共享引用)", () => {
33
+ const { registered } = mountCanvas();
34
+ const schemas = registered.map((t) => t.parameters) as Array<Record<string, unknown>>;
35
+ for (const schema of schemas) {
36
+ expect(schema.type).toBe("object");
37
+ expect(schema.properties).toBeTypeOf("object");
38
+ }
39
+ // 独立 schema:两两不同引用
40
+ for (let i = 0; i < schemas.length; i++) {
41
+ for (let j = i + 1; j < schemas.length; j++) {
42
+ expect(schemas[i]).not.toBe(schemas[j]);
43
+ }
44
+ }
45
+ });
46
+
47
+ it("canvas_edit grow 条目 schema 与契约 CanvasGrowItem 对齐:含 materiality(claim/fact 词表)、不含 factVerified(agent 不自证核实,fact 创建即未核实)", () => {
48
+ const harness = mountCanvas();
49
+ const params = toolOf(harness, "canvas_edit").parameters as Record<string, unknown>;
50
+ const growProps = ((params.properties as Record<string, unknown>).growParams as Record<string, unknown>)
51
+ .properties as Record<string, unknown>;
52
+ const batchItems = (growProps.batch as Record<string, unknown>).items as Record<string, unknown>;
53
+ const itemProps = batchItems.properties as Record<string, unknown>;
54
+ expect(Object.keys(itemProps)).toContain("materiality");
55
+ const materialityEnum = (itemProps.materiality as Record<string, unknown>).enum as string[];
56
+ expect([...materialityEnum].sort()).toEqual(["claim", "fact"]);
57
+ expect(Object.keys(itemProps)).not.toContain("factVerified");
58
+ });
59
+ });
package/src/host.ts ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * taiji GUI 宿主判定与画布工具的前置门(设计 §3.1(2) 宿主检测)。
3
+ *
4
+ * 谓词单点在 @zhushanwen/pi-ext-guards(isTaijiGuiHost:env 信号 + ctx.mode === "rpc"
5
+ * ——env 只证明 taiji runtime 在上游,TUI / json / print 形态宿主没有 marker 路由、
6
+ * select 会落终端 UI,单查 env 会在 env 泄漏到独立 pi 时误入 select 挂起;禁超时
7
+ * 探测,对齐时间平抑红线:不做「挂起后超时回退」类时间窗兜底,门必须在 select 之前)。
8
+ * 本模块 re-export 供六工具与组合根共门 import;env 的双语义耦合登记 SSOT 与拆分
9
+ * 迁移约束见 ext-guards 谓词分节注释(dmg-r1-7:自本文件与 plan state.ts 双实现收敛,
10
+ * 一致性由构造保证,不再靠注释互指)。
11
+ */
12
+
13
+ export { isTaijiGuiHost } from "@zhushanwen/pi-ext-guards";
14
+
15
+ /** 非 taiji GUI 宿主的结构化错误(设计:返回「画布需要 taiji 宿主」,不进入 select)。 */
16
+ export function hostGateError(): Error {
17
+ return new Error(
18
+ [
19
+ "canvas_unavailable_host: 画布需要 taiji 宿主。",
20
+ "当前运行环境不是 taiji GUI 托管的 pi(缺 TAIJI_AGENT_EXT_LOG=1 或 ctx.mode 非 rpc),",
21
+ "画布工具依赖 taiji runtime 的 marker 路由与 CanvasService,独立 pi CLI 下不可用。",
22
+ "恢复动作:在 taiji 桌面应用的会话中使用画布工具;纯 pi 环境请直接以对话方式规划。",
23
+ ].join(""),
24
+ );
25
+ }
package/src/index.ts ADDED
@@ -0,0 +1,174 @@
1
+ /**
2
+ * @zhushanwen/pi-canvas —— 问题解决画布工具面(设计 §3.1(2) / §5-U4)。
3
+ *
4
+ * 六工具动词族,全部 exposure:"deferred"(画布是低频入口,不设常驻 direct——
5
+ * tool_search 检索激活,一次检索同族 action 全解锁)。每工具 execute 将调用
6
+ * 转译为 CANVAS_MARKER 的 {action, params} 嵌套载荷(契约 SSOT 在
7
+ * @zhushanwen/extension-protocol 的 src/extensions/canvas/ 契约模块,U1),经 ctx.ui.select
8
+ * 挂起由 taiji runtime 的 marker 显式分支路由到 CanvasService。
9
+ *
10
+ * 宿主检测:工具调用前检查 TAIJI_AGENT_EXT_LOG === "1" 且 ctx.mode === "rpc"
11
+ * (谓词单点 @zhushanwen/pi-ext-guards isTaijiGuiHost,经 src/host.ts re-export)
12
+ * ——非 taiji GUI 宿主返回结构化错误、不进入 select(禁超时探测)。
13
+ *
14
+ * 该扩展不收窄宿主工具集(与 plan 的根本差异:agent 要边查代码边论证边生长),
15
+ * 也无 per-session 状态——画布状态唯一权威在 runtime CanvasService。
16
+ *
17
+ * <available_tools> 常驻存在性段(before_agent_start 注入):pi 1.0.0 下 deferred
18
+ * 工具的 description 只进 tool_search 检索文档、promptSnippet 不进 system prompt
19
+ * (D3-A1 真机验收实证:模型对画布工具零感知,自然语言驱动不会发起检索)——
20
+ * 本段把六工具 name + description 常驻进 system prompt 并写明「需先 tool_search
21
+ * 激活」,subagent-workflow <available_subagents> 注入段同族形态。
22
+ */
23
+
24
+ import type {
25
+ ExtensionAPI,
26
+ ExtensionContext,
27
+ BeforeAgentStartEvent,
28
+ } from "@earendil-works/pi-coding-agent";
29
+
30
+ import { setPiHandle } from "@zhushanwen/pi-extension-logger";
31
+
32
+ import { isTaijiGuiHost } from "./host.js";
33
+ import { CanvasEnterParams, registerCanvasEnterTool } from "./tool-enter.js";
34
+ import { CanvasEditParams, registerCanvasEditTool } from "./tool-edit.js";
35
+ import { CanvasReadParams, registerCanvasReadTool } from "./tool-read.js";
36
+ import { CanvasReviewParams, registerCanvasReviewTool } from "./tool-review.js";
37
+ import { CanvasExportParams, registerCanvasExportTool } from "./tool-export.js";
38
+ import { CanvasListParams, registerCanvasListTool } from "./tool-list.js";
39
+
40
+ /**
41
+ * 六工具对外描述单源(SSOT):registerTool 的 description 与 <available_tools>
42
+ * 常驻段共用同一份数据,双处文本零漂移。
43
+ */
44
+ export const CANVAS_AVAILABLE_TOOLS: ReadonlyArray<{ name: string; description: string }> = [
45
+ {
46
+ name: "canvas_enter",
47
+ description:
48
+ "Enter problem-solving-canvas mode: CREATE a canvas (pass statement + criterias + scope — the root problem triple) or OPEN an existing one (pass canvasId from canvas_list). " +
49
+ "Use when the user asks to plan / think through / break down a problem before execution: the canvas is a human-agent co-edited argument map the user watches and rules on in the taiji GUI. " +
50
+ "After entering, the user's GUI decisions (selections / vetoes / comments) arrive as canvas event notifications — always canvas_read the current state before responding. " +
51
+ "Requires the taiji desktop runtime (errors out in standalone pi).",
52
+ },
53
+ {
54
+ name: "canvas_edit",
55
+ description:
56
+ "Grow and edit the problem-solving canvas: grow (append a batch of nodes — decomposition, options, arguments), patch (edit one node), select (mark the chosen option; replace to re-select with a comparison reason), transition (lifecycle moves like reject-with-record), comment-respond (answer a user comment — adopted or rebutted, never ignored). " +
57
+ "Reason fields are mandatory (audit trail); always canvas_read first and edit the CURRENT state. " +
58
+ "Structural guarantee: user-side rulings arrive as notifications, agent-side edits never self-notify. " +
59
+ "Requires the taiji desktop runtime (errors out in standalone pi).",
60
+ },
61
+ {
62
+ name: "canvas_read",
63
+ description:
64
+ "Read the problem-solving canvas: full state or one subtree (nodes / edges / comments / review report / convergence flags). " +
65
+ "The read-before-act discipline entry: respond to canvas event notifications and edit the canvas only after reading the current state. " +
66
+ "Agent-side canvas query entry: the reviewer subagent runs with zero tools (canvas data is inlined into the reviewer prompt by this parent extension). " +
67
+ "Requires the taiji desktop runtime (errors out in standalone pi).",
68
+ },
69
+ {
70
+ name: "canvas_review",
71
+ description:
72
+ "Run the canvas adversarial review loop: run (dispatch one reviewer subagent over the six-dimension checklist — completeness / necessity / support / authenticity / conflict / depth — and write the report back; R2+ pass scope to re-review only the resolution table), append (reviewer report write path), resolve (register one entry's disposition: accepted / deferred / rejected-with-reason — the fix side's duty after repairing). " +
73
+ "must-fix entries block convergence until cleared. " +
74
+ "Requires the taiji desktop runtime (errors out in standalone pi).",
75
+ },
76
+ {
77
+ name: "canvas_export",
78
+ description:
79
+ "Export a converged canvas: 'planning-doc' (full direction-planning document) or 'requirement-card' (one execution leaf, pass leafId). " +
80
+ "Rejected with not_converged until the canvas has converged (must-fix cleared, no dirty paths, root criterias confirmed). " +
81
+ "Requires the taiji desktop runtime (errors out in standalone pi).",
82
+ },
83
+ {
84
+ name: "canvas_list",
85
+ description:
86
+ "List all problem-solving canvases (id, name, statement, converged flag, open must-fix count, review status). " +
87
+ "The canvasId source for canvas_enter's open form. " +
88
+ "Requires the taiji desktop runtime (errors out in standalone pi).",
89
+ },
90
+ ];
91
+
92
+ /**
93
+ * 构造 <available_tools> 段文本:工具清单 + 检索激活指引(纯函数,导出供单测)。
94
+ * description 单行化(注册侧 description 均为单行拼接串;列表项内换行会破坏
95
+ * 列表形态,防御外部将来传入多行文本)。
96
+ */
97
+ export function buildAvailableToolsSection(tools: ReadonlyArray<{ name: string; description: string }>): string {
98
+ const lines = tools.map((t) => `- ${t.name}: ${t.description.replace(/\s+/g, " ").trim()}`);
99
+ return [
100
+ "<available_tools>",
101
+ "The tools below are registered but NOT loaded into your toolset — calling one directly fails.",
102
+ "To use one, first call `tool_search` with a query matching its name or purpose (e.g. \"canvas enter problem solving\");",
103
+ "the match is activated and callable from your next turn.",
104
+ "",
105
+ ...lines,
106
+ "</available_tools>",
107
+ ].join("\n");
108
+ }
109
+
110
+ export default function canvasExtension(pi: ExtensionAPI): void {
111
+ // logger 持久化通道接入(appendEntry custom entry,不进 LLM 上下文)
112
+ setPiHandle(pi);
113
+
114
+ // 注册配置:description 从 CANVAS_AVAILABLE_TOOLS 单源引用(逐工具索引;两处
115
+ // 文本零漂移);promptSnippet = 工具激活后 system prompt 工具索引里的精简条目
116
+ // (未激活时不进上下文,入口可见性由 <available_tools> 常驻段承载)。
117
+ registerCanvasEnterTool(pi, {
118
+ name: "canvas_enter",
119
+ label: "Canvas Enter",
120
+ description: CANVAS_AVAILABLE_TOOLS[0].description,
121
+ promptSnippet:
122
+ "Enter canvas mode: create a problem-solving canvas (statement + criterias + scope) or open an existing one",
123
+ parameters: CanvasEnterParams,
124
+ });
125
+
126
+ registerCanvasEditTool(pi, {
127
+ name: "canvas_edit",
128
+ label: "Canvas Edit",
129
+ description: CANVAS_AVAILABLE_TOOLS[1].description,
130
+ promptSnippet: "Grow / patch / select / transition / comment-respond on a canvas (one tool, five actions)",
131
+ parameters: CanvasEditParams,
132
+ });
133
+
134
+ registerCanvasReadTool(pi, {
135
+ name: "canvas_read",
136
+ label: "Canvas Read",
137
+ description: CANVAS_AVAILABLE_TOOLS[2].description,
138
+ promptSnippet: "Read the current canvas state (full or one subtree) before acting on canvas events",
139
+ parameters: CanvasReadParams,
140
+ });
141
+
142
+ registerCanvasReviewTool(pi, {
143
+ name: "canvas_review",
144
+ label: "Canvas Review",
145
+ description: CANVAS_AVAILABLE_TOOLS[3].description,
146
+ promptSnippet: "Run the adversarial review loop, append a reviewer report, or resolve one review entry",
147
+ parameters: CanvasReviewParams,
148
+ });
149
+
150
+ registerCanvasExportTool(pi, {
151
+ name: "canvas_export",
152
+ label: "Canvas Export",
153
+ description: CANVAS_AVAILABLE_TOOLS[4].description,
154
+ promptSnippet: "Export a converged canvas as a planning doc or a requirement card",
155
+ parameters: CanvasExportParams,
156
+ });
157
+
158
+ registerCanvasListTool(pi, {
159
+ name: "canvas_list",
160
+ label: "Canvas List",
161
+ description: CANVAS_AVAILABLE_TOOLS[5].description,
162
+ promptSnippet: "List all canvases (id, name, converged flag, open must-fix count)",
163
+ parameters: CanvasListParams,
164
+ });
165
+
166
+ // <available_tools> 常驻段(注入内容注册期构造一次——每轮 system prompt 追加
167
+ // 相同文本,KV-cache 前缀稳定)。宿主守卫与工具 execute 同谓词:非 taiji GUI
168
+ // 宿主下画布工具调用必被 hostGateError 拒绝,不应引导模型检索。
169
+ const availableToolsSection = buildAvailableToolsSection(CANVAS_AVAILABLE_TOOLS);
170
+ pi.on("before_agent_start", (event: BeforeAgentStartEvent, ctx: ExtensionContext) => {
171
+ if (!isTaijiGuiHost(ctx)) return undefined;
172
+ return { systemPrompt: `${event.systemPrompt}\n\n${availableToolsSection}` };
173
+ });
174
+ }