@openparachute/agent 0.2.3-rc.2 → 0.2.3-rc.3

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 (54) hide show
  1. package/package.json +4 -1
  2. package/src/transports/vault.ts +19 -1
  3. package/src/_parked/interactive-spawn.test.ts +0 -324
  4. package/src/_parked/interactive-spawn.ts +0 -701
  5. package/src/agent-defs.test.ts +0 -1504
  6. package/src/agent-mcp-config.test.ts +0 -115
  7. package/src/agents.test.ts +0 -360
  8. package/src/auth.test.ts +0 -46
  9. package/src/backends/attached-queue.test.ts +0 -376
  10. package/src/backends/programmatic.test.ts +0 -1715
  11. package/src/backends/registry.test.ts +0 -1494
  12. package/src/backends/stream-json.test.ts +0 -570
  13. package/src/channel-backend-wiring.test.ts +0 -237
  14. package/src/credentials.test.ts +0 -274
  15. package/src/cron.test.ts +0 -342
  16. package/src/daemon-agent-def-api.test.ts +0 -166
  17. package/src/daemon-agent-defs-api.test.ts +0 -953
  18. package/src/daemon-agent-env-api.test.ts +0 -338
  19. package/src/daemon-attached-queue-store.test.ts +0 -65
  20. package/src/daemon-config-api.test.ts +0 -962
  21. package/src/daemon-jobs-api.test.ts +0 -271
  22. package/src/daemon-vault-chat.test.ts +0 -250
  23. package/src/daemon.test.ts +0 -746
  24. package/src/def-vaults.test.ts +0 -136
  25. package/src/delivery-state.test.ts +0 -110
  26. package/src/effective-env.test.ts +0 -114
  27. package/src/grants.test.ts +0 -638
  28. package/src/hub-jwt.test.ts +0 -161
  29. package/src/jobs.test.ts +0 -245
  30. package/src/mcp-http.test.ts +0 -265
  31. package/src/mint-token.test.ts +0 -152
  32. package/src/module-manifest.test.ts +0 -158
  33. package/src/programmatic-wiring.test.ts +0 -838
  34. package/src/registry.test.ts +0 -227
  35. package/src/resolve-port.test.ts +0 -64
  36. package/src/routing.test.ts +0 -184
  37. package/src/runner.test.ts +0 -506
  38. package/src/sandbox/config.test.ts +0 -150
  39. package/src/sandbox/egress.test.ts +0 -113
  40. package/src/sandbox/live-seatbelt.test.ts +0 -277
  41. package/src/sandbox/mounts.test.ts +0 -154
  42. package/src/sandbox/sandbox.test.ts +0 -168
  43. package/src/services-manifest.test.ts +0 -106
  44. package/src/spa-serve.test.ts +0 -116
  45. package/src/spawn-agent-cli.test.ts +0 -172
  46. package/src/spawn-agent.test.ts +0 -1218
  47. package/src/spawn-deps.test.ts +0 -54
  48. package/src/terminal-assets.test.ts +0 -50
  49. package/src/terminal.test.ts +0 -530
  50. package/src/transports/http-ui.test.ts +0 -455
  51. package/src/transports/telegram.test.ts +0 -174
  52. package/src/transports/vault.test.ts +0 -2012
  53. package/src/ui-kit.test.ts +0 -178
  54. package/web/ui/tsconfig.json +0 -21
