@zhushanwen/pi-subagent-workflow 9.2.1 → 9.4.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,242 @@
1
+ /**
2
+ * /subagent-model 命令 handler 测试(subagent-model-switch §7.1.1,U6)。
3
+ *
4
+ * 覆盖(通道契约:全程不抛 + 一切结果落结果文件):
5
+ * - 合法 payload → service.setModel 桩 → 结果文件内容 = 应答(core SetModelReply 原样);
6
+ * - service.setModel 抛错 → 错误 envelope 文件且 handler 不抛;
7
+ * - 非法 JSON(无 requestId)→ 零文件 + handler 不抛(runtime 超时路径承接);
8
+ * - 字段校验失败(requestId 在场)→ invalid_payload envelope;
9
+ * - service 未就绪 → subagent_runtime_not_ready envelope;
10
+ * - 注册形态:命令名 / TUI 模式指引(内部 RPC 通道,无 TUI 分支)。
11
+ *
12
+ * mock 策略:runSubagentModelRpc 依赖注入面直驱(不 mock barrel);落盘全在 mkdtemp tmp。
13
+ * 运行:cd extensions/universal/subagent-workflow && npx vitest run src/interface/command/__tests__/subagent-model.test.ts
14
+ */
15
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+ import { join } from "node:path";
18
+
19
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
20
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
21
+
22
+ import { registerSubagentModelCommand, runSubagentModelRpc } from "../subagent-model.ts";
23
+ import type { SubagentModelCommandDeps } from "../subagent-model.ts";
24
+
25
+ // ── fixture ──────────────────────────────────────────────────
26
+
27
+ let root: string;
28
+
29
+ beforeEach(() => {
30
+ root = mkdtempSync(join(tmpdir(), "subagent-model-cmd-"));
31
+ });
32
+
33
+ afterEach(() => {
34
+ rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
35
+ });
36
+
37
+ interface StubService { // oe-exempt:20261006:test:测试专用服务桩装配单实现为常态
38
+ setModel: ReturnType<typeof vi.fn>;
39
+ chatActions: { getRecordForAction: ReturnType<typeof vi.fn> };
40
+ }
41
+
42
+ function stubService(): StubService {
43
+ return {
44
+ setModel: vi.fn(async () => ({
45
+ scope: "chat",
46
+ reply: { kind: "recorded", notice: "已记录,下次执行生效。" },
47
+ })),
48
+ chatActions: {
49
+ getRecordForAction: vi.fn(() => ({ id: "sa-1" }) as never),
50
+ },
51
+ };
52
+ }
53
+
54
+ function deps(service: StubService | null): { deps: SubagentModelCommandDeps; resultsDir: string } {
55
+ const resultsDir = join(root, "results");
56
+ return {
57
+ deps: {
58
+ getService: () => service as never,
59
+ resolveAgentDir: () => join(root, "agent"),
60
+ // rootCwd 作为 enc 编码键——结果目录 = <agentDir>/subagents/<enc(cwd)>/model-switch
61
+ resolveRootCwd: () => join(root, "proj"),
62
+ },
63
+ resultsDir,
64
+ };
65
+ }
66
+
67
+ function rpcCtx(mode: "rpc" | "tui" = "rpc"): { ctx: ExtensionCommandContext; notifies: Array<{ msg: string; level: string }> } {
68
+ const notifies: Array<{ msg: string; level: string }> = [];
69
+ const ctx = {
70
+ mode,
71
+ ui: { notify: (msg: string, level: string) => notifies.push({ msg, level }) },
72
+ cwd: join(root, "proj"),
73
+ };
74
+ return { ctx: ctx as never, notifies };
75
+ }
76
+
77
+ const VALID_ARGS = JSON.stringify({
78
+ requestId: "req-abc",
79
+ recordId: "sa-1",
80
+ provider: "zai-coding-cn",
81
+ modelId: "glm-5.3-flash",
82
+ thinkingLevel: "high",
83
+ });
84
+
85
+ function resultFile(requestId: string): string {
86
+ // enc 段镜像 core encodeCwd(布局单源在 path-encoding.ts,测试只按同式派生锚)。
87
+ const enc = "--" + join(root, "proj").replace(/^[/\\]/, "").replace(/[/\\:]/g, "-") + "--";
88
+ return join(root, "agent", "subagents", enc, "model-switch", `${requestId}.json`);
89
+ }
90
+
91
+ // ── 成功路 ───────────────────────────────────────────────────
92
+
93
+ describe("/subagent-model — 成功路(payload → setModel → 结果文件)", () => {
94
+ it("合法 payload:setModel 桩收到 target/model/thinkingLevel,结果文件 = 应答原样", async () => {
95
+ const service = stubService();
96
+ const { deps: d } = deps(service);
97
+ const { ctx } = rpcCtx();
98
+
99
+ await runSubagentModelRpc(d, VALID_ARGS, ctx);
100
+
101
+ expect(service.chatActions.getRecordForAction).toHaveBeenCalledWith("sa-1");
102
+ expect(service.setModel).toHaveBeenCalledWith(
103
+ { domain: "chat", record: { id: "sa-1" } },
104
+ { provider: "zai-coding-cn", modelId: "glm-5.3-flash" },
105
+ "high",
106
+ );
107
+ const raw = readFileSync(resultFile("req-abc"), "utf8");
108
+ expect(JSON.parse(raw)).toEqual({ scope: "chat", reply: { kind: "recorded", notice: "已记录,下次执行生效。" } });
109
+ });
110
+
111
+ it("runId 目标:workflow-run 域分流(不触 getRecordForAction)", async () => {
112
+ const service = stubService();
113
+ const { deps: d } = deps(service);
114
+ const { ctx } = rpcCtx();
115
+
116
+ const args = JSON.stringify({ requestId: "req-run", runId: "wf-1", provider: "p", modelId: "m" });
117
+ await runSubagentModelRpc(d, args, ctx);
118
+
119
+ expect(service.chatActions.getRecordForAction).not.toHaveBeenCalled();
120
+ expect(service.setModel).toHaveBeenCalledWith({ domain: "workflow-run", runId: "wf-1" }, { provider: "p", modelId: "m" }, undefined);
121
+ });
122
+
123
+ it("handler 不抛(即使 setModel 桩 resolve 失败也不向上传播)", async () => {
124
+ const service = stubService();
125
+ service.setModel.mockResolvedValue({ scope: "error", message: "目录无此模型(最接近的候选:X)" });
126
+ const { deps: d } = deps(service);
127
+ const { ctx } = rpcCtx();
128
+
129
+ await expect(runSubagentModelRpc(d, VALID_ARGS, ctx)).resolves.toBeUndefined();
130
+ const envelope = JSON.parse(readFileSync(resultFile("req-abc"), "utf8")) as { scope: string };
131
+ expect(envelope.scope).toBe("error");
132
+ });
133
+ });
134
+
135
+ // ── 失败路:envelope 且不抛 ──────────────────────────────────
136
+
137
+ describe("/subagent-model — 失败路(envelope 落盘 + 全程不抛)", () => {
138
+ it("service.setModel 抛错:错误 envelope 文件 + handler 不抛", async () => {
139
+ const service = stubService();
140
+ service.setModel.mockRejectedValue(Object.assign(new Error("thinking 档位 max 在模型 X 上不可用"), { code: "thinking_level_unavailable" }));
141
+ const { deps: d } = deps(service);
142
+ const { ctx } = rpcCtx();
143
+
144
+ await expect(runSubagentModelRpc(d, VALID_ARGS, ctx)).resolves.toBeUndefined();
145
+
146
+ const envelope = JSON.parse(readFileSync(resultFile("req-abc"), "utf8")) as {
147
+ error: { code: string; message: string; recovery: string };
148
+ };
149
+ expect(envelope.error.code).toBe("thinking_level_unavailable");
150
+ expect(envelope.error.message).toContain("不可用");
151
+ expect(envelope.error.recovery).toContain("重试切换");
152
+ });
153
+
154
+ it("getRecordForAction 抛错(目标不存在):envelope + handler 不抛", async () => {
155
+ const service = stubService();
156
+ service.chatActions.getRecordForAction.mockImplementation(() => {
157
+ throw new Error("record sa-ghost not found");
158
+ });
159
+ const { deps: d } = deps(service);
160
+ const { ctx } = rpcCtx();
161
+
162
+ await expect(runSubagentModelRpc(d, VALID_ARGS, ctx)).resolves.toBeUndefined();
163
+
164
+ const envelope = JSON.parse(readFileSync(resultFile("req-abc"), "utf8")) as { error: { code: string } };
165
+ expect(envelope.error.code).toBe("subagent_model_switch_failed");
166
+ });
167
+
168
+ it("非法 JSON(无 requestId):零文件 + handler 不抛 + warning notify", async () => {
169
+ const { deps: d } = deps(stubService());
170
+ const { ctx, notifies } = rpcCtx();
171
+
172
+ await expect(runSubagentModelRpc(d, "not-json{", ctx)).resolves.toBeUndefined();
173
+ expect(existsSync(resultFile("req-abc"))).toBe(false);
174
+ expect(notifies[0]?.level).toBe("warning");
175
+ });
176
+
177
+ it("字段校验失败(缺 provider):invalid_payload envelope", async () => {
178
+ const { deps: d } = deps(stubService());
179
+ const { ctx } = rpcCtx();
180
+
181
+ const args = JSON.stringify({ requestId: "req-bad", recordId: "sa-1", modelId: "m" });
182
+ await runSubagentModelRpc(d, args, ctx);
183
+
184
+ const envelope = JSON.parse(readFileSync(resultFile("req-bad"), "utf8")) as {
185
+ error: { code: string; message: string };
186
+ };
187
+ expect(envelope.error.code).toBe("invalid_payload");
188
+ expect(envelope.error.message).toContain("provider");
189
+ });
190
+
191
+ it("recordId 与 runId 双给:invalid_payload envelope(wire 契约二选一)", async () => {
192
+ const { deps: d } = deps(stubService());
193
+ const { ctx } = rpcCtx();
194
+
195
+ const args = JSON.stringify({ requestId: "req-both", recordId: "sa-1", runId: "wf-1", provider: "p", modelId: "m" });
196
+ await runSubagentModelRpc(d, args, ctx);
197
+
198
+ const envelope = JSON.parse(readFileSync(resultFile("req-both"), "utf8")) as { error: { code: string } };
199
+ expect(envelope.error.code).toBe("invalid_payload");
200
+ });
201
+
202
+ it("service 未就绪(session_start 前):subagent_runtime_not_ready envelope", async () => {
203
+ const { deps: d } = deps(null);
204
+ const { ctx } = rpcCtx();
205
+
206
+ await runSubagentModelRpc(d, VALID_ARGS, ctx);
207
+
208
+ const envelope = JSON.parse(readFileSync(resultFile("req-abc"), "utf8")) as { error: { code: string } };
209
+ expect(envelope.error.code).toBe("subagent_runtime_not_ready");
210
+ });
211
+ });
212
+
213
+ // ── 注册形态 ─────────────────────────────────────────────────
214
+
215
+ describe("registerSubagentModelCommand — 注册形态", () => {
216
+ interface CapturedCommand { // oe-exempt:20261006:test:测试专用命令捕获结构单实现为常态(同 commands-resume.test.ts 先例)
217
+ name: string;
218
+ description: string;
219
+ handler: (args: string, ctx: ExtensionCommandContext) => Promise<void>;
220
+ }
221
+
222
+ function captureCommand(): CapturedCommand {
223
+ const commands: CapturedCommand[] = [];
224
+ const api = {
225
+ registerCommand: (name: string, def: Omit<CapturedCommand, "name">) => {
226
+ commands.push({ name, ...def });
227
+ },
228
+ };
229
+ registerSubagentModelCommand(api as never);
230
+ expect(commands).toHaveLength(1);
231
+ return commands[0]!;
232
+ }
233
+
234
+ it("命令名 = subagent-model;TUI 模式 = 指引提示(内部 RPC 通道无 TUI 分支)", async () => {
235
+ const cmd = captureCommand();
236
+ expect(cmd.name).toBe("subagent-model");
237
+ const { ctx, notifies } = rpcCtx("tui");
238
+ await cmd.handler(VALID_ARGS, ctx);
239
+ expect(notifies[0]?.level).toBe("warning");
240
+ expect(notifies[0]?.msg).toContain("internal taiji GUI channel");
241
+ });
242
+ });
@@ -22,7 +22,7 @@ export type SubagentRpcAction =
22
22
  /** /workflows RPC action 判别联合。 */
