@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/README.md +7 -0
- package/SDK.md +102 -0
- package/package.json +12 -8
- package/src/api.ts +19 -1
- package/src/capabilities.ts +1 -0
- package/src/client-errors.ts +24 -0
- package/src/client-reliability.test.ts +189 -0
- package/src/client.ts +139 -30
- package/src/daemon-sdk-effect.ts +113 -0
- package/src/daemon-sdk.test.ts +306 -0
- package/src/daemon-sdk.ts +397 -0
- package/src/examples/daemon-sdk.ts +55 -0
- package/src/harness-delivery.test.ts +1 -0
- package/src/operation-receipts.test.ts +111 -0
- package/src/operation-receipts.ts +91 -0
- package/src/pr-poller.ts +1 -0
- package/src/sdk-identities.ts +35 -0
- package/src/server.ts +32 -2
- package/src/share-blob.ts +3 -1
- package/src/store.jsonl.test.ts +14 -0
- package/src/store.ts +11 -2
- package/src/thread-agent-validation.ts +11 -0
- package/src/thread-agent.test.ts +105 -0
- package/src/thread-agent.ts +53 -5
- package/src/thread-review.ts +1 -0
- package/src/thread-subscription.test.ts +174 -0
- package/src/thread-subscription.ts +127 -0
- package/src/validate.test.ts +1 -0
- package/src/validate.ts +10 -0
- package/src/vcs-source.ts +1 -0
- package/src/working-tree.ts +2 -0
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(
|
|
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
|
|
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(
|
|
346
|
+
pendingRequest.reject(
|
|
347
|
+
new DaemonTransportError("connection", "daemon connection closed", "unknown"),
|
|
348
|
+
);
|
|
328
349
|
this.pending.clear();
|
|
329
|
-
|
|
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
|
-
|
|
479
|
+
options: number | DaemonRequestOptions = 30_000,
|
|
435
480
|
): Promise<TOutput> {
|
|
436
|
-
|
|
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
|
|
496
|
+
const cleanup = () => {
|
|
497
|
+
clearTimeout(timer);
|
|
498
|
+
signal?.removeEventListener("abort", abort);
|
|
441
499
|
this.pending.delete(id);
|
|
442
|
-
|
|
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
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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
|
-
|
|
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(
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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
|
+
});
|