DDDMUC/dsh-rerun-turn

dsh-rerun-turn

Infix rerun for DeepSeek Harness: re-run any earlier turn in place - the reply is regenerated from the same prompt while every later turn survives, so subsequent calls read A B C1 D E F G. Official seams only; the append-only session log is never rewritten.

미분류deepseek-harnessdshinfix

dsh-rerun-turn

DeepSeek Harness 的中缀重跑插件 —— 点某条模型回答旁的「重跑这一轮」,这一轮会用同一句提示词重新生成,而它之后的每一轮原样留在模型上下文里:后续调用读到的历史是 A B C1 D E F G。 重跑由官方 surface-replace 契约 + 一次逐事件重放组成(把被遮蔽的后续轮次以官方事件追加回来),append-only 会话日志一个字节都不改写。

中文 · English

中文

为什么需要它

DSH 的模型上下文是每次调用从会话 surface 重新推导的。官方只有「回退到末尾」式的编辑(dsh-edit-turn 的默认行为是改一条消息、丢掉它之后的一切),没有「重跑中间那一轮、后面照旧」这条路——因为在 surface-replace 契约里,模型回答永远不能充当替换载体(assistant/message 被格式明令禁止携带 sourceEventSeqs),所以一条回答无法原地换掉。

这个插件把它做出来:

  • 悬停任意一条模型回答,动作条里出现 ↻「重跑这一轮」;
  • 点击后:这一轮从它自己的提示词开始重新生成(模型看到的上下文正好是 A B + 那句提示词,不带旧回答、也不带后面的轮次);
  • 生成完成之后,之前被移出 surface 的后续轮次逐事件重放回去(新轮号、新消息 id、原内容),它们看起来就是普通的历史;
  • 后续任何一次调用发送的历史都是 A B C1 D E F G + 新内容——中缀拼接,前后都不受影响。

特性

  • 中缀重跑(本插件的全部意义) —— 重放把后续轮次以官方事件写回日志,冷读、导出、其他实例看到的内容与模型上下文完全一致。不是"改画面",是真的上下文。
  • 只用官方缝 —— 遮蔽是一条标准的 surfaceOp: { op: 'replace', startSeq, endSeq } 替换事件(与 /compact 同一契约);重放是普通的 append 事件。本插件仍然不写自定义事件类型。宿主 0.2.1-rc.2 已有官方的 appendPluginRecord + registerMessageProjection(dsh-edit-turn 0.3.0 用它做回复块的就地补丁);重跑没有采用,原因见下面的「为什么重跑仍然必须重放」。
  • 静默载体,且不占轮次 —— 遮蔽载体是一条没有可读文本的 `user/message`(source.kind = plugin:dsh-rerun-turn),它不开轮次、不开步骤:格式里只有 user/message 不需要打开中的 turn/step。它的内容形状由装载时对已安装适配器的一次探测决定(0.1.27):只有证明「当前适配器会丢掉空 user 消息」时才写空数组(deepseek 适配器天然如此;pi-ai 需要本地手补丁 dsh-delete-turn:skip-empty-user),否则写一个零宽空格——未打补丁的 pi-ai 会把 content: [] 变成 { role: 'user', content: '' },provider 直接 HTTP 400 user message must have content 并中断整轮(2026-10-07 桌面版真机事故)。0.1.14-0.1.25 用的是"空 system/message + 合成簿记轮",那一版每重跑一次就花掉一个轮号:对话视图里留下一条空的「已完成」条,轨迹视图轮号出现空洞(0.1.26 修)。
  • 重放保真 —— 逐事件复制:轮次括号、用户消息、助手消息(含思考与工具调用块)、tool/call、tool/result(sourceEventSeqs 重映射到副本)、TOOL_NOT_STARTED 修复结果(保留其"无 sourceEventSeqs"的精确形状);丢弃 usage(防统计翻倍)、内嵌 stream、系统消息(系统提示词由循环自己调和)与全部纯记账事件。
  • 崩溃可恢复 —— 承载事件记住 rerunId,fold 记住它遮蔽了哪些节点,每个副本带 originalSeq 标记;"哪些副本还没写"永远可以从日志单独算出来。/state 默认自动续传(会话活着、空闲、收件箱为空时),半写的括号孤儿会被识别并续写而不是误判为忙。
  • 准入失败也有正确回退 —— 重新生成没能入队时,重放会把提示词本身也复制回去,上下文内容与重跑前完全一致(不丢问题)。
  • 图片可重送 —— 原提示词里的图片会重新读字节、以 base64 重走官方 prompt 准入;文件附件暂时拒绝(见已知限制)。
  • 中英双语 UI,跟随 DSH 当前语言;皮肤友好(面板自绘表面,暗色走官方 body[data-ds-dark-theme])。
  • 宿主路由只限本机回环,并校验 Host 与 Origin。

安装

dsh plugin --profile web add dsh-rerun-turn

从本地目录安装(开发用):

dsh plugin --profile web add /path/to/dsh-rerun-turn

patchReload: live 的 profile 会热加载;否则重启该 profile 对应的 DSH 进程。

