@zhushanwen/pi-session-reader 0.11.0 → 0.13.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.
- package/package.json +5 -5
- package/src/__tests__/archive-ledger.test.ts +447 -0
- package/src/__tests__/cross-package-subagent-core.test.ts +34 -25
- package/src/__tests__/entry-anchor.test.ts +3 -3
- package/src/__tests__/execution-tree.test.ts +85 -86
- package/src/__tests__/family-zcode.test.ts +24 -14
- package/src/__tests__/family.test.ts +4 -4
- package/src/__tests__/find.test.ts +50 -5
- package/src/__tests__/index.test.ts +3 -3
- package/src/__tests__/rebuild-manifest-dual-write.test.ts +29 -22
- package/src/__tests__/render.test.ts +2 -2
- package/src/__tests__/result.test.ts +20 -11
- package/src/__tests__/roots.test.ts +64 -26
- package/src/__tests__/subagents.test.ts +108 -52
- package/src/__tests__/tool-handler.test.ts +133 -83
- package/src/__tests__/workflow.test.ts +5 -5
- package/src/__tests__/zcode-manifest.test.ts +18 -10
- package/src/__tests__/zcode-routing.test.ts +21 -19
- package/src/__tests__/zcode-whitelist.test.ts +1 -1
- package/src/core/family.ts +6 -6
- package/src/core/render.ts +2 -2
- package/src/core/toolcall.ts +1 -1
- package/src/core/workflow.ts +9 -9
- package/src/discovery/archive-ledger.ts +221 -0
- package/src/discovery/archive-match.ts +84 -0
- package/src/discovery/entry-anchor.ts +5 -5
- package/src/discovery/find.ts +12 -1
- package/src/discovery/roots.ts +149 -14
- package/src/discovery/subagents.ts +24 -17
- package/src/discovery/whitelist.ts +4 -4
- package/src/discovery/workflows.ts +8 -8
- package/src/discovery/zcode-manifest.ts +22 -27
- package/src/doctor.ts +3 -0
- package/src/extract.ts +1 -1
- package/src/index.ts +5 -2
- package/src/no-match.ts +29 -4
- package/src/result-action.ts +7 -1
- package/src/search-across.ts +3 -3
- package/src/tool-format.ts +24 -13
- package/src/tool-handler.ts +68 -29
- package/src/tui/session-command.ts +1 -1
- package/src/zcode-anchor-classify.ts +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-session-reader",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "index.ts",
|
|
6
6
|
"taiji": {
|
|
@@ -16,16 +16,16 @@
|
|
|
16
16
|
"pi-package"
|
|
17
17
|
],
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@zhushanwen/extension-protocol": "0.
|
|
19
|
+
"@zhushanwen/extension-protocol": "0.23.0",
|
|
20
20
|
"@zhushanwen/pi-ext-guards": "0.5.0",
|
|
21
21
|
"@zhushanwen/pi-extension-logger": "0.6.2",
|
|
22
|
-
"@zhushanwen/
|
|
23
|
-
"@zhushanwen/session-
|
|
22
|
+
"@zhushanwen/session-core": "0.12.0",
|
|
23
|
+
"@zhushanwen/zcode-session-source": "0.2.7"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"@vitest/coverage-v8": "^4.1.9",
|
|
27
27
|
"vitest": "^4.1.8",
|
|
28
|
-
"@zhushanwen/subagent-core": "1.
|
|
28
|
+
"@zhushanwen/subagent-core": "1.6.1"
|
|
29
29
|
},
|
|
30
30
|
"files": [
|
|
31
31
|
"index.ts",
|
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
|
|
4
|
+
import { join, dirname } from 'node:path'
|
|
5
|
+
import {
|
|
6
|
+
parseArchiveLedgerText,
|
|
7
|
+
scanArchiveLedgers,
|
|
8
|
+
formatArchivedText,
|
|
9
|
+
formatArchivedDisambiguation,
|
|
10
|
+
resolveArchiveDir,
|
|
11
|
+
type ArchiveLedgerEntry,
|
|
12
|
+
} from '../discovery/archive-ledger.js'
|
|
13
|
+
import { resolveSessionRoots } from '../discovery/roots.js'
|
|
14
|
+
import { handleSessionRead } from '../tool-handler.js'
|
|
15
|
+
import { FIX_E6 } from './fixtures.js'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* [session-archive D7] session-reader 归档根感知测试:簿记收集器(收 .archive.json
|
|
19
|
+
* 不收 .jsonl / 字段解析)+ 第五根「存在才纳入」+ 归档态返回形态(头部标注 /
|
|
20
|
+
* 摘要 turns / 尾注 / staging 占位)+ 无匹配路径回归。
|
|
21
|
+
*
|
|
22
|
+
* 全部用例数据 = mkdtemp 自建自删的最小归档目录(不触真实数据目录,测试防线纪律)。
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** 真实形态的 cwd 编码目录名(encodeCwd:`--` 开头 `--` 结尾) */
|
|
26
|
+
const SLUG = '--Users-foo--'
|
|
27
|
+
|
|
28
|
+
/** 第二归档条目 id(多命中消歧用,合法 uuid 形态) */
|
|
29
|
+
const SID2 = '0aaaaaaa-bbbb-7ccc-dddd-0000000000a2'
|
|
30
|
+
|
|
31
|
+
/** 完整 archived 簿记样本(D3 schema 全字段;overrides 覆盖单测变化点)。 */
|
|
32
|
+
function archivedLedger(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
|
33
|
+
return {
|
|
34
|
+
sessionId: FIX_E6,
|
|
35
|
+
name: 'v0.10 renderer 重构',
|
|
36
|
+
originalFile: '2026-10-19T10-00-00_abc.jsonl',
|
|
37
|
+
cwd: '/Users/foo/proj',
|
|
38
|
+
archivedAt: '2026-10-19T12:00:00.000Z',
|
|
39
|
+
rawBytes: 1000,
|
|
40
|
+
compressedBytes: 200,
|
|
41
|
+
codec: 'zstd-3',
|
|
42
|
+
status: 'archived',
|
|
43
|
+
sha256: 'deadbeef',
|
|
44
|
+
summary: {
|
|
45
|
+
turns: [
|
|
46
|
+
{ user: 'v0.10 的 renderer 重构开始,先出分层方案。', agent: '六层分层:Shell / Workspace。' },
|
|
47
|
+
{ user: '', agent: null },
|
|
48
|
+
],
|
|
49
|
+
},
|
|
50
|
+
...overrides,
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 写一份归档簿记(archive/<slug>/<base>.archive.json),返回其绝对路径。 */
|
|
55
|
+
async function writeLedger(
|
|
56
|
+
agentDir: string,
|
|
57
|
+
base: string,
|
|
58
|
+
ledger: Record<string, unknown>,
|
|
59
|
+
slug = SLUG,
|
|
60
|
+
): Promise<string> {
|
|
61
|
+
const dir = join(agentDir, 'archive', slug)
|
|
62
|
+
await mkdir(dir, { recursive: true })
|
|
63
|
+
const path = join(dir, `${base}.archive.json`)
|
|
64
|
+
await writeFile(path, JSON.stringify(ledger), 'utf8')
|
|
65
|
+
return path
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 写 .jsonl fixture(父目录不存在自建),默认合法 session header。 */
|
|
69
|
+
async function writeJsonl(path: string, content = '{"type":"session","id":"x"}\n'): Promise<void> {
|
|
70
|
+
await mkdir(dirname(path), { recursive: true })
|
|
71
|
+
await writeFile(path, content)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 归档条目(渲染/消歧单测的直造形态,绕过 fs)。 */
|
|
75
|
+
function entry(overrides: Partial<ArchiveLedgerEntry> = {}): ArchiveLedgerEntry {
|
|
76
|
+
return {
|
|
77
|
+
ledgerPath: '/tmp/agent/archive/--Users-foo--/a.archive.json',
|
|
78
|
+
sessionId: FIX_E6,
|
|
79
|
+
name: 'v0.10 renderer 重构',
|
|
80
|
+
status: 'archived',
|
|
81
|
+
summary: [
|
|
82
|
+
{ user: '先出分层方案。', agent: '六层分层。' },
|
|
83
|
+
{ user: '', agent: null },
|
|
84
|
+
],
|
|
85
|
+
archivedAt: '2026-10-19T12:00:00.000Z',
|
|
86
|
+
mtime: 1_000,
|
|
87
|
+
...overrides,
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ============================================================
|
|
92
|
+
// 簿记解析(parseArchiveLedgerText:字段解析 / 非法整条拒收)
|
|
93
|
+
// ============================================================
|
|
94
|
+
|
|
95
|
+
describe('parseArchiveLedgerText(D3 消费子集)', () => {
|
|
96
|
+
it('合法 archived 簿记解析全部消费字段', () => {
|
|
97
|
+
const parsed = parseArchiveLedgerText(JSON.stringify(archivedLedger()))
|
|
98
|
+
expect(parsed).toBeDefined()
|
|
99
|
+
expect(parsed!.sessionId).toBe(FIX_E6)
|
|
100
|
+
expect(parsed!.name).toBe('v0.10 renderer 重构')
|
|
101
|
+
expect(parsed!.status).toBe('archived')
|
|
102
|
+
expect(parsed!.summary).toEqual([
|
|
103
|
+
{ user: 'v0.10 的 renderer 重构开始,先出分层方案。', agent: '六层分层:Shell / Workspace。' },
|
|
104
|
+
{ user: '', agent: null },
|
|
105
|
+
])
|
|
106
|
+
expect(parsed!.cwd).toBe('/Users/foo/proj')
|
|
107
|
+
expect(parsed!.archivedAt).toBe('2026-10-19T12:00:00.000Z')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('staging 簿记:summary null + name 缺省可选', () => {
|
|
111
|
+
const parsed = parseArchiveLedgerText(
|
|
112
|
+
JSON.stringify(archivedLedger({ status: 'staging', summary: null, name: undefined })),
|
|
113
|
+
)
|
|
114
|
+
expect(parsed).toBeDefined()
|
|
115
|
+
expect(parsed!.status).toBe('staging')
|
|
116
|
+
expect(parsed!.summary).toBeNull()
|
|
117
|
+
expect(parsed!.name).toBeUndefined()
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('非法形态整条拒收(undefined):坏 JSON / 缺 sessionId / id 形态非法 / status 词表外 / summary 结构非法', () => {
|
|
121
|
+
expect(parseArchiveLedgerText('{not json')).toBeUndefined()
|
|
122
|
+
expect(parseArchiveLedgerText(JSON.stringify({ status: 'archived', summary: null }))).toBeUndefined()
|
|
123
|
+
// btw 虚拟 id 冒号形态(禁 `:`)拒收
|
|
124
|
+
expect(
|
|
125
|
+
parseArchiveLedgerText(JSON.stringify(archivedLedger({ sessionId: 'btw:0aaa' }))),
|
|
126
|
+
).toBeUndefined()
|
|
127
|
+
expect(
|
|
128
|
+
parseArchiveLedgerText(JSON.stringify(archivedLedger({ status: 'restoring' }))),
|
|
129
|
+
).toBeUndefined()
|
|
130
|
+
// summary 非 null 非 { turns } / turns 元素 agent 类型非法
|
|
131
|
+
expect(
|
|
132
|
+
parseArchiveLedgerText(JSON.stringify(archivedLedger({ summary: { turns: 'x' } }))),
|
|
133
|
+
).toBeUndefined()
|
|
134
|
+
expect(
|
|
135
|
+
parseArchiveLedgerText(
|
|
136
|
+
JSON.stringify(
|
|
137
|
+
archivedLedger({ summary: { turns: [{ user: 'u', agent: 42 }] } }),
|
|
138
|
+
),
|
|
139
|
+
),
|
|
140
|
+
).toBeUndefined()
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
// ============================================================
|
|
145
|
+
// 簿记收集器(scanArchiveLedgers:收 .archive.json 不收 .jsonl)
|
|
146
|
+
// ============================================================
|
|
147
|
+
|
|
148
|
+
describe('scanArchiveLedgers(D7 独立收集契约)', () => {
|
|
149
|
+
let agentDir: string
|
|
150
|
+
|
|
151
|
+
beforeEach(async () => {
|
|
152
|
+
agentDir = join(await mkdtemp(join(tmpdir(), 'archive-scan-')), 'agent')
|
|
153
|
+
})
|
|
154
|
+
afterEach(async () => {
|
|
155
|
+
await rm(agentDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('收 archive/<encodeCwd>/ 下 .archive.json 并解析字段(含 mtime)', async () => {
|
|
159
|
+
await writeLedger(agentDir, 'a', archivedLedger())
|
|
160
|
+
const entries = await scanArchiveLedgers(resolveArchiveDir(agentDir))
|
|
161
|
+
expect(entries).toHaveLength(1)
|
|
162
|
+
expect(entries[0]!.sessionId).toBe(FIX_E6)
|
|
163
|
+
expect(entries[0]!.status).toBe('archived')
|
|
164
|
+
expect(entries[0]!.ledgerPath.endsWith(join('archive', SLUG, 'a.archive.json'))).toBe(true)
|
|
165
|
+
expect(entries[0]!.mtime).toBeGreaterThan(0)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('不收 .jsonl:staging 明文主文件不进收集(硬契约——收了会被当在场 session)', async () => {
|
|
169
|
+
await writeLedger(agentDir, 'a', archivedLedger({ status: 'staging', summary: null }))
|
|
170
|
+
// staging 期归档目录内的明文主文件 + 压缩体
|
|
171
|
+
await writeJsonl(join(agentDir, 'archive', SLUG, '2026-10-19T10-00-00_abc.jsonl'))
|
|
172
|
+
await writeJsonl(join(agentDir, 'archive', SLUG, '2026-10-19T10-00-00_abc.jsonl.zst'), 'x')
|
|
173
|
+
const entries = await scanArchiveLedgers(resolveArchiveDir(agentDir))
|
|
174
|
+
expect(entries).toHaveLength(1)
|
|
175
|
+
expect(entries[0]!.ledgerPath.endsWith('.archive.json')).toBe(true)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('.restoring 后缀簿记不收(恢复中,D9 不在列表)', async () => {
|
|
179
|
+
const path = await writeLedger(agentDir, 'a', archivedLedger())
|
|
180
|
+
const restoringPath = path.replace(/\.archive\.json$/, '.restoring')
|
|
181
|
+
await writeFile(restoringPath, JSON.stringify(archivedLedger()), 'utf8')
|
|
182
|
+
const entries = await scanArchiveLedgers(resolveArchiveDir(agentDir))
|
|
183
|
+
expect(entries).toHaveLength(1)
|
|
184
|
+
expect(entries[0]!.ledgerPath).toBe(path)
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('assets 子目录不递归(附件区无簿记,只耗 IO);深层簿记照收', async () => {
|
|
188
|
+
await writeLedger(agentDir, 'a', archivedLedger())
|
|
189
|
+
await mkdir(join(agentDir, 'archive', SLUG, 'assets', FIX_E6, 'attachments'), { recursive: true })
|
|
190
|
+
await writeFile(
|
|
191
|
+
join(agentDir, 'archive', SLUG, 'assets', FIX_E6, 'attachments', 'img.png'),
|
|
192
|
+
'x',
|
|
193
|
+
)
|
|
194
|
+
const entries = await scanArchiveLedgers(resolveArchiveDir(agentDir))
|
|
195
|
+
expect(entries).toHaveLength(1)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('坏簿记跳过不中断(坏 JSON / 词表外 status 的条目丢弃,其余照收)', async () => {
|
|
199
|
+
await writeLedger(agentDir, 'a', archivedLedger())
|
|
200
|
+
await writeLedger(agentDir, 'b', archivedLedger({ sessionId: SID2, status: 'bogus' }))
|
|
201
|
+
const dir = join(agentDir, 'archive', SLUG)
|
|
202
|
+
await writeFile(join(dir, 'c.archive.json'), '{broken', 'utf8')
|
|
203
|
+
const entries = await scanArchiveLedgers(resolveArchiveDir(agentDir))
|
|
204
|
+
expect(entries).toHaveLength(1)
|
|
205
|
+
expect(entries[0]!.sessionId).toBe(FIX_E6)
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('归档根不存在 → 空数组不抛错', async () => {
|
|
209
|
+
await expect(scanArchiveLedgers(resolveArchiveDir(agentDir))).resolves.toEqual([])
|
|
210
|
+
})
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
// ============================================================
|
|
214
|
+
// roots 第五根(存在才纳入 + 收集契约分流)
|
|
215
|
+
// ============================================================
|
|
216
|
+
|
|
217
|
+
describe('resolveSessionRoots 第五根 archive(存在才纳入)', () => {
|
|
218
|
+
let agentDir: string
|
|
219
|
+
|
|
220
|
+
beforeEach(async () => {
|
|
221
|
+
agentDir = join(await mkdtemp(join(tmpdir(), 'archive-roots-')), 'agent')
|
|
222
|
+
})
|
|
223
|
+
afterEach(async () => {
|
|
224
|
+
await rm(agentDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('archive 目录不存在 → 根不进返回(现有四根行为零变化,无归档目录环境回归锚)', async () => {
|
|
228
|
+
const roots = await resolveSessionRoots({ agentDir })
|
|
229
|
+
expect(roots.map((r) => r.kind)).toEqual(['default', 'legacy', 'subagent'])
|
|
230
|
+
expect(roots.some((r) => r.kind === 'archive')).toBe(false)
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('archive 目录存在 → 第五根纳入:fileCount = 簿记数、files 恒空(不收 .jsonl)、archiveEntries 在场', async () => {
|
|
234
|
+
await writeLedger(agentDir, 'a', archivedLedger())
|
|
235
|
+
await writeLedger(agentDir, 'b', archivedLedger({ sessionId: SID2, name: '另一个' }))
|
|
236
|
+
// staging 明文 .jsonl 不影响 fileCount(不收)
|
|
237
|
+
await writeJsonl(join(agentDir, 'archive', SLUG, 'plain.jsonl'))
|
|
238
|
+
|
|
239
|
+
const roots = await resolveSessionRoots({ agentDir })
|
|
240
|
+
expect(roots.map((r) => r.kind)).toEqual(['default', 'legacy', 'subagent', 'archive'])
|
|
241
|
+
const archive = roots.find((r) => r.kind === 'archive')!
|
|
242
|
+
expect(archive.path).toBe(resolveArchiveDir(agentDir))
|
|
243
|
+
expect(archive.exists).toBe(true)
|
|
244
|
+
expect(archive.source).toBe('main')
|
|
245
|
+
expect(archive.files).toEqual([])
|
|
246
|
+
expect(archive.fileCount).toBe(2)
|
|
247
|
+
expect(archive.archiveEntries).toHaveLength(2)
|
|
248
|
+
expect(typeof archive.scanMs).toBe('number')
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it('doctor 渲染:archive 根行计数措辞为「N 归档单元」', async () => {
|
|
252
|
+
await writeLedger(agentDir, 'a', archivedLedger())
|
|
253
|
+
const r = await handleSessionRead({ action: 'doctor' }, { agentDir })
|
|
254
|
+
const text = r.content[0]!.text
|
|
255
|
+
expect(text).toContain('[archive]')
|
|
256
|
+
expect(text).toContain('1 归档单元')
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it('同 realpath 去重:更高优先级根与归档目录同路径时 archive 根 dedupedInto,不重复成根', async () => {
|
|
260
|
+
// live 信号直接指向归档目录(basename 非 encodeCwd 形态 → 取自身,字面同路径
|
|
261
|
+
// realpath 必同);优先级 live > archive,归档根不实扫簿记、标注归属——与普通根
|
|
262
|
+
// realpath 去重同构(§7B 要点 4)。回归锚:archive 分支漏 kept 检查时本用例红
|
|
263
|
+
//(out 出现两条同路径实扫根,且 byRealPath 被 archive 覆盖致 dedupedInto 失效)
|
|
264
|
+
await writeLedger(agentDir, 'a', archivedLedger())
|
|
265
|
+
const archiveDir = resolveArchiveDir(agentDir)
|
|
266
|
+
|
|
267
|
+
const roots = await resolveSessionRoots({ agentDir, liveSessionDir: archiveDir })
|
|
268
|
+
expect(roots.map((r) => r.kind)).toEqual(['live', 'default', 'legacy', 'subagent', 'archive'])
|
|
269
|
+
const archive = roots.find((r) => r.kind === 'archive')!
|
|
270
|
+
expect(archive.path).toBe(archiveDir)
|
|
271
|
+
expect(archive.dedupedInto).toBe('live')
|
|
272
|
+
// 被去重根不产生扫描统计,簿记收集未执行(与普通根去重同构)
|
|
273
|
+
expect(archive.fileCount).toBeUndefined()
|
|
274
|
+
expect(archive.scanMs).toBeUndefined()
|
|
275
|
+
expect(archive.archiveEntries).toBeUndefined()
|
|
276
|
+
// 同路径全列表只保留一个实扫根(= 更高优先级的 live),簿记不重复计数
|
|
277
|
+
const scannedAtSamePath = roots.filter(
|
|
278
|
+
(r) => r.path === archiveDir && r.dedupedInto === undefined,
|
|
279
|
+
)
|
|
280
|
+
expect(scannedAtSamePath).toHaveLength(1)
|
|
281
|
+
expect(scannedAtSamePath[0]!.kind).toBe('live')
|
|
282
|
+
})
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
// ============================================================
|
|
286
|
+
// 归档态返回形态(handleSessionRead 集成:D7 硬契约)
|
|
287
|
+
// ============================================================
|
|
288
|
+
|
|
289
|
+
describe('归档态返回形态(片段命中归档回退)', () => {
|
|
290
|
+
let agentDir: string
|
|
291
|
+
|
|
292
|
+
beforeEach(async () => {
|
|
293
|
+
agentDir = join(await mkdtemp(join(tmpdir(), 'archive-read-')), 'agent')
|
|
294
|
+
})
|
|
295
|
+
afterEach(async () => {
|
|
296
|
+
await rm(agentDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
it('名称片段命中归档:头部标注 + 摘要 turns + 尾注恢复指引(§5.1 agent 引用样例形态)', async () => {
|
|
300
|
+
await writeLedger(agentDir, 'a', archivedLedger())
|
|
301
|
+
const r = await handleSessionRead(
|
|
302
|
+
{ action: 'outline', session: 'renderer 重构' },
|
|
303
|
+
{ agentDir },
|
|
304
|
+
)
|
|
305
|
+
const text = r.content[0]!.text
|
|
306
|
+
expect(text).toContain('[session-reader] session: v0.10 renderer 重构')
|
|
307
|
+
expect(text).toContain('状态: 已归档 (archived) — 完整内容已压缩离线,以下仅为摘要')
|
|
308
|
+
expect(text).toContain('── turn 1 ──')
|
|
309
|
+
expect(text).toContain('user: v0.10 的 renderer 重构开始,先出分层方案。')
|
|
310
|
+
expect(text).toContain('assistant (摘要): 六层分层:Shell / Workspace。')
|
|
311
|
+
expect(text).toContain('注: 如需完整历史,请让用户在归档列表中恢复该会话。')
|
|
312
|
+
// details 程序化标记:archived: true + 簿记条目
|
|
313
|
+
const d = r.details as { archived: boolean; entry: { sessionId: string } }
|
|
314
|
+
expect(d.archived).toBe(true)
|
|
315
|
+
expect(d.entry.sessionId).toBe(FIX_E6)
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
it('摘要占位规则:agent null → 无文本占位;user 空串 → 图片/附件占位', async () => {
|
|
319
|
+
await writeLedger(agentDir, 'a', archivedLedger())
|
|
320
|
+
const r = await handleSessionRead({ action: 'outline', session: 'renderer' }, { agentDir })
|
|
321
|
+
const text = r.content[0]!.text
|
|
322
|
+
expect(text).toContain('── turn 2 ──')
|
|
323
|
+
expect(text).toContain('user: [图片/附件消息]')
|
|
324
|
+
expect(text).toContain('assistant (摘要): (此 turn 以工具调用结束,无文本回复)')
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
it('staging 态(summary null):头部 staging 标注 + 「摘要生成中」+ 名称/id 元数据', async () => {
|
|
328
|
+
await writeLedger(agentDir, 'a', archivedLedger({ status: 'staging', summary: null }))
|
|
329
|
+
const r = await handleSessionRead({ action: 'outline', session: 'renderer' }, { agentDir })
|
|
330
|
+
const text = r.content[0]!.text
|
|
331
|
+
expect(text).toContain('状态: 已归档 (staging) — 摘要生成中,请稍后再查')
|
|
332
|
+
expect(text).toContain(`(id: ${FIX_E6})`)
|
|
333
|
+
expect(text).toContain('注: 如需完整历史,请让用户在归档列表中恢复该会话。')
|
|
334
|
+
expect(text).not.toContain('── turn')
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
it('sessionId 片段与归一化形态命中(大写/去连字符)', async () => {
|
|
338
|
+
await writeLedger(agentDir, 'a', archivedLedger())
|
|
339
|
+
const byFragment = await handleSessionRead({ action: 'detail', session: 'e6c96', turns: 'T000-T001' }, { agentDir })
|
|
340
|
+
expect(byFragment.content[0]!.text).toContain('已归档 (archived)')
|
|
341
|
+
const byNorm = await handleSessionRead(
|
|
342
|
+
{ action: 'outline', session: FIX_E6.replace(/-/g, '').toUpperCase() },
|
|
343
|
+
{ agentDir },
|
|
344
|
+
)
|
|
345
|
+
expect(byNorm.content[0]!.text).toContain('已归档 (archived)')
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
it('staging 明文 .jsonl 不被直读:同 id 明文在归档目录内仍返回归档形态(不读压缩体/明文)', async () => {
|
|
349
|
+
await writeLedger(agentDir, 'a', archivedLedger())
|
|
350
|
+
await writeJsonl(
|
|
351
|
+
join(agentDir, 'archive', SLUG, `${FIX_E6}.jsonl`),
|
|
352
|
+
`{"type":"session","id":"${FIX_E6}"}\n`,
|
|
353
|
+
)
|
|
354
|
+
const r = await handleSessionRead({ action: 'outline', session: 'e6c96' }, { agentDir })
|
|
355
|
+
expect(r.content[0]!.text).toContain('已归档 (archived)')
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
it('多归档命中 → 归档消歧列表(列候选 + 👉,不抛错)', async () => {
|
|
359
|
+
await writeLedger(agentDir, 'a', archivedLedger())
|
|
360
|
+
await writeLedger(
|
|
361
|
+
agentDir,
|
|
362
|
+
'b',
|
|
363
|
+
archivedLedger({ sessionId: SID2, name: 'v0.11 renderer 重构' }),
|
|
364
|
+
)
|
|
365
|
+
const r = await handleSessionRead({ action: 'outline', session: 'renderer' }, { agentDir })
|
|
366
|
+
const text = r.content[0]!.text
|
|
367
|
+
expect(text).toContain('多个归档 session 匹配 "renderer"')
|
|
368
|
+
expect(text).toContain(`v0.10 renderer 重构(id: ${FIX_E6}`)
|
|
369
|
+
expect(text).toContain(`v0.11 renderer 重构(id: ${SID2}`)
|
|
370
|
+
expect(text).toContain('👉')
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
it('在场优先:同片段在场 session 命中时返回在场内容,不走归档回退', async () => {
|
|
374
|
+
await writeJsonl(
|
|
375
|
+
join(agentDir, 'sessions', SLUG, `${FIX_E6}.jsonl`),
|
|
376
|
+
`{"type":"session","id":"${FIX_E6}"}\n`,
|
|
377
|
+
)
|
|
378
|
+
await writeLedger(agentDir, 'a', archivedLedger({ name: 'e6c96 主题' }))
|
|
379
|
+
const r = await handleSessionRead({ action: 'outline', session: 'e6c96' }, { agentDir })
|
|
380
|
+
expect(r.details).not.toHaveProperty('archived')
|
|
381
|
+
expect(r.content[0]!.text).not.toContain('已归档')
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
it('result action 命中归档:统一归档态摘要(批量链路的单 id 形态)', async () => {
|
|
385
|
+
await writeLedger(agentDir, 'a', archivedLedger())
|
|
386
|
+
const r = await handleSessionRead({ action: 'result', session: 'renderer' }, { agentDir })
|
|
387
|
+
expect(r.content[0]!.text).toContain('已归档 (archived)')
|
|
388
|
+
expect((r.details as { archived: boolean }).archived).toBe(true)
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
it('无匹配路径回归:无 archive 目录 + 在场零命中 → F1 照旧(无归档提示行、无 [archive] 根行)', async () => {
|
|
392
|
+
await expect(
|
|
393
|
+
handleSessionRead({ action: 'outline', session: 'zzz-nope' }, { agentDir }),
|
|
394
|
+
).rejects.toThrow('无匹配 session')
|
|
395
|
+
await expect(
|
|
396
|
+
handleSessionRead({ action: 'outline', session: 'zzz-nope' }, { agentDir }),
|
|
397
|
+
).rejects.toThrow(/自检(发现层,只陈述事实):/)
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
it('F1 归档指引(条件行):归档根存在且簿记非空 → find 零匹配文案含「目标可能已归档」', async () => {
|
|
401
|
+
await writeLedger(agentDir, 'a', archivedLedger())
|
|
402
|
+
const r = await handleSessionRead({ action: 'find', query: 'zzz-nope' }, { agentDir })
|
|
403
|
+
const text = r.content[0]!.text
|
|
404
|
+
expect(text).toContain('无匹配 session')
|
|
405
|
+
expect(text).toContain('目标可能已归档')
|
|
406
|
+
expect(text).toContain('[archive]')
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
it('F1 归档指引不出现:归档根存在但簿记为空(空目录)', async () => {
|
|
410
|
+
await mkdir(join(agentDir, 'archive', SLUG), { recursive: true })
|
|
411
|
+
const r = await handleSessionRead({ action: 'find', query: 'zzz-nope' }, { agentDir })
|
|
412
|
+
expect(r.content[0]!.text).not.toContain('目标可能已归档')
|
|
413
|
+
})
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
// ============================================================
|
|
417
|
+
// 归档态渲染纯函数(formatArchivedText / formatArchivedDisambiguation 直测)
|
|
418
|
+
// ============================================================
|
|
419
|
+
|
|
420
|
+
describe('formatArchivedText(两态渲染纯函数)', () => {
|
|
421
|
+
it('无 name 时头行回退 sessionId', () => {
|
|
422
|
+
const text = formatArchivedText(entry({ name: undefined }))
|
|
423
|
+
expect(text).toContain(`[session-reader] session: ${FIX_E6}`)
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
it('archived 态无摘要 turns(summary 空数组)只出头部与尾注', () => {
|
|
427
|
+
const text = formatArchivedText(entry({ summary: [] }))
|
|
428
|
+
expect(text).toContain('已归档 (archived)')
|
|
429
|
+
expect(text).not.toContain('── turn')
|
|
430
|
+
expect(text).toContain('注: 如需完整历史')
|
|
431
|
+
})
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
describe('formatArchivedDisambiguation(多命中消歧)', () => {
|
|
435
|
+
it('按 mtime 倒序列候选并携带归档时间', () => {
|
|
436
|
+
const text = formatArchivedDisambiguation('q', [
|
|
437
|
+
entry({ mtime: 1, name: '旧条目' }),
|
|
438
|
+
entry({ mtime: 2, sessionId: SID2, name: '新条目' }),
|
|
439
|
+
])
|
|
440
|
+
const newIdx = text.indexOf('新条目')
|
|
441
|
+
const oldIdx = text.indexOf('旧条目')
|
|
442
|
+
expect(newIdx).toBeGreaterThan(-1)
|
|
443
|
+
expect(oldIdx).toBeGreaterThan(newIdx)
|
|
444
|
+
expect(text).toContain(`id: ${SID2}`)
|
|
445
|
+
expect(text).toContain('归档于 2026-10-19T12:00:00.000Z')
|
|
446
|
+
})
|
|
447
|
+
})
|
|
@@ -21,16 +21,16 @@ import {
|
|
|
21
21
|
} from '@zhushanwen/subagent-core/execution/assembly/path-encoding.ts'
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
|
-
* W4 跨包集成测试([collect 退役] 改造保留:原「E1 落标出口产出 manifest
|
|
25
|
-
* 随 sync
|
|
26
|
-
* 是可丢缓存,boot
|
|
24
|
+
* W4 跨包集成测试([collect 退役] 改造保留:原「E1 落标出口产出 manifest」驱动侧
|
|
25
|
+
* 随 sync 批机制删除,现行真实派生视图出口 = store 重建通道 rebuildIndexes——manifest
|
|
26
|
+
* 是可丢缓存,boot 全量路径按磁盘子文件锚幂等补缺)。「subagent-core 真实 tmpdir
|
|
27
27
|
* 产出磁盘状态 → session-reader result action sa- id 反查,不 mock manifest 写入」
|
|
28
28
|
* 的跨包契约保持不变。
|
|
29
29
|
*
|
|
30
30
|
* 与 result.test.ts 的分工:result.test.ts 的 fixture **手工预写** manifest;本文件
|
|
31
|
-
* manifest
|
|
31
|
+
* manifest 内容必须来自派生视图代码路径——subagent-core 侧真实驱动(真实 RecordStore
|
|
32
32
|
* 落盘 register/轮终 entry → initSession 内 orphan 覆写真实跑 → rebuildIndexes
|
|
33
|
-
* derivedManifestRecord
|
|
33
|
+
* derivedManifestRecord 派生视图真写 records/<sa-id>.json),session-reader 侧零 mock
|
|
34
34
|
* 读同一 tmpdir 断言反查闭环。
|
|
35
35
|
*
|
|
36
36
|
* 构造形态取自 subagent-core sync-collect-recovery.test.ts 的 kill -9 同构主用例
|
|
@@ -42,17 +42,14 @@ import {
|
|
|
42
42
|
* mkdtemp(tmpdir) 自建自删(红线:零真实数据目录触碰)。
|
|
43
43
|
*/
|
|
44
44
|
|
|
45
|
-
/**
|
|
46
|
-
*
|
|
45
|
+
/** 剥身份 env(subagent-core 测试纪律同款):身份 env 会让 service 误判自己是
|
|
46
|
+
* 子进程跳过恢复扫描。 */
|
|
47
47
|
const ENV_KEYS_TO_STRIP = [
|
|
48
48
|
'PI_SUBAGENT_ROOT_SESSION_ID',
|
|
49
49
|
'PI_SUBAGENT_SELF_RECORD_ID',
|
|
50
50
|
'PI_SUBAGENT_DEPTH',
|
|
51
51
|
'PI_SUBAGENT_ROOT_CWD',
|
|
52
52
|
'PI_SUBAGENT_FORK_DEPTH',
|
|
53
|
-
'TAIJI_SUBAGENT_RELAY_SOCKET',
|
|
54
|
-
'TAIJI_SUBAGENT_RELAY_NODE',
|
|
55
|
-
'TAIJI_SUBAGENT_RELAY_SCRIPT',
|
|
56
53
|
] as const
|
|
57
54
|
|
|
58
55
|
const ROOT_SESSION = 'root-cross-pkg'
|
|
@@ -119,30 +116,42 @@ interface MemberSeed {
|
|
|
119
116
|
}
|
|
120
117
|
|
|
121
118
|
describe('跨包集成:subagent-core 重建 manifest → session-reader result 反查(W4)', () => {
|
|
119
|
+
let dataDir: string
|
|
122
120
|
let agentDir: string
|
|
123
121
|
let mainFile: string
|
|
124
122
|
let services: SubagentService[]
|
|
125
123
|
|
|
126
124
|
beforeEach(() => {
|
|
127
125
|
for (const k of ENV_KEYS_TO_STRIP) delete process.env[k]
|
|
128
|
-
agentDir =
|
|
129
|
-
|
|
126
|
+
// 生产拓扑复刻([session-store-layout-inversion 域 9]):agentDir = <dataDir>/agent
|
|
127
|
+
//(session-reader 的 agentDir 语义),subagent-core 写侧经新公式落存储树
|
|
128
|
+
// <dataDir>/ext-sessions/<rootSid>/subagents/——listRecordManifests 双根扫描的新根
|
|
129
|
+
//(dirname(agentDir)/ext-sessions)与写侧落点相遇
|
|
130
|
+
dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cross-pkg-subagent-core-'))
|
|
131
|
+
agentDir = path.join(dataDir, 'agent')
|
|
132
|
+
fs.mkdirSync(agentDir, { recursive: true })
|
|
133
|
+
fs.mkdirSync(getSubagentSessionDir(ROOT_SESSION, dataDir), { recursive: true })
|
|
134
|
+
// 恢复 service 的内部 store 经 getSubagentRecordsDir(resolveStoreRootSessionId())
|
|
135
|
+
//(env dataDir 兜底)解析——钉 env 把写删目标收进本 mkdtemp 树(自建自删红线,
|
|
136
|
+
// subagent-core 同族测试先例 subagent-service-model-switch.test.ts)
|
|
137
|
+
process.env['TAIJI_AGENT_DATA_DIR'] = dataDir
|
|
130
138
|
mainFile = path.join(agentDir, 'main-session.jsonl')
|
|
131
139
|
services = []
|
|
132
140
|
})
|
|
133
141
|
|
|
134
142
|
afterEach(() => {
|
|
135
143
|
for (const s of services) s.dispose()
|
|
136
|
-
|
|
144
|
+
delete process.env['TAIJI_AGENT_DATA_DIR']
|
|
145
|
+
fs.rmSync(dataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
137
146
|
})
|
|
138
147
|
|
|
139
148
|
/** 真实 RecordStore(写文件 pi:种子经真实 register 通路 + v2 条目构造器
|
|
140
149
|
* (toRegisteredEntryData,[v1 兼容层删除] v1 快照写点 toSubagentRecordEntry 已删)
|
|
141
|
-
* 落主文件)。 */
|
|
150
|
+
* 落主文件)。[u-subagent dir 晚绑定] store/manifest 构造 = 目录取值函数。 */
|
|
142
151
|
function makeSeedStore(): RecordStore {
|
|
143
152
|
return new RecordStore(
|
|
144
|
-
getSubagentSessionDir(
|
|
145
|
-
new ManifestStore(getSubagentRecordsDir(
|
|
153
|
+
() => getSubagentSessionDir(ROOT_SESSION, dataDir),
|
|
154
|
+
new ManifestStore(() => getSubagentRecordsDir(ROOT_SESSION, dataDir)),
|
|
146
155
|
makeWritingPi(mainFile),
|
|
147
156
|
)
|
|
148
157
|
}
|
|
@@ -172,7 +181,7 @@ describe('跨包集成:subagent-core 重建 manifest → session-reader result
|
|
|
172
181
|
* 形态同 subagent-core W1 用例)——**不写**三 sidecar,保证恢复走
|
|
173
182
|
* finalizeOrphanRecord 真实路径。 */
|
|
174
183
|
function writeChildSessionFile(m: MemberSeed): string {
|
|
175
|
-
const childFile = path.join(getSubagentSessionDir(
|
|
184
|
+
const childFile = path.join(getSubagentSessionDir(ROOT_SESSION, dataDir), `${m.id}.jsonl`)
|
|
176
185
|
const ts = new Date(1000).toISOString()
|
|
177
186
|
const lines: string[] = [
|
|
178
187
|
JSON.stringify({ type: 'session', version: 3, id: `sess-${m.id}`, timestamp: ts, cwd: agentDir }),
|
|
@@ -202,7 +211,7 @@ describe('跨包集成:subagent-core 重建 manifest → session-reader result
|
|
|
202
211
|
data: { id: m.id, agent: '/agents/worker.md', mode: 'background', task: 'cross-package task', startedAt: 1000, rootSessionId: ROOT_SESSION, depth: 0 },
|
|
203
212
|
}),
|
|
204
213
|
JSON.stringify({
|
|
205
|
-
// 登记 §3.3
|
|
214
|
+
// 登记 §3.3:主/子文件条目范围 = v2 注册条(身份域;运行态事实源在 record 事件文件)。
|
|
206
215
|
type: 'custom', id: `cid-${m.id}-2`, parentId: `cid-${m.id}-1`, timestamp: ts,
|
|
207
216
|
customType: 'subagent-record',
|
|
208
217
|
data: {
|
|
@@ -247,7 +256,7 @@ describe('跨包集成:subagent-core 重建 manifest → session-reader result
|
|
|
247
256
|
}
|
|
248
257
|
|
|
249
258
|
/** 全链路驱动:种子 → 恢复 service(orphan 覆写)→ rebuildIndexes(现行 boot
|
|
250
|
-
* 全量重建通道——derivedManifestRecord
|
|
259
|
+
* 全量重建通道——derivedManifestRecord 派生视图真写 manifest)。返回子 session 文件
|
|
251
260
|
* 路径表。[collect 退役] 原 E1 落标出口(recoverSyncCollectBatch →
|
|
252
261
|
* appendBatchFinalizedEntry)随 sync 批机制删除。 */
|
|
253
262
|
async function driveManifestRebuild(members: MemberSeed[]): Promise<Map<string, string>> {
|
|
@@ -259,11 +268,11 @@ describe('跨包集成:subagent-core 重建 manifest → session-reader result
|
|
|
259
268
|
}
|
|
260
269
|
const service = makeRecoveryService()
|
|
261
270
|
service.rebuildIndexes()
|
|
262
|
-
// derivedManifestRecord
|
|
271
|
+
// derivedManifestRecord 派生视图为同步落盘——全成员 manifest 即刻可见(W3 派生视图
|
|
263
272
|
// 代码路径的产物,非手工预写)。
|
|
264
273
|
for (const m of members) {
|
|
265
274
|
expect(
|
|
266
|
-
fs.existsSync(path.join(getSubagentRecordsDir(
|
|
275
|
+
fs.existsSync(path.join(getSubagentRecordsDir(ROOT_SESSION, dataDir), `${m.id}.json`)),
|
|
267
276
|
`manifest of ${m.id}`,
|
|
268
277
|
).toBe(true)
|
|
269
278
|
}
|
|
@@ -280,12 +289,12 @@ describe('跨包集成:subagent-core 重建 manifest → session-reader result
|
|
|
280
289
|
}
|
|
281
290
|
const childFiles = await driveManifestRebuild([m])
|
|
282
291
|
|
|
283
|
-
// manifest 内容断言(rebuildIndexes → derivedManifestRecord
|
|
284
|
-
// 非手工预写):sessionFile 来自 W1
|
|
285
|
-
// 处于 idle(重建单规则)、无旧终态遗留位 → legacy status
|
|
292
|
+
// manifest 内容断言(rebuildIndexes → derivedManifestRecord 派生视图路径的产物,
|
|
293
|
+
// 非手工预写):sessionFile 来自 W1 派生视图扩展。[U2/U3 两态桥接] 恢复链覆写后成员
|
|
294
|
+
// 处于 idle(重建单规则)、无旧终态遗留位 → legacy status 如实派生 "running"
|
|
286
295
|
//(legacyManifestStatusFields 单点派生,session-reader 视角的活跃成员)。
|
|
287
296
|
const manifest = JSON.parse(
|
|
288
|
-
fs.readFileSync(path.join(getSubagentRecordsDir(
|
|
297
|
+
fs.readFileSync(path.join(getSubagentRecordsDir(ROOT_SESSION, dataDir), 'sa-cross-1.json'), 'utf-8'),
|
|
289
298
|
) as Record<string, unknown>
|
|
290
299
|
expect(manifest).toMatchObject({
|
|
291
300
|
id: 'sa-cross-1',
|
|
@@ -370,13 +370,13 @@ describe('候选文件列表:顺序回退 + 命中即止(越界语义由调
|
|
|
370
370
|
})
|
|
371
371
|
})
|
|
372
372
|
|
|
373
|
-
describe('
|
|
373
|
+
describe('协议字面量漂移检查(跨包契约)', () => {
|
|
374
374
|
it('entry-anchor 本地 customType 字面量 === subagent-core 写侧单源', () => {
|
|
375
|
-
//
|
|
375
|
+
// 源码本地持有磁盘协议字符串(生产依赖范围不引 subagent-core),此处锚定两侧同值
|
|
376
376
|
expect(SUBAGENT_RECORD_CUSTOM_TYPE).toBe('subagent-record')
|
|
377
377
|
})
|
|
378
378
|
|
|
379
|
-
it('entry-anchor 本地 v2 认识版本 === subagent-core
|
|
379
|
+
it('entry-anchor 本地 v2 认识版本 === subagent-core 写侧单源(写侧再升版本本检查先红)', () => {
|
|
380
380
|
expect(SUBAGENT_RECORD_ENTRY_VERSION).toBe(2)
|
|
381
381
|
})
|
|
382
382
|
})
|