@zhushanwen/pi-session-reader 0.1.0 → 0.2.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.
@@ -6,6 +6,7 @@ import type { Entry } from '../core/parser.js'
6
6
  const ROOT = '019fe620' // 家族根(无 parentSession)
7
7
  const FORK = '019fe632' // fork 子代(parentSession 指向 ROOT 的文件)
8
8
  const SUB = '019fe635' // subagent(rootSessionId=FORK,挂在 fork 子代下,非家族根)
9
+ const SUB2 = '019fe636' // U4 第二个 subagent(多 subagent 场景)
9
10
 
10
11
  // ---- fixture helpers ----
11
12
 
@@ -32,6 +33,31 @@ function subagentIdentity(id: string, rootSessionId: string, slug: string): Entr
32
33
  }
33
34
  }
34
35
 
36
+ /** U4 富字段(可选,模拟 manifest 主/P-fallback identity 回退组装的 data) */
37
+ interface SubagentRichFields {
38
+ task?: string
39
+ agent?: string
40
+ model?: string
41
+ status?: string
42
+ sessionFile?: string
43
+ }
44
+
45
+ /** 构造带 U4 富字段的 subagent-identity custom entry(manifest 主/P-fallback 两种 data 形态) */
46
+ function subagentIdentityRich(
47
+ id: string,
48
+ rootSessionId: string,
49
+ slug: string,
50
+ rich?: SubagentRichFields,
51
+ ): Entry {
52
+ const data: Record<string, unknown> = { rootSessionId, slug }
53
+ if (rich?.task !== undefined) data.task = rich.task
54
+ if (rich?.agent !== undefined) data.agent = rich.agent
55
+ if (rich?.model !== undefined) data.model = rich.model
56
+ if (rich?.status !== undefined) data.status = rich.status
57
+ if (rich?.sessionFile !== undefined) data.sessionFile = rich.sessionFile
58
+ return { type: 'custom', id, parentId: null, customType: 'subagent-identity', data }
59
+ }
60
+
35
61
  /** 构造 fileStats Map(M1 key=sessionId) */
36
62
  function makeStats(
37
63
  keys: string[],
@@ -205,3 +231,127 @@ describe('resolveFamily - 错误', () => {
205
231
  expect(() => resolveFamily('unknown-session', index)).toThrow(/not found in family index/)
206
232
  })
207
233
  })
