@zhushanwen/pi-subagent-workflow 8.1.1 → 8.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,139 @@
1
+ // model-list-injector 单测
2
+ //
3
+ // 覆盖(与 workflow-list-injector.test.ts 对称,但本 injector 无文件发现/模块级
4
+ // 缓存,纯函数比重更大):
5
+ // 1. formatModelList:排序稳定性(registry 返回顺序不保证,输出必须字节稳定)、
6
+ // caps 推导(reasoning/vision)、空 caps 省略、XML 转义、空列表不注入
7
+ // 2. setupModelListInjector handler:注入 append、空列表返回 undefined、
8
+ // registry 异常不阻断(fail-safe)
9
+
10
+ import { describe, expect, it, vi } from "vitest";
11
+
12
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
+
14
+ import type { ModelEntry } from "../model-list-injector";
15
+
16
+ // ── 测试数据工厂 ────────────────────────────────────────
17
+
18
+ function entry(overrides: Partial<ModelEntry> = {}): ModelEntry {
19
+ return {
20
+ provider: "zai-coding-cn",
21
+ id: "glm-5.2",
22
+ name: "GLM 5.2",
23
+ reasoning: true,
24
+ input: ["text"],
25
+ contextWindow: 200_000,
26
+ ...overrides,
27
+ };
28
+ }
29
+
30
+ // ── 纯函数:formatModelList ─────────────────────────────
31
+
32
+ describe("formatModelList", () => {
33
+ it("空列表返回空串(不注入)", async () => {
34
+ const { formatModelList } = await import("../model-list-injector");
35
+ expect(formatModelList([])).toBe("");
36
+ });
37
+
38
+ it("渲染 provider/modelId + name + caps + contextWindow", async () => {
39
+ const { formatModelList } = await import("../model-list-injector");
40
+ const out = formatModelList([
41
+ entry({ provider: "p1", id: "m1", name: "Model One" }),
42
+ ]);
43
+ expect(out).toContain("<available_provider_models>");
44
+ expect(out).toContain("<model><id>p1/m1</id><name>Model One</name><caps>reasoning</caps><contextWindow>200000</contextWindow></model>");
45
+ expect(out).toContain("</available_provider_models>");
46
+ });
47
+
48
+ it("input 含 image 时 caps 加 vision;无能力时省略 caps 段", async () => {
49
+ const { formatModelList } = await import("../model-list-injector");
50
+ const out = formatModelList([
51
+ entry({ id: "vision-m", input: ["text", "image"] }),
52
+ entry({ id: "plain-m", reasoning: false }),
53
+ ]);
54
+ expect(out).toContain("<id>zai-coding-cn/vision-m</id>");
55
+ expect(out).toContain("<caps>reasoning,vision</caps>");
56
+ // plain-m:reasoning=false 且无 image → 无 caps 段
57
+ expect(out).toMatch(/<id>zai-coding-cn\/plain-m<\/id><name>[^<]*<\/name><contextWindow>/);
58
+ });
59
+
60
+ it("按 (provider, id) 排序——输入乱序输出仍字节稳定(KV cache 前提)", async () => {
61
+ const { formatModelList } = await import("../model-list-injector");
62
+ const models = [
63
+ entry({ provider: "b-prov", id: "z-model" }),
64
+ entry({ provider: "a-prov", id: "y-model" }),
65
+ entry({ provider: "a-prov", id: "x-model" }),
66
+ ];
67
+ const out1 = formatModelList([...models]);
68
+ const out2 = formatModelList([...models].reverse());
69
+ expect(out1).toBe(out2);
70
+ // 排序断言:a-prov/x-model 在最前,b-prov 在最后
71
+ const ids = [...out1.matchAll(/<id>([^<]+)<\/id>/g)].map((m) => m[1]);
72
+ expect(ids).toEqual(["a-prov/x-model", "a-prov/y-model", "b-prov/z-model"]);
73
+ });
74
+
75
+ it("排序为码点序而非 locale 序(跨环境字节一致契约,禁 localeCompare)", async () => {
76
+ const { formatModelList } = await import("../model-list-injector");
77
+ // 判别样本:码点序 "B"(0x42) < "a"(0x61),而多数 locale 的 localeCompare
78
+ // 会把 "a-model" 排在 "B-model" 前——本断言在 localeCompare 实现下必挂
79
+ const out = formatModelList([
80
+ entry({ provider: "p", id: "a-model" }),
81
+ entry({ provider: "p", id: "B-model" }),
82
+ ]);
83
+ const ids = [...out.matchAll(/<id>([^<]+)<\/id>/g)].map((m) => m[1]);
84
+ expect(ids).toEqual(["p/B-model", "p/a-model"]);
85
+ });
86
+
87
+ it("name 含 XML 特殊字符时转义(防注入段结构破坏)", async () => {
88
+ const { formatModelList } = await import("../model-list-injector");
89
+ const out = formatModelList([
90
+ entry({ provider: "p", id: "m", name: `A<&>"'B` }),
91
+ ]);
92
+ expect(out).toContain("<name>A&lt;&amp;&gt;&quot;&apos;B</name>");
93
+ });
94
+ });
95
+
96
+ // ── handler 行为:setupModelListInjector ────────────────
97
+
98
+ describe("setupModelListInjector", () => {
99
+ type Handler = (event: unknown, ctx: unknown) => Promise<unknown>;
100
+
101
+ async function setupWithRegistry(models: ModelEntry[], fail = false): Promise<Handler> {
102
+ const mod = await import("../model-list-injector");
103
+ const handlers: Record<string, Handler> = {};
104
+ const pi = {
105
+ on: (name: string, fn: Handler) => {
106
+ handlers[name] = fn;
107
+ },
108
+ } as unknown as ExtensionAPI;
109
+ mod.setupModelListInjector(pi);
110
+ const registry = fail
111
+ ? { getAvailable: vi.fn(() => { throw new Error("registry boom"); }) }
112
+ : { getAvailable: vi.fn(() => models) };
113
+ const handler = handlers["before_agent_start"];
114
+ if (!handler) throw new Error("before_agent_start handler not registered");
115
+ return (event: unknown) => handler(event, { modelRegistry: registry });
116
+ }
117
+
118
+ it("注册 before_agent_start handler,注入 append 到 systemPrompt 尾部", async () => {
119
+ const handler = await setupWithRegistry([entry()]);
120
+ const result = (await handler({ systemPrompt: "BASE" }, {})) as {
121
+ systemPrompt: string;
122
+ };
123
+ expect(result.systemPrompt.startsWith("BASE")).toBe(true);
124
+ expect(result.systemPrompt).toContain("<available_provider_models>");
125
+ expect(result.systemPrompt).toContain("zai-coding-cn/glm-5.2");
126
+ });
127
+
128
+ it("空模型列表返回 undefined(不返回 systemPrompt,不干预链)", async () => {
129
+ const handler = await setupWithRegistry([]);
130
+ const result = await handler({ systemPrompt: "BASE" }, {});
131
+ expect(result).toBeUndefined();
132
+ });
133
+
134
+ it("registry 异常被吞掉(fail-safe,不阻断 agent turn)", async () => {
135
+ const handler = await setupWithRegistry([], true);
136
+ const result = await handler({ systemPrompt: "BASE" }, {});
137
+ expect(result).toBeUndefined();
138
+ });
139
+ });
@@ -10,43 +10,20 @@
10
10
 
