@zhushanwen/pi-session-reader 0.13.0 → 0.13.2

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.
@@ -1,350 +1,626 @@
1
- import { describe, it, expect } from 'vitest'
2
- import { buildFamilyIndex, resolveFamily } from '../core/family.js'
3
- import type { Entry } from '@zhushanwen/session-core'
4
-
5
- // ---- fixture 常量(映射 design §3.3 D-7 Q1 真实场景)----
6
- const ROOT = '019fe620' // 家族根(无 parentSession)
7
- const FORK = '019fe632' // fork 子代(parentSession 指向 ROOT 的文件)
8
- const SUB = '019fe635' // subagent(rootSessionId=FORK,挂在 fork 子代下,非家族根)
9
- const SUB2 = '019fe636' // U4 第二个 subagent(多 subagent 场景)
10
-
11
- // ---- fixture helpers ----
12
-
13
- /** 构造 session header entry(type=session,含 id/cwd,可选 parentSession) */
14
- function header(id: string, opts?: { parentSession?: string; cwd?: string }): Entry {
15
- const e: Entry = { type: 'session', id, parentId: null, cwd: opts?.cwd ?? '/proj' }
16
- if (opts?.parentSession) e.parentSession = opts.parentSession
17
- return e
18
- }
19
-
20
- /** 模拟真实文件路径:含 sessionId 的 jsonl 路径(resolveParentSessionId 靠 includes 反查) */
21
- function sessionFile(id: string): string {
22
- return `/sessions/2026-08-09T10-45-20Z_${id}.jsonl`
23
- }
24
-
25
- /** 构造 subagent-identity custom entry(data.rootSessionId/slug) */
26
- function subagentIdentity(id: string, rootSessionId: string, slug: string): Entry {
27
- return {
28
- type: 'custom',
29
- id,
30
- parentId: null,
31
- customType: 'subagent-identity',
32
- data: { rootSessionId, slug },
33
- }
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
-
61
- /** 构造 fileStats Map(M1 key=sessionId) */
62
- function makeStats(
63
- keys: string[],
64
- opts: { mtime?: number; size?: number } = {},
65
- ): Map<string, { mtime: number; size: number }> {
66
- const { mtime = 1000, size = 5000 } = opts
67
- const m = new Map<string, { mtime: number; size: number }>()
68
- for (const k of keys) m.set(k, { mtime, size })
69
- return m
70
- }
71
-
72
- /** Q1 核心 fixture:ROOT ← fork ← FORK;subagent SUB 挂在 fork 子代 FORK 下 */
73
- function q1Fixture(aliveKeys: string[] = [ROOT, FORK, SUB]) {
74
- return {
75
- headers: [header(ROOT), header(FORK, { parentSession: sessionFile(ROOT) })],
76
- identities: [subagentIdentity(SUB, FORK, 'deep-survey')],
77
- fileStats: makeStats(aliveKeys),
78
- }
79
- }
1
+ // family.test.ts —— session-reader C05 簇合并产物(round5-G)。
2
+ // 共同被测业务模块:`src/core/family.ts`。mock 相容:全簇零 vi.mock。
3
+ // 顶层正文按来源包 describe 纯前缀分节,it 层名零改动。
4
+ //
5
+ // 来源:
6
+ // - src/__tests__/family-zcode.test.ts(9 用例,skip 0)
7
+ // - src/__tests__/family.test.ts(20 用例,skip 0)
8
+
9
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
10
+ import { tmpdir } from 'node:os'
11
+ import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'
12
+ import { join, dirname } from 'node:path'
13
+ import { DatabaseSync } from 'node:sqlite'
14
+ import { buildFamilyIndex, resolveFamily, buildZcodeSubagentsByRoot, type Entry as FamilyEntry, type ZcodeFamilyNode } from '../core/family.js'
15
+ import { listZcodeManifests } from '../discovery/zcode-manifest.js'
16
+ import { type Entry } from '@zhushanwen/session-core'
17
+
18
+ // ======== 来源:family-zcode.test.ts ========
19
+ describe("family-zcode", () => {
20
+ // ============================================================
21
+ // family zcode 分支(design session-reader-shared-core §3.3 D5-1 / §4 场景 2)。
22
+ //
23
+ // 判据(两项、不开库):锚可解析(枚举谓词已过)∧ 库文件存在(stat)→ cleanedUp=false;
24
+ // 缺一 → true(GC 语义,不报错不抛异常)。刻意不含「库内 session 查询」第三项——
25
+ // 静息态 CANTOPEN 会误判活会话 + 列表视图不开库;反向断言(库文件在但库内 session
26
+ // 行被删 → 仍 false)即「列表视图不开库」的分工声明断言。
27
+ //
28
+ // 与 pi 判据不同构(D5-1 显式声明):pi = fileStats JSONL 存在性;zcode 节点无
29
+ // JSONL 文件,按「库可达」判。pi 侧既有 fixture 行为零变化由 family.test.ts 全绿检查
30
+ // (本文件零改动 family.test.ts——更强的零涟漪证据)。
31
+ // ============================================================
32
+
33
+ const ROOT = 'root-session-1'
34
+
35
+ let dir: string
36
+
37
+ beforeEach(() => {
38
+ dir = mkdtempSync(join(tmpdir(), 'family-zcode-test-'))
39
+ })
80
40
 
81
- describe('buildFamilyIndex', () => {
82
- it('byId / childrenOf / subagentsByRoot 结构正确', () => {
83
- const { headers, identities, fileStats } = q1Fixture()
84
- const index = buildFamilyIndex(headers, identities, fileStats)
41
+ afterEach(() => {
42
+ rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
43
+ })
85
44
 
86
- expect(index.byId.has(ROOT)).toBe(true)
87
- expect(index.byId.has(FORK)).toBe(true)
88
- expect(index.byId.get(ROOT)?.cwd).toBe('/proj')
45
+ function header(id: string): FamilyEntry {
46
+ return { type: 'session', id, parentId: null, cwd: '/proj' }
47
+ }
89
48
 
90
- // childrenOf key 是父 sessionId(经 parentSession 文件路径反查)
91
- expect(index.childrenOf.get(ROOT)).toHaveLength(1)
92
- expect(index.childrenOf.get(ROOT)?.[0].sessionId).toBe(FORK)
49
+ /** zcode 节点(core 层判据输入,与 ZcodeManifestRecord 结构兼容) */
50
+ function zcodeNode(opts: Partial<ZcodeFamilyNode> & { id: string }): ZcodeFamilyNode {
51
+ return {
52
+ rootSessionId: ROOT,
53
+ slug: 'worker',
54
+ dbPath: join(dir, 'db.sqlite'),
55
+ dbFileExists: false,
56
+ ...opts,
57
+ }
58
+ }
93
59
 
94
- // subagentsByRoot key 是 rootSessionId
95
- expect(index.subagentsByRoot.get(FORK)).toHaveLength(1)
96
- expect(index.subagentsByRoot.get(FORK)?.[0].sessionId).toBe(SUB)
97
- expect(index.subagentsByRoot.get(FORK)?.[0].slug).toBe('deep-survey')
98
- })
60
+ /** 在存储树分区 <root>/<rootSessionId>/subagents/records/ 下写 zcode manifest(F21 磁盘真实形态:无 sessionFile 键;[session-store-layout-inversion D6/单新根] 读侧已换存储树) */
61
+ function writeZcodeManifest(
62
+ agentDir: string,
63
+ saId: string,
64
+ sessionRef: Record<string, string>,
65
+ ): void {
66
+ const recordsDir = join(dirname(agentDir), 'ext-sessions', ROOT, 'subagents', 'records')
67
+ mkdirSync(recordsDir, { recursive: true })
68
+ writeFileSync(
69
+ join(recordsDir, `${saId}.json`),
70
+ JSON.stringify({
71
+ id: saId,
72
+ rootSessionId: ROOT,
73
+ agentName: 'worker',
74
+ status: 'running',
75
+ engine: 'zcode',
76
+ engineHandle: { sessionRef, poolKey: 'shared' },
77
+ }),
78
+ 'utf8',
79
+ )
80
+ }
99
81
 
100
- it('parentSession 是文件路径:经 sessionId 子串反查建 childrenOf', () => {
101
- const headers = [
102
- header('aaa'),
103
- header('bbb', { parentSession: '/some/dir/2026-01-01T00-00-00Z_aaa.jsonl' }),
104
- ]
105
- const index = buildFamilyIndex(headers, [], new Map())
82
+ /** 造一个真实 sqlite 库(session/message/part 三级表先例形态)并插入 sess 行 */
83
+ function createFixtureDb(dbPath: string, sessionId: string): void {
84
+ const db = new DatabaseSync(dbPath)
85
+ try {
86
+ db.exec('CREATE TABLE session (id TEXT PRIMARY KEY, title TEXT)')
87
+ db.exec('CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT, sequence INTEGER)')
88
+ db.exec('CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT, session_id TEXT)')
89
+ db.prepare('INSERT INTO session (id, title) VALUES (?, ?)').run(sessionId, 'fixture')
90
+ } finally {
91
+ db.close()
92
+ }
93
+ }
106
94
 
107
- expect(index.childrenOf.get('aaa')?.[0].sessionId).toBe('bbb')
95
+ describe('core:buildZcodeSubagentsByRoot / buildFamilyIndex 的 zcode 分支(D5-1 两态)', () => {
96
+ it('库文件存在 → cleanedUp=false;库文件缺席 → cleanedUp=true(缺一即 true,不抛错)', () => {
97
+ const index = buildFamilyIndex([header(ROOT)], [], new Map(), [
98
+ zcodeNode({ id: 'sa-alive', dbFileExists: true }),
99
+ zcodeNode({ id: 'sa-gone', dbFileExists: false }),
100
+ ])
101
+ const family = resolveFamily(ROOT, index)
102
+
103
+ const alive = family.subagents.find((s) => s.sessionId === 'sa-alive')
104
+ const gone = family.subagents.find((s) => s.sessionId === 'sa-gone')
105
+ expect(alive?.cleanedUp).toBe(false)
106
+ expect(gone?.cleanedUp).toBe(true)
107
+ })
108
+
109
+ it('zcode 节点按 rootSessionId 挂载,隔代场景与 pi 同链合并(resolveFamily 消费同一 subagentsByRoot)', () => {
110
+ const FORK = 'fork-session-1'
111
+ const headers = [header(ROOT), { ...header(FORK), parentSession: `/s/_${ROOT}.jsonl` }]
112
+ const index = buildFamilyIndex(headers, [], new Map(), [
113
+ zcodeNode({ id: 'sa-deep', rootSessionId: FORK }),
114
+ ])
115
+ // Q1 隔代:zcode subagent 挂在 fork 子代下,从 ROOT resolve 仍可见
116
+ expect(resolveFamily(ROOT, index).subagents.map((s) => s.sessionId)).toContain('sa-deep')
117
+ })
118
+
119
+ it('不传 zcodeNodes / 传空数组 → pi 行为不变(零涟漪等价)', () => {
120
+ const piIdent: FamilyEntry = {
121
+ type: 'custom',
122
+ id: 'sa-pi',
123
+ parentId: null,
124
+ customType: 'subagent-identity',
125
+ data: { rootSessionId: ROOT, slug: 'pi-sub' },
126
+ }
127
+ const stats = new Map([[ROOT, { mtime: 1, size: 2 }], ['sa-pi', { mtime: 3, size: 4 }]])
128
+ const without = buildFamilyIndex([header(ROOT)], [piIdent], stats)
129
+ const withEmpty = buildFamilyIndex([header(ROOT)], [piIdent], stats, [])
130
+ const a = resolveFamily(ROOT, without)
131
+ const b = resolveFamily(ROOT, withEmpty)
132
+ expect(b.subagents).toEqual(a.subagents)
133
+ expect(b.subagents[0]?.cleanedUp).toBe(false) // pi 判据:fileStats 命中
134
+ })
135
+
136
+ it('zcode 节点与 pi 孤儿节点并存:判据互不污染(不同构声明断言)', () => {
137
+ // pi 孤儿:identity 在但 fileStats 不含 → cleanedUp=true(JSONL GC)
138
+ const piOrphan: FamilyEntry = {
139
+ type: 'custom',
140
+ id: 'sa-pi-orphan',
141
+ parentId: null,
142
+ customType: 'subagent-identity',
143
+ data: { rootSessionId: ROOT, slug: 'orphan' },
144
+ }
145
+ const index = buildFamilyIndex([header(ROOT)], [piOrphan], new Map(), [
146
+ zcodeNode({ id: 'sa-zcode-gone', dbFileExists: false }),
147
+ ])
148
+ const subs = resolveFamily(ROOT, index).subagents
149
+ // 两节点都 cleanedUp=true,但判据来源不同:pi 走 fileStats,zcode 走 dbFileExists
150
+ expect(subs.find((s) => s.sessionId === 'sa-pi-orphan')?.cleanedUp).toBe(true)
151
+ expect(subs.find((s) => s.sessionId === 'sa-zcode-gone')?.cleanedUp).toBe(true)
152
+ })
153
+
154
+ it('SubagentRef 字段映射:富字段透传、sessionFile 不设、mtime/sizeBytes 占位 0', () => {
155
+ const byRoot = buildZcodeSubagentsByRoot([
156
+ zcodeNode({
157
+ id: 'sa-rich',
158
+ slug: 'rich-slug',
159
+ agentName: 'explorer',
160
+ task: 'do research',
161
+ model: 'glm-5.3',
162
+ status: 'closed',
163
+ dbFileExists: true,
164
+ }),
165
+ ])
166
+ const ref = byRoot.get(ROOT)?.[0]
167
+ expect(ref?.slug).toBe('rich-slug')
168
+ expect(ref?.agentName).toBe('explorer')
169
+ expect(ref?.task).toBe('do research')
170
+ expect(ref?.model).toBe('glm-5.3')
171
+ expect(ref?.status).toBe('closed')
172
+ expect(ref?.sessionFile).toBeUndefined() // zcode 无 session.jsonl
173
+ expect(ref?.mtime).toBe(0)
174
+ expect(ref?.sizeBytes).toBe(0)
175
+ })
108
176
  })
109
177
 
110
- it('parentSession 反查不到父 → 该 entry 不进 childrenOf(不报错)', () => {
111
- const headers = [header('bbb', { parentSession: '/missing.jsonl' })]
112
- const index = buildFamilyIndex(headers, [], new Map())
113
-
114
- expect(index.childrenOf.size).toBe(0)
178
+ describe('集成:listZcodeManifests → buildFamilyIndex → resolveFamily(场景 2 两态 + 反向断言)', () => {
179
+ it('库可达态(dbPath 指向真实 fixture 库)→ cleanedUp=false', async () => {
180
+ // agentDir 两层化(<mkdtemp>/agent):存储树推导 = dirname(agentDir)/ext-sessions,
181
+ // 单层直接当 agentDir 会把扫描根泄到 tmpdir 公共层(跨测试污染——先例 reaper 同修)
182
+ const agentDir = join(dir, 'agent')
183
+ mkdirSync(agentDir, { recursive: true })
184
+ const dbPath = join(dir, 'isolated.sqlite')
185
+ createFixtureDb(dbPath, 'sess_live')
186
+ writeZcodeManifest(agentDir, 'sa-live', { sessionId: 'sess_live', dbPath })
187
+
188
+ const nodes = await listZcodeManifests(agentDir)
189
+ expect(nodes).toHaveLength(1)
190
+ expect(nodes[0]?.dbFileExists).toBe(true)
191
+
192
+ const family = resolveFamily(ROOT, buildFamilyIndex([header(ROOT)], [], new Map(), nodes))
193
+ const sub = family.subagents.find((s) => s.sessionId === 'sa-live')
194
+ expect(sub?.cleanedUp).toBe(false)
195
+ })
196
+
197
+ it('库不存在态(dbPath 指向不存在路径)→ cleanedUp=true(GC 语义,不报错)', async () => {
198
+ const agentDir = join(dir, 'agent')
199
+ mkdirSync(agentDir, { recursive: true })
200
+ writeZcodeManifest(agentDir, 'sa-gone', {
201
+ sessionId: 'sess_gone',
202
+ dbPath: join(dir, 'subagents', 'proj-a', 'missing.sqlite'),
203
+ })
204
+
205
+ const nodes = await listZcodeManifests(agentDir)
206
+ expect(nodes[0]?.dbFileExists).toBe(false)
207
+
208
+ const family = resolveFamily(ROOT, buildFamilyIndex([header(ROOT)], [], new Map(), nodes))
209
+ expect(family.subagents.find((s) => s.sessionId === 'sa-gone')?.cleanedUp).toBe(true)
210
+ })
211
+
212
+ it('反向断言:库文件在但库内 session 行被删 → 仍 cleanedUp=false(列表视图不开库的分工声明)', async () => {
213
+ // 分工(D5-1):库内 session 已被 zcode GC 的情形由 read 路径 zcode_session_not_found
214
+ // 权威承接;family 只 stat 文件存在性。本用例删行留文件,断言 family 不受库内容影响。
215
+ const agentDir = join(dir, 'agent')
216
+ mkdirSync(agentDir, { recursive: true })
217
+ const dbPath = join(dir, 'gc.sqlite')
218
+ createFixtureDb(dbPath, 'sess_victim')
219
+ writeZcodeManifest(agentDir, 'sa-gced', { sessionId: 'sess_victim', dbPath })
220
+
221
+ // 前置核实:fixture 库里确实有该 session 行(反向断言的非平凡性前提)
222
+ {
223
+ const db = new DatabaseSync(dbPath)
224
+ try {
225
+ const row = db.prepare('SELECT COUNT(*) AS c FROM session WHERE id = ?').get('sess_victim') as
226
+ | { c: number }
227
+ | undefined
228
+ expect(row?.c).toBe(1)
229
+ } finally {
230
+ db.close()
231
+ }
232
+ }
233
+
234
+ // 删库内 session 行(含 message/part 级联),db 文件保留
235
+ {
236
+ const db = new DatabaseSync(dbPath)
237
+ try {
238
+ db.prepare('DELETE FROM part WHERE session_id = ?').run('sess_victim')
239
+ db.prepare('DELETE FROM message WHERE session_id = ?').run('sess_victim')
240
+ db.prepare('DELETE FROM session WHERE id = ?').run('sess_victim')
241
+ } finally {
242
+ db.close()
243
+ }
244
+ }
245
+
246
+ const nodes = await listZcodeManifests(agentDir)
247
+ expect(nodes[0]?.dbFileExists).toBe(true) // 只 stat:文件在即 true,不看内容
248
+
249
+ const family = resolveFamily(ROOT, buildFamilyIndex([header(ROOT)], [], new Map(), nodes))
250
+ expect(family.subagents.find((s) => s.sessionId === 'sa-gced')?.cleanedUp).toBe(false)
251
+ })
252
+
253
+ it('坏 manifest 与缺库节点混排 → 好节点照常输出(单节点不可达不炸整屏)', async () => {
254
+ const dbPath = join(dir, 'ok.sqlite')
255
+ createFixtureDb(dbPath, 'sess_ok')
256
+ const agentDir = join(dir, 'agent')
257
+ mkdirSync(agentDir, { recursive: true })
258
+ writeZcodeManifest(agentDir, 'sa-ok', { sessionId: 'sess_ok', dbPath })
259
+ // 缺库节点:合法 zcode 形态(dbFileExists=false,不抛错),不剔除出列表
260
+ writeZcodeManifest(agentDir, 'sa-broken-manifest', { sessionId: 'sess_x', dbPath: '/nonexistent/x.sqlite' })
261
+ // 坏 JSON manifest:枚举跳过(不中断)
262
+ writeFileSync(
263
+ join(dirname(agentDir), 'ext-sessions', ROOT, 'subagents', 'records', 'sa-corrupt.json'),
264
+ '{{{broken',
265
+ 'utf8',
266
+ )
267
+
268
+ const nodes = await listZcodeManifests(agentDir)
269
+ expect(nodes.map((n) => n.id).sort()).toEqual(['sa-broken-manifest', 'sa-ok'])
270
+
271
+ const family = resolveFamily(ROOT, buildFamilyIndex([header(ROOT)], [], new Map(), nodes))
272
+ expect(family.subagents.find((s) => s.sessionId === 'sa-ok')?.cleanedUp).toBe(false)
273
+ expect(family.subagents.find((s) => s.sessionId === 'sa-broken-manifest')?.cleanedUp).toBe(true)
274
+ })
115
275
  })
276
+ });
277
+
278
+ // ======== 来源:family.test.ts ========
279
+ describe("family", () => {
280
+ // ---- fixture 常量(映射 design §3.3 D-7 Q1 真实场景)----
281
+ const ROOT = '019fe620' // 家族根(无 parentSession)
282
+ const FORK = '019fe632' // fork 子代(parentSession 指向 ROOT 的文件)
283
+ const SUB = '019fe635' // subagent(rootSessionId=FORK,挂在 fork 子代下,非家族根)
284
+ const SUB2 = '019fe636' // U4 第二个 subagent(多 subagent 场景)
285
+
286
+ // ---- fixture helpers ----
287
+
288
+ /** 构造 session header entry(type=session,含 id/cwd,可选 parentSession) */
289
+ function header(id: string, opts?: { parentSession?: string; cwd?: string }): Entry {
290
+ const e: Entry = { type: 'session', id, parentId: null, cwd: opts?.cwd ?? '/proj' }
291
+ if (opts?.parentSession) e.parentSession = opts.parentSession
292
+ return e
293
+ }
294
+
295
+ /** 模拟真实文件路径:含 sessionId 的 jsonl 路径(resolveParentSessionId 靠 includes 反查) */
296
+ function sessionFile(id: string): string {
297
+ return `/sessions/2026-08-09T10-45-20Z_${id}.jsonl`
298
+ }
116
299
 
117
- it('identity 缺 rootSessionId/slug → 跳过(坏数据容错)', () => {
118
- const bad: Entry = {
300
+ /** 构造 subagent-identity custom entry(data.rootSessionId/slug) */
301
+ function subagentIdentity(id: string, rootSessionId: string, slug: string): Entry {
302
+ return {
119
303
  type: 'custom',
120
- id: 'x',
304
+ id,
121
305
  parentId: null,
122
306
  customType: 'subagent-identity',
123
- data: { slug: 'no-root' },
307
+ data: { rootSessionId, slug },
124
308
  }
125
- const index = buildFamilyIndex([], [bad], new Map())
126
-
127
- expect(index.subagentsByRoot.size).toBe(0)
128
- })
129
-
130
- it('fileStats 原样存入 index(供 cleanedUp 判断用)', () => {
131
- const fileStats = makeStats(['s1'])
132
- const index = buildFamilyIndex([], [], fileStats)
133
-
134
- expect(index.fileStats).toBe(fileStats)
135
- })
136
- })
137
-
138
- describe('resolveFamily - 隔代关联(design §3.3 D-7 Q1 核心断言)', () => {
139
- it('从家族根 resolve 能关联到挂在 fork 子代下的隔代 subagent', () => {
140
- const { headers, identities, fileStats } = q1Fixture()
141
- const index = buildFamilyIndex(headers, identities, fileStats)
142
-
143
- const family = resolveFamily(ROOT, index)
144
-
145
- // Q1 核心:subagent SUB 的 rootSessionId 是 fork 子代 FORK,不是 root ROOT。
146
- // 只查 root 的 subagentsByRoot 会漏;必须对 fork 链每个节点(含 forks)查。
147
- expect(family.subagents.some((s) => s.sessionId === SUB)).toBe(true)
148
- expect(family.subagents.find((s) => s.sessionId === SUB)?.slug).toBe('deep-survey')
149
- })
150
-
151
- it('从 fork 子代 resolve 也能关联到直接挂在其下的 subagent', () => {
152
- const { headers, identities, fileStats } = q1Fixture()
153
- const index = buildFamilyIndex(headers, identities, fileStats)
309
+ }
154
310
 
155
- const family = resolveFamily(FORK, index)
156
- expect(family.subagents.some((s) => s.sessionId === SUB)).toBe(true)
157
- })
158
- })
311
+ /** U4 富字段(可选,模拟 manifest 主/P-fallback identity 回退组装的 data) */
312
+ interface SubagentRichFields {
313
+ task?: string
314
+ agent?: string
315
+ model?: string
316
+ status?: string
317
+ sessionFile?: string
318
+ }
159
319
 
160
- describe('resolveFamily - fork 链', () => {
161
- it('fork 子代的 parents 含 root;root 的 forks 含 fork 子代', () => {
162
- const { headers, identities, fileStats } = q1Fixture()
163
- const index = buildFamilyIndex(headers, identities, fileStats)
320
+ /** 构造带 U4 富字段的 subagent-identity custom entry(manifest 主/P-fallback 两种 data 形态) */
321
+ function subagentIdentityRich(
322
+ id: string,
323
+ rootSessionId: string,
324
+ slug: string,
325
+ rich?: SubagentRichFields,
326
+ ): Entry {
327
+ const data: Record<string, unknown> = { rootSessionId, slug }
328
+ if (rich?.task !== undefined) data.task = rich.task
329
+ if (rich?.agent !== undefined) data.agent = rich.agent
330
+ if (rich?.model !== undefined) data.model = rich.model
331
+ if (rich?.status !== undefined) data.status = rich.status
332
+ if (rich?.sessionFile !== undefined) data.sessionFile = rich.sessionFile
333
+ return { type: 'custom', id, parentId: null, customType: 'subagent-identity', data }
334
+ }
164
335
 
165
- expect(resolveFamily(FORK, index).parents.some((p) => p.sessionId === ROOT)).toBe(true)
166
- expect(resolveFamily(ROOT, index).forks.some((f) => f.sessionId === FORK)).toBe(true)
167
- })
336
+ /** 构造 fileStats Map(M1 key=sessionId) */
337
+ function makeStats(
338
+ keys: string[],
339
+ opts: { mtime?: number; size?: number } = {},
340
+ ): Map<string, { mtime: number; size: number }> {
341
+ const { mtime = 1000, size = 5000 } = opts
342
+ const m = new Map<string, { mtime: number; size: number }>()
343
+ for (const k of keys) m.set(k, { mtime, size })
344
+ return m
345
+ }
168
346
 
169
- it('root 无 parentSession → parents 空、forks 取 childrenOf', () => {
170
- const { headers, identities, fileStats } = q1Fixture()
171
- const index = buildFamilyIndex(headers, identities, fileStats)
347
+ /** Q1 核心 fixture:ROOT ← fork ← FORK;subagent SUB 挂在 fork 子代 FORK 下 */
348
+ function q1Fixture(aliveKeys: string[] = [ROOT, FORK, SUB]) {
349
+ return {
350
+ headers: [header(ROOT), header(FORK, { parentSession: sessionFile(ROOT) })],
351
+ identities: [subagentIdentity(SUB, FORK, 'deep-survey')],
352
+ fileStats: makeStats(aliveKeys),
353
+ }
354
+ }
172
355
 
173
- const family = resolveFamily(ROOT, index)
174
- expect(family.parents).toEqual([])
175
- expect(family.forks.map((f) => f.sessionId)).toEqual([FORK])
356
+ describe('buildFamilyIndex', () => {
357
+ it('byId / childrenOf / subagentsByRoot 结构正确', () => {
358
+ const { headers, identities, fileStats } = q1Fixture()
359
+ const index = buildFamilyIndex(headers, identities, fileStats)
360
+
361
+ expect(index.byId.has(ROOT)).toBe(true)
362
+ expect(index.byId.has(FORK)).toBe(true)
363
+ expect(index.byId.get(ROOT)?.cwd).toBe('/proj')
364
+
365
+ // childrenOf key 是父 sessionId(经 parentSession 文件路径反查)
366
+ expect(index.childrenOf.get(ROOT)).toHaveLength(1)
367
+ expect(index.childrenOf.get(ROOT)?.[0].sessionId).toBe(FORK)
368
+
369
+ // subagentsByRoot key 是 rootSessionId
370
+ expect(index.subagentsByRoot.get(FORK)).toHaveLength(1)
371
+ expect(index.subagentsByRoot.get(FORK)?.[0].sessionId).toBe(SUB)
372
+ expect(index.subagentsByRoot.get(FORK)?.[0].slug).toBe('deep-survey')
373
+ })
374
+
375
+ it('parentSession 是文件路径:经 sessionId 子串反查建 childrenOf', () => {
376
+ const headers = [
377
+ header('aaa'),
378
+ header('bbb', { parentSession: '/some/dir/2026-01-01T00-00-00Z_aaa.jsonl' }),
379
+ ]
380
+ const index = buildFamilyIndex(headers, [], new Map())
381
+
382
+ expect(index.childrenOf.get('aaa')?.[0].sessionId).toBe('bbb')
383
+ })
384
+
385
+ it('parentSession 反查不到父 → 该 entry 不进 childrenOf(不报错)', () => {
386
+ const headers = [header('bbb', { parentSession: '/missing.jsonl' })]
387
+ const index = buildFamilyIndex(headers, [], new Map())
388
+
389
+ expect(index.childrenOf.size).toBe(0)
390
+ })
391
+
392
+ it('identity 缺 rootSessionId/slug → 跳过(坏数据容错)', () => {
393
+ const bad: Entry = {
394
+ type: 'custom',
395
+ id: 'x',
396
+ parentId: null,
397
+ customType: 'subagent-identity',
398
+ data: { slug: 'no-root' },
399
+ }
400
+ const index = buildFamilyIndex([], [bad], new Map())
401
+
402
+ expect(index.subagentsByRoot.size).toBe(0)
403
+ })
404
+
405
+ it('fileStats 原样存入 index(供 cleanedUp 判断用)', () => {
406
+ const fileStats = makeStats(['s1'])
407
+ const index = buildFamilyIndex([], [], fileStats)
408
+
409
+ expect(index.fileStats).toBe(fileStats)
410
+ })
176
411
  })
177
412
 
178
- it('线性多级 fork 链:C←B←A,resolveFamily(C).parents=[B,A]', () => {
179
- const headers = [
180
- header('A'),
181
- header('B', { parentSession: sessionFile('A') }),
182
- header('C', { parentSession: sessionFile('B') }),
183
- ]
184
- const index = buildFamilyIndex(headers, [], new Map())
185
-
186
- const family = resolveFamily('C', index)
187
- expect(family.parents.map((p) => p.sessionId)).toEqual(['B', 'A'])
188
- // forks 只含直接子代(孙代 C 是 B 的子代,不是 A 的直接子代)
189
- expect(resolveFamily('A', index).forks.map((f) => f.sessionId)).toEqual(['B'])
190
- expect(resolveFamily('B', index).forks.map((f) => f.sessionId)).toEqual(['C'])
191
- })
413
+ describe('resolveFamily - 隔代关联(design §3.3 D-7 Q1 核心断言)', () => {
414
+ it('从家族根 resolve 能关联到挂在 fork 子代下的隔代 subagent', () => {
415
+ const { headers, identities, fileStats } = q1Fixture()
416
+ const index = buildFamilyIndex(headers, identities, fileStats)
192
417
 
193
- it('workflows 恒为空数组(M1,接口字段保留供 M2)', () => {
194
- const { headers, identities, fileStats } = q1Fixture()
195
- const index = buildFamilyIndex(headers, identities, fileStats)
418
+ const family = resolveFamily(ROOT, index)
196
419
 
197
- expect(resolveFamily(ROOT, index).workflows).toEqual([])
198
- })
199
- })
420
+ // Q1 核心:subagent SUB 的 rootSessionId 是 fork 子代 FORK,不是 root ROOT。
421
+ // 只查 root 的 subagentsByRoot 会漏;必须对 fork 链每个节点(含 forks)查。
422
+ expect(family.subagents.some((s) => s.sessionId === SUB)).toBe(true)
423
+ expect(family.subagents.find((s) => s.sessionId === SUB)?.slug).toBe('deep-survey')
424
+ })
200
425
 
201
- describe('resolveFamily - cleanedUp(subagent 文件被 GC)', () => {
202
- it('identity 在但 fileStats 不含其 sessionId → cleanedUp=true', () => {
203
- const { headers, identities, fileStats } = q1Fixture([ROOT, FORK]) // SUB 不在 fileStats(已 GC)
204
- const index = buildFamilyIndex(headers, identities, fileStats)
426
+ it('从 fork 子代 resolve 也能关联到直接挂在其下的 subagent', () => {
427
+ const { headers, identities, fileStats } = q1Fixture()
428
+ const index = buildFamilyIndex(headers, identities, fileStats)
205
429
 
206
- const sub = resolveFamily(ROOT, index).subagents.find((s) => s.sessionId === SUB)
207
- expect(sub?.cleanedUp).toBe(true)
430
+ const family = resolveFamily(FORK, index)
431
+ expect(family.subagents.some((s) => s.sessionId === SUB)).toBe(true)
432
+ })
208
433
  })
209
434
 
210
- it('identity 在且 fileStats 含其 sessionId → cleanedUp=false', () => {
211
- const { headers, identities, fileStats } = q1Fixture() // 默认 [ROOT,FORK,SUB],SUB 存活
212
- const index = buildFamilyIndex(headers, identities, fileStats)
213
-
214
- const sub = resolveFamily(ROOT, index).subagents.find((s) => s.sessionId === SUB)
215
- expect(sub?.cleanedUp).toBe(false)
435
+ describe('resolveFamily - fork 链', () => {
436
+ it('fork 子代的 parents 含 root;root 的 forks 含 fork 子代', () => {
437
+ const { headers, identities, fileStats } = q1Fixture()
438
+ const index = buildFamilyIndex(headers, identities, fileStats)
439
+
440
+ expect(resolveFamily(FORK, index).parents.some((p) => p.sessionId === ROOT)).toBe(true)
441
+ expect(resolveFamily(ROOT, index).forks.some((f) => f.sessionId === FORK)).toBe(true)
442
+ })
443
+
444
+ it('root 无 parentSession → parents 空、forks 取 childrenOf', () => {
445
+ const { headers, identities, fileStats } = q1Fixture()
446
+ const index = buildFamilyIndex(headers, identities, fileStats)
447
+
448
+ const family = resolveFamily(ROOT, index)
449
+ expect(family.parents).toEqual([])
450
+ expect(family.forks.map((f) => f.sessionId)).toEqual([FORK])
451
+ })
452
+
453
+ it('线性多级 fork 链:C←B←A,resolveFamily(C).parents=[B,A]', () => {
454
+ const headers = [
455
+ header('A'),
456
+ header('B', { parentSession: sessionFile('A') }),
457
+ header('C', { parentSession: sessionFile('B') }),
458
+ ]
459
+ const index = buildFamilyIndex(headers, [], new Map())
460
+
461
+ const family = resolveFamily('C', index)
462
+ expect(family.parents.map((p) => p.sessionId)).toEqual(['B', 'A'])
463
+ // forks 只含直接子代(孙代 C 是 B 的子代,不是 A 的直接子代)
464
+ expect(resolveFamily('A', index).forks.map((f) => f.sessionId)).toEqual(['B'])
465
+ expect(resolveFamily('B', index).forks.map((f) => f.sessionId)).toEqual(['C'])
466
+ })
467
+
468
+ it('workflows 恒为空数组(M1,接口字段保留供 M2)', () => {
469
+ const { headers, identities, fileStats } = q1Fixture()
470
+ const index = buildFamilyIndex(headers, identities, fileStats)
471
+
472
+ expect(resolveFamily(ROOT, index).workflows).toEqual([])
473
+ })
216
474
  })
217
- })
218
475
 
219
- describe('resolveFamily - 错误', () => {
220
- it('sessionId 不在 index → 抛 Error', () => {
221
- const { headers, identities, fileStats } = q1Fixture()
222
- const index = buildFamilyIndex(headers, identities, fileStats)
476
+ describe('resolveFamily - cleanedUp(subagent 文件被 GC)', () => {
477
+ it('identity 在但 fileStats 不含其 sessionId → cleanedUp=true', () => {
478
+ const { headers, identities, fileStats } = q1Fixture([ROOT, FORK]) // SUB 不在 fileStats(已 GC)
479
+ const index = buildFamilyIndex(headers, identities, fileStats)
223
480
 
224
- expect(() => resolveFamily('unknown-session', index)).toThrow(/not found in family index/)
225
- })
226
- })
227
-
228
- // ============================================================
229
- // U4: SubagentRef 富字段(manifest 主 / P-fallback identity 回退)
230
- // 验证 buildFamilyIndex 从 identity entry.data 读 task/agent/model/status/sessionFile
231
- // 填 SubagentRef,异名映射(data.agent → agentName),检查放宽(只校验 rootSessionId+slug)。
232
- // ============================================================
233
-
234
- describe('U4: SubagentRef 富字段(buildFamilyIndex 透传)', () => {
235
- it('TC-u4-manifest-enrich: manifest 主路径,富字段全透传到 SubagentRef', () => {
236
- const identities = [
237
- subagentIdentityRich(SUB, ROOT, 'codex-research', {
238
- task: '调研 codex',
239
- agent: 'explorer',
240
- model: 'glm-5.2',
241
- status: 'completed',
242
- sessionFile: '/path/to/sub.jsonl',
243
- }),
244
- ]
245
- const index = buildFamilyIndex([header(ROOT)], identities, makeStats([ROOT, SUB]))
246
-
247
- const sub = resolveFamily(ROOT, index).subagents.find((s) => s.sessionId === SUB)
248
- expect(sub?.task).toBe('调研 codex')
249
- expect(sub?.slug).toBe('codex-research')
250
- // 异名映射核心断言:identity.data.agent → SubagentRef.agentName
251
- expect(sub?.agentName).toBe('explorer')
252
- expect(sub?.model).toBe('glm-5.2')
253
- expect(sub?.status).toBe('completed')
254
- expect(sub?.sessionFile).toBe('/path/to/sub.jsonl')
255
- expect(sub?.cleanedUp).toBe(false)
256
- })
257
-
258
- it('TC-u4-pfallback-identity: P-fallback(有 identity 无 model/status),task/agent/sessionFile 透传', () => {
259
- const identities = [
260
- subagentIdentityRich(SUB, ROOT, 'fix', {
261
- task: 'fix bug',
262
- agent: 'worker',
263
- sessionFile: '/alive/sub.jsonl',
264
- // 无 model/status(P-fallback identity 不含这两项,探针 15/15 确认)
265
- }),
266
- ]
267
- const index = buildFamilyIndex([header(ROOT)], identities, makeStats([ROOT, SUB]))
268
-
269
- const sub = resolveFamily(ROOT, index).subagents.find((s) => s.sessionId === SUB)
270
- expect(sub?.task).toBe('fix bug')
271
- expect(sub?.slug).toBe('fix')
272
- expect(sub?.agentName).toBe('worker')
273
- expect(sub?.sessionFile).toBe('/alive/sub.jsonl')
274
- // P-fallback 核心断言:model/status 必 undefined(identity 不可回退)
275
- expect(sub?.model).toBeUndefined()
276
- expect(sub?.status).toBeUndefined()
277
- })
481
+ const sub = resolveFamily(ROOT, index).subagents.find((s) => s.sessionId === SUB)
482
+ expect(sub?.cleanedUp).toBe(true)
483
+ })
278
484
 
279
- it('TC-u4-pfallback-no-identity: data 缺 rootSessionId → 检查拒掉,不入 subagentsByRoot,不抛错', () => {
280
- // 模拟无 identity(运行中/异常,尾行是 message 非 identity):data 只有 slug 无 rootSessionId
281
- const noRoot: Entry = {
282
- type: 'custom',
283
- id: 'no-root',
284
- parentId: null,
285
- customType: 'subagent-identity',
286
- data: { slug: 'dangling', task: 't' }, // 缺 rootSessionId
287
- }
288
- const index = buildFamilyIndex([header(ROOT)], [noRoot], makeStats([ROOT]))
485
+ it('identity 在且 fileStats 含其 sessionId → cleanedUp=false', () => {
486
+ const { headers, identities, fileStats } = q1Fixture() // 默认 [ROOT,FORK,SUB],SUB 存活
487
+ const index = buildFamilyIndex(headers, identities, fileStats)
289
488
 
290
- // 检查拒掉 → 不入 subagentsByRoot
291
- expect(index.subagentsByRoot.size).toBe(0)
292
- // resolveFamily 不抛错,subagents 空
293
- const family = resolveFamily(ROOT, index)
294
- expect(family.subagents).toHaveLength(0)
489
+ const sub = resolveFamily(ROOT, index).subagents.find((s) => s.sessionId === SUB)
490
+ expect(sub?.cleanedUp).toBe(false)
491
+ })
295
492
  })
296
493
 
297
- it('TC-u4-orphan-manifest: fileStats 不含 id → cleanedUp=true,富字段仍透传(GC 路径保留)', () => {
298
- const identities = [
299
- subagentIdentityRich('sa-ghost', ROOT, 'ghost-slug', {
300
- task: 'ghost task',
301
- agent: 'worker',
302
- model: 'gpt-4',
303
- status: 'completed',
304
- sessionFile: '/gc/ghost.jsonl',
305
- }),
306
- ]
307
- // fileStats 不含 'sa-ghost'(模拟 .jsonl 被 GC,manifest 残留)→ cleanedUp=true
308
- const index = buildFamilyIndex([header(ROOT)], identities, makeStats([ROOT]))
309
-
310
- const sub = resolveFamily(ROOT, index).subagents.find((s) => s.sessionId === 'sa-ghost')
311
- expect(sub?.cleanedUp).toBe(true)
312
- expect(sub?.task).toBe('ghost task')
313
- expect(sub?.agentName).toBe('worker')
314
- expect(sub?.model).toBe('gpt-4')
315
- expect(sub?.status).toBe('completed')
316
- expect(sub?.sessionFile).toBe('/gc/ghost.jsonl') // GC 路径保留(不置空)
317
- })
494
+ describe('resolveFamily - 错误', () => {
495
+ it('sessionId 不在 index → 抛 Error', () => {
496
+ const { headers, identities, fileStats } = q1Fixture()
497
+ const index = buildFamilyIndex(headers, identities, fileStats)
318
498
 
319
- it('TC-u4-recordmanifest-compat: 最小 identity(仅 rootSessionId+slug)→ 富字段全 undefined,不抛错', () => {
320
- // 模拟旧 manifest(无 task/slug/model/status/agentName)经 buildFamilyFromFs 转成的最小 identity
321
- const identities = [subagentIdentity(SUB, ROOT, 'legacy')]
322
- const index = buildFamilyIndex([header(ROOT)], identities, makeStats([ROOT, SUB]))
323
-
324
- const sub = resolveFamily(ROOT, index).subagents.find((s) => s.sessionId === SUB)
325
- expect(sub?.slug).toBe('legacy')
326
- expect(sub?.rootSessionId).toBe(ROOT)
327
- expect(sub?.task).toBeUndefined()
328
- expect(sub?.agentName).toBeUndefined()
329
- expect(sub?.model).toBeUndefined()
330
- expect(sub?.status).toBeUndefined()
331
- expect(sub?.sessionFile).toBeUndefined()
499
+ expect(() => resolveFamily('unknown-session', index)).toThrow(/not found in family index/)
500
+ })
332
501
  })
333
502
 
334
- it('isSubagentIdentityData 检查放宽:富字段部分缺失/全缺都通过(只校验 rootSessionId+slug)', () => {
335
- const identities = [
336
- subagentIdentityRich(SUB, ROOT, 's1', { task: 'only-task' }), // 部分富字段
337
- subagentIdentity(SUB2, ROOT, 's2'), // 完全无富字段(旧 manifest 形态)
338
- ]
339
- const index = buildFamilyIndex([header(ROOT)], identities, makeStats([ROOT, SUB, SUB2]))
340
-
341
- const family = resolveFamily(ROOT, index)
342
- expect(family.subagents).toHaveLength(2)
343
- const s1 = family.subagents.find((s) => s.sessionId === SUB)
344
- expect(s1?.task).toBe('only-task')
345
- expect(s1?.model).toBeUndefined()
346
- const s2 = family.subagents.find((s) => s.sessionId === SUB2)
347
- expect(s2?.task).toBeUndefined()
348
- expect(s2?.agentName).toBeUndefined()
503
+ // ============================================================
504
+ // U4: SubagentRef 富字段(manifest 主 / P-fallback identity 回退)
505
+ // 验证 buildFamilyIndex 从 identity entry.data 读 task/agent/model/status/sessionFile
506
+ // 填 SubagentRef,异名映射(data.agent → agentName),检查放宽(只校验 rootSessionId+slug)。
507
+ // ============================================================
508
+
509
+ describe('U4: SubagentRef 富字段(buildFamilyIndex 透传)', () => {
510
+ it('TC-u4-manifest-enrich: manifest 主路径,富字段全透传到 SubagentRef', () => {
511
+ const identities = [
512
+ subagentIdentityRich(SUB, ROOT, 'codex-research', {
513
+ task: '调研 codex',
514
+ agent: 'explorer',
515
+ model: 'glm-5.2',
516
+ status: 'completed',
517
+ sessionFile: '/path/to/sub.jsonl',
518
+ }),
519
+ ]
520
+ const index = buildFamilyIndex([header(ROOT)], identities, makeStats([ROOT, SUB]))
521
+
522
+ const sub = resolveFamily(ROOT, index).subagents.find((s) => s.sessionId === SUB)
523
+ expect(sub?.task).toBe('调研 codex')
524
+ expect(sub?.slug).toBe('codex-research')
525
+ // 异名映射核心断言:identity.data.agent → SubagentRef.agentName
526
+ expect(sub?.agentName).toBe('explorer')
527
+ expect(sub?.model).toBe('glm-5.2')
528
+ expect(sub?.status).toBe('completed')
529
+ expect(sub?.sessionFile).toBe('/path/to/sub.jsonl')
530
+ expect(sub?.cleanedUp).toBe(false)
531
+ })
532
+
533
+ it('TC-u4-pfallback-identity: P-fallback(有 identity 无 model/status),task/agent/sessionFile 透传', () => {
534
+ const identities = [
535
+ subagentIdentityRich(SUB, ROOT, 'fix', {
536
+ task: 'fix bug',
537
+ agent: 'worker',
538
+ sessionFile: '/alive/sub.jsonl',
539
+ // 无 model/status(P-fallback identity 不含这两项,探针 15/15 确认)
540
+ }),
541
+ ]
542
+ const index = buildFamilyIndex([header(ROOT)], identities, makeStats([ROOT, SUB]))
543
+
544
+ const sub = resolveFamily(ROOT, index).subagents.find((s) => s.sessionId === SUB)
545
+ expect(sub?.task).toBe('fix bug')
546
+ expect(sub?.slug).toBe('fix')
547
+ expect(sub?.agentName).toBe('worker')
548
+ expect(sub?.sessionFile).toBe('/alive/sub.jsonl')
549
+ // P-fallback 核心断言:model/status 必 undefined(identity 不可回退)
550
+ expect(sub?.model).toBeUndefined()
551
+ expect(sub?.status).toBeUndefined()
552
+ })
553
+
554
+ it('TC-u4-pfallback-no-identity: data 缺 rootSessionId → 检查拒掉,不入 subagentsByRoot,不抛错', () => {
555
+ // 模拟无 identity(运行中/异常,尾行是 message 非 identity):data 只有 slug 无 rootSessionId
556
+ const noRoot: Entry = {
557
+ type: 'custom',
558
+ id: 'no-root',
559
+ parentId: null,
560
+ customType: 'subagent-identity',
561
+ data: { slug: 'dangling', task: 't' }, // 缺 rootSessionId
562
+ }
563
+ const index = buildFamilyIndex([header(ROOT)], [noRoot], makeStats([ROOT]))
564
+
565
+ // 检查拒掉 → 不入 subagentsByRoot
566
+ expect(index.subagentsByRoot.size).toBe(0)
567
+ // resolveFamily 不抛错,subagents 空
568
+ const family = resolveFamily(ROOT, index)
569
+ expect(family.subagents).toHaveLength(0)
570
+ })
571
+
572
+ it('TC-u4-orphan-manifest: fileStats 不含 id → cleanedUp=true,富字段仍透传(GC 路径保留)', () => {
573
+ const identities = [
574
+ subagentIdentityRich('sa-ghost', ROOT, 'ghost-slug', {
575
+ task: 'ghost task',
576
+ agent: 'worker',
577
+ model: 'gpt-4',
578
+ status: 'completed',
579
+ sessionFile: '/gc/ghost.jsonl',
580
+ }),
581
+ ]
582
+ // fileStats 不含 'sa-ghost'(模拟 .jsonl 被 GC,manifest 残留)→ cleanedUp=true
583
+ const index = buildFamilyIndex([header(ROOT)], identities, makeStats([ROOT]))
584
+
585
+ const sub = resolveFamily(ROOT, index).subagents.find((s) => s.sessionId === 'sa-ghost')
586
+ expect(sub?.cleanedUp).toBe(true)
587
+ expect(sub?.task).toBe('ghost task')
588
+ expect(sub?.agentName).toBe('worker')
589
+ expect(sub?.model).toBe('gpt-4')
590
+ expect(sub?.status).toBe('completed')
591
+ expect(sub?.sessionFile).toBe('/gc/ghost.jsonl') // GC 路径保留(不置空)
592
+ })
593
+
594
+ it('TC-u4-recordmanifest-compat: 最小 identity(仅 rootSessionId+slug)→ 富字段全 undefined,不抛错', () => {
595
+ // 模拟旧 manifest(无 task/slug/model/status/agentName)经 buildFamilyFromFs 转成的最小 identity
596
+ const identities = [subagentIdentity(SUB, ROOT, 'legacy')]
597
+ const index = buildFamilyIndex([header(ROOT)], identities, makeStats([ROOT, SUB]))
598
+
599
+ const sub = resolveFamily(ROOT, index).subagents.find((s) => s.sessionId === SUB)
600
+ expect(sub?.slug).toBe('legacy')
601
+ expect(sub?.rootSessionId).toBe(ROOT)
602
+ expect(sub?.task).toBeUndefined()
603
+ expect(sub?.agentName).toBeUndefined()
604
+ expect(sub?.model).toBeUndefined()
605
+ expect(sub?.status).toBeUndefined()
606
+ expect(sub?.sessionFile).toBeUndefined()
607
+ })
608
+
609
+ it('isSubagentIdentityData 检查放宽:富字段部分缺失/全缺都通过(只校验 rootSessionId+slug)', () => {
610
+ const identities = [
611
+ subagentIdentityRich(SUB, ROOT, 's1', { task: 'only-task' }), // 部分富字段
612
+ subagentIdentity(SUB2, ROOT, 's2'), // 完全无富字段(旧 manifest 形态)
613
+ ]
614
+ const index = buildFamilyIndex([header(ROOT)], identities, makeStats([ROOT, SUB, SUB2]))
615
+
616
+ const family = resolveFamily(ROOT, index)
617
+ expect(family.subagents).toHaveLength(2)
618
+ const s1 = family.subagents.find((s) => s.sessionId === SUB)
619
+ expect(s1?.task).toBe('only-task')
620
+ expect(s1?.model).toBeUndefined()
621
+ const s2 = family.subagents.find((s) => s.sessionId === SUB2)
622
+ expect(s2?.task).toBeUndefined()
623
+ expect(s2?.agentName).toBeUndefined()
624
+ })
349
625
  })
350
- })
626
+ });