@zhushanwen/pi-system-prompt-trace 0.1.0
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/README.md +48 -0
- package/index.ts +1 -0
- package/package.json +36 -0
- package/src/__tests__/baseline.a12.test.ts +280 -0
- package/src/__tests__/diff.test.ts +107 -0
- package/src/__tests__/index-wiring.test.ts +293 -0
- package/src/__tests__/trace.a11.test.ts +181 -0
- package/src/baseline.ts +196 -0
- package/src/diff.ts +109 -0
- package/src/index.ts +85 -0
- package/src/trace.ts +153 -0
- package/src/types.ts +110 -0
package/README.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# @zhushanwen/pi-system-prompt-trace
|
|
2
|
+
|
|
3
|
+
Session Trace 单元 1(留痕 extension)。system prompt 在 pi 中每次运行时动态重建、默认不落盘——本包在
|
|
4
|
+
system prompt 确立或变化时向 session JSONL 追加留痕 entry,让「resume/reload/换配置前后 prompt 到底是什么」
|
|
5
|
+
可追溯(设计:`docs/page-design/session-trace/design.md` D2)。
|
|
6
|
+
|
|
7
|
+
## 落盘格式
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
appendEntry("xyz:system-prompt", {
|
|
11
|
+
version: number; // session 内单调递增,首条 1
|
|
12
|
+
hash: string; // sha256(fullText) 十六进制
|
|
13
|
+
reason: "initial" | "resume" | "change";
|
|
14
|
+
fullText: string; // 完整 system prompt
|
|
15
|
+
charCount: number; // fullText.length
|
|
16
|
+
parentVersionDiffSummary?: string; // 与上一版的行级 diff 摘要(有 parent 全文时)
|
|
17
|
+
});
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## 写入时机(设计 D2 校正)
|
|
21
|
+
|
|
22
|
+
- **不在 `session_start` 写**:该事件 emit 早于 resources_discover 的 prompt 重建,快照必不完整。
|
|
23
|
+
- **首个 `turn_start` 写 initial/resume**:此时 `getSystemPrompt()` 已含 `before_agent_start` 注入,是最终 prompt。
|
|
24
|
+
- **后续每个 `turn_start`** 做 hash 对比,变化才写 `change`。
|
|
25
|
+
|
|
26
|
+
## reason 映射
|
|
27
|
+
|
|
28
|
+
`SessionStartEvent.reason` 原生 5 值(startup/reload/new/resume/fork)→ 落盘 reason:
|
|
29
|
+
|
|
30
|
+
| SessionStartEvent.reason | 落盘 reason | 状态 |
|
|
31
|
+
|---|---|---|
|
|
32
|
+
| startup / new | initial | 定案 |
|
|
33
|
+
| resume | resume | 定案 |
|
|
34
|
+
| fork / reload | resume | 暂定,待 P2 探针实测定(A13) |
|
|
35
|
+
|
|
36
|
+
存在任一 hash 基线(见下)时,首个 turn 写 `resume`(无论 session_start reason)。
|
|
37
|
+
|
|
38
|
+
## 跨重启 hash 基线三路径(优先级从高到低)
|
|
39
|
+
|
|
40
|
+
1. **进程内 resume**:`session_before_switch.targetSessionFile` 直读目标 session 文件,取最后一条留痕 entry 的
|
|
41
|
+
hash/version/fullText。switch 会 teardown 并重建 extension runtime,该基线经模块级 stash 跨 runtime 传递。
|
|
42
|
+
2. **app 重启直 spawn resume**:`<agentDir>/system-prompt-trace-baseline.json` 自持久化小文件(sessionId →
|
|
43
|
+
hash/version),原子写入,保留最近 64 个 session。
|
|
44
|
+
3. **兜底**:两路都读不到且 reason=resume → 必写一条(宁可多写不可漏记)。
|
|
45
|
+
|
|
46
|
+
## 注册状态
|
|
47
|
+
|
|
48
|
+
本包尚未注册进 `packages/shared/src/mandatory-extensions.json`(后续单元处理;feature tier,可禁不可卸)。
|
package/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./src/index.js";
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zhushanwen/pi-system-prompt-trace",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "System prompt trace extension: appends xyz:system-prompt entries to the session JSONL whenever the effective system prompt is established or changes",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.ts",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"pi-package",
|
|
9
|
+
"pi",
|
|
10
|
+
"pi-coding-agent",
|
|
11
|
+
"extension",
|
|
12
|
+
"trace"
|
|
13
|
+
],
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"files": [
|
|
16
|
+
"index.ts",
|
|
17
|
+
"src/**/*.ts",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"pi": {
|
|
21
|
+
"extensions": [
|
|
22
|
+
"./index.ts"
|
|
23
|
+
]
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@vitest/coverage-v8": "^4.1.9",
|
|
30
|
+
"vitest": "^4.1.8"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"typecheck": "npx tsc --noEmit",
|
|
34
|
+
"test": "vitest run"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A12 hash 基线跨重启恢复(spec:.cw-specs/trace-ext.json;设计 D2 复审 N2 / plan §2.1)。
|
|
3
|
+
*
|
|
4
|
+
* 三路径(优先级从高到低):
|
|
5
|
+
* 1. 进程内 resume:session_before_switch.targetSessionFile 直读目标文件取上一版 hash
|
|
6
|
+
* (switch 重建 extension runtime,基线经模块级 stash 传递——用「新闭包 + 共享 stash」模拟)
|
|
7
|
+
* 2. app 重启直 spawn resume:agentDir 自持久化小文件(此链路无 switch 事件、reason=startup)
|
|
8
|
+
* 3. 兜底:两路都读不到 → resume 必写一条(宁可多写不可漏记)
|
|
9
|
+
*
|
|
10
|
+
* 用真实临时目录 + 真实 fs 函数(非 mock 投影);appendEntry 同步模拟 pi appendCustomEntry
|
|
11
|
+
* 的落盘形状(session-manager.ts)。
|
|
12
|
+
*/
|
|
13
|
+
import { appendFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
|
|
17
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
BASELINE_FILENAME,
|
|
21
|
+
readLastPromptFromSessionFile,
|
|
22
|
+
readPersistedBaseline,
|
|
23
|
+
writePersistedBaseline,
|
|
24
|
+
} from "../baseline.js";
|
|
25
|
+
import { computePromptHash, createSystemPromptTrace } from "../trace.js";
|
|
26
|
+
import type { SystemPromptTrace, TraceContext, TraceEnv } from "../trace.js";
|
|
27
|
+
import { isSystemPromptTraceEntryData, SYSTEM_PROMPT_CUSTOM_TYPE } from "../types.js";
|
|
28
|
+
import type { SystemPromptTraceEntryData, SwitchStash } from "../types.js";
|
|
29
|
+
|
|
30
|
+
const P1 = "base prompt\nline-1";
|
|
31
|
+
const P2 = "base prompt\nline-1\nline-2-added";
|
|
32
|
+
|
|
33
|
+
interface FsHarness {
|
|
34
|
+
logic: SystemPromptTrace;
|
|
35
|
+
ctx: TraceContext;
|
|
36
|
+
stash: SwitchStash;
|
|
37
|
+
sessionFile: string;
|
|
38
|
+
baselineFile: string;
|
|
39
|
+
entries: SystemPromptTraceEntryData[];
|
|
40
|
+
setPrompt(text: string): void;
|
|
41
|
+
/** 真实链路中 new/fork 的新 session 有全新 id(fake ctx 不能固定 id,否则旧基线误命中) */
|
|
42
|
+
setSessionId(id: string): void;
|
|
43
|
+
/** 模拟 extension runtime 重建(switchSession teardown + createRuntime 重跑 factory:新闭包,共享 stash 与文件) */
|
|
44
|
+
newLogic(): SystemPromptTrace;
|
|
45
|
+
/** 读 sessionFile 的非空行(模拟 pi 落盘结果核对) */
|
|
46
|
+
sessionLines(): string[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let rootDir = "";
|
|
50
|
+
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
rootDir = mkdtempSync(join(tmpdir(), "spt-a12-"));
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
rmSync(rootDir, { recursive: true, force: true });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
function makeHarness(initialPrompt: string): FsHarness {
|
|
60
|
+
const dir = join(rootDir, `sess-${Math.random().toString(36).slice(2)}`);
|
|
61
|
+
mkdirSync(dir, { recursive: true });
|
|
62
|
+
const sessionFile = join(dir, "session.jsonl");
|
|
63
|
+
const baselineFile = join(dir, BASELINE_FILENAME);
|
|
64
|
+
const entries: SystemPromptTraceEntryData[] = [];
|
|
65
|
+
let prompt = initialPrompt;
|
|
66
|
+
let sessionId = "sess-a12";
|
|
67
|
+
const stash: SwitchStash = { pending: null };
|
|
68
|
+
|
|
69
|
+
const env: TraceEnv = {
|
|
70
|
+
readLastPromptFromFile: (filePath) => readLastPromptFromSessionFile(filePath, "target-file"),
|
|
71
|
+
readPersistedBaseline: (sessionId) => readPersistedBaseline(baselineFile, sessionId),
|
|
72
|
+
writePersistedBaseline: (sessionId, hash, version) =>
|
|
73
|
+
writePersistedBaseline(baselineFile, sessionId, hash, version),
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const ctx: TraceContext = {
|
|
77
|
+
getSystemPrompt: () => prompt,
|
|
78
|
+
getSessionId: () => sessionId,
|
|
79
|
+
appendEntry: (customType, data) => {
|
|
80
|
+
if (!isSystemPromptTraceEntryData(data)) {
|
|
81
|
+
throw new Error(`entry data shape invalid: ${JSON.stringify(data)}`);
|
|
82
|
+
}
|
|
83
|
+
// 模拟 pi appendCustomEntry 落盘形状(session-manager.ts)
|
|
84
|
+
appendFileSync(
|
|
85
|
+
sessionFile,
|
|
86
|
+
JSON.stringify({
|
|
87
|
+
type: "custom",
|
|
88
|
+
customType,
|
|
89
|
+
data,
|
|
90
|
+
id: `e${entries.length + 1}`,
|
|
91
|
+
parentId: null,
|
|
92
|
+
timestamp: new Date().toISOString(),
|
|
93
|
+
}) + "\n",
|
|
94
|
+
);
|
|
95
|
+
entries.push(data);
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const makeLogic = (): SystemPromptTrace => createSystemPromptTrace(env, stash);
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
logic: makeLogic(),
|
|
103
|
+
ctx,
|
|
104
|
+
stash,
|
|
105
|
+
sessionFile,
|
|
106
|
+
baselineFile,
|
|
107
|
+
entries,
|
|
108
|
+
setPrompt: (text) => {
|
|
109
|
+
prompt = text;
|
|
110
|
+
},
|
|
111
|
+
setSessionId: (id) => {
|
|
112
|
+
sessionId = id;
|
|
113
|
+
},
|
|
114
|
+
newLogic: makeLogic,
|
|
115
|
+
sessionLines: () => {
|
|
116
|
+
try {
|
|
117
|
+
return readFileSync(sessionFile, "utf-8").split("\n").filter((l) => l.trim() !== "");
|
|
118
|
+
} catch {
|
|
119
|
+
return [];
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
describe("A12 hash 基线跨重启恢复", () => {
|
|
126
|
+
it("路径 1(进程内 resume):targetSessionFile 直读命中且 hash 未变 → 不重写(跨 switch 去重)", () => {
|
|
127
|
+
const h = makeHarness(P1);
|
|
128
|
+
h.logic.onSessionStart("startup", undefined, h.ctx);
|
|
129
|
+
h.logic.onTurnStart(h.ctx); // v1 initial
|
|
130
|
+
expect(h.entries).toHaveLength(1);
|
|
131
|
+
|
|
132
|
+
// 旧 runtime 在 before_switch 直读目标文件 → stash
|
|
133
|
+
h.logic.onSessionBeforeSwitch("resume", h.sessionFile);
|
|
134
|
+
expect(h.stash.pending).toMatchObject({ hash: computePromptHash(P1), version: 1 });
|
|
135
|
+
|
|
136
|
+
// 新 runtime(switch 后重建)消费 stash;hash 相同 → 不写
|
|
137
|
+
const logic2 = h.newLogic();
|
|
138
|
+
logic2.onSessionStart("resume", undefined, h.ctx);
|
|
139
|
+
logic2.onTurnStart(h.ctx);
|
|
140
|
+
expect(h.entries).toHaveLength(1);
|
|
141
|
+
expect(h.sessionLines()).toHaveLength(1);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("路径 1(进程内 resume):基线 hash 变化 → 写 resume v2,diff 摘要的 parent 全文来自目标文件", () => {
|
|
145
|
+
const h = makeHarness(P1);
|
|
146
|
+
h.logic.onSessionStart("startup", undefined, h.ctx);
|
|
147
|
+
h.logic.onTurnStart(h.ctx); // v1 initial
|
|
148
|
+
h.logic.onSessionBeforeSwitch("resume", h.sessionFile);
|
|
149
|
+
|
|
150
|
+
h.setPrompt(P2);
|
|
151
|
+
const logic2 = h.newLogic();
|
|
152
|
+
logic2.onSessionStart("resume", undefined, h.ctx);
|
|
153
|
+
logic2.onTurnStart(h.ctx);
|
|
154
|
+
expect(h.entries).toHaveLength(2);
|
|
155
|
+
expect(h.entries[1]).toMatchObject({ version: 2, reason: "resume", hash: computePromptHash(P2) });
|
|
156
|
+
expect(typeof h.entries[1]?.parentVersionDiffSummary).toBe("string");
|
|
157
|
+
expect(h.entries[1]?.parentVersionDiffSummary).toContain("+1 -0 lines");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("路径 2(app 重启直 spawn):自持久化小文件命中且 hash 未变 → 不写(reason=startup、无 switch 事件)", () => {
|
|
161
|
+
const h = makeHarness(P1);
|
|
162
|
+
h.logic.onSessionStart("startup", undefined, h.ctx);
|
|
163
|
+
h.logic.onTurnStart(h.ctx); // v1 initial + 基线小文件已写
|
|
164
|
+
expect(readPersistedBaseline(h.baselineFile, "sess-a12")).toMatchObject({
|
|
165
|
+
hash: computePromptHash(P1),
|
|
166
|
+
version: 1,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// app 重启:全新闭包 + 空 stash(无 before_switch 可用)
|
|
170
|
+
const logic2 = h.newLogic();
|
|
171
|
+
expect(logic2).toBeDefined();
|
|
172
|
+
logic2.onSessionStart("startup", undefined, h.ctx);
|
|
173
|
+
logic2.onTurnStart(h.ctx);
|
|
174
|
+
expect(h.entries).toHaveLength(1);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("路径 2(app 重启直 spawn):基线命中但 prompt 已变 → 写 resume v2(无 parent 全文,diff 摘要缺省)", () => {
|
|
178
|
+
const h = makeHarness(P1);
|
|
179
|
+
h.logic.onSessionStart("startup", undefined, h.ctx);
|
|
180
|
+
h.logic.onTurnStart(h.ctx); // v1
|
|
181
|
+
|
|
182
|
+
h.setPrompt(P2);
|
|
183
|
+
const logic2 = h.newLogic();
|
|
184
|
+
logic2.onSessionStart("startup", undefined, h.ctx);
|
|
185
|
+
logic2.onTurnStart(h.ctx);
|
|
186
|
+
expect(h.entries).toHaveLength(2);
|
|
187
|
+
// 基线存在 → 该 session 已有历史版本 → resume(而非 initial 重新计数)
|
|
188
|
+
expect(h.entries[1]).toMatchObject({ version: 2, reason: "resume" });
|
|
189
|
+
expect(h.entries[1]?.parentVersionDiffSummary).toBeUndefined();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("路径 3(兜底):两路基线都读不到且 reason=resume → 必写一条", () => {
|
|
193
|
+
const h = makeHarness(P1);
|
|
194
|
+
h.logic.onSessionStart("resume", undefined, h.ctx); // 无 stash、无小文件
|
|
195
|
+
h.logic.onTurnStart(h.ctx);
|
|
196
|
+
expect(h.entries).toHaveLength(1);
|
|
197
|
+
expect(h.entries[0]).toMatchObject({ version: 1, reason: "resume" });
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("基线小文件损坏 → 视为无基线,resume 必写一条", () => {
|
|
201
|
+
const h = makeHarness(P1);
|
|
202
|
+
writeFileSync(h.baselineFile, "{ not valid json");
|
|
203
|
+
h.logic.onSessionStart("resume", undefined, h.ctx);
|
|
204
|
+
h.logic.onTurnStart(h.ctx);
|
|
205
|
+
expect(h.entries).toHaveLength(1);
|
|
206
|
+
expect(h.entries[0]).toMatchObject({ version: 1, reason: "resume" });
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("目标文件无留痕 entry(旧 session 先于本 extension)→ 无基线 → resume 必写 v1", () => {
|
|
210
|
+
const h = makeHarness(P1);
|
|
211
|
+
appendFileSync(h.sessionFile, JSON.stringify({ type: "message", message: { role: "user" } }) + "\n");
|
|
212
|
+
h.logic.onSessionBeforeSwitch("resume", h.sessionFile);
|
|
213
|
+
expect(h.stash.pending).toBeNull();
|
|
214
|
+
|
|
215
|
+
const logic2 = h.newLogic();
|
|
216
|
+
logic2.onSessionStart("resume", undefined, h.ctx);
|
|
217
|
+
logic2.onTurnStart(h.ctx);
|
|
218
|
+
expect(h.entries).toHaveLength(1);
|
|
219
|
+
expect(h.entries[0]).toMatchObject({ version: 1, reason: "resume" });
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("cancelled switch 的 stash 残留不污染后续 new session(消费但不采用)", () => {
|
|
223
|
+
const h = makeHarness(P1);
|
|
224
|
+
h.logic.onSessionStart("startup", undefined, h.ctx);
|
|
225
|
+
h.logic.onTurnStart(h.ctx); // v1
|
|
226
|
+
h.logic.onSessionBeforeSwitch("resume", h.sessionFile); // switch 随后被取消,无 session_start 消费
|
|
227
|
+
expect(h.stash.pending).not.toBeNull();
|
|
228
|
+
|
|
229
|
+
// 之后用户开了全新 session:新 sessionId + reason=new → stash 被消费但不采用,从 v1 重新计数
|
|
230
|
+
const logic2 = h.newLogic();
|
|
231
|
+
h.setSessionId("sess-a12-new");
|
|
232
|
+
logic2.onSessionStart("new", undefined, h.ctx);
|
|
233
|
+
logic2.onTurnStart(h.ctx);
|
|
234
|
+
expect(h.entries).toHaveLength(2);
|
|
235
|
+
expect(h.entries[1]).toMatchObject({ version: 1, reason: "initial" });
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("fork(暂定语义,待 P2 实测定):previousSessionFile 直读作基线,hash 未变不写", () => {
|
|
239
|
+
const h = makeHarness(P1);
|
|
240
|
+
h.logic.onSessionStart("startup", undefined, h.ctx);
|
|
241
|
+
h.logic.onTurnStart(h.ctx); // v1(落进 h.sessionFile)
|
|
242
|
+
|
|
243
|
+
// pi 原生 fork 不经 session_before_switch:基线来自 session_start.previousSessionFile(新 sessionId 无持久化基线)
|
|
244
|
+
const logic2 = h.newLogic();
|
|
245
|
+
h.setSessionId("sess-a12-fork");
|
|
246
|
+
logic2.onSessionStart("fork", h.sessionFile, h.ctx);
|
|
247
|
+
logic2.onTurnStart(h.ctx);
|
|
248
|
+
expect(h.entries).toHaveLength(1);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("readLastPromptFromSessionFile:损坏行跳过、取最后一条有效留痕;文件缺失 → null", () => {
|
|
252
|
+
const scanDir = mkdtempSync(join(rootDir, "scan-"));
|
|
253
|
+
const file = join(scanDir, "s.jsonl");
|
|
254
|
+
const entryV1 = {
|
|
255
|
+
type: "custom",
|
|
256
|
+
customType: SYSTEM_PROMPT_CUSTOM_TYPE,
|
|
257
|
+
data: { version: 1, hash: "hash-v1", reason: "initial", fullText: "old text", charCount: 8 },
|
|
258
|
+
};
|
|
259
|
+
const entryV2 = {
|
|
260
|
+
type: "custom",
|
|
261
|
+
customType: SYSTEM_PROMPT_CUSTOM_TYPE,
|
|
262
|
+
data: { version: 2, hash: "hash-v2", reason: "change", fullText: "new text", charCount: 8 },
|
|
263
|
+
};
|
|
264
|
+
writeFileSync(file, [JSON.stringify(entryV1), "{ broken json", JSON.stringify(entryV2), ""].join("\n"));
|
|
265
|
+
expect(readLastPromptFromSessionFile(file, "target-file")).toMatchObject({
|
|
266
|
+
hash: "hash-v2",
|
|
267
|
+
version: 2,
|
|
268
|
+
fullText: "new text",
|
|
269
|
+
source: "target-file",
|
|
270
|
+
});
|
|
271
|
+
expect(readLastPromptFromSessionFile(join(scanDir, "missing.jsonl"), "target-file")).toBeNull();
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("自持久化小文件读写回路:write 后 read 命中同 session;其他 session → null", () => {
|
|
275
|
+
const file = join(rootDir, "baseline.json");
|
|
276
|
+
writePersistedBaseline(file, "sid-1", "hash-x", 3);
|
|
277
|
+
expect(readPersistedBaseline(file, "sid-1")).toEqual({ hash: "hash-x", version: 3, source: "persisted" });
|
|
278
|
+
expect(readPersistedBaseline(file, "sid-other")).toBeNull();
|
|
279
|
+
});
|
|
280
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* diff.ts summarizePromptDiff 分支矩阵(Gate-1.6 覆盖缺口:lcsLineDiff 回溯分支 + multiset 降级路径)。
|
|
3
|
+
*
|
|
4
|
+
* 覆盖目标(对照 diff.ts):
|
|
5
|
+
* - 全等 → header-only(samples 为空的 return 分支)
|
|
6
|
+
* - 纯尾部增/删 → LCS 回溯的两个尾部循环(while i<m / while j<n)
|
|
7
|
+
* - 中段替换 → 回溯 removed 分支;头部插入 → 回溯 added 分支(dp 上移 < 右移)
|
|
8
|
+
* - 采样交替(added 优先)+ 8 条上限;removed 提前耗尽后 added 补满(采样循环 removed 条件的 false 分支)
|
|
9
|
+
* - 单行 >80 字符截断(truncate)
|
|
10
|
+
* - LCS 面积超限(2001×2001 > 4M cells)→ multisetLineDiff 降级:共享行计数抵消、
|
|
11
|
+
* added 按新文本序、removed 按旧行插入序
|
|
12
|
+
* - 空字符串输入(split 产生单空行的边界)
|
|
13
|
+
*/
|
|
14
|
+
import { describe, expect, it } from "vitest";
|
|
15
|
+
|
|
16
|
+
import { summarizePromptDiff } from "../diff.js";
|
|
17
|
+
|
|
18
|
+
describe("summarizePromptDiff", () => {
|
|
19
|
+
it("全等 → 仅 header,无采样行", () => {
|
|
20
|
+
expect(summarizePromptDiff("a\nb\nc", "a\nb\nc")).toBe("+0 -0 lines");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("纯尾部新增 → LCS 回溯走尾部 added 循环", () => {
|
|
24
|
+
expect(summarizePromptDiff("keep", "keep\na1\na2")).toBe("+2 -0 lines\n+ a1\n+ a2");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("纯尾部删除 → LCS 回溯走尾部 removed 循环", () => {
|
|
28
|
+
expect(summarizePromptDiff("keep\nr1\nr2", "keep")).toBe("+0 -2 lines\n- r1\n- r2");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("中段单行替换 → removed/added 各一,采样 added 在前", () => {
|
|
32
|
+
expect(summarizePromptDiff("x\n1\ny", "x\nA\ny")).toBe("+1 -1 lines\n+ A\n- 1");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("头部插入 → LCS 回溯走 added 分支(dp 取右移更优)", () => {
|
|
36
|
+
expect(summarizePromptDiff("b\nc", "a\nb\nc")).toBe("+1 -0 lines\n+ a");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("交错增删 → 采样 added/removed 交替且与文本顺序一致", () => {
|
|
40
|
+
expect(summarizePromptDiff("x\n1\ny\n2", "x\nA\ny\nB")).toBe("+2 -2 lines\n+ A\n- 1\n+ B\n- 2");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("采样上限 8 条:added/removed 交错时各取前 4,余量丢弃", () => {
|
|
44
|
+
const old = ["keep", "r1", "r2", "r3", "r4", "r5", "r6"].join("\n");
|
|
45
|
+
const next = ["keep", "a1", "a2", "a3", "a4", "a5", "a6"].join("\n");
|
|
46
|
+
const lines = summarizePromptDiff(old, next).split("\n");
|
|
47
|
+
expect(lines).toEqual([
|
|
48
|
+
"+6 -6 lines",
|
|
49
|
+
"+ a1",
|
|
50
|
+
"- r1",
|
|
51
|
+
"+ a2",
|
|
52
|
+
"- r2",
|
|
53
|
+
"+ a3",
|
|
54
|
+
"- r3",
|
|
55
|
+
"+ a4",
|
|
56
|
+
"- r4",
|
|
57
|
+
]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("removed 提前耗尽后 added 补满 8 条(采样循环 removed 条件的 false 分支)", () => {
|
|
61
|
+
const old = ["keep", "r1", "r2", "r3"].join("\n");
|
|
62
|
+
const next = ["keep", "a1", "a2", "a3", "a4", "a5"].join("\n");
|
|
63
|
+
const lines = summarizePromptDiff(old, next).split("\n");
|
|
64
|
+
expect(lines).toEqual([
|
|
65
|
+
"+5 -3 lines",
|
|
66
|
+
"+ a1",
|
|
67
|
+
"- r1",
|
|
68
|
+
"+ a2",
|
|
69
|
+
"- r2",
|
|
70
|
+
"+ a3",
|
|
71
|
+
"- r3",
|
|
72
|
+
"+ a4",
|
|
73
|
+
"+ a5",
|
|
74
|
+
]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("单行超 80 字符 → 截断为 79 字符 + 省略号", () => {
|
|
78
|
+
const long = "L".repeat(120);
|
|
79
|
+
const lines = summarizePromptDiff("keep", `keep\n${long}`).split("\n");
|
|
80
|
+
expect(lines[0]).toBe("+1 -0 lines");
|
|
81
|
+
expect(lines[1]).toBe(`+ ${"L".repeat(79)}…`);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("空字符串输入 → 空行计入 removed(split 单空行边界)", () => {
|
|
85
|
+
expect(summarizePromptDiff("", "a")).toBe("+1 -1 lines\n+ a\n- ");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("LCS 面积超限(2001×2001 > 4M cells)→ multiset 降级:共享行抵消,added 按新文本序", () => {
|
|
89
|
+
const oldLines = ["shared", ...Array.from({ length: 2000 }, (_, i) => `old-${i}`)];
|
|
90
|
+
const newLines = ["shared", ...Array.from({ length: 2000 }, (_, i) => `new-${i}`)];
|
|
91
|
+
const summary = summarizePromptDiff(oldLines.join("\n"), newLines.join("\n"));
|
|
92
|
+
const lines = summary.split("\n");
|
|
93
|
+
expect(lines[0]).toBe("+2000 -2000 lines");
|
|
94
|
+
expect(lines.slice(1)).toEqual([
|
|
95
|
+
"+ new-0",
|
|
96
|
+
"- old-0",
|
|
97
|
+
"+ new-1",
|
|
98
|
+
"- old-1",
|
|
99
|
+
"+ new-2",
|
|
100
|
+
"- old-2",
|
|
101
|
+
"+ new-3",
|
|
102
|
+
"- old-3",
|
|
103
|
+
]);
|
|
104
|
+
// shared 两边各出现一次 → multiset 计数抵消,不进 added/removed
|
|
105
|
+
expect(summary).not.toContain("shared");
|
|
106
|
+
});
|
|
107
|
+
});
|