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.
@@ -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)