11
11
  import { beforeEach, describe, expect, it, vi } from "vitest";
12
12
 
13
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
14
-
15
13
  import type { DiscoveredResource } from "../../shared/resource-discovery.ts";
14
+ // 共享 mock 基建(vi.mock 工厂 / mock pi / mock ctx):helpers/injector-test-mocks.ts
15
+ import { createDiscoveryModuleMock, createLoggerModuleMock, createMockCtx, createMockPi, type CapturedHandlers } from "./helpers/injector-test-mocks.ts";
16
16
 
17
17
  // ── 稳定 spy:vi.hoisted 保证 resetModules 后引用不变(vi.mock 工厂闭包捕获同一 fn,
18
18
  // 故 fresh import 的注入器拿到的 discoverResources === spies.discoverResources,跨
19
19
  // resetModules 调用计数连续,mockClear 控制每用例重置) ──
20
- const spies = vi.hoisted(() => ({
21
- discoverResources: vi.fn(),
22
- getCachedFileContent: vi.fn(),
23
- }));
24
-
25
- vi.mock("../../shared/resource-discovery.ts", () => ({
26
- discoverResources: spies.discoverResources,
27
- findWorkspaceRoot: () => "/ws",
28
- getCachedFileContent: spies.getCachedFileContent,
29
- }));
30
-
31
- vi.mock("@zhushanwen/pi-extension-logger", () => ({
32
- getLogger: () => ({
33
- debug: () => {
34
- /* no-op */
35
- },
36
- info: () => {
37
- /* no-op */
38
- },
39
- warn: () => {
40
- /* no-op */
41
- },
42
- error: () => {
43
- /* no-op */
44
- },
45
- }),
46
- setPiHandle: () => {
47
- /* no-op */
48
- },
49
- }));
20
+ const spies = vi.hoisted(() => ({ discoverResources: vi.fn(), getCachedFileContent: vi.fn() }));
21
+
22
+ vi.mock("../../shared/resource-discovery.ts", () => createDiscoveryModuleMock(spies));
23
+
24
+ // 工厂必须写成箭头惰性形式:vi.mock 被提升到 import 之前执行,直接传
25
+ // createLoggerModuleMock 引用会在提升位置立即求值 import 绑定 → TDZ ReferenceError
26
+ vi.mock("@zhushanwen/pi-extension-logger", () => createLoggerModuleMock());
50
27
 
