@zhushanwen/pi-session-reader 0.2.1 → 0.2.3
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/package.json +8 -4
- package/src/__tests__/index.test.ts +9 -12
- package/src/__tests__/parser.test.ts +13 -9
- package/src/__tests__/tool-handler.test.ts +42 -0
- package/src/core/parser.ts +5 -9
- package/src/core/render.ts +3 -1
- package/src/index.ts +9 -12
- package/src/tool-handler.ts +10 -4
package/package.json
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-session-reader",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "index.ts",
|
|
6
|
+
"xyz-agent": {
|
|
7
|
+
"role": "universal"
|
|
8
|
+
},
|
|
6
9
|
"pi": {
|
|
7
10
|
"extensions": [
|
|
8
11
|
"./index.ts"
|
|
@@ -13,6 +16,7 @@
|
|
|
13
16
|
"pi-package"
|
|
14
17
|
],
|
|
15
18
|
"devDependencies": {
|
|
19
|
+
"@vitest/coverage-v8": "^4.1.9",
|
|
16
20
|
"vitest": "^4.1.8"
|
|
17
21
|
},
|
|
18
22
|
"files": [
|
|
@@ -21,9 +25,9 @@
|
|
|
21
25
|
"vitest.config.ts"
|
|
22
26
|
],
|
|
23
27
|
"peerDependencies": {
|
|
24
|
-
"@earendil-works/pi-coding-agent": "
|
|
25
|
-
"@earendil-works/pi-ai": "
|
|
26
|
-
"@earendil-works/pi-tui": "
|
|
28
|
+
"@earendil-works/pi-coding-agent": "^0.84.1",
|
|
29
|
+
"@earendil-works/pi-ai": "^0.84.1",
|
|
30
|
+
"@earendil-works/pi-tui": "^0.84.1",
|
|
27
31
|
"typebox": "*"
|
|
28
32
|
},
|
|
29
33
|
"peerDependenciesMeta": {
|
|
@@ -6,7 +6,8 @@ import { Check } from 'typebox/value'
|
|
|
6
6
|
* M3 pi 边界层(src/index.ts)单测(MF-6:此前该层零测试)。
|
|
7
7
|
*
|
|
8
8
|
* mock pi/ctx(as unknown as ExtensionAPI),测:
|
|
9
|
-
* 1. execute
|
|
9
|
+
* 1. execute 错误路径 throw(W4:pi 只对 execute throw 置 isError:true——agent-loop
|
|
10
|
+
* 丢弃返回值里的 isError 字段,「错误轮被标成功」契约守护)
|
|
10
11
|
* 2. session_start handler 的 ctx.mode!=='tui' 守卫
|
|
11
12
|
* 3. registeredPis WeakSet 去重:同 pi 二次 session_start 不重复注册;不同 pi 可注册
|
|
12
13
|
* 4. typeof ctx.ui.addAutocompleteProvider 运行时守卫(ui 缺方法 → 跳过,不崩)
|
|
@@ -78,7 +79,7 @@ describe('sessionReaderExtension - execute 契约', () => {
|
|
|
78
79
|
sessionReaderExtension(fake.pi as unknown as ExtensionAPI)
|
|
79
80
|
})
|
|
80
81
|
|
|
81
|
-
it('handler 抛错 → execute
|
|
82
|
+
it('handler 抛错 → execute 向 pi throw(W4:throw 才置 isError:true,返回值 isError 被 agent-loop 丢弃)', async () => {
|
|
82
83
|
const toolDef = fake.registerTool.mock.calls[0][0] as {
|
|
83
84
|
name: string
|
|
84
85
|
execute: (
|
|
@@ -87,18 +88,14 @@ describe('sessionReaderExtension - execute 契约', () => {
|
|
|
87
88
|
signal: AbortSignal | undefined,
|
|
88
89
|
onUpdate: unknown,
|
|
89
90
|
ctx: unknown,
|
|
90
|
-
) => Promise<{ content: Array<{ type: string; text: string }
|
|
91
|
+
) => Promise<{ content: Array<{ type: string; text: string }> }>
|
|
91
92
|
}
|
|
92
93
|
expect(toolDef.name).toBe('session_read')
|
|
93
|
-
// action=find 缺 query → F5 requireStr 抛错 → execute catch
|
|
94
|
-
|
|
95
|
-
expect(
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
// 非 Error 抛错(string)也能转文本
|
|
99
|
-
const result2 = await toolDef.execute('tc-2', { action: 123 }, undefined, undefined, undefined)
|
|
100
|
-
expect(result2.isError).toBe(true)
|
|
101
|
-
expect(typeof result2.content[0].text).toBe('string')
|
|
94
|
+
// action=find 缺 query → F5 requireStr 抛错 → execute 原样传播(pi catch 后
|
|
95
|
+
// isError:true + message 成为 toolResult content[0].text)
|
|
96
|
+
await expect(
|
|
97
|
+
toolDef.execute('tc-1', { action: 'find' }, undefined, undefined, undefined),
|
|
98
|
+
).rejects.toThrow(/👉/)
|
|
102
99
|
})
|
|
103
100
|
})
|
|
104
101
|
|
|
@@ -117,18 +117,22 @@ describe('parseSessionContent', () => {
|
|
|
117
117
|
expect(result.lastLinePartial).toBe(true)
|
|
118
118
|
})
|
|
119
119
|
|
|
120
|
-
it('custom entry 无顶层 id
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
120
|
+
it('custom entry 无顶层 id → 坏行跳过(W4:data.id fallback 死分支已删,pi appendCustomEntry 恒写顶层 id)', () => {
|
|
121
|
+
// pi appendCustomEntry 恒写顶层 id(session-manager.js:820-828);data.id 是扩展
|
|
122
|
+
// 业务字段而非 entry id。无顶层 id 的行按坏行跳过(skippedLines++)。
|
|
123
|
+
const content = [
|
|
124
|
+
line({ type: 'custom', customType: 'ok-entry', id: 'top-level-id', data: {} }),
|
|
125
|
+
line({
|
|
126
|
+
type: 'custom',
|
|
127
|
+
customType: 'subagent-identity',
|
|
128
|
+
data: { id: 'sa-abc123', rootSessionId: 'sess-1', slug: 'fix' },
|
|
129
|
+
}),
|
|
130
|
+
].join('\n')
|
|
126
131
|
const result = parseSessionContent(content)
|
|
127
132
|
|
|
128
133
|
expect(result.entries).toHaveLength(1)
|
|
129
|
-
expect(result.entries[0].id).toBe('
|
|
130
|
-
expect(result.
|
|
131
|
-
expect(result.skippedLines).toBe(0)
|
|
134
|
+
expect(result.entries[0].id).toBe('top-level-id')
|
|
135
|
+
expect(result.skippedLines).toBe(1)
|
|
132
136
|
})
|
|
133
137
|
|
|
134
138
|
it('session header 无 parentId → 归一化为 null(root 判定)', () => {
|
|
@@ -372,6 +372,48 @@ describe('F2 多匹配消歧(fixture,MF-9)', () => {
|
|
|
372
372
|
})
|
|
373
373
|
})
|
|
374
374
|
|
|
375
|
+
describe('outline skippedLines 报告(fixture,D8d 有检测必有报告)', () => {
|
|
376
|
+
let dir: string
|
|
377
|
+
const SID = '019e6c96-bbbb-cccc-dddd-00000000000b'
|
|
378
|
+
|
|
379
|
+
beforeEach(async () => {
|
|
380
|
+
dir = await mkdtemp(join(tmpdir(), 'tool-handler-skipped-'))
|
|
381
|
+
})
|
|
382
|
+
afterEach(async () => {
|
|
383
|
+
await rm(dir, { recursive: true, force: true })
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
it('坏行计入 stats.skippedLines 且文本尾部可见(不静默跳过)', async () => {
|
|
387
|
+
// 中间注入 1 行坏 JSON(半截对象):parser 计 skippedLines=1,outline 必须报告
|
|
388
|
+
const slug = '--demo-cwd--'
|
|
389
|
+
await mkdir(join(dir, 'sessions', slug), { recursive: true })
|
|
390
|
+
const lines = [
|
|
391
|
+
JSON.stringify({ type: 'session', id: SID, cwd: '/demo' }),
|
|
392
|
+
JSON.stringify({
|
|
393
|
+
type: 'message',
|
|
394
|
+
id: SID + '-m1',
|
|
395
|
+
parentId: SID,
|
|
396
|
+
message: { role: 'user', content: [{ type: 'text', text: 'hello' }] },
|
|
397
|
+
}),
|
|
398
|
+
'{"broken json line',
|
|
399
|
+
]
|
|
400
|
+
await writeFile(join(dir, 'sessions', slug, SID + '.jsonl'), lines.join('\n') + '\n')
|
|
401
|
+
|
|
402
|
+
const r = await handleSessionRead({ action: 'outline', session: SID }, dir)
|
|
403
|
+
const d = r.details as { stats: { skippedLines: number } }
|
|
404
|
+
expect(d.stats.skippedLines).toBe(1)
|
|
405
|
+
expect(r.content[0].text).toContain('1 skipped lines')
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
it('无坏行时不输出 skipped 片段(正常 session 零噪音)', async () => {
|
|
409
|
+
await makeFixtureSession(dir, SID, 'clean session')
|
|
410
|
+
const r = await handleSessionRead({ action: 'outline', session: SID }, dir)
|
|
411
|
+
const d = r.details as { stats: { skippedLines: number } }
|
|
412
|
+
expect(d.stats.skippedLines).toBe(0)
|
|
413
|
+
expect(r.content[0].text).not.toContain('skipped lines')
|
|
414
|
+
})
|
|
415
|
+
})
|
|
416
|
+
|
|
375
417
|
describe('search 灾难性正则降级 + abort(fixture,MF-5 回归)', () => {
|
|
376
418
|
let dir: string
|
|
377
419
|
const SID = '019e6c96-bbbb-cccc-dddd-00000000000a'
|
package/src/core/parser.ts
CHANGED
|
@@ -60,15 +60,11 @@ function toEntry(raw: unknown): Entry | undefined {
|
|
|
60
60
|
const obj = raw as Record<string, unknown>
|
|
61
61
|
if (typeof obj.type !== 'string') return undefined
|
|
62
62
|
|
|
63
|
-
// id
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
if (typeof data === 'object' && data !== null && typeof (data as Record<string, unknown>).id === 'string') {
|
|
69
|
-
id = (data as Record<string, unknown>).id
|
|
70
|
-
}
|
|
71
|
-
}
|
|
63
|
+
// id 解析:pi appendCustomEntry 恒写顶层 id(session-manager.js:820-828)——
|
|
64
|
+
// 无需 fallback。data.id 是扩展业务字段(如 subagent-identity payload),不是 entry id。
|
|
65
|
+
// [W4 删除] 原 data.id fallback 分支对 pi 写出的 entry 是死分支(对历史/异构文件
|
|
66
|
+
// 也无必要——entry id 语义以顶层为准)。
|
|
67
|
+
const id: unknown = obj.id
|
|
72
68
|
if (typeof id !== 'string') return undefined
|
|
73
69
|
|
|
74
70
|
const entry: Entry = {
|
package/src/core/render.ts
CHANGED
|
@@ -26,7 +26,8 @@ export interface TurnBrief {
|
|
|
26
26
|
|
|
27
27
|
export interface OutlineResult {
|
|
28
28
|
turns: TurnBrief[]
|
|
29
|
-
|
|
29
|
+
/** skippedLines:JSON 解析失败/缺结构字段的行数(parser 检测,工具层覆盖精确值;render 层无 ParseResult 恒 0) */
|
|
30
|
+
stats: { totalTurns: number; totalEntries: number; totalBytes: number; parsedBytes: number; skippedLines: number }
|
|
30
31
|
/** chars / 4 近似(与 design P-outline 口径一致) */
|
|
31
32
|
tokenEstimate: number
|
|
32
33
|
/** 被总预算截断的 turn 数(从尾部丢弃) */
|
|
@@ -319,6 +320,7 @@ export function renderOutline(
|
|
|
319
320
|
totalEntries: totalEntriesAll,
|
|
320
321
|
totalBytes: parsedBytes,
|
|
321
322
|
parsedBytes,
|
|
323
|
+
skippedLines: 0,
|
|
322
324
|
}
|
|
323
325
|
|
|
324
326
|
if (turns.length === 0) {
|
package/src/index.ts
CHANGED
|
@@ -12,7 +12,9 @@ import { createSessionCommand } from './tui/session-command.js'
|
|
|
12
12
|
* 分层(同 scheduler/cw-tool):
|
|
13
13
|
* - tool-handler.ts:纯逻辑 handler,agentDir 注入,零 pi 依赖,可单测
|
|
14
14
|
* - index.ts(本文件):pi 依赖层,registerTool + getAgentDir() 调用 + execute 闭包
|
|
15
|
-
*
|
|
15
|
+
* (错误直接 throw 给 pi——pi-agent-core agent-loop 只对 execute throw 置
|
|
16
|
+
* isError:true,返回值里的 isError 字段被丢弃;W4 修复,锚点
|
|
17
|
+
* agent-loop.js:453-483/525-547,pi 自带 bash 工具同范式)
|
|
16
18
|
*
|
|
17
19
|
* M4 将在此 addAutocompleteProvider(TUI # 补全,ctx.mode === 'tui' 时)。
|
|
18
20
|
*/
|
|
@@ -164,17 +166,12 @@ export default function sessionReaderExtension(pi: ExtensionAPI): void {
|
|
|
164
166
|
_onUpdate: unknown,
|
|
165
167
|
_ctx: ExtensionContext,
|
|
166
168
|
) {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
content: [{ type: 'text' as const, text: msg }],
|
|
174
|
-
details: {},
|
|
175
|
-
isError: true,
|
|
176
|
-
}
|
|
177
|
-
}
|
|
169
|
+
// 错误路径直接 throw:pi 契约只有 throw 才置 isError:true(tool_execution_end /
|
|
170
|
+
// ToolResultMessage),handler 抛的 Error 文案(含 👉 恢复提示)原样成为
|
|
171
|
+
// toolResult content,模型仍可读到。曾用 return {isError:true}——被 agent-loop
|
|
172
|
+
// 丢弃,错误轮被标成功(W4 修复)。
|
|
173
|
+
// signal 仅 search 消费(MF-5:长扫描可中断,Esc 不再挂死);其余 action 有界不接
|
|
174
|
+
return handleSessionRead(params, getAgentDir(), signal)
|
|
178
175
|
},
|
|
179
176
|
})
|
|
180
177
|
|
package/src/tool-handler.ts
CHANGED
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
* 按 action 分发到 8 条路径,串联 M1 core(parser/tree/turns/render)+ M2 discovery
|
|
8
8
|
*(find/subagents)。content 给 LLM 读(人类可读摘要),details 供程序化消费/测试断言。
|
|
9
9
|
*
|
|
10
|
-
* 错误规格 F1-F6:handler 抛 Error(message 含 👉
|
|
11
|
-
*
|
|
10
|
+
* 错误规格 F1-F6:handler 抛 Error(message 含 👉 恢复指引),index.ts 的 execute 闭包
|
|
11
|
+
* 原样传播给 pi——pi-agent-core 只对 execute throw 置 isError:true(返回值里的 isError
|
|
12
|
+
* 字段被丢弃,agent-loop.js:453-483)。handler 可抛(纯逻辑可测)。
|
|
12
13
|
* 例外:F2 多匹配与 F1 find 零匹配「不视为错误」,返回消歧/提示结果而非抛错。
|
|
13
14
|
*/
|
|
14
15
|
import { existsSync, openSync, readSync, closeSync } from 'node:fs'
|
|
@@ -425,7 +426,9 @@ function formatOutlineText(r: OutlineResult): string {
|
|
|
425
426
|
})
|
|
426
427
|
const tail = [
|
|
427
428
|
'',
|
|
428
|
-
`${r.stats.totalTurns} turns · ${r.stats.totalEntries} entries · ~${r.tokenEstimate} tokens
|
|
429
|
+
`${r.stats.totalTurns} turns · ${r.stats.totalEntries} entries · ~${r.tokenEstimate} tokens${
|
|
430
|
+
r.stats.skippedLines > 0 ? ` · ${r.stats.skippedLines} skipped lines` : ''
|
|
431
|
+
}`,
|
|
429
432
|
r.truncated ? `[还有 ${r.truncated} 轮未显示,用 detail 或调大 budget]` : '',
|
|
430
433
|
]
|
|
431
434
|
.filter(Boolean)
|
|
@@ -677,7 +680,7 @@ async function doFamily(params: SessionReadParams, agentDir: string): Promise<To
|
|
|
677
680
|
async function doOutline(params: SessionReadParams, agentDir: string): Promise<ToolResult> {
|
|
678
681
|
const resolved = await resolveSessionId(params.session, 'outline', agentDir, params.source)
|
|
679
682
|
if (resolved.kind === 'multi') return disambiguate(resolved.query, resolved.candidates)
|
|
680
|
-
const { entries, totalBytes } = await safeParse(resolved.fileName)
|
|
683
|
+
const { entries, totalBytes, skippedLines } = await safeParse(resolved.fileName)
|
|
681
684
|
const tree = buildTreeView(entries)
|
|
682
685
|
const turns = segmentTurns(entries, new Set(tree.leafPath))
|
|
683
686
|
const opts: OutlineOptions = {
|
|
@@ -689,6 +692,9 @@ async function doOutline(params: SessionReadParams, agentDir: string): Promise<T
|
|
|
689
692
|
// 覆盖 stats.totalBytes:render 用 parsedBytes(leaf entry JSON 字节和)近似,
|
|
690
693
|
// 此处用 ParseResult.totalBytes(原始文件字节数,design §3.4 stats.totalBytes 语义)
|
|
691
694
|
result.stats.totalBytes = totalBytes
|
|
695
|
+
// [D8d] skippedLines 同模式覆盖:parser 已检测坏行计数(render 签名不含 ParseResult 恒 0),
|
|
696
|
+
// 有检测必有报告——静默跳过行对调用方不可见 = 数据完整性缺口
|
|
697
|
+
result.stats.skippedLines = skippedLines
|
|
692
698
|
return { content: [{ type: 'text', text: formatOutlineText(result) }], details: result }
|
|
693
699
|
}
|
|
694
700
|
|