@zhushanwen/pi-session-reader 0.3.0 → 0.3.1
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 +1 -1
- package/src/__tests__/find.test.ts +15 -1
- package/src/__tests__/tool-handler.test.ts +146 -9
- package/src/__tests__/toolcall.test.ts +13 -0
- package/src/__tests__/workflow.test.ts +59 -1
- package/src/core/family.ts +43 -17
- package/src/core/parser.ts +28 -17
- package/src/core/render.ts +59 -34
- package/src/core/toolcall.ts +105 -69
- package/src/core/workflow.ts +139 -135
- package/src/discovery/find.ts +120 -74
- package/src/discovery/subagents.ts +188 -106
- package/src/discovery/workflows.ts +44 -29
- package/src/tool-handler.ts +103 -48
- package/vitest.config.ts +2 -0
package/package.json
CHANGED
|
@@ -145,7 +145,7 @@ describe('findSessions', () => {
|
|
|
145
145
|
recordsDir = join(agentDir, 'subagents', '--Users-demo--', 'records')
|
|
146
146
|
})
|
|
147
147
|
afterEach(async () => {
|
|
148
|
-
await rm(agentDir, { recursive: true, force: true })
|
|
148
|
+
await rm(agentDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
149
149
|
})
|
|
150
150
|
|
|
151
151
|
it('uuid 片段匹配正确的 session(sessionId 含 query)', async () => {
|
|
@@ -256,6 +256,20 @@ describe('findSessions', () => {
|
|
|
256
256
|
expect(result).toEqual({ matches: [], truncated: false })
|
|
257
257
|
})
|
|
258
258
|
|
|
259
|
+
it('uuid 片段命中路径的 firstMessagePreview 补读(命中时未读首消息,第 5 步补)', async () => {
|
|
260
|
+
// uuid 片段直接命中 → 不走关键词层(preview 未读),最终结果仍应补读出首消息预览
|
|
261
|
+
await makeSession(slugDir, {
|
|
262
|
+
name: 'a.jsonl',
|
|
263
|
+
id: '019cafe0-aaaa-bbbb',
|
|
264
|
+
cwd: '/demo',
|
|
265
|
+
firstUserText: 'uuid 命中后补读的预览文本',
|
|
266
|
+
})
|
|
267
|
+
const { matches } = await findSessions('019cafe0', agentDir)
|
|
268
|
+
expect(matches).toHaveLength(1)
|
|
269
|
+
expect(matches[0].sessionId).toBe('019cafe0-aaaa-bbbb')
|
|
270
|
+
expect(matches[0].firstMessagePreview).toBe('uuid 命中后补读的预览文本')
|
|
271
|
+
})
|
|
272
|
+
|
|
259
273
|
it('firstMessagePreview 截断到 80 字符', async () => {
|
|
260
274
|
const longText = 'X'.repeat(200)
|
|
261
275
|
await makeSession(slugDir, {
|
|
@@ -330,7 +330,7 @@ describe('F2 多匹配消歧(fixture,MF-9)', () => {
|
|
|
330
330
|
dir = await mkdtemp(join(tmpdir(), 'tool-handler-f2-'))
|
|
331
331
|
})
|
|
332
332
|
afterEach(async () => {
|
|
333
|
-
await rm(dir, { recursive: true, force: true })
|
|
333
|
+
await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
334
334
|
})
|
|
335
335
|
|
|
336
336
|
it('共享 uuid 片段 → 不抛错,content 含两候选 + 👉,details.ambiguous=true', async () => {
|
|
@@ -380,7 +380,7 @@ describe('outline skippedLines 报告(fixture,D8d 有检测必有报告)',
|
|
|
380
380
|
dir = await mkdtemp(join(tmpdir(), 'tool-handler-skipped-'))
|
|
381
381
|
})
|
|
382
382
|
afterEach(async () => {
|
|
383
|
-
await rm(dir, { recursive: true, force: true })
|
|
383
|
+
await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
384
384
|
})
|
|
385
385
|
|
|
386
386
|
it('坏行计入 stats.skippedLines 且文本尾部可见(不静默跳过)', async () => {
|
|
@@ -423,7 +423,7 @@ describe('search 灾难性正则降级 + abort(fixture,MF-5 回归)', () =
|
|
|
423
423
|
await makeFixtureSession(dir, SID, 'aaa plugin 内容')
|
|
424
424
|
})
|
|
425
425
|
afterEach(async () => {
|
|
426
|
-
await rm(dir, { recursive: true, force: true })
|
|
426
|
+
await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
427
427
|
})
|
|
428
428
|
|
|
429
429
|
it('嵌套量词 pattern (a+)+ → 降级字面子串(不挂死,零命中)', async () => {
|
|
@@ -523,6 +523,143 @@ describe('search 灾难性正则降级 + abort(fixture,MF-5 回归)', () =
|
|
|
523
523
|
})
|
|
524
524
|
})
|
|
525
525
|
|
|
526
|
+
describe('extract commits 双路径(fixture:git-cmd 主路径 + commit-context 次路径 + 去重)', () => {
|
|
527
|
+
let dir: string
|
|
528
|
+
const SID = '019e6c96-bbbb-cccc-dddd-00000000000c'
|
|
529
|
+
|
|
530
|
+
beforeEach(async () => {
|
|
531
|
+
dir = await mkdtemp(join(tmpdir(), 'tool-handler-commits-'))
|
|
532
|
+
})
|
|
533
|
+
afterEach(async () => {
|
|
534
|
+
await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
535
|
+
})
|
|
536
|
+
|
|
537
|
+
/** 写单个 session JSONL(commits 用例行内联传入)。 */
|
|
538
|
+
async function writeCommitsSession(id: string, lines: string[]): Promise<void> {
|
|
539
|
+
await mkdir(join(dir, 'sessions', '--demo-cwd--'), { recursive: true })
|
|
540
|
+
await writeFile(
|
|
541
|
+
join(dir, 'sessions', '--demo-cwd--', `${id}.jsonl`),
|
|
542
|
+
lines.join('\n') + '\n',
|
|
543
|
+
)
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
it('commit-context 次路径:非 git bash 的 toolResult,hash 邻近含关键词才收(D6)', async () => {
|
|
547
|
+
await writeCommitsSession(SID, [
|
|
548
|
+
JSON.stringify({ type: 'session', id: SID, cwd: '/demo' }),
|
|
549
|
+
JSON.stringify({
|
|
550
|
+
type: 'message',
|
|
551
|
+
id: `${SID}-m1`,
|
|
552
|
+
parentId: SID,
|
|
553
|
+
message: {
|
|
554
|
+
role: 'assistant',
|
|
555
|
+
content: [
|
|
556
|
+
{ type: 'toolCall', id: 'tc-read-1', name: 'read', arguments: { path: '/tmp/log.txt' } },
|
|
557
|
+
],
|
|
558
|
+
},
|
|
559
|
+
}),
|
|
560
|
+
// read 结果:hash 邻近 30 字符窗口含 'feat:' → commit-context 次路径纳入
|
|
561
|
+
JSON.stringify({
|
|
562
|
+
type: 'message',
|
|
563
|
+
id: `${SID}-m2`,
|
|
564
|
+
parentId: `${SID}-m1`,
|
|
565
|
+
message: {
|
|
566
|
+
role: 'toolResult',
|
|
567
|
+
toolName: 'read',
|
|
568
|
+
toolCallId: 'tc-read-1',
|
|
569
|
+
content: [{ type: 'text', text: 'deploy abc1234f done\nfeat: add login' }],
|
|
570
|
+
},
|
|
571
|
+
}),
|
|
572
|
+
// read 结果:hash 无关键词邻近 → 两路径都不收(过滤分支)
|
|
573
|
+
JSON.stringify({
|
|
574
|
+
type: 'message',
|
|
575
|
+
id: `${SID}-m3`,
|
|
576
|
+
parentId: `${SID}-m2`,
|
|
577
|
+
message: {
|
|
578
|
+
role: 'assistant',
|
|
579
|
+
content: [
|
|
580
|
+
{ type: 'toolCall', id: 'tc-read-2', name: 'read', arguments: { path: '/tmp/out.txt' } },
|
|
581
|
+
],
|
|
582
|
+
},
|
|
583
|
+
}),
|
|
584
|
+
JSON.stringify({
|
|
585
|
+
type: 'message',
|
|
586
|
+
id: `${SID}-m4`,
|
|
587
|
+
parentId: `${SID}-m3`,
|
|
588
|
+
message: {
|
|
589
|
+
role: 'toolResult',
|
|
590
|
+
toolName: 'read',
|
|
591
|
+
toolCallId: 'tc-read-2',
|
|
592
|
+
content: [{ type: 'text', text: 'output 9998887 plain text' }],
|
|
593
|
+
},
|
|
594
|
+
}),
|
|
595
|
+
])
|
|
596
|
+
const r = await handleSessionRead({ action: 'extract', session: SID, what: 'commits' }, dir)
|
|
597
|
+
const d = r.details as { count: number; items: Array<{ hash: string; source: string }> }
|
|
598
|
+
const hashes = d.items.map((it) => it.hash)
|
|
599
|
+
expect(hashes).toContain('abc1234f')
|
|
600
|
+
expect(hashes).not.toContain('9998887')
|
|
601
|
+
expect(d.items.find((it) => it.hash === 'abc1234f')?.source).toBe('commit-context')
|
|
602
|
+
})
|
|
603
|
+
|
|
604
|
+
it('去重高置信优先:同 hash 同时命中 git-cmd 与 commit-context → 保留 git-cmd 一条', async () => {
|
|
605
|
+
await writeCommitsSession(SID, [
|
|
606
|
+
JSON.stringify({ type: 'session', id: SID, cwd: '/demo' }),
|
|
607
|
+
JSON.stringify({
|
|
608
|
+
type: 'message',
|
|
609
|
+
id: `${SID}-m1`,
|
|
610
|
+
parentId: SID,
|
|
611
|
+
message: {
|
|
612
|
+
role: 'assistant',
|
|
613
|
+
content: [
|
|
614
|
+
{ type: 'toolCall', id: 'tc-bash-1', name: 'bash', arguments: { command: 'git log --oneline -3' } },
|
|
615
|
+
],
|
|
616
|
+
},
|
|
617
|
+
}),
|
|
618
|
+
// bash + git log → git-cmd 高置信(含 def5678a)
|
|
619
|
+
JSON.stringify({
|
|
620
|
+
type: 'message',
|
|
621
|
+
id: `${SID}-m2`,
|
|
622
|
+
parentId: `${SID}-m1`,
|
|
623
|
+
message: {
|
|
624
|
+
role: 'toolResult',
|
|
625
|
+
toolName: 'bash',
|
|
626
|
+
toolCallId: 'tc-bash-1',
|
|
627
|
+
content: [{ type: 'text', text: 'def5678a feat: fix crash' }],
|
|
628
|
+
},
|
|
629
|
+
}),
|
|
630
|
+
JSON.stringify({
|
|
631
|
+
type: 'message',
|
|
632
|
+
id: `${SID}-m3`,
|
|
633
|
+
parentId: `${SID}-m2`,
|
|
634
|
+
message: {
|
|
635
|
+
role: 'assistant',
|
|
636
|
+
content: [
|
|
637
|
+
{ type: 'toolCall', id: 'tc-read-1', name: 'read', arguments: { path: '/tmp/deploy.txt' } },
|
|
638
|
+
],
|
|
639
|
+
},
|
|
640
|
+
}),
|
|
641
|
+
// read 结果同 hash def5678a + 关键词 merged → low 候选,去重后丢弃
|
|
642
|
+
JSON.stringify({
|
|
643
|
+
type: 'message',
|
|
644
|
+
id: `${SID}-m4`,
|
|
645
|
+
parentId: `${SID}-m3`,
|
|
646
|
+
message: {
|
|
647
|
+
role: 'toolResult',
|
|
648
|
+
toolName: 'read',
|
|
649
|
+
toolCallId: 'tc-read-1',
|
|
650
|
+
content: [{ type: 'text', text: 'deploy def5678a merged' }],
|
|
651
|
+
},
|
|
652
|
+
}),
|
|
653
|
+
])
|
|
654
|
+
const r = await handleSessionRead({ action: 'extract', session: SID, what: 'commits' }, dir)
|
|
655
|
+
const d = r.details as { count: number; items: Array<{ hash: string; source: string }> }
|
|
656
|
+
const dupes = d.items.filter((it) => it.hash === 'def5678a')
|
|
657
|
+
expect(dupes).toHaveLength(1)
|
|
658
|
+
expect(dupes[0].source).toBe('git-cmd')
|
|
659
|
+
expect(d.count).toBe(1)
|
|
660
|
+
})
|
|
661
|
+
})
|
|
662
|
+
|
|
526
663
|
describe('renderExtractItems F9 截断(S3 首项超大 + S4 文案)', () => {
|
|
527
664
|
it('S3: 首项超大内部截断 → body 不超预算,shown<count + truncated=true', () => {
|
|
528
665
|
// 首项 10000 字符 >> EXTRACT_BUDGET_BYTES(8000);旧逻辑放行首项致 body≈10KB
|
|
@@ -635,7 +772,7 @@ describe('resolveSessionId ① 绝对路径形态(w2 TC2-TC6)', () => {
|
|
|
635
772
|
dir = await mkdtemp(join(tmpdir(), 'tool-handler-path-'))
|
|
636
773
|
})
|
|
637
774
|
afterEach(async () => {
|
|
638
|
-
await rm(dir, { recursive: true, force: true })
|
|
775
|
+
await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
639
776
|
})
|
|
640
777
|
|
|
641
778
|
it('TC2: 绝对路径 outline → sessionId=header 真实 id(非文件名)', async () => {
|
|
@@ -723,7 +860,7 @@ describe('resolveSessionId ① 绝对路径形态(w2 TC2-TC6)', () => {
|
|
|
723
860
|
)
|
|
724
861
|
expect((r.details as { path: string }).path).toContain(fileId)
|
|
725
862
|
} finally {
|
|
726
|
-
await rm(tmpUnderHome, { recursive: true, force: true })
|
|
863
|
+
await rm(tmpUnderHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
727
864
|
}
|
|
728
865
|
})
|
|
729
866
|
})
|
|
@@ -735,7 +872,7 @@ describe('resolveSessionId ② sa-id 形态(w2 TC7-TC10 + CQ3)', () => {
|
|
|
735
872
|
dir = await mkdtemp(join(tmpdir(), 'tool-handler-said-'))
|
|
736
873
|
})
|
|
737
874
|
afterEach(async () => {
|
|
738
|
-
await rm(dir, { recursive: true, force: true })
|
|
875
|
+
await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
739
876
|
})
|
|
740
877
|
|
|
741
878
|
it('TC7: sa-id 恰 1 命中 + sessionFile 存在 → 成功,sessionId=header 真实 id(非 sa-)', async () => {
|
|
@@ -858,7 +995,7 @@ describe('source 透传(w2 TC12-TC13,依赖 w1 findSessions opts.source)',
|
|
|
858
995
|
dir = await mkdtemp(join(tmpdir(), 'tool-handler-src-'))
|
|
859
996
|
})
|
|
860
997
|
afterEach(async () => {
|
|
861
|
-
await rm(dir, { recursive: true, force: true })
|
|
998
|
+
await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
862
999
|
})
|
|
863
1000
|
|
|
864
1001
|
it('TC12: find source 过滤——subagent 只返回 subagent 候选,main 只返回 main', async () => {
|
|
@@ -1089,7 +1226,7 @@ describe('doWorkflow(w6,fixture)', () => {
|
|
|
1089
1226
|
dir = await mkdtemp(join(tmpdir(), 'tool-handler-wf-'))
|
|
1090
1227
|
})
|
|
1091
1228
|
afterEach(async () => {
|
|
1092
|
-
await rm(dir, { recursive: true, force: true })
|
|
1229
|
+
await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
1093
1230
|
})
|
|
1094
1231
|
|
|
1095
1232
|
it('TC-w6-single-run:单 run 概览,content 含 run 头行/budget/step,details.runs/runIds 非空', async () => {
|
|
@@ -1370,7 +1507,7 @@ describe('doFamily recursive(m3b U8 接入)', () => {
|
|
|
1370
1507
|
})
|
|
1371
1508
|
})
|
|
1372
1509
|
afterEach(async () => {
|
|
1373
|
-
await rm(dir, { recursive: true, force: true })
|
|
1510
|
+
await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
1374
1511
|
})
|
|
1375
1512
|
|
|
1376
1513
|
it('TC-m3b-dofamily-recursive-false:不传 recursive → flat family(m0-m2 零回归)', async () => {
|
|
@@ -210,6 +210,19 @@ describe('formatToolCallSummary — coding-workflow-* / unknown fallback', () =>
|
|
|
210
210
|
circ.self = circ
|
|
211
211
|
expect(formatToolCallSummary({ id: 'c1', name: 'my-tool', arguments: circ })).toBe('my-tool')
|
|
212
212
|
})
|
|
213
|
+
|
|
214
|
+
it('treats prototype-chain key names as unknown (Map lookup, no Object.prototype hit)', () => {
|
|
215
|
+
// 表驱动用 Map 查找的语义锚:普通对象下标会把 "constructor"/"toString"/"__proto__"
|
|
216
|
+
// 解析到 Object.prototype(formatter 命中非函数成员返回对象/抛 TypeError),
|
|
217
|
+
// 这里必须与其它未知 toolName 同走 formatUnknownTool fallback 分支。
|
|
218
|
+
expect(formatToolCallSummary({ id: 'c1', name: 'constructor', arguments: {} })).toBe(
|
|
219
|
+
'constructor',
|
|
220
|
+
)
|
|
221
|
+
expect(formatToolCallSummary({ id: 'c1', name: 'toString', arguments: {} })).toBe('toString')
|
|
222
|
+
expect(formatToolCallSummary({ id: 'c1', name: '__proto__', arguments: { a: 1 } })).toBe(
|
|
223
|
+
'__proto__: {"a":1}',
|
|
224
|
+
)
|
|
225
|
+
})
|
|
213
226
|
})
|
|
214
227
|
|
|
215
228
|
describe('extractToolCalls — 提取 + coerceArgs 兜底', () => {
|
|
@@ -151,6 +151,33 @@ describe('parseRunSnapshot', () => {
|
|
|
151
151
|
expect(overview!.steps[1].contentPreview).toBe('OK')
|
|
152
152
|
})
|
|
153
153
|
|
|
154
|
+
it('TC-w5-parse-new-status-unknown:NEW call.status 非 done/running/pending(如 failed)→ step.status=pending', () => {
|
|
155
|
+
// call.status 出现三态外的值(如上游异常态)时收窄为 pending,其余字段照常提取
|
|
156
|
+
const fixture = {
|
|
157
|
+
...NEW_SNAPSHOT_FIXTURE,
|
|
158
|
+
state: {
|
|
159
|
+
...NEW_SNAPSHOT_FIXTURE.state,
|
|
160
|
+
calls: [
|
|
161
|
+
{
|
|
162
|
+
id: 3,
|
|
163
|
+
opts: { description: 'step-3' },
|
|
164
|
+
status: 'failed',
|
|
165
|
+
result: { content: 'partial', durationMs: 9 },
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
},
|
|
169
|
+
}
|
|
170
|
+
const overview = parseRunSnapshot(fixture, 'wf-status-runid', '/abs/wf.jsonl')
|
|
171
|
+
expect(overview).not.toBeNull()
|
|
172
|
+
expect(overview!.steps).toHaveLength(1)
|
|
173
|
+
const step = overview!.steps[0]
|
|
174
|
+
expect(step.index).toBe(3)
|
|
175
|
+
expect(step.status).toBe('pending')
|
|
176
|
+
expect(step.description).toBe('step-3')
|
|
177
|
+
expect(step.contentPreview).toBe('partial')
|
|
178
|
+
expect(step.durationMs).toBe(9)
|
|
179
|
+
})
|
|
180
|
+
|
|
154
181
|
it('TC-w5-parse-old:OLD 格式 (无 v) 尽力解析为 legacy overview,step status 推测', () => {
|
|
155
182
|
const overview = parseRunSnapshot(OLD_SNAPSHOT_FIXTURE, 'wf-old-link', '/abs/wf-old.jsonl')
|
|
156
183
|
expect(overview).not.toBeNull()
|
|
@@ -199,6 +226,37 @@ describe('extractCallSessionFiles', () => {
|
|
|
199
226
|
const files = extractCallSessionFiles(NEW_SNAPSHOT_FIXTURE)
|
|
200
227
|
expect(files).toEqual(['/abs/session.jsonl'])
|
|
201
228
|
})
|
|
229
|
+
|
|
230
|
+
it('OLD 快照(无 v,callCache)从 value 顶层与 value.result 提取 sessionFile', () => {
|
|
231
|
+
const files = extractCallSessionFiles({
|
|
232
|
+
callCache: [
|
|
233
|
+
{ key: 0, value: { sessionFile: '/abs/old-call0.jsonl' } },
|
|
234
|
+
{ key: 1, value: { result: { sessionFile: '/abs/old-call1.jsonl' } } },
|
|
235
|
+
],
|
|
236
|
+
})
|
|
237
|
+
expect(files).toEqual(['/abs/old-call0.jsonl', '/abs/old-call1.jsonl'])
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it('OLD 快照 value 无 sessionFile / value 非对象 → 跳过该 call,不产出路径', () => {
|
|
241
|
+
// 对齐 OLD_SNAPSHOT_FIXTURE 形状(旧 pi 不持久化 sessionFile)+ value 非对象脏数据回退 call 本身
|
|
242
|
+
const files = extractCallSessionFiles({
|
|
243
|
+
callCache: [
|
|
244
|
+
{ key: 7, value: { content: '', usage: { input: 0 } } },
|
|
245
|
+
{ key: 8, value: 42 },
|
|
246
|
+
{ key: 9, sessionFile: '/abs/bare-call.jsonl' },
|
|
247
|
+
],
|
|
248
|
+
})
|
|
249
|
+
expect(files).toEqual(['/abs/bare-call.jsonl'])
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it('快照非对象 / calls 容器缺失 / call 元素非对象 → 空数组', () => {
|
|
253
|
+
expect(extractCallSessionFiles(null)).toEqual([])
|
|
254
|
+
expect(extractCallSessionFiles('wf-run-v1')).toEqual([])
|
|
255
|
+
expect(extractCallSessionFiles({ v: 'wf-run-v1', state: null })).toEqual([])
|
|
256
|
+
expect(extractCallSessionFiles({ v: 'wf-run-v1', state: {} })).toEqual([])
|
|
257
|
+
expect(extractCallSessionFiles({ callCache: 'not-an-array' })).toEqual([])
|
|
258
|
+
expect(extractCallSessionFiles({ callCache: [null, 7, 'x'] })).toEqual([])
|
|
259
|
+
})
|
|
202
260
|
})
|
|
203
261
|
|
|
204
262
|
// ============================================================
|
|
@@ -282,7 +340,7 @@ describe('renderWorkflowOverview', () => {
|
|
|
282
340
|
describe('readRunSnapshot', () => {
|
|
283
341
|
let dir: string
|
|
284
342
|
afterEach(async () => {
|
|
285
|
-
if (dir) await rm(dir, { recursive: true, force: true }).catch(() => {})
|
|
343
|
+
if (dir) await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 }).catch(() => {})
|
|
286
344
|
})
|
|
287
345
|
|
|
288
346
|
it('TC-w5-read-tail-fallback:末行半截 JSON 回退倒数第二行完整快照', async () => {
|
package/src/core/family.ts
CHANGED
|
@@ -134,24 +134,12 @@ function resolveParentSessionId(
|
|
|
134
134
|
return null
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
-
/**
|
|
138
|
-
|
|
139
|
-
*
|
|
140
|
-
* - headers(type=session)→ byId + childrenOf(parentSession 文件路径反查父 sessionId)
|
|
141
|
-
* - subagentIdentities(type=custom, customType=subagent-identity)→ subagentsByRoot
|
|
142
|
-
* - fileStats 原样存入 index,供 cleanedUp 判断
|
|
143
|
-
*
|
|
144
|
-
* 坏数据容错:identity 缺 rootSessionId/slug 跳过;parentSession 反查不到父(父文件
|
|
145
|
-
* 未被扫描到)该 entry 不进 childrenOf——均不报错,符合 pi 坏 session 容错(design §2)。
|
|
146
|
-
*/
|
|
147
|
-
export function buildFamilyIndex(
|
|
137
|
+
/** 阶段 1:headers → byId(SessionRef 组装,mtime/size 从 fileStats 取,取不到为 0)。 */
|
|
138
|
+
function buildById(
|
|
148
139
|
headers: Entry[],
|
|
149
|
-
subagentIdentities: Entry[],
|
|
150
140
|
fileStats: Map<string, { mtime: number; size: number }>,
|
|
151
|
-
):
|
|
141
|
+
): Map<string, SessionRef> {
|
|
152
142
|
const byId = new Map<string, SessionRef>()
|
|
153
|
-
|
|
154
|
-
// 1. headers → byId
|
|
155
143
|
for (const h of headers) {
|
|
156
144
|
const stat = fileStats.get(h.id)
|
|
157
145
|
const ref: SessionRef = {
|
|
@@ -164,8 +152,17 @@ export function buildFamilyIndex(
|
|
|
164
152
|
if (h.parentSession) ref.parentSession = h.parentSession
|
|
165
153
|
byId.set(h.id, ref)
|
|
166
154
|
}
|
|
155
|
+
return byId
|
|
156
|
+
}
|
|
167
157
|
|
|
168
|
-
|
|
158
|
+
/**
|
|
159
|
+
* 阶段 2:childrenOf——parentSession(文件路径)→ 反查父 sessionId → key 用父 sessionId。
|
|
160
|
+
* 反查不到父 → 无法建反查关系,跳过(不报错,design §2 容错)。
|
|
161
|
+
*/
|
|
162
|
+
function buildChildrenOf(
|
|
163
|
+
headers: Entry[],
|
|
164
|
+
byId: Map<string, SessionRef>,
|
|
165
|
+
): Map<string, SessionRef[]> {
|
|
169
166
|
const childrenOf = new Map<string, SessionRef[]>()
|
|
170
167
|
for (const h of headers) {
|
|
171
168
|
if (!h.parentSession) continue
|
|
@@ -177,8 +174,17 @@ export function buildFamilyIndex(
|
|
|
177
174
|
list.push(childRef)
|
|
178
175
|
childrenOf.set(parentSid, list)
|
|
179
176
|
}
|
|
177
|
+
return childrenOf
|
|
178
|
+
}
|
|
180
179
|
|
|
181
|
-
|
|
180
|
+
/**
|
|
181
|
+
* 阶段 3:subagentIdentities → subagentsByRoot。
|
|
182
|
+
* 坏数据(缺 rootSessionId/slug)跳过,不报错(design §2 容错)。
|
|
183
|
+
*/
|
|
184
|
+
function buildSubagentsByRoot(
|
|
185
|
+
subagentIdentities: Entry[],
|
|
186
|
+
fileStats: Map<string, { mtime: number; size: number }>,
|
|
187
|
+
): Map<string, SubagentRef[]> {
|
|
182
188
|
const subagentsByRoot = new Map<string, SubagentRef[]>()
|
|
183
189
|
for (const ident of subagentIdentities) {
|
|
184
190
|
if (!isSubagentIdentityData(ident.data)) continue // 坏数据(缺 rootSessionId/slug)跳过
|
|
@@ -207,7 +213,27 @@ export function buildFamilyIndex(
|
|
|
207
213
|
list.push(ref)
|
|
208
214
|
subagentsByRoot.set(ident.data.rootSessionId, list)
|
|
209
215
|
}
|
|
216
|
+
return subagentsByRoot
|
|
217
|
+
}
|
|
210
218
|
|
|
219
|
+
/**
|
|
220
|
+
* 从已读入的 session headers + subagent identity entries 建家族索引(纯逻辑,无 IO)。
|
|
221
|
+
*
|
|
222
|
+
* - headers(type=session)→ byId + childrenOf(parentSession 文件路径反查父 sessionId)
|
|
223
|
+
* - subagentIdentities(type=custom, customType=subagent-identity)→ subagentsByRoot
|
|
224
|
+
* - fileStats 原样存入 index,供 cleanedUp 判断
|
|
225
|
+
*
|
|
226
|
+
* 坏数据容错:identity 缺 rootSessionId/slug 跳过;parentSession 反查不到父(父文件
|
|
227
|
+
* 未被扫描到)该 entry 不进 childrenOf——均不报错,符合 pi 坏 session 容错(design §2)。
|
|
228
|
+
*/
|
|
229
|
+
export function buildFamilyIndex(
|
|
230
|
+
headers: Entry[],
|
|
231
|
+
subagentIdentities: Entry[],
|
|
232
|
+
fileStats: Map<string, { mtime: number; size: number }>,
|
|
233
|
+
): FamilyIndex {
|
|
234
|
+
const byId = buildById(headers, fileStats)
|
|
235
|
+
const childrenOf = buildChildrenOf(headers, byId)
|
|
236
|
+
const subagentsByRoot = buildSubagentsByRoot(subagentIdentities, fileStats)
|
|
211
237
|
return { byId, childrenOf, subagentsByRoot, fileStats }
|
|
212
238
|
}
|
|
213
239
|
|
package/src/core/parser.ts
CHANGED
|
@@ -51,6 +51,31 @@ function isMessageRole(v: unknown): v is 'user' | 'assistant' | 'toolResult' {
|
|
|
51
51
|
return v === 'user' || v === 'assistant' || v === 'toolResult'
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* 归一化 message 字段(阶段 helper:role 经值守卫收窄,缺/非法 role 时
|
|
56
|
+
* 返回 undefined,调用方丢弃 message 字段——接口 role 必填)。
|
|
57
|
+
*/
|
|
58
|
+
function parseEntryMessage(raw: unknown): Entry['message'] | undefined {
|
|
59
|
+
if (raw === null || typeof raw !== 'object') return undefined
|
|
60
|
+
const m = raw as Record<string, unknown>
|
|
61
|
+
if (!isMessageRole(m.role)) return undefined
|
|
62
|
+
const message: NonNullable<Entry['message']> = { role: m.role, content: m.content }
|
|
63
|
+
if (Array.isArray(m.toolCalls)) message.toolCalls = m.toolCalls
|
|
64
|
+
// toolResult 自带的工具关联字段(O2/O3 用,additive,实测 515/515 存在)
|
|
65
|
+
if (typeof m.toolName === 'string') message.toolName = m.toolName
|
|
66
|
+
if (typeof m.toolCallId === 'string') message.toolCallId = m.toolCallId
|
|
67
|
+
return message
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 归一化剩余可选字段(阶段 helper:customType/data/parentSession/cwd/summary)。 */
|
|
71
|
+
function assignOptionalFields(obj: Record<string, unknown>, entry: Entry): void {
|
|
72
|
+
if (typeof obj.customType === 'string') entry.customType = obj.customType
|
|
73
|
+
if (obj.data !== undefined) entry.data = obj.data
|
|
74
|
+
if (typeof obj.parentSession === 'string') entry.parentSession = obj.parentSession
|
|
75
|
+
if (typeof obj.cwd === 'string') entry.cwd = obj.cwd
|
|
76
|
+
if (obj.summary !== undefined) entry.summary = obj.summary
|
|
77
|
+
}
|
|
78
|
+
|
|
54
79
|
/**
|
|
55
80
|
* 把单个已 JSON.parse 成功的原始对象归一化为 Entry。
|
|
56
81
|
* 缺必填结构字段(type/id)返回 undefined,调用方计为坏行(skippedLines++)。
|
|
@@ -74,24 +99,10 @@ function toEntry(raw: unknown): Entry | undefined {
|
|
|
74
99
|
}
|
|
75
100
|
if (typeof obj.timestamp === 'string') entry.timestamp = obj.timestamp
|
|
76
101
|
|
|
77
|
-
|
|
78
|
-
if (
|
|
79
|
-
const m = obj.message as Record<string, unknown>
|
|
80
|
-
if (isMessageRole(m.role)) {
|
|
81
|
-
const message: NonNullable<Entry['message']> = { role: m.role, content: m.content }
|
|
82
|
-
if (Array.isArray(m.toolCalls)) message.toolCalls = m.toolCalls
|
|
83
|
-
// toolResult 自带的工具关联字段(O2/O3 用,additive,实测 515/515 存在)
|
|
84
|
-
if (typeof m.toolName === 'string') message.toolName = m.toolName
|
|
85
|
-
if (typeof m.toolCallId === 'string') message.toolCallId = m.toolCallId
|
|
86
|
-
entry.message = message
|
|
87
|
-
}
|
|
88
|
-
}
|
|
102
|
+
const message = parseEntryMessage(obj.message)
|
|
103
|
+
if (message !== undefined) entry.message = message
|
|
89
104
|
|
|
90
|
-
|
|
91
|
-
if (obj.data !== undefined) entry.data = obj.data
|
|
92
|
-
if (typeof obj.parentSession === 'string') entry.parentSession = obj.parentSession
|
|
93
|
-
if (typeof obj.cwd === 'string') entry.cwd = obj.cwd
|
|
94
|
-
if (obj.summary !== undefined) entry.summary = obj.summary
|
|
105
|
+
assignOptionalFields(obj, entry)
|
|
95
106
|
|
|
96
107
|
return entry
|
|
97
108
|
}
|
package/src/core/render.ts
CHANGED
|
@@ -330,6 +330,61 @@ function leafParsedBytes(turns: Turn[]): number {
|
|
|
330
330
|
return parsedBytes
|
|
331
331
|
}
|
|
332
332
|
|
|
333
|
+
/** allBranches:对每个 brief 标注所在 turn 的 forkPoint(找到才标 branch)。 */
|
|
334
|
+
function applyBranchLabels(briefs: TurnBrief[], turns: Turn[], tree: TreeView): void {
|
|
335
|
+
for (const b of briefs) {
|
|
336
|
+
const fp = findBranchForkPoint(turns[b.index], tree)
|
|
337
|
+
if (fp !== undefined) b.branch = fp
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* 降级序逐行渲染(design §3.5 算法 1 step3):level 0 全有;超预算降到 level 1
|
|
343
|
+
* 砍 assistantBrief;仍超降到 level 2 砍 toolSummary(骨架)。
|
|
344
|
+
* 直接改写 brief 的 assistantBrief/toolSummary 字段,返回行缓存。
|
|
345
|
+
*/
|
|
346
|
+
function renderDegradingLines(
|
|
347
|
+
briefs: TurnBrief[],
|
|
348
|
+
tree: TreeView,
|
|
349
|
+
perTurnCharBudget: number,
|
|
350
|
+
): string[] {
|
|
351
|
+
const lineCache: string[] = []
|
|
352
|
+
for (const b of briefs) {
|
|
353
|
+
const branchSize = b.branch !== undefined ? tree.branches.get(b.branch) : undefined
|
|
354
|
+
let line = formatLine(b, LINE_LEVEL_FULL, branchSize)
|
|
355
|
+
if (line.length > perTurnCharBudget) {
|
|
356
|
+
// 超预算:先砍 assistantBrief(level 1),仍超再砍 toolSummary(level 2,骨架)
|
|
357
|
+
b.assistantBrief = ''
|
|
358
|
+
line = formatLine(b, LINE_LEVEL_NO_ASSISTANT, branchSize)
|
|
359
|
+
if (line.length > perTurnCharBudget) {
|
|
360
|
+
b.toolSummary = ''
|
|
361
|
+
line = formatLine(b, LINE_LEVEL_SKELETON, branchSize)
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
lineCache.push(line)
|
|
365
|
+
}
|
|
366
|
+
return lineCache
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** 总预算截断(从尾部丢弃):就地截短 lines 与 briefs,返回最终 totalChars 与 truncated。 */
|
|
370
|
+
function truncateToTotalBudget(
|
|
371
|
+
briefs: TurnBrief[],
|
|
372
|
+
lines: string[],
|
|
373
|
+
budget: number,
|
|
374
|
+
): { totalChars: number; truncated: number | undefined } {
|
|
375
|
+
let totalChars = lines.reduce((s, l) => s + l.length, 0)
|
|
376
|
+
if (totalChars / CHARS_PER_TOKEN <= budget) return { totalChars, truncated: undefined }
|
|
377
|
+
let kept = lines.length
|
|
378
|
+
while (kept > 0 && totalChars / CHARS_PER_TOKEN > budget) {
|
|
379
|
+
kept--
|
|
380
|
+
totalChars -= lines[kept].length
|
|
381
|
+
}
|
|
382
|
+
const truncated = lines.length - kept
|
|
383
|
+
lines.length = kept
|
|
384
|
+
briefs.length = kept
|
|
385
|
+
return { totalChars, truncated }
|
|
386
|
+
}
|
|
387
|
+
|
|
333
388
|
export function renderOutline(
|
|
334
389
|
turns: Turn[],
|
|
335
390
|
tree: TreeView,
|
|
@@ -365,46 +420,16 @@ export function renderOutline(
|
|
|
365
420
|
const briefs: TurnBrief[] = turns.map(computeBrief)
|
|
366
421
|
|
|
367
422
|
// allBranches:标注 forkPoint
|
|
368
|
-
if (allBranches)
|
|
369
|
-
for (const b of briefs) {
|
|
370
|
-
const fp = findBranchForkPoint(turns[b.index], tree)
|
|
371
|
-
if (fp !== undefined) b.branch = fp
|
|
372
|
-
}
|
|
373
|
-
}
|
|
423
|
+
if (allBranches) applyBranchLabels(briefs, turns, tree)
|
|
374
424
|
|
|
375
425
|
// 2. perTurnBudget(token → chars×4)
|
|
376
426
|
const perTurnCharBudget = (budget / turns.length) * CHARS_PER_TOKEN
|
|
377
427
|
|
|
378
|
-
// 3.
|
|
379
|
-
const lineCache
|
|
380
|
-
for (const b of briefs) {
|
|
381
|
-
const branchSize = b.branch !== undefined ? tree.branches.get(b.branch) : undefined
|
|
382
|
-
let line = formatLine(b, LINE_LEVEL_FULL, branchSize)
|
|
383
|
-
if (line.length > perTurnCharBudget) {
|
|
384
|
-
// 超预算:先砍 assistantBrief(level 1),仍超再砍 toolSummary(level 2,骨架)
|
|
385
|
-
b.assistantBrief = ''
|
|
386
|
-
line = formatLine(b, LINE_LEVEL_NO_ASSISTANT, branchSize)
|
|
387
|
-
if (line.length > perTurnCharBudget) {
|
|
388
|
-
b.toolSummary = ''
|
|
389
|
-
line = formatLine(b, LINE_LEVEL_SKELETON, branchSize)
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
lineCache.push(line)
|
|
393
|
-
}
|
|
428
|
+
// 3. 降级序渲染:level 0 全有 → level 1 砍 assistantBrief → level 2 骨架
|
|
429
|
+
const lineCache = renderDegradingLines(briefs, tree, perTurnCharBudget)
|
|
394
430
|
|
|
395
431
|
// 4. 总预算截断(从尾部丢弃)
|
|
396
|
-
|
|
397
|
-
let truncated: number | undefined
|
|
398
|
-
if (totalChars / CHARS_PER_TOKEN > budget) {
|
|
399
|
-
let kept = lineCache.length
|
|
400
|
-
while (kept > 0 && totalChars / CHARS_PER_TOKEN > budget) {
|
|
401
|
-
kept--
|
|
402
|
-
totalChars -= lineCache[kept].length
|
|
403
|
-
}
|
|
404
|
-
truncated = lineCache.length - kept
|
|
405
|
-
lineCache.length = kept
|
|
406
|
-
briefs.length = kept
|
|
407
|
-
}
|
|
432
|
+
const { totalChars, truncated } = truncateToTotalBudget(briefs, lineCache, budget)
|
|
408
433
|
|
|
409
434
|
const tokenEstimate = Math.ceil(totalChars / CHARS_PER_TOKEN)
|
|
410
435
|
return { turns: briefs, stats, tokenEstimate, truncated }
|