@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
|
@@ -23,6 +23,7 @@ import { extractPreferences } from "../notifications/preference-extractor.js";
|
|
|
23
23
|
import { createPreference } from "../notifications/preferences-store.js";
|
|
24
24
|
import {
|
|
25
25
|
addMessage,
|
|
26
|
+
isHiddenMessageMetadata,
|
|
26
27
|
provenanceFromTrustContext,
|
|
27
28
|
setConversationOriginChannelIfUnset,
|
|
28
29
|
setConversationOriginInterfaceIfUnset,
|
|
@@ -62,21 +63,29 @@ const log = getLogger("conversation-process");
|
|
|
62
63
|
|
|
63
64
|
/**
|
|
64
65
|
* Daemon-injected run lifecycle notifications — subagent (`subagentNotification`),
|
|
65
|
-
* ACP run (`acpNotification`), and
|
|
66
|
-
* `<background_event source="
|
|
67
|
-
* parent conversation so the orchestrator wakes and reads the
|
|
68
|
-
* they are internal scaffolding: the user sees the
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
66
|
+
* ACP run (`acpNotification`), and any wake trigger (the persisted
|
|
67
|
+
* `<background_event source="...">` row every wake reads) — are persisted into
|
|
68
|
+
* the parent conversation so the orchestrator wakes and reads the trigger, but
|
|
69
|
+
* they are internal scaffolding: the user sees the wake through its inline card
|
|
70
|
+
* ("Conversation Woke", or a terminal card for a backgrounded bash run), not a
|
|
71
|
+
* chat turn. Skip the `user_message_echo` broadcast for these so they never
|
|
72
|
+
* render as a live user bubble; the persisted row is filtered from the rendered
|
|
73
|
+
* transcript on the client.
|
|
74
|
+
*
|
|
75
|
+
* Messages explicitly flagged `hidden` (a hidden `POST /messages` send that
|
|
76
|
+
* queued behind an in-flight turn, e.g. the channel-setup wizard-close
|
|
77
|
+
* marker) are suppressed the same way — the immediate route path already
|
|
78
|
+
* skips their echo, and the persisted `hidden` metadata keeps them out of
|
|
79
|
+
* the fetched transcript.
|
|
72
80
|
*/
|
|
73
|
-
function
|
|
81
|
+
function isEchoSuppressedUserMessage(
|
|
74
82
|
metadata: Record<string, unknown> | undefined,
|
|
75
83
|
): boolean {
|
|
76
84
|
return (
|
|
85
|
+
isHiddenMessageMetadata(metadata) ||
|
|
77
86
|
metadata?.subagentNotification != null ||
|
|
78
87
|
metadata?.acpNotification != null ||
|
|
79
|
-
metadata?.backgroundEventSource === "
|
|
88
|
+
typeof metadata?.backgroundEventSource === "string"
|
|
80
89
|
);
|
|
81
90
|
}
|
|
82
91
|
|
|
@@ -521,6 +530,7 @@ async function drainSingleMessage(
|
|
|
521
530
|
}
|
|
522
531
|
: {}),
|
|
523
532
|
...(next.metadata?.automated ? { automated: true } : {}),
|
|
533
|
+
...(next.metadata?.hidden === true ? { hidden: true } : {}),
|
|
524
534
|
...(Object.keys(drainImageSourcePaths).length > 0
|
|
525
535
|
? { imageSourcePaths: drainImageSourcePaths }
|
|
526
536
|
: {}),
|
|
@@ -879,7 +889,7 @@ async function drainSingleMessage(
|
|
|
879
889
|
|
|
880
890
|
// Broadcast the user message to all hub subscribers so passive devices
|
|
881
891
|
// see the user turn before the assistant reply starts streaming.
|
|
882
|
-
if (!
|
|
892
|
+
if (!isEchoSuppressedUserMessage(next.metadata)) {
|
|
883
893
|
next.onEvent({
|
|
884
894
|
type: "user_message_echo",
|
|
885
895
|
text: resolvedContent,
|
|
@@ -897,7 +907,10 @@ async function drainSingleMessage(
|
|
|
897
907
|
|
|
898
908
|
// Fire-and-forget: detect notification preferences in the queued message
|
|
899
909
|
// and persist any that are found, mirroring the logic in processMessage.
|
|
900
|
-
|
|
910
|
+
// Hidden rows are machine signals, not user speech — running the detector
|
|
911
|
+
// on them burns an LLM call per signal and risks persisting a bogus
|
|
912
|
+
// preference from text the user never typed.
|
|
913
|
+
if (conversation.assistantId && !isHiddenMessageMetadata(next.metadata)) {
|
|
901
914
|
extractPreferences(resolvedContent)
|
|
902
915
|
.then((result) => {
|
|
903
916
|
if (!result.detected) return;
|
|
@@ -932,11 +945,14 @@ async function drainSingleMessage(
|
|
|
932
945
|
isInteractive?: boolean;
|
|
933
946
|
isUserMessage?: boolean;
|
|
934
947
|
titleText?: string;
|
|
948
|
+
isHiddenPrompt?: boolean;
|
|
935
949
|
} = { isUserMessage: true };
|
|
936
950
|
if (next.isInteractive !== undefined)
|
|
937
951
|
drainLoopOptions.isInteractive = next.isInteractive;
|
|
938
952
|
if (agentLoopContent !== resolvedContent)
|
|
939
953
|
drainLoopOptions.titleText = resolvedContent;
|
|
954
|
+
if (isHiddenMessageMetadata(next.metadata))
|
|
955
|
+
drainLoopOptions.isHiddenPrompt = true;
|
|
940
956
|
|
|
941
957
|
conversation
|
|
942
958
|
.runAgentLoop(agentLoopContent, userMessageId, {
|
|
@@ -1233,7 +1249,7 @@ async function drainBatch(
|
|
|
1233
1249
|
|
|
1234
1250
|
// Broadcast the user message to all hub subscribers so passive devices
|
|
1235
1251
|
// see each batched user turn before the assistant reply starts streaming.
|
|
1236
|
-
if (!
|
|
1252
|
+
if (!isEchoSuppressedUserMessage(qm.metadata)) {
|
|
1237
1253
|
qm.onEvent({
|
|
1238
1254
|
type: "user_message_echo",
|
|
1239
1255
|
text: qmContent,
|
|
@@ -1254,8 +1270,9 @@ async function drainBatch(
|
|
|
1254
1270
|
successfulBatch.push(qm);
|
|
1255
1271
|
|
|
1256
1272
|
// Fire-and-forget: detect notification preferences in each batched user
|
|
1257
|
-
// message and persist any that are found, mirroring drainSingleMessage
|
|
1258
|
-
|
|
1273
|
+
// message and persist any that are found, mirroring drainSingleMessage
|
|
1274
|
+
// (including its hidden-row exclusion).
|
|
1275
|
+
if (conversation.assistantId && !isHiddenMessageMetadata(qm.metadata)) {
|
|
1259
1276
|
extractPreferences(qmContent)
|
|
1260
1277
|
.then((result) => {
|
|
1261
1278
|
if (!result.detected) return;
|
|
@@ -1342,6 +1359,7 @@ async function drainBatch(
|
|
|
1342
1359
|
isInteractive?: boolean;
|
|
1343
1360
|
isUserMessage?: boolean;
|
|
1344
1361
|
titleText?: string;
|
|
1362
|
+
isHiddenPrompt?: boolean;
|
|
1345
1363
|
} = { isUserMessage: true };
|
|
1346
1364
|
// Source interactive flag from the last successfully-persisted sibling so
|
|
1347
1365
|
// a trailing failed tail doesn't flip the agent loop's interactivity.
|
|
@@ -1351,6 +1369,14 @@ async function drainBatch(
|
|
|
1351
1369
|
: undefined;
|
|
1352
1370
|
if (lastSuccessfulBatchEntry?.isInteractive !== undefined)
|
|
1353
1371
|
drainLoopOptions.isInteractive = lastSuccessfulBatchEntry.isInteractive;
|
|
1372
|
+
// A batch counts as a hidden turn only when every message in it is a
|
|
1373
|
+
// hidden machine signal — one genuine user prompt justifies the
|
|
1374
|
+
// prompt-as-user-speech consumers (title generation).
|
|
1375
|
+
if (
|
|
1376
|
+
successfulBatch.length > 0 &&
|
|
1377
|
+
successfulBatch.every((qm) => isHiddenMessageMetadata(qm.metadata))
|
|
1378
|
+
)
|
|
1379
|
+
drainLoopOptions.isHiddenPrompt = true;
|
|
1354
1380
|
|
|
1355
1381
|
// Fire-and-forget: runAgentLoop's finally block recursively calls drainQueue
|
|
1356
1382
|
// when this run completes. Mirrors drainSingleMessage.
|
|
@@ -2105,6 +2105,8 @@ export class Conversation {
|
|
|
2105
2105
|
isInteractive?: boolean;
|
|
2106
2106
|
isUserMessage?: boolean;
|
|
2107
2107
|
titleText?: string;
|
|
2108
|
+
/** See {@link runAgentLoopImpl} — hidden machine-signal turn marker. */
|
|
2109
|
+
isHiddenPrompt?: boolean;
|
|
2108
2110
|
callSite?: LLMCallSite;
|
|
2109
2111
|
/**
|
|
2110
2112
|
* Optional ad-hoc inference-profile override applied to every LLM call
|
|
@@ -267,8 +267,9 @@ export async function persistWakeTailMessage(
|
|
|
267
267
|
*
|
|
268
268
|
* Mirrors {@link persistWakeTailMessage}'s channel/interface/provenance
|
|
269
269
|
* metadata, but stamps `kind: "background-event"` (+ the originating `source`)
|
|
270
|
-
* for identification
|
|
271
|
-
* output)
|
|
270
|
+
* for identification and skips indexing (the body may carry untrusted command
|
|
271
|
+
* output). The `backgroundEventSource` stamp lets clients hide this row from the
|
|
272
|
+
* rendered transcript — the user-facing wake card carries the status instead.
|
|
272
273
|
*/
|
|
273
274
|
export async function persistWakeTriggerMessage(
|
|
274
275
|
conversation: Conversation,
|
|
@@ -312,12 +313,10 @@ export async function persistWakeTriggerMessage(
|
|
|
312
313
|
"wake trigger persist: syncMessageToDisk failed (non-fatal)",
|
|
313
314
|
);
|
|
314
315
|
}
|
|
315
|
-
// Tell connected clients the message list changed so they refetch
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
// another client, would not show the <background_event> row until a manual
|
|
320
|
-
// reload.
|
|
316
|
+
// Tell connected clients the message list changed so they refetch. The
|
|
317
|
+
// trigger row itself is hidden from the transcript (clients drop rows carrying
|
|
318
|
+
// `backgroundEventSource`), but the refetch keeps other clients' state in sync
|
|
319
|
+
// and lets a wake that produces no assistant stream still settle cleanly.
|
|
321
320
|
try {
|
|
322
321
|
publishConversationMessagesChanged(conversation.conversationId);
|
|
323
322
|
} catch (err) {
|
|
@@ -177,6 +177,16 @@ export const messageMetadataSchema = z
|
|
|
177
177
|
provenanceGuardianExternalUserId: z.string().optional(),
|
|
178
178
|
provenanceRequesterIdentifier: z.string().optional(),
|
|
179
179
|
automated: z.boolean().optional(),
|
|
180
|
+
/**
|
|
181
|
+
* Transcript-suppression flag: the row is a machine signal (e.g. the
|
|
182
|
+
* channel-setup wizard-close marker, the onboarding greeting kickoff),
|
|
183
|
+
* persisted and LLM-visible but never rendered as a user message. Test
|
|
184
|
+
* with {@link isHiddenMessageMetadata} — hidden rows are filtered from
|
|
185
|
+
* list-messages and queued snapshots, skip the user_message_echo, and
|
|
186
|
+
* are excluded from search/memory indexing and other consumers that
|
|
187
|
+
* treat message text as organic user input.
|
|
188
|
+
*/
|
|
189
|
+
hidden: z.boolean().optional(),
|
|
180
190
|
/**
|
|
181
191
|
* Structured terminal record stamped onto a `<background_event
|
|
182
192
|
* source="background-tool">` wake so the web can rebuild the inline
|
|
@@ -212,6 +222,19 @@ export const messageMetadataSchema = z
|
|
|
212
222
|
/** Validated shape of a persisted message's `metadata` column. */
|
|
213
223
|
export type MessageMetadata = z.infer<typeof messageMetadataSchema>;
|
|
214
224
|
|
|
225
|
+
/**
|
|
226
|
+
* Shared predicate for the transcript-suppression flag on user-message
|
|
227
|
+
* metadata (see the `hidden` field on {@link messageMetadataSchema}). One
|
|
228
|
+
* definition so the sites that must agree — echo suppression, list-messages
|
|
229
|
+
* filtering, queued-snapshot filtering, indexing exclusion, and downstream
|
|
230
|
+
* consumers of message text — cannot drift.
|
|
231
|
+
*/
|
|
232
|
+
export function isHiddenMessageMetadata(
|
|
233
|
+
metadata: Record<string, unknown> | null | undefined,
|
|
234
|
+
): boolean {
|
|
235
|
+
return metadata?.hidden === true;
|
|
236
|
+
}
|
|
237
|
+
|
|
215
238
|
/**
|
|
216
239
|
* Parse a persisted message's metadata JSON against {@link messageMetadataSchema}
|
|
217
240
|
* — the single source of truth for its shape — returning the validated fields,
|
|
@@ -1694,7 +1717,9 @@ export async function addMessage(
|
|
|
1694
1717
|
|
|
1695
1718
|
const message = inserted;
|
|
1696
1719
|
|
|
1697
|
-
|
|
1720
|
+
// Hidden rows are machine signals suppressed from the transcript — they
|
|
1721
|
+
// must not surface as search excerpts or be embedded into memory either.
|
|
1722
|
+
if (!skipIndexing && !isHiddenMessageMetadata(metadata)) {
|
|
1698
1723
|
// Message-content lexical indexing is host infrastructure, invoked
|
|
1699
1724
|
// directly (not through the memory hook seam, whose active side effects go
|
|
1700
1725
|
// inert while the memory plugin is disabled — search indexing must not).
|
package/src/plugin-api/types.ts
CHANGED
|
@@ -231,6 +231,13 @@ export interface UserPromptSubmitContext {
|
|
|
231
231
|
* it from the message arrays.
|
|
232
232
|
*/
|
|
233
233
|
readonly prompt: string;
|
|
234
|
+
/**
|
|
235
|
+
* True when the triggering message is a transcript-suppressed machine
|
|
236
|
+
* signal (`metadata.hidden` — e.g. the channel-setup wizard-close marker)
|
|
237
|
+
* rather than something the user typed. Hooks that treat `prompt` as
|
|
238
|
+
* user speech (e.g. title generation) should skip these turns.
|
|
239
|
+
*/
|
|
240
|
+
readonly isHiddenPrompt?: boolean;
|
|
234
241
|
/**
|
|
235
242
|
* The user's original message list, immutable for the hook. Plugins
|
|
236
243
|
* may snapshot or compare against this but MUST NOT mutate it.
|
|
@@ -19,6 +19,11 @@ import { getConversation } from "../../../../persistence/conversation-crud.js";
|
|
|
19
19
|
import { queueGenerateConversationTitle } from "../../../../persistence/conversation-title-service.js";
|
|
20
20
|
|
|
21
21
|
const userPromptSubmit: HookFunction<UserPromptSubmitContext> = async (ctx) => {
|
|
22
|
+
// Hidden machine signals (e.g. the channel-setup wizard-close marker) are
|
|
23
|
+
// not user speech — a title minted from one would surface invisible
|
|
24
|
+
// scaffolding text in the sidebar, and the LLM call is wasted.
|
|
25
|
+
if (ctx.isHiddenPrompt) return;
|
|
26
|
+
|
|
22
27
|
// System conversations (background/scheduled) carry a deterministic title
|
|
23
28
|
// from bootstrap. Their own job prompts arrive as non-interactive turns and
|
|
24
29
|
// must not spend an LLM call on a title nobody reads — only a genuine user
|
|
@@ -117,6 +117,12 @@ export const conversationSummarySchema = z.object({
|
|
|
117
117
|
*/
|
|
118
118
|
surfacedAt: z.number().optional(),
|
|
119
119
|
inferenceProfile: z.string().optional(),
|
|
120
|
+
/**
|
|
121
|
+
* Plugin-id list scoping this chat to a subset of installed plugins.
|
|
122
|
+
* Absent when there is no per-chat restriction (default: all enabled
|
|
123
|
+
* plugins); an explicit `[]` means the user cleared all optional plugins.
|
|
124
|
+
*/
|
|
125
|
+
enabledPlugins: z.array(z.string()).nullable().optional(),
|
|
120
126
|
/**
|
|
121
127
|
* True when the agent loop is currently mid-turn for this conversation.
|
|
122
128
|
* Mirrors the in-memory `Conversation.isProcessing()` flag on the daemon
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* POST /v1/conversations/switch — switch to an existing conversation
|
|
6
6
|
* POST /v1/conversations/fork — fork an existing conversation
|
|
7
7
|
* PUT /v1/conversations/:id/inference-profile — set per-conversation inference profile
|
|
8
|
+
* PUT /v1/conversations/:id/enabledplugins — set per-conversation plugin scope
|
|
8
9
|
* PATCH /v1/conversations/:id/name — rename a conversation
|
|
9
10
|
* DELETE /v1/conversations — clear all conversations
|
|
10
11
|
* POST /v1/conversations/:id/wipe — wipe conversation and revert memory
|
|
@@ -20,6 +21,7 @@
|
|
|
20
21
|
|
|
21
22
|
import { z } from "zod";
|
|
22
23
|
|
|
24
|
+
import { findConversation } from "../../daemon/conversation-registry.js";
|
|
23
25
|
import { destroyActiveConversation } from "../../daemon/conversation-store.js";
|
|
24
26
|
import {
|
|
25
27
|
cancelGeneration,
|
|
@@ -37,6 +39,7 @@ import {
|
|
|
37
39
|
deleteConversation,
|
|
38
40
|
forkConversation as forkConversationInStore,
|
|
39
41
|
getConversation,
|
|
42
|
+
setConversationEnabledPlugins,
|
|
40
43
|
setConversationSurfaced,
|
|
41
44
|
unarchiveConversation,
|
|
42
45
|
updateConversationTitle,
|
|
@@ -54,6 +57,7 @@ import { getLogger } from "../../util/logger.js";
|
|
|
54
57
|
import { ACTOR_PRINCIPALS, LOCAL_PRINCIPALS } from "../auth/route-policy.js";
|
|
55
58
|
import { buildConversationDetailResponse } from "../services/conversation-serializer.js";
|
|
56
59
|
import {
|
|
60
|
+
publishConversationEnabledPluginsChanged,
|
|
57
61
|
publishConversationListAndMetadataChanged,
|
|
58
62
|
publishConversationListChanged,
|
|
59
63
|
publishConversationTitleChanged,
|
|
@@ -222,6 +226,49 @@ async function handleSetInferenceProfile({
|
|
|
222
226
|
return result;
|
|
223
227
|
}
|
|
224
228
|
|
|
229
|
+
async function handleUpdateConversationEnabledPlugins({
|
|
230
|
+
pathParams = {},
|
|
231
|
+
body = {},
|
|
232
|
+
headers,
|
|
233
|
+
}: RouteHandlerArgs) {
|
|
234
|
+
const enabledPlugins = body.enabledPlugins as string[] | null | undefined;
|
|
235
|
+
// The field is required; `null` is the explicit "clear the scope" signal.
|
|
236
|
+
// A missing field (malformed/empty body) must not silently clear the scope.
|
|
237
|
+
if (enabledPlugins === undefined) {
|
|
238
|
+
throw new BadRequestError(
|
|
239
|
+
"enabledPlugins is required (use null to clear the scope)",
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
if (
|
|
243
|
+
enabledPlugins !== null &&
|
|
244
|
+
(!Array.isArray(enabledPlugins) ||
|
|
245
|
+
enabledPlugins.some((p) => typeof p !== "string"))
|
|
246
|
+
) {
|
|
247
|
+
throw new BadRequestError(
|
|
248
|
+
"enabledPlugins must be an array of strings or null",
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const resolvedId = resolveConversationId(pathParams.id!) ?? pathParams.id!;
|
|
253
|
+
const conversation = getConversation(resolvedId);
|
|
254
|
+
if (!conversation) {
|
|
255
|
+
throw new NotFoundError(`Conversation ${pathParams.id} not found`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// `null` clears the per-chat scope back to the default (all enabled
|
|
259
|
+
// plugins); a `string[]` scopes the chat to those plugin ids.
|
|
260
|
+
const nextEnabledPlugins = enabledPlugins;
|
|
261
|
+
setConversationEnabledPlugins(resolvedId, nextEnabledPlugins);
|
|
262
|
+
findConversation(resolvedId)?.setEnabledPlugins(nextEnabledPlugins);
|
|
263
|
+
|
|
264
|
+
publishConversationEnabledPluginsChanged(
|
|
265
|
+
resolvedId,
|
|
266
|
+
headers?.["x-vellum-client-id"]?.trim() || undefined,
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
return { conversationId: resolvedId, enabledPlugins: nextEnabledPlugins };
|
|
270
|
+
}
|
|
271
|
+
|
|
225
272
|
function handleRenameConversation({
|
|
226
273
|
pathParams = {},
|
|
227
274
|
body = {},
|
|
@@ -653,6 +700,30 @@ export const ROUTES: RouteDefinition[] = [
|
|
|
653
700
|
}),
|
|
654
701
|
handler: handleSetInferenceProfile,
|
|
655
702
|
},
|
|
703
|
+
{
|
|
704
|
+
operationId: "conversations_by_id_enabledplugins_put",
|
|
705
|
+
endpoint: "conversations/:id/enabledplugins",
|
|
706
|
+
method: "PUT",
|
|
707
|
+
policy: {
|
|
708
|
+
requiredScopes: ["chat.write"],
|
|
709
|
+
allowedPrincipalTypes: ACTOR_PRINCIPALS,
|
|
710
|
+
},
|
|
711
|
+
summary: "Set conversation enabled plugins",
|
|
712
|
+
description:
|
|
713
|
+
"Scope a single conversation to a subset of installed plugins " +
|
|
714
|
+
"(first-party defaults are always available). Pass null to clear the " +
|
|
715
|
+
"scope back to the default (all enabled plugins).",
|
|
716
|
+
tags: ["conversations"],
|
|
717
|
+
pathParams: [{ name: "id", type: "uuid" }],
|
|
718
|
+
requestBody: z.object({
|
|
719
|
+
enabledPlugins: z.array(z.string()).nullable(),
|
|
720
|
+
}),
|
|
721
|
+
responseBody: z.object({
|
|
722
|
+
conversationId: z.string(),
|
|
723
|
+
enabledPlugins: z.array(z.string()).nullable(),
|
|
724
|
+
}),
|
|
725
|
+
handler: handleUpdateConversationEnabledPlugins,
|
|
726
|
+
},
|
|
656
727
|
{
|
|
657
728
|
operationId: "renameConversation",
|
|
658
729
|
endpoint: "conversations/:id/name",
|
|
@@ -106,6 +106,7 @@ import {
|
|
|
106
106
|
getMessagesPaginated,
|
|
107
107
|
hasMessages,
|
|
108
108
|
isConversationProcessing,
|
|
109
|
+
isHiddenMessageMetadata,
|
|
109
110
|
type MessageRow,
|
|
110
111
|
provenanceFromTrustContext,
|
|
111
112
|
setConversationEnabledPlugins,
|
|
@@ -349,8 +350,9 @@ function emitCannedMessageComplete(
|
|
|
349
350
|
function isHiddenMessage(metadata: string | null): boolean {
|
|
350
351
|
if (!metadata) return false;
|
|
351
352
|
try {
|
|
352
|
-
|
|
353
|
-
|
|
353
|
+
return isHiddenMessageMetadata(
|
|
354
|
+
JSON.parse(metadata) as Record<string, unknown>,
|
|
355
|
+
);
|
|
354
356
|
} catch {
|
|
355
357
|
return false;
|
|
356
358
|
}
|
|
@@ -633,43 +635,51 @@ function buildQueuedMessagePayloads(
|
|
|
633
635
|
const conversation = findConversation(conversationId);
|
|
634
636
|
if (!conversation) return [];
|
|
635
637
|
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
638
|
+
// Hidden sends are suppressed from the transcript at every stage — echo,
|
|
639
|
+
// persisted row, and here the in-memory queue window: a latest-page fetch
|
|
640
|
+
// while the item still awaits drain must not surface it as a queued bubble.
|
|
641
|
+
return conversation
|
|
642
|
+
.snapshotQueuedMessages()
|
|
643
|
+
.filter((item) => !isHiddenMessageMetadata(item.metadata))
|
|
644
|
+
.map((item, index) => {
|
|
645
|
+
const text = item.displayContent ?? item.content;
|
|
646
|
+
const attachments: RuntimeAttachmentMetadata[] = item.attachments.map(
|
|
647
|
+
(a, idx) => ({
|
|
648
|
+
id: a.id ?? `${item.requestId}:attachment:${idx}`,
|
|
649
|
+
filename: a.filename,
|
|
650
|
+
mimeType: a.mimeType,
|
|
651
|
+
sizeBytes:
|
|
652
|
+
a.sizeBytes ?? (a.data ? Math.floor((a.data.length * 3) / 4) : 0),
|
|
653
|
+
kind: classifyKind(a.mimeType),
|
|
654
|
+
...(a.mimeType.startsWith("image/") && a.data
|
|
655
|
+
? { data: a.data }
|
|
656
|
+
: {}),
|
|
657
|
+
...(a.thumbnailData ? { thumbnailData: a.thumbnailData } : {}),
|
|
658
|
+
}),
|
|
659
|
+
);
|
|
650
660
|
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
661
|
+
const contentBlocks: ConversationContentBlock[] = [];
|
|
662
|
+
if (text.length > 0) contentBlocks.push({ type: "text", text });
|
|
663
|
+
for (const attachment of attachments) {
|
|
664
|
+
contentBlocks.push({ type: "attachment", attachment });
|
|
665
|
+
}
|
|
656
666
|
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
667
|
+
return {
|
|
668
|
+
// The queued message has no DB row yet; its requestId is the stable
|
|
669
|
+
// identifier the queued-message delete/steer endpoints key on.
|
|
670
|
+
id: item.requestId,
|
|
671
|
+
role: "user" as const,
|
|
672
|
+
content: text,
|
|
673
|
+
timestamp: new Date(item.sentAt).toISOString(),
|
|
674
|
+
attachments,
|
|
675
|
+
...(contentBlocks.length > 0 ? { contentBlocks } : {}),
|
|
676
|
+
...(item.clientMessageId
|
|
677
|
+
? { clientMessageId: item.clientMessageId }
|
|
678
|
+
: {}),
|
|
679
|
+
queueStatus: "queued" as const,
|
|
680
|
+
queuePosition: index + 1,
|
|
681
|
+
};
|
|
682
|
+
});
|
|
673
683
|
}
|
|
674
684
|
|
|
675
685
|
export function handleListMessages({
|
|
@@ -846,19 +856,19 @@ export function handleListMessages({
|
|
|
846
856
|
}
|
|
847
857
|
| undefined;
|
|
848
858
|
let acpNotification: { acpSessionId: string; agent?: string } | undefined;
|
|
849
|
-
let
|
|
859
|
+
let backgroundEventNotification: boolean | undefined;
|
|
850
860
|
let backgroundToolCompletion: ConversationMessage["backgroundToolCompletion"];
|
|
851
861
|
if (msg.metadata) {
|
|
852
862
|
try {
|
|
853
863
|
const meta = JSON.parse(msg.metadata);
|
|
854
864
|
if (typeof meta.sentAt === "number") sentAt = meta.sentAt;
|
|
855
|
-
//
|
|
856
|
-
//
|
|
857
|
-
//
|
|
858
|
-
//
|
|
859
|
-
// the status.
|
|
860
|
-
if (meta.backgroundEventSource === "
|
|
861
|
-
|
|
865
|
+
// Every wake persists a `<background_event source="...">` trigger row
|
|
866
|
+
// (see `persistWakeTriggerMessage`) that the LLM reads. Flag any such
|
|
867
|
+
// row so clients hide it from the transcript like a subagent/ACP
|
|
868
|
+
// notification — the user-facing "Conversation Woke" card (or, for a
|
|
869
|
+
// backgrounded bash run, the inline terminal card) carries the status.
|
|
870
|
+
if (typeof meta.backgroundEventSource === "string") {
|
|
871
|
+
backgroundEventNotification = true;
|
|
862
872
|
}
|
|
863
873
|
// `persistWakeTriggerMessage` stamps the structured completion onto the
|
|
864
874
|
// same wake row, letting the web rebuild a terminal inline card from
|
|
@@ -921,7 +931,7 @@ export function handleListMessages({
|
|
|
921
931
|
sentAt,
|
|
922
932
|
subagentNotification,
|
|
923
933
|
acpNotification,
|
|
924
|
-
|
|
934
|
+
backgroundEventNotification,
|
|
925
935
|
backgroundToolCompletion,
|
|
926
936
|
slackMessage,
|
|
927
937
|
clientMessageId: msg.clientMessageId ?? undefined,
|
|
@@ -1116,8 +1126,8 @@ export function handleListMessages({
|
|
|
1116
1126
|
? { subagentNotification: m.subagentNotification }
|
|
1117
1127
|
: {}),
|
|
1118
1128
|
...(m.acpNotification ? { acpNotification: m.acpNotification } : {}),
|
|
1119
|
-
...(m.
|
|
1120
|
-
? {
|
|
1129
|
+
...(m.backgroundEventNotification
|
|
1130
|
+
? { backgroundEventNotification: true }
|
|
1121
1131
|
: {}),
|
|
1122
1132
|
...(m.backgroundToolCompletion
|
|
1123
1133
|
? { backgroundToolCompletion: m.backgroundToolCompletion }
|
|
@@ -1988,6 +1998,10 @@ export async function handleSendMessage(
|
|
|
1988
1998
|
userMessageInterface: sourceInterface,
|
|
1989
1999
|
assistantMessageInterface: sourceInterface,
|
|
1990
2000
|
...(body.automated === true ? { automated: true } : {}),
|
|
2001
|
+
// Carry the transcript-suppression flag through the queue so a
|
|
2002
|
+
// hidden send that lands mid-turn stays hidden when drained —
|
|
2003
|
+
// the drain path persists this metadata and skips the echo.
|
|
2004
|
+
...(body.hidden === true ? { hidden: true } : {}),
|
|
1991
2005
|
},
|
|
1992
2006
|
isInteractive,
|
|
1993
2007
|
sourceActorPrincipalId,
|
|
@@ -2009,21 +2023,34 @@ export async function handleSendMessage(
|
|
|
2009
2023
|
// here must not turn the 202 response into a 500 — that would leave
|
|
2010
2024
|
// the client showing "Failed to send" for a message the daemon will
|
|
2011
2025
|
// process from the queue.
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2026
|
+
//
|
|
2027
|
+
// Supersede encodes user intent — a typed message while a prompt is open
|
|
2028
|
+
// means the user chose to move on. A hidden send is a machine signal
|
|
2029
|
+
// (e.g. the channel-setup wizard-close marker), not a user decision: it
|
|
2030
|
+
// must not auto-deny live approval prompts or steer a parked
|
|
2031
|
+
// ask_question to a message the user never typed. Daemon-injected
|
|
2032
|
+
// synthetic messages (subagent/ACP notifications) skip this path the
|
|
2033
|
+
// same way by enqueuing directly.
|
|
2034
|
+
if (body.hidden !== true) {
|
|
2035
|
+
try {
|
|
2036
|
+
// Supersede interactions left pending by the in-flight turn: auto-deny
|
|
2037
|
+
// confirmations (with canonical/client sync) and steer to the enqueued
|
|
2038
|
+
// message if an ask_question is parked. Centralized so the CLI signal
|
|
2039
|
+
// path (signals/user-message.ts) gets identical handling.
|
|
2040
|
+
supersedePendingInteractionsOnEnqueue(
|
|
2041
|
+
mapping.conversationId,
|
|
2042
|
+
requestId,
|
|
2043
|
+
);
|
|
2044
|
+
|
|
2045
|
+
// Expire any orphaned canonical requests that survived without a
|
|
2046
|
+
// matching in-memory pending interaction (e.g. prompter timeouts).
|
|
2047
|
+
expireOrphanedCanonicalRequests(mapping.conversationId);
|
|
2048
|
+
} catch (err) {
|
|
2049
|
+
log.warn(
|
|
2050
|
+
{ err, conversationId: mapping.conversationId },
|
|
2051
|
+
"Post-enqueue auto-deny failed — queued message unaffected",
|
|
2052
|
+
);
|
|
2053
|
+
}
|
|
2027
2054
|
}
|
|
2028
2055
|
|
|
2029
2056
|
return {
|
|
@@ -2039,7 +2066,11 @@ export async function handleSendMessage(
|
|
|
2039
2066
|
// before dispatching, so an idle conversation with lingering confirmations
|
|
2040
2067
|
// (e.g. the user never responded to a tool-approval prompt) must deny
|
|
2041
2068
|
// them before starting the new turn.
|
|
2042
|
-
|
|
2069
|
+
// Hidden sends are machine signals, not user decisions — like the queue
|
|
2070
|
+
// branch's supersede bypass above, they must not deny confirmations that
|
|
2071
|
+
// outlived a turn (e.g. a guardian approval still awaiting a channel
|
|
2072
|
+
// reply). The next visible send performs the cleanup instead.
|
|
2073
|
+
if (body.hidden !== true && conversation.hasAnyPendingConfirmation()) {
|
|
2043
2074
|
for (const interaction of pendingInteractions.getByConversation(
|
|
2044
2075
|
mapping.conversationId,
|
|
2045
2076
|
)) {
|
|
@@ -2438,6 +2469,7 @@ export async function handleSendMessage(
|
|
|
2438
2469
|
onEvent: broadcastMessage,
|
|
2439
2470
|
isInteractive,
|
|
2440
2471
|
isUserMessage: true,
|
|
2472
|
+
...(body.hidden === true ? { isHiddenPrompt: true } : {}),
|
|
2441
2473
|
})
|
|
2442
2474
|
.catch((err) => {
|
|
2443
2475
|
log.error(
|
|
@@ -2953,7 +2985,7 @@ export const ROUTES: RouteDefinition[] = [
|
|
|
2953
2985
|
.boolean()
|
|
2954
2986
|
.optional()
|
|
2955
2987
|
.describe(
|
|
2956
|
-
"When true, persist the user message but suppress it from the UI transcript (it stays in LLM-side history and still drives the turn). Used
|
|
2988
|
+
"When true, persist the user message but suppress it from the UI transcript (it stays in LLM-side history and still drives the turn). Used for machine signals the user never typed (proactive-greeting priming, channel-setup wizard close). Suppression covers the queued path too: a hidden send that lands mid-turn returns { queued: true, requestId } but never appears in list-messages queued snapshots, emits no echo, and does not supersede pending interactions. Honored on the standard send path only — slash-command content bypasses it.",
|
|
2957
2989
|
),
|
|
2958
2990
|
onboarding: z
|
|
2959
2991
|
.object({
|