pi-skill-evolve 0.1.5 → 0.1.7
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/runs.ts +4 -1
- 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 +5 -5
- package/extensions/skill-evolve/lib/web/render-controls.ts +58 -32
- package/extensions/skill-evolve/lib/web/render-rows.ts +178 -235
- package/extensions/skill-evolve/lib/web/render-session.ts +105 -34
- 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/server.ts +9 -1
- package/extensions/skill-evolve/lib/web/session-render.ts +189 -0
- package/extensions/skill-evolve/lib/web/session-view.ts +100 -84
- package/extensions/skill-evolve/lib/web/shell.ts +42 -11
- package/extensions/skill-evolve/lib/web/style.ts +171 -106
- package/extensions/skill-evolve/lib/web/text.ts +57 -0
- package/extensions/skill-evolve/test/pending-kinds.test.ts +142 -0
- package/extensions/skill-evolve/test/session-nav.test.ts +162 -0
- package/extensions/skill-evolve/test/session-page.test.ts +11 -5
- package/extensions/skill-evolve/test/session-render.test.ts +203 -0
- package/extensions/skill-evolve/test/session-view.test.ts +75 -87
- 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 +30 -32
- package/extensions/skill-evolve/test/web-shell.test.ts +7 -5
- package/package.json +1 -1
|
@@ -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
|
+
});
|
|
@@ -148,14 +148,19 @@ test("readSessionSource:非法 sessionId 直接返回空,不扫盘", () => {
|
|
|
148
148
|
assert.deepEqual([source.file, source.transcript], [null, null]);
|
|
149
149
|
});
|
|
150
150
|
|
|
151
|
-
test("renderSessionPage
|
|
151
|
+
test("renderSessionPage:顶部一行给返回入口 / 会话短 id / 原文件,概览与出口给统计,工具块默认收起", () => {
|
|
152
152
|
const { home, file } = fakeHome();
|
|
153
153
|
const evolutionDir = fakeEvolution("run-1", { sessionId: SESSION_ID, sessionFile: file, status: "rejected" });
|
|
154
154
|
const source = readSessionSource({ evolutionDir, sessionId: SESSION_ID, home });
|
|
155
155
|
const html = renderSessionPage(source, { state: emptyState() });
|
|
156
|
-
|
|
157
|
-
assert.match(html, /<
|
|
158
|
-
assert.match(html,
|
|
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>)/);
|
|
159
164
|
assert.equal(/<details[^>]*\sopen/.test(html), false, "工具块默认收起");
|
|
160
165
|
assert.match(html, /看一下这个文件/);
|
|
161
166
|
});
|
|
@@ -184,8 +189,9 @@ test("HTTP 路由:GET /?view=session&sessionId= 接对了(HOME 指到临时
|
|
|
184
189
|
assert.equal(response.status, 200);
|
|
185
190
|
assert.match(response.headers.get("content-type") ?? "", /text\/html/);
|
|
186
191
|
const html = await response.text();
|
|
187
|
-
assert.match(html,
|
|
192
|
+
assert.match(html, /<details class="tools"><summary>▸ read ×1 · <span class="muted">/);
|
|
188
193
|
assert.equal(/<details[^>]*\sopen/.test(html), false);
|
|
194
|
+
assert.match(html, /← 返回待处理/);
|
|
189
195
|
} finally {
|
|
190
196
|
await result.panel.close();
|
|
191
197
|
if (original === undefined) delete process.env.HOME;
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 会话正文的渲染测试(node --test):Markdown-lite(粗体 / 行内代码 / 标题 / 列表 / 代码块)、
|
|
3
|
+
* 注入防护、工具块折叠抬头、正文与目录截断。
|
|
4
|
+
*
|
|
5
|
+
* 纪律:只用本文件现造的 JSONL 片段解析,不读真实 `~/.pi/agent/sessions`。
|
|
6
|
+
*/
|
|
7
|
+
import { test } from "node:test";
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
|
|
10
|
+
import { renderTranscript, renderToc, TOOL_PREVIEW, TOC_TEXT } from "../lib/web/session-render.ts";
|
|
11
|
+
import { parseSessionText } from "../lib/web/session-view.ts";
|
|
12
|
+
|
|
13
|
+
function line(record: unknown): string {
|
|
14
|
+
return `${JSON.stringify(record)}\n`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function user(text: string, at?: number): string {
|
|
18
|
+
const record = { type: "message", message: { role: "user", content: [{ type: "text", text }] } };
|
|
19
|
+
return line(at === undefined ? record : { ...record, timestamp: new Date(at).toISOString() });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function assistant(text: string, calls: { id: string; name: string; arguments?: unknown }[] = []): string {
|
|
23
|
+
const content: unknown[] = [];
|
|
24
|
+
if (text.length > 0) content.push({ type: "text", text });
|
|
25
|
+
for (const call of calls) content.push({ type: "toolCall", id: call.id, name: call.name, arguments: call.arguments ?? {} });
|
|
26
|
+
return line({ type: "message", message: { role: "assistant", content } });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function result(toolCallId: string, text: string): string {
|
|
30
|
+
return line({
|
|
31
|
+
type: "message",
|
|
32
|
+
message: { role: "toolResult", toolCallId, toolName: "read", content: [{ type: "text", text }] },
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
test("消息块:用户蓝徽章 + 时刻 + 用户底色 class,助手绿徽章", () => {
|
|
37
|
+
const at = new Date(2026, 10, 8, 19, 35, 12).getTime();
|
|
38
|
+
const view = renderTranscript(parseSessionText(user("你好", at) + assistant("你好,有什么要看的?")));
|
|
39
|
+
assert.match(view.body, /<div class="msg user m" id="m-1">\n<div class="mhead"><span class="badge blue">用户<\/span><span class="at">19:35:12<\/span><\/div>/);
|
|
40
|
+
assert.match(view.body, /<div class="msg assistant m" id="m-2">\n<div class="mhead"><span class="badge green">助手<\/span>/);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("Markdown-lite:粗体与行内代码(不再把星号原样念给人听)", () => {
|
|
44
|
+
const view = renderTranscript(parseSessionText(assistant("这是 **重点**,路径是 `lib/web/render.ts`。")));
|
|
45
|
+
assert.match(view.body, /<strong>重点<\/strong>/);
|
|
46
|
+
assert.match(view.body, /<code>lib\/web\/render\.ts<\/code>/);
|
|
47
|
+
assert.equal(view.body.includes("**重点**"), false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("Markdown-lite:# / ## 出标题(h3 / h4,不用 h1 抢页面标题)", () => {
|
|
51
|
+
const view = renderTranscript(parseSessionText(assistant("# 结论\n\n## 细节")));
|
|
52
|
+
assert.match(view.body, /<h3 class="md-h">结论<\/h3>/);
|
|
53
|
+
assert.match(view.body, /<h4 class="md-h">细节<\/h4>/);
|
|
54
|
+
assert.equal(/<h1/.test(view.body), false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("Markdown-lite:`- ` 列表与 ``` 代码块", () => {
|
|
58
|
+
const view = renderTranscript(parseSessionText(assistant("- 第一行\n- 第二行\n\n```ts\nconst a = 1;\n```")));
|
|
59
|
+
assert.match(view.body, /<ul class="md-ul"><li>第一行<\/li><li>第二行<\/li><\/ul>/);
|
|
60
|
+
assert.match(view.body, /<pre class="code"><code>const a = 1;<\/code><\/pre>/);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("注入防护:<script> 被转义,[x](javascript:...) 只留文字不留链接", () => {
|
|
64
|
+
const view = renderTranscript(parseSessionText(user("<script>alert(1)</script> 看 [点我](javascript:alert(1))")));
|
|
65
|
+
assert.equal(/<script/i.test(view.body), false);
|
|
66
|
+
assert.match(view.body, /<script>alert\(1\)<\/script>/);
|
|
67
|
+
assert.equal(view.body.includes("javascript:"), false, "URL 整段丢弃,不能变成可点的洞");
|
|
68
|
+
assert.match(view.body, /看 点我/);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("工具块:默认收起(不带 open),抬头是「▸ 工具名 ×次数 · 首个参数摘要」", () => {
|
|
72
|
+
const raw =
|
|
73
|
+
assistant("", [
|
|
74
|
+
{ id: "c1", name: "read", arguments: { path: "a.ts" } },
|
|
75
|
+
{ id: "c2", name: "read", arguments: { path: "b.ts" } },
|
|
76
|
+
{ id: "c3", name: "bash", arguments: { command: "ls" } },
|
|
77
|
+
]) +
|
|
78
|
+
result("c1", "A") +
|
|
79
|
+
result("c2", "B") +
|
|
80
|
+
result("c3", "C");
|
|
81
|
+
const view = renderTranscript(parseSessionText(raw));
|
|
82
|
+
assert.match(view.body, /<details class="tools"><summary>▸ read ×2、bash ×1 · <span class="muted">/);
|
|
83
|
+
assert.equal(/<details[^>]*\sopen/.test(view.body), false, "默认收起");
|
|
84
|
+
assert.match(view.body, /<p class="tool-name">read<\/p>/);
|
|
85
|
+
assert.match(view.body, /<pre class="tool-args">\{"path":"a\.ts"\}<\/pre>/);
|
|
86
|
+
assert.match(view.body, /<pre class="tool-result">A<\/pre>/);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
/** 取工具块抬头里那句摘要(没有摘要就是空串) */
|
|
90
|
+
function muted(view: { body: string }): string {
|
|
91
|
+
return /<span class="muted">([^<]*)<\/span>/.exec(view.body)?.[1] ?? "";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
test("工具抬头:bash 只取 command 第一行(\\n 还原成空格、首尾引号去掉)", () => {
|
|
95
|
+
// 两种情况:真换行只留第一行;字面量 \n 还原成空格。首尾引号一律去掉
|
|
96
|
+
const realNewline = '"T0=$(date +%s%N)\\nD=$((1 + 1))"'.replace("\\n", "\n");
|
|
97
|
+
const literal = '"T0=$(date +%s%N)\\nD=$((1 + 1))"';
|
|
98
|
+
const head = (command: string): string =>
|
|
99
|
+
muted(
|
|
100
|
+
renderTranscript(
|
|
101
|
+
parseSessionText(assistant("", [{ id: "c1", name: "bash", arguments: { command } }]) + result("c1", "ok")),
|
|
102
|
+
),
|
|
103
|
+
);
|
|
104
|
+
assert.equal(head(realNewline), "T0=$(date +%s%N)", "真换行只留第一行");
|
|
105
|
+
assert.equal(head(literal), "T0=$(date +%s%N) D=$((1 + 1))", "字面量 \\n 还原成空格");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("工具抬头:read / write / edit 取文件名,rg / grep 取 pattern,其它工具取第一个字符串参数", () => {
|
|
109
|
+
const cases: { name: string; args: Record<string, unknown>; expect: string }[] = [
|
|
110
|
+
{ name: "read", args: { path: "/home/suveng/lib/web/a.ts" }, expect: "a.ts" },
|
|
111
|
+
{ name: "write", args: { path: "docs/README.md", content: "很长" }, expect: "README.md" },
|
|
112
|
+
{ name: "edit", args: { path: "/tmp/x/y.json" }, expect: "y.json" },
|
|
113
|
+
{ name: "rg", args: { pattern: "renderPage|escapeHtml", path: "lib" }, expect: "renderPage|escapeHtml" },
|
|
114
|
+
{ name: "grep", args: { pattern: "renderPage" }, expect: "renderPage" },
|
|
115
|
+
{ name: "custom_tool", args: { runId: "run-1", path: "p" }, expect: "run-1" },
|
|
116
|
+
];
|
|
117
|
+
for (const item of cases) {
|
|
118
|
+
const view = renderTranscript(
|
|
119
|
+
parseSessionText(
|
|
120
|
+
assistant("", [{ id: "c1", name: item.name, arguments: item.args }]) + result("c1", "ok"),
|
|
121
|
+
),
|
|
122
|
+
);
|
|
123
|
+
assert.equal(muted(view), item.expect, `${item.name} 的摘要`);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("工具抬头:compress 不给摘要,只显示 ×N;取不到参数时也只有 ×N", () => {
|
|
128
|
+
const compress = renderTranscript(
|
|
129
|
+
parseSessionText(
|
|
130
|
+
assistant("", [{ id: "c1", name: "compress", arguments: { ranges: ["m1-m5"], note: "很长" } }]) + result("c1", "ok"),
|
|
131
|
+
),
|
|
132
|
+
);
|
|
133
|
+
assert.match(compress.body, /<summary>▸ compress ×1<\/summary>/, "compress 抬头不带参数");
|
|
134
|
+
assert.equal(compress.body.includes('class="muted"'), false);
|
|
135
|
+
|
|
136
|
+
const empty = renderTranscript(parseSessionText(assistant("", [{ id: "c1", name: "write", arguments: {} }]) + result("c1", "ok")));
|
|
137
|
+
assert.match(empty.body, /<summary>▸ write ×1<\/summary>/);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("工具抬头摘要里不出现原始 JSON 片段(花括号引号、转义引号、参数名)", () => {
|
|
141
|
+
const args = { command: `bash -lc "curl -d '{"cmd": "x"}'" && echo \\n done`, pattern: "a", path: "b" };
|
|
142
|
+
const view = renderTranscript(parseSessionText(assistant("", [{ id: "c1", name: "bash", arguments: args }]) + result("c1", "ok")));
|
|
143
|
+
const summary = muted(view);
|
|
144
|
+
assert.equal(summary.includes('{"'), false, "摘要里不许出现 {\"");
|
|
145
|
+
assert.equal(summary.includes('\\"'), false, "摘要里不许出现转义引号");
|
|
146
|
+
assert.equal(summary.includes('"command"'), false, "摘要里不许出现参数名");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("工具抬头:摘要超过 60 字要截断并转义", () => {
|
|
150
|
+
const command = `<b>${"x".repeat(TOOL_PREVIEW + 20)}</b>`;
|
|
151
|
+
const view = renderTranscript(
|
|
152
|
+
parseSessionText(assistant("", [{ id: "c1", name: "bash", arguments: { command } }]) + result("c1", "ok")),
|
|
153
|
+
);
|
|
154
|
+
const summary = muted(view);
|
|
155
|
+
assert.equal(summary.endsWith("…"), true, "截断要看得出来");
|
|
156
|
+
assert.equal(summary.includes("x".repeat(TOOL_PREVIEW)), false, `摘要按 ${TOOL_PREVIEW} 字截断`);
|
|
157
|
+
assert.equal(summary.includes("<b>"), false, "摘要里的标签必须转义");
|
|
158
|
+
assert.equal(summary.includes("<b>"), true);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("正文截断:用户 2000 字、助手 4000 字,超了加省略号", () => {
|
|
162
|
+
const view = renderTranscript(parseSessionText(user("u".repeat(2500)) + assistant("a".repeat(4500))));
|
|
163
|
+
assert.equal(view.body.includes(`${"u".repeat(2000)}…`), true);
|
|
164
|
+
assert.equal(view.body.includes("u".repeat(2001)), false);
|
|
165
|
+
assert.equal(view.body.includes(`${"a".repeat(4000)}…`), true);
|
|
166
|
+
assert.equal(view.body.includes("a".repeat(4001)), false);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("只有工具调用的助手消息不产出空块(shown 不计它)", () => {
|
|
170
|
+
const view = renderTranscript(parseSessionText(user("看一下") + assistant("", [{ id: "c1", name: "read", arguments: {} }]) + result("c1", "ok")));
|
|
171
|
+
assert.equal(view.shown, 2, "一条用户消息 + 一个工具块");
|
|
172
|
+
assert.equal(view.toc.length, 1);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("只看对话:工具块换成一行「省略 N 次调用(看全部)」", () => {
|
|
176
|
+
const raw =
|
|
177
|
+
user("看一下") +
|
|
178
|
+
assistant("", [
|
|
179
|
+
{ id: "c1", name: "read", arguments: { path: "a" } },
|
|
180
|
+
{ id: "c2", name: "read", arguments: { path: "b" } },
|
|
181
|
+
]) +
|
|
182
|
+
result("c1", "A") +
|
|
183
|
+
result("c2", "B");
|
|
184
|
+
const view = renderTranscript(parseSessionText(raw), { onlyUser: true, expandHref: "/?view=session&sessionId=x" });
|
|
185
|
+
assert.equal(view.body.includes('<details class="tools">'), false);
|
|
186
|
+
assert.match(view.body, /<p class="hint">… 本轮省略 2 次工具调用(<a href="[^"]+">看全部<\/a>)<\/p>/);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("目录:只收用户消息,条目写「时刻 · 前 40 字」,换行压成空格并转义", () => {
|
|
190
|
+
const at = new Date(2026, 10, 8, 19, 35, 12).getTime();
|
|
191
|
+
const long = `${"很".repeat(TOC_TEXT + 10)}\n第二行 <b>粗</b>`;
|
|
192
|
+
const view = renderTranscript(parseSessionText(user(long, at) + assistant("回复")));
|
|
193
|
+
assert.equal(view.toc.length, 1);
|
|
194
|
+
assert.equal(view.toc[0]?.id, "m-1");
|
|
195
|
+
const toc = renderToc(view.toc);
|
|
196
|
+
assert.match(toc, /<a href="#m-1">19:35 · 很{40}…<\/a>/);
|
|
197
|
+
assert.equal(toc.includes("\n第二行"), false);
|
|
198
|
+
assert.equal(toc.includes("<b>"), false, "目录文本必须转义");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("目录没有用户消息时给一句人话(不留空栏)", () => {
|
|
202
|
+
assert.match(renderToc([]), /这段会话里没有用户消息/);
|
|
203
|
+
});
|
|
@@ -1,40 +1,45 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* 渲染(默认收起、全转义、单条与总输出截断、只留最后 200 条)。
|
|
2
|
+
* 会话详情的解析单测(node --test):JSONL 只认 message、跳过 system / thinking、
|
|
3
|
+
* 工具调用回连结果、连续调用聚块、条数与长度上限。
|
|
5
4
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* 渲染部分(Markdown-lite、折叠抬头、目录、总输出上限)在 ./session-render.test.ts 与 ./session-nav.test.ts 里,
|
|
6
|
+
* 因为解析与渲染已经是两个文件,测试也各盯一处,失败时不用先猜是哪一半。
|
|
7
|
+
*
|
|
8
|
+
* 纪律:不读真实 `~/.pi/agent/sessions`、不读真实 run 目录 —— 所有输入都是本文件里现造的 JSONL 片段。
|
|
8
9
|
*/
|
|
9
10
|
import { test } from "node:test";
|
|
10
11
|
import assert from "node:assert/strict";
|
|
11
12
|
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
MAX_OUTPUT,
|
|
15
|
-
parseSessionText,
|
|
16
|
-
renderSessionBody,
|
|
17
|
-
type SessionTranscript,
|
|
18
|
-
} from "../lib/web/session-view.ts";
|
|
13
|
+
import { MAX_MESSAGES, MAX_OUTPUT, parseSessionText, type SessionTranscript } from "../lib/web/session-view.ts";
|
|
14
|
+
import { renderTranscript } from "../lib/web/session-render.ts";
|
|
19
15
|
|
|
20
|
-
/** 一行 JSONL(pi 的真实形态:顶层 type + message) */
|
|
16
|
+
/** 一行 JSONL(pi 的真实形态:顶层 type + message,可选 timestamp) */
|
|
21
17
|
function line(record: unknown): string {
|
|
22
18
|
return `${JSON.stringify(record)}\n`;
|
|
23
19
|
}
|
|
24
20
|
|
|
25
|
-
function userMessage(text: string): string {
|
|
26
|
-
|
|
21
|
+
function userMessage(text: string, at?: number): string {
|
|
22
|
+
const record = { type: "message", message: { role: "user", content: [{ type: "text", text }] } };
|
|
23
|
+
return line(at === undefined ? record : { ...record, timestamp: new Date(at).toISOString() });
|
|
27
24
|
}
|
|
28
25
|
|
|
29
|
-
function assistantMessage(
|
|
26
|
+
function assistantMessage(
|
|
27
|
+
text: string,
|
|
28
|
+
calls: { id: string; name: string; arguments?: unknown }[] = [],
|
|
29
|
+
at?: number,
|
|
30
|
+
): string {
|
|
30
31
|
const content: unknown[] = [];
|
|
31
32
|
if (text.length > 0) content.push({ type: "text", text });
|
|
32
33
|
for (const call of calls) content.push({ type: "toolCall", id: call.id, name: call.name, arguments: call.arguments ?? {} });
|
|
33
|
-
|
|
34
|
+
const record = { type: "message", message: { role: "assistant", content } };
|
|
35
|
+
return line(at === undefined ? record : { ...record, timestamp: new Date(at).toISOString() });
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
function toolResult(toolCallId: string, text: string): string {
|
|
37
|
-
return line({
|
|
39
|
+
return line({
|
|
40
|
+
type: "message",
|
|
41
|
+
message: { role: "toolResult", toolCallId, toolName: "read", content: [{ type: "text", text }] },
|
|
42
|
+
});
|
|
38
43
|
}
|
|
39
44
|
|
|
40
45
|
test("解析:只认顶层 type=message,session / model_change / custom 全跳过", () => {
|
|
@@ -46,15 +51,33 @@ test("解析:只认顶层 type=message,session / model_change / custom 全
|
|
|
46
51
|
"这不是 JSON\n";
|
|
47
52
|
const transcript = parseSessionText(raw);
|
|
48
53
|
assert.equal(transcript.messages, 1);
|
|
49
|
-
assert.deepEqual(transcript.entries, [{ kind: "user", text: "你好" }]);
|
|
54
|
+
assert.deepEqual(transcript.entries, [{ kind: "user", text: "你好", at: null }]);
|
|
50
55
|
});
|
|
51
56
|
|
|
52
57
|
test("解析:system 提示词与 thinking 块都不渲染(它们不是对话)", () => {
|
|
53
58
|
const raw =
|
|
54
59
|
line({ type: "message", message: { role: "system", content: [{ type: "text", text: "你是复盘助手" }] } }) +
|
|
55
|
-
line({
|
|
60
|
+
line({
|
|
61
|
+
type: "message",
|
|
62
|
+
message: {
|
|
63
|
+
role: "assistant",
|
|
64
|
+
content: [{ type: "thinking", thinking: "先看看 diff" }, { type: "text", text: "结论:没问题" }],
|
|
65
|
+
},
|
|
66
|
+
});
|
|
56
67
|
const transcript = parseSessionText(raw);
|
|
57
|
-
assert.deepEqual(transcript.entries, [{ kind: "assistant", text: "结论:没问题" }]);
|
|
68
|
+
assert.deepEqual(transcript.entries, [{ kind: "assistant", text: "结论:没问题", at: null }]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("解析:顶层 timestamp 记进 at,首末时刻写进 firstAt / lastAt(概览要用)", () => {
|
|
72
|
+
const early = new Date(2026, 10, 8, 10, 32).getTime();
|
|
73
|
+
const late = new Date(2026, 10, 8, 11, 52).getTime();
|
|
74
|
+
const transcript = parseSessionText(userMessage("开始", early) + assistantMessage("结束", [], late));
|
|
75
|
+
assert.deepEqual(
|
|
76
|
+
transcript.entries.map((entry) => entry.at),
|
|
77
|
+
[early, late],
|
|
78
|
+
);
|
|
79
|
+
assert.equal(transcript.firstAt, early);
|
|
80
|
+
assert.equal(transcript.lastAt, late);
|
|
58
81
|
});
|
|
59
82
|
|
|
60
83
|
test("解析:工具调用按 toolCallId 回连结果,连续调用聚成一个块", () => {
|
|
@@ -68,7 +91,10 @@ test("解析:工具调用按 toolCallId 回连结果,连续调用聚成一
|
|
|
68
91
|
toolResult("c2", "文件 b 的内容") +
|
|
69
92
|
assistantMessage("看完了");
|
|
70
93
|
const transcript = parseSessionText(raw);
|
|
71
|
-
assert.deepEqual(
|
|
94
|
+
assert.deepEqual(
|
|
95
|
+
transcript.entries.map((entry) => entry.kind),
|
|
96
|
+
["user", "tools", "assistant"],
|
|
97
|
+
);
|
|
72
98
|
const tools = transcript.entries[1];
|
|
73
99
|
assert.equal(tools.kind, "tools");
|
|
74
100
|
if (tools.kind !== "tools") return;
|
|
@@ -90,8 +116,10 @@ test("解析:两次 assistant 消息之间的调用(中间只隔 toolResult
|
|
|
90
116
|
assistantMessage("", [{ id: "c2", name: "bash", arguments: { command: "ls" } }]) +
|
|
91
117
|
toolResult("c2", "ok");
|
|
92
118
|
const transcript = parseSessionText(raw);
|
|
93
|
-
|
|
94
|
-
|
|
119
|
+
assert.deepEqual(
|
|
120
|
+
transcript.entries.map((entry) => entry.kind),
|
|
121
|
+
["assistant", "tools"],
|
|
122
|
+
);
|
|
95
123
|
const tools = transcript.entries[1];
|
|
96
124
|
assert.equal(tools.kind === "tools" ? tools.calls.length : 0, 2);
|
|
97
125
|
});
|
|
@@ -102,65 +130,21 @@ test("解析:没有结果的调用写「(没有结果)」(子进程被 k
|
|
|
102
130
|
assert.equal(tools.kind === "tools" ? tools.calls[0]?.result : "", "(没有结果)");
|
|
103
131
|
});
|
|
104
132
|
|
|
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),抬头是「工具名 ×次数(点击展开)」", () => {
|
|
133
|
+
test("解析:工具参数截到 400 字、结果截到 200 字", () => {
|
|
112
134
|
const raw =
|
|
113
|
-
assistantMessage("", [
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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">\{"path":"a"\}<\/pre>/);
|
|
127
|
-
assert.match(html, /<pre class="tool-result">A<\/pre>/);
|
|
135
|
+
assistantMessage("", [{ id: "c1", name: "read", arguments: "p".repeat(600) }]) + toolResult("c1", "r".repeat(400));
|
|
136
|
+
const calls = parseSessionText(raw).entries[0];
|
|
137
|
+
assert.equal(calls.kind, "tools");
|
|
138
|
+
if (calls.kind !== "tools") return;
|
|
139
|
+
assert.equal(calls.calls[0]?.args, `${"p".repeat(400)}…`);
|
|
140
|
+
assert.equal(calls.calls[0]?.result, `${"r".repeat(200)}…`);
|
|
128
141
|
});
|
|
129
142
|
|
|
130
|
-
test("
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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, /<script>alert\(1\)<\/script>/);
|
|
140
|
-
assert.match(html, /<b>粗体<\/b>/);
|
|
141
|
-
assert.match(html, /<img src=x>/);
|
|
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);
|
|
143
|
+
test("解析:消息正文不在解析层截断(截断归渲染:用户 2000 / 助手 4000)", () => {
|
|
144
|
+
const transcript = parseSessionText(userMessage("u".repeat(2500)) + assistantMessage("a".repeat(4500)));
|
|
145
|
+
const [user, assistant] = transcript.entries;
|
|
146
|
+
assert.equal(user.kind === "user" ? user.text.length : 0, 2500);
|
|
147
|
+
assert.equal(assistant.kind === "assistant" ? assistant.text.length : 0, 4500);
|
|
164
148
|
});
|
|
165
149
|
|
|
166
150
|
test("解析:超过 200 条只留最后 200 条,dropped 记下丢掉多少", () => {
|
|
@@ -171,8 +155,8 @@ test("解析:超过 200 条只留最后 200 条,dropped 记下丢掉多少",
|
|
|
171
155
|
assert.equal(transcript.dropped, 5);
|
|
172
156
|
assert.equal(transcript.entries[0]?.kind === "user" ? transcript.entries[0].text : "", "第 5 条");
|
|
173
157
|
// 丢掉的要说明,否则用户以为会话只有 200 条
|
|
174
|
-
const
|
|
175
|
-
assert.match(
|
|
158
|
+
const view = renderTranscript(transcript, { dropped: transcript.dropped });
|
|
159
|
+
assert.match(view.body, /只渲染最后 200 条消息,前面还有 5 条未显示/);
|
|
176
160
|
});
|
|
177
161
|
|
|
178
162
|
test("渲染:总输出超 2MB 就停手并指路原文件", () => {
|
|
@@ -180,13 +164,17 @@ test("渲染:总输出超 2MB 就停手并指路原文件", () => {
|
|
|
180
164
|
const entries: SessionTranscript["entries"] = Array.from({ length: MAX_MESSAGES }, () => ({
|
|
181
165
|
kind: "assistant",
|
|
182
166
|
text: '"'.repeat(4000),
|
|
167
|
+
at: null,
|
|
183
168
|
}));
|
|
184
|
-
const
|
|
185
|
-
assert.equal(
|
|
186
|
-
assert.
|
|
187
|
-
assert.match(
|
|
169
|
+
const view = renderTranscript({ entries, messages: entries.length, toolCalls: 0, dropped: 0, firstAt: null, lastAt: null });
|
|
170
|
+
assert.equal(view.body.length > MAX_OUTPUT, false, "输出必须被截在上限以内");
|
|
171
|
+
assert.equal(view.truncated, true);
|
|
172
|
+
assert.match(view.body, /已达页面输出上限(2048KB),后面还有内容未渲染/);
|
|
173
|
+
assert.match(view.body, /继续看请用 rg\/read 打开原文件/);
|
|
188
174
|
});
|
|
189
175
|
|
|
190
176
|
test("渲染:空会话给一句空态,不留白页", () => {
|
|
191
|
-
|
|
177
|
+
const view = renderTranscript({ entries: [], messages: 0, toolCalls: 0, dropped: 0, firstAt: null, lastAt: null });
|
|
178
|
+
assert.match(view.body, /没有可渲染的对话/);
|
|
179
|
+
assert.equal(view.shown, 0);
|
|
192
180
|
});
|