pi-skill-evolve 0.1.4 → 0.1.6
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/extensions/skill-evolve/lib/aggregate.ts +153 -0
- package/extensions/skill-evolve/lib/dispatch-types.ts +65 -0
- package/extensions/skill-evolve/lib/dispatch.ts +32 -52
- package/extensions/skill-evolve/lib/evolution.ts +1 -0
- package/extensions/skill-evolve/lib/report.ts +4 -1
- package/extensions/skill-evolve/lib/runs.ts +35 -2
- package/extensions/skill-evolve/lib/shutdown-dispatch.ts +24 -1
- package/extensions/skill-evolve/lib/telemetry.ts +2 -0
- package/extensions/skill-evolve/lib/web/applied.ts +4 -0
- package/extensions/skill-evolve/lib/web/approvals.ts +4 -0
- package/extensions/skill-evolve/lib/web/data.ts +13 -226
- package/extensions/skill-evolve/lib/web/done.ts +65 -0
- package/extensions/skill-evolve/lib/web/filter.ts +4 -4
- package/extensions/skill-evolve/lib/web/list-query.ts +43 -0
- package/extensions/skill-evolve/lib/web/markdown-lite.ts +95 -0
- package/extensions/skill-evolve/lib/web/pending.ts +191 -0
- package/extensions/skill-evolve/lib/web/render-controls.ts +58 -32
- package/extensions/skill-evolve/lib/web/render-rows.ts +178 -205
- package/extensions/skill-evolve/lib/web/render-run.ts +14 -0
- package/extensions/skill-evolve/lib/web/render-session.ts +138 -0
- package/extensions/skill-evolve/lib/web/render-skills.ts +0 -1
- package/extensions/skill-evolve/lib/web/render-table.ts +126 -0
- package/extensions/skill-evolve/lib/web/render.ts +116 -54
- package/extensions/skill-evolve/lib/web/reports.ts +11 -5
- package/extensions/skill-evolve/lib/web/run-detail.ts +6 -0
- package/extensions/skill-evolve/lib/web/run-session.ts +62 -0
- package/extensions/skill-evolve/lib/web/server.ts +21 -1
- package/extensions/skill-evolve/lib/web/session-file.ts +136 -0
- package/extensions/skill-evolve/lib/web/session-render.ts +189 -0
- package/extensions/skill-evolve/lib/web/session-view.ts +276 -0
- package/extensions/skill-evolve/lib/web/shell.ts +42 -11
- package/extensions/skill-evolve/lib/web/style.ts +171 -89
- package/extensions/skill-evolve/lib/web/text.ts +57 -0
- package/extensions/skill-evolve/test/aggregate-dispatch.test.ts +165 -0
- package/extensions/skill-evolve/test/aggregate-harness.ts +114 -0
- package/extensions/skill-evolve/test/aggregate.test.ts +80 -0
- package/extensions/skill-evolve/test/session-nav.test.ts +162 -0
- package/extensions/skill-evolve/test/session-page.test.ts +200 -0
- package/extensions/skill-evolve/test/session-render.test.ts +203 -0
- package/extensions/skill-evolve/test/session-view.test.ts +180 -0
- package/extensions/skill-evolve/test/web-applied.test.ts +30 -22
- package/extensions/skill-evolve/test/web-redesign.test.ts +264 -0
- package/extensions/skill-evolve/test/web-render.test.ts +57 -41
- package/extensions/skill-evolve/test/web-server.test.ts +4 -1
- package/extensions/skill-evolve/test/web-session-column.test.ts +139 -0
- package/extensions/skill-evolve/test/web-shell.test.ts +7 -5
- package/package.json +1 -1
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 聚合检测的测试夹具:假子进程 + 假派发器 + 临时工作区。
|
|
3
|
+
*
|
|
4
|
+
* 为什么不放在 .test.ts 里:聚合有两条入口(自动派发与退出前补发),
|
|
5
|
+
* 各自一个测试文件,夹具共用一份才不会两边算出不同的场景。
|
|
6
|
+
*
|
|
7
|
+
* 纪律:pid 用合成号段(90001 起)而不是本进程 pid —— 「子进程是死是活」由用例通过 setAlive 控制,
|
|
8
|
+
* 否则测试进程自己活着就会被判成「有在途复盘」。全程不起真子进程。
|
|
9
|
+
*/
|
|
10
|
+
import * as fs from "node:fs";
|
|
11
|
+
import * as os from "node:os";
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
|
|
15
|
+
import { DEFAULT_CONFIG } from "../lib/config.ts";
|
|
16
|
+
import { createDispatcher } from "../lib/dispatch.ts";
|
|
17
|
+
import { type SpawnedChild, type SpawnLike } from "../lib/spawn-plan.ts";
|
|
18
|
+
import { directQueue, type TelemetryEvent, type TelemetryInput, type TelemetryWriter } from "../lib/telemetry.ts";
|
|
19
|
+
import { fakeSupervisorSpawn } from "./fake-review-spawn.ts";
|
|
20
|
+
|
|
21
|
+
export const TEMPLATE = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "prompts", "复盘.md");
|
|
22
|
+
/** 固定时钟:判据里的「派发时刻 / 未来时刻」都按它算,用例才可复现 */
|
|
23
|
+
export const NOW = 1_760_000_000_000;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 假子进程:pid 用**合成的号段**(不是本进程 pid)。
|
|
27
|
+
*
|
|
28
|
+
* 这样「子进程是死是活」完全由用例通过 setAlive 控制 —— 聚合与单飞闸都读注入的 alive,
|
|
29
|
+
* 不会因为测试进程自己活着就被判成「有在途复盘」。
|
|
30
|
+
* 合成 pid 对真 kill(pid,0) 来说是死的,于是残锁会被当作孤儿回收:正好符合「子进程已退出」的场景。
|
|
31
|
+
*/
|
|
32
|
+
export class FakeChild {
|
|
33
|
+
readonly pid: number;
|
|
34
|
+
constructor(pid: number) {
|
|
35
|
+
this.pid = pid;
|
|
36
|
+
}
|
|
37
|
+
private listeners = new Map<string, (arg: unknown) => void>();
|
|
38
|
+
unref(): void {}
|
|
39
|
+
once(event: string, listener: (arg: unknown) => void): this {
|
|
40
|
+
this.listeners.set(event, listener);
|
|
41
|
+
return this;
|
|
42
|
+
}
|
|
43
|
+
emit(event: string, arg: unknown): void {
|
|
44
|
+
this.listeners.get(event)?.(arg);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface Harness {
|
|
49
|
+
evolutionDir: string;
|
|
50
|
+
calls: { command: string; args: string[]; options: Record<string, unknown> }[];
|
|
51
|
+
children: FakeChild[];
|
|
52
|
+
events: TelemetryEvent[];
|
|
53
|
+
logs: string[];
|
|
54
|
+
tick: (ms: number) => void;
|
|
55
|
+
/** 「子进程还活着吗」:聚合与单飞闸共用这个注入,用例按需翻转 */
|
|
56
|
+
setAlive: (value: boolean) => void;
|
|
57
|
+
dispatch: ReturnType<typeof createDispatcher>["dispatch"];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function harness(tag: string): Harness {
|
|
61
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `skill-evolve-agg-${tag}-`));
|
|
62
|
+
const evolutionDir = path.join(dir, ".pi", "evolution");
|
|
63
|
+
const calls: Harness["calls"] = [];
|
|
64
|
+
const children: FakeChild[] = [];
|
|
65
|
+
const events: TelemetryEvent[] = [];
|
|
66
|
+
const logs: string[] = [];
|
|
67
|
+
const pushEvent = (event: TelemetryInput): void => {
|
|
68
|
+
events.push({ ...event, ts: NOW, iso: "" } as TelemetryEvent);
|
|
69
|
+
};
|
|
70
|
+
const writer: TelemetryWriter = { append: pushEvent, appendSync: pushEvent, flush: async () => undefined };
|
|
71
|
+
const spawnFn: SpawnLike = (command, args, options) => {
|
|
72
|
+
calls.push({ command, args, options });
|
|
73
|
+
// pid 从 90001 起编号:每个用例一个独立工作区,不会和本机真实进程号撞上也无关(judged by alive)
|
|
74
|
+
const child = new FakeChild(90_001 + children.length);
|
|
75
|
+
children.push(child);
|
|
76
|
+
return child as unknown as SpawnedChild;
|
|
77
|
+
};
|
|
78
|
+
let alive = true;
|
|
79
|
+
let clock = NOW;
|
|
80
|
+
const dispatcher = createDispatcher({
|
|
81
|
+
evolutionDir,
|
|
82
|
+
cwd: dir,
|
|
83
|
+
config: { ...DEFAULT_CONFIG, enabled: true, telemetry: true },
|
|
84
|
+
telemetry: writer,
|
|
85
|
+
queue: directQueue,
|
|
86
|
+
promptTemplatePath: TEMPLATE,
|
|
87
|
+
log: (msg) => logs.push(msg),
|
|
88
|
+
now: () => clock,
|
|
89
|
+
env: {},
|
|
90
|
+
spawnFn,
|
|
91
|
+
alive: () => alive,
|
|
92
|
+
supervisorSpawnFn: fakeSupervisorSpawn([]),
|
|
93
|
+
});
|
|
94
|
+
return {
|
|
95
|
+
evolutionDir,
|
|
96
|
+
calls,
|
|
97
|
+
children,
|
|
98
|
+
events,
|
|
99
|
+
logs,
|
|
100
|
+
tick: (ms) => {
|
|
101
|
+
clock += ms;
|
|
102
|
+
},
|
|
103
|
+
setAlive: (value) => {
|
|
104
|
+
alive = value;
|
|
105
|
+
},
|
|
106
|
+
dispatch: dispatcher.dispatch,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
export function readRun(h: Harness, runId: string): Record<string, unknown> {
|
|
110
|
+
return JSON.parse(fs.readFileSync(path.join(h.evolutionDir, "runs", runId, "run.json"), "utf8")) as Record<string, unknown>;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export const settleAsync = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 50));
|
|
114
|
+
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 同会话聚合检测:**判定规则**的单测(纯函数,不碰派发)。
|
|
3
|
+
*
|
|
4
|
+
* 现场(用户 2026-11-08 报的现象):同一个 sessionId 下堆了 1 条 analyzed + 7 条 stale,
|
|
5
|
+
* 同一段会话被反反复复复盘。根因是 runId 每轮结算都会算出新的,而全局单飞闸只管「同时一个子进程」。
|
|
6
|
+
*
|
|
7
|
+
* 判定口径:只有「同会话 + 仍在途(dispatched)+ 子进程活着 + 还没落 01-findings.json」才并入;
|
|
8
|
+
* 尸体(进程已死)交 stale 逻辑、已跑完的交下一轮 —— 这三种不许混淆。
|
|
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 { findSameSessionRun, readAggregateRuns, type AggregateRun } from "../lib/aggregate.ts";
|
|
17
|
+
import { NOW } from "./aggregate-harness.ts";
|
|
18
|
+
|
|
19
|
+
/** 假 run 的构造器:纯函数用例不需要真目录,只有 readAggregateRuns 才碰盘 */
|
|
20
|
+
function runOf(overrides: Partial<AggregateRun> & { sessionId: string }): AggregateRun {
|
|
21
|
+
return {
|
|
22
|
+
runId: "run-1",
|
|
23
|
+
runDir: "/tmp/run-1",
|
|
24
|
+
status: "dispatched",
|
|
25
|
+
pid: 4242,
|
|
26
|
+
dispatchedAt: NOW - 1000,
|
|
27
|
+
hasFindings: false,
|
|
28
|
+
coverage: null,
|
|
29
|
+
...overrides,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const alwaysAlive = (): boolean => true;
|
|
34
|
+
const alwaysDead = (): boolean => false;
|
|
35
|
+
|
|
36
|
+
test("纯函数:同会话在途且子进程活着 → 命中那条 run", () => {
|
|
37
|
+
const runs = [runOf({ sessionId: "s1", runId: "r1" })];
|
|
38
|
+
assert.equal(findSameSessionRun(runs, "s1", alwaysAlive, NOW)?.runId, "r1");
|
|
39
|
+
// 不同会话一律不并
|
|
40
|
+
assert.equal(findSameSessionRun(runs, "s2", alwaysAlive, NOW), null);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("纯函数:状态不是 dispatched(复盘已跑完)→ 不命中", () => {
|
|
44
|
+
for (const status of ["analyzed", "proposed", "reviewed", "applied", "stale", "failed"]) {
|
|
45
|
+
const runs = [runOf({ sessionId: "s1", status })];
|
|
46
|
+
assert.equal(findSameSessionRun(runs, "s1", alwaysAlive, NOW), null, status);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("纯函数:子进程已死 / 没有 pid → 不命中(尸体交给 stale,不往里并)", () => {
|
|
51
|
+
assert.equal(findSameSessionRun([runOf({ sessionId: "s1" })], "s1", alwaysDead, NOW), null);
|
|
52
|
+
assert.equal(findSameSessionRun([runOf({ sessionId: "s1", pid: null })], "s1", alwaysAlive, NOW), null);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("纯函数:01-findings.json 已落盘 → 不命中(状态可能还没回写)", () => {
|
|
56
|
+
assert.equal(findSameSessionRun([runOf({ sessionId: "s1", hasFindings: true })], "s1", alwaysAlive, NOW), null);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("纯函数:多条命中取最近派发的;派发时刻在未来的(时钟回拨)不并", () => {
|
|
60
|
+
const runs = [
|
|
61
|
+
runOf({ sessionId: "s1", runId: "old", dispatchedAt: NOW - 5000 }),
|
|
62
|
+
runOf({ sessionId: "s1", runId: "new", dispatchedAt: NOW - 100 }),
|
|
63
|
+
runOf({ sessionId: "s1", runId: "future", dispatchedAt: NOW + 60_000 }),
|
|
64
|
+
];
|
|
65
|
+
assert.equal(findSameSessionRun(runs, "s1", alwaysAlive, NOW)?.runId, "new");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("readAggregateRuns:坏 run.json 一律跳过,不伪造在途记录", () => {
|
|
69
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-agg-scan-"));
|
|
70
|
+
const evolutionDir = path.join(dir, ".pi", "evolution");
|
|
71
|
+
fs.mkdirSync(path.join(evolutionDir, "runs", "good"), { recursive: true });
|
|
72
|
+
fs.writeFileSync(path.join(evolutionDir, "runs", "good", "run.json"), JSON.stringify({ runId: "good", sessionId: "s1", status: "dispatched", pid: 7, dispatchedAt: NOW }), "utf8");
|
|
73
|
+
fs.mkdirSync(path.join(evolutionDir, "runs", "half"), { recursive: true });
|
|
74
|
+
fs.writeFileSync(path.join(evolutionDir, "runs", "half", "run.json"), '{"runId":"half"', "utf8");
|
|
75
|
+
const runs = readAggregateRuns(evolutionDir);
|
|
76
|
+
assert.deepEqual(runs.map((run) => run.runId), ["good"]);
|
|
77
|
+
// 01-findings.json 存在即视为复盘已跑完
|
|
78
|
+
fs.writeFileSync(path.join(evolutionDir, "runs", "good", "01-findings.json"), "{}", "utf8");
|
|
79
|
+
assert.equal(readAggregateRuns(evolutionDir)[0]?.hasFindings, true);
|
|
80
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 会话页快速导航(用户 2026-11-08:「会话缺少快速导航,一直往下滚动太累了」)的验收测试。
|
|
3
|
+
*
|
|
4
|
+
* 口径:目录与锚点由**服务端一次遍历**产出(不引 JS,不做滚动高亮);
|
|
5
|
+
* 宽屏左侧常驻、窄屏顶部折叠是同一份 HTML 的两种 CSS,所以两处内容必须一模一样。
|
|
6
|
+
*
|
|
7
|
+
* 纪律:`SessionSource` 一律由本文件现造的 JSONL 解析而来,不读真实 sessions 目录。
|
|
8
|
+
*/
|
|
9
|
+
import { test } from "node:test";
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
|
|
12
|
+
import type { WebState } from "../lib/web/data.ts";
|
|
13
|
+
import { renderSessionPage } from "../lib/web/render-session.ts";
|
|
14
|
+
import { parseSessionText } from "../lib/web/session-view.ts";
|
|
15
|
+
import type { SessionSource } from "../lib/web/session-file.ts";
|
|
16
|
+
|
|
17
|
+
const SID = "01a1b90f-08c3-722c-871d-d024b083770a";
|
|
18
|
+
const FILE = `/tmp/${SID}.jsonl`;
|
|
19
|
+
|
|
20
|
+
function line(record: unknown): string {
|
|
21
|
+
return `${JSON.stringify(record)}\n`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function user(text: string, at: number): string {
|
|
25
|
+
return line({
|
|
26
|
+
type: "message",
|
|
27
|
+
timestamp: new Date(at).toISOString(),
|
|
28
|
+
message: { role: "user", content: [{ type: "text", text }] },
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function assistant(text: string, calls: { id: string; name: string; arguments?: unknown }[] = []): string {
|
|
33
|
+
const content: unknown[] = [];
|
|
34
|
+
if (text.length > 0) content.push({ type: "text", text });
|
|
35
|
+
for (const call of calls) content.push({ type: "toolCall", id: call.id, name: call.name, arguments: call.arguments ?? {} });
|
|
36
|
+
return line({ type: "message", message: { role: "assistant", content } });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function result(toolCallId: string, text: string): string {
|
|
40
|
+
return line({
|
|
41
|
+
type: "message",
|
|
42
|
+
message: { role: "toolResult", toolCallId, toolName: "read", content: [{ type: "text", text }] },
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 三条用户消息 + 两段工具调用(够测导航、锚点与「只看对话」) */
|
|
47
|
+
function jsonl(): string {
|
|
48
|
+
const t = (hour: number, minute: number) => new Date(2026, 10, 8, hour, minute).getTime();
|
|
49
|
+
return (
|
|
50
|
+
user("第一段:先看列表页", t(10, 32)) +
|
|
51
|
+
assistant("", [{ id: "c1", name: "read", arguments: { path: "render.ts" } }]) +
|
|
52
|
+
result("c1", "ok") +
|
|
53
|
+
user("第二段:再改样式", t(11, 5)) +
|
|
54
|
+
assistant("", [
|
|
55
|
+
{ id: "c2", name: "bash", arguments: { command: "npm test" } },
|
|
56
|
+
{ id: "c3", name: "bash", arguments: { command: "npm run lint" } },
|
|
57
|
+
]) +
|
|
58
|
+
result("c2", "ok") +
|
|
59
|
+
result("c3", "ok") +
|
|
60
|
+
user("第三段:收尾", t(11, 52)) +
|
|
61
|
+
assistant("好了")
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function emptyState(): WebState {
|
|
66
|
+
return {
|
|
67
|
+
pending: [],
|
|
68
|
+
approvals: [],
|
|
69
|
+
applied: [],
|
|
70
|
+
done: [],
|
|
71
|
+
reports: [],
|
|
72
|
+
summary: { reviewing: 0, stale: 0, pendingApprovals: 0, appliedTotal: 0, rollbackRate: 0 },
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function source(raw = jsonl()): SessionSource {
|
|
77
|
+
return { sessionId: SID, file: FILE, sizeBytes: Buffer.byteLength(raw, "utf8"), transcript: parseSessionText(raw) };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function page(options: { onlyUser?: boolean } = {}): string {
|
|
81
|
+
return renderSessionPage(source(), { state: emptyState(), onlyUser: options.onlyUser === true });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 取 aside 里的目录链接(宽屏那份) */
|
|
85
|
+
function tocLinks(html: string): string[] {
|
|
86
|
+
const aside = /<aside class="toc">([\s\S]*?)<\/aside>/.exec(html)?.[1] ?? "";
|
|
87
|
+
return [...aside.matchAll(/<a href="#(m-\d+)">/g)].map((match) => match[1] ?? "");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
test("目录条目数等于渲染出的用户消息数(用户消息就是天然章节)", () => {
|
|
91
|
+
const html = page();
|
|
92
|
+
assert.equal(tocLinks(html).length, 3);
|
|
93
|
+
assert.match(html, /<a href="#m-1">10:32 · 第一段:先看列表页<\/a>/);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("锚点与目录一一对应:id 唯一、href 全都能落到实处", () => {
|
|
97
|
+
const html = page();
|
|
98
|
+
const ids = [...html.matchAll(/<div class="msg user m" id="(m-\d+)">/g)].map((match) => match[1]);
|
|
99
|
+
assert.deepEqual(ids, ["m-1", "m-3", "m-5"], "三条用户消息的块号(工具块不占 msg 块号)");
|
|
100
|
+
assert.deepEqual(new Set(ids).size, ids.length, "id 不许重复");
|
|
101
|
+
assert.deepEqual(tocLinks(html), ids, "目录指向的正是这三块");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("窄屏折叠目录:同一份内容放在顶部 <details> 里(宽屏由 CSS 隐藏)", () => {
|
|
105
|
+
const html = page();
|
|
106
|
+
assert.match(html, /<details class="toc-fold"><summary>目录(3 条用户消息)<\/summary>/);
|
|
107
|
+
const fold = /<div class="toc-list">([\s\S]*?)<\/div>/.exec(html)?.[1] ?? "";
|
|
108
|
+
assert.deepEqual([...fold.matchAll(/<a href="#(m-\d+)">/g)].map((match) => match[1]), tocLinks(html));
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("目录文本:转义且截到 40 字(长标题不把侧栏撑成两行)", () => {
|
|
112
|
+
const long = `${"很".repeat(60)}<b>x</b>`;
|
|
113
|
+
const html = renderSessionPage(
|
|
114
|
+
{ sessionId: SID, file: FILE, sizeBytes: 10, transcript: parseSessionText(user(long, new Date(2026, 10, 8, 9, 0).getTime())) },
|
|
115
|
+
{ state: emptyState() },
|
|
116
|
+
);
|
|
117
|
+
const aside = /<aside class="toc">([\s\S]*?)<\/aside>/.exec(html)?.[1] ?? "";
|
|
118
|
+
assert.match(aside, new RegExp(`09:00 · 很{40}…`));
|
|
119
|
+
assert.equal(aside.includes("<b>"), false, "目录文本必须转义");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("只看对话:工具块全部省略成一行,且给「看全部」的回头路", () => {
|
|
123
|
+
const html = page({ onlyUser: true });
|
|
124
|
+
assert.equal(html.includes('<details class="tools">'), false);
|
|
125
|
+
assert.match(html, /<p class="hint">… 本轮省略 1 次工具调用/);
|
|
126
|
+
assert.match(html, /<p class="hint">… 本轮省略 2 次工具调用(<a href="[^"]+">看全部<\/a>)<\/p>/);
|
|
127
|
+
const back = /<a href="([^"]+)">看全部<\/a>/.exec(html)?.[1] ?? "";
|
|
128
|
+
assert.ok(back.includes(`sessionId=${SID}`), "回头路必须还在这个会话");
|
|
129
|
+
assert.equal(back.includes("only=user"), false, "看全部 = 去掉 only=user");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("看全部 ⇄ 只看对话:切换链接都带着 sessionId 与原文件路径", () => {
|
|
133
|
+
const all = page();
|
|
134
|
+
const toOnly = /<a href="([^"]+)">只看对话<\/a>/.exec(all)?.[1] ?? "";
|
|
135
|
+
assert.ok(toOnly.includes("only=user"));
|
|
136
|
+
assert.ok(toOnly.includes(encodeURIComponent(FILE)), "换机器时 file 参数是找回原文的关键");
|
|
137
|
+
const only = page({ onlyUser: true });
|
|
138
|
+
assert.match(only, /<span class="ov-link"><a href="[^"]*">看全部<\/a>(当前只看对话)<\/span>/);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("两个跳转:顶部「跳到最新 ↓」指向最后一块,底部「↑ 回到顶部」指回 #top", () => {
|
|
142
|
+
const html = page();
|
|
143
|
+
// 最后一块是收尾的助手消息(m-6):跳转要落在它身上,不能落在工具块上
|
|
144
|
+
assert.match(html, /<a class="to-end" href="#m-6">跳到最新 ↓<\/a>/);
|
|
145
|
+
assert.match(html, /<a class="to-top" href="#top">↑ 回到顶部<\/a>/);
|
|
146
|
+
assert.match(html, /<div class="wrap" id="top">/);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("没有用户消息时目录给一句人话;没有任何块时也不给「跳到最新」", () => {
|
|
150
|
+
const onlyAssistant = renderSessionPage(
|
|
151
|
+
{ sessionId: SID, file: FILE, sizeBytes: 10, transcript: parseSessionText(assistant("只有助手在自言自语")) },
|
|
152
|
+
{ state: emptyState() },
|
|
153
|
+
);
|
|
154
|
+
assert.match(onlyAssistant, /这段会话里没有用户消息/);
|
|
155
|
+
|
|
156
|
+
const nothing = renderSessionPage(
|
|
157
|
+
{ sessionId: SID, file: FILE, sizeBytes: 0, transcript: parseSessionText("") },
|
|
158
|
+
{ state: emptyState() },
|
|
159
|
+
);
|
|
160
|
+
assert.equal(nothing.includes('class="to-end"'), false, "没有块可跳就不给「跳到最新」");
|
|
161
|
+
assert.match(nothing, /没有可渲染的对话/);
|
|
162
|
+
});
|
|
@@ -0,0 +1,200 @@
|
|
|
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:顶部一行给返回入口 / 会话短 id / 原文件,概览与出口给统计,工具块默认收起", () => {
|
|
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
|
+
// 顶部一行:回来有路、会话只显示前 8 位(完整 id 在 title 里,9 条 run 的前缀全一样)
|
|
157
|
+
assert.match(html, /<a href="\/\?view=pending&range=all">← 返回待处理<\/a>/);
|
|
158
|
+
assert.match(html, /<span class="ses-id">会话 <code>01a1b90f<\/code><\/span>/);
|
|
159
|
+
assert.match(html, /原文件 <code title="[^"]*2026-11-08T00-00-00-000Z_.*\.jsonl">/);
|
|
160
|
+
// 概览:消息 / 工具调用 / 大小(时间跨度只在这段会话有 timestamp 时才出现)
|
|
161
|
+
assert.match(html, /<p class="ov">消息 3 · 工具调用 1 · \d+B/);
|
|
162
|
+
// 出口:渲染了多少条、原文件在哪
|
|
163
|
+
assert.match(html, /已渲染全部 3 条消息(原文件 <code>.*\.jsonl<\/code>)/);
|
|
164
|
+
assert.equal(/<details[^>]*\sopen/.test(html), false, "工具块默认收起");
|
|
165
|
+
assert.match(html, /看一下这个文件/);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("renderSessionPage:会话文件不在本机时给一句人话,不是空白页", () => {
|
|
169
|
+
const source = readSessionSource({
|
|
170
|
+
evolutionDir: fakeEvolution("run-1", { sessionId: SESSION_ID, status: "rejected" }),
|
|
171
|
+
sessionId: SESSION_ID,
|
|
172
|
+
home: fs.mkdtempSync(path.join(os.tmpdir(), "skill-evolve-home-empty-")),
|
|
173
|
+
});
|
|
174
|
+
const html = renderSessionPage(source, { state: emptyState() });
|
|
175
|
+
assert.match(html, /会话文件已不在本机/);
|
|
176
|
+
assert.match(html, /原文件:<span class="off">会话文件已不在本机<\/span>/);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("HTTP 路由:GET /?view=session&sessionId= 接对了(HOME 指到临时目录,绝不碰真实 sessions)", async () => {
|
|
180
|
+
const { home, file } = fakeHome();
|
|
181
|
+
const evolutionDir = fakeEvolution("run-1", { sessionId: SESSION_ID, sessionFile: file, status: "rejected" });
|
|
182
|
+
const original = process.env.HOME;
|
|
183
|
+
process.env.HOME = home;
|
|
184
|
+
const result = await startWebPanel({ evolutionDir, port: 0 });
|
|
185
|
+
assert.equal(result.ok, true);
|
|
186
|
+
if (!result.ok) return;
|
|
187
|
+
try {
|
|
188
|
+
const response = await fetch(`${result.panel.url}/?view=session&sessionId=${SESSION_ID}`);
|
|
189
|
+
assert.equal(response.status, 200);
|
|
190
|
+
assert.match(response.headers.get("content-type") ?? "", /text\/html/);
|
|
191
|
+
const html = await response.text();
|
|
192
|
+
assert.match(html, /<details class="tools"><summary>▸ read ×1 · <span class="muted">/);
|
|
193
|
+
assert.equal(/<details[^>]*\sopen/.test(html), false);
|
|
194
|
+
assert.match(html, /← 返回待处理/);
|
|
195
|
+
} finally {
|
|
196
|
+
await result.panel.close();
|
|
197
|
+
if (original === undefined) delete process.env.HOME;
|
|
198
|
+
else process.env.HOME = original;
|
|
199
|
+
}
|
|
200
|
+
});
|