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,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
|
+
});
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 会话详情的解析单测(node --test):JSONL 只认 message、跳过 system / thinking、
|
|
3
|
+
* 工具调用回连结果、连续调用聚块、条数与长度上限。
|
|
4
|
+
*
|
|
5
|
+
* 渲染部分(Markdown-lite、折叠抬头、目录、总输出上限)在 ./session-render.test.ts 与 ./session-nav.test.ts 里,
|
|
6
|
+
* 因为解析与渲染已经是两个文件,测试也各盯一处,失败时不用先猜是哪一半。
|
|
7
|
+
*
|
|
8
|
+
* 纪律:不读真实 `~/.pi/agent/sessions`、不读真实 run 目录 —— 所有输入都是本文件里现造的 JSONL 片段。
|
|
9
|
+
*/
|
|
10
|
+
import { test } from "node:test";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
|
|
13
|
+
import { MAX_MESSAGES, MAX_OUTPUT, parseSessionText, type SessionTranscript } from "../lib/web/session-view.ts";
|
|
14
|
+
import { renderTranscript } from "../lib/web/session-render.ts";
|
|
15
|
+
|
|
16
|
+
/** 一行 JSONL(pi 的真实形态:顶层 type + message,可选 timestamp) */
|
|
17
|
+
function line(record: unknown): string {
|
|
18
|
+
return `${JSON.stringify(record)}\n`;
|
|
19
|
+
}
|
|
20
|
+
|
|
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() });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function assistantMessage(
|
|
27
|
+
text: string,
|
|
28
|
+
calls: { id: string; name: string; arguments?: unknown }[] = [],
|
|
29
|
+
at?: number,
|
|
30
|
+
): string {
|
|
31
|
+
const content: unknown[] = [];
|
|
32
|
+
if (text.length > 0) content.push({ type: "text", text });
|
|
33
|
+
for (const call of calls) content.push({ type: "toolCall", id: call.id, name: call.name, arguments: call.arguments ?? {} });
|
|
34
|
+
const record = { type: "message", message: { role: "assistant", content } };
|
|
35
|
+
return line(at === undefined ? record : { ...record, timestamp: new Date(at).toISOString() });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function toolResult(toolCallId: string, text: string): string {
|
|
39
|
+
return line({
|
|
40
|
+
type: "message",
|
|
41
|
+
message: { role: "toolResult", toolCallId, toolName: "read", content: [{ type: "text", text }] },
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
test("解析:只认顶层 type=message,session / model_change / custom 全跳过", () => {
|
|
46
|
+
const raw =
|
|
47
|
+
line({ type: "session", id: "s1" }) +
|
|
48
|
+
line({ type: "model_change", model: "x" }) +
|
|
49
|
+
line({ type: "custom", data: {} }) +
|
|
50
|
+
userMessage("你好") +
|
|
51
|
+
"这不是 JSON\n";
|
|
52
|
+
const transcript = parseSessionText(raw);
|
|
53
|
+
assert.equal(transcript.messages, 1);
|
|
54
|
+
assert.deepEqual(transcript.entries, [{ kind: "user", text: "你好", at: null }]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("解析:system 提示词与 thinking 块都不渲染(它们不是对话)", () => {
|
|
58
|
+
const raw =
|
|
59
|
+
line({ type: "message", message: { role: "system", content: [{ type: "text", text: "你是复盘助手" }] } }) +
|
|
60
|
+
line({
|
|
61
|
+
type: "message",
|
|
62
|
+
message: {
|
|
63
|
+
role: "assistant",
|
|
64
|
+
content: [{ type: "thinking", thinking: "先看看 diff" }, { type: "text", text: "结论:没问题" }],
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
const transcript = parseSessionText(raw);
|
|
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);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("解析:工具调用按 toolCallId 回连结果,连续调用聚成一个块", () => {
|
|
84
|
+
const raw =
|
|
85
|
+
userMessage("看两个文件") +
|
|
86
|
+
assistantMessage("", [
|
|
87
|
+
{ id: "c1", name: "read", arguments: { path: "a.ts" } },
|
|
88
|
+
{ id: "c2", name: "read", arguments: { path: "b.ts" } },
|
|
89
|
+
]) +
|
|
90
|
+
toolResult("c1", "文件 a 的内容") +
|
|
91
|
+
toolResult("c2", "文件 b 的内容") +
|
|
92
|
+
assistantMessage("看完了");
|
|
93
|
+
const transcript = parseSessionText(raw);
|
|
94
|
+
assert.deepEqual(
|
|
95
|
+
transcript.entries.map((entry) => entry.kind),
|
|
96
|
+
["user", "tools", "assistant"],
|
|
97
|
+
);
|
|
98
|
+
const tools = transcript.entries[1];
|
|
99
|
+
assert.equal(tools.kind, "tools");
|
|
100
|
+
if (tools.kind !== "tools") return;
|
|
101
|
+
assert.equal(tools.calls.length, 2);
|
|
102
|
+
assert.deepEqual(
|
|
103
|
+
tools.calls.map((call) => [call.name, call.args, call.result]),
|
|
104
|
+
[
|
|
105
|
+
["read", '{"path":"a.ts"}', "文件 a 的内容"],
|
|
106
|
+
["read", '{"path":"b.ts"}', "文件 b 的内容"],
|
|
107
|
+
],
|
|
108
|
+
);
|
|
109
|
+
assert.equal(transcript.toolCalls, 2);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("解析:两次 assistant 消息之间的调用(中间只隔 toolResult)也聚成一个块", () => {
|
|
113
|
+
const raw =
|
|
114
|
+
assistantMessage("先读一个", [{ id: "c1", name: "read", arguments: { path: "a" } }]) +
|
|
115
|
+
toolResult("c1", "ok") +
|
|
116
|
+
assistantMessage("", [{ id: "c2", name: "bash", arguments: { command: "ls" } }]) +
|
|
117
|
+
toolResult("c2", "ok");
|
|
118
|
+
const transcript = parseSessionText(raw);
|
|
119
|
+
assert.deepEqual(
|
|
120
|
+
transcript.entries.map((entry) => entry.kind),
|
|
121
|
+
["assistant", "tools"],
|
|
122
|
+
);
|
|
123
|
+
const tools = transcript.entries[1];
|
|
124
|
+
assert.equal(tools.kind === "tools" ? tools.calls.length : 0, 2);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("解析:没有结果的调用写「(没有结果)」(子进程被 kill 时会这样)", () => {
|
|
128
|
+
const transcript = parseSessionText(assistantMessage("", [{ id: "c9", name: "read", arguments: {} }]));
|
|
129
|
+
const tools = transcript.entries[0];
|
|
130
|
+
assert.equal(tools.kind === "tools" ? tools.calls[0]?.result : "", "(没有结果)");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("解析:工具参数截到 400 字、结果截到 200 字", () => {
|
|
134
|
+
const raw =
|
|
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)}…`);
|
|
141
|
+
});
|
|
142
|
+
|
|
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);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("解析:超过 200 条只留最后 200 条,dropped 记下丢掉多少", () => {
|
|
151
|
+
const raw = Array.from({ length: MAX_MESSAGES + 5 }, (_, index) => userMessage(`第 ${index} 条`)).join("");
|
|
152
|
+
const transcript = parseSessionText(raw);
|
|
153
|
+
assert.equal(transcript.messages, MAX_MESSAGES + 5);
|
|
154
|
+
assert.equal(transcript.entries.length, MAX_MESSAGES);
|
|
155
|
+
assert.equal(transcript.dropped, 5);
|
|
156
|
+
assert.equal(transcript.entries[0]?.kind === "user" ? transcript.entries[0].text : "", "第 5 条");
|
|
157
|
+
// 丢掉的要说明,否则用户以为会话只有 200 条
|
|
158
|
+
const view = renderTranscript(transcript, { dropped: transcript.dropped });
|
|
159
|
+
assert.match(view.body, /只渲染最后 200 条消息,前面还有 5 条未显示/);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("渲染:总输出超 2MB 就停手并指路原文件", () => {
|
|
163
|
+
// 用会被转义成 6 个字符的引号撑大输出:200 条 × 4000 字 → 转义后远超上限
|
|
164
|
+
const entries: SessionTranscript["entries"] = Array.from({ length: MAX_MESSAGES }, () => ({
|
|
165
|
+
kind: "assistant",
|
|
166
|
+
text: '"'.repeat(4000),
|
|
167
|
+
at: null,
|
|
168
|
+
}));
|
|
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 打开原文件/);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("渲染:空会话给一句空态,不留白页", () => {
|
|
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);
|
|
180
|
+
});
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 「已采纳」单独成 tab:列表只列 applied,已处理页一条 applied 都不许有。
|
|
3
|
-
*
|
|
3
|
+
* 五闸结论分三种说法:全 ok / N 项失败 / 无记录(判不了 ≠ 没过)。
|
|
4
|
+
*
|
|
5
|
+
* 重做列表页后五闸不再单占一列,而是写进状态徽章的 `title`(鼠标停一下才展开):
|
|
6
|
+
* 表格一行只放五个字段,五闸属于「点进去之前想看」的次要信息。
|
|
4
7
|
*/
|
|
5
8
|
import assert from "node:assert/strict";
|
|
6
9
|
import test from "node:test";
|
|
@@ -10,10 +13,12 @@ import { renderPage } from "../lib/web/render.ts";
|
|
|
10
13
|
import { appliedRows } from "../lib/web/render-rows.ts";
|
|
11
14
|
|
|
12
15
|
const NOW = new Date(2026, 10, 8, 12).getTime();
|
|
16
|
+
const NO_SESSION = { sessionId: null, sessionFile: null };
|
|
13
17
|
|
|
14
18
|
function item(parts: Partial<AppliedItem> = {}): AppliedItem {
|
|
15
19
|
return {
|
|
16
20
|
runId: "run-a-1794133942",
|
|
21
|
+
session: NO_SESSION,
|
|
17
22
|
level: "approval",
|
|
18
23
|
skill: "demo",
|
|
19
24
|
type: "improve",
|
|
@@ -30,7 +35,7 @@ function item(parts: Partial<AppliedItem> = {}): AppliedItem {
|
|
|
30
35
|
}
|
|
31
36
|
|
|
32
37
|
function done(runId: string, status: string, skill: string): DoneItem {
|
|
33
|
-
return { runId, status, source: "run", level: "-", skill, at: NOW, detail: "" };
|
|
38
|
+
return { runId, status, source: "run", level: "-", skill, at: NOW, detail: "", session: NO_SESSION };
|
|
34
39
|
}
|
|
35
40
|
|
|
36
41
|
function state(parts: Partial<WebState> = {}): WebState {
|
|
@@ -45,39 +50,40 @@ function state(parts: Partial<WebState> = {}): WebState {
|
|
|
45
50
|
};
|
|
46
51
|
}
|
|
47
52
|
|
|
48
|
-
test("
|
|
49
|
-
const
|
|
50
|
-
assert.
|
|
51
|
-
assert.
|
|
52
|
-
assert.
|
|
53
|
-
assert.
|
|
54
|
-
assert.
|
|
53
|
+
test("已采纳行:状态徽章 + 时间 + 技能 + 提案增删 + 五闸结论(在 title 里)", () => {
|
|
54
|
+
const [row] = appliedRows([item()]);
|
|
55
|
+
assert.equal(row.badge.text, "已采纳");
|
|
56
|
+
assert.equal(row.badge.tone, "green");
|
|
57
|
+
assert.equal(row.badge.title, "approval 级 · 五闸:全 ok");
|
|
58
|
+
assert.equal(row.skill, "demo");
|
|
59
|
+
assert.deepEqual(row.delta, { type: "improve", added: 3, removed: 1 });
|
|
60
|
+
assert.equal(row.at, NOW);
|
|
61
|
+
// 表格里一眼能看到的:`+3 / -1`
|
|
62
|
+
const html = renderPage(state({ applied: [item()] }), "applied", { now: NOW });
|
|
63
|
+
assert.match(html, /<td class="delta">\+3 \/ -1<\/td>/);
|
|
64
|
+
assert.match(html, /<td class="skill"><code>demo<\/code><\/td>/);
|
|
55
65
|
});
|
|
56
66
|
|
|
57
|
-
test("
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
]);
|
|
61
|
-
assert.match(html, /五闸:1 项失败/);
|
|
62
|
-
assert.match(html, /gate3 复验 ✗/);
|
|
63
|
-
assert.match(html, /注意:有闸没过却仍被采纳,先看 06-verify\.json/);
|
|
67
|
+
test("已采纳行:有一闸没过要说几项失败(提示这是异常)", () => {
|
|
68
|
+
const [row] = appliedRows([item({ gates: [{ gate: "gate3", name: "复验", ok: false }], gatesOk: false })]);
|
|
69
|
+
assert.equal(row.badge.title, "approval 级 · 五闸:1 项失败");
|
|
64
70
|
});
|
|
65
71
|
|
|
66
72
|
test("已采纳行:没有 06-verify.json 时写「无记录」,不写成「失败」", () => {
|
|
67
|
-
const
|
|
68
|
-
assert.
|
|
69
|
-
|
|
73
|
+
const [row] = appliedRows([item({ gates: [], gatesOk: null })]);
|
|
74
|
+
assert.equal(row.badge.title, "approval 级 · 五闸:无记录");
|
|
75
|
+
const html = renderPage(state({ applied: [item({ gates: [], gatesOk: null })] }), "applied", { now: NOW });
|
|
70
76
|
assert.equal(html.includes("项失败"), false);
|
|
71
77
|
});
|
|
72
78
|
|
|
73
79
|
test("已采纳页只列 applied;已处理页一条 applied 都没有(两页不再混)", () => {
|
|
74
80
|
const shared = state({
|
|
75
81
|
applied: [item({ runId: "run-applied" })],
|
|
76
|
-
done: [done("run-rolled", "
|
|
82
|
+
done: [done("run-rolled", "rolledback", "demo"), done("run-rejected", "rejected", "other")],
|
|
77
83
|
});
|
|
78
84
|
const appliedPage = renderPage(shared, "applied", { now: NOW });
|
|
79
85
|
assert.match(appliedPage, /<title>技能进化 · 已采纳<\/title>/);
|
|
80
|
-
assert.match(appliedPage, /<a href="\/\?view=applied" class="active"
|
|
86
|
+
assert.match(appliedPage, /<a href="\/\?view=applied" class="active">已采纳<span class="cnt">1<\/span><\/a>/);
|
|
81
87
|
assert.match(appliedPage, /run-applied/);
|
|
82
88
|
assert.equal(appliedPage.includes("run-rolled"), false, "回滚的不算已采纳");
|
|
83
89
|
|
|
@@ -88,13 +94,15 @@ test("已采纳页只列 applied;已处理页一条 applied 都没有(两页
|
|
|
88
94
|
assert.equal(donePage.includes("run-applied"), false, "applied 已经移出已处理页");
|
|
89
95
|
});
|
|
90
96
|
|
|
91
|
-
test("已采纳页空态:说清采纳过的连 diff
|
|
97
|
+
test("已采纳页空态:说清采纳过的连 diff 一起留在这里,并给下一步", () => {
|
|
92
98
|
const html = renderPage(state(), "applied", { now: NOW });
|
|
93
99
|
assert.match(html, /<p class="empty">还没有已采纳的提案:采纳过的会连 diff 一起留在这里。<\/p>/);
|
|
100
|
+
assert.match(html, /<p class="next">.*\/skill-evolve dispatch on/);
|
|
94
101
|
});
|
|
95
102
|
|
|
96
103
|
test("已采纳页有筛选与分页,但没有自动刷新(历史页刷新会把人弹回顶部)", () => {
|
|
97
104
|
const html = renderPage(state({ applied: [item()] }), "applied", { now: NOW });
|
|
98
105
|
assert.match(html, /<form class="filter"/);
|
|
106
|
+
assert.match(html, /<p class="page">/);
|
|
99
107
|
assert.equal(html.includes('http-equiv="refresh"'), false);
|
|
100
108
|
});
|