dsh-edit-turn 0.1.1 → 0.2.1

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
@@ -25,6 +25,7 @@ DSH 的会话日志是 append-only 的事件流:说错的提示词、问偏的
25
25
  - **默认零上下文污染** —— 替换事件的载体是一个**空的 `system/message`**。官方格式文档里空的后置 system 节点是「dormant,不投影成任何消息」,所以回退后模型看到的上下文,和「对话真的停在那一点」完全一致,不会多出任何标记文本。
26
26
  - **轮边界安全** —— 遮蔽窗口右端固定为日志最后一个 surface 节点,左端固定为目标消息节点,因此助手消息(内含 tool_use)与它产生的 tool/result 永远一起走,**不可能留下悬空的调用/结果对**。
27
27
  - **重跑走官方准入路径** —— `ctx.sessionController.prompt()` 是唯一的口径:它会自己 resume 冷会话,并恰好开一个新轮次。
28
+ - **模型回答也能编辑** —— 回答无法被"替换":官方格式禁止 `assistant/message` 携带 `sourceEventSeqs`(已在真实校验器上验证)。做法是回退该回答及其后的内容,再**追加**一条带改写文本的助手消息——模型会把改写后的内容当成自己说过的话,对话可以继续。`source.editedBy` 会如实记录这段文字由插件写入。
28
29
  - **一次点击即执行** —— 保存后不再有二次确认。编辑器本身已经是用户主动打开的动作,面板里也写明了保存会丢弃哪些内容;想恢复两步确认可在 profile 里一行开启(`confirm: true`)。
29
30
  - **中英双语 UI**,跟随 DSH 当前语言。
30
31
  - **皮肤友好** —— 编辑器面板自带不透明表面(`--dshet-panel`)而不是借用主题的表面色变量。皮肤的本意就是让表面半透明、把插画透出来,而它只会给**自己的**元素补可读背景,插件类名不在其中;借用皮肤变量的面板会变成全透明,文字直接压在插画上。暗色分支走官方属性 `body[data-ds-dark-theme]`(与 `dsh-client-ui-theme` 及多个官方 UI 包一致),并用 `backdrop-filter` 与皮肤融合。
@@ -53,6 +54,10 @@ dsh plugin --profile web add /path/to/dsh-edit-turn
53
54
 
54
55
  编辑器里如果提示「这条消息包含图片或文件附件」,说明改写只保留文字,附件会被丢弃。
55
56
 
57
+ **编辑模型的回答**:悬停任意一条模型回复,同样会出现编辑入口。改完点「保存替换」——这条回答被替换为你写的内容,它之后的内容一并移除,模型从此把你写的内容当成自己说过的话,对话可以继续下去。
58
+
59
+ 注意:回答里的**工具调用与思考过程**无法保留(替换后只留文字,编辑器会提示),因为工具调用的结果已经不再成立。
60
+
56
61
  ### 配置
57
62
 
58
63
  写在 profile 里即可覆盖默认值:
@@ -105,15 +110,18 @@ await ctx.sessionController.prompt({ requestId, sessionId, mode: 'queue', conten
105
110
 
106
111
  ### 验证状态
107
112
 
108
- 本插件在 DSH `0.1.6-alpha.2` 上完成以下验证(全部**零模型调用**):
113
+ 本插件在 DSH `0.1.6-alpha.2` 与 `0.1.7-rc.1` 上完成以下验证:
109
114
 
110
115
  | 验证 | 命令 | 结果 |
111
116
  |---|---|---|
112
- | 官方 append 契约(真实校验器,进程内) | `npm run verify:contract` | 46 项通过:替换事件被接受、派生历史真的收缩、日志 append-only、工具结果与调用同进同退、空 system 载体不产生模型消息、**连续两次回退都被接受** |
113
- | 纯逻辑 + 宿主集成 + 客户端 DOM 行为(真 HTTP、真校验器、桩服务、DOM 桩) | `npm test` | 63 项通过 |
114
- | 客户端半部静态检查(注册、i18n 完整性、样式、**皮肤可读性**、版本三处同步、线协议) | `npm run verify:client` | 全部通过 |
115
- | 实机前端产物校验(运行中的 DSH 是否在下发当前代码) | `npm run verify:live -- --token-file ~/path/to/dsh.log` | 全部通过 |
117
+ | 运行中的实例是否真的挂载了本插件(只读路由守卫探针,无需 token) | `npm run probe:loaded [端口]` | 通过:`/state` 返 400、`/apply` 返 405 —— 这两个状态码只有本插件会返回 |
118
+ | 浏览器半部到底问了什么、拿回了什么(只读诊断,含失败记录) | `GET /dsh-edit-turn/debug` | 通过:真实浏览器里确认客户端确实拿到 replies;并复现了旧 v3 会话的 session-not-found |
119
+ | 官方 append 契约(真实校验器,进程内) | `npm run verify:contract` | 61 项通过:替换事件被接受、派生历史真的收缩、日志 append-only、工具结果与调用同进同退、空 system 载体不产生模型消息、**连续两次回退都被接受**、**编辑模型回答的完整机制被接受** |
120
+ | 纯逻辑 + 宿主集成 + 客户端 DOM 行为(真 HTTP、真校验器、桩服务、DOM 桩) | `npm test` | 83 项通过 |
121
+ | 客户端半部静态检查(注册、i18n 完整性、样式、皮肤可读性、版本三处同步、线协议) | `npm run verify:client` | 全部通过 |
122
+ | 实机前端产物校验(运行中的 DSH 是否在下发当前代码) | `npm run verify:live -- --token-file ~/path/to/dsh.log` | 全部通过(含**宿主节点形状锚点**) |
116
123
  | 真实 profile 安装 / 补丁合成 / 启动 / 工具契约 / 路由守卫 | `npm run verify:profile` | 全部通过 |
124
+ | **真实浏览器端到端**(agent-browser 驱动 Chrome 打开运行实例) | 手动 | 已完成:模型回答行出现「编辑这条回答」、点开编辑器预填真实回复原文、取消后网络层 0 个 apply 请求。截图见 `docs/reply-editor-in-browser.png` |
117
125
 
118
126
  `verify:live` 针对**正在运行的实例**:用 DSH 启动时打印的 token 换取鉴权 cookie,读启动页里的客户端模块组,把含本插件的那一组下载下来,断言插件自己的标记确实在其中。它证明的是「浏览器刷新后会拿到当前代码」,而不是「源码看起来没问题」——前端改动后这是唯一能确认已生效的自动手段。
119
127
 
@@ -133,13 +141,31 @@ ln -s /path/to/dsh-install/node_modules ./node_modules
133
141
 
134
142
  ### 已知限制
135
143
 
136
- - **只支持人类输入的消息**。注入的上下文行、助手消息、系统提示词都不提供编辑入口。
144
+ - 可以编辑**你自己输入的消息**与**模型的回答**;注入的上下文行与系统提示词没有编辑入口。
145
+ - 编辑一条回答会把它替换为**纯文本**:该回答里的工具调用与思考过程会被移除(编辑器会提示),因为它们的结果已不再成立。
146
+ - 编辑一条用户消息会重跑那一轮;编辑一条模型回答只替换内容,**不会**重新问模型。
137
147
  - **编辑会丢弃该消息之后的全部轮次**(MVP 语义,和 ChatGPT 的编辑一致)。想保留原文形成分支,需要走 `sessionController.fork({ sessionId, atSeq })`,尚未实现。
138
148
  - **只改写文本**。消息里含图片/文件附件时,改写后只保留文字(编辑器会提示)。
139
149
  - **会话必须当前在 DSH 中打开**,否则返回 `409 session-not-active`。
140
150
  - **进行中拒绝编辑**:未闭合的轮次或正在压缩时返回 `409 busy`。
141
151
  - **回退是持久的,隐藏不是**。回退写进日志后,模型上下文永久改变;转录里那些行的隐藏是本插件客户端半部做的。卸载插件后,旧行会重新显示出来(而模型上下文里的回退仍然生效)——因为替换事件是官方事件,不会随插件消失。
142
152
  - 回退后**系统提示词保持不变**(窗口永不包含 surface 节点 0,该节点也永不可编辑)。
153
+ - **旧会话(v3 格式日志)在 DSH 0.1.7 下读不到**:DSH 的会话读取层对这类日志返回 `session-not-found`,本插件因此在其中完全不工作(表现为没有编辑入口)。新写的会话是 v4,正常。
154
+
155
+ ### 更新日志
156
+
157
+ **0.2.1** —— 修 0.1.7 上的两处界面问题,并补上让问题可自证的诊断手段。
158
+
159
+ - 修复:模型回复的编辑入口改注册进官方插槽 `conversation.chat.assistant-actions`,与平台自己的 复制 / 赞 / 踩 / 分支 / 用量 同排(此前是 DOM 注入,位置不一致、且会随工具调用行卸载而闪烁)。
160
+ - 修复:编辑模型回答后**重新闭合该轮**(`step/end` + `turn/end`)。此前修正消息被追加到 `turn/end` 之后、落在轮外,导致那一轮的尾巴(用时、动作条)渲染到回复正文**上面**。
161
+ - 修复:DSH 0.1.7 兼容——会话格式 v4 的 producer-owned source kind(`plugin:<id>`)、助手消息文本取自 `data.message.content`。
162
+ - 新增:只读诊断路由 `GET /dsh-edit-turn/debug`;`npm run probe:loaded [端口]`。
163
+
164
+ **0.2.0** —— 可以编辑模型的回答。回退该回答及其后内容,再追加一条带改写文本的助手消息(回答无法原地替换:官方格式禁止 `assistant/message` 携带 `sourceEventSeqs`)。不重跑模型;`source.editedBy` 如实标记文字由插件写入。
165
+
166
+ **0.1.1** —— 去掉多余的二次确认(默认一次点击即执行);修皮肤下界面可读性。
167
+
168
+ **0.1.0** —— 首版:回退并重跑你自己发过的消息。
143
169
 
144
170
  ### 姊妹插件
145
171
 
@@ -149,7 +175,7 @@ ln -s /path/to/dsh-install/node_modules ./node_modules
149
175
 
150
176
  ### 兼容性
151
177
 
152
- - `dsh.engines.dsh`: `>=0.1.6-alpha.2`(本插件依赖该版本的消息行定义与 surface 语义,并已在此版本实测)。
178
+ - `dsh.engines.dsh`: `>=0.1.6-alpha.2`(本插件依赖该版本的消息行定义与 surface 语义;`0.1.6-alpha.2` 与 `0.1.7-rc.1` 均已实测)。
153
179
  - 客户端依赖:`dsh-client-locale`、`dsh-client-ui-chat`、`dsh-client-ui-conversation`、`dsh-client-ui-primitives`。
154
180
  - 宿主依赖:`dsh-settings`、`dsh-tools`(peer);`@deepseek-ai/schemastery`(直接依赖)。
155
181
 
@@ -182,6 +208,7 @@ This plugin adds it:
182
208
  - **Zero context pollution by default.** The replacement carrier is an **empty `system/message`**. The official format documents empty later system nodes as dormant, projecting to no message, so the context after an edit is exactly what it would be had the conversation really stopped there - no marker text is added.
183
209
  - **Turn-boundary safe.** The shadow window always ends at the last surface node and always opens at the addressed message, so an assistant message (which carries its own tool_use blocks) and the tool/result it produced are shadowed together. A dangling call/result pair is impossible.
184
210
  - **Official re-run.** `ctx.sessionController.prompt()` is the only prompt admission path; it resumes a cold Session itself and opens exactly one new turn.
211
+ - **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.
185
212
  - **One click applies** - no second confirmation. The editor is already an explicit action the user opened, and the panel states what saving discards; a profile can restore the two-step flow with `confirm: true`.
186
213
  - **Bilingual UI** that follows the current DSH locale.
187
214
  - **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`.
@@ -211,6 +238,15 @@ Restart the DSH process that serves that profile to pick it up.
211
238
  If the editor warns that the message carries attachments, the rewrite keeps the
212
239
  text only and drops them.
213
240
 
241
+ **Editing what the model said.** Hover any model reply and the same edit action
242
+ appears. Click "Save replacement" - the reply is replaced with your text,
243
+ everything after it is removed, and the model goes on treating your words as its
244
+ own, so the conversation can continue from there.
245
+
246
+ Note that **tool calls and reasoning inside that reply cannot survive** the
247
+ replacement (only text is kept, and the editor warns first): their results are no
248
+ longer valid once you have changed what the model said.
249
+
214
250
  ### Configuration
215
251
 
216
252
  ```yaml
@@ -276,11 +312,13 @@ Verified against DSH `0.1.6-alpha.2`, entirely **without model calls**:
276
312
 
277
313
  | Check | Command | Result |
278
314
  |---|---|---|
279
- | Official append contract against the real validator, in process | `npm run verify:contract` | 46 checks pass: the replacement is accepted, the derived history really shrinks, the log stays append-only, a tool result leaves with its call, the empty system carrier adds no model message, **two consecutive rollbacks are both accepted** |
280
- | Pure logic, host integration and browser-half DOM behaviour (real HTTP, real validator, stubbed services, DOM stub) | `npm test` | 63 tests pass |
315
+ | Is the plugin actually mounted in a running instance? (read-only route-guard probe, no token needed) | `npm run probe:loaded [port]` | pass: `/state` answers 400 and `/apply` answers 405 - status codes only this plugin produces |
316
+ | Official append contract against the real validator, in process | `npm run verify:contract` | 61 checks pass: the replacement is accepted, the derived history really shrinks, the log stays append-only, a tool result leaves with its call, the empty system carrier adds no model message, **two consecutive rollbacks are both accepted**, **the whole reply-editing mechanism is accepted** |
317
+ | Pure logic, host integration and browser-half DOM behaviour (real HTTP, real validator, stubbed services, DOM stub) | `npm test` | 83 tests pass |
281
318
  | Browser-half static checks (registration, i18n completeness, styles, skin legibility, three-way version sync, wire contract) | `npm run verify:client` | all pass |
282
- | Live client artifact (is the running DSH serving the current code?) | `npm run verify:live -- --token-file ~/path/to/dsh.log` | all pass |
319
+ | Live client artifact (is the running DSH serving the current code?) | `npm run verify:live -- --token-file ~/path/to/dsh.log` | all pass, **including anchors on the host's node shapes** |
283
320
  | Real profile: install, patch composition, boot, tool contract, route guards | `npm run verify:profile` | all pass |
321
+ | **Real browser, end to end** (agent-browser drives Chrome at a running instance) | manual | done: an edit action appears on model reply rows, the editor opens pre-filled with the real reply, and cancelling leaves zero `apply` requests. See `docs/reply-editor-in-browser.png` |
284
322
 
285
323
  `verify:live` targets an instance that is **already running**: it exchanges the
286
324
  token DSH printed on boot for an auth cookie, reads the client module groups out
@@ -319,7 +357,9 @@ ln -s /path/to/dsh-install/node_modules ./node_modules
319
357
 
320
358
  ### Known limitations
321
359
 
322
- - **Human messages only.** Injected context rows, assistant messages and the system prompt offer no edit entry.
360
+ - **Human prompts and model replies** are both editable; injected context rows and the system prompt offer no edit entry.
361
+ - **Editing a reply replaces it with plain text**: the tool calls and reasoning inside it are removed (the editor warns first), because their results are no longer valid.
362
+ - **Editing a user message re-runs that turn; editing a model reply does not ask the model again** - it only replaces the text.
323
363
  - **An edit discards every later turn** (the MVP semantic, matching ChatGPT's edit). Keeping the original as a branch needs `sessionController.fork({ sessionId, atSeq })`, which is not implemented.
324
364
  - **Text only.** A message carrying image or file attachments keeps its text and drops them (the editor warns first).
325
365
  - **The session must be open in DSH**, otherwise the route answers `409 session-not-active`.
package/lib/client.js CHANGED
@@ -30,22 +30,28 @@ window.__ModuleLoader__.load({
30
30
  const ROUTE_PREFIX = '/dsh-edit-turn'
31
31
 
32
32
  /** Keep in sync with package.json and lib/index.js. */
33
- const PLUGIN_VERSION = '0.1.1'
33
+ const PLUGIN_VERSION = '0.2.1'
34
34
 
35
35
  // --- copy -----------------------------------------------------------------
36
36
 
37
37
  const zh = {
38
38
  'action.edit': '编辑这条消息并重跑',
39
+ 'action.edit-reply': '编辑这条回答',
39
40
  'editor.title': '编辑这条消息',
40
- 'editor.placeholder': '修改后确认,会话将回退到这条消息之前并重新跑这一轮。',
41
+ 'editor.title-reply': '编辑这条回答',
42
+ 'editor.placeholder': '修改后保存,会话将回退到这条消息之前并重新跑这一轮。',
43
+ 'editor.placeholder-reply': '改成你希望模型说过的内容,保存后它就成为这条回答。',
41
44
  'editor.save': '保存并重跑',
42
- 'editor.review': '确认回退',
45
+ 'editor.save-reply': '保存替换',
46
+ 'editor.review': '确认执行',
43
47
  'editor.cancel': '取消',
44
- 'editor.pending': '正在回退并重跑…',
48
+ 'editor.pending': '正在执行…',
45
49
  'editor.warn.attachments': '这条消息包含图片或文件附件,改写会丢弃它们,只保留文字。',
50
+ 'editor.warn-reply': '这条回答包含工具调用或思考过程,替换后它们会被一并移除。',
46
51
  'editor.hint': '保存后,这条消息之后的全部内容(含当轮回复与工具调用)会从模型上下文中移除,并从这里重新开始。原始会话日志不会改写。',
47
- 'confirm.title': '确认回退并重跑?',
48
- 'confirm.body': '这条消息之后的全部内容会从模型上下文中移除,然后用你改写后的内容重新跑这一轮。原始会话日志保持不变。',
52
+ 'editor.hint-reply': '保存后,这条回答会被替换为你写的内容,它之后的内容一并移除。模型会把你写的内容当成自己说过的话,对话可以继续下去。原始会话日志不会改写。',
53
+ 'confirm.title': '确认执行?',
54
+ 'confirm.body': '确认后会立即执行:这条消息之后的内容会从模型上下文中移除。原始会话日志不会改写。',
49
55
  'error.invalid': '请求无效,请刷新后重试。',
50
56
  'error.session-not-active': '这个会话当前未激活,请先打开该会话再编辑。',
51
57
  'error.session-not-found': '找不到该会话的日志。',
@@ -57,22 +63,29 @@ window.__ModuleLoader__.load({
57
63
  'error.method': '请求方式不被接受,请刷新后重试。',
58
64
  'error.generic': '编辑失败,请重试。',
59
65
  'error.prompt': '已回退,但重跑没有启动。请手动发送改写后的内容。',
66
+ 'error.reply': '已回退,但替换没有落地。请重试。',
60
67
  'error.empty': '改写后的内容不能为空。',
61
68
  'error.internal': '服务器内部错误,请稍后重试。',
62
69
  }
63
70
 
64
71
  const en = {
65
72
  'action.edit': 'Edit this message and re-run',
73
+ 'action.edit-reply': 'Edit this reply',
66
74
  'editor.title': 'Edit this message',
67
- 'editor.placeholder': 'Confirm after editing: the conversation rolls back to just before this message and this turn runs again.',
75
+ 'editor.title-reply': 'Edit this reply',
76
+ 'editor.placeholder': 'Saving rolls the conversation back to just before this message and this turn runs again.',
77
+ 'editor.placeholder-reply': 'Write what you want the model to have said; saving makes it this reply.',
68
78
  'editor.save': 'Save and re-run',
69
- 'editor.review': 'Confirm rollback',
79
+ 'editor.save-reply': 'Save replacement',
80
+ 'editor.review': 'Confirm',
70
81
  'editor.cancel': 'Cancel',
71
- 'editor.pending': 'Rolling back and re-running...',
82
+ 'editor.pending': 'Working...',
72
83
  'editor.warn.attachments': 'This message carries image or file attachments; rewriting keeps the text only.',
84
+ 'editor.warn-reply': 'This reply carries tool calls or reasoning; replacing it removes them.',
73
85
  'editor.hint': 'Saving removes everything after this message (including that turn\'s reply and tool calls) from the model context and starts again from here. The original session log is never rewritten.',
74
- 'confirm.title': 'Roll back and re-run?',
75
- 'confirm.body': 'Everything after this message leaves the model context, then this turn runs again from your revised text. The original session log stays untouched.',
86
+ 'editor.hint-reply': 'Saving replaces this reply with your text and removes everything after it. The model goes on treating your words as its own, so the conversation can continue from here. The original session log is never rewritten.',
87
+ 'confirm.title': 'Confirm?',
88
+ 'confirm.body': 'Confirming applies right away: everything after this message leaves the model context. The original session log is never rewritten.',
76
89
  'error.invalid': 'Invalid request; refresh and try again.',
77
90
  'error.session-not-active': 'This session is not open in DSH; open it first.',
78
91
  'error.session-not-found': 'No session log was found for this id.',
@@ -84,6 +97,7 @@ window.__ModuleLoader__.load({
84
97
  'error.method': 'The request method is not accepted; refresh and try again.',
85
98
  'error.generic': 'The edit failed; try again.',
86
99
  'error.prompt': 'Rolled back, but the re-run did not start. Send the revised text manually.',
100
+ 'error.reply': 'Rolled back, but the replacement did not land. Try again.',
87
101
  'error.empty': 'The revised message cannot be empty.',
88
102
  'error.internal': 'The host hit an internal error; try again shortly.',
89
103
  }
@@ -201,6 +215,8 @@ window.__ModuleLoader__.load({
201
215
  this.view = Object.freeze({
202
216
  hidden: new Map(),
203
217
  editable: new Map(),
218
+ replies: new Map(),
219
+ repliesByMessage: new Map(),
204
220
  editing: null,
205
221
  draft: '',
206
222
  confirming: false,
@@ -259,9 +275,23 @@ window.__ModuleLoader__.load({
259
275
  for (const turn of Array.isArray(data.turns) ? data.turns : []) {
260
276
  if (turn && typeof turn.seq === 'number') editable.set(turn.seq, turn)
261
277
  }
278
+ // The model's replies are editable too, as a separate list: they are
279
+ // rewritten rather than re-run, so the editor words the consequence
280
+ // differently.
281
+ const replies = new Map()
282
+ const repliesByMessage = new Map()
283
+ for (const reply of Array.isArray(data.replies) ? data.replies : []) {
284
+ if (!reply || typeof reply.seq !== 'number') continue
285
+ replies.set(reply.seq, reply)
286
+ // The official assistant-actions strip hands us a messageId, so
287
+ // replies are indexed by it too.
288
+ if (typeof reply.messageId === 'string' && reply.messageId !== '') repliesByMessage.set(reply.messageId, reply)
289
+ }
262
290
  this.publish({
263
291
  hidden,
264
292
  editable,
293
+ replies,
294
+ repliesByMessage,
265
295
  surfaceReady: true,
266
296
  loaded: true,
267
297
  loadError: false,
@@ -350,6 +380,13 @@ window.__ModuleLoader__.load({
350
380
  for (const entry of editable.values()) {
351
381
  if (hidden.has(entry.seq)) editable.delete(entry.seq)
352
382
  }
383
+ // A reply edit shadows its own reply too, so it leaves the editable
384
+ // replies here and re-enters through the next /state refresh (the
385
+ // appended correction is itself an editable reply).
386
+ const replies = new Map(this.view.replies)
387
+ for (const entry of replies.values()) {
388
+ if (hidden.has(entry.seq)) replies.delete(entry.seq)
389
+ }
353
390
  this.animateOnce = true
354
391
  this.editor = null
355
392
  this.editorDraft = ''
@@ -361,11 +398,13 @@ window.__ModuleLoader__.load({
361
398
  confirming: false,
362
399
  hidden,
363
400
  editable,
401
+ replies,
364
402
  failure: null,
365
403
  })
366
404
  // The rollback has landed, so the editor is gone with its row: a
367
- // failed re-run has to be reported somewhere that survives that.
368
- if (data.promptAccepted === false) this.notify('prompt')
405
+ // failed second half has to be reported somewhere that survives that.
406
+ if (data.kind === 'reply' && data.applied === false) this.notify('reply')
407
+ if (data.kind !== 'reply' && data.promptAccepted === false) this.notify('prompt')
369
408
  } catch {
370
409
  this.publish({ pending: false, failure: 'generic' })
371
410
  }
@@ -396,23 +435,43 @@ window.__ModuleLoader__.load({
396
435
 
397
436
  // --- row targets ----------------------------------------------------------
398
437
 
399
- // The editable target of a Chat view node, or null when the row is not a
400
- // human prompt the user can rewrite.
401
- function targetFor(node, editable, hidden) {
438
+ // The editable target of a Chat view node, or null when the row stands for
439
+ // something that cannot be rewritten.
440
+ //
441
+ // Only human prompts come through here: a reply's entry comes from the
442
+ // official assistant-actions strip instead, which is where the host puts
443
+ // every other action for that row. Injecting one here used to put the pencil
444
+ // in a different place from every other reply action.
445
+ function targetFor(node, view) {
402
446
  if (!node || node.kind !== 'user') return null
403
447
  const data = node.data || {}
404
- const seq = typeof data.seq === 'number' ? data.seq : node.anchorSeq
405
- if (typeof seq !== 'number') return null
406
- if (hidden.has(seq)) return null
407
- const entry = editable.get(seq)
408
- if (!entry) return null
409
- return {
410
- seq,
411
- turn: entry.turn,
412
- messageId: entry.messageId,
413
- text: typeof entry.text === 'string' ? entry.text : '',
414
- attachments: typeof entry.attachments === 'number' ? entry.attachments : 0,
448
+ const candidates = []
449
+ const push = (value) => {
450
+ if (typeof value === 'number' && !candidates.includes(value)) candidates.push(value)
451
+ }
452
+ push(data.seq)
453
+ push(node.anchorSeq)
454
+ for (const seq of candidates) {
455
+ if (view.hidden.has(seq)) continue
456
+ const entry = view.editable.get(seq)
457
+ if (!entry) continue
458
+ return {
459
+ seq,
460
+ mode: 'prompt',
461
+ turn: entry.turn,
462
+ messageId: entry.messageId,
463
+ text: typeof entry.text === 'string' ? entry.text : '',
464
+ attachments: typeof entry.attachments === 'number' ? entry.attachments : 0,
465
+ }
415
466
  }
467
+ return null
468
+ }
469
+
470
+ // The element that carries the turn-tail marker: either the flow-keyed row
471
+ // itself or a wrapper inside it. Its own action strip is a direct child.
472
+ function rootOfTurnTail(row) {
473
+ if (typeof row.matches === 'function' && row.matches('[data-turn-tail]')) return row
474
+ return row.querySelector('[data-turn-tail]')
416
475
  }
417
476
 
418
477
  // Every surface seq a row stands for, so one rollback can hide the rows it
@@ -506,8 +565,8 @@ window.__ModuleLoader__.load({
506
565
  rowActions.delete(row)
507
566
  }
508
567
 
509
- function injectRowAction(row, target, controller, t) {
510
- const label = t('action.edit')
568
+ function injectRowAction(row, target, controller, t, options = {}) {
569
+ const label = t(target.mode === 'reply' ? 'action.edit-reply' : 'action.edit')
511
570
  let entry = rowActions.get(row)
512
571
  if (!entry) {
513
572
  const host = document.createElement('span')
@@ -516,6 +575,11 @@ window.__ModuleLoader__.load({
516
575
  button.type = 'button'
517
576
  button.className = 'dshet-action dshet-row-action'
518
577
  button.innerHTML = ICON_MARKUP
578
+ // The marker goes on the BUTTON, not only on its wrapper: the pass that
579
+ // looks for the official strip's entry queries buttons, so a marker one
580
+ // level up made this pencil look like the strip's own and it deleted
581
+ // itself - then the next pass put it back, which reads as flicker.
582
+ if (options.fallback === true) button.dataset.dshetFallback = '1'
519
583
  host.appendChild(button)
520
584
  entry = { host, button }
521
585
  rowActions.set(row, entry)
@@ -530,7 +594,9 @@ window.__ModuleLoader__.load({
530
594
  event.stopPropagation()
531
595
  controller.open(target)
532
596
  }
533
- const anchor = row.querySelector('[class*="_actions"]')
597
+ // A caller that already picked the right bar (the turn tail's own strip)
598
+ // hands it over; otherwise the first actions container in the row wins.
599
+ const anchor = options.anchor ?? row.querySelector('[class*="_actions"]')
534
600
  if (anchor) {
535
601
  host.classList.remove('dshet-floating')
536
602
  row.classList.remove('dshet-row')
@@ -567,8 +633,23 @@ window.__ModuleLoader__.load({
567
633
  ].join('|')
568
634
  }
569
635
 
636
+ // Every string the editor shows depends on what is being edited: a prompt is
637
+ // re-run, a reply is replaced. Keeping them in one place keeps the two modes
638
+ // from drifting apart in wording.
639
+ function editorTexts(t, target) {
640
+ const reply = target.mode === 'reply'
641
+ return {
642
+ title: t(reply ? 'editor.title-reply' : 'editor.title'),
643
+ placeholder: t(reply ? 'editor.placeholder-reply' : 'editor.placeholder'),
644
+ hint: t(reply ? 'editor.hint-reply' : 'editor.hint'),
645
+ warn: t(reply ? 'editor.warn-reply' : 'editor.warn.attachments'),
646
+ save: t(reply ? 'editor.save-reply' : 'editor.save'),
647
+ }
648
+ }
649
+
570
650
  function renderEditor(row, controller, view, t) {
571
651
  const target = view.editing
652
+ const texts = editorTexts(t, target)
572
653
  const marker = editorMarker(target, view)
573
654
  const existing = row.querySelector('.dshet-editor')
574
655
  if (existing && existing.dataset.dshetFor === marker) return
@@ -580,7 +661,7 @@ window.__ModuleLoader__.load({
580
661
 
581
662
  const title = document.createElement('p')
582
663
  title.className = 'dshet-editor-title'
583
- title.textContent = view.confirming ? t('confirm.title') : t('editor.title')
664
+ title.textContent = view.confirming ? t('confirm.title') : texts.title
584
665
  box.appendChild(title)
585
666
 
586
667
  const area = document.createElement('textarea')
@@ -588,20 +669,20 @@ window.__ModuleLoader__.load({
588
669
  area.value = controller.editorDraft
589
670
  area.disabled = view.pending || view.confirming
590
671
  area.spellcheck = false
591
- area.setAttribute('aria-label', t('editor.title'))
592
- if (!view.confirming) area.placeholder = t('editor.placeholder')
672
+ area.setAttribute('aria-label', texts.title)
673
+ if (!view.confirming) area.placeholder = texts.placeholder
593
674
  area.addEventListener('input', () => controller.setDraft(area.value))
594
675
  box.appendChild(area)
595
676
 
596
677
  const note = document.createElement('p')
597
678
  note.className = 'dshet-note'
598
- note.textContent = view.confirming ? t('confirm.body') : t('editor.hint')
679
+ note.textContent = view.confirming ? t('confirm.body') : texts.hint
599
680
  box.appendChild(note)
600
681
 
601
682
  if (target.attachments > 0) {
602
683
  const warn = document.createElement('p')
603
684
  warn.className = 'dshet-warn'
604
- warn.textContent = t('editor.warn.attachments')
685
+ warn.textContent = texts.warn
605
686
  box.appendChild(warn)
606
687
  }
607
688
  if (typeof view.failure === 'string' && view.failure !== '') {
@@ -634,7 +715,7 @@ window.__ModuleLoader__.load({
634
715
  ? t('editor.pending')
635
716
  : view.confirming
636
717
  ? t('editor.review')
637
- : t('editor.save')
718
+ : texts.save
638
719
  submit.disabled = view.pending
639
720
  submit.onclick = (event) => {
640
721
  event.preventDefault()
@@ -685,15 +766,63 @@ window.__ModuleLoader__.load({
685
766
  if (!node) continue
686
767
  const hidden = isRowHidden(view.hidden, seqsFor(node))
687
768
  setRowHidden(row, hidden, animate)
688
- const target = hidden ? null : targetFor(node, view.editable, view.hidden)
689
- if (target !== null) injectRowAction(row, target, controller, t)
690
- else removeRowAction(row)
769
+ const target = hidden ? null : targetFor(node, view)
770
+ if (target !== null) {
771
+ injectRowAction(row, target, controller, t)
772
+ } else if (!hidden && node.kind === 'turn-tail') {
773
+ // A reply's entry belongs in the host's own action strip, and that is
774
+ // where it is registered. A slot entry that never renders leaves no
775
+ // trace at all, though - no error, no gap - so the same pencil is also
776
+ // offered through the turn tail's own strip, and only while that strip
777
+ // does not already carry one. Both land in the platform's own bar, so
778
+ // the position is identical either way.
779
+ const covered = seqsFor(node).find((seq) => view.replies.has(seq) && !view.hidden.has(seq))
780
+ // The strip lives inside the turn-tail root (`data-turn-tail`) as its
781
+ // LAST direct child, while the row carrying the flow key may be a
782
+ // wrapper around that root. Bounding the search by the tail root keeps
783
+ // the tool-call bars inside the tail out of reach: those unmount as a
784
+ // call expands, so a pencil parked there blinks, and it is not where
785
+ // the reply's actions live anyway.
786
+ const tailRoot = rootOfTurnTail(row)
787
+ const bar =
788
+ covered === undefined || tailRoot === null
789
+ ? undefined
790
+ : Array.from(tailRoot.children)
791
+ .reverse()
792
+ .find((child) => typeof child.className === 'string' && child.className.includes('_actions'))
793
+ const slotPencil = bar === undefined ? null : bar.querySelector('.dshet-row-action:not([data-dshet-fallback])')
794
+ if (covered !== undefined && bar !== undefined && slotPencil === null) {
795
+ const entry = view.replies.get(covered)
796
+ injectRowAction(
797
+ row,
798
+ {
799
+ seq: covered,
800
+ mode: 'reply',
801
+ turn: entry.turn,
802
+ messageId: entry.messageId,
803
+ text: typeof entry.text === 'string' ? entry.text : '',
804
+ attachments: typeof entry.attachments === 'number' ? entry.attachments : 0,
805
+ },
806
+ controller,
807
+ t,
808
+ { fallback: true, anchor: bar },
809
+ )
810
+ } else {
811
+ // Either the strip's own entry is already there, or this row holds
812
+ // nothing editable: any spare pencil of ours has to go.
813
+ removeRowAction(row)
814
+ }
815
+ } else {
816
+ removeRowAction(row)
817
+ }
818
+ // The editor anchors to whichever row covers the seq being edited, not to
819
+ // the row that owns the entry: a reply's entry now lives in the turn-tail
820
+ // action strip, while its editor still belongs under the reply text.
691
821
  if (
692
822
  !hidden &&
693
823
  view.editing !== null &&
694
824
  typeof view.editing.seq === 'number' &&
695
- target !== null &&
696
- target.seq === view.editing.seq
825
+ seqsFor(node).includes(view.editing.seq)
697
826
  ) {
698
827
  renderEditor(row, controller, view, t)
699
828
  }
@@ -701,6 +830,40 @@ window.__ModuleLoader__.load({
701
830
  if (view.editing === null) clearEditor()
702
831
  }
703
832
 
833
+ // The reply edit entry, registered in the host's own assistant-actions strip.
834
+ //
835
+ // It has to live there rather than be injected next to the message: the host
836
+ // renders that strip per assistant message, hands the entry the messageId, and
837
+ // keeps the platform's own reveal behaviour (always for the latest turn, hover
838
+ // for older ones). Injecting instead put the pencil in a different place from
839
+ // where every other action for that row lives, which is what users noticed.
840
+ function ReplyActionEntry({ messageId, controller, useEditTurn, t }) {
841
+ const view = useEditTurn((state) => state)
842
+ const entry = view.repliesByMessage.get(messageId)
843
+ if (entry === undefined || view.hidden.has(entry.seq)) return null
844
+ const label = t('action.edit-reply')
845
+ const open = (event) => {
846
+ event.preventDefault()
847
+ event.stopPropagation()
848
+ controller.open({
849
+ seq: entry.seq,
850
+ mode: 'reply',
851
+ turn: entry.turn,
852
+ messageId: entry.messageId,
853
+ text: typeof entry.text === 'string' ? entry.text : '',
854
+ attachments: typeof entry.attachments === 'number' ? entry.attachments : 0,
855
+ })
856
+ }
857
+ return jsx('button', {
858
+ type: 'button',
859
+ className: 'dshet-action dshet-row-action',
860
+ 'aria-label': label,
861
+ title: label,
862
+ onClick: open,
863
+ children: jsx(PencilIcon, null),
864
+ })
865
+ }
866
+
704
867
  // --- react entries --------------------------------------------------------
705
868
 
706
869
  function OverlayEntry({ useChat, useEditTurn, controller, t }) {
@@ -767,6 +930,25 @@ window.__ModuleLoader__.load({
767
930
  'dsh-edit-turn: per-session controllers',
768
931
  )
769
932
 
933
+ // A reply's edit entry lives in the official assistant-actions strip, one
934
+ // entry per assistant message. order 5 puts it ahead of the feedback
935
+ // entries (order 10), so the pencil sits with the leading icons.
936
+ ctx.slots.inject('conversation.chat.assistant-actions', () =>
937
+ ctx.slots.register(
938
+ {
939
+ name: 'conversation.chat.assistant-actions',
940
+ id: 'edit-turn-reply',
941
+ order: 5,
942
+ locale: NS,
943
+ inject: (sessionId) => ({
944
+ hooks: { editTurn: controllerFor(sessionId) },
945
+ controller: controllerFor(sessionId),
946
+ }),
947
+ },
948
+ ReplyActionEntry,
949
+ ),
950
+ )
951
+
770
952
  ctx.slots.inject('conversation.input.overlay', () =>
771
953
  ctx.slots.register(
772
954
  {
package/lib/index.js CHANGED
@@ -48,7 +48,24 @@ export const PLUGIN_ID = 'dsh-edit-turn'
48
48
  export const name = PLUGIN_ID
49
49
 
50
50
  /** Keep in sync with package.json and lib/client.js. */
51
- export const PLUGIN_VERSION = '0.1.1'
51
+ export const PLUGIN_VERSION = '0.2.1'
52
+
53
+ // A ring of the last few requests, answering "did the browser half even ask, and
54
+ // what did it get?" without needing its console. Read-only diagnostics: the
55
+ // browser half's own state is otherwise invisible from the host side.
56
+ const REQUEST_LOG_LIMIT = 40
57
+ const requestLog = []
58
+
59
+ /** Record one request for the diagnostic route. */
60
+ export function noteRequest(entry) {
61
+ requestLog.push({ at: Date.now(), ...entry })
62
+ if (requestLog.length > REQUEST_LOG_LIMIT) requestLog.shift()
63
+ }
64
+
65
+ /** The recorded requests, oldest first. */
66
+ export function recentRequests() {
67
+ return requestLog.slice()
68
+ }
52
69
 
53
70
  const ROUTE_PREFIX = '/dsh-edit-turn'
54
71
  const SESSION_ID_RE = /^(session-)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
@@ -219,6 +236,23 @@ export function isHumanPrompt(event) {
219
236
  return Boolean(data && data.source && data.source.kind === 'user')
220
237
  }
221
238
 
239
+ /**
240
+ * Whether an event is a reply the model produced.
241
+ *
242
+ * Editing one of these is a different operation from editing a prompt: the
243
+ * official append contract refuses to let an `assistant/message` carry
244
+ * `sourceEventSeqs` ("assistant/message embeds its source stream"), so a reply
245
+ * can never be the replacement carrier of a rollback. The rollback is therefore
246
+ * followed by appending the corrected text as a new assistant message, and the
247
+ * model goes on seeing it as its own answer.
248
+ *
249
+ * @param event - raw session event.
250
+ * @returns true for an assistant reply.
251
+ */
252
+ export function isAssistantReply(event) {
253
+ return Boolean(event) && event.type === 'assistant/message'
254
+ }
255
+
222
256
  /**
223
257
  * Read the plain text of a message payload, plus how many non-text parts it
224
258
  * carries. Editing is text-only, so a caller must be told when a rewrite would
@@ -228,7 +262,11 @@ export function isHumanPrompt(event) {
228
262
  */
229
263
  export function readMessageText(event) {
230
264
  const data = (event && event.data) || {}
231
- const blocks = Array.isArray(data.content) ? data.content : []
265
+ // `user/message` carries its message fields on `data`; the other three surface
266
+ // types nest them under `data.message`. Both shapes have to read the same way
267
+ // now that the model's replies are editable too.
268
+ const message = data.message && typeof data.message === 'object' ? data.message : data
269
+ const blocks = Array.isArray(message.content) ? message.content : []
232
270
  let text = ''
233
271
  let attachments = 0
234
272
  for (const block of blocks) {
@@ -260,7 +298,7 @@ export function rollbackLedger(events) {
260
298
  const event = bySeq.get(replacement.seq)
261
299
  const data = event && event.data
262
300
  const source = data && (data.source || (data.message && data.message.source))
263
- if (!source || source.kind !== 'plugin' || source.plugin !== PLUGIN_ID) continue
301
+ if (!(source && (source.kind === `plugin:${PLUGIN_ID}` || (source.kind === 'plugin' && source.plugin === PLUGIN_ID)))) continue
264
302
  for (const seq of replacement.shadowed) {
265
303
  const turn = turnOf.get(seq)
266
304
  hidden.push({ seq, turn: typeof turn === 'number' ? turn : null, replacement: replacement.seq })
@@ -336,8 +374,8 @@ function resolveTarget(events, request) {
336
374
  *
337
375
  * @param events - complete contiguous raw event log.
338
376
  * @param surfaceNodes - current surface seqs in model order.
339
- * @param request - `{ seq?, messageId?, turn? }`.
340
- * @returns `{ targetSeq, startSeq, endSeq, shadowed, turn, original, attachments }`.
377
+ * @param request - `{ seq?, messageId?, turn? }` (`turn` addresses a prompt only).
378
+ * @returns `{ targetSeq, messageId, startSeq, endSeq, shadowed, mode, turn, step, provider, model, original, attachments }`.
341
379
  * @throws {EditPlanError} with a stable code when the target cannot be planned.
342
380
  */
343
381
  export function planRollback(events, surfaceNodes, request) {
@@ -352,8 +390,11 @@ export function planRollback(events, surfaceNodes, request) {
352
390
  if (target.seq === surfaceNodes[0]) {
353
391
  throw new EditPlanError('not-editable', 'the system prompt head cannot be edited')
354
392
  }
355
- if (!isHumanPrompt(target)) {
356
- throw new EditPlanError('not-editable', 'only messages you typed can be edited and re-run')
393
+ // Two editable kinds, two outcomes: rewriting a prompt re-runs the turn;
394
+ // rewriting a reply replaces the answer and lets the conversation continue.
395
+ const mode = isHumanPrompt(target) ? 'prompt' : isAssistantReply(target) ? 'reply' : null
396
+ if (mode === null) {
397
+ throw new EditPlanError('not-editable', 'only your own messages and the model replies can be edited')
357
398
  }
358
399
  const startIdx = nodeIndex.get(target.seq)
359
400
  const shadowed = surfaceNodes.slice(startIdx)
@@ -362,13 +403,22 @@ export function planRollback(events, surfaceNodes, request) {
362
403
  }
363
404
  const { text, attachments } = readMessageText(target)
364
405
  const turn = turnOf.get(target.seq)
406
+ const data = target.data && typeof target.data === 'object' ? target.data : {}
407
+ const message = data.message && typeof data.message === 'object' ? data.message : data
408
+ const source = message.source && typeof message.source === 'object' ? message.source : null
365
409
  return {
366
410
  targetSeq: target.seq,
367
411
  messageId: messageIdOf(target),
368
412
  startSeq: shadowed[0],
369
413
  endSeq: shadowed[shadowed.length - 1],
370
414
  shadowed,
415
+ mode,
371
416
  turn: typeof turn === 'number' ? turn : null,
417
+ // The appended correction is keyed by turn:step in the client's reducer, so a
418
+ // missing step would make two corrections collide on "undefined:undefined".
419
+ step: typeof data.step === 'number' ? data.step : 1,
420
+ provider: source && typeof source.provider === 'string' ? source.provider : null,
421
+ model: source && typeof source.model === 'string' ? source.model : null,
372
422
  original: text,
373
423
  attachments,
374
424
  }
@@ -407,6 +457,42 @@ export function editableTurns(events, surfaceNodes) {
407
457
  return out
408
458
  }
409
459
 
460
+ /**
461
+ * Every model reply that can currently be rewritten.
462
+ *
463
+ * Replies with no text at all are skipped: a step that only produced tool calls
464
+ * has nothing to put in a text editor. A reply that carries tool calls *and*
465
+ * text is offered, because replacing it drops those calls along with it - the
466
+ * client warns about that, and the calls cannot survive a rewrite anyway.
467
+ *
468
+ * @param events - complete contiguous raw event log.
469
+ * @param surfaceNodes - current surface seqs in model order.
470
+ * @returns one entry per editable reply, in conversation order.
471
+ */
472
+ export function editableReplies(events, surfaceNodes) {
473
+ const nodeIndex = new Map(surfaceNodes.map((seq, index) => [seq, index]))
474
+ const turnOf = turnIndex(events)
475
+ const head = surfaceNodes[0]
476
+ const out = []
477
+ for (const event of events) {
478
+ if (!isAssistantReply(event)) continue
479
+ if (!nodeIndex.has(event.seq)) continue
480
+ if (event.seq === head) continue
481
+ const { text, attachments } = readMessageText(event)
482
+ if (text.trim() === '') continue
483
+ const turn = turnOf.get(event.seq)
484
+ out.push({
485
+ seq: event.seq,
486
+ turn: typeof turn === 'number' ? turn : null,
487
+ messageId: messageIdOf(event) ?? null,
488
+ text,
489
+ // For a reply these are the non-text blocks: tool calls and reasoning.
490
+ attachments,
491
+ })
492
+ }
493
+ return out
494
+ }
495
+
410
496
  // ---------------------------------------------------------------------------
411
497
  // host service plumbing
412
498
  // ---------------------------------------------------------------------------
@@ -526,15 +612,31 @@ async function flushSession(ctx, session) {
526
612
 
527
613
  async function stateOf(ctx, sessionId, config) {
528
614
  const events = await readEvents(ctx, sessionId)
529
- if (!events) throw new HttpError(404, 'session-not-found', 'no session log for this id')
615
+ if (!events) {
616
+ noteRequest({ kind: 'state', sessionId, ok: false, code: 'session-not-found' })
617
+ throw new HttpError(404, 'session-not-found', 'no session log for this id')
618
+ }
530
619
  const folded = foldSurface(events)
531
620
  const ledger = rollbackLedger(events)
532
621
  const live = findLiveSession(ctx, sessionId)
622
+ const turns = editableTurns(events, folded.nodes)
623
+ const replies = editableReplies(events, folded.nodes)
624
+ noteRequest({
625
+ kind: 'state',
626
+ sessionId,
627
+ ok: true,
628
+ events: events.length,
629
+ turns: turns.length,
630
+ replies: replies.length,
631
+ hidden: ledger.hidden.length,
632
+ live: Boolean(live),
633
+ })
533
634
  return {
534
635
  hidden: ledger.hidden,
535
636
  edits: ledger.edits.length,
536
637
  surface: folded.nodes,
537
- turns: editableTurns(events, folded.nodes),
638
+ turns,
639
+ replies,
538
640
  live: Boolean(live),
539
641
  busy: isBusy(events),
540
642
  lastSeq: events.length > 0 ? events[events.length - 1].seq : -1,
@@ -564,7 +666,7 @@ export function buildCarrier(plan, lastTurn, config) {
564
666
  id: randomUUID(),
565
667
  role: 'system',
566
668
  content: [],
567
- source: { kind: 'plugin', plugin: PLUGIN_ID },
669
+ source: { kind: `plugin:${PLUGIN_ID}` },
568
670
  },
569
671
  },
570
672
  }
@@ -575,7 +677,43 @@ export function buildCarrier(plan, lastTurn, config) {
575
677
  id: randomUUID(),
576
678
  role: 'user',
577
679
  content: [{ type: 'text', text: config.markerText }],
578
- source: { kind: 'plugin', plugin: PLUGIN_ID },
680
+ source: { kind: `plugin:${PLUGIN_ID}` },
681
+ },
682
+ }
683
+ }
684
+
685
+ /**
686
+ * The new assistant message that carries an edited reply.
687
+ *
688
+ * A reply cannot be swapped in place - the validator refuses `sourceEventSeqs`
689
+ * on an `assistant/message` - so the correction is appended as a fresh reply.
690
+ * `turn`/`step` are always set because the client keys a reply node by
691
+ * `turn:step`; leaving them out would make two corrections collide.
692
+ *
693
+ * `source.kind` stays `model` so the entry renders and projects exactly like an
694
+ * ordinary reply, while `editedBy` records honestly that the text was written by
695
+ * the plugin and not produced by the model.
696
+ *
697
+ * @param plan - the planned window, for the original's turn/step/provider.
698
+ * @param text - the replacement text.
699
+ * @returns `{ type, data }` for `session.append`, to be marked `surfaceOp: 'append'`.
700
+ */
701
+ export function buildCorrection(plan, text) {
702
+ const source = { kind: 'model', editedBy: PLUGIN_ID }
703
+ if (typeof plan.provider === 'string' && plan.provider !== '') source.provider = plan.provider
704
+ if (typeof plan.model === 'string' && plan.model !== '') source.model = plan.model
705
+ return {
706
+ type: 'assistant/message',
707
+ data: {
708
+ turn: typeof plan.turn === 'number' ? plan.turn : 0,
709
+ step: typeof plan.step === 'number' ? plan.step : 1,
710
+ message: {
711
+ id: randomUUID(),
712
+ role: 'assistant',
713
+ content: [{ type: 'text', text }],
714
+ source,
715
+ },
716
+ stream: [],
579
717
  },
580
718
  }
581
719
  }
@@ -632,11 +770,54 @@ async function applyEdit(ctx, sessionId, body, config) {
632
770
  }
633
771
  const flush = await flushSession(ctx, session)
634
772
 
635
- // Step two: admit the revised prompt. This is the only official prompt path,
636
- // so it also resumes a cold Session and starts exactly one new turn.
773
+ // Step two depends on what was edited.
774
+ //
775
+ // Rewriting a reply cannot swap the text in place - an `assistant/message` may
776
+ // never carry `sourceEventSeqs`, so it can never be a replacement carrier
777
+ // (checked against the real validator, not assumed). The correction is
778
+ // appended instead, in the same shape the agent loop records a reply, so the
779
+ // model goes on seeing the edited text as its own answer and the conversation
780
+ // continues from there. Nothing is re-run: the answer was fixed, the model was
781
+ // not asked again.
782
+ if (plan.mode === 'reply') {
783
+ const correction = buildCorrection(plan, text)
784
+ let appended
785
+ try {
786
+ appended = session.append(correction.type, correction.data, { surfaceOp: 'append' })
787
+ // Close the turn again, now that the corrected reply is the last thing in
788
+ // it. The host anchors a turn's tail (its duration and action strip) at the
789
+ // turn's LAST `turn/end`, so a correction appended after the old one lands
790
+ // OUTSIDE the turn and the tail renders above the corrected text. These two
791
+ // events put the tail back behind it, exactly as a normal turn ends.
792
+ session.append('step/end', { turn: appended.data.turn, step: appended.data.step })
793
+ session.append('turn/end', { turn: appended.data.turn, reason: { kind: 'completed' } })
794
+ } catch (error) {
795
+ return {
796
+ kind: 'reply',
797
+ replacementSeq: replacement.seq,
798
+ applied: false,
799
+ applyError: `the corrected reply was refused: ${String((error && error.message) || error)}`,
800
+ shadowed: plan.shadowed,
801
+ ...flush,
802
+ }
803
+ }
804
+ const corrected = await flushSession(ctx, session)
805
+ return {
806
+ kind: 'reply',
807
+ replacementSeq: replacement.seq,
808
+ appendedSeq: appended.seq,
809
+ applied: true,
810
+ shadowed: plan.shadowed,
811
+ ...corrected,
812
+ }
813
+ }
814
+
815
+ // A rewritten prompt is re-run through the only official prompt path, which
816
+ // also resumes a cold Session and starts exactly one new turn.
637
817
  const controller = ctx.get('sessionController')
638
818
  if (!controller || typeof controller.prompt !== 'function') {
639
819
  return {
820
+ kind: 'prompt',
640
821
  replacementSeq: replacement.seq,
641
822
  promptAccepted: false,
642
823
  promptError: 'sessionController.prompt unavailable',
@@ -656,6 +837,7 @@ async function applyEdit(ctx, sessionId, body, config) {
656
837
  )
657
838
  } catch (error) {
658
839
  return {
840
+ kind: 'prompt',
659
841
  replacementSeq: replacement.seq,
660
842
  promptAccepted: false,
661
843
  promptError: String((error && error.message) || error),
@@ -665,6 +847,7 @@ async function applyEdit(ctx, sessionId, body, config) {
665
847
  }
666
848
 
667
849
  return {
850
+ kind: 'prompt',
668
851
  replacementSeq: replacement.seq,
669
852
  promptAccepted: true,
670
853
  shadowed: plan.shadowed,
@@ -716,16 +899,24 @@ function registerTool(ctx) {
716
899
  if (!events) throw new Error(`no session log for ${sessionId}`)
717
900
  const nodes = surfaceOf(ctx, sessionId, events)
718
901
  const turns = editableTurns(events, nodes)
902
+ const replies = editableReplies(events, nodes)
719
903
  const limit = typeof args.limit === 'number' && args.limit > 0 ? Math.floor(args.limit) : 20
720
904
  const shown = turns.slice(-limit)
905
+ const shownReplies = replies.slice(-limit)
721
906
  const lines = [
722
- `session ${sessionId}: ${turns.length} editable turn(s), ${isBusy(events) ? 'BUSY' : 'idle'}`,
907
+ `session ${sessionId}: ${turns.length} editable turn(s), ${replies.length} editable reply/replies, ${isBusy(events) ? 'BUSY' : 'idle'}`,
723
908
  ]
724
909
  for (const turn of shown) {
725
910
  const preview = turn.text.length > 160 ? `${turn.text.slice(0, 160)}...` : turn.text
726
911
  lines.push(`- turn ${turn.turn} (seq ${turn.seq}, ${turn.attachments} attachment(s)): ${preview}`)
727
912
  }
728
913
  if (turns.length > shown.length) lines.push(`(${turns.length - shown.length} earlier turn(s) omitted)`)
914
+ for (const reply of shownReplies) {
915
+ const preview = reply.text.length > 160 ? `${reply.text.slice(0, 160)}...` : reply.text
916
+ lines.push(`- reply (seq ${reply.seq}, ${reply.attachments} non-text part(s)): ${preview}`)
917
+ }
918
+ if (replies.length > shownReplies.length) lines.push(`(${replies.length - shownReplies.length} earlier reply/replies omitted)`)
919
+ lines.push('editing a turn re-runs it; editing a reply replaces its text and discards what follows.')
729
920
  return lines.join('\n')
730
921
  },
731
922
  }),
@@ -861,6 +1052,24 @@ export function apply(ctx, config) {
861
1052
  }),
862
1053
  )
863
1054
 
1055
+ // Read-only diagnostics. The browser half's own state cannot be observed
1056
+ // from here, so the next best thing is an honest record of what its requests
1057
+ // asked for and what they got back.
1058
+ fiber.effect(() =>
1059
+ webServer.register({
1060
+ kind: 'exact',
1061
+ path: `${ROUTE_PREFIX}/debug`,
1062
+ handler: (req, res) => {
1063
+ if (!guard(req, res)) return
1064
+ if (req.method !== 'GET') {
1065
+ sendJson(res, 405, { ok: false, code: 'method', error: 'GET only' })
1066
+ return
1067
+ }
1068
+ sendJson(res, 200, { ok: true, version: PLUGIN_VERSION, requests: recentRequests() })
1069
+ },
1070
+ }),
1071
+ )
1072
+
864
1073
  fiber.effect(() =>
865
1074
  webServer.register({
866
1075
  kind: 'exact',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-edit-turn",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
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.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -39,6 +39,7 @@
39
39
  "verify:client": "node tools/verify-client-static.mjs",
40
40
  "verify:live": "node tools/verify-live-client.mjs",
41
41
  "verify:profile": "bash tools/verify-dsh-plugin.sh",
42
+ "probe:loaded": "node tools/probe-loaded.mjs",
42
43
  "check": "npm run test && npm run verify:contract && npm run verify:client"
43
44
  },
44
45
  "dependencies": {