51
28
  // ── 纯函数测试:静态 import(模块级缓存状态不影响纯函数;与下方缓存 describe 隔离) ──
52
29
  import { formatAgentList, parseAgentFrontmatter } from "../subagent-list-injector";
@@ -173,45 +150,9 @@ describe("formatAgentList", () => {
173
150
  });
174
151
 
175
152
  // ──────────────────────────────────────────────────────────────
176
- // session 级缓存行为(TC1-TC4)
153
+ // session 级缓存行为(TC1-TC4;mock pi/ctx 构造在 helpers/injector-test-mocks.ts
177
154
  // ──────────────────────────────────────────────────────────────
178
155
 
179
- /** before_agent_start handler 的返回结构(取 systemPrompt 断言)。 */
180
- interface BeforeAgentResult {
181
- systemPrompt: string;
182
- }
183
-
184
- /** 三 handler 捕获引用(setupSubagentListInjector 注册后填充)。 */
185
- interface CapturedHandlers {
186
- sessionStart?: (event: unknown, ctx: unknown) => Promise<void> | void;
187
- beforeAgentStart?: (event: { systemPrompt: string }, ctx: unknown) => Promise<BeforeAgentResult | void> | BeforeAgentResult | void;
188
- sessionShutdown?: (event: unknown, ctx: unknown) => void;
189
- }
190
-
191
- /** 构造 mock pi:捕获三 handler 引用,其余 prop 走 noop(仅 on 被调用)。 */
192
- function createMockPi(handlers: CapturedHandlers): ExtensionAPI {
193
- const on = (event: string, handler: (...args: unknown[]) => unknown): void => {
194
- if (event === "session_start") {
195
- handlers.sessionStart = handler as CapturedHandlers["sessionStart"];
196
- } else if (event === "before_agent_start") {
197
- handlers.beforeAgentStart = handler as CapturedHandlers["beforeAgentStart"];
198
- } else if (event === "session_shutdown") {
199
- handlers.sessionShutdown = handler as CapturedHandlers["sessionShutdown"];
200
- }
201
- };
202
- const noop = (): void => {
203
- /* mock */
204
- };
205
- // setupSubagentListInjector 仅调 pi.on;用最小对象 + 双重断言满足 ExtensionAPI 契约
206
- // (测试 mock 约定,见 crash-recovery.test.ts 的 Proxy 模式)
207
- return { on, appendEntry: noop, registerTool: noop, registerCommand: noop, registerMessageRenderer: noop, events: { emit: noop, on: noop } } as unknown as ExtensionAPI;
208
- }
209
-
210
- /** 最小 ctx mock(注入器只读 ctx.cwd)。 */
211
- function createMockCtx(): Record<string, unknown> {
212
- return { cwd: "/ws", mode: "tui" };
213
- }
214
-
215
156
  /** fixture:单个 worker agent 的 DiscoveredResource。 */
216
157
  function agentResource(path: string): DiscoveredResource {
217
158
  return { path, source: "project-agents", available: true };
@@ -344,3 +285,52 @@ describe("subagent-list-injector session 级缓存", () => {
344
285
  expect(r?.systemPrompt).toContain("<name>worker</name>");
345
286
  });
346
287
  });
