@zhushanwen/pi-session-reader 0.6.1 → 0.8.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.
Files changed (43) hide show
  1. package/README.md +100 -0
  2. package/package.json +6 -3
  3. package/src/__tests__/entry-anchor.test.ts +370 -0
  4. package/src/__tests__/execution-tree.test.ts +12 -12
  5. package/src/__tests__/family-zcode.test.ts +255 -0
  6. package/src/__tests__/family.test.ts +1 -1
  7. package/src/__tests__/find.test.ts +32 -49
  8. package/src/__tests__/fixtures.ts +15 -0
  9. package/src/__tests__/parser.test.ts +37 -13
  10. package/src/__tests__/render.test.ts +22 -19
  11. package/src/__tests__/result.test.ts +2 -2
  12. package/src/__tests__/roots.test.ts +32 -34
  13. package/src/__tests__/subagents.test.ts +131 -108
  14. package/src/__tests__/tool-handler.test.ts +506 -153
  15. package/src/__tests__/toolcall.test.ts +1 -1
  16. package/src/__tests__/tree.test.ts +1 -1
  17. package/src/__tests__/turns.test.ts +21 -20
  18. package/src/__tests__/workflow.test.ts +319 -23
  19. package/src/__tests__/zcode-manifest.test.ts +361 -0
  20. package/src/__tests__/zcode-routing.test.ts +751 -0
  21. package/src/__tests__/zcode-whitelist.test.ts +119 -0
  22. package/src/core/execution-tree.ts +3 -1
  23. package/src/core/family.ts +80 -1
  24. package/src/core/render.ts +1 -1
  25. package/src/core/toolcall.ts +2 -2
  26. package/src/core/tree.ts +1 -1
  27. package/src/core/turns.ts +1 -1
  28. package/src/core/workflow.ts +196 -6
  29. package/src/discovery/entry-anchor.ts +139 -0
  30. package/src/discovery/roots.ts +1 -2
  31. package/src/discovery/session-header.ts +30 -69
  32. package/src/discovery/subagents.ts +9 -2
  33. package/src/discovery/whitelist.ts +87 -0
  34. package/src/discovery/workflows.ts +232 -22
  35. package/src/discovery/zcode-manifest.ts +277 -0
  36. package/src/index.ts +7 -0
  37. package/src/result-action.ts +14 -5
  38. package/src/search-across.ts +1 -1
  39. package/src/tool-format.ts +455 -0
  40. package/src/tool-handler.ts +400 -344
  41. package/src/zcode-anchor-classify.ts +74 -0
  42. package/src/__tests__/real-data.ts +0 -96
  43. package/src/core/parser.ts +0 -167
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect } from 'vitest'
2
2
  import { extractToolCalls, formatToolCallSummary } from '../core/toolcall.js'
3
- import type { Entry } from '../core/parser.js'
3
+ import type { Entry } from '@zhushanwen/session-core'
4
4
 
5
5
  /**
6
6
  * D1 映射表(design §3.3)确定性测试:formatToolCallSummary 12 个 switch 分支
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect } from 'vitest'
2
2
  import { buildTreeView } from '../core/tree.js'
3
- import type { Entry } from '../core/parser.js'
3
+ import type { Entry } from '@zhushanwen/session-core'
4
4
 
5
5
  /** 构造最小 Entry(tree.ts 只消费 type/id/parentId,其余字段省略) */
