@semalt-ai/code 1.8.4 → 1.19.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/.claude/settings.local.json +8 -1
- package/.github/workflows/ci.yml +69 -0
- package/CLAUDE.md +1588 -27
- package/README.md +147 -3
- package/TECHNICAL_DEBT.md +66 -0
- package/examples/embed.js +74 -0
- package/index.js +259 -11
- package/lib/agent.js +935 -181
- package/lib/api.js +308 -55
- package/lib/args.js +96 -2
- package/lib/audit.js +23 -1
- package/lib/background.js +584 -0
- package/lib/checkpoints.js +757 -0
- package/lib/commands/auth.js +94 -0
- package/lib/commands/chat-session.js +306 -0
- package/lib/commands/chat-slash.js +399 -0
- package/lib/commands/chat-turn.js +446 -0
- package/lib/commands/chat.js +403 -0
- package/lib/commands/custom.js +157 -0
- package/lib/commands/history-utils.js +66 -0
- package/lib/commands/index.js +268 -0
- package/lib/commands/mcp.js +113 -0
- package/lib/commands/oneshot.js +193 -0
- package/lib/commands/registry.js +269 -0
- package/lib/commands/tasks.js +89 -0
- package/lib/compact.js +87 -0
- package/lib/config.js +346 -11
- package/lib/constants.js +372 -3
- package/lib/debug.js +106 -0
- package/lib/deny.js +199 -0
- package/lib/doctor.js +160 -0
- package/lib/headless.js +167 -0
- package/lib/hooks.js +286 -0
- package/lib/images.js +264 -0
- package/lib/internals.js +49 -0
- package/lib/mcp/boundary.js +131 -0
- package/lib/mcp/client.js +270 -0
- package/lib/mcp/oauth.js +134 -0
- package/lib/memory.js +209 -0
- package/lib/metrics.js +37 -2
- package/lib/payload.js +54 -0
- package/lib/permission-rules.js +401 -0
- package/lib/permissions.js +100 -10
- package/lib/pricing.js +67 -0
- package/lib/proc.js +158 -0
- package/lib/prompts.js +88 -8
- package/lib/sandbox.js +568 -0
- package/lib/sdk.js +328 -0
- package/lib/secrets.js +211 -0
- package/lib/skills.js +223 -0
- package/lib/subagents.js +516 -0
- package/lib/tool_registry.js +2558 -0
- package/lib/tool_specs.js +236 -9
- package/lib/tools.js +370 -944
- package/lib/ui/chat-history.js +19 -1
- package/lib/ui/format.js +101 -6
- package/lib/ui/input-field.js +16 -7
- package/lib/ui/status-bar.js +79 -11
- package/lib/ui/terminal.js +10 -4
- package/lib/ui/theme.js +1 -0
- package/lib/ui/web-activity.js +218 -0
- package/lib/ui/writer.js +7 -9
- package/lib/verify.js +229 -0
- package/lib/web-extract.js +213 -0
- package/lib/web-summarize.js +68 -0
- package/package.json +19 -4
- package/scripts/lint.js +57 -0
- package/test/agent-loop.test.js +389 -0
- package/test/background.test.js +414 -0
- package/test/chat.test.js +114 -0
- package/test/checkpoints-agent.test.js +181 -0
- package/test/checkpoints.test.js +650 -0
- package/test/command-registry.test.js +160 -0
- package/test/compact.test.js +116 -0
- package/test/completion-lazy.test.js +52 -0
- package/test/config-merge.test.js +324 -0
- package/test/config-quarantine.test.js +128 -0
- package/test/config-write-guard-allow-anywhere.test.js +56 -0
- package/test/config-write-guard-skip.test.js +46 -0
- package/test/config-write-guard.test.js +153 -0
- package/test/context-split.test.js +215 -0
- package/test/cost-doctor.test.js +142 -0
- package/test/custom-commands-chat.test.js +106 -0
- package/test/custom-commands.test.js +230 -0
- package/test/deny-windows.test.js +120 -0
- package/test/deny.test.js +83 -0
- package/test/download-allow-anywhere.test.js +66 -0
- package/test/download-confine.test.js +153 -0
- package/test/executors.test.js +362 -0
- package/test/extract-tool-calls.test.js +315 -0
- package/test/fetch-url-validation.test.js +219 -0
- package/test/fixtures/tool-calls.js +57 -0
- package/test/fixtures/web-page.js +91 -0
- package/test/git-tools.test.js +384 -0
- package/test/grep-glob-serialize.test.js +242 -0
- package/test/grep-glob.test.js +268 -0
- package/test/harness/README.md +57 -0
- package/test/harness/chat-harness.js +142 -0
- package/test/harness/memwarn-headless-child.js +65 -0
- package/test/harness/mock-llm.js +120 -0
- package/test/harness/mock-mcp-server.js +142 -0
- package/test/harness/sse-server.js +69 -0
- package/test/headless.test.js +203 -0
- package/test/history-utils.test.js +88 -0
- package/test/hooks-agent.test.js +238 -0
- package/test/hooks-verify-sandbox.test.js +232 -0
- package/test/hooks.test.js +216 -0
- package/test/http-get-user-agent.test.js +142 -0
- package/test/images-api.test.js +208 -0
- package/test/images.test.js +238 -0
- package/test/max-iterations.test.js +216 -0
- package/test/mcp-boundary.test.js +57 -0
- package/test/mcp-client.test.js +267 -0
- package/test/mcp-oauth.test.js +86 -0
- package/test/memory-truncation-warning.test.js +222 -0
- package/test/memory.test.js +198 -0
- package/test/native-dispatch.test.js +356 -0
- package/test/output-chokepoint.test.js +188 -0
- package/test/path-guards.test.js +134 -0
- package/test/payload.test.js +99 -0
- package/test/permission-rules-agent.test.js +210 -0
- package/test/permission-rules.test.js +297 -0
- package/test/permissions.test.js +163 -0
- package/test/plan-mode.test.js +167 -0
- package/test/read-paginate.test.js +275 -0
- package/test/readonly-tools.test.js +177 -0
- package/test/result-cap.test.js +233 -0
- package/test/sandbox-agent.test.js +147 -0
- package/test/sandbox-integration.test.js +216 -0
- package/test/sandbox.test.js +408 -0
- package/test/sdk.test.js +234 -0
- package/test/shell-output-cap.test.js +181 -0
- package/test/skills-chat.test.js +110 -0
- package/test/skills.test.js +295 -0
- package/test/smoke.test.js +68 -0
- package/test/status-bar-pause.test.js +164 -0
- package/test/stream-parser.test.js +147 -0
- package/test/subagents-agent.test.js +178 -0
- package/test/subagents.test.js +222 -0
- package/test/tool-registry.test.js +85 -0
- package/test/trim-budget.test.js +101 -0
- package/test/verify-agent.test.js +317 -0
- package/test/verify.test.js +141 -0
- package/test/web-activity-ordering.test.js +194 -0
- package/test/web-activity.test.js +207 -0
- package/test/web-data-extraction-guidance.test.js +71 -0
- package/test/web-extract.test.js +185 -0
- package/test/web-fetch-agent.test.js +291 -0
- package/test/web-fetch-mode.test.js +193 -0
- package/test/web-search.test.js +380 -0
- package/lib/commands.js +0 -1288
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Unit tests for the checkpoint store (Task 4.3). These drive the store DIRECTLY
|
|
4
|
+
// (no agent loop) with a temp rootDir and an injected no-op audit, simulating
|
|
5
|
+
// the executor seam: beginCapture (pre-mutation) → do the fs mutation →
|
|
6
|
+
// commit() (post-mutation). The agent-loop / subagent integration lives in
|
|
7
|
+
// test/checkpoints-agent.test.js.
|
|
8
|
+
|
|
9
|
+
const { test } = require('node:test');
|
|
10
|
+
const assert = require('node:assert');
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const os = require('os');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
|
|
15
|
+
const {
|
|
16
|
+
normalizeCheckpoints,
|
|
17
|
+
createCheckpointStore,
|
|
18
|
+
latestSession,
|
|
19
|
+
CHECKPOINTABLE_ACTIONS,
|
|
20
|
+
SCOPE_NOTICE,
|
|
21
|
+
REWIND_MODES,
|
|
22
|
+
normalizeRewindMode,
|
|
23
|
+
formatCheckpointList,
|
|
24
|
+
formatRewindResult,
|
|
25
|
+
findOrphanedToolCalls,
|
|
26
|
+
snapToTurnBoundary,
|
|
27
|
+
locateTurnStart,
|
|
28
|
+
planConversationRewind,
|
|
29
|
+
} = require('../lib/checkpoints');
|
|
30
|
+
|
|
31
|
+
const { DEFAULT_CHECKPOINT_MAX_FILE_BYTES, DEFAULT_CHECKPOINT_MAX_PER_SESSION } = require('../lib/constants');
|
|
32
|
+
|
|
33
|
+
const NOOP_AUDIT = { logCheckpoint: () => {} };
|
|
34
|
+
|
|
35
|
+
function tmpdir(tag = 'cp') { return fs.mkdtempSync(path.join(os.tmpdir(), `semalt-${tag}-`)); }
|
|
36
|
+
|
|
37
|
+
// Build a store over a fresh temp rootDir with a fixed session and a config.
|
|
38
|
+
function makeStore(checkpoints = {}, { sessionId = 'sess1', fsImpl, restoreGuard } = {}) {
|
|
39
|
+
const root = tmpdir('cproot');
|
|
40
|
+
const store = createCheckpointStore({
|
|
41
|
+
getConfig: () => ({ checkpoints }),
|
|
42
|
+
sessionId,
|
|
43
|
+
rootDir: root,
|
|
44
|
+
audit: NOOP_AUDIT,
|
|
45
|
+
fs: fsImpl,
|
|
46
|
+
restoreGuard,
|
|
47
|
+
});
|
|
48
|
+
return { store, root };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// normalizeCheckpoints
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
test('normalizeCheckpoints: defaults', () => {
|
|
56
|
+
const c = normalizeCheckpoints(undefined);
|
|
57
|
+
assert.strictEqual(c.enabled, true);
|
|
58
|
+
assert.strictEqual(c.max_file_bytes, DEFAULT_CHECKPOINT_MAX_FILE_BYTES);
|
|
59
|
+
assert.strictEqual(c.max_per_session, DEFAULT_CHECKPOINT_MAX_PER_SESSION);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('normalizeCheckpoints: enabled:false honored; invalid numbers fall back', () => {
|
|
63
|
+
const c = normalizeCheckpoints({ enabled: false, max_file_bytes: -3, max_per_session: 'x' });
|
|
64
|
+
assert.strictEqual(c.enabled, false);
|
|
65
|
+
assert.strictEqual(c.max_file_bytes, DEFAULT_CHECKPOINT_MAX_FILE_BYTES);
|
|
66
|
+
assert.strictEqual(c.max_per_session, DEFAULT_CHECKPOINT_MAX_PER_SESSION);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('normalizeCheckpoints: valid overrides applied', () => {
|
|
70
|
+
const c = normalizeCheckpoints({ max_file_bytes: 1234, max_per_session: 5 });
|
|
71
|
+
assert.strictEqual(c.max_file_bytes, 1234);
|
|
72
|
+
assert.strictEqual(c.max_per_session, 5);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('checkpointable action set covers all mutating file tools', () => {
|
|
76
|
+
for (const a of ['write', 'append', 'edit_file', 'replace_in_file', 'delete_file', 'move_file', 'copy_file', 'upload']) {
|
|
77
|
+
assert.ok(CHECKPOINTABLE_ACTIONS.has(a), `${a} should be checkpointable`);
|
|
78
|
+
}
|
|
79
|
+
assert.ok(!CHECKPOINTABLE_ACTIONS.has('shell'));
|
|
80
|
+
assert.ok(!CHECKPOINTABLE_ACTIONS.has('read'));
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// Capture: prior state pre-mutation; committed only on demand; turn linkage
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
test('write: prior state captured pre-mutation; record persisted with turn linkage', async () => {
|
|
88
|
+
const { store } = makeStore();
|
|
89
|
+
const dir = tmpdir('work');
|
|
90
|
+
const file = path.join(dir, 'a.txt');
|
|
91
|
+
fs.writeFileSync(file, 'ORIGINAL');
|
|
92
|
+
|
|
93
|
+
store.setTurnContext({ promptIndex: 3, messageCountAtStart: 4, promptText: 'do the thing' });
|
|
94
|
+
|
|
95
|
+
const h = await store.beginCapture('write', [file, 'NEW']);
|
|
96
|
+
assert.ok(h, 'beginCapture returns a handle');
|
|
97
|
+
// capture read the prior content BEFORE the mutation
|
|
98
|
+
assert.strictEqual(Buffer.from(h.targets[0].priorContentB64, 'base64').toString('utf8'), 'ORIGINAL');
|
|
99
|
+
|
|
100
|
+
// simulate the executor mutating the file
|
|
101
|
+
fs.writeFileSync(file, 'NEW');
|
|
102
|
+
const res = h.commit();
|
|
103
|
+
assert.ok(res && res.seq === 1);
|
|
104
|
+
|
|
105
|
+
const items = store.list();
|
|
106
|
+
assert.strictEqual(items.length, 1);
|
|
107
|
+
const rec = store._loadRecord(1);
|
|
108
|
+
assert.strictEqual(rec.action, 'write');
|
|
109
|
+
assert.strictEqual(rec.targets[0].path, file);
|
|
110
|
+
assert.strictEqual(rec.targets[0].existedBefore, true);
|
|
111
|
+
// after-state recorded for external-mod detection
|
|
112
|
+
assert.ok(rec.targets[0].afterExists);
|
|
113
|
+
assert.ok(rec.targets[0].afterHash);
|
|
114
|
+
// conversation linkage present + correct (forward-compat for Task 4.3b)
|
|
115
|
+
assert.strictEqual(rec.turn.turnId, 'turn-1');
|
|
116
|
+
assert.strictEqual(rec.turn.promptIndex, 3);
|
|
117
|
+
assert.strictEqual(rec.turn.messageCountAtStart, 4);
|
|
118
|
+
assert.match(rec.turn.promptId, /^[0-9a-f]{12}$/);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('denied/withheld call (no commit) produces NO checkpoint', async () => {
|
|
122
|
+
const { store } = makeStore();
|
|
123
|
+
const dir = tmpdir('work');
|
|
124
|
+
const file = path.join(dir, 'a.txt');
|
|
125
|
+
fs.writeFileSync(file, 'X');
|
|
126
|
+
const h = await store.beginCapture('write', [file, 'Y']);
|
|
127
|
+
assert.ok(h);
|
|
128
|
+
h.discard(); // simulate a denied gate / executor refusal — never commit
|
|
129
|
+
assert.strictEqual(store.list().length, 0, 'no checkpoint recorded for an uncommitted capture');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('non-checkpointable action returns no handle', async () => {
|
|
133
|
+
const { store } = makeStore();
|
|
134
|
+
assert.strictEqual(await store.beginCapture('read', ['/x']), null);
|
|
135
|
+
assert.strictEqual(await store.beginCapture('shell', ['ls']), null);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('disabled config: beginCapture is a no-op', async () => {
|
|
139
|
+
const { store } = makeStore({ enabled: false });
|
|
140
|
+
const dir = tmpdir('work');
|
|
141
|
+
const file = path.join(dir, 'a.txt');
|
|
142
|
+
fs.writeFileSync(file, 'X');
|
|
143
|
+
assert.strictEqual(await store.beginCapture('write', [file, 'Y']), null);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
// Rewind: restore content; to-sequence; delete; move
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
async function captureAndMutate(store, action, args, mutate) {
|
|
151
|
+
const h = await store.beginCapture(action, args);
|
|
152
|
+
mutate();
|
|
153
|
+
return h ? h.commit() : null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
test('rewind restores prior content', async () => {
|
|
157
|
+
const { store } = makeStore();
|
|
158
|
+
const dir = tmpdir('work');
|
|
159
|
+
const file = path.join(dir, 'a.txt');
|
|
160
|
+
fs.writeFileSync(file, 'ORIGINAL');
|
|
161
|
+
await captureAndMutate(store, 'write', [file, 'NEW'], () => fs.writeFileSync(file, 'NEW'));
|
|
162
|
+
assert.strictEqual(fs.readFileSync(file, 'utf8'), 'NEW');
|
|
163
|
+
|
|
164
|
+
const res = store.rewind('last');
|
|
165
|
+
assert.ok(res.ok);
|
|
166
|
+
assert.strictEqual(fs.readFileSync(file, 'utf8'), 'ORIGINAL');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('rewind to a chosen sequence restores that point', async () => {
|
|
170
|
+
const { store } = makeStore();
|
|
171
|
+
const dir = tmpdir('work');
|
|
172
|
+
const file = path.join(dir, 'a.txt');
|
|
173
|
+
fs.writeFileSync(file, 'V0');
|
|
174
|
+
await captureAndMutate(store, 'write', [file, 'V1'], () => fs.writeFileSync(file, 'V1')); // seq 1 (prior V0)
|
|
175
|
+
await captureAndMutate(store, 'write', [file, 'V2'], () => fs.writeFileSync(file, 'V2')); // seq 2 (prior V1)
|
|
176
|
+
assert.strictEqual(fs.readFileSync(file, 'utf8'), 'V2');
|
|
177
|
+
|
|
178
|
+
// rewind to seq 1 restores the state prior to the first write → V0
|
|
179
|
+
const res = store.rewind(1);
|
|
180
|
+
assert.ok(res.ok);
|
|
181
|
+
assert.strictEqual(fs.readFileSync(file, 'utf8'), 'V0');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('delete rewind recreates the file with prior content', async () => {
|
|
185
|
+
const { store } = makeStore();
|
|
186
|
+
const dir = tmpdir('work');
|
|
187
|
+
const file = path.join(dir, 'gone.txt');
|
|
188
|
+
fs.writeFileSync(file, 'KEEP ME');
|
|
189
|
+
await captureAndMutate(store, 'delete_file', [file], () => fs.unlinkSync(file));
|
|
190
|
+
assert.ok(!fs.existsSync(file), 'file deleted by the mutation');
|
|
191
|
+
|
|
192
|
+
const res = store.rewind('last');
|
|
193
|
+
assert.ok(res.ok);
|
|
194
|
+
assert.ok(fs.existsSync(file), 'rewind recreated the file');
|
|
195
|
+
assert.strictEqual(fs.readFileSync(file, 'utf8'), 'KEEP ME');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('write of a NEW file rewinds by deleting it (existedBefore=false)', async () => {
|
|
199
|
+
const { store } = makeStore();
|
|
200
|
+
const dir = tmpdir('work');
|
|
201
|
+
const file = path.join(dir, 'created.txt');
|
|
202
|
+
await captureAndMutate(store, 'write', [file, 'HELLO'], () => fs.writeFileSync(file, 'HELLO'));
|
|
203
|
+
assert.ok(fs.existsSync(file));
|
|
204
|
+
|
|
205
|
+
const res = store.rewind('last');
|
|
206
|
+
assert.ok(res.ok);
|
|
207
|
+
assert.ok(!fs.existsSync(file), 'rewind removed the file the agent created');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('move rewind returns the file to its origin', async () => {
|
|
211
|
+
const { store } = makeStore();
|
|
212
|
+
const dir = tmpdir('work');
|
|
213
|
+
const src = path.join(dir, 'src.txt');
|
|
214
|
+
const dst = path.join(dir, 'dst.txt');
|
|
215
|
+
fs.writeFileSync(src, 'PAYLOAD');
|
|
216
|
+
await captureAndMutate(store, 'move_file', [src, dst], () => { fs.renameSync(src, dst); });
|
|
217
|
+
assert.ok(!fs.existsSync(src) && fs.existsSync(dst), 'move happened');
|
|
218
|
+
|
|
219
|
+
const res = store.rewind('last');
|
|
220
|
+
assert.ok(res.ok);
|
|
221
|
+
assert.ok(fs.existsSync(src), 'src restored');
|
|
222
|
+
assert.ok(!fs.existsSync(dst), 'dst removed (it did not exist before the move)');
|
|
223
|
+
assert.strictEqual(fs.readFileSync(src, 'utf8'), 'PAYLOAD');
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
// External-modification integrity
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
test('external modification blocks rewind (no clobber) unless forced', async () => {
|
|
231
|
+
const { store } = makeStore();
|
|
232
|
+
const dir = tmpdir('work');
|
|
233
|
+
const file = path.join(dir, 'a.txt');
|
|
234
|
+
fs.writeFileSync(file, 'ORIGINAL');
|
|
235
|
+
await captureAndMutate(store, 'write', [file, 'AGENT'], () => fs.writeFileSync(file, 'AGENT'));
|
|
236
|
+
|
|
237
|
+
// a user/other process edits the file out of band AFTER the agent left it
|
|
238
|
+
fs.writeFileSync(file, 'USER EDIT');
|
|
239
|
+
|
|
240
|
+
const blocked = store.rewind('last');
|
|
241
|
+
assert.strictEqual(blocked.ok, false);
|
|
242
|
+
assert.strictEqual(blocked.blocked, true);
|
|
243
|
+
assert.deepStrictEqual(blocked.externallyModified, [file]);
|
|
244
|
+
assert.strictEqual(fs.readFileSync(file, 'utf8'), 'USER EDIT', 'out-of-band edit NOT clobbered');
|
|
245
|
+
|
|
246
|
+
const forced = store.rewind('last', { force: true });
|
|
247
|
+
assert.ok(forced.ok);
|
|
248
|
+
assert.deepStrictEqual(forced.forcedOverExternal, [file]);
|
|
249
|
+
assert.strictEqual(fs.readFileSync(file, 'utf8'), 'ORIGINAL', 'force overwrote the external edit');
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
// Size cap, retention, fail-safe
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
test('size cap: oversized file is not snapshotted, recorded rewind-unavailable, capture still proceeds', async () => {
|
|
257
|
+
const { store } = makeStore({ max_file_bytes: 8 });
|
|
258
|
+
const dir = tmpdir('work');
|
|
259
|
+
const file = path.join(dir, 'big.txt');
|
|
260
|
+
fs.writeFileSync(file, 'THIS IS WAY MORE THAN EIGHT BYTES');
|
|
261
|
+
|
|
262
|
+
const h = await store.beginCapture('write', [file, 'small']);
|
|
263
|
+
assert.ok(h, 'capture still returns a handle (mutation proceeds)');
|
|
264
|
+
assert.strictEqual(h.targets[0].oversize, true);
|
|
265
|
+
assert.strictEqual(h.targets[0].rewindable, false);
|
|
266
|
+
assert.strictEqual(h.targets[0].priorContentB64, null, 'oversize file not snapshotted');
|
|
267
|
+
|
|
268
|
+
fs.writeFileSync(file, 'small');
|
|
269
|
+
const res = h.commit();
|
|
270
|
+
assert.ok(res);
|
|
271
|
+
const rec = store._loadRecord(res.seq);
|
|
272
|
+
assert.strictEqual(rec.rewindable, false);
|
|
273
|
+
|
|
274
|
+
// rewind reports the file as unrewindable rather than corrupting it
|
|
275
|
+
const rw = store.rewind(res.seq);
|
|
276
|
+
assert.deepStrictEqual(rw.unrewindable, [file]);
|
|
277
|
+
assert.strictEqual(rw.restored.length, 0);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test('retention prunes the oldest checkpoints beyond max_per_session', async () => {
|
|
281
|
+
const { store } = makeStore({ max_per_session: 3 });
|
|
282
|
+
const dir = tmpdir('work');
|
|
283
|
+
for (let i = 0; i < 5; i++) {
|
|
284
|
+
const file = path.join(dir, `f${i}.txt`);
|
|
285
|
+
await captureAndMutate(store, 'write', [file, 'x'], () => fs.writeFileSync(file, 'x'));
|
|
286
|
+
}
|
|
287
|
+
const items = store.list();
|
|
288
|
+
assert.strictEqual(items.length, 3, 'only the newest 3 kept');
|
|
289
|
+
assert.deepStrictEqual(items.map((i) => i.seq), [3, 4, 5], 'oldest (1,2) pruned');
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test('snapshot failure is fail-safe: beginCapture returns null, never throws', async () => {
|
|
293
|
+
const dir = tmpdir('work');
|
|
294
|
+
const file = path.join(dir, 'a.txt');
|
|
295
|
+
fs.writeFileSync(file, 'X');
|
|
296
|
+
// inject an fs whose readFileSync throws a non-ENOENT error (e.g. EACCES)
|
|
297
|
+
const brokenFs = Object.assign({}, fs, {
|
|
298
|
+
readFileSync: () => { const e = new Error('EACCES'); e.code = 'EACCES'; throw e; },
|
|
299
|
+
});
|
|
300
|
+
const { store } = makeStore({}, { fsImpl: brokenFs });
|
|
301
|
+
let handle;
|
|
302
|
+
await assert.doesNotReject(async () => { handle = await store.beginCapture('write', [file, 'Y']); });
|
|
303
|
+
assert.strictEqual(handle, null, 'no checkpoint, but no throw — mutation will still proceed');
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
// Multi-session helpers
|
|
308
|
+
// ---------------------------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
test('latestSession resolves the most-recently-written session dir', async () => {
|
|
311
|
+
const root = tmpdir('cproot');
|
|
312
|
+
const sA = createCheckpointStore({ getConfig: () => ({ checkpoints: {} }), sessionId: 'A', rootDir: root, audit: NOOP_AUDIT });
|
|
313
|
+
const sB = createCheckpointStore({ getConfig: () => ({ checkpoints: {} }), sessionId: 'B', rootDir: root, audit: NOOP_AUDIT });
|
|
314
|
+
const dir = tmpdir('work');
|
|
315
|
+
const fA = path.join(dir, 'a.txt');
|
|
316
|
+
const fB = path.join(dir, 'b.txt');
|
|
317
|
+
fs.writeFileSync(fA, '1');
|
|
318
|
+
await captureAndMutate(sA, 'write', [fA, '2'], () => fs.writeFileSync(fA, '2'));
|
|
319
|
+
fs.writeFileSync(fB, '1');
|
|
320
|
+
await captureAndMutate(sB, 'write', [fB, '2'], () => fs.writeFileSync(fB, '2'));
|
|
321
|
+
// Pin mtimes explicitly so the assertion does not depend on sub-millisecond
|
|
322
|
+
// wall-clock gaps between the two captures (B is the most recent).
|
|
323
|
+
fs.utimesSync(path.join(root, 'A', '1.json'), new Date(1000), new Date(1000));
|
|
324
|
+
fs.utimesSync(path.join(root, 'B', '1.json'), new Date(2000), new Date(2000));
|
|
325
|
+
assert.strictEqual(latestSession(root), 'B');
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
// ---------------------------------------------------------------------------
|
|
329
|
+
// Scope limit surfaced to the user (shell side effects not reversible)
|
|
330
|
+
// ---------------------------------------------------------------------------
|
|
331
|
+
|
|
332
|
+
test('the shell-not-reversible scope limit is stated in /rewind output', () => {
|
|
333
|
+
assert.match(SCOPE_NOTICE, /shell side effects/i);
|
|
334
|
+
assert.match(SCOPE_NOTICE, /not\b.*reversible/i);
|
|
335
|
+
// both the list view and the rewind result carry it
|
|
336
|
+
assert.match(formatCheckpointList([]), /shell side effects/i);
|
|
337
|
+
assert.match(formatRewindResult({ seq: 1, action: 'write', restored: ['/x'] }), /shell side effects/i);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test('formatRewindResult reports a blocked (externally-modified) rewind with a force hint', () => {
|
|
341
|
+
const out = formatRewindResult({ blocked: true, seq: 2, externallyModified: ['/p'], message: 'Rewind blocked: ...' });
|
|
342
|
+
assert.match(out, /\/rewind 2 force/);
|
|
343
|
+
assert.match(out, /changed: \/p/);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test('setSession realigns the checkpoint directory', async () => {
|
|
347
|
+
const root = tmpdir('cproot');
|
|
348
|
+
const store = createCheckpointStore({ getConfig: () => ({ checkpoints: {} }), sessionId: 'auto', rootDir: root, audit: NOOP_AUDIT });
|
|
349
|
+
store.setSession('chat-xyz');
|
|
350
|
+
assert.strictEqual(store.getSession(), 'chat-xyz');
|
|
351
|
+
const dir = tmpdir('work');
|
|
352
|
+
const file = path.join(dir, 'a.txt');
|
|
353
|
+
await captureAndMutate(store, 'write', [file, 'x'], () => fs.writeFileSync(file, 'x'));
|
|
354
|
+
assert.ok(fs.existsSync(path.join(root, 'chat-xyz', '1.json')));
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
// ===========================================================================
|
|
358
|
+
// Task 4.3b · Part 1 — restore-path guard re-validation
|
|
359
|
+
// ===========================================================================
|
|
360
|
+
|
|
361
|
+
test('restore re-validates current guards: a now-denied path is refused (skipped), a still-allowed path restores', async () => {
|
|
362
|
+
const dir = tmpdir('work');
|
|
363
|
+
const allowed = path.join(dir, 'ok.txt');
|
|
364
|
+
const denied = path.join(dir, 'secret.env');
|
|
365
|
+
// A guard that refuses anything ending in .env (simulating a deny rule /
|
|
366
|
+
// protected-config / isPathSafe change between capture and restore).
|
|
367
|
+
const restoreGuard = (p) => p.endsWith('.env')
|
|
368
|
+
? { ok: false, reason: 'blocked by a deny permission rule' }
|
|
369
|
+
: { ok: true };
|
|
370
|
+
const { store } = makeStore({}, { restoreGuard });
|
|
371
|
+
|
|
372
|
+
fs.writeFileSync(allowed, 'A0');
|
|
373
|
+
fs.writeFileSync(denied, 'S0');
|
|
374
|
+
// One checkpoint that touches BOTH files (a move would, but simplest: two
|
|
375
|
+
// separate writes — rewind the most recent of each independently). Use a
|
|
376
|
+
// single record by capturing a move so both targets are in one checkpoint.
|
|
377
|
+
await captureAndMutate(store, 'write', [allowed, 'A1'], () => fs.writeFileSync(allowed, 'A1'));
|
|
378
|
+
await captureAndMutate(store, 'write', [denied, 'S1'], () => fs.writeFileSync(denied, 'S1'));
|
|
379
|
+
|
|
380
|
+
// Rewind the allowed file — restores fine.
|
|
381
|
+
const r1 = store.rewind(1, { mode: 'code' });
|
|
382
|
+
assert.ok(r1.ok);
|
|
383
|
+
assert.deepStrictEqual(r1.restored, [allowed]);
|
|
384
|
+
assert.strictEqual(fs.readFileSync(allowed, 'utf8'), 'A0');
|
|
385
|
+
|
|
386
|
+
// Rewind the denied file — refused (skipped), file untouched, rest of rewind not aborted.
|
|
387
|
+
const r2 = store.rewind(2, { mode: 'code' });
|
|
388
|
+
assert.deepStrictEqual(r2.restored, []);
|
|
389
|
+
assert.strictEqual(r2.refused.length, 1);
|
|
390
|
+
assert.strictEqual(r2.refused[0].path, denied);
|
|
391
|
+
assert.match(r2.refused[0].reason, /deny permission rule/);
|
|
392
|
+
assert.strictEqual(fs.readFileSync(denied, 'utf8'), 'S1', 'denied path NOT re-written');
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
test('force overrides only the external-mod check, NOT the restore guards', async () => {
|
|
396
|
+
const dir = tmpdir('work');
|
|
397
|
+
const file = path.join(dir, 'x.env');
|
|
398
|
+
const restoreGuard = (p) => p.endsWith('.env') ? { ok: false, reason: 'protected' } : { ok: true };
|
|
399
|
+
const { store } = makeStore({}, { restoreGuard });
|
|
400
|
+
|
|
401
|
+
fs.writeFileSync(file, 'V0');
|
|
402
|
+
await captureAndMutate(store, 'write', [file, 'V1'], () => fs.writeFileSync(file, 'V1'));
|
|
403
|
+
// out-of-band edit so the external-mod path is exercised under force
|
|
404
|
+
fs.writeFileSync(file, 'USER');
|
|
405
|
+
|
|
406
|
+
const res = store.rewind('last', { mode: 'code', force: true });
|
|
407
|
+
// force got past the external-mod block, but the guard still refused the write
|
|
408
|
+
assert.deepStrictEqual(res.restored, []);
|
|
409
|
+
assert.strictEqual(res.refused.length, 1);
|
|
410
|
+
assert.strictEqual(fs.readFileSync(file, 'utf8'), 'USER', 'guard refusal holds even under force');
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
test('a guard that throws fails closed (refused, not restored)', async () => {
|
|
414
|
+
const dir = tmpdir('work');
|
|
415
|
+
const file = path.join(dir, 'a.txt');
|
|
416
|
+
const restoreGuard = () => { throw new Error('boom'); };
|
|
417
|
+
const { store } = makeStore({}, { restoreGuard });
|
|
418
|
+
fs.writeFileSync(file, 'V0');
|
|
419
|
+
await captureAndMutate(store, 'write', [file, 'V1'], () => fs.writeFileSync(file, 'V1'));
|
|
420
|
+
const res = store.rewind('last', { mode: 'code' });
|
|
421
|
+
assert.strictEqual(res.restored.length, 0);
|
|
422
|
+
assert.strictEqual(res.refused.length, 1);
|
|
423
|
+
assert.match(res.refused[0].reason, /guard error/);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
test('with no restoreGuard injected the store defaults to allow (4.3 behavior preserved)', async () => {
|
|
427
|
+
const { store } = makeStore(); // no restoreGuard
|
|
428
|
+
const dir = tmpdir('work');
|
|
429
|
+
const file = path.join(dir, 'a.txt');
|
|
430
|
+
fs.writeFileSync(file, 'V0');
|
|
431
|
+
await captureAndMutate(store, 'write', [file, 'V1'], () => fs.writeFileSync(file, 'V1'));
|
|
432
|
+
const res = store.rewind('last', { mode: 'code' });
|
|
433
|
+
assert.ok(res.ok);
|
|
434
|
+
assert.deepStrictEqual(res.restored, [file]);
|
|
435
|
+
assert.deepStrictEqual(res.refused, []);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
// ===========================================================================
|
|
439
|
+
// Task 4.3b · Part 2 — conversation rewind + restore modes
|
|
440
|
+
// ===========================================================================
|
|
441
|
+
|
|
442
|
+
// A small two-turn native conversation. Turn 1 ("do A") writes a.txt via a
|
|
443
|
+
// native tool_call; turn 2 ("do B") writes b.txt. Indices:
|
|
444
|
+
// 0 user "do A" 1 assistant(tool_call ca) 2 tool(ca) 3 assistant "done A"
|
|
445
|
+
// 4 user "do B" 5 assistant(tool_call cb) 6 tool(cb) 7 assistant "done B"
|
|
446
|
+
function nativeConversation() {
|
|
447
|
+
return [
|
|
448
|
+
{ role: 'user', content: 'do A' },
|
|
449
|
+
{ role: 'assistant', content: '', tool_calls: [{ id: 'ca', type: 'function', function: { name: 'write_file', arguments: '{}' } }] },
|
|
450
|
+
{ role: 'tool', tool_call_id: 'ca', content: 'ok' },
|
|
451
|
+
{ role: 'assistant', content: 'done A' },
|
|
452
|
+
{ role: 'user', content: 'do B' },
|
|
453
|
+
{ role: 'assistant', content: '', tool_calls: [{ id: 'cb', type: 'function', function: { name: 'write_file', arguments: '{}' } }] },
|
|
454
|
+
{ role: 'tool', tool_call_id: 'cb', content: 'ok' },
|
|
455
|
+
{ role: 'assistant', content: 'done B' },
|
|
456
|
+
];
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
test('normalizeRewindMode: defaults to both; known modes pass; unknown → null', () => {
|
|
460
|
+
assert.strictEqual(normalizeRewindMode(undefined), 'both');
|
|
461
|
+
assert.strictEqual(normalizeRewindMode(''), 'both');
|
|
462
|
+
assert.strictEqual(normalizeRewindMode('code'), 'code');
|
|
463
|
+
assert.strictEqual(normalizeRewindMode('Conversation'), 'conversation');
|
|
464
|
+
assert.strictEqual(normalizeRewindMode('both'), 'both');
|
|
465
|
+
assert.strictEqual(normalizeRewindMode('bogus'), null);
|
|
466
|
+
assert.deepStrictEqual([...REWIND_MODES].sort(), ['both', 'code', 'conversation']);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
test('findOrphanedToolCalls: clean native conversation has no orphans', () => {
|
|
470
|
+
assert.deepStrictEqual(findOrphanedToolCalls(nativeConversation()), []);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
test('findOrphanedToolCalls: a tool_call with no result, or a result with no call, is an orphan', () => {
|
|
474
|
+
const dangling = nativeConversation().slice(0, 2); // user + assistant(tool_call ca), no tool result
|
|
475
|
+
assert.deepStrictEqual(findOrphanedToolCalls(dangling), ['ca']);
|
|
476
|
+
const orphanResult = [{ role: 'tool', tool_call_id: 'zz', content: 'x' }];
|
|
477
|
+
assert.deepStrictEqual(findOrphanedToolCalls(orphanResult), ['zz']);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
test('snapToTurnBoundary snaps a mid-turn index back to the turn-start user message', () => {
|
|
481
|
+
const msgs = nativeConversation();
|
|
482
|
+
// index 6 is the tool result inside turn 2 → snaps back to user "do B" at 4
|
|
483
|
+
assert.strictEqual(snapToTurnBoundary(msgs, 6), 4);
|
|
484
|
+
// index 2 (tool result inside turn 1) → snaps to user "do A" at 0
|
|
485
|
+
assert.strictEqual(snapToTurnBoundary(msgs, 2), 0);
|
|
486
|
+
// already a user boundary stays put
|
|
487
|
+
assert.strictEqual(snapToTurnBoundary(msgs, 4), 4);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
test('locateTurnStart matches by promptId even when indices shifted (compaction)', () => {
|
|
491
|
+
const crypto = require('crypto');
|
|
492
|
+
const promptId = crypto.createHash('sha256').update(Buffer.from('do B')).digest('hex').slice(0, 12);
|
|
493
|
+
// simulate compaction: a summary message was prepended, so the real index of
|
|
494
|
+
// "do B" is now 5, but the checkpoint recorded promptIndex 4.
|
|
495
|
+
const msgs = [{ role: 'system', content: 'summary' }, ...nativeConversation()];
|
|
496
|
+
const turn = { turnId: 'turn-2', promptId, promptIndex: 4, messageCountAtStart: 5 };
|
|
497
|
+
assert.strictEqual(locateTurnStart(msgs, turn), 5);
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
test('planConversationRewind cuts on the turn boundary; kept history has no orphaned tool_call', () => {
|
|
501
|
+
const crypto = require('crypto');
|
|
502
|
+
const msgs = nativeConversation();
|
|
503
|
+
const promptId = crypto.createHash('sha256').update(Buffer.from('do B')).digest('hex').slice(0, 12);
|
|
504
|
+
const plan = planConversationRewind(msgs, { turnId: 'turn-2', promptId, promptIndex: 4, messageCountAtStart: 5 });
|
|
505
|
+
assert.ok(plan.ok);
|
|
506
|
+
assert.strictEqual(plan.cutIndex, 4);
|
|
507
|
+
assert.strictEqual(plan.kept.length, 4);
|
|
508
|
+
assert.strictEqual(plan.removed.length, 4);
|
|
509
|
+
assert.deepStrictEqual(findOrphanedToolCalls(plan.kept), [], 'no orphaned tool_call after the cut');
|
|
510
|
+
assert.strictEqual(plan.kept[plan.kept.length - 1].content, 'done A');
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
test('planConversationRewind: missing turn linkage / unlocatable turn → ok:false', () => {
|
|
514
|
+
assert.strictEqual(planConversationRewind(nativeConversation(), null).ok, false);
|
|
515
|
+
// promptId that matches nothing and an out-of-range promptIndex
|
|
516
|
+
const r = planConversationRewind(nativeConversation(), { promptId: 'deadbeefdead', promptIndex: 99 });
|
|
517
|
+
assert.strictEqual(r.ok, false);
|
|
518
|
+
assert.match(r.reason, /could not locate/i);
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
// Build a store whose checkpoint for seq N is linked to a given turn.
|
|
522
|
+
async function storeWithTurnLinkedWrite(turn, fileVal) {
|
|
523
|
+
const { store } = makeStore();
|
|
524
|
+
const dir = tmpdir('work');
|
|
525
|
+
const file = path.join(dir, 'f.txt');
|
|
526
|
+
fs.writeFileSync(file, 'PRIOR');
|
|
527
|
+
store.setTurnContext(turn);
|
|
528
|
+
await captureAndMutate(store, 'write', [file, fileVal], () => fs.writeFileSync(file, fileVal));
|
|
529
|
+
return { store, file };
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
test('code mode: files restored, conversation untouched (4.3 behavior)', async () => {
|
|
533
|
+
const { store, file } = await storeWithTurnLinkedWrite({ promptIndex: 4, messageCountAtStart: 5, promptText: 'do B' }, 'NEW');
|
|
534
|
+
const msgs = nativeConversation();
|
|
535
|
+
const res = store.rewind('last', { mode: 'code', messages: msgs });
|
|
536
|
+
assert.ok(res.ok);
|
|
537
|
+
assert.strictEqual(fs.readFileSync(file, 'utf8'), 'PRIOR');
|
|
538
|
+
assert.strictEqual(res.conversation, undefined, 'code mode does not touch conversation');
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
test('conversation mode: history truncated to the turn, files untouched', async () => {
|
|
542
|
+
const { store, file } = await storeWithTurnLinkedWrite({ promptIndex: 4, messageCountAtStart: 5, promptText: 'do B' }, 'NEW');
|
|
543
|
+
const msgs = nativeConversation();
|
|
544
|
+
const res = store.rewind('last', { mode: 'conversation', messages: msgs });
|
|
545
|
+
assert.ok(res.conversation.ok);
|
|
546
|
+
assert.strictEqual(res.conversation.cutIndex, 4);
|
|
547
|
+
assert.strictEqual(res.conversation.removedCount, 4);
|
|
548
|
+
assert.deepStrictEqual(findOrphanedToolCalls(res.conversation.messages), []);
|
|
549
|
+
assert.strictEqual(fs.readFileSync(file, 'utf8'), 'NEW', 'conversation mode leaves files alone');
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
test('both mode (default): files + conversation restored to the linked point coherently', async () => {
|
|
553
|
+
const { store, file } = await storeWithTurnLinkedWrite({ promptIndex: 4, messageCountAtStart: 5, promptText: 'do B' }, 'NEW');
|
|
554
|
+
const msgs = nativeConversation();
|
|
555
|
+
const res = store.rewind('last', { messages: msgs }); // default mode = both
|
|
556
|
+
assert.strictEqual(res.mode, 'both');
|
|
557
|
+
assert.ok(res.ok);
|
|
558
|
+
assert.strictEqual(fs.readFileSync(file, 'utf8'), 'PRIOR', 'files restored');
|
|
559
|
+
assert.ok(res.conversation.ok);
|
|
560
|
+
assert.strictEqual(res.conversation.messages.length, 4, 'conversation truncated to the turn');
|
|
561
|
+
assert.deepStrictEqual(findOrphanedToolCalls(res.conversation.messages), []);
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
test('Part 1 guard also applies under both mode (files refused, conversation still rewinds)', async () => {
|
|
565
|
+
const dir = tmpdir('work');
|
|
566
|
+
const file = path.join(dir, 'x.env');
|
|
567
|
+
const restoreGuard = (p) => p.endsWith('.env') ? { ok: false, reason: 'protected' } : { ok: true };
|
|
568
|
+
const { store } = makeStore({}, { restoreGuard });
|
|
569
|
+
fs.writeFileSync(file, 'V0');
|
|
570
|
+
store.setTurnContext({ promptIndex: 4, messageCountAtStart: 5, promptText: 'do B' });
|
|
571
|
+
await captureAndMutate(store, 'write', [file, 'V1'], () => fs.writeFileSync(file, 'V1'));
|
|
572
|
+
const res = store.rewind('last', { mode: 'both', messages: nativeConversation() });
|
|
573
|
+
assert.deepStrictEqual(res.restored, [], 'file restore refused by the guard');
|
|
574
|
+
assert.strictEqual(res.refused.length, 1);
|
|
575
|
+
assert.strictEqual(fs.readFileSync(file, 'utf8'), 'V1', 'guarded file untouched');
|
|
576
|
+
assert.ok(res.conversation.ok, 'conversation half still rewinds under both');
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
test('rewinding to a turn whose linkage points mid-tool-call cuts on the boundary, not mid-pair', async () => {
|
|
580
|
+
// The checkpoint records a promptIndex that (after edits) lands on the tool
|
|
581
|
+
// result INSIDE turn 2 rather than the user message. The cut must still land
|
|
582
|
+
// on the turn boundary (user "do B" at 4) so no orphan remains.
|
|
583
|
+
const { store } = await storeWithTurnLinkedWrite({ promptIndex: 6, messageCountAtStart: 7, promptText: '' }, 'NEW');
|
|
584
|
+
const msgs = nativeConversation();
|
|
585
|
+
const res = store.rewind('last', { mode: 'conversation', messages: msgs });
|
|
586
|
+
assert.ok(res.conversation.ok);
|
|
587
|
+
assert.strictEqual(res.conversation.cutIndex, 4, 'snapped back to the user boundary');
|
|
588
|
+
assert.deepStrictEqual(findOrphanedToolCalls(res.conversation.messages), []);
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
test('conversation mode with no messages provided → ok:false, surfaced', async () => {
|
|
592
|
+
const { store } = await storeWithTurnLinkedWrite({ promptIndex: 4, messageCountAtStart: 5, promptText: 'do B' }, 'NEW');
|
|
593
|
+
const res = store.rewind('last', { mode: 'conversation' });
|
|
594
|
+
assert.strictEqual(res.ok, false);
|
|
595
|
+
assert.strictEqual(res.conversation.ok, false);
|
|
596
|
+
assert.match(res.conversation.reason, /no conversation/i);
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
test('on-disk checkpoint format unchanged: a record written WITHOUT the 4.3b code still rewinds', async () => {
|
|
600
|
+
// Hand-author a v1 record exactly as Task 4.3 wrote it (turn linkage present,
|
|
601
|
+
// no new fields) and prove the 4.3b rewind reads it for both code + conversation.
|
|
602
|
+
const root = tmpdir('cproot');
|
|
603
|
+
const dir = tmpdir('work');
|
|
604
|
+
const file = path.join(dir, 'legacy.txt');
|
|
605
|
+
fs.writeFileSync(file, 'CURRENT');
|
|
606
|
+
const sessDir = path.join(root, 'legacy-sess');
|
|
607
|
+
fs.mkdirSync(sessDir, { recursive: true });
|
|
608
|
+
const crypto = require('crypto');
|
|
609
|
+
const priorB64 = Buffer.from('LEGACY-PRIOR').toString('base64');
|
|
610
|
+
const afterHash = crypto.createHash('sha256').update(Buffer.from('CURRENT')).digest('hex');
|
|
611
|
+
const promptId = crypto.createHash('sha256').update(Buffer.from('do B')).digest('hex').slice(0, 12);
|
|
612
|
+
const record = {
|
|
613
|
+
version: 1, seq: 1, session: 'legacy-sess', ts: '2026-01-01T00:00:00.000Z', action: 'write',
|
|
614
|
+
turn: { turnId: 'turn-2', promptId, promptIndex: 4, messageCountAtStart: 5 },
|
|
615
|
+
targets: [{ path: file, role: 'primary', existedBefore: true, isDir: false, oversize: false, rewindable: true, priorContentB64: priorB64, priorMode: 420, afterExists: true, afterHash }],
|
|
616
|
+
rewindable: true,
|
|
617
|
+
};
|
|
618
|
+
fs.writeFileSync(path.join(sessDir, '1.json'), JSON.stringify(record, null, 2));
|
|
619
|
+
|
|
620
|
+
const store = createCheckpointStore({ getConfig: () => ({ checkpoints: {} }), sessionId: 'legacy-sess', rootDir: root, audit: NOOP_AUDIT });
|
|
621
|
+
const res = store.rewind('last', { mode: 'both', messages: nativeConversation() });
|
|
622
|
+
assert.ok(res.ok);
|
|
623
|
+
assert.strictEqual(fs.readFileSync(file, 'utf8'), 'LEGACY-PRIOR', 'legacy record restores files');
|
|
624
|
+
assert.ok(res.conversation.ok, 'legacy turn linkage drives conversation rewind');
|
|
625
|
+
assert.strictEqual(res.conversation.cutIndex, 4);
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
test('rewind remains human-only: no rewind/checkpoint tool is registered (static, dynamic, specs, tags)', () => {
|
|
629
|
+
const { TOOL_REGISTRY, registryToolNames, dynamicToolEntries } = require('../lib/tool_registry');
|
|
630
|
+
const { TOOL_SPECS } = require('../lib/tool_specs');
|
|
631
|
+
const { TAG_REGISTRY } = require('../lib/constants');
|
|
632
|
+
const forbidden = /rewind|checkpoint/i;
|
|
633
|
+
|
|
634
|
+
assert.ok(!registryToolNames().some((n) => forbidden.test(n)), 'no static tool name mentions rewind/checkpoint');
|
|
635
|
+
assert.ok(!Object.keys(TOOL_REGISTRY).some((n) => forbidden.test(n)));
|
|
636
|
+
assert.ok(!Object.keys(TOOL_SPECS).some((n) => forbidden.test(n)), 'no tool spec for rewind/checkpoint');
|
|
637
|
+
assert.ok(!Object.keys(TAG_REGISTRY).some((n) => forbidden.test(n)), 'no XML tag for rewind/checkpoint');
|
|
638
|
+
assert.ok(!dynamicToolEntries().some(([n]) => forbidden.test(n)), 'no dynamic tool for rewind/checkpoint');
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
test('formatRewindResult surfaces mode, refused guards, and conversation truncation', () => {
|
|
642
|
+
const out = formatRewindResult({
|
|
643
|
+
seq: 3, mode: 'both', action: 'write', restored: ['/a'],
|
|
644
|
+
refused: [{ path: '/b.env', reason: 'blocked by a deny permission rule' }],
|
|
645
|
+
conversation: { ok: true, turnId: 'turn-2', removedCount: 4 },
|
|
646
|
+
});
|
|
647
|
+
assert.match(out, /\[3\] \[both\]/);
|
|
648
|
+
assert.match(out, /refused \(current guards\): \/b\.env/);
|
|
649
|
+
assert.match(out, /conversation: rewound to turn-2 — 4 message/);
|
|
650
|
+
});
|