@zhushanwen/pi-subagent-workflow 9.3.0 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-subagent-workflow",
3
- "version": "9.3.0",
3
+ "version": "9.4.0",
4
4
  "type": "module",
5
5
  "main": "index.ts",
6
6
  "description": "Unified subagent execution and multi-agent workflow orchestration for Pi — spawned-process agent runtime with sync/background modes, stateful workflow management with persistence, state machine, and execution tracing.",
@@ -58,22 +58,22 @@
58
58
  ]
59
59
  },
60
60
  "dependencies": {
61
- "@zhushanwen/extension-protocol": "0.18.0",
61
+ "@zhushanwen/extension-protocol": "0.19.0",
62
62
  "@zhushanwen/session-delivery": "1.1.0",
63
63
  "@zhushanwen/pi-ext-guards": "0.4.3",
64
64
  "@zhushanwen/pi-extension-logger": "0.6.2",
65
- "@zhushanwen/pi-notify-ledger-host": "0.3.0",
66
- "@zhushanwen/subagent-core": "1.3.0",
67
- "@zhushanwen/pi-subagent-cli": "0.6.0",
68
- "@zhushanwen/zcode-subagent-cli": "0.5.0"
65
+ "@zhushanwen/pi-notify-ledger-host": "0.3.1",
66
+ "@zhushanwen/pi-subagent-cli": "0.7.0",
67
+ "@zhushanwen/zcode-subagent-cli": "0.6.0",
68
+ "@zhushanwen/subagent-core": "1.4.0"
69
69
  },
70
70
  "peerDependencies": {
71
71
  "@earendil-works/pi-ai": "^1.0.0",
72
72
  "@earendil-works/pi-coding-agent": "^1.0.0",
73
73
  "@earendil-works/pi-tui": "^1.0.0",
74
74
  "typebox": "1.3.27",
75
- "@zhushanwen/pi-pending-notifications": "0.8.2",
76
- "@zhushanwen/pi-structured-output": "6.1.0"
75
+ "@zhushanwen/pi-structured-output": "6.1.0",
76
+ "@zhushanwen/pi-pending-notifications": "0.8.3"
77
77
  },
