@vellumai/assistant 0.11.1-staging.1 → 0.11.1-staging.2

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 (28) hide show
  1. package/openapi.yaml +7 -1
  2. package/package.json +1 -1
  3. package/src/__tests__/agent-loop-override-profile.test.ts +21 -14
  4. package/src/__tests__/config-callsite-patch-merge.test.ts +92 -0
  5. package/src/__tests__/drain-requeue-on-contention.test.ts +124 -0
  6. package/src/__tests__/queued-message-delete-contract.test.ts +339 -0
  7. package/src/__tests__/worker-entrypoint-guards.test.ts +39 -0
  8. package/src/api/events/message-requeued.ts +35 -0
  9. package/src/api/index.ts +6 -0
  10. package/src/daemon/conversation-process.ts +77 -12
  11. package/src/daemon/conversation-queue-manager.ts +19 -0
  12. package/src/daemon/handlers/conversations.ts +77 -9
  13. package/src/daemon/shutdown-handlers.ts +20 -7
  14. package/src/persistence/embeddings/__tests__/embedding-local-lifecycle.test.ts +755 -0
  15. package/src/persistence/embeddings/embedding-backend.ts +109 -0
  16. package/src/persistence/embeddings/embedding-local.ts +677 -135
  17. package/src/persistence/embeddings/embedding-types.ts +19 -0
  18. package/src/plugins/defaults/memory/worker.ts +54 -10
  19. package/src/providers/__tests__/preflight-resolved-config.test.ts +25 -0
  20. package/src/providers/__tests__/vellum-mismatch-routing.test.ts +108 -1
  21. package/src/providers/connection-resolution.ts +61 -1
  22. package/src/providers/inference/connection-availability.ts +13 -1
  23. package/src/providers/inference/connections.ts +44 -3
  24. package/src/providers/routing-identity.ts +2 -1
  25. package/src/runtime/routes/__tests__/default-provider-routes.test.ts +4 -4
  26. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +57 -4
  27. package/src/runtime/routes/conversation-query-routes.ts +38 -5
  28. package/src/runtime/routes/inference-provider-connection-routes.ts +27 -3
package/openapi.yaml CHANGED
@@ -20459,7 +20459,9 @@ paths:
20459
20459
  delete:
20460
20460
  operationId: messages_queued_by_id_delete
20461
20461
  summary: Delete a queued message
20462
- description: Remove a pending message from the conversation queue before it is processed.
20462
+ description:
20463
+ Remove a pending message from the conversation queue before it is processed. Broadcasts
20464
+ `message_queued_deleted` so every client can close out the pending row.
20463
20465
  tags:
20464
20466
  - messages
20465
20467
  parameters:
@@ -20477,6 +20479,10 @@ paths:
20477
20479
  responses:
20478
20480
  "200":
20479
20481
  description: Successful response
20482
+ "403":
20483
+ description: The queued message was enqueued by a different actor principal.
20484
+ "404":
20485
+ description: Conversation or queued message not found.
20480
20486
  /v1/messages/queued/{id}/steer:
20481
20487
  post:
20482
20488
  operationId: messages_queued_by_id_steer_post
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.11.1-staging.1",
3
+ "version": "0.11.1-staging.2",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -276,21 +276,28 @@ import { VELLUM_MANAGED_CONNECTION_NAME } from "../providers/vellum-model-routin
276
276
  // Connection-aware resolver path: satisfy
277
277
  // `tryResolveProviderForConnectionName` lookups so resolveDefaultProvider
278
278
  // returns a usable provider for any connection name the winning profile
