@cueloop/daemon 0.1.0-alpha.3 → 0.1.0-alpha.30

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.
@@ -0,0 +1,243 @@
1
+ /** The shared review core against a real DaemonServer in a temp home: workspace resolution, title derivation, open-or-revise by agentSessionId, and both awaitVerdict shapes (one long-poll and the chunked loop with progress and abort). */
2
+
3
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
4
+ import { mkdtempSync, rmSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import type { ReviewSession } from "@cueloop/schema";
8
+ import { DaemonServer } from "./server";
9
+ import { DaemonClient } from "./client";
10
+ import { openReview, resolveWorkspace } from "./review";
11
+
12
+ const PLAN = "# Rollout Plan\n\nShip it in two stages.\n";
13
+
14
+ let home: string;
15
+ let server: DaemonServer;
16
+ let client: DaemonClient;
17
+
18
+ beforeEach(async () => {
19
+ home = mkdtempSync(join(tmpdir(), "cueloop-review-core-"));
20
+ server = new DaemonServer({ home, idleExitMs: 0 });
21
+ server.start();
22
+ client = await DaemonClient.connect({ home });
23
+ });
24
+ afterEach(() => {
25
+ client.close();
26
+ server.stop();
27
+ rmSync(home, { recursive: true, force: true });
28
+ });
29
+
30
+ function sh(args: string[], cwd: string): void {
31
+ const gitResult = Bun.spawnSync(args, { cwd, stdout: "ignore", stderr: "ignore" });
32
+ if (gitResult.exitCode !== 0) throw new Error(`${args.join(" ")} failed`);
33
+ }
34
+
35
+ describe("resolveWorkspace", () => {
36
+ test("a git repo resolves to its root and branch", async () => {
37
+ const repo = mkdtempSync(join(tmpdir(), "cueloop-ws-"));
38
+ try {
39
+ // Arrange
40
+ sh(["git", "init", "-q", "-b", "main"], repo);
41
+ sh(["git", "config", "user.email", "t@t"], repo);
42
+ sh(["git", "config", "user.name", "t"], repo);
43
+ sh(["git", "commit", "-q", "--allow-empty", "-m", "init"], repo);
44
+
45
+ // Act
46
+ const ws = await resolveWorkspace(repo);
47
+
48
+ // Assert
49
+ expect(ws.branch).toBe("main");
50
+ // macOS tmpdirs can resolve through /private; compare the tail
51
+ expect(ws.repoRoot.endsWith(repo.split("/").at(-1)!)).toBe(true);
52
+ } finally {
53
+ rmSync(repo, { recursive: true, force: true });
54
+ }
55
+ });
56
+
57
+ test("outside a repo the cwd itself is the workspace, branch detached", async () => {
58
+ // Act
59
+ const ws = await resolveWorkspace(home);
60
+
61
+ // Assert
62
+ expect(ws).toEqual({ repoRoot: home, branch: "detached" });
63
+ });
64
+ });
65
+
66
+ describe("openReview", () => {
67
+ test("shapes the artifact: agent, derived title, cwd", async () => {
68
+ // Act
69
+ const review = await openReview(client, { type: "plan", content: PLAN, cwd: home, agent: "test-agent" });
70
+
71
+ // Assert
72
+ const session = review.session;
73
+ expect(session.status).toBe("pending");
74
+ expect(session.artifact.type).toBe("plan");
75
+ expect(session.artifact.meta.agent).toBe("test-agent");
76
+ expect(session.artifact.meta.title).toBe("Rollout Plan");
77
+ expect(session.artifact.meta.cwd).toBe(home);
78
+ expect(session.workspace).toEqual({ repoRoot: home, branch: "detached" });
79
+ });
80
+
81
+ test("an explicit title wins; diffs never derive one from content", async () => {
82
+ // Act
83
+ const titled = await openReview(client, { type: "plan", content: PLAN, cwd: home, title: "Custom" });
84
+
85
+ // Assert
86
+ expect(titled.session.artifact.meta.title).toBe("Custom");
87
+
88
+ // Act
89
+ const diff = await openReview(client, { type: "diff", content: "# not a heading\n", cwd: home });
90
+
91
+ // Assert
92
+ expect(diff.session.artifact.meta.title).toBeUndefined();
93
+ });
94
+
95
+ test("a pre-resolved workspace skips git resolution", async () => {
96
+ // Arrange
97
+ const ws = { repoRoot: "/elsewhere", branch: "feature" };
98
+
99
+ // Act
100
+ const review = await openReview(client, { type: "diff", content: "d", cwd: home, workspace: ws });
101
+
102
+ // Assert
103
+ expect(review.session.workspace).toEqual(ws);
104
+ });
105
+
106
+ test("the same agentSessionId revises instead of opening a new session", async () => {
107
+ // Arrange
108
+ const first = await openReview(client, { type: "plan", content: PLAN, cwd: home, agentSessionId: "agent-1" });
109
+
110
+ // Act
111
+ const second = await openReview(client, {
112
+ type: "plan",
113
+ content: PLAN + "\nStage three.\n",
114
+ cwd: home,
115
+ agentSessionId: "agent-1",
116
+ });
117
+
118
+ // Assert
119
+ expect(second.id).toBe(first.id);
120
+ expect(second.session.revisions.length).toBe(2);
121
+ expect(second.session.status).toBe("pending");
122
+
123
+ // Act
124
+ const other = await openReview(client, { type: "plan", content: PLAN, cwd: home, agentSessionId: "agent-2" });
125
+
126
+ // Assert
127
+ expect(other.id).not.toBe(first.id);
128
+ });
129
+
130
+ test("per-file notes land as note annotations anchored at the file path", async () => {
131
+ // Act
132
+ const review = await openReview(client, {
133
+ type: "diff",
134
+ content: "d",
135
+ cwd: home,
136
+ notes: [
137
+ { path: "src/a.ts", body: "Renames b and adds c." },
138
+ { path: "src/b.ts", body: "Swaps the line." },
139
+ ],
140
+ });
141
+
142
+ // Assert
143
+ const notes = review.session.annotations;
144
+ expect(notes.length).toBe(2);
145
+ expect(notes.every((annotation) => annotation.kind === "note")).toBe(true);
146
+ expect(notes.map((annotation) => annotation.anchor.quote)).toEqual(["src/a.ts", "src/b.ts"]);
147
+ expect(notes[0]!.body).toBe("Renames b and adds c.");
148
+ });
149
+ });
150
+
151
+ describe("awaitVerdict: one long-poll (the hook shape)", () => {
152
+ test("times out to pending; a later wait collects the stored verdict", async () => {
153
+ // Arrange
154
+ const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
155
+
156
+ // Assert
157
+ expect(await review.awaitVerdict({ timeoutMs: 50 })).toBe("pending");
158
+
159
+ // Act
160
+ await client.sessionResolve(review.id, "approve", "Fine.");
161
+ const verdict = await review.awaitVerdict({ timeoutMs: 1_000 });
162
+
163
+ // Assert
164
+ expect(verdict).not.toBe("pending");
165
+ if (verdict === "pending") throw new Error("unreachable");
166
+ expect(verdict.allow).toBe(true);
167
+ expect(verdict.feedback).toContain("Fine.");
168
+ expect(verdict.session.verdict!.kind).toBe("approve");
169
+ });
170
+
171
+ test("request_changes maps to allow=false with feedback.md attached", async () => {
172
+ // Arrange
173
+ const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
174
+ const waiting = review.awaitVerdict({ timeoutMs: 5_000 });
175
+
176
+ // Act
177
+ await client.sessionResolve(review.id, "request_changes", "One stage only.");
178
+ const verdict = await waiting;
179
+
180
+ // Assert
181
+ if (verdict === "pending") throw new Error("expected a verdict");
182
+ expect(verdict.allow).toBe(false);
183
+ expect(verdict.feedback).toContain("# Review: request changes");
184
+ expect(verdict.feedback).toContain("One stage only.");
185
+ });
186
+ });
187
+
188
+ describe("awaitVerdict: chunked loop (the pi shape)", () => {
189
+ test("onProgress sees fresh sessions between chunks; the verdict ends the loop", async () => {
190
+ // Arrange
191
+ const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
192
+ const seen: ReviewSession[] = [];
193
+ const waiting = review.awaitVerdict({
194
+ timeoutMs: Infinity,
195
+ pollMs: 100,
196
+ onProgress: (progress) => seen.push(progress),
197
+ });
198
+
199
+ // Act
200
+ await client.sessionAnnotate(review.id, {
201
+ id: "a1",
202
+ kind: "comment",
203
+ anchor: { quote: "two stages", prefix: "in ", suffix: "." },
204
+ body: "Name them.",
205
+ });
206
+
207
+ // Assert
208
+ // give the loop a chunk to observe the annotation before resolving
209
+ for (let i = 0; i < 100 && !seen.some((snapshot) => snapshot.annotations.length === 1); i++) await Bun.sleep(20);
210
+ expect(seen.some((snapshot) => snapshot.annotations.length === 1)).toBe(true);
211
+
212
+ // Act
213
+ await client.sessionResolve(review.id, "request_changes", "Too vague.");
214
+ const verdict = await waiting;
215
+
216
+ // Assert
217
+ if (verdict === "pending") throw new Error("expected a verdict");
218
+ expect(verdict.allow).toBe(false);
219
+ expect(verdict.session.annotations.length).toBe(1);
220
+ }, 15_000);
221
+
222
+ test("abort surfaces as pending and leaves the session collectable", async () => {
223
+ // Arrange
224
+ const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
225
+ const controller = new AbortController();
226
+ const waiting = review.awaitVerdict({ timeoutMs: Infinity, pollMs: 100, signal: controller.signal });
227
+
228
+ // Act
229
+ controller.abort();
230
+
231
+ // Assert
232
+ expect(await waiting).toBe("pending");
233
+ expect((await client.sessionGet(review.id)).status).toBe("pending");
234
+ });
235
+
236
+ test("a finite budget runs out to pending", async () => {
237
+ // Arrange
238
+ const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
239
+
240
+ // Assert
241
+ expect(await review.awaitVerdict({ timeoutMs: 120, pollMs: 50 })).toBe("pending");
242
+ });
243
+ });
package/src/review.ts ADDED
@@ -0,0 +1,208 @@
1
+ /**
2
+ * The shared review core: one open/wait/verdict path for every consumer -
3
+ * CLI commands and agent adapters. openReview resolves the workspace, shapes
4
+ * the artifact, and opens-or-revises by agentSessionId; awaitVerdict maps the
5
+ * wait contract onto the agent contract through verdictResponse. An
6
+ * adapter keeps only two bespoke parts: parsing its host's event shape and
7
+ * serializing the decision in its host's contract.
8
+ */
9
+
10
+ import { newAnnotationId, type ArtifactType, type ReviewSession, type WorkspaceKey } from "@cueloop/schema";
11
+ import { verdictResponse } from "./api";
12
+ import type { DaemonClient } from "./client";
13
+
14
+ // Adapters and CLI verbs reach the verdict mapping through this module too,
15
+ // so a session obtained outside a ReviewHandle maps the same way.
16
+ export { verdictResponse };
17
+
18
+ async function git(args: string[], cwd: string): Promise<string | null> {
19
+ try {
20
+ const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "ignore" });
21
+ const out = await new Response(proc.stdout).text();
22
+ if ((await proc.exited) !== 0) return null;
23
+ return out.trim();
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ /** Workspace key resolution: repo root + branch from the cwd. */
30
+ export async function resolveWorkspace(cwd = process.cwd()): Promise<WorkspaceKey> {
31
+ const repoRoot = (await git(["rev-parse", "--show-toplevel"], cwd)) ?? cwd;
32
+ const branch = (await git(["rev-parse", "--abbrev-ref", "HEAD"], cwd)) ?? "detached";
33
+ return { repoRoot, branch };
34
+ }
35
+
36
+ function firstHeading(markdown: string): string | undefined {
37
+ const headingMatch = markdown.match(/^#\s+(.+)$/m);
38
+ return headingMatch?.[1]?.trim();
39
+ }
40
+
41
+ export interface OpenReviewOptions {
42
+ type: ArtifactType;
43
+ content: string;
44
+ /** Workspace resolution root and meta.cwd; defaults to process.cwd(). */
45
+ cwd?: string;
46
+ /** Pre-resolved workspace key; skips git resolution when the caller already has it. */
47
+ workspace?: WorkspaceKey;
48
+ agent?: string;
49
+ /** When set, a resubmit from the same agent session becomes a revision, not a new session. */
50
+ agentSessionId?: string;
51
+ planPath?: string;
52
+ pr?: string;
53
+ herdrPane?: string;
54
+ /** Defaults to the plan's first markdown heading; diffs get no derived title. */
55
+ title?: string;
56
+ /**
57
+ * Per-file agent notes for diff sessions: the submitting agent's own
58
+ * explanation of each file's change, in dead prose. Stored as annotations
59
+ * with kind "note" anchored at the file path, so they render as regular
60
+ * rail cards and feed the guided walk's agent-note block.
61
+ */
62
+ notes?: ReviewNote[];
63
+ }
64
+
65
+ /** The note contract: one note per changed file, anchored by the file path. */
66
+ export interface ReviewNote {
67
+ path: string;
68
+ body: string;
69
+ }
70
+
71
+ /** Anchor a note at its file: quote = the path, no context selectors. */
72
+ async function attachNotes(client: DaemonClient, sessionId: string, notes: ReviewNote[]): Promise<void> {
73
+ for (const note of notes) {
74
+ await client.sessionAnnotate(sessionId, {
75
+ id: newAnnotationId(),
76
+ kind: "note",
77
+ anchor: { quote: note.path, prefix: "", suffix: "" },
78
+ body: note.body,
79
+ });
80
+ }
81
+ }
82
+
83
+ export interface AwaitVerdictOptions {
84
+ /** Total wait budget; Infinity keeps polling until resolved or aborted. */
85
+ timeoutMs: number;
86
+ /** Chunk length for the poll loop; between chunks the session is re-read for progress. */
87
+ pollMs?: number;
88
+ /** Called with the fresh session after each chunk that is still pending. */
89
+ onProgress?: (session: ReviewSession) => void;
90
+ signal?: AbortSignal;
91
+ }
92
+
93
+ /** A resolved review mapped onto the agent contract, plus the full session. */
94
+ export interface VerdictOutcome {
95
+ allow: boolean;
96
+ feedback: string;
97
+ session: ReviewSession;
98
+ }
99
+
100
+ /** Sentinel distinguishing an abort from any daemon response. */
101
+ const ABORTED = Symbol("aborted");
102
+
103
+ function raceAbort<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T | typeof ABORTED> {
104
+ if (!signal) return promise;
105
+ if (signal.aborted) {
106
+ promise.catch(() => {});
107
+ return Promise.resolve(ABORTED);
108
+ }
109
+ return new Promise<T | typeof ABORTED>((resolve, reject) => {
110
+ const onAbort = () => {
111
+ // The daemon request keeps running until the client closes; swallow its
112
+ // eventual rejection so the abort path never leaks an unhandled error.
113
+ promise.catch(() => {});
114
+ resolve(ABORTED);
115
+ };
116
+ signal.addEventListener("abort", onAbort, { once: true });
117
+ promise.then(
118
+ (value) => {
119
+ signal.removeEventListener("abort", onAbort);
120
+ resolve(value);
121
+ },
122
+ (error) => {
123
+ signal.removeEventListener("abort", onAbort);
124
+ reject(error);
125
+ },
126
+ );
127
+ });
128
+ }
129
+
130
+ export class ReviewHandle {
131
+ constructor(
132
+ private readonly client: DaemonClient,
133
+ readonly session: ReviewSession,
134
+ ) {}
135
+
136
+ get id(): string {
137
+ return this.session.id;
138
+ }
139
+
140
+ /**
141
+ * Block on the verdict. "pending" means the budget ran out or the signal
142
+ * aborted - the session stays open and the verdict is collectable later
143
+ * (verdicts outlive waits). With only timeoutMs this is one long-poll;
144
+ * pollMs/onProgress/signal switch to the chunked loop.
145
+ */
146
+ async awaitVerdict(options: AwaitVerdictOptions): Promise<VerdictOutcome | "pending"> {
147
+ const { timeoutMs, pollMs, onProgress, signal } = options;
148
+ if (pollMs === undefined && onProgress === undefined && signal === undefined) {
149
+ const resolved = await this.client.sessionWait(this.session.id, timeoutMs);
150
+ return resolved === null ? "pending" : outcome(resolved);
151
+ }
152
+ const chunkMs = pollMs ?? 10_000;
153
+ const deadline = Number.isFinite(timeoutMs) ? Date.now() + timeoutMs : undefined;
154
+ for (;;) {
155
+ const budget = deadline === undefined ? chunkMs : Math.min(chunkMs, deadline - Date.now());
156
+ if (budget <= 0 || signal?.aborted) return "pending";
157
+ const resolved = await raceAbort(this.client.sessionWait(this.session.id, budget), signal);
158
+ if (resolved === ABORTED) return "pending";
159
+ if (resolved !== null) return outcome(resolved);
160
+ // Still pending after this chunk: re-read to surface reviewer progress.
161
+ const current = await raceAbort(this.client.sessionGet(this.session.id), signal);
162
+ if (current === ABORTED) return "pending";
163
+ onProgress?.(current);
164
+ }
165
+ }
166
+ }
167
+
168
+ function outcome(session: ReviewSession): VerdictOutcome {
169
+ return { ...verdictResponse(session), session };
170
+ }
171
+
172
+ /** Open a review session (or revise the agent session's existing one) and hand back the wait surface. */
173
+ export async function openReview(client: DaemonClient, options: OpenReviewOptions): Promise<ReviewHandle> {
174
+ const cwd = options.cwd ?? process.cwd();
175
+ const workspace = options.workspace ?? (await resolveWorkspace(cwd));
176
+ // Resubmits from the same agent session become revisions, not new sessions.
177
+ if (options.agentSessionId !== undefined) {
178
+ const existing = (await client.sessionList()).find(
179
+ (candidate) => candidate.artifact.meta.agentSessionId === options.agentSessionId,
180
+ );
181
+ if (existing !== undefined) {
182
+ let revised = await client.sessionSubmitRevision(existing.id, options.content);
183
+ if (options.notes?.length) {
184
+ await attachNotes(client, revised.id, options.notes);
185
+ revised = await client.sessionGet(revised.id);
186
+ }
187
+ return new ReviewHandle(client, revised);
188
+ }
189
+ }
190
+ let session = await client.sessionCreate(workspace, {
191
+ type: options.type,
192
+ content: options.content,
193
+ meta: {
194
+ agent: options.agent,
195
+ agentSessionId: options.agentSessionId,
196
+ planPath: options.planPath,
197
+ pr: options.pr,
198
+ herdrPane: options.herdrPane,
199
+ title: options.title ?? (options.type === "plan" ? firstHeading(options.content) : undefined),
200
+ cwd,
201
+ },
202
+ });
203
+ if (options.notes?.length) {
204
+ await attachNotes(client, session.id, options.notes);
205
+ session = await client.sessionGet(session.id);
206
+ }
207
+ return new ReviewHandle(client, session);
208
+ }
@@ -27,18 +27,27 @@ afterEach(() => {
27
27
 
28
28
  describe("socket round-trip", () => {
29
29
  test("ping and full session flow over the wire", async () => {
30
+ // Assert
30
31
  expect((await client.ping()).pid).toBe(process.pid);
31
- const s = await client.sessionCreate(WS, PLAN);
32
- expect(s.status).toBe("pending");
33
- await client.sessionAnnotate(s.id, {
32
+
33
+ // Act
34
+ const session = await client.sessionCreate(WS, PLAN);
35
+
36
+ // Assert
37
+ expect(session.status).toBe("pending");
38
+
39
+ // Act
40
+ await client.sessionAnnotate(session.id, {
34
41
  id: "a1",
35
42
  kind: "comment",
36
43
  anchor: { quote: "Body text", prefix: "", suffix: "." },
37
44
  body: "More detail please.",
38
45
  });
39
- const wait = client.sessionWait(s.id, 5_000);
40
- await client.sessionResolve(s.id, "request_changes", "Expand it.");
46
+ const wait = client.sessionWait(session.id, 5_000);
47
+ await client.sessionResolve(session.id, "request_changes", "Expand it.");
41
48
  const resolved = (await wait)!;
49
+
50
+ // Assert
42
51
  expect(resolved.verdict!.kind).toBe("request_changes");
43
52
  expect(resolved.verdict!.feedback).toContain("More detail please.");
44
53
  });
@@ -47,18 +56,23 @@ describe("socket round-trip", () => {
47
56
  expect(client.sessionGet("nope")).rejects.toBeInstanceOf(DaemonClientError);
48
57
  try {
49
58
  await client.sessionGet("nope");
50
- } catch (e) {
51
- expect((e as DaemonClientError).code).toBe("not_found");
59
+ } catch (error) {
60
+ expect((error as DaemonClientError).code).toBe("not_found");
52
61
  }
53
62
  });
54
63
 
55
64
  test("events push to subscribed connections only", async () => {
65
+ // Arrange
56
66
  const observer = await DaemonClient.connect({ home });
57
67
  const seen: string[] = [];
58
- observer.onEvent((e) => seen.push(e.event));
68
+ observer.onEvent((event) => seen.push(event.event));
59
69
  await observer.subscribe();
60
- const s = await client.sessionCreate(WS, PLAN);
61
- await client.sessionResolve(s.id, "approve", "");
70
+
71
+ // Act
72
+ const session = await client.sessionCreate(WS, PLAN);
73
+ await client.sessionResolve(session.id, "approve", "");
74
+
75
+ // Assert
62
76
  // events are pushed async over the socket; give the loop a beat
63
77
  await Bun.sleep(50);
64
78
  expect(seen).toContain("session.created");
@@ -71,33 +85,134 @@ describe("socket round-trip", () => {
71
85
  try {
72
86
  await client.request("session.create", { artifact: { type: "plan", content: "x" } });
73
87
  throw new Error("should have thrown");
74
- } catch (e) {
75
- expect((e as DaemonClientError).code).toBe("invalid_params");
88
+ } catch (error) {
89
+ expect((error as DaemonClientError).code).toBe("invalid_params");
76
90
  }
77
91
  // wrong types
78
92
  try {
79
93
  await client.request("session.wait", { id: 42 });
80
94
  throw new Error("should have thrown");
81
- } catch (e) {
82
- expect((e as DaemonClientError).code).toBe("invalid_params");
95
+ } catch (error) {
96
+ expect((error as DaemonClientError).code).toBe("invalid_params");
83
97
  }
84
98
  // unknown method
85
99
  try {
86
100
  await client.request("session.nuke", {});
87
101
  throw new Error("should have thrown");
88
- } catch (e) {
89
- expect((e as DaemonClientError).code).toBe("unknown_method");
102
+ } catch (error) {
103
+ expect((error as DaemonClientError).code).toBe("unknown_method");
90
104
  }
91
105
  // the daemon is still fully alive afterwards
92
- const s = await client.sessionCreate(WS, PLAN);
93
- expect((await client.sessionGet(s.id)).id).toBe(s.id);
106
+ const session = await client.sessionCreate(WS, PLAN);
107
+ expect((await client.sessionGet(session.id)).id).toBe(session.id);
108
+ });
109
+
110
+ test("meta fields survive the wire: herdrPane set on create comes back from get", async () => {
111
+ // Act
112
+ const session = await client.sessionCreate(WS, {
113
+ type: "plan",
114
+ content: "# P",
115
+ meta: { agent: "claude-code", herdrPane: "%7" },
116
+ });
117
+ const got = await client.sessionGet(session.id);
118
+
119
+ // Assert
120
+ expect(got.artifact.meta.herdrPane).toBe("%7");
121
+ expect(got.artifact.meta.agent).toBe("claude-code");
122
+ });
123
+
124
+ test("share-sync verbs round-trip: setShareId persists, mergeShared unions notes and identities by id", async () => {
125
+ // Given a shared session with one local note
126
+ const session = await client.sessionCreate(WS, PLAN);
127
+ const anchor = { quote: "Body text", prefix: "", suffix: "." };
128
+ await client.sessionSetShareId(session.id, "p_abc123xy");
129
+ await client.sessionAnnotate(session.id, { id: "a1", kind: "comment", anchor, body: "mine" });
130
+ expect((await client.sessionGet(session.id)).shareId).toBe("p_abc123xy");
131
+
132
+ // When a pull merges an update to the known id, a new collaborator note, and the collaborator's identity
133
+ const merged = await client.sessionMergeShared(session.id, {
134
+ annotations: [
135
+ { id: "a1", kind: "comment", anchor, body: "should not overwrite", createdAt: "2026-01-01T00:00:00.000Z" },
136
+ { id: "a2", kind: "comment", anchor, body: "theirs", author: "SHA256:mate", createdAt: "2026-01-01T00:00:00.000Z" },
137
+ ],
138
+ participants: [{ id: "SHA256:mate", provider: "ssh", name: "Sam" }],
139
+ });
140
+
141
+ // Then notes union by id (existing a1 kept), and the participant registry carries the collaborator's name
142
+ expect(merged.annotations.map((annotation) => annotation.id).sort()).toEqual(["a1", "a2"]);
143
+ expect(merged.annotations.find((annotation) => annotation.id === "a1")!.body).toBe("mine");
144
+ expect(merged.annotations.find((annotation) => annotation.id === "a2")!.author).toBe("SHA256:mate");
145
+ expect(merged.participants).toEqual([{ id: "SHA256:mate", provider: "ssh", name: "Sam" }]);
146
+ });
147
+
148
+ test("mergeShared validates each annotation at the socket boundary", async () => {
149
+ // Given a session
150
+ const session = await client.sessionCreate(WS, PLAN);
151
+ try {
152
+ // When a malformed annotation is merged
153
+ // @ts-expect-error intentionally malformed annotation (empty id, missing fields)
154
+ await client.sessionMergeShared(session.id, { annotations: [{ id: "" }] });
155
+ throw new Error("should have thrown");
156
+ } catch (error) {
157
+ // Then it is rejected at the wire boundary
158
+ expect((error as DaemonClientError).code).toBe("invalid_params");
159
+ }
160
+ });
161
+
162
+ test("session.delete removes a session over the wire", async () => {
163
+ // Given a created session
164
+ const session = await client.sessionCreate(WS, PLAN);
165
+
166
+ // When it is deleted
167
+ await client.sessionDelete(session.id);
168
+
169
+ // Then it is gone, and deleting an unknown id is a not_found error
170
+ expect(client.sessionGet(session.id)).rejects.toBeInstanceOf(DaemonClientError);
171
+ try {
172
+ await client.sessionDelete("ses_missing");
173
+ throw new Error("should have thrown");
174
+ } catch (error) {
175
+ expect((error as DaemonClientError).code).toBe("not_found");
176
+ }
94
177
  });
95
178
 
96
179
  test("two clients see the same state (thin-renderer model)", async () => {
180
+ // Arrange
97
181
  const second = await DaemonClient.connect({ home });
98
- const s = await client.sessionCreate(WS, PLAN);
99
- const fromSecond = await second.sessionGet(s.id);
182
+
183
+ // Act
184
+ const session = await client.sessionCreate(WS, PLAN);
185
+ const fromSecond = await second.sessionGet(session.id);
186
+
187
+ // Assert
100
188
  expect(fromSecond.artifact.content).toBe(PLAN.content);
101
189
  second.close();
102
190
  });
103
191
  });
192
+
193
+ describe("large payloads survive socket backpressure", () => {
194
+ test("a session bigger than the kernel socket buffer round-trips", async () => {
195
+ // Regression: responses larger than one socket write used to truncate
196
+ // mid-line because the write return value was ignored, so the client
197
+ // never saw the frame's newline and every later request wedged too.
198
+ // Arrange
199
+ const bigContent = "# Big plan\n" + "lorem ipsum dolor sit amet, consectetur adipiscing elit\n".repeat(4000);
200
+
201
+ // Act
202
+ const created = await client.sessionCreate(
203
+ { repoRoot: "/repo", branch: "main" },
204
+ { type: "plan", content: bigContent, meta: { title: "big" } },
205
+ );
206
+ const fetched = await client.sessionGet(created.id);
207
+
208
+ // Assert
209
+ expect(fetched.artifact.content).toBe(bigContent);
210
+
211
+ // Act
212
+ // the connection must still be usable for the next frame
213
+ const listed = await client.sessionList();
214
+
215
+ // Assert
216
+ expect(listed.some((session) => session.id === created.id)).toBe(true);
217
+ }, 15_000);
218
+ });