agent-yes 1.287.0 → 1.288.0

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.
Files changed (111) hide show
  1. package/dist/{SUPPORTED_CLIS-DKdFELZL.js → SUPPORTED_CLIS-BHowMgMY.js} +2 -2
  2. package/dist/{SUPPORTED_CLIS-5YPYQ8Iw.js → SUPPORTED_CLIS-ChhJyq4h.js} +3 -3
  3. package/dist/{agentShare-B525ly6v.js → agentShare-T3RskvDT.js} +2 -2
  4. package/dist/{callback-C-awM4qn.js → callback-CuohbCJA.js} +3 -3
  5. package/dist/{callback-BLTW4Mgp.js → callback-DeaETuHt.js} +2 -2
  6. package/dist/cli.js +5 -5
  7. package/dist/{cmdPs-BRtmnpsM.js → cmdPs-Bm4JG7hi.js} +2 -2
  8. package/dist/index.js +2 -2
  9. package/dist/{notifyDaemon-DshQvO-4.js → notifyDaemon-BORqPeAl.js} +2 -2
  10. package/dist/{parentPingLoop-Bjw6hKoZ.js → parentPingLoop-COePRYr9.js} +2 -2
  11. package/dist/{rustBinary-C57KWEtX.js → rustBinary-BmXzqj5z.js} +2 -2
  12. package/dist/{rustBinary-BXhp8yBp.js → rustBinary-D12k7iOD.js} +2 -2
  13. package/dist/{schedule-Boa0nE0V.js → schedule-DLoUmyJh.js} +4 -4
  14. package/dist/{serve-7mcHebep.js → serve-DaDlOtss.js} +17 -17
  15. package/dist/{setup-DXRL-Mdo.js → setup-Cqy8nRrq.js} +2 -2
  16. package/dist/{subcommands-BftthBK7.js → subcommands-BfiOyfz7.js} +15 -15
  17. package/dist/{subcommands-BQJ-Uqtl.js → subcommands-DKTa3E6O.js} +1 -1
  18. package/dist/{terminal-DrYf3HP0.js → terminal-C0tnGnrZ.js} +2 -2
  19. package/dist/trayApp-BrAMlCY7.js +5 -0
  20. package/dist/{trayApp-DG2fQnhx.js → trayApp-DvL9qW6V.js} +2 -2
  21. package/dist/{ts-LDnoqCV2.js → ts-DJLp2_bv.js} +3 -3
  22. package/dist/{versionChecker-CGmyt2qE.js → versionChecker-CkO7wYyV.js} +2 -2
  23. package/dist/{widget-B2SluyG1.js → widget-CJqpScCG.js} +3 -3
  24. package/dist/{ws-DWA90-uE.js → ws-DGENGNrr.js} +2 -2
  25. package/dist/{ws-CTOnVXg8.js → ws-DzCKvzWD.js} +2 -2
  26. package/lab/ui/index.html +95 -67
  27. package/package.json +2 -1
  28. package/ts/JsonlStore.bun.spec.ts +204 -0
  29. package/ts/ReadyManager.bun.spec.ts +72 -0
  30. package/ts/agentNice.bun.spec.ts +24 -0
  31. package/ts/agentPermissions.bun.spec.ts +117 -0
  32. package/ts/agentShare.bun.spec.ts +240 -0
  33. package/ts/agentTree.bun.spec.ts +92 -0
  34. package/ts/agentYesHome.bun.spec.ts +25 -0
  35. package/ts/askApi.bun.spec.ts +358 -0
  36. package/ts/askCli.bun.spec.ts +304 -0
  37. package/ts/autoRetry.bun.spec.ts +123 -0
  38. package/ts/badges.bun.spec.ts +187 -0
  39. package/ts/buildRustArgs.bun.spec.ts +351 -0
  40. package/ts/callbackCore.bun.spec.ts +139 -0
  41. package/ts/channels/hlc.bun.spec.ts +56 -0
  42. package/ts/channels/link.bun.spec.ts +72 -0
  43. package/ts/channels/op.bun.spec.ts +78 -0
  44. package/ts/channels/store.browser.bun.spec.ts +60 -0
  45. package/ts/channels/store.bun.spec.ts +145 -0
  46. package/ts/channels/store.node.bun.spec.ts +76 -0
  47. package/ts/channels/trust.bun.spec.ts +86 -0
  48. package/ts/channels.bun.spec.ts +212 -0
  49. package/ts/cli-idle.bun.spec.ts +20 -0
  50. package/ts/cmdDsh.bun.spec.ts +90 -0
  51. package/ts/configLoader.bun.spec.ts +273 -0
  52. package/ts/cwdConflictWarn.bun.spec.ts +60 -0
  53. package/ts/cwdPassthroughHint.bun.spec.ts +74 -0
  54. package/ts/e2e-crypto.bun.spec.ts +235 -0
  55. package/ts/forkNested.bun.spec.ts +240 -0
  56. package/ts/histStore.bun.spec.ts +355 -0
  57. package/ts/identity.bun.spec.ts +190 -0
  58. package/ts/idleWaiter.bun.spec.ts +91 -0
  59. package/ts/initMsg.bun.spec.ts +132 -0
  60. package/ts/invokedCli.bun.spec.ts +30 -0
  61. package/ts/ipcLock.bun.spec.ts +107 -0
  62. package/ts/logger.bun.spec.ts +27 -0
  63. package/ts/lsWatch.bun.spec.ts +61 -0
  64. package/ts/messageLog.bun.spec.ts +145 -0
  65. package/ts/needsInput.bun.spec.ts +157 -0
  66. package/ts/nodeRuntime.bun.spec.ts +99 -0
  67. package/ts/notifyDaemon.bun.spec.ts +297 -0
  68. package/ts/notifyInbox.bun.spec.ts +277 -0
  69. package/ts/notifyRouter.bun.spec.ts +351 -0
  70. package/ts/notifyStore.bun.spec.ts +403 -0
  71. package/ts/ownerHeartbeat.bun.spec.ts +175 -0
  72. package/ts/parentLink.bun.spec.ts +64 -0
  73. package/ts/parentPing.bun.spec.ts +176 -0
  74. package/ts/parentPingLoop.bun.spec.ts +79 -0
  75. package/ts/parentPingSend.bun.spec.ts +195 -0
  76. package/ts/parentWatching.bun.spec.ts +61 -0
  77. package/ts/pidStore.bun.spec.ts +372 -0
  78. package/ts/procStartTime.bun.spec.ts +117 -0
  79. package/ts/reaper.bun.spec.ts +86 -0
  80. package/ts/removeControlCharacters.bun.spec.ts +94 -0
  81. package/ts/resolveBinary.bun.spec.ts +133 -0
  82. package/ts/resultEnvelope.bun.spec.ts +46 -0
  83. package/ts/runningLock.bun.spec.ts +485 -0
  84. package/ts/rustBinary.bun.spec.ts +81 -0
  85. package/ts/schedule.bun.spec.ts +30 -0
  86. package/ts/serve.bun.spec.ts +166 -0
  87. package/ts/serveLock.bun.spec.ts +220 -0
  88. package/ts/session-integration.bun.spec.ts +93 -0
  89. package/ts/setup.bun.spec.ts +42 -0
  90. package/ts/share.bun.spec.ts +36 -0
  91. package/ts/sizeNego.bun.spec.ts +56 -0
  92. package/ts/statusText.bun.spec.ts +17 -0
  93. package/ts/strayServe.bun.spec.ts +50 -0
  94. package/ts/subcommandMirror.bun.spec.ts +76 -0
  95. package/ts/systemPathLink.bun.spec.ts +79 -0
  96. package/ts/termToken.bun.spec.ts +74 -0
  97. package/ts/terminal.bun.spec.ts +50 -0
  98. package/ts/terminalReply.bun.spec.ts +36 -0
  99. package/ts/titleScanner.bun.spec.ts +65 -0
  100. package/ts/todoAutomation.bun.spec.ts +253 -0
  101. package/ts/todoBlock.bun.spec.ts +73 -0
  102. package/ts/todoDigest.bun.spec.ts +184 -0
  103. package/ts/todoIdentity.bun.spec.ts +85 -0
  104. package/ts/todoLifecycle.bun.spec.ts +79 -0
  105. package/ts/todoParse.bun.spec.ts +98 -0
  106. package/ts/todoRoot.bun.spec.ts +117 -0
  107. package/ts/todoStore.bun.spec.ts +727 -0
  108. package/ts/tryCatch.bun.spec.ts +275 -0
  109. package/ts/webrtcLink.bun.spec.ts +45 -0
  110. package/ts/workspaceConfig.bun.spec.ts +319 -0
  111. package/dist/trayApp-1AQ6X2V9.js +0 -5