279
- // references. The managed connection must carry the provider the catalog
280
- // `balanced` default declares (fireworks) or resolution rejects the row as a
281
- // provider mismatch; other names behave as personal anthropic connections.
279
+ // references. The managed connection must be the platform-auth sentinel row:
280
+ // a managed profile routing through anything else resolves to the platform
281
+ // instead. Other names behave as personal anthropic connections.
282
282
  mock.module("../providers/inference/connections.js", () => ({
283
- getConnection: (_db: unknown, name: string) => ({
284
- id: 1,
285
- name,
286
- provider:
287
- name === VELLUM_MANAGED_CONNECTION_NAME ? "fireworks" : "anthropic",
288
- auth_strategy: "user_managed_credential",
289
- credential_alias: null,
290
- metadata_json: null,
291
- created_at: new Date().toISOString(),
292
- updated_at: new Date().toISOString(),
293
- }),
283
+ getConnection: (_db: unknown, name: string) =>
284
+ name === VELLUM_MANAGED_CONNECTION_NAME
285
+ ? {
286
+ id: 1,
287
+ name,
288
+ provider: "vellum",
289
+ auth: { type: "platform" },
290
+ }
291
+ : {
292
+ id: 1,
293
+ name,
294
+ provider: "anthropic",
295
+ auth_strategy: "user_managed_credential",
296
+ credential_alias: null,
297
+ metadata_json: null,
298
+ created_at: new Date().toISOString(),
299
+ updated_at: new Date().toISOString(),
300
+ },
294
301
  }));
295
302
 
296
303
  /**
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Pins the `deepMergeOverwrite` behaviour that the Action Overrides editor
3
+ * depends on when it builds an `llm.callSites` patch (LUM-2949).
4
+ *
5
+ * A persisted call-site entry may carry tuning the web editor renders no
6
+ * control for (`effort`, `thinking`, `maxTokens`, ...). The editor decides
7
+ * per entry whether to send the picker triple, send `null`, or omit the key
8
+ * entirely, and each of those choices is only correct because of how this
9
+ * merge treats it. Asserting on the patch body alone would not catch a
10
+ * change here, so the contract is pinned where it lives.
11
+ */
12
+
13
+ import { describe, expect, test } from "bun:test";
14
+
15
+ import { deepMergeOverwrite } from "../config/loader.js";
16
+
17
+ type Raw = Record<string, unknown>;
18
+
19
+ function callSites(entries: Raw): Raw {
20
+ return { llm: { callSites: entries } };
21
+ }
22
+
23
+ function mergedCallSites(before: Raw, patch: Raw): Raw {
24
+ const raw = callSites(before);
25
+ deepMergeOverwrite(raw, callSites(patch));
26
+ return (raw.llm as Raw).callSites as Raw;
27
+ }
28
+
29
+ const TUNED = {
30
+ profile: "latency-optimized",
31
+ effort: "low",
32
+ thinking: { enabled: false },
33
+ };
34
+
35
+ describe("llm.callSites patch merge", () => {
36
+ test("an omitted key keeps its persisted value", () => {
37
+ // What the editor sends for an active row: the picker triple only.
38
+ const after = mergedCallSites(
39
+ { voiceFrontDoor: TUNED },
40
+ { voiceFrontDoor: { profile: "balanced", provider: null, model: null } },
41
+ );
42
+ expect(after.voiceFrontDoor).toEqual({
43
+ profile: "balanced",
44
+ effort: "low",
45
+ thinking: { enabled: false },
46
+ });
47
+ });
48
+
49
+ test("a null on an absent key is a no-op, not a write", () => {
50
+ const after = mergedCallSites(
51
+ { voiceFrontDoor: TUNED },
52
+ { heartbeatAgent: null },
53
+ );
54
+ expect(after).toEqual({ voiceFrontDoor: TUNED });
55
+ });
56
+
57
+ test("a null deletes the whole entry, tuning included", () => {
58
+ // Why the editor must not send `null` for a row the user left alone:
59
+ // an entry holding only tuning would be erased.
60
+ const after = mergedCallSites(
61
+ { tuningOnly: { effort: "low", thinking: { enabled: false } } },
62
+ { tuningOnly: null },
63
+ );
64
+ expect("tuningOnly" in after).toBe(false);
65
+ });
66
+
67
+ test("omitting a tuning-only entry leaves it untouched", () => {
68
+ const before = {
69
+ tuningOnly: { effort: "low", thinking: { enabled: false } },
70
+ };
71
+ const after = mergedCallSites(before, {
72
+ heartbeatAgent: { profile: "balanced", provider: null, model: null },
73
+ });
74
+ expect(after.tuningOnly).toEqual({
75
+ effort: "low",
76
+ thinking: { enabled: false },
77
+ });
78
+ });
79
+
80
+ test("an explicit null clears a scalar that is present", () => {
81
+ // The picker triple relies on this to drop a stale provider/model pin.
82
+ const after = mergedCallSites(
83
+ { pinned: { provider: "anthropic", model: "claude-fable-5" } },
84
+ { pinned: { profile: "balanced", provider: null, model: null } },
85
+ );
86
+ expect(after.pinned).toEqual({
87
+ profile: "balanced",
88
+ provider: null,
89
+ model: null,
90
+ });
91
+ });
92
+ });
@@ -19,12 +19,17 @@ import {
19
19
  interface FakeEvent {
20
20
  type: string;
21
21
  message?: string;
22
+ conversationId?: string;
23
+ requestId?: string;
24
+ position?: number;
25
+ clientMessageId?: string;
22
26
  }
23
27
 
24
28
  function makeQueued(
25
29
  content: string,
26
30
  requestId: string,
27
31
  events: FakeEvent[],
32
+ extra?: Partial<QueuedMessage>,
28
33
  ): QueuedMessage {
29
34
  return {
30
35
  content,
@@ -34,6 +39,7 @@ function makeQueued(
34
39
  events.push(event);
35
40
  },
36
41
  sentAt: Date.now(),
42
+ ...extra,
37
43
  } as unknown as QueuedMessage;
38
44
  }
39
45
 
@@ -200,3 +206,121 @@ describe("drainQueue under processing-lock contention", () => {
200
206
  ).toBe(1);
201
207
  });
