Skip to content

dsh-coding-sidebar v1.0.34

Choose a tag to compare

@kkutysllb kkutysllb released this 25 Sep 04:37
· 25 commits to main since this release

dsh-coding-sidebar v1.0.34

发布日期:2026-09-25 · 面向 DSH 0.1.7(rc 系列)· 上游同源:DSH-better-sidebar 0.21.1

侧边对话(Side Chat)转正

(beta) 标记移除——这一版把它从「摆着不能用」修成可用、可流式、可读:

  • 能读了(本轮最大的一条):此前侧边对话永远空白。根因是 fetchThread 开头一段遗留的
    能力探测——它先查 ctx.connection.api.sessions.history 是否存在,而当前 rc 载体的客户端
    没有 connection.api 这个面(迁到 remote-namespace 之后),探测失败即 return:
    transcript 一次都不拉,连插件自家的路由都不会被调用。
  • 读法换了:通用 session.history 对 subagent 来源的会话直接拒绝
    (session/agent-busy:"subagent Sessions require their durable parent address"),
    而侧边对话的子会话正是这一类 ⇒ 改走插件自家路由 sidechat.events
    (主机侧切掉继承的 fork seed;活线程读 snapshotEvents()、冷线程走 persistence.open(id,'read')
    句柄,两条路都不激活子会话;带 afterSeq/beforeSeq/maxEvents 分页)。
  • 实时逐字输出:0.1.5 起流式文本不再写入会话日志(旧 assistant/chunk 事件永不再来),
    在途增量是作用域帧 agent/assistant-stream。插件现在订阅它({ global: true },
    与引擎自己的折叠实现同款)折成按会话的有界缓冲,经 sidechat.events 的 live 行送到界面,
    定稿后由 assistant/message 覆盖(不重复、不闪回)。
  • 轮询节拍修了两处:① 不再以 running 为前提——引擎不给 subagent 来源的会话产生 running
    状态
    (恒假),此前导致「只拉一次」;② 等回复期间不退避,且用户动作会取消已排好的那一拍
    (只清退避计数没用,定时器已经 armed)。
  • 工具行结构化(移植自同源上游 0.21.1,与主对话渲染同一批宿主 Block 原子):
    改动卡(edit/write:路径 + 新文本,调用时即出现)、读取卡(行窗口:路径 +
    已读/总行数 + 带行号代码块,行号契约与宿主一致)、终端卡(bash:工作目录 + $ 命令 +
    输出 + 退出药丸,[exit code: N] / [killed by signal: X] 尾标记剥出);
    失败结果退回通用文本行;畸形 meta 一律弃卡而不崩。
  • 每轮收尾一行:token 用量(输出累加、输入取最后一次)与墙钟时长(turn/start → turn/end)。
  • 读失败不再静默:客户端对 sidechat.events 设 5s 截止、主机冷读设 2.5s 上限,超时会明说。

提问回答路径(本轮新增:侧边对话的提问真正可答)

现场:子会话用 ask_user_question 提问时,在输入框敲答案回车没有任何反应——插件当时只走
sidechat.prompt(把回答当成追问送出去),而子会话正卡在那个提问上,于是两边都不动;
提问卡上的选项也只是静态文本,点不了。

引擎的机制(packages/client/ui-user-questions + api-gateway)与本轮接法:

  • 提问走 Remote waterfall user-questions/request,客户端监听器把请求登记成 Session 级
    pending interaction
    (uiSession.sessionStatus 按 sessionId 可见);只有调它的
    answer({ answers }) 才把答案交回服务器。侧边对话的子会话同样会登记——反向解析用的是
    sessions.retainAgentScope(sessionId)(按 id 现取作用域),与「主视图专用」的那几道栅栏无关。
  • 该面通过 waitable ctx.inject(['uiSession'], …) 捕获(别的插件提供的服务;裸 ctx.get 读不到),
    读法与作答都在 src/client/sidechat-questions.ts(结构化消费:只认形状,不 import 引擎运行值)。
  • 问答卡可点:选项渲染成按钮(单选点完即答;多选累积后由「提交回答」收口),
    选项文本原样回传(引擎按 label 匹配);历史里的提问卡不会长出按钮——按题目 id 序列
    与当前待答请求配对,只有配对上的那一行才可交互。
  • 输入框分流:有待答提问时回车先是回答(填第一道未答题,凑齐即成批提交),没有待答时
    才是追问——这正是此前缺的那条判据。提问期间发送键仍在(没有选项的题目也能点着作答),
    停止键也仍在(子会话此时仍是 running)。
  • 答案编码逐字对齐引擎 QuestionComposer.submitDrafts:单选下 custom 与 selected 互斥
    (有自定义文本即清空 selected),多选下两者并存,id 回显题目 id。
  • 选项卡样式的选项卡(按实机反馈调整):选项不再是缩进的一段文本,而是独立卡面上的卡片行
    ——指示器(单选圆点 / 多选方框,选中填充并出勾)+ 主文本 + 次要说明;选中态改用与发送键同一个
    信息蓝描边(深色主题下 brand-primary 是浅色,做描边读不出「选中」);问题本体、detail
    与「可多选」提示都在卡内,整卡用独立卡面(bg-layer-1 + 描边 + 圆角)与工具行正文分开。
    渲染用的是引擎的请求本体(pending.questions)而不是工具行里的副本:配对按题目 id 序列成立,
    请求本体才是权威(带 detail,也是宿主真正在等的那批题)。

