@cueloop/daemon 0.1.0-alpha.9 → 0.1.0-alpha.90

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.
Files changed (60) hide show
  1. package/package.json +39 -22
  2. package/src/api.test.ts +1060 -65
  3. package/src/api.ts +1398 -89
  4. package/src/capabilities.test.ts +57 -0
  5. package/src/capabilities.ts +89 -0
  6. package/src/client.test.ts +24 -0
  7. package/src/client.ts +669 -86
  8. package/src/curate.ts +109 -0
  9. package/src/daemon-version.test.ts +142 -0
  10. package/src/diff-hot-reload.test.ts +419 -0
  11. package/src/diff-watcher.ts +293 -0
  12. package/src/gh.test.ts +59 -0
  13. package/src/gh.ts +74 -0
  14. package/src/ghostty-thread-surface-store.test.ts +49 -0
  15. package/src/ghostty-thread-surface-store.ts +101 -0
  16. package/src/ghostty-thread-surface.test.ts +398 -0
  17. package/src/ghostty-thread-surface.ts +267 -0
  18. package/src/harness-delivery.test.ts +268 -0
  19. package/src/harness-state-store.test.ts +93 -0
  20. package/src/harness-state-store.ts +213 -0
  21. package/src/herdr-split.test.ts +106 -0
  22. package/src/herdr-split.ts +92 -0
  23. package/src/herdr-thread-surface-store.test.ts +81 -0
  24. package/src/herdr-thread-surface-store.ts +64 -0
  25. package/src/herdr-thread-surface.test.ts +411 -0
  26. package/src/herdr-thread-surface.ts +355 -0
  27. package/src/index.ts +8 -9
  28. package/src/interruptible-wait.test.ts +127 -0
  29. package/src/interruptible-wait.ts +66 -0
  30. package/src/jj-working-tree.ts +131 -0
  31. package/src/main.ts +4 -1
  32. package/src/paths.ts +49 -0
  33. package/src/pr-poller.test.ts +102 -0
  34. package/src/pr-poller.ts +73 -0
  35. package/src/project-files.test.ts +124 -0
  36. package/src/project-files.ts +56 -0
  37. package/src/protocol.ts +79 -7
  38. package/src/retention.test.ts +191 -0
  39. package/src/retention.ts +123 -0
  40. package/src/server.test.ts +453 -42
  41. package/src/server.ts +433 -88
  42. package/src/share-blob.test.ts +112 -0
  43. package/src/share-blob.ts +55 -0
  44. package/src/single-instance.test.ts +162 -13
  45. package/src/store.conformance.test.ts +41 -0
  46. package/src/store.jsonl.test.ts +159 -0
  47. package/src/store.ts +264 -34
  48. package/src/testing/store-conformance.ts +194 -0
  49. package/src/thread-review.test.ts +483 -0
  50. package/src/thread-review.ts +398 -0
  51. package/src/thread-surface-config.test.ts +63 -0
  52. package/src/thread-surface-config.ts +61 -0
  53. package/src/validate.test.ts +282 -37
  54. package/src/validate.ts +398 -33
  55. package/src/vcs-source.test.ts +180 -0
  56. package/src/vcs-source.ts +228 -0
  57. package/src/version.ts +6 -0
  58. package/src/working-tree.ts +149 -0
  59. package/src/review.test.ts +0 -167
  60. 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, verdictResponse } from "./api";
