@zhushanwen/pi-session-reader 0.13.1 → 0.13.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -5
- package/src/__tests__/entry-anchor.test.ts +704 -324
- package/src/__tests__/family.test.ts +588 -312
- package/src/__tests__/{cross-package-subagent-core.test.ts → path-encoding.test.ts} +194 -30
- package/src/__tests__/roots-merged.test.ts +4919 -0
- package/src/__tests__/roots.test.ts +1986 -432
- package/src/__tests__/tool-handler.test.ts +872 -3216
- package/src/__tests__/tree.test.ts +738 -101
- package/src/__tests__/archive-ledger.test.ts +0 -447
- package/src/__tests__/execution-tree.test.ts +0 -898
- package/src/__tests__/family-zcode.test.ts +0 -265
- package/src/__tests__/find.test.ts +0 -802
- package/src/__tests__/rebuild-manifest-dual-write.test.ts +0 -188
- package/src/__tests__/render.test.ts +0 -481
- package/src/__tests__/result.test.ts +0 -347
- package/src/__tests__/subagents.test.ts +0 -668
- package/src/__tests__/summary.test.ts +0 -223
- package/src/__tests__/turns.test.ts +0 -144
- package/src/__tests__/zcode-manifest.test.ts +0 -372
- package/src/__tests__/zcode-routing.test.ts +0 -772
|
@@ -1,350 +1,626 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
//
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const index = buildFamilyIndex(headers, identities, fileStats)
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
43
|
+
})
|
|
85
44
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
45
|
+
function header(id: string): FamilyEntry {
|
|
46
|
+
return { type: 'session', id, parentId: null, cwd: '/proj' }
|
|
47
|
+
}
|
|
89
48
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
118
|
-
|
|
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
|
|
304
|
+
id,
|
|
121
305
|
parentId: null,
|
|
122
306
|
customType: 'subagent-identity',
|
|
123
|
-
data: { slug
|
|
307
|
+
data: { rootSessionId, slug },
|
|
124
308
|
}
|
|
125
|
-
|
|
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
|
-
|
|
156
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
166
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
194
|
-
const { headers, identities, fileStats } = q1Fixture()
|
|
195
|
-
const index = buildFamilyIndex(headers, identities, fileStats)
|
|
418
|
+
const family = resolveFamily(ROOT, index)
|
|
196
419
|
|
|
197
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
207
|
-
|
|
430
|
+
const family = resolveFamily(FORK, index)
|
|
431
|
+
expect(family.subagents.some((s) => s.sessionId === SUB)).toBe(true)
|
|
432
|
+
})
|
|
208
433
|
})
|
|
209
434
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
320
|
-
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
+
});
|