模型跟随主会话(本轮新增)

现场:侧边对话永远跑「配置里第一个默认模型」,主会话切到 GLM-5.3-Flash Max 也不跟随
(截图里主会话跑 GLM、侧边栏徽标写 deepseek-v4-flash)。

根因是引擎有两套模型状态,插件拿错了那套:

  • AgentOptions(=插件的 parent.options)是 agent 创建时的启动参数——引擎自己创建主会话时
    传的就是 agentDefaultModel.currentSelection()(部署默认);
  • 用户在会话里换的模型走 session.selectModel → session/selection 事件 + 运行时装订的
    selection(ApiSessionAgentController.selectionFor,由 composeAgent 的 setup 装订),
    从不回写 agent.options。

而插件的子会话 setup 是自己写的(只挂 preset)——把引擎那步 install 跳过了(旧注释还写着
「child carries the parent's provider/model in agentOptions」,前提本身就是错的)。
⇒ 子会话退回启动参数 = 默认模型。

修法三条:

  1. 建线程时装订:子会话 setup 里补上 agents.selectionFor(agent)——引擎 composeAgent
    的第一步(packages/api-session-controller/src/agent.ts:393)。它按会话自己的日志投影
    解析 pending ?? lastUsed,而子会话的日志带着父会话的 fork seed ⇒ 解析出的正是父会话此刻的模型
    (含推理档位);冷恢复同理(子会话自己的 request/header 就是它上次真正用过的模型)。
    两处 setup(新建 / 冷恢复)都装订,服务缺席或投影缺席时记一行警告并退回启动参数。
  2. 每次投递前对齐:sidechat.prompt 在投递消息前把线程对齐到父会话此刻的选择。只装订开局
    那一次不够——用户之后在主会话换模型,已存在的线程不会自己知道(针对 subagent 的
    session.selectModel 被引擎 fence 掉)。相同则一个字节都不写(只有真的不同才落一条
    model/selection)。
  3. 徽标不再说谎:sidechat.info 原先报 agent.options(启动参数)。现在报装订的选择;
    冷线程报持久记录里最后一次请求头真正用过的模型(resolveLoggedModelSelection)。

第二轮修正(真正的根因,前两版都错在这里):前两版都去调
ctx.get('agents').selectionFor(...) / selectForNextRequest(...)——那两个方法不在 agents
服务上
。ctx.get('agents') 是核心 AgentRegistry(create/get/resume),而
selectionFor/selectForNextRequest 在 ApiSessionAgentController 上,那是个私有实例
(private readonly agents),根本不注册成服务 ⇒ 那两处调用恒为 no-op(可选链把 TypeError
吞了)。于是:建线程时看着「跟上了」其实是 agentOptions 带过去的 provider/model 在起作用;
而「已经开着的线程换模型」没有任何机制——现场正是「第一次跟随、之后不跟随」。

正确的公开装配面是 installModelSelection(agentCtx, ref)(@deepseek-ai/dsh-agent,引擎自己的
composeAgent 也用它),它在 agent 作用域挂三件事:system-prompt/assemble 写入 provider/model
变量、agent/request 用 ref.assembled 覆盖请求配置(真正决定模型的那一步)、agent/pre-step
在换路由时追加一条耐久「model changed」通知。而 ref 就是一个可变对象:

  • 建线程:装订一个本插件持有的 ref,初值 = 父会话此刻的选择;
  • 冷恢复:初值 = 线程自己日志里生效的那个(pending ?? lastUsed);
  • 每次投递前对齐:直接改 ref.current(下一次请求组装即生效),并落一条 model/selection
    事件(耐久 + 转录里那行「已跟随主会话切换到 X」);本来就一致则一个字节都不写;
  • 徽标读的是这个 ref(不再是 agent.options,也不再依赖任何可选服务)。

失败必须看得见:sidechat.prompt 现在回传 modelFollow { ok, switched, model?, reason? },
面板在 ok=false 时直接显示「未能跟随主会话的模型:<原因>」——两轮实机排查都是因为「静默不发散」
才多绕了一圈,这条把本轮所有早退原因(没记录父会话 / 父会话不在 / 读不到模型选择 / 线程未装订)
变成了界面上的字。

实机复验补的一轮(首版没生效):首版只走 sessionProjections 投影,而裸 ctx.get 在
某些挂载顺序下取不到该服务 ⇒ 跟随静默失效(徽标不换、也没有任何提示)。这一轮:

  • 加日志兜底:effectiveModelSelectionFromLog 是引擎 modelSelection 投影的等价 fold
    (model/selection → pending;request/header → lastUsed,与 pending 相同即消费掉 pending),
    投影取不到就地折会话日志(snapshotEvents())——同一份事实源,少一个服务依赖;
  • 父会话两级查找:先 sessions.get(id),退 agents.get(id).session;
  • 不再无声跳过:每条早退路径都记一行带原因的警告(没有 parentSession / 父会话不在 / 投影与日志
    都读不到 / 服务缺 selectForNextRequest / 对齐抛错),换模型成功也记一行 info;
  • 转录里看得见:子会话日志新出现的 model/selection 渲染成一行「已跟随主会话切换到 X」
    (此前用户只能靠徽标猜,而徽标还会说谎)。

追问队列卡(本轮新增)

现场:侧边对话正在跑的时候追问一句,输入框一清空就什么都没有了——其实消息没丢,是看不见:
追问走引擎的排队语义(agent.followup),消息在引擎领取之前不进会话日志,转录里自然没有它。

  • 唯一知道队列的地方是 agent 收件箱的 nextTurn:主机侧 sidechat.info 现在把它读出来
    (queuedFollowups,防御式收窄 + 多文本块拼接),客户端在输入框正上方画成队列卡:
    「队列中 N 条追问(当前回合结束后依次发送)」+ 每条正文(两行截断)+ 一行提示。
  • 队列随 info 的每一拍刷新 ⇒ 引擎领取一条,卡片自己少一条,转录里同时出现那条用户消息。
  • 「停止」保持原语义(keepInbox):只中止当前回合,队列保留——提示里也这么写了。

修复与加固

  • openTab 展开判据抽出为无依赖纯函数(src/client/open-intent.ts),新增真源码行为测试
    tests/open-intent.mjs(13 例,含 v0.1.33 那条 type + meta 回归)。
  • 新增侧边对话行为测试 tests/sidechat-live.mjs(16 例:实时流折叠、root/ctx 双通道、
    transcript 合并与定稿覆盖、工具卡收窄与退化、每轮汇总聚合)。
  • 契约断言新增检查 ④(四项回归闸,静态钉住本轮五个真 bug 的形态,断言前先剥注释)。
  • 新增追问队列测试 tests/sidechat-queue.mjs(5 例:顺序与身份、多文本块拼接与裁白、
    畸形收件箱不造行、缺 id 兜底、只看 nextTurn 不看 steering)。
  • 契约断言新增检查 ⑧(队列两项回归闸:主机必须读收件箱、客户端必须画队列卡)。
  • 新增模型跟随测试 tests/sidechat-model.mjs(12 例:投影取值 pending ?? lastUsed 与容错、
    冷线程请求头解析、主机侧接线用真函数 + 假服务驱动——一致则零写入 / 不同则写出父会话选择 /
    服务缺席与抛错都只降级不抛;以及投影服务缺席时走日志兜底仍能跟随、
    早退路径必须留下带原因的警告)。
  • 契约断言新增检查 ⑦(模型跟随三项回归闸:必须调 selectionFor(;两处 setup 都要装订;
    每次投递前必须对齐)。
  • 新增回答路径行为测试 tests/sidechat-questions.mjs(15 例:待答面结构化收窄与容错、
    useSyncExternalStore 要求的引用稳定性、单选/多选草稿语义、答案编码对齐引擎、输入框分流判据、
    历史卡配对)。
  • 契约断言新增检查 ⑤(css.<类名> 必须在本文件 import 的那张 CSS 表里定义):CSS Modules 按
    文件哈希类名,跨文件引用取到的是 undefined ⇒ 样式无声失效——P3 的三张结构化卡正是这样
    上线且没人看出来的(内容照常,只是没排版)。这条检查当场还抓出 TrajectoryGraph.tsx 里一处同类
    死引用(css.legendEdgeOn,实际样式由 aria-pressed 承载,已删)。
  • 契约断言新增检查 ⑥(回答路径两项回归闸:提交路径必须含回答分支;待答面必须走 waitable
    ctx.inject(['uiSession']) 且不得进全必需的 inject 清单)。
  • 发布构建环境无关:构建模式只认显式开关(KCODER_BUILD_MODE),不再继承环境 NODE_ENV
    ——此前 npm publish(会跑 prepack 重建)在 NODE_ENV=development 的 shell 里会把开发版分支
    打进发布包(1.0.33 的实测证据:发布产物的 lib/client-office.js 与 dev 构建逐字节相同)。

升级注意

  • 侧边对话的既有线程历史都还在:它们此前「空白」是读取路径的问题,不是数据丢了。
  • 冷会话读有 2.5s 上限:超时会明确报「读取超时」,而不是无限等待或静默空白。
  • 本轮诊断代码已全部移除(零残留);保留的是两类正式防御(两侧超时)与双通道订阅
    (作用域帧的投递边界随宿主组合而异,折叠幂等)。