From 1ed4ec9a7149012a7ff19de506877202793c98c4 Mon Sep 17 00:00:00 2001 From: gkinter <20522120+gkinter@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:36:36 +0000 Subject: [PATCH] fix(sidechat): cut the inherited prefix before the thread's own descriptor sidechat.start passed inheritedEventCount = seed.length while the thread's own subagent/descriptor was the last seed event, so the descriptor sat inside the inherited prefix. The host reads a child's descriptor only from events.slice(inheritedEventCount), so side threads had none: they listed as corrupt and could not be resumed as continuable children. Build the seed the way the host's own session.fork does (buildForkSeed): the copied parent history, an inherited session/end-seed marker at the cut, then the thread's own working-directory stamp and descriptor. The cut is the copied history's length. --- docs/host-contracts.md | 2 +- src/sidechat-routes.ts | 26 ++++++++-- tests/host-runtime-imports.spec.ts | 2 +- tests/sidechat-routes.spec.ts | 72 +++++++++++++++++++++----- tests/sidechat-seed-validation.spec.ts | 58 ++++++++++----------- 5 files changed, 109 insertions(+), 51 deletions(-) diff --git a/docs/host-contracts.md b/docs/host-contracts.md index 9c46a6308..adf2d6025 100644 --- a/docs/host-contracts.md +++ b/docs/host-contracts.md @@ -41,7 +41,7 @@ - `tab.visible` 语义是 `active && (...)`(全局面板 / 后台会话时 body 不挂载);持久化校验也收紧(非根 / 列轴 / 嵌套 / 3+ docked pane 的 split 一律判非法**整份丢弃**)。全局面板(根级 `main` keyed 槽 + `sidebar.panellist` + `ctx.layout.selectPanel/beginNavigation`、`rightbar` 改根级并新增 `rightbar.session`)插件**不接入**,只做兼容。底部工作台的中心列锚点认 `[data-slot="main.conversation"]`(并跳过 `display: contents` 祖先,同时保留 `[data-slot="conversation"]`)。原生 tab 体宿主 `.paneBody` 是**有确定高度的块级滚动容器**(非 flex 容器),native 适配层因此给每个 tab 体包一层 `height:100%` 的列 flex 宿主(`sidebar.module.css` 的 `.nativeTabHost`),tab 组件根继续用 `flex:1` / `height:100%`。 - **原生注册账本的两条不变量**(`tests/native-registration.spec.ts` 守护):① `live` 里同时装着描述符类型(键 = descriptor id)与 `files` 接管(键 = `FILES_KIND`,**永远不在 `wanted` 里**),清理循环必须**跳过 `FILES_KIND`**——它只由编辑器类型的 `wantsFiles/hasFiles` 开关分支与 seat disposer 管理;否则每次通知都会把它拆掉再重建,而 teardown 期间的那次重建会把 id 孤儿化:`tabs.register` 建在**宿主** ctx 上(宿主 ctx 此时仍活着)所以照样成功,紧随的 `ctx.slots.inject` 建在**插件** ctx 上却抛 `cannot create effect on inactive context`,`live.set` 永不执行 → 该 id 在整页生命周期内不可再注册(真机表现:`native register files error: … already registered` 刷屏 + 文件树落到宿主空态,只有刷新页面才恢复;实证见 [docs/plans/2026-09-28-native-files-takeover-reload-leak.md](plans/2026-09-28-native-files-takeover-reload-leak.md))。② 因此「注册成功一半」是**可达状态**:任何在宿主取走 id **之后**失败的注册(槽位注册抛错等)必须**回滚释放该 type** 并释放已经建好的槽位,插件侧只留一个下次通知可以重试的失败,绝不留下无处释放的 id。相关断言必须写在注册表的**事件日志**上而不是「活着的 id 集合」上:孤儿 id 会让键集合看起来完全正常,键集合断言在未修复代码上同样通过。 - **原生 tab 的状态保留(已采纳 `keepMounted: true`)**:宿主因此把访问过的 body 挂住,同会话切 tab 不再卸载,组件态(树滚动、未保存草稿、chip 重命名)随之免费保留,**不需要**任何 module-level 记忆。三条必须同时成立的配套事实:(a) **记录不能随卸载删除**(只由 `surface.close` 与「会话消失」回收),而「切回会话清零」那一路(真机日志里 `DISPOSE type …:files` → `register`、同键 `drop`+`mint`)已由上面的 `FILES_KIND` 守卫挡掉(真机 e2e 现断言跨会话 `scrollTop` 复原);(b) 原生 tab id **每会话各自计数**(`tab1`…)且多会话同号并存,记录与写面一律按 `sessionId::tabId` 记账,`Surface.update/has/activate` 的 `sessionId` 缺省按 `sidebarRight.mounted` 解析,多会话同名时**拒绝而不是猜**;(c) **展开集的权威在插件 store 的会话状态里**(`attachStore` + `syncExpanded` + `getSessionStates().expanded`,由 `native/index.ts` 绑定),记录只负责**身份**与投影——两套持久化会互相打架,插件不自持「每记录的展开集」。设计过程与实测见 [docs/plans/2026-09-25-native-tab-state-retention-keepmounted.md](plans/2026-09-25-native-tab-state-retention-keepmounted.md)。 -10. **自定义种子必须带 fork 标记对(`meta.isSeeded: true` + `inheritedEventCount`),且 create 后必须 `inbox.clear()`、冷恢复必须按 id 精确清扫——标记对必要但不充分**:dsh-session 契约规定「只给 seed 不标 isSeeded,种子算重放历史而非继承前缀」——缺标记时 `Session.ownEvents()` 含整个种子,子会话的 inbox 折重放种子里的 `agent/inbox/spliced`,**继承父会话切割时刻未领取的 inbox 输入**。**但运行时 inbox 不走 `ownEvents()`**:`ReactLoopInbox.current()` → `ctx.sessionProjections.stateOf()` → `SessionProjectionRegistry.cellFor` 折的是 `snapshotEvents()` **全量日志(含继承种子前缀)**,且标准 inbox 投影的 `init` 忽略 `inheritedEventCount`、注册表 `session/created` 急切初始化跳过一切种子会话(seq ≠ 0)——种子里的 `agent/inbox/spliced` 会原样重放进子会话 live inbox。因此 sidechat 宿主 `agents.create` 成功后、任何 inject/followup 之前必须 `handle.agent.inbox.clear()`(durable 补偿 splice 是 own 事件,冷恢复折存储日志同样归零)。**幽灵的来源比「父会话排队」更宽**:父会话 mid-turn 且工具有未返回调用时,种子被截断在 `turn/start` 之前,而 inbox 的「领取」splice 发生在 `turn/start` **之后** → **父会话本轮开头已领取的输入**在种子折叠里仍是 pending;`clear()` 是唯一能一次清干净的硬围栏(create 时本线程自己不可能有排队输入)。**冷恢复不能沿用 `clear()`**(会误杀「用户在线程里排队、宿主重启后尚未领取」的合法输入):改为 `dropInheritedGhosts`——`inheritedPendingIds(log, inheritedEventCount)` 只折继承前缀得到幽灵 id 集合,再逐个 `agent.inbox.remove(id)`,精确且幂等(`src/sidechat-core.ts` 纯函数,`tests/sidechat-core.spec.ts` / `tests/sidechat-routes.spec.ts` 守护)。宿主 `session.fork`(api-session-controller)的调用即规范形态(注意:宿主自己的 `fork` **没有**这一步补偿,同窗口有同样的暴露面);回归由 `tests/sidechat-seed-validation.spec.ts`(真实 `Session.create` + 对 `ownEvents()` 跑 inbox 折 + **真实 `SessionProjectionRegistry`** 折全量日志钉住上游洞与 clear 补偿)守护。**模型选择同为 agent 作用域**:`sidechat.model` 必须同时落 `session.append('model/selection', …)`(宿主 `selectForNextRequest` 的同一形状)与 `installModelSelection(agent.ctx, ref)`(`@deepseek-ai/dsh-agent` 公开导出),否则控制器缓存的 per-agent selection 永不回读投影;resume 后也要按日志里的有效选择重装(`src/sidechat-routes.ts` 的 `ensureModelSelection`)。**上游 0.2.0 新增的 `buildForkSeed` 与本插件手工的 `buildSidechatInheritance` 职责重叠,但收益不明确**(插件另有 dangling-tool-call 退回结构化快照的自有需求),暂不替换。 +10. **自定义种子必须带 fork 标记对(`meta.isSeeded: true` + `inheritedEventCount`),且 create 后必须 `inbox.clear()`、冷恢复必须按 id 精确清扫——标记对必要但不充分**:dsh-session 契约规定「只给 seed 不标 isSeeded,种子算重放历史而非继承前缀」——缺标记时 `Session.ownEvents()` 含整个种子,子会话的 inbox 折重放种子里的 `agent/inbox/spliced`,**继承父会话切割时刻未领取的 inbox 输入**。**但运行时 inbox 不走 `ownEvents()`**:`ReactLoopInbox.current()` → `ctx.sessionProjections.stateOf()` → `SessionProjectionRegistry.cellFor` 折的是 `snapshotEvents()` **全量日志(含继承种子前缀)**,且标准 inbox 投影的 `init` 忽略 `inheritedEventCount`、注册表 `session/created` 急切初始化跳过一切种子会话(seq ≠ 0)——种子里的 `agent/inbox/spliced` 会原样重放进子会话 live inbox。因此 sidechat 宿主 `agents.create` 成功后、任何 inject/followup 之前必须 `handle.agent.inbox.clear()`(durable 补偿 splice 是 own 事件,冷恢复折存储日志同样归零)。**幽灵的来源比「父会话排队」更宽**:父会话 mid-turn 且工具有未返回调用时,种子被截断在 `turn/start` 之前,而 inbox 的「领取」splice 发生在 `turn/start` **之后** → **父会话本轮开头已领取的输入**在种子折叠里仍是 pending;`clear()` 是唯一能一次清干净的硬围栏(create 时本线程自己不可能有排队输入)。**冷恢复不能沿用 `clear()`**(会误杀「用户在线程里排队、宿主重启后尚未领取」的合法输入):改为 `dropInheritedGhosts`——`inheritedPendingIds(log, inheritedEventCount)` 只折继承前缀得到幽灵 id 集合,再逐个 `agent.inbox.remove(id)`,精确且幂等(`src/sidechat-core.ts` 纯函数,`tests/sidechat-core.spec.ts` / `tests/sidechat-routes.spec.ts` 守护)。宿主 `session.fork`(api-session-controller)的调用即规范形态(注意:宿主自己的 `fork` **没有**这一步补偿,同窗口有同样的暴露面);回归由 `tests/sidechat-seed-validation.spec.ts`(真实 `Session.create` + 对 `ownEvents()` 跑 inbox 折 + **真实 `SessionProjectionRegistry`** 折全量日志钉住上游洞与 clear 补偿)守护。**模型选择同为 agent 作用域**:`sidechat.model` 必须同时落 `session.append('model/selection', …)`(宿主 `selectForNextRequest` 的同一形状)与 `installModelSelection(agent.ctx, ref)`(`@deepseek-ai/dsh-agent` 公开导出),否则控制器缓存的 per-agent selection 永不回读投影;resume 后也要按日志里的有效选择重装(`src/sidechat-routes.ts` 的 `ensureModelSelection`)。**上游 0.2.0 新增的 `buildForkSeed` 与本插件手工的 `buildSidechatInheritance` 职责重叠,但收益不明确**(插件另有 dangling-tool-call 退回结构化快照的自有需求),暂不替换。 **切点位置与宿主 `buildForkSeed` 一致**:`inheritedEventCount` = 复制的父会话前缀长度,切点处放 `session/end-seed {inherited: true}`,本线程自己的 `working-directory/change` 与 `subagent/descriptor` 排在它之后。宿主只在 `events.slice(inheritedEventCount)` 里找子会话的 descriptor(冷恢复、持久化会话的 facts),descriptor 落在继承前缀内会让侧边对话被列为 corrupt、无法作为 continuable 子会话恢复;种子构造器只在切点处是该标记时才接受短于种子的切点(`tests/sidechat-routes.spec.ts` 用真实 `Session.create` + `foldSubagentDescriptor` 守护)。 11. **`dsh.plugin.json` 从来不是 DSH 契约**(全树 grep 0 命中):DSH 真正读的是 `package.json` 的 `dsh.bundle.patch` + `dsh.client`(`platform`/`inject`/`immediately`/`external`)+ `exports["./client"]`。`dsh.plugin.json` 与 `lib/client-registry.js` 只服务第三方 registry(社区市场)通道,`tests/manifest-consistency.spec.ts` 守护的是这条通道的形状,不是 DSH 契约。**不要混淆两个 manifest 的 `engines.dsh`**:当前宿主的兼容性检查优先读取 `package.json#engines.dsh`,存在时由它独立裁决;本插件未声明此字段,仍由 13 条 DSH peer 决定兼容性。`dsh.plugin.json#engines.dsh` 只是第三方 registry 元数据,不能替代 package 字段;`dsh.manifestVersion` 也不是版本兼容性开关。`dsh.bundle.patch` 有形状校验(非法值会 `throw`,引导期跳过该 bundle),本插件是合法的单字符串。 **插件展示元数据**(Plugins 页与 `plugin_manager` 列表,v0.21.1 起已采纳)三条硬规则:`package.json#icon` 必须是**包内相对路径**且写进 `files`(36×36、纯填充无描边、**双色渐变**——与 DSH 自带 `PluginArtwork*` 同风格,单色版在 30px 行内尺寸下会读成一块纯色板);`locale/en.json` 是发现基线(缺它整个目录都不读,`meta.title` / `description` 在这里);`exports` 必须暴露 `./locale/*.json` 与 `./package.json`。**所有失败都是静默的**——元数据有问题只会退化成一行纯文字,没有任何报错;`tests/plugin-meta.spec.ts` 守护「声明了就必须发布出去」。plugin-manager 侧另有**多 registry 回退**(配置源 → `fallbackRegistries`,默认含 `registry.npmmirror.com`;`network`/`timeout`/`not-found`/`no-matching-version` 会自动问下一个源;`registry` 入参只存在于 Web 对话框与 `plugin_manager` 工具,**CLI 没有 registry flag**)。 12. **宿主版本兼容性是启动期硬约束**:当前宿主先读插件 `package.json#engines.dsh`;字段存在时只按该范围判定,DSH peer 不再拓宽或收窄结果。字段缺席时回退检查名字为 `@deepseek-ai/dsh` 或以 `@deepseek-ai/dsh-` 开头的 peer,按 `semver.satisfies(宿主版本, range, { includePrerelease: true })` 判定。本插件刻意不新增该字段,仍由 13 条 DSH peer 的 `^0.2.1-rc.1` 支持线约束,optional peer 没有豁免。`engines` 非对象或 `engines.dsh` 非字符串会导致兼容性清单不可读并拒绝加载;`workspace:` 仅在 peer 中有效。安装与 profile 启动拒绝可能禁用整行,需读 `disabling profile plugin` 日志;拒绝记录字段已由 `peers` 改为 `requirements`,其中 key 为 `engines.dsh` 或回退检查的 peer 包名。`pnpm peers check` 校验的是 dev 依赖树,不能证明宿主启动预检通过。[官方升级指南](https://github.com/deepseek-ai/deepseek-harness/blob/dsh-v0.2.1-alpha.5/docs/upgrade-guide/v0.2.1-alpha.3/plugin-engines-dsh/guide.md)。 diff --git a/src/sidechat-routes.ts b/src/sidechat-routes.ts index b75b6d1b7..1554c50d1 100644 --- a/src/sidechat-routes.ts +++ b/src/sidechat-routes.ts @@ -653,24 +653,40 @@ export function buildSidechatApi(ctx: Context, live?: AssistantLiveBuffer): Side ...(routeProvider === undefined ? {} : { agentProvider: routeProvider }), ...(routeModel === undefined ? {} : { agentModel: routeModel }), }) + // The inherited cut sits right after the copied parent history, the + // same shape as the host's own session.fork (buildForkSeed): the + // inherited prefix, an inherited end-seed marker AT the cut, then the + // thread's own events. The host reads a child's descriptor only from + // `events.slice(inheritedEventCount)` (subagent resume, persisted + // session facts); a descriptor inside the prefix leaves the thread + // listed as corrupt and not resumable as a continuable child. The + // seeded constructor accepts a cut shorter than the seed only when + // the event at the cut is this marker. + const inheritedEventCount = inheritance.seed.length + const endSeed: SeedEvent = { + type: 'session/end-seed', + seq: inheritedEventCount, + time: Date.now(), + data: { inherited: true }, + } // A truncated inheritance can end before the parent's last directory // change. Header cwd alone cannot win: the host projection replays seed // working-directory/change events over it. Stamp the committed directory - // at the seed boundary, using the host-owned event rather than refolding + // right after the cut, using the host-owned event rather than refolding // its history or asking set() to enqueue a synthetic user notice. const directorySeed: SeedEvent[] = workingDirectory === undefined ? [] : [{ type: 'working-directory/change', - seq: inheritance.seed.length, + seq: inheritedEventCount + 1, time: Date.now(), data: { cwd: parentCwd }, }] const descriptorEvent: SeedEvent = { type: 'subagent/descriptor', - seq: inheritance.seed.length + directorySeed.length, + seq: inheritedEventCount + 1 + directorySeed.length, time: Date.now(), data: descriptor as unknown as Record, } - const seed = [...inheritance.seed, ...directorySeed, descriptorEvent] + const seed = [...inheritance.seed, endSeed, ...directorySeed, descriptorEvent] // Fork-marker fields (the exact shape the host's own session.fork uses, // api-session-controller): without `isSeeded` + `inheritedEventCount` the // session treats the whole seed as the child's OWN events, so the child's @@ -710,7 +726,7 @@ export function buildSidechatApi(ctx: Context, live?: AssistantLiveBuffer): Side // stamp, so importing the value aborts the whole host half at load time. // `Array#length` is a non-negative safe integer by construction — exactly // what the upstream stamp asserts — so the cast is lossless here. - inheritedEventCount: seed.length as SessionLogOffset, + inheritedEventCount: inheritedEventCount as SessionLogOffset, agentOptions: childAgentOptions, setup, signal: AbortSignal.timeout(CREATE_TIMEOUT_MS), diff --git a/tests/host-runtime-imports.spec.ts b/tests/host-runtime-imports.spec.ts index b3c1271c9..a739c6c7a 100644 --- a/tests/host-runtime-imports.spec.ts +++ b/tests/host-runtime-imports.spec.ts @@ -63,6 +63,6 @@ describe('host runtime imports', () => { const source = readFileSync(resolve(SRC, 'sidechat-routes.ts'), 'utf8') expect(source).toContain(`import type { SessionEvent, SessionId, SessionLogOffset } from '${TYPE_ONLY_PACKAGE}'`) // The cast carries the brand; the runtime stamp is what the host surface lacks. - expect(source).toContain('inheritedEventCount: seed.length as SessionLogOffset') + expect(source).toContain('inheritedEventCount: inheritedEventCount as SessionLogOffset') }) }) diff --git a/tests/sidechat-routes.spec.ts b/tests/sidechat-routes.spec.ts index acaf54960..c1325e6ff 100644 --- a/tests/sidechat-routes.spec.ts +++ b/tests/sidechat-routes.spec.ts @@ -12,7 +12,7 @@ import { resolve } from 'node:path' import WorkingDirectoryService from '@deepseek-ai/dsh-working-directory' import { Session, SessionLogOffset, SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' -import { SUBAGENT_DESCRIPTOR_VERSION } from '@deepseek-ai/dsh-subagent' +import { foldSubagentDescriptor, SUBAGENT_DESCRIPTOR_VERSION } from '@deepseek-ai/dsh-subagent' import { buildSidechatApi } from '../src/sidechat-routes.ts' import { SidebarError } from '../src/wire.ts' import { buildSidechatInheritance, SIDE_BOUNDARY_PROMPT, SIDE_INJECTION_SOURCE_KIND, SIDE_NEW_THREAD_TITLE, sideLabel } from '../src/sidechat-core.ts' @@ -243,16 +243,18 @@ describe('sidechat.start', () => { }) // The fork-marker pair is load-bearing: without it the child's Inbox // replays the parent's seed inbox splices and inherits whatever input sat - // unclaimed in the parent (see sidechat-seed-validation.spec.ts). - expect(options.inheritedEventCount).toBe(options.seed.length) + // unclaimed in the parent (see sidechat-seed-validation.spec.ts). The cut + // is the copied parent history; the marker and the descriptor after it + // are the thread's own events. + expect(options.inheritedEventCount).toBe(options.seed.length - 2) expect(options.agentOptions).toEqual({ provider: 'test', model: 'model-x' }) // The child carries the parent's completed turns as a verbatim seed, - // closed by the durable subagent descriptor (honest catalog citizenship: - // without it the host's subagents.list renders the cold thread as a - // 'corrupt' diagnostic row). + // then the inherited end-seed marker and the durable subagent descriptor + // (honest catalog citizenship: without it the host's subagents.list + // renders the cold thread as a 'corrupt' diagnostic row). expect(options.seed.map(event => event.type)).toEqual([ 'user/message', 'turn/start', 'step/start', 'assistant/message', 'step/end', 'turn/end', - 'subagent/descriptor', + 'session/end-seed', 'subagent/descriptor', ]) expect(options.seed.at(-1)?.data).toMatchObject({ // The descriptor version is stamped by the host package's @@ -310,9 +312,9 @@ describe('sidechat.start', () => { const options = services.create.mock.calls[0]![0] as { seed: Array<{ type: string; data: Record }> } expect(options.seed.map(event => event.type)).toEqual([ 'user/message', 'turn/start', 'step/start', 'step/end', 'turn/end', - 'subagent/descriptor', + 'session/end-seed', 'subagent/descriptor', ]) - expect(options.seed.at(-2)?.data).toEqual({ turn: 1, reason: { kind: 'interrupted' } }) + expect(options.seed.at(-3)?.data).toEqual({ turn: 1, reason: { kind: 'interrupted' } }) }) it('falls back to the snapshot when a tool call is still executing', async () => { @@ -332,7 +334,7 @@ describe('sidechat.start', () => { const options = services.create.mock.calls[0]![0] as { seed: Array<{ type: string }> } // Cut BEFORE the open turn: only the pending user message is inherited. - expect(options.seed.map(event => event.type)).toEqual(['user/message', 'subagent/descriptor']) + expect(options.seed.map(event => event.type)).toEqual(['user/message', 'session/end-seed', 'subagent/descriptor']) // The snapshot rides the injection, not the follow-up question. const injection = child.inject.mock.calls[0]![0] as { content: Array<{ text: string }> } expect(injection.content[0]!.text).toContain('`bash` (executing)') @@ -356,14 +358,15 @@ describe('sidechat.start', () => { const { childId } = await api['sidechat.start']({ sessionId: 'parent', question: ' ' }) // The empty thread carries the same fork-marker pair (isSeeded + - // inheritedEventCount = seed length) as the prompted create. + // inheritedEventCount = the inherited prefix) as the prompted create. const options = services.create.mock.calls[0]![0] as { meta: Record seed: readonly { type: string }[] inheritedEventCount: number } expect(options.meta.isSeeded).toBe(true) - expect(options.inheritedEventCount).toBe(options.seed.length) + expect(options.inheritedEventCount).toBe(options.seed.length - 2) + expect(options.seed[options.inheritedEventCount]).toMatchObject({ type: 'session/end-seed', data: { inherited: true } }) // No prompt yet: the composer owns the first message; the placeholder // label is pinned and the in-progress snapshot is parked for it. @@ -516,6 +519,51 @@ describe('sidechat.start', () => { expect(options.agentOptions.reasoningEffort).toBeUndefined() }) + it('puts the side thread descriptor after the inherited cut, where the host looks for it', async () => { + // The host reads a child's descriptor only from its OWN events, i.e. + // `events.slice(inheritedEventCount)` (dsh-subagent cold resume; the + // persisted-session facts read the same range). A descriptor inside the + // inherited prefix leaves the side thread without one: it lists as corrupt + // and cannot be resumed as a continuable child. + const parent = agent('parent', { + events: [ + ev('user/message', 0, { id: 'm-q', role: 'user', content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }), + ev('turn/start', 1, { turn: 1 }), + ev('step/start', 2, { turn: 1, step: 1 }), + ev('assistant/message', 3, { + turn: 1, + step: 1, + message: { id: 'm-a', role: 'assistant', content: [{ type: 'text', text: 'a' }], source: { kind: 'model', provider: 'test', model: 'model-x' } }, + stream: [], + }), + ev('step/end', 4, { turn: 1, step: 1 }), + ev('turn/end', 5, { turn: 1, reason: { kind: 'completed' } }), + ], + }) + const services = happyServices(parent, agent('child')) + const workingDirectory = { get: vi.fn((_session: unknown) => resolve('/current-execution')) } + await buildSidechatApi(ctxWith({ ...services, workingDirectory }))['sidechat.start']({ sessionId: 'parent', question: 'q' }) + const options = services.create.mock.calls[0]![0] as { + sessionId: SessionId; meta: Partial; seed: readonly SessionEvent[]; inheritedEventCount: number + } + // The REAL seeded constructor must accept the seed and keep the cut. + const child = Session.create(options.sessionId, options.seed, { + ...options.meta, id: options.sessionId, version: SESSION_FORMAT_VERSION, createdAt: Date.now(), isSeeded: true, + }, SessionLogOffset(options.inheritedEventCount)) + expect(Number(child.inheritedEventCount)).toBe(options.inheritedEventCount) + const own = child.snapshotEvents().slice(options.inheritedEventCount) + expect(foldSubagentDescriptor(own)).toMatchObject({ mode: 'continuable', provider: 'sidechat', label: sideLabel('q') }) + // The inherited prefix is exactly the parent history the thread copied. + expect(child.snapshotEvents(SessionLogOffset(0), SessionLogOffset(options.inheritedEventCount)).map(event => event.type)).toEqual([ + 'user/message', 'turn/start', 'step/start', 'assistant/message', 'step/end', 'turn/end', + ]) + // Same shape as the host's own session.fork: the inherited end-seed + // marker opens the thread's own range, followed by its own events. + expect(own.map(event => event.type)).toEqual(['session/end-seed', 'working-directory/change', 'subagent/descriptor']) + expect(own[0]!.data).toEqual({ inherited: true }) + expect(child.snapshotEvents().every((event, index) => event.seq === index)).toBe(true) + }) + it('rejects a non-running parent', async () => { const parent = agent('parent', { events: [] }) const child = agent('child') diff --git a/tests/sidechat-seed-validation.spec.ts b/tests/sidechat-seed-validation.spec.ts index 9fc3f7d66..57f0b1664 100644 --- a/tests/sidechat-seed-validation.spec.ts +++ b/tests/sidechat-seed-validation.spec.ts @@ -77,13 +77,24 @@ const pendingMessage = (id: string, kind: string, text: string) => ({ source: { kind }, }) -/** The shape the routes pass since the marker fix: isSeeded + inheritedEventCount. */ -const forkMarkedSession = (id: string, seed: readonly SidebarSessionEvent[]): Session => +/** The shape sidechat.start passes: isSeeded + inheritedEventCount = the + * inherited prefix, an inherited end-seed marker AT the cut, then the + * thread's own durable descriptor (the host's own session.fork shape). */ +const forkMarkedSession = (id: string, inherited: readonly SidebarSessionEvent[]): Session => Session.create( id as SessionId, - seed as never, + [ + ...inherited, + { type: 'session/end-seed', seq: inherited.length, time: Date.now(), data: { inherited: true } }, + { + type: 'subagent/descriptor', + seq: inherited.length + 1, + time: Date.now(), + data: snapshotSubagentDescriptor({ mode: 'continuable', provider: 'sidechat', label: 'Side: test' }), + }, + ] as never, { version: SESSION_FORMAT_VERSION, id: id as SessionId, createdAt: Date.now(), isSeeded: true }, - SessionLogOffset(seed.length), + SessionLogOffset(inherited.length), ) /** Real loop order: inbox insert → turn/start → claim deletion → step/start @@ -202,8 +213,9 @@ describe('sidechat seed against the real dsh-session validator', () => { it('accepts the durable subagent descriptor the routes append to the seed', () => { // Regression guard for the catalog-corrupt fix: a cold thread WITHOUT a // descriptor renders as a 'corrupt' diagnostic in the host subagents.list. - // The descriptor is a log-only event appended INSIDE the seed (before the - // end-seed marker); the real validator must accept the combined seed. + // The descriptor is a log-only event carried in the seed; the real + // validator must accept the combined seed. (Its position relative to the + // inherited cut is pinned in sidechat-routes.spec.ts.) const { seed } = buildSidechatInheritance(parentLog()) const descriptor = snapshotSubagentDescriptor({ mode: 'continuable', @@ -280,25 +292,16 @@ describe('sidechat seed fork markers vs the reconstructed inbox', () => { }) it('the fork-marker pair (isSeeded + inheritedEventCount) empties the ownEvents()-based fold', () => { - // Same mid-turn log as the root-cause case, plus the descriptor the - // routes append — exactly what sidechat.start passes to agents.create. + // Same mid-turn log as the root-cause case, plus the marker and the + // descriptor the routes append — exactly what sidechat.start passes to + // agents.create. // NOTE: this pins the marker pair's ownEvents() cut only — necessary but // NOT sufficient for the RUNTIME inbox, which folds the full log (the // describe below proves the hole and the route's clear() fence). const { seed } = buildSidechatInheritance(midTurnLog()) - const withDescriptor = [ - ...seed, - { - type: 'subagent/descriptor', - seq: seed.length, - time: Date.now(), - data: snapshotSubagentDescriptor({ mode: 'continuable', provider: 'sidechat', label: 'Side: test' }), - } as unknown as SidebarSessionEvent, - ] - const marked = forkMarkedSession('session-inbox-marked', withDescriptor) - // Only the end-seed marker is own; the transcript cut is unchanged. - expect(marked.ownEvents().map(event => event.type)).toEqual(['session/end-seed']) - expect(marked.snapshotEvents().map(event => event.type).at(-1)).toBe('session/end-seed') + const marked = forkMarkedSession('session-inbox-marked', seed) + // Only the end-seed marker and the descriptor are own. + expect(marked.ownEvents().map(event => event.type)).toEqual(['session/end-seed', 'subagent/descriptor']) expect(inboxOver(marked)).toEqual({ nextTurn: 0, nextStep: 0 }) }) }) @@ -358,16 +361,7 @@ describe('sidechat seed vs the REAL projection registry (the runtime inbox path) ev('agent/inbox/spliced', 16, { target: 'next-turn', start: 0, inserted: [pendingMessage('m-late', 'user', 'queued while running')] }), ] const { seed } = buildSidechatInheritance(log) - const withDescriptor = [ - ...seed, - { - type: 'subagent/descriptor', - seq: seed.length, - time: Date.now(), - data: snapshotSubagentDescriptor({ mode: 'continuable', provider: 'sidechat', label: 'Side: test' }), - } as unknown as SidebarSessionEvent, - ] - return forkMarkedSession('session-inbox-registry', withDescriptor) + return forkMarkedSession('session-inbox-registry', seed) } it('the fork-marker pair ALONE leaves the phantom input live in the runtime inbox (the rc.2 projection hole)', () => { @@ -398,6 +392,6 @@ describe('sidechat seed vs the REAL projection registry (the runtime inbox path) // stored log to the same empty state, and the transcript cut is // unchanged (splices never render). expect(child.ownEvents().map(event => event.type)) - .toEqual(['session/end-seed', 'agent/inbox/spliced', 'agent/inbox/spliced']) + .toEqual(['session/end-seed', 'subagent/descriptor', 'agent/inbox/spliced', 'agent/inbox/spliced']) }) })