@zhushanwen/pi-session-reader 0.3.1 → 0.4.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.
@@ -0,0 +1,341 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
+ import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import {
6
+ handleSessionRead,
7
+ extractFinalAssistantText,
8
+ type SessionReadParams,
9
+ } from '../tool-handler.js'
10
+
11
+ /**
12
+ * U6 result action 测试(design subagent-sync-collect §3.1.3 + impl-plan U6)。
13
+ *
14
+ * 覆盖(任务验收用例清单):
15
+ * 1. 单 id 取回正文与预期一致(sa-id manifest 反查,同源语义:无包装纯正文)
16
+ * 2. 批量 ≤10(3 id,头行 + 分隔 + 顺序保持)
17
+ * 3. 批量 >10 拒绝(文案含上限 10)
18
+ * 4. limit 截断 + 尾提示(默认 8000 + 显式 limit)
19
+ * 5. 不存在 id 报错(sa-id / uuid 片段两路)
20
+ * 6. 空 assistant 报错(文件无 assistant 消息)
21
+ * 7. 文件尚未 flush 报错(manifest 有、sessionFile 不存在——pi 延迟写入)
22
+ *
23
+ * 红线:vitest;fixture 全部 mkdtemp(tmpdir) 自建自删,零真实数据目录触碰。
24
+ * extractFinalAssistantText 纯函数白盒:同源重建规则锁定(与 record.result 逐字节
25
+ * 一致的前置,A4 取回门)。
26
+ */
27
+
28
+ // ---- fixture 工具(合成 subagent session,同 tool-handler.test.ts makeFixtureSubagent 结构)----
29
+
30
+ const SLUG = '--demo-cwd--'
31
+
32
+ interface SubagentFixture {
33
+ saId: string
34
+ realId: string
35
+ /** header + user 任务之后追加的 entries(assistant 消息等) */
36
+ entries?: unknown[]
37
+ /** false = 只写 manifest 不写 session 文件(模拟 pi 延迟写入未 flush / GC) */
38
+ sessionFileExists?: boolean
39
+ }
40
+
41
+ function assistantEntry(id: string, content: unknown): unknown {
42
+ return {
43
+ type: 'message',
44
+ id,
45
+ parentId: 'p',
46
+ message: { role: 'assistant', content },
47
+ }
48
+ }
49
+
50
+ async function makeSubagent(dir: string, f: SubagentFixture): Promise<string> {
51
+ const sessionFile = join(dir, 'subagents', SLUG, 'sessions', `${f.realId}.jsonl`)
52
+ if (f.sessionFileExists !== false) {
53
+ await mkdir(join(dir, 'subagents', SLUG, 'sessions'), { recursive: true })
54
+ const header = JSON.stringify({ type: 'session', id: f.realId, cwd: '/demo' })
55
+ const task = JSON.stringify({
56
+ type: 'message',
57
+ id: `${f.realId}-u1`,
58
+ parentId: f.realId,
59
+ message: { role: 'user', content: [{ type: 'text', text: 'do the task' }] },
60
+ })
61
+ const lines = [header, task, ...(f.entries ?? []).map((e) => JSON.stringify(e))]
62
+ await writeFile(sessionFile, lines.join('\n') + '\n')
63
+ }
64
+ const recordsDir = join(dir, 'subagents', SLUG, 'records')
65
+ await mkdir(recordsDir, { recursive: true })
66
+ await writeFile(
67
+ join(recordsDir, `${f.saId}.json`),
68
+ JSON.stringify({
69
+ id: f.saId,
70
+ rootSessionId: 'root-session-1',
71
+ agentName: 'explorer',
72
+ sessionFile,
73
+ }),
74
+ )
75
+ return sessionFile
76
+ }
77
+
78
+ // ---- extractFinalAssistantText 纯函数白盒(同源重建规则) ----
79
+
80
+ describe('extractFinalAssistantText(同源语义白盒)', () => {
81
+ it('message 内多 text 块无分隔拼接(text_delta 直累积同构);thinking/toolCall 排除', () => {
82
+ const text = extractFinalAssistantText([
83
+ assistantEntry('a1', [
84
+ { type: 'thinking', thinking: 'should not appear' },
85
+ { type: 'text', text: 'AB' },
86
+ { type: 'text', text: 'CD' },
87
+ { type: 'toolCall', toolCallId: 'tc1', name: 'bash' },
88
+ ]),
89
+ ])
90
+ expect(text).toBe('ABCD')
91
+ })
92
+
93
+ it('跨 assistant message 非空过滤 join("\\n\\n")(getFullText 同构);字符串 content 直取', () => {
94
+ const text = extractFinalAssistantText([
95
+ assistantEntry('a1', [{ type: 'text', text: 'first' }]),
96
+ assistantEntry('a2', []), // 空 content → 过滤
97
+ assistantEntry('a3', 'plain string content'), // 字符串形态
98
+ ])
99
+ expect(text).toBe('first\n\nplain string content')
100
+ })
101
+
102
+ it('只取最后一条 user message 之后的 assistant 文本(多轮 session 对齐最终一轮)', () => {
103
+ const text = extractFinalAssistantText([
104
+ assistantEntry('r1', [{ type: 'text', text: 'round one' }]),
105
+ { type: 'message', id: 'u2', parentId: 'x', message: { role: 'user', content: [{ type: 'text', text: 'next round' }] } },
106
+ assistantEntry('r2', [{ type: 'text', text: 'round two' }]),
107
+ ])
108
+ expect(text).toBe('round two')
109
+ })
110
+
111
+ it('无 user message 时计入全部 assistant(防御形态)', () => {
112
+ const text = extractFinalAssistantText([
113
+ assistantEntry('a1', [{ type: 'text', text: 'solo' }]),
114
+ ])
115
+ expect(text).toBe('solo')
116
+ })
117
+
118
+ it('空 entries → 空串(空 assistant 报错路径的判定基础)', () => {
119
+ expect(extractFinalAssistantText([])).toBe('')
120
+ })
121
+ })
122
+
123
+ // ---- result action 黑盒(fixture:sa-id manifest 反查主路径) ----
124
+
125
+ describe('result action(U6)', () => {
126
+ let dir: string
127
+
128
+ beforeEach(async () => {
129
+ dir = await mkdtemp(join(tmpdir(), 'session-reader-result-'))
130
+ })
131
+ afterEach(async () => {
132
+ await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
133
+ })
134
+
135
+ it('1. 单 id:返回纯正文(无包装),details 带元数据(sessionId=header 真实 id)', async () => {
136
+ const realId = '019e6c96-dddd-eeee-ffff-000000000001'
137
+ await makeSubagent(dir, {
138
+ saId: 'sa-one',
139
+ realId,
140
+ entries: [
141
+ assistantEntry(`${realId}-a1`, [
142
+ { type: 'thinking', thinking: 'quiet thinking' },
143
+ { type: 'text', text: 'FINAL RESULT' },
144
+ { type: 'toolCall', toolCallId: 'tc1', name: 'read' },
145
+ ]),
146
+ {
147
+ type: 'message',
148
+ id: `${realId}-t1`,
149
+ parentId: `${realId}-a1`,
150
+ message: { role: 'toolResult', content: [{ type: 'text', text: 'file body' }], toolName: 'read', toolCallId: 'tc1' },
151
+ },
152
+ assistantEntry(`${realId}-a2`, [{ type: 'text', text: 'second part' }]),
153
+ ],
154
+ })
155
+ const r = await handleSessionRead({ action: 'result', session: 'sa-one' }, dir)
156
+ // 与 record.result 同源:全部 assistant 正文非空过滤 '\n\n' join,无任何包装
157
+ expect(r.content[0]?.text).toBe('FINAL RESULT\n\nsecond part')
158
+ const d = r.details as {
159
+ session: string
160
+ sessionId: string
161
+ sessionFile: string
162
+ totalChars: number
163
+ truncated: boolean
164
+ }
165
+ expect(d.session).toBe('sa-one')
166
+ expect(d.sessionId).toBe(realId) // header 真实 id,非 sa- 占位
167
+ expect(d.sessionFile).toContain(realId)
168
+ expect(d.totalChars).toBe('FINAL RESULT\n\nsecond part'.length)
169
+ expect(d.truncated).toBe(false)
170
+ })
171
+
172
+ it('2. 批量 ≤10:3 id 各带头行与正文,"---" 分隔,顺序与输入一致', async () => {
173
+ const ids = [
174
+ { saId: 'sa-b1', realId: '019e6c96-0000-0000-0000-000000000b01', text: 'alpha result' },
175
+ { saId: 'sa-b2', realId: '019e6c96-0000-0000-0000-000000000b02', text: 'beta result' },
176
+ { saId: 'sa-b3', realId: '019e6c96-0000-0000-0000-000000000b03', text: 'gamma result' },
177
+ ]
178
+ for (const f of ids) {
179
+ await makeSubagent(dir, {
180
+ saId: f.saId,
181
+ realId: f.realId,
182
+ entries: [assistantEntry(`${f.realId}-a1`, [{ type: 'text', text: f.text }])],
183
+ })
184
+ }
185
+ const r = await handleSessionRead(
186
+ { action: 'result', session: 'sa-b1,sa-b2,sa-b3' },
187
+ dir,
188
+ )
189
+ const text = r.content[0]?.text ?? ''
190
+ expect(text).toContain('[1/3] sa-b1')
191
+ expect(text).toContain('[2/3] sa-b2')
192
+ expect(text).toContain('[3/3] sa-b3')
193
+ expect(text).toContain('alpha result')
194
+ expect(text).toContain('beta result')
195
+ expect(text).toContain('gamma result')
196
+ // 顺序保持:alpha 头行位于 beta 头行之前
197
+ expect(text.indexOf('[1/3]')).toBeLessThan(text.indexOf('[2/3]'))
198
+ expect(text.indexOf('[2/3]')).toBeLessThan(text.indexOf('[3/3]'))
199
+ // 条目分隔(与批通知同款)
200
+ expect(text).toContain('\n\n---\n\n')
201
+ const d = r.details as { count: number; items: Array<{ session: string; truncated: boolean }> }
202
+ expect(d.count).toBe(3)
203
+ expect(d.items.map((i) => i.session)).toEqual(['sa-b1', 'sa-b2', 'sa-b3'])
204
+ expect(d.items.every((i) => !i.truncated)).toBe(true)
205
+ })
206
+
207
+ it('3. 批量 >10:11 id 拒绝,报错含上限 10 与分批指引(解析先于 fs,无需 fixture)', async () => {
208
+ const list = Array.from({ length: 11 }, (_, i) => `sa-x${i}`).join(',')
209
+ await expect(
210
+ handleSessionRead({ action: 'result', session: list }, dir),
211
+ ).rejects.toThrow(/最多 10 个/)
212
+ try {
213
+ await handleSessionRead({ action: 'result', session: list }, dir)
214
+ } catch (e) {
215
+ const msg = (e as Error).message
216
+ expect(msg).toContain('11')
217
+ expect(msg).toContain('👉')
218
+ }
219
+ })
220
+
221
+ it('4. limit 默认 8000:8001 字符正文截断到 8000 + 尾提示(读原文件指引)', async () => {
222
+ const realId = '019e6c96-0000-0000-0000-000000000c01'
223
+ const longText = 'x'.repeat(8001)
224
+ const sessionFile = await makeSubagent(dir, {
225
+ saId: 'sa-long',
226
+ realId,
227
+ entries: [assistantEntry(`${realId}-a1`, [{ type: 'text', text: longText }])],
228
+ })
229
+ const r = await handleSessionRead({ action: 'result', session: 'sa-long' }, dir)
230
+ const text = r.content[0]?.text ?? ''
231
+ // 截断到 8000:第 8001 个 x 不在正文,但提示行给出计数
232
+ expect(text.startsWith('x'.repeat(8000))).toBe(true)
233
+ expect(text).not.toBe(longText)
234
+ expect(text).toContain('[truncated 8000 of 8001 chars')
235
+ expect(text).toContain(`read ${sessionFile}`)
236
+ expect(text).toContain('session_read { action:"detail", session:"sa-long" }')
237
+ const d = r.details as { totalChars: number; truncated: boolean; sessionFile: string }
238
+ expect(d.totalChars).toBe(8001)
239
+ expect(d.truncated).toBe(true)
240
+ expect(d.sessionFile).toBe(sessionFile)
241
+ })
242
+
243
+ it('5. 显式 limit:5 字符截断 + 提示;limit 恰等长度不截断;非法 limit 报错', async () => {
244
+ const realId = '019e6c96-0000-0000-0000-000000000c02'
245
+ await makeSubagent(dir, {
246
+ saId: 'sa-lim',
247
+ realId,
248
+ entries: [assistantEntry(`${realId}-a1`, [{ type: 'text', text: 'abcdefghij' }])],
249
+ })
250
+ const cut = await handleSessionRead(
251
+ { action: 'result', session: 'sa-lim', limit: 5 },
252
+ dir,
253
+ )
254
+ expect(cut.content[0]?.text).toBe(
255
+ 'abcde\n\n[truncated 5 of 10 chars — full text: read ' +
256
+ `${join(dir, 'subagents', SLUG, 'sessions', `${realId}.jsonl`)}` +
257
+ ', or session_read { action:"detail", session:"sa-lim" }]',
258
+ )
259
+ const exact = await handleSessionRead(
260
+ { action: 'result', session: 'sa-lim', limit: 10 },
261
+ dir,
262
+ )
263
+ expect(exact.content[0]?.text).toBe('abcdefghij')
264
+ const d = exact.details as { truncated: boolean }
265
+ expect(d.truncated).toBe(false)
266
+ for (const bad of [0, -1, Number.NaN]) {
267
+ await expect(
268
+ handleSessionRead({ action: 'result', session: 'sa-lim', limit: bad }, dir),
269
+ ).rejects.toThrow(/limit.*无效/)
270
+ }
271
+ })
272
+
273
+ it('6. 不存在 id:sa-id 无 manifest → 无匹配 record;uuid 片段零匹配 → 无匹配 session', async () => {
274
+ await expect(
275
+ handleSessionRead({ action: 'result', session: 'sa-nonexist-9999' }, dir),
276
+ ).rejects.toThrow(/无匹配 record/)
277
+ try {
278
+ await handleSessionRead({ action: 'result', session: 'sa-nonexist-9999' }, dir)
279
+ } catch (e) {
280
+ const msg = (e as Error).message
281
+ expect(msg).toContain('👉')
282
+ expect(msg).toContain('family')
283
+ }
284
+ await expect(
285
+ handleSessionRead({ action: 'result', session: 'zzz-nonexistent-9q8x2' }, dir),
286
+ ).rejects.toThrow(/无匹配 session/)
287
+ })
288
+
289
+ it('7. 文件尚未 flush:manifest 有、sessionFile 不存在 → 明确报错(GC/未写入)', async () => {
290
+ await makeSubagent(dir, {
291
+ saId: 'sa-flush',
292
+ realId: '019e6c96-0000-0000-0000-000000000e01',
293
+ sessionFileExists: false,
294
+ })
295
+ await expect(
296
+ handleSessionRead({ action: 'result', session: 'sa-flush' }, dir),
297
+ ).rejects.toThrow(/session 文件不存在/)
298
+ try {
299
+ await handleSessionRead({ action: 'result', session: 'sa-flush' }, dir)
300
+ } catch (e) {
301
+ const msg = (e as Error).message
302
+ expect(msg).toContain('未写入')
303
+ expect(msg).toContain('👉')
304
+ }
305
+ })
306
+
307
+ it('8. 空 assistant:文件只有 header + user 任务 → 尚无 assistant 输出报错', async () => {
308
+ await makeSubagent(dir, {
309
+ saId: 'sa-empty',
310
+ realId: '019e6c96-0000-0000-0000-000000000a01',
311
+ })
312
+ await expect(
313
+ handleSessionRead({ action: 'result', session: 'sa-empty' }, dir),
314
+ ).rejects.toThrow(/尚无 assistant 输出/)
315
+ try {
316
+ await handleSessionRead({ action: 'result', session: 'sa-empty' }, dir)
317
+ } catch (e) {
318
+ const msg = (e as Error).message
319
+ expect(msg).toContain('sa-empty')
320
+ expect(msg).toContain('👉')
321
+ }
322
+ })
323
+
324
+ it('9. 参数防御:缺 session / 空条目(尾逗号)均报错', async () => {
325
+ await expect(
326
+ handleSessionRead({ action: 'result' } as SessionReadParams, dir),
327
+ ).rejects.toThrow(/需要参数 "session"/)
328
+ await expect(
329
+ handleSessionRead({ action: 'result', session: 'sa-a,' }, dir),
330
+ ).rejects.toThrow(/空条目/)
331
+ })
332
+
333
+ it('10. 未知 action 报错文案含 result(10 action 全列举)', async () => {
334
+ await expect(
335
+ handleSessionRead(
336
+ { action: 'bogus' } as unknown as SessionReadParams,
337
+ dir,
338
+ ),
339
+ ).rejects.toThrow(/find\/family\/outline\/expand\/detail\/search\/export\/extract\/workflow\/result/)
340
+ })
341
+ })
@@ -12,7 +12,7 @@ describe('listMainSessions', () => {
12
12
  dir = await mkdtemp(join(tmpdir(), 'roots-test-'))
13
13
  })
