@cueloop/daemon 0.1.0-alpha.8 → 0.1.0-alpha.80
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 +34 -22
- package/src/api.test.ts +925 -65
- package/src/api.ts +1060 -88
- package/src/capabilities.test.ts +57 -0
- package/src/capabilities.ts +84 -0
- package/src/client.test.ts +24 -0
- package/src/client.ts +526 -70
- package/src/curate.ts +109 -0
- package/src/daemon-version.test.ts +93 -0
- package/src/diff-hot-reload.test.ts +272 -0
- package/src/diff-watcher.ts +274 -0
- package/src/gh.ts +37 -0
- package/src/harness-delivery.test.ts +193 -0
- package/src/harness-state-store.test.ts +65 -0
- package/src/harness-state-store.ts +182 -0
- package/src/herdr-pane.test.ts +240 -0
- package/src/herdr-pane.ts +167 -0
- package/src/herdr-split.test.ts +106 -0
- package/src/herdr-split.ts +92 -0
- package/src/herdr-tab-store.test.ts +96 -0
- package/src/herdr-tab-store.ts +66 -0
- package/src/index.ts +8 -9
- package/src/interruptible-wait.test.ts +127 -0
- package/src/interruptible-wait.ts +66 -0
- package/src/main.ts +4 -1
- package/src/paths.ts +44 -0
- package/src/pr-poller.test.ts +76 -0
- package/src/pr-poller.ts +74 -0
- package/src/project-files.test.ts +124 -0
- package/src/project-files.ts +56 -0
- package/src/protocol.ts +79 -7
- package/src/retention.test.ts +191 -0
- package/src/retention.ts +123 -0
- package/src/server.test.ts +430 -42
- package/src/server.ts +362 -84
- package/src/share-blob.test.ts +112 -0
- package/src/share-blob.ts +55 -0
- package/src/single-instance.test.ts +72 -11
- package/src/store.conformance.test.ts +41 -0
- package/src/store.jsonl.test.ts +159 -0
- package/src/store.ts +264 -34
- package/src/testing/store-conformance.ts +194 -0
- package/src/thread-review.test.ts +394 -0
- package/src/thread-review.ts +324 -0
- package/src/validate.test.ts +214 -35
- package/src/validate.ts +296 -25
- package/src/version.ts +6 -0
- package/src/working-tree.ts +149 -0
- package/src/review.test.ts +0 -167
- package/src/review.ts +0 -174
package/src/api.test.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import { mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
import { DaemonCore,
|
|
6
|
-
import {
|
|
7
|
-
import type
|
|
5
|
+
import { DaemonCore, messageResponse } from "./api";
|
|
6
|
+
import { ThreadStore } from "./store";
|
|
7
|
+
import { derivePath, tipOf, type Artifact, type WorkspaceKey } from "@cueloop/schema";
|
|
8
|
+
import { MAX_BLOB_BYTES, packSessionBlob, unpackSessionBlob } from "./share-blob";
|
|
8
9
|
|
|
9
10
|
const WS: WorkspaceKey = { repoRoot: "/repo", branch: "main" };
|
|
10
11
|
const PLAN: Artifact = {
|
|
@@ -24,120 +25,407 @@ afterEach(() => rmSync(home, { recursive: true, force: true }));
|
|
|
24
25
|
|
|
25
26
|
describe("session lifecycle", () => {
|
|
26
27
|
test("create → get → list", () => {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
// Act
|
|
29
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
30
|
+
|
|
31
|
+
// Assert
|
|
32
|
+
expect(session.status).toBe("pending");
|
|
33
|
+
expect(session.revisions.length).toBe(1);
|
|
34
|
+
expect(core.sessionGet(session.id).id).toBe(session.id);
|
|
31
35
|
expect(core.sessionList({ status: "pending" }).length).toBe(1);
|
|
32
36
|
});
|
|
33
37
|
|
|
34
38
|
test("annotate upserts by id; remove deletes", () => {
|
|
35
|
-
|
|
36
|
-
core.
|
|
39
|
+
// Arrange
|
|
40
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
41
|
+
|
|
42
|
+
// Act
|
|
43
|
+
core.sessionAnnotate(session.id, {
|
|
37
44
|
id: "a1",
|
|
38
45
|
kind: "comment",
|
|
39
46
|
anchor: { quote: "carefully", prefix: "the thing ", suffix: "." },
|
|
40
47
|
body: "How carefully?",
|
|
41
48
|
});
|
|
42
|
-
core.sessionAnnotate(
|
|
49
|
+
core.sessionAnnotate(session.id, {
|
|
43
50
|
id: "a1",
|
|
44
51
|
kind: "comment",
|
|
45
52
|
anchor: { quote: "carefully", prefix: "the thing ", suffix: "." },
|
|
46
53
|
body: "Define carefully.",
|
|
47
54
|
});
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
core.
|
|
51
|
-
expect(core.sessionGet(
|
|
55
|
+
|
|
56
|
+
// Assert
|
|
57
|
+
expect(core.sessionGet(session.id).annotations.length).toBe(1);
|
|
58
|
+
expect(core.sessionGet(session.id).annotations[0]!.body).toBe("Define carefully.");
|
|
59
|
+
|
|
60
|
+
// Act
|
|
61
|
+
core.sessionRemoveAnnotation(session.id, "a1");
|
|
62
|
+
|
|
63
|
+
// Assert
|
|
64
|
+
expect(core.sessionGet(session.id).annotations.length).toBe(0);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("a welcome-playground note is ephemeral: never persisted", () => {
|
|
68
|
+
// Arrange
|
|
69
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
70
|
+
|
|
71
|
+
// Act
|
|
72
|
+
core.sessionAnnotate(session.id, {
|
|
73
|
+
id: "w1",
|
|
74
|
+
kind: "comment",
|
|
75
|
+
anchor: { quote: "start", prefix: "", suffix: "" },
|
|
76
|
+
target: { kind: "welcome" },
|
|
77
|
+
body: "just trying it",
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Assert - the onboarding playground stores nothing
|
|
81
|
+
expect(core.sessionGet(session.id).annotations.length).toBe(0);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("annotate with an author registers them in the participant registry", () => {
|
|
85
|
+
// Arrange
|
|
86
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
87
|
+
|
|
88
|
+
// Act
|
|
89
|
+
core.sessionAnnotate(
|
|
90
|
+
session.id,
|
|
91
|
+
{
|
|
92
|
+
id: "a1",
|
|
93
|
+
kind: "comment",
|
|
94
|
+
anchor: { quote: "carefully", prefix: "the thing ", suffix: "." },
|
|
95
|
+
body: "A collaborator's note.",
|
|
96
|
+
author: "SHA256:ana",
|
|
97
|
+
},
|
|
98
|
+
"Ana",
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
// Assert
|
|
102
|
+
const stored = core.sessionGet(session.id);
|
|
103
|
+
|
|
104
|
+
expect(stored.annotations[0]!.author).toBe("SHA256:ana");
|
|
105
|
+
expect(stored.participants).toEqual([{ id: "SHA256:ana", provider: "ssh", name: "Ana" }]);
|
|
52
106
|
});
|
|
53
107
|
|
|
54
108
|
test("resolve produces feedback.md and maps to the agent contract", () => {
|
|
55
|
-
|
|
56
|
-
core.
|
|
109
|
+
// Arrange
|
|
110
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
111
|
+
|
|
112
|
+
core.sessionAnnotate(session.id, {
|
|
57
113
|
id: "a1",
|
|
58
114
|
kind: "comment",
|
|
59
115
|
anchor: { quote: "carefully", prefix: "the thing ", suffix: "." },
|
|
60
116
|
body: "Spell out the steps.",
|
|
61
117
|
});
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
118
|
+
|
|
119
|
+
// Act
|
|
120
|
+
const resolved = core.sessionSendMessage(session.id, "changes_requested", "Needs detail.");
|
|
121
|
+
|
|
122
|
+
// Assert
|
|
123
|
+
expect(resolved.message!.body).toContain("# Review: changes requested");
|
|
124
|
+
expect(resolved.message!.body).toContain("> carefully");
|
|
125
|
+
expect(messageResponse(resolved)).toEqual({
|
|
126
|
+
allow: false,
|
|
127
|
+
message: resolved.message!,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// Arrange
|
|
66
131
|
const approved = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
67
|
-
|
|
68
|
-
|
|
132
|
+
|
|
133
|
+
// Act
|
|
134
|
+
core.sessionSendMessage(approved.id, "approved", "LGTM");
|
|
135
|
+
|
|
136
|
+
// Assert
|
|
137
|
+
expect(messageResponse(core.sessionGet(approved.id)).allow).toBe(true);
|
|
69
138
|
});
|
|
70
139
|
|
|
71
140
|
test("mutating a resolved session throws", () => {
|
|
72
|
-
|
|
73
|
-
core.
|
|
141
|
+
// Arrange
|
|
142
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
143
|
+
|
|
144
|
+
core.sessionSendMessage(session.id, "approved", "");
|
|
145
|
+
|
|
146
|
+
// Assert
|
|
74
147
|
expect(() =>
|
|
75
|
-
core.sessionAnnotate(
|
|
148
|
+
core.sessionAnnotate(session.id, {
|
|
149
|
+
id: "a1",
|
|
150
|
+
kind: "comment",
|
|
151
|
+
anchor: { quote: "x", prefix: "", suffix: "" },
|
|
152
|
+
body: "b",
|
|
153
|
+
}),
|
|
76
154
|
).toThrow("already resolved");
|
|
77
155
|
});
|
|
78
156
|
|
|
79
157
|
test("working copy stores edits and clears on revert or no-op", () => {
|
|
80
|
-
|
|
81
|
-
core.
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
core.
|
|
91
|
-
|
|
158
|
+
// Arrange
|
|
159
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
160
|
+
|
|
161
|
+
// Act
|
|
162
|
+
core.sessionSetWorkingCopy(session.id, PLAN.content.replace("carefully", "very carefully"));
|
|
163
|
+
|
|
164
|
+
// Assert
|
|
165
|
+
expect(core.sessionGet(session.id).workingCopy).toContain("very carefully");
|
|
166
|
+
|
|
167
|
+
// Act
|
|
168
|
+
core.sessionSetWorkingCopy(session.id, PLAN.content);
|
|
169
|
+
|
|
170
|
+
// Assert
|
|
171
|
+
expect(core.sessionGet(session.id).workingCopy).toBeUndefined();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("a thread rename sets the title, clears back to the default on empty, and survives a restart", () => {
|
|
175
|
+
// Arrange
|
|
176
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
177
|
+
|
|
178
|
+
// Act
|
|
179
|
+
core.sessionSetTitle(session.id, " Renamed thread ");
|
|
180
|
+
|
|
181
|
+
// Assert - trimmed and persisted
|
|
182
|
+
expect(core.sessionGet(session.id).artifact.meta.title).toBe("Renamed thread");
|
|
183
|
+
expect(new DaemonCore(home).sessionGet(session.id).artifact.meta.title).toBe("Renamed thread");
|
|
184
|
+
|
|
185
|
+
// Act - an empty title clears back to the derived default
|
|
186
|
+
core.sessionSetTitle(session.id, " ");
|
|
187
|
+
|
|
188
|
+
// Assert
|
|
189
|
+
expect(core.sessionGet(session.id).artifact.meta.title).toBeUndefined();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("viewed paths merge, dedupe, clear on empty, and survive a daemon restart", () => {
|
|
193
|
+
// Arrange
|
|
194
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
195
|
+
|
|
196
|
+
// Act
|
|
197
|
+
core.sessionSetViewed(session.id, ["src/a.ts", "src/b.ts", "src/a.ts"]);
|
|
198
|
+
|
|
199
|
+
// Assert
|
|
200
|
+
expect(core.sessionGet(session.id).viewedPaths).toEqual(["src/a.ts", "src/b.ts"]);
|
|
201
|
+
|
|
202
|
+
// Act
|
|
203
|
+
// merge-additive: a stale client sending only its own new mark loses nothing
|
|
204
|
+
core.sessionSetViewed(session.id, ["src/c.ts"]);
|
|
205
|
+
|
|
206
|
+
// Assert
|
|
207
|
+
expect(core.sessionGet(session.id).viewedPaths).toEqual(["src/a.ts", "src/b.ts", "src/c.ts"]);
|
|
208
|
+
// a resumed review reads its progress back after a daemon restart
|
|
209
|
+
const reborn = new DaemonCore(home);
|
|
210
|
+
|
|
211
|
+
expect(reborn.sessionGet(session.id).viewedPaths).toEqual(["src/a.ts", "src/b.ts", "src/c.ts"]);
|
|
212
|
+
|
|
213
|
+
// Act
|
|
214
|
+
core.sessionSetViewed(session.id, []);
|
|
215
|
+
|
|
216
|
+
// Assert
|
|
217
|
+
expect(core.sessionGet(session.id).viewedPaths).toBeUndefined();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("revision reopens the session and resets working copy + message", () => {
|
|
221
|
+
// Arrange
|
|
222
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
223
|
+
|
|
224
|
+
core.sessionSetWorkingCopy(session.id, PLAN.content + "\nedit");
|
|
225
|
+
core.sessionSendMessage(session.id, "changes_requested", "redo");
|
|
226
|
+
|
|
227
|
+
// Act
|
|
228
|
+
const revised = core.sessionSubmitRevision(session.id, "# Plan v2\n");
|
|
229
|
+
|
|
230
|
+
// Assert
|
|
92
231
|
expect(revised.status).toBe("pending");
|
|
93
|
-
expect(revised.
|
|
232
|
+
expect(revised.message).toBeNull();
|
|
94
233
|
expect(revised.workingCopy).toBeUndefined();
|
|
95
234
|
expect(revised.revisions.length).toBe(2);
|
|
96
235
|
expect(revised.artifact.content).toBe("# Plan v2\n");
|
|
97
236
|
});
|
|
98
237
|
});
|
|
99
238
|
|
|
100
|
-
describe("
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
239
|
+
describe("revision marks addressed annotations", () => {
|
|
240
|
+
const annotate = (sessionId: string, id: string, quote: string, body = "note") =>
|
|
241
|
+
core.sessionAnnotate(sessionId, {
|
|
242
|
+
id,
|
|
243
|
+
kind: "comment",
|
|
244
|
+
anchor: { quote, prefix: "", suffix: "" },
|
|
245
|
+
body,
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("ids the agent reports are marked addressed by that revision", () => {
|
|
249
|
+
// Arrange
|
|
250
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
251
|
+
|
|
252
|
+
annotate(session.id, "a1", "carefully");
|
|
253
|
+
annotate(session.id, "a2", "Context");
|
|
254
|
+
|
|
255
|
+
// Act: the revision keeps both quoted texts, so only the reported id resolves
|
|
256
|
+
const revised = core.sessionSubmitRevision(session.id, PLAN.content + "\nMore.\n", ["a1"]);
|
|
257
|
+
|
|
258
|
+
// Assert
|
|
259
|
+
const [first, second] = revised.annotations;
|
|
260
|
+
|
|
261
|
+
expect(first!.resolution).toEqual({ revision: 2, source: "agent" });
|
|
262
|
+
expect(second!.resolution).toBeUndefined();
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("a reported root comment addresses its replies as well", () => {
|
|
266
|
+
// Arrange: a discussion of a root and one reply, plus an unrelated comment
|
|
267
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
268
|
+
|
|
269
|
+
annotate(session.id, "root", "carefully");
|
|
270
|
+
core.sessionAnnotate(session.id, {
|
|
271
|
+
id: "reply",
|
|
272
|
+
kind: "comment",
|
|
273
|
+
anchor: { quote: "carefully", prefix: "", suffix: "" },
|
|
274
|
+
body: "agreed",
|
|
275
|
+
replyTo: "root",
|
|
276
|
+
});
|
|
277
|
+
annotate(session.id, "other", "Context");
|
|
278
|
+
|
|
279
|
+
// Act: the agent reports only the root
|
|
280
|
+
const revised = core.sessionSubmitRevision(session.id, PLAN.content + "\nMore.\n", ["root"]);
|
|
281
|
+
const byId = new Map(revised.annotations.map((annotation) => [annotation.id, annotation]));
|
|
282
|
+
|
|
283
|
+
// Assert
|
|
284
|
+
expect(byId.get("root")!.resolution).toEqual({ revision: 2, source: "agent" });
|
|
285
|
+
expect(byId.get("reply")!.resolution).toEqual({ revision: 2, source: "agent" });
|
|
286
|
+
expect(byId.get("other")!.resolution).toBeUndefined();
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test("a plan annotation whose quoted text vanished is drift-addressed; a surviving quote stays open", () => {
|
|
290
|
+
// Arrange
|
|
291
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
292
|
+
|
|
293
|
+
annotate(session.id, "gone", "carefully");
|
|
294
|
+
annotate(session.id, "kept", "Context");
|
|
295
|
+
|
|
296
|
+
// Act: the revision rewrites the "carefully" sentence but keeps the Context heading
|
|
297
|
+
const revised = core.sessionSubmitRevision(
|
|
298
|
+
session.id,
|
|
299
|
+
"# Plan\n\n## Context\n\nDo the thing with tests.\n",
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
// Assert
|
|
303
|
+
expect(revised.annotations.find((a) => a.id === "gone")!.resolution).toEqual({
|
|
304
|
+
revision: 2,
|
|
305
|
+
source: "drift",
|
|
306
|
+
});
|
|
307
|
+
expect(revised.annotations.find((a) => a.id === "kept")!.resolution).toBeUndefined();
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("an unknown reported id is ignored, and an already-addressed annotation keeps its first resolution", () => {
|
|
311
|
+
// Arrange
|
|
312
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
313
|
+
|
|
314
|
+
annotate(session.id, "a1", "carefully");
|
|
315
|
+
core.sessionSubmitRevision(session.id, PLAN.content, ["a1"]);
|
|
316
|
+
|
|
317
|
+
// Act: a second revision reports a stale id and re-reports the settled one
|
|
318
|
+
const revised = core.sessionSubmitRevision(session.id, PLAN.content, ["a1", "a_never_existed"]);
|
|
319
|
+
|
|
320
|
+
// Assert
|
|
321
|
+
expect(revised.annotations[0]!.resolution).toEqual({ revision: 2, source: "agent" });
|
|
322
|
+
expect(revised.revisions.length).toBe(3);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test("a diff revision never drift-addresses - a new patch says nothing about the feedback", () => {
|
|
326
|
+
// Arrange
|
|
327
|
+
const diffArtifact: Artifact = { type: "diff", content: "+++ b/a.ts\n+new line\n", meta: {} };
|
|
328
|
+
const session = core.sessionCreate({ workspace: WS, artifact: diffArtifact });
|
|
329
|
+
|
|
330
|
+
annotate(session.id, "d1", "new line");
|
|
331
|
+
|
|
332
|
+
// Act: the revised patch no longer contains the quoted text
|
|
333
|
+
const revised = core.sessionSubmitRevision(session.id, "+++ b/b.ts\n+other\n");
|
|
334
|
+
|
|
335
|
+
// Assert
|
|
336
|
+
expect(revised.annotations[0]!.resolution).toBeUndefined();
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test("the next feedback document omits addressed annotations and teaches the addressed-ids call", () => {
|
|
340
|
+
// Arrange
|
|
341
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
342
|
+
|
|
343
|
+
annotate(session.id, "settled", "carefully");
|
|
344
|
+
annotate(session.id, "open", "Context", "needs a diagram");
|
|
345
|
+
core.sessionSubmitRevision(session.id, PLAN.content, ["settled"]);
|
|
346
|
+
|
|
347
|
+
// Act
|
|
348
|
+
const resolved = core.sessionSendMessage(session.id, "changes_requested", "one left");
|
|
349
|
+
|
|
350
|
+
// Assert
|
|
351
|
+
expect(resolved.message!.body).not.toContain("settled");
|
|
352
|
+
expect(resolved.message!.body).toContain("annotation id: `open`");
|
|
353
|
+
expect(resolved.message!.body).toContain(`submit-revision ${session.id}`);
|
|
354
|
+
expect(resolved.message!.body).toContain("--addressed");
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
describe("the wait contract: messages outlive waits", () => {
|
|
359
|
+
test("wait resolves when the message arrives", async () => {
|
|
360
|
+
// Arrange
|
|
361
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
362
|
+
const wait = core.sessionWait(session.id, 5_000);
|
|
363
|
+
|
|
364
|
+
// Act
|
|
365
|
+
core.sessionSendMessage(session.id, "approved", "");
|
|
105
366
|
const resolved = await wait;
|
|
106
|
-
|
|
367
|
+
|
|
368
|
+
// Assert
|
|
369
|
+
expect(resolved!.message!.outcome).toBe("approved");
|
|
107
370
|
});
|
|
108
371
|
|
|
109
|
-
test("wait times out to null; a later wait collects the stored
|
|
110
|
-
|
|
111
|
-
const
|
|
372
|
+
test("wait times out to null; a later wait collects the stored message", async () => {
|
|
373
|
+
// Arrange
|
|
374
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
375
|
+
|
|
376
|
+
// Act
|
|
377
|
+
const first = await core.sessionWait(session.id, 30);
|
|
378
|
+
|
|
379
|
+
// Assert
|
|
112
380
|
expect(first).toBeNull();
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
381
|
+
|
|
382
|
+
// Act
|
|
383
|
+
core.sessionSendMessage(session.id, "changes_requested", "later");
|
|
384
|
+
const second = await core.sessionWait(session.id, 30);
|
|
385
|
+
|
|
386
|
+
// Assert
|
|
387
|
+
expect(second!.message!.summary).toBe("later");
|
|
116
388
|
});
|
|
117
389
|
|
|
118
390
|
test("multiple waiters all resolve", async () => {
|
|
119
|
-
|
|
120
|
-
const
|
|
121
|
-
core.
|
|
391
|
+
// Arrange
|
|
392
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
393
|
+
const waits = [core.sessionWait(session.id, 5_000), core.sessionWait(session.id, 5_000)];
|
|
394
|
+
|
|
395
|
+
// Act
|
|
396
|
+
core.sessionSendMessage(session.id, "approved", "");
|
|
122
397
|
const results = await Promise.all(waits);
|
|
123
|
-
|
|
398
|
+
|
|
399
|
+
// Assert
|
|
400
|
+
expect(results.every((result) => result?.status === "resolved")).toBe(true);
|
|
124
401
|
});
|
|
125
402
|
});
|
|
126
403
|
|
|
127
404
|
describe("persistence and recovery", () => {
|
|
128
405
|
test("sessions survive a daemon restart", () => {
|
|
129
|
-
|
|
130
|
-
core.
|
|
406
|
+
// Arrange
|
|
407
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
408
|
+
|
|
409
|
+
core.sessionSendMessage(session.id, "approved", "done");
|
|
410
|
+
|
|
411
|
+
// Act
|
|
131
412
|
const reborn = new DaemonCore(home);
|
|
132
|
-
const recovered = reborn.sessionGet(
|
|
133
|
-
|
|
413
|
+
const recovered = reborn.sessionGet(session.id);
|
|
414
|
+
|
|
415
|
+
// Assert
|
|
416
|
+
expect(recovered.message!.summary).toBe("done");
|
|
134
417
|
});
|
|
135
418
|
|
|
136
419
|
test("corrupt records are skipped, not fatal, and never deleted", async () => {
|
|
420
|
+
// Arrange
|
|
137
421
|
core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
138
422
|
await Bun.write(join(home, "sessions", "broken.json"), "{ not json");
|
|
139
|
-
const store = new
|
|
423
|
+
const store = new ThreadStore(home);
|
|
424
|
+
|
|
425
|
+
// Act
|
|
140
426
|
const report = store.recover();
|
|
427
|
+
|
|
428
|
+
// Assert
|
|
141
429
|
expect(report.recovered.length).toBe(1);
|
|
142
430
|
expect(report.skipped.length).toBe(1);
|
|
143
431
|
expect(await Bun.file(join(home, "sessions", "broken.json")).exists()).toBe(true);
|
|
@@ -146,11 +434,583 @@ describe("persistence and recovery", () => {
|
|
|
146
434
|
|
|
147
435
|
describe("events", () => {
|
|
148
436
|
test("lifecycle events fire in order", () => {
|
|
437
|
+
// Arrange
|
|
149
438
|
const seen: string[] = [];
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
439
|
+
|
|
440
|
+
core.onEvent((event) => seen.push(event.event));
|
|
441
|
+
|
|
442
|
+
// Act
|
|
443
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
444
|
+
|
|
445
|
+
core.sessionAnnotate(session.id, {
|
|
446
|
+
id: "a1",
|
|
447
|
+
kind: "comment",
|
|
448
|
+
anchor: { quote: "thing", prefix: "", suffix: "" },
|
|
449
|
+
body: "b",
|
|
450
|
+
});
|
|
451
|
+
core.sessionSendMessage(session.id, "approved", "");
|
|
452
|
+
|
|
453
|
+
// Assert
|
|
454
|
+
expect(seen).toEqual([
|
|
455
|
+
"session.created",
|
|
456
|
+
"inbox.changed",
|
|
457
|
+
"session.updated",
|
|
458
|
+
"message.sent",
|
|
459
|
+
"inbox.changed",
|
|
460
|
+
]);
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
describe("the session history records every write as an entry", () => {
|
|
465
|
+
const annotate = (sessionId: string, id: string, quote: string, body = "note") =>
|
|
466
|
+
core.sessionAnnotate(sessionId, {
|
|
467
|
+
id,
|
|
468
|
+
kind: "comment",
|
|
469
|
+
anchor: { quote, prefix: "", suffix: "" },
|
|
470
|
+
body,
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
test("create, comment, remove, resolve, and revise append in order on main", () => {
|
|
474
|
+
// Arrange
|
|
475
|
+
const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
476
|
+
|
|
477
|
+
// Assert: a fresh session is one root revision
|
|
478
|
+
expect(created.history?.entries.map((entry) => entry.type)).toEqual(["revision"]);
|
|
479
|
+
expect(created.history?.tips.main).toBe(created.history?.entries[0]!.id);
|
|
480
|
+
|
|
481
|
+
// Act
|
|
482
|
+
annotate(created.id, "a1", "carefully");
|
|
483
|
+
annotate(created.id, "a1", "carefully", "edited body");
|
|
484
|
+
annotate(created.id, "a2", "Context");
|
|
485
|
+
core.sessionRemoveAnnotation(created.id, "a2");
|
|
486
|
+
core.sessionRemoveAnnotation(created.id, "never-existed");
|
|
487
|
+
core.sessionSendMessage(created.id, "changes_requested", "tighten");
|
|
488
|
+
const revised = core.sessionSubmitRevision(created.id, PLAN.content + "\nMore.\n", ["a1"]);
|
|
489
|
+
|
|
490
|
+
// Assert: an edit of an existing comment and a removal of an unknown id leave no entry
|
|
491
|
+
const history = revised.history!;
|
|
492
|
+
|
|
493
|
+
expect(history.entries.map((entry) => entry.type)).toEqual([
|
|
494
|
+
"revision",
|
|
495
|
+
"comment",
|
|
496
|
+
"comment",
|
|
497
|
+
"comment-removed",
|
|
498
|
+
"message",
|
|
499
|
+
"revision",
|
|
500
|
+
]);
|
|
501
|
+
expect(history.branch).toBe("main");
|
|
502
|
+
expect(history.tips.main).toBe(history.entries.at(-1)!.id);
|
|
503
|
+
// every entry chains on the one before
|
|
504
|
+
history.entries.forEach((entry, index) => {
|
|
505
|
+
expect(entry.parentId).toBe(index === 0 ? null : history.entries[index - 1]!.id);
|
|
506
|
+
});
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
test("the head of the current path is the artifact, and the open comments are the path's", () => {
|
|
510
|
+
// Arrange
|
|
511
|
+
const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
512
|
+
|
|
513
|
+
annotate(created.id, "a1", "carefully");
|
|
514
|
+
annotate(created.id, "a2", "Context");
|
|
515
|
+
core.sessionRemoveAnnotation(created.id, "a1");
|
|
516
|
+
const revised = core.sessionSubmitRevision(created.id, "# Plan v2\n");
|
|
517
|
+
|
|
518
|
+
// Act
|
|
519
|
+
const derived = derivePath(revised.history!);
|
|
520
|
+
|
|
521
|
+
// Assert
|
|
522
|
+
expect(derived.head.content).toBe(revised.artifact.content);
|
|
523
|
+
expect(derived.annotationIds).toEqual(["a2"]);
|
|
524
|
+
expect(derived.rounds).toBe(2);
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
test("a shared session's incoming comments become entries, and the blob round-trips with its history", () => {
|
|
528
|
+
// Arrange
|
|
529
|
+
const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
530
|
+
|
|
531
|
+
// Act
|
|
532
|
+
const merged = core.sessionMergeShared(created.id, {
|
|
533
|
+
annotations: [
|
|
534
|
+
{
|
|
535
|
+
id: "ana_1",
|
|
536
|
+
kind: "comment",
|
|
537
|
+
anchor: { quote: "Context", prefix: "", suffix: "" },
|
|
538
|
+
body: "from ana",
|
|
539
|
+
author: "SHA256:ana",
|
|
540
|
+
createdAt: "2026-09-01T10:00:00.000Z",
|
|
541
|
+
},
|
|
542
|
+
],
|
|
543
|
+
});
|
|
544
|
+
const roundTripped = unpackSessionBlob(packSessionBlob(merged));
|
|
545
|
+
|
|
546
|
+
// Assert
|
|
547
|
+
expect(merged.history?.entries.at(-1)).toMatchObject({
|
|
548
|
+
type: "comment",
|
|
549
|
+
annotationId: "ana_1",
|
|
550
|
+
});
|
|
551
|
+
expect(roundTripped.history).toEqual(merged.history);
|
|
552
|
+
expect(packSessionBlob(merged).byteLength).toBeLessThan(MAX_BLOB_BYTES);
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
test("a session written before histories existed gets one on the next boot", () => {
|
|
556
|
+
// Arrange: a record with no history on disk, as an earlier daemon left it
|
|
557
|
+
const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
558
|
+
const legacy = { ...created };
|
|
559
|
+
|
|
560
|
+
delete legacy.history;
|
|
561
|
+
new ThreadStore(home).upsert(legacy);
|
|
562
|
+
|
|
563
|
+
// Act
|
|
564
|
+
const rebooted = new DaemonCore(home);
|
|
565
|
+
const recovered = rebooted.sessionGet(created.id);
|
|
566
|
+
|
|
567
|
+
// Assert
|
|
568
|
+
expect(recovered.history?.entries.map((entry) => entry.type)).toEqual(["revision"]);
|
|
569
|
+
expect(derivePath(recovered.history!).head.content).toBe(PLAN.content);
|
|
570
|
+
});
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
describe("curation primitives", () => {
|
|
574
|
+
test("cutting a block records a reviewer revision; restoring it returns the copy to pristine", () => {
|
|
575
|
+
// Arrange: block 2 of the plan is the paragraph
|
|
576
|
+
const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
577
|
+
|
|
578
|
+
// Act
|
|
579
|
+
const cut = core.sessionCutBlock(created.id, 2);
|
|
580
|
+
|
|
581
|
+
// Assert: the working copy lost the paragraph and the history gained the edit
|
|
582
|
+
expect(cut.workingCopy).not.toContain("Do the thing carefully.");
|
|
583
|
+
expect(cut.history!.entries.at(-1)).toMatchObject({ type: "revision", by: "reviewer" });
|
|
584
|
+
expect(derivePath(cut.history!).head.content).toBe(cut.workingCopy!);
|
|
585
|
+
|
|
586
|
+
// Act: put it back where it was (block 2 of the submitted revision, before the end)
|
|
587
|
+
const restored = core.sessionRestoreBlock(created.id, 2);
|
|
588
|
+
|
|
589
|
+
// Assert: pristine again, and that edit is on record too
|
|
590
|
+
expect(restored.workingCopy).toBeUndefined();
|
|
591
|
+
expect(derivePath(restored.history!).head.content).toBe(PLAN.content);
|
|
592
|
+
expect(restored.history!.entries.filter((entry) => entry.type === "revision")).toHaveLength(3);
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
test("a block index outside the text is refused", () => {
|
|
596
|
+
// Arrange
|
|
597
|
+
const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
598
|
+
|
|
599
|
+
// Assert
|
|
600
|
+
expect(() => core.sessionCutBlock(created.id, 99)).toThrow(/no block 99/);
|
|
601
|
+
expect(() => core.sessionRestoreBlock(created.id, 99)).toThrow(/no block 99/);
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
test("restoring a block that is present is refused rather than duplicated", () => {
|
|
605
|
+
// Arrange: nothing cut, then block 2 cut once
|
|
606
|
+
const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
607
|
+
|
|
608
|
+
// Assert: a pristine copy has every block
|
|
609
|
+
expect(() => core.sessionRestoreBlock(created.id, 2)).toThrow(/present in the working copy/);
|
|
610
|
+
|
|
611
|
+
// Act
|
|
612
|
+
core.sessionCutBlock(created.id, 2);
|
|
613
|
+
const restored = core.sessionRestoreBlock(created.id, 2);
|
|
614
|
+
|
|
615
|
+
// Assert: one restore puts it back; a second would duplicate it
|
|
616
|
+
expect(restored.workingCopy).toBeUndefined();
|
|
617
|
+
expect(() => core.sessionRestoreBlock(created.id, 2)).toThrow(/present in the working copy/);
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
test("curating a diff stores the decisions and the patch they leave; no decisions clear both", () => {
|
|
621
|
+
// Arrange: one file with two separated changes
|
|
622
|
+
const oldContents = "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\n";
|
|
623
|
+
const newContents = "a\nB\nc\nd\ne\nf\ng\nh\ni\nJ\n";
|
|
624
|
+
const diff: Artifact = {
|
|
625
|
+
type: "diff",
|
|
626
|
+
content: "",
|
|
627
|
+
meta: {},
|
|
628
|
+
files: [{ path: "src/x.txt", oldContents, newContents, status: "modified" }],
|
|
629
|
+
};
|
|
630
|
+
const created = core.sessionCreate({ workspace: WS, artifact: diff });
|
|
631
|
+
|
|
632
|
+
// Act: reject the first hunk
|
|
633
|
+
const curated = core.sessionCurate(created.id, [{ path: "src/x.txt", hunkIndex: 0 }]);
|
|
634
|
+
|
|
635
|
+
// Assert: only the second change survives in the working copy
|
|
636
|
+
expect(curated.curation).toEqual([{ path: "src/x.txt", hunkIndex: 0 }]);
|
|
637
|
+
expect(curated.workingCopy).toContain("+J");
|
|
638
|
+
expect(curated.workingCopy).not.toContain("+B");
|
|
639
|
+
expect(curated.history!.entries.at(-1)).toMatchObject({ type: "revision", by: "reviewer" });
|
|
640
|
+
|
|
641
|
+
// Act
|
|
642
|
+
const cleared = core.sessionCurate(created.id, []);
|
|
643
|
+
|
|
644
|
+
// Assert
|
|
645
|
+
expect(cleared.curation).toBeUndefined();
|
|
646
|
+
expect(cleared.workingCopy).toBeUndefined();
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
test("a plan cannot be curated by hunk", () => {
|
|
650
|
+
// Arrange
|
|
651
|
+
const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
652
|
+
|
|
653
|
+
// Assert
|
|
654
|
+
expect(() => core.sessionCurate(created.id, [])).toThrow(/only a diff review/);
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
test("rejects hunk curation after a patch-only diff revision clears file snapshots", () => {
|
|
658
|
+
const created = core.sessionCreate({
|
|
659
|
+
workspace: WS,
|
|
660
|
+
artifact: {
|
|
661
|
+
type: "diff",
|
|
662
|
+
content: "diff --git a/a.ts b/a.ts\n",
|
|
663
|
+
meta: {},
|
|
664
|
+
files: [{ path: "a.ts", status: "modified", oldContents: "old", newContents: "new" }],
|
|
665
|
+
},
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
core.sessionSubmitRevision(created.id, "diff --git a/b.ts b/b.ts\n", [], []);
|
|
669
|
+
|
|
670
|
+
expect(() => core.sessionCurate(created.id, [{ path: "b.ts", hunkIndex: 0 }])).toThrow(
|
|
671
|
+
/hunk curation needs full file contents/,
|
|
672
|
+
);
|
|
673
|
+
expect(core.sessionGet(created.id).workingCopy).toBeUndefined();
|
|
674
|
+
});
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
describe("tree primitives", () => {
|
|
678
|
+
const comment = (id: string, body: string) => ({
|
|
679
|
+
id,
|
|
680
|
+
kind: "comment" as const,
|
|
681
|
+
anchor: { quote: "carefully", prefix: "the thing ", suffix: "." },
|
|
682
|
+
body,
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
test("navigating main back hides later comments and edits, and the agent's next revision lands there", () => {
|
|
686
|
+
// Arrange: comment, reviewer edit, second comment
|
|
687
|
+
const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
688
|
+
|
|
689
|
+
core.sessionAnnotate(created.id, comment("a1", "first"));
|
|
690
|
+
const checkpoint = derivePath(core.sessionGet(created.id).history!);
|
|
691
|
+
const checkpointEntry = tipOf(core.sessionGet(created.id).history!);
|
|
692
|
+
|
|
693
|
+
core.sessionLabel(created.id, "after a1");
|
|
694
|
+
core.sessionSetWorkingCopy(created.id, "# Plan\n\n## Context\n\nDo it.\n");
|
|
695
|
+
core.sessionAnnotate(created.id, comment("a2", "second"));
|
|
696
|
+
|
|
697
|
+
// Act
|
|
698
|
+
const moved = core.sessionNavigate(created.id, checkpointEntry, "dropped the edit");
|
|
699
|
+
|
|
700
|
+
// Assert: the view is the checkpoint's, the abandoned segment is on record
|
|
701
|
+
expect(moved.workingCopy).toBeUndefined();
|
|
702
|
+
expect(moved.annotations.map((annotation) => annotation.id)).toEqual(["a1"]);
|
|
703
|
+
expect(moved.shelvedAnnotations!.map((annotation) => annotation.id)).toEqual(["a2"]);
|
|
704
|
+
expect(moved.history!.labels[checkpointEntry]).toBe("after a1");
|
|
705
|
+
expect(derivePath(moved.history!).summaries[0]!.abandoned).toHaveLength(2);
|
|
706
|
+
expect(checkpoint.annotationIds).toEqual(["a1"]);
|
|
707
|
+
|
|
708
|
+
// Act: the agent resubmits
|
|
709
|
+
const revised = core.sessionSubmitRevision(created.id, "# Plan\n\nRevised.\n");
|
|
710
|
+
|
|
711
|
+
// Assert: the revision chains after the summary on main
|
|
712
|
+
const path = derivePath(revised.history!);
|
|
713
|
+
|
|
714
|
+
expect(path.head.content).toBe("# Plan\n\nRevised.\n");
|
|
715
|
+
expect(revised.history!.entries.at(-1)!.parentId).toBe(
|
|
716
|
+
derivePath(moved.history!).summaries[0]!.id,
|
|
717
|
+
);
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
test("the feedback document renders from the tip's path", () => {
|
|
721
|
+
// Arrange: two comments, then main moved back between them
|
|
722
|
+
const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
723
|
+
|
|
724
|
+
core.sessionAnnotate(created.id, comment("a1", "keep this one"));
|
|
725
|
+
const between = tipOf(core.sessionGet(created.id).history!);
|
|
726
|
+
|
|
727
|
+
core.sessionAnnotate(created.id, comment("a2", "not this one"));
|
|
728
|
+
core.sessionNavigate(created.id, between);
|
|
729
|
+
|
|
730
|
+
// Act
|
|
731
|
+
const resolved = core.sessionSendMessage(created.id, "changes_requested", "one note");
|
|
732
|
+
|
|
733
|
+
// Assert
|
|
734
|
+
expect(resolved.message!.body).toContain("keep this one");
|
|
735
|
+
expect(resolved.message!.body).not.toContain("not this one");
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
test("a navigate names the branch to stand on, so a move on another branch is one request", () => {
|
|
739
|
+
// Arrange: alt has a comment; the owner stands on main
|
|
740
|
+
const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
741
|
+
const revision = tipOf(created.history!);
|
|
742
|
+
|
|
743
|
+
core.sessionBranch(created.id, "alt");
|
|
744
|
+
core.sessionAnnotate(created.id, comment("a1", "on alt"));
|
|
745
|
+
core.sessionSwitch(created.id, "main");
|
|
746
|
+
|
|
747
|
+
// Act
|
|
748
|
+
const moved = core.sessionNavigate(created.id, revision, undefined, "alt");
|
|
749
|
+
|
|
750
|
+
// Assert: on alt, back at the revision, the comment shelved
|
|
751
|
+
expect(moved.history!.branch).toBe("alt");
|
|
752
|
+
expect(tipOf(moved.history!)).toBe(revision);
|
|
753
|
+
expect(moved.shelvedAnnotations!.map((annotation) => annotation.id)).toEqual(["a1"]);
|
|
754
|
+
expect(() => core.sessionNavigate(created.id, revision, undefined, "nope")).toThrow(
|
|
755
|
+
/no branch/,
|
|
756
|
+
);
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
test("a navigate to the tip or off the path is refused", () => {
|
|
760
|
+
// Arrange
|
|
761
|
+
const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
762
|
+
const tip = tipOf(created.history!);
|
|
763
|
+
|
|
764
|
+
core.sessionBranch(created.id, "alt");
|
|
765
|
+
core.sessionAnnotate(created.id, comment("a1", "on alt"));
|
|
766
|
+
const onAlt = tipOf(core.sessionGet(created.id).history!, "alt");
|
|
767
|
+
|
|
768
|
+
core.sessionSwitch(created.id, "main");
|
|
769
|
+
|
|
770
|
+
// Assert
|
|
771
|
+
expect(() => core.sessionNavigate(created.id, tip)).toThrow(/already at/);
|
|
772
|
+
expect(() => core.sessionNavigate(created.id, onAlt)).toThrow(/not on branch "main"/);
|
|
773
|
+
expect(() => core.sessionNavigate(created.id, "e_nope")).toThrow(/no entry/);
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
test("branching keeps main where it is; switching shows each branch's own comments", () => {
|
|
777
|
+
// Arrange
|
|
778
|
+
const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
779
|
+
|
|
780
|
+
core.sessionAnnotate(created.id, comment("shared", "on both"));
|
|
781
|
+
|
|
782
|
+
// Act
|
|
783
|
+
core.sessionBranch(created.id, "alt");
|
|
784
|
+
core.sessionAnnotate(created.id, comment("alt-only", "on alt"));
|
|
785
|
+
const onAlt = core
|
|
786
|
+
.sessionSwitch(created.id, "alt")
|
|
787
|
+
.annotations.map((annotation) => annotation.id);
|
|
788
|
+
const onMain = core.sessionSwitch(created.id, "main");
|
|
789
|
+
|
|
790
|
+
// Assert
|
|
791
|
+
expect(onAlt).toEqual(["shared", "alt-only"]);
|
|
792
|
+
expect(onMain.annotations.map((annotation) => annotation.id)).toEqual(["shared"]);
|
|
793
|
+
expect(onMain.shelvedAnnotations!.map((annotation) => annotation.id)).toEqual(["alt-only"]);
|
|
794
|
+
expect(() => core.sessionBranch(created.id, "alt")).toThrow(/exists/);
|
|
795
|
+
expect(() => core.sessionSwitch(created.id, "nope")).toThrow(/no branch/);
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
test("a removed comment is shelved, and a share merge does not bring it back", () => {
|
|
799
|
+
// Arrange
|
|
800
|
+
const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
801
|
+
|
|
802
|
+
core.sessionAnnotate(created.id, comment("a1", "first"));
|
|
803
|
+
const removed = core.sessionRemoveAnnotation(created.id, "a1");
|
|
804
|
+
|
|
805
|
+
// Act
|
|
806
|
+
const merged = core.sessionMergeShared(created.id, {
|
|
807
|
+
annotations: [{ ...comment("a1", "first"), createdAt: "2026-01-01T00:00:00.000Z" }],
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
// Assert
|
|
811
|
+
expect(removed.shelvedAnnotations!.map((annotation) => annotation.id)).toEqual(["a1"]);
|
|
812
|
+
expect(merged.annotations).toEqual([]);
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
test("a share's removal entry hides the comment for the owner, once, by entry id", () => {
|
|
816
|
+
// Arrange: a collaborator's note pulled in earlier
|
|
817
|
+
const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
818
|
+
const note = {
|
|
819
|
+
...comment("c1", "from a teammate"),
|
|
820
|
+
author: "SHA256:collab",
|
|
821
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
822
|
+
};
|
|
823
|
+
|
|
824
|
+
core.sessionMergeShared(created.id, { annotations: [note] });
|
|
825
|
+
const removal = { id: "e_share_rm", annotationId: "c1", createdAt: "2026-01-02T00:00:00.000Z" };
|
|
826
|
+
|
|
827
|
+
// Act: the share hands the removal back twice, as two pulls would
|
|
828
|
+
core.sessionMergeShared(created.id, { annotations: [], removals: [removal] });
|
|
829
|
+
const merged = core.sessionMergeShared(created.id, {
|
|
830
|
+
annotations: [note],
|
|
831
|
+
removals: [removal],
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
// Assert: gone from view, kept on the shelf, one removal entry with the share's id
|
|
835
|
+
expect(merged.annotations).toEqual([]);
|
|
836
|
+
expect(merged.shelvedAnnotations!.map((annotation) => annotation.id)).toEqual(["c1"]);
|
|
837
|
+
expect(merged.history!.entries.filter((entry) => entry.type === "comment-removed")).toEqual([
|
|
838
|
+
expect.objectContaining({ id: "e_share_rm", annotationId: "c1" }),
|
|
839
|
+
]);
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
test("a merge lands on the branch the share follows, wherever the owner stands", () => {
|
|
843
|
+
// Arrange: the owner works on a side branch while the share follows main
|
|
844
|
+
const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
845
|
+
|
|
846
|
+
core.sessionBranch(created.id, "alt");
|
|
847
|
+
const note = {
|
|
848
|
+
...comment("c1", "from a teammate"),
|
|
849
|
+
author: "SHA256:collab",
|
|
850
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
851
|
+
};
|
|
852
|
+
|
|
853
|
+
// Act
|
|
854
|
+
const merged = core.sessionMergeShared(created.id, { annotations: [note] });
|
|
855
|
+
|
|
856
|
+
// Assert: main's path has the note, alt's does not show it
|
|
857
|
+
expect(merged.history!.branch).toBe("alt");
|
|
858
|
+
expect(merged.annotations).toEqual([]);
|
|
859
|
+
expect(merged.shelvedAnnotations!.map((annotation) => annotation.id)).toEqual(["c1"]);
|
|
860
|
+
expect(derivePath(merged.history!, tipOf(merged.history!, "main")).annotationIds).toEqual([
|
|
861
|
+
"c1",
|
|
862
|
+
]);
|
|
863
|
+
expect(
|
|
864
|
+
core.sessionSwitch(created.id, "main").annotations.map((annotation) => annotation.id),
|
|
865
|
+
).toEqual(["c1"]);
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
test("a fork is a new pending session on the copied path, without message, edits, or share", () => {
|
|
869
|
+
// Arrange: comment, edit, resolve, then a second round
|
|
870
|
+
const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
871
|
+
|
|
872
|
+
core.sessionAnnotate(created.id, comment("a1", "first"), "Ana");
|
|
873
|
+
core.sessionSetShareId(created.id, "share-1");
|
|
874
|
+
core.sessionSendMessage(created.id, "changes_requested", "again");
|
|
875
|
+
core.sessionSubmitRevision(created.id, "# Plan\n\nRound two.\n", ["a1"]);
|
|
876
|
+
core.sessionLabel(created.id, "round two");
|
|
877
|
+
core.sessionSetWorkingCopy(created.id, "# Plan\n\nRound two, edited.\n");
|
|
878
|
+
|
|
879
|
+
// Act
|
|
880
|
+
const fork = core.sessionFork(created.id);
|
|
881
|
+
|
|
882
|
+
// Assert
|
|
883
|
+
expect(fork.id).not.toBe(created.id);
|
|
884
|
+
expect(fork.parentSessionId).toBe(created.id);
|
|
885
|
+
expect(fork.status).toBe("pending");
|
|
886
|
+
expect(fork.message).toBeNull();
|
|
887
|
+
expect(fork.shareId).toBeUndefined();
|
|
888
|
+
expect(fork.workingCopy).toBeUndefined();
|
|
889
|
+
expect(fork.artifact.content).toBe("# Plan\n\nRound two.\n");
|
|
890
|
+
expect(fork.revisions.map((revision) => revision.revision)).toEqual([1, 2]);
|
|
891
|
+
expect(fork.annotations.map((annotation) => annotation.id)).toEqual(["a1"]);
|
|
892
|
+
expect(fork.participants).toEqual(
|
|
893
|
+
created.participants ?? core.sessionGet(created.id).participants,
|
|
894
|
+
);
|
|
895
|
+
expect(Object.values(fork.history!.labels)).toEqual(["round two"]);
|
|
896
|
+
expect(fork.history!.entries.map((entry) => entry.type)).toEqual([
|
|
897
|
+
"revision",
|
|
898
|
+
"comment",
|
|
899
|
+
"revision",
|
|
900
|
+
]);
|
|
901
|
+
expect(core.sessionList().map((session) => session.id)).toContain(fork.id);
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
test("a fork's addressed marks follow its own numbering; one addressed off the path reopens", () => {
|
|
905
|
+
// Arrange: a1 addressed by round two, main navigated back before round two, round three submitted
|
|
906
|
+
const created = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
907
|
+
|
|
908
|
+
core.sessionAnnotate(created.id, comment("a1", "first"));
|
|
909
|
+
const beforeRoundTwo = tipOf(core.sessionGet(created.id).history!);
|
|
910
|
+
|
|
911
|
+
core.sessionSubmitRevision(created.id, "# Plan\n\nRound two.\n", ["a1"]);
|
|
912
|
+
core.sessionAnnotate(created.id, comment("a2", "second"));
|
|
913
|
+
core.sessionSubmitRevision(created.id, "# Plan\n\nRound three.\n", ["a2"]);
|
|
914
|
+
|
|
915
|
+
// Act: fork with both rounds on the path
|
|
916
|
+
const straight = core.sessionFork(created.id);
|
|
917
|
+
|
|
918
|
+
core.sessionNavigate(created.id, beforeRoundTwo);
|
|
919
|
+
core.sessionSubmitRevision(created.id, "# Plan\n\nRound four.\n");
|
|
920
|
+
// the source numbers this 4; on the fork's path it is the second revision
|
|
921
|
+
const rerouted = core.sessionFork(created.id);
|
|
922
|
+
|
|
923
|
+
// Assert
|
|
924
|
+
expect(straight.annotations.map((annotation) => annotation.resolution?.revision)).toEqual([
|
|
925
|
+
2, 3,
|
|
926
|
+
]);
|
|
927
|
+
expect(rerouted.revisions.map((revision) => revision.content)).toEqual([
|
|
928
|
+
PLAN.content,
|
|
929
|
+
"# Plan\n\nRound four.\n",
|
|
930
|
+
]);
|
|
931
|
+
expect(rerouted.annotations.map((annotation) => annotation.id)).toEqual(["a1"]);
|
|
932
|
+
expect(rerouted.annotations[0]!.resolution).toBeUndefined();
|
|
933
|
+
});
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
describe("workbench session", () => {
|
|
937
|
+
function makeRepo(): string {
|
|
938
|
+
const root = mkdtempSync(join(tmpdir(), "cueloop-wb-repo-"));
|
|
939
|
+
|
|
940
|
+
// a unique seed file so each repo's root commit is a distinct SHA (empty commits in the same
|
|
941
|
+
// second hash identically) - the root commit is the per-project key under test
|
|
942
|
+
writeFileSync(join(root, "seed.txt"), root);
|
|
943
|
+
Bun.spawnSync(["git", "-C", root, "init", "-q"]);
|
|
944
|
+
Bun.spawnSync(["git", "-C", root, "config", "user.email", "t@e.st"]);
|
|
945
|
+
Bun.spawnSync(["git", "-C", root, "config", "user.name", "T"]);
|
|
946
|
+
Bun.spawnSync(["git", "-C", root, "add", "-A"]);
|
|
947
|
+
Bun.spawnSync(["git", "-C", root, "commit", "-q", "-m", "seed"]);
|
|
948
|
+
|
|
949
|
+
return root;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
test("creates a diff-backed, workbench-marked thread and attaches idempotently", async () => {
|
|
953
|
+
const repo = makeRepo();
|
|
954
|
+
|
|
955
|
+
try {
|
|
956
|
+
const first = await core.workbenchSession(repo);
|
|
957
|
+
|
|
958
|
+
expect(first.artifact.meta.workbench).toBe(true);
|
|
959
|
+
expect(first.artifact.type).toBe("diff");
|
|
960
|
+
expect(first.workspace.rootCommit).toBeTruthy();
|
|
961
|
+
|
|
962
|
+
// a second call for the same repo returns the same thread, never a duplicate
|
|
963
|
+
const second = await core.workbenchSession(repo);
|
|
964
|
+
|
|
965
|
+
expect(second.id).toBe(first.id);
|
|
966
|
+
expect(core.sessionList().filter((s) => s.artifact.meta.workbench).length).toBe(1);
|
|
967
|
+
} finally {
|
|
968
|
+
rmSync(repo, { recursive: true, force: true });
|
|
969
|
+
}
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
test("a resolved workbench is not reused; a fresh one is created past it", async () => {
|
|
973
|
+
const repo = makeRepo();
|
|
974
|
+
|
|
975
|
+
try {
|
|
976
|
+
const first = await core.workbenchSession(repo);
|
|
977
|
+
|
|
978
|
+
core.sessionSendMessage(first.id, "changes_requested", "");
|
|
979
|
+
const second = await core.workbenchSession(repo);
|
|
980
|
+
|
|
981
|
+
expect(second.id).not.toBe(first.id);
|
|
982
|
+
expect(second.status).toBe("pending");
|
|
983
|
+
} finally {
|
|
984
|
+
rmSync(repo, { recursive: true, force: true });
|
|
985
|
+
}
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
test("concurrent bare launches for one repo share a single workbench", async () => {
|
|
989
|
+
const repo = makeRepo();
|
|
990
|
+
|
|
991
|
+
try {
|
|
992
|
+
const [a, b] = await Promise.all([core.workbenchSession(repo), core.workbenchSession(repo)]);
|
|
993
|
+
|
|
994
|
+
expect(a.id).toBe(b.id);
|
|
995
|
+
expect(core.sessionList().filter((s) => s.artifact.meta.workbench).length).toBe(1);
|
|
996
|
+
} finally {
|
|
997
|
+
rmSync(repo, { recursive: true, force: true });
|
|
998
|
+
}
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
test("keys the workbench by root commit: different repos get different threads", async () => {
|
|
1002
|
+
const repoA = makeRepo();
|
|
1003
|
+
const repoB = makeRepo();
|
|
1004
|
+
|
|
1005
|
+
try {
|
|
1006
|
+
const a = await core.workbenchSession(repoA);
|
|
1007
|
+
const b = await core.workbenchSession(repoB);
|
|
1008
|
+
|
|
1009
|
+
expect(a.id).not.toBe(b.id);
|
|
1010
|
+
expect(a.workspace.rootCommit).not.toBe(b.workspace.rootCommit);
|
|
1011
|
+
} finally {
|
|
1012
|
+
rmSync(repoA, { recursive: true, force: true });
|
|
1013
|
+
rmSync(repoB, { recursive: true, force: true });
|
|
1014
|
+
}
|
|
155
1015
|
});
|
|
156
1016
|
});
|