@zhushanwen/pi-subagent-workflow 9.0.1 → 9.1.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 +10 -10
- package/skills/subagent-ext-config/SKILL.md +9 -11
- package/src/host/__tests__/pi-host.test.ts +0 -1
- package/src/index.ts +7 -7
- package/src/injectors/subagent-list-injector.ts +1 -1
- package/src/interface/command/__tests__/commands-open.test.ts +278 -0
- package/src/interface/{commands.ts → command/commands.ts} +15 -7
- package/src/interface/{subagents.ts → command/subagents.ts} +4 -4
- package/src/interface/format/__tests__/display-state.test.ts +146 -0
- package/src/interface/format/display-state.ts +112 -0
- package/src/interface/{format.ts → format/format.ts} +20 -14
- package/src/interface/{id-preview.ts → format/id-preview.ts} +1 -1
- package/src/interface/{bg-notify-render.ts → gui/bg-notify-render.ts} +60 -35
- package/src/interface/gui/gui-mappers.ts +74 -0
- package/src/interface/{subagent-actions.ts → gui/subagent-actions.ts} +3 -3
- package/src/interface/tool/__tests__/subagent-tool-actions.test.ts +238 -0
- package/src/interface/{__tests__ → tool/__tests__}/subagent-tool-path-guard.test.ts +6 -5
- package/src/interface/{__tests__ → tool/__tests__}/tool-subagents.test.ts +4 -4
- package/src/interface/tool/__tests__/tool-workflow-resume.test.ts +161 -0
- package/src/interface/{__tests__ → tool/__tests__}/tool-workflow.test.ts +33 -9
- package/src/interface/{subagent-tool-schema.ts → tool/subagent-tool-schema.ts} +1 -1
- package/src/interface/{subagent-tool.ts → tool/subagent-tool.ts} +4 -4
- package/src/interface/{tool-render.ts → tool/tool-render.ts} +3 -3
- package/src/interface/{tool-shared.ts → tool/tool-shared.ts} +1 -1
- package/src/interface/{tool-subagents.ts → tool/tool-subagents.ts} +12 -11
- package/src/interface/{tool-workflow-script.ts → tool/tool-workflow-script.ts} +1 -1
- package/src/interface/{tool-workflow.ts → tool/tool-workflow.ts} +20 -165
- package/src/interface/tui/__tests__/list-view.test.ts +188 -0
- package/src/interface/{list-component.ts → tui/list-component.ts} +2 -2
- package/src/interface/{list-shared.ts → tui/list-shared.ts} +1 -1
- package/src/interface/{list-view.ts → tui/list-view.ts} +2 -2
- package/src/interface/{tui-kit.ts → tui/tui-kit.ts} +1 -1
- package/src/interface/{views → tui/views}/WorkflowsView.ts +10 -6
- package/src/interface/tui/views/__tests__/WorkflowsView.driver.test.ts +595 -0
- package/src/interface/{views → tui/views}/__tests__/WorkflowsView.test.ts +10 -3
- package/src/interface/tui/views/__tests__/detail-content.test.ts +195 -0
- package/src/interface/{views → tui/views}/detail-content.ts +8 -3
- package/src/interface/{views → tui/views}/view-constants.ts +2 -2
- package/src/jsonl-run-store.ts +133 -81
- package/src/session-lifecycle.ts +2 -1
- package/src/workflow-events.ts +21 -3
- package/src/workflow-notify.ts +7 -6
- package/src/interface/__tests__/tool-workflow-resume.test.ts +0 -227
- package/src/interface/gui-mappers.ts +0 -81
- /package/src/interface/{__tests__ → command/__tests__}/commands-resume.test.ts +0 -0
- /package/src/interface/{command-actions.ts → command/command-actions.ts} +0 -0
- /package/src/interface/{__tests__ → tool/__tests__}/capture-tool.ts +0 -0
- /package/src/interface/{__tests__ → tool/__tests__}/tool-prompt-contract.test.ts +0 -0
- /package/src/interface/{__tests__ → tool/__tests__}/tool-render.test.ts +0 -0
- /package/src/interface/{__tests__ → tool/__tests__}/tool-workflow-script.test.ts +0 -0
- /package/src/interface/{reentry-guard.ts → tool/reentry-guard.ts} +0 -0
- /package/src/interface/{tool-result.ts → tool/tool-result.ts} +0 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* subagent tool 非 start 通道 + renderCall 预解析测试(与 path-guard 的 start 守卫面
|
|
3
|
+
* 互补,覆盖 executeSubagent 的 action 路由全分支与 renderCall 的 model 预解析降级链)。
|
|
4
|
+
*
|
|
5
|
+
* 覆盖面:
|
|
6
|
+
* - execute:service 缺席 → 带恢复动作的错误;未知 action → immediate throw;
|
|
7
|
+
* list/cancel/message/close/fork-from 六通道 handler 接线 + adapter 包装
|
|
8
|
+
* (content JSON 给 LLM / details 领域对象给 renderResult)
|
|
9
|
+
* - renderCall:model override 三层解析(override 在场 → resolved 注入标题;
|
|
10
|
+
* resolveModel 抛错 → 降级不崩;override 缺省 → resolved undefined)
|
|
11
|
+
*
|
|
12
|
+
* mock 策略:六 handler 深路径 stub(领域内核行为由 core 侧
|
|
13
|
+
* subagent-actions-core.test.ts 锁定,此处只测接线);pi-ai/typebox 用共享桩
|
|
14
|
+
* (对齐 subagent-tool-path-guard.test.ts);logger stub(debug 冷路径不落盘)。
|
|
15
|
+
*/
|
|
16
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
17
|
+
|
|
18
|
+
vi.mock("@earendil-works/pi-ai", async () => {
|
|
19
|
+
const { piAiStringEnumStub } = await import("../../../__tests__/mocks/runtime-stubs.ts");
|
|
20
|
+
return piAiStringEnumStub();
|
|
21
|
+
});
|
|
22
|
+
vi.mock("typebox", async () => {
|
|
23
|
+
const { typeboxStub } = await import("../../../__tests__/mocks/runtime-stubs.ts");
|
|
24
|
+
return typeboxStub;
|
|
25
|
+
});
|
|
26
|
+
vi.mock("@zhushanwen/pi-extension-logger", () => ({
|
|
27
|
+
getLogger: () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }),
|
|
28
|
+
}));
|
|
29
|
+
vi.mock("@zhushanwen/subagent-core/execution/assembly/subagent-actions-core.ts", () => ({
|
|
30
|
+
startHandler: vi.fn(),
|
|
31
|
+
listHandler: vi.fn(),
|
|
32
|
+
cancelHandler: vi.fn(),
|
|
33
|
+
messageHandler: vi.fn(),
|
|
34
|
+
closeHandler: vi.fn(),
|
|
35
|
+
forkFromHandler: vi.fn(),
|
|
36
|
+
endedMessageGuard: vi.fn(),
|
|
37
|
+
mapExternalState: vi.fn(),
|
|
38
|
+
recordToListItem: vi.fn(),
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
import {
|
|
42
|
+
cancelHandler,
|
|
43
|
+
closeHandler,
|
|
44
|
+
forkFromHandler,
|
|
45
|
+
listHandler,
|
|
46
|
+
messageHandler,
|
|
47
|
+
startHandler,
|
|
48
|
+
setSubagentService,
|
|
49
|
+
GLOBAL_SLOT_KEYS,
|
|
50
|
+
} from "@zhushanwen/subagent-core";
|
|
51
|
+
|
|
52
|
+
import { registerSubagentTool } from "../subagent-tool.ts";
|
|
53
|
+
import { mockExtensionApi } from "@zhushanwen/subagent-core/testing/execution/__tests__/helpers/mock-extension-api.ts";
|
|
54
|
+
|
|
55
|
+
/** 注册捕获的 tool 窄 view(execute + renderCall)。 */
|
|
56
|
+
interface SubagentToolView { // oe-exempt:20260930:test:test double shape for tool capture (test infra)
|
|
57
|
+
name: string;
|
|
58
|
+
execute: (
|
|
59
|
+
toolCallId: string,
|
|
60
|
+
params: Record<string, unknown>,
|
|
61
|
+
signal: AbortSignal | undefined,
|
|
62
|
+
onUpdate: unknown,
|
|
63
|
+
ctx: unknown,
|
|
64
|
+
) => Promise<{ content: Array<{ type: string; text: string }>; details: Record<string, unknown> }>;
|
|
65
|
+
renderCall: (args: unknown, theme: unknown, ctx: unknown) => { render(width: number): string[] };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function captureTool(): SubagentToolView {
|
|
69
|
+
let captured: SubagentToolView | undefined;
|
|
70
|
+
const pi = mockExtensionApi({
|
|
71
|
+
registerTool: (tool: unknown) => {
|
|
72
|
+
captured = tool as SubagentToolView;
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
registerSubagentTool(pi);
|
|
76
|
+
if (!captured) throw new Error("subagent tool not registered");
|
|
77
|
+
return captured;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function resetServiceSlot(): void {
|
|
81
|
+
const slot = Reflect.get(globalThis, Symbol.for(GLOBAL_SLOT_KEYS.service)) as
|
|
82
|
+
| { current: unknown }
|
|
83
|
+
| undefined;
|
|
84
|
+
if (slot) slot.current = null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const markingTheme = {
|
|
88
|
+
fg: (tag: string, text: string) => `${tag}(${text})`,
|
|
89
|
+
bold: (text: string) => text,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
beforeEach(() => {
|
|
93
|
+
vi.clearAllMocks();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
afterEach(() => {
|
|
97
|
+
resetServiceSlot();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("subagent tool execute:守卫与缺席面", () => {
|
|
101
|
+
it("service 缺席 → immediate throw,错误带恢复动作指引", async () => {
|
|
102
|
+
const tool = captureTool(); // 槽未注入
|
|
103
|
+
await expect(
|
|
104
|
+
tool.execute("c1", { action: "list" }, undefined, undefined, undefined),
|
|
105
|
+
).rejects.toThrow(/subagents runtime not initialized.*Recovery: reload this session/s);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("未知 action → immediate throw(Unknown subagent action)", async () => {
|
|
109
|
+
setSubagentService({ execute: vi.fn() } as never);
|
|
110
|
+
const tool = captureTool();
|
|
111
|
+
await expect(
|
|
112
|
+
tool.execute("c2", { action: "pause" }, undefined, undefined, undefined),
|
|
113
|
+
).rejects.toThrow("Unknown subagent action: pause");
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("subagent tool execute:六 action 通道接线", () => {
|
|
118
|
+
/** 注入最小 fake service 并捕获 tool(六通道共用基座)。 */
|
|
119
|
+
function withService(): SubagentToolView {
|
|
120
|
+
setSubagentService({ execute: vi.fn() } as never);
|
|
121
|
+
return captureTool();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
it("list:listParam 解包透传,adapter 包装 listResponse", async () => {
|
|
125
|
+
vi.mocked(listHandler).mockReturnValue({ response: { items: [], total: 0 } } as never);
|
|
126
|
+
const tool = withService();
|
|
127
|
+
const params = { action: "list", listParam: { includeFinished: true, limit: 5 } };
|
|
128
|
+
const result = await tool.execute("c3", params, undefined, undefined, undefined);
|
|
129
|
+
expect(vi.mocked(listHandler)).toHaveBeenCalledWith(expect.anything(), params.listParam);
|
|
130
|
+
expect(JSON.parse(result.content[0]!.text)).toMatchObject({ action: "list" });
|
|
131
|
+
expect(result.details).toMatchObject({ action: "list" });
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("cancel:cancelParam 解包透传 + subagentId 回显", async () => {
|
|
135
|
+
vi.mocked(cancelHandler).mockResolvedValue({ subagentId: "sa-9", response: { stopped: true } } as never);
|
|
136
|
+
const tool = withService();
|
|
137
|
+
const params = { action: "cancel", cancelParam: { subagentId: "sa-9" } };
|
|
138
|
+
const result = await tool.execute("c4", params, undefined, undefined, undefined);
|
|
139
|
+
expect(vi.mocked(cancelHandler)).toHaveBeenCalledWith(expect.anything(), { subagentId: "sa-9" });
|
|
140
|
+
expect(JSON.parse(result.content[0]!.text)).toMatchObject({ action: "cancel", subagentId: "sa-9" });
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("message:messageParam 解包透传", async () => {
|
|
144
|
+
vi.mocked(messageHandler).mockResolvedValue({ subagentId: "sa-7", response: { queued: true } } as never);
|
|
145
|
+
const tool = withService();
|
|
146
|
+
const params = { action: "message", messageParam: { subagentId: "sa-7", text: "continue" } };
|
|
147
|
+
const result = await tool.execute("c5", params, undefined, undefined, undefined);
|
|
148
|
+
expect(vi.mocked(messageHandler)).toHaveBeenCalledWith(expect.anything(), params.messageParam);
|
|
149
|
+
expect(JSON.parse(result.content[0]!.text)).toMatchObject({ action: "message", subagentId: "sa-7" });
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("close:closeParam 解包透传", async () => {
|
|
153
|
+
vi.mocked(closeHandler).mockResolvedValue({ subagentId: "sa-6", response: { archived: true } } as never);
|
|
154
|
+
const tool = withService();
|
|
155
|
+
const params = { action: "close", closeParam: { subagentId: "sa-6" } };
|
|
156
|
+
const result = await tool.execute("c6", params, undefined, undefined, undefined);
|
|
157
|
+
expect(vi.mocked(closeHandler)).toHaveBeenCalledWith(expect.anything(), { subagentId: "sa-6" });
|
|
158
|
+
expect(JSON.parse(result.content[0]!.text)).toMatchObject({ action: "close", subagentId: "sa-6" });
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("fork-from:forkFromParam 解包透传", async () => {
|
|
162
|
+
vi.mocked(forkFromHandler).mockResolvedValue({
|
|
163
|
+
subagentId: "sa-new",
|
|
164
|
+
response: { sourceSubagentId: "sa-old", newSubagentId: "sa-new" },
|
|
165
|
+
} as never);
|
|
166
|
+
const tool = withService();
|
|
167
|
+
const params = { action: "fork-from", forkFromParam: { sourceSubagentId: "sa-old", prompt: "continue" } };
|
|
168
|
+
const result = await tool.execute("c7", params, undefined, undefined, undefined);
|
|
169
|
+
expect(vi.mocked(forkFromHandler)).toHaveBeenCalledWith(expect.anything(), params.forkFromParam);
|
|
170
|
+
expect(JSON.parse(result.content[0]!.text)).toMatchObject({ action: "fork-from", subagentId: "sa-new" });
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("start:合法绝对路径放行,顶层 params 原样透传(含 signal/model override)", async () => {
|
|
174
|
+
vi.mocked(startHandler).mockResolvedValue({
|
|
175
|
+
subagentId: "sa-1",
|
|
176
|
+
slug: "demo",
|
|
177
|
+
model: "p/m",
|
|
178
|
+
response: { mode: "background" },
|
|
179
|
+
} as never);
|
|
180
|
+
setSubagentService({ execute: vi.fn() } as never);
|
|
181
|
+
const tool = captureTool();
|
|
182
|
+
const params = {
|
|
183
|
+
action: "start",
|
|
184
|
+
task: "t",
|
|
185
|
+
slug: "demo",
|
|
186
|
+
skillPath: "/abs/skills/x.md",
|
|
187
|
+
cwd: "/abs/cwd",
|
|
188
|
+
model: "p/m",
|
|
189
|
+
};
|
|
190
|
+
const result = await tool.execute("c8", params, undefined, undefined, { mode: "rpc", hasUI: true });
|
|
191
|
+
expect(vi.mocked(startHandler)).toHaveBeenCalledWith(expect.anything(), params, undefined, undefined);
|
|
192
|
+
expect(JSON.parse(result.content[0]!.text)).toMatchObject({ action: "start", subagentId: "sa-1", slug: "demo" });
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe("subagent tool renderCall:model 预解析", () => {
|
|
197
|
+
it("override 在场 + resolveModel 成功 → resolved 注入标题(provider/model · thinking)", () => {
|
|
198
|
+
setSubagentService({
|
|
199
|
+
resolveModel: () => ({ model: { provider: "p", id: "m1" }, thinkingLevel: "high" }),
|
|
200
|
+
} as never);
|
|
201
|
+
const tool = captureTool();
|
|
202
|
+
const comp = tool.renderCall({ agent: "coder", model: "p/m1", thinkingLevel: "high", slug: "demo" }, markingTheme, {});
|
|
203
|
+
const text = comp.render(200).join("");
|
|
204
|
+
expect(text).toContain("p/m1");
|
|
205
|
+
expect(text).toContain("· high");
|
|
206
|
+
expect(text).toContain("demo");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("resolveModel 抛错 → 降级不崩(标题无 model 段)", () => {
|
|
210
|
+
setSubagentService({
|
|
211
|
+
resolveModel: () => {
|
|
212
|
+
throw new Error("registry cold");
|
|
213
|
+
},
|
|
214
|
+
} as never);
|
|
215
|
+
const tool = captureTool();
|
|
216
|
+
const comp = tool.renderCall({ agent: "coder", model: "p/m1" }, markingTheme, {});
|
|
217
|
+
const text = comp.render(80).join("");
|
|
218
|
+
expect(text).toContain("subagent");
|
|
219
|
+
expect(text).not.toContain("p/m1");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("service 缺席 → resolved undefined 降级;无 agent 字段兜底默认名", () => {
|
|
223
|
+
const tool = captureTool(); // 槽空
|
|
224
|
+
const comp = tool.renderCall({ task: "t" }, markingTheme, {});
|
|
225
|
+
const text = comp.render(80).join("");
|
|
226
|
+
expect(text).toContain("subagent");
|
|
227
|
+
expect(text).not.toContain(" · high");
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("args 非 object(null)→ 不崩,标题仍渲染(isModelOverrideObj 假分支)", () => {
|
|
231
|
+
setSubagentService({
|
|
232
|
+
resolveModel: () => ({ model: { provider: "p", id: "m1" }, thinkingLevel: "high" }),
|
|
233
|
+
} as never);
|
|
234
|
+
const tool = captureTool();
|
|
235
|
+
const comp = tool.renderCall(null, markingTheme, {});
|
|
236
|
+
expect(comp.render(80).join("")).toContain("subagent");
|
|
237
|
+
});
|
|
238
|
+
});
|
|
@@ -21,22 +21,23 @@
|
|
|
21
21
|
|
|
22
22
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
23
23
|
|
|
24
|
-
// 共享桩(./mocks/runtime-stubs.ts 相对本文件 =
|
|
24
|
+
// 共享桩(./mocks/runtime-stubs.ts 相对本文件 = ../../../__tests__/mocks/runtime-stubs.ts):
|
|
25
25
|
// vi.mock 工厂提升到文件顶部,体内不能引用普通顶层变量,一律经 async 工厂 +
|
|
26
26
|
// 动态 import 取桩。
|
|
27
27
|
vi.mock("@earendil-works/pi-ai", async () => {
|
|
28
|
-
const { piAiStringEnumStub } = await import("
|
|
28
|
+
const { piAiStringEnumStub } = await import("../../../__tests__/mocks/runtime-stubs.ts");
|
|
29
29
|
return piAiStringEnumStub();
|
|
30
30
|
});
|
|
31
31
|
vi.mock("typebox", async () => {
|
|
32
|
-
const { typeboxStub } = await import("
|
|
32
|
+
const { typeboxStub } = await import("../../../__tests__/mocks/runtime-stubs.ts");
|
|
33
33
|
return typeboxStub;
|
|
34
34
|
});
|
|
35
35
|
|
|
36
36
|
import { setSubagentService } from "@zhushanwen/subagent-core";
|
|
37
37
|
|
|
38
|
-
import { registerSubagentTool } from "
|
|
38
|
+
import { registerSubagentTool } from "../subagent-tool.ts";
|
|
39
39
|
import { mockExtensionApi } from "@zhushanwen/subagent-core/testing/execution/__tests__/helpers/mock-extension-api.ts";
|
|
40
|
+
import { GLOBAL_SLOT_KEYS } from "@zhushanwen/subagent-core";
|
|
40
41
|
|
|
41
42
|
// ── 访问器槽注入 helpers ─────────────────────────────────────
|
|
42
43
|
|
|
@@ -48,7 +49,7 @@ const { mockServiceExecute } = vi.hoisted(() => ({
|
|
|
48
49
|
/** 重置进程级 SubagentService 单例槽(setSubagentService 不接受 null,测试清理用
|
|
49
50
|
* Symbol 直写;key 与生产 getServiceSlot 的 SERVICE_SLOT_KEY 一致)。 */
|
|
50
51
|
function resetServiceSlot(): void {
|
|
51
|
-
const slot = Reflect.get(globalThis, Symbol.for(
|
|
52
|
+
const slot = Reflect.get(globalThis, Symbol.for(GLOBAL_SLOT_KEYS.service)) as
|
|
52
53
|
| { current: unknown }
|
|
53
54
|
| undefined;
|
|
54
55
|
if (slot) slot.current = null;
|
|
@@ -142,7 +142,7 @@ interface SharedConstantsModule {
|
|
|
142
142
|
|
|
143
143
|
/** 动态 import 真 constants 模块(文本断言会漏「值被改」的漂移,故取真值)。 */
|
|
144
144
|
async function loadSharedConstants(): Promise<SharedConstantsModule> {
|
|
145
|
-
const url = pathToFileURL(join(__dirname, "
|
|
145
|
+
const url = pathToFileURL(join(__dirname, "../../../../../../../packages/shared/src/constants.ts")).href;
|
|
146
146
|
const mod: Record<string, unknown> = await import(/* @vite-ignore */ url);
|
|
147
147
|
const workflows = mod.WORKFLOW_TOOL_NAMES;
|
|
148
148
|
const subagents = mod.SUBAGENT_TOOL_NAMES;
|
|
@@ -213,8 +213,8 @@ describe("schema 契约:一跳扁平 + tasks 必填(D2)", () => {
|
|
|
213
213
|
expect(TOOL_SRC).toContain("SLUG_MAX_LENGTH");
|
|
214
214
|
});
|
|
215
215
|
|
|
216
|
-
it("文件头与 interface/subagents.ts(/subagents 命令壳)互指防混淆", () => {
|
|
217
|
-
expect(TOOL_SRC).toContain("interface/subagents.ts");
|
|
216
|
+
it("文件头与 interface/command/subagents.ts(/subagents 命令壳)互指防混淆", () => {
|
|
217
|
+
expect(TOOL_SRC).toContain("interface/command/subagents.ts");
|
|
218
218
|
// 指认对面是 slash 命令(同名不同命名空间)
|
|
219
219
|
expect(TOOL_SRC.toLowerCase()).toContain("slash");
|
|
220
220
|
});
|
|
@@ -239,7 +239,7 @@ describe("schema 契约:一跳扁平 + tasks 必填(D2)", () => {
|
|
|
239
239
|
});
|
|
240
240
|
|
|
241
241
|
// ══════════════════════════════════════════════════════════════
|
|
242
|
-
describe("handler
|
|
242
|
+
describe("handler 组装:runWorkflow('fan-out') 确定性映射(D3)", () => {
|
|
243
243
|
it("全参数调用 → spec 逐字段映射(scriptSource/budget/slug/model/thinkingLevel/args)", async () => {
|
|
244
244
|
const registry = makeRegistry();
|
|
245
245
|
const result = await runSubagentsBatch(
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* workflow tool resume action 契约(workflow-run-resume-revision U3 / D14)。
|
|
3
|
+
*
|
|
4
|
+
* 覆盖面([§2.5] D14 判定已下沉 core,本文件只测壳的转发与接线面):
|
|
5
|
+
* - 转发契约:args 与 journalDir 原样下传 core(判定与拒绝文案的权威测试在 core
|
|
6
|
+
* `orchestration/__tests__/resume-args-guard.test.ts`;真链路拒绝见
|
|
7
|
+
* scenario-24-args-mismatch-rejection.test.ts)。
|
|
8
|
+
* - resumeRun 接线:budgetTimeMs(time 形参透传)/ budgetTokens(tokens 形参透传)/
|
|
9
|
+
* host / runId;成功文案与 details。
|
|
10
|
+
* - runId 缺失与 time/tokens 负值的入口护栏(assertEntryTimeBudget / assertEntryTokenBudget 同源)。
|
|
11
|
+
*
|
|
12
|
+
* mock 策略:resume-run 深路径 stub(resumeRun 为 vi.fn——不起锁/IO,只测入口
|
|
13
|
+
* 面与校验层);record 流用 mkdtemp 真文件(D14 读取面读真实路径)。范式对齐
|
|
14
|
+
* tool-workflow.test.ts(lifecycle 深路径 mock + captureTool 注册层黑盒)。
|
|
15
|
+
*/
|
|
16
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
|
|
20
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
21
|
+
|
|
22
|
+
// 被 mock 的模块——import 路径与被测源文件的等值实例(vi.mock hoist 后 barrel
|
|
23
|
+
// re-export 与深路径指向同一 mock 实例,对齐 tool-workflow.test.ts 先例)
|
|
24
|
+
import { resumeRun } from "@zhushanwen/subagent-core/orchestration/resume-run.ts";
|
|
25
|
+
|
|
26
|
+
import { actionResume } from "../tool-workflow.ts";
|
|
27
|
+
|
|
28
|
+
/** 桩化 resume-run——resumeRun 为 vi.fn(不起锁/record IO,只测入口校验与接线面)。 */
|
|
29
|
+
vi.mock("@zhushanwen/subagent-core/orchestration/resume-run.ts", () => ({
|
|
30
|
+
resumeRun: vi.fn(),
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
// ── fixture ──────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
let tmpDir: string;
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
vi.mocked(resumeRun).mockReset();
|
|
39
|
+
vi.mocked(resumeRun).mockResolvedValue("wf-test");
|
|
40
|
+
tmpDir = mkdtempSync(join(tmpdir(), "u3-resume-"));
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
rmSync(tmpDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
|
45
|
+
vi.restoreAllMocks();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
/** 写 record 流(每行一个 JSON 对象)并返回路径——D14 读取面的真实文件。 */
|
|
49
|
+
function writeRecordStream(lines: unknown[]): string {
|
|
50
|
+
const path = join(tmpDir, "wf-test.record.jsonl");
|
|
51
|
+
writeFileSync(path, lines.map((l) => JSON.stringify(l)).join("\n") + "\n", "utf8");
|
|
52
|
+
return path;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** run-created 帧(args 含 _runId——对齐 lifecycle runWorkflow 注入后落账形态)。 */
|
|
56
|
+
function runCreatedFrame(argsSummary: string) {
|
|
57
|
+
return {
|
|
58
|
+
type: "run-created",
|
|
59
|
+
seq: 1,
|
|
60
|
+
ts: 1_700_000_000_000,
|
|
61
|
+
runId: "wf-test",
|
|
62
|
+
workflowName: "demo",
|
|
63
|
+
argsSummary,
|
|
64
|
+
scriptSource: "// demo",
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 最小 deps stub(actionResume 只触 store.stateFilePath 与 resumeRun(deps))。 */
|
|
69
|
+
function makeDeps(stateFilePath: string): unknown {
|
|
70
|
+
return {
|
|
71
|
+
runs: new Map(),
|
|
72
|
+
store: { stateFilePath: () => stateFilePath },
|
|
73
|
+
registry: { get: vi.fn(), getPath: vi.fn(), loadAll: vi.fn() },
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── actionResume:入口校验与接线 ─────────────────────────────
|
|
78
|
+
|
|
79
|
+
describe("actionResume", () => {
|
|
80
|
+
it("runId 缺失 → throw 用法指引(不触 resumeRun)", async () => {
|
|
81
|
+
await expect(
|
|
82
|
+
actionResume({ action: "resume" } as never, makeDeps("/nonexistent") as never),
|
|
83
|
+
).rejects.toThrow(/resume requires 'runId'/);
|
|
84
|
+
expect(vi.mocked(resumeRun)).not.toHaveBeenCalled();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("[§2.5 转发契约] args + journalDir 原样下传(判定在 core;journalDir = store 路径同目录,防双锚)", async () => {
|
|
88
|
+
const recordPath = join(tmpDir, "wf-test.record.jsonl");
|
|
89
|
+
const result = (await actionResume(
|
|
90
|
+
{ action: "resume", runId: "wf-test", args: { a: 1 } } as never,
|
|
91
|
+
makeDeps(recordPath) as never,
|
|
92
|
+
)) as { content: Array<{ text: string }>; details: Record<string, unknown> };
|
|
93
|
+
expect(vi.mocked(resumeRun)).toHaveBeenCalledTimes(1);
|
|
94
|
+
const [calledRunId, , options] = vi.mocked(resumeRun).mock.calls[0]!;
|
|
95
|
+
expect(calledRunId).toBe("wf-test");
|
|
96
|
+
expect((options as { args?: unknown }).args).toEqual({ a: 1 });
|
|
97
|
+
// core 缺省走模块锚;壳必须传 store 同源目录,否则多 session 场景读成「无记录」
|
|
98
|
+
expect((options as { journalDir?: string }).journalDir).toBe(tmpDir);
|
|
99
|
+
expect(result.content[0]!.text).toContain("Resuming workflow run wf-test");
|
|
100
|
+
expect(result.details).toMatchObject({ action: "resume", runId: "wf-test", status: "running" });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("[§2.5 转发契约] args 缺省 → options 不带 args 键(core 侧跳过比对)", async () => {
|
|
104
|
+
await actionResume(
|
|
105
|
+
{ action: "resume", runId: "wf-test" } as never,
|
|
106
|
+
makeDeps(join(tmpDir, "missing.record.jsonl")) as never,
|
|
107
|
+
);
|
|
108
|
+
expect(vi.mocked(resumeRun)).toHaveBeenCalledTimes(1);
|
|
109
|
+
const options = vi.mocked(resumeRun).mock.calls[0]![2] as Record<string, unknown>;
|
|
110
|
+
expect("args" in options).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("time 形参透传 budgetTimeMs(u2 偏差②:预算经 D14 同款通道传入)", async () => {
|
|
114
|
+
const recordPath = writeRecordStream([runCreatedFrame('{"a":1}')]);
|
|
115
|
+
await actionResume(
|
|
116
|
+
{ action: "resume", runId: "wf-test", args: { a: 1 }, time: 120_000 } as never,
|
|
117
|
+
makeDeps(recordPath) as never,
|
|
118
|
+
);
|
|
119
|
+
const options = vi.mocked(resumeRun).mock.calls[0]![2] as { budgetTimeMs?: number };
|
|
120
|
+
expect(options.budgetTimeMs).toBe(120_000);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("tokens 形参透传 budgetTokens(与 time 同款通道,三档回落与落盘归 core)", async () => {
|
|
124
|
+
const recordPath = writeRecordStream([runCreatedFrame('{"a":1}')]);
|
|
125
|
+
await actionResume(
|
|
126
|
+
{ action: "resume", runId: "wf-test", args: { a: 1 }, tokens: 50_000 } as never,
|
|
127
|
+
makeDeps(recordPath) as never,
|
|
128
|
+
);
|
|
129
|
+
const options = vi.mocked(resumeRun).mock.calls[0]![2] as { budgetTokens?: number };
|
|
130
|
+
expect(options.budgetTokens).toBe(50_000);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("tokens 缺省 → options 不带 budgetTokens 键(core 侧跳过覆盖、走三档回落)", async () => {
|
|
134
|
+
await actionResume(
|
|
135
|
+
{ action: "resume", runId: "wf-test" } as never,
|
|
136
|
+
makeDeps(join(tmpDir, "missing.record.jsonl")) as never,
|
|
137
|
+
);
|
|
138
|
+
const options = vi.mocked(resumeRun).mock.calls[0]![2] as Record<string, unknown>;
|
|
139
|
+
expect("budgetTokens" in options).toBe(false);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("tokens 负值 → 入口护栏拒绝(与 run action 同源 assertEntryTokenBudget)", async () => {
|
|
143
|
+
await expect(
|
|
144
|
+
actionResume(
|
|
145
|
+
{ action: "resume", runId: "wf-test", tokens: -1 } as never,
|
|
146
|
+
makeDeps("/nonexistent") as never,
|
|
147
|
+
),
|
|
148
|
+
).rejects.toThrow(/tokens/);
|
|
149
|
+
expect(vi.mocked(resumeRun)).not.toHaveBeenCalled();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("time 负值 → 入口护栏拒绝(与 run action 同源 assertEntryTimeBudget)", async () => {
|
|
153
|
+
await expect(
|
|
154
|
+
actionResume(
|
|
155
|
+
{ action: "resume", runId: "wf-test", time: -1 } as never,
|
|
156
|
+
makeDeps("/nonexistent") as never,
|
|
157
|
+
),
|
|
158
|
+
).rejects.toThrow(/time/);
|
|
159
|
+
expect(vi.mocked(resumeRun)).not.toHaveBeenCalled();
|
|
160
|
+
});
|
|
161
|
+
});
|
|
@@ -38,6 +38,8 @@ import {
|
|
|
38
38
|
Trace,
|
|
39
39
|
WorkflowRun,
|
|
40
40
|
WorkflowScriptRegistryImpl,
|
|
41
|
+
doneReasonToRunOutcome,
|
|
42
|
+
noteRebuiltSettlement,
|
|
41
43
|
parseResourceMeta,
|
|
42
44
|
setModelConfigService,
|
|
43
45
|
} from "@zhushanwen/subagent-core";
|
|
@@ -49,6 +51,9 @@ import {
|
|
|
49
51
|
// 被 mock 的模块——import 路径与被测源文件的等值实例(vi.mock hoist 后 barrel
|
|
50
52
|
// re-export 与深路径指向同一 mock 实例)
|
|
51
53
|
import { runWorkflow, abortRun } from "@zhushanwen/subagent-core/orchestration/lifecycle.ts";
|
|
54
|
+
// [D6(a) 第 1 步] 终局记录注册表清零面(换源后 done 判定唯一源 = 注册表,且注册表
|
|
55
|
+
// 按 runId 全局分区——用例间不清零会让复用 runId 的 fixture 跨用例污染)。
|
|
56
|
+
import { setRunEventJournalDirForTest } from "@zhushanwen/subagent-core/orchestration/terminal-actions.ts";
|
|
52
57
|
|
|
53
58
|
import { actionRun, registerWorkflowTool, TOOL_TOP_LEVEL } from "../tool-workflow.ts";
|
|
54
59
|
import { REENTRY_BUSY_MESSAGE, type ReentryGuardRef } from "../reentry-guard.ts";
|
|
@@ -65,6 +70,8 @@ vi.mock("@zhushanwen/subagent-core/orchestration/lifecycle.ts", () => ({
|
|
|
65
70
|
beforeEach(() => {
|
|
66
71
|
vi.mocked(runWorkflow).mockReset();
|
|
67
72
|
vi.mocked(runWorkflow).mockResolvedValue("run-id-1");
|
|
73
|
+
// 终局记录注册表清零(setRunEventJournalDirForTest 同实现内清注册表 + 活体态)
|
|
74
|
+
setRunEventJournalDirForTest(undefined);
|
|
68
75
|
});
|
|
69
76
|
|
|
70
77
|
afterEach(() => {
|
|
@@ -162,7 +169,7 @@ function makeRun(opts: {
|
|
|
162
169
|
startedAt: string;
|
|
163
170
|
completedAt?: string;
|
|
164
171
|
}): WorkflowRun {
|
|
165
|
-
|
|
172
|
+
const run = WorkflowRun.reconstruct(
|
|
166
173
|
opts.runId,
|
|
167
174
|
{
|
|
168
175
|
scriptSource: "// stub source",
|
|
@@ -181,6 +188,16 @@ function makeRun(opts: {
|
|
|
181
188
|
},
|
|
182
189
|
{ startedAt: opts.startedAt, completedAt: opts.completedAt },
|
|
183
190
|
);
|
|
191
|
+
// [D6(a) 第 1 步] 终局判定源 = 终局记录注册表:done 形态 fixture 建模「重水合
|
|
192
|
+
// done run」时必须携带注册表条目(生产 = 壳重建点 noteRebuiltSettlement 注入
|
|
193
|
+
// run-settled 帧事实;settledAt = 快照 completedAt = 帧时序)。
|
|
194
|
+
if (opts.status === "done") {
|
|
195
|
+
noteRebuiltSettlement(opts.runId, {
|
|
196
|
+
outcome: doneReasonToRunOutcome(opts.reason ?? "completed"),
|
|
197
|
+
settledAt: opts.completedAt !== undefined ? Date.parse(opts.completedAt) : 0,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
return run;
|
|
184
201
|
}
|
|
185
202
|
|
|
186
203
|
describe("actionStatus 输出形态(LLM 可见文本锁)", () => {
|
|
@@ -790,14 +807,18 @@ describe("abort 转移文案全文锚定(LLM 可见文本锁)", () => {
|
|
|
790
807
|
) => Promise<AbortResult>;
|
|
791
808
|
};
|
|
792
809
|
|
|
793
|
-
/** mock abortRun 落位终态(模拟 lifecycle transition 语义:status=done + 可选 reason)。
|
|
794
|
-
|
|
810
|
+
/** mock abortRun 落位终态(模拟 lifecycle transition 语义:status=done + 可选 reason)。
|
|
811
|
+
* `settle: false` = abort 未产生终局记录(换源后 run 保持 running 投影)。 */
|
|
812
|
+
function stubAbortTransition(opts: { reason?: string; settle?: boolean }): void {
|
|
795
813
|
vi.mocked(abortRun).mockReset();
|
|
796
814
|
vi.mocked(abortRun).mockImplementation(async (runId, deps) => {
|
|
797
815
|
const run = deps.runs.get(runId);
|
|
798
|
-
if (run) {
|
|
816
|
+
if (run && (opts.settle ?? true)) {
|
|
799
817
|
run.state.status = "done";
|
|
800
818
|
run.state.reason = opts.reason;
|
|
819
|
+
// [D6(a) 第 1 步] 终局判定源 = 终局记录注册表:stub 直改状态须同步注入终局
|
|
820
|
+
// 事实(生产 abortRun 经 dispatch 链 note)——否则 runSummary 投影回退 running。
|
|
821
|
+
noteRebuiltSettlement(runId, { outcome: "cancelled", settledAt: Date.now() });
|
|
801
822
|
}
|
|
802
823
|
});
|
|
803
824
|
}
|
|
@@ -817,8 +838,11 @@ describe("abort 转移文案全文锚定(LLM 可见文本锁)", () => {
|
|
|
817
838
|
expect(r.details).toMatchObject({ action: "abort", runId: run.runId, status: "done", reason: "aborted" });
|
|
818
839
|
});
|
|
819
840
|
|
|
820
|
-
it("
|
|
821
|
-
|
|
841
|
+
it("无终局记录 → 转移段无后缀(reasonSuffix 条件拼接;[D6(a) 第 1 步] 换源后「未终局」不再投影 done/reason)", async () => {
|
|
842
|
+
// 换源前本用例靠「聚合 done 而注册表 miss」造出 done+无 reason 形态——该形态随
|
|
843
|
+
// R6 判据换源消失(done ⟺ 注册表有条目 ⟺ reason 由 (outcome,errorCode) 恒派生)。
|
|
844
|
+
// 现锁同一条拼接分支的可达形态:无终局记录 = 未终局,status 与 reason 都不投影。
|
|
845
|
+
stubAbortTransition({ settle: false });
|
|
822
846
|
const run = makeRun({
|
|
823
847
|
runId: "wf-1719600000000-z9y8x7",
|
|
824
848
|
scriptName: "cleanup-wf",
|
|
@@ -828,8 +852,8 @@ describe("abort 转移文案全文锚定(LLM 可见文本锁)", () => {
|
|
|
828
852
|
const tool = captureRegisteredTool<AbortToolView>(new Map([[run.runId, run]]));
|
|
829
853
|
|
|
830
854
|
const r = await tool.execute("id", { action: "abort", runId: run.runId }, undefined, undefined, {});
|
|
831
|
-
expect(r.content[0]?.text).toBe("Workflow 'cleanup-wf' (wf-1719600000000-z9y8x7): running →
|
|
832
|
-
expect(r.details).toMatchObject({ action: "abort", runId: run.runId, status: "
|
|
855
|
+
expect(r.content[0]?.text).toBe("Workflow 'cleanup-wf' (wf-1719600000000-z9y8x7): running → running");
|
|
856
|
+
expect(r.details).toMatchObject({ action: "abort", runId: run.runId, status: "running" });
|
|
833
857
|
});
|
|
834
858
|
});
|
|
835
859
|
|
|
@@ -847,7 +871,7 @@ function rflParams(): object {
|
|
|
847
871
|
const src = readFileSync(
|
|
848
872
|
join(
|
|
849
873
|
__dirname,
|
|
850
|
-
"
|
|
874
|
+
"../../../../node_modules/@zhushanwen/subagent-core/workflows/review-fix-loop.js",
|
|
851
875
|
),
|
|
852
876
|
"utf-8",
|
|
853
877
|
);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// src/interface/subagent-tool.ts
|
|
1
|
+
// src/interface/tool/subagent-tool.ts
|
|
2
2
|
//
|
|
3
3
|
// `subagent` LLM 工具。薄壳——参数解析 + 调 runtime.execute。
|
|
4
4
|
// 不创建 state、不节流 onUpdate、不持久化(全部在 runtime 层统一)。
|
|
@@ -27,9 +27,9 @@ import {
|
|
|
27
27
|
messageHandler,
|
|
28
28
|
startHandler,
|
|
29
29
|
} from "@zhushanwen/subagent-core";
|
|
30
|
-
import { extractAgentName } from "
|
|
31
|
-
import { toGuiCtx } from "
|
|
32
|
-
import { adapter } from "
|
|
30
|
+
import { extractAgentName } from "../format/format.ts";
|
|
31
|
+
import { toGuiCtx } from "../gui/gui-mappers.ts";
|
|
32
|
+
import { adapter } from "../gui/subagent-actions.ts";
|
|
33
33
|
import { SubagentParams } from "./subagent-tool-schema.ts";
|
|
34
34
|
import { type RenderContext,renderSubagentCall, renderSubagentResult } from "./tool-render.ts";
|
|
35
35
|
import { toErrorMessage } from "@zhushanwen/pi-ext-guards";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// src/interface/tool-render.ts
|
|
1
|
+
// src/interface/tool/tool-render.ts
|
|
2
2
|
//
|
|
3
3
|
// 对话流 tool block 渲染。renderCall(标题行)+ renderResult(背景色 block)。
|
|
4
4
|
//
|
|
@@ -32,8 +32,8 @@ import {
|
|
|
32
32
|
formatElapsedSeconds,
|
|
33
33
|
statusGlyph,
|
|
34
34
|
type ThemeLike,
|
|
35
|
-
} from "
|
|
36
|
-
import { truncLine } from "
|
|
35
|
+
} from "../format/format.ts";
|
|
36
|
+
import { truncLine } from "../tui/tui-kit.ts";
|
|
37
37
|
|
|
38
38
|
// ============================================================
|
|
39
39
|
// 常量
|
|
@@ -29,7 +29,7 @@ import type { GuiComponent, GuiContext, GuiRenderResult } from "@zhushanwen/exte
|
|
|
29
29
|
import { guiResult, isGuiCapable } from "@zhushanwen/extension-protocol";
|
|
30
30
|
import type { RunSpec, WorkflowScript } from "@zhushanwen/subagent-core";
|
|
31
31
|
import { toErrorMessage } from "@zhushanwen/pi-ext-guards";
|
|
32
|
-
import { renderTextFallback } from "
|
|
32
|
+
import { renderTextFallback } from "../format/format.ts";
|
|
33
33
|
|
|
34
34
|
/** renderResult 回调的宽入参形态(content 可缺省,由 renderTextFallback 兜底)。 */
|
|
35
35
|
export interface RenderableToolResult {
|