234
+
235
+ // ============================================================
236
+ // U4: SubagentRef 富字段(manifest 主 / P-fallback identity 回退)
237
+ // 验证 buildFamilyIndex 从 identity entry.data 读 task/agent/model/status/sessionFile
238
+ // 填 SubagentRef,异名映射(data.agent → agentName),守卫放宽(只校验 rootSessionId+slug)。
239
+ // ============================================================
240
+
241
+ describe('U4: SubagentRef 富字段(buildFamilyIndex 透传)', () => {
242
+ it('TC-u4-manifest-enrich: manifest 主路径,富字段全透传到 SubagentRef', () => {
243
+ const identities = [
244
+ subagentIdentityRich(SUB, ROOT, 'codex-research', {
245
+ task: '调研 codex',
246
+ agent: 'explorer',
247
+ model: 'glm-5.2',
248
+ status: 'completed',
249
+ sessionFile: '/path/to/sub.jsonl',
250
+ }),
251
+ ]
252
+ const index = buildFamilyIndex([header(ROOT)], identities, makeStats([ROOT, SUB]))
253
+
254
+ const sub = resolveFamily(ROOT, index).subagents.find((s) => s.sessionId === SUB)
255
+ expect(sub?.task).toBe('调研 codex')
256
+ expect(sub?.slug).toBe('codex-research')
257
+ // 异名映射核心断言:identity.data.agent → SubagentRef.agentName
258
+ expect(sub?.agentName).toBe('explorer')
259
+ expect(sub?.model).toBe('glm-5.2')
260
+ expect(sub?.status).toBe('completed')
261
+ expect(sub?.sessionFile).toBe('/path/to/sub.jsonl')
262
+ expect(sub?.cleanedUp).toBe(false)
263
+ })
264
+
265
+ it('TC-u4-pfallback-identity: P-fallback(有 identity 无 model/status),task/agent/sessionFile 透传', () => {
266
+ const identities = [
267
+ subagentIdentityRich(SUB, ROOT, 'fix', {
268
+ task: 'fix bug',
269
+ agent: 'worker',
270
+ sessionFile: '/alive/sub.jsonl',
271
+ // 无 model/status(P-fallback identity 不含这两项,探针 15/15 确认)
272
+ }),
273
+ ]
274
+ const index = buildFamilyIndex([header(ROOT)], identities, makeStats([ROOT, SUB]))
275
+
276
+ const sub = resolveFamily(ROOT, index).subagents.find((s) => s.sessionId === SUB)
277
+ expect(sub?.task).toBe('fix bug')
278
+ expect(sub?.slug).toBe('fix')
279
+ expect(sub?.agentName).toBe('worker')
280
+ expect(sub?.sessionFile).toBe('/alive/sub.jsonl')
281
+ // P-fallback 核心断言:model/status 必 undefined(identity 不可回退)
282
+ expect(sub?.model).toBeUndefined()
283
+ expect(sub?.status).toBeUndefined()
284
+ })
285
+
286
+ it('TC-u4-pfallback-no-identity: data 缺 rootSessionId → 守卫拒掉,不入 subagentsByRoot,不抛错', () => {
287
+ // 模拟无 identity(运行中/异常,尾行是 message 非 identity):data 只有 slug 无 rootSessionId
288
+ const noRoot: Entry = {
289
+ type: 'custom',
290
+ id: 'no-root',
291
+ parentId: null,
292
+ customType: 'subagent-identity',
293
+ data: { slug: 'dangling', task: 't' }, // 缺 rootSessionId
294
+ }
295
+ const index = buildFamilyIndex([header(ROOT)], [noRoot], makeStats([ROOT]))
296
+
297
+ // 守卫拒掉 → 不入 subagentsByRoot
298
+ expect(index.subagentsByRoot.size).toBe(0)
299
+ // resolveFamily 不抛错,subagents 空
300
+ const family = resolveFamily(ROOT, index)
301
+ expect(family.subagents).toHaveLength(0)
302
+ })
303
+
304
+ it('TC-u4-orphan-manifest: fileStats 不含 id → cleanedUp=true,富字段仍透传(GC 路径保留)', () => {
305
+ const identities = [
306
+ subagentIdentityRich('sa-ghost', ROOT, 'ghost-slug', {
307
+ task: 'ghost task',
308
+ agent: 'worker',
309
+ model: 'gpt-4',
310
+ status: 'completed',
311
+ sessionFile: '/gc/ghost.jsonl',
312
+ }),
313
+ ]
314
+ // fileStats 不含 'sa-ghost'(模拟 .jsonl 被 GC,manifest 残留)→ cleanedUp=true
315
+ const index = buildFamilyIndex([header(ROOT)], identities, makeStats([ROOT]))
316
+
317
+ const sub = resolveFamily(ROOT, index).subagents.find((s) => s.sessionId === 'sa-ghost')
318
+ expect(sub?.cleanedUp).toBe(true)
319
+ expect(sub?.task).toBe('ghost task')
320
+ expect(sub?.agentName).toBe('worker')
321
+ expect(sub?.model).toBe('gpt-4')
322
+ expect(sub?.status).toBe('completed')
323
+ expect(sub?.sessionFile).toBe('/gc/ghost.jsonl') // GC 路径保留(不置空)
324
+ })
325
+
326
+ it('TC-u4-recordmanifest-compat: 最小 identity(仅 rootSessionId+slug)→ 富字段全 undefined,不抛错', () => {
327
+ // 模拟旧 manifest(无 task/slug/model/status/agentName)经 buildFamilyFromFs 转成的最小 identity
328
+ const identities = [subagentIdentity(SUB, ROOT, 'legacy')]
329
+ const index = buildFamilyIndex([header(ROOT)], identities, makeStats([ROOT, SUB]))
330
+
331
+ const sub = resolveFamily(ROOT, index).subagents.find((s) => s.sessionId === SUB)
332
+ expect(sub?.slug).toBe('legacy')
333
+ expect(sub?.rootSessionId).toBe(ROOT)
334
+ expect(sub?.task).toBeUndefined()
335
+ expect(sub?.agentName).toBeUndefined()
336
+ expect(sub?.model).toBeUndefined()
337
+ expect(sub?.status).toBeUndefined()
338
+ expect(sub?.sessionFile).toBeUndefined()
339
+ })
340
+
341
+ it('isSubagentIdentityData 守卫放宽:富字段部分缺失/全缺都通过(只校验 rootSessionId+slug)', () => {
342
+ const identities = [
343
+ subagentIdentityRich(SUB, ROOT, 's1', { task: 'only-task' }), // 部分富字段
344
+ subagentIdentity(SUB2, ROOT, 's2'), // 完全无富字段(旧 manifest 形态)
345
+ ]
346
+ const index = buildFamilyIndex([header(ROOT)], identities, makeStats([ROOT, SUB, SUB2]))
347
+
348
+ const family = resolveFamily(ROOT, index)
349
+ expect(family.subagents).toHaveLength(2)
350
+ const s1 = family.subagents.find((s) => s.sessionId === SUB)
351
+ expect(s1?.task).toBe('only-task')
352
+ expect(s1?.model).toBeUndefined()
353
+ const s2 = family.subagents.find((s) => s.sessionId === SUB2)
354
+ expect(s2?.task).toBeUndefined()
355
+ expect(s2?.agentName).toBeUndefined()
356
+ })
357
+ })
@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'
3
3
  import { mkdtemp, mkdir, writeFile, rm, utimes } from 'node:fs/promises'
