diff --git a/apps/web/src/domains/chat/api/event-types.ts b/apps/web/src/domains/chat/api/event-types.ts index 17bf8254516..4652dcdcba1 100644 --- a/apps/web/src/domains/chat/api/event-types.ts +++ b/apps/web/src/domains/chat/api/event-types.ts @@ -12,9 +12,9 @@ import type { AllowlistOption, DirectoryScopeOption, QuestionEntry, + QuestionRequestEvent, ScopeOption, -} from "@/types/interaction-ui-types"; -import type { QuestionRequestEvent } from "@/types/event-types"; +} from "@vellumai/assistant-api"; /** Data needed to render an inline permission prompt inside a ToolCallChip. */ export interface PendingToolConfirmation { diff --git a/apps/web/src/domains/chat/utils/chat.ts b/apps/web/src/domains/chat/utils/chat.ts index 4790fbe3869..5c99ff36115 100644 --- a/apps/web/src/domains/chat/utils/chat.ts +++ b/apps/web/src/domains/chat/utils/chat.ts @@ -2,12 +2,15 @@ import type { DisplayMessage } from "@/domains/chat/types/types"; import type { AssistantIdentity } from "@/assistant/identity"; import type { Conversation } from "@/types/conversation-types"; import type { AssistantEvent } from "@/types/event-types"; -import type { AllowlistOption, DirectoryScopeOption, ScopeOption } from "@/types/interaction-ui-types"; +import type { + AllowlistOption, + DirectoryScopeOption, + ScopeOption, +} from "@/types/interaction-ui-types"; import type { PendingToolConfirmation } from "@/domains/chat/api/event-types"; export const ERROR_MESSAGES: Record = { - rate_limit_exceeded: - "Too many requests. Please wait a moment and try again.", + rate_limit_exceeded: "Too many requests. Please wait a moment and try again.", invalid_api_key: "The API key for this provider is invalid or expired. Please check your settings.", }; @@ -22,6 +25,11 @@ const GLOBAL_STREAM_EVENT_TYPE_NAMES = [ "disk_pressure_status_changed", "home_feed_updated", "relationship_state_updated", + // Workspace-scoped prompt — the `contacts/prompt` IPC route fires it + // from settings or skill flows that have no conversation binding, so + // the wire payload has no `conversationId` and the conversation gate + // would drop it. + "contact_request", // Subagent lifecycle events route by `subagentId` into the global subagent // store, not by the parent stream's `conversationId`. They carry // `parentConversationId` (spawn) or nothing (`subagent_status_changed`) at the @@ -54,7 +62,9 @@ export function isConversationScopedStreamEvent( return !GLOBAL_STREAM_EVENT_TYPES.has(event.type); } -export function hasPendingAssistantResponse(messages: DisplayMessage[]): boolean { +export function hasPendingAssistantResponse( + messages: DisplayMessage[], +): boolean { let lastNonQueuedUserIndex = -1; for (let i = messages.length - 1; i >= 0; i--) { @@ -77,9 +87,9 @@ const VOICE_ERROR_MESSAGES: Readonly> = { "Microphone is blocked in your browser settings. Click the lock icon in your address bar and allow microphone access, then reload.", "audio-capture": "No microphone detected. Connect a microphone and try again.", - "network": + network: "Speech recognition couldn\u2019t reach its service. Check your network and try again.", - "aborted": "Recording was interrupted. Try again.", + aborted: "Recording was interrupted. Try again.", "stt-not-configured": "Speech-to-text isn\u2019t set up for this assistant. Open Settings \u2192 Voice to choose a provider.", "stt-audio-rejected": @@ -119,7 +129,9 @@ const BACKGROUND_CONVERSATION_SOURCES: ReadonlySet = new Set([ /** Whether a conversation should return to Background on unpin (macOS parity). */ export function shouldReturnToBackground(c: Conversation): boolean { - return c.source !== undefined && BACKGROUND_CONVERSATION_SOURCES.has(c.source); + return ( + c.source !== undefined && BACKGROUND_CONVERSATION_SOURCES.has(c.source) + ); } // Shallow per-field equality check — used to skip re-renders when an identity @@ -147,7 +159,10 @@ function applyConfirmationToToolCall( messageIndex: number, toolCallIndex: number, pending: PendingToolConfirmation, -): { updatedMessages: DisplayMessage[]; attachedToolCallId: string | undefined } { +): { + updatedMessages: DisplayMessage[]; + attachedToolCallId: string | undefined; +} { const msg = messages[messageIndex]!; const tc = msg.toolCalls![toolCallIndex]!; const updatedToolCalls = [...msg.toolCalls!]; @@ -182,7 +197,10 @@ export function attachConfirmationToToolCall( persistentDecisionsAllowed?: boolean; toolUseId?: string; }, -): { updatedMessages: DisplayMessage[]; attachedToolCallId: string | undefined } { +): { + updatedMessages: DisplayMessage[]; + attachedToolCallId: string | undefined; +} { const { toolUseId, ...pendingFields } = conf; const pending: PendingToolConfirmation = pendingFields; diff --git a/apps/web/src/domains/chat/utils/stream-handlers/interaction-handlers.test.ts b/apps/web/src/domains/chat/utils/stream-handlers/interaction-handlers.test.ts index 79cc6c7f041..e1e340bb107 100644 --- a/apps/web/src/domains/chat/utils/stream-handlers/interaction-handlers.test.ts +++ b/apps/web/src/domains/chat/utils/stream-handlers/interaction-handlers.test.ts @@ -16,12 +16,21 @@ describe("handleSecretRequest", () => { it("dispatches SECRET_REQUEST turn event and updates interaction store", () => { const ctx = makeCtx(); handleSecretRequest( - { type: "secret_request", requestId: "sr-1", label: "API Key" }, + { + type: "secret_request", + requestId: "sr-1", + service: "openai", + field: "api_key", + label: "API Key", + }, ctx, ); expect(ctx.turnActions.onSecretRequest).toHaveBeenCalled(); const state = useInteractionStore.getState(); - expect(state.pendingSecret).toMatchObject({ requestId: "sr-1", label: "API Key" }); + expect(state.pendingSecret).toMatchObject({ + requestId: "sr-1", + label: "API Key", + }); }); }); @@ -29,7 +38,15 @@ describe("handleConfirmationRequest", () => { it("dispatches CONFIRMATION_REQUEST turn event and updates interaction store", () => { const ctx = makeCtx(); handleConfirmationRequest( - { type: "confirmation_request", requestId: "cr-1", title: "Allow?" }, + { + type: "confirmation_request", + requestId: "cr-1", + toolName: "bash", + input: { command: "ls" }, + riskLevel: "low", + allowlistOptions: [], + scopeOptions: [], + }, ctx, ); expect(ctx.turnActions.onConfirmationRequest).toHaveBeenCalled(); diff --git a/apps/web/src/domains/chat/utils/stream-handlers/interaction-handlers.ts b/apps/web/src/domains/chat/utils/stream-handlers/interaction-handlers.ts index cf0c773babb..69718f9a6a1 100644 --- a/apps/web/src/domains/chat/utils/stream-handlers/interaction-handlers.ts +++ b/apps/web/src/domains/chat/utils/stream-handlers/interaction-handlers.ts @@ -2,7 +2,12 @@ import { attachConfirmationToToolCall } from "@/domains/chat/utils/chat"; import type { PendingConfirmationState } from "@/domains/chat/types"; import { useInteractionStore } from "@/domains/chat/interaction-store"; import type { StreamHandlerContext } from "@/domains/chat/utils/stream-handlers/types"; -import type { ConfirmationRequestEvent, ContactRequestEvent, QuestionRequestEvent, SecretRequestEvent } from "@/types/event-types"; +import type { + ConfirmationRequestEvent, + ContactRequestEvent, + QuestionRequestEvent, + SecretRequestEvent, +} from "@vellumai/assistant-api"; import { normalizeQuestionRequest } from "@/domains/chat/api/event-types"; export function handleSecretRequest( @@ -29,10 +34,6 @@ export function handleConfirmationRequest( ctx.turnActions.onConfirmationRequest(); const confData: PendingConfirmationState = { requestId: event.requestId, - title: event.title, - description: event.description, - confirmLabel: event.confirmLabel, - denyLabel: event.denyLabel, toolName: event.toolName, riskLevel: event.riskLevel, riskReason: event.riskReason, @@ -45,11 +46,16 @@ export function handleConfirmationRequest( }; useInteractionStore.getState().showConfirmation(confData); - const result = attachConfirmationToToolCall(ctx.messagesRef.current, confData); + const result = attachConfirmationToToolCall( + ctx.messagesRef.current, + confData, + ); ctx.setMessages(() => result.updatedMessages); if (result.attachedToolCallId) { - useInteractionStore.getState().setInlineConfirmationToolCallId(result.attachedToolCallId); + useInteractionStore + .getState() + .setInlineConfirmationToolCallId(result.attachedToolCallId); ctx.confirmationToolCallMapRef.current.set( confData.requestId, result.attachedToolCallId, diff --git a/apps/web/src/lib/streaming/event-parser.test.ts b/apps/web/src/lib/streaming/event-parser.test.ts index a151ad964cd..ae138f36862 100644 --- a/apps/web/src/lib/streaming/event-parser.test.ts +++ b/apps/web/src/lib/streaming/event-parser.test.ts @@ -1326,166 +1326,6 @@ describe("parseAssistantEvent", () => { }); }); - test("parses secret_request with all fields", () => { - const event = parseAssistantEvent({ - type: "secret_request", - requestId: "req-1", - service: "github", - field: "token", - label: "GitHub Token", - description: "Enter your personal access token", - placeholder: "ghp_...", - allowOneTimeSend: true, - }); - expect(event).toEqual({ - type: "secret_request", - requestId: "req-1", - service: "github", - field: "token", - label: "GitHub Token", - description: "Enter your personal access token", - placeholder: "ghp_...", - allowOneTimeSend: true, - }); - }); - - test("defaults secret_request requestId to empty string", () => { - const event = parseAssistantEvent({ type: "secret_request" }); - expect(event).toEqual({ - type: "secret_request", - requestId: "", - service: undefined, - field: undefined, - label: undefined, - description: undefined, - placeholder: undefined, - allowOneTimeSend: undefined, - }); - }); - - describe("confirmation_request", () => { - test("parses confirmation_request with toolUseId", () => { - const event = parseAssistantEvent({ - type: "confirmation_request", - requestId: "req-1", - title: "Allow file write?", - toolName: "write_file", - toolUseId: "tool-use-42", - riskLevel: "high", - }); - expect(event).toEqual({ - type: "confirmation_request", - requestId: "req-1", - title: "Allow file write?", - description: undefined, - confirmLabel: undefined, - denyLabel: undefined, - toolName: "write_file", - executionTarget: undefined, - riskLevel: "high", - riskReason: undefined, - allowlistOptions: undefined, - scopeOptions: undefined, - directoryScopeOptions: undefined, - persistentDecisionsAllowed: undefined, - input: undefined, - toolUseId: "tool-use-42", - }); - }); - - test("parses confirmation_request without toolUseId", () => { - const event = parseAssistantEvent({ - type: "confirmation_request", - requestId: "req-2", - title: "Allow shell command?", - toolName: "bash", - }); - expect(event.type).toBe("confirmation_request"); - if (event.type === "confirmation_request") { - expect(event.requestId).toBe("req-2"); - expect(event.toolUseId).toBeUndefined(); - } - }); - - test("ignores non-string toolUseId", () => { - const event = parseAssistantEvent({ - type: "confirmation_request", - requestId: "req-3", - toolUseId: 12345, - }); - expect(event.type).toBe("confirmation_request"); - if (event.type === "confirmation_request") { - expect(event.toolUseId).toBeUndefined(); - } - }); - - test("parses full confirmation_request with allowlist and scope options", () => { - const event = parseAssistantEvent({ - type: "confirmation_request", - requestId: "req-full", - title: "Allow bash command?", - description: "ls -la /tmp", - confirmLabel: "Allow", - denyLabel: "Deny", - toolName: "bash", - executionTarget: "sandbox", - riskLevel: "medium", - riskReason: "Filesystem access", - toolUseId: "tool-use-99", - allowlistOptions: [ - { pattern: "Bash(*)", label: "Allow all bash commands" }, - ], - scopeOptions: [{ scope: "workspace", label: "Current workspace" }], - directoryScopeOptions: [{ scope: "/src", label: "Source directory" }], - persistentDecisionsAllowed: true, - input: { command: "ls -la /tmp" }, - }); - expect(event.type).toBe("confirmation_request"); - if (event.type === "confirmation_request") { - expect(event.requestId).toBe("req-full"); - expect(event.toolUseId).toBe("tool-use-99"); - expect(event.allowlistOptions).toEqual([ - { pattern: "Bash(*)", label: "Allow all bash commands" }, - ]); - expect(event.scopeOptions).toEqual([ - { scope: "workspace", label: "Current workspace" }, - ]); - expect(event.directoryScopeOptions).toEqual([ - { scope: "/src", label: "Source directory" }, - ]); - expect(event.persistentDecisionsAllowed).toBe(true); - expect(event.input).toEqual({ command: "ls -la /tmp" }); - } - }); - - test("defaults requestId to empty string when missing", () => { - const event = parseAssistantEvent({ - type: "confirmation_request", - title: "Confirm?", - }); - expect(event.type).toBe("confirmation_request"); - if (event.type === "confirmation_request") { - expect(event.requestId).toBe(""); - } - }); - - test("ignores non-array allowlistOptions and non-boolean persistentDecisionsAllowed", () => { - const event = parseAssistantEvent({ - type: "confirmation_request", - requestId: "req-invalid", - allowlistOptions: "not-an-array", - persistentDecisionsAllowed: "yes", - input: [1, 2, 3], - }); - expect(event.type).toBe("confirmation_request"); - if (event.type === "confirmation_request") { - expect(event.allowlistOptions).toBeUndefined(); - expect(event.persistentDecisionsAllowed).toBeUndefined(); - expect(event.input).toBeUndefined(); - } - }); - }); - describe("tool_result", () => { test("maps riskAllowlistOptions → allowlistOptions (Minimatch save-path) and riskDirectoryScopeOptions → directoryScopeOptions", () => { const event = parseAssistantEvent({ @@ -1958,6 +1798,360 @@ describe("parseAssistantEvent", () => { conversationId: "conv-1", }); }); + + // --------------------------------------------------------------------- + // secret_request (schema-validated) + // --------------------------------------------------------------------- + + test("parses secret_request with all fields", () => { + const event = parseAssistantEvent({ + type: "secret_request", + requestId: "sr-1", + service: "github", + field: "token", + label: "GitHub Token", + description: "Personal access token", + placeholder: "ghp_...", + conversationId: "conv-1", + purpose: "push", + allowedTools: ["bash"], + allowedDomains: ["github.com"], + allowOneTimeSend: true, + }); + expect(event).toEqual({ + type: "secret_request", + requestId: "sr-1", + service: "github", + field: "token", + label: "GitHub Token", + description: "Personal access token", + placeholder: "ghp_...", + conversationId: "conv-1", + purpose: "push", + allowedTools: ["bash"], + allowedDomains: ["github.com"], + allowOneTimeSend: true, + }); + }); + + test("parses secret_request with required fields only", () => { + const event = parseAssistantEvent({ + type: "secret_request", + requestId: "sr-2", + service: "openai", + field: "api_key", + label: "OpenAI API Key", + }); + expect(event).toEqual({ + type: "secret_request", + requestId: "sr-2", + service: "openai", + field: "api_key", + label: "OpenAI API Key", + }); + }); + + test("returns unknown secret_request when service is missing", () => { + const data = { + type: "secret_request", + requestId: "sr-3", + field: "api_key", + label: "Missing service", + }; + expect(parseAssistantEvent(data)).toEqual({ + type: "unknown", + rawType: "secret_request", + data, + }); + }); + + test("returns unknown secret_request when extra field is present", () => { + const data = { + type: "secret_request", + requestId: "sr-4", + service: "openai", + field: "api_key", + label: "Extra", + surpriseField: "boom", + }; + expect(parseAssistantEvent(data)).toEqual({ + type: "unknown", + rawType: "secret_request", + data, + }); + }); + + // --------------------------------------------------------------------- + // confirmation_request (schema-validated) + // --------------------------------------------------------------------- + + test("parses confirmation_request with all fields", () => { + const event = parseAssistantEvent({ + type: "confirmation_request", + requestId: "cr-1", + toolName: "bash", + input: { command: "ls -la" }, + riskLevel: "medium", + riskReason: "Filesystem read", + isContainerized: false, + executionTarget: "sandbox", + allowlistOptions: [ + { + label: "Allow all bash", + description: "All commands", + pattern: "Bash(*)", + }, + ], + scopeOptions: [{ label: "This workspace", scope: "workspace" }], + directoryScopeOptions: [{ label: "/src", scope: "/src" }], + diff: { + filePath: "/tmp/x", + oldContent: "a", + newContent: "b", + isNewFile: false, + }, + conversationId: "conv-1", + persistentDecisionsAllowed: true, + toolUseId: "tu-1", + acpToolKind: "fs", + acpOptions: [{ optionId: "o1", name: "Allow once", kind: "allow_once" }], + }); + expect(event).toEqual({ + type: "confirmation_request", + requestId: "cr-1", + toolName: "bash", + input: { command: "ls -la" }, + riskLevel: "medium", + riskReason: "Filesystem read", + isContainerized: false, + executionTarget: "sandbox", + allowlistOptions: [ + { + label: "Allow all bash", + description: "All commands", + pattern: "Bash(*)", + }, + ], + scopeOptions: [{ label: "This workspace", scope: "workspace" }], + directoryScopeOptions: [{ label: "/src", scope: "/src" }], + diff: { + filePath: "/tmp/x", + oldContent: "a", + newContent: "b", + isNewFile: false, + }, + conversationId: "conv-1", + persistentDecisionsAllowed: true, + toolUseId: "tu-1", + acpToolKind: "fs", + acpOptions: [{ optionId: "o1", name: "Allow once", kind: "allow_once" }], + }); + }); + + test("parses confirmation_request with required fields only", () => { + const event = parseAssistantEvent({ + type: "confirmation_request", + requestId: "cr-2", + toolName: "write_file", + input: { path: "/tmp/y" }, + riskLevel: "low", + allowlistOptions: [], + scopeOptions: [], + }); + expect(event).toEqual({ + type: "confirmation_request", + requestId: "cr-2", + toolName: "write_file", + input: { path: "/tmp/y" }, + riskLevel: "low", + allowlistOptions: [], + scopeOptions: [], + }); + }); + + test("returns unknown confirmation_request when toolName is missing", () => { + const data = { + type: "confirmation_request", + requestId: "cr-3", + input: {}, + riskLevel: "low", + allowlistOptions: [], + scopeOptions: [], + }; + expect(parseAssistantEvent(data)).toEqual({ + type: "unknown", + rawType: "confirmation_request", + data, + }); + }); + + test("returns unknown confirmation_request when extra field is present", () => { + const data = { + type: "confirmation_request", + requestId: "cr-4", + toolName: "bash", + input: {}, + riskLevel: "low", + allowlistOptions: [], + scopeOptions: [], + title: "Allow?", + }; + expect(parseAssistantEvent(data)).toEqual({ + type: "unknown", + rawType: "confirmation_request", + data, + }); + }); + + // --------------------------------------------------------------------- + // contact_request (schema-validated) + // --------------------------------------------------------------------- + + test("parses contact_request with all fields", () => { + const event = parseAssistantEvent({ + type: "contact_request", + requestId: "ctc-1", + channel: "email", + placeholder: "you@example.com", + label: "Email", + description: "How can we reach you?", + role: "primary", + }); + expect(event).toEqual({ + type: "contact_request", + requestId: "ctc-1", + channel: "email", + placeholder: "you@example.com", + label: "Email", + description: "How can we reach you?", + role: "primary", + }); + }); + + test("parses contact_request with required fields only", () => { + const event = parseAssistantEvent({ + type: "contact_request", + requestId: "ctc-2", + }); + expect(event).toEqual({ + type: "contact_request", + requestId: "ctc-2", + }); + }); + + test("returns unknown contact_request when requestId is missing", () => { + const data = { type: "contact_request", channel: "email" }; + expect(parseAssistantEvent(data)).toEqual({ + type: "unknown", + rawType: "contact_request", + data, + }); + }); + + test("returns unknown contact_request when extra field is present", () => { + const data = { + type: "contact_request", + requestId: "ctc-3", + surpriseField: "boom", + }; + expect(parseAssistantEvent(data)).toEqual({ + type: "unknown", + rawType: "contact_request", + data, + }); + }); + + // --------------------------------------------------------------------- + // question_request (schema-validated) + // --------------------------------------------------------------------- + + test("parses question_request with all fields", () => { + const event = parseAssistantEvent({ + type: "question_request", + requestId: "qr-1", + questions: [ + { + id: "q1", + question: "Pick one", + description: "Choose carefully", + options: [{ id: "a", label: "A", description: "first" }], + freeTextPlaceholder: "or type", + }, + ], + question: "Pick one", + description: "Choose carefully", + options: [{ id: "a", label: "A", description: "first" }], + freeTextPlaceholder: "or type", + conversationId: "conv-1", + toolUseId: "tu-1", + }); + expect(event).toEqual({ + type: "question_request", + requestId: "qr-1", + questions: [ + { + id: "q1", + question: "Pick one", + description: "Choose carefully", + options: [{ id: "a", label: "A", description: "first" }], + freeTextPlaceholder: "or type", + }, + ], + question: "Pick one", + description: "Choose carefully", + options: [{ id: "a", label: "A", description: "first" }], + freeTextPlaceholder: "or type", + conversationId: "conv-1", + toolUseId: "tu-1", + }); + }); + + test("parses question_request with required fields only", () => { + const event = parseAssistantEvent({ + type: "question_request", + requestId: "qr-2", + questions: [{ id: "q1", question: "Continue?", options: [] }], + question: "Continue?", + options: [], + }); + expect(event).toEqual({ + type: "question_request", + requestId: "qr-2", + questions: [{ id: "q1", question: "Continue?", options: [] }], + question: "Continue?", + options: [], + }); + }); + + test("returns unknown question_request when questions array is missing", () => { + const data = { + type: "question_request", + requestId: "qr-3", + question: "Continue?", + options: [], + }; + expect(parseAssistantEvent(data)).toEqual({ + type: "unknown", + rawType: "question_request", + data, + }); + }); + + test("returns unknown question_request when extra field is present", () => { + const data = { + type: "question_request", + requestId: "qr-4", + questions: [{ id: "q1", question: "?", options: [] }], + question: "?", + options: [], + surpriseField: "boom", + }; + expect(parseAssistantEvent(data)).toEqual({ + type: "unknown", + rawType: "question_request", + data, + }); + }); }); describe("envelope format parsing", () => { @@ -2202,5 +2396,3 @@ describe("RuntimeMessage metadata types", () => { expect(msg.metadata).toEqual({ source: "test" }); }); }); - - diff --git a/apps/web/src/lib/streaming/event-parser.ts b/apps/web/src/lib/streaming/event-parser.ts index e59bed09ffb..ed65d55669c 100644 --- a/apps/web/src/lib/streaming/event-parser.ts +++ b/apps/web/src/lib/streaming/event-parser.ts @@ -8,7 +8,6 @@ * coercion for legacy events not yet covered by a schema. * * Legacy coercion is split across focused sub-modules by event group: - * - `parse-interaction-events` — user-facing prompts * - `parse-tool-events` — tool execution lifecycle * - `parse-surface-events` — daemon-driven UI surfaces * - `parse-subagent-events` — subagent orchestration @@ -27,12 +26,6 @@ import type { import { AssistantEventSchema } from "@vellumai/assistant-api"; import { unknownEvent } from "@/lib/streaming/parse-helpers"; -import { - parseSecretRequest, - parseConfirmationRequest, - parseContactRequest, - parseQuestionRequest, -} from "@/lib/streaming/parse-interaction-events"; import { parseToolUseStart, parseToolResult, @@ -141,16 +134,6 @@ function parseLegacyEvent(data: Record): AssistantEvent { const rawType = typeof data.type === "string" ? data.type : ""; switch (rawType) { - // --- Interaction prompts --- - case "secret_request": - return parseSecretRequest(data); - case "confirmation_request": - return parseConfirmationRequest(data); - case "contact_request": - return parseContactRequest(data); - case "question_request": - return parseQuestionRequest(data); - // --- Tool execution lifecycle --- case "tool_use_start": return parseToolUseStart(data); diff --git a/apps/web/src/lib/streaming/parse-interaction-events.ts b/apps/web/src/lib/streaming/parse-interaction-events.ts deleted file mode 100644 index 57dc586d3a2..00000000000 --- a/apps/web/src/lib/streaming/parse-interaction-events.ts +++ /dev/null @@ -1,150 +0,0 @@ -/** - * Legacy parsers for user-facing interaction prompt events. - * - * Each function coerces a raw SSE payload into a typed event for one - * of the four daemon-initiated prompts that require the user to - * respond: secret entry, tool-risk confirmation, contact sharing, - * and multi-option questions. - */ - -import type { AssistantEvent } from "@/types/event-types"; -import type { - AllowlistOption, - DirectoryScopeOption, - QuestionEntry, - QuestionOption, - ScopeOption, -} from "@/types/interaction-ui-types"; - -export function parseSecretRequest( - data: Record, -): AssistantEvent { - return { - type: "secret_request", - requestId: typeof data.requestId === "string" ? data.requestId : "", - service: typeof data.service === "string" ? data.service : undefined, - field: typeof data.field === "string" ? data.field : undefined, - label: typeof data.label === "string" ? data.label : undefined, - description: - typeof data.description === "string" ? data.description : undefined, - placeholder: - typeof data.placeholder === "string" ? data.placeholder : undefined, - allowOneTimeSend: - typeof data.allowOneTimeSend === "boolean" - ? data.allowOneTimeSend - : undefined, - allowedTools: Array.isArray(data.allowedTools) - ? (data.allowedTools as string[]) - : undefined, - allowedDomains: Array.isArray(data.allowedDomains) - ? (data.allowedDomains as string[]) - : undefined, - purpose: typeof data.purpose === "string" ? data.purpose : undefined, - conversationId: - typeof data.conversationId === "string" - ? data.conversationId - : undefined, - }; -} - -export function parseConfirmationRequest( - data: Record, -): AssistantEvent { - return { - type: "confirmation_request", - requestId: typeof data.requestId === "string" ? data.requestId : "", - title: typeof data.title === "string" ? data.title : undefined, - description: - typeof data.description === "string" ? data.description : undefined, - confirmLabel: - typeof data.confirmLabel === "string" ? data.confirmLabel : undefined, - denyLabel: - typeof data.denyLabel === "string" ? data.denyLabel : undefined, - conversationId: - typeof data.conversationId === "string" - ? data.conversationId - : undefined, - toolName: typeof data.toolName === "string" ? data.toolName : undefined, - executionTarget: - typeof data.executionTarget === "string" - ? data.executionTarget - : undefined, - riskLevel: - typeof data.riskLevel === "string" ? data.riskLevel : undefined, - riskReason: - typeof data.riskReason === "string" ? data.riskReason : undefined, - allowlistOptions: Array.isArray(data.allowlistOptions) - ? (data.allowlistOptions as AllowlistOption[]) - : undefined, - scopeOptions: Array.isArray(data.scopeOptions) - ? (data.scopeOptions as ScopeOption[]) - : undefined, - directoryScopeOptions: Array.isArray(data.directoryScopeOptions) - ? (data.directoryScopeOptions as DirectoryScopeOption[]) - : undefined, - persistentDecisionsAllowed: - typeof data.persistentDecisionsAllowed === "boolean" - ? data.persistentDecisionsAllowed - : undefined, - input: - typeof data.input === "object" && - data.input !== null && - !Array.isArray(data.input) - ? (data.input as Record) - : undefined, - toolUseId: - typeof data.toolUseId === "string" ? data.toolUseId : undefined, - }; -} - -export function parseContactRequest( - data: Record, -): AssistantEvent { - return { - type: "contact_request", - requestId: typeof data.requestId === "string" ? data.requestId : "", - channel: typeof data.channel === "string" ? data.channel : undefined, - placeholder: - typeof data.placeholder === "string" ? data.placeholder : undefined, - label: typeof data.label === "string" ? data.label : undefined, - description: - typeof data.description === "string" ? data.description : undefined, - role: typeof data.role === "string" ? data.role : undefined, - conversationId: - typeof data.conversationId === "string" - ? data.conversationId - : undefined, - }; -} - -export function parseQuestionRequest( - data: Record, -): AssistantEvent { - const requestId = - typeof data.requestId === "string" ? data.requestId : ""; - const options: QuestionOption[] | undefined = Array.isArray(data.options) - ? (data.options as QuestionOption[]) - : undefined; - const questions: QuestionEntry[] | undefined = Array.isArray(data.questions) - ? (data.questions as QuestionEntry[]) - : undefined; - return { - type: "question_request", - requestId, - questions, - question: typeof data.question === "string" ? data.question : undefined, - description: - typeof data.description === "string" ? data.description : undefined, - options, - freeTextPlaceholder: - typeof data.freeTextPlaceholder === "string" - ? data.freeTextPlaceholder - : undefined, - conversationId: - typeof data.conversationId === "string" - ? data.conversationId - : undefined, - toolUseId: - typeof data.toolUseId === "string" ? data.toolUseId : undefined, - }; -} diff --git a/apps/web/src/lib/streaming/parse-resource-events.ts b/apps/web/src/lib/streaming/parse-resource-events.ts index 477a809dc98..c9fb0e7c687 100644 --- a/apps/web/src/lib/streaming/parse-resource-events.ts +++ b/apps/web/src/lib/streaming/parse-resource-events.ts @@ -27,9 +27,7 @@ export function parseSyncChanged( return unknownEvent("sync_changed", data); } const rawOriginClientId = - typeof data.originClientId === "string" - ? data.originClientId.trim() - : ""; + typeof data.originClientId === "string" ? data.originClientId.trim() : ""; return { type: "sync_changed", tags: tags as SyncInvalidationTag[], @@ -37,13 +35,6 @@ export function parseSyncChanged( }; } -// `identity_changed`, `avatar_updated`, `conversation_title_updated`, and -// `conversation_list_invalidated` are now schema-validated via canonical -// schemas in `@vellumai/assistant-api`. The legacy parser functions -// previously here are gone — `event-parser.ts` no longer dispatches -// these cases; `parseAssistantEvent` resolves them through -// `AssistantEventSchema` before reaching the legacy switch. - export function parseNotificationIntent( data: Record, ): AssistantEvent { @@ -81,28 +72,21 @@ export function parseDiskPressureStatusChanged( return { type: "disk_pressure_status_changed", status: parseDiskPressureStatus( - Object.prototype.hasOwnProperty.call(data, "status") - ? data.status - : data, + Object.prototype.hasOwnProperty.call(data, "status") ? data.status : data, ), conversationId: - typeof data.conversationId === "string" - ? data.conversationId - : undefined, + typeof data.conversationId === "string" ? data.conversationId : undefined, }; } export function parseDocumentEditorUpdate( data: Record, ): AssistantEvent { - const surfaceId = - typeof data.surfaceId === "string" ? data.surfaceId : ""; + const surfaceId = typeof data.surfaceId === "string" ? data.surfaceId : ""; const markdown = typeof data.markdown === "string" ? data.markdown : ""; const mode = typeof data.mode === "string" ? data.mode : "replace"; const conversationId = - typeof data.conversationId === "string" - ? data.conversationId - : undefined; + typeof data.conversationId === "string" ? data.conversationId : undefined; if (!surfaceId) { return unknownEvent("document_editor_update", data); } diff --git a/apps/web/src/types/event-types.ts b/apps/web/src/types/event-types.ts index 53ddd478023..70ebae9df72 100644 --- a/apps/web/src/types/event-types.ts +++ b/apps/web/src/types/event-types.ts @@ -20,9 +20,9 @@ import type { SyncChangedEvent } from "@/lib/sync/types"; import type { AllowlistOption, DirectoryScopeOption, - QuestionEntry, - QuestionOption, ScopeOption, +} from "@vellumai/assistant-api"; +import type { SubagentInnerEvent, SubagentStatus, } from "@/types/interaction-ui-types"; @@ -39,71 +39,9 @@ export interface StreamErrorEvent { conversationId?: string; } -export interface SecretRequestEvent { - type: "secret_request"; - requestId: string; - service?: string; - field?: string; - label?: string; - description?: string; - placeholder?: string; - allowOneTimeSend?: boolean; - allowedTools?: string[]; - allowedDomains?: string[]; - purpose?: string; - conversationId?: string; -} - /** Valid decisions accepted by the assistant runtime's POST /v1/confirm endpoint. */ export type ConfirmationDecision = "allow" | "deny"; -export interface ConfirmationRequestEvent { - type: "confirmation_request"; - requestId: string; - title?: string; - description?: string; - confirmLabel?: string; - denyLabel?: string; - conversationId?: string; - toolName?: string; - executionTarget?: string; - riskLevel?: string; - riskReason?: string; - allowlistOptions?: AllowlistOption[]; - scopeOptions?: ScopeOption[]; - directoryScopeOptions?: DirectoryScopeOption[]; - persistentDecisionsAllowed?: boolean; - input?: Record; - toolUseId?: string; -} - -export interface ContactRequestEvent { - type: "contact_request"; - requestId: string; - /** Suggested channel type hint (e.g. "phone", "email", "telegram"). */ - channel?: string; - placeholder?: string; - label?: string; - description?: string; - /** Suggested role for the new contact. */ - role?: string; - conversationId?: string; -} - -export interface QuestionRequestEvent { - type: "question_request"; - requestId: string; - /** New shape — present when the daemon ships the batched-questions PR. */ - questions?: QuestionEntry[]; - /** Legacy flat fields — still emitted by older daemons. */ - question?: string; - description?: string; - options?: QuestionOption[]; - freeTextPlaceholder?: string; - conversationId?: string; - toolUseId?: string; -} - export interface UISurfaceShowEvent { type: "ui_surface_show"; surfaceId: string; @@ -437,10 +375,6 @@ export const USER_FACING_INTERACTION_KINDS: ReadonlySet = export type AssistantEvent = | APIAssistantEvent | StreamErrorEvent - | SecretRequestEvent - | ConfirmationRequestEvent - | ContactRequestEvent - | QuestionRequestEvent | UISurfaceShowEvent | UISurfaceUpdateEvent | UISurfaceDismissEvent diff --git a/apps/web/src/types/interaction-ui-types.ts b/apps/web/src/types/interaction-ui-types.ts index 9acfabdab0c..d84edf7ec38 100644 --- a/apps/web/src/types/interaction-ui-types.ts +++ b/apps/web/src/types/interaction-ui-types.ts @@ -4,51 +4,20 @@ * SSR dependencies. */ -// --------------------------------------------------------------------------- -// Confirmation / secret / contact / question request shapes -// --------------------------------------------------------------------------- - -export interface AllowlistOption { - /** Short display label for the radio row in the rule editor. */ - label: string; - /** - * Optional longer-form description shown beneath/alongside the label. - * Daemon includes this on `riskAllowlistOptions` (shared with macOS); the - * web modal renders the label today and may surface description later. - */ - description?: string; - /** - * Minimatch-glob compatible pattern saved as the trust rule's `pattern` - * field. The gateway matches incoming tool calls against this string — - * it is NOT a regex despite some legacy emit sites prefixing with `^`. - * See `gateway/src/risk/bash-risk-classifier.ts` for the matching contract. - */ - pattern: string; -} +export type { + AllowlistOption, + DirectoryScopeOption, + QuestionEntry, + QuestionOption, + ScopeOption, +} from "@vellumai/assistant-api"; -export interface ScopeOption { - label: string; - scope: string; -} - -export interface DirectoryScopeOption { - label: string; - scope: string; -} - -export interface QuestionOption { - id: string; - label: string; - description?: string; -} - -export interface QuestionEntry { - id: string; - question: string; - description?: string; - options: QuestionOption[]; - freeTextPlaceholder?: string; -} +import type { + AllowlistOption, + DirectoryScopeOption, + QuestionEntry, + ScopeOption, +} from "@vellumai/assistant-api"; // --------------------------------------------------------------------------- // Chat UI state types — used by interaction store and chat domain @@ -101,7 +70,13 @@ export interface PendingQuestionState { // Subagent event types — used by subagent domain and chat SSE stream // --------------------------------------------------------------------------- -export type SubagentStatus = "pending" | "running" | "awaiting_input" | "completed" | "failed" | "aborted"; +export type SubagentStatus = + | "pending" + | "running" + | "awaiting_input" + | "completed" + | "failed" + | "aborted"; export interface SubagentInnerEvent { type: string; diff --git a/assistant/src/__tests__/secret-prompt-log-hygiene.test.ts b/assistant/src/__tests__/secret-prompt-log-hygiene.test.ts index 212a29399d7..aec31ae8265 100644 --- a/assistant/src/__tests__/secret-prompt-log-hygiene.test.ts +++ b/assistant/src/__tests__/secret-prompt-log-hygiene.test.ts @@ -1,9 +1,7 @@ import { beforeEach, describe, expect, mock, test } from "bun:test"; -import type { - SecretRequest, - ServerMessage, -} from "../daemon/message-protocol.js"; +import type { SecretRequestEvent } from "../api/events/secret-request.js"; +import type { ServerMessage } from "../daemon/message-protocol.js"; // Capture all logger calls so we can verify secret values never appear const logCalls: Array<{ level: string; args: unknown[] }> = []; @@ -43,7 +41,11 @@ mock.module("../runtime/assistant-event-hub.js", () => ({ const _piStore = new Map(); mock.module("../runtime/pending-interactions.js", () => ({ register: (id: string, entry: object) => _piStore.set(id, entry), - resolve: (id: string) => { const e = _piStore.get(id); _piStore.delete(id); return e; }, + resolve: (id: string) => { + const e = _piStore.get(id); + _piStore.delete(id); + return e; + }, get: (id: string) => _piStore.get(id), getAll: () => [..._piStore.values()], getByConversation: () => [], @@ -83,7 +85,7 @@ describe("secret prompt log hygiene", () => { test("resolveSecret never logs the secret value", async () => { const secret = "sv42"; const promise = prompter.prompt("myservice", "apikey", "API Key"); - const requestId = (broadcastedMessages[0] as SecretRequest).requestId; + const requestId = (broadcastedMessages[0] as SecretRequestEvent).requestId; prompter.resolveSecret(requestId, secret, "store"); const result = await promise; @@ -124,7 +126,9 @@ describe("secret prompt log hygiene", () => { test("sent message contains value=undefined (value flows through event, not logs)", async () => { const promise = prompter.prompt("svc", "tok", "Token"); - const msg = broadcastedMessages[0] as SecretRequest & { value?: unknown }; + const msg = broadcastedMessages[0] as SecretRequestEvent & { + value?: unknown; + }; // The message should NOT contain a value field expect(msg.value).toBeUndefined(); prompter.resolveSecret(msg.requestId, undefined); diff --git a/assistant/src/__tests__/secret-prompter-channel-fallback.test.ts b/assistant/src/__tests__/secret-prompter-channel-fallback.test.ts index 6fa5ff10b46..7c42ced5b5f 100644 --- a/assistant/src/__tests__/secret-prompter-channel-fallback.test.ts +++ b/assistant/src/__tests__/secret-prompter-channel-fallback.test.ts @@ -1,9 +1,7 @@ import { beforeEach, describe, expect, mock, test } from "bun:test"; -import type { - SecretRequest, - ServerMessage, -} from "../daemon/message-protocol.js"; +import type { SecretRequestEvent } from "../api/events/secret-request.js"; +import type { ServerMessage } from "../daemon/message-protocol.js"; // Use a tiny timeout so the setTimeout branch fires quickly in tests const mockConfig = { @@ -42,7 +40,11 @@ mock.module("../runtime/assistant-event-hub.js", () => ({ const _piStore = new Map(); mock.module("../runtime/pending-interactions.js", () => ({ register: (id: string, entry: object) => _piStore.set(id, entry), - resolve: (id: string) => { const e = _piStore.get(id); _piStore.delete(id); return e; }, + resolve: (id: string) => { + const e = _piStore.get(id); + _piStore.delete(id); + return e; + }, get: (id: string) => _piStore.get(id), getAll: () => [..._piStore.values()], getByConversation: () => [], @@ -70,7 +72,7 @@ describe("secret prompter channel fallback", () => { expect(broadcastMessages).toHaveLength(1); expect(broadcastMessages[0]!.type).toBe("secret_request"); - const requestId = (broadcastMessages[0] as SecretRequest).requestId; + const requestId = (broadcastMessages[0] as SecretRequestEvent).requestId; prompter.resolveSecret(requestId, "test-secret", "store"); const result = await promise; expect(result.value).toBe("test-secret"); @@ -88,7 +90,7 @@ describe("secret prompter channel fallback", () => { expect(broadcastMessages).toHaveLength(1); expect(broadcastMessages[0]!.type).toBe("secret_request"); - const requestId = (broadcastMessages[0] as SecretRequest).requestId; + const requestId = (broadcastMessages[0] as SecretRequestEvent).requestId; prompter.resolveSecret(requestId, "test-secret", "store"); await promise; }); @@ -101,7 +103,7 @@ describe("secret prompter channel fallback", () => { expect(broadcastMessages).toHaveLength(1); expect(broadcastMessages[0]!.type).toBe("secret_request"); - const requestId = (broadcastMessages[0] as SecretRequest).requestId; + const requestId = (broadcastMessages[0] as SecretRequestEvent).requestId; prompter.resolveSecret(requestId, "val", "store"); await promise; }); @@ -110,7 +112,7 @@ describe("secret prompter channel fallback", () => { const prompter = new SecretPrompter(); const promise = prompter.prompt("myservice", "apikey", "API Key"); - const requestId = (broadcastMessages[0] as SecretRequest).requestId; + const requestId = (broadcastMessages[0] as SecretRequestEvent).requestId; expect(prompter.hasPendingRequest(requestId)).toBe(true); diff --git a/assistant/src/__tests__/secret-response-routing.test.ts b/assistant/src/__tests__/secret-response-routing.test.ts index 257e18198e0..22738b18aa5 100644 --- a/assistant/src/__tests__/secret-response-routing.test.ts +++ b/assistant/src/__tests__/secret-response-routing.test.ts @@ -1,9 +1,7 @@ import { beforeEach, describe, expect, mock, test } from "bun:test"; -import type { - SecretRequest, - ServerMessage, -} from "../daemon/message-protocol.js"; +import type { SecretRequestEvent } from "../api/events/secret-request.js"; +import type { ServerMessage } from "../daemon/message-protocol.js"; import type { SecretPromptResult } from "../permissions/secret-prompter.js"; let broadcastedMessages: ServerMessage[] = []; @@ -15,7 +13,11 @@ mock.module("../runtime/assistant-event-hub.js", () => ({ const _piStore = new Map(); mock.module("../runtime/pending-interactions.js", () => ({ register: (id: string, entry: object) => _piStore.set(id, entry), - resolve: (id: string) => { const e = _piStore.get(id); _piStore.delete(id); return e; }, + resolve: (id: string) => { + const e = _piStore.get(id); + _piStore.delete(id); + return e; + }, get: (id: string) => _piStore.get(id), getAll: () => [..._piStore.values()], getByConversation: () => [], @@ -36,7 +38,7 @@ describe("secret response routing", () => { test("resolveSecret defaults delivery to store when omitted", async () => { const promise = prompter.prompt("github", "token", "GitHub Token"); - const requestId = (broadcastedMessages[0] as SecretRequest).requestId; + const requestId = (broadcastedMessages[0] as SecretRequestEvent).requestId; prompter.resolveSecret(requestId, "test-value"); const result: SecretPromptResult = await promise; expect(result.value).toBe("test-value"); @@ -45,7 +47,7 @@ describe("secret response routing", () => { test("resolveSecret passes store delivery", async () => { const promise = prompter.prompt("github", "token", "GitHub Token"); - const requestId = (broadcastedMessages[0] as SecretRequest).requestId; + const requestId = (broadcastedMessages[0] as SecretRequestEvent).requestId; prompter.resolveSecret(requestId, "test-value", "store"); const result = await promise; expect(result.value).toBe("test-value"); @@ -54,7 +56,7 @@ describe("secret response routing", () => { test("resolveSecret passes transient_send delivery", async () => { const promise = prompter.prompt("github", "token", "GitHub Token"); - const requestId = (broadcastedMessages[0] as SecretRequest).requestId; + const requestId = (broadcastedMessages[0] as SecretRequestEvent).requestId; prompter.resolveSecret(requestId, "one-time-value", "transient_send"); const result = await promise; expect(result.value).toBe("one-time-value"); @@ -63,7 +65,7 @@ describe("secret response routing", () => { test("resolveSecret with cancelled value defaults delivery to store", async () => { const promise = prompter.prompt("github", "token", "GitHub Token"); - const requestId = (broadcastedMessages[0] as SecretRequest).requestId; + const requestId = (broadcastedMessages[0] as SecretRequestEvent).requestId; prompter.resolveSecret(requestId, undefined); const result = await promise; expect(result.value).toBeNull(); @@ -74,7 +76,7 @@ describe("secret response routing", () => { // We can't easily test the full timeout, but we verify the structure // by resolving immediately (the timeout path also returns { value: null, delivery: 'store' }) const promise = prompter.prompt("github", "token", "GitHub Token"); - const requestId = (broadcastedMessages[0] as SecretRequest).requestId; + const requestId = (broadcastedMessages[0] as SecretRequestEvent).requestId; prompter.resolveSecret(requestId, undefined, undefined); const result = await promise; expect(result.value).toBeNull(); @@ -91,7 +93,7 @@ describe("secret response routing", () => { "session-1", ); expect(broadcastedMessages.length).toBe(1); - const msg = broadcastedMessages[0] as SecretRequest; + const msg = broadcastedMessages[0] as SecretRequestEvent; expect(msg.type).toBe("secret_request"); expect(msg.service).toBe("github"); expect(msg.field).toBe("token"); diff --git a/assistant/src/api/events/confirmation-request.ts b/assistant/src/api/events/confirmation-request.ts new file mode 100644 index 00000000000..3a0af23fe20 --- /dev/null +++ b/assistant/src/api/events/confirmation-request.ts @@ -0,0 +1,126 @@ +/** + * `confirmation_request` SSE event. + * + * Server → client prompt asking the user to approve or deny a tool + * invocation that fell outside the auto-approve threshold. Emitted by + * the confirmation prompter / risk classifier when a tool call needs + * human review. + * + * Resolved by a paired `interaction_resolved` event (`kind: + * "confirmation"`, `state: "approved" | "rejected" | "cancelled" | + * "superseded"`) once the user decides, the daemon times out, or a + * newer user message supersedes the pending request. + * + * Required fields are what the prompter always supplies: + * - `toolName`, `input` — what the tool will run with + * - `riskLevel` — risk-classifier output, used for display only + * (kept loose `string` rather than enum — risk grades evolve + * independently of the wire and the client renders them as text) + * - `allowlistOptions`, `scopeOptions` — radio choices the rule + * editor offers when the user picks "always allow" + * + * `acpToolKind` and `acpOptions` are present only for ACP (Agent + * Client Protocol) permission requests forwarded from a sub-agent; + * `acpOptions.kind` is a strict 4-variant enum because the agent + * protocol mandates exactly those four shapes. + * + * `executionTarget` distinguishes sandbox vs host execution — a strict + * 2-variant enum because the sandbox switch is binary at the daemon + * level. + * + * Canonical wire-contract source. Daemon code imports the type + * directly from this file; external consumers import via + * `@vellumai/assistant-api`. + */ + +import { z } from "zod"; + +export const AllowlistOptionSchema = z + .object({ + label: z.string(), + description: z.string(), + pattern: z.string(), + }) + .strict(); + +export type AllowlistOption = z.infer; + +export const ScopeOptionSchema = z + .object({ + label: z.string(), + scope: z.string(), + }) + .strict(); + +export type ScopeOption = z.infer; + +export const DirectoryScopeOptionSchema = z + .object({ + label: z.string(), + scope: z.string(), + }) + .strict(); + +export type DirectoryScopeOption = z.infer; + +export const ConfirmationDiffSchema = z + .object({ + filePath: z.string(), + oldContent: z.string(), + newContent: z.string(), + isNewFile: z.boolean(), + }) + .strict(); + +export type ConfirmationDiff = z.infer; + +export const ACPOptionKindSchema = z.enum([ + "allow_once", + "allow_always", + "reject_once", + "reject_always", +]); + +export type ACPOptionKind = z.infer; + +export const ACPOptionSchema = z + .object({ + optionId: z.string(), + name: z.string(), + kind: ACPOptionKindSchema, + }) + .strict(); + +export type ACPOption = z.infer; + +export const ConfirmationExecutionTargetSchema = z.enum(["sandbox", "host"]); + +export type ConfirmationExecutionTarget = z.infer< + typeof ConfirmationExecutionTargetSchema +>; + +export const ConfirmationRequestEventSchema = z + .object({ + type: z.literal("confirmation_request"), + requestId: z.string(), + toolName: z.string(), + input: z.record(z.string(), z.unknown()), + riskLevel: z.string(), + riskReason: z.string().optional(), + isContainerized: z.boolean().optional(), + executionTarget: ConfirmationExecutionTargetSchema.optional(), + allowlistOptions: z.array(AllowlistOptionSchema), + scopeOptions: z.array(ScopeOptionSchema), + directoryScopeOptions: z.array(DirectoryScopeOptionSchema).optional(), + diff: ConfirmationDiffSchema.optional(), + conversationId: z.string().optional(), + persistentDecisionsAllowed: z.boolean().optional(), + toolUseId: z.string().optional(), + acpToolKind: z.string().optional(), + acpOptions: z.array(ACPOptionSchema).optional(), + }) + .strict(); + +export type ConfirmationRequestEvent = z.infer< + typeof ConfirmationRequestEventSchema +>; diff --git a/assistant/src/api/events/contact-request.ts b/assistant/src/api/events/contact-request.ts new file mode 100644 index 00000000000..5aae154143e --- /dev/null +++ b/assistant/src/api/events/contact-request.ts @@ -0,0 +1,35 @@ +/** + * `contact_request` SSE event. + * + * Server → client prompt asking the user to enter a contact channel + * address (phone, email, etc.). Emitted by the `contacts/prompt` IPC + * route while a `pendingContactPrompts` entry awaits a reply. + * + * Resolved by a paired `interaction_resolved` event (`kind: + * "contact"`, `state: "answered" | "cancelled"`) once the user + * responds or the timeout fires. + * + * `channel` and `role` are advisory hints, not enforced enums — the + * client may render any input it likes and post back a structured + * contact payload. + * + * Canonical wire-contract source. Daemon code imports the type + * directly from this file; external consumers import via + * `@vellumai/assistant-api`. + */ + +import { z } from "zod"; + +export const ContactRequestEventSchema = z + .object({ + type: z.literal("contact_request"), + requestId: z.string(), + channel: z.string().optional(), + placeholder: z.string().optional(), + label: z.string().optional(), + description: z.string().optional(), + role: z.string().optional(), + }) + .strict(); + +export type ContactRequestEvent = z.infer; diff --git a/assistant/src/api/events/question-request.ts b/assistant/src/api/events/question-request.ts new file mode 100644 index 00000000000..bf6336261de --- /dev/null +++ b/assistant/src/api/events/question-request.ts @@ -0,0 +1,73 @@ +/** + * `question_request` SSE event. + * + * Server → client prompt asking the user one or a small batch (≤5) of + * clarifying questions during a turn. Emitted by the question + * prompter when the LLM calls `ask_question` (or its batched + * equivalent). + * + * Resolved by a paired `interaction_resolved` event (`kind: + * "question"`, `state: "answered" | "cancelled" | "superseded"`) once + * the user submits answers, the daemon times out, or a newer user + * message supersedes the pending request. + * + * Wire-compat: + * + * - `questions[]` is the canonical batched shape new clients should + * consume. The whole batch is one card lifecycle on the client: + * one render, one state machine, one response submission. `id` on + * each entry is daemon-assigned (`q1`, `q2`, …) so the client has + * a stable handle to post the user's answer against. + * + * - The flat `question` / `description` / `options` / + * `freeTextPlaceholder` fields mirror `questions[0]` for older + * clients that key off the flat shape. Daemon callers that don't + * supply a batch get a one-element `questions` array synthesized + * from the flat fields, so both shapes are populated on every + * broadcast. Once all clients consume `questions[]`, the flat + * fields can be dropped (separate cleanup). + * + * Canonical wire-contract source. Daemon code imports the type + * directly from this file; external consumers import via + * `@vellumai/assistant-api`. + */ + +import { z } from "zod"; + +export const QuestionOptionSchema = z + .object({ + id: z.string(), + label: z.string(), + description: z.string().optional(), + }) + .strict(); + +export type QuestionOption = z.infer; + +export const QuestionEntrySchema = z + .object({ + id: z.string(), + question: z.string(), + description: z.string().optional(), + options: z.array(QuestionOptionSchema), + freeTextPlaceholder: z.string().optional(), + }) + .strict(); + +export type QuestionEntry = z.infer; + +export const QuestionRequestEventSchema = z + .object({ + type: z.literal("question_request"), + requestId: z.string(), + questions: z.array(QuestionEntrySchema), + question: z.string(), + description: z.string().optional(), + options: z.array(QuestionOptionSchema), + freeTextPlaceholder: z.string().optional(), + conversationId: z.string().optional(), + toolUseId: z.string().optional(), + }) + .strict(); + +export type QuestionRequestEvent = z.infer; diff --git a/assistant/src/api/events/secret-request.ts b/assistant/src/api/events/secret-request.ts new file mode 100644 index 00000000000..773f807038a --- /dev/null +++ b/assistant/src/api/events/secret-request.ts @@ -0,0 +1,44 @@ +/** + * `secret_request` SSE event. + * + * Server → client prompt asking the user to supply a credential value + * (API key, password, etc.). Emitted by the credential prompter when + * a tool call needs a missing secret and the daemon delegates + * collection to the active client. + * + * Resolved by a paired `interaction_resolved` event (`kind: "secret"`, + * `state: "answered" | "cancelled"`) once the client posts the secret + * back via the credential-store route or cancels. + * + * `service`, `field`, and `label` are required because the prompter + * always supplies them — they identify which credential the daemon is + * asking for and how to label the input. Optional fields are scope + * hints (`allowedTools`, `allowedDomains`), display affordances + * (`description`, `placeholder`), and the `allowOneTimeSend` override + * used by clients that support a "send without saving" path. + * + * Canonical wire-contract source. Daemon code imports the type + * directly from this file; external consumers import via + * `@vellumai/assistant-api`. + */ + +import { z } from "zod"; + +export const SecretRequestEventSchema = z + .object({ + type: z.literal("secret_request"), + requestId: z.string(), + service: z.string(), + field: z.string(), + label: z.string(), + description: z.string().optional(), + placeholder: z.string().optional(), + conversationId: z.string().optional(), + purpose: z.string().optional(), + allowedTools: z.array(z.string()).optional(), + allowedDomains: z.array(z.string()).optional(), + allowOneTimeSend: z.boolean().optional(), + }) + .strict(); + +export type SecretRequestEvent = z.infer; diff --git a/assistant/src/api/index.ts b/assistant/src/api/index.ts index eedb07caca7..9286280dcb4 100644 --- a/assistant/src/api/index.ts +++ b/assistant/src/api/index.ts @@ -5,6 +5,8 @@ import { AssistantTurnStartEventSchema } from "./events/assistant-turn-start.js" import { AvatarUpdatedEventSchema } from "./events/avatar-updated.js"; import { CompactionCircuitClosedEventSchema } from "./events/compaction-circuit-closed.js"; import { CompactionCircuitOpenEventSchema } from "./events/compaction-circuit-open.js"; +import { ConfirmationRequestEventSchema } from "./events/confirmation-request.js"; +import { ContactRequestEventSchema } from "./events/contact-request.js"; import { ConversationListInvalidatedEventSchema } from "./events/conversation-list-invalidated.js"; import { ConversationTitleUpdatedEventSchema } from "./events/conversation-title-updated.js"; import { DocumentCommentCreatedEventSchema } from "./events/document-comment-created.js"; @@ -22,7 +24,9 @@ import { MessageQueuedEventSchema } from "./events/message-queued.js"; import { MessageQueuedDeletedEventSchema } from "./events/message-queued-deleted.js"; import { MessageRequestCompleteEventSchema } from "./events/message-request-complete.js"; import { OpenUrlEventSchema } from "./events/open-url.js"; +import { QuestionRequestEventSchema } from "./events/question-request.js"; import { RelationshipStateUpdatedEventSchema } from "./events/relationship-state-updated.js"; +import { SecretRequestEventSchema } from "./events/secret-request.js"; import { ToolUseStartEventSchema } from "./events/tool-use-start.js"; export { CALL_SITE_SYNTHETIC_AGENT_ERROR_MESSAGE } from "./constants/call-sites.js"; @@ -51,6 +55,28 @@ export { type CompactionCircuitOpenEvent, CompactionCircuitOpenEventSchema, } from "./events/compaction-circuit-open.js"; +export { + type ACPOption, + type ACPOptionKind, + ACPOptionKindSchema, + ACPOptionSchema, + type AllowlistOption, + AllowlistOptionSchema, + type ConfirmationDiff, + ConfirmationDiffSchema, + type ConfirmationExecutionTarget, + ConfirmationExecutionTargetSchema, + type ConfirmationRequestEvent, + ConfirmationRequestEventSchema, + type DirectoryScopeOption, + DirectoryScopeOptionSchema, + type ScopeOption, + ScopeOptionSchema, +} from "./events/confirmation-request.js"; +export { + type ContactRequestEvent, + ContactRequestEventSchema, +} from "./events/contact-request.js"; export { type ConversationListInvalidatedEvent, ConversationListInvalidatedEventSchema, @@ -120,10 +146,22 @@ export { MessageRequestCompleteEventSchema, } from "./events/message-request-complete.js"; export { type OpenUrlEvent, OpenUrlEventSchema } from "./events/open-url.js"; +export { + type QuestionEntry, + QuestionEntrySchema, + type QuestionOption, + QuestionOptionSchema, + type QuestionRequestEvent, + QuestionRequestEventSchema, +} from "./events/question-request.js"; export { type RelationshipStateUpdatedEvent, RelationshipStateUpdatedEventSchema, } from "./events/relationship-state-updated.js"; +export { + type SecretRequestEvent, + SecretRequestEventSchema, +} from "./events/secret-request.js"; export { type ToolUseStartEvent, ToolUseStartEventSchema, @@ -182,6 +220,8 @@ export const AssistantEventSchema = z.discriminatedUnion("type", [ AvatarUpdatedEventSchema, CompactionCircuitClosedEventSchema, CompactionCircuitOpenEventSchema, + ConfirmationRequestEventSchema, + ContactRequestEventSchema, ConversationListInvalidatedEventSchema, ConversationTitleUpdatedEventSchema, DocumentCommentCreatedEventSchema, @@ -199,7 +239,9 @@ export const AssistantEventSchema = z.discriminatedUnion("type", [ MessageQueuedDeletedEventSchema, MessageRequestCompleteEventSchema, OpenUrlEventSchema, + QuestionRequestEventSchema, RelationshipStateUpdatedEventSchema, + SecretRequestEventSchema, ToolUseStartEventSchema, ]); diff --git a/assistant/src/daemon/message-types/contacts.ts b/assistant/src/daemon/message-types/contacts.ts index 657986e960c..525aa9fdb50 100644 --- a/assistant/src/daemon/message-types/contacts.ts +++ b/assistant/src/daemon/message-types/contacts.ts @@ -1,5 +1,7 @@ // Contact management: list, get, update channel status, and delete. +import type { ContactRequestEvent } from "../../api/events/contact-request.js"; + // === Client → Server === export interface ContactsRequest { @@ -36,25 +38,6 @@ export interface ContactsChanged { type: "contacts_changed"; } -/** - * Server → Client prompt requesting the user to enter a contact channel address. - * Emitted by the `contacts/prompt` IPC route. - */ -export interface ContactRequest { - type: "contact_request"; - requestId: string; - /** Suggested channel type (e.g. "phone", "email") — used as a hint, not enforced. */ - channel?: string; - /** Placeholder text for the address input field. */ - placeholder?: string; - /** Display label shown above the input field. */ - label?: string; - /** Longer description shown below the label. */ - description?: string; - /** Suggested role for the new contact (guardian / trusted-contact / unknown). */ - role?: string; -} - export interface ContactPayload { id: string; displayName: string; @@ -90,4 +73,4 @@ export type _ContactsClientMessages = ContactsRequest; export type _ContactsServerMessages = | ContactsResponse | ContactsChanged - | ContactRequest; + | ContactRequestEvent; diff --git a/assistant/src/daemon/message-types/conversations.ts b/assistant/src/daemon/message-types/conversations.ts index 152cae52b53..77eba74381b 100644 --- a/assistant/src/daemon/message-types/conversations.ts +++ b/assistant/src/daemon/message-types/conversations.ts @@ -220,10 +220,6 @@ export interface ConversationInfo { inferenceProfile?: string; } -// `conversation_title_updated` is now the canonical -// `ConversationTitleUpdatedEvent` defined in -// `assistant/src/api/events/conversation-title-updated.ts` and imported above. - /** Channel binding metadata exposed in conversation list APIs. */ interface ChannelBinding { sourceChannel: ChannelId; @@ -559,13 +555,6 @@ export interface ConversationErrorMessage { profileName?: string; } -// `conversation_list_invalidated` is now the canonical -// `ConversationListInvalidatedEvent` defined in -// `assistant/src/api/events/conversation-list-invalidated.ts` and imported -// above. The `reason` enum (`ConversationListInvalidatedReason`) lives -// there too; the one daemon consumer -// (`runtime/sync/resource-sync-events.ts`) imports it directly. - /** Server push — broadcast when a schedule creates a conversation. */ export interface ScheduleConversationCreated { type: "schedule_conversation_created"; diff --git a/assistant/src/daemon/message-types/messages.ts b/assistant/src/daemon/message-types/messages.ts index c7f712558b5..1f9aa7d7d76 100644 --- a/assistant/src/daemon/message-types/messages.ts +++ b/assistant/src/daemon/message-types/messages.ts @@ -2,12 +2,15 @@ import type { AssistantTextDeltaEvent } from "../../api/events/assistant-text-delta.js"; import type { AssistantTurnStartEvent } from "../../api/events/assistant-turn-start.js"; +import type { ConfirmationRequestEvent } from "../../api/events/confirmation-request.js"; import type { InteractionResolvedEvent } from "../../api/events/interaction-resolved.js"; import type { MessageCompleteEvent } from "../../api/events/message-complete.js"; import type { MessageDequeuedEvent } from "../../api/events/message-dequeued.js"; import type { MessageQueuedEvent } from "../../api/events/message-queued.js"; import type { MessageQueuedDeletedEvent } from "../../api/events/message-queued-deleted.js"; import type { MessageRequestCompleteEvent } from "../../api/events/message-request-complete.js"; +import type { QuestionRequestEvent } from "../../api/events/question-request.js"; +import type { SecretRequestEvent } from "../../api/events/secret-request.js"; import type { ToolUseStartEvent } from "../../api/events/tool-use-start.js"; import type { ChannelId, InterfaceId } from "../../channels/types.js"; import type { CommandIntent, UserMessageAttachment } from "./shared.js"; @@ -168,7 +171,7 @@ export interface ToolResult { * Allowlist options for the rule editor save path (narrowest to * broadest). Each `pattern` is a Minimatch-glob compatible string — * what the gateway actually matches against. Mirrors the - * `allowlistOptions` field on `ConfirmationRequest`. May be absent + * `allowlistOptions` field on `ConfirmationRequestEvent`. May be absent * for tools whose classifier does not produce an allowlist (e.g. * web-risk classifier, MCP tools without classifier coverage). */ @@ -190,124 +193,6 @@ export interface ToolResult { activityMetadata?: ToolActivityMetadata; } -export interface ConfirmationRequest { - type: "confirmation_request"; - requestId: string; - toolName: string; - input: Record; - riskLevel: string; - /** Human-readable reason for the risk classification (e.g. "Modifies remote repository state"). */ - riskReason?: string; - /** Whether the daemon is running in a containerized (Docker) environment. */ - isContainerized?: boolean; - executionTarget?: "sandbox" | "host"; - allowlistOptions: Array<{ - label: string; - description: string; - pattern: string; - }>; - scopeOptions: Array<{ label: string; scope: string }>; - directoryScopeOptions?: Array<{ scope: string; label: string }>; - diff?: { - filePath: string; - oldContent: string; - newContent: string; - isNewFile: boolean; - }; - conversationId?: string; - /** When false, the client should hide "always allow" / trust-rule persistence affordances. */ - persistentDecisionsAllowed?: boolean; - /** The tool_use block ID for client-side correlation with specific tool calls. */ - toolUseId?: string; - /** ACP tool kind from the agent (e.g. "read", "edit", "execute"). Present only for ACP permission requests. */ - acpToolKind?: string; - /** ACP permission options from the agent. Present only for ACP permission requests. Clients should use these to render the correct buttons. */ - acpOptions?: Array<{ - optionId: string; - name: string; - kind: "allow_once" | "allow_always" | "reject_once" | "reject_always"; - }>; -} - -export interface SecretRequest { - type: "secret_request"; - requestId: string; - service: string; - field: string; - label: string; - description?: string; - placeholder?: string; - conversationId?: string; - /** Intended purpose of the credential (displayed to user). */ - purpose?: string; - /** Tools allowed to use this credential. */ - allowedTools?: string[]; - /** Domains where this credential may be used. */ - allowedDomains?: string[]; - /** Whether one-time send override is available. */ - allowOneTimeSend?: boolean; -} - -export interface QuestionOption { - id: string; - label: string; - description?: string; -} - -/** - * One entry in a batched ask-question request. - * - * `id` is daemon-assigned (e.g. `q1`, `q2`...) — the LLM neither sees nor - * supplies it. It exists so the client has a stable handle to post the - * user's answer back against. See `QuestionRequest` for the batching - * contract. - */ -export interface QuestionEntry { - id: string; - question: string; - description?: string; - /** LLM-supplied options, capped at 4. The client always renders a fixed - * 5th "Type something else" slot wired to a free-text response — so this - * array never represents the full choice set the user sees. */ - options: QuestionOption[]; - /** Optional placeholder shown in the free-text input. */ - freeTextPlaceholder?: string; -} - -/** - * A single broadcast that carries either one or a small batch (≤5) of - * clarifying questions. The whole batch is one card lifecycle on the client: - * one render, one state machine, one response submission. - * - * Wire-compat plan: both shapes are populated on every broadcast. - * - `questions[]` is the canonical shape new clients should consume. - * - The flat `question` / `description` / `options` / `freeTextPlaceholder` - * fields mirror `questions[0]` for backwards compat with the existing - * web client, which keys off the flat fields. Once that client adopts - * `questions[]`, the flat fields can be dropped (separate cleanup). - * - * Daemon callers that don't supply a batch get a one-element `questions` - * array synthesized from the flat fields. - */ -export interface QuestionRequest { - type: "question_request"; - requestId: string; - /** Batched-question payload. Always populated (single questions are sent - * as a one-element array). Each entry's `id` is daemon-assigned. */ - questions: QuestionEntry[]; - /** Legacy: mirrors `questions[0].question`. Kept populated for clients - * that haven't adopted the batched `questions[]` shape yet. */ - question: string; - /** Legacy: mirrors `questions[0].description`. */ - description?: string; - /** Legacy: mirrors `questions[0].options`. */ - options: QuestionOption[]; - /** Legacy: mirrors `questions[0].freeTextPlaceholder`. */ - freeTextPlaceholder?: string; - conversationId?: string; - toolUseId?: string; -} - export interface ErrorMessage { type: "error"; conversationId?: string; @@ -467,9 +352,9 @@ export type _MessagesServerMessages = | ToolOutputChunk | ToolInputDelta | ToolResult - | ConfirmationRequest - | SecretRequest - | QuestionRequest + | ConfirmationRequestEvent + | SecretRequestEvent + | QuestionRequestEvent | MessageCompleteEvent | ErrorMessage | MessageQueuedEvent diff --git a/assistant/src/daemon/message-types/settings.ts b/assistant/src/daemon/message-types/settings.ts index ce16f348ad5..596a644dd98 100644 --- a/assistant/src/daemon/message-types/settings.ts +++ b/assistant/src/daemon/message-types/settings.ts @@ -29,9 +29,6 @@ export interface ClientSettingsUpdate { value: string; } -// `avatar_updated` is now the canonical `AvatarUpdatedEvent` defined in -// `assistant/src/api/events/avatar-updated.ts` and imported above. - /** Sent by the daemon when workspace config.json changes on disk. */ export interface ConfigChanged { type: "config_changed"; diff --git a/assistant/src/daemon/message-types/workspace.ts b/assistant/src/daemon/message-types/workspace.ts index 3bd89b2cc22..baa5c401509 100644 --- a/assistant/src/daemon/message-types/workspace.ts +++ b/assistant/src/daemon/message-types/workspace.ts @@ -119,9 +119,6 @@ export interface ToolNamesListResponse { schemas?: Record; } -// `identity_changed` is now the canonical `IdentityChangedEvent` defined in -// `assistant/src/api/events/identity-changed.ts` and imported above. - // --- Domain-level union aliases (consumed by the barrel file) --- export type _WorkspaceClientMessages = diff --git a/assistant/src/permissions/question-prompter.test.ts b/assistant/src/permissions/question-prompter.test.ts index 8744288fd3e..2482dc88bf2 100644 --- a/assistant/src/permissions/question-prompter.test.ts +++ b/assistant/src/permissions/question-prompter.test.ts @@ -1,9 +1,7 @@ import { beforeEach, describe, expect, mock, test } from "bun:test"; -import type { - QuestionRequest, - ServerMessage, -} from "../daemon/message-protocol.js"; +import type { QuestionRequestEvent } from "../api/events/question-request.js"; +import type { ServerMessage } from "../daemon/message-protocol.js"; import type { QuestionBatchSubmission, QuestionPromptResult, @@ -63,11 +61,8 @@ mock.module("../runtime/pending-interactions.js", () => ({ clear: () => _piStore.clear(), })); -const { - QuestionPrompter, - QuestionBatchValidationError, - buildBatchEntries, -} = await import("./question-prompter.js"); +const { QuestionPrompter, QuestionBatchValidationError, buildBatchEntries } = + await import("./question-prompter.js"); function makePrompter() { const sent: ServerMessage[] = []; @@ -176,7 +171,7 @@ describe("QuestionPrompter", () => { const promise = prompter.prompt(singleQuestionParams); expect(sent).toHaveLength(1); - const req = sent[0] as QuestionRequest; + const req = sent[0] as QuestionRequestEvent; expect(req.type).toBe("question_request"); expect(req.questions).toHaveLength(1); expect(req.questions[0]?.id).toBe("q1"); @@ -207,7 +202,7 @@ describe("QuestionPrompter", () => { ], }); - const req = sent[0] as QuestionRequest; + const req = sent[0] as QuestionRequestEvent; expect(req.freeTextPlaceholder).toBe("Type a fruit"); expect(req.questions[0]?.freeTextPlaceholder).toBe("Type a fruit"); @@ -228,7 +223,7 @@ describe("QuestionPrompter", () => { void prompter.prompt(threeQuestionParams); expect(sent).toHaveLength(1); - const req = sent[0] as QuestionRequest; + const req = sent[0] as QuestionRequestEvent; expect(req.questions.map((q) => q.id)).toEqual(["q1", "q2", "q3"]); // Flat fields mirror the first entry for backwards compat. expect(req.question).toBe("Q1?"); @@ -239,7 +234,7 @@ describe("QuestionPrompter", () => { const { prompter, sent } = makePrompter(); const promise = prompter.prompt(threeQuestionParams); - const req = sent[0] as QuestionRequest; + const req = sent[0] as QuestionRequestEvent; resolveBatch(req.requestId, [ { questionId: "q2", kind: "option", optionId: "y" }, @@ -262,7 +257,7 @@ describe("QuestionPrompter", () => { const { prompter, sent } = makePrompter(); const promise = prompter.prompt(threeQuestionParams); - const req = sent[0] as QuestionRequest; + const req = sent[0] as QuestionRequestEvent; resolveBatch(req.requestId, [ { questionId: "q1", kind: "skip" }, @@ -279,7 +274,7 @@ describe("QuestionPrompter", () => { const { prompter, sent } = makePrompter(); const promise = prompter.prompt(threeQuestionParams); - const req = sent[0] as QuestionRequest; + const req = sent[0] as QuestionRequestEvent; closeBatch(req.requestId); @@ -294,12 +289,9 @@ describe("QuestionPrompter", () => { test("buildBatchEntries rejects unknown questionId", () => { expect(() => - buildBatchEntries( - ["q1"], - () => true, - new Set(["q1"]), - [{ questionId: "qX", kind: "option", optionId: "a" }], - ), + buildBatchEntries(["q1"], () => true, new Set(["q1"]), [ + { questionId: "qX", kind: "option", optionId: "a" }, + ]), ).toThrow(QuestionBatchValidationError); }); @@ -347,7 +339,7 @@ describe("QuestionPrompter", () => { ...threeQuestionParams, signal: ac.signal, }); - const req = sent[0] as QuestionRequest; + const req = sent[0] as QuestionRequestEvent; expect(_piStore.has(req.requestId)).toBe(true); ac.abort(); @@ -377,7 +369,7 @@ describe("QuestionPrompter", () => { ...threeQuestionParams, signal: ac.signal, }); - const req = sent[0] as QuestionRequest; + const req = sent[0] as QuestionRequestEvent; expect(_piStore.has(req.requestId)).toBe(true); // Simulate `removeByConversation` clearing the registry entry before diff --git a/assistant/src/permissions/question-prompter.ts b/assistant/src/permissions/question-prompter.ts index 35fece40074..4e859564a0a 100644 --- a/assistant/src/permissions/question-prompter.ts +++ b/assistant/src/permissions/question-prompter.ts @@ -1,11 +1,11 @@ import { v4 as uuid } from "uuid"; -import { getConfig } from "../config/loader.js"; import type { QuestionOption, - QuestionRequest, - ServerMessage, -} from "../daemon/message-protocol.js"; + QuestionRequestEvent, +} from "../api/events/question-request.js"; +import { getConfig } from "../config/loader.js"; +import type { ServerMessage } from "../daemon/message-protocol.js"; import * as pendingInteractions from "../runtime/pending-interactions.js"; import { AssistantError, ErrorCode } from "../util/errors.js"; import { getLogger } from "../util/logger.js"; @@ -161,9 +161,7 @@ export interface QuestionBatchMetadata { * secret prompts, so they share the same idle-timeout knob. */ export class QuestionPrompter { - constructor( - private deps: { broadcastMessage(msg: ServerMessage): void }, - ) {} + constructor(private deps: { broadcastMessage(msg: ServerMessage): void }) {} async prompt(params: QuestionPromptParams): Promise { const { conversationId, questions, toolUseId, signal } = params; @@ -279,7 +277,7 @@ export class QuestionPrompter { // batched payload, and the flat fields mirror `questions[0]` for // backwards compat with clients that haven't adopted `questions[]`. const head = entries[0]!; - const msg: QuestionRequest = { + const msg: QuestionRequestEvent = { type: "question_request", requestId, questions: entries,