@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 +19 -0
- package/package.json +1 -1
- package/src/client.js +77 -26
- package/src/index.js +39 -8
- package/test/ops-core.test.js +38 -1
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.
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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() :
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
693
|
-
|
|
694
|
-
|
|
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
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
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
|
-
|
|
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
|
-
|
|
806
|
-
|
|
807
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
+
})
|