@vellumai/assistant 0.11.1-dev.202608032128.6f59e1c → 0.11.1-dev.202608032226.351a4e2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.11.1-dev.202608032128.6f59e1c",
3
+ "version": "0.11.1-dev.202608032226.351a4e2",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -170,6 +170,112 @@ describe("classifyConversationError", () => {
170
170
  expect(result.userMessage).toContain("AI provider");
171
171
  expect(result.errorCategory).toBe("rate_limit");
172
172
  });
173
+
174
+ it("uses the ChatGPT OAuth route instead of the OpenAI registry default", () => {
175
+ providerRoutingSources.openai = "managed-proxy";
176
+ const err = new ProviderError(
177
+ "OpenAI API error (429): Too many requests",
178
+ "openai",
179
+ 429,
180
+ { reason: "rate_limited" },
181
+ );
182
+
183
+ const result = classifyConversationError(err, {
184
+ ...baseCtx,
185
+ connectionName: "chatgpt-subscription",
186
+ isManagedRoute: false,
187
+ });
188
+
189
+ expect(result.code).toBe("PROVIDER_RATE_LIMIT");
190
+ expect(result.userMessage).toContain("AI provider");
191
+ expect(result.errorCategory).toBe("rate_limit");
192
+ });
193
+
194
+ it("prefers the failed call's direct route over stale managed turn attribution", () => {
195
+ providerRoutingSources.openai = "managed-proxy";
196
+ const err = new ProviderError(
197
+ "OpenAI API error (429): Too many requests",
198
+ "openai",
199
+ 429,
200
+ { reason: "rate_limited" },
201
+ );
202
+ err.attachRouteAttribution({
203
+ connectionName: "chatgpt-subscription",
204
+ profileName: "chatgpt",
205
+ isManagedRoute: false,
206
+ });
207
+
208
+ const result = classifyConversationError(err, {
209
+ ...baseCtx,
210
+ connectionName: "vellum",
211
+ profileName: "managed",
212
+ isManagedRoute: true,
213
+ });
214
+
215
+ expect(result.code).toBe("PROVIDER_RATE_LIMIT");
216
+ expect(result.userMessage).toContain("AI provider");
217
+ expect(result.errorCategory).toBe("rate_limit");
218
+ });
219
+
220
+ it("prefers the failed call's managed fallback over stale direct turn attribution", () => {
221
+ const err = new ProviderError(
222
+ "Anthropic API error (429): Too many requests",
223
+ "anthropic",
224
+ 429,
225
+ { reason: "rate_limited" },
226
+ );
227
+ err.attachRouteAttribution({
228
+ profileName: "direct",
229
+ isManagedRoute: true,
230
+ });
231
+
232
+ const result = classifyConversationError(err, {
233
+ ...baseCtx,
234
+ connectionName: "anthropic-key",
235
+ profileName: "direct",
236
+ isManagedRoute: false,
237
+ });
238
+
239
+ expect(result.code).toBe("MANAGED_USAGE_LIMIT");
240
+ expect(result.userMessage).toContain("Vellum managed inference");
241
+ expect(result.errorCategory).toBe("managed_usage_limit");
242
+ });
243
+
244
+ it("does not apply a managed LLM route to a plain rate-limit error", () => {
245
+ const result = classifyConversationError(
246
+ new Error("429 Too Many Requests from a tool API"),
247
+ {
248
+ ...baseCtx,
249
+ isManagedRoute: true,
250
+ },
251
+ );
252
+
253
+ expect(result.code).toBe("PROVIDER_RATE_LIMIT");
254
+ expect(result.errorCategory).toBe("rate_limit");
255
+ });
256
+
257
+ it("falls back to context for fields the failed call's route omits", () => {
258
+ const err = new ProviderError("Unauthorized", "anthropic", 401);
259
+ err.attachRouteAttribution({ profileName: "direct" });
260
+
261
+ const result = classifyConversationError(err, {
262
+ ...baseCtx,
263
+ connectionName: "anthropic-key",
264
+ });
265
+
266
+ expect(result.connectionName).toBe("anthropic-key");
267
+ expect(result.profileName).toBe("direct");
268
+ });
269
+
270
+ it("still recognizes a rewrapped Vellum quota body", () => {
271
+ const result = classifyConversationError(
272
+ new Error('429 {"code":"daily_quota_exceeded"}'),
273
+ baseCtx,
274
+ );
275
+
276
+ expect(result.code).toBe("MANAGED_USAGE_LIMIT");
277
+ expect(result.errorCategory).toBe("managed_usage_limit");
278
+ });
173
279
  });
