@cueloop/daemon 0.1.0-alpha.8 → 0.1.0-alpha.81

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