@vellumai/assistant 0.12.3 → 0.12.4-staging.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/Dockerfile +7 -0
  2. package/README.md +12 -12
  3. package/docker/mousepad/90-vellum.gschema.override +4 -0
  4. package/openapi.yaml +9 -3
  5. package/package.json +1 -1
  6. package/src/__tests__/approval-routes-http.test.ts +4 -2
  7. package/src/__tests__/call-routes-http.test.ts +4 -2
  8. package/src/__tests__/config-loader-backfill.test.ts +1 -0
  9. package/src/__tests__/conversation-resurface-done.test.ts +289 -0
  10. package/src/__tests__/default-profile-catalog-fallback.test.ts +1 -0
  11. package/src/__tests__/gemini-provider.test.ts +31 -0
  12. package/src/__tests__/inference-profile-validation-jev.test.ts +17 -0
  13. package/src/__tests__/llm-catalog-parity.test.ts +4 -0
  14. package/src/__tests__/llm-resolver-override-or-default.test.ts +43 -0
  15. package/src/__tests__/plugin-api-model-profiles.test.ts +7 -0
  16. package/src/__tests__/provider-error-scenarios.test.ts +46 -0
  17. package/src/__tests__/provider-platform-proxy-integration.test.ts +45 -15
  18. package/src/__tests__/retry-fallback-escalation.test.ts +22 -0
  19. package/src/__tests__/runtime-attachment-metadata.test.ts +4 -2
  20. package/src/__tests__/secret-routes-platform-proxy.test.ts +1 -0
  21. package/src/__tests__/send-endpoint-busy.test.ts +4 -2
  22. package/src/__tests__/steer-on-enqueue-question.test.ts +265 -34
  23. package/src/__tests__/workspace-migration-159-rename-colliding-jev-profile-name.test.ts +197 -0
  24. package/src/api/requests/dictation.ts +2 -2
  25. package/src/calls/__tests__/typesafe-noul.test.ts +66 -0
  26. package/src/calls/typesafe-noul.ts +8 -2
  27. package/src/calls/voice-escalation-judge.ts +5 -4
  28. package/src/cli/commands/channel-verification-sessions.help.ts +12 -2
  29. package/src/cli/lib/bundled-marketplace.json +4 -4
  30. package/src/config/__tests__/default-profile-catalog.test.ts +33 -1
  31. package/src/config/__tests__/jev-managed-profile.test.ts +82 -0
  32. package/src/config/call-site-defaults.ts +11 -11
  33. package/src/config/default-profile-catalog.ts +88 -18
  34. package/src/config/default-profile-names.ts +35 -0
  35. package/src/config/feature-flag-registry.json +8 -8
  36. package/src/config/inference-profile-validation.ts +7 -6
  37. package/src/config/llm-resolver.ts +16 -0
  38. package/src/config/profile-text-generation.test.ts +9 -0
  39. package/src/config/schemas/call-site-catalog.ts +1 -1
  40. package/src/config/schemas/llm.ts +54 -9
  41. package/src/config/sidebar-done-gate.ts +27 -0
  42. package/src/daemon/conversation-slash.ts +2 -2
  43. package/src/daemon/conversation-turn-finalize.ts +12 -0
  44. package/src/daemon/dictation-text-processing.ts +4 -4
  45. package/src/daemon/handlers/__tests__/conversation-history-search.test.ts +61 -0
  46. package/src/daemon/handlers/config-channels.ts +3 -1
  47. package/src/daemon/handlers/conversation-history.ts +11 -1
  48. package/src/daemon/handlers/conversations.ts +118 -10
  49. package/src/desktop/desktop-browser-endpoint.ts +1 -0
  50. package/src/ipc/routes/__tests__/conversation-sync-ipc-routes.test.ts +49 -1
  51. package/src/ipc/routes/conversation-sync-ipc-routes.ts +38 -2
  52. package/src/permissions/question-prompter.ts +14 -4
  53. package/src/persistence/conversation-crud.ts +114 -3
  54. package/src/persistence/conversation-queries.ts +48 -3
  55. package/src/persistence/embeddings/__tests__/embedding-local-lifecycle.test.ts +157 -32
  56. package/src/persistence/embeddings/embedding-backend-shutdown.test.ts +47 -0
  57. package/src/persistence/embeddings/embedding-backend.ts +23 -41
  58. package/src/persistence/embeddings/embedding-gemini.test.ts +17 -0
  59. package/src/persistence/embeddings/embedding-gemini.ts +3 -1
  60. package/src/persistence/embeddings/embedding-local.ts +72 -17
  61. package/src/persistence/embeddings/embedding-types.ts +0 -19
  62. package/src/plugin-api/model-profiles.ts +5 -3
  63. package/src/plugins/defaults/memory/__tests__/memory-retrospective-skill-card.test.ts +2 -0
  64. package/src/plugins/defaults/memory/memory-retrospective-skill-card.ts +5 -0
  65. package/src/providers/gemini/client.ts +17 -1
  66. package/src/providers/malformed-tool-call.ts +55 -0
  67. package/src/providers/model-catalog.ts +23 -4
  68. package/src/providers/openai/__tests__/chat-completions-malformed-tool-call.test.ts +105 -0
  69. package/src/providers/openai/chat-completions-provider.ts +30 -0
  70. package/src/providers/platform-proxy/constants.ts +5 -0
  71. package/src/providers/retry.ts +68 -36
  72. package/src/providers/vellum-model-routing.test.ts +1 -0
  73. package/src/runtime/__tests__/agent-wake-archived-gate.test.ts +90 -0
  74. package/src/runtime/__tests__/question-resolution.test.ts +32 -0
  75. package/src/runtime/agent-wake.ts +9 -2
  76. package/src/runtime/auth/__tests__/middleware.test.ts +138 -0
  77. package/src/runtime/auth/middleware.ts +15 -11
  78. package/src/runtime/middleware/auth.ts +7 -1
  79. package/src/runtime/pending-interactions.ts +8 -8
  80. package/src/runtime/question-resolution.ts +54 -8
  81. package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +146 -0
  82. package/src/runtime/routes/__tests__/dictation-processing.test.ts +174 -0
  83. package/src/runtime/routes/__tests__/global-search-routes.test.ts +74 -1
  84. package/src/runtime/routes/__tests__/llm-call-sites-routes.test.ts +16 -1
  85. package/src/runtime/routes/conversation-list-routes.ts +16 -14
  86. package/src/runtime/routes/conversation-query-routes.ts +4 -0
  87. package/src/runtime/routes/conversation-routes.ts +4 -2
  88. package/src/runtime/routes/diagnostics-routes.ts +66 -229
  89. package/src/runtime/routes/global-search-routes.ts +22 -7
  90. package/src/runtime/routes/llm-call-sites-routes.ts +19 -10
  91. package/src/runtime/sync/documents-sidecar-publish.test.ts +50 -4
  92. package/src/runtime/sync/resource-sync-events.ts +11 -0
  93. package/src/runtime/sync/worker-daemon-notify.test.ts +30 -0
  94. package/src/runtime/sync/worker-daemon-notify.ts +44 -0
  95. package/src/runtime/verification-outbound-actions.ts +2 -2
  96. package/src/tools/ask-question/ask-question-tool.test.ts +38 -0
  97. package/src/tools/ask-question/ask-question-tool.ts +12 -1
  98. package/src/tools/workflows/manage-workflows.ts +4 -2
  99. package/src/util/worker-pipe.test.ts +100 -0
  100. package/src/util/worker-pipe.ts +44 -0
  101. package/src/workspace/migrations/159-rename-colliding-jev-profile-name.ts +344 -0
  102. package/src/workspace/migrations/registry.ts +2 -0
  103. package/src/runtime/routes/__tests__/dictation-cleanup-guard.test.ts +0 -107