288
+
289
+ // ──────────────────────────────────────────────────────────────
290
+ // KV-cache 顺序契约:输出按 name 码点序,重建(两次发现)逐字节一致
291
+ // ──────────────────────────────────────────────────────────────
292
+
293
+ describe("discoverAllAgents 顺序契约(KV-cache)", () => {
294
+ it("输出按 name 码点序排序,与发现层返回顺序(readdir 枚举序)无关", async () => {
295
+ const byPath: Record<string, string> = {
296
+ "/ws/.agents/agents/zeta.md": agentMd("zeta", "z"),
297
+ "/ws/.agents/agents/worker.md": agentMd("worker", "w"),
298
+ "/ws/.agents/agents/alpha.md": agentMd("alpha", "a"),
299
+ };
300
+ // 刻意以非字母序返回(模拟 readdir 无契约枚举序)
301
+ spies.discoverResources.mockResolvedValue([
302
+ agentResource("/ws/.agents/agents/zeta.md"),
303
+ agentResource("/ws/.agents/agents/worker.md"),
304
+ agentResource("/ws/.agents/agents/alpha.md"),
305
+ ]);
306
+ spies.getCachedFileContent.mockImplementation((p: string) => byPath[p] ?? null);
307
+
308
+ const { discoverAllAgents } = await import("../subagent-list-injector");
309
+ const agents = await discoverAllAgents("/ws", "/agent");
310
+ expect(agents.map((a) => a.name)).toEqual(["alpha", "worker", "zeta"]);
311
+ });
312
+
313
+ it("重建(两次发现)输出与渲染结果逐字节一致——目录不变时 session_start/fallback/resume 任意重建等价", async () => {
314
+ const byPath: Record<string, string> = {
315
+ "/ws/.agents/agents/b.md": agentMd("beta", "b"),
316
+ "/ws/.agents/agents/a.md": agentMd("alpha", "a"),
317
+ };
318
+ // 两次发现返回顺序不同(模拟跨进程 readdir 漂移)
319
+ spies.discoverResources
320
+ .mockResolvedValueOnce([
321
+ agentResource("/ws/.agents/agents/b.md"),
322
+ agentResource("/ws/.agents/agents/a.md"),
323
+ ])
324
+ .mockResolvedValueOnce([
325
+ agentResource("/ws/.agents/agents/a.md"),
326
+ agentResource("/ws/.agents/agents/b.md"),
327
+ ]);
328
+ spies.getCachedFileContent.mockImplementation((p: string) => byPath[p] ?? null);
329
+
330
+ const { discoverAllAgents, formatAgentList } = await import("../subagent-list-injector");
331
+ const first = await discoverAllAgents("/ws", "/agent");
332
+ const second = await discoverAllAgents("/ws", "/agent");
333
+ expect(second).toEqual(first);
334
+ expect(formatAgentList(second)).toBe(formatAgentList(first));
335
+ });
336
+ });
@@ -10,41 +10,17 @@
10
10
 
11
11
  import { beforeEach, describe, expect, it, vi } from "vitest";
12
12
 
13
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
14
-
15
13
  import type { DiscoveredResource } from "../../shared/resource-discovery.ts";
14
+ // 共享 mock 基建(vi.mock 工厂 / mock pi / mock ctx):helpers/injector-test-mocks.ts
15
+ import { createDiscoveryModuleMock, createLoggerModuleMock, createMockCtx, createMockPi, type CapturedHandlers } from "./helpers/injector-test-mocks.ts";
16
16
 
17
17
  // ── 稳定 spy(vi.hoisted 保证 resetModules 后引用不变,见 subagent 测试同款注释) ──
18
- const spies = vi.hoisted(() => ({
19
- discoverResources: vi.fn(),
20
- getCachedFileContent: vi.fn(),
21
- }));
22
-
23
- vi.mock("../../shared/resource-discovery.ts", () => ({
24
- discoverResources: spies.discoverResources,
25
- findWorkspaceRoot: () => "/ws",
26
- getCachedFileContent: spies.getCachedFileContent,
27
- }));
28
-
29
- vi.mock("@zhushanwen/pi-extension-logger", () => ({
30
- getLogger: () => ({
31
- debug: () => {
32
- /* no-op */
33
- },
34
- info: () => {
35
- /* no-op */
36
- },
37
- warn: () => {
38
- /* no-op */
39
- },
40
- error: () => {
41
- /* no-op */
42
- },
43
- }),
44
- setPiHandle: () => {
45
- /* no-op */
46
- },
47
- }));
18
+ const spies = vi.hoisted(() => ({ discoverResources: vi.fn(), getCachedFileContent: vi.fn() }));
19
+
20
+ vi.mock("../../shared/resource-discovery.ts", () => createDiscoveryModuleMock(spies));
21
+
22
+ // 工厂必须写成箭头惰性形式(vi.mock 提升后直接传引用会 TDZ,见 helper 文件头注释)
23
+ vi.mock("@zhushanwen/pi-extension-logger", () => createLoggerModuleMock());
48
24
 
