@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
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Shared primitives for streaming think-tag scanning.
3
+ *
4
+ * Two consumers parse inline `<think>` spans out of streamed model content
5
+ * and must not drift apart (single source of truth):
6
+ *
7
+ * - `providers/openai/chat-completions-provider.ts` ROUTES the spans to
8
+ * `thinking_delta` events (content preserved, case-sensitive `<think>`
9
+ * only, matching the models that emit it).
10
+ * - `tts/reasoning-tag-filter.ts` DROPS the spans from the spoken stream
11
+ * (case-insensitive, `<think>` and `<thinking>` variants).
12
+ *
13
+ * The state machines stay separate because they do different things with
14
+ * the reasoning; what they share is the tag scanning below. All scanning is
15
+ * positionally exact on the ORIGINAL string: case-insensitive mode folds
16
+ * A-Z per character code and never calls `toLowerCase()` on the haystack,
17
+ * because Unicode lowercasing can change string length (e.g. dotted capital
18
+ * I, U+0130, lowers to two code units) and shift every subsequent index.
19
+ * Tags must be given in lowercase ASCII.
20
+ */
21
+
22
+ function foldedCharCode(code: number, caseInsensitive: boolean): number {
23
+ return caseInsensitive && code >= 65 && code <= 90 ? code + 32 : code;
24
+ }
25
+
26
+ function tagMatchesAt(
27
+ haystack: string,
28
+ start: number,
29
+ tag: string,
30
+ caseInsensitive: boolean,
31
+ ): boolean {
32
+ if (start + tag.length > haystack.length) {
33
+ return false;
34
+ }
35
+ for (let k = 0; k < tag.length; k += 1) {
36
+ if (
37
+ foldedCharCode(haystack.charCodeAt(start + k), caseInsensitive) !==
38
+ tag.charCodeAt(k)
39
+ ) {
40
+ return false;
41
+ }
42
+ }
43
+ return true;
44
+ }
45
+
46
+ /**
47
+ * First occurrence of any tag in `haystack`, by original-string index.
48
+ * When several tags match at the same index the longest wins, so
49
+ * `<thinking>` is not consumed as `<think>` plus stray text.
50
+ */
51
+ export function indexOfTag(
52
+ haystack: string,
53
+ tags: readonly string[],
54
+ caseInsensitive: boolean,
55
+ ): { index: number; tag: string } | null {
56
+ for (let i = 0; i < haystack.length; i += 1) {
57
+ let bestTag: string | null = null;
58
+ for (const tag of tags) {
59
+ if (tagMatchesAt(haystack, i, tag, caseInsensitive)) {
60
+ if (bestTag === null || tag.length > bestTag.length) {
61
+ bestTag = tag;
62
+ }
63
+ }
64
+ }
65
+ if (bestTag !== null) {
66
+ return { index: i, tag: bestTag };
67
+ }
68
+ }
69
+ return null;
70
+ }
71
+
72
+ /**
73
+ * Length of the longest suffix of `text` that is a proper prefix of any
74
+ * listed tag. Streaming scanners hold that many characters back so a tag
75
+ * split across deltas is not emitted before it can be recognized.
76
+ */
77
+ export function partialTagSuffix(
78
+ text: string,
79
+ tags: readonly string[],
80
+ caseInsensitive: boolean,
81
+ ): number {
82
+ const longest = Math.max(...tags.map((tag) => tag.length)) - 1;
83
+ for (let len = Math.min(text.length, longest); len > 0; len -= 1) {
84
+ const start = text.length - len;
85
+ for (const tag of tags) {
86
+ if (
87
+ len < tag.length &&
88
+ tagMatchesAt(text, start, tag.slice(0, len), caseInsensitive)
89
+ ) {
90
+ return len;
91
+ }
92
+ }
93
+ }
94
+ return 0;
95
+ }
@@ -163,9 +163,10 @@ function foldPersonalEntryName(provider: string): string {
163
163
  * 2026-05-05, #29755), and the profile-model migrations (100, 103, 109, 113,
164
164
  * 123) deliberately rewrite only the managed default entries (`custom-*` is
165
165
  * the user's to manage), so an untouched copy still carries whichever value
166
- * its hatch-era intent resolved to. Migration 136 is the one exception: it
167
- * rewrote kimi-k2p5 pins (`custom-*` included) to deepseek-v4-flash in
168
- * place. A model listed here counts as unedited only when every non-model
166
+ * its hatch-era intent resolved to. Migrations 136 and 146 are the
167
+ * exceptions: they rewrite stale Fireworks pins (`custom-*` included) in
168
+ * place, so their targets are machinery-authored values too. A model listed
169
+ * here counts as unedited only when every non-model
169
170
  * field still matches the template.
170
171
  */
171
172
  const HISTORICAL_INTENT_MODELS: Record<
@@ -184,6 +185,8 @@ const HISTORICAL_INTENT_MODELS: Record<
184
185
  "accounts/fireworks/models/kimi-k2p6",
185
186
  // migration 136's in-place rewrite of a kimi-k2p5 pin.
186
187
  "accounts/fireworks/models/deepseek-v4-flash",
188
+ // the ID migration 146 writes over stale deepseek-v4-flash pins.
189
+ "accounts/fireworks/models/deepseek-v4-flash-0731",
187
190
  ],
