@vellumai/assistant 0.10.5-staging.1 → 0.10.5-staging.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/openapi.yaml +81 -4
  2. package/package.json +1 -1
  3. package/src/__tests__/compactor-media-strip.test.ts +177 -0
  4. package/src/__tests__/conversation-enabledplugins-route.test.ts +184 -0
  5. package/src/__tests__/conversation-history-web-search.test.ts +7 -0
  6. package/src/__tests__/conversation-queue.test.ts +48 -0
  7. package/src/__tests__/conversation-routes-hidden-queue.test.ts +407 -0
  8. package/src/__tests__/guardian-question-mode.test.ts +57 -0
  9. package/src/__tests__/list-messages-background-tool-completion.test.ts +6 -6
  10. package/src/__tests__/list-messages-queued.test.ts +25 -0
  11. package/src/__tests__/notification-decision-fallback.test.ts +94 -0
  12. package/src/__tests__/pre-model-call-sanitize.test.ts +1 -1
  13. package/src/__tests__/slack-app-setup-skill-regression.test.ts +24 -0
  14. package/src/__tests__/slack-notification-approval-card.test.ts +89 -2
  15. package/src/__tests__/title-generate-hook.test.ts +16 -0
  16. package/src/agent/ax-tree-compaction.test.ts +4 -1
  17. package/src/agent/loop.ts +1 -176
  18. package/src/api/responses/conversation-message.ts +6 -6
  19. package/src/context/compactor.ts +29 -15
  20. package/src/context/outbound-sanitize.ts +202 -0
  21. package/src/daemon/conversation-agent-loop.ts +8 -0
  22. package/src/daemon/conversation-process.ts +40 -14
  23. package/src/daemon/conversation.ts +2 -0
  24. package/src/daemon/handlers/shared.ts +1 -23
  25. package/src/daemon/host-cu-proxy.ts +1 -1
  26. package/src/daemon/wake-conversation-ops.ts +7 -8
  27. package/src/messaging/providers/slack/send.test.ts +79 -0
  28. package/src/messaging/providers/slack/send.ts +48 -12
  29. package/src/notifications/README.md +1 -1
  30. package/src/notifications/adapters/slack.ts +44 -13
  31. package/src/notifications/broadcaster.ts +21 -15
  32. package/src/notifications/decision-engine.ts +121 -31
  33. package/src/notifications/guardian-question-mode.ts +58 -10
  34. package/src/persistence/conversation-crud.ts +26 -1
  35. package/src/plugin-api/types.ts +7 -0
  36. package/src/plugins/defaults/title-generate/hooks/user-prompt-submit.ts +5 -0
  37. package/src/prompts/templates/SOUL.md +40 -14
  38. package/src/runtime/routes/conversation-list-routes.ts +6 -0
  39. package/src/runtime/routes/conversation-management-routes.ts +71 -0
  40. package/src/runtime/routes/conversation-routes.ts +97 -65
  41. package/src/runtime/routes/inbound-stages/acl-enforcement.test.ts +13 -8
  42. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +24 -9
  43. package/src/runtime/services/__tests__/conversation-serializer.test.ts +19 -0
  44. package/src/runtime/services/conversation-serializer.ts +5 -0
  45. package/src/runtime/slack-reply-session.test.ts +81 -0
  46. package/src/runtime/slack-reply-session.ts +13 -0
  47. package/src/runtime/sync/resource-sync-events.ts +10 -0
  48. package/src/util/__tests__/text-spacing.test.ts +53 -0
  49. package/src/util/text-spacing.ts +53 -0