78
78
  "peerDependenciesMeta": {
79
79
  "@earendil-works/pi-coding-agent": {
@@ -87,7 +87,7 @@
87
87
  "@types/node": "^24.0.0",
88
88
  "@vitest/coverage-v8": "^4.1.9",
89
89
  "vitest": "^4.1.8",
90
- "@zhushanwen/subagent-engine-sdk": "0.9.0"
90
+ "@zhushanwen/subagent-engine-sdk": "0.10.0"
91
91
  },
92
92
  "scripts": {
93
93
  "typecheck": "npx tsc --noEmit",
package/src/index.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * subagent-workflow Extension — Factory(extension 装配点)
3
3
  *
4
4
  * 合并 @zhushanwen/pi-subagents + @zhushanwen/pi-workflow 为统一包。
5
- * 注册项:4 tool(subagent + subagents + workflow + workflow-script)+ 2 command(subagents + workflows)
5
+ * 注册项:4 tool(subagent + subagents + workflow + workflow-script)+ 3 command(subagents + workflows + subagent-model)
6
6
  * + messageRenderer(subagent-bg-notify)+ session 事件。
7
7
  *
8
8
  * 包内结构(执行运行时已迁 packages/subagent-core,本包只留注册面与宿主适配):
@@ -52,6 +52,8 @@ import { registerWorkflowsCommand } from "./interface/command/commands.ts";
52
52
  import { registerSubagentTool } from "./interface/tool/subagent-tool.ts";
53
53
  // ═══ interface/ 层(tools/commands/tui 合并) ═══
54
54
  import { registerSubagentsCommand } from "./interface/command/subagents.ts";
55
+ // [subagent-model-switch U6] 模型切换 RPC 通道消费端(§7.1.1,runtime 出站点 prompt 触达)
56
+ import { registerSubagentModelCommand } from "./interface/command/subagent-model.ts";
55
57
  import { registerSubagentsTool } from "./interface/tool/tool-subagents.ts";
56
58
  import { registerWorkflowTool } from "./interface/tool/tool-workflow.ts";
57
59
  import { registerWorkflowScriptTool } from "./interface/tool/tool-workflow-script.ts";
@@ -143,6 +145,7 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
143
145
  // ════════════════════════════════════════════════════════════
144
146
  registerSubagentTool(pi);
145
147
  registerSubagentsCommand(pi);
148
+ registerSubagentModelCommand(pi);
146
149
  pi.registerMessageRenderer(SUBAGENT_BG_NOTIFY_CUSTOM_TYPE, renderBgNotifyMessage);
147
150
 
148
151
  // ════════════════════════════════════════════════════════════
@@ -226,7 +229,7 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
226
229
  process.on("beforeExit", reapSpawnedChildrenOnShutdown);
227
230
 
228
231
  // ════════════════════════════════════════════════════════════
229
- // Tools(3 个)+ Commands(2 个)—— 注册面
232
+ // Tools(3 个)+ Commands(3 个)—— 注册面
230
233
  //
231
234
  // lazyDeps / isScriptRunning / registry 由 workflow-events.ts 装配结果提供。
232
235
  // guard:workflow tool 与 subagents(批量派发入口)共用同一 guard——两者是同一条
@@ -1,13 +1,13 @@
1
1
  /**
2
- * /workflows 命令的 resume 通道契约(workflow-run-resume-revision U3)。
2
+ * /workflows 命令的 resume 通道契约(workflow-run-resume-revision U3 + F1-26 后续项)。
3
3
  *
4
- * 覆盖面(TUI 模式):/workflows resume <runId> → 执行断点续跑后 notify,不打开
5
- * 面板;缺 runId → Usage 提示;resumeRun 拒绝 → warning notify 透出恢复指引文案。
4
+ * 覆盖面(TUI 模式):/workflows resume <runId> [model] → 执行断点续跑后 notify,
5
+ * 不打开面板;缺 runId → Usage 提示(含可选 [model]);resumeRun 拒绝 → warning
6
+ * notify 透出恢复指引文案;显式 model 的 handler 透传(第三参 { model } / 无 model
7
+ * 不落键)与目录预检拒单(与 tool 通道同款 D8 语义)。
6
8
  *
7
- * RPC 模式(taiji GUI)的 resume verb 接线未随 U3 落地:解析词表在
8
- * command-actions.ts(领地外,resume verb 现仍解析为 lifecycle-removed),其
9
- * dispatch 断言由 src/__tests__/rpc-command-handling.test.ts 原样锁定——词表
10
- * 适配归编排层(见 commands.ts handleRpcMode 注释)。workflow tool 通道
9
+ * RPC 模式(taiji GUI)的 resume verb 接线与 model 透传由
10
+ * src/__tests__/rpc-command-handling.test.ts 覆盖;workflow tool 通道
11
11
  * (action:"resume",含 D14 args 校验)由 tool-workflow-resume.test.ts 覆盖。
12
12
  *
13
13
  * mock 策略:resume-run 深路径 stub(对齐 tool-workflow-resume.test.ts);命令
@@ -19,6 +19,7 @@ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
19
19
 
20
20
  // 被 mock 的模块——import 路径与被测源文件的等值实例(对齐 tool-workflow-resume.test.ts)
21
21
  import { resumeRun } from "@zhushanwen/subagent-core/orchestration/resume-run.ts";
22
+ import { setModelConfigService, GLOBAL_SLOT_KEYS } from "@zhushanwen/subagent-core";
22
23
 
23
24
  import { registerWorkflowsCommand } from "../commands.ts";
24
25
 
@@ -66,9 +67,26 @@ function makeCtx(mode: "rpc" | "tui"): { ctx: ExtensionCommandContext; notifies:
66
67
  return { ctx: ctx as never, notifies };
67
68
  }
68
69
 
70
+ /** 重置进程级 ModelConfigService 单例槽(model catalog 预检用例的注入/清理,
71
+ * Symbol 直写——key 与生产 getModelServiceSlot 的 MODEL_SERVICE_SLOT_KEY 一致)。 */
72
+ function resetModelServiceSlot(): void {
73
+ const slot = Reflect.get(globalThis, Symbol.for(GLOBAL_SLOT_KEYS.modelService)) as
74
+ | { current: unknown }
75
+ | undefined;
76
+ if (slot) slot.current = null;
77
+ }
78
+
79
+ /** 注入目录为 entries 的最小 ModelConfigService fake(duck-typed getAvailable)。 */
80
+ function injectModelServiceWith(entries: Array<{ provider: string; id: string }>): void {
81
+ setModelConfigService({
82
+ getModelRegistry: () => ({ getAvailable: () => entries }),
83
+ } as never);
84
+ }
85
+
69
86
  beforeEach(() => {
70
87
  vi.mocked(resumeRun).mockReset();
71
88
  vi.mocked(resumeRun).mockResolvedValue("wf-x");
89
+ resetModelServiceSlot();
72
90
  });
73
91
 
74
92
  // ── TUI 模式 ─────────────────────────────────────────────────
@@ -79,17 +97,44 @@ describe("/workflows TUI resume", () => {
79
97
  const { ctx, notifies } = makeCtx("tui");
80
98
  await cmd.handler("resume wf-x", ctx);
81
99
  expect(vi.mocked(resumeRun)).toHaveBeenCalledWith("wf-x", expect.anything());
100
+ // 无 model 不落键:调用恰为 (runId, deps) 两参(spread 条件反写不会静默通过)
101
+ expect(vi.mocked(resumeRun).mock.calls[0]).toHaveLength(2);
82
102
  expect(notifies).toHaveLength(1);
83
103
  expect(notifies[0]!.msg).toContain("resuming");
84
104
  expect(notifies[0]!.level).toBe("info");
85
105
  });
86
106
 
107
+ it("resume <runId> <model> → resumeRun 第三参 { model } 透传 + notify 带模型", async () => {
108
+ injectModelServiceWith([{ provider: "p2", id: "m2" }]);
109
+ const cmd = captureCommand();
110
+ const { ctx, notifies } = makeCtx("tui");
111
+ await cmd.handler("resume wf-x p2/m2:high", ctx);
112
+ expect(vi.mocked(resumeRun)).toHaveBeenCalledWith("wf-x", expect.anything(), {
113
+ model: "p2/m2:high",
114
+ });
115
+ expect(notifies[0]!.msg).toContain("resuming with model p2/m2:high");
116
+ expect(notifies[0]!.level).toBe("info");
117
+ });
118
+
119
+ it("resume 显式 model 目录查无 → 同步拒单 warning(tool 通道同款 D8 语义,不落覆盖记账)", async () => {
120
+ injectModelServiceWith([{ provider: "p1", id: "m1" }]);
121
+ const cmd = captureCommand();
122
+ const { ctx, notifies } = makeCtx("tui");
123
+ await cmd.handler("resume wf-x pX/nope", ctx);
124
+ // 目录查无在 resumeRun 之前拒绝——不产生假成功 notify
125
+ expect(vi.mocked(resumeRun)).not.toHaveBeenCalled();
126
+ expect(notifies).toHaveLength(1);
127
+ expect(notifies[0]!.msg).toContain("Failed to resume workflow wf-x:");
128
+ expect(notifies[0]!.msg).toContain("pX/nope");
129
+ expect(notifies[0]!.level).toBe("warning");
130
+ });
131
+
87
132
  it("resume 缺 runId → Usage warning", async () => {
88
133
  const cmd = captureCommand();
89
134
  const { ctx, notifies } = makeCtx("tui");
90
135
  await cmd.handler("resume", ctx);
91
136
  expect(vi.mocked(resumeRun)).not.toHaveBeenCalled();
92
- expect(notifies[0]).toMatchObject({ msg: "Usage: /workflows resume <runId>", level: "warning" });
137
+ expect(notifies[0]).toMatchObject({ msg: "Usage: /workflows resume <runId> [model]", level: "warning" });
93
138
  });
94
139
 
95
140
  it("resumeRun 拒绝 → warning notify(TUI 同款透出)", async () => {
@@ -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
+ }
@@ -130,6 +130,26 @@ describe("actionResume", () => {
130
130
  expect(options.budgetTokens).toBe(50_000);
131
131
  });
132
132
 
133
+ it("model 形参透传(F1-26 后续项:core 落统一覆盖记账——补切 + 无参 resume 合一步)", async () => {
134
+ const recordPath = writeRecordStream([runCreatedFrame('{"a":1}')]);
135
+ await actionResume(
136
+ { action: "resume", runId: "wf-test", args: { a: 1 }, model: "p2/m2" } as never,
137
+ makeDeps(recordPath) as never,
138
+ );
139
+ const options = vi.mocked(resumeRun).mock.calls[0]![2] as { model?: string };
140
+ expect(options.model).toBe("p2/m2");
141
+ });
142
+
143
+ it("model 缺省 → options 不带 model 键(无参 resume 不写覆盖记账)", async () => {
144
+ const recordPath = writeRecordStream([runCreatedFrame('{"a":1}')]);
145
+ await actionResume(
146
+ { action: "resume", runId: "wf-test", args: { a: 1 } } as never,
147
+ makeDeps(recordPath) as never,
148
+ );
149
+ const options = vi.mocked(resumeRun).mock.calls[0]![2] as { model?: string };
150
+ expect("model" in options).toBe(false);
151
+ });
152
+
133
153
  it("tokens 缺省 → options 不带 budgetTokens 键(core 侧跳过覆盖、走三档回落)", async () => {
134
154
  await actionResume(
135
155
  { action: "resume", runId: "wf-test" } as never,
@@ -114,7 +114,11 @@ const WorkflowParams = Type.Object({
114
114
  Type.String({ description: "Error/reason message (optional, used with abort)" }),
115
115
  ),
116
116
  model: Type.Optional(Type.String({
117
- description: "Run-level model override in 'provider/modelId' format. When set, all agents spawned by this run inherit it by default (unless a per-call agent() opts.model is set). Omit to inherit the main agent's model.",
117
+ description:
118
+ "Run-level model override in 'provider/modelId' format. When set, all agents spawned by this run inherit it by default (unless a per-call agent() opts.model is set). " +
119
+ "Also accepted on action:\"resume\" — recorded as the run's user override (same channel as a panel switch) and drives all re-dispatched steps. " +
120
+ "A user-issued runtime model override (panel model switch / /subagent-model command) takes precedence over this parameter — user override wins over any explicitly scripted model. " +
121
+ "Omit to inherit the main agent's model.",
118
122
  })),
119
123
  thinkingLevel: Type.Optional(StringEnum(THINKING_ORDER, {
120
124
  description: "Run-level thinkingLevel override (off/minimal/low/medium/high/xhigh/max). All agents in this run inherit it by default. Omit to default each agent to its model's highest available level.",
@@ -232,14 +236,21 @@ export function registerWorkflowTool(
232
236
  "completed calls from the record at zero token cost and continues where the run stopped. " +
233
237
  "Do NOT resume a settled (done/failed/cancelled) run — start a new run instead.",
234
238
  "resume: pass the SAME args as the original run (they are verified field-by-field; a mismatch is rejected " +
235
- "with the differing fields listed). Omit args to reuse the original ones. Changed args = different intent = new run.",
239
+ "with the differing fields listed). Omit args to reuse the original ones. Changed args = different intent = new run. " +
240
+ "To resume with a specific model: pass model ('provider/modelId') on the resume itself — it is recorded as the " +
241
+ "run's user override (same channel as a panel switch), drives all re-dispatched steps, and persists for later " +
242
+ "resumes without the parameter. Alternatively switch the run's model first (panel selector or /subagent-model) " +
243
+ "then resume without model — both are the same override channel.",
236
244
  "Call shapes (JSON): " +
237
245
  "- run: {\"action\":\"run\",\"name\":\"<script>\",\"args\":{...},\"tokens\":N,\"time\":N,\"model\":\"<provider/modelId>\",\"thinkingLevel\":\"<level>\"}. " +
238
246
  "- status: {\"action\":\"status\"}. " +
239
247
  "- abort: {\"action\":\"abort\",\"runId\":\"<id>\"} (optional: {\"error\":\"<reason>\"}). " +
240
- "- resume: {\"action\":\"resume\",\"runId\":\"<id>\",\"args\":{...},\"tokens\":N,\"time\":N} — args/tokens/time optional.",
248
+ "- resume: {\"action\":\"resume\",\"runId\":\"<id>\",\"args\":{...},\"tokens\":N,\"time\":N,\"model\":\"<provider/modelId>\"} — args/tokens/time/model optional.",
241
249
  "Budget: Do NOT set tokens/time unless the user explicitly requests a limit. Built-in workflows run unlimited by default.",
242
- "Model/thinkingLevel: omit by default (inherit main agent's model). Only set model/thinkingLevel when the user explicitly requests a specific model or thinking depth for this run.",
250
+ "Model/thinkingLevel: omit by default (inherit main agent's model). Only set model/thinkingLevel when the user explicitly requests a specific model or thinking depth for this run. " +
251
+ "Note: a user-issued runtime model override (panel switch / /subagent-model) takes precedence over the model parameter — an explicitly scripted thinkingLevel still applies " +
252
+ "(explicit levels outrank the override's inline level). If a run's explicitly scripted model seems ignored, " +
253
+ "check for an active user override (run journal model-override frame) before assuming a resolution bug.",
243
254
  "Anti-patterns: Flattening args sub-fields (task/items/...) to the top level — they belong inside args. Calling {\"action\":\"run\"} without name.",
244
255
  "CRITICAL: For orchestration patterns, ALWAYS use action:run with the <location> absolute " +
245
256
  "path of the matching listed workflow — NEVER use workflow-script action:generate to recreate patterns " +
@@ -565,10 +576,28 @@ export async function actionResume(
565
576
  assertEntryTimeBudget(params.time);
566
577
  assertEntryTokenBudget(params.tokens);
567
578
 
579
+ // 显式 model 参数的目录预检(run action 同款 D8 拒单语义——查无同步 throw 零写,
580
+ // 单例缺席降级跳过 + warn,派发期 identity 解析仍是权威裁决)。
581
+ if (params.model !== undefined) {
582
+ const modelService = getModelConfigService();
583
+ if (modelService === null) {
584
+ logger.warn(
585
+ "[tool-workflow] model catalog unavailable (model service not initialized) — resume model catalog check skipped; dispatch-time identity resolution remains authoritative",
586
+ );
587
+ } else {
588
+ assertModelInCatalog(params.model, modelService.getModelRegistry(), {
589
+ source: "resume model override",
590
+ });
591
+ }
592
+ }
593
+
568
594
  const options: ResumeRunOptions = {
569
595
  ...(params.time !== undefined ? { budgetTimeMs: params.time } : {}),
570
596
  // token 预算显式覆盖(与 time 同款通道;三档回落与落盘归 core 单点)
571
597
  ...(params.tokens !== undefined ? { budgetTokens: params.tokens } : {}),
598
+ // [F1-26 后续项] 显式 model 透传(core 落统一覆盖记账——「补切 + 无参 resume」
599
+ // 的合一步;格式与目录已在本入口预检,记账落盘归 core 锁段单点)
600
+ ...(params.model !== undefined ? { model: params.model } : {}),
572
601
  // [§2.5] args 原样下传由 core 判定(D14 单源);journalDir 传壳的 store 同源目录,
573
602
  // 否则 core 会按模块锚解析 record 路径——多 session 场景会静默读成「无记录」
574
603
  //(D14 静默放行 = 安全语义反转;core 锚点与壳 store 锚点必须同源)。
@@ -76,7 +76,7 @@ import {
76
76
  type WorkflowRunEvent,
77
77
  } from "@zhushanwen/subagent-core";
78
78
  import { WorkflowRun } from "@zhushanwen/subagent-core";
79
- import { errorLogsFromEvents, rebuildBudget, runAccountingFromEvents } from "@zhushanwen/subagent-core";
79
+ import { errorLogsFromEvents, latestModelOverride, rebuildBudget, runAccountingFromEvents } from "@zhushanwen/subagent-core";
80
80
  import { guardStaleCtx, isEnoentError, toErrorMessage } from "@zhushanwen/pi-ext-guards";
81
81
 
82
82
  // ── [W1 / D1] v2 条目读面(注册定界 + 终态条目抑制)────────────────
@@ -283,7 +283,7 @@ interface CallDraft {
283
283
  /**
284
284
  * record 事件流 → WorkflowRun 聚合重建(纯函数,零 IO)。
285
285
  *
286
- * fold 语义(词表 = run-events.ts [D4] 对齐后 9 事件):
286
+ * fold 语义(词表 = run-events.ts 事件词表,单源):
287
287
  * - `run-created`:spec.scriptSource(全文,[D1])+ args(全文,设计 §3.1 载荷表;
288
288
  * 旧格式帧回落 argsSummary 尽力恢复——parseLegacyArgsSummary)+ startedAt 锚点
289
289
  * (帧 ts 优先,回落注册条目 startedAt);
@@ -294,7 +294,10 @@ interface CallDraft {
294
294
  * 形态建占位行——同 journal fold 兜底);sessionFile/sessionId 从 result 透传
295
295
  * (对齐 finalizeCall 的 call 字段填充纪律);
296
296
  * - `run-settled`:终局(status=done + reason 映射 + completedAt=帧 ts);无帧 =
297
- * running(交恢复链收编)。
297
+ * running(交恢复链收编);
298
+ * - `model-override`:latestModelOverride 折叠 → meta.modelOverride(最新一条生效,
299
+ * 覆盖旧覆盖值不叠加;无覆盖不造键——详见 foldRecordStreamToRun 内
300
+ * [subagent-model-switch §6.6①/§7.4] 注释,不在此重复展开)。
298
301
  *
299
302
  * record 流不承载的字段(budget 计数/errorLogs/trace 完整面)按恢复语义最小形态
300
303
  * 缺省——步骤级详情的恢复读面 = record 流直读(session-reader 家族链),不经本聚合。
@@ -506,6 +509,11 @@ function foldRecordStreamToRun(
506
509
  const calls = draftsToAgentCalls(drafts, sharedNodes, nodes);
507
510
 
508
511
  const settledEvent = lastRunSettledEvent(events);
512
+ // [subagent-model-switch §6.6①/§7.4] 覆盖记账折叠(派生视图半边):生效覆盖值 =
513
+ // 最新一条(latestModelOverride core 单点——覆盖旧覆盖值不叠加,不变量 2);
514
+ // 无覆盖 undefined 不造键。fold 未跟时本行缺失 = 覆盖状态静默丢行——
515
+ // jsonl-run-store-loadall 的 fold 消费用例是此丢行风险的机器防线(P8 第五道)。
516
+ const modelOverride = latestModelOverride(events);
509
517
  if (settledEvent === undefined) {
510
518
  const interruptedAt = lastInterruptedAt(events);
511
519
  return WorkflowRun.reconstruct(
@@ -524,6 +532,7 @@ function foldRecordStreamToRun(
524
532
  //(CLI/TUI 不显示僵尸「运行中」;resume 资格判据在 core fold lifecycle,
525
533
  // 不受本投影影响)。
526
534
  ...(interruptedAt !== undefined ? { interruptedAt } : {}),
535
+ ...(modelOverride !== undefined ? { modelOverride } : {}),
527
536
  },
528
537
  );
529
538
  }
@@ -543,6 +552,7 @@ function foldRecordStreamToRun(
543
552
  {
544
553
  startedAt: startedAtIso,
545
554
  completedAt: new Date(settledEvent.ts).toISOString(),
555
+ ...(modelOverride !== undefined ? { modelOverride } : {}),
546
556
  },
547
557
  );
548
558
  }
@@ -402,6 +402,28 @@ export function setupWorkflowDomain(
402
402
  }
403
403
  return service.executeWorkflowAgent(opts, parentRunId, signal, undefined, undefined, stepIndex);
404
404
  },
405
+ // [F1-18 修复] resume 显式 model 落账后的宿主投影同步(生产接线——单例现读
406
+ // 同 workflowAgentDispatch 形态)。实现 = SubagentService.applyRunOverrideProjection
407
+ // (覆盖内存表写值 + 重建负缓存作废);throw 走 resume 侧回滚围栏(run 回
408
+ // interrupted 可重试),service 缺席文案同款 [C2] 闭环。
409
+ onResumeModelOverrideCommitted: (runId, override) => {
410
+ const service = getSubagentService();
411
+ if (!service) {
412
+ throw new Error(
413
+ "resume model override projection unavailable: subagent service not initialized " +
414
+ "(session_start assembly failed). Recovery: retry the resume once the session " +
415
+ "is initialized; the override frame is already persisted in the run journal.",
416
+ );
417
+ }
418
+ service.applyRunOverrideProjection(runId, override);
419
+ },
420
+ // [dmg-r2-5] 宿主会话锚现读口(run-created 帧 rootSessionId 载荷源):与
421
+ // workflowAgentDispatch 同款 service 单例现读形态——sessionRootId 根进程语义
422
+ // 与 record 域 rootSessionId 同源(根进程 = 本 session id,嵌套 = env ROOT)。
423
+ // 同 cwd 多会话共享 workflow-state 目录,runtime 网关 runId 路由靠该字段精确
424
+ // 判归属;service 缺席返回 null(帧不落字段,读侧旧格式行放行——不抛,创建
425
+ // 主链不因锚缺席失败)。
426
+ getSessionRootId: () => getSubagentService()?.getSessionRootId() ?? null,
405
427
  log,
406
428
  };
407
429
  return deps;
@@ -702,6 +724,20 @@ export function setupWorkflowDomain(
702
724
  // "sar-unattached" → armed 回执落账键错 → fold 出 created 态 → IllegalTransitionError
703
725
  // 让位,run journal 恒缺 armed 帧(真机 wf-1790034646281-w7tp4f 实证链,R2 裁决)。
704
726
  get workflowAgentDispatch() { return createLazy(resolveDeps, "workflowAgentDispatch"); },
727
+ // [dmg-r2-5] 宿主会话锚必须随 lazyDeps 转发(workflowAgentDispatch 同款先例):
728
+ // workflow tool / subagents tool 的 run action 以 lazyDeps 为 deps 启动 run,漏本
729
+ // 成员则 run-created 帧不落 rootSessionId(可选成员静默放行)——同 cwd 多会话下
730
+ // runtime 网关 runId 路由恒回落目录存在性弱锚,精确归属形同虚设。
731
+ get getSessionRootId() { return createLazy(resolveDeps, "getSessionRootId"); },
732
+ // [dmg-r1-1] resume 显式 model 落账回调必须随 lazyDeps 转发(getSessionRootId
733
+ // 同款先例,同族第三例):三个生产入口(registerWorkflowTool / registerSubagentsTool /
734
+ // registerWorkflowsCommand)都以 lazyDeps 为 deps 启动 run,resume with model 的
735
+ // 落账点(resume-run appendResumeModelOverride)落完 journal B 帧后经本回调同步
736
+ // 宿主投影——漏本成员则可选链静默短路(可选成员静默放行):覆盖内存表 /
737
+ // 负缓存 stale 不失效,重派被旧投影遮蔽(旧 model 命中或覆盖丢失按 run-created.model
738
+ // 解析),pi 仍 notify「resuming with model B」成功文案。转发清单对账测试防第四处
739
+ // 漏转发(workflow-events.test.ts lazyDeps 转发清单对账 describe)。
740
+ get onResumeModelOverrideCommitted() { return createLazy(resolveDeps, "onResumeModelOverrideCommitted"); },
705
741
  // scheduleTimeBudget / appendEntry 不可缺席(ports.ts D-12 regression fix 同族):
706
742
  // rebuildRuntime 重排 run 级墙钟预算计时器、finalizeRun 的 pending:unregister
707
743
  // 直落都经这两个成员消费——lazyDeps 缺席会让消费点拿到 undefined(可选属性