@cueloop/daemon 0.1.0-alpha.9 → 0.1.0-alpha.91
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/package.json +39 -22
- package/src/api.test.ts +1060 -65
- package/src/api.ts +1398 -89
- package/src/capabilities.test.ts +57 -0
- package/src/capabilities.ts +89 -0
- package/src/client.test.ts +24 -0
- package/src/client.ts +669 -86
- package/src/curate.ts +109 -0
- package/src/daemon-version.test.ts +142 -0
- package/src/diff-hot-reload.test.ts +419 -0
- package/src/diff-watcher.ts +293 -0
- package/src/gh.test.ts +59 -0
- package/src/gh.ts +74 -0
- package/src/ghostty-thread-surface-store.test.ts +49 -0
- package/src/ghostty-thread-surface-store.ts +101 -0
- package/src/ghostty-thread-surface.test.ts +398 -0
- package/src/ghostty-thread-surface.ts +267 -0
- package/src/harness-delivery.test.ts +268 -0
- package/src/harness-state-store.test.ts +93 -0
- package/src/harness-state-store.ts +213 -0
- package/src/herdr-split.test.ts +106 -0
- package/src/herdr-split.ts +92 -0
- package/src/herdr-thread-surface-store.test.ts +81 -0
- package/src/herdr-thread-surface-store.ts +64 -0
- package/src/herdr-thread-surface.test.ts +411 -0
- package/src/herdr-thread-surface.ts +355 -0
- package/src/index.ts +8 -9
- package/src/interruptible-wait.test.ts +127 -0
- package/src/interruptible-wait.ts +66 -0
- package/src/jj-working-tree.ts +131 -0
- package/src/main.ts +4 -1
- package/src/paths.ts +49 -0
- package/src/pr-poller.test.ts +102 -0
- package/src/pr-poller.ts +73 -0
- package/src/project-files.test.ts +124 -0
- package/src/project-files.ts +56 -0
- package/src/protocol.ts +79 -7
- package/src/retention.test.ts +191 -0
- package/src/retention.ts +123 -0
- package/src/server.test.ts +453 -42
- package/src/server.ts +433 -88
- package/src/share-blob.test.ts +112 -0
- package/src/share-blob.ts +55 -0
- package/src/single-instance.test.ts +162 -13
- package/src/store.conformance.test.ts +41 -0
- package/src/store.jsonl.test.ts +159 -0
- package/src/store.ts +264 -34
- package/src/testing/store-conformance.ts +194 -0
- package/src/thread-review.test.ts +483 -0
- package/src/thread-review.ts +398 -0
- package/src/thread-surface-config.test.ts +63 -0
- package/src/thread-surface-config.ts +61 -0
- package/src/validate.test.ts +282 -37
- package/src/validate.ts +398 -33
- package/src/vcs-source.test.ts +180 -0
- package/src/vcs-source.ts +228 -0
- package/src/version.ts +6 -0
- package/src/working-tree.ts +149 -0
- package/src/review.test.ts +0 -167
- package/src/review.ts +0 -174
package/src/api.test.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import { mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
import { DaemonCore,
|
|
6
|
-
import {
|
|
7
|
-
import type
|
|
5
|
+
import { DaemonCore, messageResponse } from "./api";
|
|
6
|
+
import { ThreadStore } from "./store";
|
|
7
|
+
import { derivePath, tipOf, type Artifact, type WorkspaceKey } from "@cueloop/schema";
|
|
8
|
+
import { MAX_BLOB_BYTES, packSessionBlob, unpackSessionBlob } from "./share-blob";
|
|
8
9
|
|
|
9
10
|
const WS: WorkspaceKey = { repoRoot: "/repo", branch: "main" };
|
|
10
11
|
const PLAN: Artifact = {
|
|
@@ -23,121 +24,543 @@ beforeEach(() => {
|
|
|
23
24
|
afterEach(() => rmSync(home, { recursive: true, force: true }));
|
|
24
25
|
|
|
25
26
|
describe("session lifecycle", () => {
|
|
27
|
+
test("startup retention clears native surface handles for expired Threads", () => {
|
|
28
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
29
|
+
|
|
30
|
+
core.store.upsert({ ...session, status: "resolved", createdAt: "2020-01-01T00:00:00Z" });
|
|
31
|
+
core.herdrSetThreadSurface(session.id, { tabId: "tab-old", paneId: "pane-old" });
|
|
32
|
+
core.ghosttySetThreadSurface(session.id, { terminalId: "terminal-old" });
|
|
33
|
+
|
|
34
|
+
const restarted = new DaemonCore(home);
|
|
35
|
+
|
|
36
|
+
expect(restarted.store.get(session.id)).toBeUndefined();
|
|
37
|
+
expect(restarted.herdrGetThreadSurface(session.id)).toBeNull();
|
|
38
|
+
expect(restarted.ghosttyGetThreadSurface(session.id)).toBeNull();
|
|
39
|
+
restarted.dispose();
|
|
40
|
+
});
|
|
41
|
+
|
|
26
42
|
test("create → get → list", () => {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
43
|
+
// Act
|
|
44
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
45
|
+
|
|
46
|
+
// Assert
|
|
47
|
+
expect(session.status).toBe("pending");
|
|
48
|
+
expect(session.revisions.length).toBe(1);
|
|
49
|
+
expect(core.sessionGet(session.id).id).toBe(session.id);
|
|
31
50
|
expect(core.sessionList({ status: "pending" }).length).toBe(1);
|
|
32
51
|
});
|
|
33
52
|
|
|
34
53
|
test("annotate upserts by id; remove deletes", () => {
|
|
35
|
-
|
|
36
|
-
core.
|
|
54
|
+
// Arrange
|
|
55
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
56
|
+
|
|
57
|
+
// Act
|
|
58
|
+
core.sessionAnnotate(session.id, {
|
|
37
59
|
id: "a1",
|
|
38
60
|
kind: "comment",
|
|
39
61
|
anchor: { quote: "carefully", prefix: "the thing ", suffix: "." },
|
|
40
62
|
body: "How carefully?",
|
|
41
63
|
});
|
|
42
|
-
core.sessionAnnotate(
|
|
64
|
+
core.sessionAnnotate(session.id, {
|
|
43
65
|
id: "a1",
|
|
44
66
|
kind: "comment",
|
|
45
67
|
anchor: { quote: "carefully", prefix: "the thing ", suffix: "." },
|
|
46
68
|
body: "Define carefully.",
|
|
47
69
|
});
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
core.
|
|
51
|
-
expect(core.sessionGet(
|
|
70
|
+
|
|
71
|
+
// Assert
|
|
72
|
+
expect(core.sessionGet(session.id).annotations.length).toBe(1);
|
|
73
|
+
expect(core.sessionGet(session.id).annotations[0]!.body).toBe("Define carefully.");
|
|
74
|
+
|
|
75
|
+
// Act
|
|
76
|
+
core.sessionRemoveAnnotation(session.id, "a1");
|
|
77
|
+
|
|
78
|
+
// Assert
|
|
79
|
+
expect(core.sessionGet(session.id).annotations.length).toBe(0);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("a welcome-playground note is ephemeral: never persisted", () => {
|
|
83
|
+
// Arrange
|
|
84
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
85
|
+
|
|
86
|
+
// Act
|
|
87
|
+
core.sessionAnnotate(session.id, {
|
|
88
|
+
id: "w1",
|
|
89
|
+
kind: "comment",
|
|
90
|
+
anchor: { quote: "start", prefix: "", suffix: "" },
|
|
91
|
+
target: { kind: "welcome" },
|
|
92
|
+
body: "just trying it",
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Assert - the onboarding playground stores nothing
|
|
96
|
+
expect(core.sessionGet(session.id).annotations.length).toBe(0);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("annotate with an author registers them in the participant registry", () => {
|
|
100
|
+
// Arrange
|
|
101
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
102
|
+
|
|
103
|
+
// Act
|
|
104
|
+
core.sessionAnnotate(
|
|
105
|
+
session.id,
|
|
106
|
+
{
|
|
107
|
+
id: "a1",
|
|
108
|
+
kind: "comment",
|
|
109
|
+
anchor: { quote: "carefully", prefix: "the thing ", suffix: "." },
|
|
110
|
+
body: "A collaborator's note.",
|
|
111
|
+
author: "SHA256:ana",
|
|
112
|
+
},
|
|
113
|
+
"Ana",
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
// Assert
|
|
117
|
+
const stored = core.sessionGet(session.id);
|
|
118
|
+
|
|
119
|
+
expect(stored.annotations[0]!.author).toBe("SHA256:ana");
|
|
120
|
+
expect(stored.participants).toEqual([{ id: "SHA256:ana", provider: "ssh", name: "Ana" }]);
|
|
52
121
|
});
|
|
53
122
|
|
|
54
123
|
test("resolve produces feedback.md and maps to the agent contract", () => {
|
|
55
|
-
|
|
56
|
-
core.
|
|
124
|
+
// Arrange
|
|
125
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
126
|
+
|
|
127
|
+
core.sessionAnnotate(session.id, {
|
|
57
128
|
id: "a1",
|
|
58
129
|
kind: "comment",
|
|
59
130
|
anchor: { quote: "carefully", prefix: "the thing ", suffix: "." },
|
|
60
131
|
body: "Spell out the steps.",
|
|
61
132
|
});
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
133
|
+
|
|
134
|
+
// Act
|
|
135
|
+
const resolved = core.sessionSendMessage(session.id, "changes_requested", "Needs detail.");
|
|
136
|
+
|
|
137
|
+
// Assert
|
|
138
|
+
expect(resolved.message!.body).toContain("# Review: changes requested");
|
|
139
|
+
expect(resolved.message!.body).toContain("> carefully");
|
|
140
|
+
expect(messageResponse(resolved)).toEqual({
|
|
141
|
+
allow: false,
|
|
142
|
+
message: resolved.message!,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// Arrange
|
|
66
146
|
const approved = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
67
|
-
|
|
68
|
-
|
|
147
|
+
|
|
148
|
+
// Act
|
|
149
|
+
core.sessionSendMessage(approved.id, "approved", "LGTM");
|
|
150
|
+
|
|
151
|
+
// Assert
|
|
152
|
+
expect(messageResponse(core.sessionGet(approved.id)).allow).toBe(true);
|
|
69
153
|
});
|
|
70
154
|
|
|
71
155
|
test("mutating a resolved session throws", () => {
|
|
72
|
-
|
|
73
|
-
core.
|
|
156
|
+
// Arrange
|
|
157
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
158
|
+
|
|
159
|
+
core.sessionSendMessage(session.id, "approved", "");
|
|
160
|
+
|
|
161
|
+
// Assert
|
|
74
162
|
expect(() =>
|
|
75
|
-
core.sessionAnnotate(
|
|
163
|
+
core.sessionAnnotate(session.id, {
|
|
164
|
+
id: "a1",
|
|
165
|
+
kind: "comment",
|
|
166
|
+
anchor: { quote: "x", prefix: "", suffix: "" },
|
|
167
|
+
body: "b",
|
|
168
|
+
}),
|
|
76
169
|
).toThrow("already resolved");
|
|
77
170
|
});
|
|
78
171
|
|
|
172
|
+
test("Comment delivers only changed annotations and keeps the Thread pending", () => {
|
|
173
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
174
|
+
|
|
175
|
+
core.sessionAnnotate(session.id, {
|
|
176
|
+
id: "first",
|
|
177
|
+
kind: "comment",
|
|
178
|
+
anchor: { quote: "carefully", prefix: "the thing ", suffix: "." },
|
|
179
|
+
body: "first note",
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const commented = core.sessionSendMessage(session.id, "comment", "Interim feedback");
|
|
183
|
+
|
|
184
|
+
expect(commented.status).toBe("pending");
|
|
185
|
+
expect(commented.message!.annotations?.map((annotation) => annotation.id)).toEqual(["first"]);
|
|
186
|
+
expect(commented.message!.body).toContain("first note");
|
|
187
|
+
|
|
188
|
+
core.sessionAnnotate(session.id, {
|
|
189
|
+
id: "second",
|
|
190
|
+
kind: "comment",
|
|
191
|
+
anchor: { quote: "Context", prefix: "Plan", suffix: "Do" },
|
|
192
|
+
body: "second note",
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const secondComment = core.sessionSendMessage(session.id, "comment", "Another note");
|
|
196
|
+
|
|
197
|
+
expect(secondComment.status).toBe("pending");
|
|
198
|
+
expect(secondComment.message!.annotations?.map((annotation) => annotation.id)).toEqual([
|
|
199
|
+
"second",
|
|
200
|
+
]);
|
|
201
|
+
expect(secondComment.message!.body).not.toContain("first note");
|
|
202
|
+
expect(secondComment.message!.body).toContain("second note");
|
|
203
|
+
|
|
204
|
+
core.sessionAnnotate(session.id, {
|
|
205
|
+
id: "first",
|
|
206
|
+
kind: "comment",
|
|
207
|
+
anchor: { quote: "carefully", prefix: "the thing ", suffix: "." },
|
|
208
|
+
body: "edited first note",
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
const resolved = core.sessionSendMessage(session.id, "approved", "Ready");
|
|
212
|
+
|
|
213
|
+
expect(resolved.status).toBe("resolved");
|
|
214
|
+
expect(resolved.message!.annotations?.map((annotation) => annotation.id)).toEqual(["first"]);
|
|
215
|
+
expect(resolved.message!.body).toContain("edited first note");
|
|
216
|
+
expect(resolved.message!.body).not.toContain("second note");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("agent-side resolution does not redeliver a sent annotation", () => {
|
|
220
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
221
|
+
|
|
222
|
+
core.sessionAnnotate(session.id, {
|
|
223
|
+
id: "sent",
|
|
224
|
+
kind: "comment",
|
|
225
|
+
anchor: { quote: "carefully", prefix: "the thing ", suffix: "." },
|
|
226
|
+
body: "sent note",
|
|
227
|
+
});
|
|
228
|
+
core.sessionSendMessage(session.id, "comment", "Interim feedback");
|
|
229
|
+
core.sessionSubmitRevision(session.id, PLAN.content, ["sent"]);
|
|
230
|
+
|
|
231
|
+
const resolved = core.sessionSendMessage(session.id, "approved", "Ready");
|
|
232
|
+
|
|
233
|
+
expect(resolved.message!.annotations).toEqual([]);
|
|
234
|
+
expect(resolved.message!.body).not.toContain("sent note");
|
|
235
|
+
});
|
|
236
|
+
|
|
79
237
|
test("working copy stores edits and clears on revert or no-op", () => {
|
|
80
|
-
|
|
81
|
-
core.
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
core.
|
|
91
|
-
|
|
238
|
+
// Arrange
|
|
239
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
240
|
+
|
|
241
|
+
// Act
|
|
242
|
+
core.sessionSetWorkingCopy(session.id, PLAN.content.replace("carefully", "very carefully"));
|
|
243
|
+
|
|
244
|
+
// Assert
|
|
245
|
+
expect(core.sessionGet(session.id).workingCopy).toContain("very carefully");
|
|
246
|
+
|
|
247
|
+
// Act
|
|
248
|
+
core.sessionSetWorkingCopy(session.id, PLAN.content);
|
|
249
|
+
|
|
250
|
+
// Assert
|
|
251
|
+
expect(core.sessionGet(session.id).workingCopy).toBeUndefined();
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("character Cut provenance survives persistence and clears on an arbitrary edit", () => {
|
|
255
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
256
|
+
const start = PLAN.content.indexOf("carefully");
|
|
257
|
+
const end = start + "carefully".length;
|
|
258
|
+
const workingCopy = PLAN.content.slice(0, start) + PLAN.content.slice(end);
|
|
259
|
+
const cut = { start, end, quote: "carefully" };
|
|
260
|
+
|
|
261
|
+
core.sessionSetWorkingCopy(session.id, workingCopy, [cut]);
|
|
262
|
+
|
|
263
|
+
expect(core.sessionGet(session.id).textCuts).toEqual([cut]);
|
|
264
|
+
expect(new DaemonCore(home).sessionGet(session.id).textCuts).toEqual([cut]);
|
|
265
|
+
const cutTip = core.sessionGet(session.id).history!.tips.main!;
|
|
266
|
+
const root = core.sessionGet(session.id).history!.entries[0]!.id;
|
|
267
|
+
|
|
268
|
+
core.sessionBranch(session.id, "with-cut");
|
|
269
|
+
core.sessionNavigate(session.id, root, undefined, "main");
|
|
270
|
+
expect(core.sessionGet(session.id).textCuts).toBeUndefined();
|
|
271
|
+
core.sessionSwitch(session.id, "with-cut");
|
|
272
|
+
expect(core.sessionGet(session.id).history!.tips["with-cut"]).toBe(cutTip);
|
|
273
|
+
expect(core.sessionGet(session.id).textCuts).toEqual([cut]);
|
|
274
|
+
core.sessionSetWorkingCopy(session.id, PLAN.content.replace("carefully", "safely"));
|
|
275
|
+
expect(core.sessionGet(session.id).textCuts).toBeUndefined();
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("rejects character Cut provenance that does not match the artifact", () => {
|
|
279
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
280
|
+
|
|
281
|
+
for (const textCuts of [
|
|
282
|
+
[{ start: 0, end: 5, quote: "stale" }],
|
|
283
|
+
[{ start: PLAN.content.length - 1, end: PLAN.content.length + 1, quote: "xx" }],
|
|
284
|
+
]) {
|
|
285
|
+
expect(() => core.sessionSetWorkingCopy(session.id, "remaining", textCuts)).toThrow(
|
|
286
|
+
"text Cuts do not match the submitted artifact and working copy",
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
expect(core.sessionGet(session.id).workingCopy).toBeUndefined();
|
|
290
|
+
expect(core.sessionGet(session.id).textCuts).toBeUndefined();
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("rejects character Cut provenance whose result differs from the working copy", () => {
|
|
294
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
295
|
+
const start = PLAN.content.indexOf("carefully");
|
|
296
|
+
const textCuts = [{ start, end: start + "carefully".length, quote: "carefully" }];
|
|
297
|
+
|
|
298
|
+
expect(() => core.sessionSetWorkingCopy(session.id, "different", textCuts)).toThrow(
|
|
299
|
+
"text Cuts do not match the submitted artifact and working copy",
|
|
300
|
+
);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test("a thread rename sets the title, clears back to the default on empty, and survives a restart", () => {
|
|
304
|
+
// Arrange
|
|
305
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
306
|
+
|
|
307
|
+
// Act
|
|
308
|
+
core.sessionSetTitle(session.id, " Renamed thread ");
|
|
309
|
+
|
|
310
|
+
// Assert - trimmed and persisted
|
|
311
|
+
expect(core.sessionGet(session.id).artifact.meta.title).toBe("Renamed thread");
|
|
312
|
+
expect(new DaemonCore(home).sessionGet(session.id).artifact.meta.title).toBe("Renamed thread");
|
|
313
|
+
|
|
314
|
+
// Act - an empty title clears back to the derived default
|
|
315
|
+
core.sessionSetTitle(session.id, " ");
|
|
316
|
+
|
|
317
|
+
// Assert
|
|
318
|
+
expect(core.sessionGet(session.id).artifact.meta.title).toBeUndefined();
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test("viewed paths merge, dedupe, clear on empty, and survive a daemon restart", () => {
|
|
322
|
+
// Arrange
|
|
323
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
324
|
+
|
|
325
|
+
// Act
|
|
326
|
+
core.sessionSetViewed(session.id, ["src/a.ts", "src/b.ts", "src/a.ts"]);
|
|
327
|
+
|
|
328
|
+
// Assert
|
|
329
|
+
expect(core.sessionGet(session.id).viewedPaths).toEqual(["src/a.ts", "src/b.ts"]);
|
|
330
|
+
|
|
331
|
+
// Act
|
|
332
|
+
// merge-additive: a stale client sending only its own new mark loses nothing
|
|
333
|
+
core.sessionSetViewed(session.id, ["src/c.ts"]);
|
|
334
|
+
|
|
335
|
+
// Assert
|
|
336
|
+
expect(core.sessionGet(session.id).viewedPaths).toEqual(["src/a.ts", "src/b.ts", "src/c.ts"]);
|
|
337
|
+
// a resumed review reads its progress back after a daemon restart
|
|
338
|
+
const reborn = new DaemonCore(home);
|
|
339
|
+
|
|
340
|
+
expect(reborn.sessionGet(session.id).viewedPaths).toEqual(["src/a.ts", "src/b.ts", "src/c.ts"]);
|
|
341
|
+
|
|
342
|
+
// Act
|
|
343
|
+
core.sessionSetViewed(session.id, []);
|
|
344
|
+
|
|
345
|
+
// Assert
|
|
346
|
+
expect(core.sessionGet(session.id).viewedPaths).toBeUndefined();
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test("revision reopens the session and resets working copy + message", () => {
|
|
350
|
+
// Arrange
|
|
351
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
352
|
+
const start = PLAN.content.indexOf("carefully");
|
|
353
|
+
const end = start + "carefully".length;
|
|
354
|
+
const cut = { start, end, quote: "carefully" };
|
|
355
|
+
|
|
356
|
+
core.sessionSetWorkingCopy(session.id, PLAN.content.slice(0, start) + PLAN.content.slice(end), [
|
|
357
|
+
cut,
|
|
358
|
+
]);
|
|
359
|
+
core.sessionSendMessage(session.id, "changes_requested", "redo");
|
|
360
|
+
|
|
361
|
+
// Act
|
|
362
|
+
const revised = core.sessionSubmitRevision(session.id, "# Plan v2\n");
|
|
363
|
+
|
|
364
|
+
// Assert
|
|
92
365
|
expect(revised.status).toBe("pending");
|
|
93
|
-
expect(revised.
|
|
366
|
+
expect(revised.message).toBeNull();
|
|
94
367
|
expect(revised.workingCopy).toBeUndefined();
|
|
368
|
+
expect(revised.textCuts).toBeUndefined();
|
|
95
369
|
expect(revised.revisions.length).toBe(2);
|
|
96
370
|
expect(revised.artifact.content).toBe("# Plan v2\n");
|
|
97
371
|
});
|
|
98
372
|
});
|
|
99
373
|
|
|
100
|
-
describe("
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
374
|
+
describe("revision marks addressed annotations", () => {
|
|
375
|
+
const annotate = (sessionId: string, id: string, quote: string, body = "note") =>
|
|
376
|
+
core.sessionAnnotate(sessionId, {
|
|
377
|
+
id,
|
|
378
|
+
kind: "comment",
|
|
379
|
+
anchor: { quote, prefix: "", suffix: "" },
|
|
380
|
+
body,
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
test("ids the agent reports are marked addressed by that revision", () => {
|
|
384
|
+
// Arrange
|
|
385
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
386
|
+
|
|
387
|
+
annotate(session.id, "a1", "carefully");
|
|
388
|
+
annotate(session.id, "a2", "Context");
|
|
389
|
+
|
|
390
|
+
// Act: the revision keeps both quoted texts, so only the reported id resolves
|
|
391
|
+
const revised = core.sessionSubmitRevision(session.id, PLAN.content + "\nMore.\n", ["a1"]);
|
|
392
|
+
|
|
393
|
+
// Assert
|
|
394
|
+
const [first, second] = revised.annotations;
|
|
395
|
+
|
|
396
|
+
expect(first!.resolution).toEqual({ revision: 2, source: "agent" });
|
|
397
|
+
expect(second!.resolution).toBeUndefined();
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test("a reported root comment addresses its replies as well", () => {
|
|
401
|
+
// Arrange: a discussion of a root and one reply, plus an unrelated comment
|
|
402
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
403
|
+
|
|
404
|
+
annotate(session.id, "root", "carefully");
|
|
405
|
+
core.sessionAnnotate(session.id, {
|
|
406
|
+
id: "reply",
|
|
407
|
+
kind: "comment",
|
|
408
|
+
anchor: { quote: "carefully", prefix: "", suffix: "" },
|
|
409
|
+
body: "agreed",
|
|
410
|
+
replyTo: "root",
|
|
411
|
+
});
|
|
412
|
+
annotate(session.id, "other", "Context");
|
|
413
|
+
|
|
414
|
+
// Act: the agent reports only the root
|
|
415
|
+
const revised = core.sessionSubmitRevision(session.id, PLAN.content + "\nMore.\n", ["root"]);
|
|
416
|
+
const byId = new Map(revised.annotations.map((annotation) => [annotation.id, annotation]));
|
|
417
|
+
|
|
418
|
+
// Assert
|
|
419
|
+
expect(byId.get("root")!.resolution).toEqual({ revision: 2, source: "agent" });
|
|
420
|
+
expect(byId.get("reply")!.resolution).toEqual({ revision: 2, source: "agent" });
|
|
421
|
+
expect(byId.get("other")!.resolution).toBeUndefined();
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
test("a plan annotation whose quoted text vanished is drift-addressed; a surviving quote stays open", () => {
|
|
425
|
+
// Arrange
|
|
426
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
427
|
+
|
|
428
|
+
annotate(session.id, "gone", "carefully");
|
|
429
|
+
annotate(session.id, "kept", "Context");
|
|
430
|
+
|
|
431
|
+
// Act: the revision rewrites the "carefully" sentence but keeps the Context heading
|
|
432
|
+
const revised = core.sessionSubmitRevision(
|
|
433
|
+
session.id,
|
|
434
|
+
"# Plan\n\n## Context\n\nDo the thing with tests.\n",
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
// Assert
|
|
438
|
+
expect(revised.annotations.find((a) => a.id === "gone")!.resolution).toEqual({
|
|
439
|
+
revision: 2,
|
|
440
|
+
source: "drift",
|
|
441
|
+
});
|
|
442
|
+
expect(revised.annotations.find((a) => a.id === "kept")!.resolution).toBeUndefined();
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
test("an unknown reported id is ignored, and an already-addressed annotation keeps its first resolution", () => {
|
|
446
|
+
// Arrange
|
|
447
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
448
|
+
|
|
449
|
+
annotate(session.id, "a1", "carefully");
|
|
450
|
+
core.sessionSubmitRevision(session.id, PLAN.content, ["a1"]);
|
|
451
|
+
|
|
452
|
+
// Act: a second revision reports a stale id and re-reports the settled one
|
|
453
|
+
const revised = core.sessionSubmitRevision(session.id, PLAN.content, ["a1", "a_never_existed"]);
|
|
454
|
+
|
|
455
|
+
// Assert
|
|
456
|
+
expect(revised.annotations[0]!.resolution).toEqual({ revision: 2, source: "agent" });
|
|
457
|
+
expect(revised.revisions.length).toBe(3);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
test("a diff revision never drift-addresses - a new patch says nothing about the feedback", () => {
|
|
461
|
+
// Arrange
|
|
462
|
+
const diffArtifact: Artifact = { type: "diff", content: "+++ b/a.ts\n+new line\n", meta: {} };
|
|
463
|
+
const session = core.sessionCreate({ workspace: WS, artifact: diffArtifact });
|
|
464
|
+
|
|
465
|
+
annotate(session.id, "d1", "new line");
|
|
466
|
+
|
|
467
|
+
// Act: the revised patch no longer contains the quoted text
|
|
468
|
+
const revised = core.sessionSubmitRevision(session.id, "+++ b/b.ts\n+other\n");
|
|
469
|
+
|
|
470
|
+
// Assert
|
|
471
|
+
expect(revised.annotations[0]!.resolution).toBeUndefined();
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
test("the next feedback document omits addressed annotations and teaches the addressed-ids call", () => {
|
|
475
|
+
// Arrange
|
|
476
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
477
|
+
|
|
478
|
+
annotate(session.id, "settled", "carefully");
|
|
479
|
+
annotate(session.id, "open", "Context", "needs a diagram");
|
|
480
|
+
core.sessionSubmitRevision(session.id, PLAN.content, ["settled"]);
|
|
481
|
+
|
|
482
|
+
// Act
|
|
483
|
+
const resolved = core.sessionSendMessage(session.id, "changes_requested", "one left");
|
|
484
|
+
|
|
485
|
+
// Assert
|
|
486
|
+
expect(resolved.message!.body).not.toContain("settled");
|
|
487
|
+
expect(resolved.message!.body).toContain("annotation id: `open`");
|
|
488
|
+
expect(resolved.message!.body).toContain(`submit-revision ${session.id}`);
|
|
489
|
+
expect(resolved.message!.body).toContain("--addressed");
|
|
490
|
+
});
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
describe("the wait contract: messages outlive waits", () => {
|
|
494
|
+
test("wait resolves when the message arrives", async () => {
|
|
495
|
+
// Arrange
|
|
496
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
497
|
+
const wait = core.sessionWait(session.id, 5_000);
|
|
498
|
+
|
|
499
|
+
// Act
|
|
500
|
+
core.sessionSendMessage(session.id, "approved", "");
|
|
105
501
|
const resolved = await wait;
|
|
106
|
-
|
|
502
|
+
|
|
503
|
+
// Assert
|
|
504
|
+
expect(resolved!.message!.outcome).toBe("approved");
|
|
107
505
|
});
|
|
108
506
|
|
|
109
|
-
test("wait times out to null; a later wait collects the stored
|
|
110
|
-
|
|
111
|
-
const
|
|
507
|
+
test("wait times out to null; a later wait collects the stored message", async () => {
|
|
508
|
+
// Arrange
|
|
509
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
510
|
+
|
|
511
|
+
// Act
|
|
512
|
+
const first = await core.sessionWait(session.id, 30);
|
|
513
|
+
|
|
514
|
+
// Assert
|
|
112
515
|
expect(first).toBeNull();
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
516
|
+
|
|
517
|
+
// Act
|
|
518
|
+
core.sessionSendMessage(session.id, "changes_requested", "later");
|
|
519
|
+
const second = await core.sessionWait(session.id, 30);
|
|
520
|
+
|
|
521
|
+
// Assert
|
|
522
|
+
expect(second!.message!.summary).toBe("later");
|
|
116
523
|
});
|
|
117
524
|
|
|
118
525
|
test("multiple waiters all resolve", async () => {
|
|
119
|
-
|
|
120
|
-
const
|
|
121
|
-
core.
|
|
526
|
+
// Arrange
|
|
527
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
528
|
+
const waits = [core.sessionWait(session.id, 5_000), core.sessionWait(session.id, 5_000)];
|
|
529
|
+
|
|
530
|
+
// Act
|
|
531
|
+
core.sessionSendMessage(session.id, "approved", "");
|
|
122
532
|
const results = await Promise.all(waits);
|
|
123
|
-
|
|
533
|
+
|
|
534
|
+
// Assert
|
|
535
|
+
expect(results.every((result) => result?.status === "resolved")).toBe(true);
|
|
124
536
|
});
|
|
125
537
|
});
|
|
126
538
|
|
|
127
539
|
describe("persistence and recovery", () => {
|
|
128
540
|
test("sessions survive a daemon restart", () => {
|
|
129
|
-
|
|
130
|
-
core.
|
|
541
|
+
// Arrange
|
|
542
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
543
|
+
|
|
544
|
+
core.sessionSendMessage(session.id, "approved", "done");
|
|
545
|
+
|
|
546
|
+
// Act
|
|
131
547
|
const reborn = new DaemonCore(home);
|
|
132
|
-
const recovered = reborn.sessionGet(
|
|
133
|
-
|
|
548
|
+
const recovered = reborn.sessionGet(session.id);
|
|
549
|
+
|
|
550
|
+
// Assert
|
|
551
|
+
expect(recovered.message!.summary).toBe("done");
|
|
134
552
|
});
|
|
135
553
|
|
|
136
554
|
test("corrupt records are skipped, not fatal, and never deleted", async () => {
|
|
555
|
+
// Arrange
|
|
137
556
|
core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
138
557
|
await Bun.write(join(home, "sessions", "broken.json"), "{ not json");
|
|
139
|
-
const store = new
|
|
558
|
+
const store = new ThreadStore(home);
|
|
559
|
+
|
|
560
|
+
// Act
|
|
140
561
|
const report = store.recover();
|
|
562
|
+
|
|
563
|
+
// Assert
|
|
141
564
|
expect(report.recovered.length).toBe(1);
|
|
142
565
|
expect(report.skipped.length).toBe(1);
|
|
143
566
|
expect(await Bun.file(join(home, "sessions", "broken.json")).exists()).toBe(true);
|
|
@@ -146,11 +569,583 @@ describe("persistence and recovery", () => {
|
|
|
146
569
|
|
|
147
570
|
describe("events", () => {
|
|
148
571
|
test("lifecycle events fire in order", () => {
|
|
572
|
+
// Arrange
|
|
149
573
|
const seen: string[] = [];
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
574
|
+
|
|
575
|
+
core.onEvent((event) => seen.push(event.event));
|
|
576
|
+
|
|
577
|
+
// Act
|
|
578
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
579
|
+
|
|
580
|
+
core.sessionAnnotate(session.id, {
|
|
581
|
+
id: "a1",
|
|
582
|
+
kind: "comment",
|
|
583
|
+
anchor: { quote: "thing", prefix: "", suffix: "" },
|
|
584
|
+
body: "b",
|
|
585
|
+
});
|
|
586
|
+
core.sessionSendMessage(session.id, "approved", "");
|
|
587
|
+
|
|
588
|
+
// Assert
|
|
589
|
+
expect(seen).toEqual([
|
|
590
|
+
"session.created",
|
|
591
|
+
"inbox.changed",
|
|
592
|
+
"session.updated",
|
|
593
|
+
"message.sent",
|
|
594
|
+
"inbox.changed",
|
|
595
|
+
]);
|
|
596
|
+
});
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
describe("the session history records every write as an entry", () => {
|
|
600
|
+
const annotate = (sessionId: string, id: string, quote: string, body = "note") =>
|
|
601
|
+
core.sessionAnnotate(sessionId, {
|
|
602
|
+
id,
|
|
603
|
+
kind: "comment",
|
|
604
|
+
anchor: { quote, prefix: "", suffix: "" },
|
|
605
|
+
body,
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
test("create, comment, remove, resolve, and revise append in order on main", () => {
|
|
609
|
+
// Arrange
|
|
610
|
+
const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
611
|
+
|
|
612
|
+
// Assert: a fresh session is one root revision
|
|
613
|
+
expect(created.history?.entries.map((entry) => entry.type)).toEqual(["revision"]);
|
|
614
|
+
expect(created.history?.tips.main).toBe(created.history?.entries[0]!.id);
|
|
615
|
+
|
|
616
|
+
// Act
|
|
617
|
+
annotate(created.id, "a1", "carefully");
|
|
618
|
+
annotate(created.id, "a1", "carefully", "edited body");
|
|
619
|
+
annotate(created.id, "a2", "Context");
|
|
620
|
+
core.sessionRemoveAnnotation(created.id, "a2");
|
|
621
|
+
core.sessionRemoveAnnotation(created.id, "never-existed");
|
|
622
|
+
core.sessionSendMessage(created.id, "changes_requested", "tighten");
|
|
623
|
+
const revised = core.sessionSubmitRevision(created.id, PLAN.content + "\nMore.\n", ["a1"]);
|
|
624
|
+
|
|
625
|
+
// Assert: an edit of an existing comment and a removal of an unknown id leave no entry
|
|
626
|
+
const history = revised.history!;
|
|
627
|
+
|
|
628
|
+
expect(history.entries.map((entry) => entry.type)).toEqual([
|
|
629
|
+
"revision",
|
|
630
|
+
"comment",
|
|
631
|
+
"comment",
|
|
632
|
+
"comment-removed",
|
|
633
|
+
"message",
|
|
634
|
+
"revision",
|
|
635
|
+
]);
|
|
636
|
+
expect(history.branch).toBe("main");
|
|
637
|
+
expect(history.tips.main).toBe(history.entries.at(-1)!.id);
|
|
638
|
+
// every entry chains on the one before
|
|
639
|
+
history.entries.forEach((entry, index) => {
|
|
640
|
+
expect(entry.parentId).toBe(index === 0 ? null : history.entries[index - 1]!.id);
|
|
641
|
+
});
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
test("the head of the current path is the artifact, and the open comments are the path's", () => {
|
|
645
|
+
// Arrange
|
|
646
|
+
const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
647
|
+
|
|
648
|
+
annotate(created.id, "a1", "carefully");
|
|
649
|
+
annotate(created.id, "a2", "Context");
|
|
650
|
+
core.sessionRemoveAnnotation(created.id, "a1");
|
|
651
|
+
const revised = core.sessionSubmitRevision(created.id, "# Plan v2\n");
|
|
652
|
+
|
|
653
|
+
// Act
|
|
654
|
+
const derived = derivePath(revised.history!);
|
|
655
|
+
|
|
656
|
+
// Assert
|
|
657
|
+
expect(derived.head.content).toBe(revised.artifact.content);
|
|
658
|
+
expect(derived.annotationIds).toEqual(["a2"]);
|
|
659
|
+
expect(derived.rounds).toBe(2);
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
test("a shared session's incoming comments become entries, and the blob round-trips with its history", () => {
|
|
663
|
+
// Arrange
|
|
664
|
+
const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
665
|
+
|
|
666
|
+
// Act
|
|
667
|
+
const merged = core.sessionMergeShared(created.id, {
|
|
668
|
+
annotations: [
|
|
669
|
+
{
|
|
670
|
+
id: "ana_1",
|
|
671
|
+
kind: "comment",
|
|
672
|
+
anchor: { quote: "Context", prefix: "", suffix: "" },
|
|
673
|
+
body: "from ana",
|
|
674
|
+
author: "SHA256:ana",
|
|
675
|
+
createdAt: "2026-09-01T10:00:00.000Z",
|
|
676
|
+
},
|
|
677
|
+
],
|
|
678
|
+
});
|
|
679
|
+
const roundTripped = unpackSessionBlob(packSessionBlob(merged));
|
|
680
|
+
|
|
681
|
+
// Assert
|
|
682
|
+
expect(merged.history?.entries.at(-1)).toMatchObject({
|
|
683
|
+
type: "comment",
|
|
684
|
+
annotationId: "ana_1",
|
|
685
|
+
});
|
|
686
|
+
expect(roundTripped.history).toEqual(merged.history);
|
|
687
|
+
expect(packSessionBlob(merged).byteLength).toBeLessThan(MAX_BLOB_BYTES);
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
test("a session written before histories existed gets one on the next boot", () => {
|
|
691
|
+
// Arrange: a record with no history on disk, as an earlier daemon left it
|
|
692
|
+
const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
693
|
+
const legacy = { ...created };
|
|
694
|
+
|
|
695
|
+
delete legacy.history;
|
|
696
|
+
new ThreadStore(home).upsert(legacy);
|
|
697
|
+
|
|
698
|
+
// Act
|
|
699
|
+
const rebooted = new DaemonCore(home);
|
|
700
|
+
const recovered = rebooted.sessionGet(created.id);
|
|
701
|
+
|
|
702
|
+
// Assert
|
|
703
|
+
expect(recovered.history?.entries.map((entry) => entry.type)).toEqual(["revision"]);
|
|
704
|
+
expect(derivePath(recovered.history!).head.content).toBe(PLAN.content);
|
|
705
|
+
});
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
describe("curation primitives", () => {
|
|
709
|
+
test("cutting a block records a reviewer revision; restoring it returns the copy to pristine", () => {
|
|
710
|
+
// Arrange: block 2 of the plan is the paragraph
|
|
711
|
+
const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
712
|
+
|
|
713
|
+
// Act
|
|
714
|
+
const cut = core.sessionCutBlock(created.id, 2);
|
|
715
|
+
|
|
716
|
+
// Assert: the working copy lost the paragraph and the history gained the edit
|
|
717
|
+
expect(cut.workingCopy).not.toContain("Do the thing carefully.");
|
|
718
|
+
expect(cut.history!.entries.at(-1)).toMatchObject({ type: "revision", by: "reviewer" });
|
|
719
|
+
expect(derivePath(cut.history!).head.content).toBe(cut.workingCopy!);
|
|
720
|
+
|
|
721
|
+
// Act: put it back where it was (block 2 of the submitted revision, before the end)
|
|
722
|
+
const restored = core.sessionRestoreBlock(created.id, 2);
|
|
723
|
+
|
|
724
|
+
// Assert: pristine again, and that edit is on record too
|
|
725
|
+
expect(restored.workingCopy).toBeUndefined();
|
|
726
|
+
expect(derivePath(restored.history!).head.content).toBe(PLAN.content);
|
|
727
|
+
expect(restored.history!.entries.filter((entry) => entry.type === "revision")).toHaveLength(3);
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
test("a block index outside the text is refused", () => {
|
|
731
|
+
// Arrange
|
|
732
|
+
const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
733
|
+
|
|
734
|
+
// Assert
|
|
735
|
+
expect(() => core.sessionCutBlock(created.id, 99)).toThrow(/no block 99/);
|
|
736
|
+
expect(() => core.sessionRestoreBlock(created.id, 99)).toThrow(/no block 99/);
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
test("restoring a block that is present is refused rather than duplicated", () => {
|
|
740
|
+
// Arrange: nothing cut, then block 2 cut once
|
|
741
|
+
const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
742
|
+
|
|
743
|
+
// Assert: a pristine copy has every block
|
|
744
|
+
expect(() => core.sessionRestoreBlock(created.id, 2)).toThrow(/present in the working copy/);
|
|
745
|
+
|
|
746
|
+
// Act
|
|
747
|
+
core.sessionCutBlock(created.id, 2);
|
|
748
|
+
const restored = core.sessionRestoreBlock(created.id, 2);
|
|
749
|
+
|
|
750
|
+
// Assert: one restore puts it back; a second would duplicate it
|
|
751
|
+
expect(restored.workingCopy).toBeUndefined();
|
|
752
|
+
expect(() => core.sessionRestoreBlock(created.id, 2)).toThrow(/present in the working copy/);
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
test("curating a diff stores the decisions and the patch they leave; no decisions clear both", () => {
|
|
756
|
+
// Arrange: one file with two separated changes
|
|
757
|
+
const oldContents = "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\n";
|
|
758
|
+
const newContents = "a\nB\nc\nd\ne\nf\ng\nh\ni\nJ\n";
|
|
759
|
+
const diff: Artifact = {
|
|
760
|
+
type: "diff",
|
|
761
|
+
content: "",
|
|
762
|
+
meta: {},
|
|
763
|
+
files: [{ path: "src/x.txt", oldContents, newContents, status: "modified" }],
|
|
764
|
+
};
|
|
765
|
+
const created = core.sessionCreate({ workspace: WS, artifact: diff });
|
|
766
|
+
|
|
767
|
+
// Act: reject the first hunk
|
|
768
|
+
const curated = core.sessionCurate(created.id, [{ path: "src/x.txt", hunkIndex: 0 }]);
|
|
769
|
+
|
|
770
|
+
// Assert: only the second change survives in the working copy
|
|
771
|
+
expect(curated.curation).toEqual([{ path: "src/x.txt", hunkIndex: 0 }]);
|
|
772
|
+
expect(curated.workingCopy).toContain("+J");
|
|
773
|
+
expect(curated.workingCopy).not.toContain("+B");
|
|
774
|
+
expect(curated.history!.entries.at(-1)).toMatchObject({ type: "revision", by: "reviewer" });
|
|
775
|
+
|
|
776
|
+
// Act
|
|
777
|
+
const cleared = core.sessionCurate(created.id, []);
|
|
778
|
+
|
|
779
|
+
// Assert
|
|
780
|
+
expect(cleared.curation).toBeUndefined();
|
|
781
|
+
expect(cleared.workingCopy).toBeUndefined();
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
test("a plan cannot be curated by hunk", () => {
|
|
785
|
+
// Arrange
|
|
786
|
+
const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
787
|
+
|
|
788
|
+
// Assert
|
|
789
|
+
expect(() => core.sessionCurate(created.id, [])).toThrow(/only a diff review/);
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
test("rejects hunk curation after a patch-only diff revision clears file snapshots", () => {
|
|
793
|
+
const created = core.sessionCreate({
|
|
794
|
+
workspace: WS,
|
|
795
|
+
artifact: {
|
|
796
|
+
type: "diff",
|
|
797
|
+
content: "diff --git a/a.ts b/a.ts\n",
|
|
798
|
+
meta: {},
|
|
799
|
+
files: [{ path: "a.ts", status: "modified", oldContents: "old", newContents: "new" }],
|
|
800
|
+
},
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
core.sessionSubmitRevision(created.id, "diff --git a/b.ts b/b.ts\n", [], []);
|
|
804
|
+
|
|
805
|
+
expect(() => core.sessionCurate(created.id, [{ path: "b.ts", hunkIndex: 0 }])).toThrow(
|
|
806
|
+
/hunk curation needs full file contents/,
|
|
807
|
+
);
|
|
808
|
+
expect(core.sessionGet(created.id).workingCopy).toBeUndefined();
|
|
809
|
+
});
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
describe("tree primitives", () => {
|
|
813
|
+
const comment = (id: string, body: string) => ({
|
|
814
|
+
id,
|
|
815
|
+
kind: "comment" as const,
|
|
816
|
+
anchor: { quote: "carefully", prefix: "the thing ", suffix: "." },
|
|
817
|
+
body,
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
test("navigating main back hides later comments and edits, and the agent's next revision lands there", () => {
|
|
821
|
+
// Arrange: comment, reviewer edit, second comment
|
|
822
|
+
const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
823
|
+
|
|
824
|
+
core.sessionAnnotate(created.id, comment("a1", "first"));
|
|
825
|
+
const checkpoint = derivePath(core.sessionGet(created.id).history!);
|
|
826
|
+
const checkpointEntry = tipOf(core.sessionGet(created.id).history!);
|
|
827
|
+
|
|
828
|
+
core.sessionLabel(created.id, "after a1");
|
|
829
|
+
core.sessionSetWorkingCopy(created.id, "# Plan\n\n## Context\n\nDo it.\n");
|
|
830
|
+
core.sessionAnnotate(created.id, comment("a2", "second"));
|
|
831
|
+
|
|
832
|
+
// Act
|
|
833
|
+
const moved = core.sessionNavigate(created.id, checkpointEntry, "dropped the edit");
|
|
834
|
+
|
|
835
|
+
// Assert: the view is the checkpoint's, the abandoned segment is on record
|
|
836
|
+
expect(moved.workingCopy).toBeUndefined();
|
|
837
|
+
expect(moved.annotations.map((annotation) => annotation.id)).toEqual(["a1"]);
|
|
838
|
+
expect(moved.shelvedAnnotations!.map((annotation) => annotation.id)).toEqual(["a2"]);
|
|
839
|
+
expect(moved.history!.labels[checkpointEntry]).toBe("after a1");
|
|
840
|
+
expect(derivePath(moved.history!).summaries[0]!.abandoned).toHaveLength(2);
|
|
841
|
+
expect(checkpoint.annotationIds).toEqual(["a1"]);
|
|
842
|
+
|
|
843
|
+
// Act: the agent resubmits
|
|
844
|
+
const revised = core.sessionSubmitRevision(created.id, "# Plan\n\nRevised.\n");
|
|
845
|
+
|
|
846
|
+
// Assert: the revision chains after the summary on main
|
|
847
|
+
const path = derivePath(revised.history!);
|
|
848
|
+
|
|
849
|
+
expect(path.head.content).toBe("# Plan\n\nRevised.\n");
|
|
850
|
+
expect(revised.history!.entries.at(-1)!.parentId).toBe(
|
|
851
|
+
derivePath(moved.history!).summaries[0]!.id,
|
|
852
|
+
);
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
test("the feedback document renders from the tip's path", () => {
|
|
856
|
+
// Arrange: two comments, then main moved back between them
|
|
857
|
+
const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
858
|
+
|
|
859
|
+
core.sessionAnnotate(created.id, comment("a1", "keep this one"));
|
|
860
|
+
const between = tipOf(core.sessionGet(created.id).history!);
|
|
861
|
+
|
|
862
|
+
core.sessionAnnotate(created.id, comment("a2", "not this one"));
|
|
863
|
+
core.sessionNavigate(created.id, between);
|
|
864
|
+
|
|
865
|
+
// Act
|
|
866
|
+
const resolved = core.sessionSendMessage(created.id, "changes_requested", "one note");
|
|
867
|
+
|
|
868
|
+
// Assert
|
|
869
|
+
expect(resolved.message!.body).toContain("keep this one");
|
|
870
|
+
expect(resolved.message!.body).not.toContain("not this one");
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
test("a navigate names the branch to stand on, so a move on another branch is one request", () => {
|
|
874
|
+
// Arrange: alt has a comment; the owner stands on main
|
|
875
|
+
const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
876
|
+
const revision = tipOf(created.history!);
|
|
877
|
+
|
|
878
|
+
core.sessionBranch(created.id, "alt");
|
|
879
|
+
core.sessionAnnotate(created.id, comment("a1", "on alt"));
|
|
880
|
+
core.sessionSwitch(created.id, "main");
|
|
881
|
+
|
|
882
|
+
// Act
|
|
883
|
+
const moved = core.sessionNavigate(created.id, revision, undefined, "alt");
|
|
884
|
+
|
|
885
|
+
// Assert: on alt, back at the revision, the comment shelved
|
|
886
|
+
expect(moved.history!.branch).toBe("alt");
|
|
887
|
+
expect(tipOf(moved.history!)).toBe(revision);
|
|
888
|
+
expect(moved.shelvedAnnotations!.map((annotation) => annotation.id)).toEqual(["a1"]);
|
|
889
|
+
expect(() => core.sessionNavigate(created.id, revision, undefined, "nope")).toThrow(
|
|
890
|
+
/no branch/,
|
|
891
|
+
);
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
test("a navigate to the tip or off the path is refused", () => {
|
|
895
|
+
// Arrange
|
|
896
|
+
const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
897
|
+
const tip = tipOf(created.history!);
|
|
898
|
+
|
|
899
|
+
core.sessionBranch(created.id, "alt");
|
|
900
|
+
core.sessionAnnotate(created.id, comment("a1", "on alt"));
|
|
901
|
+
const onAlt = tipOf(core.sessionGet(created.id).history!, "alt");
|
|
902
|
+
|
|
903
|
+
core.sessionSwitch(created.id, "main");
|
|
904
|
+
|
|
905
|
+
// Assert
|
|
906
|
+
expect(() => core.sessionNavigate(created.id, tip)).toThrow(/already at/);
|
|
907
|
+
expect(() => core.sessionNavigate(created.id, onAlt)).toThrow(/not on branch "main"/);
|
|
908
|
+
expect(() => core.sessionNavigate(created.id, "e_nope")).toThrow(/no entry/);
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
test("branching keeps main where it is; switching shows each branch's own comments", () => {
|
|
912
|
+
// Arrange
|
|
913
|
+
const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
914
|
+
|
|
915
|
+
core.sessionAnnotate(created.id, comment("shared", "on both"));
|
|
916
|
+
|
|
917
|
+
// Act
|
|
918
|
+
core.sessionBranch(created.id, "alt");
|
|
919
|
+
core.sessionAnnotate(created.id, comment("alt-only", "on alt"));
|
|
920
|
+
const onAlt = core
|
|
921
|
+
.sessionSwitch(created.id, "alt")
|
|
922
|
+
.annotations.map((annotation) => annotation.id);
|
|
923
|
+
const onMain = core.sessionSwitch(created.id, "main");
|
|
924
|
+
|
|
925
|
+
// Assert
|
|
926
|
+
expect(onAlt).toEqual(["shared", "alt-only"]);
|
|
927
|
+
expect(onMain.annotations.map((annotation) => annotation.id)).toEqual(["shared"]);
|
|
928
|
+
expect(onMain.shelvedAnnotations!.map((annotation) => annotation.id)).toEqual(["alt-only"]);
|
|
929
|
+
expect(() => core.sessionBranch(created.id, "alt")).toThrow(/exists/);
|
|
930
|
+
expect(() => core.sessionSwitch(created.id, "nope")).toThrow(/no branch/);
|
|
931
|
+
});
|
|
932
|
+
|
|
933
|
+
test("a removed comment is shelved, and a share merge does not bring it back", () => {
|
|
934
|
+
// Arrange
|
|
935
|
+
const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
936
|
+
|
|
937
|
+
core.sessionAnnotate(created.id, comment("a1", "first"));
|
|
938
|
+
const removed = core.sessionRemoveAnnotation(created.id, "a1");
|
|
939
|
+
|
|
940
|
+
// Act
|
|
941
|
+
const merged = core.sessionMergeShared(created.id, {
|
|
942
|
+
annotations: [{ ...comment("a1", "first"), createdAt: "2026-01-01T00:00:00.000Z" }],
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
// Assert
|
|
946
|
+
expect(removed.shelvedAnnotations!.map((annotation) => annotation.id)).toEqual(["a1"]);
|
|
947
|
+
expect(merged.annotations).toEqual([]);
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
test("a share's removal entry hides the comment for the owner, once, by entry id", () => {
|
|
951
|
+
// Arrange: a collaborator's note pulled in earlier
|
|
952
|
+
const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
953
|
+
const note = {
|
|
954
|
+
...comment("c1", "from a teammate"),
|
|
955
|
+
author: "SHA256:collab",
|
|
956
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
957
|
+
};
|
|
958
|
+
|
|
959
|
+
core.sessionMergeShared(created.id, { annotations: [note] });
|
|
960
|
+
const removal = { id: "e_share_rm", annotationId: "c1", createdAt: "2026-01-02T00:00:00.000Z" };
|
|
961
|
+
|
|
962
|
+
// Act: the share hands the removal back twice, as two pulls would
|
|
963
|
+
core.sessionMergeShared(created.id, { annotations: [], removals: [removal] });
|
|
964
|
+
const merged = core.sessionMergeShared(created.id, {
|
|
965
|
+
annotations: [note],
|
|
966
|
+
removals: [removal],
|
|
967
|
+
});
|
|
968
|
+
|
|
969
|
+
// Assert: gone from view, kept on the shelf, one removal entry with the share's id
|
|
970
|
+
expect(merged.annotations).toEqual([]);
|
|
971
|
+
expect(merged.shelvedAnnotations!.map((annotation) => annotation.id)).toEqual(["c1"]);
|
|
972
|
+
expect(merged.history!.entries.filter((entry) => entry.type === "comment-removed")).toEqual([
|
|
973
|
+
expect.objectContaining({ id: "e_share_rm", annotationId: "c1" }),
|
|
974
|
+
]);
|
|
975
|
+
});
|
|
976
|
+
|
|
977
|
+
test("a merge lands on the branch the share follows, wherever the owner stands", () => {
|
|
978
|
+
// Arrange: the owner works on a side branch while the share follows main
|
|
979
|
+
const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
980
|
+
|
|
981
|
+
core.sessionBranch(created.id, "alt");
|
|
982
|
+
const note = {
|
|
983
|
+
...comment("c1", "from a teammate"),
|
|
984
|
+
author: "SHA256:collab",
|
|
985
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
986
|
+
};
|
|
987
|
+
|
|
988
|
+
// Act
|
|
989
|
+
const merged = core.sessionMergeShared(created.id, { annotations: [note] });
|
|
990
|
+
|
|
991
|
+
// Assert: main's path has the note, alt's does not show it
|
|
992
|
+
expect(merged.history!.branch).toBe("alt");
|
|
993
|
+
expect(merged.annotations).toEqual([]);
|
|
994
|
+
expect(merged.shelvedAnnotations!.map((annotation) => annotation.id)).toEqual(["c1"]);
|
|
995
|
+
expect(derivePath(merged.history!, tipOf(merged.history!, "main")).annotationIds).toEqual([
|
|
996
|
+
"c1",
|
|
997
|
+
]);
|
|
998
|
+
expect(
|
|
999
|
+
core.sessionSwitch(created.id, "main").annotations.map((annotation) => annotation.id),
|
|
1000
|
+
).toEqual(["c1"]);
|
|
1001
|
+
});
|
|
1002
|
+
|
|
1003
|
+
test("a fork is a new pending session on the copied path, without message, edits, or share", () => {
|
|
1004
|
+
// Arrange: comment, edit, resolve, then a second round
|
|
1005
|
+
const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
1006
|
+
|
|
1007
|
+
core.sessionAnnotate(created.id, comment("a1", "first"), "Ana");
|
|
1008
|
+
core.sessionSetShareId(created.id, "share-1");
|
|
1009
|
+
core.sessionSendMessage(created.id, "changes_requested", "again");
|
|
1010
|
+
core.sessionSubmitRevision(created.id, "# Plan\n\nRound two.\n", ["a1"]);
|
|
1011
|
+
core.sessionLabel(created.id, "round two");
|
|
1012
|
+
core.sessionSetWorkingCopy(created.id, "# Plan\n\nRound two, edited.\n");
|
|
1013
|
+
|
|
1014
|
+
// Act
|
|
1015
|
+
const fork = core.sessionFork(created.id);
|
|
1016
|
+
|
|
1017
|
+
// Assert
|
|
1018
|
+
expect(fork.id).not.toBe(created.id);
|
|
1019
|
+
expect(fork.parentSessionId).toBe(created.id);
|
|
1020
|
+
expect(fork.status).toBe("pending");
|
|
1021
|
+
expect(fork.message).toBeNull();
|
|
1022
|
+
expect(fork.shareId).toBeUndefined();
|
|
1023
|
+
expect(fork.workingCopy).toBeUndefined();
|
|
1024
|
+
expect(fork.artifact.content).toBe("# Plan\n\nRound two.\n");
|
|
1025
|
+
expect(fork.revisions.map((revision) => revision.revision)).toEqual([1, 2]);
|
|
1026
|
+
expect(fork.annotations.map((annotation) => annotation.id)).toEqual(["a1"]);
|
|
1027
|
+
expect(fork.participants).toEqual(
|
|
1028
|
+
created.participants ?? core.sessionGet(created.id).participants,
|
|
1029
|
+
);
|
|
1030
|
+
expect(Object.values(fork.history!.labels)).toEqual(["round two"]);
|
|
1031
|
+
expect(fork.history!.entries.map((entry) => entry.type)).toEqual([
|
|
1032
|
+
"revision",
|
|
1033
|
+
"comment",
|
|
1034
|
+
"revision",
|
|
1035
|
+
]);
|
|
1036
|
+
expect(core.sessionList().map((session) => session.id)).toContain(fork.id);
|
|
1037
|
+
});
|
|
1038
|
+
|
|
1039
|
+
test("a fork's addressed marks follow its own numbering; one addressed off the path reopens", () => {
|
|
1040
|
+
// Arrange: a1 addressed by round two, main navigated back before round two, round three submitted
|
|
1041
|
+
const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
1042
|
+
|
|
1043
|
+
core.sessionAnnotate(created.id, comment("a1", "first"));
|
|
1044
|
+
const beforeRoundTwo = tipOf(core.sessionGet(created.id).history!);
|
|
1045
|
+
|
|
1046
|
+
core.sessionSubmitRevision(created.id, "# Plan\n\nRound two.\n", ["a1"]);
|
|
1047
|
+
core.sessionAnnotate(created.id, comment("a2", "second"));
|
|
1048
|
+
core.sessionSubmitRevision(created.id, "# Plan\n\nRound three.\n", ["a2"]);
|
|
1049
|
+
|
|
1050
|
+
// Act: fork with both rounds on the path
|
|
1051
|
+
const straight = core.sessionFork(created.id);
|
|
1052
|
+
|
|
1053
|
+
core.sessionNavigate(created.id, beforeRoundTwo);
|
|
1054
|
+
core.sessionSubmitRevision(created.id, "# Plan\n\nRound four.\n");
|
|
1055
|
+
// the source numbers this 4; on the fork's path it is the second revision
|
|
1056
|
+
const rerouted = core.sessionFork(created.id);
|
|
1057
|
+
|
|
1058
|
+
// Assert
|
|
1059
|
+
expect(straight.annotations.map((annotation) => annotation.resolution?.revision)).toEqual([
|
|
1060
|
+
2, 3,
|
|
1061
|
+
]);
|
|
1062
|
+
expect(rerouted.revisions.map((revision) => revision.content)).toEqual([
|
|
1063
|
+
PLAN.content,
|
|
1064
|
+
"# Plan\n\nRound four.\n",
|
|
1065
|
+
]);
|
|
1066
|
+
expect(rerouted.annotations.map((annotation) => annotation.id)).toEqual(["a1"]);
|
|
1067
|
+
expect(rerouted.annotations[0]!.resolution).toBeUndefined();
|
|
1068
|
+
});
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
describe("workbench session", () => {
|
|
1072
|
+
function makeRepo(): string {
|
|
1073
|
+
const root = mkdtempSync(join(tmpdir(), "cueloop-wb-repo-"));
|
|
1074
|
+
|
|
1075
|
+
// a unique seed file so each repo's root commit is a distinct SHA (empty commits in the same
|
|
1076
|
+
// second hash identically) - the root commit is the per-project key under test
|
|
1077
|
+
writeFileSync(join(root, "seed.txt"), root);
|
|
1078
|
+
Bun.spawnSync(["git", "-C", root, "init", "-q"]);
|
|
1079
|
+
Bun.spawnSync(["git", "-C", root, "config", "user.email", "t@e.st"]);
|
|
1080
|
+
Bun.spawnSync(["git", "-C", root, "config", "user.name", "T"]);
|
|
1081
|
+
Bun.spawnSync(["git", "-C", root, "add", "-A"]);
|
|
1082
|
+
Bun.spawnSync(["git", "-C", root, "commit", "-q", "-m", "seed"]);
|
|
1083
|
+
|
|
1084
|
+
return root;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
test("creates a diff-backed, workbench-marked thread and attaches idempotently", async () => {
|
|
1088
|
+
const repo = makeRepo();
|
|
1089
|
+
|
|
1090
|
+
try {
|
|
1091
|
+
const first = await core.workbenchSession(repo);
|
|
1092
|
+
|
|
1093
|
+
expect(first.artifact.meta.workbench).toBe(true);
|
|
1094
|
+
expect(first.artifact.type).toBe("diff");
|
|
1095
|
+
expect(first.workspace.rootCommit).toBeTruthy();
|
|
1096
|
+
|
|
1097
|
+
// a second call for the same repo returns the same thread, never a duplicate
|
|
1098
|
+
const second = await core.workbenchSession(repo);
|
|
1099
|
+
|
|
1100
|
+
expect(second.id).toBe(first.id);
|
|
1101
|
+
expect(core.sessionList().filter((s) => s.artifact.meta.workbench).length).toBe(1);
|
|
1102
|
+
} finally {
|
|
1103
|
+
rmSync(repo, { recursive: true, force: true });
|
|
1104
|
+
}
|
|
1105
|
+
});
|
|
1106
|
+
|
|
1107
|
+
test("a resolved workbench is not reused; a fresh one is created past it", async () => {
|
|
1108
|
+
const repo = makeRepo();
|
|
1109
|
+
|
|
1110
|
+
try {
|
|
1111
|
+
const first = await core.workbenchSession(repo);
|
|
1112
|
+
|
|
1113
|
+
core.sessionSendMessage(first.id, "changes_requested", "");
|
|
1114
|
+
const second = await core.workbenchSession(repo);
|
|
1115
|
+
|
|
1116
|
+
expect(second.id).not.toBe(first.id);
|
|
1117
|
+
expect(second.status).toBe("pending");
|
|
1118
|
+
} finally {
|
|
1119
|
+
rmSync(repo, { recursive: true, force: true });
|
|
1120
|
+
}
|
|
1121
|
+
});
|
|
1122
|
+
|
|
1123
|
+
test("concurrent bare launches for one repo share a single workbench", async () => {
|
|
1124
|
+
const repo = makeRepo();
|
|
1125
|
+
|
|
1126
|
+
try {
|
|
1127
|
+
const [a, b] = await Promise.all([core.workbenchSession(repo), core.workbenchSession(repo)]);
|
|
1128
|
+
|
|
1129
|
+
expect(a.id).toBe(b.id);
|
|
1130
|
+
expect(core.sessionList().filter((s) => s.artifact.meta.workbench).length).toBe(1);
|
|
1131
|
+
} finally {
|
|
1132
|
+
rmSync(repo, { recursive: true, force: true });
|
|
1133
|
+
}
|
|
1134
|
+
});
|
|
1135
|
+
|
|
1136
|
+
test("keys the workbench by root commit: different repos get different threads", async () => {
|
|
1137
|
+
const repoA = makeRepo();
|
|
1138
|
+
const repoB = makeRepo();
|
|
1139
|
+
|
|
1140
|
+
try {
|
|
1141
|
+
const a = await core.workbenchSession(repoA);
|
|
1142
|
+
const b = await core.workbenchSession(repoB);
|
|
1143
|
+
|
|
1144
|
+
expect(a.id).not.toBe(b.id);
|
|
1145
|
+
expect(a.workspace.rootCommit).not.toBe(b.workspace.rootCommit);
|
|
1146
|
+
} finally {
|
|
1147
|
+
rmSync(repoA, { recursive: true, force: true });
|
|
1148
|
+
rmSync(repoB, { recursive: true, force: true });
|
|
1149
|
+
}
|
|
155
1150
|
});
|
|
156
1151
|
});
|