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
36 changes: 36 additions & 0 deletions app/(app)/agents/[agentId]/AgentVoiceCallCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -28,6 +29,10 @@ export function AgentVoiceCallCard({
onEnd,
error,
remoteAudioStatus,
chatMirrorAvailable = false,
chatMirrorEnabled = false,
chatMirrorScope = "direct",
onChatMirrorEnabledChange,
}: {
agentName: string;
capability?: AgentVoiceCapability | null;
Expand All @@ -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 (
<Card>
Expand All @@ -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."}
</p>
{chatMirrorAvailable ? (
<div className="rounded-md border border-border/70 p-3">
<div className="flex items-start gap-2">
<Checkbox
aria-label={`Save final voice turns to ${mirrorDestination}`}
checked={chatMirrorEnabled}
disabled={mirrorLocked}
onCheckedChange={(checked) => onChatMirrorEnabledChange?.(checked === true)}
/>
<div className="space-y-1">
<p className="text-sm font-medium">
Save final voice turns to {mirrorDestination}
</p>
<p className="text-xs text-muted-foreground">
On by default. Turn this off for a private, non-transcribed call. No raw audio or interim text is saved.
</p>
</div>
</div>
<p className="mt-2 text-xs text-muted-foreground" role="status">
{chatMirrorEnabled
? `Final user and agent text will be saved to ${mirrorDestination}.`
: "Private call: no final user or agent text will be saved."}
</p>
</div>
) : null}
{state === "in-call" ? <Button variant="destructive" className="gap-2" onClick={onEnd}><PhoneOff className="h-4 w-4" />End call</Button> : <Button className="gap-2" disabled={!enabled || busy} aria-disabled={!enabled || busy} onClick={onStart}>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Mic className="h-4 w-4" />}
{state === "failed" || state === "ended" ? "Retry voice call" : `Call ${agentName}`}
Expand Down
43 changes: 39 additions & 4 deletions app/(app)/agents/[agentId]/AgentVoiceCallController.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<AgentVoiceState | null>(null); const [error, setError] = useState<string | null>(null);
const [activeSession, setActiveSession] = useState<VoiceSessionApi | null>(null);
const [remoteAudioStatus, setRemoteAudioStatus] = useState<string | null>(null);
const [mirrorFinalTurns, setMirrorFinalTurns] = useState(true);
const roomRef = useRef<Room | null>(null); const meetingRef = useRef<ProjectMeeting | null>(null); const sessionRef = useRef<VoiceSessionApi | null>(null);
const remoteAudioRef = useRef<HTMLAudioElement | null>(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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 <AgentVoiceCallCard agentName={agentName} capability={capability.data ?? null} callState={state} onStart={project ? () => void start() : undefined} onEnd={() => void end()} error={error} remoteAudioStatus={remoteAudioStatus} />;
return <AgentVoiceCallCard
agentName={agentName}
capability={capability.data ?? null}
callState={state}
onStart={project ? () => void start() : undefined}
onEnd={() => void end()}
error={error}
remoteAudioStatus={remoteAudioStatus}
chatMirrorAvailable={chatMirrorAvailable}
chatMirrorEnabled={chatMirrorAvailable && mirrorFinalTurns}
chatMirrorScope={chatCommitScopeKind}
onChatMirrorEnabledChange={setMirrorFinalTurns}
/>;
}
2 changes: 1 addition & 1 deletion app/(app)/agents/[agentId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@ export default function AgentDetailPage({ params }: PageProps) {
: undefined}
/>

<AgentVoiceCallController agentId={agent.id} agentName={agent.name} project={voiceProject} />
<AgentVoiceCallController agentId={agent.id} agentName={agent.name} project={voiceProject} chatCommitScopeKind="direct" />

<section className="grid grid-cols-1 gap-3 md:grid-cols-2">
<MetadataCard agent={agent} />
Expand Down
1 change: 1 addition & 0 deletions app/lib/agentVoice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
22 changes: 19 additions & 3 deletions app/lib/perkosApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AgentVoiceCapabilityApi> {
Expand All @@ -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<VoiceSessionApi> {
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<VoiceSessionApi> {
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;
}

Expand Down
14 changes: 14 additions & 0 deletions tests/AgentVoiceCallCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,18 @@ describe("AgentVoiceCallCard", () => {
render(<AgentVoiceCallCard agentName="Bragi" callState="in-call" remoteAudioStatus="Remote audio playing." />);
expect(screen.getByRole("status")).toHaveTextContent("Remote audio playing.");
});

it("shows default-on final-turn history with a private opt-out", () => {
render(<AgentVoiceCallCard
agentName="Bragi"
callState="ready"
chatMirrorAvailable
chatMirrorEnabled
onChatMirrorEnabledChange={() => 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();
});
});
81 changes: 79 additions & 2 deletions tests/AgentVoiceCallController.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, (track: unknown) => 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" },
Expand Down Expand Up @@ -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();
Expand All @@ -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(<AgentVoiceCallController agentId="bragi-enrollment" agentName="Bragi" project={project} />);

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(<AgentVoiceCallController agentId="bragi-enrollment" agentName="Bragi" project={project} />);

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(<AgentVoiceCallController
agentId="bragi-enrollment"
agentName="Bragi"
project={project}
chatCommitScopeKind="project"
chatConversationId="canonical-project-conv"
/>);

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(<AgentVoiceCallController
agentId="bragi-enrollment"
agentName="Bragi"
project={project}
chatCommitScopeKind="project"
/>);

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;
Expand Down
Loading