@cueloop/daemon 0.1.0-alpha.7 → 0.1.0-alpha.70

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 (42) hide show
  1. package/package.json +33 -22
  2. package/src/api.test.ts +891 -50
  3. package/src/api.ts +849 -72
  4. package/src/capabilities.test.ts +57 -0
  5. package/src/capabilities.ts +77 -0
  6. package/src/client.test.ts +24 -0
  7. package/src/client.ts +459 -69
  8. package/src/curate.ts +109 -0
  9. package/src/daemon-version.test.ts +93 -0
  10. package/src/diff-hot-reload.test.ts +186 -0
  11. package/src/diff-watcher.ts +102 -0
  12. package/src/herdr-pane.test.ts +240 -0
  13. package/src/herdr-pane.ts +167 -0
  14. package/src/herdr-split.test.ts +106 -0
  15. package/src/herdr-split.ts +92 -0
  16. package/src/herdr-tab-store.test.ts +96 -0
  17. package/src/herdr-tab-store.ts +66 -0
  18. package/src/index.ts +8 -9
  19. package/src/interruptible-wait.test.ts +127 -0
  20. package/src/interruptible-wait.ts +66 -0
  21. package/src/main.ts +4 -1
  22. package/src/paths.ts +39 -0
  23. package/src/project-files.test.ts +124 -0
  24. package/src/project-files.ts +56 -0
  25. package/src/protocol.ts +79 -7
  26. package/src/retention.test.ts +182 -0
  27. package/src/retention.ts +122 -0
  28. package/src/review.test.ts +255 -28
  29. package/src/review.ts +164 -62
  30. package/src/server.test.ts +381 -39
  31. package/src/server.ts +322 -84
  32. package/src/share-blob.test.ts +112 -0
  33. package/src/share-blob.ts +55 -0
  34. package/src/single-instance.test.ts +38 -11
  35. package/src/store.conformance.test.ts +41 -0
  36. package/src/store.jsonl.test.ts +159 -0
  37. package/src/store.ts +261 -34
  38. package/src/testing/store-conformance.ts +194 -0
  39. package/src/validate.test.ts +174 -28
  40. package/src/validate.ts +226 -16
  41. package/src/version.ts +6 -0
  42. package/src/working-tree.ts +149 -0
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
5
  import { DaemonCore, verdictResponse } from "./api";
6
- import { SessionStore } from "./store";
7
- import type { Artifact, WorkspaceKey } from "@cueloop/schema";
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,71 +25,209 @@ 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.");
118
+
119
+ // Act
120
+ const resolved = core.sessionResolve(session.id, "request_changes", "Needs detail.");
121
+
122
+ // Assert
63
123
  expect(resolved.verdict!.feedback).toContain("# Review: request changes");
64
124
  expect(resolved.verdict!.feedback).toContain("> carefully");
65
- expect(verdictResponse(resolved)).toEqual({ allow: false, feedback: resolved.verdict!.feedback });
125
+ expect(verdictResponse(resolved)).toEqual({
126
+ allow: false,
127
+ feedback: resolved.verdict!.feedback,
128
+ });
129
+
130
+ // Arrange
66
131
  const approved = core.sessionCreate({ workspace: WS, artifact: PLAN });
132
+
133
+ // Act
67
134
  core.sessionResolve(approved.id, "approve", "LGTM");
135
+
136
+ // Assert
68
137
  expect(verdictResponse(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.sessionResolve(session.id, "approve", "");
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();
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();
85
218
  });
86
219
 
87
220
  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");
221
+ // Arrange
222
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
223
+
224
+ core.sessionSetWorkingCopy(session.id, PLAN.content + "\nedit");
225
+ core.sessionResolve(session.id, "request_changes", "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
232
  expect(revised.verdict).toBeNull();
94
233
  expect(revised.workingCopy).toBeUndefined();
@@ -97,47 +236,196 @@ describe("session lifecycle", () => {
97
236
  });
98
237
  });
99
238
 
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.sessionResolve(session.id, "request_changes", "one left");
349
+
350
+ // Assert
351
+ expect(resolved.verdict!.feedback).not.toContain("settled");
352
+ expect(resolved.verdict!.feedback).toContain("annotation id: `open`");
353
+ expect(resolved.verdict!.feedback).toContain(`submit-revision ${session.id}`);
354
+ expect(resolved.verdict!.feedback).toContain("--addressed");
355
+ });
356
+ });
357
+
100
358
  describe("the wait contract: verdicts outlive waits", () => {
101
359
  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", "");
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.sessionResolve(session.id, "approve", "");
105
366
  const resolved = await wait;
367
+
368
+ // Assert
106
369
  expect(resolved!.verdict!.kind).toBe("approve");
107
370
  });