@@ -191,6 +191,7 @@ const MANAGED_FALLBACK_PROVIDERS: string[] = [
191
191
  "openai",
192
192
  "fireworks",
193
193
  "together",
194
+ "typesafe",
194
195
  ];
195
196
 
196
197
  function enableManagedProxy() {
@@ -314,28 +315,57 @@ describe("managed proxy integration — credential precedence", () => {
314
315
  },
315
316
  );
316
317
 
317
- test("managed bootstrap registers anthropic, openai, gemini, fireworks, and together", async () => {
318
+ test("managed bootstrap registers every managed-routable catalog provider", async () => {
318
319
  enableManagedProxy();
319
320
  mockProviderKeys = {};
320
321
  await initializeProviders(makeProvidersConfig("anthropic", "test-model"));
321
- expect(listProviders()).toEqual(
322
- expect.arrayContaining([
323
- "anthropic",
324
- "openai",
325
- "gemini",
326
- "fireworks",
327
- "together",
328
- ]),
322
+ expect([...listProviders()].sort()).toEqual(
323
+ [...MANAGED_FALLBACK_PROVIDERS].sort(),
329
324
  );
330
- expect(listProviders()).toHaveLength(5);
331
- expect(getProviderRoutingSource("anthropic")).toBe("managed-proxy");
332
- expect(getProviderRoutingSource("openai")).toBe("managed-proxy");
333
- expect(getProviderRoutingSource("gemini")).toBe("managed-proxy");
334
- expect(getProviderRoutingSource("fireworks")).toBe("managed-proxy");
335
- expect(getProviderRoutingSource("together")).toBe("managed-proxy");
325
+ for (const provider of MANAGED_FALLBACK_PROVIDERS) {
326
+ expect(getProviderRoutingSource(provider)).toBe("managed-proxy");
327
+ }
336
328
  expect(getProviderRoutingSource("openrouter")).toBeUndefined();
337
329
  });
338
330
 
331
+ test("managed typesafe sends System One through the typesafe proxy path with the assistant key", async () => {
332
+ enableManagedProxy();
333
+ mockProviderKeys = {};
334
+ await initializeProviders(makeProvidersConfig("anthropic", "test-model"));
335
+
336
+ const provider = getProvider("typesafe");
337
+ const originalFetch = globalThis.fetch;
338
+ let capturedUrl = "";
339
+ let capturedHeaders: Record<string, string> = {};
340
+ globalThis.fetch = (async (
341
+ input: string | URL | Request,
342
+ init?: RequestInit,
343
+ ) => {
344
+ capturedUrl = String(input);
345
+ capturedHeaders = (init?.headers ?? {}) as Record<string, string>;
346
+ return new Response(
347
+ JSON.stringify({
348
+ model: "jev-latest",
349
+ answers: {},
350
+ usage: { input_tokens: 1, output_tokens: 0 },
351
+ }),
352
+ { status: 200, headers: { "Content-Type": "application/json" } },
353
+ );
354
+ }) as typeof fetch;
355
+ try {
356
+ await provider.sendMessage([userMsg("hello")], {
357
+ config: { model: "jev-latest" },
358
+ });
359
+ } finally {
360
+ globalThis.fetch = originalFetch;
361
+ }
362
+
363
+ expect(capturedUrl).toBe(
364
+ `${PLATFORM_BASE}/v1/runtime-proxy/typesafe/v1/systemone`,
365
+ );
366
+ expect(capturedHeaders.Authorization).toBe(`Bearer ${MANAGED_API_KEY}`);
367
+ });
368
+
339
369
  test("managed anthropic uses anthropic proxy path", async () => {
340
370
  enableManagedProxy();
341
371
  mockProviderKeys = {};
@@ -12,7 +12,9 @@ mock.module("../util/retry.js", () => ({
12
12
  import {
13
13
  recordFallbackServed,
14
14
  resetFallbackBreaker,
15
+ shouldSkipPrimary,
15
16
  } from "../providers/fallback-breaker.js";
17
+ import { malformedToolCallError } from "../providers/malformed-tool-call.js";
16
18
  import type {
17
19
  Message,
18
20
  Provider,
@@ -666,6 +668,26 @@ describe("RetryProvider fallback-route escalation", () => {
666
668
  expect(backup.calls()).toBe(1);
667
669
  });
668
670
 
671
+ test("malformed tool call exhausts retries → backup serves, breaker stays closed", async () => {
672
+ const primary = failingProvider("openai", () =>
673
+ malformedToolCallError("openai", "MALFORMED_FUNCTION_CALL"),
674
+ );
675
+ const backup = backupProvider();
676
+ const route = makeRoute(backup.provider);
677
+ const wrapped = new RetryProvider(primary.provider, {
678
+ resolveFallbackRoute: route.resolveFallbackRoute,
679
+ });
680
+
681
+ const result = await wrapped.sendMessage(MESSAGES, {
682
+ config: { callSite: "mainAgent" },
683
+ });
684
+
685
+ expect(primary.calls()).toBe(1 + DEFAULT_MAX_RETRIES);
686
+ expect(backup.calls()).toBe(1);
687
+ expect(result.model).toBe("backup-model");
688
+ expect(shouldSkipPrimary({ upstream: "openai" })).toBe(false);
689
+ });
690
+
669
691
  test("resolveFallbackRoute unset → behavior unchanged (original error rethrown)", async () => {
670
692
  const finalError = new ProviderError("Service Unavailable", "openai", 503);
671
693
  const primary = failingProvider("openai", () => finalError);
@@ -55,8 +55,10 @@ afterAll(() => {
55
55
  resetDbForTesting();
56
56
  });
57
57
 
58
- const TEST_TOKEN = "test-bearer-token-attach";
59
- const AUTH_HEADERS = { Authorization: `Bearer ${TEST_TOKEN}` };
58
+ // This suite mocks isHttpAuthDisabled() to true, so a request carrying no
59
+ // bearer authenticates through the dev bypass. A bearer that is present is
60
+ // verified against the signing key, which these tests do not set up.
61
+ const AUTH_HEADERS: Record<string, string> = {};
60
62
 
61
63
  describe("Runtime attachment metadata", () => {
62
64
  let server: RuntimeHttpServer;
@@ -25,6 +25,7 @@ const MANAGED_PROVIDERS = [
25
25
  "gemini",
26
26
  "fireworks",
27
27
  "together",
28
+ "typesafe",
28
29
  ] as const;
29
30
 
30
31
  let platformBaseUrlOverride: string | undefined;
@@ -302,8 +302,10 @@ function makePendingApprovalConversation(
302
302
  // Tests
303
303
  // ---------------------------------------------------------------------------
304
304
 
305
- const TEST_TOKEN = "test-bearer-token-send";
306
- const AUTH_HEADERS = { Authorization: `Bearer ${TEST_TOKEN}` };
305
+ // This suite mocks isHttpAuthDisabled() to true, so a request carrying no
306
+ // bearer authenticates through the dev bypass. A bearer that is present is
307
+ // verified against the signing key, which these tests do not set up.
308
+ const AUTH_HEADERS: Record<string, string> = {};
307
309
 
308
310
  describe("POST /v1/messages — queue-if-busy and hub publishing", () => {
309
311
  let server: RuntimeHttpServer;
@@ -1,45 +1,62 @@
1
1
  /**
2
- * Enqueue-steer contract: when a chat message is enqueued while an
3
- * `ask_question` prompt is open for the same conversation, the user has chosen
4
- * to move on rather than answer it. `steerOnEnqueuedMessageIfQuestionParked`
5
- * steers to that message — aborting the parked turn (which settles the open
6
- * question via its turn-abort signal) and draining the message — instead of
7
- * stranding it behind a prompt no one will answer.
2
+ * Enqueue contract for a message typed while an `ask_question` card is open.
8
3
  *
9
- * Only `kind: "question"` interactions trigger the steer; pending confirmations
10
- * are handled separately by the enqueue path's auto-deny.
4
+ * The message is read as the answer: `answerParkedQuestionWithEnqueuedMessage`
5
+ * resolves the parked prompt with the typed text as free text and consumes the
6
+ * queued message, so the parked turn keeps running and the model reads the
7
+ * answer once, in the tool result.
8
+ *
9
+ * Everything a single free-text answer cannot honestly stand in for falls back
10
+ * to `steerOnEnqueuedMessageIfQuestionParked`, which supersedes the prompt by
11
+ * aborting the parked turn and draining the message instead. Confirmations and
12
+ * secrets are separate interaction kinds and are untouched by both paths.
11
13
  */
12
14
  import { afterEach, describe, expect, test } from "bun:test";
13
15
 
14
16
  import type { Conversation } from "../daemon/conversation.js";
17
+ import type { QueuedMessage } from "../daemon/conversation-queue-manager.js";
15
18
  import {
16
19
  deleteConversation,
17
20
  setConversation,
18
21
  } from "../daemon/conversation-registry.js";
19
22
  import {
23
+ answerParkedQuestionWithEnqueuedMessage,
20
24
  steerOnEnqueuedMessageIfQuestionParked,
21
25
  supersedePendingInteractionsOnEnqueue,
22
26
  } from "../daemon/handlers/conversations.js";
27
+ import type { QuestionPromptResult } from "../permissions/question-prompter.js";
23
28
  import * as pendingInteractions from "../runtime/pending-interactions.js";
24
29
 
25
30
  interface ParkedTurn {
26
31
  abortCount: () => number;
27
32
  fake: { pendingSteerRepair: boolean };
33
+ /** Queue entries still waiting, by request id. */
34
+ queuedIds: () => string[];
35
+ /** Event types emitted through the queued message's own sink. */
36
+ emittedTypes: () => string[];
37
+ enqueue: (message: Partial<QueuedMessage> & { requestId: string }) => void;
28
38
  }
29
39
 
30
40
  /**
31
41
  * Register a fake conversation whose in-flight turn is parked. The fake exposes
32
- * just the surface `steerToMessage` touches: a processing flag, a queue whose
33
- * messages can be looked up and promoted, an abort controller that records
42
+ * just the surface the enqueue paths touch: a processing flag, a queue that can
43
+ * be looked up, promoted and removed from, an abort controller that records
34
44
  * aborts, and the confirmation-deny hook.
35
45
  */
36
46
  function registerParkedTurn(id: string): ParkedTurn {
37
47
  let abortCount = 0;
48
+ const emitted: string[] = [];
49
+ const queue = new Map<string, QueuedMessage>();
38
50
  const fake = {
39
51
  isProcessing: () => true,
40
52
  queue: {
41
- findByRequestId: (requestId: string) => ({ requestId }),
42
- promoteToHead: (requestId: string) => ({ requestId }),
53
+ findByRequestId: (requestId: string) => queue.get(requestId),
54
+ promoteToHead: (requestId: string) => queue.get(requestId),
55
+ },
56
+ removeQueuedMessage: (requestId: string) => {
57
+ const item = queue.get(requestId);
58
+ queue.delete(requestId);
59
+ return item;
43
60
  },
44
61
  pendingSteerRepair: false,
45
62
  abortController: {
@@ -51,23 +68,249 @@ function registerParkedTurn(id: string): ParkedTurn {
51
68
  denyAllPendingConfirmations: () => {},
52
69
  };
53
70
  setConversation(id, fake as unknown as Conversation);
54
- return { abortCount: () => abortCount, fake };
71
+ const enqueue = (
72
+ message: Partial<QueuedMessage> & { requestId: string },
73
+ ): void => {
74
+ queue.set(message.requestId, {
75
+ content: "",
76
+ attachments: [],
77
+ onEvent: (event) => {
78
+ emitted.push(event.type);
79
+ },
80
+ sentAt: 0,
81
+ ...message,
82
+ } as QueuedMessage);
83
+ };
84
+ // Every case needs a queued message to act on; the plain typed reply is the
85
+ // common one, and cases that need other content re-enqueue over it.
86
+ enqueue({ requestId: "msg-1", content: "next tuesday works" });
87
+ return {
88
+ abortCount: () => abortCount,
89
+ fake,
90
+ queuedIds: () => [...queue.keys()],
91
+ emittedTypes: () => [...emitted],
92
+ enqueue,
93
+ };
55
94
  }
56
95
 
57
96
  const registeredRequestIds: string[] = [];
58
97
  function registerInteraction(
59
98
  conversationId: string,
60
- kind: "question" | "confirmation",
61
- ): void {
62
- const requestId = `pending-${kind}-${conversationId}`;
63
- pendingInteractions.register(requestId, { conversationId, kind });
99
+ kind: "question" | "confirmation" | "secret",
100
+ options: { requestId?: string; questionIds?: string[] } = {},
101
+ ): { requestId: string; result: () => QuestionPromptResult | undefined } {
102
+ const requestId =
103
+ options.requestId ?? `pending-${kind}-${conversationId}-${Date.now()}`;
104
+ let result: QuestionPromptResult | undefined;
105
+ const questionIds = options.questionIds ?? ["q1"];
106
+ pendingInteractions.register(requestId, {
107
+ conversationId,
108
+ kind,
109
+ ...(kind === "question"
110
+ ? {
111
+ metadata: {
112
+ orderedIds: questionIds,
113
+ optionsById: Object.fromEntries(
114
+ questionIds.map((id) => [id, ["opt-a"]]),
115
+ ),
116
+ },
117
+ rpcResolve: (value: unknown) => {
118
+ result = value as QuestionPromptResult;
119
+ },
120
+ }
121
+ : {}),
122
+ });
64
123
  registeredRequestIds.push(requestId);
124
+ return { requestId, result: () => result };
65
125
  }
66
126
 
67
127
  const QUESTION_CONV = "steer-enqueue-question";
68
128
  const CONFIRMATION_CONV = "steer-enqueue-confirmation";
69
129
  const NONE_CONV = "steer-enqueue-none";
70
130
 
131
+ describe("a typed reply answers a parked question", () => {
132
+ afterEach(() => {
133
+ for (const id of registeredRequestIds) {
134
+ pendingInteractions.resolve(id, "cancelled");
135
+ }
136
+ registeredRequestIds.length = 0;
137
+ deleteConversation(QUESTION_CONV);
138
+ deleteConversation(CONFIRMATION_CONV);
139
+ deleteConversation(NONE_CONV);
140
+ });
141
+
142
+ test("resolves a single parked question with the typed text", () => {
143
+ const conv = registerParkedTurn(QUESTION_CONV);
144
+ const question = registerInteraction(QUESTION_CONV, "question");
145
+
146
+ const answered = answerParkedQuestionWithEnqueuedMessage(
147
+ QUESTION_CONV,
148
+ "msg-1",
149
+ );
150
+
151
+ expect(answered).toBe(true);
152
+ expect(question.result()).toEqual({
153
+ entries: [
154
+ { questionId: "q1", decision: "free_text", text: "next tuesday works" },
155
+ ],
156
+ overall: "completed",
157
+ answeredInChat: true,
158
+ });
159
+ // The parked turn keeps running: no abort, no tool-result repair.
160
+ expect(conv.abortCount()).toBe(0);
161
+ expect(conv.fake.pendingSteerRepair).toBe(false);
162
+ // The message reaches the model through the tool result, so it is consumed
163
+ // rather than run as a turn of its own, and the client's queued row is
164
+ // retired the way a queue delete retires it.
165
+ expect(conv.queuedIds()).toEqual([]);
166
+ expect(conv.emittedTypes()).toEqual(["message_queued_deleted"]);
167
+ });
168
+
169
+ test("answers the first question of a batch and leaves the rest unanswered", () => {
170
+ const conv = registerParkedTurn(QUESTION_CONV);
171
+ const question = registerInteraction(QUESTION_CONV, "question", {
172
+ questionIds: ["q1", "q2", "q3"],
173
+ });
174
+
175
+ const answered = answerParkedQuestionWithEnqueuedMessage(
176
+ QUESTION_CONV,
177
+ "msg-1",
178
+ );
179
+
180
+ expect(answered).toBe(true);
181
+ expect(question.result()).toEqual({
182
+ entries: [
183
+ { questionId: "q1", decision: "free_text", text: "next tuesday works" },
184
+ { questionId: "q2", decision: "skipped" },
185
+ { questionId: "q3", decision: "skipped" },
186
+ ],
187
+ overall: "completed",
188
+ answeredInChat: true,
189
+ });
190
+ expect(conv.abortCount()).toBe(0);
191
+ });
192
+
193
+ test("supersedes instead when the message carries an attachment", () => {
194
+ const conv = registerParkedTurn(QUESTION_CONV);
195
+ const question = registerInteraction(QUESTION_CONV, "question");
196
+ conv.enqueue({
197
+ requestId: "msg-1",
198
+ content: "here it is",
199
+ attachments: [
200
+ {
201
+ id: "att-1",
202
+ filename: "notes.txt",
203
+ mimeType: "text/plain",
204
+ data: "",
205
+ },
206
+ ],
207
+ });
208
+
209
+ supersedePendingInteractionsOnEnqueue(QUESTION_CONV, "msg-1");
210
+
211
+ expect(question.result()).toBeUndefined();
212
+ expect(conv.abortCount()).toBe(1);
213
+ expect(conv.queuedIds()).toEqual(["msg-1"]);
214
+ });
215
+
216
+ test("supersedes instead when the message is a slash command", () => {
217
+ const conv = registerParkedTurn(QUESTION_CONV);
218
+ const question = registerInteraction(QUESTION_CONV, "question");
219
+ conv.enqueue({ requestId: "msg-1", content: "/compact" });
220
+
221
+ supersedePendingInteractionsOnEnqueue(QUESTION_CONV, "msg-1");
222
+
223
+ expect(question.result()).toBeUndefined();
224
+ expect(conv.abortCount()).toBe(1);
225
+ });
226
+
227
+ test("supersedes instead when the message is blank", () => {
228
+ const conv = registerParkedTurn(QUESTION_CONV);
229
+ const question = registerInteraction(QUESTION_CONV, "question");
230
+ conv.enqueue({ requestId: "msg-1", content: " " });
231
+
232
+ supersedePendingInteractionsOnEnqueue(QUESTION_CONV, "msg-1");
233
+
234
+ expect(question.result()).toBeUndefined();
235
+ expect(conv.abortCount()).toBe(1);
236
+ });
237
+
238
+ test("supersedes instead when the send is automated rather than typed", () => {
239
+ const conv = registerParkedTurn(QUESTION_CONV);
240
+ const question = registerInteraction(QUESTION_CONV, "question");
241
+ conv.enqueue({
242
+ requestId: "msg-1",
243
+ content: "scheduled follow-up",
244
+ metadata: { automated: true },
245
+ });
246
+
247
+ supersedePendingInteractionsOnEnqueue(QUESTION_CONV, "msg-1");
248
+
249
+ expect(question.result()).toBeUndefined();
250
+ expect(conv.abortCount()).toBe(1);
251
+ });
252
+
253
+ test("supersedes instead when two questions are parked at once", () => {
254
+ // Which prompt the text answers would be a guess, so the old behavior
255
+ // stands: abort the turn and run the message as its own turn.
256
+ const conv = registerParkedTurn(QUESTION_CONV);
257
+ const first = registerInteraction(QUESTION_CONV, "question", {
258
+ requestId: "question-a",
259
+ });
260
+ const second = registerInteraction(QUESTION_CONV, "question", {
261
+ requestId: "question-b",
262
+ });
263
+
264
+ supersedePendingInteractionsOnEnqueue(QUESTION_CONV, "msg-1");
265
+
266
+ expect(first.result()).toBeUndefined();
267
+ expect(second.result()).toBeUndefined();
268
+ expect(conv.abortCount()).toBe(1);
269
+ });
270
+
271
+ test("leaves a parked secret alone while answering the question", () => {
272
+ const conv = registerParkedTurn(QUESTION_CONV);
273
+ const secret = registerInteraction(QUESTION_CONV, "secret");
274
+ const question = registerInteraction(QUESTION_CONV, "question");
275
+
276
+ supersedePendingInteractionsOnEnqueue(QUESTION_CONV, "msg-1");
277
+
278
+ expect(question.result()?.overall).toBe("completed");
279
+ expect(conv.abortCount()).toBe(0);
280
+ expect(pendingInteractions.get(secret.requestId)?.kind).toBe("secret");
281
+ });
282
+
283
+ test("does not answer from a pending confirmation (not a question)", () => {
284
+ const conv = registerParkedTurn(CONFIRMATION_CONV);
285
+ registerInteraction(CONFIRMATION_CONV, "confirmation");
286
+
287
+ const answered = answerParkedQuestionWithEnqueuedMessage(
288
+ CONFIRMATION_CONV,
289
+ "msg-1",
290
+ );
291
+ const steered = steerOnEnqueuedMessageIfQuestionParked(
292
+ CONFIRMATION_CONV,
293
+ "msg-1",
294
+ );
295
+
296
+ expect(answered).toBe(false);
297
+ expect(steered).toBe(false);
298
+ expect(conv.abortCount()).toBe(0);
299
+ // The message is left on the queue to run as its own turn.
300
+ expect(conv.queuedIds()).toEqual(["msg-1"]);
301
+ });
302
+
303
+ test("does nothing when no prompt is parked", () => {
304
+ const conv = registerParkedTurn(NONE_CONV);
305
+
306
+ supersedePendingInteractionsOnEnqueue(NONE_CONV, "msg-1");
307
+
308
+ expect(conv.abortCount()).toBe(0);
309
+ expect(conv.queuedIds()).toEqual(["msg-1"]);
310
+ expect(conv.emittedTypes()).toEqual([]);
311
+ });
312
+ });
313
+
71
314
  describe("steerOnEnqueuedMessageIfQuestionParked", () => {
72
315
  afterEach(() => {
73
316
  for (const id of registeredRequestIds) {
@@ -134,27 +377,15 @@ describe("steerOnEnqueuedMessageIfQuestionParked", () => {
134
377
  expect(steered).toBe(false);
135
378
  expect(conv.abortCount()).toBe(0);
136
379
  });
137
-
138
- test("supersedePendingInteractionsOnEnqueue steers a parked question", () => {
139
- // The centralized routine is invoked by both the HTTP send handler and the
140
- // CLI signal path. With no pending confirmation it steers a parked question
141
- // — the behavior the CLI signal path previously lacked.
142
- const conv = registerParkedTurn(QUESTION_CONV);
143
- registerInteraction(QUESTION_CONV, "question");
144
-
145
- supersedePendingInteractionsOnEnqueue(QUESTION_CONV, "msg-1");
146
-
147
- expect(conv.abortCount()).toBe(1);
148
- expect(conv.fake.pendingSteerRepair).toBe(true);
149
- });
150
380
  });
151
381
 
152
382
  describe("removeByConversation preserves question interactions", () => {
153
383
  // The enqueue path's confirmation auto-deny calls removeByConversation before
154
- // the steer runs. Questions must survive it — they are settled instead by the
155
- // steer's turn abort — or, when an ask_question and a confirmation are pending
156
- // concurrently, the queued message would strand behind a question whose entry
157
- // was cleared (and whose Promise was never settled) before the steer fired.
384
+ // the question is answered or steered to. Questions must survive it (they
385
+ // are settled instead by the answer or by the steer's turn abort) or, when
386
+ // an ask_question and a confirmation are pending concurrently, the queued
387
+ // message would strand behind a question whose entry was cleared (and whose
388
+ // Promise was never settled) before either path fired.
158
389
  const CONV = "remove-by-conv-preserve-question";
159
390
  const ids: string[] = [];
160
391
  function register(kind: "question" | "confirmation"): void {