49
25
  // ── 纯函数测试:静态 import(模块级缓存状态不影响纯函数) ──
50
26
  import {
@@ -178,43 +154,10 @@ describe("formatWorkflowList", () => {
178
154
  });
179
155
 
180
156
  // ──────────────────────────────────────────────────────────────
181
- // session 级缓存行为(TC1-TC4,与 subagent 对称)
157
+ // session 级缓存行为(TC1-TC4,与 subagent 对称;mock pi/ctx 构造在
158
+ // helpers/injector-test-mocks.ts)
182
159
  // ──────────────────────────────────────────────────────────────
183
160
 
184
- /** before_agent_start handler 的返回结构(取 systemPrompt 断言)。 */
185
- interface BeforeAgentResult {
186
- systemPrompt: string;
187
- }
188
-
189
- /** 三 handler 捕获引用(setupWorkflowListInjector 注册后填充)。 */
190
- interface CapturedHandlers {
191
- sessionStart?: (event: unknown, ctx: unknown) => Promise<void> | void;
192
- beforeAgentStart?: (event: { systemPrompt: string }, ctx: unknown) => Promise<BeforeAgentResult | void> | BeforeAgentResult | void;
193
- sessionShutdown?: (event: unknown, ctx: unknown) => void;
194
- }
195
-
196
- /** 构造 mock pi:捕获三 handler 引用,其余 prop 走 noop(仅 on 被调用)。 */
197
- function createMockPi(handlers: CapturedHandlers): ExtensionAPI {
198
- const on = (event: string, handler: (...args: unknown[]) => unknown): void => {
199
- if (event === "session_start") {
200
- handlers.sessionStart = handler as CapturedHandlers["sessionStart"];
201
- } else if (event === "before_agent_start") {
202
- handlers.beforeAgentStart = handler as CapturedHandlers["beforeAgentStart"];
203
- } else if (event === "session_shutdown") {
204
- handlers.sessionShutdown = handler as CapturedHandlers["sessionShutdown"];
205
- }
206
- };
207
- const noop = (): void => {
208
- /* mock */
209
- };
210
- return { on, appendEntry: noop, registerTool: noop, registerCommand: noop, registerMessageRenderer: noop, events: { emit: noop, on: noop } } as unknown as ExtensionAPI;
211
- }
212
-
213
- /** 最小 ctx mock(注入器只读 ctx.cwd)。 */
214
- function createMockCtx(): Record<string, unknown> {
215
- return { cwd: "/ws", mode: "tui" };
216
- }
217
-
218
161
  /** fixture:单个 workflow 的 DiscoveredResource。 */
219
162
  function workflowResource(path: string): DiscoveredResource {
220
163
  return { path, source: "project-pi-tmp", available: true };
@@ -330,3 +273,50 @@ describe("workflow-list-injector session 级缓存", () => {
330
273
  expect(r?.systemPrompt).toContain("<name>chain</name>");
331
274
  });
332
275
  });
