@cueloop/daemon 0.1.0-alpha.98 → 0.1.0-alpha.99

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.
package/src/client.ts CHANGED
@@ -57,9 +57,11 @@ import {
57
57
  PendingDeliverySchema,
58
58
  } from "./validate";
59
59
  import type { WorkingTreeDiff } from "./working-tree";
60
+ import { DaemonTransportError, type DaemonRequestOptions } from "./client-errors";
61
+ export { DaemonTransportError, type DaemonRequestOptions } from "./client-errors";
60
62
  import { DAEMON_VERSION } from "./version";
61
63
  import { ThreadAgentSchema } from "./thread-agent-validation";
62
- import type { AgentComment, ThreadAgentState } from "@cueloop/schema";
64
+ import type { AgentComment, ThreadAgentState, AgentPromptRequest } from "@cueloop/schema";
63
65
 
64
66
  export type { EventFrame } from "./protocol";
65
67
 
@@ -226,7 +228,8 @@ export class DaemonClient implements ThreadClient {
226
228
  private daemonVersion: string | undefined;
227
229
  private nextId = 1;
228
230
  private eventListeners = new Set<(event: EventFrame) => void>();
229
- private disconnectListeners = new Set<() => void>();
231
+ private disconnectListeners = new Set<(reason: DaemonTransportError) => void>();
232
+ private disconnectReason: DaemonTransportError | undefined;
230
233
  private closed = false;
231
234
  private role: DaemonRole = "owner";
232
235
  private author: string | undefined;
@@ -255,7 +258,15 @@ export class DaemonClient implements ThreadClient {
255
258
  if (!options.autostart || err instanceof DaemonClientError) {
256
259
  client.close();
257
260
 
258
- throw err;
261
+ throw err instanceof DaemonClientError || err instanceof DaemonTransportError
262
+ ? err
263
+ : new DaemonTransportError(
264
+ "connection",
265
+ "Daemon connection failed",
266
+ "not_sent",
267
+ undefined,
268
+ { cause: err },
269
+ );
259
270
  }
260
271
  }
261
272
 
@@ -270,7 +281,9 @@ export class DaemonClient implements ThreadClient {
270
281
  this.writer = null;
271
282
  this.closed = false;
272
283
  for (const pendingRequest of this.pending.values())
273
- pendingRequest.reject(new Error("daemon connection replaced"));
284
+ pendingRequest.reject(
285
+ new DaemonTransportError("connection", "daemon connection replaced", "unknown"),
286
+ );
274
287
  this.pending.clear();
275
288
  }
276
289
 
@@ -310,7 +323,11 @@ export class DaemonClient implements ThreadClient {
310
323
  const output = readFileSync(startupLogPath, "utf8").trim();
311
324
  const detail = output ? `\ndaemon startup output:\n${output.slice(-4_096)}` : "";
312
325
 
313
- throw new Error(`daemon did not come up at ${path}: ${String(lastError)}${detail}`);
326
+ throw new DaemonTransportError(
327
+ "connection",
328
+ `Daemon startup failed at ${path}: ${String(lastError)}${detail}`,
329
+ "not_sent",
330
+ );
314
331
  } finally {
315
332
  rmSync(startupLogPath, { force: true });
316
333
  }
@@ -318,15 +335,23 @@ export class DaemonClient implements ThreadClient {
318
335
 
319
336
  private async dial(path: string): Promise<void> {
320
337
  const buffer = new LineBuffer();
338
+
339
+ this.disconnectReason = undefined;
321
340
  const epoch = ++this.connectionEpoch;
322
341
 
323
342
  const close = () => {
324
343
  if (this.connectionEpoch !== epoch) return;
325
344
  this.closed = true;
326
345
  for (const pendingRequest of this.pending.values())
327
- pendingRequest.reject(new Error("daemon connection closed"));
346
+ pendingRequest.reject(
347
+ new DaemonTransportError("connection", "daemon connection closed", "unknown"),
348
+ );
328
349
  this.pending.clear();
329
- for (const listener of this.disconnectListeners) listener();
350
+ const reason =
351
+ this.disconnectReason ??
352
+ new DaemonTransportError("connection", "daemon connection closed", "unknown");
353
+
354
+ for (const listener of this.disconnectListeners) listener(reason);
330
355
  };
331
356
 
332
357
  if (typeof Bun !== "undefined") {
@@ -399,7 +424,7 @@ export class DaemonClient implements ThreadClient {
399
424
  }
400
425
 
401
426
  /** Notify an adapter when its daemon socket closes so it can reconnect and replay Messages. */
402
- onDisconnect(listener: () => void): () => void {
427
+ onDisconnect(listener: (reason: DaemonTransportError) => void): () => void {
403
428
  this.disconnectListeners.add(listener);
404
429
 
405
430
  return () => this.disconnectListeners.delete(listener);
@@ -410,7 +435,27 @@ export class DaemonClient implements ThreadClient {
410
435
 
411
436
  try {
412
437
  frame = parseInboundFrame(line);
413
- } catch {
438
+ } catch (cause) {
439
+ this.disconnectReason = new DaemonTransportError(
440
+ "protocol",
441
+ "daemon sent a malformed frame",
442
+ "unknown",
443
+ undefined,
444
+ { cause },
445
+ );
446
+ for (const pendingRequest of this.pending.values())
447
+ pendingRequest.reject(
448
+ new DaemonTransportError(
449
+ "protocol",
450
+ "daemon sent a malformed frame",
451
+ "unknown",
452
+ undefined,
453
+ { cause },
454
+ ),
455
+ );
456
+ this.pending.clear();
457
+ this.close();
458
+
414
459
  return;
415
460
  }
416
461
  if ("event" in frame) {
@@ -431,49 +476,109 @@ export class DaemonClient implements ThreadClient {
431
476
  method: string,
432
477
  params: Request["params"],
433
478
  resultSchema: v.GenericSchema<unknown, TOutput>,
434
- timeoutMs = 30_000,
479
+ options: number | DaemonRequestOptions = 30_000,
435
480
  ): Promise<TOutput> {
436
- if (this.closed || !this.socket) return Promise.reject(new Error("not connected"));
481
+ const { timeoutMs = 30_000, signal } = v.is(v.number(), options)
482
+ ? { timeoutMs: options }
483
+ : options;
484
+
485
+ if (signal?.aborted)
486
+ return Promise.reject(
487
+ new DaemonTransportError("cancelled", `request ${method} cancelled`, "not_sent", method),
488
+ );
489
+ if (this.closed || !this.socket)
490
+ return Promise.reject(
491
+ new DaemonTransportError("connection", "not connected", "not_sent", method),
492
+ );
437
493
  const id = this.nextId++;
438
494
 
439
495
  return new Promise<TOutput>((resolve, reject) => {
440
- const timer = setTimeout(() => {
496
+ const cleanup = () => {
497
+ clearTimeout(timer);
498
+ signal?.removeEventListener("abort", abort);
441
499
  this.pending.delete(id);
442
- reject(new Error(`request ${method} timed out`));
500
+ };
501
+ const fail = (error: Error) => {
502
+ cleanup();
503
+ reject(error);
504
+ };
505
+ const abort = () =>
506
+ fail(
507
+ new DaemonTransportError("cancelled", `request ${method} cancelled`, "unknown", method),
508
+ );
509
+ const timer = setTimeout(() => {
510
+ fail(new DaemonTransportError("timeout", `request ${method} timed out`, "unknown", method));
443
511
  }, timeoutMs);
444
512
 
445
513
  this.pending.set(id, {
446
514
  resolve: (value) => {
447
- clearTimeout(timer);
448
- resolve(v.parse(resultSchema, value));
449
- },
450
- reject: (error) => {
451
- clearTimeout(timer);
452
- reject(error);
515
+ cleanup();
516
+ try {
517
+ const parsed = v.safeParse(resultSchema, value);
518
+
519
+ if (!parsed.success)
520
+ throw new DaemonTransportError(
521
+ "protocol",
522
+ `request ${method} returned an invalid result`,
523
+ "unknown",
524
+ method,
525
+ { cause: parsed.issues },
526
+ );
527
+ resolve(parsed.output);
528
+ } catch (cause) {
529
+ reject(
530
+ cause instanceof DaemonTransportError
531
+ ? cause
532
+ : new DaemonTransportError(
533
+ "protocol",
534
+ `request ${method} returned an invalid result`,
535
+ "unknown",
536
+ method,
537
+ { cause },
538
+ ),
539
+ );
540
+ }
453
541
  },
542
+ reject: fail,
454
543
  });
455
- this.writer!.write(JSON.stringify({ id, method, params }) + "\n");
544
+ signal?.addEventListener("abort", abort, { once: true });
545
+ try {
546
+ this.writer!.write(JSON.stringify({ id, method, params }) + "\n");
547
+ } catch (cause) {
548
+ fail(
549
+ new DaemonTransportError(
550
+ "connection",
551
+ `request ${method} could not be written`,
552
+ "unknown",
553
+ method,
554
+ { cause },
555
+ ),
556
+ );
557
+ }
456
558
  });
457
559
  }
458
560
 
459
561
  close(): void {
460
562
  this.closed = true;
563
+ for (const pendingRequest of this.pending.values())
564
+ pendingRequest.reject(
565
+ new DaemonTransportError("connection", "daemon connection closed", "unknown"),
566
+ );
567
+ this.pending.clear();
461
568
  this.socket?.end();
462
569
  }
463
570
 
464
571
  // ── typed primitives ─────────────────────────────
465
572
  /** Read the agent transcript without starting a model request. */
466
- agentGet(id: string): Promise<ThreadAgentState> {
467
- return this.request("agent.get", { id }, ThreadAgentSchema);
573
+ agentGet(id: string, options?: DaemonRequestOptions): Promise<ThreadAgentState> {
574
+ return this.request("agent.get", { id }, ThreadAgentSchema, options);
468
575
  }
469
576
  /** Submit a question and optional selected passage to the daemon-owned agent. */
470
- agentPrompt(params: {
471
- id: string;
472
- text: string;
473
- context?: string;
474
- retry?: string;
475
- }): Promise<ThreadAgentState> {
476
- return this.request("agent.prompt", params, ThreadAgentSchema);
577
+ agentPrompt(
578
+ params: AgentPromptRequest,
579
+ options?: DaemonRequestOptions,
580
+ ): Promise<ThreadAgentState> {
581
+ return this.request("agent.prompt", params, ThreadAgentSchema, options);
477
582
  }
478
583
  /** Cancel the active turn; the daemon retains partial output. */
479
584
  agentCancel(id: string): Promise<ThreadAgentState> {
@@ -681,11 +786,14 @@ export class DaemonClient implements ThreadClient {
681
786
  outcome: MessageOutcome,
682
787
  summary: string,
683
788
  actionBodies?: Record<string, string>,
789
+ operationId?: string,
790
+ options?: DaemonRequestOptions,
684
791
  ): Promise<Thread> {
685
792
  return this.request(
686
793
  "session.sendMessage",
687
- { id, outcome, summary, actionBodies },
794
+ { id, outcome, summary, actionBodies, operationId },
688
795
  ThreadRecordSchema,
796
+ options,
689
797
  );
690
798
  }
691
799
  harnessBind(
@@ -764,6 +872,7 @@ export class DaemonClient implements ThreadClient {
764
872
  }
765
873
 
766
874
  export class DaemonClientError extends Error {
875
+ readonly _tag = "DaemonClientError";
767
876
  constructor(
768
877
  readonly code: string,
769
878
  message: string,
@@ -0,0 +1,113 @@
1
+ import { Context, Effect, Layer, Stream, Queue, Cause, type Scope } from "effect";
2
+ import {
3
+ connectOwnerSdk,
4
+ normalizeSdkError,
5
+ type OwnerDaemonSdk,
6
+ type DaemonSdkError,
7
+ type SdkThread,
8
+ type SdkThreadId,
9
+ } from "./daemon-sdk";
10
+ import type { ConnectOptions } from "./client";
11
+ import type { DaemonRequestOptions } from "./client-errors";
12
+
13
+ type EffectMethods<Methods> = {
14
+ readonly [Key in keyof Methods]: Methods[Key] extends (
15
+ ...args: infer Arguments
16
+ ) => Promise<infer Result>
17
+ ? (...args: Arguments) => Effect.Effect<Result, DaemonSdkError>
18
+ : never;
19
+ };
20
+
21
+ /** The Effect service owns a scoped connection while daemon-owned work outlives local waits. */
22
+ export class CueloopSdk extends Context.Service<
23
+ CueloopSdk,
24
+ {
25
+ readonly ids: OwnerDaemonSdk["ids"];
26
+ readonly threads: EffectMethods<OwnerDaemonSdk["threads"]> & {
27
+ watch(threadId: SdkThreadId): Stream.Stream<SdkThread, DaemonSdkError>;
28
+ };
29
+ readonly comments: EffectMethods<OwnerDaemonSdk["comments"]>;
30
+ readonly sessions: EffectMethods<OwnerDaemonSdk["sessions"]>;
31
+ readonly agents: EffectMethods<OwnerDaemonSdk["agents"]>;
32
+ }
33
+ >()("@cueloop/daemon/CueloopSdk") {
34
+ static ownerLayer(
35
+ options: Omit<ConnectOptions, "role"> = {},
36
+ ): Layer.Layer<CueloopSdk, DaemonSdkError> {
37
+ return Layer.effect(CueloopSdk, acquireOwnerSdk(options).pipe(Effect.map(effectSdkService)));
38
+ }
39
+ }
40
+
41
+ function acquireOwnerSdk(
42
+ options: Omit<ConnectOptions, "role">,
43
+ ): Effect.Effect<OwnerDaemonSdk, DaemonSdkError, Scope.Scope> {
44
+ return Effect.acquireRelease(
45
+ Effect.tryPromise({ try: () => connectOwnerSdk(options), catch: normalizeSdkError }),
46
+ (sdk) => Effect.sync(() => sdk.close()),
47
+ );
48
+ }
49
+
50
+ function sdkEffect<Result>(
51
+ request: (options: DaemonRequestOptions) => Promise<Result>,
52
+ options?: DaemonRequestOptions,
53
+ ): Effect.Effect<Result, DaemonSdkError> {
54
+ return Effect.tryPromise({
55
+ try: (signal) =>
56
+ request({
57
+ ...options,
58
+ signal: options?.signal ? AbortSignal.any([signal, options.signal]) : signal,
59
+ }),
60
+ catch: normalizeSdkError,
61
+ });
62
+ }
63
+
64
+ function effectSdkService(sdk: OwnerDaemonSdk): CueloopSdk["Service"] {
65
+ return {
66
+ ids: sdk.ids,
67
+ threads: {
68
+ watch: (threadId) =>
69
+ Stream.callback<SdkThread, DaemonSdkError>(
70
+ Effect.fn(function* (queue) {
71
+ yield* Effect.acquireRelease(
72
+ Effect.sync(() =>
73
+ sdk.observeThread({
74
+ threadId,
75
+ onValue: (thread) => {
76
+ Queue.offerUnsafe(queue, thread);
77
+ },
78
+ onError: (error) => {
79
+ if (error._tag === "DaemonClientError" || error.kind === "protocol")
80
+ Queue.failCauseUnsafe(queue, Cause.fail(error));
81
+ },
82
+ }),
83
+ ),
84
+ (stop) => Effect.sync(stop),
85
+ );
86
+ }),
87
+ { bufferSize: 1, strategy: "sliding" },
88
+ ),
89
+ get: (id, options) => sdkEffect((request) => sdk.threads.get(id, request), options),
90
+ create: (input, options) =>
91
+ sdkEffect((request) => sdk.threads.create(input, request), options),
92
+ },
93
+ comments: {
94
+ add: (input, options) => sdkEffect((request) => sdk.comments.add(input, request), options),
95
+ list: (id, options) => sdkEffect((request) => sdk.comments.list(id, request), options),
96
+ reply: (input, options) =>
97
+ sdkEffect((request) => sdk.comments.reply(input, request), options),
98
+ },
99
+ sessions: {
100
+ sendMessage: (input, options) =>
101
+ sdkEffect((request) => sdk.sessions.sendMessage(input, request), options),
102
+ },
103
+ agents: {
104
+ get: (id, options) => sdkEffect((request) => sdk.agents.get(id, request), options),
105
+ prompt: (input, options) =>
106
+ sdkEffect((request) => sdk.agents.prompt(input, request), options),
107
+ replyToComment: (input, options) =>
108
+ sdkEffect((request) => sdk.agents.replyToComment(input, request), options),
109
+ wait: (input, options) => sdkEffect((request) => sdk.agents.wait(input, request), options),
110
+ cancel: (id, options) => sdkEffect((request) => sdk.agents.cancel(id, request), options),
111
+ },
112
+ };
113
+ }
@@ -0,0 +1,306 @@
1
+ import { expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { Effect, Stream } from "effect";
6
+ import { DaemonServer } from "./server";
7
+ import { DaemonClient } from "./client";
8
+ import { connectOwnerSdk, connectReviewSdk } from "./daemon-sdk";
9
+ import { CueloopSdk } from "./daemon-sdk-effect";
10
+
11
+ import { createTestSdkHarness } from "../../../test/helpers/sdk-harness";
12
+
13
+ test("SDK accepts once, waits for its own submission and cancelling a wait preserves work", async () => {
14
+ const home = mkdtempSync(join(tmpdir(), "cueloop-sdk-"));
15
+ const harness = createTestSdkHarness();
16
+ const server = new DaemonServer({
17
+ home,
18
+ idleExitMs: 0,
19
+ threadAgent: { enabled: true, adapter: harness.adapter },
20
+ });
21
+
22
+ server.start();
23
+ const sdk = await connectOwnerSdk({ home });
24
+
25
+ try {
26
+ const thread = await sdk.threads.create({
27
+ workspace: { repoRoot: home, branch: "main" },
28
+ artifact: { type: "plan", content: "Explain this", meta: {} },
29
+ });
30
+ const input = {
31
+ threadId: thread.id,
32
+ operationId: sdk.ids.operation("ask-once"),
33
+ text: "Explain",
34
+ };
35
+ const accepted = await sdk.agents.prompt(input);
36
+ const duplicate = await sdk.agents.prompt(input);
37
+
38
+ expect(duplicate).toEqual(accepted);
39
+ expect((await sdk.agents.get(thread.id)).submissions).toHaveLength(1);
40
+ await expect(sdk.agents.prompt({ ...input, text: "Different" })).rejects.toMatchObject({
41
+ code: "operation_conflict",
42
+ });
43
+ const cancel = new AbortController();
44
+ const abandonedWait = sdk.agents.wait(accepted, { signal: cancel.signal });
45
+
46
+ cancel.abort();
47
+ await expect(abandonedWait).rejects.toMatchObject({ kind: "cancelled" });
48
+ expect(harness.cancelled()).toBe(0);
49
+ const completed = sdk.agents.wait(accepted);
50
+
51
+ harness.complete();
52
+ expect((await completed).outcome).toBe("completed");
53
+ const message = {
54
+ threadId: thread.id,
55
+ operationId: sdk.ids.operation("approve-once"),
56
+ outcome: "approved" as const,
57
+ summary: "Good",
58
+ };
59
+ const sent = await sdk.sessions.sendMessage(message);
60
+
61
+ expect(await sdk.sessions.sendMessage(message)).toEqual(sent);
62
+ } finally {
63
+ sdk.close();
64
+ server.stop();
65
+ rmSync(home, { recursive: true, force: true });
66
+ }
67
+ });
68
+
69
+ test("Effect scope supports review comments and typed owner-only capabilities", async () => {
70
+ const home = mkdtempSync(join(tmpdir(), "cueloop-sdk-effect-"));
71
+ const server = new DaemonServer({ home, idleExitMs: 0 });
72
+
73
+ server.start();
74
+
75
+ try {
76
+ const program = Effect.gen(function* () {
77
+ const sdk = yield* CueloopSdk;
78
+ const thread = yield* sdk.threads.create({
79
+ workspace: { repoRoot: home, branch: "main" },
80
+ artifact: { type: "plan", content: "Discuss this", meta: {} },
81
+ });
82
+ const snapshots = yield* sdk.threads.watch(thread.id).pipe(Stream.take(1), Stream.runCollect);
83
+
84
+ expect(snapshots[0]?.id).toBe(thread.id);
85
+ const client = yield* Effect.promise(() => DaemonClient.connect({ home }));
86
+
87
+ try {
88
+ yield* Effect.promise(() =>
89
+ client.sessionComment(thread.id, {
90
+ id: "comment",
91
+ kind: "comment",
92
+ anchor: { quote: "Discuss", prefix: "", suffix: " this" },
93
+ body: "Why?",
94
+ }),
95
+ );
96
+ } finally {
97
+ client.close();
98
+ }
99
+ yield* sdk.comments.reply({
100
+ threadId: thread.id,
101
+ commentId: "comment",
102
+ body: "Because",
103
+ replyId: "reply",
104
+ });
105
+ const comments = yield* sdk.comments.list(thread.id);
106
+
107
+ expect(comments.at(-1)).toMatchObject({ id: "reply", replyTo: "comment", body: "Because" });
108
+
109
+ return thread.id;
110
+ });
111
+ const id = await Effect.runPromise(
112
+ program.pipe(Effect.provide(CueloopSdk.ownerLayer({ home }))),
113
+ );
114
+ const review = await connectReviewSdk({ home, role: "agent", author: "review-agent" });
115
+
116
+ try {
117
+ expect((await review.threads.get(id)).id).toBe(id);
118
+ expect("agents" in review).toBe(false);
119
+ const capped = await DaemonClient.connect({ home, role: "agent", author: "review-agent" });
120
+
121
+ try {
122
+ await expect(capped.agentPrompt({ id, text: "Forbidden" })).rejects.toMatchObject({
123
+ code: "forbidden",
124
+ });
125
+ } finally {
126
+ capped.close();
127
+ }
128
+ } finally {
129
+ review.close();
130
+ }
131
+ } finally {
132
+ server.stop();
133
+ rmSync(home, { recursive: true, force: true });
134
+ }
135
+ });
136
+
137
+ test("lost response after message commit is uncertain and an explicit retry survives server restart", async () => {
138
+ const home = mkdtempSync(join(tmpdir(), "cueloop-sdk-lost-response-"));
139
+ let server = new DaemonServer({ home, idleExitMs: 0 });
140
+
141
+ server.start();
142
+ const connection = await DaemonClient.connect({ home });
143
+
144
+ try {
145
+ const thread = await connection.sessionCreate(
146
+ { repoRoot: home, branch: "main" },
147
+ { type: "plan", content: "Approve this", meta: {} },
148
+ );
149
+ const send = server.core.sessionSendMessage.bind(server.core);
150
+
151
+ server.core.sessionSendMessage = (...args) => {
152
+ const result = send(...args);
153
+
154
+ connection.close();
155
+
156
+ return result;
157
+ };
158
+ await expect(
159
+ connection.sessionSendMessage(thread.id, "approved", "Done", undefined, "lost-ack"),
160
+ ).rejects.toMatchObject({ kind: "connection", certainty: "unknown" });
161
+ const original = server.core.sessionGet(thread.id).message;
162
+
163
+ server.stop();
164
+ server = new DaemonServer({ home, idleExitMs: 0 });
165
+ server.start();
166
+ const sdk = await connectOwnerSdk({ home });
167
+
168
+ try {
169
+ expect(
170
+ await sdk.sessions.sendMessage({
171
+ threadId: sdk.ids.thread(thread.id),
172
+ operationId: sdk.ids.operation("lost-ack"),
173
+ outcome: "approved",
174
+ summary: "Done",
175
+ }),
176
+ ).toEqual(original!);
177
+ expect(
178
+ (await sdk.threads.get(sdk.ids.thread(thread.id))).history?.entries.filter(
179
+ (entry) => entry.type === "message",
180
+ ),
181
+ ).toHaveLength(1);
182
+ } finally {
183
+ sdk.close();
184
+ }
185
+ } finally {
186
+ connection.close();
187
+ server.stop();
188
+ rmSync(home, { recursive: true, force: true });
189
+ }
190
+ });
191
+
192
+ test("Effect interruption aborts the completion wait without stopping the harness", async () => {
193
+ const { Fiber } = await import("effect");
194
+ const home = mkdtempSync(join(tmpdir(), "cueloop-sdk-interrupt-"));
195
+ const harness = createTestSdkHarness();
196
+ const server = new DaemonServer({
197
+ home,
198
+ idleExitMs: 0,
199
+ threadAgent: { enabled: true, adapter: harness.adapter },
200
+ });
201
+
202
+ server.start();
203
+
204
+ try {
205
+ const program = Effect.gen(function* () {
206
+ const sdk = yield* CueloopSdk;
207
+ const thread = yield* sdk.threads.create({
208
+ workspace: { repoRoot: home, branch: "main" },
209
+ artifact: { type: "plan", content: "Explain", meta: {} },
210
+ });
211
+ const accepted = yield* sdk.agents.prompt({
212
+ threadId: thread.id,
213
+ operationId: sdk.ids.operation("interrupt"),
214
+ text: "Explain",
215
+ });
216
+ const fiber = yield* Effect.forkChild(sdk.agents.wait(accepted));
217
+
218
+ yield* Effect.yieldNow;
219
+ yield* Fiber.interrupt(fiber);
220
+ expect(harness.cancelled()).toBe(0);
221
+ yield* Effect.sync(() => harness.complete());
222
+ const completed = yield* sdk.agents.wait(accepted);
223
+
224
+ expect(completed.outcome).toBe("completed");
225
+ });
226
+
227
+ await Effect.runPromise(program.pipe(Effect.provide(CueloopSdk.ownerLayer({ home }))));
228
+ } finally {
229
+ server.stop();
230
+ rmSync(home, { recursive: true, force: true });
231
+ }
232
+ });
233
+
234
+ test("lost prompt acknowledgement replays original acceptance after restart without a harness", async () => {
235
+ const home = mkdtempSync(join(tmpdir(), "cueloop-sdk-lost-prompt-"));
236
+ const harness = createTestSdkHarness();
237
+ let server = new DaemonServer({
238
+ home,
239
+ idleExitMs: 0,
240
+ threadAgent: { enabled: true, adapter: harness.adapter },
241
+ });
242
+
243
+ server.start();
244
+ const connection = await DaemonClient.connect({ home });
245
+
246
+ try {
247
+ const thread = await connection.sessionCreate(
248
+ { repoRoot: home, branch: "main" },
249
+ { type: "plan", content: "Explain", meta: {} },
250
+ );
251
+
252
+ await connection.subscribe();
253
+ connection.onEvent((event) => {
254
+ if (event.event === "agent.updated") connection.close();
255
+ });
256
+ await expect(
257
+ connection.agentPrompt({ id: thread.id, text: "Once", operationId: "lost-prompt" }),
258
+ ).rejects.toMatchObject({ kind: "connection", certainty: "unknown" });
259
+ const inspect = await DaemonClient.connect({ home });
260
+ const accepted = await inspect.agentGet(thread.id);
261
+
262
+ inspect.close();
263
+ server.stop();
264
+ server = new DaemonServer({ home, idleExitMs: 0, threadAgent: { enabled: true } });
265
+ server.start();
266
+ const sdk = await connectOwnerSdk({ home });
267
+
268
+ try {
269
+ const replay = await sdk.agents.prompt({
270
+ threadId: sdk.ids.thread(thread.id),
271
+ operationId: sdk.ids.operation("lost-prompt"),
272
+ text: "Once",
273
+ });
274
+
275
+ expect(replay.submissionIds).toEqual(
276
+ accepted.promptOperations![0]!.result.map(sdk.ids.submission),
277
+ );
278
+ expect((await sdk.agents.get(sdk.ids.thread(thread.id))).submissions).toHaveLength(1);
279
+ expect((await sdk.agents.wait(replay)).outcome).toBe("failed");
280
+ } finally {
281
+ sdk.close();
282
+ }
283
+ } finally {
284
+ connection.close();
285
+ server.stop();
286
+ rmSync(home, { recursive: true, force: true });
287
+ }
288
+ });
289
+
290
+ test("SDK identity types distinguish Thread, operation, and submission capabilities", () => {
291
+ type ThreadId = import("./daemon-sdk").SdkThreadId;
292
+ type OperationId = import("./daemon-sdk").SdkOperationId;
293
+ type SubmissionId = import("./daemon-sdk").SdkSubmissionId;
294
+ type Review = import("./daemon-sdk").ReviewDaemonSdk;
295
+ const operationIsThread: OperationId extends ThreadId ? true : false = false;
296
+ const submissionIsOperation: SubmissionId extends OperationId ? true : false = false;
297
+ const reviewHasAgent: "agents" extends keyof Review ? true : false = false;
298
+ const reviewCreatesThread: "create" extends keyof Review["threads"] ? true : false = false;
299
+
300
+ expect([operationIsThread, submissionIsOperation, reviewHasAgent, reviewCreatesThread]).toEqual([
301
+ false,
302
+ false,
303
+ false,
304
+ false,
305
+ ]);
306
+ });