@@ -1,2012 +0,0 @@
1
- /**
2
- * Tier 1 unit tests for the vault transport.
3
- *
4
- * These exercise the transport WITHOUT a live vault — `fetch` is stubbed to
5
- * capture the outbound note write, and `ctx.emit` is recorded to assert inbound
6
- * delivery. They cover:
7
- * - reply(): writes the right POST .../api/notes tagged BOTH the queryable parent
8
- * `#agent/message` AND the directional child `#agent/message/outbound` (no
9
- * `outbound` metadata key), with direction, `metadata.agent`, Bearer token; returns the id;
10
- * - reply(): threads in_reply_to when the bridge passes it;
11
- * - loadTranscript(): queries the single `#agent/message` parent tag, filters by
12
- * `noteAgentKey(meta)` (the routing key) client-side;
13
- * - ingestInbound(): emits the inbound content + meta onto its channel;
14
- * - ingestInbound(): IGNORES a `#agent/message/outbound`-tagged note (loop avoidance);
15
- * - schema: `AGENT_VAULT_TAG_SCHEMA` declares the `#agent/*` namespace rollup;
16
- * - registry: a vault channel instantiates from config.
17
- *
18
- * TAG NAMESPACE — `#agent/*` (design 2026-06-17-vault-native-agents). WRITE + READ
19
- * are the `#agent/message*` tags only — the channel→agent data-model rename CONTRACT
20
- * dropped the legacy `#channel-message*` / interim `#agent-message*` dual-read. The
21
- * routing key is written under `metadata.agent` ONLY (the `channel` dual-write is
22
- * dropped); `noteAgentKey` keeps an `agent ?? channel` read fallback for stragglers.
23
- * The channel-name slugs, `?channel=`, the `Channel*` types, and the `channel/<name>/`
24
- * note path prefix are DOMAIN — unchanged.
25
- */
26
-
27
- import { describe, test, expect, afterEach } from "bun:test";
28
- import { VaultTransport, AGENT_VAULT_TAG_SCHEMA, AGENT_THREAD_TAG, AGENT_JOB_TAG, InboundClaimConflictError, noteAgentKey } from "./vault.ts";
29
- import type { TransportContext, InboundMessage } from "../transport.ts";
30
- import { instantiateTransport } from "../registry.ts";
31
-
32
- const realFetch = globalThis.fetch;
33
- afterEach(() => {
34
- globalThis.fetch = realFetch;
35
- });
36
-
37
- /** A test context that records emitted inbound messages. */
38
- function fakeCtx(channel: string): TransportContext & { emitted: InboundMessage[] } {
39
- const emitted: InboundMessage[] = [];
40
- return {
41
- channel,
42
- emitted,
43
- emit(msg) {
44
- emitted.push(msg);
45
- },
46
- emitPermissionVerdict() {},
47
- };
48
- }
49
-
50
- function baseConfig() {
51
- return {
52
- vault: "default",
53
- vaultUrl: "http://127.0.0.1:1940",
54
- token: "write-token-xyz",
55
- webhookSecret: "s3cret",
56
- };
57
- }
58
-
59
- describe("noteAgentKey — the expand-phase dual-read routing key", () => {
60
- test("returns `agent` when present", () => {
61
- expect(noteAgentKey({ agent: "eng" })).toBe("eng");
62
- });
63
- test("falls back to legacy `channel` when `agent` is absent", () => {
64
- expect(noteAgentKey({ channel: "ops" })).toBe("ops");
65
- });
66
- test("prefers `agent` over `channel` when BOTH are present", () => {
67
- expect(noteAgentKey({ agent: "eng", channel: "legacy" })).toBe("eng");
68
- });
69
- test("returns undefined when neither is present", () => {
70
- expect(noteAgentKey({})).toBeUndefined();
71
- expect(noteAgentKey(undefined)).toBeUndefined();
72
- expect(noteAgentKey(null)).toBeUndefined();
73
- });
74
- test("ignores empty-string / non-string values (falls through)", () => {
75
- // An empty `agent` is not a usable routing key → fall back to channel.
76
- expect(noteAgentKey({ agent: "", channel: "ops" })).toBe("ops");
77
- // Non-string values are ignored entirely.
78
- expect(noteAgentKey({ agent: 123 as unknown as string, channel: "ops" })).toBe("ops");
79
- expect(noteAgentKey({ agent: "", channel: "" })).toBeUndefined();
80
- });
81
- });
82
-
83
- describe("VaultTransport — reply (outbound note write)", () => {
84
- test("reply() POSTs .../api/notes tagged #agent/message + #agent/message/outbound + direction + channel + Bearer", async () => {
85
- const calls: { url: string; init: RequestInit }[] = [];
86
- globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
87
- calls.push({ url: String(url), init: init ?? {} });
88
- return new Response(JSON.stringify({ id: "note-created-1" }), {
89
- status: 201,
90
- headers: { "content-type": "application/json" },
91
- });
92
- }) as typeof fetch;
93
-
94
- const t = new VaultTransport(baseConfig());
95
- await t.start(fakeCtx("eng"));
96
- const result = await t.reply({ channel: "eng", text: "the reply text" });
97
-
98
- expect(result.sent).toEqual(["note-created-1"]);
99
- // start() also fires ensureSchema() (PUT .../api/tags/*); isolate the note POST.
100
- const noteCalls = calls.filter((c) => c.url.endsWith("/api/notes"));
101
- expect(noteCalls).toHaveLength(1);
102
- const call = noteCalls[0]!;
103
- expect(call.url).toBe("http://127.0.0.1:1940/vault/default/api/notes");
104
- expect(call.init.method).toBe("POST");
105
- const headers = call.init.headers as Record<string, string>;
106
- expect(headers.authorization).toBe("Bearer write-token-xyz");
107
-
108
- const sent = JSON.parse(String(call.init.body)) as {
109
- content: string;
110
- path: string;
111
- tags: string[];
112
- metadata: Record<string, string>;
113
- };
114
- expect(sent.content).toBe("the reply text");
115
- // Two orthogonal tags: the parent `#agent/message` is carried LITERALLY so
116
- // the note is queryable under it (a slash is namespace, NOT query inheritance —
117
- // a child-only-tagged note is invisible to a `tag:#agent/message` query), and
118
- // the directional child `#agent/message/outbound` is the trigger discriminator.
119
- // We WRITE only the `#agent/message*` tags.
120
- expect(sent.tags).toEqual(["agent/message", "agent/message/outbound"]);
121
- // Regression guard: the queryable parent tag MUST be present literally.
122
- expect(sent.tags).toContain("agent/message");
123
- expect(sent.tags).toContain("agent/message/outbound");
124
- // Write-discipline: the interim/legacy tags are gone (CONTRACT dropped them).
125
- expect(sent.tags).not.toContain("#agent-message");
126
- expect(sent.tags).not.toContain("#agent-message/outbound");
127
- expect(sent.tags).not.toContain("#channel-message");
128
- expect(sent.tags).not.toContain("#channel-message/outbound");
129
- // The note PATH prefix is DOMAIN (`channel/<name>/`) — unchanged by the rename.
130
- expect(sent.path.startsWith("channel/eng/")).toBe(true);
131
- // CONTRACT: the routing key is written under `metadata.agent` ONLY — no `channel`.
132
- expect(sent.metadata.agent).toBe("eng");
133
- expect(sent.metadata.channel).toBeUndefined();
134
- expect(sent.metadata.direction).toBe("outbound");
135
- expect(sent.metadata.sender).toBe("session");
136
- // The old `outbound:"1"` presence marker is gone — no such metadata key.
137
- expect("outbound" in sent.metadata).toBe(false);
138
- expect(typeof sent.metadata.ts).toBe("string");
139
- });
140
-
141
- test("reply() threads in_reply_to from args.meta", async () => {
142
- let captured: Record<string, string> | undefined;
143
- globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
144
- // Ignore the ensureSchema PUTs fired by start(); only the note POST carries metadata.
145
- if (String(url).endsWith("/api/notes")) {
146
- const body = JSON.parse(String(init?.body)) as { metadata: Record<string, string> };
147
- captured = body.metadata;
148
- }
149
- return new Response(JSON.stringify({ id: "n2" }), { status: 201 });
150
- }) as typeof fetch;
151
-
152
- const t = new VaultTransport(baseConfig());
153
- await t.start(fakeCtx("eng"));
154
- await t.reply({ channel: "eng", text: "re", meta: { in_reply_to: "inbound-99" } });
155
- expect(captured!.in_reply_to).toBe("inbound-99");
156
- });
157
-
158
- test("reply() stamps metadata.thread from args.meta.thread (the definition→thread→message link)", async () => {
159
- let captured: Record<string, string> | undefined;
160
- globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
161
- if (String(url).endsWith("/api/notes")) {
162
- const body = JSON.parse(String(init?.body)) as { metadata: Record<string, string> };
163
- captured = body.metadata;
164
- }
165
- return new Response(JSON.stringify({ id: "n3" }), { status: 201 });
166
- }) as typeof fetch;
167
-
168
- const t = new VaultTransport(baseConfig());
169
- await t.start(fakeCtx("eng"));
170
- await t.reply({ channel: "eng", text: "re", meta: { in_reply_to: "inbound-99", thread: "fire-7" } });
171
- expect(captured!.thread).toBe("fire-7");
172
- expect(captured!.in_reply_to).toBe("inbound-99");
173
- });
174
-
175
- test("reply() falls back to the proposed id when the response has no id", async () => {
176
- globalThis.fetch = (async () =>
177
- new Response("", { status: 201 })) as unknown as typeof fetch;
178
- const t = new VaultTransport(baseConfig());
179
- await t.start(fakeCtx("eng"));
180
- const result = await t.reply({ channel: "eng", text: "x" });
181
- expect(result.sent).toHaveLength(1);
182
- expect(typeof result.sent[0]).toBe("string");
183
- });
184
-
185
- test("reply() throws on a non-ok vault response", async () => {
186
- globalThis.fetch = (async () =>
187
- new Response("boom", { status: 500 })) as unknown as typeof fetch;
188
- const t = new VaultTransport(baseConfig());
189
- await t.start(fakeCtx("eng"));
190
- await expect(t.reply({ channel: "eng", text: "x" })).rejects.toThrow(/write reply failed/);
191
- });
192
- });
193
-
194
- describe("VaultTransport — writeThread (#agent/thread note, the unified model)", () => {
195
- test("MULTI-THREADED: writeThread() PATCH-upserts (if_missing:create) a fresh-per-fire #agent/thread note with indexed status/definition/mode + timing + Bearer (NO read-back)", async () => {
196
- const calls: { url: string; init: RequestInit }[] = [];
197
- globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
198
- calls.push({ url: String(url), init: init ?? {} });
199
- return new Response(JSON.stringify({ id: "thread-note-1" }), {
200
- status: 201,
201
- headers: { "content-type": "application/json" },
202
- });
203
- }) as typeof fetch;
204
-
205
- const t = new VaultTransport(baseConfig());
206
- await t.start(fakeCtx("eng"));
207
- const result = await t.writeThread({
208
- channel: "eng",
209
- name: "digest",
210
- definition: "Agents/digest",
211
- mode: "multi-threaded",
212
- status: "ok",
213
- input: "run the daily digest",
214
- output: "digest complete: 3 items",
215
- started_at: "2026-06-18T07:00:00.000Z",
216
- ended_at: "2026-06-18T07:00:12.000Z",
217
- usage: { inputTokens: 100, outputTokens: 40, totalCostUsd: 0.002 },
218
- });
219
-
220
- expect(result.sent).toEqual(["thread-note-1"]);
221
- // start() also fires ensureSchema() (PUT .../api/tags/*); isolate the thread-note
222
- // write. The write is a PATCH-by-path upsert (NOT POST — POST 409s on an existing
223
- // path), so it targets /api/notes/<encoded-path>, discriminated by method.
224
- const noteCalls = calls.filter((c) => c.url.includes("/api/notes/") && c.init.method === "PATCH");
225
- expect(noteCalls).toHaveLength(1);
226
- // Multi-threaded does NO read-back (no GET to /api/notes/<path>) — fresh per fire.
227
- const getCalls = calls.filter((c) => c.url.includes("/api/notes/") && (c.init.method ?? "GET") === "GET");
228
- expect(getCalls).toHaveLength(0);
229
- const call = noteCalls[0]!;
230
- expect(decodeURIComponent(call.url)).toContain("/vault/default/api/notes/Threads/eng/");
231
- expect(call.init.method).toBe("PATCH");
232
- expect((call.init.headers as Record<string, string>).authorization).toBe("Bearer write-token-xyz");
233
-
234
- const sent = JSON.parse(String(call.init.body)) as {
235
- content: string;
236
- path: string;
237
- tags: string[];
238
- metadata: Record<string, string>;
239
- if_missing: string;
240
- force: boolean;
241
- };
242
- // The upsert verb: PATCH + `if_missing: "create"` (creates when missing — every
243
- // multi-threaded fire — updates when present) + `force: true` (the 428 precondition).
244
- expect(sent.if_missing).toBe("create");
245
- expect(sent.force).toBe(true);
246
- // LOOP SAFETY (HARD CONSTRAINT 4): the thread note carries the thread tag EXACTLY —
247
- // NOT a message tag + NOT the inbound child — so it can never wake a session.
248
- expect(sent.tags).toEqual([AGENT_THREAD_TAG]);
249
- expect(sent.tags).not.toContain("agent/message");
250
- expect(sent.tags).not.toContain("agent/message/inbound");
251
- // Indexed/queryable fields.
252
- expect(sent.metadata.status).toBe("ok");
253
- expect(sent.metadata.definition).toBe("Agents/digest");
254
- expect(sent.metadata.mode).toBe("multi-threaded");
255
- // Thread-state + routing key + usage (stringified for the vault).
256
- // CONTRACT: routing key under `metadata.agent` ONLY — no `channel`.
257
- expect(sent.metadata.agent).toBe("eng");
258
- expect(sent.metadata.channel).toBeUndefined();
259
- expect(sent.metadata.started_at).toBe("2026-06-18T07:00:00.000Z");
260
- expect(sent.metadata.last_turn_at).toBe("2026-06-18T07:00:12.000Z");
261
- expect(sent.metadata.turn_count).toBe("1");
262
- expect(sent.metadata.input_tokens).toBe("100");
263
- expect(sent.metadata.output_tokens).toBe("40");
264
- expect(sent.metadata.total_cost_usd).toBe("0.002");
265
- // The body is a rolling SUMMARY with the two documented sections.
266
- expect(sent.content).toContain("## Summary");
267
- expect(sent.content).toContain("## Latest turn");
268
- expect(sent.content).toContain("run the daily digest");
269
- expect(sent.content).toContain("digest complete: 3 items");
270
- // Multi-threaded path leaf is a fresh uuid under Threads/<channel>/.
271
- expect(sent.path.startsWith("Threads/eng/")).toBe(true);
272
- });
273
-
274
- test("SINGLE-THREADED: writeThread() upserts ONE deterministic-path note named after the def (reads existing first)", async () => {
275
- const posts: { url: string; init: RequestInit }[] = [];
276
- const gets: string[] = [];
277
- globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
278
- const u = String(url);
279
- const method = init?.method ?? "GET";
280
- if (u.includes("/api/notes/") && method === "GET") {
281
- gets.push(u);
282
- // First turn: the note doesn't exist yet (404 → turn_count starts at 0).
283
- return new Response("not found", { status: 404 });
284
- }
285
- // The write is a PATCH-by-path upsert (if_missing:create), NOT POST.
286
- if (u.includes("/api/notes/") && method === "PATCH") {
287
- posts.push({ url: u, init: init ?? {} });
288
- return new Response(JSON.stringify({ id: "thread-eng" }), { status: 200 });
289
- }
290
- return new Response("{}", { status: 200 }); // ensureSchema PUTs
291
- }) as typeof fetch;
292
-
293
- const t = new VaultTransport(baseConfig());
294
- await t.start(fakeCtx("eng"));
295
- await t.writeThread({
296
- channel: "eng",
297
- name: "eng",
298
- mode: "single-threaded",
299
- status: "ok",
300
- input: "hello",
301
- output: "hi there",
302
- started_at: "2026-06-18T07:00:00.000Z",
303
- ended_at: "2026-06-18T07:00:05.000Z",
304
- usage: { inputTokens: 10, outputTokens: 5 },
305
- });
306
-
307
- // It READ the existing note first (the upsert read-back), by the DETERMINISTIC path.
308
- expect(gets).toHaveLength(1);
309
- expect(decodeURIComponent(gets[0]!)).toContain("/api/notes/Threads/eng/eng");
310
- // Then UPSERTED via PATCH (if_missing:create) to the same deterministic path.
311
- expect(posts).toHaveLength(1);
312
- expect(posts[0]!.init.method).toBe("PATCH");
313
- expect(decodeURIComponent(posts[0]!.url)).toContain("/api/notes/Threads/eng/eng");
314
- const sent = JSON.parse(String(posts[0]!.init.body)) as {
315
- path: string;
316
- tags: string[];
317
- metadata: Record<string, string>;
318
- content: string;
319
- if_missing: string;
320
- force: boolean;
321
- };
322
- expect(sent.if_missing).toBe("create"); // upsert verb (not POST — POST 409s).
323
- expect(sent.force).toBe(true);
324
- expect(sent.tags).toEqual([AGENT_THREAD_TAG]); // loop safety.
325
- expect(sent.path).toBe("Threads/eng/eng"); // deterministic, named after the def.
326
- expect(sent.metadata.mode).toBe("single-threaded");
327
- expect(sent.metadata.turn_count).toBe("1"); // first turn (no prior).
328
- expect(sent.metadata.started_at).toBe("2026-06-18T07:00:00.000Z");
329
- expect(sent.metadata.last_turn_at).toBe("2026-06-18T07:00:05.000Z");
330
- expect(sent.content).toContain("## Summary");
331
- expect(sent.content).toContain("single-threaded thread for eng");
332
- });
333
-
334
- test("SINGLE-THREADED over TWO turns: same deterministic path, turn_count==2, summed usage, preserved started_at", async () => {
335
- // Simulate a vault: the second turn reads back the note the FIRST turn wrote.
336
- let stored: { metadata: Record<string, string>; content: string } | undefined;
337
- globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
338
- const u = String(url);
339
- const method = init?.method ?? "GET";
340
- if (u.includes("/api/notes/") && method === "GET") {
341
- if (!stored) return new Response("not found", { status: 404 });
342
- return new Response(JSON.stringify(stored), { status: 200 });
343
- }
344
- // PATCH-by-path with if_missing:create is the upsert (turn 1 creates, turn 2 updates).
345
- if (u.includes("/api/notes/") && method === "PATCH") {
346
- const body = JSON.parse(String(init?.body)) as { metadata: Record<string, string>; content: string };
347
- stored = { metadata: body.metadata, content: body.content }; // the vault upserts it.
348
- return new Response(JSON.stringify({ id: "thread-eng" }), { status: 200 });
349
- }
350
- return new Response("{}", { status: 200 });
351
- }) as typeof fetch;
352
-
353
- const t = new VaultTransport(baseConfig());
354
- await t.start(fakeCtx("eng"));
355
-
356
- await t.writeThread({
357
- channel: "eng",
358
- name: "eng",
359
- mode: "single-threaded",
360
- status: "ok",
361
- input: "turn one",
362
- output: "reply one",
363
- started_at: "2026-06-18T07:00:00.000Z",
364
- ended_at: "2026-06-18T07:00:05.000Z",
365
- usage: { inputTokens: 10, outputTokens: 5, totalCostUsd: 0.001 },
366
- });
367
- expect(stored!.metadata.turn_count).toBe("1");
368
-
369
- await t.writeThread({
370
- channel: "eng",
371
- name: "eng",
372
- mode: "single-threaded",
373
- status: "ok",
374
- input: "turn two",
375
- output: "reply two",
376
- started_at: "2026-06-18T08:00:00.000Z", // a LATER start — must NOT overwrite the first.
377
- ended_at: "2026-06-18T08:00:09.000Z",
378
- usage: { inputTokens: 20, outputTokens: 8, totalCostUsd: 0.002 },
379
- });
380
-
381
- // ONE note, upserted: turn_count incremented, usage SUMMED, started_at PRESERVED,
382
- // last_turn_at advanced.
383
- expect(stored!.metadata.turn_count).toBe("2");
384
- expect(stored!.metadata.input_tokens).toBe("30"); // 10 + 20
385
- expect(stored!.metadata.output_tokens).toBe("13"); // 5 + 8
386
- expect(stored!.metadata.total_cost_usd).toBe("0.003"); // 0.001 + 0.002
387
- expect(stored!.metadata.started_at).toBe("2026-06-18T07:00:00.000Z"); // first turn's, preserved.
388
- expect(stored!.metadata.last_turn_at).toBe("2026-06-18T08:00:09.000Z"); // latest turn.
389
- // The body's summary reflects 2 turns + the latest turn's content.
390
- expect(stored!.content).toContain("2 turns");
391
- expect(stored!.content).toContain("turn two");
392
- expect(stored!.content).toContain("reply two");
393
- });
394
-
395
- test("SINGLE-THREADED re-record of the SAME turn (sameTurn) flips status WITHOUT double-counting turn_count (PR #3 FIX 1)", async () => {
396
- // The outbound-failure path: the turn was recorded `ok`, then the additive transcript
397
- // write failed, so the same turn is re-recorded `error`. `sameTurn` must keep the count
398
- // (the turn was already counted) — the reviewer caught the original re-record bumping it.
399
- let stored: { metadata: Record<string, string>; content: string } | undefined;
400
- globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
401
- const u = String(url);
402
- const method = init?.method ?? "GET";
403
- if (u.includes("/api/notes/") && method === "GET") {
404
- if (!stored) return new Response("not found", { status: 404 });
405
- return new Response(JSON.stringify(stored), { status: 200 });
406
- }
407
- if (u.includes("/api/notes/") && method === "PATCH") {
408
- const body = JSON.parse(String(init?.body)) as { metadata: Record<string, string>; content: string };
409
- stored = { metadata: body.metadata, content: body.content };
410
- return new Response(JSON.stringify({ id: "thread-eng" }), { status: 200 });
411
- }
412
- return new Response("{}", { status: 200 });
413
- }) as typeof fetch;
414
- const t = new VaultTransport(baseConfig());
415
- await t.start(fakeCtx("eng"));
416
- await t.writeThread({
417
- channel: "eng", name: "eng", mode: "single-threaded", status: "ok",
418
- input: "q", output: "a", started_at: "2026-06-18T07:00:00.000Z",
419
- ended_at: "2026-06-18T07:00:05.000Z", threadId: "t1",
420
- });
421
- expect(stored!.metadata.turn_count).toBe("1");
422
- // Re-record the SAME turn as error (outbound delivery failed). sameTurn → no increment.
423
- await t.writeThread({
424
- channel: "eng", name: "eng", mode: "single-threaded", status: "error",
425
- input: "q", output: "reply produced but NOT delivered", started_at: "2026-06-18T07:00:00.000Z",
426
- ended_at: "2026-06-18T07:00:06.000Z", threadId: "t1", sameTurn: true,
427
- });
428
- expect(stored!.metadata.turn_count).toBe("1"); // NOT 2 — the same turn, not a new one.
429
- expect(stored!.metadata.status).toBe("error");
430
- expect(stored!.content).toContain("NOT delivered");
431
- });
432
-
433
- test("SINGLE-THREADED FULL lifecycle start→end(ok)→end(error,sameTurn): count goes 0→1→1, never double-counts (thread-as-container + FIX 1)", async () => {
434
- // The real drain path now writes a `working` start-ensure BEFORE the turn, then an
435
- // `end` record, then (on outbound failure) an `end` re-record with sameTurn. This is
436
- // the one combination the prior FIX-1 test didn't exercise: a start-ensure preceding
437
- // the re-record. The start must NOT count; the first end counts once; the sameTurn
438
- // re-record must keep it.
439
- let stored: { metadata: Record<string, string>; content: string } | undefined;
440
- globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
441
- const u = String(url);
442
- const method = init?.method ?? "GET";
443
- if (u.includes("/api/notes/") && method === "GET") {
444
- if (!stored) return new Response("not found", { status: 404 });
445
- return new Response(JSON.stringify(stored), { status: 200 });
446
- }
447
- if (u.includes("/api/notes/") && method === "PATCH") {
448
- const body = JSON.parse(String(init?.body)) as { metadata: Record<string, string>; content: string };
449
- stored = { metadata: body.metadata, content: body.content };
450
- return new Response(JSON.stringify({ id: "thread-eng" }), { status: 200 });
451
- }
452
- return new Response("{}", { status: 200 });
453
- }) as typeof fetch;
454
- const t = new VaultTransport(baseConfig());
455
- await t.start(fakeCtx("eng"));
456
- const base = {
457
- channel: "eng", name: "eng", mode: "single-threaded" as const, input: "q",
458
- started_at: "2026-06-18T07:00:00.000Z", ended_at: "2026-06-18T07:00:05.000Z", threadId: "t1",
459
- };
460
- // 1) start-ensure (working) — the container, BEFORE the turn. Must NOT count.
461
- await t.writeThread({ ...base, status: "working", output: "", phase: "start" });
462
- expect(stored!.metadata.turn_count).toBe("0");
463
- expect(stored!.metadata.status).toBe("working");
464
- // 2) end(ok) — the turn completed: count once.
465
- await t.writeThread({ ...base, status: "ok", output: "a", phase: "end" });
466
- expect(stored!.metadata.turn_count).toBe("1");
467
- expect(stored!.metadata.status).toBe("ok");
468
- // 3) end(error, sameTurn) — outbound write failed, re-record the SAME turn. No increment.
469
- await t.writeThread({ ...base, status: "error", output: "reply produced but NOT delivered", phase: "end", sameTurn: true });
470
- expect(stored!.metadata.turn_count).toBe("1"); // STILL 1 — start didn't count, sameTurn didn't re-count.
471
- expect(stored!.metadata.status).toBe("error");
472
- });
473
-
474
- test("MULTI-THREADED re-record reuses the passed threadId leaf — ONE note, not a duplicate (PR #3 FIX 1)", async () => {
475
- const patchPaths: string[] = [];
476
- globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
477
- const u = String(url);
478
- if (u.includes("/api/notes/") && (init?.method ?? "GET") === "PATCH") {
479
- patchPaths.push(decodeURIComponent(u));
480
- return new Response(JSON.stringify({ id: "x" }), { status: 200 });
481
- }
482
- return new Response("{}", { status: 200 });
483
- }) as typeof fetch;
484
- const t = new VaultTransport(baseConfig());
485
- await t.start(fakeCtx("eng"));
486
- const base = {
487
- channel: "eng", name: "d", mode: "multi-threaded" as const,
488
- input: "q", started_at: "2026-06-18T07:00:00.000Z", ended_at: "2026-06-18T07:00:05.000Z",
489
- threadId: "fixed-uuid",
490
- };
491
- await t.writeThread({ ...base, status: "ok", output: "a" });
492
- await t.writeThread({ ...base, status: "error", output: "undelivered", sameTurn: true });
493
- // Both writes hit the SAME per-fire path (the reused threadId) — without the fix the
494
- // second would mint a fresh uuid → a DIFFERENT path → a duplicate note for one turn.
495
- const threadPatches = patchPaths.filter((p) => p.includes("/Threads/eng/"));
496
- expect(threadPatches).toHaveLength(2);
497
- expect(threadPatches[0]).toContain("/Threads/eng/fixed-uuid");
498
- expect(threadPatches[1]).toContain("/Threads/eng/fixed-uuid");
499
- });
500
-
501
- test("SINGLE-THREADED error on turn 2: turn_count==2, status:error, started_at preserved, last_turn_at advanced", async () => {
502
- // Same stored-note simulation as the two-turn test: turn 2 reads back turn 1's note.
503
- let stored: { metadata: Record<string, string>; content: string } | undefined;
504
- globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
505
- const u = String(url);
506
- const method = init?.method ?? "GET";
507
- if (u.includes("/api/notes/") && method === "GET") {
508
- if (!stored) return new Response("not found", { status: 404 });
509
- return new Response(JSON.stringify(stored), { status: 200 });
510
- }
511
- if (u.includes("/api/notes/") && method === "PATCH") {
512
- const body = JSON.parse(String(init?.body)) as { metadata: Record<string, string>; content: string };
513
- stored = { metadata: body.metadata, content: body.content };
514
- return new Response(JSON.stringify({ id: "thread-eng" }), { status: 200 });
515
- }
516
- return new Response("{}", { status: 200 });
517
- }) as typeof fetch;
518
-
519
- const t = new VaultTransport(baseConfig());
520
- await t.start(fakeCtx("eng"));
521
-
522
- // Turn 1 — ok.
523
- await t.writeThread({
524
- channel: "eng",
525
- name: "eng",
526
- mode: "single-threaded",
527
- status: "ok",
528
- input: "turn one",
529
- output: "reply one",
530
- started_at: "2026-06-18T07:00:00.000Z",
531
- ended_at: "2026-06-18T07:00:05.000Z",
532
- });
533
- expect(stored!.metadata.status).toBe("ok");
534
-
535
- // Turn 2 — ERROR. The single-threaded thread keeps upserting (the failure is part of
536
- // the rolling thread record); the status reflects this latest turn.
537
- await t.writeThread({
538
- channel: "eng",
539
- name: "eng",
540
- mode: "single-threaded",
541
- status: "error",
542
- input: "turn two",
543
- output: "claude -p exited 1: boom",
544
- started_at: "2026-06-18T08:00:00.000Z", // later — must NOT overwrite the first.
545
- ended_at: "2026-06-18T08:00:09.000Z",
546
- });
547
-
548
- expect(stored!.metadata.turn_count).toBe("2"); // incremented despite the error.
549
- expect(stored!.metadata.status).toBe("error"); // the latest turn's outcome.
550
- expect(stored!.metadata.started_at).toBe("2026-06-18T07:00:00.000Z"); // preserved.
551
- expect(stored!.metadata.last_turn_at).toBe("2026-06-18T08:00:09.000Z"); // advanced.
552
- // The body's latest-turn section is the Error block.
553
- expect(stored!.content).toContain("**Error:**");
554
- expect(stored!.content).toContain("claude -p exited 1: boom");
555
- });
556
-
557
- test("SINGLE-THREADED: a 500 on the read-back GET rejects (not a silent aggregate reset)", async () => {
558
- globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
559
- const u = String(url);
560
- const method = init?.method ?? "GET";
561
- // The single-threaded read-back GET returns a 500 (an UNEXPECTED non-404 error) →
562
- // readThreadNote throws → writeThread rejects, surfacing the misconfig rather than
563
- // silently resetting the thread's aggregates.
564
- if (u.includes("/api/notes/") && method === "GET") {
565
- return new Response("boom", { status: 500 });
566
- }
567
- return new Response("{}", { status: 200 });
568
- }) as unknown as typeof fetch;
569
-
570
- const t = new VaultTransport(baseConfig());
571
- await t.start(fakeCtx("eng"));
572
- await expect(
573
- t.writeThread({
574
- channel: "eng",
575
- name: "eng",
576
- mode: "single-threaded",
577
- status: "ok",
578
- input: "x",
579
- output: "y",
580
- started_at: "2026-06-18T07:00:00.000Z",
581
- ended_at: "2026-06-18T07:00:01.000Z",
582
- }),
583
- ).rejects.toThrow(/read thread note failed/);
584
- });
585
-
586
- test("SINGLE-THREADED cost rounding: 0.1 + 0.2 serializes as \"0.3\" (no IEEE-754 drift)", async () => {
587
- let stored: { metadata: Record<string, string>; content: string } | undefined;
588
- globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
589
- const u = String(url);
590
- const method = init?.method ?? "GET";
591
- if (u.includes("/api/notes/") && method === "GET") {
592
- if (!stored) return new Response("not found", { status: 404 });
593
- return new Response(JSON.stringify(stored), { status: 200 });
594
- }
595
- if (u.includes("/api/notes/") && method === "PATCH") {
596
- const body = JSON.parse(String(init?.body)) as { metadata: Record<string, string>; content: string };
597
- stored = { metadata: body.metadata, content: body.content };
598
- return new Response(JSON.stringify({ id: "thread-eng" }), { status: 200 });
599
- }
600
- return new Response("{}", { status: 200 });
601
- }) as typeof fetch;
602
-
603
- const t = new VaultTransport(baseConfig());
604
- await t.start(fakeCtx("eng"));
605
-
606
- await t.writeThread({
607
- channel: "eng",
608
- name: "eng",
609
- mode: "single-threaded",
610
- status: "ok",
611
- input: "one",
612
- output: "r1",
613
- started_at: "2026-06-18T07:00:00.000Z",
614
- ended_at: "2026-06-18T07:00:05.000Z",
615
- usage: { totalCostUsd: 0.1 },
616
- });
617
- await t.writeThread({
618
- channel: "eng",
619
- name: "eng",
620
- mode: "single-threaded",
621
- status: "ok",
622
- input: "two",
623
- output: "r2",
624
- started_at: "2026-06-18T08:00:00.000Z",
625
- ended_at: "2026-06-18T08:00:09.000Z",
626
- usage: { totalCostUsd: 0.2 },
627
- });
628
-
629
- // The naive sum 0.1 + 0.2 === 0.30000000000000004; the round-to-9-decimals guard
630
- // serializes it cleanly as "0.3".
631
- expect(stored!.metadata.total_cost_usd).toBe("0.3");
632
- });
633
-
634
- test("writeThread() on a MULTI-THREADED error turn records status:error + the failure reason in the body (NO read-back)", async () => {
635
- const calls: { url: string; init: RequestInit }[] = [];
636
- let captured: { tags: string[]; metadata: Record<string, string>; content: string } | undefined;
637
- globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
638
- calls.push({ url: String(url), init: init ?? {} });
639
- if (String(url).includes("/api/notes/") && (init?.method ?? "GET") === "PATCH") {
640
- captured = JSON.parse(String(init?.body));
641
- }
642
- return new Response(JSON.stringify({ id: "thread-err-1" }), { status: 200 });
643
- }) as typeof fetch;
644
-
645
- const t = new VaultTransport(baseConfig());
646
- await t.start(fakeCtx("eng"));
647
- await t.writeThread({
648
- channel: "eng",
649
- mode: "multi-threaded",
650
- status: "error",
651
- input: "do the thing",
652
- output: "claude -p exited 1: boom",
653
- started_at: "2026-06-18T07:00:00.000Z",
654
- ended_at: "2026-06-18T07:00:01.000Z",
655
- });
656
-
657
- expect(captured!.metadata.status).toBe("error");
658
- // No definition → the field is absent (not an empty string).
659
- expect("definition" in captured!.metadata).toBe(false);
660
- // The body's latest-turn section is the Error block on a failure.
661
- expect(captured!.content).toContain("**Error:**");
662
- expect(captured!.content).toContain("claude -p exited 1: boom");
663
- // Multi-threaded does NO read-back even on the error path (fresh per fire).
664
- expect(
665
- calls.filter((c) => c.url.includes("/api/notes/") && (c.init.method ?? "GET") === "GET"),
666
- ).toHaveLength(0);
667
- });
668
-
669
- test("writeThread() throws on a non-ok vault response (PATCH)", async () => {
670
- globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
671
- // multi-threaded → no GET; the PATCH upsert fails.
672
- if (String(url).includes("/api/notes/") && (init?.method ?? "GET") === "PATCH") {
673
- return new Response("boom", { status: 500 });
674
- }
675
- return new Response("{}", { status: 200 });
676
- }) as unknown as typeof fetch;
677
- const t = new VaultTransport(baseConfig());
678
- await t.start(fakeCtx("eng"));
679
- await expect(
680
- t.writeThread({
681
- channel: "eng",
682
- mode: "multi-threaded",
683
- status: "ok",
684
- input: "x",
685
- output: "y",
686
- started_at: "2026-06-18T07:00:00.000Z",
687
- ended_at: "2026-06-18T07:00:01.000Z",
688
- }),
689
- ).rejects.toThrow(/write thread note failed/);
690
- });
691
-
692
- // ── Thread-as-container: the phase:"start" working-ensure (Part B) ────────────────────
693
- // A turn now writes TWO thread notes: a `phase:"start"` working-ensure BEFORE the turn
694
- // (status:working, NO reply, turn_count UNCHANGED) and a `phase:"end"` final record after
695
- // (status:ok/error, turn counted). turn_count must be counted EXACTLY ONCE — on `end` —
696
- // never double-counted across the start+end pair. These assert that at the transport.
697
-
698
- test("SINGLE-THREADED start→end does NOT double-count: turn 1 start writes turn_count 0 (working), end writes 1", async () => {
699
- let stored: { metadata: Record<string, string>; content: string } | undefined;
700
- globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
701
- const u = String(url);
702
- const method = init?.method ?? "GET";
703
- if (u.includes("/api/notes/") && method === "GET") {
704
- if (!stored) return new Response("not found", { status: 404 });
705
- return new Response(JSON.stringify(stored), { status: 200 });
706
- }
707
- if (u.includes("/api/notes/") && method === "PATCH") {
708
- const body = JSON.parse(String(init?.body)) as { metadata: Record<string, string>; content: string };
709
- stored = { metadata: body.metadata, content: body.content };
710
- return new Response(JSON.stringify({ id: "thread-eng" }), { status: 200 });
711
- }
712
- return new Response("{}", { status: 200 });
713
- }) as typeof fetch;
714
- const t = new VaultTransport(baseConfig());
715
- await t.start(fakeCtx("eng"));
716
-
717
- // START-ENSURE (before the turn): status working, turn_count UNCHANGED (prior 0 → 0).
718
- await t.writeThread({
719
- channel: "eng", name: "eng", mode: "single-threaded", status: "working",
720
- input: "turn one", output: "", started_at: "2026-06-18T07:00:00.000Z",
721
- ended_at: "2026-06-18T07:00:00.000Z", threadId: "t1", phase: "start",
722
- });
723
- expect(stored!.metadata.status).toBe("working");
724
- expect(stored!.metadata.turn_count).toBe("0"); // NOT counted yet.
725
- // The working body shows the input + an awaiting-reply state — NO fake reply.
726
- expect(stored!.content).toContain("turn one");
727
- expect(stored!.content).toContain("working");
728
- expect(stored!.content).not.toContain("**Reply:**");
729
- // last_turn_at is not stamped on a brand-new working-ensure (no turn completed yet).
730
- expect(stored!.metadata.last_turn_at).toBeUndefined();
731
-
732
- // END (after the turn): status ok, turn_count now 1 (counted exactly once).
733
- await t.writeThread({
734
- channel: "eng", name: "eng", mode: "single-threaded", status: "ok",
735
- input: "turn one", output: "reply one", started_at: "2026-06-18T07:00:00.000Z",
736
- ended_at: "2026-06-18T07:00:05.000Z", threadId: "t1", phase: "end",
737
- });
738
- expect(stored!.metadata.status).toBe("ok");
739
- expect(stored!.metadata.turn_count).toBe("1"); // counted ONCE across start+end.
740
- expect(stored!.metadata.last_turn_at).toBe("2026-06-18T07:00:05.000Z");
741
- expect(stored!.content).toContain("reply one");
742
- });
743
-
744
- test("SINGLE-THREADED turn 2 start preserves prior count (1), end increments to 2 — start never double-counts", async () => {
745
- let stored: { metadata: Record<string, string>; content: string } | undefined;
746
- globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
747
- const u = String(url);
748
- const method = init?.method ?? "GET";
749
- if (u.includes("/api/notes/") && method === "GET") {
750
- if (!stored) return new Response("not found", { status: 404 });
751
- return new Response(JSON.stringify(stored), { status: 200 });
752
- }
753
- if (u.includes("/api/notes/") && method === "PATCH") {
754
- const body = JSON.parse(String(init?.body)) as { metadata: Record<string, string>; content: string };
755
- stored = { metadata: body.metadata, content: body.content };
756
- return new Response(JSON.stringify({ id: "thread-eng" }), { status: 200 });
757
- }
758
- return new Response("{}", { status: 200 });
759
- }) as typeof fetch;
760
- const t = new VaultTransport(baseConfig());
761
- await t.start(fakeCtx("eng"));
762
- const tn = (status: "working" | "ok", input: string, output: string, ended: string, phase: "start" | "end") => ({
763
- channel: "eng", name: "eng", mode: "single-threaded" as const, status,
764
- input, output, started_at: "2026-06-18T07:00:00.000Z", ended_at: ended, phase,
765
- });
766
-
767
- // Turn 1 — start (0) then end (1).
768
- await t.writeThread(tn("working", "one", "", "2026-06-18T07:00:00.000Z", "start"));
769
- expect(stored!.metadata.turn_count).toBe("0");
770
- await t.writeThread(tn("ok", "one", "reply one", "2026-06-18T07:00:05.000Z", "end"));
771
- expect(stored!.metadata.turn_count).toBe("1");
772
-
773
- // Turn 2 — start reads prior=1 → writes 1 (UNCHANGED, the no-double-count invariant),
774
- // then end increments to 2. The start working-ensure must NOT bump the count.
775
- await t.writeThread(tn("working", "two", "", "2026-06-18T08:00:00.000Z", "start"));
776
- expect(stored!.metadata.turn_count).toBe("1"); // start preserves the count.
777
- expect(stored!.metadata.status).toBe("working");
778
- expect(stored!.metadata.started_at).toBe("2026-06-18T07:00:00.000Z"); // first turn's, preserved.
779
- await t.writeThread(tn("ok", "two", "reply two", "2026-06-18T08:00:09.000Z", "end"));
780
- expect(stored!.metadata.turn_count).toBe("2"); // counted twice total — once per turn.
781
- expect(stored!.metadata.last_turn_at).toBe("2026-06-18T08:00:09.000Z");
782
- });
783
-
784
- test("MULTI-THREADED start writes turn_count 0 (working) at the per-fire path; end writes 1 at the SAME path", async () => {
785
- const patches: { path: string; metadata: Record<string, string>; content: string }[] = [];
786
- globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
787
- const u = String(url);
788
- if (u.includes("/api/notes/") && (init?.method ?? "GET") === "PATCH") {
789
- const body = JSON.parse(String(init?.body)) as {
790
- path: string; metadata: Record<string, string>; content: string;
791
- };
792
- patches.push({ path: decodeURIComponent(u), metadata: body.metadata, content: body.content });
793
- return new Response(JSON.stringify({ id: "x" }), { status: 200 });
794
- }
795
- return new Response("{}", { status: 200 });
796
- }) as typeof fetch;
797
- const t = new VaultTransport(baseConfig());
798
- await t.start(fakeCtx("eng"));
799
- const base = {
800
- channel: "eng", name: "d", mode: "multi-threaded" as const, input: "q",
801
- started_at: "2026-06-18T07:00:00.000Z", threadId: "fire-1",
802
- };
803
- // START — working, turn_count 0, the per-fire note created.
804
- await t.writeThread({ ...base, status: "working", output: "", ended_at: "2026-06-18T07:00:00.000Z", phase: "start" });
805
- // END — ok, turn_count 1, the SAME per-fire path (same threadId).
806
- await t.writeThread({ ...base, status: "ok", output: "a", ended_at: "2026-06-18T07:00:05.000Z", phase: "end" });
807
-
808
- expect(patches).toHaveLength(2);
809
- expect(patches[0]!.metadata.status).toBe("working");
810
- expect(patches[0]!.metadata.turn_count).toBe("0");
811
- expect(patches[1]!.metadata.status).toBe("ok");
812
- expect(patches[1]!.metadata.turn_count).toBe("1");
813
- // Both writes hit the SAME per-fire path (the reused threadId) — start updates, not dupes.
814
- expect(patches[0]!.path).toContain("/Threads/eng/fire-1");
815
- expect(patches[1]!.path).toContain("/Threads/eng/fire-1");
816
- // The working body shows no fake reply; the end body carries the real reply.
817
- expect(patches[0]!.content).not.toContain("**Reply:**");
818
- expect(patches[1]!.content).toContain("a");
819
- });
820
-
821
- // ── thread ≡ session (metadata.session — the unified record) ──────────────────────────
822
-
823
- test("writeThread() persists metadata.session when thread.session is set", async () => {
824
- const posts: { metadata: Record<string, string> }[] = [];
825
- globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
826
- const u = String(url);
827
- if (u.includes("/api/notes/") && (init?.method ?? "GET") === "PATCH") {
828
- posts.push({ metadata: (JSON.parse(String(init?.body)) as { metadata: Record<string, string> }).metadata });
829
- return new Response(JSON.stringify({ id: "x" }), { status: 200 });
830
- }
831
- // multi-threaded → no GET read-back; serve ensureSchema PUTs + anything else 200.
832
- return new Response("{}", { status: 200 });
833
- }) as typeof fetch;
834
- const t = new VaultTransport(baseConfig());
835
- await t.start(fakeCtx("eng"));
836
- await t.writeThread({
837
- channel: "eng",
838
- mode: "multi-threaded",
839
- status: "ok",
840
- input: "q",
841
- output: "a",
842
- started_at: "2026-06-18T07:00:00.000Z",
843
- ended_at: "2026-06-18T07:00:05.000Z",
844
- session: "11111111-1111-4111-8111-111111111111",
845
- });
846
- expect(posts).toHaveLength(1);
847
- expect(posts[0]!.metadata.session).toBe("11111111-1111-4111-8111-111111111111");
848
- });
849
-
850
- test("SINGLE-THREADED upsert PRESERVES a prior metadata.session when the new write carries none", async () => {
851
- // The start-phase working-ensure carries NO session; it must not drop the prior one.
852
- let stored: { metadata: Record<string, string>; content: string } | undefined;
853
- globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
854
- const u = String(url);
855
- const method = init?.method ?? "GET";
856
- if (u.includes("/api/notes/") && method === "GET") {
857
- if (!stored) return new Response("not found", { status: 404 });
858
- return new Response(JSON.stringify(stored), { status: 200 });
859
- }
860
- if (u.includes("/api/notes/") && method === "PATCH") {
861
- const body = JSON.parse(String(init?.body)) as { metadata: Record<string, string>; content: string };
862
- stored = { metadata: body.metadata, content: body.content };
863
- return new Response(JSON.stringify({ id: "thread-eng" }), { status: 200 });
864
- }
865
- return new Response("{}", { status: 200 });
866
- }) as typeof fetch;
867
- const t = new VaultTransport(baseConfig());
868
- await t.start(fakeCtx("eng"));
869
-
870
- // Turn 1 END establishes the session on the note.
871
- await t.writeThread({
872
- channel: "eng", name: "eng", mode: "single-threaded", status: "ok",
873
- input: "one", output: "reply one", started_at: "2026-06-18T07:00:00.000Z",
874
- ended_at: "2026-06-18T07:00:05.000Z", phase: "end",
875
- session: "sess-ESTABLISHED",
876
- });
877
- expect(stored!.metadata.session).toBe("sess-ESTABLISHED");
878
-
879
- // Turn 2 START-ENSURE carries NO session — the upsert must PRESERVE the prior one.
880
- await t.writeThread({
881
- channel: "eng", name: "eng", mode: "single-threaded", status: "working",
882
- input: "two", output: "", started_at: "2026-06-18T08:00:00.000Z",
883
- ended_at: "2026-06-18T08:00:00.000Z", phase: "start",
884
- });
885
- expect(stored!.metadata.session).toBe("sess-ESTABLISHED"); // preserved, not dropped.
886
- });
887
-
888
- test("readThreadSession() round-trips the stored session (the pre-turn resume read)", async () => {
889
- let stored: { metadata: Record<string, string>; content: string } | undefined;
890
- const gets: string[] = [];
891
- globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
892
- const u = String(url);
893
- const method = init?.method ?? "GET";
894
- if (u.includes("/api/notes/") && method === "GET") {
895
- gets.push(decodeURIComponent(u));
896
- if (!stored) return new Response("not found", { status: 404 });
897
- return new Response(JSON.stringify(stored), { status: 200 });
898
- }
899
- if (u.includes("/api/notes/") && method === "PATCH") {
900
- const body = JSON.parse(String(init?.body)) as { metadata: Record<string, string>; content: string };
901
- stored = { metadata: body.metadata, content: body.content };
902
- return new Response(JSON.stringify({ id: "thread-eng" }), { status: 200 });
903
- }
904
- return new Response("{}", { status: 200 });
905
- }) as typeof fetch;
906
- const t = new VaultTransport(baseConfig());
907
- await t.start(fakeCtx("eng"));
908
-
909
- // Before any turn: no note → undefined (the first-turn create path).
910
- expect(await t.readThreadSession("eng", "eng")).toBeUndefined();
911
-
912
- // Write a thread note carrying a session…
913
- await t.writeThread({
914
- channel: "eng", name: "eng", mode: "single-threaded", status: "ok",
915
- input: "x", output: "y", started_at: "2026-06-18T07:00:00.000Z",
916
- ended_at: "2026-06-18T07:00:05.000Z", phase: "end",
917
- session: "sess-ROUNDTRIP",
918
- });
919
-
920
- // …readThreadSession reads it back off the DETERMINISTIC single-threaded path.
921
- expect(await t.readThreadSession("eng", "eng")).toBe("sess-ROUNDTRIP");
922
- expect(gets.some((g) => g.includes("/api/notes/Threads/eng/eng"))).toBe(true);
923
- });
924
-
925
- test("clearThreadSession() wipes the session (PATCH session:\"\", force) → readThreadSession undefined (the per-agent reset)", async () => {
926
- // The vault: a stateful note whose metadata is replaced by each PATCH (mirrors the real
927
- // PATCH-merge for the fields we send). readThreadSession's truthy guard treats "" as none.
928
- let stored: { metadata: Record<string, string>; content: string } | undefined;
929
- const patches: { metadata: Record<string, unknown>; force?: boolean }[] = [];
930
- globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
931
- const u = String(url);
932
- const method = init?.method ?? "GET";
933
- if (u.includes("/api/notes/") && method === "GET") {
934
- if (!stored) return new Response("not found", { status: 404 });
935
- return new Response(JSON.stringify(stored), { status: 200 });
936
- }
937
- if (u.includes("/api/notes/") && method === "PATCH") {
938
- const body = JSON.parse(String(init?.body)) as {
939
- metadata: Record<string, string>;
940
- content?: string;
941
- force?: boolean;
942
- };
943
- patches.push({ metadata: body.metadata, force: body.force });
944
- // Merge the PATCHed metadata over the prior (the vault upserts field-by-field).
945
- stored = { metadata: { ...(stored?.metadata ?? {}), ...body.metadata }, content: body.content ?? stored?.content ?? "" };
946
- return new Response(JSON.stringify({ id: "thread-eng" }), { status: 200 });
947
- }
948
- return new Response("{}", { status: 200 });
949
- }) as typeof fetch;
950
- const t = new VaultTransport(baseConfig());
951
- await t.start(fakeCtx("eng"));
952
-
953
- // Establish a session, then RESET it.
954
- await t.writeThread({
955
- channel: "eng", name: "eng", mode: "single-threaded", status: "ok",
956
- input: "x", output: "y", started_at: "2026-06-18T07:00:00.000Z",
957
- ended_at: "2026-06-18T07:00:05.000Z", phase: "end", session: "sess-TO-CLEAR",
958
- });
959
- expect(await t.readThreadSession("eng", "eng")).toBe("sess-TO-CLEAR");
960
-
961
- await t.clearThreadSession("eng", "eng");
962
- // The clear PATCH wrote session:"" with force (the vault mutation precondition).
963
- const clearPatch = patches[patches.length - 1]!;
964
- expect(clearPatch.metadata.session).toBe("");
965
- expect(clearPatch.force).toBe(true);
966
- // …and readThreadSession now reports NO session (the "" guard) → next turn starts fresh.
967
- expect(await t.readThreadSession("eng", "eng")).toBeUndefined();
968
- });
969
-
970
- test("clearThreadSession() is a no-op when no thread note exists yet (404)", async () => {
971
- let patched = false;
972
- globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
973
- if (String(url).includes("/api/notes/") && (init?.method ?? "GET") === "PATCH") {
974
- patched = true;
975
- return new Response("not found", { status: 404 });
976
- }
977
- return new Response("{}", { status: 200 });
978
- }) as typeof fetch;
979
- const t = new VaultTransport(baseConfig());
980
- await t.start(fakeCtx("eng"));
981
- // Must NOT throw on a 404 (no thread yet = already fresh).
982
- await t.clearThreadSession("eng", "eng");
983
- expect(patched).toBe(true); // it tried (and tolerated the 404).
984
- });
985
-
986
- test("clearThreadSession() throws on a non-ok, non-404 vault response", async () => {
987
- globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
988
- if (String(url).includes("/api/notes/") && (init?.method ?? "GET") === "PATCH") {
989
- return new Response("boom", { status: 500 });
990
- }
991
- return new Response("{}", { status: 200 });
992
- }) as typeof fetch;
993
- const t = new VaultTransport(baseConfig());
994
- await t.start(fakeCtx("eng"));
995
- await expect(t.clearThreadSession("eng", "eng")).rejects.toThrow(/clear thread session failed/);
996
- });
997
- });
998
-
999
- describe("VaultTransport — loadTranscript (read the durable store)", () => {
1000
- test("queries by tag only (NO operator metadata filter), filters this channel client-side, sorts ascending by ts", async () => {
1001
- const getUrls: string[] = [];
1002
- let capturedAuth = "";
1003
- globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
1004
- const u = String(url);
1005
- // Ignore the ensureSchema PUTs fired by start(); only the GET /api/notes is the transcript read.
1006
- if (u.includes("/api/notes") && (init?.method ?? "GET") === "GET") {
1007
- getUrls.push(u);
1008
- capturedAuth = (init?.headers as Record<string, string> | undefined)?.authorization ?? "";
1009
- // CONTRACT: a SINGLE `#agent/message` query (no interim/legacy union).
1010
- if (u.includes("tag=agent%2Fmessage")) {
1011
- // Return notes OUT of ts order (prove the ascending sort) + a note from a
1012
- // DIFFERENT channel (prove the client-side channel filter excludes it).
1013
- return new Response(
1014
- JSON.stringify([
1015
- {
1016
- id: "n-out",
1017
- content: "session reply",
1018
- tags: ["agent/message", "agent/message/outbound"],
1019
- metadata: { agent: "eng", direction: "outbound", sender: "session", ts: "2026-06-08T00:00:02Z", in_reply_to: "n-in" },
1020
- },
1021
- {
1022
- id: "n-other",
1023
- content: "different channel — must be excluded",
1024
- tags: ["agent/message", "agent/message/inbound"],
1025
- metadata: { agent: "other", direction: "inbound", sender: "x", ts: "2026-06-08T00:00:03Z" },
1026
- },
1027
- {
1028
- id: "n-in",
1029
- content: "hi session",
1030
- tags: ["agent/message", "agent/message/inbound"],
1031
- metadata: { agent: "eng", direction: "inbound", sender: "aaron", ts: "2026-06-08T00:00:01Z" },
1032
- },
1033
- ]),
1034
- { status: 200, headers: { "content-type": "application/json" } },
1035
- );
1036
- }
1037
- return new Response("[]", { status: 200, headers: { "content-type": "application/json" } });
1038
- }
1039
- // ensureSchema PUTs
1040
- return new Response("{}", { status: 200 });
1041
- }) as typeof fetch;
1042
-
1043
- const t = new VaultTransport(baseConfig());
1044
- await t.start(fakeCtx("eng"));
1045
- const msgs = await t.loadTranscript();
1046
-
1047
- // CONTRACT: exactly ONE `#agent/message` query — the interim/legacy union is gone.
1048
- // It carries the encoded parent tag + include_content, and DELIBERATELY no
1049
- // `metadata=` operator filter (the routing-key field isn't indexed on a bare
1050
- // vault; we filter client-side). Overfetches the tag so other channels don't
1051
- // crowd us out.
1052
- const agentGets = getUrls.filter((u) => u.includes("tag=agent%2Fmessage"));
1053
- expect(agentGets).toHaveLength(1);
1054
- // No interim/legacy queries are issued.
1055
- expect(getUrls.some((u) => u.includes("tag=%23agent-message"))).toBe(false);
1056
- expect(getUrls.some((u) => u.includes("tag=%23channel-message"))).toBe(false);
1057
- const agentGet = agentGets[0]!;
1058
- expect(agentGet.startsWith("http://127.0.0.1:1940/vault/default/api/notes?")).toBe(true);
1059
- expect(agentGet).toContain("include_content=true");
1060
- expect(agentGet).not.toContain("metadata=");
1061
- expect(capturedAuth).toBe("Bearer write-token-xyz");
1062
-
1063
- // The "other" channel note is filtered OUT; the two "eng" notes remain, sorted
1064
- // ascending by ts (n-in before n-out).
1065
- expect(msgs).toHaveLength(2);
1066
- expect(msgs[0]!.id).toBe("n-in");
1067
- expect(msgs[0]!.direction).toBe("inbound");
1068
- expect(msgs[0]!.text).toBe("hi session");
1069
- expect(msgs[0]!.sender).toBe("aaron");
1070
- expect(msgs[1]!.id).toBe("n-out");
1071
- expect(msgs[1]!.direction).toBe("outbound");
1072
- expect(msgs[1]!.inReplyTo).toBe("n-in");
1073
- });
1074
-
1075
- test("caps the returned transcript to the requested limit (most-recent by ts)", async () => {
1076
- globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
1077
- const u = String(url);
1078
- if (u.includes("/api/notes") && (init?.method ?? "GET") === "GET") {
1079
- if (u.includes("tag=agent%2Fmessage")) {
1080
- return new Response(
1081
- JSON.stringify([1, 2, 3, 4].map((i) => ({
1082
- id: "n" + i,
1083
- content: "m" + i,
1084
- tags: ["agent/message", "agent/message/inbound"],
1085
- metadata: { agent: "eng", direction: "inbound", sender: "aaron", ts: "2026-06-08T00:00:0" + i + "Z" },
1086
- }))),
1087
- { status: 200 },
1088
- );
1089
- }
1090
- return new Response("[]", { status: 200 });
1091
- }
1092
- return new Response("{}", { status: 200 });
1093
- }) as typeof fetch;
1094
- const t = new VaultTransport(baseConfig());
1095
- await t.start(fakeCtx("eng"));
1096
- const msgs = await t.loadTranscript({ limit: 2 });
1097
- // 4 notes fetched → the 2 most recent (by ts) returned, ascending.
1098
- expect(msgs.map((m) => m.id)).toEqual(["n3", "n4"]);
1099
- });
1100
-
1101
- test("falls back to the outbound child tag for direction when metadata.direction is absent", async () => {
1102
- globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
1103
- const u = String(url);
1104
- if (u.includes("/api/notes") && (init?.method ?? "GET") === "GET") {
1105
- if (u.includes("tag=agent%2Fmessage")) {
1106
- return new Response(
1107
- JSON.stringify([
1108
- // Outbound child → direction inferred "outbound".
1109
- { id: "a", content: "x", tags: ["agent/message", "agent/message/outbound"], metadata: { agent: "eng", ts: "2026-06-08T00:00:01Z" } },
1110
- // No direction signal at all → defaults to "inbound".
1111
- { id: "b", content: "y", tags: ["agent/message", "agent/message/inbound"], metadata: { agent: "eng", ts: "2026-06-08T00:00:02Z" } },
1112
- ]),
1113
- { status: 200 },
1114
- );
1115
- }
1116
- return new Response("[]", { status: 200 });
1117
- }
1118
- return new Response("{}", { status: 200 });
1119
- }) as typeof fetch;
1120
- const t = new VaultTransport(baseConfig());
1121
- await t.start(fakeCtx("eng"));
1122
- const msgs = await t.loadTranscript();
1123
- expect(msgs.find((m) => m.id === "a")!.direction).toBe("outbound");
1124
- expect(msgs.find((m) => m.id === "b")!.direction).toBe("inbound");
1125
- });
1126
-
1127
- test("throws a clear error on a non-ok vault response", async () => {
1128
- globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
1129
- const u = String(url);
1130
- if (u.includes("/api/notes") && (init?.method ?? "GET") === "GET") {
1131
- return new Response("nope", { status: 502 });
1132
- }
1133
- return new Response("{}", { status: 200 });
1134
- }) as typeof fetch;
1135
- const t = new VaultTransport(baseConfig());
1136
- await t.start(fakeCtx("eng"));
1137
- await expect(t.loadTranscript()).rejects.toThrow(/load transcript failed/);
1138
- });
1139
- });
1140
-
1141
- describe("VaultTransport — writeInbound (the chat's send → wakes the session)", () => {
1142
- test("POSTs an INBOUND note tagged [#agent/message, #agent/message/inbound] with direction + channel + sender + Bearer", async () => {
1143
- const calls: { url: string; init: RequestInit }[] = [];
1144
- globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
1145
- calls.push({ url: String(url), init: init ?? {} });
1146
- return new Response(JSON.stringify({ id: "inbound-note-1" }), {
1147
- status: 201,
1148
- headers: { "content-type": "application/json" },
1149
- });
1150
- }) as typeof fetch;
1151
-
1152
- const t = new VaultTransport(baseConfig());
1153
- await t.start(fakeCtx("eng"));
1154
- const result = await t.writeInbound("wake up", "operator");
1155
-
1156
- expect(result.id).toBe("inbound-note-1");
1157
- const noteCalls = calls.filter((c) => c.url.endsWith("/api/notes") && c.init.method === "POST");
1158
- expect(noteCalls).toHaveLength(1);
1159
- const call = noteCalls[0]!;
1160
- expect(call.url).toBe("http://127.0.0.1:1940/vault/default/api/notes");
1161
- expect((call.init.headers as Record<string, string>).authorization).toBe("Bearer write-token-xyz");
1162
-
1163
- const sent = JSON.parse(String(call.init.body)) as {
1164
- content: string;
1165
- path: string;
1166
- tags: string[];
1167
- metadata: Record<string, string>;
1168
- };
1169
- expect(sent.content).toBe("wake up");
1170
- // The INBOUND tag pair — the child is the trigger discriminator that wakes the session.
1171
- expect(sent.tags).toEqual(["agent/message", "agent/message/inbound"]);
1172
- expect(sent.tags).toContain("agent/message");
1173
- expect(sent.tags).toContain("agent/message/inbound");
1174
- // It must NOT carry the outbound tag (that would be a reply, never wake).
1175
- expect(sent.tags).not.toContain("agent/message/outbound");
1176
- // Write-discipline: the legacy tag family is gone (CONTRACT dropped it).
1177
- expect(sent.tags).not.toContain("#channel-message");
1178
- // CONTRACT: the routing key under `metadata.agent` ONLY — no `channel`. The vault
1179
- // trigger keys on `has_metadata:["agent"]` to fire on this inbound note.
1180
- expect(sent.metadata.agent).toBe("eng");
1181
- expect(sent.metadata.channel).toBeUndefined();
1182
- expect(sent.metadata.direction).toBe("inbound");
1183
- expect(sent.metadata.sender).toBe("operator");
1184
- expect(typeof sent.metadata.ts).toBe("string");
1185
- // Note PATH prefix is DOMAIN (`channel/<name>/`) — unchanged.
1186
- expect(sent.path.startsWith("channel/eng/")).toBe(true);
1187
- });
1188
-
1189
- test("defaults sender to 'operator' when omitted", async () => {
1190
- let captured: Record<string, string> | undefined;
1191
- globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
1192
- if (String(url).endsWith("/api/notes") && init?.method === "POST") {
1193
- captured = (JSON.parse(String(init?.body)) as { metadata: Record<string, string> }).metadata;
1194
- }
1195
- return new Response(JSON.stringify({ id: "n" }), { status: 201 });
1196
- }) as typeof fetch;
1197
- const t = new VaultTransport(baseConfig());
1198
- await t.start(fakeCtx("eng"));
1199
- await t.writeInbound("hi");
1200
- expect(captured!.sender).toBe("operator");
1201
- });
1202
-
1203
- test("does NOT emit (no double-wake) — the trigger is the single wake path", async () => {
1204
- globalThis.fetch = (async () =>
1205
- new Response(JSON.stringify({ id: "n" }), { status: 201 })) as unknown as typeof fetch;
1206
- const t = new VaultTransport(baseConfig());
1207
- const ctx = fakeCtx("eng");
1208
- await t.start(ctx);
1209
- await t.writeInbound("hi");
1210
- // writeInbound must never ctx.emit — the vault trigger wakes the session.
1211
- expect(ctx.emitted).toHaveLength(0);
1212
- });
1213
-
1214
- test("throws a clear error on a non-ok vault response", async () => {
1215
- globalThis.fetch = (async () =>
1216
- new Response("boom", { status: 500 })) as unknown as typeof fetch;
1217
- const t = new VaultTransport(baseConfig());
1218
- await t.start(fakeCtx("eng"));
1219
- await expect(t.writeInbound("x")).rejects.toThrow(/write inbound failed/);
1220
- });
1221
- });
1222
-
1223
- describe("VaultTransport — writeCallback (agent-to-agent reply_to substrate)", () => {
1224
- test("writes an INBOUND note carrying the callback metadata contract, NO reply_to, both inbound tags", async () => {
1225
- let sent: { content: string; tags: string[]; metadata: Record<string, string> } | undefined;
1226
- globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
1227
- if (String(url).endsWith("/api/notes") && init?.method === "POST") {
1228
- sent = JSON.parse(String(init?.body));
1229
- }
1230
- return new Response(JSON.stringify({ id: "callback-note-1" }), { status: 201 });
1231
- }) as typeof fetch;
1232
-
1233
- const t = new VaultTransport(baseConfig());
1234
- await t.start(fakeCtx("orchestrator")); // the SENDER's channel.
1235
- const result = await t.writeCallback("[callback] worker finished (ok) — see source_message.", {
1236
- callback: "true",
1237
- status: "ok",
1238
- source_channel: "worker",
1239
- source_thread: "thread-uuid-1",
1240
- source_message: "reply-note-7",
1241
- correlation_id: "corr-abc",
1242
- delegation_depth: "3",
1243
- });
1244
-
1245
- expect(result.sent).toEqual(["callback-note-1"]);
1246
- // The callback is an INBOUND note (so it wakes the sender via the normal vault trigger).
1247
- expect(sent!.tags).toEqual(["agent/message", "agent/message/inbound"]);
1248
- expect(sent!.tags).not.toContain("agent/message/outbound");
1249
- // The metadata contract — all present fields stamped.
1250
- expect(sent!.metadata.callback).toBe("true");
1251
- expect(sent!.metadata.status).toBe("ok");
1252
- expect(sent!.metadata.source_channel).toBe("worker");
1253
- expect(sent!.metadata.source_thread).toBe("thread-uuid-1");
1254
- expect(sent!.metadata.source_message).toBe("reply-note-7");
1255
- expect(sent!.metadata.correlation_id).toBe("corr-abc");
1256
- expect(sent!.metadata.delegation_depth).toBe("3");
1257
- // The channel it's routed to is THIS transport's channel (the sender's), direction inbound.
1258
- // CONTRACT: routing key under `metadata.agent` ONLY — no `channel`.
1259
- expect(sent!.metadata.agent).toBe("orchestrator");
1260
- expect(sent!.metadata.channel).toBeUndefined();
1261
- expect(sent!.metadata.direction).toBe("inbound");
1262
- expect(sent!.metadata.sender).toBe("callback:worker");
1263
- // LOOP GUARD: the callback note must NEVER carry a reply_to (terminal callback).
1264
- expect(sent!.metadata.reply_to).toBeUndefined();
1265
- });
1266
-
1267
- test("omits source_message + correlation_id when absent (error callback, no reply)", async () => {
1268
- let sent: { metadata: Record<string, string> } | undefined;
1269
- globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
1270
- if (String(url).endsWith("/api/notes") && init?.method === "POST") sent = JSON.parse(String(init?.body));
1271
- return new Response(JSON.stringify({ id: "n" }), { status: 201 });
1272
- }) as typeof fetch;
1273
- const t = new VaultTransport(baseConfig());
1274
- await t.start(fakeCtx("orchestrator"));
1275
- await t.writeCallback("[callback] worker finished with an error.", {
1276
- callback: "true",
1277
- status: "error",
1278
- source_channel: "worker",
1279
- source_thread: "thread-2",
1280
- delegation_depth: "1",
1281
- });
1282
- expect(sent!.metadata.status).toBe("error");
1283
- expect(sent!.metadata.source_message).toBeUndefined();
1284
- expect(sent!.metadata.correlation_id).toBeUndefined();
1285
- });
1286
-
1287
- test("a stray reply_to on the meta is STRIPPED (defense-in-depth loop guard)", async () => {
1288
- let sent: { metadata: Record<string, string> } | undefined;
1289
- globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
1290
- if (String(url).endsWith("/api/notes") && init?.method === "POST") sent = JSON.parse(String(init?.body));
1291
- return new Response(JSON.stringify({ id: "n" }), { status: 201 });
1292
- }) as typeof fetch;
1293
- const t = new VaultTransport(baseConfig());
1294
- await t.start(fakeCtx("orchestrator"));
1295
- // Simulate a (mistaken) caller widening the shape with a reply_to — it must NOT survive.
1296
- await t.writeCallback("x", {
1297
- callback: "true",
1298
- status: "ok",
1299
- source_channel: "worker",
1300
- source_thread: "t",
1301
- delegation_depth: "1",
1302
- // @ts-expect-error — intentionally passing an extra field the contract forbids.
1303
- reply_to: "should-be-stripped",
1304
- });
1305
- expect(sent!.metadata.reply_to).toBeUndefined();
1306
- });
1307
- });
1308
-
1309
- describe("VaultTransport — ingestInbound", () => {
1310
- test("emits the inbound content + meta onto its channel", () => {
1311
- const t = new VaultTransport(baseConfig());
1312
- const ctx = fakeCtx("eng");
1313
- // start synchronously enough for the test (start just stores ctx).
1314
- void t.start(ctx);
1315
- void t.ingestInbound({
1316
- id: "note-in-1",
1317
- content: "hello session",
1318
- tags: ["agent/message", "agent/message/inbound"],
1319
- metadata: { agent: "eng", direction: "inbound", sender: "aaron", ts: "2026-06-08T00:00:00Z" },
1320
- });
1321
- expect(ctx.emitted).toHaveLength(1);
1322
- const m = ctx.emitted[0]!;
1323
- expect(m.channel).toBe("eng");
1324
- expect(m.content).toBe("hello session");
1325
- expect(m.source).toBe("vault");
1326
- expect(m.meta.source).toBe("vault");
1327
- expect(m.meta.note_id).toBe("note-in-1");
1328
- expect(m.meta.sender).toBe("aaron");
1329
- expect(m.meta.direction).toBe("inbound");
1330
- // CONTRACT: the routing key on the in-memory event meta is stamped under `agent`
1331
- // ONLY (the `channel` dual-write is dropped). The top-level InboundMessage.channel
1332
- // TS field stays the channel name.
1333
- expect(m.meta.agent).toBe("eng");
1334
- expect(m.meta.channel).toBeUndefined();
1335
- });
1336
-
1337
- test("IGNORES a #agent/message/outbound-tagged note (loop avoidance)", () => {
1338
- const t = new VaultTransport(baseConfig());
1339
- const ctx = fakeCtx("eng");
1340
- void t.start(ctx);
1341
- void t.ingestInbound({
1342
- id: "our-own-reply",
1343
- content: "I am awake",
1344
- tags: ["agent/message", "agent/message/outbound"],
1345
- metadata: { channel: "eng", direction: "outbound", sender: "session" },
1346
- });
1347
- expect(ctx.emitted).toHaveLength(0);
1348
- });
1349
-
1350
- test("IGNORES a note with direction:outbound even if the outbound tag is absent", () => {
1351
- const t = new VaultTransport(baseConfig());
1352
- const ctx = fakeCtx("eng");
1353
- void t.start(ctx);
1354
- void t.ingestInbound({
1355
- id: "x",
1356
- content: "y",
1357
- metadata: { channel: "eng", direction: "outbound" },
1358
- });
1359
- expect(ctx.emitted).toHaveLength(0);
1360
- });
1361
-
1362
- test("SURFACES attachments on the emitted InboundMessage when the note carries them (Phase 1)", async () => {
1363
- // The webhook payload carries `note.attachments` inline (the has-attachments signal);
1364
- // ingestInbound then fetches the authoritative attachment list (REST) and surfaces the
1365
- // refs on the emitted message so the programmatic backend can stage them.
1366
- const calls: string[] = [];
1367
- globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
1368
- calls.push(String(url));
1369
- // The attachment-list endpoint → a bare Attachment[] array (vault REST shape).
1370
- return new Response(
1371
- JSON.stringify([
1372
- { id: "a1", noteId: "note-att-1", path: "2026-06-24/pic.png", mimeType: "image/png", createdAt: "x" },
1373
- { id: "a2", noteId: "note-att-1", path: "2026-06-24/doc.pdf", mimeType: "application/pdf", createdAt: "x" },
1374
- ]),
1375
- { status: 200, headers: { "content-type": "application/json" } },
1376
- );
1377
- }) as typeof fetch;
1378
-
1379
- const t = new VaultTransport(baseConfig());
1380
- const ctx = fakeCtx("eng");
1381
- void t.start(ctx);
1382
- await t.ingestInbound({
1383
- id: "note-att-1",
1384
- content: "look at these",
1385
- tags: ["agent/message", "agent/message/inbound"],
1386
- metadata: { agent: "eng", direction: "inbound", sender: "aaron" },
1387
- // inline list from the trigger payload — the has-attachments SIGNAL.
1388
- attachments: [{ id: "a1", path: "2026-06-24/pic.png", mimeType: "image/png" }],
1389
- });
1390
-
1391
- // It fetched the attachment-list endpoint with the channel's vault token.
1392
- expect(calls.some((u) => u.endsWith("/vault/default/api/notes/note-att-1/attachments"))).toBe(true);
1393
-
1394
- expect(ctx.emitted).toHaveLength(1);
1395
- const m = ctx.emitted[0]!;
1396
- expect(m.content).toBe("look at these");
1397
- expect(m.attachments).toBeDefined();
1398
- expect(m.attachments).toHaveLength(2);
1399
- expect(m.attachments![0]).toEqual({ path: "2026-06-24/pic.png", mimeType: "image/png", filename: "pic.png" });
1400
- expect(m.attachments![1]).toEqual({ path: "2026-06-24/doc.pdf", mimeType: "application/pdf", filename: "doc.pdf" });
1401
- });
1402
-
1403
- test("attachment-list fetch FAILURE is best-effort — the message is still emitted with text, no attachments", async () => {
1404
- globalThis.fetch = (async () => new Response("boom", { status: 500 })) as unknown as typeof fetch;
1405
- const t = new VaultTransport(baseConfig());
1406
- const ctx = fakeCtx("eng");
1407
- void t.start(ctx);
1408
- await t.ingestInbound({
1409
- id: "note-att-fail",
1410
- content: "still delivered",
1411
- tags: ["agent/message", "agent/message/inbound"],
1412
- metadata: { agent: "eng", direction: "inbound" },
1413
- attachments: [{ id: "a1", path: "2026-06-24/pic.png", mimeType: "image/png" }],
1414
- });
1415
- expect(ctx.emitted).toHaveLength(1);
1416
- expect(ctx.emitted[0]!.content).toBe("still delivered");
1417
- expect(ctx.emitted[0]!.attachments).toBeUndefined();
1418
- });
1419
-
1420
- test("NO inline attachments → NO fetch, emits synchronously (today's behavior)", () => {
1421
- // Any fetch here would throw — proving the no-attachment path never reaches out.
1422
- globalThis.fetch = (async () => {
1423
- throw new Error("must not fetch");
1424
- }) as unknown as typeof fetch;
1425
- const t = new VaultTransport(baseConfig());
1426
- const ctx = fakeCtx("eng");
1427
- void t.start(ctx);
1428
- // Not awaited — emit must be synchronous (before any await) when there are no attachments.
1429
- void t.ingestInbound({
1430
- id: "note-plain",
1431
- content: "no files",
1432
- tags: ["agent/message", "agent/message/inbound"],
1433
- metadata: { agent: "eng", direction: "inbound" },
1434
- });
1435
- expect(ctx.emitted).toHaveLength(1);
1436
- expect(ctx.emitted[0]!.attachments).toBeUndefined();
1437
- });
1438
-
1439
- test("FLATTENS the agent-to-agent callback fields (reply_to/correlation_id/delegation_depth) into meta", () => {
1440
- // The READ side of the callback round-trip: a SENDING agent stamps reply_to et al on the
1441
- // inbound note's metadata; ingestInbound must surface them in `meta` so contextFor.emit's
1442
- // callbackFieldsFromMeta can pick them up. (ingestInbound already flattens ALL metadata —
1443
- // this pins the behavior the callback substrate depends on.)
1444
- const t = new VaultTransport(baseConfig());
1445
- const ctx = fakeCtx("worker");
1446
- void t.start(ctx);
1447
- void t.ingestInbound({
1448
- id: "note-deleg-1",
1449
- content: "do the sub-task",
1450
- tags: ["agent/message", "agent/message/inbound"],
1451
- metadata: {
1452
- channel: "worker",
1453
- direction: "inbound",
1454
- sender: "orchestrator",
1455
- reply_to: "orchestrator",
1456
- correlation_id: "corr-1",
1457
- delegation_depth: "2",
1458
- },
1459
- });
1460
- expect(ctx.emitted).toHaveLength(1);
1461
- const m = ctx.emitted[0]!.meta;
1462
- expect(m.reply_to).toBe("orchestrator");
1463
- expect(m.correlation_id).toBe("corr-1");
1464
- expect(m.delegation_depth).toBe("2"); // string-valued, as the vault stores it.
1465
- });
1466
- });
1467
-
1468
- describe("VaultTransport — ensureSchema (tag-schema declaration on connect)", () => {
1469
- /** Drain microtasks so a fire-and-forget `void this.ensureSchema()` from
1470
- * start() has issued its fetches before we assert. */
1471
- const flush = () => new Promise<void>((r) => setTimeout(r, 0));
1472
-
1473
- test("PUTs each AGENT_VAULT_TAG_SCHEMA entry with the right URL encoding, Bearer, and body", async () => {
1474
- const calls: { url: string; init: RequestInit }[] = [];
1475
- globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
1476
- calls.push({ url: String(url), init: init ?? {} });
1477
- return new Response("{}", { status: 200, headers: { "content-type": "application/json" } });
1478
- }) as typeof fetch;
1479
-
1480
- const t = new VaultTransport(baseConfig());
1481
- await t.ensureSchema();
1482
-
1483
- expect(calls).toHaveLength(AGENT_VAULT_TAG_SCHEMA.length);
1484
-
1485
- // Namespace ROOT `agent` — no parent_names, just a description. A single bare
1486
- // segment needs no percent-encoding.
1487
- const root = calls[0]!;
1488
- expect(root.url).toBe(
1489
- "http://127.0.0.1:1940/vault/default/api/tags/agent",
1490
- );
1491
- expect(root.init.method).toBe("PUT");
1492
- expect((root.init.headers as Record<string, string>).authorization).toBe(
1493
- "Bearer write-token-xyz",
1494
- );
1495
- const rootBody = JSON.parse(String(root.init.body)) as {
1496
- description?: string;
1497
- parent_names?: string[];
1498
- };
1499
- expect("parent_names" in rootBody).toBe(false);
1500
-
1501
- // Definition (NEW) — name carries `/`; rolls up to the namespace root.
1502
- const def = calls[1]!;
1503
- expect(def.url).toBe(
1504
- "http://127.0.0.1:1940/vault/default/api/tags/agent%2Fdefinition",
1505
- );
1506
- expect(decodeURIComponent(def.url.split("/api/tags/")[1]!)).toBe("agent/definition");
1507
- const defBody = JSON.parse(String(def.init.body)) as { parent_names?: string[] };
1508
- expect(defBody.parent_names).toEqual(["agent"]);
1509
-
1510
- // Message parent (NEW) — rolls up to the namespace root.
1511
- const parent = calls[2]!;
1512
- expect(parent.url).toBe(
1513
- "http://127.0.0.1:1940/vault/default/api/tags/agent%2Fmessage",
1514
- );
1515
- const parentBody = JSON.parse(String(parent.init.body)) as {
1516
- description?: string;
1517
- parent_names?: string[];
1518
- };
1519
- expect(parentBody.description).toBe(
1520
- "A message in a Parachute channel (parent of /inbound + /outbound).",
1521
- );
1522
- expect(parentBody.parent_names).toEqual(["agent"]);
1523
-
1524
- // Inbound child (NEW) — name carries `/`. The vault route matches a
1525
- // single path segment (`[^/]+`) then decodeURIComponent's it, so the `/` MUST
1526
- // be encoded as `%2F` (a bare slash would fail the single-segment match → 404).
1527
- const inbound = calls[3]!;
1528
- expect(inbound.url).toBe(
1529
- "http://127.0.0.1:1940/vault/default/api/tags/agent%2Fmessage%2Finbound",
1530
- );
1531
- // Confirm the encoding decodes back to the literal tag name the vault stores.
1532
- const encodedSegment = inbound.url.split("/api/tags/")[1]!;
1533
- expect(decodeURIComponent(encodedSegment)).toBe("agent/message/inbound");
1534
- const inboundBody = JSON.parse(String(inbound.init.body)) as {
1535
- description?: string;
1536
- parent_names?: string[];
1537
- };
1538
- expect(inboundBody.parent_names).toEqual(["agent/message"]);
1539
- expect(inboundBody.description).toBe(
1540
- "Human→session message; the vault trigger fires on this.",
1541
- );
1542
-
1543
- // Outbound child (NEW) — same encoding, parent declared.
1544
- const outbound = calls[4]!;
1545
- expect(outbound.url).toBe(
1546
- "http://127.0.0.1:1940/vault/default/api/tags/agent%2Fmessage%2Foutbound",
1547
- );
1548
- expect(decodeURIComponent(outbound.url.split("/api/tags/")[1]!)).toBe(
1549
- "agent/message/outbound",
1550
- );
1551
- const outboundBody = JSON.parse(String(outbound.init.body)) as { parent_names?: string[] };
1552
- expect(outboundBody.parent_names).toEqual(["agent/message"]);
1553
-
1554
- // Job (NEW) — rolls up to the namespace root.
1555
- const job = calls[5]!;
1556
- expect(decodeURIComponent(job.url.split("/api/tags/")[1]!)).toBe("agent/job");
1557
- const jobBody = JSON.parse(String(job.init.body)) as { parent_names?: string[] };
1558
- expect(jobBody.parent_names).toEqual(["agent"]);
1559
- });
1560
-
1561
- test("schema declares ONLY the #agent/* namespace rollup (CONTRACT dropped interim + legacy, 7 entries)", async () => {
1562
- // The `#agent/*` namespace (design 2026-06-17-vault-native-agents) rolls up
1563
- // definitions, messages, jobs, AND threads to the `#agent` root. The channel→agent
1564
- // CONTRACT dropped the interim flat `#agent-message*` AND legacy `#channel-message*`
1565
- // schema entries — exactly 7 entries, all under `#agent/*`.
1566
- const names = AGENT_VAULT_TAG_SCHEMA.map((e) => e.name);
1567
- expect(names).toEqual([
1568
- "agent",
1569
- "agent/definition",
1570
- "agent/message",
1571
- "agent/message/inbound",
1572
- "agent/message/outbound",
1573
- "agent/job",
1574
- "agent/thread",
1575
- ]);
1576
- // The interim/legacy families are gone entirely.
1577
- expect(names).not.toContain("#agent-message");
1578
- expect(names).not.toContain("#channel-message");
1579
- // The namespace children all roll up to the `#agent` root (the human rollup).
1580
- const byName = (n: string) => AGENT_VAULT_TAG_SCHEMA.find((e) => e.name === n)!;
1581
- expect(byName("agent/definition").parent_names).toEqual(["agent"]);
1582
- expect(byName("agent/message").parent_names).toEqual(["agent"]);
1583
- expect(byName("agent/job").parent_names).toEqual(["agent"]);
1584
- expect(byName("agent/thread").parent_names).toEqual(["agent"]);
1585
- expect(byName("agent/message/inbound").parent_names).toEqual(["agent/message"]);
1586
- expect(byName("agent/message/outbound").parent_names).toEqual(["agent/message"]);
1587
- // `#agent/thread` declares INDEXED string fields so threads are operator-queryable —
1588
- // "all failed threads" (status), "all threads of agent X" (definition), "all
1589
- // multi-threaded threads" (mode). The three axes carry over from the run record VERBATIM.
1590
- expect(byName("agent/thread").fields).toEqual({
1591
- // The canonical `agent` routing-key alias is declared indexed.
1592
- agent: { type: "string", indexed: true },
1593
- status: { type: "string", indexed: true },
1594
- definition: { type: "string", indexed: true },
1595
- mode: { type: "string", indexed: true },
1596
- });
1597
- // `#agent/message` declares the indexed `agent` routing key.
1598
- expect(byName("agent/message").fields).toEqual({
1599
- agent: { type: "string", indexed: true },
1600
- });
1601
- // CONTRACT: `#agent/job` indexes the routing key under `agent` ONLY — no `channel`.
1602
- expect(byName("agent/job").fields).toEqual({
1603
- agent: { type: "string", indexed: true },
1604
- enabled: { type: "string", indexed: true },
1605
- lastStatus: { type: "string", indexed: true },
1606
- });
1607
- });
1608
-
1609
- test("schema is sourced from AGENT_VAULT_TAG_SCHEMA — declares exactly its entries", async () => {
1610
- const declared: string[] = [];
1611
- globalThis.fetch = (async (url: string | URL | Request) => {
1612
- declared.push(decodeURIComponent(String(url).split("/api/tags/")[1]!));
1613
- return new Response("{}", { status: 200 });
1614
- }) as typeof fetch;
1615
-
1616
- const t = new VaultTransport(baseConfig());
1617
- await t.ensureSchema();
1618
-
1619
- expect(declared).toEqual(AGENT_VAULT_TAG_SCHEMA.map((e) => e.name));
1620
- });
1621
-
1622
- test("ensureSchema sends the indexed `fields` body for #agent/thread", async () => {
1623
- let threadBody: { fields?: Record<string, unknown> } | undefined;
1624
- globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
1625
- const name = decodeURIComponent(String(url).split("/api/tags/")[1]!);
1626
- if (name === AGENT_THREAD_TAG) threadBody = JSON.parse(String(init?.body));
1627
- return new Response("{}", { status: 200 });
1628
- }) as typeof fetch;
1629
-
1630
- const t = new VaultTransport(baseConfig());
1631
- await t.ensureSchema();
1632
-
1633
- expect(threadBody?.fields).toEqual({
1634
- // Expand phase: the new `agent` routing-key alias is declared indexed (additive).
1635
- agent: { type: "string", indexed: true },
1636
- status: { type: "string", indexed: true },
1637
- definition: { type: "string", indexed: true },
1638
- mode: { type: "string", indexed: true },
1639
- });
1640
- });
1641
-
1642
- test("ensureSchema sends the indexed `fields` body for #agent/job (query by agent/enabled/lastStatus)", async () => {
1643
- let jobBody: { fields?: Record<string, unknown> } | undefined;
1644
- globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
1645
- const name = decodeURIComponent(String(url).split("/api/tags/")[1]!);
1646
- if (name === AGENT_JOB_TAG) jobBody = JSON.parse(String(init?.body));
1647
- return new Response("{}", { status: 200 });
1648
- }) as typeof fetch;
1649
-
1650
- const t = new VaultTransport(baseConfig());
1651
- await t.ensureSchema();
1652
-
1653
- expect(jobBody?.fields).toEqual({
1654
- // CONTRACT: index the routing key under `agent` ONLY — no `channel`.
1655
- agent: { type: "string", indexed: true },
1656
- enabled: { type: "string", indexed: true },
1657
- lastStatus: { type: "string", indexed: true },
1658
- });
1659
- });
1660
-
1661
- test("best-effort: a rejecting fetch does NOT throw out of ensureSchema", async () => {
1662
- globalThis.fetch = (async () => {
1663
- throw new Error("ECONNREFUSED");
1664
- }) as unknown as typeof fetch;
1665
-
1666
- const t = new VaultTransport(baseConfig());
1667
- // Must resolve, not reject.
1668
- await expect(t.ensureSchema()).resolves.toBeUndefined();
1669
- });
1670
-
1671
- test("best-effort: a 500 response does NOT throw out of ensureSchema", async () => {
1672
- globalThis.fetch = (async () =>
1673
- new Response("boom", { status: 500 })) as unknown as typeof fetch;
1674
-
1675
- const t = new VaultTransport(baseConfig());
1676
- await expect(t.ensureSchema()).resolves.toBeUndefined();
1677
- });
1678
-
1679
- test("start() stays non-fatal + the transport still works when schema-ensure fails", async () => {
1680
- // A fetch that fails the PUT (schema) but the test asserts start() resolves
1681
- // and ingestInbound still emits — the transport is fully functional regardless.
1682
- globalThis.fetch = (async () => {
1683
- throw new Error("vault unreachable");
1684
- }) as unknown as typeof fetch;
1685
-
1686
- const t = new VaultTransport(baseConfig());
1687
- const ctx = fakeCtx("eng");
1688
- await expect(t.start(ctx)).resolves.toBeUndefined();
1689
- await flush(); // let the fire-and-forget ensureSchema settle (it must not reject globally)
1690
-
1691
- // Transport still delivers inbound after a failed schema declaration.
1692
- void t.ingestInbound({
1693
- id: "n1",
1694
- content: "still works",
1695
- tags: ["agent/message", "agent/message/inbound"],
1696
- metadata: { channel: "eng", direction: "inbound", sender: "aaron" },
1697
- });
1698
- expect(ctx.emitted).toHaveLength(1);
1699
- expect(ctx.emitted[0]!.content).toBe("still works");
1700
- });
1701
- });
1702
-
1703
- describe("registry — vault", () => {
1704
- test("a vault channel instantiates from config", () => {
1705
- const transport = instantiateTransport({
1706
- name: "eng",
1707
- transport: "vault",
1708
- config: baseConfig(),
1709
- });
1710
- expect(transport.kind).toBe("vault");
1711
- expect(transport).toBeInstanceOf(VaultTransport);
1712
- });
1713
-
1714
- test("a vault channel without a token throws", () => {
1715
- expect(() =>
1716
- instantiateTransport({
1717
- name: "eng",
1718
- transport: "vault",
1719
- config: { vault: "default", webhookSecret: "s" },
1720
- }),
1721
- ).toThrow(/token/);
1722
- });
1723
- });
1724
-
1725
- describe("VaultTransport — injectInbound (runner seam)", () => {
1726
- test("injectInbound writes an INBOUND note (both tags) with runner provenance", async () => {
1727
- const calls: { url: string; init: RequestInit }[] = [];
1728
- globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
1729
- calls.push({ url: String(url), init: init ?? {} });
1730
- return new Response(JSON.stringify({ id: "inbound-1" }), {
1731
- status: 201,
1732
- headers: { "content-type": "application/json" },
1733
- });
1734
- }) as typeof fetch;
1735
-
1736
- const t = new VaultTransport(baseConfig());
1737
- await t.start(fakeCtx("eng"));
1738
- const r = await t.injectInbound({ content: "Run the morning weave", sender: "runner:morning" });
1739
- expect(r.id).toBe("inbound-1");
1740
-
1741
- const noteCalls = calls.filter((c) => c.url.endsWith("/api/notes"));
1742
- expect(noteCalls).toHaveLength(1);
1743
- const body = JSON.parse(String(noteCalls[0]!.init.body));
1744
- // Inbound: BOTH the parent + the inbound child (the trigger discriminator).
1745
- expect(body.tags).toEqual(["agent/message", "agent/message/inbound"]);
1746
- expect(body.content).toBe("Run the morning weave");
1747
- expect(body.metadata.direction).toBe("inbound");
1748
- expect(body.metadata.sender).toBe("runner:morning");
1749
- // NEVER stamps channel_inbound_rendered_at (so the trigger fires).
1750
- expect(body.metadata.channel_inbound_rendered_at).toBeUndefined();
1751
- });
1752
-
1753
- test("injectInbound defaults sender to 'runner'", async () => {
1754
- globalThis.fetch = (async () =>
1755
- new Response(JSON.stringify({ id: "x" }), {
1756
- status: 201,
1757
- headers: { "content-type": "application/json" },
1758
- })) as unknown as typeof fetch;
1759
- const t = new VaultTransport(baseConfig());
1760
- await t.start(fakeCtx("eng"));
1761
- // No throw + returns the id; the default-sender path is exercised.
1762
- expect((await t.injectInbound({ content: "hi" })).id).toBe("x");
1763
- });
1764
- });
1765
-
1766
- describe("VaultTransport — scheduled-job notes (vault-native store)", () => {
1767
- test("listJobNotes queries by #agent/job + maps metadata; skips malformed", async () => {
1768
- const urls: string[] = [];
1769
- globalThis.fetch = (async (url: string | URL | Request) => {
1770
- urls.push(String(url));
1771
- return new Response(
1772
- JSON.stringify([
1773
- {
1774
- id: "note-uuid-1",
1775
- content: "the message",
1776
- metadata: { jobId: "morning", channel: "eng", cron: "0 9 * * *", tz: "UTC", enabled: "true", createdAt: "t0" },
1777
- },
1778
- // a note WITHOUT jobId metadata → slug falls back to the note id
1779
- {
1780
- id: "Channels/eng/jobs/legacy",
1781
- content: "legacy",
1782
- metadata: { channel: "eng", cron: "0 0 * * *", enabled: "false" },
1783
- },
1784
- // malformed (no cron) → skipped
1785
- { id: "job-bad", content: "x", metadata: { channel: "eng" } },
1786
- ]),
1787
- { status: 200, headers: { "content-type": "application/json" } },
1788
- );
1789
- }) as typeof fetch;
1790
-
1791
- const t = new VaultTransport(baseConfig());
1792
- const jobs = await t.listJobNotes();
1793
- expect(urls[0]).toContain("tag=agent%2Fjob");
1794
- expect(urls[0]).toContain("include_content=true");
1795
- expect(jobs).toHaveLength(2);
1796
- // id = the slug from metadata.jobId; noteId = the vault note id.
1797
- expect(jobs[0]).toMatchObject({ id: "morning", noteId: "note-uuid-1", channel: "eng", cron: "0 9 * * *", tz: "UTC", enabled: true });
1798
- // legacy note (no jobId) → id falls back to the note id.
1799
- expect(jobs[1]).toMatchObject({ id: "Channels/eng/jobs/legacy", noteId: "Channels/eng/jobs/legacy", enabled: false });
1800
- });
1801
-
1802
- test("listJobNotes throws on a non-ok vault response", async () => {
1803
- globalThis.fetch = (async () => new Response("nope", { status: 502 })) as unknown as typeof fetch;
1804
- const t = new VaultTransport(baseConfig());
1805
- await expect(t.listJobNotes()).rejects.toThrow(/list jobs failed \(502\)/);
1806
- });
1807
-
1808
- test("upsertJobNote POSTs a #agent/job note at the deterministic path", async () => {
1809
- const calls: { url: string; init: RequestInit }[] = [];
1810
- globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
1811
- calls.push({ url: String(url), init: init ?? {} });
1812
- return new Response(JSON.stringify({ id: "Channels/eng/jobs/m" }), {
1813
- status: 201,
1814
- headers: { "content-type": "application/json" },
1815
- });
1816
- }) as typeof fetch;
1817
- const t = new VaultTransport(baseConfig());
1818
- const r = await t.upsertJobNote({
1819
- id: "m",
1820
- message: "go",
1821
- channel: "eng",
1822
- cron: "0 9 * * *",
1823
- enabled: true,
1824
- createdAt: "t0",
1825
- });
1826
- expect(r.id).toBe("Channels/eng/jobs/m");
1827
- const body = JSON.parse(String(calls[0]!.init.body));
1828
- expect(body.path).toBe("Channels/eng/jobs/m");
1829
- expect(body.tags).toEqual(["agent/job"]);
1830
- expect(body.metadata.enabled).toBe("true");
1831
- expect(body.metadata.jobId).toBe("m"); // slug persisted for stable display
1832
- // CONTRACT: routing key under `metadata.agent` ONLY — no `channel`.
1833
- expect(body.metadata.agent).toBe("eng");
1834
- expect(body.metadata.channel).toBeUndefined();
1835
- });
1836
-
1837
- test("patchJobNote sends a PATCH with only the changed metadata", async () => {
1838
- const calls: { url: string; init: RequestInit }[] = [];
1839
- globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
1840
- calls.push({ url: String(url), init: init ?? {} });
1841
- return new Response(null, { status: 200 });
1842
- }) as typeof fetch;
1843
- const t = new VaultTransport(baseConfig());
1844
- await t.patchJobNote("job-1", { lastStatus: "ok", lastRunAt: "t1" });
1845
- expect(calls[0]!.init.method).toBe("PATCH");
1846
- expect(calls[0]!.url).toContain("/api/notes/job-1");
1847
- const patchBody = JSON.parse(String(calls[0]!.init.body));
1848
- expect(patchBody.metadata).toEqual({ lastRunAt: "t1", lastStatus: "ok" });
1849
- // MUST carry the vault mutation precondition or the PATCH 428s (real-vault bug).
1850
- expect(patchBody.force).toBe(true);
1851
- });
1852
-
1853
- test("deleteJobNote DELETEs by id", async () => {
1854
- const calls: { url: string; method?: string }[] = [];
1855
- globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
1856
- calls.push({ url: String(url), method: init?.method });
1857
- return new Response(null, { status: 204 });
1858
- }) as typeof fetch;
1859
- const t = new VaultTransport(baseConfig());
1860
- await t.deleteJobNote("job-1");
1861
- expect(calls[0]!.method).toBe("DELETE");
1862
- expect(calls[0]!.url).toContain("/api/notes/job-1");
1863
- });
1864
-
1865
- test("deleteJobNote throws on a non-ok vault response", async () => {
1866
- globalThis.fetch = (async () => new Response("no", { status: 404 })) as unknown as typeof fetch;
1867
- const t = new VaultTransport(baseConfig());
1868
- await expect(t.deleteJobNote("job-1")).rejects.toThrow(/delete job failed \(404\)/);
1869
- });
1870
- });
1871
-
1872
- // ---------------------------------------------------------------------------
1873
- // Channel-queue inbound notes — FIX 3 (CAS claim) + FIX 6 (handled exclusion).
1874
- // ---------------------------------------------------------------------------
1875
-
1876
- describe("VaultTransport — listInboundQueue", () => {
1877
- test("FIX 6: EXCLUDES handled notes so pending is never crowded out past the cap", async () => {
1878
- // The vault returns many `handled` notes plus one still-`pending` note. The handled
1879
- // ones must be dropped client-side so the pending one is always in the returned queue.
1880
- const handled = Array.from({ length: 50 }, (_, i) => ({
1881
- id: `h${i}`,
1882
- content: `handled ${i}`,
1883
- metadata: { channel: "eng", direction: "inbound", sender: "operator", ts: `2026-01-01T00:${String(i).padStart(2, "0")}:00Z`, status: "handled" },
1884
- updated_at: `2026-01-01T01:00:00Z`,
1885
- }));
1886
- const pending = {
1887
- id: "p1",
1888
- content: "still pending",
1889
- metadata: { channel: "eng", direction: "inbound", sender: "operator", ts: "2026-01-02T00:00:00Z", status: "pending" },
1890
- updated_at: "2026-01-02T00:00:00Z",
1891
- };
1892
- let listUrl = "";
1893
- globalThis.fetch = (async (url: string | URL | Request) => {
1894
- const u = String(url);
1895
- // start() fires ensureSchema PUTs (.../api/tags/*); only capture the list GET.
1896
- if (u.includes("/api/notes?")) {
1897
- listUrl = u;
1898
- return new Response(JSON.stringify([...handled, pending]), {
1899
- status: 200,
1900
- headers: { "content-type": "application/json" },
1901
- });
1902
- }
1903
- return new Response(null, { status: 200 });
1904
- }) as typeof fetch;
1905
-
1906
- const t = new VaultTransport(baseConfig());
1907
- await t.start(fakeCtx("eng"));
1908
- const queue = await t.listInboundQueue();
1909
- // No handled notes survive; the pending one IS present.
1910
- expect(queue.every((n) => n.status !== "handled")).toBe(true);
1911
- expect(queue.map((n) => n.id)).toEqual(["p1"]);
1912
- expect(queue[0]!.status).toBe("pending");
1913
- // The list request asks the vault NEWEST-first (so a hard cap drops the oldest
1914
- // handled notes, never a recent pending).
1915
- expect(listUrl).toContain("sort=desc");
1916
- });
1917
-
1918
- test("FIX 6: in-flight notes are KEPT (only handled is excluded)", async () => {
1919
- globalThis.fetch = (async () =>
1920
- new Response(
1921
- JSON.stringify([
1922
- { id: "a", content: "p", metadata: { channel: "eng", ts: "t1", status: "pending" }, updated_at: "u1" },
1923
- { id: "b", content: "f", metadata: { channel: "eng", ts: "t2", status: "in-flight", claimedAt: "c2" }, updated_at: "u2" },
1924
- { id: "c", content: "h", metadata: { channel: "eng", ts: "t3", status: "handled" }, updated_at: "u3" },
1925
- ]),
1926
- { status: 200, headers: { "content-type": "application/json" } },
1927
- )) as unknown as typeof fetch;
1928
- const t = new VaultTransport(baseConfig());
1929
- await t.start(fakeCtx("eng"));
1930
- const queue = await t.listInboundQueue();
1931
- expect(queue.map((n) => n.id)).toEqual(["a", "b"]);
1932
- expect(queue.find((n) => n.id === "b")!.status).toBe("in-flight");
1933
- });
1934
-
1935
- test("FIX 3: threads the note's updated_at through as updatedAt (the CAS precondition)", async () => {
1936
- globalThis.fetch = (async () =>
1937
- new Response(
1938
- JSON.stringify([
1939
- { id: "n1", content: "hi", metadata: { channel: "eng", ts: "t1", status: "pending" }, updated_at: "2026-06-01T00:00:00Z" },
1940
- ]),
1941
- { status: 200, headers: { "content-type": "application/json" } },
1942
- )) as unknown as typeof fetch;
1943
- const t = new VaultTransport(baseConfig());
1944
- await t.start(fakeCtx("eng"));
1945
- const queue = await t.listInboundQueue();
1946
- expect(queue[0]!.updatedAt).toBe("2026-06-01T00:00:00Z");
1947
- });
1948
- });
1949
-
1950
- describe("VaultTransport — setInboundStatus (FIX 3 compare-and-swap claim)", () => {
1951
- test("with ifUpdatedAt: sends if_updated_at (NOT force) as the precondition", async () => {
1952
- let body: any;
1953
- globalThis.fetch = (async (_url: string | URL | Request, init?: RequestInit) => {
1954
- body = JSON.parse(String(init?.body));
1955
- return new Response(null, { status: 200 });
1956
- }) as typeof fetch;
1957
- const t = new VaultTransport(baseConfig());
1958
- await t.setInboundStatus("n1", "in-flight", "2026-06-01T00:00:01Z", "2026-06-01T00:00:00Z");
1959
- expect(body.if_updated_at).toBe("2026-06-01T00:00:00Z");
1960
- expect(body.force).toBeUndefined();
1961
- expect(body.metadata.status).toBe("in-flight");
1962
- expect(body.metadata.claimedAt).toBe("2026-06-01T00:00:01Z");
1963
- });
1964
-
1965
- test("without ifUpdatedAt: keeps the last-write-wins force:true (release/handled/sweep)", async () => {
1966
- let body: any;
1967
- globalThis.fetch = (async (_url: string | URL | Request, init?: RequestInit) => {
1968
- body = JSON.parse(String(init?.body));
1969
- return new Response(null, { status: 200 });
1970
- }) as typeof fetch;
1971
- const t = new VaultTransport(baseConfig());
1972
- await t.setInboundStatus("n1", "handled", null);
1973
- expect(body.force).toBe(true);
1974
- expect(body.if_updated_at).toBeUndefined();
1975
- });
1976
-
1977
- test("a 409 (stale precondition) on a CAS write throws InboundClaimConflictError", async () => {
1978
- globalThis.fetch = (async () =>
1979
- new Response(JSON.stringify({ error_type: "conflict" }), { status: 409 })) as unknown as typeof fetch;
1980
- const t = new VaultTransport(baseConfig());
1981
- await expect(
1982
- t.setInboundStatus("n1", "in-flight", "now", "stale-updated-at"),
1983
- ).rejects.toBeInstanceOf(InboundClaimConflictError);
1984
- });
1985
-
1986
- test("a 428 (precondition required) on a CAS write also throws InboundClaimConflictError", async () => {
1987
- globalThis.fetch = (async () =>
1988
- new Response(JSON.stringify({ error: "precondition_required" }), { status: 428 })) as unknown as typeof fetch;
1989
- const t = new VaultTransport(baseConfig());
1990
- await expect(
1991
- t.setInboundStatus("n1", "in-flight", "now", "some-updated-at"),
1992
- ).rejects.toBeInstanceOf(InboundClaimConflictError);
1993
- });
1994
-
1995
- test("a 409 on a NON-CAS write (no ifUpdatedAt) throws a plain Error, not a conflict", async () => {
1996
- globalThis.fetch = (async () =>
1997
- new Response("conflict", { status: 409 })) as unknown as typeof fetch;
1998
- const t = new VaultTransport(baseConfig());
1999
- const err = await t.setInboundStatus("n1", "handled", null).catch((e) => e);
2000
- expect(err).toBeInstanceOf(Error);
2001
- expect(err).not.toBeInstanceOf(InboundClaimConflictError);
2002
- });
2003
-
2004
- test("a 500 on a CAS write throws a plain Error (a real failure, not a lost race)", async () => {
2005
- globalThis.fetch = (async () =>
2006
- new Response("boom", { status: 500 })) as unknown as typeof fetch;
2007
- const t = new VaultTransport(baseConfig());
2008
- const err = await t.setInboundStatus("n1", "in-flight", "now", "u1").catch((e) => e);
2009
- expect(err).toBeInstanceOf(Error);
2010
- expect(err).not.toBeInstanceOf(InboundClaimConflictError);
2011
- });
2012
- });