@zhushanwen/pi-subagent-workflow 9.0.1 → 9.1.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 +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,595 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WorkflowsView 交互面驱动测试(createWorkflowsView 返回的 Component 全行为面)。
|
|
3
|
+
*
|
|
4
|
+
* 与 WorkflowsView.test.ts(纯函数三块:渲染签名 / live 配对 / detail session 行)
|
|
5
|
+
* 互补无断言重叠。本文件经 fake ctx.ui.custom 捕获 Component 后直接驱动:
|
|
6
|
+
* - render 三级布局(L0 phase 列表 / L1 agent 列表 / L2 详情 + 位置指示)
|
|
7
|
+
* - 按键链:escape 降级与关闭、↑↓ 导航、enter 下钻与 prompt 展开、
|
|
8
|
+
* PgUp/PgDn/Home/End 详情滚动、a abort、s save overlay 全编辑键、S trace 导出
|
|
9
|
+
* - 200ms tick 条件失效(签名同跳过重绘 / trace 变化重绘 / disposed 空转)
|
|
10
|
+
* - live 进度注入(liveRecords → L1 行与 L2 详情的 live 路径)
|
|
11
|
+
*
|
|
12
|
+
* mock 策略:saveWorkflow 深路径 stub(对齐 commands-resume.test.ts 的
|
|
13
|
+
* barrel-re-export 捕获形态);saveTraceToFile 的 fs 调用经 spyOn fsPromises
|
|
14
|
+
* 拦截(不触真实盘——trace 导出目标目录由 pi-coding-agent mock 的 getAgentDir
|
|
15
|
+
* 提供,指向不存在的路径,spyOn 后不会发起真实 mkdir)。
|
|
16
|
+
*/
|
|
17
|
+
import { promises as fsPromises } from "node:fs";
|
|
18
|
+
|
|
19
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
20
|
+
|
|
21
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
22
|
+
import { noteRebuiltSettlement } from "@zhushanwen/subagent-core";
|
|
23
|
+
import { saveWorkflow } from "@zhushanwen/subagent-core";
|
|
24
|
+
import type { AgentEventLogEntry, ExecutionTraceNode, SubagentRecord, WorkerLogEntry, WorkflowRun } from "@zhushanwen/subagent-core";
|
|
25
|
+
|
|
26
|
+
import type { ThemeLike } from "../../../format/format.ts";
|
|
27
|
+
import { createWorkflowsView } from "../WorkflowsView.ts";
|
|
28
|
+
|
|
29
|
+
vi.mock("@zhushanwen/subagent-core/orchestration/workflow-files.ts", () => ({
|
|
30
|
+
saveWorkflow: vi.fn(),
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
// ── 键序列(pi-tui 实装转义序列,与 list-component.test.ts 同源) ──
|
|
34
|
+
|
|
35
|
+
const ESC = "\x1b";
|
|
36
|
+
const UP = "\x1b[A";
|
|
37
|
+
const DOWN = "\x1b[B";
|
|
38
|
+
const ENTER = "\r";
|
|
39
|
+
const BACKSPACE = "\x7f";
|
|
40
|
+
const PAGE_UP = "\x1b[5~";
|
|
41
|
+
const PAGE_DOWN = "\x1b[6~";
|
|
42
|
+
const HOME = "\x1b[H";
|
|
43
|
+
const END = "\x1b[F";
|
|
44
|
+
|
|
45
|
+
const T0 = 1_700_000_000_000;
|
|
46
|
+
|
|
47
|
+
// ── fixtures ─────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
/** plain theme:着色方法原样返回(断言业务文本而非 ANSI 码)。 */
|
|
50
|
+
const plainTheme: ThemeLike = {
|
|
51
|
+
bg: (_color: string, text: string) => text,
|
|
52
|
+
fg: (_tag: string, text: string) => text,
|
|
53
|
+
bold: (text: string) => text,
|
|
54
|
+
underline: (text: string) => text,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
function makeNode(stepIndex: number, over: Partial<ExecutionTraceNode> = {}): ExecutionTraceNode {
|
|
58
|
+
return {
|
|
59
|
+
stepIndex,
|
|
60
|
+
agent: "worker",
|
|
61
|
+
task: `task ${stepIndex} do things`,
|
|
62
|
+
model: "test-model",
|
|
63
|
+
status: "running",
|
|
64
|
+
phase: "build",
|
|
65
|
+
startedAt: new Date(T0 + stepIndex * 1000).toISOString(),
|
|
66
|
+
...over,
|
|
67
|
+
} as ExecutionTraceNode;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 带两个显式 phase(build×2 + deploy×1)与一个无 phase 节点的默认 trace。 */
|
|
71
|
+
function defaultNodes(): ExecutionTraceNode[] {
|
|
72
|
+
return [
|
|
73
|
+
makeNode(0),
|
|
74
|
+
makeNode(1),
|
|
75
|
+
makeNode(2, { phase: "deploy" }),
|
|
76
|
+
makeNode(3, { phase: undefined }),
|
|
77
|
+
];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let driverRunSeq = 0;
|
|
81
|
+
|
|
82
|
+
function makeRun(
|
|
83
|
+
nodes: ExecutionTraceNode[],
|
|
84
|
+
opts: { status?: string; errorLogs?: WorkerLogEntry[]; description?: string } = {},
|
|
85
|
+
): WorkflowRun {
|
|
86
|
+
const runId = `wf-driver-${driverRunSeq++}`;
|
|
87
|
+
const status = opts.status ?? "running";
|
|
88
|
+
if (status === "done") {
|
|
89
|
+
noteRebuiltSettlement(runId, { outcome: "done", settledAt: Date.parse("2026-09-27T00:00:00.000Z") });
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
runId,
|
|
93
|
+
spec: { scriptName: "driver-wf", slug: "dw", description: opts.description ?? "driver workflow" },
|
|
94
|
+
meta: { startedAt: new Date(T0).toISOString() },
|
|
95
|
+
state: {
|
|
96
|
+
status,
|
|
97
|
+
budget: { usedTokens: 5000, maxTokens: 200_000, usedCost: 0.0123 },
|
|
98
|
+
trace: { toArray: () => nodes },
|
|
99
|
+
errorLogs: opts.errorLogs ?? [],
|
|
100
|
+
},
|
|
101
|
+
} as unknown as WorkflowRun;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function makeSub(over: Partial<SubagentRecord> = {}): SubagentRecord {
|
|
105
|
+
return {
|
|
106
|
+
id: "sa-driver",
|
|
107
|
+
task: "task 0 do things",
|
|
108
|
+
status: "running",
|
|
109
|
+
startedAt: T0,
|
|
110
|
+
turns: 2,
|
|
111
|
+
totalTokens: 1200,
|
|
112
|
+
eventLog: [
|
|
113
|
+
{ type: "tool_start", label: "read", ts: T0 },
|
|
114
|
+
{ type: "tool_start", label: "write", ts: T0 },
|
|
115
|
+
] as AgentEventLogEntry[],
|
|
116
|
+
...over,
|
|
117
|
+
} as SubagentRecord;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── 驱动器 ───────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
interface ViewComponent { // oe-exempt:20260930:test:test double shape for component capture (test infra)
|
|
123
|
+
render(width: number): string[];
|
|
124
|
+
handleInput(data: string): void;
|
|
125
|
+
invalidate(): void;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
interface Driver { // oe-exempt:20260930:test:test fixture harness shape (test infra)
|
|
129
|
+
nodes: ExecutionTraceNode[];
|
|
130
|
+
component: ViewComponent;
|
|
131
|
+
tui: { terminal: { columns: number; rows: number }; requestRender: ReturnType<typeof vi.fn> };
|
|
132
|
+
notifies: Array<{ msg: string; level: string }>;
|
|
133
|
+
abort: ReturnType<typeof vi.fn>;
|
|
134
|
+
done: Promise<void>;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function openDriver(opts: {
|
|
138
|
+
nodes?: ExecutionTraceNode[];
|
|
139
|
+
status?: string;
|
|
140
|
+
errorLogs?: WorkerLogEntry[];
|
|
141
|
+
description?: string;
|
|
142
|
+
liveRecords?: () => SubagentRecord[];
|
|
143
|
+
abortImpl?: (runId: string) => Promise<void>;
|
|
144
|
+
} = {}): Driver {
|
|
145
|
+
const nodes = opts.nodes ?? defaultNodes();
|
|
146
|
+
const run = makeRun(nodes, opts);
|
|
147
|
+
const tui = { terminal: { columns: 100, rows: 30 }, requestRender: vi.fn() };
|
|
148
|
+
const notifies: Array<{ msg: string; level: string }> = [];
|
|
149
|
+
let component!: ViewComponent;
|
|
150
|
+
let resolveDone!: () => void;
|
|
151
|
+
const done = new Promise<void>((resolve) => {
|
|
152
|
+
resolveDone = resolve;
|
|
153
|
+
});
|
|
154
|
+
const ctx = {
|
|
155
|
+
mode: "tui",
|
|
156
|
+
ui: {
|
|
157
|
+
custom: (
|
|
158
|
+
factory: (t: unknown, th: unknown, kb: unknown, d: (v: void) => void) => ViewComponent,
|
|
159
|
+
): Promise<void> => {
|
|
160
|
+
component = factory(tui, plainTheme, {}, () => resolveDone());
|
|
161
|
+
return done;
|
|
162
|
+
},
|
|
163
|
+
notify: (msg: string, level: string) => {
|
|
164
|
+
notifies.push({ msg, level });
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
} as unknown as ExtensionContext;
|
|
168
|
+
const abort = vi.fn(opts.abortImpl ?? (() => Promise.resolve()));
|
|
169
|
+
void createWorkflowsView(run, plainTheme, ctx, { abort }, "/state/snapshots/wf.json", opts.liveRecords);
|
|
170
|
+
return { nodes, component, tui, notifies, abort, done };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** 微任务冲刷(abort/saveWorkflow/saveTraceToFile 的 .then 链在断言前落定)。 */
|
|
174
|
+
async function flush(): Promise<void> {
|
|
175
|
+
for (let i = 0; i < 6; i++) await Promise.resolve();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
beforeEach(() => {
|
|
179
|
+
vi.mocked(saveWorkflow).mockReset();
|
|
180
|
+
vi.useFakeTimers();
|
|
181
|
+
vi.setSystemTime(T0);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
afterEach(() => {
|
|
185
|
+
vi.useRealTimers();
|
|
186
|
+
vi.restoreAllMocks();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// ── 渲染面 ───────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
describe("WorkflowsView L0 渲染", () => {
|
|
192
|
+
it("初绘:header(名/slug/描述/state 路径)+ phase 列表 + 右侧 agent 概览 + footer", () => {
|
|
193
|
+
const d = openDriver();
|
|
194
|
+
const lines = d.component.render(100);
|
|
195
|
+
const joined = lines.join("\n");
|
|
196
|
+
expect(lines[0]).toContain("╭");
|
|
197
|
+
expect(joined).toContain("driver-wf");
|
|
198
|
+
expect(joined).toContain("dw");
|
|
199
|
+
expect(joined).toContain("driver workflow");
|
|
200
|
+
expect(joined).toContain("state: /state/snapshots/wf.json");
|
|
201
|
+
expect(joined).toContain("1 build 0/2");
|
|
202
|
+
expect(joined).toContain("2 deploy 0/1");
|
|
203
|
+
expect(joined).toContain("3 (unnamed) 0/1");
|
|
204
|
+
expect(joined).toContain("2 agents");
|
|
205
|
+
// footer:running 态含 abort 快捷键
|
|
206
|
+
expect(joined).toContain("↑↓ phase · ⏎ enter");
|
|
207
|
+
expect(joined).toContain("a abort");
|
|
208
|
+
expect(joined).toContain("esc back");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("无 description 的 run:header 仍渲染右侧状态段(walled 分支)", () => {
|
|
212
|
+
const d = openDriver({ description: undefined });
|
|
213
|
+
const joined = d.component.render(100).join("\n");
|
|
214
|
+
expect(joined).toContain("driver-wf");
|
|
215
|
+
expect(joined).toContain("0/4 agents");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("render 缓存命中返回同一数组;invalidate 后重建", () => {
|
|
219
|
+
const d = openDriver();
|
|
220
|
+
const first = d.component.render(100);
|
|
221
|
+
expect(d.component.render(100)).toBe(first);
|
|
222
|
+
d.component.invalidate();
|
|
223
|
+
expect(d.component.render(100)).not.toBe(first);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe("WorkflowsView 导航(↑↓/enter/escape)", () => {
|
|
228
|
+
it("L0 ↓ 切 phase:选中标记移到第二项,右侧列跟随", () => {
|
|
229
|
+
const d = openDriver();
|
|
230
|
+
d.component.handleInput(DOWN);
|
|
231
|
+
const lines = d.component.render(100);
|
|
232
|
+
const joined = lines.join("\n");
|
|
233
|
+
expect(joined).toContain("❯ ● 2 deploy");
|
|
234
|
+
expect(joined).not.toContain("❯ ● 1 build");
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("L0 enter → L1:footer 切 agent 提示,右列渲染 agent 行(live 缺省走终态统计)", () => {
|
|
238
|
+
const d = openDriver();
|
|
239
|
+
d.component.handleInput(ENTER);
|
|
240
|
+
const lines = d.component.render(100);
|
|
241
|
+
const joined = lines.join("\n");
|
|
242
|
+
expect(joined).toContain("↑↓ agent · ⏎ detail");
|
|
243
|
+
expect(joined).toContain("worker");
|
|
244
|
+
expect(joined).toContain("test-model");
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("L1 enter → L2 详情:Detail 标题 + Prompt 段 + Outcome;enter 切换 prompt 展开", () => {
|
|
248
|
+
const d = openDriver();
|
|
249
|
+
d.component.handleInput(ENTER); // L1
|
|
250
|
+
d.component.handleInput(ENTER); // L2
|
|
251
|
+
const folded = d.component.render(100).join("\n");
|
|
252
|
+
expect(folded).toContain("Detail");
|
|
253
|
+
expect(folded).toContain("Prompt · 1 lines · ⏎ expand");
|
|
254
|
+
expect(folded).toContain("Still running...");
|
|
255
|
+
|
|
256
|
+
d.component.handleInput(ENTER); // 展开 prompt
|
|
257
|
+
const expanded = d.component.render(100).join("\n");
|
|
258
|
+
expect(expanded).toContain("⏎ collapse");
|
|
259
|
+
expect(expanded).toContain("task 0 do things");
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("多行 prompt 折叠:显示前 3 行 + more lines 计数;展开后全量", () => {
|
|
263
|
+
const task = Array.from({ length: 6 }, (_, i) => `line-${i}`).join("\n");
|
|
264
|
+
const d = openDriver({ nodes: [makeNode(0, { task })] });
|
|
265
|
+
d.component.handleInput(ENTER);
|
|
266
|
+
d.component.handleInput(ENTER);
|
|
267
|
+
const folded = d.component.render(100).join("\n");
|
|
268
|
+
expect(folded).toContain("Prompt · 6 lines · ⏎ expand");
|
|
269
|
+
expect(folded).toContain("3 more lines");
|
|
270
|
+
d.component.handleInput(ENTER);
|
|
271
|
+
const expanded = d.component.render(100).join("\n");
|
|
272
|
+
expect(expanded).toContain("line-5");
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("escape 逐级降级;L0 escape 关闭 overlay(done 落定);关闭后按键与 tick 空转", async () => {
|
|
276
|
+
const d = openDriver();
|
|
277
|
+
d.component.handleInput(ENTER);
|
|
278
|
+
d.component.handleInput(ENTER);
|
|
279
|
+
expect(d.component.render(100).join("\n")).toContain("PgUp/PgDn scroll");
|
|
280
|
+
d.component.handleInput(ESC);
|
|
281
|
+
expect(d.component.render(100).join("\n")).toContain("⏎ detail");
|
|
282
|
+
d.component.handleInput(ESC);
|
|
283
|
+
expect(d.component.render(100).join("\n")).toContain("⏎ enter");
|
|
284
|
+
d.component.handleInput(ESC);
|
|
285
|
+
await d.done;
|
|
286
|
+
// disposed 后:handleInput 不再消费(abort 不触发)、tick 不再重绘
|
|
287
|
+
const callsBefore = d.tui.requestRender.mock.calls.length;
|
|
288
|
+
d.component.handleInput("a");
|
|
289
|
+
expect(d.abort).not.toHaveBeenCalled();
|
|
290
|
+
vi.advanceTimersByTime(600);
|
|
291
|
+
expect(d.tui.requestRender.mock.calls.length).toBe(callsBefore);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("L2 ↑↓ 切 agent:重置详情滚动与 prompt 展开", () => {
|
|
295
|
+
const d = openDriver();
|
|
296
|
+
d.component.handleInput(ENTER); // L1
|
|
297
|
+
d.component.handleInput(ENTER); // L2
|
|
298
|
+
d.component.handleInput(ENTER); // 展开 prompt
|
|
299
|
+
expect(d.component.render(100).join("\n")).toContain("⏎ collapse");
|
|
300
|
+
d.component.handleInput(DOWN); // 切到本 phase 第二个 agent
|
|
301
|
+
const joined = d.component.render(100).join("\n");
|
|
302
|
+
expect(joined).toContain("task 1 do things");
|
|
303
|
+
expect(joined).toContain("⏎ expand");
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
describe("WorkflowsView L2 详情滚动(PgUp/PgDn/Home/End + 位置指示)", () => {
|
|
308
|
+
/** 长 task(30 行)保证 detail 内容超出视口(rows=30 → viewH=14)。 */
|
|
309
|
+
function longTaskDriver(): Driver {
|
|
310
|
+
const task = Array.from({ length: 30 }, (_, i) => `prompt-line-${i}`).join("\n");
|
|
311
|
+
return openDriver({ nodes: [makeNode(0, { task })] });
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
it("running + followTail 默认钉底:指示显示末窗;PgUp 上滚脱离跟随;Home 回顶;End 回底", () => {
|
|
315
|
+
const d = longTaskDriver();
|
|
316
|
+
d.component.handleInput(ENTER); // L1
|
|
317
|
+
d.component.handleInput(ENTER); // L2
|
|
318
|
+
d.component.handleInput(ENTER); // 展开 prompt(30 行 → 内容超出视口)
|
|
319
|
+
const bottom = d.component.render(100).join("\n");
|
|
320
|
+
expect(bottom).toMatch(/Detail \(\d+-\d+\/\d+\)/); // 位置指示(内容 > 视口才出现)
|
|
321
|
+
expect(bottom).toContain("prompt-line-29");
|
|
322
|
+
|
|
323
|
+
d.component.handleInput(PAGE_UP);
|
|
324
|
+
const paged = d.component.render(100).join("\n");
|
|
325
|
+
expect(paged).toContain("prompt-line-15");
|
|
326
|
+
expect(paged).not.toContain("prompt-line-29");
|
|
327
|
+
|
|
328
|
+
d.component.handleInput(HOME);
|
|
329
|
+
const top = d.component.render(100).join("\n");
|
|
330
|
+
expect(top).toContain("prompt-line-0");
|
|
331
|
+
expect(top).not.toContain("prompt-line-14");
|
|
332
|
+
|
|
333
|
+
d.component.handleInput(END);
|
|
334
|
+
expect(d.component.render(100).join("\n")).toContain("prompt-line-29");
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("PgDn 向下翻页;未命中滚动键回退导航链(up/down 仍切 agent)", () => {
|
|
338
|
+
const d = longTaskDriver();
|
|
339
|
+
d.component.handleInput(ENTER);
|
|
340
|
+
d.component.handleInput(ENTER);
|
|
341
|
+
d.component.handleInput(ENTER); // 展开 prompt
|
|
342
|
+
d.component.handleInput(HOME);
|
|
343
|
+
d.component.handleInput(PAGE_DOWN);
|
|
344
|
+
const paged = d.component.render(100).join("\n");
|
|
345
|
+
expect(paged).toContain("prompt-line-14");
|
|
346
|
+
|
|
347
|
+
// 非 L2 滚动键(如 'w')落到快捷键链尾(无副作用),up 仍走导航链——
|
|
348
|
+
// 单 agent 场景两者都不改滚动窗口(offset 保持 14)
|
|
349
|
+
d.component.handleInput("w");
|
|
350
|
+
d.component.handleInput(UP);
|
|
351
|
+
const joined = d.component.render(100).join("\n");
|
|
352
|
+
expect(joined).toContain("prompt-line-14");
|
|
353
|
+
expect(joined).not.toContain("prompt-line-28");
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
describe("WorkflowsView abort / 快捷键", () => {
|
|
358
|
+
it("running 时 'a' → actions.abort(runId),成功后重绘", async () => {
|
|
359
|
+
const d = openDriver();
|
|
360
|
+
d.component.handleInput("a");
|
|
361
|
+
expect(d.abort).toHaveBeenCalledWith(expect.any(String));
|
|
362
|
+
await flush();
|
|
363
|
+
expect(d.tui.requestRender).toHaveBeenCalled();
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it("abort 拒绝 → notify error(不静默吞)", async () => {
|
|
367
|
+
const d = openDriver({ abortImpl: () => Promise.reject(new Error("boom")) });
|
|
368
|
+
d.component.handleInput("a");
|
|
369
|
+
await flush();
|
|
370
|
+
expect(d.notifies).toEqual([{ msg: "Abort failed: boom", level: "error" }]);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it("非 running(done)时 'a' 不触发 abort", () => {
|
|
374
|
+
const d = openDriver({ status: "done", nodes: [makeNode(0, { status: "completed" })] });
|
|
375
|
+
d.component.handleInput("a");
|
|
376
|
+
expect(d.abort).not.toHaveBeenCalled();
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
describe("WorkflowsView save overlay('s')", () => {
|
|
381
|
+
function openSave(): Driver {
|
|
382
|
+
const d = openDriver();
|
|
383
|
+
d.component.handleInput("s");
|
|
384
|
+
return d;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
it("'s' 进入 save mode:标题/目的地预览/输入行(初值 scriptName)", () => {
|
|
388
|
+
const d = openSave();
|
|
389
|
+
const joined = d.component.render(100).join("\n");
|
|
390
|
+
expect(joined).toContain("Save dynamic workflow");
|
|
391
|
+
expect(joined).toContain(".pi/workflows/driver-wf.js");
|
|
392
|
+
expect(joined).toContain("> driver-wf\u2588");
|
|
393
|
+
expect(joined).toContain("Enter to save · Esc to cancel");
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it("可打印字符追加 + backspace 删除(输入行实时可见)", () => {
|
|
397
|
+
const d = openSave();
|
|
398
|
+
d.component.handleInput("x");
|
|
399
|
+
expect(d.component.render(100).join("\n")).toContain("> driver-wfx\u2588");
|
|
400
|
+
d.component.handleInput(BACKSPACE);
|
|
401
|
+
expect(d.component.render(100).join("\n")).toContain("> driver-wf\u2588");
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it("空名 enter → Please enter a name;esc 退出 save mode", () => {
|
|
405
|
+
const d = openSave();
|
|
406
|
+
for (let i = 0; i < "driver-wf".length; i++) d.component.handleInput(BACKSPACE);
|
|
407
|
+
d.component.handleInput(ENTER);
|
|
408
|
+
expect(d.component.render(100).join("\n")).toContain("Please enter a name");
|
|
409
|
+
d.component.handleInput(ESC);
|
|
410
|
+
expect(d.component.render(100).join("\n")).not.toContain("Save dynamic workflow");
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it("enter 保存成功:saveWorkflow 接线(scriptName, 输入名),overlay 关闭", async () => {
|
|
414
|
+
vi.mocked(saveWorkflow).mockResolvedValue("Saved to .pi/workflows/saved-wf.js");
|
|
415
|
+
const d = openSave();
|
|
416
|
+
for (let i = 0; i < "driver-wf".length; i++) d.component.handleInput(BACKSPACE);
|
|
417
|
+
for (const ch of "saved-wf") d.component.handleInput(ch);
|
|
418
|
+
d.component.handleInput(ENTER);
|
|
419
|
+
expect(vi.mocked(saveWorkflow)).toHaveBeenCalledWith("driver-wf", "saved-wf");
|
|
420
|
+
await flush();
|
|
421
|
+
const joined = d.component.render(100).join("\n");
|
|
422
|
+
expect(joined).not.toContain("Save dynamic workflow");
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it("enter 保存失败:错误消息回显在 overlay(不关闭)", async () => {
|
|
426
|
+
vi.mocked(saveWorkflow).mockRejectedValue(new Error("refusing to overwrite non-tmp workflow"));
|
|
427
|
+
const d = openSave();
|
|
428
|
+
d.component.handleInput(ENTER);
|
|
429
|
+
await flush();
|
|
430
|
+
const joined = d.component.render(100).join("\n");
|
|
431
|
+
expect(joined).toContain("refusing to overwrite non-tmp workflow");
|
|
432
|
+
expect(joined).toContain("Save dynamic workflow");
|
|
433
|
+
});
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
describe("WorkflowsView trace 导出('S')", () => {
|
|
437
|
+
it("导出 Markdown:mkdir→writeFile 链 + 成功 notify(含 header/phase/节点全段)", async () => {
|
|
438
|
+
const mkdir = vi.spyOn(fsPromises, "mkdir").mockResolvedValue(undefined);
|
|
439
|
+
const writeFile = vi.spyOn(fsPromises, "writeFile").mockResolvedValue(undefined);
|
|
440
|
+
const nodes = [
|
|
441
|
+
makeNode(0, {
|
|
442
|
+
status: "completed",
|
|
443
|
+
completedAt: new Date(T0 + 60_000).toISOString(),
|
|
444
|
+
result: {
|
|
445
|
+
content: "all done",
|
|
446
|
+
usage: { input: 900, output: 100 },
|
|
447
|
+
toolCalls: [{ name: "read", input: "/a.ts" }],
|
|
448
|
+
},
|
|
449
|
+
}),
|
|
450
|
+
];
|
|
451
|
+
const d = openDriver({ nodes, errorLogs: [{ level: "error", message: "E-1" }] });
|
|
452
|
+
d.component.handleInput("S");
|
|
453
|
+
await flush();
|
|
454
|
+
expect(mkdir).toHaveBeenCalledWith(expect.stringContaining("workflow-traces"), { recursive: true });
|
|
455
|
+
expect(writeFile).toHaveBeenCalledTimes(1);
|
|
456
|
+
const [path, content] = vi.mocked(writeFile).mock.calls[0] as unknown as [string, string];
|
|
457
|
+
expect(path).toContain("workflow-traces/");
|
|
458
|
+
expect(path).toMatch(/wf-driver-\d+\.md$/);
|
|
459
|
+
expect(content).toContain("# Workflow Trace: driver-wf");
|
|
460
|
+
expect(content).toContain("Status: running");
|
|
461
|
+
expect(content).toContain("Budget: 5000/200000 tokens, $0.0123");
|
|
462
|
+
expect(content).toContain("## Phase: build");
|
|
463
|
+
expect(content).toContain("### [#0] worker — completed");
|
|
464
|
+
expect(content).toContain("**Activity:**");
|
|
465
|
+
expect(content).toContain("- read(/a.ts)");
|
|
466
|
+
expect(content).toContain("all done");
|
|
467
|
+
expect(d.notifies[0]?.level).toBe("info");
|
|
468
|
+
expect(d.notifies[0]?.msg).toContain("Trace saved: ");
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
it("导出失败(mkdir 拒绝)→ notify error(Save failed 指引)", async () => {
|
|
472
|
+
vi.spyOn(fsPromises, "mkdir").mockRejectedValue(new Error("EACCES: no perms"));
|
|
473
|
+
const d = openDriver();
|
|
474
|
+
d.component.handleInput("S");
|
|
475
|
+
await flush();
|
|
476
|
+
expect(d.notifies).toEqual([{ msg: "Save failed: EACCES: no perms", level: "error" }]);
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
describe("WorkflowsView tick 条件失效(200ms)", () => {
|
|
481
|
+
it("首 tick 必失效重绘;签名不变跳过;trace 变化后恢复重绘", () => {
|
|
482
|
+
const d = openDriver();
|
|
483
|
+
vi.advanceTimersByTime(200);
|
|
484
|
+
expect(d.tui.requestRender).toHaveBeenCalledTimes(1);
|
|
485
|
+
vi.advanceTimersByTime(200);
|
|
486
|
+
expect(d.tui.requestRender).toHaveBeenCalledTimes(1); // 同秒桶同签名 → 跳过
|
|
487
|
+
d.nodes.push(makeNode(4));
|
|
488
|
+
vi.advanceTimersByTime(200);
|
|
489
|
+
expect(d.tui.requestRender).toHaveBeenCalledTimes(2);
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it("live record 变化驱动重绘(签名含 live 字段)", () => {
|
|
493
|
+
const subs = [makeSub()];
|
|
494
|
+
const d = openDriver({ liveRecords: () => subs });
|
|
495
|
+
vi.advanceTimersByTime(200);
|
|
496
|
+
expect(d.tui.requestRender).toHaveBeenCalledTimes(1);
|
|
497
|
+
vi.advanceTimersByTime(200);
|
|
498
|
+
expect(d.tui.requestRender).toHaveBeenCalledTimes(1);
|
|
499
|
+
// 原位替换 record(token 数变化 → 配对投影变 → 签名变)。追加同分候选不改投影
|
|
500
|
+
// (startedAt 最近邻贪心仍取首个,签名不变——非本用例目标)。
|
|
501
|
+
subs[0] = makeSub({ totalTokens: 2400 });
|
|
502
|
+
vi.advanceTimersByTime(200);
|
|
503
|
+
expect(d.tui.requestRender).toHaveBeenCalledTimes(2);
|
|
504
|
+
});
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
describe("WorkflowsView live 进度渲染(liveRecords 注入)", () => {
|
|
508
|
+
it("L1 agent 行 live 路径:tok/tools/elapsed 来自 store record 投影", () => {
|
|
509
|
+
const d = openDriver({
|
|
510
|
+
liveRecords: () => [makeSub({ totalTokens: 1200, turns: 2 })],
|
|
511
|
+
});
|
|
512
|
+
d.component.handleInput(ENTER);
|
|
513
|
+
const joined = d.component.render(100).join("\n");
|
|
514
|
+
expect(joined).toContain("1k tok");
|
|
515
|
+
expect(joined).toContain("2 tools");
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it("L2 详情 live 路径:当前活动行 + 最近事件 + 实时 Outcome(含 lastError)", () => {
|
|
519
|
+
const d = openDriver({
|
|
520
|
+
liveRecords: () => [
|
|
521
|
+
makeSub({
|
|
522
|
+
currentActivity: { type: "tool", label: "bash" },
|
|
523
|
+
eventLog: [
|
|
524
|
+
{ type: "tool_start", label: "read", ts: T0 },
|
|
525
|
+
{ type: "error", label: "EPIPE: broken pipe", ts: T0 },
|
|
526
|
+
] as AgentEventLogEntry[],
|
|
527
|
+
}),
|
|
528
|
+
],
|
|
529
|
+
});
|
|
530
|
+
d.component.handleInput(ENTER);
|
|
531
|
+
d.component.handleInput(ENTER);
|
|
532
|
+
const joined = d.component.render(100).join("\n");
|
|
533
|
+
expect(joined).toContain("⎿ tool: bash");
|
|
534
|
+
expect(joined).toContain("→ read");
|
|
535
|
+
expect(joined).toContain("Running ·");
|
|
536
|
+
expect(joined).toContain("EPIPE: broken pipe");
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
it("L2 详情 live 零活动:显示 (starting...) 占位", () => {
|
|
540
|
+
const d = openDriver({ liveRecords: () => [makeSub({ eventLog: [], turns: 0, totalTokens: 0 })] });
|
|
541
|
+
d.component.handleInput(ENTER);
|
|
542
|
+
d.component.handleInput(ENTER);
|
|
543
|
+
expect(d.component.render(100).join("\n")).toContain("(starting...)");
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
it("L2 详情 worker diagnostics:errorLogs 渲染 level 前缀行", () => {
|
|
547
|
+
const d = openDriver({
|
|
548
|
+
errorLogs: [
|
|
549
|
+
{ level: "error", message: "wave 1 failed" },
|
|
550
|
+
{ level: "warn", message: "retrying" },
|
|
551
|
+
],
|
|
552
|
+
});
|
|
553
|
+
d.component.handleInput(ENTER);
|
|
554
|
+
d.component.handleInput(ENTER);
|
|
555
|
+
const joined = d.component.render(100).join("\n");
|
|
556
|
+
expect(joined).toContain("Worker diagnostics · 2 entries");
|
|
557
|
+
expect(joined).toContain("[error] wave 1 failed");
|
|
558
|
+
expect(joined).toContain("[warn] retrying");
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
it("终态节点 Activity 截断标签:toolCalls 超 3 条显示 last N of M", () => {
|
|
562
|
+
const toolCalls = Array.from({ length: 5 }, (_, i) => ({ name: `t${i}`, input: "{}" }));
|
|
563
|
+
const d = openDriver({
|
|
564
|
+
nodes: [makeNode(0, { status: "completed", result: { content: "ok", toolCalls } })],
|
|
565
|
+
});
|
|
566
|
+
d.component.handleInput(ENTER);
|
|
567
|
+
d.component.handleInput(ENTER);
|
|
568
|
+
const joined = d.component.render(100).join("\n");
|
|
569
|
+
expect(joined).toContain("Activity · last 3 of 5 tool calls");
|
|
570
|
+
expect(joined).toContain("t4({})");
|
|
571
|
+
expect(joined).not.toContain("t0({})");
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
it("终态节点 Outcome:error 优先于 content;超预算内容截断 + (truncated) 标记", () => {
|
|
575
|
+
const big = "x".repeat(120_000);
|
|
576
|
+
const d = openDriver({
|
|
577
|
+
nodes: [
|
|
578
|
+
makeNode(0, { status: "failed", result: { error: "agent crashed", content: big } }),
|
|
579
|
+
],
|
|
580
|
+
});
|
|
581
|
+
d.component.handleInput(ENTER);
|
|
582
|
+
d.component.handleInput(ENTER);
|
|
583
|
+
const joined = d.component.render(100).join("\n");
|
|
584
|
+
expect(joined).toContain("agent crashed");
|
|
585
|
+
expect(joined).not.toContain("xxxx");
|
|
586
|
+
|
|
587
|
+
const d2 = openDriver({
|
|
588
|
+
nodes: [makeNode(0, { status: "completed", result: { content: `head\n${big}` } })],
|
|
589
|
+
});
|
|
590
|
+
d2.component.handleInput(ENTER);
|
|
591
|
+
d2.component.handleInput(ENTER);
|
|
592
|
+
const joined2 = d2.component.render(100).join("\n");
|
|
593
|
+
expect(joined2).toContain("(truncated)");
|
|
594
|
+
});
|
|
595
|
+
});
|
|
@@ -23,8 +23,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
|
23
23
|
|
|
24
24
|
import type { LiveProgressView } from "../detail-content.ts";
|
|
25
25
|
import { buildDetailContent } from "../detail-content.ts";
|
|
26
|
-
import type { ThemeLike } from "
|
|
26
|
+
import type { ThemeLike } from "../../../format/format.ts";
|
|
27
27
|
import { collectNodeLiveProgress, computeRenderSignature } from "../WorkflowsView.ts";
|
|
28
|
+
import { noteRebuiltSettlement } from "@zhushanwen/subagent-core";
|
|
28
29
|
import type { AgentEventLogEntry } from "@zhushanwen/subagent-core";
|
|
29
30
|
import type { ExecutionTraceNode, WorkerLogEntry } from "@zhushanwen/subagent-core";
|
|
30
31
|
import type { SubagentRecord } from "@zhushanwen/subagent-core";
|
|
@@ -74,15 +75,21 @@ function makeRun(shape: RunShape = {}): WorkflowRun {
|
|
|
74
75
|
}
|
|
75
76
|
|
|
76
77
|
/** [W2/V1] runId 唯一化 + spec 最小面(displayStatusOf → core runSummary 投影
|
|
77
|
-
* 读 runId/spec.scriptName——签名测试的 mock 需携带该最小字段集)。
|
|
78
|
+
* 读 runId/spec.scriptName——签名测试的 mock 需携带该最小字段集)。
|
|
79
|
+
* [D6(a) 第 1 步] 终局判定源 = 终局记录注册表:done 形态 fixture 必须携带注册表
|
|
80
|
+
* 条目(生产 = 壳重建点 noteRebuiltSettlement 注入 run-settled 帧事实)。 */
|
|
78
81
|
let signatureRunSeq = 0;
|
|
79
82
|
function makeRunWithId(runId: string, shape: RunShape): WorkflowRun {
|
|
83
|
+
const status = shape.status ?? "running";
|
|
84
|
+
if (status === "done") {
|
|
85
|
+
noteRebuiltSettlement(runId, { outcome: "done", settledAt: Date.parse("2026-09-27T00:00:00.000Z") });
|
|
86
|
+
}
|
|
80
87
|
return {
|
|
81
88
|
runId,
|
|
82
89
|
spec: { scriptName: "sig-wf" },
|
|
83
90
|
meta: { startedAt: "2026-09-27T00:00:00.000Z" },
|
|
84
91
|
state: {
|
|
85
|
-
status
|
|
92
|
+
status,
|
|
86
93
|
budget: shape.budget ?? { usedTokens: 0, maxTokens: 200_000, usedCost: 0 },
|
|
87
94
|
trace: { toArray: () => shape.nodes ?? [] },
|
|
88
95
|
errorLogs: shape.errorLogs ?? [],
|