@vellumai/assistant 0.12.3 → 0.12.4-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/Dockerfile +7 -0
- package/README.md +12 -12
- package/docker/mousepad/90-vellum.gschema.override +4 -0
- package/openapi.yaml +9 -3
- 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__/config-loader-backfill.test.ts +1 -0
- package/src/__tests__/conversation-resurface-done.test.ts +289 -0
- package/src/__tests__/default-profile-catalog-fallback.test.ts +1 -0
- package/src/__tests__/gemini-provider.test.ts +31 -0
- package/src/__tests__/inference-profile-validation-jev.test.ts +17 -0
- package/src/__tests__/llm-catalog-parity.test.ts +4 -0
- package/src/__tests__/llm-resolver-override-or-default.test.ts +43 -0
- package/src/__tests__/plugin-api-model-profiles.test.ts +7 -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/__tests__/workspace-migration-159-rename-colliding-jev-profile-name.test.ts +197 -0
- package/src/api/requests/dictation.ts +2 -2
- package/src/calls/__tests__/typesafe-noul.test.ts +66 -0
- package/src/calls/typesafe-noul.ts +8 -2
- package/src/calls/voice-escalation-judge.ts +5 -4
- package/src/cli/commands/channel-verification-sessions.help.ts +12 -2
- package/src/cli/lib/bundled-marketplace.json +4 -4
- package/src/config/__tests__/default-profile-catalog.test.ts +33 -1
- package/src/config/__tests__/jev-managed-profile.test.ts +82 -0
- package/src/config/call-site-defaults.ts +11 -11
- package/src/config/default-profile-catalog.ts +88 -18
- package/src/config/default-profile-names.ts +35 -0
- package/src/config/feature-flag-registry.json +8 -8
- package/src/config/inference-profile-validation.ts +7 -6
- package/src/config/llm-resolver.ts +16 -0
- package/src/config/profile-text-generation.test.ts +9 -0
- package/src/config/schemas/call-site-catalog.ts +1 -1
- package/src/config/schemas/llm.ts +54 -9
- package/src/config/sidebar-done-gate.ts +27 -0
- package/src/daemon/conversation-slash.ts +2 -2
- package/src/daemon/conversation-turn-finalize.ts +12 -0
- package/src/daemon/dictation-text-processing.ts +4 -4
- 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/plugin-api/model-profiles.ts +5 -3
- 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/model-catalog.ts +23 -4
- 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__/dictation-processing.test.ts +174 -0
- package/src/runtime/routes/__tests__/global-search-routes.test.ts +74 -1
- package/src/runtime/routes/__tests__/llm-call-sites-routes.test.ts +16 -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/conversation-routes.ts +4 -2
- package/src/runtime/routes/diagnostics-routes.ts +66 -229
- package/src/runtime/routes/global-search-routes.ts +22 -7
- package/src/runtime/routes/llm-call-sites-routes.ts +19 -10
- 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/tools/workflows/manage-workflows.ts +4 -2
- package/src/util/worker-pipe.test.ts +100 -0
- package/src/util/worker-pipe.ts +44 -0
- package/src/workspace/migrations/159-rename-colliding-jev-profile-name.ts +344 -0
- package/src/workspace/migrations/registry.ts +2 -0
- package/src/runtime/routes/__tests__/dictation-cleanup-guard.test.ts +0 -107
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Shared resolution core for pending `ask_question` interactions.
|
|
3
3
|
*
|
|
4
4
|
* A pending `question` interaction (registered by {@link QuestionPrompter}) can
|
|
5
|
-
* be resolved from
|
|
5
|
+
* be resolved from three places:
|
|
6
6
|
*
|
|
7
7
|
* - `POST /v1/question-response` — an app/web client submits the user's
|
|
8
8
|
* batched selection (see `routes/question-routes.ts`).
|
|
@@ -10,8 +10,11 @@
|
|
|
10
10
|
* or bare-text answer routes through the guardian reply router to the
|
|
11
11
|
* `pending_question` resolver, which builds the submission (see
|
|
12
12
|
* `approvals/guardian-request-resolvers.ts`).
|
|
13
|
+
* - the enqueue path: a message typed into the chat while the card is open
|
|
14
|
+
* answers the parked question as free text (see
|
|
15
|
+
* `daemon/handlers/conversations.ts`).
|
|
13
16
|
*
|
|
14
|
-
*
|
|
17
|
+
* All funnel through {@link resolvePendingQuestion} so the validate → consume
|
|
15
18
|
* → `rpcResolve` sequence has a single implementation. The helper is
|
|
16
19
|
* transport-agnostic: it returns a discriminated outcome rather than throwing
|
|
17
20
|
* route errors, so each caller maps the outcome to its own surface (HTTP status
|
|
@@ -28,12 +31,20 @@ import {
|
|
|
28
31
|
import * as pendingInteractions from "./pending-interactions.js";
|
|
29
32
|
|
|
30
33
|
/**
|
|
31
|
-
* How to resolve a pending question:
|
|
32
|
-
*
|
|
34
|
+
* How to resolve a pending question:
|
|
35
|
+
*
|
|
36
|
+
* - `submit`: a batched submission, one entry per question.
|
|
37
|
+
* - `close`: the card was dismissed; every question is reported as
|
|
38
|
+
* `skipped`.
|
|
39
|
+
* - `chat_reply`: the user typed a message into the chat while the card was
|
|
40
|
+
* open. The text answers the first question as free text and the rest stay
|
|
41
|
+
* `skipped`; the result is flagged so the tool tells the model where the
|
|
42
|
+
* answer came from.
|
|
33
43
|
*/
|
|
34
44
|
export type QuestionResolutionInput =
|
|
35
45
|
| { kind: "submit"; submissions: QuestionBatchSubmission[] }
|
|
36
|
-
| { kind: "close" }
|
|
46
|
+
| { kind: "close" }
|
|
47
|
+
| { kind: "chat_reply"; text: string };
|
|
37
48
|
|
|
38
49
|
/**
|
|
39
50
|
* Outcome of {@link resolvePendingQuestion}.
|
|
@@ -57,9 +68,10 @@ export type QuestionResolutionOutcome =
|
|
|
57
68
|
| { status: "invalid"; message: string };
|
|
58
69
|
|
|
59
70
|
/**
|
|
60
|
-
* Resolve a pending `question` interaction with a batched submission
|
|
61
|
-
*
|
|
62
|
-
* outcome leaves the pending prompt (and its timer) intact for a
|
|
71
|
+
* Resolve a pending `question` interaction with a batched submission, a close,
|
|
72
|
+
* or a chat reply. Validation runs BEFORE the interaction is consumed, so an
|
|
73
|
+
* `invalid` outcome leaves the pending prompt (and its timer) intact for a
|
|
74
|
+
* retry.
|
|
63
75
|
*/
|
|
64
76
|
export function resolvePendingQuestion(
|
|
65
77
|
requestId: string,
|
|
@@ -81,6 +93,8 @@ export function resolvePendingQuestion(
|
|
|
81
93
|
})),
|
|
82
94
|
overall: "closed",
|
|
83
95
|
};
|
|
96
|
+
} else if (input.kind === "chat_reply") {
|
|
97
|
+
result = buildChatReplyResult(input.text, interaction);
|
|
84
98
|
} else {
|
|
85
99
|
result = buildCompletedResult(input.submissions, interaction);
|
|
86
100
|
}
|
|
@@ -136,6 +150,38 @@ function buildCompletedResult(
|
|
|
136
150
|
return { entries, overall: "completed" };
|
|
137
151
|
}
|
|
138
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Build the result for a chat reply: the typed text answers the first question
|
|
155
|
+
* as free text, every other question stays `skipped`.
|
|
156
|
+
*
|
|
157
|
+
* The daemon holds no partial per-question state (the card keeps the user's
|
|
158
|
+
* in-progress answers locally until the whole batch is posted), so the first
|
|
159
|
+
* question is by construction the first unanswered one. Which question the
|
|
160
|
+
* text really speaks to is the model's call, not the daemon's: the flag on the
|
|
161
|
+
* result tells it the answer came from the chat rather than the card, and it
|
|
162
|
+
* maps the reply onto the batch from there.
|
|
163
|
+
*/
|
|
164
|
+
function buildChatReplyResult(
|
|
165
|
+
text: string,
|
|
166
|
+
interaction: ReturnType<typeof pendingInteractions.get>,
|
|
167
|
+
): QuestionPromptResult {
|
|
168
|
+
const { orderedIds } = readBatchMetadata(interaction);
|
|
169
|
+
if (orderedIds.length === 0) {
|
|
170
|
+
throw new QuestionBatchValidationError(
|
|
171
|
+
"No registered question ids for this batch",
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
entries: orderedIds.map((id, index) =>
|
|
176
|
+
index === 0
|
|
177
|
+
? { questionId: id, decision: "free_text" as const, text }
|
|
178
|
+
: { questionId: id, decision: "skipped" as const },
|
|
179
|
+
),
|
|
180
|
+
overall: "completed",
|
|
181
|
+
answeredInChat: true,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
139
185
|
/**
|
|
140
186
|
* Pull the prompter-stashed batch bookkeeping off a pending interaction.
|
|
141
187
|
* Returns empty defaults if the metadata is absent.
|
|
@@ -1302,3 +1302,149 @@ describe("GET /v1/conversations/sections", () => {
|
|
|
1302
1302
|
expect(sections.some((s) => s.kind === "pinned")).toBe(false);
|
|
1303
1303
|
});
|
|
1304
1304
|
});
|
|
1305
|
+
|
|
1306
|
+
describe("GET /v1/conversations, conversationType=all", () => {
|
|
1307
|
+
beforeEach(() => {
|
|
1308
|
+
clearConversations();
|
|
1309
|
+
});
|
|
1310
|
+
|
|
1311
|
+
function setLastMessageAt(id: string, at: number): void {
|
|
1312
|
+
rawRun(
|
|
1313
|
+
"test:setLastMessageAt",
|
|
1314
|
+
"UPDATE conversations SET last_message_at = ? WHERE id = ?",
|
|
1315
|
+
at,
|
|
1316
|
+
id,
|
|
1317
|
+
);
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
test("returns the standard listing and the background umbrella together", async () => {
|
|
1321
|
+
createConversation("foreground-1");
|
|
1322
|
+
createConversation({ title: "bg-1", conversationType: "background" });
|
|
1323
|
+
createConversation({ title: "sched-1", conversationType: "scheduled" });
|
|
1324
|
+
|
|
1325
|
+
const result = (await invoke({ conversationType: "all" })) as ListResponse;
|
|
1326
|
+
|
|
1327
|
+
expect(result.conversations.map((c) => c.title).sort()).toEqual([
|
|
1328
|
+
"bg-1",
|
|
1329
|
+
"foreground-1",
|
|
1330
|
+
"sched-1",
|
|
1331
|
+
]);
|
|
1332
|
+
});
|
|
1333
|
+
|
|
1334
|
+
test("with archiveStatus=all one page spans live and done rows of every type", async () => {
|
|
1335
|
+
createConversation("foreground-1");
|
|
1336
|
+
createConversation({ title: "bg-1", conversationType: "background" });
|
|
1337
|
+
seedArchived("archived-1");
|
|
1338
|
+
const archivedBackground = createConversation({
|
|
1339
|
+
title: "archived-bg",
|
|
1340
|
+
conversationType: "background",
|
|
1341
|
+
});
|
|
1342
|
+
rawRun(
|
|
1343
|
+
"test:archiveConversation",
|
|
1344
|
+
"UPDATE conversations SET archived_at = ? WHERE id = ?",
|
|
1345
|
+
Date.now(),
|
|
1346
|
+
archivedBackground.id,
|
|
1347
|
+
);
|
|
1348
|
+
|
|
1349
|
+
const result = (await invoke({
|
|
1350
|
+
conversationType: "all",
|
|
1351
|
+
archiveStatus: "all",
|
|
1352
|
+
})) as ListResponse;
|
|
1353
|
+
|
|
1354
|
+
expect(result.conversations.map((c) => c.title).sort()).toEqual([
|
|
1355
|
+
"archived-1",
|
|
1356
|
+
"archived-bg",
|
|
1357
|
+
"bg-1",
|
|
1358
|
+
"foreground-1",
|
|
1359
|
+
]);
|
|
1360
|
+
expect(result.hasMore).toBe(false);
|
|
1361
|
+
});
|
|
1362
|
+
|
|
1363
|
+
test("orders by last message time descending across the types it merges", async () => {
|
|
1364
|
+
const oldest = createConversation("oldest-foreground");
|
|
1365
|
+
const middle = createConversation({
|
|
1366
|
+
title: "middle-background",
|
|
1367
|
+
conversationType: "background",
|
|
1368
|
+
});
|
|
1369
|
+
const newest = createConversation("newest-foreground");
|
|
1370
|
+
setLastMessageAt(oldest.id, 1000);
|
|
1371
|
+
setLastMessageAt(middle.id, 2000);
|
|
1372
|
+
setLastMessageAt(newest.id, 3000);
|
|
1373
|
+
|
|
1374
|
+
const result = (await invoke({ conversationType: "all" })) as ListResponse;
|
|
1375
|
+
|
|
1376
|
+
expect(result.conversations.map((c) => c.title)).toEqual([
|
|
1377
|
+
"newest-foreground",
|
|
1378
|
+
"middle-background",
|
|
1379
|
+
"oldest-foreground",
|
|
1380
|
+
]);
|
|
1381
|
+
});
|
|
1382
|
+
|
|
1383
|
+
test("excludes legacy private rows and subagent runs", async () => {
|
|
1384
|
+
createConversation("foreground-1");
|
|
1385
|
+
const legacyPrivate = createConversation("legacy-private");
|
|
1386
|
+
rawRun(
|
|
1387
|
+
"test:legacyPrivateRow",
|
|
1388
|
+
"UPDATE conversations SET conversation_type = 'private', group_id = 'system:background' WHERE id = ?",
|
|
1389
|
+
legacyPrivate.id,
|
|
1390
|
+
);
|
|
1391
|
+
const subagent = createConversation({
|
|
1392
|
+
title: "subagent-run",
|
|
1393
|
+
conversationType: "background",
|
|
1394
|
+
});
|
|
1395
|
+
rawRun(
|
|
1396
|
+
"test:subagentRun",
|
|
1397
|
+
"UPDATE conversations SET source = 'subagent' WHERE id = ?",
|
|
1398
|
+
subagent.id,
|
|
1399
|
+
);
|
|
1400
|
+
|
|
1401
|
+
const result = (await invoke({ conversationType: "all" })) as ListResponse;
|
|
1402
|
+
|
|
1403
|
+
expect(result.conversations.map((c) => c.title)).toEqual(["foreground-1"]);
|
|
1404
|
+
});
|
|
1405
|
+
|
|
1406
|
+
test("never appends pinned rows outside the page", async () => {
|
|
1407
|
+
// Pinned injection is the compatibility shim for the default sidebar
|
|
1408
|
+
// read. A whole-history page already contains the pinned row in recency
|
|
1409
|
+
// order, so appending it would duplicate it and put hasMore out of step
|
|
1410
|
+
// with the count.
|
|
1411
|
+
const pinned = createConversation("pinned-1");
|
|
1412
|
+
rawRun(
|
|
1413
|
+
"test:pinConversation",
|
|
1414
|
+
"UPDATE conversations SET is_pinned = 1, group_id = 'system:pinned' WHERE id = ?",
|
|
1415
|
+
pinned.id,
|
|
1416
|
+
);
|
|
1417
|
+
const newer = createConversation("newer-1");
|
|
1418
|
+
setLastMessageAt(pinned.id, 1000);
|
|
1419
|
+
setLastMessageAt(newer.id, 2000);
|
|
1420
|
+
|
|
1421
|
+
const result = (await invoke({
|
|
1422
|
+
conversationType: "all",
|
|
1423
|
+
limit: "1",
|
|
1424
|
+
})) as ListResponse;
|
|
1425
|
+
|
|
1426
|
+
expect(result.conversations.map((c) => c.title)).toEqual(["newer-1"]);
|
|
1427
|
+
expect(result.hasMore).toBe(true);
|
|
1428
|
+
});
|
|
1429
|
+
|
|
1430
|
+
test("hasMore describes the combined total, so pagination drains it", async () => {
|
|
1431
|
+
createConversation("foreground-1");
|
|
1432
|
+
createConversation({ title: "bg-1", conversationType: "background" });
|
|
1433
|
+
createConversation({ title: "sched-1", conversationType: "scheduled" });
|
|
1434
|
+
|
|
1435
|
+
const first = (await invoke({
|
|
1436
|
+
conversationType: "all",
|
|
1437
|
+
limit: "2",
|
|
1438
|
+
})) as ListResponse;
|
|
1439
|
+
expect(first.conversations).toHaveLength(2);
|
|
1440
|
+
expect(first.hasMore).toBe(true);
|
|
1441
|
+
|
|
1442
|
+
const second = (await invoke({
|
|
1443
|
+
conversationType: "all",
|
|
1444
|
+
limit: "2",
|
|
1445
|
+
offset: String(first.nextOffset),
|
|
1446
|
+
})) as ListResponse;
|
|
1447
|
+
expect(second.conversations).toHaveLength(1);
|
|
1448
|
+
expect(second.hasMore).toBe(false);
|
|
1449
|
+
});
|
|
1450
|
+
});
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import type { DictationProfile } from "../../../daemon/dictation-profile-store.js";
|
|
4
|
+
|
|
5
|
+
let profile: DictationProfile;
|
|
6
|
+
let nextToolInput: { mode?: string; text?: string } | null;
|
|
7
|
+
let providerAvailable: boolean;
|
|
8
|
+
let providerFails: boolean;
|
|
9
|
+
let providerResolutions: number;
|
|
10
|
+
let requestedProperties: Record<string, unknown> | undefined;
|
|
11
|
+
|
|
12
|
+
mock.module("../../../providers/provider-send-message.js", () => ({
|
|
13
|
+
getConfiguredProvider: async () => {
|
|
14
|
+
providerResolutions += 1;
|
|
15
|
+
if (!providerAvailable) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
sendMessage: async (
|
|
20
|
+
_messages: unknown,
|
|
21
|
+
options: {
|
|
22
|
+
tools: Array<{
|
|
23
|
+
input_schema: { properties: Record<string, unknown> };
|
|
24
|
+
}>;
|
|
25
|
+
},
|
|
26
|
+
) => {
|
|
27
|
+
if (providerFails) {
|
|
28
|
+
throw new Error("Provider unavailable");
|
|
29
|
+
}
|
|
30
|
+
requestedProperties = options.tools[0]?.input_schema.properties;
|
|
31
|
+
return {
|
|
32
|
+
content: nextToolInput
|
|
33
|
+
? [{ type: "tool_use", input: nextToolInput }]
|
|
34
|
+
: [{ type: "text", text: "Rewritten text." }],
|
|
35
|
+
};
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
},
|
|
39
|
+
extractToolUse: (response: { content: Array<{ type: string }> }) =>
|
|
40
|
+
response.content.find((block) => block.type === "tool_use"),
|
|
41
|
+
userMessage: (text: string) => ({ role: "user", content: text }),
|
|
42
|
+
createTimeout: () => ({ signal: undefined, cleanup: () => undefined }),
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
mock.module("../../../daemon/dictation-profile-store.js", () => ({
|
|
46
|
+
resolveProfile: () => ({ profile, source: "default" }),
|
|
47
|
+
}));
|
|
48
|
+
|
|
49
|
+
const { ROUTES, computeMaxTokens } = await import("../diagnostics-routes.js");
|
|
50
|
+
const route = ROUTES.find((entry) => entry.operationId === "dictation_post")!;
|
|
51
|
+
|
|
52
|
+
async function dictate(
|
|
53
|
+
transcription: string,
|
|
54
|
+
context: { cursorInTextField?: boolean; selectedText?: string } = {
|
|
55
|
+
cursorInTextField: true,
|
|
56
|
+
},
|
|
57
|
+
) {
|
|
58
|
+
return (await route.handler({
|
|
59
|
+
body: { transcription, context },
|
|
60
|
+
} as unknown as Parameters<typeof route.handler>[0])) as {
|
|
61
|
+
text: string;
|
|
62
|
+
mode: string;
|
|
63
|
+
actionPlan?: string;
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
beforeEach(() => {
|
|
68
|
+
profile = { id: "general", name: "General" };
|
|
69
|
+
nextToolInput = { mode: "dictation", text: "We should ship it." };
|
|
70
|
+
providerAvailable = true;
|
|
71
|
+
providerFails = false;
|
|
72
|
+
providerResolutions = 0;
|
|
73
|
+
requestedProperties = undefined;
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("dictation preserves recognized words", () => {
|
|
77
|
+
test.each([
|
|
78
|
+
"I think we probably shouldn't ship it, at least not yet.",
|
|
79
|
+
"Um, I don't, I don't want to say yes. Maybe next week?",
|
|
80
|
+
"First onions, then tomatoes. Actually, no tomatoes.",
|
|
81
|
+
" Please don't send that.\nI mean it. ",
|
|
82
|
+
"send me the draft, but don't send it to anyone else",
|
|
83
|
+
])(
|
|
84
|
+
"preserves text-field dictation despite an attempted model rewrite: %s",
|
|
85
|
+
async (words) => {
|
|
86
|
+
expect(await dictate(words)).toMatchObject({
|
|
87
|
+
mode: "dictation",
|
|
88
|
+
text: words,
|
|
89
|
+
});
|
|
90
|
+
expect(providerResolutions).toBe(1);
|
|
91
|
+
},
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
test("a style profile cannot automatically rewrite ordinary dictation", async () => {
|
|
95
|
+
profile.stylePrompt = "Make every sentence confident and professional.";
|
|
96
|
+
const words = "I guess maybe we shouldn't do this.";
|
|
97
|
+
|
|
98
|
+
expect(await dictate(words)).toMatchObject({ text: words });
|
|
99
|
+
expect(providerResolutions).toBe(1);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("whitespace-only selection is ordinary dictation", async () => {
|
|
103
|
+
expect(
|
|
104
|
+
await dictate("Don't change my words.", {
|
|
105
|
+
cursorInTextField: true,
|
|
106
|
+
selectedText: " \n ",
|
|
107
|
+
}),
|
|
108
|
+
).toMatchObject({ mode: "dictation", text: "Don't change my words." });
|
|
109
|
+
expect(providerResolutions).toBe(1);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("keeps explicitly configured snippet and dictionary replacements", async () => {
|
|
113
|
+
profile.snippets = [
|
|
114
|
+
{ trigger: "my sign off", expansion: "Thanks, user one" },
|
|
115
|
+
];
|
|
116
|
+
profile.dictionary = [{ spoken: "user one", written: "Example User" }];
|
|
117
|
+
|
|
118
|
+
expect(await dictate("Maybe later. my sign off")).toMatchObject({
|
|
119
|
+
mode: "dictation",
|
|
120
|
+
text: "Maybe later. Thanks, Example User",
|
|
121
|
+
});
|
|
122
|
+
expect(providerResolutions).toBe(1);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("classification outside a text field cannot supply replacement text", async () => {
|
|
126
|
+
const words = "I think we probably shouldn't ship it.";
|
|
127
|
+
|
|
128
|
+
expect(await dictate(words, {})).toMatchObject({
|
|
129
|
+
mode: "dictation",
|
|
130
|
+
text: words,
|
|
131
|
+
});
|
|
132
|
+
expect(providerResolutions).toBe(1);
|
|
133
|
+
expect(Object.keys(requestedProperties ?? {})).toEqual(["mode"]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test.each([true, false])(
|
|
137
|
+
"action classification preserves the original command with cursorInTextField=%s",
|
|
138
|
+
async (cursorInTextField) => {
|
|
139
|
+
nextToolInput = { mode: "action", text: "Send the files." };
|
|
140
|
+
profile.snippets = [{ trigger: "files", expansion: "all files" }];
|
|
141
|
+
const words = "Send the files, but not the private ones.";
|
|
142
|
+
|
|
143
|
+
expect(await dictate(words, { cursorInTextField })).toMatchObject({
|
|
144
|
+
mode: "action",
|
|
145
|
+
text: words,
|
|
146
|
+
actionPlan: `User wants to: ${words}`,
|
|
147
|
+
});
|
|
148
|
+
},
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
test.each([
|
|
152
|
+
"missing-provider",
|
|
153
|
+
"provider-error",
|
|
154
|
+
"missing-tool",
|
|
155
|
+
"invalid-mode",
|
|
156
|
+
])("classification fallback preserves words when %s", async (failure) => {
|
|
157
|
+
providerAvailable = failure !== "missing-provider";
|
|
158
|
+
providerFails = failure === "provider-error";
|
|
159
|
+
nextToolInput = failure === "missing-tool" ? null : { mode: "invalid" };
|
|
160
|
+
const words = "I don't think that's what I meant, you know?";
|
|
161
|
+
|
|
162
|
+
expect(await dictate(words, {})).toMatchObject({
|
|
163
|
+
mode: "dictation",
|
|
164
|
+
text: words,
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe("selected-text output budget", () => {
|
|
170
|
+
test("reserves space for the edit and tool-call JSON", () => {
|
|
171
|
+
expect(computeMaxTokens(900)).toBeGreaterThan(600);
|
|
172
|
+
expect(computeMaxTokens(5)).toBe(512);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
@@ -8,7 +8,10 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Also covers the search-query parser: `is:archived` and its synonyms in the
|
|
10
10
|
* `q` string flip the conversation arm to `includeArchived: true`, and the
|
|
11
|
-
* filter token is stripped from the term before any backend is called.
|
|
11
|
+
* filter token is stripped from the term before any backend is called. Under
|
|
12
|
+
* the `sidebar-done` flag the unfiltered default is `true` instead and
|
|
13
|
+
* `is:unarchived` is the opt-out, so the flag-on twins assert the mirror
|
|
14
|
+
* image of the default ones.
|
|
12
15
|
*/
|
|
13
16
|
|
|
14
17
|
import { afterEach, describe, expect, mock, test } from "bun:test";
|
|
@@ -30,9 +33,13 @@ const searchConversationsMock = mock(
|
|
|
30
33
|
},
|
|
31
34
|
);
|
|
32
35
|
|
|
36
|
+
let sidebarDoneEnabled = false;
|
|
37
|
+
|
|
33
38
|
const actualContactStore = await import("../../../contacts/contact-store.js");
|
|
34
39
|
const actualConvQueries =
|
|
35
40
|
await import("../../../persistence/conversation-queries.js");
|
|
41
|
+
const actualSidebarDoneGate =
|
|
42
|
+
await import("../../../config/sidebar-done-gate.js");
|
|
36
43
|
|
|
37
44
|
mock.module("../../../contacts/contact-store.js", () => ({
|
|
38
45
|
...actualContactStore,
|
|
@@ -42,6 +49,10 @@ mock.module("../../../persistence/conversation-queries.js", () => ({
|
|
|
42
49
|
...actualConvQueries,
|
|
43
50
|
searchConversations: searchConversationsMock,
|
|
44
51
|
}));
|
|
52
|
+
mock.module("../../../config/sidebar-done-gate.js", () => ({
|
|
53
|
+
...actualSidebarDoneGate,
|
|
54
|
+
isSidebarDoneEnabled: () => sidebarDoneEnabled,
|
|
55
|
+
}));
|
|
45
56
|
|
|
46
57
|
const { ROUTES } = await import("../global-search-routes.js");
|
|
47
58
|
|
|
@@ -69,6 +80,7 @@ afterEach(() => {
|
|
|
69
80
|
searchConversationsCalls = [];
|
|
70
81
|
searchConversationsResult = [];
|
|
71
82
|
searchConversationsMock.mockClear();
|
|
83
|
+
sidebarDoneEnabled = false;
|
|
72
84
|
});
|
|
73
85
|
|
|
74
86
|
describe("global-search contacts recency source", () => {
|
|
@@ -268,3 +280,64 @@ describe("global-search q parser", () => {
|
|
|
268
280
|
expect(result.queryTokens).toEqual(["alpha", "beta"]);
|
|
269
281
|
});
|
|
270
282
|
});
|
|
283
|
+
|
|
284
|
+
describe("global-search q parser under sidebar-done", () => {
|
|
285
|
+
test("plain term reaches done chats by default", async () => {
|
|
286
|
+
sidebarDoneEnabled = true;
|
|
287
|
+
|
|
288
|
+
await handler({
|
|
289
|
+
queryParams: { q: "shema", categories: "conversations" },
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
expect(searchConversationsCalls[0]?.query).toBe("shema");
|
|
293
|
+
expect(searchConversationsCalls[0]?.opts).toMatchObject({
|
|
294
|
+
includeArchived: true,
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("is:unarchived (and archive:no / archive:false) narrows back to live chats", async () => {
|
|
299
|
+
for (const filter of ["is:unarchived", "archive:no", "archive:false"]) {
|
|
300
|
+
sidebarDoneEnabled = true;
|
|
301
|
+
searchConversationsCalls = [];
|
|
302
|
+
searchConversationsMock.mockClear();
|
|
303
|
+
|
|
304
|
+
await handler({
|
|
305
|
+
queryParams: {
|
|
306
|
+
q: `${filter} shema`,
|
|
307
|
+
categories: "conversations",
|
|
308
|
+
},
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
expect(searchConversationsCalls[0]?.query).toBe("shema");
|
|
312
|
+
expect(searchConversationsCalls[0]?.opts).toMatchObject({
|
|
313
|
+
includeArchived: false,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("is:archived stays a no-op the term is still stripped of", async () => {
|
|
319
|
+
sidebarDoneEnabled = true;
|
|
320
|
+
|
|
321
|
+
await handler({
|
|
322
|
+
queryParams: { q: "is:archived shema", categories: "conversations" },
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
expect(searchConversationsCalls[0]?.query).toBe("shema");
|
|
326
|
+
expect(searchConversationsCalls[0]?.opts).toMatchObject({
|
|
327
|
+
includeArchived: true,
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("unknown filter tokens stay in the term and do not narrow the default", async () => {
|
|
332
|
+
sidebarDoneEnabled = true;
|
|
333
|
+
|
|
334
|
+
await handler({
|
|
335
|
+
queryParams: { q: "is:starred shema", categories: "conversations" },
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
expect(searchConversationsCalls[0]?.query).toBe("is:starred shema");
|
|
339
|
+
expect(searchConversationsCalls[0]?.opts).toMatchObject({
|
|
340
|
+
includeArchived: true,
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
});
|
|
@@ -4,8 +4,9 @@ import { CALL_SITE_DEFAULTS } from "../../../config/call-site-defaults.js";
|
|
|
4
4
|
import {
|
|
5
5
|
type LLMCallSite,
|
|
6
6
|
LLMCallSiteEnum,
|
|
7
|
+
LLMSchema,
|
|
7
8
|
} from "../../../config/schemas/llm.js";
|
|
8
|
-
import { ROUTES } from "../llm-call-sites-routes.js";
|
|
9
|
+
import { buildCallSiteCatalog, ROUTES } from "../llm-call-sites-routes.js";
|
|
9
10
|
|
|
10
11
|
const route = ROUTES.find((r) => r.operationId === "llm_call_sites_list")!;
|
|
11
12
|
|
|
@@ -89,6 +90,20 @@ describe("llm-call-sites-routes", () => {
|
|
|
89
90
|
}
|
|
90
91
|
});
|
|
91
92
|
|
|
93
|
+
test("omits an unavailable managed-only shipped default under BYOK", () => {
|
|
94
|
+
const managed = buildCallSiteCatalog(LLMSchema.parse({})).find(
|
|
95
|
+
({ id }) => id === "voiceEscalationJudge",
|
|
96
|
+
);
|
|
97
|
+
expect(managed?.defaultProfile).toBe("jev-managed");
|
|
98
|
+
expect(managed?.shippedDefaultProfile).toBe("jev-managed");
|
|
99
|
+
|
|
100
|
+
const byok = buildCallSiteCatalog(
|
|
101
|
+
LLMSchema.parse({ defaultProvider: { provider: "anthropic" } }),
|
|
102
|
+
).find(({ id }) => id === "voiceEscalationJudge");
|
|
103
|
+
expect(byok?.defaultProfile).toBeUndefined();
|
|
104
|
+
expect(byok?.shippedDefaultProfile).toBeUndefined();
|
|
105
|
+
});
|
|
106
|
+
|
|
92
107
|
test("domains have non-empty id and displayName", async () => {
|
|
93
108
|
const result = (await route.handler({})) as {
|
|
94
109
|
domains: Array<{ id: string; displayName: string }>;
|
|
@@ -31,16 +31,14 @@ import {
|
|
|
31
31
|
import { resolveConversationId } from "../../persistence/conversation-key-store.js";
|
|
32
32
|
import {
|
|
33
33
|
type ConversationListFilter,
|
|
34
|
+
type ConversationListTypeFilter,
|
|
34
35
|
countConversations,
|
|
35
36
|
countConversationSections,
|
|
36
37
|
countUnreadConversations,
|
|
37
38
|
listConversations,
|
|
38
39
|
listPinnedConversations,
|
|
39
40
|
} from "../../persistence/conversation-queries.js";
|
|
40
|
-
import {
|
|
41
|
-
ASSISTANT_INITIATED_GROUP_ID,
|
|
42
|
-
type ConversationType,
|
|
43
|
-
} from "../../persistence/conversation-types.js";
|
|
41
|
+
import { ASSISTANT_INITIATED_GROUP_ID } from "../../persistence/conversation-types.js";
|
|
44
42
|
import { getBindingsForConversations } from "../../persistence/external-conversation-store.js";
|
|
45
43
|
import { listGroups } from "../../persistence/group-crud.js";
|
|
46
44
|
import { UserError } from "../../util/errors.js";
|
|
@@ -252,12 +250,14 @@ function handleListConversations({
|
|
|
252
250
|
const limit = Number(queryParams.limit ?? 50);
|
|
253
251
|
const offset = Number(queryParams.offset ?? 0);
|
|
254
252
|
// "background" is the back-compat umbrella (background + scheduled); newer
|
|
255
|
-
// clients can pass "scheduled" to load only the Scheduled section
|
|
256
|
-
//
|
|
257
|
-
|
|
253
|
+
// clients can pass "scheduled" to load only the Scheduled section, or "all"
|
|
254
|
+
// to page a user's whole history in one cursor. Absent defaults to the
|
|
255
|
+
// standard foreground list.
|
|
256
|
+
const conversationType: ConversationListTypeFilter =
|
|
258
257
|
parseEnumQueryParam(queryParams, "conversationType", [
|
|
259
258
|
"background",
|
|
260
259
|
"scheduled",
|
|
260
|
+
"all",
|
|
261
261
|
]) ?? "standard";
|
|
262
262
|
// Defaults to `active` so sidebar restores no longer pull archived rows.
|
|
263
263
|
// The Archive page opts into `archived` to render only archived rows
|
|
@@ -294,7 +294,9 @@ function handleListConversations({
|
|
|
294
294
|
read withholds them, so they appear in their own section and nowhere
|
|
295
295
|
else. Confined to `standard`: the background and scheduled buckets are
|
|
296
296
|
back-compat umbrellas addressed by conversation type, and narrowing them
|
|
297
|
-
by source would drop rows their callers still page through
|
|
297
|
+
by source would drop rows their callers still page through, while `all`
|
|
298
|
+
is a whole-history read with no second section to defer those rows to,
|
|
299
|
+
so withholding there would simply lose them. Confined to
|
|
298
300
|
active reads too: the Archive view asks `archiveStatus=archived` with no
|
|
299
301
|
`groupId`, and the section only ever lists active rows, so withholding
|
|
300
302
|
there would leave an archived section thread visible nowhere. */
|
|
@@ -584,10 +586,7 @@ function handleMarkUnread({ body = {}, headers }: RouteHandlerArgs) {
|
|
|
584
586
|
}
|
|
585
587
|
}
|
|
586
588
|
|
|
587
|
-
function handleGetConversation({
|
|
588
|
-
pathParams = {},
|
|
589
|
-
headers,
|
|
590
|
-
}: RouteHandlerArgs) {
|
|
589
|
+
function handleGetConversation({ pathParams = {}, headers }: RouteHandlerArgs) {
|
|
591
590
|
const detail = buildConversationDetailResponse(
|
|
592
591
|
pathParams.id!,
|
|
593
592
|
localeFromAcceptLanguage(headers?.["accept-language"]),
|
|
@@ -633,8 +632,11 @@ export const ROUTES: RouteDefinition[] = [
|
|
|
633
632
|
type: "string",
|
|
634
633
|
required: false,
|
|
635
634
|
description:
|
|
636
|
-
'Filter by conversation type. Pass "background" to list background and scheduled conversations together (the back-compat umbrella),
|
|
637
|
-
schema: {
|
|
635
|
+
'Filter by conversation type. Pass "background" to list background and scheduled conversations together (the back-compat umbrella), "scheduled" to list only scheduled conversations, or "all" for the union of the standard listing and that umbrella, so one paginated cursor spans every type. Combine "all" with archiveStatus=all for a whole-history read; rows are recency-ordered (COALESCE(last_message_at, updated_at) descending) like every other list read, and pinned rows are never appended to it.',
|
|
636
|
+
schema: {
|
|
637
|
+
type: "string",
|
|
638
|
+
enum: ["background", "scheduled", "all"],
|
|
639
|
+
},
|
|
638
640
|
},
|
|
639
641
|
{
|
|
640
642
|
name: "archiveStatus",
|
|
@@ -65,6 +65,7 @@ import {
|
|
|
65
65
|
} from "../../config/schemas/llm.js";
|
|
66
66
|
import { VALID_MEMORY_EMBEDDING_PROVIDERS } from "../../config/schemas/memory-storage.js";
|
|
67
67
|
import { ServiceModeSchema } from "../../config/schemas/services.js";
|
|
68
|
+
import { isSidebarDoneEnabled } from "../../config/sidebar-done-gate.js";
|
|
68
69
|
import {
|
|
69
70
|
describeShadowedConfigSet,
|
|
70
71
|
findSubstrateShadowing,
|
|
@@ -2108,6 +2109,9 @@ async function handleSearchConversations({
|
|
|
2108
2109
|
query: q,
|
|
2109
2110
|
limit,
|
|
2110
2111
|
maxMessagesPerConversation: maxMessages,
|
|
2112
|
+
// Under `sidebar-done` a done chat is filed rather than hidden, so the
|
|
2113
|
+
// agent's own history search reaches it the same way the user's does.
|
|
2114
|
+
includeArchived: isSidebarDoneEnabled(),
|
|
2111
2115
|
});
|
|
2112
2116
|
return { query: q, results };
|
|
2113
2117
|
}
|
|
@@ -42,7 +42,7 @@ import {
|
|
|
42
42
|
supportsHostProxy,
|
|
43
43
|
} from "../../channels/types.js";
|
|
44
44
|
import { isAssistantFeatureFlagEnabled } from "../../config/assistant-feature-flags.js";
|
|
45
|
-
import {
|
|
45
|
+
import { getConversationProfilesForProvider } from "../../config/default-profile-catalog.js";
|
|
46
46
|
import { isHttpAuthDisabled } from "../../config/env.js";
|
|
47
47
|
import { getConfig } from "../../config/loader.js";
|
|
48
48
|
import { isModeSessionRecoveryHealthy } from "../../config/session-groups-gate.js";
|
|
@@ -1754,7 +1754,9 @@ export async function handleSendMessage(
|
|
|
1754
1754
|
}
|
|
1755
1755
|
if (requestedInferenceProfile !== undefined) {
|
|
1756
1756
|
const { llm } = getConfig();
|
|
1757
|
-
|
|
1757
|
+
// The conversation view: a sticky pin runs the main agent's turns, so a
|
|
1758
|
+
// profile whose model returns verdicts rather than text is not offered.
|
|
1759
|
+
const profiles = getConversationProfilesForProvider(
|
|
1758
1760
|
llm.profiles,
|
|
1759
1761
|
llm.defaultProvider ?? null,
|
|
1760
1762
|
);
|