@zhushanwen/pi-session-reader 0.5.0 → 0.6.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 -2
- package/src/__tests__/cross-package-subagent-core.test.ts +13 -10
- package/src/__tests__/execution-tree.test.ts +139 -31
- package/src/__tests__/family.test.ts +0 -7
- package/src/__tests__/find.test.ts +5 -5
- package/src/__tests__/real-data.ts +1 -1
- package/src/__tests__/rebuild-manifest-dual-write.test.ts +177 -0
- package/src/__tests__/render.test.ts +36 -1
- package/src/__tests__/result.test.ts +20 -23
- package/src/__tests__/roots.test.ts +46 -80
- package/src/__tests__/session-command.test.ts +23 -0
- package/src/__tests__/subagents.test.ts +4 -3
- package/src/__tests__/tool-handler.test.ts +419 -141
- package/src/__tests__/workflow.test.ts +1 -7
- package/src/core/execution-tree.ts +1 -0
- package/src/core/family.ts +12 -9
- package/src/core/render.ts +40 -12
- package/src/core/workflow.ts +7 -20
- package/src/discovery/find.ts +42 -134
- package/src/discovery/roots.ts +16 -87
- package/src/discovery/session-header.ts +103 -0
- package/src/discovery/subagents.ts +23 -94
- package/src/doctor.ts +11 -77
- package/src/extract.ts +6 -19
- package/src/handler-utils.ts +28 -5
- package/src/no-match.ts +3 -2
- package/src/result-action.ts +30 -35
- package/src/search-across.ts +5 -4
- package/src/tool-handler.ts +151 -176
- package/src/tui/hash-provider.ts +7 -3
- package/src/tui/session-command.ts +22 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-session-reader",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "index.ts",
|
|
6
6
|
"xyz-agent": {
|
|
@@ -15,10 +15,13 @@
|
|
|
15
15
|
"keywords": [
|
|
16
16
|
"pi-package"
|
|
17
17
|
],
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@zhushanwen/pi-ext-guards": "0.4.0"
|
|
20
|
+
},
|
|
18
21
|
"devDependencies": {
|
|
19
22
|
"@vitest/coverage-v8": "^4.1.9",
|
|
20
23
|
"vitest": "^4.1.8",
|
|
21
|
-
"@zhushanwen/subagent-core": "0.
|
|
24
|
+
"@zhushanwen/subagent-core": "0.10.1"
|
|
22
25
|
},
|
|
23
26
|
"files": [
|
|
24
27
|
"index.ts",
|
|
@@ -12,12 +12,12 @@ import {
|
|
|
12
12
|
SubagentService,
|
|
13
13
|
type SubagentRecord,
|
|
14
14
|
} from '@zhushanwen/subagent-core'
|
|
15
|
-
import { ManifestStore } from '@zhushanwen/subagent-core/execution/manifest-store.ts'
|
|
16
|
-
import type { ModelRegistryLike } from '@zhushanwen/subagent-core/execution/model-resolver.ts'
|
|
15
|
+
import { ManifestStore } from '@zhushanwen/subagent-core/execution/persistence/manifest-store.ts'
|
|
16
|
+
import type { ModelRegistryLike } from '@zhushanwen/subagent-core/execution/assembly/model-resolver.ts'
|
|
17
17
|
import {
|
|
18
18
|
getSubagentRecordsDir,
|
|
19
19
|
getSubagentSessionDir,
|
|
20
|
-
} from '@zhushanwen/subagent-core/execution/path-encoding.ts'
|
|
20
|
+
} from '@zhushanwen/subagent-core/execution/assembly/path-encoding.ts'
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
23
|
* W4 跨包集成测试(subagent-sync-collect v2 impl-plan W4 验收条款①,设计 §5 W4:
|
|
@@ -213,13 +213,14 @@ describe('跨包集成:subagent-core E1 落标 manifest → session-reader res
|
|
|
213
213
|
return childFile
|
|
214
214
|
}
|
|
215
215
|
|
|
216
|
-
/** 种「崩溃前主文件末条序列」:register(running+sync)→ 轮终(
|
|
217
|
-
*
|
|
216
|
+
/** 种「崩溃前主文件末条序列」:register(running+sync)→ 轮终(idle + result
|
|
217
|
+
* 全文 + sessionFile)——[U4/D4] 翻边后成功成员崩溃时的真实末条形态(U5 前的
|
|
218
|
+
* running+resumable 桥接形态已随字段退役消亡)。 */
|
|
218
219
|
function seedRoundTerminalEntries(m: MemberSeed, childFile: string): void {
|
|
219
220
|
const store = makeSeedStore()
|
|
220
221
|
store.reportSubagentRecord(memberRecord({ id: m.id, sessionFile: childFile, model: m.model }))
|
|
221
222
|
store.reportSubagentRecord(
|
|
222
|
-
memberRecord({ id: m.id, sessionFile: childFile, model: m.model,
|
|
223
|
+
memberRecord({ id: m.id, sessionFile: childFile, model: m.model, status: 'idle', result: m.resultText }),
|
|
223
224
|
)
|
|
224
225
|
}
|
|
225
226
|
|
|
@@ -265,7 +266,9 @@ describe('跨包集成:subagent-core E1 落标 manifest → session-reader res
|
|
|
265
266
|
const childFiles = await driveSyncBatchRecovery([m])
|
|
266
267
|
|
|
267
268
|
// manifest 内容断言(W3 appendBatchFinalizedEntry 投影路径的产物,非手工预写):
|
|
268
|
-
//
|
|
269
|
+
// sessionFile 来自 W1 投影扩展。[U2/U3 两态桥接] E1 落标成员经恢复链覆写后处于
|
|
270
|
+
// idle(重建单规则)/running 形态、无旧终态遗留位 → legacy status 如实投影
|
|
271
|
+
// "running"(batchManifestRecord 单点派生,session-reader 视角的活跃成员)。
|
|
269
272
|
const manifest = JSON.parse(
|
|
270
273
|
fs.readFileSync(path.join(getSubagentRecordsDir(agentDir, agentDir), 'sa-cross-1.json'), 'utf-8'),
|
|
271
274
|
) as Record<string, unknown>
|
|
@@ -273,12 +276,12 @@ describe('跨包集成:subagent-core E1 落标 manifest → session-reader res
|
|
|
273
276
|
id: 'sa-cross-1',
|
|
274
277
|
rootSessionId: ROOT_SESSION,
|
|
275
278
|
agentName: '/agents/worker.md',
|
|
276
|
-
status: '
|
|
279
|
+
status: 'running',
|
|
277
280
|
sessionFile: childFiles.get('sa-cross-1'),
|
|
278
281
|
})
|
|
279
282
|
|
|
280
283
|
// session-reader 侧:sa- id 反查(v1 此处报「无匹配 record」——manifest 惰性不落盘)
|
|
281
|
-
const r = await handleSessionRead({ action: 'result', session: 'sa-cross-1' }, agentDir)
|
|
284
|
+
const r = await handleSessionRead({ action: 'result', session: 'sa-cross-1' }, { agentDir })
|
|
282
285
|
// 同源语义:跨 assistant message "\n\n" join、thinking 块排除——与 record.result 逐字节一致
|
|
283
286
|
expect(r.content[0]?.text).toBe(resultText)
|
|
284
287
|
const d = r.details as {
|
|
@@ -302,7 +305,7 @@ describe('跨包集成:subagent-core E1 落标 manifest → session-reader res
|
|
|
302
305
|
]
|
|
303
306
|
await driveSyncBatchRecovery(members)
|
|
304
307
|
|
|
305
|
-
const r = await handleSessionRead({ action: 'result', session: 'sa-cross-a,sa-cross-b' }, agentDir)
|
|
308
|
+
const r = await handleSessionRead({ action: 'result', session: 'sa-cross-a,sa-cross-b' }, { agentDir })
|
|
306
309
|
const text = r.content[0]?.text ?? ''
|
|
307
310
|
expect(text).toContain('[1/2] sa-cross-a')
|
|
308
311
|
expect(text).toContain('[2/2] sa-cross-b')
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
2
2
|
import { tmpdir } from 'node:os'
|
|
3
|
-
import { execSync } from 'node:child_process'
|
|
4
3
|
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
|
|
5
4
|
import { join } from 'node:path'
|
|
6
5
|
import { buildExecutionTree, formatExecutionTreeText, type ExecutionTreeNode } from '../core/execution-tree.js'
|
|
@@ -18,7 +17,11 @@ import { buildExecutionTree, formatExecutionTreeText, type ExecutionTreeNode } f
|
|
|
18
17
|
* - TC-m3b-cycle-detection:workflow 指针环(A→B→A),visited Set 防环
|
|
19
18
|
* - TC-m3b-single-node:单节点树(无后代,ES5)
|
|
20
19
|
* - TC-m3b-source-priority:parentRecordId 三级数据源优先级(manifest>identity>flat,DM4)
|
|
21
|
-
* - TC-m3b-real-data-guard
|
|
20
|
+
* - TC-m3b-real-data-guard:真实形态数据守卫(合成 fixture)。原直读本机 ~/.pi/agent 动态
|
|
21
|
+
* 发现家族根(skipIf CI 无数据),真实数据演化后确定性红且触碰真实数据目录(TEST-STRATEGY
|
|
22
|
+
* 红线),2026-09 数据面合成化:mkdtemp 临时目录仿真真实 agentDir 布局(时间戳前缀文件名 /
|
|
23
|
+
* 新旧机制混合 / 孤儿 manifest / 坏 manifest / 外来家族),guard 语义不变——解析不抛错 /
|
|
24
|
+
* 树非空 / 结构自洽,不锁 sourceMode 具体值
|
|
22
25
|
*/
|
|
23
26
|
|
|
24
27
|
// ---- fixture 常量(uuid 特征,互不为子串,满足 extractSessionIdFromFilename)----
|
|
@@ -32,20 +35,6 @@ const X_REAL = '0aaaaaaa-bbbb-7ccc-dddd-000000000006'
|
|
|
32
35
|
const RUN_A = 'wf-1786121304924-runA'
|
|
33
36
|
const RUN_B = 'wf-1786121304924-runB'
|
|
34
37
|
|
|
35
|
-
/** 真实 pi agent 目录(本机),用于集成测试守卫。 */
|
|
36
|
-
const REAL_AGENT_DIR = '/Users/zhushanwen/.pi/agent'
|
|
37
|
-
const HAS_REAL = (() => {
|
|
38
|
-
try {
|
|
39
|
-
return (
|
|
40
|
-
execSync(`find ${REAL_AGENT_DIR}/sessions -maxdepth 1 -type d 2>/dev/null | head -1`, {
|
|
41
|
-
encoding: 'utf8',
|
|
42
|
-
}).trim().length > 0
|
|
43
|
-
)
|
|
44
|
-
} catch {
|
|
45
|
-
return false
|
|
46
|
-
}
|
|
47
|
-
})()
|
|
48
|
-
|
|
49
38
|
// ---- fixture helpers(自包含,拓扑同 subagents.test.ts)----
|
|
50
39
|
|
|
51
40
|
async function makeAgentDir(): Promise<string> {
|
|
@@ -64,6 +53,7 @@ async function writeMainSession(dir: string, slug: string, id: string): Promise<
|
|
|
64
53
|
/**
|
|
65
54
|
* 写 subagent session 文件:首行 header(真实 id)+ 占位 message + 尾行 identity。
|
|
66
55
|
* identity 尾行含 rootSessionId/slug/agent,可选 parentRecordId(数据源 ② 测试用)。
|
|
56
|
+
* fileName 可选:默认 `<realId>.jsonl`;真实形态守卫用例传时间戳前缀名(<ts>_<id>.jsonl)。
|
|
67
57
|
*/
|
|
68
58
|
async function writeSubagentSession(
|
|
69
59
|
dir: string,
|
|
@@ -76,10 +66,11 @@ async function writeSubagentSession(
|
|
|
76
66
|
parentRecordId?: string
|
|
77
67
|
task?: string
|
|
78
68
|
},
|
|
69
|
+
fileName: string = `${realId}.jsonl`,
|
|
79
70
|
): Promise<string> {
|
|
80
71
|
const sessionDir = join(dir, 'subagents', slug, 'sessions')
|
|
81
72
|
await mkdir(sessionDir, { recursive: true })
|
|
82
|
-
const path = join(sessionDir,
|
|
73
|
+
const path = join(sessionDir, fileName)
|
|
83
74
|
const data: Record<string, unknown> = {
|
|
84
75
|
id: `sa-${realId.slice(0, 8)}`,
|
|
85
76
|
rootSessionId: identity.rootSessionId,
|
|
@@ -688,23 +679,140 @@ describe('buildExecutionTree - fixture', () => {
|
|
|
688
679
|
})
|
|
689
680
|
|
|
690
681
|
// ============================================================
|
|
691
|
-
//
|
|
682
|
+
// 真实形态数据守卫(合成 fixture,2026-09 测试债修复)
|
|
692
683
|
// ============================================================
|
|
684
|
+
//
|
|
685
|
+
// 原用例直读本机 ~/.pi/agent(execSync 探测 + findRealTreeRoot 扫真实 records 动态发现
|
|
686
|
+
// 家族根,skipIf CI 无数据):真实数据演化后确定性红(impl-plan 台账登记根因),且触碰
|
|
687
|
+
// 真实数据目录违反 TEST-STRATEGY 红线。数据面合成化——mkdtemp 临时目录仿真真实 agentDir
|
|
688
|
+
// 布局,guard 语义断言(解析不抛错 / 树非空 / 结构自洽 / sourceMode 值域)原样保留。
|
|
693
689
|
|
|
694
|
-
describe
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
690
|
+
describe('buildExecutionTree - 真实形态数据守卫(合成)', () => {
|
|
691
|
+
let dir: string
|
|
692
|
+
|
|
693
|
+
beforeEach(async () => {
|
|
694
|
+
dir = await makeAgentDir()
|
|
695
|
+
})
|
|
696
|
+
afterEach(async () => {
|
|
697
|
+
await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
698
|
+
})
|
|
699
|
+
|
|
700
|
+
it('TC-m3b-real-data-guard:真实形态布局(时间戳文件名/新旧机制混合/孤儿 manifest/坏 manifest/外来家族)解析不抛错,树非空且结构自洽', async () => {
|
|
701
|
+
// 仿真真实 agentDir 数据形态(原直读本机 ~/.pi/agent,合成化后确定性可重跑):
|
|
702
|
+
// - session 文件名带时间戳前缀(<ISO 时间戳>_<sessionId>.jsonl,真实 pi 落盘形态)
|
|
703
|
+
// - 混合新旧机制:A 顶层(无 parentRecordId)→ B 嵌套(manifest.parentRecordId 精确链);
|
|
704
|
+
// C 旧机制顶层(manifest/identity 均无 parentRecordId → flat 挂 main)
|
|
705
|
+
// - 孤儿 manifest D:sessionFile 已被 30 天 GC、manifest 残留(真实数据常态)
|
|
706
|
+
// - 坏 manifest(JSON 损坏)不中断扫描(ES4)
|
|
707
|
+
// - 外来家族 X(同 agentDir 其他 main 的 record)不应泄漏进目标树
|
|
708
|
+
const CWD = '--guard-cwd--'
|
|
709
|
+
const TS = '2026-09-11T10-00-00-000Z'
|
|
710
|
+
const OTHER_MAIN = '0aaaaaaa-bbbb-7ccc-dddd-000000000009'
|
|
711
|
+
const prefixed = (id: string): string => `${TS}_${id}.jsonl`
|
|
712
|
+
|
|
713
|
+
await writeMainSession(dir, CWD, MAIN)
|
|
714
|
+
// A:顶层 subagent(identity 尾行走数据源 ② 读路径,无 parentRecordId → 顶层挂 main)
|
|
715
|
+
const saA = await writeSubagentSession(
|
|
716
|
+
dir,
|
|
717
|
+
CWD,
|
|
718
|
+
A_REAL,
|
|
719
|
+
{ rootSessionId: MAIN, slug: 'guard-a' },
|
|
720
|
+
prefixed(A_REAL),
|
|
721
|
+
)
|
|
722
|
+
await writeRecordManifest(dir, CWD, 'rec-ga', {
|
|
723
|
+
rootSessionId: MAIN,
|
|
724
|
+
agentName: 'explorer',
|
|
725
|
+
sessionFile: saA,
|
|
726
|
+
slug: 'guard-a',
|
|
727
|
+
status: 'completed',
|
|
728
|
+
})
|
|
729
|
+
// B:A 的嵌套后代(manifest.parentRecordId 精确链,新机制)
|
|
730
|
+
const saB = await writeSubagentSession(
|
|
731
|
+
dir,
|
|
732
|
+
CWD,
|
|
733
|
+
B_REAL,
|
|
734
|
+
{ rootSessionId: MAIN, slug: 'guard-b' },
|
|
735
|
+
prefixed(B_REAL),
|
|
736
|
+
)
|
|
737
|
+
await writeRecordManifest(dir, CWD, 'rec-gb', {
|
|
738
|
+
rootSessionId: MAIN,
|
|
739
|
+
agentName: 'worker',
|
|
740
|
+
sessionFile: saB,
|
|
741
|
+
slug: 'guard-b',
|
|
742
|
+
status: 'completed',
|
|
743
|
+
parentRecordId: 'rec-ga',
|
|
744
|
+
})
|
|
745
|
+
// C:旧机制顶层 subagent(manifest/identity 均无 parentRecordId → flat 挂 main)
|
|
746
|
+
const saC = await writeSubagentSession(
|
|
747
|
+
dir,
|
|
748
|
+
CWD,
|
|
749
|
+
C_REAL,
|
|
750
|
+
{ rootSessionId: MAIN, slug: 'guard-c' },
|
|
751
|
+
prefixed(C_REAL),
|
|
752
|
+
)
|
|
753
|
+
await writeRecordManifest(dir, CWD, 'rec-gc', {
|
|
754
|
+
rootSessionId: MAIN,
|
|
755
|
+
agentName: 'explorer',
|
|
756
|
+
sessionFile: saC,
|
|
757
|
+
slug: 'guard-c',
|
|
758
|
+
status: 'completed',
|
|
759
|
+
})
|
|
760
|
+
// D:孤儿 manifest(sessionFile 指向已被 GC 的不存在文件——manifest 残留形态)
|
|
761
|
+
await writeRecordManifest(dir, CWD, 'rec-gd', {
|
|
762
|
+
rootSessionId: MAIN,
|
|
763
|
+
agentName: 'worker',
|
|
764
|
+
sessionFile: join(dir, 'subagents', CWD, 'sessions', prefixed(D_REAL)),
|
|
765
|
+
status: 'completed',
|
|
766
|
+
})
|
|
767
|
+
// 坏 manifest(JSON 损坏,listRecordManifests 容错跳过不中断)
|
|
768
|
+
await mkdir(join(dir, 'subagents', CWD, 'records'), { recursive: true })
|
|
769
|
+
await writeFile(join(dir, 'subagents', CWD, 'records', 'broken.json'), '{oops')
|
|
770
|
+
// 外来家族 X:同 agentDir 内其他 main(OTHER_MAIN)的 subagent,不应泄漏进 MAIN 的树
|
|
771
|
+
const saX = await writeSubagentSession(
|
|
772
|
+
dir,
|
|
773
|
+
CWD,
|
|
774
|
+
X_REAL,
|
|
775
|
+
{ rootSessionId: OTHER_MAIN, slug: 'foreign-x' },
|
|
776
|
+
prefixed(X_REAL),
|
|
777
|
+
)
|
|
778
|
+
await writeRecordManifest(dir, CWD, 'rec-gx', {
|
|
779
|
+
rootSessionId: OTHER_MAIN,
|
|
780
|
+
agentName: 'explorer',
|
|
781
|
+
sessionFile: saX,
|
|
782
|
+
status: 'completed',
|
|
783
|
+
})
|
|
784
|
+
|
|
785
|
+
const tree = await buildExecutionTree(MAIN, dir)
|
|
786
|
+
|
|
787
|
+
// 契约值域:sourceMode 只能是两种精度模式之一(record 含 parentRecordId → precise,
|
|
788
|
+
// 全无 → flat-fallback),不锁具体值
|
|
789
|
+
expect(['precise', 'flat-fallback']).toContain(tree.sourceMode)
|
|
790
|
+
// 家族树非空:main + 顶层 A/C/D + A 的嵌套后代 B = 5;外来 X 不泄漏(合成数据确定性
|
|
791
|
+
// 可数,精确计数即非泄漏守卫)
|
|
792
|
+
expect(tree.totalNodes).toBe(5)
|
|
704
793
|
expect(tree.root.type).toBe('main')
|
|
705
|
-
expect(tree.root.sessionId).toBe(
|
|
706
|
-
expect(tree.
|
|
707
|
-
|
|
794
|
+
expect(tree.root.sessionId).toBe(MAIN)
|
|
795
|
+
expect(findNode(tree.root, 'subagent', X_REAL)).toBeUndefined()
|
|
796
|
+
// 孤儿 D 仍入树(sessionFile 残留路径,不因文件缺失抛错/丢弃 record——原真实数据守卫
|
|
797
|
+
// 独有覆盖面,合成化后保留)
|
|
798
|
+
const nodeD = findNode(tree.root, 'subagent', D_REAL)
|
|
799
|
+
expect(nodeD).toBeDefined()
|
|
800
|
+
expect(nodeD!.sessionFile).toBe(join(dir, 'subagents', CWD, 'sessions', prefixed(D_REAL)))
|
|
801
|
+
// 树结构自洽(ExecutionTreeNode 契约,与具体数据形态无关):DFS 实际节点数 ===
|
|
802
|
+
// totalNodes;子节点 depth = 父 depth + 1;全树 rootSessionId 共享同一顶层 main
|
|
803
|
+
let count = 0
|
|
804
|
+
const walk = (node: ExecutionTreeNode): void => {
|
|
805
|
+
count++
|
|
806
|
+
expect(node.rootSessionId).toBe(tree.root.rootSessionId)
|
|
807
|
+
for (const c of node.children) {
|
|
808
|
+
expect(c.depth).toBe(node.depth + 1)
|
|
809
|
+
walk(c)
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
walk(tree.root)
|
|
813
|
+
expect(count).toBe(tree.totalNodes)
|
|
814
|
+
// 不抛错(已隐含:到这行说明成功)
|
|
815
|
+
})
|
|
708
816
|
})
|
|
709
817
|
|
|
710
818
|
// ============================================================
|
|
@@ -107,13 +107,6 @@ describe('buildFamilyIndex', () => {
|
|
|
107
107
|
expect(index.childrenOf.get('aaa')?.[0].sessionId).toBe('bbb')
|
|
108
108
|
})
|
|
109
109
|
|
|
110
|
-
it('parentSession 直接是 sessionId 也兼容(简化 fixture)', () => {
|
|
111
|
-
const headers = [header('aaa'), header('bbb', { parentSession: 'aaa' })]
|
|
112
|
-
const index = buildFamilyIndex(headers, [], new Map())
|
|
113
|
-
|
|
114
|
-
expect(index.childrenOf.get('aaa')?.[0].sessionId).toBe('bbb')
|
|
115
|
-
})
|
|
116
|
-
|
|
117
110
|
it('parentSession 反查不到父 → 该 entry 不进 childrenOf(不报错)', () => {
|
|
118
111
|
const headers = [header('bbb', { parentSession: '/missing.jsonl' })]
|
|
119
112
|
const index = buildFamilyIndex(headers, [], new Map())
|
|
@@ -133,7 +133,7 @@ async function makeSubagentSession(
|
|
|
133
133
|
describe('findSessions', () => {
|
|
134
134
|
let agentDir: string
|
|
135
135
|
let slugDir: string
|
|
136
|
-
/** subagent fixture 目录(模拟 subagents/<cwd编码>/sessions/ 结构,
|
|
136
|
+
/** subagent fixture 目录(模拟 subagents/<cwd编码>/sessions/ 结构,resolveSessionRoots [subagent] 根扫描路径) */
|
|
137
137
|
let saDir: string
|
|
138
138
|
|
|
139
139
|
let recordsDir: string
|
|
@@ -705,8 +705,8 @@ describe('u11 标题元数据(metadataProvider 注入,§6.6)', () => {
|
|
|
705
705
|
await mkdir(legacyDir, { recursive: true })
|
|
706
706
|
const provider = vi.fn(async (dir: string) =>
|
|
707
707
|
dir === flatMain
|
|
708
|
-
? [{ path: pathA, id: 'dup-id-0001', cwd: '/demo', name: '旧标题', modified: 1000 }]
|
|
709
|
-
: [{ path: pathA, id: 'dup-id-0001', cwd: '/demo', name: '新标题', modified: 2000 }],
|
|
708
|
+
? [{ path: pathA, id: 'dup-id-0001', cwd: '/demo', name: '旧标题', modified: new Date(1000) }]
|
|
709
|
+
: [{ path: pathA, id: 'dup-id-0001', cwd: '/demo', name: '新标题', modified: new Date(2000) }],
|
|
710
710
|
)
|
|
711
711
|
const { matches } = await findSessions('新标题', agentDir, { metadataProvider: provider })
|
|
712
712
|
expect(provider).toHaveBeenCalledTimes(2) // 两个平铺存在的根各一次
|
|
@@ -753,8 +753,8 @@ describe('u11 标题元数据(metadataProvider 注入,§6.6)', () => {
|
|
|
753
753
|
const provider = vi.fn(async (dir: string) =>
|
|
754
754
|
dir === flatMain
|
|
755
755
|
? [
|
|
756
|
-
{ path: pNew1, id: 'recent-new-1', cwd: '/demo', name: '最新标题一', modified: (base + 300) * 1000 },
|
|
757
|
-
{ path: pNew2, id: 'recent-new-2', cwd: '/demo', name: '最新标题二', modified: (base + 200) * 1000 },
|
|
756
|
+
{ path: pNew1, id: 'recent-new-1', cwd: '/demo', name: '最新标题一', modified: new Date((base + 300) * 1000) },
|
|
757
|
+
{ path: pNew2, id: 'recent-new-2', cwd: '/demo', name: '最新标题二', modified: new Date((base + 200) * 1000) },
|
|
758
758
|
]
|
|
759
759
|
: [],
|
|
760
760
|
)
|
|
@@ -22,7 +22,7 @@ export const REAL_AGENT_DIR = '/Users/zhushanwen/.pi/agent'
|
|
|
22
22
|
* 这类用例遍历本机 ~/.pi/agent 全量会话语料(实测 4600+ 文件),空载单用例已需 6-10s;
|
|
23
23
|
* 38 包并行全量测试时 CPU/IO 争抢可降速 3-4 倍,30s 标定必假红。90s ≈ 空载 9 倍余量,
|
|
24
24
|
* 仍能兜住真正的挂死。仅用于遍历全量语料的用例(findSessions / buildFamilyFromFs /
|
|
25
|
-
*
|
|
25
|
+
* resolveSessionRoots 等);读已知单文件的用例不需要放宽。
|
|
26
26
|
*/
|
|
27
27
|
export const REAL_DATA_TIMEOUT_MS = 90_000
|
|
28
28
|
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// 跨包集成([U4c / G4-S5③] record 持久化收敛 P4c 验收锚点):
|
|
2
|
+
// subagent-core 重建/终态写面产出的 manifest → session-reader identity 投影不降级。
|
|
3
|
+
//
|
|
4
|
+
// 断言链(设计 subagent-record-persistence-consolidation D5 前向兼容判定 + S5③):
|
|
5
|
+
// 1. manifest 被人为删除(S5 缓存可丢场景)→ subagent-core RecordStore.rebuildIndexes
|
|
6
|
+
// 全量重建 → session-reader listRecordManifests 重新可见;
|
|
7
|
+
// 2. 重建窗口前后,session-reader identity 投影的输入字段集(rootSessionId/slug/
|
|
8
|
+
// task/agentName/model/status/sessionFile/parentRecordId)逐字段一致——双写过渡
|
|
9
|
+
// 字段(executionStatus)在场且不被本包消费(旧 status 三态投影继续生效);
|
|
10
|
+
// 3. 重建源 = `.state` 权威(closedReason 从终态位派生,旧三态 cancelled 形态
|
|
11
|
+
// 保真——buildRecord 分支 1 → derivedManifestRecord 派生)。
|
|
12
|
+
//
|
|
13
|
+
// 形态同 cross-package-subagent-core.test.ts:subagent-core 真实 tmpdir 产出磁盘
|
|
14
|
+
// 状态,本包零 mock 读同一 tmpdir;不触碰真实数据目录(红线)。
|
|
15
|
+
|
|
16
|
+
import * as fs from 'node:fs'
|
|
17
|
+
import * as os from 'node:os'
|
|
18
|
+
import * as path from 'node:path'
|
|
19
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
20
|
+
|
|
21
|
+
import { RecordStore } from '@zhushanwen/subagent-core'
|
|
22
|
+
// barrel 外符号按包名 .ts 深路径消费(vitest alias 重写,同 cross-package 先例)
|
|
23
|
+
import { createRecord } from '@zhushanwen/subagent-core/execution/persistence/execution-record.ts'
|
|
24
|
+
import {
|
|
25
|
+
getSubagentRecordsDir,
|
|
26
|
+
getSubagentSessionDir,
|
|
27
|
+
} from '@zhushanwen/subagent-core/execution/assembly/path-encoding.ts'
|
|
28
|
+
|
|
29
|
+
import { listRecordManifests, type RecordManifest } from '../discovery/subagents.js'
|
|
30
|
+
|
|
31
|
+
const ROOT_SESSION = 'root-dual-write'
|
|
32
|
+
|
|
33
|
+
/** session-reader identity 投影(manifestIdentityData)消费的字段集——重建窗口
|
|
34
|
+
* 前后必须逐字段一致(降级 = 任一字段缺失/漂移)。 */
|
|
35
|
+
function identityView(m: RecordManifest) {
|
|
36
|
+
return {
|
|
37
|
+
id: m.id,
|
|
38
|
+
rootSessionId: m.rootSessionId,
|
|
39
|
+
agentName: m.agentName,
|
|
40
|
+
task: m.task,
|
|
41
|
+
slug: m.slug,
|
|
42
|
+
model: m.model,
|
|
43
|
+
status: m.status,
|
|
44
|
+
sessionFile: m.sessionFile,
|
|
45
|
+
parentRecordId: m.parentRecordId,
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('跨包集成:subagent-core rebuildIndexes 重建 manifest → session-reader 投影不降级', () => {
|
|
50
|
+
let agentDir: string
|
|
51
|
+
let sessionsDir: string
|
|
52
|
+
let recordsDir: string
|
|
53
|
+
let childFile: string
|
|
54
|
+
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
agentDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sr-rebuild-dualwrite-'))
|
|
57
|
+
sessionsDir = getSubagentSessionDir(agentDir, agentDir)
|
|
58
|
+
recordsDir = getSubagentRecordsDir(agentDir, agentDir)
|
|
59
|
+
fs.mkdirSync(sessionsDir, { recursive: true })
|
|
60
|
+
fs.mkdirSync(recordsDir, { recursive: true })
|
|
61
|
+
|
|
62
|
+
// 子 session 文件:session header + model_change(light 重建的 model 源——
|
|
63
|
+
// identity 前途经 entry best-effort 提取)+ identity entry(session-reader
|
|
64
|
+
// 步骤 2 首行 header 探测 + manifest 主路径的数据锚)
|
|
65
|
+
childFile = path.join(sessionsDir, '20260912T000000_sa-dw.jsonl')
|
|
66
|
+
const ts = new Date(1000).toISOString()
|
|
67
|
+
fs.writeFileSync(
|
|
68
|
+
childFile,
|
|
69
|
+
[
|
|
70
|
+
JSON.stringify({ type: 'session', version: 3, id: 'sess-sa-dw', timestamp: ts, cwd: agentDir }),
|
|
71
|
+
JSON.stringify({ type: 'model_change', id: 'mc-1', parentId: null, timestamp: ts, provider: 'prov', modelId: 'm1' }),
|
|
72
|
+
JSON.stringify({
|
|
73
|
+
type: 'custom',
|
|
74
|
+
id: 'id-1',
|
|
75
|
+
parentId: null,
|
|
76
|
+
timestamp: ts,
|
|
77
|
+
customType: 'subagent-identity',
|
|
78
|
+
data: {
|
|
79
|
+
id: 'sa-dw',
|
|
80
|
+
agent: 'worker',
|
|
81
|
+
mode: 'background',
|
|
82
|
+
task: 'dual write task',
|
|
83
|
+
slug: 'dual',
|
|
84
|
+
startedAt: 1000,
|
|
85
|
+
rootSessionId: ROOT_SESSION,
|
|
86
|
+
parentRecordId: 'sa-parent',
|
|
87
|
+
depth: 1,
|
|
88
|
+
},
|
|
89
|
+
}),
|
|
90
|
+
].join('\n') + '\n',
|
|
91
|
+
'utf-8',
|
|
92
|
+
)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
afterEach(() => {
|
|
96
|
+
fs.rmSync(agentDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('S5③: manifest 删除 → rebuildIndexes 重建 → identity 投影字段集前后一致(双写字段在场)', async () => {
|
|
100
|
+
// ── 初始 manifest:subagent-core 终态写面(markFinalized)真实产出 ──
|
|
101
|
+
const record = createRecord('sa-dw', {
|
|
102
|
+
agent: 'worker',
|
|
103
|
+
model: 'prov/m1',
|
|
104
|
+
mode: 'background',
|
|
105
|
+
task: 'dual write task',
|
|
106
|
+
slug: 'dual',
|
|
107
|
+
startedAt: 1000,
|
|
108
|
+
rootSessionId: ROOT_SESSION,
|
|
109
|
+
parentRecordId: 'sa-parent',
|
|
110
|
+
depth: 1,
|
|
111
|
+
chatMode: false,
|
|
112
|
+
controller: new AbortController(),
|
|
113
|
+
})
|
|
114
|
+
Object.assign(record, {
|
|
115
|
+
sessionFile: childFile,
|
|
116
|
+
status: 'closed' as const,
|
|
117
|
+
closedReason: 'gc' as const,
|
|
118
|
+
endedAt: 2000,
|
|
119
|
+
})
|
|
120
|
+
const writer = new RecordStore(sessionsDir, undefined, undefined, recordsDir)
|
|
121
|
+
expect(writer.markFinalized(record, 'gc')).toBe(true)
|
|
122
|
+
writer.dispose()
|
|
123
|
+
|
|
124
|
+
const before = (await listRecordManifests(agentDir)).find((m) => m.id === 'sa-dw')
|
|
125
|
+
expect(before).toBeDefined()
|
|
126
|
+
const viewBefore = identityView(before!)
|
|
127
|
+
|
|
128
|
+
// 双写过渡字段在写面就位(本包不消费,但前向兼容锚要求其在场)。
|
|
129
|
+
// [U2 两态] executionStatus 是内部权威词汇(running|idle 两态,终态概念删除)
|
|
130
|
+
// → 终态化后恒 'idle';status 旧三态是 session-reader 直读投影(永久保留)→ 'closed'。
|
|
131
|
+
const rawBefore = JSON.parse(fs.readFileSync(path.join(recordsDir, 'sa-dw.json'), 'utf-8')) as Record<string, unknown>
|
|
132
|
+
expect(rawBefore.executionStatus).toBe('idle')
|
|
133
|
+
expect(rawBefore.status).toBe('closed')
|
|
134
|
+
|
|
135
|
+
// ── S5 场景:人为删除全部 manifest(缓存可丢)→ 全量重建 ──
|
|
136
|
+
for (const f of fs.readdirSync(recordsDir)) fs.rmSync(path.join(recordsDir, f))
|
|
137
|
+
expect((await listRecordManifests(agentDir)).filter((m) => m.id === 'sa-dw')).toHaveLength(0)
|
|
138
|
+
|
|
139
|
+
const rebuilder = new RecordStore(sessionsDir, undefined, undefined, recordsDir)
|
|
140
|
+
expect(rebuilder.rebuildIndexes()).toBe(1)
|
|
141
|
+
rebuilder.dispose()
|
|
142
|
+
|
|
143
|
+
// ── session-reader 视角:重建后 identity 投影不降级 ──
|
|
144
|
+
const after = (await listRecordManifests(agentDir)).find((m) => m.id === 'sa-dw')
|
|
145
|
+
expect(after).toBeDefined()
|
|
146
|
+
expect(identityView(after!)).toEqual(viewBefore)
|
|
147
|
+
|
|
148
|
+
// 重建源 = `.state` 权威:closedReason 从终态位派生回 manifest(旧三态 closed)
|
|
149
|
+
const rawAfter = JSON.parse(fs.readFileSync(path.join(recordsDir, 'sa-dw.json'), 'utf-8')) as Record<string, unknown>
|
|
150
|
+
expect(rawAfter.status).toBe('closed')
|
|
151
|
+
expect(rawAfter.executionStatus).toBe('idle')
|
|
152
|
+
expect(rawAfter.closedReason).toBe('gc')
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('惰性通道:查询面(collectRecords)补建的 manifest 同样可被本包读取投影', async () => {
|
|
156
|
+
// 不经终态写面(无 manifest 初始形态):磁盘 record 只有 identity + 无 sidecar
|
|
157
|
+
// → 查询触发惰性补建。
|
|
158
|
+
const store = new RecordStore(sessionsDir, undefined, undefined, recordsDir)
|
|
159
|
+
expect(store.collectRecords(10).map((r) => r.id)).toContain('sa-dw')
|
|
160
|
+
store.dispose()
|
|
161
|
+
|
|
162
|
+
const manifests = await listRecordManifests(agentDir)
|
|
163
|
+
const found = manifests.find((m) => m.id === 'sa-dw')
|
|
164
|
+
expect(found).toBeDefined()
|
|
165
|
+
expect(found!.status).toBe('running')
|
|
166
|
+
expect(found!.task).toBe('dual write task')
|
|
167
|
+
expect(found!.agentName).toBe('worker')
|
|
168
|
+
expect(found!.sessionFile).toBe(childFile)
|
|
169
|
+
expect(found!.parentRecordId).toBe('sa-parent')
|
|
170
|
+
// 双写过渡字段([U3 重建单规则] 无 sidecar 重建一律 idle:executionStatus 如实
|
|
171
|
+
// 投影两态词汇;legacy status 经桥接判据派生——无 closedReason 遗留位的 idle
|
|
172
|
+
// 投影 "running"(session-reader 视角的活跃成员,§3.2.8 下行映射)。
|
|
173
|
+
const raw = JSON.parse(fs.readFileSync(path.join(recordsDir, 'sa-dw.json'), 'utf-8')) as Record<string, unknown>
|
|
174
|
+
expect(raw.executionStatus).toBe('idle')
|
|
175
|
+
expect(raw.status).toBe('running')
|
|
176
|
+
})
|
|
177
|
+
})
|
|
@@ -90,6 +90,8 @@ describe('renderOutline', () => {
|
|
|
90
90
|
// thinking 'secret'(6B) + toolResult 'output'(6B)
|
|
91
91
|
expect(b.omittedBytes).toBe(12)
|
|
92
92
|
expect(result.truncated).toBeUndefined()
|
|
93
|
+
// E7 行渲染统一:lines 随结果返回,与 turns 一一对应(预算度量 = 展示行同一份)
|
|
94
|
+
expect(result.lines).toEqual(['T000 · do something · bash×2,read×2 · → sure · [12B omitted]'])
|
|
93
95
|
})
|
|
94
96
|
|
|
95
97
|
it('2a. 降级:单行 toolSummary 过长超 perTurnBudget → 砍 toolSummary,保 userBrief 骨架', () => {
|
|
@@ -108,6 +110,25 @@ describe('renderOutline', () => {
|
|
|
108
110
|
expect(b.toolSummary).toBe('') // 被砍(降级)
|
|
109
111
|
expect(b.userBrief).toBe('u'.repeat(10)) // 骨架保留
|
|
110
112
|
}
|
|
113
|
+
// E7:降级后的渲染行(骨架形态)与 briefs 字段同步——行内无 → 结论段、无工具聚合段
|
|
114
|
+
expect(result.lines).toHaveLength(3)
|
|
115
|
+
expect(result.lines).toEqual(['T000 · uuuuuuuuuu', 'T001 · uuuuuuuuuu', 'T002 · uuuuuuuuuu'])
|
|
116
|
+
expect(result.truncated).toBeUndefined()
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('2b. 降级(砍 assistantBrief 档):行内 → 结论段消失、toolSummary 保留,与改前语义一致', () => {
|
|
120
|
+
// budget=20 → perTurnCharBudget=80;level 0 行 = T000 · u*10 · bash×2 · → a*80… ≈112 > 80
|
|
121
|
+
// → 砍 assistantBrief 后 ≈26 ≤ 80 停(S3:该档形态与改前 handler 侧渲染降级后 briefs 等价)
|
|
122
|
+
const t = turn(
|
|
123
|
+
0,
|
|
124
|
+
[uEntry('U', 'u'.repeat(10)), aEntry('A', 'a'.repeat(100), [{ name: 'bash' }, { name: 'bash' }])],
|
|
125
|
+
{ userEntry: uEntry('U', 'u'.repeat(10)) },
|
|
126
|
+
)
|
|
127
|
+
const result = renderOutline([t], emptyTree(), { budget: 20 })
|
|
128
|
+
|
|
129
|
+
expect(result.turns[0].assistantBrief).toBe('') // 被砍
|
|
130
|
+
expect(result.turns[0].toolSummary).toBe('bash×2') // 保留
|
|
131
|
+
expect(result.lines).toEqual(['T000 · uuuuuuuuuu · bash×2'])
|
|
111
132
|
expect(result.truncated).toBeUndefined()
|
|
112
133
|
})
|
|
113
134
|
|
|
@@ -122,6 +143,8 @@ describe('renderOutline', () => {
|
|
|
122
143
|
|
|
123
144
|
expect(result.truncated).toBe(3)
|
|
124
145
|
expect(result.turns).toHaveLength(2)
|
|
146
|
+
// E7:lines 与 turns 同步截短(truncateToTotalBudget 双数组一致)
|
|
147
|
+
expect(result.lines).toHaveLength(2)
|
|
125
148
|
})
|
|
126
149
|
|
|
127
150
|
it('4. toolSummary 聚合:同名计数 ×N,不同名逗号分隔', () => {
|
|
@@ -168,6 +191,19 @@ describe('renderOutline', () => {
|
|
|
168
191
|
expect(withoutBranches.turns[0].branch).toBeUndefined()
|
|
169
192
|
})
|
|
170
193
|
|
|
194
|
+
it('7b. E7 旁支标记统一:渲染行 `[旁支 N entries]`(N = 该 forkPoint 下旁支子树 entry 数)', () => {
|
|
195
|
+
// branches: U → 3 entries 旁支子树(E7/D3:outline 与 export 两条路径共用此形态)
|
|
196
|
+
const tree: TreeView = { leafPath: ['U'], branches: new Map([['U', 3]]), orphans: [] }
|
|
197
|
+
const userEntry = uEntry('U', 'hi')
|
|
198
|
+
const turns = [turn(0, [userEntry], { userEntry })]
|
|
199
|
+
|
|
200
|
+
const result = renderOutline(turns, tree, { allBranches: true })
|
|
201
|
+
expect(result.lines).toEqual(['T000 · hi · [旁支 3 entries]'])
|
|
202
|
+
// branchSize 缺失(allBranches:false)不标旁支
|
|
203
|
+
const noBranch = renderOutline(turns, tree, { allBranches: false })
|
|
204
|
+
expect(noBranch.lines).toEqual(['T000 · hi'])
|
|
205
|
+
})
|
|
206
|
+
|
|
171
207
|
it.skipIf(!HAS_REAL_SESSION)('8. 真实 019e6c96:outline tokenEstimate <= 1500 + assistantBrief/toolSummary 非空(v2 O1)', async () => {
|
|
172
208
|
// v2 O1:加 assistantBrief + 修 toolSummary bug 后 outline 变长,阈值 600→1500(design §3.3 D4)
|
|
173
209
|
const parsed = await parseSessionFile(REAL_SESSION)
|
|
@@ -310,7 +346,6 @@ describe('renderDetail', () => {
|
|
|
310
346
|
expect(summary.summary).toMatch(/^read: f\.ts \(\d+KB\)$/)
|
|
311
347
|
expect(summary.totalLines).toBe(4)
|
|
312
348
|
expect(summary.headLines).toContain('line1')
|
|
313
|
-
expect(summary.fullEntry.message?.role).toBe('toolResult')
|
|
314
349
|
|
|
315
350
|
// includeToolResult 全文:返回原 Entry
|
|
316
351
|
const full = renderDetail([t], { includeToolResult: true })
|