188
191
  },
189
192
  "quality-optimized": {
@@ -208,6 +211,8 @@ const HISTORICAL_INTENT_MODELS: Record<
208
211
  "accounts/fireworks/models/kimi-k2p5",
209
212
  // migration 136's in-place rewrite of a kimi-k2p5 pin.
210
213
  "accounts/fireworks/models/deepseek-v4-flash",
214
+ // the ID migration 146 writes over stale deepseek-v4-flash pins.
215
+ "accounts/fireworks/models/deepseek-v4-flash-0731",
211
216
  ],
212
217
  },
213
218
  "cost-optimized": {
@@ -221,10 +226,14 @@ const HISTORICAL_INTENT_MODELS: Record<
221
226
  ],
222
227
  fireworks: [
223
228
  // latency intent 2026-05-05 (#29755) to 2026-07-28 (#39446). On live
224
- // configs migration 136 rewrote it to deepseek-v4-flash (the current
225
- // intent), so this survives only in configs restored from pre-136
226
- // backups.
229
+ // configs migration 136 rewrites it to deepseek-v4-flash, so this
230
+ // survives only in configs restored from pre-136 backups.
227
231
  "accounts/fireworks/models/kimi-k2p5",
232
+ // latency intent 2026-07-28 (#39446) to the 2026-08-17 dated-ID
233
+ // repoint; also the ID migration 136 writes. On live configs migration
234
+ // 146 rewrites it to the dated ID, so this survives only in pre-146
235
+ // backups.
236
+ "accounts/fireworks/models/deepseek-v4-flash",
228
237
  ],
229
238
  },
230
239
  };
