@vellumai/assistant 0.12.3-staging.2 → 0.12.4-staging.1
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/Dockerfile +7 -0
- package/README.md +12 -12
- package/docker/mousepad/90-vellum.gschema.override +4 -0
- package/openapi.yaml +6 -2
- package/package.json +1 -1
- package/src/__tests__/approval-routes-http.test.ts +4 -2
- package/src/__tests__/call-routes-http.test.ts +4 -2
- package/src/__tests__/conversation-resurface-done.test.ts +289 -0
- package/src/__tests__/gemini-provider.test.ts +31 -0
- package/src/__tests__/provider-error-scenarios.test.ts +46 -0
- package/src/__tests__/provider-platform-proxy-integration.test.ts +45 -15
- package/src/__tests__/retry-fallback-escalation.test.ts +22 -0
- package/src/__tests__/runtime-attachment-metadata.test.ts +4 -2
- package/src/__tests__/secret-routes-platform-proxy.test.ts +1 -0
- package/src/__tests__/send-endpoint-busy.test.ts +4 -2
- package/src/__tests__/steer-on-enqueue-question.test.ts +265 -34
- package/src/cli/commands/channel-verification-sessions.help.ts +12 -2
- package/src/cli/lib/bundled-marketplace.json +4 -4
- package/src/config/feature-flag-registry.json +8 -8
- package/src/config/sidebar-done-gate.ts +27 -0
- package/src/daemon/conversation-turn-finalize.ts +12 -0
- package/src/daemon/handlers/__tests__/conversation-history-search.test.ts +61 -0
- package/src/daemon/handlers/config-channels.ts +3 -1
- package/src/daemon/handlers/conversation-history.ts +11 -1
- package/src/daemon/handlers/conversations.ts +118 -10
- package/src/desktop/desktop-browser-endpoint.ts +1 -0
- package/src/ipc/routes/__tests__/conversation-sync-ipc-routes.test.ts +49 -1
- package/src/ipc/routes/conversation-sync-ipc-routes.ts +38 -2
- package/src/permissions/question-prompter.ts +14 -4
- package/src/persistence/conversation-crud.ts +114 -3
- package/src/persistence/conversation-queries.ts +48 -3
- package/src/persistence/embeddings/__tests__/embedding-local-lifecycle.test.ts +157 -32
- package/src/persistence/embeddings/embedding-backend-shutdown.test.ts +47 -0
- package/src/persistence/embeddings/embedding-backend.ts +23 -41
- package/src/persistence/embeddings/embedding-gemini.test.ts +17 -0
- package/src/persistence/embeddings/embedding-gemini.ts +3 -1
- package/src/persistence/embeddings/embedding-local.ts +72 -17
- package/src/persistence/embeddings/embedding-types.ts +0 -19
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-skill-card.test.ts +2 -0
- package/src/plugins/defaults/memory/memory-retrospective-skill-card.ts +5 -0
- package/src/providers/gemini/client.ts +17 -1
- package/src/providers/malformed-tool-call.ts +55 -0
- package/src/providers/openai/__tests__/chat-completions-malformed-tool-call.test.ts +105 -0
- package/src/providers/openai/chat-completions-provider.ts +30 -0
- package/src/providers/platform-proxy/constants.ts +5 -0
- package/src/providers/retry.ts +68 -36
- package/src/providers/vellum-model-routing.test.ts +1 -0
- package/src/runtime/__tests__/agent-wake-archived-gate.test.ts +90 -0
- package/src/runtime/__tests__/question-resolution.test.ts +32 -0
- package/src/runtime/agent-wake.ts +9 -2
- package/src/runtime/auth/__tests__/middleware.test.ts +138 -0
- package/src/runtime/auth/middleware.ts +15 -11
- package/src/runtime/middleware/auth.ts +7 -1
- package/src/runtime/pending-interactions.ts +8 -8
- package/src/runtime/question-resolution.ts +54 -8
- package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +146 -0
- package/src/runtime/routes/__tests__/global-search-routes.test.ts +74 -1
- package/src/runtime/routes/conversation-list-routes.ts +16 -14
- package/src/runtime/routes/conversation-query-routes.ts +4 -0
- package/src/runtime/routes/global-search-routes.ts +22 -7
- package/src/runtime/sync/documents-sidecar-publish.test.ts +50 -4
- package/src/runtime/sync/resource-sync-events.ts +11 -0
- package/src/runtime/sync/worker-daemon-notify.test.ts +30 -0
- package/src/runtime/sync/worker-daemon-notify.ts +44 -0
- package/src/runtime/verification-outbound-actions.ts +2 -2
- package/src/tools/ask-question/ask-question-tool.test.ts +38 -0
- package/src/tools/ask-question/ask-question-tool.ts +12 -1
- package/src/util/worker-pipe.test.ts +100 -0
- package/src/util/worker-pipe.ts +44 -0
|
@@ -10,9 +10,11 @@ import { resolveConversationId } from "../../persistence/conversation-key-store.
|
|
|
10
10
|
import { broadcastMessage } from "../../runtime/assistant-event-hub.js";
|
|
11
11
|
import { resolveCapabilities } from "../../runtime/capabilities.js";
|
|
12
12
|
import * as pendingInteractions from "../../runtime/pending-interactions.js";
|
|
13
|
+
import { resolvePendingQuestion } from "../../runtime/question-resolution.js";
|
|
13
14
|
import { getSubagentManager } from "../../subagent/index.js";
|
|
14
15
|
import { createAbortReason } from "../../util/abort-reasons.js";
|
|
15
16
|
import { UserError } from "../../util/errors.js";
|
|
17
|
+
import type { Conversation } from "../conversation.js";
|
|
16
18
|
import { touchConversation } from "../conversation-evictor.js";
|
|
17
19
|
import { forceClearStaleProcessing } from "../conversation-lifecycle.js";
|
|
18
20
|
import {
|
|
@@ -24,7 +26,7 @@ import {
|
|
|
24
26
|
conversationEntries,
|
|
25
27
|
findConversation,
|
|
26
28
|
} from "../conversation-registry.js";
|
|
27
|
-
import { resolveSlash } from "../conversation-slash.js";
|
|
29
|
+
import { classifySlash, resolveSlash } from "../conversation-slash.js";
|
|
28
30
|
import {
|
|
29
31
|
clearAllActiveConversations,
|
|
30
32
|
getOrCreateConversation,
|
|
@@ -328,23 +330,36 @@ export function deleteQueuedMessage(
|
|
|
328
330
|
);
|
|
329
331
|
return { removed: false, reason: "forbidden" };
|
|
330
332
|
}
|
|
331
|
-
conversation
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
333
|
+
consumeQueuedMessage(conversation, conversationId, queued);
|
|
334
|
+
return { removed: true };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Drop a queued message from the queue and close out the client row it
|
|
339
|
+
* created, so a message that will never run leaves no pending indicator
|
|
340
|
+
* behind.
|
|
341
|
+
*
|
|
342
|
+
* Queue events come in pairs, so an entry that never produced a
|
|
343
|
+
* `message_queued` ack (a suppressed daemon-injected send, or a hidden machine
|
|
344
|
+
* send) must not produce a delete either: clients have no row to retire and an
|
|
345
|
+
* unpaired event would decrement a counter that was never incremented.
|
|
346
|
+
*/
|
|
347
|
+
function consumeQueuedMessage(
|
|
348
|
+
conversation: Conversation,
|
|
349
|
+
conversationId: string,
|
|
350
|
+
queued: QueuedMessage,
|
|
351
|
+
): void {
|
|
352
|
+
conversation.removeQueuedMessage(queued.requestId);
|
|
337
353
|
if (!isSuppressedQueuedMessage(queued.metadata)) {
|
|
338
354
|
queued.onEvent({
|
|
339
355
|
type: "message_queued_deleted",
|
|
340
356
|
conversationId,
|
|
341
|
-
requestId,
|
|
357
|
+
requestId: queued.requestId,
|
|
342
358
|
...(queued.clientMessageId
|
|
343
359
|
? { clientMessageId: queued.clientMessageId }
|
|
344
360
|
: {}),
|
|
345
361
|
});
|
|
346
362
|
}
|
|
347
|
-
return { removed: true };
|
|
348
363
|
}
|
|
349
364
|
|
|
350
365
|
/**
|
|
@@ -458,6 +473,92 @@ export function steerToMessage(
|
|
|
458
473
|
return { steered: true };
|
|
459
474
|
}
|
|
460
475
|
|
|
476
|
+
/**
|
|
477
|
+
* Answer an open `ask_question` prompt with a message the user typed into the
|
|
478
|
+
* chat while the card was up.
|
|
479
|
+
*
|
|
480
|
+
* Typing an answer instead of using the card is the same act as typing it into
|
|
481
|
+
* the card's free-text field, so it resolves the prompt rather than cancelling
|
|
482
|
+
* it: the parked turn keeps running and the model reads the answer in the tool
|
|
483
|
+
* result. Channel replies already work this way (the bare-text branch of
|
|
484
|
+
* `runtime/guardian-reply-router.ts`); this is the app path saying the same
|
|
485
|
+
* thing through the same `resolvePendingQuestion` core.
|
|
486
|
+
*
|
|
487
|
+
* The message is consumed, not run as a turn of its own: it reaches the model
|
|
488
|
+
* once, through the tool result, and the answered card carries the user's own
|
|
489
|
+
* words in the transcript. Consuming it retires the client's queued row the
|
|
490
|
+
* same way a queue delete does.
|
|
491
|
+
*
|
|
492
|
+
* Deliberately narrow. Anything the free-text field of a card could not have
|
|
493
|
+
* carried, or that a single answer cannot honestly stand in for, falls through
|
|
494
|
+
* to {@link steerOnEnqueuedMessageIfQuestionParked} and supersedes as before:
|
|
495
|
+
* - more than one parked question, so which prompt the text answers is a guess
|
|
496
|
+
* - a message no longer on the queue, which cannot be consumed
|
|
497
|
+
* - attachments, which the tool result has no way to carry
|
|
498
|
+
* - a send that is not a person typing: automated, non-interactive, or
|
|
499
|
+
* echo-suppressed machine signals
|
|
500
|
+
* - empty text, which answers nothing
|
|
501
|
+
* - a slash command, which is an instruction to the assistant rather than prose
|
|
502
|
+
*
|
|
503
|
+
* Returns `true` when a parked question was answered and the message consumed.
|
|
504
|
+
*/
|
|
505
|
+
export function answerParkedQuestionWithEnqueuedMessage(
|
|
506
|
+
conversationId: string,
|
|
507
|
+
enqueuedRequestId: string,
|
|
508
|
+
): boolean {
|
|
509
|
+
const conversation = findConversation(conversationId);
|
|
510
|
+
if (!conversation) {
|
|
511
|
+
return false;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const parked = pendingInteractions
|
|
515
|
+
.getByConversation(conversationId)
|
|
516
|
+
.filter((interaction) => interaction.kind === "question");
|
|
517
|
+
if (parked.length !== 1) {
|
|
518
|
+
return false;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const queued = conversation.queue.findByRequestId(enqueuedRequestId);
|
|
522
|
+
if (!queued || queued.attachments.length > 0) {
|
|
523
|
+
return false;
|
|
524
|
+
}
|
|
525
|
+
if (
|
|
526
|
+
queued.isInteractive === false ||
|
|
527
|
+
queued.metadata?.automated === true ||
|
|
528
|
+
isSuppressedQueuedMessage(queued.metadata)
|
|
529
|
+
) {
|
|
530
|
+
return false;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const text = queued.content.trim();
|
|
534
|
+
if (text.length === 0 || classifySlash(text) !== "passthrough") {
|
|
535
|
+
return false;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const outcome = resolvePendingQuestion(parked[0].requestId, {
|
|
539
|
+
kind: "chat_reply",
|
|
540
|
+
text,
|
|
541
|
+
});
|
|
542
|
+
if (outcome.status !== "resolved") {
|
|
543
|
+
log.warn(
|
|
544
|
+
{
|
|
545
|
+
conversationId,
|
|
546
|
+
requestId: parked[0].requestId,
|
|
547
|
+
outcome: outcome.status,
|
|
548
|
+
},
|
|
549
|
+
"Parked question did not resolve from the enqueued message",
|
|
550
|
+
);
|
|
551
|
+
return false;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
consumeQueuedMessage(conversation, conversationId, queued);
|
|
555
|
+
log.info(
|
|
556
|
+
{ conversationId, requestId: parked[0].requestId, enqueuedRequestId },
|
|
557
|
+
"Enqueued message answered a parked question",
|
|
558
|
+
);
|
|
559
|
+
return true;
|
|
560
|
+
}
|
|
561
|
+
|
|
461
562
|
/**
|
|
462
563
|
* Supersede an open `ask_question` prompt when a new chat message is enqueued
|
|
463
564
|
* for the same conversation.
|
|
@@ -495,7 +596,9 @@ export function steerOnEnqueuedMessageIfQuestionParked(
|
|
|
495
596
|
* gateway request-status sync *before* clearing the prompter-owned
|
|
496
597
|
* confirmations, so a later guardian reply can't match a stale "pending"
|
|
497
598
|
* record and fail with `pending_interaction_not_found`.
|
|
498
|
-
* 2.
|
|
599
|
+
* 2. Answer a parked ask_question with the enqueued message when the message
|
|
600
|
+
* reads as an answer ({@link answerParkedQuestionWithEnqueuedMessage}), and
|
|
601
|
+
* otherwise supersede it by steering to the enqueued message.
|
|
499
602
|
*
|
|
500
603
|
* Order matters: the steer aborts the turn, which denies the prompter's
|
|
501
604
|
* confirmations as a side effect, so the status/notification sync must be
|
|
@@ -508,6 +611,11 @@ export function supersedePendingInteractionsOnEnqueue(
|
|
|
508
611
|
enqueuedRequestId: string,
|
|
509
612
|
): void {
|
|
510
613
|
denyPendingConfirmationsOnSupersession(conversationId);
|
|
614
|
+
if (
|
|
615
|
+
answerParkedQuestionWithEnqueuedMessage(conversationId, enqueuedRequestId)
|
|
616
|
+
) {
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
511
619
|
steerOnEnqueuedMessageIfQuestionParked(conversationId, enqueuedRequestId);
|
|
512
620
|
}
|
|
513
621
|
|
|
@@ -134,6 +134,7 @@ export function desktopChromeArguments(
|
|
|
134
134
|
`--class=${DESKTOP_CHROME_WINDOW_CLASS}`,
|
|
135
135
|
"--force-renderer-accessibility",
|
|
136
136
|
"--disable-dev-shm-usage",
|
|
137
|
+
"--disable-features=Prerender2",
|
|
137
138
|
...(shouldRestoreDesktopChromeSession(profileDir)
|
|
138
139
|
? ["--restore-last-session", "--hide-crash-restore-bubble"]
|
|
139
140
|
: []),
|
|
@@ -16,6 +16,7 @@ import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
|
16
16
|
|
|
17
17
|
const recordCalls: Array<[string, number]> = [];
|
|
18
18
|
const publishCalls: string[] = [];
|
|
19
|
+
const listPublishCalls: Array<[string, string | string[]]> = [];
|
|
19
20
|
|
|
20
21
|
mock.module("../../../persistence/conversation-crud.js", () => ({
|
|
21
22
|
recordConversationPersistedSeq: (id: string, seq: number) => {
|
|
@@ -27,6 +28,12 @@ mock.module("../../../runtime/sync/resource-sync-events.js", () => ({
|
|
|
27
28
|
publishConversationMessagesChanged: (id: string) => {
|
|
28
29
|
publishCalls.push(id);
|
|
29
30
|
},
|
|
31
|
+
publishConversationListAndMetadataChanged: (
|
|
32
|
+
reason: string,
|
|
33
|
+
ids: string | string[],
|
|
34
|
+
) => {
|
|
35
|
+
listPublishCalls.push([reason, ids]);
|
|
36
|
+
},
|
|
30
37
|
}));
|
|
31
38
|
|
|
32
39
|
import type { AssistantEventEnvelope } from "../../../api/index.js";
|
|
@@ -40,9 +47,13 @@ import {
|
|
|
40
47
|
_resetStreamStateForTesting,
|
|
41
48
|
stampAndBuffer,
|
|
42
49
|
} from "../../../runtime/assistant-stream-state.js";
|
|
43
|
-
import {
|
|
50
|
+
import {
|
|
51
|
+
NOTIFY_CONVERSATION_LIST_CHANGED_IPC_METHOD,
|
|
52
|
+
NOTIFY_CONVERSATION_PERSISTED_IPC_METHOD,
|
|
53
|
+
} from "../../../runtime/sync/worker-daemon-notify.js";
|
|
44
54
|
import {
|
|
45
55
|
CONVERSATION_SYNC_IPC_METHODS,
|
|
56
|
+
handleNotifyConversationListChanged,
|
|
46
57
|
handleNotifyConversationPersisted,
|
|
47
58
|
} from "../conversation-sync-ipc-routes.js";
|
|
48
59
|
|
|
@@ -176,3 +187,40 @@ describe("conversation-sync IPC route", () => {
|
|
|
176
187
|
).toBe(false);
|
|
177
188
|
});
|
|
178
189
|
});
|
|
190
|
+
|
|
191
|
+
describe("conversation-list-changed IPC route", () => {
|
|
192
|
+
beforeEach(() => {
|
|
193
|
+
listPublishCalls.length = 0;
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("republishes the worker's list invalidation on the daemon's hub", () => {
|
|
197
|
+
const result = handleNotifyConversationListChanged({
|
|
198
|
+
body: { reason: "reordered", conversationIds: ["conv-1"] },
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
expect(result).toEqual({ ok: true });
|
|
202
|
+
expect(listPublishCalls).toEqual([["reordered", ["conv-1"]]]);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("rejects an unknown reason and an empty id list", () => {
|
|
206
|
+
expect(() =>
|
|
207
|
+
handleNotifyConversationListChanged({
|
|
208
|
+
body: { reason: "resurfaced", conversationIds: ["conv-1"] },
|
|
209
|
+
}),
|
|
210
|
+
).toThrow();
|
|
211
|
+
expect(() =>
|
|
212
|
+
handleNotifyConversationListChanged({
|
|
213
|
+
body: { reason: "reordered", conversationIds: [] },
|
|
214
|
+
}),
|
|
215
|
+
).toThrow();
|
|
216
|
+
expect(listPublishCalls).toEqual([]);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("is reachable on the IPC surface under the shared method name", () => {
|
|
220
|
+
expect(
|
|
221
|
+
typeof CONVERSATION_SYNC_IPC_METHODS[
|
|
222
|
+
NOTIFY_CONVERSATION_LIST_CHANGED_IPC_METHOD
|
|
223
|
+
],
|
|
224
|
+
).toBe("function");
|
|
225
|
+
});
|
|
226
|
+
});
|
|
@@ -29,13 +29,30 @@ import { getInflightFlushedContentSeq } from "../../daemon/inflight-turn-registr
|
|
|
29
29
|
import { recordConversationPersistedSeq } from "../../persistence/conversation-crud.js";
|
|
30
30
|
import { getCurrentSeq } from "../../runtime/assistant-stream-state.js";
|
|
31
31
|
import type { RouteHandlerArgs } from "../../runtime/routes/types.js";
|
|
32
|
-
import {
|
|
33
|
-
|
|
32
|
+
import {
|
|
33
|
+
publishConversationListAndMetadataChanged,
|
|
34
|
+
publishConversationMessagesChanged,
|
|
35
|
+
} from "../../runtime/sync/resource-sync-events.js";
|
|
36
|
+
import {
|
|
37
|
+
NOTIFY_CONVERSATION_LIST_CHANGED_IPC_METHOD,
|
|
38
|
+
NOTIFY_CONVERSATION_PERSISTED_IPC_METHOD,
|
|
39
|
+
} from "../../runtime/sync/worker-daemon-notify.js";
|
|
34
40
|
|
|
35
41
|
const NotifyConversationPersistedParamsSchema = z.object({
|
|
36
42
|
conversationId: z.string().min(1),
|
|
37
43
|
});
|
|
38
44
|
|
|
45
|
+
const NotifyConversationListChangedParamsSchema = z.object({
|
|
46
|
+
reason: z.enum([
|
|
47
|
+
"created",
|
|
48
|
+
"renamed",
|
|
49
|
+
"deleted",
|
|
50
|
+
"reordered",
|
|
51
|
+
"seen_changed",
|
|
52
|
+
]),
|
|
53
|
+
conversationIds: z.array(z.string().min(1)).min(1),
|
|
54
|
+
});
|
|
55
|
+
|
|
39
56
|
/**
|
|
40
57
|
* Record the daemon-issued snapshot anchor for a worker-persisted turn and
|
|
41
58
|
* republish its messages-changed invalidation to the daemon's subscribers.
|
|
@@ -61,6 +78,23 @@ export function handleNotifyConversationPersisted({
|
|
|
61
78
|
return { ok: true };
|
|
62
79
|
}
|
|
63
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Republish a worker's conversation-list-and-metadata invalidation on the
|
|
83
|
+
* daemon's hub, where the SSE subscribers live.
|
|
84
|
+
*
|
|
85
|
+
* A worker turn that changes which rows belong in the list (clearing
|
|
86
|
+
* `archived_at` on a Done conversation it woke) has to reach connected
|
|
87
|
+
* sidebars, and a publish in the worker reaches none of them.
|
|
88
|
+
*/
|
|
89
|
+
export function handleNotifyConversationListChanged({
|
|
90
|
+
body = {},
|
|
91
|
+
}: RouteHandlerArgs) {
|
|
92
|
+
const { reason, conversationIds } =
|
|
93
|
+
NotifyConversationListChangedParamsSchema.parse(body);
|
|
94
|
+
publishConversationListAndMetadataChanged(reason, conversationIds);
|
|
95
|
+
return { ok: true };
|
|
96
|
+
}
|
|
97
|
+
|
|
64
98
|
/**
|
|
65
99
|
* IPC-only conversation-sync methods, keyed by operationId. Registered directly
|
|
66
100
|
* on the assistant IPC server (see `assistant-server.ts`).
|
|
@@ -70,4 +104,6 @@ export const CONVERSATION_SYNC_IPC_METHODS: Record<
|
|
|
70
104
|
(args: RouteHandlerArgs) => unknown
|
|
71
105
|
> = {
|
|
72
106
|
[NOTIFY_CONVERSATION_PERSISTED_IPC_METHOD]: handleNotifyConversationPersisted,
|
|
107
|
+
[NOTIFY_CONVERSATION_LIST_CHANGED_IPC_METHOD]:
|
|
108
|
+
handleNotifyConversationListChanged,
|
|
73
109
|
};
|
|
@@ -58,6 +58,14 @@ export interface QuestionPromptEntryResult {
|
|
|
58
58
|
export interface QuestionPromptResult {
|
|
59
59
|
entries: QuestionPromptEntryResult[];
|
|
60
60
|
overall: "completed" | "closed" | "timed_out" | "aborted";
|
|
61
|
+
/**
|
|
62
|
+
* True when the answer arrived as a message typed into the chat while the
|
|
63
|
+
* card was still open, rather than through the card itself. Only the first
|
|
64
|
+
* question carries the typed text; the rest stay `skipped`. The
|
|
65
|
+
* `ask_question` tool says so in the model-facing result, so the model can
|
|
66
|
+
* map the reply onto the questions itself.
|
|
67
|
+
*/
|
|
68
|
+
answeredInChat?: boolean;
|
|
61
69
|
}
|
|
62
70
|
|
|
63
71
|
/**
|
|
@@ -178,10 +186,12 @@ export interface QuestionBatchMetadata {
|
|
|
178
186
|
*
|
|
179
187
|
* The idle timeout is a backstop (`getConfig().timeouts.questionResponseTimeoutSec`,
|
|
180
188
|
* default 30 min), not the primary way a prompt is dismissed. An interactive
|
|
181
|
-
* user who
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
189
|
+
* user who types into the chat instead of using the card settles the prompt at
|
|
190
|
+
* the enqueue handler: the typed text answers it, or, when it cannot stand in
|
|
191
|
+
* for an answer, supersedes it (see
|
|
192
|
+
* `daemon/handlers/conversations.ts`). A non-interactive turn resolves
|
|
193
|
+
* immediately at the tool. The backstop only fires when a prompt is left open
|
|
194
|
+
* with no response and no follow-up message.
|
|
185
195
|
*/
|
|
186
196
|
export class QuestionPrompter {
|
|
187
197
|
async prompt(params: QuestionPromptParams): Promise<QuestionPromptOutcome> {
|
|
@@ -32,6 +32,7 @@ import type { ChannelId, InterfaceId } from "../channels/types.js";
|
|
|
32
32
|
import { parseChannelId, parseInterfaceId } from "../channels/types.js";
|
|
33
33
|
import { CHANNEL_IDS, isChannelId } from "../channels/types.js";
|
|
34
34
|
import { getConfig } from "../config/loader.js";
|
|
35
|
+
import { isSidebarDoneEnabled } from "../config/sidebar-done-gate.js";
|
|
35
36
|
import { findDisplayTurnEndIndex } from "../conversations/message-consolidation.js";
|
|
36
37
|
import { findConversation } from "../daemon/conversation-registry.js";
|
|
37
38
|
import { conversationMetadataSyncTag } from "../daemon/message-types/sync.js";
|
|
@@ -48,6 +49,7 @@ import { indexMessageNow } from "../plugins/defaults/memory/indexer.js";
|
|
|
48
49
|
import { runHook } from "../plugins/pipeline.js";
|
|
49
50
|
import type { ContentBlock } from "../providers/types.js";
|
|
50
51
|
import { getCurrentSeq } from "../runtime/assistant-stream-state.js";
|
|
52
|
+
import { publishConversationListAndMetadataChanged } from "../runtime/sync/resource-sync-events.js";
|
|
51
53
|
import { publishSyncInvalidation } from "../runtime/sync/sync-publisher.js";
|
|
52
54
|
import { UserError } from "../util/errors.js";
|
|
53
55
|
import { getLogger } from "../util/logger.js";
|
|
@@ -100,6 +102,7 @@ import {
|
|
|
100
102
|
computerUseScreenshotAttachmentIdsFromMetadata,
|
|
101
103
|
type ConversationCreateType,
|
|
102
104
|
type ConversationOrigin,
|
|
105
|
+
isEchoSuppressedUserMessage,
|
|
103
106
|
isHiddenMessageMetadata,
|
|
104
107
|
isNoResponseMetadata,
|
|
105
108
|
isReactionMessageMetadata,
|
|
@@ -942,6 +945,11 @@ interface InsertMessageCoreParams {
|
|
|
942
945
|
/** Answered synchronously at the top of every insert attempt. See
|
|
943
946
|
* {@link AddMessageOptions.insertPrecondition}. */
|
|
944
947
|
insertPrecondition?: () => boolean;
|
|
948
|
+
/** The row is a reservation booked ahead of the content that will fill it
|
|
949
|
+
* ({@link reserveMessage}), so it carries nothing to read yet. */
|
|
950
|
+
reserved?: boolean;
|
|
951
|
+
/** See {@link AddMessageOptions.skipResurface}. */
|
|
952
|
+
skipResurface?: boolean;
|
|
945
953
|
}
|
|
946
954
|
|
|
947
955
|
/**
|
|
@@ -1029,6 +1037,8 @@ async function insertMessageCore(
|
|
|
1029
1037
|
clientMessageId,
|
|
1030
1038
|
id,
|
|
1031
1039
|
insertPrecondition,
|
|
1040
|
+
reserved,
|
|
1041
|
+
skipResurface,
|
|
1032
1042
|
} = params;
|
|
1033
1043
|
warnOnModelInvisibleContent(content, conversationId);
|
|
1034
1044
|
const db = getDb();
|
|
@@ -1054,7 +1064,7 @@ async function insertMessageCore(
|
|
|
1054
1064
|
|
|
1055
1065
|
// The timestamp is recomputed each attempt so a late retry doesn't persist a
|
|
1056
1066
|
// stale `updatedAt`.
|
|
1057
|
-
|
|
1067
|
+
const inserted = await withSqliteRetry(
|
|
1058
1068
|
(): InsertedMessage => {
|
|
1059
1069
|
// Asked at the top of EVERY attempt, and synchronously, because that is
|
|
1060
1070
|
// the scope the answer holds for. Contention retries this function after
|
|
@@ -1179,6 +1189,32 @@ async function insertMessageCore(
|
|
|
1179
1189
|
},
|
|
1180
1190
|
{ op: "insertMessageCore", context: { conversationId } },
|
|
1181
1191
|
);
|
|
1192
|
+
|
|
1193
|
+
// A message the user reads brings a Done conversation back to the list.
|
|
1194
|
+
// Four kinds of insert are not that: the echo-suppressed set, which never
|
|
1195
|
+
// renders in the transcript; the empty row `reserveMessage` books for an LLM
|
|
1196
|
+
// call that has not run yet, whose content arrives at the finalize seam and
|
|
1197
|
+
// resurfaces from there; a row whose producer declared it bookkeeping ABOUT
|
|
1198
|
+
// the conversation rather than activity in it (`skipResurface`); and a
|
|
1199
|
+
// deduplicated insert, which wrote no row at all. Best-effort: a failure
|
|
1200
|
+
// here must not escalate into a failed persist.
|
|
1201
|
+
if (
|
|
1202
|
+
!inserted.deduplicated &&
|
|
1203
|
+
!reserved &&
|
|
1204
|
+
!skipResurface &&
|
|
1205
|
+
!isEchoSuppressedUserMessage(metadata)
|
|
1206
|
+
) {
|
|
1207
|
+
try {
|
|
1208
|
+
resurfaceArchivedConversation(conversationId, inserted.createdAt);
|
|
1209
|
+
} catch (err) {
|
|
1210
|
+
log.warn(
|
|
1211
|
+
{ err, conversationId, messageId: inserted.id },
|
|
1212
|
+
"Failed to resurface Done conversation after message insert",
|
|
1213
|
+
);
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
return inserted;
|
|
1182
1218
|
}
|
|
1183
1219
|
|
|
1184
1220
|
/**
|
|
@@ -2653,6 +2689,17 @@ export interface AddMessageOptions {
|
|
|
2653
2689
|
* during that sleep.
|
|
2654
2690
|
*/
|
|
2655
2691
|
insertPrecondition?: () => boolean;
|
|
2692
|
+
/**
|
|
2693
|
+
* The row is the assistant's bookkeeping ABOUT this conversation rather than
|
|
2694
|
+
* activity in it, so it must not bring the conversation back from Done (see
|
|
2695
|
+
* {@link resurfaceArchivedConversation}). The producer declares this, because
|
|
2696
|
+
* only the producer knows which of the two a row is.
|
|
2697
|
+
*
|
|
2698
|
+
* The retrospective skill card is the case: a pass runs once a conversation
|
|
2699
|
+
* has gone idle, which is exactly when the user has just marked it done, so
|
|
2700
|
+
* a card that resurfaced would bounce fresh done chats back into the sidebar.
|
|
2701
|
+
*/
|
|
2702
|
+
skipResurface?: boolean;
|
|
2656
2703
|
}
|
|
2657
2704
|
|
|
2658
2705
|
/**
|
|
@@ -2666,8 +2713,14 @@ export async function addMessage(
|
|
|
2666
2713
|
content: string,
|
|
2667
2714
|
options?: AddMessageOptions,
|
|
2668
2715
|
) {
|
|
2669
|
-
const {
|
|
2670
|
-
|
|
2716
|
+
const {
|
|
2717
|
+
metadata,
|
|
2718
|
+
skipIndexing,
|
|
2719
|
+
clientMessageId,
|
|
2720
|
+
id,
|
|
2721
|
+
insertPrecondition,
|
|
2722
|
+
skipResurface,
|
|
2723
|
+
} = options ?? {};
|
|
2671
2724
|
const inserted = await insertMessageCore({
|
|
2672
2725
|
conversationId,
|
|
2673
2726
|
role,
|
|
@@ -2676,6 +2729,7 @@ export async function addMessage(
|
|
|
2676
2729
|
clientMessageId,
|
|
2677
2730
|
id,
|
|
2678
2731
|
...(insertPrecondition ? { insertPrecondition } : {}),
|
|
2732
|
+
...(skipResurface ? { skipResurface } : {}),
|
|
2679
2733
|
});
|
|
2680
2734
|
|
|
2681
2735
|
if (inserted.deduplicated) {
|
|
@@ -3521,6 +3575,62 @@ export function unarchiveConversation(id: string): boolean {
|
|
|
3521
3575
|
return true;
|
|
3522
3576
|
}
|
|
3523
3577
|
|
|
3578
|
+
/**
|
|
3579
|
+
* Bring a Done conversation back to the list because a message the user reads
|
|
3580
|
+
* landed in it at `activityAt`.
|
|
3581
|
+
*
|
|
3582
|
+
* Under the `sidebar-done` gate "Done" is a completion mark, not a deletion:
|
|
3583
|
+
* the conversation keeps its schedules and its channel threads, so anything
|
|
3584
|
+
* the user would read arriving in it has to be reachable again. Two seams call
|
|
3585
|
+
* this, which between them cover every ingest path without a per-provider
|
|
3586
|
+
* hook: {@link insertMessageCore}, where each durable append lands (channel
|
|
3587
|
+
* inbound, web and CLI sends, wake tail messages, assistant-initiated
|
|
3588
|
+
* notification bodies), and the turn's finalize effect, where a streamed
|
|
3589
|
+
* assistant reply becomes readable content.
|
|
3590
|
+
*
|
|
3591
|
+
* Rows the user never reads resurface nothing. `insertMessageCore` excludes
|
|
3592
|
+
* the echo-suppressed set (hidden signals, subagent and ACP notifications, and
|
|
3593
|
+
* the `<background_event>` trigger each wake persists); the empty row an LLM
|
|
3594
|
+
* call reserves before it runs, so a call rejected before producing anything
|
|
3595
|
+
* leaves the conversation Done; and any row whose producer passed
|
|
3596
|
+
* {@link AddMessageOptions.skipResurface}. Retrospectives and memory
|
|
3597
|
+
* consolidation are excluded by construction: they append to a fork or to a
|
|
3598
|
+
* conversation of their own, never to the conversation under review.
|
|
3599
|
+
*
|
|
3600
|
+
* The clear is conditional on `archived_at <= activityAt` in one statement, so
|
|
3601
|
+
* a Done mark the user makes after this activity (while a turn's deferred tail
|
|
3602
|
+
* is still settling, or from the daemon while a worker runs the turn) wins
|
|
3603
|
+
* instead of being erased by it.
|
|
3604
|
+
*
|
|
3605
|
+
* No-ops when the gate is off, which is the shipped behavior: nothing but the
|
|
3606
|
+
* unarchive route clears `archived_at`.
|
|
3607
|
+
*/
|
|
3608
|
+
export function resurfaceArchivedConversation(
|
|
3609
|
+
conversationId: string,
|
|
3610
|
+
activityAt: number,
|
|
3611
|
+
): boolean {
|
|
3612
|
+
if (!isSidebarDoneEnabled(getConfig())) {
|
|
3613
|
+
return false;
|
|
3614
|
+
}
|
|
3615
|
+
const now = Date.now();
|
|
3616
|
+
const changed = rawRun(
|
|
3617
|
+
"conversation:resurface",
|
|
3618
|
+
"UPDATE conversations SET archived_at = NULL, updated_at = ? WHERE id = ? AND archived_at IS NOT NULL AND archived_at <= ?",
|
|
3619
|
+
now,
|
|
3620
|
+
conversationId,
|
|
3621
|
+
activityAt,
|
|
3622
|
+
);
|
|
3623
|
+
if (changed === 0) {
|
|
3624
|
+
return false;
|
|
3625
|
+
}
|
|
3626
|
+
publishConversationListAndMetadataChanged("reordered", conversationId);
|
|
3627
|
+
log.info(
|
|
3628
|
+
{ conversationId },
|
|
3629
|
+
"Resurfaced Done conversation on user-visible activity",
|
|
3630
|
+
);
|
|
3631
|
+
return true;
|
|
3632
|
+
}
|
|
3633
|
+
|
|
3524
3634
|
/**
|
|
3525
3635
|
* Persist the processing-start timestamp for a conversation. Called by
|
|
3526
3636
|
* `Conversation.setProcessing(true)` so out-of-process callers can detect
|
|
@@ -4530,6 +4640,7 @@ export async function reserveMessage(
|
|
|
4530
4640
|
content: inflightRef ? JSON.stringify({ ref: inflightRef }) : "[]",
|
|
4531
4641
|
...(inflightRef ? { finalized: 0 as const } : {}),
|
|
4532
4642
|
metadata,
|
|
4643
|
+
reserved: true,
|
|
4533
4644
|
});
|
|
4534
4645
|
}
|
|
4535
4646
|
|
|
@@ -116,6 +116,16 @@ export function hasLexicalTokens(text: string): boolean {
|
|
|
116
116
|
*/
|
|
117
117
|
export type ArchiveStatusFilter = "active" | "archived" | "all";
|
|
118
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Which bucket a listing covers.
|
|
121
|
+
*
|
|
122
|
+
* The persisted {@link ConversationType} values plus `"all"`, a read-side
|
|
123
|
+
* union that exists only here: no row carries it, and nothing writes it. It
|
|
124
|
+
* lets one paginated request span a user's whole history, which is what a
|
|
125
|
+
* full-history view needs and what three interleaved cursors cannot give it.
|
|
126
|
+
*/
|
|
127
|
+
export type ConversationListTypeFilter = ConversationType | "all";
|
|
128
|
+
|
|
119
129
|
function archiveStatusClause(status: ArchiveStatusFilter) {
|
|
120
130
|
switch (status) {
|
|
121
131
|
case "active":
|
|
@@ -196,6 +206,25 @@ function surfacedVisibilitySql(alias = "conversations"): string {
|
|
|
196
206
|
);
|
|
197
207
|
}
|
|
198
208
|
|
|
209
|
+
/**
|
|
210
|
+
* Raw SQL predicate for the background **umbrella**: background and scheduled
|
|
211
|
+
* rows together, matched on `conversation_type` or on the system group the
|
|
212
|
+
* producer filed them in, with subagent runs excluded so the sidebar never
|
|
213
|
+
* surfaces them.
|
|
214
|
+
*
|
|
215
|
+
* Shared by the `"background"` bucket of {@link conversationTypeClause} and by
|
|
216
|
+
* the `"all"` bucket, which is the union of that one with the standard
|
|
217
|
+
* listing, so the combined read can never admit a row either bucket alone
|
|
218
|
+
* would have withheld.
|
|
219
|
+
*/
|
|
220
|
+
function backgroundUmbrellaSql(alias = "conversations"): string {
|
|
221
|
+
return (
|
|
222
|
+
`((${alias}.conversation_type IN ('background', 'scheduled')` +
|
|
223
|
+
` OR COALESCE(${alias}.group_id, 'system:all') IN ('system:background', 'system:scheduled'))` +
|
|
224
|
+
` AND (${alias}.source IS NULL OR ${alias}.source != 'subagent'))`
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
199
228
|
/**
|
|
200
229
|
* Raw SQL predicate for "not an automated background/scheduled row".
|
|
201
230
|
*
|
|
@@ -289,6 +318,11 @@ function ungroupedSql(alias = "conversations"): string {
|
|
|
289
318
|
* Background and Scheduled sidebar sections from one request.
|
|
290
319
|
* - `"scheduled"` — scheduled rows only, so the Scheduled section can load
|
|
291
320
|
* independently of the broader background backlog without over-fetching it.
|
|
321
|
+
* - `"all"`: the union of `"standard"` and `"background"`, so one paginated
|
|
322
|
+
* request covers a user's whole history instead of interleaving three. Not
|
|
323
|
+
* a bypass of the visibility rules: it is exactly those two predicates
|
|
324
|
+
* OR-ed, with the legacy `private` type excluded on top, so nothing reaches
|
|
325
|
+
* it that neither bucket would have returned.
|
|
292
326
|
*
|
|
293
327
|
* `group_id` is matched alongside `conversationType` so conversations routed to
|
|
294
328
|
* `system:background` / `system:scheduled` (heartbeat, reminders, schedule-job
|
|
@@ -296,7 +330,7 @@ function ungroupedSql(alias = "conversations"): string {
|
|
|
296
330
|
* correct bucket. Subagent runs are excluded from the background/scheduled
|
|
297
331
|
* buckets so the sidebar never surfaces them.
|
|
298
332
|
*/
|
|
299
|
-
function conversationTypeClause(type:
|
|
333
|
+
function conversationTypeClause(type: ConversationListTypeFilter) {
|
|
300
334
|
const notSubagent = sql`(${conversations.source} IS NULL OR ${conversations.source} != 'subagent')`;
|
|
301
335
|
switch (type) {
|
|
302
336
|
case "standard":
|
|
@@ -305,9 +339,20 @@ function conversationTypeClause(type: ConversationType) {
|
|
|
305
339
|
// standardListingVisibilitySql for the full predicate semantics.
|
|
306
340
|
return sql.raw(standardListingVisibilitySql());
|
|
307
341
|
case "background":
|
|
308
|
-
return sql
|
|
342
|
+
return sql.raw(backgroundUmbrellaSql());
|
|
309
343
|
case "scheduled":
|
|
310
344
|
return sql`(${conversations.conversationType} = 'scheduled' OR group_id = 'system:scheduled') AND ${notSubagent}`;
|
|
345
|
+
case "all":
|
|
346
|
+
/* `private` is excluded explicitly rather than left to the two arms.
|
|
347
|
+
The standard arm already drops it everywhere, but the umbrella
|
|
348
|
+
matches on the system group as well as on the type, so a legacy
|
|
349
|
+
private row filed in `system:background` would otherwise arrive
|
|
350
|
+
through it. Those rows exist transiently after an in-place snapshot
|
|
351
|
+
restore, before migration cleanup deletes them. */
|
|
352
|
+
return sql.raw(
|
|
353
|
+
`((${standardListingVisibilitySql()} OR ${backgroundUmbrellaSql()})` +
|
|
354
|
+
` AND conversations.conversation_type != 'private')`,
|
|
355
|
+
);
|
|
311
356
|
}
|
|
312
357
|
}
|
|
313
358
|
|
|
@@ -321,7 +366,7 @@ function conversationTypeClause(type: ConversationType) {
|
|
|
321
366
|
* transposition waiting to happen.
|
|
322
367
|
*/
|
|
323
368
|
export interface ConversationListFilter {
|
|
324
|
-
conversationType?:
|
|
369
|
+
conversationType?: ConversationListTypeFilter;
|
|
325
370
|
archiveStatus?: ArchiveStatusFilter;
|
|
326
371
|
originChannel?: string;
|
|
327
372
|
/**
|