pi-skill-evolve 0.1.3 → 0.1.5

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.
Files changed (36) hide show
  1. package/extensions/skill-evolve/lib/aggregate.ts +153 -0
  2. package/extensions/skill-evolve/lib/command.ts +1 -1
  3. package/extensions/skill-evolve/lib/config.ts +7 -7
  4. package/extensions/skill-evolve/lib/dispatch-types.ts +65 -0
  5. package/extensions/skill-evolve/lib/dispatch.ts +32 -52
  6. package/extensions/skill-evolve/lib/evolution.ts +1 -0
  7. package/extensions/skill-evolve/lib/report.ts +4 -1
  8. package/extensions/skill-evolve/lib/runs.ts +35 -2
  9. package/extensions/skill-evolve/lib/shutdown-dispatch.ts +24 -1
  10. package/extensions/skill-evolve/lib/telemetry.ts +2 -0
  11. package/extensions/skill-evolve/lib/web/applied.ts +4 -0
  12. package/extensions/skill-evolve/lib/web/approvals.ts +4 -0
  13. package/extensions/skill-evolve/lib/web/data.ts +13 -226
  14. package/extensions/skill-evolve/lib/web/done.ts +65 -0
  15. package/extensions/skill-evolve/lib/web/filter.ts +4 -4
  16. package/extensions/skill-evolve/lib/web/pending.ts +191 -0
  17. package/extensions/skill-evolve/lib/web/render-rows.ts +37 -6
  18. package/extensions/skill-evolve/lib/web/render-run.ts +14 -0
  19. package/extensions/skill-evolve/lib/web/render-session.ts +67 -0
  20. package/extensions/skill-evolve/lib/web/reports.ts +11 -5
  21. package/extensions/skill-evolve/lib/web/run-detail.ts +6 -0
  22. package/extensions/skill-evolve/lib/web/run-session.ts +62 -0
  23. package/extensions/skill-evolve/lib/web/server.ts +13 -1
  24. package/extensions/skill-evolve/lib/web/session-file.ts +136 -0
  25. package/extensions/skill-evolve/lib/web/session-view.ts +260 -0
  26. package/extensions/skill-evolve/lib/web/style.ts +17 -0
  27. package/extensions/skill-evolve/test/aggregate-dispatch.test.ts +165 -0
  28. package/extensions/skill-evolve/test/aggregate-harness.ts +114 -0
  29. package/extensions/skill-evolve/test/aggregate.test.ts +80 -0
  30. package/extensions/skill-evolve/test/config.test.ts +10 -10
  31. package/extensions/skill-evolve/test/dispatch-supervisor.test.ts +2 -1
  32. package/extensions/skill-evolve/test/evolution.test.ts +6 -6
  33. package/extensions/skill-evolve/test/session-page.test.ts +194 -0
  34. package/extensions/skill-evolve/test/session-view.test.ts +192 -0
  35. package/extensions/skill-evolve/test/web-session-column.test.ts +141 -0
  36. package/package.json +1 -1
@@ -17,11 +17,11 @@ function workspace(): string {
17
17
  return path.join(cwd, ".pi", "evolution");
18
18
  }
19
19
 