6
6
  function e(id: string, parentId: string | null): Entry {
@@ -1,9 +1,7 @@
1
1
  import { describe, it, expect } from 'vitest'
2
2
  import { segmentTurns } from '../core/turns.js'
3
- import type { Entry } from '../core/parser.js'
4
- import { parseSessionFile } from '../core/parser.js'
3
+ import type { Entry } from '@zhushanwen/session-core'
5
4
  import { buildTreeView } from '../core/tree.js'
6
- import { REAL_SESSION, HAS_REAL_SESSION } from './real-data.js'
7
5
 
8
6
  // ---- Entry 构造助手(turns.ts 只消费 type/id/parentId/message.role) ----
9
7
 
@@ -121,23 +119,26 @@ describe('segmentTurns', () => {
121
119
  expect(turns[0].startTime).toBe('2026-05-28T03:17:12.844Z')
122
120
  })
123
121
 
124
- it.skipIf(!HAS_REAL_SESSION)('真实 019e6c96:leaf 视图分段(26 user + 5 compaction + 1 前置 = 32 turn)', async () => {
125
- // 注:design P-outline / plan T1.3 基线为 26(仅数 user 的旧定义)。
126
- // 本实现按冻结接口 Turn.isCompaction + design §3.5 算法 3 规则 2(compaction 独立成 turn)
127
- // + 规则 4(首 user 前的 model_change/thinking_level_change 成 preface turn),
128
- // 真实数据为 32 turn。26 基线早于 compaction-split 细化,详见交接说明。
129
- const parsed = await parseSessionFile(REAL_SESSION)
130
- const tree = buildTreeView(parsed.entries)
131
- const turns = segmentTurns(parsed.entries, new Set(tree.leafPath))
132
-
133
- expect(turns.length).toBe(32)
134
- // 首个 turn 是 preface(model_change/thinking_level_change),无 userEntry
122
+ it('leaf 视图分段结构形态:preface(首 user 前的 model_change)+ user turns + compaction turn', () => {
123
+ // 合成分段结构形态(1 preface + 1 compaction + 2 user turn):
124
+ // 规则 2(compaction 独立成 turn)+ 规则 4(首 user 前的 model_change/thinking_level_change
125
+ // 成 preface turn,无 userEntry)
126
+ const entries = [
127
+ entry('MC', null, 'model_change'),
128
+ msg('U1', 'MC', 'user'),
129
+ msg('A1', 'U1', 'assistant'),
130
+ entry('C1', 'A1', 'compaction'),
131
+ msg('U2', 'C1', 'user'),
132
+ msg('A2', 'U2', 'assistant'),
133
+ ]
134
+ const tree = buildTreeView(entries)
135
+ const turns = segmentTurns(entries, new Set(tree.leafPath))
136
+
137
+ expect(turns.length).toBe(4)
138
+ // 首 turn 是 preface(model_change),无 userEntry、非 compaction
135
139
  expect(turns[0].userEntry).toBeUndefined()
136
140
  expect(turns[0].isCompaction).toBe(false)
137
- // 其中有 5 个 compaction turn
138
- expect(turns.filter((t) => t.isCompaction).length).toBe(5)
139
- // 其中有 26 个 user turn
140
- expect(turns.filter((t) => t.userEntry !== undefined).length).toBe(26)
141
- // 5.6MB 全量解析在并发/高负载下可能超 vitest 默认 5s,显式放宽
142
- }, 60000)
141
+ expect(turns.filter((t) => t.isCompaction).length).toBe(1)
142
+ expect(turns.filter((t) => t.userEntry !== undefined).length).toBe(2)
143
+ })
143
144
  })
@@ -1,19 +1,29 @@
1
1
  import { describe, it, expect, afterEach } from 'vitest'
2
2
  import { tmpdir } from 'node:os'
3
- import { existsSync } from 'node:fs'
4
- import { mkdtemp, writeFile, rm } from 'node:fs/promises'
3
+ import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
5
4
  import { join } from 'node:path'
6
5
 
6
+ import {
7
+ WORKFLOW_RECORD_CUSTOM_TYPE,
8
+ WORKFLOW_RECORD_ENTRY_VERSION,
9
+ WORKFLOW_STATE_LINK_CUSTOM_TYPE,
10
+ } from '@zhushanwen/subagent-core'
11
+
7
12
  import { parseRunSnapshot, renderWorkflowOverview } from '../core/workflow.js'
