@selesai/code 0.8.4 → 0.8.6
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/CHANGELOG.md +22 -0
- package/README.md +7 -1
- package/dist/core/slash-commands.js +1 -0
- package/dist/defaults/models.json +46 -55
- package/dist/defaults/settings.json +7 -8
- package/dist/extensions/agent-browser.test.ts +178 -2
- package/dist/extensions/agent-browser.ts +2 -0
- package/dist/extensions/copy-turn.test.ts +229 -0
- package/dist/extensions/copy-turn.ts +11 -0
- package/dist/extensions/grep-app/index.test.ts +356 -1
- package/dist/extensions/grep-app/index.ts +6 -1
- package/dist/extensions/handoff-new.test.ts +351 -161
- package/dist/extensions/handoff-new.ts +3 -0
- package/dist/extensions/inline-skills.test.ts +93 -0
- package/dist/extensions/inline-skills.ts +3 -0
- package/dist/extensions/pi-subagents/CHANGELOG.md +26 -3
- package/dist/extensions/pi-subagents/README.md +2 -0
- package/dist/extensions/pi-subagents/agents/builder.md +5 -2
- package/dist/extensions/pi-subagents/agents/commentator.md +1 -1
- package/dist/extensions/pi-subagents/docs/configuration.md +54 -2
- package/dist/extensions/pi-subagents/docs/observability.md +1 -1
- package/dist/extensions/pi-subagents/docs/tool-reference.md +1 -1
- package/dist/extensions/pi-subagents/docs/workflows.md +1 -1
- package/dist/extensions/pi-subagents/package-lock.json +2 -2
- package/dist/extensions/pi-subagents/package.json +1 -1
- package/dist/extensions/pi-subagents/skills/pi-subagents/references/execution-controls.md +1 -1
- package/dist/extensions/pi-subagents/src/agents/agents.ts +9 -3
- package/dist/extensions/pi-subagents/src/extension/config.ts +12 -0
- package/dist/extensions/pi-subagents/src/extension/doctor.ts +40 -0
- package/dist/extensions/pi-subagents/src/extension/index.ts +104 -14
- package/dist/extensions/pi-subagents/src/extension/public-execution.ts +5 -0
- package/dist/extensions/pi-subagents/src/extension/rpc.ts +3 -9
- package/dist/extensions/pi-subagents/src/extension/schemas.ts +2 -2
- package/dist/extensions/pi-subagents/src/intercom/intercom-bridge.ts +4 -1
- package/dist/extensions/pi-subagents/src/missions/lifecycle.ts +4 -7
- package/dist/extensions/pi-subagents/src/missions/store.ts +4 -4
- package/dist/extensions/pi-subagents/src/missions/workflow-state.ts +6 -2
- package/dist/extensions/pi-subagents/src/runs/background/active-async-capacity.ts +374 -0
- package/dist/extensions/pi-subagents/src/runs/background/active-run-index.ts +9 -5
- package/dist/extensions/pi-subagents/src/runs/background/async-execution.ts +117 -29
- package/dist/extensions/pi-subagents/src/runs/background/async-job-tracker.ts +12 -6
- package/dist/extensions/pi-subagents/src/runs/background/async-resume.ts +7 -1
- package/dist/extensions/pi-subagents/src/runs/background/async-status.ts +14 -5
- package/dist/extensions/pi-subagents/src/runs/background/chain-append.ts +33 -15
- package/dist/extensions/pi-subagents/src/runs/background/notify.ts +5 -2
- package/dist/extensions/pi-subagents/src/runs/background/owned-process-tree.ts +104 -0
- package/dist/extensions/pi-subagents/src/runs/background/process-terminal.ts +17 -3
- package/dist/extensions/pi-subagents/src/runs/background/result-watcher.ts +19 -1
- package/dist/extensions/pi-subagents/src/runs/background/retained-children.ts +2 -2
- package/dist/extensions/pi-subagents/src/runs/background/run-id-resolver.ts +5 -2
- package/dist/extensions/pi-subagents/src/runs/background/run-status.ts +12 -6
- package/dist/extensions/pi-subagents/src/runs/background/stale-run-reconciler.ts +3 -3
- package/dist/extensions/pi-subagents/src/runs/background/subagent-runner.ts +84 -36
- package/dist/extensions/pi-subagents/src/runs/background/wait-subscriptions.ts +2 -1
- package/dist/extensions/pi-subagents/src/runs/foreground/chain-execution.ts +37 -2
- package/dist/extensions/pi-subagents/src/runs/foreground/execution.ts +135 -22
- package/dist/extensions/pi-subagents/src/runs/foreground/foreground-control.ts +12 -0
- package/dist/extensions/pi-subagents/src/runs/foreground/foreground-history.ts +7 -4
- package/dist/extensions/pi-subagents/src/runs/foreground/prompt-audit.ts +171 -0
- package/dist/extensions/pi-subagents/src/runs/foreground/subagent-executor.ts +653 -196
- package/dist/extensions/pi-subagents/src/runs/shared/acceptance.ts +13 -4
- package/dist/extensions/pi-subagents/src/runs/shared/llm-intent-arbiter.ts +286 -0
- package/dist/extensions/pi-subagents/src/runs/shared/model-fallback.ts +23 -0
- package/dist/extensions/pi-subagents/src/runs/shared/parallel-utils.ts +2 -0
- package/dist/extensions/pi-subagents/src/runs/shared/pi-args.ts +44 -1
- package/dist/extensions/pi-subagents/src/runs/shared/run-fanout-budget.ts +280 -0
- package/dist/extensions/pi-subagents/src/runs/shared/single-output.ts +4 -2
- package/dist/extensions/pi-subagents/src/runs/shared/task-intent.ts +19 -3
- package/dist/extensions/pi-subagents/src/runs/shared/worktree.ts +17 -5
- package/dist/extensions/pi-subagents/src/shared/session-lineage.ts +71 -0
- package/dist/extensions/pi-subagents/src/shared/types.ts +108 -0
- package/dist/extensions/pi-subagents/src/shared/utils.ts +3 -1
- package/dist/extensions/pi-subagents/src/tui/fleet-status.ts +7 -5
- package/dist/extensions/pi-subagents/src/tui/fleet.ts +226 -13
- package/dist/extensions/pi-subagents/src/workflows/scripted-workflow.ts +22 -5
- package/dist/extensions/pi-subagents/src/workflows/workflow-auto-relaunch.ts +28 -0
- package/dist/extensions/pi-subagents/test/integration/acceptance-file-report.test.ts +87 -0
- package/dist/extensions/pi-subagents/test/integration/async-execution.test.ts +96 -6
- package/dist/extensions/pi-subagents/test/integration/async-status.test.ts +28 -5
- package/dist/extensions/pi-subagents/test/integration/chain-execution.test.ts +8 -5
- package/dist/extensions/pi-subagents/test/integration/fork-context-execution.test.ts +83 -2
- package/dist/extensions/pi-subagents/test/integration/intercom-result-delivery.test.ts +211 -15
- package/dist/extensions/pi-subagents/test/integration/parallel-execution.test.ts +41 -3
- package/dist/extensions/pi-subagents/test/integration/single-execution.test.ts +314 -10
- package/dist/extensions/pi-subagents/test/unit/acceptance.test.ts +31 -1
- package/dist/extensions/pi-subagents/test/unit/active-async-capacity.test.ts +317 -0
- package/dist/extensions/pi-subagents/test/unit/agent-overrides.test.ts +21 -2
- package/dist/extensions/pi-subagents/test/unit/async-recovery-descriptor.test.ts +16 -1
- package/dist/extensions/pi-subagents/test/unit/async-resume.test.ts +6 -1
- package/dist/extensions/pi-subagents/test/unit/chain-append.test.ts +75 -4
- package/dist/extensions/pi-subagents/test/unit/completion-guard.test.ts +18 -0
- package/dist/extensions/pi-subagents/test/unit/doctor.test.ts +29 -1
- package/dist/extensions/pi-subagents/test/unit/fleet-status.test.ts +31 -5
- package/dist/extensions/pi-subagents/test/unit/fleet.test.ts +139 -1
- package/dist/extensions/pi-subagents/test/unit/foreground-control.test.ts +10 -0
- package/dist/extensions/pi-subagents/test/unit/handoff-adoption.test.ts +103 -0
- package/dist/extensions/pi-subagents/test/unit/index-child-registration.test.ts +22 -0
- package/dist/extensions/pi-subagents/test/unit/intercom-bridge.test.ts +2 -1
- package/dist/extensions/pi-subagents/test/unit/llm-intent-arbiter.test.ts +171 -0
- package/dist/extensions/pi-subagents/test/unit/mission-lifecycle.test.ts +30 -4
- package/dist/extensions/pi-subagents/test/unit/model-fallback.test.ts +14 -0
- package/dist/extensions/pi-subagents/test/unit/owned-process-tree.test.ts +69 -0
- package/dist/extensions/pi-subagents/test/unit/pi-args.test.ts +83 -0
- package/dist/extensions/pi-subagents/test/unit/pi-coding-agent-dir.test.ts +2 -2
- package/dist/extensions/pi-subagents/test/unit/process-terminal.test.ts +55 -2
- package/dist/extensions/pi-subagents/test/unit/public-execution.test.ts +12 -1
- package/dist/extensions/pi-subagents/test/unit/rpc.test.ts +7 -7
- package/dist/extensions/pi-subagents/test/unit/run-fanout-budget.test.ts +121 -0
- package/dist/extensions/pi-subagents/test/unit/run-status.test.ts +9 -0
- package/dist/extensions/pi-subagents/test/unit/schemas.test.ts +4 -3
- package/dist/extensions/pi-subagents/test/unit/session-lineage.test.ts +73 -0
- package/dist/extensions/pi-subagents/test/unit/single-output.test.ts +28 -0
- package/dist/extensions/pi-subagents/test/unit/steering-action.test.ts +10 -1
- package/dist/extensions/pi-subagents/test/unit/task-intent.test.ts +39 -0
- package/dist/extensions/pi-subagents/test/unit/timeout-defaults.test.ts +65 -0
- package/dist/extensions/pi-subagents/test/unit/workflow-auto-relaunch.test.ts +45 -0
- package/dist/extensions/pi-subagents/test/unit/worktree.test.ts +61 -0
- package/dist/extensions/ponytail/index.js +11 -0
- package/dist/extensions/ponytail/ponytail-config.cjs +2 -0
- package/dist/extensions/ponytail/ponytail-instructions.cjs +6 -0
- package/dist/extensions/ponytail/test/extension.test.js +274 -140
- package/dist/extensions/ponytail/test/helpers.test.js +280 -92
- package/dist/extensions/question/index.ts +18 -0
- package/dist/extensions/question/question-list.ts +22 -0
- package/dist/extensions/question/tests/batch.test.ts +103 -65
- package/dist/extensions/question/tests/helpers.test.ts +68 -35
- package/dist/extensions/question/tests/question-list.test.ts +506 -204
- package/dist/extensions/question/tests/row-layout.test.ts +180 -114
- package/dist/extensions/question/tests/schemas.test.ts +42 -19
- package/dist/extensions/question/tests/shortcuts.test.ts +106 -84
- package/dist/extensions/question/tests/wizard.test.ts +771 -50
- package/dist/extensions/rtk.test.ts +180 -1
- package/dist/extensions/tokenin-onboarding.ts +3 -0
- package/dist/extensions/undo.test.ts +720 -0
- package/dist/extensions/undo.ts +6 -0
- package/dist/extensions/web-agent-onboarding.test.ts +415 -0
- package/dist/extensions/workflow/extension.ts +3 -3
- package/dist/extensions/workflow/modes.ts +41 -19
- package/dist/modes/interactive/interactive-mode.d.ts +1 -0
- package/dist/modes/interactive/interactive-mode.js +48 -1
- package/dist/skills/pi-subagents/references/execution-controls.md +1 -1
- package/docs/plans/workflow-autoloop-reference.md +1 -2
- package/docs/plans/workflow-handoff-carryover.md +108 -0
- package/docs/quickstart.md +27 -43
- package/docs/settings.md +4 -0
- package/docs/usage.md +1 -0
- package/package.json +5 -1
|
@@ -0,0 +1,720 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
type Handler = (event: any, ctx: any) => any;
|
|
7
|
+
type Command = { description: string; handler: (args: string, ctx: any) => Promise<void> };
|
|
8
|
+
|
|
9
|
+
let undoExtension: (pi: any) => void;
|
|
10
|
+
let handlers: Map<string, Handler>;
|
|
11
|
+
let commands: Map<string, Command>;
|
|
12
|
+
|
|
13
|
+
beforeEach(async () => {
|
|
14
|
+
vi.resetModules();
|
|
15
|
+
const mod = await import("./undo.ts");
|
|
16
|
+
undoExtension = mod.default;
|
|
17
|
+
handlers = new Map();
|
|
18
|
+
commands = new Map();
|
|
19
|
+
const pi = {
|
|
20
|
+
on: vi.fn((event: string, handler: Handler) => {
|
|
21
|
+
handlers.set(event, handler);
|
|
22
|
+
}),
|
|
23
|
+
registerCommand: vi.fn((name: string, opts: Command) => {
|
|
24
|
+
commands.set(name, opts);
|
|
25
|
+
}),
|
|
26
|
+
};
|
|
27
|
+
undoExtension(pi);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
function makeCtx(opts: {
|
|
31
|
+
cwd?: string;
|
|
32
|
+
branch?: any[];
|
|
33
|
+
notify?: ReturnType<typeof vi.fn>;
|
|
34
|
+
setEditorText?: ReturnType<typeof vi.fn>;
|
|
35
|
+
navigateTree?: ReturnType<typeof vi.fn>;
|
|
36
|
+
waitForIdle?: ReturnType<typeof vi.fn>;
|
|
37
|
+
} = {}) {
|
|
38
|
+
const notify = opts.notify ?? vi.fn();
|
|
39
|
+
const setEditorText = opts.setEditorText ?? vi.fn();
|
|
40
|
+
const navigateTree = opts.navigateTree ?? vi.fn(async () => {});
|
|
41
|
+
const waitForIdle = opts.waitForIdle ?? vi.fn(async () => {});
|
|
42
|
+
const ctx = {
|
|
43
|
+
cwd: opts.cwd ?? "/tmp",
|
|
44
|
+
sessionManager: {
|
|
45
|
+
getBranch: () => opts.branch ?? [],
|
|
46
|
+
},
|
|
47
|
+
ui: { notify, setEditorText },
|
|
48
|
+
navigateTree,
|
|
49
|
+
waitForIdle,
|
|
50
|
+
};
|
|
51
|
+
return { ctx, notify, setEditorText, navigateTree, waitForIdle };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function userEntry(id: string, content: any, parentId: string | null = "root") {
|
|
55
|
+
return { id, type: "message", message: { role: "user", content }, parentId };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe("undo extension", () => {
|
|
59
|
+
it("registers undo and undo-status commands", () => {
|
|
60
|
+
expect(commands.has("undo")).toBe(true);
|
|
61
|
+
expect(commands.has("undo-status")).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("tracks an edit and restores it on /undo", async () => {
|
|
65
|
+
const dir = mkdtempSync(join(tmpdir(), "undo-edit-"));
|
|
66
|
+
const file = join(dir, "a.ts");
|
|
67
|
+
writeFileSync(file, "line1\nline2\n", "utf-8");
|
|
68
|
+
const { ctx, notify, setEditorText, navigateTree } = makeCtx({
|
|
69
|
+
cwd: dir,
|
|
70
|
+
branch: [userEntry("u1", "fix the bug")],
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
await handlers.get("tool_call")!(
|
|
74
|
+
{ type: "tool_call", toolName: "edit", toolCallId: "c1", input: { path: "a.ts", edits: [{ oldText: "line1", newText: "line1 changed" }] } },
|
|
75
|
+
ctx,
|
|
76
|
+
);
|
|
77
|
+
writeFileSync(file, "line1 changed\nline2\n", "utf-8");
|
|
78
|
+
await handlers.get("tool_result")!({ type: "tool_result", toolCallId: "c1", isError: false }, ctx);
|
|
79
|
+
|
|
80
|
+
await commands.get("undo")!.handler("", ctx);
|
|
81
|
+
expect(notify).toHaveBeenCalledWith(expect.stringContaining("Undid turn u1; restored 1 file op(s)."), "info");
|
|
82
|
+
expect(setEditorText).toHaveBeenCalledWith("fix the bug");
|
|
83
|
+
expect(navigateTree).toHaveBeenCalledWith("root", { summarize: false });
|
|
84
|
+
expect(require("node:fs").readFileSync(file, "utf-8")).toBe("line1\nline2\n");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("tracks a write to a new file and removes it on /undo", async () => {
|
|
88
|
+
const dir = mkdtempSync(join(tmpdir(), "undo-write-new-"));
|
|
89
|
+
const file = join(dir, "new.ts");
|
|
90
|
+
const { ctx, notify } = makeCtx({ cwd: dir, branch: [userEntry("u2", "add file")] });
|
|
91
|
+
|
|
92
|
+
await handlers.get("tool_call")!(
|
|
93
|
+
{ type: "tool_call", toolName: "write", toolCallId: "c1", input: { path: "new.ts", content: "content" } },
|
|
94
|
+
ctx,
|
|
95
|
+
);
|
|
96
|
+
writeFileSync(file, "content", "utf-8");
|
|
97
|
+
await handlers.get("tool_result")!({ type: "tool_result", toolCallId: "c1", isError: false }, ctx);
|
|
98
|
+
|
|
99
|
+
await commands.get("undo")!.handler("", ctx);
|
|
100
|
+
expect(notify).toHaveBeenCalledWith(expect.stringContaining("restored 1 file op(s)."), "info");
|
|
101
|
+
expect(require("node:fs").existsSync(file)).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("tracks a write to an existing file and restores the old content", async () => {
|
|
105
|
+
const dir = mkdtempSync(join(tmpdir(), "undo-write-old-"));
|
|
106
|
+
const file = join(dir, "a.ts");
|
|
107
|
+
writeFileSync(file, "old content", "utf-8");
|
|
108
|
+
const { ctx, notify } = makeCtx({ cwd: dir, branch: [userEntry("u3", "overwrite")] });
|
|
109
|
+
|
|
110
|
+
await handlers.get("tool_call")!(
|
|
111
|
+
{ type: "tool_call", toolName: "write", toolCallId: "c1", input: { path: "a.ts", content: "new content" } },
|
|
112
|
+
ctx,
|
|
113
|
+
);
|
|
114
|
+
writeFileSync(file, "new content", "utf-8");
|
|
115
|
+
await handlers.get("tool_result")!({ type: "tool_result", toolCallId: "c1", isError: false }, ctx);
|
|
116
|
+
|
|
117
|
+
await commands.get("undo")!.handler("", ctx);
|
|
118
|
+
expect(notify).toHaveBeenCalledWith(expect.stringContaining("restored 1 file op(s)."), "info");
|
|
119
|
+
expect(require("node:fs").readFileSync(file, "utf-8")).toBe("old content");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("tracks mutating bash commands and warns on /undo", async () => {
|
|
123
|
+
const { ctx, notify } = makeCtx({ branch: [userEntry("u4", "run stuff")] });
|
|
124
|
+
|
|
125
|
+
await handlers.get("tool_call")!(
|
|
126
|
+
{ type: "tool_call", toolName: "bash", toolCallId: "c1", input: { command: "rm -rf build" } },
|
|
127
|
+
ctx,
|
|
128
|
+
);
|
|
129
|
+
await handlers.get("tool_result")!({ type: "tool_result", toolCallId: "c1", isError: false }, ctx);
|
|
130
|
+
|
|
131
|
+
await commands.get("undo")!.handler("", ctx);
|
|
132
|
+
expect(notify).toHaveBeenCalledWith(expect.stringContaining("1 bash command(s) not tracked"), "warning");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("does not track readonly bash commands", async () => {
|
|
136
|
+
const { ctx, notify } = makeCtx({ branch: [userEntry("u5", "read")] });
|
|
137
|
+
|
|
138
|
+
await handlers.get("tool_call")!(
|
|
139
|
+
{ type: "tool_call", toolName: "bash", toolCallId: "c1", input: { command: "ls -la" } },
|
|
140
|
+
ctx,
|
|
141
|
+
);
|
|
142
|
+
await handlers.get("tool_result")!({ type: "tool_result", toolCallId: "c1", isError: false }, ctx);
|
|
143
|
+
|
|
144
|
+
await commands.get("undo")!.handler("", ctx);
|
|
145
|
+
expect(notify).toHaveBeenCalledWith(expect.stringContaining("restored 0 file op(s)."), "info");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("notifies when there is nothing to undo", async () => {
|
|
149
|
+
const { ctx, notify } = makeCtx();
|
|
150
|
+
await commands.get("undo")!.handler("", ctx);
|
|
151
|
+
expect(notify).toHaveBeenCalledWith("Nothing to undo", "warning");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("refuses to undo a root prompt", async () => {
|
|
155
|
+
const { ctx, notify } = makeCtx({ branch: [userEntry("u6", "root", null)] });
|
|
156
|
+
await commands.get("undo")!.handler("", ctx);
|
|
157
|
+
expect(notify).toHaveBeenCalledWith("Cannot undo root prompt in-place yet. Use /new or /tree.", "warning");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("stops on a restore error without --force", async () => {
|
|
161
|
+
const dir = mkdtempSync(join(tmpdir(), "undo-error-"));
|
|
162
|
+
const file = join(dir, "a.ts");
|
|
163
|
+
writeFileSync(file, "original", "utf-8");
|
|
164
|
+
const { ctx, notify } = makeCtx({ cwd: dir, branch: [userEntry("u7", "edit")] });
|
|
165
|
+
|
|
166
|
+
await handlers.get("tool_call")!(
|
|
167
|
+
{ type: "tool_call", toolName: "edit", toolCallId: "c1", input: { path: "a.ts", edits: [{ oldText: "original", newText: "changed" }] } },
|
|
168
|
+
ctx,
|
|
169
|
+
);
|
|
170
|
+
writeFileSync(file, "changed", "utf-8");
|
|
171
|
+
await handlers.get("tool_result")!({ type: "tool_result", toolCallId: "c1", isError: false }, ctx);
|
|
172
|
+
|
|
173
|
+
// Modify the file after the edit so the hash check fails.
|
|
174
|
+
writeFileSync(file, "changed by someone else", "utf-8");
|
|
175
|
+
await commands.get("undo")!.handler("", ctx);
|
|
176
|
+
expect(notify).toHaveBeenCalledWith(expect.stringContaining("Undo stopped"), "error");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("forces restore past errors with --force", async () => {
|
|
180
|
+
const dir = mkdtempSync(join(tmpdir(), "undo-force-"));
|
|
181
|
+
const file = join(dir, "a.ts");
|
|
182
|
+
writeFileSync(file, "original", "utf-8");
|
|
183
|
+
const { ctx, notify } = makeCtx({ cwd: dir, branch: [userEntry("u8", "edit")] });
|
|
184
|
+
|
|
185
|
+
await handlers.get("tool_call")!(
|
|
186
|
+
{ type: "tool_call", toolName: "edit", toolCallId: "c1", input: { path: "a.ts", edits: [{ oldText: "original", newText: "changed" }] } },
|
|
187
|
+
ctx,
|
|
188
|
+
);
|
|
189
|
+
writeFileSync(file, "changed", "utf-8");
|
|
190
|
+
await handlers.get("tool_result")!({ type: "tool_result", toolCallId: "c1", isError: false }, ctx);
|
|
191
|
+
|
|
192
|
+
// Modify the file after the edit so the hash check fails.
|
|
193
|
+
writeFileSync(file, "changed by someone else", "utf-8");
|
|
194
|
+
await commands.get("undo")!.handler("--force", ctx);
|
|
195
|
+
// Force skips the hash check and applies the inverse edit ("changed" → "original").
|
|
196
|
+
expect(notify).toHaveBeenCalledWith(expect.stringContaining("restored 1 file op(s)."), "info");
|
|
197
|
+
expect(require("node:fs").readFileSync(file, "utf-8")).toBe("original by someone else");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("skips tool results for unknown tool call ids", async () => {
|
|
201
|
+
await handlers.get("tool_result")!({ type: "tool_result", toolCallId: "unknown", isError: false }, {});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("ignores tool results with errors", async () => {
|
|
205
|
+
const dir = mkdtempSync(join(tmpdir(), "undo-err-"));
|
|
206
|
+
const file = join(dir, "a.ts");
|
|
207
|
+
writeFileSync(file, "original", "utf-8");
|
|
208
|
+
const { ctx, notify } = makeCtx({ cwd: dir, branch: [userEntry("u9", "edit")] });
|
|
209
|
+
|
|
210
|
+
await handlers.get("tool_call")!(
|
|
211
|
+
{ type: "tool_call", toolName: "edit", toolCallId: "c1", input: { path: "a.ts", edits: [{ oldText: "original", newText: "changed" }] } },
|
|
212
|
+
ctx,
|
|
213
|
+
);
|
|
214
|
+
writeFileSync(file, "changed", "utf-8");
|
|
215
|
+
await handlers.get("tool_result")!({ type: "tool_result", toolCallId: "c1", isError: true }, ctx);
|
|
216
|
+
|
|
217
|
+
await commands.get("undo")!.handler("", ctx);
|
|
218
|
+
expect(notify).toHaveBeenCalledWith(expect.stringContaining("restored 0 file op(s)."), "info");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("skips tool results when the file is missing after an edit", async () => {
|
|
222
|
+
const dir = mkdtempSync(join(tmpdir(), "undo-missing-"));
|
|
223
|
+
const { ctx, notify } = makeCtx({ cwd: dir, branch: [userEntry("u10", "edit")] });
|
|
224
|
+
|
|
225
|
+
await handlers.get("tool_call")!(
|
|
226
|
+
{ type: "tool_call", toolName: "edit", toolCallId: "c1", input: { path: "a.ts", edits: [{ oldText: "x", newText: "y" }] } },
|
|
227
|
+
ctx,
|
|
228
|
+
);
|
|
229
|
+
// No file written → tool_result sees a missing file.
|
|
230
|
+
await handlers.get("tool_result")!({ type: "tool_result", toolCallId: "c1", isError: false }, ctx);
|
|
231
|
+
|
|
232
|
+
await commands.get("undo")!.handler("", ctx);
|
|
233
|
+
expect(notify).toHaveBeenCalledWith(expect.stringContaining("restored 0 file op(s)."), "info");
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("skips tool results when the file is missing after a write", async () => {
|
|
237
|
+
const dir = mkdtempSync(join(tmpdir(), "undo-write-missing-"));
|
|
238
|
+
const { ctx, notify } = makeCtx({ cwd: dir, branch: [userEntry("u11", "write")] });
|
|
239
|
+
|
|
240
|
+
await handlers.get("tool_call")!(
|
|
241
|
+
{ type: "tool_call", toolName: "write", toolCallId: "c1", input: { path: "a.ts", content: "x" } },
|
|
242
|
+
ctx,
|
|
243
|
+
);
|
|
244
|
+
await handlers.get("tool_result")!({ type: "tool_result", toolCallId: "c1", isError: false }, ctx);
|
|
245
|
+
|
|
246
|
+
await commands.get("undo")!.handler("", ctx);
|
|
247
|
+
expect(notify).toHaveBeenCalledWith(expect.stringContaining("restored 0 file op(s)."), "info");
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("warns when the old write content is too large to restore", async () => {
|
|
251
|
+
const dir = mkdtempSync(join(tmpdir(), "undo-large-"));
|
|
252
|
+
const file = join(dir, "big.ts");
|
|
253
|
+
writeFileSync(file, "x".repeat(600 * 1024), "utf-8");
|
|
254
|
+
const { ctx, notify } = makeCtx({ cwd: dir, branch: [userEntry("u12", "write big")] });
|
|
255
|
+
|
|
256
|
+
await handlers.get("tool_call")!(
|
|
257
|
+
{ type: "tool_call", toolName: "write", toolCallId: "c1", input: { path: "big.ts", content: "small" } },
|
|
258
|
+
ctx,
|
|
259
|
+
);
|
|
260
|
+
writeFileSync(file, "small", "utf-8");
|
|
261
|
+
await handlers.get("tool_result")!({ type: "tool_result", toolCallId: "c1", isError: false }, ctx);
|
|
262
|
+
|
|
263
|
+
// Without --force the restore stops with an error.
|
|
264
|
+
await commands.get("undo")!.handler("", ctx);
|
|
265
|
+
expect(notify).toHaveBeenCalledWith(expect.stringContaining("Undo stopped"), "error");
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("restoreWrite throws when the old content is too large", async () => {
|
|
269
|
+
const dir = mkdtempSync(join(tmpdir(), "undo-large-restore-"));
|
|
270
|
+
const file = join(dir, "big.ts");
|
|
271
|
+
writeFileSync(file, "x".repeat(600 * 1024), "utf-8");
|
|
272
|
+
const { ctx, notify } = makeCtx({ cwd: dir, branch: [userEntry("u13", "write big")] });
|
|
273
|
+
|
|
274
|
+
await handlers.get("tool_call")!(
|
|
275
|
+
{ type: "tool_call", toolName: "write", toolCallId: "c1", input: { path: "big.ts", content: "small" } },
|
|
276
|
+
ctx,
|
|
277
|
+
);
|
|
278
|
+
writeFileSync(file, "small", "utf-8");
|
|
279
|
+
await handlers.get("tool_result")!({ type: "tool_result", toolCallId: "c1", isError: false }, ctx);
|
|
280
|
+
|
|
281
|
+
// Force restore: the too-large op throws, but --force continues.
|
|
282
|
+
await commands.get("undo")!.handler("--force", ctx);
|
|
283
|
+
expect(notify).toHaveBeenCalledWith(expect.stringContaining("restore error(s)"), "warning");
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("restoreEdit throws when the file is missing", async () => {
|
|
287
|
+
const dir = mkdtempSync(join(tmpdir(), "undo-edit-missing-"));
|
|
288
|
+
const { ctx, notify } = makeCtx({ cwd: dir, branch: [userEntry("u14", "edit")] });
|
|
289
|
+
|
|
290
|
+
await handlers.get("tool_call")!(
|
|
291
|
+
{ type: "tool_call", toolName: "edit", toolCallId: "c1", input: { path: "a.ts", edits: [{ oldText: "x", newText: "y" }] } },
|
|
292
|
+
ctx,
|
|
293
|
+
);
|
|
294
|
+
writeFileSync(join(dir, "a.ts"), "y", "utf-8");
|
|
295
|
+
await handlers.get("tool_result")!({ type: "tool_result", toolCallId: "c1", isError: false }, ctx);
|
|
296
|
+
|
|
297
|
+
// Delete the file before undo.
|
|
298
|
+
rmSync(join(dir, "a.ts"), { force: true });
|
|
299
|
+
await commands.get("undo")!.handler("", ctx);
|
|
300
|
+
expect(notify).toHaveBeenCalledWith(expect.stringContaining("Undo stopped"), "error");
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("restoreEdit throws when the inverse edit matches multiple times", async () => {
|
|
304
|
+
const dir = mkdtempSync(join(tmpdir(), "undo-multi-"));
|
|
305
|
+
const file = join(dir, "a.ts");
|
|
306
|
+
writeFileSync(file, "x\nx\n", "utf-8");
|
|
307
|
+
const { ctx, notify } = makeCtx({ cwd: dir, branch: [userEntry("u15", "edit")] });
|
|
308
|
+
|
|
309
|
+
await handlers.get("tool_call")!(
|
|
310
|
+
{ type: "tool_call", toolName: "edit", toolCallId: "c1", input: { path: "a.ts", edits: [{ oldText: "x", newText: "y" }] } },
|
|
311
|
+
ctx,
|
|
312
|
+
);
|
|
313
|
+
writeFileSync(file, "y\ny\n", "utf-8");
|
|
314
|
+
await handlers.get("tool_result")!({ type: "tool_result", toolCallId: "c1", isError: false }, ctx);
|
|
315
|
+
|
|
316
|
+
// The inverse edit oldText is "y" which appears twice.
|
|
317
|
+
await commands.get("undo")!.handler("", ctx);
|
|
318
|
+
expect(notify).toHaveBeenCalledWith(expect.stringContaining("Undo stopped"), "error");
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("restoreWrite removes a file that was recreated after the write", async () => {
|
|
322
|
+
const dir = mkdtempSync(join(tmpdir(), "undo-recreate-"));
|
|
323
|
+
const file = join(dir, "a.ts");
|
|
324
|
+
const { ctx, notify } = makeCtx({ cwd: dir, branch: [userEntry("u16", "write")] });
|
|
325
|
+
|
|
326
|
+
await handlers.get("tool_call")!(
|
|
327
|
+
{ type: "tool_call", toolName: "write", toolCallId: "c1", input: { path: "a.ts", content: "content" } },
|
|
328
|
+
ctx,
|
|
329
|
+
);
|
|
330
|
+
writeFileSync(file, "content", "utf-8");
|
|
331
|
+
await handlers.get("tool_result")!({ type: "tool_result", toolCallId: "c1", isError: false }, ctx);
|
|
332
|
+
|
|
333
|
+
// Recreate the file with different content → hash mismatch without force.
|
|
334
|
+
writeFileSync(file, "different", "utf-8");
|
|
335
|
+
await commands.get("undo")!.handler("", ctx);
|
|
336
|
+
expect(notify).toHaveBeenCalledWith(expect.stringContaining("Undo stopped"), "error");
|
|
337
|
+
|
|
338
|
+
// With --force the file is removed.
|
|
339
|
+
await commands.get("undo")!.handler("--force", ctx);
|
|
340
|
+
expect(require("node:fs").existsSync(file)).toBe(false);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("undo-status reports tracked checkpoints", async () => {
|
|
344
|
+
const dir = mkdtempSync(join(tmpdir(), "undo-status-"));
|
|
345
|
+
const file = join(dir, "a.ts");
|
|
346
|
+
writeFileSync(file, "x", "utf-8");
|
|
347
|
+
const { ctx, notify } = makeCtx({ cwd: dir, branch: [userEntry("u17", "edit")] });
|
|
348
|
+
|
|
349
|
+
await handlers.get("tool_call")!(
|
|
350
|
+
{ type: "tool_call", toolName: "edit", toolCallId: "c1", input: { path: "a.ts", edits: [{ oldText: "x", newText: "y" }] } },
|
|
351
|
+
ctx,
|
|
352
|
+
);
|
|
353
|
+
writeFileSync(file, "y", "utf-8");
|
|
354
|
+
await handlers.get("tool_result")!({ type: "tool_result", toolCallId: "c1", isError: false }, ctx);
|
|
355
|
+
|
|
356
|
+
await commands.get("undo-status")!.handler("", ctx);
|
|
357
|
+
expect(notify).toHaveBeenCalledWith(expect.stringContaining("u17 *: 1 op(s), 0 bash, 0 skipped"), "info");
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it("undo-status reports no checkpoints when none are tracked", async () => {
|
|
361
|
+
const { ctx, notify } = makeCtx({ branch: [userEntry("u18", "hi")] });
|
|
362
|
+
await commands.get("undo-status")!.handler("", ctx);
|
|
363
|
+
expect(notify).toHaveBeenCalledWith("No undo checkpoints tracked", "info");
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it("extracts prompts from string, array, and non-text content", async () => {
|
|
367
|
+
const dir = mkdtempSync(join(tmpdir(), "undo-prompt-"));
|
|
368
|
+
const file = join(dir, "a.ts");
|
|
369
|
+
writeFileSync(file, "x", "utf-8");
|
|
370
|
+
|
|
371
|
+
// String content.
|
|
372
|
+
const { ctx: ctx1, setEditorText: set1 } = makeCtx({
|
|
373
|
+
cwd: dir,
|
|
374
|
+
branch: [userEntry("u19", "plain string prompt")],
|
|
375
|
+
});
|
|
376
|
+
await handlers.get("tool_call")!(
|
|
377
|
+
{ type: "tool_call", toolName: "edit", toolCallId: "c1", input: { path: "a.ts", edits: [{ oldText: "x", newText: "y" }] } },
|
|
378
|
+
ctx1,
|
|
379
|
+
);
|
|
380
|
+
writeFileSync(file, "y", "utf-8");
|
|
381
|
+
await handlers.get("tool_result")!({ type: "tool_result", toolCallId: "c1", isError: false }, ctx1);
|
|
382
|
+
await commands.get("undo")!.handler("", ctx1);
|
|
383
|
+
expect(set1).toHaveBeenCalledWith("plain string prompt");
|
|
384
|
+
|
|
385
|
+
// Array content with text + image parts.
|
|
386
|
+
const { ctx: ctx2, setEditorText: set2 } = makeCtx({
|
|
387
|
+
cwd: dir,
|
|
388
|
+
branch: [userEntry("u20", [{ type: "text", text: "text part" }, { type: "image", mimeType: "image/png" }, { type: "tool", text: "ignored" }])],
|
|
389
|
+
});
|
|
390
|
+
await handlers.get("tool_call")!(
|
|
391
|
+
{ type: "tool_call", toolName: "edit", toolCallId: "c2", input: { path: "a.ts", edits: [{ oldText: "y", newText: "z" }] } },
|
|
392
|
+
ctx2,
|
|
393
|
+
);
|
|
394
|
+
writeFileSync(file, "z", "utf-8");
|
|
395
|
+
await handlers.get("tool_result")!({ type: "tool_result", toolCallId: "c2", isError: false }, ctx2);
|
|
396
|
+
await commands.get("undo")!.handler("", ctx2);
|
|
397
|
+
expect(set2).toHaveBeenCalledWith("text part\n[image image/png]");
|
|
398
|
+
|
|
399
|
+
// Non-string non-array content.
|
|
400
|
+
const { ctx: ctx3, setEditorText: set3 } = makeCtx({
|
|
401
|
+
cwd: dir,
|
|
402
|
+
branch: [userEntry("u21", 42)],
|
|
403
|
+
});
|
|
404
|
+
await commands.get("undo")!.handler("", ctx3);
|
|
405
|
+
expect(set3).toHaveBeenCalledWith("");
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it("handles absolute paths in edit inputs", async () => {
|
|
409
|
+
const dir = mkdtempSync(join(tmpdir(), "undo-abs-"));
|
|
410
|
+
const file = join(dir, "a.ts");
|
|
411
|
+
writeFileSync(file, "x", "utf-8");
|
|
412
|
+
const { ctx, notify } = makeCtx({ cwd: "/other", branch: [userEntry("u22", "edit")] });
|
|
413
|
+
|
|
414
|
+
await handlers.get("tool_call")!(
|
|
415
|
+
{ type: "tool_call", toolName: "edit", toolCallId: "c1", input: { path: file, edits: [{ oldText: "x", newText: "y" }] } },
|
|
416
|
+
ctx,
|
|
417
|
+
);
|
|
418
|
+
writeFileSync(file, "y", "utf-8");
|
|
419
|
+
await handlers.get("tool_result")!({ type: "tool_result", toolCallId: "c1", isError: false }, ctx);
|
|
420
|
+
|
|
421
|
+
await commands.get("undo")!.handler("", ctx);
|
|
422
|
+
expect(notify).toHaveBeenCalledWith(expect.stringContaining("restored 1 file op(s)."), "info");
|
|
423
|
+
expect(require("node:fs").readFileSync(file, "utf-8")).toBe("x");
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it("restores multiple ops in reverse order", async () => {
|
|
427
|
+
const dir = mkdtempSync(join(tmpdir(), "undo-multi-op-"));
|
|
428
|
+
const file = join(dir, "a.ts");
|
|
429
|
+
writeFileSync(file, "one", "utf-8");
|
|
430
|
+
const { ctx, notify } = makeCtx({ cwd: dir, branch: [userEntry("u23", "edit twice")] });
|
|
431
|
+
|
|
432
|
+
await handlers.get("tool_call")!(
|
|
433
|
+
{ type: "tool_call", toolName: "edit", toolCallId: "c1", input: { path: "a.ts", edits: [{ oldText: "one", newText: "two" }] } },
|
|
434
|
+
ctx,
|
|
435
|
+
);
|
|
436
|
+
writeFileSync(file, "two", "utf-8");
|
|
437
|
+
await handlers.get("tool_result")!({ type: "tool_result", toolCallId: "c1", isError: false }, ctx);
|
|
438
|
+
|
|
439
|
+
await handlers.get("tool_call")!(
|
|
440
|
+
{ type: "tool_call", toolName: "edit", toolCallId: "c2", input: { path: "a.ts", edits: [{ oldText: "two", newText: "three" }] } },
|
|
441
|
+
ctx,
|
|
442
|
+
);
|
|
443
|
+
writeFileSync(file, "three", "utf-8");
|
|
444
|
+
await handlers.get("tool_result")!({ type: "tool_result", toolCallId: "c2", isError: false }, ctx);
|
|
445
|
+
|
|
446
|
+
await commands.get("undo")!.handler("", ctx);
|
|
447
|
+
expect(notify).toHaveBeenCalledWith(expect.stringContaining("restored 2 file op(s)."), "info");
|
|
448
|
+
expect(require("node:fs").readFileSync(file, "utf-8")).toBe("one");
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it("restores a write to a nested directory", async () => {
|
|
452
|
+
const dir = mkdtempSync(join(tmpdir(), "undo-nested-"));
|
|
453
|
+
const nested = join(dir, "sub", "dir");
|
|
454
|
+
mkdirSync(nested, { recursive: true });
|
|
455
|
+
const file = join(nested, "a.ts");
|
|
456
|
+
writeFileSync(file, "old", "utf-8");
|
|
457
|
+
const { ctx, notify } = makeCtx({ cwd: dir, branch: [userEntry("u24", "write")] });
|
|
458
|
+
|
|
459
|
+
await handlers.get("tool_call")!(
|
|
460
|
+
{ type: "tool_call", toolName: "write", toolCallId: "c1", input: { path: "sub/dir/a.ts", content: "new" } },
|
|
461
|
+
ctx,
|
|
462
|
+
);
|
|
463
|
+
writeFileSync(file, "new", "utf-8");
|
|
464
|
+
await handlers.get("tool_result")!({ type: "tool_result", toolCallId: "c1", isError: false }, ctx);
|
|
465
|
+
|
|
466
|
+
await commands.get("undo")!.handler("", ctx);
|
|
467
|
+
expect(notify).toHaveBeenCalledWith(expect.stringContaining("restored 1 file op(s)."), "info");
|
|
468
|
+
expect(require("node:fs").readFileSync(file, "utf-8")).toBe("old");
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
it("restores a write to a deleted directory by recreating it with --force", async () => {
|
|
472
|
+
const dir = mkdtempSync(join(tmpdir(), "undo-recreate-dir-"));
|
|
473
|
+
const nested = join(dir, "sub");
|
|
474
|
+
mkdirSync(nested, { recursive: true });
|
|
475
|
+
const file = join(nested, "a.ts");
|
|
476
|
+
writeFileSync(file, "old", "utf-8");
|
|
477
|
+
const { ctx, notify } = makeCtx({ cwd: dir, branch: [userEntry("u25", "write")] });
|
|
478
|
+
|
|
479
|
+
await handlers.get("tool_call")!(
|
|
480
|
+
{ type: "tool_call", toolName: "write", toolCallId: "c1", input: { path: "sub/a.ts", content: "new" } },
|
|
481
|
+
ctx,
|
|
482
|
+
);
|
|
483
|
+
writeFileSync(file, "new", "utf-8");
|
|
484
|
+
await handlers.get("tool_result")!({ type: "tool_result", toolCallId: "c1", isError: false }, ctx);
|
|
485
|
+
|
|
486
|
+
// Delete the whole sub dir before undo → the file is missing, so the
|
|
487
|
+
// hash check fails without --force.
|
|
488
|
+
rmSync(nested, { recursive: true, force: true });
|
|
489
|
+
await commands.get("undo")!.handler("--force", ctx);
|
|
490
|
+
expect(notify).toHaveBeenCalledWith(expect.stringContaining("restored 1 file op(s)."), "info");
|
|
491
|
+
expect(require("node:fs").readFileSync(file, "utf-8")).toBe("old");
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it("restoreWrite with --force removes a recreated file", async () => {
|
|
495
|
+
const dir = mkdtempSync(join(tmpdir(), "undo-force-rm-"));
|
|
496
|
+
const file = join(dir, "a.ts");
|
|
497
|
+
const { ctx, notify } = makeCtx({ cwd: dir, branch: [userEntry("u26", "write")] });
|
|
498
|
+
|
|
499
|
+
await handlers.get("tool_call")!(
|
|
500
|
+
{ type: "tool_call", toolName: "write", toolCallId: "c1", input: { path: "a.ts", content: "content" } },
|
|
501
|
+
ctx,
|
|
502
|
+
);
|
|
503
|
+
writeFileSync(file, "content", "utf-8");
|
|
504
|
+
await handlers.get("tool_result")!({ type: "tool_result", toolCallId: "c1", isError: false }, ctx);
|
|
505
|
+
|
|
506
|
+
// Recreate with different content.
|
|
507
|
+
writeFileSync(file, "different", "utf-8");
|
|
508
|
+
await commands.get("undo")!.handler("--force", ctx);
|
|
509
|
+
expect(require("node:fs").existsSync(file)).toBe(false);
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it("ignores tool calls when there is no user entry in the branch", async () => {
|
|
513
|
+
const { ctx } = makeCtx({ branch: [{ type: "message", message: { role: "assistant", content: "hi" } }] });
|
|
514
|
+
await handlers.get("tool_call")!(
|
|
515
|
+
{ type: "tool_call", toolName: "edit", toolCallId: "c1", input: { path: "a.ts", edits: [] } },
|
|
516
|
+
ctx,
|
|
517
|
+
);
|
|
518
|
+
// No crash; nothing tracked.
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
it("readTextIfExists rethrows non-ENOENT errors", async () => {
|
|
522
|
+
const dir = mkdtempSync(join(tmpdir(), "undo-readerr-"));
|
|
523
|
+
const file = join(dir, "a.ts");
|
|
524
|
+
mkdirSync(file, { recursive: true }); // a directory, not a file → EISDIR
|
|
525
|
+
const { ctx, notify } = makeCtx({ cwd: dir, branch: [userEntry("u27", "edit")] });
|
|
526
|
+
|
|
527
|
+
await handlers.get("tool_call")!(
|
|
528
|
+
{ type: "tool_call", toolName: "edit", toolCallId: "c1", input: { path: "a.ts", edits: [{ oldText: "x", newText: "y" }] } },
|
|
529
|
+
ctx,
|
|
530
|
+
);
|
|
531
|
+
// The tool_result read of the directory throws EISDIR, which propagates.
|
|
532
|
+
await expect(handlers.get("tool_result")!({ type: "tool_result", toolCallId: "c1", isError: false }, ctx)).rejects.toThrow();
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it("countOccurrences handles empty needles and multiple matches", async () => {
|
|
536
|
+
const dir = mkdtempSync(join(tmpdir(), "undo-count-"));
|
|
537
|
+
const file = join(dir, "a.ts");
|
|
538
|
+
writeFileSync(file, "x", "utf-8");
|
|
539
|
+
const { ctx, notify } = makeCtx({ cwd: dir, branch: [userEntry("u28", "edit")] });
|
|
540
|
+
|
|
541
|
+
// An edit with an empty newText produces an inverse edit with an empty
|
|
542
|
+
// oldText, which countOccurrences treats as infinite matches → error.
|
|
543
|
+
await handlers.get("tool_call")!(
|
|
544
|
+
{ type: "tool_call", toolName: "edit", toolCallId: "c1", input: { path: "a.ts", edits: [{ oldText: "x", newText: "" }] } },
|
|
545
|
+
ctx,
|
|
546
|
+
);
|
|
547
|
+
writeFileSync(file, "", "utf-8");
|
|
548
|
+
await handlers.get("tool_result")!({ type: "tool_result", toolCallId: "c1", isError: false }, ctx);
|
|
549
|
+
|
|
550
|
+
await commands.get("undo")!.handler("", ctx);
|
|
551
|
+
expect(notify).toHaveBeenCalledWith(expect.stringContaining("Undo stopped"), "error");
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
it("looksReadonlyBash treats empty commands as readonly", async () => {
|
|
555
|
+
const { ctx, notify } = makeCtx({ branch: [userEntry("u29", "")] });
|
|
556
|
+
await handlers.get("tool_call")!(
|
|
557
|
+
{ type: "tool_call", toolName: "bash", toolCallId: "c1", input: { command: " " } },
|
|
558
|
+
ctx,
|
|
559
|
+
);
|
|
560
|
+
await handlers.get("tool_result")!({ type: "tool_result", toolCallId: "c1", isError: false }, ctx);
|
|
561
|
+
await commands.get("undo")!.handler("", ctx);
|
|
562
|
+
expect(notify).toHaveBeenCalledWith(expect.stringContaining("restored 0 file op(s)."), "info");
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
it("looksReadonlyBash treats compound mutating commands as mutating", async () => {
|
|
566
|
+
const { ctx, notify } = makeCtx({ branch: [userEntry("u30", "run")] });
|
|
567
|
+
await handlers.get("tool_call")!(
|
|
568
|
+
{ type: "tool_call", toolName: "bash", toolCallId: "c1", input: { command: "cd build && rm -rf dist" } },
|
|
569
|
+
ctx,
|
|
570
|
+
);
|
|
571
|
+
await handlers.get("tool_result")!({ type: "tool_result", toolCallId: "c1", isError: false }, ctx);
|
|
572
|
+
await commands.get("undo")!.handler("", ctx);
|
|
573
|
+
expect(notify).toHaveBeenCalledWith(expect.stringContaining("1 bash command(s) not tracked"), "warning");
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
it("looksReadonlyBash treats git checkout as mutating", async () => {
|
|
577
|
+
const { ctx, notify } = makeCtx({ branch: [userEntry("u31", "run")] });
|
|
578
|
+
await handlers.get("tool_call")!(
|
|
579
|
+
{ type: "tool_call", toolName: "bash", toolCallId: "c1", input: { command: "git checkout main" } },
|
|
580
|
+
ctx,
|
|
581
|
+
);
|
|
582
|
+
await handlers.get("tool_result")!({ type: "tool_result", toolCallId: "c1", isError: false }, ctx);
|
|
583
|
+
await commands.get("undo")!.handler("", ctx);
|
|
584
|
+
expect(notify).toHaveBeenCalledWith(expect.stringContaining("1 bash command(s) not tracked"), "warning");
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
it("looksReadonlyBash treats git status as readonly", async () => {
|
|
588
|
+
const { ctx, notify } = makeCtx({ branch: [userEntry("u32", "run")] });
|
|
589
|
+
await handlers.get("tool_call")!(
|
|
590
|
+
{ type: "tool_call", toolName: "bash", toolCallId: "c1", input: { command: "git status" } },
|
|
591
|
+
ctx,
|
|
592
|
+
);
|
|
593
|
+
await handlers.get("tool_result")!({ type: "tool_result", toolCallId: "c1", isError: false }, ctx);
|
|
594
|
+
await commands.get("undo")!.handler("", ctx);
|
|
595
|
+
expect(notify).toHaveBeenCalledWith(expect.stringContaining("restored 0 file op(s)."), "info");
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
it("looksReadonlyBash treats npm test as readonly", async () => {
|
|
599
|
+
const { ctx, notify } = makeCtx({ branch: [userEntry("u33", "run")] });
|
|
600
|
+
await handlers.get("tool_call")!(
|
|
601
|
+
{ type: "tool_call", toolName: "bash", toolCallId: "c1", input: { command: "npm test" } },
|
|
602
|
+
ctx,
|
|
603
|
+
);
|
|
604
|
+
await handlers.get("tool_result")!({ type: "tool_result", toolCallId: "c1", isError: false }, ctx);
|
|
605
|
+
await commands.get("undo")!.handler("", ctx);
|
|
606
|
+
expect(notify).toHaveBeenCalledWith(expect.stringContaining("restored 0 file op(s)."), "info");
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
it("looksReadonlyBash treats unknown commands as mutating", async () => {
|
|
610
|
+
const { ctx, notify } = makeCtx({ branch: [userEntry("u34", "run")] });
|
|
611
|
+
await handlers.get("tool_call")!(
|
|
612
|
+
{ type: "tool_call", toolName: "bash", toolCallId: "c1", input: { command: "echo hello" } },
|
|
613
|
+
ctx,
|
|
614
|
+
);
|
|
615
|
+
await handlers.get("tool_result")!({ type: "tool_result", toolCallId: "c1", isError: false }, ctx);
|
|
616
|
+
await commands.get("undo")!.handler("", ctx);
|
|
617
|
+
expect(notify).toHaveBeenCalledWith(expect.stringContaining("1 bash command(s) not tracked"), "warning");
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
it("ignores tool calls with unknown tool names", async () => {
|
|
621
|
+
const { ctx } = makeCtx({ branch: [userEntry("u35", "run")] });
|
|
622
|
+
await handlers.get("tool_call")!(
|
|
623
|
+
{ type: "tool_call", toolName: "read", toolCallId: "c1", input: { path: "a.ts" } },
|
|
624
|
+
ctx,
|
|
625
|
+
);
|
|
626
|
+
// No crash; nothing tracked.
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
it("extracts prompts with missing text and mimeType fields", async () => {
|
|
630
|
+
const dir = mkdtempSync(join(tmpdir(), "undo-prompt2-"));
|
|
631
|
+
const file = join(dir, "a.ts");
|
|
632
|
+
writeFileSync(file, "x", "utf-8");
|
|
633
|
+
const { ctx, setEditorText } = makeCtx({
|
|
634
|
+
cwd: dir,
|
|
635
|
+
branch: [userEntry("u36", [{ type: "text" }, { type: "image" }])],
|
|
636
|
+
});
|
|
637
|
+
await handlers.get("tool_call")!(
|
|
638
|
+
{ type: "tool_call", toolName: "edit", toolCallId: "c1", input: { path: "a.ts", edits: [{ oldText: "x", newText: "y" }] } },
|
|
639
|
+
ctx,
|
|
640
|
+
);
|
|
641
|
+
writeFileSync(file, "y", "utf-8");
|
|
642
|
+
await handlers.get("tool_result")!({ type: "tool_result", toolCallId: "c1", isError: false }, ctx);
|
|
643
|
+
await commands.get("undo")!.handler("", ctx);
|
|
644
|
+
expect(setEditorText).toHaveBeenCalledWith("[image ]");
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
it("handles user entries without a parentId", async () => {
|
|
648
|
+
const dir = mkdtempSync(join(tmpdir(), "undo-noparent-"));
|
|
649
|
+
const file = join(dir, "a.ts");
|
|
650
|
+
writeFileSync(file, "x", "utf-8");
|
|
651
|
+
const { ctx, notify } = makeCtx({
|
|
652
|
+
cwd: dir,
|
|
653
|
+
branch: [{ id: "u37", type: "message", message: { role: "user", content: "edit" }, parentId: "root" }],
|
|
654
|
+
});
|
|
655
|
+
await handlers.get("tool_call")!(
|
|
656
|
+
{ type: "tool_call", toolName: "edit", toolCallId: "c1", input: { path: "a.ts", edits: [{ oldText: "x", newText: "y" }] } },
|
|
657
|
+
ctx,
|
|
658
|
+
);
|
|
659
|
+
writeFileSync(file, "y", "utf-8");
|
|
660
|
+
await handlers.get("tool_result")!({ type: "tool_result", toolCallId: "c1", isError: false }, ctx);
|
|
661
|
+
await commands.get("undo")!.handler("", ctx);
|
|
662
|
+
expect(notify).toHaveBeenCalledWith(expect.stringContaining("restored 1 file op(s)."), "info");
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
it("skips the rm when a written file was already deleted", async () => {
|
|
666
|
+
const dir = mkdtempSync(join(tmpdir(), "undo-already-gone-"));
|
|
667
|
+
const file = join(dir, "a.ts");
|
|
668
|
+
const { ctx, notify } = makeCtx({ cwd: dir, branch: [userEntry("u38", "write")] });
|
|
669
|
+
|
|
670
|
+
await handlers.get("tool_call")!(
|
|
671
|
+
{ type: "tool_call", toolName: "write", toolCallId: "c1", input: { path: "a.ts", content: "content" } },
|
|
672
|
+
ctx,
|
|
673
|
+
);
|
|
674
|
+
writeFileSync(file, "content", "utf-8");
|
|
675
|
+
await handlers.get("tool_result")!({ type: "tool_result", toolCallId: "c1", isError: false }, ctx);
|
|
676
|
+
|
|
677
|
+
// Delete the file before undo → hash check fails without --force.
|
|
678
|
+
rmSync(file, { force: true });
|
|
679
|
+
await commands.get("undo")!.handler("--force", ctx);
|
|
680
|
+
expect(notify).toHaveBeenCalledWith(expect.stringContaining("restored 1 file op(s)."), "info");
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
it("undo-status marks only the current user's checkpoint", async () => {
|
|
684
|
+
const dir = mkdtempSync(join(tmpdir(), "undo-status2-"));
|
|
685
|
+
const file = join(dir, "a.ts");
|
|
686
|
+
writeFileSync(file, "x", "utf-8");
|
|
687
|
+
|
|
688
|
+
// Track a checkpoint while u39 is the current entry.
|
|
689
|
+
const { ctx: ctx1 } = makeCtx({ cwd: dir, branch: [userEntry("u39", "first")] });
|
|
690
|
+
await handlers.get("tool_call")!(
|
|
691
|
+
{ type: "tool_call", toolName: "edit", toolCallId: "c1", input: { path: "a.ts", edits: [{ oldText: "x", newText: "y" }] } },
|
|
692
|
+
ctx1,
|
|
693
|
+
);
|
|
694
|
+
writeFileSync(file, "y", "utf-8");
|
|
695
|
+
await handlers.get("tool_result")!({ type: "tool_result", toolCallId: "c1", isError: false }, ctx1);
|
|
696
|
+
|
|
697
|
+
// Now u40 is the current entry; u39's checkpoint has no star.
|
|
698
|
+
const { ctx: ctx2, notify } = makeCtx({ cwd: dir, branch: [userEntry("u39", "first"), userEntry("u40", "second")] });
|
|
699
|
+
await commands.get("undo-status")!.handler("", ctx2);
|
|
700
|
+
expect(notify).toHaveBeenCalledWith(expect.stringContaining("u39: 1 op(s), 0 bash, 0 skipped"), "info");
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
it("handles user entries with an undefined parentId", async () => {
|
|
704
|
+
const dir = mkdtempSync(join(tmpdir(), "undo-undefined-parent-"));
|
|
705
|
+
const file = join(dir, "a.ts");
|
|
706
|
+
writeFileSync(file, "x", "utf-8");
|
|
707
|
+
const { ctx, notify } = makeCtx({
|
|
708
|
+
cwd: dir,
|
|
709
|
+
branch: [{ id: "u41", type: "message", message: { role: "user", content: "edit" }, parentId: undefined }],
|
|
710
|
+
});
|
|
711
|
+
await handlers.get("tool_call")!(
|
|
712
|
+
{ type: "tool_call", toolName: "edit", toolCallId: "c1", input: { path: "a.ts", edits: [{ oldText: "x", newText: "y" }] } },
|
|
713
|
+
ctx,
|
|
714
|
+
);
|
|
715
|
+
writeFileSync(file, "y", "utf-8");
|
|
716
|
+
await handlers.get("tool_result")!({ type: "tool_result", toolCallId: "c1", isError: false }, ctx);
|
|
717
|
+
await commands.get("undo")!.handler("", ctx);
|
|
718
|
+
expect(notify).toHaveBeenCalledWith(expect.stringContaining("Cannot undo root prompt"), "warning");
|
|
719
|
+
});
|
|
720
|
+
});
|