@240xu/dsh-message-ops 0.2.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/LICENSE +21 -0
- package/README.md +132 -0
- package/cordis.patch.yml +5 -0
- package/package.json +51 -0
- package/src/branch.js +54 -0
- package/src/client.js +560 -0
- package/src/index.js +436 -0
- package/src/ops-core.js +269 -0
- package/src/session-file.js +230 -0
- package/test/fence.test.js +208 -0
- package/test/ops-core.test.js +216 -0
- package/test/session-file.test.js +110 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-message-ops — 评审修复测试(node --test)。
|
|
3
|
+
* 覆盖:HTTP 信任围栏(P0)、写操作 Content-Type/413、写端 replace 拼写
|
|
4
|
+
* 运行时探测(P1 前向雷)、异步逐帧读取与 partial 语义(P1)。
|
|
5
|
+
*/
|
|
6
|
+
import test from 'node:test'
|
|
7
|
+
import assert from 'node:assert/strict'
|
|
8
|
+
import fs from 'node:fs'
|
|
9
|
+
import os from 'node:os'
|
|
10
|
+
import path from 'node:path'
|
|
11
|
+
|
|
12
|
+
process.env.DSH_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-msgops-fence-'))
|
|
13
|
+
|
|
14
|
+
const { isTrustedApiRequest, isJsonContentType, applySurfaceReplace, _resetReplaceShape, OpsError } =
|
|
15
|
+
await import('../src/ops-core.js')
|
|
16
|
+
const { encodeSessionFile, readSessionFileAsync, computeShadowed } = await import('../src/session-file.js')
|
|
17
|
+
const { apply } = await import('../src/index.js')
|
|
18
|
+
|
|
19
|
+
// --- 围栏纯函数 ----------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
function req(headers) { return { headers } }
|
|
22
|
+
|
|
23
|
+
test('isTrustedApiRequest:回环 Host 放行(localhost / 127.x / ::1)', () => {
|
|
24
|
+
assert.equal(isTrustedApiRequest(req({ host: 'localhost:3080' })), true)
|
|
25
|
+
assert.equal(isTrustedApiRequest(req({ host: '127.0.0.1:3080' })), true)
|
|
26
|
+
assert.equal(isTrustedApiRequest(req({ host: '127.7.7.7' })), true)
|
|
27
|
+
assert.equal(isTrustedApiRequest(req({ host: "[::1]:3080" })), true)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('isTrustedApiRequest:非回环 Host / 缺 Host 拒绝(DNS rebinding)', () => {
|
|
31
|
+
assert.equal(isTrustedApiRequest(req({ host: 'evil.com:3080' })), false)
|
|
32
|
+
// 2130706433 是十进制 IPv4,WHATWG URL 会规范化为 127.0.0.1 → 本就是回环,放行
|
|
33
|
+
assert.equal(isTrustedApiRequest(req({ host: '2130706433' })), true)
|
|
34
|
+
assert.equal(isTrustedApiRequest(req({})), false)
|
|
35
|
+
assert.equal(isTrustedApiRequest(req({ host: '127.0.0.999' })), false)
|
|
36
|
+
assert.equal(isTrustedApiRequest(req({ host: 'foo.127.0.0.1' })), false)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('isTrustedApiRequest:sec-fetch-site cross-site / Origin 不同源拒绝;同源放行', () => {
|
|
40
|
+
const h = { host: '127.0.0.1:3080' }
|
|
41
|
+
assert.equal(isTrustedApiRequest(req({ ...h, 'sec-fetch-site': 'cross-site' })), false)
|
|
42
|
+
assert.equal(isTrustedApiRequest(req({ ...h, origin: 'http://evil.com' })), false)
|
|
43
|
+
assert.equal(isTrustedApiRequest(req({ ...h, origin: 'http://127.0.0.1:3080' })), true)
|
|
44
|
+
assert.equal(isTrustedApiRequest(req({ ...h, 'sec-fetch-site': 'same-origin' })), true)
|
|
45
|
+
assert.equal(isTrustedApiRequest(req({ ...h, origin: 'not a url' })), false)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('isJsonContentType:application/json(含 charset 后缀)通过,其余拒绝', () => {
|
|
49
|
+
assert.equal(isJsonContentType(req({ 'content-type': 'application/json' })), true)
|
|
50
|
+
assert.equal(isJsonContentType(req({ 'content-type': 'application/json; charset=utf-8' })), true)
|
|
51
|
+
assert.equal(isJsonContentType(req({ 'content-type': 'text/plain' })), false)
|
|
52
|
+
assert.equal(isJsonContentType(req({})), false)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
// --- 路由级围栏(mock req/res 直接驱动注册的 handler) --------------------------
|
|
56
|
+
|
|
57
|
+
function makeCtx() {
|
|
58
|
+
const routes = new Map()
|
|
59
|
+
const ctx = {
|
|
60
|
+
get(k) { return k === 'webServer' ? { register: (r) => routes.set(r.path, r.handler) } : undefined },
|
|
61
|
+
inject() {},
|
|
62
|
+
effect(fn) { return fn() },
|
|
63
|
+
}
|
|
64
|
+
apply(ctx)
|
|
65
|
+
return routes
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function mockReq({ method = 'GET', url = '/', headers = {}, body } = {}) {
|
|
69
|
+
const chunks = body === undefined ? [] : [Buffer.from(body)]
|
|
70
|
+
return {
|
|
71
|
+
method, url, headers,
|
|
72
|
+
async *[Symbol.asyncIterator]() { for (const c of chunks) yield c },
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function mockRes() {
|
|
77
|
+
const out = { status: null, headers: null, body: '' }
|
|
78
|
+
out.writeHead = (status, headers) => { out.status = status; out.headers = headers || {} }
|
|
79
|
+
out.end = (b) => { out.body = b == null ? '' : String(b) }
|
|
80
|
+
return out
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
test('路由级:非回环 Host → 403;cross-site POST → 403(先于业务校验)', async () => {
|
|
84
|
+
const routes = makeCtx()
|
|
85
|
+
const res1 = mockRes()
|
|
86
|
+
await routes.get('/api/message-ops/messages')(mockReq({ url: '/api/message-ops/messages?sessionId=x', headers: { host: 'evil.com' } }), res1)
|
|
87
|
+
assert.equal(res1.status, 403)
|
|
88
|
+
const res2 = mockRes()
|
|
89
|
+
await routes.get('/api/message-ops/revert')(mockReq({
|
|
90
|
+
method: 'POST', url: '/api/message-ops/revert', headers: { host: '127.0.0.1', 'sec-fetch-site': 'cross-site', origin: 'http://evil.com' },
|
|
91
|
+
}), res2)
|
|
92
|
+
assert.equal(res2.status, 403)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('路由级:写操作 text/plain 绕预检 → 415;超大 body → 413', async () => {
|
|
96
|
+
const routes = makeCtx()
|
|
97
|
+
const res1 = mockRes()
|
|
98
|
+
await routes.get('/api/message-ops/revert')(mockReq({
|
|
99
|
+
method: 'POST', url: '/api/message-ops/revert',
|
|
100
|
+
headers: { host: '127.0.0.1', 'content-type': 'text/plain' }, body: '{"sessionId":"x","seq":1}',
|
|
101
|
+
}), res1)
|
|
102
|
+
assert.equal(res1.status, 415)
|
|
103
|
+
|
|
104
|
+
const big = Buffer.alloc(1024 * 1024 + 1, 0x61).toString()
|
|
105
|
+
const res2 = mockRes()
|
|
106
|
+
await routes.get('/api/message-ops/revert')(mockReq({
|
|
107
|
+
method: 'POST', url: '/api/message-ops/revert',
|
|
108
|
+
headers: { host: '127.0.0.1', 'content-type': 'application/json' }, body: big,
|
|
109
|
+
}), res2)
|
|
110
|
+
assert.equal(res2.status, 413)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test('路由级:回环同源 GET export → 200 附件;非法 body JSON → 400', async () => {
|
|
114
|
+
const id = 'session-99999999-8888-7777-6666-555555555555'
|
|
115
|
+
const dir = path.join(process.env.DSH_HOME, 'sessions', 'proj-fence', id)
|
|
116
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
117
|
+
const events = [
|
|
118
|
+
{ type: 'user/message', seq: 0, surfaceOp: 'append', data: { message: { role: 'user', content: [{ type: 'text', text: '围栏测试' }] } } },
|
|
119
|
+
]
|
|
120
|
+
fs.writeFileSync(path.join(dir, 'session.v3.jsonl.zstd'), encodeSessionFile({ type: 'session', version: 3, id, createdAt: 1 }, events))
|
|
121
|
+
|
|
122
|
+
const routes = makeCtx()
|
|
123
|
+
const res1 = mockRes()
|
|
124
|
+
await routes.get('/api/message-ops/export')(mockReq({
|
|
125
|
+
url: `/api/message-ops/export?sessionId=${id}`,
|
|
126
|
+
headers: { host: 'localhost:3080', origin: 'http://localhost:3080' },
|
|
127
|
+
}), res1)
|
|
128
|
+
assert.equal(res1.status, 200)
|
|
129
|
+
assert.match(res1.headers['content-type'], /text\/markdown/)
|
|
130
|
+
assert.match(res1.headers['content-disposition'], /attachment/)
|
|
131
|
+
assert.match(res1.body, /围栏测试/)
|
|
132
|
+
|
|
133
|
+
const res2 = mockRes()
|
|
134
|
+
await routes.get('/api/message-ops/revert')(mockReq({
|
|
135
|
+
method: 'POST', url: '/api/message-ops/revert',
|
|
136
|
+
headers: { host: '127.0.0.1', 'content-type': 'application/json' }, body: '{broken',
|
|
137
|
+
}), res2)
|
|
138
|
+
assert.equal(res2.status, 400)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
// --- 写端 replace 拼写运行时探测(P1 前向雷) -----------------------------------
|
|
142
|
+
|
|
143
|
+
function probingSession(failLegacy) {
|
|
144
|
+
const appends = []
|
|
145
|
+
return {
|
|
146
|
+
appends,
|
|
147
|
+
append(type, data, opts) {
|
|
148
|
+
appends.push(opts)
|
|
149
|
+
const op = opts && opts.surfaceOp
|
|
150
|
+
if (failLegacy && op && typeof op === 'object' && op.op === 'replace' && 'startSeq' in op) {
|
|
151
|
+
throw new Error('session event "system/message" carries an invalid replace surfaceOp')
|
|
152
|
+
}
|
|
153
|
+
return { seq: appends.length - 1, type, data, opts }
|
|
154
|
+
},
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
test('applySurfaceReplace:新引擎(拒 startSeq)自动降级 {start,end} 并记住拼写', () => {
|
|
159
|
+
_resetReplaceShape()
|
|
160
|
+
const session = probingSession(true)
|
|
161
|
+
const ev = applySurfaceReplace(session, 3, 4, [3, 4], 'notice')
|
|
162
|
+
assert.equal(ev.seq, 1)
|
|
163
|
+
// appends[0] 是被引擎拒绝的 legacy 尝试,appends[1] 是降级重试
|
|
164
|
+
assert.deepEqual(session.appends[0].surfaceOp, { op: 'replace', startSeq: 3, endSeq: 4 })
|
|
165
|
+
assert.deepEqual(ev.opts.surfaceOp, { op: 'replace', start: 3, end: 4 })
|
|
166
|
+
// 第二次直接用新拼写,不再探测
|
|
167
|
+
const ev2 = applySurfaceReplace(session, 5, 6, [5, 6], 'n2')
|
|
168
|
+
assert.deepEqual(ev2.opts.surfaceOp, { op: 'replace', start: 5, end: 6 })
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
test('applySurfaceReplace:当前引擎(收 startSeq)保持原拼写;无关错误原样抛出', () => {
|
|
172
|
+
_resetReplaceShape()
|
|
173
|
+
const session = probingSession(false)
|
|
174
|
+
applySurfaceReplace(session, 1, 2, [1, 2], 'n')
|
|
175
|
+
assert.deepEqual(session.appends[0].surfaceOp, { op: 'replace', startSeq: 1, endSeq: 2 })
|
|
176
|
+
_resetReplaceShape()
|
|
177
|
+
const boom = { append() { throw new Error('disk on fire') } }
|
|
178
|
+
assert.throws(() => applySurfaceReplace(boom, 1, 1, [1], 'n'), /disk on fire/)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
// --- 异步逐帧读取 + partial 语义 + 双拼写 computeShadowed -----------------------
|
|
182
|
+
|
|
183
|
+
test('readSessionFileAsync:事件完整恢复,返回 frameCount;超 frameBudget 置 partial', async () => {
|
|
184
|
+
const id = 'session-aaaa0000-0000-0000-0000-00000000f001'
|
|
185
|
+
const dir = path.join(process.env.DSH_HOME, 'sessions', 'proj-async', id)
|
|
186
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
187
|
+
const events = [{ type: 'user/message', seq: 0, surfaceOp: 'append', data: { message: { role: 'user', content: [{ type: 'text', text: '异步读取' }] } } }]
|
|
188
|
+
const file = path.join(dir, 'session.v3.jsonl.zstd')
|
|
189
|
+
fs.writeFileSync(file, encodeSessionFile({ type: 'session', version: 3, id, createdAt: 1 }, events))
|
|
190
|
+
|
|
191
|
+
const r1 = await readSessionFileAsync(file)
|
|
192
|
+
assert.equal(r1.partial, false)
|
|
193
|
+
assert.equal(r1.frameCount, 1)
|
|
194
|
+
assert.equal(r1.events[0].data.message.content[0].text, '异步读取')
|
|
195
|
+
// encodeSessionFile 产生 header 帧 + 1 个事件帧;用最小 frameBudget 触发 partial
|
|
196
|
+
const r2 = await readSessionFileAsync(file, { frameBudget: 0 })
|
|
197
|
+
assert.equal(r2.partial, true)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
test('computeShadowed:双拼写(startSeq/endSeq 与 start/end)都能推导遮蔽', () => {
|
|
201
|
+
const shadowed = computeShadowed([
|
|
202
|
+
{ seq: 0, surfaceOp: { op: 'replace', startSeq: 0, endSeq: 1 } },
|
|
203
|
+
{ seq: 2, surfaceOp: { op: 'replace', start: 2, end: 3 } },
|
|
204
|
+
{ seq: 4, surfaceOp: { op: 'replace', startSeq: 4, start: 4 } }, // 半残:非法忽略
|
|
205
|
+
])
|
|
206
|
+
assert.ok([0, 1, 2, 3].every((s) => shadowed.has(s)))
|
|
207
|
+
assert.ok(!shadowed.has(4))
|
|
208
|
+
})
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-message-ops — 新功能零依赖测试(node --test)。
|
|
3
|
+
* 覆盖:export Markdown、restore 重放规划/落定、message_ops 工具构造与
|
|
4
|
+
* 容错注册(缺 tools 服务 / 缺 dsh-tools 包均不 fatal)。
|
|
5
|
+
*/
|
|
6
|
+
import test from 'node:test'
|
|
7
|
+
import assert from 'node:assert/strict'
|
|
8
|
+
|
|
9
|
+
const { OpsError, planRestore, applyRestore, exportMarkdown, messageText } =
|
|
10
|
+
await import('../src/ops-core.js')
|
|
11
|
+
const { createMessageOpsTool, apply } = await import('../src/index.js')
|
|
12
|
+
|
|
13
|
+
function msg(type, seq, text, role) {
|
|
14
|
+
return {
|
|
15
|
+
type, seq, time: Date.now(), surfaceOp: 'append',
|
|
16
|
+
data: { turn: 1, message: { role: role || (type === 'user/message' ? 'user' : 'assistant'), content: [{ type: 'text', text }] } },
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function sampleEvents() {
|
|
21
|
+
return [
|
|
22
|
+
msg('user/message', 0, '第一问'),
|
|
23
|
+
msg('assistant/message', 1, '第一答'),
|
|
24
|
+
{ type: 'tool/call', seq: 2, data: { name: 'bash', call: { name: 'bash', arguments: { command: 'ls' } } } },
|
|
25
|
+
msg('user/message', 3, '第二问'),
|
|
26
|
+
msg('assistant/message', 4, '第二答'),
|
|
27
|
+
// 一次 revert 落定:遮蔽 3..4,sourceEventSeqs 覆盖被遮蔽节点
|
|
28
|
+
{
|
|
29
|
+
type: 'system/message', seq: 5, time: Date.now(),
|
|
30
|
+
surfaceOp: { op: 'replace', startSeq: 3, endSeq: 4 },
|
|
31
|
+
sourceEventSeqs: [3, 4],
|
|
32
|
+
data: { message: { role: 'system', content: [{ type: 'text', text: '[消息回滚] 已回滚到 seq 3(含)之后的 2 个节点' }] } },
|
|
33
|
+
},
|
|
34
|
+
]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const header = { type: 'session', version: 3, id: 'session-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', createdAt: 1, cwd: '/tmp/x' }
|
|
38
|
+
|
|
39
|
+
// --- exportMarkdown ----------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
test('exportMarkdown:消息按角色小节展开,工具调用折叠为单行', () => {
|
|
42
|
+
const md = exportMarkdown(header, sampleEvents())
|
|
43
|
+
assert.match(md, /^# DSH 会话导出:session-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/m)
|
|
44
|
+
assert.match(md, /## \[seq 0\] user\n\n第一问/)
|
|
45
|
+
assert.match(md, /## \[seq 1\] assistant\n\n第一答/)
|
|
46
|
+
// 工具调用是单行引用,且含参数提示
|
|
47
|
+
const toolLines = md.split('\n').filter((l) => l.startsWith('> [seq 2]'))
|
|
48
|
+
assert.equal(toolLines.length, 1)
|
|
49
|
+
assert.match(toolLines[0], /🔧 工具调用:bash/)
|
|
50
|
+
assert.match(toolLines[0], /command/)
|
|
51
|
+
// revert 标记本身是 system 消息,也展开
|
|
52
|
+
assert.match(md, /## \[seq 5\] system/)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('exportMarkdown:seq 上界只导出 ≤ 上界的事件', () => {
|
|
56
|
+
const md = exportMarkdown(header, sampleEvents(), 1)
|
|
57
|
+
assert.match(md, /seq ≤ 1/)
|
|
58
|
+
assert.match(md, /第一问/)
|
|
59
|
+
assert.doesNotMatch(md, /第二问/)
|
|
60
|
+
assert.doesNotMatch(md, /工具调用:bash/)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('exportMarkdown:非法 seq 抛 OpsError(400)', () => {
|
|
64
|
+
assert.throws(() => exportMarkdown(header, sampleEvents(), -1), (e) => e instanceof OpsError && e.status === 400)
|
|
65
|
+
assert.throws(() => exportMarkdown(header, sampleEvents(), 1.5), OpsError)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
// --- planRestore / applyRestore ----------------------------------------------
|
|
69
|
+
|
|
70
|
+
test('planRestore:从 revert 标记事件规划重放(含 user/assistant,跳过 tool)', () => {
|
|
71
|
+
const plan = planRestore(sampleEvents(), 5)
|
|
72
|
+
assert.equal(plan.startSeq, 3)
|
|
73
|
+
assert.equal(plan.endSeq, 4)
|
|
74
|
+
assert.equal(plan.replayable.length, 2)
|
|
75
|
+
assert.equal(plan.skipped, 0)
|
|
76
|
+
assert.deepEqual(plan.replayable.map((r) => [r.role, r.seq, r.text]), [
|
|
77
|
+
['user', 3, '第二问'],
|
|
78
|
+
['assistant', 4, '第二答'],
|
|
79
|
+
])
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('planRestore:兼容 start/end 拼写(dsh-src 较新引擎形状)', () => {
|
|
83
|
+
const events = [
|
|
84
|
+
msg('user/message', 0, 'a'),
|
|
85
|
+
{ type: 'system/message', seq: 1, surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0] },
|
|
86
|
+
]
|
|
87
|
+
const plan = planRestore(events, 1)
|
|
88
|
+
assert.equal(plan.startSeq, 0)
|
|
89
|
+
assert.equal(plan.replayable[0].text, 'a')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('planRestore:非标记事件 / 越界 seq / 无可重放消息 均拒绝', () => {
|
|
93
|
+
const events = sampleEvents()
|
|
94
|
+
assert.throws(() => planRestore(events, 99), (e) => e instanceof OpsError && e.status === 404)
|
|
95
|
+
assert.throws(() => planRestore(events, 0), (e) => e instanceof OpsError && e.status === 409) // 普通消息非标记
|
|
96
|
+
assert.throws(() => planRestore(events, -1), (e) => e instanceof OpsError && e.status === 400)
|
|
97
|
+
// 区间内只有 tool 事件 → 无可重放
|
|
98
|
+
const onlyTool = [
|
|
99
|
+
{ type: 'tool/call', seq: 0, data: { name: 'bash' } },
|
|
100
|
+
{ type: 'system/message', seq: 1, surfaceOp: { op: 'replace', startSeq: 0, endSeq: 0 }, sourceEventSeqs: [0] },
|
|
101
|
+
]
|
|
102
|
+
assert.throws(() => planRestore(onlyTool, 1), (e) => e instanceof OpsError && e.status === 409)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test('applyRestore:重放 append 带 [恢复] 前缀 + system 说明,flush 被调用', () => {
|
|
106
|
+
const appended = []
|
|
107
|
+
let seq = 10
|
|
108
|
+
const session = {
|
|
109
|
+
append(type, data, opts) {
|
|
110
|
+
const event = { seq: seq++, type, data, opts }
|
|
111
|
+
appended.push(event)
|
|
112
|
+
return event
|
|
113
|
+
},
|
|
114
|
+
}
|
|
115
|
+
const plan = planRestore(sampleEvents(), 5)
|
|
116
|
+
let flushed = 0
|
|
117
|
+
const result = applyRestore(session, plan, { flush: () => flushed++ })
|
|
118
|
+
assert.equal(flushed, 1)
|
|
119
|
+
assert.equal(result.restoredCount, 2)
|
|
120
|
+
assert.equal(result.skipped, 0)
|
|
121
|
+
assert.equal(appended.length, 3)
|
|
122
|
+
assert.equal(appended[0].type, 'system/message')
|
|
123
|
+
assert.match(appended[0].data.message.content[0].text, /\[消息恢复\] 重放 seq 3\.\.4 的 2 条消息/)
|
|
124
|
+
assert.equal(appended[1].type, 'user/message')
|
|
125
|
+
assert.equal(appended[1].data.message.content[0].text, '[恢复] 第二问')
|
|
126
|
+
assert.equal(appended[2].data.message.content[0].text, '[恢复] 第二答')
|
|
127
|
+
for (const a of appended) assert.equal(a.opts.surfaceOp, 'append')
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('messageText:数组 content 提取首个非空 text;data.content 兼容', () => {
|
|
131
|
+
assert.equal(messageText(msg('user/message', 0, 'hi')), 'hi')
|
|
132
|
+
assert.equal(messageText({ type: 'user/message', data: { content: [{ type: 'text', text: 'legacy' }] } }), 'legacy')
|
|
133
|
+
assert.equal(messageText({ type: 'user/message', data: {} }), '')
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
// --- message_ops 工具(桩 defineTool + 桩 ops) --------------------------------
|
|
137
|
+
|
|
138
|
+
function stubDefineTool(def) {
|
|
139
|
+
def.__compiled = true
|
|
140
|
+
return def
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
test('createMessageOpsTool:defineTool 桩拿到 name/parameters/execute,execute 分发到 ops', async () => {
|
|
144
|
+
const calls = []
|
|
145
|
+
const ops = {
|
|
146
|
+
list: (ctx, id) => { calls.push(['list', id]); return { ok: true, session: { id }, running: false, total: 0, visibleCount: 0, messages: [] } },
|
|
147
|
+
revert: (ctx, id, seq) => { calls.push(['revert', id, seq]); return { mode: 'revert', seq, shadowedCount: 2, eventSeq: 9 } },
|
|
148
|
+
}
|
|
149
|
+
const tool = createMessageOpsTool({ defineTool: stubDefineTool, ops, ctx: {} })
|
|
150
|
+
assert.equal(tool.name, 'message_ops')
|
|
151
|
+
assert.ok(tool.__compiled)
|
|
152
|
+
assert.equal(tool.parameters.action.enum.length, 6)
|
|
153
|
+
assert.ok(tool.parameters.sessionId.required)
|
|
154
|
+
const out = await tool.execute({ action: 'list', sessionId: 'session-x' })
|
|
155
|
+
assert.match(out, /session session-x/)
|
|
156
|
+
const out2 = await tool.execute({ action: 'revert', sessionId: 'session-x', seq: 3 })
|
|
157
|
+
assert.match(out2, /revert ok: shadowed 2 node\(s\) from seq 3; marker event seq 9/)
|
|
158
|
+
assert.deepEqual(calls[1], ['revert', 'session-x', 3])
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test('createMessageOpsTool:execute 捕获 OpsError 为失败文本而非抛出', async () => {
|
|
162
|
+
const ops = { delete: () => { throw new OpsError('seq 3 not visible on current surface', 409) } }
|
|
163
|
+
const tool = createMessageOpsTool({ defineTool: stubDefineTool, ops, ctx: {} })
|
|
164
|
+
const out = await tool.execute({ action: 'delete', sessionId: 'session-x', seq: 3 })
|
|
165
|
+
assert.match(out, /^delete failed: seq 3 not visible/)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
// --- index.js apply:工具注册容错(不 fatal) ----------------------------------
|
|
169
|
+
|
|
170
|
+
function mockCtx({ tools, webServer } = {}) {
|
|
171
|
+
const registrations = []
|
|
172
|
+
const injects = []
|
|
173
|
+
const host = { register: (route) => registrations.push(route) }
|
|
174
|
+
return {
|
|
175
|
+
registrations, injects, host,
|
|
176
|
+
get(key) {
|
|
177
|
+
if (key === 'webServer') return webServer === undefined ? undefined : webServer
|
|
178
|
+
if (key === 'tools') return tools
|
|
179
|
+
return undefined
|
|
180
|
+
},
|
|
181
|
+
inject(deps, fn) { injects.push([deps, fn]) },
|
|
182
|
+
effect(fn) { return fn() },
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
test('apply:无 webServer、无 tools 时走 inject 等待,不抛错', () => {
|
|
187
|
+
const ctx = mockCtx()
|
|
188
|
+
assert.doesNotThrow(() => apply(ctx))
|
|
189
|
+
assert.deepEqual(ctx.injects.map((i) => i[0]), [['webServer'], ['tools']])
|
|
190
|
+
assert.equal(ctx.registrations.length, 0)
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
test('apply:webServer 存在时注册全部 6 条路由;tools 缺失走 inject 等待', () => {
|
|
194
|
+
const ctx = mockCtx({ webServer: { register: (route) => ctx.registrations.push(route) } })
|
|
195
|
+
assert.doesNotThrow(() => apply(ctx))
|
|
196
|
+
const paths = ctx.registrations.map((r) => r.path).sort()
|
|
197
|
+
assert.deepEqual(paths, [
|
|
198
|
+
'/api/message-ops/branch',
|
|
199
|
+
'/api/message-ops/delete',
|
|
200
|
+
'/api/message-ops/export',
|
|
201
|
+
'/api/message-ops/messages',
|
|
202
|
+
'/api/message-ops/restore',
|
|
203
|
+
'/api/message-ops/revert',
|
|
204
|
+
])
|
|
205
|
+
// tools 缺失 → 容错等待而非 fatal
|
|
206
|
+
assert.ok(ctx.injects.some(([deps]) => deps[0] === 'tools'))
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
test('apply:tools 服务存在时直接进入注册路径(包缺失时 catch 静默跳过)', () => {
|
|
210
|
+
const registered = []
|
|
211
|
+
const ctx = mockCtx({ webServer: { register: () => {} }, tools: { register: (t) => registered.push(t) } })
|
|
212
|
+
assert.doesNotThrow(() => apply(ctx))
|
|
213
|
+
// 动态 import('@deepseek-ai/dsh-tools') 在本测试环境不可解析 → 工具跳过,
|
|
214
|
+
// 但绝不能让插件 apply 抛错;异步分支也不能产生未处理拒绝。
|
|
215
|
+
assert.equal(registered.length, 0)
|
|
216
|
+
})
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-message-ops — 零依赖测试(node --test)。
|
|
3
|
+
* 覆盖:帧扫描/读写往返、消息列表、遮蔽推导、分支规划与落盘(临时目录)。
|
|
4
|
+
*/
|
|
5
|
+
import test from 'node:test'
|
|
6
|
+
import assert from 'node:assert/strict'
|
|
7
|
+
import fs from 'node:fs'
|
|
8
|
+
import os from 'node:os'
|
|
9
|
+
import path from 'node:path'
|
|
10
|
+
|
|
11
|
+
process.env.DSH_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-msgops-'))
|
|
12
|
+
|
|
13
|
+
const { scanZstdFrames, readSessionFile, encodeSessionFile, findSessionDirs, listMessages, computeShadowed } =
|
|
14
|
+
await import('../src/session-file.js')
|
|
15
|
+
const { planBranch, applyBranch } = await import('../src/branch.js')
|
|
16
|
+
|
|
17
|
+
const MAGIC = 0xfd2fb528
|
|
18
|
+
|
|
19
|
+
function header(id) {
|
|
20
|
+
return { type: 'session', version: 3, id, createdAt: Date.now(), cwd: '/tmp/x', delegationDepth: 0 }
|
|
21
|
+
}
|
|
22
|
+
function msg(type, seq, text) {
|
|
23
|
+
return { type, seq, time: Date.now(), surfaceOp: 'append', data: { turn: 1, step: 1, message: { role: type === 'user/message' ? 'user' : 'assistant', content: [{ type: 'text', text }] } } }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function writeLog(dir, header, events) {
|
|
27
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
28
|
+
const p = path.join(dir, 'session.v3.jsonl.zstd')
|
|
29
|
+
fs.writeFileSync(p, encodeSessionFile(header, events))
|
|
30
|
+
return p
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
test('zstd 帧扫描定位每个魔数边界', () => {
|
|
34
|
+
const a = encodeSessionFile(header('session-00000000-0000-0000-0000-000000000001'), [msg('user/message', 0, 'hi')])
|
|
35
|
+
const b = encodeSessionFile(header('session-00000000-0000-0000-0000-000000000002'), [msg('user/message', 0, 'yo')])
|
|
36
|
+
const buf = Buffer.concat([a, b])
|
|
37
|
+
const { frames } = scanZstdFrames(buf)
|
|
38
|
+
assert.equal(frames.length, 4) // 2 files × (header frame + event frame)
|
|
39
|
+
for (const f of frames) assert.equal(buf.readUInt32LE(f.start), MAGIC)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('读写往返:header 与事件完整恢复', () => {
|
|
43
|
+
const events = [
|
|
44
|
+
msg('user/message', 0, '你好'),
|
|
45
|
+
msg('assistant/message', 1, '你好!有什么可以帮你?'),
|
|
46
|
+
{ type: 'tool/call', seq: 2, data: { name: 'bash' } },
|
|
47
|
+
]
|
|
48
|
+
const p = writeLog(path.join(process.env.DSH_HOME, 'sessions', 'proj-a', 'session-00000000-0000-0000-0000-000000000003'), header('session-00000000-0000-0000-0000-000000000003'), events)
|
|
49
|
+
const { header: h, events: evs } = readSessionFile(p)
|
|
50
|
+
assert.equal(h.type, 'session')
|
|
51
|
+
assert.equal(evs.length, 3)
|
|
52
|
+
assert.equal(evs[0].data.message.content[0].text, '你好')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('listMessages 提取 role/snippet 与 tool 摘要', () => {
|
|
56
|
+
const events = [msg('user/message', 0, ' 多 空格\n文本 '), msg('assistant/message', 1, '回复'), { type: 'tool/call', seq: 2, data: { name: 'bash' } }]
|
|
57
|
+
const ms = listMessages(events)
|
|
58
|
+
assert.equal(ms.length, 3)
|
|
59
|
+
assert.equal(ms[0].role, 'user')
|
|
60
|
+
assert.equal(ms[0].snippet, '多 空格 文本')
|
|
61
|
+
assert.equal(ms[2].role, 'tool')
|
|
62
|
+
assert.match(ms[2].snippet, /\[tool\] bash/)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('computeShadowed 识别 surface replace 遮蔽区间', () => {
|
|
66
|
+
const events = [
|
|
67
|
+
msg('user/message', 0, 'a'),
|
|
68
|
+
msg('assistant/message', 1, 'b'),
|
|
69
|
+
msg('user/message', 2, 'c'),
|
|
70
|
+
{ type: 'system/message', seq: 3, surfaceOp: { op: 'replace', startSeq: 1, endSeq: 2 }, data: { message: { role: 'system', content: [{ type: 'text', text: '[回滚]' }] } } },
|
|
71
|
+
]
|
|
72
|
+
const shadowed = computeShadowed(events)
|
|
73
|
+
assert.ok(shadowed.has(1) && shadowed.has(2))
|
|
74
|
+
assert.ok(!shadowed.has(0))
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('planBranch 生成新 id + parentSession,事件截断正确', () => {
|
|
78
|
+
const h = header('session-00000000-0000-0000-0000-000000000004')
|
|
79
|
+
const events = [msg('user/message', 0, 'a'), msg('assistant/message', 1, 'b'), msg('user/message', 2, 'c')]
|
|
80
|
+
const { header: nh, events: kept } = planBranch(h, events, 1)
|
|
81
|
+
assert.notEqual(nh.id, h.id)
|
|
82
|
+
assert.equal(nh.parentSession, h.id)
|
|
83
|
+
assert.match(nh.id, /^session-[0-9a-f-]{36}$/)
|
|
84
|
+
assert.equal(kept.length, 2)
|
|
85
|
+
assert.throws(() => planBranch(h, events, -1))
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('applyBranch 落盘:新目录 + 新日志 + 原文件字节不变', () => {
|
|
89
|
+
const id = 'session-00000000-0000-0000-0000-000000000005'
|
|
90
|
+
const events = [msg('user/message', 0, 'a'), msg('assistant/message', 1, 'b'), msg('user/message', 2, 'c')]
|
|
91
|
+
const origLog = writeLog(path.join(process.env.DSH_HOME, 'sessions', 'proj-b', id), header(id), events)
|
|
92
|
+
const before = fs.readFileSync(origLog)
|
|
93
|
+
const result = applyBranch(origLog, 1)
|
|
94
|
+
assert.match(result.newId, /^session-[0-9a-f-]{36}$/)
|
|
95
|
+
assert.ok(fs.existsSync(result.logPath))
|
|
96
|
+
const { header: nh, events: kept } = readSessionFile(result.logPath)
|
|
97
|
+
assert.equal(nh.parentSession, id)
|
|
98
|
+
assert.equal(kept.length, 2)
|
|
99
|
+
assert.deepEqual(fs.readFileSync(origLog), before)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('findSessionDirs 两种 id 拼写均可定位', () => {
|
|
103
|
+
const uuid = '11111111-2222-3333-4444-555555555555'
|
|
104
|
+
writeLog(path.join(process.env.DSH_HOME, 'sessions', 'proj-c', `session-${uuid}`), header(`session-${uuid}`), [msg('user/message', 0, 'x')])
|
|
105
|
+
const byFull = findSessionDirs(`session-${uuid}`)
|
|
106
|
+
const byBare = findSessionDirs(uuid)
|
|
107
|
+
assert.equal(byFull.length, 1)
|
|
108
|
+
assert.equal(byBare.length, 1)
|
|
109
|
+
assert.equal(byFull[0].dir, byBare[0].dir)
|
|
110
|
+
})
|