@zhushanwen/pi-session-reader 0.13.0 → 0.13.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,382 +1,762 @@
1
+ // entry-anchor.test.ts —— session-reader C04 簇合并产物(round5-G)。
2
+ // 共同被测业务模块:`src/discovery/entry-anchor.ts`。mock 相容:全簇零 vi.mock。
3
+ // 顶层正文按来源包 describe 纯前缀分节,it 层名零改动。
4
+ //
5
+ // 来源:
6
+ // - src/__tests__/entry-anchor.test.ts(18 用例,skip 0)
7
+ // - src/__tests__/zcode-manifest.test.ts(18 用例,skip 0)
8
+
1
9
  import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
10
  import { tmpdir } from 'node:os'
3
11
  import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'node:fs'
4
- import { join } from 'node:path'
5
-
12
+ import { join, dirname } from 'node:path'
6
13
  import { SUBAGENT_RECORD_CUSTOM_TYPE, SUBAGENT_RECORD_ENTRY_VERSION } from '@zhushanwen/subagent-core'
7
-
8
14
  import { findZcodeEntryAnchor, type ZcodeAnchor } from '../discovery/entry-anchor.js'
15
+ import { readZcodeManifest, listZcodeManifests } from '../discovery/zcode-manifest.js'
9
16
 