174
280
 
175
281
  describe("provider overloaded errors", () => {
@@ -6,6 +6,7 @@ import { CODE_DEFAULT_PROFILE_ENTRIES } from "../config/default-profile-catalog.
6
6
  import {
7
7
  type ResolutionFallbackReason,
8
8
  resolveCallSiteConfig,
9
+ resolveCallSiteConfigWithProfile,
9
10
  resolveDefaultProfileKey,
10
11
  resolveEffectiveProfileKey,
11
12
  } from "../config/llm-resolver.js";
@@ -859,6 +860,21 @@ describe("mix profiles", () => {
859
860
  }
860
861
  });
861
862
 
863
+ test("config and profile attribution share one mix selection", () => {
864
+ const selectedArms: string[] = [];
865
+ const resolved = resolveCallSiteConfigWithProfile("mainAgent", mixLlm, {
866
+ onMixSelected: ({ chosenProfile }) => {
867
+ selectedArms.push(chosenProfile);
868
+ },
869
+ });
870
+
871
+ expect(resolved.profileName).toBe("ab");
872
+ expect(selectedArms).toHaveLength(1);
873
+ expect(resolved.config.model).toBe(
874
+ selectedArms[0] === "a" ? "model-a" : "model-b",
875
+ );
876
+ });
877
+
862
878
  test("all dereference spots in a turn agree for the same seed", () => {
863
879
  // mainAgent (mix as activeProfile) and a non-main call site resolving the
864
880
  // same mix as its call-site profile must pick the same arm when given the
@@ -27,6 +27,7 @@ import type {
27
27
  ProviderResponse,
28
28
  SendMessageOptions,
29
29
  } from "../providers/types.js";
30
+ import { ProviderError } from "../util/errors.js";
30
31
  import { setConfig } from "./helpers/set-config.js";
31
32
 
32
33
  const DUMMY_MESSAGES: Message[] = [
@@ -239,6 +240,91 @@ describe("SendMessageOptions.config.overrideProfile", () => {
239
240
  expect(response.model).toBe("openai");
240
241
  });
241
242
 
243
+ test("CallSiteRoutingProvider attributes provider errors to the actual resolved connection", async () => {
244
+ setLlmConfig({
245
+ profiles: {
246
+ fast: {
247
+ provider: "openai",
248
+ provider_connection: "openai-conn",
249
+ model: "gpt-5.4",
250
+ },
251
+ },
252
+ });
253
+
254
+ const providerError = new ProviderError(
255
+ "OpenAI API error (429): Too many requests",
256
+ "openai",
257
+ 429,
258
+ { reason: "rate_limited" },
259
+ );
260
+ const defaultProvider = makeThrowingProvider(
261
+ "anthropic",
262
+ new Error("default provider should not be called"),
263
+ );
264
+ const altProvider = makeThrowingProvider("openai", providerError);
265
+ altProvider.routeAttribution = {
266
+ connectionName: "openai-recovered",
267
+ isManagedRoute: false,
268
+ };
269
+ const wrapped = new CallSiteRoutingProvider(
270
+ defaultProvider,
271
+ async (connectionName) =>
272
+ connectionName === "openai-conn" ? altProvider : null,
273
+ { connectionName: "vellum", isManagedRoute: true },
274
+ );
275
+
276
+ await expect(
277
+ wrapped.sendMessage(DUMMY_MESSAGES, {
278
+ config: { callSite: "mainAgent", overrideProfile: "fast" },
279
+ }),
280
+ ).rejects.toBe(providerError);
281
+ expect(providerError.routeAttribution).toEqual({
282
+ connectionName: "openai-recovered",
283
+ profileName: "fast",
284
+ isManagedRoute: false,
285
+ });
286
+ });
287
+
288
+ test("CallSiteRoutingProvider attributes soft fallback errors to the actual default connection", async () => {
289
+ setLlmConfig({
290
+ profiles: {
291
+ fast: {
292
+ provider: "openai",
293
+ provider_connection: "openai-conn",
294
+ model: "gpt-5.4",
295
+ },
296
+ },
297
+ });
298
+
299
+ const providerError = new ProviderError(
300
+ "Anthropic API error (429): Too many requests",
301
+ "anthropic",
302
+ 429,
303
+ { reason: "rate_limited" },
304
+ );
305
+ const defaultProvider = makeThrowingProvider("anthropic", providerError);
306
+ defaultProvider.routeAttribution = {
307
+ connectionName: "anthropic-default",
308
+ isManagedRoute: false,
309
+ };
310
+ const wrapped = new CallSiteRoutingProvider(
311
+ defaultProvider,
312
+ async () => null,
313
+ defaultProvider.routeAttribution,
314
+ );
315
+
316
+ await expect(
317
+ wrapped.sendMessage(DUMMY_MESSAGES, {
318
+ config: { callSite: "mainAgent", overrideProfile: "fast" },
319
+ }),
320
+ ).rejects.toBe(providerError);
321
+ expect(providerError.routeAttribution).toEqual({
322
+ connectionName: "anthropic-default",
323
+ profileName: "fast",
324
+ isManagedRoute: false,
325
+ });
326
+ });
327
+
242
328
  test("missing overrideProfile name silently falls through to base resolution", async () => {
243
329
  setLlmConfig({
244
330
  // The call-site tweak applies last in resolution, so it pins the model
@@ -328,6 +414,15 @@ describe("SendMessageOptions.config.overrideProfile", () => {
328
414
  });
329
415
  });
330
416
 
417
+ function makeThrowingProvider(name: string, error: Error): Provider {
418
+ return {
419
+ name,
420
+ async sendMessage(): Promise<ProviderResponse> {
421
+ throw error;
422
+ },
423
+ };
424
+ }
425
+
331
426
  describe("SendMessageOptions.config.forceOverrideProfile", () => {
332
427
  test("CallSiteConfiguredProvider forwards forceOverrideProfile into the send config", async () => {
333
428
  let captured: SendMessageOptions | undefined;
@@ -114,12 +114,29 @@ export interface ResolveCallSiteOpts {
114
114
 
115
115
  export type ResolutionFallbackReason = "missing" | "disabled" | "incomplete";
116
116
 
117
+ export interface ResolvedCallSiteConfig {
118
+ config: z.infer<typeof LLMConfigBase>;
119
+ profileName?: string;
120
+ }
121
+
122
+ export function resolveCallSiteConfigWithProfile(
123
+ callSite: LLMCallSite,
124
+ llm: z.infer<typeof LLMSchema>,
125
+ opts: ResolveCallSiteOpts = {},
126
+ ): ResolvedCallSiteConfig {
127
+ const selection = selectWinningProfile(callSite, llm, opts);
128
+ return {
129
+ config: resolveOverrideOrDefault(callSite, llm, selection),
130
+ ...(selection.profileName ? { profileName: selection.profileName } : {}),
131
+ };
132
+ }
133
+
117
134
  export function resolveCallSiteConfig(
118
135
  callSite: LLMCallSite,
119
136
  llm: z.infer<typeof LLMSchema>,
120
137
  opts: ResolveCallSiteOpts = {},
121
138
  ): z.infer<typeof LLMConfigBase> {
122
- return resolveOverrideOrDefault(callSite, llm, opts);
139
+ return resolveCallSiteConfigWithProfile(callSite, llm, opts).config;
123
140
  }
124
141
 
125
142
  // ─── Single-winner profile resolution ───────────────────────────────────────
@@ -358,9 +375,8 @@ const CODE_DEFAULT_BASE: z.infer<typeof LLMConfigBase> = LLMConfigBase.parse(
358
375
  function resolveOverrideOrDefault(
359
376
  callSite: LLMCallSite,
360
377
  llm: z.infer<typeof LLMSchema>,
361
- opts: ResolveCallSiteOpts,
378
+ selection: ProfileWinnerSelection,
362
379
  ): z.infer<typeof LLMConfigBase> {
363
- const selection = selectWinningProfile(callSite, llm, opts);
364
380
  const winnerFragment: Mergeable =
365
381
  selection.entry == null ? {} : winnerConfigFragment(selection.entry);
366
382
 
@@ -120,6 +120,7 @@ import type { AssistantSurface } from "./conversation-agent-loop.js";
120
120
  import {
121
121
  buildConversationErrorMessage,
122
122
  classifyConversationError,
123
+ type ConversationErrorAttribution,
123
124
  maxTokensReachedClassification,
124
125
  } from "./conversation-error.js";
125
126
  import { buildDeferredFinalizeEffect } from "./conversation-turn-finalize.js";
@@ -555,6 +556,8 @@ export interface EventHandlerDeps {
555
556
  * degrades gracefully when it's absent.
556
557
  */
557
558
  readonly latencyTracker?: TurnLatencyTracker;
559
+ /** Best-effort resolved route metadata for provider error classification. */
560
+ readonly errorAttribution?: () => ConversationErrorAttribution;
558
561
  }
559
562
 
560
563
  // ── Factory ──────────────────────────────────────────────────────────
@@ -2628,9 +2631,7 @@ function handleError(
2628
2631
  deps: EventHandlerDeps,
2629
2632
  event: Extract<AgentEvent, { type: "error" }>,
2630
2633
  ): void {
2631
- const classified = classifyConversationError(event.error, {
2632
- phase: "agent_loop",
2633
- });
2634
+ const classified = classifyAgentLoopError(event.error, deps);
2634
2635
  if (classified.errorCategory === "provider_api_error") {
2635
2636
  log.error(
2636
2637
  {
@@ -3140,9 +3141,7 @@ function handleProviderError(
3140
3141
  deps: EventHandlerDeps,
3141
3142
  event: Extract<AgentEvent, { type: "provider_error" }>,
3142
3143
  ): void {
3143
- const classified = classifyConversationError(event.error, {
3144
- phase: "agent_loop",
3145
- });
3144
+ const classified = classifyAgentLoopError(event.error, deps);
3146
3145
  if (!shouldPersistProviderErrorAsAssistantMessage(classified)) {
3147
3146
  return;
3148
3147
  }
@@ -3164,6 +3163,16 @@ function handleProviderError(
3164
3163
  }
3165
3164
  }
3166
3165
 
3166
+ function classifyAgentLoopError(
3167
+ error: Error,
3168
+ deps: EventHandlerDeps,
3169
+ ): ReturnType<typeof classifyConversationError> {
3170
+ return classifyConversationError(error, {
3171
+ phase: "agent_loop",
3172
+ ...deps.errorAttribution?.(),
3173
+ });
3174
+ }
3175
+
3167
3176
  // ── Dispatcher ───────────────────────────────────────────────────────
3168
3177
 
3169
3178
  /** Routes an AgentEvent to the appropriate handler. */
@@ -30,7 +30,7 @@ import {
30
30
  } from "../config/llm-context-resolution.js";
31
31
  import {
32
32
  resolveCallSiteConfig,
33
- resolveEffectiveProfileKey,
33
+ resolveCallSiteConfigWithProfile,
34
34
  resolveProfilelessModelKey,
35
35
  selectWinningProfile,
36
36
  } from "../config/llm-resolver.js";
@@ -61,6 +61,11 @@ import { HOOKS } from "../plugin-api/constants.js";
61
61
  import type { ConversationGraphMemory } from "../plugins/defaults/memory/graph/conversation-graph-memory.js";
62
62
  import { enqueueMemoryRetrospectiveOnCompaction } from "../plugins/defaults/memory/memory-retrospective-enqueue.js";
63
63
  import { runHook } from "../plugins/pipeline.js";
64
+ import { isManagedConnectionRoute } from "../providers/connection-resolution.js";
65
+ import {
66
+ ConnectionResolutionError,
67
+ resolveRoutingIdentity,
68
+ } from "../providers/routing-identity.js";
64
69
  import type { ContentBlock, Message } from "../providers/types.js";
65
70
  import type { Provider } from "../providers/types.js";
66
71
  import { resolveCapabilities } from "../runtime/capabilities.js";
@@ -95,6 +100,7 @@ import {
95
100
  budgetYieldUnrecoveredClassification,
96
101
  buildConversationErrorMessage,
97
102
  classifyConversationError,
103
+ type ConversationErrorAttribution,
98
104
  isUserCancellation,
99
105
  } from "./conversation-error.js";
100
106
  import { raceWithTimeout } from "./conversation-media-retry.js";
@@ -422,10 +428,7 @@ export async function runAgentLoopImpl(
422
428
  // connection and profile so credential/connection errors point at the
423
429
  // exact slot to fix instead of a generic banner. Resolution can itself
424
430
  // throw on a broken config — attribution must never mask the real error.
425
- const turnErrorAttribution = (): {
426
- connectionName?: string;
427
- profileName?: string;
428
- } => {
431
+ const turnErrorAttribution = (): ConversationErrorAttribution => {
429
432
  try {
430
433
  const overrideProfile = readCurrentOverrideProfile();
431
434
  const resolveOpts = {
@@ -433,21 +436,30 @@ export async function runAgentLoopImpl(
433
436
  forceOverrideProfile,
434
437
  selectionSeed: ctx.conversationId,
435
438
  };
436
- const resolved = resolveCallSiteConfig(
437
- turnCallSite,
438
- config.llm,
439
- resolveOpts,
440
- );
441
- const profileName = resolveEffectiveProfileKey(
442
- turnCallSite,
443
- config.llm,
444
- resolveOpts,
445
- );
439
+ const { config: resolved, profileName } =
440
+ resolveCallSiteConfigWithProfile(turnCallSite, config.llm, resolveOpts);
441
+ let connectionName = resolved.provider_connection;
442
+ try {
443
+ connectionName =
444
+ resolveRoutingIdentity(resolved.provider, resolved.model)
445
+ ?.connectionName ?? connectionName;
446
+ } catch (error) {
447
+ if (!(error instanceof ConnectionResolutionError)) {
448
+ throw error;
449
+ }
450
+ connectionName = error.connectionName;
451
+ }
452
+ // Managed-ness comes from the connection row, matching what dispatch
453
+ // decides. The profile's own provider can't stand in: a concrete
454
+ // provider tweak over a managed winner keeps the managed connection
455
+ // while replacing the provider (`llm-resolver.ts`).
456
+ const isManagedRoute = connectionName
457
+ ? isManagedConnectionRoute(connectionName)
458
+ : undefined;
446
459
  return {
447
- ...(resolved.provider_connection
448
- ? { connectionName: resolved.provider_connection }
449
- : {}),
460
+ ...(connectionName ? { connectionName } : {}),
450
461
  ...(profileName ? { profileName } : {}),
462
+ ...(isManagedRoute !== undefined ? { isManagedRoute } : {}),
451
463
  };
452
464
  } catch {
453
465
  return {};
@@ -1049,6 +1061,7 @@ export async function runAgentLoopImpl(
1049
1061
  turnInterfaceContext: capturedTurnInterfaceContext,
1050
1062
  applyCompaction: applySuccessfulCompaction,
1051
1063
  latencyTracker,
1064
+ errorAttribution: turnErrorAttribution,
1052
1065
  };
1053
1066
  const eventHandler = (event: AgentEvent): Promise<void> => {
1054
1067
  if (
@@ -63,6 +63,8 @@ export interface ClassifiedConversationError {
63
63
  export interface ConversationErrorAttribution {
64
64
  connectionName?: string;
65
65
  profileName?: string;
66
+ /** Whether the resolved turn route uses Vellum-managed inference. */
67
+ isManagedRoute?: boolean;
66
68
  }
67
69
 
68
70
  // Network-level error patterns (connection refused, timeout, DNS, reset)
@@ -181,6 +183,11 @@ export interface ErrorContext {
181
183
  * underlying connection name is generic.
182
184
  */
183
185
  profileName?: string;
186
+ /**
187
+ * Whether the resolved turn route uses Vellum-managed inference. Turn routing
188
+ * takes precedence over the provider registry's boot-time default.
189
+ */
190
+ isManagedRoute?: boolean;
184
191
  }
185
192
 
186
193
  /**
@@ -253,9 +260,19 @@ export function classifyConversationError(
253
260
  // credential-related classifications can name the exact slot to fix.
254
261
  // A `ProviderNotConfiguredError` instance carries its own attribution
255
262
  // (from the throw site) which takes priority over context when present.
263
+ // The failed call's own route wins field by field — it is resolved at
264
+ // dispatch, while context describes the turn and can be stale for this
265
+ // call. Per-field, so a route carrying only some fields doesn't blank the
266
+ // rest.
267
+ const providerRoute =
268
+ error instanceof ProviderError ? error.routeAttribution : undefined;
269
+ const connectionName = providerRoute?.connectionName ?? ctx.connectionName;
270
+ const profileName = providerRoute?.profileName ?? ctx.profileName;
271
+ const isManagedRoute = providerRoute?.isManagedRoute ?? ctx.isManagedRoute;
256
272
  const attribution: ConversationErrorAttribution = {
257
- ...(ctx.connectionName ? { connectionName: ctx.connectionName } : {}),
258
- ...(ctx.profileName ? { profileName: ctx.profileName } : {}),
273
+ ...(connectionName ? { connectionName } : {}),
274
+ ...(profileName ? { profileName } : {}),
275
+ ...(isManagedRoute !== undefined ? { isManagedRoute } : {}),
259
276
  };
260
277
 
261
278
  // Dedicated classification for missing provider API key
@@ -354,13 +371,18 @@ function classifyCore(
354
371
  message: string,
355
372
  attribution: ConversationErrorAttribution = {},
356
373
  ): Omit<ClassifiedConversationError, "debugDetails"> {
374
+ const isManagedRoute =
375
+ error instanceof ProviderError &&
376
+ (attribution.isManagedRoute ??
377
+ getProviderRoutingSource(error.provider) === "managed-proxy");
378
+
357
379
  // Prefer the semantic reason stamped by the provider layer, regardless of
358
380
  // HTTP status — statusless errors (e.g. SDK streaming failures) still carry a
359
381
  // reason. Reasons that map cleanly win here; `bad_request`/`unknown` (and a
360
382
  // reason-less error) fall through to the status switch + regex battery below.
361
383
  if (error instanceof ProviderError && error.reason) {
362
384
  const c = reasonToClassification(error.reason, {
363
- routingSource: getProviderRoutingSource(error.provider),
385
+ isManagedRoute,
364
386
  attribution,
365
387
  message,
366
388
  });
@@ -382,20 +404,19 @@ function classifyCore(
382
404
  // Everything else is a user-set credential that the upstream provider
383
405
  // rejected, so emit `PROVIDER_INVALID_KEY` and let the chat banner point
384
406
  // at Settings.
385
- const providerName = error.provider;
386
- if (getProviderRoutingSource(providerName) === "managed-proxy") {
407
+ if (isManagedRoute) {
387
408
  return managedKeyInvalidClassification();
388
409
  }
389
410
  return invalidApiKeyClassification(attribution);
390
411
  }
391
412
  if (error.statusCode === 402) {
392
- if (isManagedBalanceError(error)) {
413
+ if (isManagedRoute) {
393
414
  return managedBalanceClassification();
394
415
  }
395
416
  return providerBillingClassification();
396
417
  }
397
418
  if (error.statusCode === 429) {
398
- if (isManagedUsageLimitError(error, message)) {
419
+ if (isManagedUsageLimitError(message, isManagedRoute)) {
399
420
  return managedUsageLimitClassification();
400
421
  }
401
422
  return rateLimitClassification();
@@ -508,7 +529,7 @@ function classifyCore(
508
529
  }
509
530
 
510
531
  // Regex fallback for non-ProviderError or ProviderError without statusCode
511
- return classifyByMessage(error, message);
532
+ return classifyByMessage(message, isManagedRoute);
512
533
  }
513
534
 
514
535
  /**
@@ -540,12 +561,12 @@ function extractProviderDetail(message: string): string | undefined {
540
561
  function reasonToClassification(
541
562
  reason: ProviderErrorReason,
542
563
  args: {
543
- routingSource?: string;
564
+ isManagedRoute: boolean;
544
565
  attribution?: ConversationErrorAttribution;
545
566
  message: string;
546
567
  },
547
568
  ): Omit<ClassifiedConversationError, "debugDetails"> | null {
548
- const managed = args.routingSource === "managed-proxy";
569
+ const managed = args.isManagedRoute;
549
570
  switch (reason) {
550
571
  case "invalid_credentials":
551
572
  return managed
@@ -670,18 +691,13 @@ function isStreamingError(message: string): boolean {
670
691
  return STREAMING_ERROR_PATTERNS.some((p) => p.test(message));
671
692
  }
672
693
 
673
- function isManagedUsageLimitError(error: unknown, message: string): boolean {
674
- if (
675
- error instanceof ProviderError &&
676
- getProviderRoutingSource(error.provider) === "managed-proxy"
677
- ) {
678
- return true;
679
- }
680
- return MANAGED_USAGE_LIMIT_PATTERNS.some((p) => p.test(message));
681
- }
682
-
683
- function isManagedBalanceError(error: ProviderError): boolean {
684
- return getProviderRoutingSource(error.provider) === "managed-proxy";
694
+ function isManagedUsageLimitError(
695
+ message: string,
696
+ isManagedRoute: boolean,
697
+ ): boolean {
698
+ return (
699
+ isManagedRoute || MANAGED_USAGE_LIMIT_PATTERNS.some((p) => p.test(message))
700
+ );
685
701
  }
686
702
 
687
703
  function isProviderBillingError(message: string): boolean {
@@ -912,8 +928,8 @@ function providerNotConfiguredClassification(
912
928
  * short-circuit and the status switch both decline to classify.
913
929
  */
914
930
  function classifyByMessage(
915
- error: unknown,
916
931
  message: string,
932
+ isManagedRoute: boolean,
917
933
  ): Omit<ClassifiedConversationError, "debugDetails"> {
918
934
  // Empty-request-messages is always an internal construction failure — check
919
935
  // it before the provider/network patterns so a ProviderError that carries the
@@ -930,7 +946,7 @@ function classifyByMessage(
930
946
  // Check rate limit first (before network, since 429 could match both)
931
947
  for (const pattern of RATE_LIMIT_PATTERNS) {
932
948
  if (pattern.test(message)) {
933
- if (isManagedUsageLimitError(error, message)) {
949
+ if (isManagedUsageLimitError(message, isManagedRoute)) {
934
950
  return managedUsageLimitClassification();
935
951
  }
936
952
  return rateLimitClassification();
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Tests for the voice activity line.
3
+ *
4
+ * The invariant worth guarding is totality: the tool vocabulary is open
5
+ * (plugins, MCP servers, and skills all contribute names this module has never
6
+ * seen), and the surface this feeds is a Lock Screen the user cannot correct.
7
+ * A label that came back empty would blank the island exactly when the
8
+ * assistant is busiest.
9
+ */
10
+
11
+ import { describe, expect, test } from "bun:test";
12
+
13
+ import {
14
+ activityLabelForTool,
15
+ GENERIC_ACTIVITY_LABEL,
16
+ } from "../activity-label.js";
17
+
18
+ describe("activityLabelForTool", () => {
19
+ test("names what the user would say is happening, not the tool", () => {
20
+ expect(activityLabelForTool("bash")).toBe("Running a command");
21
+ expect(activityLabelForTool("web_search")).toBe("Searching the web");
22
+ expect(activityLabelForTool("file_read")).toBe("Reading a file");
23
+ });
24
+
25
+ // Where a tool runs is not a distinction anyone glancing at a Lock Screen is
26
+ // making.
27
+ test("host and sandbox variants read the same", () => {
28
+ expect(activityLabelForTool("host_bash")).toBe(
29
+ activityLabelForTool("bash"),
30
+ );
31
+ expect(activityLabelForTool("host_file_read")).toBe(
32
+ activityLabelForTool("file_read"),
33
+ );
34
+ });
35
+
36
+ test("falls back by prefix for structured tool families", () => {
37
+ expect(activityLabelForTool("browser_click")).toBe("Using the browser");
38
+ expect(activityLabelForTool("mcp_notion_search")).toBe(
39
+ "Using a connected app",
40
+ );
41
+ });
42
+
43
+ test("every tool gets a label, including ones this module has never seen", () => {
44
+ for (const name of [
45
+ "slack_conversations_history",
46
+ "",
47
+ "a",
48
+ "some_vendor::weird.tool/name",
49
+ ]) {
50
+ expect(activityLabelForTool(name)).toBe(GENERIC_ACTIVITY_LABEL);
51
+ }
52
+ });
53
+
54
+ test("labels stay short enough for the slot they land in", () => {
55
+ for (const name of [
56
+ "bash",
57
+ "web_search",
58
+ "host_file_transfer",
59
+ "computer_use",
60
+ "ui_show",
61
+ "unknown_tool",
62
+ ]) {
63
+ const label = activityLabelForTool(name);
64
+ expect(label.length).toBeLessThanOrEqual(32);
65
+ expect(label).not.toMatch(/[.!?]$/);
66
+ }
67
+ });
68
+ });