@zhushanwen/pi-subagent-workflow 9.3.0 → 9.4.1

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.1",
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.20.0",
62
62
  "@zhushanwen/session-delivery": "1.1.0",
63
- "@zhushanwen/pi-ext-guards": "0.4.3",
63
+ "@zhushanwen/pi-ext-guards": "0.4.4",
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.2",
66
+ "@zhushanwen/subagent-core": "1.5.0",
67
+ "@zhushanwen/pi-subagent-cli": "0.7.1",
68
+ "@zhushanwen/zcode-subagent-cli": "0.7.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-pending-notifications": "0.8.4",
76
+ "@zhushanwen/pi-structured-output": "6.1.1"
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.1"
91
91
  },
92
92
  "scripts": {
93
93
  "typecheck": "npx tsc --noEmit",
@@ -107,12 +107,15 @@ export function corePackageNpmRoot(moduleUrl: string = import.meta.url): string
107
107
  * `<Resources>/extensions/@zhushanwen/pi-subagent-workflow/index.js`),其**父目录**
108
108
  * 正是 `@zhushanwen` scope 目录——npm 槽语义("一级子项 = 包目录")恰好成立,
109
109
  * 于是可直接当 npm 槽根用:约定目录扫描即命中
110
- * `pi-subagent-workflow/{workflows,agents}`(两目录由 `scripts/bundle-extensions.mjs`
111
- * 随包拷贝;`pi.agents`/`pi.workflows` manifest 约定及其"声明路径不存在 → 整包失败
112
- * 占位"语义是 subagent-core resource-discovery 的 taiji 自建规则,权威源 =
113
- * resource-discovery.ts processPackage——pi 上游 manifest 只有
114
- * extensions/skills/prompts/themes 四字段,无 agents/workflows 概念。已核仓内无任何
115
- * 包声明该字段,故扫描面内所有包都走约定目录分支,不进 manifest 失败分支)。
110
+ * `pi-subagent-workflow/{workflows,agents}`(两目录由 `scripts/bundle-extensions.mjs`
111
+ * 随包拷贝;`pi.agents`/`pi.workflows` manifest 约定及其"声明路径不存在 → 整包失败
112
+ * 占位"语义是 subagent-core resource-discovery 的 taiji 自建规则,权威源 =
113
+ * resource-discovery.ts processPackage——pi 上游 manifest 只有
114
+ * extensions/skills/prompts/themes 四字段(锚 = pi-coding-agent
115
+ * dist/core/pi-manifest.js:3 RESOURCE_FIELDS,pi@1.0.0 已核;同源登记见
116
+ * docs/extensions/extension-conventions.md「readPiManifest」条),无 agents/workflows
117
+ * 概念。已核仓内无任何包声明该字段,故扫描面内所有包都走约定目录分支,不进
118
+ * manifest 失败分支)。
116
119
  *
117
120
  * 与 dev/npm 形态的既有语义一致:npm 槽根扫的就是 scope 下全部包目录(因此本包自带
118
121
  * 的 workflows/agents 在 dev/npm 形态同样入发现面),非新增语义。
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
  }