feat(sidechat): 侧边对话——每对话一 Tab(Codex 对齐)+ 完整上下文继承 + 保存为新会话 (#286)

三轮实测驱动迭代:种子信封透传(surfaceOp 校验)、每线程一 Tab 交互重做完、四连修(fork 丢 this / 自绘 history+save 图标 / collectOwnEvents 大页回源修重开丢工具行 / descriptor 入种子修目录 corrupt 行 + 前缀过滤保拓扑零噪音)。739 单测 + 挂载冒烟 12/12 全绿。
This commit is contained in:
Atlas Zeng
2026-08-21 15:41:14 +08:00
committed by GitHub
parent 50a888845f
commit 7f4f84c4cf
27 changed files with 3501 additions and 13 deletions
+1
View File
@@ -353,6 +353,7 @@ ctx.effect(() => {
| `editor` | 10 | 否(按 path 去重) | 否 | 唯一的「文件窗口」(文件编辑/预览 + 文件资源管理):文件 tab(有 path)在两种 `editorExplorer` 模式下 chrome 恒为合并形态——头部路径输入框 + 文本编辑器预览/编辑/保存控件 + 可开关的内嵌文件树面板(含全局文件名搜索,走 host `fs.search` 路由;左缘拖拽调宽),状态存 `tab.meta.treeOpen` / `tab.meta.treeWidth`;pref 控制**打开行为与无路径窗口形态**:关(默认,独立)= 走 `openSidebarFile` 按 path 新开,**无路径窗口即独立资源管理器——只渲染文件树面板**(搜索 + FileTree 撑满全窗,无编辑器 chrome);开(合并)= 树点击/输入框 Enter 原地切换当前 tab(`updateTab` 重写 path/title,id 与 meta 不变),无路径窗口 = 带 chrome 的空文件窗口(树默认展开)。树右键菜单提供「在新 Tab 中打开」「在侧边打开」(后者在当前 pane 右侧 split 出新 editor tab)。新会话在两种模式下都默认 seed 空文件窗口(`title: 'Files'`,无 path,树面板展开);持久化的旧 `explorer` tab 经 `sanitizeState` 迁移为该 home tab |
| `git` | 20 | 是 | 否 | Git 面板 |
| `subagent` | 30 | 是 | 否 | 子代理拓扑 |
| `sidechat` | 35 | 否(createTab 铸造 `sidechat:<uuid>`/按 `meta.threadId` 去重) | 否 | 侧边对话(Codex 风格,**每个对话一个独立 Tab**):打开 Tab 即自动创建空线程(composer 拥有首条消息,host 侧包裹边界提示 + 创建时停泊的进行中快照,首条消息赢得真实标签并同步 Tab 标题);线程 = 插件自建子会话(自定义种子继承父会话完整上下文,进行中回合以 `interrupted` 冻结诚实闭合;种子尾部带合法 `subagent/descriptor`——否则 cold 线程在宿主 subagents.list 里是 corrupt 诊断行——SubagentView/subagent-detect 按 `Side: ` 前缀过滤保持拓扑零噪音),`origin:'subagent'` 隐藏于主列表;生命周期走自有 `/sidebar/api/sidechat.*` 路由(`ctx.agents.create/resume` + `agent.followup/cancel` + `sidechat.info` 读 Agent 身份/状态);头部菜单可切换/重开既有线程(`parkSidechatReopen` + `sidechat:<threadId>` 确定性 id),关闭 Tab 释放 live agent(历史保留);重开 Tab 经 `collectOwnEvents` 大页回源到种子边界(cold 读会展开 chunk 压缩行,小窗口会丢早期 tool/call 行);「保存为新会话」= `session.fork` 提升顶层会话(必须方法调用形态,`this` 敏感)。UI 对齐主对话区(用户气泡 `--dsw-specific-bubble`、assistant 通栏 markdown、胶囊 composer + 圆形发送/停止钮、运行扫光状态行)。见 [设计文档](docs/plans/2026-08-20-sidechat-tab-design.md) |
| `terminal` | 40 | 否 | 否 | 终端(nextTerminal 自增) |
| `browser` | 50 | 否(createTab 铸造 browser:`<n>`,nextBrowser 自增) | 否 | 内嵌网页浏览器(沙箱 iframe;可设置关闭沙箱) |
| `diff` | -1 | 否(按 id 去重) | 是 | 差异查看(由 GitView 触发) |
+7
View File
@@ -26,6 +26,7 @@
- **💻 真实终端**:xterm.js + node-pty 真实 shell,断线重连回放;可选为模型注入 `terminal_*` 工具
- **🌿 Git 面板**:真 diff + VSCode 式 diff tab、历史、右键暂存 / 提交 / 还原
- **🧩 后台任务页**:subagent 拓扑 + 后台任务(退出码 / 实时输出 / 强制终止)
- **💬 侧边对话**:Codex 风格的侧边线程——继承主会话完整上下文(含进行中的回合与工具调用)独立运行,不进入主会话;线程内可持续追问,一键「保存为新会话」提升为顶层会话
- **🪟 双工作台**:右侧栏 + 底部面板;拖 Tab 拆分 / 合并分栏(可跨面板),移动端自动合并全宽抽屉
- **🔁 会话隔离**:布局 / Tab / 面板按会话持久化,陈旧状态自动净化
- **⚙️ 声明式设置**:设置页「侧边卡片」逐项独立开关,二级设置经齿轮弹窗
@@ -42,6 +43,12 @@
<a href="https://github.com/user-attachments/assets/d4385b7e-aab4-425d-a5c4-2da5da81a34e"><img width="33%" alt="添加插件截图" src="https://github.com/user-attachments/assets/d4385b7e-aab4-425d-a5c4-2da5da81a34e" /></a>
</div>
### v0.14.1(开发中)
**✨ 新功能**
- 💬 **侧边对话 Tab**:Codex 风格的侧边线程——子会话继承主会话**完整上下文**(已完成回合 + 未回答消息 + 进行中回合的 assistant 输出与工具调用,以「interrupted」冻结标记诚实继承);同组合创建(同 preset / provider / model)复用前缀输入缓存;线程对主会话列表不可见、零子代理目录噪音;线程内可持续追问(重启后自动恢复);一键「保存为新会话」提升为顶层会话([设计文档](docs/plans/2026-08-20-sidechat-tab-design.md))
### v0.14.1
**✨ 新功能**
+7
View File
@@ -26,6 +26,7 @@
- **💻 Real Terminal**: xterm.js + node-pty real shell, reconnect with transcript replay; optionally injects `terminal_*` tools for the model
- **🌿 Git Panel**: real diff + VSCode-style diff tabs, history, right-click to stage / commit / revert
- **🧩 Background Tasks**: agent topology + background tasks (exit codes / live output / force-kill)
- **💬 Side Chat**: Codex-style side threads — the child inherits the parent's FULL context (completed turns + the pending question + the in-progress turn's assistant output and tool activity, honestly frozen as "interrupted") and runs independently without entering the main conversation; threads support continuous follow-ups (auto-resumed after a DSH restart) and one-click "Save as new session" promotion to a top-level session
- **🪟 Dual Workbench**: right sidebar + bottom panel; drag tabs to split / merge panes (cross-panel), mobile auto-merges into a full-width drawer
- **🔁 Session Isolation**: layout / tabs / panels persisted per session, stale state auto-purged
- **⚙️ Declarative Settings**: per-item toggles in the "Side Cards" settings section, secondary settings via the gear dialog
@@ -41,6 +42,12 @@
<a href="https://github.com/user-attachments/assets/d4385b7e-aab4-425d-a5c4-2da5da81a34e"><img width="45%" alt="Add Plugins screenshot" src="https://github.com/user-attachments/assets/d4385b7e-aab4-425d-a5c4-2da5da81a34e" /></a>
</div>
### v0.14.1 (in development)
**✨ New features**
- 💬 **Side Chat tab**: Codex-style side threads — the child inherits the parent's FULL context (completed turns + pending message + the in-progress turn's assistant output and tool activity, honestly frozen with an "interrupted" marker); created with an identical composition (same preset / provider / model) so the first request reuses the parent's input prefix cache; threads stay invisible in the main session list with zero subagent-catalog noise; follow-ups survive DSH restarts (auto cold-resume); one-click "Save as new session" promotes the thread to a top-level session ([design](docs/plans/2026-08-20-sidechat-tab-design.md))
### v0.14.1
**✨ New features**
@@ -0,0 +1,97 @@
# 侧边对话(Side Chat)Tab 设计
**日期**:2026-08-20
**状态**:已批准,实施中
**作者**:opencode + 用户
**当前版本**:v0.14.1(分支 `feat/sidechat-tab`)
**目标版本**:v0.14.1(不 bump)
## 1. 目标
1. 对标 Codex 的 side 对话,为 better-sidebar 新增内置 Tab **sidechat**:在当前主会话下开一个**继承主会话完整上下文**的独立子会话线程,线程在 Tab 内可续聊、可查看完整时间线(user / assistant / reasoning / tool)。
2. **继承必须是结构化的**:线程种子 = 父会话点击时刻的全部事件——已完成回合 + 未回答的 user 消息 + **进行中回合的 assistant 流式输出与工具调用**(原样复制,合成 `step/end`+`turn/end{reason:'interrupted'}` 诚实闭合,子会话看到的是「被切断的进行中回合」而非完整定稿)。
3. **前缀缓存复用**:子会话与父会话同组合(同 preset / 同 provider/model、无 persona/toolFilter、非 continuable 无 report 增量)→ 子会话首个请求的 token 前缀与父会话当前请求一致(到未回答 user 消息为止),命中 provider 前缀缓存。
4. **即用即销语义 + 保存为新会话**:线程以 `origin:'subagent'` 创建(主会话列表不可见、子代理目录零噪音);用户可一键「保存为新会话」——`session.fork` 提升为顶层会话(自动命名、挂工作区、打开)。
5. 零 dsh-subagent 依赖;dsh-subagent 缺失时功能不受影响。
## 2. 非目标(Out of Scope)
- 不做 `/btw` 一次性侧问(只做持续可续聊线程)。
- 不做合并回主会话 / 重命名线程。
- **不改 DSH 源码**:不加 promote RPC、不改 fork 边界、不改 continuation 机制。
- 工具执行中途(悬挂 tool/call)的事件级继承不可行(provider 拒绝悬挂 assistant 调用)→ 该窗口回退为「截断 + 结构化文本转储」(见 §4)。
## 3. 为什么自建机制(重实现)
用户明确拍板:不走 `ctx.subagents.startContinuable`。原因(已核实源码):
1. **种子粒度受限**:fork provider 的 `completedTurnPrefix` 固定切在最后一个 `turn/end`(`subagent-fork-in-process/src/index.ts`),`session.fork` RPC 显式拒绝 open-turn 锚点(`core/session` `OPEN_TURN`)——进行中回合永远进不了种子。
2. **continuable 组合破坏前缀复用**:continuable 子会话的请求头部在继承历史之前插入 `report` 工具 + `tool:report` 提示段(`.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md`,issue #2124)——前缀从请求头即分叉,重发整个转录却零 cache 收益。DSH 官方因此把 fork 绑定为 one-shot。
3. **无法注入自定义种子**:`ContinuableStartSpec` 无 seed 字段,创建路径无注入点。
自建机制只复用 DSH 公开 host 能力:`ctx.agents.create/resume`(AgentRegistry 公开方法,api-proxy fork 与 subagent driver 同用)、`agent.followup/cancel`(公开方法)、`ctx.get('agentPresets')`、`ctx.get('sessionTitle')`、`ctx.get('sessionPersistence')`。客户端复用 `ctx.sessions.list / fork / binding / open` 与通用 `session.history` RPC。
## 4. 种子算法(`src/sidechat-core.ts`)
1. 复制父会话**全部事件**(点击时刻快照;live 日志 seq === 数组下标,保持连续)。
2. 日志结束于 turn 外(最后 turn 边界是 turn/end 或无 turn):种子 = 全部复制事件(可能以未回答 user 消息结尾,合法)。
3. 结束于 open turn 内:
- **当前 step 存在无配对 tool/call(悬挂)** → 回退:切到该 turn 的 `turn/start` 之前(保留已完成回合 + 未回答 user 消息);进行中内容(含执行中的工具调用,标注 executing)走 `buildOpenTurnSnapshot` 结构化文本转储并入 boundary 消息。原因:validator 不拒悬挂调用(step/end 清空 pendingCalls),但 **provider 拒绝悬挂 assistant 调用**(`core/session/src/repair.ts:108`)。
- 否则:追加合成 `step/end{turn,step}`(若 step 未闭合)+ `turn/end{turn, reason:{kind:'interrupted'}}`(编号取自已复制事件的 turn/start、step/start)→ 种子平衡合法(种子逐事件过同一不变式检查器,`core/session/src/index.ts:517-541`;`turn/end` schema 要求 reason,`interrupted` 是合法持久化变体,语义即「回合未完成被切断」——诚实标记,客户端对冻结局部渲染「已停止」)。
子会话首个请求(常见路径)= [已完成回合][未回答 user][冻结的进行中回合:chunk 文本 + 已完成工具调用/结果][boundary+question]。前缀复用不受影响:共享前缀到未回答 user 消息为止,冻结回合位于共享前缀之后。
## 5. 线程生命周期(host 路由,`/sidebar/api/sidechat.*`,既有信任 fence)
- **start** `{sessionId, question}`:父 Agent 必须 live;`buildSidechatInheritance` 出种子 + 转储;`ctx.agents.create({meta:{cwd,parentSession,seedLength,origin:'subagent',delegationDepth:父+1,agentPreset}, seed, agentOptions:父 provider/model, setup:preset 组合})`;`agent.followup(createUserMessage({content:[boundary+转储?+question], source:{kind:'user'}}))`;`sessionTitle.rename(session,'Side: '+截断(question,48))` 钉死线程标签;返回 `{childId}`。
- **prompt** `{childId, text}`:live → `followup`;absent(重启/关闭后)→ `ctx.agents.resume`(`resolvePresetId` 从 persisted header/events 解析 preset,`agentPresets.resolve/mount` 组合)→ `followup`。
- **cancel** `{childId}`:`agent.cancel({kind:'user'},{keepInbox:true})`。
- **dispose** `{childId}`:释放创建/恢复时保存的 AgentHandle disposer(会话与历史保留)。
为什么全走自有路由:`origin:'subagent'` 的会话被通用 RPC 的 agent-lookup 所有权 fence 全面封锁(`api/remotes/agent-lookup.ts` `hasApiRemoteSubagentOwner`),`subagents.prompt` 对我们的线程也无法寻址——自有路由直接调 registry/agent 绕开 fence,且只依赖公开方法。~~线程没有 subagent/descriptor(无目录注册、零噪音:`list-children.ts` 投影为 undefined 即丢弃)~~ **修正(v3 起)**:该判断只对 live 线程成立——cold 线程无 descriptor 时 `resolveColdIdentity` 确定性产出 `corrupt` 诊断行(`list-children.ts:405`),用户实测在 Subagent 页看到「目录损坏」。修复 = 线程种子尾部追加合法的 `subagent/descriptor`(`snapshotSubagentDescriptor`,version 2 / continuable / provider `'sidechat'` / label 即 `Side: ` 标题),线程成为目录里的健康行;SubagentView 与 `subagent-detect.ts` 按 `Side: ` 标签/标题前缀过滤,拓扑 UI 与自动打开触发保持零噪音(模型侧 `list_agents` 也会列出带标签的健康行,而非 corrupt 垃圾)。
## 6. 客户端 Tab(`src/client/SideChatView.tsx`)
- 内置 descriptor:`id:'sidechat'`、`order:35`、`single:true`、图标 `IconNewChatOutline16`。
- 两栏布局:左线程列表(`sideThreadRows`:`origin==='subagent' && parentId===当前 && displayTitle 前缀 'Side: '`;订阅 `ctx.sessions.list`);右详情(时间线 + 输入框 + 保存/停止/关闭按钮)。
- 时间线:轮询 `connection.api.sessions.history`(通用 RPC,未 fence;`session/end-seed` 截断 + boundary 行丢弃 + chunk 流式累加 + 工具配对——`src/client/sidechat-transcript.ts` 纯映射);可见且运行中 ~2s,seq 去重缓存;`visible=false` 停轮询。
- 保存为新会话:`ctx.sessions.fork({sessionId: threadId, increaseTitle:true})` → `binding(newId).session.rename(标题去前缀)` → `ctx.sessions.open(newId)`。无已完成回合 → 禁用 + 提示;末条追问未完成 → 提示不包含(fork 边界=最后 turn/end)。
- 选中线程持久化:`tab.meta.threadId`(既有 per-session localStorage 通道)。
## 7. 边界情况与失败模式
| 场景 | 行为 |
|---|---|
| 父会话无 live agent | 409 `sidechat-error` 内联提示 |
| 父会话进行中(流式/思考/工具已返回) | 完整事件入种子 + interrupted 合成闭合 |
| 父会话工具执行中途点击新建 | 悬挂 tool/call → 截断 + 结构化转储(工具标注 executing) |
| seam 缺失(agentPresets/sessionTitle/sessionPersistence) | 降级:默认组合 / 自动标题 / 无 preset 组合的冷恢复 |
| DSH 重启后续聊 / 线程关闭后追问 | `ctx.agents.resume`(persisted preset 组合) |
| 线程无已完成回合 | 保存禁用 + 提示 |
| 线程末条追问未完成 | 保存前提示不包含 |
| 与 dsh-sidechain 并存 | 各自独立;双方线程都有 descriptor(目录健康行),SubagentView 按 `Side: ` 前缀过滤均不可见;双方共享 `Side: ` 标签约定,线程互见 |
## 8. 测试与自验
- 单测:`sidechat-core.spec.ts`(种子全分支:turn 外 / 空 / 仅 pending user / open turn 无工具 / 成对工具 / 悬挂回退 / 多 step 闭合 / seq 连续性;转储保真;线程过滤;保存资格;preset 解析;boundaryDelivered)、`sidechat-seed-validation.spec.ts`(真实 `Session.create` 校验器接受种子产物)、`sidechat-transcript.spec.ts`(种子截断、boundary 丢弃、chunk 流式、工具配对、孤儿失败工具)、`sidechat-routes.spec.ts`(start 带问/空问立即创建、首条 prompt 包裹边界+快照并改名、prompt live+cold、cancel、dispose、info live/cold/未知)、`builtins.spec.ts` 7 tab + sidechat 每线程一 Tab 铸造/去重。
- e2e 挂载冒烟:+ 菜单深扫含 Side Chat;**host 路由级自验**(真实 keyless dsh web 中 sidechat.start/prompt/cancel/dispose/info 全链路 + 空问题立即创建流程,种子会话为父)。
- 真实环境手测(实施后勾选):新建线程 → 追问往返 → 流式渲染 → 工具行 → 主会话流式中新建(冻结继承)→ 工具执行中途新建(回退转储)→ 重启后续聊 → 保存为新会话 → 刷新持久。
## 9. 复用门记录
搜索面:DSH checkout `docs/` + `packages/`(关键词 side conversation / sidetrack / thread / promote)+ dsh-external hub catalog(`search-org.mjs`,323 repos)。命中:`dsh-external/dsh-sidechain`(Codex 风格侧会话,/side & /btw,临时 fork + 侧栏面板),但**无 better-sidebar Tab、无保存为新会话**;用户拍板独立实现(「我们做独立实现」),sidechain 保持不动。本文档与 PR 记录该结论。
## 10. 实施偏差记录
- 路由键命名:`buildSidechatApi` 返回对象的键必须是**完整 wire 方法名**(`'sidechat.start'` 等,/sidebar/api 分发器按 `api[method]` 查找),而非短名 `start/prompt/…`。首次实现用了短名,单测直接调对象方法未暴露,真实挂载冒烟以 404 抓出(该冒烟因此新增了路由级自验,见 §8)。
- 种子函数入参类型:sidechat-core 使用宽松结构类型 `SidechatLogEvent {type, seq, time, data: unknown}`(同时接受 host 真实 `SessionEvent` 与 client 的 `SidebarSessionEvent` 镜像),避免两半的类型图互相污染。
- keyless 挂载冒烟无法验证 boundary 消息落盘:`agent.followup` 的消息先入 inbox,被 turn claim 后才写入 session 日志——keyless(无模型路由)下 turn 不会 claim。挂载冒烟的深度断言因此改为 **session.list 成员校验**(证明子会话真实创建,provider 无关);boundary 落盘与流式/回答在真实 provider 环境手测(§8 清单)。
- **UI 两轮返工(用户实测驱动)**:
1. 种子事件信封字段丢失(用户实测报 `invalid seed event … requires a surfaceOp marker`):`copyEvents` 重建事件时只保留 `{type,seq,time,data}`,剥掉了 `surfaceOp`/`sourceEventSeqs`/`ignorable`,而种子校验器要求 surface-eligible 事件必带 `surfaceOp`。修复 = 信封字段原样透传 + 新增 `sidechat-seed-validation.spec.ts`(用真实 `@deepseek-ai/dsh-session` 的 `Session.create` 校验器跑种子产物,回归守护)。
2. 交互模型从「单 Tab + 内嵌线程列表 + 首问表单」改为 **Codex app 对齐的「每线程一 Tab」**:`createTab` 铸造 `sidechat:new-<uuid>`(meta `autoCreate`,视图挂载即调 `sidechat.start` 空问题立即建线程)或消费 `parkSidechatReopen` 停泊的 `sidechat:<threadId>` 重连 Tab;`dedupeKey = meta.threadId` 保证同线程聚焦不重复;`onClose` 释放 live agent(历史保留,头部菜单可重开)。配套后端变化:`sidechat.start` 的 `question` 变为可选(空 = 仅建线程,快照停泊在 `pendingSnapshots`);`sidechat.prompt` 检测边界未送达时(`boundaryDelivered` 扫日志)自动包裹边界 + 停泊快照并用首条消息改名;新增 `sidechat.info` 路由(live 状态 + provider/model/preset 身份,供头部 Agent 徽标)。UI 对齐主对话区:用户右对齐气泡(`--dsw-specific-bubble`)、assistant 通栏 markdown、胶囊 composer(`--dsw-specific-input-major` + 圆形 accent 发送/停止钮 + 自动增高 textarea)、运行扫光状态行与工具行(`StateDot` + shimmer),动效全部 ≤200ms 交叉淡化并受 `prefers-reduced-motion` 收敛。
- (预留)实施过程中如有与本文档的其他偏差,在此记录。
- **第三轮实测修复(4 bug)**:
1. 「保存为新会话」报 `Cannot read properties of undefined (reading 'list')`:`handleSave` 把 `ctx.sessions.fork` 解构成自由函数再调用,丢失 `this`——client-runtime 的 `service.fork` 内部读 `this.list.getSnapshot()`(标题递增)。修复 = 保持方法调用形态(`ctx.sessions.fork({...})`)。
2. 图标语义:线程切换菜单改自绘 `IconHistoryOutline16`(逆时针时钟)、保存按钮改自绘 `IconSaveOutline16`(软盘),沿用 1.5px 描边规范(primitives 无现成 history/save 图标)。
3. 重开线程 Tab 丢工具行:根因**不在宿主**(`session.history` cold 路径原样返回 `tool/call`,见 `api-proxy.ts:749` 与 `chunk-rows.ts` 的无损 chunk 压缩)而在首挂回源窗口——旧实现 8 事件/页 × 32 页 = 256 事件上限,而 cold 读会把 chunk-rows 展开回每个流式 delta 一条事件,一轮回答动辄数百事件,较早的 `tool/call` 掉出窗口(settled 文本靠最终 `assistant/message` 幸存)。修复 = 抽出 `collectOwnEvents`(200 事件/页 × 40 页帽、日志穷尽/无标记时落定 `seedBoundary:0` 停止每轮重走),单测覆盖四分支。
4. Subagent 页「目录损坏」行:见 §5 修正——种子追加 descriptor + SubagentView/subagent-detect 按前缀过滤(`isSideThreadSummary` 同时修好 autoOpenSubagent 误触发与计数膨胀)。旧版创建的线程仍是 corrupt 诊断行,由 SubagentView 的标题前缀过滤兜底隐藏。
+2
View File
@@ -96,6 +96,7 @@
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.8",
"@deepseek-ai/dsh-llm": "^0.1.0-rc.8",
"@deepseek-ai/dsh-session": "^0.1.0-rc.8",
"@deepseek-ai/dsh-subagent": "^0.1.0-rc.8",
"@deepseek-ai/dsh-settings": "^0.1.0-rc.8",
"@deepseek-ai/dsh-tools": "^0.1.0-rc.8",
"cordis": "^4.0.0-rc.8",
@@ -150,6 +151,7 @@
"@deepseek-ai/dsh-llm": "0.1.0-rc.8",
"@deepseek-ai/dsh-session": "0.1.0-rc.8",
"@deepseek-ai/dsh-settings": "0.1.0-rc.8",
"@deepseek-ai/dsh-subagent": "0.1.0-rc.8",
"@deepseek-ai/dsh-tools": "0.1.0-rc.8",
"@playwright/test": "^1.62.1",
"@types/node": "^24.0.0",
+3
View File
@@ -132,6 +132,9 @@ importers:
'@deepseek-ai/dsh-settings':
specifier: 0.1.0-rc.8
version: 0.1.0-rc.8(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-brand@0.1.0-rc.8(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.8(@deepseek-ai/cordis@4.0.1)))(@deepseek-ai/dsh-invariants@0.1.0-rc.8(@deepseek-ai/cordis@4.0.1))(@deepseek-ai/schemastery@3.18.1)
'@deepseek-ai/dsh-subagent':
specifier: 0.1.0-rc.8
version: 0.1.0-rc.8(52d3162df9a65923cc941f3a328408da)
'@deepseek-ai/dsh-tools':
specifier: 0.1.0-rc.8
version: 0.1.0-rc.8(ac9081d1e007de8a90a707560a8ae1c3)
+396
View File
@@ -0,0 +1,396 @@
/* Side Chat tab: ONE side conversation per tab (Codex-style). Visual
* language mirrors the main conversation pane: right-aligned user bubbles
* on --dsw-specific-bubble, full-width assistant markdown, a capsule
* composer card on --dsw-specific-input-major with a circular accent
* send/stop button, and shimmer reserved for the running status line and
* executing tools. Motion is crossfade-only (≤200ms entrances, no
* springs); reduced-motion disables every loop. All values consume DSW
* tokens (fallbacks point at older alias tokens for rc-era hosts). */
.sidechat {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
/* ── thread header (status dot · agent badge · threads menu · save) ─── */
.sidechatDetailHeader {
flex: none;
display: flex;
align-items: center;
gap: 4px;
min-height: 36px;
padding: 4px 8px 4px 12px;
border-bottom: 1px solid var(--dsw-alias-hairline);
}
.sidechatHeaderDot {
flex: none;
}
.sidechatHeaderSpacer {
flex: 1;
min-width: 0;
}
.sidechatAgentBadge {
flex: none;
max-width: 55%;
padding: 1px 8px;
border: 1px solid var(--dsw-alias-hairline);
border-radius: 999px;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-tertiary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidechatIconBtn {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
padding: 0;
border: none;
border-radius: 6px;
background: transparent;
color: var(--dsw-alias-label-secondary);
cursor: pointer;
transition: background-color 100ms ease-out, color 100ms ease-out;
}
.sidechatIconBtn:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
.sidechatIconBtn:disabled {
opacity: 0.4;
cursor: default;
}
/* ── hero (unbound tab: the one brand moment) ───────────────────────── */
.sidechatHero {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 24px 20px;
color: var(--dsw-alias-label-tertiary);
text-align: center;
animation: sidechatFadeIn 200ms ease-out;
}
.sidechatHeroTitle {
font: var(--dsw-font-s-14);
font-weight: 500;
color: var(--dsw-alias-label-primary);
}
.sidechatHeroDesc {
max-width: 300px;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-tertiary);
line-height: 1.6;
}
.sidechatPrimaryBtn {
flex: none;
margin-top: 4px;
padding: 6px 14px;
border: none;
border-radius: 999px;
background: var(--dsw-alias-button-info-fill, var(--dsw-alias-accent));
color: var(--dsw-alias-button-info-label, var(--dsw-alias-accent-ink, #fff));
font: var(--dsw-font-s-13);
cursor: pointer;
transition: opacity 100ms ease-out;
}
.sidechatPrimaryBtn:hover:not(:disabled) {
opacity: 0.88;
}
.sidechatPrimaryBtn:disabled {
opacity: 0.4;
cursor: default;
}
.sidechatHint {
flex: none;
padding: 4px 12px;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-tertiary);
}
.sidechatError {
flex: none;
padding: 4px 12px;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-danger);
}
/* ── transcript ─────────────────────────────────────────────────────── */
.sidechatScroll {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
/* Rows crossfade in on append (keys are stable, so only NEW rows play). */
.sidechatScroll > * {
animation: sidechatRowIn 180ms ease-out;
}
/* User message: right-aligned bubble, same surface as the main pane. */
.sidechatUser {
align-self: flex-end;
max-width: 88%;
padding: 8px 14px;
border-radius: 18px;
background: var(--dsw-specific-bubble, var(--dsw-alias-bg-base));
font: var(--dsw-font-s-14);
color: var(--dsw-alias-label-primary);
overflow-wrap: anywhere;
}
/* Assistant message: full-width narration, no bubble — the main pane way. */
.sidechatAssistant {
align-self: stretch;
font: var(--dsw-font-s-14);
color: var(--dsw-alias-label-primary);
overflow-wrap: anywhere;
}
.sidechatReasoning {
align-self: stretch;
}
.sidechatReasoningSummary {
display: inline-block;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
list-style: none;
}
.sidechatReasoningSummary::-webkit-details-marker {
display: none;
}
.sidechatReasoningBody {
margin-top: 2px;
padding-left: 2px;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-tertiary);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.sidechatTool {
align-self: stretch;
border: 1px solid var(--dsw-alias-hairline);
border-radius: 10px;
background: var(--dsw-alias-bg-base);
overflow: hidden;
}
.sidechatToolSummary {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-secondary);
cursor: pointer;
list-style: none;
}
.sidechatToolSummary::-webkit-details-marker {
display: none;
}
.sidechatToolName {
font-family: var(--dsw-font-mono);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Running tool: a light sweep across the summary row (shimmer = generating). */
.sidechatToolExecuting {
background-image: linear-gradient(
90deg,
transparent 0%,
var(--dsw-alias-interactive-bg-hover) 50%,
transparent 100%
);
background-size: 200% 100%;
animation: sidechatSweep 2.6s linear infinite;
}
.sidechatToolFailed {
color: var(--dsw-alias-danger);
}
.sidechatToolArgs,
.sidechatToolResult {
margin: 0;
padding: 6px 10px;
border-top: 1px solid var(--dsw-alias-hairline);
font: var(--dsw-font-xxs-12);
font-family: var(--dsw-font-mono);
color: var(--dsw-alias-label-secondary);
white-space: pre-wrap;
overflow-wrap: anywhere;
max-height: 220px;
overflow-y: auto;
}
/* ── running status line (above the composer, main-pane style) ──────── */
.sidechatStatus {
flex: none;
display: flex;
align-items: center;
gap: 8px;
padding: 2px 14px 6px;
animation: sidechatFadeIn 160ms ease-out;
}
.sidechatStatusText {
font: var(--dsw-font-xxs-12);
background-image: linear-gradient(
90deg,
var(--dsw-alias-label-tertiary) 0%,
var(--dsw-alias-label-primary) 50%,
var(--dsw-alias-label-tertiary) 100%
);
background-size: 200% 100%;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
animation: sidechatSweep 2.6s linear infinite;
}
/* ── composer (capsule card, InputBar-aligned) ──────────────────────── */
.sidechatComposer {
flex: none;
display: flex;
flex-direction: column;
gap: 4px;
margin: 0 8px 8px;
padding: 8px 8px 6px 14px;
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin, var(--dsw-alias-hairline));
border-radius: 16px;
background: var(--dsw-specific-input-major, var(--dsw-alias-bg-base));
box-shadow: var(--dsw-shadow-lv2, none);
}
.sidechatComposerInput {
width: 100%;
box-sizing: border-box;
padding: 2px 0;
border: none;
background: transparent;
color: var(--dsw-alias-label-primary);
font: var(--dsw-font-s-14);
line-height: 22px;
outline: none;
resize: none;
max-height: 132px;
}
.sidechatComposerInput::placeholder {
color: var(--dsw-alias-label-tertiary);
}
.sidechatComposerBar {
flex: none;
display: flex;
align-items: center;
gap: 8px;
min-height: 28px;
}
.sidechatComposerMeta {
flex: 1;
min-width: 0;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-tertiary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidechatSendBtn {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: none;
border-radius: 50%;
background: var(--dsw-alias-button-info-fill, var(--dsw-alias-accent));
color: var(--dsw-alias-button-info-label, var(--dsw-alias-accent-ink, #fff));
cursor: pointer;
/* send ⇄ stop swap: keyed remount plays this, hover only fades. */
animation: sidechatBtnIn 120ms ease-out;
transition: opacity 100ms ease-out;
}
.sidechatSendBtn:hover:not(:disabled) {
opacity: 0.88;
}
.sidechatSendBtn:disabled {
opacity: 0.35;
cursor: default;
}
/* ── motion vocabulary (crossfade only, ≤200ms; loops reduced-motion off) */
@keyframes sidechatRowIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes sidechatFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes sidechatBtnIn {
from { opacity: 0; transform: scale(0.85); }
to { opacity: 1; transform: scale(1); }
}
@keyframes sidechatSweep {
from { background-position: 200% 0; }
to { background-position: -200% 0; }
}
@media (prefers-reduced-motion: reduce) {
.sidechatScroll > *,
.sidechatHero,
.sidechatStatus,
.sidechatSendBtn {
animation: none;
}
.sidechatStatusText,
.sidechatToolExecuting {
animation: none;
background-image: none;
color: var(--dsw-alias-label-tertiary);
}
}
+568
View File
@@ -0,0 +1,568 @@
/**
* Side Chat page: Codex-style side conversations for the current session.
*
* EVERY side conversation is its own sidebar tab (侧边对话1/2/3 …): the
* descriptor's createTab mints a fresh tab flagged `autoCreate` and this
* view creates the EMPTY thread on mount (one click = one conversation,
* exactly like the Codex app); the composer owns the first message (the
* host wraps it with the side boundary + the in-progress snapshot parked
* at creation, and the thread earns its real label — and the tab its
* title — from that first message). Closing the tab releases the thread's
* live agent (its history stays persisted); the header menu reopens any
* existing thread into a tab (deduped by threadId).
*
* Each side thread is a child session the plugin created itself with a
* custom seed (the parent's full log up to the click moment — see
* sidechat-core.ts). Transport: thread creation/follow-up/cancel/dispose/
* info go through the plugin's own /sidebar/api sidechat.* routes
* (subagent-origin identities are fenced from the generic session RPCs);
* the transcript is polled from the generic session.history RPC (seed-cut
* at session/end-seed, boundary row dropped, chunk streaming accumulated)
* — see sidechat-transcript.ts.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useSyncExternalStore } from 'react'
import clsx from 'clsx'
import {
IconNewChatOutline16,
IconPlusOutline16,
IconSendOutline16,
IconStopFill16,
MarkdownText,
Menu,
StateDot,
type MenuEntry,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { IconHistoryOutline16, IconSaveOutline16 } from './icons.tsx'
import type { Context, SidebarHistoryEntry } from '../context-types.ts'
import {
SIDE_LABEL_PREFIX,
SIDE_NEW_THREAD_TITLE,
sideThreadRows,
threadHasCompletedTurn,
threadTrailingPending,
type SidechatThreadInfo,
} from '../sidechat-core.ts'
import { collectOwnEvents, transcriptRows, type SidechatTranscriptRow } from './sidechat-transcript.ts'
import { api } from './api.ts'
import { t } from './locales.ts'
import type { SessionScope } from './api.ts'
import type { SidebarTab } from './state.ts'
import css from './SideChatView.module.css'
/** Tail-page size for one transcript poll (events per page). Small on
* purpose: streaming polls ride the tail and merge by seq. */
const PAGE_MESSAGES = 8
/** First-attach walk page size: cold reads re-expand chunk-rows into one
* event per streamed delta, so a single answer can be hundreds of events —
* the walk must page big or earlier tool/call rows fall out of the window. */
const WALK_PAGE_EVENTS = 200
/** Poll cadence while the selected thread is running and the tab visible. */
const POLL_MS = 2000
/** Textarea auto-grow ceiling (px) — the composer scrolls beyond it. */
const COMPOSER_MAX_HEIGHT = 132
/** The thread a tab is bound to (durable in tab.meta across refreshes). */
export function sidechatThreadIdOf(tab: SidebarTab): string | undefined {
const meta = tab.meta as { threadId?: unknown } | undefined
return typeof meta?.threadId === 'string' ? meta.threadId : undefined
}
/** The parked reopen target consumed by the descriptor's createTab (the
* service's createTab receives no seed, so a thread-switch parks the id
* here and openTab picks it up synchronously — exactly one consume per
* park). */
let parkedReopen: string | undefined
/** Park a thread id for the NEXT sidechat openTab to reattach. */
export function parkSidechatReopen(threadId: string): void {
parkedReopen = threadId
}
/** Consume the parked reopen target (undefined = mint a fresh thread tab). */
export function consumeSidechatSeed(): string | undefined {
const value = parkedReopen
parkedReopen = undefined
return value
}
/** In-flight thread creations keyed by tab id (double-mount guard: React
* StrictMode / HMR must not mint two threads for one tab). */
const inFlightStarts = new Set<string>()
/** Per-thread transcript cache: seed boundary + thread-own events merged by
* seq (streaming polls never re-download the inherited seed). */
interface ThreadCache {
seedBoundary: number | null
entries: SidebarHistoryEntry[]
}
/** Row-render labels (locale-dependent, memoized once per mount). */
interface RowLabels {
copyLabel: string
copiedLabel: string
thinkLabel: string
}
/** Merge history entries by event seq (newest wins), log order preserved. */
function mergeBySeq(
previous: readonly SidebarHistoryEntry[],
incoming: readonly SidebarHistoryEntry[],
): SidebarHistoryEntry[] {
const bySeq = new Map<number, SidebarHistoryEntry>()
for (const entry of previous) bySeq.set(entry.event.seq, entry)
for (const entry of incoming) bySeq.set(entry.event.seq, entry)
return [...bySeq.values()].sort((a, b) => a.event.seq - b.event.seq)
}
/** The display title of a thread: the durable label minus the 'Side: '
* prefix, with the fresh-thread placeholder localized. */
function threadDisplayTitle(title: string): string {
if (title === SIDE_NEW_THREAD_TITLE) return t('sideChatUntitled')
return title.startsWith(SIDE_LABEL_PREFIX) ? title.slice(SIDE_LABEL_PREFIX.length) : title
}
/** One row renderer (React keys ride the source event seq). */
function renderRow(row: SidechatTranscriptRow, labels: RowLabels): React.ReactNode {
switch (row.kind) {
case 'user':
return (
<div key={row.seq} className={css.sidechatUser}>
<MarkdownText text={row.text} codeLabels={labels} />
</div>
)
case 'assistant':
return (
<div key={row.seq} className={css.sidechatAssistant}>
<MarkdownText text={row.text} codeLabels={labels} />
</div>
)
case 'reasoning':
return (
<details key={row.seq} className={css.sidechatReasoning}>
<summary className={css.sidechatReasoningSummary}>{labels.thinkLabel}</summary>
<div className={css.sidechatReasoningBody}>{row.text}</div>
</details>
)
case 'tool':
return (
<details key={row.seq} className={css.sidechatTool}>
<summary
className={clsx(
css.sidechatToolSummary,
row.executing === true && css.sidechatToolExecuting,
row.failed && css.sidechatToolFailed,
)}
>
<StateDot
state={row.executing === true ? 'ongoing' : row.failed ? 'error' : 'done'}
size={8}
/>
<span className={css.sidechatToolName}>{row.name}</span>
</summary>
{row.args !== undefined && <pre className={css.sidechatToolArgs}>{row.args}</pre>}
{row.resultText !== undefined && <pre className={css.sidechatToolResult}>{row.resultText}</pre>}
</details>
)
}
}
/** One side conversation tab (one thread per tab, Codex-style). */
export function SideChatView(props: {
ctx: Context
scope: SessionScope
tab: SidebarTab
visible: boolean
}): React.ReactNode {
const { ctx, scope, tab, visible } = props
const rowLabels = useMemo<RowLabels>(
() => ({ copyLabel: t('copy'), copiedLabel: t('copied'), thinkLabel: t('sideChatThink') }),
[],
)
// The session list feed: thread rows (the header menu) + running states.
const list = useSyncExternalStore(
useMemo(() => (callback: () => void) => ctx.sessions.list.subscribe(callback), [ctx]),
useCallback(() => ctx.sessions.list.getSnapshot(), [ctx]),
)
const threads = useMemo(
() => sideThreadRows(list.byId, scope.sessionId),
[list, scope.sessionId],
)
// The thread this tab is bound to rides tab.meta (refresh-restored).
const threadId = sidechatThreadIdOf(tab)
const autoCreate = (tab.meta as { autoCreate?: unknown } | undefined)?.autoCreate === true
const [composer, setComposer] = useState('')
const [busy, setBusy] = useState<'starting' | 'sending' | 'saving' | null>(null)
const [error, setError] = useState<string | null>(null)
const [saved, setSaved] = useState(false)
const [revision, setRevision] = useState(0)
const [info, setInfo] = useState<SidechatThreadInfo | null>(null)
const [menuOpen, setMenuOpen] = useState(false)
const cacheRef = useRef<ThreadCache>({ seedBoundary: null, entries: [] })
const controllerRef = useRef<AbortController | null>(null)
const scrollRef = useRef<HTMLDivElement | null>(null)
const composerRef = useRef<HTMLTextAreaElement | null>(null)
const summary = threadId === undefined ? undefined : list.byId[threadId]
const running = summary?.running === true
/** The agent-identity badge of the thread header (preset · model). */
const agentBadge = useMemo(() => {
if (info === null) return ''
return [info.preset, info.model ?? info.provider].filter(Boolean).join(' · ')
}, [info])
/** Create this tab's thread (immediate-create tabs and hero retries). */
const startThread = useCallback(async (): Promise<void> => {
if (inFlightStarts.has(tab.id)) return
inFlightStarts.add(tab.id)
setBusy('starting')
setError(null)
try {
const { childId } = await api.sidechatStart(scope.sessionId)
ctx.betterSidebar?.updateTab(tab.id, { meta: { threadId: childId } })
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause))
} finally {
inFlightStarts.delete(tab.id)
setBusy(null)
}
}, [ctx, scope.sessionId, tab.id])
// Codex-style immediate create: an autoCreate tab spawns its thread as
// soon as it first renders.
useEffect(() => {
if (threadId !== undefined || !autoCreate || !visible) return
void startThread()
}, [threadId, autoCreate, visible, startThread])
// The tab title follows the thread's durable label (the first prompt
// renames the thread; the strip picks it up here).
useEffect(() => {
const display = summary?.displayTitle
if (display === undefined) return
const title = threadDisplayTitle(display)
if (title !== '' && title !== tab.title) {
try {
ctx.betterSidebar?.updateTab(tab.id, { title })
} catch {
// A stale title is cosmetic; the thread keeps working.
}
}
}, [summary, tab.id, tab.title, ctx])
/** One transcript pull: the first read walks back to the seed boundary
* (big pages — chunk deltas re-expand on cold reads), later reads fetch
* one tail page and merge (seq-deduped). */
const fetchThread = useCallback(async (childId: string): Promise<void> => {
controllerRef.current?.abort()
const controller = new AbortController()
controllerRef.current = controller
const cache = cacheRef.current
try {
if (cache.seedBoundary === null) {
const walk = await collectOwnEvents(async (beforeSeq) => {
const response = await ctx.connection.api.sessions.history(
{
sessionId: childId,
maxMessages: WALK_PAGE_EVENTS,
...(beforeSeq === undefined ? {} : { beforeSeq }),
},
controller.signal,
)
if (!response.result.ok) throw new Error('history walk failed')
return response.result.value.events
})
cache.seedBoundary = walk.seedBoundary
cache.entries = mergeBySeq(cache.entries, walk.entries)
} else {
const response = await ctx.connection.api.sessions.history(
{ sessionId: childId, maxMessages: PAGE_MESSAGES },
controller.signal,
)
if (!response.result.ok) return
cache.entries = mergeBySeq(cache.entries, response.result.value.events)
}
setRevision(value => value + 1)
} catch {
// Aborted by a newer pull or a wire failure: keep the last rows.
}
}, [ctx])
/** The thread header badge pull (live state + preset/model identity). */
const fetchInfo = useCallback(async (childId: string): Promise<void> => {
try {
setInfo(await api.sidechatInfo(childId))
} catch {
// The badge is decorative; a wire failure keeps the last value.
}
}, [])
// Reset the transcript cache whenever the binding changes, then focus
// the composer — it owns the first message of a fresh thread.
useEffect(() => {
cacheRef.current = { seedBoundary: null, entries: [] }
controllerRef.current?.abort()
setError(null)
setSaved(false)
setInfo(null)
if (threadId !== undefined) {
void fetchInfo(threadId)
window.setTimeout(() => composerRef.current?.focus(), 0)
}
}, [threadId, fetchInfo])
// Poll while the tab is visible and the thread runs.
useEffect(() => {
if (!visible || threadId === undefined) return
void fetchThread(threadId)
if (!running) return
const timer = window.setInterval(() => {
void fetchThread(threadId)
void fetchInfo(threadId)
}, POLL_MS)
return () => { window.clearInterval(timer) }
}, [visible, threadId, running, fetchThread, fetchInfo])
useEffect(() => () => { controllerRef.current?.abort() }, [])
const rows = useMemo(
() => (threadId === undefined ? [] : transcriptRows(cacheRef.current.entries)),
// The cache is a ref; revision bumps on every successful pull.
// eslint-disable-next-line react-hooks/exhaustive-deps
[threadId, revision],
)
const canSave = threadId !== undefined && threadHasCompletedTurn(cacheRef.current.entries)
const trailingPending = threadId !== undefined && threadTrailingPending(cacheRef.current.entries)
const freshThread = threadId !== undefined && rows.length === 0
// Follow the stream: stick to the bottom while the log grows.
useEffect(() => {
const scroller = scrollRef.current
if (scroller === null) return
scroller.scrollTop = scroller.scrollHeight
}, [rows.length, threadId])
/** Open a NEW thread tab (createTab mints the autoCreate tab; its view
* creates the thread on mount). */
const openNewThread = (): void => {
setMenuOpen(false)
ctx.betterSidebar?.openTab({ type: 'sidechat' }, scope)
}
/** Switch to an existing thread: parked for createTab, deduped to the
* already-open tab when there is one. */
const openExistingThread = (id: string): void => {
setMenuOpen(false)
if (id === threadId) return
parkSidechatReopen(id)
ctx.betterSidebar?.openTab({ type: 'sidechat' }, scope)
}
const menuItems = useMemo<MenuEntry[]>(() => {
const items: MenuEntry[] = [
{ id: '$new', label: t('sideChatNew'), icon: <IconPlusOutline16 /> },
]
if (threads.length > 0) {
items.push({ type: 'separator', id: '$sep' })
for (const row of threads) {
items.push({
id: row.id,
label: threadDisplayTitle(row.title),
...(row.running ? { icon: <StateDot state="ongoing" size={8} /> } : {}),
})
}
}
return items
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [threads])
const growComposer = (): void => {
const field = composerRef.current
if (field === null) return
field.style.height = '0px'
field.style.height = `${Math.min(field.scrollHeight, COMPOSER_MAX_HEIGHT)}px`
}
const handleSend = async (): Promise<void> => {
const text = composer.trim()
if (text === '' || threadId === undefined || busy !== null) return
setBusy('sending')
setError(null)
try {
await api.sidechatPrompt(threadId, text)
setComposer('')
const field = composerRef.current
if (field !== null) field.style.height = ''
void fetchThread(threadId)
void fetchInfo(threadId)
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause))
} finally {
setBusy(null)
}
}
const handleCancel = async (): Promise<void> => {
if (threadId === undefined || busy !== null) return
try {
await api.sidechatCancel(threadId)
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause))
}
}
const handleSave = async (): Promise<void> => {
if (threadId === undefined || !canSave || busy !== null) return
setBusy('saving')
setError(null)
setSaved(false)
try {
// NOTE: fork must stay a METHOD call — `ctx.sessions.fork` is the
// client-runtime sessions service, and an unbound reference loses
// `this` (its fork reads this.list for the title bump).
if (ctx.sessions.fork === undefined) throw new Error('session fork is unavailable')
const newId = await ctx.sessions.fork({ sessionId: threadId, increaseTitle: true })
const title = summary === undefined ? '' : threadDisplayTitle(summary.displayTitle).trim()
const binding = ctx.sessions.binding?.(newId)
if (binding !== undefined && title !== '') {
await binding.session.rename(title)
}
ctx.sessions.open?.(newId)
setSaved(true)
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause))
} finally {
setBusy(null)
}
}
// ── unbound tab: the hero (fresh autoCreate tabs flash a creating state
// until the thread lands; legacy persisted tabs offer a manual start) ──
if (threadId === undefined) {
return (
<div className={css.sidechat}>
<div className={css.sidechatHero}>
<IconNewChatOutline16 />
<div className={css.sidechatHeroTitle}>
{busy === 'starting' ? t('sideChatCreating') : t('sideChatEmpty')}
</div>
<div className={css.sidechatHeroDesc}>{t('sideChatEmptyDesc')}</div>
{error !== null && <div className={css.sidechatError}>{t('sideChatError', { message: error })}</div>}
{busy !== 'starting' && (
<button
type="button"
className={css.sidechatPrimaryBtn}
onClick={() => void startThread()}
>
{error === null ? t('sideChatNew') : t('sideChatRetry')}
</button>
)}
</div>
</div>
)
}
return (
<div className={css.sidechat}>
<div className={css.sidechatDetailHeader}>
{running && <StateDot state="ongoing" size={8} className={css.sidechatHeaderDot} />}
{agentBadge !== '' && <span className={css.sidechatAgentBadge}>{agentBadge}</span>}
<span className={css.sidechatHeaderSpacer} />
<Menu
open={menuOpen}
anchor={(
<button
type="button"
className={css.sidechatIconBtn}
onClick={() => { setMenuOpen(value => !value) }}
title={t('sideChatThreads')}
>
<IconHistoryOutline16 />
</button>
)}
items={menuItems}
selectedId={threadId}
onSelect={(id) => { id === '$new' ? openNewThread() : openExistingThread(id) }}
onClose={() => { setMenuOpen(false) }}
align="end"
portal
dense
/>
<button
type="button"
className={css.sidechatIconBtn}
onClick={() => void handleSave()}
disabled={!canSave || busy !== null}
title={`${t('sideChatSave')} — ${t('sideChatSaveTitle')}`}
>
<IconSaveOutline16 />
</button>
</div>
{!canSave && !freshThread && <div className={css.sidechatHint}>{t('sideChatNoTurn')}</div>}
{canSave && trailingPending
&& <div className={css.sidechatHint}>{t('sideChatPendingDrop')}</div>}
{saved && <div className={css.sidechatHint}>{t('sideChatSaved')}</div>}
{error !== null && <div className={css.sidechatError}>{t('sideChatError', { message: error })}</div>}
<div ref={scrollRef} className={css.sidechatScroll}>
{rows.map(row => renderRow(row, rowLabels))}
</div>
{running && (
<div className={css.sidechatStatus}>
<StateDot state="ongoing" size={8} />
<span className={css.sidechatStatusText}>{t('sideChatThinking')}</span>
</div>
)}
<div className={css.sidechatComposer}>
<textarea
ref={composerRef}
className={css.sidechatComposerInput}
value={composer}
placeholder={freshThread ? t('sideChatFirstPlaceholder') : t('sideChatComposerPlaceholder')}
rows={1}
onChange={event => {
setComposer(event.target.value)
growComposer()
}}
onKeyDown={event => {
if (event.key !== 'Enter' || event.shiftKey || event.nativeEvent.isComposing) return
event.preventDefault()
void handleSend()
}}
/>
<div className={css.sidechatComposerBar}>
<span className={css.sidechatComposerMeta}>
{running ? '' : agentBadge}
</span>
{running ? (
<button
key="stop"
type="button"
className={css.sidechatSendBtn}
onClick={() => void handleCancel()}
disabled={busy !== null}
title={t('sideChatCancelTitle')}
>
<IconStopFill16 />
</button>
) : (
<button
key="send"
type="button"
className={css.sidechatSendBtn}
onClick={() => void handleSend()}
disabled={composer.trim() === '' || busy !== null}
title={t('sideChatSend')}
>
<IconSendOutline16 />
</button>
)}
</div>
</div>
</div>
)
}
+16 -3
View File
@@ -38,9 +38,11 @@ import type {
import {
collectBranchIds,
countSubagentDescendants,
isSideThreadSummary,
rootAncestor,
} from './subagent-detect.ts'
import { lastActivity } from './subagent-activity.ts'
import { SIDE_LABEL_PREFIX } from '../sidechat-core.ts'
import {
collectTreeJobs,
formatJobDuration,
@@ -64,13 +66,16 @@ const JOB_POLL_MS = 2000
/** How long the kill button stays armed before it needs re-confirming. */
const JOB_KILL_ARM_MS = 3000
/** The direct subagent children of one parent (durable `origin` rows). */
/** The direct subagent children of one parent (durable `origin` rows;
* Side Chat threads ride the same origin but are tab-strip conversations,
* never topology). */
function directChildren(
byId: Readonly<Record<string, SidebarSessionSummary>>,
parentSessionId: string,
): SidebarSessionSummary[] {
return Object.values(byId).filter(
summary => summary.origin === 'subagent' && summary.parentId === parentSessionId,
summary => summary.origin === 'subagent' && summary.parentId === parentSessionId
&& !isSideThreadSummary(summary),
)
}
@@ -235,6 +240,14 @@ function CatalogRows({
openChild, refresh,
}: RowsProps) {
const emptyLoading = catalog?.state === 'loading' && catalog.entries.length === 0
// Side Chat threads are honest catalog citizens (durable descriptor, 'Side: '
// label) but they are NOT subagent topology — filter them out here (the tab
// strip owns them). Legacy threads created before the descriptor fix still
// arrive as corrupt diagnostics; they are recognized by summary title.
const visibleEntries = (catalog?.entries ?? []).filter((entry) => {
if (entry.kind === 'child') return !(entry.label?.startsWith(SIDE_LABEL_PREFIX) ?? false)
return !(byId[entry.id]?.displayTitle.startsWith(SIDE_LABEL_PREFIX) ?? false)
})
return (
<>
{emptyLoading && (
@@ -253,7 +266,7 @@ function CatalogRows({
</button>
</div>
)}
{(catalog?.entries ?? []).map((entry) => {
{visibleEntries.map((entry) => {
if (entry.kind === 'diagnostic') {
return (
<div key={entry.id} className={css.subagentNode}>
+18
View File
@@ -7,6 +7,7 @@
* request). Failures surface as {@link SidebarApiError} with the wire code.
*/
import { encodeHtmlUrl } from '../html-route.ts'
import type { SidechatThreadInfo } from '../sidechat-core.ts'
import type { BrowserProbeResult } from './browser.ts'
/** One wire failure. */
@@ -244,6 +245,23 @@ export const api = {
id,
...(reason !== undefined ? { reason } : {}),
})),
/** Create a Side Chat thread: a child session seeded with the parent's
* full log up to now. Empty question = immediate create (Codex-style):
* the thread opens empty, the first prompt carries the boundary. */
sidechatStart: (sessionId: string, question?: string) =>
call<{ childId: string }>('sidechat.start', { sessionId, question: question ?? '' }),
/** Deliver one follow-up message to a Side Chat thread. */
sidechatPrompt: (childId: string, text: string) =>
call<{ accepted: true }>('sidechat.prompt', { childId, text }),
/** Abort a Side Chat thread's running turn (queued work is preserved). */
sidechatCancel: (childId: string) =>
call<{ accepted: true }>('sidechat.cancel', { childId }),
/** Release a Side Chat thread's live agent (history stays persisted). */
sidechatDispose: (childId: string) =>
call<{ accepted: true }>('sidechat.dispose', { childId }),
/** Live state + agent identity (provider/model/preset) of a thread. */
sidechatInfo: (childId: string) =>
call<SidechatThreadInfo>('sidechat.info', { childId }),
/** The effective terminal shell and its display name (plugin-global). */
shellGet: () =>
call<{ shell: string; name: string }>('shell.get', {}),
+50 -3
View File
@@ -1,13 +1,13 @@
/**
* The 6 built-in tab descriptors: the plugin registers its own pages
* (editor / git / terminal / browser / subagent / diff) through
* The 7 built-in tab descriptors: the plugin registers its own pages
* (editor / git / subagent / sidechat / terminal / browser / diff) through
* the same {@link BetterSidebarService} external plugins use — eating its
* own dogfood. The terminal descriptor owns its quota (`TERMINAL_LIMIT`)
* and mints `terminal:<uuid>` ids through `createTab`; the browser mints
* `browser:<n>` the same way (no quota). The editor IS the files window
* (the old standalone explorer merged into it).
*/
import { IconBranchOutline16, IconCodeOutline16, IconFolderOpen16, IconPanelLeftOutline16, IconThinkOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconBranchOutline16, IconCodeOutline16, IconFolderOpen16, IconNewChatOutline16, IconPanelLeftOutline16, IconThinkOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context } from '../../context-types.ts'
import { allLeaves, isAgentTabId, type SidebarState } from '../state.ts'
import { t } from '../locales.ts'
@@ -17,6 +17,8 @@ import { lazyChunkComponent } from '../lazy-chunk.tsx'
import { GitView } from '../GitView.tsx'
import { DiffTab } from '../DiffTab.tsx'
import { SubagentView } from '../SubagentView.tsx'
import { consumeSidechatSeed, SideChatView, sidechatThreadIdOf } from '../SideChatView.tsx'
import { api } from '../api.ts'
import { BrowserView } from '../BrowserView.tsx'
import { IconTerminalOutline16, IconDiffOutline16, IconGlobeOutline16 } from '../icons.tsx'
import { TERMINAL_FONT_SIZE_MAX, TERMINAL_FONT_SIZE_MIN } from '../../prefs-shared.ts'
@@ -165,6 +167,51 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
/>
),
},
{
id: 'sidechat',
title: () => t('sideChat'),
icon: (size: number) => <IconNewChatOutline16 size={size} />,
order: 35,
// Codex-style: EVERY side conversation is its own tab. A plain open
// mints a fresh tab flagged `autoCreate` (the view creates the EMPTY
// thread on mount); a thread switch from the header menu parks the
// target id for a deterministic `sidechat:<threadId>` reattach tab.
createTab: () => {
const threadId = consumeSidechatSeed()
if (threadId !== undefined) {
return {
tab: {
id: `sidechat:${threadId}`,
type: 'sidechat',
title: t('sideChat'),
meta: { threadId },
},
}
}
return {
tab: {
id: `sidechat:new-${crypto.randomUUID()}`,
type: 'sidechat',
title: t('sideChatUntitled'),
meta: { autoCreate: true },
},
}
},
// One tab per thread: an already-open thread focuses instead of
// duplicating; unbound fresh tabs never dedupe (each mints its own).
dedupeKey: (tab) => sidechatThreadIdOf(tab),
// Closing the tab releases the thread's live agent; the session and
// its history stay persisted (reopen from any thread's header menu).
onClose: (tab) => {
const threadId = sidechatThreadIdOf(tab)
if (threadId !== undefined) {
void api.sidechatDispose(threadId).catch(() => {})
}
},
component: ({ ctx, scope, tab, visible }) => (
<SideChatView ctx={ctx} scope={scope} tab={tab} visible={visible} />
),
},
{
id: 'terminal',
title: () => t('terminal'),
+20
View File
@@ -117,3 +117,23 @@ export const IconGlobeOutline16 = ({ size = 16, className }: IconProps) => (
<path d="M1.5 8h13M8 1.5c-2.4 1.8-2.4 11.2 0 13M8 1.5c2.4 1.8 2.4 11.2 0 13" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
)
/** History glyph (thread switcher): a clock with a counterclockwise arrow,
* in the app's outline style — the "past conversations" mark. */
export const IconHistoryOutline16 = ({ size = 16, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.4 6.8A5.6 5.6 0 1 1 2.4 9.2" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<path d="M2.2 3.4v3.4h3.4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M8 5.4V8l1.9 1.2" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
/** Save glyph (save-as-new-session): the classic floppy disk, in the app's
* outline style. */
export const IconSaveOutline16 = ({ size = 16, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.2 14.5h7.6a1.2 1.2 0 0 0 1.2-1.2V4.9L10.6 2.5H4.2A1.2 1.2 0 0 0 3 3.7v9.6a1.2 1.2 0 0 0 1.2 1.2z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
<path d="M10 2.5v2.6H5.6V2.5" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
<path d="M5.4 14.5v-4.2h5.2v4.2" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
</svg>
)
+46
View File
@@ -247,6 +247,29 @@ export const zh = {
subagentDiagUnsupported: '不支持的条目',
subagentDiagUnavailable: '不可用',
subagentThinking: '思考中…',
sideChat: '侧边对话',
sideChatNew: '新建对话',
sideChatUntitled: '新对话',
sideChatEmpty: '暂无侧边对话',
sideChatEmptyDesc: '每个侧边对话是标签栏里的独立 Tab,继承当前会话的上下文运行,不会进入主会话',
sideChatCreating: '正在创建侧边对话…',
sideChatRetry: '重试',
sideChatThreads: '切换线程 / 新建',
sideChatSave: '保存为新会话',
sideChatSaveTitle: '把该线程提升为顶层会话,出现在主会话列表中',
sideChatSaved: '已保存为新会话',
sideChatNoTurn: '至少完成一轮对话后才能保存',
sideChatPendingDrop: '最后一条未完成的追问不会包含在新会话中',
sideChatFirstPlaceholder: '输入第一个问题,已继承当前会话上下文…',
sideChatComposerPlaceholder: '追问…',
sideChatThinking: '正在深入…',
sideChatThink: '思考过程',
sideChatSend: '发送',
sideChatCancel: '停止',
sideChatCancelTitle: '中止当前回合(保留队列)',
sideChatClose: '关闭线程',
sideChatCloseTitle: '释放线程的 agent(历史保留)',
sideChatError: '侧边对话出错:{message}',
jobs: '后台任务',
jobsCount: '{count} 个后台任务',
jobsCountRunning: '{count} 个后台任务 · {running} 运行中',
@@ -525,6 +548,29 @@ export const en: Record<keyof typeof zh, string> = {
subagentDiagUnsupported: 'Unsupported',
subagentDiagUnavailable: 'Unavailable',
subagentThinking: 'Thinking…',
sideChat: 'Side Chat',
sideChatNew: 'New thread',
sideChatUntitled: 'New thread',
sideChatEmpty: 'No side conversations',
sideChatEmptyDesc: 'Every side conversation is its own tab in the tab strip — it inherits the current session\'s context and never enters the main conversation',
sideChatCreating: 'Creating side conversation…',
sideChatRetry: 'Retry',
sideChatThreads: 'Switch thread / new',
sideChatSave: 'Save as new session',
sideChatSaveTitle: 'Promote this thread to a top-level session in the main session list',
sideChatSaved: 'Saved as a new session',
sideChatNoTurn: 'Save is available after the first completed turn',
sideChatPendingDrop: 'The last unanswered follow-up will not be included in the saved session',
sideChatFirstPlaceholder: 'Ask the first question — context inherited…',
sideChatComposerPlaceholder: 'Ask a follow-up…',
sideChatThinking: 'Deep diving…',
sideChatThink: 'Thinking',
sideChatSend: 'Send',
sideChatCancel: 'Stop',
sideChatCancelTitle: 'Abort the running turn (queued work is kept)',
sideChatClose: 'Close thread',
sideChatCloseTitle: 'Release the thread\'s agent (history is kept)',
sideChatError: 'Side Chat error: {message}',
jobs: 'Background jobs',
jobsCount: '{count} background jobs',
jobsCountRunning: '{count} background jobs · {running} running',
+264
View File
@@ -0,0 +1,264 @@
/**
* Side Chat transcript mapping (browser half): turns a thread child's
* history rows (`session.history` — the generic RPC, which reads the durable
* log without activating the child) into compact display rows.
*
* A thread child's log starts with the ENTIRE inherited parent log as its
* fork seed. The mapping therefore cuts everything up to the LAST
* `session/end-seed` marker and drops the "Side conversation boundary"
* prompt row, so the view shows only the thread's own conversation.
*
* Live streaming: `assistant/message` events only land when a step
* completes, but `assistant/chunk` events stream token-level text and
* reasoning deltas. The mapping accumulates both per block and supersedes
* them with the assembled message once it lands (settled rows).
*/
import type { SidebarHistoryEntry } from '../context-types.ts'
import { SIDE_BOUNDARY_PREFIX } from '../sidechat-core.ts'
/** One compact transcript row rendered in the thread view. `seq` is the
* source event's log sequence — stable row identity for React keys across
* polls (streaming caches ride the key, so window slides must not re-key
* rows). */
export type SidechatTranscriptRow =
| { kind: 'user'; seq: number; text: string }
/** `settled` distinguishes an assembled message from a still-streaming
* chunk accumulation (streaming rows are superseded by the settle). */
| { kind: 'assistant'; seq: number; text: string; settled: boolean }
| { kind: 'reasoning'; seq: number; text: string; settled: boolean }
| {
kind: 'tool'
seq: number
name: string
failed: boolean
/** Raw arguments JSON as the model produced it. */
args?: string
/** Plain text of the paired result. */
resultText?: string
/** True while the call's result has not landed yet. */
executing?: boolean
}
/** Extract the visible text of a content-block list (`text` blocks verbatim,
* joined by blank lines); empty reads `…` so rows never render blank. */
export function blockText(content: readonly unknown[]): string {
const parts: string[] = []
for (const block of content) {
if (block === null || typeof block !== 'object') continue
const candidate = block as { type?: unknown; text?: unknown }
if (candidate.type === 'text' && typeof candidate.text === 'string') {
parts.push(candidate.text)
}
}
const text = parts.join('\n\n')
return text === '' ? '…' : text
}
/** The plain text of a tool/result message (text blocks inside its
* `tool-result` content block). */
function resultTextOf(data: Record<string, unknown>): string {
const message = data.message as { content?: unknown } | undefined
const content = message?.content
if (!Array.isArray(content)) return ''
const parts: string[] = []
for (const block of content) {
if (block === null || typeof block !== 'object') continue
const candidate = block as { type?: unknown; content?: unknown }
if (candidate.type !== 'tool-result') continue
const inner = candidate.content
if (!Array.isArray(inner)) continue
for (const item of inner) {
if (item === null || typeof item !== 'object') continue
const textItem = item as { type?: unknown; text?: unknown }
if (textItem.type === 'text' && typeof textItem.text === 'string') {
parts.push(textItem.text)
}
}
}
return parts.join('\n')
}
/** Index of the last `session/end-seed` event (fork seed marker), or -1. */
function lastSeedEnd(events: readonly { type: string }[]): number {
for (let index = events.length - 1; index >= 0; index--) {
if (events[index]?.type === 'session/end-seed') return index
}
return -1
}
/**
* Collect the thread's OWN events on first attach: walk backward from the
* log tail (oldest-first accumulation) until the `session/end-seed` marker
* surfaces, then keep everything after it.
*
* Page size matters: cold reads re-expand persisted chunk-rows into one
* `assistant/chunk` event per delta, so a single streamed answer can be
* HUNDREDS of events. A small walk window (the old 8×32 = 256 events) let
* earlier `tool/call` events fall out of the loaded window — the tool rows
* vanished on re-entry while the settled text survived. The walk therefore
* pages big; tail polls stay small.
*
* Exhaustion (log start reached without a marker — a thread created before
* seeding existed, or a pathological log) returns `seedBoundary: 0` so the
* caller stops re-walking and renders the window as-is.
*
* @param fetchPage - one history page (newest-first window ending at
* `beforeSeq`, exclusive; omit for the tail page).
* @param pageCap - safety bound on backward pages.
*/
export async function collectOwnEvents(
fetchPage: (beforeSeq?: number) => Promise<readonly SidebarHistoryEntry[]>,
pageCap = 40,
): Promise<{ seedBoundary: number; entries: SidebarHistoryEntry[] }> {
const collected: SidebarHistoryEntry[] = []
let beforeSeq: number | undefined
for (let page = 0; page < pageCap; page++) {
const events = await fetchPage(beforeSeq)
if (events.length === 0) {
// Log start reached without a marker: the window IS the whole log.
return { seedBoundary: 0, entries: collected }
}
const olderThan = collected.length > 0 ? collected[0]!.event.seq : undefined
const fresh = olderThan === undefined
? [...events]
: events.filter(entry => entry.event.seq < olderThan)
const seedEnd = fresh.findLastIndex(entry => entry.event.type === 'session/end-seed')
if (seedEnd >= 0) {
collected.unshift(...fresh.slice(seedEnd + 1))
return { seedBoundary: fresh[seedEnd]!.event.seq, entries: collected }
}
collected.unshift(...fresh)
if (fresh.length === 0) {
// The page overlaps entirely with what we have: nothing older exists.
return { seedBoundary: 0, entries: collected }
}
beforeSeq = fresh[0]!.event.seq
}
// Cap hit: accept the window (it is overwhelmingly the thread's own tail)
// rather than re-walking on every poll.
return { seedBoundary: 0, entries: collected }
}
/**
* Map a thread child's history rows onto compact transcript rows: the
* inherited fork seed is cut at the last `session/end-seed`, the boundary
* prompt row is dropped, `assistant/chunk` deltas accumulate into streaming
* rows per (turn, step, block) and are superseded by the assembled
* `assistant/message`, and tool invocations render one expandable line each
* (arguments, paired result text, failure marker; a still-executing call is
* marked until its result lands).
* @param entries - history rows (event + host-computed view) in seq order.
* @returns display rows in log order.
*/
export function transcriptRows(entries: readonly SidebarHistoryEntry[]): SidechatTranscriptRow[] {
const events = entries.map(entry => entry.event)
const seedEnd = lastSeedEnd(events)
const rows: SidechatTranscriptRow[] = []
/** (turn, step, index, kind) key → index of its accumulating stream row. */
const streamRows = new Map<string, number>()
/** tool callId → index of its tool row in `rows` (result pairing). */
const callRows = new Map<string, number>()
for (let index = 0; index < events.length; index++) {
if (index <= seedEnd) continue
const event = events[index]
if (event === undefined) continue
const data = event.data as Record<string, unknown>
switch (event.type) {
case 'user/message': {
const text = blockText(Array.isArray(data.content) ? data.content : [])
if (text.startsWith(SIDE_BOUNDARY_PREFIX)) break
rows.push({ kind: 'user', seq: event.seq, text })
break
}
case 'assistant/chunk': {
const chunk = data.chunk as { type?: unknown; text?: unknown } | undefined
if (chunk === null || typeof chunk !== 'object') break
const kind = chunk.type === 'text-delta' ? 'assistant' : chunk.type === 'reasoning-delta' ? 'reasoning' : null
if (kind === null || typeof chunk.text !== 'string' || chunk.text === '') break
const turn = data.turn
const step = data.step
const blockIndex = (chunk as { index?: unknown }).index
const key = `${String(turn)}:${String(step)}:${String(blockIndex)}:${kind}`
const existing = streamRows.get(key)
if (existing !== undefined) {
const row = rows[existing]
if (row !== undefined && row.kind === kind && !row.settled) {
rows[existing] = { ...row, text: row.text + chunk.text }
}
} else {
streamRows.set(key, rows.length)
rows.push({ kind, seq: event.seq, text: chunk.text, settled: false })
}
break
}
case 'assistant/message': {
const prefix = `${String(data.turn)}:${String(data.step)}:`
const streamed = [...streamRows.entries()]
.filter(([key]) => key.startsWith(prefix))
.map(([, rowIndex]) => rowIndex)
for (const key of [...streamRows.keys()]) {
if (key.startsWith(prefix)) streamRows.delete(key)
}
const content = Array.isArray((data.message as { content?: unknown } | undefined)?.content)
? (data.message as { content: readonly unknown[] }).content
: []
const settled: SidechatTranscriptRow[] = content.flatMap((block): SidechatTranscriptRow[] => {
if (block === null || typeof block !== 'object') return []
const candidate = block as { type?: unknown; text?: unknown }
if (candidate.type === 'reasoning' && typeof candidate.text === 'string' && candidate.text !== '') {
return [{ kind: 'reasoning', seq: event.seq, text: candidate.text, settled: true }]
}
if (candidate.type === 'text' && typeof candidate.text === 'string' && candidate.text !== '') {
return [{ kind: 'assistant', seq: event.seq, text: candidate.text, settled: true }]
}
return []
})
if (streamed.length === 0) rows.push(...settled)
else rows.splice(Math.min(...streamed), streamed.length, ...settled)
break
}
case 'tool/call': {
const callId = data.callId
const name = typeof data.name === 'string' ? data.name : 'tool'
const args = typeof data.arguments === 'string' ? data.arguments : undefined
const rowIndex = rows.length
if (typeof callId === 'string') callRows.set(callId, rowIndex)
rows.push({ kind: 'tool', seq: event.seq, name, failed: false, args, executing: true })
break
}
case 'tool/result': {
const source = data.message as { source?: { callId?: unknown } } | undefined
const callId = typeof source?.source?.callId === 'string' ? source.source.callId : undefined
const rowIndex = callId === undefined ? undefined : callRows.get(callId)
const failed = data.error !== undefined
const resultText = resultTextOf(data)
if (rowIndex !== undefined) {
const row = rows[rowIndex]
if (row !== undefined && row.kind === 'tool') {
rows[rowIndex] = {
...row,
failed: row.failed || failed,
resultText: resultText === '' ? row.resultText : resultText,
executing: false,
}
}
} else if (failed || resultText !== '') {
// Orphan result (no call row in the window): surface it so the row
// stays informative and expandable.
rows.push({
kind: 'tool',
seq: event.seq,
name: callId === undefined ? 'tool' : `tool:${callId.slice(0, 8)}`,
failed,
resultText: resultText === '' ? undefined : resultText,
})
}
break
}
default: {
break
}
}
}
return rows
}
+14 -2
View File
@@ -15,6 +15,17 @@ import type {
SidebarSessionSummary,
SidebarSubagentCatalog,
} from '../context-types.ts'
import { SIDE_LABEL_PREFIX } from '../sidechat-core.ts'
/**
* Side Chat threads ride the subagent origin (main-list hiding + the RPC
* ownership fence) but they are NOT subagent topology: they carry the
* durable 'Side: ' label and live as sidebar tabs. Excluding them here
* keeps the auto-open trigger and the Subagent page counts clean.
*/
export function isSideThreadSummary(summary: SidebarSessionSummary): boolean {
return summary.origin === 'subagent' && summary.displayTitle.startsWith(SIDE_LABEL_PREFIX)
}
/** Count the direct subagent children of one session (durable `origin` rows). */
export function directSubagentCount(
@@ -23,7 +34,8 @@ export function directSubagentCount(
): number {
let count = 0
for (const summary of Object.values(byId)) {
if (summary.origin === 'subagent' && summary.parentId === sessionId) count += 1
if (summary.origin === 'subagent' && summary.parentId === sessionId
&& !isSideThreadSummary(summary)) count += 1
}
return count
}
@@ -107,7 +119,7 @@ export function countSubagentDescendants(
): SubagentDescendantTotals {
const totals: SubagentDescendantTotals = { count: 0, runningCount: 0 }
for (const descendant of Object.values(byId)) {
if (descendant.origin !== 'subagent') continue
if (descendant.origin !== 'subagent' || isSideThreadSummary(descendant)) continue
const seen = new Set<string>()
let current: SidebarSessionSummary | undefined = descendant
while (current?.origin === 'subagent' && current.parentId !== undefined
+86 -1
View File
@@ -225,6 +225,45 @@ export interface SidebarJobsService {
export interface SidebarAgentsService {
/** The live agent registered under a session id, or undefined when not live. */
get(id: string): SidebarAgent | undefined
/**
* Create a session + agent with a custom seed (mirror of the runtime
* AgentRegistry.create) — the Side Chat thread-creation seam: the SAME
* public seam api-proxy's session.fork and the subagent fork provider use.
*/
create?(options: unknown): Promise<{ agent: SidebarAgent; dispose(): Promise<void> }>
/**
* Resume an agent on a persisted session (mirror of the runtime
* AgentRegistry.resume) — the Side Chat cold-continuation seam after a
* DSH restart or a closed thread.
*/
resume?(options: unknown): Promise<{ agent: SidebarAgent; dispose(): Promise<void> }>
}
/** The host agent-presets service face (mirror of the runtime agentPresets
* service): resolves and mounts the preset composition a session recorded,
* so a resumed or forked session rebuilds the same tool/prompt world its
* history was produced under. */
export interface SidebarAgentPresetsService {
/** Resolve a preset id; undefined resolves the deployment default. */
resolve(presetId?: string): Promise<{ id: string }>
/** Mount a preset's composition into an agent scope before publication. */
mount(agentCtx: unknown, presetId: string): Promise<void>
}
/** The host session-title service face (mirror of the sessionTitle service). */
export interface SidebarSessionTitleService {
/** Rename one live session's title (pins it against auto-regeneration). */
rename(session: unknown, title: string): { title: string; eventSeq: number }
}
/** The host session-persistence face (mirror of the sessionPersistence
* service): detached inspection of a persisted session, used to compose the
* recorded preset when a Side Chat thread cold-resumes. */
export interface SidebarSessionPersistenceService {
inspect(sessionId: string): Promise<{
meta: { cwd?: string; agentPreset?: string }
events: readonly SidebarSessionEvent[]
}>
}
/** RPC result slot mirror (`RpcResult<T>` on the wire). */
@@ -236,9 +275,21 @@ export interface SidebarRpcResponse<T> {
result: SidebarRpcResult<T>
}
/** The generic session-history RPC face the Side Chat transcript polls
* (subagent.history verifies subagent-catalog membership, which our custom
* side-thread children do not have — the generic session.history reads any
* durable log directly). */
export interface SidebarSessionHistoryRpc {
history(
payload: { sessionId: string; beforeSeq?: number; maxMessages?: number },
signal?: AbortSignal,
): Promise<SidebarRpcResponse<{ events: SidebarHistoryEntry[]; hasMore: boolean }>>
}
/** The wire face the Subagent activity summary needs (subset of `ctx.connection`). */
export interface SidebarConnectionHandle {
api: {
sessions: SidebarSessionHistoryRpc
subagents: {
history(
payload: SidebarSubagentAddress & { beforeSeq?: number; maxMessages?: number },
@@ -273,6 +324,23 @@ export interface SidebarSessionsService {
* — used to jump back to the main agent from the topology root node.
*/
open?(id: string): void
/**
* Fork a session from a completed-turn prefix of the source and resolve
* the child session id (mirror of the runtime ISessions.fork — throws on
* failure). The Side Chat "save as new session" action uses this to
* promote a hidden side thread into a top-level session.
*/
fork?(opts: { sessionId: string; atSeq?: number; increaseTitle?: boolean }): Promise<string>
/**
* Resolve the stable session binding of one listed session (mirror of the
* runtime ISessions.binding); the saved-session rename uses the face's
* behavior verbs.
*/
binding?(id: string): {
session: {
rename(title: string): Promise<unknown>
}
} | undefined
/**
* Resolve an Agent-scoped context view for one session (mirror of the
* runtime ISessions.scope) — the ticket `ctx.conversation.input.for`
@@ -442,9 +510,26 @@ declare module 'cordis' {
jobs: SidebarJobsService
/**
* The host live-agent registry (`ctx.get('agents')`; optional — used to
* resolve the caller the jobs fence compares against).
* resolve the caller the jobs fence compares against, and to create /
* resume the Side Chat thread agents).
*/
agents: SidebarAgentsService
/**
* The host agent-presets service (`ctx.get('agentPresets')`; optional —
* absent deployments compose nothing and every session shares the host
* composition).
*/
agentPresets: SidebarAgentPresetsService
/**
* The host session-title service (`ctx.get('sessionTitle')`; optional —
* the Side Chat thread label pin degrades to the auto-generated title).
*/
sessionTitle: SidebarSessionTitleService
/**
* The host session-persistence service (`ctx.get('sessionPersistence')`;
* optional — needed only for the Side Chat cold-resume composition).
*/
sessionPersistence: SidebarSessionPersistenceService
/**
* The client-side sidebar registry: external plugins register tab types
* and file previewers here. Provided by the client half (see
+9
View File
@@ -47,6 +47,7 @@ import {
} from './pty-deps.ts'
import { registerTools } from './tools.ts'
import { buildJobsApi, type SidebarJobsRoutes } from './jobs-routes.ts'
import { buildSidechatApi } from './sidechat-routes.ts'
import { readJsonBody, requireString, SidebarError, writeError, writeJson, writeOk } from './wire.ts'
export { Config }
@@ -470,6 +471,14 @@ function buildApi(
clearTimeout(timer)
}
},
// Side Chat: create a side-thread child seeded with the parent's full
// log up to now, deliver follow-ups (cold-resuming when the thread's
// agent is gone), abort a running thread, and release a thread's agent.
// Every operation runs through these routes because subagent-origin
// identities are fenced from the generic session RPCs (agent-lookup
// ownership), and the thread is created with a CUSTOM seed the stock
// fork APIs cannot express.
...buildSidechatApi(ctx),
}
}
+444
View File
@@ -0,0 +1,444 @@
/**
* Pure side-conversation ("Side Chat") logic shared by the host routes and
* the client tab. Framework-free (no React, no Node) so both halves and the
* node test environment can import it.
*
* A side thread is a child session the plugin creates ITSELF with a custom
* seed — the parent session's FULL event log up to the click moment
* (completed turns, the unanswered user message, and — when the parent is
* mid-turn — the in-progress assistant output and tool activity). The log
* model forbids open-turn seeds, so an in-flight parent turn is copied
* verbatim and CLOSED with synthetic `step/end` + `turn/end{reason:
* 'interrupted'}` events: the child sees the partial turn as honestly
* frozen ("cut off"), never as a completed answer. The one case that cannot
* be closed honestly — a tool call still executing (no `tool/result` yet;
* providers reject dangling assistant calls) — falls back to cutting before
* the open turn and carrying the partial content as a structured text
* snapshot inside the boundary prompt.
*/
import type { SidebarHistoryEntry, SidebarSessionSummary } from './context-types.ts'
/** The durable thread-label prefix (also the row filter in the client list). */
export const SIDE_LABEL_PREFIX = 'Side: '
/** The pinned label of a freshly created thread that no prompt has reached
* yet (Codex-style immediate create: the tab opens an EMPTY thread, the
* first composer message carries the boundary and earns the real label).
* The client renders it localized; the prefix keeps the row filter honest. */
export const SIDE_NEW_THREAD_TITLE = 'Side: New thread'
/** Maximum code points kept in a durable thread label (matches subagent labels). */
export const LABEL_MAX_CHARS = 48
/** The boundary message's opening line — the transcript mapping drops user
* rows starting with it (same first line as dsh-sidechain's boundary, so
* the two plugins' threads render consistently in either UI). */
export const SIDE_BOUNDARY_PREFIX = 'Side conversation boundary'
/**
* The boundary prompt delivered as the thread's first user message: the
* inherited seed is reference context only, never active instruction.
* Model-facing contract — change only with intent, tests pin the sentences.
*/
export const SIDE_BOUNDARY_PROMPT = `Side conversation boundary.
Everything before this boundary is inherited history from the parent session: its completed turns, its pending question, and — if the parent was mid-turn — its in-progress output frozen at the moment this side conversation started. It is reference context only. It is not your current task.
Do not continue, execute, or complete any instructions, plans, tool calls, approvals, edits, or requests from before this boundary. Only messages submitted after this boundary are active user instructions for this side conversation.
Mode: this is a continuable side conversation. Your answers stay in this side thread and are viewed in the side panel; they are never delivered into the parent session.`
/** One seed event (structural mirror of the durable SessionEvent). The
* envelope fields are preserved verbatim: surface-eligible events
* (user/message, assistant/message, tool/result) REQUIRE the `surfaceOp`
* marker (and may carry `sourceEventSeqs`) — the seed validator rejects
* them without it. */
export interface SeedEvent {
type: string
seq: number
time: number
data: Record<string, unknown>
/** Surface marker of message-producing events ('append' | replace op). */
surfaceOp?: unknown
/** Seq numbers of earlier events this event cites as sources. */
sourceEventSeqs?: unknown
/** Reader-skip marker of purely informational events. */
ignorable?: true
}
/** The minimal structural face of a session-log event this module reads
* (loose enough to accept both the host's real SessionEvent and the
* client's SidebarSessionEvent mirror). */
export interface SidechatLogEvent {
type: string
seq: number
time: number
data: unknown
}
/** The result of cutting a parent log into a side-thread inheritance. */
export interface SidechatInheritance {
/** The child seed: contiguous from seq 0, ends outside any open turn. */
seed: SeedEvent[]
/**
* Structured snapshot of the parent's in-progress turn when it could NOT
* be included as events (a tool call was still executing); null when the
* seed already carries the whole picture.
*/
snapshot: string | null
}
/** The data record of one event (narrowed from the loose face). */
function dataOf(event: SidechatLogEvent): Record<string, unknown> {
return event.data as Record<string, unknown>
}
/** Copy parent events verbatim (their live seq === array index contract).
* The FULL envelope is preserved — stripping `surfaceOp` would make the
* seed validator reject every surface-eligible message event. */
function copyEvents(events: readonly SidechatLogEvent[]): SeedEvent[] {
return events.map(event => {
const source = event as SidechatLogEvent & {
surfaceOp?: unknown
sourceEventSeqs?: unknown
ignorable?: true
}
return {
type: source.type,
seq: source.seq,
time: source.time,
data: dataOf(source),
...(source.surfaceOp === undefined ? {} : { surfaceOp: source.surfaceOp }),
...(source.sourceEventSeqs === undefined ? {} : { sourceEventSeqs: source.sourceEventSeqs }),
...(source.ignorable === undefined ? {} : { ignorable: source.ignorable }),
}
})
}
/** Index of the last `turn/start` or `turn/end`, or -1. */
function lastTurnBoundary(events: readonly SidechatLogEvent[]): number {
for (let index = events.length - 1; index >= 0; index--) {
const type = events[index]?.type
if (type === 'turn/start' || type === 'turn/end') return index
}
return -1
}
/** Numeric field of an event's data (turn / step numbers). */
function numberAt(data: Record<string, unknown>, key: string): number {
const value = data[key]
return typeof value === 'number' && Number.isSafeInteger(value) ? value : 0
}
/** The step number still open at the log tail inside the turn starting at
* `turnStart` (undefined when no step is open). */
function openStepInTurn(events: readonly SidechatLogEvent[], turnStart: number): number | undefined {
let open: number | undefined
for (let index = turnStart + 1; index < events.length; index++) {
const event = events[index]
if (event === undefined) continue
if (event.type === 'step/start') open = numberAt(dataOf(event), 'step')
else if (event.type === 'step/end') open = undefined
}
return open
}
/**
* Whether the open turn ending the log has a `tool/call` without its paired
* `tool/result` in the CURRENT open step. Providers reject dangling
* assistant calls, so such a turn cannot be honestly closed and the
* inheritance must fall back to the snapshot.
*/
export function hasDanglingToolCall(events: readonly SidechatLogEvent[], turnStart: number): boolean {
const pending = new Set<string>()
for (let index = turnStart + 1; index < events.length; index++) {
const event = events[index]
if (event === undefined) continue
const data = dataOf(event)
if (event.type === 'step/end') {
pending.clear()
continue
}
if (event.type === 'tool/call') {
const callId = data.callId
if (typeof callId === 'string') pending.add(callId)
continue
}
if (event.type === 'tool/result') {
const source = data.message as { source?: { callId?: unknown } } | undefined
const callId = source?.source?.callId
if (typeof callId === 'string') pending.delete(callId)
}
}
return pending.size > 0
}
/** The plain text of one tool/result message (text blocks inside its
* `tool-result` content block). */
function toolResultText(data: Record<string, unknown>): string {
const message = data.message as { content?: unknown } | undefined
const content = message?.content
if (!Array.isArray(content)) return ''
const parts: string[] = []
for (const block of content) {
if (block === null || typeof block !== 'object') continue
const candidate = block as { type?: unknown; content?: unknown }
if (candidate.type !== 'tool-result') continue
const inner = candidate.content
if (!Array.isArray(inner)) continue
for (const item of inner) {
if (item === null || typeof item !== 'object') continue
const textItem = item as { type?: unknown; text?: unknown }
if (textItem.type === 'text' && typeof textItem.text === 'string') {
parts.push(textItem.text)
}
}
}
return parts.join('\n')
}
/** Cap applied to one tool-result's text inside a snapshot (prompt budget). */
const SNAPSHOT_RESULT_CAP = 2000
/** Cap applied to the whole snapshot (prompt budget). */
const SNAPSHOT_TOTAL_CAP = 8000
/**
* Build the side-thread inheritance for one parent log: the full event log
* up to the click moment, honestly closed when it ends inside an open turn.
*/
export function buildSidechatInheritance(events: readonly SidechatLogEvent[]): SidechatInheritance {
if (events.length === 0) return { seed: [], snapshot: null }
const boundary = lastTurnBoundary(events)
if (boundary < 0 || events[boundary]?.type === 'turn/end') {
// Ends outside any turn (or has no turns at all): the whole log is a
// valid, balanced seed — possibly ending with a pending user message.
return { seed: copyEvents(events), snapshot: null }
}
// Ends inside the open turn starting at `boundary`.
if (hasDanglingToolCall(events, boundary)) {
// Cannot close honestly: cut before the open turn; the caller attaches
// the structured snapshot to the boundary prompt instead.
return {
seed: copyEvents(events.slice(0, boundary)),
snapshot: buildOpenTurnSnapshot(events),
}
}
const seed = copyEvents(events)
const last = events[events.length - 1]
const turn = numberAt(dataOf(events[boundary]!), 'turn')
const now = last?.time ?? 0
const openStep = openStepInTurn(events, boundary)
if (openStep !== undefined) {
seed.push({ type: 'step/end', seq: seed.length, time: now, data: { turn, step: openStep } })
}
seed.push({
type: 'turn/end',
seq: seed.length,
time: now,
data: { turn, reason: { kind: 'interrupted' } },
})
return { seed, snapshot: null }
}
/** The seed half of {@link buildSidechatInheritance} (test convenience). */
export function sidechatSeed(events: readonly SidechatLogEvent[]): SeedEvent[] {
return buildSidechatInheritance(events).seed
}
/**
* Structured text snapshot of the parent's OPEN turn (from its `turn/start`
* to the log tail): the accumulated assistant/reasoning output verbatim
* (code blocks ride the raw deltas) and the tool activity — executed tools
* with their result text, the still-executing one marked. Returns null when
* there is no open turn or nothing to show.
*/
export function buildOpenTurnSnapshot(events: readonly SidechatLogEvent[]): string | null {
const boundary = lastTurnBoundary(events)
if (boundary < 0 || events[boundary]?.type !== 'turn/start') return null
let text = ''
let reasoning = ''
const tools: string[] = []
const pendingCalls = new Map<string, { name: string; args: string }>()
let total = 0
for (let index = boundary + 1; index < events.length; index++) {
const event = events[index]
if (event === undefined) continue
const data = dataOf(event)
if (event.type === 'step/end') {
pendingCalls.clear()
continue
}
if (event.type === 'assistant/chunk') {
const chunk = data.chunk as { type?: unknown; text?: unknown } | undefined
if (chunk === null || typeof chunk !== 'object') continue
if (chunk.type === 'text-delta' && typeof chunk.text === 'string') text += chunk.text
else if (chunk.type === 'reasoning-delta' && typeof chunk.text === 'string') reasoning += chunk.text
continue
}
if (event.type === 'tool/call') {
const callId = data.callId
if (typeof callId === 'string') {
pendingCalls.set(callId, {
name: typeof data.name === 'string' ? data.name : 'tool',
args: typeof data.arguments === 'string' ? data.arguments : '',
})
}
continue
}
if (event.type === 'tool/result') {
const source = data.message as { source?: { callId?: unknown } } | undefined
const callId = typeof source?.source?.callId === 'string' ? source.source.callId : undefined
const name = callId !== undefined ? pendingCalls.get(callId)?.name : undefined
const args = callId !== undefined ? pendingCalls.get(callId)?.args : undefined
if (callId !== undefined) pendingCalls.delete(callId)
const result = toolResultText(data).slice(0, SNAPSHOT_RESULT_CAP)
const failed = data.error !== undefined
const line = [
`- \`${name ?? 'tool'}\`${failed ? ' (failed)' : ''}`
+ (args !== undefined && args !== '' ? ` — arguments: \`${args}\`` : ''),
...(result === '' ? [] : [` Result: ${result}`]),
].join('\n')
tools.push(line)
total += line.length
}
}
for (const [, call] of pendingCalls) {
const line = `- \`${call.name}\` (executing) — arguments: \`${call.args}\``
tools.push(line)
total += line.length
}
const sections: string[] = []
if (text.trim() !== '') sections.push(`Assistant output so far:\n\n${text}`)
if (reasoning.trim() !== '') sections.push(`Reasoning so far:\n\n${reasoning}`)
if (tools.length > 0) sections.push(`Tool activity:\n${tools.join('\n')}`)
if (sections.length === 0) return null
const body = sections.join('\n\n')
return body.length > SNAPSHOT_TOTAL_CAP
? `Parent session in-progress turn (reference only):\n\n${body.slice(0, SNAPSHOT_TOTAL_CAP)}…`
: `Parent session in-progress turn (reference only):\n\n${body}`
}
/** One side-thread row in the client's thread list. */
export interface SideThreadRow {
id: string
/** The durable thread title ('Side: …'). */
title: string
/** Whether the thread's agent is currently running. */
running: boolean
}
/**
* Derive the side threads of one parent session from the client session list:
* durable `origin: 'subagent'` children of the parent whose pinned title
* carries the thread label prefix (our creation path pins it via
* sessionTitle.rename; dsh-sidechain threads share the convention, so they
* are visible here too).
*/
export function sideThreadRows(
byId: Readonly<Record<string, SidebarSessionSummary>>,
sessionId: string,
): SideThreadRow[] {
const rows: SideThreadRow[] = []
for (const summary of Object.values(byId)) {
if (summary.origin !== 'subagent' || summary.parentId !== sessionId) continue
if (!summary.displayTitle.startsWith(SIDE_LABEL_PREFIX)) continue
rows.push({ id: summary.id, title: summary.displayTitle, running: summary.running === true })
}
return rows
}
/** Truncate + prefix a question into a durable thread label. */
export function sideLabel(question: string): string {
const flat = question.replace(/\s+/g, ' ').trim()
const max = Math.max(1, LABEL_MAX_CHARS - SIDE_LABEL_PREFIX.length)
const body = flat.length > max ? `${flat.slice(0, max - 1)}…` : flat
return `${SIDE_LABEL_PREFIX}${body}`
}
/**
* Whether the thread log already carries the side boundary message — i.e.
* the first prompt was delivered. Tolerant to the content shape (block
* array or bare string) and to inherited seed messages (only an OWN
* boundary message starts with the prefix; seed messages came from the
* parent's log, which never contains one).
*/
export function boundaryDelivered(events: readonly SidechatLogEvent[]): boolean {
for (const event of events) {
if (event.type !== 'user/message') continue
const content = dataOf(event).content
const first = Array.isArray(content) ? content[0] : content
const text = typeof first === 'string'
? first
: (typeof first === 'object' && first !== null && 'text' in first
? String((first as { text: unknown }).text)
: '')
if (text.startsWith(SIDE_BOUNDARY_PREFIX)) return true
}
return false
}
/** The info the thread header shows (live runtime state + agent identity). */
export interface SidechatThreadInfo {
/** A live agent drives the thread right now (false = cold/persisted). */
live: boolean
/** Live lifecycle state; absent on cold threads. */
status?: 'idle' | 'running'
/** Provider route of the live agent. */
provider?: string
/** Model id of the live agent. */
model?: string
/** The recorded agent preset (live header, or persisted on cold reads). */
preset?: string
}
/** The events a thread produced itself: everything after the LAST
* `session/end-seed` marker (the fork-seed boundary). */
export function threadOwnEvents(entries: readonly SidebarHistoryEntry[]): SidechatLogEvent[] {
const events = entries.map(entry => entry.event)
for (let index = events.length - 1; index >= 0; index--) {
if (events[index]?.type === 'session/end-seed') return events.slice(index + 1)
}
return events
}
/**
* Whether the thread has at least one completed turn — the save-as-new-
* session precondition (`session.fork` refuses to fork before the first
* `turn/end`).
*/
export function threadHasCompletedTurn(entries: readonly SidebarHistoryEntry[]): boolean {
return threadOwnEvents(entries).some(event => event.type === 'turn/end')
}
/** Whether the thread ends with a user message that no completed turn
* answered yet — such a pending follow-up is NOT carried into the saved
* session (the fork cut is the last `turn/end`). */
export function threadTrailingPending(entries: readonly SidebarHistoryEntry[]): boolean {
const own = threadOwnEvents(entries)
let lastUser = -1
let lastTurnEnd = -1
own.forEach((event, index) => {
if (event.type === 'user/message') lastUser = index
if (event.type === 'turn/end') lastTurnEnd = index
})
return lastUser > lastTurnEnd
}
/**
* The agent preset a session actually runs: newest `agent-preset/selected`
* event wins, else the creation header (mirror of the dsh-agent-presets
* resolveSessionPreset helper — replicated here to avoid a host dependency
* on that package).
*/
export function resolvePresetId(
header: { agentPreset?: string },
events: readonly SidechatLogEvent[],
): string | undefined {
for (let index = events.length - 1; index >= 0; index--) {
const event = events[index]
if (event?.type !== 'agent-preset/selected') continue
const preset = dataOf(event).agentPreset
if (typeof preset === 'string') return preset
}
return header.agentPreset
}
+325
View File
@@ -0,0 +1,325 @@
/**
* Side Chat routes of the /sidebar JSON API ('sidechat.start' /
* 'sidechat.prompt' / 'sidechat.cancel' / 'sidechat.dispose').
*
* A side thread is a child session the plugin creates ITSELF with a custom
* seed — the parent's full event log up to the click moment, honestly closed
* at an in-progress turn (see sidechat-core.ts). The child is marked
* `origin: 'subagent'` so the main session list hides it, and EVERY
* operation goes through these routes because the generic session RPCs are
* fenced away from subagent-origin identities (the api-remotes
* agent-lookup ownership fence). No DSH source is touched:
*
* - creation uses the public AgentRegistry.create seam (the same one
* api-proxy's session.fork and the subagent fork provider use), with the
* parent's preset composition and provider/model selection so the child's
* first request shares the parent's token prefix (provider-side prefix
* cache reuse);
* - the first prompt (boundary + question) and every follow-up are admitted
* with the stock `agent.followup`;
* - a cold thread (DSH restart, or a closed thread) is resumed with
* AgentRegistry.resume, composing the preset the child recorded.
*/
import { randomUUID } from 'node:crypto'
import { createUserMessage, type ContentBlock, type UserMessage } from '@deepseek-ai/dsh-llm'
import type { Agent, AgentSetup, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent'
import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
import type { Context as CordisContext } from '@deepseek-ai/cordis'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import type {
Context,
SidebarAgentPresetsService,
SidebarSessionPersistenceService,
SidebarSessionTitleService,
} from './context-types.ts'
import {
boundaryDelivered,
buildSidechatInheritance,
resolvePresetId,
SIDE_BOUNDARY_PROMPT,
SIDE_NEW_THREAD_TITLE,
sideLabel,
type SeedEvent,
type SidechatLogEvent,
type SidechatThreadInfo,
} from './sidechat-core.ts'
import { requireString, SidebarError } from './wire.ts'
/** The five Side Chat routes of the sidebar API (wire method names). */
export interface SidechatRoutes {
/** Create a side thread child seeded with the parent's log up to now.
* `question` is optional: empty creates an EMPTY thread (Codex-style
* immediate create); the first `sidechat.prompt` then carries the
* boundary + snapshot and earns the thread its real label. */
'sidechat.start'(payload: unknown): Promise<{ childId: string }>
/** Deliver one follow-up message to a thread (live, or cold-resumed). */
'sidechat.prompt'(payload: unknown): Promise<{ accepted: true }>
/** Abort the thread's running turn (queued work is preserved). */
'sidechat.cancel'(payload: unknown): Promise<{ accepted: true }>
/** Release the thread's live agent (session and history stay persisted). */
'sidechat.dispose'(payload: unknown): Promise<{ accepted: true }>
/** Live state + agent identity for the thread header. */
'sidechat.info'(payload: unknown): Promise<SidechatThreadInfo>
}
/** Timeout guarding the create call (the registry detaches it before the
* handle becomes visible, so the child is never cancelled by it). */
const CREATE_TIMEOUT_MS = 15_000
/** Per-activation disposers of created thread agents (the dispose route
* releases them; the session and its history always stay persisted). */
const threadDisposers = new Map<string, () => Promise<void>>()
/** The in-progress-turn snapshot captured at creation of an EMPTY thread,
* waiting to ride the first prompt (lost on a host restart — the boundary
* prompt is then delivered alone, a logged degradation). */
const pendingSnapshots = new Map<string, string>()
/** Resolve the parent's preset and build the child's composition setup
* (mirror of api-proxy's composeAgent minus the model-selection install —
* the child carries the parent's provider/model in agentOptions). */
async function composeChildSetup(
ctx: Context,
presetId: string | undefined,
): Promise<{ agentPreset?: string; setup: AgentSetup }> {
const presets = ctx.get('agentPresets') as SidebarAgentPresetsService | undefined
if (presets === undefined) {
return { setup: () => Promise.resolve() }
}
const resolved = await presets.resolve(presetId)
return {
agentPreset: resolved.id,
setup: async (agentCtx: CordisContext) => { await presets.mount(agentCtx, resolved.id) },
}
}
/** Build the cold-resume setup from the thread's PERSISTED record (the
* recorded preset wins, newest selection event first). */
async function composePersistedSetup(
ctx: Context,
childId: string,
): Promise<AgentSetup> {
const persistence = ctx.get('sessionPersistence') as SidebarSessionPersistenceService | undefined
if (persistence === undefined) {
return () => Promise.resolve()
}
const inspected = await persistence.inspect(childId)
const presetId = resolvePresetId(inspected.meta, inspected.events)
const presets = ctx.get('agentPresets') as SidebarAgentPresetsService | undefined
if (presets === undefined || presetId === undefined) {
return () => Promise.resolve()
}
const resolved = await presets.resolve(presetId)
return async (agentCtx: CordisContext) => { await presets.mount(agentCtx, resolved.id) }
}
/** One text-block prompt (the thread boundary + question, or a follow-up). */
function textPrompt(text: string): ContentBlock[] {
return [{ type: 'text', text }]
}
/** Admit one user message to a live agent through the stock followup path. */
function admitFollowup(agent: Agent, blocks: ContentBlock[]): void {
const message: UserMessage = createUserMessage({ content: blocks, source: { kind: 'user' } })
agent.followup(message)
}
/** The live thread agent, or undefined (cold — the caller resumes). */
function liveThreadAgent(ctx: Context, childId: string): Agent | undefined {
const agents = ctx.get('agents') as { get(id: string): Agent | undefined } | undefined
return agents?.get(childId)
}
/** Build the Side Chat routes (all optional services degrade to a wire
* error the tab surfaces inline). The record keys are the FULL wire method
* names the /sidebar/api dispatcher looks up (`api[method]`). */
export function buildSidechatApi(ctx: Context): SidechatRoutes {
return {
'sidechat.start': async (payload: unknown) => {
const sessionId = requireString(payload, 'sessionId')
const rawQuestion = (payload as { question?: unknown }).question
const question = typeof rawQuestion === 'string' ? rawQuestion.trim() : ''
const parent = liveThreadAgent(ctx, sessionId)
if (parent === undefined) {
throw new SidebarError('sidechat-error', `parent session "${sessionId}" is not running`, 409)
}
const parentSession = parent.session
const inheritance = buildSidechatInheritance(
parentSession.events as unknown as readonly SidechatLogEvent[],
)
const { agentPreset, setup } = await composeChildSetup(
ctx,
resolvePresetId(parentSession.header, parentSession.events),
)
const childId = `session-${randomUUID()}` as SessionId
const label = question === '' ? SIDE_NEW_THREAD_TITLE : sideLabel(question)
// Honest catalog citizenship: the durable descriptor keeps the thread
// a HEALTHY row in the host's subagents.list — a cold child without
// one is deterministically rendered as a 'corrupt' diagnostic. The
// SubagentView filters the 'Side: ' label out, so the topology UI
// stays noise-free; the row only serves enumeration correctness.
const descriptor = snapshotSubagentDescriptor({
mode: 'continuable',
provider: 'sidechat',
label,
...(parent.options.provider === undefined ? {} : { agentProvider: parent.options.provider }),
...(parent.options.model === undefined ? {} : { agentModel: parent.options.model }),
})
const descriptorEvent: SeedEvent = {
type: 'subagent/descriptor',
seq: inheritance.seed.length,
time: Date.now(),
data: descriptor as unknown as Record<string, unknown>,
}
const seed = [...inheritance.seed, descriptorEvent]
const options: CreateAgentOptions = {
sessionId: childId,
meta: {
...(parentSession.header.cwd === undefined ? {} : { cwd: parentSession.header.cwd }),
parentSession: parentSession.id,
seedLength: seed.length,
origin: 'subagent',
delegationDepth: (parentSession.header.delegationDepth ?? 0) + 1,
...(agentPreset === undefined ? {} : { agentPreset }),
},
seed: seed as unknown as readonly SessionEvent[],
agentOptions: { ...parent.options },
setup,
signal: AbortSignal.timeout(CREATE_TIMEOUT_MS),
}
const agents = ctx.get('agents') as { create(options: CreateAgentOptions): Promise<{ agent: Agent; dispose(): Promise<void> }> } | undefined
if (agents?.create === undefined) {
throw new SidebarError('sidechat-error', 'the agents service is unavailable', 503)
}
let handle: { agent: Agent; dispose(): Promise<void> }
try {
handle = await agents.create(options)
} catch (error) {
throw new SidebarError('sidechat-error', `thread creation failed: ${error instanceof Error ? error.message : String(error)}`, 500)
}
threadDisposers.set(childId, () => handle.dispose())
// Pin the thread label so the client can identify its threads by
// title prefix (the rename is a live-session op, no RPC fence).
const titles = ctx.get('sessionTitle') as SidebarSessionTitleService | undefined
const pinTitle = (label: string): void => {
if (titles === undefined) return
try {
titles.rename(handle.agent.session, label)
} catch {
// Keep the auto-generated title; the thread stays usable.
}
}
if (question === '') {
// Codex-style immediate create: no prompt yet — the composer owns
// the first message; the snapshot waits for it.
if (inheritance.snapshot !== null) pendingSnapshots.set(childId, inheritance.snapshot)
pinTitle(SIDE_NEW_THREAD_TITLE)
} else {
const promptParts = [SIDE_BOUNDARY_PROMPT]
if (inheritance.snapshot !== null) promptParts.push(inheritance.snapshot)
promptParts.push(question)
admitFollowup(handle.agent, textPrompt(promptParts.join('\n\n')))
pinTitle(sideLabel(question))
}
return { childId }
},
'sidechat.prompt': async (payload: unknown) => {
const childId = requireString(payload, 'childId')
const text = requireString(payload, 'text').trim()
if (text === '') {
throw new SidebarError('bad-request', 'text is required')
}
let agent = liveThreadAgent(ctx, childId)
if (agent === undefined) {
// Cold thread: resume the persisted session under its recorded
// composition, then deliver the follow-up.
const agents = ctx.get('agents') as { resume(options: ResumeAgentOptions): Promise<{ agent: Agent; dispose(): Promise<void> }> } | undefined
if (agents?.resume === undefined) {
throw new SidebarError('sidechat-error', 'the agents service is unavailable', 503)
}
const setup = await composePersistedSetup(ctx, childId)
try {
const handle = await agents.resume({ resumeSessionId: childId as SessionId, setup })
threadDisposers.set(childId, () => handle.dispose())
agent = handle.agent
} catch (error) {
throw new SidebarError('sidechat-error', `thread resume failed: ${error instanceof Error ? error.message : String(error)}`, 500)
}
}
if (boundaryDelivered(agent.session.events as unknown as readonly SidechatLogEvent[])) {
admitFollowup(agent, textPrompt(text))
} else {
// First message of an immediately-created thread: it carries the
// boundary (+ the snapshot parked at creation, if still around)
// and earns the thread its real label.
const parts = [SIDE_BOUNDARY_PROMPT]
const snapshot = pendingSnapshots.get(childId)
pendingSnapshots.delete(childId)
if (snapshot !== undefined) parts.push(snapshot)
parts.push(text)
admitFollowup(agent, textPrompt(parts.join('\n\n')))
const titles = ctx.get('sessionTitle') as SidebarSessionTitleService | undefined
if (titles !== undefined) {
try {
titles.rename(agent.session, sideLabel(text))
} catch {
// Keep the placeholder title; the thread stays usable.
}
}
}
return { accepted: true as const }
},
'sidechat.cancel': async (payload: unknown) => {
const childId = requireString(payload, 'childId')
const agent = liveThreadAgent(ctx, childId)
if (agent !== undefined) {
agent.cancel({ kind: 'user' }, { keepInbox: true })
}
return { accepted: true as const }
},
'sidechat.dispose': async (payload: unknown) => {
const childId = requireString(payload, 'childId')
pendingSnapshots.delete(childId)
const dispose = threadDisposers.get(childId)
if (dispose !== undefined) {
threadDisposers.delete(childId)
try {
await dispose()
} catch {
// The agent may already be gone (restart); the session persists.
}
}
return { accepted: true as const }
},
'sidechat.info': async (payload: unknown) => {
const childId = requireString(payload, 'childId')
const agent = liveThreadAgent(ctx, childId)
if (agent !== undefined) {
const preset = agent.session.header.agentPreset
return {
live: true,
status: agent.status,
...(agent.options.provider === undefined ? {} : { provider: agent.options.provider }),
...(agent.options.model === undefined ? {} : { model: agent.options.model }),
...(preset === undefined ? {} : { preset }),
}
}
// Cold thread: only the persisted preset is worth reading back.
const persistence = ctx.get('sessionPersistence') as SidebarSessionPersistenceService | undefined
if (persistence !== undefined) {
try {
const inspected = await persistence.inspect(childId)
const preset = resolvePresetId(inspected.meta, inspected.events)
return { live: false, ...(preset === undefined ? {} : { preset }) }
} catch {
// Unknown/gone session: report a bare cold info.
}
}
return { live: false }
},
}
}
+1
View File
@@ -18,6 +18,7 @@ export type SidebarErrorCode =
| 'pty-error'
| 'pty-deps-missing'
| 'job-error'
| 'sidechat-error'
| 'settings-rejected'
| 'settings-conflict'
| 'internal'
+32 -3
View File
@@ -1,5 +1,5 @@
/**
* Built-in registration tests: the plugin registers 6 tabs and 6 file
* Built-in registration tests: the plugin registers 7 tabs and 6 file
* viewers through the same service external plugins use (dogfooding);
* the catch-all `code` viewer, the NUL-sniffing `binary-download` viewer,
* and the html sandbox settings pin the registry's behavior. (Office
@@ -16,6 +16,7 @@ import { createSidebarStore } from '../src/client/state.ts'
import { allLeaves } from '../src/client/state.ts'
import { registerBuiltins } from '../src/client/builtins/index.ts'
import type { BuiltinTabOptions } from '../src/client/builtins/tabs.tsx'
import { parkSidechatReopen } from '../src/client/SideChatView.tsx'
import { t } from '../src/client/locales.ts'
function setup(options: BuiltinTabOptions = {}): { service: ReturnType<typeof createBetterSidebarService>; store: ReturnType<typeof createSidebarStore>; dispose: () => void } {
@@ -26,10 +27,10 @@ function setup(options: BuiltinTabOptions = {}): { service: ReturnType<typeof cr
}
describe('built-in tab registrations', () => {
it('registers the 6 built-in tabs', () => {
it('registers the 7 built-in tabs', () => {
const { service } = setup()
expect(service.getTabs().map(t => t.id).sort()).toEqual(
['browser', 'diff', 'editor', 'git', 'subagent', 'terminal'],
['browser', 'diff', 'editor', 'git', 'sidechat', 'subagent', 'terminal'],
)
})
@@ -48,6 +49,34 @@ describe('built-in tab registrations', () => {
}
})
it('the side chat tab sits between tasks and terminal in the + menu', () => {
const { service } = setup()
const sidechat = service.getTab('sidechat')
expect(sidechat?.order).toBe(35)
expect(sidechat?.hidden).not.toBe(true)
})
it('side chat mints one tab per thread (Codex-style multi-instance)', () => {
const { service } = setup()
const sidechat = service.getTab('sidechat')
expect(sidechat?.single).not.toBe(true)
// A plain open mints a fresh autoCreate tab (the view creates the
// thread on mount); two opens never collide. (createTab ignores the
// state argument for sidechat — the cast stands in for it.)
const first = sidechat?.createTab?.(undefined as never)
const second = sidechat?.createTab?.(undefined as never)
expect(first?.tab.meta).toEqual({ autoCreate: true })
expect(first?.tab.id).not.toBe(second?.tab.id)
// A parked reopen target mints the deterministic reattach tab, and
// dedupeKey focuses an already-open thread instead of duplicating it.
parkSidechatReopen('session-t1')
const reopen = sidechat?.createTab?.(undefined as never)
expect(reopen?.tab.id).toBe('sidechat:session-t1')
expect(reopen?.tab.meta).toEqual({ threadId: 'session-t1' })
expect(sidechat?.dedupeKey?.(reopen!.tab)).toBe('session-t1')
expect(sidechat?.dedupeKey?.(first!.tab)).toBeUndefined()
})
it('the subagent tab declares its auto-open related settings', () => {
const { service } = setup()
const toggles = service.getTab('subagent')?.settings?.toggles ?? []
+74 -1
View File
@@ -56,9 +56,11 @@ const SEEDED_MD_FILE = 'diagram.md'
const CRASH_STRIP_PATTERNS = [/^dsh-better-sidebar:/, /^\[dsh-better-sidebar\]/]
/** Built-in tab titles the sweep drives (en-US copy; follows DSH locale). */
const BUILTIN_TABS = ['Files', 'Source Control', 'Tasks', 'Terminal', 'Browser']
const BUILTIN_TABS = ['Files', 'Source Control', 'Tasks', 'Side Chat', 'Terminal', 'Browser']
let api: APIRequestContext
/** The seeded session id (captured by seedSession; the Side Chat smoke's parent). */
let seededSessionId: string
/** Seed one workspace + one session (plus files for the editor/mermaid-chunk
* probes) through the host's unary RPC surface. */
@@ -99,6 +101,11 @@ async function seedSession(): Promise<void> {
data: { type: 'client-request', rpcId: 'e2e-session', method: 'session.create', payload: { workspaceId } },
})
expect(session.ok(), `session.create: ${session.status()} ${await session.text()}`).toBe(true)
const sessionBody = (await session.json()) as {
result: { ok: true; value: { sessionId: string } } | { ok: false; error: unknown }
}
expect(sessionBody.result.ok).toBe(true)
seededSessionId = (sessionBody.result as { value: { sessionId: string } }).value.sessionId
}
test.beforeAll(async () => {
@@ -228,6 +235,72 @@ test('plugin mounts into the DSH shell and survives a built-in tab sweep', async
await assertNoCrash()
}
// Side Chat host-route smoke against the REAL host: create a thread child
// under the seeded session (custom-seed creation through AgentRegistry),
// deliver a follow-up, cancel, and release it. The turn itself cannot run
// (keyless boot has no model route), but admission + creation + the wire
// envelope must all succeed — this is the deepest functional proof the
// mount lane can make without a provider.
const start = await api.post(`${BASE_URL}/sidebar/api/sidechat.start`, {
data: { sessionId: seededSessionId, question: 'mount lane smoke' },
})
expect(start.ok(), `sidechat.start: ${start.status()} ${await start.text()}`).toBe(true)
const startBody = (await start.json()) as {
ok: boolean
value?: { childId?: string }
error?: { code?: string; message?: string }
}
expect(startBody.ok, `sidechat.start envelope: ${JSON.stringify(startBody)}`).toBe(true)
const childId = startBody.value?.childId
expect(childId, 'sidechat.start must return a child session id').toMatch(/^session-/)
// The child must be a REAL session in the host's store (provider-free
// proof of the custom-seed creation; the boundary message itself only
// becomes durable when a turn claims it, which needs a model route).
const list = await api.post(`${BASE_URL}/api/session.list`, {
data: { type: 'client-request', rpcId: 'e2e-sidechat-list', method: 'session.list', payload: {} },
})
expect(list.ok(), `session.list: ${list.status()} ${await list.text()}`).toBe(true)
const listBody = (await list.json()) as {
result: { ok: true; value: { items: Array<{ sessionId: string }> } } | { ok: false; error: unknown }
}
expect(listBody.result.ok, `session.list envelope: ${JSON.stringify(listBody)}`).toBe(true)
const listedItems = (listBody.result as { value: { items: Array<{ sessionId: string }> } }).value.items
expect(
listedItems.some(item => item.sessionId === childId),
'the thread child must appear in the host session list',
).toBe(true)
for (const method of ['sidechat.prompt', 'sidechat.cancel', 'sidechat.dispose']) {
const response = await api.post(`${BASE_URL}/sidebar/api/${method}`, {
data: method === 'sidechat.prompt' ? { childId, text: 'follow-up' } : { childId },
})
expect(response.ok(), `${method}: ${response.status()} ${await response.text()}`).toBe(true)
}
// The Codex-style immediate-create flow: a blank question creates an
// EMPTY thread (no prompt admitted), sidechat.info reports the live
// agent, and the first prompt delivers the boundary host-side.
const empty = await api.post(`${BASE_URL}/sidebar/api/sidechat.start`, {
data: { sessionId: seededSessionId, question: '' },
})
expect(empty.ok(), `sidechat.start (empty): ${empty.status()} ${await empty.text()}`).toBe(true)
const emptyBody = (await empty.json()) as { ok: boolean; value?: { childId?: string } }
const emptyChildId = emptyBody.value?.childId
expect(emptyChildId, 'immediate create must return a child session id').toMatch(/^session-/)
const info = await api.post(`${BASE_URL}/sidebar/api/sidechat.info`, {
data: { childId: emptyChildId },
})
expect(info.ok(), `sidechat.info: ${info.status()} ${await info.text()}`).toBe(true)
const infoBody = (await info.json()) as { ok: boolean; value?: { live?: boolean; preset?: string } }
expect(infoBody.ok, `sidechat.info envelope: ${JSON.stringify(infoBody)}`).toBe(true)
expect(infoBody.value?.live, 'the fresh thread must have a live agent').toBe(true)
for (const method of ['sidechat.prompt', 'sidechat.dispose']) {
const response = await api.post(`${BASE_URL}/sidebar/api/${method}`, {
data: method === 'sidechat.prompt' ? { childId: emptyChildId, text: 'first message' } : { childId: emptyChildId },
})
expect(response.ok(), `${method} (immediate thread): ${response.status()} ${await response.text()}`).toBe(true)
}
await assertNoCrash()
// The editor chunk (client-editor.js) only loads when a files-window tab
// renders. Exercise the file-open path explicitly through the Files window's
// own tree: the seeded home tab ("Files") is already open with its tree
+335
View File
@@ -0,0 +1,335 @@
/**
* Unit tests for the pure Side Chat core (src/sidechat-core.ts): the seed
* construction (full-log inheritance with honest synthetic closing of an
* in-progress parent turn), the structured in-progress snapshot (with the
* dangling-tool-call fallback), thread-row derivation, save eligibility, and
* the preset resolution.
*/
import { describe, expect, it } from 'vitest'
import type { SidebarHistoryEntry, SidebarSessionEvent, SidebarSessionSummary } from '../src/context-types.ts'
import {
boundaryDelivered,
buildOpenTurnSnapshot,
buildSidechatInheritance,
hasDanglingToolCall,
resolvePresetId,
sideLabel,
sideThreadRows,
threadHasCompletedTurn,
threadTrailingPending,
SIDE_BOUNDARY_PROMPT,
SIDE_LABEL_PREFIX,
type SeedEvent,
type SidechatLogEvent,
} from '../src/sidechat-core.ts'
/** One log event fixture (structural, seq === index like the live contract).
* Surface-eligible events carry their required `surfaceOp: 'append'`
* marker, exactly like the live pipeline appends them. */
function ev(type: string, seq: number, data: Record<string, unknown> = {}): SidebarSessionEvent {
const event: SidebarSessionEvent = { type, seq, time: seq * 1000, data }
if (type === 'user/message' || type === 'assistant/message' || type === 'tool/result') {
return { ...event, surfaceOp: 'append' } as SidebarSessionEvent
}
return event
}
/** A completed user→assistant turn block (turn T, step 1, optional tools). */
function completedTurn(seq: number, turn: number, over: {
text?: string
tools?: Array<{ callId: string; name?: string; args?: string; result?: string }>
} = {}): SidebarSessionEvent[] {
const events: SidebarSessionEvent[] = [
ev('user/message', seq, { content: [{ type: 'text', text: `q${turn}` }], source: { kind: 'user' } }),
ev('turn/start', seq + 1, { turn }),
ev('step/start', seq + 2, { turn, step: 1 }),
]
let next = seq + 3
for (const tool of over.tools ?? []) {
events.push(ev('tool/call', next++, { turn, step: 1, callId: tool.callId, name: tool.name ?? 'bash', arguments: tool.args ?? '{}' }))
events.push(ev('tool/result', next++, {
turn,
step: 1,
message: {
source: { kind: 'tool', callId: tool.callId },
content: [{ type: 'tool-result', toolCallId: tool.callId, isError: false, content: [{ type: 'text', text: tool.result ?? 'ok' }] }],
},
}))
}
if (over.text !== undefined) {
events.push(ev('assistant/message', next++, {
turn,
step: 1,
message: { content: [{ type: 'text', text: over.text }] },
}))
}
events.push(ev('step/end', next++, { turn, step: 1 }))
events.push(ev('turn/end', next, { turn, reason: { kind: 'completed' } }))
return events
}
/** Assert the seed is contiguous from seq 0. */
function expectContiguous(seed: readonly SeedEvent[]): void {
seed.forEach((event, index) => expect(event.seq).toBe(index))
}
describe('buildSidechatInheritance', () => {
it('returns an empty seed for an empty log', () => {
const { seed, snapshot } = buildSidechatInheritance([])
expect(seed).toEqual([])
expect(snapshot).toBeNull()
})
it('copies a log that ends outside any turn verbatim (pending user message)', () => {
const events = [
...completedTurn(0, 1, { text: 'a' }),
ev('user/message', 6, { content: [{ type: 'text', text: 'pending?' }], source: { kind: 'user' } }),
]
const { seed, snapshot } = buildSidechatInheritance(events)
expectContiguous(seed)
expect(seed).toHaveLength(events.length)
expect(seed.at(-1)?.type).toBe('user/message')
expect(seed.some(event => event.type === 'turn/end')).toBe(true)
expect(snapshot).toBeNull()
})
it('preserves the surface envelope of message events in the seed', () => {
// Regression: the seed validator rejects surface-eligible events
// (user/message, assistant/message, tool/result) whose `surfaceOp`
// marker was stripped — the copy must keep the FULL event envelope.
const events = [
ev('user/message', 0, { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }),
ev('turn/start', 1, { turn: 1 }),
ev('step/start', 2, { turn: 1, step: 1 }),
ev('tool/call', 3, { turn: 1, step: 1, callId: 'c1', name: 'read', arguments: '{}' }),
ev('tool/result', 4, {
turn: 1,
step: 1,
message: {
source: { kind: 'tool', callId: 'c1' },
content: [{ type: 'tool-result', toolCallId: 'c1', content: [{ type: 'text', text: 'ok' }] }],
},
}),
ev('assistant/message', 5, { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'a' }] } }),
ev('step/end', 6, { turn: 1, step: 1 }),
ev('turn/end', 7, { turn: 1, reason: { kind: 'completed' } }),
]
const { seed } = buildSidechatInheritance(events)
expect(seed.filter(event => event.type === 'user/message').every(event => event.surfaceOp === 'append')).toBe(true)
expect(seed.filter(event => event.type === 'assistant/message').every(event => event.surfaceOp === 'append')).toBe(true)
expect(seed.filter(event => event.type === 'tool/result').every(event => event.surfaceOp === 'append')).toBe(true)
// Non-surface events never gain a marker.
expect(seed.filter(event => event.type === 'turn/end').every(event => event.surfaceOp === undefined)).toBe(true)
})
it('copies a log with no turns at all', () => {
const events = [ev('user/message', 0, { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } })]
const { seed } = buildSidechatInheritance(events)
expectContiguous(seed)
expect(seed).toHaveLength(1)
})
it('closes an open streaming turn with synthetic step/end + interrupted turn/end', () => {
const events = [
...completedTurn(0, 1, { text: 'done' }),
ev('user/message', 6, { content: [{ type: 'text', text: 'next?' }], source: { kind: 'user' } }),
ev('turn/start', 7, { turn: 2 }),
ev('step/start', 8, { turn: 2, step: 1 }),
ev('assistant/chunk', 9, { turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: 'partial answer' } }),
]
const { seed, snapshot } = buildSidechatInheritance(events)
expectContiguous(seed)
expect(seed).toHaveLength(events.length + 2)
const stepEnd = seed.at(-2)!
const turnEnd = seed.at(-1)!
expect(stepEnd).toMatchObject({ type: 'step/end', data: { turn: 2, step: 1 } })
expect(turnEnd).toMatchObject({ type: 'turn/end', data: { turn: 2, reason: { kind: 'interrupted' } } })
// The pending user message and the partial chunk survive as REAL events.
expect(seed.some(event => event.type === 'user/message'
&& (event.data.content as Array<{ text: string }>)[0]?.text === 'next?')).toBe(true)
expect(seed.some(event => event.type === 'assistant/chunk')).toBe(true)
expect(snapshot).toBeNull()
})
it('closes an open turn whose tool calls already have results', () => {
const events = [
ev('user/message', 0, { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }),
ev('turn/start', 1, { turn: 1 }),
ev('step/start', 2, { turn: 1, step: 1 }),
ev('tool/call', 3, { turn: 1, step: 1, callId: 'c1', name: 'grep', arguments: '{"q":"x"}' }),
ev('tool/result', 4, {
turn: 1,
step: 1,
message: {
source: { kind: 'tool', callId: 'c1' },
content: [{ type: 'tool-result', toolCallId: 'c1', content: [{ type: 'text', text: 'hit' }] }],
},
}),
ev('assistant/chunk', 5, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'found ' } }),
]
const { seed, snapshot } = buildSidechatInheritance(events)
expectContiguous(seed)
expect(seed.at(-1)?.type).toBe('turn/end')
expect(hasDanglingToolCall(events, 1)).toBe(false)
expect(snapshot).toBeNull()
})
it('falls back to a snapshot cut when a tool call is still executing', () => {
const events = [
ev('user/message', 0, { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }),
ev('turn/start', 1, { turn: 1 }),
ev('step/start', 2, { turn: 1, step: 1 }),
ev('assistant/chunk', 3, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'running ' } }),
ev('tool/call', 4, { turn: 1, step: 1, callId: 'c1', name: 'bash', arguments: '{"cmd":"sleep 9"}' }),
]
expect(hasDanglingToolCall(events, 1)).toBe(true)
const { seed, snapshot } = buildSidechatInheritance(events)
expectContiguous(seed)
// The seed stops BEFORE the open turn; the pending user message stays.
expect(seed.at(-1)?.type).toBe('user/message')
expect(seed.some(event => event.type === 'turn/start')).toBe(false)
expect(snapshot).not.toBeNull()
expect(snapshot).toContain('running ')
expect(snapshot).toContain('`bash` (executing)')
expect(snapshot).toContain('sleep 9')
})
it('closes only the current step when the open turn has completed steps', () => {
const events = [
ev('user/message', 0, { 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: { content: [{ type: 'text', text: 'first' }] } }),
ev('step/end', 4, { turn: 1, step: 1 }),
ev('step/start', 5, { turn: 1, step: 2 }),
ev('assistant/chunk', 6, { turn: 1, step: 2, chunk: { type: 'text-delta', index: 0, text: 'second' } }),
]
const { seed } = buildSidechatInheritance(events)
expectContiguous(seed)
expect(seed.at(-2)).toMatchObject({ type: 'step/end', data: { turn: 1, step: 2 } })
expect(seed.at(-1)).toMatchObject({ type: 'turn/end', data: { turn: 1 } })
})
})
describe('buildOpenTurnSnapshot', () => {
it('returns null without an open turn', () => {
expect(buildOpenTurnSnapshot([ev('user/message', 0, { content: [], source: { kind: 'user' } })])).toBeNull()
expect(buildOpenTurnSnapshot(completedTurn(0, 1, { text: 'x' }))).toBeNull()
})
it('preserves streamed text and tool detail verbatim', () => {
const events = [
ev('turn/start', 0, { turn: 1 }),
ev('step/start', 1, { turn: 1, step: 1 }),
ev('assistant/chunk', 2, { turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'think' } }),
ev('assistant/chunk', 3, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: '```js\ncode' } }),
ev('assistant/chunk', 4, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: '\n```' } }),
ev('tool/call', 5, { turn: 1, step: 1, callId: 'c1', name: 'read', arguments: '{"path":"a.txt"}' }),
ev('tool/result', 6, {
turn: 1,
step: 1,
message: {
source: { kind: 'tool', callId: 'c1' },
content: [{ type: 'tool-result', toolCallId: 'c1', content: [{ type: 'text', text: 'file body' }] }],
},
}),
ev('tool/call', 7, { turn: 1, step: 1, callId: 'c2', name: 'bash', arguments: '{"cmd":"long"}' }),
]
const snapshot = buildOpenTurnSnapshot(events)
expect(snapshot).not.toBeNull()
expect(snapshot).toContain('```js\ncode\n```')
expect(snapshot).toContain('Reasoning so far')
expect(snapshot).toContain('`read` — arguments: `{"path":"a.txt"}`')
expect(snapshot).toContain('Result: file body')
expect(snapshot).toContain('`bash` (executing)')
})
})
describe('sideLabel', () => {
it('prefixes and truncates', () => {
expect(sideLabel('hello')).toBe('Side: hello')
expect(sideLabel(' a b ')).toBe('Side: a b')
const long = 'x'.repeat(100)
const label = sideLabel(long)
expect(label.startsWith(SIDE_LABEL_PREFIX)).toBe(true)
expect(label.length).toBeLessThanOrEqual(48)
expect(label.endsWith('…')).toBe(true)
})
})
describe('sideThreadRows', () => {
const summary = (over: Partial<SidebarSessionSummary>): SidebarSessionSummary => ({
id: 's1',
displayTitle: 'x',
...over,
})
it('keeps only subagent-origin children of the session with the label prefix', () => {
const byId: Record<string, SidebarSessionSummary> = {
main: summary({ id: 'main', displayTitle: 'Main' }),
thread: summary({ id: 'thread', origin: 'subagent', parentId: 'main', displayTitle: 'Side: q', running: true }),
plainSubagent: summary({ id: 'plain', origin: 'subagent', parentId: 'main', displayTitle: 'Read files' }),
foreignThread: summary({ id: 'foreign', origin: 'subagent', parentId: 'other', displayTitle: 'Side: other' }),
}
const rows = sideThreadRows(byId, 'main')
expect(rows).toEqual([{ id: 'thread', title: 'Side: q', running: true }])
})
})
describe('save eligibility', () => {
const entry = (event: SidebarSessionEvent): SidebarHistoryEntry => ({ event })
it('requires a completed turn after the seed boundary', () => {
const seed = [ev('session/end-seed', 0)]
expect(threadHasCompletedTurn([entry(seed[0]!)])).toBe(false)
expect(threadHasCompletedTurn([entry(seed[0]!), entry(ev('user/message', 1, { content: [], source: { kind: 'user' } }))])).toBe(false)
const withTurn = [...completedTurn(2, 1, { text: 'a' })]
const entries = [entry(seed[0]!), ...withTurn.map(entry)]
expect(threadHasCompletedTurn(entries)).toBe(true)
})
it('detects a trailing unanswered follow-up', () => {
const entries = [
...completedTurn(2, 1, { text: 'a' }).map(entry),
entry(ev('user/message', 9, { content: [], source: { kind: 'user' } })),
]
expect(threadTrailingPending(entries)).toBe(true)
expect(threadTrailingPending(completedTurn(2, 1, { text: 'a' }).map(entry))).toBe(false)
})
})
describe('resolvePresetId', () => {
it('prefers the newest selection event over the header', () => {
const events = [ev('agent-preset/selected', 0, { agentPreset: 'later' })]
expect(resolvePresetId({ agentPreset: 'early' }, events)).toBe('later')
expect(resolvePresetId({ agentPreset: 'early' }, [])).toBe('early')
expect(resolvePresetId({}, [])).toBeUndefined()
})
})
describe('SIDE_BOUNDARY_PROMPT contract', () => {
it('opens with the boundary prefix the transcript drops', () => {
expect(SIDE_BOUNDARY_PROMPT.startsWith('Side conversation boundary')).toBe(true)
})
})
describe('boundaryDelivered', () => {
it('detects the boundary message and ignores everything else', () => {
expect(boundaryDelivered([])).toBe(false)
expect(boundaryDelivered([ev('user/message', 0, {
content: [{ type: 'text', text: 'ordinary question' }],
})])).toBe(false)
// The inherited seed never contains a boundary: parent messages are
// ordinary user rows.
expect(boundaryDelivered([
ev('user/message', 0, { content: [{ type: 'text', text: 'parent q' }] }),
ev('user/message', 1, {
content: [{ type: 'text', text: `${SIDE_BOUNDARY_PROMPT}\n\nfirst` }],
}),
])).toBe(true)
// Bare-string content is tolerated too.
expect(boundaryDelivered([ev('user/message', 0, {
content: `${SIDE_BOUNDARY_PROMPT}\n\nfirst`,
})])).toBe(true)
})
})
+347
View File
@@ -0,0 +1,347 @@
/**
* Host route tests for the Side Chat API ('sidechat.start' / 'sidechat.prompt'
* / 'sidechat.cancel' / 'sidechat.dispose'): the custom-seed thread creation
* (with the in-progress-turn synthetic close and the dangling-tool-call
* snapshot fallback), the boundary+question first prompt, follow-ups on live
* and cold (resumed) agents, cancel, and dispose.
*/
import { describe, expect, it, vi } from 'vitest'
import { buildSidechatApi } from '../src/sidechat-routes.ts'
import { SidebarError } from '../src/wire.ts'
import { SIDE_BOUNDARY_PROMPT, SIDE_NEW_THREAD_TITLE, sideLabel } from '../src/sidechat-core.ts'
import type { Context } from '../src/context-types.ts'
/** A fake live agent (followup/cancel spied). */
function agent(id: string, over: { events?: unknown[]; header?: Record<string, unknown>; provider?: string; model?: string } = {}) {
return {
id,
status: 'idle' as const,
options: { provider: over.provider ?? 'test', model: over.model ?? 'model-x' },
session: {
id,
header: { cwd: '/p', delegationDepth: 0, agentPreset: 'preset-a', ...over.header },
events: over.events ?? [],
},
followup: vi.fn(),
cancel: vi.fn(),
}
}
type AgentLike = ReturnType<typeof agent>
type Handle = { agent: AgentLike; dispose: () => Promise<void> }
/** The standard services the happy paths need. */
function happyServices(parent: AgentLike | undefined, child: AgentLike) {
const create = vi.fn(async (_options: unknown): Promise<Handle> => ({
agent: child,
dispose: vi.fn(async () => {}),
}))
const resume = vi.fn(async (_options: unknown): Promise<Handle> => ({
agent: child,
dispose: vi.fn(async () => {}),
}))
const get = vi.fn((id: unknown) => (id === child.id ? child : parent))
const rename = vi.fn((_session: unknown, _title: string) => ({ title: 'x', eventSeq: 1 }))
const resolve = vi.fn(async (_id?: string) => ({ id: 'preset-a' }))
const mount = vi.fn(async () => {})
const inspect = vi.fn(async () => ({ meta: { agentPreset: 'preset-a' }, events: [] }))
return {
agents: { get, create, resume },
agentPresets: { resolve, mount },
sessionTitle: { rename },
sessionPersistence: { inspect },
create,
resume,
get,
rename,
mount,
inspect,
}
}
/** A context serving the optional services the routes read via ctx.get. */
function ctxWith(services: {
agents?: unknown
agentPresets?: unknown
sessionTitle?: unknown
sessionPersistence?: unknown
}): Context {
const table: Record<string, unknown> = {
...(services.agents === undefined ? {} : { agents: services.agents }),
...(services.agentPresets === undefined ? {} : { agentPresets: services.agentPresets }),
...(services.sessionTitle === undefined ? {} : { sessionTitle: services.sessionTitle }),
...(services.sessionPersistence === undefined ? {} : { sessionPersistence: services.sessionPersistence }),
}
return {
get: (key: string) => table[key],
} as unknown as Context
}
function ev(type: string, seq: number, data: Record<string, unknown> = {}): { type: string; seq: number; time: number; data: Record<string, unknown>; surfaceOp?: string } {
const event = { type, seq, time: seq * 1000, data }
if (type === 'user/message' || type === 'assistant/message' || type === 'tool/result') {
return { ...event, surfaceOp: 'append' }
}
return event
}
describe('sidechat.start', () => {
it('creates a seeded subagent-origin child and admits the boundary + question', async () => {
const parent = agent('parent', {
events: [
ev('user/message', 0, { 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: { content: [{ type: 'text', text: 'a' }] } }),
ev('step/end', 4, { turn: 1, step: 1 }),
ev('turn/end', 5, { turn: 1, reason: { kind: 'completed' } }),
],
})
const child = agent('child')
const services = happyServices(parent, child)
const api = buildSidechatApi(ctxWith(services))
const result = await api['sidechat.start']({ sessionId: 'parent', question: 'explain the event flow' })
expect(result.childId).toMatch(/^session-/)
expect(services.create).toHaveBeenCalledTimes(1)
const options = services.create.mock.calls[0]![0] as {
sessionId: string
meta: Record<string, unknown>
seed: readonly { type: string; data: Record<string, unknown> }[]
agentOptions: { provider: string; model: string }
setup: unknown
}
expect(options.sessionId).toBe(result.childId)
expect(options.meta).toMatchObject({
parentSession: 'parent',
origin: 'subagent',
seedLength: 7,
delegationDepth: 1,
agentPreset: 'preset-a',
cwd: '/p',
})
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).
expect(options.seed.map(event => event.type)).toEqual([
'user/message', 'turn/start', 'step/start', 'assistant/message', 'step/end', 'turn/end',
'subagent/descriptor',
])
expect(options.seed.at(-1)?.data).toMatchObject({
version: 2,
mode: 'continuable',
provider: 'sidechat',
label: sideLabel('explain the event flow'),
agentProvider: 'test',
agentModel: 'model-x',
})
expect(child.followup).toHaveBeenCalledTimes(1)
const message = child.followup.mock.calls[0]![0] as { content: Array<{ type: string; text: string }>; source: { kind: string } }
expect(message.source).toEqual({ kind: 'user' })
const promptText = message.content[0]!.text
expect(promptText.startsWith(SIDE_BOUNDARY_PROMPT)).toBe(true)
expect(promptText).toContain('explain the event flow')
expect(services.rename).toHaveBeenCalledWith(child.session, sideLabel('explain the event flow'))
})
it('synthetically closes an in-progress parent turn inside the seed', async () => {
const parent = agent('parent', {
events: [
ev('user/message', 0, { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }),
ev('turn/start', 1, { turn: 1 }),
ev('step/start', 2, { turn: 1, step: 1 }),
ev('assistant/chunk', 3, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'streaming' } }),
],
})
const child = agent('child')
const services = happyServices(parent, child)
const api = buildSidechatApi(ctxWith(services))
await api['sidechat.start']({ sessionId: 'parent', question: 'what now?' })
const options = services.create.mock.calls[0]![0] as { seed: Array<{ type: string; data: Record<string, unknown> }> }
expect(options.seed.map(event => event.type)).toEqual([
'user/message', 'turn/start', 'step/start', 'assistant/chunk', 'step/end', 'turn/end',
'subagent/descriptor',
])
expect(options.seed.at(-2)?.data).toEqual({ turn: 1, reason: { kind: 'interrupted' } })
})
it('falls back to the snapshot when a tool call is still executing', async () => {
const parent = agent('parent', {
events: [
ev('user/message', 0, { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }),
ev('turn/start', 1, { turn: 1 }),
ev('step/start', 2, { turn: 1, step: 1 }),
ev('tool/call', 3, { turn: 1, step: 1, callId: 'c1', name: 'bash', arguments: '{"cmd":"sleep"}' }),
],
})
const child = agent('child')
const services = happyServices(parent, child)
const api = buildSidechatApi(ctxWith(services))
await api['sidechat.start']({ sessionId: 'parent', question: 'what now?' })
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'])
const message = child.followup.mock.calls[0]![0] as { content: Array<{ text: string }> }
expect(message.content[0]!.text).toContain('`bash` (executing)')
})
it('creates an EMPTY thread on a blank question (Codex-style immediate create)', async () => {
const parent = agent('parent', {
events: [
ev('user/message', 0, { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }),
ev('turn/start', 1, { turn: 1 }),
ev('step/start', 2, { turn: 1, step: 1 }),
ev('tool/call', 3, { turn: 1, step: 1, callId: 'c1', name: 'bash', arguments: '{"cmd":"sleep"}' }),
],
})
const child = agent('child')
const services = happyServices(parent, child)
const api = buildSidechatApi(ctxWith(services))
const { childId } = await api['sidechat.start']({ sessionId: 'parent', question: ' ' })
// No prompt yet: the composer owns the first message; the placeholder
// label is pinned and the in-progress snapshot is parked for it.
expect(child.followup).not.toHaveBeenCalled()
expect(services.rename).toHaveBeenCalledWith(child.session, SIDE_NEW_THREAD_TITLE)
// The first prompt carries the boundary + the parked snapshot and earns
// the thread its real label.
services.agents.get = vi.fn((id: unknown) => (id === childId ? child : parent))
await api['sidechat.prompt']({ childId, text: 'explain the event flow' })
expect(child.followup).toHaveBeenCalledTimes(1)
const message = child.followup.mock.calls[0]![0] as { content: Array<{ text: string }> }
expect(message.content[0]!.text.startsWith(SIDE_BOUNDARY_PROMPT)).toBe(true)
expect(message.content[0]!.text).toContain('`bash` (executing)')
expect(message.content[0]!.text).toContain('explain the event flow')
expect(services.rename).toHaveBeenCalledWith(child.session, sideLabel('explain the event flow'))
})
it('rejects a non-running parent', async () => {
const parent = agent('parent', { events: [] })
const child = agent('child')
const services = happyServices(parent, child)
const idle = ctxWith({
...services,
agents: { ...services.agents, get: vi.fn((_id: unknown) => undefined) },
})
const idleApi = buildSidechatApi(idle)
await expect(idleApi['sidechat.start']({ sessionId: 'parent', question: 'q' })).rejects.toThrow(/not running/)
})
it('degrades when the agents service is absent', async () => {
const api = buildSidechatApi(ctxWith({}))
await expect(api['sidechat.start']({ sessionId: 'parent', question: 'q' })).rejects.toThrow(SidebarError)
})
})
describe('sidechat.prompt', () => {
it('delivers follow-ups to a live agent (boundary already delivered)', async () => {
const child = agent('child', {
events: [
ev('user/message', 0, {
content: [{ type: 'text', text: `${SIDE_BOUNDARY_PROMPT}\n\nfirst question` }],
source: { kind: 'user' },
}),
],
})
const services = happyServices(undefined, child)
const api = buildSidechatApi(ctxWith(services))
const result = await api['sidechat.prompt']({ childId: 'child', text: 'tell me more' })
expect(result).toEqual({ accepted: true })
expect(child.followup).toHaveBeenCalledTimes(1)
const message = child.followup.mock.calls[0]![0] as { content: Array<{ text: string }> }
expect(message.content[0]!.text).toBe('tell me more')
})
it('cold-resumes the thread when the agent is gone', async () => {
const child = agent('child')
const services = happyServices(undefined, child)
services.agents.get = vi.fn((_id: unknown) => undefined)
const api = buildSidechatApi(ctxWith(services))
await api['sidechat.prompt']({ childId: 'child', text: 'after restart' })
expect(services.resume).toHaveBeenCalledTimes(1)
const resumeOptions = services.resume.mock.calls[0]![0] as { resumeSessionId: string; setup: unknown }
expect(resumeOptions.resumeSessionId).toBe('child')
expect(typeof resumeOptions.setup).toBe('function')
expect(services.inspect).toHaveBeenCalledWith('child')
expect(child.followup).toHaveBeenCalledTimes(1)
})
it('rejects blank text', async () => {
const child = agent('child')
const services = happyServices(undefined, child)
const api = buildSidechatApi(ctxWith(services))
await expect(api['sidechat.prompt']({ childId: 'child', text: '' })).rejects.toThrow(SidebarError)
})
})
describe('sidechat.cancel and sidechat.dispose', () => {
it('cancels the running turn with the user authority', async () => {
const child = agent('child')
const services = happyServices(undefined, child)
const api = buildSidechatApi(ctxWith(services))
await api['sidechat.cancel']({ childId: 'child' })
expect(child.cancel).toHaveBeenCalledWith({ kind: 'user' }, { keepInbox: true })
})
it('disposes the created thread agent', async () => {
const parent = agent('parent', { events: [] })
const dispose = vi.fn(async () => {})
const create = vi.fn(async (_options: unknown): Promise<Handle> => ({
agent: agent('child'),
dispose,
}))
const services = happyServices(parent, agent('child'))
services.agents.create = create
const api = buildSidechatApi(ctxWith(services))
const { childId } = await api['sidechat.start']({ sessionId: 'parent', question: 'q' })
await api['sidechat.dispose']({ childId })
expect(dispose).toHaveBeenCalledTimes(1)
// A second dispose is a harmless no-op.
await api['sidechat.dispose']({ childId })
expect(dispose).toHaveBeenCalledTimes(1)
})
})
describe('sidechat.info', () => {
it('reports the live agent state and identity', async () => {
const child = agent('child')
const services = happyServices(undefined, child)
const api = buildSidechatApi(ctxWith(services))
const info = await api['sidechat.info']({ childId: 'child' })
expect(info).toEqual({
live: true,
status: 'idle',
provider: 'test',
model: 'model-x',
preset: 'preset-a',
})
})
it('reports the persisted preset of a cold thread', async () => {
const child = agent('child')
const services = happyServices(undefined, child)
services.agents.get = vi.fn((_id: unknown) => undefined)
const api = buildSidechatApi(ctxWith(services))
const info = await api['sidechat.info']({ childId: 'child' })
expect(info).toEqual({ live: false, preset: 'preset-a' })
expect(services.inspect).toHaveBeenCalledWith('child')
})
it('degrades to a bare cold info when the session is gone', async () => {
const services = happyServices(undefined, agent('child'))
services.agents.get = vi.fn((_id: unknown) => undefined)
services.inspect = vi.fn(async () => { throw new Error('unknown session') })
services.sessionPersistence = { inspect: services.inspect }
const api = buildSidechatApi(ctxWith(services))
await expect(api['sidechat.info']({ childId: 'ghost' })).resolves.toEqual({ live: false })
})
})
+122
View File
@@ -0,0 +1,122 @@
/**
* Seed-validator integration test: the seed built by {@link buildSidechatInheritance}
* must be accepted by the REAL dsh-session validator (`Session.create`).
*
* Regression: the seed copy originally stripped the event envelope's
* `surfaceOp` marker, and the real validator rejects surface-eligible events
* (user/message, assistant/message, tool/result) without it — the live host
* surfaced it as "invalid seed event at index N: ... requires a surfaceOp
* marker" during real thread creation. This test runs the ACTUAL validator,
* not a mock, so the class of bug cannot silently return.
*/
import { describe, expect, it } from 'vitest'
import { Session } from '@deepseek-ai/dsh-session'
import type { SessionId } from '@deepseek-ai/dsh-session'
import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
import type { SidebarSessionEvent } from '../src/context-types.ts'
import { buildSidechatInheritance } from '../src/sidechat-core.ts'
/** One live-style event with the surface marker message events carry, and
* the REAL message shapes the validator demands (id/role/source/content;
* tool/result messages carry role 'user' + one tool-result block). */
function ev(type: string, seq: number, data: Record<string, unknown>): SidebarSessionEvent {
const event: SidebarSessionEvent = { type, seq, time: seq * 1000, data }
if (type === 'user/message' || type === 'assistant/message' || type === 'tool/result') {
return { ...event, surfaceOp: 'append' } as SidebarSessionEvent
}
return event
}
function userMessage(text: string): Record<string, unknown> {
return { id: `m-${text}`, role: 'user', content: [{ type: 'text', text }], source: { kind: 'user' } }
}
function assistantMessage(text: string): Record<string, unknown> {
return {
id: `m-${text}`,
role: 'assistant',
content: [{ type: 'text', text }],
source: { kind: 'model', provider: 'test', model: 'model-x' },
}
}
function toolResultMessage(callId: string, text: string): Record<string, unknown> {
return {
id: `m-${callId}`,
role: 'user',
content: [{ type: 'tool-result', toolCallId: callId, content: [{ type: 'text', text }] }],
source: { kind: 'tool', callId },
}
}
/** A parent log with a completed turn, a pending question, and an open
* in-progress turn (the exact shape a mid-stream thread creation sees). */
function parentLog(): SidebarSessionEvent[] {
return [
ev('user/message', 0, userMessage('first question')),
ev('turn/start', 1, { turn: 1 }),
ev('step/start', 2, { turn: 1, step: 1 }),
ev('assistant/message', 3, { turn: 1, step: 1, message: assistantMessage('first answer') }),
ev('step/end', 4, { turn: 1, step: 1 }),
ev('turn/end', 5, { turn: 1, reason: { kind: 'completed' } }),
ev('user/message', 6, userMessage('pending question')),
ev('turn/start', 7, { turn: 2 }),
ev('step/start', 8, { turn: 2, step: 1 }),
ev('assistant/chunk', 9, { turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: 'in-progress' } }),
]
}
describe('sidechat seed against the real dsh-session validator', () => {
it('accepts a seed with a completed turn + pending user message + synthetically closed open turn', () => {
const { seed, snapshot } = buildSidechatInheritance(parentLog())
expect(snapshot).toBeNull()
// The REAL validator: this throws when an envelope field was stripped
// (the reported regression) or the turn balance is off.
const child = Session.create('session-validator-test' as SessionId, seed as never)
const types = child.events.map(event => event.type)
expect(types).toEqual([
'user/message', 'turn/start', 'step/start', 'assistant/message', 'step/end', 'turn/end',
'user/message', 'turn/start', 'step/start', 'assistant/chunk', 'step/end', 'turn/end',
'session/end-seed',
])
// The synthetic close is honest: the frozen turn ends interrupted.
const turnEnd = child.events.findLast(event => event.type === 'turn/end')
expect(turnEnd?.data).toEqual({ turn: 2, reason: { kind: 'interrupted' } })
})
it('accepts a dangling-tool-call fallback seed (cut before the open turn)', () => {
const log = [
...parentLog().slice(0, 7),
ev('turn/start', 7, { turn: 2 }),
ev('step/start', 8, { turn: 2, step: 1 }),
ev('tool/call', 9, { turn: 2, step: 1, callId: 'c1', name: 'bash', arguments: '{"cmd":"sleep"}' }),
]
const { seed, snapshot } = buildSidechatInheritance(log)
expect(snapshot).not.toBeNull()
const child = Session.create('session-validator-fallback' as SessionId, seed as never)
expect(child.events.map(event => event.type).at(-1)).toBe('session/end-seed')
})
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.
const { seed } = buildSidechatInheritance(parentLog())
const descriptor = snapshotSubagentDescriptor({
mode: 'continuable',
provider: 'sidechat',
label: 'Side: test',
agentProvider: 'test',
agentModel: 'model-x',
})
const withDescriptor = [
...seed,
{ type: 'subagent/descriptor', seq: seed.length, time: Date.now(), data: descriptor },
]
const child = Session.create('session-validator-descriptor' as SessionId, withDescriptor as never)
const types = child.events.map(event => event.type)
expect(types.at(-2)).toBe('subagent/descriptor')
expect(types.at(-1)).toBe('session/end-seed')
})
})
+194
View File
@@ -0,0 +1,194 @@
/**
* Unit tests for the Side Chat transcript mapping (src/client/sidechat-
* transcript.ts): the seed cut at session/end-seed, the boundary-row drop,
* chunk streaming accumulation superseded by assembled messages, tool
* call/result pairing, and orphan failed results.
*/
import { describe, expect, it } from 'vitest'
import type { SidebarHistoryEntry, SidebarSessionEvent } from '../src/context-types.ts'
import { SIDE_BOUNDARY_PREFIX } from '../src/sidechat-core.ts'
import { collectOwnEvents, transcriptRows, type SidechatTranscriptRow } from '../src/client/sidechat-transcript.ts'
/** One history entry (event + optional view). */
function entry(event: SidebarSessionEvent): SidebarHistoryEntry {
return { event }
}
/** One log event fixture. Surface-eligible events carry their required
* `surfaceOp: 'append'` marker, exactly like the live pipeline appends them. */
function ev(type: string, seq: number, data: Record<string, unknown> = {}): SidebarSessionEvent {
const event: SidebarSessionEvent = { type, seq, time: seq * 1000, data }
if (type === 'user/message' || type === 'assistant/message' || type === 'tool/result') {
return { ...event, surfaceOp: 'append' } as SidebarSessionEvent
}
return event
}
function textBlocks(...texts: string[]): unknown[] {
return texts.map(text => ({ type: 'text', text }))
}
describe('transcriptRows', () => {
it('cuts the inherited seed at the last end-seed and drops the boundary row', () => {
const entries = [
entry(ev('user/message', 0, { content: textBlocks('inherited'), source: { kind: 'user' } })),
entry(ev('session/end-seed', 1)),
entry(ev('user/message', 2, { content: textBlocks(`${SIDE_BOUNDARY_PREFIX}\n\nmode`), source: { kind: 'user' } })),
entry(ev('user/message', 3, { content: textBlocks('the side question'), source: { kind: 'user' } })),
]
const rows = transcriptRows(entries)
expect(rows).toEqual([{ kind: 'user', seq: 3, text: 'the side question' }])
})
it('accumulates chunk deltas per block and supersedes them on settle', () => {
const entries = [
entry(ev('session/end-seed', 0)),
entry(ev('user/message', 1, { content: textBlocks('q'), source: { kind: 'user' } })),
entry(ev('turn/start', 2, { turn: 1 })),
entry(ev('step/start', 3, { turn: 1, step: 1 })),
entry(ev('assistant/chunk', 4, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'Hel' } })),
entry(ev('assistant/chunk', 5, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'lo' } })),
entry(ev('assistant/chunk', 6, { turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 1, text: 'think' } })),
]
const rows = transcriptRows(entries)
const assistant = rows.find(row => row.kind === 'assistant') as Extract<SidechatTranscriptRow, { kind: 'assistant' }>
expect(assistant.text).toBe('Hello')
expect(assistant.settled).toBe(false)
const reasoning = rows.find(row => row.kind === 'reasoning') as Extract<SidechatTranscriptRow, { kind: 'reasoning' }>
expect(reasoning.text).toBe('think')
})
it('replaces streaming rows with the settled assistant message', () => {
const entries = [
entry(ev('session/end-seed', 0)),
entry(ev('user/message', 1, { content: textBlocks('q'), source: { kind: 'user' } })),
entry(ev('turn/start', 2, { turn: 1 })),
entry(ev('step/start', 3, { turn: 1, step: 1 })),
entry(ev('assistant/chunk', 4, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'par' } })),
entry(ev('assistant/message', 5, { turn: 1, step: 1, message: { content: textBlocks('final answer') } })),
]
const rows = transcriptRows(entries)
const assistants = rows.filter(row => row.kind === 'assistant')
expect(assistants).toHaveLength(1)
expect(assistants[0]).toMatchObject({ kind: 'assistant', text: 'final answer', settled: true })
})
it('pairs tool calls with results and marks failures', () => {
const entries = [
entry(ev('session/end-seed', 0)),
entry(ev('turn/start', 1, { turn: 1 })),
entry(ev('step/start', 2, { turn: 1, step: 1 })),
entry(ev('tool/call', 3, { turn: 1, step: 1, callId: 'c1', name: 'read', arguments: '{"path":"a"}' })),
entry(ev('tool/result', 4, {
turn: 1,
step: 1,
message: {
source: { kind: 'tool', callId: 'c1' },
content: [{ type: 'tool-result', toolCallId: 'c1', isError: true, content: [{ type: 'text', text: 'denied' }] }],
},
error: { name: 'EACCES', code: 'EACCES' },
})),
]
const rows = transcriptRows(entries)
expect(rows).toHaveLength(1)
const tool = rows[0]
expect(tool).toMatchObject({
kind: 'tool',
name: 'read',
args: '{"path":"a"}',
resultText: 'denied',
failed: true,
executing: false,
})
})
it('keeps a call executing until its result lands and surfaces orphan failures', () => {
const entries = [
entry(ev('session/end-seed', 0)),
entry(ev('turn/start', 1, { turn: 1 })),
entry(ev('step/start', 2, { turn: 1, step: 1 })),
entry(ev('tool/call', 3, { turn: 1, step: 1, callId: 'c1', name: 'bash', arguments: '{}' })),
]
let rows = transcriptRows(entries)
expect(rows[0]).toMatchObject({ kind: 'tool', executing: true })
// Orphan failed result outside the fetched window still surfaces.
const orphan = [
entry(ev('session/end-seed', 0)),
entry(ev('tool/result', 1, {
turn: 1,
step: 1,
message: {
source: { kind: 'tool', callId: 'gone' },
content: [{ type: 'tool-result', toolCallId: 'gone', isError: true, content: [{ type: 'text', text: 'boom' }] }],
},
error: { name: 'X', code: 'X' },
})),
]
rows = transcriptRows(orphan)
expect(rows[0]).toMatchObject({ kind: 'tool', failed: true, resultText: 'boom' })
})
})
describe('collectOwnEvents', () => {
/** A paginated fake log: fetchPage(beforeSeq) returns the ≤pageSize events
* ENDING before beforeSeq (exclusive); the tail page when omitted. */
function pagedLog(events: SidebarSessionEvent[], pageSize: number) {
const calls: Array<number | undefined> = []
const fetchPage = async (beforeSeq?: number): Promise<SidebarHistoryEntry[]> => {
calls.push(beforeSeq)
const window = beforeSeq === undefined ? events : events.filter(e => e.seq < beforeSeq)
return window.slice(-pageSize).map(entry)
}
return { fetchPage, calls }
}
it('finds the seed boundary on the tail page in one fetch', async () => {
const { fetchPage, calls } = pagedLog([
ev('user/message', 0, { content: textBlocks('inherited'), source: { kind: 'user' } }),
ev('session/end-seed', 1),
ev('user/message', 2, { content: textBlocks('own'), source: { kind: 'user' } }),
ev('assistant/message', 3, { turn: 1, step: 1, message: { content: textBlocks('a') } }),
], 200)
const result = await collectOwnEvents(fetchPage)
expect(calls).toEqual([undefined])
expect(result.seedBoundary).toBe(1)
expect(result.entries.map(e => e.event.seq)).toEqual([2, 3])
})
it('walks back across pages until the boundary surfaces', async () => {
// A dense chunk tail (cold reads re-expand chunk-rows): the boundary is
// several pages back and earlier tool/call events must NOT fall out of
// the loaded window (the re-entry tool-row regression).
const events = [
ev('session/end-seed', 0),
ev('tool/call', 1, { turn: 1, step: 1, callId: 'c1', name: 'bash' }),
...Array.from({ length: 12 }, (_, i) => ev('assistant/chunk', 2 + i, { turn: 1, step: 1 })),
]
const { fetchPage, calls } = pagedLog(events, 5)
const result = await collectOwnEvents(fetchPage)
expect(calls.length).toBeGreaterThan(1)
expect(result.seedBoundary).toBe(0)
// Everything after the marker, oldest-first, deduped.
expect(result.entries.map(e => e.event.seq)).toEqual(events.slice(1).map(e => e.seq))
})
it('stops at the log start when no marker exists and reports boundary 0', async () => {
const { fetchPage } = pagedLog([
ev('user/message', 0, { content: textBlocks('legacy'), source: { kind: 'user' } }),
ev('assistant/message', 1, { turn: 1, step: 1, message: { content: textBlocks('a') } }),
], 200)
const result = await collectOwnEvents(fetchPage)
expect(result.seedBoundary).toBe(0)
expect(result.entries.map(e => e.event.seq)).toEqual([0, 1])
})
it('honours the page cap instead of re-walking forever', async () => {
const events = Array.from({ length: 30 }, (_, i) => ev('assistant/chunk', i, { turn: 1, step: 1 }))
const { fetchPage, calls } = pagedLog(events, 5)
const result = await collectOwnEvents(fetchPage, 2)
expect(calls).toEqual([undefined, 25])
expect(result.seedBoundary).toBe(0)
expect(result.entries.map(e => e.event.seq)).toEqual([20, 21, 22, 23, 24, 25, 26, 27, 28, 29])
})
})
+23
View File
@@ -75,6 +75,29 @@ describe('subagent detection over the sessions list feed', () => {
expect(countSubagentDescendants(cyclic, 'a')).toEqual({ count: 2, runningCount: 0 })
})
it('excludes Side Chat threads (subagent origin, "Side: " label) from counts', () => {
// Side threads ride the subagent origin for list hiding + the RPC fence
// but they are tab-strip conversations, never topology: they must not
// fire the auto-open trigger nor inflate the Subagent page totals.
const byId: SidebarSessionList['byId'] = {
p1: { id: 'p1', displayTitle: 'P1' },
c1: { id: 'c1', displayTitle: 'C1', origin: 'subagent', parentId: 'p1' },
s1: { id: 's1', displayTitle: 'Side: refactor plan', origin: 'subagent', parentId: 'p1', running: true },
}
expect(directSubagentCount(byId, 'p1')).toBe(1)
expect(countSubagentDescendants(byId, 'p1')).toEqual({ count: 1, runningCount: 0 })
// A side thread appearing under an empty session never trips 0 → N.
const before: SidebarSessionList = { current: 'p2', byId: { p2: { id: 'p2', displayTitle: 'P2' } } }
const after: SidebarSessionList = {
current: 'p2',
byId: {
p2: { id: 'p2', displayTitle: 'P2' },
s2: { id: 's2', displayTitle: 'Side: New thread', origin: 'subagent', parentId: 'p2' },
},
}
expect(detectNewDirectSubagent(before, after, 'p2')).toBe(false)
})
it('resolves the main-agent root of the current session tree', () => {
const byId: SidebarSessionList['byId'] = {
main: { id: 'main', displayTitle: 'Main' },