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

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 (82) hide show
  1. package/README.md +7 -0
  2. package/SDK.md +102 -0
  3. package/package.json +43 -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 +91 -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 +456 -42
  58. package/src/server.ts +601 -88
  59. package/src/share-blob.test.ts +119 -0
  60. package/src/share-blob.ts +57 -0
  61. package/src/single-instance.test.ts +162 -13
  62. package/src/store.conformance.test.ts +41 -0
  63. package/src/store.jsonl.test.ts +173 -0
  64. package/src/store.ts +267 -34
  65. package/src/testing/store-conformance.ts +192 -0
  66. package/src/thread-agent-validation.ts +119 -0
  67. package/src/thread-agent.test.ts +412 -0
  68. package/src/thread-agent.ts +713 -0
  69. package/src/thread-review.test.ts +483 -0
  70. package/src/thread-review.ts +382 -0
  71. package/src/thread-subscription.test.ts +174 -0
  72. package/src/thread-subscription.ts +129 -0
  73. package/src/thread-surface-config.test.ts +63 -0
  74. package/src/thread-surface-config.ts +61 -0
  75. package/src/validate.test.ts +279 -37
  76. package/src/validate.ts +418 -33
  77. package/src/vcs-source.test.ts +180 -0
  78. package/src/vcs-source.ts +229 -0
  79. package/src/version.ts +6 -0
  80. package/src/working-tree.ts +150 -0
  81. package/src/review.test.ts +0 -167
  82. package/src/review.ts +0 -174
package/src/curate.ts ADDED
@@ -0,0 +1,109 @@
1
+ /**
2
+ * The curated patch of a diff review: the submitted changes minus the hunks and
3
+ * change blocks the reviewer rejected. The daemon computes it from the
4
+ * session's reject decisions so the working copy every client sees is one and
5
+ * the same; the client only maps rows to decisions.
6
+ */
7
+
8
+ import { diffAcceptRejectHunk, parseDiffFromFile, type FileDiffMetadata } from "@pierre/diffs";
9
+ import {
10
+ unifiedDiffText,
11
+ type DiffFileContents,
12
+ type DiffFileStatus,
13
+ type HunkRejection,
14
+ } from "@cueloop/schema";
15
+
16
+ /**
17
+ * Context lines around each change. Matching git's default (3) makes the model
18
+ * split hunks like the rendered working-tree patch does; a row is mapped by
19
+ * absolute line number, never by counting hunks, so a whole-hunk reject can
20
+ * only ever span the model hunk it lands in.
21
+ */
22
+ export const DIFF_CONTEXT = 3;
23
+
24
+ /** Parse one file's full contents into a non-partial diff model. */
25
+ export function parseFileDiff(file: DiffFileContents): FileDiffMetadata {
26
+ return parseDiffFromFile(
27
+ { name: file.path, contents: file.oldContents },
28
+ { name: file.path, contents: file.newContents },
29
+ { context: DIFF_CONTEXT },
30
+ );
31
+ }
32
+
33
+ /**
34
+ * Apply one file's rejections and return its curated patch, or null when the
35
+ * curated content equals the old content (every change rejected - no patch).
36
+ * Whole-hunk rejections supersede change-level ones for the same hunk. Because
37
+ * `diffAcceptRejectHunk` returns a model with the same hunk/content shape, the
38
+ * indices stay valid across calls; they are applied high-to-low regardless, so
39
+ * the order is deterministic.
40
+ */
41
+ function curateFilePatch(file: DiffFileContents, rejections: HunkRejection[]): string | null {
42
+ let model = parseFileDiff(file);
43
+ const wholeHunks = new Set(
44
+ rejections
45
+ .filter((rejection) => rejection.changeIndex === undefined)
46
+ .map((rejection) => rejection.hunkIndex),
47
+ );
48
+
49
+ for (const hunkIndex of [...wholeHunks].sort((a, b) => b - a)) {
50
+ model = diffAcceptRejectHunk(model, hunkIndex, "reject");
51
+ }
52
+ const changeRejections = rejections
53
+ .filter(
54
+ (rejection) => rejection.changeIndex !== undefined && !wholeHunks.has(rejection.hunkIndex),
55
+ )
56
+ .sort((a, b) => b.hunkIndex - a.hunkIndex || b.changeIndex! - a.changeIndex!);
57
+
58
+ for (const rejection of changeRejections) {
59
+ model = diffAcceptRejectHunk(model, rejection.hunkIndex, {
60
+ type: "reject",
61
+ changeIndex: rejection.changeIndex!,
62
+ });
63
+ }
64
+ const curatedNew = model.additionLines.join("");
65
+
66
+ if (curatedNew === file.oldContents) return null;
67
+ const patch = unifiedDiffText(file.oldContents, curatedNew, file.path);
68
+
69
+ if (patch === null) return null;
70
+
71
+ return withFileStateHeaders(patch, file.path, file.status, curatedNew);
72
+ }
73
+
74
+ /**
75
+ * Point the diff headers at /dev/null for a created or fully-deleted file so the
76
+ * curated patch applies. git's status - not empty contents - decides existence,
77
+ * so editing an existing empty file stays a modify; a deletion whose removals
78
+ * were partly restored also falls back to a modify.
79
+ */
80
+ function withFileStateHeaders(
81
+ patch: string,
82
+ path: string,
83
+ status: DiffFileStatus,
84
+ curatedNew: string,
85
+ ): string {
86
+ if (status === "added") return patch.replace(`--- a/${path}`, "--- /dev/null");
87
+ if (status === "deleted" && curatedNew === "") {
88
+ return patch.replace(`+++ b/${path}`, "+++ /dev/null");
89
+ }
90
+
91
+ return patch;
92
+ }
93
+
94
+ /**
95
+ * The curated unified-diff patch of the accepted changes across every file.
96
+ * Files with no net change (unchanged, or with all changes rejected) drop out.
97
+ */
98
+ export function curateDiff(files: DiffFileContents[], rejections: HunkRejection[]): string {
99
+ const parts: string[] = [];
100
+
101
+ for (const file of files) {
102
+ const fileRejections = rejections.filter((rejection) => rejection.path === file.path);
103
+ const patch = curateFilePatch(file, fileRejections);
104
+
105
+ if (patch) parts.push(patch);
106
+ }
107
+
108
+ return parts.join("\n");
109
+ }
@@ -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
+ });