@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.
- package/package.json +3 -2
- package/src/__tests__/cross-package-subagent-core.test.ts +321 -0
- package/src/__tests__/execution-tree.test.ts +1 -1
- package/src/__tests__/find.test.ts +3 -3
- package/src/__tests__/hash-provider.test.ts +2 -2
- package/src/__tests__/result.test.ts +341 -0
- package/src/__tests__/roots.test.ts +2 -2
- package/src/__tests__/session-command.test.ts +2 -2
- package/src/__tests__/subagents.test.ts +3 -3
- package/src/discovery/subagents.ts +7 -5
- package/src/index.ts +20 -5
- package/src/result-action.ts +244 -0
- package/src/tool-handler.ts +87 -53
- package/vitest.config.ts +15 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* result action(subagent-sync-collect U6:design subagent-sync-collect.md §3.1.3)。
|
|
3
|
+
*
|
|
4
|
+
* 从 tool-handler.ts 机械提取(max-lines 拆分轮):纯移动零行为变更。tool-handler 的
|
|
5
|
+
* 定位/解析 helper(err/stripHash/requireStr/resolveSessionId/disambiguate/safeParse)
|
|
6
|
+
* 为文件私有且被其余 9 个 action 共用,不便搬移——经 {@link ResultActionDeps} 注入,
|
|
7
|
+
* tool-handler.ts 构造 RESULT_ACTION_DEPS 常量传入(构造期绑定,运行时零查找开销)。
|
|
8
|
+
*
|
|
9
|
+
* 设计来源:docs/design/subagent-sync-collect.md §3.1.3 ——
|
|
10
|
+
* session_read {"action":"result","session":"sa-aaa"} // 单个
|
|
11
|
+
* session_read {"action":"result","session":"sa-aaa,sa-bbb,sa-ccc"} // 批量(≤10)
|
|
12
|
+
* → 每条返回该 subagent session 的最终 assistant 正文(与通知 record.result 同源),
|
|
13
|
+
* 可选 limit 参数(默认 8000 字符/条,超出截断 + 提示读原文件)。
|
|
14
|
+
* 定位复用既有发现机制(sa-xxx manifest 反查 / uuid 片段 / 路径),不新造目录或文件。
|
|
15
|
+
*/
|
|
16
|
+
import type { Entry, ParseResult } from './core/parser.js'
|
|
17
|
+
import type { MatchedSession } from './discovery/find.js'
|
|
18
|
+
import { listRecordManifests, type RecordManifest } from './discovery/subagents.js'
|
|
19
|
+
import type { ResolveResult, SessionReadAction, SessionReadParams, ToolResult } from './tool-handler.js'
|
|
20
|
+
|
|
21
|
+
/** result 批量上限(design subagent-sync-collect §3.1.3:一次最多 10 个)。 */
|
|
22
|
+
const RESULT_MAX_BATCH = 10
|
|
23
|
+
|
|
24
|
+
/** result 单条默认字符上限(design §3.1.3:默认 8000 字符/条)。 */
|
|
25
|
+
const RESULT_DEFAULT_LIMIT = 8000
|
|
26
|
+
|
|
27
|
+
/** result 批量条目分隔符(与 notifier 批通知 "\n\n---\n\n" join 同款)。 */
|
|
28
|
+
const RESULT_BATCH_SEPARATOR = '\n\n---\n\n'
|
|
29
|
+
|
|
30
|
+
/** result action 对 tool-handler 私有 helper 的注入面(构造期常量绑定)。 */
|
|
31
|
+
export interface ResultActionDeps {
|
|
32
|
+
err(message: string): Error
|
|
33
|
+
stripHash(s: string): string
|
|
34
|
+
requireStr(val: string | undefined, name: string, action: SessionReadAction): string
|
|
35
|
+
resolveSessionId(
|
|
36
|
+
rawSession: string | undefined,
|
|
37
|
+
action: SessionReadAction,
|
|
38
|
+
agentDir: string,
|
|
39
|
+
source?: 'main' | 'subagent',
|
|
40
|
+
/** S3:批量预取的 manifest 列表(避免逐 id 重复全量扫描);单 id 不传。 */
|
|
41
|
+
prefetchedManifests?: RecordManifest[],
|
|
42
|
+
): Promise<ResolveResult>
|
|
43
|
+
disambiguate(query: string, candidates: MatchedSession[]): ToolResult
|
|
44
|
+
safeParse(fileName: string): Promise<ParseResult>
|
|
45
|
+
/** 批量头行短 id 截断宽度(tool-handler SESSION_ID_PREFIX_LEN 同源)。 */
|
|
46
|
+
sessionIdPrefixLen: number
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 从 assistant message content 提取纯 text(text 块顺序拼接)。
|
|
51
|
+
*
|
|
52
|
+
* 与 record 侧 text_delta 直累积同构:同一 message 内多个 text 块无分隔拼接
|
|
53
|
+
*(流式 delta 逐段 append),thinking/toolCall 块不入 record.text,此处同样排除。
|
|
54
|
+
*
|
|
55
|
+
* [S7 code-simplify 登记] 本包内第 4 个同構「text 块提取」变体(其余三处均在
|
|
56
|
+
* tool-handler.ts / discovery/find.ts):messageReadableText('' join + 占位符)、
|
|
57
|
+
* extractContentText('\n' join)、extractTextFromContent(' ' join + 空返 undefined)。
|
|
58
|
+
* 本变体的 '' 无分隔拼接是 A4 取回逐字节一致锁定的硬理由(对齐 record.result 的
|
|
59
|
+
* 流式 delta 无分隔累积),不可与带分隔符的变体合并——差异是行为敏感点,勿「顺手统一」。
|
|
60
|
+
*/
|
|
61
|
+
function assistantMessageText(content: unknown): string {
|
|
62
|
+
if (typeof content === 'string') return content
|
|
63
|
+
if (!Array.isArray(content)) return ''
|
|
64
|
+
let out = ''
|
|
65
|
+
for (const block of content) {
|
|
66
|
+
if (block !== null && typeof block === 'object') {
|
|
67
|
+
const o = block as Record<string, unknown>
|
|
68
|
+
if (o.type === 'text' && typeof o.text === 'string') out += o.text
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return out
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 从 session entries 提取最终结果正文(与完成通知 record.result 同源,A4 逐字节一致的前置)。
|
|
76
|
+
*
|
|
77
|
+
* 同源语义(packages/subagent-core/src/execution/execution-record.ts getFullText,只读参考):
|
|
78
|
+
* record.result = AgentResult.text = getFullText(record) = 全部 assistant turn 文本
|
|
79
|
+
* filter(非空,空白串按非空) join("\\n\\n");pi 每 turn_end 对应一条 assistant message
|
|
80
|
+
*(agent-session.js turn_end 携带 message),故文件侧重建 = 每条 assistant message 的
|
|
81
|
+
* text 块顺序拼接 → 跨 message 非空过滤 join("\\n\\n")。
|
|
82
|
+
*
|
|
83
|
+
* 取「最后一条 user message 之后」的 assistant 文本:one-shot(单 user prompt,
|
|
84
|
+
* collect:sync 的目标形态)与 join-all 逐字节一致;多轮 chatMode session 对齐
|
|
85
|
+
* 「最终一轮输出」语义。
|
|
86
|
+
*
|
|
87
|
+
* 导出供单测白盒锁定同源重建规则(message 内 '' join / 跨 message '\n\n' join /
|
|
88
|
+
* thinking 与 toolCall 排除 / 空 text 过滤 / 末 user 边界)。
|
|
89
|
+
*/
|
|
90
|
+
export function extractFinalAssistantText(entries: Entry[]): string {
|
|
91
|
+
let lastUserIdx = -1
|
|
92
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
93
|
+
if (entries[i]?.message?.role === 'user') {
|
|
94
|
+
lastUserIdx = i
|
|
95
|
+
break
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const texts: string[] = []
|
|
99
|
+
for (let i = lastUserIdx + 1; i < entries.length; i++) {
|
|
100
|
+
const e = entries[i]
|
|
101
|
+
if (e?.message?.role !== 'assistant') continue
|
|
102
|
+
const text = assistantMessageText(e.message.content)
|
|
103
|
+
if (text.length > 0) texts.push(text)
|
|
104
|
+
}
|
|
105
|
+
return texts.join('\n\n')
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** result 单条取回结果(单条 details 与批量 details.items 共用形状)。 */
|
|
109
|
+
interface ResultItem {
|
|
110
|
+
/** 调用方原始输入(sa-id / uuid 片段 / 路径) */
|
|
111
|
+
session: string
|
|
112
|
+
/** 反查到的真实 session id(header id,非 sa- 占位) */
|
|
113
|
+
sessionId: string
|
|
114
|
+
/** session .jsonl 绝对路径(截断提示读原文件用) */
|
|
115
|
+
sessionFile: string
|
|
116
|
+
/** 全量正文字符数(截断前口径) */
|
|
117
|
+
totalChars: number
|
|
118
|
+
truncated: boolean
|
|
119
|
+
/** 实际展示正文(截断后含尾提示;未截断时 = 全量正文) */
|
|
120
|
+
text: string
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** result 截断尾提示(design §3.1.3:超出截断 + 提示读原文件)。 */
|
|
124
|
+
/** result 条目截断提示行。
|
|
125
|
+
*
|
|
126
|
+
* [S8 code-simplify 口径注] 本函数的 X = **保留**字符数(limit);而 subagent-core
|
|
127
|
+
* notifier.ts buildTruncationPointer 同模板的 X = **丢弃**字符数(total - kept)。
|
|
128
|
+
* 两处措辞同构但口径相反(主 agent 会先后消费两种通知),统一字符串 = 行为变更,
|
|
129
|
+
* 勿顺手改口径——读本行时先确认在消费哪一侧。
|
|
130
|
+
* [C6] 英文去 emoji(对齐 notifier 指针行的纯英文形态,adversarial-review-fixes §3.4)。 */
|
|
131
|
+
function formatResultTruncation(
|
|
132
|
+
raw: string,
|
|
133
|
+
sessionFile: string,
|
|
134
|
+
limit: number,
|
|
135
|
+
totalChars: number,
|
|
136
|
+
): string {
|
|
137
|
+
return (
|
|
138
|
+
`\n\n[truncated ${limit} of ${totalChars} chars — ` +
|
|
139
|
+
`full text: read ${sessionFile}, or session_read { action:"detail", session:"${raw}" }]`
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* result 的 limit 解析:缺省 8000;非有限数/非正数报错(schema 已限 Number,此处防御
|
|
145
|
+
* + 可单测绕过 schema)。向下取整防小数 slice 语义模糊。
|
|
146
|
+
*/
|
|
147
|
+
function resolveResultLimit(raw: number | undefined): number {
|
|
148
|
+
if (raw === undefined) return RESULT_DEFAULT_LIMIT
|
|
149
|
+
if (!Number.isFinite(raw) || raw <= 0) {
|
|
150
|
+
throw new Error(
|
|
151
|
+
`limit "${String(raw)}" 无效,需为正数。👉 传正整数重试(缺省 ${RESULT_DEFAULT_LIMIT} 字符/条)。`,
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
return Math.floor(raw)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* result 的 session 列表解析:单 id 或逗号批量(≤10)。
|
|
159
|
+
* 空条目(如 "sa-a," 或连续逗号)与超限均明确报错(含上限数字与 👉)。
|
|
160
|
+
*/
|
|
161
|
+
function parseResultSessionList(raw: string | undefined, deps: ResultActionDeps): string[] {
|
|
162
|
+
const trimmed = deps.requireStr(raw, 'session', 'result')
|
|
163
|
+
const ids = trimmed.split(',').map((s) => deps.stripHash(s.trim()))
|
|
164
|
+
if (ids.some((id) => id === '')) {
|
|
165
|
+
throw deps.err(
|
|
166
|
+
`session 列表含空条目:"${trimmed}"。👉 检查逗号分隔格式(如 "sa-aaa,sa-bbb")重试。`,
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
if (ids.length > RESULT_MAX_BATCH) {
|
|
170
|
+
throw deps.err(
|
|
171
|
+
`批量取回 ${ids.length} 个 session 超上限:一次最多 ${RESULT_MAX_BATCH} 个。` +
|
|
172
|
+
`\n👉 分批取回(每批 ≤${RESULT_MAX_BATCH} 个 id)。`,
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
return ids
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* result:取回 subagent session 的最终结果正文(design subagent-sync-collect §3.1.3)。
|
|
180
|
+
*
|
|
181
|
+
* 流程:逐 id 走 resolveSessionId 三形态反查(sa-xxx manifest 精确反查 / uuid 片段 /
|
|
182
|
+
* 绝对路径,multi 走既有 disambiguate)→ safeParse → extractFinalAssistantText
|
|
183
|
+
*(与通知 record.result 同源)→ limit 截断。定位零新机制,不新造目录或文件。
|
|
184
|
+
*
|
|
185
|
+
* content 形态:单 id = 纯正文(无包装,未截断时与 record.result 逐字节一致,A4 取回门);
|
|
186
|
+
* 批量 = 每条目 `[i/n] <原始id> (session <短id>)` 头行 + 正文,'\\n\\n---\\n\\n' join。
|
|
187
|
+
*
|
|
188
|
+
* 错误路径(明确报错不返空串):id 无匹配(ES2/F1 风格)、manifest 有但文件不存在
|
|
189
|
+
*(ES1:GC 或 pi session 延迟写入尚未 flush)、无 assistant 输出(运行中/未写正文)、
|
|
190
|
+
* 批量超限 / 空条目 / limit 非法。
|
|
191
|
+
*/
|
|
192
|
+
export async function doResult(
|
|
193
|
+
params: SessionReadParams,
|
|
194
|
+
agentDir: string,
|
|
195
|
+
deps: ResultActionDeps,
|
|
196
|
+
): Promise<ToolResult> {
|
|
197
|
+
const ids = parseResultSessionList(params.session, deps)
|
|
198
|
+
const limit = resolveResultLimit(params.limit)
|
|
199
|
+
// S3(code-simplify):sa- 形态走 manifest 反查,逐 id 调用会重复全量扫 subagents/ 树
|
|
200
|
+
//(N+1)——批量入口预取一次注入。uuid 片段/路径形态不经 manifest,不预取。
|
|
201
|
+
const prefetchedManifests = ids.some((id) => id.startsWith('sa-'))
|
|
202
|
+
? await listRecordManifests(agentDir)
|
|
203
|
+
: undefined
|
|
204
|
+
const items: ResultItem[] = []
|
|
205
|
+
for (const id of ids) {
|
|
206
|
+
const resolved = await deps.resolveSessionId(id, 'result', agentDir, params.source, prefetchedManifests)
|
|
207
|
+
if (resolved.kind === 'multi') return deps.disambiguate(resolved.query, resolved.candidates)
|
|
208
|
+
const { entries } = await deps.safeParse(resolved.fileName)
|
|
209
|
+
const text = extractFinalAssistantText(entries)
|
|
210
|
+
if (text.length === 0) {
|
|
211
|
+
throw deps.err(
|
|
212
|
+
`session "${id}" 尚无 assistant 输出(运行中或文件尚未 flush 完整)。` +
|
|
213
|
+
`\n👉 稍后重试,或用 session_read { action:"outline", session:"${id}" } 看当前进度。`,
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
const totalChars = text.length
|
|
217
|
+
const truncated = totalChars > limit
|
|
218
|
+
items.push({
|
|
219
|
+
session: id,
|
|
220
|
+
sessionId: resolved.sessionId,
|
|
221
|
+
sessionFile: resolved.fileName,
|
|
222
|
+
totalChars,
|
|
223
|
+
truncated,
|
|
224
|
+
text: truncated
|
|
225
|
+
? text.slice(0, limit) + formatResultTruncation(id, resolved.fileName, limit, totalChars)
|
|
226
|
+
: text,
|
|
227
|
+
})
|
|
228
|
+
}
|
|
229
|
+
if (items.length === 1) {
|
|
230
|
+
const only = items[0]
|
|
231
|
+
return {
|
|
232
|
+
content: [{ type: 'text', text: only.text }],
|
|
233
|
+
details: { ...only },
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
const blocks = items.map(
|
|
237
|
+
(it, i) =>
|
|
238
|
+
`[${i + 1}/${items.length}] ${it.session} (session ${it.sessionId.slice(0, deps.sessionIdPrefixLen)}…)\n${it.text}`,
|
|
239
|
+
)
|
|
240
|
+
return {
|
|
241
|
+
content: [{ type: 'text', text: blocks.join(RESULT_BATCH_SEPARATOR) }],
|
|
242
|
+
details: { count: items.length, items },
|
|
243
|
+
}
|
|
244
|
+
}
|
package/src/tool-handler.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* 分层约定(同 scheduler/cw-tool):本文件零 pi 依赖——agentDir 作参数注入,
|
|
5
5
|
* 不调用 getAgentDir(),可完全单测;pi 注册与 getAgentDir() 调用在 index.ts。
|
|
6
6
|
*
|
|
7
|
-
* 按 action 分发到
|
|
7
|
+
* 按 action 分发到 10 条路径,串联 M1 core(parser/tree/turns/render)+ M2 discovery
|
|
8
8
|
*(find/subagents)。content 给 LLM 读(人类可读摘要),details 供程序化消费/测试断言。
|
|
9
9
|
*
|
|
10
10
|
* 错误规格 F1-F6:handler 抛 Error(message 含 👉 恢复指引),index.ts 的 execute 闭包
|
|
@@ -35,6 +35,10 @@ import {
|
|
|
35
35
|
type ToolResultSummaryEntry,
|
|
36
36
|
} from './core/render.js'
|
|
37
37
|
import type { Family, SessionRef, WorkflowRef } from './core/family.js'
|
|
38
|
+
import { doResult, extractFinalAssistantText, type ResultActionDeps } from './result-action.js'
|
|
39
|
+
|
|
40
|
+
// result action 主体在 result-action.ts(max-lines 拆分轮机械提取,零行为变更);
|
|
41
|
+
// 导出面保持不变:extractFinalAssistantText 仍从本模块导出(包内测试白盒导入路径不变)。
|
|
38
42
|
import {
|
|
39
43
|
buildExecutionTree,
|
|
40
44
|
formatExecutionTreeText,
|
|
@@ -55,6 +59,7 @@ export type SessionReadAction =
|
|
|
55
59
|
| 'export'
|
|
56
60
|
| 'extract'
|
|
57
61
|
| 'workflow'
|
|
62
|
+
| 'result'
|
|
58
63
|
|
|
59
64
|
export interface SessionReadParams {
|
|
60
65
|
action: SessionReadAction
|
|
@@ -185,7 +190,7 @@ function rangeLabel(r: { start: number; end: number }): string {
|
|
|
185
190
|
// resolveSessionId:片段 → 完整 id(design §3.4 resolveSessionId 辅助)
|
|
186
191
|
// ---------------------------------------------------------------------------
|
|
187
192
|
|
|
188
|
-
type ResolveResult =
|
|
193
|
+
export type ResolveResult =
|
|
189
194
|
| { kind: 'ok'; sessionId: string; fileName: string }
|
|
190
195
|
| { kind: 'multi'; query: string; candidates: MatchedSession[] }
|
|
191
196
|
|
|
@@ -246,73 +251,87 @@ function expandHome(p: string): string {
|
|
|
246
251
|
}
|
|
247
252
|
|
|
248
253
|
/**
|
|
249
|
-
* 把 session 参数解析到唯一完整 id(design §6.1 M0 + U2/U3
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
* -
|
|
253
|
-
|
|
254
|
-
* (禁止降级 record.id——sa- 形态不可当 sessionId,CQ3 决策)
|
|
255
|
-
* - ③ 其余 → findSessions 透传 source 沿用 F1/F2
|
|
256
|
-
*
|
|
257
|
-
* 错误契约(U3):① 文件不存在/非 .jsonl/header 读不出 → F6 风格;
|
|
258
|
-
* ② sessionFile GC → ES1(manifest 元数据 + 👉);sa-id 0/>1 命中 → ES2(👉 family)。
|
|
259
|
-
*
|
|
260
|
-
* 仅用于 family/outline/expand/detail/search/export/extract(find action 自行调 findSessions,
|
|
261
|
-
* 零匹配时返回空 + 提示,不抛错)。
|
|
254
|
+
* 把 session 参数解析到唯一完整 id(design §6.1 M0 + U2/U3)。三形态各拆独立解析器:
|
|
255
|
+
* resolveBySessionPath(① 绝对路径/~)→ resolveByRecordId(② sa- 前缀)→ resolveByFragment(③ 片段)。
|
|
256
|
+
* 错误契约(U3):① 文件不存在/非 .jsonl/header 读不出 → F6 风格;② sessionFile GC → ES1(manifest 元数据 + 👉);
|
|
257
|
+
* sa-id 0/>1 命中 → ES2(👉 family)。仅用于 family/outline/expand/detail/search/export/extract/result
|
|
258
|
+
*(find action 自行调 findSessions,零匹配时返回空 + 提示,不抛错)。
|
|
262
259
|
*/
|
|
263
260
|
async function resolveSessionId(
|
|
264
261
|
rawSession: string | undefined,
|
|
265
262
|
action: SessionReadAction,
|
|
266
263
|
agentDir: string,
|
|
267
264
|
source?: 'main' | 'subagent',
|
|
265
|
+
/** S3(code-simplify):批量调用方(doResult)预取的 manifest 列表——省去逐 id
|
|
266
|
+
* 重复全量扫 subagents/ 树(N+1)。单 id 调用点不传,行为零变化。 */
|
|
267
|
+
prefetchedManifests?: RecordManifest[],
|
|
268
268
|
): Promise<ResolveResult> {
|
|
269
269
|
const session = stripHash(requireStr(rawSession, 'session', action))
|
|
270
270
|
|
|
271
271
|
// ① 绝对路径或 ~ 前缀(Windows 盘符由 isAbsolute 处理)
|
|
272
272
|
if (isAbsolute(session) || session === '~' || session.startsWith('~/')) {
|
|
273
|
-
|
|
274
|
-
if (!expanded.endsWith('.jsonl')) {
|
|
275
|
-
throw err(
|
|
276
|
-
`读取失败:${session}(非 .jsonl session 文件)。👉 检查文件或换 session。`,
|
|
277
|
-
)
|
|
278
|
-
}
|
|
279
|
-
if (!existsSync(expanded)) {
|
|
280
|
-
throw err(`读取失败:${session}(文件不存在)。👉 检查文件或换 session。`)
|
|
281
|
-
}
|
|
282
|
-
const headerId = readSessionHeaderId(expanded)
|
|
283
|
-
if (headerId === undefined) {
|
|
284
|
-
throw err(
|
|
285
|
-
`读取失败:${session}(首行非合法 session header)。👉 检查文件或换 session。`,
|
|
286
|
-
)
|
|
287
|
-
}
|
|
288
|
-
return { kind: 'ok', sessionId: headerId, fileName: expanded }
|
|
273
|
+
return resolveBySessionPath(session)
|
|
289
274
|
}
|
|
290
275
|
|
|
291
276
|
// ② sa-id 前缀 → record manifest 精确反查
|
|
292
277
|
if (session.startsWith('sa-')) {
|
|
293
|
-
|
|
294
|
-
const hits = manifests.filter((m) => m.id === session)
|
|
295
|
-
if (hits.length === 0) {
|
|
296
|
-
throw err(formatSaIdNotFound(session))
|
|
297
|
-
}
|
|
298
|
-
if (hits.length > 1) {
|
|
299
|
-
throw err(formatSaIdAmbiguous(session, hits))
|
|
300
|
-
}
|
|
301
|
-
const record = hits[0]
|
|
302
|
-
if (!existsSync(record.sessionFile)) {
|
|
303
|
-
throw err(formatSessionGc(record))
|
|
304
|
-
}
|
|
305
|
-
const headerId = readSessionHeaderId(record.sessionFile)
|
|
306
|
-
if (headerId === undefined) {
|
|
307
|
-
// header 读不出不降级 record.id(sa- 形态不可当 sessionId,CQ3)
|
|
308
|
-
throw err(
|
|
309
|
-
`读取失败:${record.sessionFile}(首行非合法 session header)。👉 检查文件或换 session。`,
|
|
310
|
-
)
|
|
311
|
-
}
|
|
312
|
-
return { kind: 'ok', sessionId: headerId, fileName: record.sessionFile }
|
|
278
|
+
return resolveByRecordId(session, agentDir, prefetchedManifests)
|
|
313
279
|
}
|
|
314
280
|
|
|
315
281
|
// ③ 其余:findSessions 透传 source 沿用 F1/F2
|
|
282
|
+
return resolveByFragment(session, agentDir, source)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** 形态①:绝对路径 / ~ 前缀 → 展开后读首行 header,sessionId=header 真实 id(文件名仅定位)。 */
|
|
286
|
+
function resolveBySessionPath(session: string): ResolveResult {
|
|
287
|
+
const expanded = expandHome(session)
|
|
288
|
+
if (!expanded.endsWith('.jsonl')) {
|
|
289
|
+
throw err(
|
|
290
|
+
`读取失败:${session}(非 .jsonl session 文件)。👉 检查文件或换 session。`,
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
if (!existsSync(expanded)) {
|
|
294
|
+
throw err(`读取失败:${session}(文件不存在)。👉 检查文件或换 session。`)
|
|
295
|
+
}
|
|
296
|
+
const headerId = readSessionHeaderId(expanded)
|
|
297
|
+
if (headerId === undefined) {
|
|
298
|
+
throw err(
|
|
299
|
+
`读取失败:${session}(首行非合法 session header)。👉 检查文件或换 session。`,
|
|
300
|
+
)
|
|
301
|
+
}
|
|
302
|
+
return { kind: 'ok', sessionId: headerId, fileName: expanded }
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* 形态②:sa-id 前缀 → record manifest 精确反查,sessionId=sessionFile header id
|
|
307
|
+
*(禁止降级 record.id——sa- 形态不可当 sessionId,CQ3 决策)。批量调用方传预取列表
|
|
308
|
+
*(S3:避免逐 id 全量重扫);单 id 调用点现场扫一次。
|
|
309
|
+
*/
|
|
310
|
+
async function resolveByRecordId(session: string, agentDir: string, prefetchedManifests: RecordManifest[] | undefined): Promise<ResolveResult> {
|
|
311
|
+
const manifests = prefetchedManifests ?? (await listRecordManifests(agentDir))
|
|
312
|
+
const hits = manifests.filter((m) => m.id === session)
|
|
313
|
+
if (hits.length === 0) {
|
|
314
|
+
throw err(formatSaIdNotFound(session))
|
|
315
|
+
}
|
|
316
|
+
if (hits.length > 1) {
|
|
317
|
+
throw err(formatSaIdAmbiguous(session, hits))
|
|
318
|
+
}
|
|
319
|
+
const record = hits[0]
|
|
320
|
+
if (!existsSync(record.sessionFile)) {
|
|
321
|
+
throw err(formatSessionGc(record))
|
|
322
|
+
}
|
|
323
|
+
const headerId = readSessionHeaderId(record.sessionFile)
|
|
324
|
+
if (headerId === undefined) {
|
|
325
|
+
// header 读不出不降级 record.id(sa- 形态不可当 sessionId,CQ3)
|
|
326
|
+
throw err(
|
|
327
|
+
`读取失败:${record.sessionFile}(首行非合法 session header)。👉 检查文件或换 session。`,
|
|
328
|
+
)
|
|
329
|
+
}
|
|
330
|
+
return { kind: 'ok', sessionId: headerId, fileName: record.sessionFile }
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** 形态③:其余片段 → findSessions 透传 source 沿用 F1(零匹配)/ F2(多匹配消歧)。 */
|
|
334
|
+
async function resolveByFragment(session: string, agentDir: string, source?: 'main' | 'subagent'): Promise<ResolveResult> {
|
|
316
335
|
const opts = { limit: 10, ...(source ? { source } : {}) }
|
|
317
336
|
const { matches } = await findSessions(session, agentDir, opts)
|
|
318
337
|
if (matches.length === 0) {
|
|
@@ -1529,6 +1548,19 @@ async function doWorkflow(params: SessionReadParams, agentDir: string): Promise<
|
|
|
1529
1548
|
return { content: [{ type: 'text', text }], details }
|
|
1530
1549
|
}
|
|
1531
1550
|
|
|
1551
|
+
/** result action 的注入依赖(构造期绑定本文件私有 helper,运行时零查找开销)。 */
|
|
1552
|
+
const RESULT_ACTION_DEPS: ResultActionDeps = {
|
|
1553
|
+
err,
|
|
1554
|
+
stripHash,
|
|
1555
|
+
requireStr,
|
|
1556
|
+
resolveSessionId,
|
|
1557
|
+
disambiguate,
|
|
1558
|
+
safeParse,
|
|
1559
|
+
sessionIdPrefixLen: SESSION_ID_PREFIX_LEN,
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
export { extractFinalAssistantText }
|
|
1563
|
+
|
|
1532
1564
|
// ===========================================================================
|
|
1533
1565
|
// 入口:按 action 分发
|
|
1534
1566
|
// ===========================================================================
|
|
@@ -1565,12 +1597,14 @@ export async function handleSessionRead(
|
|
|
1565
1597
|
return doExtract(params, agentDir)
|
|
1566
1598
|
case 'workflow':
|
|
1567
1599
|
return doWorkflow(params, agentDir)
|
|
1600
|
+
case 'result':
|
|
1601
|
+
return doResult(params, agentDir, RESULT_ACTION_DEPS)
|
|
1568
1602
|
default: {
|
|
1569
|
-
// exhaustive guard:switch 覆盖全部
|
|
1603
|
+
// exhaustive guard:switch 覆盖全部 10 action,此处 params.action 收窄为 never;
|
|
1570
1604
|
// 仅防御运行时非法 action(schema 正常校验下不可达)
|
|
1571
1605
|
const exhaustive: never = params.action
|
|
1572
1606
|
throw err(
|
|
1573
|
-
`未知 action "${JSON.stringify(exhaustive)}"。👉 合法 action: find/family/outline/expand/detail/search/export/extract/workflow。`,
|
|
1607
|
+
`未知 action "${JSON.stringify(exhaustive)}"。👉 合法 action: find/family/outline/expand/detail/search/export/extract/workflow/result。`,
|
|
1574
1608
|
)
|
|
1575
1609
|
}
|
|
1576
1610
|
}
|
package/vitest.config.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
|
|
1
3
|
import { defineConfig } from 'vitest/config'
|
|
2
4
|
|
|
3
5
|
export default defineConfig({
|
|
@@ -6,4 +8,17 @@ export default defineConfig({
|
|
|
6
8
|
outputFile: { junit: './test-results/vitest-junit.xml' },
|
|
7
9
|
include: ['src/__tests__/**/*.test.ts'],
|
|
8
10
|
},
|
|
11
|
+
resolve: {
|
|
12
|
+
// 数组形态按序匹配(vite 文档)。跨包集成测试(cross-package-subagent-core.test.ts)
|
|
13
|
+
// 消费 @zhushanwen/subagent-core 未进 barrel 的内部符号(D3 判定不进),按
|
|
14
|
+
// subagent-workflow vitest.config 同款正则把带 .ts 后缀的包名深路径重写到 core src
|
|
15
|
+
// 物理路径——不依赖 package exports 的 `./*` 通配(u-2c 已删);barrel(包根无路径段)
|
|
16
|
+
// 不命中,走 workspace node 解析(exports "." → src/index.ts)。
|
|
17
|
+
alias: [
|
|
18
|
+
{
|
|
19
|
+
find: /^@zhushanwen\/subagent-core\/(.+\.ts)$/,
|
|
20
|
+
replacement: `${path.resolve(__dirname, '../../../packages/subagent-core/src')}/$1`,
|
|
21
|
+
},
|
|
22
|
+
],
|
|
23
|
+
},
|
|
9
24
|
})
|