@cueloop/daemon 0.1.0-alpha.83 → 0.1.0-alpha.85

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cueloop/daemon",
3
- "version": "0.1.0-alpha.83",
3
+ "version": "0.1.0-alpha.85",
4
4
  "description": "The cueloop session daemon and its client: socket protocol, atomic store, resumable waits",
5
5
  "homepage": "https://github.com/mmurakaru/cueloop#readme",
6
6
  "bugs": {
@@ -38,7 +38,7 @@
38
38
  "access": "public"
39
39
  },
40
40
  "dependencies": {
41
- "@cueloop/schema": "0.1.0-alpha.83",
41
+ "@cueloop/schema": "0.1.0-alpha.85",
42
42
  "@pierre/diffs": "^1.4.1",
43
43
  "effect": "^4.0.0-rc.115",
44
44
  "smol-toml": "^1.8.0",
package/src/api.test.ts CHANGED
@@ -169,6 +169,71 @@ describe("session lifecycle", () => {
169
169
  ).toThrow("already resolved");
170
170
  });
171
171
 
172
+ test("Comment delivers only changed annotations and keeps the Thread pending", () => {
173
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
174
+
175
+ core.sessionAnnotate(session.id, {
176
+ id: "first",
177
+ kind: "comment",
178
+ anchor: { quote: "carefully", prefix: "the thing ", suffix: "." },
179
+ body: "first note",
180
+ });
181
+
182
+ const commented = core.sessionSendMessage(session.id, "comment", "Interim feedback");
183
+
184
+ expect(commented.status).toBe("pending");
185
+ expect(commented.message!.annotations?.map((annotation) => annotation.id)).toEqual(["first"]);
186
+ expect(commented.message!.body).toContain("first note");
187
+
188
+ core.sessionAnnotate(session.id, {
189
+ id: "second",
190
+ kind: "comment",
191
+ anchor: { quote: "Context", prefix: "Plan", suffix: "Do" },
192
+ body: "second note",
193
+ });
194
+
195
+ const secondComment = core.sessionSendMessage(session.id, "comment", "Another note");
196
+
197
+ expect(secondComment.status).toBe("pending");
198
+ expect(secondComment.message!.annotations?.map((annotation) => annotation.id)).toEqual([
199
+ "second",
200
+ ]);
201
+ expect(secondComment.message!.body).not.toContain("first note");
202
+ expect(secondComment.message!.body).toContain("second note");
203
+
204
+ core.sessionAnnotate(session.id, {
205
+ id: "first",
206
+ kind: "comment",
207
+ anchor: { quote: "carefully", prefix: "the thing ", suffix: "." },
208
+ body: "edited first note",
209
+ });
210
+
211
+ const resolved = core.sessionSendMessage(session.id, "approved", "Ready");
212
+
213
+ expect(resolved.status).toBe("resolved");
214
+ expect(resolved.message!.annotations?.map((annotation) => annotation.id)).toEqual(["first"]);
215
+ expect(resolved.message!.body).toContain("edited first note");
216
+ expect(resolved.message!.body).not.toContain("second note");
217
+ });
218
+
219
+ test("agent-side resolution does not redeliver a sent annotation", () => {
220
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
221
+
222
+ core.sessionAnnotate(session.id, {
223
+ id: "sent",
224
+ kind: "comment",
225
+ anchor: { quote: "carefully", prefix: "the thing ", suffix: "." },
226
+ body: "sent note",
227
+ });
228
+ core.sessionSendMessage(session.id, "comment", "Interim feedback");
229
+ core.sessionSubmitRevision(session.id, PLAN.content, ["sent"]);
230
+
231
+ const resolved = core.sessionSendMessage(session.id, "approved", "Ready");
232
+
233
+ expect(resolved.message!.annotations).toEqual([]);
234
+ expect(resolved.message!.body).not.toContain("sent note");
235
+ });
236
+
172
237
  test("working copy stores edits and clears on revert or no-op", () => {
173
238
  // Arrange
174
239
  const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
@@ -186,6 +251,55 @@ describe("session lifecycle", () => {
186
251
  expect(core.sessionGet(session.id).workingCopy).toBeUndefined();
187
252
  });
188
253
 
254
+ test("character Cut provenance survives persistence and clears on an arbitrary edit", () => {
255
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
256
+ const start = PLAN.content.indexOf("carefully");
257
+ const end = start + "carefully".length;
258
+ const workingCopy = PLAN.content.slice(0, start) + PLAN.content.slice(end);
259
+ const cut = { start, end, quote: "carefully" };
260
+
261
+ core.sessionSetWorkingCopy(session.id, workingCopy, [cut]);
262
+
263
+ expect(core.sessionGet(session.id).textCuts).toEqual([cut]);
264
+ expect(new DaemonCore(home).sessionGet(session.id).textCuts).toEqual([cut]);
265
+ const cutTip = core.sessionGet(session.id).history!.tips.main!;
266
+ const root = core.sessionGet(session.id).history!.entries[0]!.id;
267
+
268
+ core.sessionBranch(session.id, "with-cut");
269
+ core.sessionNavigate(session.id, root, undefined, "main");
270
+ expect(core.sessionGet(session.id).textCuts).toBeUndefined();
271
+ core.sessionSwitch(session.id, "with-cut");
272
+ expect(core.sessionGet(session.id).history!.tips["with-cut"]).toBe(cutTip);
273
+ expect(core.sessionGet(session.id).textCuts).toEqual([cut]);
274
+ core.sessionSetWorkingCopy(session.id, PLAN.content.replace("carefully", "safely"));
275
+ expect(core.sessionGet(session.id).textCuts).toBeUndefined();
276
+ });
277
+
278
+ test("rejects character Cut provenance that does not match the artifact", () => {
279
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
280
+
281
+ for (const textCuts of [
282
+ [{ start: 0, end: 5, quote: "stale" }],
283
+ [{ start: PLAN.content.length - 1, end: PLAN.content.length + 1, quote: "xx" }],
284
+ ]) {
285
+ expect(() => core.sessionSetWorkingCopy(session.id, "remaining", textCuts)).toThrow(
286
+ "text Cuts do not match the submitted artifact and working copy",
287
+ );
288
+ }
289
+ expect(core.sessionGet(session.id).workingCopy).toBeUndefined();
290
+ expect(core.sessionGet(session.id).textCuts).toBeUndefined();
291
+ });
292
+
293
+ test("rejects character Cut provenance whose result differs from the working copy", () => {
294
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
295
+ const start = PLAN.content.indexOf("carefully");
296
+ const textCuts = [{ start, end: start + "carefully".length, quote: "carefully" }];
297
+
298
+ expect(() => core.sessionSetWorkingCopy(session.id, "different", textCuts)).toThrow(
299
+ "text Cuts do not match the submitted artifact and working copy",
300
+ );
301
+ });
302
+
189
303
  test("a thread rename sets the title, clears back to the default on empty, and survives a restart", () => {
190
304
  // Arrange
191
305
  const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
@@ -235,8 +349,13 @@ describe("session lifecycle", () => {
235
349
  test("revision reopens the session and resets working copy + message", () => {
236
350
  // Arrange
237
351
  const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
352
+ const start = PLAN.content.indexOf("carefully");
353
+ const end = start + "carefully".length;
354
+ const cut = { start, end, quote: "carefully" };
238
355
 
239
- core.sessionSetWorkingCopy(session.id, PLAN.content + "\nedit");
356
+ core.sessionSetWorkingCopy(session.id, PLAN.content.slice(0, start) + PLAN.content.slice(end), [
357
+ cut,
358
+ ]);
240
359
  core.sessionSendMessage(session.id, "changes_requested", "redo");
241
360
 
242
361
  // Act
@@ -246,6 +365,7 @@ describe("session lifecycle", () => {
246
365
  expect(revised.status).toBe("pending");
247
366
  expect(revised.message).toBeNull();
248
367
  expect(revised.workingCopy).toBeUndefined();
368
+ expect(revised.textCuts).toBeUndefined();
249
369
  expect(revised.revisions.length).toBe(2);
250
370
  expect(revised.artifact.content).toBe("# Plan v2\n");
251
371
  });
package/src/api.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  import {
9
9
  SCHEMA_VERSION,
10
10
  appendEntry,
11
+ applyTextCuts,
11
12
  applyPathView,
12
13
  createBranch,
13
14
  cutBlock,
@@ -44,6 +45,7 @@ import {
44
45
  type Delivery,
45
46
  type PendingDelivery,
46
47
  type Thread,
48
+ type TextCut,
47
49
  type SessionHistory,
48
50
  type Message,
49
51
  type MessageOutcome,
@@ -69,6 +71,29 @@ import { listProjectFiles, readProjectFile } from "./project-files";
69
71
  import { resolveWorkspace } from "./thread-review";
70
72
  import { DaemonError } from "./errors";
71
73
 
74
+ /** Reviewer-controlled fields that make an annotation new or edited for delivery. */
75
+ function annotationDeliveryFingerprint(annotation: Annotation | undefined): string {
76
+ if (!annotation) return "";
77
+
78
+ return JSON.stringify({
79
+ kind: annotation.kind,
80
+ anchor: {
81
+ quote: annotation.anchor.quote,
82
+ prefix: annotation.anchor.prefix,
83
+ suffix: annotation.anchor.suffix,
84
+ blockIndex: annotation.anchor.blockIndex,
85
+ endBlockIndex: annotation.anchor.endBlockIndex,
86
+ start: annotation.anchor.start,
87
+ end: annotation.anchor.end,
88
+ selector: annotation.anchor.selector,
89
+ },
90
+ target: annotation.target,
91
+ body: annotation.body,
92
+ author: annotation.author,
93
+ replyTo: annotation.replyTo,
94
+ });
95
+ }
96
+
72
97
  /** What a share hands back: the notes and names it collected, and the removals it recorded. */
73
98
  export interface SharedMerge {
74
99
  annotations: Annotation[];
@@ -445,9 +470,11 @@ export class DaemonCore {
445
470
  }
446
471
 
447
472
  /** The reviewer's working copy; undefined clears it (revert all edits). */
448
- sessionSetWorkingCopy(id: string, workingCopy: string | undefined): Thread {
473
+ sessionSetWorkingCopy(id: string, workingCopy: string | undefined, textCuts?: TextCut[]): Thread {
449
474
  const session = this.mutable(id);
450
- const entryId = this.applyWorkingCopy(session, workingCopy);
475
+
476
+ if (textCuts?.length) this.assertTextCuts(session.artifact.content, workingCopy, textCuts);
477
+ const entryId = this.applyWorkingCopy(session, workingCopy, textCuts);
451
478
 
452
479
  this.store.upsert(session);
453
480
  this.emit("session.updated", id, entryId);
@@ -455,6 +482,32 @@ export class DaemonCore {
455
482
  return session;
456
483
  }
457
484
 
485
+ private assertTextCuts(
486
+ source: string,
487
+ workingCopy: string | undefined,
488
+ textCuts: readonly TextCut[],
489
+ ): void {
490
+ let previousEnd = 0;
491
+ const valid = textCuts.every((cut) => {
492
+ const matches =
493
+ cut.start >= previousEnd &&
494
+ cut.end > cut.start &&
495
+ cut.end <= source.length &&
496
+ source.slice(cut.start, cut.end) === cut.quote;
497
+
498
+ previousEnd = cut.end;
499
+
500
+ return matches;
501
+ });
502
+
503
+ if (!valid || workingCopy === undefined || applyTextCuts(source, textCuts) !== workingCopy) {
504
+ throw new DaemonError(
505
+ "invalid_params",
506
+ "text Cuts do not match the submitted artifact and working copy",
507
+ );
508
+ }
509
+ }
510
+
458
511
  /**
459
512
  * The guided walk's viewed marks. Merge-additive: walking only ever adds
460
513
  * marks, so concurrent or stale clients converge instead of overwriting
@@ -755,16 +808,29 @@ export class DaemonCore {
755
808
  ): Thread {
756
809
  const session = this.mutable(id);
757
810
  const sentAt = new Date().toISOString();
811
+ const sentAnnotations = new Map(
812
+ (session.history?.entries ?? [])
813
+ .filter((entry) => entry.type === "message")
814
+ .flatMap((entry) => (entry.type === "message" ? (entry.message.annotations ?? []) : []))
815
+ .map((annotation) => [annotation.id, annotation]),
816
+ );
817
+ const annotations = session.annotations.filter(
818
+ (annotation) =>
819
+ !isAddressed(annotation) &&
820
+ annotationDeliveryFingerprint(sentAnnotations.get(annotation.id)) !==
821
+ annotationDeliveryFingerprint(annotation),
822
+ );
758
823
  const message: Message = {
759
824
  id: newMessageId(),
760
825
  outcome,
761
826
  summary,
762
- body: feedbackForSession(session, outcome, summary, actionBodies),
827
+ body: feedbackForSession(session, outcome, summary, actionBodies, annotations),
828
+ annotations,
763
829
  sentAt,
764
830
  };
765
831
 
766
832
  session.message = message;
767
- session.status = "resolved";
833
+ session.status = outcome === "comment" ? "pending" : "resolved";
768
834
  const entryId = this.record(session, {
769
835
  type: "message",
770
836
  message,
@@ -772,13 +838,18 @@ export class DaemonCore {
772
838
  });
773
839
  this.store.upsert(session);
774
840
  this.reconcileDeliveries(session);
841
+ this.emit("message.sent", id, entryId);
842
+ if (outcome === "comment") {
843
+ this.emit("session.updated", id, entryId);
844
+
845
+ return session;
846
+ }
775
847
  // a resolved diff review is frozen; stop hot-reloading its working tree
776
848
  this.untrackLiveDiffSession(session);
777
849
  const parked = this.waiters.get(id) ?? [];
778
850
 
779
851
  this.waiters.delete(id);
780
852
  for (const parkedWaiter of parked) parkedWaiter(session);
781
- this.emit("message.sent", id, entryId);
782
853
  this.emit("inbox.changed", id);
783
854
 
784
855
  return session;
@@ -815,6 +886,7 @@ export class DaemonCore {
815
886
  createdAt: now,
816
887
  });
817
888
  delete session.workingCopy;
889
+ delete session.textCuts;
818
890
  session.message = null;
819
891
  session.status = "pending";
820
892
 
@@ -1063,25 +1135,35 @@ export class DaemonCore {
1063
1135
  * Set or clear the working copy and, when the reviewer's text changed, record
1064
1136
  * it as a reviewer revision on the current branch. Returns that entry's id.
1065
1137
  */
1066
- private applyWorkingCopy(session: Thread, workingCopy: string | undefined): string | undefined {
1138
+ private applyWorkingCopy(
1139
+ session: Thread,
1140
+ workingCopy: string | undefined,
1141
+ textCuts?: TextCut[],
1142
+ ): string | undefined {
1067
1143
  const before = session.workingCopy ?? session.artifact.content;
1068
1144
  const next =
1069
1145
  workingCopy === undefined || workingCopy === session.artifact.content
1070
1146
  ? undefined
1071
1147
  : workingCopy;
1072
1148
 
1149
+ if (textCuts?.length) session.textCuts = textCuts;
1150
+ else delete session.textCuts;
1073
1151
  if (next === undefined) delete session.workingCopy;
1074
1152
  else session.workingCopy = next;
1075
1153
  const after = next ?? session.artifact.content;
1076
1154
 
1077
1155
  if (after === before) return undefined;
1078
1156
 
1079
- return this.record(session, {
1157
+ const revision: Extract<NewEntry, { type: "revision" }> = {
1080
1158
  type: "revision",
1081
1159
  by: "reviewer",
1082
1160
  content: after,
1083
1161
  createdAt: new Date().toISOString(),
1084
- });
1162
+ };
1163
+
1164
+ if (textCuts?.length) revision.textCuts = textCuts;
1165
+
1166
+ return this.record(session, revision);
1085
1167
  }
1086
1168
 
1087
1169
  /** The session's history; a record without a revision has none and cannot be moved through. */
package/src/client.ts CHANGED
@@ -31,6 +31,7 @@ import type {
31
31
  PendingDelivery,
32
32
  MessageOutcome,
33
33
  WorkspaceKey,
34
+ TextCut,
34
35
  } from "@cueloop/schema";
35
36
  import {
36
37
  BackpressureWriter,
@@ -113,7 +114,11 @@ export interface ThreadClient {
113
114
  ): Promise<Thread>;
114
115
  /** Remove a comment; a non-owner connection removes only the comments of the author it is bound to. */
115
116
  sessionRemoveAnnotation(id: string, annotationId: string): Promise<Thread>;
116
- sessionSetWorkingCopy(id: string, workingCopy: string | undefined): Promise<Thread>;
117
+ sessionSetWorkingCopy(
118
+ id: string,
119
+ workingCopy: string | undefined,
120
+ textCuts?: TextCut[],
121
+ ): Promise<Thread>;
117
122
  /** Cut the `blockIndex`-th block of the working copy. */
118
123
  sessionCutBlock(id: string, blockIndex: number): Promise<Thread>;
119
124
  /** Re-insert the `baseBlockIndex`-th block of the submitted revision before `line` (default: the end). */
@@ -495,8 +500,16 @@ export class DaemonClient implements ThreadClient {
495
500
  sessionSetParticipantName(id: string, author: string, name: string): Promise<Thread> {
496
501
  return this.request("session.setParticipantName", { id, author, name }, ThreadRecordSchema);
497
502
  }
498
- sessionSetWorkingCopy(id: string, workingCopy: string | undefined): Promise<Thread> {
499
- return this.request("session.setWorkingCopy", { id, workingCopy }, ThreadRecordSchema);
503
+ sessionSetWorkingCopy(
504
+ id: string,
505
+ workingCopy: string | undefined,
506
+ textCuts?: TextCut[],
507
+ ): Promise<Thread> {
508
+ return this.request(
509
+ "session.setWorkingCopy",
510
+ { id, workingCopy, textCuts },
511
+ ThreadRecordSchema,
512
+ );
500
513
  }
501
514
  sessionCutBlock(id: string, blockIndex: number): Promise<Thread> {
502
515
  return this.request("session.cutBlock", { id, blockIndex }, ThreadRecordSchema);
@@ -34,6 +34,23 @@ function createPlan(instance: DaemonCore) {
34
34
  }
35
35
 
36
36
  describe("durable harness delivery", () => {
37
+ test("delivers Comment without resolving the Thread", () => {
38
+ const instance = core();
39
+ const thread = createPlan(instance);
40
+ const binding = instance.harnessBind({
41
+ threadId: thread.id,
42
+ harness: "fake",
43
+ harnessSessionId: "fake_1",
44
+ });
45
+
46
+ const sent = instance.sessionSendMessage(thread.id, "comment", "Interim note.");
47
+ const pending = instance.deliveryPending(binding.id);
48
+
49
+ expect(sent.status).toBe("pending");
50
+ expect(pending).toHaveLength(1);
51
+ expect(pending[0]!.message.outcome).toBe("comment");
52
+ });
53
+
37
54
  test("reload finds only bindings for the active harness conversation", () => {
38
55
  const first = core();
39
56
  const thread = createPlan(first);
package/src/server.ts CHANGED
@@ -377,7 +377,7 @@ export class DaemonServer {
377
377
  "session.setWorkingCopy": (_connection, request) => {
378
378
  const params = parseParams("session.setWorkingCopy", request.params);
379
379
 
380
- return this.core.sessionSetWorkingCopy(params.id, params.workingCopy);
380
+ return this.core.sessionSetWorkingCopy(params.id, params.workingCopy, params.textCuts);
381
381
  },
382
382
  "session.cutBlock": (_connection, request) => {
383
383
  const params = parseParams("session.cutBlock", request.params);
@@ -76,6 +76,30 @@ describe("parseParams", () => {
76
76
  }
77
77
  });
78
78
 
79
+ test.each([
80
+ [
81
+ [
82
+ { start: 2, end: 4, quote: "ab" },
83
+ { start: 1, end: 2, quote: "c" },
84
+ ],
85
+ ],
86
+ [
87
+ [
88
+ { start: 1, end: 4, quote: "abc" },
89
+ { start: 3, end: 5, quote: "de" },
90
+ ],
91
+ ],
92
+ [[{ start: 1, end: 4, quote: "ab" }]],
93
+ ])("rejects malformed text Cut provenance", (textCuts) => {
94
+ expect(() =>
95
+ parseParams("session.setWorkingCopy", {
96
+ id: "ses_1",
97
+ workingCopy: "remaining",
98
+ textCuts,
99
+ }),
100
+ ).toThrow("text Cuts must be ordered, non-overlapping, and match their quote lengths");
101
+ });
102
+
79
103
  test("rejects an unknown artifact type", () => {
80
104
  expect(() =>
81
105
  parseParams("session.create", {
@@ -224,6 +248,7 @@ describe("wire pins", () => {
224
248
  outcome: "approved",
225
249
  summary: "",
226
250
  body: "",
251
+ annotations: [fullAnnotation],
227
252
  sentAt: "now",
228
253
  };
229
254
  const fullRevision: Required<Revision> = { revision: 1, content: "# P", submittedAt: "now" };
@@ -276,7 +301,8 @@ describe("wire pins", () => {
276
301
  shelvedAnnotations: [fullAnnotation],
277
302
  parentSessionId: "ses_0",
278
303
  shareBranch: "main",
279
- workingCopy: "# P edited",
304
+ workingCopy: "# ",
305
+ textCuts: [{ start: 2, end: 3, quote: "P" }],
280
306
  viewedPaths: ["src/a.ts"],
281
307
  message: fullMessage,
282
308
  status: "pending",
@@ -320,6 +346,20 @@ describe("wire pins", () => {
320
346
  if (!parsed.ok) expect(parsed.error).toContain("history:");
321
347
  });
322
348
 
349
+ test("a persisted Thread with stale text Cut provenance is refused", () => {
350
+ const result = validateThreadRecord({
351
+ ...fullSession,
352
+ workingCopy: "# P",
353
+ });
354
+
355
+ expect(result.ok).toBe(false);
356
+ if (!result.ok) {
357
+ expect(result.error).toContain(
358
+ "text Cuts do not match the submitted artifact and working copy",
359
+ );
360
+ }
361
+ });
362
+
323
363
  test("schema key sets match the schema types", () => {
324
364
  expect(entryKeys(WorkspaceSchema)).toEqual(keys(fullWorkspace));
325
365
  expect(entryKeys(ArtifactMetaSchema)).toEqual(keys(fullMeta));
package/src/validate.ts CHANGED
@@ -12,6 +12,7 @@
12
12
  import * as v from "valibot";
13
13
  import {
14
14
  ARTIFACT_TYPES,
15
+ MESSAGE_OUTCOMES,
15
16
  WORKFLOW_KINDS,
16
17
  SCHEMA_VERSION,
17
18
  type Anchor,
@@ -22,6 +23,7 @@ import {
22
23
  type DiffFileContents,
23
24
  type Identity,
24
25
  type Thread,
26
+ type TextCut,
25
27
  type HunkRejection,
26
28
  type HarnessBinding,
27
29
  type Delivery,
@@ -33,6 +35,7 @@ import {
33
35
  validateHistory,
34
36
  type Message,
35
37
  type WorkspaceKey,
38
+ applyTextCuts,
36
39
  } from "@cueloop/schema";
37
40
  import { DaemonError } from "./errors";
38
41
  import type { Request } from "./protocol";
@@ -48,6 +51,25 @@ type EntriesOf<T> = { [K in keyof T]-?: v.GenericSchema<any, any> };
48
51
 
49
52
  const NonEmpty = v.pipe(v.string(), v.minLength(1));
50
53
 
54
+ const TextCutSchema = v.object({
55
+ start: v.pipe(v.number(), v.integer(), v.minValue(0)),
56
+ end: v.pipe(v.number(), v.integer(), v.minValue(1)),
57
+ quote: NonEmpty,
58
+ } satisfies EntriesOf<TextCut>);
59
+
60
+ const TextCutsSchema = v.pipe(
61
+ v.array(TextCutSchema),
62
+ v.check(
63
+ (cuts) =>
64
+ cuts.every(
65
+ (cut, index) =>
66
+ cut.end - cut.start === cut.quote.length &&
67
+ (index === 0 || cuts[index - 1]!.end <= cut.start),
68
+ ),
69
+ "text Cuts must be ordered, non-overlapping, and match their quote lengths",
70
+ ),
71
+ );
72
+
51
73
  export const WorkspaceSchema = v.object({
52
74
  repoRoot: NonEmpty,
53
75
  branch: NonEmpty,
@@ -196,7 +218,11 @@ export const Params = {
196
218
  }),
197
219
  "session.removeAnnotation": v.object({ id: SessionId, annotationId: NonEmpty }),
198
220
  "session.setParticipantName": v.object({ id: SessionId, author: NonEmpty, name: NonEmpty }),
199
- "session.setWorkingCopy": v.object({ id: SessionId, workingCopy: v.optional(v.string()) }),
221
+ "session.setWorkingCopy": v.object({
222
+ id: SessionId,
223
+ workingCopy: v.optional(v.string()),
224
+ textCuts: v.optional(TextCutsSchema),
225
+ }),
200
226
  "session.cutBlock": v.object({
201
227
  id: SessionId,
202
228
  blockIndex: v.pipe(v.number(), v.integer(), v.minValue(0)),
@@ -252,7 +278,7 @@ export const Params = {
252
278
  }),
253
279
  "session.sendMessage": v.object({
254
280
  id: SessionId,
255
- outcome: v.picklist(["approved", "changes_requested"]),
281
+ outcome: v.picklist(MESSAGE_OUTCOMES),
256
282
  summary: v.optional(v.string(), ""),
257
283
  actionBodies: v.optional(v.record(v.string(), v.string())),
258
284
  }),
@@ -336,9 +362,10 @@ export const RevisionSchema = v.object({
336
362
 
337
363
  export const MessageSchema = v.object({
338
364
  id: NonEmpty,
339
- outcome: v.picklist(["approved", "changes_requested"]),
365
+ outcome: v.picklist(MESSAGE_OUTCOMES),
340
366
  summary: v.string(),
341
367
  body: v.string(),
368
+ annotations: v.optional(v.array(FullAnnotationSchema)),
342
369
  sentAt: v.string(),
343
370
  } satisfies EntriesOf<Message>);
344
371
 
@@ -360,6 +387,7 @@ export const SessionEntrySchema = v.variant("type", [
360
387
  type: v.literal("revision"),
361
388
  by: v.picklist(["agent", "reviewer"]),
362
389
  content: v.string(),
390
+ textCuts: v.optional(TextCutsSchema),
363
391
  }),
364
392
  v.object({ ...EntryBaseEntries, type: v.literal("comment"), annotationId: NonEmpty }),
365
393
  v.object({ ...EntryBaseEntries, type: v.literal("comment-removed"), annotationId: NonEmpty }),
@@ -389,39 +417,53 @@ export const SessionHistorySchema = v.pipe(
389
417
  );
390
418
 
391
419
  /** Persisted records are validated on recovery: a bad file is skipped, not fatal. */
392
- export const ThreadRecordSchema = v.object({
393
- schemaVersion: v.literal(SCHEMA_VERSION),
394
- id: NonEmpty,
395
- workspace: WorkspaceSchema,
396
- artifact: ArtifactSchema,
397
- revisions: v.array(RevisionSchema),
398
- annotations: v.array(FullAnnotationSchema),
399
- history: v.optional(SessionHistorySchema),
400
- curation: v.optional(
401
- v.array(
402
- v.object({
403
- path: NonEmpty,
404
- hunkIndex: v.number(),
405
- changeIndex: v.optional(v.number()),
406
- } satisfies EntriesOf<HunkRejection>),
420
+ export const ThreadRecordSchema = v.pipe(
421
+ v.object({
422
+ schemaVersion: v.literal(SCHEMA_VERSION),
423
+ id: NonEmpty,
424
+ workspace: WorkspaceSchema,
425
+ artifact: ArtifactSchema,
426
+ revisions: v.array(RevisionSchema),
427
+ annotations: v.array(FullAnnotationSchema),
428
+ history: v.optional(SessionHistorySchema),
429
+ curation: v.optional(
430
+ v.array(
431
+ v.object({
432
+ path: NonEmpty,
433
+ hunkIndex: v.number(),
434
+ changeIndex: v.optional(v.number()),
435
+ } satisfies EntriesOf<HunkRejection>),
436
+ ),
407
437
  ),
408
- ),
409
- workingCopy: v.optional(v.string()),
410
- viewedPaths: v.optional(v.array(v.string())),
411
- message: v.nullable(MessageSchema),
412
- status: v.picklist(["pending", "resolved"]),
413
- createdAt: v.string(),
414
- shelvedAnnotations: v.optional(v.array(FullAnnotationSchema)),
415
- parentSessionId: v.optional(v.string()),
416
- shares: v.optional(v.array(ShareLinkSchema)),
417
- shareId: v.optional(v.string()),
418
- shareBranch: v.optional(v.string()),
419
- owner: v.optional(v.string()),
420
- access: v.optional(
421
- v.object({ githubLogins: v.array(NonEmpty) } satisfies EntriesOf<ShareAccess>),
422
- ),
423
- participants: v.optional(v.array(IdentitySchema)),
424
- } satisfies EntriesOf<Thread>);
438
+ workingCopy: v.optional(v.string()),
439
+ textCuts: v.optional(TextCutsSchema),
440
+ viewedPaths: v.optional(v.array(v.string())),
441
+ message: v.nullable(MessageSchema),
442
+ status: v.picklist(["pending", "resolved"]),
443
+ createdAt: v.string(),
444
+ shelvedAnnotations: v.optional(v.array(FullAnnotationSchema)),
445
+ parentSessionId: v.optional(v.string()),
446
+ shares: v.optional(v.array(ShareLinkSchema)),
447
+ shareId: v.optional(v.string()),
448
+ shareBranch: v.optional(v.string()),
449
+ owner: v.optional(v.string()),
450
+ access: v.optional(
451
+ v.object({ githubLogins: v.array(NonEmpty) } satisfies EntriesOf<ShareAccess>),
452
+ ),
453
+ participants: v.optional(v.array(IdentitySchema)),
454
+ } satisfies EntriesOf<Thread>),
455
+ v.rawCheck(({ dataset, addIssue }) => {
456
+ if (!dataset.typed || !dataset.value.textCuts?.length) return;
457
+ const { artifact, textCuts, workingCopy } = dataset.value;
458
+ const matchesSource = textCuts.every(
459
+ (cut) => artifact.content.slice(cut.start, cut.end) === cut.quote,
460
+ );
461
+
462
+ if (!matchesSource || workingCopy !== applyTextCuts(artifact.content, textCuts)) {
463
+ addIssue({ message: "text Cuts do not match the submitted artifact and working copy" });
464
+ }
465
+ }),
466
+ );
425
467
 
426
468
  export function validateThreadRecord(
427
469
  raw: Parameters<typeof v.safeParse>[1],