10
- // ============================================================
11
- // 变体 E(设计 §4 场景 1a):manifest 缺席时 entry 兜底定位(F16 每轮覆写 → 取文件
12
- // 顺序末条,collectLastRecordEntries 同构)。
13
- //
14
- // 单元边界:本文件只测「定位」(sa-id → sessionRef 锚);「读到 sess-B 行集内容」的
15
- // 库读取断言属 U9 集成测试(场景 1a 变体 E 全量断言),此处不引入 sqlite 依赖。
16
- // sess-A / sess-B 本身即两个可区分的锚值——取首条或合并的实现会直接失败。
17
- // ============================================================
18
-
19
- const DB_PATH = '/data/agent/engines/zcode/session-db/db.sqlite'
20
-
21
- let dir: string
22
-
23
- beforeEach(() => {
24
- dir = mkdtempSync(join(tmpdir(), 'entry-anchor-test-'))
25
- })
26
- afterEach(() => {
27
- rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
28
- })
29
-
30
- /** subagent-record custom entry 行(顶层 id = entry id;data.id = record id 即 sa-id)。 */
31
- function recordLine(
32
- entryId: string,
33
- saId: string,
34
- sessionRef: Record<string, string>,
35
- v = 2,
36
- ): string {
37
- // 登记 §3.3:v1 全量快照形态已删——锚源 = v2 终态条(settled 携带 engine/engineHandle)。
38
- return JSON.stringify({
39
- type: 'custom',
40
- customType: 'subagent-record',
41
- id: entryId,
42
- parentId: null,
43
- data: {
44
- v,
45
- kind: 'settled',
46
- id: saId,
47
- status: 'idle',
48
- stopReason: 'completed',
49
- endedAt: 2,
50
- turns: 1,
51
- totalTokens: 1,
52
- engine: 'zcode',
53
- engineHandle: { sessionRef, poolKey: 'shared' },
54
- },
17
+ // ======== 来源:entry-anchor.test.ts ========
18
+ describe("entry-anchor", () => {
19
+ // ============================================================
20
+ // 变体 E(设计 §4 场景 1a):manifest 缺席时 entry 兜底定位(F16 每轮覆写 → 取文件
21
+ // 顺序末条,collectLastRecordEntries 同构)。
22
+ //
23
+ // 单元边界:本文件只测「定位」(sa-id → sessionRef 锚);「读到 sess-B 行集内容」的
24
+ // 库读取断言属 U9 集成测试(场景 1a 变体 E 全量断言),此处不引入 sqlite 依赖。
25
+ // sess-A / sess-B 本身即两个可区分的锚值——取首条或合并的实现会直接失败。
26
+ // ============================================================
27
+
28
+ const DB_PATH = '/data/agent/engines/zcode/session-db/db.sqlite'
29
+
30
+ let dir: string
31
+
32
+ beforeEach(() => {
33
+ dir = mkdtempSync(join(tmpdir(), 'entry-anchor-test-'))
55
34
  })
56
- }
57
-
58
- /** pi 形态 message 行(真实感填充)。 */
59
- function messageLine(entryId: string, text: string): string {
60
- return JSON.stringify({
61
- type: 'message',
62
- id: entryId,
63
- parentId: null,
64
- message: { role: 'user', content: text },
35
+
36
+ afterEach(() => {
37
+ rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
65
38
  })
66
- }
67
39
 
68
- /**
40
+ /** subagent-record custom entry 行(顶层 id = entry id;data.id = record id 即 sa-id)。 */
41
+ function recordLine(
42
+ entryId: string,
43
+ saId: string,
44
+ sessionRef: Record<string, string>,
45
+ v = 2,
46
+ ): string {
47
+ // 登记 §3.3:v1 全量快照形态已删——锚源 = v2 终态条(settled 携带 engine/engineHandle)。
48
+ return JSON.stringify({
49
+ type: 'custom',
50
+ customType: 'subagent-record',
51
+ id: entryId,
52
+ parentId: null,
53
+ data: {
54
+ v,
55
+ kind: 'settled',
56
+ id: saId,
57
+ status: 'idle',
58
+ stopReason: 'completed',
59
+ endedAt: 2,
60
+ turns: 1,
61
+ totalTokens: 1,
62
+ engine: 'zcode',
63
+ engineHandle: { sessionRef, poolKey: 'shared' },
64
+ },
65
+ })
66
+ }
67
+
68
+ /** pi 形态 message 行(真实感填充)。 */
69
+ function messageLine(entryId: string, text: string): string {
70
+ return JSON.stringify({
71
+ type: 'message',
72
+ id: entryId,
73
+ parentId: null,
74
+ message: { role: 'user', content: text },
75
+ })
76
+ }
77
+
78
+ /**
69
79
  * v2 终态条行(W1 D1:`{v:2, kind:"settled", id, engine?, engineHandle?, ...}`——
70
80
  * 载荷形态对齐 subagent-core SubagentRecordSettledEntryData,锚取 engineHandle.sessionRef)。
71
81
  */
72
- function v2SettledLine(
73
- entryId: string,
74
- saId: string,
75
- opts: {
76
- sessionRef?: Record<string, string>
77
- engine?: string
78
- } = {},
79
- ): string {
80
- const data: Record<string, unknown> = {
81
- v: 2,
82
- kind: 'settled',
83
- id: saId,
84
- status: 'idle',
85
- stopReason: 'end_turn',
86
- endedAt: 123,
87
- turns: 1,
88
- totalTokens: 10,
89
- model: undefined,
90
- thinkingLevel: undefined,
91
- }
92
- if (opts.engine !== undefined) data.engine = opts.engine
93
- if (opts.sessionRef !== undefined) {
94
- data.engineHandle = { sessionRef: opts.sessionRef, poolKey: 'shared' }
82
+ function v2SettledLine(
83
+ entryId: string,
84
+ saId: string,
85
+ opts: {
86
+ sessionRef?: Record<string, string>
87
+ engine?: string
88
+ } = {},
89
+ ): string {
90
+ const data: Record<string, unknown> = {
91
+ v: 2,
92
+ kind: 'settled',
93
+ id: saId,
94
+ status: 'idle',
95
+ stopReason: 'end_turn',
96
+ endedAt: 123,
97
+ turns: 1,
98
+ totalTokens: 10,
99
+ model: undefined,
100
+ thinkingLevel: undefined,
101
+ }
102
+ if (opts.engine !== undefined) data.engine = opts.engine
103
+ if (opts.sessionRef !== undefined) {
104
+ data.engineHandle = { sessionRef: opts.sessionRef, poolKey: 'shared' }
105
+ }
106
+ return JSON.stringify({
107
+ type: 'custom',
108
+ customType: 'subagent-record',
109
+ id: entryId,
110
+ parentId: null,
111
+ data,
112
+ })
95
113
  }
96
- return JSON.stringify({
97
- type: 'custom',
98
- customType: 'subagent-record',
99
- id: entryId,
100
- parentId: null,
101
- data,
102
- })
103
- }
104
114
 
105
- /**
115
+ /**
106
116
  * v2 注册条行(`{v:2, kind:"registered", id, agent, task, ...}`——身份域字段,不携带
107
117
  * engineHandle;设计 D1 裁决「sessionRef 双键取自终态条」,注册条目不是锚源)。
108
118
  */
109
- function v2RegisteredLine(entryId: string, saId: string): string {
110
- return JSON.stringify({
111
- type: 'custom',
112
- customType: 'subagent-record',
113
- id: entryId,
114
- parentId: null,
115
- data: {
116
- v: 2,
117
- kind: 'registered',
118
- id: saId,
119
- agent: 'dev',
120
- task: 't',
121
- slug: 's',
122
- origin: 'tool',
123
- rootSessionId: 'root-1',
124
- depth: 0,
125
- startedAt: 1,
126
- },
127
- })
128
- }
129
-
130
- /** 缺 dbPath 的 sessionRef(pi record 形态:只有 sessionId 单键)。 */
131
- const REF_NO_DBPATH = { sessionId: 'sess-pi-x' }
132
-
133
- /** 缺 sessionId 的 sessionRef(半键)。 */
134
- const REF_NO_SESSION_ID = { dbPath: DB_PATH }
135
-
136
- function writeMainSession(name: string, lines: string[]): string {
137
- mkdirSync(dir, { recursive: true })
138
- const file = join(dir, name)
139
- writeFileSync(file, lines.join('\n') + '\n', 'utf8')
140
- return file
141
- }
142
-
143
- describe('变体 E:同 sa-id 多条 entry 取文件顺序末条(F16 覆写)', () => {
144
- it('首条 sess-A / 末条 sess-B → 命中 sess-B 且 ≠ sess-A(取首条/合并必失败)', async () => {
145
- const file = writeMainSession('main.jsonl', [
146
- JSON.stringify({ type: 'session', id: 'main-1', cwd: '/tmp/proj' }),
147
- messageLine('m1', 'dispatch subagent'),
148
- recordLine('e1', 'sa-1', { sessionId: 'sess-A', dbPath: DB_PATH }),
149
- messageLine('m2', 'round 1 done'),
150
- recordLine('e2', 'sa-1', { sessionId: 'sess-B', dbPath: DB_PATH }),
151
- ])
152
-
153
- const anchor = await findZcodeEntryAnchor([file], 'sa-1')
154
-
155
- // 非平凡断言:两个不同锚值使「末条 vs 首条」结果可区分
156
- expect(anchor).toBeDefined()
157
- expect(anchor?.sessionId).toBe('sess-B')
158
- expect(anchor?.sessionId).not.toBe('sess-A')
159
- expect(anchor?.dbPath).toBe(DB_PATH)
160
- })
119
+ function v2RegisteredLine(entryId: string, saId: string): string {
120
+ return JSON.stringify({
121
+ type: 'custom',
122
+ customType: 'subagent-record',
123
+ id: entryId,
124
+ parentId: null,
125
+ data: {
126
+ v: 2,
127
+ kind: 'registered',
128
+ id: saId,
129
+ agent: 'dev',
130
+ task: 't',
131
+ slug: 's',
132
+ origin: 'tool',
133
+ rootSessionId: 'root-1',
134
+ depth: 0,
135
+ startedAt: 1,
136
+ },
137
+ })
138
+ }
161
139
 
162
- it('多条不同 sa-id 交错:只命中目标 id 的末条,不串锚', async () => {
163
- const file = writeMainSession('main.jsonl', [
164
- JSON.stringify({ type: 'session', id: 'main-1' }),
165
- recordLine('e1', 'sa-1', { sessionId: 'sa1-r1', dbPath: DB_PATH }),
166
- recordLine('e2', 'sa-2', { sessionId: 'sa2-r1', dbPath: DB_PATH }),
167
- recordLine('e3', 'sa-1', { sessionId: 'sa1-r2', dbPath: DB_PATH }),
168
- recordLine('e4', 'sa-2', { sessionId: 'sa2-r2', dbPath: DB_PATH }),
169
- recordLine('e5', 'sa-1', { sessionId: 'sa1-r3', dbPath: DB_PATH }),
170
- ])
171
-
172
- expect((await findZcodeEntryAnchor([file], 'sa-1'))?.sessionId).toBe('sa1-r3')
173
- expect((await findZcodeEntryAnchor([file], 'sa-2'))?.sessionId).toBe('sa2-r2')
174
- })
175
- })
176
-
177
- describe('行级容错(best-effort,不因一行坏 JSON 失败整文件)', () => {
178
- it('坏行穿插 + 末行半行:正常命中末条完整条目', async () => {
179
- const file = writeMainSession('main.jsonl', [
180
- JSON.stringify({ type: 'session', id: 'main-1' }),
181
- 'not-json-at-all{{{',
182
- recordLine('e1', 'sa-1', { sessionId: 'sess-A', dbPath: DB_PATH }),
183
- '{"type":"custom","customType":"subagent-record","data":{"broken"',
184
- ])
185
-
186
- const anchor = await findZcodeEntryAnchor([file], 'sa-1')
187
- expect(anchor?.sessionId).toBe('sess-A')
140
+ /** 缺 dbPath 的 sessionRef(pi record 形态:只有 sessionId 单键)。 */
141
+ const REF_NO_DBPATH = { sessionId: 'sess-pi-x' }
142
+
143
+ /** 缺 sessionId 的 sessionRef(半键)。 */
144
+ const REF_NO_SESSION_ID = { dbPath: DB_PATH }
145
+
146
+ function writeMainSession(name: string, lines: string[]): string {
147
+ mkdirSync(dir, { recursive: true })
148
+ const file = join(dir, name)
149
+ writeFileSync(file, lines.join('\n') + '\n', 'utf8')
150
+ return file
151
+ }
152
+
153
+ describe('变体 E:同 sa-id 多条 entry 取文件顺序末条(F16 覆写)', () => {
154
+ it('首条 sess-A / 末条 sess-B → 命中 sess-B 且 ≠ sess-A(取首条/合并必失败)', async () => {
155
+ const file = writeMainSession('main.jsonl', [
156
+ JSON.stringify({ type: 'session', id: 'main-1', cwd: '/tmp/proj' }),
157
+ messageLine('m1', 'dispatch subagent'),
158
+ recordLine('e1', 'sa-1', { sessionId: 'sess-A', dbPath: DB_PATH }),
159
+ messageLine('m2', 'round 1 done'),
160
+ recordLine('e2', 'sa-1', { sessionId: 'sess-B', dbPath: DB_PATH }),
161
+ ])
162
+
163
+ const anchor = await findZcodeEntryAnchor([file], 'sa-1')
164
+
165
+ // 非平凡断言:两个不同锚值使「末条 vs 首条」结果可区分
166
+ expect(anchor).toBeDefined()
167
+ expect(anchor?.sessionId).toBe('sess-B')
168
+ expect(anchor?.sessionId).not.toBe('sess-A')
169
+ expect(anchor?.dbPath).toBe(DB_PATH)
170
+ })
171
+
172
+ it('多条不同 sa-id 交错:只命中目标 id 的末条,不串锚', async () => {
173
+ const file = writeMainSession('main.jsonl', [
174
+ JSON.stringify({ type: 'session', id: 'main-1' }),
175
+ recordLine('e1', 'sa-1', { sessionId: 'sa1-r1', dbPath: DB_PATH }),
176
+ recordLine('e2', 'sa-2', { sessionId: 'sa2-r1', dbPath: DB_PATH }),
177
+ recordLine('e3', 'sa-1', { sessionId: 'sa1-r2', dbPath: DB_PATH }),
178
+ recordLine('e4', 'sa-2', { sessionId: 'sa2-r2', dbPath: DB_PATH }),
179
+ recordLine('e5', 'sa-1', { sessionId: 'sa1-r3', dbPath: DB_PATH }),
180
+ ])
181
+
182
+ expect((await findZcodeEntryAnchor([file], 'sa-1'))?.sessionId).toBe('sa1-r3')
183
+ expect((await findZcodeEntryAnchor([file], 'sa-2'))?.sessionId).toBe('sa2-r2')
184
+ })
188
185
  })
189
- })
190
-
191
- describe('形状校验窄而严:缺键条目不算命中,继续找更早条目', () => {
192
- it('末条 sessionRef 缺 dbPath → 命中更早的完整条目', async () => {
193
- const file = writeMainSession('main.jsonl', [
194
- JSON.stringify({ type: 'session', id: 'main-1' }),
195
- recordLine('e1', 'sa-1', { sessionId: 'sess-EARLY', dbPath: DB_PATH }),
196
- recordLine('e2', 'sa-1', REF_NO_DBPATH),
197
- ])
198
-
199
- const anchor = await findZcodeEntryAnchor([file], 'sa-1')
200
- expect(anchor?.sessionId).toBe('sess-EARLY')
186
+
187
+ describe('行级容错(best-effort,不因一行坏 JSON 失败整文件)', () => {
188
+ it('坏行穿插 + 末行半行:正常命中末条完整条目', async () => {
189
+ const file = writeMainSession('main.jsonl', [
190
+ JSON.stringify({ type: 'session', id: 'main-1' }),
191
+ 'not-json-at-all{{{',
192
+ recordLine('e1', 'sa-1', { sessionId: 'sess-A', dbPath: DB_PATH }),
193
+ '{"type":"custom","customType":"subagent-record","data":{"broken"',
194
+ ])
195
+
196
+ const anchor = await findZcodeEntryAnchor([file], 'sa-1')
197
+ expect(anchor?.sessionId).toBe('sess-A')
198
+ })
201
199
  })
202
200
 
203
- it('全部条目缺双键 → not-found(undefined)', async () => {
204
- const file = writeMainSession('main.jsonl', [
205
- JSON.stringify({ type: 'session', id: 'main-1' }),
206
- recordLine('e1', 'sa-1', REF_NO_DBPATH),
207
- recordLine('e2', 'sa-1', REF_NO_SESSION_ID),
208
- ])
201
+ describe('形状校验窄而严:缺键条目不算命中,继续找更早条目', () => {
202
+ it('末条 sessionRef 缺 dbPath → 命中更早的完整条目', async () => {
203
+ const file = writeMainSession('main.jsonl', [
204
+ JSON.stringify({ type: 'session', id: 'main-1' }),
205
+ recordLine('e1', 'sa-1', { sessionId: 'sess-EARLY', dbPath: DB_PATH }),
206
+ recordLine('e2', 'sa-1', REF_NO_DBPATH),
207
+ ])
209
208
 
210
- expect(await findZcodeEntryAnchor([file], 'sa-1')).toBeUndefined()
211
- })
209
+ const anchor = await findZcodeEntryAnchor([file], 'sa-1')
210
+ expect(anchor?.sessionId).toBe('sess-EARLY')
211
+ })
212
212
 
213
- it('sessionRef 键为空串 → 不算命中(非空 string 才算锚)', async () => {
214
- const file = writeMainSession('main.jsonl', [
215
- JSON.stringify({ type: 'session', id: 'main-1' }),
216
- recordLine('e1', 'sa-1', { sessionId: '', dbPath: DB_PATH }),
217
- recordLine('e2', 'sa-1', { sessionId: 'sess-A', dbPath: '' }),
218
- ])
213
+ it('全部条目缺双键 → not-found(undefined)', async () => {
214
+ const file = writeMainSession('main.jsonl', [
215
+ JSON.stringify({ type: 'session', id: 'main-1' }),
216
+ recordLine('e1', 'sa-1', REF_NO_DBPATH),
217
+ recordLine('e2', 'sa-1', REF_NO_SESSION_ID),
218
+ ])
219
219
 
220
- expect(await findZcodeEntryAnchor([file], 'sa-1')).toBeUndefined()
220
+ expect(await findZcodeEntryAnchor([file], 'sa-1')).toBeUndefined()
221
+ })
222
+
223
+ it('sessionRef 键为空串 → 不算命中(非空 string 才算锚)', async () => {
224
+ const file = writeMainSession('main.jsonl', [
225
+ JSON.stringify({ type: 'session', id: 'main-1' }),
226
+ recordLine('e1', 'sa-1', { sessionId: '', dbPath: DB_PATH }),
227
+ recordLine('e2', 'sa-1', { sessionId: 'sess-A', dbPath: '' }),
228
+ ])
229
+
230
+ expect(await findZcodeEntryAnchor([file], 'sa-1')).toBeUndefined()
231
+ })
232
+
233
+ it('data.v 非当前版本(future-v,如 3)不算命中,更早 v=2 条目仍可命中', async () => {
234
+ const file = writeMainSession('main.jsonl', [
235
+ JSON.stringify({ type: 'session', id: 'main-1' }),
236
+ recordLine('e1', 'sa-1', { sessionId: 'sess-A', dbPath: DB_PATH }),
237
+ recordLine('e2', 'sa-1', { sessionId: 'sess-V3', dbPath: DB_PATH }, 3),
238
+ ])
239
+
240
+ const anchor = await findZcodeEntryAnchor([file], 'sa-1')
241
+ expect(anchor?.sessionId).toBe('sess-A')
242
+ expect(anchor?.sessionId).not.toBe('sess-V3')
243
+ })
221
244
  })
222
245
 
223
- it('data.v 非当前版本(future-v,如 3)不算命中,更早 v=2 条目仍可命中', async () => {
224
- const file = writeMainSession('main.jsonl', [
225
- JSON.stringify({ type: 'session', id: 'main-1' }),
226
- recordLine('e1', 'sa-1', { sessionId: 'sess-A', dbPath: DB_PATH }),
227
- recordLine('e2', 'sa-1', { sessionId: 'sess-V3', dbPath: DB_PATH }, 3),
228
- ])
246
+ // ============================================================
247
+ // v2 终态条锚定(W1 D1 版本门补齐:sessionRef 双键取自终态条)
248
+ // ============================================================
249
+
250
+ describe('v2 终态条锚定:kind="settled" + engineHandle.sessionRef 双键', () => {
251
+ it('v2 终态条完整锚(sessionRef 双键齐)→ 命中', async () => {
252
+ const file = writeMainSession('main.jsonl', [
253
+ JSON.stringify({ type: 'session', id: 'main-1' }),
254
+ messageLine('m1', 'dispatch'),
255
+ v2SettledLine('e1', 'sa-1', {
256
+ engine: 'zcode',
257
+ sessionRef: { sessionId: 'sess-V2A', dbPath: DB_PATH },
258
+ }),
259
+ ])
260
+
261
+ const anchor = await findZcodeEntryAnchor([file], 'sa-1')
262
+ expect(anchor).toBeDefined()
263
+ expect(anchor?.sessionId).toBe('sess-V2A')
264
+ expect(anchor?.dbPath).toBe(DB_PATH)
265
+ })
266
+
267
+ it('v2 注册条(kind="registered",不携带 engineHandle)→ 不命中(锚只取自终态条)', async () => {
268
+ const file = writeMainSession('main.jsonl', [
269
+ JSON.stringify({ type: 'session', id: 'main-1' }),
270
+ v2RegisteredLine('e-reg', 'sa-1'),
271
+ ])
229
272
 
230
- const anchor = await findZcodeEntryAnchor([file], 'sa-1')
231
- expect(anchor?.sessionId).toBe('sess-A')
232
- expect(anchor?.sessionId).not.toBe('sess-V3')
273
+ expect(await findZcodeEntryAnchor([file], 'sa-1')).toBeUndefined()
274
+ })
275
+
276
+ it('同 id 两条 v2 终态条(早/晚)→ 末条胜(末条锚定语义)', async () => {
277
+ const file = writeMainSession('main.jsonl', [
278
+ JSON.stringify({ type: 'session', id: 'main-1' }),
279
+ recordLine('e1', 'sa-1', { sessionId: 'sess-OLD', dbPath: DB_PATH }),
280
+ v2SettledLine('e2', 'sa-1', {
281
+ engine: 'zcode',
282
+ sessionRef: { sessionId: 'sess-NEW', dbPath: DB_PATH },
283
+ }),
284
+ ])
285
+
286
+ const anchor = await findZcodeEntryAnchor([file], 'sa-1')
287
+ expect(anchor?.sessionId).toBe('sess-NEW')
288
+ expect(anchor?.sessionId).not.toBe('sess-OLD')
289
+ })
290
+
291
+ it('v2 终态条锚残缺(缺 engineHandle)→ 不命中,更早完整条目仍可命中(缺键不遮蔽)', async () => {
292
+ const file = writeMainSession('main.jsonl', [
293
+ JSON.stringify({ type: 'session', id: 'main-1' }),
294
+ recordLine('e1', 'sa-1', { sessionId: 'sess-EARLY', dbPath: DB_PATH }),
295
+ v2SettledLine('e2', 'sa-1', { engine: 'zcode' }), // 无 sessionRef
296
+ ])
297
+
298
+ const anchor = await findZcodeEntryAnchor([file], 'sa-1')
299
+ expect(anchor?.sessionId).toBe('sess-EARLY')
300
+ })
301
+
302
+ it('v2 但 kind 不在词表(unknown-kind,非 registered/settled)→ 不命中', async () => {
303
+ const badKind = JSON.stringify({
304
+ type: 'custom',
305
+ customType: 'subagent-record',
306
+ id: 'e-bad',
307
+ parentId: null,
308
+ data: {
309
+ v: 2,
310
+ kind: 'migrated',
311
+ id: 'sa-1',
312
+ engineHandle: { sessionRef: { sessionId: 'sess-BAD', dbPath: DB_PATH }, poolKey: 'shared' },
313
+ },
314
+ })
315
+ const file = writeMainSession('main.jsonl', [
316
+ JSON.stringify({ type: 'session', id: 'main-1' }),
317
+ badKind,
318
+ ])
319
+
320
+ expect(await findZcodeEntryAnchor([file], 'sa-1')).toBeUndefined()
321
+ })
233
322
  })
234
- })
235
-
236
- // ============================================================
237
- // v2 终态条锚定(W1 D1 版本门补齐:sessionRef 双键取自终态条)
238
- // ============================================================
239
-
240
- describe('v2 终态条锚定:kind="settled" + engineHandle.sessionRef 双键', () => {
241
- it('v2 终态条完整锚(sessionRef 双键齐)→ 命中', async () => {
242
- const file = writeMainSession('main.jsonl', [
243
- JSON.stringify({ type: 'session', id: 'main-1' }),
244
- messageLine('m1', 'dispatch'),
245
- v2SettledLine('e1', 'sa-1', {
246
- engine: 'zcode',
247
- sessionRef: { sessionId: 'sess-V2A', dbPath: DB_PATH },
248
- }),
249
- ])
250
-
251
- const anchor = await findZcodeEntryAnchor([file], 'sa-1')
252
- expect(anchor).toBeDefined()
253
- expect(anchor?.sessionId).toBe('sess-V2A')
254
- expect(anchor?.dbPath).toBe(DB_PATH)
323
+
324
+ describe('无关条目不命中', () => {
325
+ it('非 subagent-record 的 custom entry、id 不匹配的 record 均跳过', async () => {
326
+ const todoLine = JSON.stringify({
327
+ type: 'custom',
328
+ customType: 'todo',
329
+ id: 't1',
330
+ parentId: null,
331
+ data: { v: 1, id: 'sa-1', engineHandle: { sessionRef: { sessionId: 'sess-TODO', dbPath: DB_PATH } } },
332
+ })
333
+ const file = writeMainSession('main.jsonl', [
334
+ JSON.stringify({ type: 'session', id: 'main-1' }),
335
+ todoLine,
336
+ recordLine('e1', 'sa-other', { sessionId: 'sess-OTHER', dbPath: DB_PATH }),
337
+ ])
338
+
339
+ expect(await findZcodeEntryAnchor([file], 'sa-1')).toBeUndefined()
340
+ expect((await findZcodeEntryAnchor([file], 'sa-other'))?.sessionId).toBe('sess-OTHER')
341
+ })
255
342
  })
256
343
 
257
- it('v2 注册条(kind="registered",不携带 engineHandle)→ 不命中(锚只取自终态条)', async () => {
258
- const file = writeMainSession('main.jsonl', [
259
- JSON.stringify({ type: 'session', id: 'main-1' }),
260
- v2RegisteredLine('e-reg', 'sa-1'),
261
- ])
344
+ describe('候选文件列表:顺序回退 + 命中即止(越界语义由调用方承载)', () => {
345
+ it('前一候选未命中 → 后一候选命中', async () => {
346
+ const empty = writeMainSession('empty.jsonl', [JSON.stringify({ type: 'session', id: 's0' })])
347
+ const withAnchor = writeMainSession('with-anchor.jsonl', [
348
+ JSON.stringify({ type: 'session', id: 'main-2' }),
349
+ recordLine('e1', 'sa-1', { sessionId: 'sess-B', dbPath: DB_PATH }),
350
+ ])
262
351
 
263
- expect(await findZcodeEntryAnchor([file], 'sa-1')).toBeUndefined()
264
- })
352
+ const anchor: ZcodeAnchor | undefined = await findZcodeEntryAnchor([empty, withAnchor], 'sa-1')
353
+ expect(anchor?.sessionId).toBe('sess-B')
354
+ })
265
355
 
266
- it('同 id 两条 v2 终态条(早/晚)→ 末条胜(末条锚定语义)', async () => {
267
- const file = writeMainSession('main.jsonl', [
268
- JSON.stringify({ type: 'session', id: 'main-1' }),
269
- recordLine('e1', 'sa-1', { sessionId: 'sess-OLD', dbPath: DB_PATH }),
270
- v2SettledLine('e2', 'sa-1', {
271
- engine: 'zcode',
272
- sessionRef: { sessionId: 'sess-NEW', dbPath: DB_PATH },
273
- }),
274
- ])
356
+ it('前一候选命中即止:不跨文件合并(后一候选的更新条目不覆盖)', async () => {
357
+ const first = writeMainSession('first.jsonl', [
358
+ JSON.stringify({ type: 'session', id: 'main-1' }),
359
+ recordLine('e1', 'sa-1', { sessionId: 'sess-A', dbPath: DB_PATH }),
360
+ ])
361
+ const second = writeMainSession('second.jsonl', [
362
+ JSON.stringify({ type: 'session', id: 'main-2' }),
363
+ recordLine('e2', 'sa-1', { sessionId: 'sess-B', dbPath: DB_PATH }),
364
+ ])
365
+
366
+ const anchor = await findZcodeEntryAnchor([first, second], 'sa-1')
367
+ expect(anchor?.sessionId).toBe('sess-A')
368
+ expect(anchor?.sessionId).not.toBe('sess-B')
369
+ })
370
+
371
+ it('候选不可读(不存在)→ 跳过继续,全不可读 → not-found', async () => {
372
+ const withAnchor = writeMainSession('with-anchor.jsonl', [
373
+ JSON.stringify({ type: 'session', id: 'main-1' }),
374
+ recordLine('e1', 'sa-1', { sessionId: 'sess-B', dbPath: DB_PATH }),
375
+ ])
275
376
 
276
- const anchor = await findZcodeEntryAnchor([file], 'sa-1')
277
- expect(anchor?.sessionId).toBe('sess-NEW')
278
- expect(anchor?.sessionId).not.toBe('sess-OLD')
377
+ expect((await findZcodeEntryAnchor([join(dir, 'missing.jsonl'), withAnchor], 'sa-1'))?.sessionId).toBe('sess-B')
378
+ expect(await findZcodeEntryAnchor([join(dir, 'missing.jsonl')], 'sa-1')).toBeUndefined()
379
+ expect(await findZcodeEntryAnchor([], 'sa-1')).toBeUndefined()
380
+ })
279
381
  })
280
382
 
281
- it('v2 终态条锚残缺(缺 engineHandle)→ 不命中,更早完整条目仍可命中(缺键不遮蔽)', async () => {
282
- const file = writeMainSession('main.jsonl', [
283
- JSON.stringify({ type: 'session', id: 'main-1' }),
284
- recordLine('e1', 'sa-1', { sessionId: 'sess-EARLY', dbPath: DB_PATH }),
285
- v2SettledLine('e2', 'sa-1', { engine: 'zcode' }), // 无 sessionRef
286
- ])
383
+ describe('协议字面量漂移检查(跨包契约)', () => {
384
+ it('entry-anchor 本地 customType 字面量 === subagent-core 写侧单源', () => {
385
+ // 源码本地持有磁盘协议字符串(生产依赖范围不引 subagent-core),此处锚定两侧同值
386
+ expect(SUBAGENT_RECORD_CUSTOM_TYPE).toBe('subagent-record')
387
+ })
287
388
 
288
- const anchor = await findZcodeEntryAnchor([file], 'sa-1')
289
- expect(anchor?.sessionId).toBe('sess-EARLY')
389
+ it('entry-anchor 本地 v2 认识版本 === subagent-core 写侧单源(写侧再升版本本检查先红)', () => {
390
+ expect(SUBAGENT_RECORD_ENTRY_VERSION).toBe(2)
391
+ })
290
392
  })
393
+ });
394
+
395
+ // ======== 来源:zcode-manifest.test.ts ========
396
+ describe("zcode-manifest", () => {
397
+ // ============================================================
398
+ // 变体 M(设计 §4 场景 1a):zcode manifest 直读(§3.1 第①步主路径)+ 枚举(family 用)。
399
+ //
400
+ // fixture 对齐 F21 磁盘真实形态:zcode manifest **无 sessionFile 键**
401
+ // (派生视图函数照写 sessionFile: undefined,JSON.stringify 丢键)、engine:'zcode'、
402
+ // engineHandle.sessionRef 双键齐。
403
+ //
404
+ // 单元边界:只测「认出 zcode manifest + 提取锚」;白名单门禁 / 错误码映射(U9)、
405
+ // entry 兜底定位语义(U7 已交付)不在本文件展开。
406
+ // ============================================================
407
+
408
+ const DB_PATH = '/data/agent/engines/zcode/session-db/db.sqlite'
409
+
410
+ let agentDir: string
411
+
412
+ /**
413
+ * 在存储树分区 <root>/<sid>/subagents/records/ 下写一个 manifest 文件(手写 JSON 字符串,
414
+ * 忠实控制键)。[session-store-layout-inversion D6/单新根] 新布局无 cwd-slug 层,
415
+ * slug 参数消亡(保留签名兼容既有调用点,弃用改名 _slug);分区名固定合法 sid。
416
+ */
417
+ function writeManifest(_slug: string, name: string, content: string): string {
418
+ const recordsDir = join(dirname(agentDir), 'ext-sessions', 'root-session-test', 'subagents', 'records')
419
+ mkdirSync(recordsDir, { recursive: true })
420
+ const file = join(recordsDir, name)
421
+ writeFileSync(file, content, 'utf8')
422
+ return file
423
+ }
291
424
 
292
- it('v2 但 kind 不在词表(unknown-kind,非 registered/settled)→ 不命中', async () => {
293
- const badKind = JSON.stringify({
425
+ /** 磁盘真实形态的 zcode manifest(无 sessionFile 键——F21) */
426
+ function zcodeManifestJson(opts: {
427
+ id: string
428
+ sessionId?: string
429
+ dbPath?: string
430
+ withEngineHandle?: boolean
431
+ engine?: string
432
+ rich?: Record<string, unknown>
433
+ }): string {
434
+ const m: Record<string, unknown> = {
435
+ id: opts.id,
436
+ rootSessionId: 'root-1',
437
+ agentName: 'worker',
438
+ status: 'running',
439
+ ...opts.rich,
440
+ }
441
+ // 缺省 zcode(F21 磁盘真实形态);显式传 'pi' 可覆盖
442
+ m.engine = opts.engine ?? 'zcode'
443
+ if (opts.withEngineHandle !== false) {
444
+ m.engineHandle = {
445
+ sessionRef: { sessionId: opts.sessionId ?? 'sess-1', dbPath: opts.dbPath ?? DB_PATH },
446
+ poolKey: 'shared',
447
+ }
448
+ }
449
+ // 刻意不写 sessionFile 键(F21:JSON.stringify 丢 undefined 键的产物形态)
450
+ return JSON.stringify(m)
451
+ }
452
+
453
+ /** 变体 M instrument 用:主 session 文件里的 subagent-record entry 行(U7 recordLine 同款) */
454
+ function recordLine(saId: string, sessionRef: Record<string, string>): string {
455
+ return JSON.stringify({
294
456
  type: 'custom',
295
457
  customType: 'subagent-record',
296
- id: 'e-bad',
458
+ id: `e-${saId}`,
297
459
  parentId: null,
298
460
  data: {
299
- v: 2,
300
- kind: 'migrated',
301
- id: 'sa-1',
302
- engineHandle: { sessionRef: { sessionId: 'sess-BAD', dbPath: DB_PATH }, poolKey: 'shared' },
461
+ v: 2, kind: 'settled', id: saId, status: 'idle', stopReason: 'completed', endedAt: 2,
462
+ turns: 1, totalTokens: 10, engine: 'zcode', engineHandle: { sessionRef, poolKey: 'shared' },
303
463
  },
304
464
  })
305
- const file = writeMainSession('main.jsonl', [
306
- JSON.stringify({ type: 'session', id: 'main-1' }),
307
- badKind,
308
- ])
465
+ }
309
466
 
310
- expect(await findZcodeEntryAnchor([file], 'sa-1')).toBeUndefined()
467
+ beforeEach(() => {
468
+ // agentDir 两层化(<mkdtemp>/agent):存储树推导 = dirname(agentDir)/ext-sessions,
469
+ // 单层直接当 agentDir 会把扫描根泄到 tmpdir 公共层(跨测试污染——先例 reaper/family-zcode 同修)
470
+ agentDir = join(mkdtempSync(join(tmpdir(), 'zcode-manifest-test-')), 'agent')
471
+ mkdirSync(agentDir, { recursive: true })
311
472
  })
312
- })
313
473
 
314
- describe('无关条目不命中', () => {
315
- it('非 subagent-record 的 custom entry、id 不匹配的 record 均跳过', async () => {
316
- const todoLine = JSON.stringify({
317
- type: 'custom',
318
- customType: 'todo',
319
- id: 't1',
320
- parentId: null,
321
- data: { v: 1, id: 'sa-1', engineHandle: { sessionRef: { sessionId: 'sess-TODO', dbPath: DB_PATH } } },
474
+ afterEach(() => {
475
+ rmSync(agentDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
476
+ })
477
+
478
+ describe('readZcodeManifest 直读:有锚 manifest → 取到 sessionRef', () => {
479
+ it('engine zcode + sessionRef 双键齐 → kind=zcode,锚字段正确', async () => {
480
+ writeManifest(
481
+ 'proj-a',
482
+ 'sa-abc.json',
483
+ zcodeManifestJson({ id: 'sa-abc', sessionId: 'sess_5f3c', dbPath: DB_PATH }),
484
+ )
485
+
486
+ const lookup = await readZcodeManifest(agentDir, 'sa-abc')
487
+
488
+ expect(lookup.kind).toBe('zcode')
489
+ if (lookup.kind !== 'zcode') return
490
+ expect(lookup.anchor.sessionId).toBe('sess_5f3c')
491
+ expect(lookup.anchor.dbPath).toBe(DB_PATH)
322
492
  })
323
- const file = writeMainSession('main.jsonl', [
324
- JSON.stringify({ type: 'session', id: 'main-1' }),
325
- todoLine,
326
- recordLine('e1', 'sa-other', { sessionId: 'sess-OTHER', dbPath: DB_PATH }),
327
- ])
328
493
 
329
- expect(await findZcodeEntryAnchor([file], 'sa-1')).toBeUndefined()
330
- expect((await findZcodeEntryAnchor([file], 'sa-other'))?.sessionId).toBe('sess-OTHER')
331
- })
332
- })
333
-
334
- describe('候选文件列表:顺序回退 + 命中即止(越界语义由调用方承载)', () => {
335
- it('前一候选未命中 → 后一候选命中', async () => {
336
- const empty = writeMainSession('empty.jsonl', [JSON.stringify({ type: 'session', id: 's0' })])
337
- const withAnchor = writeMainSession('with-anchor.jsonl', [
338
- JSON.stringify({ type: 'session', id: 'main-2' }),
339
- recordLine('e1', 'sa-1', { sessionId: 'sess-B', dbPath: DB_PATH }),
340
- ])
341
-
342
- const anchor: ZcodeAnchor | undefined = await findZcodeEntryAnchor([empty, withAnchor], 'sa-1')
343
- expect(anchor?.sessionId).toBe('sess-B')
344
- })
494
+ it('多 cwd 目录窄 walk:目标不在第一个目录 → 后续目录命中(命中即止)', async () => {
495
+ writeManifest('proj-empty', 'sa-other.json', zcodeManifestJson({ id: 'sa-other' }))
496
+ writeManifest(
497
+ 'proj-deep',
498
+ 'sa-target.json',
499
+ zcodeManifestJson({ id: 'sa-target', sessionId: 'sess-deep', dbPath: DB_PATH }),
500
+ )
501
+
502
+ const lookup = await readZcodeManifest(agentDir, 'sa-target')
345
503
 
346
- it('前一候选命中即止:不跨文件合并(后一候选的更新条目不覆盖)', async () => {
347
- const first = writeMainSession('first.jsonl', [
348
- JSON.stringify({ type: 'session', id: 'main-1' }),
349
- recordLine('e1', 'sa-1', { sessionId: 'sess-A', dbPath: DB_PATH }),
350
- ])
351
- const second = writeMainSession('second.jsonl', [
352
- JSON.stringify({ type: 'session', id: 'main-2' }),
353
- recordLine('e2', 'sa-1', { sessionId: 'sess-B', dbPath: DB_PATH }),
354
- ])
355
-
356
- const anchor = await findZcodeEntryAnchor([first, second], 'sa-1')
357
- expect(anchor?.sessionId).toBe('sess-A')
358
- expect(anchor?.sessionId).not.toBe('sess-B')
504
+ expect(lookup.kind).toBe('zcode')
505
+ if (lookup.kind === 'zcode') expect(lookup.anchor.sessionId).toBe('sess-deep')
506
+ })
507
+
508
+ it('同目录存在坏 JSON manifest 不影响直读(不解析其他 manifest)', async () => {
509
+ writeManifest('proj-a', 'sa-broken.json', '{"id":"sa-broken", truncated')
510
+ writeManifest(
511
+ 'proj-a',
512
+ 'sa-good.json',
513
+ zcodeManifestJson({ id: 'sa-good', sessionId: 'sess-good', dbPath: DB_PATH }),
514
+ )
515
+
516
+ const lookup = await readZcodeManifest(agentDir, 'sa-good')
517
+
518
+ expect(lookup.kind).toBe('zcode')
519
+ if (lookup.kind === 'zcode') expect(lookup.anchor.sessionId).toBe('sess-good')
520
+ })
359
521
  })
360
522
 
361
- it('候选不可读(不存在)→ 跳过继续,全不可读 → not-found', async () => {
362
- const withAnchor = writeMainSession('with-anchor.jsonl', [
363
- JSON.stringify({ type: 'session', id: 'main-1' }),
364
- recordLine('e1', 'sa-1', { sessionId: 'sess-B', dbPath: DB_PATH }),
365
- ])
523
+ describe('readZcodeManifest 直读:失败信号(U9 错误码映射的归因输入)', () => {
524
+ it('文件不存在(未知 sa-id)→ not-zcode', async () => {
525
+ writeManifest('proj-a', 'sa-abc.json', zcodeManifestJson({ id: 'sa-abc' }))
526
+
527
+ expect((await readZcodeManifest(agentDir, 'sa-missing')).kind).toBe('not-zcode')
528
+ })
529
+
530
+ it('坏 JSON → not-zcode(残缺 manifest 与今天的 tryReadManifest 丢弃行为等价)', async () => {
531
+ writeManifest('proj-a', 'sa-abc.json', 'not-json-at-all{{{')
532
+
533
+ expect((await readZcodeManifest(agentDir, 'sa-abc')).kind).toBe('not-zcode')
534
+ })
535
+
536
+ it('engine 缺省(pi 存量形态:有 sessionFile 无 engine)→ not-zcode', async () => {
537
+ writeManifest(
538
+ 'proj-a',
539
+ 'sa-abc.json',
540
+ JSON.stringify({
541
+ id: 'sa-abc',
542
+ rootSessionId: 'root-1',
543
+ sessionFile: '/sessions/sub.jsonl',
544
+ }),
545
+ )
546
+
547
+ expect((await readZcodeManifest(agentDir, 'sa-abc')).kind).toBe('not-zcode')
548
+ })
549
+
550
+ it("engine 显式 'pi' → not-zcode", async () => {
551
+ writeManifest(
552
+ 'proj-a',
553
+ 'sa-abc.json',
554
+ zcodeManifestJson({
555
+ id: 'sa-abc',
556
+ engine: 'pi',
557
+ rich: { sessionFile: '/sessions/sub.jsonl' },
558
+ }),
559
+ )
560
+
561
+ expect((await readZcodeManifest(agentDir, 'sa-abc')).kind).toBe('not-zcode')
562
+ })
563
+
564
+ it('engineHandle 整体缺席 → anchor-missing + reason=missing-engineHandle(旧版本产物 / 孤儿 manifest)', async () => {
565
+ writeManifest(
566
+ 'proj-a',
567
+ 'sa-abc.json',
568
+ zcodeManifestJson({ id: 'sa-abc', engine: 'zcode', withEngineHandle: false }),
569
+ )
570
+
571
+ const lookup = await readZcodeManifest(agentDir, 'sa-abc')
572
+ expect(lookup.kind).toBe('anchor-missing')
573
+ if (lookup.kind === 'anchor-missing') expect(lookup.reason).toBe('missing-engineHandle')
574
+ })
575
+
576
+ it('sessionRef 整体缺席 → anchor-missing + reason=missing-sessionRef', async () => {
577
+ writeManifest(
578
+ 'proj-a',
579
+ 'sa-abc.json',
580
+ JSON.stringify({ id: 'sa-abc', rootSessionId: 'root-1', engine: 'zcode', engineHandle: { poolKey: 'shared' } }),
581
+ )
366
582
 
367
- expect((await findZcodeEntryAnchor([join(dir, 'missing.jsonl'), withAnchor], 'sa-1'))?.sessionId).toBe('sess-B')
368
- expect(await findZcodeEntryAnchor([join(dir, 'missing.jsonl')], 'sa-1')).toBeUndefined()
369
- expect(await findZcodeEntryAnchor([], 'sa-1')).toBeUndefined()
583
+ const lookup = await readZcodeManifest(agentDir, 'sa-abc')
584
+ expect(lookup.kind).toBe('anchor-missing')
585
+ if (lookup.kind === 'anchor-missing') expect(lookup.reason).toBe('missing-sessionRef')
586
+ })
587
+
588
+ it('sessionRef 缺 dbPath 键 → anchor-missing + reason=missing-dbPath(部分回填的残余形态)', async () => {
589
+ // 手写 JSON:sessionRef 只有 sessionId 单键(zcodeManifestJson 的 ?? 兜底不适用此形态)
590
+ writeManifest(
591
+ 'proj-a',
592
+ 'sa-abc.json',
593
+ JSON.stringify({
594
+ id: 'sa-abc',
595
+ rootSessionId: 'root-1',
596
+ engine: 'zcode',
597
+ engineHandle: { sessionRef: { sessionId: 'sess-1' }, poolKey: 'shared' },
598
+ }),
599
+ )
600
+
601
+ const lookup = await readZcodeManifest(agentDir, 'sa-abc')
602
+ expect(lookup.kind).toBe('anchor-missing')
603
+ if (lookup.kind === 'anchor-missing') expect(lookup.reason).toBe('missing-dbPath')
604
+ })
605
+
606
+ it('sessionRef 键为空串 → anchor-missing + reason=missing-sessionId(非空 string 才算锚)', async () => {
607
+ writeManifest(
608
+ 'proj-a',
609
+ 'sa-abc.json',
610
+ zcodeManifestJson({ id: 'sa-abc', sessionId: '', dbPath: DB_PATH }),
611
+ )
612
+
613
+ const lookup = await readZcodeManifest(agentDir, 'sa-abc')
614
+ expect(lookup.kind).toBe('anchor-missing')
615
+ if (lookup.kind === 'anchor-missing') expect(lookup.reason).toBe('missing-sessionId')
616
+ })
617
+
618
+ it('subagents 根目录不存在 → not-zcode(不抛异常)', async () => {
619
+ const bareDir = mkdtempSync(join(tmpdir(), 'zcode-manifest-bare-'))
620
+ try {
621
+ expect((await readZcodeManifest(bareDir, 'sa-abc')).kind).toBe('not-zcode')
622
+ } finally {
623
+ rmSync(bareDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
624
+ }
625
+ })
370
626
  })
371
- })
372
627
 
373
- describe('协议字面量漂移检查(跨包契约)', () => {
374
- it('entry-anchor 本地 customType 字面量 === subagent-core 写侧单源', () => {
375
- // 源码本地持有磁盘协议字符串(生产依赖范围不引 subagent-core),此处锚定两侧同值
376
- expect(SUBAGENT_RECORD_CUSTOM_TYPE).toBe('subagent-record')
628
+ describe('变体 M 定位分支 instrument:manifest 主路径命中(结果来源标记)', () => {
629
+ it('manifest 与 entry 同 sa-id 各持不同锚 → 直读返回 manifest 的锚(entry 兜底来源未被采信)', async () => {
630
+ // 结果来源标记:两来源锚值刻意不同——若实现(未来的路由编排)错误地跳过主路径
631
+ // 落到 entry 兜底,将得到 'sess-from-entry',本断言即失败(走对路而非碰巧对)。
632
+ writeManifest(
633
+ 'proj-a',
634
+ 'sa-inst.json',
635
+ zcodeManifestJson({ id: 'sa-inst', sessionId: 'sess-from-manifest', dbPath: DB_PATH }),
636
+ )
637
+ const mainFile = join(agentDir, 'main.jsonl')
638
+ writeFileSync(
639
+ mainFile,
640
+ [
641
+ JSON.stringify({ type: 'session', id: 'main-1', cwd: '/proj' }),
642
+ recordLine('sa-inst', { sessionId: 'sess-from-entry', dbPath: DB_PATH }),
643
+ ].join('\n') + '\n',
644
+ 'utf8',
645
+ )
646
+
647
+ // 主路径命中:锚来自 manifest
648
+ const lookup = await readZcodeManifest(agentDir, 'sa-inst')
649
+ expect(lookup.kind).toBe('zcode')
650
+ if (lookup.kind === 'zcode') {
651
+ expect(lookup.anchor.sessionId).toBe('sess-from-manifest')
652
+ expect(lookup.anchor.sessionId).not.toBe('sess-from-entry')
653
+ }
654
+
655
+ // 标记有效性自证:entry 兜底(U7 函数)在同 fixture 上返回的是 entry 来源的锚——
656
+ // 两来源可区分,主路径断言非平凡
657
+ const entryAnchor = await findZcodeEntryAnchor([mainFile], 'sa-inst')
658
+ expect(entryAnchor?.sessionId).toBe('sess-from-entry')
659
+ })
660
+
661
+ it('manifest 缺位 → not-zcode(此情形才需要 entry 兜底介入——定位链分支边界)', async () => {
662
+ expect((await readZcodeManifest(agentDir, 'sa-none')).kind).toBe('not-zcode')
663
+ })
377
664
  })
378
665
 
379
- it('entry-anchor 本地 v2 认识版本 === subagent-core 写侧单源(写侧再升版本本检查先红)', () => {
380
- expect(SUBAGENT_RECORD_ENTRY_VERSION).toBe(2)
666
+ describe('listZcodeManifests 枚举(family 节点列表来源)', () => {
667
+ it('混排目录只返回 zcode 形态:pi manifest / 坏 JSON / 无锚 zcode 全部跳过', async () => {
668
+ writeManifest(
669
+ 'proj-a',
670
+ 'sa-z1.json',
671
+ zcodeManifestJson({ id: 'sa-z1', sessionId: 'sess-z1', dbPath: DB_PATH }),
672
+ )
673
+ // pi 存量形态(无 engine、有 sessionFile)——枚举不吸入(isRecordManifest 链零改动)
674
+ writeManifest(
675
+ 'proj-a',
676
+ 'sa-pi.json',
677
+ JSON.stringify({
678
+ id: 'sa-pi',
679
+ rootSessionId: 'root-1',
680
+ sessionFile: '/sessions/pi-sub.jsonl',
681
+ agentName: 'explorer',
682
+ }),
683
+ )
684
+ // engine:'pi' 显式
685
+ writeManifest(
686
+ 'proj-a',
687
+ 'sa-pi2.json',
688
+ zcodeManifestJson({ id: 'sa-pi2', engine: 'pi', rich: { sessionFile: '/s.jsonl' } }),
689
+ )
690
+ // zcode 但无锚 → 不产出(直读侧归 anchor-missing,枚举侧无节点可建)
691
+ writeManifest(
692
+ 'proj-a',
693
+ 'sa-noanchor.json',
694
+ zcodeManifestJson({ id: 'sa-noanchor', engine: 'zcode', withEngineHandle: false }),
695
+ )
696
+ // 坏 JSON → 跳过不中断
697
+ writeManifest('proj-a', 'sa-bad.json', '{{{broken')
698
+ // 第二目录的 zcode manifest → 跨目录枚举
699
+ writeManifest(
700
+ 'proj-b',
701
+ 'sa-z2.json',
702
+ zcodeManifestJson({ id: 'sa-z2', sessionId: 'sess-z2', dbPath: DB_PATH }),
703
+ )
704
+
705
+ const manifests = await listZcodeManifests(agentDir)
706
+
707
+ expect(manifests.map((m) => m.id).sort()).toEqual(['sa-z1', 'sa-z2'])
708
+ const z1 = manifests.find((m) => m.id === 'sa-z1')
709
+ expect(z1?.engine).toBe('zcode')
710
+ expect(z1?.dbPath).toBe(DB_PATH)
711
+ expect(z1?.rootSessionId).toBe('root-1')
712
+ })
713
+
714
+ it('dbFileExists 预计算:dbPath 指向真实文件 → true;不存在路径 → false(stat 不开库)', async () => {
715
+ const realDb = join(agentDir, 'real-db.sqlite')
716
+ writeFileSync(realDb, 'SQLite format 3 stub', 'utf8')
717
+ writeManifest(
718
+ 'proj-a',
719
+ 'sa-alive.json',
720
+ zcodeManifestJson({ id: 'sa-alive', sessionId: 'sess-a', dbPath: realDb }),
721
+ )
722
+ writeManifest(
723
+ 'proj-a',
724
+ 'sa-gone.json',
725
+ zcodeManifestJson({ id: 'sa-gone', sessionId: 'sess-g', dbPath: join(agentDir, 'missing-db.sqlite') }),
726
+ )
727
+
728
+ const manifests = await listZcodeManifests(agentDir)
729
+
730
+ expect(manifests.find((m) => m.id === 'sa-alive')?.dbFileExists).toBe(true)
731
+ expect(manifests.find((m) => m.id === 'sa-gone')?.dbFileExists).toBe(false)
732
+ })
733
+
734
+ it('slug 兜底 m.slug ?? m.agentName ?? ""(与 pi 孤儿链同款)', async () => {
735
+ writeManifest(
736
+ 'proj-a',
737
+ 'sa-s1.json',
738
+ zcodeManifestJson({ id: 'sa-s1', sessionId: 'sess-1', rich: { slug: 'explicit-slug' } }),
739
+ )
740
+ writeManifest(
741
+ 'proj-a',
742
+ 'sa-s2.json',
743
+ zcodeManifestJson({ id: 'sa-s2', sessionId: 'sess-2' }), // 无 slug → 回退 agentName
744
+ )
745
+
746
+ const manifests = await listZcodeManifests(agentDir)
747
+
748
+ expect(manifests.find((m) => m.id === 'sa-s1')?.slug).toBe('explicit-slug')
749
+ expect(manifests.find((m) => m.id === 'sa-s2')?.slug).toBe('worker') // agentName 兜底
750
+ })
751
+
752
+ it('存储树根不存在 → 空数组(不报错;单节点不可达不炸整屏)', async () => {
753
+ // agentDir 两层化:扫描根 = dirname(agentDir)/ext-sessions(mkdtemp 私有层内,防公共层污染)
754
+ const bareDir = join(mkdtempSync(join(tmpdir(), 'zcode-manifest-bare-')), 'agent')
755
+ try {
756
+ expect(await listZcodeManifests(bareDir)).toEqual([])
757
+ } finally {
758
+ rmSync(dirname(bareDir), { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
759
+ }
760
+ })
381
761
  })
382
- })
762
+ });