@zhushanwen/pi-session-reader 0.6.1 → 0.7.0

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.
Files changed (36) hide show
  1. package/README.md +100 -0
  2. package/package.json +6 -3
  3. package/src/__tests__/entry-anchor.test.ts +226 -0
  4. package/src/__tests__/execution-tree.test.ts +6 -0
  5. package/src/__tests__/family-zcode.test.ts +255 -0
  6. package/src/__tests__/family.test.ts +1 -1
  7. package/src/__tests__/find.test.ts +23 -0
  8. package/src/__tests__/parser.test.ts +1 -2
  9. package/src/__tests__/render.test.ts +1 -2
  10. package/src/__tests__/result.test.ts +2 -2
  11. package/src/__tests__/tool-handler.test.ts +12 -8
  12. package/src/__tests__/toolcall.test.ts +1 -1
  13. package/src/__tests__/tree.test.ts +1 -1
  14. package/src/__tests__/turns.test.ts +1 -2
  15. package/src/__tests__/zcode-manifest.test.ts +361 -0
  16. package/src/__tests__/zcode-routing.test.ts +751 -0
  17. package/src/__tests__/zcode-whitelist.test.ts +119 -0
  18. package/src/core/execution-tree.ts +3 -1
  19. package/src/core/family.ts +80 -1
  20. package/src/core/render.ts +1 -1
  21. package/src/core/toolcall.ts +2 -2
  22. package/src/core/tree.ts +1 -1
  23. package/src/core/turns.ts +1 -1
  24. package/src/discovery/entry-anchor.ts +108 -0
  25. package/src/discovery/session-header.ts +30 -69
  26. package/src/discovery/subagents.ts +9 -2
  27. package/src/discovery/whitelist.ts +87 -0
  28. package/src/discovery/workflows.ts +1 -1
  29. package/src/discovery/zcode-manifest.ts +277 -0
  30. package/src/index.ts +7 -0
  31. package/src/result-action.ts +14 -5
  32. package/src/search-across.ts +1 -1
  33. package/src/tool-format.ts +455 -0
  34. package/src/tool-handler.ts +321 -325
  35. package/src/zcode-anchor-classify.ts +60 -0
  36. package/src/core/parser.ts +0 -167
@@ -1,9 +1,8 @@
1
1
  import { describe, it, expect } from 'vitest'
2
2
  import { renderOutline, renderExpand, renderDetail, type ToolResultSummaryEntry } from '../core/render.js'
3
- import type { Entry } from '../core/parser.js'
3
+ import { parseSessionFile, type Entry } from '@zhushanwen/session-core'
4
4
  import type { Turn } from '../core/turns.js'
5
5
  import type { TreeView } from '../core/tree.js'
6
- import { parseSessionFile } from '../core/parser.js'
7
6
  import { buildTreeView } from '../core/tree.js'
8
7
  import { segmentTurns } from '../core/turns.js'
9
8
  import { REAL_SESSION, HAS_REAL_SESSION } from './real-data.js'
@@ -267,10 +267,10 @@ describe('result action(U6)', () => {
267
267
  }
268
268
  })
269
269
 
