@cueloop/schema 0.1.0-alpha.7 → 0.1.0-alpha.71

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/src/diff.ts CHANGED
@@ -1,52 +1,66 @@
1
1
  /**
2
2
  * Line/token diffing and unified-diff serialization. The working copy
3
- * serializes as ONE unified diff against the submitted revision (map #2).
3
+ * serializes as ONE unified diff against the submitted revision.
4
4
  * Pure algorithms, no IO.
5
5
  */
6
6
 
7
- export type DiffOpType = "ctx" | "del" | "add";
7
+ export type DiffOpKind = "ctx" | "del" | "add";
8
8
 
9
9
  export interface DiffOp<T = string> {
10
- t: DiffOpType;
10
+ kind: DiffOpKind;
11
11
  /** Present for ctx and del. */
12
- a?: T;
12
+ oldValue?: T;
13
13
  /** Present for ctx and add. */
14
- b?: T;
14
+ newValue?: T;
15
15
  }
16
16
 
17
17
  /** LCS diff over arrays. O(n*m) - fine for plan-sized documents. */
18
- export function lcsDiff<T>(a: T[], b: T[], eq: (x: T, y: T) => boolean = (x, y) => x === y): DiffOp<T>[] {
19
- const n = a.length;
20
- const m = b.length;
21
- const dp: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0));
22
- for (let i = n - 1; i >= 0; i--) {
23
- for (let j = m - 1; j >= 0; j--) {
24
- dp[i]![j] = eq(a[i]!, b[j]!) ? dp[i + 1]![j + 1]! + 1 : Math.max(dp[i + 1]![j]!, dp[i]![j + 1]!);
18
+ export function lcsDiff<T>(
19
+ oldItems: T[],
20
+ newItems: T[],
21
+ equals: (oldItem: T, newItem: T) => boolean = (oldItem, newItem) => oldItem === newItem,
22
+ ): DiffOp<T>[] {
23
+ const oldCount = oldItems.length;
24
+ const newCount = newItems.length;
25
+ const longestCommon: number[][] = Array.from({ length: oldCount + 1 }, () =>
26
+ Array.from({ length: newCount + 1 }, () => 0),
27
+ );
28
+
29
+ for (let oldIndex = oldCount - 1; oldIndex >= 0; oldIndex--) {
30
+ for (let newIndex = newCount - 1; newIndex >= 0; newIndex--) {
31
+ longestCommon[oldIndex]![newIndex] = equals(oldItems[oldIndex]!, newItems[newIndex]!)
32
+ ? longestCommon[oldIndex + 1]![newIndex + 1]! + 1
33
+ : Math.max(
34
+ longestCommon[oldIndex + 1]![newIndex]!,
35
+ longestCommon[oldIndex]![newIndex + 1]!,
36
+ );
25
37
  }
26
38
  }
27
- const out: DiffOp<T>[] = [];
28
- let i = 0;
29
- let j = 0;
30
- while (i < n && j < m) {
31
- if (eq(a[i]!, b[j]!)) {
32
- out.push({ t: "ctx", a: a[i], b: b[j] });
33
- i++;
34
- j++;
35
- } else if (dp[i + 1]![j]! >= dp[i]![j + 1]!) {
36
- out.push({ t: "del", a: a[i] });
37
- i++;
39
+ const ops: DiffOp<T>[] = [];
40
+ let oldIndex = 0;
41
+ let newIndex = 0;
42
+
43
+ while (oldIndex < oldCount && newIndex < newCount) {
44
+ if (equals(oldItems[oldIndex]!, newItems[newIndex]!)) {
45
+ ops.push({ kind: "ctx", oldValue: oldItems[oldIndex], newValue: newItems[newIndex] });
46
+ oldIndex++;
47
+ newIndex++;
48
+ } else if (longestCommon[oldIndex + 1]![newIndex]! >= longestCommon[oldIndex]![newIndex + 1]!) {
49
+ ops.push({ kind: "del", oldValue: oldItems[oldIndex] });
50
+ oldIndex++;
38
51
  } else {
39
- out.push({ t: "add", b: b[j] });
40
- j++;
52
+ ops.push({ kind: "add", newValue: newItems[newIndex] });
53
+ newIndex++;
41
54
  }
42
55
  }
43
- while (i < n) out.push({ t: "del", a: a[i++] });
44
- while (j < m) out.push({ t: "add", b: b[j++] });
45
- return out;
56
+ while (oldIndex < oldCount) ops.push({ kind: "del", oldValue: oldItems[oldIndex++] });
57
+ while (newIndex < newCount) ops.push({ kind: "add", newValue: newItems[newIndex++] });
58
+
59
+ return ops;
46
60
  }
47
61
 
48
62
  export interface UnifiedLine {
49
- t: DiffOpType | "hunk";
63
+ kind: DiffOpKind | "hunk";
50
64
  text: string;
51
65
  }
52
66
 
@@ -56,58 +70,83 @@ export interface UnifiedLine {
56
70
  */
57
71
  export function unifiedDiff(oldText: string, newText: string, context = 3): UnifiedLine[] | null {
58
72
  const ops = lcsDiff(oldText.split("\n"), newText.split("\n"));
59
- if (!ops.some((o) => o.t !== "ctx")) return null;
60
- let ol = 1;
61
- let nl = 1;
62
- const rows = ops.map((o) => {
63
- const r = {
64
- t: o.t,
65
- text: (o.t === "add" ? o.b : o.a) ?? "",
66
- ol: o.t !== "add" ? ol : null,
67
- nl: o.t !== "del" ? nl : null,
73
+
74
+ if (!ops.some((op) => op.kind !== "ctx")) return null;
75
+ let oldLineNumber = 1;
76
+ let newLineNumber = 1;
77
+ const rows = ops.map((op) => {
78
+ const row = {
79
+ kind: op.kind,
80
+ text: (op.kind === "add" ? op.newValue : op.oldValue) ?? "",
81
+ oldLine: op.kind !== "add" ? oldLineNumber : null,
82
+ newLine: op.kind !== "del" ? newLineNumber : null,
68
83
  };
69
- if (o.t !== "add") ol++;
70
- if (o.t !== "del") nl++;
71
- return r;
84
+
85
+ if (op.kind !== "add") oldLineNumber++;
86
+ if (op.kind !== "del") newLineNumber++;
87
+
88
+ return row;
72
89
  });
73
- const keep = new Array<boolean>(rows.length).fill(false);
74
- rows.forEach((r, idx) => {
75
- if (r.t !== "ctx") {
76
- for (let k = Math.max(0, idx - context); k <= Math.min(rows.length - 1, idx + context); k++) keep[k] = true;
90
+ const keep = Array.from({ length: rows.length }, () => false);
91
+
92
+ rows.forEach((row, rowIndex) => {
93
+ if (row.kind !== "ctx") {
94
+ for (
95
+ let keepIndex = Math.max(0, rowIndex - context);
96
+ keepIndex <= Math.min(rows.length - 1, rowIndex + context);
97
+ keepIndex++
98
+ ) {
99
+ keep[keepIndex] = true;
100
+ }
77
101
  }
78
102
  });
79
103
  const hunks: { rows: (typeof rows)[number][] }[] = [];
80
- let cur: { rows: (typeof rows)[number][] } | null = null;
81
- rows.forEach((r, idx) => {
82
- if (!keep[idx]) {
83
- cur = null;
104
+ let openHunk: { rows: (typeof rows)[number][] } | null = null;
105
+
106
+ rows.forEach((row, rowIndex) => {
107
+ if (!keep[rowIndex]) {
108
+ openHunk = null;
109
+
84
110
  return;
85
111
  }
86
- if (!cur) {
87
- cur = { rows: [] };
88
- hunks.push(cur);
112
+ if (!openHunk) {
113
+ openHunk = { rows: [] };
114
+ hunks.push(openHunk);
89
115
  }
90
- cur.rows.push(r);
116
+ openHunk.rows.push(row);
91
117
  });
92
- const out: UnifiedLine[] = [];
93
- for (const h of hunks) {
94
- const oStart = h.rows.find((r) => r.ol !== null)?.ol ?? 1;
95
- const nStart = h.rows.find((r) => r.nl !== null)?.nl ?? 1;
96
- const oCount = h.rows.filter((r) => r.t !== "add").length;
97
- const nCount = h.rows.filter((r) => r.t !== "del").length;
98
- out.push({ t: "hunk", text: `@@ -${oStart},${oCount} +${nStart},${nCount} @@` });
99
- for (const r of h.rows) {
100
- out.push({ t: r.t, text: (r.t === "add" ? "+" : r.t === "del" ? "-" : " ") + r.text });
118
+ const lines: UnifiedLine[] = [];
119
+
120
+ for (const hunk of hunks) {
121
+ const oldStart = hunk.rows.find((row) => row.oldLine !== null)?.oldLine ?? 1;
122
+ const newStart = hunk.rows.find((row) => row.newLine !== null)?.newLine ?? 1;
123
+ const oldCount = hunk.rows.filter((row) => row.kind !== "add").length;
124
+ const newCount = hunk.rows.filter((row) => row.kind !== "del").length;
125
+
126
+ lines.push({ kind: "hunk", text: `@@ -${oldStart},${oldCount} +${newStart},${newCount} @@` });
127
+ for (const row of hunk.rows) {
128
+ lines.push({
129
+ kind: row.kind,
130
+ text: (row.kind === "add" ? "+" : row.kind === "del" ? "-" : " ") + row.text,
131
+ });
101
132
  }
102
133
  }
103
- return out;
134
+
135
+ return lines;
104
136
  }
105
137
 
106
138
  /** Render a unified diff as text with file headers. */
107
- export function unifiedDiffText(oldText: string, newText: string, path = "plan.md", context = 3): string | null {
108
- const d = unifiedDiff(oldText, newText, context);
109
- if (!d) return null;
110
- return [`--- a/${path}`, `+++ b/${path}`, ...d.map((l) => l.text)].join("\n");
139
+ export function unifiedDiffText(
140
+ oldText: string,
141
+ newText: string,
142
+ path = "plan.md",
143
+ context = 3,
144
+ ): string | null {
145
+ const lines = unifiedDiff(oldText, newText, context);
146
+
147
+ if (!lines) return null;
148
+
149
+ return [`--- a/${path}`, `+++ b/${path}`, ...lines.map((line) => line.text)].join("\n");
111
150
  }
112
151
 
113
152
  export interface EditStats {
@@ -116,16 +155,12 @@ export interface EditStats {
116
155
  }
117
156
 
118
157
  export function editStats(oldText: string, newText: string): EditStats {
119
- const d = unifiedDiff(oldText, newText, 0);
120
- if (!d) return { added: 0, removed: 0 };
158
+ const lines = unifiedDiff(oldText, newText, 0);
159
+
160
+ if (!lines) return { added: 0, removed: 0 };
161
+
121
162
  return {
122
- added: d.filter((r) => r.t === "add").length,
123
- removed: d.filter((r) => r.t === "del").length,
163
+ added: lines.filter((line) => line.kind === "add").length,
164
+ removed: lines.filter((line) => line.kind === "del").length,
124
165
  };
125
166
  }
126
-
127
- /** Word-level diff ops for tracked-changes rendering. Whitespace-preserving. */
128
- export function wordDiff(oldText: string, newText: string): DiffOp[] {
129
- const tok = (s: string) => s.split(/(\s+)/).filter((x) => x !== "");
130
- return lcsDiff(tok(oldText), tok(newText));
131
- }
@@ -13,7 +13,9 @@ Review sessions live only in daemon memory today.
13
13
  Sessions are written as one JSON document per session.
14
14
  `;
15
15
 
16
- function ann(partial: Partial<Annotation> & { anchor: Annotation["anchor"] }): Annotation {
16
+ function makeAnnotation(
17
+ partial: Partial<Annotation> & { anchor: Annotation["anchor"] },
18
+ ): Annotation {
17
19
  return {
18
20
  id: "a1",
19
21
  kind: "comment",
@@ -25,91 +27,366 @@ function ann(partial: Partial<Annotation> & { anchor: Annotation["anchor"] }): A
25
27
 
26
28
  describe("renderFeedback", () => {
27
29
  test("plan edits section carries the one unified diff, directive framing first", () => {
28
- const out = renderFeedback({
30
+ // Act
31
+ const feedback = renderFeedback({
29
32
  verdictKind: "request_changes",
30
33
  summary: "Tighten the storage section.",
31
34
  artifactContent: PLAN,
32
35
  workingCopy: PLAN.replace("one JSON document", "one JSON record"),
33
36
  annotations: [],
34
- planPath: "docs/plan.md",
35
- });
36
- expect(out).toContain("# Review: request changes");
37
- expect(out).toContain("Tighten the storage section.");
38
- expect(out).toContain("## Plan edits");
39
- expect(out).toContain("Apply this exact diff first");
40
- expect(out).toContain("--- a/docs/plan.md");
41
- expect(out).toContain("-Sessions are written as one JSON document per session.");
42
- expect(out).toContain("+Sessions are written as one JSON record per session.");
37
+ artifactPath: "docs/plan.md",
38
+ });
39
+
40
+ // Assert
41
+ expect(feedback).toContain("# Review: request changes");
42
+ expect(feedback).toContain("Tighten the storage section.");
43
+ expect(feedback).toContain("## Plan edits");
44
+ expect(feedback).toContain("Apply this exact diff first");
45
+ expect(feedback).toContain("--- a/docs/plan.md");
46
+ expect(feedback).toContain("-Sessions are written as one JSON document per session.");
47
+ expect(feedback).toContain("+Sessions are written as one JSON record per session.");
48
+ });
49
+
50
+ test("reply edits section is labelled for the reply artifact, not a plan", () => {
51
+ // Act
52
+ const feedback = renderFeedback({
53
+ verdictKind: "request_changes",
54
+ summary: "",
55
+ artifactContent: PLAN,
56
+ workingCopy: PLAN.replace("one JSON document", "one JSON record"),
57
+ artifactType: "reply",
58
+ annotations: [],
59
+ artifactPath: "reply.md",
60
+ });
61
+
62
+ // Assert
63
+ expect(feedback).toContain("## Reply edits");
64
+ expect(feedback).not.toContain("## Plan edits");
65
+ expect(feedback).toContain("Apply this exact diff first");
66
+ });
67
+
68
+ test("a reply review with no artifact path references reply.md, never plan.md", () => {
69
+ // Act
70
+ const feedback = renderFeedback({
71
+ verdictKind: "request_changes",
72
+ summary: "",
73
+ artifactContent: "# Findings\n\nThe cache is fine.\n",
74
+ artifactType: "reply",
75
+ annotations: [
76
+ makeAnnotation({ anchor: { quote: "The cache is fine.", prefix: "", suffix: "" } }),
77
+ ],
78
+ sessionId: "ses_reply",
79
+ });
80
+
81
+ // Assert
82
+ expect(feedback).toContain("Locate each one in reply.md");
83
+ expect(feedback).toContain("submit-revision ses_reply --content-file reply.md");
84
+ expect(feedback).not.toContain("plan.md");
85
+ });
86
+
87
+ test("a diff working copy is handed back as the curated patch, not a diff of diffs", () => {
88
+ // Arrange
89
+ const submittedPatch = `--- a/src/x.ts
90
+ +++ b/src/x.ts
91
+ @@ -1,1 +1,1 @@
92
+ -const limit = 100;
93
+ +const limit = 250;
94
+ `;
95
+ const curatedPatch = `--- a/src/x.ts
96
+ +++ b/src/x.ts
97
+ @@ -1,1 +1,1 @@
98
+ -const limit = 100;
99
+ +const limit = 175;
100
+ `;
101
+
102
+ // Act
103
+ const feedback = renderFeedback({
104
+ verdictKind: "request_changes",
105
+ summary: "",
106
+ artifactContent: submittedPatch,
107
+ workingCopy: curatedPatch,
108
+ artifactType: "diff",
109
+ annotations: [],
110
+ });
111
+
112
+ // Assert
113
+ expect(feedback).toContain("## Curated changes");
114
+ expect(feedback).toContain("+const limit = 175;");
115
+ // the submitted patch is not re-diffed against the curated one
116
+ expect(feedback).not.toContain("Plan edits");
117
+ expect(feedback).not.toContain("++const");
118
+ });
119
+
120
+ test("a diff working copy that rejected everything says so", () => {
121
+ // Act
122
+ const feedback = renderFeedback({
123
+ verdictKind: "request_changes",
124
+ summary: "",
125
+ artifactContent: "--- a/x\n+++ b/x\n@@ -1 +1 @@\n-a\n+b\n",
126
+ workingCopy: "",
127
+ artifactType: "diff",
128
+ annotations: [],
129
+ });
130
+
131
+ // Assert
132
+ expect(feedback).toContain("## Curated changes");
133
+ expect(feedback).toContain("rejected all of your proposed changes");
43
134
  });
44
135
 
45
136
  test("annotations are located by quote and section", () => {
46
- const out = renderFeedback({
137
+ // Act
138
+ const feedback = renderFeedback({
47
139
  verdictKind: "comment",
48
140
  summary: "",
49
141
  artifactContent: PLAN,
50
142
  annotations: [
51
- ann({
143
+ makeAnnotation({
52
144
  anchor: { quote: "daemon memory", prefix: "live only in ", suffix: " today." },
53
145
  }),
54
146
  ],
55
147
  });
56
- expect(out).toContain("## Annotations (1)");
57
- expect(out).toContain("### 1. Comment (§ Context)");
58
- expect(out).toContain("> daemon memory");
59
- expect(out).toContain("Please clarify.");
148
+
149
+ // Assert
150
+ expect(feedback).toContain("## Annotations (1)");
151
+ expect(feedback).toContain("### 1. Comment (§ Context)");
152
+ expect(feedback).toContain("> daemon memory");
153
+ expect(feedback).toContain("Please clarify.");
60
154
  });
61
155
 
62
- test("suggestions render as replace/with pairs", () => {
63
- const out = renderFeedback({
64
- verdictKind: "request_changes",
156
+ test("a discussion renders as one item: the root comment, then its replies in order", () => {
157
+ // Arrange: a root, a later reply, an earlier reply, and a reply whose root is gone
158
+ const anchor = { quote: "daemon memory", prefix: "live only in ", suffix: " today." };
159
+
160
+ // Act
161
+ const feedback = renderFeedback({
162
+ verdictKind: "comment",
65
163
  summary: "",
66
164
  artifactContent: PLAN,
67
165
  annotations: [
68
- ann({
69
- kind: "suggestion",
70
- body: "one durable JSON record",
71
- anchor: { quote: "one JSON document", prefix: "written as ", suffix: " per session." },
166
+ makeAnnotation({ id: "root", anchor, body: "Which daemon?" }),
167
+ makeAnnotation({
168
+ id: "late",
169
+ anchor,
170
+ body: "The local one, I think.",
171
+ replyTo: "root",
172
+ createdAt: "2026-08-07T00:02:00.000Z",
173
+ }),
174
+ makeAnnotation({
175
+ id: "early",
176
+ anchor,
177
+ body: "Worth stating in the plan.",
178
+ replyTo: "root",
179
+ author: "SHA256:ana",
180
+ createdAt: "2026-08-07T00:01:00.000Z",
72
181
  }),
182
+ makeAnnotation({ id: "stray", anchor, body: "Standalone.", replyTo: "gone" }),
73
183
  ],
74
184
  });
75
- expect(out).toContain("### 1. Suggested change (§ Storage)");
76
- expect(out).toContain("Replace:\n> one JSON document");
77
- expect(out).toContain("With:\n> one durable JSON record");
185
+
186
+ // Assert: two items, replies listed under the root by time
187
+ expect(feedback).toContain("## Annotations (2)");
188
+ expect(feedback).toContain(
189
+ "Which daemon?\n\nReplies:\n\n- Worth stating in the plan.\n- The local one, I think.\n\nannotation id: `root`",
190
+ );
191
+ expect(feedback).toContain("### 2. Comment");
192
+ expect(feedback).toContain("Standalone.");
193
+ expect(feedback).not.toContain("annotation id: `late`");
194
+ });
195
+
196
+ test("a reply whose root was addressed leaves the document with it", () => {
197
+ // Arrange: the root is addressed, its reply is not marked, a stray reply's root is unknown
198
+ const anchor = { quote: "daemon memory", prefix: "live only in ", suffix: " today." };
199
+
200
+ // Act
201
+ const feedback = renderFeedback({
202
+ verdictKind: "comment",
203
+ summary: "",
204
+ artifactContent: PLAN,
205
+ annotations: [
206
+ makeAnnotation({
207
+ id: "root",
208
+ anchor,
209
+ body: "Which daemon?",
210
+ resolution: { revision: 2, source: "agent" },
211
+ }),
212
+ makeAnnotation({ id: "reply", anchor, body: "The local one.", replyTo: "root" }),
213
+ makeAnnotation({ id: "stray", anchor, body: "Standalone.", replyTo: "gone" }),
214
+ ],
215
+ });
216
+
217
+ // Assert: only the stray reply remains, as its own item
218
+ expect(feedback).toContain("## Annotations (1)");
219
+ expect(feedback).not.toContain("The local one.");
220
+ expect(feedback).toContain("Standalone.");
78
221
  });
79
222
 
80
223
  test("orphaned anchors are flagged, never dropped", () => {
81
- const out = renderFeedback({
224
+ // Act
225
+ const feedback = renderFeedback({
82
226
  verdictKind: "comment",
83
227
  summary: "",
84
228
  artifactContent: PLAN,
85
- annotations: [ann({ anchor: { quote: "text that is gone", prefix: "", suffix: "" } })],
229
+ annotations: [
230
+ makeAnnotation({ anchor: { quote: "text that is gone", prefix: "", suffix: "" } }),
231
+ ],
86
232
  });
87
- expect(out).toContain("[orphaned anchor: the quoted text is no longer present]");
88
- expect(out).toContain("> text that is gone");
233
+
234
+ // Assert
235
+ expect(feedback).toContain("[orphaned anchor: the quoted text is no longer present]");
236
+ expect(feedback).toContain("> text that is gone");
89
237
  });
90
238
 
91
239
  test("empty review says so explicitly", () => {
92
- const out = renderFeedback({
240
+ // Act
241
+ const feedback = renderFeedback({
93
242
  verdictKind: "approve",
94
243
  summary: "",
95
244
  artifactContent: PLAN,
96
245
  annotations: [],
97
246
  });
98
- expect(out).toContain("_No edits or annotations._");
247
+
248
+ // Assert
249
+ expect(feedback).toContain("_No edits or annotations._");
250
+ });
251
+
252
+ test("agent notes never come back as feedback items", () => {
253
+ // Arrange
254
+ const note = makeAnnotation({
255
+ kind: "note",
256
+ anchor: { quote: "src/a.ts", prefix: "", suffix: "" },
257
+ body: "The agent's own explanation of the change.",
258
+ });
259
+
260
+ // Act
261
+ const noteOnly = renderFeedback({
262
+ verdictKind: "approve",
263
+ summary: "",
264
+ artifactContent: PLAN,
265
+ annotations: [note],
266
+ });
267
+
268
+ // Assert
269
+ expect(noteOnly).toContain("_No edits or annotations._");
270
+ expect(noteOnly).not.toContain("The agent's own explanation of the change.");
271
+ const mixed = renderFeedback({
272
+ verdictKind: "request_changes",
273
+ summary: "",
274
+ artifactContent: PLAN,
275
+ annotations: [
276
+ note,
277
+ makeAnnotation({
278
+ anchor: { quote: "one JSON document", prefix: "written as ", suffix: " per session." },
279
+ }),
280
+ ],
281
+ });
282
+
283
+ expect(mixed).toContain("## Annotations (1)");
284
+ expect(mixed).not.toContain("The agent's own explanation of the change.");
99
285
  });
100
286
 
101
287
  test("anchors resolve against the working copy when present", () => {
288
+ // Arrange
102
289
  // annotation made on text that only exists in the edited copy
103
290
  const working = PLAN.replace("## Storage", "## Persistence");
104
- const out = renderFeedback({
291
+
292
+ // Act
293
+ const feedback = renderFeedback({
105
294
  verdictKind: "request_changes",
106
295
  summary: "",
107
296
  artifactContent: PLAN,
108
297
  workingCopy: working,
109
298
  annotations: [
110
- ann({ anchor: { quote: "one JSON document", prefix: "written as ", suffix: " per session." } }),
299
+ makeAnnotation({
300
+ anchor: { quote: "one JSON document", prefix: "written as ", suffix: " per session." },
301
+ }),
302
+ ],
303
+ });
304
+
305
+ // Assert
306
+ expect(feedback).toContain("(§ Persistence)");
307
+ });
308
+
309
+ test("a prototype annotation locates by its selector and is never orphan-flagged", () => {
310
+ // Act
311
+ const feedback = renderFeedback({
312
+ verdictKind: "request_changes",
313
+ summary: "",
314
+ artifactType: "prototype",
315
+ artifactContent: "<main><div class='card'>Pricing</div></main>",
316
+ annotations: [
317
+ makeAnnotation({
318
+ body: "Tighten the padding.",
319
+ anchor: { quote: "Pricing", prefix: "", suffix: "", selector: "main > div.card" },
320
+ }),
321
+ ],
322
+ artifactPath: "proto.html",
323
+ });
324
+
325
+ // Assert
326
+ expect(feedback).toContain("(main > div.card)");
327
+ expect(feedback).toContain("Tighten the padding.");
328
+ expect(feedback).toContain("by its CSS selector");
329
+ expect(feedback).not.toContain("orphaned anchor");
330
+ });
331
+ });
332
+
333
+ describe("renderFeedback target grouping", () => {
334
+ test("file notes group under their own path section, apart from the artifact", () => {
335
+ const feedback = renderFeedback({
336
+ verdictKind: "request_changes",
337
+ summary: "",
338
+ artifactContent: PLAN,
339
+ artifactPath: "docs/plan.md",
340
+ annotations: [
341
+ makeAnnotation({
342
+ id: "art1",
343
+ anchor: { quote: "Sessions are written", prefix: "", suffix: "" },
344
+ body: "artifact note",
345
+ }),
346
+ makeAnnotation({
347
+ id: "f1",
348
+ anchor: { quote: "const a = 1", prefix: "", suffix: "" },
349
+ target: { kind: "file", path: "src/a.ts", rev: "worktree" },
350
+ body: "file a note",
351
+ }),
352
+ makeAnnotation({
353
+ id: "f2",
354
+ anchor: { quote: "const b = 2", prefix: "", suffix: "" },
355
+ target: { kind: "file", path: "src/b.ts", rev: "worktree" },
356
+ body: "file b note",
357
+ }),
358
+ ],
359
+ });
360
+
361
+ // Assert - the artifact keeps its section; each file gets its own, with its note
362
+ expect(feedback).toContain("## Annotations (1)");
363
+ expect(feedback).toContain("artifact note");
364
+ expect(feedback).toContain("## src/a.ts (1)");
365
+ expect(feedback).toContain("file a note");
366
+ expect(feedback).toContain("## src/b.ts (1)");
367
+ expect(feedback).toContain("file b note");
368
+ // the file note's path is the heading, not an inline "(in ...)" locator on the artifact list
369
+ expect(feedback).not.toContain("(in src/a.ts)");
370
+ });
371
+
372
+ test("a quick-action reference expands to its body; an unknown skill reference passes through", () => {
373
+ const feedback = renderFeedback({
374
+ verdictKind: "comment",
375
+ summary: "",
376
+ artifactContent: PLAN,
377
+ annotations: [
378
+ makeAnnotation({
379
+ anchor: { quote: "Storage", prefix: "", suffix: "" },
380
+ body: "/restate-simplified and also /vitest-patterns here",
381
+ }),
111
382
  ],
383
+ artifactPath: "docs/plan.md",
384
+ actionBodies: { "restate-simplified": "Restate this as the simplest thing that works." },
112
385
  });
113
- expect(out).toContain("(§ Persistence)");
386
+
387
+ expect(feedback).toContain("Restate this as the simplest thing that works.");
388
+ // the unknown reference (a user skill) is left verbatim for the harness to resolve
389
+ expect(feedback).toContain("/vitest-patterns");
390
+ expect(feedback).not.toContain("/restate-simplified");
114
391
  });
115
392
  });