@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
|
@@ -302,8 +302,10 @@ function makePendingApprovalConversation(
|
|
|
302
302
|
// Tests
|
|
303
303
|
// ---------------------------------------------------------------------------
|
|
304
304
|
|
|
305
|
-
|
|
306
|
-
|
|
305
|
+
// This suite mocks isHttpAuthDisabled() to true, so a request carrying no
|
|
306
|
+
// bearer authenticates through the dev bypass. A bearer that is present is
|
|
307
|
+
// verified against the signing key, which these tests do not set up.
|
|
308
|
+
const AUTH_HEADERS: Record<string, string> = {};
|
|
307
309
|
|
|
308
310
|
describe("POST /v1/messages — queue-if-busy and hub publishing", () => {
|
|
309
311
|
let server: RuntimeHttpServer;
|
|
@@ -1,45 +1,62 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Enqueue
|
|
3
|
-
* `ask_question` prompt is open for the same conversation, the user has chosen
|
|
4
|
-
* to move on rather than answer it. `steerOnEnqueuedMessageIfQuestionParked`
|
|
5
|
-
* steers to that message — aborting the parked turn (which settles the open
|
|
6
|
-
* question via its turn-abort signal) and draining the message — instead of
|
|
7
|
-
* stranding it behind a prompt no one will answer.
|
|
2
|
+
* Enqueue contract for a message typed while an `ask_question` card is open.
|
|
8
3
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
4
|
+
* The message is read as the answer: `answerParkedQuestionWithEnqueuedMessage`
|
|
5
|
+
* resolves the parked prompt with the typed text as free text and consumes the
|
|
6
|
+
* queued message, so the parked turn keeps running and the model reads the
|
|
7
|
+
* answer once, in the tool result.
|
|
8
|
+
*
|
|
9
|
+
* Everything a single free-text answer cannot honestly stand in for falls back
|
|
10
|
+
* to `steerOnEnqueuedMessageIfQuestionParked`, which supersedes the prompt by
|
|
11
|
+
* aborting the parked turn and draining the message instead. Confirmations and
|
|
12
|
+
* secrets are separate interaction kinds and are untouched by both paths.
|
|
11
13
|
*/
|
|
12
14
|
import { afterEach, describe, expect, test } from "bun:test";
|
|
13
15
|
|
|
14
16
|
import type { Conversation } from "../daemon/conversation.js";
|
|
17
|
+
import type { QueuedMessage } from "../daemon/conversation-queue-manager.js";
|
|
15
18
|
import {
|
|
16
19
|
deleteConversation,
|
|
17
20
|
setConversation,
|
|
18
21
|
} from "../daemon/conversation-registry.js";
|
|
19
22
|
import {
|
|
23
|
+
answerParkedQuestionWithEnqueuedMessage,
|
|
20
24
|
steerOnEnqueuedMessageIfQuestionParked,
|
|
21
25
|
supersedePendingInteractionsOnEnqueue,
|
|
22
26
|
} from "../daemon/handlers/conversations.js";
|
|
27
|
+
import type { QuestionPromptResult } from "../permissions/question-prompter.js";
|
|
23
28
|
import * as pendingInteractions from "../runtime/pending-interactions.js";
|
|
24
29
|
|
|
25
30
|
interface ParkedTurn {
|
|
26
31
|
abortCount: () => number;
|
|
27
32
|
fake: { pendingSteerRepair: boolean };
|
|
33
|
+
/** Queue entries still waiting, by request id. */
|
|
34
|
+
queuedIds: () => string[];
|
|
35
|
+
/** Event types emitted through the queued message's own sink. */
|
|
36
|
+
emittedTypes: () => string[];
|
|
37
|
+
enqueue: (message: Partial<QueuedMessage> & { requestId: string }) => void;
|
|
28
38
|
}
|
|
29
39
|
|
|
30
40
|
/**
|
|
31
41
|
* Register a fake conversation whose in-flight turn is parked. The fake exposes
|
|
32
|
-
* just the surface
|
|
33
|
-
*
|
|
42
|
+
* just the surface the enqueue paths touch: a processing flag, a queue that can
|
|
43
|
+
* be looked up, promoted and removed from, an abort controller that records
|
|
34
44
|
* aborts, and the confirmation-deny hook.
|
|
35
45
|
*/
|
|
36
46
|
function registerParkedTurn(id: string): ParkedTurn {
|
|
37
47
|
let abortCount = 0;
|
|
48
|
+
const emitted: string[] = [];
|
|
49
|
+
const queue = new Map<string, QueuedMessage>();
|
|
38
50
|
const fake = {
|
|
39
51
|
isProcessing: () => true,
|
|
40
52
|
queue: {
|
|
41
|
-
findByRequestId: (requestId: string) => (
|
|
42
|
-
promoteToHead: (requestId: string) => (
|
|
53
|
+
findByRequestId: (requestId: string) => queue.get(requestId),
|
|
54
|
+
promoteToHead: (requestId: string) => queue.get(requestId),
|
|
55
|
+
},
|
|
56
|
+
removeQueuedMessage: (requestId: string) => {
|
|
57
|
+
const item = queue.get(requestId);
|
|
58
|
+
queue.delete(requestId);
|
|
59
|
+
return item;
|
|
43
60
|
},
|
|
44
61
|
pendingSteerRepair: false,
|
|
45
62
|
abortController: {
|
|
@@ -51,23 +68,249 @@ function registerParkedTurn(id: string): ParkedTurn {
|
|
|
51
68
|
denyAllPendingConfirmations: () => {},
|
|
52
69
|
};
|
|
53
70
|
setConversation(id, fake as unknown as Conversation);
|
|
54
|
-
|
|
71
|
+
const enqueue = (
|
|
72
|
+
message: Partial<QueuedMessage> & { requestId: string },
|
|
73
|
+
): void => {
|
|
74
|
+
queue.set(message.requestId, {
|
|
75
|
+
content: "",
|
|
76
|
+
attachments: [],
|
|
77
|
+
onEvent: (event) => {
|
|
78
|
+
emitted.push(event.type);
|
|
79
|
+
},
|
|
80
|
+
sentAt: 0,
|
|
81
|
+
...message,
|
|
82
|
+
} as QueuedMessage);
|
|
83
|
+
};
|
|
84
|
+
// Every case needs a queued message to act on; the plain typed reply is the
|
|
85
|
+
// common one, and cases that need other content re-enqueue over it.
|
|
86
|
+
enqueue({ requestId: "msg-1", content: "next tuesday works" });
|
|
87
|
+
return {
|
|
88
|
+
abortCount: () => abortCount,
|
|
89
|
+
fake,
|
|
90
|
+
queuedIds: () => [...queue.keys()],
|
|
91
|
+
emittedTypes: () => [...emitted],
|
|
92
|
+
enqueue,
|
|
93
|
+
};
|
|
55
94
|
}
|
|
56
95
|
|
|
57
96
|
const registeredRequestIds: string[] = [];
|
|
58
97
|
function registerInteraction(
|
|
59
98
|
conversationId: string,
|
|
60
|
-
kind: "question" | "confirmation",
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
99
|
+
kind: "question" | "confirmation" | "secret",
|
|
100
|
+
options: { requestId?: string; questionIds?: string[] } = {},
|
|
101
|
+
): { requestId: string; result: () => QuestionPromptResult | undefined } {
|
|
102
|
+
const requestId =
|
|
103
|
+
options.requestId ?? `pending-${kind}-${conversationId}-${Date.now()}`;
|
|
104
|
+
let result: QuestionPromptResult | undefined;
|
|
105
|
+
const questionIds = options.questionIds ?? ["q1"];
|
|
106
|
+
pendingInteractions.register(requestId, {
|
|
107
|
+
conversationId,
|
|
108
|
+
kind,
|
|
109
|
+
...(kind === "question"
|
|
110
|
+
? {
|
|
111
|
+
metadata: {
|
|
112
|
+
orderedIds: questionIds,
|
|
113
|
+
optionsById: Object.fromEntries(
|
|
114
|
+
questionIds.map((id) => [id, ["opt-a"]]),
|
|
115
|
+
),
|
|
116
|
+
},
|
|
117
|
+
rpcResolve: (value: unknown) => {
|
|
118
|
+
result = value as QuestionPromptResult;
|
|
119
|
+
},
|
|
120
|
+
}
|
|
121
|
+
: {}),
|
|
122
|
+
});
|
|
64
123
|
registeredRequestIds.push(requestId);
|
|
124
|
+
return { requestId, result: () => result };
|
|
65
125
|
}
|
|
66
126
|
|
|
67
127
|
const QUESTION_CONV = "steer-enqueue-question";
|
|
68
128
|
const CONFIRMATION_CONV = "steer-enqueue-confirmation";
|
|
69
129
|
const NONE_CONV = "steer-enqueue-none";
|
|
70
130
|
|
|
131
|
+
describe("a typed reply answers a parked question", () => {
|
|
132
|
+
afterEach(() => {
|
|
133
|
+
for (const id of registeredRequestIds) {
|
|
134
|
+
pendingInteractions.resolve(id, "cancelled");
|
|
135
|
+
}
|
|
136
|
+
registeredRequestIds.length = 0;
|
|
137
|
+
deleteConversation(QUESTION_CONV);
|
|
138
|
+
deleteConversation(CONFIRMATION_CONV);
|
|
139
|
+
deleteConversation(NONE_CONV);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("resolves a single parked question with the typed text", () => {
|
|
143
|
+
const conv = registerParkedTurn(QUESTION_CONV);
|
|
144
|
+
const question = registerInteraction(QUESTION_CONV, "question");
|
|
145
|
+
|
|
146
|
+
const answered = answerParkedQuestionWithEnqueuedMessage(
|
|
147
|
+
QUESTION_CONV,
|
|
148
|
+
"msg-1",
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
expect(answered).toBe(true);
|
|
152
|
+
expect(question.result()).toEqual({
|
|
153
|
+
entries: [
|
|
154
|
+
{ questionId: "q1", decision: "free_text", text: "next tuesday works" },
|
|
155
|
+
],
|
|
156
|
+
overall: "completed",
|
|
157
|
+
answeredInChat: true,
|
|
158
|
+
});
|
|
159
|
+
// The parked turn keeps running: no abort, no tool-result repair.
|
|
160
|
+
expect(conv.abortCount()).toBe(0);
|
|
161
|
+
expect(conv.fake.pendingSteerRepair).toBe(false);
|
|
162
|
+
// The message reaches the model through the tool result, so it is consumed
|
|
163
|
+
// rather than run as a turn of its own, and the client's queued row is
|
|
164
|
+
// retired the way a queue delete retires it.
|
|
165
|
+
expect(conv.queuedIds()).toEqual([]);
|
|
166
|
+
expect(conv.emittedTypes()).toEqual(["message_queued_deleted"]);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("answers the first question of a batch and leaves the rest unanswered", () => {
|
|
170
|
+
const conv = registerParkedTurn(QUESTION_CONV);
|
|
171
|
+
const question = registerInteraction(QUESTION_CONV, "question", {
|
|
172
|
+
questionIds: ["q1", "q2", "q3"],
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const answered = answerParkedQuestionWithEnqueuedMessage(
|
|
176
|
+
QUESTION_CONV,
|
|
177
|
+
"msg-1",
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
expect(answered).toBe(true);
|
|
181
|
+
expect(question.result()).toEqual({
|
|
182
|
+
entries: [
|
|
183
|
+
{ questionId: "q1", decision: "free_text", text: "next tuesday works" },
|
|
184
|
+
{ questionId: "q2", decision: "skipped" },
|
|
185
|
+
{ questionId: "q3", decision: "skipped" },
|
|
186
|
+
],
|
|
187
|
+
overall: "completed",
|
|
188
|
+
answeredInChat: true,
|
|
189
|
+
});
|
|
190
|
+
expect(conv.abortCount()).toBe(0);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("supersedes instead when the message carries an attachment", () => {
|
|
194
|
+
const conv = registerParkedTurn(QUESTION_CONV);
|
|
195
|
+
const question = registerInteraction(QUESTION_CONV, "question");
|
|
196
|
+
conv.enqueue({
|
|
197
|
+
requestId: "msg-1",
|
|
198
|
+
content: "here it is",
|
|
199
|
+
attachments: [
|
|
200
|
+
{
|
|
201
|
+
id: "att-1",
|
|
202
|
+
filename: "notes.txt",
|
|
203
|
+
mimeType: "text/plain",
|
|
204
|
+
data: "",
|
|
205
|
+
},
|
|
206
|
+
],
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
supersedePendingInteractionsOnEnqueue(QUESTION_CONV, "msg-1");
|
|
210
|
+
|
|
211
|
+
expect(question.result()).toBeUndefined();
|
|
212
|
+
expect(conv.abortCount()).toBe(1);
|
|
213
|
+
expect(conv.queuedIds()).toEqual(["msg-1"]);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("supersedes instead when the message is a slash command", () => {
|
|
217
|
+
const conv = registerParkedTurn(QUESTION_CONV);
|
|
218
|
+
const question = registerInteraction(QUESTION_CONV, "question");
|
|
219
|
+
conv.enqueue({ requestId: "msg-1", content: "/compact" });
|
|
220
|
+
|
|
221
|
+
supersedePendingInteractionsOnEnqueue(QUESTION_CONV, "msg-1");
|
|
222
|
+
|
|
223
|
+
expect(question.result()).toBeUndefined();
|
|
224
|
+
expect(conv.abortCount()).toBe(1);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("supersedes instead when the message is blank", () => {
|
|
228
|
+
const conv = registerParkedTurn(QUESTION_CONV);
|
|
229
|
+
const question = registerInteraction(QUESTION_CONV, "question");
|
|
230
|
+
conv.enqueue({ requestId: "msg-1", content: " " });
|
|
231
|
+
|
|
232
|
+
supersedePendingInteractionsOnEnqueue(QUESTION_CONV, "msg-1");
|
|
233
|
+
|
|
234
|
+
expect(question.result()).toBeUndefined();
|
|
235
|
+
expect(conv.abortCount()).toBe(1);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("supersedes instead when the send is automated rather than typed", () => {
|
|
239
|
+
const conv = registerParkedTurn(QUESTION_CONV);
|
|
240
|
+
const question = registerInteraction(QUESTION_CONV, "question");
|
|
241
|
+
conv.enqueue({
|
|
242
|
+
requestId: "msg-1",
|
|
243
|
+
content: "scheduled follow-up",
|
|
244
|
+
metadata: { automated: true },
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
supersedePendingInteractionsOnEnqueue(QUESTION_CONV, "msg-1");
|
|
248
|
+
|
|
249
|
+
expect(question.result()).toBeUndefined();
|
|
250
|
+
expect(conv.abortCount()).toBe(1);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("supersedes instead when two questions are parked at once", () => {
|
|
254
|
+
// Which prompt the text answers would be a guess, so the old behavior
|
|
255
|
+
// stands: abort the turn and run the message as its own turn.
|
|
256
|
+
const conv = registerParkedTurn(QUESTION_CONV);
|
|
257
|
+
const first = registerInteraction(QUESTION_CONV, "question", {
|
|
258
|
+
requestId: "question-a",
|
|
259
|
+
});
|
|
260
|
+
const second = registerInteraction(QUESTION_CONV, "question", {
|
|
261
|
+
requestId: "question-b",
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
supersedePendingInteractionsOnEnqueue(QUESTION_CONV, "msg-1");
|
|
265
|
+
|
|
266
|
+
expect(first.result()).toBeUndefined();
|
|
267
|
+
expect(second.result()).toBeUndefined();
|
|
268
|
+
expect(conv.abortCount()).toBe(1);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("leaves a parked secret alone while answering the question", () => {
|
|
272
|
+
const conv = registerParkedTurn(QUESTION_CONV);
|
|
273
|
+
const secret = registerInteraction(QUESTION_CONV, "secret");
|
|
274
|
+
const question = registerInteraction(QUESTION_CONV, "question");
|
|
275
|
+
|
|
276
|
+
supersedePendingInteractionsOnEnqueue(QUESTION_CONV, "msg-1");
|
|
277
|
+
|
|
278
|
+
expect(question.result()?.overall).toBe("completed");
|
|
279
|
+
expect(conv.abortCount()).toBe(0);
|
|
280
|
+
expect(pendingInteractions.get(secret.requestId)?.kind).toBe("secret");
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("does not answer from a pending confirmation (not a question)", () => {
|
|
284
|
+
const conv = registerParkedTurn(CONFIRMATION_CONV);
|
|
285
|
+
registerInteraction(CONFIRMATION_CONV, "confirmation");
|
|
286
|
+
|
|
287
|
+
const answered = answerParkedQuestionWithEnqueuedMessage(
|
|
288
|
+
CONFIRMATION_CONV,
|
|
289
|
+
"msg-1",
|
|
290
|
+
);
|
|
291
|
+
const steered = steerOnEnqueuedMessageIfQuestionParked(
|
|
292
|
+
CONFIRMATION_CONV,
|
|
293
|
+
"msg-1",
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
expect(answered).toBe(false);
|
|
297
|
+
expect(steered).toBe(false);
|
|
298
|
+
expect(conv.abortCount()).toBe(0);
|
|
299
|
+
// The message is left on the queue to run as its own turn.
|
|
300
|
+
expect(conv.queuedIds()).toEqual(["msg-1"]);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test("does nothing when no prompt is parked", () => {
|
|
304
|
+
const conv = registerParkedTurn(NONE_CONV);
|
|
305
|
+
|
|
306
|
+
supersedePendingInteractionsOnEnqueue(NONE_CONV, "msg-1");
|
|
307
|
+
|
|
308
|
+
expect(conv.abortCount()).toBe(0);
|
|
309
|
+
expect(conv.queuedIds()).toEqual(["msg-1"]);
|
|
310
|
+
expect(conv.emittedTypes()).toEqual([]);
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
71
314
|
describe("steerOnEnqueuedMessageIfQuestionParked", () => {
|
|
72
315
|
afterEach(() => {
|
|
73
316
|
for (const id of registeredRequestIds) {
|
|
@@ -134,27 +377,15 @@ describe("steerOnEnqueuedMessageIfQuestionParked", () => {
|
|
|
134
377
|
expect(steered).toBe(false);
|
|
135
378
|
expect(conv.abortCount()).toBe(0);
|
|
136
379
|
});
|
|
137
|
-
|
|
138
|
-
test("supersedePendingInteractionsOnEnqueue steers a parked question", () => {
|
|
139
|
-
// The centralized routine is invoked by both the HTTP send handler and the
|
|
140
|
-
// CLI signal path. With no pending confirmation it steers a parked question
|
|
141
|
-
// — the behavior the CLI signal path previously lacked.
|
|
142
|
-
const conv = registerParkedTurn(QUESTION_CONV);
|
|
143
|
-
registerInteraction(QUESTION_CONV, "question");
|
|
144
|
-
|
|
145
|
-
supersedePendingInteractionsOnEnqueue(QUESTION_CONV, "msg-1");
|
|
146
|
-
|
|
147
|
-
expect(conv.abortCount()).toBe(1);
|
|
148
|
-
expect(conv.fake.pendingSteerRepair).toBe(true);
|
|
149
|
-
});
|
|
150
380
|
});
|
|
151
381
|
|
|
152
382
|
describe("removeByConversation preserves question interactions", () => {
|
|
153
383
|
// The enqueue path's confirmation auto-deny calls removeByConversation before
|
|
154
|
-
// the
|
|
155
|
-
// steer's turn abort
|
|
156
|
-
//
|
|
157
|
-
//
|
|
384
|
+
// the question is answered or steered to. Questions must survive it (they
|
|
385
|
+
// are settled instead by the answer or by the steer's turn abort) or, when
|
|
386
|
+
// an ask_question and a confirmation are pending concurrently, the queued
|
|
387
|
+
// message would strand behind a question whose entry was cleared (and whose
|
|
388
|
+
// Promise was never settled) before either path fired.
|
|
158
389
|
const CONV = "remove-by-conv-preserve-question";
|
|
159
390
|
const ids: string[] = [];
|
|
160
391
|
function register(kind: "question" | "confirmation"): void {
|
|
@@ -44,7 +44,11 @@ Examples:
|
|
|
44
44
|
description:
|
|
45
45
|
"Destination address for outbound verification (handle, phone number, user ID, or email address)",
|
|
46
46
|
},
|
|
47
|
-
{
|
|
47
|
+
{
|
|
48
|
+
flags: "--rebind",
|
|
49
|
+
description:
|
|
50
|
+
"Create a code although the channel already has a guardian (see below)",
|
|
51
|
+
},
|
|
48
52
|
{
|
|
49
53
|
flags: "--conversation-id <conversationId>",
|
|
50
54
|
description: "Conversation ID for inbound challenges",
|
|
@@ -74,7 +78,13 @@ Routes between three creation modes based on the provided options:
|
|
|
74
78
|
2. Outbound: --channel <ch> --destination <dest>
|
|
75
79
|
Sends a verification code to the given destination. Supports telegram
|
|
76
80
|
(handle or chat ID), phone (E.164 number), slack and discord (user ID),
|
|
77
|
-
and email.
|
|
81
|
+
and email.
|
|
82
|
+
|
|
83
|
+
--rebind creates a code although the channel already has a guardian. On
|
|
84
|
+
telegram, slack, discord and email, only the account already bound can redeem
|
|
85
|
+
that code, which is how the same guardian verifies again. A code never moves
|
|
86
|
+
the guardian to a different account there: run "revoke" first, then create
|
|
87
|
+
without --rebind. On phone, an outbound code replaces the bound number.
|
|
78
88
|
|
|
79
89
|
3. Inbound: --channel <ch> (no --destination)
|
|
80
90
|
Generates a challenge secret for the guardian to send back on the
|
|
@@ -176,16 +176,16 @@
|
|
|
176
176
|
"icon": "\ud83d\udcbb"
|
|
177
177
|
},
|
|
178
178
|
{
|
|
179
|
-
"name": "ecommerce-
|
|
179
|
+
"name": "ecommerce-order-supporter",
|
|
180
180
|
"description": "Resolves ecommerce Where-Is-My-Order (WISMO) support inquiries, cross-checks real-time carrier tracking milestones, flags delivery exceptions, and stages customer update drafts without helpdesk seat taxes.",
|
|
181
181
|
"category": "commerce",
|
|
182
182
|
"icon": "📦",
|
|
183
183
|
"license": "MIT",
|
|
184
|
-
"homepage": "https://github.com/ZeebBoyBlue/ecommerce-
|
|
184
|
+
"homepage": "https://github.com/ZeebBoyBlue/ecommerce-order-supporter",
|
|
185
185
|
"source": {
|
|
186
186
|
"source": "github",
|
|
187
|
-
"repo": "ZeebBoyBlue/ecommerce-
|
|
188
|
-
"ref": "
|
|
187
|
+
"repo": "ZeebBoyBlue/ecommerce-order-supporter",
|
|
188
|
+
"ref": "35e2165a14447a897aee750a198c3f166458226b"
|
|
189
189
|
}
|
|
190
190
|
},
|
|
191
191
|
{
|
|
@@ -170,14 +170,6 @@
|
|
|
170
170
|
"description": "Enable Anthropic fast mode for Opus models (4.6, 4.7, 4.8), delivering up to 2.5x higher output tokens per second at premium pricing",
|
|
171
171
|
"defaultEnabled": false
|
|
172
172
|
},
|
|
173
|
-
{
|
|
174
|
-
"id": "teleport",
|
|
175
|
-
"scope": "client",
|
|
176
|
-
"key": "teleport",
|
|
177
|
-
"label": "Teleport",
|
|
178
|
-
"description": "Enable the local-to-platform teleport UI in General settings. Platform-to-local teleport is GA and not gated by this flag",
|
|
179
|
-
"defaultEnabled": false
|
|
180
|
-
},
|
|
181
173
|
{
|
|
182
174
|
"id": "apple-container",
|
|
183
175
|
"scope": "client",
|
|
@@ -559,6 +551,14 @@
|
|
|
559
551
|
"label": "Assistant Inbox",
|
|
560
552
|
"description": "Shows an Assistant Inbox entry above Preferences in the web sidebar and the /assistant/inbox page it opens: a mailbox for the assistant's managed vellum.me address (received and sent, with a reading pane), the inline email setup card for entitled orgs with no address yet, and an upgrade card for orgs without the managed_email entitlement. Off hides the entry and redirects the route to chat.",
|
|
561
553
|
"defaultEnabled": false
|
|
554
|
+
},
|
|
555
|
+
{
|
|
556
|
+
"id": "sidebar-done",
|
|
557
|
+
"scope": "both",
|
|
558
|
+
"key": "sidebar-done",
|
|
559
|
+
"label": "Sidebar Done",
|
|
560
|
+
"description": "Turns archiving a chat into marking it done: the sidebar row carries a Done check instead of the overflow menu, an Old chats page lists the full history, and done chats stay findable in search by default. Off keeps today's Archive wording, the overflow menu, and search that hides archived rows unless the query asks for them.",
|
|
561
|
+
"defaultEnabled": false
|
|
562
562
|
}
|
|
563
563
|
]
|
|
564
564
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { isAssistantFeatureFlagEnabled } from "./assistant-feature-flags.js";
|
|
2
|
+
import type { AssistantConfig } from "./schema.js";
|
|
3
|
+
|
|
4
|
+
export const SIDEBAR_DONE_FLAG_KEY = "sidebar-done" as const;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Whether archiving a chat means marking it done.
|
|
8
|
+
*
|
|
9
|
+
* The flag is `both`-scoped: clients gate the Done affordances and the Old
|
|
10
|
+
* chats page on it, and the daemon gates the halves the feature needs. Done is
|
|
11
|
+
* `archived_at`, so no row changes shape in either state.
|
|
12
|
+
*
|
|
13
|
+
* On the daemon side there are two. The first is search reach: off, an
|
|
14
|
+
* archived conversation is hidden from search unless the query names it
|
|
15
|
+
* (`is:archived`); on, a done chat is findable by default and `is:unarchived`
|
|
16
|
+
* is the opt-out, because a chat the user marked done is filed, not deleted,
|
|
17
|
+
* and search is how they get back to it.
|
|
18
|
+
*
|
|
19
|
+
* The second is that a done chat keeps working. Off, an archived conversation
|
|
20
|
+
* rejects every wake and nothing but the unarchive route clears the column, so
|
|
21
|
+
* marking a chat done silently kills its schedule or its channel thread. On, a
|
|
22
|
+
* wake still runs on it, and a message the user reads landing in it brings the
|
|
23
|
+
* conversation back to the list.
|
|
24
|
+
*/
|
|
25
|
+
export function isSidebarDoneEnabled(config?: AssistantConfig): boolean {
|
|
26
|
+
return isAssistantFeatureFlagEnabled(SIDEBAR_DONE_FLAG_KEY, config);
|
|
27
|
+
}
|
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
getConversation,
|
|
41
41
|
getMessageById,
|
|
42
42
|
parseMessageMetadata,
|
|
43
|
+
resurfaceArchivedConversation,
|
|
43
44
|
} from "../persistence/conversation-crud.js";
|
|
44
45
|
import { getResolvedConversationDirPath } from "../persistence/conversation-directories.js";
|
|
45
46
|
import { syncMessageToDisk } from "../persistence/conversation-disk-view.js";
|
|
@@ -150,6 +151,17 @@ export function buildDeferredFinalizeEffect(params: {
|
|
|
150
151
|
"Failed to project assistant message for attention tracking (non-fatal)",
|
|
151
152
|
);
|
|
152
153
|
}
|
|
154
|
+
// The row this turn reserved was empty at insert, so this is where its
|
|
155
|
+
// content becomes readable and a Done conversation earns its way back to
|
|
156
|
+
// the list. A run that never got this far produced nothing to read.
|
|
157
|
+
try {
|
|
158
|
+
resurfaceArchivedConversation(conversationId, finalizedRow.createdAt);
|
|
159
|
+
} catch (err) {
|
|
160
|
+
rlog.warn(
|
|
161
|
+
{ err, conversationId, messageId: assistantMessageId },
|
|
162
|
+
"Failed to resurface Done conversation after finalize (non-fatal)",
|
|
163
|
+
);
|
|
164
|
+
}
|
|
153
165
|
};
|
|
154
166
|
}
|
|
155
167
|
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the `includeArchived` option on the agent-facing conversation
|
|
3
|
+
* history search.
|
|
4
|
+
*
|
|
5
|
+
* The `"*"` wildcard takes its own branch (FTS reads `*` as a literal), so it
|
|
6
|
+
* has to honor the option through the list read rather than through
|
|
7
|
+
* `searchConversations`, which has its own coverage in
|
|
8
|
+
* `persistence/__tests__/conversation-queries-search.test.ts`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
12
|
+
|
|
13
|
+
import { createConversation } from "../../../persistence/conversation-crud.js";
|
|
14
|
+
import { getDb } from "../../../persistence/db-connection.js";
|
|
15
|
+
import { initializeDb } from "../../../persistence/db-init.js";
|
|
16
|
+
import { rawRun } from "../../../persistence/raw-query.js";
|
|
17
|
+
import { conversations } from "../../../persistence/schema/index.js";
|
|
18
|
+
import { performConversationSearch } from "../conversation-history.js";
|
|
19
|
+
|
|
20
|
+
await initializeDb();
|
|
21
|
+
|
|
22
|
+
function seedArchived(title: string): string {
|
|
23
|
+
const conv = createConversation({ title });
|
|
24
|
+
rawRun(
|
|
25
|
+
"test:archiveConversation",
|
|
26
|
+
"UPDATE conversations SET archived_at = ? WHERE id = ?",
|
|
27
|
+
Date.now(),
|
|
28
|
+
conv.id,
|
|
29
|
+
);
|
|
30
|
+
return conv.id;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("performConversationSearch · wildcard listing", () => {
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
getDb().delete(conversations).run();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("omits archived conversations by default", async () => {
|
|
39
|
+
createConversation("live-1");
|
|
40
|
+
seedArchived("archived-1");
|
|
41
|
+
|
|
42
|
+
const results = await performConversationSearch({ query: "*" });
|
|
43
|
+
|
|
44
|
+
expect(results.map((r) => r.conversationTitle)).toEqual(["live-1"]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("includeArchived reaches archived conversations", async () => {
|
|
48
|
+
createConversation("live-1");
|
|
49
|
+
seedArchived("archived-1");
|
|
50
|
+
|
|
51
|
+
const results = await performConversationSearch({
|
|
52
|
+
query: "*",
|
|
53
|
+
includeArchived: true,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
expect(results.map((r) => r.conversationTitle).sort()).toEqual([
|
|
57
|
+
"archived-1",
|
|
58
|
+
"live-1",
|
|
59
|
+
]);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -156,7 +156,9 @@ export async function createInboundChallenge(
|
|
|
156
156
|
success: false,
|
|
157
157
|
error: "already_bound",
|
|
158
158
|
message:
|
|
159
|
-
|
|
159
|
+
resolvedChannel === "phone"
|
|
160
|
+
? "A guardian is already bound for this channel. On phone, verify through an outbound call with rebind: true. To a new number, it replaces the bound number once the code is entered. To the bound number, it confirms that number again and leaves the binding as it is. An inbound challenge cannot be completed by phone: a call collects a six-digit code, which an inbound challenge does not issue."
|
|
161
|
+
: "A guardian is already bound for this channel. To verify a different account, revoke the existing binding first. Set rebind: true only to verify the same account again.",
|
|
160
162
|
channel: resolvedChannel,
|
|
161
163
|
};
|
|
162
164
|
}
|
|
@@ -14,6 +14,12 @@ export interface ConversationSearchParams {
|
|
|
14
14
|
query: string;
|
|
15
15
|
limit?: number;
|
|
16
16
|
maxMessagesPerConversation?: number;
|
|
17
|
+
/**
|
|
18
|
+
* Reach archived conversations as well as live ones. Defaults to false, so
|
|
19
|
+
* this search matches what the sidebar shows. Callers opt in where an
|
|
20
|
+
* archived chat is a filed chat rather than a hidden one.
|
|
21
|
+
*/
|
|
22
|
+
includeArchived?: boolean;
|
|
17
23
|
}
|
|
18
24
|
|
|
19
25
|
/** Search conversations and return results (no transport dependency). */
|
|
@@ -22,7 +28,10 @@ export async function performConversationSearch(
|
|
|
22
28
|
) {
|
|
23
29
|
// Treat "*" as a list-all wildcard — FTS treats it as a literal character.
|
|
24
30
|
if (params.query.trim() === "*") {
|
|
25
|
-
const rows = listConversations({
|
|
31
|
+
const rows = listConversations({
|
|
32
|
+
limit: params.limit,
|
|
33
|
+
archiveStatus: params.includeArchived ? "all" : "active",
|
|
34
|
+
});
|
|
26
35
|
return rows.map((r) => ({
|
|
27
36
|
conversationId: r.id,
|
|
28
37
|
conversationTitle: r.title,
|
|
@@ -33,6 +42,7 @@ export async function performConversationSearch(
|
|
|
33
42
|
return searchConversations(params.query, {
|
|
34
43
|
limit: params.limit,
|
|
35
44
|
maxMessagesPerConversation: params.maxMessagesPerConversation,
|
|
45
|
+
includeArchived: params.includeArchived,
|
|
36
46
|
});
|
|
37
47
|
}
|
|
38
48
|
|