@cueloop/daemon 0.1.0-alpha.10 → 0.1.0-alpha.101

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 (83) hide show
  1. package/README.md +7 -0
  2. package/SDK.md +102 -0
  3. package/package.json +44 -22
  4. package/src/agent-tools.ts +98 -0
  5. package/src/agent-turn.test.ts +24 -0
  6. package/src/agent-turn.ts +34 -0
  7. package/src/api.test.ts +1081 -65
  8. package/src/api.ts +1425 -89
  9. package/src/capabilities.test.ts +57 -0
  10. package/src/capabilities.ts +95 -0
  11. package/src/client-errors.ts +24 -0
  12. package/src/client-reliability.test.ts +189 -0
  13. package/src/client.test.ts +37 -0
  14. package/src/client.ts +820 -94
  15. package/src/curate.ts +109 -0
  16. package/src/daemon-sdk-effect.ts +113 -0
  17. package/src/daemon-sdk.test.ts +306 -0
  18. package/src/daemon-sdk.ts +397 -0
  19. package/src/daemon-version.test.ts +142 -0
  20. package/src/diff-hot-reload.test.ts +419 -0
  21. package/src/diff-watcher.test.ts +117 -0
  22. package/src/diff-watcher.ts +326 -0
  23. package/src/examples/daemon-sdk.ts +55 -0
  24. package/src/gh.test.ts +59 -0
  25. package/src/gh.ts +74 -0
  26. package/src/ghostty-thread-surface-store.test.ts +49 -0
  27. package/src/ghostty-thread-surface-store.ts +101 -0
  28. package/src/ghostty-thread-surface.test.ts +398 -0
  29. package/src/ghostty-thread-surface.ts +267 -0
  30. package/src/harness-delivery.test.ts +269 -0
  31. package/src/harness-diagnostics.test.ts +26 -0
  32. package/src/harness-diagnostics.ts +30 -0
  33. package/src/harness-state-store.test.ts +93 -0
  34. package/src/harness-state-store.ts +213 -0
  35. package/src/herdr-split.test.ts +106 -0
  36. package/src/herdr-split.ts +85 -0
  37. package/src/herdr-thread-surface-store.test.ts +81 -0
  38. package/src/herdr-thread-surface-store.ts +66 -0
  39. package/src/herdr-thread-surface.test.ts +465 -0
  40. package/src/herdr-thread-surface.ts +378 -0
  41. package/src/index.ts +8 -9
  42. package/src/interruptible-wait.test.ts +127 -0
  43. package/src/interruptible-wait.ts +66 -0
  44. package/src/jj-working-tree.ts +131 -0
  45. package/src/main.ts +4 -1
  46. package/src/operation-receipts.test.ts +111 -0
  47. package/src/operation-receipts.ts +98 -0
  48. package/src/paths.ts +49 -0
  49. package/src/pr-poller.test.ts +102 -0
  50. package/src/pr-poller.ts +73 -0
  51. package/src/project-files.test.ts +134 -0
  52. package/src/project-files.ts +68 -0
  53. package/src/protocol.ts +78 -7
  54. package/src/retention.test.ts +191 -0
  55. package/src/retention.ts +123 -0
  56. package/src/sdk-identities.ts +35 -0
  57. package/src/server.test.ts +502 -42
  58. package/src/server.ts +631 -88
  59. package/src/share-blob.test.ts +119 -0
  60. package/src/share-blob.ts +57 -0
  61. package/src/shared-agent-protocol.ts +30 -0
  62. package/src/single-instance.test.ts +162 -13
  63. package/src/store.conformance.test.ts +41 -0
  64. package/src/store.jsonl.test.ts +173 -0
  65. package/src/store.ts +267 -34
  66. package/src/testing/store-conformance.ts +192 -0
  67. package/src/thread-agent-validation.ts +121 -0
  68. package/src/thread-agent.test.ts +719 -0
  69. package/src/thread-agent.ts +835 -0
  70. package/src/thread-review.test.ts +483 -0
  71. package/src/thread-review.ts +382 -0
  72. package/src/thread-subscription.test.ts +174 -0
  73. package/src/thread-subscription.ts +129 -0
  74. package/src/thread-surface-config.test.ts +63 -0
  75. package/src/thread-surface-config.ts +61 -0
  76. package/src/validate.test.ts +279 -37
  77. package/src/validate.ts +422 -33
  78. package/src/vcs-source.test.ts +180 -0
  79. package/src/vcs-source.ts +229 -0
  80. package/src/version.ts +6 -0
  81. package/src/working-tree.ts +150 -0
  82. package/src/review.test.ts +0 -167
  83. package/src/review.ts +0 -174