@@ -0,0 +1,407 @@
1
+ /**
2
+ * Queue-branch contract for hidden sends in POST /v1/messages.
3
+ *
4
+ * A hidden send is a machine signal (e.g. the channel-setup wizard-close
5
+ * marker), not a user decision — when it queues behind an in-flight turn it
6
+ * must NOT supersede pending interactions: no auto-denied confirmations, no
7
+ * steer of a parked ask_question. A visible send in the same state keeps the
8
+ * existing supersede behavior ("the user chose to move on").
9
+ */
10
+ import { afterEach, describe, expect, mock, test } from "bun:test";
11
+
12
+ mock.module("../config/env.js", () => ({ isHttpAuthDisabled: () => true }));
13
+
14
+ mock.module("../config/loader.js", () => ({
15
+ getConfig: () => ({
16
+ ui: {},
17
+ model: "claude-opus-4-7",
18
+ provider: "anthropic",
19
+ memory: { enabled: false },
20
+ rateLimit: { maxRequestsPerMinute: 0 },
21
+ secretDetection: { enabled: false },
22
+ contextWindow: { maxInputTokens: 200000 },
23
+ llm: {
24
+ default: {
25
+ provider: "anthropic",
26
+ model: "claude-opus-4-7",
27
+ maxTokens: 64000,
28
+ effort: "max" as const,
29
+ speed: "standard" as const,
30
+ temperature: null,
31
+ thinking: { enabled: true, streamThinking: true },
32
+ contextWindow: {
33
+ enabled: true,
34
+ maxInputTokens: 200000,
35
+ targetBudgetRatio: 0.3,
36
+ compactThreshold: 0.8,
37
+ summaryBudgetRatio: 0.05,
38
+ },
39
+ },
40
+ profiles: {},
41
+ callSites: {},
42
+ pricingOverrides: [],
43
+ },
44
+ services: {
45
+ inference: {
46
+ mode: "your-own",
47
+ provider: "anthropic",
48
+ model: "claude-opus-4-7",
49
+ },
50
+ "image-generation": {
51
+ mode: "your-own",
52
+ provider: "gemini",
53
+ model: "gemini-3.1-flash-image-preview",
54
+ },
55
+ "web-search": { mode: "your-own", provider: "inference-provider-native" },
56
+ },
57
+ }),
58
+ }));
59
+
60
+ mock.module("../util/logger.js", () => ({
61
+ getLogger: () =>
62
+ new Proxy({} as Record<string, unknown>, {
63
+ get: () => () => {},
64
+ }),
65
+ }));
66
+
67
+ mock.module("../persistence/conversation-key-store.js", () => ({
68
+ getOrCreateConversation: () => ({ conversationId: "conv-hidden-queue" }),
69
+ getConversationByKey: () => null,
70
+ }));
71
+
72
+ mock.module("../runtime/guardian-reply-router.js", () => ({
73
+ routeGuardianReply: async () => ({
74
+ consumed: false,
75
+ decisionApplied: false,
76
+ type: "not_consumed",
77
+ }),
78
+ }));
79
+
80
+ mock.module("../contacts/canonical-guardian-store.js", () => ({
81
+ createCanonicalGuardianRequest: () => ({
82
+ id: "canonical-id",
83
+ requestCode: "ABC123",
84
+ }),
85
+ generateCanonicalRequestCode: () => "ABC123",
86
+ listPendingCanonicalGuardianRequestsByDestinationConversation: () => [],
87
+ listCanonicalGuardianRequests: () => [],
88
+ listPendingRequestsByConversationScope: () => [],
89
+ resolveCanonicalGuardianRequest: () => undefined,
90
+ }));
91
+
92
+ mock.module("../runtime/confirmation-request-guardian-bridge.js", () => ({
93
+ bridgeConfirmationRequestToGuardian: async () => undefined,
94
+ }));
95
+
96
+ mock.module("../persistence/conversation-crud.js", () => ({
97
+ setConversationProcessingStartedAt: () => {},
98
+ isConversationProcessing: () => false,
99
+ addMessage: async (_conversationId: string, role: string) => ({
100
+ id: role === "user" ? "persisted-user-id" : "persisted-assistant-id",
101
+ deduplicated: false,
102
+ }),
103
+ extractImageSourcePaths: () => undefined,
104
+ getConversation: () => null,
105
+ getConversationOverrideProfile: () => undefined,
106
+ getMessages: () => [],
107
+ isHiddenMessageMetadata: (meta: Record<string, unknown> | undefined) =>
108
+ meta?.hidden === true,
109
+ provenanceFromTrustContext: (ctx: unknown) =>
110
+ ctx
111
+ ? { provenanceTrustClass: (ctx as Record<string, unknown>).trustClass }
112
+ : { provenanceTrustClass: "unknown" },
113
+ setConversationOriginChannelIfUnset: () => {},
114
+ setConversationOriginInterfaceIfUnset: () => {},
115
+ setConversationInferenceProfile: () => {},
116
+ setConversationEnabledPlugins: () => {},
117
+ reserveMessage: mock(async () => ({ id: "msg-reserve" })),
118
+ }));
119
+
120
+ mock.module("../persistence/conversation-disk-view.js", () => ({
121
+ syncMessageToDisk: () => {},
122
+ updateMetaFile: () => {},
123
+ }));
124
+
125
+ mock.module("../persistence/attachments-store.js", () => ({
126
+ getAttachmentsByIds: () => [],
127
+ getSourcePathsForAttachments: () => new Map(),
128
+ attachmentExists: () => false,
129
+ linkAttachmentToMessage: () => {},
130
+ attachInlineAttachmentToMessage: () => {},
131
+ validateAttachmentUpload: () => ({ ok: true }),
132
+ }));
133
+
134
+ mock.module("../daemon/conversation-process.js", () => ({
135
+ buildModelInfoEvent: () => ({
136
+ type: "model_info",
137
+ model: "claude-opus-4-7",
138
+ provider: "anthropic",
139
+ configuredProviders: ["anthropic"],
140
+ }),
141
+ isModelSlashCommand: () => false,
142
+ formatCompactResult: () => "",
143
+ }));
144
+
145
+ mock.module("../runtime/local-actor-identity.js", () => ({
146
+ resolveLocalTrustContext: () => ({
147
+ trustClass: "guardian",
148
+ sourceChannel: "vellum",
149
+ }),
150
+ }));
151
+
152
+ mock.module("../runtime/trust-context-resolver.js", () => ({
153
+ resolveTrustContext: () => ({
154
+ trustClass: "guardian",
155
+ sourceChannel: "vellum",
156
+ }),
157
+ withSourceChannel: (sourceChannel: unknown, ctx: unknown) => ({
158
+ ...(ctx as Record<string, unknown>),
159
+ sourceChannel,
160
+ }),
161
+ }));
162
+
163
+ mock.module("../contacts/guardian-delivery-reader.js", () => ({
164
+ getGuardianDelivery: async () => [
165
+ {
166
+ channelType: "vellum",
167
+ contactId: "guardian-contact",
168
+ principalId: "test-user",
169
+ address: "test-user",
170
+ status: "active",
171
+ },
172
+ ],
173
+ }));
174
+
175
+ mock.module("../ipc/gateway-client.js", () => ({
176
+ ipcCall: async () => ({ ok: true }),
177
+ }));
178
+
179
+ import type { Conversation } from "../daemon/conversation.js";
180
+ import {
181
+ deleteConversation,
182
+ setConversation,
183
+ } from "../daemon/conversation-registry.js";
184
+ import * as pendingInteractions from "../runtime/pending-interactions.js";
185
+ import { handleSendMessage } from "../runtime/routes/conversation-routes.js";
186
+ import { callHandler } from "./helpers/call-route-handler.js";
187
+
188
+ const CONV_ID = "conv-hidden-queue";
189
+
190
+ interface BusyConversationSpies {
191
+ conversation: Conversation;
192
+ enqueuedMetadata: () => Record<string, unknown> | undefined;
193
+ denyAllCount: () => number;
194
+ abortCount: () => number;
195
+ agentLoopOptions: () => Record<string, unknown> | undefined;
196
+ }
197
+
198
+ /**
199
+ * A live conversation with a pending tool confirmation. `processing: true`
200
+ * models a mid-turn conversation (queue branch); `false` an idle one whose
201
+ * confirmation outlived its turn (e.g. a guardian approval awaiting a
202
+ * channel reply).
203
+ */
204
+ function makeConversationWithPendingConfirmation(
205
+ processing: boolean,
206
+ ): BusyConversationSpies {
207
+ let enqueuedMetadata: Record<string, unknown> | undefined;
208
+ let denyAllCount = 0;
209
+ let abortCount = 0;
210
+ let agentLoopOptions: Record<string, unknown> | undefined;
211
+ const conversation = {
212
+ conversationId: CONV_ID,
213
+ messages: [],
214
+ abortController: {
215
+ abort: () => {
216
+ abortCount += 1;
217
+ },
218
+ },
219
+ currentRequestId: undefined,
220
+ queue: {
221
+ length: 0,
222
+ promoteToHead: (requestId: string) => ({ requestId }),
223
+ },
224
+ pendingSteerRepair: false,
225
+ setTrustContext: () => {},
226
+ updateClient: () => {},
227
+ emitConfirmationStateChanged: () => {},
228
+ emitActivityState: () => {},
229
+ setTurnChannelContext: () => {},
230
+ setTurnInterfaceContext: () => {},
231
+ getTurnChannelContext: () => null,
232
+ getTurnInterfaceContext: () => null,
233
+ ensureActorScopedHistory: async () => {},
234
+ isProcessing: () => processing,
235
+ setProcessing: () => {},
236
+ hasAnyPendingConfirmation: () => true,
237
+ denyAllPendingConfirmations: () => {
238
+ denyAllCount += 1;
239
+ },
240
+ enqueueMessage: (options: { metadata?: Record<string, unknown> }) => {
241
+ enqueuedMetadata = options.metadata;
242
+ return { queued: true, requestId: "queued-id" };
243
+ },
244
+ persistUserMessage: async () => ({
245
+ id: "persisted-user-id",
246
+ deduplicated: false,
247
+ }),
248
+ runAgentLoop: async (
249
+ _content: string,
250
+ _messageId: string,
251
+ options?: Record<string, unknown>,
252
+ ) => {
253
+ agentLoopOptions = options;
254
+ },
255
+ setPreactivatedSkillIds: () => {},
256
+ drainQueue: async () => {},
257
+ warmPromptCache: () => {},
258
+ getMessages: () => [],
259
+ assistantId: "self",
260
+ trustContext: undefined,
261
+ hasPendingConfirmation: () => false,
262
+ setHostBrowserProxy: () => {},
263
+ setHostCuProxy: () => {},
264
+ setHostAppControlProxy: () => {},
265
+ addPreactivatedSkillId: () => {},
266
+ usageStats: { inputTokens: 1000, outputTokens: 500, estimatedCost: 0.05 },
267
+ } as unknown as Conversation;
268
+ return {
269
+ conversation,
270
+ enqueuedMetadata: () => enqueuedMetadata,
271
+ denyAllCount: () => denyAllCount,
272
+ abortCount: () => abortCount,
273
+ agentLoopOptions: () => agentLoopOptions,
274
+ };
275
+ }
276
+
277
+ function makeBusyConversation(): BusyConversationSpies {
278
+ return makeConversationWithPendingConfirmation(true);
279
+ }
280
+
281
+ function makeRequest(extras: Record<string, unknown> = {}) {
282
+ return new Request("http://localhost/v1/messages", {
283
+ method: "POST",
284
+ headers: {
285
+ "Content-Type": "application/json",
286
+ "x-vellum-actor-principal-id": "test-user",
287
+ "x-vellum-principal-type": "actor",
288
+ },
289
+ body: JSON.stringify({
290
+ conversationKey: "hidden-queue-key",
291
+ content:
292
+ "[User action on channel_setup surface: closed the slack setup wizard]",
293
+ sourceChannel: "vellum",
294
+ interface: "macos",
295
+ ...extras,
296
+ }),
297
+ });
298
+ }
299
+
300
+ function makeDeps(conversation: Conversation) {
301
+ return {
302
+ sendMessageDeps: {
303
+ getOrCreateConversation: async () => conversation,
304
+ assistantEventHub: { publish: async () => {} } as never,
305
+ resolveAttachments: () => [],
306
+ },
307
+ };
308
+ }
309
+
310
+ const registeredRequestIds: string[] = [];
311
+ function registerConfirmation(): void {
312
+ const requestId = `pending-confirmation-${registeredRequestIds.length}`;
313
+ pendingInteractions.register(requestId, {
314
+ conversationId: CONV_ID,
315
+ kind: "confirmation",
316
+ });
317
+ registeredRequestIds.push(requestId);
318
+ }
319
+
320
+ afterEach(() => {
321
+ for (const id of registeredRequestIds) {
322
+ pendingInteractions.resolve(id, "cancelled");
323
+ }
324
+ registeredRequestIds.length = 0;
325
+ deleteConversation(CONV_ID);
326
+ });
327
+
328
+ describe("hidden sends queued behind an in-flight turn", () => {
329
+ test("carry hidden metadata and do NOT supersede pending interactions", async () => {
330
+ const spies = makeBusyConversation();
331
+ setConversation(CONV_ID, spies.conversation);
332
+ registerConfirmation();
333
+
334
+ const res = await callHandler(
335
+ (args) => handleSendMessage(args, makeDeps(spies.conversation)),
336
+ makeRequest({ hidden: true }),
337
+ undefined,
338
+ 202,
339
+ );
340
+
341
+ expect(res.status).toBe(202);
342
+ // Queued with the transcript-suppression flag intact...
343
+ expect(spies.enqueuedMetadata()?.hidden).toBe(true);
344
+ // ...without auto-denying the live approval prompt or aborting the turn:
345
+ // a passive UI event is not the user choosing to move on.
346
+ expect(spies.denyAllCount()).toBe(0);
347
+ expect(spies.abortCount()).toBe(0);
348
+ });
349
+
350
+ test("visible sends in the same state keep the supersede behavior", async () => {
351
+ const spies = makeBusyConversation();
352
+ setConversation(CONV_ID, spies.conversation);
353
+ registerConfirmation();
354
+
355
+ const res = await callHandler(
356
+ (args) => handleSendMessage(args, makeDeps(spies.conversation)),
357
+ makeRequest({ content: "actually, do this instead" }),
358
+ undefined,
359
+ 202,
360
+ );
361
+
362
+ expect(res.status).toBe(202);
363
+ expect(spies.enqueuedMetadata()?.hidden).toBeUndefined();
364
+ // The typed message supersedes: pending confirmations are auto-denied.
365
+ expect(spies.denyAllCount()).toBe(1);
366
+ });
367
+ });
368
+
369
+ describe("hidden sends to an idle conversation with a pending confirmation", () => {
370
+ test("do NOT auto-deny the confirmation and mark the turn as a hidden prompt", async () => {
371
+ const spies = makeConversationWithPendingConfirmation(false);
372
+ setConversation(CONV_ID, spies.conversation);
373
+ registerConfirmation();
374
+
375
+ const res = await callHandler(
376
+ (args) => handleSendMessage(args, makeDeps(spies.conversation)),
377
+ makeRequest({ hidden: true }),
378
+ undefined,
379
+ 202,
380
+ );
381
+
382
+ expect(res.status).toBe(202);
383
+ // The confirmation that outlived its turn (e.g. a guardian approval
384
+ // awaiting a channel reply) survives the machine signal...
385
+ expect(spies.denyAllCount()).toBe(0);
386
+ // ...and the turn is flagged so prompt-as-user-speech consumers (title
387
+ // generation) skip it.
388
+ expect(spies.agentLoopOptions()?.isHiddenPrompt).toBe(true);
389
+ });
390
+
391
+ test("visible sends to the same state keep the idle auto-deny cleanup", async () => {
392
+ const spies = makeConversationWithPendingConfirmation(false);
393
+ setConversation(CONV_ID, spies.conversation);
394
+ registerConfirmation();
395
+
396
+ const res = await callHandler(
397
+ (args) => handleSendMessage(args, makeDeps(spies.conversation)),
398
+ makeRequest({ content: "hello again" }),
399
+ undefined,
400
+ 202,
401
+ );
402
+
403
+ expect(res.status).toBe(202);
404
+ expect(spies.denyAllCount()).toBe(1);
405
+ expect(spies.agentLoopOptions()?.isHiddenPrompt).toBeUndefined();
406
+ });
407
+ });
@@ -9,10 +9,12 @@ import {
9
9
  buildGuardianRequestCodeInstruction,
10
10
  hasGuardianRequestCodeInstruction,
11
11
  parseGuardianQuestionPayload,
12
+ parseInteractiveApprovalPayload,
12
13
  resolveGuardianInstructionModeForRequest,
13
14
  resolveGuardianInstructionModeFromFields,
14
15
  resolveGuardianQuestionInstructionMode,
15
16
  stripConflictingGuardianRequestInstructions,
17
+ stripGuardianRequestCodeInstructions,
16
18
  } from "../notifications/guardian-question-mode.js";
