@cueloop/daemon 0.1.0-alpha.4 → 0.1.0-alpha.40
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/package.json +24 -15
- package/src/api.test.ts +278 -46
- package/src/api.ts +182 -52
- package/src/capabilities.test.ts +37 -0
- package/src/capabilities.ts +30 -0
- package/src/client.ts +124 -36
- package/src/herdr-pane.test.ts +236 -0
- package/src/herdr-pane.ts +143 -0
- package/src/herdr-split.test.ts +103 -0
- package/src/herdr-split.ts +84 -0
- package/src/herdr-tab-store.test.ts +95 -0
- package/src/herdr-tab-store.ts +67 -0
- package/src/index.ts +8 -8
- package/src/main.ts +3 -1
- package/src/paths.ts +5 -0
- package/src/protocol.ts +44 -7
- package/src/review.test.ts +362 -0
- package/src/review.ts +263 -0
- package/src/server.test.ts +151 -20
- package/src/server.ts +117 -55
- package/src/share-blob.test.ts +62 -0
- package/src/share-blob.ts +51 -0
- package/src/single-instance.test.ts +28 -11
- package/src/store.ts +7 -5
- package/src/validate.test.ts +182 -14
- package/src/validate.ts +129 -37
package/src/review.ts
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
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 {
|
|
11
|
+
newAnnotationId,
|
|
12
|
+
type ArtifactType,
|
|
13
|
+
type DiffFileContents,
|
|
14
|
+
type ReviewSession,
|
|
15
|
+
type WorkspaceKey,
|
|
16
|
+
} from "@cueloop/schema";
|
|
17
|
+
import { verdictResponse } from "./api";
|
|
18
|
+
import type { DaemonClient } from "./client";
|
|
19
|
+
|
|
20
|
+
// Adapters and CLI verbs reach the verdict mapping through this module too,
|
|
21
|
+
// so a session obtained outside a ReviewHandle maps the same way.
|
|
22
|
+
export { verdictResponse };
|
|
23
|
+
|
|
24
|
+
async function git(args: string[], cwd: string): Promise<string | null> {
|
|
25
|
+
try {
|
|
26
|
+
const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "ignore" });
|
|
27
|
+
const out = await new Response(proc.stdout).text();
|
|
28
|
+
if ((await proc.exited) !== 0) return null;
|
|
29
|
+
return out.trim();
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Workspace key resolution: repo root + branch from the cwd. */
|
|
36
|
+
export async function resolveWorkspace(cwd = process.cwd()): Promise<WorkspaceKey> {
|
|
37
|
+
const repoRoot = (await git(["rev-parse", "--show-toplevel"], cwd)) ?? cwd;
|
|
38
|
+
const branch = (await git(["rev-parse", "--abbrev-ref", "HEAD"], cwd)) ?? "detached";
|
|
39
|
+
return { repoRoot, branch };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function firstHeading(markdown: string): string | undefined {
|
|
43
|
+
const headingMatch = markdown.match(/^#\s+(.+)$/m);
|
|
44
|
+
return headingMatch?.[1]?.trim();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface OpenReviewOptions {
|
|
48
|
+
type: ArtifactType;
|
|
49
|
+
content: string;
|
|
50
|
+
/** Workspace resolution root and meta.cwd; defaults to process.cwd(). */
|
|
51
|
+
cwd?: string;
|
|
52
|
+
/** Pre-resolved workspace key; skips git resolution when the caller already has it. */
|
|
53
|
+
workspace?: WorkspaceKey;
|
|
54
|
+
agent?: string;
|
|
55
|
+
/** When set, a resubmit from the same agent session becomes a revision, not a new session. */
|
|
56
|
+
agentSessionId?: string;
|
|
57
|
+
planPath?: string;
|
|
58
|
+
pr?: string;
|
|
59
|
+
herdrPane?: string;
|
|
60
|
+
/**
|
|
61
|
+
* Full file contents per changed file for a working-tree diff, carried onto
|
|
62
|
+
* the artifact so hunk curation produces an exactly applyable patch.
|
|
63
|
+
*/
|
|
64
|
+
files?: DiffFileContents[];
|
|
65
|
+
/** Defaults to the plan's first markdown heading; diffs get no derived title. */
|
|
66
|
+
title?: string;
|
|
67
|
+
/**
|
|
68
|
+
* Per-file agent notes for diff sessions: the submitting agent's own
|
|
69
|
+
* explanation of each file's change, in dead prose. Stored as annotations
|
|
70
|
+
* with kind "note" anchored at the file path, so they render as regular
|
|
71
|
+
* rail cards and feed the guided walk's agent-note block.
|
|
72
|
+
*/
|
|
73
|
+
notes?: ReviewNote[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The note contract: one note per changed file, anchored by the file path. */
|
|
77
|
+
export interface ReviewNote {
|
|
78
|
+
path: string;
|
|
79
|
+
body: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Anchor a note at its file: quote = the path, no context selectors. */
|
|
83
|
+
async function attachNotes(
|
|
84
|
+
client: DaemonClient,
|
|
85
|
+
sessionId: string,
|
|
86
|
+
notes: ReviewNote[],
|
|
87
|
+
): Promise<void> {
|
|
88
|
+
for (const note of notes) {
|
|
89
|
+
await client.sessionAnnotate(sessionId, {
|
|
90
|
+
id: newAnnotationId(),
|
|
91
|
+
kind: "note",
|
|
92
|
+
anchor: { quote: note.path, prefix: "", suffix: "" },
|
|
93
|
+
body: note.body,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface AwaitVerdictOptions {
|
|
99
|
+
/** Total wait budget; Infinity keeps polling until resolved or aborted. */
|
|
100
|
+
timeoutMs: number;
|
|
101
|
+
/** Chunk length for the poll loop; between chunks the session is re-read for progress. */
|
|
102
|
+
pollMs?: number;
|
|
103
|
+
/** Called with the fresh session after each chunk that is still pending. */
|
|
104
|
+
onProgress?: (session: ReviewSession) => void;
|
|
105
|
+
signal?: AbortSignal;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** A resolved review mapped onto the agent contract, plus the full session. */
|
|
109
|
+
export interface VerdictOutcome {
|
|
110
|
+
allow: boolean;
|
|
111
|
+
feedback: string;
|
|
112
|
+
session: ReviewSession;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Sentinel distinguishing an abort from any daemon response. */
|
|
116
|
+
const ABORTED = Symbol("aborted");
|
|
117
|
+
|
|
118
|
+
function raceAbort<T>(
|
|
119
|
+
promise: Promise<T>,
|
|
120
|
+
signal: AbortSignal | undefined,
|
|
121
|
+
): Promise<T | typeof ABORTED> {
|
|
122
|
+
if (!signal) return promise;
|
|
123
|
+
if (signal.aborted) {
|
|
124
|
+
promise.catch(() => {});
|
|
125
|
+
return Promise.resolve(ABORTED);
|
|
126
|
+
}
|
|
127
|
+
return new Promise<T | typeof ABORTED>((resolve, reject) => {
|
|
128
|
+
const onAbort = () => {
|
|
129
|
+
// The daemon request keeps running until the client closes; swallow its
|
|
130
|
+
// eventual rejection so the abort path never leaks an unhandled error.
|
|
131
|
+
promise.catch(() => {});
|
|
132
|
+
resolve(ABORTED);
|
|
133
|
+
};
|
|
134
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
135
|
+
promise.then(
|
|
136
|
+
(value) => {
|
|
137
|
+
signal.removeEventListener("abort", onAbort);
|
|
138
|
+
resolve(value);
|
|
139
|
+
},
|
|
140
|
+
(error) => {
|
|
141
|
+
signal.removeEventListener("abort", onAbort);
|
|
142
|
+
reject(error);
|
|
143
|
+
},
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export class ReviewHandle {
|
|
149
|
+
constructor(
|
|
150
|
+
private readonly client: DaemonClient,
|
|
151
|
+
readonly session: ReviewSession,
|
|
152
|
+
) {}
|
|
153
|
+
|
|
154
|
+
get id(): string {
|
|
155
|
+
return this.session.id;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Block on the verdict. "pending" means the budget ran out or the signal
|
|
160
|
+
* aborted - the session stays open and the verdict is collectable later
|
|
161
|
+
* (verdicts outlive waits). With only timeoutMs this is one long-poll;
|
|
162
|
+
* pollMs/onProgress/signal switch to the chunked loop.
|
|
163
|
+
*/
|
|
164
|
+
async awaitVerdict(options: AwaitVerdictOptions): Promise<VerdictOutcome | "pending"> {
|
|
165
|
+
const { timeoutMs, pollMs, onProgress, signal } = options;
|
|
166
|
+
if (pollMs === undefined && onProgress === undefined && signal === undefined) {
|
|
167
|
+
const resolved = await this.client.sessionWait(this.session.id, timeoutMs);
|
|
168
|
+
return resolved === null ? "pending" : outcome(resolved);
|
|
169
|
+
}
|
|
170
|
+
const chunkMs = pollMs ?? 10_000;
|
|
171
|
+
const deadline = Number.isFinite(timeoutMs) ? Date.now() + timeoutMs : undefined;
|
|
172
|
+
for (;;) {
|
|
173
|
+
const budget = deadline === undefined ? chunkMs : Math.min(chunkMs, deadline - Date.now());
|
|
174
|
+
if (budget <= 0 || signal?.aborted) return "pending";
|
|
175
|
+
const resolved = await raceAbort(this.client.sessionWait(this.session.id, budget), signal);
|
|
176
|
+
if (resolved === ABORTED) return "pending";
|
|
177
|
+
if (resolved !== null) return outcome(resolved);
|
|
178
|
+
// Still pending after this chunk: re-read to surface reviewer progress.
|
|
179
|
+
const current = await raceAbort(this.client.sessionGet(this.session.id), signal);
|
|
180
|
+
if (current === ABORTED) return "pending";
|
|
181
|
+
onProgress?.(current);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function outcome(session: ReviewSession): VerdictOutcome {
|
|
187
|
+
return { ...verdictResponse(session), session };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export interface AwaitResolveOptions {
|
|
191
|
+
/** Long-poll chunk length; the wait re-arms each chunk until resolved or aborted. Default 30s. */
|
|
192
|
+
pollMs?: number;
|
|
193
|
+
/** Abort the wait (the harness session shut down); resolves to null. */
|
|
194
|
+
signal?: AbortSignal;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Park until a review session resolves, then return the verdict outcome; null
|
|
199
|
+
* when the signal aborts first. Where ReviewHandle.awaitVerdict needs the handle
|
|
200
|
+
* that opened the review, this needs only a session id - so a background waiter
|
|
201
|
+
* that woke on a session it did not open (a detached Claude Code / Codex waiter,
|
|
202
|
+
* or pi's session_start listener) can collect the same verdict. This is the
|
|
203
|
+
* wake seam every non-blocking adapter builds on. Loops the daemon long-poll, so
|
|
204
|
+
* a verdict that lands between chunks is never missed and a session already
|
|
205
|
+
* resolved returns on the first chunk. The held connection also keeps the daemon
|
|
206
|
+
* off its idle-exit path for the whole wait.
|
|
207
|
+
*/
|
|
208
|
+
export async function awaitResolve(
|
|
209
|
+
client: DaemonClient,
|
|
210
|
+
sessionId: string,
|
|
211
|
+
options: AwaitResolveOptions = {},
|
|
212
|
+
): Promise<VerdictOutcome | null> {
|
|
213
|
+
const chunkMs = options.pollMs ?? 30_000;
|
|
214
|
+
const { signal } = options;
|
|
215
|
+
for (;;) {
|
|
216
|
+
if (signal?.aborted) return null;
|
|
217
|
+
const resolved = await raceAbort(client.sessionWait(sessionId, chunkMs), signal);
|
|
218
|
+
if (resolved === ABORTED) return null;
|
|
219
|
+
if (resolved !== null) return outcome(resolved);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Open a review session (or revise the agent session's existing one) and hand back the wait surface. */
|
|
224
|
+
export async function openReview(
|
|
225
|
+
client: DaemonClient,
|
|
226
|
+
options: OpenReviewOptions,
|
|
227
|
+
): Promise<ReviewHandle> {
|
|
228
|
+
const cwd = options.cwd ?? process.cwd();
|
|
229
|
+
const workspace = options.workspace ?? (await resolveWorkspace(cwd));
|
|
230
|
+
// Resubmits from the same agent session become revisions, not new sessions.
|
|
231
|
+
if (options.agentSessionId !== undefined) {
|
|
232
|
+
const existing = (await client.sessionList()).find(
|
|
233
|
+
(candidate) => candidate.artifact.meta.agentSessionId === options.agentSessionId,
|
|
234
|
+
);
|
|
235
|
+
if (existing !== undefined) {
|
|
236
|
+
let revised = await client.sessionSubmitRevision(existing.id, options.content);
|
|
237
|
+
if (options.notes?.length) {
|
|
238
|
+
await attachNotes(client, revised.id, options.notes);
|
|
239
|
+
revised = await client.sessionGet(revised.id);
|
|
240
|
+
}
|
|
241
|
+
return new ReviewHandle(client, revised);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
let session = await client.sessionCreate(workspace, {
|
|
245
|
+
type: options.type,
|
|
246
|
+
content: options.content,
|
|
247
|
+
files: options.files,
|
|
248
|
+
meta: {
|
|
249
|
+
agent: options.agent,
|
|
250
|
+
agentSessionId: options.agentSessionId,
|
|
251
|
+
planPath: options.planPath,
|
|
252
|
+
pr: options.pr,
|
|
253
|
+
herdrPane: options.herdrPane,
|
|
254
|
+
title: options.title ?? (options.type === "plan" ? firstHeading(options.content) : undefined),
|
|
255
|
+
cwd,
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
if (options.notes?.length) {
|
|
259
|
+
await attachNotes(client, session.id, options.notes);
|
|
260
|
+
session = await client.sessionGet(session.id);
|
|
261
|
+
}
|
|
262
|
+
return new ReviewHandle(client, session);
|
|
263
|
+
}
|
package/src/server.test.ts
CHANGED
|
@@ -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
|
-
|
|
32
|
-
|
|
33
|
-
await client.
|
|
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(
|
|
40
|
-
await client.sessionResolve(
|
|
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 (
|
|
51
|
-
expect((
|
|
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((
|
|
68
|
+
observer.onEvent((event) => seen.push(event.event));
|
|
59
69
|
await observer.subscribe();
|
|
60
|
-
|
|
61
|
-
|
|
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,150 @@ 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 (
|
|
75
|
-
expect((
|
|
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 (
|
|
82
|
-
expect((
|
|
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 (
|
|
89
|
-
expect((
|
|
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
|
|
93
|
-
expect((await client.sessionGet(
|
|
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
|
+
{
|
|
136
|
+
id: "a1",
|
|
137
|
+
kind: "comment",
|
|
138
|
+
anchor,
|
|
139
|
+
body: "should not overwrite",
|
|
140
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
id: "a2",
|
|
144
|
+
kind: "comment",
|
|
145
|
+
anchor,
|
|
146
|
+
body: "theirs",
|
|
147
|
+
author: "SHA256:mate",
|
|
148
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
149
|
+
},
|
|
150
|
+
],
|
|
151
|
+
participants: [{ id: "SHA256:mate", provider: "ssh", name: "Sam" }],
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// Then notes union by id (existing a1 kept), and the participant registry carries the collaborator's name
|
|
155
|
+
expect(merged.annotations.map((annotation) => annotation.id).sort()).toEqual(["a1", "a2"]);
|
|
156
|
+
expect(merged.annotations.find((annotation) => annotation.id === "a1")!.body).toBe("mine");
|
|
157
|
+
expect(merged.annotations.find((annotation) => annotation.id === "a2")!.author).toBe(
|
|
158
|
+
"SHA256:mate",
|
|
159
|
+
);
|
|
160
|
+
expect(merged.participants).toEqual([{ id: "SHA256:mate", provider: "ssh", name: "Sam" }]);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("mergeShared validates each annotation at the socket boundary", async () => {
|
|
164
|
+
// Given a session
|
|
165
|
+
const session = await client.sessionCreate(WS, PLAN);
|
|
166
|
+
try {
|
|
167
|
+
// When a malformed annotation is merged
|
|
168
|
+
// @ts-expect-error intentionally malformed annotation (empty id, missing fields)
|
|
169
|
+
await client.sessionMergeShared(session.id, { annotations: [{ id: "" }] });
|
|
170
|
+
throw new Error("should have thrown");
|
|
171
|
+
} catch (error) {
|
|
172
|
+
// Then it is rejected at the wire boundary
|
|
173
|
+
expect((error as DaemonClientError).code).toBe("invalid_params");
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("session.delete removes a session over the wire", async () => {
|
|
178
|
+
// Given a created session
|
|
179
|
+
const session = await client.sessionCreate(WS, PLAN);
|
|
180
|
+
|
|
181
|
+
// When it is deleted
|
|
182
|
+
await client.sessionDelete(session.id);
|
|
183
|
+
|
|
184
|
+
// Then it is gone, and deleting an unknown id is a not_found error
|
|
185
|
+
expect(client.sessionGet(session.id)).rejects.toBeInstanceOf(DaemonClientError);
|
|
186
|
+
try {
|
|
187
|
+
await client.sessionDelete("ses_missing");
|
|
188
|
+
throw new Error("should have thrown");
|
|
189
|
+
} catch (error) {
|
|
190
|
+
expect((error as DaemonClientError).code).toBe("not_found");
|
|
191
|
+
}
|
|
94
192
|
});
|
|
95
193
|
|
|
96
194
|
test("two clients see the same state (thin-renderer model)", async () => {
|
|
195
|
+
// Arrange
|
|
97
196
|
const second = await DaemonClient.connect({ home });
|
|
98
|
-
|
|
99
|
-
|
|
197
|
+
|
|
198
|
+
// Act
|
|
199
|
+
const session = await client.sessionCreate(WS, PLAN);
|
|
200
|
+
const fromSecond = await second.sessionGet(session.id);
|
|
201
|
+
|
|
202
|
+
// Assert
|
|
100
203
|
expect(fromSecond.artifact.content).toBe(PLAN.content);
|
|
101
204
|
second.close();
|
|
102
205
|
});
|
|
103
206
|
});
|
|
207
|
+
|
|
208
|
+
describe("large payloads survive socket backpressure", () => {
|
|
209
|
+
test("a session bigger than the kernel socket buffer round-trips", async () => {
|
|
210
|
+
// Regression: responses larger than one socket write used to truncate
|
|
211
|
+
// mid-line because the write return value was ignored, so the client
|
|
212
|
+
// never saw the frame's newline and every later request wedged too.
|
|
213
|
+
// Arrange
|
|
214
|
+
const bigContent =
|
|
215
|
+
"# Big plan\n" + "lorem ipsum dolor sit amet, consectetur adipiscing elit\n".repeat(4000);
|
|
216
|
+
|
|
217
|
+
// Act
|
|
218
|
+
const created = await client.sessionCreate(
|
|
219
|
+
{ repoRoot: "/repo", branch: "main" },
|
|
220
|
+
{ type: "plan", content: bigContent, meta: { title: "big" } },
|
|
221
|
+
);
|
|
222
|
+
const fetched = await client.sessionGet(created.id);
|
|
223
|
+
|
|
224
|
+
// Assert
|
|
225
|
+
expect(fetched.artifact.content).toBe(bigContent);
|
|
226
|
+
|
|
227
|
+
// Act
|
|
228
|
+
// the connection must still be usable for the next frame
|
|
229
|
+
const listed = await client.sessionList();
|
|
230
|
+
|
|
231
|
+
// Assert
|
|
232
|
+
expect(listed.some((session) => session.id === created.id)).toBe(true);
|
|
233
|
+
}, 15_000);
|
|
234
|
+
});
|