@vellumai/assistant 0.8.12-dev.202606131247.4d64b75 → 0.8.12-dev.202606131450.c6f2ffd

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
@@ -10561,6 +10561,8 @@ paths:
10561
10561
  - hostPattern
10562
10562
  - injectionType
10563
10563
  additionalProperties: false
10564
+ conversationId:
10565
+ type: string
10564
10566
  required:
10565
10567
  - service
10566
10568
  - field
@@ -20904,7 +20906,6 @@ paths:
20904
20906
  type: string
20905
20907
  required:
20906
20908
  - requestId
20907
- - conversationId
20908
20909
  - kind
20909
20910
  additionalProperties: false
20910
20911
  description: All pending interactions (returned when no filters given)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.8.12-dev.202606131247.4d64b75",
3
+ "version": "0.8.12-dev.202606131450.c6f2ffd",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -8,6 +8,7 @@ import { beforeEach, describe, expect, mock, test } from "bun:test";
8
8
 
9
9
  import type { Conversation } from "../daemon/conversation.js";
10
10
  import type { ServerMessage } from "../daemon/message-protocol.js";
11
+ import type { SecretPromptResult } from "../permissions/secret-prompter.js";
11
12
 
12
13
  mock.module("../util/logger.js", () => ({
13
14
  getLogger: () =>
@@ -196,6 +197,7 @@ function makeIdleSession(opts?: {
196
197
  pendingInteractions.resolve(requestId);
197
198
  opts?.onConfirmation?.(requestId, decision);
198
199
  },
200
+ hasPendingSecret: () => true,
199
201
  handleSecretResponse: (
200
202
  requestId: string,
201
203
  value?: string,
@@ -593,6 +595,54 @@ describe("standalone approval endpoints — HTTP layer", () => {
593
595
  await stopServer();
594
596
  });
595
597
 
598
+ test("resolves a conversation-less secret request via its resolver", async () => {
599
+ /**
600
+ * The CLI `credentials prompt` command registers a conversation-less
601
+ * secret interaction whose resolver lives in pendingInteractions. POST
602
+ * /v1/secret must drive that resolver directly — with no owning
603
+ * conversation — so the standalone prompt completes instead of only
604
+ * timing out.
605
+ */
606
+ // GIVEN a running server
607
+ await startServer(() => makeIdleSession());
608
+
609
+ // AND a conversation-less secret interaction whose resolver is captured
610
+ let resolved: SecretPromptResult | undefined;
611
+ pendingInteractions.register("standalone-secret-1", {
612
+ kind: "secret",
613
+ rpcResolve: (value: unknown) => {
614
+ resolved = value as SecretPromptResult;
615
+ },
616
+ });
617
+
618
+ // WHEN the secret is submitted for that requestId
619
+ const res = await fetch(url("secret"), {
620
+ method: "POST",
621
+ headers: { "Content-Type": "application/json", ...AUTH_HEADERS },
622
+ body: JSON.stringify({
623
+ requestId: "standalone-secret-1",
624
+ value: "cli-secret-value",
625
+ delivery: "store",
626
+ }),
627
+ });
628
+ const body = (await res.json()) as { accepted: boolean };
629
+
630
+ // THEN the request is accepted
631
+ expect(res.status).toBe(200);
632
+ expect(body.accepted).toBe(true);
633
+
634
+ // AND the standalone resolver receives the value and delivery
635
+ expect(resolved).toEqual({
636
+ value: "cli-secret-value",
637
+ delivery: "store",
638
+ });
639
+
640
+ // AND the interaction is removed after resolution
641
+ expect(pendingInteractions.get("standalone-secret-1")).toBeUndefined();
642
+
643
+ await stopServer();
644
+ });
645
+
596
646
  test("returns 404 for unknown requestId", async () => {
597
647
  await startServer(() => makeIdleSession());
598
648
 
@@ -638,6 +688,47 @@ describe("standalone approval endpoints — HTTP layer", () => {
638
688
 
639
689
  await stopServer();
640
690
  });
691
+
692
+ test("rejects a non-secret requestId without consuming it", async () => {
693
+ /**
694
+ * /v1/secret only settles secret prompts. A confirmation (or any other
695
+ * interaction kind) posted here from stale client state must be rejected
696
+ * so its real approval endpoint still finds an intact pending interaction
697
+ * rather than one consumed and resolved with a SecretPromptResult.
698
+ */
699
+ // GIVEN a running server
700
+ await startServer(() => makeIdleSession());
701
+
702
+ // AND a pending confirmation interaction whose resolver would be corrupted
703
+ let resolverCalled = false;
704
+ pendingInteractions.register("confirm-not-secret", {
705
+ conversationId: "conv-1",
706
+ kind: "confirmation",
707
+ rpcResolve: () => {
708
+ resolverCalled = true;
709
+ },
710
+ });
711
+
712
+ // WHEN that confirmation's requestId is posted to /v1/secret
713
+ const res = await fetch(url("secret"), {
714
+ method: "POST",
715
+ headers: { "Content-Type": "application/json", ...AUTH_HEADERS },
716
+ body: JSON.stringify({
717
+ requestId: "confirm-not-secret",
718
+ value: "leaked",
719
+ delivery: "store",
720
+ }),
721
+ });
722
+
723
+ // THEN the request is rejected
724
+ expect(res.status).toBe(404);
725
+
726
+ // AND the confirmation interaction is left intact and unresolved
727
+ expect(pendingInteractions.get("confirm-not-secret")).toBeDefined();
728
+ expect(resolverCalled).toBe(false);
729
+
730
+ await stopServer();
731
+ });
641
732
  });
642
733
 
643
734
  // ── POST /v1/trust-rules ─────────────────────────────────────────────
@@ -102,6 +102,28 @@ describe("pendingInteractions.resolve emits interaction_resolved", () => {
102
102
  expect(publishedMessages).toHaveLength(0);
103
103
  });
104
104
 
105
+ test("no event is emitted for a conversation-less interaction", () => {
106
+ /**
107
+ * Conversation-less interactions (e.g. the CLI `credentials prompt`
108
+ * command) resolve through their own resolver rather than a conversation.
109
+ * The `interaction_resolved` envelope requires a conversationId, so the
110
+ * tracker skips the broadcast instead of emitting an invalid event.
111
+ */
112
+ // GIVEN a registered interaction with no owning conversation
113
+ pendingInteractions.register("req-detached", { kind: "secret" });
114
+
115
+ // WHEN it is resolved
116
+ const returned = pendingInteractions.resolve("req-detached", "answered");
117
+
118
+ // THEN the entry is still returned to its caller
119
+ expect(returned).toBeDefined();
120
+
121
+ // AND no interaction_resolved envelope is published
122
+ expect(
123
+ publishedMessages.filter((m) => m.type === "interaction_resolved"),
124
+ ).toHaveLength(0);
125
+ });
126
+
105
127
  test("a single resolve emits exactly one event", () => {
106
128
  pendingInteractions.register("req-once", {
107
129
  conversationId: "conv-e",
@@ -186,4 +208,44 @@ describe("removeByConversation emits interaction_resolved per entry", () => {
186
208
  .state,
187
209
  ).toBe("cancelled");
188
210
  });
211
+
212
+ test("settles a swept secret prompt's resolver with a cancelled result", () => {
213
+ /**
214
+ * A secret prompt blocks its caller (the CLI `credentials prompt` command
215
+ * or the in-conversation SecretPrompter) on `rpcResolve`. Unlike questions
216
+ * (abort-signal teardown) and confirmations (denyAllPendingConfirmations),
217
+ * nothing else settles a secret when it is superseded, so removing the
218
+ * entry alone would hang the caller until its IPC client times out.
219
+ */
220
+ // GIVEN a pending secret whose caller is blocked on rpcResolve
221
+ const resolved: unknown[] = [];
222
+ pendingInteractions.register("secret-sweep", {
223
+ conversationId: "conv-sweep",
224
+ kind: "secret",
225
+ rpcResolve: (value) => resolved.push(value),
226
+ });
227
+
228
+ // WHEN a new user message supersedes the conversation's interactions
229
+ pendingInteractions.removeByConversation("conv-sweep");
230
+
231
+ // THEN the secret resolver is settled once with a cancelled result
232
+ expect(resolved).toEqual([{ value: null, delivery: "store" }]);
233
+ });
234
+
235
+ test("does not invoke a swept confirmation's resolver with a secret result", () => {
236
+ // GIVEN a pending confirmation carrying an rpcResolve callback
237
+ const resolved: unknown[] = [];
238
+ pendingInteractions.register("conf-sweep", {
239
+ conversationId: "conv-conf",
240
+ kind: "confirmation",
241
+ rpcResolve: (value) => resolved.push(value),
242
+ });
243
+
244
+ // WHEN the conversation is superseded
245
+ pendingInteractions.removeByConversation("conv-conf");
246
+
247
+ // THEN the confirmation resolver is left untouched — only secret prompts
248
+ // are settled with a SecretPromptResult-shaped value here.
249
+ expect(resolved).toHaveLength(0);
250
+ });
189
251
  });
@@ -138,6 +138,7 @@ describe("SSE HTTP parity — streaming/delta message types", () => {
138
138
  type: "assistant_thinking_delta" as const,
139
139
  thinking: "Let me reason through this...",
140
140
  conversationId: "conv-thinking-test",
141
+ timestampMs: 1_700_000_000_000,
141
142
  };
142
143
  const event = await publishAndReadFrame("parity-thinking-delta", msg);
143
144
 
@@ -145,6 +146,7 @@ describe("SSE HTTP parity — streaming/delta message types", () => {
145
146
  const m = event.message as typeof msg;
146
147
  expect(m.thinking).toBe("Let me reason through this...");
147
148
  expect(m.conversationId).toBe("conv-thinking-test");
149
+ expect(m.timestampMs).toBe(1_700_000_000_000);
148
150
  });
149
151
 
150
152
  // ── tool_input_delta ─────────────────────────────────────────────────────
@@ -12,6 +12,13 @@
12
12
  * Absent on streams produced by older daemons that pre-date the anchor
13
13
  * protocol.
14
14
  *
15
+ * `timestampMs` is the daemon's wall-clock emission time for the delta. The
16
+ * transport envelope (`AssistantEvent.emittedAt`) carries the same instant,
17
+ * but it is dropped once the inner message is unwrapped for persistence,
18
+ * debug buffers, and backend storage — so the timestamp is carried on the
19
+ * payload itself to make per-delta timing observable wherever the message
20
+ * travels.
21
+ *
15
22
  * Only emitted when thinking streaming is enabled for the turn; turns that
16
23
  * suppress reasoning output produce none.
17
24
  *
@@ -26,6 +33,9 @@ export const AssistantThinkingDeltaEventSchema = z.object({
26
33
  thinking: z.string(),
27
34
  messageId: z.string().optional(),
28
35
  conversationId: z.string().optional(),
36
+ /** Epoch milliseconds (`Date.now()`) at which the daemon emitted this
37
+ * delta. Absent on streams produced by daemons that pre-date this field. */
38
+ timestampMs: z.number().optional(),
29
39
  });
30
40
 
31
41
  export type AssistantThinkingDeltaEvent = z.infer<
@@ -5,6 +5,7 @@ import type { CredentialPromptResult } from "../../runtime/routes/credential-pro
5
5
  import { registerCommand } from "../lib/register-command.js";
6
6
  import { log } from "../logger.js";
7
7
  import { shouldOutputJson, writeOutput } from "../output.js";
8
+ import { tryResolveConversationId } from "../utils/conversation-id.js";
8
9
 
9
10
  // ---------------------------------------------------------------------------
10
11
  // Format-aware error output
@@ -114,8 +115,7 @@ export function registerCredentialsCommand(program: Command): void {
114
115
  description:
115
116
  "Manage credentials in the encrypted vault (API keys, tokens, passwords)",
116
117
  build: (credential) => {
117
- credential
118
- .option("--json", "Machine-readable compact JSON output");
118
+ credential.option("--json", "Machine-readable compact JSON output");
119
119
 
120
120
  credential.addHelpText(
121
121
  "after",
@@ -224,9 +224,7 @@ Examples:
224
224
 
225
225
  credential
226
226
  .command("status")
227
- .description(
228
- "Show the active credential backend and its configuration",
229
- )
227
+ .description("Show the active credential backend and its configuration")
230
228
  .addHelpText(
231
229
  "after",
232
230
  `
@@ -248,9 +246,8 @@ Examples:
248
246
  $ assistant credentials status --json`,
249
247
  )
250
248
  .action(async (_opts: Record<string, unknown>, cmd: Command) => {
251
- const r = await cliIpcCall<CredentialsStatusResponse>(
252
- "credentials_status",
253
- );
249
+ const r =
250
+ await cliIpcCall<CredentialsStatusResponse>("credentials_status");
254
251
  if (!r.ok) {
255
252
  return exitFromIpcResult(
256
253
  r as { ok: false; error?: string; statusCode?: number },
@@ -286,7 +283,10 @@ Examples:
286
283
  credential
287
284
  .command("set <value>")
288
285
  .description("Store a secret and create or update its metadata")
289
- .requiredOption("--service <service>", "Service namespace (e.g. google)")
286
+ .requiredOption(
287
+ "--service <service>",
288
+ "Service namespace (e.g. google)",
289
+ )
290
290
  .requiredOption("--field <field>", "Field name (e.g. client_secret)")
291
291
  .option("--label <label>", 'Human-friendly label (e.g. "prod", "work")')
292
292
  .option(
@@ -345,7 +345,8 @@ Examples:
345
345
  if (!r.ok) {
346
346
  writeError(
347
347
  cmd,
348
- r.error ?? `Failed to store credential ${opts.service}:${opts.field}`,
348
+ r.error ??
349
+ `Failed to store credential ${opts.service}:${opts.field}`,
349
350
  );
350
351
  process.exitCode = 1;
351
352
  return;
@@ -397,7 +398,8 @@ Examples:
397
398
  if (!r.ok) {
398
399
  writeError(
399
400
  cmd,
400
- r.error ?? `Failed to delete credential ${opts.service}:${opts.field}`,
401
+ r.error ??
402
+ `Failed to delete credential ${opts.service}:${opts.field}`,
401
403
  );
402
404
  process.exitCode = 1;
403
405
  return;
@@ -410,9 +412,7 @@ Examples:
410
412
  field: opts.field,
411
413
  });
412
414
  } else {
413
- log.info(
414
- `Deleted credential ${opts.service}:${opts.field}`,
415
- );
415
+ log.info(`Deleted credential ${opts.service}:${opts.field}`);
416
416
  }
417
417
  },
418
418
  );
@@ -666,6 +666,7 @@ Examples:
666
666
  allowedDomains,
667
667
  allowedTools,
668
668
  injectionTemplates,
669
+ conversationId: tryResolveConversationId(),
669
670
  },
670
671
  },
671
672
  { timeoutMs: PROMPT_TIMEOUT_MS },
@@ -681,10 +682,7 @@ Examples:
681
682
  }
682
683
 
683
684
  if (!ipc.result?.ok) {
684
- writeError(
685
- cmd,
686
- ipc.result?.error ?? "Credential prompt failed",
687
- );
685
+ writeError(cmd, ipc.result?.error ?? "Credential prompt failed");
688
686
  process.exitCode = 1;
689
687
  return;
690
688
  }
@@ -867,6 +867,7 @@ function handleThinkingDelta(
867
867
  thinking: event.thinking,
868
868
  conversationId: deps.ctx.conversationId,
869
869
  messageId: state.lastAssistantMessageId,
870
+ timestampMs: Date.now(),
870
871
  });
871
872
  // Mirror thinking into the same running view as text so the debounced
872
873
  // partial flush persists it mid-turn -- long reasoning streams survive a
@@ -13,6 +13,7 @@ import type { SecretPromptResult } from "../../permissions/secret-prompter.js";
13
13
  import { isPlaceholderSentinelText } from "../../providers/placeholder-sentinels.js";
14
14
  import { broadcastMessage } from "../../runtime/assistant-event-hub.js";
15
15
  import type { AuthContext } from "../../runtime/auth/types.js";
16
+ import * as pendingInteractions from "../../runtime/pending-interactions.js";
16
17
  import { unwrapExternalContentForDisplay } from "../../security/untrusted-content.js";
17
18
  import { getLogger } from "../../util/logger.js";
18
19
  import { estimateBase64Bytes } from "../assistant-attachments.js";
@@ -28,15 +29,6 @@ export const CONFIG_RELOAD_DEBOUNCE_MS = 300;
28
29
 
29
30
  const HISTORY_ATTACHMENT_TEXT_LIMIT = 500;
30
31
 
31
- // Module-level map for non-conversation secret prompts (e.g. publish_page)
32
- const pendingStandaloneSecrets = new Map<
33
- string,
34
- {
35
- resolve: (result: SecretPromptResult) => void;
36
- timer: ReturnType<typeof setTimeout>;
37
- }
38
- >();
39
-
40
32
  /**
41
33
  * A single tool call rendered into a history row. Alias of the canonical
42
34
  * wire-contract type so `renderHistoryContent` (the producer) cannot drift
@@ -704,9 +696,15 @@ export function renderHistoryContent(
704
696
  }
705
697
 
706
698
  /**
707
- * Send a `secret_request` to the client and wait for the response,
708
- * outside of a conversation context (e.g. from IPC routes like
709
- * credentials/prompt).
699
+ * Send a `secret_request` to the client and wait for the response, outside of a
700
+ * conversation context (e.g. from IPC routes like credentials/prompt).
701
+ *
702
+ * Lifecycle state (resolver, timer) is registered in pendingInteractions — the
703
+ * same tracker the in-conversation SecretPrompter uses — so `POST /v1/secret`
704
+ * resolves the prompt generically. When a `conversationId` is supplied (the CLI
705
+ * `credentials prompt` command forwards `__CONVERSATION_ID`), the broadcast is
706
+ * scoped to that conversation so clients deliver it; otherwise it is
707
+ * conversation-less.
710
708
  */
711
709
  export function requestSecretStandalone(params: {
712
710
  service: string;
@@ -717,15 +715,21 @@ export function requestSecretStandalone(params: {
717
715
  purpose?: string;
718
716
  allowedTools?: string[];
719
717
  allowedDomains?: string[];
718
+ conversationId?: string;
720
719
  }): Promise<SecretPromptResult> {
721
720
  const requestId = uuid();
722
721
  const config = getConfig();
723
722
  return new Promise((resolve) => {
724
723
  const timer = setTimeout(() => {
725
- pendingStandaloneSecrets.delete(requestId);
724
+ pendingInteractions.resolve(requestId, "cancelled");
726
725
  resolve({ value: null, delivery: "store" });
727
726
  }, config.timeouts.permissionTimeoutSec * 1000);
728
- pendingStandaloneSecrets.set(requestId, { resolve, timer });
727
+ pendingInteractions.register(requestId, {
728
+ conversationId: params.conversationId,
729
+ kind: "secret",
730
+ rpcResolve: resolve as (value: unknown) => void,
731
+ timer,
732
+ });
729
733
  broadcastMessage({
730
734
  type: "secret_request",
731
735
  requestId,
@@ -734,6 +738,7 @@ export function requestSecretStandalone(params: {
734
738
  label: params.label,
735
739
  description: params.description,
736
740
  placeholder: params.placeholder,
741
+ conversationId: params.conversationId,
737
742
  purpose: params.purpose,
738
743
  allowedTools: params.allowedTools,
739
744
  allowedDomains: params.allowedDomains,
@@ -473,19 +473,22 @@ export class HostCuProxy {
473
473
  for (const requestId of this._ownedRequests) {
474
474
  const entry = pendingInteractions.resolve(requestId, "cancelled");
475
475
  if (!entry) continue;
476
+ const { conversationId } = entry;
476
477
  try {
477
- broadcastMessage(
478
- {
479
- type: "host_cu_cancel",
480
- requestId,
481
- conversationId: entry.conversationId,
482
- ...(entry.targetClientId != null
483
- ? { targetClientId: entry.targetClientId }
484
- : {}),
485
- },
486
- entry.conversationId,
487
- { targetClientId: entry.targetClientId as string | undefined },
488
- );
478
+ if (conversationId !== undefined) {
479
+ broadcastMessage(
480
+ {
481
+ type: "host_cu_cancel",
482
+ requestId,
483
+ conversationId,
484
+ ...(entry.targetClientId != null
485
+ ? { targetClientId: entry.targetClientId }
486
+ : {}),
487
+ },
488
+ conversationId,
489
+ { targetClientId: entry.targetClientId as string | undefined },
490
+ );
491
+ }
489
492
  } catch {
490
493
  // Best-effort cancel notification
491
494
  }
@@ -277,19 +277,22 @@ export class HostFileProxy {
277
277
  dispose(): void {
278
278
  for (const entry of pendingInteractions.getByKind("host_file")) {
279
279
  pendingInteractions.resolve(entry.requestId, "cancelled");
280
+ const { conversationId } = entry;
280
281
  try {
281
- broadcastMessage(
282
- {
283
- type: "host_file_cancel",
284
- requestId: entry.requestId,
285
- conversationId: entry.conversationId,
286
- ...(entry.targetClientId != null
287
- ? { targetClientId: entry.targetClientId }
288
- : {}),
289
- },
290
- entry.conversationId,
291
- { targetClientId: entry.targetClientId as string | undefined },
292
- );
282
+ if (conversationId !== undefined) {
283
+ broadcastMessage(
284
+ {
285
+ type: "host_file_cancel",
286
+ requestId: entry.requestId,
287
+ conversationId,
288
+ ...(entry.targetClientId != null
289
+ ? { targetClientId: entry.targetClientId }
290
+ : {}),
291
+ },
292
+ conversationId,
293
+ { targetClientId: entry.targetClientId as string | undefined },
294
+ );
295
+ }
293
296
  } catch {
294
297
  // Best-effort cancel notification
295
298
  }
@@ -663,19 +663,22 @@ export class HostTransferProxy {
663
663
  const transferId = interaction.metadata?.transferId as string | undefined;
664
664
  if (transferId) this.transfers.delete(transferId);
665
665
  pendingInteractions.resolve(requestId, "cancelled");
666
+ const { conversationId } = interaction;
666
667
  try {
667
- broadcastMessage(
668
- {
669
- type: "host_transfer_cancel",
670
- requestId,
671
- conversationId: interaction.conversationId,
672
- ...(interaction.targetClientId != null
673
- ? { targetClientId: interaction.targetClientId }
674
- : {}),
675
- },
676
- interaction.conversationId,
677
- { targetClientId: interaction.targetClientId },
678
- );
668
+ if (conversationId !== undefined) {
669
+ broadcastMessage(
670
+ {
671
+ type: "host_transfer_cancel",
672
+ requestId,
673
+ conversationId,
674
+ ...(interaction.targetClientId != null
675
+ ? { targetClientId: interaction.targetClientId }
676
+ : {}),
677
+ },
678
+ conversationId,
679
+ { targetClientId: interaction.targetClientId },
680
+ );
681
+ }
679
682
  } catch {
680
683
  // Best-effort cancel notification
681
684
  }
@@ -719,19 +722,22 @@ export class HostTransferProxy {
719
722
  const transferId = entry.metadata?.transferId as string | undefined;
720
723
  if (transferId) this.transfers.delete(transferId);
721
724
  pendingInteractions.resolve(entry.requestId, "cancelled");
725
+ const { conversationId } = entry;
722
726
  try {
723
- broadcastMessage(
724
- {
725
- type: "host_transfer_cancel",
726
- requestId: entry.requestId,
727
- conversationId: entry.conversationId,
728
- ...(entry.targetClientId != null
729
- ? { targetClientId: entry.targetClientId }
730
- : {}),
731
- },
732
- entry.conversationId,
733
- { targetClientId: entry.targetClientId },
734
- );
727
+ if (conversationId !== undefined) {
728
+ broadcastMessage(
729
+ {
730
+ type: "host_transfer_cancel",
731
+ requestId: entry.requestId,
732
+ conversationId,
733
+ ...(entry.targetClientId != null
734
+ ? { targetClientId: entry.targetClientId }
735
+ : {}),
736
+ },
737
+ conversationId,
738
+ { targetClientId: entry.targetClientId },
739
+ );
740
+ }
735
741
  } catch {
736
742
  // Best-effort cancel notification
737
743
  }
@@ -2,6 +2,7 @@
2
2
 
3
3
  import type { AssistantActivityStateEvent } from "../../api/events/assistant-activity-state.js";
4
4
  import type { AssistantTextDeltaEvent } from "../../api/events/assistant-text-delta.js";
5
+ import type { AssistantThinkingDeltaEvent } from "../../api/events/assistant-thinking-delta.js";
5
6
  import type { AssistantTurnStartEvent } from "../../api/events/assistant-turn-start.js";
6
7
  import type { ConfirmationRequestEvent } from "../../api/events/confirmation-request.js";
7
8
  import type { ErrorEvent } from "../../api/events/error.js";
@@ -76,15 +77,6 @@ export interface SuggestionRequest {
76
77
 
77
78
  // === Server → Client ===
78
79
 
79
- export interface AssistantThinkingDelta {
80
- type: "assistant_thinking_delta";
81
- thinking: string;
82
- conversationId?: string;
83
- /** Database ID of the assistant message this thinking delta belongs to.
84
- * Same semantics as `AssistantTextDeltaEvent.messageId`. */
85
- messageId?: string;
86
- }
87
-
88
80
  export interface ToolInputDelta {
89
81
  type: "tool_input_delta";
90
82
  toolName: string;
@@ -160,7 +152,7 @@ export type _MessagesServerMessages =
160
152
  | UserMessageEchoEvent
161
153
  | AssistantTurnStartEvent
162
154
  | AssistantTextDeltaEvent
163
- | AssistantThinkingDelta
155
+ | AssistantThinkingDeltaEvent
164
156
  | ToolUseStartEvent
165
157
  | ToolUsePreviewStartEvent
166
158
  | ToolOutputChunkEvent
@@ -51,7 +51,12 @@ export interface ConfirmationDetails {
51
51
  }
52
52
 
53
53
  export interface PendingInteraction {
54
- conversationId: string;
54
+ /**
55
+ * Owning conversation, when the interaction was raised inside one. Absent
56
+ * for interactions raised outside any conversation (e.g. the CLI
57
+ * `credentials prompt` command), which resolve via {@link rpcResolve}.
58
+ */
59
+ conversationId?: string;
55
60
  kind:
56
61
  | "confirmation"
57
62
  | "secret"
@@ -140,6 +145,10 @@ function emitResolved(
140
145
  },
141
146
  "Pending interaction resolved",
142
147
  );
148
+ // interaction_resolved is conversation-scoped on the wire; a conversation-less
149
+ // interaction has no conversation for clients to route the event to, so skip
150
+ // the broadcast.
151
+ if (interaction.conversationId === undefined) return;
143
152
  broadcastMessage({
144
153
  type: "interaction_resolved",
145
154
  requestId,
@@ -204,6 +213,16 @@ export function removeByConversation(
204
213
  ) {
205
214
  // resolve() clears the stored timer and detaches abort listeners.
206
215
  resolve(requestId, state);
216
+ // Secret prompts have no abort-signal teardown (unlike questions) and
217
+ // are not pre-settled by denyAllPendingConfirmations (unlike
218
+ // confirmations), so removing the entry alone would leave the caller's
219
+ // Promise — the CLI `credentials prompt` command or the in-conversation
220
+ // SecretPrompter — hanging until its IPC client times out. Settle it
221
+ // with a cancelled result, matching the prompt timeout path. rpcResolve
222
+ // is idempotent, so any later resolveSecret/dispose call is a no-op.
223
+ if (interaction.kind === "secret") {
224
+ interaction.rpcResolve?.({ value: null, delivery: "store" });
225
+ }
207
226
  }
208
227
  }
209
228
  }
@@ -11,6 +11,10 @@ import { z } from "zod";
11
11
 
12
12
  import { findConversation } from "../../daemon/conversation-registry.js";
13
13
  import { getConversationByKey } from "../../memory/conversation-key-store.js";
14
+ import type {
15
+ SecretDelivery,
16
+ SecretPromptResult,
17
+ } from "../../permissions/secret-prompter.js";
14
18
  import type { UserDecision } from "../../permissions/types.js";
15
19
  import { getLogger } from "../../util/logger.js";
16
20
  import { ACTOR_PRINCIPALS } from "../auth/route-policy.js";
@@ -127,24 +131,47 @@ function handleSecret({ body }: RouteHandlerArgs) {
127
131
  throw new BadRequestError('delivery must be "store" or "transient_send"');
128
132
  }
129
133
 
130
- // Use get() — SecretPrompter.resolveSecret() owns deregistration.
131
134
  const interaction = pendingInteractions.get(requestId);
132
135
  if (!interaction) {
133
136
  throw new NotFoundError("No pending interaction found for this requestId");
134
137
  }
135
138
 
136
- const conversation = findConversation(interaction.conversationId);
137
- if (!conversation) {
139
+ // /v1/secret only settles secret prompts. A requestId belonging to another
140
+ // interaction kind (a confirmation or host-proxy request posted here from
141
+ // stale or mismatched client state) must not be consumed or resolved with a
142
+ // SecretPromptResult, which would strand its real approval/result endpoint.
143
+ if (interaction.kind !== "secret") {
138
144
  throw new NotFoundError(
139
- "Conversation not found for this pending secret request",
145
+ "No pending secret request found for this requestId",
140
146
  );
141
147
  }
142
148
 
143
- conversation.handleSecretResponse(
149
+ // When a live conversation owns the request, route through it so the
150
+ // SecretPrompter's ownership tracking and dispose path stay consistent (this
151
+ // also drives the voice auto-resolve path). The prompter owns deregistration.
152
+ const conversation = interaction.conversationId
153
+ ? findConversation(interaction.conversationId)
154
+ : undefined;
155
+ if (conversation?.hasPendingSecret(requestId)) {
156
+ conversation.handleSecretResponse(
157
+ requestId,
158
+ value,
159
+ delivery as "store" | "transient_send" | undefined,
160
+ );
161
+ return { accepted: true };
162
+ }
163
+
164
+ // Conversation-less requests (e.g. the CLI `credentials prompt` command) and
165
+ // any request no live conversation owns resolve generically via the resolver
166
+ // stored on the interaction, with no Conversation in the loop.
167
+ const resolved = pendingInteractions.resolve(
144
168
  requestId,
145
- value,
146
- delivery as "store" | "transient_send" | undefined,
169
+ value === undefined ? "cancelled" : "answered",
147
170
  );
171
+ (resolved?.rpcResolve as ((r: SecretPromptResult) => void) | undefined)?.({
172
+ value: value ?? null,
173
+ delivery: (delivery as SecretDelivery) ?? "store",
174
+ });
148
175
  return { accepted: true };
149
176
  }
150
177
 
@@ -321,7 +348,7 @@ export const ROUTES: RouteDefinition[] = [
321
348
  .array(
322
349
  z.object({
323
350
  requestId: z.string(),
324
- conversationId: z.string(),
351
+ conversationId: z.string().optional(),
325
352
  kind: z.string(),
326
353
  toolName: z.string().optional(),
327
354
  riskLevel: z.string().optional(),
@@ -40,6 +40,7 @@ const CredentialPromptParams = z.object({
40
40
  allowedDomains: z.array(z.string()).optional(),
41
41
  allowedTools: z.array(z.string()).optional(),
42
42
  injectionTemplates: z.array(InjectionTemplateSchema).optional(),
43
+ conversationId: z.string().optional(),
43
44
  });
44
45
 
45
46
  // ---------------------------------------------------------------------------
@@ -70,6 +71,7 @@ async function handleCredentialPrompt({ body = {} }: RouteHandlerArgs) {
70
71
  placeholder: validated.placeholder,
71
72
  allowedTools: validated.allowedTools,
72
73
  allowedDomains: validated.allowedDomains,
74
+ conversationId: validated.conversationId,
73
75
  });
74
76
 
75
77
  if (!result.value) {