Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/web/src/domains/chat/api/event-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
36 changes: 27 additions & 9 deletions apps/web/src/domains/chat/utils/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
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.",
};
Expand All @@ -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
Expand Down Expand Up @@ -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--) {
Expand All @@ -77,9 +87,9 @@ const VOICE_ERROR_MESSAGES: Readonly<Record<string, string>> = {
"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":
Expand Down Expand Up @@ -119,7 +129,9 @@ const BACKGROUND_CONVERSATION_SOURCES: ReadonlySet<string> = 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
Expand Down Expand Up @@ -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!];
Expand Down Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,37 @@ 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",
});
});
});

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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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,
Expand All @@ -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,
Expand Down
Loading
Loading