8
- import { extractCallSessionFiles, readRunSnapshot } from '../discovery/workflows.js'
9
- import { REAL_AGENT_DIR } from './real-data.js'
13
+ import {
14
+ extractCallSessionFiles,
15
+ extractRecordStreamSessionFiles,
16
+ readRunSnapshot,
17
+ resolveWorkflows,
18
+ RUN_RECORD_STREAM_SUFFIX,
19
+ } from '../discovery/workflows.js'
10
20
 
11
21
  // ============================================================
12
22
  // fixture(结构对齐真实 wf-state 探针数据)
13
23
  // ============================================================
14
24
 
15
25
  /**
16
- * NEW 格式 fixture(对齐 ~/.pi/agent/workflow-state/wf-1785762350110-d297tr.jsonl)。
26
+ * NEW 格式 fixture(快照字段形态 = pi-subagent-workflow 写侧落盘契约)。
17
27
  * runId 故意写成 'wf-ignore' 验证 parseRunSnapshot 用参数透传不读 snapshot.runId。
18
28
  */
19
29
  const NEW_SNAPSHOT_FIXTURE = {
@@ -367,39 +377,325 @@ describe('readRunSnapshot', () => {
367
377
  })
368
378
 
369
379
  // ============================================================
370
- // 真实数据守卫(~/.pi/agent/workflow-state,CI 无本机数据时 skipIf 跳过)
380
+ // resolveWorkflows 三档发现链(W1 D10 断链修复:v2 journalPath 主源 / v1 快照层 /
381
+ // 旧指针 fallback——W17 前 / W17~W1 / W1+ 三类 run 全部有发现通道)
382
+ // ============================================================
383
+
384
+ /** v1 快照对象(RunSnapshot 形态:v/runId/state.calls——v1 workflow-record 条目的 snapshot 载荷)。 */
385
+ function v1Snapshot(runId: string, sessionFiles: string[]): Record<string, unknown> {
386
+ return {
387
+ v: 'wf-run-v2',
388
+ runId,
389
+ spec: { scriptName: 'mid-window', name: 'Mid' },
390
+ state: {
391
+ status: 'done',
392
+ reason: 'completed',
393
+ budget: { usedTokens: 10, usedCost: 0, totalCallCount: sessionFiles.length, maxTokens: 1000 },
394
+ calls: sessionFiles.map((sf, i) => ({
395
+ id: i,
396
+ opts: { prompt: 'work', model: 'm', description: `step-${i}` },
397
+ status: 'done',
398
+ attempts: 1,
399
+ result: { content: 'ok', durationMs: 1, sessionId: `019v1${i}`, sessionFile: sf },
400
+ })),
401
+ },
402
+ meta: { startedAt: '2026-09-20T00:00:00.000Z', completedAt: '2026-09-20T00:01:00.000Z' },
403
+ }
404
+ }
405
+
406
+ describe('resolveWorkflows 三档发现链(W1 D10)', () => {
407
+ let dir: string
408
+ afterEach(async () => {
409
+ if (dir) await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 }).catch(() => {})
410
+ })
411
+
412
+ /** 写 main session 文件(首行 header + 追加行集);返回路径与 sessionIdToPath 单条目映射。 */
413
+ async function writeMain(lines: string[]): Promise<{
414
+ path: string
415
+ sessionIdToPath: Map<string, string>
416
+ }> {
417
+ const sessionId = 'main-session-1'
418
+ const path = join(dir, `${sessionId}.jsonl`)
419
+ await writeFile(path, [JSON.stringify({ type: 'session', id: sessionId, cwd: '/proj' }), ...lines].join('\n') + '\n')
420
+ return { path, sessionIdToPath: new Map([[sessionId, path]]) }
421
+ }
422
+
423
+ /** 写 wf-state 快照文件(尾行最新快照)。返回绝对路径。 */
424
+ async function writeStateFile(runId: string, snapshot: Record<string, unknown>): Promise<string> {
425
+ const wfDir = join(dir, 'workflow-state')
426
+ await mkdir(wfDir, { recursive: true })
427
+ const path = join(wfDir, `${runId}.jsonl`)
428
+ await writeFile(path, JSON.stringify(snapshot) + '\n')
429
+ return path
430
+ }
431
+
432
+ /** 写 record 流文件([D16③] v2 档数据源——目录自建)。 */
433
+ async function writeRecordStream(runId: string, lines: string[]): Promise<string> {
434
+ const wfDir = join(dir, 'workflow-state')
435
+ await mkdir(wfDir, { recursive: true })
436
+ const p = join(wfDir, `${runId}.record.jsonl`)
437
+ await writeFile(p, lines.join('\n') + '\n')
438
+ return p
439
+ }
440
+
441
+ /** workflow-record v2 注册条目行(journalPath 锚点)。 */
442
+ function v2RegisteredLine(runId: string, journalPath: string): string {
443
+ return JSON.stringify({
444
+ type: 'custom',
445
+ customType: 'workflow-record',
446
+ id: `e-${runId}`,
447
+ parentId: null,
448
+ data: {
449
+ v: 2,
450
+ kind: 'registered',
451
+ runId,
452
+ workflowName: 'probe-flow',
453
+ scriptName: 'probe-flow',
454
+ slug: 'probe',
455
+ startedAt: 1,
456
+ journalPath,
457
+ },
458
+ })
459
+ }
460
+
461
+ /** workflow-record v1 快照条目行(W17~W1 中间档形态:{v:1, snapshot, updatedAt})。 */
462
+ function v1SnapshotLine(runId: string, sessionFiles: string[]): string {
463
+ return JSON.stringify({
464
+ type: 'custom',
465
+ customType: 'workflow-record',
466
+ id: `e-${runId}`,
467
+ parentId: null,
468
+ data: { v: 1, snapshot: v1Snapshot(runId, sessionFiles), updatedAt: 2 },
469
+ })
470
+ }
471
+
472
+ /** workflow-record v2 终态条目行(不携带 journalPath——非发现链数据源)。 */
473
+ function v2SettledLine(runId: string): string {
474
+ return JSON.stringify({
475
+ type: 'custom',
476
+ customType: 'workflow-record',
477
+ id: `e-settled-${runId}`,
478
+ parentId: null,
479
+ data: {
480
+ v: 2,
481
+ kind: 'settled',
482
+ runId,
483
+ status: 'done',
484
+ reason: 'completed',
485
+ outcome: 'success',
486
+ settledAt: 3,
487
+ callCount: 1,
488
+ usedTokens: 10,
489
+ },
490
+ })
491
+ }
492
+
493
+ /** 旧指针条目行(W17 前形态:workflow-state-link)。 */
494
+ function linkLine(runId: string, path: string): string {
495
+ return JSON.stringify({
496
+ type: 'custom',
497
+ customType: 'workflow-state-link',
498
+ id: `e-link-${runId}`,
499
+ parentId: null,
500
+ data: { runId, path, updatedAt: '2026-08-07T16:48:24.933Z' },
501
+ })
502
+ }
503
+
504
+ const CALL_A = '/abs/subagents/--proj--/sessions/call-a.jsonl'
505
+ const CALL_B = '/abs/subagents/--proj--/sessions/call-b.jsonl'
506
+
507
+ it('v2 journalPath 主源([D16③] 重锚):注册条目 → record 流直读提 calls,stateFile = 流路径', async () => {
508
+ dir = await mkdtemp(join(tmpdir(), 'wf-v2-tier-'))
509
+ // record 流内容:run-created + agent-started + agent-settled(result 携带 sessionFile)
510
+ const recordPath = await writeRecordStream('wf-v2-1', [
511
+ JSON.stringify({ type: 'run-created', seq: 1, ts: 1000, runId: 'wf-v2-1', workflowName: 'v2-flow', argsSummary: '{}' }),
512
+ JSON.stringify({ type: 'agent-started', seq: 2, ts: 1100, taskIndex: 0, agentName: 'step-0', attempt: 1 }),
513
+ JSON.stringify({ type: 'agent-settled', seq: 3, ts: 1500, taskIndex: 0, attempt: 1, outcome: 'done', durationMs: 400, result: { content: 'ok', sessionFile: CALL_A } }),
514
+ ])
515
+ const { sessionIdToPath } = await writeMain([v2RegisteredLine('wf-v2-1', recordPath)])
516
+
517
+ const workflows = await resolveWorkflows('main-session-1', sessionIdToPath, new Map())
518
+
519
+ expect(workflows).toHaveLength(1)
520
+ expect(workflows[0].runId).toBe('wf-v2-1')
521
+ // stateFile = record 流路径(journalPath 锚点语义重定义——[D16③])
522
+ expect(workflows[0].stateFile).toBe(recordPath)
523
+ expect(workflows[0].calls).toHaveLength(1)
524
+ expect(workflows[0].calls[0].fileName).toBe(CALL_A)
525
+ })
526
+
527
+ it('v2 档窗外:record 流已被保留期清理 → calls=[],runId 存在性兜底(stateFile 仍是流路径)', async () => {
528
+ dir = await mkdtemp(join(tmpdir(), 'wf-v2-gc-'))
529
+ const recordPath = join(dir, 'workflow-state', 'wf-gc-1.record.jsonl')
530
+ const { sessionIdToPath } = await writeMain([v2RegisteredLine('wf-gc-1', recordPath)])
531
+
532
+ const workflows = await resolveWorkflows('main-session-1', sessionIdToPath, new Map())
533
+
534
+ expect(workflows).toHaveLength(1)
535
+ expect(workflows[0].runId).toBe('wf-gc-1')
536
+ expect(workflows[0].calls).toEqual([])
537
+ // 窗外语义 = 锚点路径在、流文件不在(readFile undefined → calls 空)
538
+ expect(workflows[0].stateFile).toBe(recordPath)
539
+ })
540
+
541
+ it('v2 档旧后缀锚点(.events.jsonl 形态 = [D1] 历史实体)与形态损坏 → stateFile 原样保留 + calls 空(不读旧两件、不伪造数据)', async () => {
542
+ dir = await mkdtemp(join(tmpdir(), 'wf-v2-bad-'))
543
+ // 旧后缀锚点:[D1] 历史数据处置——旧两件不读(calls 退空,run 存在性兜底)
544
+ const legacyPath = join(dir, 'workflow-state', 'wf-legacy-1.events.jsonl')
545
+ await mkdir(join(dir, 'workflow-state'), { recursive: true })
546
+ await writeFile(legacyPath, JSON.stringify({ type: 'ask-settled', ts: 1 }) + '\n')
547
+ const legacyMain = await writeMain([v2RegisteredLine('wf-legacy-1', legacyPath)])
548
+ const legacy = await resolveWorkflows('main-session-1', legacyMain.sessionIdToPath, new Map())
549
+ expect(legacy).toHaveLength(1)
550
+ expect(legacy[0].runId).toBe('wf-legacy-1')
551
+ expect(legacy[0].stateFile).toBe(legacyPath)
552
+ expect(legacy[0].calls).toEqual([])
553
+
554
+ // 形态损坏(无已知尾段):同旧锚点分流
555
+ const oddMain = await writeMain([v2RegisteredLine('wf-bad-1', '/abs/odd/path.txt')])
556
+ const odd = await resolveWorkflows('main-session-1', oddMain.sessionIdToPath, new Map())
557
+ expect(odd).toHaveLength(1)
558
+ expect(odd[0].runId).toBe('wf-bad-1')
559
+ expect(odd[0].stateFile).toBe('/abs/odd/path.txt')
560
+ expect(odd[0].calls).toEqual([])
561
+ })
562
+
563
+ it('v1 快照层(W17~W1 中间档恢复):v1 快照条目直接提 snapshot.calls,无指针条目也可见', async () => {
564
+ // 该档现状发现链恒空(只认指针)——本用例锚定中间档恢复:升级窗口期的旧格式会话
565
+ dir = await mkdtemp(join(tmpdir(), 'wf-v1-tier-'))
566
+ const { sessionIdToPath } = await writeMain([v1SnapshotLine('wf-mid-1', [CALL_A, CALL_B])])
567
+
568
+ const workflows = await resolveWorkflows('main-session-1', sessionIdToPath, new Map())
569
+
570
+ expect(workflows).toHaveLength(1)
571
+ expect(workflows[0].runId).toBe('wf-mid-1')
572
+ // v1 条目不携带 state 路径 → stateFile 空串(概览消费面自然降级,家族链 calls 完整)
573
+ expect(workflows[0].stateFile).toBe('')
574
+ expect(workflows[0].calls.map((c) => c.fileName)).toEqual([CALL_A, CALL_B])
575
+ })
576
+
577
+ it('旧指针 fallback:workflow-state-link 指针条目照旧读 link 指向的快照文件', async () => {
578
+ dir = await mkdtemp(join(tmpdir(), 'wf-link-tier-'))
579
+ const stateFile = await writeStateFile('wf-old-1', v1Snapshot('wf-old-1', [CALL_B]))
580
+ const { sessionIdToPath } = await writeMain([linkLine('wf-old-1', stateFile)])
581
+
582
+ const workflows = await resolveWorkflows('main-session-1', sessionIdToPath, new Map())
583
+
584
+ expect(workflows).toHaveLength(1)
585
+ expect(workflows[0].runId).toBe('wf-old-1')
586
+ expect(workflows[0].stateFile).toBe(stateFile)
587
+ expect(workflows[0].calls.map((c) => c.fileName)).toEqual([CALL_B])
588
+ })
589
+
590
+ it('三档混合会话:三个 run 各处一档 → 各自正确,按条目首见序输出([D16③] v2 档 = record 流直读)', async () => {
591
+ dir = await mkdtemp(join(tmpdir(), 'wf-mixed-'))
592
+ const recordPath = await writeRecordStream('wf-v2-1', [
593
+ JSON.stringify({ type: 'run-created', seq: 1, ts: 1000, runId: 'wf-v2-1', workflowName: 'v2-flow', argsSummary: '{}' }),
594
+ JSON.stringify({ type: 'agent-started', seq: 2, ts: 1100, taskIndex: 0, agentName: 's0', attempt: 1 }),
595
+ JSON.stringify({ type: 'agent-settled', seq: 3, ts: 1500, taskIndex: 0, attempt: 1, outcome: 'done', durationMs: 400, result: { content: 'ok', sessionFile: CALL_A } }),
596
+ ])
597
+ const oldState = await writeStateFile('wf-old-1', v1Snapshot('wf-old-1', [CALL_B]))
598
+ // 首见序:指针(早)→ v1 快照(中)→ v2 注册(晚)
599
+ const { sessionIdToPath } = await writeMain([
600
+ linkLine('wf-old-1', oldState),
601
+ v1SnapshotLine('wf-mid-1', [CALL_A]),
602
+ v2RegisteredLine('wf-v2-1', recordPath),
603
+ ])
604
+
605
+ const workflows = await resolveWorkflows('main-session-1', sessionIdToPath, new Map())
606
+
607
+ expect(workflows.map((w) => w.runId)).toEqual(['wf-old-1', 'wf-mid-1', 'wf-v2-1'])
608
+ expect(workflows[0].stateFile).toBe(oldState)
609
+ expect(workflows[1].stateFile).toBe('')
610
+ expect(workflows[2].stateFile).toBe(recordPath)
611
+ expect(workflows[2].calls[0].fileName).toBe(CALL_A)
612
+ })
613
+
614
+ it('v2 终态条目(settled,无 journalPath)不参与发现链——不产 runId 幻影', async () => {
615
+ dir = await mkdtemp(join(tmpdir(), 'wf-settled-'))
616
+ const { sessionIdToPath } = await writeMain([v2SettledLine('wf-settled-1')])
617
+
618
+ const workflows = await resolveWorkflows('main-session-1', sessionIdToPath, new Map())
619
+
620
+ expect(workflows).toEqual([])
621
+ })
622
+
623
+ it('同 runId 跨档坏数据防御:v2 注册条目在 → 用 v2 档(高档在即用高档;[D16③] v2 档 = record 流)', async () => {
624
+ dir = await mkdtemp(join(tmpdir(), 'wf-tier-prio-'))
625
+ const recordPath = await writeRecordStream('wf-both-1', [
626
+ JSON.stringify({ type: 'run-created', seq: 1, ts: 1000, runId: 'wf-both-1', workflowName: 'both', argsSummary: '{}' }),
627
+ JSON.stringify({ type: 'agent-started', seq: 2, ts: 1100, taskIndex: 0, agentName: 's0', attempt: 1 }),
628
+ JSON.stringify({ type: 'agent-settled', seq: 3, ts: 1500, taskIndex: 0, attempt: 1, outcome: 'done', durationMs: 400, result: { content: 'ok', sessionFile: CALL_A } }),
629
+ ])
630
+ const oldState = await writeStateFile('wf-both-1-old', v1Snapshot('wf-both-1', [CALL_B]))
631
+ const { sessionIdToPath } = await writeMain([
632
+ linkLine('wf-both-1', oldState),
633
+ v2RegisteredLine('wf-both-1', recordPath),
634
+ ])
635
+
636
+ const workflows = await resolveWorkflows('main-session-1', sessionIdToPath, new Map())
637
+
638
+ expect(workflows).toHaveLength(1)
639
+ // v2 档胜出:stateFile 是 record 流路径(非 link 的 oldState),calls 来自流
640
+ expect(workflows[0].stateFile).toBe(recordPath)
641
+ expect(workflows[0].calls.map((c) => c.fileName)).toEqual([CALL_A])
642
+ })
643
+
644
+ it('跨包契约守卫:三档判别的本地字面量 === subagent-core 写侧单源([D1] record 后缀)', () => {
645
+ // 源码本地持有磁盘协议字符串与版本/尾段(生产依赖面不引 subagent-core),锚定两侧同值
646
+ expect(WORKFLOW_RECORD_CUSTOM_TYPE).toBe('workflow-record')
647
+ expect(WORKFLOW_RECORD_ENTRY_VERSION).toBe(2)
648
+ expect(RUN_RECORD_STREAM_SUFFIX).toBe('.record.jsonl')
649
+ // legacy 指针档:collectWorkflowEntryTiers 本地字面量与 core 常量同值
650
+ expect(WORKFLOW_STATE_LINK_CUSTOM_TYPE).toBe('workflow-state-link')
651
+ })
652
+ })
653
+
654
+ // ============================================================
655
+ // readRunSnapshot + parseRunSnapshot 磁盘回路(fixture):快照形态已由
656
+ // NEW_SNAPSHOT_FIXTURE / OLD_SNAPSHOT_FIXTURE 单源锚定,此处补「wf-state 落盘 →
657
+ // 读回 → 解析」的端到端回路断言
371
658
  // ============================================================
