@zhushanwen/pi-session-reader 0.3.1 → 0.5.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 +3 -2
- package/src/__tests__/cross-package-subagent-core.test.ts +321 -0
- package/src/__tests__/env.test.ts +258 -0
- package/src/__tests__/execution-tree.test.ts +9 -8
- package/src/__tests__/find.test.ts +214 -9
- package/src/__tests__/hash-provider.test.ts +2 -2
- package/src/__tests__/index.test.ts +157 -5
- package/src/__tests__/real-data.ts +10 -0
- package/src/__tests__/result.test.ts +341 -0
- package/src/__tests__/roots.test.ts +362 -9
- package/src/__tests__/session-command.test.ts +2 -2
- package/src/__tests__/subagents.test.ts +7 -6
- package/src/__tests__/tool-handler.test.ts +1216 -7
- package/src/discovery/env.ts +238 -0
- package/src/discovery/find.ts +363 -56
- package/src/discovery/roots.ts +236 -8
- package/src/discovery/subagents.ts +31 -11
- package/src/doctor.ts +262 -0
- package/src/extract.ts +422 -0
- package/src/handler-utils.ts +63 -0
- package/src/index.ts +63 -9
- package/src/no-match.ts +160 -0
- package/src/result-action.ts +244 -0
- package/src/search-across.ts +539 -0
- package/src/tool-handler.ts +554 -723
- package/vitest.config.ts +15 -0
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
2
|
import { tmpdir } from 'node:os'
|
|
3
3
|
import { mkdtemp, mkdir, writeFile, rm, utimes } from 'node:fs/promises'
|
|
4
4
|
import { join } from 'node:path'
|
|
5
5
|
import { findSessions } from '../discovery/find.js'
|
|
6
|
-
import { REAL_AGENT_DIR, HAS_E6, HAS_REAL_SUBAGENTS_DIR } from './real-data.js'
|
|
6
|
+
import { REAL_AGENT_DIR, HAS_E6, HAS_REAL_SUBAGENTS_DIR, REAL_DATA_TIMEOUT_MS } from './real-data.js'
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* 建一个假 session 文件:首行 header(type=session,含 id/cwd/parentSession),
|
|
@@ -43,9 +43,9 @@ async function makeSession(
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
/**
|
|
46
|
-
* 建 records manifest(U5 fixture)。manifest
|
|
47
|
-
* sessionFile 指向 alive subagent session
|
|
48
|
-
* 从该路径提取 sessionId 建索引)。
|
|
46
|
+
* 建 records manifest(U5 fixture)。manifest 由终态 finalize 或 sync 批落标出口写入
|
|
47
|
+
* records/<sa-id>.json(创建时不写),sessionFile 指向 alive subagent session 的绝对路径
|
|
48
|
+
* (find 用 extractSessionIdFromFilename 从该路径提取 sessionId 建索引)。
|
|
49
49
|
*/
|
|
50
50
|
async function makeRecordManifest(
|
|
51
51
|
recordsDir: string,
|
|
@@ -523,7 +523,7 @@ describe('findSessions', () => {
|
|
|
523
523
|
expect(matches.every((m) => m.source === 'subagent')).toBe(true)
|
|
524
524
|
expect(typeof truncated).toBe('boolean')
|
|
525
525
|
},
|
|
526
|
-
|
|
526
|
+
REAL_DATA_TIMEOUT_MS,
|
|
527
527
|
)
|
|
528
528
|
})
|
|
529
529
|
|
|
@@ -536,7 +536,7 @@ describe('findSessions', () => {
|
|
|
536
536
|
expect(hit.fileName).toContain('019e6c96')
|
|
537
537
|
expect(hit.mtime).toBeGreaterThan(0)
|
|
538
538
|
expect(hit.sizeBytes).toBeGreaterThan(0)
|
|
539
|
-
},
|
|
539
|
+
}, REAL_DATA_TIMEOUT_MS)
|
|
540
540
|
|
|
541
541
|
it.skipIf(!HAS_E6)("真实数据:recent 返回最近 N 个,mtime 倒序,truncated=true", async () => {
|
|
542
542
|
const { matches, truncated } = await findSessions('recent', REAL_AGENT_DIR, { limit: 5 })
|
|
@@ -548,7 +548,7 @@ describe('findSessions', () => {
|
|
|
548
548
|
}
|
|
549
549
|
// 真实 session 文件远多于 5 → 截断
|
|
550
550
|
expect(truncated).toBe(true)
|
|
551
|
-
},
|
|
551
|
+
}, REAL_DATA_TIMEOUT_MS)
|
|
552
552
|
|
|
553
553
|
it.skipIf(!HAS_REAL_SUBAGENTS_DIR)(
|
|
554
554
|
"真实数据:source:'subagent' 能找到 completed subagent(§7 场景 1 find 部分)",
|
|
@@ -564,6 +564,211 @@ describe('findSessions', () => {
|
|
|
564
564
|
expect(m.fileName).toContain('subagents')
|
|
565
565
|
}
|
|
566
566
|
},
|
|
567
|
-
|
|
567
|
+
REAL_DATA_TIMEOUT_MS,
|
|
568
568
|
)
|
|
569
569
|
})
|
|
570
|
+
|
|
571
|
+
// ============================================================
|
|
572
|
+
// u11:标题元数据 metadataProvider 注入(design 2026-09-10 §6.6)
|
|
573
|
+
// 覆盖三条调用策略中的 ①惰性 ②窄化(TTL 缓存句柄在 tool-handler 注入侧,
|
|
574
|
+
// 见 tool-handler.test.ts u11 段)+ 两条 guard + §11.3b 多目录合并语义
|
|
575
|
+
// + 纯 TS 首条 user 降级路径保留。
|
|
576
|
+
// provider 一律注入替身(不真调 pi SessionManager)。
|
|
577
|
+
// ============================================================
|
|
578
|
+
describe('u11 标题元数据(metadataProvider 注入,§6.6)', () => {
|
|
579
|
+
let root: string
|
|
580
|
+
let agentDir: string
|
|
581
|
+
/** 平铺 main 根(xyz-agent 形态:session 直在 sessions/ 下,[default] 根无子目录) */
|
|
582
|
+
let flatMain: string
|
|
583
|
+
/** legacy 根(dirname(agentDir)/sessions) */
|
|
584
|
+
let legacyDir: string
|
|
585
|
+
|
|
586
|
+
async function writeSession(
|
|
587
|
+
dir: string,
|
|
588
|
+
opts: { name: string; id: string; cwd?: string; firstUserText?: string },
|
|
589
|
+
): Promise<string> {
|
|
590
|
+
await mkdir(dir, { recursive: true })
|
|
591
|
+
const lines = [JSON.stringify({ type: 'session', id: opts.id, cwd: opts.cwd ?? '/demo' })]
|
|
592
|
+
if (opts.firstUserText !== undefined) {
|
|
593
|
+
lines.push(
|
|
594
|
+
JSON.stringify({
|
|
595
|
+
type: 'message',
|
|
596
|
+
id: `${opts.id}-m1`,
|
|
597
|
+
message: { role: 'user', content: [{ type: 'text', text: opts.firstUserText }] },
|
|
598
|
+
}),
|
|
599
|
+
)
|
|
600
|
+
}
|
|
601
|
+
const path = join(dir, opts.name)
|
|
602
|
+
await writeFile(path, lines.join('\n') + '\n')
|
|
603
|
+
return path
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
beforeEach(async () => {
|
|
607
|
+
// agentDir 挂在 mkdtemp 子目录下,legacy 根(dirname(agentDir)/sessions)仍在
|
|
608
|
+
// 自建 tmp 树内,不污染共享 tmpdir(也不受其他用例残留影响)
|
|
609
|
+
root = await mkdtemp(join(tmpdir(), 'find-u11-'))
|
|
610
|
+
agentDir = join(root, 'agent')
|
|
611
|
+
flatMain = join(agentDir, 'sessions')
|
|
612
|
+
legacyDir = join(root, 'sessions')
|
|
613
|
+
})
|
|
614
|
+
afterEach(async () => {
|
|
615
|
+
await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
616
|
+
})
|
|
617
|
+
|
|
618
|
+
it('① 惰性触发:uuid 精确/归一化路径不调 provider(spy 计数 0)', async () => {
|
|
619
|
+
await writeSession(flatMain, { name: 'a.jsonl', id: '019cafe00001', firstUserText: '无关' })
|
|
620
|
+
const provider = vi.fn(async () => [])
|
|
621
|
+
// 精确子串层命中
|
|
622
|
+
await findSessions('019cafe00001', agentDir, { metadataProvider: provider })
|
|
623
|
+
expect(provider).not.toHaveBeenCalled()
|
|
624
|
+
// 归一化层命中(大写变体:精确层大小写敏感 0 命中,norm 层命中后短路)
|
|
625
|
+
await findSessions('019CAFE00001', agentDir, { metadataProvider: provider })
|
|
626
|
+
expect(provider).not.toHaveBeenCalled()
|
|
627
|
+
})
|
|
628
|
+
|
|
629
|
+
it('② keyword 路径调用 provider 且标题进匹配(name 命中,preview 取元数据首消息免深读)', async () => {
|
|
630
|
+
const pathA = await writeSession(flatMain, {
|
|
631
|
+
name: 'a.jsonl',
|
|
632
|
+
id: 'title-aaaa',
|
|
633
|
+
firstUserText: '首消息完全无关',
|
|
634
|
+
})
|
|
635
|
+
await writeSession(flatMain, { name: 'b.jsonl', id: 'title-bbbb', firstUserText: '另一个无关会话' })
|
|
636
|
+
const provider = vi.fn(async (dir: string) =>
|
|
637
|
+
dir === flatMain
|
|
638
|
+
? [
|
|
639
|
+
{
|
|
640
|
+
path: pathA,
|
|
641
|
+
id: 'title-aaaa',
|
|
642
|
+
cwd: '/demo',
|
|
643
|
+
name: '福耀玻璃深度研究',
|
|
644
|
+
modified: new Date('2026-09-01'),
|
|
645
|
+
firstMessage: '首消息完全无关',
|
|
646
|
+
},
|
|
647
|
+
]
|
|
648
|
+
: [],
|
|
649
|
+
)
|
|
650
|
+
const { matches } = await findSessions('福耀玻璃', agentDir, { metadataProvider: provider })
|
|
651
|
+
// 平铺存在的 [default] 根被调用,且永传非空串
|
|
652
|
+
expect(provider).toHaveBeenCalledTimes(1)
|
|
653
|
+
expect(provider.mock.calls[0][0]).toBe(flatMain)
|
|
654
|
+
expect(provider.mock.calls[0][0].length).toBeGreaterThan(0)
|
|
655
|
+
// 标题命中:name 透传 + preview 取元数据 firstMessage
|
|
656
|
+
expect(matches).toHaveLength(1)
|
|
657
|
+
expect(matches[0].sessionId).toBe('title-aaaa')
|
|
658
|
+
expect(matches[0].name).toBe('福耀玻璃深度研究')
|
|
659
|
+
expect(matches[0].firstMessagePreview).toBe('首消息完全无关')
|
|
660
|
+
})
|
|
661
|
+
|
|
662
|
+
it('③ 窄化:含子目录根跳过 provider,候选不退出 keyword 匹配(回退首条 user,标题留空)', async () => {
|
|
663
|
+
// 纯 pi 形态:文件在 encodeCwd 子目录 → [default] 根含子目录 → listAll 跳过
|
|
664
|
+
await writeSession(join(flatMain, '--Users-demo--'), {
|
|
665
|
+
name: 'a.jsonl',
|
|
666
|
+
id: 'nested-aaaa',
|
|
667
|
+
firstUserText: '重构插件架构 plugin review',
|
|
668
|
+
})
|
|
669
|
+
const provider = vi.fn(async () => [])
|
|
670
|
+
const { matches } = await findSessions('plugin', agentDir, { metadataProvider: provider })
|
|
671
|
+
expect(provider).not.toHaveBeenCalled()
|
|
672
|
+
expect(matches).toHaveLength(1)
|
|
673
|
+
expect(matches[0].sessionId).toBe('nested-aaaa')
|
|
674
|
+
expect(matches[0].firstMessagePreview).toContain('plugin')
|
|
675
|
+
// 标题维度降级留空:「能找到 session」不受影响(§6.6 降级路径)
|
|
676
|
+
expect(matches[0].name).toBeUndefined()
|
|
677
|
+
})
|
|
678
|
+
|
|
679
|
+
it('④ guard:provider 抛错降级(标题留空 + 不抛,回退首条 user 匹配)', async () => {
|
|
680
|
+
await writeSession(flatMain, {
|
|
681
|
+
name: 'a.jsonl',
|
|
682
|
+
id: 'err-aaaa',
|
|
683
|
+
firstUserText: '按首条消息命中的文本',
|
|
684
|
+
})
|
|
685
|
+
const provider = vi.fn(async () => {
|
|
686
|
+
throw new Error('listAll boom')
|
|
687
|
+
})
|
|
688
|
+
const { matches } = await findSessions('按首条消息命中', agentDir, {
|
|
689
|
+
metadataProvider: provider,
|
|
690
|
+
})
|
|
691
|
+
expect(provider).toHaveBeenCalledTimes(1) // 调用了但抛错
|
|
692
|
+
expect(matches).toHaveLength(1)
|
|
693
|
+
expect(matches[0].sessionId).toBe('err-aaaa')
|
|
694
|
+
expect(matches[0].name).toBeUndefined()
|
|
695
|
+
expect(matches[0].firstMessagePreview).toContain('按首条消息命中')
|
|
696
|
+
})
|
|
697
|
+
|
|
698
|
+
it('⑤ 多目录结果合并:按 id 去重、重复取新 modified(§11.3b)', async () => {
|
|
699
|
+
const pathA = await writeSession(flatMain, {
|
|
700
|
+
name: 'a.jsonl',
|
|
701
|
+
id: 'dup-id-0001',
|
|
702
|
+
firstUserText: '无关',
|
|
703
|
+
})
|
|
704
|
+
// legacy 根平铺存在(0 文件也算存在根,isFlatRoot 空集恒真)
|
|
705
|
+
await mkdir(legacyDir, { recursive: true })
|
|
706
|
+
const provider = vi.fn(async (dir: string) =>
|
|
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 }],
|
|
710
|
+
)
|
|
711
|
+
const { matches } = await findSessions('新标题', agentDir, { metadataProvider: provider })
|
|
712
|
+
expect(provider).toHaveBeenCalledTimes(2) // 两个平铺存在的根各一次
|
|
713
|
+
expect(matches).toHaveLength(1)
|
|
714
|
+
expect(matches[0].name).toBe('新标题') // 同 id 重复 → 取新 modified 的条目
|
|
715
|
+
const miss = await findSessions('旧标题', agentDir, { metadataProvider: provider })
|
|
716
|
+
expect(miss.matches).toHaveLength(0) // 旧条目已按合并规则丢弃
|
|
717
|
+
})
|
|
718
|
+
|
|
719
|
+
it('⑥ guard:仅对存在的根调用且永传非空串(不存在根 / 不存在 liveSessionDir 不调)', async () => {
|
|
720
|
+
await writeSession(flatMain, { name: 'a.jsonl', id: 'guard-aaaa', firstUserText: '无关' })
|
|
721
|
+
const provider = vi.fn(async () => [])
|
|
722
|
+
await findSessions('zzznohit9q', agentDir, {
|
|
723
|
+
liveSessionDir: join(agentDir, 'no-such-dir'),
|
|
724
|
+
metadataProvider: provider,
|
|
725
|
+
})
|
|
726
|
+
// 仅平铺存在的 [default] 根被调;不存在的 liveSessionDir 与 [legacy] 根均被排除
|
|
727
|
+
expect(provider).toHaveBeenCalledTimes(1)
|
|
728
|
+
expect(provider.mock.calls[0][0]).toBe(flatMain)
|
|
729
|
+
expect(provider.mock.calls.every((c) => typeof c[0] === 'string' && c[0].length > 0)).toBe(true)
|
|
730
|
+
})
|
|
731
|
+
|
|
732
|
+
it('⑥b liveSessionDir 与平铺根同字面路径 → 同目录只调一次(字面路径去重)', async () => {
|
|
733
|
+
await writeSession(flatMain, { name: 'a.jsonl', id: 'dedup-aaaa', firstUserText: '无关' })
|
|
734
|
+
const provider = vi.fn(async () => [])
|
|
735
|
+
await findSessions('zzznohit9q', agentDir, {
|
|
736
|
+
liveSessionDir: flatMain,
|
|
737
|
+
metadataProvider: provider,
|
|
738
|
+
})
|
|
739
|
+
expect(provider).toHaveBeenCalledTimes(1)
|
|
740
|
+
expect(provider.mock.calls[0][0]).toBe(flatMain)
|
|
741
|
+
})
|
|
742
|
+
|
|
743
|
+
it('⑦ recent:仅对 limit 截断后的少数候选补元数据(旧候选所在根不调)', async () => {
|
|
744
|
+
const pNew1 = await writeSession(flatMain, { name: 'n1.jsonl', id: 'recent-new-1' })
|
|
745
|
+
const pNew2 = await writeSession(flatMain, { name: 'n2.jsonl', id: 'recent-new-2' })
|
|
746
|
+
await writeSession(legacyDir, { name: 'o1.jsonl', id: 'recent-old-1' })
|
|
747
|
+
await writeSession(legacyDir, { name: 'o2.jsonl', id: 'recent-old-2' })
|
|
748
|
+
const base = Math.floor(Date.now() / 1000)
|
|
749
|
+
await utimes(pNew1, base + 300, base + 300)
|
|
750
|
+
await utimes(pNew2, base + 200, base + 200)
|
|
751
|
+
await utimes(join(legacyDir, 'o1.jsonl'), base + 100, base + 100)
|
|
752
|
+
await utimes(join(legacyDir, 'o2.jsonl'), base + 50, base + 50)
|
|
753
|
+
const provider = vi.fn(async (dir: string) =>
|
|
754
|
+
dir === flatMain
|
|
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 },
|
|
758
|
+
]
|
|
759
|
+
: [],
|
|
760
|
+
)
|
|
761
|
+
const { matches, truncated } = await findSessions('recent', agentDir, {
|
|
762
|
+
limit: 2,
|
|
763
|
+
metadataProvider: provider,
|
|
764
|
+
})
|
|
765
|
+
expect(truncated).toBe(true)
|
|
766
|
+
expect(matches.map((m) => m.sessionId)).toEqual(['recent-new-1', 'recent-new-2'])
|
|
767
|
+
// provider 只调最新两个候选所在的平铺根;被截断的 legacy 根候选不触发补元数据
|
|
768
|
+
expect(provider).toHaveBeenCalledTimes(1)
|
|
769
|
+
expect(provider.mock.calls[0][0]).toBe(flatMain)
|
|
770
|
+
// 截断后少数候选补到标题
|
|
771
|
+
expect(matches[0].name).toBe('最新标题一')
|
|
772
|
+
expect(matches[1].name).toBe('最新标题二')
|
|
773
|
+
})
|
|
774
|
+
})
|
|
@@ -242,7 +242,7 @@ describe('provideHashCandidates', () => {
|
|
|
242
242
|
await mkdir(otherCwdDir, { recursive: true })
|
|
243
243
|
})
|
|
244
244
|
afterEach(async () => {
|
|
245
|
-
await rm(agentDir, { recursive: true, force: true })
|
|
245
|
+
await rm(agentDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
246
246
|
})
|
|
247
247
|
|
|
248
248
|
it('非 # 输入 → null(委托下家 provider)', async () => {
|
|
@@ -377,7 +377,7 @@ describe('createHashAutocompleteProvider - getSuggestions', () => {
|
|
|
377
377
|
await mkdir(cwdSessionDir, { recursive: true })
|
|
378
378
|
})
|
|
379
379
|
afterEach(async () => {
|
|
380
|
-
await rm(agentDir, { recursive: true, force: true })
|
|
380
|
+
await rm(agentDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
381
381
|
})
|
|
382
382
|
|
|
383
383
|
it('signal.aborted → 早退返 null,不调 current、不做 IO', async () => {
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
2
5
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
3
6
|
import { Check } from 'typebox/value'
|
|
4
7
|
|
|
@@ -13,19 +16,26 @@ import { Check } from 'typebox/value'
|
|
|
13
16
|
* 4. typeof ctx.ui.addAutocompleteProvider 运行时守卫(ui 缺方法 → 跳过,不崩)
|
|
14
17
|
* 5. registerCommand + addAutocompleteProvider 组装(tui 模式下各注册一次)
|
|
15
18
|
* 6. TypeBox schema 与 SessionReadParams 对齐:合法 params 过、非法 action/scope 拒
|
|
19
|
+
* 7. U3 信号包采集降级:sessionManager 缺 getSessionDir 方法 / getSessionDir 抛错 →
|
|
20
|
+
* liveSessionDir 降级 undefined,find 正常返回不抛(ctx===undefined 形态由 1 覆盖)
|
|
16
21
|
*
|
|
17
22
|
* 不触碰真实文件系统:getAgentDir mock 固定假路径(execute 用例只触发 F5 抛错路径,
|
|
18
23
|
* 不读盘)。
|
|
19
24
|
*/
|
|
20
25
|
|
|
21
26
|
// vi.mock 在 import 前 hoist;SessionManager stub 仅防 session-command/hash-provider
|
|
22
|
-
//
|
|
27
|
+
// 模块加载期缺导出(本文件用例不调用它)。u11:state/listAll 经 vi.hoisted 可控——
|
|
28
|
+
// getAgentDir 可指向真实 tmp fixture,listAll 为 spy,供 execute 接线断言。
|
|
29
|
+
const piMocks = vi.hoisted(() => {
|
|
30
|
+
const state = { agentDir: '/tmp/pi-session-reader-test-agent' }
|
|
31
|
+
const listAll = vi.fn(async (_dir: string) => [] as Array<Record<string, unknown>>)
|
|
32
|
+
return { state, listAll }
|
|
33
|
+
})
|
|
34
|
+
|
|
23
35
|
vi.mock('@earendil-works/pi-coding-agent', () => ({
|
|
24
|
-
getAgentDir: () =>
|
|
36
|
+
getAgentDir: () => piMocks.state.agentDir,
|
|
25
37
|
SessionManager: class {
|
|
26
|
-
static
|
|
27
|
-
return []
|
|
28
|
-
}
|
|
38
|
+
static listAll = piMocks.listAll
|
|
29
39
|
},
|
|
30
40
|
}))
|
|
31
41
|
|
|
@@ -99,6 +109,119 @@ describe('sessionReaderExtension - execute 契约', () => {
|
|
|
99
109
|
})
|
|
100
110
|
})
|
|
101
111
|
|
|
112
|
+
describe('sessionReaderExtension - execute 信号包采集(U3:ctx 全形态降级)', () => {
|
|
113
|
+
type ExecFn = (
|
|
114
|
+
toolCallId: string,
|
|
115
|
+
params: unknown,
|
|
116
|
+
signal: AbortSignal | undefined,
|
|
117
|
+
onUpdate: unknown,
|
|
118
|
+
ctx: unknown,
|
|
119
|
+
) => Promise<{ content: Array<{ type: string; text: string }> }>
|
|
120
|
+
|
|
121
|
+
let execute: ExecFn
|
|
122
|
+
|
|
123
|
+
beforeEach(() => {
|
|
124
|
+
const fake = makeFakePi()
|
|
125
|
+
sessionReaderExtension(fake.pi as unknown as ExtensionAPI)
|
|
126
|
+
execute = (fake.registerTool.mock.calls[0][0] as { execute: ExecFn }).execute
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
// find 零匹配不抛(返回提示文本)——用例只断言「正常返回」降级语义,不碰真实数据目录
|
|
130
|
+
//(mock agentDir = /tmp/pi-session-reader-test-agent 及其派生根均不存在,扫描瞬时零文件)
|
|
131
|
+
const FIND_NO_MATCH = { action: 'find', query: 'zzznoindexmatch' } as const
|
|
132
|
+
|
|
133
|
+
it('sessionManager 存在但缺 getSessionDir 方法 → 降级 liveSessionDir=undefined,find 正常返回不抛', async () => {
|
|
134
|
+
const ctx = { mode: 'tui', sessionManager: {} }
|
|
135
|
+
const r = await execute('tc-u3-1', FIND_NO_MATCH, undefined, undefined, ctx)
|
|
136
|
+
expect(r.content[0]?.type).toBe('text')
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('getSessionDir 调用抛错 → 同样降级不抛(可选链只防缺失,调用抛错由 try/catch 兜住)', async () => {
|
|
140
|
+
const ctx = {
|
|
141
|
+
mode: 'tui',
|
|
142
|
+
sessionManager: {
|
|
143
|
+
getSessionDir: () => {
|
|
144
|
+
throw new Error('boom')
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
}
|
|
148
|
+
const r = await execute('tc-u3-2', FIND_NO_MATCH, undefined, undefined, ctx)
|
|
149
|
+
expect(r.content[0]?.type).toBe('text')
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('ctx 提供正常 getSessionDir → 信号被采集(调用一次)且 find 正常返回', async () => {
|
|
153
|
+
const getSessionDir = vi.fn(() => '/tmp/pi-session-reader-test-live-dir')
|
|
154
|
+
const ctx = { mode: 'tui', sessionManager: { getSessionDir } }
|
|
155
|
+
const r = await execute('tc-u3-3', FIND_NO_MATCH, undefined, undefined, ctx)
|
|
156
|
+
expect(getSessionDir).toHaveBeenCalledTimes(1)
|
|
157
|
+
expect(r.content[0]?.type).toBe('text')
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('u8:doctor 经 execute 走通——env/bundleUrl 补采 + ctx===undefined 降级仍出环境判定与根表', async () => {
|
|
161
|
+
// mock agentDir(/tmp/pi-session-reader-test-agent)及其派生根均不存在 → 根表全空仍渲染;
|
|
162
|
+
// 残留 glob 基点 = /tmp,只读 readdir(无写操作),不触碰任何真实数据目录。
|
|
163
|
+
const r = await execute('tc-u8-1', { action: 'doctor' }, undefined, undefined, undefined)
|
|
164
|
+
const text = r.content[0]?.text as string
|
|
165
|
+
expect(text).toContain('环境判定:')
|
|
166
|
+
// bundleUrl 信号已采集:测试内 import.meta.url 为仓库源码路径 → dev(非打包资源目录)
|
|
167
|
+
expect(text).toContain('发行形态:dev')
|
|
168
|
+
expect(text).toContain('会话根(按优先级)')
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('u11:execute 构造 metadataProvider 接 SessionManager.listAll——keyword 标题命中端到端', async () => {
|
|
172
|
+
// fixture:平铺 main 根(xyz-agent 形态),session 首消息不含 query,标题经 mock listAll 注入
|
|
173
|
+
const tmp = await mkdtemp(join(tmpdir(), 'index-u11-'))
|
|
174
|
+
try {
|
|
175
|
+
const agentDir = join(tmp, 'agent')
|
|
176
|
+
const liveDir = join(agentDir, 'sessions')
|
|
177
|
+
await mkdir(liveDir, { recursive: true })
|
|
178
|
+
const id = 'm-wire-0001'
|
|
179
|
+
await writeFile(
|
|
180
|
+
join(liveDir, 'a.jsonl'),
|
|
181
|
+
[
|
|
182
|
+
JSON.stringify({ type: 'session', id, cwd: '/demo' }),
|
|
183
|
+
JSON.stringify({
|
|
184
|
+
type: 'message',
|
|
185
|
+
id: `${id}-m1`,
|
|
186
|
+
message: { role: 'user', content: [{ type: 'text', text: '完全无关的首消息' }] },
|
|
187
|
+
}),
|
|
188
|
+
].join('\n') + '\n',
|
|
189
|
+
)
|
|
190
|
+
piMocks.state.agentDir = agentDir
|
|
191
|
+
piMocks.listAll.mockResolvedValue([
|
|
192
|
+
{
|
|
193
|
+
path: join(liveDir, 'a.jsonl'),
|
|
194
|
+
id,
|
|
195
|
+
cwd: '/demo',
|
|
196
|
+
name: '接线标题命中',
|
|
197
|
+
modified: new Date(),
|
|
198
|
+
firstMessage: '完全无关的首消息',
|
|
199
|
+
created: new Date(),
|
|
200
|
+
messageCount: 1,
|
|
201
|
+
allMessagesText: '完全无关的首消息',
|
|
202
|
+
},
|
|
203
|
+
])
|
|
204
|
+
const ctx = { mode: 'rpc', sessionManager: { getSessionDir: () => liveDir } }
|
|
205
|
+
const r = await execute('tc-u11-1', { action: 'find', query: '接线标题' }, undefined, undefined, ctx)
|
|
206
|
+
// provider 已接线:listAll 被调,且实参恒为非空串(永传非空串 guard)
|
|
207
|
+
expect(piMocks.listAll).toHaveBeenCalledTimes(1)
|
|
208
|
+
expect(piMocks.listAll.mock.calls[0][0]).toBe(liveDir)
|
|
209
|
+
expect(piMocks.listAll.mock.calls[0][0].length).toBeGreaterThan(0)
|
|
210
|
+
// 标题命中路径端到端:文本与 details 都带 name
|
|
211
|
+
const text = r.content[0]?.text as string
|
|
212
|
+
expect(text).toContain('接线标题命中')
|
|
213
|
+
const d = r.details as { matches: Array<{ sessionId: string; name?: string }> }
|
|
214
|
+
expect(d.matches[0]?.sessionId).toBe(id)
|
|
215
|
+
expect(d.matches[0]?.name).toBe('接线标题命中')
|
|
216
|
+
} finally {
|
|
217
|
+
// 还原 mock 态,不污染同文件其他用例
|
|
218
|
+
piMocks.state.agentDir = '/tmp/pi-session-reader-test-agent'
|
|
219
|
+
piMocks.listAll.mockReset()
|
|
220
|
+
await rm(tmp, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
221
|
+
}
|
|
222
|
+
})
|
|
223
|
+
})
|
|
224
|
+
|
|
102
225
|
describe('sessionReaderExtension - session_start TUI 注册', () => {
|
|
103
226
|
it('mode !== tui → 不注册 command/provider', () => {
|
|
104
227
|
const fake = makeFakePi()
|
|
@@ -237,4 +360,33 @@ describe('sessionReaderExtension - TypeBox schema 与 SessionReadParams 对齐',
|
|
|
237
360
|
expect(Check(schema, { action: 'family', session: 'x', recursive: 'yes' })).toBe(false)
|
|
238
361
|
expect(Check(schema, { action: 'family', session: 'x', recursive: 1 })).toBe(false)
|
|
239
362
|
})
|
|
363
|
+
|
|
364
|
+
it('limit 退化输入:minimum:1 在 schema 校验层拒绝 0/负数(不落入 find F1「无匹配」措辞面)', () => {
|
|
365
|
+
const fake = makeFakePi()
|
|
366
|
+
sessionReaderExtension(fake.pi as unknown as ExtensionAPI)
|
|
367
|
+
const toolDef = fake.registerTool.mock.calls[0][0] as { parameters: unknown }
|
|
368
|
+
const schema = toolDef.parameters
|
|
369
|
+
|
|
370
|
+
// 0/负数被 schema 拒绝(minimum:1),不会进入 find/F1 输出「无匹配 session」
|
|
371
|
+
expect(Check(schema, { action: 'find', query: 'x', limit: 0 })).toBe(false)
|
|
372
|
+
expect(Check(schema, { action: 'find', query: 'x', limit: -3 })).toBe(false)
|
|
373
|
+
// 正数合法;result 消费同一 limit 字段(正数合法)
|
|
374
|
+
expect(Check(schema, { action: 'find', query: 'x', limit: 1 })).toBe(true)
|
|
375
|
+
expect(Check(schema, { action: 'result', session: 'sa-x', limit: 100 })).toBe(true)
|
|
376
|
+
// 不传 limit 向后兼容(缺省语义不变)
|
|
377
|
+
expect(Check(schema, { action: 'find', query: 'x' })).toBe(true)
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
it('TC-u8-schema-doctor:action enum 含 doctor + includeSubagents optional boolean', () => {
|
|
381
|
+
const fake = makeFakePi()
|
|
382
|
+
sessionReaderExtension(fake.pi as unknown as ExtensionAPI)
|
|
383
|
+
const toolDef = fake.registerTool.mock.calls[0][0] as { parameters: unknown }
|
|
384
|
+
const schema = toolDef.parameters
|
|
385
|
+
|
|
386
|
+
expect(Check(schema, { action: 'doctor' })).toBe(true)
|
|
387
|
+
expect(Check(schema, { action: 'doctor', includeSubagents: true })).toBe(true)
|
|
388
|
+
expect(Check(schema, { action: 'doctor', includeSubagents: false })).toBe(true)
|
|
389
|
+
expect(Check(schema, { action: 'doctor', includeSubagents: 'yes' })).toBe(false)
|
|
390
|
+
expect(Check(schema, { action: 'doctor', includeSubagents: 1 })).toBe(false)
|
|
391
|
+
})
|
|
240
392
|
})
|
|
@@ -16,6 +16,16 @@ import { join } from 'node:path'
|
|
|
16
16
|
/** 真实 pi agent 目录(本机),用于集成测试。 */
|
|
17
17
|
export const REAL_AGENT_DIR = '/Users/zhushanwen/.pi/agent'
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* 全量扫描类真实数据用例的统一超时标定(90s)。
|
|
21
|
+
*
|
|
22
|
+
* 这类用例遍历本机 ~/.pi/agent 全量会话语料(实测 4600+ 文件),空载单用例已需 6-10s;
|
|
23
|
+
* 38 包并行全量测试时 CPU/IO 争抢可降速 3-4 倍,30s 标定必假红。90s ≈ 空载 9 倍余量,
|
|
24
|
+
* 仍能兜住真正的挂死。仅用于遍历全量语料的用例(findSessions / buildFamilyFromFs /
|
|
25
|
+
* listMainSessions 等);读已知单文件的用例不需要放宽。
|
|
26
|
+
*/
|
|
27
|
+
export const REAL_DATA_TIMEOUT_MS = 90_000
|
|
28
|
+
|
|
19
29
|
/** 5.4MB / 32 turn / 1204 entry 的真实 session(feat-plugin-arch-3 目录)。 */
|
|
20
30
|
export const E6 = '019e6c96-0a0c-74b8-a73f-d1854d88e2a7'
|
|
21
31
|
/** 真实 fork 家族根(fork 子代 019fe632,隔代 subagent 019fe635 挂在 019fe632 下)。 */
|