@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.
- 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__/index.test.ts +17 -1
- 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/tui/tool-render.ts +3 -28
- 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,487 +1,2041 @@
|
|
|
1
|
+
// roots.test.ts —— session-reader C02 簇合并产物(round5-G)。
|
|
2
|
+
// 共同被测业务模块:`src/discovery/roots.ts`。mock 相容:全簇零 vi.mock。
|
|
3
|
+
// 顶层正文按来源包 describe 纯前缀分节,it 层名零改动。
|
|
4
|
+
//
|
|
5
|
+
// 来源:
|
|
6
|
+
// - src/__tests__/execution-tree.test.ts(14 用例,skip 0)
|
|
7
|
+
// - src/__tests__/roots.test.ts(21 用例,skip 0)
|
|
8
|
+
// - src/__tests__/subagents.test.ts(21 用例,skip 0)
|
|
9
|
+
|
|
1
10
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
2
11
|
import { tmpdir } from 'node:os'
|
|
3
12
|
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
|
|
4
13
|
import { join, dirname } from 'node:path'
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
resolveStoreSubagentSessionsDir,
|
|
9
|
-
normalizeLiveSessionDir,
|
|
10
|
-
type SessionRoot,
|
|
11
|
-
type SessionRootSignals,
|
|
12
|
-
} from '../discovery/roots.js'
|
|
13
|
-
import { buildFamilyFromFs } from '../discovery/subagents.js'
|
|
14
|
+
import { buildExecutionTree, formatExecutionTreeText, type ExecutionTreeNode } from '../core/execution-tree.js'
|
|
15
|
+
import { resolveStoreSubagentSessionsDir, resolveStoreSubagentRecordsDir, resolveSessionRoots, resolveSessionStoreRoot, normalizeLiveSessionDir, type SessionRoot, type SessionRootSignals } from '../discovery/roots.js'
|
|
16
|
+
import { buildFamilyFromFs, listRecordManifests, extractSessionIdFromFilename, type RecordManifest } from '../discovery/subagents.js'
|
|
14
17
|
import { FIX_E6 } from './fixtures.js'
|
|
18
|
+
import { parseSessionHeader, readSessionHeaderFirstLine } from '../discovery/session-header.js'
|
|
15
19
|
|
|
16
|
-
//
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
async function writeJsonl(path: string, content = '{"type":"session","id":"x"}\n') {
|
|
36
|
-
await mkdir(dirname(path), { recursive: true })
|
|
37
|
-
await writeFile(path, content)
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** 真实形态的 cwd 编码目录名(encodeCwd:`--` 开头 `--` 结尾) */
|
|
41
|
-
const SLUG = '--Users-foo--'
|
|
42
|
-
|
|
43
|
-
/** taiji 用例的存储树分区 sid fixture(合法 pi sid 形态)。 */
|
|
44
|
-
const TAIJI_SID = '0aaaaaaa-bbbb-7ccc-dddd-0000000000t1'
|
|
45
|
-
|
|
46
|
-
describe('main 根过滤(原 listMainSessions 等价式,A3 退役后保覆盖)', () => {
|
|
47
|
-
let dir: string
|
|
48
|
-
|
|
49
|
-
beforeEach(async () => {
|
|
50
|
-
dir = await mkdtemp(join(tmpdir(), 'roots-test-'))
|
|
51
|
-
})
|
|
52
|
-
afterEach(async () => {
|
|
53
|
-
await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
54
|
-
})
|
|
20
|
+
// ======== 来源:execution-tree.test.ts ========
|
|
21
|
+
describe("execution-tree", () => {
|
|
22
|
+
/**
|
|
23
|
+
* M3b U7 buildExecutionTree 单测(design m3b 6 testCases)。
|
|
24
|
+
*
|
|
25
|
+
* 测试框架 vitest(禁止 node:test/tsx)。fixture 自包含(mkdtemp 临时目录),复用
|
|
26
|
+
* subagents.test.ts 验证过的 fixture 拓扑(main session + subagent session + records manifest
|
|
27
|
+
* + workflow-state 文件 + workflow-state-link)。
|
|
28
|
+
*
|
|
29
|
+
* 覆盖:
|
|
30
|
+
* - TC-m3b-nested-tree:任意深度 subagent↔workflow-call 相互嵌套(parentRecordId 精确链)
|
|
31
|
+
* - TC-m3b-flat-fallback:旧机制扁平回退(全无 parentRecordId)
|
|
32
|
+
* - TC-m3b-cycle-detection:workflow 指针环(A→B→A),visited Set 防环
|
|
33
|
+
* - TC-m3b-single-node:单节点树(无后代,ES5)
|
|
34
|
+
* - TC-m3b-source-priority:parentRecordId 三级数据源优先级(manifest>identity>flat,DM4)
|
|
35
|
+
* - TC-m3b-real-data-guard:真实形态数据检查(合成 fixture):mkdtemp 临时目录仿真真实
|
|
36
|
+
* agentDir 布局(时间戳前缀文件名 / 新旧机制混合 / 孤儿 manifest / 坏 manifest / 外来
|
|
37
|
+
* 家族),guard 语义断言——解析不抛错 / 树非空 / 结构自洽,不锁 sourceMode 具体值
|
|
38
|
+
*/
|
|
55
39
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
expect(result[0].mtime).toBeTypeOf('number')
|
|
67
|
-
expect(result[0].mtime).toBeGreaterThan(0)
|
|
68
|
-
expect(result[0].size).toBeTypeOf('number')
|
|
69
|
-
expect(result[0].size).toBeGreaterThan(0)
|
|
70
|
-
})
|
|
40
|
+
// ---- fixture 常量(uuid 特征,互不为子串,满足 extractSessionIdFromFilename)----
|
|
41
|
+
const MAIN = '0aaaaaaa-bbbb-7ccc-dddd-000000000001'
|
|
42
|
+
const A_REAL = '0aaaaaaa-bbbb-7ccc-dddd-000000000002'
|
|
43
|
+
const B_REAL = '0aaaaaaa-bbbb-7ccc-dddd-000000000003'
|
|
44
|
+
const C_REAL = '0aaaaaaa-bbbb-7ccc-dddd-000000000004'
|
|
45
|
+
const D_REAL = '0aaaaaaa-bbbb-7ccc-dddd-000000000005'
|
|
46
|
+
const X_REAL = '0aaaaaaa-bbbb-7ccc-dddd-000000000006'
|
|
47
|
+
// workflow-state-link runId
|
|
48
|
+
const RUN_A = 'wf-1786121304924-runA'
|
|
49
|
+
const RUN_B = 'wf-1786121304924-runB'
|
|
71
50
|
|
|
72
|
-
|
|
73
|
-
await mkdir(join(dir, 'sessions', 'slug', 'deep', 'nested'), { recursive: true })
|
|
74
|
-
await writeFile(join(dir, 'sessions', 'slug', 'deep', 'nested', 'd.jsonl'), '{}\n')
|
|
75
|
-
// 同 slug 直接层也放一个,验证同一 slug 下平铺与嵌套并存
|
|
76
|
-
await writeFile(join(dir, 'sessions', 'slug', 'top.jsonl'), '{}\n')
|
|
77
|
-
|
|
78
|
-
const result = await mainSessionFiles(dir)
|
|
79
|
-
const names = result.map((m) => m.path.split('/').pop()!)
|
|
80
|
-
expect(names).toContain('d.jsonl')
|
|
81
|
-
expect(names).toContain('top.jsonl')
|
|
82
|
-
expect(result).toHaveLength(2)
|
|
83
|
-
})
|
|
51
|
+
// ---- fixture helpers(自包含,拓扑同 subagents.test.ts)----
|
|
84
52
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
await writeFile(join(dir, 'sessions', slug, 'real.jsonl'), '{"type":"session"}\n')
|
|
89
|
-
await writeFile(
|
|
90
|
-
join(dir, 'sessions', slug, 'workflow-state', 'wf-abc.jsonl'),
|
|
91
|
-
'{"v":"wf-run-v1"}\n',
|
|
92
|
-
)
|
|
93
|
-
|
|
94
|
-
const result = await mainSessionFiles(dir)
|
|
95
|
-
expect(result).toHaveLength(1)
|
|
96
|
-
expect(result[0].path.endsWith('real.jsonl')).toBe(true)
|
|
97
|
-
expect(result.every((m) => !m.path.includes('workflow-state'))).toBe(true)
|
|
98
|
-
})
|
|
53
|
+
async function makeAgentDir(): Promise<string> {
|
|
54
|
+
return mkdtemp(join(tmpdir(), 'exec-tree-test-'))
|
|
55
|
+
}
|
|
99
56
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
57
|
+
/** 写 main session 文件(首行 header)。 */
|
|
58
|
+
async function writeMainSession(dir: string, slug: string, id: string): Promise<string> {
|
|
59
|
+
const sessionDir = join(dir, 'sessions', slug)
|
|
60
|
+
await mkdir(sessionDir, { recursive: true })
|
|
61
|
+
const path = join(sessionDir, `${id}.jsonl`)
|
|
62
|
+
await writeFile(path, JSON.stringify({ type: 'session', id, cwd: `/proj/${slug}` }) + '\n')
|
|
63
|
+
return path
|
|
64
|
+
}
|
|
103
65
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
66
|
+
/**
|
|
67
|
+
* 写 subagent session 文件:首行 header(真实 id)+ 占位 message + 尾行 identity。
|
|
68
|
+
* identity 尾行含 rootSessionId/slug/agent,可选 parentRecordId(数据源 ② 测试用)。
|
|
69
|
+
* fileName 可选:默认 `<realId>.jsonl`;真实形态检查用例传时间戳前缀名(<ts>_<id>.jsonl)。
|
|
70
|
+
*/
|
|
71
|
+
async function writeSubagentSession(
|
|
72
|
+
dir: string,
|
|
73
|
+
rootSessionId: string,
|
|
74
|
+
realId: string,
|
|
75
|
+
identity: {
|
|
76
|
+
rootSessionId: string
|
|
77
|
+
slug: string
|
|
78
|
+
agent?: string
|
|
79
|
+
parentRecordId?: string
|
|
80
|
+
task?: string
|
|
81
|
+
},
|
|
82
|
+
fileName: string = `${realId}.jsonl`,
|
|
83
|
+
): Promise<string> {
|
|
84
|
+
const sessionDir = resolveStoreSubagentSessionsDir(dir, rootSessionId)
|
|
85
|
+
await mkdir(sessionDir, { recursive: true })
|
|
86
|
+
const path = join(sessionDir, fileName)
|
|
87
|
+
const data: Record<string, unknown> = {
|
|
88
|
+
id: `sa-${realId.slice(0, 8)}`,
|
|
89
|
+
rootSessionId: identity.rootSessionId,
|
|
90
|
+
slug: identity.slug,
|
|
91
|
+
agent: identity.agent ?? 'explorer',
|
|
92
|
+
mode: 'sync',
|
|
93
|
+
task: identity.task ?? 'do work',
|
|
94
|
+
startedAt: 1,
|
|
95
|
+
}
|
|
96
|
+
if (identity.parentRecordId !== undefined) data.parentRecordId = identity.parentRecordId
|
|
97
|
+
const lines = [
|
|
98
|
+
JSON.stringify({ type: 'session', id: realId, cwd: `/proj/${rootSessionId}` }),
|
|
99
|
+
JSON.stringify({
|
|
100
|
+
type: 'message',
|
|
101
|
+
id: 'm1',
|
|
102
|
+
parentId: realId,
|
|
103
|
+
message: { role: 'user', content: 'do work' },
|
|
104
|
+
}),
|
|
105
|
+
JSON.stringify({ type: 'custom', customType: 'subagent-identity', data }),
|
|
106
|
+
]
|
|
107
|
+
await writeFile(path, lines.join('\n') + '\n')
|
|
108
|
+
return path
|
|
109
|
+
}
|
|
107
110
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
expect(result.every((m) => m.mtime > 0 && m.size > 0)).toBe(true)
|
|
129
|
-
})
|
|
130
|
-
})
|
|
111
|
+
/** 写 records manifest。fields 含必填 + 可选 parentRecordId(数据源 ①)。 */
|
|
112
|
+
async function writeRecordManifest(
|
|
113
|
+
dir: string,
|
|
114
|
+
rootSessionId: string,
|
|
115
|
+
id: string,
|
|
116
|
+
fields: {
|
|
117
|
+
rootSessionId: string
|
|
118
|
+
agentName?: string
|
|
119
|
+
sessionFile: string
|
|
120
|
+
task?: string
|
|
121
|
+
slug?: string
|
|
122
|
+
model?: string
|
|
123
|
+
status?: string
|
|
124
|
+
parentRecordId?: string
|
|
125
|
+
},
|
|
126
|
+
): Promise<void> {
|
|
127
|
+
const recordsDir = resolveStoreSubagentRecordsDir(dir, rootSessionId)
|
|
128
|
+
await mkdir(recordsDir, { recursive: true })
|
|
129
|
+
await writeFile(join(recordsDir, `${id}.json`), JSON.stringify({ id, ...fields }))
|
|
130
|
+
}
|
|
131
131
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
132
|
+
/** [ADR-0095] v2 单档 fixture:写 record 流(agent-settled 帧提 calls)并向 session 文件
|
|
133
|
+
* 追加 v2 注册条目——替代已删除的 wf-state 快照 + workflow-state-link 组合。 */
|
|
134
|
+
async function writeWfV2Run(
|
|
135
|
+
dir: string,
|
|
136
|
+
cwdSlug: string,
|
|
137
|
+
sessionPath: string,
|
|
138
|
+
sessionId: string,
|
|
139
|
+
runId: string,
|
|
140
|
+
callSessionFiles: string[],
|
|
141
|
+
): Promise<string> {
|
|
142
|
+
const wfDir = join(dir, 'sessions', cwdSlug, 'workflow-state')
|
|
143
|
+
await mkdir(wfDir, { recursive: true })
|
|
144
|
+
const recordPath = join(wfDir, `${runId}.record.jsonl`)
|
|
145
|
+
const lines = [
|
|
146
|
+
JSON.stringify({ type: 'run-created', seq: 1, ts: 1758000000000, runId, workflowName: 'tree-flow', argsSummary: '{}' }),
|
|
147
|
+
...callSessionFiles.map((sf, i) =>
|
|
148
|
+
JSON.stringify({ type: 'agent-settled', seq: 2 + i, ts: 1758000001000 + i, taskIndex: i, attempt: 1, outcome: 'done', durationMs: 100, result: { content: 'ok', sessionFile: sf } }),
|
|
149
|
+
),
|
|
150
|
+
]
|
|
151
|
+
await writeFile(recordPath, lines.join('\n') + '\n')
|
|
152
|
+
const line = JSON.stringify({
|
|
153
|
+
type: 'custom',
|
|
154
|
+
id: `wf-reg-${runId}`,
|
|
155
|
+
parentId: sessionId,
|
|
156
|
+
customType: 'workflow-record',
|
|
157
|
+
data: {
|
|
158
|
+
v: 2,
|
|
159
|
+
kind: 'registered',
|
|
160
|
+
runId,
|
|
161
|
+
workflowName: 'tree-flow',
|
|
162
|
+
scriptName: 'tree-flow',
|
|
163
|
+
slug: 'tree',
|
|
164
|
+
startedAt: 1758000000000,
|
|
165
|
+
recordPath,
|
|
166
|
+
},
|
|
167
|
+
timestamp: '2026-09-28T00:00:00Z',
|
|
168
|
+
})
|
|
169
|
+
await writeFile(sessionPath, line + '\n', { flag: 'a' })
|
|
170
|
+
return recordPath
|
|
171
|
+
}
|
|
135
172
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
173
|
+
/** 在节点树里按 type+sessionId 查找节点(DFS)。 */
|
|
174
|
+
function findNode(
|
|
175
|
+
node: ExecutionTreeNode,
|
|
176
|
+
type: string,
|
|
177
|
+
sidPrefix: string,
|
|
178
|
+
): ExecutionTreeNode | undefined {
|
|
179
|
+
if (node.type === type && node.sessionId.startsWith(sidPrefix)) return node
|
|
180
|
+
for (const c of node.children) {
|
|
181
|
+
const found = findNode(c, type, sidPrefix)
|
|
182
|
+
if (found) return found
|
|
183
|
+
}
|
|
184
|
+
return undefined
|
|
185
|
+
}
|
|
143
186
|
|
|
144
|
-
|
|
145
|
-
|
|
187
|
+
// ============================================================
|
|
188
|
+
// fixture 用例
|
|
189
|
+
// ============================================================
|
|
146
190
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
await writeFile(join(sessionsDir, 'c.jsonl'), '{"type":"session"}\n')
|
|
151
|
-
await writeFile(join(sessionsDir, 'c.jsonl.finalized'), '{"type":"session"}\n')
|
|
191
|
+
describe('buildExecutionTree - fixture', () => {
|
|
192
|
+
let dataDir: string
|
|
193
|
+
let dir: string // agentDir = <dataDir>/agent(生产拓扑复刻)
|
|
152
194
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
195
|
+
beforeEach(async () => {
|
|
196
|
+
dataDir = await makeAgentDir()
|
|
197
|
+
dir = join(dataDir, 'agent')
|
|
198
|
+
})
|
|
199
|
+
afterEach(async () => {
|
|
200
|
+
await rm(dataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('TC-m3b-nested-tree:main→subagent A→{workflow-call B, subagent C} 相互嵌套(parentRecordId 精确链)', async () => {
|
|
204
|
+
await writeMainSession(dir, '--main-cwd--', MAIN)
|
|
205
|
+
// subagent A(顶层,parentRecordId=undefined),sessionFile 含 workflow-state-link → call session B
|
|
206
|
+
const saA = await writeSubagentSession(dir, MAIN, A_REAL, {
|
|
207
|
+
rootSessionId: MAIN,
|
|
208
|
+
slug: 'sub-a',
|
|
209
|
+
})
|
|
210
|
+
const recAId = 'sa-record-A'
|
|
211
|
+
await writeRecordManifest(dir, MAIN, recAId, {
|
|
212
|
+
rootSessionId: MAIN,
|
|
213
|
+
agentName: 'explorer',
|
|
214
|
+
sessionFile: saA,
|
|
215
|
+
status: 'completed',
|
|
216
|
+
parentRecordId: undefined,
|
|
217
|
+
})
|
|
218
|
+
// subagent C(A 的后代,parentRecordId=recAId,精确链)
|
|
219
|
+
const saC = await writeSubagentSession(dir, MAIN, C_REAL, {
|
|
220
|
+
rootSessionId: MAIN,
|
|
221
|
+
slug: 'sub-c',
|
|
222
|
+
})
|
|
223
|
+
await writeRecordManifest(dir, MAIN, 'sa-record-C', {
|
|
224
|
+
rootSessionId: MAIN,
|
|
225
|
+
agentName: 'worker',
|
|
226
|
+
sessionFile: saC,
|
|
227
|
+
status: 'completed',
|
|
228
|
+
parentRecordId: recAId,
|
|
229
|
+
})
|
|
230
|
+
// workflow-call B:A 的 sessionFile 的 v2 注册条目 → record 流 → calls[].sessionFile = SB
|
|
231
|
+
const sbB = join(
|
|
232
|
+
resolveStoreSubagentSessionsDir(dir, MAIN),
|
|
233
|
+
`2026-08-07T16-49-48-393Z_${B_REAL}.jsonl`,
|
|
234
|
+
)
|
|
235
|
+
// B call session 文件需存在(resolveWorkflows 读 call sessionFile;这里只需路径,不读内容建树)
|
|
236
|
+
await mkdir(resolveStoreSubagentSessionsDir(dir, MAIN), { recursive: true })
|
|
237
|
+
await writeFile(sbB, JSON.stringify({ type: 'session', id: B_REAL, cwd: '/proj/wf' }) + '\n')
|
|
238
|
+
await writeWfV2Run(dir, '--main-cwd--', saA, A_REAL, RUN_A, [sbB])
|
|
239
|
+
|
|
240
|
+
const tree = await buildExecutionTree(MAIN, dir)
|
|
241
|
+
|
|
242
|
+
// 根节点
|
|
243
|
+
expect(tree.root.type).toBe('main')
|
|
244
|
+
expect(tree.root.sessionId).toBe(MAIN)
|
|
245
|
+
// root.children 含 A(subagent)
|
|
246
|
+
const nodeA = findNode(tree.root, 'subagent', A_REAL)
|
|
247
|
+
expect(nodeA).toBeDefined()
|
|
248
|
+
expect(tree.root.children.some((c) => c === nodeA)).toBe(true)
|
|
249
|
+
// A.children 含 B(workflow-call,runId/stateFile)
|
|
250
|
+
const nodeB = findNode(tree.root, 'workflow-call', B_REAL)
|
|
251
|
+
expect(nodeB).toBeDefined()
|
|
252
|
+
expect(nodeA!.children.some((c) => c === nodeB)).toBe(true)
|
|
253
|
+
expect(nodeB!.runId).toBe(RUN_A)
|
|
254
|
+
// A.children 含 C(subagent,parentRecordId=recAId,严格 parentRecordId 链挂 A 下)
|
|
255
|
+
const nodeC = findNode(tree.root, 'subagent', C_REAL)
|
|
256
|
+
expect(nodeC).toBeDefined()
|
|
257
|
+
expect(nodeA!.children.some((c) => c === nodeC)).toBe(true)
|
|
258
|
+
expect(nodeC!.parentRecordId).toBe(recAId)
|
|
259
|
+
// 树规模与精度
|
|
260
|
+
expect(tree.totalNodes).toBe(4) // main + A + B + C
|
|
261
|
+
expect(tree.maxDepth).toBe(2) // root=0, A=1, B/C=2
|
|
262
|
+
expect(tree.sourceMode).toBe('precise')
|
|
263
|
+
expect(tree.truncated).toBe(false)
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
it('TC-m3b-flat-fallback:旧机制全无 parentRecordId → 全挂 main(扁平)+ flat-fallback', async () => {
|
|
267
|
+
await writeMainSession(dir, '--main-cwd--', MAIN)
|
|
268
|
+
// 3 个顶层 subagent,全无 parentRecordId(旧机制)
|
|
269
|
+
const saA = await writeSubagentSession(dir, MAIN, A_REAL, {
|
|
270
|
+
rootSessionId: MAIN,
|
|
271
|
+
slug: 'a',
|
|
272
|
+
})
|
|
273
|
+
await writeRecordManifest(dir, MAIN, 'sa-A', {
|
|
274
|
+
rootSessionId: MAIN,
|
|
275
|
+
agentName: 'explorer',
|
|
276
|
+
sessionFile: saA,
|
|
277
|
+
})
|
|
278
|
+
const saB = await writeSubagentSession(dir, MAIN, B_REAL, {
|
|
279
|
+
rootSessionId: MAIN,
|
|
280
|
+
slug: 'b',
|
|
281
|
+
})
|
|
282
|
+
await writeRecordManifest(dir, MAIN, 'sa-B', {
|
|
283
|
+
rootSessionId: MAIN,
|
|
284
|
+
agentName: 'explorer',
|
|
285
|
+
sessionFile: saB,
|
|
286
|
+
})
|
|
287
|
+
const saC = await writeSubagentSession(dir, MAIN, C_REAL, {
|
|
288
|
+
rootSessionId: MAIN,
|
|
289
|
+
slug: 'c',
|
|
290
|
+
})
|
|
291
|
+
await writeRecordManifest(dir, MAIN, 'sa-C', {
|
|
292
|
+
rootSessionId: MAIN,
|
|
293
|
+
agentName: 'explorer',
|
|
294
|
+
sessionFile: saC,
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
const tree = await buildExecutionTree(MAIN, dir)
|
|
298
|
+
|
|
299
|
+
// 全挂 main(flat)
|
|
300
|
+
expect(tree.root.children).toHaveLength(3)
|
|
301
|
+
expect(tree.root.children.every((c) => c.type === 'subagent')).toBe(true)
|
|
302
|
+
// 不丢弃任何 record
|
|
303
|
+
expect(tree.totalNodes).toBe(4) // main + 3 subagent
|
|
304
|
+
expect(tree.sourceMode).toBe('flat-fallback')
|
|
305
|
+
expect(tree.truncated).toBe(false)
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
it('TC-m3b-cycle-detection:workflow 指针环 A→B→A,visited Set 防环 + truncated', async () => {
|
|
309
|
+
await writeMainSession(dir, '--main-cwd--', MAIN)
|
|
310
|
+
// A(顶层),A 的 workflow call → SB
|
|
311
|
+
const saA = await writeSubagentSession(dir, MAIN, A_REAL, {
|
|
312
|
+
rootSessionId: MAIN,
|
|
313
|
+
slug: 'a',
|
|
314
|
+
})
|
|
315
|
+
await writeRecordManifest(dir, MAIN, 'sa-A', {
|
|
316
|
+
rootSessionId: MAIN,
|
|
317
|
+
agentName: 'explorer',
|
|
318
|
+
sessionFile: saA,
|
|
319
|
+
})
|
|
320
|
+
// B(顶层),B 的 workflow call → SA(指回 A,成环)
|
|
321
|
+
const saB = await writeSubagentSession(dir, MAIN, B_REAL, {
|
|
322
|
+
rootSessionId: MAIN,
|
|
323
|
+
slug: 'b',
|
|
324
|
+
})
|
|
325
|
+
await writeRecordManifest(dir, MAIN, 'sa-B', {
|
|
326
|
+
rootSessionId: MAIN,
|
|
327
|
+
agentName: 'explorer',
|
|
328
|
+
sessionFile: saB,
|
|
329
|
+
})
|
|
330
|
+
await writeWfV2Run(dir, '--main-cwd--', saA, A_REAL, RUN_A, [saB])
|
|
331
|
+
await writeWfV2Run(dir, '--main-cwd--', saB, B_REAL, RUN_B, [saA])
|
|
332
|
+
|
|
333
|
+
const tree = await buildExecutionTree(MAIN, dir)
|
|
334
|
+
|
|
335
|
+
// 环检测:truncated=true,不抛错
|
|
336
|
+
expect(tree.truncated).toBe(true)
|
|
337
|
+
// root.children 含 A, B(正常分支继续建树)
|
|
338
|
+
expect(tree.root.children).toHaveLength(2)
|
|
339
|
+
// A 下有 workflow-call 指向 B session;B 的 workflow 指回 A 被 visited 跳过(不无限递归)
|
|
340
|
+
const nodeA = findNode(tree.root, 'subagent', A_REAL)
|
|
341
|
+
expect(nodeA).toBeDefined()
|
|
342
|
+
// A 至少展开了 workflow-call(B session 的 call)
|
|
343
|
+
expect(nodeA!.children.some((c) => c.type === 'workflow-call')).toBe(true)
|
|
344
|
+
})
|
|
158
345
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
346
|
+
it('TC-m3b-single-node:无 subagent record、无 workflow → 单节点树(ES5)', async () => {
|
|
347
|
+
await writeMainSession(dir, '--main-cwd--', MAIN)
|
|
348
|
+
|
|
349
|
+
const tree = await buildExecutionTree(MAIN, dir)
|
|
350
|
+
|
|
351
|
+
expect(tree.root.type).toBe('main')
|
|
352
|
+
expect(tree.root.sessionId).toBe(MAIN)
|
|
353
|
+
expect(tree.root.children).toEqual([])
|
|
354
|
+
expect(tree.totalNodes).toBe(1)
|
|
355
|
+
expect(tree.maxDepth).toBe(0)
|
|
356
|
+
expect(tree.sourceMode).toBe('precise') // 无 record → 确定无后代(非回退),ES5
|
|
357
|
+
expect(tree.truncated).toBe(false)
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
it('TC-m3b-source-priority:parentRecordId 三级数据源 manifest>identity>flat(DM4)', async () => {
|
|
361
|
+
await writeMainSession(dir, '--main-cwd--', MAIN)
|
|
362
|
+
// X:顶层 subagent(挂 main)
|
|
363
|
+
const saX = await writeSubagentSession(dir, MAIN, A_REAL, {
|
|
364
|
+
rootSessionId: MAIN,
|
|
365
|
+
slug: 'x',
|
|
366
|
+
})
|
|
367
|
+
await writeRecordManifest(dir, MAIN, 'sa-X', {
|
|
368
|
+
rootSessionId: MAIN,
|
|
369
|
+
agentName: 'explorer',
|
|
370
|
+
sessionFile: saX,
|
|
371
|
+
})
|
|
372
|
+
// Y:manifest.parentRecordId=sa-X(数据源 ①,精确挂 X)
|
|
373
|
+
const saY = await writeSubagentSession(dir, MAIN, B_REAL, {
|
|
374
|
+
rootSessionId: MAIN,
|
|
375
|
+
slug: 'y',
|
|
376
|
+
})
|
|
377
|
+
await writeRecordManifest(dir, MAIN, 'sa-Y', {
|
|
378
|
+
rootSessionId: MAIN,
|
|
379
|
+
agentName: 'worker',
|
|
380
|
+
sessionFile: saY,
|
|
381
|
+
parentRecordId: 'sa-X',
|
|
382
|
+
})
|
|
383
|
+
// Z:manifest 无 parentRecordId,但 identity.data.parentRecordId=sa-X(数据源 ②,精确挂 X)
|
|
384
|
+
const saZ = await writeSubagentSession(dir, MAIN, C_REAL, {
|
|
385
|
+
rootSessionId: MAIN,
|
|
386
|
+
slug: 'z',
|
|
387
|
+
parentRecordId: 'sa-X', // 写入 identity.data.parentRecordId
|
|
388
|
+
})
|
|
389
|
+
await writeRecordManifest(dir, MAIN, 'sa-Z', {
|
|
390
|
+
rootSessionId: MAIN,
|
|
391
|
+
agentName: 'worker',
|
|
392
|
+
sessionFile: saZ,
|
|
393
|
+
// manifest 无 parentRecordId → 走 ② identity
|
|
394
|
+
})
|
|
395
|
+
// W:manifest + identity 都无 parentRecordId(数据源 ③,flat 挂 main)
|
|
396
|
+
const saW = await writeSubagentSession(dir, MAIN, '000000000005', {
|
|
397
|
+
rootSessionId: MAIN,
|
|
398
|
+
slug: 'w',
|
|
399
|
+
})
|
|
400
|
+
await writeRecordManifest(dir, MAIN, 'sa-W', {
|
|
401
|
+
rootSessionId: MAIN,
|
|
402
|
+
agentName: 'worker',
|
|
403
|
+
sessionFile: saW,
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
const tree = await buildExecutionTree(MAIN, dir)
|
|
407
|
+
|
|
408
|
+
// sourceMode=precise(Y/Z 有精确链)
|
|
409
|
+
expect(tree.sourceMode).toBe('precise')
|
|
410
|
+
// X 挂 main(顶层)
|
|
411
|
+
const nodeX = findNode(tree.root, 'subagent', A_REAL)
|
|
412
|
+
expect(nodeX).toBeDefined()
|
|
413
|
+
expect(tree.root.children.some((c) => c === nodeX)).toBe(true)
|
|
414
|
+
// Y 挂 X(manifest.parentRecordId=sa-X,①)
|
|
415
|
+
const nodeY = findNode(tree.root, 'subagent', B_REAL)
|
|
416
|
+
expect(nodeY).toBeDefined()
|
|
417
|
+
expect(nodeX!.children.some((c) => c === nodeY)).toBe(true)
|
|
418
|
+
expect(nodeY!.parentRecordId).toBe('sa-X')
|
|
419
|
+
// Z 挂 X(identity.data.parentRecordId=sa-X,②)
|
|
420
|
+
const nodeZ = findNode(tree.root, 'subagent', C_REAL)
|
|
421
|
+
expect(nodeZ).toBeDefined()
|
|
422
|
+
expect(nodeX!.children.some((c) => c === nodeZ)).toBe(true)
|
|
423
|
+
expect(nodeZ!.parentRecordId).toBe('sa-X')
|
|
424
|
+
// W 挂 main(flat,③)
|
|
425
|
+
const nodeW = findNode(tree.root, 'subagent', '000000000005')
|
|
426
|
+
expect(nodeW).toBeDefined()
|
|
427
|
+
expect(tree.root.children.some((c) => c === nodeW)).toBe(true)
|
|
428
|
+
expect(nodeW!.parentRecordId).toBeUndefined()
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
it('TC-m3b-mf1-dedup:call session 有 manifest(parentRecordId 链 + wf 指针双命中)只出现一次(MF-1)', async () => {
|
|
432
|
+
await writeMainSession(dir, '--main-cwd--', MAIN)
|
|
433
|
+
// A(顶层,parentRecordId=undefined),sessionFile 含 workflow-state-link → call session B
|
|
434
|
+
const saA = await writeSubagentSession(dir, MAIN, A_REAL, {
|
|
435
|
+
rootSessionId: MAIN,
|
|
436
|
+
slug: 'sub-a',
|
|
437
|
+
})
|
|
438
|
+
const recAId = 'sa-record-A'
|
|
439
|
+
await writeRecordManifest(dir, MAIN, recAId, {
|
|
440
|
+
rootSessionId: MAIN,
|
|
441
|
+
agentName: 'explorer',
|
|
442
|
+
sessionFile: saA,
|
|
443
|
+
status: 'completed',
|
|
444
|
+
parentRecordId: undefined,
|
|
445
|
+
})
|
|
446
|
+
// B:A 的 subagent 后代(parentRecordId=recA)+ A 的 workflow call 目标(双命中,MF-1 场景)
|
|
447
|
+
const sbB = join(
|
|
448
|
+
resolveStoreSubagentSessionsDir(dir, MAIN),
|
|
449
|
+
`2026-08-07T16-49-48-393Z_${B_REAL}.jsonl`,
|
|
450
|
+
)
|
|
451
|
+
await mkdir(resolveStoreSubagentSessionsDir(dir, MAIN), { recursive: true })
|
|
452
|
+
await writeFile(sbB, JSON.stringify({ type: 'session', id: B_REAL, cwd: '/proj/wf' }) + '\n')
|
|
453
|
+
const recBId = 'sa-record-B'
|
|
454
|
+
await writeRecordManifest(dir, MAIN, recBId, {
|
|
455
|
+
rootSessionId: MAIN,
|
|
456
|
+
agentName: 'worker',
|
|
457
|
+
sessionFile: sbB,
|
|
458
|
+
status: 'completed',
|
|
459
|
+
parentRecordId: recAId,
|
|
460
|
+
})
|
|
461
|
+
// D:B 的 subagent 后代(验证 call session 内派生的嵌套 sub-subagent 仍正确挂到 B 下)
|
|
462
|
+
const saD = await writeSubagentSession(dir, MAIN, D_REAL, {
|
|
463
|
+
rootSessionId: MAIN,
|
|
464
|
+
slug: 'sub-d',
|
|
465
|
+
})
|
|
466
|
+
await writeRecordManifest(dir, MAIN, 'sa-record-D', {
|
|
467
|
+
rootSessionId: MAIN,
|
|
468
|
+
agentName: 'worker',
|
|
469
|
+
sessionFile: saD,
|
|
470
|
+
status: 'completed',
|
|
471
|
+
parentRecordId: recBId,
|
|
472
|
+
})
|
|
473
|
+
// A 的 v2 注册条目 → record 流 → calls[0].sessionFile = sbB
|
|
474
|
+
await writeWfV2Run(dir, '--main-cwd--', saA, A_REAL, RUN_A, [sbB])
|
|
475
|
+
|
|
476
|
+
const tree = await buildExecutionTree(MAIN, dir)
|
|
477
|
+
|
|
478
|
+
// B 作为 subagent 出现一次(parentRecordId 链命中)
|
|
479
|
+
const nodeBSub = findNode(tree.root, 'subagent', B_REAL)
|
|
480
|
+
expect(nodeBSub).toBeDefined()
|
|
481
|
+
expect(nodeBSub!.parentRecordId).toBe(recAId)
|
|
482
|
+
// B 不作为 workflow-call 重复出现(MF-1 dedup)
|
|
483
|
+
const nodeBWf = findNode(tree.root, 'workflow-call', B_REAL)
|
|
484
|
+
expect(nodeBWf).toBeUndefined()
|
|
485
|
+
// B 挂在 A 下;A 下不应有指向 B 的 workflow-call
|
|
486
|
+
const nodeA = findNode(tree.root, 'subagent', A_REAL)
|
|
487
|
+
expect(nodeA).toBeDefined()
|
|
488
|
+
expect(nodeA!.children.some((c) => c === nodeBSub)).toBe(true)
|
|
489
|
+
expect(
|
|
490
|
+
nodeA!.children.some((c) => c.type === 'workflow-call' && c.sessionId === B_REAL),
|
|
491
|
+
).toBe(false)
|
|
492
|
+
// B 的嵌套后代 D 仍挂在 B 下(caution:call 节点内派生的 sub-subagent 不丢失)
|
|
493
|
+
const nodeD = findNode(tree.root, 'subagent', D_REAL)
|
|
494
|
+
expect(nodeD).toBeDefined()
|
|
495
|
+
expect(nodeBSub!.children.some((c) => c === nodeD)).toBe(true)
|
|
496
|
+
// 树规模:main + A + B + D = 4(B 不重复计数)
|
|
497
|
+
expect(tree.totalNodes).toBe(4)
|
|
498
|
+
expect(tree.truncated).toBe(false)
|
|
499
|
+
})
|
|
500
|
+
|
|
501
|
+
it('TC-m3b-mf2-subtree:buildExecutionTree(subagentId) 切该 subagent 子树(MF-2,§5.5)', async () => {
|
|
502
|
+
await writeMainSession(dir, '--main-cwd--', MAIN)
|
|
503
|
+
// A(顶层)+ X(顶层,A 的兄弟,不应进 A 的子树)
|
|
504
|
+
const saA = await writeSubagentSession(dir, MAIN, A_REAL, {
|
|
505
|
+
rootSessionId: MAIN,
|
|
506
|
+
slug: 'sub-a',
|
|
507
|
+
})
|
|
508
|
+
const recAId = 'sa-record-A'
|
|
509
|
+
await writeRecordManifest(dir, MAIN, recAId, {
|
|
510
|
+
rootSessionId: MAIN,
|
|
511
|
+
agentName: 'explorer',
|
|
512
|
+
sessionFile: saA,
|
|
513
|
+
status: 'completed',
|
|
514
|
+
parentRecordId: undefined,
|
|
515
|
+
})
|
|
516
|
+
const saX = await writeSubagentSession(dir, MAIN, X_REAL, {
|
|
517
|
+
rootSessionId: MAIN,
|
|
518
|
+
slug: 'sub-x',
|
|
519
|
+
})
|
|
520
|
+
await writeRecordManifest(dir, MAIN, 'sa-record-X', {
|
|
521
|
+
rootSessionId: MAIN,
|
|
522
|
+
agentName: 'explorer',
|
|
523
|
+
sessionFile: saX,
|
|
524
|
+
status: 'completed',
|
|
525
|
+
parentRecordId: undefined,
|
|
526
|
+
})
|
|
527
|
+
// B(A 的后代),C(B 的后代)
|
|
528
|
+
const saB = await writeSubagentSession(dir, MAIN, B_REAL, {
|
|
529
|
+
rootSessionId: MAIN,
|
|
530
|
+
slug: 'sub-b',
|
|
531
|
+
})
|
|
532
|
+
const recBId = 'sa-record-B'
|
|
533
|
+
await writeRecordManifest(dir, MAIN, recBId, {
|
|
534
|
+
rootSessionId: MAIN,
|
|
535
|
+
agentName: 'worker',
|
|
536
|
+
sessionFile: saB,
|
|
537
|
+
status: 'completed',
|
|
538
|
+
parentRecordId: recAId,
|
|
539
|
+
})
|
|
540
|
+
const saC = await writeSubagentSession(dir, MAIN, C_REAL, {
|
|
541
|
+
rootSessionId: MAIN,
|
|
542
|
+
slug: 'sub-c',
|
|
543
|
+
})
|
|
544
|
+
await writeRecordManifest(dir, MAIN, 'sa-record-C', {
|
|
545
|
+
rootSessionId: MAIN,
|
|
546
|
+
agentName: 'worker',
|
|
547
|
+
sessionFile: saC,
|
|
548
|
+
status: 'completed',
|
|
549
|
+
parentRecordId: recBId,
|
|
550
|
+
})
|
|
551
|
+
|
|
552
|
+
// 切 A 的子树(subagent root)
|
|
553
|
+
const tree = await buildExecutionTree(A_REAL, dir)
|
|
554
|
+
|
|
555
|
+
// root 是 A(subagent 节点,携带 manifest 元数据)
|
|
556
|
+
expect(tree.root.type).toBe('subagent')
|
|
557
|
+
expect(tree.root.sessionId).toBe(A_REAL)
|
|
558
|
+
expect(tree.root.agentName).toBe('explorer')
|
|
559
|
+
// A 的子树含 B→C,不含兄弟 X
|
|
560
|
+
const nodeB = findNode(tree.root, 'subagent', B_REAL)
|
|
561
|
+
expect(nodeB).toBeDefined()
|
|
562
|
+
expect(tree.root.children.some((c) => c === nodeB)).toBe(true)
|
|
563
|
+
const nodeC = findNode(tree.root, 'subagent', C_REAL)
|
|
564
|
+
expect(nodeC).toBeDefined()
|
|
565
|
+
expect(nodeB!.children.some((c) => c === nodeC)).toBe(true)
|
|
566
|
+
// 兄弟 X 不在 A 的子树
|
|
567
|
+
const nodeX = findNode(tree.root, 'subagent', X_REAL)
|
|
568
|
+
expect(nodeX).toBeUndefined()
|
|
569
|
+
// 树规模:A + B + C = 3
|
|
570
|
+
expect(tree.totalNodes).toBe(3)
|
|
571
|
+
expect(tree.sourceMode).toBe('precise')
|
|
572
|
+
expect(tree.truncated).toBe(false)
|
|
573
|
+
})
|
|
574
|
+
|
|
575
|
+
it('TC-m3b-mf1-main-root:main root 填 sessionFile 后 main 自身发起的 workflow run 入树(MF-1)', async () => {
|
|
576
|
+
const mainPath = await writeMainSession(dir, '--main-cwd--', MAIN)
|
|
577
|
+
// main 的 v2 注册条目 → record 流 → call session B
|
|
578
|
+
const sbB = join(
|
|
579
|
+
resolveStoreSubagentSessionsDir(dir, MAIN),
|
|
580
|
+
`2026-08-07T16-49-48-393Z_${B_REAL}.jsonl`,
|
|
581
|
+
)
|
|
582
|
+
await mkdir(resolveStoreSubagentSessionsDir(dir, MAIN), { recursive: true })
|
|
583
|
+
await writeFile(sbB, JSON.stringify({ type: 'session', id: B_REAL, cwd: '/proj/wf' }) + '\n')
|
|
584
|
+
await writeWfV2Run(dir, '--main-cwd--', mainPath, MAIN, RUN_A, [sbB])
|
|
585
|
+
|
|
586
|
+
const tree = await buildExecutionTree(MAIN, dir, mainPath)
|
|
587
|
+
|
|
588
|
+
// main root 携带 sessionFile,v2 注册条目被读取 → wf-call 子节点入树
|
|
589
|
+
expect(tree.root.type).toBe('main')
|
|
590
|
+
expect(tree.root.sessionFile).toBe(mainPath)
|
|
591
|
+
const nodeB = findNode(tree.root, 'workflow-call', B_REAL)
|
|
592
|
+
expect(nodeB).toBeDefined()
|
|
593
|
+
expect(tree.root.children.some((c) => c === nodeB)).toBe(true)
|
|
594
|
+
expect(nodeB!.runId).toBe(RUN_A)
|
|
595
|
+
expect(tree.totalNodes).toBe(2) // main + wf-call B
|
|
596
|
+
expect(tree.maxDepth).toBe(1)
|
|
597
|
+
expect(tree.truncated).toBe(false)
|
|
598
|
+
|
|
599
|
+
// 差分检查:不传 mainSessionFile(旧行为)→ main root 无 sessionFile → workflow run 不可达
|
|
600
|
+
const tree2 = await buildExecutionTree(MAIN, dir)
|
|
601
|
+
expect(tree2.root.sessionFile).toBeUndefined()
|
|
602
|
+
expect(tree2.totalNodes).toBe(1)
|
|
603
|
+
|
|
604
|
+
// 空串哨兵归一:mainSessionFile=''(zcode family 路由反查未命中的降级形态)与
|
|
605
|
+
// 不传等价——root 节点 details 不携带 sessionFile:"" 这类非路径占位值
|
|
606
|
+
const tree3 = await buildExecutionTree(MAIN, dir, '')
|
|
607
|
+
expect(tree3.root.sessionFile).toBeUndefined()
|
|
608
|
+
expect(tree3.totalNodes).toBe(1)
|
|
609
|
+
})
|
|
610
|
+
|
|
611
|
+
it('TC-m3b-max-depth:21+ 层 parentRecordId 链在 MAX_DEPTH(20) 截断,truncated=true 不抛错(MF-4)', async () => {
|
|
612
|
+
await writeMainSession(dir, '--main-cwd--', MAIN)
|
|
613
|
+
// 循环生成 25 层链:R1 顶层(parentRecordId=undefined),R(i+1).parentRecordId=Ri.id
|
|
614
|
+
// 深度计数:root=0,R1=1,…,R20=20(attachSubagentChildren 在 node.depth>=20 截断,R21+ 不挂)
|
|
615
|
+
const CHAIN_RECORDS = 25
|
|
616
|
+
// 链 id 用独立前缀(0bbbbb…,与 MAIN 的 0aaaa… 不撞——否则 sidOf(1)===MAIN 会被
|
|
617
|
+
// rootManifest 探测误判为 subagent root,深度计数整体偏移 1)
|
|
618
|
+
const sidOf = (n: number): string =>
|
|
619
|
+
`0bbbbbbb-cccc-7ddd-eeee-0000000000${String(n).padStart(2, '0')}`
|
|
620
|
+
const recIdOf = (n: number): string => `sa-chain-${String(n).padStart(2, '0')}`
|
|
621
|
+
let prevRecId: string | undefined
|
|
622
|
+
for (let i = 1; i <= CHAIN_RECORDS; i++) {
|
|
623
|
+
const saPath = await writeSubagentSession(dir, MAIN, sidOf(i), {
|
|
624
|
+
rootSessionId: MAIN,
|
|
625
|
+
slug: `chain-${i}`,
|
|
626
|
+
})
|
|
627
|
+
await writeRecordManifest(dir, MAIN, recIdOf(i), {
|
|
628
|
+
rootSessionId: MAIN,
|
|
629
|
+
agentName: 'explorer',
|
|
630
|
+
sessionFile: saPath,
|
|
631
|
+
parentRecordId: prevRecId,
|
|
632
|
+
})
|
|
633
|
+
prevRecId = recIdOf(i)
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const tree = await buildExecutionTree(MAIN, dir)
|
|
637
|
+
|
|
638
|
+
// 深度截断契约:MAX_DEPTH=20 → main + 20 层 subagent = 21 节点,R21+ 被截断
|
|
639
|
+
expect(tree.truncated).toBe(true)
|
|
640
|
+
expect(tree.maxDepth).toBe(20)
|
|
641
|
+
expect(tree.totalNodes).toBe(21)
|
|
642
|
+
// R20 是最后挂载的节点,其下无后代(R21 被截断)
|
|
643
|
+
const deepest = findNode(tree.root, 'subagent', sidOf(20))
|
|
644
|
+
expect(deepest).toBeDefined()
|
|
645
|
+
expect(deepest!.children).toEqual([])
|
|
646
|
+
// R21 不在树中
|
|
647
|
+
expect(findNode(tree.root, 'subagent', sidOf(21))).toBeUndefined()
|
|
648
|
+
expect(tree.root.children).toHaveLength(1)
|
|
649
|
+
expect(tree.sourceMode).toBe('precise')
|
|
650
|
+
})
|
|
651
|
+
|
|
652
|
+
it('TC-m3b-bfs-spread:旧机制嵌套形态 rootSessionId=父 session id,BFS 扩散收集不丢 record(MF-5)', async () => {
|
|
653
|
+
await writeMainSession(dir, '--main-cwd--', MAIN)
|
|
654
|
+
// A:顶层(rootSessionId=MAIN,无 parentRecordId——旧机制)
|
|
655
|
+
const saA = await writeSubagentSession(dir, MAIN, A_REAL, {
|
|
656
|
+
rootSessionId: MAIN,
|
|
657
|
+
slug: 'a',
|
|
658
|
+
})
|
|
659
|
+
await writeRecordManifest(dir, MAIN, 'sa-A', {
|
|
660
|
+
rootSessionId: MAIN,
|
|
661
|
+
agentName: 'explorer',
|
|
662
|
+
sessionFile: saA,
|
|
663
|
+
})
|
|
664
|
+
// B:旧机制嵌套形态——rootSessionId=A 的 session id(非 MAIN),A 无 manifest.parentRecordId
|
|
665
|
+
const saB = await writeSubagentSession(dir, '--main-cwd--', B_REAL, {
|
|
666
|
+
rootSessionId: A_REAL,
|
|
667
|
+
slug: 'b',
|
|
668
|
+
})
|
|
669
|
+
await writeRecordManifest(dir, MAIN, 'sa-B', {
|
|
670
|
+
rootSessionId: A_REAL,
|
|
671
|
+
agentName: 'explorer',
|
|
672
|
+
sessionFile: saB,
|
|
673
|
+
})
|
|
674
|
+
|
|
675
|
+
const tree = await buildExecutionTree(MAIN, dir)
|
|
676
|
+
|
|
677
|
+
// BFS 扩散:queue 从 MAIN → A 的 session id 入队 → 第二轮收 B。B 不被丢弃(ES3)
|
|
678
|
+
expect(tree.totalNodes).toBe(3) // main + A + B
|
|
679
|
+
expect(tree.sourceMode).toBe('flat-fallback')
|
|
680
|
+
const nodeA = findNode(tree.root, 'subagent', A_REAL)
|
|
681
|
+
expect(nodeA).toBeDefined()
|
|
682
|
+
const nodeB = findNode(tree.root, 'subagent', B_REAL)
|
|
683
|
+
expect(nodeB).toBeDefined()
|
|
684
|
+
// 旧机制无 parentRecordId → B 与 A 都 flat 挂 main(flat-fallback 语义)
|
|
685
|
+
expect(tree.root.children.some((c) => c === nodeA)).toBe(true)
|
|
686
|
+
expect(tree.root.children.some((c) => c === nodeB)).toBe(true)
|
|
687
|
+
expect(tree.truncated).toBe(false)
|
|
688
|
+
})
|
|
170
689
|
})
|
|
171
690
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
691
|
+
// ============================================================
|
|
692
|
+
// 真实形态数据检查(合成 fixture):mkdtemp 临时目录仿真真实 agentDir 布局,
|
|
693
|
+
// guard 语义断言(解析不抛错 / 树非空 / 结构自洽 / sourceMode 值域),不触碰真实数据目录。
|
|
694
|
+
// ============================================================
|
|
695
|
+
|
|
696
|
+
describe('buildExecutionTree - 真实形态数据守卫(合成)', () => {
|
|
697
|
+
let dataDir: string
|
|
698
|
+
let dir: string // agentDir = <dataDir>/agent(生产拓扑复刻)
|
|
699
|
+
|
|
700
|
+
beforeEach(async () => {
|
|
701
|
+
dataDir = await makeAgentDir()
|
|
702
|
+
dir = join(dataDir, 'agent')
|
|
703
|
+
})
|
|
704
|
+
afterEach(async () => {
|
|
705
|
+
await rm(dataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
706
|
+
})
|
|
707
|
+
|
|
708
|
+
it('TC-m3b-real-data-guard:真实形态布局(时间戳文件名/新旧机制混合/孤儿 manifest/坏 manifest/外来家族)解析不抛错,树非空且结构自洽', async () => {
|
|
709
|
+
// 仿真真实 agentDir 数据形态(全部 mkdtemp 自建自删,确定性可重跑):
|
|
710
|
+
// - session 文件名带时间戳前缀(<ISO 时间戳>_<sessionId>.jsonl,真实 pi 落盘形态)
|
|
711
|
+
// - 混合新旧机制:A 顶层(无 parentRecordId)→ B 嵌套(manifest.parentRecordId 精确链);
|
|
712
|
+
// C 旧机制顶层(manifest/identity 均无 parentRecordId → flat 挂 main)
|
|
713
|
+
// - 孤儿 manifest D:sessionFile 已被 30 天 GC、manifest 残留(真实数据常态)
|
|
714
|
+
// - 坏 manifest(JSON 损坏)不中断扫描(ES4)
|
|
715
|
+
// - 外来家族 X(同 agentDir 其他 main 的 record)不应泄漏进目标树
|
|
716
|
+
const CWD = '--guard-cwd--'
|
|
717
|
+
const TS = '2026-09-11T10-00-00-000Z'
|
|
718
|
+
const OTHER_MAIN = '0aaaaaaa-bbbb-7ccc-dddd-000000000009'
|
|
719
|
+
const prefixed = (id: string): string => `${TS}_${id}.jsonl`
|
|
720
|
+
|
|
721
|
+
await writeMainSession(dir, CWD, MAIN)
|
|
722
|
+
// A:顶层 subagent(identity 尾行走数据源 ② 读路径,无 parentRecordId → 顶层挂 main)
|
|
723
|
+
const saA = await writeSubagentSession(
|
|
724
|
+
dir,
|
|
725
|
+
MAIN,
|
|
726
|
+
A_REAL,
|
|
727
|
+
{ rootSessionId: MAIN, slug: 'guard-a' },
|
|
728
|
+
prefixed(A_REAL),
|
|
729
|
+
)
|
|
730
|
+
await writeRecordManifest(dir, MAIN, 'rec-ga', {
|
|
731
|
+
rootSessionId: MAIN,
|
|
732
|
+
agentName: 'explorer',
|
|
733
|
+
sessionFile: saA,
|
|
734
|
+
slug: 'guard-a',
|
|
735
|
+
status: 'completed',
|
|
736
|
+
})
|
|
737
|
+
// B:A 的嵌套后代(manifest.parentRecordId 精确链,新机制)
|
|
738
|
+
const saB = await writeSubagentSession(
|
|
739
|
+
dir,
|
|
740
|
+
MAIN,
|
|
741
|
+
B_REAL,
|
|
742
|
+
{ rootSessionId: MAIN, slug: 'guard-b' },
|
|
743
|
+
prefixed(B_REAL),
|
|
744
|
+
)
|
|
745
|
+
await writeRecordManifest(dir, MAIN, 'rec-gb', {
|
|
746
|
+
rootSessionId: MAIN,
|
|
747
|
+
agentName: 'worker',
|
|
748
|
+
sessionFile: saB,
|
|
749
|
+
slug: 'guard-b',
|
|
750
|
+
status: 'completed',
|
|
751
|
+
parentRecordId: 'rec-ga',
|
|
752
|
+
})
|
|
753
|
+
// C:旧机制顶层 subagent(manifest/identity 均无 parentRecordId → flat 挂 main)
|
|
754
|
+
const saC = await writeSubagentSession(
|
|
755
|
+
dir,
|
|
756
|
+
MAIN,
|
|
757
|
+
C_REAL,
|
|
758
|
+
{ rootSessionId: MAIN, slug: 'guard-c' },
|
|
759
|
+
prefixed(C_REAL),
|
|
760
|
+
)
|
|
761
|
+
await writeRecordManifest(dir, MAIN, 'rec-gc', {
|
|
762
|
+
rootSessionId: MAIN,
|
|
763
|
+
agentName: 'explorer',
|
|
764
|
+
sessionFile: saC,
|
|
765
|
+
slug: 'guard-c',
|
|
766
|
+
status: 'completed',
|
|
767
|
+
})
|
|
768
|
+
// D:孤儿 manifest(sessionFile 指向已被 GC 的不存在文件——manifest 残留形态)
|
|
769
|
+
await writeRecordManifest(dir, MAIN, 'rec-gd', {
|
|
770
|
+
rootSessionId: MAIN,
|
|
771
|
+
agentName: 'worker',
|
|
772
|
+
sessionFile: join(resolveStoreSubagentSessionsDir(dir, MAIN), prefixed(D_REAL)),
|
|
773
|
+
status: 'completed',
|
|
774
|
+
})
|
|
775
|
+
// 坏 manifest(JSON 损坏,listRecordManifests 容错跳过不中断)
|
|
776
|
+
await mkdir(resolveStoreSubagentRecordsDir(dir, MAIN), { recursive: true })
|
|
777
|
+
await writeFile(join(resolveStoreSubagentRecordsDir(dir, MAIN), 'broken.json'), '{oops')
|
|
778
|
+
// 外来家族 X:同 agentDir 内其他 main(OTHER_MAIN)的 subagent,不应泄漏进 MAIN 的树
|
|
779
|
+
const saX = await writeSubagentSession(
|
|
780
|
+
dir,
|
|
781
|
+
OTHER_MAIN,
|
|
782
|
+
X_REAL,
|
|
783
|
+
{ rootSessionId: OTHER_MAIN, slug: 'foreign-x' },
|
|
784
|
+
prefixed(X_REAL),
|
|
785
|
+
)
|
|
786
|
+
await writeRecordManifest(dir, MAIN, 'rec-gx', {
|
|
787
|
+
rootSessionId: OTHER_MAIN,
|
|
788
|
+
agentName: 'explorer',
|
|
789
|
+
sessionFile: saX,
|
|
790
|
+
status: 'completed',
|
|
791
|
+
})
|
|
792
|
+
|
|
793
|
+
const tree = await buildExecutionTree(MAIN, dir)
|
|
182
794
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
795
|
+
// 契约值域:sourceMode 只能是两种精度模式之一(record 含 parentRecordId → precise,
|
|
796
|
+
// 全无 → flat-fallback),不锁具体值
|
|
797
|
+
expect(['precise', 'flat-fallback']).toContain(tree.sourceMode)
|
|
798
|
+
// 家族树非空:main + 顶层 A/C/D + A 的嵌套后代 B = 5;外来 X 不泄漏(合成数据确定性
|
|
799
|
+
// 可数,精确计数即非泄漏检查)
|
|
800
|
+
expect(tree.totalNodes).toBe(5)
|
|
801
|
+
expect(tree.root.type).toBe('main')
|
|
802
|
+
expect(tree.root.sessionId).toBe(MAIN)
|
|
803
|
+
expect(findNode(tree.root, 'subagent', X_REAL)).toBeUndefined()
|
|
804
|
+
// 孤儿 D 仍入树(sessionFile 残留路径,不因文件缺失抛错/丢弃 record——原真实数据检查
|
|
805
|
+
// 独有覆盖范围,合成化后保留)
|
|
806
|
+
const nodeD = findNode(tree.root, 'subagent', D_REAL)
|
|
807
|
+
expect(nodeD).toBeDefined()
|
|
808
|
+
expect(nodeD!.sessionFile).toBe(join(resolveStoreSubagentSessionsDir(dir, MAIN), prefixed(D_REAL)))
|
|
809
|
+
// 树结构自洽(ExecutionTreeNode 契约,与具体数据形态无关):DFS 实际节点数 ===
|
|
810
|
+
// totalNodes;子节点 depth = 父 depth + 1;全树 rootSessionId 共享同一顶层 main
|
|
811
|
+
let count = 0
|
|
812
|
+
const walk = (node: ExecutionTreeNode): void => {
|
|
813
|
+
count++
|
|
814
|
+
expect(node.rootSessionId).toBe(tree.root.rootSessionId)
|
|
815
|
+
for (const c of node.children) {
|
|
816
|
+
expect(c.depth).toBe(node.depth + 1)
|
|
817
|
+
walk(c)
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
walk(tree.root)
|
|
821
|
+
expect(count).toBe(tree.totalNodes)
|
|
822
|
+
// 不抛错(已隐含:到这行说明成功)
|
|
823
|
+
})
|
|
186
824
|
})
|
|
187
825
|
|
|
188
|
-
|
|
189
|
-
|
|
826
|
+
// ============================================================
|
|
827
|
+
// formatExecutionTreeText 渲染(IF5)
|
|
828
|
+
// ============================================================
|
|
829
|
+
|
|
830
|
+
describe('formatExecutionTreeText', () => {
|
|
831
|
+
it('渲染头部摘要 + 树形 + 尾部 👉 指引', () => {
|
|
832
|
+
const tree = {
|
|
833
|
+
root: {
|
|
834
|
+
type: 'main' as const,
|
|
835
|
+
sessionId: MAIN,
|
|
836
|
+
depth: 0,
|
|
837
|
+
rootSessionId: MAIN,
|
|
838
|
+
children: [
|
|
839
|
+
{
|
|
840
|
+
type: 'subagent' as const,
|
|
841
|
+
sessionId: A_REAL,
|
|
842
|
+
depth: 1,
|
|
843
|
+
rootSessionId: MAIN,
|
|
844
|
+
status: 'completed',
|
|
845
|
+
slug: 'sub-a',
|
|
846
|
+
task: 'do something',
|
|
847
|
+
children: [],
|
|
848
|
+
},
|
|
849
|
+
],
|
|
850
|
+
},
|
|
851
|
+
totalNodes: 2,
|
|
852
|
+
maxDepth: 1,
|
|
853
|
+
truncated: false,
|
|
854
|
+
sourceMode: 'precise' as const,
|
|
855
|
+
}
|
|
856
|
+
const text = formatExecutionTreeText(tree)
|
|
857
|
+
// 头部摘要
|
|
858
|
+
expect(text).toContain('2 node(s)')
|
|
859
|
+
expect(text).toContain('maxDepth 1')
|
|
860
|
+
expect(text).toContain('precise')
|
|
861
|
+
// 树形:type + sessionId 截断 + status + slug + task
|
|
862
|
+
expect(text).toContain('main')
|
|
863
|
+
expect(text).toContain('subagent')
|
|
864
|
+
expect(text).toContain(A_REAL.slice(0, 8))
|
|
865
|
+
expect(text).toContain('[completed]')
|
|
866
|
+
expect(text).toContain('slug=sub-a')
|
|
867
|
+
expect(text).toContain('do something')
|
|
868
|
+
// 尾部 👉 指引
|
|
869
|
+
expect(text).toContain('👉')
|
|
870
|
+
expect(text).toContain("outline")
|
|
871
|
+
})
|
|
872
|
+
|
|
873
|
+
it('flat-fallback 模式注明精度限制', () => {
|
|
874
|
+
const tree = {
|
|
875
|
+
root: {
|
|
876
|
+
type: 'main' as const,
|
|
877
|
+
sessionId: MAIN,
|
|
878
|
+
depth: 0,
|
|
879
|
+
rootSessionId: MAIN,
|
|
880
|
+
children: [],
|
|
881
|
+
},
|
|
882
|
+
totalNodes: 1,
|
|
883
|
+
maxDepth: 0,
|
|
884
|
+
truncated: false,
|
|
885
|
+
sourceMode: 'flat-fallback' as const,
|
|
886
|
+
}
|
|
887
|
+
const text = formatExecutionTreeText(tree)
|
|
888
|
+
expect(text).toContain('flat-fallback')
|
|
889
|
+
expect(text).toContain('旧机制')
|
|
890
|
+
})
|
|
891
|
+
|
|
892
|
+
it('truncated 模式标注截断', () => {
|
|
893
|
+
const tree = {
|
|
894
|
+
root: {
|
|
895
|
+
type: 'main' as const,
|
|
896
|
+
sessionId: MAIN,
|
|
897
|
+
depth: 0,
|
|
898
|
+
rootSessionId: MAIN,
|
|
899
|
+
children: [],
|
|
900
|
+
},
|
|
901
|
+
totalNodes: 1,
|
|
902
|
+
maxDepth: 0,
|
|
903
|
+
truncated: true,
|
|
904
|
+
sourceMode: 'precise' as const,
|
|
905
|
+
}
|
|
906
|
+
const text = formatExecutionTreeText(tree)
|
|
907
|
+
expect(text).toContain('truncated')
|
|
908
|
+
})
|
|
190
909
|
})
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
// ======== 来源:roots.test.ts ========
|
|
913
|
+
describe("roots", () => {
|
|
914
|
+
// ============================================================
|
|
915
|
+
// A3(ext-simplify-04 U4):listMainSessions/listSubagentSessions 薄包装已删除,
|
|
916
|
+
// subagents.ts 直调 resolveSessionRoots+filter。以下 describe 用同一等价式作入口,
|
|
917
|
+
// 保过滤语义覆盖(.finalized 排除 / workflow-state 跳过 / 递归 / 根选择 / 空目录容错)。
|
|
918
|
+
// ============================================================
|
|
919
|
+
|
|
920
|
+
/** 原 listMainSessions 等价式:agentDir 信号包下未去重 main 根的文件并集 */
|
|
921
|
+
async function mainSessionFiles(agentDir: string) {
|
|
922
|
+
return (await resolveSessionRoots({ agentDir }))
|
|
923
|
+
.filter((r) => r.source === 'main' && r.dedupedInto === undefined)
|
|
924
|
+
.flatMap((r) => r.files)
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/** 原 listSubagentSessions 等价式:agentDir 信号包下 [subagent] 根文件集 */
|
|
928
|
+
async function subagentSessionFiles(agentDir: string) {
|
|
929
|
+
return (await resolveSessionRoots({ agentDir })).find((r) => r.kind === 'subagent')?.files ?? []
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/** 写 .jsonl fixture(父目录不存在则自建),内容默认合法 session header */
|
|
933
|
+
async function writeJsonl(path: string, content = '{"type":"session","id":"x"}\n') {
|
|
934
|
+
await mkdir(dirname(path), { recursive: true })
|
|
935
|
+
await writeFile(path, content)
|
|
936
|
+
}
|
|
191
937
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
const legacyDir = join(dir, 'subagents', '--legacy-cwd--', 'sessions')
|
|
195
|
-
await mkdir(legacyDir, { recursive: true })
|
|
196
|
-
await writeFile(join(legacyDir, 'legacy.jsonl'), '{"type":"session"}\n')
|
|
938
|
+
/** 真实形态的 cwd 编码目录名(encodeCwd:`--` 开头 `--` 结尾) */
|
|
939
|
+
const SLUG = '--Users-foo--'
|
|
197
940
|
|
|
198
|
-
|
|
941
|
+
/** taiji 用例的存储树分区 sid fixture(合法 pi sid 形态)。 */
|
|
942
|
+
const TAIJI_SID = '0aaaaaaa-bbbb-7ccc-dddd-0000000000t1'
|
|
943
|
+
|
|
944
|
+
describe('main 根过滤(原 listMainSessions 等价式,A3 退役后保覆盖)', () => {
|
|
945
|
+
let dir: string
|
|
946
|
+
|
|
947
|
+
beforeEach(async () => {
|
|
948
|
+
dir = await mkdtemp(join(tmpdir(), 'roots-test-'))
|
|
949
|
+
})
|
|
950
|
+
afterEach(async () => {
|
|
951
|
+
await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
952
|
+
})
|
|
953
|
+
|
|
954
|
+
it('扫描 sessions/<slug>/*.jsonl,排除 *.jsonl.finalized', async () => {
|
|
955
|
+
const slug = '--Users-foo--'
|
|
956
|
+
await mkdir(join(dir, 'sessions', slug), { recursive: true })
|
|
957
|
+
await writeFile(join(dir, 'sessions', slug, 'a.jsonl'), '{"type":"session","id":"a"}\n')
|
|
958
|
+
await writeFile(join(dir, 'sessions', slug, 'b.jsonl.finalized'), '{"type":"session","id":"b"}\n')
|
|
959
|
+
|
|
960
|
+
const result = await mainSessionFiles(dir)
|
|
961
|
+
expect(result).toHaveLength(1)
|
|
962
|
+
expect(result[0].path.endsWith('a.jsonl')).toBe(true)
|
|
963
|
+
expect(result[0].path.endsWith('.finalized')).toBe(false)
|
|
964
|
+
expect(result[0].mtime).toBeTypeOf('number')
|
|
965
|
+
expect(result[0].mtime).toBeGreaterThan(0)
|
|
966
|
+
expect(result[0].size).toBeTypeOf('number')
|
|
967
|
+
expect(result[0].size).toBeGreaterThan(0)
|
|
968
|
+
})
|
|
969
|
+
|
|
970
|
+
it('嵌套子目录正确递归(sessions/<slug>/deep/nested/d.jsonl)', async () => {
|
|
971
|
+
await mkdir(join(dir, 'sessions', 'slug', 'deep', 'nested'), { recursive: true })
|
|
972
|
+
await writeFile(join(dir, 'sessions', 'slug', 'deep', 'nested', 'd.jsonl'), '{}\n')
|
|
973
|
+
// 同 slug 直接层也放一个,验证同一 slug 下平铺与嵌套并存
|
|
974
|
+
await writeFile(join(dir, 'sessions', 'slug', 'top.jsonl'), '{}\n')
|
|
975
|
+
|
|
976
|
+
const result = await mainSessionFiles(dir)
|
|
977
|
+
const names = result.map((m) => m.path.split('/').pop()!)
|
|
978
|
+
expect(names).toContain('d.jsonl')
|
|
979
|
+
expect(names).toContain('top.jsonl')
|
|
980
|
+
expect(result).toHaveLength(2)
|
|
981
|
+
})
|
|
982
|
+
|
|
983
|
+
it('跳过 workflow-state 子目录(wf-*.jsonl 非 session 文件)', async () => {
|
|
984
|
+
const slug = '--Users-x--'
|
|
985
|
+
await mkdir(join(dir, 'sessions', slug, 'workflow-state'), { recursive: true })
|
|
986
|
+
await writeFile(join(dir, 'sessions', slug, 'real.jsonl'), '{"type":"session"}\n')
|
|
987
|
+
await writeFile(
|
|
988
|
+
join(dir, 'sessions', slug, 'workflow-state', 'wf-abc.jsonl'),
|
|
989
|
+
'{"v":"wf-run-v1"}\n',
|
|
990
|
+
)
|
|
991
|
+
|
|
992
|
+
const result = await mainSessionFiles(dir)
|
|
993
|
+
expect(result).toHaveLength(1)
|
|
994
|
+
expect(result[0].path.endsWith('real.jsonl')).toBe(true)
|
|
995
|
+
expect(result.every((m) => !m.path.includes('workflow-state'))).toBe(true)
|
|
996
|
+
})
|
|
997
|
+
|
|
998
|
+
it('空 agentDir(无 sessions 目录)返回 [],不抛错', async () => {
|
|
999
|
+
await expect(mainSessionFiles(dir)).resolves.toEqual([])
|
|
1000
|
+
})
|
|
1001
|
+
|
|
1002
|
+
it('不存在的 agentDir 返回 [],不抛错', async () => {
|
|
1003
|
+
await expect(mainSessionFiles(join(dir, 'no-such-dir'))).resolves.toEqual([])
|
|
1004
|
+
})
|
|
1005
|
+
|
|
1006
|
+
it('扫描 sessions/:含目标 session,排除 .finalized 与 workflow-state 目录(019e6c96 锚)', async () => {
|
|
1007
|
+
// wf-state 文件(wf-*.jsonl)经 workflow-state 目录跳过排除——main 根无独立的
|
|
1008
|
+
// wf- 文件名前缀过滤,slug 顶层放 wf-*.jsonl 会被收进候选集(该形态不在真实
|
|
1009
|
+
// 落盘布局中出现:wf-state 恒在 workflow-state/ 目录内)
|
|
1010
|
+
const slug = '--Users-foo--'
|
|
1011
|
+
const slugDir = join(dir, 'sessions', slug)
|
|
1012
|
+
await mkdir(join(slugDir, 'workflow-state'), { recursive: true })
|
|
1013
|
+
await writeFile(join(slugDir, `${FIX_E6}.jsonl`), `{"type":"session","id":"${FIX_E6}"}\n`)
|
|
1014
|
+
await writeFile(join(slugDir, 'b.jsonl.finalized'), '{"type":"session","id":"b"}\n')
|
|
1015
|
+
await writeFile(join(slugDir, 'workflow-state', 'wf-inner.jsonl'), '{"v":"wf-run-v1"}\n')
|
|
1016
|
+
|
|
1017
|
+
const result = await mainSessionFiles(dir)
|
|
1018
|
+
expect(result.length).toBeGreaterThan(0)
|
|
1019
|
+
// 含目标 session
|
|
1020
|
+
expect(result.some((m) => m.path.includes('019e6c96'))).toBe(true)
|
|
1021
|
+
// 排除 finalized
|
|
1022
|
+
expect(result.every((m) => !m.path.endsWith('.finalized'))).toBe(true)
|
|
1023
|
+
// 排除 workflow-state 目录
|
|
1024
|
+
expect(result.every((m) => !m.path.includes('workflow-state'))).toBe(true)
|
|
1025
|
+
// mtime/size 真实
|
|
1026
|
+
expect(result.every((m) => m.mtime > 0 && m.size > 0)).toBe(true)
|
|
1027
|
+
})
|
|
199
1028
|
})
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
1029
|
+
|
|
1030
|
+
describe('subagent 根选择(原 listSubagentSessions 等价式,A3 退役后保覆盖;[session-store-layout-inversion] 单新根)', () => {
|
|
1031
|
+
let dataDir: string
|
|
1032
|
+
let dir: string // agentDir = <dataDir>/agent(生产拓扑复刻)
|
|
1033
|
+
|
|
1034
|
+
beforeEach(async () => {
|
|
1035
|
+
dataDir = await mkdtemp(join(tmpdir(), 'roots-sub-test-'))
|
|
1036
|
+
dir = join(dataDir, 'agent')
|
|
1037
|
+
})
|
|
1038
|
+
afterEach(async () => {
|
|
1039
|
+
await rm(dataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
1040
|
+
})
|
|
1041
|
+
|
|
1042
|
+
/** 存储树分区 sid fixture(合法 pi sid 形态)。 */
|
|
1043
|
+
const SID_P = '0aaaaaaa-bbbb-7ccc-dddd-0000000000p1'
|
|
1044
|
+
|
|
1045
|
+
it('扫描存储树 <root>/<sid>/subagents/sessions/*.jsonl,排除 .finalized', async () => {
|
|
1046
|
+
const sessionsDir = resolveStoreSubagentSessionsDir(dir, SID_P)
|
|
1047
|
+
await mkdir(sessionsDir, { recursive: true })
|
|
1048
|
+
await writeFile(join(sessionsDir, 'c.jsonl'), '{"type":"session"}\n')
|
|
1049
|
+
await writeFile(join(sessionsDir, 'c.jsonl.finalized'), '{"type":"session"}\n')
|
|
1050
|
+
|
|
1051
|
+
const result = await subagentSessionFiles(dir)
|
|
1052
|
+
expect(result).toHaveLength(1)
|
|
1053
|
+
expect(result[0].path.endsWith('c.jsonl')).toBe(true)
|
|
1054
|
+
expect(result[0].path.endsWith('.finalized')).toBe(false)
|
|
1055
|
+
})
|
|
1056
|
+
|
|
1057
|
+
it('records/ 子目录(.json manifest)不被误收(同分区固定位置只探 sessions/)', async () => {
|
|
1058
|
+
const { resolveStoreSubagentRecordsDir } = await import('../discovery/roots.js')
|
|
1059
|
+
await mkdir(resolveStoreSubagentRecordsDir(dir, SID_P), { recursive: true })
|
|
1060
|
+
const sessionsDir = resolveStoreSubagentSessionsDir(dir, SID_P)
|
|
1061
|
+
await mkdir(sessionsDir, { recursive: true })
|
|
1062
|
+
await writeFile(join(resolveStoreSubagentRecordsDir(dir, SID_P), 'manifest.json'), '{}\n')
|
|
1063
|
+
await writeFile(join(sessionsDir, 'sub.jsonl'), '{"type":"session"}\n')
|
|
1064
|
+
|
|
1065
|
+
const result = await subagentSessionFiles(dir)
|
|
1066
|
+
expect(result).toHaveLength(1)
|
|
1067
|
+
expect(result[0].path.endsWith('sub.jsonl')).toBe(true)
|
|
1068
|
+
})
|
|
1069
|
+
|
|
1070
|
+
it('非会话分区目录(isPiSessionId 不过)剪枝跳过,不进 subagent 根候选', async () => {
|
|
1071
|
+
// 非法分区名(前导 - 违反首字符规则;btw 虚拟 id 冒号形态同拒)
|
|
1072
|
+
for (const bogus of ['-bogus', 'btw:0aaa']) {
|
|
1073
|
+
const bogusDir = join(resolveSessionStoreRoot(dir), bogus, 'subagents', 'sessions')
|
|
1074
|
+
await mkdir(bogusDir, { recursive: true })
|
|
1075
|
+
await writeFile(join(bogusDir, 'x.jsonl'), '{"type":"session"}\n')
|
|
227
1076
|
}
|
|
1077
|
+
const okDir = resolveStoreSubagentSessionsDir(dir, SID_P)
|
|
1078
|
+
await mkdir(okDir, { recursive: true })
|
|
1079
|
+
await writeFile(join(okDir, 'ok.jsonl'), '{"type":"session"}\n')
|
|
1080
|
+
|
|
1081
|
+
const result = await subagentSessionFiles(dir)
|
|
1082
|
+
expect(result).toHaveLength(1)
|
|
1083
|
+
expect(result[0]!.path.endsWith('ok.jsonl')).toBe(true)
|
|
1084
|
+
})
|
|
1085
|
+
|
|
1086
|
+
it('无存储树目录返回 [],不抛错', async () => {
|
|
1087
|
+
await expect(subagentSessionFiles(dir)).resolves.toEqual([])
|
|
1088
|
+
})
|
|
1089
|
+
|
|
1090
|
+
it('D6 单新根锚定:旧布局 <agentDir>/subagents 造会话文件不被扫描(存量不可见)', async () => {
|
|
1091
|
+
// 旧布局存量原位保留(D6 裁决:不迁移、不再读写)
|
|
1092
|
+
const legacyDir = join(dir, 'subagents', '--legacy-cwd--', 'sessions')
|
|
1093
|
+
await mkdir(legacyDir, { recursive: true })
|
|
1094
|
+
await writeFile(join(legacyDir, 'legacy.jsonl'), '{"type":"session"}\n')
|
|
1095
|
+
|
|
1096
|
+
expect(await subagentSessionFiles(dir)).toEqual([])
|
|
1097
|
+
})
|
|
1098
|
+
})
|
|
1099
|
+
|
|
1100
|
+
// ============================================================
|
|
1101
|
+
// U1 resolveSessionRoots(design 2026-09-10 §6.1 / §7B 要点 2/3/4/6/7;B 收缩无 [env])
|
|
1102
|
+
// ============================================================
|
|
1103
|
+
|
|
1104
|
+
/** 三宿主信号包用例的行内校验上下文 */
|
|
1105
|
+
interface HostCaseCtx {
|
|
1106
|
+
tmp: string
|
|
1107
|
+
agentDir: string
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
interface HostCase {
|
|
1111
|
+
name: string
|
|
1112
|
+
build: (tmp: string) => Promise<{ agentDir: string; signals: SessionRootSignals }>
|
|
1113
|
+
verify: (ctx: HostCaseCtx, roots: SessionRoot[]) => void
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
const HOST_CASES: HostCase[] = [
|
|
1117
|
+
{
|
|
1118
|
+
name: '纯 pi:live=encodeCwd 子目录,剥层后与 default 同路径去重,保留 live 标签',
|
|
1119
|
+
async build(tmp) {
|
|
1120
|
+
const agentDir = join(tmp, 'agent')
|
|
1121
|
+
await writeJsonl(join(agentDir, 'sessions', SLUG, 'a.jsonl'))
|
|
1122
|
+
return {
|
|
1123
|
+
agentDir,
|
|
1124
|
+
signals: { agentDir, liveSessionDir: join(agentDir, 'sessions', SLUG) },
|
|
1125
|
+
}
|
|
1126
|
+
},
|
|
1127
|
+
verify({ agentDir }, roots) {
|
|
1128
|
+
// kind 序 = 优先级序(live > default > legacy > subagent;B 收缩无 env)
|
|
1129
|
+
expect(roots.map((r) => r.kind)).toEqual(['live', 'default', 'legacy', 'subagent'])
|
|
1130
|
+
const live = roots.find((r) => r.kind === 'live')!
|
|
1131
|
+
// 剥层(§7B 要点 3):父目录名为 sessions 的真实形态 → dirname
|
|
1132
|
+
expect(live.path).toBe(join(agentDir, 'sessions'))
|
|
1133
|
+
expect(live.exists).toBe(true)
|
|
1134
|
+
expect(live.fileCount).toBe(1)
|
|
1135
|
+
const def = roots.find((r) => r.kind === 'default')!
|
|
1136
|
+
expect(def.path).toBe(join(agentDir, 'sessions'))
|
|
1137
|
+
// realpath 去重(§7B 要点 4):同路径只扫一次,保留最高优先级 kind
|
|
1138
|
+
expect(def.dedupedInto).toBe('live')
|
|
1139
|
+
expect(def.files).toHaveLength(0)
|
|
1140
|
+
expect(def.fileCount).toBeUndefined()
|
|
1141
|
+
// 该路径在全列表中只被一个根实扫(文件不重复计入)
|
|
1142
|
+
const scannedAtSamePath = roots.filter(
|
|
1143
|
+
(r) => r.path === join(agentDir, 'sessions') && r.dedupedInto === undefined,
|
|
1144
|
+
)
|
|
1145
|
+
expect(scannedAtSamePath).toHaveLength(1)
|
|
1146
|
+
expect(roots.reduce((n, r) => n + r.files.length, 0)).toBe(1)
|
|
1147
|
+
},
|
|
228
1148
|
},
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
return { agentDir, signals: { agentDir, liveSessionDir: join(agentDir, 'sessions') } }
|
|
261
|
-
},
|
|
262
|
-
verify({ agentDir, tmp }, roots) {
|
|
263
|
-
expect(roots.map((r) => r.kind)).toEqual(['live', 'default', 'legacy', 'subagent'])
|
|
264
|
-
// live=根本身(basename 非 encodeCwd 形态)→ 取自身,与 default 同字面路径 → 去重
|
|
265
|
-
const scanned = roots.filter((r) => r.dedupedInto === undefined)
|
|
266
|
-
expect(scanned.map((r) => r.kind)).toEqual(['live', 'legacy', 'subagent'])
|
|
267
|
-
const live = scanned[0]!
|
|
268
|
-
expect(live.path).toBe(join(agentDir, 'sessions'))
|
|
269
|
-
expect(live.fileCount).toBe(1) // 递归扫到 encodeCwd 子目录内的文件
|
|
270
|
-
const def = roots.find((r) => r.kind === 'default')!
|
|
271
|
-
expect(def.dedupedInto).toBe('live')
|
|
272
|
-
// legacy(<dataDir>/sessions 残留)非空即纳入候选,独立成根
|
|
273
|
-
const legacy = roots.find((r) => r.kind === 'legacy')!
|
|
274
|
-
expect(legacy.path).toBe(join(tmp, 'data', 'sessions'))
|
|
275
|
-
expect(legacy.exists).toBe(true)
|
|
276
|
-
expect(legacy.fileCount).toBe(1)
|
|
277
|
-
const sub = roots.find((r) => r.kind === 'subagent')!
|
|
278
|
-
expect(sub.exists).toBe(true)
|
|
279
|
-
expect(sub.fileCount).toBe(1)
|
|
280
|
-
expect(sub.files[0]!.path.endsWith('s.jsonl')).toBe(true)
|
|
281
|
-
},
|
|
282
|
-
},
|
|
283
|
-
{
|
|
284
|
-
name: 'liveSessionDir 缺失降级:default/legacy/subagent 三根仍可用,无 live 根',
|
|
285
|
-
async build(tmp) {
|
|
286
|
-
const agentDir = join(tmp, 'agent')
|
|
287
|
-
await writeJsonl(join(agentDir, 'sessions', SLUG, 'a.jsonl'))
|
|
288
|
-
return { agentDir, signals: { agentDir } }
|
|
1149
|
+
{
|
|
1150
|
+
name: 'taiji:live=根本身(RPC 覆盖态),default 去重,legacy 残留与 subagent 独立列出',
|
|
1151
|
+
async build(tmp) {
|
|
1152
|
+
const dataDir = join(tmp, 'data')
|
|
1153
|
+
const agentDir = join(dataDir, 'agent')
|
|
1154
|
+
await writeJsonl(join(agentDir, 'sessions', SLUG, 'x.jsonl'))
|
|
1155
|
+
await writeJsonl(join(dataDir, 'sessions', 'old.jsonl')) // 旧旧布局迁移残留(§6.13 U1 行)
|
|
1156
|
+
// [session-store-layout-inversion] subagent 会话在新布局存储树(生产同源推导落盘)
|
|
1157
|
+
await writeJsonl(join(resolveStoreSubagentSessionsDir(agentDir, TAIJI_SID), 's.jsonl'))
|
|
1158
|
+
return { agentDir, signals: { agentDir, liveSessionDir: join(agentDir, 'sessions') } }
|
|
1159
|
+
},
|
|
1160
|
+
verify({ agentDir, tmp }, roots) {
|
|
1161
|
+
expect(roots.map((r) => r.kind)).toEqual(['live', 'default', 'legacy', 'subagent'])
|
|
1162
|
+
// live=根本身(basename 非 encodeCwd 形态)→ 取自身,与 default 同字面路径 → 去重
|
|
1163
|
+
const scanned = roots.filter((r) => r.dedupedInto === undefined)
|
|
1164
|
+
expect(scanned.map((r) => r.kind)).toEqual(['live', 'legacy', 'subagent'])
|
|
1165
|
+
const live = scanned[0]!
|
|
1166
|
+
expect(live.path).toBe(join(agentDir, 'sessions'))
|
|
1167
|
+
expect(live.fileCount).toBe(1) // 递归扫到 encodeCwd 子目录内的文件
|
|
1168
|
+
const def = roots.find((r) => r.kind === 'default')!
|
|
1169
|
+
expect(def.dedupedInto).toBe('live')
|
|
1170
|
+
// legacy(<dataDir>/sessions 残留)非空即纳入候选,独立成根
|
|
1171
|
+
const legacy = roots.find((r) => r.kind === 'legacy')!
|
|
1172
|
+
expect(legacy.path).toBe(join(tmp, 'data', 'sessions'))
|
|
1173
|
+
expect(legacy.exists).toBe(true)
|
|
1174
|
+
expect(legacy.fileCount).toBe(1)
|
|
1175
|
+
const sub = roots.find((r) => r.kind === 'subagent')!
|
|
1176
|
+
expect(sub.exists).toBe(true)
|
|
1177
|
+
expect(sub.fileCount).toBe(1)
|
|
1178
|
+
expect(sub.files[0]!.path.endsWith('s.jsonl')).toBe(true)
|
|
1179
|
+
},
|
|
289
1180
|
},
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
1181
|
+
{
|
|
1182
|
+
name: 'liveSessionDir 缺失降级:default/legacy/subagent 三根仍可用,无 live 根',
|
|
1183
|
+
async build(tmp) {
|
|
1184
|
+
const agentDir = join(tmp, 'agent')
|
|
1185
|
+
await writeJsonl(join(agentDir, 'sessions', SLUG, 'a.jsonl'))
|
|
1186
|
+
return { agentDir, signals: { agentDir } }
|
|
1187
|
+
},
|
|
1188
|
+
verify({ agentDir, tmp }, roots) {
|
|
1189
|
+
expect(roots.map((r) => r.kind)).toEqual(['default', 'legacy', 'subagent'])
|
|
1190
|
+
const def = roots.find((r) => r.kind === 'default')!
|
|
1191
|
+
expect(def.path).toBe(join(agentDir, 'sessions'))
|
|
1192
|
+
expect(def.exists).toBe(true)
|
|
1193
|
+
expect(def.fileCount).toBe(1)
|
|
1194
|
+
expect(roots.find((r) => r.kind === 'legacy')!.path).toBe(join(tmp, 'sessions'))
|
|
1195
|
+
// live 缺席 → 无去重发生
|
|
1196
|
+
expect(roots.every((r) => r.dedupedInto === undefined)).toBe(true)
|
|
1197
|
+
},
|
|
299
1198
|
},
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
1199
|
+
]
|
|
1200
|
+
|
|
1201
|
+
describe('resolveSessionRoots', () => {
|
|
1202
|
+
it.each(HOST_CASES)('$name', async ({ build, verify }) => {
|
|
1203
|
+
const tmp = await mkdtemp(join(tmpdir(), 'roots-resolve-'))
|
|
1204
|
+
try {
|
|
1205
|
+
const { agentDir, signals } = await build(tmp)
|
|
1206
|
+
const roots = await resolveSessionRoots(signals)
|
|
1207
|
+
|
|
1208
|
+
// ④ subagent 根恒在(常量推导,不来自任何信号,§6.1 信号 5);
|
|
1209
|
+
// [session-store-layout-inversion] 根 = session 存储树(dirname(agentDir)/ext-sessions)
|
|
1210
|
+
const sub = roots.find((r) => r.kind === 'subagent')
|
|
1211
|
+
expect(sub).toBeDefined()
|
|
1212
|
+
expect(sub!.source).toBe('subagent')
|
|
1213
|
+
expect(sub!.path).toBe(resolveSessionStoreRoot(agentDir))
|
|
1214
|
+
|
|
1215
|
+
// ⑤ 已扫描根 fileCount/scanMs 恒填充且与 files 一致;被去重根不产生扫描统计
|
|
1216
|
+
for (const r of roots) {
|
|
1217
|
+
if (r.dedupedInto !== undefined) {
|
|
1218
|
+
expect(r.fileCount).toBeUndefined()
|
|
1219
|
+
expect(r.scanMs).toBeUndefined()
|
|
1220
|
+
} else {
|
|
1221
|
+
expect(typeof r.fileCount).toBe('number')
|
|
1222
|
+
expect(r.fileCount).toBe(r.files.length)
|
|
1223
|
+
expect(typeof r.scanMs).toBe('number')
|
|
1224
|
+
expect(r.scanMs!).toBeGreaterThanOrEqual(0)
|
|
1225
|
+
}
|
|
327
1226
|
}
|
|
1227
|
+
|
|
1228
|
+
verify({ tmp, agentDir }, roots)
|
|
1229
|
+
} finally {
|
|
1230
|
+
await rm(tmp, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
328
1231
|
}
|
|
1232
|
+
})
|
|
329
1233
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
1234
|
+
it('[live] 规范化第二形态:普通目录(basename 非 encodeCwd 形态)取自身,不剥层', async () => {
|
|
1235
|
+
const tmp = await mkdtemp(join(tmpdir(), 'roots-live-plain-'))
|
|
1236
|
+
try {
|
|
1237
|
+
const agentDir = join(tmp, 'agent')
|
|
1238
|
+
const liveDir = join(tmp, 'live-sessions')
|
|
1239
|
+
await writeJsonl(join(liveDir, 'b.jsonl'))
|
|
1240
|
+
|
|
1241
|
+
const roots = await resolveSessionRoots({ agentDir, liveSessionDir: liveDir })
|
|
1242
|
+
const live = roots.find((r) => r.kind === 'live')!
|
|
1243
|
+
expect(live.path).toBe(liveDir)
|
|
1244
|
+
expect(live.exists).toBe(true)
|
|
1245
|
+
expect(live.fileCount).toBe(1)
|
|
1246
|
+
// default/legacy 独立列出,互不干扰
|
|
1247
|
+
expect(roots.find((r) => r.kind === 'default')!.path).toBe(join(agentDir, 'sessions'))
|
|
1248
|
+
expect(roots.every((r) => r.dedupedInto === undefined)).toBe(true)
|
|
1249
|
+
} finally {
|
|
1250
|
+
await rm(tmp, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
1251
|
+
}
|
|
1252
|
+
})
|
|
1253
|
+
|
|
1254
|
+
it('空 agentDir 防御:不派生任何根(避免扫到进程 cwd 相对路径)', async () => {
|
|
1255
|
+
await expect(resolveSessionRoots({ agentDir: '' })).resolves.toEqual([])
|
|
1256
|
+
})
|
|
1257
|
+
|
|
1258
|
+
it('encodeCwd 判据自洽:--xx-- 形态子目录剥层到 sessions 根,普通名子目录取自身', async () => {
|
|
1259
|
+
// 两种目录形态各造一个:encodeCwd 编码目录(剥层分支)与普通目录名(取自身分支)
|
|
1260
|
+
const tmp = await mkdtemp(join(tmpdir(), 'roots-encode-'))
|
|
1261
|
+
try {
|
|
1262
|
+
const sessionsDir = join(tmp, 'agent', 'sessions')
|
|
1263
|
+
const encoded = join(sessionsDir, '--Users-foo--')
|
|
1264
|
+
const plain = join(sessionsDir, 'permission-forwarding')
|
|
1265
|
+
await writeJsonl(join(encoded, 'a.jsonl'))
|
|
1266
|
+
await writeJsonl(join(plain, 'p.jsonl'))
|
|
1267
|
+
// 判据自洽:匹配 encodeCwd 形态 → 剥层到父目录;不匹配(如 permission-forwarding/)→ 取自身
|
|
1268
|
+
expect(normalizeLiveSessionDir(encoded)).toBe(sessionsDir)
|
|
1269
|
+
expect(normalizeLiveSessionDir(plain)).toBe(plain)
|
|
1270
|
+
} finally {
|
|
1271
|
+
await rm(tmp, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
1272
|
+
}
|
|
1273
|
+
})
|
|
334
1274
|
})
|
|
335
1275
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
const roots = await resolveSessionRoots({ agentDir, liveSessionDir: liveDir })
|
|
344
|
-
const live = roots.find((r) => r.kind === 'live')!
|
|
345
|
-
expect(live.path).toBe(liveDir)
|
|
346
|
-
expect(live.exists).toBe(true)
|
|
347
|
-
expect(live.fileCount).toBe(1)
|
|
348
|
-
// default/legacy 独立列出,互不干扰
|
|
349
|
-
expect(roots.find((r) => r.kind === 'default')!.path).toBe(join(agentDir, 'sessions'))
|
|
350
|
-
expect(roots.every((r) => r.dedupedInto === undefined)).toBe(true)
|
|
351
|
-
} finally {
|
|
1276
|
+
describe('buildFamilyFromFs not-found 文案(U1 并入:列实际扫描候选根)', () => {
|
|
1277
|
+
let tmp: string
|
|
1278
|
+
|
|
1279
|
+
beforeEach(async () => {
|
|
1280
|
+
tmp = await mkdtemp(join(tmpdir(), 'roots-notfound-'))
|
|
1281
|
+
})
|
|
1282
|
+
afterEach(async () => {
|
|
352
1283
|
await rm(tmp, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
353
|
-
}
|
|
354
|
-
|
|
1284
|
+
})
|
|
1285
|
+
|
|
1286
|
+
it('错误信息含各候选根路径、kind 标签与文件数(default/legacy),保留 findSessions 指引', async () => {
|
|
1287
|
+
const agentDir = join(tmp, 'data', 'agent')
|
|
1288
|
+
await writeJsonl(
|
|
1289
|
+
join(agentDir, 'sessions', SLUG, 'known.jsonl'),
|
|
1290
|
+
'{"type":"session","id":"known-id"}\n',
|
|
1291
|
+
)
|
|
355
1292
|
|
|
356
|
-
|
|
357
|
-
|
|
1293
|
+
const msg = await buildFamilyFromFs('missing-id', agentDir).then(
|
|
1294
|
+
() => {
|
|
1295
|
+
throw new Error('buildFamilyFromFs should reject for unknown sessionId')
|
|
1296
|
+
},
|
|
1297
|
+
(e: unknown) => (e instanceof Error ? e.message : String(e)),
|
|
1298
|
+
)
|
|
1299
|
+
expect(msg).toContain('not found')
|
|
1300
|
+
expect(msg).toContain(join(agentDir, 'sessions'))
|
|
1301
|
+
expect(msg).toContain('[default]')
|
|
1302
|
+
expect(msg).toContain('1 file(s)')
|
|
1303
|
+
expect(msg).toContain(join(tmp, 'data', 'sessions'))
|
|
1304
|
+
expect(msg).toContain('[legacy]')
|
|
1305
|
+
expect(msg).toContain('not exists')
|
|
1306
|
+
// 只列 main 候选根(collectMainSessions 实扫集合),不含 subagent 根
|
|
1307
|
+
expect(msg).not.toContain('[subagent]')
|
|
1308
|
+
expect(msg).toContain('findSessions')
|
|
1309
|
+
})
|
|
358
1310
|
})
|
|
359
1311
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
await
|
|
1312
|
+
// ============================================================
|
|
1313
|
+
// u8 追加:resolveSessionRoots options(doctor 统一数据源,design §6.3「同一数据源
|
|
1314
|
+
// 两处渲染,不重复实现探测」——subagent 扫描模式经 options 由调用方驱动;原 u8 的
|
|
1315
|
+
// 注入式缓存面已删除,ext-simplify-04 U3,每次调用实扫)
|
|
1316
|
+
// ============================================================
|
|
1317
|
+
|
|
1318
|
+
describe('resolveSessionRoots options(u8:subagents 模式)', () => {
|
|
1319
|
+
/** 最小 fixture:default 根 1 文件 + subagent 根 1 文件(legacy 不存在) */
|
|
1320
|
+
async function buildMinimalFixture(): Promise<{ tmp: string; agentDir: string }> {
|
|
1321
|
+
const tmp = await mkdtemp(join(tmpdir(), 'roots-opts-'))
|
|
1322
|
+
const agentDir = join(tmp, 'agent')
|
|
1323
|
+
await mkdir(join(agentDir, 'sessions', SLUG), { recursive: true })
|
|
1324
|
+
await writeFile(join(agentDir, 'sessions', SLUG, 'a.jsonl'), '{"type":"session"}\n')
|
|
1325
|
+
await mkdir(resolveStoreSubagentSessionsDir(agentDir, '0aaaaaaa-bbbb-7ccc-dddd-0000000000o1'), { recursive: true })
|
|
1326
|
+
await writeFile(
|
|
1327
|
+
join(resolveStoreSubagentSessionsDir(agentDir, '0aaaaaaa-bbbb-7ccc-dddd-0000000000o1'), 's.jsonl'),
|
|
1328
|
+
'{"type":"session"}\n',
|
|
1329
|
+
)
|
|
1330
|
+
return { tmp, agentDir }
|
|
374
1331
|
}
|
|
375
|
-
})
|
|
376
|
-
})
|
|
377
1332
|
|
|
378
|
-
|
|
379
|
-
|
|
1333
|
+
it("subagents:'stat':subagent 根只 stat(无计数/耗时/files 空),main 根照常实扫", async () => {
|
|
1334
|
+
const { tmp, agentDir } = await buildMinimalFixture()
|
|
1335
|
+
try {
|
|
1336
|
+
const roots = await resolveSessionRoots({ agentDir }, { subagents: 'stat' })
|
|
1337
|
+
const sub = roots.find((r) => r.kind === 'subagent')!
|
|
1338
|
+
expect(sub.exists).toBe(true)
|
|
1339
|
+
expect(sub.fileCount).toBeUndefined()
|
|
1340
|
+
expect(sub.scanMs).toBeUndefined()
|
|
1341
|
+
expect(sub.files).toEqual([])
|
|
1342
|
+
expect(sub.dedupedInto).toBeUndefined()
|
|
1343
|
+
// main 根不受影响:照常实扫
|
|
1344
|
+
const def = roots.find((r) => r.kind === 'default')!
|
|
1345
|
+
expect(def.fileCount).toBe(1)
|
|
1346
|
+
expect(def.files).toHaveLength(1)
|
|
1347
|
+
} finally {
|
|
1348
|
+
await rm(tmp, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
1349
|
+
}
|
|
1350
|
+
})
|
|
1351
|
+
|
|
1352
|
+
it("options 缺省与 subagents:'scan':subagent 根照旧全扫(现有调用方零破坏)", async () => {
|
|
1353
|
+
const { tmp, agentDir } = await buildMinimalFixture()
|
|
1354
|
+
try {
|
|
1355
|
+
for (const options of [undefined, { subagents: 'scan' as const }]) {
|
|
1356
|
+
const roots = await resolveSessionRoots({ agentDir }, options)
|
|
1357
|
+
const sub = roots.find((r) => r.kind === 'subagent')!
|
|
1358
|
+
expect(sub.fileCount).toBe(1)
|
|
1359
|
+
expect(sub.files).toHaveLength(1)
|
|
1360
|
+
}
|
|
1361
|
+
} finally {
|
|
1362
|
+
await rm(tmp, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
1363
|
+
}
|
|
1364
|
+
})
|
|
380
1365
|
|
|
381
|
-
|
|
382
|
-
|
|
1366
|
+
it('重复调用每次实扫(无缓存):两次结果独立完整,files 恒非空实扫值', async () => {
|
|
1367
|
+
// 原三个 cache 用例(set 回写 / 命中优先 / stat × cache)随缓存注入点删除
|
|
1368
|
+
// (ext-simplify-04 U3);本用例固定删除后契约——不存在任何缓存层
|
|
1369
|
+
const { tmp, agentDir } = await buildMinimalFixture()
|
|
1370
|
+
try {
|
|
1371
|
+
const first = await resolveSessionRoots({ agentDir })
|
|
1372
|
+
const second = await resolveSessionRoots({ agentDir })
|
|
1373
|
+
for (const roots of [first, second]) {
|
|
1374
|
+
const def = roots.find((r) => r.kind === 'default')!
|
|
1375
|
+
expect(def.fileCount).toBe(1)
|
|
1376
|
+
expect(def.files).toHaveLength(1)
|
|
1377
|
+
const sub = roots.find((r) => r.kind === 'subagent')!
|
|
1378
|
+
expect(sub.fileCount).toBe(1)
|
|
1379
|
+
expect(sub.files).toHaveLength(1)
|
|
1380
|
+
}
|
|
1381
|
+
} finally {
|
|
1382
|
+
await rm(tmp, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
1383
|
+
}
|
|
1384
|
+
})
|
|
383
1385
|
})
|
|
384
|
-
|
|
385
|
-
|
|
1386
|
+
});
|
|
1387
|
+
|
|
1388
|
+
// ======== 来源:subagents.test.ts ========
|
|
1389
|
+
describe("subagents", () => {
|
|
1390
|
+
// ---- fixture 常量(uuid 特征,满足 extractSessionIdFromFilename + 互不为子串)----
|
|
1391
|
+
const ROOT = '0aaaaaaa-bbbb-7ccc-dddd-000000000001'
|
|
1392
|
+
const FORK = '0aaaaaaa-bbbb-7ccc-dddd-000000000002'
|
|
1393
|
+
const SUB_REAL = '0aaaaaaa-bbbb-7ccc-dddd-000000000003'
|
|
1394
|
+
|
|
1395
|
+
// ---- fixture helpers ----
|
|
1396
|
+
|
|
1397
|
+
/**
|
|
1398
|
+
* 建 mkdtemp dataDir 根(生产拓扑复刻,[session-store-layout-inversion] 布局 B):
|
|
1399
|
+
* agentDir = `<dataDir>/agent`(调用方经 agentDirOf 推导并传入被测函数),存储树 =
|
|
1400
|
+
* `<dataDir>/ext-sessions`(fixture 经 roots.ts 生产同源推导函数落盘,禁手拼树根段字面量)。
|
|
1401
|
+
*/
|
|
1402
|
+
async function makeAgentDir(): Promise<string> {
|
|
1403
|
+
return mkdtemp(join(tmpdir(), 'subagents-test-'))
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
/** agentDir 推导(生产拓扑:agentDir = <dataDir>/agent,session-reader 的 agentDir 语义)。 */
|
|
1407
|
+
function agentDirOf(dataDir: string): string {
|
|
1408
|
+
return join(dataDir, 'agent')
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
/** 写主 session 文件(首行 header)。返回绝对路径(供 fork 的 parentSession 指向)。 */
|
|
1412
|
+
async function writeMainSession(
|
|
1413
|
+
dir: string,
|
|
1414
|
+
slug: string,
|
|
1415
|
+
id: string,
|
|
1416
|
+
opts?: { cwd?: string; parentSession?: string },
|
|
1417
|
+
): Promise<string> {
|
|
1418
|
+
const sessionDir = join(dir, 'sessions', slug)
|
|
1419
|
+
await mkdir(sessionDir, { recursive: true })
|
|
1420
|
+
const path = join(sessionDir, `${id}.jsonl`)
|
|
1421
|
+
const header: Record<string, unknown> = { type: 'session', id, cwd: opts?.cwd ?? `/proj/${slug}` }
|
|
1422
|
+
if (opts?.parentSession) header.parentSession = opts.parentSession
|
|
1423
|
+
await writeFile(path, JSON.stringify(header) + '\n')
|
|
1424
|
+
return path
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
/**
|
|
1428
|
+
* 写 subagent session 文件(新布局存储树:`<root>/<rootSessionId>/subagents/sessions/`,
|
|
1429
|
+
* 分区键 = identity.rootSessionId,生产同源 resolveStoreSubagentSessionsDir 推导):
|
|
1430
|
+
* 首行 header(真实 id)+ 占位 message + 尾行 identity。
|
|
1431
|
+
* identity 在尾行(实测 pi 行为;subagent-identity 由 session-runner 在 session 创建后写)。
|
|
1432
|
+
* U4 扩展:identity.task/agent 可定制(P-fallback 测试用,默认 't'/'explorer')。
|
|
1433
|
+
*/
|
|
1434
|
+
async function writeSubagentSession(
|
|
1435
|
+
dir: string,
|
|
1436
|
+
realId: string,
|
|
1437
|
+
identity: { rootSessionId: string; slug: string; dataId?: string; task?: string; agent?: string },
|
|
1438
|
+
): Promise<string> {
|
|
1439
|
+
const sessionDir = resolveStoreSubagentSessionsDir(dir, identity.rootSessionId)
|
|
1440
|
+
await mkdir(sessionDir, { recursive: true })
|
|
1441
|
+
const path = join(sessionDir, `${realId}.jsonl`)
|
|
1442
|
+
const lines = [
|
|
1443
|
+
JSON.stringify({ type: 'session', id: realId, cwd: `/proj/${identity.slug}` }),
|
|
1444
|
+
JSON.stringify({
|
|
1445
|
+
type: 'message',
|
|
1446
|
+
id: 'm1',
|
|
1447
|
+
parentId: realId,
|
|
1448
|
+
message: { role: 'user', content: 'do work' },
|
|
1449
|
+
}),
|
|
1450
|
+
JSON.stringify({
|
|
1451
|
+
type: 'custom',
|
|
1452
|
+
customType: 'subagent-identity',
|
|
1453
|
+
data: {
|
|
1454
|
+
id: identity.dataId ?? `sa-${realId.slice(0, 8)}`,
|
|
1455
|
+
rootSessionId: identity.rootSessionId,
|
|
1456
|
+
slug: identity.slug,
|
|
1457
|
+
agent: identity.agent ?? 'explorer',
|
|
1458
|
+
mode: 'sync',
|
|
1459
|
+
task: identity.task ?? 't',
|
|
1460
|
+
startedAt: 1,
|
|
1461
|
+
},
|
|
1462
|
+
}),
|
|
1463
|
+
]
|
|
1464
|
+
await writeFile(path, lines.join('\n') + '\n')
|
|
1465
|
+
return path
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
/**
|
|
1469
|
+
* 写 alive subagent 文件但**无 identity 尾行**(运行中/异常,尾行是 message)。
|
|
1470
|
+
* TC-u4-pfallback-no-identity 场景:header 有效、无 manifest、无 identity → buildFamilyFromFs 跳过。
|
|
1471
|
+
*/
|
|
1472
|
+
async function writeAliveSubagentNoIdentity(
|
|
1473
|
+
dir: string,
|
|
1474
|
+
rootSessionId: string,
|
|
1475
|
+
realId: string,
|
|
1476
|
+
): Promise<string> {
|
|
1477
|
+
const sessionDir = resolveStoreSubagentSessionsDir(dir, rootSessionId)
|
|
1478
|
+
await mkdir(sessionDir, { recursive: true })
|
|
1479
|
+
const path = join(sessionDir, `${realId}.jsonl`)
|
|
1480
|
+
const lines = [
|
|
1481
|
+
JSON.stringify({ type: 'session', id: realId, cwd: '/proj/root' }),
|
|
1482
|
+
JSON.stringify({
|
|
1483
|
+
type: 'message',
|
|
1484
|
+
id: 'm1',
|
|
1485
|
+
parentId: realId,
|
|
1486
|
+
message: { role: 'user', content: 'still running' },
|
|
1487
|
+
}),
|
|
1488
|
+
]
|
|
1489
|
+
await writeFile(path, lines.join('\n') + '\n')
|
|
1490
|
+
return path
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
/**
|
|
1494
|
+
* 写 records manifest(孤儿源;新布局 `<root>/<rootSessionId>/subagents/records/`,
|
|
1495
|
+
* 分区键 = fields.rootSessionId,生产同源 resolveStoreSubagentRecordsDir 推导)。
|
|
1496
|
+
* U4 扩展:fields 支持富字段 task/slug/model/status。
|
|
1497
|
+
*/
|
|
1498
|
+
async function writeRecordManifest(
|
|
1499
|
+
dir: string,
|
|
1500
|
+
id: string,
|
|
1501
|
+
fields: {
|
|
1502
|
+
rootSessionId: string
|
|
1503
|
+
agentName?: string
|
|
1504
|
+
sessionFile: string
|
|
1505
|
+
task?: string
|
|
1506
|
+
slug?: string
|
|
1507
|
+
model?: string
|
|
1508
|
+
status?: string
|
|
1509
|
+
},
|
|
1510
|
+
): Promise<void> {
|
|
1511
|
+
const recordsDir = resolveStoreSubagentRecordsDir(dir, fields.rootSessionId)
|
|
1512
|
+
await mkdir(recordsDir, { recursive: true })
|
|
1513
|
+
await writeFile(join(recordsDir, `${id}.json`), JSON.stringify({ id, ...fields }))
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
// ============================================================
|
|
1517
|
+
// fixture 测试
|
|
1518
|
+
// ============================================================
|
|
1519
|
+
|
|
1520
|
+
describe('buildFamilyFromFs - fixture', () => {
|
|
1521
|
+
let dataDir: string
|
|
1522
|
+
let dir: string // agentDir = <dataDir>/agent(生产拓扑复刻,见 makeAgentDir 注释)
|
|
1523
|
+
|
|
1524
|
+
beforeEach(async () => {
|
|
1525
|
+
dataDir = await makeAgentDir()
|
|
1526
|
+
dir = agentDirOf(dataDir)
|
|
1527
|
+
})
|
|
1528
|
+
afterEach(async () => {
|
|
1529
|
+
await rm(dataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
1530
|
+
})
|
|
1531
|
+
|
|
1532
|
+
it('基础 family:root + fork + 隔代 subagent,SubagentRef.sessionId 是真实 id(非 sa-xxx)', async () => {
|
|
1533
|
+
const rootPath = await writeMainSession(dir, '--root-cwd--', ROOT, { cwd: '/proj/root' })
|
|
1534
|
+
// fork 在不同 cwd slug(模拟跨 cwd fork)
|
|
1535
|
+
await writeMainSession(dir, '--fork-cwd--', FORK, {
|
|
1536
|
+
cwd: '/proj/fork',
|
|
1537
|
+
parentSession: rootPath,
|
|
1538
|
+
})
|
|
1539
|
+
// subagent 挂在 fork 子代下(rootSessionId=FORK,非 ROOT)→ 隔代
|
|
1540
|
+
await writeSubagentSession(dir, SUB_REAL, {
|
|
1541
|
+
rootSessionId: FORK,
|
|
1542
|
+
slug: 'test-sub',
|
|
1543
|
+
dataId: 'sa-placeholder-1',
|
|
1544
|
+
})
|
|
1545
|
+
|
|
1546
|
+
const family = await buildFamilyFromFs(ROOT, dir)
|
|
1547
|
+
|
|
1548
|
+
expect(family.root.sessionId).toBe(ROOT)
|
|
1549
|
+
// fork:parentSession 含 ROOT id → childrenOf[ROOT] = [FORK]
|
|
1550
|
+
expect(family.forks.some((f) => f.sessionId === FORK)).toBe(true)
|
|
1551
|
+
// Q1 隔代:subagent rootSessionId=FORK(fork 子代),从 ROOT resolve 能关联
|
|
1552
|
+
const sub = family.subagents.find((s) => s.sessionId === SUB_REAL)
|
|
1553
|
+
expect(sub).toBeDefined()
|
|
1554
|
+
// id 修正核心断言:sessionId 是 subagent 文件首行 header.id(真实),非 identity.data.id 的 sa-xxx
|
|
1555
|
+
expect(sub!.sessionId).toBe(SUB_REAL)
|
|
1556
|
+
expect(sub!.sessionId.startsWith('sa-')).toBe(false)
|
|
1557
|
+
expect(sub!.rootSessionId).toBe(FORK)
|
|
1558
|
+
expect(sub!.slug).toBe('test-sub')
|
|
1559
|
+
expect(sub!.cleanedUp).toBe(false)
|
|
1560
|
+
// enrichRefs 已删除(ext-simplify-04 U4/E2):family 路径 fileName/cwd 维持 core 占位空串
|
|
1561
|
+
//(SessionRef 与 find 路径共享,find 侧有真实消费者;workflow calls 反查走 pathToRef 不受影响)
|
|
1562
|
+
expect(sub!.fileName).toBe('')
|
|
1563
|
+
expect(sub!.cwd).toBe('')
|
|
1564
|
+
})
|
|
1565
|
+
|
|
1566
|
+
it('从 fork 子代 resolve 也能关联到挂在其下的 subagent', async () => {
|
|
1567
|
+
const rootPath = await writeMainSession(dir, '--root-cwd--', ROOT)
|
|
1568
|
+
await writeMainSession(dir, '--fork-cwd--', FORK, { parentSession: rootPath })
|
|
1569
|
+
await writeSubagentSession(dir, SUB_REAL, { rootSessionId: FORK, slug: 's' })
|
|
1570
|
+
|
|
1571
|
+
const family = await buildFamilyFromFs(FORK, dir)
|
|
1572
|
+
expect(family.subagents.some((s) => s.sessionId === SUB_REAL)).toBe(true)
|
|
1573
|
+
})
|
|
1574
|
+
|
|
1575
|
+
it('cleanedUp:manifest 孤儿(.jsonl 不存在)→ cleanedUp=true', async () => {
|
|
1576
|
+
await writeMainSession(dir, '--root-cwd--', ROOT, { cwd: '/proj/root' })
|
|
1577
|
+
// 孤儿 manifest:rootSessionId=ROOT,sessionFile 指向不存在路径(模拟 .jsonl 被 GC)
|
|
1578
|
+
await writeRecordManifest(dir, 'sa-ghost-id', {
|
|
1579
|
+
rootSessionId: ROOT,
|
|
1580
|
+
agentName: 'explorer',
|
|
1581
|
+
sessionFile: '/nonexistent/ghost.jsonl',
|
|
1582
|
+
})
|
|
1583
|
+
|
|
1584
|
+
const family = await buildFamilyFromFs(ROOT, dir)
|
|
1585
|
+
|
|
1586
|
+
const ghost = family.subagents.find((s) => s.sessionId === 'sa-ghost-id')
|
|
1587
|
+
expect(ghost).toBeDefined()
|
|
1588
|
+
expect(ghost!.cleanedUp).toBe(true)
|
|
1589
|
+
expect(ghost!.rootSessionId).toBe(ROOT)
|
|
1590
|
+
// 孤儿无文件 → mtime/size 占位 0
|
|
1591
|
+
expect(ghost!.mtime).toBe(0)
|
|
1592
|
+
expect(ghost!.sizeBytes).toBe(0)
|
|
1593
|
+
})
|
|
1594
|
+
|
|
1595
|
+
it('alive subagent 同时有 manifest → 不重复计数(manifest 跳过 alive)', async () => {
|
|
1596
|
+
const rootPath = await writeMainSession(dir, '--root-cwd--', ROOT)
|
|
1597
|
+
await writeMainSession(dir, '--fork-cwd--', FORK, { parentSession: rootPath })
|
|
1598
|
+
const subPath = await writeSubagentSession(dir, SUB_REAL, {
|
|
1599
|
+
rootSessionId: FORK,
|
|
1600
|
+
slug: 'dup-test',
|
|
1601
|
+
})
|
|
1602
|
+
// manifest 指向真实文件路径(alive)→ 应被跳过,不产生孤儿副本
|
|
1603
|
+
await writeRecordManifest(dir, `sa-${SUB_REAL}`, {
|
|
1604
|
+
rootSessionId: FORK,
|
|
1605
|
+
agentName: 'explorer',
|
|
1606
|
+
sessionFile: subPath,
|
|
1607
|
+
})
|
|
1608
|
+
|
|
1609
|
+
const family = await buildFamilyFromFs(ROOT, dir)
|
|
1610
|
+
// 只有一个 SUB_REAL(真实 id),无 sa- 副本
|
|
1611
|
+
const realOnes = family.subagents.filter((s) => s.sessionId === SUB_REAL)
|
|
1612
|
+
expect(realOnes).toHaveLength(1)
|
|
1613
|
+
expect(realOnes[0].cleanedUp).toBe(false)
|
|
1614
|
+
const orphans = family.subagents.filter((s) => s.sessionId.startsWith('sa-'))
|
|
1615
|
+
expect(orphans).toHaveLength(0)
|
|
1616
|
+
})
|
|
1617
|
+
|
|
1618
|
+
it('sessionId 不在任意 main header → 抛 Error', async () => {
|
|
1619
|
+
await writeMainSession(dir, '--root-cwd--', ROOT, { cwd: '/proj/root' })
|
|
1620
|
+
await expect(buildFamilyFromFs('nonexistent-session-id', dir)).rejects.toThrow(/not found/)
|
|
1621
|
+
})
|
|
1622
|
+
|
|
1623
|
+
it('workflows:v2 注册条目 record 流直读提 calls;命中 pathToRef 取完整 ref,GC\'d 路径回退最小 ref', async () => {
|
|
1624
|
+
await writeMainSession(dir, '--root-cwd--', ROOT, { cwd: '/proj/root' })
|
|
1625
|
+
// 真实存在的 subagent(步骤 2 扫到 → pathToRef 命中 → 完整 SessionRef)
|
|
1626
|
+
const subPath = await writeSubagentSession(dir, SUB_REAL, {
|
|
1627
|
+
rootSessionId: ROOT,
|
|
1628
|
+
slug: 'wf-sub',
|
|
1629
|
+
})
|
|
1630
|
+
// GC'd 路径(文件不存在 → pathToRef 未命中 → sessionRefFromPath 最小 ref);
|
|
1631
|
+
// 新布局 subagent 会话路径形态(存储树分区,生产同源推导)
|
|
1632
|
+
const gced = join(
|
|
1633
|
+
resolveStoreSubagentSessionsDir(dir, ROOT),
|
|
1634
|
+
'2026-08-07T16-49-48-393Z_019fdd21-8169-7a02-8f11-eef6c9ca11cc.jsonl',
|
|
1635
|
+
)
|
|
1636
|
+
// record 流:agent-settled 帧携带 result.sessionFile(两 call:命中 + GC 各一)
|
|
1637
|
+
const wfDir = join(dir, 'sessions', '--root-cwd--', 'workflow-state')
|
|
1638
|
+
await mkdir(wfDir, { recursive: true })
|
|
1639
|
+
const recordPath = join(wfDir, 'wf-v2-rich.record.jsonl')
|
|
1640
|
+
await writeFile(
|
|
1641
|
+
recordPath,
|
|
1642
|
+
[
|
|
1643
|
+
JSON.stringify({ type: 'run-created', seq: 1, ts: 1000, runId: 'wf-v2-rich', workflowName: 'rich', argsSummary: '{}' }),
|
|
1644
|
+
JSON.stringify({ type: 'agent-settled', seq: 2, ts: 1100, taskIndex: 0, attempt: 1, outcome: 'done', durationMs: 1, result: { content: 'ok', sessionFile: subPath } }),
|
|
1645
|
+
JSON.stringify({ type: 'agent-settled', seq: 3, ts: 1200, taskIndex: 1, attempt: 1, outcome: 'done', durationMs: 1, result: { content: 'ok', sessionFile: gced } }),
|
|
1646
|
+
].join('\n') + '\n',
|
|
1647
|
+
)
|
|
1648
|
+
const line = JSON.stringify({
|
|
1649
|
+
type: 'custom',
|
|
1650
|
+
id: 'wf-record-wf-v2-rich',
|
|
1651
|
+
parentId: ROOT,
|
|
1652
|
+
customType: 'workflow-record',
|
|
1653
|
+
data: {
|
|
1654
|
+
v: 2,
|
|
1655
|
+
kind: 'registered',
|
|
1656
|
+
runId: 'wf-v2-rich',
|
|
1657
|
+
workflowName: 'rich',
|
|
1658
|
+
scriptName: 'rich',
|
|
1659
|
+
slug: 'rich',
|
|
1660
|
+
startedAt: 1,
|
|
1661
|
+
recordPath,
|
|
1662
|
+
},
|
|
1663
|
+
timestamp: '2026-08-07T16:48:24.933Z',
|
|
1664
|
+
})
|
|
1665
|
+
await writeFile(join(dir, 'sessions', '--root-cwd--', `${ROOT}.jsonl`), line + '\n', { flag: 'a' })
|
|
1666
|
+
|
|
1667
|
+
const family = await buildFamilyFromFs(ROOT, dir)
|
|
1668
|
+
|
|
1669
|
+
expect(family.workflows).toHaveLength(1)
|
|
1670
|
+
const wf = family.workflows[0]
|
|
1671
|
+
expect(wf.runId).toBe('wf-v2-rich')
|
|
1672
|
+
expect(wf.stateFile).toBe(recordPath)
|
|
1673
|
+
expect(wf.calls).toHaveLength(2)
|
|
1674
|
+
// 命中 pathToRef:完整 ref(真实 id / mtime / size / cwd)
|
|
1675
|
+
expect(wf.calls[0].fileName).toBe(subPath)
|
|
1676
|
+
expect(wf.calls[0].sessionId).toBe(SUB_REAL)
|
|
1677
|
+
expect(wf.calls[0].mtime).toBeGreaterThan(0)
|
|
1678
|
+
expect(wf.calls[0].sizeBytes).toBeGreaterThan(0)
|
|
1679
|
+
expect(wf.calls[0].cwd).toBe('/proj/wf-sub') // cwd 从 subagent 文件首行 header 透传(fixture header.cwd = /proj/<identity.slug>)
|
|
1680
|
+
// GC'd 未命中:fileName-only 最小 ref(sessionId 从文件名提取,mtime/size/cwd 占位)
|
|
1681
|
+
expect(wf.calls[1].fileName).toBe(gced)
|
|
1682
|
+
expect(wf.calls[1].sessionId).toBe('019fdd21-8169-7a02-8f11-eef6c9ca11cc')
|
|
1683
|
+
expect(wf.calls[1].mtime).toBe(0)
|
|
1684
|
+
expect(wf.calls[1].sizeBytes).toBe(0)
|
|
1685
|
+
expect(wf.calls[1].cwd).toBe('')
|
|
1686
|
+
})
|
|
1687
|
+
|
|
1688
|
+
it('MF-3 回归:alive 但无 identity 的 subagent(运行中)不被收编为 cleanedUp——U4 后 manifest 主路径建族', async () => {
|
|
1689
|
+
await writeMainSession(dir, '--root-cwd--', ROOT, { cwd: '/proj/root' })
|
|
1690
|
+
// 运行中 subagent:有效 header、无 identity 尾行(identity 完成时才写入),manifest 已存在
|
|
1691
|
+
const subDir = resolveStoreSubagentSessionsDir(dir, ROOT)
|
|
1692
|
+
await mkdir(subDir, { recursive: true })
|
|
1693
|
+
const subPath = join(subDir, SUB_REAL + '.jsonl')
|
|
1694
|
+
await writeFile(
|
|
1695
|
+
subPath,
|
|
1696
|
+
JSON.stringify({ type: 'session', id: SUB_REAL, cwd: '/proj/root' }) + '\n',
|
|
1697
|
+
)
|
|
1698
|
+
await writeRecordManifest(dir, `sa-${SUB_REAL}`, {
|
|
1699
|
+
rootSessionId: ROOT,
|
|
1700
|
+
agentName: 'explorer',
|
|
1701
|
+
sessionFile: subPath,
|
|
1702
|
+
})
|
|
1703
|
+
|
|
1704
|
+
const family = await buildFamilyFromFs(ROOT, dir)
|
|
1705
|
+
// U4 语义变化(TC-manifest-source):m0 时 alive 无 identity → 完全丢弃(无数据源建族)。
|
|
1706
|
+
// U4 manifest 主路径:manifest 命中即建族(manifest 有 rootSessionId),不再依赖 identity。
|
|
1707
|
+
// MF-3 核心保护仍生效:不被收编为 cleanedUp(fileStats 有 realId → cleanedUp=false)。
|
|
1708
|
+
const sub = family.subagents.find((s) => s.sessionId === SUB_REAL)
|
|
1709
|
+
expect(sub).toBeDefined()
|
|
1710
|
+
expect(sub!.cleanedUp).toBe(false) // 核心:运行中不被当孤儿
|
|
1711
|
+
expect(sub!.rootSessionId).toBe(ROOT) // manifest 主:rootSessionId 从 manifest 透传
|
|
1712
|
+
expect(sub!.agentName).toBe('explorer') // manifest.agentName → agentName
|
|
1713
|
+
// 无 identity 尾行 → task/model/status 取决于 manifest(本 fixture manifest 未写这些 → undefined)
|
|
1714
|
+
expect(sub!.task).toBeUndefined()
|
|
1715
|
+
expect(sub!.model).toBeUndefined()
|
|
1716
|
+
expect(family.subagents.every((s) => s.cleanedUp === false)).toBe(true)
|
|
1717
|
+
})
|
|
386
1718
|
})
|
|
387
1719
|
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
join(agentDir, 'sessions', SLUG, 'known.jsonl'),
|
|
392
|
-
'{"type":"session","id":"known-id"}\n',
|
|
393
|
-
)
|
|
1720
|
+
// ============================================================
|
|
1721
|
+
// w2 TC1:listRecordManifests + RecordManifest 导出(IF2/DM2,行为零变更验证)
|
|
1722
|
+
// ============================================================
|
|
394
1723
|
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
1724
|
+
describe('listRecordManifests 导出(w2 TC1)', () => {
|
|
1725
|
+
let dataDir: string
|
|
1726
|
+
let dir: string // agentDir = <dataDir>/agent(生产拓扑复刻,见 makeAgentDir 注释)
|
|
1727
|
+
|
|
1728
|
+
beforeEach(async () => {
|
|
1729
|
+
dataDir = await makeAgentDir()
|
|
1730
|
+
dir = agentDirOf(dataDir)
|
|
1731
|
+
})
|
|
1732
|
+
afterEach(async () => {
|
|
1733
|
+
await rm(dataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
1734
|
+
})
|
|
1735
|
+
|
|
1736
|
+
it('导出生效:import + 调用返回 RecordManifest[],字段完整', async () => {
|
|
1737
|
+
await writeRecordManifest(dir, 'sa-tc1', {
|
|
1738
|
+
rootSessionId: 'root-1',
|
|
1739
|
+
agentName: 'explorer',
|
|
1740
|
+
sessionFile: '/tmp/sa-tc1.jsonl',
|
|
1741
|
+
})
|
|
1742
|
+
const manifests: RecordManifest[] = await listRecordManifests(dir)
|
|
1743
|
+
expect(manifests).toHaveLength(1)
|
|
1744
|
+
const m = manifests[0]
|
|
1745
|
+
expect(m.id).toBe('sa-tc1')
|
|
1746
|
+
expect(m.rootSessionId).toBe('root-1')
|
|
1747
|
+
expect(m.agentName).toBe('explorer')
|
|
1748
|
+
expect(m.sessionFile).toBe('/tmp/sa-tc1.jsonl')
|
|
1749
|
+
})
|
|
1750
|
+
|
|
1751
|
+
it('D6 单新根锚定:旧布局 <agentDir>/subagents 造 record + 新根不造 → 不返回(存量不可见,防回归双根扫描)', async () => {
|
|
1752
|
+
// 旧布局存量原位保留(D6 裁决:不迁移、不再读写)——旧根造一份合法 manifest
|
|
1753
|
+
const legacyRecordsDir = join(dir, 'subagents', '--legacy-cwd--', 'records')
|
|
1754
|
+
await mkdir(legacyRecordsDir, { recursive: true })
|
|
1755
|
+
await writeFile(
|
|
1756
|
+
join(legacyRecordsDir, 'sa-legacy.json'),
|
|
1757
|
+
JSON.stringify({ id: 'sa-legacy', rootSessionId: 'root-legacy', sessionFile: '/nonexistent/legacy.jsonl' }),
|
|
1758
|
+
)
|
|
1759
|
+
// 单新根语义:旧根 manifest 不进结果集(result/find 面旧记录不可见是 D6 取舍清单明示项)
|
|
1760
|
+
expect(await listRecordManifests(dir)).toHaveLength(0)
|
|
1761
|
+
})
|
|
1762
|
+
|
|
1763
|
+
it('D6 单新根锚定:非会话分区目录(isPiSessionId 不过)剪枝跳过,合法分区正常读', async () => {
|
|
1764
|
+
const { resolveSessionStoreRoot } = await import('../discovery/roots.js')
|
|
1765
|
+
const storeRoot = resolveSessionStoreRoot(dir)
|
|
1766
|
+
// 非法分区名(前导 - 违反 isPiSessionId 首字符规则)下的 records 不扫
|
|
1767
|
+
const bogus = join(storeRoot, '-bogus-partition', 'subagents', 'records')
|
|
1768
|
+
await mkdir(bogus, { recursive: true })
|
|
1769
|
+
await writeFile(
|
|
1770
|
+
join(bogus, 'sa-bogus.json'),
|
|
1771
|
+
JSON.stringify({ id: 'sa-bogus', rootSessionId: '-bogus-partition', sessionFile: '/nonexistent/b.jsonl' }),
|
|
1772
|
+
)
|
|
1773
|
+
// 合法分区在场
|
|
1774
|
+
await writeRecordManifest(dir, 'sa-ok', {
|
|
1775
|
+
rootSessionId: 'root-ok',
|
|
1776
|
+
agentName: 'explorer',
|
|
1777
|
+
sessionFile: '/tmp/sa-ok.jsonl',
|
|
1778
|
+
})
|
|
1779
|
+
const manifests = await listRecordManifests(dir)
|
|
1780
|
+
expect(manifests.map((m) => m.id)).toEqual(['sa-ok'])
|
|
1781
|
+
})
|
|
411
1782
|
})
|
|
412
|
-
})
|
|
413
|
-
|
|
414
|
-
// ============================================================
|
|
415
|
-
// u8 追加:resolveSessionRoots options(doctor 统一数据源,design §6.3「同一数据源
|
|
416
|
-
// 两处渲染,不重复实现探测」——subagent 扫描模式经 options 由调用方驱动;原 u8 的
|
|
417
|
-
// 注入式缓存面已删除,ext-simplify-04 U3,每次调用实扫)
|
|
418
|
-
// ============================================================
|
|
419
|
-
|
|
420
|
-
describe('resolveSessionRoots options(u8:subagents 模式)', () => {
|
|
421
|
-
/** 最小 fixture:default 根 1 文件 + subagent 根 1 文件(legacy 不存在) */
|
|
422
|
-
async function buildMinimalFixture(): Promise<{ tmp: string; agentDir: string }> {
|
|
423
|
-
const tmp = await mkdtemp(join(tmpdir(), 'roots-opts-'))
|
|
424
|
-
const agentDir = join(tmp, 'agent')
|
|
425
|
-
await mkdir(join(agentDir, 'sessions', SLUG), { recursive: true })
|
|
426
|
-
await writeFile(join(agentDir, 'sessions', SLUG, 'a.jsonl'), '{"type":"session"}\n')
|
|
427
|
-
await mkdir(resolveStoreSubagentSessionsDir(agentDir, '0aaaaaaa-bbbb-7ccc-dddd-0000000000o1'), { recursive: true })
|
|
428
|
-
await writeFile(
|
|
429
|
-
join(resolveStoreSubagentSessionsDir(agentDir, '0aaaaaaa-bbbb-7ccc-dddd-0000000000o1'), 's.jsonl'),
|
|
430
|
-
'{"type":"session"}\n',
|
|
431
|
-
)
|
|
432
|
-
return { tmp, agentDir }
|
|
433
|
-
}
|
|
434
1783
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
1784
|
+
// ============================================================
|
|
1785
|
+
// U4 端到端:buildFamilyFromFs 富化(manifest 主 / P-fallback identity 回退 / orphan / compat)
|
|
1786
|
+
// 验证数据流重组:manifest 索引命中透全字段,未命中读尾行 identity 回退,孤儿 manifest 富字段+cleanedUp
|
|
1787
|
+
// ============================================================
|
|
1788
|
+
|
|
1789
|
+
describe('U4 buildFamilyFromFs 富化(manifest 主 / P-fallback)', () => {
|
|
1790
|
+
let dataDir: string
|
|
1791
|
+
let dir: string // agentDir = <dataDir>/agent(生产拓扑复刻,见 makeAgentDir 注释)
|
|
1792
|
+
|
|
1793
|
+
beforeEach(async () => {
|
|
1794
|
+
dataDir = await makeAgentDir()
|
|
1795
|
+
dir = agentDirOf(dataDir)
|
|
1796
|
+
})
|
|
1797
|
+
afterEach(async () => {
|
|
1798
|
+
await rm(dataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
1799
|
+
})
|
|
1800
|
+
|
|
1801
|
+
it('TC-u4-manifest-enrich: alive + manifest 全字段 → SubagentRef 富字段全透传', async () => {
|
|
1802
|
+
await writeMainSession(dir, '--root-cwd--', ROOT, { cwd: '/proj/root' })
|
|
1803
|
+
const subPath = await writeSubagentSession(dir, SUB_REAL, {
|
|
1804
|
+
rootSessionId: ROOT,
|
|
1805
|
+
slug: 'identity-slug', // identity.slug,manifest 主时被 manifest.slug 覆盖
|
|
1806
|
+
task: 'identity-task', // identity.task,manifest 主时被 manifest.task 覆盖
|
|
1807
|
+
agent: 'worker', // identity.agent,manifest 主时被 manifest.agentName 覆盖
|
|
1808
|
+
})
|
|
1809
|
+
// manifest 全字段(命中 meta.path 索引 → 走 manifest 主路径,覆盖 identity 值)
|
|
1810
|
+
await writeRecordManifest(dir, `sa-${SUB_REAL}`, {
|
|
1811
|
+
rootSessionId: ROOT,
|
|
1812
|
+
agentName: 'explorer',
|
|
1813
|
+
sessionFile: subPath,
|
|
1814
|
+
task: '调研 codex',
|
|
1815
|
+
slug: 'codex-ask-user-research',
|
|
1816
|
+
model: 'glm-5.2',
|
|
1817
|
+
status: 'completed',
|
|
1818
|
+
})
|
|
1819
|
+
|
|
1820
|
+
const family = await buildFamilyFromFs(ROOT, dir)
|
|
1821
|
+
const sub = family.subagents.find((s) => s.sessionId === SUB_REAL)
|
|
1822
|
+
|
|
1823
|
+
expect(sub).toBeDefined()
|
|
1824
|
+
// manifest 主:富字段从 manifest 透传(覆盖 identity 的值)
|
|
1825
|
+
expect(sub!.task).toBe('调研 codex')
|
|
1826
|
+
expect(sub!.slug).toBe('codex-ask-user-research')
|
|
1827
|
+
expect(sub!.agentName).toBe('explorer') // manifest.agentName → agentName
|
|
1828
|
+
expect(sub!.model).toBe('glm-5.2')
|
|
1829
|
+
expect(sub!.status).toBe('completed')
|
|
1830
|
+
expect(sub!.sessionFile).toBe(subPath)
|
|
1831
|
+
expect(sub!.cleanedUp).toBe(false)
|
|
1832
|
+
expect(sub!.rootSessionId).toBe(ROOT)
|
|
1833
|
+
})
|
|
1834
|
+
|
|
1835
|
+
it('TC-u4-pfallback-identity: alive 无 manifest + identity 含 task/agent → 回退 identity,model/status undefined', async () => {
|
|
1836
|
+
await writeMainSession(dir, '--root-cwd--', ROOT, { cwd: '/proj/root' })
|
|
1837
|
+
// 只写 alive session(含 identity 尾行),不写 manifest → P-fallback 路径
|
|
1838
|
+
const subPath = await writeSubagentSession(dir, SUB_REAL, {
|
|
1839
|
+
rootSessionId: ROOT,
|
|
1840
|
+
slug: 'fix',
|
|
1841
|
+
task: 'fix bug',
|
|
1842
|
+
agent: 'worker',
|
|
1843
|
+
})
|
|
1844
|
+
|
|
1845
|
+
const family = await buildFamilyFromFs(ROOT, dir)
|
|
1846
|
+
const sub = family.subagents.find((s) => s.sessionId === SUB_REAL)
|
|
1847
|
+
|
|
1848
|
+
expect(sub).toBeDefined()
|
|
1849
|
+
// P-fallback:task/agent/sessionFile 从 identity 回退
|
|
1850
|
+
expect(sub!.task).toBe('fix bug')
|
|
1851
|
+
expect(sub!.slug).toBe('fix')
|
|
1852
|
+
expect(sub!.agentName).toBe('worker') // identity.data.agent → agentName
|
|
1853
|
+
expect(sub!.sessionFile).toBe(subPath) // P-fallback sessionFile=alive meta.path
|
|
1854
|
+
// P-fallback 核心断言:model/status 不可回退,必 undefined
|
|
1855
|
+
expect(sub!.model).toBeUndefined()
|
|
1856
|
+
expect(sub!.status).toBeUndefined()
|
|
1857
|
+
expect(sub!.cleanedUp).toBe(false)
|
|
1858
|
+
})
|
|
1859
|
+
|
|
1860
|
+
it('TC-u4-pfallback-no-identity: alive 无 manifest 无 identity(运行中)→ 不入 family.subagents,不抛错', async () => {
|
|
1861
|
+
await writeMainSession(dir, '--root-cwd--', ROOT, { cwd: '/proj/root' })
|
|
1862
|
+
// alive 文件无 identity 尾行(运行中/异常),无 manifest
|
|
1863
|
+
await writeAliveSubagentNoIdentity(dir, ROOT, SUB_REAL)
|
|
1864
|
+
|
|
1865
|
+
// 核心断言:buildFamilyFromFs 不抛错、不崩溃
|
|
1866
|
+
const family = await buildFamilyFromFs(ROOT, dir)
|
|
1867
|
+
|
|
1868
|
+
// 无 manifest 无 identity → 无法确定 rootSessionId → 不入 family.subagents
|
|
1869
|
+
const sub = family.subagents.find((s) => s.sessionId === SUB_REAL)
|
|
1870
|
+
expect(sub).toBeUndefined()
|
|
1871
|
+
})
|
|
1872
|
+
|
|
1873
|
+
it('TC-u4-orphan-manifest: manifest 全字段 + sessionFile 指向不存在路径 → 孤儿 cleanedUp=true,富字段透传', async () => {
|
|
1874
|
+
await writeMainSession(dir, '--root-cwd--', ROOT, { cwd: '/proj/root' })
|
|
1875
|
+
const ghostPath = '/nonexistent/gc-ghost.jsonl' // .jsonl 已被 GC(不写该文件)
|
|
1876
|
+
await writeRecordManifest(dir, 'sa-ghost-id', {
|
|
1877
|
+
rootSessionId: ROOT,
|
|
1878
|
+
agentName: 'worker',
|
|
1879
|
+
sessionFile: ghostPath,
|
|
1880
|
+
task: 'ghost task',
|
|
1881
|
+
slug: 'ghost-slug',
|
|
1882
|
+
model: 'gpt-4',
|
|
1883
|
+
status: 'completed',
|
|
1884
|
+
})
|
|
1885
|
+
|
|
1886
|
+
const family = await buildFamilyFromFs(ROOT, dir)
|
|
1887
|
+
const ghost = family.subagents.find((s) => s.sessionId === 'sa-ghost-id')
|
|
1888
|
+
|
|
1889
|
+
expect(ghost).toBeDefined()
|
|
1890
|
+
expect(ghost!.cleanedUp).toBe(true) // 文件 GC → cleanedUp
|
|
1891
|
+
// 孤儿用 manifest 完整富字段
|
|
1892
|
+
expect(ghost!.task).toBe('ghost task')
|
|
1893
|
+
expect(ghost!.slug).toBe('ghost-slug')
|
|
1894
|
+
expect(ghost!.agentName).toBe('worker')
|
|
1895
|
+
expect(ghost!.model).toBe('gpt-4')
|
|
1896
|
+
expect(ghost!.status).toBe('completed')
|
|
1897
|
+
expect(ghost!.sessionFile).toBe(ghostPath) // GC 路径保留(不置空)
|
|
1898
|
+
expect(ghost!.rootSessionId).toBe(ROOT)
|
|
1899
|
+
})
|
|
1900
|
+
|
|
1901
|
+
it('TC-u4-recordmanifest-compat: 旧 manifest(仅 id/rootSessionId/sessionFile)→ 富字段 undefined,不抛错', async () => {
|
|
1902
|
+
await writeMainSession(dir, '--root-cwd--', ROOT, { cwd: '/proj/root' })
|
|
1903
|
+
// 旧格式 manifest:无 task/slug/model/status/agentName
|
|
1904
|
+
await writeRecordManifest(dir, 'sa-old', {
|
|
1905
|
+
rootSessionId: ROOT,
|
|
1906
|
+
sessionFile: '/nonexistent/old.jsonl',
|
|
1907
|
+
})
|
|
1908
|
+
|
|
1909
|
+
// listRecordManifests 兼容:返回该 manifest,富字段 undefined
|
|
1910
|
+
const manifests = await listRecordManifests(dir)
|
|
1911
|
+
expect(manifests).toHaveLength(1)
|
|
1912
|
+
const m = manifests[0]
|
|
1913
|
+
expect(m.id).toBe('sa-old')
|
|
1914
|
+
expect(m.rootSessionId).toBe(ROOT)
|
|
1915
|
+
expect(m.task).toBeUndefined()
|
|
1916
|
+
expect(m.slug).toBeUndefined()
|
|
1917
|
+
expect(m.model).toBeUndefined()
|
|
1918
|
+
expect(m.status).toBeUndefined()
|
|
1919
|
+
expect(m.agentName).toBeUndefined()
|
|
1920
|
+
|
|
1921
|
+
// buildFamilyFromFs 消费旧 manifest(孤儿路径)不抛错,富字段 undefined
|
|
1922
|
+
const family = await buildFamilyFromFs(ROOT, dir)
|
|
1923
|
+
const ghost = family.subagents.find((s) => s.sessionId === 'sa-old')
|
|
1924
|
+
expect(ghost).toBeDefined()
|
|
1925
|
+
expect(ghost!.cleanedUp).toBe(true)
|
|
1926
|
+
expect(ghost!.task).toBeUndefined()
|
|
1927
|
+
expect(ghost!.model).toBeUndefined()
|
|
1928
|
+
// 旧 manifest slug 缺失 → 回退 agentName(也缺)→ 空串兜底
|
|
1929
|
+
expect(ghost!.slug).toBe('')
|
|
1930
|
+
})
|
|
452
1931
|
})
|
|
453
1932
|
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
1933
|
+
// ============================================================
|
|
1934
|
+
// .events 文件族结构性忽略(W1 D3 落点裁决 + 设计检查点④)
|
|
1935
|
+
// ============================================================
|
|
1936
|
+
//
|
|
1937
|
+
// record 事件文件族(<recordsDir>/<sa-id>.events,无 .jsonl 后缀)与 manifest 同目录
|
|
1938
|
+
// 同主名。session-reader 的两层兼容形态在此断言:
|
|
1939
|
+
// ① 结构性忽略——subagents 树扫描只收 .jsonl,.events 天然不进候选集(D3「无后缀
|
|
1940
|
+
// 的结构性收益」:被忽略或被误读两类风险一次排空);
|
|
1941
|
+
// ② 首行头行读者兼容——.events 首行是自描述头行 {"type":"record-journal","id":...}
|
|
1942
|
+
// (写侧 record-events.ts toRecordEventHeader 契约),session-reader 对未知文件
|
|
1943
|
+
// 读首行判 header 时命中非 session header 即忽略(检查点④:零成本兼容——不改
|
|
1944
|
+
// 扫描器即可与该文件族共存)。
|
|
1945
|
+
|
|
1946
|
+
describe('.events 文件族结构性忽略(W1 D3 / 检查点④)', () => {
|
|
1947
|
+
let dataDir: string
|
|
1948
|
+
let dir: string // agentDir = <dataDir>/agent(生产拓扑复刻,见 makeAgentDir 注释)
|
|
1949
|
+
|
|
1950
|
+
beforeEach(async () => {
|
|
1951
|
+
dataDir = await makeAgentDir()
|
|
1952
|
+
dir = agentDirOf(dataDir)
|
|
1953
|
+
})
|
|
1954
|
+
afterEach(async () => {
|
|
1955
|
+
await rm(dataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
1956
|
+
})
|
|
1957
|
+
|
|
1958
|
+
/** 写 record 事件文件(首行头行 + 事件行集,形态对齐写侧 record-events.ts)。 */
|
|
1959
|
+
async function writeEventsFile(saId: string): Promise<string> {
|
|
1960
|
+
const recordsDir = resolveStoreSubagentRecordsDir(dir, ROOT)
|
|
1961
|
+
await mkdir(recordsDir, { recursive: true })
|
|
1962
|
+
const path = join(recordsDir, `${saId}.events`)
|
|
1963
|
+
const lines = [
|
|
1964
|
+
JSON.stringify({ type: 'record-journal', id: saId }),
|
|
1965
|
+
JSON.stringify({ type: 'record-created', seq: 1, ts: 1, agent: 'dev', task: 't', slug: 's', origin: 'tool', rootSessionId: ROOT, depth: 0, mode: 'sync', startedAt: 1 }),
|
|
1966
|
+
JSON.stringify({ type: 'record-settled', seq: 2, ts: 2, stopReason: 'end_turn', endedAt: 2, turns: 1, totalTokens: 5 }),
|
|
1967
|
+
]
|
|
1968
|
+
await writeFile(path, lines.join('\n') + '\n')
|
|
1969
|
+
return path
|
|
465
1970
|
}
|
|
1971
|
+
|
|
1972
|
+
it('subagent 树扫描不收 .events:resolveSessionRoots 的 subagent 根 files 含 .jsonl 不含 .events', async () => {
|
|
1973
|
+
await writeMainSession(dir, '--root-cwd--', ROOT, { cwd: '/proj/root' })
|
|
1974
|
+
const subPath = await writeSubagentSession(dir, SUB_REAL, {
|
|
1975
|
+
rootSessionId: ROOT,
|
|
1976
|
+
slug: 'ev-sibling',
|
|
1977
|
+
})
|
|
1978
|
+
await writeEventsFile('sa-ev-1')
|
|
1979
|
+
|
|
1980
|
+
const roots = await resolveSessionRoots({ agentDir: dir })
|
|
1981
|
+
const subRoot = roots.find((r) => r.kind === 'subagent')
|
|
1982
|
+
|
|
1983
|
+
expect(subRoot).toBeDefined()
|
|
1984
|
+
expect(subRoot!.files.map((f) => f.path)).toContain(subPath)
|
|
1985
|
+
expect(subRoot!.files.some((f) => f.path.endsWith('.events'))).toBe(false)
|
|
1986
|
+
})
|
|
1987
|
+
|
|
1988
|
+
it('首行头行命中非 session header 即忽略:record-journal 头行 → parseSessionHeader 返回 null', async () => {
|
|
1989
|
+
const eventsPath = await writeEventsFile('sa-ev-1')
|
|
1990
|
+
// 检查点④形态:session-reader 对未知文件读首行判 header,非 session 行 → null
|
|
1991
|
+
//(即使未来通配规则把 .events 收进候选集,该文件也不会被误认成 session)
|
|
1992
|
+
const firstLine = await readSessionHeaderFirstLine(eventsPath)
|
|
1993
|
+
expect(firstLine).toBeDefined()
|
|
1994
|
+
expect(parseSessionHeader(firstLine)).toBeNull()
|
|
1995
|
+
})
|
|
1996
|
+
|
|
1997
|
+
it('家族扫描:.events 与正常 subagent 同目录共存 → 家族正常、不产幻影 subagent 节点', async () => {
|
|
1998
|
+
await writeMainSession(dir, '--root-cwd--', ROOT, { cwd: '/proj/root' })
|
|
1999
|
+
await writeSubagentSession(dir, SUB_REAL, {
|
|
2000
|
+
rootSessionId: ROOT,
|
|
2001
|
+
slug: 'ev-coexist',
|
|
2002
|
+
})
|
|
2003
|
+
await writeEventsFile('sa-ev-1')
|
|
2004
|
+
|
|
2005
|
+
const family = await buildFamilyFromFs(ROOT, dir)
|
|
2006
|
+
|
|
2007
|
+
// 正常 subagent 在场
|
|
2008
|
+
expect(family.subagents.some((s) => s.sessionId === SUB_REAL)).toBe(true)
|
|
2009
|
+
// .events 的 record id 不进任何集合(无幻影)
|
|
2010
|
+
expect(family.subagents.some((s) => s.sessionId === 'sa-ev-1')).toBe(false)
|
|
2011
|
+
expect(family.subagents.some((s) => s.sessionId.startsWith('sa-ev'))).toBe(false)
|
|
2012
|
+
})
|
|
466
2013
|
})
|
|
467
2014
|
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
2015
|
+
// ============================================================
|
|
2016
|
+
// U4 C4 契约:extractSessionIdFromFilename 导出(仅加 export,不改逻辑,供 w4 复用)
|
|
2017
|
+
// ============================================================
|
|
2018
|
+
|
|
2019
|
+
describe('U4 extractSessionIdFromFilename 导出(C4 契约)', () => {
|
|
2020
|
+
it('导出生效:从 <timestamp>_<sessionId>.jsonl 提取 sessionId', () => {
|
|
2021
|
+
expect(
|
|
2022
|
+
extractSessionIdFromFilename('2026-08-07T16-49-48-393Z_019fdd21-8169-7a02-8f11-eef6c9ca11cc.jsonl'),
|
|
2023
|
+
).toBe('019fdd21-8169-7a02-8f11-eef6c9ca11cc')
|
|
2024
|
+
})
|
|
2025
|
+
|
|
2026
|
+
it('非 uuid 特征文件名 → 空串(行为不变)', () => {
|
|
2027
|
+
expect(extractSessionIdFromFilename('not-a-uuid.jsonl')).toBe('')
|
|
2028
|
+
expect(extractSessionIdFromFilename('readme.txt')).toBe('')
|
|
2029
|
+
})
|
|
2030
|
+
|
|
2031
|
+
it('无下划线的纯 uuid 文件名 → 返回 uuid(兼容简化场景)', () => {
|
|
2032
|
+
expect(extractSessionIdFromFilename('019fdd21-8169-7a02-8f11-eef6c9ca11cc.jsonl')).toBe(
|
|
2033
|
+
'019fdd21-8169-7a02-8f11-eef6c9ca11cc',
|
|
2034
|
+
)
|
|
2035
|
+
})
|
|
486
2036
|
})
|
|
487
|
-
|
|
2037
|
+
|
|
2038
|
+
// U4 富字段透传契约由 fixture 侧逐条覆盖:manifest 主(TC-u4-manifest-enrich,task/slug/
|
|
2039
|
+
// model/status 全字段 + sessionFile 非空)、P-fallback(TC-u4-pfallback-identity,status 必
|
|
2040
|
+
// undefined)、孤儿(TC-u4-orphan-manifest)、旧 manifest 兼容(TC-u4-recordmanifest-compat)。
|
|
2041
|
+
});
|