4
4
  import { join } from 'node:path'
5
5
  import { findSessions } from '../discovery/find.js'
6
- import { REAL_AGENT_DIR, HAS_E6 } from './real-data.js'
6
+ import { REAL_AGENT_DIR, HAS_E6, HAS_REAL_SUBAGENTS_DIR } from './real-data.js'
7
7
 
8
8
  /**
9
9
  * 建一个假 session 文件:首行 header(type=session,含 id/cwd/parentSession),
@@ -42,13 +42,107 @@ async function makeSession(
42
42
  return path
43
43
  }
44
44
 
45
+ /**
46
+ * 建 records manifest(U5 fixture)。manifest 在 subagent 创建时写入 records/<sa-id>.json,
47
+ * sessionFile 指向 alive subagent session 的绝对路径(find 用 extractSessionIdFromFilename
48
+ * 从该路径提取 sessionId 建索引)。
49
+ */
50
+ async function makeRecordManifest(
51
+ recordsDir: string,
52
+ opts: {
53
+ id: string
54
+ rootSessionId: string
55
+ sessionFile: string
56
+ agentName?: string
57
+ task?: string
58
+ slug?: string
59
+ model?: string
60
+ status?: string
61
+ },
62
+ ): Promise<void> {
63
+ const m: Record<string, unknown> = {
64
+ id: opts.id,
65
+ rootSessionId: opts.rootSessionId,
66
+ sessionFile: opts.sessionFile,
67
+ }
68
+ if (opts.agentName !== undefined) m.agentName = opts.agentName
69
+ if (opts.task !== undefined) m.task = opts.task
70
+ if (opts.slug !== undefined) m.slug = opts.slug
71
+ if (opts.model !== undefined) m.model = opts.model
72
+ if (opts.status !== undefined) m.status = opts.status
73
+ await mkdir(recordsDir, { recursive: true })
74
+ await writeFile(join(recordsDir, `${opts.id}.json`), JSON.stringify(m))
75
+ }
76
+
77
+ /**
78
+ * 建 subagent session 文件(header + 可选首消息 + 可选尾行 identity)。返回绝对路径。
79
+ *
80
+ * 文件名用 `<ts>_<sessionId>.jsonl` 格式(满足 extractSessionIdFromFilename)。传 rootSessionId
81
+ * 时追加尾行 subagent-identity custom entry(P-fallback fixture 用);不传则只有 header(manifest 主 fixture 用)。
82
+ */
83
+ async function makeSubagentSession(
84
+ dir: string,
85
+ opts: {
86
+ name: string
87
+ id: string
88
+ cwd?: string
89
+ rootSessionId?: string
90
+ slug?: string
91
+ task?: string
92
+ agent?: string
93
+ firstUserText?: string
94
+ },
95
+ ): Promise<string> {
96
+ const header: Record<string, unknown> = {
97
+ type: 'session',
98
+ id: opts.id,
99
+ timestamp: '2026-01-01T00:00:00.000Z',
100
+ }
101
+ if (opts.cwd) header.cwd = opts.cwd
102
+ const lines: string[] = [JSON.stringify(header)]
103
+ if (opts.firstUserText) {
104
+ lines.push(
105
+ JSON.stringify({
106
+ type: 'message',
107
+ id: opts.id + '-m1',
108
+ message: { role: 'user', content: [{ type: 'text', text: opts.firstUserText }] },
109
+ }),
110
+ )
111
+ }
112
+ if (opts.rootSessionId !== undefined) {
113
+ const data: Record<string, unknown> = {
114
+ id: 'sa-' + opts.id,
115
+ agent: opts.agent ?? 'worker',
116
+ mode: 'background',
117
+ task: opts.task ?? '',
118
+ slug: opts.slug ?? '',
119
+ startedAt: Date.now(),
120
+ rootSessionId: opts.rootSessionId,
121
+ depth: 1,
122
+ }
123
+ lines.push(
124
+ JSON.stringify({ type: 'custom', id: opts.id, customType: 'subagent-identity', data }),
125
+ )
126
+ }
127
+ await mkdir(dir, { recursive: true })
128
+ const path = join(dir, opts.name)
129
+ await writeFile(path, lines.join('\n') + '\n')
130
+ return path
131
+ }
132
+
45
133
  describe('findSessions', () => {
46
134
  let agentDir: string
47
135
  let slugDir: string
136
+ /** subagent fixture 目录(模拟 subagents/<cwd编码>/sessions/ 结构,roots.listSubagentSessions 扫描路径) */
137
+ let saDir: string
138
+
139
+ let recordsDir: string
48
140
 
49
141
  beforeEach(async () => {
50
142
  agentDir = await mkdtemp(join(tmpdir(), 'find-test-'))
51
143
  slugDir = join(agentDir, 'sessions', '--Users-demo--')
144
+ saDir = join(agentDir, 'subagents', '--Users-demo--', 'sessions')
145
+ recordsDir = join(agentDir, 'subagents', '--Users-demo--', 'records')
52
146
  })
53
147
  afterEach(async () => {
54
148
  await rm(agentDir, { recursive: true, force: true })
@@ -189,6 +283,236 @@ describe('findSessions', () => {
189
283
  expect(result.truncated).toBe(false)
190
284
  })
191
285
 
286
+ it('缺省合并:main + subagent 候选都返回,source 标记正确(DM1)', async () => {
287
+ await makeSession(slugDir, { name: 'm.jsonl', id: 'main-shared', cwd: '/demo' })
288
+ await makeSession(saDir, { name: 's.jsonl', id: 'sub-shared', cwd: '/demo' })
289
+
290
+ const { matches } = await findSessions('shared', agentDir)
291
+ expect(matches).toHaveLength(2)
292
+ // source 必填标记(DM1)+ 集合为 {main, subagent}
293
+ const sources = matches.map((m) => m.source).sort()
294
+ expect(sources).toEqual(['main', 'subagent'])
295
+ // fileName 指向各自目录
296
+ const mainHit = matches.find((m) => m.source === 'main')!
297
+ const subHit = matches.find((m) => m.source === 'subagent')!
298
+ expect(mainHit.sessionId).toBe('main-shared')
299
+ expect(mainHit.fileName).not.toContain('subagents')
300
+ expect(subHit.sessionId).toBe('sub-shared')
301
+ expect(subHit.fileName).toContain('subagents')
302
+ })
303
+
304
+ it("source:'main' 只含 main 候选(subagent 被过滤)", async () => {
305
+ await makeSession(slugDir, { name: 'm.jsonl', id: 'main-shared', cwd: '/demo' })
306
+ await makeSession(saDir, { name: 's.jsonl', id: 'sub-shared', cwd: '/demo' })
307
+
308
+ const { matches } = await findSessions('shared', agentDir, { source: 'main' })
309
+ expect(matches).toHaveLength(1)
310
+ expect(matches[0].source).toBe('main')
311
+ expect(matches[0].sessionId).toBe('main-shared')
312
+ })
313
+
314
+ it("source:'subagent' 只含 subagent 候选(main 被过滤)", async () => {
315
+ await makeSession(slugDir, { name: 'm.jsonl', id: 'main-shared', cwd: '/demo' })
316
+ await makeSession(saDir, { name: 's.jsonl', id: 'sub-shared', cwd: '/demo' })
317
+
318
+ const { matches } = await findSessions('shared', agentDir, { source: 'subagent' })
319
+ expect(matches).toHaveLength(1)
320
+ expect(matches[0].source).toBe('subagent')
321
+ expect(matches[0].sessionId).toBe('sub-shared')
322
+ expect(matches[0].fileName).toContain('subagents')
323
+ })
324
+
325
+ it("recent 也尊重 source 过滤(含不传 source 时两侧按 mtime 混合倒序)", async () => {
326
+ const mainPath = await makeSession(slugDir, { name: 'm.jsonl', id: 'r-main', cwd: '/demo' })
327
+ const subPath = await makeSession(saDir, { name: 's.jsonl', id: 'r-sub', cwd: '/demo' })
328
+ // subagent mtime 更新
329
+ const base = Math.floor(Date.now() / 1000)
330
+ await utimes(mainPath, base, base)
331
+ await utimes(subPath, base + 100, base + 100)
332
+
333
+ // source:'main' → 只 main 侧,subagent 被过滤
334
+ const mainOnly = await findSessions('recent', agentDir, { source: 'main' })
335
+ expect(mainOnly.matches).toHaveLength(1)
336
+ expect(mainOnly.matches[0].source).toBe('main')
337
+ expect(mainOnly.matches[0].sessionId).toBe('r-main')
338
+
339
+ // 不传 source → 两侧合并按 mtime 倒序(subagent 更新排前)
340
+ const merged = await findSessions('recent', agentDir)
341
+ expect(merged.matches).toHaveLength(2)
342
+ expect(merged.matches[0].sessionId).toBe('r-sub')
343
+ expect(merged.matches[0].source).toBe('subagent')
344
+ expect(merged.matches[1].sessionId).toBe('r-main')
345
+ expect(merged.matches[1].source).toBe('main')
346
+ })
347
+
348
+ // ============================================================
349
+ // U5:subagent task/slug/agentName 匹配(manifest 索引 + P-fallback identity 回退)
350
+ // ============================================================
351
+ describe('U5 task/slug/agentName 匹配', () => {
352
+ it('TC-u5-find-task:manifest.task 含 query 入选(main 首消息不含 query 不入选)', async () => {
353
+ const subId = '0aaaaaaa-bbbb-cccc-dddd-000000000001'
354
+ const subPath = await makeSubagentSession(saDir, {
355
+ name: `1234567890_${subId}.jsonl`,
356
+ id: subId,
357
+ cwd: '/demo',
358
+ })
359
+ await makeRecordManifest(recordsDir, {
360
+ id: `sa-${subId}`,
361
+ rootSessionId: 'root-1',
362
+ sessionFile: subPath,
363
+ task: '调研 codex CLI 的功能',
364
+ })
365
+ // main 首消息不含 codex → 关键词层不命中
366
+ await makeSession(slugDir, {
367
+ name: 'main.jsonl',
368
+ id: 'main-no-task-match',
369
+ cwd: '/demo',
370
+ firstUserText: '完全无关的对话内容',
371
+ })
372
+
373
+ const { matches } = await findSessions('codex', agentDir)
374
+ expect(matches).toHaveLength(1)
375
+ expect(matches[0].source).toBe('subagent')
376
+ expect(matches[0].sessionId).toBe(subId)
377
+ })
378
+
379
+ it('TC-u5-find-slug:manifest.slug 子串命中入选', async () => {
380
+ const subId = '0aaaaaaa-bbbb-cccc-dddd-000000000002'
381
+ const subPath = await makeSubagentSession(saDir, {
382
+ name: `1234567890_${subId}.jsonl`,
383
+ id: subId,
384
+ cwd: '/demo',
385
+ })
386
+ await makeRecordManifest(recordsDir, {
387
+ id: `sa-${subId}`,
388
+ rootSessionId: 'root-2',
389
+ sessionFile: subPath,
390
+ slug: 'codex-ask-user-research',
391
+ task: '其他不含 ask-user 的任务文本',
392
+ })
393
+
394
+ const { matches } = await findSessions('ask-user', agentDir)
395
+ expect(matches).toHaveLength(1)
396
+ expect(matches[0].source).toBe('subagent')
397
+ expect(matches[0].sessionId).toBe(subId)
398
+ })
399
+
400
+ it('TC-u5-find-agentname:manifest.agentName 命中入选', async () => {
401
+ const subId = '0aaaaaaa-bbbb-cccc-dddd-000000000003'
402
+ const subPath = await makeSubagentSession(saDir, {
403
+ name: `1234567890_${subId}.jsonl`,
404
+ id: subId,
405
+ cwd: '/demo',
406
+ })
407
+ await makeRecordManifest(recordsDir, {
408
+ id: `sa-${subId}`,
409
+ rootSessionId: 'root-3',
410
+ sessionFile: subPath,
411
+ agentName: 'explorer',
412
+ task: '不含 explorer 的任务',
413
+ slug: '不含-explorer-的-slug',
414
+ })
415
+
416
+ const { matches } = await findSessions('explorer', agentDir)
417
+ expect(matches).toHaveLength(1)
418
+ expect(matches[0].source).toBe('subagent')
419
+ expect(matches[0].sessionId).toBe(subId)
420
+ })
421
+
422
+ it('TC-u5-find-source-task-combo:source:subagent 过滤 + task 匹配正交(与 m0 U1 source 过滤组合)', async () => {
423
+ // main:首消息含 codex,但 source:'subagent' 在文件列表层排除 sessions/ 目录(不扫 main)
424
+ await makeSession(slugDir, {
425
+ name: 'main.jsonl',
426
+ id: 'main-with-codex',
427
+ cwd: '/demo',
428
+ firstUserText: '讨论 codex 工具的使用',
429
+ })
430
+ // subagent:task 含 codex
431
+ const subId = '0aaaaaaa-bbbb-cccc-dddd-000000000004'
432
+ const subPath = await makeSubagentSession(saDir, {
433
+ name: `1234567890_${subId}.jsonl`,
434
+ id: subId,
435
+ cwd: '/demo',
436
+ })
437
+ await makeRecordManifest(recordsDir, {
438
+ id: `sa-${subId}`,
439
+ rootSessionId: 'root-4',
440
+ sessionFile: subPath,
441
+ task: '用 codex 完成任务',
442
+ })
443
+
444
+ const { matches } = await findSessions('codex', agentDir, { source: 'subagent' })
445
+ expect(matches.length).toBeGreaterThanOrEqual(1)
446
+ expect(matches.every((m) => m.source === 'subagent')).toBe(true)
447
+ expect(matches.some((m) => m.sessionId === subId)).toBe(true)
448
+ // main 被 source 过滤排除(文件列表层不扫 sessions/)
449
+ expect(matches.some((m) => m.source === 'main')).toBe(false)
450
+ })
451
+
452
+ it('TC-u5-find-pfallback:无 manifest,读尾行 identity.task 回退匹配(场景 A)', async () => {
453
+ // subagent:无 manifest(P-fallback),但 session 文件尾行 identity.task 含 'resolve-bug'
454
+ const subId = '0aaaaaaa-bbbb-cccc-dddd-000000000005'
455
+ await makeSubagentSession(saDir, {
456
+ name: `1234567890_${subId}.jsonl`,
457
+ id: subId,
458
+ cwd: '/demo',
459
+ rootSessionId: 'root-5',
460
+ task: '修复 resolve-bug 这个问题',
461
+ slug: 'fix',
462
+ agent: 'worker',
463
+ })
464
+
465
+ const { matches } = await findSessions('resolve', agentDir)
466
+ expect(matches).toHaveLength(1)
467
+ expect(matches[0].source).toBe('subagent')
468
+ expect(matches[0].sessionId).toBe(subId)
469
+ })
470
+
471
+ it('TC-u5-find-uuid-priority:uuid 片段命中后短路,不走 task 匹配(TC-find-match-priority)', async () => {
472
+ // subagent A:sessionId 含 'abc123'(uuid 片段命中)
473
+ const idA = 'aabc123e-0000-0000-0000-000000000001'
474
+ await makeSubagentSession(saDir, {
475
+ name: `1111111111_${idA}.jsonl`,
476
+ id: idA,
477
+ cwd: '/demo',
478
+ })
479
+ // subagent B:sessionId 不含 abc123,但 manifest.task 含 abc123
480
+ const idB = '0aaaaaaa-bbbb-cccc-dddd-000000000006'
481
+ const subPathB = await makeSubagentSession(saDir, {
482
+ name: `2222222222_${idB}.jsonl`,
483
+ id: idB,
484
+ cwd: '/demo',
485
+ })
486
+ await makeRecordManifest(recordsDir, {
487
+ id: `sa-${idB}`,
488
+ rootSessionId: 'root-6',
489
+ sessionFile: subPathB,
490
+ task: '任务包含 abc123 关键词',
491
+ })
492
+
493
+ // 'abc123' 全十六进制 → uuid 特征;A 的 sessionId 含 abc123 → uuid 片段命中 → 短路
494
+ const { matches } = await findSessions('abc123', agentDir)
495
+ expect(matches).toHaveLength(1)
496
+ expect(matches[0].sessionId).toBe(idA)
497
+ // B 不入选(uuid 命中短路,不走 task 匹配)
498
+ expect(matches.some((m) => m.sessionId === idB)).toBe(false)
499
+ })
500
+
501
+ it.skipIf(!HAS_REAL_SUBAGENTS_DIR)(
502
+ 'TC-u5-real-data-guard:find codex source:subagent 命中(本机有 codex 相关 subagent task)',
503
+ async () => {
504
+ const { matches, truncated } = await findSessions('codex', REAL_AGENT_DIR, {
505
+ source: 'subagent',
506
+ limit: 50,
507
+ })
508
+ expect(matches.length).toBeGreaterThan(0)
509
+ expect(matches.every((m) => m.source === 'subagent')).toBe(true)
510
+ expect(typeof truncated).toBe('boolean')
511
+ },
512
+ 30000,
513
+ )
514
+ })
515
+
192
516
  it.skipIf(!HAS_E6)('真实数据:e6c96 匹配 019e6c96 开头的 session', async () => {
193
517
  const { matches } = await findSessions('e6c96', REAL_AGENT_DIR)
194
518
  expect(matches.length).toBeGreaterThan(0)
@@ -211,4 +535,21 @@ describe('findSessions', () => {
211
535
  // 真实 session 文件远多于 5 → 截断
212
536
  expect(truncated).toBe(true)
213
537
  }, 30000)
538
+
539
+ it.skipIf(!HAS_REAL_SUBAGENTS_DIR)(
540
+ "真实数据:source:'subagent' 能找到 completed subagent(§7 场景 1 find 部分)",
541
+ async () => {
542
+ const { matches } = await findSessions('recent', REAL_AGENT_DIR, {
543
+ source: 'subagent',
544
+ limit: 5,
545
+ })
546
+ expect(matches.length).toBeGreaterThan(0)
547
+ // 全部 source==='subagent',fileName 在 subagents/ 目录下
548
+ for (const m of matches) {
549
+ expect(m.source).toBe('subagent')
550
+ expect(m.fileName).toContain('subagents')
551
+ }
552
+ },
553
+ 30000,
554
+ )
214
555
  })
@@ -190,4 +190,54 @@ describe('sessionReaderExtension - TypeBox schema 与 SessionReadParams 对齐',
190
190
  expect(Check(schema, { action: 42 })).toBe(false)
191
191
  expect(Check(schema, {})).toBe(false) // 缺必填 action
192
192
  })
193
+
194
+ it('source 字段:合法值(main/subagent)通过、非法值拒绝;不传 source 向后兼容', () => {
195
+ const fake = makeFakePi()
196
+ sessionReaderExtension(fake.pi as unknown as ExtensionAPI)
197
+ const toolDef = fake.registerTool.mock.calls[0][0] as { parameters: unknown }
198
+ const schema = toolDef.parameters
199
+
200
+ // 合法值通过(enum 约束)
201
+ expect(Check(schema, { action: 'find', query: 'x', source: 'main' })).toBe(true)
202
+ expect(Check(schema, { action: 'find', query: 'x', source: 'subagent' })).toBe(true)
203
+ // 非法值被拒
204
+ expect(Check(schema, { action: 'find', query: 'x', source: 'bogus' })).toBe(false)
205
+ // 不传 source 向后兼容(既有合法形态仍 true)
206
+ expect(Check(schema, { action: 'find', query: 'x' })).toBe(true)
207
+ })
208
+
209
+ it('TC-w6-schema-action:action enum 含 workflow + runId optional(w6)', () => {
210
+ const fake = makeFakePi()
211
+ sessionReaderExtension(fake.pi as unknown as ExtensionAPI)
212
+ const toolDef = fake.registerTool.mock.calls[0][0] as { parameters: unknown }
213
+ const schema = toolDef.parameters
214
+
215
+ // action='workflow' 不传 runId(合法)
216
+ expect(Check(schema, { action: 'workflow', session: 'e6c96' })).toBe(true)
217
+ // action='workflow' 传 runId(合法)
218
+ expect(Check(schema, { action: 'workflow', session: 'e6c96', runId: 'wf-1' })).toBe(true)
219
+ // runId 传任意 action 均合法(其他 action 忽略 runId,不报错)
220
+ expect(Check(schema, { action: 'find', query: 'x', runId: 'wf-1' })).toBe(true)
221
+ // 不传 runId 向后兼容
222
+ expect(Check(schema, { action: 'outline', session: 'e6c96' })).toBe(true)
223
+ // runId 类型校验:非 string 被拒
224
+ expect(Check(schema, { action: 'workflow', session: 'x', runId: 123 })).toBe(false)
225
+ })
226
+
227
+ it('TC-m3b-schema-recursive:recursive optional boolean(family 专用)', () => {
228
+ const fake = makeFakePi()
229
+ sessionReaderExtension(fake.pi as unknown as ExtensionAPI)
230
+ const toolDef = fake.registerTool.mock.calls[0][0] as { parameters: unknown }
231
+ const schema = toolDef.parameters
232
+
233
+ // recursive=true(合法)
234
+ expect(Check(schema, { action: 'family', session: 'e6c96', recursive: true })).toBe(true)
235
+ // 不传 recursive(向后兼容,合法)
236
+ expect(Check(schema, { action: 'family', session: 'e6c96' })).toBe(true)
237
+ // recursive=false(合法)
238
+ expect(Check(schema, { action: 'family', session: 'e6c96', recursive: false })).toBe(true)
239
+ // recursive 非 boolean 被拒
240
+ expect(Check(schema, { action: 'family', session: 'x', recursive: 'yes' })).toBe(false)
241
+ expect(Check(schema, { action: 'family', session: 'x', recursive: 1 })).toBe(false)
242
+ })
193
243
  })
@@ -162,5 +162,6 @@ describe('parseSessionFile', () => {
162
162
  expect(result.lastLinePartial).toBe(false)
163
163
  // 5.4MB 量级
164
164
  expect(result.totalBytes).toBeGreaterThan(5_000_000)
165
- })
165
+ // 5.6MB 全量解析在并发/高负载下可能超 vitest 默认 5s,显式放宽
166
+ }, 60000)
166
167
  })
