@vellumai/assistant 0.10.5-staging.1 → 0.10.5-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.
- package/openapi.yaml +81 -4
- package/package.json +1 -1
- package/src/__tests__/conversation-enabledplugins-route.test.ts +184 -0
- package/src/__tests__/conversation-queue.test.ts +48 -0
- package/src/__tests__/conversation-routes-hidden-queue.test.ts +407 -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__/slack-app-setup-skill-regression.test.ts +24 -0
- package/src/__tests__/title-generate-hook.test.ts +16 -0
- package/src/api/responses/conversation-message.ts +6 -6
- 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/wake-conversation-ops.ts +7 -8
- 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/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/sync/resource-sync-events.ts +10 -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
|
+
});
|
|
@@ -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();
|
|
@@ -32,6 +32,30 @@ describe("slack-app-setup skill regression", () => {
|
|
|
32
32
|
);
|
|
33
33
|
});
|
|
34
34
|
|
|
35
|
+
test("verifies on the wizard-closed auto-notify instead of manual confirmation", () => {
|
|
36
|
+
// Step 2 must promise the auto-notify rather than asking the user to
|
|
37
|
+
// report back...
|
|
38
|
+
expect(skillContent).toContain(
|
|
39
|
+
"The wizard will auto-notify me when you close it",
|
|
40
|
+
);
|
|
41
|
+
expect(skillContent).not.toContain("let me know when you're done");
|
|
42
|
+
// ...and Step 3 must trigger on the notification, keyed to the exact
|
|
43
|
+
// marker the web client sends on drawer close (see
|
|
44
|
+
// `clients/web/src/domains/chat/channel-setup-close-notify.ts`).
|
|
45
|
+
expect(skillContent).toContain("wizard-closed notification");
|
|
46
|
+
expect(skillContent).toContain(
|
|
47
|
+
"[User action on channel_setup surface: closed the slack setup wizard]",
|
|
48
|
+
);
|
|
49
|
+
// Phone-sized clients open the setup on the Contacts page, which cannot
|
|
50
|
+
// auto-notify on completion — the client signals the relocation with a
|
|
51
|
+
// hand-off marker and the skill must react by asking the user to report
|
|
52
|
+
// back instead of waiting for a wizard-closed notification.
|
|
53
|
+
expect(skillContent).toContain(
|
|
54
|
+
"[User action on channel_setup surface: moved the slack setup to the Contacts page]",
|
|
55
|
+
);
|
|
56
|
+
expect(skillContent).toContain("Hand-off notification");
|
|
57
|
+
});
|
|
58
|
+
|
|
35
59
|
test("retains the Settings clearing path", () => {
|
|
36
60
|
expect(skillContent).toContain(
|
|
37
61
|
"same Slack settings handler used by Settings",
|
|
@@ -185,6 +185,22 @@ describe("title-generate user-prompt-submit hook", () => {
|
|
|
185
185
|
expect(call).not.toHaveProperty("onTitleUpdated");
|
|
186
186
|
});
|
|
187
187
|
|
|
188
|
+
test("skips title generation for hidden machine-signal prompts", async () => {
|
|
189
|
+
// A hidden send (e.g. the channel-setup wizard-close marker) is not user
|
|
190
|
+
// speech — minting a title from it would surface invisible scaffolding
|
|
191
|
+
// text in the sidebar.
|
|
192
|
+
const ctx = makeCtx({
|
|
193
|
+
prompt:
|
|
194
|
+
"[User action on channel_setup surface: closed the slack setup wizard]",
|
|
195
|
+
isHiddenPrompt: true,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
await userPromptSubmit(ctx);
|
|
199
|
+
await flushMacrotasks();
|
|
200
|
+
|
|
201
|
+
expect(queueGenerateConversationTitleMock).toHaveBeenCalledTimes(0);
|
|
202
|
+
});
|
|
203
|
+
|
|
188
204
|
test("does not block: returns before the title job is scheduled", async () => {
|
|
189
205
|
// GIVEN a fresh user prompt submission
|
|
190
206
|
const ctx = makeCtx();
|
|
@@ -567,13 +567,13 @@ export const ConversationMessageSchema = z.object({
|
|
|
567
567
|
contentBlocks: z.array(ConversationContentBlockSchema).optional(),
|
|
568
568
|
subagentNotification: ConversationSubagentNotificationSchema.optional(),
|
|
569
569
|
acpNotification: ConversationAcpNotificationSchema.optional(),
|
|
570
|
-
/** Set on
|
|
571
|
-
*
|
|
572
|
-
*
|
|
573
|
-
*
|
|
574
|
-
|
|
570
|
+
/** Set on any persisted `<background_event source="...">` wake trigger row.
|
|
571
|
+
* Like the subagent/ACP notifications, the row stays in state (the LLM reads
|
|
572
|
+
* it) but is filtered from the rendered transcript — the user-facing wake
|
|
573
|
+
* card carries the status. */
|
|
574
|
+
backgroundEventNotification: z.boolean().optional(),
|
|
575
575
|
/** Structured completion of a backgrounded bash/host_bash run, stamped on the
|
|
576
|
-
* same persisted background-event wake row as `
|
|
576
|
+
* same persisted background-event wake row as `backgroundEventNotification`.
|
|
577
577
|
* Lets the web reconstruct a terminal inline card after a daemon restart
|
|
578
578
|
* (the in-memory completed ring does not survive restarts). `id` equals the
|
|
579
579
|
* spawning tool call's `{backgrounded,id}` id. */
|
|
@@ -261,6 +261,13 @@ export async function runAgentLoopImpl(
|
|
|
261
261
|
isInteractive?: boolean;
|
|
262
262
|
isUserMessage?: boolean;
|
|
263
263
|
titleText?: string;
|
|
264
|
+
/**
|
|
265
|
+
* True when the triggering message is a transcript-suppressed machine
|
|
266
|
+
* signal (`metadata.hidden`). Forwarded to the user-prompt-submit hook
|
|
267
|
+
* context so prompt-as-user-speech consumers (title generation) skip
|
|
268
|
+
* the turn.
|
|
269
|
+
*/
|
|
270
|
+
isHiddenPrompt?: boolean;
|
|
264
271
|
/**
|
|
265
272
|
* LLM call-site identifier threaded into the per-call provider config.
|
|
266
273
|
* Adapter callers (heartbeat, filing, scheduler, etc.) pass their own
|
|
@@ -903,6 +910,7 @@ export async function runAgentLoopImpl(
|
|
|
903
910
|
userMessageId,
|
|
904
911
|
requestId: reqId,
|
|
905
912
|
prompt: options?.titleText ?? content,
|
|
913
|
+
isHiddenPrompt: options?.isHiddenPrompt === true,
|
|
906
914
|
originalMessages: ctx.messages,
|
|
907
915
|
latestMessages: ctx.messages,
|
|
908
916
|
logger: rlog,
|