@240xu/dsh-message-ops 0.5.0 → 0.5.2

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
@@ -237,3 +237,28 @@ MIT
237
237
  (`> ` 前缀)插入输入框,零 DOM hack。
238
238
  - 槽按钮收敛为两个:**⏱ 回撤到此** 与 **❝ 引用**;删除/分支保留在对话框
239
239
  (官方分支按钮已覆盖最后一条消息场景)。
240
+
241
+ ### 0.5.1 · 修复 0.5.0 客户端激活失败
242
+
243
+ - 0.4.2 的 dock 重写误删了 `INPUT_DOCK_SLOT/ID` 常量声明——apply() 引用未定义
244
+ 标识符导致**整个客户端激活失败**(页面横幅 Failed to load plugins)。
245
+ 已恢复常量;新增 vm 冒烟自测路径(见 .e2e/)。
246
+
247
+ ## 0.5.2 · Bug 猎场第一轮修复(P0×1 + P1×3 + P2×6)
248
+
249
+ - **[P0] activeMarkers 被 0.5.0 误删**(与 INPUT_DOCK 同一 commit,0.5.1 只找回常量):
250
+ dock 100% 静默失效(ReferenceError 被 .catch 吞掉)。已恢复函数 + vm 冒烟守卫。
251
+ - **[P1] 工具层未 await async ops**(0.2.1 改 async 后漏同步化):list/restore 必抛
252
+ TypeError、export 渲出空 text。switch 全部 await + 回归测试(异步桩)。
253
+ - **[P1] 引用空块**:assistant 消息 fullText 恒 null,回退 `''` 通过 null 检查 →
254
+ 插入空 `> ` 块并报成功。改为按 seq 走新端点 `GET /text` 取单条全文,空串按不可用处理。
255
+ - **[P1] 侧栏菜单卡死**:dispatch 缺 sessionId → 对话框永久「正在读取…」。
256
+ 从 data-row-key 提取 sessionId 一并派发。
257
+ - **[P2] seq 索引缓存投毒**:失败必须 `delete`,否则该会话按钮/dock 到刷新前全失效。
258
+ - **[P2] 索引口径**:同 id 取最早 seq(对齐服务端 buildSeqIndex,不再内联取最大)。
259
+ - **[P2] branch 工具渲染**:读 keptEvents/parentSession(原读不存在的 kept/parentId)。
260
+ - **[P2] dock 标题口径**:改报标记 range 内被遮蔽消息数(原报标记数,严重低报)。
261
+ - **[P2] busy 卡死**:runRevert/restoreRow/对话框 run 全部 `Promise.resolve()` 包裹——
262
+ 同步抛出也落进 finally;restoreRow 补 finally 双保险。
263
+ - **[P2] dock 陈旧**:新增 `dsh-message-ops:changed` 广播,回滚/恢复成功后 dock 自动重拉。
264
+ - **[P3] 删 en 独有死键 dock.shadowedN;GET 端点补 405 方法校验。**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@240xu/dsh-message-ops",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "Roll back, branch, and export any DSH conversation — from the UI or straight from the agent via a message_ops tool. Non-destructive: logs stay append-only and recoverable. 消息回滚/删除/分支/导出四合一,内置 message_ops agent 工具;非破坏语义,日志只追加可恢复。",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/client.js CHANGED
@@ -22,8 +22,13 @@ window.__ModuleLoader__.load({
22
22
  const SLOT = 'conversation.session.header.actions'
23
23
  const ROW_ID = 'message-ops'
24
24
  const OVERLAY_SLOT = 'shell.overlay'
25
+ const INPUT_DOCK_SLOT = 'conversation.input.dock'
26
+ const INPUT_DOCK_ID = 'message-ops-revert-dock'
25
27
  const DIALOG_ID = 'message-ops-dialog'
26
28
  const EVENT = 'dsh-message-ops:open'
29
+ // 0.5.2(P2):回滚/恢复成功后广播 → RevertDock 重拉标记(否则 dock 只在切会话时刷新)
30
+ const CHANGED_EVENT = 'dsh-message-ops:changed'
31
+ const emitChanged = () => { try { window.dispatchEvent(new CustomEvent(CHANGED_EVENT)) } catch { /* noop */ } }
27
32
  const NS = 'dsh-message-ops'
28
33
 
29
34
  const zhDict = {
@@ -384,11 +389,12 @@ window.__ModuleLoader__.load({
384
389
  }
385
390
  const path = mode === 'restore' ? 'restore' : mode
386
391
  const body = { sessionId: target.sessionId, seq: picked }
387
- fetch('/api/message-ops/' + path, {
388
- method: 'POST',
389
- headers: { 'content-type': 'application/json' },
390
- body: JSON.stringify(body),
391
- })
392
+ Promise.resolve()
393
+ .then(() => fetch('/api/message-ops/' + path, {
394
+ method: 'POST',
395
+ headers: { 'content-type': 'application/json' },
396
+ body: JSON.stringify(body),
397
+ }))
392
398
  .then(async (res) => {
393
399
  let data = {}
394
400
  try { data = await res.json() } catch { /* keep {} */ }
@@ -408,6 +414,7 @@ window.__ModuleLoader__.load({
408
414
  setBusyMsg('')
409
415
  setState('done')
410
416
  notifyDone(okMsg)
417
+ emitChanged()
411
418
  })
412
419
  .catch((reason) => {
413
420
  setBusyMsg('')
@@ -639,12 +646,14 @@ window.__ModuleLoader__.load({
639
646
  else if (cached) resolve(cached)
640
647
  else {
641
648
  const p = fetch('/api/message-ops/messages?sessionId=' + encodeURIComponent(sessionId))
642
- .then((r) => (r.ok ? r.json() : { messages: [] }))
649
+ .then((r) => (r.ok ? r.json() : Promise.reject(new Error('HTTP ' + r.status))))
643
650
  .then((data) => {
644
651
  const index = new Map()
645
652
  const full = new Map()
646
653
  for (const m of (data && data.messages) || []) {
647
- if (m && m.id != null) index.set(String(m.id), m.seq)
654
+ // 0.5.2(P2):同 id 取**最早** seq——与服务端 buildSeqIndex 口径一致
655
+ // (重试链取最早可见节点;此前内联 set 覆盖=取最大,双实现分裂)
656
+ if (m && m.id != null && !index.has(String(m.id))) index.set(String(m.id), m.seq)
648
657
  if (m && m.id != null && m.fullText) full.set(String(m.id), m.fullText)
649
658
  }
650
659
  __seqIndexCache.set(sessionId, index)
@@ -652,7 +661,9 @@ window.__ModuleLoader__.load({
652
661
  return index
653
662
  })
654
663
  __seqIndexCache.set(sessionId, p)
655
- p.then(resolve).catch(() => {})
664
+ // 0.5.2(P2):失败必须清缓存——否则 rejected promise 被永久缓存,
665
+ // 该会话按钮/dock 到刷新页面为止全部失效
666
+ p.then(resolve).catch(() => { __seqIndexCache.delete(sessionId) })
656
667
  }
657
668
  return () => { alive = false }
658
669
  }, [messageId, sessionId])
@@ -687,16 +698,20 @@ window.__ModuleLoader__.load({
687
698
  const runRevert = () => {
688
699
  if (busy || seq == null || running) return
689
700
  setBusy('revert')
690
- fetch('/api/message-ops/revert', {
691
- method: 'POST', headers: { 'content-type': 'application/json' },
692
- body: JSON.stringify({ sessionId, seq }),
693
- })
701
+ // 0.5.2(P2):Promise.resolve 包裹——fetch/stringify 同步抛出也转为
702
+ // rejection 落进 finally,busy 永不卡死
703
+ Promise.resolve()
704
+ .then(() => fetch('/api/message-ops/revert', {
705
+ method: 'POST', headers: { 'content-type': 'application/json' },
706
+ body: JSON.stringify({ sessionId, seq }),
707
+ }))
694
708
  .then(async (res) => {
695
709
  let data = {}
696
710
  try { data = await res.json() } catch { /* keep {} */ }
697
711
  if (!res.ok || !data.ok) throw new Error(data.error || ('HTTP ' + res.status))
698
712
  notifyDone(t('done.revert'))
699
713
  revealSession()
714
+ emitChanged()
700
715
  })
701
716
  .catch((reason) => {
702
717
  notifyDone(t('errorPrefix') + (reason && reason.message ? reason.message : String(reason)), 'error')
@@ -712,11 +727,18 @@ window.__ModuleLoader__.load({
712
727
  return
713
728
  }
714
729
  const span = inputActions.captureInsertion()
715
- const text = fullText != null ? fullText : (summary && summary.title ? '' : null)
716
- if (text == null) { notifyDone(t('quote.unavailable'), 'warn'); return }
717
- const quoted = text.split('\n').map((l) => '> ' + l).join('\n') + '\n\n'
718
- const ok = inputActions.insertText(quoted, span)
719
- notifyDone(ok ? t('quote.done') : t('quote.unavailable'), ok ? 'ok' : 'warn')
730
+ const insertQuote = (text) => {
731
+ if (text == null || text === '') { notifyDone(t('quote.unavailable'), 'warn'); return }
732
+ const quoted = text.split('\n').map((l) => '> ' + l).join('\n') + '\n\n'
733
+ const ok = inputActions.insertText(quoted, span)
734
+ notifyDone(ok ? t('quote.done') : t('quote.unavailable'), ok ? 'ok' : 'warn')
735
+ }
736
+ // assistant 消息 fullText 恒 null(列表不携带)→ 按 seq 向服务端取单条全文
737
+ if (fullText != null && fullText !== '') { insertQuote(fullText); return }
738
+ fetch('/api/message-ops/text?sessionId=' + encodeURIComponent(sessionId) + '&seq=' + encodeURIComponent(seq))
739
+ .then((r) => (r.ok ? r.json() : null))
740
+ .then((d) => insertQuote(d && d.ok && typeof d.text === 'string' ? d.text : null))
741
+ .catch(() => insertQuote(null))
720
742
  } catch (e) {
721
743
  notifyDone(t('errorPrefix') + (e && e.message ? e.message : String(e)), 'error')
722
744
  }
@@ -738,6 +760,16 @@ window.__ModuleLoader__.load({
738
760
  )
739
761
  }
740
762
 
763
+ // 0.4.2 引入(恢复的标记自动离开活跃列表 = opencode clear 语义)。
764
+ // 0.5.0 脚本化编辑曾误删本函数 → dock 100% 静默失效(P0,0.5.2 恢复)。
765
+ function activeMarkers(msgs) {
766
+ const restored = new Set()
767
+ for (const m of msgs || []) {
768
+ if (m && typeof m.restoresSeq === 'number') restored.add(m.restoresSeq)
769
+ }
770
+ return (msgs || []).filter((m) => m.marker && m.sourceKind !== 'compact-checkpoint' && !restored.has(m.seq))
771
+ }
772
+
741
773
  function RevertDock(props) {
742
774
  const { sessionId, useSessions, inputActions } = props
743
775
  const t = (props && props.t) || __t
@@ -748,6 +780,7 @@ window.__ModuleLoader__.load({
748
780
  const running = summary ? summary.running === true : false
749
781
  const [open, setOpen] = useState(false)
750
782
  const [markers, setMarkers] = useState(null) // 活跃回撤标记(已恢复的自动消失)
783
+ const [shadowCount, setShadowCount] = useState(0) // 标记 range 内被遮蔽消息数(标题口径)
751
784
  const [restoring, setRestoring] = useState(null)
752
785
  const [styleInjected, setStyleInjected] = useState(false)
753
786
 
@@ -782,15 +815,28 @@ window.__ModuleLoader__.load({
782
815
 
783
816
  useEffect(() => {
784
817
  let alive = true
818
+ const onChange = () => load()
819
+ window.addEventListener(CHANGED_EVENT, onChange)
785
820
  const load = () => fetch('/api/message-ops/messages?sessionId=' + encodeURIComponent(sessionId))
786
821
  .then((r) => (r.ok ? r.json() : null))
787
822
  .then((data) => {
788
823
  if (!alive || !data || !data.ok) return
789
- setMarkers(activeMarkers(data.messages || []))
824
+ const msgs = data.messages || []
825
+ setMarkers(activeMarkers(msgs))
826
+ // 0.5.2(P2):标题口径 = 标记 range 内 visible=false 的去重 seq 数
827
+ const seen = new Set()
828
+ for (const mk of activeMarkers(msgs)) {
829
+ const start = mk.range && typeof mk.range.start === 'number' ? mk.range.start : mk.seq
830
+ const end = mk.range && typeof mk.range.end === 'number' ? mk.range.end : start
831
+ for (const m of msgs) {
832
+ if (m && m.visible === false && typeof m.seq === 'number' && m.seq >= start && m.seq <= end) seen.add(m.seq)
833
+ }
834
+ }
835
+ setShadowCount(seen.size)
790
836
  })
791
837
  .catch(() => {})
792
838
  load()
793
- return () => { alive = false }
839
+ return () => { alive = false; window.removeEventListener(CHANGED_EVENT, onChange) }
794
840
  }, [sessionId])
795
841
 
796
842
  // items 变化自动折叠(opencode createEffect 同款;必须在早退之前——Hooks 规则)
@@ -800,10 +846,11 @@ window.__ModuleLoader__.load({
800
846
  const restoreRow = (row) => {
801
847
  if (restoring != null || running) return
802
848
  setRestoring(row.seq)
803
- fetch('/api/message-ops/restore', {
804
- method: 'POST', headers: { 'content-type': 'application/json' },
805
- body: JSON.stringify({ sessionId, seq: row.seq }),
806
- })
849
+ Promise.resolve()
850
+ .then(() => fetch('/api/message-ops/restore', {
851
+ method: 'POST', headers: { 'content-type': 'application/json' },
852
+ body: JSON.stringify({ sessionId, seq: row.seq }),
853
+ }))
807
854
  .then(async (res) => {
808
855
  let data = {}
809
856
  try { data = await res.json() } catch { /* keep {} */ }
@@ -819,13 +866,15 @@ window.__ModuleLoader__.load({
819
866
  })
820
867
  .catch((reason) => {
821
868
  notifyDone(t('errorPrefix') + (reason && reason.message ? reason.message : String(reason)), 'error')
822
- setRestoring(null)
823
869
  })
870
+ .finally(() => setRestoring(null))
824
871
  }
825
872
 
826
873
 
827
874
  const headerKbd = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setOpen((v) => !v) } }
828
- const label = t('dock.title', { n: String(markers.length) })
875
+ // 0.5.2(P2):标题报「被遮蔽消息数」而非标记数——一个 revert 可遮蔽数百条,
876
+ // 用标记数会严重低报(0.4.2 文案引入的错口径);无 range 数据时回退标记数
877
+ const label = t('dock.title', { n: String(shadowCount || markers.length) })
829
878
  const preview = markers[0] && markers[0].snippet ? markers[0].snippet : ''
830
879
 
831
880
  const rowEl = (row) => React.createElement('div', { key: row.seq, className: 'mopsRdRow' },
@@ -897,7 +946,11 @@ window.__ModuleLoader__.load({
897
946
  var titleEl = row.querySelector('[class*=title]')
898
947
  var title = titleEl ? String(titleEl.innerText || '').trim() : ''
899
948
  if (!title) return
900
- window.dispatchEvent(new CustomEvent(EVENT, { detail: { title: title } }))
949
+ // 0.5.2(P1):必须带 sessionId——否则对话框加载 effect 早退,永久卡「正在读取…」
950
+ var sid = ''
951
+ var rk = row.getAttribute && row.getAttribute('data-row-key')
952
+ if (rk && rk.indexOf('session:') === 0) sid = rk.slice(8)
953
+ window.dispatchEvent(new CustomEvent(EVENT, { detail: { title: title, sessionId: sid || undefined } }))
901
954
  })
902
955
  var sep = document.createElement('div')
903
956
  sep.style.cssText = 'height:1px;margin:4px 8px;background:var(--dsw-alias-border-l1,rgba(128,128,128,.2))'
package/src/index.js CHANGED
@@ -20,7 +20,7 @@
20
20
  * @module dsh-message-ops
21
21
  */
22
22
 
23
- import { isSessionId, sessionIdVariants, findSessionDirs, readSessionFile, readSessionFileAsync, listMessages, computeShadowed } from "./session-file.js";
23
+ import { isSessionId, sessionIdVariants, findSessionDirs, readSessionFile, readSessionFileAsync, listMessages, computeShadowed, messageText } from "./session-file.js";
24
24
  import { applyBranch } from "./branch.js";
25
25
  import {
26
26
  OpsError, planRevert, planDelete, planRestore, applyRestore, applySurfaceReplace, exportMarkdown,
@@ -250,15 +250,20 @@ export function createMessageOpsTool({ defineTool, ops, ctx }) {
250
250
  const sessionId = String(args.sessionId || "").trim();
251
251
  try {
252
252
  let result;
253
+ // 0.5.2(P1):ops 自 0.2.1 起为 async——不 await 会把 Promise 直接送进 renderResult,
254
+ // list/restore 必抛 TypeError、export 渲出空 text(测试桩曾是同步的故漏检)。
253
255
  switch (action) {
254
- case "list": result = ops.list(ctx, sessionId); break;
255
- case "revert": result = ops.revert(ctx, sessionId, args.seq); break;
256
- case "delete": result = ops.delete(ctx, sessionId, args.seq); break;
257
- case "branch": result = ops.branch(ctx, sessionId, args.upToSeq); break;
258
- case "restore": result = ops.restore(ctx, sessionId, args.seq); break;
259
- case "export": result = ops.export(ctx, sessionId, args.seq); break;
256
+ case "list": result = await ops.list(ctx, sessionId); break;
257
+ case "revert": result = await ops.revert(ctx, sessionId, args.seq); break;
258
+ case "delete": result = await ops.delete(ctx, sessionId, args.seq); break;
259
+ case "branch": result = await ops.branch(ctx, sessionId, args.upToSeq); break;
260
+ case "restore": result = await ops.restore(ctx, sessionId, args.seq); break;
261
+ case "export": result = await ops.export(ctx, sessionId, args.seq); break;
260
262
  default: throw new OpsError(`unknown action: ${action}`, 400);
261
263
  }
264
+ if (action === "branch" && result && typeof result === "object" && result.parentSession == null) {
265
+ result.parentSession = sessionId; // renderResult 需要原始会话 id(branch 返回值不含/可为 undefined)
266
+ }
262
267
  return renderResult(action, result);
263
268
  } catch (e) {
264
269
  return `${action} failed: ${e && e.message ? e.message : e}`;
@@ -284,7 +289,7 @@ function renderResult(action, r) {
284
289
  case "delete":
285
290
  return `${r.mode} ok: shadowed ${r.shadowedCount} node(s) from seq ${r.seq}; marker event seq ${r.eventSeq}`;
286
291
  case "branch":
287
- return `branch ok: new session ${r.newId} (parent ${r.parentId ?? "n/a"}), kept ${r.kept ?? "?"} event(s)`;
292
+ return `branch ok: new session ${r.newId} (parent ${r.parentSession ?? "n/a"}), kept ${r.keptEvents ?? "?"} event(s)`;
288
293
  case "restore":
289
294
  return `restore ok: replayed ${r.restoredCount} message(s) from range ${r.range.startSeq}..${r.range.endSeq} (${r.skipped} skipped); appended event seqs ${r.eventSeqs.join(", ")}`;
290
295
  case "export":
@@ -333,6 +338,7 @@ export function apply(ctx) {
333
338
  kind: "exact",
334
339
  path: "/api/message-ops/messages",
335
340
  handler: async (req, res) => {
341
+ if (req.method !== "GET") return sendJson(res, 405, { ok: false, error: "method not allowed" });
336
342
  if (!fence(req, res)) return;
337
343
  try {
338
344
  const url = new URL(req.url, "http://localhost");
@@ -343,6 +349,31 @@ export function apply(ctx) {
343
349
  },
344
350
  }), "dsh-message-ops: messages route");
345
351
 
352
+ // --- GET /api/message-ops/text:单条消息全文(0.5.2 引用按钮按需取) ----------
353
+ // assistant 消息的 fullText 不在 messages 列表里(载荷控制),quote 点击时单取。
354
+ targetCtx.effect(() => host.register({
355
+ kind: "exact",
356
+ path: "/api/message-ops/text",
357
+ handler: async (req, res) => {
358
+ if (req.method !== "GET") return sendJson(res, 405, { ok: false, error: "method not allowed" });
359
+ if (!fence(req, res)) return;
360
+ try {
361
+ const url = new URL(req.url, "http://localhost");
362
+ const sessionId = url.searchParams.get("sessionId") || "";
363
+ const seq = Number.parseInt(url.searchParams.get("seq") || "", 10);
364
+ if (!isSessionId(sessionId)) throw new OpsError("invalid sessionId", 400);
365
+ if (!Number.isSafeInteger(seq) || seq < 0) throw new OpsError("invalid seq", 400);
366
+ const { logPath } = pickSessionDir(sessionId);
367
+ const { events } = await readSessionFileAsync(logPath);
368
+ const ev = events.find((e) => e && e.seq === seq);
369
+ if (!ev) throw new OpsError("seq not found", 404);
370
+ return sendJson(res, 200, { ok: true, seq, text: messageText(ev) });
371
+ } catch (err) {
372
+ return sendJson(res, err instanceof OpsError ? err.status : 500, { ok: false, error: String(err && err.message ? err.message : err) });
373
+ }
374
+ },
375
+ }), "dsh-message-ops: text route");
376
+
346
377
  // --- POST revert / delete:live-session surface replace ------------------
347
378
  const commitHandler = (mode) => async (req, res) => {
348
379
  if (req.method !== "POST") return sendJson(res, 405, { ok: false, error: "method not allowed" });
@@ -190,7 +190,7 @@ test('apply:无 webServer、无 tools 时走 inject 等待,不抛错', () =>
190
190
  assert.equal(ctx.registrations.length, 0)
191
191
  })
192
192
 
193
- test('apply:webServer 存在时注册全部 6 条路由;tools 缺失走 inject 等待', () => {
193
+ test('apply:webServer 存在时注册全部 7 条路由;tools 缺失走 inject 等待', () => {
194
194
  const ctx = mockCtx({ webServer: { register: (route) => ctx.registrations.push(route) } })
195
195
  assert.doesNotThrow(() => apply(ctx))
196
196
  const paths = ctx.registrations.map((r) => r.path).sort()
@@ -201,6 +201,7 @@ test('apply:webServer 存在时注册全部 6 条路由;tools 缺失走 inje
201
201
  '/api/message-ops/messages',
202
202
  '/api/message-ops/restore',
203
203
  '/api/message-ops/revert',
204
+ '/api/message-ops/text',
204
205
  ])
205
206
  // tools 缺失 → 容错等待而非 fatal
206
207
  assert.ok(ctx.injects.some(([deps]) => deps[0] === 'tools'))
@@ -214,3 +215,39 @@ test('apply:tools 服务存在时直接进入注册路径(包缺失时 catch
214
215
  // 但绝不能让插件 apply 抛错;异步分支也不能产生未处理拒绝。
215
216
  assert.equal(registered.length, 0)
216
217
  })
218
+
219
+ // ── 0.5.2 回归:工具层必须 await async ops(0.2.1 改 async 后曾漏同步化,
220
+ // list/restore 抛 TypeError、export 渲出空 text;旧桩是同步的故漏检)──────
221
+ test('工具 execute:async ops 被 await(list/branch/restore/export 全路径)', async () => {
222
+ let def
223
+ const defineTool = (d) => { def = d; return d }
224
+ const asyncOps = {
225
+ list: async () => ({ session: { id: 'session-abc', parentSession: null }, running: false, total: 1, visibleCount: 1,
226
+ messages: [{ seq: 10, role: 'user', visible: true, snippet: 'hello' }] }),
227
+ revert: async () => ({ mode: 'revert', shadowedCount: 2, seq: 10, eventSeq: 99 }),
228
+ delete: async () => ({ mode: 'delete', shadowedCount: 1, seq: 10, eventSeq: 98 }),
229
+ branch: async () => ({ newId: 'session-child', keptEvents: 42, parentSession: undefined }),
230
+ restore: async () => ({ restoredCount: 1, skipped: 0, range: { startSeq: 5, endSeq: 6 }, eventSeqs: [7, 8] }),
231
+ export: async () => ({ markdown: '# md' }),
232
+ }
233
+ createMessageOpsTool({ defineTool, ops: asyncOps, ctx: {} })
234
+ assert.ok(def, 'tool defined')
235
+ const outList = await def.execute({ action: 'list', sessionId: 'session-abc' })
236
+ assert.match(String(outList), /session session-abc/)
237
+ assert.doesNotMatch(String(outList), /failed:/)
238
+ const outBranch = await def.execute({ action: 'branch', sessionId: 'session-abc', upToSeq: 10 })
239
+ // 0.5.2 P2:branch 渲染读 keptEvents + parentSession(注入自入参),不再读不存在的 kept/parentId
240
+ assert.match(String(outBranch), /kept 42 event/)
241
+ assert.match(String(outBranch), /parent session-abc/)
242
+ const outRestore = await def.execute({ action: 'restore', sessionId: 'session-abc', seq: 6 })
243
+ assert.match(String(outRestore), /replayed 1 message/)
244
+ const outExport = await def.execute({ action: 'export', sessionId: 'session-abc' })
245
+ assert.equal(String(outExport), '# md')
246
+ // 同步抛出的 ops 仍走错误文案路径
247
+ const errTool = await (async () => {
248
+ let d2
249
+ createMessageOpsTool({ defineTool: (x) => { d2 = x }, ops: { list: () => { throw new Error('boom') } }, ctx: {} })
250
+ return d2.execute({ action: 'list', sessionId: 'session-abc' })
251
+ })()
252
+ assert.match(String(errTool), /list failed: boom/)
253
+ })