dsh-edit-turn 0.2.19 → 0.2.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # dsh-edit-turn
2
2
 
3
- **DeepSeek Harness 的「编辑某一轮」插件 —— 点用户消息旁的编辑按钮,就地改写那一句。保存即生效:改后的内容成为模型后续读到的版本,这一条消息下面的对话原样不动。** 改写走官方 surface-replace 契约(一条替换事件同时完成"删旧 + 立新",被替换的内容从模型上下文里消失),**保存本身永不调用模型**。会话日志是 append-only 的,**原始字节一个都不改写**。
3
+ **DeepSeek Harness 的「编辑某一轮」插件 —— 点消息旁的编辑按钮,就地改写。改提问:那一句就地替换,这一条下面的回复与后续对话原样不动;改回答:回答被替换,它后面的轮次先回退、再逐事件重放回来,顺序不变。** 改写走官方 surface-replace 契约(一条替换事件同时完成"删旧 + 立新",被替换的内容从模型上下文里消失),**保存本身永不调用模型**。会话日志是 append-only 的,**原始字节一个都不改写**。
4
4
 
5
5
  [中文](#中文) · [English](#english)
6
6
 
@@ -18,16 +18,17 @@ DSH 的会话日志是 append-only 的事件流:说错的提示词、问偏的
18
18
  - 点击后在该消息下方就地展开编辑器,预填原文;
19
19
  - 保存后:**只有这一条消息被替换**——旧措辞从模型上下文里消失,改后的内容占住它的位置(因此后续每一轮读到的都是改后的版本);
20
20
  - **它下面的助手回复、以及之后的全部对话,原样保留**,不重跑、不清空;
21
- - 装了姊妹插件 **dsh-rerun-turn** 时,编辑器里会多一个「**重跑**」:先保存这次修改,再让它用改后的提示词重新生成这一轮(后续轮次逐事件重放回来,不丢)。
21
+ - **模型回答也能这样改**:改写后的回答落在原回答的位置,它后面的轮次**先回退、再逐事件重放回原位**——改一句回答不会把后面的对话丢掉;
22
+ - 装了姊妹插件 **dsh-rerun-turn** 时,编辑器里会多一个「**重跑**」:先保存这次修改,再让它用改后的提示词重新生成这一轮(后续轮次同样逐事件重放回来,不丢)。
22
23
 
23
24
  ### 特性
24
25
 
25
26
  - **官方 seam,不改日志** —— 回退 = 追加一条带 `surfaceOp: { op: 'replace', startSeq, endSeq }` 的替换事件。原始事件全部留在会话文件里,只是不再进入 `deriveMessages()`。与官方 `/compact` 用的是同一套契约。
26
27
  - **改写内容就是载体本身** —— 提示词编辑落下的是一条 `user/message` 替换事件,内容就是你改后的那句话。它既让模型读到你改后的版本,也在转录里显示成你自己的气泡;平台不会把它当成新输入去回答(这一点踩过坑:**追加**一条 user 消息会触发平台自动回答,越改越多)。
27
28
  - **块随消息一起改(0.2.19)** —— 改写一条带图/带文件的消息不再只留文字:**用户没碰过的块原样搬**(逐字节复制,attachmentId、文件名、尺寸一个不改),**删掉的块不写**,**新加的块**经平台的附件服务录用后写进载体。代码里没有任何"这是图片/这是文件"的判断,唯一的判定是"这个块里有文字吗",所以平台以后加的块类型自动适用。编辑器的非文字块显示成芯片(缩略图或名字 + 大小 + 删除按钮),另有一个「添加附件」入口;服务不在时退回旧行为(只改文字 + 继续显示那条警告)。
28
- - **窗口最小化** —— 提示词编辑只遮蔽目标那一格(`shadowed = [target.seq]`);回复编辑才需要"到末尾"的窗口(回答变了,建立在它之上的一切都不再成立),因此助手消息(内含 tool_use)与它产生的 tool/result 永远一起走,**不可能留下悬空的调用/结果对**。
29
+ - **窗口最小化 + 尾部重放** —— 提示词编辑只遮蔽目标那一格(`shadowed = [target.seq]`);回复编辑因为新回答只能 append,窗口必须到末尾,**但被遮蔽的后续轮次不会消失**:它们会带着新 turn 号、新 messageId、原内容和 replay 标记逐事件重放回来(与 `dsh-rerun-turn` 的 infix splice 是同一套写法,两个仓库的 replay 输出有逐字段对照测试)。助手消息(内含 tool_use)与它产生的 tool/result 永远一起走,**不可能留下悬空的调用/结果对**。
29
30
  - **保存永不调用模型** —— 保存只写上下文,不产生任何模型调用。"改完立刻重答"是另一件事,交给姊妹插件 **dsh-rerun-turn**(它遮蔽该轮、用表面上的提示词重新生成、再把后续轮次逐事件重放回来);本插件只在它装着时提供一个「重跑」按钮转发过去。
30
- - **模型回答也能编辑** —— 回答无法被"替换":官方格式禁止 `assistant/message` 携带 `sourceEventSeqs`(已在真实校验器上验证)。做法是回退该回答及其后的内容,再**追加**一条带改写文本的助手消息——模型会把改写后的内容当成自己说过的话,对话可以继续。`source.editedBy` 会如实记录这段文字由插件写入。
31
+ - **模型回答也能编辑** —— 回答无法被"替换":官方格式禁止 `assistant/message` 携带 `sourceEventSeqs`(已在真实校验器上验证)。做法是回退该回答及其后的内容,**追加**一条带改写文本的助手消息,再把回退掉的后续轮次**逐事件重放**在它后面——模型读到的是「前缀 + 改写后的回答 + 原来的后续轮次」,对话可以继续。`source.editedBy` 记录改写文字由插件写入,重放事件另带 `replayBy` / `replayId` / `originalSeq`。
31
32
  - **改过的那条还能再改** —— 它仍然显示在原来的位置(由插件渲染成气泡),悬停就有编辑按钮。
32
33
  - **一次点击即执行** —— 保存后不再有二次确认(`confirm: true` 可恢复两步确认,确认步里会写明这次保存会丢弃什么)。编辑器本身已经是用户主动打开的动作,保存的后果在 README 与确认步里说明,输入框本身保持干净。
33
34
  - **中英双语 UI**,跟随 DSH 当前语言。
@@ -57,9 +58,9 @@ dsh plugin --profile web add /path/to/dsh-edit-turn
57
58
  编辑器是**平台输入框那样的一个圆角盒子**:输入区在上、取消与主按钮在右下,聚焦时整圈描边高亮;**随文字长高**(空的时候不留空白),没有标题栏、没有常驻说明、没有拖拽手柄。键盘也和输入框一致:**Enter 保存、Shift+Enter 换行、Esc 取消**(确认步里 Esc 是退回上一步)。**输入法优先**:组合中的 Enter 与"候选选词的 Enter"(`isComposing` 或 `keyCode 229`,平台自己的输入框也是这两条一起挡)都不会被当成保存,组合期间也不重排输入框——此前少挡了 229 那半,拼音选词按回车会直接保存/关闭,看起来就是"打不了中文"。
58
59
 
59
60
  编辑器里,消息带的图片与文件**各显示成一个芯片**(缩略图或文件名 + 大小 + 删除按钮):不碰就原样保留,点删除就是不要它了;旁边还有一个「**添加附件**」(这条消息本来没有附件时也能用)。缩略图由本插件的只读路由按需取回;取不回来、或那些字节不是图片,芯片退回显示名字。**只有当这个部署没有附件存储时**,编辑器才会改回旧的那句话——「这条消息包含图片或文件附件,改写会丢弃它们,只保留文字」——并且不出现添加入口。
60
- **一字不改就按保存,什么都不会发生。** 编辑器预填的就是这条消息**现在**的文本,所以「打开编辑器、不输入、直接保存」等于没提出任何修改:宿主不写任何事件(不落替换、不开合成轮次、不追加回答),编辑器关闭并提示「内容没有变化,未做任何修改」。会话日志、轮次导轨、轨迹视图都保持原样——此前它照样落一条替换(一次会话里三条),改回答时还会多出一整个轮次(用户只发了两条消息,轨迹视图里却有四轮)。**「重跑」不受这条守卫影响**:文本没变但用户点的是「重跑」,宿主同样不写,重跑照常发起(用户的意图就是让这一轮重新生成)。
61
+ **一字不改就按保存,什么都不会发生。** 编辑器预填的就是这条消息**现在**的文本,所以「打开编辑器、不输入、直接保存」等于没提出任何修改:宿主不写任何事件(不落替换、不开合成轮次、不追加回答),编辑器照常关闭、**一条提示都不显示**(0.2.20 起;这条提示此前压在输入框的占位文字上)。会话日志、轮次导轨、轨迹视图都保持原样——此前它照样落一条替换(一次会话里三条),改回答时还会多出一整个轮次(用户只发了两条消息,轨迹视图里却有四轮)。**「重跑」不受这条守卫影响**:文本没变但用户点的是「重跑」,宿主同样不写,重跑照常发起(用户的意图就是让这一轮重新生成)。
61
62
 
62
- **编辑模型的回答**:悬停任意一条模型回复,同样会出现编辑入口。改完点「保存」——这条回答被替换为你写的内容,它之后的内容一并移除,模型从此把你写的内容当成自己说过的话,对话可以继续下去。
63
+ **编辑模型的回答**:悬停任意一条模型回复,同样会出现编辑入口。改完点「保存」——这条回答被替换为你写的内容;它后面的轮次先从上下文里回退,再逐事件重放回原位(新 turn 号、新 messageId、原内容),顺序与原来一致,模型从此把你写的内容当成自己说过的话,对话可以继续下去。
63
64
 
64
65
  注意:回答里的**工具调用与思考过程**无法保留(替换后只留文字,编辑器会提示),因为工具调用的结果已经不再成立。
65
66
 
@@ -82,7 +83,7 @@ dsh plugin --profile web add /path/to/dsh-edit-turn
82
83
  一次编辑就是**一条官方替换事件**。读事件流(`sessionQuery.readSession`,回退到活动会话的 `snapshotEvents()`),用官方折叠规则算出当前 surface 顺序,定位目标消息的节点,然后:
83
84
 
84
85
  - 提示词编辑:窗口 = **仅那一格**(`[target.seq]`),载体就是改后的文本;
85
- - 回复编辑:窗口 = `surface[目标下标 .. 末尾]`(回答变了,建立在它之上的一切都不再成立);先在日志里**开一个新轮次**(`turn/start` + `step/start`),载体是空 `developer/message`(空内容不投影给模型),随后把改写文本作为一条助手消息追加进这个轮次,再 `step/end` + `turn/end` 收尾。
86
+ - 回复编辑:窗口 = `surface[目标下标 .. 末尾]`(修正回答只能 append,所以窗口必须到末尾);先在日志里**开一个新轮次**(`turn/start` + `step/start`),载体是空 `developer/message`(空内容不投影给模型),随后把改写文本作为一条助手消息追加进这个轮次,再 `step/end` + `turn/end` 收尾;最后用 `buildReplayWrites` 把窗口里**后续轮次**的 surface 事件逐条重放回来(新 turn 号、新 messageId、`replayBy` / `replayId` / `originalSeq` 标记)。重放规则与 `dsh-rerun-turn` 共用一套:tool call 与 result 必须成对、没等到结果的调用连同它的广告一起丢、`TOOL_NOT_STARTED` 修复保持不带 `sourceEventSeqs`、system / developer / 静默载体 / 日志-only 记录一律跳过。
86
87
 
87
88
  追加一条:
88
89
 
@@ -94,11 +95,11 @@ session.append('developer/message', { turn, step, message: { role: 'developer',
94
95
  })
95
96
  ```
96
97
 
97
- 落盘后等一次持久化检查点(`sessions.flush`),保证重启也能看到。
98
+ 落盘后等一次持久化检查点(`sessions.flush`),保证重启也能看到。回复编辑的这条重放**不调模型**:它只是把已经记录的事件以新身份再 append 一遍,所以保存仍然是"一次点击、零模型调用"。
98
99
 
99
100
  **重跑不由本插件做。** "改完立刻重答"是姊妹插件 `dsh-rerun-turn` 的操作:它遮蔽该轮、用表面现在显示的提示词(也就是你改后的文本)重新生成,再把后续轮次逐事件重放回来。本插件只负责把编辑落地;编辑器里的「重跑」按钮仅在检测到它挂载时出现,点击后先保存、再调用它的 `POST /dsh-rerun-turn/apply`。它不在(例如桌面版没装)时,没有这个按钮。
100
101
 
101
- **文本没变就不写(0.2.18)。** 决定「写什么」的那一层在落任何事件之前,把请求里的文本与**当前活节点的文本**做**逐字**比较(`readMessageText`——`/state` 的 `turns[].text` / `replies[].text` 用的就是它,所以编辑器预填的文本与这里的比较对象天然同源)。相同则直接返回 `{ applied:false, unchanged:true, shadowed:[] }`:不落替换、不 flush、不开轮次、不动活循环的轮次计数器。比较**不 trim**:只有空白差异(哪怕多一个尾随空格)也算真修改,走原来的写入路径,行为与本版之前完全一致。`unchanged` 是给调用方的**标志**而不是拒绝——路由照常 200 返回,编辑器「重跑」的链式调用照常发起;所以这道守卫只拦得住「写」,拦不住用户要的重跑。
102
+ **文本没变就不写(0.2.18)。** 决定「写什么」的那一层在落任何事件之前,把请求里的文本与**当前活节点的文本**做**逐字**比较(`readMessageText`——`/state` 的 `turns[].text` / `replies[].text` 用的就是它,所以编辑器预填的文本与这里的比较对象天然同源)。相同则直接返回 `{ applied:false, unchanged:true, shadowed:[] }`:不落替换、不 flush、不开轮次、不动活循环的轮次计数器。比较**不 trim**:只有空白差异(哪怕多一个尾随空格)也算真修改,走原来的写入路径,行为与本版之前完全一致。`unchanged` 是给调用方的**标志**而不是拒绝——路由照常 200 返回,编辑器「重跑」的链式调用照常发起;所以这道守卫只拦得住「写」,拦不住用户要的重跑。客户端读到这个标志只做一件事:不把 no-op 当成 `applied:false` 的失败(否则会误报「已回退,但替换没有落地」);**它不显示任何提示**——0.2.20 删掉了那条「内容没有变化,未做任何修改。」,原样保存就是编辑器照常关闭、屏幕上不多一个字。
102
103
 
103
104
  **块随消息一起改(0.2.19)。** 载体不再写死成一个 text 块,而是照**提交的块列表**重建:列表里只有两种条目——`{ keep: <index> }`(原文里那一块,原样搬)与 `{ add: {...} }`(用户刚选的字节,录用后写进载体)。"原样搬"是 `structuredClone` 出来的原文块对象,**逐字节一致**;删掉的块就是**不在列表里**,因此不写;新文字落在原文**第一个文字块**的位置,其余块维持相对顺序(`planBlockLayout` / `assembleBlocks`)——真实日志里 `text → image` 与 `image → text` 两种顺序都常见,所以不重排。整个模块里**只有一处**块判断(`isTextBlock`:这个块里有文字吗),别的什么类型都不认识。录用走平台公开的附件服务:`ctx.get('attachments')`(`dsh-attachment` 的 `AttachmentStore`,`dsh-acp` 用的同一个 seam;另接受 `attachment` / `attachment-local` 两个包名拼写)的 `saveImage` / `saveFile`,**每次请求现场探测**;服务不在就退回只写文字,响应带 `dropped: true`,**一个引用都不会写进日志**。录用发生在任何 `session.append` 之前:被拒(超限、字节非法)时返回 400 `attachment-refused`,日志与轮次一个字节都不动。
104
105
 
@@ -132,14 +133,14 @@ session.append('developer/message', { turn, step, message: { role: 'developer',
132
133
 
133
134
  ### 验证状态
134
135
 
135
- 本插件在 DSH `0.1.6-alpha.2` 与 `0.1.7-rc.1` 上完成以下验证:
136
+ 本插件在 DSH `0.2.1-rc.2`(真实校验器与真实会话)以及 `0.1.6-alpha.2` / `0.1.7-rc.1` 上完成以下验证:
136
137
 
137
138
  | 验证 | 命令 | 结果 |
138
139
  |---|---|---|
139
140
  | 运行中的实例是否真的挂载了本插件(只读路由守卫探针,无需 token) | `npm run probe:loaded [端口]` | 通过:`/state` 返 400、`/apply` 返 405 —— 这两个状态码只有本插件会返回 |
140
141
  | 浏览器半部到底问了什么、拿回了什么(只读诊断,含失败记录) | `GET /dsh-edit-turn/debug` | 通过:真实浏览器里确认客户端确实拿到 replies;并复现了旧 v3 会话的 session-not-found |
141
- | 官方 append **与读取**契约(真实校验器,进程内) | `npm run verify:contract` | 80 项通过(每段写入后都用加载器重读整份日志):替换事件被接受、派生历史真的收缩、日志 append-only、工具结果与调用同进同退、空 developer 载体不产生模型消息、**连续两次回退都被接受**、**编辑模型回答的完整机制被接受**、**跨插件依赖的不变量(单节点窗口 / 同事件类型 / 全窗口 sourceEventSeqs / 每个替换都带 `editedBy` 标记 / 改写后的 `source.kind` 不是 `user` / 修正回复不带 `sourceEventSeqs`)** |
142
- | 纯逻辑 + 宿主集成 + 客户端 DOM 行为(真 HTTP、真校验器、桩服务、DOM 桩) | `npm test` | 182 项通过(含「文本没变 → 不写」的四条,以及块随消息一起改的 35 条:未动的块逐字节保留、删块少一块、文字位置不变、no-op 语义升级、附件服务缺失时降级且不写坏数据、自定义 `quote-card` 块的通用性、录用与拒绝、`/state` 的块描述与能力、附件字节路由、编辑器芯片/删除/添加/缩略图/降级警告) |
142
+ | 官方 append **与读取**契约(真实校验器,进程内) | `npm run verify:contract` | 85 项通过(每段写入后都用加载器重读整份日志):替换事件被接受、派生历史真的收缩、日志 append-only、工具结果与调用同进同退、空 developer 载体不产生模型消息、**连续两次回退都被接受**、**编辑模型回答的完整机制被接受**、**回复编辑后的尾部重放(后续提问/回答/tool 调用与结果按新 turn 号回到修正回答之后,顺序正确、日志仍可加载)**、**跨插件依赖的不变量(单节点窗口 / 同事件类型 / 全窗口 sourceEventSeqs / 每个替换都带 `editedBy` 标记 / 改写后的 `source.kind` 不是 `user` / 修正回复不带 `sourceEventSeqs`)** |
143
+ | 纯逻辑 + 宿主集成 + 客户端 DOM 行为(真 HTTP、真校验器、桩服务、DOM 桩) | `npm test` | 188 项通过(含回复编辑的尾部重放用例:窗口/重放区间、新 turn 号与新 messageId、tool 调用与结果成对重映射、上一轮修正可再次编辑,以及与 `dsh-rerun-turn` builder 的逐字段对照;另含「文本没变 → 不写」的四条,以及块随消息一起改的 35 条:未动的块逐字节保留、删块少一块、文字位置不变、no-op 语义升级、附件服务缺失时降级且不写坏数据、自定义 `quote-card` 块的通用性、录用与拒绝、`/state` 的块描述与能力、附件字节路由、编辑器芯片/删除/添加/缩略图/降级警告) |
143
144
  | 客户端半部静态检查(注册、i18n 完整性、样式、皮肤可读性、版本三处同步、线协议、块契约) | `npm run verify:client` | 184 项全部通过(含 12 条新的块契约检查:能力上报、服务现场探测、录用是唯一建块路径、录用先于 append、**两侧都不特判块类型**、块逐字节复制、文字归位、`dropped` 上报、只在无服务时警告、芯片靠 `error` 事件自证) |
144
145
  | 实机前端产物校验(运行中的 DSH 是否在下发当前代码) | `npm run verify:live -- --token-file ~/path/to/dsh.log` | 全部通过(含**宿主节点形状锚点**) |
145
146
  | **真实浏览器渲染冒烟**(CDP 驱动已开着的标签页,只读) | `npm run verify:ui` | 全部通过:下发的字节就是刚改的字节、无槽位崩溃留下的空占位、回复铅笔在平台动作条里、被回滚的行保住动作条、**被回滚的用户行一定显示替代文本(消息不会凭空消失)**、**替代气泡一定排在时间/复制之上(顺序与正常消息一致)**、**改写后那颗铅笔一定在还活着的动作条末尾(已在原始会话、切走的会话、切回后三处现场确认)**、**回复铅笔一定排在动作条最左(复制之前,已现场确认)**、气泡上没有 `.dshet-floating` 压着文字、切走再切回入口仍在 |
@@ -177,7 +178,7 @@ ln -s /path/to/dsh-install/node_modules ./node_modules
177
178
  - 编辑一条用户消息**只替换那一条**,**保存永不调用模型**;编辑一条模型回答只替换内容,同样不问模型。想立刻重答,用 `dsh-rerun-turn` 的「重跑」。
178
179
  - **提示词编辑只动那一条**:它下面的回复与之后的对话**原样保留**。**回复编辑**才会移除该回答及其后的内容——回答变了,建立在它之上的一切都不再成立。想保留原文形成分支,需要走 `sessionController.fork({ sessionId, atSeq })`,尚未实现。
179
180
  - **消息里的块随消息一起改,回答里的块不随**。用户消息的图片/文件(以及平台以后加的块类型)会原样保留、可单个删除、可新增(见「工作原理」);**模型回答里的工具调用与思考过程无法保留**——它们一旦离开原轮次就不再成立,所以回答编辑仍是纯文本,编辑器照旧提示。
180
- - **文本一字不差时保存不写任何记录**(0.2.18):编辑器预填的就是这条消息**现在**的文本,原样保存不会写入任何事件(不落替换、不开轮次),编辑器关闭并提示「内容没有变化」,日志与轨迹都保持原样;**「重跑」例外,它照常发起**。副作用:一条带**工具调用或思考过程**的模型回答,如果你一字不改地保存(本意只是想借替换把它们丢掉),本版不会再替换它,那些调用会留在上下文里——要丢掉它们,请把文本改成与原文不同的内容再保存。
181
+ - **文本一字不差时保存不写任何记录**(0.2.18):编辑器预填的就是这条消息**现在**的文本,原样保存不会写入任何事件(不落替换、不开轮次),编辑器照常关闭、**不弹任何提示**(0.2.20 起;此前那条「内容没有变化」就压在输入框的占位文字上),日志与轨迹都保持原样;**「重跑」例外,它照常发起**。副作用:一条带**工具调用或思考过程**的模型回答,如果你一字不改地保存(本意只是想借替换把它们丢掉),本版不会再替换它,那些调用会留在上下文里——要丢掉它们,请把文本改成与原文不同的内容再保存。
181
182
  - **会话必须当前在 DSH 中打开**,否则返回 `409 session-not-active`。
182
183
  - **进行中拒绝编辑**:未闭合的轮次或正在压缩时返回 `409 busy`。
183
184
  - **回退是持久的,隐藏不是**。回退写进日志后,模型上下文永久改变;转录里那些行的隐藏是本插件客户端半部做的。卸载插件后,旧行会重新显示出来(而模型上下文里的回退仍然生效)——因为替换事件是官方事件,不会随插件消失。
@@ -225,6 +226,28 @@ ln -s /path/to/dsh-install/node_modules ./node_modules
225
226
 
226
227
  ### 更新日志
227
228
 
229
+ **0.2.21** —— 回复编辑改成 infix splice:改一条中间的回答,后面的轮次不再被丢掉,而是重放回来(与 `dsh-rerun-turn` 同一套逻辑)。
230
+
231
+ - **为什么要改**:0.2.20 的回复编辑是「从这条回答到末尾」的整段回退——改第一条回答,第 2、3 轮就一起消失。所谓「任意位置编辑」此前只对用户消息成立;用户消息的改写本身就在原位,不需要重放。
232
+ - **改法**(宿主半侧):
233
+ 1. `planRollback` 的回复窗口仍到末尾(修正回答只能 append,尾部必须先离开 surface),但额外算出 `replayFrom`(目标轮之后的下一个 `turn/start` 括号)与 `logTo`(窗口最后一个轮的 `turn/end`);
234
+ 2. 新增 `buildReplayWrites`:把窗口里后续轮次的 surface 事件按新 turn 号、新 messageId 重放回去,并打 `replayBy` / `replayId` / `originalSeq` 标记。规则与 rerun 完全一致:tool call 与 result 成对、没等到结果的调用连同广告丢掉、`TOOL_NOT_STARTED` 修复保持不带 `sourceEventSeqs`、system/developer/静默载体与日志-only 记录一律跳过;`test/replay-parity.test.js` 把两个仓库的 builder 放在同一份日志上逐字段比对(sibling 不在时 skip);
235
+ 3. `applyEdit` 在「开轮 → 空载体替换窗口 → 追加修正回答 → 收尾」之后同步重放,并把活循环的轮次计数器推到重放出来的最高轮号;整批只在最后 flush 一次,避免留下「影子已持久化、修正却不在」的中间态;
236
+ 4. 派生顺序变成「前缀 + 改写后的回答 + 原来的后续轮次」,日志仍然 append-only。
237
+ - **测试**:`npm test` **189/189**、`npm run verify:contract` **85** 项、`npm run verify:client` **184** 项全绿;真实 `Session` 校验器上新增「改第一轮回答 → 后续轮次的提问、回答、tool call/result 全部重放回来且顺序正确、日志仍可加载」的用例;宿主集成测试改成断言重放标记与轮次号,并新增「改三段会话中间那一轮的回复,前后两轮都还在」;与 `dsh-rerun-turn` 的 `buildReplayWrites` 有逐字段对照测试。
238
+ - **真实会话验证**(只读、不写盘):在本机 4 个多轮会话上把整条 apply 路径跑在内存副本里——最多 11,013 个事件 / 130 轮,重放 687 条写入,全部被真实校验器接受、重新加载通过,改后的派生顺序是「前缀 + 改写回答 + 后续轮次」。
239
+ - **诚实说明**:重放是同步 append(不调模型,毫秒级),没有 rerun 那套跨分钟的后台恢复流程;如果中途某条写入被校验器拒绝,响应会带 `replay.error` 与已写入条数,已经落地的副本保留,不会回滚成「什么都没有」。
240
+
241
+ **0.2.20** —— 删掉「原样保存」时那条提示。只改提示,行为不变。
242
+
243
+ - **症状**:一字不改按保存,宿主本来就不写任何事件(0.2.18 的守卫),但客户端还会弹一条「内容没有变化,未做任何修改。」——而它在真机上是**压在输入框的占位文字上**渲染的(截图可见重叠)。这条提示既没有信息量(用户刚做的动作就是"什么都没做"),位置也不对。
244
+ - **改法**:`confirm()` 里那句 `if (!rerunIntent) this.notify('unchanged')` 删除。**`unchanged` 标志本身原样保留**——它唯一的职责是"不把 no-op 当成 `applied:false` 的失败"(否则会误报「已回退,但替换没有落地」):判断改为 `if (data.unchanged !== true) { …失败分支… }`(`lib/client.js:768`),语义与之前逐字等价。字典里已经没人引用的 `error.unchanged`(中英各一条)一并删除,**别的 `error.*` 未动**。
245
+ - **重跑链路一行未动**:`if (rerunIntent) this.startSiblingRerun(target.turn)`(`lib/client.js:783`)仍在那个 if **之外**。文本没变点「重跑」:宿主不写(它只是标志)、重跑照常发起——只是不再有横幅。宿主侧 `applyEdit` 也一字未改:no-op 仍返回 `{ applied:false, unchanged:true, shadowed:[] }`(`lib/index.js:1427`),`/dsh-edit-turn/debug` 仍如实记录 `unchanged`(`lib/index.js:1897`)。
246
+ - **原样保存,现在用户看到什么**:编辑器照常关闭,屏幕上**不多一个字**——没有横幅、没有浮动节点,消息行、铅笔、日志、轮次导轨、轨迹视图全部保持原样。
247
+ - **English**: the no-op guard still answers `unchanged: true`, but the browser half no longer announces it. The flag keeps its only job - telling a no-op apart from a failed apply, so the old "rolled back, but the replacement did not land" wording can never fire for it - while the notice it used to draw (on top of the composer's placeholder) is gone from the client and from both dictionaries. The re-run chain is untouched: an unchanged draft plus "Re-run" still writes nothing and re-runs the turn.
248
+ - 本版:`npm test` **182/182**、`npm run verify:contract` 80/80、`npm run verify:client` **184/184**(`npm run check` exit 0)。原来那条钉「原样保存会提示」的 DOM 用例改写为「**不出现任何提示**」——`notice === null`、组件树里没有 `dshet-notice`、DOM 里也没有;「文本没变但点重跑 → 重跑照常发起」那条一行未动,仍然绿。
249
+ - **负向验证**(`/tmp/dshet-noop-notice` 副本,仓库文件未动):把 `notify('unchanged')` 放回 `confirm()`(连同 0.2.20 之前那个 if/else 形状)→ **恰好 1 条**转红,正是改写后的那条「原样保存不弹任何提示」;「重跑照常发起」不受影响(放回的调用只在**没有**重跑意图时执行)。
250
+
228
251
  **0.2.19** —— 改写消息对所有内容块通用:图片、文件、以及平台以后加的块都随消息一起改。
229
252
 
230
253
  - **症状**:改写一条带图/带文件的消息,**非文字块必然丢**。`readMessageText` 只取 text 块(其余只累加 `attachments` 计数),`buildCarrier` 写死 `content: [{ type: 'text', text }]`,宿主里也没有拒绝路径——那个计数唯一的用途就是让客户端画一行「改写会丢弃它们」,等于把数据丢失写进了说明文案。
@@ -245,10 +268,10 @@ ln -s /path/to/dsh-install/node_modules ./node_modules
245
268
  - **守卫的位置**:`applyEdit`(宿主半部)里、算出回退窗口并确认目标仍在 surface 上之后、**任何 `session.append` 之前**。文本没变则直接返回 `{ kind, applied:false, unchanged:true, shadowed:[], original }`——不落替换、不 flush、不开轮次、不动活循环的 `lastTurn`。
246
269
  - **怎么比较**:`text === plan.original`,而 `plan.original` 就是 `readMessageText(target)` 的结果,也就是 `/state` 的 `turns[].text` / `replies[].text` 用的**同一份解析**(编辑器预填的正是它)。**逐字比较、不 trim**:只有空白差异(多一个尾随空格)仍算真修改,走原来的写入路径,行为与旧版**完全一致**——这是防回归的关键一条。
247
270
  - **两条路都走同一个守卫**:改提问(`user/message` 载体)不写替换;改回答(`developer/message` 空载体 + 合成轮次 + 新 `assistant/message`)既不写替换,也**不开轮次、不追加回答**。
248
- - **「重跑」不被守卫拦掉(关键陷阱)**:编辑器的「重跑」= 先保存、再链式调用 `POST /dsh-rerun-turn/apply`,所以守卫只能拦「写」。`unchanged` 是**标志**而非拒绝:路由照常 200,客户端读到它以后,`confirm()` 里那句 `if (rerunIntent) this.startSiblingRerun(target.turn)` 仍然执行(它与「写没写」无关,不被 `applied === false` 的报错分支吃到),只在**没有**重跑意图时才提示「内容没有变化」。
271
+ - **「重跑」不被守卫拦掉(关键陷阱)**:编辑器的「重跑」= 先保存、再链式调用 `POST /dsh-rerun-turn/apply`,所以守卫只能拦「写」。`unchanged` 是**标志**而非拒绝:路由照常 200,客户端读到它以后,`confirm()` 里那句 `if (rerunIntent) this.startSiblingRerun(target.turn)` 仍然执行(它与「写没写」无关,不被 `applied === false` 的报错分支吃到),只在**没有**重跑意图时才提示「内容没有变化」(该提示已在 0.2.20 删除)。
249
272
  - **客户端只做三件事**:不把 `unchanged` 当成 `applied:false` 的失败(旧文案会说「已回退,但替换没有落地」)、不隐藏任何行(`shadowed` 为空,消息留在屏上、铅笔还在)、照常把重跑交出去。
250
273
  - **English**: saving a draft that is verbatim the text the message already carries now writes NOTHING - no replacement event, no synthetic turn, no appended answer - and answers `unchanged:true`. The decision lives in the host, in the layer that decides what to write, and compares against the same parse the editor was filled with (`readMessageText`), verbatim and without trimming. The flag is not a refusal: the editor still starts the sibling re-run when that is what the user pressed.
251
- - 本版:`npm test` **147/147**、`npm run verify:contract` 80/80、`npm run verify:client` **163/163**(`npm run check` exit 0);新增 4 条宿主用例(改提问不写、改回答不写且不新增轮次、真改了仍逐字写入且随后同样的保存成为 no-op、no-op 不越过 busy/not-editable 等既有拒绝)与 2 条 DOM 用例(原样保存 → 提示且不隐藏行、不发重跑;文本没变但点「重跑」→ 不写但重跑照常发起)。
274
+ - 本版:`npm test` **147/147**、`npm run verify:contract` 80/80、`npm run verify:client` **163/163**(`npm run check` exit 0);新增 4 条宿主用例(改提问不写、改回答不写且不新增轮次、真改了仍逐字写入且随后同样的保存成为 no-op、no-op 不越过 busy/not-editable 等既有拒绝)与 2 条 DOM 用例(原样保存 → 提示且不隐藏行、不发重跑;文本没变但点「重跑」→ 不写但重跑照常发起)。**其中「原样保存 → 提示」这一条已在 0.2.20 改写为「原样保存 → 不出现任何提示」**,因为那条提示本身被删掉了。
252
275
  - **负向验证**(`/tmp` 副本,仓库文件未动):去掉宿主守卫 → **恰好 3 条**新用例转红(144/147);把客户端的 `data.unchanged === true` 改成 `false` → **恰好 2 条**新用例转红;把 `unchanged` 分支改成提前 return(即“在路由/客户端直接 return 掉整个 apply”那种错法)→ **恰好 1 条**转红,正是「文本没变但走重跑 → 重跑仍发起」。
253
276
 
254
277
  **0.2.17** —— 修「改写过的提示词,复制出来还是旧文本」。只修 Bug,交互语义不变。
@@ -405,7 +428,7 @@ This plugin adds it:
405
428
  - clicking it opens an in-place editor below that message, pre-filled with the original text;
406
429
  - saving **replaces that one message in place**: only the old wording leaves the model context, the revised text takes its place, and every later turn is answered against the new version;
407
430
  - **the reply under it and the whole conversation after it stay exactly as they were** - nothing is re-run and nothing is cleared;
408
- - model replies are editable too: replacing one rolls back that answer and everything built on it (a changed answer invalidates what followed) and appends your text as a fresh reply;
431
+ - model replies are editable too: the corrected answer is appended as a fresh reply and **everything that followed is replayed back behind it** - event by event, with fresh turn numbers and message ids - so a reply edit never drops the rest of the conversation;
409
432
  - with the sister plugin **dsh-rerun-turn** installed, the editor also offers a "**Re-run**" button: it saves your change first, then has that plugin regenerate the turn from the revised prompt (replaying what followed, so nothing after it is lost).
410
433
 
411
434
  ### Features
@@ -413,11 +436,11 @@ This plugin adds it:
413
436
  - **Official seam, log untouched.** A rollback appends one replacement event carrying `surfaceOp: { op: 'replace', startSeq, endSeq }`. Every original event stays in the session file; it simply stops entering `deriveMessages()`. This is the same contract `/compact` uses.
414
437
  - **The revision is the carrier.** A prompt edit lands a `user/message` replacement whose content **is** your revised text: it updates the model context and shows in the transcript in place of the old wording. The platform does not answer it as a new input (that was tried: *appending* a user message makes the platform reply, and the next save added another copy). Reply edits stay silent too: their carrier is an empty `developer/message` - the format admits only system-prompt sources on `system/message`, and an empty plugin-owned system node is exactly what used to make sessions unreadable (see below).
415
438
  - **A revised message carries every block it carried (0.2.19).** A block the user did not touch is copied verbatim (a structured clone of the original block object: attachment ids, names, sizes and every field a later platform adds), a block they removed is not written, and a newly picked payload is admitted through the platform's attachment store before anything is appended. Nothing in the module names a block type - the only block test is "does this block carry text" - so a block kind the platform adds later works with no change here. The editor draws one chip per non-text block (a thumbnail when its bytes are reachable, otherwise its name and size, plus a remove button) and offers an "Add an attachment" entry; with no store mounted it falls back to the old warning and no picker.
416
- - **The smallest window that is correct.** A prompt edit shadows exactly one node (`shadowed = [target.seq]`); a reply edit shadows the reply through the end of the surface - a changed answer invalidates everything built on it - which also keeps an assistant message (carrying its own tool_use blocks) and the tool/result it produced together. A dangling call/result pair is impossible.
439
+ - **The smallest window that is correct, with a faithful replay.** A prompt edit shadows exactly one node (`shadowed = [target.seq]`); a reply edit shadows the reply through the end of the surface (a correction can only be appended, so the tail must leave first), and then replays every later turn back behind the correction with fresh turn numbers, fresh message ids and a `replayBy` / `replayId` / `originalSeq` marker. The walk is the same one `dsh-rerun-turn` performs after a rerun, and a parity test compares the two builders write for write. An assistant message (carrying its own tool_use blocks) and the tool/result it produced always leave together, so a dangling call/result pair is impossible.
417
440
  - **A rewritten message stays editable.** It still shows where the original stood, and hovering it offers the pencil again.
418
441
  - **Saving never calls the model.** It only writes the context. Regenerating a turn after the edit is a different operation, owned by the sister plugin **dsh-rerun-turn** (it shadows the turn, regenerates from the prompt the surface now shows, and replays the turns that followed); this plugin only forwards to it, through the "Re-run" button, when it is mounted.
419
- - **A save that changes nothing writes nothing** (0.2.18). The layer that decides what to write compares the draft with the live text **verbatim**, through the same parse the editor was prefilled from (`readMessageText`, the one behind `/state`’s `turns[].text`), and answers `unchanged: true` before appending anything: no replacement event, no synthetic turn, no appended answer, no loop-counter sync. The flag is not a refusal - the route still answers 200, and the editor still starts the re-run when that was the button the user pressed.
420
- - **The model's replies are editable too.** A reply cannot be swapped in place: the format refuses `sourceEventSeqs` on an `assistant/message` (verified against the real validator). The answer is rolled back together with everything after it, and the corrected text is **appended** as a fresh reply - the model goes on treating it as its own. `source.editedBy` records honestly that the plugin wrote those words.
442
+ - **A save that changes nothing writes nothing** (0.2.18). The layer that decides what to write compares the draft with the live text **verbatim**, through the same parse the editor was prefilled from (`readMessageText`, the one behind `/state`’s `turns[].text`), and answers `unchanged: true` before appending anything: no replacement event, no synthetic turn, no appended answer, no loop-counter sync. The flag is not a refusal - the route still answers 200, and the editor still starts the re-run when that was the button the user pressed. Nothing is announced for it either (0.2.20 removed the notice this used to draw): the editor closes like any save, and the screen is exactly as it was.
443
+ - **The model's replies are editable too.** A reply cannot be swapped in place: the format refuses `sourceEventSeqs` on an `assistant/message` (verified against the real validator). The correction is **appended** as a fresh reply - the model goes on treating it as its own - and the turns the rollback took leave the surface only to be **replayed** back behind it, in order, as fresh copies. `source.editedBy` records honestly that the plugin wrote those words; each replay event carries `replayBy`, `replayId` and `originalSeq`.
421
444
  - **One click applies** - no second confirmation by default. The editor is already an explicit action the user opened; with `confirm: true` the confirmation step states what this save will discard, while the input box itself stays clean.
422
445
  - **Bilingual UI** that follows the current DSH locale.
423
446
  - **Skin-friendly** - the editor paints its own opaque surface (`--dshet-panel`) instead of borrowing the theme's surface colours. A skin exists to make surfaces translucent so its artwork shows through, and it only compensates for *its own* elements; a plugin's class names are not on that list, so a panel that borrows those variables can end up fully transparent with text sitting straight on the art. The dark branch uses the official `body[data-ds-dark-theme]` hook (the same one `dsh-client-ui-theme` and several official UI packages use) and blends in with `backdrop-filter`.
@@ -463,8 +486,9 @@ attachments; rewriting keeps the text only" - and no picker at all.
463
486
  **Saving a draft that is word-for-word what the message already says does
464
487
  nothing at all.** The editor was prefilled with that text, so an untouched save
465
488
  is not a request to change anything: the host writes no event - no replacement,
466
- no synthetic turn, no fresh answer - closes the editor and says
467
- "nothing changed". The log, the turn rail and the Trajectory view stay exactly
489
+ no synthetic turn, no fresh answer - and the editor closes in silence, with no
490
+ banner at all (0.2.20; that notice used to be drawn on top of the composer's
491
+ placeholder text). The log, the turn rail and the Trajectory view stay exactly
468
492
  as they were. **"Re-run" is not affected**: an unchanged draft plus that button
469
493
  still re-runs the turn, because regenerating it is what the user asked for.
470
494
 
@@ -505,7 +529,7 @@ node, then:
505
529
  the corrected text is appended as an assistant message inside that turn, and
506
530
  `step/end` + `turn/end` close it behind the correction.
507
531
 
508
- **An unchanged draft is not written at all** (0.2.18): the layer that decides what to write compares the request’s text with the live node’s text - through the same `readMessageText` the editor was prefilled from - and returns `{ applied: false, unchanged: true, shadowed: [] }` **before** appending anything: no replacement, no flush, no turn, no loop-counter sync. The comparison is verbatim and does not trim, so any real difference (a trailing space included) takes the old path unchanged. `unchanged` is a flag for the caller, never a refusal: the route still answers 200, and the editor’s "Re-run" chain still starts (its whole point is to regenerate the turn even when the wording is what it already was).
532
+ **An unchanged draft is not written at all** (0.2.18): the layer that decides what to write compares the request’s text with the live node’s text - through the same `readMessageText` the editor was prefilled from - and returns `{ applied: false, unchanged: true, shadowed: [] }` **before** appending anything: no replacement, no flush, no turn, no loop-counter sync. The comparison is verbatim and does not trim, so any real difference (a trailing space included) takes the old path unchanged. `unchanged` is a flag for the caller, never a refusal: the route still answers 200, and the editor’s "Re-run" chain still starts (its whole point is to regenerate the turn even when the wording is what it already was). The browser half reads that flag only to keep a no-op out of the failed-apply wording, and draws nothing for it (0.2.20).
509
533
 
510
534
  Then append:
511
535
 
@@ -568,7 +592,7 @@ The transcript only builds rows for **append surface events** (ui-chat's user an
568
592
  2. **The replacement event has the type of the target**: a `user/message` edit lands a `user/message` (a reply cannot be swapped in place — see 5);
569
593
  3. **`sourceEventSeqs` always lists the whole window**: the official validator demands complete coverage; one missing node and the append is refused;
570
594
  4. **Every replacement carries the semantic marker**: `source.editedBy === 'dsh-edit-turn'` — including the empty text-free `developer/message` carrier, so siblings can recognise rewrites by `editedBy` alone instead of inferring from the window shape;
571
- 5. **A reply edit is a multi-node rollback plus an appended correction**, not an in-place swap: the old reply row keeping no entry is **by design** (the new row carries it). Do not add `sourceEventSeqs` to an `assistant/message` to keep the old row's entry — the official validator refuses it outright;
595
+ 5. **A reply edit is a multi-node rollback, an appended correction, and a replay of the tail**, not an in-place swap: the old reply row keeping no entry is **by design** (the new row carries it), and everything after it is re-appended with fresh identities and a replay marker. Do not add `sourceEventSeqs` to an `assistant/message` to keep the old row's entry — the official validator refuses it outright;
572
596
  6. **The block list is an optional, forward-compatible parameter**: `POST /apply` accepts `parts` (`[{ keep } | { add }]`), and **omitting it is exactly the 0.2.18 behaviour** (a text-only carrier), with the response's `dropped` stating whether that save left non-text blocks behind. `GET /state` answers `capabilities: { attachments, preview }` for what this deployment can admit and read, and `turns[].blocks` describes each block as `{ index, type, name?, mediaType?, bytes?, width?, height?, preview }` - read by field presence, never by type, so a sibling can do the same and a block kind the platform adds later needs no change anywhere;
573
597
  7. **The mapping is published**: `revisions[]` on `GET /dsh-edit-turn/state` gives `{ replacementSeq, startSeq, endSeq, shadowed }` (the field names the `POST /apply` response already used), and `hidden[]` lists `{ seq, turn, replacement }` per shadowed row. No sibling has to re-derive the ledger from the event stream.
574
598
 
@@ -811,7 +835,7 @@ ln -s /path/to/dsh-install/node_modules ./node_modules
811
835
  - **Editing a user message replaces that one message and leaves the rest of the conversation untouched; saving never calls the model.** For an immediate answer, use `dsh-rerun-turn`'s "Re-run"; editing a model reply only replaces the text, too.
812
836
  - **A prompt edit touches only that message.** The reply under it and the whole conversation after it are kept; saving never calls the model. A **reply edit** removes that answer and everything built on it, because a changed answer invalidates what followed. Keeping the original as a branch needs `sessionController.fork({ sessionId, atSeq })`, which is not implemented.
813
837
  - **Blocks travel with a user message, not with a reply.** The images and files on your own message are kept as they are, can be removed one by one and new ones can be added (see "How it works"); the **tool calls and reasoning inside a model reply cannot survive** - they stop being true the moment the reply is replaced - so a reply edit is still text only, and the editor says so.
814
- - **A draft identical to the live text writes nothing** (0.2.18): saving it as-is closes the editor with a "nothing changed" notice and leaves both the log and the rail alone; "Re-run" is the exception and still starts. The consequence worth knowing: a reply edit that is *textually* identical - the one case where you might have wanted the save only to drop that reply’s tool calls or reasoning - is now a no-op, and those calls stay in the context.
838
+ - **A draft identical to the live text writes nothing** (0.2.18): saving it as-is closes the editor in silence - no notice at all since 0.2.20, before that a "nothing changed" banner that landed on the composer's placeholder - and leaves both the log and the rail alone; "Re-run" is the exception and still starts. The consequence worth knowing: a reply edit that is *textually* identical - the one case where you might have wanted the save only to drop that reply’s tool calls or reasoning - is now a no-op, and those calls stay in the context.
815
839
  - **The session must be open in DSH**, otherwise the route answers `409 session-not-active`.
816
840
  - **Running work is refused**: an unclosed turn or an in-flight compaction answers `409 busy`.
817
841
  - **The rollback is durable; the hiding is not.** Once written, the rollback permanently changes the model context. Hiding those rows in the transcript is this plugin's browser half. Uninstall the plugin and the old rows reappear - while the rollback in the model context still stands, because the replacement is an official event that outlives the plugin.
package/lib/client.js CHANGED
@@ -42,7 +42,7 @@ window.__ModuleLoader__.load({
42
42
  const RERUN_PREFIX = '/dsh-rerun-turn'
43
43
 
44
44
  /** Keep in sync with package.json and lib/index.js. */
45
- const PLUGIN_VERSION = '0.2.19'
45
+ const PLUGIN_VERSION = '0.2.21'
46
46
 
47
47
  // --- copy -----------------------------------------------------------------
48
48
 
@@ -59,7 +59,7 @@ window.__ModuleLoader__.load({
59
59
  'action.copied': '复制成功',
60
60
  'editor.title': '编辑这条消息',
61
61
  'editor.title-reply': '编辑这条回答',
62
- 'editor.placeholder': '改成你希望自己当时说的内容。保存后这条消息之后的轮次会从模型上下文中移除,改后的内容成为新的起点。',
62
+ 'editor.placeholder': '改成你希望自己当时说的内容。保存后就地替换这条消息,后面的对话保持不变。',
63
63
  'editor.placeholder-reply': '改成你希望模型说过的内容,保存后它就成为这条回答。',
64
64
  'editor.save': '保存',
65
65
  'editor.rerun': '重跑',
@@ -71,7 +71,7 @@ window.__ModuleLoader__.load({
71
71
  'editor.warn-reply': '这条回答包含工具调用或思考过程,替换后它们会被一并移除。',
72
72
  'editor.attach.add': '添加附件',
73
73
  'editor.attach.remove': '移除这个附件',
74
- 'confirm.body': '确认后会立即执行:这条消息之后的内容会从模型上下文中移除。原始会话日志不会改写。',
74
+ 'confirm.body': '确认后会立即执行:只有这条消息被替换,后面的对话保持不变。原始会话日志不会改写。',
75
75
  'error.invalid': '请求无效,请刷新后重试。',
76
76
  'error.session-not-active': '这个会话当前未激活,请先打开该会话再编辑。',
77
77
  'error.session-not-found': '找不到该会话的日志。',
@@ -85,7 +85,6 @@ window.__ModuleLoader__.load({
85
85
  'error.prompt': '已回退,但改后的内容没能写进上下文。请重试。',
86
86
  'error.reply': '已回退,但替换没有落地。请重试。',
87
87
  'error.empty': '改写后的内容不能为空。',
88
- 'error.unchanged': '内容没有变化,未做任何修改。',
89
88
  'error.rerun-nothing': '已保存,但这一轮没有可重跑的作答。',
90
89
  'error.rerun-unavailable': '已保存,但重跑没有开始,请稍后重试。',
91
90
  'error.not-rerunnable': '这一轮没有可重跑的作答。',
@@ -97,6 +96,7 @@ window.__ModuleLoader__.load({
97
96
  'error.attachment-unreadable': '这个附件读不出来,请换一个文件。',
98
97
  'error.attachment-too-large': '这个文件太大,无法作为附件。',
99
98
  'error.too-large': '这次修改太大,服务器没有接收。',
99
+ 'error.replay-partial': '已保存并改写了这条回答,但后面的轮次没能全部重放回来,请重新打开会话确认。',
100
100
  }
101
101
 
102
102
  const en = {
@@ -107,7 +107,7 @@ window.__ModuleLoader__.load({
107
107
  'action.copied': 'Copied',
108
108
  'editor.title': 'Edit this message',
109
109
  'editor.title-reply': 'Edit this reply',
110
- 'editor.placeholder': 'Saving rolls the conversation back to just before this message and this turn runs again.',
110
+ 'editor.placeholder': 'Write what you want to have said. Saving replaces this message in place; the conversation after it stays as it is.',
111
111
  'editor.placeholder-reply': 'Write what you want the model to have said; saving makes it this reply.',
112
112
  'editor.save': 'Save',
113
113
  'editor.rerun': 'Re-run',
@@ -119,7 +119,7 @@ window.__ModuleLoader__.load({
119
119
  'editor.warn-reply': 'This reply carries tool calls or reasoning; replacing it removes them.',
120
120
  'editor.attach.add': 'Add an attachment',
121
121
  'editor.attach.remove': 'Remove this attachment',
122
- 'confirm.body': 'Confirming applies right away: everything after this message leaves the model context. The original session log is never rewritten.',
122
+ 'confirm.body': 'Confirming applies right away: only this message is replaced; the conversation after it stays as it is. The original session log is never rewritten.',
123
123
  'error.invalid': 'Invalid request; refresh and try again.',
124
124
  'error.session-not-active': 'This session is not open in DSH; open it first.',
125
125
  'error.session-not-found': 'No session log was found for this id.',
@@ -133,7 +133,6 @@ window.__ModuleLoader__.load({
133
133
  'error.prompt': 'Rolled back, but the re-run did not start. Send the revised text manually.',
134
134
  'error.reply': 'Rolled back, but the replacement did not land. Try again.',
135
135
  'error.empty': 'The revised message cannot be empty.',
136
- 'error.unchanged': 'Nothing changed, so no edit was made.',
137
136
  'error.rerun-nothing': 'Saved, but this turn has no reply that can be re-run.',
138
137
  'error.rerun-unavailable': 'Saved, but the re-run did not start; try again.',
139
138
  'error.not-rerunnable': 'This turn has no reply that can be re-run.',
@@ -145,6 +144,7 @@ window.__ModuleLoader__.load({
145
144
  'error.attachment-unreadable': 'That attachment could not be read; try another file.',
146
145
  'error.attachment-too-large': 'That file is too large to attach.',
147
146
  'error.too-large': 'That revision was too large for the host to accept.',
147
+ 'error.replay-partial': 'Saved and corrected, but the turns after it could not all be replayed; reopen the session to check.',
148
148
  }
149
149
 
150
150
  // Every error code the host can return travels through here. A code is
@@ -748,10 +748,18 @@ window.__ModuleLoader__.load({
748
748
  failure: null,
749
749
  })
750
750
  // `unchanged` means the host wrote NOTHING: the draft is (verbatim)
751
- // the text the message already carries. There is no failure to report -
752
- // the edit was simply not an edit - so it is noticed as such, and
753
- // nothing else about this pass changes: no row was shadowed, so none is
754
- // hidden, and the message stays on screen with the pencil it had.
751
+ // the text the message already carries. The flag is read here for one
752
+ // reason, and it is not the user's: a no-op is not a failure. The
753
+ // `applied === false` wording below would claim the rollback landed
754
+ // and the replacement did not, which is a lie about an edit that was
755
+ // never attempted.
756
+ //
757
+ // Nothing is announced for it, deliberately (0.2.20 removed the
758
+ // notice this used to draw): the edit was simply not an edit, so the
759
+ // editor closes like any save and the screen is exactly as it was -
760
+ // no row was shadowed, so none is hidden, and the message keeps the
761
+ // pencil it had. (The banner also landed on top of the composer's
762
+ // placeholder text, which is not a place to report anything from.)
755
763
  //
756
764
  // The re-run intent survives it. "Re-run" saves first only because the
757
765
  // sibling regenerates the turn from the prompt the surface shows; a
@@ -759,9 +767,7 @@ window.__ModuleLoader__.load({
759
767
  // wording moved, so the chain below is NOT gated on a write having
760
768
  // happened. (The decision itself is the host's - the text is parsed
761
769
  // there, once - and this half only reads the flag it sends back.)
762
- if (data.unchanged === true) {
763
- if (!rerunIntent) this.notify('unchanged')
764
- } else {
770
+ if (data.unchanged !== true) {
765
771
  // The rollback has landed, so the editor is gone with its row: a
766
772
  // failed second half has to be reported somewhere that survives that.
767
773
  if (data.kind === 'reply' && data.applied === false) this.notify('reply')
@@ -771,6 +777,11 @@ window.__ModuleLoader__.load({
771
777
  // the only place the user can still be told (see applyEdit's
772
778
  // `dropped`).
773
779
  if (data.kind !== 'reply' && data.applied === true && data.dropped === true) this.notify('attachments-dropped')
780
+ // The correction landed, but replaying the turns behind it did not:
781
+ // the editor is already gone, so this is the only place left to say
782
+ // that the tail is missing from the context instead of silently
783
+ // hiding rows the user still expects.
784
+ if (data.applied === true && data.replay && data.replay.error) this.notify('replay-partial')
774
785
  }
775
786
  // Save first, re-run second - always: the sibling re-runs the turn
776
787
  // from the prompt the surface NOW shows, so the revised wording has
package/lib/index.js CHANGED
@@ -69,7 +69,7 @@ export const PLUGIN_ID = 'dsh-edit-turn'
69
69
  export const name = PLUGIN_ID
70
70
 
71
71
  /** Keep in sync with package.json and lib/client.js. */
72
- export const PLUGIN_VERSION = '0.2.19'
72
+ export const PLUGIN_VERSION = '0.2.21'
73
73
 
74
74
  // A ring of the last few requests, answering "did the browser half even ask, and
75
75
  // what did it get?" without needing its console. Read-only diagnostics: the
@@ -237,6 +237,44 @@ export function turnIndex(events) {
237
237
  return turnOf
238
238
  }
239
239
 
240
+ /**
241
+ * One turn bracket: its number, log range, first prompt and reply count.
242
+ *
243
+ * A turn NUMBER is not unique in a log - a replay reuses numbers on fresh
244
+ * brackets - so planning must locate the bracket that CONTAINS an event rather
245
+ * than the first bracket carrying its number (the same rule dsh-rerun-turn
246
+ * learned in 0.1.23; this is the same walk).
247
+ *
248
+ * @param events - complete contiguous raw event log.
249
+ * @returns one entry per `turn/start`, in log order.
250
+ */
251
+ export function turnSpans(events) {
252
+ const spans = []
253
+ let open = null
254
+ for (const event of events) {
255
+ if (event.type === 'turn/start') {
256
+ open = {
257
+ turn: event.data && event.data.turn,
258
+ startSeq: event.seq,
259
+ endSeq: null,
260
+ promptSeq: null,
261
+ replies: 0,
262
+ }
263
+ spans.push(open)
264
+ continue
265
+ }
266
+ if (open === null) continue
267
+ if (event.type === 'turn/end') {
268
+ open.endSeq = event.seq
269
+ open = null
270
+ continue
271
+ }
272
+ if (event.type === 'user/message' && open.promptSeq === null) open.promptSeq = event.seq
273
+ if (event.type === 'assistant/message') open.replies += 1
274
+ }
275
+ return spans
276
+ }
277
+
240
278
  /** The turn still awaiting its `turn/end`, or null when every turn closed. */
241
279
  export function openTurn(events) {
242
280
  let open = null
@@ -710,18 +748,24 @@ function resolveTarget(events, request) {
710
748
  }
711
749
 
712
750
  /**
713
- * Turn one UI target into the canonical rollback window.
751
+ * Turn one UI target into the canonical rollback window plus, for a reply, the
752
+ * replay range that puts the later turns back after the correction.
714
753
  *
715
- * The window is `[the addressed prompt .. the last surface node]`: the complete
716
- * remainder of the conversation in surface order. It is contiguous by
717
- * construction, it always ends on a completed turn's last node, and its left
718
- * edge is exactly the prompt being rewritten, so the surviving prefix ends on
719
- * the previous turn's boundary.
754
+ * A revised PROMPT is replaced in place: its window is the message alone, so
755
+ * the reply under it and everything after stay exactly where they were.
756
+ *
757
+ * A revised REPLY cannot be swapped in place - the format refuses
758
+ * `sourceEventSeqs` on an `assistant/message`, so a new reply can only be
759
+ * APPENDED. The window therefore runs from the addressed reply to the last
760
+ * surface node, and the correction is appended in a fresh turn. Everything the
761
+ * window took is replayed back behind the correction by
762
+ * {@link buildReplayWrites}: the same infix splice dsh-rerun-turn performs, with
763
+ * the user's text instead of a regenerated answer.
720
764
  *
721
765
  * @param events - complete contiguous raw event log.
722
766
  * @param surfaceNodes - current surface seqs in model order.
723
767
  * @param request - `{ seq?, messageId?, turn? }` (`turn` addresses a prompt only).
724
- * @returns `{ targetSeq, messageId, startSeq, endSeq, shadowed, mode, turn, step, provider, model, original, attachments }`.
768
+ * @returns `{ targetSeq, messageId, startSeq, endSeq, shadowed, mode, turn, step, provider, model, original, attachments, replayFrom, logTo, turnStart, turnEnd }`.
725
769
  * @throws {EditPlanError} with a stable code when the target cannot be planned.
726
770
  */
727
771
  export function planRollback(events, surfaceNodes, request) {
@@ -737,7 +781,8 @@ export function planRollback(events, surfaceNodes, request) {
737
781
  throw new EditPlanError('not-editable', 'the system prompt head cannot be edited')
738
782
  }
739
783
  // Two editable kinds. A prompt is rewritten in place and the conversation
740
- // continues; a reply is replaced and everything built on it goes with it.
784
+ // continues; a reply is replaced by an appended correction and the tail is
785
+ // replayed behind it.
741
786
  const mode = isHumanPrompt(target) ? 'prompt' : isAssistantReply(target) ? 'reply' : null
742
787
  if (mode === null) {
743
788
  throw new EditPlanError('not-editable', 'only your own messages and the model replies can be edited')
@@ -746,14 +791,54 @@ export function planRollback(events, surfaceNodes, request) {
746
791
  // Two different windows, because the two edits mean different things.
747
792
  //
748
793
  // A revised PROMPT is replaced in place: only the message itself leaves the
749
- // surface, so the reply under it and everything after stay exactly as they
750
- // were - edit the wording, keep the conversation. A revised REPLY cannot do
751
- // that (an answer invalidates whatever was built on it), so its window runs to
752
- // the tail and the turn is closed again behind the correction.
794
+ // surface, so the reply under it and everything after stay exactly where they
795
+ // were - edit the wording, keep the conversation.
796
+ //
797
+ // A revised REPLY cannot be a replacement carrier (the format refuses
798
+ // `sourceEventSeqs` on an assistant message), so the correction is appended
799
+ // at the tail and everything from the addressed reply to the end must leave
800
+ // the surface first. `buildReplayWrites` then puts every later turn back,
801
+ // event by event, with fresh turn numbers and message ids - the model reads
802
+ // prefix + corrected reply + the same later turns, in order.
753
803
  const shadowed = mode === 'prompt' ? [target.seq] : surfaceNodes.slice(startIdx)
754
804
  if (shadowed.length === 0 || shadowed[0] !== target.seq) {
755
805
  throw new EditPlanError('not-editable', 'the target does not open a rollback window')
756
806
  }
807
+ // The replay range: the log range the shadow covers, and the bracket where
808
+ // the later turns start. A reply in the last turn has neither, and the edit
809
+ // degenerates to the old tail-less rollback (there is nothing to replay).
810
+ let replayFrom = null
811
+ let logTo = null
812
+ let turnStart = null
813
+ let turnEnd = null
814
+ if (mode === 'reply') {
815
+ const spans = turnSpans(events)
816
+ // A closed bracket that CONTAINS the reply. The fallback (the last bracket
817
+ // that starts before it) only matters for a malformed log with a bracket
818
+ // that never closed; a valid log always takes the containment branch.
819
+ const owning = spans.find(
820
+ (entry) => entry.endSeq !== null && entry.startSeq <= target.seq && target.seq <= entry.endSeq,
821
+ ) ?? spans.filter((entry) => entry.startSeq <= target.seq).at(-1)
822
+ if (owning !== undefined) {
823
+ turnStart = owning.startSeq
824
+ turnEnd = owning.endSeq
825
+ }
826
+ const lastShadowed = shadowed[shadowed.length - 1]
827
+ const bySeq = new Map(events.map((event) => [event.seq, event]))
828
+ logTo = lastShadowed
829
+ for (let seq = lastShadowed + 1; ; seq += 1) {
830
+ const event = bySeq.get(seq)
831
+ if (event === undefined) break
832
+ // The window already ends on the last surface node: only log-only events
833
+ // (step/turn closing records) can follow it.
834
+ if (isSurfaceEvent(event)) break
835
+ logTo = seq
836
+ if (event.type === 'turn/end') break
837
+ }
838
+ const owningIndex = owning === undefined ? -1 : spans.indexOf(owning)
839
+ const nextSpan = owningIndex >= 0 ? spans[owningIndex + 1] : undefined
840
+ if (nextSpan !== undefined && nextSpan.startSeq <= logTo) replayFrom = nextSpan.startSeq
841
+ }
757
842
  const { text, attachments } = readMessageText(target)
758
843
  // The blocks themselves travel with the plan: the carrier is rebuilt from
759
844
  // them, so no caller has to re-read the message to know what it carries.
@@ -778,7 +863,293 @@ export function planRollback(events, surfaceNodes, request) {
778
863
  original: text,
779
864
  attachments,
780
865
  blocks,
866
+ // Reply-only replay range. `replayFrom` is the next turn's bracket (null when
867
+ // the edited reply is in the last turn); `logTo` is the end of the last turn
868
+ // the window shadowed. Both are computed from the ORIGINAL log and stay valid
869
+ // while the correction is appended after it.
870
+ replayFrom,
871
+ logTo,
872
+ turnStart,
873
+ turnEnd,
874
+ }
875
+ }
876
+
877
+ // --- reply replay -----------------------------------------------------------
878
+ //
879
+ // A reply edit lands exactly like a rerun's infix splice: the shadow takes the
880
+ // old answer and every later turn off the surface, the correction is appended in
881
+ // a fresh turn, and this walk puts the later turns back as fresh copies. The
882
+ // algorithm is the one dsh-rerun-turn ships (`buildReplayWrites` there); keep the
883
+ // two in step - `test/replay-parity.test.js` compares their output on one log
884
+ // when the sibling repository is checked out.
885
+
886
+ /** Marker written into every replayed event's source. */
887
+ export const REPLAY_MARKER = PLUGIN_ID
888
+
889
+ /** Text that carries nothing a model could read (spaces, zero-width glue). */
890
+ const INVISIBLE_TEXT_RE = /^[\s\u200b-\u200f\u2060\ufeff]*$/
891
+
892
+ /**
893
+ * Whether content blocks carry anything readable.
894
+ * @param content - the `content` array of a message.
895
+ * @returns true when at least one block is not invisible text.
896
+ */
897
+ export function hasReadableContent(content) {
898
+ if (!Array.isArray(content)) return false
899
+ for (const block of content) {
900
+ if (!block || typeof block !== 'object') continue
901
+ if (block.type !== 'text') return true
902
+ if (typeof block.text !== 'string' || !INVISIBLE_TEXT_RE.test(block.text)) return true
903
+ }
904
+ return false
905
+ }
906
+
907
+ /**
908
+ * Whether an event is a plugin's silent surface carrier rather than conversation.
909
+ *
910
+ * `dsh-delete-turn` writes one for a deletion and `dsh-rerun-turn` for a rerun's
911
+ * shadow - a `user/message` whose source kind is `plugin:<id>` and whose content
912
+ * is empty or invisible. A replay must retire it without a copy: it projects to
913
+ * no model message, so copying it would resurrect an empty user node.
914
+ *
915
+ * A rewritten prompt is NOT this shape: `dsh-edit-turn` keeps `kind: 'user'` and
916
+ * carries the user's wording, so it stays a real prompt and is copied.
917
+ *
918
+ * @param event - raw session event.
919
+ * @returns true for a text-free plugin replacement.
920
+ */
921
+ export function isSilentPluginCarrier(event) {
922
+ if (!event || event.type !== 'user/message') return false
923
+ const data = event.data || {}
924
+ const source = data.source
925
+ if (!source || typeof source.kind !== 'string' || !source.kind.startsWith('plugin:')) return false
926
+ return !hasReadableContent(data.content)
927
+ }
928
+
929
+ /** Stamp one copied event's source with this plugin's replay markers. */
930
+ function replaySource(source, replayId, originalSeq) {
931
+ return {
932
+ ...(source && typeof source === 'object' ? source : {}),
933
+ replayBy: REPLAY_MARKER,
934
+ replayId,
935
+ originalSeq,
936
+ }
937
+ }
938
+
939
+ /**
940
+ * Build the replay writes for a reply edit's shadowed tail.
941
+ *
942
+ * Every shadowed event after the edited reply is copied verbatim except for:
943
+ * fresh turn numbers (the log requires them contiguous), fresh message ids,
944
+ * remapped `sourceEventSeqs` on tool results, dropped usage numbers and embedded
945
+ * streams, and the replay marker in each message source. System, developer and
946
+ * every log-only record are skipped: they describe the original run, not the
947
+ * conversation.
948
+ *
949
+ * Tool calls are copied only as complete pairs. A call whose result never landed
950
+ * (an interrupted turn) is dropped along with its advertisement in the copied
951
+ * assistant content, because the format refuses a turn that closes with an
952
+ * unresolved call. The inverse repair shape - a `TOOL_NOT_STARTED` result with no
953
+ * call event - is copied as-is and keeps its absent `sourceEventSeqs`: the
954
+ * validator accepts that repair only without one.
955
+ *
956
+ * @param events - complete contiguous raw event log (the pre-edit snapshot).
957
+ * @param plan - the planned window (`shadowed`, `replayFrom`, `logTo`).
958
+ * @param baseTurn - the first fresh turn number the replay may use.
959
+ * @param replayId - the operation identity recorded on every copy.
960
+ * @param startSeq - the seq the first write will land at (writes are appended
961
+ * back to back, so each predicted seq is `startSeq` + its offset).
962
+ * @param promptCopied - parity with dsh-rerun-turn's builder: when true, the
963
+ * plan's `promptSeq` is replayed too. A reply edit never shadows the prompt,
964
+ * so its own plans leave this false.
965
+ * @returns an ordered `{ type, data, surfaceOp?, sourceEventSeqs? }` write list.
966
+ */
967
+ export function buildReplayWrites(events, plan, baseTurn, replayId, startSeq, promptCopied = false) {
968
+ const shadowedSet = new Set(plan.shadowed)
969
+ const bySeq = new Map(events.map((event) => [event.seq, event]))
970
+ const turnOf = turnIndex(events)
971
+ const shadowedTurns = new Set()
972
+ for (const seq of plan.shadowed) {
973
+ const turn = turnOf.get(seq)
974
+ if (typeof turn === 'number') shadowedTurns.add(turn)
975
+ }
976
+ const included = (event) => {
977
+ if (isSurfaceEvent(event)) return shadowedSet.has(event.seq)
978
+ if (
979
+ event.type === 'turn/start' ||
980
+ event.type === 'turn/end' ||
981
+ event.type === 'step/start' ||
982
+ event.type === 'step/end' ||
983
+ event.type === 'tool/call'
984
+ ) {
985
+ return typeof event.data.turn === 'number' && shadowedTurns.has(event.data.turn)
986
+ }
987
+ return false
988
+ }
989
+ const range = []
990
+ // `replayFrom` is this plugin's plan field; `logFrom` is the name the sibling
991
+ // builder uses, accepted here so the parity test can feed one plan object.
992
+ const walkFrom = typeof plan.replayFrom === 'number' ? plan.replayFrom : plan.logFrom
993
+ for (let seq = walkFrom; seq <= plan.logTo; seq += 1) {
994
+ const event = bySeq.get(seq)
995
+ if (event === undefined) continue
996
+ if (included(event)) range.push(event)
997
+ }
998
+ // Tool pairing facts for the whole range, so assistant content can be
999
+ // corrected before anything is written.
1000
+ const calledIds = new Set()
1001
+ const resultIds = new Set()
1002
+ const repairIds = new Set()
1003
+ for (const event of range) {
1004
+ if (event.type === 'tool/call') calledIds.add(event.data.callId)
1005
+ else if (event.type === 'tool/result') {
1006
+ resultIds.add(event.data.message && event.data.message.source ? event.data.message.source.callId : event.data.callId)
1007
+ const error = event.data.error
1008
+ if (error && error.name === 'ToolNotStartedError' && error.code === 'TOOL_NOT_STARTED' && event.sourceEventSeqs === undefined) {
1009
+ repairIds.add(event.data.message && event.data.message.source ? event.data.message.source.callId : event.data.callId)
1010
+ }
1011
+ }
1012
+ }
1013
+ const healthyIds = new Set()
1014
+ for (const id of calledIds) if (resultIds.has(id)) healthyIds.add(id)
1015
+ for (const id of repairIds) if (!calledIds.has(id)) healthyIds.add(id)
1016
+ const pairedResultIds = new Set()
1017
+ for (const id of calledIds) if (resultIds.has(id)) pairedResultIds.add(id)
1018
+ for (const id of repairIds) if (!calledIds.has(id)) pairedResultIds.add(id)
1019
+
1020
+ const writes = []
1021
+ let predicted = startSeq
1022
+ let nextTurn = baseTurn
1023
+ const turnMap = new Map()
1024
+ const assistantSeqById = new Map()
1025
+ let lastAssistantSeq = null
1026
+ const seqOf = () => {
1027
+ const seq = predicted
1028
+ predicted += 1
1029
+ return seq
1030
+ }
1031
+ for (const event of range) {
1032
+ const data = event.data || {}
1033
+ if (event.type === 'turn/start') {
1034
+ turnMap.set(data.turn, nextTurn)
1035
+ writes.push({ type: 'turn/start', data: { turn: nextTurn } })
1036
+ seqOf()
1037
+ continue
1038
+ }
1039
+ if (event.type === 'turn/end') {
1040
+ writes.push({ type: 'turn/end', data: { turn: turnMap.get(data.turn), reason: data.reason } })
1041
+ seqOf()
1042
+ nextTurn += 1
1043
+ continue
1044
+ }
1045
+ if (event.type === 'step/start' || event.type === 'step/end') {
1046
+ writes.push({ type: event.type, data: { turn: turnMap.get(data.turn), step: data.step } })
1047
+ seqOf()
1048
+ continue
1049
+ }
1050
+ if (event.type === 'user/message') {
1051
+ if (event.seq === plan.promptSeq && promptCopied !== true) continue
1052
+ // A silent carrier standing where a prompt was is bookkeeping, not
1053
+ // conversation: retire it without a copy, exactly as rerun does.
1054
+ if (isSilentPluginCarrier(event)) continue
1055
+ writes.push({
1056
+ type: 'user/message',
1057
+ surfaceOp: 'append',
1058
+ data: {
1059
+ id: randomUUID(),
1060
+ role: 'user',
1061
+ content: data.content,
1062
+ source: replaySource(data.source, replayId, event.seq),
1063
+ },
1064
+ })
1065
+ seqOf()
1066
+ continue
1067
+ }
1068
+ if (event.type === 'assistant/message') {
1069
+ const message = data.message || {}
1070
+ const source = { ...(message.source || {}) }
1071
+ delete source.replayState
1072
+ const content = Array.isArray(message.content)
1073
+ ? message.content.filter((block) => !(block && block.type === 'tool-call' && !healthyIds.has(block.id)))
1074
+ : []
1075
+ writes.push({
1076
+ type: 'assistant/message',
1077
+ surfaceOp: 'append',
1078
+ data: {
1079
+ turn: turnMap.get(data.turn),
1080
+ step: data.step,
1081
+ message: {
1082
+ id: randomUUID(),
1083
+ role: 'assistant',
1084
+ content,
1085
+ source: replaySource(source, replayId, event.seq),
1086
+ },
1087
+ stream: [],
1088
+ ...(data.interrupted === true ? { interrupted: true } : {}),
1089
+ },
1090
+ })
1091
+ assistantSeqById.set(event.seq, predicted)
1092
+ lastAssistantSeq = predicted
1093
+ seqOf()
1094
+ continue
1095
+ }
1096
+ if (event.type === 'tool/call') {
1097
+ if (!pairedResultIds.has(data.callId)) continue
1098
+ writes.push({
1099
+ type: 'tool/call',
1100
+ data: { turn: turnMap.get(data.turn), step: data.step, callId: data.callId, name: data.name, arguments: data.arguments },
1101
+ })
1102
+ seqOf()
1103
+ continue
1104
+ }
1105
+ if (event.type === 'tool/result') {
1106
+ const callId = data.message && data.message.source ? data.message.source.callId : data.callId
1107
+ if (!pairedResultIds.has(callId)) continue
1108
+ const message = data.message
1109
+ const source = replaySource(message && message.source, replayId, event.seq)
1110
+ // The TOOL_NOT_STARTED repair is accepted only without sourceEventSeqs;
1111
+ // ordinary results remap theirs onto the copied assistant message.
1112
+ let sourceEventSeqs
1113
+ if (!repairIds.has(callId)) {
1114
+ const remapped = Array.isArray(event.sourceEventSeqs)
1115
+ ? event.sourceEventSeqs.map((seq) => assistantSeqById.get(seq)).filter((seq) => typeof seq === 'number')
1116
+ : []
1117
+ if (remapped.length > 0) sourceEventSeqs = remapped
1118
+ else if (typeof lastAssistantSeq === 'number') sourceEventSeqs = [lastAssistantSeq]
1119
+ }
1120
+ const originalMessage = message || {}
1121
+ const rest = { ...originalMessage }
1122
+ delete rest.id
1123
+ delete rest.content
1124
+ delete rest.source
1125
+ // The interrupted-repair carrier must keep its contract-shaped id
1126
+ // (`interrupted-tool-result-<callId>-<n>`): the validator refuses a repair
1127
+ // whose id does not match, and a random id breaks it. The suffix is the
1128
+ // copy's own predicted seq, so the identity stays self-describing.
1129
+ const messageId = repairIds.has(callId) ? `interrupted-tool-result-${callId}-${predicted}` : randomUUID()
1130
+ writes.push({
1131
+ type: 'tool/result',
1132
+ surfaceOp: 'append',
1133
+ ...(sourceEventSeqs === undefined ? {} : { sourceEventSeqs }),
1134
+ data: {
1135
+ turn: turnMap.get(data.turn),
1136
+ step: data.step,
1137
+ message: {
1138
+ ...rest,
1139
+ id: messageId,
1140
+ content: originalMessage.content ?? [],
1141
+ source,
1142
+ },
1143
+ ...(data.error === undefined ? {} : { error: data.error }),
1144
+ ...(data.meta === undefined ? {} : { meta: data.meta }),
1145
+ },
1146
+ })
1147
+ seqOf()
1148
+ continue
1149
+ }
1150
+ // system/message, developer/message and every log-only record are skipped.
781
1151
  }
1152
+ return writes
782
1153
  }
783
1154
 
784
1155
  /**
@@ -1465,6 +1836,13 @@ async function applyEdit(ctx, sessionId, body, config) {
1465
1836
  // (it projects `lastTurn` from every turn/start it observes). A prompt edit
1466
1837
  // does not: `user/message` carries the revision and needs no turn.
1467
1838
  const editTurn = plan.mode === 'reply' ? nextTurnOf(events) : lastTurnOf(events)
1839
+ // One identity for the whole reply splice: every copy the replay appends
1840
+ // carries it, so a later reader (or a sibling plugin) can tell a replayed turn
1841
+ // from the one it retired without re-deriving anything.
1842
+ const replayId = plan.mode === 'reply' ? randomUUID() : null
1843
+ // Resolved BEFORE the appends so the loop counter can be re-pointed in the
1844
+ // same tick as the writes, not after an await that lets the loop start.
1845
+ const loopAgent = plan.mode === 'reply' ? await resolveLoopAgent(ctx, sessionId) : undefined
1468
1846
  const carrier = buildCarrier(plan, editTurn, plan.mode === 'prompt' ? { ...config, carrier: 'user/message', markerText: text } : config, content)
1469
1847
  if (plan.mode === 'reply') {
1470
1848
  try {
@@ -1495,82 +1873,122 @@ async function applyEdit(ctx, sessionId, body, config) {
1495
1873
  } catch (error) {
1496
1874
  throw new HttpError(409, 'stale', `the surface refused the rollback: ${String((error && error.message) || error)}`)
1497
1875
  }
1498
- const flush = await flushSession(ctx, session)
1499
1876
 
1500
- // Step two depends on what was edited.
1501
- //
1502
- // Rewriting a reply cannot swap the text in place - an `assistant/message` may
1503
- // never carry `sourceEventSeqs`, so it can never be a replacement carrier
1504
- // (checked against the real validator, not assumed). The correction is
1505
- // appended instead, in the same shape the agent loop records a reply, so the
1506
- // model goes on seeing the edited text as its own answer and the conversation
1507
- // continues from there. Nothing is re-run: the answer was fixed, the model was
1508
- // not asked again.
1509
- if (plan.mode === 'reply') {
1510
- const correction = buildCorrection(plan, text, editTurn, 1)
1511
- let appended
1877
+ // A prompt edit saved here: the carrier that replaced the window IS the
1878
+ // revised prompt, so the model reads it as the user's wording from here on.
1879
+ // Nothing is prompted and nothing else needs writing.
1880
+ if (plan.mode === 'prompt') {
1881
+ const flush = await flushSession(ctx, session)
1882
+ return {
1883
+ kind: 'prompt',
1884
+ replacementSeq: replacement.seq,
1885
+ applied: true,
1886
+ shadowed: plan.shadowed,
1887
+ dropped,
1888
+ // What the carrier now carries, described exactly the way /state describes
1889
+ // it. The browser half adopts this for the row it re-draws before its own
1890
+ // refresh lands - without it a second save could not know which blocks are
1891
+ // still there and would rewrite the message text-only.
1892
+ blocks: blockSummaries(content, canPreviewAttachments(store)),
1893
+ ...flush,
1894
+ }
1895
+ }
1896
+
1897
+ // Step two of a reply edit: the correction itself, inside the turn just
1898
+ // opened. An `assistant/message` may never carry `sourceEventSeqs`, so it can
1899
+ // never be a replacement carrier (checked against the real validator, not
1900
+ // assumed); it is appended in the same shape the agent loop records a reply,
1901
+ // and the model goes on seeing the edited text as its own answer.
1902
+ const correction = buildCorrection(plan, text, editTurn, 1)
1903
+ let appended
1904
+ try {
1905
+ appended = session.append(correction.type, correction.data, { surfaceOp: 'append' })
1906
+ // Close the step and the turn behind the corrected reply: the host anchors
1907
+ // a turn's tail (duration, action strip) at its last `turn/end`, and the
1908
+ // format only reads a step message inside an open turn and step.
1909
+ session.append('step/end', { turn: editTurn, step: 1 })
1910
+ session.append('turn/end', { turn: editTurn, reason: { kind: 'completed' } })
1911
+ } catch (error) {
1912
+ const failedFlush = await flushSession(ctx, session)
1913
+ return {
1914
+ kind: 'reply',
1915
+ replacementSeq: replacement.seq,
1916
+ applied: false,
1917
+ applyError: `the corrected reply was refused: ${String((error && error.message) || error)}`,
1918
+ shadowed: plan.shadowed,
1919
+ dropped,
1920
+ ...failedFlush,
1921
+ }
1922
+ }
1923
+
1924
+ // Step three: put every later turn back behind the correction, as fresh
1925
+ // copies. This is the same infix splice dsh-rerun-turn performs after a
1926
+ // regenerated answer - shadow the tail, write the new reply, replay what
1927
+ // followed - with the user's text taking the place of the model call.
1928
+ let replay = null
1929
+ if (typeof plan.replayFrom === 'number' && typeof plan.logTo === 'number') {
1930
+ const writes = buildReplayWrites(events, plan, editTurn + 1, replayId, session.seq)
1931
+ let replayed = 0
1932
+ let replayError = null
1512
1933
  try {
1513
- appended = session.append(correction.type, correction.data, { surfaceOp: 'append' })
1514
- // Close the step and the turn behind the corrected reply: the host anchors
1515
- // a turn's tail (duration, action strip) at its last `turn/end`, and the
1516
- // format only reads a step message inside an open turn and step.
1517
- session.append('step/end', { turn: editTurn, step: 1 })
1518
- session.append('turn/end', { turn: editTurn, reason: { kind: 'completed' } })
1519
- } catch (error) {
1520
- return {
1521
- kind: 'reply',
1522
- replacementSeq: replacement.seq,
1523
- applied: false,
1524
- applyError: `the corrected reply was refused: ${String((error && error.message) || error)}`,
1525
- shadowed: plan.shadowed,
1526
- dropped,
1527
- ...flush,
1934
+ for (const write of writes) {
1935
+ const options = {}
1936
+ if (write.surfaceOp !== undefined) options.surfaceOp = write.surfaceOp
1937
+ if (write.sourceEventSeqs !== undefined) options.sourceEventSeqs = write.sourceEventSeqs
1938
+ session.append(write.type, write.data, options)
1939
+ replayed += 1
1528
1940
  }
1941
+ } catch (error) {
1942
+ replayError = String((error && error.message) || error)
1943
+ }
1944
+ const playedMax = writes.reduce((max, write) => {
1945
+ const turn = write.data && typeof write.data.turn === 'number' ? write.data.turn : 0
1946
+ return turn > max ? turn : max
1947
+ }, 0)
1948
+ replay = {
1949
+ id: replayId,
1950
+ replayed,
1951
+ total: writes.length,
1952
+ turns: writes.reduce((count, write) => count + (write.type === 'turn/start' ? 1 : 0), 0),
1953
+ ...(replayError === null ? {} : { error: replayError }),
1954
+ }
1955
+ // The replayed turns are closed in the log but were never opened by the
1956
+ // live loop: re-point its idle counter at the highest one, synchronously.
1957
+ const loopTurn = syncLoopTurn(loopAgent, playedMax > editTurn ? playedMax : editTurn)
1958
+ if (loopTurn === 'unavailable') {
1959
+ noteRequest({ kind: 'reply', sessionId, ok: replayError === null, loopTurn, turn: editTurn, replayId, replayed })
1529
1960
  }
1530
1961
  const corrected = await flushSession(ctx, session)
1531
- // The synthetic turn this edit opened is invisible to the live loop's own
1532
- // counter; without the sync its next run reuses that number and the log
1533
- // fails its next load. Only a reply edit opens a turn - a prompt edit does
1534
- // not, and needs nothing.
1535
- const loopAgent = await resolveLoopAgent(ctx, sessionId)
1536
- const loopTurn = syncLoopTurn(loopAgent, editTurn)
1537
- // `loopTurn` is the diagnostic the request ring records; the field is DELIBERATELY
1538
- // not called `code` - the static checks read `code:` as host error codes and
1539
- // must not start demanding a user-visible message for an internal sync state.
1540
- const syncNote = loopTurn
1541
- if (syncNote === 'unavailable') {
1542
- noteRequest({ kind: 'reply', sessionId, ok: true, loopTurn: syncNote, turn: editTurn })
1543
- }
1544
1962
  return {
1545
1963
  kind: 'reply',
1546
1964
  replacementSeq: replacement.seq,
1547
1965
  appendedSeq: appended.seq,
1548
1966
  applied: true,
1549
1967
  loopTurn,
1968
+ replay,
1550
1969
  shadowed: plan.shadowed,
1551
1970
  dropped,
1552
1971
  ...corrected,
1553
1972
  }
1554
1973
  }
1555
1974
 
1556
- // The rollback above already did the whole job: the carrier that replaced the
1557
- // window *is* the revised prompt, so the model reads it as the user's wording
1558
- // from here on. Saving therefore costs no model call, always: regenerating
1559
- // the turn is a different operation with a different owner - the sibling
1560
- // plugin dsh-rerun-turn shadows the turn and regenerates it from the prompt
1561
- // the surface now shows, then replays what followed. Nothing here prompts.
1975
+ // No later turn to replay: the correction is the last answer in the log.
1976
+ // The synthetic turn still moved the loop's counter; sync it the same way.
1977
+ const loopTurn = syncLoopTurn(loopAgent, editTurn)
1978
+ if (loopTurn === 'unavailable') {
1979
+ noteRequest({ kind: 'reply', sessionId, ok: true, loopTurn, turn: editTurn, replayId })
1980
+ }
1981
+ const corrected = await flushSession(ctx, session)
1562
1982
  return {
1563
- kind: 'prompt',
1983
+ kind: 'reply',
1564
1984
  replacementSeq: replacement.seq,
1985
+ appendedSeq: appended.seq,
1565
1986
  applied: true,
1987
+ loopTurn,
1988
+ replay: null,
1566
1989
  shadowed: plan.shadowed,
1567
1990
  dropped,
1568
- // What the carrier now carries, described exactly the way /state describes
1569
- // it. The browser half adopts this for the row it re-draws before its own
1570
- // refresh lands - without it a second save could not know which blocks are
1571
- // still there and would rewrite the message text-only.
1572
- blocks: blockSummaries(content, canPreviewAttachments(store)),
1573
- ...flush,
1991
+ ...corrected,
1574
1992
  }
1575
1993
  }
1576
1994
 
@@ -1597,7 +2015,7 @@ function registerTool(ctx) {
1597
2015
  defineTool({
1598
2016
  name: 'edit_turn_targets',
1599
2017
  description:
1600
- 'List the user turns of a DeepSeek Harness session that the edit-turn plugin can rewrite and re-run, with each prompt\'s original text. Read-only: it changes nothing.',
2018
+ 'List the user turns and model replies of a DeepSeek Harness session that the edit-turn plugin can rewrite, with each message\'s current text. A prompt is replaced where it stands; a reply correction is appended and every later turn is replayed back behind it, with no model call. Read-only: it changes nothing.',
1601
2019
  parameters: {
1602
2020
  sessionId: {
1603
2021
  type: 'string',
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-edit-turn",
3
- "version": "0.2.19",
4
- "description": "Edit any user turn in DeepSeek Harness and re-run it: an inline editor on the message row rolls the conversation back to just before that turn through the official surface-replace contract, then re-prompts the agent with the revised text. The append-only session log is never rewritten.",
3
+ "version": "0.2.21",
4
+ "description": "Edit any message in DeepSeek Harness in place: a prompt is replaced where it stands, a model reply is corrected and every later turn is replayed back behind it - the same infix splice dsh-rerun-turn performs after a rerun, with no model call. The append-only session log is never rewritten.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {