@vellumai/assistant 0.11.4-staging.1 → 0.11.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.
Files changed (30) hide show
  1. package/openapi.yaml +1 -1
  2. package/package.json +1 -1
  3. package/src/__tests__/byok-default-profile-ensure.test.ts +15 -0
  4. package/src/__tests__/conversation-queue.test.ts +122 -0
  5. package/src/__tests__/ui-shape-teaching.test.ts +33 -0
  6. package/src/__tests__/ui-voice-picker-surface.test.ts +128 -0
  7. package/src/__tests__/voice-config-update.test.ts +40 -0
  8. package/src/__tests__/workspace-migration-146-repair-retired-fireworks-deepseek-flash-model-id.test.ts +235 -0
  9. package/src/api/surfaces.ts +7 -3
  10. package/src/calls/call-controller.ts +16 -7
  11. package/src/config/bundled-skills/settings/tools/navigate-settings-tab.test.ts +65 -0
  12. package/src/config/bundled-skills/settings/tools/navigate-settings-tab.ts +7 -1
  13. package/src/config/bundled-skills/settings/tools/shared.ts +16 -0
  14. package/src/config/bundled-skills/settings/tools/voice-config-update.ts +19 -1
  15. package/src/config/default-profile-catalog.ts +1 -1
  16. package/src/daemon/conversation-process.ts +27 -0
  17. package/src/daemon/conversation-surfaces.ts +27 -5
  18. package/src/daemon/interactive-turn-sender.ts +59 -0
  19. package/src/daemon/process-message.ts +11 -21
  20. package/src/plugin-api/vision-support.test.ts +1 -1
  21. package/src/providers/model-catalog.ts +3 -3
  22. package/src/providers/model-intents.ts +2 -2
  23. package/src/providers/openai/chat-completions-provider.ts +5 -6
  24. package/src/tools/ui-surface/surface-shape-docs.ts +11 -0
  25. package/src/tts/__tests__/reasoning-tag-filter.test.ts +15 -0
  26. package/src/tts/reasoning-tag-filter.ts +11 -32
  27. package/src/util/think-tag-stream.ts +95 -0
  28. package/src/workspace/byok-default-profile-ensure.ts +15 -6
  29. package/src/workspace/migrations/146-repair-retired-fireworks-deepseek-flash-model-id.ts +195 -0
  30. package/src/workspace/migrations/registry.ts +2 -0
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.11.4
6
+ version: 0.11.3
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.11.4-staging.1",
3
+ "version": "0.11.4-staging.2",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -282,11 +282,26 @@ describe("ensureByokDefaultProfiles", () => {
282
282
  ["openai", "quality-optimized", "gpt-5.4"],
283
283
  ["fireworks", "balanced", "accounts/fireworks/models/kimi-k2p5"],
284
284
  ["fireworks", "balanced", "accounts/fireworks/models/kimi-k2p6"],
285
+ [
286
+ "fireworks",
287
+ "balanced",
288
+ "accounts/fireworks/models/deepseek-v4-flash-0731",
289
+ ],
285
290
  [
286
291
  "fireworks",
287
292
  "quality-optimized",
288
293
  "accounts/fireworks/models/deepseek-v4-flash",
289
294
  ],
295
+ [
296
+ "fireworks",
297
+ "quality-optimized",
298
+ "accounts/fireworks/models/deepseek-v4-flash-0731",
299
+ ],
300
+ [
301
+ "fireworks",
302
+ "cost-optimized",
303
+ "accounts/fireworks/models/deepseek-v4-flash",
304
+ ],
290
305
  ] as const)(
291
306
  "a historical intent-era copy converts (%s %s pinned to %s)",
292
307
  (provider, key, model) => {
@@ -11,6 +11,7 @@ import type {
11
11
  } from "../agent/loop.js";
12
12
  import type { AssistantEvent } from "../api/index.js";
13
13
  import type { Message, ProviderResponse } from "../providers/types.js";
14
+ import { broadcastMessage } from "../runtime/assistant-event-hub.js";
14
15
  import { stampAndBuffer } from "../runtime/assistant-stream-state.js";
15
16
  import { createAbortReason } from "../util/abort-reasons.js";
16
17
  import { setConfig } from "./helpers/set-config.js";
@@ -3923,3 +3924,124 @@ describe("subagent notification user_message_echo suppression", () => {
3923
3924
  await new Promise((r) => setTimeout(r, 10));
3924
3925
  });
3925
3926
  });
3927
+
3928
+ describe("interactive drain sender rebind", () => {
3929
+ beforeEach(() => {
3930
+ pendingRuns = [];
3931
+ });
3932
+
3933
+ test("a drained interactive message rebinds the hub sender for its turn, then restores the pre-drain state", async () => {
3934
+ const conversation = makeConversation();
3935
+ await conversation.loadFromDb();
3936
+
3937
+ const p1 = conversation.processMessage({
3938
+ content: "msg-1",
3939
+ attachments: [],
3940
+ onEvent: () => {},
3941
+ requestId: "req-1",
3942
+ });
3943
+ await waitForPendingRun(1);
3944
+
3945
+ // Queue an interactive item the way the /v1/messages route does when the
3946
+ // conversation is busy.
3947
+ const result = conversation.enqueueMessage({
3948
+ content: "msg-2",
3949
+ requestId: "req-2",
3950
+ onEvent: () => {},
3951
+ isInteractive: true,
3952
+ });
3953
+ expect(result.queued).toBe(true);
3954
+
3955
+ // The interactive turn that finishes ahead of the drain resets the
3956
+ // conversation-level sender to a no-op (process-message.ts contract).
3957
+ // The drained turn must not inherit that dead sender: the
3958
+ // PermissionPrompter reads the conversation-level sender, so a
3959
+ // confirmation_request raised during the drained turn would otherwise
3960
+ // reach no client and hang until the permission timeout.
3961
+ conversation.updateClient(() => {}, true);
3962
+
3963
+ await resolveRun(0);
3964
+ await p1;
3965
+ await waitForPendingRun(2);
3966
+
3967
+ expect(conversation.getCurrentSender()).toBe(broadcastMessage);
3968
+ expect(conversation.hasNoClient).toBe(false);
3969
+
3970
+ await resolveRun(1);
3971
+ await waitForCondition(
3972
+ () => conversation.getCurrentSender() !== broadcastMessage,
3973
+ );
3974
+ expect(conversation.hasNoClient).toBe(true);
3975
+ });
3976
+
3977
+ test("a drained interactive message restores a live pre-drain binding instead of clearing it", async () => {
3978
+ const conversation = makeConversation();
3979
+ await conversation.loadFromDb();
3980
+
3981
+ const p1 = conversation.processMessage({
3982
+ content: "msg-1",
3983
+ attachments: [],
3984
+ onEvent: () => {},
3985
+ requestId: "req-1",
3986
+ });
3987
+ await waitForPendingRun(1);
3988
+
3989
+ conversation.enqueueMessage({
3990
+ content: "msg-2",
3991
+ requestId: "req-2",
3992
+ onEvent: () => {},
3993
+ isInteractive: true,
3994
+ });
3995
+
3996
+ // A live binding predates the drained turn (the send route installs one
3997
+ // when a message is queued behind a non-interactive turn, which performs
3998
+ // no restore). The drain's restore must preserve it for out-of-turn
3999
+ // producers (call transcript and completion notifiers), not clear it.
4000
+ conversation.updateClient(broadcastMessage, false);
4001
+
4002
+ await resolveRun(0);
4003
+ await p1;
4004
+ await waitForPendingRun(2);
4005
+
4006
+ expect(conversation.getCurrentSender()).toBe(broadcastMessage);
4007
+ expect(conversation.hasNoClient).toBe(false);
4008
+
4009
+ await resolveRun(1);
4010
+ // The drained turn has ended; the pre-drain live binding must survive.
4011
+ await waitForCondition(() => !conversation.isProcessing());
4012
+ expect(conversation.getCurrentSender()).toBe(broadcastMessage);
4013
+ expect(conversation.hasNoClient).toBe(false);
4014
+ });
4015
+
4016
+ test("a drained non-interactive message leaves the no-op sender in place", async () => {
4017
+ const conversation = makeConversation();
4018
+ await conversation.loadFromDb();
4019
+
4020
+ const p1 = conversation.processMessage({
4021
+ content: "msg-1",
4022
+ attachments: [],
4023
+ onEvent: () => {},
4024
+ requestId: "req-1",
4025
+ });
4026
+ await waitForPendingRun(1);
4027
+
4028
+ conversation.enqueueMessage({
4029
+ content: "msg-2",
4030
+ requestId: "req-2",
4031
+ onEvent: () => {},
4032
+ isInteractive: false,
4033
+ });
4034
+
4035
+ conversation.updateClient(() => {}, true);
4036
+
4037
+ await resolveRun(0);
4038
+ await p1;
4039
+ await waitForPendingRun(2);
4040
+
4041
+ expect(conversation.getCurrentSender()).not.toBe(broadcastMessage);
4042
+ expect(conversation.hasNoClient).toBe(true);
4043
+
4044
+ await resolveRun(1);
4045
+ await new Promise((r) => setTimeout(r, 10));
4046
+ });
4047
+ });
@@ -5,6 +5,7 @@ import { uiShowTool } from "../tools/ui-surface/definitions.js";
5
5
  import {
6
6
  SURFACE_SHAPE_DOCS,
7
7
  SURFACE_TYPE_NAMES,
8
+ UI_SHOW_TYPE_DOCS,
8
9
  uiShowTeachingError,
9
10
  } from "../tools/ui-surface/surface-shape-docs.js";
10
11
 
@@ -290,6 +291,12 @@ describe("ui_show displayable payloads proxy through", () => {
290
291
  surfaceType: "channel_setup",
291
292
  input: { surface_type: "channel_setup", data: { channel: "telegram" } },
292
293
  },
294
+ {
295
+ // The picker reads its own voice and catalog, so an empty payload is
296
+ // the whole contract and there is no missing-content state to teach.
297
+ surfaceType: "voice_picker",
298
+ input: { surface_type: "voice_picker", data: {} },
299
+ },
293
300
  ];
294
301
 
295
302
  for (const { surfaceType, input } of cases) {
@@ -347,4 +354,30 @@ describe("uiShowTeachingError", () => {
347
354
  expect(uiShowTool.description).toContain(name);
348
355
  }
349
356
  });
357
+
358
+ test("voice_picker is a cold type that accepts an empty payload", () => {
359
+ expect(
360
+ uiShowTeachingError({ surface_type: "voice_picker", data: {} }),
361
+ ).toBeNull();
362
+ expect(UI_SHOW_TYPE_DOCS).toContain(
363
+ `voice_picker (${SURFACE_SHAPE_DOCS.voice_picker!.purpose})`,
364
+ );
365
+ expect(UI_SHOW_TYPE_DOCS).not.toContain("- voice_picker:");
366
+ });
367
+
368
+ test("voice_picker steering reaches the model through the cold index", () => {
369
+ // Asserted on the built description rather than the doc entry: a cold
370
+ // type's `shape` reaches the model only through a teaching error, and an
371
+ // empty payload has no missing-content state to raise one, so steering
372
+ // parked there would be dead.
373
+ for (const directive of [
374
+ "change, hear, or pick a voice",
375
+ "rather than describing voices in prose",
376
+ "attach no actions and never set await_action",
377
+ "during a live voice call answer in speech",
378
+ ]) {
379
+ expect(UI_SHOW_TYPE_DOCS).toContain(directive);
380
+ expect(uiShowTool.description).toContain(directive);
381
+ }
382
+ });
350
383
  });
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Tests for the `voice_picker` ui_show surface's "never blocks a turn"
3
+ * invariant.
4
+ *
5
+ * The card has no terminal action: it settles when the user picks a voice,
6
+ * with no button to click. `actions` and `await_action` are generic ui_show
7
+ * params though, so the model can attach them to any surface, and the web
8
+ * client latches `awaiting_user_input` on the presence of actions alone with
9
+ * nothing to clear it. The daemon resolver therefore strips both, and a
10
+ * pending picker never holds the one-interactive-surface lock.
11
+ */
12
+
13
+ import { describe, expect, test } from "bun:test";
14
+
15
+ import { UISurfaceShowEventSchema } from "../api/events/ui-surface-show.js";
16
+ import type { AssistantEvent } from "../api/index.js";
17
+ import type { Conversation } from "../daemon/conversation.js";
18
+ import {
19
+ createSurfaceMutex,
20
+ surfaceProxyResolver,
21
+ } from "../daemon/conversation-surfaces.js";
22
+ import type { SurfaceType } from "../daemon/message-protocol.js";
23
+ import { INTERACTIVE_SURFACE_TYPES } from "../daemon/message-protocol.js";
24
+ import { asConversation } from "./helpers/mock-conversation.js";
25
+
26
+ function makeContext(sent: AssistantEvent[] = []): Conversation {
27
+ return asConversation({
28
+ conversationId: "session-1",
29
+ sendToClient: (msg) => sent.push(msg),
30
+ pendingSurfaceActions: new Map<string, { surfaceType: SurfaceType }>(),
31
+ lastSurfaceAction: new Map<
32
+ string,
33
+ { actionId: string; data?: Record<string, unknown> }
34
+ >(),
35
+ surfaceState: new Map(),
36
+ surfaceUndoStacks: new Map<string, string[]>(),
37
+ accumulatedSurfaceState: new Map<string, Record<string, unknown>>(),
38
+ surfaceActionRequestIds: new Set<string>(),
39
+ currentTurnSurfaces: [],
40
+ isProcessing: () => false,
41
+ enqueueMessage: () => ({ queued: false, requestId: "req-1" }),
42
+ getQueueDepth: () => 0,
43
+ processMessage: async () => "ok",
44
+ withSurface: createSurfaceMutex(),
45
+ });
46
+ }
47
+
48
+ function showEvent(sent: AssistantEvent[]) {
49
+ return UISurfaceShowEventSchema.parse(
50
+ sent.find((msg) => msg.type === "ui_surface_show"),
51
+ );
52
+ }
53
+
54
+ describe("voice_picker never blocks a turn", () => {
55
+ test("shows with an empty payload and awaits nothing", async () => {
56
+ const sent: AssistantEvent[] = [];
57
+ const ctx = makeContext(sent);
58
+
59
+ const result = await surfaceProxyResolver(ctx, "ui_show", {
60
+ surface_type: "voice_picker",
61
+ data: {},
62
+ });
63
+
64
+ expect(result.isError).toBe(false);
65
+ expect(result.yieldToUser).toBeUndefined();
66
+
67
+ const parsed = showEvent(sent);
68
+ expect(parsed.surfaceType).toBe("voice_picker");
69
+ expect(parsed.actions).toBeUndefined();
70
+ expect(ctx.pendingSurfaceActions.has(parsed.surfaceId)).toBe(false);
71
+ });
72
+
73
+ test("strips actions the model attached", async () => {
74
+ const sent: AssistantEvent[] = [];
75
+ const ctx = makeContext(sent);
76
+
77
+ const result = await surfaceProxyResolver(ctx, "ui_show", {
78
+ surface_type: "voice_picker",
79
+ data: {},
80
+ actions: [{ id: "done", label: "Done", style: "primary" }],
81
+ });
82
+
83
+ expect(result.isError).toBe(false);
84
+ expect(result.yieldToUser).toBeUndefined();
85
+
86
+ const parsed = showEvent(sent);
87
+ expect(parsed.actions).toBeUndefined();
88
+ // The persisted copy must match the emitted one, or a history reseed would
89
+ // resurrect the button the client blocks on.
90
+ expect(ctx.currentTurnSurfaces[0]?.actions).toBeUndefined();
91
+ expect(ctx.pendingSurfaceActions.has(parsed.surfaceId)).toBe(false);
92
+ });
93
+
94
+ test("ignores an explicit await_action", async () => {
95
+ const sent: AssistantEvent[] = [];
96
+ const ctx = makeContext(sent);
97
+
98
+ const result = await surfaceProxyResolver(ctx, "ui_show", {
99
+ surface_type: "voice_picker",
100
+ data: {},
101
+ await_action: true,
102
+ });
103
+
104
+ expect(result.isError).toBe(false);
105
+ expect(result.yieldToUser).toBeUndefined();
106
+ expect(result.content).not.toContain("awaiting_user_action");
107
+ expect(ctx.pendingSurfaceActions.size).toBe(0);
108
+ });
109
+
110
+ test("a pending picker does not hold the one-interactive-surface lock", async () => {
111
+ const ctx = makeContext();
112
+ ctx.pendingSurfaceActions.set("surface-picker", {
113
+ surfaceType: "voice_picker",
114
+ });
115
+
116
+ const result = await surfaceProxyResolver(ctx, "ui_show", {
117
+ surface_type: "choice",
118
+ data: { options: [{ id: "a", title: "Option A" }] },
119
+ });
120
+
121
+ expect(result.isError).toBe(false);
122
+ expect(result.content).not.toContain("already awaiting user input");
123
+ });
124
+
125
+ test("voice_picker is not an interactive surface type", () => {
126
+ expect(INTERACTIVE_SURFACE_TYPES).not.toContain("voice_picker");
127
+ });
128
+ });
@@ -276,6 +276,46 @@ describe("voice_config_update — tts_voice_id (managed/vellum active)", () => {
276
276
  expect(result.content).not.toContain("broadcast");
277
277
  });
278
278
 
279
+ test("hints the inline picker for the next managed voice change", async () => {
280
+ makeVellumActive();
281
+
282
+ const result = await run(
283
+ { setting: "tts_voice_id", value: "aura-2-zeus-en" },
284
+ makeContext(),
285
+ );
286
+
287
+ expect(result.isError).toBe(false);
288
+ expect(result.content).toStartWith("Vellum managed voice updated to");
289
+ expect(result.content).toContain(
290
+ 'ui_show { surface_type: "voice_picker", data: {} }',
291
+ );
292
+ });
293
+
294
+ test("does not hint the picker when the voice is not managed", async () => {
295
+ writeConfig({ services: { tts: { provider: "elevenlabs" } } });
296
+ invalidateConfigCache();
297
+
298
+ const result = await run(
299
+ { setting: "tts_voice_id", value: "abc123" },
300
+ makeContext(),
301
+ );
302
+
303
+ expect(result.isError).toBe(false);
304
+ expect(result.content).not.toContain("voice_picker");
305
+ });
306
+
307
+ test("does not hint the picker for a non-voice setting", async () => {
308
+ makeVellumActive();
309
+
310
+ const result = await run(
311
+ { setting: "conversation_timeout", value: 30 },
312
+ makeContext(),
313
+ );
314
+
315
+ expect(result.isError).toBe(false);
316
+ expect(result.content).not.toContain("voice_picker");
317
+ });
318
+
279
319
  test("rejects a value with characters invalid for any voice id", async () => {
280
320
  makeVellumActive();
281
321
 
@@ -0,0 +1,235 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ rmSync,
6
+ writeFileSync,
7
+ } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { Database } from "bun:sqlite";
11
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
12
+
13
+ import { repairRetiredFireworksDeepseekFlashModelIdMigration } from "../workspace/migrations/146-repair-retired-fireworks-deepseek-flash-model-id.js";
14
+ import { WORKSPACE_MIGRATIONS } from "../workspace/migrations/registry.js";
15
+ import { assertNotLiveDb } from "./assert-not-live-db.js";
16
+
17
+ const STALE = "accounts/fireworks/models/deepseek-v4-flash";
18
+ const REPLACEMENT = "accounts/fireworks/models/deepseek-v4-flash-0731";
19
+
20
+ let workspaceDir: string;
21
+
22
+ function freshWorkspace(): void {
23
+ workspaceDir = join(
24
+ tmpdir(),
25
+ `vellum-migration-146-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
26
+ );
27
+ mkdirSync(workspaceDir, { recursive: true });
28
+ }
29
+
30
+ function writeConfig(data: Record<string, unknown>): void {
31
+ writeFileSync(
32
+ join(workspaceDir, "config.json"),
33
+ JSON.stringify(data, null, 2) + "\n",
34
+ );
35
+ }
36
+
37
+ function readConfig(): Record<string, unknown> {
38
+ return JSON.parse(readFileSync(join(workspaceDir, "config.json"), "utf-8"));
39
+ }
40
+
41
+ function seedRows(rows: Array<{ name: string; provider: string }>): void {
42
+ mkdirSync(join(workspaceDir, "data", "db"), { recursive: true });
43
+ const db = new Database(join(workspaceDir, "data", "db", "assistant.db"));
44
+ db.run(`CREATE TABLE IF NOT EXISTS provider_connections (
45
+ name TEXT PRIMARY KEY,
46
+ provider TEXT NOT NULL,
47
+ auth TEXT NOT NULL,
48
+ created_at INTEGER NOT NULL,
49
+ updated_at INTEGER NOT NULL
50
+ )`);
51
+ for (const row of rows) {
52
+ db.query(
53
+ `INSERT INTO provider_connections (name, provider, auth, created_at, updated_at) VALUES (?, ?, '{"type":"api_key"}', 1, 1)`,
54
+ ).run(row.name, row.provider);
55
+ }
56
+ db.close();
57
+ }
58
+
59
+ beforeEach(() => {
60
+ freshWorkspace();
61
+ });
62
+
63
+ afterEach(() => {
64
+ if (existsSync(workspaceDir)) {
65
+ assertNotLiveDb(workspaceDir);
66
+ rmSync(workspaceDir, { recursive: true, force: true });
67
+ }
68
+ });
69
+
70
+ describe("146-repair-retired-fireworks-deepseek-flash-model-id migration", () => {
71
+ test("has correct migration id and is registered", () => {
72
+ expect(repairRetiredFireworksDeepseekFlashModelIdMigration.id).toBe(
73
+ "146-repair-retired-fireworks-deepseek-flash-model-id",
74
+ );
75
+ expect(WORKSPACE_MIGRATIONS.map((m) => m.id)).toContain(
76
+ "146-repair-retired-fireworks-deepseek-flash-model-id",
77
+ );
78
+ });
79
+
80
+ test("repairs the stale ID in default, call sites, and profiles", () => {
81
+ writeConfig({
82
+ llm: {
83
+ default: { provider: "fireworks", model: STALE },
84
+ callSites: {
85
+ recall: { model: STALE, maxTokens: 4096 },
86
+ heartbeat: { model: `${STALE}-tuned` },
87
+ malformed: STALE,
88
+ },
89
+ profiles: {
90
+ "cost-optimized": { provider: "vellum", model: STALE },
91
+ legacy: { model: STALE },
92
+ },
93
+ },
94
+ });
95
+
96
+ repairRetiredFireworksDeepseekFlashModelIdMigration.run(workspaceDir);
97
+
98
+ const llm = readConfig().llm as Record<string, any>;
99
+ expect(llm.default.model).toBe(REPLACEMENT);
100
+ expect(llm.callSites.recall.model).toBe(REPLACEMENT);
101
+ expect(llm.callSites.recall.maxTokens).toBe(4096);
102
+ // Non-exact matches and malformed leaves are untouched. The dated
103
+ // replacement itself extends the stale ID, so the exact match must not
104
+ // fire on already-repaired leaves.
105
+ expect(llm.callSites.heartbeat.model).toBe(`${STALE}-tuned`);
106
+ expect(llm.callSites.malformed).toBe(STALE);
107
+ // Managed profiles stamped provider "vellum" carry Fireworks model IDs.
108
+ expect(llm.profiles["cost-optimized"].model).toBe(REPLACEMENT);
109
+ expect(llm.profiles.legacy.model).toBe(REPLACEMENT);
110
+ });
111
+
112
+ test("repairs entry-bound fragments whose row kind is fireworks or vellum", () => {
113
+ seedRows([
114
+ { name: "my-fireworks", provider: "fireworks" },
115
+ { name: "managed-alt", provider: "vellum" },
116
+ ]);
117
+ writeConfig({
118
+ llm: {
119
+ profiles: {
120
+ bound: { provider: "my-fireworks", model: STALE },
121
+ managed: { provider: "managed-alt", model: STALE },
122
+ },
123
+ },
124
+ });
125
+
126
+ repairRetiredFireworksDeepseekFlashModelIdMigration.run(workspaceDir);
127
+
128
+ const llm = readConfig().llm as Record<string, any>;
129
+ expect(llm.profiles.bound.model).toBe(REPLACEMENT);
130
+ expect(llm.profiles.managed.model).toBe(REPLACEMENT);
131
+ });
132
+
133
+ test("leaves fragments with an explicit other provider untouched", () => {
134
+ seedRows([{ name: "byo-compat", provider: "openai-compatible" }]);
135
+ writeConfig({
136
+ llm: {
137
+ default: { provider: "openai-compatible", model: STALE },
138
+ profiles: {
139
+ byo: { provider: "openai-compatible", model: STALE },
140
+ // Entry-bound profile whose row kind is not repairable.
141
+ compatBound: { provider: "byo-compat", model: STALE },
142
+ // Entry name with no row: dangling, so nothing proves it is a
143
+ // fireworks-kind route.
144
+ dangling: { provider: "ghost", model: STALE },
145
+ },
146
+ },
147
+ });
148
+
149
+ repairRetiredFireworksDeepseekFlashModelIdMigration.run(workspaceDir);
150
+
151
+ const llm = readConfig().llm as Record<string, any>;
152
+ expect(llm.default.model).toBe(STALE);
153
+ expect(llm.profiles.byo.model).toBe(STALE);
154
+ expect(llm.profiles.compatBound.model).toBe(STALE);
155
+ expect(llm.profiles.dangling.model).toBe(STALE);
156
+ });
157
+
158
+ test("leaves entry-name providers untouched when no DB file exists", () => {
159
+ writeConfig({
160
+ llm: {
161
+ profiles: { bound: { provider: "my-fireworks", model: STALE } },
162
+ },
163
+ });
164
+
165
+ repairRetiredFireworksDeepseekFlashModelIdMigration.run(workspaceDir);
166
+
167
+ const llm = readConfig().llm as Record<string, any>;
168
+ expect(llm.profiles.bound.model).toBe(STALE);
169
+ });
170
+
171
+ test("throws when an entry-name provider needs rows and the DB is unreadable", () => {
172
+ // A DB file without the provider_connections table is unqueryable, so
173
+ // the run must fail (and retry later) instead of skipping the profile.
174
+ mkdirSync(join(workspaceDir, "data", "db"), { recursive: true });
175
+ writeFileSync(join(workspaceDir, "data", "db", "assistant.db"), "");
176
+ writeConfig({
177
+ llm: {
178
+ profiles: { bound: { provider: "my-fireworks", model: STALE } },
179
+ },
180
+ });
181
+
182
+ expect(() =>
183
+ repairRetiredFireworksDeepseekFlashModelIdMigration.run(workspaceDir),
184
+ ).toThrow();
185
+ const llm = readConfig().llm as Record<string, any>;
186
+ expect(llm.profiles.bound.model).toBe(STALE);
187
+
188
+ // Vendor and absent providers never need the rows, so the same broken
189
+ // DB does not block their repair.
190
+ writeConfig({
191
+ llm: {
192
+ default: { provider: "fireworks", model: STALE },
193
+ profiles: { legacy: { model: STALE } },
194
+ },
195
+ });
196
+ repairRetiredFireworksDeepseekFlashModelIdMigration.run(workspaceDir);
197
+ const repaired = readConfig().llm as Record<string, any>;
198
+ expect(repaired.default.model).toBe(REPLACEMENT);
199
+ expect(repaired.profiles.legacy.model).toBe(REPLACEMENT);
200
+ });
201
+
202
+ test("is idempotent and a no-op without the stale ID", () => {
203
+ writeConfig({
204
+ llm: {
205
+ default: { provider: "fireworks", model: STALE },
206
+ profiles: { fine: { provider: "fireworks", model: REPLACEMENT } },
207
+ },
208
+ });
209
+
210
+ repairRetiredFireworksDeepseekFlashModelIdMigration.run(workspaceDir);
211
+ const first = readConfig();
212
+ repairRetiredFireworksDeepseekFlashModelIdMigration.run(workspaceDir);
213
+ expect(readConfig()).toEqual(first);
214
+
215
+ const llm = first.llm as Record<string, any>;
216
+ expect(llm.default.model).toBe(REPLACEMENT);
217
+ expect(llm.profiles.fine.model).toBe(REPLACEMENT);
218
+ });
219
+
220
+ test("handles missing config, missing llm block, and invalid JSON", () => {
221
+ // No config.json at all.
222
+ expect(() =>
223
+ repairRetiredFireworksDeepseekFlashModelIdMigration.run(workspaceDir),
224
+ ).not.toThrow();
225
+
226
+ writeConfig({ theme: "dark" });
227
+ repairRetiredFireworksDeepseekFlashModelIdMigration.run(workspaceDir);
228
+ expect(readConfig()).toEqual({ theme: "dark" });
229
+
230
+ writeFileSync(join(workspaceDir, "config.json"), "{not json");
231
+ expect(() =>
232
+ repairRetiredFireworksDeepseekFlashModelIdMigration.run(workspaceDir),
233
+ ).not.toThrow();
234
+ });
235
+ });
@@ -591,6 +591,7 @@ export const SURFACE_TYPES = [
591
591
  "skill_card",
592
592
  "call_summary",
593
593
  "visual",
594
+ "voice_picker",
594
595
  ] as const;
595
596
 
596
597
  export const SurfaceTypeSchema = z.enum(SURFACE_TYPES);
@@ -662,9 +663,10 @@ export type SurfaceData =
662
663
  * and serves verbatim but whose shape it does not model, which is why they
663
664
  * are absent from the renderable `SurfaceData` union: `channel_setup` (a
664
665
  * side-effect command forwarded to the setup panel), `task_preferences` (a
665
- * fixed grid that reads no data), and `skill_card` / `call_summary` (cards
666
- * the daemon appends to history directly — the memory retrospective and a
667
- * call summary — and whose data shape is owned by their client renderers).
666
+ * fixed grid that reads no data), `voice_picker` (a settings card that reads
667
+ * its own config), and `skill_card` / `call_summary` (cards the daemon appends
668
+ * to history directly, the memory retrospective and a call summary, whose
669
+ * data shape is owned by their client renderers).
668
670
  */
669
671
  export interface SurfaceDataByType {
670
672
  card: CardSurfaceData;
@@ -684,6 +686,7 @@ export interface SurfaceDataByType {
684
686
  skill_card: Record<string, unknown>;
685
687
  call_summary: Record<string, unknown>;
686
688
  visual: VisualSurfaceData;
689
+ voice_picker: Record<string, unknown>;
687
690
  }
688
691
 
689
692
  /** Any surface `data` payload, including the opaque (non-renderable) types. */
@@ -717,6 +720,7 @@ export const SURFACE_DATA_SCHEMAS: {
717
720
  skill_card: z.record(z.string(), z.unknown()),
718
721
  call_summary: z.record(z.string(), z.unknown()),
719
722
  visual: VisualSurfaceDataSchema,
723
+ voice_picker: z.record(z.string(), z.unknown()),
720
724
  };
721
725
 
722
726
  /**