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

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