dsh-rewind-plugin 0.2.0 → 0.2.1

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,33 @@
1
+ /**
2
+ * Pure computation of the chat rows a rewind hides from the rendered
3
+ * transcript. Extracted from the client plugin (`src/client/index.ts`) so the
4
+ * multi-rewind cut logic stays unit-testable without a DOM.
5
+ *
6
+ * @module dsh-rewind/client/hidden
7
+ */
8
+ import type { ChatConversationViewNode } from '@deepseek-ai/dsh-client-runtime/client';
9
+ /** Minimal chat snapshot reader the hiding logic needs. */
10
+ export interface HiddenChat {
11
+ readonly order: readonly string[];
12
+ readonly nodes: {
13
+ get(key: string): ChatConversationViewNode | undefined;
14
+ };
15
+ }
16
+ /** Extract the rewind target from a command outcome text ("已撤回 seq N..."). */
17
+ export declare function targetOfOutcome(text: string | undefined): number | undefined;
18
+ /**
19
+ * Anchor seqs that must be hidden from the rendered transcript so the user
20
+ * sees the conversation as the agent sees it: every EXECUTED `/rewind`
21
+ * command row (one that appended a marker; preview-only rows stay visible
22
+ * until a real rewind's range covers them) and every message withdrawn by a
23
+ * rewind — the target message itself, everything after it, and the (empty,
24
+ * unrendered) marker.
25
+ *
26
+ * The cut span is [min target, max marker] across ALL executed rewinds, not
27
+ * just the newest one: every rewind withdraws its target and everything after
28
+ * it, and a later rewind to a LATER point (after new traffic) must not re-show
29
+ * rows an earlier rewind already cut. Endpoints come from the command nodes:
30
+ * `sourceEventSeq` is the marker's log seq, and the outcome text carries the
31
+ * target seq.
32
+ */
33
+ export declare function hiddenSeqsOf(snap: HiddenChat): Set<number>;
@@ -4,11 +4,13 @@ export declare const zh: {
4
4
  'button.aria': string;
5
5
  'button.title': string;
6
6
  'popover.title': string;
7
- 'popover.target': string;
7
+ 'popover.noText': string;
8
8
  'popover.chat': string;
9
9
  'popover.chat.hint': string;
10
10
  'popover.both': string;
11
11
  'popover.both.hint': string;
12
+ 'popover.checking': string;
13
+ 'popover.noChanges': string;
12
14
  'popover.cancel': string;
13
15
  'popover.impact.loading': string;
14
16
  'popover.impact.failed': string;
@@ -30,11 +32,13 @@ export declare const en: {
30
32
  'button.aria': string;
31
33
  'button.title': string;
32
34
  'popover.title': string;
33
- 'popover.target': string;
35
+ 'popover.noText': string;
34
36
  'popover.chat': string;
35
37
  'popover.chat.hint': string;
36
38
  'popover.both': string;
37
39
  'popover.both.hint': string;
40
+ 'popover.checking': string;
41
+ 'popover.noChanges': string;
38
42
  'popover.cancel': string;
39
43
  'popover.impact.loading': string;
40
44
  'popover.impact.failed': string;
@@ -25,4 +25,4 @@ export declare const CLASS: {
25
25
  /** The ↶ glyph, drawn inline so the bundle stays dependency-free. */
26
26
  export declare const REWIND_ICON_SVG: string;
27
27
  /** One injected stylesheet (scoped under `.dsh-rewind-*`). */
28
- export declare const STYLE = "\n.dsh-rewind-btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 28px;\n height: 28px;\n padding: 6px;\n border: none;\n border-radius: 28px;\n background: transparent;\n color: var(--dsw-alias-label-tertiary);\n cursor: pointer;\n}\n.dsh-rewind-btn:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n color: var(--dsw-alias-label-secondary);\n}\n\n.dsh-rewind-popover {\n position: fixed;\n z-index: 1000;\n width: 288px;\n padding: 12px;\n border-radius: 12px;\n background: var(--dsw-specific-surface-1, var(--dsw-alias-surface-1, #1f2127));\n box-shadow: 0 8px 28px rgba(0, 0, 0, 0.32);\n font-size: 14px;\n line-height: 20px;\n color: var(--dsw-alias-label-primary);\n}\n.dsh-rewind-popover-title {\n font-size: 14px;\n font-weight: 600;\n line-height: 20px;\n}\n.dsh-rewind-popover-target {\n margin: 4px 0 10px;\n font-size: 12px;\n line-height: 16px;\n color: var(--dsw-alias-label-tertiary);\n word-break: break-all;\n}\n.dsh-rewind-popover-option {\n display: flex;\n flex-direction: column;\n gap: 2px;\n width: 100%;\n margin: 0 0 6px;\n padding: 8px 10px;\n border: 1px solid transparent;\n border-radius: 8px;\n background: transparent;\n color: inherit;\n font: inherit;\n text-align: left;\n cursor: pointer;\n}\n.dsh-rewind-popover-option:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n.dsh-rewind-popover-option-label {\n font-weight: 500;\n}\n.dsh-rewind-popover-option-hint {\n font-size: 12px;\n line-height: 16px;\n color: var(--dsw-alias-label-tertiary);\n}\n.dsh-rewind-popover-impact {\n margin: 4px 0 10px;\n padding: 8px 10px;\n border-radius: 8px;\n background: var(--dsw-alias-interactive-bg-hover);\n font-size: 12px;\n line-height: 16px;\n color: var(--dsw-alias-label-secondary);\n white-space: pre-wrap;\n max-height: 160px;\n overflow: auto;\n}\n.dsh-rewind-popover-actions {\n display: flex;\n justify-content: flex-end;\n gap: 8px;\n}\n.dsh-rewind-popover-primary,\n.dsh-rewind-popover-ghost {\n padding: 5px 12px;\n border: none;\n border-radius: 8px;\n font: inherit;\n font-size: 13px;\n line-height: 18px;\n cursor: pointer;\n}\n.dsh-rewind-popover-primary {\n background: var(--dsw-alias-accent, var(--dsw-accent, #5b8cff));\n color: var(--dsw-alias-on-accent, #fff);\n}\n.dsh-rewind-popover-primary:disabled {\n opacity: 0.5;\n cursor: default;\n}\n.dsh-rewind-popover-ghost {\n background: transparent;\n color: var(--dsw-alias-label-secondary);\n}\n.dsh-rewind-popover-ghost:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n\n.dsh-rewind-guard-hint {\n position: fixed;\n z-index: 1000;\n max-width: min(440px, calc(100vw - 24px));\n padding: 8px 12px;\n border-radius: 10px;\n background: var(--dsw-specific-surface-1, var(--dsw-alias-surface-1, #1f2127));\n box-shadow: 0 8px 28px rgba(0, 0, 0, 0.32);\n font-size: 13px;\n line-height: 18px;\n color: var(--dsw-alias-label-primary);\n pointer-events: none;\n}\n";
28
+ export declare const STYLE = "\n.dsh-rewind-btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 28px;\n height: 28px;\n padding: 6px;\n border: none;\n border-radius: 28px;\n background: transparent;\n color: var(--dsw-alias-label-tertiary);\n cursor: pointer;\n}\n.dsh-rewind-btn:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n color: var(--dsw-alias-label-secondary);\n}\n\n.dsh-rewind-popover {\n position: fixed;\n z-index: 1000;\n width: 288px;\n padding: 12px;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 12px;\n background: var(--dsw-specific-menu, var(--dsw-alias-bg-layer-3));\n box-shadow: var(--dsw-shadow-lv3);\n font-size: 14px;\n line-height: 20px;\n color: var(--dsw-alias-label-primary);\n}\n.dsh-rewind-popover-title {\n font-size: 14px;\n font-weight: 600;\n line-height: 20px;\n}\n.dsh-rewind-popover-target {\n margin: 4px 0 10px;\n font-size: 12px;\n line-height: 16px;\n color: var(--dsw-alias-label-tertiary);\n word-break: break-all;\n}\n.dsh-rewind-popover-option {\n display: flex;\n flex-direction: column;\n gap: 2px;\n width: 100%;\n margin: 0 0 6px;\n padding: 8px 10px;\n border: 1px solid transparent;\n border-radius: 8px;\n background: transparent;\n color: inherit;\n font: inherit;\n text-align: left;\n cursor: pointer;\n}\n.dsh-rewind-popover-option:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n.dsh-rewind-popover-option:disabled {\n opacity: 0.5;\n cursor: default;\n}\n.dsh-rewind-popover-option-label {\n font-weight: 500;\n}\n.dsh-rewind-popover-option-hint {\n font-size: 12px;\n line-height: 16px;\n color: var(--dsw-alias-label-tertiary);\n}\n.dsh-rewind-popover-impact {\n margin: 4px 0 10px;\n padding: 8px 10px;\n border-radius: 8px;\n background: var(--dsw-alias-interactive-bg-hover);\n font-size: 12px;\n line-height: 16px;\n color: var(--dsw-alias-label-secondary);\n white-space: pre-wrap;\n max-height: 160px;\n overflow: auto;\n}\n.dsh-rewind-popover-actions {\n display: flex;\n justify-content: flex-end;\n gap: 8px;\n}\n.dsh-rewind-popover-primary,\n.dsh-rewind-popover-ghost {\n padding: 5px 12px;\n border: none;\n border-radius: 8px;\n font: inherit;\n font-size: 13px;\n line-height: 18px;\n cursor: pointer;\n}\n.dsh-rewind-popover-primary {\n background: var(--dsw-alias-button-primary-fill);\n color: var(--dsw-alias-label-primary-foreground);\n}\n.dsh-rewind-popover-primary:hover:not(:disabled) {\n background: var(--dsw-alias-button-primary-hover);\n}\n.dsh-rewind-popover-primary:disabled {\n opacity: 0.5;\n cursor: default;\n}\n.dsh-rewind-popover-ghost {\n background: transparent;\n color: var(--dsw-alias-label-secondary);\n}\n.dsh-rewind-popover-ghost:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n\n.dsh-rewind-guard-hint {\n position: fixed;\n z-index: 1000;\n max-width: min(440px, calc(100vw - 24px));\n padding: 8px 12px;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 10px;\n background: var(--dsw-specific-menu, var(--dsw-alias-bg-layer-3));\n box-shadow: var(--dsw-shadow-lv3);\n font-size: 13px;\n line-height: 18px;\n color: var(--dsw-alias-label-primary);\n pointer-events: none;\n}\n";
@@ -23,7 +23,8 @@
23
23
  * Restore semantics (identical to Claude Code): for every path with entries
24
24
  * anchored at or after the target message, apply the EARLIEST entry — write
25
25
  * the before content back, or delete the file when that entry recorded a
26
- * creation. Symbolic links are skipped and reported, never written through.
26
+ * creation. Symlinked and hard-linked paths are skipped and reported, never
27
+ * written through.
27
28
  *
28
29
  * @module dsh-rewind/snapshot
29
30
  */
@@ -55,6 +56,7 @@ export interface FileImpact {
55
56
  export interface RestoreOutcome {
56
57
  readonly restored: readonly string[];
57
58
  readonly deleted: readonly string[];
59
+ /** Symlinked or hard-linked paths left untouched. */
58
60
  readonly skipped: readonly string[];
59
61
  readonly failed: readonly {
60
62
  path: string;
@@ -69,7 +71,12 @@ export type DeleteFile = (path: string) => Promise<void>;
69
71
  */
70
72
  export declare class SnapshotStore {
71
73
  readonly root: string;
74
+ /** Debounce window for the per-commit prune (keeps the readdir+sort off the hot path). */
75
+ private static readonly PRUNE_INTERVAL_MS;
76
+ private lastPruneAt;
72
77
  constructor(root?: string);
78
+ /** Absolute path of one session's snapshot directory (id sanitized). */
79
+ sessionDir(sessionId: string): string;
73
80
  /** Absolute path of one anchor group directory. */
74
81
  anchorDir(sessionId: string, anchorSeq: number): string;
75
82
  /** Commit one before-backup under its turn's anchor group. */
@@ -82,14 +89,21 @@ export declare class SnapshotStore {
82
89
  * earlier messages survive.
83
90
  */
84
91
  entriesAfter(sessionId: string, targetSeq: number): Promise<CheckpointEntry[]>;
92
+ /**
93
+ * Per-path EARLIEST committed entry anchored at or after the target — the
94
+ * single source of truth for both restore and impact preview.
95
+ */
96
+ private earliestEntries;
85
97
  /** Per-file restore impact for the earliest entry at/after the target. */
86
98
  impactsAfter(sessionId: string, targetSeq: number): Promise<FileImpact[]>;
87
99
  /**
88
100
  * Restore the workspace to the target message's checkpoint: for every path
89
101
  * with entries anchored at or after it, apply the EARLIEST entry — write the
90
102
  * before content back, or delete the file when it was created after the
91
- * target. Symbolic links are skipped (reported, never written through).
92
- * Failures are per-file and never abort the pass.
103
+ * target. Symlinked and hard-linked paths are skipped (reported, never
104
+ * written through); a restored file's parent directory is created when it
105
+ * was deleted after the backup. Failures are per-file and never abort the
106
+ * pass.
93
107
  */
94
108
  restoreAfter(sessionId: string, targetSeq: number, deleteFile: DeleteFile): Promise<RestoreOutcome>;
95
109
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-rewind-plugin",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "DeepSeek Harness plugin: in-place conversation rewind in the same session window (Claude Code /rewind semantics) with optional workspace file restore",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -32,14 +32,15 @@
32
32
  "types": "./lib/types/client/index.d.ts",
33
33
  "default": "./lib/client.js"
34
34
  },
35
- "./src/*": "./src/*",
36
35
  "./package.json": "./package.json"
37
36
  },
38
37
  "files": [
39
38
  "lib",
40
39
  "cordis.patch.yml",
41
- "scripts",
42
40
  "README.md",
41
+ "README.zh.md",
42
+ "docs",
43
+ "assets",
43
44
  "LICENSE"
44
45
  ],
45
46
  "dsh": {
package/scripts/build.mjs DELETED
@@ -1,89 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * dsh-rewind build — produces the npm-package artifacts under lib/:
4
- * lib/index.js — host half: src/index.ts bundled to plain ESM. Every
5
- * `@deepseek-ai/*` import stays external: the dsh loader
6
- * resolves those from the harness installation, never from
7
- * this package.
8
- * lib/client.js — client half: src/client/index.ts bundled to CJS, then
9
- * wrapped in the web boot handoff
10
- * `window.__ModuleLoader__.load({ id, factory })`, the
11
- * closure-factory format every `dsh.client` package's
12
- * `./client` export must use. The client half is fully
13
- * self-contained (plain DOM, no React), so nothing is
14
- * external and the injected `require` is never called.
15
- *
16
- * Type checking is a separate step (`npm run typecheck`, tsc --noEmit); this
17
- * script only transpiles (esbuild) and runs smoke checks: both outputs must
18
- * parse, and the host half must import with the expected plugin shape.
19
- */
20
- import { build } from 'esbuild'
21
- import { execSync } from 'node:child_process'
22
- import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
23
- import { dirname, join } from 'node:path'
24
- import { fileURLToPath } from 'node:url'
25
-
26
- const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
27
- const pkg = JSON.parse(await readFile(join(ROOT, 'package.json'), 'utf8'))
28
-
29
- await mkdir(join(ROOT, 'lib'), { recursive: true })
30
-
31
- // ---- type declarations: host (lib/types) + client (lib/types/client) ----
32
- execSync('npx tsc -p tsconfig.build.json', { cwd: ROOT, stdio: 'inherit' })
33
- execSync('npx tsc -p tsconfig.client.json', { cwd: ROOT, stdio: 'inherit' })
34
-
35
- // ---- host half: bundled TS -> ESM (@deepseek-ai/* stays external) ----
36
- await build({
37
- entryPoints: [join(ROOT, 'src', 'index.ts')],
38
- outfile: join(ROOT, 'lib', 'index.js'),
39
- format: 'esm',
40
- platform: 'node',
41
- target: 'es2024',
42
- bundle: true,
43
- external: ['@deepseek-ai/*'],
44
- sourcemap: false,
45
- })
46
-
47
- // ---- client half: bundled TS -> CJS, then wrapped in the loader handoff ----
48
- await build({
49
- entryPoints: [join(ROOT, 'src', 'client', 'index.ts')],
50
- outfile: join(ROOT, 'lib', '_client.js'),
51
- format: 'cjs',
52
- platform: 'browser',
53
- target: 'es2020',
54
- bundle: true,
55
- external: [],
56
- sourcemap: false,
57
- })
58
- const clientSource = await readFile(join(ROOT, 'lib', '_client.js'), 'utf8')
59
- await rm(join(ROOT, 'lib', '_client.js'))
60
-
61
- const bundle = [
62
- '/* dsh-rewind client bundle — generated by scripts/build.mjs from src/client/ */',
63
- 'window.__ModuleLoader__.load({',
64
- ` id: ${JSON.stringify(pkg.name)},`,
65
- ' factory: (require) => {',
66
- ' var module = { exports: {} };',
67
- ' var exports = module.exports;',
68
- clientSource.replace(/\s+$/, '\n'),
69
- ' return module.exports;',
70
- ' }',
71
- '});',
72
- '',
73
- ].join('\n')
74
- await writeFile(join(ROOT, 'lib', 'client.js'), bundle)
75
-
76
- // ---- smoke checks ----
77
- const host = await readFile(join(ROOT, 'lib', 'index.js'), 'utf8')
78
- const exportBlock = host.slice(host.lastIndexOf('export {'))
79
- for (const needle of ['name', 'inject', 'apply']) {
80
- if (!exportBlock.includes(needle)) throw new Error(`host bundle missing export ${needle}`)
81
- }
82
- for (const needle of ['window.__ModuleLoader__.load', `id: ${JSON.stringify(pkg.name)}`]) {
83
- if (!bundle.includes(needle)) throw new Error(`client bundle missing ${needle}`)
84
- }
85
- // Declarations must exist (published tarball carries them).
86
- for (const dts of ['lib/types/index.d.ts', 'lib/types/client/index.d.ts']) {
87
- await readFile(join(ROOT, dts), 'utf8')
88
- }
89
- console.log('build ok: lib/index.js (host), lib/client.js (client), lib/types/ (declarations)')
@@ -1,206 +0,0 @@
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 checkpoint pipeline end to end — no model, no UI. Files are
6
- * real files under a temporary directory, so a restore is verified against
7
- * actual on-disk content, and the checkpoint store root is overridden to that
8
- * temporary directory.
9
- *
10
- * Run: `npm run build && node scripts/verify-host.mjs`
11
- *
12
- * What it proves:
13
- * 1. the plugin registers a `rewind` command on the ctx;
14
- * 2. `/rewind` (no args) withdraws the most recent user message;
15
- * 3. `/rewind @<seq> chat` cuts the surface in-place (log untouched);
16
- * 4. a successful write through the tools pipeline commits a before-backup
17
- * under the turn's anchor seq;
18
- * 5. a denied call never commits (no phantom entry in the store);
19
- * 6. relative file paths resolve against the session cwd (fs-tools rule);
20
- * 7. `/rewind preview @<seq> both` reports the checkpoint impact;
21
- * 8. `/rewind @<seq> both` restores the real file to its pre-edit content
22
- * and deletes files created after the target;
23
- * 9. a running agent is force-stopped before the rewind (not refused);
24
- * 10. a cancel that never quiesces aborts the rewind (timeout path).
25
- */
26
- import { Context } from '@deepseek-ai/cordis'
27
- import { FileSystem, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
28
- import { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
29
- import { Session, SessionId } from '@deepseek-ai/dsh-session'
30
- import { mkdtemp, mkdir, rm, writeFile, readFile } from 'node:fs/promises'
31
- import { tmpdir } from 'node:os'
32
- import { join } from 'node:path'
33
- import { apply as applyRewind } from '../lib/index.js'
34
-
35
- const aborted = () => new AbortController().signal
36
-
37
- const tmpRoot = await mkdtemp(join(tmpdir(), 'dsh-rewind-verify-'))
38
- const wsDir = join(tmpRoot, 'ws')
39
- const snapRoot = join(tmpRoot, 'snapshots')
40
- await mkdir(wsDir, { recursive: true })
41
-
42
- /** Real-filesystem fs double: resolve returns the real display path. */
43
- class FakeFs extends FileSystem {
44
- async resolve(path, opts = {}) {
45
- const displayPath = opts?.cwd !== undefined && !path.startsWith('/') ? join(opts.cwd, path) : path
46
- return { targetKey: FsTargetKey(displayPath), displayPath }
47
- }
48
- processPath(target) { return target.displayPath }
49
- async readText(target) { return readFile(target.displayPath, 'utf8') }
50
- async writeText(target, content) { await writeFile(target.displayPath, content, 'utf8'); return { operation: 'update', version: FsVersion('v'), before: null, after: content } }
51
- async stat(target) {
52
- try { await readFile(target.displayPath); return { version: FsVersion('v'), type: 'file' } } catch { return undefined }
53
- }
54
- }
55
-
56
- const fs = new FakeFs(new Context())
57
-
58
- const user = text => createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } })
59
- const assistant = text => createAssistantMessage({ content: [{ type: 'text', text }], source: { provider: 'test', model: 'test' } })
60
-
61
- function buildSession(id, cwd) {
62
- const session = Session.create(SessionId(id), undefined,
63
- cwd !== undefined
64
- ? { version: 0, id: SessionId(id), createdAt: Date.now(), cwd }
65
- : undefined)
66
- session.append('user/message', user('first question'), { surfaceOp: 'append' })
67
- session.append('assistant/message', { turn: 0, step: 0, message: assistant('first answer') }, { surfaceOp: 'append' })
68
- session.append('user/message', user('second question'), { surfaceOp: 'append' })
69
- session.append('assistant/message', { turn: 1, step: 0, message: assistant('second answer') }, { surfaceOp: 'append' })
70
- return session
71
- }
72
-
73
- const session = buildSession('verify-host')
74
- const agent = { id: session.id, session, status: 'idle' }
75
-
76
- const ctx = new Context()
77
- let registered = null
78
- ctx.provide('commands', {
79
- register: definition => {
80
- registered = definition
81
- return () => {}
82
- },
83
- })
84
- ctx.provide('fs', fs)
85
- applyRewind(ctx, { snapshotDir: snapRoot })
86
-
87
- const call = (agentOf, rawInput) => registered.handler({ commandId: Symbol('cid'), agent: agentOf, rawInput, signal: aborted() })
88
-
89
- /** Simulate one tracked tool call: before-capture, dispatch writes the file, post-execute commits. */
90
- async function runWrite(agentOf, callId, filePath, content) {
91
- const exec = { callId, name: 'write', arguments: { file_path: filePath, content }, agent: agentOf, signal: aborted() }
92
- await ctx.waterfall('tools/execute', exec, async () => {
93
- await fs.writeText({ targetKey: FsTargetKey(filePath), displayPath: filePath }, content)
94
- return { isError: false, content: [] }
95
- })
96
- await ctx.waterfall('tools/post-execute', exec, { isError: false, content: [] }, async () => ({ kind: 'accept' }))
97
- }
98
-
99
- let failures = 0
100
- const check = (name, ok, detail) => {
101
- console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${ok ? '' : ` — ${detail}`}`)
102
- if (!ok) failures += 1
103
- }
104
-
105
- // 1. command registered
106
- check('command registered', typeof registered?.handler === 'function' && registered.name === 'rewind', JSON.stringify(registered))
107
-
108
- // 2. bare /rewind (manual, no parameters) withdraws the most recent user
109
- // message (seq 2 "second question") and everything after it
110
- const bareBefore = [...session.surface.nodes]
111
- const bareResult = await call(agent, '')
112
- const bareAfter = [...session.surface.nodes]
113
- check('bare /rewind succeeds', bareResult.kind === 'success', bareResult.text)
114
- check('bare /rewind withdraws the latest message', bareAfter.length === 3 && bareAfter[0] === 0 && bareAfter[1] === 1 && bareAfter[2] > 3, `before ${JSON.stringify(bareBefore)} -> after ${JSON.stringify(bareAfter)}`)
115
- check('log stays append-only (5 events)', session.events.length === 5, `events=${session.events.length}`)
116
-
117
- // 3. /rewind @<seq> chat (the button's exact call form) cuts the surface on a
118
- // fresh session
119
- const paramSession = buildSession('verify-param')
120
- const paramAgent = { id: paramSession.id, session: paramSession, status: 'idle' }
121
- const before = [...paramSession.surface.nodes]
122
- const chatResult = await call(paramAgent, '@2 chat')
123
- const after = [...paramSession.surface.nodes]
124
- check('rewind chat succeeds', chatResult.kind === 'success', chatResult.text)
125
- check('surface cut to [0,1,marker] (target withdrawn)', after.length === 3 && after[0] === 0 && after[1] === 1 && after[2] > 3, `before ${JSON.stringify(before)} -> after ${JSON.stringify(after)}`)
126
- check('log stays append-only (5 events)', paramSession.events.length === 5, `events=${paramSession.events.length}`)
127
-
128
- // 4. a tracked write commits a before-backup; rewinding both restores the
129
- // real file and deletes files created after the target
130
- {
131
- const aPath = join(wsDir, 'a.txt')
132
- await writeFile(aPath, 'original content', 'utf8')
133
- // The next user message anchors the turn that will edit a.txt (seq 5).
134
- session.append('user/message', user('third question'), { surfaceOp: 'append' })
135
- const anchorSeq = 5
136
- await runWrite(agent, 'c1', aPath, 'rewritten') // before-capture: 'original content'
137
- const createdPath = join(wsDir, 'created.txt')
138
- await runWrite(agent, 'c2', createdPath, 'new') // file did not exist: before-capture = created
139
- await writeFile(aPath, 'v3', 'utf8') // later edit lands after the backups
140
-
141
- const preview = await call(agent, `preview @${anchorSeq} both`)
142
- check('preview reports the file impact', preview.kind === 'success' && preview.text.includes(aPath) && preview.text.includes('还原'), preview.text)
143
-
144
- const both = await call(agent, `@${anchorSeq} both`)
145
- check('rewind both succeeds', both.kind === 'success' && both.text.includes('还原 1 个文件') && both.text.includes('删除 1 个文件'), both.text)
146
- check('modified file restored to pre-edit content', await readFile(aPath, 'utf8') === 'original content', await readFile(aPath, 'utf8'))
147
- let createdGone = false
148
- try { await readFile(createdPath, 'utf8') } catch { createdGone = true }
149
- check('created file deleted', createdGone, `exists=${!createdGone}`)
150
- }
151
-
152
- // 5. a denied call never commits (no phantom entry)
153
- {
154
- const deniedPath = join(wsDir, 'denied.txt')
155
- await writeFile(deniedPath, 'x', 'utf8')
156
- const exec = { callId: 'c3', name: 'write', arguments: { file_path: deniedPath, content: 'denied write' }, agent, signal: aborted() }
157
- await ctx.waterfall('tools/pre-execute', exec, async () => ({ kind: 'deny', reason: 'no' }))
158
- await ctx.waterfall('tools/post-execute', exec, { isError: true, error: { message: 'denied', info: { name: 'x', code: 'y' } }, content: [] }, async () => ({ kind: 'accept' }))
159
- const preview = await call(agent, 'preview @5 both')
160
- check('denied call is not in the impact list', !preview.text.includes(deniedPath), preview.text)
161
- }
162
-
163
- // 6. relative paths resolve against the session cwd (fs-tools rule)
164
- {
165
- const cwdSession = buildSession('verify-cwd', wsDir)
166
- const cwdAgent = { id: cwdSession.id, session: cwdSession, status: 'idle' }
167
- const relPath = join(wsDir, 'rel.txt')
168
- await writeFile(relPath, 'relative original', 'utf8')
169
- cwdSession.append('user/message', user('relative question'), { surfaceOp: 'append' })
170
- await runWrite(cwdAgent, 'c4', 'rel.txt', 'relative new') // relative path
171
-
172
- const preview = await call(cwdAgent, 'preview @4 both')
173
- check('relative path resolved via session cwd', preview.kind === 'success' && preview.text.includes(relPath), preview.text)
174
- const both = await call(cwdAgent, '@4 both')
175
- check('both restores cwd-resolved file', both.kind === 'success' && await readFile(relPath, 'utf8') === 'relative original', both.text)
176
- }
177
-
178
- // 7. preview on a message with no recorded changes reports none
179
- {
180
- const cleanSession = buildSession('verify-norec')
181
- const cleanAgent = { id: cleanSession.id, session: cleanSession, status: 'idle' }
182
- const previewResult = await call(cleanAgent, 'preview @2 both')
183
- check('preview with no entries reports no changes', previewResult.kind === 'success' && previewResult.text.includes('无需还原文件'), previewResult.text)
184
- }
185
-
186
- // 8. a running agent is force-stopped before the rewind (not refused)
187
- {
188
- const runningSession = buildSession('verify-running')
189
- let cancelled = false
190
- const running = { id: runningSession.id, session: runningSession, status: 'running', cancel: () => { cancelled = true; running.status = 'idle' } }
191
- const runningResult = await call(running, '@2 chat')
192
- check('running agent is cancelled first', cancelled === true, `cancelled=${cancelled}`)
193
- check('rewind succeeds after stop', runningResult.kind === 'success', runningResult.text)
194
- }
195
-
196
- // 9. a cancel that never quiesces aborts the rewind (timeout path)
197
- {
198
- const stuckSession = buildSession('verify-stuck')
199
- const stuck = { id: stuckSession.id, session: stuckSession, status: 'running', cancel: () => {} }
200
- const stuckResult = await call(stuck, '@2 chat')
201
- check('stuck agent aborts rewind', stuckResult.kind === 'error', stuckResult.text)
202
- }
203
-
204
- await rm(tmpRoot, { recursive: true, force: true })
205
- console.log(failures === 0 ? '\nverify-host: all checks passed' : `\nverify-host: ${failures} check(s) FAILED`)
206
- process.exit(failures === 0 ? 0 : 1)