@@ -0,0 +1,719 @@
1
+ import { expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { SCHEMA_VERSION, type Thread, type AgentHarnessAdapter } from "@cueloop/schema";
6
+ import { ThreadAgentManager } from "./thread-agent";
7
+
8
+ function createTestThread(home: string): Thread {
9
+ return {
10
+ schemaVersion: SCHEMA_VERSION,
11
+ id: "adapter-test",
12
+ workspace: { repoRoot: home, branch: "main" },
13
+ artifact: { type: "plan", content: "Review the retry", meta: {} },
14
+ revisions: [],
15
+ annotations: [],
16
+ message: null,
17
+ status: "pending",
18
+ createdAt: "2026-10-04",
19
+ };
20
+ }
21
+
22
+ test("a non-ACP harness streams and resumes through the same Thread manager", async () => {
23
+ const home = mkdtempSync(join(tmpdir(), "cueloop-harness-"));
24
+ const thread = createTestThread(home);
25
+ const sessions: (string | undefined)[] = [];
26
+ const adapter: AgentHarnessAdapter = {
27
+ id: "test-native",
28
+ label: "Native agent",
29
+ connect(options) {
30
+ sessions.push(options.sessionId);
31
+
32
+ return {
33
+ start: async () => options.sessionId ?? "native-session",
34
+ prompt: async () => {
35
+ options.onEvent({
36
+ kind: "tool",
37
+ id: "read",
38
+ title: "Read retry",
39
+ toolKind: "read",
40
+ status: "completed",
41
+ });
42
+ options.onEvent({ kind: "message", id: crypto.randomUUID(), text: "Native answer" });
43
+
44
+ return { outcome: "completed" as const };
45
+ },
46
+ cancel() {},
47
+ permission() {},
48
+ close() {},
49
+ };
50
+ },
51
+ };
52
+ let manager = new ThreadAgentManager({
53
+ home,
54
+ enabled: true,
55
+ adapter,
56
+ getThread: () => thread,
57
+ onChange() {},
58
+ });
59
+ const finish = async () => {
60
+ for (let attempt = 0; manager.get(thread.id).phase.kind !== "idle" && attempt < 100; attempt++)
61
+ await Bun.sleep(1);
62
+ expect(manager.get(thread.id).phase).toEqual({ kind: "idle" });
63
+ };
64
+
65
+ try {
66
+ manager.prompt({ id: thread.id, text: "Explain" });
67
+ await finish();
68
+ expect(manager.get(thread.id).harness).toEqual({
69
+ id: "test-native",
70
+ label: "Native agent",
71
+ sessionId: "native-session",
72
+ });
73
+ expect(manager.get(thread.id).messages[1]?.text).toBe("Native answer");
74
+ const promptDiscussion = { id: manager.get(thread.id).submissions![0]!.id };
75
+
76
+ expect(manager.isReadOnly(thread.id, promptDiscussion.id)).toBe(true);
77
+ const promptReply = manager.reply(thread.id, promptDiscussion.id, "The prompt is clear");
78
+
79
+ expect(promptReply.comments.at(-1)?.replyTo).toBe(promptDiscussion.id);
80
+ const answer = manager.get(thread.id).messages[1]!;
81
+
82
+ manager.comment({
83
+ id: thread.id,
84
+ comment: {
85
+ id: "answer-question",
86
+ messageId: answer.id,
87
+ anchor: { quote: "Native", prefix: "", suffix: " answer", blockIndex: 0, start: 0, end: 6 },
88
+ body: "Explain this",
89
+ sent: false,
90
+ },
91
+ });
92
+ const replied = manager.reply(thread.id, "answer-question", "This is the explanation");
93
+
94
+ expect(replied.comments.at(-1)).toMatchObject({
95
+ replyTo: "answer-question",
96
+ messageId: answer.id,
97
+ sent: true,
98
+ author: "embedded-agent",
99
+ });
100
+ manager.comment({
101
+ id: thread.id,
102
+ comment: {
103
+ ...replied.comments.find((entry) => entry.id === "answer-question")!,
104
+ id: "answer-followup",
105
+ replyTo: "answer-question",
106
+ body: "Why?",
107
+ sent: false,
108
+ },
109
+ });
110
+ manager.prompt({ id: thread.id, text: "" });
111
+ expect(
112
+ manager.get(thread.id).submissions?.find((entry) => entry.commentId === "answer-followup")
113
+ ?.context,
114
+ ).toContain("This is the explanation");
115
+ await finish();
116
+ manager.dispose();
117
+ manager = new ThreadAgentManager({
118
+ home,
119
+ enabled: true,
120
+ adapter,
121
+ getThread: () => thread,
122
+ onChange() {},
123
+ });
124
+ manager.prompt({ id: thread.id, text: "Continue" });
125
+ await finish();
126
+ expect(sessions).toEqual([undefined, "native-session"]);
127
+ const other = new ThreadAgentManager({
128
+ home,
129
+ enabled: true,
130
+ adapter: { ...adapter, id: "another-agent" },
131
+ getThread: () => thread,
132
+ onChange() {},
133
+ });
134
+
135
+ expect(() => other.prompt({ id: thread.id, text: "Mix sessions" })).toThrow(
136
+ "different harness",
137
+ );
138
+ other.dispose();
139
+ } finally {
140
+ manager.dispose();
141
+ rmSync(home, { recursive: true, force: true });
142
+ }
143
+ });
144
+
145
+ test("the first prototype's fx records migrate without losing session identity", () => {
146
+ const home = mkdtempSync(join(tmpdir(), "cueloop-harness-migrate-"));
147
+ const thread = createTestThread(home);
148
+ const path = join(home, "thread-agents");
149
+
150
+ mkdirSync(path);
151
+ writeFileSync(
152
+ join(path, `${thread.id}.json`),
153
+ JSON.stringify({
154
+ threadId: thread.id,
155
+ fxSessionId: "saved-fx-session",
156
+ phase: { kind: "idle" },
157
+ messages: [],
158
+ tools: [],
159
+ comments: [],
160
+ }),
161
+ );
162
+ const manager = new ThreadAgentManager({
163
+ home,
164
+ enabled: false,
165
+ getThread: () => thread,
166
+ onChange() {},
167
+ });
168
+
169
+ try {
170
+ expect(manager.get(thread.id).harness).toEqual({
171
+ id: "fx",
172
+ label: "fx",
173
+ sessionId: "saved-fx-session",
174
+ });
175
+ } finally {
176
+ manager.dispose();
177
+ rmSync(home, { recursive: true, force: true });
178
+ }
179
+ });
180
+
181
+ test("empty input is a no-op; queued comments freeze once and retries reuse their mirrors", async () => {
182
+ const home = mkdtempSync(join(tmpdir(), "cueloop-agent-queue-"));
183
+ const thread = createTestThread(home);
184
+ const prompts: string[] = [];
185
+ const finishes: ((outcome: "completed" | "cancelled") => void)[] = [];
186
+ const adapter: AgentHarnessAdapter = {
187
+ id: "test",
188
+ label: "Test",
189
+ connect(options) {
190
+ return {
191
+ start: async () => "session",
192
+ prompt: (text) => {
193
+ prompts.push(text);
194
+ options.onEvent({
195
+ kind: "message",
196
+ id: `answer-${prompts.length}`,
197
+ text: `Reply ${prompts.length}`,
198
+ });
199
+
200
+ return new Promise((resolve) => finishes.push((outcome) => resolve({ outcome })));
201
+ },
202
+ cancel() {
203
+ finishes.at(-1)?.("cancelled");
204
+ },
205
+ permission() {},
206
+ close() {},
207
+ };
208
+ },
209
+ };
210
+ const manager = new ThreadAgentManager({
211
+ home,
212
+ enabled: true,
213
+ adapter,
214
+ getThread: () => thread,
215
+ onChange() {},
216
+ });
217
+ const tick = () => new Promise<void>((resolve) => setImmediate(resolve));
218
+
219
+ try {
220
+ manager.prompt({ id: thread.id, text: "" });
221
+ expect(prompts).toEqual([]);
222
+ expect(manager.get(thread.id).messages).toEqual([]);
223
+ thread.annotations.push({
224
+ id: "first",
225
+ kind: "comment",
226
+ body: "Explain first",
227
+ anchor: {
228
+ quote: "Review",
229
+ prefix: "",
230
+ suffix: " the retry",
231
+ blockIndex: 0,
232
+ start: 0,
233
+ end: 6,
234
+ },
235
+ createdAt: thread.createdAt,
236
+ });
237
+ manager.prompt({ id: thread.id, text: "" });
238
+ await tick();
239
+ expect(manager.isReadOnly(thread.id, "first")).toBe(true);
240
+ thread.annotations.push({ ...thread.annotations[0]!, id: "second", body: "Explain second" });
241
+ manager.prompt({ id: thread.id, text: "" });
242
+ expect(prompts).toHaveLength(1);
243
+ expect(manager.get(thread.id).submissions?.map((entry) => entry.status)).toEqual([
244
+ "running",
245
+ "queued",
246
+ ]);
247
+ finishes[0]!("completed");
248
+ await tick();
249
+ expect(prompts).toHaveLength(2);
250
+ expect(prompts[1]).toContain("Input: Explain second");
251
+ expect(prompts[1]).toContain("Reply 1");
252
+ finishes[1]!("cancelled");
253
+ await tick();
254
+ const failed = manager.get(thread.id).submissions![1]!;
255
+ const mirrors = manager.get(thread.id).messages.filter((entry) => entry.role === "user");
256
+
257
+ manager.prompt({ id: thread.id, text: "", retry: failed.id });
258
+ await tick();
259
+ expect(manager.get(thread.id).messages.filter((entry) => entry.role === "user")).toEqual(
260
+ mirrors,
261
+ );
262
+ expect(manager.isReadOnly(thread.id, "second")).toBe(true);
263
+ finishes[2]!("completed");
264
+ await tick();
265
+ expect(manager.get(thread.id).submissions?.map((entry) => entry.status)).toEqual([
266
+ "completed",
267
+ "completed",
268
+ ]);
269
+ manager.prompt({ id: thread.id, text: "" });
270
+ expect(prompts).toHaveLength(3);
271
+ } finally {
272
+ manager.dispose();
273
+ rmSync(home, { recursive: true, force: true });
274
+ }
275
+ });
276
+
277
+ test("rejected first batches leave originals editable and create no queued turn", () => {
278
+ const home = mkdtempSync(join(tmpdir(), "cueloop-agent-rejected-"));
279
+ const thread = createTestThread(home);
280
+ const manager = new ThreadAgentManager({
281
+ home,
282
+ enabled: true,
283
+ adapter: {
284
+ id: "test",
285
+ label: "Test",
286
+ connect() {
287
+ throw new Error("A rejected batch must not start");
288
+ },
289
+ },
290
+ getThread: () => thread,
291
+ onChange() {},
292
+ });
293
+ const original = {
294
+ id: "valid",
295
+ kind: "comment",
296
+ body: "Explain",
297
+ anchor: { quote: "Review", prefix: "", suffix: " the retry", blockIndex: 0, start: 0, end: 6 },
298
+ createdAt: thread.createdAt,
299
+ };
300
+
301
+ try {
302
+ thread.annotations.push(original, { ...original, id: "oversized", body: "x".repeat(320001) });
303
+ expect(() => manager.prompt({ id: thread.id, text: "" })).toThrow("320 KiB");
304
+ expect(manager.get(thread.id).messages).toEqual([]);
305
+ expect(manager.get(thread.id).submissions).toBeUndefined();
306
+ expect(manager.isReadOnly(thread.id, "valid")).toBe(false);
307
+ } finally {
308
+ manager.dispose();
309
+ rmSync(home, { recursive: true, force: true });
310
+ }
311
+ });
312
+
313
+ test("prompt operation receipts survive restart without requeueing accepted inputs", () => {
314
+ const home = mkdtempSync(join(tmpdir(), "cueloop-agent-receipt-"));
315
+ const thread = createTestThread(home);
316
+ const adapter: AgentHarnessAdapter = {
317
+ id: "receipt-test",
318
+ label: "Receipt test",
319
+ connect: () => ({
320
+ start: async () => "session",
321
+ prompt: () => new Promise(() => {}),
322
+ cancel() {},
323
+ permission() {},
324
+ close() {},
325
+ }),
326
+ };
327
+ const options = { home, enabled: true, adapter, getThread: () => thread, onChange() {} };
328
+ let manager = new ThreadAgentManager(options);
329
+
330
+ try {
331
+ const input = { id: thread.id, text: "Once", operationId: "once" };
332
+ const accepted = manager.prompt(input);
333
+
334
+ manager.dispose();
335
+ manager = new ThreadAgentManager(options);
336
+ const replay = manager.prompt(input);
337
+
338
+ expect(replay.promptOperations?.[0]?.result).toEqual(accepted.promptOperations?.[0]?.result);
339
+ expect(replay.submissions).toHaveLength(1);
340
+ expect(replay.promptOperations?.[0]?.outcome).toBe("failed");
341
+ expect(() => manager.prompt({ ...input, text: "Different" })).toThrow(
342
+ "Operation payload conflict",
343
+ );
344
+ manager.prompt({
345
+ id: thread.id,
346
+ text: "",
347
+ retry: replay.submissions![0]!.id,
348
+ operationId: "retry",
349
+ });
350
+ expect(manager.get(thread.id).promptOperations?.[0]?.outcome).toBe("failed");
351
+ } finally {
352
+ manager.dispose();
353
+ rmSync(home, { recursive: true, force: true });
354
+ }
355
+ });
356
+
357
+ test("retrying accepted work after a failed running-state save starts the harness once", async () => {
358
+ const home = mkdtempSync(join(tmpdir(), "cueloop-agent-start-save-"));
359
+ const thread = createTestThread(home);
360
+ const blockedPath = join(home, "thread-agents", `${thread.id}.json.tmp`);
361
+ let saves = 0;
362
+ let prompts = 0;
363
+ const started = Promise.withResolvers<void>();
364
+ const adapter: AgentHarnessAdapter = {
365
+ id: "start-save-test",
366
+ label: "Start save test",
367
+ connect: () => ({
368
+ start: async () => "session",
369
+ prompt: () => {
370
+ prompts++;
371
+ started.resolve();
372
+
373
+ return new Promise(() => {});
374
+ },
375
+ cancel() {},
376
+ permission() {},
377
+ close() {},
378
+ }),
379
+ };
380
+ const manager = new ThreadAgentManager({
381
+ home,
382
+ enabled: true,
383
+ adapter,
384
+ getThread: () => thread,
385
+ onChange() {
386
+ if (++saves === 1) mkdirSync(blockedPath);
387
+ },
388
+ });
389
+
390
+ try {
391
+ const input = { id: thread.id, text: "Start once", operationId: "start-once" };
392
+
393
+ expect(() => manager.prompt(input)).toThrow();
394
+ expect(manager.get(thread.id).phase.kind).toBe("idle");
395
+ expect(manager.get(thread.id).submissions?.[0]?.status).toBe("queued");
396
+ expect(prompts).toBe(0);
397
+ const acceptedIds = manager.get(thread.id).promptOperations?.[0]?.result;
398
+
399
+ rmSync(blockedPath, { recursive: true });
400
+ const replay = manager.prompt(input);
401
+
402
+ expect(replay.promptOperations?.[0]?.result).toEqual(acceptedIds);
403
+ expect(replay.submissions).toHaveLength(1);
404
+ await started.promise;
405
+ expect(prompts).toBe(1);
406
+ manager.prompt(input);
407
+ expect(prompts).toBe(1);
408
+ } finally {
409
+ manager.dispose();
410
+ rmSync(home, { recursive: true, force: true });
411
+ }
412
+ });
413
+
414
+ test("a shared plain input does not consume the owner's pending comments", () => {
415
+ const home = mkdtempSync(join(tmpdir(), "cueloop-agent-input-only-"));
416
+ const thread = createTestThread(home);
417
+ const adapter: AgentHarnessAdapter = {
418
+ id: "pi",
419
+ label: "pi",
420
+ connect: () => ({
421
+ start: () => new Promise(() => {}),
422
+ prompt: () => new Promise(() => {}),
423
+ cancel() {},
424
+ permission() {},
425
+ close() {},
426
+ }),
427
+ };
428
+ const manager = new ThreadAgentManager({
429
+ home,
430
+ enabled: true,
431
+ adapter,
432
+ getThread: () => thread,
433
+ onChange() {},
434
+ });
435
+
436
+ thread.annotations = [
437
+ {
438
+ id: "owner-note",
439
+ author: "owner",
440
+ kind: "comment",
441
+ body: "Private draft",
442
+ anchor: { quote: "Review", prefix: "", suffix: " the retry" },
443
+ createdAt: "now",
444
+ },
445
+ ];
446
+ try {
447
+ const state = manager.prompt({
448
+ id: thread.id,
449
+ text: "Shared question",
450
+ operationId: "shared-input",
451
+ inputOnly: true,
452
+ });
453
+
454
+ expect(state.messages.map((entry) => entry.text)).toEqual(["Shared question"]);
455
+ expect(manager.isReadOnly(thread.id, "owner-note")).toBe(false);
456
+ expect(
457
+ manager.prompt({
458
+ id: thread.id,
459
+ text: "Shared question",
460
+ operationId: "shared-input",
461
+ inputOnly: true,
462
+ }).messages,
463
+ ).toHaveLength(1);
464
+ } finally {
465
+ manager.dispose();
466
+ rmSync(home, { recursive: true, force: true });
467
+ }
468
+ });
469
+
470
+ test("Pi recovery keeps the frozen harness input and submission identity after interruption", async () => {
471
+ const home = mkdtempSync(join(tmpdir(), "cueloop-pi-recovery-"));
472
+ const thread = createTestThread(home);
473
+ const prompts: Array<{ text: string; id?: string }> = [];
474
+ let resumed = false;
475
+ const adapter: AgentHarnessAdapter = {
476
+ id: "pi",
477
+ label: "pi",
478
+ connect: ({ onEvent }) => ({
479
+ start: async () => "durable-session",
480
+ prompt: async (text, id) => {
481
+ prompts.push({ text, id });
482
+ if (!resumed) return new Promise(() => {});
483
+ onEvent({ kind: "message", id: "durable-answer", text: "Recovered answer", replace: true });
484
+
485
+ return { outcome: "completed" as const };
486
+ },
487
+ cancel() {},
488
+ permission() {},
489
+ close() {},
490
+ }),
491
+ };
492
+ const options = { home, enabled: true, adapter, getThread: () => thread, onChange() {} };
493
+ let manager = new ThreadAgentManager(options);
494
+
495
+ try {
496
+ manager.prompt({
497
+ id: thread.id,
498
+ text: "Recover",
499
+ operationId: "recover-input",
500
+ inputOnly: true,
501
+ });
502
+ for (let turn = 0; !prompts.length && turn < 100; turn++) await Bun.sleep(1);
503
+ await manager.dispose();
504
+ thread.artifact.content = "Changed artifact after admission";
505
+ resumed = true;
506
+ manager = new ThreadAgentManager(options);
507
+ expect(manager.get(thread.id).submissions?.[0]?.status).toBe("queued");
508
+ manager.prompt({
509
+ id: thread.id,
510
+ text: "Recover",
511
+ operationId: "recover-input",
512
+ inputOnly: true,
513
+ });
514
+ for (let turn = 0; manager.get(thread.id).phase.kind !== "idle" && turn < 100; turn++)
515
+ await Bun.sleep(1);
516
+ expect(prompts).toHaveLength(2);
517
+ expect(prompts[1]).toEqual(prompts[0]);
518
+ expect(manager.get(thread.id).messages.map((message) => message.text)).toEqual([
519
+ "Recover",
520
+ "Recovered answer",
521
+ ]);
522
+ } finally {
523
+ await manager.dispose();
524
+ rmSync(home, { recursive: true, force: true });
525
+ }
526
+ });
527
+
528
+ test("explicit shared comments execute the frozen payload and retain the original discussion", async () => {
529
+ const home = mkdtempSync(join(tmpdir(), "cueloop-shared-frozen-"));
530
+ const thread = createTestThread(home);
531
+ const original = {
532
+ id: "root",
533
+ kind: "comment" as const,
534
+ body: "Original discussion",
535
+ anchor: { quote: "retry", prefix: "Review the ", suffix: "" },
536
+ createdAt: "now",
537
+ };
538
+
539
+ thread.annotations = [
540
+ original,
541
+ { ...original, id: "reply", replyTo: "root", body: "Later edited value" },
542
+ ];
543
+ const manager = new ThreadAgentManager({
544
+ home,
545
+ enabled: true,
546
+ adapter: {
547
+ id: "pi",
548
+ label: "pi",
549
+ connect: () => ({
550
+ start: async () => "session",
551
+ prompt: async () => ({ outcome: "completed" }),
552
+ cancel() {},
553
+ close() {},
554
+ permission() {},
555
+ }),
556
+ },
557
+ getThread: () => thread,
558
+ onChange() {},
559
+ });
560
+
561
+ try {
562
+ const state = manager.prompt({
563
+ id: thread.id,
564
+ commentId: "reply",
565
+ operationId: "shared-frozen",
566
+ text: "Accepted reply",
567
+ context: "retry",
568
+ discussion: JSON.stringify({
569
+ discussion: [{ body: "Original discussion" }, { body: "Accepted reply" }],
570
+ }),
571
+ });
572
+
573
+ expect(state.submissions?.[0]?.prompt).toBe("Accepted reply");
574
+ expect(state.submissions?.[0]?.context).toContain("Original discussion");
575
+ expect(state.submissions?.[0]?.context).not.toContain("Later edited value");
576
+ } finally {
577
+ await manager.dispose();
578
+ rmSync(home, { recursive: true, force: true });
579
+ }
580
+ });
581
+
582
+ test("a failed connection finishes asynchronous close before the next queued turn opens storage", async () => {
583
+ const home = mkdtempSync(join(tmpdir(), "cueloop-agent-close-"));
584
+ const thread = createTestThread(home);
585
+ const closeStarted = Promise.withResolvers<void>();
586
+ const release = Promise.withResolvers<void>();
587
+ let connects = 0;
588
+ const manager = new ThreadAgentManager({
589
+ home,
590
+ enabled: true,
591
+ getThread: () => thread,
592
+ onChange() {},
593
+ adapter: {
594
+ id: "pi",
595
+ label: "pi",
596
+ connect: () => {
597
+ const first = ++connects === 1;
598
+
599
+ return {
600
+ start: async () => "session",
601
+ prompt: async () => {
602
+ if (first) throw new Error("Connection failed");
603
+
604
+ return { outcome: "completed" };
605
+ },
606
+ cancel() {},
607
+ permission() {},
608
+ close: async () => {
609
+ if (first) {
610
+ closeStarted.resolve();
611
+ await release.promise;
612
+ }
613
+ },
614
+ };
615
+ },
616
+ },
617
+ });
618
+
619
+ try {
620
+ manager.prompt({ id: thread.id, text: "First" });
621
+ manager.prompt({ id: thread.id, text: "Second" });
622
+ await closeStarted.promise;
623
+ expect(connects).toBe(1);
624
+ release.resolve();
625
+ for (
626
+ let attempt = 0;
627
+ manager.get(thread.id).submissions?.[1]?.status !== "completed" && attempt < 100;
628
+ attempt++
629
+ )
630
+ await Bun.sleep(1);
631
+ expect(connects).toBe(2);
632
+ expect(manager.get(thread.id).submissions?.[1]?.status).toBe("completed");
633
+ } finally {
634
+ release.resolve();
635
+ await manager.dispose();
636
+ rmSync(home, { recursive: true, force: true });
637
+ }
638
+ });
639
+
640
+ test("shutdown joins a failed turn that is already retiring its durable writer", async () => {
641
+ const home = mkdtempSync(join(tmpdir(), "cueloop-agent-retiring-"));
642
+ const thread = createTestThread(home);
643
+ const closeStarted = Promise.withResolvers<void>();
644
+ const release = Promise.withResolvers<void>();
645
+ let closed = false;
646
+ const manager = new ThreadAgentManager({
647
+ home,
648
+ enabled: true,
649
+ getThread: () => thread,
650
+ onChange() {},
651
+ adapter: {
652
+ id: "pi",
653
+ label: "pi",
654
+ connect: () => ({
655
+ start: async () => "session",
656
+ prompt: async () => {
657
+ throw new Error("Failed turn");
658
+ },
659
+ cancel() {},
660
+ permission() {},
661
+ close: async () => {
662
+ closeStarted.resolve();
663
+ await release.promise;
664
+ closed = true;
665
+ },
666
+ }),
667
+ },
668
+ });
669
+
670
+ try {
671
+ manager.prompt({ id: thread.id, text: "First" });
672
+ await closeStarted.promise;
673
+ const shutdown = manager.dispose();
674
+ let finished = false;
675
+
676
+ void shutdown.then(() => {
677
+ finished = true;
678
+ });
679
+ await Promise.resolve();
680
+ expect(finished).toBe(false);
681
+ release.resolve();
682
+ await shutdown;
683
+ expect(closed).toBe(true);
684
+ } finally {
685
+ release.resolve();
686
+ await manager.dispose();
687
+ rmSync(home, { recursive: true, force: true });
688
+ }
689
+ });
690
+
691
+ test("shutdown seals admission so a later configuration cannot open a new harness", async () => {
692
+ const home = mkdtempSync(join(tmpdir(), "cueloop-agent-sealed-"));
693
+ const thread = createTestThread(home);
694
+ let connects = 0;
695
+ const manager = new ThreadAgentManager({
696
+ home,
697
+ enabled: true,
698
+ getThread: () => thread,
699
+ onChange() {},
700
+ adapter: {
701
+ id: "pi",
702
+ label: "pi",
703
+ connect: () => {
704
+ connects++;
705
+
706
+ throw new Error("Must not connect after shutdown");
707
+ },
708
+ },
709
+ });
710
+
711
+ try {
712
+ await manager.dispose();
713
+ await expect(manager.configure({ id: thread.id })).rejects.toThrow("shutting down");
714
+ expect(() => manager.prompt({ id: thread.id, text: "Late input" })).toThrow("shutting down");
715
+ expect(connects).toBe(0);
716
+ } finally {
717
+ rmSync(home, { recursive: true, force: true });
718
+ }
719
+ });