From b9ba98e518b93450b123d4a877c396216d6da046 Mon Sep 17 00:00:00 2001
From: Julio M Cruz
Date: Sat, 15 Aug 2026 12:32:05 -0400
Subject: [PATCH] feat(voice): add final-turn chat consent UI
---
.../agents/[agentId]/AgentVoiceCallCard.tsx | 36 +++++++++
.../[agentId]/AgentVoiceCallController.tsx | 43 +++++++++-
app/(app)/agents/[agentId]/page.tsx | 2 +-
app/lib/agentVoice.ts | 1 +
app/lib/perkosApi.ts | 22 ++++-
tests/AgentVoiceCallCard.test.tsx | 14 ++++
tests/AgentVoiceCallController.test.tsx | 81 ++++++++++++++++++-
7 files changed, 189 insertions(+), 10 deletions(-)
diff --git a/app/(app)/agents/[agentId]/AgentVoiceCallCard.tsx b/app/(app)/agents/[agentId]/AgentVoiceCallCard.tsx
index 716a703..30f2a6f 100644
--- a/app/(app)/agents/[agentId]/AgentVoiceCallCard.tsx
+++ b/app/(app)/agents/[agentId]/AgentVoiceCallCard.tsx
@@ -3,6 +3,7 @@
import { Loader2, Mic, MicOff, PhoneOff } from "lucide-react";
import { Button } from "@/components/ui/button";
+import { Checkbox } from "@/components/ui/checkbox";
import {
Card,
CardContent,
@@ -28,6 +29,10 @@ export function AgentVoiceCallCard({
onEnd,
error,
remoteAudioStatus,
+ chatMirrorAvailable = false,
+ chatMirrorEnabled = false,
+ chatMirrorScope = "direct",
+ onChatMirrorEnabledChange,
}: {
agentName: string;
capability?: AgentVoiceCapability | null;
@@ -36,10 +41,16 @@ export function AgentVoiceCallCard({
onEnd?: () => void;
error?: string | null;
remoteAudioStatus?: string | null;
+ chatMirrorAvailable?: boolean;
+ chatMirrorEnabled?: boolean;
+ chatMirrorScope?: "direct" | "project";
+ onChatMirrorEnabledChange?: (enabled: boolean) => void;
}) {
const state = callState ?? resolveAgentVoiceState(capability);
const busy = BUSY_STATES.includes(state);
const enabled = canStartAgentVoiceCall(state) && Boolean(onStart);
+ const mirrorLocked = busy || state === "in-call";
+ const mirrorDestination = chatMirrorScope === "project" ? "project chat" : "direct chat";
return (
@@ -58,6 +69,31 @@ export function AgentVoiceCallCard({
? `${agentName} has not reported a verified voice gateway and speech provider. Text availability does not enable voice.`
: "Voice calls use an active project meeting and temporary microphone processing consent."}
+ {chatMirrorAvailable ? (
+
+
+
onChatMirrorEnabledChange?.(checked === true)}
+ />
+
+
+ Save final voice turns to {mirrorDestination}
+
+
+ On by default. Turn this off for a private, non-transcribed call. No raw audio or interim text is saved.
+
+
+
+
+ {chatMirrorEnabled
+ ? `Final user and agent text will be saved to ${mirrorDestination}.`
+ : "Private call: no final user or agent text will be saved."}
+
+
+ ) : null}
{state === "in-call" ? End call :
{busy ? : }
{state === "failed" || state === "ended" ? "Retry voice call" : `Call ${agentName}`}
diff --git a/app/(app)/agents/[agentId]/AgentVoiceCallController.tsx b/app/(app)/agents/[agentId]/AgentVoiceCallController.tsx
index f813a88..c0b8bd9 100644
--- a/app/(app)/agents/[agentId]/AgentVoiceCallController.tsx
+++ b/app/(app)/agents/[agentId]/AgentVoiceCallController.tsx
@@ -7,25 +7,36 @@ import { AgentVoiceCallCard } from "./AgentVoiceCallCard";
import type { AgentVoiceState } from "../../../lib/agentVoice";
import {
cancelVoiceSessionApi, createMeetingJoinSessionApi, createProjectMeetingApi, createVoiceSessionApi,
- endProjectMeetingApi, getAgentVoiceCapabilityApi, getVoiceSessionApi, startProjectMeetingApi,
+ endProjectMeetingApi, ensureAgentConv, getAgentVoiceCapabilityApi, getVoiceSessionApi, startProjectMeetingApi,
type ProjectDetail, type ProjectMeeting, type VoiceSessionApi,
} from "../../../lib/perkosApi";
-export function AgentVoiceCallController({ agentId, agentName, project }: { agentId: string; agentName: string; project?: ProjectDetail }) {
+export function AgentVoiceCallController({ agentId, agentName, project, chatCommitScopeKind = "direct", chatConversationId }: { agentId: string; agentName: string; project?: ProjectDetail; chatCommitScopeKind?: "direct" | "project"; chatConversationId?: string }) {
const [callState, setCallState] = useState(null); const [error, setError] = useState(null);
const [activeSession, setActiveSession] = useState(null);
const [remoteAudioStatus, setRemoteAudioStatus] = useState(null);
+ const [mirrorFinalTurns, setMirrorFinalTurns] = useState(true);
const roomRef = useRef(null); const meetingRef = useRef(null); const sessionRef = useRef(null);
const remoteAudioRef = useRef(null);
const remoteAudioTrackRef = useRef<{ detach: (element?: HTMLMediaElement) => HTMLMediaElement[] } | null>(null);
const projectId = project?.project.id ?? "";
const capability = useQuery({ queryKey: ["agent-voice-capability", projectId, agentId], queryFn: () => getAgentVoiceCapabilityApi({ projectId, agentId }), enabled: Boolean(projectId), refetchInterval: 15_000 });
+ const directConversation = useQuery({
+ queryKey: ["agent-conv", agentId, "voice-chat-commit"],
+ queryFn: () => ensureAgentConv({ agentId }),
+ enabled: chatCommitScopeKind === "direct" && capability.data?.supportsFinalChatMirror === true,
+ staleTime: 5 * 60 * 1000,
+ });
const capabilityState: AgentVoiceState = !projectId || capability.isError
? "unavailable"
: capability.isFetching && !capability.data
? "checking"
: capability.data?.available && capability.data.status === "ready" ? "ready" : "unavailable";
const state = callState ?? capabilityState;
+ const resolvedConversationId = chatCommitScopeKind === "direct"
+ ? directConversation.data?.convId
+ : chatConversationId;
+ const chatMirrorAvailable = capability.data?.supportsFinalChatMirror === true && Boolean(resolvedConversationId);
useEffect(() => {
const session = activeSession; const meeting = meetingRef.current;
if (!session || !meeting || !["connecting", "reconnecting"].includes(state)) return;
@@ -79,7 +90,19 @@ export function AgentVoiceCallController({ agentId, agentName, project }: { agen
noiseSuppression: true,
autoGainControl: true,
});
- const session = await createVoiceSessionApi({ projectId, meetingId: meeting.id, agentId });
+ const mirrorEnabled = chatMirrorAvailable && mirrorFinalTurns && Boolean(resolvedConversationId);
+ const session = await createVoiceSessionApi({
+ projectId,
+ meetingId: meeting.id,
+ agentId,
+ chatCommit: mirrorEnabled
+ ? {
+ policy: "final_pair",
+ consent: true,
+ scope: { kind: chatCommitScopeKind, conversationId: resolvedConversationId! },
+ }
+ : { policy: "none" },
+ });
sessionRef.current = session;
setActiveSession(session);
} catch (cause) {
@@ -107,5 +130,17 @@ export function AgentVoiceCallController({ agentId, agentName, project }: { agen
} catch (cause) { setError(cause instanceof Error ? cause.message : "Could not end voice call."); setCallState("failed"); }
finally { roomRef.current = null; meetingRef.current = null; sessionRef.current = null; setActiveSession(null); }
};
- return void start() : undefined} onEnd={() => void end()} error={error} remoteAudioStatus={remoteAudioStatus} />;
+ return void start() : undefined}
+ onEnd={() => void end()}
+ error={error}
+ remoteAudioStatus={remoteAudioStatus}
+ chatMirrorAvailable={chatMirrorAvailable}
+ chatMirrorEnabled={chatMirrorAvailable && mirrorFinalTurns}
+ chatMirrorScope={chatCommitScopeKind}
+ onChatMirrorEnabledChange={setMirrorFinalTurns}
+ />;
}
diff --git a/app/(app)/agents/[agentId]/page.tsx b/app/(app)/agents/[agentId]/page.tsx
index 0455d17..5c764d6 100644
--- a/app/(app)/agents/[agentId]/page.tsx
+++ b/app/(app)/agents/[agentId]/page.tsx
@@ -282,7 +282,7 @@ export default function AgentDetailPage({ params }: PageProps) {
: undefined}
/>
-
+
diff --git a/app/lib/agentVoice.ts b/app/lib/agentVoice.ts
index b197b10..89ec488 100644
--- a/app/lib/agentVoice.ts
+++ b/app/lib/agentVoice.ts
@@ -16,6 +16,7 @@ export type AgentVoiceCapability = {
available: boolean;
status: "pending" | "ready" | "unavailable";
reason?: "gateway_pending" | "provider_pending" | "not_supported";
+ supportsFinalChatMirror?: boolean;
};
export function resolveAgentVoiceState(
diff --git a/app/lib/perkosApi.ts b/app/lib/perkosApi.ts
index a1f2757..a1ef15b 100644
--- a/app/lib/perkosApi.ts
+++ b/app/lib/perkosApi.ts
@@ -2578,7 +2578,15 @@ export type VoiceGatewayGrant = {
expiresAt: string;
};
-export type AgentVoiceCapabilityApi = { available: boolean; status: "ready" | "unavailable"; reason?: "gateway_pending" | "provider_pending" | "not_supported"; expiresAt?: string };
+export type VoiceChatCommit =
+ | { policy: "none" }
+ | {
+ policy: "final_pair";
+ consent: true;
+ scope: { kind: "direct" | "project"; conversationId: string };
+ };
+
+export type AgentVoiceCapabilityApi = { available: boolean; status: "ready" | "unavailable"; reason?: "gateway_pending" | "provider_pending" | "not_supported"; expiresAt?: string; supportsFinalChatMirror?: boolean };
export type VoiceSessionApi = { id: string; status: "pending" | "claimed" | "joined" | "completed" | "failed" | "cancelled" | "expired"; expiresAt: string; reason?: string };
export async function getAgentVoiceCapabilityApi(input: { projectId: string; agentId: string; owner?: string }): Promise {
@@ -2587,8 +2595,16 @@ export async function getAgentVoiceCapabilityApi(input: { projectId: string; age
return payload.capability;
}
-export async function createVoiceSessionApi(input: { projectId: string; meetingId: string; agentId: string; owner?: string }): Promise {
- const payload = await meetingRequest<{ session: VoiceSessionApi }>(`/api/projects/${encodeURIComponent(input.projectId)}/meetings/${encodeURIComponent(input.meetingId)}/voice-sessions`, { method: "POST", body: JSON.stringify({ owner: input.owner, agentId: input.agentId, voiceProcessingConsent: true }) });
+export async function createVoiceSessionApi(input: { projectId: string; meetingId: string; agentId: string; owner?: string; chatCommit: VoiceChatCommit }): Promise {
+ const payload = await meetingRequest<{ session: VoiceSessionApi }>(`/api/projects/${encodeURIComponent(input.projectId)}/meetings/${encodeURIComponent(input.meetingId)}/voice-sessions`, {
+ method: "POST",
+ body: JSON.stringify({
+ owner: input.owner,
+ agentId: input.agentId,
+ voiceProcessingConsent: true,
+ chatCommit: input.chatCommit,
+ }),
+ });
return payload.session;
}
diff --git a/tests/AgentVoiceCallCard.test.tsx b/tests/AgentVoiceCallCard.test.tsx
index 3a9d891..44d1ac8 100644
--- a/tests/AgentVoiceCallCard.test.tsx
+++ b/tests/AgentVoiceCallCard.test.tsx
@@ -33,4 +33,18 @@ describe("AgentVoiceCallCard", () => {
render( );
expect(screen.getByRole("status")).toHaveTextContent("Remote audio playing.");
});
+
+ it("shows default-on final-turn history with a private opt-out", () => {
+ render( undefined}
+ />);
+
+ expect(screen.getByRole("checkbox", { name: "Save final voice turns to direct chat" })).toBeChecked();
+ expect(screen.getByText(/turn this off for a private, non-transcribed call/i)).toBeVisible();
+ expect(screen.getByText(/no raw audio or interim text is saved/i)).toBeVisible();
+ });
});
diff --git a/tests/AgentVoiceCallController.test.tsx b/tests/AgentVoiceCallController.test.tsx
index 0644f67..95f38c5 100644
--- a/tests/AgentVoiceCallController.test.tsx
+++ b/tests/AgentVoiceCallController.test.tsx
@@ -6,7 +6,11 @@ const mocks = vi.hoisted(() => ({
getSession: vi.fn(), cancelSession: vi.fn(), endMeeting: vi.fn(), connect: vi.fn(), disconnect: vi.fn(), microphone: vi.fn(),
handlers: new Map void>(),
}));
-vi.mock("@tanstack/react-query", () => ({ useQuery: () => ({ data: { available: true, status: "ready" }, isError: false, isFetching: false }) }));
+vi.mock("@tanstack/react-query", () => ({
+ useQuery: ({ queryKey }: { queryKey: string[] }) => queryKey[0] === "agent-conv"
+ ? { data: { convId: "canonical-direct-conv" }, isError: false, isFetching: false }
+ : { data: { available: true, status: "ready", supportsFinalChatMirror: true }, isError: false, isFetching: false },
+}));
vi.mock("livekit-client", () => ({
Track: { Kind: { Audio: "audio" } },
RoomEvent: { TrackSubscribed: "subscribed", TrackUnsubscribed: "unsubscribed", Disconnected: "disconnected" },
@@ -49,7 +53,7 @@ describe("AgentVoiceCallController", () => {
await waitFor(() => expect(mocks.createSession).toHaveBeenCalledOnce());
mocks.handlers.get("subscribed")?.(track);
- expect(await screen.findByRole("status")).toHaveTextContent("Remote audio playing.");
+ expect(await screen.findByText("Remote audio playing.")).toBeVisible();
const audio = attached[0] as HTMLAudioElement;
expect(audio).toMatchObject({ autoplay: true, playsInline: true, controls: false, muted: false });
expect(play).toHaveBeenCalled();
@@ -73,6 +77,79 @@ describe("AgentVoiceCallController", () => {
}));
});
+ it("mirrors final turns to direct chat by default", async () => {
+ const project = { project: { id: "project-1", pmAgent: "Bragi" } } as never;
+ render( );
+
+ expect(screen.getByRole("checkbox", { name: "Save final voice turns to direct chat" })).toBeChecked();
+ fireEvent.click(screen.getByRole("button", { name: "Call Bragi" }));
+
+ await waitFor(() => expect(mocks.createSession).toHaveBeenCalledWith({
+ projectId: "project-1",
+ meetingId: "meeting-1",
+ agentId: "bragi-enrollment",
+ chatCommit: {
+ policy: "final_pair",
+ consent: true,
+ scope: { kind: "direct", conversationId: "canonical-direct-conv" },
+ },
+ }));
+ });
+
+ it("sends an explicit off policy for a private call", async () => {
+ const project = { project: { id: "project-1", pmAgent: "Bragi" } } as never;
+ render( );
+
+ fireEvent.click(screen.getByRole("checkbox", { name: "Save final voice turns to direct chat" }));
+ expect(screen.getByText("Private call: no final user or agent text will be saved.")).toBeVisible();
+ fireEvent.click(screen.getByRole("button", { name: "Call Bragi" }));
+
+ await waitFor(() => expect(mocks.createSession).toHaveBeenCalledWith({
+ projectId: "project-1",
+ meetingId: "meeting-1",
+ agentId: "bragi-enrollment",
+ chatCommit: { policy: "none" },
+ }));
+ });
+
+ it("preserves an explicit project-chat scope in the session payload", async () => {
+ const project = { project: { id: "project-1", pmAgent: "Bragi" } } as never;
+ render( );
+
+ expect(screen.getByRole("checkbox", { name: "Save final voice turns to project chat" })).toBeChecked();
+ fireEvent.click(screen.getByRole("button", { name: "Call Bragi" }));
+
+ await waitFor(() => expect(mocks.createSession).toHaveBeenCalledWith(expect.objectContaining({
+ chatCommit: {
+ policy: "final_pair",
+ consent: true,
+ scope: { kind: "project", conversationId: "canonical-project-conv" },
+ },
+ })));
+ });
+
+ it("fails chat persistence closed when project scope has no canonical conversation", async () => {
+ const project = { project: { id: "project-1", pmAgent: "Bragi" } } as never;
+ render( );
+
+ expect(screen.queryByRole("checkbox", { name: "Save final voice turns to project chat" })).not.toBeInTheDocument();
+ fireEvent.click(screen.getByRole("button", { name: "Call Bragi" }));
+ await waitFor(() => expect(mocks.createSession).toHaveBeenCalledWith(expect.objectContaining({
+ chatCommit: { policy: "none" },
+ })));
+ });
+
it("fails closed when microphone audio processing cannot be enabled", async () => {
mocks.microphone.mockRejectedValueOnce(new Error("Microphone unavailable"));
const project = { project: { id: "project-1", pmAgent: "Bragi" } } as never;