@vellumai/assistant 0.11.1-dev.202608041245.b3413fb → 0.11.1-dev.202608041551.40f0fbf

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/openapi.yaml CHANGED
@@ -14637,7 +14637,9 @@ paths:
14637
14637
  "404":
14638
14638
  description: Profile not found
14639
14639
  "409":
14640
- description: Profile is still referenced by activeProfile, advisorProfile, a call site, or a mix arm
14640
+ description:
14641
+ Profile is still referenced by activeProfile, advisorProfile, a call site, a default-tier override, or a
14642
+ mix arm
14641
14643
  get:
14642
14644
  operationId: inference_profiles_by_name_get
14643
14645
  summary: Get an effective inference profile
@@ -27286,10 +27288,13 @@ paths:
27286
27288
  type: string
27287
27289
  name:
27288
27290
  type: string
27291
+ error:
27292
+ description:
27293
+ Why the secret was not stored (e.g. provider-side API key validation failed). Present only when success is
27294
+ false.
27295
+ type: string
27289
27296
  required:
27290
27297
  - success
27291
- - type
27292
- - name
27293
27298
  additionalProperties: false
27294
27299
  /v1/secrets/read:
27295
27300
  post:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.11.1-dev.202608041245.b3413fb",
3
+ "version": "0.11.1-dev.202608041551.40f0fbf",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -12,6 +12,7 @@ import type {
12
12
  import type { AssistantEvent } from "../api/index.js";
13
13
  import type { Message, ProviderResponse } from "../providers/types.js";
14
14
  import { stampAndBuffer } from "../runtime/assistant-stream-state.js";
15
+ import { createAbortReason } from "../util/abort-reasons.js";
15
16
  import { setConfig } from "./helpers/set-config.js";
16
17
 
17
18
  // ---------------------------------------------------------------------------
@@ -826,7 +827,7 @@ describe("Conversation message queue", () => {
826
827
  await new Promise((r) => setTimeout(r, 10));
827
828
  });
828
829
 
829
- test("abort() clears the queue and sends generation_cancelled for each queued message", async () => {
830
+ test("abort() clears the queue and closes out each queued message", async () => {
830
831
  const conversation = makeConversation();
831
832
  await conversation.loadFromDb();
832
833
 
@@ -873,6 +874,17 @@ describe("Conversation message queue", () => {
873
874
  conversationId: "conv-1",
874
875
  });
875
876
 
877
+ // Each discarded row also gets its own terminal event. `generation_cancelled`
878
+ // closes out the turn they were waiting on; only `message_queued_deleted`
879
+ // closes out the queued rows themselves, and without it clients keep the
880
+ // pending indicator up forever, since no `message_dequeued` is coming.
881
+ expect(
882
+ events2.find((e) => e.type === "message_queued_deleted"),
883
+ ).toMatchObject({ conversationId: "conv-1", requestId: "req-2" });
884
+ expect(
885
+ events3.find((e) => e.type === "message_queued_deleted"),
886
+ ).toMatchObject({ conversationId: "conv-1", requestId: "req-3" });
887
+
876
888
  // abort() must NOT emit conversation_error or generic error for queued discards.
877
889
  const err2 = events2.find((e) => e.type === "error");
878
890
  expect(err2).toBeUndefined();
@@ -897,6 +909,122 @@ describe("Conversation message queue", () => {
897
909
  await new Promise((r) => setTimeout(r, 10));
898
910
  });
899
911
 
912
+ test("a user interrupt keeps the queue and the stopped turn's drain sends it", async () => {
913
+ // GIVEN a turn in flight with a message queued behind it
914
+ const conversation = makeConversation();
915
+ await conversation.loadFromDb();
916
+
917
+ const events2: AssistantEvent[] = [];
918
+ conversation.processMessage({
919
+ content: "msg-1",
920
+ attachments: [],
921
+ requestId: "req-1",
922
+ });
923
+ await waitForPendingRun(1);
924
+
925
+ conversation.enqueueMessage({
926
+ content: "msg-2",
927
+ onEvent: (e) => events2.push(e),
928
+ requestId: "req-2",
929
+ });
930
+ expect(conversation.getQueueDepth()).toBe(1);
931
+
932
+ // WHEN the user hits Stop
933
+ conversation.abort(
934
+ createAbortReason("user_cancel", "conversation-queue.test", "conv-1"),
935
+ );
936
+
937
+ // THEN the queued message survives the abort. Stop ends the turn the user
938
+ // is watching, not the message they queued behind it, and a discard here
939
+ // reaches no client: it emits no per-row terminal event.
940
+ expect(conversation.getQueueDepth()).toBe(1);
941
+ expect(
942
+ events2.find((e) => e.type === "generation_cancelled"),
943
+ ).toBeUndefined();
944
+ expect(
945
+ events2.find((e) => e.type === "message_queued_deleted"),
946
+ ).toBeUndefined();
947
+
948
+ // AND once the stopped turn unwinds, its `finally` drains the queue, so the
949
+ // message is sent instead of needing to be retyped.
950
+ await resolveRun(0);
951
+ await waitForPendingRun(2);
952
+ expect(events2.find((e) => e.type === "message_dequeued")).toMatchObject({
953
+ conversationId: "conv-1",
954
+ requestId: "req-2",
955
+ });
956
+ expect(conversation.getQueueDepth()).toBe(0);
957
+ expect(
958
+ JSON.stringify(
959
+ pendingRuns[1].messages[pendingRuns[1].messages.length - 1],
960
+ ),
961
+ ).toContain("msg-2");
962
+
963
+ await resolveRun(1);
964
+ await new Promise((r) => setTimeout(r, 10));
965
+ });
966
+
967
+ test("a user interrupt mid-tool repairs the abandoned tool_use before the queued message runs", async () => {
968
+ // GIVEN a turn whose history ends with an unanswered tool_use, the shape a
969
+ // Stop mid-tool-call leaves behind and which providers reject outright
970
+ const conversation = makeConversation();
971
+ await conversation.loadFromDb();
972
+
973
+ const events2: AssistantEvent[] = [];
974
+ conversation.processMessage({
975
+ content: "msg-1",
976
+ attachments: [],
977
+ requestId: "req-1",
978
+ });
979
+ await waitForPendingRun(1);
980
+
981
+ conversation.enqueueMessage({
982
+ content: "msg-2",
983
+ onEvent: (e) => events2.push(e),
984
+ requestId: "req-2",
985
+ });
986
+
987
+ // WHEN the user interrupts and the stopped turn unwinds with the tool call
988
+ // still unanswered, then drains the queue
989
+ conversation.abort(
990
+ createAbortReason("user_cancel", "conversation-queue.test", "conv-1"),
991
+ );
992
+ const interrupted = pendingRuns[0];
993
+ await interrupted.onEvent({ type: "llm_call_started" });
994
+ interrupted.resolve([
995
+ ...interrupted.messages,
996
+ {
997
+ role: "assistant",
998
+ content: [
999
+ {
1000
+ type: "tool_use",
1001
+ id: "toolu-interrupted",
1002
+ name: "bash",
1003
+ input: {},
1004
+ },
1005
+ ],
1006
+ },
1007
+ ]);
1008
+ await waitForPendingRun(2);
1009
+
1010
+ // THEN the drained turn opens with a synthetic result for the abandoned
1011
+ // call rather than a dangling tool_use.
1012
+ const repaired = pendingRuns[1].messages.find(
1013
+ (m) =>
1014
+ m.role === "user" &&
1015
+ m.content.some(
1016
+ (block) =>
1017
+ block.type === "tool_result" &&
1018
+ block.tool_use_id === "toolu-interrupted",
1019
+ ),
1020
+ );
1021
+ expect(repaired).toBeDefined();
1022
+ expect(conversation.pendingInterruptRepair).toBe(false);
1023
+
1024
+ await resolveRun(1);
1025
+ await new Promise((r) => setTimeout(r, 10));
1026
+ });
1027
+
900
1028
  test("conversation-scoped errors emit both conversation_error and generic error", async () => {
901
1029
  const conversation = makeConversation();
902
1030
  await conversation.loadFromDb();
@@ -530,3 +530,82 @@ describe("user shadows of default keys keep their intent", () => {
530
530
  expect(pinned.provider).toBe("openai");
531
531
  });
532
532
  });
533
+
534
+ describe("defaultProfileOverrides tier remap", () => {
535
+ test("a tier remap redirects every call site on that tier, and only that tier", () => {
536
+ const llm = LLMSchema.parse({
537
+ profiles: { mine: completeCustom },
538
+ defaultProfileOverrides: { "cost-optimized": "mine" },
539
+ ...anthropicDp,
540
+ });
541
+ // conversationSummarization ships on the cost-optimized tier.
542
+ const resolved = resolveCallSiteConfig("conversationSummarization", llm);
543
+ expect(resolved.model).toBe("gpt-5.5");
544
+ expect(resolved.provider).toBe("openai");
545
+ const selection = selectWinningProfile("conversationSummarization", llm);
546
+ expect(selection.profileName).toBe("mine");
547
+ expect(selection.source).toBe("default");
548
+ // A balanced-tier site is untouched by a cost-optimized remap.
549
+ expect(resolveCallSiteConfig("subagentSpawn", llm).provider).toBe(
550
+ "anthropic",
551
+ );
552
+ });
553
+
554
+ test("a per-call-site pin still beats the tier remap", () => {
555
+ const llm = LLMSchema.parse({
556
+ profiles: {
557
+ mine: completeCustom,
558
+ pinned: { ...completeCustom, model: "gpt-5.4" },
559
+ },
560
+ defaultProfileOverrides: { "cost-optimized": "mine" },
561
+ callSites: { conversationSummarization: { profile: "pinned" } },
562
+ ...anthropicDp,
563
+ });
564
+ expect(resolveCallSiteConfig("conversationSummarization", llm).model).toBe(
565
+ "gpt-5.4",
566
+ );
567
+ });
568
+
569
+ test("an unusable remap target reports and falls through to the stock tier", () => {
570
+ const llm = LLMSchema.parse({
571
+ profiles: { partial: { source: "user", model: "gpt-5.5" } },
572
+ defaultProfileOverrides: { "cost-optimized": "partial" },
573
+ ...anthropicDp,
574
+ });
575
+ const { fallbacks, opts } = collect();
576
+ const resolved = resolveCallSiteConfig(
577
+ "conversationSummarization",
578
+ llm,
579
+ opts,
580
+ );
581
+ expect(resolved.provider).toBe("anthropic");
582
+ expect(fallbacks).toContainEqual({
583
+ callSite: "conversationSummarization",
584
+ requested: "partial",
585
+ reason: "incomplete",
586
+ });
587
+ });
588
+
589
+ test("the anchor consults the balanced remap for profileless call sites", () => {
590
+ const llm = LLMSchema.parse({
591
+ profiles: { mine: completeCustom },
592
+ defaultProfileOverrides: { balanced: "mine" },
593
+ ...anthropicDp,
594
+ });
595
+ // `vision` has no shipped profile; it resolves through the anchor.
596
+ expect(resolveCallSiteConfig("vision", llm).model).toBe("gpt-5.5");
597
+ expect(selectWinningProfile("vision", llm).profileName).toBe("mine");
598
+ });
599
+
600
+ test("a self-referential remap is a no-op", () => {
601
+ const llm = LLMSchema.parse({
602
+ defaultProfileOverrides: { balanced: "balanced" },
603
+ ...anthropicDp,
604
+ });
605
+ const { fallbacks, opts } = collect();
606
+ expect(resolveCallSiteConfig("subagentSpawn", llm, opts).provider).toBe(
607
+ "anthropic",
608
+ );
609
+ expect(fallbacks).toEqual([]);
610
+ });
611
+ });
@@ -385,6 +385,7 @@ describe("resolveCallSiteConfig", () => {
385
385
  callSites: {
386
386
  mainAgent: { profile: "nonexistent" },
387
387
  },
388
+ defaultProfileOverrides: {},
388
389
  profileSession: { defaultTtlSeconds: 1800, maxTtlSeconds: 43200 },
389
390
  pricingOverrides: [],
390
391
  };
@@ -561,6 +562,7 @@ describe("resolveCallSiteConfig", () => {
561
562
  profileOrder: [],
562
563
  callSites: {},
563
564
  activeProfile: "nonexistent",
565
+ defaultProfileOverrides: {},
564
566
  profileSession: { defaultTtlSeconds: 1800, maxTtlSeconds: 43200 },
565
567
  pricingOverrides: [],
566
568
  };
@@ -319,4 +319,40 @@ describe("LLMSchema", () => {
319
319
  });
320
320
  expect(result.success).toBe(true);
321
321
  });
322
+
323
+ test("defaultProfileOverrides accepts custom profiles and default keys as targets", () => {
324
+ const parsed = LLMSchema.parse({
325
+ profiles: { fast: { speed: "fast", effort: "low" } },
326
+ defaultProfileOverrides: {
327
+ balanced: "fast",
328
+ "cost-optimized": "quality-optimized",
329
+ },
330
+ });
331
+ expect(parsed.defaultProfileOverrides).toEqual({
332
+ balanced: "fast",
333
+ "cost-optimized": "quality-optimized",
334
+ });
335
+ expect(LLMSchema.parse({}).defaultProfileOverrides).toEqual({});
336
+ });
337
+
338
+ test("a defaultProfileOverrides target naming no profile is rejected", () => {
339
+ const result = LLMSchema.safeParse({
340
+ defaultProfileOverrides: { balanced: "no-such-profile" },
341
+ });
342
+ expect(result.success).toBe(false);
343
+ if (!result.success) {
344
+ expect(result.error.issues[0]?.path).toEqual([
345
+ "defaultProfileOverrides",
346
+ "balanced",
347
+ ]);
348
+ }
349
+ });
350
+
351
+ test("a defaultProfileOverrides key outside the default-profile tiers is rejected", () => {
352
+ const result = LLMSchema.safeParse({
353
+ profiles: { fast: { speed: "fast" } },
354
+ defaultProfileOverrides: { turbo: "fast" },
355
+ });
356
+ expect(result.success).toBe(false);
357
+ });
322
358
  });
@@ -128,8 +128,10 @@ Arguments:
128
128
  name Name of a configured MCP server to authenticate with
129
129
 
130
130
  Only works with sse or streamable-http transports (stdio servers do not use
131
- OAuth). Opens a browser for OAuth authorization with the remote server. The
132
- running assistant handles the OAuth callback and token exchange.
131
+ OAuth). On desktop, opens a browser for OAuth authorization with the remote
132
+ server. On web, prints the OAuth URL to the terminal — copy and open it in a
133
+ new browser tab manually. The running assistant handles the OAuth callback
134
+ and token exchange in both cases.
133
135
 
134
136
  The command waits up to 2.5 minutes for the user to complete the browser-based
135
137
  OAuth flow. If the server already has valid cached tokens, the command succeeds
@@ -340,6 +340,31 @@ describe("reconcileFlagGatedProfiles", () => {
340
340
  expect(LLMSchema.safeParse(after.llm).success).toBe(true);
341
341
  });
342
342
 
343
+ test("flag off clears a tier override targeting os-beta", () => {
344
+ process.env.IS_PLATFORM = "true";
345
+ seedBalancedConfig();
346
+ setOverridesForTesting({ "os-beta": true });
347
+ reconcileFlagGatedProfiles();
348
+
349
+ const raw = readConfig();
350
+ (raw.llm as Record<string, unknown>).defaultProfileOverrides = {
351
+ balanced: "os-beta",
352
+ "cost-optimized": "quality-optimized",
353
+ };
354
+ writeConfig(raw);
355
+ invalidateConfigCache();
356
+
357
+ setOverridesForTesting({ "os-beta": false });
358
+ expect(reconcileFlagGatedProfiles()).toBe(true);
359
+
360
+ const after = readConfig();
361
+ expect(
362
+ (after.llm as Record<string, unknown>).defaultProfileOverrides,
363
+ ).toEqual({ "cost-optimized": "quality-optimized" });
364
+
365
+ expect(LLMSchema.safeParse(after.llm).success).toBe(true);
366
+ });
367
+
343
368
  test("a user-owned os-beta profile is never touched", () => {
344
369
  process.env.IS_PLATFORM = "true";
345
370
  writeConfig({
@@ -38,10 +38,12 @@ import {
38
38
  * 2. `llm.activeProfile` — `mainAgent` only, since it IS that call site's
39
39
  * user-facing chat-model selection
40
40
  * 3. `llm.callSites[callSite].profile` (the call site's named profile)
41
- * 4. `CALL_SITE_DEFAULTS[callSite].profile` intent resolved through
42
- * `llm.defaultProvider`
43
- * 5. balanced intent through `llm.defaultProvider` — the code-owned anchor
44
- * for profileless call sites, or when nothing above is usable
41
+ * 4. `CALL_SITE_DEFAULTS[callSite].profile` intent, remapped through
42
+ * `llm.defaultProfileOverrides[intent]` when set, otherwise resolved
43
+ * through `llm.defaultProvider`
44
+ * 5. balanced intent (same remap consult) through `llm.defaultProvider`:
45
+ * the code-owned anchor for profileless call sites, or when nothing
46
+ * above is usable
45
47
  *
46
48
  * A winner must carry its own `provider` AND `model`: the base layer's schema
47
49
  * default identity never stands in for a selected profile. The anchor is
@@ -147,9 +149,11 @@ export function resolveCallSiteConfig(
147
149
  // 1. `opts.overrideProfile` (conversation/schedule/per-turn pin)
148
150
  // 2. `llm.activeProfile` (mainAgent only — it IS that call site's selection)
149
151
  // 3. `llm.callSites[callSite].profile`
150
- // 4. `CALL_SITE_DEFAULTS[callSite].profile` intent × default provider
151
- // 5. balanced intent × default provider (profileless sites, or nothing
152
- // above usable)
152
+ // 4. `CALL_SITE_DEFAULTS[callSite].profile` intent, remapped via
153
+ // `llm.defaultProfileOverrides[intent]` when set, else intent × default
154
+ // provider
155
+ // 5. balanced intent (same remap consult) × default provider (profileless
156
+ // sites, or nothing above usable)
153
157
  // A winner must carry its own `provider` AND `model`: the base layer's
154
158
  // schema-default identity must never stand in for a selected profile (that
155
159
  // would be merge inheritance by the back door). A fallback anchor must be
@@ -212,8 +216,28 @@ export function selectWinningProfile(
212
216
  entry: sitePin.entry,
213
217
  };
214
218
  }
219
+ // A tier remap (`llm.defaultProfileOverrides`) redirects the intent rung
220
+ // to a named profile. Resolved through the same `usableEntry` gate as
221
+ // every other rung, so an unusable target reports and falls through to
222
+ // the stock tier resolution. A self-reference is skipped rather than
223
+ // resolved: the stock rung's handling of unusable user shadows differs
224
+ // from the named-rung path, and a no-op remap must not change behavior.
225
+ const remappedIntent = (intent: string) => {
226
+ if (!isDefaultProfileKey(intent)) {
227
+ return undefined;
228
+ }
229
+ const target = llm.defaultProfileOverrides?.[intent];
230
+ return target === intent
231
+ ? undefined
232
+ : usableEntry(target, llm, opts, report);
233
+ };
234
+
215
235
  const intent = CALL_SITE_DEFAULTS[callSite]?.profile;
216
236
  if (intent != null) {
237
+ const remap = remappedIntent(intent);
238
+ if (remap) {
239
+ return { profileName: remap.name, source: "default", entry: remap.entry };
240
+ }
217
241
  const entry = usableDefaultIntent(intent, llm, opts, report);
218
242
  if (entry) {
219
243
  return { profileName: intent, source: "default", entry };
@@ -221,8 +245,19 @@ export function selectWinningProfile(
221
245
  }
222
246
  // Anchor: profileless call sites (`vision`, `workflowLeaf`) and any
223
247
  // resolution whose every named rung was unusable land on balanced intent
224
- // through the default provider. `profileName` stays null — the anchor is
225
- // not a selection.
248
+ // through the default provider. The anchor consults the balanced remap
249
+ // first (a profileless call site follows the user's balanced choice like
250
+ // any balanced-tier site) but bottoms out on the code-owned catalog, so
251
+ // it always resolves. `profileName` stays null on the catalog path: the
252
+ // anchor itself is not a selection.
253
+ const anchorRemap = remappedIntent("balanced");
254
+ if (anchorRemap) {
255
+ return {
256
+ profileName: anchorRemap.name,
257
+ source: "default",
258
+ entry: anchorRemap.entry,
259
+ };
260
+ }
226
261
  return {
227
262
  profileName: null,
228
263
  source: "default",
@@ -657,6 +657,15 @@ export const LLMSchema = z
657
657
  // are seeded into the user's on-disk config by migration 040, not at
658
658
  // schema level, so `LLMSchema.parse({})` yields an empty map.
659
659
  callSites: z.partialRecord(LLMCallSiteEnum, LLMCallSiteConfig).default({}),
660
+ // Per-tier remap of the shipped call-site defaults: every call site whose
661
+ // `CALL_SITE_DEFAULTS` profile is <tier> resolves through the named
662
+ // profile instead of the tier's intent × provider matrix body. A
663
+ // per-call-site pin (`llm.callSites.*.profile`) still wins over the
664
+ // remap, and an unusable remap target falls through to the stock tier
665
+ // resolution (see `selectWinningProfile`).
666
+ defaultProfileOverrides: z
667
+ .partialRecord(z.enum(DEFAULT_PROFILE_KEYS), z.string().min(1))
668
+ .default({}),
660
669
  activeProfile: z.string().min(1).optional(),
661
670
  // The profile the advisor role consults when spawned as a subagent (chosen
662
671
  // under Models & Services). It is excluded from the chat-profile pickers so
@@ -725,6 +734,17 @@ export const LLMSchema = z
725
734
  });
726
735
  }
727
736
  }
737
+ for (const [tier, profileName] of Object.entries(
738
+ config.defaultProfileOverrides ?? {},
739
+ )) {
740
+ if (profileName != null && !profileNames.has(profileName)) {
741
+ ctx.addIssue({
742
+ code: "custom",
743
+ path: ["defaultProfileOverrides", tier],
744
+ message: `Profile "${profileName}" referenced by llm.defaultProfileOverrides.${tier} is not defined in llm.profiles`,
745
+ });
746
+ }
747
+ }
728
748
  if (
729
749
  config.activeProfile != null &&
730
750
  !profileNames.has(config.activeProfile)
@@ -221,5 +221,16 @@ function disableProfile(
221
221
  }
222
222
  }
223
223
 
224
+ // Clear any tier override targeting a removed profile; the cleared tier
225
+ // falls back to its stock intent resolution.
226
+ const tierOverrides = readObject(llm.defaultProfileOverrides);
227
+ if (tierOverrides) {
228
+ for (const [tier, target] of Object.entries(tierOverrides)) {
229
+ if (typeof target === "string" && removed.has(target)) {
230
+ delete tierOverrides[tier];
231
+ }
232
+ }
233
+ }
234
+
224
235
  return true;
225
236
  }
@@ -37,25 +37,41 @@ mock.module("../../messaging/providers/telegram-bot/render.js", () => ({
37
37
  }));
38
38
 
39
39
  const { abortConversation } = await import("../conversation-lifecycle.js");
40
+ import { createAbortReason } from "../../util/abort-reasons.js";
40
41
  import type { AbortContext } from "../conversation-lifecycle.js";
41
42
 
43
+ interface QueuedEvent {
44
+ type: string;
45
+ requestId?: string;
46
+ }
47
+
42
48
  interface Harness {
43
49
  ctx: AbortContext;
44
50
  setProcessingCalls: boolean[];
45
51
  prompterDisposed: () => boolean;
46
52
  secretPrompterDisposed: () => boolean;
47
53
  queueClearedCount: () => number;
54
+ queuedEvents: QueuedEvent[];
55
+ drainKicks: string[];
48
56
  }
49
57
 
50
58
  function makeContext(opts: {
51
59
  processing: boolean;
52
60
  controller: AbortController | null;
61
+ /** requestIds of messages sitting in the queue behind the aborted turn. */
62
+ queued?: string[];
53
63
  }): Harness {
54
64
  let processing = opts.processing;
55
65
  const setProcessingCalls: boolean[] = [];
56
66
  let prompterDisposed = false;
57
67
  let secretPrompterDisposed = false;
58
68
  let queueCleared = 0;
69
+ const queuedEvents: QueuedEvent[] = [];
70
+ const drainKicks: string[] = [];
71
+ let queuedItems = (opts.queued ?? []).map((requestId) => ({
72
+ requestId,
73
+ onEvent: (event: QueuedEvent) => queuedEvents.push(event),
74
+ }));
59
75
 
60
76
  const ctx = {
61
77
  conversationId: "abort-null-controller-test",
@@ -82,10 +98,19 @@ function makeContext(opts: {
82
98
  queue: {
83
99
  clear: () => {
84
100
  queueCleared += 1;
101
+ queuedItems = [];
102
+ },
103
+ get isEmpty() {
104
+ return queuedItems.length === 0;
85
105
  },
86
- [Symbol.iterator]: function* () {
87
- // no queued messages
106
+ get length() {
107
+ return queuedItems.length;
88
108
  },
109
+ [Symbol.iterator]: () => queuedItems[Symbol.iterator](),
110
+ },
111
+ pendingInterruptRepair: false,
112
+ kickDrainQueue: async (_reason?: string, origin?: string) => {
113
+ drainKicks.push(origin ?? "");
89
114
  },
90
115
  } as unknown as AbortContext;
91
116
 
@@ -95,6 +120,8 @@ function makeContext(opts: {
95
120
  prompterDisposed: () => prompterDisposed,
96
121
  secretPrompterDisposed: () => secretPrompterDisposed,
97
122
  queueClearedCount: () => queueCleared,
123
+ queuedEvents,
124
+ drainKicks,
98
125
  };
99
126
  }
100
127
 
@@ -152,6 +179,114 @@ describe("abortConversation", () => {
152
179
  );
153
180
  });
154
181
 
182
+ test("a user interrupt leaves queued messages for the stopped turn's drain", () => {
183
+ // GIVEN a live turn with two messages queued behind it
184
+ const controller = new AbortController();
185
+ const h = makeContext({
186
+ processing: true,
187
+ controller,
188
+ queued: ["req-a", "req-b"],
189
+ });
190
+
191
+ // WHEN the user stops the turn
192
+ abortConversation(
193
+ h.ctx,
194
+ createAbortReason("user_cancel", "test", "abort-null-controller-test"),
195
+ );
196
+
197
+ // THEN the queue is untouched, so the agent loop's `finally` drains it and
198
+ // the queued messages are sent rather than silently dropped
199
+ expect(h.queueClearedCount()).toBe(0);
200
+ expect(h.ctx.queue.length).toBe(2);
201
+ expect(h.queuedEvents).toEqual([]);
202
+ // AND the drain is left to that `finally`, not kicked from here
203
+ expect(h.drainKicks).toEqual([]);
204
+ // AND the drain is told to repair tool_use blocks the killed turn abandoned
205
+ expect(h.ctx.pendingInterruptRepair).toBe(true);
206
+ });
207
+
208
+ test("a user interrupt with no live turn kicks the drain itself", () => {
209
+ // GIVEN a conversation flagged processing with no controller left to
210
+ // signal, so no agent-loop `finally` is coming to drain the preserved queue
211
+ const h = makeContext({
212
+ processing: true,
213
+ controller: null,
214
+ queued: ["req-a"],
215
+ });
216
+
217
+ // WHEN the user stops it
218
+ abortConversation(
219
+ h.ctx,
220
+ createAbortReason("user_cancel", "test", "abort-null-controller-test"),
221
+ );
222
+
223
+ // THEN the queued message is still sent, because the abort kicks the drain
224
+ expect(h.ctx.queue.length).toBe(1);
225
+ expect(h.drainKicks).toEqual(["abortConversation:no_live_turn"]);
226
+ });
227
+
228
+ test("a non-interrupt abort discards the queue and closes out each row", () => {
229
+ // GIVEN a live turn with a queued message, torn down by a dispose
230
+ const controller = new AbortController();
231
+ const h = makeContext({
232
+ processing: true,
233
+ controller,
234
+ queued: ["req-a"],
235
+ });
236
+
237
+ // WHEN the conversation is disposed rather than interrupted
238
+ abortConversation(
239
+ h.ctx,
240
+ createAbortReason(
241
+ "conversation_disposed",
242
+ "test",
243
+ "abort-null-controller-test",
244
+ ),
245
+ );
246
+
247
+ // THEN the queue is dropped, since no turn is left for it to run on, and
248
+ // the sender gets the terminal event that closes the queued row, so no
249
+ // client is left holding a pending indicator nothing will ever settle
250
+ expect(h.queueClearedCount()).toBe(1);
251
+ expect(h.queuedEvents.map((e) => e.type)).toEqual([
252
+ "generation_cancelled",
253
+ "message_queued_deleted",
254
+ ]);
255
+ expect(h.queuedEvents[1].requestId).toBe("req-a");
256
+ expect(h.ctx.pendingInterruptRepair).toBe(false);
257
+ });
258
+
259
+ test("a teardown abort discards the queue even when the flag reads idle", () => {
260
+ // GIVEN a conversation whose turn already cleared the processing flag but
261
+ // is still unwinding: the agent-loop `finally` clears the flag, then awaits
262
+ // the turn-boundary commit before its `kickDrainQueue`. A dispose landing
263
+ // in that window sees an idle conversation with a populated queue.
264
+ const h = makeContext({
265
+ processing: false,
266
+ controller: null,
267
+ queued: ["req-a"],
268
+ });
269
+
270
+ // WHEN the conversation is disposed
271
+ abortConversation(
272
+ h.ctx,
273
+ createAbortReason(
274
+ "conversation_disposed",
275
+ "test",
276
+ "abort-null-controller-test",
277
+ ),
278
+ );
279
+
280
+ // THEN the queue goes with it, so the pending `kickDrainQueue` finds
281
+ // nothing to run against the disposed conversation, and the queued row
282
+ // still gets its terminal event
283
+ expect(h.ctx.queue.length).toBe(0);
284
+ expect(h.queuedEvents.map((e) => e.type)).toEqual([
285
+ "generation_cancelled",
286
+ "message_queued_deleted",
287
+ ]);
288
+ });
289
+
155
290
  test("clears the persisted flag when in-memory reads idle (cancel with no live abort)", () => {
156
291
  // GIVEN a conversation whose in-memory flag reads idle — e.g. reloaded
157
292
  // after its owning turn was interrupted out-of-process, so a persisted
@@ -166,9 +301,10 @@ describe("abortConversation", () => {
166
301
  // persisted column, so the row is unwedged for cold readers and the next
167
302
  // reload — cancel is not a silent no-op.
168
303
  expect(h.setProcessingCalls).toEqual([false]);
169
- // AND the live-turn teardown (prompters, queue) is left alone: there was no
170
- // live turn holding them.
304
+ // AND the live-turn teardown (prompters, surfaces) is left alone: there was
305
+ // no live turn holding them. The queue is the exception, discarded on any
306
+ // non-interrupt abort regardless of the flag.
171
307
  expect(h.prompterDisposed()).toBe(false);
172
- expect(h.queueClearedCount()).toBe(0);
308
+ expect(h.ctx.queue.length).toBe(0);
173
309
  });
174
310
  });
@@ -12,6 +12,7 @@ import { getConfig } from "../config/loader.js";
12
12
  import { usesConceptPageMemory } from "../config/memory-v3-gate.js";
13
13
  import type { PermissionPrompter } from "../permissions/prompter.js";
14
14
  import type { SecretPrompter } from "../permissions/secret-prompter.js";
15
+ import { isHiddenMessageMetadata } from "../persistence/conversation-types.js";
15
16
  import {
16
17
  enqueueMemoryJob,
17
18
  isMemoryEnabled,
@@ -23,10 +24,17 @@ import { resolveCapabilities } from "../runtime/capabilities.js";
23
24
  import { isAutoAnalysisConversation } from "../runtime/services/auto-analysis-guard.js";
24
25
  import { unregisterConversationSender } from "../tools/browser/browser-screencast.js";
25
26
  import { disposeToolProfiler } from "../tools/tool-profiler.js";
26
- import { type AbortReason, createAbortReason } from "../util/abort-reasons.js";
27
+ import {
28
+ type AbortReason,
29
+ createAbortReason,
30
+ isUserInterruptAbort,
31
+ } from "../util/abort-reasons.js";
27
32
  import { getLogger } from "../util/logger.js";
28
33
  import { unregisterCallNotifiers } from "./conversation-notifiers.js";
29
- import type { MessageQueue } from "./conversation-queue-manager.js";
34
+ import type {
35
+ MessageQueue,
36
+ QueueDrainReason,
37
+ } from "./conversation-queue-manager.js";
30
38
  import { resetSkillToolProjection } from "./conversation-skill-tools.js";
31
39
  import type { SurfaceData, SurfaceType } from "./message-protocol.js";
32
40
 
@@ -112,6 +120,12 @@ export interface AbortContext {
112
120
  >;
113
121
  accumulatedSurfaceState: Map<string, Record<string, unknown>>;
114
122
  readonly queue: MessageQueue;
123
+ /** Armed when an interrupt leaves queued messages behind, so the drain
124
+ * repairs tool_use blocks the killed turn abandoned. */
125
+ pendingInterruptRepair: boolean;
126
+ /** Never rejects. Called only when an interrupt preserves the queue but no
127
+ * live turn is left to reach its `finally` and drain it. */
128
+ kickDrainQueue(reason?: QueueDrainReason, origin?: string): Promise<void>;
115
129
  }
116
130
 
117
131
  export interface DisposeContext extends AbortContext {
@@ -129,18 +143,99 @@ export interface DisposeContext extends AbortContext {
129
143
 
130
144
  // ── abort ─────────────────────────────────────────────────────────────
131
145
 
146
+ /**
147
+ * Keep the queue intact across a user interrupt so the messages queued behind
148
+ * the stopped turn still run.
149
+ *
150
+ * A user interrupt ends the turn in flight and nothing else: the aborted turn
151
+ * unwinds to the agent loop's `finally`, which calls `kickDrainQueue`, so the
152
+ * queued messages become the next turn. This is the same drain-after-abort
153
+ * sequence `steerToMessage` relies on.
154
+ *
155
+ * An interrupt can land mid-tool, leaving `tool_use` blocks with no results, so
156
+ * arm the same repair a steer arms: the drained turn then opens with synthetic
157
+ * results instead of a dangling call.
158
+ *
159
+ * `hasLiveTurn` is false when the processing flag was force-cleared because no
160
+ * controller was left to signal. No agent-loop `finally` runs in that case, so
161
+ * the drain is kicked here instead; otherwise the preserved messages wait for
162
+ * an unrelated later turn to finish.
163
+ */
164
+ function preserveQueueAcrossInterrupt(
165
+ ctx: AbortContext,
166
+ { hasLiveTurn }: { hasLiveTurn: boolean },
167
+ ): void {
168
+ if (ctx.queue.isEmpty) {
169
+ return;
170
+ }
171
+ ctx.pendingInterruptRepair = true;
172
+ log.info(
173
+ {
174
+ conversationId: ctx.conversationId,
175
+ queueDepth: ctx.queue.length,
176
+ hasLiveTurn,
177
+ },
178
+ "User interrupt: preserving queued messages for the interrupted turn's drain",
179
+ );
180
+ if (!hasLiveTurn) {
181
+ void ctx.kickDrainQueue("loop_complete", "abortConversation:no_live_turn");
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Discard the queue when the conversation itself is going away (dispose,
187
+ * eviction, voice supersession, subagent teardown): there is no turn left for
188
+ * a queued message to run on.
189
+ *
190
+ * This runs whether or not the in-memory flag reads processing. A turn that
191
+ * cleared the flag can still be unwinding through its awaited turn-boundary
192
+ * commit with a `kickDrainQueue` ahead of it, and a queue left populated there
193
+ * drains onto a conversation whose state (and possibly whose durable row) is
194
+ * already gone.
195
+ *
196
+ * Each sender gets two events, both load-bearing. `generation_cancelled`
197
+ * closes out the turn the message was waiting on; `message_queued_deleted` is
198
+ * the terminal event for the queued row itself, the same one a user-issued
199
+ * DELETE emits (`deleteQueuedMessage`). Without the second, a client holds the
200
+ * pending indicator forever, since no `message_dequeued` is coming. Hidden
201
+ * sends are suppressed for the same reason they get no queued ack: they have
202
+ * no client row to close.
203
+ */
204
+ function discardQueueOnAbort(ctx: AbortContext): void {
205
+ for (const queued of ctx.queue) {
206
+ queued.onEvent({
207
+ type: "generation_cancelled",
208
+ conversationId: ctx.conversationId,
209
+ });
210
+ if (isHiddenMessageMetadata(queued.metadata)) {
211
+ continue;
212
+ }
213
+ queued.onEvent({
214
+ type: "message_queued_deleted",
215
+ conversationId: ctx.conversationId,
216
+ requestId: queued.requestId,
217
+ ...(queued.clientMessageId
218
+ ? { clientMessageId: queued.clientMessageId }
219
+ : {}),
220
+ });
221
+ }
222
+ ctx.queue.clear();
223
+ }
224
+
132
225
  export function abortConversation(
133
226
  ctx: AbortContext,
134
227
  reason?: AbortReason,
135
228
  ): void {
136
- if (ctx.isProcessing()) {
137
- const effectiveReason =
138
- reason ??
139
- createAbortReason(
140
- "preempted_by_new_message",
141
- "abortConversation:default",
142
- ctx.conversationId,
143
- );
229
+ const effectiveReason =
230
+ reason ??
231
+ createAbortReason(
232
+ "preempted_by_new_message",
233
+ "abortConversation:default",
234
+ ctx.conversationId,
235
+ );
236
+ const hasLiveTurn = ctx.abortController !== null;
237
+ const wasProcessing = ctx.isProcessing();
238
+ if (wasProcessing) {
144
239
  log.info(
145
240
  { conversationId: ctx.conversationId, abortReason: effectiveReason },
146
241
  "Aborting in-flight processing",
@@ -175,13 +270,6 @@ export function abortConversation(
175
270
  ctx.surfaceActionRequestIds.clear();
176
271
  ctx.surfaceState.clear();
177
272
  ctx.accumulatedSurfaceState.clear();
178
- for (const queued of ctx.queue) {
179
- queued.onEvent({
180
- type: "generation_cancelled",
181
- conversationId: ctx.conversationId,
182
- });
183
- }
184
- ctx.queue.clear();
185
273
  } else {
186
274
  // The in-memory flag reads idle, but a cancel must still clear a persisted
187
275
  // `processing_started_at` that outlived the turn that set it. This is the
@@ -201,6 +289,20 @@ export function abortConversation(
201
289
  // reports idle in-memory to clients, so no refetch needs to be pushed.
202
290
  ctx.setProcessing(false);
203
291
  }
292
+
293
+ // The queue decision is independent of the processing flag: an interrupt
294
+ // hands its queue to the drain, and every other abort kind is a teardown
295
+ // that must take the queue with it even when the flag already reads idle.
296
+ if (isUserInterruptAbort(effectiveReason)) {
297
+ // An idle conversation counts as having a live turn for drain purposes:
298
+ // either a turn is unwinding toward its own `kickDrainQueue`, or the queue
299
+ // is empty. Kicking a second drain here would race the first.
300
+ preserveQueueAcrossInterrupt(ctx, {
301
+ hasLiveTurn: hasLiveTurn || !wasProcessing,
302
+ });
303
+ } else {
304
+ discardQueueOnAbort(ctx);
305
+ }
204
306
  }
205
307
 
206
308
  // ── dispose ──────────────────────────────────────────────────────────
@@ -324,10 +324,11 @@ async function buildPassthroughBatch(
324
324
  return conversation.queue.shiftN(matched);
325
325
  }
326
326
 
327
- // ── Steer repair ────────────────────────────────────────────────────
327
+ // ── Steer / interrupt repair ────────────────────────────────────────
328
328
 
329
329
  /**
330
- * When a steer-to-message abort interrupts an in-flight tool call, the
330
+ * When a steer-to-message abort (or a user interrupt with messages still
331
+ * queued behind the stopped turn) cuts off an in-flight tool call, the
331
332
  * conversation history may end with an assistant message containing one
332
333
  * or more `tool_use` blocks that have no corresponding `tool_result`.
333
334
  * LLM providers reject this sequence. This helper scans the tail of the
@@ -335,10 +336,12 @@ async function buildPassthroughBatch(
335
336
  * unmatched `tool_use` blocks.
336
337
  */
337
338
  function repairPendingToolUseBlocks(conversation: Conversation): void {
338
- if (!conversation.pendingSteerRepair) {
339
+ const steered = conversation.pendingSteerRepair;
340
+ if (!steered && !conversation.pendingInterruptRepair) {
339
341
  return;
340
342
  }
341
343
  conversation.pendingSteerRepair = false;
344
+ conversation.pendingInterruptRepair = false;
342
345
 
343
346
  const messages = conversation.messages;
344
347
  if (messages.length === 0) {
@@ -381,15 +384,18 @@ function repairPendingToolUseBlocks(conversation: Conversation): void {
381
384
  {
382
385
  conversationId: conversation.conversationId,
383
386
  pendingToolUseCount: pendingToolUseIds.length,
387
+ trigger: steered ? "steer" : "interrupt",
384
388
  },
385
- "Injecting synthetic tool_result for pending tool_use blocks after steer",
389
+ "Injecting synthetic tool_result for pending tool_use blocks",
386
390
  );
387
391
 
388
392
  // Build a single user message with tool_result blocks for all pending IDs.
389
393
  const syntheticContent = pendingToolUseIds.map((toolUseId) => ({
390
394
  type: "tool_result" as const,
391
395
  tool_use_id: toolUseId,
392
- content: "Tool execution was interrupted by user steering.",
396
+ content: steered
397
+ ? "Tool execution was interrupted by user steering."
398
+ : "Tool execution was interrupted by the user.",
393
399
  is_error: true,
394
400
  }));
395
401
  conversation.messages.push({
@@ -538,6 +538,18 @@ export class Conversation {
538
538
  * @internal
539
539
  */
540
540
  pendingSteerRepair = false;
541
+ /**
542
+ * Set by `abortConversation` when a user interrupt (Stop / Esc / the CLI
543
+ * cancel signal) ends a turn that still has messages queued behind it. Those
544
+ * messages survive the abort and drain into the next turn, so the drain path
545
+ * owes them the same synthetic tool_result repair a steer gets: the killed
546
+ * turn may have left `tool_use` blocks with no results. Unlike
547
+ * `pendingSteerRepair` this does not promote a single head message. An
548
+ * interrupt has nothing to promote, so the drain batches the queue the way it
549
+ * would after any other turn. Cleared after repair.
550
+ * @internal
551
+ */
552
+ pendingInterruptRepair = false;
541
553
  /**
542
554
  * When true, side-effect tools must prompt even if a trust/allow rule
543
555
  * would auto-allow. Set by non-interactive callers (e.g. non-guardian
@@ -12,6 +12,8 @@ The single HTTP send endpoint is `POST /v1/messages`. Key behaviors:
12
12
 
13
13
  Do NOT add new send endpoints. All message ingress should go through `POST /v1/messages` (HTTP).
14
14
 
15
+ **A user interrupt ends the turn, not the queue.** `POST /v1/conversations/:id/cancel` (Stop / Esc, and the CLI's cancel signal file) aborts the turn in flight; anything the same user queued behind it survives and runs on that turn's drain, because the aborted loop reaches its `finally`, which calls `kickDrainQueue` like any other turn end. `abortConversation` discards the queue only for abort kinds where the conversation itself is going away (dispose, eviction, voice supersession, subagent teardown), and every discarded row gets `message_queued_deleted` alongside `generation_cancelled`. Two invariants to keep: an unconditional `queue.clear()` emits no per-row terminal event, so clients keep rendering a queued row whose cancel/steer buttons are dead (DELETE 404s, steer reports `not_processing`) and whose message is silently lost on the next reload; and a teardown must discard even when the processing flag already reads idle, since a turn that cleared the flag can still be unwinding through its awaited turn-boundary commit with a `kickDrainQueue` ahead of it.
16
+
15
17
  ### Channel inbound honors "queue if busy" via defer-until-idle
16
18
 
17
19
  Channel inbound turns (Slack/Telegram/etc. — `processChannelMessageInBackground`) obey the same "process when the current turn completes" contract, but they do **not** route through the conversation's queue. A channel turn delivers its reply back through the provider callback URL (streaming session + `finalizeEventDelivery` + processed/delivery bookkeeping); the queue drain fans replies onto the SSE hub only and performs none of that. So a channel turn that arrives while the conversation is mid-turn is **deferred until the processing lock frees** (`withChannelTurnAdmission` in `routes/inbound-stages/channel-turn-admission.ts`: per-conversation single-flight for FIFO ordering + event-driven `waitForIdle`), then run with its delivery orchestration intact.
@@ -277,6 +277,10 @@ describe("collectProfileReferences", () => {
277
277
  memoryExtraction: { profile: "my-fast" },
278
278
  recall: { profile: "other" },
279
279
  },
280
+ defaultProfileOverrides: {
281
+ balanced: "my-fast",
282
+ "cost-optimized": "other",
283
+ },
280
284
  profiles: {
281
285
  "my-fast": { source: "user", provider: "anthropic" },
282
286
  "my-mix": {
@@ -290,6 +294,7 @@ describe("collectProfileReferences", () => {
290
294
  "llm.activeProfile",
291
295
  "llm.advisorProfile",
292
296
  "llm.callSites.memoryExtraction",
297
+ "llm.defaultProfileOverrides.balanced",
293
298
  "llm.profiles.my-mix.mix",
294
299
  ].sort(),
295
300
  );
@@ -296,7 +296,8 @@ function fragmentFromBody(
296
296
 
297
297
  /**
298
298
  * Enumerate every live reference to profile `name` in the raw `llm` config
299
- * block: `activeProfile`, `advisorProfile`, each `callSites.<id>.profile`, and
299
+ * block: `activeProfile`, `advisorProfile`, each `callSites.<id>.profile`,
300
+ * each `defaultProfileOverrides.<tier>`, and
300
301
  * every mix arm (`profiles.<mix>.mix[].profile`). Deleting a profile while any
301
302
  * of these point at it would leave a dangling reference that `LLMSchema`'s
302
303
  * superRefine rejects on the next load — silently resetting the user's chat
@@ -324,6 +325,14 @@ export function collectProfileReferences(
324
325
  }
325
326
  }
326
327
  }
328
+ const tierOverrides = asPlainObject(llm.defaultProfileOverrides);
329
+ if (tierOverrides) {
330
+ for (const [tier, target] of Object.entries(tierOverrides)) {
331
+ if (target === name) {
332
+ refs.push(`llm.defaultProfileOverrides.${tier}`);
333
+ }
334
+ }
335
+ }
327
336
  const profiles = asPlainObject(llm.profiles);
328
337
  if (profiles) {
329
338
  for (const [profileName, profileEntry] of Object.entries(profiles)) {
@@ -741,7 +750,7 @@ export const ROUTES: RouteDefinition[] = [
741
750
  "404": { description: "Profile not found" },
742
751
  "409": {
743
752
  description:
744
- "Profile is still referenced by activeProfile, advisorProfile, a call site, or a mix arm",
753
+ "Profile is still referenced by activeProfile, advisorProfile, a call site, a default-tier override, or a mix arm",
745
754
  },
746
755
  },
747
756
  handler: handleDeleteProfile,
@@ -756,8 +756,14 @@ export const ROUTES: RouteDefinition[] = [
756
756
  }),
757
757
  responseBody: z.object({
758
758
  success: z.boolean(),
759
- type: z.string(),
760
- name: z.string(),
759
+ type: z.string().optional(),
760
+ name: z.string().optional(),
761
+ error: z
762
+ .string()
763
+ .optional()
764
+ .describe(
765
+ "Why the secret was not stored (e.g. provider-side API key validation failed). Present only when success is false.",
766
+ ),
761
767
  }),
762
768
  handler: handleAddSecret,
763
769
  },
@@ -46,6 +46,22 @@ export function createAbortReason(
46
46
  };
47
47
  }
48
48
 
49
+ /**
50
+ * True when the abort came from a person stopping the turn they are watching
51
+ * (the Stop button, Esc, or the CLI's cancel signal file) rather than from the
52
+ * conversation going away or another subsystem seizing it.
53
+ *
54
+ * The distinction matters for the message queue. A user interrupt ends the
55
+ * turn in flight; it says nothing about the messages the same user queued
56
+ * behind it, so those survive the abort and run on the interrupted turn's
57
+ * drain. Every other kind (dispose, eviction, voice supersession, subagent
58
+ * teardown) is the conversation or its owner disappearing, where a queued
59
+ * message has nothing left to run on and is discarded.
60
+ */
61
+ export function isUserInterruptAbort(reason: AbortReason | undefined): boolean {
62
+ return reason?.kind === "user_cancel" || reason?.kind === "signal_cancel";
63
+ }
64
+
49
65
  export function isAbortReason(value: unknown): value is AbortReason {
50
66
  if (typeof value !== "object" || value === null) {
51
67
  return false;