@vellumai/assistant 0.12.0-staging.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/Dockerfile +5 -0
  2. package/docs/architecture/turn-actor.md +9 -0
  3. package/knip.json +1 -0
  4. package/node_modules/@vellumai/app-icons/package.json +18 -0
  5. package/node_modules/@vellumai/app-icons/src/index.test.ts +85 -0
  6. package/node_modules/@vellumai/app-icons/src/index.ts +387 -0
  7. package/node_modules/@vellumai/app-icons/tsconfig.json +20 -0
  8. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +31 -0
  9. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts +28 -3
  10. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +31 -0
  11. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/channels.ts +28 -3
  12. package/node_modules/@vellumai/gateway-client/src/__tests__/plugin-admission-denied-contract.test.ts +10 -0
  13. package/node_modules/@vellumai/gateway-client/src/index.ts +1 -0
  14. package/node_modules/@vellumai/gateway-client/src/plugin-admission-denied-contract.ts +15 -2
  15. package/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +31 -0
  16. package/node_modules/@vellumai/service-contracts/src/channels.ts +28 -3
  17. package/openapi.yaml +628 -0
  18. package/package.json +3 -1
  19. package/scripts/smoke-container-workspace-dependencies.ts +19 -0
  20. package/src/__tests__/app-builder-icon-names.test.ts +29 -0
  21. package/src/__tests__/assistant-attachment-directive.test.ts +4 -0
  22. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +1 -0
  23. package/src/__tests__/conversation-agent-loop-overflow.test.ts +1 -0
  24. package/src/__tests__/conversation-agent-loop.test.ts +2 -0
  25. package/src/__tests__/conversation-attachments.test.ts +142 -0
  26. package/src/__tests__/conversation-delete-activation-progress.test.ts +145 -0
  27. package/src/__tests__/conversation-event-sink.test.ts +50 -0
  28. package/src/__tests__/conversation-process-app-control-preactivation.test.ts +10 -2
  29. package/src/__tests__/conversation-queue.test.ts +297 -0
  30. package/src/__tests__/credential-routes.test.ts +185 -6
  31. package/src/__tests__/drain-kick-guard.test.ts +2 -0
  32. package/src/__tests__/drain-requeue-on-contention.test.ts +6 -0
  33. package/src/__tests__/messaging-send-tool.test.ts +42 -0
  34. package/src/__tests__/oauth-commands-routes.test.ts +47 -0
  35. package/src/__tests__/subagent-manager-notify.test.ts +25 -4
  36. package/src/activation/progress-store.test.ts +1564 -0
  37. package/src/activation/progress-store.ts +1296 -0
  38. package/src/activation/turn-hooks.test.ts +246 -0
  39. package/src/activation/turn-hooks.ts +126 -0
  40. package/src/api/events/subagent-status-changed.ts +7 -5
  41. package/src/api/responses/activation.ts +136 -0
  42. package/src/apps/app-store.ts +8 -0
  43. package/src/cli/__tests__/catalog-search-help.test.ts +7 -5
  44. package/src/cli/commands/__tests__/cli-test-harness.ts +12 -3
  45. package/src/cli/commands/channels/__tests__/channels.test.ts +2 -0
  46. package/src/cli/commands/channels/__tests__/request.test.ts +191 -0
  47. package/src/cli/commands/channels/index.help.ts +70 -18
  48. package/src/cli/commands/channels/index.ts +17 -6
  49. package/src/cli/commands/channels/request.ts +66 -0
  50. package/src/cli/commands/oauth/request.test.ts +2 -0
  51. package/src/cli/commands/oauth/request.ts +227 -186
  52. package/src/config/bundled-skills/app-builder/SKILL.md +3 -1
  53. package/src/config/bundled-skills/app-builder/TOOLS.json +2 -2
  54. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +8 -4
  55. package/src/config/feature-flag-registry.json +35 -5
  56. package/src/daemon/assistant-attachments.ts +11 -0
  57. package/src/daemon/conversation-agent-loop-handlers.ts +5 -0
  58. package/src/daemon/conversation-agent-loop.ts +71 -3
  59. package/src/daemon/conversation-attachments.ts +42 -1
  60. package/src/daemon/conversation-event-sink.ts +25 -0
  61. package/src/daemon/conversation-process.ts +69 -21
  62. package/src/daemon/conversation-store.ts +4 -3
  63. package/src/daemon/conversation-surfaces.ts +19 -4
  64. package/src/daemon/message-types/sync.ts +2 -0
  65. package/src/ipc/assistant-server.ts +2 -0
  66. package/src/ipc/routes/__tests__/activation-sync-ipc-routes.test.ts +51 -0
  67. package/src/ipc/routes/activation-sync-ipc-routes.ts +42 -0
  68. package/src/notifications/AGENTS.md +1 -1
  69. package/src/notifications/__tests__/proactive-home-thread.test.ts +111 -0
  70. package/src/notifications/conversation-pairing.ts +47 -5
  71. package/src/notifications/delivered-post-record.ts +3 -0
  72. package/src/persistence/__tests__/slack-thread-root-evidence.test.ts +102 -0
  73. package/src/persistence/conversation-crud.ts +39 -0
  74. package/src/persistence/delivery-crud.ts +13 -0
  75. package/src/plugins/AGENTS.md +1 -0
  76. package/src/runtime/auth/__tests__/route-policy.test.ts +37 -0
  77. package/src/runtime/routes/__tests__/user-routes-notices.test.ts +248 -0
  78. package/src/runtime/routes/activation-routes.test.ts +415 -0
  79. package/src/runtime/routes/activation-routes.ts +172 -0
  80. package/src/runtime/routes/credential-routes.ts +46 -3
  81. package/src/runtime/routes/index.ts +2 -0
  82. package/src/runtime/routes/oauth-commands-routes.ts +21 -8
  83. package/src/runtime/routes/platform-managed-credentials.ts +48 -0
  84. package/src/runtime/routes/secret-routes.ts +3 -12
  85. package/src/runtime/routes/user-route-resolution.ts +21 -0
  86. package/src/runtime/routes/user-routes.ts +112 -9
  87. package/src/runtime/sync/activation-sidecar-publish.test.ts +78 -0
  88. package/src/runtime/sync/documents-sidecar-publish.test.ts +3 -0
  89. package/src/runtime/sync/resource-sync-events.ts +23 -0
  90. package/src/runtime/sync/worker-daemon-notify.test.ts +36 -0
  91. package/src/runtime/sync/worker-daemon-notify.ts +39 -1
  92. package/src/tools/apps/executors.ts +8 -8
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The app-builder skill lists the icon names a model may put in
3
+ * `preview.icon`, and `@vellumai/app-icons` is what the executor accepts.
4
+ * The skill is prose, so nothing types it against the package; this pins
5
+ * the two lists to each other.
6
+ */
7
+
8
+ import { readFileSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { describe, expect, test } from "bun:test";
11
+
12
+ import { APP_ICON_NAMES } from "@vellumai/app-icons";
13
+
14
+ const SKILL_PATH = join(
15
+ import.meta.dir,
16
+ "../config/bundled-skills/app-builder/SKILL.md",
17
+ );
18
+
19
+ describe("app-builder skill icon names", () => {
20
+ test("lists exactly the registry's names, in its order", () => {
21
+ const skill = readFileSync(SKILL_PATH, "utf8");
22
+ const line = skill
23
+ .split("\n")
24
+ .find((l) => l.startsWith("**App icon names**"));
25
+ expect(line).toBeDefined();
26
+ const listed = [...line!.matchAll(/`([a-z0-9-]+)`/g)].map((m) => m[1]);
27
+ expect(listed).toEqual([...APP_ICON_NAMES]);
28
+ });
29
+ });
@@ -218,6 +218,9 @@ describe("resolveSandboxDirective", () => {
218
218
  expect(result.draft!.mimeType).toBe("text/plain");
219
219
  expect(result.draft!.sizeBytes).toBe(11);
220
220
  expect(result.draft!.kind).toBe("document");
221
+ // The resolved path, not the named one: callers that surface the file
222
+ // need to know where it actually is.
223
+ expect(result.draft!.sourcePath).toBe(filePath);
221
224
  });
222
225
 
223
226
  test("uses directive filename override when provided", () => {
@@ -360,6 +363,7 @@ describe("resolveHostDirective", () => {
360
363
  expect(result.draft!.filename).toBe("doc.txt");
361
364
  expect(result.draft!.mimeType).toBe("text/plain");
362
365
  expect(result.draft!.sizeBytes).toBe(12);
366
+ expect(result.draft!.sourcePath).toBe(filePath);
363
367
  });
364
368
 
365
369
  test("skips when user denies", async () => {
@@ -230,6 +230,7 @@ mock.module("../daemon/conversation-attachments.js", () => ({
230
230
  assistantAttachments: [],
231
231
  emittedAttachments: [],
232
232
  directiveWarnings: [],
233
+ persistedFiles: [],
233
234
  }),
234
235
  approveHostAttachmentRead: async () => true,
235
236
  formatAttachmentWarnings: () => "",
@@ -339,6 +339,7 @@ const resolveAssistantAttachmentsMock = mock(async () => ({
339
339
  assistantAttachments: [],
340
340
  emittedAttachments: [],
341
341
  directiveWarnings: [],
342
+ persistedFiles: [],
342
343
  }));
343
344
  mock.module("../daemon/conversation-attachments.js", () => ({
344
345
  resolveAssistantAttachments: resolveAssistantAttachmentsMock,
@@ -584,6 +584,7 @@ const resolveAssistantAttachmentsMock = mock(async () => ({
584
584
  assistantAttachments: [],
585
585
  emittedAttachments: [],
586
586
  directiveWarnings: [],
587
+ persistedFiles: [],
587
588
  }));
588
589
  mock.module("../daemon/conversation-attachments.js", () => ({
589
590
  resolveAssistantAttachments: resolveAssistantAttachmentsMock,
@@ -1036,6 +1037,7 @@ beforeEach(() => {
1036
1037
  assistantAttachments: [],
1037
1038
  emittedAttachments: [],
1038
1039
  directiveWarnings: [],
1040
+ persistedFiles: [],
1039
1041
  }));
1040
1042
  mockMessageById = null;
1041
1043
  resetConversationNoticesForTests();
@@ -187,6 +187,148 @@ describe("resolveAssistantAttachments", () => {
187
187
  // fileBacked flag is always true now
188
188
  expect(emitted.fileBacked).toBe(true);
189
189
  });
190
+
191
+ test("persistedFiles carries only directives that resolved and persisted", async () => {
192
+ const conv = createConversation("test-conv-persisted");
193
+ const msg = await addMessage(conv.id, "assistant", "hello");
194
+
195
+ const accepted: AssistantAttachmentDraft = {
196
+ sourceType: "sandbox_file",
197
+ filename: "plan.md",
198
+ mimeType: "text/markdown",
199
+ dataBase64: makeBase64(64),
200
+ sizeBytes: 64,
201
+ kind: "document",
202
+ sourcePath: "notes/plan.md",
203
+ };
204
+ const oversized: AssistantAttachmentDraft = {
205
+ sourceType: "sandbox_file",
206
+ filename: "huge.bin",
207
+ mimeType: "application/octet-stream",
208
+ dataBase64: makeBase64(64),
209
+ sizeBytes: 64,
210
+ kind: "document",
211
+ sourcePath: "notes/huge.bin",
212
+ };
213
+ const toolBlock: AssistantAttachmentDraft = {
214
+ sourceType: "tool_block",
215
+ filename: "tool-output.png",
216
+ mimeType: "image/png",
217
+ dataBase64: makeBase64(64),
218
+ sizeBytes: 64,
219
+ kind: "image",
220
+ };
221
+
222
+ mock.module("../daemon/assistant-attachments.js", () => ({
223
+ // The missing file's directive produces a warning and no draft.
224
+ resolveDirectives: () =>
225
+ Promise.resolve({
226
+ drafts: [accepted, oversized],
227
+ warnings: [
228
+ 'Skipped sandbox attachment "notes/missing.md": file not found.',
229
+ ],
230
+ }),
231
+ contentBlocksToDrafts: () => [toolBlock],
232
+ deduplicateDrafts: (d: AssistantAttachmentDraft[]) => d,
233
+ validateDrafts: (d: AssistantAttachmentDraft[]) => ({
234
+ accepted: d.filter((draft) => draft.filename !== "huge.bin"),
235
+ warnings: ['Skipped attachment "huge.bin": too large.'],
236
+ }),
237
+ }));
238
+
239
+ const { resolveAssistantAttachments: resolve } =
240
+ await import("../daemon/conversation-attachments.js");
241
+
242
+ const result = await resolve(
243
+ [
244
+ {
245
+ source: "sandbox" as const,
246
+ path: "notes/plan.md",
247
+ filename: "plan.md",
248
+ mimeType: "text/markdown",
249
+ },
250
+ {
251
+ source: "sandbox" as const,
252
+ path: "notes/missing.md",
253
+ filename: undefined,
254
+ mimeType: undefined,
255
+ },
256
+ {
257
+ source: "sandbox" as const,
258
+ path: "notes/huge.bin",
259
+ filename: undefined,
260
+ mimeType: undefined,
261
+ },
262
+ ],
263
+ [
264
+ {
265
+ type: "image",
266
+ source: { type: "base64", media_type: "image/png", data: "" },
267
+ },
268
+ ],
269
+ [],
270
+ "/tmp",
271
+ async () => true,
272
+ msg.id,
273
+ );
274
+
275
+ // The rejected directive, the failed validation, and the tool block
276
+ // (which has no file of its own) are all absent.
277
+ expect(result.persistedFiles).toEqual([
278
+ {
279
+ sourcePath: "notes/plan.md",
280
+ displayName: "plan.md",
281
+ sourceType: "sandbox_file",
282
+ },
283
+ ]);
284
+ });
285
+
286
+ test("persistedFiles is empty when there is no message to persist against", async () => {
287
+ const accepted: AssistantAttachmentDraft = {
288
+ sourceType: "sandbox_file",
289
+ filename: "plan.md",
290
+ mimeType: "text/markdown",
291
+ dataBase64: makeBase64(64),
292
+ sizeBytes: 64,
293
+ kind: "document",
294
+ sourcePath: "notes/plan.md",
295
+ };
296
+
297
+ mock.module("../daemon/assistant-attachments.js", () => ({
298
+ resolveDirectives: () =>
299
+ Promise.resolve({ drafts: [accepted], warnings: [] }),
300
+ contentBlocksToDrafts: () => [],
301
+ deduplicateDrafts: (d: AssistantAttachmentDraft[]) => d,
302
+ validateDrafts: (d: AssistantAttachmentDraft[]) => ({
303
+ accepted: d,
304
+ warnings: [],
305
+ }),
306
+ }));
307
+
308
+ const { resolveAssistantAttachments: resolve } =
309
+ await import("../daemon/conversation-attachments.js");
310
+
311
+ // No assistant message id: the draft is emitted for this turn only and
312
+ // nothing is stored, so it is not a persisted file.
313
+ const result = await resolve(
314
+ [
315
+ {
316
+ source: "sandbox" as const,
317
+ path: "notes/plan.md",
318
+ filename: "plan.md",
319
+ mimeType: "text/markdown",
320
+ },
321
+ ],
322
+ [],
323
+ [],
324
+ "/tmp",
325
+ async () => true,
326
+ undefined,
327
+ );
328
+
329
+ expect(result.emittedAttachments).toHaveLength(1);
330
+ expect(result.persistedFiles).toEqual([]);
331
+ });
190
332
  });
191
333
 
192
334
  describe("approveHostAttachmentRead", () => {
@@ -0,0 +1,145 @@
1
+ /**
2
+ * The activation checklist records which task was launched into which
3
+ * conversation in a workspace file no database cascade reaches. A delete that
4
+ * skipped it would leave the task's row stuck on Working, pointing at a
5
+ * conversation the user can no longer open and offering no way to launch the
6
+ * task again.
7
+ *
8
+ * The cleanup hangs off the shared delete primitives rather than the route, so
9
+ * every caller (route, retrospective GC, cleanup jobs) cleans up, and it is
10
+ * best-effort like the rest of the post-transaction cleanup: these tests wait
11
+ * for it rather than awaiting it.
12
+ */
13
+ import { describe, expect, mock, test } from "bun:test";
14
+
15
+ // Keep the rest of the module real; only the Qdrant collection drop is
16
+ // replaced, so `clearAll` does not reach for a lexical index that is not
17
+ // running here.
18
+ const actualLexical =
19
+ await import("../persistence/job-handlers/message-lexical.js");
20
+ mock.module("../persistence/job-handlers/message-lexical.js", () => ({
21
+ ...actualLexical,
22
+ clearMessagesLexicalIndex: async () => {},
23
+ }));
24
+
25
+ import {
26
+ markActivationTurnComplete,
27
+ readActivationProgress,
28
+ startActivationTask,
29
+ } from "../activation/progress-store.js";
30
+ import type { ActivationTaskProgress } from "../api/responses/activation.js";
31
+ import {
32
+ clearAll,
33
+ createConversation,
34
+ deleteConversation,
35
+ deleteConversationGently,
36
+ } from "../persistence/conversation-crud.js";
37
+ import { initializeDb } from "../persistence/db-init.js";
38
+
39
+ await initializeDb();
40
+
41
+ function taskRecord(taskId: string): ActivationTaskProgress | undefined {
42
+ return readActivationProgress().tasks[taskId];
43
+ }
44
+
45
+ async function waitFor(predicate: () => boolean): Promise<void> {
46
+ const deadline = Date.now() + 5_000;
47
+ while (Date.now() < deadline) {
48
+ if (predicate()) {
49
+ return;
50
+ }
51
+ await Bun.sleep(10);
52
+ }
53
+ throw new Error("Timed out waiting for the activation checklist to settle");
54
+ }
55
+
56
+ /** A conversation with a `started` checklist task pointing at it. */
57
+ async function seedStartedTask(taskId: string): Promise<string> {
58
+ const conversation = createConversation(`activation-${taskId}`);
59
+ await startActivationTask({ taskId, conversationId: conversation.id });
60
+ expect(taskRecord(taskId)).toMatchObject({
61
+ status: "started",
62
+ conversationId: conversation.id,
63
+ });
64
+ return conversation.id;
65
+ }
66
+
67
+ /** A conversation whose checklist task already finished in it. */
68
+ async function seedDoneTask(taskId: string): Promise<string> {
69
+ const conversationId = await seedStartedTask(taskId);
70
+ await markActivationTurnComplete({
71
+ conversationId,
72
+ toolCallCount: 2,
73
+ endedAwaitingUser: false,
74
+ artifacts: [],
75
+ });
76
+ expect(taskRecord(taskId)).toMatchObject({ status: "done", stepCount: 2 });
77
+ return conversationId;
78
+ }
79
+
80
+ describe("deleteConversation releases the checklist task", () => {
81
+ test("a started task returns to Todo and can be launched again", async () => {
82
+ const taskId = "delete-started";
83
+ const conversationId = await seedStartedTask(taskId);
84
+ const other = await seedStartedTask("delete-started-other");
85
+
86
+ deleteConversation(conversationId);
87
+ await waitFor(() => taskRecord(taskId) === undefined);
88
+
89
+ // Scoped to the conversation being deleted.
90
+ expect(taskRecord("delete-started-other")).toMatchObject({
91
+ status: "started",
92
+ conversationId: other,
93
+ });
94
+
95
+ // Todo again, so the row is launchable.
96
+ const relaunched = createConversation("activation-relaunch");
97
+ await startActivationTask({ taskId, conversationId: relaunched.id });
98
+ expect(taskRecord(taskId)).toMatchObject({
99
+ status: "started",
100
+ conversationId: relaunched.id,
101
+ });
102
+ });
103
+
104
+ test("a finished task keeps its history and loses only the dead link", async () => {
105
+ const taskId = "delete-done";
106
+ const conversationId = await seedDoneTask(taskId);
107
+
108
+ deleteConversation(conversationId);
109
+ await waitFor(() => taskRecord(taskId)?.conversationId === "");
110
+
111
+ expect(taskRecord(taskId)).toMatchObject({
112
+ status: "done",
113
+ stepCount: 2,
114
+ conversationId: "",
115
+ });
116
+ });
117
+ });
118
+
119
+ describe("deleteConversationGently releases the checklist task", () => {
120
+ test("the off-loop path clears the same state as the synchronous one", async () => {
121
+ const taskId = "delete-gently";
122
+ const conversationId = await seedStartedTask(taskId);
123
+
124
+ await deleteConversationGently(conversationId);
125
+ await waitFor(() => taskRecord(taskId) === undefined);
126
+ });
127
+ });
128
+
129
+ describe("clearAll releases every checklist task", () => {
130
+ test("started tasks go, finished ones keep their history", async () => {
131
+ await seedStartedTask("clear-all-started");
132
+ await seedDoneTask("clear-all-done");
133
+
134
+ await clearAll();
135
+ await waitFor(
136
+ () => readActivationProgress().tasks["clear-all-started"] === undefined,
137
+ );
138
+
139
+ expect(taskRecord("clear-all-done")).toMatchObject({
140
+ status: "done",
141
+ stepCount: 2,
142
+ conversationId: "",
143
+ });
144
+ });
145
+ });
@@ -0,0 +1,50 @@
1
+ import { describe, expect, mock, test } from "bun:test";
2
+
3
+ const published: Array<{ type: string; conversationId: string | undefined }> =
4
+ [];
5
+ mock.module("../runtime/assistant-event-hub.js", () => ({
6
+ broadcastMessage: (msg: { type: string }, conversationId?: string) => {
7
+ published.push({ type: msg.type, conversationId });
8
+ },
9
+ }));
10
+
11
+ const { conversationEventSink } =
12
+ await import("../daemon/conversation-event-sink.js");
13
+
14
+ /**
15
+ * The scoping guarantee the hub depends on: attribution comes from the
16
+ * emitting conversation, so an event carrying no conversation of its own is
17
+ * still filtered, seq-stamped and replayed like every other one.
18
+ */
19
+ describe("conversationEventSink", () => {
20
+ test("scopes an event whose payload names no conversation", () => {
21
+ published.length = 0;
22
+
23
+ conversationEventSink("conv-parent")({
24
+ type: "subagent_status_changed",
25
+ subagentId: "sa-1",
26
+ status: "running",
27
+ } as never);
28
+
29
+ expect(published).toEqual([
30
+ { type: "subagent_status_changed", conversationId: "conv-parent" },
31
+ ]);
32
+ });
33
+
34
+ test("scopes every event to the emitting conversation, not the payload's", () => {
35
+ published.length = 0;
36
+ const sink = conversationEventSink("conv-parent");
37
+
38
+ sink({ type: "subagent_spawned", subagentId: "sa-1" } as never);
39
+ sink({
40
+ type: "subagent_event",
41
+ subagentId: "sa-1",
42
+ conversationId: "conv-parent",
43
+ } as never);
44
+
45
+ expect(published.map((p) => p.conversationId)).toEqual([
46
+ "conv-parent",
47
+ "conv-parent",
48
+ ]);
49
+ });
50
+ });
@@ -161,6 +161,12 @@ function makeFakeContext(opts: {
161
161
  trustClass: "guardian" as const,
162
162
  guardianPrincipalId: "user-1",
163
163
  },
164
+ setTrustContext(
165
+ this: { trustContext?: TrustContext },
166
+ trustContext: TrustContext | null,
167
+ ) {
168
+ this.trustContext = trustContext ?? undefined;
169
+ },
164
170
  setTransportHints() {},
165
171
  applyHostEnvFromTransport() {},
166
172
  ensureHostProxiesForTurn() {},
@@ -409,8 +415,10 @@ describe("drainQueue preactivation re-add for host-proxy interfaces", () => {
409
415
  "U-contact",
410
416
  );
411
417
  expect(ctx.currentTurnTrustContext?.sourceChannel).toBe("slack");
412
- // The slot itself is left alone; only the turn's view is corrected.
413
- expect(ctx.trustContext?.trustClass).toBe("guardian");
418
+ // The drain is where a queued message commits to a run, so the resting
419
+ // slot names the sender too: history is scoped from it, and a slot still
420
+ // naming the guardian would hand the contact's turn the guardian's rows.
421
+ expect(ctx.trustContext).toBe(contactTrust);
414
422
  });
415
423
 
416
424
  test("buildPassthroughBatch refuses to coalesce two channel senders", async () => {