6
- import { SessionStore } from "./store";
7
- import type { Artifact, WorkspaceKey } from "@cueloop/schema";
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
- const s = core.sessionCreate({ workspace: WS, artifact: PLAN });
28
- expect(s.status).toBe("pending");
29
- expect(s.revisions.length).toBe(1);
30
- expect(core.sessionGet(s.id).id).toBe(s.id);
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
- const s = core.sessionCreate({ workspace: WS, artifact: PLAN });
36
- core.sessionAnnotate(s.id, {
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(s.id, {
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
- expect(core.sessionGet(s.id).annotations.length).toBe(1);
49
- expect(core.sessionGet(s.id).annotations[0]!.body).toBe("Define carefully.");
50
- core.sessionRemoveAnnotation(s.id, "a1");
51
- expect(core.sessionGet(s.id).annotations.length).toBe(0);
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
- const s = core.sessionCreate({ workspace: WS, artifact: PLAN });
56
- core.sessionAnnotate(s.id, {
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
- const resolved = core.sessionResolve(s.id, "request_changes", "Needs detail.");
63
- expect(resolved.verdict!.feedback).toContain("# Review: request changes");
64
- expect(resolved.verdict!.feedback).toContain("> carefully");
65
- expect(verdictResponse(resolved)).toEqual({ allow: false, feedback: resolved.verdict!.feedback });
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
- core.sessionResolve(approved.id, "approve", "LGTM");
68
- expect(verdictResponse(core.sessionGet(approved.id)).allow).toBe(true);
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
- const s = core.sessionCreate({ workspace: WS, artifact: PLAN });
73
- core.sessionResolve(s.id, "approve", "");
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(s.id, { id: "a1", kind: "comment", anchor: { quote: "x", prefix: "", suffix: "" }, body: "b" }),
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
- const s = core.sessionCreate({ workspace: WS, artifact: PLAN });
81
- core.sessionSetWorkingCopy(s.id, PLAN.content.replace("carefully", "very carefully"));
82
- expect(core.sessionGet(s.id).workingCopy).toContain("very carefully");
83
- core.sessionSetWorkingCopy(s.id, PLAN.content);
84
- expect(core.sessionGet(s.id).workingCopy).toBeUndefined();
85
- });
86
-
87
- test("revision reopens the session and resets working copy + verdict", () => {
88
- const s = core.sessionCreate({ workspace: WS, artifact: PLAN });
89
- core.sessionSetWorkingCopy(s.id, PLAN.content + "\nedit");
90
- core.sessionResolve(s.id, "request_changes", "redo");
91
- const revised = core.sessionSubmitRevision(s.id, "# Plan v2\n");
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.verdict).toBeNull();
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("the wait contract: verdicts outlive waits", () => {
101
- test("wait resolves when the verdict arrives", async () => {
102
- const s = core.sessionCreate({ workspace: WS, artifact: PLAN });
103
- const wait = core.sessionWait(s.id, 5_000);
104
- core.sessionResolve(s.id, "approve", "");
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
- expect(resolved!.verdict!.kind).toBe("approve");
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 verdict", async () => {
110
- const s = core.sessionCreate({ workspace: WS, artifact: PLAN });
111
- const first = await core.sessionWait(s.id, 30);
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
- core.sessionResolve(s.id, "request_changes", "later");
114
- const second = await core.sessionWait(s.id, 30);
115
- expect(second!.verdict!.summary).toBe("later");
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
- const s = core.sessionCreate({ workspace: WS, artifact: PLAN });
120
- const waits = [core.sessionWait(s.id, 5_000), core.sessionWait(s.id, 5_000)];
121
- core.sessionResolve(s.id, "approve", "");
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
- expect(results.every((r) => r?.status === "resolved")).toBe(true);
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
- const s = core.sessionCreate({ workspace: WS, artifact: PLAN });
130
- core.sessionResolve(s.id, "approve", "done");
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(s.id);
133
- expect(recovered.verdict!.summary).toBe("done");
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 SessionStore(home);
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
- core.onEvent((e) => seen.push(e.event));
151
- const s = core.sessionCreate({ workspace: WS, artifact: PLAN });
152
- core.sessionAnnotate(s.id, { id: "a1", kind: "comment", anchor: { quote: "thing", prefix: "", suffix: "" }, body: "b" });
153
- core.sessionResolve(s.id, "approve", "");
154
- expect(seen).toEqual(["session.created", "inbox.changed", "session.updated", "session.resolved", "inbox.changed"]);
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
  });