@@ -0,0 +1,195 @@
1
+ import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { Database } from "bun:sqlite";
4
+
5
+ import type { WorkspaceMigration } from "./types.js";
6
+
7
+ /**
8
+ * Repair the retired undated Fireworks DeepSeek V4 Flash model ID in
9
+ * workspace LLM config.
10
+ *
11
+ * Fireworks serves DeepSeek V4 Flash only under the dated official release
12
+ * ID `accounts/fireworks/models/deepseek-v4-flash-0731`; the undated
13
+ * `accounts/fireworks/models/deepseek-v4-flash` has no deployment and every
14
+ * call to it fails with a 404 "Model not found, inaccessible, and/or not
15
+ * deployed". Existing configs can still pin the undated ID in
16
+ * `llm.default`, `llm.callSites.*`, and `llm.profiles.*`.
17
+ *
18
+ * Repair those leaves only on an exact stale match, replacing with the
19
+ * dated ID.
20
+ *
21
+ * Provider guard: the stale ID belongs to the `fireworks` provider and also
22
+ * appears in managed profiles stamped `provider: "vellum"` (which route
23
+ * Fireworks-account model IDs through the managed proxy). Under the entries
24
+ * model (migration 145) `provider` can also hold a `provider_connections`
25
+ * entry name whose row kind drives dispatch. A fragment is repaired when
26
+ * its `provider` is `"fireworks"`, `"vellum"`, absent, or an entry name
27
+ * whose row kind is one of those: every fireworks-kind route serves the
28
+ * dated ID and only the dated ID. Any other provider is left untouched: an
29
+ * `openai-compatible` endpoint may legitimately serve a model by the stale
30
+ * name.
31
+ */
32
+ export const repairRetiredFireworksDeepseekFlashModelIdMigration: WorkspaceMigration =
33
+ {
34
+ id: "146-repair-retired-fireworks-deepseek-flash-model-id",
35
+ description:
36
+ "Repair retired Fireworks accounts/fireworks/models/deepseek-v4-flash model ID in workspace LLM config",
37
+ run(workspaceDir: string): void {
38
+ const configPath = join(workspaceDir, "config.json");
39
+ if (!existsSync(configPath)) {
40
+ return;
41
+ }
42
+
43
+ // Read outside the parse catch: a transient filesystem error (EIO,
44
+ // EACCES) must reach the runner so the migration retries, while
45
+ // malformed JSON is a permanent state this migration cannot repair.
46
+ const rawText = readFileSync(configPath, "utf-8");
47
+
48
+ let config: Record<string, unknown>;
49
+ try {
50
+ const raw = JSON.parse(rawText);
51
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
52
+ return;
53
+ }
54
+ config = raw as Record<string, unknown>;
55
+ } catch {
56
+ return;
57
+ }
58
+
59
+ const llm = readObject(config.llm);
60
+ if (llm === null) {
61
+ return;
62
+ }
63
+
64
+ // Entry rows load lazily: only a stale fragment whose provider is
65
+ // neither a repairable vendor nor absent needs them. An unreadable DB
66
+ // then fails the run (retried next boot) rather than checkpointing a
67
+ // pass that skips entry-bound profiles.
68
+ let rows: Map<string, string> | null | undefined;
69
+ const isRepairableProvider = (provider: unknown): boolean => {
70
+ if (provider === undefined) {
71
+ return true;
72
+ }
73
+ if (typeof provider !== "string") {
74
+ return false;
75
+ }
76
+ if (REPAIRABLE_PROVIDERS.has(provider)) {
77
+ return true;
78
+ }
79
+ if (rows === undefined) {
80
+ rows = readConnectionRows(workspaceDir);
81
+ }
82
+ if (rows === null) {
83
+ throw new Error(
84
+ "provider_connections is not readable; retrying the model-ID repair on the next run",
85
+ );
86
+ }
87
+ const kind = rows.get(provider);
88
+ return kind !== undefined && REPAIRABLE_PROVIDERS.has(kind);
89
+ };
90
+
91
+ let changed = false;
92
+
93
+ changed =
94
+ repairFragment(readObject(llm.default), isRepairableProvider) ||
95
+ changed;
96
+
97
+ const callSites = readObject(llm.callSites);
98
+ if (callSites !== null) {
99
+ for (const rawConfig of Object.values(callSites)) {
100
+ changed =
101
+ repairFragment(readObject(rawConfig), isRepairableProvider) ||
102
+ changed;
103
+ }
104
+ }
105
+
106
+ const profiles = readObject(llm.profiles);
107
+ if (profiles !== null) {
108
+ for (const rawProfile of Object.values(profiles)) {
109
+ changed =
110
+ repairFragment(readObject(rawProfile), isRepairableProvider) ||
111
+ changed;
112
+ }
113
+ }
114
+
115
+ if (!changed) {
116
+ return;
117
+ }
118
+
119
+ // Write-then-rename so an interrupted write cannot leave config.json
120
+ // truncated: a torn in-place write would parse as invalid JSON on the
121
+ // retry, which the catch above treats as "nothing to do", letting the
122
+ // runner checkpoint the migration as completed against a corrupt file.
123
+ const tmpPath = `${configPath}.migration-146.tmp`;
124
+ writeFileSync(tmpPath, JSON.stringify(config, null, 2) + "\n");
125
+ renameSync(tmpPath, configPath);
126
+ },
127
+ // The exact-match rewrite is idempotent, so a transient failure (full
128
+ // disk, I/O error) is safe to retry on later startups.
129
+ retryFailedCheckpoint: true,
130
+ down(_workspaceDir: string): void {
131
+ // Forward-only: reintroducing the retired model ID would break
132
+ // Fireworks calls.
133
+ },
134
+ };
135
+
136
+ // ---------------------------------------------------------------------------
137
+ // Helpers: self-contained per workspace migrations AGENTS.md
138
+ // ---------------------------------------------------------------------------
139
+
140
+ const STALE_MODEL_ID = "accounts/fireworks/models/deepseek-v4-flash";
141
+ const REPLACEMENT_MODEL_ID = "accounts/fireworks/models/deepseek-v4-flash-0731";
142
+ const REPAIRABLE_PROVIDERS = new Set(["fireworks", "vellum"]);
143
+
144
+ function repairFragment(
145
+ fragment: Record<string, unknown> | null,
146
+ isRepairableProvider: (provider: unknown) => boolean,
147
+ ): boolean {
148
+ if (fragment === null) {
149
+ return false;
150
+ }
151
+ if (fragment.model !== STALE_MODEL_ID) {
152
+ return false;
153
+ }
154
+ if (!isRepairableProvider(fragment.provider)) {
155
+ return false;
156
+ }
157
+ fragment.model = REPLACEMENT_MODEL_ID;
158
+ return true;
159
+ }
160
+
161
+ /**
162
+ * Connection name -> provider kind, or null when the DB or table is not
163
+ * readable. The caller fails the run on null: entry-name providers must be
164
+ * judged against real rows, never guessed. An absent DB file is a real
165
+ * state (no rows, so every entry name is dangling and stays untouched).
166
+ */
167
+ function readConnectionRows(workspaceDir: string): Map<string, string> | null {
168
+ const dbPath = join(workspaceDir, "data", "db", "assistant.db");
169
+ if (!existsSync(dbPath)) {
170
+ return new Map();
171
+ }
172
+ let db: Database;
173
+ try {
174
+ db = new Database(dbPath);
175
+ } catch {
176
+ return null;
177
+ }
178
+ try {
179
+ const rows = db
180
+ .query(`SELECT name, provider FROM provider_connections`)
181
+ .all() as Array<{ name: string; provider: string }>;
182
+ return new Map(rows.map((r) => [r.name, r.provider]));
183
+ } catch {
184
+ return null;
185
+ } finally {
186
+ db.close();
187
+ }
188
+ }
189
+
190
+ function readObject(value: unknown): Record<string, unknown> | null {
191
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
192
+ return null;
193
+ }
194
+ return value as Record<string, unknown>;
195
+ }
@@ -143,6 +143,7 @@ import { consolidateVoiceFrontDoorMigration } from "./142-consolidate-voice-fron
143
143
  import { repairDeprecatedCodexModelIdMigration } from "./143-repair-deprecated-codex-model-id.js";
144
144
  import { convertStrandedSubscriptionOpenaiProfilesMigration } from "./144-convert-stranded-subscription-openai-profiles.js";
145
145
  import { collapseProfileBindingsToEntriesMigration } from "./145-collapse-profile-bindings-to-entries.js";
146
+ import { repairRetiredFireworksDeepseekFlashModelIdMigration } from "./146-repair-retired-fireworks-deepseek-flash-model-id.js";
146
147
  import { migrateToWorkspaceVolumeMigration } from "./migrate-to-workspace-volume.js";
147
148
  import type { WorkspaceMigration } from "./types.js";
148
149
 
@@ -301,4 +302,5 @@ export const WORKSPACE_MIGRATIONS: WorkspaceMigration[] = [
301
302
  repairDeprecatedCodexModelIdMigration,
302
303
  convertStrandedSubscriptionOpenaiProfilesMigration,
303
304
  collapseProfileBindingsToEntriesMigration,
305
+ repairRetiredFireworksDeepseekFlashModelIdMigration,
304
306
  ];