使用

  1. 把鼠标移到想重跑的那条模型回答上,点 ↻(官方动作条最左,编辑铅笔旁边)。
  2. 后台立刻开始:旧的一轮及其后内容先从模型上下文移除,提示词重新发给模型,新回答流式出现;结束后后续轮次自动重放回来。期间按钮隐藏(避免和正在生成的轮次打架)。
  3. 界面上:被退役的旧行隐藏,新生成的回答与重放的行正常出现——最终看到的顺序就是模型读到的顺序。

默认一次点击即执行;设置页勾上「重跑时询问」(或配置 confirm: true)后先弹确认框。

配置

- id: dsh-rerun-turn
  config:
    confirm: false            # 默认 false:一次点击即执行
    autoResume: true          # 默认 true:中断的重放在会话空闲时自动续传
    idleTimeoutMs: 600000     # 等待新回答落地的最长时间
    devTools: false           # 默认 false:端到端验证用的 /dev/* 回环路由

字段 · 默认 · 含义 confirm · false · 客户端是否先弹一次确认框。设置页的「重跑时询问」勾选会覆盖它(存在浏览器本地,未勾选过时跟随这里的值)。 autoResume · true · /state 是否在会话活着、空闲、收件箱为空时自动完成被中断的重放(不花模型调用)。 idleTimeoutMs · 600000 · 等待新回答闭合的最长时间;超时后重放留给续传。 devTools · false · 是否挂载验证用回环路由 /dev/scratch、/dev/derived(会发真实模型调用)。

设置页

插件管理页里 dsh-rerun-turn 那一行有配置入口,里面一个勾选:

  • 重跑时询问(默认关):勾上后每次点重跑先弹确认框(官方 Modal,与 dsh-delete-turn 的删除确认同一个位置/尺寸/样式),说明这一轮会怎么重放;取消勾选则点一下直接重跑。选择存在浏览器本地(dsh-rerun-turn:prefs),没有存过时跟随 profile 里的 confirm。

工作原理

一次重跑是三组官方操作,按序落地:

① 遮蔽(SHADOW)。 从目标轮次的提示词到 surface 末尾,是一条连续的窗口;追加一条带 surfaceOp: { op: 'replace', startSeq, endSeq } 的不占轮次的 user/message 载体,sourceEventSeqs 完整列出被遮蔽的每一个节点。载体没有任何可读文本、source.kind = plugin:dsh-rerun-turn:plugin: 类型让平台不会把它当人类提问(dsh-session-turn-outline 只认 source.kind === 'user' 的人类提问),内容形状按装载时的适配器探测二选一(见下面 0.1.27 的说明)。