276
+
277
+ // ──────────────────────────────────────────────────────────────
278
+ // KV-cache 顺序契约:输出按 name 码点序,重建(两次发现)逐字节一致
279
+ // ──────────────────────────────────────────────────────────────
280
+
281
+ describe("discoverAllWorkflows 顺序契约(KV-cache)", () => {
282
+ it("输出按 name 码点序排序,与发现层返回顺序(readdir 枚举序)无关", async () => {
283
+ const byPath: Record<string, string> = {
284
+ "/ws/.pi/workflows/zeta.js": workflowJs("zeta", "z"),
285
+ "/ws/.pi/workflows/chain.js": workflowJs("chain", "c"),
286
+ "/ws/.pi/workflows/alpha.js": workflowJs("alpha", "a"),
287
+ };
288
+ spies.discoverResources.mockResolvedValue([
289
+ workflowResource("/ws/.pi/workflows/zeta.js"),
290
+ workflowResource("/ws/.pi/workflows/chain.js"),
291
+ workflowResource("/ws/.pi/workflows/alpha.js"),
292
+ ]);
293
+ spies.getCachedFileContent.mockImplementation((p: string) => byPath[p] ?? null);
294
+
295
+ const { discoverAllWorkflows } = await import("../workflow-list-injector");
296
+ const workflows = await discoverAllWorkflows("/ws", "/agent");
297
+ expect(workflows.map((w) => w.name)).toEqual(["alpha", "chain", "zeta"]);
298
+ });
299
+
300
+ it("重建(两次发现顺序不同)输出与渲染结果逐字节一致", async () => {
301
+ const byPath: Record<string, string> = {
302
+ "/ws/.pi/workflows/b.js": workflowJs("beta", "b"),
303
+ "/ws/.pi/workflows/a.js": workflowJs("alpha", "a"),
304
+ };
305
+ spies.discoverResources
306
+ .mockResolvedValueOnce([
307
+ workflowResource("/ws/.pi/workflows/b.js"),
308
+ workflowResource("/ws/.pi/workflows/a.js"),
309
+ ])
310
+ .mockResolvedValueOnce([
311
+ workflowResource("/ws/.pi/workflows/a.js"),
312
+ workflowResource("/ws/.pi/workflows/b.js"),
313
+ ]);
314
+ spies.getCachedFileContent.mockImplementation((p: string) => byPath[p] ?? null);
315
+
316
+ const { discoverAllWorkflows, formatWorkflowList } = await import("../workflow-list-injector");
317
+ const first = await discoverAllWorkflows("/ws", "/agent");
318
+ const second = await discoverAllWorkflows("/ws", "/agent");
319
+ expect(second).toEqual(first);
320
+ expect(formatWorkflowList(second)).toBe(formatWorkflowList(first));
321
+ });
322
+ });
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Model List Injector
3
+ *
4
+ * 通过 before_agent_start 每 turn 注入 `<available_provider_models>` 段,列出
5
+ * 当前 auth 可用的模型(provider/modelId + 能力 + contextWindow),与
6
+ * `<available_subagents>` / `<available_workflows>` 对称——三者合起来让模型掌握
7
+ * 派发所需的全部资源清单。
8
+ *
9
+ * 背景:模型列表的最大消费者是本包的 subagent/workflow `model` 参数(要求
10
+ * "provider/modelId" 格式,非法值直接 throw)。注入后模型可直接按 id 派发,
11
+ * 无需臆造模型名。
12
+ *
13
+ * 与另两个 injector 的差异:数据源不是文件发现而是 ModelRegistry.getAvailable()
14
+ * (pi 权威的 auth 可用模型快照,纯内存同步调用),因此:
15
+ * - 不需要 session_start 预热 / 渲染缓存 / session_shutdown 清理(无模块级
16
+ * 状态——结构上规避了缓存生命周期问题)
17
+ * - 每 turn 直接渲染;排序 (provider, id) 码点序保证输出字节稳定(turn 间
18
+ * systemPrompt 前缀稳定 = KV cache 友好;跨环境逐字节可复现,与另两个
19
+ * injector 的码点序契约对齐)。数据真实变化(用户中途配置了新 provider)
20
+ * 时下一 turn 自然反映。
21
+ *
22
+ * 立场:本注入段只服务「派发时选模型」,明确告知模型不要在会话中切换主模型
23
+ * (KV cache 不友好);用户明确要求换模型时走 pi 原生 /model 命令(人手动触发)。
24
+ */
25
+
26
+ import type {
27
+ Api,
28
+ Model,
29
+ } from "@earendil-works/pi-ai";
30
+ import type {
31
+ BeforeAgentStartEvent,
32
+ BeforeAgentStartEventResult,
33
+ ExtensionAPI,
34
+ ExtensionContext,
35
+ } from "@earendil-works/pi-coding-agent";
36
+ import { getLogger } from "@zhushanwen/pi-extension-logger";
37
+
38
+ import { escapeXml, renderXmlSection } from "../shared/xml-injection.ts";
39
+
40
+ const logger = getLogger("injector");
41
+
42
+ /** 注入段的最小模型投影(从 Model<Api> 收窄,测试无需构造完整 Model) */
43
+ export interface ModelEntry {
44
+ provider: string;
45
+ id: string;
46
+ name: string;
47
+ reasoning: boolean;
48
+ input: string[];
49
+ contextWindow: number;
50
+ }
51
+
52
+ /** Model<Api> → ModelEntry 投影(只留注入段消费的字段;模块内唯一消费方 setupModelListInjector) */
53
+ function toModelEntry(model: Model<Api>): ModelEntry {
54
+ return {
55
+ provider: model.provider,
56
+ id: model.id,
57
+ name: model.name,
58
+ reasoning: model.reasoning,
59
+ input: [...model.input],
60
+ contextWindow: model.contextWindow,
61
+ };
62
+ }
63
+
64
+ /** 码点序比较(显式契约,禁 localeCompare——宿主 locale 差异会破坏跨环境字节一致)。 */
65
+ function compareByCodepoint(a: string, b: string): number {
66
+ return a < b ? -1 : a > b ? 1 : 0;
67
+ }
68
+
69
+ /** 能力标记:reasoning → "reasoning",input 含 image → "vision"(空则省略 caps 段) */
70
+ function formatCaps(entry: ModelEntry): string {
71
+ const caps: string[] = [];
72
+ if (entry.reasoning) caps.push("reasoning");
73
+ if (entry.input.includes("image")) caps.push("vision");
74
+ return caps.join(",");
75
+ }
76
+
77
+ /**
78
+ * 将模型列表格式化为 XML 注入段。
79
+ *
80
+ * 输入按 (provider, id) 码点序排序——registry 返回顺序不作保证,排序后同一
81
+ * 数据集输出字节稳定。码点序是显式契约(禁 localeCompare——宿主 locale 差异
82
+ * 会破坏跨环境字节一致,见 subagent-list-injector.ts sortByCodepoint 注释),
83
+ * 保证注入段进每 turn system prompt 时跨环境逐字节可复现(cache-probe 前缀
84
+ * 指纹归因 / 换机器 resume 场景依赖此性质)。空列表返回空串(不注入)。
85
+ */
86
+ export function formatModelList(models: ModelEntry[]): string {
87
+ if (models.length === 0) return "";
88
+
89
+ const sorted = [...models].sort((a, b) =>
90
+ a.provider === b.provider
91
+ ? compareByCodepoint(a.id, b.id)
92
+ : compareByCodepoint(a.provider, b.provider),
93
+ );
94
+
95
+ const items = sorted.map((m) => {
96
+ const caps = formatCaps(m);
97
+ return (
98
+ ` <model><id>${escapeXml(`${m.provider}/${m.id}`)}</id>`
99
+ + `<name>${escapeXml(m.name)}</name>`
100
+ + (caps ? `<caps>${caps}</caps>` : "")
101
+ + `<contextWindow>${m.contextWindow}</contextWindow></model>`
102
+ );
103
+ });
104
+ return renderXmlSection({
105
+ tag: "available_provider_models",
106
+ guide: "The following models are available (auth-configured). Use these ids when delegating via the subagent/workflow `model` param (\"provider/modelId\" format) to match the task (e.g. vision models for screenshots, strong reasoners for architecture). Do NOT switch the main conversation model mid-session — per-call model override on delegates only (switching the main model is cache-hostile); use the /model command only when the user explicitly asks to change it.",
107
+ items,
108
+ });
109
+ }
110
+
111
+ /**
112
+ * 注册 before_agent_start handler,注入 `<available_provider_models>` 段。
113
+ *
114
+ * 每 turn 从 ctx.modelRegistry.getAvailable() 同步取快照渲染注入;空列表不
115
+ * 返回 systemPrompt;任何异常被吞掉(记日志),不阻断 agent turn。与 subagent/
116
+ * workflow 注入 handler 链式(pi 串联多 handler 的 systemPrompt 返回值)。
117
+ */
118
+ export function setupModelListInjector(pi: ExtensionAPI): void {
119
+ pi.on(
120
+ "before_agent_start",
121
+ async (
122
+ event: BeforeAgentStartEvent,
123
+ ctx: ExtensionContext,
124
+ ): Promise<BeforeAgentStartEventResult | void> => {
125
+ try {
126
+ const injection = formatModelList(
127
+ ctx.modelRegistry.getAvailable().map(toModelEntry),
128
+ );
129
+ if (!injection) return;
130
+ return { systemPrompt: event.systemPrompt + injection };
131
+ } catch (err) {
132
+ logger.error("[model-list-injector] before_agent_start failed", {
133
+ reason: err instanceof Error ? err.message : String(err),
134
+ });
135
+ }
136
+ },
137
+ );
138
+ }