202
208
  });
209
+
210
+ /**
211
+ * The corrective `message_requeued` event.
212
+ *
213
+ * A drain announces `message_dequeued` before the steps that can send the
214
+ * message back, so a client that cleared its pending indicator on that
215
+ * announcement needs to be told the message is queued again. The rule is
216
+ * announcement-scoped: a requeue that happens before the announcement owes
217
+ * clients nothing, because they never stopped showing the row as queued.
218
+ */
219
+ describe("message_requeued corrective event", () => {
220
+ test("a busy persist tells the sender its announced message is queued again", async () => {
221
+ const events: FakeEvent[] = [];
222
+ const { conversation, queue } = makeFakeConversation({
223
+ persistError: new Error(CONVERSATION_BUSY_MESSAGE),
224
+ });
225
+ queue.push(
226
+ makeQueued("hello there", "r1", events, {
227
+ clientMessageId: "nonce-1",
228
+ }),
229
+ );
230
+ queue.push(makeQueued("and another", "r2", events));
231
+
232
+ await drainQueue(conversation as never);
233
+
234
+ expect(events.map((event) => event.type)).toEqual([
235
+ "message_dequeued",
236
+ "message_requeued",
237
+ ]);
238
+ expect(events[1]).toEqual({
239
+ type: "message_requeued",
240
+ conversationId: "conv-drain-requeue",
241
+ requestId: "r1",
242
+ // Back at the head, so 1 of the 2 visible queued items.
243
+ position: 1,
244
+ clientMessageId: "nonce-1",
245
+ });
246
+ });
247
+
248
+ test("a requeue before the dequeue announcement stays silent", async () => {
249
+ const events: FakeEvent[] = [];
250
+ const { conversation, queue } = makeFakeConversation({ processing: true });
251
+ queue.push(makeQueued("hello there", "r1", events));
252
+
253
+ await drainQueue(conversation as never);
254
+
255
+ // The early lock check requeues before announcing anything, so a
256
+ // corrective event here would contradict what the client is showing.
257
+ expect(events).toEqual([]);
258
+ expect(queue.length).toBe(1);
259
+ });
260
+
261
+ test("only the batch member whose dequeue was announced is corrected", async () => {
262
+ const headEvents: FakeEvent[] = [];
263
+ const tailEvents: FakeEvent[] = [];
264
+ const { conversation, queue } = makeFakeConversation({
265
+ persistError: new Error(CONVERSATION_BUSY_MESSAGE),
266
+ });
267
+ queue.push(makeQueued("hello there", "r1", headEvents));
268
+ queue.push(makeQueued("and another", "r2", tailEvents));
269
+
270
+ await drainQueue(conversation as never);
271
+
272
+ // The batch drain announces per member as it walks the batch, and the
273
+ // head's busy persist ends the walk before the tail is announced.
274
+ expect(headEvents.map((event) => event.type)).toEqual([
275
+ "message_dequeued",
276
+ "message_requeued",
277
+ ]);
278
+ expect(tailEvents).toEqual([]);
279
+ expect(queue.peek(0)?.requestId).toBe("r1");
280
+ expect(queue.peek(1)?.requestId).toBe("r2");
281
+ });
282
+
283
+ test("a hidden send is announced but never corrected", async () => {
284
+ const events: FakeEvent[] = [];
285
+ const { conversation, queue } = makeFakeConversation({
286
+ persistError: new Error(CONVERSATION_BUSY_MESSAGE),
287
+ });
288
+ queue.push(
289
+ makeQueued("wizard closed", "r1", events, {
290
+ metadata: { hidden: true },
291
+ }),
292
+ );
293
+
294
+ await drainQueue(conversation as never);
295
+
296
+ // Hidden sends get no queued ack and render no client row, so there is
297
+ // nothing for a corrective event to restore.
298
+ expect(events.some((event) => event.type === "message_requeued")).toBe(
299
+ false,
300
+ );
301
+ expect(queue.peek(0)?.requestId).toBe("r1");
302
+ });
303
+
304
+ test("a second contention round corrects the message again", async () => {
305
+ const events: FakeEvent[] = [];
306
+ const { conversation, queue } = makeFakeConversation({
307
+ persistErrors: [
308
+ new Error(CONVERSATION_BUSY_MESSAGE),
309
+ new Error(CONVERSATION_BUSY_MESSAGE),
310
+ ],
311
+ });
312
+ queue.push(makeQueued("hello there", "r1", events));
313
+
314
+ await drainQueue(conversation as never);
315
+ await drainQueue(conversation as never);
316
+
317
+ // Each round re-announces the dequeue, so each round owes its own
318
+ // correction: the flag settles per announcement, not once per message.
319
+ expect(events.map((event) => event.type)).toEqual([
320
+ "message_dequeued",
321
+ "message_requeued",
322
+ "message_dequeued",
323
+ "message_requeued",
324
+ ]);
325
+ });
326
+ });
@@ -0,0 +1,339 @@
1
+ /**
2
+ * Contract and authorization for deleting a queued message.
3
+ *
4
+ * Two things this pins:
5
+ *
6
+ * 1. **Terminal event.** A queued row that is cancelled never runs, so no
7
+ * `message_dequeued` is ever coming for it. `message_queued_deleted` is the
8
+ * only signal that closes it out, and it must reach the message's event
9
+ * sink (the hub, for HTTP sends) rather than only the caller that issued
10
+ * the DELETE.
11
+ * 2. **Scoping.** The removal is scoped both ways: to the named conversation's
12
+ * own queue, and to the actor principal that enqueued the message. Every
13
+ * subscriber sees every `message_queued` ack, so requestIds are not secrets
14
+ * and cannot stand in for authorization. The caller identity is normalized
15
+ * exactly as the send path normalizes it before recording
16
+ * `sourceActorPrincipalId`, or the two disagree and a legitimate cancel is
17
+ * refused.
18
+ */
19
+ import { afterEach, describe, expect, test } from "bun:test";
20
+
21
+ import type { AssistantEvent } from "../api/index.js";
22
+ import {
23
+ MessageQueue,
24
+ type QueuedMessage,
25
+ } from "../daemon/conversation-queue-manager.js";
26
+ import {
27
+ deleteConversation,
28
+ setConversation,
29
+ } from "../daemon/conversation-registry.js";
30
+ import { ROUTES } from "../runtime/routes/conversation-query-routes.js";
31
+
32
+ const deleteRoute = ROUTES.find(
33
+ (route) => route.operationId === "messages_queued_delete",
34
+ )!;
35
+
36
+ const registered: string[] = [];
37
+
38
+ interface QueuedFixture {
39
+ requestId: string;
40
+ clientMessageId?: string;
41
+ sourceActorPrincipalId?: string;
42
+ metadata?: Record<string, unknown>;
43
+ }
44
+
45
+ /**
46
+ * Register a live conversation holding `items`, and return the events its
47
+ * queued messages publish. The delete path reads the in-memory registry and
48
+ * touches no persistence, so a queue-only stand-in exercises the real code.
49
+ */
50
+ function seedConversation(
51
+ conversationId: string,
52
+ items: QueuedFixture[],
53
+ ): { events: AssistantEvent[]; queue: MessageQueue } {
54
+ const events: AssistantEvent[] = [];
55
+ const queue = new MessageQueue();
56
+ for (const item of items) {
57
+ queue.push({
58
+ content: `content for ${item.requestId}`,
59
+ attachments: [],
60
+ requestId: item.requestId,
61
+ onEvent: (event: AssistantEvent) => {
62
+ events.push(event);
63
+ },
64
+ sentAt: Date.now(),
65
+ clientMessageId: item.clientMessageId,
66
+ sourceActorPrincipalId: item.sourceActorPrincipalId,
67
+ metadata: item.metadata,
68
+ } as QueuedMessage);
69
+ }
70
+ const conversation = {
71
+ conversationId,
72
+ queue,
73
+ removeQueuedMessage: (requestId: string) =>
74
+ queue.removeByRequestId(requestId) !== undefined,
75
+ };
76
+ setConversation(conversationId, conversation as never);
77
+ registered.push(conversationId);
78
+ return { events, queue };
79
+ }
80
+
81
+ function dispatchDelete(args: {
82
+ conversationId: string;
83
+ requestId: string;
84
+ actorPrincipalId?: string;
85
+ }) {
86
+ return Promise.resolve(
87
+ deleteRoute.handler({
88
+ pathParams: { id: args.requestId },
89
+ queryParams: { conversationId: args.conversationId },
90
+ headers:
91
+ args.actorPrincipalId !== undefined
92
+ ? { "x-vellum-actor-principal-id": args.actorPrincipalId }
93
+ : {},
94
+ }),
95
+ );
96
+ }
97
+
98
+ afterEach(() => {
99
+ for (const conversationId of registered.splice(0)) {
100
+ deleteConversation(conversationId);
101
+ }
102
+ delete process.env.DISABLE_HTTP_AUTH;
103
+ });
104
+
105
+ describe("queued message delete: terminal event", () => {
106
+ test("a deleted queued message publishes message_queued_deleted with its nonce", async () => {
107
+ const { events, queue } = seedConversation("conv-delete-1", [
108
+ { requestId: "r1", clientMessageId: "nonce-1" },
109
+ { requestId: "r2" },
110
+ ]);
111
+
112
+ expect(
113
+ await dispatchDelete({
114
+ conversationId: "conv-delete-1",
115
+ requestId: "r1",
116
+ }),
117
+ ).toEqual({ ok: true, conversationId: "conv-delete-1", requestId: "r1" });
118
+
119
+ expect(events).toEqual([
120
+ {
121
+ type: "message_queued_deleted",
122
+ conversationId: "conv-delete-1",
123
+ requestId: "r1",
124
+ clientMessageId: "nonce-1",
125
+ },
126
+ ]);
127
+ // Only the named message left the queue.
128
+ expect(queue.length).toBe(1);
129
+ expect(queue.peek(0)?.requestId).toBe("r2");
130
+ });
131
+
132
+ test("the nonce is omitted when the sender minted none", async () => {
133
+ const { events } = seedConversation("conv-delete-2", [{ requestId: "r1" }]);
134
+
135
+ await dispatchDelete({ conversationId: "conv-delete-2", requestId: "r1" });
136
+
137
+ expect(events).toEqual([
138
+ {
139
+ type: "message_queued_deleted",
140
+ conversationId: "conv-delete-2",
141
+ requestId: "r1",
142
+ },
143
+ ]);
144
+ });
145
+
146
+ test("a hidden send is removed without publishing a terminal event", async () => {
147
+ const { events, queue } = seedConversation("conv-delete-3", [
148
+ { requestId: "r1", metadata: { hidden: true } },
149
+ ]);
150
+
151
+ await dispatchDelete({ conversationId: "conv-delete-3", requestId: "r1" });
152
+
153
+ // Hidden sends never got a queued ack and render no client row, so there
154
+ // is nothing to close out.
155
+ expect(events).toEqual([]);
156
+ expect(queue.length).toBe(0);
157
+ });
158
+
159
+ test("a delete that finds nothing publishes nothing", async () => {
160
+ const { events, queue } = seedConversation("conv-delete-4", [
161
+ { requestId: "r1" },
162
+ ]);
163
+
164
+ await expect(
165
+ dispatchDelete({
166
+ conversationId: "conv-delete-4",
167
+ requestId: "r-absent",
168
+ }),
169
+ ).rejects.toThrow("Queued message not found");
170
+ expect(events).toEqual([]);
171
+ expect(queue.length).toBe(1);
172
+ });
173
+ });
174
+
175
+ describe("queued message delete: conversation scoping", () => {
176
+ test("a requestId from another conversation does not delete across the boundary", async () => {
177
+ const owner = seedConversation("conv-scope-owner", [{ requestId: "r1" }]);
178
+ const bystander = seedConversation("conv-scope-bystander", [
179
+ { requestId: "r2" },
180
+ ]);
181
+
182
+ await expect(
183
+ dispatchDelete({
184
+ conversationId: "conv-scope-bystander",
185
+ requestId: "r1",
186
+ }),
187
+ ).rejects.toThrow("Queued message not found");
188
+
189
+ expect(owner.queue.length).toBe(1);
190
+ expect(bystander.queue.length).toBe(1);
191
+ expect(owner.events).toEqual([]);
192
+ expect(bystander.events).toEqual([]);
193
+ });
194
+
195
+ test("an unknown conversation is reported as such, not as a missing message", async () => {
196
+ await expect(
197
+ dispatchDelete({ conversationId: "conv-absent", requestId: "r1" }),
198
+ ).rejects.toThrow("Conversation not found");
199
+ });
200
+
201
+ test("conversationId is required", async () => {
202
+ await expect(
203
+ Promise.resolve(deleteRoute.handler({ pathParams: { id: "r1" } })),
204
+ ).rejects.toThrow("Missing required parameter: conversationId");
205
+ });
206
+ });
207
+
208
+ describe("queued message delete: actor scoping", () => {
209
+ test("a different actor principal cannot cancel someone else's queued message", async () => {
210
+ const { events, queue } = seedConversation("conv-actor-1", [
211
+ { requestId: "r1", sourceActorPrincipalId: "actor-owner" },
212
+ ]);
213
+
214
+ await expect(
215
+ dispatchDelete({
216
+ conversationId: "conv-actor-1",
217
+ requestId: "r1",
218
+ actorPrincipalId: "actor-bystander",
219
+ }),
220
+ ).rejects.toThrow("sent by a different user");
221
+
222
+ // Left intact, and no terminal event went out: the row is still pending.
223
+ expect(queue.length).toBe(1);
224
+ expect(events).toEqual([]);
225
+ });
226
+
227
+ test("the enqueuing actor principal can cancel its own queued message", async () => {
228
+ const { events, queue } = seedConversation("conv-actor-2", [
229
+ { requestId: "r1", sourceActorPrincipalId: "actor-owner" },
230
+ ]);
231
+
232
+ await dispatchDelete({
233
+ conversationId: "conv-actor-2",
234
+ requestId: "r1",
235
+ actorPrincipalId: "actor-owner",
236
+ });
237
+
238
+ expect(queue.length).toBe(0);
239
+ expect(events.map((event) => event.type)).toEqual([
240
+ "message_queued_deleted",
241
+ ]);
242
+ });
243
+
244
+ test("a caller with no actor principal is the guardian by construction", async () => {
245
+ const { queue } = seedConversation("conv-actor-3", [
246
+ { requestId: "r1", sourceActorPrincipalId: "actor-owner" },
247
+ ]);
248
+
249
+ // Local/IPC and service principals carry no actorPrincipalId; the CLI
250
+ // must keep being able to cancel a queued message.
251
+ await dispatchDelete({ conversationId: "conv-actor-3", requestId: "r1" });
252
+
253
+ expect(queue.length).toBe(0);
254
+ });
255
+
256
+ test("a daemon-internal enqueue with no recorded requester stays cancellable", async () => {
257
+ const { queue } = seedConversation("conv-actor-4", [{ requestId: "r1" }]);
258
+
259
+ // Agent wakes, subagent notifications and surface actions have no
260
+ // enqueuing actor to compare against.
261
+ await dispatchDelete({
262
+ conversationId: "conv-actor-4",
263
+ requestId: "r1",
264
+ actorPrincipalId: "actor-bystander",
265
+ });
266
+
267
+ expect(queue.length).toBe(0);
268
+ });
269
+ });
270
+
271
+ describe("queued message delete: caller identity normalization", () => {
272
+ test("a padded principal still matches the recorded requester", async () => {
273
+ const { queue } = seedConversation("conv-normalize-1", [
274
+ { requestId: "r1", sourceActorPrincipalId: "actor-owner" },
275
+ ]);
276
+
277
+ // Sibling handlers in this layer all trim the header; without that the
278
+ // padded value is a distinct id and the owner's own cancel 403s.
279
+ await dispatchDelete({
280
+ conversationId: "conv-normalize-1",
281
+ requestId: "r1",
282
+ actorPrincipalId: " actor-owner ",
283
+ });
284
+
285
+ expect(queue.length).toBe(0);
286
+ });
287
+
288
+ test("a whitespace-only principal is treated as absent, not as an id", async () => {
289
+ const { queue } = seedConversation("conv-normalize-2", [
290
+ { requestId: "r1", sourceActorPrincipalId: "actor-owner" },
291
+ ]);
292
+
293
+ await dispatchDelete({
294
+ conversationId: "conv-normalize-2",
295
+ requestId: "r1",
296
+ actorPrincipalId: " ",
297
+ });
298
+
299
+ expect(queue.length).toBe(0);
300
+ });
301
+
302
+ test("the dev-bypass principal does not 403 against a resolved guardian id", async () => {
303
+ // Under DISABLE_HTTP_AUTH the send path stores the REAL local guardian
304
+ // principal (`resolveActorPrincipalIdForLocalGuardian` translates the
305
+ // synthetic `dev-bypass` before enqueue), so a delete that compared the
306
+ // raw header would never match and every local-dev cancel would 403.
307
+ process.env.DISABLE_HTTP_AUTH = "true";
308
+ const { queue } = seedConversation("conv-dev-bypass", [
309
+ { requestId: "r1", sourceActorPrincipalId: "guardian-principal-abc" },
310
+ ]);
311
+
312
+ await dispatchDelete({
313
+ conversationId: "conv-dev-bypass",
314
+ requestId: "r1",
315
+ actorPrincipalId: "dev-bypass",
316
+ });
317
+
318
+ expect(queue.length).toBe(0);
319
+ });
320
+
321
+ test("a real principal is untouched by the dev-bypass translation", async () => {
322
+ process.env.DISABLE_HTTP_AUTH = "true";
323
+ const { queue } = seedConversation("conv-dev-bypass-real", [
324
+ { requestId: "r1", sourceActorPrincipalId: "actor-owner" },
325
+ ]);
326
+
327
+ // Only the literal `dev-bypass` principal is translated, so auth-disabled
328
+ // mode is not itself an authorization bypass.
329
+ await expect(
330
+ dispatchDelete({
331
+ conversationId: "conv-dev-bypass-real",
332
+ requestId: "r1",
333
+ actorPrincipalId: "actor-bystander",
334
+ }),
335
+ ).rejects.toThrow("sent by a different user");
336
+
337
+ expect(queue.length).toBe(1);
338
+ });
339
+ });