载体内容为什么是两态(0.1.27):0.1.26 断言「两个官方适配器都会丢掉空 user 消息」,因此载体写死 content: []。这条断言只在宿主装载的 pi-ai 带本地手补丁 `dsh-delete-turn:skip-empty-user` 时成立——补丁只在 npx 缓存副本里,DSH 桌面版(app.asar 内置运行时)没有。真机 session-5c3c4c12(2026-10-07 16:27:33)就是这样断的:载体 content: [] → 未打补丁的 pi-ai 转成 { role: 'user', content: '' } → provider 返回 400 {"message":"user message must have content","param":"messages.93.content"},整轮 turn/end → reason.error 中止。同一天对真实 provider(https://api.cline.bot/api/v1,cline-pass/deepseek-v4.1-flash)实测:content: "" → 失败;content: [] → 失败;content: "\u200b" → 通过;content: "ok" → 通过。所以 0.1.27 把形状改成能力相关:装载时从宿主入口解析 @deepseek-ai/dsh-llm-pi-ai 并读源码,只有读到补丁标记才写空数组,否则写一个零宽空格(代价是几个启发式 token,用户不可见,provider 一定接受)。失败的默认方向是安全的那一侧:解析不到、读不到、异常 → 都当"没有证明" → 零宽空格。如果你要把载体改回空的,必须同时证明当前安装的适配器会丢掉它——否则就是上面那次 400。
为什么载体不能开轮次(0.1.1 的事故、0.1.14 的倒退、0.1.26 的定案):0.1.0 用"空 developer/message + 自开轮次"当载体。它通过了追加校验,但代理循环的轮号计数器是进程内局部的——只统计循环自己开的轮,永不重读日志里的最大轮号。于是循环为再生成开轮时复用了载体占过的号,日志从此冷读失败(turn/start does not open the expected turn)。同理,重放写下的轮次也会被循环的下一次开轮撞上。0.1.1 的修复有两件:载体彻底不占轮次;重放完成后把循环的空闲计数器同步到日志真实最大轮号(syncLoopTurn,带形状守卫、失败降级并写诊断——DSH 没有官方重同步 API,这是本插件唯一一处触及循环状态的地方)。
0.1.14 为了"载体完全不进模型输入"改回开轮次的形状(空 system/message + 合成簿记轮):模型确实看不到了,但每重跑一次就多一个没有内容的轮次——对话视图里是一条空的「已完成,用时 N 秒」,轨迹视图里轮号出现空洞(真实会话 session-5ce30467 的 turn 4/6/8/10/12 就是这样)。0.1.26 回到不占轮次的载体(0.1.27 起内容形状随宿主适配器能力二选一)。格式义务仍由同一处满足——只有 user/message 能在没有打开中的 turn/step 时被读取(system/message / developer/message 是 step 事件、assistant/message 走同一检查、替换型 tool/result 需要打开中的轮次),所以不占轮次的载体只可能是 user 消息这一种形状。

② 再生成(REGENERATE)。 走官方准入路径 ctx.sessionController.prompt({ mode: 'queue', content: 原提示词 })。模型看到的正是 A B + 提示词,用会话自己的模型与工具重新回答。后台任务通过 agent.whenIdle()(失败时轮询日志的 turn/end)等它闭合。

③ 重放(REPLAY)。 从目标轮之后的下一轮开始(目标轮自己的旧回答是被替换掉的,不重放),逐事件复制到日志尾部:

  • turn/start / step/start / step/end / turn/end —— 轮号重排为紧接生成轮之后的连续编号(格式的 Relationships 要求 turn/start 恰好打开 nextTurn);
  • user/message / assistant/message / tool/result —— 新 id、新轮号/步骤、原文内容、sourceEventSeqs 重映射;消息 source 加 { rerunBy, rerunId, originalSeq } 标记;
  • tool/call —— 与结果成对才复制;悬空调用连同助手内容里的工具调用块一起剥掉(格式拒绝带着未决调用的轮次收尾);TOOL_NOT_STARTED 修复结果保留"无 sourceEventSeqs"的形状(校验器只接受这种修复形状);
  • 系统消息与纯记账事件(attempt、retry、inbox、title、deliverable 卡片……)不复制。

为什么重跑仍然必须重放? 先区分两种操作:

  • dsh-edit-turn 的回复编辑不调用模型,只是把已有回答的某个块改成用户写好的文字。宿主 0.2.1-rc.2 的 appendPluginRecord + registerMessageProjection 正好能做这件事:surface 不动,后面的轮次一个不用重放。
  • 重跑必须让模型在新的上下文里重新生成。模型只能从活会话的尾部开始生成;要让上下文停在目标轮,必须先把目标轮及其后的内容移出 surface(遮蔽),生成完再让后续轮次回到 surface——surface 事件不能"取消遮蔽",只能重新追加,所以重放是结构性的,不是实现偷懒。

有没有办法绕过? 有三种,都有代价,本插件不做:

  1. 官方 `sessionController.fork` 在目标位置分叉,生成完再回来:fork 会创建一个可见的子会话(并复制前缀),而且用当前的默认模型,不是那一轮记录的模型;生成期间的工具副作用也发生在子会话里。子会话还会进入会话列表。
  2. 直接调 `ctx.llm.stream`:可以绕开 surface,但绕开了 agent loop 的工具执行、审批、重试、用量记账与工具历史重建;从日志里也拿不到那一轮精确的工具声明/系统提示词增补。目标轮一旦真的调工具,这条路径就无法产出对应的 tool/result。只适合"纯文字、不用工具"的轮次,不能作为重跑语义。
  3. 用投影把新回答盖到旧 seq 上:投影只能改写已有消息的 text/reasoning 块,不能向消息里添加 tool-call 块,也不能生成它;重跑如果用了工具,投影无法表达。

因此重跑保留"遮蔽 → 官方 prompt 再生成 → 逐事件重放"。重放本身已经做过压缩:内嵌 stream、usage、系统消息和纯记账事件都不复制;只复制仍在 surface 上的后续节点及其 tool 配对,所以日志增长是尾部内容的线性副本,不是整份会话或前缀的重复。

崩溃恢复。 承载事件源里的 rerunId/promptRequestId/replayFrom/rerunTurnEnd + fold 的 shadowed 列表 + 每个副本的 originalSeq 标记,让"哪些副本缺失"完全可从日志推导。/state 报出未完成的重跑;续传只写缺失的后缀,并用"尾部前缀匹配"跳过崩溃前已经落地的括号写(避免重复 turn/start 让日志读不出来)。

插件接口

类型 · 名称 · 说明 路由 · GET /dsh-rerun-turn/state?sessionId= · 可重跑的作答、hidden 账本、重跑记录(含 complete/missing)、忙碌/进度 路由 · POST /dsh-rerun-turn/apply · { sessionId, seq \ · messageId } → 遮蔽 + 后台再生成/重放;响应含 started/rerunId/shadowed 路由 · POST /dsh-rerun-turn/replay · { sessionId, rerunId? } → 为中断的重跑补写重放 路由 · GET /dsh-rerun-turn/debug · 只读请求记录(诊断"点了没反应") 路由 · GET/POST /dev/scratch、GET /dev/derived · devTools: true 才挂载;端到端验证用 前端 · conversation.chat.assistant-actions · 回答动作条里的 ↻ 入口(order 6,编辑铅笔之后) 前端 · conversation.input.overlay · 隐藏被退役的行、跟随后台重跑、显示确认框(官方 Modal)/错误

重放会带上 dsh-edit-turn 的回复补丁(0.1.28):edit-turn 0.3.0 的回复编辑是 plugin:dsh-edit-turn/patch 投影,不改写原始回答事件。如果先 edit 了后面某条回答、再 rerun 前面一轮,本插件在复制那条被保留下来的回答时,会把该 seq 上最新的 text / reasoning patch 应用到副本内容里(reasoning 会去掉旧签名);因此被重跑保留的后续轮上的编辑不会因为一次更早的重跑而消失。同一轮自身的重跑仍然以新生成的结果为准——重跑的目标回答不会被复制,编辑被覆盖是重跑的定义。记录是只读的跨插件契约:版本不是 1、字段畸形或目标块类型不符的 patch 一律跳过;即使 edit-turn 暂时不在,记录也会被本插件读取并带到副本里。

给兄弟插件的契约:每次重跑落一条没有可读文本的 `user/message` 替换载体(source.kind === 'plugin:dsh-rerun-turn'、rerunBy、rerunId,不占轮次);其 content 在 0.1.27 起为「空数组(证明适配器会丢)或一个零宽空格」,不要按内容判形状。每个重放副本的 message.source 带 { rerunBy, rerunId, originalSeq }。按 surface 校验的入口可以沿 /state 的 reruns[].shadowed 或副本的 originalSeq 找到活节点。识别约定:source.kind 以 plugin: 开头、且内容里没有可读文本(空数组,或只有零宽/空白字符的文本块)的 user 消息是静默载体,不是提示词——本插件的规划器(resolveRerunPrompt)据此拒绝"重发一条已被删除/已被替换掉的提示词";dsh-edit-turn 的就地改写(kind: 'user' + editedBy,且带新文本)不受影响,仍然可以重跑。历史形状(0.1.0-0.1.13 的 developer/user 载体、0.1.14-0.1.25 的 system/message 载体 + 簿记轮)继续被识别,旧日志的账本、隐藏集与 markerTurns 都照旧正确。

验证状态

本插件在 DSH 0.1.7-rc.2(npx 实例)上完成以下验证:

验证 · 命令 · 结果 单测(窗口规划、重放改写、标记、恢复匹配、计数器同步、载体不占轮次、载体两种内容形状,+ 客户端 DOM stub 的注入/隐藏/重装用例) · npm test · 60 项通过 官方校验器契约(真实 Session + sessionFormatCatalog strict 冷读往返) · npm run verify:contract · 29 项通过:完整重跑后的日志(含重跑后新增的普通轮)通过 v4 词汇表/关系/生命周期 + Session.fromRestore;派生上下文恰好 A B C1' D' E'(线上视图去掉静默载体);遮蔽不买轮号、全日志没有任何"只装载体"的轮次;载体形状与本机适配器探测一致(无证明时不得为空);"同一轮连点三次 ↻"的真机形状;链式重跑;崩溃半写后续传;准入失败回退;TOOL_NOT_STARTED 修复保真 客户端静态检查 + 运行中实例下发字节 · npm run verify:client / npm run verify:live -- · 7 项通过:模块可加载、槽位/字典/版本/错误码一致;3080 实例下发的模块组里就是本插件的当前字节 真实沙箱端到端(独立 DSH_HOME + 独立端口,真实模型调用) · npm run verify:e2e · 通过:3 轮 scratch 会话 → /apply 重跑中间轮 → 后台生成+重放完成 → 追加第 4 轮提示 → 读实时派生上下文:提示词保序、中间回答是新生成的、后一轮是带 originalSeq 标记的重放副本;随后用 tools/repair-session.mjs 对沙箱写出的日志做严格冷读校验:0 broken 运行中实例挂载探针 · npm run probe:loaded [端口] · 通过:/state 返 400、/apply 返 405

尚未验证的一环(诚实说明):浏览器里的交互渲染(按钮落位、点击、隐藏行)还没有自动化冒烟——本机没有可用的浏览器驱动接线;客户端半部的加载、槽位注册与真实下发已由 verify:client/verify:live 覆盖,宿主行为由端到端覆盖。首次使用请在浏览器里刷新一次 DSH 页面确认按钮出现。

已知限制

  • 只能重跑有提示词的轮次:目标回答所属轮次必须有一条 user/message 提示词(人类提问或注入的用户消息都算);系统提示词头不可重跑。
  • 一次重跑整轮:点某一步的回答 = 重跑它所在的整个轮次(提示词重新发送、整轮重新生成)。多步轮次的中间回答同样按整轮处理。
  • 带文件附件的提示词拒绝重跑(attachments-unsupported):prompt 准入要求文件走上传收据,durable 引用无法直接重送;图片可以。
  • 重放副本是有损拷贝:usage、内嵌 stream 被丢弃(防 token 统计翻倍);系统消息与 deliverable/workspace/todo 等纯记账事件不复制;被重放轮次里的工具不会重新执行(结果照抄)。
  • 重跑期间用户又发消息:重放会等会话空闲(忙时延后、最多 ~60 秒后转交续传),仍可能把副本排到新轮次之后——这是同一时间只有一个写者的自然限制,也是续传路径存在的原因。
  • 卸载插件后:重跑已经落地的效果保留(全是官方事件);但被退役的旧行会重新显示,界面看起来像重复(模型上下文不受影响)。
  • 会话必须当前置活(web 实例已打开);忙碌(有未闭合轮次/压缩中)时拒绝。
  • 没有撤销:重跑是破坏性的上下文操作(旧轮次从模型视野永久退役,日志里仍在)。想保留原文形成分支要走官方的 fork,未实现。
  • 兄弟插件的已知问题(与本插件无关):dsh-delete-turn 0.1.x 删除模型回答时会落一条 system/message 替换载体(source 为 plugin:dsh-delete-turn),而格式只允许 system-prompt 来源的 system 消息——含这种事件的会话在冷读时报 seed system/message ... must have system-prompt source、历史打不开。修复方向与 dsh-edit-turn 0.2.4 相同(载体改为不占轮次的 user/message,或空 developer/message + 合成轮)。本仓库的修复工具能用 --force 截断这类坏日志。

排查

  1. 点了 ↻ 提示「这条回答已经不在当前上下文里了」:你点的是已经被退役的旧回答——最常见的情形是刚重跑完,客户端还没来得及刷新那一行,又点了一次。0.1.2 起这种情况会静默刷新、不报错。
  2. 按钮没出现:npm run probe:loaded [端口]——404 说明插件没挂载;400/405 说明挂载正常,问题在浏览器(刷新页面、看控制台)。
  3. 点了没反应:GET /dsh-rerun-turn/debug——没有 apply 记录 = 点击丢在浏览器半边;有 apply 且 ok:false = 宿主拒绝,code 是原因。
  4. 重跑后历史看起来缺了后续轮次:等几秒(重放等待新回答闭合),或 GET /state 看 reruns[].complete/missing 与 progress;interrupted 时默认会自动续传。
  5. 「历史加载失败 / 会话读不出来」:可能是 0.1.0 留下的坏日志(见更新日志)。先让持有该会话的实例释放它(重启 DSH),再运行:
node tools/repair-session.mjs ~/.dsh/sessions --dry-run   # 先看
   node tools/repair-session.mjs ~/.dsh/sessions             # 修复(先备份)

工具只自动修复带 plugin:dsh-rerun-turn 标记的坏日志;其他坏日志只报告不动。修复后重开会话,待处理的提示词会被循环自动补答。

更新日志

0.2.5 —— 设置卡导出给组合包;两个补法只留两个字,说明收成一句,下拉不再撑满一行。

  • 选项就是词:「顺补」「自补」这两个词本身已经说清了是二选一,标签里却还跟着一整句解释。现在选项只有两个字,解释放在它正下方那行提示里。
  • 提示一句话:原来的提示是一百多字的整段,带三对【】和一对();现在各一句 —— 顺补是「重跑它下面第一个有 O 的可见轮;下面没有,就重发最新那条缺 O 的」,自补是「重跑这一可见轮自己的 O,后面的轮次不动」。
  • 下拉跟内容走:控件原来有 190px 的最小宽度,两个字的选项被撑成一条横贯卡片的框;现在宽度跟着内容。
  • 设置卡导出:exports.ConfigCard 对外导出,聚合包 dsh-as-aistudio 在自己页面上渲染的是这一张卡,不是另画一份;单独装本插件时那张卡仍在本插件自己的行上。

0.2.4 —— 缺失 O 的可见轮也能点重跑:顺补 / 自补两种补法可选。

  • 约定简写(本节与后续记录都用它):Q = 用户输入;O = 模型输出的所有内容块(O = C + A + T),其中 C = 思维链,A = 正文,T = 工具调用。一轮 = Q O,所以 QOQOQO 是三轮各有一问一答。注意 `tool/result`(工具的结果)不是模型输出,不算 O —— 它是工具/宿主产生的,只是恰好和 T 挨着。实测一份两万条的日志:assistant 消息 2931 条、内容块只有 tool-call ×2855 / reasoning ×2205 / text ×195,没有第四类。
  • 两种轮:日志轮 = 日志里记录的轮次(日志不可删,所以它是全集);可见轮 = 对话视窗里、经插件操作之后应该看到的那些轮次(被删掉 / 被隐藏 / 早先重跑退役掉的那部分不在其中,所以可见轮 ⊆ 日志轮)。下面两条规则都按可见轮算。
  • 没回答的轮次也有按钮了:以前是「这一轮没有回复 → 撤掉按钮」,于是「刚发出去还没回答」「被停止」「失败」「回答被删掉」这几种情况下按钮根本不在,用户看到的就是一片空。现在每一条没有回复的用户消息都带按钮。
  • 新增设置「缺失 O 的可见轮」(插件页配置卡片里,和「重跑时询问」同一张卡):两种补法各有两字名字 —— 顺补(顺着往下补:去重跑下面第一个有 O 的可见轮)与 自补(补它自己那一轮):
  • 顺补(默认):点哪个缺失 O 的可见轮,都去重跑【从这个轮往下、第一个有 O 的可见轮】的回答。它下面一个可见的 O 都没有时(它就在最末),改成重发最新那条缺失 O 的可见轮。
  • 自补:点哪个缺失 O 的可见轮,就重新生成【这个轮自己】的回答 —— 这是唯一能把上下文里的洞真正补上的做法。
  • 代价说清楚(两条路一样的地方):真正会再调一次模型的只有被作用的那一轮本身。它后面的轮次是被重放,不是重新生成 —— 把日志里已经记下的 C/T/A 原样抄回上下文(buildReplayWrites 逐条 appendWrite,全程没有模型调用;只有思维链的签名会去掉,因为签名签的是旧文本)。副作用是日志追加一份尾巴、被抄的轮次拿到新 seq(界面上看起来一样)。只有被作用的那一轮本来就是最末一轮时,才是「一次请求、后面一概不动」。
  • 宿主没改:planRerun 本来就接受「提示词目标」,只对「自己没有任何可重跑回复的那一轮」放行(停止 / 失败 / 刚发出还没回答都算),并拒绝把「有 A 的轮次的 Q」当目标 —— 所以「点这个 Q 去重跑下面那一轮」这种映射由客户端决定发哪个 seq,宿主不接受这种地址。被隐藏的 A 不算可见:默认模式会跳过它继续往下找,找不到就退回重发最新那条没回答的提示词。
  • 测试:npm test 86/86(宿主侧提示词目标只 shadow 提示词自己、回放窗口为空,且只广播「带 O」的回答 —— 空内容 / 零宽文本不算 O,纯工具调用算 O;客户端侧两种模式各自的作用对象与按钮文案、隐藏的 A 被跳过、回答被删的轮次仍带按钮且不落到别人的 A 上、卡片里能切模式并落盘)。

0.2.3 —— 修掉「重跑已中断」提示关不掉;命名会话不再被拒。

  • 根因(宿主):setProgress() 把每次阶段更新合并进上一条记录,从不清理旧字段。一次 interrupted 写下的 error 会跟着 replaying、done 一路活下来,/state 于是一直报告"已中断",前端也就一直画那条提示。
  • 修法(宿主):新增纯函数 progressAfter(current, patch);阶段变化 = 新记录,不再继承上一阶段的 error / promptError;同一阶段内的增量 patch 仍然合并。setProgress() 改走它。
  • 修法(客户端):提示只认 phase === 'interrupted' | 'failed' 的 error(旧宿主合并出来的 done + error 不再显示);提示条右侧加关闭按钮,点整条也能关;关闭状态按「阶段 + rerunId + 文案」记忆,新错误仍会重新出现。
  • 命名会话不再被拒(本版一并带上):会话 id 校验原来只认 UUID,而 bili-whale 这类命名会话(~/.dsh/storages//sessions/bili-whale.json)是完全合法的会话 —— 于是对它的每个请求都被 400 拒掉,面板上就是「状态加载失败,稍后重试。」。现在按 DSH 自己的语法(宿主侧 schema 就是 z.string().min(1))放宽为 /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/,仍然挡住 /、`、..`、空白与控制字符。
  • 查不到会话按空状态处理:没有日志的会话(刚建的命名会话)返回空状态而不是 404 —— 面板正常加载、显示"没有可操作的内容",日志一出现下一次刷新就接上。
  • 测试:npm test 81/81(新增 3 条 progressAfter 用例、5 条 DOM 用例:stale error 不显示、关闭按钮、点整条关闭、dismissed key、promptError 仍显示;以及命名会话 / 空状态的契约用例)。

0.2.2 —— 设置页多一个「重跑时询问」,确认框换成官方 Modal。

  • 设置页勾选:插件管理页里 dsh-rerun-turn 这一行的配置入口新增「重跑时询问」(默认关)。勾上后每次点重跑先弹确认框,说明这一轮会怎么重放;取消勾选则点一下直接重跑。选择存在浏览器本地(dsh-rerun-turn:prefs),没有存过时跟随 profile 里的 confirm。
  • 确认框与 delete 对齐:原来是按钮下方的内联小面板,现在改用官方 Modal + Button,位置/宽度(380)/圆角/阴影/按钮行与 dsh-delete-turn 的删除确认完全一致;正文 14/22,第二段说明用三级文字 13/20。
  • 文案:删掉「后续调用将读到 A B C1 D E F G」那句。
  • 测试:72 全绿(含新增 4 条:注册键、summary 随勾选、忙时不拦、Modal 结构)。

0.2.1 —— 重跑一开始就退掉旧回答;轮次导航不再留空档。

  • 之前:旧回答要等到新回答的 assistant-step 行出现才隐藏;而生成期间的「正在思考中」通常是 process/tail 行,所以旧回答会一直留在原地,新内容在它下面流式出现,生成完才替换——视觉上很别扭。
  • 现在:in-flight rerun 一旦确认,目标回答行立即隐藏,新的流式行直接占用它原来的位置;后续轮次仍按 order:2 留在下面。代价是最多一帧的空位(宿主挂上 process 行即填满)。

0.2.0 —— 与 dsh-edit-turn 0.5 的整轮保存对齐,并把重跑按钮的尺寸修准。

  • 多目标补丁兼容:edit-turn 0.5 的一次「全部」保存会把整轮多个块写成一条 plugin:dsh-edit-turn/patch 记录,targets 可能跨多条消息。重放现在有测试覆盖这条形状:被保留的后续轮里,每条消息都按自己的 target 取到新文本,原记录不被改写。
  • 按钮尺寸:.dsrr-action 补上 box-sizing:border-box(此前 width/height:28px 叠加 padding:6px 实际渲染成 40×40),并去掉 hover 的 inset 描边,只保留浅色圆底;现在与官方 action 图标一致:28×28、15px SVG、stroke-width:1.2、颜色 #81858c。

0.1.28 —— 重放带上 dsh-edit-turn 的回复补丁:先编辑后面某条回答、再重跑前面一轮时,编辑不再消失。

  • 读补丁:editPatchesOf(events) 读取 plugin:dsh-edit-turn/patch 记录,按 seq + blockIndex 取每个块最新的 text / reasoning 补丁;记录版本不是 1、字段畸形、目标块类型不符的一律跳过。
  • 写入副本:buildReplayWrites 在复制被保留下来的后续回答时,用 applyEditPatches 把补丁应用到副本内容;思维链补丁同时去掉旧 signature / thinkingSignature。工具调用、其他块、轮次结构不动;原始事件不改。
  • 边界:同一轮自身的重跑仍以新生成结果为准(目标回答不复制,编辑被覆盖是重跑的定义);被重跑保留的后续轮,编辑跟着副本走。记录是只读跨插件契约,edit-turn 暂时不在也能读。
  • 测试:新增 3 条纯 builder 用例(正文补丁、思维链补丁去签名、畸形/旧版本/目标不存在时跳过且最新补丁覆盖);npm test 63/63、npm run verify:contract 29 项、npm run verify:client 7 项全绿。

0.1.27 —— 修复真机中断事故:载体写死空数组,在没有手补丁的宿主上让整个 provider 请求 400,整轮中止。

  • 现场(桌面版,session-5c3c4c12-e803-492a-b614-9a0f4c5ea6a3,2026-10-07 16:27:33):turn 187 中止,turn/end → reason.error: Failed to create stream ... 400 {"message":"user message must have content","param":"messages.93.content"};派生消息下标 93 正是本插件的空载体(seq 10968,content: [],source.kind = plugin:dsh-rerun-turn)。
  • 根因:0.1.26 的设计说明断言「两个官方适配器都会丢掉空 user 消息」。该断言只对带本地手补丁 `dsh-delete-turn:skip-empty-user` 的 pi-ai 成立(补丁只存在于 npx 缓存副本,所以 web 宿主没事);DSH 桌面版(/Applications/DeepSeek Harness.app,运行时打在 app.asar 里,同一包内没有这一行)会把 content: [] 转成 { role: 'user', content: '' },provider 拒绝整个请求。
  • provider 实测(当日,https://api.cline.bot/api/v1,cline-pass/deepseek-v4.1-flash):content: "" → stream_initialization_failed;content: [] → 同样失败;content: "\u200b" → 通过;content: "ok" → 通过。
  • 修法:形状能力相关——装载时从宿主入口(process.argv[1] 所在目录)解析 @deepseek-ai/dsh-llm-pi-ai 并读源码,读到补丁标记才写空数组(保住 0.1.26 的零 token 行为),否则写一个零宽空格;解析失败/读失败/异常一律按"没有证明"处理,即零宽空格。判定是纯函数 + 装载时算一次(adapterSourceDropsEmptyUserContent / silentCarrierContent / ADAPTER_DROPS_EMPTY_USER_CONTENT),形状经 buildShadowWrites(plan, rerunId, promptRequestId, dropsEmptyUserContent?) 注入,测试不依赖本机 npx 缓存与 process.argv。与兄弟仓库 dsh-delete-turn 的同名设计一致(同一补丁标记、同一保守默认)。
  • 连带修正:isSilentPluginCarrier 由「内容为空」放宽为「没有可读文本」(空数组,或只有零宽/空白字符的文本块)——否则零宽载体在链式重跑里会被当成人类提示词重发;/dev/derived 只在有证明时才把空 user 消息从报告里去掉(未证明时那条零宽消息就是适配器真正发出去的东西),并在响应里多一个 carrier: { shape, adapterDropsEmptyUserContent } 让现场一眼看出走的是哪一支。
  • 不变式:遮蔽仍是恰好一条不占轮次的事件、不写 turn/step 括号、不调 syncLoopTurn、resolveRerunPrompt 的 'carrier' 拒绝、isRerunCarrier 的账本跳过照旧(形状判定只看 source 标记,不看内容)。
  • 验证:60 单测 / 29 契约 / 7 客户端全绿;/tmp 副本把探测强制为"无证明"后同样全绿(证明两支都受支持);/tmp 副本只还原载体行为(保留新用例)→ 恰好 2 条新用例转红(零宽回退、零宽载体不是可重发提示词),再把探测强制为"无证明"→ 契约校验器在「无证明时不得写空载体」这条断言上转红。

0.1.26 —— 修复真机可见缺陷:载体占了独立的轮次,屏幕上留下空的「已完成」空壳(对话视图)+ 轮号空洞(轨迹视图),两者同一个根因。

  • 现场(session-5ce30467):用户对同一条提问反复点 ↻,日志里出现 turn 4/6/8/10/12——每个只有一条空 system/message 载体、turn/start → step/start → 载体 → step/end → turn/end,屏幕上就是「已完成,用时 1 秒」下面什么都没有,轨迹视图轮号 2,3,5,7,9,11,13 跳号。
  • 根因:0.1.14 起载体是空 `system/message` + 合成簿记轮,每重跑一次消耗一个轮号。而格式里 system/message/developer/message 只能在打开中的 turn+step 里被读(requireStep)、assistant/message 走同一检查、替换型 tool/result 需要打开中的轮次、turn/start 又必须等于 nextTurn(已关闭的轮次回不去)——所以只要载体是这几种类型之一,就必须开一个轮次。
  • 修法:载体改回不占轮次的空 user/message(source.kind = plugin:dsh-rerun-turn,content: [])。格式只对 user/message 不要求打开中的 turn/step;"模型看不到"当时按"两个官方适配器都在发请求前丢掉空 user 消息"论证(dsh-llm-deepseek:role === "user" && content.length === 0 → continue;pi-ai:同款跳过,注释里叫 dsh-delete-turn:skip-empty-user)。同一个形状也是 dsh-delete-turn 一直在用的静默载体,不是新发明的写法。0.1.27 更正:这条论证对 pi-ai 只成立于带本地手补丁的构建——桌面版内置运行时没有这一行,空数组会被转成 content: '' 并被 provider 以 400 拒绝(session-5c3c4c12 真机事故),所以 0.1.27 起内容形状改为按适配器探测二选一。
  • 连带修正:resolveRerunPrompt 不再把"站在已退役提示词位置上的静默载体"当作可重发的提示词(空内容也过不了 prompt 准入);重放不再复制本插件自己的载体(记账不是对话,副本只会变成空节点);/dev/derived 报的是适配器真正发出的请求(去掉空 user 消息),不再把派生列表直接当成模型输入。宿主不再需要 syncLoopTurn 去迁就载体轮——载体的那次调用整个删掉了(重放之后的那次保留)。
  • 客户端:markerTurns/retiredTurns 的轮号判定除了 key 之外也读平台自己的 data-chat-turn——DSH 现在把过程/尾巴行按节点 kind 作 key(turn-tail、["turn-tail","response"]),只从 key 里抠轮号会让旧日志(以及被退役的轮)的空条留在屏幕上。
  • 验证:56 单测 / 29 契约 / 7 客户端全绿;/tmp 副本还原改动 → 恰好 4 条新增用例转红(2 条载体形状 + 2 条客户端隐藏),契约校验器在第一条新断言就红;真实会话 `session-5ce30467` 全程用真机校验器(0.2.0-rc.2)验证:原日志照旧严格冷读通过、账本输出与 0.1.25 逐字节一致;在该日志上跑一次新形状重跑(真 Session 追加 + 重新冷读)通过,遮蔽买的轮号数为 0(只有再生成轮 +1)。

0.1.25 —— 没有定稿作答的轮次也能重跑(点它的提示词行):rerunnableReplies 末尾为"有活提示词、但没有可重跑回答"的轮次补一条 { seq: 提示词 seq, messageId: null },规划器只在这类轮次接受提示词寻址。

0.1.24 —— 补齐 0.1.23 的一个漏网形状:提示词被兄弟插件替换成非用户消息时,界面仍会给出 ↻。

  • 现场(session-e81f9424,turn 31):dsh-delete-turn 先把提示词「机组」替换成自己的用户载体,又把它替换成自己的 `system/message` 载体——最终活节点仍是 system 消息。作答还在界面上,但已经没有可重发的提示词。
  • 根因:广告侧只检查「提示词链能落到界面上的活节点」,规划侧却还要求那个节点必须是 user/message——两侧判据仍有一处没有共享。现在两侧共同调用同一个 resolveRerunPrompt(bracket 归属 + 链解析 + 类型检查 + 头部检查全在里面),广告与承诺不可能再分叉。
  • 验证:check-rerun-plans.mjs 对 8 个真实会话(含 10953 事件的大会话)全部 M=0 / 广告全部可规划;新增回归测试「提示词 → 兄弟用户载体 → system 载体」钉死该形状。

0.1.23 —— 修复「这条回答不支持重跑」:作答所属的轮次可能被拆成多个 bracket,规划器原先取同号的第一个,于是一个界面上明明可见、且自己 bracket 里有提示词的作答被拒。

  • 谁拥有这一轮:重跑会重放尾部,重放开出的记账 bracket 会复用轮次号却没有任何用户消息;规划器现在取范围包含该作答的那个 bracket(退路依次是「带提示词的」→「同号的第一个」)。真实会话复现:一个含四次重跑的会话里,带提示词的作答全部被拒,修复后 30/32 → 30/30 可规划。
  • 不再把数组下标当 seq:日志文件以一条 header 记录开头(平台的扫描器会剥掉它),resume 的会话还会继承日志头,所以 events[seq] 并不等于「seq 等于它的那条事件」。同一个会话里这个错位会把提示词读成一条 step/start,报「the turn prompt is not a user message」。所有「按 seq 取事件」的读取现在都走 seq 索引。
  • 广告即承诺:/state 的 replies[] 与规划器共用同一个 bracket 判据 —— 没有提示词(或提示词已不在界面上)的应答不再出现在列表里,界面不会给出一个点了必然失败的按钮。

0.1.22 —— 修复「重新 apply 时注入节点不清理 → 幽灵按钮堆积」:行内注入前先复用已有宿主(不再删旧的再造新的),fiber dispose 时按命名空间属性清扫本插件的注入节点。

  • 先复用:注入前按 [data-dsrr-action-host="1"] 查一次行内已有宿主,找到就复用同一个节点(WeakMap 重新指向它),绝不新建第二个;重复的旧副本(本插件自己的节点)才删。
  • 卸载即清理:dispose 里同步扫掉自建的宿主(含行内按钮)与本插件 append 的样式表,并在下一个任务再扫一次由 React 渲染的节点(回答条按钮、浮层根)——React 会自己卸载它们,抢在它前面删会让 React 的 removeChild 抛错。只删本插件自己的节点(data-dsrr-* 命名空间 / 本插件 id 的 style),宿主与兄弟插件的节点一律不动。

0.1.21 —— 互操作加固(AI Studio 组件契约):输入框浮层槽位号 8 → 10、隐藏归属守卫、注入节点带命名空间。