dsh-rewind-plugin 0.1.0
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 +198 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +469 -0
- package/lib/index.js +461 -0
- package/package.json +103 -0
- package/scripts/build.mjs +80 -0
- package/scripts/verify-host.mjs +159 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Host-half verification: boots the built plugin (`lib/index.js`) on a real
|
|
4
|
+
* cordis context with a real dsh-session, then drives the `/rewind` command
|
|
5
|
+
* handler and the tools-pipeline ledger events end to end — no model, no UI.
|
|
6
|
+
*
|
|
7
|
+
* Run: `npm run build && node scripts/verify-host.mjs`
|
|
8
|
+
*
|
|
9
|
+
* What it proves:
|
|
10
|
+
* 1. the plugin registers a `rewind` command on the ctx;
|
|
11
|
+
* 2. `/rewind` (no args) lists recent user messages;
|
|
12
|
+
* 3. `/rewind @<seq> chat` cuts the surface in-place (log untouched);
|
|
13
|
+
* 4. the ledger captures through `tools/execute` (NOT pre-execute): a
|
|
14
|
+
* pre-execute `ask` short-circuit still gets captured after approval, and
|
|
15
|
+
* a denied call never captures (no pending leak);
|
|
16
|
+
* 5. relative file paths resolve against the session cwd (fs-tools rule);
|
|
17
|
+
* 6. `/rewind preview @<seq> both` reports the file impact;
|
|
18
|
+
* 7. `/rewind @<seq> both` restores the file and reports it.
|
|
19
|
+
*/
|
|
20
|
+
import { Context } from '@deepseek-ai/cordis'
|
|
21
|
+
import { FileSystem, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
|
|
22
|
+
import { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
23
|
+
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
|
24
|
+
import { join } from 'node:path'
|
|
25
|
+
import { apply as applyRewind } from '../lib/index.js'
|
|
26
|
+
|
|
27
|
+
const aborted = () => new AbortController().signal
|
|
28
|
+
|
|
29
|
+
/** In-memory fs double with session-cwd resolution (resolve/readText/writeText/processPath). */
|
|
30
|
+
class FakeFs extends FileSystem {
|
|
31
|
+
files = new Map()
|
|
32
|
+
async resolve(path, opts = {}) {
|
|
33
|
+
const displayPath = opts?.cwd !== undefined && !path.startsWith('/') ? join(opts.cwd, path) : path
|
|
34
|
+
return { targetKey: FsTargetKey(displayPath), displayPath }
|
|
35
|
+
}
|
|
36
|
+
processPath(target) { return target.displayPath }
|
|
37
|
+
async readText(target) {
|
|
38
|
+
const content = this.files.get(target.displayPath)
|
|
39
|
+
if (content === undefined) throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
|
|
40
|
+
return content
|
|
41
|
+
}
|
|
42
|
+
async writeText(target, content) { this.files.set(target.displayPath, content); return { operation: 'update', version: FsVersion('v'), before: null, after: content } }
|
|
43
|
+
async stat(target) { return this.files.has(target.displayPath) ? { version: FsVersion('v'), type: 'file' } : undefined }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const fs = new FakeFs(new Context())
|
|
47
|
+
fs.files.set('/workspace/a.txt', 'original content')
|
|
48
|
+
|
|
49
|
+
const user = text => createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } })
|
|
50
|
+
const assistant = text => createAssistantMessage({ content: [{ type: 'text', text }], source: { provider: 'test', model: 'test' } })
|
|
51
|
+
|
|
52
|
+
function buildSession(id, cwd) {
|
|
53
|
+
const session = Session.create(SessionId(id), undefined,
|
|
54
|
+
cwd !== undefined
|
|
55
|
+
? { version: 0, id: SessionId(id), createdAt: Date.now(), cwd }
|
|
56
|
+
: undefined)
|
|
57
|
+
session.append('user/message', user('first question'), { surfaceOp: 'append' })
|
|
58
|
+
session.append('assistant/message', { turn: 0, step: 0, message: assistant('first answer') }, { surfaceOp: 'append' })
|
|
59
|
+
session.append('user/message', user('second question'), { surfaceOp: 'append' })
|
|
60
|
+
session.append('assistant/message', { turn: 1, step: 0, message: assistant('second answer') }, { surfaceOp: 'append' })
|
|
61
|
+
return session
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const session = buildSession('verify-host')
|
|
65
|
+
const agent = { id: session.id, session, status: 'idle' }
|
|
66
|
+
|
|
67
|
+
const ctx = new Context()
|
|
68
|
+
let registered = null
|
|
69
|
+
ctx.provide('commands', {
|
|
70
|
+
register: definition => {
|
|
71
|
+
registered = definition
|
|
72
|
+
return () => {}
|
|
73
|
+
},
|
|
74
|
+
})
|
|
75
|
+
ctx.provide('fs', fs)
|
|
76
|
+
applyRewind(ctx)
|
|
77
|
+
|
|
78
|
+
const call = (agentOf, rawInput) => registered.handler({ commandId: Symbol('cid'), agent: agentOf, rawInput, signal: aborted() })
|
|
79
|
+
|
|
80
|
+
let failures = 0
|
|
81
|
+
const check = (name, ok, detail) => {
|
|
82
|
+
console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${ok ? '' : ` — ${detail}`}`)
|
|
83
|
+
if (!ok) failures += 1
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 1. command registered
|
|
87
|
+
check('command registered', typeof registered?.handler === 'function' && registered.name === 'rewind', JSON.stringify(registered))
|
|
88
|
+
|
|
89
|
+
// 2. bare /rewind lists candidates
|
|
90
|
+
const listResult = await call(agent, '')
|
|
91
|
+
check('bare /rewind lists candidates', listResult.kind === 'success' && listResult.text.includes('second question') && listResult.text.includes('first question'), listResult.text)
|
|
92
|
+
|
|
93
|
+
// 3. /rewind @2 chat cuts the surface in place
|
|
94
|
+
const before = [...session.surface.nodes]
|
|
95
|
+
const chatResult = await call(agent, '@2 chat')
|
|
96
|
+
const after = [...session.surface.nodes]
|
|
97
|
+
check('rewind chat succeeds', chatResult.kind === 'success', chatResult.text)
|
|
98
|
+
check('surface cut to [0,1,2,marker]', after.length === 4 && after[0] === 0 && after[1] === 1 && after[2] === 2 && after[3] > 3, `before ${JSON.stringify(before)} -> after ${JSON.stringify(after)}`)
|
|
99
|
+
check('log stays append-only (5 events)', session.events.length === 5, `events=${session.events.length}`)
|
|
100
|
+
|
|
101
|
+
const writeExec = (callId, filePath, content) => ({
|
|
102
|
+
callId, name: 'write', arguments: { file_path: filePath, content }, agent, signal: aborted(),
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
// 4. a pre-execute `ask` short-circuit (dsh-edit-approval) must not skip the
|
|
106
|
+
// capture: capture happens in tools/execute, which runs after approval.
|
|
107
|
+
{
|
|
108
|
+
const exec = writeExec('c1', '/workspace/a.txt', 'rewritten')
|
|
109
|
+
// Another plugin asks at pre-execute; the user then allows it.
|
|
110
|
+
const gate = await ctx.waterfall('tools/pre-execute', exec, async () => ({ kind: 'ask', reason: 'approve me' }))
|
|
111
|
+
check('pre-execute gate asks', gate.kind === 'ask', JSON.stringify(gate))
|
|
112
|
+
// Approved → dispatch stage runs: capture fires here.
|
|
113
|
+
await ctx.waterfall('tools/execute', exec, async () => ({ isError: false, content: [] }))
|
|
114
|
+
await fs.writeText({ targetKey: FsTargetKey('/workspace/a.txt'), displayPath: '/workspace/a.txt' }, 'rewritten')
|
|
115
|
+
await ctx.waterfall('tools/post-execute', exec, { isError: false, content: [] }, async () => ({ kind: 'accept' }))
|
|
116
|
+
check('file mutated on disk', fs.files.get('/workspace/a.txt') === 'rewritten', fs.files.get('/workspace/a.txt'))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// 5. a denied call never captures (no pending leak: nothing recorded after it)
|
|
120
|
+
{
|
|
121
|
+
const exec = writeExec('c2', '/workspace/a.txt', 'denied write')
|
|
122
|
+
const gate = await ctx.waterfall('tools/pre-execute', exec, async () => ({ kind: 'deny', reason: 'no' }))
|
|
123
|
+
check('pre-execute gate denies', gate.kind === 'deny', JSON.stringify(gate))
|
|
124
|
+
// Denied calls do not dispatch: post-execute must record nothing for c2.
|
|
125
|
+
await ctx.waterfall('tools/post-execute', exec, { isError: true, error: { message: 'denied', info: { name: 'x', code: 'y' } }, content: [] }, async () => ({ kind: 'accept' }))
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// 6. relative paths resolve against the session cwd (fs-tools rule)
|
|
129
|
+
{
|
|
130
|
+
const cwdSession = buildSession('verify-cwd', '/workspace')
|
|
131
|
+
const cwdAgent = { id: cwdSession.id, session: cwdSession, status: 'idle' }
|
|
132
|
+
fs.files.set('/workspace/rel.txt', 'relative original')
|
|
133
|
+
const exec = { callId: 'c3', name: 'write', arguments: { file_path: 'rel.txt', content: 'relative new' }, agent: cwdAgent, signal: aborted() }
|
|
134
|
+
await ctx.waterfall('tools/execute', exec, async () => ({ isError: false, content: [] }))
|
|
135
|
+
await fs.writeText({ targetKey: FsTargetKey('/workspace/rel.txt'), displayPath: '/workspace/rel.txt' }, 'relative new')
|
|
136
|
+
await ctx.waterfall('tools/post-execute', exec, { isError: false, content: [] }, async () => ({ kind: 'accept' }))
|
|
137
|
+
// Rewind to seq 2 in the cwd session must report the cwd-resolved path.
|
|
138
|
+
const preview = await call(cwdAgent, 'preview @2 both')
|
|
139
|
+
check('preview resolves relative path via session cwd', preview.kind === 'success' && preview.text.includes('/workspace/rel.txt'), preview.text)
|
|
140
|
+
const both = await call(cwdAgent, '@2 both')
|
|
141
|
+
check('both restores cwd-resolved file', both.kind === 'success' && fs.files.get('/workspace/rel.txt') === 'relative original', both.text)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 7. preview reports the impact (rewind to seq 2 reverts the anchor-2 write)
|
|
145
|
+
const previewResult = await call(agent, 'preview @2 both')
|
|
146
|
+
check('preview shows file impact', previewResult.kind === 'success' && previewResult.text.includes('/workspace/a.txt'), previewResult.text)
|
|
147
|
+
|
|
148
|
+
// 8. both mode restores the file
|
|
149
|
+
const bothResult = await call(agent, '@2 both')
|
|
150
|
+
check('rewind both restores file', bothResult.kind === 'success' && bothResult.text.includes('还原 1 个文件'), bothResult.text)
|
|
151
|
+
check('file content restored', fs.files.get('/workspace/a.txt') === 'original content', fs.files.get('/workspace/a.txt'))
|
|
152
|
+
|
|
153
|
+
// 9. safety guard: running agent refuses
|
|
154
|
+
const running = { ...agent, status: 'running' }
|
|
155
|
+
const runningResult = await registered.handler({ commandId: Symbol('cid'), agent: running, rawInput: '@2 chat', signal: aborted() })
|
|
156
|
+
check('running agent refused', runningResult.kind === 'error', runningResult.text)
|
|
157
|
+
|
|
158
|
+
console.log(failures === 0 ? '\nverify-host: all checks passed' : `\nverify-host: ${failures} check(s) FAILED`)
|
|
159
|
+
process.exit(failures === 0 ? 0 : 1)
|