@@ -44,6 +44,26 @@ export function hasRealSession(sid: string): boolean {
44
44
  }
45
45
  }
46
46
 
47
+ /**
48
+ * 同步探测真实数据中任意 session 文件存在(main sessions/ 与 subagents/ 双目录)。
49
+ * 与 hasRealSession 的区别:subagent session 文件位于 subagents/<cwd>/sessions/ 下,
50
+ * hasRealSession 只扫主 sessions/ 目录扫不到。用于对活跃数据目录中具体文件(如 fork
51
+ * 子代/隔代 subagent)存在性的守卫探测。
52
+ */
53
+ export function hasAnyRealSession(fragment: string): boolean {
54
+ if (!existsSync(REAL_AGENT_DIR)) return false
55
+ try {
56
+ return (
57
+ execSync(
58
+ `find ${REAL_AGENT_DIR}/sessions ${REAL_AGENT_DIR}/subagents -name '*${fragment}*' -name '*.jsonl' ! -name '*.finalized' 2>/dev/null | head -1`,
59
+ { encoding: 'utf8' },
60
+ ).trim().length > 0
61
+ )
62
+ } catch {
63
+ return false
64
+ }
65
+ }
66
+
47
67
  export const HAS_REAL_AGENT_DIR = existsSync(REAL_AGENT_DIR)
48
68
  export const HAS_REAL_SUBAGENTS_DIR = existsSync(join(REAL_AGENT_DIR, 'subagents'))
49
69
  export const HAS_REAL_SESSION = existsSync(REAL_SESSION)
@@ -185,7 +185,8 @@ describe('renderOutline', () => {
185
185
  // totalEntries 近似(leaf+branch+orphan)不含 session header(segmentTurns 规则1 跳过);
186
186
  // 准确值由 M2 工具层用 ParseResult.totalEntries 覆盖。M1 验量级。
187
187
  expect(result.stats.totalEntries).toBeGreaterThan(1000)
188
- })
188
+ // 5.6MB 全量解析在并发/高负载下可能超 vitest 默认 5s,显式放宽
189
+ }, 60000)
189
190
  })
190
191
 
191
192
  describe('renderExpand', () => {