17
19
 
18
20
  describe("guardian-question-mode", () => {
@@ -228,4 +230,59 @@ describe("guardian-question-mode", () => {
228
230
  ),
229
231
  ).toBe("");
230
232
  });
233
+
234
+ test("stripGuardianRequestCodeInstructions removes both-mode directives and bare code mentions", () => {
235
+ const text = [
236
+ "Alice is asking to run ls /tmp.",
237
+ "Approval code: A1B2C3",
238
+ 'Reference code: A1B2C3. Reply "A1B2C3 approve" or "A1B2C3 reject".',
239
+ 'Reply "A1B2C3 <your answer>".',
240
+ ].join("\n");
241
+
242
+ expect(stripGuardianRequestCodeInstructions(text, "A1B2C3")).toBe(
243
+ "Alice is asking to run ls /tmp.",
244
+ );
245
+ });
246
+
247
+ test("stripGuardianRequestCodeInstructions leaves unrelated text and other codes intact", () => {
248
+ const text = 'Approval code: ZZZZZZ. Reply "A1B2C3 approve" if you agree.';
249
+ expect(stripGuardianRequestCodeInstructions(text, "A1B2C3")).toBe(text);
250
+ });
251
+
252
+ test("parseInteractiveApprovalPayload accepts approval-mode payloads with a requestId", () => {
253
+ expect(
254
+ parseInteractiveApprovalPayload({
255
+ requestKind: "tool_grant_request",
256
+ requestId: "req-1",
257
+ requestCode: "A1B2C3",
258
+ questionText: "Allow host bash?",
259
+ toolName: "host_bash",
260
+ }),
261
+ ).not.toBeNull();
262
+ });
263
+
264
+ test("parseInteractiveApprovalPayload rejects answer-mode and unparseable payloads", () => {
265
+ // Answer mode: free-text questions get no Approve/Reject buttons.
266
+ expect(
267
+ parseInteractiveApprovalPayload({
268
+ requestKind: "pending_question",
269
+ requestId: "req-2",
270
+ requestCode: "A1B2C3",
271
+ questionText: "What time works?",
272
+ callSessionId: "call-1",
273
+ activeGuardianRequestCount: 1,
274
+ }),
275
+ ).toBeNull();
276
+
277
+ // Strict-parse failure: missing required pending_question fields.
278
+ expect(
279
+ parseInteractiveApprovalPayload({
280
+ requestKind: "pending_question",
281
+ requestId: "req-3",
282
+ requestCode: "A1B2C3",
283
+ questionText: "Allow send_email?",
284
+ toolName: "send_email",
285
+ }),
286
+ ).toBeNull();
287
+ });
231
288
  });