108
371
 
109
372
  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);
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);
381
+
382
+ // Act
383
+ core.sessionResolve(session.id, "request_changes", "later");
384
+ const second = await core.sessionWait(session.id, 30);
385
+
386
+ // Assert
115
387
  expect(second!.verdict!.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.sessionResolve(session.id, "approve", "");
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.sessionResolve(session.id, "approve", "done");
410
+
411
+ // Act
131
412
  const reborn = new DaemonCore(home);
132
- const recovered = reborn.sessionGet(s.id);
413
+ const recovered = reborn.sessionGet(session.id);
414
+
415
+ // Assert
133
416
  expect(recovered.verdict!.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,564 @@ 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.sessionResolve(session.id, "approve", "");
452
+
453
+ // Assert
454
+ expect(seen).toEqual([
455
+ "session.created",
456
+ "inbox.changed",
457
+ "session.updated",
458
+ "session.resolved",
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.sessionResolve(created.id, "request_changes", "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
+ "verdict",
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
+
658
+ describe("tree primitives", () => {
659
+ const comment = (id: string, body: string) => ({
660
+ id,
661
+ kind: "comment" as const,
662
+ anchor: { quote: "carefully", prefix: "the thing ", suffix: "." },
663
+ body,
664
+ });
665
+
666
+ test("navigating main back hides later comments and edits, and the agent's next revision lands there", () => {
667
+ // Arrange: comment, reviewer edit, second comment
668
+ const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
669
+
670
+ core.sessionAnnotate(created.id, comment("a1", "first"));
671
+ const checkpoint = derivePath(core.sessionGet(created.id).history!);
672
+ const checkpointEntry = tipOf(core.sessionGet(created.id).history!);
673
+
674
+ core.sessionLabel(created.id, "after a1");
675
+ core.sessionSetWorkingCopy(created.id, "# Plan\n\n## Context\n\nDo it.\n");
676
+ core.sessionAnnotate(created.id, comment("a2", "second"));
677
+
678
+ // Act
679
+ const moved = core.sessionNavigate(created.id, checkpointEntry, "dropped the edit");
680
+
681
+ // Assert: the view is the checkpoint's, the abandoned segment is on record
682
+ expect(moved.workingCopy).toBeUndefined();
683
+ expect(moved.annotations.map((annotation) => annotation.id)).toEqual(["a1"]);
684
+ expect(moved.shelvedAnnotations!.map((annotation) => annotation.id)).toEqual(["a2"]);
685
+ expect(moved.history!.labels[checkpointEntry]).toBe("after a1");
686
+ expect(derivePath(moved.history!).summaries[0]!.abandoned).toHaveLength(2);
687
+ expect(checkpoint.annotationIds).toEqual(["a1"]);
688
+
689
+ // Act: the agent resubmits
690
+ const revised = core.sessionSubmitRevision(created.id, "# Plan\n\nRevised.\n");
691
+
692
+ // Assert: the revision chains after the summary on main
693
+ const path = derivePath(revised.history!);
694
+
695
+ expect(path.head.content).toBe("# Plan\n\nRevised.\n");
696
+ expect(revised.history!.entries.at(-1)!.parentId).toBe(
697
+ derivePath(moved.history!).summaries[0]!.id,
698
+ );
699
+ });
700
+
701
+ test("the feedback document renders from the tip's path", () => {
702
+ // Arrange: two comments, then main moved back between them
703
+ const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
704
+
705
+ core.sessionAnnotate(created.id, comment("a1", "keep this one"));
706
+ const between = tipOf(core.sessionGet(created.id).history!);
707
+
708
+ core.sessionAnnotate(created.id, comment("a2", "not this one"));
709
+ core.sessionNavigate(created.id, between);
710
+
711
+ // Act
712
+ const resolved = core.sessionResolve(created.id, "request_changes", "one note");
713
+
714
+ // Assert
715
+ expect(resolved.verdict!.feedback).toContain("keep this one");
716
+ expect(resolved.verdict!.feedback).not.toContain("not this one");
717
+ });
718
+
719
+ test("a navigate names the branch to stand on, so a move on another branch is one request", () => {
720
+ // Arrange: alt has a comment; the owner stands on main
721
+ const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
722
+ const revision = tipOf(created.history!);
723
+
724
+ core.sessionBranch(created.id, "alt");
725
+ core.sessionAnnotate(created.id, comment("a1", "on alt"));
726
+ core.sessionSwitch(created.id, "main");
727
+
728
+ // Act
729
+ const moved = core.sessionNavigate(created.id, revision, undefined, "alt");
730
+
731
+ // Assert: on alt, back at the revision, the comment shelved
732
+ expect(moved.history!.branch).toBe("alt");
733
+ expect(tipOf(moved.history!)).toBe(revision);
734
+ expect(moved.shelvedAnnotations!.map((annotation) => annotation.id)).toEqual(["a1"]);
735
+ expect(() => core.sessionNavigate(created.id, revision, undefined, "nope")).toThrow(
736
+ /no branch/,
737
+ );
738
+ });
739
+
740
+ test("a navigate to the tip or off the path is refused", () => {
741
+ // Arrange
742
+ const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
743
+ const tip = tipOf(created.history!);
744
+
745
+ core.sessionBranch(created.id, "alt");
746
+ core.sessionAnnotate(created.id, comment("a1", "on alt"));
747
+ const onAlt = tipOf(core.sessionGet(created.id).history!, "alt");
748
+
749
+ core.sessionSwitch(created.id, "main");
750
+
751
+ // Assert
752
+ expect(() => core.sessionNavigate(created.id, tip)).toThrow(/already at/);
753
+ expect(() => core.sessionNavigate(created.id, onAlt)).toThrow(/not on branch "main"/);
754
+ expect(() => core.sessionNavigate(created.id, "e_nope")).toThrow(/no entry/);
755
+ });
756
+
757
+ test("branching keeps main where it is; switching shows each branch's own comments", () => {
758
+ // Arrange
759
+ const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
760
+
761
+ core.sessionAnnotate(created.id, comment("shared", "on both"));
762
+
763
+ // Act
764
+ core.sessionBranch(created.id, "alt");
765
+ core.sessionAnnotate(created.id, comment("alt-only", "on alt"));
766
+ const onAlt = core
767
+ .sessionSwitch(created.id, "alt")
768
+ .annotations.map((annotation) => annotation.id);
769
+ const onMain = core.sessionSwitch(created.id, "main");
770
+
771
+ // Assert
772
+ expect(onAlt).toEqual(["shared", "alt-only"]);
773
+ expect(onMain.annotations.map((annotation) => annotation.id)).toEqual(["shared"]);
774
+ expect(onMain.shelvedAnnotations!.map((annotation) => annotation.id)).toEqual(["alt-only"]);
775
+ expect(() => core.sessionBranch(created.id, "alt")).toThrow(/exists/);
776
+ expect(() => core.sessionSwitch(created.id, "nope")).toThrow(/no branch/);
777
+ });
778
+
779
+ test("a removed comment is shelved, and a share merge does not bring it back", () => {
780
+ // Arrange
781
+ const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
782
+
783
+ core.sessionAnnotate(created.id, comment("a1", "first"));
784
+ const removed = core.sessionRemoveAnnotation(created.id, "a1");
785
+
786
+ // Act
787
+ const merged = core.sessionMergeShared(created.id, {
788
+ annotations: [{ ...comment("a1", "first"), createdAt: "2026-01-01T00:00:00.000Z" }],
789
+ });
790
+
791
+ // Assert
792
+ expect(removed.shelvedAnnotations!.map((annotation) => annotation.id)).toEqual(["a1"]);
793
+ expect(merged.annotations).toEqual([]);
794
+ });
795
+
796
+ test("a share's removal entry hides the comment for the owner, once, by entry id", () => {
797
+ // Arrange: a collaborator's note pulled in earlier
798
+ const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
799
+ const note = {
800
+ ...comment("c1", "from a teammate"),
801
+ author: "SHA256:collab",
802
+ createdAt: "2026-01-01T00:00:00.000Z",
803
+ };
804
+
805
+ core.sessionMergeShared(created.id, { annotations: [note] });
806
+ const removal = { id: "e_share_rm", annotationId: "c1", createdAt: "2026-01-02T00:00:00.000Z" };
807
+
808
+ // Act: the share hands the removal back twice, as two pulls would
809
+ core.sessionMergeShared(created.id, { annotations: [], removals: [removal] });
810
+ const merged = core.sessionMergeShared(created.id, {
811
+ annotations: [note],
812
+ removals: [removal],
813
+ });
814
+
815
+ // Assert: gone from view, kept on the shelf, one removal entry with the share's id
816
+ expect(merged.annotations).toEqual([]);
817
+ expect(merged.shelvedAnnotations!.map((annotation) => annotation.id)).toEqual(["c1"]);
818
+ expect(merged.history!.entries.filter((entry) => entry.type === "comment-removed")).toEqual([
819
+ expect.objectContaining({ id: "e_share_rm", annotationId: "c1" }),
820
+ ]);
821
+ });
822
+
823
+ test("a merge lands on the branch the share follows, wherever the owner stands", () => {
824
+ // Arrange: the owner works on a side branch while the share follows main
825
+ const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
826
+
827
+ core.sessionBranch(created.id, "alt");
828
+ const note = {
829
+ ...comment("c1", "from a teammate"),
830
+ author: "SHA256:collab",
831
+ createdAt: "2026-01-01T00:00:00.000Z",
832
+ };
833
+
834
+ // Act
835
+ const merged = core.sessionMergeShared(created.id, { annotations: [note] });
836
+
837
+ // Assert: main's path has the note, alt's does not show it
838
+ expect(merged.history!.branch).toBe("alt");
839
+ expect(merged.annotations).toEqual([]);
840
+ expect(merged.shelvedAnnotations!.map((annotation) => annotation.id)).toEqual(["c1"]);
841
+ expect(derivePath(merged.history!, tipOf(merged.history!, "main")).annotationIds).toEqual([
842
+ "c1",
843
+ ]);
844
+ expect(
845
+ core.sessionSwitch(created.id, "main").annotations.map((annotation) => annotation.id),
846
+ ).toEqual(["c1"]);
847
+ });
848
+
849
+ test("a fork is a new pending session on the copied path, without verdict, edits, or share", () => {
850
+ // Arrange: comment, edit, resolve, then a second round
851
+ const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
852
+
853
+ core.sessionAnnotate(created.id, comment("a1", "first"), "Ana");
854
+ core.sessionSetShareId(created.id, "share-1");
855
+ core.sessionResolve(created.id, "request_changes", "again");
856
+ core.sessionSubmitRevision(created.id, "# Plan\n\nRound two.\n", ["a1"]);
857
+ core.sessionLabel(created.id, "round two");
858
+ core.sessionSetWorkingCopy(created.id, "# Plan\n\nRound two, edited.\n");
859
+
860
+ // Act
861
+ const fork = core.sessionFork(created.id);
862
+
863
+ // Assert
864
+ expect(fork.id).not.toBe(created.id);
865
+ expect(fork.parentSessionId).toBe(created.id);
866
+ expect(fork.status).toBe("pending");
867
+ expect(fork.verdict).toBeNull();
868
+ expect(fork.shareId).toBeUndefined();
869
+ expect(fork.workingCopy).toBeUndefined();
870
+ expect(fork.artifact.content).toBe("# Plan\n\nRound two.\n");
871
+ expect(fork.revisions.map((revision) => revision.revision)).toEqual([1, 2]);
872
+ expect(fork.annotations.map((annotation) => annotation.id)).toEqual(["a1"]);
873
+ expect(fork.participants).toEqual(
874
+ created.participants ?? core.sessionGet(created.id).participants,
875
+ );
876
+ expect(Object.values(fork.history!.labels)).toEqual(["round two"]);
877
+ expect(fork.history!.entries.map((entry) => entry.type)).toEqual([
878
+ "revision",
879
+ "comment",
880
+ "revision",
881
+ ]);
882
+ expect(core.sessionList().map((session) => session.id)).toContain(fork.id);
883
+ });
884
+
885
+ test("a fork's addressed marks follow its own numbering; one addressed off the path reopens", () => {
886
+ // Arrange: a1 addressed by round two, main navigated back before round two, round three submitted
887
+ const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
888
+
889
+ core.sessionAnnotate(created.id, comment("a1", "first"));
890
+ const beforeRoundTwo = tipOf(core.sessionGet(created.id).history!);
891
+
892
+ core.sessionSubmitRevision(created.id, "# Plan\n\nRound two.\n", ["a1"]);
893
+ core.sessionAnnotate(created.id, comment("a2", "second"));
894
+ core.sessionSubmitRevision(created.id, "# Plan\n\nRound three.\n", ["a2"]);
895
+
896
+ // Act: fork with both rounds on the path
897
+ const straight = core.sessionFork(created.id);
898
+
899
+ core.sessionNavigate(created.id, beforeRoundTwo);
900
+ core.sessionSubmitRevision(created.id, "# Plan\n\nRound four.\n");
901
+ // the source numbers this 4; on the fork's path it is the second revision
902
+ const rerouted = core.sessionFork(created.id);
903
+
904
+ // Assert
905
+ expect(straight.annotations.map((annotation) => annotation.resolution?.revision)).toEqual([
906
+ 2, 3,
907
+ ]);
908
+ expect(rerouted.revisions.map((revision) => revision.content)).toEqual([
909
+ PLAN.content,
910
+ "# Plan\n\nRound four.\n",
911
+ ]);
912
+ expect(rerouted.annotations.map((annotation) => annotation.id)).toEqual(["a1"]);
913
+ expect(rerouted.annotations[0]!.resolution).toBeUndefined();
914
+ });
915
+ });
916
+
917
+ describe("workbench session", () => {
918
+ function makeRepo(): string {
919
+ const root = mkdtempSync(join(tmpdir(), "cueloop-wb-repo-"));
920
+
921
+ // a unique seed file so each repo's root commit is a distinct SHA (empty commits in the same
922
+ // second hash identically) - the root commit is the per-project key under test
923
+ writeFileSync(join(root, "seed.txt"), root);
924
+ Bun.spawnSync(["git", "-C", root, "init", "-q"]);
925
+ Bun.spawnSync(["git", "-C", root, "config", "user.email", "t@e.st"]);
926
+ Bun.spawnSync(["git", "-C", root, "config", "user.name", "T"]);
927
+ Bun.spawnSync(["git", "-C", root, "add", "-A"]);
928
+ Bun.spawnSync(["git", "-C", root, "commit", "-q", "-m", "seed"]);
929
+
930
+ return root;
931
+ }
932
+
933
+ test("creates a diff-backed, workbench-marked thread and attaches idempotently", async () => {
934
+ const repo = makeRepo();
935
+
936
+ try {
937
+ const first = await core.workbenchSession(repo);
938
+
939
+ expect(first.artifact.meta.workbench).toBe(true);
940
+ expect(first.artifact.type).toBe("diff");
941
+ expect(first.workspace.rootCommit).toBeTruthy();
942
+
943
+ // a second call for the same repo returns the same thread, never a duplicate
944
+ const second = await core.workbenchSession(repo);
945
+
946
+ expect(second.id).toBe(first.id);
947
+ expect(core.sessionList().filter((s) => s.artifact.meta.workbench).length).toBe(1);
948
+ } finally {
949
+ rmSync(repo, { recursive: true, force: true });
950
+ }
951
+ });
952
+
953
+ test("a resolved workbench is not reused; a fresh one is created past it", async () => {
954
+ const repo = makeRepo();
955
+
956
+ try {
957
+ const first = await core.workbenchSession(repo);
958
+
959
+ core.sessionResolve(first.id, "comment", "");
960
+ const second = await core.workbenchSession(repo);
961
+
962
+ expect(second.id).not.toBe(first.id);
963
+ expect(second.status).toBe("pending");
964
+ } finally {
965
+ rmSync(repo, { recursive: true, force: true });
966
+ }
967
+ });
968
+
969
+ test("concurrent bare launches for one repo share a single workbench", async () => {
970
+ const repo = makeRepo();
971
+
972
+ try {
973
+ const [a, b] = await Promise.all([core.workbenchSession(repo), core.workbenchSession(repo)]);
974
+
975
+ expect(a.id).toBe(b.id);
976
+ expect(core.sessionList().filter((s) => s.artifact.meta.workbench).length).toBe(1);
977
+ } finally {
978
+ rmSync(repo, { recursive: true, force: true });
979
+ }
980
+ });
981
+
982
+ test("keys the workbench by root commit: different repos get different threads", async () => {
983
+ const repoA = makeRepo();
984
+ const repoB = makeRepo();
985
+
986
+ try {
987
+ const a = await core.workbenchSession(repoA);
988
+ const b = await core.workbenchSession(repoB);
989
+
990
+ expect(a.id).not.toBe(b.id);
991
+ expect(a.workspace.rootCommit).not.toBe(b.workspace.rootCommit);
992
+ } finally {
993
+ rmSync(repoA, { recursive: true, force: true });
994
+ rmSync(repoB, { recursive: true, force: true });
995
+ }
155
996
  });
156
997
  });