@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.
@@ -0,0 +1,58 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { registerParticipant } from "./participants";
3
+ import { SCHEMA_VERSION, type Thread } from "./types";
4
+
5
+ function emptySession(): Thread {
6
+ return {
7
+ schemaVersion: SCHEMA_VERSION,
8
+ id: "ses_1",
9
+ workspace: { repoRoot: "/repo", branch: "main" },
10
+ artifact: { type: "plan", content: "# Plan\n", meta: {} },
11
+ revisions: [],
12
+ annotations: [],
13
+ verdict: null,
14
+ status: "pending",
15
+ createdAt: "2026-01-01T00:00:00.000Z",
16
+ };
17
+ }
18
+
19
+ describe("registerParticipant", () => {
20
+ test("adds a new author with a display name", () => {
21
+ // Act
22
+ const next = registerParticipant(emptySession(), "SHA256:ana", "Ana");
23
+
24
+ // Assert
25
+ expect(next.participants).toEqual([{ id: "SHA256:ana", provider: "ssh", name: "Ana" }]);
26
+ });
27
+
28
+ test("records presence without a name (renders anonymous)", () => {
29
+ // Act
30
+ const next = registerParticipant(emptySession(), "SHA256:ana");
31
+
32
+ // Assert
33
+ expect(next.participants).toEqual([{ id: "SHA256:ana", provider: "ssh" }]);
34
+ });
35
+
36
+ test("a nameless revisit never erases a name a past visit set", () => {
37
+ // Arrange
38
+ const named = registerParticipant(emptySession(), "SHA256:ana", "Ana");
39
+
40
+ // Act
41
+ const next = registerParticipant(named, "SHA256:ana");
42
+
43
+ // Assert
44
+ expect(next).toBe(named);
45
+ expect(next.participants).toEqual([{ id: "SHA256:ana", provider: "ssh", name: "Ana" }]);
46
+ });
47
+
48
+ test("a later name updates the existing identity in place", () => {
49
+ // Arrange
50
+ const present = registerParticipant(emptySession(), "SHA256:ana");
51
+
52
+ // Act
53
+ const next = registerParticipant(present, "SHA256:ana", "Ana");
54
+
55
+ // Assert
56
+ expect(next.participants).toEqual([{ id: "SHA256:ana", provider: "ssh", name: "Ana" }]);
57
+ });
58
+ });
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The participant registry: every annotation carries an `author` id, and the
3
+ * rail resolves that id to a display name through `session.participants`. This
4
+ * registers an author in that registry so a name search on "participant" or
5
+ * "author name" lands here.
6
+ */
7
+
8
+ import type { Identity, Thread } from "./types";
9
+
10
+ /**
11
+ * Return the session with `author` present in the participant registry, setting
12
+ * the display name when one is given. A nameless call only records presence (the
13
+ * rail renders anonymous) and never erases a name a past visit set. Immutable:
14
+ * the input session is not mutated.
15
+ */
16
+ export function registerParticipant(session: Thread, author: string, name?: string): Thread {
17
+ const participants = session.participants ?? [];
18
+ const existing = participants.find((participant) => participant.id === author);
19
+ const trimmed = name?.trim();
20
+
21
+ if (existing && !trimmed) return session;
22
+ const next: Identity = {
23
+ id: author,
24
+ provider: "ssh",
25
+ ...(trimmed ? { name: trimmed } : existing?.name ? { name: existing.name } : {}),
26
+ };
27
+
28
+ return {
29
+ ...session,
30
+ participants: existing
31
+ ? participants.map((participant) => (participant.id === author ? next : participant))
32
+ : [...participants, next],
33
+ };
34
+ }
@@ -0,0 +1,201 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ appendEntry,
4
+ createBranch,
5
+ historyFromLinear,
6
+ navigateTo,
7
+ switchBranch,
8
+ type SessionHistory,
9
+ } from "./history";
10
+ import { applyPathView, viewFollowing, viewOfPath } from "./path-view";
11
+ import type { Annotation, Thread } from "./types";
12
+
13
+ const AT = "2026-09-01T10:00:00.000Z";
14
+
15
+ function annotation(id: string): Annotation {
16
+ return {
17
+ id,
18
+ kind: "comment",
19
+ anchor: { quote: "q", prefix: "", suffix: "" },
20
+ body: id,
21
+ createdAt: AT,
22
+ };
23
+ }
24
+
25
+ /** rev1 (agent) -> comment a1 -> reviewer edit -> comment a2, all on main. */
26
+ function threaded(): SessionHistory {
27
+ let history = historyFromLinear({
28
+ id: "ses_1",
29
+ revisions: [{ revision: 1, content: "Plan v1", submittedAt: AT }],
30
+ annotations: [],
31
+ verdict: null,
32
+ createdAt: AT,
33
+ });
34
+
35
+ history = appendEntry(history, { type: "comment", annotationId: "a1", createdAt: AT }).history;
36
+ history = appendEntry(history, {
37
+ type: "revision",
38
+ by: "reviewer",
39
+ content: "Plan v1 edited",
40
+ createdAt: AT,
41
+ }).history;
42
+ history = appendEntry(history, { type: "comment", annotationId: "a2", createdAt: AT }).history;
43
+
44
+ return history;
45
+ }
46
+
47
+ describe("viewOfPath", () => {
48
+ test("the artifact is the agent's last revision and the reviewer's head is the working copy", () => {
49
+ // Act
50
+ const view = viewOfPath(threaded(), [annotation("a1"), annotation("a2")]);
51
+
52
+ // Assert
53
+ expect(view.content).toBe("Plan v1");
54
+ expect(view.workingCopy).toBe("Plan v1 edited");
55
+ expect(view.annotations.map((entry) => entry.id)).toEqual(["a1", "a2"]);
56
+ expect(view.shelvedAnnotations).toEqual([]);
57
+ });
58
+
59
+ test("navigating back shelves the comments the path no longer reaches, and forward again unshelves them", () => {
60
+ // Arrange: a branch that stays at the full path, main moved back to the first comment
61
+ let history = createBranch(threaded(), "full");
62
+ const known = [annotation("a1"), annotation("a2")];
63
+
64
+ history = switchBranch(history, "main");
65
+ history = navigateTo(history, history.entries[1]!.id);
66
+
67
+ // Act
68
+ const back = viewOfPath(history, known);
69
+ const forward = viewOfPath(
70
+ switchBranch(history, "full"),
71
+ back.annotations.concat(back.shelvedAnnotations),
72
+ );
73
+
74
+ // Assert
75
+ expect(back.workingCopy).toBeUndefined();
76
+ expect(back.annotations.map((entry) => entry.id)).toEqual(["a1"]);
77
+ expect(back.shelvedAnnotations.map((entry) => entry.id)).toEqual(["a2"]);
78
+ expect(forward.annotations.map((entry) => entry.id)).toEqual(["a1", "a2"]);
79
+ expect(forward.shelvedAnnotations).toEqual([]);
80
+ });
81
+
82
+ test("a removed comment stays shelved, not deleted", () => {
83
+ // Arrange
84
+ let history = threaded();
85
+
86
+ history = appendEntry(history, {
87
+ type: "comment-removed",
88
+ annotationId: "a1",
89
+ createdAt: AT,
90
+ }).history;
91
+
92
+ // Act
93
+ const view = viewOfPath(history, [annotation("a1"), annotation("a2")]);
94
+
95
+ // Assert
96
+ expect(view.annotations.map((entry) => entry.id)).toEqual(["a2"]);
97
+ expect(view.shelvedAnnotations.map((entry) => entry.id)).toEqual(["a1"]);
98
+ });
99
+ });
100
+
101
+ describe("applyPathView", () => {
102
+ test("writes the view into the record and removes the fields the view leaves empty", () => {
103
+ // Arrange
104
+ const session: Thread = {
105
+ schemaVersion: "1",
106
+ id: "ses_1",
107
+ workspace: { repoRoot: "/repo", branch: "main" },
108
+ artifact: { type: "plan", content: "old", meta: {} },
109
+ revisions: [],
110
+ annotations: [annotation("a2")],
111
+ shelvedAnnotations: [annotation("a1")],
112
+ workingCopy: "edited",
113
+ verdict: null,
114
+ status: "pending",
115
+ createdAt: AT,
116
+ };
117
+
118
+ // Act
119
+ applyPathView(session, {
120
+ content: "Plan v1",
121
+ workingCopy: undefined,
122
+ annotations: [annotation("a1"), annotation("a2")],
123
+ shelvedAnnotations: [],
124
+ });
125
+
126
+ // Assert
127
+ expect(session.artifact.content).toBe("Plan v1");
128
+ expect(session.annotations.map((entry) => entry.id)).toEqual(["a1", "a2"]);
129
+ expect("workingCopy" in session).toBe(false);
130
+ expect("shelvedAnnotations" in session).toBe(false);
131
+ });
132
+ });
133
+
134
+ describe("viewFollowing", () => {
135
+ test("a share carries one branch's path: its entries, tip, labels, and open comments, nothing shelved", () => {
136
+ // Arrange: main has a1 and an edit; a branch off a1 has its own comment; the owner stands on the branch
137
+ let history = threaded();
138
+
139
+ history = navigateTo(history, history.entries[1]!.id);
140
+ history = createBranch(history, "alt");
141
+ history = appendEntry(history, { type: "comment", annotationId: "b1", createdAt: AT }).history;
142
+ const session: Thread = {
143
+ schemaVersion: "1",
144
+ id: "ses_1",
145
+ workspace: { repoRoot: "/repo", branch: "main" },
146
+ artifact: { type: "plan", content: "Plan v1", meta: {} },
147
+ revisions: [],
148
+ annotations: [annotation("a1"), annotation("b1")],
149
+ shelvedAnnotations: [annotation("a2")],
150
+ workingCopy: "Plan v1 edited",
151
+ history,
152
+ verdict: null,
153
+ status: "pending",
154
+ createdAt: AT,
155
+ };
156
+
157
+ // Act
158
+ const shared = viewFollowing(session);
159
+
160
+ // Assert: main's path only, back at the first comment, no working copy, no shelf, the branch named
161
+ expect(shared.shareBranch).toBe("main");
162
+ expect(shared.history!.tips).toEqual({ main: history.entries[1]!.id });
163
+ expect(shared.history!.entries.map((entry) => entry.type)).toEqual(["revision", "comment"]);
164
+ expect(shared.annotations.map((entry) => entry.id)).toEqual(["a1"]);
165
+ expect("shelvedAnnotations" in shared).toBe(false);
166
+ expect("workingCopy" in shared).toBe(false);
167
+ // the owner's record is untouched
168
+ expect(session.annotations.map((entry) => entry.id)).toEqual(["a1", "b1"]);
169
+ });
170
+
171
+ test("a followed branch that no longer exists falls back to main", () => {
172
+ // Arrange: a plain main-only history, asked to follow a branch that is not there
173
+ const history = historyFromLinear({
174
+ id: "ses_1",
175
+ revisions: [{ revision: 1, content: "Plan v1", submittedAt: AT }],
176
+ annotations: [],
177
+ verdict: null,
178
+ createdAt: AT,
179
+ });
180
+ const session: Thread = {
181
+ schemaVersion: "1",
182
+ id: "ses_1",
183
+ workspace: { repoRoot: "/repo", branch: "main" },
184
+ artifact: { type: "plan", content: "Plan v1", meta: {} },
185
+ revisions: [],
186
+ annotations: [],
187
+ shareBranch: "gone",
188
+ history,
189
+ verdict: null,
190
+ status: "pending",
191
+ createdAt: AT,
192
+ };
193
+
194
+ // Act
195
+ const shared = viewFollowing(session);
196
+
197
+ // Assert: it does not throw, and falls back to main
198
+ expect(shared.shareBranch).toBe("main");
199
+ expect(shared.history!.branch).toBe("main");
200
+ });
201
+ });
@@ -0,0 +1,85 @@
1
+ /**
2
+ * The linear view of a session's active path. Every client reads the artifact
3
+ * text, the working copy, and the comments straight off the record, so the
4
+ * record keeps them materialized; this module recomputes them from the path
5
+ * whenever a tip moves. Comments the path does not reach are shelved, never
6
+ * dropped, so a later move can show them again.
7
+ */
8
+
9
+ import { derivePath, followBranch, MAIN_BRANCH, pathOf, type SessionHistory } from "./history";
10
+ import type { Annotation, Thread } from "./types";
11
+
12
+ export interface PathView {
13
+ /** The last agent revision on the path: what the artifact shows. */
14
+ content: string;
15
+ /** The reviewer's edits over it, when the path's head is a reviewer revision. */
16
+ workingCopy: string | undefined;
17
+ /** The comments open on the path, in path order. */
18
+ annotations: Annotation[];
19
+ /** Every other comment the session knows. */
20
+ shelvedAnnotations: Annotation[];
21
+ }
22
+
23
+ /**
24
+ * Fold the active path over every comment the session holds, on or off the
25
+ * path. Comments on the path whose objects are gone stay gone.
26
+ */
27
+ export function viewOfPath(history: SessionHistory, known: Annotation[]): PathView {
28
+ const derived = derivePath(history);
29
+ const agentHead = pathOf(history)
30
+ .filter((entry) => entry.type === "revision" && entry.by === "agent")
31
+ .at(-1);
32
+ const content = agentHead?.type === "revision" ? agentHead.content : derived.head.content;
33
+ const byId = new Map(known.map((annotation) => [annotation.id, annotation]));
34
+ const annotations: Annotation[] = [];
35
+
36
+ for (const id of derived.annotationIds) {
37
+ const annotation = byId.get(id);
38
+
39
+ if (annotation) {
40
+ annotations.push(annotation);
41
+ byId.delete(id);
42
+ }
43
+ }
44
+
45
+ return {
46
+ content,
47
+ workingCopy: derived.head.content === content ? undefined : derived.head.content,
48
+ annotations,
49
+ shelvedAnnotations: [...byId.values()],
50
+ };
51
+ }
52
+
53
+ /** Write a path view back into a record, in place; absent fields are removed. */
54
+ export function applyPathView(session: Thread, view: PathView): void {
55
+ session.artifact = { ...session.artifact, content: view.content };
56
+ session.annotations = view.annotations;
57
+ if (view.workingCopy === undefined) delete session.workingCopy;
58
+ else session.workingCopy = view.workingCopy;
59
+ if (view.shelvedAnnotations.length === 0) delete session.shelvedAnnotations;
60
+ else session.shelvedAnnotations = view.shelvedAnnotations;
61
+ }
62
+
63
+ /**
64
+ * The record as a share carries it: the followed branch's path and what it
65
+ * shows, with no other branch, no shelved comment, and no working copy of the
66
+ * owner's. A followed branch that no longer exists falls back to main. A record
67
+ * without a history travels as it is.
68
+ */
69
+ export function viewFollowing(session: Thread, branch?: string): Thread {
70
+ const asked = branch ?? session.shareBranch ?? MAIN_BRANCH;
71
+ // a branch that no longer exists falls back to main, matching the daemon
72
+ const followed =
73
+ session.history && session.history.tips[asked] === undefined ? MAIN_BRANCH : asked;
74
+ const shared: Thread = { ...session, shareBranch: followed };
75
+
76
+ if (!session.history) return shared;
77
+ const history = followBranch(session.history, followed);
78
+ const known = [...session.annotations, ...(session.shelvedAnnotations ?? [])];
79
+
80
+ shared.history = history;
81
+ applyPathView(shared, viewOfPath(history, known));
82
+ delete shared.shelvedAnnotations;
83
+
84
+ return shared;
85
+ }
package/src/types.test.ts CHANGED
@@ -1,5 +1,37 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { newAnnotationId, verdictAllows } from "./types";
2
+ import {
3
+ annotationTarget,
4
+ ARTIFACT_TYPES,
5
+ isArtifactType,
6
+ isMarkdownArtifact,
7
+ newAnnotationId,
8
+ verdictAllows,
9
+ } from "./types";
10
+
11
+ describe("annotationTarget", () => {
12
+ test("an annotation with no target means the reviewed artifact", () => {
13
+ expect(annotationTarget({ target: undefined })).toEqual({ kind: "artifact" });
14
+ });
15
+
16
+ test("a stamped target is returned as-is", () => {
17
+ const target = { kind: "file", path: "src/x.ts", rev: "worktree" } as const;
18
+
19
+ expect(annotationTarget({ target })).toBe(target);
20
+ });
21
+ });
22
+
23
+ describe("ARTIFACT_TYPES", () => {
24
+ test("names every primitive exactly once", () => {
25
+ expect([...ARTIFACT_TYPES].sort()).toEqual(["diff", "plan", "prototype", "reply"]);
26
+ expect(new Set(ARTIFACT_TYPES).size).toBe(ARTIFACT_TYPES.length);
27
+ });
28
+
29
+ test("isArtifactType accepts every member and rejects strangers", () => {
30
+ for (const type of ARTIFACT_TYPES) expect(isArtifactType(type)).toBe(true);
31
+ expect(isArtifactType("blueprint")).toBe(false);
32
+ expect(isArtifactType("")).toBe(false);
33
+ });
34
+ });
3
35
 
4
36
  describe("verdictAllows", () => {
5
37
  test("only approve maps to allow", () => {
@@ -9,9 +41,21 @@ describe("verdictAllows", () => {
9
41
  });
10
42
  });
11
43
 
44
+ describe("isMarkdownArtifact", () => {
45
+ test("plan, reply, and prototype are markdown; diff is not", () => {
46
+ expect(isMarkdownArtifact("plan")).toBe(true);
47
+ expect(isMarkdownArtifact("reply")).toBe(true);
48
+ expect(isMarkdownArtifact("prototype")).toBe(true);
49
+ expect(isMarkdownArtifact("diff")).toBe(false);
50
+ });
51
+ });
52
+
12
53
  describe("newAnnotationId", () => {
13
54
  test("unique by construction within a process, even on one millisecond", () => {
55
+ // Act
14
56
  const ids = new Set(Array.from({ length: 10_000 }, () => newAnnotationId()));
57
+
58
+ // Assert
15
59
  expect(ids.size).toBe(10_000);
16
60
  for (const id of ids) expect(id).toMatch(/^a_[0-9a-z]+_[0-9a-z]+$/);
17
61
  });