@@ -4,7 +4,7 @@
4
4
  * A backgrounded bash/host_bash run posts a `<background_event
5
5
  * source="background-tool">` wake on completion, stamped with
6
6
  * `metadata.backgroundToolCompletion`. The history projection must surface
7
- * that structured record (and the `backgroundToolNotification` flag) so the
7
+ * that structured record (and the `backgroundEventNotification` flag) so the
8
8
  * web can rebuild a terminal inline card after a daemon restart.
9
9
  */
10
10
 
@@ -48,7 +48,7 @@ function resetTables() {
48
48
 
49
49
  interface ProjectedMessage {
50
50
  role: string;
51
- backgroundToolNotification?: boolean;
51
+ backgroundEventNotification?: boolean;
52
52
  backgroundToolCompletion?: Record<string, unknown>;
53
53
  }
54
54
 
@@ -76,8 +76,8 @@ describe("handleListMessages background-tool completion projection", () => {
76
76
  };
77
77
  // Mirror persistWakeTriggerMessage: a user-role
78
78
  // `<background_event source="background-tool">` row carrying the structured
79
- // completion. Not flagged `hidden`; the client suppresses it from the
80
- // transcript via `backgroundToolNotification`.
79
+ // completion. The client suppresses it from the transcript via
80
+ // `backgroundEventNotification`.
81
81
  await addMessage(
82
82
  conv.id,
83
83
  "user",
@@ -110,7 +110,7 @@ describe("handleListMessages background-tool completion projection", () => {
110
110
  (m) => m.backgroundToolCompletion !== undefined,
111
111
  );
112
112
  expect(wakeRow).toBeDefined();
113
- expect(wakeRow?.backgroundToolNotification).toBe(true);
113
+ expect(wakeRow?.backgroundEventNotification).toBe(true);
114
114
  expect(wakeRow?.backgroundToolCompletion).toEqual(completion);
115
115
  });
116
116
 
@@ -134,7 +134,7 @@ describe("handleListMessages background-tool completion projection", () => {
134
134
  expect(response.messages).toHaveLength(2);
135
135
  for (const message of response.messages) {
136
136
  expect(message.backgroundToolCompletion).toBeUndefined();
137
- expect(message.backgroundToolNotification).toBeUndefined();
137
+ expect(message.backgroundEventNotification).toBeUndefined();
138
138
  expect(() => ConversationMessageSchema.parse(message)).not.toThrow();
139
139
  }
140
140
  });
@@ -178,6 +178,31 @@ describe("handleListMessages in-memory queue", () => {
178
178
  expect(att?.id).toBe("req-att:attachment:0");
179
179
  });
180
180
 
181
+ test("filters hidden queued messages from the snapshot, keeping positions contiguous", async () => {
182
+ // A hidden send (e.g. the channel-setup wizard-close marker) that queued
183
+ // behind an in-flight turn must not surface as a queued bubble on a
184
+ // reload/reconnect fetch; visible siblings keep 1-based positions.
185
+ const conv = createConversation();
186
+ registerLiveConversation(conv.id, [
187
+ makeQueued({
188
+ requestId: "req-hidden",
189
+ content:
190
+ "[User action on channel_setup surface: closed the slack setup wizard]",
191
+ metadata: { hidden: true },
192
+ }),
193
+ makeQueued({ requestId: "req-visible", content: "a real message" }),
194
+ ]);
195
+
196
+ const response = handleListMessages({
197
+ queryParams: { conversationId: conv.id },
198
+ });
199
+ const body = response as { messages: MessagePayload[] };
200
+
201
+ expect(body.messages).toHaveLength(1);
202
+ expect(body.messages[0].id).toBe("req-visible");
203
+ expect(body.messages[0].queuePosition).toBe(1);
204
+ });
205
+
181
206
  test("does not append queued messages to an older-history page", async () => {
182
207
  // GIVEN history requested with beforeTimestamp (older page)
183
208
  const conv = createConversation();
@@ -285,6 +285,100 @@ describe("notification decision fallback copy", () => {
285
285
  expect(decision.renderedCopy.vellum?.body).toContain('"A1B2C3 reject"');
286
286
  expect(decision.renderedCopy.vellum?.body).not.toContain("<your answer>");
287
287
  });
288
+
289
+ test("slack approval copy is stripped of request-code instructions instead of enforced", async () => {
290
+ const signal = makeSignal({
291
+ contextPayload: {
292
+ requestId: "req-grant-slack-1",
293
+ questionText: "Approve tool: bash — ls /tmp (requested by Alice)",
294
+ requestCode: "A1B2C3",
295
+ requestKind: "tool_grant_request",
296
+ toolName: "bash",
297
+ },
298
+ });
299
+
300
+ const decision = await evaluateSignal(signal, [
301
+ "vellum",
302
+ "slack",
303
+ ] as NotificationChannel[]);
304
+
305
+ expect(decision.fallbackUsed).toBe(true);
306
+ // Vellum keeps the code-reply directive.
307
+ expect(decision.renderedCopy.vellum?.body).toContain('"A1B2C3 approve"');
308
+ // Slack renders Approve/Reject buttons — no code anywhere in its copy.
309
+ expect(decision.renderedCopy.slack?.body).toBe(
310
+ "Approve tool: bash — ls /tmp (requested by Alice)",
311
+ );
312
+ expect(decision.renderedCopy.slack?.body).not.toContain("A1B2C3");
313
+ });
314
+
315
+ test("slack copy strips LLM-authored approval-code phrasing for approval requests", async () => {
316
+ configuredProvider = {
317
+ sendMessage: async () => ({ content: [] }),
318
+ };
319
+ extractedToolUse = {
320
+ name: "record_notification_decision",
321
+ input: {
322
+ shouldNotify: true,
323
+ selectedChannels: ["slack"],
324
+ reasoningSummary: "LLM decision",
325
+ renderedCopy: {
326
+ slack: {
327
+ title: "Tool Grant Request",
328
+ body: "Alice is asking to run ls /tmp.",
329
+ deliveryText:
330
+ 'Alice is asking to run ls /tmp.\nApproval code: A1B2C3\nReference code: A1B2C3. Reply "A1B2C3 approve" or "A1B2C3 reject".',
331
+ },
332
+ },
333
+ dedupeKey: "guardian-question-slack-llm-test",
334
+ confidence: 0.9,
335
+ },
336
+ };
337
+
338
+ const signal = makeSignal({
339
+ contextPayload: {
340
+ requestId: "req-grant-slack-2",
341
+ questionText: "Approve tool: bash — ls /tmp (requested by Alice)",
342
+ requestCode: "A1B2C3",
343
+ requestKind: "tool_grant_request",
344
+ toolName: "bash",
345
+ },
346
+ });
347
+
348
+ const decision = await evaluateSignal(signal, [
349
+ "slack",
350
+ ] as NotificationChannel[]);
351
+
352
+ expect(decision.fallbackUsed).toBe(false);
353
+ expect(decision.renderedCopy.slack?.deliveryText).toBe(
354
+ "Alice is asking to run ls /tmp.",
355
+ );
356
+ expect(decision.renderedCopy.slack?.body).toBe(
357
+ "Alice is asking to run ls /tmp.",
358
+ );
359
+ });
360
+
361
+ test("slack answer-mode questions keep code instructions (no buttons render)", async () => {
362
+ const signal = makeSignal({
363
+ contextPayload: {
364
+ requestId: "req-pending-slack-1",
365
+ questionText: "What is the gate code?",
366
+ requestCode: "A1B2C3",
367
+ requestKind: "pending_question",
368
+ callSessionId: "call-1",
369
+ activeGuardianRequestCount: 1,
370
+ },
371
+ });
372
+
373
+ const decision = await evaluateSignal(signal, [
374
+ "slack",
375
+ ] as NotificationChannel[]);
376
+
377
+ expect(decision.fallbackUsed).toBe(true);
378
+ expect(decision.renderedCopy.slack?.body).toContain(
379
+ '"A1B2C3 <your answer>"',
380
+ );
381
+ });
288
382
  });
289
383
 
290
384
  // ── Access-request instruction enforcement ──────────────────────────────
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
 
3
- import { preModelCallSanitize } from "../agent/loop.js";
3
+ import { preModelCallSanitize } from "../context/outbound-sanitize.js";
4
4
  import type { Message } from "../providers/types.js";
5
5
 
6
6
  /**