20
- test("DEFAULT_CONFIG:只开自动评审,采集 / 复盘 / 自动采纳都默认关", () => {
21
- // 自动评审单独为 true 是安全的:enabled 关着时它永远走不到,不会替 clone 下来的人花 token
20
+ test("DEFAULT_CONFIG:四个开关默认全关", () => {
21
+ // 四个默认全关:clone 下来不采集、不派生、不评审、不落盘(2026-11-08 用户定)
22
22
  assert.deepEqual(
23
23
  [DEFAULT_CONFIG.enabled, DEFAULT_CONFIG.telemetry, DEFAULT_CONFIG.autoReview, DEFAULT_CONFIG.autoApply],
24
- [false, false, true, false],
24
+ [false, false, false, false],
25
25
  );
26
26
  });
27
27
 
@@ -30,14 +30,14 @@ test("loadConfig:没有 config.json 时只开自动评审,且不会顺手造
30
30
  const config = loadConfig(evolutionDir);
31
31
  assert.deepEqual(
32
32
  [config.enabled, config.telemetry, config.autoReview, config.autoApply],
33
- [false, false, true, false],
33
+ [false, false, false, false],
34
34
  );
35
35
  assert.equal(fs.existsSync(configFile(evolutionDir)), false, "读配置不能有副作用");
36
36
  });
37
37
 
38
38
  test("normalizeConfig:空对象与缺字段都回落到默认(评审开、其余关)", () => {
39
39
  const empty = normalizeConfig({});
40
- assert.deepEqual([empty.enabled, empty.telemetry, empty.autoReview, empty.autoApply], [false, false, true, false]);
40
+ assert.deepEqual([empty.enabled, empty.telemetry, empty.autoReview, empty.autoApply], [false, false, false, false]);
41
41
  const onlyEnabled = normalizeConfig({ enabled: true });
42
42
  assert.deepEqual(
43
43
  [onlyEnabled.enabled, onlyEnabled.telemetry, onlyEnabled.autoApply],
@@ -49,12 +49,12 @@ test("normalizeConfig:空对象与缺字段都回落到默认(评审开、
49
49
  test("normalizeConfig:autoReview / autoApply 写错类型回落到默认值", () => {
50
50
  // 手改配置写成字符串是常事:坏值只回退那一个字段,不能让整份配置失效
51
51
  const wrong = normalizeConfig({ autoReview: "off", autoApply: 1 });
52
- assert.deepEqual([wrong.autoReview, wrong.autoApply], [true, false]);
52
+ assert.deepEqual([wrong.autoReview, wrong.autoApply], [false, false]);
53
53
  const explicit = normalizeConfig({ autoReview: false, autoApply: true, enabled: true });
54
54
  assert.deepEqual([explicit.enabled, explicit.autoReview, explicit.autoApply], [true, false, true]);
55
55
  });
56
56
 
57
- test("saveConfigSwitch:on 只开复盘 + 评审,采集与自动采纳保持原值", async () => {
57
+ test("saveConfigSwitch:on 只开复盘,采集 / 评审 / 采纳都保持原值", async () => {
58
58
  const evolutionDir = workspace();
59
59
  fs.mkdirSync(evolutionDir, { recursive: true });
60
60
  fs.writeFileSync(
@@ -65,13 +65,13 @@ test("saveConfigSwitch:on 只开复盘 + 评审,采集与自动采纳保持
65
65
  const next = await saveConfigSwitch(evolutionDir, true, directQueue);
66
66
  assert.deepEqual(
67
67
  [next.enabled, next.autoReview, next.telemetry, next.autoApply, next.idleMs, next.perSessionCap],
68
- [true, true, true, false, 1_234, 3],
68
+ [true, false, true, false, 1_234, 3],
69
69
  );
70
70
  const raw = JSON.parse(fs.readFileSync(configFile(evolutionDir), "utf8"));
71
71
  assert.deepEqual(
72
72
  [raw.enabled, raw.autoReview, raw.telemetry, raw.autoApply, raw.idleMs, raw.perSessionCap],
73
- [true, true, true, false, 1_234, 3],
74
- "写盘内容必须与返回值一致:采集关着不能被 on 顺手打开,自动采纳更不能被打开",
73
+ [true, false, true, false, 1_234, 3],
74
+ "写盘内容必须与返回值一致:on 只开复盘,采集 / 评审 / 采纳都保持原值",
75
75
  );
76
76
  });
77
77
 
@@ -68,7 +68,8 @@ function harness(
68
68
  }
69
69
 
70
70
  test("派发成功时同一轮就起评审督导,并把评审模型透传过去", async () => {
71
- const h = harness({ reviewModel: () => "deepseek/deepseek-v3.2" });
71
+ // 评审默认关(2026-11-08),这里显式打开才测得到「起督导 + 透传模型」
72
+ const h = harness({ config: { autoReview: true }, reviewModel: () => "deepseek/deepseek-v3.2" });
72
73
  const outcome = await h.dispatch({ sessionId: "sess-1", skills: ["alpha"] });
73
74
  assert.equal(outcome.status, "dispatched");
74
75
  assert.equal(h.supervisorCalls.length, 1, "必须起且只起一个督导");
@@ -222,7 +222,7 @@ test("statusText / reportText:无数据也不崩,且带上盲区提示", asy
222
222
  const status = await h.evo.statusText();
223
223
  assert.equal(
224
224
  status.includes(
225
- "采集:开(遥测写入 .pi/evolution/telemetry/)|复盘:开(会派生 agent)|评审:开(提案后自动起督导)|采纳:关(auto 提案等人工批准)",
225
+ "采集:开(遥测写入 .pi/evolution/telemetry/)|复盘:开(会派生 agent)|评审:关(提案停在 proposed 等人工)|采纳:关(auto 提案等人工批准)",
226
226
  ),
227
227
  true,
228
228
  );
@@ -257,7 +257,7 @@ test("reportText:按遥测给出曝光/加载/未命中", async () => {
257
257
  );
258
258
  });
259
259
 
260
- test("setEnabled:off 四个全关、on 只开复盘 + 评审(不动采集与自动采纳)", async () => {
260
+ test("setEnabled:off 四个全关、on 只开复盘(不动采集 / 评审 / 采纳)", async () => {
261
261
  const h = harness();
262
262
  const message = await h.evo.setEnabled(false);
263
263
  assert.equal(message.includes("关闭"), true);
@@ -267,8 +267,8 @@ test("setEnabled:off 四个全关、on 只开复盘 + 评审(不动采集与
267
267
 
268
268
  await h.evo.setEnabled(true);
269
269
  const back = JSON.parse(fs.readFileSync(path.join(h.dir, ".pi", "evolution", "config.json"), "utf8"));
270
- // 用户定的语义:on = 复盘 + 评审两开;采集要另外 collect on,自动采纳要另外 apply on
271
- assert.deepEqual([back.enabled, back.autoReview, back.telemetry, back.autoApply], [true, true, false, false]);
270
+ // 用户定的语义(2026-11-08):四个开关各管一段,on 只开自动复盘;评审要另外 review on
271
+ assert.deepEqual([back.enabled, back.autoReview, back.telemetry, back.autoApply], [true, false, false, false]);
272
272
  });
273
273
 
274
274
  test("setFlag:只切一个字段,并把当前四个开关的状态写进回执", async () => {
@@ -309,9 +309,9 @@ test("没有 config.json 时默认全关:不派生、不落遥测", async () =
309
309
  });
310
310
  const status = await evo.statusText();
311
311
  assert.equal(
312
- status.includes("采集:关(不写遥测)|复盘:关(不会派生 agent)|评审:开(提案后自动起督导)|采纳:关(auto 提案等人工批准)"),
312
+ status.includes("采集:关(不写遥测)|复盘:关(不会派生 agent)|评审:关(提案停在 proposed 等人工)|采纳:关(auto 提案等人工批准)"),
313
313
  true,
314
- "默认只开自动评审:enabled 关着时它永远走不到",
314
+ "默认四个全关:采集 / 复盘 / 评审 / 采纳都不动",
315
315
  );
316
316
  evo.recordAdvertised({ sessionId: "s1", cwd: dir, prompt: "x", skills: [skill("a")] });
317
317
  evo.settle({ sessionId: "s1" });
@@ -0,0 +1,194 @@
1
+ /**
2
+ * 会话详情页的读盘层与整页渲染(node --test)。
3
+ *
4
+ * 纪律:所有会话文件都造在临时目录里,并把 `home` 显式传进 readSessionSource ——
5
+ * 绝不扫真实 `~/.pi/agent/sessions`(本机会话文件随时在变,测试不能依赖它)。
6
+ *
7
+ * 覆盖:按 sessionId 反查、三条安全闸(根外路径 / 名字不含 sessionId / 非 .jsonl)、
8
+ * run.json 里的 sessionFile 优先、文件不在本机的文案、整页统计与「工具块默认收起」、HTTP 路由接对。
9
+ */
10
+ import { test } from "node:test";
11
+ import assert from "node:assert/strict";
12
+ import * as fs from "node:fs";
13
+ import * as os from "node:os";
14
+ import * as path from "node:path";
15
+
16
+ import type { WebState } from "../lib/web/data.ts";
17
+ import { renderSessionPage } from "../lib/web/render-session.ts";
18
+ import {
19
+ declaredSessionFile,
20
+ findSessionFile,
21
+ readSessionSource,
22
+ resolveSessionFile,
23
+ safeSessionId,
24
+ sessionsRoot,
25
+ } from "../lib/web/session-file.ts";
26
+ import { startWebPanel } from "../lib/web/server.ts";
27
+
28
+ const SESSION_ID = "01a1b90f-08c3-722c-871d-d024b083770a";
29
+
30
+ /** 一段最小的会话 JSONL:一条用户消息 + 一次带结果的工具调用 */
31
+ function sessionJsonl(): string {
32
+ return [
33
+ JSON.stringify({ type: "session", id: SESSION_ID }),
34
+ JSON.stringify({ type: "message", message: { role: "user", content: [{ type: "text", text: "看一下这个文件" }] } }),
35
+ JSON.stringify({
36
+ type: "message",
37
+ message: { role: "assistant", content: [{ type: "toolCall", id: "c1", name: "read", arguments: { path: "a.ts" } }] },
38
+ }),
39
+ JSON.stringify({ type: "message", message: { role: "toolResult", toolCallId: "c1", toolName: "read", content: [{ type: "text", text: "文件内容" }] } }),
40
+ ].join("\n") + "\n";
41
+ }
42
+
43
+ /** 临时 home:`<home>/.pi/agent/sessions/--project--/2026-11-08T00-00-00-000Z_<id>.jsonl` */
44
+ function fakeHome(): { home: string; file: string } {
45
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-home-"));
46
+ const dir = path.join(sessionsRoot(home), "--home-suveng-demo--");
47
+ fs.mkdirSync(dir, { recursive: true });
48
+ const file = path.join(dir, `2026-11-08T00-00-00-000Z_${SESSION_ID}.jsonl`);
49
+ fs.writeFileSync(file, sessionJsonl(), "utf8");
50
+ return { home, file };
51
+ }
52
+
53
+ /** 临时工作区:只造 runs/<runId>/run.json,够 readSessionSource 反查 sessionFile */
54
+ function fakeEvolution(runId: string, record: Record<string, unknown>): string {
55
+ const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-evo-"));
56
+ const evolutionDir = path.join(cwd, ".pi", "evolution");
57
+ const runDir = path.join(evolutionDir, "runs", runId);
58
+ fs.mkdirSync(runDir, { recursive: true });
59
+ fs.writeFileSync(path.join(runDir, "run.json"), JSON.stringify({ runId, ...record }), "utf8");
60
+ return evolutionDir;
61
+ }
62
+
63
+ function emptyState(): WebState {
64
+ return {
65
+ pending: [],
66
+ approvals: [],
67
+ applied: [],
68
+ done: [],
69
+ reports: [],
70
+ summary: { reviewing: 0, stale: 0, pendingApprovals: 0, appliedTotal: 0, rollbackRate: 0 },
71
+ };
72
+ }
73
+
74
+ test("sessionsRoot / safeSessionId:路径拼对,非法 id 一律拒(它会被当文件名匹配词)", () => {
75
+ assert.equal(sessionsRoot("/home/demo"), path.join("/home/demo", ".pi", "agent", "sessions"));
76
+ assert.equal(safeSessionId(SESSION_ID), SESSION_ID);
77
+ assert.equal(safeSessionId(" " + SESSION_ID + " "), SESSION_ID);
78
+ for (const bad of ["", "../../etc/passwd", "a/b", "x".repeat(200), "id with space"]) {
79
+ assert.equal(safeSessionId(bad), null, bad);
80
+ }
81
+ });
82
+
83
+ test("findSessionFile:按 sessionId 在 sessions 子目录里反查,命中多条取最近(文件名时间最大)", () => {
84
+ const { home, file } = fakeHome();
85
+ const dir = path.dirname(file);
86
+ const older = path.join(dir, `2026-11-07T00-00-00-000Z_${SESSION_ID}.jsonl`);
87
+ fs.copyFileSync(file, older);
88
+ assert.equal(findSessionFile(SESSION_ID, sessionsRoot(home)), file);
89
+ // 换一个 sessionId 就找不到(绝不模糊匹配到别的会话)
90
+ assert.equal(findSessionFile("ffffffff-0000-0000-0000-000000000000", sessionsRoot(home)), null);
91
+ });
92
+
93
+ test("resolveSessionFile 安全闸:根目录外 / 名字不含 sessionId / 非 .jsonl 一律不采纳(回落到按 id 反查)", () => {
94
+ const { home, file } = fakeHome();
95
+ const root = sessionsRoot(home);
96
+ assert.equal(resolveSessionFile({ sessionId: SESSION_ID, file, root }), file);
97
+
98
+ // 根目录内但不是这个会话的文件:不采纳那一条,落到反查(反查命中的才是真会话)
99
+ const other = path.join(path.dirname(file), "2026-11-08T00-00-00-000Z_other.jsonl");
100
+ fs.writeFileSync(other, "{}\n", "utf8");
101
+ assert.equal(resolveSessionFile({ sessionId: SESSION_ID, file: other, root }), file);
102
+ // 后缀不是 .jsonl 同理
103
+ const txt = path.join(path.dirname(file), `${SESSION_ID}.txt`);
104
+ fs.writeFileSync(txt, "x", "utf8");
105
+ assert.equal(resolveSessionFile({ sessionId: SESSION_ID, file: txt, root }), file);
106
+
107
+ // ?file=/etc/passwd 这种绝不能读(根目录外):任何情况下都不会返回它
108
+ const outside = resolveSessionFile({ sessionId: SESSION_ID, file: "/etc/passwd", root });
109
+ assert.equal(outside, file);
110
+ assert.equal(resolveSessionFile({ sessionId: "ffffffff-0000-0000-0000-000000000000", file: "/etc/passwd", root }), null);
111
+ });
112
+
113
+ test("declaredSessionFile:从 runs 里的 run.json 反查同一会话的 sessionFile", () => {
114
+ const file = "/tmp/demo-session.jsonl";
115
+ const evolutionDir = fakeEvolution("run-1", { sessionId: SESSION_ID, sessionFile: file, status: "rejected" });
116
+ assert.equal(declaredSessionFile(evolutionDir, SESSION_ID), file);
117
+ assert.equal(declaredSessionFile(evolutionDir, "ffffffff-0000-0000-0000-000000000000"), null);
118
+ });
119
+
120
+ test("readSessionSource:解析会话原文并给出消息数 / 工具调用数 / 字节数", () => {
121
+ const { home, file } = fakeHome();
122
+ const evolutionDir = fakeEvolution("run-1", { sessionId: SESSION_ID, sessionFile: file, status: "rejected" });
123
+ const source = readSessionSource({ evolutionDir, sessionId: SESSION_ID, home });
124
+ assert.equal(source.file, file);
125
+ assert.equal(source.transcript?.messages, 3);
126
+ assert.equal(source.transcript?.toolCalls, 1);
127
+ assert.equal(source.sizeBytes, Buffer.byteLength(sessionJsonl(), "utf8"));
128
+ });
129
+
130
+ test("readSessionSource:run.json 记的路径已不在本机时按 sessionId 反查兜底", () => {
131
+ const { home, file } = fakeHome();
132
+ const evolutionDir = fakeEvolution("run-1", { sessionId: SESSION_ID, sessionFile: "/gone/away.jsonl", status: "rejected" });
133
+ const source = readSessionSource({ evolutionDir, sessionId: SESSION_ID, home });
134
+ assert.equal(source.file, file, "声明的路径没了,但本机其它目录里还留着这个会话");
135
+ });
136
+
137
+ test("readSessionSource:彻底找不到 → file 与 transcript 都是 null(页面要说人话,不能 500)", () => {
138
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-home-empty-"));
139
+ const evolutionDir = fakeEvolution("run-1", { sessionId: SESSION_ID, sessionFile: null, status: "rejected" });
140
+ const source = readSessionSource({ evolutionDir, sessionId: SESSION_ID, home });
141
+ assert.deepEqual([source.file, source.transcript, source.sizeBytes], [null, null, 0]);
142
+ });
143
+
144
+ test("readSessionSource:非法 sessionId 直接返回空,不扫盘", () => {
145
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-home-empty-"));
146
+ const evolutionDir = fakeEvolution("run-1", { sessionId: SESSION_ID, status: "rejected" });
147
+ const source = readSessionSource({ evolutionDir, sessionId: "../../etc/passwd", home });
148
+ assert.deepEqual([source.file, source.transcript], [null, null]);
149
+ });
150
+
151
+ test("renderSessionPage:顶部给完整 sessionId 与文件路径,底部给原文件统计,工具块默认收起", () => {
152
+ const { home, file } = fakeHome();
153
+ const evolutionDir = fakeEvolution("run-1", { sessionId: SESSION_ID, sessionFile: file, status: "rejected" });
154
+ const source = readSessionSource({ evolutionDir, sessionId: SESSION_ID, home });
155
+ const html = renderSessionPage(source, { state: emptyState() });
156
+ assert.match(html, new RegExp(`<code>${SESSION_ID}</code>`));
157
+ assert.match(html, /<code>.*2026-11-08T00-00-00-000Z_.*\.jsonl<\/code>/);
158
+ assert.match(html, /原文件 <code>.*\.jsonl<\/code>(3 条消息 \/ 1 次工具调用 \/ \d+B)/);
159
+ assert.equal(/<details[^>]*\sopen/.test(html), false, "工具块默认收起");
160
+ assert.match(html, /看一下这个文件/);
161
+ });
162
+
163
+ test("renderSessionPage:会话文件不在本机时给一句人话,不是空白页", () => {
164
+ const source = readSessionSource({
165
+ evolutionDir: fakeEvolution("run-1", { sessionId: SESSION_ID, status: "rejected" }),
166
+ sessionId: SESSION_ID,
167
+ home: fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-home-empty-")),
168
+ });
169
+ const html = renderSessionPage(source, { state: emptyState() });
170
+ assert.match(html, /会话文件已不在本机/);
171
+ assert.match(html, /原文件:<span class="off">会话文件已不在本机<\/span>/);
172
+ });
173
+
174
+ test("HTTP 路由:GET /?view=session&sessionId= 接对了(HOME 指到临时目录,绝不碰真实 sessions)", async () => {
175
+ const { home, file } = fakeHome();
176
+ const evolutionDir = fakeEvolution("run-1", { sessionId: SESSION_ID, sessionFile: file, status: "rejected" });
177
+ const original = process.env.HOME;
178
+ process.env.HOME = home;
179
+ const result = await startWebPanel({ evolutionDir, port: 0 });
180
+ assert.equal(result.ok, true);
181
+ if (!result.ok) return;
182
+ try {
183
+ const response = await fetch(`${result.panel.url}/?view=session&sessionId=${SESSION_ID}`);
184
+ assert.equal(response.status, 200);
185
+ assert.match(response.headers.get("content-type") ?? "", /text\/html/);
186
+ const html = await response.text();
187
+ assert.match(html, /工具调用:read ×1(点击展开)/);
188
+ assert.equal(/<details[^>]*\sopen/.test(html), false);
189
+ } finally {
190
+ await result.panel.close();
191
+ if (original === undefined) delete process.env.HOME;
192
+ else process.env.HOME = original;
193
+ }
194
+ });
@@ -0,0 +1,192 @@
1
+ /**
2
+ * 会话详情页的纯函数单测(node --test):
3
+ * JSONL 解析(只认 message、跳过 system / thinking、工具调用回连结果、连续工具聚块)、
4
+ * 渲染(默认收起、全转义、单条与总输出截断、只留最后 200 条)。
5
+ *
6
+ * 纪律:不读真实 `~/.pi/agent/sessions`、不读真实 run 目录 —— 所有输入都是本文件里现造的 JSONL 片段,
7
+ * 这样用例既快又不受本机会话文件变动影响。
8
+ */
9
+ import { test } from "node:test";
10
+ import assert from "node:assert/strict";
11
+
12
+ import {
13
+ MAX_MESSAGES,
14
+ MAX_OUTPUT,
15
+ parseSessionText,
16
+ renderSessionBody,
17
+ type SessionTranscript,
18
+ } from "../lib/web/session-view.ts";
19
+
20
+ /** 一行 JSONL(pi 的真实形态:顶层 type + message) */
21
+ function line(record: unknown): string {
22
+ return `${JSON.stringify(record)}\n`;
23
+ }
24
+
25
+ function userMessage(text: string): string {
26
+ return line({ type: "message", message: { role: "user", content: [{ type: "text", text }] } });
27
+ }
28
+
29
+ function assistantMessage(text: string, calls: { id: string; name: string; arguments?: unknown }[] = []): string {
30
+ const content: unknown[] = [];
31
+ if (text.length > 0) content.push({ type: "text", text });
32
+ for (const call of calls) content.push({ type: "toolCall", id: call.id, name: call.name, arguments: call.arguments ?? {} });
33
+ return line({ type: "message", message: { role: "assistant", content } });
34
+ }
35
+
36
+ function toolResult(toolCallId: string, text: string): string {
37
+ return line({ type: "message", message: { role: "toolResult", toolCallId, toolName: "read", content: [{ type: "text", text }] } });
38
+ }
39
+
40
+ test("解析:只认顶层 type=message,session / model_change / custom 全跳过", () => {
41
+ const raw =
42
+ line({ type: "session", id: "s1" }) +
43
+ line({ type: "model_change", model: "x" }) +
44
+ line({ type: "custom", data: {} }) +
45
+ userMessage("你好") +
46
+ "这不是 JSON\n";
47
+ const transcript = parseSessionText(raw);
48
+ assert.equal(transcript.messages, 1);
49
+ assert.deepEqual(transcript.entries, [{ kind: "user", text: "你好" }]);
50
+ });
51
+
52
+ test("解析:system 提示词与 thinking 块都不渲染(它们不是对话)", () => {
53
+ const raw =
54
+ line({ type: "message", message: { role: "system", content: [{ type: "text", text: "你是复盘助手" }] } }) +
55
+ line({ type: "message", message: { role: "assistant", content: [{ type: "thinking", thinking: "先看看 diff" }, { type: "text", text: "结论:没问题" }] } });
56
+ const transcript = parseSessionText(raw);
57
+ assert.deepEqual(transcript.entries, [{ kind: "assistant", text: "结论:没问题" }]);
58
+ });
59
+
60
+ test("解析:工具调用按 toolCallId 回连结果,连续调用聚成一个块", () => {
61
+ const raw =
62
+ userMessage("看两个文件") +
63
+ assistantMessage("", [
64
+ { id: "c1", name: "read", arguments: { path: "a.ts" } },
65
+ { id: "c2", name: "read", arguments: { path: "b.ts" } },
66
+ ]) +
67
+ toolResult("c1", "文件 a 的内容") +
68
+ toolResult("c2", "文件 b 的内容") +
69
+ assistantMessage("看完了");
70
+ const transcript = parseSessionText(raw);
71
+ assert.deepEqual(transcript.entries.map((entry) => entry.kind), ["user", "tools", "assistant"]);
72
+ const tools = transcript.entries[1];
73
+ assert.equal(tools.kind, "tools");
74
+ if (tools.kind !== "tools") return;
75
+ assert.equal(tools.calls.length, 2);
76
+ assert.deepEqual(
77
+ tools.calls.map((call) => [call.name, call.args, call.result]),
78
+ [
79
+ ["read", '{"path":"a.ts"}', "文件 a 的内容"],
80
+ ["read", '{"path":"b.ts"}', "文件 b 的内容"],
81
+ ],
82
+ );
83
+ assert.equal(transcript.toolCalls, 2);
84
+ });
85
+
86
+ test("解析:两次 assistant 消息之间的调用(中间只隔 toolResult)也聚成一个块", () => {
87
+ const raw =
88
+ assistantMessage("先读一个", [{ id: "c1", name: "read", arguments: { path: "a" } }]) +
89
+ toolResult("c1", "ok") +
90
+ assistantMessage("", [{ id: "c2", name: "bash", arguments: { command: "ls" } }]) +
91
+ toolResult("c2", "ok");
92
+ const transcript = parseSessionText(raw);
93
+ // 文本在前、工具在后:中间那两次调用仍属同一段连续工具调用
94
+ assert.deepEqual(transcript.entries.map((entry) => entry.kind), ["assistant", "tools"]);
95
+ const tools = transcript.entries[1];
96
+ assert.equal(tools.kind === "tools" ? tools.calls.length : 0, 2);
97
+ });
98
+
99
+ test("解析:没有结果的调用写「(没有结果)」(子进程被 kill 时会这样)", () => {
100
+ const transcript = parseSessionText(assistantMessage("", [{ id: "c9", name: "read", arguments: {} }]));
101
+ const tools = transcript.entries[0];
102
+ assert.equal(tools.kind === "tools" ? tools.calls[0]?.result : "", "(没有结果)");
103
+ });
104
+
105
+ test("渲染:用户与助手各一段,抬头写明是谁", () => {
106
+ const html = renderSessionBody(parseSessionText(userMessage("你好") + assistantMessage("你好,有什么要看的?")));
107
+ assert.match(html, /<div class="msg user">\s*<p class="who">用户<\/p>\s*<pre>你好<\/pre>/);
108
+ assert.match(html, /<div class="msg assistant">\s*<p class="who">助手<\/p>/);
109
+ });
110
+
111
+ test("渲染:工具块默认收起(details 不带 open),抬头是「工具名 ×次数(点击展开)」", () => {
112
+ const raw =
113
+ assistantMessage("", [
114
+ { id: "c1", name: "read", arguments: { path: "a" } },
115
+ { id: "c2", name: "read", arguments: { path: "b" } },
116
+ { id: "c3", name: "bash", arguments: { command: "ls" } },
117
+ ]) +
118
+ toolResult("c1", "A") +
119
+ toolResult("c2", "B") +
120
+ toolResult("c3", "C");
121
+ const html = renderSessionBody(parseSessionText(raw));
122
+ assert.match(html, /<details class="tools"><summary>工具调用:read ×2、bash ×1(点击展开)<\/summary>/);
123
+ assert.equal(/<details[^>]*\sopen/.test(html), false, "默认收起:不许出现 open 属性");
124
+ // 每次调用的三段都在:名字 / 参数 / 结果
125
+ assert.match(html, /<p class="tool-name">read<\/p>/);
126
+ assert.match(html, /<pre class="tool-args">\{&quot;path&quot;:&quot;a&quot;\}<\/pre>/);
127
+ assert.match(html, /<pre class="tool-result">A<\/pre>/);
128
+ });
129
+
130
+ test("渲染:消息里的 HTML 一律转义,页面不出现裸 <script>", () => {
131
+ const raw =
132
+ userMessage("<script>alert(1)</script>") +
133
+ assistantMessage("引用 <b>粗体</b>") +
134
+ assistantMessage("", [{ id: "c1", name: "read", arguments: { path: "<img src=x>" } }]) +
135
+ toolResult("c1", "<script>x()</script>");
136
+ const html = renderSessionBody(parseSessionText(raw));
137
+ assert.equal(/<script/i.test(html), false);
138
+ assert.equal(/<img/i.test(html), false);
139
+ assert.match(html, /&lt;script&gt;alert\(1\)&lt;\/script&gt;/);
140
+ assert.match(html, /&lt;b&gt;粗体&lt;\/b&gt;/);
141
+ assert.match(html, /&lt;img src=x&gt;/);
142
+ });
143
+
144
+ test("渲染:用户原文超 2000 字截断并加省略号,助手正文超 4000 字同理", () => {
145
+ const longUser = "u".repeat(2500);
146
+ const longAssistant = "a".repeat(4500);
147
+ const transcript = parseSessionText(userMessage(longUser) + assistantMessage(longAssistant));
148
+ const html = renderSessionBody(transcript);
149
+ assert.equal(html.includes("u".repeat(2000) + "…"), true);
150
+ assert.equal(html.includes("u".repeat(2001)), false);
151
+ assert.equal(html.includes("a".repeat(4000) + "…"), true);
152
+ assert.equal(html.includes("a".repeat(4001)), false);
153
+ });
154
+
155
+ test("渲染:工具参数截到 400 字、结果截到 200 字", () => {
156
+ const raw =
157
+ assistantMessage("", [{ id: "c1", name: "read", arguments: "p".repeat(600) }]) +
158
+ toolResult("c1", "r".repeat(400));
159
+ const html = renderSessionBody(parseSessionText(raw));
160
+ assert.equal(html.includes("p".repeat(400) + "…"), true);
161
+ assert.equal(html.includes("p".repeat(401)), false);
162
+ assert.equal(html.includes("r".repeat(200) + "…"), true);
163
+ assert.equal(html.includes("r".repeat(201)), false);
164
+ });
165
+
166
+ test("解析:超过 200 条只留最后 200 条,dropped 记下丢掉多少", () => {
167
+ const raw = Array.from({ length: MAX_MESSAGES + 5 }, (_, index) => userMessage(`第 ${index} 条`)).join("");
168
+ const transcript = parseSessionText(raw);
169
+ assert.equal(transcript.messages, MAX_MESSAGES + 5);
170
+ assert.equal(transcript.entries.length, MAX_MESSAGES);
171
+ assert.equal(transcript.dropped, 5);
172
+ assert.equal(transcript.entries[0]?.kind === "user" ? transcript.entries[0].text : "", "第 5 条");
173
+ // 丢掉的要说明,否则用户以为会话只有 200 条
174
+ const html = renderSessionBody(transcript, { dropped: transcript.dropped });
175
+ assert.match(html, /只渲染最后 200 条消息,前面还有 5 条未显示/);
176
+ });
177
+
178
+ test("渲染:总输出超 2MB 就停手并指路原文件", () => {
179
+ // 用会被转义成 6 个字符的引号撑大输出:200 条 × 4000 字 → 转义后远超上限
180
+ const entries: SessionTranscript["entries"] = Array.from({ length: MAX_MESSAGES }, () => ({
181
+ kind: "assistant",
182
+ text: '"'.repeat(4000),
183
+ }));
184
+ const html = renderSessionBody({ entries, messages: entries.length, toolCalls: 0, dropped: 0 });
185
+ assert.equal(html.length > MAX_OUTPUT, false, "输出必须被截在上限以内");
186
+ assert.match(html, /已达页面输出上限(2048KB),后面还有内容未渲染/);
187
+ assert.match(html, /继续看请用 rg\/read 打开原文件/);
188
+ });
189
+
190
+ test("渲染:空会话给一句空态,不留白页", () => {
191
+ assert.match(renderSessionBody({ entries: [], messages: 0, toolCalls: 0, dropped: 0 }), /没有可渲染的对话/);
192
+ });
@@ -0,0 +1,141 @@
1
+ /**
2
+ * 列表页的「会话」列:五个视图(待处理 / 待审批 / 已采纳 / 已处理 / 复盘报告)都必须带,
3
+ * 且文案一致 —— 只显示 sessionId 前 8 位,完整 id 放 title,点进 `?view=session`。
4
+ *
5
+ * 为什么必须每页都有(用户 2026-11-08):「面板展示的也应该是 pi sessionId 否则我都不知道怎么看对应会话」。
6
+ * 同一个渲染函数 `sessionCell` 供五处复用,本文件就是把这条约定钉住:
7
+ * 少一页就说明有人另写了一份行渲染。
8
+ */
9
+ import assert from "node:assert/strict";
10
+ import test from "node:test";
11
+ import type { AppliedItem } from "../lib/web/applied.ts";
12
+ import type { ApprovalItem } from "../lib/web/approvals.ts";
13
+ import type { QueueItem } from "../lib/queue.ts";
14
+ import type { DoneItem, PendingItem, WebState } from "../lib/web/data.ts";
15
+ import type { RunReport } from "../lib/web/reports.ts";
16
+ import { renderPage } from "../lib/web/render.ts";
17
+ import { pendingRows } from "../lib/web/render-rows.ts";
18
+ import type { SessionRef } from "../lib/web/run-session.ts";
19
+
20
+ const NOW = new Date(2026, 10, 8, 12).getTime();
21
+ const SESSION_ID = "01a1b90f-08c3-722c-871d-d024b083770a";
22
+ const SESSION: SessionRef = { sessionId: SESSION_ID, sessionFile: `/tmp/${SESSION_ID}.jsonl` };
23
+ const NO_SESSION: SessionRef = { sessionId: null, sessionFile: null };
24
+ const CTX = { csrf: "c", writable: true, view: "pending" as const };
25
+
26
+ function state(parts: Partial<WebState> = {}): WebState {
27
+ return {
28
+ pending: [],
29
+ approvals: [],
30
+ applied: [],
31
+ done: [],
32
+ reports: [],
33
+ summary: { reviewing: 0, stale: 0, pendingApprovals: 0, appliedTotal: 0, rollbackRate: 0 },
34
+ ...parts,
35
+ };
36
+ }
37
+
38
+ function pendingItem(session: SessionRef): PendingItem {
39
+ return { runId: "run-p-1794133942", kind: "reviewed", skill: "demo", at: NOW, detail: "", session };
40
+ }
41
+
42
+ function approvalItem(session: SessionRef): ApprovalItem {
43
+ return {
44
+ runId: "run-b-1794133942",
45
+ session,
46
+ skill: "demo",
47
+ level: "approval",
48
+ targets: [".pi/skills/demo/SKILL.md"],
49
+ createdAt: NOW,
50
+ proposal: { type: "improve", added: 3, removed: 1 },
51
+ review: { verdict: "pass", blocking: 0 },
52
+ detail: "",
53
+ };
54
+ }
55
+
56
+ function appliedItem(session: SessionRef): AppliedItem {
57
+ return {
58
+ runId: "run-a-1794133942",
59
+ session,
60
+ level: "approval",
61
+ skill: "demo",
62
+ type: "improve",
63
+ added: 3,
64
+ removed: 1,
65
+ appliedAt: NOW,
66
+ gates: [],
67
+ gatesOk: null,
68
+ };
69
+ }
70
+
71
+ function doneItem(session: SessionRef): DoneItem {
72
+ return { runId: "run-d-1794133942", status: "rejected", source: "run", level: "-", skill: "demo", at: NOW, detail: "", session };
73
+ }
74
+
75
+ function reportItem(session: SessionRef): RunReport {
76
+ return {
77
+ runId: "run-r-1794133942",
78
+ status: "reviewed",
79
+ trigger: "auto",
80
+ session,
81
+ startedAt: NOW,
82
+ findings: { total: 0, rows: [] },
83
+ proposal: null,
84
+ review: null,
85
+ decision: null,
86
+ verify: null,
87
+ };
88
+ }
89
+
90
+ /** 五个视图各塞一条带会话的行,返回整页 HTML */
91
+ function pageWithSession(): Record<string, string> {
92
+ const base: Partial<WebState> = {
93
+ pending: [pendingItem(SESSION)],
94
+ approvals: [approvalItem(SESSION)],
95
+ applied: [appliedItem(SESSION)],
96
+ done: [doneItem(SESSION)],
97
+ reports: [reportItem(SESSION)],
98
+ };
99
+ const current = state(base);
100
+ return {
101
+ pending: renderPage(current, "pending"),
102
+ approvals: renderPage(current, "approvals"),
103
+ applied: renderPage(current, "applied"),
104
+ done: renderPage(current, "done"),
105
+ reports: renderPage(current, "reports"),
106
+ };
107
+ }
108
+
109
+ test("五个视图都有会话列:链接指向 ?view=session,文案是 sessionId 前 8 位,title 是完整 id", () => {
110
+ const pages = pageWithSession();
111
+ for (const [view, html] of Object.entries(pages)) {
112
+ const href = `/?view=session&amp;sessionId=${SESSION_ID}`;
113
+ assert.equal(html.includes(href), true, `${view} 页缺会话链接`);
114
+ assert.match(html, /会话 01a1b90f/, `${view} 页会话列要显示前 8 位`);
115
+ assert.equal(html.includes(`title="${SESSION_ID}"`), true, `${view} 页 title 要放完整 sessionId`);
116
+ }
117
+ });
118
+
119
+ test("待处理行:会话列跟在 runId 后面,同一行里两者都在", () => {
120
+ const html = pendingRows([pendingItem(SESSION)], CTX);
121
+ const runIdIndex = html.indexOf("run-p-1794133942");
122
+ const sessIndex = html.indexOf('class="sess"');
123
+ assert.equal(runIdIndex >= 0 && sessIndex > runIdIndex, true, "会话列应在 runId 之后");
124
+ assert.match(html, / · <a class="sess"/, "会话列与行头之间用 · 分隔");
125
+ });
126
+
127
+ test("待审批行:summary 里同样带会话列(它是自己拼的 details,最容易漏)", () => {
128
+ const html = renderPage(state({ approvals: [approvalItem(SESSION)] }), "approvals");
129
+ assert.match(html, /<summary class="head">[^<]*<a class="sess" href="\/\?view=session&amp;sessionId=/);
130
+ });
131
+
132
+ test("没有 sessionId 的老 run:写「会话 —」占位,不造空链接、不报错", () => {
133
+ const html = renderPage(state({ pending: [pendingItem(NO_SESSION)], done: [doneItem(NO_SESSION)] }), "pending");
134
+ assert.match(html, /<span class="hint">会话 —<\/span>/);
135
+ assert.equal(html.includes("view=session"), false, "缺 id 时不许出现会话链接");
136
+ });
137
+
138
+ test("会话链接带 file 参数:页面可直接打开声明过的会话文件,不必再反查", () => {
139
+ const html = pendingRows([pendingItem(SESSION)], CTX);
140
+ assert.match(html, new RegExp(`file=%2Ftmp%2F${SESSION_ID}\\.jsonl`));
141
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-skill-evolve",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "技能进化:采集会话中的技能曝光/加载,异步复盘、生成改进提案并独立评审,附本机 Web 管理面板",
5
5
  "keywords": [
6
6
  "pi-package",