@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.
- package/openapi.yaml +81 -4
- package/package.json +1 -1
- package/src/__tests__/compactor-media-strip.test.ts +177 -0
- package/src/__tests__/conversation-enabledplugins-route.test.ts +184 -0
- package/src/__tests__/conversation-history-web-search.test.ts +7 -0
- package/src/__tests__/conversation-queue.test.ts +48 -0
- package/src/__tests__/conversation-routes-hidden-queue.test.ts +407 -0
- package/src/__tests__/guardian-question-mode.test.ts +57 -0
- package/src/__tests__/list-messages-background-tool-completion.test.ts +6 -6
- package/src/__tests__/list-messages-queued.test.ts +25 -0
- package/src/__tests__/notification-decision-fallback.test.ts +94 -0
- package/src/__tests__/pre-model-call-sanitize.test.ts +1 -1
- package/src/__tests__/slack-app-setup-skill-regression.test.ts +24 -0
- package/src/__tests__/slack-notification-approval-card.test.ts +89 -2
- package/src/__tests__/title-generate-hook.test.ts +16 -0
- package/src/agent/ax-tree-compaction.test.ts +4 -1
- package/src/agent/loop.ts +1 -176
- package/src/api/responses/conversation-message.ts +6 -6
- package/src/context/compactor.ts +29 -15
- package/src/context/outbound-sanitize.ts +202 -0
- package/src/daemon/conversation-agent-loop.ts +8 -0
- package/src/daemon/conversation-process.ts +40 -14
- package/src/daemon/conversation.ts +2 -0
- package/src/daemon/handlers/shared.ts +1 -23
- package/src/daemon/host-cu-proxy.ts +1 -1
- package/src/daemon/wake-conversation-ops.ts +7 -8
- package/src/messaging/providers/slack/send.test.ts +79 -0
- package/src/messaging/providers/slack/send.ts +48 -12
- package/src/notifications/README.md +1 -1
- package/src/notifications/adapters/slack.ts +44 -13
- package/src/notifications/broadcaster.ts +21 -15
- package/src/notifications/decision-engine.ts +121 -31
- package/src/notifications/guardian-question-mode.ts +58 -10
- package/src/persistence/conversation-crud.ts +26 -1
- package/src/plugin-api/types.ts +7 -0
- package/src/plugins/defaults/title-generate/hooks/user-prompt-submit.ts +5 -0
- package/src/prompts/templates/SOUL.md +40 -14
- package/src/runtime/routes/conversation-list-routes.ts +6 -0
- package/src/runtime/routes/conversation-management-routes.ts +71 -0
- package/src/runtime/routes/conversation-routes.ts +97 -65
- package/src/runtime/routes/inbound-stages/acl-enforcement.test.ts +13 -8
- package/src/runtime/routes/inbound-stages/acl-enforcement.ts +24 -9
- package/src/runtime/services/__tests__/conversation-serializer.test.ts +19 -0
- package/src/runtime/services/conversation-serializer.ts +5 -0
- package/src/runtime/slack-reply-session.test.ts +81 -0
- package/src/runtime/slack-reply-session.ts +13 -0
- package/src/runtime/sync/resource-sync-events.ts +10 -0
- package/src/util/__tests__/text-spacing.test.ts +53 -0
- 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 `
|
|
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
|
-
|
|
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.
|
|
80
|
-
//
|
|
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?.
|
|
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.
|
|
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 ──────────────────────────────
|