@cueloop/schema 0.1.0-alpha.63 → 0.1.0-alpha.65
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 +1 -1
- package/src/anchor.test.ts +115 -2
- package/src/anchor.ts +199 -6
- package/src/feedback.test.ts +67 -0
- package/src/feedback.ts +23 -1
- package/src/history.test.ts +395 -0
- package/src/history.ts +419 -0
- package/src/index.ts +2 -0
- package/src/path-view.test.ts +201 -0
- package/src/path-view.ts +85 -0
- package/src/types.ts +43 -4
- package/src/working-copy.ts +14 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/schema",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.65",
|
|
4
4
|
"description": "The ReviewSession primitive: types, markdown block model, quote-primary anchors, feedback serialization",
|
|
5
5
|
"homepage": "https://github.com/mmurakaru/cueloop#readme",
|
|
6
6
|
"bugs": {
|
package/src/anchor.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { parseBlocks } from "./markdown";
|
|
3
|
-
import { makeAnchor, resolveAnchor } from "./anchor";
|
|
3
|
+
import { BLOCK_SEPARATOR, makeAnchor, resolveAnchor } from "./anchor";
|
|
4
4
|
|
|
5
5
|
const DOC = `# Plan
|
|
6
6
|
|
|
@@ -140,7 +140,11 @@ describe("anchor cascade", () => {
|
|
|
140
140
|
const blocks = blocksOf(DOC);
|
|
141
141
|
const contextBlockIndex = blocks.findIndex((block) => block.text.startsWith("Review sessions"));
|
|
142
142
|
// a typo the reviewer never saw: "sesssions"
|
|
143
|
-
const anchor = {
|
|
143
|
+
const anchor = {
|
|
144
|
+
quote: "Review sesssions currently live",
|
|
145
|
+
prefix: "",
|
|
146
|
+
suffix: "",
|
|
147
|
+
};
|
|
144
148
|
|
|
145
149
|
// Act
|
|
146
150
|
const resolved = resolveAnchor(anchor, blocks)!;
|
|
@@ -207,3 +211,112 @@ describe("anchor cascade", () => {
|
|
|
207
211
|
expect(resolveAnchor({ quote: "", prefix: "", suffix: "" }, blocks)).toBeNull();
|
|
208
212
|
});
|
|
209
213
|
});
|
|
214
|
+
|
|
215
|
+
describe("anchors spanning blocks", () => {
|
|
216
|
+
const SPANNING_DOC = `## Phase 1
|
|
217
|
+
|
|
218
|
+
All persistence lives in a new module,
|
|
219
|
+
so a crash can never leave a torn file on disk.
|
|
220
|
+
|
|
221
|
+
- server/storage/store.ts - the SessionStore class
|
|
222
|
+
- server/storage/schema.ts - the on-disk record shape
|
|
223
|
+
|
|
224
|
+
## Phase 2
|
|
225
|
+
`;
|
|
226
|
+
const blocks = parseBlocks(SPANNING_DOC);
|
|
227
|
+
const paragraph = blocks.findIndex((block) => block.text.startsWith("All persistence"));
|
|
228
|
+
const secondBullet = blocks.findIndex((block) => block.text.startsWith("server/storage/schema"));
|
|
229
|
+
const start = blocks[paragraph]!.text.indexOf("so a crash");
|
|
230
|
+
const end = "server/storage/schema.ts".length;
|
|
231
|
+
const anchor = makeAnchor(blocks, paragraph, start, end, secondBullet);
|
|
232
|
+
|
|
233
|
+
test("the quote is the spanned text joined by a blank line, with an end block hint", () => {
|
|
234
|
+
// Assert
|
|
235
|
+
expect(anchor.quote).toBe(
|
|
236
|
+
[
|
|
237
|
+
"so a crash can never leave a torn file on disk.",
|
|
238
|
+
"server/storage/store.ts - the SessionStore class",
|
|
239
|
+
"server/storage/schema.ts",
|
|
240
|
+
].join(BLOCK_SEPARATOR),
|
|
241
|
+
);
|
|
242
|
+
expect(anchor.blockIndex).toBe(paragraph);
|
|
243
|
+
expect(anchor.endBlockIndex).toBe(secondBullet);
|
|
244
|
+
expect(anchor.suffix).toBe(" - the on-disk record sh");
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("a single-block anchor carries no end block hint", () => {
|
|
248
|
+
// Assert
|
|
249
|
+
expect(makeAnchor(blocks, paragraph, 0, 3)).not.toHaveProperty("endBlockIndex");
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("resolves exactly back onto the first and last block offsets", () => {
|
|
253
|
+
// Act
|
|
254
|
+
const resolved = resolveAnchor(anchor, blocks);
|
|
255
|
+
|
|
256
|
+
// Assert
|
|
257
|
+
expect(resolved).toEqual({
|
|
258
|
+
blockIndex: paragraph,
|
|
259
|
+
start,
|
|
260
|
+
endBlockIndex: secondBullet,
|
|
261
|
+
end,
|
|
262
|
+
approximate: false,
|
|
263
|
+
strategy: "exact",
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("follows the span when blocks are inserted above it", () => {
|
|
268
|
+
// Arrange: a new paragraph shifts every block index by one
|
|
269
|
+
const shifted = parseBlocks(
|
|
270
|
+
SPANNING_DOC.replace("## Phase 1\n", "## Phase 1\n\nA new intro.\n"),
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
// Act
|
|
274
|
+
const resolved = resolveAnchor(anchor, shifted);
|
|
275
|
+
|
|
276
|
+
// Assert
|
|
277
|
+
expect(resolved?.blockIndex).toBe(paragraph + 1);
|
|
278
|
+
expect(resolved?.endBlockIndex).toBe(secondBullet + 1);
|
|
279
|
+
expect(resolved?.start).toBe(start);
|
|
280
|
+
expect(resolved?.end).toBe(end);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("re-binds fuzzily after a light edit inside the span", () => {
|
|
284
|
+
// Arrange
|
|
285
|
+
const edited = parseBlocks(SPANNING_DOC.replace("SessionStore class", "SessionStore type"));
|
|
286
|
+
|
|
287
|
+
// Act
|
|
288
|
+
const resolved = resolveAnchor(anchor, edited);
|
|
289
|
+
|
|
290
|
+
// Assert: still one span from the paragraph to the second bullet
|
|
291
|
+
expect(resolved?.strategy).toBe("fuzzy");
|
|
292
|
+
expect(resolved?.approximate).toBe(true);
|
|
293
|
+
expect(resolved?.blockIndex).toBe(paragraph);
|
|
294
|
+
expect(resolved?.endBlockIndex).toBe(secondBullet);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("orphans when the spanned passage is gone", () => {
|
|
298
|
+
// Arrange
|
|
299
|
+
const gutted = parseBlocks("## Phase 1\n\nSomething else entirely.\n");
|
|
300
|
+
|
|
301
|
+
// Assert
|
|
302
|
+
expect(resolveAnchor(anchor, gutted)).toBeNull();
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("a match landing on a separator edge is pulled onto the neighbouring block", () => {
|
|
306
|
+
// Arrange: a quote whose exact text starts with the separator only exists
|
|
307
|
+
// when its first block portion is empty, which makeAnchor never produces;
|
|
308
|
+
// simulate the stale case directly
|
|
309
|
+
const staleAnchor = {
|
|
310
|
+
...anchor,
|
|
311
|
+
quote: `${BLOCK_SEPARATOR}server/storage/store.ts - the SessionStore class`,
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
// Act
|
|
315
|
+
const resolved = resolveAnchor(staleAnchor, blocks);
|
|
316
|
+
|
|
317
|
+
// Assert: the span opens at the bullet, not on the blank line above it
|
|
318
|
+
expect(resolved?.blockIndex).toBe(secondBullet - 1);
|
|
319
|
+
expect(resolved?.start).toBe(0);
|
|
320
|
+
expect(resolved?.endBlockIndex).toBe(secondBullet - 1);
|
|
321
|
+
});
|
|
322
|
+
});
|
package/src/anchor.ts
CHANGED
|
@@ -10,6 +10,10 @@
|
|
|
10
10
|
* 5. orphan - none matched; keep the quote for display, drop the highlight
|
|
11
11
|
* Prefix/suffix selectors and the recorded position break ties within a tier.
|
|
12
12
|
* Never silently bind to the wrong text: fuzzy needs a high similarity floor.
|
|
13
|
+
*
|
|
14
|
+
* A quote may span consecutive blocks: its text is the blocks' text joined by
|
|
15
|
+
* BLOCK_SEPARATOR, and it resolves against the document joined the same way,
|
|
16
|
+
* so one comment can cover the tail of a paragraph and the bullets under it.
|
|
13
17
|
*/
|
|
14
18
|
|
|
15
19
|
import type { Anchor } from "./types";
|
|
@@ -22,12 +26,18 @@ const ANCHOR_CONTEXT_CHARS = 24;
|
|
|
22
26
|
/** Minimum similarity a fuzzy match needs before the resolver will trust it. */
|
|
23
27
|
const FUZZY_MINIMUM_SIMILARITY = 0.75;
|
|
24
28
|
|
|
29
|
+
/** Joins block texts in a spanning quote and in the document it resolves against. */
|
|
30
|
+
export const BLOCK_SEPARATOR = "\n\n";
|
|
31
|
+
|
|
25
32
|
/** How the quote bound to the block text, weakest binding named last. */
|
|
26
33
|
export type AnchorMatchStrategy = "exact" | "trimmed" | "normalized" | "fuzzy";
|
|
27
34
|
|
|
28
35
|
export interface ResolvedAnchor {
|
|
29
36
|
blockIndex: number;
|
|
30
37
|
start: number;
|
|
38
|
+
/** Last block of the quote; equals blockIndex unless the quote spans blocks. */
|
|
39
|
+
endBlockIndex: number;
|
|
40
|
+
/** Offset within the end block. */
|
|
31
41
|
end: number;
|
|
32
42
|
/** True whenever the binding was not an exact quote match. */
|
|
33
43
|
approximate: boolean;
|
|
@@ -40,17 +50,32 @@ export function makeAnchor(
|
|
|
40
50
|
blockIndex: number,
|
|
41
51
|
start: number,
|
|
42
52
|
end: number,
|
|
53
|
+
endBlockIndex: number = blockIndex,
|
|
43
54
|
): Anchor {
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
55
|
+
const startText = blocks[blockIndex]?.text ?? "";
|
|
56
|
+
const endText = blocks[endBlockIndex]?.text ?? "";
|
|
57
|
+
const quote = blocks
|
|
58
|
+
.slice(blockIndex, endBlockIndex + 1)
|
|
59
|
+
.map((block, offset, spanned) => {
|
|
60
|
+
const isFirst = offset === 0;
|
|
61
|
+
const isLast = offset === spanned.length - 1;
|
|
62
|
+
|
|
63
|
+
return block.text.slice(isFirst ? start : 0, isLast ? end : block.text.length);
|
|
64
|
+
})
|
|
65
|
+
.join(BLOCK_SEPARATOR);
|
|
66
|
+
|
|
67
|
+
const anchor: Anchor = {
|
|
68
|
+
quote,
|
|
69
|
+
prefix: startText.slice(Math.max(0, start - ANCHOR_CONTEXT_CHARS), start),
|
|
70
|
+
suffix: endText.slice(end, end + ANCHOR_CONTEXT_CHARS),
|
|
50
71
|
blockIndex,
|
|
51
72
|
start,
|
|
52
73
|
end,
|
|
53
74
|
};
|
|
75
|
+
|
|
76
|
+
if (endBlockIndex !== blockIndex) anchor.endBlockIndex = endBlockIndex;
|
|
77
|
+
|
|
78
|
+
return anchor;
|
|
54
79
|
}
|
|
55
80
|
|
|
56
81
|
interface Candidate {
|
|
@@ -149,15 +174,183 @@ function resolved(candidate: Candidate, strategy: AnchorMatchStrategy): Resolved
|
|
|
149
174
|
return {
|
|
150
175
|
blockIndex: candidate.blockIndex,
|
|
151
176
|
start: candidate.start,
|
|
177
|
+
endBlockIndex: candidate.blockIndex,
|
|
152
178
|
end: candidate.end,
|
|
153
179
|
approximate: strategy !== "exact",
|
|
154
180
|
strategy,
|
|
155
181
|
};
|
|
156
182
|
}
|
|
157
183
|
|
|
184
|
+
/* ------------------------------------------------ quotes spanning blocks */
|
|
185
|
+
|
|
186
|
+
/** The blocks as one document, with where each block's text starts in it. */
|
|
187
|
+
interface JoinedDocument {
|
|
188
|
+
text: string;
|
|
189
|
+
starts: number[];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function joinBlocks(blocks: Block[]): JoinedDocument {
|
|
193
|
+
const starts: number[] = [];
|
|
194
|
+
let text = "";
|
|
195
|
+
|
|
196
|
+
blocks.forEach((block, blockIndex) => {
|
|
197
|
+
if (blockIndex > 0) text += BLOCK_SEPARATOR;
|
|
198
|
+
starts.push(text.length);
|
|
199
|
+
text += block.text;
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
return { text, starts };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The block and in-block offset of a document offset. An offset inside a
|
|
207
|
+
* separator belongs to the block after it when it opens a range and to the
|
|
208
|
+
* block before it when it closes one, so a span never starts or ends on the
|
|
209
|
+
* blank line between blocks.
|
|
210
|
+
*/
|
|
211
|
+
interface BlockOffset {
|
|
212
|
+
blockIndex: number;
|
|
213
|
+
offset: number;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function locateInBlocks(
|
|
217
|
+
blocks: Block[],
|
|
218
|
+
starts: number[],
|
|
219
|
+
offset: number,
|
|
220
|
+
edge: "start" | "end",
|
|
221
|
+
): BlockOffset {
|
|
222
|
+
let blockIndex = starts.findLastIndex((start) => start <= offset);
|
|
223
|
+
|
|
224
|
+
if (blockIndex < 0) blockIndex = 0;
|
|
225
|
+
const blockEnd = starts[blockIndex]! + blocks[blockIndex]!.text.length;
|
|
226
|
+
|
|
227
|
+
if (edge === "start" && offset >= blockEnd && blockIndex < blocks.length - 1) {
|
|
228
|
+
return { blockIndex: blockIndex + 1, offset: 0 };
|
|
229
|
+
}
|
|
230
|
+
if (edge === "end" && offset === starts[blockIndex] && blockIndex > 0) {
|
|
231
|
+
return {
|
|
232
|
+
blockIndex: blockIndex - 1,
|
|
233
|
+
offset: blocks[blockIndex - 1]!.text.length,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
blockIndex,
|
|
239
|
+
offset: Math.min(offset, blockEnd) - starts[blockIndex]!,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Context agreement plus position hints for a window of the joined document. */
|
|
244
|
+
function documentScore(
|
|
245
|
+
anchor: Anchor,
|
|
246
|
+
documentText: string,
|
|
247
|
+
start: number,
|
|
248
|
+
end: number,
|
|
249
|
+
startBlockIndex: number,
|
|
250
|
+
): number {
|
|
251
|
+
const prefix = documentText.slice(Math.max(0, start - ANCHOR_CONTEXT_CHARS), start);
|
|
252
|
+
const suffix = documentText.slice(end, end + ANCHOR_CONTEXT_CHARS);
|
|
253
|
+
let score = 0;
|
|
254
|
+
|
|
255
|
+
if (prefix === anchor.prefix) score += 2;
|
|
256
|
+
if (suffix === anchor.suffix) score += 2;
|
|
257
|
+
if (anchor.blockIndex === startBlockIndex) score += 1;
|
|
258
|
+
|
|
259
|
+
return score;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
interface DocumentCandidate {
|
|
263
|
+
start: number;
|
|
264
|
+
end: number;
|
|
265
|
+
score: number;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function findSpanningExact(
|
|
269
|
+
anchor: Anchor,
|
|
270
|
+
documentText: string,
|
|
271
|
+
quote: string,
|
|
272
|
+
starts: number[],
|
|
273
|
+
blocks: Block[],
|
|
274
|
+
): DocumentCandidate[] {
|
|
275
|
+
const candidates: DocumentCandidate[] = [];
|
|
276
|
+
|
|
277
|
+
for (
|
|
278
|
+
let matchStart = documentText.indexOf(quote);
|
|
279
|
+
matchStart !== -1;
|
|
280
|
+
matchStart = documentText.indexOf(quote, matchStart + 1)
|
|
281
|
+
) {
|
|
282
|
+
const matchEnd = matchStart + quote.length;
|
|
283
|
+
const startBlockIndex = locateInBlocks(blocks, starts, matchStart, "start").blockIndex;
|
|
284
|
+
|
|
285
|
+
candidates.push({
|
|
286
|
+
start: matchStart,
|
|
287
|
+
end: matchEnd,
|
|
288
|
+
score: documentScore(anchor, documentText, matchStart, matchEnd, startBlockIndex),
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return candidates;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function resolvedSpan(
|
|
296
|
+
blocks: Block[],
|
|
297
|
+
starts: number[],
|
|
298
|
+
candidate: DocumentCandidate,
|
|
299
|
+
strategy: AnchorMatchStrategy,
|
|
300
|
+
): ResolvedAnchor {
|
|
301
|
+
const startPosition = locateInBlocks(blocks, starts, candidate.start, "start");
|
|
302
|
+
const endPosition = locateInBlocks(blocks, starts, candidate.end, "end");
|
|
303
|
+
|
|
304
|
+
return {
|
|
305
|
+
blockIndex: startPosition.blockIndex,
|
|
306
|
+
start: startPosition.offset,
|
|
307
|
+
endBlockIndex: endPosition.blockIndex,
|
|
308
|
+
end: endPosition.offset,
|
|
309
|
+
approximate: strategy !== "exact",
|
|
310
|
+
strategy,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** The cascade for a quote that spans blocks, run over the joined document. */
|
|
315
|
+
function resolveSpanningAnchor(anchor: Anchor, blocks: Block[]): ResolvedAnchor | null {
|
|
316
|
+
const document = joinBlocks(blocks);
|
|
317
|
+
const best = (candidates: DocumentCandidate[]): DocumentCandidate | null =>
|
|
318
|
+
candidates.toSorted((left, right) => right.score - left.score)[0] ?? null;
|
|
319
|
+
|
|
320
|
+
const exact = best(
|
|
321
|
+
findSpanningExact(anchor, document.text, anchor.quote, document.starts, blocks),
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
if (exact) return resolvedSpan(blocks, document.starts, exact, "exact");
|
|
325
|
+
|
|
326
|
+
const trimmedQuote = anchor.quote.trim();
|
|
327
|
+
|
|
328
|
+
if (trimmedQuote !== anchor.quote) {
|
|
329
|
+
const trimmed = best(
|
|
330
|
+
findSpanningExact(anchor, document.text, trimmedQuote, document.starts, blocks),
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
if (trimmed) return resolvedSpan(blocks, document.starts, trimmed, "trimmed");
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const fuzzy = fuzzyFindBestMatch(trimmedQuote, document.text, FUZZY_MINIMUM_SIMILARITY);
|
|
337
|
+
|
|
338
|
+
if (fuzzy) {
|
|
339
|
+
return resolvedSpan(
|
|
340
|
+
blocks,
|
|
341
|
+
document.starts,
|
|
342
|
+
{ start: fuzzy.start, end: fuzzy.end, score: 0 },
|
|
343
|
+
"fuzzy",
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
|
|
158
350
|
/** Resolve an anchor against the current blocks, or null when orphaned. */
|
|
159
351
|
export function resolveAnchor(anchor: Anchor, blocks: Block[]): ResolvedAnchor | null {
|
|
160
352
|
if (anchor.quote === "") return null;
|
|
353
|
+
if (anchor.quote.includes(BLOCK_SEPARATOR)) return resolveSpanningAnchor(anchor, blocks);
|
|
161
354
|
|
|
162
355
|
const exact = pickBest(findExactCandidates(anchor, blocks, anchor.quote));
|
|
163
356
|
|
package/src/feedback.test.ts
CHANGED
|
@@ -153,6 +153,73 @@ describe("renderFeedback", () => {
|
|
|
153
153
|
expect(feedback).toContain("Please clarify.");
|
|
154
154
|
});
|
|
155
155
|
|
|
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",
|
|
163
|
+
summary: "",
|
|
164
|
+
artifactContent: PLAN,
|
|
165
|
+
annotations: [
|
|
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",
|
|
181
|
+
}),
|
|
182
|
+
makeAnnotation({ id: "stray", anchor, body: "Standalone.", replyTo: "gone" }),
|
|
183
|
+
],
|
|
184
|
+
});
|
|
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.");
|
|
221
|
+
});
|
|
222
|
+
|
|
156
223
|
test("orphaned anchors are flagged, never dropped", () => {
|
|
157
224
|
// Act
|
|
158
225
|
const feedback = renderFeedback({
|
package/src/feedback.ts
CHANGED
|
@@ -43,9 +43,23 @@ export function renderFeedback(input: FeedbackInput): string {
|
|
|
43
43
|
// agent notes are the submitter's own context - never echoed back as
|
|
44
44
|
// feedback - and annotations a previous revision already addressed stay out
|
|
45
45
|
// of the next document, so the agent only ever sees the open items
|
|
46
|
-
const
|
|
46
|
+
const open = input.annotations.filter(
|
|
47
47
|
(annotation) => !isAgentNote(annotation) && !isAddressed(annotation),
|
|
48
48
|
);
|
|
49
|
+
// a discussion is one item: the root comment, then its replies in order. A
|
|
50
|
+
// reply whose root was addressed leaves with it; only a reply whose root is
|
|
51
|
+
// gone entirely stands on its own
|
|
52
|
+
const openIds = new Set(open.map((annotation) => annotation.id));
|
|
53
|
+
const knownIds = new Set(input.annotations.map((annotation) => annotation.id));
|
|
54
|
+
const annotations = open.filter(
|
|
55
|
+
(annotation) =>
|
|
56
|
+
annotation.replyTo === undefined ||
|
|
57
|
+
(!openIds.has(annotation.replyTo) && !knownIds.has(annotation.replyTo)),
|
|
58
|
+
);
|
|
59
|
+
const repliesTo = (root: Annotation): Annotation[] =>
|
|
60
|
+
open
|
|
61
|
+
.filter((annotation) => annotation.replyTo === root.id)
|
|
62
|
+
.toSorted((left, right) => left.createdAt.localeCompare(right.createdAt));
|
|
49
63
|
const isDiff = input.artifactType === "diff";
|
|
50
64
|
const isPrototype = input.artifactType === "prototype";
|
|
51
65
|
// A diff's annotations anchor to the submitted patch rows; resolve against it,
|
|
@@ -131,6 +145,14 @@ export function renderFeedback(input: FeedbackInput): string {
|
|
|
131
145
|
lines.push("");
|
|
132
146
|
lines.push(annotation.body);
|
|
133
147
|
lines.push("");
|
|
148
|
+
const replies = repliesTo(annotation);
|
|
149
|
+
|
|
150
|
+
if (replies.length > 0) {
|
|
151
|
+
lines.push("Replies:");
|
|
152
|
+
lines.push("");
|
|
153
|
+
for (const reply of replies) lines.push(`- ${reply.body.replace(/\n/g, "\n ")}`);
|
|
154
|
+
lines.push("");
|
|
155
|
+
}
|
|
134
156
|
lines.push(`annotation id: \`${annotation.id}\``);
|
|
135
157
|
lines.push("");
|
|
136
158
|
});
|