@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 +25 -0
- package/package.json +1 -1
- package/src/client.js +79 -26
- package/src/index.js +39 -8
- package/test/ops-core.test.js +38 -1
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.
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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() :
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
691
|
-
|
|
692
|
-
|
|
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
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
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
|
-
|
|
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
|
-
|
|
804
|
-
|
|
805
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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" });
|
package/test/ops-core.test.js
CHANGED
|
@@ -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 存在时注册全部
|
|
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
|
+
})
|