372
659
 
373
- const REAL_WF_NEW = join(REAL_AGENT_DIR, 'workflow-state', 'wf-1785762350110-d297tr.jsonl')
374
- const REAL_WF_OLD = join(REAL_AGENT_DIR, 'workflow-state', 'wf-skip-ok.jsonl')
375
- const HAS_REAL_WF_NEW = existsSync(REAL_WF_NEW)
376
- const HAS_REAL_WF_OLD = existsSync(REAL_WF_OLD)
660
+ describe('readRunSnapshot + parseRunSnapshot 磁盘回路(fixture)', () => {
661
+ let tmpDir: string | undefined
377
662
 
378
- describe.skipIf(!HAS_REAL_WF_NEW)('真实数据守卫 - NEW wf-state(wf-1785762350110-d297tr)', () => {
379
- it('TC-w5-real-new-guard:readRunSnapshot+parseRunSnapshot 类型化 NEW 真实快照', async () => {
380
- const snap = await readRunSnapshot(REAL_WF_NEW)
663
+ afterEach(async () => {
664
+ if (tmpDir !== undefined) {
665
+ await rm(tmpDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
666
+ }
667
+ })
668
+
669
+ it('TC-w5-disk-new-guard:NEW 快照落盘读回,类型化为 wf-run-v1 overview', async () => {
670
+ tmpDir = await mkdtemp(join(tmpdir(), 'wf-disk-new-'))
671
+ const wfPath = join(tmpDir, 'wf-new.jsonl')
672
+ await writeFile(wfPath, JSON.stringify(NEW_SNAPSHOT_FIXTURE) + '\n')
673
+
674
+ const snap = await readRunSnapshot(wfPath)
381
675
  expect(snap).not.toBeUndefined()
382
- const overview = parseRunSnapshot(snap, 'wf-1785762350110-d297tr', REAL_WF_NEW)
676
+ const overview = parseRunSnapshot(snap, 'wf-disk-new', wfPath)
383
677
  expect(overview).not.toBeNull()
384
678
  expect(overview!.version).toBe('wf-run-v1')
385
679
  expect(overview!.steps.length).toBeGreaterThanOrEqual(1)
386
- // call 的 sessionFile 是真实绝对 .jsonl 路径(跳转入口)
680
+ // call 的 sessionFile 是绝对 .jsonl 路径(跳转入口)
387
681
  expect(overview!.steps[0].sessionFile).toMatch(/\.jsonl$/)
388
682
  expect(overview!.steps[0].sessionFile!.startsWith('/')).toBe(true)
389
683
  expect(overview!.steps[0].sessionId).toBeTruthy()
390
- }, 30000)
391
- })
684
+ })
392
685
 
393
- describe.skipIf(!HAS_REAL_WF_OLD)('真实数据守卫 - OLD wf-state(wf-skip-ok)', () => {
394
- it('TC-w5-real-old-guard:readRunSnapshot+parseRunSnapshot 尽力解析 OLD 真实快照', async () => {
395
- const snap = await readRunSnapshot(REAL_WF_OLD)
686
+ it('TC-w5-disk-old-guard:OLD 快照落盘读回,尽力解析为 legacy overview', async () => {
687
+ tmpDir = await mkdtemp(join(tmpdir(), 'wf-disk-old-'))
688
+ const wfPath = join(tmpDir, 'wf-old.jsonl')
689
+ await writeFile(wfPath, JSON.stringify(OLD_SNAPSHOT_FIXTURE) + '\n')
690
+
691
+ const snap = await readRunSnapshot(wfPath)
396
692
  expect(snap).not.toBeUndefined()
397
- const overview = parseRunSnapshot(snap, 'wf-skip-ok', REAL_WF_OLD)
693
+ const overview = parseRunSnapshot(snap, 'wf-skip-ok', wfPath)
398
694
  expect(overview).not.toBeNull()
399
695
  expect(overview!.version).toBe('legacy')
400
696
  expect(overview!.status).toBe('running')
401
697
  expect(overview!.script).toBe('workflow-wf-skip-ok') // name 映射
402
- // OLD callCache value 无 sessionFile(探针 112 文件 0 sessionFile)
698
+ // OLD callCache value 无 sessionFile → step 无跳转入口
403
699
  expect(overview!.steps[0].sessionFile).toBeUndefined()
404
- }, 30000)
700
+ })
405
701
  })