23
23
  export type WorkflowRpcAction =
24
24
  | { action: "abort"; runId: string }
25
- | { action: "resume"; runId: string }
25
+ | { action: "resume"; runId: string; model?: string }
26
26
  | { action: "lifecycle-missing-id"; verb: "abort" | "resume" }
27
27
  | { action: "lifecycle-removed"; verb: "pause" }
28
28
  | { action: "noop" };
@@ -128,8 +128,9 @@ export function parseSubagentRpcCommand(argsStr: string): SubagentRpcAction {
128
128
  * 支持格式:
129
129
  * - `abort <runId>` → { action: "abort", runId }
130
130
  * - `abort`(无 runId)→ { action: "lifecycle-missing-id", verb: "abort" }
131
- * - `resume <runId>` → { action: "resume", runId }(断点续跑——与 TUI verb、
132
- * workflow tool action:"resume" 三通道同语义)
131
+ * - `resume <runId> [model]` → { action: "resume", runId, model? }(断点续跑——与
132
+ * TUI verb、workflow tool action:"resume" 三通道同语义;model 可选 canonical ref
133
+ * 'provider/modelId[:thinkingLevel]',落统一覆盖记账——F1-26 后续项)
133
134
  * - `resume`(无 runId)→ { action: "lifecycle-missing-id", verb: "resume" }
134
135
  * - `pause ...`(已移除的 lifecycle verb,带或不带 runId 均同)→
135
136
  * { action: "lifecycle-removed", verb: "pause" }——removed verb 优先于 missing-id 判定
@@ -140,7 +141,7 @@ export function parseWorkflowRpcCommand(argsStr: string): WorkflowRpcAction {
140
141
  const args = argsStr.trim().split(/\s+/).filter(Boolean);
141
142
  if (args.length === 0) return { action: "noop" };
142
143
 
143
- const [verb, runId] = args;
144
+ const [verb, runId, model] = args;
144
145
  if (isRemovedLifecycleVerb(verb)) {
145
146
  return { action: "lifecycle-removed", verb };
146
147
  }
@@ -149,7 +150,7 @@ export function parseWorkflowRpcCommand(argsStr: string): WorkflowRpcAction {
149
150
  if (!runId) return { action: "lifecycle-missing-id", verb };
150
151
  return verb === "abort"
151
152
  ? { action: "abort", runId }
152
- : { action: "resume", runId };
153
+ : { action: "resume", runId, ...(model !== undefined ? { model } : {}) };
153
154
  }
154
155
  return { action: "noop" };
155
156
  }
@@ -24,7 +24,13 @@ import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@earendil-wor
24
24
  import { getLogger } from "@zhushanwen/pi-extension-logger";
25
25
 
26
26
  import type { LauncherDeps } from "@zhushanwen/subagent-core";
27
- import { abortRun, getSubagentService, resumeRun } from "@zhushanwen/subagent-core";
27
+ import {
28
+ abortRun,
29
+ assertModelInCatalog,
30
+ getModelConfigService,
31
+ getSubagentService,
32
+ resumeRun,
33
+ } from "@zhushanwen/subagent-core";
28
34
  import type { WorkflowRun } from "@zhushanwen/subagent-core";
29
35
  import { parseWorkflowRpcCommand, type WorkflowRpcAction } from "./command-actions.ts";
30
36
  import { createWorkflowsView, type ViewActions } from "../tui/views/WorkflowsView.ts";
@@ -130,13 +136,17 @@ export function registerWorkflowsCommand(
130
136
  if (verb === "resume") {
131
137
  const runId = rest[0];
132
138
  if (!runId) {
133
- ctx.ui.notify("Usage: /workflows resume <runId>", "warning");
139
+ ctx.ui.notify("Usage: /workflows resume <runId> [model]", "warning");
134
140
  return;
135
141
  }
142
+ // 可选第二参数 = 显式模型 canonical ref('provider/modelId[:thinkingLevel]')——
143
+ // 落统一覆盖记账(F1-26 后续项:与 setModel 补切同通道,resume 合一步表达)。
144
+ const model = rest[1];
136
145
  try {
137
- await resumeRun(runId, deps);
146
+ if (model !== undefined) assertResumeModelInCatalog(model);
147
+ await resumeRun(runId, deps, ...(model !== undefined ? [{ model }] : []));
138
148
  ctx.ui.notify(
139
- `Workflow ${runId}: resuming — completed calls replay at zero token cost, unfinished calls re-dispatched`,
149
+ `Workflow ${runId}: resuming${model !== undefined ? ` with model ${model}` : ""} — completed calls replay at zero token cost, unfinished calls re-dispatched`,
140
150
  "info",
141
151
  );
142
152
  } catch (err) {
@@ -192,9 +202,10 @@ async function handleRpcMode(
192
202
  }
193
203
  case "resume": {
194
204
  try {
195
- await resumeRun(parsed.runId, deps);
205
+ if (parsed.model !== undefined) assertResumeModelInCatalog(parsed.model);
206
+ await resumeRun(parsed.runId, deps, ...(parsed.model !== undefined ? [{ model: parsed.model }] : []));
196
207
  ctx.ui.notify(
197
- `Workflow ${parsed.runId}: resuming — completed calls replay at zero token cost, unfinished calls re-dispatched`,
208
+ `Workflow ${parsed.runId}: resuming${parsed.model !== undefined ? ` with model ${parsed.model}` : ""} — completed calls replay at zero token cost, unfinished calls re-dispatched`,
198
209
  "info",
199
210
  );
200
211
  } catch (err) {
@@ -210,7 +221,14 @@ async function handleRpcMode(
210
221
  );
211
222
  return;
212
223
  case "lifecycle-missing-id":
213
- ctx.ui.notify(`Usage: /workflows ${parsed.verb} <runId>`, "warning");
224
+ // Usage 按 verb 区分(与 TUI verb 分支同文案——两模式统一):resume 的可选
225
+ // [model] 参数要出现在提示里,GUI 侧缺 runId 时得到的指引不指向旧用法。
226
+ ctx.ui.notify(
227
+ parsed.verb === "resume"
228
+ ? "Usage: /workflows resume <runId> [model]"
229
+ : `Usage: /workflows ${parsed.verb} <runId>`,
230
+ "warning",
231
+ );
214
232
  return;
215
233
  case "noop":
216
234
  ctx.ui.notify("View workflows in the composer task tray", "info");
@@ -223,6 +241,28 @@ async function handleRpcMode(
223
241
  }
224
242
  }
225
243
 
244
+ /**
245
+ * 命令通道显式 model 的目录预检(D8 拒单语义,与 tool 通道 run/resume action 同款
246
+ * ——isModelRef 语法闸在 core resumeRun,此处补目录存在性:语法合法但目录查无
247
+ * (如 provider 段拼写错)在写覆盖记账之前同步拒绝,不延迟到派发期,避免「记账已
248
+ * 落盘 + 命令已 notify 成功」的假成功形态;三通道(TUI verb / RPC verb / tool)对称)。
249
+ * throw 落调用方 try/catch,分类化错误文案(含可用模型清单 + 修复指引)经 notify
250
+ * 直达用户。单例缺席(session_start 前不可达)降级跳过 + warn 留痕——派发期
251
+ * identity 解析仍是权威裁决(与 tool 通道降级语义一致)。
252
+ */
253
+ function assertResumeModelInCatalog(model: string): void {
254
+ const modelService = getModelConfigService();
255
+ if (modelService === null) {
256
+ getLogger("subagent-workflow").warn(
257
+ "[commands] model catalog unavailable (model service not initialized) — resume model catalog check skipped; dispatch-time identity resolution remains authoritative",
258
+ );
259
+ return;
260
+ }
261
+ assertModelInCatalog(model, modelService.getModelRegistry(), {
262
+ source: "resume model override",
263
+ });
264
+ }
265
+
226
266
  /**
227
267
  * TUI 模式:按 runId 直接打开对应 run 的 view(精确匹配优先,前缀唯一匹配次之)。
228
268
  * 均未命中 → error notify。
@@ -0,0 +1,268 @@
1
+ // src/interface/command/subagent-model.ts
2
+ //
3
+ // /subagent-model 命令——subagent/workflow run 执行模型切换的宿主触达通道消费端
4
+ // (subagent-model-switch §7.1.1 通道,U6)。内部 RPC 命令:runtime 网关适配器经
5
+ // client.prompt("/subagent-model <单行JSON>") 短路本 handler(不经主 agent LLM,
6
+ // pi _tryExecuteExtensionCommand 主路径对 / 前缀先行执行)。
7
+ //
8
+ // 通道契约(§7.1.1 四要素):
9
+ // - 载荷 = 单行 JSON {requestId, recordId?|runId?, provider, modelId, thinkingLevel?};
10
+ // - 执行 = 本进程直调宿主 setModel 编排(service.setModel,§7.2 单一三步);
11
+ // - 回执 = 一切结果(SetModelReply 三 scope / 错误 envelope)写结果文件
12
+ // `<subagent 数据根>/model-switch/<requestId>.json` 后正常返回——**全程不抛**;
13
+ // runtime 出站点在 handler 返回后读文件一次(写后读,无轮询无监视)。
14
+ // - 结果文件承载 core SetModelReply 形状(scope 判别)——extension 不依赖
15
+ // @taiji/shared,wire 三形态映射归 runtime 适配器(依赖方向的唯一合法位置)。
16
+ //
17
+ // TUI 分支不存在:本命令是 GUI 通道专用内部命令,非交互入口(照 /subagents RPC
18
+ // verb 的 GUI 屏蔽先例,TUI 手敲仅得指引提示)。
19
+
20
+ import * as path from "node:path";
21
+
22
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
23
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
24
+
25
+ import {
26
+ ENV_ROOT_CWD,
27
+ getSubagentModelSwitchResultsDir,
28
+ getSubagentService,
29
+ writeAtomicFileSync,
30
+ } from "@zhushanwen/subagent-core";
31
+ import type { ModelSwitchTarget, SetModelReply } from "@zhushanwen/subagent-core";
32
+ import { getLogger } from "@zhushanwen/pi-extension-logger";
33
+ import { toErrorMessage } from "@zhushanwen/pi-ext-guards";
34
+
35
+ const logger = getLogger("subagents");
36
+
37
+ /**
38
+ * requestId 文件名白名单:`[\w-]{1,128}`(与 core record id 白名单同式——防路径穿越;
39
+ * 生产源 = runtime randomUUID,字符集 ⊆ 白名单,本式仅防御畸形通道输入)。
40
+ */
41
+ const REQUEST_ID_PATTERN = /^[\w-]{1,128}$/;
42
+
43
+ /** 错误 envelope 形状(结果文件内的失败承载;code 值域 = 本文件错误分型常量族)。
44
+ * scope: "error" = 网关 mapResultFileToWireReply 的分派判别键(D3-A4 缺陷修复:
45
+ * 缺 scope 时域内失败被「scope 未知」通道错误吞掉,真实 code/recovery 丢失)。 */
46
+ interface SubagentModelEnvelope { // oe-exempt:20261006:framework:通道结果信封契约形状(单命令单载体,单实现常态)
47
+ scope: "error";
48
+ error: { code: string; message: string; recovery: string };
49
+ }
50
+
51
+ /** /subagent-model 命令的依赖注入面(注册点装配生产实现,单测注入桩)。 */
52
+ export interface SubagentModelCommandDeps { // oe-exempt:20261006:framework:命令 handler 依赖注入面(注册点装配,单实现常态)
53
+ /** 宿主 setModel 编排单例(session_start 前 null——envelope 分型承接)。 */
54
+ getService: () => ReturnType<typeof getSubagentService>;
55
+ /** agentDir 锚(pi SDK 活源,与 session-lifecycle 装配同源)。 */
56
+ resolveAgentDir: () => string;
57
+ /** 树根主 cwd(session-baselines rootCwd 同式推导,见 registerSubagentModelCommand)。 */
58
+ resolveRootCwd: () => string;
59
+ }
60
+
61
+ /** 载荷解析三态(显式判别:无 requestId = 无法命名结果文件,与字段校验失败分流)。 */
62
+ type ParsedPayload =
63
+ | { ok: true; requestId: string; payload: SubagentModelPayload }
64
+ | { ok: false; requestIdMissing: true }
65
+ | { ok: false; requestIdMissing: false; requestId: string; validationMessage: string };
66
+
67
+ /** 载荷结构(wire 契约 §7.1.1:recordId/runId 二选一,provider/modelId 必填)。
68
+ * 字段经逐项 typeof 抽取组装(JSON 边界不落地 unknown 断言)。 */
69
+ interface SubagentModelPayload { // oe-exempt:20261006:framework:通道载荷契约形状(单命令单载体,单实现常态)
70
+ requestId: string;
71
+ recordId?: string;
72
+ runId?: string;
73
+ provider?: string;
74
+ modelId?: string;
75
+ thinkingLevel?: string;
76
+ }
77
+
78
+ /** 非空字符串字段抽取(undefined = 缺失或类型不符,二者同判缺失)。 */
79
+ function nonEmptyString(v: unknown): string | undefined {
80
+ return typeof v === "string" && v !== "" ? v : undefined;
81
+ }
82
+
83
+ /**
84
+ * 解析单行 JSON 载荷(§7.1.1 要素 1)。requestId 缺失/非法 → requestIdMissing(无法
85
+ * 命名结果文件,本侧零回执——runtime 出站点超时路径承接通道失败,仅日志留痕);
86
+ * 其余字段校验失败在 requestId 在场时落 envelope(invalid_payload 分型)。
87
+ */
88
+ function parsePayload(argsStr: string): ParsedPayload {
89
+ let raw: unknown;
90
+ try {
91
+ raw = JSON.parse(argsStr.trim());
92
+ } catch {
93
+ return { ok: false, requestIdMissing: true };
94
+ }
95
+ if (typeof raw !== "object" || raw === null) return { ok: false, requestIdMissing: true };
96
+ const obj = raw as Record<string, unknown>;
97
+ const requestId = nonEmptyString(obj.requestId);
98
+ if (requestId === undefined || !REQUEST_ID_PATTERN.test(requestId)) {
99
+ return { ok: false, requestIdMissing: true };
100
+ }
101
+ const payload: SubagentModelPayload = {
102
+ requestId,
103
+ recordId: nonEmptyString(obj.recordId),
104
+ runId: nonEmptyString(obj.runId),
105
+ provider: nonEmptyString(obj.provider),
106
+ modelId: nonEmptyString(obj.modelId),
107
+ thinkingLevel: nonEmptyString(obj.thinkingLevel),
108
+ };
109
+ const validationMessage = validatePayload(payload);
110
+ if (validationMessage !== undefined) {
111
+ return { ok: false, requestIdMissing: false, requestId, validationMessage };
112
+ }
113
+ return { ok: true, requestId, payload };
114
+ }
115
+
116
+ /** 载荷字段校验(requestId 之外的域内畸形 → envelope invalid_payload 的 message 文案)。 */
117
+ function validatePayload(payload: SubagentModelPayload): string | undefined {
118
+ if ((payload.recordId !== undefined) === (payload.runId !== undefined)) {
119
+ return "payload 需要 recordId(chat 域)或 runId(workflow run 级)二选一";
120
+ }
121
+ if (payload.provider === undefined) {
122
+ return "payload 缺 provider(canonical ref 的 provider 段)";
123
+ }
124
+ if (payload.modelId === undefined) {
125
+ return "payload 缺 modelId(canonical ref 的 modelId 段)";
126
+ }
127
+ return undefined;
128
+ }
129
+
130
+ /**
131
+ * 结果文件写入(唯一出站原语,永不抛):writeAtomicFileSync(tmp+rename,防 runtime
132
+ * 读到半文件)+ 目录递归建。写失败 = 通道回执丢失(runtime 超时路径承接),日志留痕。
133
+ */
134
+ function writeResultFile(resultsFile: string, content: string): void {
135
+ try {
136
+ writeAtomicFileSync(resultsFile, content);
137
+ } catch (err) {
138
+ logger.warn(`[subagent-model] result file write failed (runtime will report channel timeout): ${toErrorMessage(err)}`, {
139
+ resultsFile,
140
+ });
141
+ }
142
+ }
143
+
144
+ /** 提取引擎分型错误码(EngineError 载荷 .code——分型值域校验交由 runtime 侧)。 */
145
+ function errorCodeOf(err: unknown): string | undefined {
146
+ // `in` 收窄(TS 4.9+):Error & Record<"code", unknown>,零断言读载荷。
147
+ if (!(err instanceof Error) || !("code" in err)) return undefined;
148
+ const code: unknown = err.code;
149
+ return typeof code === "string" && code !== "" ? code : undefined;
150
+ }
151
+
152
+ /**
153
+ * RPC 执行体(§7.1.1 要素 2):payload 校验 → 宿主 setModel → 结果文件。
154
+ * **全程不抛**——一切异常 catch 后落错误 envelope;校验失败同落 envelope。
155
+ */
156
+ export async function runSubagentModelRpc(
157
+ deps: SubagentModelCommandDeps,
158
+ argsStr: string,
159
+ ctx: ExtensionCommandContext,
160
+ ): Promise<void> {
161
+ const parsed = parsePayload(argsStr);
162
+ if (!parsed.ok) {
163
+ if (parsed.requestIdMissing) {
164
+ // requestId 不可用 = 无关联回执面:日志留痕即可,runtime 超时按通道失败应答
165
+ //(结果文件名 = requestId,载荷缺失时本侧无从落文件)。
166
+ logger.warn("[subagent-model] payload unparseable or requestId missing — no result file, runtime timeout path will report");
167
+ ctx.ui.notify("/subagent-model payload requires requestId — request dropped", "warning");
168
+ return;
169
+ }
170
+ // requestId 在场 + 字段校验失败:envelope 落盘(invalid_payload 分型)。
171
+ const invalidFile = path.join(
172
+ getSubagentModelSwitchResultsDir(deps.resolveAgentDir(), deps.resolveRootCwd()),
173
+ `${parsed.requestId}.json`,
174
+ );
175
+ writeResultFile(
176
+ invalidFile,
177
+ JSON.stringify({
178
+ scope: "error",
179
+ error: {
180
+ code: "invalid_payload",
181
+ message: parsed.validationMessage,
182
+ recovery: "修正 subagent.setModel 载荷后重试(runtime 协议面问题,正常使用不可达)",
183
+ },
184
+ } satisfies SubagentModelEnvelope),
185
+ );
186
+ return;
187
+ }
188
+ const { requestId, payload } = parsed;
189
+ const resultsFile = path.join(
190
+ getSubagentModelSwitchResultsDir(deps.resolveAgentDir(), deps.resolveRootCwd()),
191
+ `${requestId}.json`,
192
+ );
193
+
194
+ try {
195
+ const service = deps.getService();
196
+ if (!service) {
197
+ writeResultFile(
198
+ resultsFile,
199
+ JSON.stringify({
200
+ scope: "error",
201
+ error: {
202
+ code: "subagent_runtime_not_ready",
203
+ message: "subagent 执行运行时未就绪(session 未启动或已 dispose)",
204
+ recovery: "先在该会话派发一条消息启动 subagent 运行时,再重试切换",
205
+ },
206
+ } satisfies SubagentModelEnvelope),
207
+ );
208
+ return;
209
+ }
210
+
211
+ // 目标组装(§6.1 作用域分流):recordId → chat 域(getRecordForAction 同 message/close
212
+ // 的统一入口——含归属校验与冷查);runId → run 级(终局/成员清单校验在编排步骤①)。
213
+ let target: ModelSwitchTarget;
214
+ if (typeof payload.recordId === "string") {
215
+ target = { domain: "chat", record: service.chatActions.getRecordForAction(payload.recordId) };
216
+ } else {
217
+ target = { domain: "workflow-run", runId: payload.runId as string };
218
+ }
219
+
220
+ const thinkingLevel = payload.thinkingLevel as string | undefined;
221
+ const reply: SetModelReply = await service.setModel(
222
+ target,
223
+ { provider: payload.provider as string, modelId: payload.modelId as string },
224
+ thinkingLevel,
225
+ );
226
+ // 应答三 scope 原样落文件(scope 判别由 runtime 适配器消费——core 形状是唯一权威)。
227
+ writeResultFile(resultsFile, JSON.stringify(reply));
228
+ } catch (err) {
229
+ // §7.5 通道行同族语义:handler 域内失败如实落 envelope(生效状态未知不虚构——
230
+ // 消息携带宿主编排错误原文,恢复动作照通道行)。
231
+ writeResultFile(
232
+ resultsFile,
233
+ JSON.stringify({
234
+ scope: "error",
235
+ error: {
236
+ code: errorCodeOf(err) ?? "subagent_model_switch_failed",
237
+ message: toErrorMessage(err),
238
+ recovery: "重试切换(覆盖替换幂等);当前生效状态以面板与记录链为准",
239
+ },
240
+ } satisfies SubagentModelEnvelope),
241
+ );
242
+ }
243
+ }
244
+
245
+ /** 注册 /subagent-model 命令(内部 RPC 通道,taiji GUI 专用)。 */
246
+ export function registerSubagentModelCommand(pi: ExtensionAPI): void {
247
+ pi.registerCommand("subagent-model", {
248
+ description: "Internal RPC: switch subagent/workflow-run model (single-line JSON payload)",
249
+ handler: async (argsStr: string, ctx: ExtensionCommandContext) => {
250
+ if (ctx.mode !== "rpc") {
251
+ ctx.ui.notify("/subagent-model is an internal taiji GUI channel — switch models from the subagent panel", "warning");
252
+ return;
253
+ }
254
+ // rootCwd 与 session-baselines 构造推导同式(env PI_SUBAGENT_ROOT_CWD 贯穿 ||
255
+ // ctx.cwd)——保证结果目录与 records 落同一 enc 段(enc 段不变量,path-encoding
256
+ // 头注同族约束)。本命令只在主进程执行(子进程无 / 命令通道),env 恒缺省。
257
+ await runSubagentModelRpc(
258
+ {
259
+ getService: getSubagentService,
260
+ resolveAgentDir: getAgentDir,
261
+ resolveRootCwd: () => process.env[ENV_ROOT_CWD] || ctx.cwd,
262
+ },
263
+ argsStr,
264
+ ctx,
265
+ );
266
+ },
267
+ });
268
+ }
@@ -36,8 +36,8 @@ export interface SubagentDirectiveDetails {
36
36
  /**
37
37
  * 定向消息留痕:向主 session 落 subagent-directive custom_message entry。
38
38
  *
39
- * 按主 agent streaming 状态分流 sendMessage options。pi 0.84.4 sendCustomMessage
40
- * 实装(agent-session.js):isStreaming 且无 deliverAs 时默认 agent.steer()——会把
39
+ * 按主 agent streaming 状态分流 sendMessage options。pi sendCustomMessage
40
+ * 实装(agent-session.js,1.0.0 复核同构):isStreaming 且无 deliverAs 时默认 agent.steer()——会把
41
41
  * 定向消息注入正在运行的主 agent LLM turn,违反「不经主 agent LLM 直达 subagent」。
42
42
  * 故按调用时刻的权威 streaming 状态(ctx.isIdle(),与 sendCustomMessage 内部
43
43
  * isStreaming 判据精确互补,含 agent_end 后 retry/continuation 窗口)分流: