@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
package/Dockerfile
CHANGED
|
@@ -102,6 +102,7 @@ RUN apt-get update && apt-get install -y \
|
|
|
102
102
|
&& rm -rf /var/lib/apt/lists/*
|
|
103
103
|
|
|
104
104
|
# Desktop system packages persist through container replacement as image layers.
|
|
105
|
+
COPY assistant/docker/mousepad/90-vellum.gschema.override /usr/share/glib-2.0/schemas/90-vellum.gschema.override
|
|
105
106
|
RUN set -eu; \
|
|
106
107
|
wezterm_version=20240203-110809-5046fc22; \
|
|
107
108
|
case "$(dpkg --print-architecture)" in \
|
|
@@ -129,6 +130,7 @@ RUN set -eu; \
|
|
|
129
130
|
libgl1-mesa-dri \
|
|
130
131
|
libgtk-3-0 \
|
|
131
132
|
libvulkan1 \
|
|
133
|
+
mousepad \
|
|
132
134
|
openbox \
|
|
133
135
|
plank \
|
|
134
136
|
python3 \
|
|
@@ -144,6 +146,11 @@ RUN set -eu; \
|
|
|
144
146
|
xfonts-base \
|
|
145
147
|
xterm \
|
|
146
148
|
/tmp/wezterm.deb; \
|
|
149
|
+
/usr/lib/*/glib-2.0/glib-compile-schemas --strict /usr/share/glib-2.0/schemas; \
|
|
150
|
+
mkdir -p /etc/xdg; \
|
|
151
|
+
awk -v desktop_id=org.xfce.mousepad.desktop \
|
|
152
|
+
'BEGIN { print "[Default Applications]" } /^MimeType=/ { sub(/^MimeType=/, ""); count = split($0, types, ";"); for (i = 1; i <= count; i++) { if (types[i] != "") { print types[i] "=" desktop_id ";" } } }' \
|
|
153
|
+
/usr/share/applications/org.xfce.mousepad.desktop > /etc/xdg/mimeapps.list; \
|
|
147
154
|
wezterm --version; \
|
|
148
155
|
rm -rf /tmp/wezterm.deb /var/lib/apt/lists/* /var/cache/apt/archives/*.deb
|
|
149
156
|
|
package/README.md
CHANGED
|
@@ -70,16 +70,16 @@ bun run src/index.ts # interactive CLI session
|
|
|
70
70
|
|
|
71
71
|
### CLI commands
|
|
72
72
|
|
|
73
|
-
| Command
|
|
74
|
-
|
|
|
75
|
-
| `vellum wake`
|
|
76
|
-
| `vellum sleep`
|
|
77
|
-
| `vellum ps`
|
|
78
|
-
| `assistant`
|
|
73
|
+
| Command | Description |
|
|
74
|
+
| ---------------------------------------------------------- | ------------------------------------------------ |
|
|
75
|
+
| `vellum wake` | Start assistant + gateway from current checkout |
|
|
76
|
+
| `vellum sleep` | Stop assistant + gateway processes |
|
|
77
|
+
| `vellum ps` | List assistants and per-assistant process status |
|
|
78
|
+
| `assistant` | Launch interactive CLI session |
|
|
79
79
|
| `assistant conversations list\|search\|new\|export\|clear` | Manage conversations |
|
|
80
|
-
| `assistant config set\|get\|list`
|
|
81
|
-
| `assistant keys set\|list\|delete`
|
|
82
|
-
| `assistant trust list\|add\|update\|remove`
|
|
80
|
+
| `assistant config set\|get\|list` | Manage configuration |
|
|
81
|
+
| `assistant keys set\|list\|delete` | Manage API keys in secure storage |
|
|
82
|
+
| `assistant trust list\|add\|update\|remove` | Manage trust rules |
|
|
83
83
|
|
|
84
84
|
## Project Structure
|
|
85
85
|
|
|
@@ -183,7 +183,7 @@ Internal forwarding routes (`/v1/internal/twilio/*`) are unaffected — these ac
|
|
|
183
183
|
The `/channels/inbound` endpoint requires a JWT with the `svc_gateway` principal type and `ingress.write` scope to prove the request originated from the gateway. This ensures channel messages can only arrive via the gateway (which performs webhook-level verification) and not via direct HTTP calls that bypass signature checks.
|
|
184
184
|
|
|
185
185
|
- **JWT-based enforcement:** The route policy in `route-policy.ts` restricts `/channels/inbound` to the `svc_gateway` principal type with `ingress.write` scope. Actor and local principals are rejected with 403.
|
|
186
|
-
- **Auth bypass:** When `DISABLE_HTTP_AUTH=true` is set (platform-managed deployments),
|
|
186
|
+
- **Auth bypass:** When `DISABLE_HTTP_AUTH=true` is set (platform-managed deployments), a request that supplies no `Authorization` header gets a synthetic context. A request that supplies one is verified normally, and a token that fails verification is rejected.
|
|
187
187
|
|
|
188
188
|
## Twilio Setup Primitive
|
|
189
189
|
|
|
@@ -244,8 +244,8 @@ The channel guardian service generates verification challenge instructions with
|
|
|
244
244
|
### Operator Notes
|
|
245
245
|
|
|
246
246
|
- **Verification input format:** Channel verification accepts a bare code reply only (6-digit numeric for identity-bound sessions; 64-char hex for unbound inbound/bootstrap compatibility).
|
|
247
|
-
- **Rebind requirement:** Creating a new guardian challenge when a binding already exists requires `rebind: true` in the HTTP request. Without it, the assistant returns `already_bound`.
|
|
248
|
-
- **Takeover prevention:**
|
|
247
|
+
- **Rebind requirement:** Creating a new guardian challenge when a binding already exists requires `rebind: true` in the HTTP request. Without it, the assistant returns `already_bound`. On text channels the resulting code can only be redeemed by the account already bound, which is how the same guardian verifies again; to move the guardian to a different account, revoke the binding first and verify again. An outbound phone verification is the exception: its code replaces the bound number.
|
|
248
|
+
- **Takeover prevention:** On text channels, verification is rejected when an active binding exists for a different external user: the code is spent, the reply is the generic "invalid or expired", and the sender is made neither guardian nor contact. Same-user re-verification is allowed, including for a guardian who revoked their binding and is verifying the same account again. Phone differs: an outbound verification with `rebind: true` replaces the bound number once its code is entered, and an inbound call from a different number is not bound.
|
|
249
249
|
|
|
250
250
|
### Vellum Guardian Identity (Actor Tokens)
|
|
251
251
|
|
package/openapi.yaml
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
openapi: 3.1.0
|
|
4
4
|
info:
|
|
5
5
|
title: Vellum Assistant API
|
|
6
|
-
version: 0.12.
|
|
6
|
+
version: 0.12.4
|
|
7
7
|
description: Auto-generated OpenAPI specification for the Vellum Assistant runtime HTTP server.
|
|
8
8
|
servers:
|
|
9
9
|
- url: http://127.0.0.1:7821
|
|
@@ -7985,9 +7985,13 @@ paths:
|
|
|
7985
7985
|
enum:
|
|
7986
7986
|
- background
|
|
7987
7987
|
- scheduled
|
|
7988
|
+
- all
|
|
7988
7989
|
description:
|
|
7989
7990
|
Filter by conversation type. Pass "background" to list background and scheduled conversations together (the
|
|
7990
|
-
back-compat umbrella),
|
|
7991
|
+
back-compat umbrella), "scheduled" to list only scheduled conversations, or "all" for the union of the
|
|
7992
|
+
standard listing and that umbrella, so one paginated cursor spans every type. Combine "all" with
|
|
7993
|
+
archiveStatus=all for a whole-history read; rows are recency-ordered (COALESCE(last_message_at, updated_at)
|
|
7994
|
+
descending) like every other list read, and pinned rows are never appended to it.
|
|
7991
7995
|
- name: archiveStatus
|
|
7992
7996
|
in: query
|
|
7993
7997
|
required: false
|
|
@@ -12056,7 +12060,9 @@ paths:
|
|
|
12056
12060
|
post:
|
|
12057
12061
|
operationId: dictation_post
|
|
12058
12062
|
summary: Process dictation
|
|
12059
|
-
description:
|
|
12063
|
+
description:
|
|
12064
|
+
Preserve dictated words with explicit dictionary and snippet replacements, classify actions, or edit
|
|
12065
|
+
selected text when instructed.
|
|
12060
12066
|
tags:
|
|
12061
12067
|
- diagnostics
|
|
12062
12068
|
requestBody:
|
package/package.json
CHANGED
|
@@ -241,8 +241,10 @@ function makeConfirmationEmittingSession(opts?: {
|
|
|
241
241
|
// Tests
|
|
242
242
|
// ---------------------------------------------------------------------------
|
|
243
243
|
|
|
244
|
-
|
|
245
|
-
|
|
244
|
+
// This suite mocks isHttpAuthDisabled() to true, so a request carrying no
|
|
245
|
+
// bearer authenticates through the dev bypass. A bearer that is present is
|
|
246
|
+
// verified against the signing key, which these tests do not set up.
|
|
247
|
+
const AUTH_HEADERS: Record<string, string> = {};
|
|
246
248
|
|
|
247
249
|
describe("standalone approval endpoints — HTTP layer", () => {
|
|
248
250
|
let server: RuntimeHttpServer;
|
|
@@ -82,8 +82,10 @@ await initializeDb();
|
|
|
82
82
|
// Helpers
|
|
83
83
|
// ---------------------------------------------------------------------------
|
|
84
84
|
|
|
85
|
-
|
|
86
|
-
|
|
85
|
+
// This suite mocks isHttpAuthDisabled() to true, so a request carrying no
|
|
86
|
+
// bearer authenticates through the dev bypass. A bearer that is present is
|
|
87
|
+
// verified against the signing key, which these tests do not set up.
|
|
88
|
+
const AUTH_HEADERS: Record<string, string> = {};
|
|
87
89
|
|
|
88
90
|
let ensuredConvIds = new Set<string>();
|
|
89
91
|
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Done conversation comes back to the list when a message the user reads
|
|
3
|
+
* lands in it.
|
|
4
|
+
*
|
|
5
|
+
* Two seams enforce the rule: `insertMessageCore`, where every durable append
|
|
6
|
+
* lands, and the turn's finalize effect, where a streamed reply becomes
|
|
7
|
+
* readable. These tests drive the insert seam the way each ingest path does,
|
|
8
|
+
* with `addMessage` and that path's row shape, and drive the finalize seam
|
|
9
|
+
* through `resurfaceArchivedConversation` itself, which is all the finalize
|
|
10
|
+
* effect adds to the reserved row.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
14
|
+
|
|
15
|
+
import { eq } from "drizzle-orm";
|
|
16
|
+
|
|
17
|
+
mock.module("../persistence/embeddings/qdrant-client.js", () => ({
|
|
18
|
+
getQdrantClient: () => ({
|
|
19
|
+
searchWithFilter: async () => [],
|
|
20
|
+
hybridSearch: async () => [],
|
|
21
|
+
upsertPoints: async () => {},
|
|
22
|
+
deletePoints: async () => {},
|
|
23
|
+
}),
|
|
24
|
+
initQdrantClient: () => {},
|
|
25
|
+
resolveQdrantUrl: () => "http://127.0.0.1:6333",
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
let sidebarDoneEnabled = true;
|
|
29
|
+
mock.module("../config/sidebar-done-gate.js", () => ({
|
|
30
|
+
SIDEBAR_DONE_FLAG_KEY: "sidebar-done",
|
|
31
|
+
isSidebarDoneEnabled: () => sidebarDoneEnabled,
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
const listInvalidations: Array<{ reason: string; conversationIds: unknown }> =
|
|
35
|
+
[];
|
|
36
|
+
const actualSyncEvents =
|
|
37
|
+
await import("../runtime/sync/resource-sync-events.js");
|
|
38
|
+
mock.module("../runtime/sync/resource-sync-events.js", () => ({
|
|
39
|
+
...actualSyncEvents,
|
|
40
|
+
publishConversationListAndMetadataChanged: (
|
|
41
|
+
reason: string,
|
|
42
|
+
conversationIds: unknown,
|
|
43
|
+
) => {
|
|
44
|
+
listInvalidations.push({ reason, conversationIds });
|
|
45
|
+
},
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
import {
|
|
49
|
+
addMessage,
|
|
50
|
+
archiveConversation,
|
|
51
|
+
getConversation,
|
|
52
|
+
reserveMessage,
|
|
53
|
+
resurfaceArchivedConversation,
|
|
54
|
+
} from "../persistence/conversation-crud.js";
|
|
55
|
+
import { getDb } from "../persistence/db-connection.js";
|
|
56
|
+
import { initializeDb } from "../persistence/db-init.js";
|
|
57
|
+
import { conversations, messages } from "../persistence/schema/index.js";
|
|
58
|
+
import {
|
|
59
|
+
MEMORY_RETROSPECTIVE_INSTRUCTION_KIND,
|
|
60
|
+
SKILL_CARD_MESSAGE_KIND,
|
|
61
|
+
} from "../plugins/defaults/memory/memory-retrospective-constants.js";
|
|
62
|
+
import { setConfig } from "./helpers/set-config.js";
|
|
63
|
+
|
|
64
|
+
setConfig("memory", { extraction: { useLLM: false } });
|
|
65
|
+
|
|
66
|
+
await initializeDb();
|
|
67
|
+
|
|
68
|
+
const DONE_CONVERSATION_ID = "conv-done";
|
|
69
|
+
const RETRO_FORK_ID = "conv-done-retro-fork";
|
|
70
|
+
|
|
71
|
+
function insertConversation(id: string): void {
|
|
72
|
+
const now = Date.now();
|
|
73
|
+
getDb()
|
|
74
|
+
.insert(conversations)
|
|
75
|
+
.values({
|
|
76
|
+
id,
|
|
77
|
+
title: null,
|
|
78
|
+
createdAt: now,
|
|
79
|
+
updatedAt: now,
|
|
80
|
+
totalInputTokens: 0,
|
|
81
|
+
totalOutputTokens: 0,
|
|
82
|
+
totalEstimatedCost: 0,
|
|
83
|
+
contextSummary: null,
|
|
84
|
+
contextCompactedMessageCount: 0,
|
|
85
|
+
contextCompactedAt: null,
|
|
86
|
+
})
|
|
87
|
+
.run();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function resetTables(): void {
|
|
91
|
+
const db = getDb();
|
|
92
|
+
db.run("DELETE FROM messages");
|
|
93
|
+
db.run("DELETE FROM conversations");
|
|
94
|
+
insertConversation(DONE_CONVERSATION_ID);
|
|
95
|
+
insertConversation(RETRO_FORK_ID);
|
|
96
|
+
archiveConversation(DONE_CONVERSATION_ID);
|
|
97
|
+
listInvalidations.length = 0;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function archivedAtOf(id: string): number | null | undefined {
|
|
101
|
+
return getConversation(id)?.archivedAt;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function blocks(text: string): string {
|
|
105
|
+
return JSON.stringify([{ type: "text", text }]);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
describe("Done conversations and new activity", () => {
|
|
109
|
+
beforeEach(() => {
|
|
110
|
+
sidebarDoneEnabled = true;
|
|
111
|
+
resetTables();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("assistant output from a scheduled run brings the conversation back", async () => {
|
|
115
|
+
// The wake persists its `<background_event>` trigger first. That row is
|
|
116
|
+
// dropped from the transcript, so it must not resurface anything on its
|
|
117
|
+
// own: the run has produced nothing the user can read yet.
|
|
118
|
+
await addMessage(
|
|
119
|
+
DONE_CONVERSATION_ID,
|
|
120
|
+
"user",
|
|
121
|
+
blocks(
|
|
122
|
+
'<background_event source="schedule">Daily digest</background_event>',
|
|
123
|
+
),
|
|
124
|
+
{
|
|
125
|
+
metadata: {
|
|
126
|
+
kind: "background-event",
|
|
127
|
+
backgroundEventSource: "schedule",
|
|
128
|
+
automated: true,
|
|
129
|
+
},
|
|
130
|
+
skipIndexing: true,
|
|
131
|
+
},
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
expect(archivedAtOf(DONE_CONVERSATION_ID)).not.toBeNull();
|
|
135
|
+
expect(listInvalidations).toHaveLength(0);
|
|
136
|
+
|
|
137
|
+
// The run's own output is what the user reads.
|
|
138
|
+
await addMessage(
|
|
139
|
+
DONE_CONVERSATION_ID,
|
|
140
|
+
"assistant",
|
|
141
|
+
blocks("Here is today's digest."),
|
|
142
|
+
{ metadata: { automated: true }, skipIndexing: true },
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
expect(archivedAtOf(DONE_CONVERSATION_ID)).toBeNull();
|
|
146
|
+
expect(listInvalidations).toEqual([
|
|
147
|
+
{ reason: "reordered", conversationIds: DONE_CONVERSATION_ID },
|
|
148
|
+
]);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("the empty row an LLM call reserves leaves the conversation Done", async () => {
|
|
152
|
+
const reserved = await reserveMessage(
|
|
153
|
+
DONE_CONVERSATION_ID,
|
|
154
|
+
"assistant",
|
|
155
|
+
undefined,
|
|
156
|
+
"inflight/ref.jsonl",
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
expect(archivedAtOf(DONE_CONVERSATION_ID)).not.toBeNull();
|
|
160
|
+
expect(listInvalidations).toHaveLength(0);
|
|
161
|
+
|
|
162
|
+
// The finalize seam is what resurfaces it, once the row holds content.
|
|
163
|
+
expect(
|
|
164
|
+
resurfaceArchivedConversation(DONE_CONVERSATION_ID, reserved.createdAt),
|
|
165
|
+
).toBe(true);
|
|
166
|
+
expect(archivedAtOf(DONE_CONVERSATION_ID)).toBeNull();
|
|
167
|
+
expect(listInvalidations).toHaveLength(1);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("a Done mark made after the activity wins over the resurface", () => {
|
|
171
|
+
const activityAt = Date.now() - 60_000;
|
|
172
|
+
|
|
173
|
+
expect(
|
|
174
|
+
resurfaceArchivedConversation(DONE_CONVERSATION_ID, activityAt),
|
|
175
|
+
).toBe(false);
|
|
176
|
+
expect(archivedAtOf(DONE_CONVERSATION_ID)).not.toBeNull();
|
|
177
|
+
expect(listInvalidations).toHaveLength(0);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("an inbound channel message from a person brings the conversation back", async () => {
|
|
181
|
+
await addMessage(DONE_CONVERSATION_ID, "user", "any updates?", {
|
|
182
|
+
metadata: {
|
|
183
|
+
userMessageChannel: "slack",
|
|
184
|
+
provenanceSourceChannel: "slack",
|
|
185
|
+
},
|
|
186
|
+
skipIndexing: true,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
expect(archivedAtOf(DONE_CONVERSATION_ID)).toBeNull();
|
|
190
|
+
expect(listInvalidations).toHaveLength(1);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("rows that never render leave the conversation Done", async () => {
|
|
194
|
+
await addMessage(DONE_CONVERSATION_ID, "user", "internal signal", {
|
|
195
|
+
metadata: { hidden: true },
|
|
196
|
+
skipIndexing: true,
|
|
197
|
+
});
|
|
198
|
+
await addMessage(DONE_CONVERSATION_ID, "user", "subagent finished", {
|
|
199
|
+
metadata: { subagentNotification: { subagentId: "sub-1" } },
|
|
200
|
+
skipIndexing: true,
|
|
201
|
+
});
|
|
202
|
+
await addMessage(DONE_CONVERSATION_ID, "user", "acp session update", {
|
|
203
|
+
metadata: { acpNotification: { sessionId: "acp-1" } },
|
|
204
|
+
skipIndexing: true,
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
expect(archivedAtOf(DONE_CONVERSATION_ID)).not.toBeNull();
|
|
208
|
+
expect(listInvalidations).toHaveLength(0);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("a memory retrospective leaves the conversation it reviews Done", async () => {
|
|
212
|
+
// A retrospective runs in a fork: its instruction and its whole pass are
|
|
213
|
+
// appended to the fork, never to the conversation under review.
|
|
214
|
+
await addMessage(RETRO_FORK_ID, "user", "Review the transcript.", {
|
|
215
|
+
metadata: { kind: MEMORY_RETROSPECTIVE_INSTRUCTION_KIND, hidden: true },
|
|
216
|
+
skipIndexing: true,
|
|
217
|
+
});
|
|
218
|
+
await addMessage(RETRO_FORK_ID, "assistant", blocks("Saved one fact."), {
|
|
219
|
+
metadata: { automated: true },
|
|
220
|
+
skipIndexing: true,
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
expect(archivedAtOf(DONE_CONVERSATION_ID)).not.toBeNull();
|
|
224
|
+
expect(listInvalidations).toHaveLength(0);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("a row its producer marked as bookkeeping leaves the conversation Done", async () => {
|
|
228
|
+
// The retrospective skill card, written the way
|
|
229
|
+
// `memory-retrospective-skill-card.ts` writes it. Persistence cannot
|
|
230
|
+
// import the memory plugin (`persistence-layering-guard`), so the producer
|
|
231
|
+
// declares the row instead of the seam recognising its kind.
|
|
232
|
+
await addMessage(
|
|
233
|
+
DONE_CONVERSATION_ID,
|
|
234
|
+
"assistant",
|
|
235
|
+
JSON.stringify([
|
|
236
|
+
{
|
|
237
|
+
type: "ui_surface",
|
|
238
|
+
surfaceId: "surface-1",
|
|
239
|
+
surfaceType: "skill_card",
|
|
240
|
+
title: "I just learned how to do expense filing",
|
|
241
|
+
display: "inline",
|
|
242
|
+
data: { skills: [] },
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
type: "text",
|
|
246
|
+
text: "I just learned how to do expense filing",
|
|
247
|
+
_surfaceFallback: true,
|
|
248
|
+
},
|
|
249
|
+
]),
|
|
250
|
+
{
|
|
251
|
+
metadata: { kind: SKILL_CARD_MESSAGE_KIND, automated: true },
|
|
252
|
+
skipIndexing: true,
|
|
253
|
+
clientMessageId: "surface-1",
|
|
254
|
+
skipResurface: true,
|
|
255
|
+
},
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
expect(archivedAtOf(DONE_CONVERSATION_ID)).not.toBeNull();
|
|
259
|
+
expect(listInvalidations).toHaveLength(0);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("the flag off keeps every append from clearing archived_at", async () => {
|
|
263
|
+
sidebarDoneEnabled = false;
|
|
264
|
+
|
|
265
|
+
await addMessage(DONE_CONVERSATION_ID, "user", "any updates?", {
|
|
266
|
+
metadata: {
|
|
267
|
+
userMessageChannel: "slack",
|
|
268
|
+
provenanceSourceChannel: "slack",
|
|
269
|
+
},
|
|
270
|
+
skipIndexing: true,
|
|
271
|
+
});
|
|
272
|
+
await addMessage(
|
|
273
|
+
DONE_CONVERSATION_ID,
|
|
274
|
+
"assistant",
|
|
275
|
+
blocks("Here is today's digest."),
|
|
276
|
+
{ metadata: { automated: true }, skipIndexing: true },
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
expect(archivedAtOf(DONE_CONVERSATION_ID)).not.toBeNull();
|
|
280
|
+
expect(listInvalidations).toHaveLength(0);
|
|
281
|
+
expect(
|
|
282
|
+
getDb()
|
|
283
|
+
.select()
|
|
284
|
+
.from(messages)
|
|
285
|
+
.where(eq(messages.conversationId, DONE_CONVERSATION_ID))
|
|
286
|
+
.all(),
|
|
287
|
+
).toHaveLength(2);
|
|
288
|
+
});
|
|
289
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
2
|
|
|
3
|
+
import { MALFORMED_TOOL_CALL_MESSAGE } from "../providers/malformed-tool-call.js";
|
|
3
4
|
import type {
|
|
4
5
|
ContentBlock,
|
|
5
6
|
Message,
|
|
@@ -209,6 +210,36 @@ describe("GeminiProvider", () => {
|
|
|
209
210
|
expect(result.stopReason).toBe("STOP");
|
|
210
211
|
});
|
|
211
212
|
|
|
213
|
+
test("throws a status-less malformed tool-call error on MALFORMED_FUNCTION_CALL", async () => {
|
|
214
|
+
fakeChunks = [
|
|
215
|
+
textChunk(",featuredImage:{_type:image},patch:true}]})"),
|
|
216
|
+
finishChunk("MALFORMED_FUNCTION_CALL", 10, 5),
|
|
217
|
+
];
|
|
218
|
+
|
|
219
|
+
const error = await provider
|
|
220
|
+
.sendMessage([{ role: "user", content: [{ type: "text", text: "Hi" }] }])
|
|
221
|
+
.then(
|
|
222
|
+
() => undefined,
|
|
223
|
+
(e: unknown) => e,
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
expect(error).toBeInstanceOf(ProviderError);
|
|
227
|
+
const providerError = error as ProviderError;
|
|
228
|
+
expect(providerError.statusCode).toBeUndefined();
|
|
229
|
+
expect(providerError.message).toContain(MALFORMED_TOOL_CALL_MESSAGE);
|
|
230
|
+
expect(providerError.message).toContain("MALFORMED_FUNCTION_CALL");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("throws a malformed tool-call error on UNEXPECTED_TOOL_CALL", async () => {
|
|
234
|
+
fakeChunks = [finishChunk("UNEXPECTED_TOOL_CALL", 10, 0)];
|
|
235
|
+
|
|
236
|
+
await expect(
|
|
237
|
+
provider.sendMessage([
|
|
238
|
+
{ role: "user", content: [{ type: "text", text: "Hi" }] },
|
|
239
|
+
]),
|
|
240
|
+
).rejects.toThrow(MALFORMED_TOOL_CALL_MESSAGE);
|
|
241
|
+
});
|
|
242
|
+
|
|
212
243
|
// -----------------------------------------------------------------------
|
|
213
244
|
// Streaming events
|
|
214
245
|
// -----------------------------------------------------------------------
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { validateInferenceProfileKey } from "../config/inference-profile-validation.js";
|
|
4
|
+
import { setConfig } from "./helpers/set-config.js";
|
|
5
|
+
|
|
6
|
+
describe("validateInferenceProfileKey and the managed Jev profile", () => {
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
setConfig("llm", { profiles: {} });
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("a schedule or subagent turn cannot run on the verdict model", () => {
|
|
12
|
+
expect(validateInferenceProfileKey("balanced")).toBeNull();
|
|
13
|
+
expect(validateInferenceProfileKey("jev-managed")).toContain(
|
|
14
|
+
"is not defined in llm.profiles",
|
|
15
|
+
);
|
|
16
|
+
});
|
|
17
|
+
});
|
|
@@ -235,6 +235,10 @@ describe("LLM catalog parity: daemon vs client", () => {
|
|
|
235
235
|
|
|
236
236
|
test("jev-latest opts out of chat text generation", () => {
|
|
237
237
|
expect(catalogModelSupportsText("typesafe", "jev-latest")).toBe(false);
|
|
238
|
+
// A managed Jev profile is configured as `vellum`; the routing identity
|
|
239
|
+
// owns no catalog models, so the lookup must judge by the catalog owner.
|
|
240
|
+
expect(catalogModelSupportsText("vellum", "jev-latest")).toBe(false);
|
|
241
|
+
expect(catalogModelSupportsText("vellum", "claude-opus-5")).toBe(true);
|
|
238
242
|
expect(catalogModelSupportsText("anthropic", "claude-opus-4-8")).toBe(true);
|
|
239
243
|
expect(catalogModelSupportsText("openai-compatible", "local-model")).toBe(
|
|
240
244
|
true,
|
|
@@ -225,6 +225,49 @@ describe("composeCallSiteTweak", () => {
|
|
|
225
225
|
});
|
|
226
226
|
|
|
227
227
|
describe("shipped call-site tuning", () => {
|
|
228
|
+
test("the escalation judge uses managed Jev by default", () => {
|
|
229
|
+
const resolved = resolveCallSiteConfig(
|
|
230
|
+
"voiceEscalationJudge",
|
|
231
|
+
LLMSchema.parse({}),
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
expect(resolved.provider).toBe("vellum");
|
|
235
|
+
expect(resolved.provider_connection).toBeUndefined();
|
|
236
|
+
expect(resolved.model).toBe("jev-latest");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("the escalation judge falls through on a BYOK default provider", () => {
|
|
240
|
+
const resolved = resolveCallSiteConfig(
|
|
241
|
+
"voiceEscalationJudge",
|
|
242
|
+
LLMSchema.parse(anthropicDp),
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
expect(resolved.provider).toBe("anthropic");
|
|
246
|
+
expect(resolved.model).not.toBe("jev-latest");
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("a BYOK Jev call-site override replaces the managed default", () => {
|
|
250
|
+
const llm = LLMSchema.parse({
|
|
251
|
+
...anthropicDp,
|
|
252
|
+
profiles: {
|
|
253
|
+
jev: {
|
|
254
|
+
...completeCustom,
|
|
255
|
+
provider: "typesafe",
|
|
256
|
+
provider_connection: "typesafe-personal",
|
|
257
|
+
model: "jev-latest",
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
callSites: {
|
|
261
|
+
voiceEscalationJudge: { profile: "jev" },
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
const resolved = resolveCallSiteConfig("voiceEscalationJudge", llm);
|
|
265
|
+
|
|
266
|
+
expect(resolved.provider).toBe("typesafe");
|
|
267
|
+
expect(resolved.provider_connection).toBe("typesafe-personal");
|
|
268
|
+
expect(resolved.model).toBe("jev-latest");
|
|
269
|
+
});
|
|
270
|
+
|
|
228
271
|
// A workspace entry that only repoints the profile must not cost the call
|
|
229
272
|
// site the tuning it ships with. `recall` carries a full set: a token
|
|
230
273
|
// budget, effort, sampling, thinking, and cache posture.
|
|
@@ -40,6 +40,13 @@ describe("getModelProfiles", () => {
|
|
|
40
40
|
writeFixtureConfig({});
|
|
41
41
|
});
|
|
42
42
|
|
|
43
|
+
test("omits the managed Jev profile, whose model returns verdicts rather than text", () => {
|
|
44
|
+
writeFixtureConfig({});
|
|
45
|
+
const keys = getModelProfiles().map((p) => p.key);
|
|
46
|
+
expect(keys).toContain("balanced");
|
|
47
|
+
expect(keys).not.toContain("jev-managed");
|
|
48
|
+
});
|
|
49
|
+
|
|
43
50
|
test("orders profiles by profileOrder then the remaining keys alphabetically", () => {
|
|
44
51
|
// GIVEN a workspace whose profileOrder names some keys, with a duplicate
|
|
45
52
|
// ("balanced") and a key that resolves to no profile ("ghost").
|
|
@@ -118,6 +118,7 @@ mock.module("../util/retry.js", () => {
|
|
|
118
118
|
};
|
|
119
119
|
});
|
|
120
120
|
|
|
121
|
+
import { malformedToolCallError } from "../providers/malformed-tool-call.js";
|
|
121
122
|
import { RetryProvider } from "../providers/retry.js";
|
|
122
123
|
import { createStreamTimeout } from "../providers/stream-timeout.js";
|
|
123
124
|
import {
|
|
@@ -941,6 +942,51 @@ describe("RetryProvider — streaming corruption retries", () => {
|
|
|
941
942
|
}
|
|
942
943
|
});
|
|
943
944
|
|
|
945
|
+
test("malformed tool-call finishes retry once with a corrective note", async () => {
|
|
946
|
+
const providerError = malformedToolCallError(
|
|
947
|
+
"openrouter",
|
|
948
|
+
"MALFORMED_FUNCTION_CALL",
|
|
949
|
+
);
|
|
950
|
+
// Providers wrap stream-loop throws in their generic catch.
|
|
951
|
+
const wrapped = new ProviderError(
|
|
952
|
+
`OpenRouter request failed: ${providerError.message}`,
|
|
953
|
+
"openrouter",
|
|
954
|
+
undefined,
|
|
955
|
+
{ cause: providerError },
|
|
956
|
+
);
|
|
957
|
+
const inner = makeCapturingFlaky(1, wrapped);
|
|
958
|
+
const provider = new RetryProvider(inner);
|
|
959
|
+
|
|
960
|
+
await provider.sendMessage(MESSAGES);
|
|
961
|
+
|
|
962
|
+
expect(inner.calls).toBe(2);
|
|
963
|
+
expect(inner.seen[0]).toBe(MESSAGES);
|
|
964
|
+
const retried = inner.seen[1]!;
|
|
965
|
+
const lastMessage = retried[retried.length - 1]!;
|
|
966
|
+
expect(lastMessage.role).toBe("user");
|
|
967
|
+
expect(lastMessage.content).toHaveLength(2);
|
|
968
|
+
const hint = lastMessage.content[1] as { type: string; text: string };
|
|
969
|
+
expect(hint.type).toBe("text");
|
|
970
|
+
expect(hint.text).toContain("did not parse");
|
|
971
|
+
expect(MESSAGES[MESSAGES.length - 1]!.content).toHaveLength(1);
|
|
972
|
+
});
|
|
973
|
+
|
|
974
|
+
test("a malformed tool-call message with an HTTP status is not retried", async () => {
|
|
975
|
+
const inner = makeFailing(
|
|
976
|
+
new ProviderError(
|
|
977
|
+
malformedToolCallError("gemini", "MALFORMED_FUNCTION_CALL").message,
|
|
978
|
+
"gemini",
|
|
979
|
+
400,
|
|
980
|
+
),
|
|
981
|
+
);
|
|
982
|
+
const provider = new RetryProvider(inner);
|
|
983
|
+
|
|
984
|
+
await expect(provider.sendMessage(MESSAGES)).rejects.toThrow(
|
|
985
|
+
"malformed tool call",
|
|
986
|
+
);
|
|
987
|
+
expect(inner.calls).toBe(1);
|
|
988
|
+
});
|
|
989
|
+
|
|
944
990
|
test("other stream-corruption retries resend the messages untouched", async () => {
|
|
945
991
|
const inner = makeCapturingFlaky(
|
|
946
992
|
1,
|