@240xu/dsh-message-ops 0.5.1 → 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
@@ -243,3 +243,22 @@ MIT
243
243
  - 0.4.2 的 dock 重写误删了 `INPUT_DOCK_SLOT/ID` 常量声明——apply() 引用未定义
244
244
  标识符导致**整个客户端激活失败**(页面横幅 Failed to load plugins)。
245
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.1",
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
@@ -26,6 +26,9 @@ window.__ModuleLoader__.load({
26
26
  const INPUT_DOCK_ID = 'message-ops-revert-dock'
27
27
  const DIALOG_ID = 'message-ops-dialog'
28
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 */ } }
29
32
  const NS = 'dsh-message-ops'
30
33
 
31
34
  const zhDict = {
@@ -386,11 +389,12 @@ window.__ModuleLoader__.load({
386
389
  }
387
390
  const path = mode === 'restore' ? 'restore' : mode
388
391
  const body = { sessionId: target.sessionId, seq: picked }
389
- fetch('/api/message-ops/' + path, {
390
- method: 'POST',
391
- headers: { 'content-type': 'application/json' },
392
- body: JSON.stringify(body),
393
- })
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
+ }))
394
398
  .then(async (res) => {
395
399
  let data = {}
396
400
  try { data = await res.json() } catch { /* keep {} */ }
@@ -410,6 +414,7 @@ window.__ModuleLoader__.load({
410
414
  setBusyMsg('')
411
415
  setState('done')
412
416
  notifyDone(okMsg)
417
+ emitChanged()
413
418
  })
414
419
  .catch((reason) => {
415
420
  setBusyMsg('')
@@ -641,12 +646,14 @@ window.__ModuleLoader__.load({
641
646
  else if (cached) resolve(cached)
642
647
  else {
643
648
  const p = fetch('/api/message-ops/messages?sessionId=' + encodeURIComponent(sessionId))
644
- .then((r) => (r.ok ? r.json() : { messages: [] }))
649
+ .then((r) => (r.ok ? r.json() : Promise.reject(new Error('HTTP ' + r.status))))
645
650
  .then((data) => {
646
651
  const index = new Map()
647
652
  const full = new Map()
648
653
  for (const m of (data && data.messages) || []) {
649
- 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)
650
657
  if (m && m.id != null && m.fullText) full.set(String(m.id), m.fullText)
651
658
  }
652
659
  __seqIndexCache.set(sessionId, index)
@@ -654,7 +661,9 @@ window.__ModuleLoader__.load({
654
661
  return index
655
662
  })
656
663
  __seqIndexCache.set(sessionId, p)
657
- p.then(resolve).catch(() => {})
664
+ // 0.5.2(P2):失败必须清缓存——否则 rejected promise 被永久缓存,
665
+ // 该会话按钮/dock 到刷新页面为止全部失效
666
+ p.then(resolve).catch(() => { __seqIndexCache.delete(sessionId) })
658
667
  }
659
668
  return () => { alive = false }
660
669
  }, [messageId, sessionId])
@@ -689,16 +698,20 @@ window.__ModuleLoader__.load({
689
698
  const runRevert = () => {
690
699
  if (busy || seq == null || running) return
691
700
  setBusy('revert')
692
- fetch('/api/message-ops/revert', {
693
- method: 'POST', headers: { 'content-type': 'application/json' },
694
- body: JSON.stringify({ sessionId, seq }),
695
- })
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
+ }))
696
708
  .then(async (res) => {
697
709
  let data = {}
698
710
  try { data = await res.json() } catch { /* keep {} */ }
699
711
  if (!res.ok || !data.ok) throw new Error(data.error || ('HTTP ' + res.status))
700
712
  notifyDone(t('done.revert'))
701
713
  revealSession()
714
+ emitChanged()
702
715
  })
703
716
  .catch((reason) => {
704
717
  notifyDone(t('errorPrefix') + (reason && reason.message ? reason.message : String(reason)), 'error')
@@ -714,11 +727,18 @@ window.__ModuleLoader__.load({
714
727
  return
715
728
  }
716
729
  const span = inputActions.captureInsertion()
717
- const text = fullText != null ? fullText : (summary && summary.title ? '' : null)
718
- if (text == null) { notifyDone(t('quote.unavailable'), 'warn'); return }
719
- const quoted = text.split('\n').map((l) => '> ' + l).join('\n') + '\n\n'
720
- const ok = inputActions.insertText(quoted, span)
721
- 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))
722
742
  } catch (e) {
723
743
  notifyDone(t('errorPrefix') + (e && e.message ? e.message : String(e)), 'error')
724
744
  }
@@ -740,6 +760,16 @@ window.__ModuleLoader__.load({
740
760
  )
741
761
  }
742
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
+
743
773
  function RevertDock(props) {
744
774
  const { sessionId, useSessions, inputActions } = props
745
775
  const t = (props && props.t) || __t
@@ -750,6 +780,7 @@ window.__ModuleLoader__.load({
750
780
  const running = summary ? summary.running === true : false
751
781
  const [open, setOpen] = useState(false)
752
782
  const [markers, setMarkers] = useState(null) // 活跃回撤标记(已恢复的自动消失)
783
+ const [shadowCount, setShadowCount] = useState(0) // 标记 range 内被遮蔽消息数(标题口径)
753
784
  const [restoring, setRestoring] = useState(null)
754
785
  const [styleInjected, setStyleInjected] = useState(false)
755
786
 
@@ -784,15 +815,28 @@ window.__ModuleLoader__.load({
784
815
 
785
816
  useEffect(() => {
786
817
  let alive = true
818
+ const onChange = () => load()
819
+ window.addEventListener(CHANGED_EVENT, onChange)
787
820
  const load = () => fetch('/api/message-ops/messages?sessionId=' + encodeURIComponent(sessionId))
788
821
  .then((r) => (r.ok ? r.json() : null))
789
822
  .then((data) => {
790
823
  if (!alive || !data || !data.ok) return
791
- 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)
792
836
  })
793
837
  .catch(() => {})
794
838
  load()
795
- return () => { alive = false }
839
+ return () => { alive = false; window.removeEventListener(CHANGED_EVENT, onChange) }
796
840
  }, [sessionId])
797
841
 
798
842
  // items 变化自动折叠(opencode createEffect 同款;必须在早退之前——Hooks 规则)
@@ -802,10 +846,11 @@ window.__ModuleLoader__.load({
802
846
  const restoreRow = (row) => {
803
847
  if (restoring != null || running) return
804
848
  setRestoring(row.seq)
805
- fetch('/api/message-ops/restore', {
806
- method: 'POST', headers: { 'content-type': 'application/json' },
807
- body: JSON.stringify({ sessionId, seq: row.seq }),
808
- })
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
+ }))
809
854
  .then(async (res) => {
810
855
  let data = {}
811
856
  try { data = await res.json() } catch { /* keep {} */ }
@@ -821,13 +866,15 @@ window.__ModuleLoader__.load({
821
866
  })
822
867
  .catch((reason) => {
823
868
  notifyDone(t('errorPrefix') + (reason && reason.message ? reason.message : String(reason)), 'error')
824
- setRestoring(null)
825
869
  })
870
+ .finally(() => setRestoring(null))
826
871
  }
827
872
 
828
873
 
829
874
  const headerKbd = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setOpen((v) => !v) } }
830
- 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) })
831
878
  const preview = markers[0] && markers[0].snippet ? markers[0].snippet : ''
832
879
 
833
880
  const rowEl = (row) => React.createElement('div', { key: row.seq, className: 'mopsRdRow' },
@@ -899,7 +946,11 @@ window.__ModuleLoader__.load({
899
946
  var titleEl = row.querySelector('[class*=title]')
900
947
  var title = titleEl ? String(titleEl.innerText || '').trim() : ''
901
948
  if (!title) return
902
- 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 } }))
903
954
  })
904
955
  var sep = document.createElement('div')
905
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
+ })