@zhushanwen/pi-session-reader 0.4.0 → 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 +2 -2
- package/src/__tests__/env.test.ts +258 -0
- package/src/__tests__/execution-tree.test.ts +8 -7
- package/src/__tests__/find.test.ts +211 -6
- package/src/__tests__/index.test.ts +157 -5
- package/src/__tests__/real-data.ts +10 -0
- package/src/__tests__/result.test.ts +2 -2
- package/src/__tests__/roots.test.ts +360 -7
- package/src/__tests__/subagents.test.ts +4 -3
- 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 +24 -6
- package/src/doctor.ts +262 -0
- package/src/extract.ts +422 -0
- package/src/handler-utils.ts +63 -0
- package/src/index.ts +46 -7
- package/src/no-match.ts +160 -0
- package/src/search-across.ts +539 -0
- package/src/tool-handler.ts +478 -681
|
@@ -1,10 +1,23 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
2
|
import { existsSync } from 'node:fs'
|
|
3
|
-
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
|
|
3
|
+
import { mkdtemp, mkdir, writeFile, rm, utimes } from 'node:fs/promises'
|
|
4
4
|
import { tmpdir, homedir } from 'node:os'
|
|
5
|
-
import { join } from 'node:path'
|
|
6
|
-
import {
|
|
5
|
+
import { join, dirname } from 'node:path'
|
|
6
|
+
import {
|
|
7
|
+
handleSessionRead,
|
|
8
|
+
levenshtein,
|
|
9
|
+
renderExtractItems,
|
|
10
|
+
DOCTOR_CACHE_TTL_MS,
|
|
11
|
+
METADATA_CACHE_TTL_MS,
|
|
12
|
+
MULTI_SEARCH_MAX_SESSIONS,
|
|
13
|
+
SEARCH_SCAN_BYTE_BUDGET,
|
|
14
|
+
searchAcrossSessions,
|
|
15
|
+
type SessionReadParams,
|
|
16
|
+
type SessionReadSignals,
|
|
17
|
+
} from '../tool-handler.js'
|
|
7
18
|
import { listRecordManifests } from '../discovery/subagents.js'
|
|
19
|
+
import type { SessionMetadataProvider } from '../discovery/find.js'
|
|
20
|
+
import type { DoctorDetails } from '../doctor.js'
|
|
8
21
|
import {
|
|
9
22
|
REAL_AGENT_DIR as REAL,
|
|
10
23
|
E6,
|
|
@@ -22,7 +35,8 @@ import {
|
|
|
22
35
|
* 测试框架 vitest(禁止 node:test/tsx)。直接调 handleSessionRead(纯逻辑,agentDir 注入),
|
|
23
36
|
* 传真实 `/Users/zhushanwen/.pi/agent` 作 agentDir——用本机真实历史 session 数据,无需 mock。
|
|
24
37
|
*
|
|
25
|
-
*
|
|
38
|
+
* 覆盖全 11 action 主路径 + F1(find 零匹配)/F4(turn 越界)/F5(缺参)/resolveSessionId 片段等价,
|
|
39
|
+
* 以及 u8 doctor / u9 归一化 / u10 find 分组 / u11 metadataProvider 缓存 / u12 跨会话检索(各见对应 describe)。
|
|
26
40
|
*
|
|
27
41
|
* 真实数据用例全部带 skipIf 守卫(CI 无本机 ~/.pi/agent → skip,不硬失败);
|
|
28
42
|
* renderExtractItems F9 截断是纯 fixture,无条件跑。
|
|
@@ -94,7 +108,7 @@ describe.skipIf(!HAS_REAL)('handleSessionRead', () => {
|
|
|
94
108
|
// wave 的 subagent task 引用了它)会触发 name-keyword fallback 命中 → 零匹配断言失败。
|
|
95
109
|
// w2 实现 handler source 透传后,用 source:'main' 收窄到 main 侧避开 subagent 干扰
|
|
96
110
|
//(w1 test 注释原预言的修复路径),同时恢复默认 timeout(main 单侧扫描快)。
|
|
97
|
-
it('7. F1 find zero match returns empty matches +
|
|
111
|
+
it('7. F1 find zero match returns empty matches + fact-based self-check (no throw)', async () => {
|
|
98
112
|
const r = await handleSessionRead(
|
|
99
113
|
{ action: 'find', query: 'zzz-nonexistent-session-9q8x2', source: 'main' },
|
|
100
114
|
REAL,
|
|
@@ -102,7 +116,11 @@ describe.skipIf(!HAS_REAL)('handleSessionRead', () => {
|
|
|
102
116
|
const d = r.details as { matches: unknown[]; truncated: boolean }
|
|
103
117
|
expect(d.matches).toEqual([])
|
|
104
118
|
expect(d.truncated).toBe(false)
|
|
105
|
-
|
|
119
|
+
// u9 F1 重写(§5.2):事实型自检 + 无归因断言 + 无「recent 看全量」误导
|
|
120
|
+
expect(r.content[0].text).toContain('无匹配 session')
|
|
121
|
+
expect(r.content[0].text).toContain('自检(发现层,只陈述事实)')
|
|
122
|
+
expect(r.content[0].text).not.toContain('真的没有')
|
|
123
|
+
expect(r.content[0].text).not.toContain('recent')
|
|
106
124
|
})
|
|
107
125
|
|
|
108
126
|
it('8. F4 detail turn out of range throws with 越界', async () => {
|
|
@@ -1554,3 +1572,1194 @@ describe('doFamily recursive(m3b U8 接入)', () => {
|
|
|
1554
1572
|
expect(r.content[0].text).toContain('👉')
|
|
1555
1573
|
})
|
|
1556
1574
|
})
|
|
1575
|
+
|
|
1576
|
+
// ===========================================================================
|
|
1577
|
+
// doctor action(u8:design 2026-09-10 §6.3/§6.4/§7B 要点 2/4/5/8 + §6.11 U14b 段)
|
|
1578
|
+
// 全部 mkdtemp fixture 自建自删,不触碰真实数据目录。
|
|
1579
|
+
// ===========================================================================
|
|
1580
|
+
|
|
1581
|
+
// doctor details 契约直用 doctor.ts 导出的 DoctorDetails(不再自建局部镜像——生产侧
|
|
1582
|
+
// 形状漂移由编译期捕捉,S-R2 同步)
|
|
1583
|
+
|
|
1584
|
+
describe('doctor action(u8:环境判定 + 根表 + 告警 + 残留 glob + 缓存)', () => {
|
|
1585
|
+
let tmp: string
|
|
1586
|
+
|
|
1587
|
+
const SLUG = '--Users-foo--'
|
|
1588
|
+
|
|
1589
|
+
/** 写 .jsonl fixture(父目录自建),内容合法 session header */
|
|
1590
|
+
async function writeJsonl(path: string, id = 'x'): Promise<void> {
|
|
1591
|
+
await mkdir(dirname(path), { recursive: true })
|
|
1592
|
+
await writeFile(path, `{"type":"session","id":"${id}"}\n`)
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
/**
|
|
1596
|
+
* xyz-agent 形态四根 fixture:dataDir 目录名带 .xyz-agent 前缀(detectEnvironment 的
|
|
1597
|
+
* PI_CODING_AGENT_DIR 形态判据 `<*>/.xyz-agent*\/agent` 可命中,托管判定走正态)。
|
|
1598
|
+
* live=agent/sessions 与 default 同字面路径(default 去重)、legacy 独立非空、subagent 独立。
|
|
1599
|
+
*/
|
|
1600
|
+
async function buildFourRootFixture(): Promise<{
|
|
1601
|
+
dataDir: string
|
|
1602
|
+
agentDir: string
|
|
1603
|
+
signals: SessionReadSignals
|
|
1604
|
+
}> {
|
|
1605
|
+
const dataDir = join(tmp, '.xyz-agent-fixture')
|
|
1606
|
+
const agentDir = join(dataDir, 'agent')
|
|
1607
|
+
await writeJsonl(join(agentDir, 'sessions', SLUG, 'a.jsonl'), 'aaaaaaaa')
|
|
1608
|
+
await writeJsonl(join(agentDir, 'sessions', SLUG, 'b.jsonl'), 'bbbbbbbb')
|
|
1609
|
+
await writeJsonl(join(dataDir, 'sessions', 'old.jsonl'), 'cccccccc') // legacy 非空
|
|
1610
|
+
await writeJsonl(join(agentDir, 'subagents', SLUG, 'sessions', 's.jsonl'), 'dddddddd')
|
|
1611
|
+
return {
|
|
1612
|
+
dataDir,
|
|
1613
|
+
agentDir,
|
|
1614
|
+
signals: {
|
|
1615
|
+
agentDir,
|
|
1616
|
+
liveSessionDir: join(agentDir, 'sessions'),
|
|
1617
|
+
env: {
|
|
1618
|
+
XYZ_AGENT_EXT_LOG: '1',
|
|
1619
|
+
PI_CODING_AGENT_DIR: agentDir,
|
|
1620
|
+
XYZ_AGENT_DATA_DIR: dataDir,
|
|
1621
|
+
},
|
|
1622
|
+
},
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
beforeEach(async () => {
|
|
1627
|
+
tmp = await mkdtemp(join(tmpdir(), 'tool-handler-doctor-'))
|
|
1628
|
+
})
|
|
1629
|
+
afterEach(async () => {
|
|
1630
|
+
await rm(tmp, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
1631
|
+
})
|
|
1632
|
+
|
|
1633
|
+
it('四根表渲染:优先级序 + 去重注记 + 文件数/耗时 + 托管判定行 + 事实型诊断(无归因断言)', async () => {
|
|
1634
|
+
const { dataDir, agentDir, signals } = await buildFourRootFixture()
|
|
1635
|
+
const r = await handleSessionRead({ action: 'doctor' }, signals)
|
|
1636
|
+
const text = r.content[0].text
|
|
1637
|
+
|
|
1638
|
+
// 环境判定行:双信号合取 → xyz-agent(托管)+ 数据目录 + evidence 正态
|
|
1639
|
+
expect(text).toContain('环境判定:xyz-agent(托管)')
|
|
1640
|
+
expect(text).toContain(`数据目录:${dataDir}`)
|
|
1641
|
+
expect(text).toContain('依据:')
|
|
1642
|
+
expect(text).toContain(`PI_CODING_AGENT_DIR='${agentDir}' → 形态匹配`)
|
|
1643
|
+
|
|
1644
|
+
// 四根按优先级序渲染
|
|
1645
|
+
const liveIdx = text.indexOf('[live]')
|
|
1646
|
+
const defIdx = text.indexOf('[default]')
|
|
1647
|
+
const legacyIdx = text.indexOf('[legacy]')
|
|
1648
|
+
const subIdx = text.indexOf('[subagent]')
|
|
1649
|
+
expect(liveIdx).toBeGreaterThan(-1)
|
|
1650
|
+
expect(defIdx).toBeGreaterThan(liveIdx)
|
|
1651
|
+
expect(legacyIdx).toBeGreaterThan(defIdx)
|
|
1652
|
+
expect(subIdx).toBeGreaterThan(legacyIdx)
|
|
1653
|
+
|
|
1654
|
+
// 去重注记(§7B 要点 4):default 与第 1 行(live)同路径,已去重
|
|
1655
|
+
expect(text).toContain('与 1 同路径,已去重')
|
|
1656
|
+
|
|
1657
|
+
// 事实型诊断:只陈述最高优先级 main 根 N 文件;禁止归因断言(§7B 要点 5)
|
|
1658
|
+
expect(text).toContain(
|
|
1659
|
+
`诊断:最高优先级 main 根 [live] ${join(agentDir, 'sessions')}:2 文件。`,
|
|
1660
|
+
)
|
|
1661
|
+
expect(text).not.toContain('真的没有')
|
|
1662
|
+
|
|
1663
|
+
// details 程序化形态
|
|
1664
|
+
const d = r.details as DoctorDetails
|
|
1665
|
+
expect(d.roots.map((x) => x.kind)).toEqual(['live', 'default', 'legacy', 'subagent'])
|
|
1666
|
+
expect(d.roots[0].fileCount).toBe(2)
|
|
1667
|
+
expect(typeof d.roots[0].scanMs).toBe('number')
|
|
1668
|
+
expect(d.roots[1].dedupedInto).toBe('live')
|
|
1669
|
+
expect(d.roots[1].fileCount).toBeUndefined()
|
|
1670
|
+
expect(d.roots[2].fileCount).toBe(1) // legacy 独立实扫
|
|
1671
|
+
// subagent 默认 stat 模式:只列路径与可扫性,无计数
|
|
1672
|
+
expect(d.roots[3].exists).toBe(true)
|
|
1673
|
+
expect(d.roots[3].fileCount).toBeUndefined()
|
|
1674
|
+
expect(d.roots[3].scanMs).toBeUndefined()
|
|
1675
|
+
})
|
|
1676
|
+
|
|
1677
|
+
it('legacy 非空告警:独立非空 → 告警;不存在 → 无告警;与 live 同路径被去重 → 不告警(§5.1)', async () => {
|
|
1678
|
+
// 正态:legacy 独立且非空
|
|
1679
|
+
const { signals } = await buildFourRootFixture()
|
|
1680
|
+
const positive = await handleSessionRead({ action: 'doctor' }, signals)
|
|
1681
|
+
expect(positive.content[0].text).toContain(
|
|
1682
|
+
`告警:[legacy] 根 ${join(tmp, '.xyz-agent-fixture', 'sessions')} 非空(1 文件)`,
|
|
1683
|
+
)
|
|
1684
|
+
|
|
1685
|
+
// 反态:legacy 目录不存在
|
|
1686
|
+
const agentDir2 = join(tmp, 'data2', 'agent')
|
|
1687
|
+
await writeJsonl(join(agentDir2, 'sessions', SLUG, 'a.jsonl'))
|
|
1688
|
+
const negative = await handleSessionRead({ action: 'doctor' }, { agentDir: agentDir2 })
|
|
1689
|
+
expect(negative.content[0].text).not.toContain('告警:')
|
|
1690
|
+
|
|
1691
|
+
// 去重态:live 与 legacy 同路径(文件非空)→ 已去重不告警
|
|
1692
|
+
const dataDir3 = join(tmp, 'data3')
|
|
1693
|
+
const agentDir3 = join(dataDir3, 'agent')
|
|
1694
|
+
await writeJsonl(join(dataDir3, 'sessions', SLUG, 'a.jsonl'))
|
|
1695
|
+
const deduped = await handleSessionRead(
|
|
1696
|
+
{ action: 'doctor' },
|
|
1697
|
+
{ agentDir: agentDir3, liveSessionDir: join(dataDir3, 'sessions') },
|
|
1698
|
+
)
|
|
1699
|
+
expect(deduped.content[0].text).not.toContain('告警:')
|
|
1700
|
+
expect(deduped.content[0].text).toContain('已去重')
|
|
1701
|
+
})
|
|
1702
|
+
|
|
1703
|
+
it('独立 glob 残留探测:pi/ 含 agent|sessions 形态列出 + 迁移指引;pi.backup-v2-* 备份列出;基点 = dirname(agentDir)', async () => {
|
|
1704
|
+
const dataDir = join(tmp, 'data')
|
|
1705
|
+
const agentDir = join(dataDir, 'agent')
|
|
1706
|
+
await writeJsonl(join(agentDir, 'sessions', SLUG, 'a.jsonl'))
|
|
1707
|
+
// 未迁移旧布局 pi/(含 agent/ 形态)
|
|
1708
|
+
await mkdir(join(dataDir, 'pi', 'agent'), { recursive: true })
|
|
1709
|
+
await writeJsonl(join(dataDir, 'pi', 'agent', 'sessions', 'old.jsonl'), 'old-id')
|
|
1710
|
+
// 迁移备份(含 sessions/ 形态——备份即原 pi/ 改名)
|
|
1711
|
+
const backup = join(dataDir, 'pi.backup-v2-1725900000000')
|
|
1712
|
+
await mkdir(join(backup, 'sessions'), { recursive: true })
|
|
1713
|
+
await writeJsonl(join(backup, 'sessions', 'bak.jsonl'), 'bak-id')
|
|
1714
|
+
|
|
1715
|
+
const r = await handleSessionRead({ action: 'doctor' }, { agentDir })
|
|
1716
|
+
const text = r.content[0].text
|
|
1717
|
+
expect(text).toContain(`旧布局残留探测(基点 ${dataDir})`)
|
|
1718
|
+
expect(text).toContain(` - ${join(dataDir, 'pi')}(未迁移旧布局 pi/)`)
|
|
1719
|
+
expect(text).toContain(` - ${backup}(迁移备份 pi.backup-v2-*)`)
|
|
1720
|
+
// 同一迁移指引(§6.11:doctor 与 u14b 启动探测同指引)
|
|
1721
|
+
expect(text).toContain('👉 关闭应用后运行 scripts/migrate-pi-layout-v2.mjs 完成迁移。')
|
|
1722
|
+
|
|
1723
|
+
const d = r.details as DoctorDetails
|
|
1724
|
+
expect(d.globBase).toBe(dataDir)
|
|
1725
|
+
expect(d.leftovers.map((l) => l.kind).sort()).toEqual(['backup', 'unmigrated'])
|
|
1726
|
+
})
|
|
1727
|
+
|
|
1728
|
+
it('残留探测反态:pi/ 空壳(无 agent|sessions 子目录)不报——防纯 pi 宿主 ~/.pi/pi/ 误报(§6.11 v9.1)', async () => {
|
|
1729
|
+
const dataDir = join(tmp, 'data')
|
|
1730
|
+
const agentDir = join(dataDir, 'agent')
|
|
1731
|
+
await writeJsonl(join(agentDir, 'sessions', SLUG, 'a.jsonl'))
|
|
1732
|
+
// 纯 pi 宿主下任意来源的 pi/ 目录:只有无关子目录,无 agent/ 与 sessions/ 形态
|
|
1733
|
+
await mkdir(join(dataDir, 'pi', 'extensions'), { recursive: true })
|
|
1734
|
+
|
|
1735
|
+
const r = await handleSessionRead({ action: 'doctor' }, { agentDir })
|
|
1736
|
+
expect(r.content[0].text).not.toContain('旧布局残留')
|
|
1737
|
+
expect((r.details as DoctorDetails).leftovers).toEqual([])
|
|
1738
|
+
})
|
|
1739
|
+
|
|
1740
|
+
it('subagent 根默认不扫(只列路径与可扫性);includeSubagents:true 才扫出文件数(§6.3)', async () => {
|
|
1741
|
+
const { agentDir, signals } = await buildFourRootFixture()
|
|
1742
|
+
|
|
1743
|
+
const def = await handleSessionRead({ action: 'doctor' }, signals)
|
|
1744
|
+
const defSub = (def.details as DoctorDetails).roots.find((x) => x.kind === 'subagent')
|
|
1745
|
+
expect(defSub?.fileCount).toBeUndefined()
|
|
1746
|
+
expect(defSub?.scanMs).toBeUndefined()
|
|
1747
|
+
expect(defSub?.exists).toBe(true)
|
|
1748
|
+
expect(def.content[0].text).toContain('未扫描(subagent 根默认不扫')
|
|
1749
|
+
|
|
1750
|
+
const full = await handleSessionRead({ action: 'doctor', includeSubagents: true }, signals)
|
|
1751
|
+
const fullSub = (full.details as DoctorDetails).roots.find((x) => x.kind === 'subagent')
|
|
1752
|
+
expect(fullSub?.fileCount).toBe(1)
|
|
1753
|
+
expect(fullSub?.exists).toBe(true)
|
|
1754
|
+
})
|
|
1755
|
+
|
|
1756
|
+
it('进程内缓存:同根二次调用命中缓存不重扫;根目录 mtime 变化即失效重扫(§7B 要点 8)', async () => {
|
|
1757
|
+
const agentDir = join(tmp, 'agent')
|
|
1758
|
+
await writeJsonl(join(agentDir, 'sessions', SLUG, 'a.jsonl'))
|
|
1759
|
+
const signals: SessionReadSignals = { agentDir }
|
|
1760
|
+
|
|
1761
|
+
const first = (await handleSessionRead({ action: 'doctor' }, signals)).details as DoctorDetails
|
|
1762
|
+
const firstDef = first.roots.find((x) => x.kind === 'default')!
|
|
1763
|
+
expect(firstDef.cached).toBeUndefined() // 首扫
|
|
1764
|
+
expect(firstDef.fileCount).toBe(1)
|
|
1765
|
+
|
|
1766
|
+
const second = (await handleSessionRead({ action: 'doctor' }, signals)).details as DoctorDetails
|
|
1767
|
+
const secondDef = second.roots.find((x) => x.kind === 'default')!
|
|
1768
|
+
expect(secondDef.cached).toBe(true) // 命中缓存,未重扫
|
|
1769
|
+
expect(secondDef.fileCount).toBe(1)
|
|
1770
|
+
|
|
1771
|
+
// 根目录 mtime 变化(根层新增文件)→ 失效重扫,计数更新
|
|
1772
|
+
await writeJsonl(join(agentDir, 'sessions', 'b.jsonl'), 'bbbbbbbb')
|
|
1773
|
+
const third = (await handleSessionRead({ action: 'doctor' }, signals)).details as DoctorDetails
|
|
1774
|
+
const thirdDef = third.roots.find((x) => x.kind === 'default')!
|
|
1775
|
+
expect(thirdDef.cached).toBeUndefined()
|
|
1776
|
+
expect(thirdDef.fileCount).toBe(2)
|
|
1777
|
+
})
|
|
1778
|
+
|
|
1779
|
+
it('缓存 TTL 到期失效重扫(秒级 TTL,fake timers 推进时钟)', async () => {
|
|
1780
|
+
vi.useFakeTimers()
|
|
1781
|
+
try {
|
|
1782
|
+
const agentDir = join(tmp, 'agent-ttl')
|
|
1783
|
+
await writeJsonl(join(agentDir, 'sessions', SLUG, 'a.jsonl'))
|
|
1784
|
+
const signals: SessionReadSignals = { agentDir }
|
|
1785
|
+
const first = (await handleSessionRead({ action: 'doctor' }, signals))
|
|
1786
|
+
.details as DoctorDetails
|
|
1787
|
+
expect(first.roots.find((x) => x.kind === 'default')!.cached).toBeUndefined()
|
|
1788
|
+
await vi.advanceTimersByTimeAsync(DOCTOR_CACHE_TTL_MS + 1)
|
|
1789
|
+
const second = (await handleSessionRead({ action: 'doctor' }, signals))
|
|
1790
|
+
.details as DoctorDetails
|
|
1791
|
+
// TTL 过期 → 即使 mtime 未变也重扫
|
|
1792
|
+
expect(second.roots.find((x) => x.kind === 'default')!.cached).toBeUndefined()
|
|
1793
|
+
} finally {
|
|
1794
|
+
vi.useRealTimers()
|
|
1795
|
+
}
|
|
1796
|
+
})
|
|
1797
|
+
|
|
1798
|
+
it('find 不读 doctor 缓存(§7B 要点 8 PS-14):doctor 缓存 0 文件后新建 session,find 立即可见', async () => {
|
|
1799
|
+
const agentDir = join(tmp, 'agent')
|
|
1800
|
+
// doctor 首跑时 main 根为空 → 缓存 fileCount=0(PS-14 形态:首条 assistant 前 jsonl 不落盘)
|
|
1801
|
+
await mkdir(join(agentDir, 'sessions'), { recursive: true })
|
|
1802
|
+
await handleSessionRead({ action: 'doctor' }, { agentDir })
|
|
1803
|
+
// 之后新 session 落盘
|
|
1804
|
+
await writeJsonl(join(agentDir, 'sessions', SLUG, 'found-later.jsonl'), 'id-find-later-xyz')
|
|
1805
|
+
// find 实扫立即可见——若 find 读 doctor 缓存(0 候选)此断言必红
|
|
1806
|
+
const r = await handleSessionRead({ action: 'find', query: 'id-find-later-xyz' }, agentDir)
|
|
1807
|
+
const d = r.details as { matches: Array<{ sessionId: string }> }
|
|
1808
|
+
expect(d.matches.some((m) => m.sessionId.includes('id-find-later-xyz'))).toBe(true)
|
|
1809
|
+
})
|
|
1810
|
+
|
|
1811
|
+
it('env/bundleUrl 信号缺失降级:仍出环境判定行(standalone-pi · 未知)+ 完整根表,不抛错', async () => {
|
|
1812
|
+
const agentDir = join(tmp, 'agent')
|
|
1813
|
+
await writeJsonl(join(agentDir, 'sessions', SLUG, 'a.jsonl'))
|
|
1814
|
+
// signals 无 env/bundleUrl 字段(u3 旧信号包形态)
|
|
1815
|
+
const r = await handleSessionRead({ action: 'doctor' }, { agentDir })
|
|
1816
|
+
const text = r.content[0].text
|
|
1817
|
+
expect(text).toContain('环境判定:standalone-pi · 发行形态:未知(不猜)')
|
|
1818
|
+
expect(text).toContain('XYZ_AGENT_EXT_LOG=<未设置> → 未命中')
|
|
1819
|
+
expect(text).toContain('会话根(按优先级)')
|
|
1820
|
+
expect(text).toContain('[default]')
|
|
1821
|
+
expect(text).toContain(
|
|
1822
|
+
`诊断:最高优先级 main 根 [default] ${join(agentDir, 'sessions')}:1 文件。`,
|
|
1823
|
+
)
|
|
1824
|
+
})
|
|
1825
|
+
|
|
1826
|
+
it('空 agentDir 防御:无根表、诊断「无候选根」,残留探测不触发(不扫 cwd 相对路径)', async () => {
|
|
1827
|
+
const r = await handleSessionRead({ action: 'doctor' }, { agentDir: '' })
|
|
1828
|
+
const text = r.content[0].text
|
|
1829
|
+
expect(text).toContain('诊断:无候选根')
|
|
1830
|
+
expect(text).not.toContain('[default]')
|
|
1831
|
+
expect((r.details as DoctorDetails).roots).toEqual([])
|
|
1832
|
+
expect((r.details as DoctorDetails).leftovers).toEqual([])
|
|
1833
|
+
})
|
|
1834
|
+
})
|
|
1835
|
+
|
|
1836
|
+
// ============================================================
|
|
1837
|
+
// u9:F1 重写(§5.2 事实型自检 + 编辑距离 top-3 + 四条做法 + 禁止项)
|
|
1838
|
+
// + uuid 归一化两级匹配(§6.7 子决策 1 + §11.5 对比基线)
|
|
1839
|
+
// ============================================================
|
|
1840
|
+
|
|
1841
|
+
describe('u9 F1 重写 + uuid 归一化(fixture,§5.2 / §6.7 / §11.5)', () => {
|
|
1842
|
+
let tmp: string
|
|
1843
|
+
const SLUG = '--demo-cwd--'
|
|
1844
|
+
const ID1 = '019e6c96-aaaa-bbbb-cccc-000000000001'
|
|
1845
|
+
const ID2 = '019e6c96-aaaa-bbbb-cccc-000000000002'
|
|
1846
|
+
|
|
1847
|
+
/**
|
|
1848
|
+
* 写 session 文件(文件名 = `${id}.jsonl`,extractSessionIdFromFilename 可提取——
|
|
1849
|
+
* F1 编辑距离候选的 id 来源)。subagent:true 写入 subagent 根。
|
|
1850
|
+
*/
|
|
1851
|
+
async function writeSession(
|
|
1852
|
+
id: string,
|
|
1853
|
+
opts?: { subagent?: boolean; firstUserText?: string },
|
|
1854
|
+
): Promise<void> {
|
|
1855
|
+
const dir = opts?.subagent
|
|
1856
|
+
? join(tmp, 'agent', 'subagents', SLUG, 'sessions')
|
|
1857
|
+
: join(tmp, 'agent', 'sessions', SLUG)
|
|
1858
|
+
await mkdir(dir, { recursive: true })
|
|
1859
|
+
const lines = [JSON.stringify({ type: 'session', id, cwd: '/demo' })]
|
|
1860
|
+
if (opts?.firstUserText !== undefined) {
|
|
1861
|
+
lines.push(
|
|
1862
|
+
JSON.stringify({
|
|
1863
|
+
type: 'message',
|
|
1864
|
+
id: `${id}-m1`,
|
|
1865
|
+
message: { role: 'user', content: [{ type: 'text', text: opts.firstUserText }] },
|
|
1866
|
+
}),
|
|
1867
|
+
)
|
|
1868
|
+
}
|
|
1869
|
+
await writeFile(join(dir, `${id}.jsonl`), lines.join('\n') + '\n')
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
/** find action 便捷调用(agentDir = tmp/agent)。 */
|
|
1873
|
+
function find(query: string, extra?: Partial<SessionReadParams>): Promise<ToolResultLike> {
|
|
1874
|
+
return handleSessionRead({ action: 'find', query, ...extra }, join(tmp, 'agent'))
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
interface ToolResultLike {
|
|
1878
|
+
content: Array<{ type: string; text: string }>
|
|
1879
|
+
details: unknown
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
beforeEach(async () => {
|
|
1883
|
+
tmp = await mkdtemp(join(tmpdir(), 'tool-handler-f1-'))
|
|
1884
|
+
})
|
|
1885
|
+
afterEach(async () => {
|
|
1886
|
+
await rm(tmp, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
1887
|
+
})
|
|
1888
|
+
|
|
1889
|
+
it('① 大写 uuid 片段命中(§3.3 盲区:String.includes 大小写敏感)', async () => {
|
|
1890
|
+
await writeSession(ID1, { firstUserText: '无关内容' })
|
|
1891
|
+
const r = await find('019E6C96-AAAA')
|
|
1892
|
+
const d = r.details as { matches: Array<{ sessionId: string }> }
|
|
1893
|
+
expect(d.matches).toHaveLength(1)
|
|
1894
|
+
expect(d.matches[0].sessionId).toBe(ID1)
|
|
1895
|
+
})
|
|
1896
|
+
|
|
1897
|
+
it('② 去连字符完整 uuid 命中(§3.3 盲区:与带连字符 id 不构成子串)', async () => {
|
|
1898
|
+
await writeSession(ID1, { firstUserText: '无关内容' })
|
|
1899
|
+
// 去连字符形态与 sessionId/path 均不构成精确子串,只能走归一化层
|
|
1900
|
+
const r = await find('019e6c96aaaabbbbcccc000000000001')
|
|
1901
|
+
const d = r.details as { matches: Array<{ sessionId: string }> }
|
|
1902
|
+
expect(d.matches).toHaveLength(1)
|
|
1903
|
+
expect(d.matches[0].sessionId).toBe(ID1)
|
|
1904
|
+
})
|
|
1905
|
+
|
|
1906
|
+
it('③ F1 文案四要素 + 负向断言(无 recent 误导指引、无「真的没有」归因)', async () => {
|
|
1907
|
+
await writeSession(ID1, { firstUserText: '无关内容' })
|
|
1908
|
+
const r = await find('zzz-no-hit-9q8x')
|
|
1909
|
+
const text = r.content[0].text
|
|
1910
|
+
|
|
1911
|
+
// 首行 + ①事实型自检行(根计数 / 候选集判定 / 归一化声明)
|
|
1912
|
+
expect(text).toContain('无匹配 session:"zzz-no-hit-9q8x"')
|
|
1913
|
+
expect(text).toContain('自检(发现层,只陈述事实)')
|
|
1914
|
+
expect(text).toContain('[default]')
|
|
1915
|
+
expect(text).toContain('候选集非空(共 1 文件),根解析正常')
|
|
1916
|
+
expect(text).toContain('查询已做 uuid 归一化匹配(小写 + 去连字符)后仍无命中')
|
|
1917
|
+
// ②编辑距离候选段存在
|
|
1918
|
+
expect(text).toContain('最接近的候选(编辑距离)')
|
|
1919
|
+
// ③「正确做法」四条
|
|
1920
|
+
expect(text).toContain('正确做法')
|
|
1921
|
+
expect(text).toContain('改用标题/keyword')
|
|
1922
|
+
expect(text).toContain('直接传绝对路径')
|
|
1923
|
+
expect(text).toContain('更短前缀')
|
|
1924
|
+
expect(text).toContain('action:"doctor"')
|
|
1925
|
+
// ④最后一行封死 shell 绕行
|
|
1926
|
+
expect(text).toContain('不要用 shell find/ls/rg 搜 session 目录,不要 cat/read 原始 .jsonl')
|
|
1927
|
+
// 负向:无「recent 看全量」误导、无归因断言
|
|
1928
|
+
expect(text).not.toContain('recent')
|
|
1929
|
+
expect(text).not.toContain('真的没有')
|
|
1930
|
+
// details 程序化契约保持(零匹配形态不变)
|
|
1931
|
+
expect(r.details).toEqual({ matches: [], truncated: false })
|
|
1932
|
+
})
|
|
1933
|
+
|
|
1934
|
+
it('④ 编辑距离 top-3:近似 id 入选(标注 source 与差异位),远 id 被挤出', async () => {
|
|
1935
|
+
const near1 = '019aaabc-0000-7000-8000-00000000000a'
|
|
1936
|
+
const near2 = '019aaabc-0000-7000-8000-00000000000b'
|
|
1937
|
+
const near3 = '019aaabc-0000-7000-8000-00000000000c'
|
|
1938
|
+
const far = 'ffffffff-ffff-ffff-ffff-ffffffffffff'
|
|
1939
|
+
await writeSession(near1)
|
|
1940
|
+
await writeSession(near2)
|
|
1941
|
+
await writeSession(near3, { subagent: true })
|
|
1942
|
+
await writeSession(far)
|
|
1943
|
+
// 手算锚点:query 与 near* 前 33 字符相同,第 34 位起 z→0/z→0/z→a,距离 3
|
|
1944
|
+
expect(levenshtein('019aaabc-0000-7000-8000-000000000zzz', near1)).toBe(3)
|
|
1945
|
+
expect(levenshtein('kitten', 'sitting')).toBe(3)
|
|
1946
|
+
|
|
1947
|
+
const r = await find('019aaabc-0000-7000-8000-000000000zzz')
|
|
1948
|
+
const text = r.content[0].text
|
|
1949
|
+
// top-3 恰为三个近似候选(far 距离约 36,第 4 名被上限挤掉)
|
|
1950
|
+
expect(text).toContain(near1)
|
|
1951
|
+
expect(text).toContain(near2)
|
|
1952
|
+
expect(text).toContain(near3)
|
|
1953
|
+
expect(text).not.toContain(far)
|
|
1954
|
+
// 差异位标注(首个差异字符,1-based)
|
|
1955
|
+
expect(text).toContain('差异在第 34 位:z → 0')
|
|
1956
|
+
// source 标注:near3 在 subagent 根,标注透传
|
|
1957
|
+
expect(text).toContain(`${near3} subagent`)
|
|
1958
|
+
})
|
|
1959
|
+
|
|
1960
|
+
it('⑤ §11.5 归一化对比基线:增量仅限大小写/连字符变体,非 uuid 乱串不被 norm 误吸', async () => {
|
|
1961
|
+
const idHit = '019cafe0-1234-7000-8000-deadbeef0001'
|
|
1962
|
+
await writeSession(idHit, { firstUserText: '福耀玻璃深度研究' })
|
|
1963
|
+
await writeSession(ID2, { firstUserText: '完全不同的另一个会话' })
|
|
1964
|
+
|
|
1965
|
+
// 现状基线:精确小写片段命中 1
|
|
1966
|
+
const base = await find('019cafe0')
|
|
1967
|
+
expect((base.details as { matches: unknown[] }).matches).toHaveLength(1)
|
|
1968
|
+
// 大写变体:增量 = 同一 id(大小写变体),无新对象
|
|
1969
|
+
const upper = await find('019CAFE0')
|
|
1970
|
+
expect((upper.details as { matches: Array<{ sessionId: string }> }).matches.map((m) => m.sessionId)).toEqual([idHit])
|
|
1971
|
+
// 去连字符变体:增量 = 同一 id(连字符变体),无新对象
|
|
1972
|
+
const noDash = await find('019cafe0123470008000deadbeef0001')
|
|
1973
|
+
expect((noDash.details as { matches: Array<{ sessionId: string }> }).matches.map((m) => m.sessionId)).toEqual([idHit])
|
|
1974
|
+
// 'deadbeef' 段是带连字符 id 的连续子串——现状精确匹配已命中,norm 后命中数不变(无增量)
|
|
1975
|
+
const deadbeef = await find('deadbeef')
|
|
1976
|
+
expect((deadbeef.details as { matches: Array<{ sessionId: string }> }).matches.map((m) => m.sessionId)).toEqual([idHit])
|
|
1977
|
+
// keyword 路径不受 norm 影响(非 uuid 特征照常回退首消息)
|
|
1978
|
+
const kw = await find('福耀玻璃')
|
|
1979
|
+
expect((kw.details as { matches: Array<{ sessionId: string }> }).matches.map((m) => m.sessionId)).toEqual([idHit])
|
|
1980
|
+
// 非 uuid 乱串:0 命中(norm 不把乱串吸进 uuid 匹配)
|
|
1981
|
+
const junk = await find('zzznotexist9q')
|
|
1982
|
+
expect((junk.details as { matches: unknown[] }).matches).toHaveLength(0)
|
|
1983
|
+
// hex 乱串(uuid 特征但非任何 id 子串):两级均 0 → 短路不回退(与现状一致,无召回回归)
|
|
1984
|
+
const hexJunk = await find('0123456789abcdef')
|
|
1985
|
+
expect((hexJunk.details as { matches: unknown[] }).matches).toHaveLength(0)
|
|
1986
|
+
})
|
|
1987
|
+
|
|
1988
|
+
it('⑥ 自检行计数 = 本次实扫结果(不读 doctor 缓存)+ 去重根注记', async () => {
|
|
1989
|
+
const agentDir = join(tmp, 'agent')
|
|
1990
|
+
// PS-14 形态:doctor 首跑时 main 根空 → 缓存 fileCount=0
|
|
1991
|
+
await mkdir(join(agentDir, 'sessions'), { recursive: true })
|
|
1992
|
+
await handleSessionRead({ action: 'doctor' }, { agentDir })
|
|
1993
|
+
// 之后 session 落盘(main 2 + subagent 1)
|
|
1994
|
+
await writeSession(ID1)
|
|
1995
|
+
await writeSession(ID2)
|
|
1996
|
+
await writeSession('019e6c96-aaaa-bbbb-cccc-000000000003', { subagent: true })
|
|
1997
|
+
|
|
1998
|
+
const r = await handleSessionRead(
|
|
1999
|
+
{ action: 'find', query: 'zzz-no-hit-9q8x' },
|
|
2000
|
+
{ agentDir },
|
|
2001
|
+
)
|
|
2002
|
+
const text = r.content[0].text
|
|
2003
|
+
// 若 F1 读 doctor 缓存(0 文件),以下计数断言必红
|
|
2004
|
+
expect(text).toContain(`${join(agentDir, 'sessions')}:2 文件`)
|
|
2005
|
+
expect(text).toContain(`${join(agentDir, 'subagents')}:1 文件`)
|
|
2006
|
+
expect(text).toContain('候选集非空(共 3 文件)')
|
|
2007
|
+
|
|
2008
|
+
// 完整信号包(live 与 default 同路径)→ 去重根注记,不重复计数
|
|
2009
|
+
const deduped = await handleSessionRead(
|
|
2010
|
+
{ action: 'find', query: 'zzz-no-hit-9q8x' },
|
|
2011
|
+
{ agentDir, liveSessionDir: join(agentDir, 'sessions') },
|
|
2012
|
+
)
|
|
2013
|
+
expect(deduped.content[0].text).toContain('同路径,已去重')
|
|
2014
|
+
expect(deduped.content[0].text).toContain('候选集非空(共 3 文件)')
|
|
2015
|
+
})
|
|
2016
|
+
})
|
|
2017
|
+
|
|
2018
|
+
// ============================================================
|
|
2019
|
+
// 片段解析接通 [live] 根(同一 roots 契约):resolveByFragment/doXxx 透传
|
|
2020
|
+
// liveSessionDir——[live]≠[default] 环境(纯 pi + 显式 --session-dir)下,find
|
|
2021
|
+
// 能列出的 session 在 outline/result 等解析路径同样可达;F1 自检行含 [live] 根行。
|
|
2022
|
+
// 全部 mkdtemp fixture 自建自删,不触碰真实数据目录。
|
|
2023
|
+
// ============================================================
|
|
2024
|
+
|
|
2025
|
+
describe('片段解析接通 [live] 根(fixture,同一 roots 契约)', () => {
|
|
2026
|
+
let tmp: string
|
|
2027
|
+
const SLUG = '--live-cwd--'
|
|
2028
|
+
const ID = '019e6c96-aaaa-bbbb-cccc-0000000000aa'
|
|
2029
|
+
const FRAGMENT = '0000000000aa'
|
|
2030
|
+
let agentDir: string
|
|
2031
|
+
/** liveSessionDir 信号(encodeCwd 形态,normalizeLiveSessionDir 剥层到 sessions/ 根)。 */
|
|
2032
|
+
let liveDir: string
|
|
2033
|
+
/** 剥层后的 [live] 根路径(F1 自检行的显示形态)。 */
|
|
2034
|
+
let liveRoot: string
|
|
2035
|
+
|
|
2036
|
+
/** 在指定目录写 session 文件(header + user 消息,extraLines 追加 assistant 等条目)。 */
|
|
2037
|
+
async function writeSessionAt(dir: string, id: string, extraLines: string[] = []): Promise<void> {
|
|
2038
|
+
await mkdir(dir, { recursive: true })
|
|
2039
|
+
const lines = [
|
|
2040
|
+
JSON.stringify({ type: 'session', id, cwd: '/demo' }),
|
|
2041
|
+
JSON.stringify({
|
|
2042
|
+
type: 'message',
|
|
2043
|
+
id: `${id}-m1`,
|
|
2044
|
+
parentId: id,
|
|
2045
|
+
message: { role: 'user', content: [{ type: 'text', text: 'live 根里的会话' }] },
|
|
2046
|
+
}),
|
|
2047
|
+
...extraLines,
|
|
2048
|
+
]
|
|
2049
|
+
await writeFile(join(dir, `${id}.jsonl`), lines.join('\n') + '\n')
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
beforeEach(async () => {
|
|
2053
|
+
tmp = await mkdtemp(join(tmpdir(), 'tool-handler-live-'))
|
|
2054
|
+
agentDir = join(tmp, 'agent')
|
|
2055
|
+
await mkdir(join(agentDir, 'sessions'), { recursive: true }) // [default] 空根
|
|
2056
|
+
liveDir = join(tmp, 'pi-live', 'sessions', SLUG)
|
|
2057
|
+
liveRoot = join(tmp, 'pi-live', 'sessions')
|
|
2058
|
+
})
|
|
2059
|
+
afterEach(async () => {
|
|
2060
|
+
await rm(tmp, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
2061
|
+
})
|
|
2062
|
+
|
|
2063
|
+
it('① [live]≠[default]:outline 片段解析到 [live] 根 session;不传信号则 F1(负向对照)', async () => {
|
|
2064
|
+
await writeSessionAt(liveDir, ID)
|
|
2065
|
+
// 接通后:片段解析可达 [live] 根,outline 正常产出
|
|
2066
|
+
const r = await handleSessionRead(
|
|
2067
|
+
{ action: 'outline', session: FRAGMENT },
|
|
2068
|
+
{ agentDir, liveSessionDir: liveDir },
|
|
2069
|
+
)
|
|
2070
|
+
const d = r.details as { stats: { totalTurns: number } }
|
|
2071
|
+
expect(d.stats.totalTurns).toBeGreaterThanOrEqual(1)
|
|
2072
|
+
// 负向对照:不传 liveSessionDir(三根降级)→ 同一片段 F1 零匹配
|
|
2073
|
+
//([default]/[legacy]/[subagent] 均无此文件——证明命中确实来自 [live] 根)
|
|
2074
|
+
await expect(
|
|
2075
|
+
handleSessionRead({ action: 'outline', session: FRAGMENT }, agentDir),
|
|
2076
|
+
).rejects.toThrow(/无匹配 session/)
|
|
2077
|
+
})
|
|
2078
|
+
|
|
2079
|
+
it('② F1 自检行含 [live] 根行(剥层路径 + 实扫计数),与 [default] 空根同表可见', async () => {
|
|
2080
|
+
await writeSessionAt(liveDir, ID)
|
|
2081
|
+
const caught = await handleSessionRead(
|
|
2082
|
+
{ action: 'outline', session: 'zzz-no-hit-9q8x' },
|
|
2083
|
+
{ agentDir, liveSessionDir: liveDir },
|
|
2084
|
+
).catch((e: unknown) => e)
|
|
2085
|
+
expect(caught).toBeInstanceOf(Error)
|
|
2086
|
+
const text = (caught as Error).message
|
|
2087
|
+
expect(text).toContain('无匹配 session:"zzz-no-hit-9q8x"')
|
|
2088
|
+
expect(text).toContain('自检(发现层,只陈述事实)')
|
|
2089
|
+
// [live] 根行:encodeCwd 已剥层到 sessions/ 根,计数 = 本次实扫
|
|
2090
|
+
expect(text).toContain(`${liveRoot}:1 文件`)
|
|
2091
|
+
// [default] 空根同表可见(同一 roots 契约的全貌)
|
|
2092
|
+
expect(text).toContain(`${join(agentDir, 'sessions')}:0 文件`)
|
|
2093
|
+
})
|
|
2094
|
+
|
|
2095
|
+
it('③ result 片段形态同样可达 [live] 根(per-call deps 接线)', async () => {
|
|
2096
|
+
await writeSessionAt(liveDir, ID, [
|
|
2097
|
+
JSON.stringify({
|
|
2098
|
+
type: 'message',
|
|
2099
|
+
id: `${ID}-a1`,
|
|
2100
|
+
parentId: `${ID}-m1`,
|
|
2101
|
+
message: { role: 'assistant', content: [{ type: 'text', text: '最终结论正文' }] },
|
|
2102
|
+
}),
|
|
2103
|
+
])
|
|
2104
|
+
const r = await handleSessionRead(
|
|
2105
|
+
{ action: 'result', session: FRAGMENT },
|
|
2106
|
+
{ agentDir, liveSessionDir: liveDir },
|
|
2107
|
+
)
|
|
2108
|
+
expect(r.content[0].text).toContain('最终结论正文')
|
|
2109
|
+
})
|
|
2110
|
+
})
|
|
2111
|
+
|
|
2112
|
+
// ============================================================
|
|
2113
|
+
// u10:find 分组输出(design 2026-09-10 §5.1 形态 / §6.7 子决策 2/3 / §8.2 回归基线)
|
|
2114
|
+
// ============================================================
|
|
2115
|
+
|
|
2116
|
+
describe('u10 find 分组输出(fixture,§6.7 子决策 2/3 + §8.2 回归基线)', () => {
|
|
2117
|
+
let tmp: string
|
|
2118
|
+
const SLUG = '--demo-cwd--'
|
|
2119
|
+
// uuidv7 同毫秒时间前缀碰撞是现网「34 条 subagent」形态的成因(§3.4),fixture 沿用共享前缀
|
|
2120
|
+
const PREFIX = '019e6c96'
|
|
2121
|
+
const mainId = (n: number) => `${PREFIX}-aaaa-bbbb-cccc-d${String(n).padStart(11, '0')}`
|
|
2122
|
+
const subId = (n: number) => `${PREFIX}-aaaa-bbbb-cccc-e${String(n).padStart(11, '0')}`
|
|
2123
|
+
|
|
2124
|
+
/** 写 session 文件(subagent:true 写入 subagent 根),形态同 u9 段 writeSession。 */
|
|
2125
|
+
async function writeSession(
|
|
2126
|
+
id: string,
|
|
2127
|
+
opts?: { subagent?: boolean; firstUserText?: string },
|
|
2128
|
+
): Promise<void> {
|
|
2129
|
+
const dir = opts?.subagent
|
|
2130
|
+
? join(tmp, 'agent', 'subagents', SLUG, 'sessions')
|
|
2131
|
+
: join(tmp, 'agent', 'sessions', SLUG)
|
|
2132
|
+
await mkdir(dir, { recursive: true })
|
|
2133
|
+
const lines = [JSON.stringify({ type: 'session', id, cwd: '/demo' })]
|
|
2134
|
+
if (opts?.firstUserText !== undefined) {
|
|
2135
|
+
lines.push(
|
|
2136
|
+
JSON.stringify({
|
|
2137
|
+
type: 'message',
|
|
2138
|
+
id: `${id}-m1`,
|
|
2139
|
+
message: { role: 'user', content: [{ type: 'text', text: opts.firstUserText }] },
|
|
2140
|
+
}),
|
|
2141
|
+
)
|
|
2142
|
+
}
|
|
2143
|
+
await writeFile(join(dir, `${id}.jsonl`), lines.join('\n') + '\n')
|
|
2144
|
+
}
|
|
2145
|
+
|
|
2146
|
+
/**
|
|
2147
|
+
* 写带 assistant 正文的 subagent(manifest + session 文件三行形态),
|
|
2148
|
+
* 供 result action 批量调用(回归基线:批量头行 8 字符短 id 不受 u10 影响)。
|
|
2149
|
+
*/
|
|
2150
|
+
async function writeSubagentWithResult(saId: string, id: string, body: string): Promise<void> {
|
|
2151
|
+
const sessionFile = join(tmp, 'agent', 'subagents', SLUG, 'sessions', `${id}.jsonl`)
|
|
2152
|
+
await mkdir(dirname(sessionFile), { recursive: true })
|
|
2153
|
+
const lines = [
|
|
2154
|
+
JSON.stringify({ type: 'session', id, cwd: '/demo' }),
|
|
2155
|
+
JSON.stringify({
|
|
2156
|
+
type: 'message',
|
|
2157
|
+
id: `${id}-m1`,
|
|
2158
|
+
message: { role: 'user', content: [{ type: 'text', text: 'task' }] },
|
|
2159
|
+
}),
|
|
2160
|
+
JSON.stringify({
|
|
2161
|
+
type: 'message',
|
|
2162
|
+
id: `${id}-a1`,
|
|
2163
|
+
message: { role: 'assistant', content: [{ type: 'text', text: body }] },
|
|
2164
|
+
}),
|
|
2165
|
+
]
|
|
2166
|
+
await writeFile(sessionFile, lines.join('\n') + '\n')
|
|
2167
|
+
const recordsDir = join(tmp, 'agent', 'subagents', SLUG, 'records')
|
|
2168
|
+
await mkdir(recordsDir, { recursive: true })
|
|
2169
|
+
await writeFile(
|
|
2170
|
+
join(recordsDir, `${saId}.json`),
|
|
2171
|
+
JSON.stringify({ id: saId, rootSessionId: 'root-u10', agentName: 'explorer', sessionFile }),
|
|
2172
|
+
)
|
|
2173
|
+
}
|
|
2174
|
+
|
|
2175
|
+
function find(
|
|
2176
|
+
query: string,
|
|
2177
|
+
extra?: Partial<SessionReadParams>,
|
|
2178
|
+
): ReturnType<typeof handleSessionRead> {
|
|
2179
|
+
return handleSessionRead({ action: 'find', query, ...extra }, join(tmp, 'agent'))
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
interface FindDetails {
|
|
2183
|
+
matches: Array<{ source: string; sessionId: string }>
|
|
2184
|
+
truncated: boolean
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2187
|
+
beforeEach(async () => {
|
|
2188
|
+
tmp = await mkdtemp(join(tmpdir(), 'tool-handler-u10-'))
|
|
2189
|
+
})
|
|
2190
|
+
afterEach(async () => {
|
|
2191
|
+
await rm(tmp, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
2192
|
+
})
|
|
2193
|
+
|
|
2194
|
+
it('① main 置顶 + 全 id + ↳ 可复制调用串 + 编号跨组连续(§5.1 全列出形态)', async () => {
|
|
2195
|
+
const M1 = mainId(1)
|
|
2196
|
+
const S1 = subId(1)
|
|
2197
|
+
const S2 = subId(2)
|
|
2198
|
+
await writeSession(M1, { firstUserText: 'main content' })
|
|
2199
|
+
await writeSession(S1, { subagent: true, firstUserText: 'sub content one' })
|
|
2200
|
+
await writeSession(S2, { subagent: true, firstUserText: 'sub content two' })
|
|
2201
|
+
|
|
2202
|
+
const r = await find(PREFIX)
|
|
2203
|
+
const text = r.content[0].text
|
|
2204
|
+
// main 段置顶:main 组头与 main id 均先于 subagent 出现
|
|
2205
|
+
expect(text.indexOf('main(')).toBeLessThan(text.indexOf('subagent('))
|
|
2206
|
+
expect(text.indexOf(M1)).toBeLessThan(text.indexOf(S1))
|
|
2207
|
+
expect(text).toContain('main(1 条命中):')
|
|
2208
|
+
expect(text).toContain('subagent(2 条命中):')
|
|
2209
|
+
// 全 id:36 字符完整出现,8 字符截断形态消失(§6.7 子决策 3)
|
|
2210
|
+
expect(text).toContain(M1)
|
|
2211
|
+
expect(text).not.toContain(`${M1.slice(0, 8)}…`)
|
|
2212
|
+
// ↳ 可复制调用串只附 main 候选(subagent 噪声不配指针)
|
|
2213
|
+
expect(text).toContain(`↳ session_read { action:"outline", session:"${M1}" }`)
|
|
2214
|
+
expect(text).not.toContain(`session:"${S1}"`)
|
|
2215
|
+
// 编号跨组连续:main 是 1 号,subagent 段续 2 号(组内 mtime 序不固定,用正则)
|
|
2216
|
+
expect(text).toContain(` 1. ${M1}`)
|
|
2217
|
+
expect(text).toMatch(/ 2\. 019e6c96-aaaa-bbbb-cccc-e0000000000[12]/)
|
|
2218
|
+
// details 合并列表:main 首位、集合完整、无截断
|
|
2219
|
+
const d = r.details as FindDetails
|
|
2220
|
+
expect(d.truncated).toBe(false)
|
|
2221
|
+
expect(d.matches[0]).toMatchObject({ source: 'main', sessionId: M1 })
|
|
2222
|
+
expect(new Set(d.matches.map((m) => m.sessionId))).toEqual(new Set([M1, S1, S2]))
|
|
2223
|
+
})
|
|
2224
|
+
|
|
2225
|
+
it('② subagent 超剩余配额 → 折叠计数行 + 加 source:"subagent" 提示(§6.7 子决策 2)', async () => {
|
|
2226
|
+
const M1 = mainId(1)
|
|
2227
|
+
await writeSession(M1)
|
|
2228
|
+
for (let i = 1; i <= 3; i++) await writeSession(subId(i), { subagent: true })
|
|
2229
|
+
|
|
2230
|
+
// limit 2:main 1 条(无溢出)→ 剩余配额 1 → subagent 展示 1 条 + 溢出折叠
|
|
2231
|
+
const r = await find(PREFIX, { limit: 2 })
|
|
2232
|
+
const text = r.content[0].text
|
|
2233
|
+
expect(text).toContain('main(1 条命中):')
|
|
2234
|
+
expect(text).toContain('subagent(>1 条命中,显示前 1 条):')
|
|
2235
|
+
expect(text).toContain('… 另有 subagent 命中未显示。👉 加 source:"subagent" 查看')
|
|
2236
|
+
const d = r.details as FindDetails
|
|
2237
|
+
expect(d.truncated).toBe(true)
|
|
2238
|
+
expect(d.matches).toHaveLength(2)
|
|
2239
|
+
expect(d.matches[0].source).toBe('main')
|
|
2240
|
+
expect(d.matches.filter((m) => m.source === 'subagent')).toHaveLength(1)
|
|
2241
|
+
})
|
|
2242
|
+
|
|
2243
|
+
it('③ main 命中占满 limit → subagent 段 0 条仅显示计数(§6.7:main 优先占满)', async () => {
|
|
2244
|
+
for (let i = 1; i <= 3; i++) await writeSession(mainId(i))
|
|
2245
|
+
for (let i = 1; i <= 2; i++) await writeSession(subId(i), { subagent: true })
|
|
2246
|
+
|
|
2247
|
+
const r = await find(PREFIX, { limit: 3 })
|
|
2248
|
+
const text = r.content[0].text
|
|
2249
|
+
expect(text).toContain('main(3 条命中):')
|
|
2250
|
+
expect(text).toContain('subagent(有命中未显示——展示配额已被 main 占满):')
|
|
2251
|
+
expect(text).toContain('👉 加 source:"subagent" 查看')
|
|
2252
|
+
// subagent 条目与调用串不出现(0 条展示)
|
|
2253
|
+
expect(text).not.toContain(subId(1))
|
|
2254
|
+
const d = r.details as FindDetails
|
|
2255
|
+
expect(d.matches).toHaveLength(3)
|
|
2256
|
+
expect(d.matches.every((m) => m.source === 'main')).toBe(true)
|
|
2257
|
+
// 合并总量:命中 3 main + 2 sub > 展示 3 → truncated=true
|
|
2258
|
+
expect(d.truncated).toBe(true)
|
|
2259
|
+
})
|
|
2260
|
+
|
|
2261
|
+
it('④ truncated 按合并总量计算(命中总数 vs 实际输出数,含 main 恰满边界)', async () => {
|
|
2262
|
+
for (let i = 1; i <= 2; i++) await writeSession(mainId(i))
|
|
2263
|
+
for (let i = 1; i <= 2; i++) await writeSession(subId(i), { subagent: true })
|
|
2264
|
+
|
|
2265
|
+
// a) 全列出(4 ≤ 20):truncated=false
|
|
2266
|
+
const all = (await find(PREFIX)).details as FindDetails
|
|
2267
|
+
expect(all.matches).toHaveLength(4)
|
|
2268
|
+
expect(all.truncated).toBe(false)
|
|
2269
|
+
|
|
2270
|
+
// b) main 恰好占满 limit(2/2)+ subagent 有命中 → 仍截断(防 main 恰满漏探测 subagent)
|
|
2271
|
+
const exact = (await find(PREFIX, { limit: 2 })).details as FindDetails
|
|
2272
|
+
expect(exact.matches).toHaveLength(2)
|
|
2273
|
+
expect(exact.matches.every((m) => m.source === 'main')).toBe(true)
|
|
2274
|
+
expect(exact.truncated).toBe(true)
|
|
2275
|
+
|
|
2276
|
+
// c) 混合截断:展示 3(2 main + 1 sub)< 命中 4 → truncated=true
|
|
2277
|
+
const mixed = (await find(PREFIX, { limit: 3 })).details as FindDetails
|
|
2278
|
+
expect(mixed.matches).toHaveLength(3)
|
|
2279
|
+
expect(mixed.matches.filter((m) => m.source === 'main')).toHaveLength(2)
|
|
2280
|
+
expect(mixed.matches.filter((m) => m.source === 'subagent')).toHaveLength(1)
|
|
2281
|
+
expect(mixed.truncated).toBe(true)
|
|
2282
|
+
})
|
|
2283
|
+
|
|
2284
|
+
it('⑤a 回归基线:find{query:<前缀>, limit:100} main 0 条 + subagent 全列(现网 34 条形态)', async () => {
|
|
2285
|
+
for (let i = 1; i <= 34; i++) await writeSession(subId(i), { subagent: true })
|
|
2286
|
+
|
|
2287
|
+
const r = await find(PREFIX, { limit: 100 })
|
|
2288
|
+
const d = r.details as FindDetails
|
|
2289
|
+
expect(d.matches).toHaveLength(34)
|
|
2290
|
+
expect(d.matches.every((m) => m.source === 'subagent')).toBe(true)
|
|
2291
|
+
expect(d.truncated).toBe(false)
|
|
2292
|
+
const text = r.content[0].text
|
|
2293
|
+
// main 段 0 条可辨识(防「全是 subagent」被误读),subagent 段全列不折叠
|
|
2294
|
+
expect(text).toContain('main(0 条命中):')
|
|
2295
|
+
expect(text).toContain('subagent(34 条命中):')
|
|
2296
|
+
expect(text).not.toContain('加 source:"subagent"')
|
|
2297
|
+
})
|
|
2298
|
+
|
|
2299
|
+
it('⑤b 回归基线:result 批量头行仍 8 字符短 id(SESSION_ID_PREFIX_LEN 通路不动)', async () => {
|
|
2300
|
+
const R1 = `${PREFIX}-aaaa-bbbb-cccc-f00000000001`
|
|
2301
|
+
const R2 = `${PREFIX}-aaaa-bbbb-cccc-f00000000002`
|
|
2302
|
+
await writeSubagentWithResult('sa-u10-r1', R1, 'alpha body')
|
|
2303
|
+
await writeSubagentWithResult('sa-u10-r2', R2, 'beta body')
|
|
2304
|
+
|
|
2305
|
+
const res = await handleSessionRead(
|
|
2306
|
+
{ action: 'result', session: 'sa-u10-r1,sa-u10-r2' },
|
|
2307
|
+
join(tmp, 'agent'),
|
|
2308
|
+
)
|
|
2309
|
+
const text = res.content[0].text
|
|
2310
|
+
expect(text).toContain(`(session ${R1.slice(0, 8)}…)`)
|
|
2311
|
+
expect(text).toContain(`(session ${R2.slice(0, 8)}…)`)
|
|
2312
|
+
})
|
|
2313
|
+
|
|
2314
|
+
it('⑤c 回归基线:recent 查询 main 段置顶可辨识', async () => {
|
|
2315
|
+
for (let i = 1; i <= 2; i++) await writeSession(mainId(i))
|
|
2316
|
+
for (let i = 1; i <= 2; i++) await writeSession(subId(i), { subagent: true })
|
|
2317
|
+
|
|
2318
|
+
const r = await find('recent')
|
|
2319
|
+
const text = r.content[0].text
|
|
2320
|
+
expect(text.indexOf('main(2 条命中):')).toBeLessThan(text.indexOf('subagent(2 条命中):'))
|
|
2321
|
+
const d = r.details as FindDetails
|
|
2322
|
+
expect(d.matches).toHaveLength(4)
|
|
2323
|
+
// 分组价值:main/subagent 的 mtime 交错也被 main 段置顶(§8.2「main 段置顶后可辨识」)
|
|
2324
|
+
expect(d.matches.slice(0, 2).every((m) => m.source === 'main')).toBe(true)
|
|
2325
|
+
expect(d.truncated).toBe(false)
|
|
2326
|
+
})
|
|
2327
|
+
|
|
2328
|
+
it('⑥ resolveByFragment 独立无分组语义:outline 片段多匹配 → F2 全量候选(不被配额截断)', async () => {
|
|
2329
|
+
for (let i = 1; i <= 2; i++) await writeSession(mainId(i))
|
|
2330
|
+
for (let i = 1; i <= 3; i++) await writeSession(subId(i), { subagent: true })
|
|
2331
|
+
|
|
2332
|
+
// 解析路径(resolveByFragment → findSessions limit:10 无 source)保持 mtime 排序 +
|
|
2333
|
+
// limit 截断原语义:5 命中全进 F2 消歧候选,无 main 置顶/配额分组介入(§6.7 末条)
|
|
2334
|
+
const r = await handleSessionRead({ action: 'outline', session: PREFIX }, join(tmp, 'agent'))
|
|
2335
|
+
const d = r.details as { ambiguous: boolean; candidates: Array<{ sessionId: string }> }
|
|
2336
|
+
expect(d.ambiguous).toBe(true)
|
|
2337
|
+
expect(d.candidates).toHaveLength(5)
|
|
2338
|
+
expect(new Set(d.candidates.map((c) => c.sessionId))).toEqual(
|
|
2339
|
+
new Set([mainId(1), mainId(2), subId(1), subId(2), subId(3)]),
|
|
2340
|
+
)
|
|
2341
|
+
})
|
|
2342
|
+
|
|
2343
|
+
it('⑥b 显式 source 查询走单组原语义:不折叠、保持 mtime+limit 截断', async () => {
|
|
2344
|
+
for (let i = 1; i <= 3; i++) await writeSession(subId(i), { subagent: true })
|
|
2345
|
+
|
|
2346
|
+
// 显式 source 本身就是折叠提示所指的展开动作,不再折叠
|
|
2347
|
+
const r = await find(PREFIX, { source: 'subagent', limit: 1 })
|
|
2348
|
+
const text = r.content[0].text
|
|
2349
|
+
expect(text).toContain('subagent(1 条命中):')
|
|
2350
|
+
expect(text).not.toContain('加 source:"subagent"')
|
|
2351
|
+
const d = r.details as FindDetails
|
|
2352
|
+
expect(d.matches).toHaveLength(1)
|
|
2353
|
+
expect(d.matches[0].source).toBe('subagent')
|
|
2354
|
+
expect(d.truncated).toBe(true)
|
|
2355
|
+
})
|
|
2356
|
+
})
|
|
2357
|
+
|
|
2358
|
+
// ============================================================
|
|
2359
|
+
// u11:metadataProvider 注入 + TTL 缓存(design 2026-09-10 §6.6)
|
|
2360
|
+
// handler 级集成:注入包装(withMetadataCache 独立实例,不与 doctorScanCache 串扰)
|
|
2361
|
+
// + 标题渲染(§5.1 候选行标题优先)+ provider 抛错降级不外抛。
|
|
2362
|
+
// 策略①惰性/②窄化/合并语义在 find.test.ts u11 段覆盖。
|
|
2363
|
+
// ============================================================
|
|
2364
|
+
describe('u11 metadataProvider 注入 + TTL 缓存(fixture,§6.6)', () => {
|
|
2365
|
+
let tmp: string
|
|
2366
|
+
/** 平铺 main 根(xyz-agent 形态),同时作 liveSessionDir(与 [default] 根同字面路径) */
|
|
2367
|
+
let flatMain: string
|
|
2368
|
+
|
|
2369
|
+
async function writeSession(
|
|
2370
|
+
id: string,
|
|
2371
|
+
opts?: { firstUserText?: string },
|
|
2372
|
+
): Promise<string> {
|
|
2373
|
+
await mkdir(flatMain, { recursive: true })
|
|
2374
|
+
const lines = [JSON.stringify({ type: 'session', id, cwd: '/demo' })]
|
|
2375
|
+
if (opts?.firstUserText !== undefined) {
|
|
2376
|
+
lines.push(
|
|
2377
|
+
JSON.stringify({
|
|
2378
|
+
type: 'message',
|
|
2379
|
+
id: `${id}-m1`,
|
|
2380
|
+
message: { role: 'user', content: [{ type: 'text', text: opts.firstUserText }] },
|
|
2381
|
+
}),
|
|
2382
|
+
)
|
|
2383
|
+
}
|
|
2384
|
+
const path = join(flatMain, `${id}.jsonl`)
|
|
2385
|
+
await writeFile(path, lines.join('\n') + '\n')
|
|
2386
|
+
return path
|
|
2387
|
+
}
|
|
2388
|
+
|
|
2389
|
+
function findWithProvider(
|
|
2390
|
+
query: string,
|
|
2391
|
+
provider: SessionMetadataProvider,
|
|
2392
|
+
): ReturnType<typeof handleSessionRead> {
|
|
2393
|
+
return handleSessionRead(
|
|
2394
|
+
{ action: 'find', query },
|
|
2395
|
+
{ agentDir: join(tmp, 'agent'), liveSessionDir: flatMain },
|
|
2396
|
+
undefined,
|
|
2397
|
+
provider,
|
|
2398
|
+
)
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2401
|
+
beforeEach(async () => {
|
|
2402
|
+
tmp = await mkdtemp(join(tmpdir(), 'tool-handler-u11-'))
|
|
2403
|
+
flatMain = join(tmp, 'agent', 'sessions')
|
|
2404
|
+
})
|
|
2405
|
+
afterEach(async () => {
|
|
2406
|
+
await rm(tmp, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
2407
|
+
})
|
|
2408
|
+
|
|
2409
|
+
it('② 标题进匹配与渲染:keyword 命中 name → 候选行标题优先展示 + details.matches[].name 透传', async () => {
|
|
2410
|
+
const id = 'm-title-0001'
|
|
2411
|
+
const p = await writeSession(id, { firstUserText: '首消息无关' })
|
|
2412
|
+
const provider = vi.fn(async (dir: string) =>
|
|
2413
|
+
dir === flatMain
|
|
2414
|
+
? [
|
|
2415
|
+
{
|
|
2416
|
+
path: p,
|
|
2417
|
+
id,
|
|
2418
|
+
cwd: '/demo',
|
|
2419
|
+
name: '太保寿险深度研究攻坚',
|
|
2420
|
+
modified: new Date(),
|
|
2421
|
+
firstMessage: '首消息无关',
|
|
2422
|
+
},
|
|
2423
|
+
]
|
|
2424
|
+
: [],
|
|
2425
|
+
)
|
|
2426
|
+
const r = await findWithProvider('太保寿险', provider)
|
|
2427
|
+
const text = r.content[0].text
|
|
2428
|
+
// §5.1 形态:候选行末段为标题(name 优先于首消息预览)
|
|
2429
|
+
expect(text).toContain('太保寿险深度研究攻坚')
|
|
2430
|
+
const d = r.details as { matches: Array<{ sessionId: string; name?: string }> }
|
|
2431
|
+
expect(d.matches[0].sessionId).toBe(id)
|
|
2432
|
+
expect(d.matches[0].name).toBe('太保寿险深度研究攻坚')
|
|
2433
|
+
// 注入边界生效:provider 被 TTL 包装调用且实参非空串
|
|
2434
|
+
expect(provider).toHaveBeenCalledTimes(1)
|
|
2435
|
+
expect(provider.mock.calls[0][0]).toBe(flatMain)
|
|
2436
|
+
})
|
|
2437
|
+
|
|
2438
|
+
it('④ provider 抛错 → handler 不抛,降级首条 user 匹配(标题留空,错误不外泄)', async () => {
|
|
2439
|
+
const id = 'err-handler-01'
|
|
2440
|
+
await writeSession(id, { firstUserText: 'handler 降级命中文本' })
|
|
2441
|
+
const provider = vi.fn(async () => {
|
|
2442
|
+
throw new Error('listAll secret boom')
|
|
2443
|
+
})
|
|
2444
|
+
const r = await findWithProvider('handler 降级命中', provider)
|
|
2445
|
+
const d = r.details as { matches: Array<{ sessionId: string; name?: string }> }
|
|
2446
|
+
expect(d.matches).toHaveLength(1)
|
|
2447
|
+
expect(d.matches[0].sessionId).toBe(id)
|
|
2448
|
+
expect(d.matches[0].name).toBeUndefined()
|
|
2449
|
+
// 错误细节不进工具输出(降级不是错误)
|
|
2450
|
+
expect(r.content[0].text).not.toContain('boom')
|
|
2451
|
+
})
|
|
2452
|
+
|
|
2453
|
+
it('⑤ TTL 缓存(fake timers):TTL 内不重调、过期重调、目录 mtime 变化失效', async () => {
|
|
2454
|
+
const id = 'ttl-00000001'
|
|
2455
|
+
const p = await writeSession(id, { firstUserText: '无关' })
|
|
2456
|
+
const provider = vi.fn(async (dir: string) =>
|
|
2457
|
+
dir === flatMain
|
|
2458
|
+
? [{ path: p, id, cwd: '/demo', name: '标题', modified: new Date() }]
|
|
2459
|
+
: [],
|
|
2460
|
+
)
|
|
2461
|
+
// 仅 fake Date(缓存 TTL 判定用),fs/performance 走真实实现
|
|
2462
|
+
vi.useFakeTimers({ toFake: ['Date'] })
|
|
2463
|
+
try {
|
|
2464
|
+
// 首查:cache miss → 调 1 次
|
|
2465
|
+
await findWithProvider('zzznohit9q', provider)
|
|
2466
|
+
expect(provider).toHaveBeenCalledTimes(1)
|
|
2467
|
+
// TTL 内二查:命中缓存不重调
|
|
2468
|
+
await findWithProvider('zzznohit9q', provider)
|
|
2469
|
+
expect(provider).toHaveBeenCalledTimes(1)
|
|
2470
|
+
// TTL 过期 → 重调
|
|
2471
|
+
vi.setSystemTime(Date.now() + METADATA_CACHE_TTL_MS + 1)
|
|
2472
|
+
await findWithProvider('zzznohit9q', provider)
|
|
2473
|
+
expect(provider).toHaveBeenCalledTimes(2)
|
|
2474
|
+
// TTL 内但目录 mtime 变化 → 失效重调
|
|
2475
|
+
const bumped = new Date(Date.now() + 60_000)
|
|
2476
|
+
await utimes(flatMain, bumped, bumped)
|
|
2477
|
+
await findWithProvider('zzznohit9q', provider)
|
|
2478
|
+
expect(provider).toHaveBeenCalledTimes(3)
|
|
2479
|
+
} finally {
|
|
2480
|
+
vi.useRealTimers()
|
|
2481
|
+
}
|
|
2482
|
+
})
|
|
2483
|
+
})
|
|
2484
|
+
|
|
2485
|
+
// ---------------------------------------------------------------------------
|
|
2486
|
+
// u12 跨会话内容检索(fixture,§2 目标 5 / §8.2 V8:窄化前置 + 字节上限 + 渲染)
|
|
2487
|
+
// 全部 mkdtemp fixture 自建自删,不触碰真实数据目录。
|
|
2488
|
+
// ---------------------------------------------------------------------------
|
|
2489
|
+
|
|
2490
|
+
describe('u12 跨会话内容检索(fixture,V8)', () => {
|
|
2491
|
+
let tmp: string
|
|
2492
|
+
/** main 根(平铺,[default] 根直扫)与 subagent 根 */
|
|
2493
|
+
let mainDir: string
|
|
2494
|
+
const SLUG = '--demo-cwd--'
|
|
2495
|
+
// uuidv7 风格形态(find 输出的完整 id 即此形态;u12 索引按 header id,不依赖文件名反推)
|
|
2496
|
+
const mainId = (n: number) => `019e6c96-aaaa-bbbb-cccc-d${String(n).padStart(11, '0')}`
|
|
2497
|
+
const subId = (n: number) => `019e6c96-aaaa-bbbb-cccc-e${String(n).padStart(11, '0')}`
|
|
2498
|
+
|
|
2499
|
+
/**
|
|
2500
|
+
* 写多 turn session(链式 parentId——buildTreeView leaf 回溯需要,无链的后行 entry
|
|
2501
|
+
* 会被当旁支过滤,MF-5 段同款教训)。turn 分段:user 开新 turn,assistant 并入当前。
|
|
2502
|
+
*/
|
|
2503
|
+
async function writeSession(
|
|
2504
|
+
id: string,
|
|
2505
|
+
turns: Array<{ role: 'user' | 'assistant'; text: string }>,
|
|
2506
|
+
opts?: { subagent?: boolean },
|
|
2507
|
+
): Promise<void> {
|
|
2508
|
+
const dir = opts?.subagent
|
|
2509
|
+
? join(tmp, 'agent', 'subagents', SLUG, 'sessions')
|
|
2510
|
+
: join(tmp, 'agent', 'sessions')
|
|
2511
|
+
await mkdir(dir, { recursive: true })
|
|
2512
|
+
const lines = [JSON.stringify({ type: 'session', id, cwd: '/demo' })]
|
|
2513
|
+
turns.forEach((t, i) => {
|
|
2514
|
+
lines.push(
|
|
2515
|
+
JSON.stringify({
|
|
2516
|
+
type: 'message',
|
|
2517
|
+
id: `${id}-m${i}`,
|
|
2518
|
+
parentId: i === 0 ? id : `${id}-m${i - 1}`,
|
|
2519
|
+
message: { role: t.role, content: [{ type: 'text', text: t.text }] },
|
|
2520
|
+
}),
|
|
2521
|
+
)
|
|
2522
|
+
})
|
|
2523
|
+
await writeFile(join(dir, `${id}.jsonl`), lines.join('\n') + '\n')
|
|
2524
|
+
}
|
|
2525
|
+
|
|
2526
|
+
/** 造大文件:追加一条 2KB filler 文本(字节上限用例用,pattern 避开 filler 字符)。 */
|
|
2527
|
+
async function writeFatSession(id: string): Promise<void> {
|
|
2528
|
+
await writeSession(id, [
|
|
2529
|
+
{ role: 'user', text: `filler ${'x'.repeat(2048)}` },
|
|
2530
|
+
{ role: 'assistant', text: 'done' },
|
|
2531
|
+
])
|
|
2532
|
+
}
|
|
2533
|
+
|
|
2534
|
+
interface CrossDetails {
|
|
2535
|
+
rejected?: boolean
|
|
2536
|
+
candidateCount?: number
|
|
2537
|
+
pattern: string
|
|
2538
|
+
scope: string
|
|
2539
|
+
degraded: boolean
|
|
2540
|
+
byteBudget: number
|
|
2541
|
+
scannedBytes: number
|
|
2542
|
+
scanned: Array<{
|
|
2543
|
+
sessionId: string
|
|
2544
|
+
source: string
|
|
2545
|
+
name?: string
|
|
2546
|
+
hits: Array<{ turnIndex: number; entryIndex: number; role: string; matchSnippet: string }>
|
|
2547
|
+
hitsTotal?: number
|
|
2548
|
+
}>
|
|
2549
|
+
skipped: Array<{ sessionId: string; reason: string; sizeBytes?: number }>
|
|
2550
|
+
truncated: boolean
|
|
2551
|
+
}
|
|
2552
|
+
|
|
2553
|
+
beforeEach(async () => {
|
|
2554
|
+
tmp = await mkdtemp(join(tmpdir(), 'tool-handler-u12-'))
|
|
2555
|
+
mainDir = join(tmp, 'agent', 'sessions')
|
|
2556
|
+
})
|
|
2557
|
+
afterEach(async () => {
|
|
2558
|
+
await rm(tmp, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
2559
|
+
})
|
|
2560
|
+
|
|
2561
|
+
it('① 窄化拒绝:候选超上限 → 拒绝文案 + 先 find 指引,不深扫(V8)', async () => {
|
|
2562
|
+
// 库中放一个真实可命中的 session——拒绝路径不得把它扫出来
|
|
2563
|
+
await writeSession(mainId(1), [
|
|
2564
|
+
{ role: 'user', text: '讨论 adj_factor 的历史数据' },
|
|
2565
|
+
{ role: 'assistant', text: 'adj_factor 表在 dws 库' },
|
|
2566
|
+
])
|
|
2567
|
+
const ids = Array.from({ length: MULTI_SEARCH_MAX_SESSIONS + 1 }, (_, i) => mainId(i + 10))
|
|
2568
|
+
const r = await handleSessionRead(
|
|
2569
|
+
{ action: 'search', session: ids.join(','), pattern: 'adj_factor' },
|
|
2570
|
+
join(tmp, 'agent'),
|
|
2571
|
+
)
|
|
2572
|
+
const text = r.content[0].text
|
|
2573
|
+
expect(text).toContain('跨会话检索已拒绝')
|
|
2574
|
+
expect(text).toContain(`超出单次上限 ${MULTI_SEARCH_MAX_SESSIONS} 个`)
|
|
2575
|
+
expect(text).toContain('先窄化')
|
|
2576
|
+
expect(text).toContain('action:"find"')
|
|
2577
|
+
expect(text).toContain('action:"search", session:"<id1>,<id2>"')
|
|
2578
|
+
const d = r.details as Partial<CrossDetails>
|
|
2579
|
+
expect(d.rejected).toBe(true)
|
|
2580
|
+
expect(d.candidateCount).toBe(MULTI_SEARCH_MAX_SESSIONS + 1)
|
|
2581
|
+
// 不深扫:可命中内容与命中结构都不出现
|
|
2582
|
+
expect(text).not.toContain('adj_factor 表在 dws 库')
|
|
2583
|
+
expect(text).not.toContain('hits:')
|
|
2584
|
+
expect(d.scanned).toBeUndefined()
|
|
2585
|
+
})
|
|
2586
|
+
|
|
2587
|
+
it('②a 字节上限截断:预算按序消耗,放不下的文件起整段停止并报告已扫范围', async () => {
|
|
2588
|
+
const A = mainId(1)
|
|
2589
|
+
const B = mainId(2)
|
|
2590
|
+
await writeSession(A, [
|
|
2591
|
+
{ role: 'user', text: 'small session' },
|
|
2592
|
+
{ role: 'assistant', text: 'ok' },
|
|
2593
|
+
])
|
|
2594
|
+
await writeFatSession(B)
|
|
2595
|
+
// 直接调导出函数注入小预算(工具路径走默认 64MB,fixture 不构造 64MB 文件)
|
|
2596
|
+
const r = await searchAcrossSessions([A, B], 'zzzkw', { agentDir: join(tmp, 'agent') }, {
|
|
2597
|
+
byteBudget: 1024,
|
|
2598
|
+
})
|
|
2599
|
+
const d = r.details as CrossDetails
|
|
2600
|
+
// A 扫完(无命中也入 scanned),B 放不下 → byte-budget,且其 sizeBytes 有报告
|
|
2601
|
+
expect(d.scanned).toHaveLength(1)
|
|
2602
|
+
expect(d.scanned[0].sessionId).toBe(A)
|
|
2603
|
+
expect(d.scannedBytes).toBeGreaterThan(0)
|
|
2604
|
+
expect(d.scannedBytes).toBeLessThanOrEqual(1024)
|
|
2605
|
+
expect(d.skipped).toHaveLength(1)
|
|
2606
|
+
expect(d.skipped[0]).toMatchObject({ sessionId: B, reason: 'byte-budget' })
|
|
2607
|
+
expect(d.skipped[0].sizeBytes).toBeGreaterThan(1024)
|
|
2608
|
+
const text = r.content[0].text
|
|
2609
|
+
expect(text).toContain('not scanned')
|
|
2610
|
+
expect(text).toContain(B)
|
|
2611
|
+
expect(text).toContain(A)
|
|
2612
|
+
// 未扫 id 附单独检索指引
|
|
2613
|
+
expect(text).toContain(`action:"search", session:"${B}", pattern:"zzzkw"`)
|
|
2614
|
+
})
|
|
2615
|
+
|
|
2616
|
+
it('②b 首个文件即超预算 → 0 扫描,整段未扫报告(不突破上限)', async () => {
|
|
2617
|
+
const B = mainId(2)
|
|
2618
|
+
await writeFatSession(B)
|
|
2619
|
+
const r = await searchAcrossSessions([B], 'zzzkw', { agentDir: join(tmp, 'agent') }, {
|
|
2620
|
+
byteBudget: 512,
|
|
2621
|
+
})
|
|
2622
|
+
const d = r.details as CrossDetails
|
|
2623
|
+
expect(d.scanned).toHaveLength(0)
|
|
2624
|
+
expect(d.scannedBytes).toBe(0)
|
|
2625
|
+
expect(d.skipped).toEqual([{ sessionId: B, reason: 'byte-budget', sizeBytes: expect.any(Number) }])
|
|
2626
|
+
expect(r.content[0].text).toContain('not scanned')
|
|
2627
|
+
})
|
|
2628
|
+
|
|
2629
|
+
it('③ 命中渲染:session 列表(完整 id+source+标题)+ turn 索引/角色/片段 + 可执行调用串', async () => {
|
|
2630
|
+
const A = mainId(1)
|
|
2631
|
+
const B = subId(1)
|
|
2632
|
+
const C = mainId(2)
|
|
2633
|
+
// A:T000 两命中(user+assistant)+ T001 一命中(assistant)
|
|
2634
|
+
await writeSession(A, [
|
|
2635
|
+
{ role: 'user', text: '查询 adj_factor 的历史数据' },
|
|
2636
|
+
{ role: 'assistant', text: 'adj_factor 表在 dws 库' },
|
|
2637
|
+
{ role: 'user', text: '换一个话题' },
|
|
2638
|
+
{ role: 'assistant', text: '再看 adj_factor 的最新分区' },
|
|
2639
|
+
])
|
|
2640
|
+
await writeSession(B, [
|
|
2641
|
+
{ role: 'user', text: 'subagent task 无关键词' },
|
|
2642
|
+
{ role: 'assistant', text: '结果里含 adj_factor 字样' },
|
|
2643
|
+
], { subagent: true })
|
|
2644
|
+
await writeSession(C, [
|
|
2645
|
+
{ role: 'user', text: '完全没有关键词的内容' },
|
|
2646
|
+
])
|
|
2647
|
+
const provider: SessionMetadataProvider = async (dir) =>
|
|
2648
|
+
dir === mainDir
|
|
2649
|
+
? [{ path: join(mainDir, `${A}.jsonl`), id: A, cwd: '/demo', name: '太保寿险深度研究', modified: new Date() }]
|
|
2650
|
+
: []
|
|
2651
|
+
const r = await handleSessionRead(
|
|
2652
|
+
{ action: 'search', session: `${A},${B},${C}`, pattern: 'adj_factor' },
|
|
2653
|
+
{ agentDir: join(tmp, 'agent'), liveSessionDir: mainDir },
|
|
2654
|
+
undefined,
|
|
2655
|
+
provider,
|
|
2656
|
+
)
|
|
2657
|
+
const text = r.content[0].text
|
|
2658
|
+
// header:命中/已扫计数 + 默认预算标注(handleSessionRead 路径走 SEARCH_SCAN_BYTE_BUDGET,
|
|
2659
|
+
// formatScanBytes 的 MB 形态恒 1 位小数)
|
|
2660
|
+
expect(text).toContain('2/3 session(s) hit for /adj_factor/')
|
|
2661
|
+
expect(text).toContain(`${(SEARCH_SCAN_BYTE_BUDGET / (1024 * 1024)).toFixed(1)}MB budget`)
|
|
2662
|
+
// 命中块:完整 id + source + 标题(provider 注入)+ 命中数,列表序 = 传入序
|
|
2663
|
+
expect(text).toContain(`1. ${A} · main · 太保寿险深度研究(3 hits)`)
|
|
2664
|
+
expect(text).toContain(`2. ${B} · subagent(1 hit)`)
|
|
2665
|
+
// turn 索引 + entry 序号 + 角色 + 片段
|
|
2666
|
+
expect(text).toContain(' T000 #0 user: ')
|
|
2667
|
+
expect(text).toContain(' T000 #1 assistant: ')
|
|
2668
|
+
expect(text).toContain(' T001 #1 assistant: ')
|
|
2669
|
+
// 可直接执行的调用串(真实参数 = pattern)
|
|
2670
|
+
expect(text).toContain(`↳ session_read { action:"search", session:"${A}", pattern:"adj_factor" }`)
|
|
2671
|
+
expect(text).toContain(`↳ session_read { action:"search", session:"${B}", pattern:"adj_factor" }`)
|
|
2672
|
+
// 已扫无命中段
|
|
2673
|
+
expect(text).toContain('scanned, no hit:')
|
|
2674
|
+
expect(text).toContain(`${C} · main`)
|
|
2675
|
+
const d = r.details as CrossDetails
|
|
2676
|
+
expect(d.rejected).toBeUndefined()
|
|
2677
|
+
expect(d.truncated).toBe(false)
|
|
2678
|
+
expect(d.degraded).toBe(false)
|
|
2679
|
+
expect(d.byteBudget).toBe(SEARCH_SCAN_BYTE_BUDGET)
|
|
2680
|
+
expect(d.skipped).toEqual([])
|
|
2681
|
+
expect(d.scanned.map((s) => s.sessionId)).toEqual([A, B, C])
|
|
2682
|
+
expect(d.scanned[0].hits).toHaveLength(3)
|
|
2683
|
+
expect(d.scanned[0].name).toBe('太保寿险深度研究')
|
|
2684
|
+
expect(d.scanned[1].hits).toHaveLength(1)
|
|
2685
|
+
// B 只有一条 user → 单 turn,assistant 命中在 T000 #1
|
|
2686
|
+
expect(d.scanned[1].hits[0]).toMatchObject({
|
|
2687
|
+
turnIndex: 0,
|
|
2688
|
+
entryIndex: 1,
|
|
2689
|
+
role: 'assistant',
|
|
2690
|
+
})
|
|
2691
|
+
expect(d.scanned[2].hits).toHaveLength(0)
|
|
2692
|
+
})
|
|
2693
|
+
|
|
2694
|
+
it('④ 无命中形态 + not-found 报告(不在库中的 id 不中断整体)', async () => {
|
|
2695
|
+
const A = mainId(1)
|
|
2696
|
+
const GHOST = mainId(99)
|
|
2697
|
+
await writeSession(A, [{ role: 'user', text: '普通内容' }])
|
|
2698
|
+
const r = await handleSessionRead(
|
|
2699
|
+
{ action: 'search', session: `${A},${GHOST}`, pattern: 'zzznothing' },
|
|
2700
|
+
join(tmp, 'agent'),
|
|
2701
|
+
)
|
|
2702
|
+
const text = r.content[0].text
|
|
2703
|
+
expect(text).toContain('0/1 session(s) hit for /zzznothing/')
|
|
2704
|
+
expect(text).toContain('scanned, no hit:')
|
|
2705
|
+
expect(text).toContain(`${A} · main`)
|
|
2706
|
+
expect(text).toContain('换更精确 pattern')
|
|
2707
|
+
expect(text).toContain('not found in library')
|
|
2708
|
+
expect(text).toContain(`- ${GHOST}`)
|
|
2709
|
+
const d = r.details as CrossDetails
|
|
2710
|
+
expect(d.scanned).toHaveLength(1)
|
|
2711
|
+
expect(d.scanned[0].hits).toHaveLength(0)
|
|
2712
|
+
expect(d.skipped).toEqual([{ sessionId: GHOST, reason: 'not-found' }])
|
|
2713
|
+
})
|
|
2714
|
+
|
|
2715
|
+
it('⑤ 单会话语义无回归:无逗号 session 走原路径(hits 结构),pattern 含逗号不误分流', async () => {
|
|
2716
|
+
const A = mainId(1)
|
|
2717
|
+
await writeSession(A, [
|
|
2718
|
+
{ role: 'user', text: 'aaa plugin 内容' },
|
|
2719
|
+
{ role: 'assistant', text: 'bbb' },
|
|
2720
|
+
])
|
|
2721
|
+
// 单 id:原 details.hits 结构与 header 形态不变
|
|
2722
|
+
const r = await handleSessionRead(
|
|
2723
|
+
{ action: 'search', session: A, pattern: 'plugin' },
|
|
2724
|
+
join(tmp, 'agent'),
|
|
2725
|
+
)
|
|
2726
|
+
expect(r.content[0].text).toContain('1 hit(s) for /plugin/')
|
|
2727
|
+
const d = r.details as { hits: unknown[]; truncated: boolean; scanned?: unknown }
|
|
2728
|
+
expect(d.hits).toHaveLength(1)
|
|
2729
|
+
expect(d.truncated).toBe(false)
|
|
2730
|
+
expect(d.scanned).toBeUndefined()
|
|
2731
|
+
// pattern 含逗号不触发跨会话(分流只看 session)
|
|
2732
|
+
const r2 = await handleSessionRead(
|
|
2733
|
+
{ action: 'search', session: A, pattern: 'aaa,bbb' },
|
|
2734
|
+
join(tmp, 'agent'),
|
|
2735
|
+
)
|
|
2736
|
+
expect((r2.details as { hits: unknown[] }).hits).toHaveLength(0)
|
|
2737
|
+
expect((r2.details as { scanned?: unknown }).scanned).toBeUndefined()
|
|
2738
|
+
expect(r2.content[0].text).not.toContain('session(s) hit')
|
|
2739
|
+
})
|
|
2740
|
+
|
|
2741
|
+
it('⑥ per-session limit 截断:超限 session 标注 >N hits,truncated=true', async () => {
|
|
2742
|
+
const A = mainId(1)
|
|
2743
|
+
await writeSession(A, [
|
|
2744
|
+
{ role: 'user', text: 'kw one' },
|
|
2745
|
+
{ role: 'assistant', text: 'kw two' },
|
|
2746
|
+
{ role: 'user', text: 'kw three' },
|
|
2747
|
+
])
|
|
2748
|
+
const r = await searchAcrossSessions([A], 'kw', { agentDir: join(tmp, 'agent') }, { limit: 2 })
|
|
2749
|
+
const d = r.details as CrossDetails
|
|
2750
|
+
expect(d.truncated).toBe(true)
|
|
2751
|
+
expect(d.scanned[0].hits).toHaveLength(2)
|
|
2752
|
+
expect(d.scanned[0].hitsTotal).toBe(3)
|
|
2753
|
+
expect(r.content[0].text).toContain('(>3 hits, showing first 2)')
|
|
2754
|
+
})
|
|
2755
|
+
|
|
2756
|
+
it('⑦ abort 信号透传:跨会话扫描中断抛错(与单会话 MF-5 语义一致)', async () => {
|
|
2757
|
+
const A = mainId(1)
|
|
2758
|
+
await writeSession(A, [{ role: 'user', text: 'content' }])
|
|
2759
|
+
const ac = new AbortController()
|
|
2760
|
+
ac.abort()
|
|
2761
|
+
await expect(
|
|
2762
|
+
searchAcrossSessions([A], 'kw', { agentDir: join(tmp, 'agent') }, { signal: ac.signal }),
|
|
2763
|
+
).rejects.toThrow(/中断/)
|
|
2764
|
+
})
|
|
2765
|
+
})
|