@@ -0,0 +1,139 @@
1
+ import { createHmac } from "node:crypto";
2
+ import { describe, expect, it } from "bun:test";
3
+ import {
4
+ MAX_EXPIRES_MS,
5
+ buildSnippet,
6
+ frameVisitorMessage,
7
+ mintCapability,
8
+ parseExpires,
9
+ verifyCapability,
10
+ } from "./callbackCore.ts";
11
+
12
+ const SECRET = "test-secret";
13
+ const NOW = 1_784_700_000_000;
14
+
15
+ describe("parseExpires", () => {
16
+ it("parses minutes/hours/days/weeks", () => {
17
+ expect(parseExpires("30m")).toBe(30 * 60_000);
18
+ expect(parseExpires("12h")).toBe(12 * 3_600_000);
19
+ expect(parseExpires("7d")).toBe(7 * 24 * 3_600_000);
20
+ expect(parseExpires("2w")).toBe(14 * 24 * 3_600_000);
21
+ expect(parseExpires(" 1d ")).toBe(24 * 3_600_000);
22
+ });
23
+
24
+ it("rejects everything that is not an explicit finite duration", () => {
25
+ for (const bad of ["", "7", "d", "never", "0d", "-1d", "1y", "1.5d", "7 d"]) {
26
+ expect(() => parseExpires(bad), bad).toThrow();
27
+ }
28
+ });
29
+
30
+ it("caps at 365d — no effectively-immortal capabilities", () => {
31
+ expect(parseExpires("365d")).toBe(MAX_EXPIRES_MS);
32
+ expect(() => parseExpires("366d")).toThrow(/365d/);
33
+ expect(() => parseExpires("53w")).toThrow(/365d/);
34
+ });
35
+ });
36
+
37
+ describe("mint / verify", () => {
38
+ const payload = { id: "ab12cd34", agent: "4d65a096e983", exp: NOW + 3_600_000 };
39
+
40
+ it("round-trips a valid capability", () => {
41
+ const cap = mintCapability(SECRET, payload);
42
+ expect(cap).toMatch(/^cb1\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/);
43
+ const v = verifyCapability(SECRET, cap, NOW);
44
+ expect(v).toEqual({ ok: true, payload });
45
+ });
46
+
47
+ it("fails hard after expiry (410 path)", () => {
48
+ const cap = mintCapability(SECRET, payload);
49
+ expect(verifyCapability(SECRET, cap, payload.exp)).toEqual({ ok: false, reason: "expired" });
50
+ expect(verifyCapability(SECRET, cap, payload.exp + 1)).toEqual({
51
+ ok: false,
52
+ reason: "expired",
53
+ });
54
+ });
55
+
56
+ it("rejects a tampered payload as badsig — retargeting is impossible", () => {
57
+ const cap = mintCapability(SECRET, payload);
58
+ const [pre, , sig] = cap.split(".");
59
+ const forged = Buffer.from(
60
+ JSON.stringify({ ...payload, agent: "other-agent-id", exp: NOW + 999_999_999 }),
61
+ ).toString("base64url");
62
+ expect(verifyCapability(SECRET, `${pre}.${forged}.${sig}`, NOW)).toEqual({
63
+ ok: false,
64
+ reason: "badsig",
65
+ });
66
+ });
67
+
68
+ it("rejects a capability minted with a different secret", () => {
69
+ const cap = mintCapability("other-secret", payload);
70
+ expect(verifyCapability(SECRET, cap, NOW)).toEqual({ ok: false, reason: "badsig" });
71
+ });
72
+
73
+ it("rejects malformed tokens without throwing", () => {
74
+ for (const bad of ["", "cb1", "cb1.x", "cb2.a.b", "nope.a.b", "cb1..", "cb1.!!.??"]) {
75
+ const v = verifyCapability(SECRET, bad, NOW);
76
+ expect(v.ok, bad).toBe(false);
77
+ }
78
+ });
79
+
80
+ it("rejects a signed payload with missing fields as malformed", () => {
81
+ // Sign a structurally wrong payload with the REAL secret: signature passes,
82
+ // shape check must still refuse it.
83
+ const raw = Buffer.from(JSON.stringify({ id: "x" })).toString("base64url");
84
+ const sig = createHmac("sha256", SECRET).update(`cb1.${raw}`).digest().toString("base64url");
85
+ expect(verifyCapability(SECRET, `cb1.${raw}.${sig}`, NOW)).toEqual({
86
+ ok: false,
87
+ reason: "malformed",
88
+ });
89
+ });
90
+ });
91
+
92
+ describe("frameVisitorMessage", () => {
93
+ it("wraps the message in an untrusted frame carrying the capability id", () => {
94
+ const framed = frameVisitorMessage("ab12", "hello agent");
95
+ expect(framed).toContain("<ay-callback ab12");
96
+ expect(framed).toContain("untrusted visitor message");
97
+ expect(framed).toContain("hello agent");
98
+ expect(framed).toContain("</ay-callback ab12>");
99
+ });
100
+
101
+ it("strips control characters so a visitor cannot inject escapes or Enter", () => {
102
+ const framed = frameVisitorMessage("ab12", "a\u001b[31mred\u0000\rb");
103
+ expect(framed).toContain("a[31mredb");
104
+ // eslint-disable-next-line no-control-regex
105
+ expect(framed).not.toMatch(/[\u0000-\u0008\u000b-\u001f\u007f]/);
106
+ });
107
+
108
+ it("keeps newlines and tabs (multi-line messages stay readable)", () => {
109
+ const framed = frameVisitorMessage("ab12", "line1\nline2\tend");
110
+ expect(framed).toContain("line1\nline2\tend");
111
+ });
112
+ });
113
+
114
+ describe("buildSnippet", () => {
115
+ const cap = mintCapability(SECRET, { id: "ab12", agent: "deadbeef1234", exp: NOW + 1000 });
116
+
117
+ it("targets <base>/cb/<cap> and is fully self-contained", () => {
118
+ const s = buildSnippet({ base: "https://x1.agent-yes.com/", cap });
119
+ expect(s).toContain(`"https://x1.agent-yes.com/cb/${cap}"`);
120
+ expect(s).toMatch(/^<script>/);
121
+ expect(s).toMatch(/<\/script>$/);
122
+ // No external loads: everything inline, only the endpoint is fetched.
123
+ expect(s).not.toContain("src=");
124
+ expect(s).not.toContain("import ");
125
+ });
126
+
127
+ it("handles the expired (410) and rate-limited (429) states explicitly", () => {
128
+ const s = buildSnippet({ base: "http://127.0.0.1:4680", cap });
129
+ expect(s).toContain("410");
130
+ expect(s).toContain("expired");
131
+ expect(s).toContain("429");
132
+ });
133
+
134
+ it("sanitizes the title against markup injection", () => {
135
+ const s = buildSnippet({ base: "http://h", cap, title: `<img onerror="x">"'&` });
136
+ expect(s).not.toContain("<img");
137
+ expect(s).toContain('var TITLE = "img onerror=x"');
138
+ });
139
+ });
@@ -0,0 +1,56 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { compareHlc, formatHlc, hlcSend, parseHlc } from "./hlc.ts";
3
+
4
+ describe("hlc", () => {
5
+ it("round-trips format/parse", () => {
6
+ const s = formatHlc(1_700_000_000_000, 3, "node7");
7
+ expect(parseHlc(s)).toEqual({ ms: 1_700_000_000_000, ctr: 3, node: "node7" });
8
+ });
9
+
10
+ it("is lexicographically sortable in HLC order", () => {
11
+ const a = formatHlc(1000, 0, "z"); // earlier ms
12
+ const b = formatHlc(1000, 1, "a"); // same ms, higher ctr
13
+ const c = formatHlc(2000, 0, "a"); // later ms
14
+ expect([c, a, b].sort()).toEqual([a, b, c]);
15
+ expect(compareHlc(a, b)).toBeLessThan(0);
16
+ expect(compareHlc(c, a)).toBeGreaterThan(0);
17
+ expect(compareHlc(a, a)).toBe(0);
18
+ });
19
+
20
+ it("advances the ms and resets the counter when wall clock moves forward", () => {
21
+ const prev = formatHlc(1000, 5, "n1");
22
+ expect(parseHlc(hlcSend(prev, 2000, "n1"))).toMatchObject({ ms: 2000, ctr: 0 });
23
+ });
24
+
25
+ it("keeps ms and bumps the counter when wall clock has not advanced", () => {
26
+ const prev = formatHlc(5000, 2, "n1");
27
+ // physNow behind the max we've seen (clock skew / same ms) → monotonic via ctr
28
+ expect(parseHlc(hlcSend(prev, 4000, "n1"))).toMatchObject({ ms: 5000, ctr: 3 });
29
+ expect(parseHlc(hlcSend(prev, 5000, "n1"))).toMatchObject({ ms: 5000, ctr: 3 });
30
+ });
31
+
32
+ it("is strictly monotonic across repeated sends seeded from the running max", () => {
33
+ let max: string | null = null;
34
+ const out: string[] = [];
35
+ for (let i = 0; i < 100; i++) {
36
+ // simulate a clock that sometimes stalls
37
+ const now = 1000 + (i % 3 === 0 ? 0 : i);
38
+ max = hlcSend(max, now, "n");
39
+ out.push(max);
40
+ }
41
+ for (let i = 1; i < out.length; i++) expect(compareHlc(out[i - 1]!, out[i]!)).toBeLessThan(0);
42
+ });
43
+
44
+ it("starts from zero with no prior max", () => {
45
+ expect(parseHlc(hlcSend(null, 42, "n"))).toEqual({ ms: 42, ctr: 0, node: "n" });
46
+ });
47
+
48
+ it("rejects malformed and out-of-range values", () => {
49
+ expect(() => parseHlc("nope")).toThrow();
50
+ expect(() => parseHlc("1.2")).toThrow(); // too few fields
51
+ expect(() => formatHlc(-1, 0, "n")).toThrow();
52
+ expect(() => formatHlc(0, -1, "n")).toThrow();
53
+ expect(() => formatHlc(10 ** 15, 0, "n")).toThrow(); // ms overflow
54
+ expect(() => formatHlc(0, 10 ** 6, "n")).toThrow(); // ctr overflow
55
+ });
56
+ });
@@ -0,0 +1,72 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import {
3
+ deriveChannelId,
4
+ deriveRoom,
5
+ formatChannelLink,
6
+ formatChannelWebLink,
7
+ isChannelLink,
8
+ parseChannelLink,
9
+ secretFromTopic,
10
+ } from "./link.ts";
11
+
12
+ const S = "a".repeat(64); // a valid 64-hex secret
13
+
14
+ describe("channel identity derivation", () => {
15
+ it("derives a stable, topic-blind channelId and room from the secret", async () => {
16
+ const [id1, id2] = await Promise.all([deriveChannelId(S), deriveChannelId(S)]);
17
+ expect(id1).toBe(id2);
18
+ expect(id1).toMatch(/^[0-9a-f]{16}$/);
19
+ const room = await deriveRoom(S);
20
+ expect(room).toMatch(/^c[0-9a-f]{12}$/); // matches the signaling room grammar
21
+ // different secret → different identity
22
+ expect(await deriveChannelId("b".repeat(64))).not.toBe(id1);
23
+ });
24
+
25
+ it("rejects a non-hex secret before hashing", async () => {
26
+ await expect(deriveChannelId("not-hex")).rejects.toThrow();
27
+ });
28
+
29
+ it("derives a deterministic, valid secret from a topic (same URL → same channel)", async () => {
30
+ const url = "https://example.com/docs/page";
31
+ const [a, b] = await Promise.all([secretFromTopic(url), secretFromTopic(url)]);
32
+ expect(a).toBe(b); // deterministic
33
+ expect(a).toMatch(/^[0-9a-f]{64}$/); // a valid S
34
+ // usable as a real secret end-to-end
35
+ await expect(deriveChannelId(a)).resolves.toMatch(/^[0-9a-f]{16}$/);
36
+ // distinct topics (incl. a differing hash) yield distinct channels
37
+ expect(await secretFromTopic(url + "#section")).not.toBe(a);
38
+ expect(await secretFromTopic("https://example.com/other")).not.toBe(a);
39
+ });
40
+ });
41
+
42
+ describe("channel invite links", () => {
43
+ const link = { sighost: "s.agent-yes.com", room: "cabc123", s: S };
44
+
45
+ it("round-trips the ay:// form", () => {
46
+ const str = formatChannelLink(link);
47
+ expect(str).toBe(`ay://ch/s.agent-yes.com/cabc123#e1.${S}`);
48
+ expect(parseChannelLink(str)).toEqual(link);
49
+ expect(isChannelLink(str)).toBe(true);
50
+ });
51
+
52
+ it("round-trips the browser https form, defaulting the sighost", () => {
53
+ const web = formatChannelWebLink(link);
54
+ expect(web).toBe(`https://agent-yes.com/w/#ch=cabc123:e1.${S}`);
55
+ expect(parseChannelLink(web)).toEqual(link);
56
+ // a non-default sighost is carried explicitly
57
+ const custom = { ...link, sighost: "sig.example.com" };
58
+ expect(parseChannelLink(formatChannelWebLink(custom))).toEqual(custom);
59
+ });
60
+
61
+ it("returns null for non-links and throws on a malformed secret slot", () => {
62
+ expect(parseChannelLink("just a topic name")).toBeNull();
63
+ expect(isChannelLink("topic")).toBe(false);
64
+ // http url without the #ch= fragment is not a channel link
65
+ expect(isChannelLink("https://example.com/page")).toBe(false);
66
+ expect(parseChannelLink("https://example.com/page")).toBeNull();
67
+ // https channel form missing the room:secret separator → null
68
+ expect(parseChannelLink("https://x/w/#ch=noseparator")).toBeNull();
69
+ expect(() => parseChannelLink("ay://ch/host/room#e1.short")).toThrow();
70
+ expect(() => parseChannelLink("https://x/w/#ch=room:e1.short")).toThrow();
71
+ });
72
+ });
@@ -0,0 +1,78 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { isValidOp, makeOp, opId } from "./op.ts";
3
+
4
+ const base = { author: "a1", name: "alice", role: "human" as const, hlc: "h1" };
5
+
6
+ describe("op", () => {
7
+ it("derives a stable id from author + hlc", () => {
8
+ expect(opId("a1", "h1")).toBe("a1@h1");
9
+ expect(makeOp({ ...base, kind: "msg", body: "hi" }).id).toBe("a1@h1");
10
+ });
11
+
12
+ it("drops empty optional fields", () => {
13
+ const op = makeOp({ ...base, kind: "msg", body: "hi" });
14
+ expect(op).not.toHaveProperty("ref");
15
+ const del = makeOp({ ...base, kind: "delete", ref: "a1@h0" });
16
+ expect(del).not.toHaveProperty("body");
17
+ expect(del.ref).toBe("a1@h0");
18
+ });
19
+
20
+ it("keeps an empty-string body (a cleared edit) but not undefined", () => {
21
+ expect(makeOp({ ...base, kind: "edit", body: "", ref: "x" }).body).toBe("");
22
+ });
23
+
24
+ it("validates a well-formed op", () => {
25
+ expect(isValidOp(makeOp({ ...base, kind: "msg", body: "hi" }))).toBe(true);
26
+ expect(isValidOp(makeOp({ ...base, kind: "reaction", body: "👍", ref: "x" }))).toBe(true);
27
+ });
28
+
29
+ it("validates control ops (cmd/stream) only with a payload body", () => {
30
+ expect(isValidOp(makeOp({ ...base, kind: "cmd", body: '{"action":"highlight"}' }))).toBe(true);
31
+ expect(isValidOp(makeOp({ ...base, kind: "stream", body: "delta" }))).toBe(true);
32
+ // cmd/stream without a body are rejected (fail-closed)
33
+ expect(isValidOp({ ...base, id: "a1@h1", kind: "cmd", name: "x" })).toBe(false);
34
+ expect(isValidOp({ ...base, id: "a1@h1", kind: "stream", name: "x" })).toBe(false);
35
+ });
36
+
37
+ it("rejects malformed ops (fail-closed)", () => {
38
+ expect(isValidOp(null)).toBe(false);
39
+ expect(isValidOp({ ...base, id: "a1@h1", kind: "nope", name: "x" })).toBe(false);
40
+ expect(isValidOp({ ...base, id: "a1@h1", kind: "msg", name: "x", role: "robot" })).toBe(false);
41
+ // id must equal author@hlc
42
+ expect(isValidOp({ ...base, id: "forged", kind: "msg", name: "x" })).toBe(false);
43
+ // amendments must carry a ref
44
+ expect(isValidOp({ ...base, id: "a1@h1", kind: "edit", name: "x", body: "z" })).toBe(false);
45
+ // wrong field types
46
+ expect(isValidOp({ ...base, id: "a1@h1", kind: "msg", name: 5 })).toBe(false);
47
+ expect(
48
+ isValidOp({
49
+ author: "a1",
50
+ hlc: "h1",
51
+ id: "a1@h1",
52
+ kind: "msg",
53
+ name: "x",
54
+ role: "human",
55
+ body: 1,
56
+ }),
57
+ ).toBe(false);
58
+ // ref of the wrong type
59
+ expect(isValidOp({ ...base, id: "a1@h1", kind: "edit", name: "x", body: "z", ref: 9 })).toBe(
60
+ false,
61
+ );
62
+ // sig of the wrong type is rejected; a string sig is accepted
63
+ expect(isValidOp({ ...base, id: "a1@h1", kind: "msg", name: "x", sig: 1 })).toBe(false);
64
+ expect(
65
+ isValidOp({ ...base, id: "a1@h1", kind: "msg", name: "x", body: "hi", sig: "deadbeef" }),
66
+ ).toBe(true);
67
+ // reaction/delete without a ref also fail-closed
68
+ expect(isValidOp({ ...base, id: "a1@h1", kind: "reaction", name: "x", body: "👍" })).toBe(
69
+ false,
70
+ );
71
+ expect(isValidOp({ ...base, id: "a1@h1", kind: "delete", name: "x" })).toBe(false);
72
+ // missing/blank required fields
73
+ expect(isValidOp({ ...base, id: "a1@h1", kind: "msg", name: "x", author: "" })).toBe(false);
74
+ expect(
75
+ isValidOp({ id: "@h1", kind: "msg", name: "x", role: "human", author: "", hlc: "h1" }),
76
+ ).toBe(false);
77
+ });
78
+ });
@@ -0,0 +1,60 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { formatHlc } from "./hlc.ts";
3
+ import { makeOp, type Op } from "./op.ts";
4
+ import { LocalStorageStore } from "./store.browser.ts";
5
+
6
+ // Minimal in-memory Storage stand-in (the parts LocalStorageStore uses).
7
+ function memStorage(): Storage {
8
+ const m = new Map<string, string>();
9
+ return {
10
+ getItem: (k) => m.get(k) ?? null,
11
+ setItem: (k, v) => void m.set(k, v),
12
+ removeItem: (k) => void m.delete(k),
13
+ clear: () => m.clear(),
14
+ key: (i) => [...m.keys()][i] ?? null,
15
+ get length() {
16
+ return m.size;
17
+ },
18
+ } as Storage;
19
+ }
20
+
21
+ function op(author: string, ms: number, body: string): Op {
22
+ return makeOp({
23
+ author,
24
+ name: author,
25
+ role: "human",
26
+ hlc: formatHlc(ms, 0, author),
27
+ kind: "msg",
28
+ body,
29
+ });
30
+ }
31
+
32
+ describe("LocalStorageStore", () => {
33
+ it("returns [] for an empty channel", async () => {
34
+ const s = new LocalStorageStore("c1", memStorage());
35
+ expect(await s.all()).toEqual([]);
36
+ });
37
+
38
+ it("appends, dedups, and reads back sorted (same CRDT as the jsonl backend)", async () => {
39
+ const store = memStorage();
40
+ const s = new LocalStorageStore("c1", store);
41
+ const added = await s.append([op("b", 2, "two"), op("a", 1, "one")]);
42
+ expect(added).toHaveLength(2);
43
+ expect((await s.all()).map((o) => o.body)).toEqual(["one", "two"]);
44
+ // re-append an existing op → nothing new; a second reader converges identically
45
+ expect(await s.append([op("a", 1, "one")])).toEqual([]);
46
+ expect((await new LocalStorageStore("c1", store).all()).map((o) => o.body)).toEqual([
47
+ "one",
48
+ "two",
49
+ ]);
50
+ });
51
+
52
+ it("tolerates corrupt storage + drops invalid ops", async () => {
53
+ const store = memStorage();
54
+ store.setItem("ay29ch:c1", "not json");
55
+ const s = new LocalStorageStore("c1", store);
56
+ expect(await s.all()).toEqual([]);
57
+ // @ts-expect-error deliberately malformed
58
+ expect(await s.append([{ id: "x", kind: "msg" }])).toEqual([]);
59
+ });
60
+ });
@@ -0,0 +1,145 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { formatHlc } from "./hlc.ts";
3
+ import { makeOp, type Op, type Role } from "./op.ts";
4
+ import { haveVector, maxHlc, mergeOps, opsMissing, renderThread, sortOps } from "./store.ts";
5
+
6
+ // Build an op with an explicit (ms, ctr) HLC for deterministic ordering.
7
+ function op(
8
+ author: string,
9
+ ms: number,
10
+ kind: Op["kind"],
11
+ body?: string,
12
+ ref?: string,
13
+ role: Role = "human",
14
+ ): Op {
15
+ return makeOp({ author, name: author, role, hlc: formatHlc(ms, 0, author), kind, body, ref });
16
+ }
17
+
18
+ describe("mergeOps", () => {
19
+ const a = op("a", 1, "msg", "one");
20
+ const b = op("b", 2, "msg", "two");
21
+ const c = op("c", 3, "msg", "three");
22
+
23
+ it("is a union deduped by id", () => {
24
+ const { merged, added } = mergeOps([a], [a, b]);
25
+ expect(merged.map((o) => o.id)).toEqual([a.id, b.id]);
26
+ expect(added.map((o) => o.id)).toEqual([b.id]); // only genuinely new
27
+ });
28
+
29
+ it("is commutative and idempotent (convergence)", () => {
30
+ const x = mergeOps(mergeOps([], [a, b]).merged, [c]).merged;
31
+ const y = mergeOps(mergeOps([], [c, b]).merged, [a]).merged;
32
+ expect(x.map((o) => o.id)).toEqual(y.map((o) => o.id));
33
+ // merging again adds nothing
34
+ expect(mergeOps(x, [a, b, c]).added).toEqual([]);
35
+ });
36
+
37
+ it("reports the running max HLC", () => {
38
+ expect(maxHlc([])).toBeNull();
39
+ expect(maxHlc([a, c, b])).toBe(c.hlc);
40
+ });
41
+
42
+ it("sorts by HLC then id", () => {
43
+ expect(sortOps([c, a, b]).map((o) => o.id)).toEqual([a.id, b.id, c.id]);
44
+ });
45
+
46
+ it("breaks a same-HLC tie deterministically by id", () => {
47
+ // two authors that collide on (ms, ctr) — the id (author@hlc) decides order
48
+ const x = makeOp({
49
+ author: "z",
50
+ name: "z",
51
+ role: "human",
52
+ hlc: formatHlc(9, 0, "z"),
53
+ kind: "msg",
54
+ body: "x",
55
+ });
56
+ const y = makeOp({
57
+ author: "a",
58
+ name: "a",
59
+ role: "human",
60
+ hlc: formatHlc(9, 0, "a"),
61
+ kind: "msg",
62
+ body: "y",
63
+ });
64
+ expect(sortOps([x, y]).map((o) => o.author)).toEqual(["a", "z"]);
65
+ expect(sortOps([y, x]).map((o) => o.author)).toEqual(["a", "z"]);
66
+ });
67
+ });
68
+
69
+ describe("renderThread", () => {
70
+ it("renders base messages in HLC order", () => {
71
+ const msgs = renderThread([op("b", 2, "msg", "two"), op("a", 1, "msg", "one")]);
72
+ expect(msgs.map((m) => m.text)).toEqual(["one", "two"]);
73
+ });
74
+
75
+ it("applies the latest edit (last-writer-wins)", () => {
76
+ const m = op("a", 1, "msg", "orig");
77
+ const e1 = op("a", 2, "edit", "v2", m.id);
78
+ const e2 = op("a", 3, "edit", "v3", m.id);
79
+ const [r] = renderThread([m, e2, e1]);
80
+ expect(r!.text).toBe("v3");
81
+ expect(r!.amendedHlc).toBe(e2.hlc);
82
+ });
83
+
84
+ it("hides a deleted message, and an edit after a delete revives it", () => {
85
+ const m = op("a", 1, "msg", "hi");
86
+ expect(renderThread([m, op("a", 2, "delete", undefined, m.id)])[0]!).toMatchObject({
87
+ deleted: true,
88
+ text: "",
89
+ });
90
+ // delete then a newer edit → revived
91
+ const revived = renderThread([
92
+ m,
93
+ op("a", 2, "delete", undefined, m.id),
94
+ op("a", 3, "edit", "back", m.id),
95
+ ])[0]!;
96
+ expect(revived).toMatchObject({ deleted: false, text: "back" });
97
+ });
98
+
99
+ it("groups reactions by emoji into distinct authors", () => {
100
+ const m = op("a", 1, "msg", "hi");
101
+ const r = renderThread([
102
+ m,
103
+ op("b", 2, "reaction", "👍", m.id),
104
+ op("c", 3, "reaction", "👍", m.id),
105
+ op("b", 4, "reaction", "👍", m.id), // duplicate author → deduped
106
+ op("b", 5, "reaction", "🎉", m.id),
107
+ ])[0]!;
108
+ expect(r.reactions).toEqual([
109
+ { emoji: "👍", by: ["b", "c"] },
110
+ { emoji: "🎉", by: ["b"] },
111
+ ]);
112
+ });
113
+
114
+ it("ignores amendments whose target op is absent", () => {
115
+ expect(renderThread([op("a", 2, "edit", "x", "missing@id")])).toEqual([]);
116
+ });
117
+
118
+ it("ignores a reaction with an empty body", () => {
119
+ const m = op("a", 1, "msg", "hi");
120
+ const [r] = renderThread([m, op("b", 2, "reaction", "", m.id)]);
121
+ expect(r!.reactions).toEqual([]);
122
+ });
123
+ });
124
+
125
+ describe("anti-entropy sync", () => {
126
+ const local = [op("a", 1, "msg", "a1"), op("a", 2, "msg", "a2"), op("b", 5, "msg", "b1")];
127
+
128
+ it("summarizes what a replica holds per author", () => {
129
+ expect(haveVector(local)).toEqual({ a: formatHlc(2, 0, "a"), b: formatHlc(5, 0, "b") });
130
+ // keeps the max even when an older op for an author appears after a newer one
131
+ const outOfOrder = [op("a", 2, "msg", "a2"), op("a", 1, "msg", "a1")];
132
+ expect(haveVector(outOfOrder)).toEqual({ a: formatHlc(2, 0, "a") });
133
+ });
134
+
135
+ it("computes exactly the ops a peer is missing", () => {
136
+ // peer has a up to ms=1 and nothing from b
137
+ const remoteHave = { a: formatHlc(1, 0, "a") };
138
+ const missing = opsMissing(local, remoteHave);
139
+ expect(missing.map((o) => o.body)).toEqual(["a2", "b1"]);
140
+ });
141
+
142
+ it("sends nothing when the peer is already caught up", () => {
143
+ expect(opsMissing(local, haveVector(local))).toEqual([]);
144
+ });
145
+ });
@@ -0,0 +1,76 @@
1
+ import { appendFile, mkdtemp, mkdir, rm } from "fs/promises";
2
+ import os from "os";
3
+ import path from "path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
5
+ import { formatHlc } from "./hlc.ts";
6
+ import { makeOp, type Op } from "./op.ts";
7
+ import { appendOps, channelFilePath, readOps } from "./store.node.ts";
8
+
9
+ function op(author: string, ms: number, body: string): Op {
10
+ return makeOp({
11
+ author,
12
+ name: author,
13
+ role: "human",
14
+ hlc: formatHlc(ms, 0, author),
15
+ kind: "msg",
16
+ body,
17
+ });
18
+ }
19
+
20
+ describe("store.node jsonl backend", () => {
21
+ let cwd: string;
22
+ const CH = "abc123";
23
+
24
+ beforeEach(async () => {
25
+ cwd = await mkdtemp(path.join(os.tmpdir(), "ay-ch-"));
26
+ });
27
+ afterEach(async () => {
28
+ await rm(cwd, { recursive: true, force: true });
29
+ });
30
+
31
+ it("colocates the replica under <cwd>/.agent-yes", () => {
32
+ expect(channelFilePath("/x", CH)).toBe(path.join("/x", ".agent-yes", "ch-abc123.jsonl"));
33
+ });
34
+
35
+ it("returns [] for a channel with no file yet", async () => {
36
+ expect(await readOps(cwd, CH)).toEqual([]);
37
+ });
38
+
39
+ it("appends and reads back, sorted by HLC", async () => {
40
+ const added = await appendOps(cwd, CH, [op("b", 2, "two"), op("a", 1, "one")]);
41
+ expect(added).toHaveLength(2);
42
+ expect((await readOps(cwd, CH)).map((o) => o.body)).toEqual(["one", "two"]);
43
+ });
44
+
45
+ it("dedups already-stored ops on append (idempotent replica)", async () => {
46
+ const first = op("a", 1, "one");
47
+ await appendOps(cwd, CH, [first]);
48
+ const added = await appendOps(cwd, CH, [first, op("a", 2, "two")]);
49
+ expect(added.map((o) => o.body)).toEqual(["two"]); // only the new one
50
+ expect(await readOps(cwd, CH)).toHaveLength(2);
51
+ });
52
+
53
+ it("drops invalid ops instead of storing them", async () => {
54
+ // @ts-expect-error deliberately malformed
55
+ const added = await appendOps(cwd, CH, [{ id: "x", kind: "msg" }]);
56
+ expect(added).toEqual([]);
57
+ expect(await readOps(cwd, CH)).toEqual([]);
58
+ });
59
+
60
+ it("skips corrupt/partial lines when reading", async () => {
61
+ const good = op("a", 1, "one");
62
+ await appendOps(cwd, CH, [good]);
63
+ // simulate a torn write: a half-line + a non-op JSON line
64
+ await appendFile(channelFilePath(cwd, CH), `{"not":"an op"}\n{oops not json\n\n`);
65
+ const ops = await readOps(cwd, CH);
66
+ expect(ops.map((o) => o.body)).toEqual(["one"]);
67
+ });
68
+
69
+ it("returns [] when the ops list is entirely invalid", async () => {
70
+ // appendOps with an empty list is a no-op
71
+ expect(await appendOps(cwd, CH, [])).toEqual([]);
72
+ // reading a fresh channel dir that exists but has no file
73
+ await mkdir(path.join(cwd, ".agent-yes"), { recursive: true });
74
+ expect(await readOps(cwd, "never-written")).toEqual([]);
75
+ });
76
+ });