@vellumai/assistant 0.10.3-dev.202606252237.df0fc92 → 0.10.3-dev.202606260109.8a3d17b

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.10.3-dev.202606252237.df0fc92",
3
+ "version": "0.10.3-dev.202606260109.8a3d17b",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1426,9 +1426,156 @@ describe("seedInferenceProfiles BYOK-mode managed profile labels", () => {
1426
1426
 
1427
1427
  expect(config.llm.profiles.balanced?.status).toBe("disabled");
1428
1428
  expect(config.llm.profiles["quality-optimized"]?.status).toBe("disabled");
1429
+ expect(config.llm.profiles.frontier?.status).toBe("disabled");
1429
1430
  expect(config.llm.profiles["cost-optimized"]?.status).toBe("disabled");
1430
1431
  });
1431
1432
 
1433
+ test("off-platform BYOK hatch defaults advisor to the personal quality profile", () => {
1434
+ const overlayPath = join(WORKSPACE_DIR, "hatch-overlay.json");
1435
+ writeFileSync(
1436
+ overlayPath,
1437
+ JSON.stringify({ llm: { default: { provider: "anthropic" } } }, null, 2) +
1438
+ "\n",
1439
+ );
1440
+ process.env.VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH = overlayPath;
1441
+
1442
+ mergeDefaultConfigAndSeedInferenceProfiles();
1443
+ const config = loadConfig();
1444
+
1445
+ expect(config.llm.activeProfile).toBe("custom-balanced");
1446
+ expect(config.llm.advisorProfile).toBe("custom-quality-optimized");
1447
+ expect(config.llm.profiles["custom-quality-optimized"]?.provider).toBe(
1448
+ "anthropic",
1449
+ );
1450
+ expect(
1451
+ config.llm.profiles["custom-quality-optimized"]?.provider_connection,
1452
+ ).toBe("anthropic-personal");
1453
+ expect(config.llm.profiles.frontier?.status).toBe("disabled");
1454
+ });
1455
+
1456
+ test("off-platform boot repairs a disabled managed advisor to a personal profile when no active managed replacement exists", () => {
1457
+ writeConfig({
1458
+ llm: {
1459
+ advisorProfile: "frontier",
1460
+ profiles: {
1461
+ frontier: {
1462
+ source: "managed",
1463
+ provider: "anthropic",
1464
+ provider_connection: "anthropic-managed",
1465
+ model: "claude-opus-4-8",
1466
+ status: "disabled",
1467
+ },
1468
+ balanced: {
1469
+ source: "managed",
1470
+ provider: "together",
1471
+ provider_connection: "together-managed",
1472
+ model: "open-model",
1473
+ status: "disabled",
1474
+ },
1475
+ "quality-optimized": {
1476
+ source: "managed",
1477
+ provider: "fireworks",
1478
+ provider_connection: "fireworks-managed",
1479
+ model: "accounts/fireworks/models/glm-5p2",
1480
+ status: "disabled",
1481
+ },
1482
+ "cost-optimized": {
1483
+ source: "managed",
1484
+ provider: "fireworks",
1485
+ provider_connection: "fireworks-managed",
1486
+ model: "accounts/fireworks/models/deepseek-v4-flash",
1487
+ status: "disabled",
1488
+ },
1489
+ "custom-quality-optimized": {
1490
+ source: "user",
1491
+ provider: "anthropic",
1492
+ provider_connection: "anthropic-personal",
1493
+ model: "claude-opus-4-8",
1494
+ label: "Quality",
1495
+ },
1496
+ },
1497
+ },
1498
+ });
1499
+
1500
+ mergeDefaultConfigAndSeedInferenceProfiles();
1501
+ const config = loadConfig();
1502
+
1503
+ expect(config.llm.advisorProfile).toBe("custom-quality-optimized");
1504
+ });
1505
+
1506
+ test("platform boot repairs a disabled managed advisor to an active managed profile", () => {
1507
+ process.env.IS_PLATFORM = "true";
1508
+ writeConfig({
1509
+ llm: {
1510
+ advisorProfile: "frontier",
1511
+ profiles: {
1512
+ frontier: {
1513
+ source: "managed",
1514
+ provider: "anthropic",
1515
+ provider_connection: "anthropic-managed",
1516
+ model: "claude-opus-4-8",
1517
+ status: "disabled",
1518
+ },
1519
+ "custom-quality-optimized": {
1520
+ source: "user",
1521
+ provider: "anthropic",
1522
+ provider_connection: "anthropic-personal",
1523
+ model: "claude-opus-4-8",
1524
+ label: "Quality",
1525
+ },
1526
+ },
1527
+ },
1528
+ });
1529
+
1530
+ mergeDefaultConfigAndSeedInferenceProfiles();
1531
+ const config = loadConfig();
1532
+
1533
+ expect(config.llm.advisorProfile).toBe("quality-optimized");
1534
+ });
1535
+
1536
+ test("off-platform boot clears a disabled managed advisor when no active replacement exists", () => {
1537
+ writeConfig({
1538
+ llm: {
1539
+ advisorProfile: "frontier",
1540
+ profiles: {
1541
+ frontier: {
1542
+ source: "managed",
1543
+ provider: "anthropic",
1544
+ provider_connection: "anthropic-managed",
1545
+ model: "claude-opus-4-8",
1546
+ status: "disabled",
1547
+ },
1548
+ balanced: {
1549
+ source: "managed",
1550
+ provider: "together",
1551
+ provider_connection: "together-managed",
1552
+ model: "open-model",
1553
+ status: "disabled",
1554
+ },
1555
+ "quality-optimized": {
1556
+ source: "managed",
1557
+ provider: "fireworks",
1558
+ provider_connection: "fireworks-managed",
1559
+ model: "accounts/fireworks/models/glm-5p2",
1560
+ status: "disabled",
1561
+ },
1562
+ "cost-optimized": {
1563
+ source: "managed",
1564
+ provider: "fireworks",
1565
+ provider_connection: "fireworks-managed",
1566
+ model: "accounts/fireworks/models/deepseek-v4-flash",
1567
+ status: "disabled",
1568
+ },
1569
+ },
1570
+ },
1571
+ });
1572
+
1573
+ mergeDefaultConfigAndSeedInferenceProfiles();
1574
+ const config = loadConfig();
1575
+
1576
+ expect(config.llm.advisorProfile).toBeUndefined();
1577
+ });
1578
+
1432
1579
  test("off-platform managed-inference hatch keeps selected managed connection active", () => {
1433
1580
  const overlayPath = join(WORKSPACE_DIR, "hatch-overlay.json");
1434
1581
  writeFileSync(
@@ -1451,6 +1598,7 @@ describe("seedInferenceProfiles BYOK-mode managed profile labels", () => {
1451
1598
  const raw = JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
1452
1599
 
1453
1600
  expect(raw.llm.activeProfile).toBe("balanced");
1601
+ expect(raw.llm.advisorProfile).toBe("balanced");
1454
1602
  expect(raw.llm.profiles.balanced.provider_connection).toBe(
1455
1603
  "together-managed",
1456
1604
  );
@@ -278,6 +278,7 @@ const deleteMessageByIdMock = mock(() => ({
278
278
  }));
279
279
  const reserveMessageMock = mock(async () => ({ id: "msg-reserve" }));
280
280
  const updateMessageContentMock = mock(() => {});
281
+ const addMessageMock = mock(() => ({ id: "mock-msg-id" }));
281
282
  mock.module("../memory/conversation-crud.js", () => ({
282
283
  setConversationProcessingStartedAt: () => {},
283
284
  isConversationProcessing: () => false,
@@ -292,7 +293,7 @@ mock.module("../memory/conversation-crud.js", () => ({
292
293
  trustContext: undefined,
293
294
  }),
294
295
  getConversationOriginInterface: () => null,
295
- addMessage: () => ({ id: "mock-msg-id" }),
296
+ addMessage: addMessageMock,
296
297
  deleteMessageById: deleteMessageByIdMock,
297
298
  updateConversationContextWindow: () => {},
298
299
  updateConversationSlackContextWatermark:
@@ -560,13 +561,16 @@ mock.module("../workspace/git-service.js", () => ({
560
561
  }),
561
562
  }));
562
563
 
564
+ let mockConversationErrorClassification = {
565
+ code: "CONVERSATION_PROCESSING_FAILED",
566
+ userMessage: "Something went wrong processing your message.",
567
+ retryable: false,
568
+ errorCategory: "processing_failed",
569
+ };
570
+
563
571
  mock.module("../daemon/conversation-error.js", () => ({
564
- classifyConversationError: (_err: unknown, _ctx: unknown) => ({
565
- code: "CONVERSATION_PROCESSING_FAILED",
566
- userMessage: "Something went wrong processing your message.",
567
- retryable: false,
568
- errorCategory: "processing_failed",
569
- }),
572
+ classifyConversationError: (_err: unknown, _ctx: unknown) =>
573
+ mockConversationErrorClassification,
570
574
  isUserCancellation: (err: unknown, ctx: { aborted?: boolean }) => {
571
575
  if (!ctx.aborted) return false;
572
576
  if (err instanceof DOMException && err.name === "AbortError") return true;
@@ -872,6 +876,13 @@ beforeEach(() => {
872
876
  deleteMessageByIdMock.mockClear();
873
877
  reserveMessageMock.mockClear();
874
878
  updateMessageContentMock.mockClear();
879
+ addMessageMock.mockClear();
880
+ mockConversationErrorClassification = {
881
+ code: "CONVERSATION_PROCESSING_FAILED",
882
+ userMessage: "Something went wrong processing your message.",
883
+ retryable: false,
884
+ errorCategory: "processing_failed",
885
+ };
875
886
  indexMessageNowMock.mockClear();
876
887
  projectAssistantMessageMock.mockClear();
877
888
  publishSyncInvalidationMock.mockClear();
@@ -2241,6 +2252,49 @@ describe("session-agent-loop", () => {
2241
2252
  expect(backfillCall[0]).toBe("test-conv");
2242
2253
  expect(backfillCall[1]).toBe("mock-msg-id");
2243
2254
  });
2255
+
2256
+ test("does not persist managed credential refresh failures as assistant text", async () => {
2257
+ mockConversationErrorClassification = {
2258
+ code: "MANAGED_KEY_INVALID",
2259
+ userMessage: "Couldn't refresh assistant credentials.",
2260
+ retryable: false,
2261
+ errorCategory: "managed_key_invalid",
2262
+ };
2263
+ const events: ServerMessage[] = [];
2264
+
2265
+ const ctx = makeCtx({
2266
+ loopProvider: {
2267
+ name: "mock-provider",
2268
+ async sendMessage() {
2269
+ throw new Error("API key has expired.");
2270
+ },
2271
+ } as unknown as Provider,
2272
+ });
2273
+ await runAgentLoopImpl(ctx, "hello", "msg-1", (msg) => events.push(msg));
2274
+
2275
+ expect(
2276
+ events.filter((event) => event.type === "assistant_text_delta"),
2277
+ ).toHaveLength(0);
2278
+
2279
+ const conversationError = events.find(
2280
+ (event) => event.type === "conversation_error",
2281
+ );
2282
+ expect(conversationError).toBeDefined();
2283
+ expect(conversationError).toMatchObject({
2284
+ code: "MANAGED_KEY_INVALID",
2285
+ userMessage: "Couldn't refresh assistant credentials.",
2286
+ errorCategory: "managed_key_invalid",
2287
+ });
2288
+
2289
+ expect(addMessageMock).not.toHaveBeenCalled();
2290
+ expect(recordRequestLogMock).not.toHaveBeenCalled();
2291
+ expect(backfillMessageIdOnLogsMock).not.toHaveBeenCalled();
2292
+ expect(deleteMessageByIdMock).toHaveBeenCalledTimes(1);
2293
+ const deleteCall = deleteMessageByIdMock.mock.calls[0] as unknown as [
2294
+ string,
2295
+ ];
2296
+ expect(deleteCall[0]).toBe("msg-reserve");
2297
+ });
2244
2298
  });
2245
2299
 
2246
2300
  describe("B3 pre-allocation: indexing + cleanup", () => {
@@ -2452,6 +2506,41 @@ describe("session-agent-loop", () => {
2452
2506
  expect(lastSync?.[1]).toBe("mock-msg-id");
2453
2507
  expect(lastSync?.[1]).not.toBe("msg-orphaned-reservation");
2454
2508
  });
2509
+
2510
+ test("managed-key provider-error cleanup publishes message invalidation after deleting the reservation", async () => {
2511
+ reserveMessageMock.mockImplementationOnce(async () => ({
2512
+ id: "msg-managed-key-reservation",
2513
+ }));
2514
+ mockConversationErrorClassification = {
2515
+ code: "MANAGED_KEY_INVALID",
2516
+ userMessage: "Couldn't refresh assistant credentials.",
2517
+ retryable: false,
2518
+ errorCategory: "managed_key_invalid",
2519
+ };
2520
+
2521
+ const ctx = makeCtx({
2522
+ loopProvider: {
2523
+ name: "mock-provider",
2524
+ async sendMessage() {
2525
+ throw new Error("API key has expired.");
2526
+ },
2527
+ } as unknown as Provider,
2528
+ });
2529
+ await runAgentLoopImpl(ctx, "hi", "msg-1", () => {});
2530
+
2531
+ expect(deleteMessageByIdMock).toHaveBeenCalledTimes(1);
2532
+ const deleteCall = deleteMessageByIdMock.mock.calls[0] as unknown as [
2533
+ string,
2534
+ ];
2535
+ expect(deleteCall[0]).toBe("msg-managed-key-reservation");
2536
+ expect(addMessageMock).not.toHaveBeenCalled();
2537
+ expect(syncMessageToDiskMock).not.toHaveBeenCalled();
2538
+
2539
+ const messagePublishes = (
2540
+ publishSyncInvalidationMock.mock.calls as unknown as Array<[string[]]>
2541
+ ).filter((args) => args[0]?.includes("conversation:test-conv:messages"));
2542
+ expect(messagePublishes).toHaveLength(1);
2543
+ });
2455
2544
  });
2456
2545
 
2457
2546
  describe("partial persistence", () => {
@@ -623,6 +623,24 @@ describe("classifyConversationError", () => {
623
623
  expect(result.errorCategory).toBe("provider_invalid_key");
624
624
  });
625
625
 
626
+ it("classifies managed-proxy auth failures as managed credential refresh failures", () => {
627
+ providerRoutingSources.anthropic = "managed-proxy";
628
+ const err = new ProviderError(
629
+ 'Anthropic API error (403): {"detail":"API key has expired."}',
630
+ "anthropic",
631
+ 403,
632
+ );
633
+
634
+ const result = classifyConversationError(err, baseCtx);
635
+
636
+ expect(result.code).toBe("MANAGED_KEY_INVALID");
637
+ expect(result.userMessage).toBe(
638
+ "Couldn't refresh assistant credentials.",
639
+ );
640
+ expect(result.retryable).toBe(false);
641
+ expect(result.errorCategory).toBe("managed_key_invalid");
642
+ });
643
+
626
644
  it("classifies ProviderError 401 with 'invalid x-api-key' message as PROVIDER_INVALID_KEY", () => {
627
645
  // Regex-match branch — Anthropic's standard 401 wording.
628
646
  const err = new ProviderError(
@@ -426,19 +426,32 @@ export function seedInferenceProfiles(
426
426
  }
427
427
  }
428
428
 
429
- // Advisor profile: default to the strongest managed profile when unset, so
430
- // the advisor consults `frontier` (Anthropic Opus) out of the box, falling
431
- // back to `quality-optimized` if `frontier` is unavailable. The `frontier`
432
- // arm requires managed ownership: the seed loop above leaves a user-owned
433
- // profile named `frontier` in place, and pointing the advisor at that would
434
- // consult an arbitrary user model. Guarded on existence so it never names a
435
- // missing profile (superRefine rejects that); off-platform/BYOK installs can
436
- // repoint it at one of their own profiles.
437
- if (readString(llm.advisorProfile) === undefined) {
438
- if (readObject(profiles["frontier"])?.source === "managed") {
439
- llm.advisorProfile = "frontier";
440
- } else if (readObject(profiles["quality-optimized"]) !== null) {
441
- llm.advisorProfile = "quality-optimized";
429
+ // Advisor profile: BYOK hatches default to the strongest personal profile
430
+ // backed by the entered provider key. Managed-profile hatches and registered
431
+ // platform installs default to the strongest active managed profile.
432
+ const requestedAdvisorProfile = readString(llm.advisorProfile);
433
+ const requestedAdvisorEntry =
434
+ requestedAdvisorProfile !== undefined
435
+ ? readObject(profiles[requestedAdvisorProfile])
436
+ : null;
437
+ const requestedAdvisorIsDisabledManaged =
438
+ requestedAdvisorEntry?.source === "managed" &&
439
+ requestedAdvisorEntry.status === "disabled";
440
+ const preferPersonalAdvisor =
441
+ userConnectionName !== undefined &&
442
+ hatchSelectedManagedConnection === undefined;
443
+ if (
444
+ requestedAdvisorProfile === undefined ||
445
+ requestedAdvisorIsDisabledManaged
446
+ ) {
447
+ const defaultAdvisorProfile = selectDefaultAdvisorProfile(
448
+ profiles,
449
+ preferPersonalAdvisor,
450
+ );
451
+ if (defaultAdvisorProfile) {
452
+ llm.advisorProfile = defaultAdvisorProfile;
453
+ } else if (requestedAdvisorIsDisabledManaged) {
454
+ delete llm.advisorProfile;
442
455
  }
443
456
  }
444
457
 
@@ -578,6 +591,48 @@ function readString(value: unknown): string | undefined {
578
591
  return typeof value === "string" && value.length > 0 ? value : undefined;
579
592
  }
580
593
 
594
+ function selectDefaultAdvisorProfile(
595
+ profiles: Record<string, Record<string, unknown>>,
596
+ preferPersonalProfile: boolean,
597
+ ): string | undefined {
598
+ const personal = firstActiveProfile(profiles, [
599
+ "custom-quality-optimized",
600
+ "custom-balanced",
601
+ "custom-cost-optimized",
602
+ ]);
603
+ const managed = firstActiveManagedProfile(profiles, [
604
+ "frontier",
605
+ "quality-optimized",
606
+ "balanced",
607
+ "cost-optimized",
608
+ ]);
609
+ return preferPersonalProfile ? (personal ?? managed) : (managed ?? personal);
610
+ }
611
+
612
+ function firstActiveProfile(
613
+ profiles: Record<string, Record<string, unknown>>,
614
+ names: string[],
615
+ ): string | undefined {
616
+ for (const name of names) {
617
+ const profile = readObject(profiles[name]);
618
+ if (profile && profile.status !== "disabled") return name;
619
+ }
620
+ return undefined;
621
+ }
622
+
623
+ function firstActiveManagedProfile(
624
+ profiles: Record<string, Record<string, unknown>>,
625
+ names: string[],
626
+ ): string | undefined {
627
+ for (const name of names) {
628
+ const profile = readObject(profiles[name]);
629
+ if (profile?.source === "managed" && profile.status !== "disabled") {
630
+ return name;
631
+ }
632
+ }
633
+ return undefined;
634
+ }
635
+
581
636
  function getHatchSelectedManagedConnection(
582
637
  llm: Record<string, unknown>,
583
638
  profiles: Record<string, Record<string, unknown>>,
@@ -102,6 +102,12 @@ import type {
102
102
 
103
103
  const log = getLogger("agent-loop-handlers");
104
104
 
105
+ function shouldPersistProviderErrorAsAssistantMessage(classified: {
106
+ code: string;
107
+ }): boolean {
108
+ return classified.code !== "MANAGED_KEY_INVALID";
109
+ }
110
+
105
111
  /**
106
112
  * Persist the history-stripped marker after the loop strips runtime injections
107
113
  * for compaction / overflow recovery. The marker is a durability hint, not
@@ -161,6 +167,7 @@ export interface EventHandlerState {
161
167
  readonly exchangeRawResponses: unknown[];
162
168
  model: string;
163
169
  providerErrorUserMessage: string | null;
170
+ persistProviderErrorAsAssistantMessage: boolean;
164
171
  lastAssistantMessageId: string | undefined;
165
172
  /**
166
173
  * True when `handleLlmCallStarted` has reserved an empty assistant row
@@ -339,6 +346,7 @@ export function createEventHandlerState(): EventHandlerState {
339
346
  exchangeRawResponses: [],
340
347
  model: "",
341
348
  providerErrorUserMessage: null,
349
+ persistProviderErrorAsAssistantMessage: false,
342
350
  lastAssistantMessageId: undefined,
343
351
  assistantRowAwaitingFinalization: false,
344
352
  pendingToolResults: new Map(),
@@ -1616,6 +1624,8 @@ function handleError(
1616
1624
  buildConversationErrorMessage(deps.ctx.conversationId, classified),
1617
1625
  );
1618
1626
  state.providerErrorUserMessage = classified.userMessage;
1627
+ state.persistProviderErrorAsAssistantMessage =
1628
+ shouldPersistProviderErrorAsAssistantMessage(classified);
1619
1629
  }
1620
1630
 
1621
1631
  export function handleMaxTokensReached(
@@ -2129,6 +2139,13 @@ function handleProviderError(
2129
2139
  deps: EventHandlerDeps,
2130
2140
  event: Extract<AgentEvent, { type: "provider_error" }>,
2131
2141
  ): void {
2142
+ const classified = classifyConversationError(event.error, {
2143
+ phase: "agent_loop",
2144
+ });
2145
+ if (!shouldPersistProviderErrorAsAssistantMessage(classified)) {
2146
+ return;
2147
+ }
2148
+
2132
2149
  try {
2133
2150
  recordRequestLog(
2134
2151
  deps.ctx.conversationId,
@@ -520,12 +520,14 @@ export async function runAgentLoopImpl(
520
520
  let turnStarted = false;
521
521
  const state = createEventHandlerState();
522
522
  let persistedErrorAssistantMessage = false;
523
+ let deletedReservedAssistantMessage = false;
523
524
 
524
525
  const publishLoopMessagesChanged = (): void => {
525
526
  if (
526
527
  state.lastAssistantMessageId ||
527
528
  state.persistedToolUseIds.size > 0 ||
528
- persistedErrorAssistantMessage
529
+ persistedErrorAssistantMessage ||
530
+ deletedReservedAssistantMessage
529
531
  ) {
530
532
  publishConversationMessagesChanged(ctx.conversationId);
531
533
  }
@@ -1151,23 +1153,22 @@ export async function runAgentLoopImpl(
1151
1153
  !abortController.signal.aborted &&
1152
1154
  !yieldedForHandoff
1153
1155
  ) {
1154
- // Drop any reservation stranded by the failed LLM call before
1155
- // inserting the synthetic error message. The B3 pre-allocation
1156
- // path reserves an empty assistant row at `llm_call_started`;
1157
- // when the call exits through the provider-error branch (no
1158
- // `message_complete`), `assistantRowAwaitingFinalization` stays
1159
- // true. Without this delete the transcript would carry both the
1160
- // empty reserved row AND the error message — and downstream sync
1161
- // (`syncLastAssistantMessageToDisk`) would mis-target the empty
1162
- // row. After delete we set `lastAssistantMessageId` to the new
1163
- // error row's id so the post-loop emission paths still point at
1164
- // a real message.
1156
+ // Drop any reservation stranded by the failed LLM call. The B3
1157
+ // pre-allocation path reserves an empty assistant row at
1158
+ // `llm_call_started`; when the call exits through the provider-error
1159
+ // branch (no `message_complete`), `assistantRowAwaitingFinalization`
1160
+ // stays true. Without this delete the transcript would carry an empty
1161
+ // reserved row, and downstream sync (`syncLastAssistantMessageToDisk`)
1162
+ // would target it.
1165
1163
  if (
1166
1164
  state.assistantRowAwaitingFinalization &&
1167
1165
  state.lastAssistantMessageId
1168
1166
  ) {
1169
1167
  try {
1170
1168
  deleteMessageById(state.lastAssistantMessageId);
1169
+ deletedReservedAssistantMessage = true;
1170
+ state.lastAssistantMessageId = undefined;
1171
+ state.assistantRowAwaitingFinalization = false;
1171
1172
  } catch (err) {
1172
1173
  rlog.warn(
1173
1174
  { err, messageId: state.lastAssistantMessageId },
@@ -1175,57 +1176,63 @@ export async function runAgentLoopImpl(
1175
1176
  );
1176
1177
  }
1177
1178
  }
1178
- const errChannelMeta = {
1179
- ...provenanceFromTrustContext(ctx.trustContext),
1180
- userMessageChannel: capturedTurnChannelContext.userMessageChannel,
1181
- assistantMessageChannel:
1182
- capturedTurnChannelContext.assistantMessageChannel,
1183
- userMessageInterface: capturedTurnInterfaceContext.userMessageInterface,
1184
- assistantMessageInterface:
1185
- capturedTurnInterfaceContext.assistantMessageInterface,
1186
- };
1187
- const errorAssistantMessage = createAssistantMessage(
1188
- state.providerErrorUserMessage,
1189
- );
1190
- const errorRow = await addMessage(
1191
- ctx.conversationId,
1192
- "assistant",
1193
- JSON.stringify(errorAssistantMessage.content),
1194
- { metadata: errChannelMeta },
1195
- );
1196
- persistedErrorAssistantMessage = true;
1197
- // Repoint `lastAssistantMessageId` at the synthetic error row so the
1198
- // post-loop sync, attachment resolution, and `message_complete`/
1199
- // `generation_handoff` emissions all reference a real, persisted
1200
- // message id. The previous reservation (if any) was already deleted
1201
- // above. Mark finalization complete so the next LLM call in this run
1202
- // (or a downstream handler) doesn't try to clean up an id that
1203
- // already corresponds to a finalized row.
1204
- state.lastAssistantMessageId = errorRow.id;
1205
- state.assistantRowAwaitingFinalization = false;
1206
- newMessages.push(errorAssistantMessage);
1207
- // Pipe the just-assigned message id into any orphaned LLM request log
1208
- // row(s) for this turn. The success path links rows via
1209
- // `handleMessageComplete` -> `backfillMessageIdOnLogs`, but provider-
1210
- // failure turns never fire `message_complete` (the synthetic assistant
1211
- // message is persisted directly above), so without this call the rows
1212
- // from `handleProviderError` stay with `message_id IS NULL` and a
1213
- // later turn's backfill sweep would wrong-attach them to that turn's
1214
- // assistant message. Scope is per-conversation, so concurrent runs on
1215
- // other conversations cannot collide. Non-fatal — a DB hiccup must
1216
- // not escalate a provider rejection into a turn-level throw.
1217
- try {
1218
- backfillMessageIdOnLogs(ctx.conversationId, errorRow.id);
1219
- } catch (err) {
1220
- rlog.warn(
1221
- { err },
1222
- "Failed to backfill message_id on provider-error LLM request logs (non-fatal)",
1179
+ if (!state.persistProviderErrorAsAssistantMessage) {
1180
+ state.assistantRowAwaitingFinalization = false;
1181
+ state.lastAssistantMessageId = undefined;
1182
+ } else {
1183
+ const errChannelMeta = {
1184
+ ...provenanceFromTrustContext(ctx.trustContext),
1185
+ userMessageChannel: capturedTurnChannelContext.userMessageChannel,
1186
+ assistantMessageChannel:
1187
+ capturedTurnChannelContext.assistantMessageChannel,
1188
+ userMessageInterface:
1189
+ capturedTurnInterfaceContext.userMessageInterface,
1190
+ assistantMessageInterface:
1191
+ capturedTurnInterfaceContext.assistantMessageInterface,
1192
+ };
1193
+ const errorAssistantMessage = createAssistantMessage(
1194
+ state.providerErrorUserMessage,
1195
+ );
1196
+ const errorRow = await addMessage(
1197
+ ctx.conversationId,
1198
+ "assistant",
1199
+ JSON.stringify(errorAssistantMessage.content),
1200
+ { metadata: errChannelMeta },
1223
1201
  );
1202
+ persistedErrorAssistantMessage = true;
1203
+ // Repoint `lastAssistantMessageId` at the synthetic error row so the
1204
+ // post-loop sync, attachment resolution, and `message_complete`/
1205
+ // `generation_handoff` emissions all reference a real, persisted
1206
+ // message id. The previous reservation (if any) was already deleted
1207
+ // above. Mark finalization complete so the next LLM call in this run
1208
+ // (or a downstream handler) doesn't try to clean up an id that
1209
+ // already corresponds to a finalized row.
1210
+ state.lastAssistantMessageId = errorRow.id;
1211
+ state.assistantRowAwaitingFinalization = false;
1212
+ newMessages.push(errorAssistantMessage);
1213
+ // Pipe the just-assigned message id into any orphaned LLM request log
1214
+ // row(s) for this turn. The success path links rows via
1215
+ // `handleMessageComplete` -> `backfillMessageIdOnLogs`, but provider-
1216
+ // failure turns never fire `message_complete` (the synthetic assistant
1217
+ // message is persisted directly above), so without this call the rows
1218
+ // from `handleProviderError` stay with `message_id IS NULL` and a
1219
+ // later turn's backfill sweep would wrong-attach them to that turn's
1220
+ // assistant message. Scope is per-conversation, so concurrent runs on
1221
+ // other conversations cannot collide. Non-fatal — a DB hiccup must
1222
+ // not escalate a provider rejection into a turn-level throw.
1223
+ try {
1224
+ backfillMessageIdOnLogs(ctx.conversationId, errorRow.id);
1225
+ } catch (err) {
1226
+ rlog.warn(
1227
+ { err },
1228
+ "Failed to backfill message_id on provider-error LLM request logs (non-fatal)",
1229
+ );
1230
+ }
1231
+ // Do NOT send assistant_text_delta here — handleProviderError already
1232
+ // emitted a conversation_error event for this same error text, and the
1233
+ // client renders it as an InlineChatErrorAlert. Sending a text delta
1234
+ // would create a duplicate plain-text bubble below the alert card.
1224
1235
  }
1225
- // Do NOT send assistant_text_delta here — handleProviderError already
1226
- // emitted a conversation_error event for this same error text, and the
1227
- // client renders it as an InlineChatErrorAlert. Sending a text delta
1228
- // would create a duplicate plain-text bubble below the alert card.
1229
1236
  }
1230
1237
 
1231
1238
  // Base persisted into `ctx.messages` is the loop's own returned history
@@ -315,20 +315,17 @@ function classifyCore(
315
315
  }
316
316
  if (error.statusCode === 401 || error.statusCode === 403) {
317
317
  // Both managed-proxy and user-key 401/403s reach this branch.
318
- // Managed-proxy routes through the assistant API key (stale → re-
319
- // provision) and emits `MANAGED_KEY_INVALID`; everything else is a
320
- // user-set credential that the upstream provider rejected → emit
321
- // `PROVIDER_INVALID_KEY` so the macOS chat banner renders an
322
- // "Invalid API key" surface (distinct from "API key required"
323
- // which only fires when the key is genuinely missing — see
324
- // `providerNotConfiguredClassification`).
318
+ // Managed-proxy routes through the assistant API key; if that
319
+ // credential is stale, the user cannot fix it from model settings.
320
+ // Everything else is a user-set credential that the upstream provider
321
+ // rejected, so emit `PROVIDER_INVALID_KEY` and let the chat banner point
322
+ // at Settings.
325
323
  const providerName = error.provider;
326
324
  if (getProviderRoutingSource(providerName) === "managed-proxy") {
327
325
  return {
328
326
  code: "MANAGED_KEY_INVALID",
329
- userMessage:
330
- "The assistant API key is invalid. Attempting to re-provision…",
331
- retryable: true,
327
+ userMessage: "Couldn't refresh assistant credentials.",
328
+ retryable: false,
332
329
  errorCategory: "managed_key_invalid",
333
330
  };
334
331
  }
@@ -116,6 +116,8 @@ export interface FormSurfaceData {
116
116
  submitLabel?: string;
117
117
  pages?: FormPage[];
118
118
  pageLabels?: { next?: string; back?: string; submit?: string };
119
+ /** Progress indicator style for multi-page forms: segment bar or labeled tabs. */
120
+ progressStyle?: "bar" | "tabs";
119
121
  }
120
122
 
121
123
  export interface ListItem {