@openparachute/agent 0.2.3-rc.2 → 0.2.3-rc.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -1
- package/src/transports/vault.ts +19 -1
- package/web/ui/dist/assets/{index-5KEwEhfi.js → index-0e7eQymr.js} +1 -1
- package/web/ui/dist/assets/index-tvKbxee4.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/src/_parked/interactive-spawn.test.ts +0 -324
- package/src/_parked/interactive-spawn.ts +0 -701
- package/src/agent-defs.test.ts +0 -1504
- package/src/agent-mcp-config.test.ts +0 -115
- package/src/agents.test.ts +0 -360
- package/src/auth.test.ts +0 -46
- package/src/backends/attached-queue.test.ts +0 -376
- package/src/backends/programmatic.test.ts +0 -1715
- package/src/backends/registry.test.ts +0 -1494
- package/src/backends/stream-json.test.ts +0 -570
- package/src/channel-backend-wiring.test.ts +0 -237
- package/src/credentials.test.ts +0 -274
- package/src/cron.test.ts +0 -342
- package/src/daemon-agent-def-api.test.ts +0 -166
- package/src/daemon-agent-defs-api.test.ts +0 -953
- package/src/daemon-agent-env-api.test.ts +0 -338
- package/src/daemon-attached-queue-store.test.ts +0 -65
- package/src/daemon-config-api.test.ts +0 -962
- package/src/daemon-jobs-api.test.ts +0 -271
- package/src/daemon-vault-chat.test.ts +0 -250
- package/src/daemon.test.ts +0 -746
- package/src/def-vaults.test.ts +0 -136
- package/src/delivery-state.test.ts +0 -110
- package/src/effective-env.test.ts +0 -114
- package/src/grants.test.ts +0 -638
- package/src/hub-jwt.test.ts +0 -161
- package/src/jobs.test.ts +0 -245
- package/src/mcp-http.test.ts +0 -265
- package/src/mint-token.test.ts +0 -152
- package/src/module-manifest.test.ts +0 -158
- package/src/programmatic-wiring.test.ts +0 -838
- package/src/registry.test.ts +0 -227
- package/src/resolve-port.test.ts +0 -64
- package/src/routing.test.ts +0 -184
- package/src/runner.test.ts +0 -506
- package/src/sandbox/config.test.ts +0 -150
- package/src/sandbox/egress.test.ts +0 -113
- package/src/sandbox/live-seatbelt.test.ts +0 -277
- package/src/sandbox/mounts.test.ts +0 -154
- package/src/sandbox/sandbox.test.ts +0 -168
- package/src/services-manifest.test.ts +0 -106
- package/src/spa-serve.test.ts +0 -116
- package/src/spawn-agent-cli.test.ts +0 -172
- package/src/spawn-agent.test.ts +0 -1218
- package/src/spawn-deps.test.ts +0 -54
- package/src/terminal-assets.test.ts +0 -50
- package/src/terminal.test.ts +0 -530
- package/src/transports/http-ui.test.ts +0 -455
- package/src/transports/telegram.test.ts +0 -174
- package/src/transports/vault.test.ts +0 -2012
- package/src/ui-kit.test.ts +0 -178
- package/web/ui/dist/assets/index-C-iWdFFV.css +0 -1
- 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
|
-
});
|