From a82a0a52534f75bab564aa9d3bbf5c5c977fc86f Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Fri, 16 Oct 2026 16:17:45 +0800 Subject: [PATCH 1/5] chore: bump version to 0.2.0 --- package.json | 2 +- packages/codingcode/package.json | 2 +- packages/desktop/package.json | 2 +- packages/sdk/package.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 50ac9676..5a3a6f3f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "coding-agent", - "version": "0.1.0", + "version": "0.2.0", "packageManager": "pnpm@11.3.0", "type": "module", "workspaces": [ diff --git a/packages/codingcode/package.json b/packages/codingcode/package.json index 8fe4c437..bdd29203 100644 --- a/packages/codingcode/package.json +++ b/packages/codingcode/package.json @@ -1,6 +1,6 @@ { "name": "@codingcode/core", - "version": "0.1.0", + "version": "0.2.0", "type": "module", "main": "./src/layer.ts", "scripts": { diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 919def07..85ad603c 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@codingcode/desktop", - "version": "0.1.0", + "version": "0.2.0", "private": true, "main": "./out/main/main.js", "scripts": { diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 244c3d15..f6219648 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@codingcode/sdk", - "version": "0.1.0", + "version": "0.2.0", "type": "module", "main": "./src/index.ts", "exports": { From 85938388c53debcf0ecceceedecaed1db398fd31 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Thu, 5 Nov 2026 15:24:01 +0300 Subject: [PATCH 2/5] =?UTF-8?q?=E5=AD=90=E4=BB=A3=E7=90=86=E7=BB=93?= =?UTF-8?q?=E6=9E=9C=E6=8A=95=E9=80=92=E5=8D=B3=E8=90=BD=E7=9B=98=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=BC=82=E5=B8=B8=E4=B8=AD=E6=96=AD=E4=B8=A2?= =?UTF-8?q?=E5=A4=B1=E7=BB=93=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - drainRun 先 appendEvent 写父转录再 mailbox.offer:盘为事实源,队列只负责本回合唤醒 - SpawnOptions.parentChain 改为必填,并按链首位推出父会话自己的转录位置(parentTranscriptOf) - 删除 recordSubagentResult / recordSubagentMessage,父回合 drain 只做 context.absorb,避免双写 - subagent_result / subagent_message 增加 uuid,absorb 按 uuid 判重,防跨回合重复注入 - send_message 走同一契约:先写收件人转录再投递,且只追加到已存在的转录 --- docs/subagent.md | 20 +++--- packages/codingcode/src/agent/agent.ts | 12 ++-- packages/codingcode/src/context/context.ts | 13 +++- packages/codingcode/src/layer.ts | 15 ++++- packages/codingcode/src/session/mailbox.ts | 5 +- packages/codingcode/src/session/port.ts | 10 --- packages/codingcode/src/session/session.ts | 49 --------------- packages/codingcode/src/session/types.ts | 4 ++ packages/codingcode/src/subagent/registry.ts | 53 ++++++++++++---- .../src/tools/domains/subagent/message.ts | 37 ++++++++--- .../test/context/memory-buffer.test.ts | 20 ++++++ .../codingcode/test/session/mailbox.test.ts | 12 ++-- .../session/subagent-lifecycle-meta.test.ts | 37 +---------- .../codingcode/test/subagent/registry.test.ts | 61 +++++++++++++++++-- .../domains/subagent/lifecycle-tools.test.ts | 22 ++++++- 15 files changed, 220 insertions(+), 150 deletions(-) diff --git a/docs/subagent.md b/docs/subagent.md index 008b618c..34e766b0 100644 --- a/docs/subagent.md +++ b/docs/subagent.md @@ -63,10 +63,12 @@ ## 结果回注与 Mailbox -子智能体结果**不通过回调透传**,而是走**会话级 Mailbox**(`session/mailbox.ts`): +子智能体结果**不通过回调透传**,而是走 **「先写父转录、再入队列」**: -- Mailbox 是一个全局单例的易失入站队列,按收件人 `sessionId` 分区;子代理终态进的是**父会话**的队列(嵌套委派下 B 的终态进 `mailbox[A]`)。 -- `SubagentRunRegistry.drainRun` 消费完子帧流后,把结果渲染成一条 `subagent_result` 条目 `offer` 给父会话。 +- `SubagentRunRegistry.drainRun` 消费完子帧流后,把结果组装成一条带 `uuid` 的 `subagent_result` 事件,**先** `session.appendEvent` 追加到父会话自己的转录,**再** `mailbox.offer` 通知父会话。 +- 顺序不可颠倒:先入队后落盘会在「已 absorb 但未落盘」的崩溃窗口里丢结果;先落盘后入队最坏只丢「通知」,事实仍在盘上,换回合重建上下文时会读回。 +- Mailbox(`session/mailbox.ts`)因此只是**本回合的唤醒通道**:全局单例、按收件人 `sessionId` 分区、进程退出即清空都不影响结果可达性。嵌套委派下 B 的终态进 `mailbox[A]`。 +- 父会话自己的转录位置由 `SpawnOptions.parentChain` 推出(链首位即父,其余是父的祖先),见 `subagent/registry.ts` 的 `parentTranscriptOf`:顶层父落在 `sessions/.jsonl`,嵌套父落在链末位的 `subagents/` 下。 - 条目内容 `renderResult()` 组装为: ``` @@ -81,8 +83,10 @@ Payload: ### 渲染与终态来源 -- **终态由子会话自己写**:子回合的 `finish` 通过 `turn.transition` 落终态;父侧只负责派发与投递。 -- **投递信号**:`drainRun` 回注 mailbox 后调用 `turn.markDelivered(childSessionId)`,用于唤醒 `wait_agent`。 +- **终态由子会话自己写**:子回合的 `finish` 通过 `turn.transition` 落终态;父侧只负责派发、落盘与投递。 +- **投递信号**:`drainRun` 落盘并 `offer` 之后调用 `turn.markDelivered(childSessionId)`,用于唤醒 `wait_agent`。 + +`send_message` 的来信走同一条契约:先追加到收件人转录(只追加到已存在的转录,避免为已删除的祖先建幽灵文件),再 `offer` 给对方 mailbox。 --- @@ -97,7 +101,9 @@ Payload: 3. 调 LLM、执行工具 ``` -顺序上 steer(用户中途输入)优先于子代理汇报,避免"用户指令"排在"子代理汇报"之后。每条 mailbox 条目都经 `session.recordSubagentResult` 落盘为 `subagent_result` 事件,再 `context.absorb` 进入上下文。首轮(`step === 0`)不 drain mailbox——此时子代理刚派发,尚无结果。 +顺序上 steer(用户中途输入)优先于子代理汇报,避免"用户指令"排在"子代理汇报"之后。drain 到的条目**已经在盘上**,父回合只做 `context.absorb`,不再重复写盘。 + +跨回合才被 drain 到的条目(父回合提前结束、结果等到下一回合才被取走)不会重复注入:`absorb` 按事件 `uuid` 判重,读盘时已进上下文的事件直接被跳过。首轮(`step === 0`)不 drain mailbox——此时子代理刚派发,尚无结果。 --- @@ -120,7 +126,7 @@ Payload: - **不要在不需要结果时等待**:子智能体最终输出会自动追加到父会话,`wait_agent` 不用于取文本。 - **只在结果阻塞下一步时等**:且给一个宽松的超时,不要用短超时轮询。 -- **等待到"已投递"为止**:委派记录的 wait 会先等终态(`ended`),再等结果进入父会话 mailbox(`delivered`),所以返回 `completed` 时结果已可被父回合 drain 到。 +- **等待到"已投递"为止**:委派记录的 wait 会先等终态(`ended`),再等结果写进父转录并进入父会话 mailbox(`delivered`),所以返回 `completed` 时结果已可被父回合 drain 到——即使父回合不再 drain,下一回合重建上下文也能读到。 --- diff --git a/packages/codingcode/src/agent/agent.ts b/packages/codingcode/src/agent/agent.ts index 279d04cc..03933de2 100644 --- a/packages/codingcode/src/agent/agent.ts +++ b/packages/codingcode/src/agent/agent.ts @@ -394,13 +394,11 @@ export const AgentLayer = Layer.effect( let drainedCount = yield* absorbPendingInputs(); if (step > 0) { - for (const item of yield* mailbox.drain(state.sessionId)) { - const ev = - item.type === 'subagent_message' - ? yield* session.recordSubagentMessage(state, item) - : yield* session.recordSubagentResult(state, item); - yield* context.absorb(sessionRef, [ev]); - drainedCount++; + // 条目在投递点已写进本会话转录,这里只把它们并进本回合上下文 + const items = yield* mailbox.drain(state.sessionId); + if (items.length > 0) { + yield* context.absorb(sessionRef, items); + drainedCount += items.length; } } diff --git a/packages/codingcode/src/context/context.ts b/packages/codingcode/src/context/context.ts index 0dc8c4a2..98a9b464 100644 --- a/packages/codingcode/src/context/context.ts +++ b/packages/codingcode/src/context/context.ts @@ -453,12 +453,23 @@ export const ContextLayer = Layer.effect( ); }); + /** 投递点已落盘,跨回合才 drain 到的条目其事件早被重建时的读盘读进来了 */ + const isBuffered = (buf: ContextBuffer, ev: SessionEvent): boolean => { + const uuid = (ev as { uuid?: string }).uuid; + return ( + typeof uuid === 'string' && buf.events.some((e) => (e as { uuid?: string }).uuid === uuid) + ); + }; + const absorb = (ref: SessionRef, events: readonly SessionEvent[]): Effect.Effect => Effect.sync(() => { const buf = buffers.get(ref.sessionId); // 未建(事件已在盘上,重建时会读到)或已换回合 ⇒ no-op if (!buf || buf.turnId !== ref.currentTurnId) return; - for (const ev of events) if (passesContextFilter(ev)) buf.events.push(ev); + for (const ev of events) { + if (!passesContextFilter(ev) || isBuffered(buf, ev)) continue; + buf.events.push(ev); + } }); const compact = ( diff --git a/packages/codingcode/src/layer.ts b/packages/codingcode/src/layer.ts index 23f8b356..2fc73589 100644 --- a/packages/codingcode/src/layer.ts +++ b/packages/codingcode/src/layer.ts @@ -70,11 +70,20 @@ const AgentWithDeps = AgentLayer.pipe( // subagent runner (depends on agent) const SubagentWithDeps = SubagentRunnerLayer.pipe(Layer.provide(AgentWithDeps)); -// 运行注册表:要 runner 起子代理、要 mailbox 投递终态、要 sink 发 subagent_event 帧、 -// 要 hooks 在子代理终态时触发 agent.subagent.complete。 +// 运行注册表:要 runner 起子代理、要 session 把终态写进父转录、要 mailbox 投递终态、 +// 要 sink 发 subagent_event 帧、要 hooks 在子代理终态时触发 agent.subagent.complete。 // 终态归属已并入状态表(turn 已在 AgentWithDeps 链路里,Layer 按引用去重 ⇒ 全进程一份 records)。 const SubagentRunRegistryWithDeps = SubagentRunRegistryLayer.pipe( - Layer.provide(Layer.mergeAll(SubagentWithDeps, MailboxLayer, EventSinkLayer, HookLayer, TurnWithDeps)) + Layer.provide( + Layer.mergeAll( + SubagentWithDeps, + SessionLayer, + MailboxLayer, + EventSinkLayer, + HookLayer, + TurnWithDeps + ) + ) ); export const AppLayer = Layer.mergeAll( diff --git a/packages/codingcode/src/session/mailbox.ts b/packages/codingcode/src/session/mailbox.ts index bdbad246..0b039d12 100644 --- a/packages/codingcode/src/session/mailbox.ts +++ b/packages/codingcode/src/session/mailbox.ts @@ -1,7 +1,7 @@ import { Chunk, Context, Effect, Layer, Queue } from 'effect'; import type { SubagentMessageEvent, SubagentResultEvent } from './types.js'; -/** 入站暂存条目:子代理终态结果,或其它智能体主动投递的来信 */ +/** 入站暂存条目:子代理终态结果,或其它智能体主动投递的来信;都是**已落盘**的事件 */ export type MailboxItem = SubagentResultEvent | SubagentMessageEvent; export interface MailboxShape { @@ -16,7 +16,8 @@ export interface MailboxShape { export class MailboxService extends Context.Tag('Mailbox')() {} /** - * 会话的易失入站队列,与 transcript(持久出站)对称:同一把键 sessionId。 + * 会话的入站通知队列:条目在 offer 之前已写进收件人转录,队列只负责「本回合叫醒收件人」。 + * 因此队列易失不丢事实 —— 进程退出或收件人回合提前结束,重建上下文仍会从盘上读到。 * 全局单例,内部按收件人分区 —— 嵌套委派下 B 的终态进 mailbox[A]。 */ export const MailboxLayer = Layer.scoped( diff --git a/packages/codingcode/src/session/port.ts b/packages/codingcode/src/session/port.ts index 05ad3d35..b06fd515 100644 --- a/packages/codingcode/src/session/port.ts +++ b/packages/codingcode/src/session/port.ts @@ -5,8 +5,6 @@ import type { AssistantEvent, RollbackEvent, SessionEvent, - SubagentMessageEvent, - SubagentResultEvent, SummaryEvent, ToolResultEvent, UserEvent, @@ -73,14 +71,6 @@ export interface SessionShape { toolCallId: string, output: string ): Effect.Effect; - recordSubagentResult( - state: SessionStoreState, - result: { sessionId: string; agentName: string; content: string } - ): Effect.Effect; - recordSubagentMessage( - state: SessionStoreState, - message: { sessionId: string; agentName: string; content: string } - ): Effect.Effect; appendSummary( state: SessionStoreState, summaryText: string, diff --git a/packages/codingcode/src/session/session.ts b/packages/codingcode/src/session/session.ts index aad25888..0b7bd965 100644 --- a/packages/codingcode/src/session/session.ts +++ b/packages/codingcode/src/session/session.ts @@ -22,8 +22,6 @@ import type { UserEvent, AssistantEvent, ToolResultEvent, - SubagentResultEvent, - SubagentMessageEvent, SummaryEvent, RollbackEvent, SessionEvent, @@ -476,51 +474,6 @@ export const SessionLayer = Layer.effect( : new AgentError('SESSION_IO_ERROR', `Session write failed: ${String(e)}`, e), }); - // 由父回合循环在 drain 点调用:终态先入 mailbox,到这里才落盘。 - // 不写 state.usage —— 子代理的用量不算进父会话。 - const recordSubagentResult = ( - state: SessionStoreState, - result: { sessionId: string; agentName: string; content: string } - ): Effect.Effect => - Effect.try({ - try: () => { - const event: SubagentResultEvent = { - type: 'subagent_result', - sessionId: result.sessionId, - agentName: result.agentName, - content: result.content, - }; - appendLine(pathsFromState(state).transcriptPath, event); - return event; - }, - catch: (e) => - e instanceof AgentError - ? e - : new AgentError('SESSION_IO_ERROR', `Session write failed: ${String(e)}`, e), - }); - - // 与 recordSubagentResult 同构:另一个智能体主动投递的来信,落盘后由收件人回合吸收 - const recordSubagentMessage = ( - state: SessionStoreState, - message: { sessionId: string; agentName: string; content: string } - ): Effect.Effect => - Effect.try({ - try: () => { - const event: SubagentMessageEvent = { - type: 'subagent_message', - sessionId: message.sessionId, - agentName: message.agentName, - content: message.content, - }; - appendLine(pathsFromState(state).transcriptPath, event); - return event; - }, - catch: (e) => - e instanceof AgentError - ? e - : new AgentError('SESSION_IO_ERROR', `Session write failed: ${String(e)}`, e), - }); - const appendSummary = ( state: SessionStoreState, summaryText: string, @@ -643,8 +596,6 @@ export const SessionLayer = Layer.effect( recordSystem, recordAssistant, recordToolResult, - recordSubagentResult, - recordSubagentMessage, appendSummary, rollbackToTurn, diff --git a/packages/codingcode/src/session/types.ts b/packages/codingcode/src/session/types.ts index 0890ff6f..a4ecb093 100644 --- a/packages/codingcode/src/session/types.ts +++ b/packages/codingcode/src/session/types.ts @@ -88,6 +88,8 @@ export const SUBAGENT_RESULT_PREFIX = 'Message Type: FINAL_ANSWER'; export interface SubagentResultEvent { type: 'subagent_result'; + /** 事件身份:投递点已落盘,上下文重建后又 drain 到同一条时按它去重 */ + uuid: string; sessionId: string; agentName: string; content: string; @@ -97,6 +99,8 @@ export const SUBAGENT_MESSAGE_PREFIX = 'Message Type: INTER_AGENT_MESSAGE'; export interface SubagentMessageEvent { type: 'subagent_message'; + /** 同 subagent_result:投递即落盘,重建后按它去重 */ + uuid: string; sessionId: string; agentName: string; content: string; diff --git a/packages/codingcode/src/subagent/registry.ts b/packages/codingcode/src/subagent/registry.ts index 7b7cb687..2df5489d 100644 --- a/packages/codingcode/src/subagent/registry.ts +++ b/packages/codingcode/src/subagent/registry.ts @@ -1,14 +1,18 @@ import { Context, Effect, Layer } from 'effect'; +import { randomUUID } from 'crypto'; import { AgentError } from '../util/error.js'; import { isTurnEnd } from '../sink/types.js'; import type { EndTransition, FrameBody } from '../sink/types.js'; import { BYPASS_PERMISSION_MODE, type ProfileName } from '../util/enums.js'; import { SUBAGENT_RESULT_PREFIX } from '../session/types.js'; +import type { SubagentResultEvent } from '../session/types.js'; +import { transcriptPathOf } from '../session/paths.js'; import { estimateTokensForContent } from '../context/tokens.js'; import { textPart } from '../llm/types.js'; import { loadConfig } from '../infra/config.js'; import { MailboxService } from '../session/mailbox.js'; +import { SessionService } from '../session/port.js'; import { EventSinkService } from '../sink/port.js'; import { HookService } from '../hooks/port.js'; import { TurnRegistryService } from '../turn/port.js'; @@ -27,11 +31,14 @@ export interface SpawnOptions { resumeSessionId?: string; /** 首次派发的用途描述;续跑不传(meta 已有) */ description?: string; - /** 祖先会话 id,由近及远;子会话回合的工具上下文由此而来 */ - parentChain?: readonly string[]; + /** + * 祖先会话 id,由近及远,首位即 `parentSessionId`;子会话回合的工具上下文由此而来, + * 父会话自己的转录位置也由其余位推出(见 parentTranscriptOf) + */ + parentChain: readonly string[]; } -/** 终态由子会话自己的 finish 写入;本表只负责派发与投递 */ +/** 终态由子会话自己的 finish 写入;本表只负责派发、落盘与投递 */ export interface SubagentRunRegistryShape { spawn(opts: SpawnOptions): Effect.Effect<{ sessionId: string; agentName: string }, AgentError>; } @@ -46,8 +53,22 @@ interface SubagentRun { readonly parentSessionId: string; readonly parentCwd: string; readonly agentName: string; + /** 父会话自己的转录:结果在投递点就写进这里,不等父回合 drain */ + readonly parentTranscriptPath: string; } +/** + * 父会话自己的转录位置。链首位就是父,故父的「父 id」与祖链都是本链去掉首位: + * 顶层父(链长 1)⇒ `sessions/.jsonl`,嵌套父 ⇒ 挂在链末位的 subagents/ 下。 + */ +const parentTranscriptOf = (opts: SpawnOptions): string => + transcriptPathOf( + opts.parentCwd, + opts.parentSessionId, + opts.parentChain[1], + opts.parentChain.slice(1) + ); + const BODY_TOKEN_BUDGET = 900; /** 二分收敛到不超过预算的最长前缀,避免逐字扫描 */ @@ -123,6 +144,7 @@ export const SubagentRunRegistryLayer = Layer.scoped( Effect.gen(function* () { const mailbox = yield* MailboxService; const runner = yield* SubagentRunnerService; + const session = yield* SessionService; const sink = yield* EventSinkService; const hooks = yield* HookService; const turn = yield* TurnRegistryService; @@ -142,7 +164,8 @@ export const SubagentRunRegistryLayer = Layer.scoped( event: { type: 'subagent_event', sessionId, agentName, status }, }); - // 只入队,不写盘:写盘由父回合在自己的 drain 点做(父回合循环手里才有 state) + // 先落盘再入队:盘是事实源,队列只是本回合的唤醒通道。 + // 父回合提前结束、进程退出都不会丢结果 —— 换回合重建上下文时从父转录读回。 const drainRun = (run: SubagentRun, stream: AsyncGenerator) => Effect.gen(function* () { const settled = yield* Effect.either(consume(stream)); @@ -151,17 +174,20 @@ export const SubagentRunRegistryLayer = Layer.scoped( ? settled.right : { end: failedEnd(settled.left.message), content: settled.left.message }; - yield* mailbox - .offer(run.parentSessionId, { - type: 'subagent_result', - sessionId: run.sessionId, - agentName: run.agentName, - content: renderResult(run, outcome), - }) - .pipe(Effect.ignore); + const event: SubagentResultEvent = { + type: 'subagent_result', + uuid: randomUUID(), + sessionId: run.sessionId, + agentName: run.agentName, + content: renderResult(run, outcome), + }; + + // 落盘失败仍投递:退化成本回合可见、崩溃不可恢复,不影响父回合继续 + yield* session.appendEvent(run.parentTranscriptPath, event).pipe(Effect.ignore); + yield* mailbox.offer(run.parentSessionId, event).pipe(Effect.ignore); // 终态由子会话自己的 finish 写入(turn.transition);这里只补投递信号, - // 让 wait 在"结果已进父会话 mailbox"之后才返回 + // 让 wait 在"结果已落盘且已进父会话 mailbox"之后才返回 yield* turn.markDelivered(run.sessionId); yield* emitSubagent( run.parentSessionId, @@ -206,6 +232,7 @@ export const SubagentRunRegistryLayer = Layer.scoped( parentSessionId: opts.parentSessionId, parentCwd: opts.parentCwd, agentName: opts.agentName, + parentTranscriptPath: parentTranscriptOf(opts), }; parents.add(opts.parentSessionId); yield* emitSubagent(opts.parentSessionId, sessionId, opts.agentName, 'spawned'); diff --git a/packages/codingcode/src/tools/domains/subagent/message.ts b/packages/codingcode/src/tools/domains/subagent/message.ts index 2f79db93..ea66aa1a 100644 --- a/packages/codingcode/src/tools/domains/subagent/message.ts +++ b/packages/codingcode/src/tools/domains/subagent/message.ts @@ -1,13 +1,16 @@ import { existsSync } from 'fs'; +import { randomUUID } from 'crypto'; import { z } from 'zod'; import { Effect } from 'effect'; import { AgentError } from '../../../util/error.js'; import { transcriptPathOf } from '../../../session/paths.js'; import { SUBAGENT_MESSAGE_PREFIX } from '../../../session/types.js'; +import type { SubagentMessageEvent } from '../../../session/types.js'; import { MailboxService } from '../../../session/mailbox.js'; +import { SessionService } from '../../../session/port.js'; import type { ToolDefinition } from '../../types.js'; -export const sendMessageTool: ToolDefinition = { +export const sendMessageTool: ToolDefinition = { name: 'send_message', concurrencySafe: true, description: @@ -21,6 +24,7 @@ export const sendMessageTool: ToolDefinition = { execute: (args, ctx) => Effect.gen(function* () { const mailbox = yield* MailboxService; + const session = yield* SessionService; const { sessionId, message } = args as { sessionId: string; message: string }; const projectPath = ctx?.projectPath || process.cwd(); const selfSessionId = ctx?.sessionId; @@ -32,29 +36,42 @@ export const sendMessageTool: ToolDefinition = { } // TODO:后续修改格式,让同一根会话下的所有子智能体都能互相访问 const chain = ctx?.parentChain ?? []; - const isAncestor = chain.includes(sessionId); - const childChain = [selfSessionId, ...chain]; - if ( - !isAncestor && - !existsSync(transcriptPathOf(projectPath, sessionId, selfSessionId, childChain)) - ) { + const ancestorAt = chain.indexOf(sessionId); + // 收件人转录:祖先自己的链是本链中它之后的部分;直接子代的父就是本会话 + const recipientPath = + ancestorAt >= 0 + ? transcriptPathOf( + projectPath, + sessionId, + chain[ancestorAt + 1], + chain.slice(ancestorAt + 1) + ) + : transcriptPathOf(projectPath, sessionId, selfSessionId, [selfSessionId, ...chain]); + + const known = existsSync(recipientPath); + if (ancestorAt < 0 && !known) { return yield* Effect.fail( new AgentError('INVALID_INPUT', `Unknown recipient: ${sessionId}`) ); } - // 发送方归属行:链条为空 = 顶层主智能体 const from = chain.length === 0 ? `以下是来自于当前主智能体(id:${selfSessionId})的信息` : `以下是来自于(${ctx?.agentName ?? 'unknown'})智能体(id:${selfSessionId})的信息`; - yield* mailbox.offer(sessionId, { + const event: SubagentMessageEvent = { type: 'subagent_message', + uuid: randomUUID(), sessionId: selfSessionId, agentName: ctx?.agentName ?? '', content: `${SUBAGENT_MESSAGE_PREFIX}\n${from}\n${message}`, - }); + }; + + // 与子代理结果同一条契约:先落盘再入队,收件人不在线也不会丢。 + // 只追加到已存在的转录 —— 祖先链可能指向已删除的会话,不新建幽灵转录 + if (known) yield* session.appendEvent(recipientPath, event).pipe(Effect.ignore); + yield* mailbox.offer(sessionId, event); return `delivered to ${sessionId}`; }), diff --git a/packages/codingcode/test/context/memory-buffer.test.ts b/packages/codingcode/test/context/memory-buffer.test.ts index 116f4814..9e5f9139 100644 --- a/packages/codingcode/test/context/memory-buffer.test.ts +++ b/packages/codingcode/test/context/memory-buffer.test.ts @@ -130,6 +130,26 @@ describe('context memory buffer', () => { expect(messages.some((m) => textOf(m.content) === 'q1')).toBe(true); }); + it('跨回合 drain 到已写盘的结果:按 uuid 判重,不重复注入', async () => { + const event: SessionEvent = { + type: 'subagent_result', + uuid: 'sr-1', + sessionId: 'child-1', + agentName: 'build', + content: 'Message Type: FINAL_ANSWER\nTask name: parent-1\nSender: child-1\nPayload:\nhi', + }; + const counting = makeCountingSession([...seedEvents(), event]); + const ctx = await getCtx(counting); + // 上一回合投递、本回合才被 drain 到:事件早已随读盘进了 buffer + const ref: SessionRef = { ...REF, currentTurnId: 2 }; + + await Effect.runPromise(ctx.getHistory(ref, 'test-model')); + await Effect.runPromise(ctx.absorb(ref, [event])); + const messages = await Effect.runPromise(ctx.getHistory(ref, 'test-model')); + + expect(messages.filter((m) => textOf(m.content).includes('FINAL_ANSWER'))).toHaveLength(1); + }); + it('压缩轮不再额外读盘', async () => { windowState.value = 1000; try { diff --git a/packages/codingcode/test/session/mailbox.test.ts b/packages/codingcode/test/session/mailbox.test.ts index 253e1bdd..9e7737fd 100644 --- a/packages/codingcode/test/session/mailbox.test.ts +++ b/packages/codingcode/test/session/mailbox.test.ts @@ -1,16 +1,20 @@ import { describe, it, expect } from 'vitest'; +import { randomUUID } from 'crypto'; import { Effect } from 'effect'; import { MailboxLayer, MailboxService } from '../../src/session/mailbox.js'; +import type { SubagentMessageEvent, SubagentResultEvent } from '../../src/session/types.js'; -const item = (sessionId: string, content = 'x') => ({ - type: 'subagent_result' as const, +const item = (sessionId: string, content = 'x'): SubagentResultEvent => ({ + type: 'subagent_result', + uuid: randomUUID(), sessionId, agentName: 'build', content, }); -const message = (sessionId: string, content = 'hi') => ({ - type: 'subagent_message' as const, +const message = (sessionId: string, content = 'hi'): SubagentMessageEvent => ({ + type: 'subagent_message', + uuid: randomUUID(), sessionId, agentName: 'build', content, diff --git a/packages/codingcode/test/session/subagent-lifecycle-meta.test.ts b/packages/codingcode/test/session/subagent-lifecycle-meta.test.ts index 605cc92c..3a70bea1 100644 --- a/packages/codingcode/test/session/subagent-lifecycle-meta.test.ts +++ b/packages/codingcode/test/session/subagent-lifecycle-meta.test.ts @@ -3,7 +3,7 @@ import { Effect } from 'effect'; import { SessionService } from '../../src/session/port.js'; import { SessionLayer } from '../../src/session/session.js'; import { computePaths } from '../../src/session/paths.js'; -import { readSessionMeta, readHistory } from '../../src/session/file-ops.js'; +import { readSessionMeta } from '../../src/session/file-ops.js'; import { useTempProjectBase } from '../helpers/project-base.js'; useTempProjectBase('codingcode-test-subagent-meta-'); @@ -74,38 +74,3 @@ describe('subagent identity persisted in the session head', () => { expect(meta?.systemPrompt).toBeUndefined(); }); }); - -describe('recordSubagentMessage', () => { - it('落盘一条 subagent_message 事件,形状正确且不推进 turnId', async () => { - const cwd = '/tmp/test-subagent-message'; - const result = await run( - Effect.gen(function* () { - const svc = yield* SessionService; - const state = yield* svc.create(cwd, childOpts); - const before = state.currentTurnId; - const event = yield* svc.recordSubagentMessage(state, { - sessionId: 'child-1', - agentName: 'build', - content: 'Message Type: INTER_AGENT_MESSAGE\nfrom root\nhi', - }); - return { before, after: state.currentTurnId, event, state }; - }) - ); - - expect(result.event).toEqual({ - type: 'subagent_message', - sessionId: 'child-1', - agentName: 'build', - content: 'Message Type: INTER_AGENT_MESSAGE\nfrom root\nhi', - }); - // 消息不占回合:编号不变 - expect(result.after).toBe(result.before); - - const events = readHistory( - computePaths(result.state.cwd, result.state.sessionId).transcriptPath - ); - const last = events[events.length - 1]!; - expect(last.type).toBe('subagent_message'); - expect('turnId' in last).toBe(false); - }); -}); diff --git a/packages/codingcode/test/subagent/registry.test.ts b/packages/codingcode/test/subagent/registry.test.ts index 1472b6e9..2a5d9cc1 100644 --- a/packages/codingcode/test/subagent/registry.test.ts +++ b/packages/codingcode/test/subagent/registry.test.ts @@ -6,6 +6,8 @@ import { } from '../../src/subagent/registry.js'; import { SubagentRunnerService } from '../../src/subagent/port.js'; import { MailboxLayer, MailboxService } from '../../src/session/mailbox.js'; +import { SessionService } from '../../src/session/port.js'; +import type { SessionEvent } from '../../src/session/types.js'; import { EventSinkService } from '../../src/sink/port.js'; import { HookService } from '../../src/hooks/port.js'; import { TurnRegistryLayer } from '../../src/turn/registry.js'; @@ -19,8 +21,18 @@ const spawnOpts = { parentCwd: '/tmp', parentProfile: 'build' as const, model: 'test-model', + parentChain: ['parent-1'], }; +/** 只关心 appendEvent 的 (path, event):投递点必须先把结果写进父转录 */ +const sessionLayer = (appended: Array<{ path: string; event: SessionEvent }>) => + Layer.succeed(SessionService, { + appendEvent: (path: string, event: SessionEvent) => + Effect.sync(() => { + appended.push({ path, event }); + }), + } as any); + /** 立刻发一条 text_delta 与终态 */ function doneStream(text = 'child-done'): AsyncGenerator { return (async function* () { @@ -49,6 +61,7 @@ function failStream(): AsyncGenerator { function makeHarness(makeStream: () => AsyncGenerator) { const emitted: Array<{ sessionId: string; body: FrameBody }> = []; + const appended: Array<{ path: string; event: SessionEvent }> = []; let n = 0; const runner = Layer.succeed(SubagentRunnerService, { runSubagent: (_prompt: string, _opts: any) => @@ -70,9 +83,11 @@ function makeHarness(makeStream: () => AsyncGenerator) { const turn = TurnRegistryLayer.pipe(Layer.provide(sink)); const layers = SubagentRunRegistryLayer.pipe( - Layer.provideMerge(Layer.mergeAll(runner, MailboxLayer, sink, hooks, turn)) + Layer.provideMerge( + Layer.mergeAll(runner, MailboxLayer, sink, hooks, turn, sessionLayer(appended)) + ) ); - return { emitted, layers }; + return { emitted, appended, layers }; } const run = (layers: Layer.Layer, eff: Effect.Effect): Promise => @@ -107,7 +122,7 @@ function makeCaptureHarness() { } as any); const turn = TurnRegistryLayer.pipe(Layer.provide(sink)); const layers = SubagentRunRegistryLayer.pipe( - Layer.provideMerge(Layer.mergeAll(runner, MailboxLayer, sink, hooks, turn)) + Layer.provideMerge(Layer.mergeAll(runner, MailboxLayer, sink, hooks, turn, sessionLayer([]))) ); return { captured, layers }; } @@ -177,6 +192,43 @@ describe('subagent run registry', () => { expect(statuses).toEqual(['spawned', 'completed']); }); + it('结果在投递点就写进父转录,且盘上那条与队列里那条是同一份', async () => { + const { layers, appended } = makeHarness(() => doneStream('persisted')); + const result = await run( + layers, + Effect.gen(function* () { + const reg = yield* SubagentRunRegistryService; + const mb = yield* MailboxService; + yield* reg.spawn(spawnOpts); + yield* Effect.sleep(50); + return yield* mb.drain('parent-1'); + }) + ); + + expect(appended).toHaveLength(1); + // 顶层父会话:转录就是 sessions/.jsonl + expect(appended[0]!.path.replaceAll('\\', '/')).toContain('sessions/parent-1.jsonl'); + // 同一份事件:落盘一次、吸收一次,不需要额外去重键 + expect(appended[0]!.event).toBe(result[0]); + }); + + it('嵌套父会话:结果写进它自己挂在链末位下的转录', async () => { + const { layers, appended } = makeHarness(() => doneStream('nested')); + await run( + layers, + Effect.gen(function* () { + const reg = yield* SubagentRunRegistryService; + yield* reg.spawn({ ...spawnOpts, parentChain: ['parent-1', 'mid-1', 'root-1'] }); + yield* Effect.sleep(50); + }) + ); + + expect(appended).toHaveLength(1); + expect(appended[0]!.path.replaceAll('\\', '/')).toContain( + 'sessions/root-1/subagents/parent-1.jsonl' + ); + }); + it('error 终态:mailbox 正文含失败原因', async () => { const { layers } = makeHarness(failStream); const result = await run( @@ -259,7 +311,7 @@ describe('subagent run registry', () => { expect(captured[0].description).toBe('reads code'); }); - it('首次派发不传三个新字段时,runner 收到 undefined(现状路径不变)', async () => { + it('首次派发不传续跑字段时,runner 收到 undefined(现状路径不变)', async () => { const { layers, captured } = makeCaptureHarness(); await run( layers, @@ -270,7 +322,6 @@ describe('subagent run registry', () => { ); expect(captured[0].sessionId).toBeUndefined(); - expect(captured[0].parentChain).toBeUndefined(); expect(captured[0].description).toBeUndefined(); }); diff --git a/packages/codingcode/test/tools/domains/subagent/lifecycle-tools.test.ts b/packages/codingcode/test/tools/domains/subagent/lifecycle-tools.test.ts index 66ea65d1..93f830ff 100644 --- a/packages/codingcode/test/tools/domains/subagent/lifecycle-tools.test.ts +++ b/packages/codingcode/test/tools/domains/subagent/lifecycle-tools.test.ts @@ -7,6 +7,8 @@ import { HookService } from '../../../../src/hooks/port.js'; import { TurnRegistryService } from '../../../../src/turn/port.js'; import { SubagentRunRegistryService } from '../../../../src/subagent/registry.js'; import { SUBAGENT_MESSAGE_PREFIX } from '../../../../src/session/types.js'; +import { computePaths } from '../../../../src/session/paths.js'; +import { readHistory } from '../../../../src/session/file-ops.js'; import { resumeAgentTool } from '../../../../src/tools/domains/subagent/resume.js'; import { sendMessageTool } from '../../../../src/tools/domains/subagent/message.js'; import { interruptAgentTool } from '../../../../src/tools/domains/subagent/interrupt.js'; @@ -260,7 +262,9 @@ describe('resume_agent', () => { describe('send_message', () => { const deliver = (cwd: string, args: any, context: ToolExecCtx) => { const { items, layer } = mailboxLayer(); - return attempt(sendMessageTool.execute(args, context), layer).then((r) => ({ r, items })); + return attempt(sendMessageTool.execute(args, context), Layer.merge(layer, SessionLayer)).then( + (r) => ({ r, items }) + ); }; it('主智能体发出:归属行为「当前主智能体」格式(链条为空)', async () => { @@ -309,11 +313,23 @@ describe('send_message', () => { } }); - it('直接子会话:路径存在即投递', async () => { + it('直接子会话:路径存在即投递,且来信先落进收件人转录', async () => { const cwd = freshCwd(); const child = await createSession(cwd, PARENT); - const { r } = await deliver(cwd, { sessionId: child.sessionId, message: 'hi' }, ctx(cwd)); + const { r, items } = await deliver( + cwd, + { sessionId: child.sessionId, message: 'hi' }, + ctx(cwd) + ); expect(codeOf(r)).toBe('ok'); + + // 收件人此刻不在线:消息已经在盘上,下次重建上下文就能读到 + const events = readHistory( + computePaths(cwd, child.sessionId, child.parentSessionId).transcriptPath + ); + const last = events[events.length - 1]!; + expect(last.type).toBe('subagent_message'); + expect(last).toEqual(items[0]); }); it('兄弟会话 / 其它父会话的子会话 / 陌生 id 一律拒绝', async () => { From 484a8fa9c5dd7fed5ff20321e3af56e65e6306c2 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Thu, 5 Nov 2026 17:45:16 +0300 Subject: [PATCH 3/5] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20system=20=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E5=BA=8F=E5=88=97=E5=8C=96=E4=BB=A5=E9=80=9A=E8=BF=87?= =?UTF-8?q?=20AI=20SDK=20=E6=B6=88=E6=81=AF=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/codingcode/src/llm/providers/shared.ts | 6 +++++- packages/codingcode/test/llm/convert-messages.test.ts | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/codingcode/src/llm/providers/shared.ts b/packages/codingcode/src/llm/providers/shared.ts index 550f776c..e3a4d6b9 100644 --- a/packages/codingcode/src/llm/providers/shared.ts +++ b/packages/codingcode/src/llm/providers/shared.ts @@ -29,7 +29,11 @@ export function convertMessages(messages: ResolvedMessage[]): ModelMessage[] { } as unknown as ModelMessage; } - if (m.role === 'user' || m.role === 'system') { + if (m.role === 'system') { + return { role: 'system', content: textOf(m.content) }; + } + + if (m.role === 'user') { const parts: any[] = []; for (const p of m.content) { if (p.type === 'text') { diff --git a/packages/codingcode/test/llm/convert-messages.test.ts b/packages/codingcode/test/llm/convert-messages.test.ts index 587fc28e..6cc5c2f1 100644 --- a/packages/codingcode/test/llm/convert-messages.test.ts +++ b/packages/codingcode/test/llm/convert-messages.test.ts @@ -91,7 +91,7 @@ describe('convertMessages', () => { it('system 消息按 parts 映射', () => { expect(convertMessages([{ role: 'system', content: [t('rules')] }])).toEqual([ - { role: 'system', content: [{ type: 'text', text: 'rules' }] }, + { role: 'system', content: 'rules' }, ]); }); }); From 04f3196728b60cad28a75c5a66a5d9f3ba67f6bd Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Fri, 6 Nov 2026 07:27:56 +0300 Subject: [PATCH 4/5] =?UTF-8?q?send=5Fmessage=20=E6=94=B6=E4=BB=B6?= =?UTF-8?q?=E4=BA=BA=E5=87=86=E5=85=A5=E7=BB=9F=E4=B8=80=E6=8C=89=E8=BD=AC?= =?UTF-8?q?=E5=BD=95=E5=AD=98=E5=9C=A8=E5=88=A4=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 祖先不再凭链上身份免校验:没有转录就收不下可持久化的消息,一律 INVALID_INPUT。 用例改为真实建盘后再投递,同时锁住祖先路径推导(子代理平级挂顶层会话的 subagents/)。 --- .../src/tools/domains/subagent/message.ts | 9 +++---- .../domains/subagent/lifecycle-tools.test.ts | 26 ++++++++++++++----- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/packages/codingcode/src/tools/domains/subagent/message.ts b/packages/codingcode/src/tools/domains/subagent/message.ts index ea66aa1a..bef3ae9e 100644 --- a/packages/codingcode/src/tools/domains/subagent/message.ts +++ b/packages/codingcode/src/tools/domains/subagent/message.ts @@ -48,8 +48,8 @@ export const sendMessageTool: ToolDefinition = ) : transcriptPathOf(projectPath, sessionId, selfSessionId, [selfSessionId, ...chain]); - const known = existsSync(recipientPath); - if (ancestorAt < 0 && !known) { + // 没有转录就收不下可持久化的消息,祖先也不例外 + if (!existsSync(recipientPath)) { return yield* Effect.fail( new AgentError('INVALID_INPUT', `Unknown recipient: ${sessionId}`) ); @@ -68,9 +68,8 @@ export const sendMessageTool: ToolDefinition = content: `${SUBAGENT_MESSAGE_PREFIX}\n${from}\n${message}`, }; - // 与子代理结果同一条契约:先落盘再入队,收件人不在线也不会丢。 - // 只追加到已存在的转录 —— 祖先链可能指向已删除的会话,不新建幽灵转录 - if (known) yield* session.appendEvent(recipientPath, event).pipe(Effect.ignore); + // 与子代理结果同一条契约:先落盘再入队,收件人不在线也不会丢 + yield* session.appendEvent(recipientPath, event).pipe(Effect.ignore); yield* mailbox.offer(sessionId, event); return `delivered to ${sessionId}`; diff --git a/packages/codingcode/test/tools/domains/subagent/lifecycle-tools.test.ts b/packages/codingcode/test/tools/domains/subagent/lifecycle-tools.test.ts index 93f830ff..5b51ce37 100644 --- a/packages/codingcode/test/tools/domains/subagent/lifecycle-tools.test.ts +++ b/packages/codingcode/test/tools/domains/subagent/lifecycle-tools.test.ts @@ -302,14 +302,28 @@ describe('send_message', () => { ); }); - it('链条上任意祖先都可投递(祖父 / 根),免文件校验', async () => { + it('链条上的祖先:按链推出各自转录位置并落盘', async () => { const cwd = freshCwd(); - const context = ctx(cwd, { sessionId: 'grandchild-1', parentChain: ['mid-1', 'root-1'] }); + const root = await createSession(cwd, undefined); + const mid = await createSession(cwd, root.sessionId, { parentChain: [root.sessionId] }); + const context = ctx(cwd, { + sessionId: 'grandchild-1', + parentChain: [mid.sessionId, root.sessionId], + }); - for (const target of ['mid-1', 'root-1']) { - const { r, items } = await deliver(cwd, { sessionId: target, message: 'hi' }, context); - expect(codeOf(r), target).toBe('ok'); - expect(items[0]!.sessionId).toBe('grandchild-1'); + const targets = [ + { + id: mid.sessionId, + path: computePaths(cwd, mid.sessionId, root.sessionId, [root.sessionId]).transcriptPath, + }, + { id: root.sessionId, path: computePaths(cwd, root.sessionId).transcriptPath }, + ]; + + for (const { id, path } of targets) { + const { r, items } = await deliver(cwd, { sessionId: id, message: 'hi' }, context); + expect(codeOf(r), id).toBe('ok'); + const events = readHistory(path); + expect(events[events.length - 1], id).toEqual(items[0]); } }); From 045a521bd008a6ef2b25a5c782988a785bb8f1fa Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Fri, 6 Nov 2026 07:27:59 +0300 Subject: [PATCH 5/5] =?UTF-8?q?=E6=BE=84=E6=B8=85=E5=AD=90=E4=BB=A3?= =?UTF-8?q?=E7=90=86=E8=BD=AC=E5=BD=95=E6=8C=89=E9=A1=B6=E5=B1=82=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E6=89=81=E5=B9=B3=E5=AD=98=E6=94=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parentTranscriptOf 注释与文档原文都写成「嵌套父」,易被读成目录逐层嵌套; 实际 owner 取链末位,子代理一律与父平级挂在顶层会话的 subagents/ 下。 --- docs/subagent.md | 2 +- packages/codingcode/src/subagent/registry.ts | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/subagent.md b/docs/subagent.md index 34e766b0..9ba0e139 100644 --- a/docs/subagent.md +++ b/docs/subagent.md @@ -68,7 +68,7 @@ - `SubagentRunRegistry.drainRun` 消费完子帧流后,把结果组装成一条带 `uuid` 的 `subagent_result` 事件,**先** `session.appendEvent` 追加到父会话自己的转录,**再** `mailbox.offer` 通知父会话。 - 顺序不可颠倒:先入队后落盘会在「已 absorb 但未落盘」的崩溃窗口里丢结果;先落盘后入队最坏只丢「通知」,事实仍在盘上,换回合重建上下文时会读回。 - Mailbox(`session/mailbox.ts`)因此只是**本回合的唤醒通道**:全局单例、按收件人 `sessionId` 分区、进程退出即清空都不影响结果可达性。嵌套委派下 B 的终态进 `mailbox[A]`。 -- 父会话自己的转录位置由 `SpawnOptions.parentChain` 推出(链首位即父,其余是父的祖先),见 `subagent/registry.ts` 的 `parentTranscriptOf`:顶层父落在 `sessions/.jsonl`,嵌套父落在链末位的 `subagents/` 下。 +- 父会话自己的转录位置由 `SpawnOptions.parentChain` 推出(链首位即父,其余是父的祖先),见 `subagent/registry.ts` 的 `parentTranscriptOf`。**子代理之间不逐层嵌套**:转录目录一律由链末位(顶层会话)定位,所以父本身是子智能体时与它的父平级——顶层父落在 `sessions/.jsonl`,其余落在 `sessions/<顶层会话>/subagents/.jsonl`。 - 条目内容 `renderResult()` 组装为: ``` diff --git a/packages/codingcode/src/subagent/registry.ts b/packages/codingcode/src/subagent/registry.ts index 2df5489d..0ff3fbc7 100644 --- a/packages/codingcode/src/subagent/registry.ts +++ b/packages/codingcode/src/subagent/registry.ts @@ -58,8 +58,10 @@ interface SubagentRun { } /** - * 父会话自己的转录位置。链首位就是父,故父的「父 id」与祖链都是本链去掉首位: - * 顶层父(链长 1)⇒ `sessions/.jsonl`,嵌套父 ⇒ 挂在链末位的 subagents/ 下。 + * 父会话自己的转录位置。链首位就是父,故父的「父 id」与祖链都是本链去掉首位。 + * 转录目录由链末位(顶层会话)定位,子代理之间不逐层嵌套,所以父本身是子智能体时 + * 与它的父平级:顶层父(链长 1)⇒ `sessions/.jsonl`, + * 否则 ⇒ `sessions/<链末位>/subagents/.jsonl`。 */ const parentTranscriptOf = (opts: SpawnOptions): string => transcriptPathOf(