@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.
Files changed (69) hide show
  1. package/Dockerfile +7 -0
  2. package/README.md +12 -12
  3. package/docker/mousepad/90-vellum.gschema.override +4 -0
  4. package/openapi.yaml +6 -2
  5. package/package.json +1 -1
  6. package/src/__tests__/approval-routes-http.test.ts +4 -2
  7. package/src/__tests__/call-routes-http.test.ts +4 -2
  8. package/src/__tests__/conversation-resurface-done.test.ts +289 -0
  9. package/src/__tests__/gemini-provider.test.ts +31 -0
  10. package/src/__tests__/provider-error-scenarios.test.ts +46 -0
  11. package/src/__tests__/provider-platform-proxy-integration.test.ts +45 -15
  12. package/src/__tests__/retry-fallback-escalation.test.ts +22 -0
  13. package/src/__tests__/runtime-attachment-metadata.test.ts +4 -2
  14. package/src/__tests__/secret-routes-platform-proxy.test.ts +1 -0
  15. package/src/__tests__/send-endpoint-busy.test.ts +4 -2
  16. package/src/__tests__/steer-on-enqueue-question.test.ts +265 -34
  17. package/src/cli/commands/channel-verification-sessions.help.ts +12 -2
  18. package/src/cli/lib/bundled-marketplace.json +4 -4
  19. package/src/config/feature-flag-registry.json +8 -8
  20. package/src/config/sidebar-done-gate.ts +27 -0
  21. package/src/daemon/conversation-turn-finalize.ts +12 -0
  22. package/src/daemon/handlers/__tests__/conversation-history-search.test.ts +61 -0
  23. package/src/daemon/handlers/config-channels.ts +3 -1
  24. package/src/daemon/handlers/conversation-history.ts +11 -1
  25. package/src/daemon/handlers/conversations.ts +118 -10
  26. package/src/desktop/desktop-browser-endpoint.ts +1 -0
  27. package/src/ipc/routes/__tests__/conversation-sync-ipc-routes.test.ts +49 -1
  28. package/src/ipc/routes/conversation-sync-ipc-routes.ts +38 -2
  29. package/src/permissions/question-prompter.ts +14 -4
  30. package/src/persistence/conversation-crud.ts +114 -3
  31. package/src/persistence/conversation-queries.ts +48 -3
  32. package/src/persistence/embeddings/__tests__/embedding-local-lifecycle.test.ts +157 -32
  33. package/src/persistence/embeddings/embedding-backend-shutdown.test.ts +47 -0
  34. package/src/persistence/embeddings/embedding-backend.ts +23 -41
  35. package/src/persistence/embeddings/embedding-gemini.test.ts +17 -0
  36. package/src/persistence/embeddings/embedding-gemini.ts +3 -1
  37. package/src/persistence/embeddings/embedding-local.ts +72 -17
  38. package/src/persistence/embeddings/embedding-types.ts +0 -19
  39. package/src/plugins/defaults/memory/__tests__/memory-retrospective-skill-card.test.ts +2 -0
  40. package/src/plugins/defaults/memory/memory-retrospective-skill-card.ts +5 -0
  41. package/src/providers/gemini/client.ts +17 -1
  42. package/src/providers/malformed-tool-call.ts +55 -0
  43. package/src/providers/openai/__tests__/chat-completions-malformed-tool-call.test.ts +105 -0
  44. package/src/providers/openai/chat-completions-provider.ts +30 -0
  45. package/src/providers/platform-proxy/constants.ts +5 -0
  46. package/src/providers/retry.ts +68 -36
  47. package/src/providers/vellum-model-routing.test.ts +1 -0
  48. package/src/runtime/__tests__/agent-wake-archived-gate.test.ts +90 -0
  49. package/src/runtime/__tests__/question-resolution.test.ts +32 -0
  50. package/src/runtime/agent-wake.ts +9 -2
  51. package/src/runtime/auth/__tests__/middleware.test.ts +138 -0
  52. package/src/runtime/auth/middleware.ts +15 -11
  53. package/src/runtime/middleware/auth.ts +7 -1
  54. package/src/runtime/pending-interactions.ts +8 -8
  55. package/src/runtime/question-resolution.ts +54 -8
  56. package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +146 -0
  57. package/src/runtime/routes/__tests__/global-search-routes.test.ts +74 -1
  58. package/src/runtime/routes/conversation-list-routes.ts +16 -14
  59. package/src/runtime/routes/conversation-query-routes.ts +4 -0
  60. package/src/runtime/routes/global-search-routes.ts +22 -7
  61. package/src/runtime/sync/documents-sidecar-publish.test.ts +50 -4
  62. package/src/runtime/sync/resource-sync-events.ts +11 -0
  63. package/src/runtime/sync/worker-daemon-notify.test.ts +30 -0
  64. package/src/runtime/sync/worker-daemon-notify.ts +44 -0
  65. package/src/runtime/verification-outbound-actions.ts +2 -2
  66. package/src/tools/ask-question/ask-question-tool.test.ts +38 -0
  67. package/src/tools/ask-question/ask-question-tool.ts +12 -1
  68. package/src/util/worker-pipe.test.ts +100 -0
  69. package/src/util/worker-pipe.ts +44 -0
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 | 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 |
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` | Manage configuration |
81
- | `assistant keys set\|list\|delete` | Manage API keys in secure storage |
82
- | `assistant trust list\|add\|update\|remove` | Manage trust rules |
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), JWT verification is skipped and a synthetic context is used.
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`. This prevents accidental guardian replacement.
248
- - **Takeover prevention:** Verification is rejected when an active binding exists for a different external user. Same-user re-verification is allowed.
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
 