14
14
  afterEach(async () => {
15
- await rm(dir, { recursive: true, force: true })
15
+ await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
16
16
  })
17
17
 
18
18
  it('扫描 sessions/<slug>/*.jsonl,排除 *.jsonl.finalized', async () => {
@@ -90,7 +90,7 @@ describe('listSubagentSessions', () => {
90
90
  dir = await mkdtemp(join(tmpdir(), 'roots-sub-test-'))
91
91
  })
92
92
  afterEach(async () => {
93
- await rm(dir, { recursive: true, force: true })
93
+ await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
94
94
  })
95
95
 
96
96
  it('扫描 subagents/<slug>/sessions/*.jsonl,排除 .finalized', async () => {
@@ -72,7 +72,7 @@ describe('createSessionCommand - getArgumentCompletions', () => {
72
72
  await mkdir(cwdSessionDir, { recursive: true })
73
73
  })
74
74
  afterEach(async () => {
75
- await rm(agentDir, { recursive: true, force: true })
75
+ await rm(agentDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
76
76
  })
77
77
 
78
78
  it('空参数 → recent 列表(value=完整 uuid 剥 #,label 含预览)', async () => {
@@ -129,7 +129,7 @@ describe('createSessionCommand - handler select 流程', () => {
129
129
  await mkdir(cwdSessionDir, { recursive: true })
130
130
  })
131
131
  afterEach(async () => {
132
- await rm(agentDir, { recursive: true, force: true })
132
+ await rm(agentDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
133
133
  })
134
134
 
135
135
  it('选中 label → setEditorText(#完整 uuid + 尾随空格)(value 语义保持完整 uuid)', async () => {
@@ -196,7 +196,7 @@ describe('buildFamilyFromFs - fixture', () => {
196
196
  dir = await makeAgentDir()
197
197
  })
198
198
  afterEach(async () => {
199
- await rm(dir, { recursive: true, force: true })
199
+ await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
200
200
  })
201
201
 
202
202
  it('基础 family:root + fork + 隔代 subagent,SubagentRef.sessionId 是真实 id(非 sa-xxx)', async () => {
@@ -514,7 +514,7 @@ describe('listRecordManifests 导出(w2 TC1)', () => {
514
514
  dir = await makeAgentDir()
515
515
  })
516
516
  afterEach(async () => {
517
- await rm(dir, { recursive: true, force: true })
517
+ await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
518
518
  })
519
519
 
520
520
  it('导出生效:import + 调用返回 RecordManifest[],字段完整', async () => {
@@ -545,7 +545,7 @@ describe('U4 buildFamilyFromFs 富化(manifest 主 / P-fallback)', () => {
545
545
  dir = await makeAgentDir()
546
546
  })
547
547
  afterEach(async () => {
548
- await rm(dir, { recursive: true, force: true })
548
+ await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
549
549
  })
550
550
 
551
551
  it('TC-u4-manifest-enrich: alive + manifest 全字段 → SubagentRef 富字段全透传', async () => {
@@ -20,7 +20,8 @@ import { resolveWorkflows } from './workflows.js'
20
20
  * M2 用 subagent 文件首行 header.id(真实 session id)替换之,使 SubagentRef.sessionId
21
21
  * 是真实 id,Q1 隔代关联仍用 data.rootSessionId(不变)。
22
22
  * - cleanedUp:subagent 文件被 30 天 TTL GC 后,identity(在文件尾)一并消失。唯一残留痕迹
23
- * 是 records/<sa-id>.json manifest(subagent 创建时写入,持久存在)。故 manifest 是孤儿/
23
+ * 是 records/<sa-id>.json manifest(终态 finalize 或 sync 批落标出口写入——subagent
24
+ * 创建时不写,两写点均先于 30 天 GC 落盘,故 GC 后必残留)。manifest 是孤儿/
24
25
  * cleanedUp 的来源(design §3.3 D-7 "records/*.json manifest 作孤儿补充")。
25
26
  * - workflows:M1 resolveFamily 恒返回 []。M2 在此单独读目标 session 的 workflow-state-link
26
27
  * custom entry → link.data.path(wf-state 文件绝对路径)→ 从文件尾向头找首个非空且
@@ -195,7 +196,8 @@ async function collectSubagentIdentities(
195
196
 
196
197
  /**
197
198
  * 步骤 3:records manifest → 孤儿(cleanedUp,ES-orphan-manifest)。
198
- * manifest 在 subagent 创建时写入,.jsonl 被 GC 后仍残留。alive 的(sessionFile 已在步骤 2
199
+ * manifest 由终态 finalize / sync 批落标出口写入(创建时不写),.jsonl 被 GC 后仍
200
+ * 残留。alive 的(sessionFile 已在步骤 2
199
201
  * 扫到)跳过;未扫到的 = 文件已 GC → 孤儿。用 manifest 完整富字段填 SubagentRef,
200
202
  * sessionFile 保留 manifest 的 GC 路径(不置空,供 LLM 知晓原位置),cleanedUp 由
201
203
  * buildFamilyIndex 的 !fileStats.has(ident.id) 判 true(ident.id=manifest.id 不在 fileStats)。
@@ -383,7 +385,7 @@ export interface RecordManifest {
383
385
  id: string
384
386
  rootSessionId: string
385
387
  agentName?: string
386
- /** subagent session.jsonl 绝对路径(创建时写入;文件 GC 后路径仍残留) */
388
+ /** subagent session.jsonl 绝对路径(manifest 写入时快照;文件 GC 后路径仍残留) */
387
389
  sessionFile: string
388
390
  /** subagent 任务文本(探针 20/20 全有;旧 manifest 缺→undefined) */
389
391
  task?: string
@@ -425,8 +427,8 @@ async function tryReadManifest(path: string): Promise<RecordManifest | undefined
425
427
 
426
428
  /**
427
429
  * 扫描 subagents/<cwdSlug>/records/*.json —— subagent 注册清单。
428
- * 每个 manifest 在 subagent 创建时写入,持久存在即使 .jsonl 被 GC。坏 manifest(缺必填字段
429
- * /JSON 损坏)跳过,不中断扫描。
430
+ * 每个 manifest 由终态 finalize 或 sync 批落标出口写入(创建时不写),持久存在即使
431
+ * .jsonl 被 GC。坏 manifest(缺必填字段/JSON 损坏)跳过,不中断扫描。
430
432
  */
431
433
  export async function listRecordManifests(agentDir: string): Promise<RecordManifest[]> {
432
434
  const root = join(agentDir, 'subagents')
package/src/index.ts CHANGED
@@ -21,16 +21,27 @@ import { createSessionCommand } from './tui/session-command.js'
21
21
 
22
22
  const SessionReadSchema = Type.Object({
23
23
  action: StringEnum(
24
- ['find', 'family', 'outline', 'expand', 'detail', 'search', 'export', 'extract', 'workflow'],
24
+ [
25
+ 'find',
26
+ 'family',
27
+ 'outline',
28
+ 'expand',
29
+ 'detail',
30
+ 'search',
31
+ 'export',
32
+ 'extract',
33
+ 'workflow',
34
+ 'result',
35
+ ],
25
36
  {
26
37
  description:
27
- 'Action to perform: find (locate session), family (fork/subagent/workflow relations; recursive=true returns nested execution tree), outline (turn-level overview), expand (single-turn entries), detail (full text of turns), search (full-text grep), export (materialize to file), extract (pull user messages / commands / files / commits / tool results by type), workflow (workflow run overview: status/budget/steps; requires session, optional runId focuses one run; step call sessionId jumps to outline/detail).',
38
+ 'Action to perform: find (locate session), family (fork/subagent/workflow relations; recursive=true returns nested execution tree), outline (turn-level overview), expand (single-turn entries), detail (full text of turns), search (full-text grep), export (materialize to file), extract (pull user messages / commands / files / commits / tool results by type), workflow (workflow run overview: status/budget/steps; requires session, optional runId focuses one run; step call sessionId jumps to outline/detail), result (fetch a subagent session final result text — same content as its completion notice; session = single id or comma-separated batch of at most 10, optional limit caps chars per item).',
28
39
  },
29
40
  ),
30
41
  session: Type.Optional(
31
42
  Type.String({
32
43
  description:
33
- 'Session id, uuid fragment (e.g. e6c96), subagent record id (sa-xxx, precise lookup), or absolute .jsonl path (~ or ~/ allowed). Required for family/outline/expand/detail/search/export/extract/workflow. # prefix auto-stripped.',
44
+ 'Session id, uuid fragment (e.g. e6c96), subagent record id (sa-xxx, precise lookup), or absolute .jsonl path (~ or ~/ allowed). Required for family/outline/expand/detail/search/export/extract/workflow/result. result also accepts a comma-separated list of up to 10 ids. # prefix auto-stripped.',
34
45
  }),
35
46
  ),
36
47
  query: Type.Optional(
@@ -91,7 +102,10 @@ const SessionReadSchema = Type.Object({
91
102
  }),
92
103
  ),
93
104
  limit: Type.Optional(
94
- Type.Number({ description: 'find/search: max results. Default 20.' }),
105
+ Type.Number({
106
+ description:
107
+ 'find/search: max results. Default 20. result: max chars per item, default 8000 (overlong text truncated with a pointer to the full file).',
108
+ }),
95
109
  ),
96
110
  what: Type.Optional(
97
111
  StringEnum(
@@ -129,12 +143,13 @@ const guidelines = [
129
143
  'family traces fork parents/children, subagent sessions, and workflow runs.',
130
144
  'extract what=<type> to pull user messages / commands / files / commits / tool results across turns (optional tool= filter for commands/tool-results).',
131
145
  "workflow action to see workflow run overviews (status/budget/steps). Each step's call sessionId can jump to outline/detail for deep reading.",
146
+ "result action to fetch a subagent's final result text (same content as its completion notice): session takes a single sa-id/uuid/path or a comma-separated batch of at most 10; optional limit caps chars per item (default 8000, truncated items carry a pointer to the full file).",
132
147
  'Errors carry a 👉 recovery hint—follow it to retry in one step.',
133
148
  ]
134
149
 
135
150
  // ---- 工具 description(design §3.4,照搬措辞)----
136
151
 
137
- const description = `Read pi session files (conversation history) by semantic structure instead of raw bytes. Use when you need to review another session, trace a fork/subagent/workflow family, or locate a past decision. Nine actions: find (locate by name/uuid fragment), family (fork/subagent/workflow relations), outline (turn-level overview, ~1500 token), expand (single-turn entry list), detail (full text of turns), search (full-text grep across a session), export (materialize to file), extract (pull user messages / commands / files / commits / tool results by type), workflow (workflow run overview: status/budget/steps, step call sessionId jumps to outline/detail). Progressive reading: outline → expand → detail. Do NOT use for the current session (the host provides current-session access) or to edit sessions (pi has /resume /fork).`
152
+ const description = `Read pi session files (conversation history) by semantic structure instead of raw bytes. Use when you need to review another session, trace a fork/subagent/workflow family, or locate a past decision. Ten actions: find (locate by name/uuid fragment), family (fork/subagent/workflow relations), outline (turn-level overview, ~1500 token), expand (single-turn entry list), detail (full text of turns), search (full-text grep across a session), export (materialize to file), extract (pull user messages / commands / files / commits / tool results by type), workflow (workflow run overview: status/budget/steps, step call sessionId jumps to outline/detail), result (fetch a subagent session final result text, same content as its completion notice; single id or comma-separated batch ≤10, optional limit chars per item default 8000). Progressive reading: outline → expand → detail. Do NOT use for the current session (the host provides current-session access) or to edit sessions (pi has /resume /fork).`
138
153
 
139
154
  /**
140
155
  * 已注册过 TUI provider/command 的 pi 实例集合。