270
- it('6. 不存在 id:sa-id 无 manifest → 无匹配 record;uuid 片段零匹配 → 无匹配 session', async () => {
270
+ it('6. 不存在 id:sa-id 无 manifest → zcode_record_not_found(U9 统一错误面);uuid 片段零匹配 → 无匹配 session', async () => {
271
271
  await expect(
272
272
  handleSessionRead({ action: 'result', session: 'sa-nonexist-9999' }, { agentDir: dir }),
273
- ).rejects.toThrow(/无匹配 record/)
273
+ ).rejects.toThrow(/zcode_record_not_found/)
274
274
  try {
275
275
  await handleSessionRead({ action: 'result', session: 'sa-nonexist-9999' }, { agentDir: dir })
276
276
  } catch (e) {
@@ -1026,28 +1026,32 @@ describe('resolveSessionId ② sa-id 形态(w2 TC7-TC10 + CQ3)', () => {
1026
1026
  }
1027
1027
  })
1028
1028
 
1029
- it('TC9: sa-id 0 命中(可能 running)→ ES2(含 family 指引 + 完整 id 提示 + 👉)', async () => {
1029
+ it('TC9: sa-id 0 命中(可能 running)→ zcode_record_not_found(U9:sa-id 语境统一错误面,含 family 指引 + 👉)', async () => {
1030
1030
  await expect(
1031
1031
  handleSessionRead({ action: 'outline', session: 'sa-nonexist-9999' }, { agentDir: dir }),
1032
- ).rejects.toThrow('无匹配 record')
1032
+ ).rejects.toThrow('zcode_record_not_found')
1033
1033
  try {
1034
1034
  await handleSessionRead({ action: 'outline', session: 'sa-nonexist-9999' }, { agentDir: dir })
1035
1035
  } catch (e) {
1036
1036
  const msg = (e as Error).message
1037
- expect(msg).toContain('可能尚未落盘')
1037
+ expect(msg).toContain('未落盘')
1038
1038
  expect(msg).toContain('action:"family"')
1039
1039
  expect(msg).toContain('👉')
1040
1040
  }
1041
1041
  })
1042
1042
 
1043
- it('TC10: sa-id 片段输入(精确相等不命中)→ ES2', async () => {
1043
+ it('TC10: sa-id 片段输入(精确相等不命中)→ zcode_record_not_found + 完整 sa- id 重试指引(U9 统一错误面)', async () => {
1044
1044
  await makeFixtureSubagent(dir, 'sa-c8c8dfa8', {
1045
1045
  realSessionId: '019e6c96-dddd-eeee-ffff-0000000010a3',
1046
1046
  })
1047
- // 片段 sa-c8c8 不等于完整 sa-c8c8dfa8 → 精确相等不命中 → ES2
1047
+ // 片段 sa-c8c8 不等于完整 sa-c8c8dfa8 → 精确相等不命中(兜底亦未命中)
1048
1048
  await expect(
1049
1049
  handleSessionRead({ action: 'outline', session: 'sa-c8c8' }, { agentDir: dir }),
1050
- ).rejects.toThrow('无匹配 record')
1050
+ ).rejects.toThrow('zcode_record_not_found')
1051
+ // 片段输入恢复路径在指引内可见(A4:ES2 原文案「用完整 sa- id 重试」的覆盖补全)
1052
+ await expect(
1053
+ handleSessionRead({ action: 'outline', session: 'sa-c8c8' }, { agentDir: dir }),
1054
+ ).rejects.toThrow(/完整 sa- id(形如 `sa-xxxx`)后重试/)
1051
1055
  })
1052
1056
 
1053
1057
  it('sa-id 多 manifest 命中(数据异常)→ ES2 ambiguous(C4)', async () => {
@@ -1220,11 +1224,11 @@ describe.skipIf(!HAS_REAL_SUBAGENTS_DIR)('真实数据:subagent sa-id(w2 TC1
1220
1224
  ).rejects.toThrow('session 文件不存在')
1221
1225
  })
1222
1226
 
1223
- it('TC18: 不存在 sa-id → ES2(场景 4,可能 running 指引)', async () => {
1227
+ it('TC18: 不存在 sa-id → zcode_record_not_found(场景 4,U9 统一错误面)', async () => {
1224
1228
  const fakeId = `sa-nonexist-${Date.now().toString(16)}-${Math.random().toString(16).slice(2, 8)}`
1225
1229
  await expect(
1226
1230
  handleSessionRead({ action: 'outline', session: fakeId }, { agentDir: REAL }),
1227
- ).rejects.toThrow('无匹配 record')
1231
+ ).rejects.toThrow('zcode_record_not_found')
1228
1232
  })
1229
1233
  }, 60000)
1230
1234
 
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect } from 'vitest'
2
2
  import { extractToolCalls, formatToolCallSummary } from '../core/toolcall.js'
3
- import type { Entry } from '../core/parser.js'
3
+ import type { Entry } from '@zhushanwen/session-core'
4
4
 
5
5
  /**
6
6
  * D1 映射表(design §3.3)确定性测试:formatToolCallSummary 12 个 switch 分支
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect } from 'vitest'
2
2
  import { buildTreeView } from '../core/tree.js'
3
- import type { Entry } from '../core/parser.js'
3
+ import type { Entry } from '@zhushanwen/session-core'
4
4
 
5
5
  /** 构造最小 Entry(tree.ts 只消费 type/id/parentId,其余字段省略) */
6
6
  function e(id: string, parentId: string | null): Entry {
@@ -1,7 +1,6 @@
1
1
  import { describe, it, expect } from 'vitest'
2
2
  import { segmentTurns } from '../core/turns.js'
3
- import type { Entry } from '../core/parser.js'
4
- import { parseSessionFile } from '../core/parser.js'
3
+ import { parseSessionFile, type Entry } from '@zhushanwen/session-core'
5
4
  import { buildTreeView } from '../core/tree.js'
6
5
  import { REAL_SESSION, HAS_REAL_SESSION } from './real-data.js'
7
6
 
@@ -0,0 +1,361 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
+ import { tmpdir } from 'node:os'
3
+ import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'
4
+ import { join } from 'node:path'
5
+
6
+ import { readZcodeManifest, listZcodeManifests } from '../discovery/zcode-manifest.js'
7
+ import { findZcodeEntryAnchor } from '../discovery/entry-anchor.js'
8
+
9
+ // ============================================================
10
+ // 变体 M(设计 §4 场景 1a):zcode manifest 直读(§3.1 第①步主路径)+ 枚举(family 用)。
11
+ //
12
+ // fixture 对齐 F21 磁盘真实形态:zcode manifest **无 sessionFile 键**
13
+ // (投影函数照写 sessionFile: undefined,JSON.stringify 丢键)、engine:'zcode'、
14
+ // engineHandle.sessionRef 双键齐。
15
+ //
16
+ // 单元边界:只测「认出 zcode manifest + 提取锚」;白名单闸 / 错误码映射(U9)、
17
+ // entry 兜底定位语义(U7 已交付)不在本文件展开。
18
+ // ============================================================
19
+
20
+ const DB_PATH = '/data/agent/engines/zcode/session-db/db.sqlite'
21
+
22
+ let agentDir: string
23
+
24
+ /** 在 <agentDir>/subagents/<slug>/records/ 下写一个 manifest 文件(手写 JSON 字符串,忠实控制键) */
25
+ function writeManifest(slug: string, name: string, content: string): string {
26
+ const recordsDir = join(agentDir, 'subagents', slug, 'records')
27
+ mkdirSync(recordsDir, { recursive: true })
28
+ const file = join(recordsDir, name)
29
+ writeFileSync(file, content, 'utf8')
30
+ return file
31
+ }
32
+
33
+ /** 磁盘真实形态的 zcode manifest(无 sessionFile 键——F21) */
34
+ function zcodeManifestJson(opts: {
35
+ id: string
36
+ sessionId?: string
37
+ dbPath?: string
38
+ withEngineHandle?: boolean
39
+ engine?: string
40
+ rich?: Record<string, unknown>
41
+ }): string {
42
+ const m: Record<string, unknown> = {
43
+ id: opts.id,
44
+ rootSessionId: 'root-1',
45
+ agentName: 'worker',
46
+ status: 'running',
47
+ ...opts.rich,
48
+ }
49
+ // 缺省 zcode(F21 磁盘真实形态);显式传 'pi' 可覆盖
50
+ m.engine = opts.engine ?? 'zcode'
51
+ if (opts.withEngineHandle !== false) {
52
+ m.engineHandle = {
53
+ sessionRef: { sessionId: opts.sessionId ?? 'sess-1', dbPath: opts.dbPath ?? DB_PATH },
54
+ poolKey: 'shared',
55
+ }
56
+ }
57
+ // 刻意不写 sessionFile 键(F21:JSON.stringify 丢 undefined 键的产物形态)
58
+ return JSON.stringify(m)
59
+ }
60
+
61
+ /** 变体 M instrument 用:主 session 文件里的 subagent-record entry 行(U7 recordLine 同款) */
62
+ function recordLine(saId: string, sessionRef: Record<string, string>): string {
63
+ return JSON.stringify({
64
+ type: 'custom',
65
+ customType: 'subagent-record',
66
+ id: `e-${saId}`,
67
+ parentId: null,
68
+ data: { v: 1, id: saId, engine: 'zcode', engineHandle: { sessionRef, poolKey: 'shared' } },
69
+ })
70
+ }
71
+
72
+ beforeEach(() => {
73
+ agentDir = mkdtempSync(join(tmpdir(), 'zcode-manifest-test-'))
74
+ })
75
+ afterEach(() => {
76
+ rmSync(agentDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
77
+ })
78
+
79
+ describe('readZcodeManifest 直读:有锚 manifest → 取到 sessionRef', () => {
80
+ it('engine zcode + sessionRef 双键齐 → kind=zcode,锚字段正确', async () => {
81
+ writeManifest(
82
+ 'proj-a',
83
+ 'sa-abc.json',
84
+ zcodeManifestJson({ id: 'sa-abc', sessionId: 'sess_5f3c', dbPath: DB_PATH }),
85
+ )
86
+
87
+ const lookup = await readZcodeManifest(agentDir, 'sa-abc')
88
+
89
+ expect(lookup.kind).toBe('zcode')
90
+ if (lookup.kind !== 'zcode') return
91
+ expect(lookup.anchor.sessionId).toBe('sess_5f3c')
92
+ expect(lookup.anchor.dbPath).toBe(DB_PATH)
93
+ })
94
+
95
+ it('多 cwd 目录窄 walk:目标不在第一个目录 → 后续目录命中(命中即止)', async () => {
96
+ writeManifest('proj-empty', 'sa-other.json', zcodeManifestJson({ id: 'sa-other' }))
97
+ writeManifest(
98
+ 'proj-deep',
99
+ 'sa-target.json',
100
+ zcodeManifestJson({ id: 'sa-target', sessionId: 'sess-deep', dbPath: DB_PATH }),
101
+ )
102
+
103
+ const lookup = await readZcodeManifest(agentDir, 'sa-target')
104
+
105
+ expect(lookup.kind).toBe('zcode')
106
+ if (lookup.kind === 'zcode') expect(lookup.anchor.sessionId).toBe('sess-deep')
107
+ })
108
+
109
+ it('同目录存在坏 JSON manifest 不影响直读(不解析其他 manifest)', async () => {
110
+ writeManifest('proj-a', 'sa-broken.json', '{"id":"sa-broken", truncated')
111
+ writeManifest(
112
+ 'proj-a',
113
+ 'sa-good.json',
114
+ zcodeManifestJson({ id: 'sa-good', sessionId: 'sess-good', dbPath: DB_PATH }),
115
+ )
116
+
117
+ const lookup = await readZcodeManifest(agentDir, 'sa-good')
118
+
119
+ expect(lookup.kind).toBe('zcode')
120
+ if (lookup.kind === 'zcode') expect(lookup.anchor.sessionId).toBe('sess-good')
121
+ })
122
+ })
123
+
124
+ describe('readZcodeManifest 直读:失败信号(U9 错误码映射的归因输入)', () => {
125
+ it('文件不存在(未知 sa-id)→ not-zcode', async () => {
126
+ writeManifest('proj-a', 'sa-abc.json', zcodeManifestJson({ id: 'sa-abc' }))
127
+
128
+ expect((await readZcodeManifest(agentDir, 'sa-missing')).kind).toBe('not-zcode')
129
+ })
130
+
131
+ it('坏 JSON → not-zcode(残缺 manifest 与今天的 tryReadManifest 丢弃行为等价)', async () => {
132
+ writeManifest('proj-a', 'sa-abc.json', 'not-json-at-all{{{')
133
+
134
+ expect((await readZcodeManifest(agentDir, 'sa-abc')).kind).toBe('not-zcode')
135
+ })
136
+
137
+ it('engine 缺省(pi 存量形态:有 sessionFile 无 engine)→ not-zcode', async () => {
138
+ writeManifest(
139
+ 'proj-a',
140
+ 'sa-abc.json',
141
+ JSON.stringify({
142
+ id: 'sa-abc',
143
+ rootSessionId: 'root-1',
144
+ sessionFile: '/sessions/sub.jsonl',
145
+ }),
146
+ )
147
+
148
+ expect((await readZcodeManifest(agentDir, 'sa-abc')).kind).toBe('not-zcode')
149
+ })
150
+
151
+ it("engine 显式 'pi' → not-zcode", async () => {
152
+ writeManifest(
153
+ 'proj-a',
154
+ 'sa-abc.json',
155
+ zcodeManifestJson({
156
+ id: 'sa-abc',
157
+ engine: 'pi',
158
+ rich: { sessionFile: '/sessions/sub.jsonl' },
159
+ }),
160
+ )
161
+
162
+ expect((await readZcodeManifest(agentDir, 'sa-abc')).kind).toBe('not-zcode')
163
+ })
164
+
165
+ it('engineHandle 整体缺席 → anchor-missing + reason=missing-engineHandle(旧版本产物 / 孤儿 manifest)', async () => {
166
+ writeManifest(
167
+ 'proj-a',
168
+ 'sa-abc.json',
169
+ zcodeManifestJson({ id: 'sa-abc', engine: 'zcode', withEngineHandle: false }),
170
+ )
171
+
172
+ const lookup = await readZcodeManifest(agentDir, 'sa-abc')
173
+ expect(lookup.kind).toBe('anchor-missing')
174
+ if (lookup.kind === 'anchor-missing') expect(lookup.reason).toBe('missing-engineHandle')
175
+ })
176
+
177
+ it('sessionRef 整体缺席 → anchor-missing + reason=missing-sessionRef', async () => {
178
+ writeManifest(
179
+ 'proj-a',
180
+ 'sa-abc.json',
181
+ JSON.stringify({ id: 'sa-abc', rootSessionId: 'root-1', engine: 'zcode', engineHandle: { poolKey: 'shared' } }),
182
+ )
183
+
184
+ const lookup = await readZcodeManifest(agentDir, 'sa-abc')
185
+ expect(lookup.kind).toBe('anchor-missing')
186
+ if (lookup.kind === 'anchor-missing') expect(lookup.reason).toBe('missing-sessionRef')
187
+ })
188
+
189
+ it('sessionRef 缺 dbPath 键 → anchor-missing + reason=missing-dbPath(部分回填的残余形态)', async () => {
190
+ // 手写 JSON:sessionRef 只有 sessionId 单键(zcodeManifestJson 的 ?? 兜底不适用此形态)
191
+ writeManifest(
192
+ 'proj-a',
193
+ 'sa-abc.json',
194
+ JSON.stringify({
195
+ id: 'sa-abc',
196
+ rootSessionId: 'root-1',
197
+ engine: 'zcode',
198
+ engineHandle: { sessionRef: { sessionId: 'sess-1' }, poolKey: 'shared' },
199
+ }),
200
+ )
201
+
202
+ const lookup = await readZcodeManifest(agentDir, 'sa-abc')
203
+ expect(lookup.kind).toBe('anchor-missing')
204
+ if (lookup.kind === 'anchor-missing') expect(lookup.reason).toBe('missing-dbPath')
205
+ })
206
+
207
+ it('sessionRef 键为空串 → anchor-missing + reason=missing-sessionId(非空 string 才算锚)', async () => {
208
+ writeManifest(
209
+ 'proj-a',
210
+ 'sa-abc.json',
211
+ zcodeManifestJson({ id: 'sa-abc', sessionId: '', dbPath: DB_PATH }),
212
+ )
213
+
214
+ const lookup = await readZcodeManifest(agentDir, 'sa-abc')
215
+ expect(lookup.kind).toBe('anchor-missing')
216
+ if (lookup.kind === 'anchor-missing') expect(lookup.reason).toBe('missing-sessionId')
217
+ })
218
+
219
+ it('subagents 根目录不存在 → not-zcode(不抛异常)', async () => {
220
+ const bareDir = mkdtempSync(join(tmpdir(), 'zcode-manifest-bare-'))
221
+ try {
222
+ expect((await readZcodeManifest(bareDir, 'sa-abc')).kind).toBe('not-zcode')
223
+ } finally {
224
+ rmSync(bareDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
225
+ }
226
+ })
227
+ })
228
+
229
+ describe('变体 M 定位分支 instrument:manifest 主路径命中(结果来源标记)', () => {
230
+ it('manifest 与 entry 同 sa-id 各持不同锚 → 直读返回 manifest 的锚(entry 兜底来源未被采信)', async () => {
231
+ // 结果来源标记:两来源锚值刻意不同——若实现(未来的路由编排)错误地跳过主路径
232
+ // 落到 entry 兜底,将得到 'sess-from-entry',本断言即失败(走对路而非碰巧对)。
233
+ writeManifest(
234
+ 'proj-a',
235
+ 'sa-inst.json',
236
+ zcodeManifestJson({ id: 'sa-inst', sessionId: 'sess-from-manifest', dbPath: DB_PATH }),
237
+ )
238
+ const mainFile = join(agentDir, 'main.jsonl')
239
+ writeFileSync(
240
+ mainFile,
241
+ [
242
+ JSON.stringify({ type: 'session', id: 'main-1', cwd: '/proj' }),
243
+ recordLine('sa-inst', { sessionId: 'sess-from-entry', dbPath: DB_PATH }),
244
+ ].join('\n') + '\n',
245
+ 'utf8',
246
+ )
247
+
248
+ // 主路径命中:锚来自 manifest
249
+ const lookup = await readZcodeManifest(agentDir, 'sa-inst')
250
+ expect(lookup.kind).toBe('zcode')
251
+ if (lookup.kind === 'zcode') {
252
+ expect(lookup.anchor.sessionId).toBe('sess-from-manifest')
253
+ expect(lookup.anchor.sessionId).not.toBe('sess-from-entry')
254
+ }
255
+
256
+ // 标记有效性自证:entry 兜底(U7 函数)在同 fixture 上返回的是 entry 来源的锚——
257
+ // 两来源可区分,主路径断言非平凡
258
+ const entryAnchor = await findZcodeEntryAnchor([mainFile], 'sa-inst')
259
+ expect(entryAnchor?.sessionId).toBe('sess-from-entry')
260
+ })
261
+
262
+ it('manifest 缺位 → not-zcode(此情形才需要 entry 兜底介入——定位链分支边界)', async () => {
263
+ expect((await readZcodeManifest(agentDir, 'sa-none')).kind).toBe('not-zcode')
264
+ })
265
+ })
266
+
267
+ describe('listZcodeManifests 枚举(family 节点列表来源)', () => {
268
+ it('混排目录只返回 zcode 形态:pi manifest / 坏 JSON / 无锚 zcode 全部跳过', async () => {
269
+ writeManifest(
270
+ 'proj-a',
271
+ 'sa-z1.json',
272
+ zcodeManifestJson({ id: 'sa-z1', sessionId: 'sess-z1', dbPath: DB_PATH }),
273
+ )
274
+ // pi 存量形态(无 engine、有 sessionFile)——枚举不吸入(isRecordManifest 链零改动)
275
+ writeManifest(
276
+ 'proj-a',
277
+ 'sa-pi.json',
278
+ JSON.stringify({
279
+ id: 'sa-pi',
280
+ rootSessionId: 'root-1',
281
+ sessionFile: '/sessions/pi-sub.jsonl',
282
+ agentName: 'explorer',
283
+ }),
284
+ )
285
+ // engine:'pi' 显式
286
+ writeManifest(
287
+ 'proj-a',
288
+ 'sa-pi2.json',
289
+ zcodeManifestJson({ id: 'sa-pi2', engine: 'pi', rich: { sessionFile: '/s.jsonl' } }),
290
+ )
291
+ // zcode 但无锚 → 不产出(直读侧归 anchor-missing,枚举侧无节点可建)
292
+ writeManifest(
293
+ 'proj-a',
294
+ 'sa-noanchor.json',
295
+ zcodeManifestJson({ id: 'sa-noanchor', engine: 'zcode', withEngineHandle: false }),
296
+ )
297
+ // 坏 JSON → 跳过不中断
298
+ writeManifest('proj-a', 'sa-bad.json', '{{{broken')
299
+ // 第二目录的 zcode manifest → 跨目录枚举
300
+ writeManifest(
301
+ 'proj-b',
302
+ 'sa-z2.json',
303
+ zcodeManifestJson({ id: 'sa-z2', sessionId: 'sess-z2', dbPath: DB_PATH }),
304
+ )
305
+
306
+ const manifests = await listZcodeManifests(agentDir)
307
+
308
+ expect(manifests.map((m) => m.id).sort()).toEqual(['sa-z1', 'sa-z2'])
309
+ const z1 = manifests.find((m) => m.id === 'sa-z1')
310
+ expect(z1?.engine).toBe('zcode')
311
+ expect(z1?.dbPath).toBe(DB_PATH)
312
+ expect(z1?.rootSessionId).toBe('root-1')
313
+ })
314
+
315
+ it('dbFileExists 预计算:dbPath 指向真实文件 → true;不存在路径 → false(stat 不开库)', async () => {
316
+ const realDb = join(agentDir, 'real-db.sqlite')
317
+ writeFileSync(realDb, 'SQLite format 3 stub', 'utf8')
318
+ writeManifest(
319
+ 'proj-a',
320
+ 'sa-alive.json',
321
+ zcodeManifestJson({ id: 'sa-alive', sessionId: 'sess-a', dbPath: realDb }),
322
+ )
323
+ writeManifest(
324
+ 'proj-a',
325
+ 'sa-gone.json',
326
+ zcodeManifestJson({ id: 'sa-gone', sessionId: 'sess-g', dbPath: join(agentDir, 'missing-db.sqlite') }),
327
+ )
328
+
329
+ const manifests = await listZcodeManifests(agentDir)
330
+
331
+ expect(manifests.find((m) => m.id === 'sa-alive')?.dbFileExists).toBe(true)
332
+ expect(manifests.find((m) => m.id === 'sa-gone')?.dbFileExists).toBe(false)
333
+ })
334
+
335
+ it('slug 兜底 m.slug ?? m.agentName ?? ""(与 pi 孤儿链同款)', async () => {
336
+ writeManifest(
337
+ 'proj-a',
338
+ 'sa-s1.json',
339
+ zcodeManifestJson({ id: 'sa-s1', sessionId: 'sess-1', rich: { slug: 'explicit-slug' } }),
340
+ )
341
+ writeManifest(
342
+ 'proj-a',
343
+ 'sa-s2.json',
344
+ zcodeManifestJson({ id: 'sa-s2', sessionId: 'sess-2' }), // 无 slug → 回退 agentName
345
+ )
346
+
347
+ const manifests = await listZcodeManifests(agentDir)
348
+
349
+ expect(manifests.find((m) => m.id === 'sa-s1')?.slug).toBe('explicit-slug')
350
+ expect(manifests.find((m) => m.id === 'sa-s2')?.slug).toBe('worker') // agentName 兜底
351
+ })
352
+
353
+ it('subagents 根不存在 → 空数组(不报错;单节点不可达不炸整屏)', async () => {
354
+ const bareDir = mkdtempSync(join(tmpdir(), 'zcode-manifest-bare-'))
355
+ try {
356
+ expect(await listZcodeManifests(bareDir)).toEqual([])
357
+ } finally {
358
+ rmSync(bareDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
359
+ }
360
+ })
361
+ })