@@ -0,0 +1,4 @@
1
+ [org.xfce.mousepad.preferences.view]
2
+ font-name='FreeSans 12'
3
+ use-default-monospace-font=false
4
+ word-wrap=true
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.3
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), or "scheduled" to list only scheduled conversations.
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.12.3-staging.2",
3
+ "version": "0.12.4-staging.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -241,8 +241,10 @@ function makeConfirmationEmittingSession(opts?: {
241
241
  // Tests
242
242
  // ---------------------------------------------------------------------------
243
243
 
244
- const TEST_TOKEN = "test-bearer-token-approvals";
245
- const AUTH_HEADERS = { Authorization: `Bearer ${TEST_TOKEN}` };
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
- const TEST_TOKEN = "test-bearer-token-calls";
86
- const AUTH_HEADERS = { Authorization: `Bearer ${TEST_TOKEN}` };
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
  // -----------------------------------------------------------------------
@@ -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,
@@ -191,6 +191,7 @@ const MANAGED_FALLBACK_PROVIDERS: string[] = [
191
191
  "openai",
192
192
  "fireworks",
193
193
  "together",
194
+ "typesafe",
194
195
  ];
195
196
 
196
197
  function enableManagedProxy() {
@@ -314,28 +315,57 @@ describe("managed proxy integration — credential precedence", () => {
314
315
  },
315
316
  );
316
317
 
317
- test("managed bootstrap registers anthropic, openai, gemini, fireworks, and together", async () => {
318
+ test("managed bootstrap registers every managed-routable catalog provider", async () => {
318
319
  enableManagedProxy();
319
320
  mockProviderKeys = {};
320
321
  await initializeProviders(makeProvidersConfig("anthropic", "test-model"));
321
- expect(listProviders()).toEqual(
322
- expect.arrayContaining([
323
- "anthropic",
324
- "openai",
325
- "gemini",
326
- "fireworks",
327
- "together",
328
- ]),
322
+ expect([...listProviders()].sort()).toEqual(
323
+ [...MANAGED_FALLBACK_PROVIDERS].sort(),
329
324
  );
330
- expect(listProviders()).toHaveLength(5);
331
- expect(getProviderRoutingSource("anthropic")).toBe("managed-proxy");
332
- expect(getProviderRoutingSource("openai")).toBe("managed-proxy");
333
- expect(getProviderRoutingSource("gemini")).toBe("managed-proxy");
334
- expect(getProviderRoutingSource("fireworks")).toBe("managed-proxy");
335
- expect(getProviderRoutingSource("together")).toBe("managed-proxy");
325
+ for (const provider of MANAGED_FALLBACK_PROVIDERS) {
326
+ expect(getProviderRoutingSource(provider)).toBe("managed-proxy");
327
+ }
336
328
  expect(getProviderRoutingSource("openrouter")).toBeUndefined();
337
329
  });
338
330
 
331
+ test("managed typesafe sends System One through the typesafe proxy path with the assistant key", async () => {
332
+ enableManagedProxy();
333
+ mockProviderKeys = {};
334
+ await initializeProviders(makeProvidersConfig("anthropic", "test-model"));
335
+
336
+ const provider = getProvider("typesafe");
337
+ const originalFetch = globalThis.fetch;
338
+ let capturedUrl = "";
339
+ let capturedHeaders: Record<string, string> = {};
340
+ globalThis.fetch = (async (
341
+ input: string | URL | Request,
342
+ init?: RequestInit,
343
+ ) => {
344
+ capturedUrl = String(input);
345
+ capturedHeaders = (init?.headers ?? {}) as Record<string, string>;
346
+ return new Response(
347
+ JSON.stringify({
348
+ model: "jev-latest",
349
+ answers: {},
350
+ usage: { input_tokens: 1, output_tokens: 0 },
351
+ }),
352
+ { status: 200, headers: { "Content-Type": "application/json" } },
353
+ );
354
+ }) as typeof fetch;
355
+ try {
356
+ await provider.sendMessage([userMsg("hello")], {
357
+ config: { model: "jev-latest" },
358
+ });
359
+ } finally {
360
+ globalThis.fetch = originalFetch;
361
+ }
362
+
363
+ expect(capturedUrl).toBe(
364
+ `${PLATFORM_BASE}/v1/runtime-proxy/typesafe/v1/systemone`,
365
+ );
366
+ expect(capturedHeaders.Authorization).toBe(`Bearer ${MANAGED_API_KEY}`);
367
+ });
368
+
339
369
  test("managed anthropic uses anthropic proxy path", async () => {
340
370
  enableManagedProxy();
341
371
  mockProviderKeys = {};
@@ -12,7 +12,9 @@ mock.module("../util/retry.js", () => ({
12
12
  import {
13
13
  recordFallbackServed,
14
14
  resetFallbackBreaker,
15
+ shouldSkipPrimary,
15
16
  } from "../providers/fallback-breaker.js";
17
+ import { malformedToolCallError } from "../providers/malformed-tool-call.js";
16
18
  import type {
17
19
  Message,
18
20
  Provider,
@@ -666,6 +668,26 @@ describe("RetryProvider fallback-route escalation", () => {
666
668
  expect(backup.calls()).toBe(1);
667
669
  });
668
670
 
671
+ test("malformed tool call exhausts retries → backup serves, breaker stays closed", async () => {
672
+ const primary = failingProvider("openai", () =>
673
+ malformedToolCallError("openai", "MALFORMED_FUNCTION_CALL"),
674
+ );
675
+ const backup = backupProvider();
676
+ const route = makeRoute(backup.provider);
677
+ const wrapped = new RetryProvider(primary.provider, {
678
+ resolveFallbackRoute: route.resolveFallbackRoute,
679
+ });
680
+
681
+ const result = await wrapped.sendMessage(MESSAGES, {
682
+ config: { callSite: "mainAgent" },
683
+ });
684
+
685
+ expect(primary.calls()).toBe(1 + DEFAULT_MAX_RETRIES);
686
+ expect(backup.calls()).toBe(1);
687
+ expect(result.model).toBe("backup-model");
688
+ expect(shouldSkipPrimary({ upstream: "openai" })).toBe(false);
689
+ });
690
+
669
691
  test("resolveFallbackRoute unset → behavior unchanged (original error rethrown)", async () => {
670
692
  const finalError = new ProviderError("Service Unavailable", "openai", 503);
671
693
  const primary = failingProvider("openai", () => finalError);
@@ -55,8 +55,10 @@ afterAll(() => {
55
55
  resetDbForTesting();
56
56
  });
57
57
 
58
- const TEST_TOKEN = "test-bearer-token-attach";
59
- const AUTH_HEADERS = { Authorization: `Bearer ${TEST_TOKEN}` };
58
+ // This suite mocks isHttpAuthDisabled() to true, so a request carrying no
59
+ // bearer authenticates through the dev bypass. A bearer that is present is
60
+ // verified against the signing key, which these tests do not set up.
61
+ const AUTH_HEADERS: Record<string, string> = {};
60
62
 
61
63
  describe("Runtime attachment metadata", () => {
62
64
  let server: RuntimeHttpServer;