feat(vocero): add linked content and TALKFIRST greetings (#2)

Opening a report or podcast link now establishes an authenticated starting reference for the conversation. Users can select other accessible content or compare reports while retaining the original link, and TALKFIRST greetings can name the verified report or podcast.

Release notes:

- Added authenticated report and episode link resolution with bounded lookups and unavailable-content handling
- Added explicit conversation selection without restricting searches or changing the reference during comparisons
- Added contextual TALKFIRST greetings through both voice-proxy and HTTP reasoning paths
- Preserved backend permission checks, conversation isolation, cancellation, and the existing speech protocol

Reviewed-on: AquaPortal/s2s#2
This commit is contained in:
2026-10-08 09:34:16 -04:00
parent 96895996d9
commit 4b0108ecf0
21 changed files with 946 additions and 66 deletions
+8 -3
View File
@@ -8,6 +8,7 @@ import {
reportsSchema,
toolsEnvelopeSchema,
} from "./contracts";
import type { Caller } from "./settings";
export class ApiError extends Error {
readonly status: number;
@@ -123,17 +124,21 @@ export async function getTools(signal: AbortSignal, refresh = false) {
export function conversationHeaders(id: string, turn?: number): Record<string, string> {
return { "X-Vocero-Conversation": id, ...(turn === undefined ? {} : { "X-Vocero-Turn": String(turn) }) };
}
export function createConversation(phoneNumber: string, signal: AbortSignal) {
export function createConversation(caller: Caller, signal: AbortSignal) {
return request(
"/api/conversations",
conversationSchema,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ phoneNumber }),
body: JSON.stringify({
phoneNumber: caller.phoneNumber,
...(caller.reportId ? { report_id: caller.reportId } : {}),
...(caller.episodeId ? { episode_id: caller.episodeId } : {}),
}),
signal,
},
15000,
30000,
);
}
export async function getMcpTools(id: string, signal: AbortSignal) {
+6 -1
View File
@@ -58,10 +58,15 @@ export function saveSettings(settings: Partial<Settings>) {
export interface Caller {
phoneNumber: string;
episodeId: string;
reportId: string;
}
export function callerFromLocation(search = window.location.search): Caller {
const query = new URLSearchParams(search);
return { phoneNumber: (query.get("phoneNumber") ?? "").trim(), episodeId: (query.get("episode_id") ?? "").trim() };
return {
phoneNumber: (query.get("phoneNumber") ?? "").trim(),
episodeId: (query.get("episode_id") ?? "").trim(),
reportId: (query.get("report_id") ?? "").trim(),
};
}
export function realtimeUrl(raw: string, caller: Caller): string {
let value = raw.trim();
+102 -6
View File
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest";
import { callerFromLocation } from "../lib/settings";
import type { ClientOptions } from "./client";
import { VoiceController } from "./controller";
@@ -72,7 +73,7 @@ beforeEach(() => {
return Promise.resolve(new Response(JSON.stringify(responses[pathname] ?? {})));
});
vi.stubGlobal("fetch", fetchMock);
controller = new VoiceController({ phoneNumber: "1234", episodeId: "episode1" });
controller = new VoiceController({ phoneNumber: "1234", episodeId: "episode1", reportId: "" });
});
afterEach(async () => {
await controller.stop();
@@ -371,8 +372,8 @@ describe("voice application lifecycle", () => {
describe("authenticated MCP conversations", () => {
const relayUrl = () =>
`${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/v1/realtime`;
function enableMcp(reasoningType = "OPENAI") {
controller = new VoiceController({ phoneNumber: "+56000000001", episodeId: "episode1" });
function enableMcp(reasoningType = "OPENAI", startSessionMode = "") {
controller = new VoiceController({ phoneNumber: "+56000000001", episodeId: "episode1", reportId: "" });
const original = fetchMock.getMockImplementation();
let sequence = 0;
if (!original) {
@@ -380,7 +381,9 @@ describe("authenticated MCP conversations", () => {
}
fetchMock.mockImplementation((path, init) => {
if (path === "/api/config") {
return Promise.resolve(new Response(JSON.stringify({ mcp: true, reasoningType, s2sUrl: relayUrl() })));
return Promise.resolve(
new Response(JSON.stringify({ mcp: true, reasoningType, startSessionMode, s2sUrl: relayUrl() })),
);
}
if (path === "/api/conversations") {
sequence++;
@@ -438,6 +441,99 @@ describe("authenticated MCP conversations", () => {
expect(MockClient.instances[1]?.options.directUrl).toContain("conversation_id=conversation-2");
});
it.each([
["", {}],
["&report_id=report-example", { report_id: "report-example" }],
["&episode_id=episode-1", { episode_id: "episode-1" }],
["&report_id=%20report-example%20&episode_id=episode-1", { report_id: "report-example", episode_id: "episode-1" }],
["&report_id=%20&episode_id=", {}],
])("binds page selectors at creation and restart for %s", async (query, selectors) => {
enableMcp("PROXY");
controller = new VoiceController(callerFromLocation(`?phoneNumber=%2B56000000001${query}&title=Untrusted`));
const client = await start();
expect(client.options.directUrl).toBe(`${relayUrl()}?conversation_id=conversation-1`);
expect(client.options.instructions).not.toContain("Untrusted");
await controller.stop();
await controller.start();
const creations = fetchMock.mock.calls.filter(([path]) => path === "/api/conversations");
expect(creations).toHaveLength(2);
for (const [, init] of creations) {
expect(init?.body).toBe(JSON.stringify({ phoneNumber: "+56000000001", ...selectors }));
}
});
it("waits for linked content before TALKFIRST and permits the bounded server lookup", async () => {
enableMcp("PROXY", "TALKFIRST");
controller = new VoiceController(callerFromLocation("?phoneNumber=%2B56000000001&report_id=report-example"));
const original = fetchMock.getMockImplementation();
if (!original) {
throw new Error("Missing fetch mock");
}
let finishConversation: (() => void) | undefined;
let bootstrapSignal: AbortSignal | null | undefined;
fetchMock.mockImplementation((path, init) => {
if (path !== "/api/conversations") {
return original(path, init);
}
bootstrapSignal = init?.signal;
return new Promise<Response>((resolve) => {
finishConversation = () => {
void original(path, init).then(resolve);
};
});
});
const initializing = controller.initialize();
await vi.advanceTimersByTimeAsync(15001);
expect(bootstrapSignal?.aborted).toBe(false);
expect(controller.getSnapshot().contextLoading).toBe(true);
controller.offerAutoStart();
expect(MockClient.instances).toHaveLength(0);
finishConversation?.();
await initializing;
controller.offerAutoStart();
controller.offerAutoStart();
expect(MockClient.instances).toHaveLength(1);
expect(MockClient.instances[0]?.options.greeting).toBe("hola vocero");
});
it("uses authenticated HTTP reasoning once for the OPENAI TALKFIRST greeting", async () => {
enableMcp("OPENAI", "TALKFIRST");
const client = await start();
expect(client.options.greeting).toBeUndefined();
const greetings = fetchMock.mock.calls.filter(([path]) => path === "/api/reasoning");
expect(greetings).toHaveLength(1);
expect(greetings[0]?.[1]?.body).toContain('"transcript":"hola vocero"');
expect(new Headers(greetings[0]?.[1]?.headers).get("X-Vocero-Conversation")).toBe("conversation-1");
pendingReasoning?.(new Response(JSON.stringify({ answer: "Hola, Alice. Abriste el reporte de alimentación." })));
await vi.advanceTimersByTimeAsync(1);
expect(client.renderExternalAnswer).toHaveBeenCalledExactlyOnceWith(
"Hola, Alice. Abriste el reporte de alimentación.",
);
});
it("discards an HTTP opening greeting that arrives after the user speaks", async () => {
enableMcp("OPENAI", "TALKFIRST");
const client = await start();
const greetingResponse = pendingReasoning;
client.emit("status", "user-speaking");
client.emit("transcript", { id: "question", role: "user", text: "Veamos otro reporte", partial: false });
greetingResponse?.(new Response(JSON.stringify({ answer: "Saludo anterior" })));
pendingReasoning?.(new Response(JSON.stringify({ answer: "Revisemos otro reporte" })));
await vi.advanceTimersByTimeAsync(1);
expect(client.renderExternalAnswer).toHaveBeenCalledExactlyOnceWith("Revisemos otro reporte");
});
it("lets the user speak first while the TALKFIRST connection is becoming ready", async () => {
enableMcp("OPENAI", "TALKFIRST");
await controller.initialize();
const starting = controller.start();
const client = MockClient.instances[0];
expect(client).toBeDefined();
client?.emit("status", "user-speaking");
await starting;
expect(fetchMock.mock.calls.some(([path]) => path === "/api/reasoning")).toBe(false);
});
it("blocks voice startup when phone login fails and retries on a later manual start", async () => {
enableMcp();
const original = fetchMock.getMockImplementation();
@@ -466,7 +562,7 @@ describe("authenticated MCP conversations", () => {
it("does not initialize voice when the phone selector is missing", async () => {
enableMcp();
controller = new VoiceController({ phoneNumber: "", episodeId: "" });
controller = new VoiceController({ phoneNumber: "", episodeId: "", reportId: "" });
await controller.initialize();
await controller.start();
expect(MockClient.instances).toHaveLength(0);
@@ -538,7 +634,7 @@ describe("authenticated MCP conversations", () => {
it("keeps separate conversation IDs in separate controller instances", async () => {
enableMcp();
await controller.initialize();
const other = new VoiceController({ phoneNumber: "5678", episodeId: "" });
const other = new VoiceController({ phoneNumber: "5678", episodeId: "", reportId: "" });
await other.initialize();
await controller.start();
await other.start();
+17 -7
View File
@@ -217,7 +217,7 @@ export class VoiceController {
if (!this.caller.phoneNumber) {
throw new Error("Abre Vocero desde un enlace que incluya tu número de teléfono.");
}
const conversation = await createConversation(this.caller.phoneNumber, signal);
const conversation = await createConversation(this.caller, signal);
createdId = conversation.conversationId;
const tools = await getMcpTools(createdId, signal);
signal.throwIfAborted();
@@ -359,7 +359,7 @@ export class VoiceController {
const target = connectionTarget(
this.state.config,
this.state.settings,
this.state.config.mcp ? { phoneNumber: "", episodeId: "" } : this.caller,
this.state.config.mcp ? { phoneNumber: "", episodeId: "", reportId: "" } : this.caller,
);
context = preparedContext ?? createAudioContext();
if (automatic && context.state !== "running") {
@@ -388,6 +388,10 @@ export class VoiceController {
}
this.reasoned.clear();
this.sessionNumber++;
const startMode = this.state.config.startSessionMode.trim().toUpperCase();
const openingGreeting = greeting ?? (["TALK", "TALKFIRST"].includes(startMode) ? "hola vocero" : undefined);
const reasonGreeting =
this.state.config.mcp && this.state.config.reasoningType === "OPENAI" && startMode === "TALKFIRST";
const client = new RealtimeClient({
...target,
context,
@@ -395,11 +399,7 @@ export class VoiceController {
instructions: speechInstructions(this.instructions(), this.state.config),
tools: this.tools(),
gate: this.gate(),
greeting:
greeting ??
(["TALK", "TALKFIRST"].includes(this.state.config.startSessionMode.toUpperCase())
? "hola vocero"
: undefined),
greeting: reasonGreeting ? undefined : openingGreeting,
});
this.client = client;
this.subscriptions = [
@@ -489,7 +489,17 @@ export class VoiceController {
}
}, 1000);
try {
const openingTurn = this.reasoningTurn;
await client.connect();
if (
reasonGreeting &&
openingGreeting &&
this.client === client &&
!this.requests.signal.aborted &&
this.reasoningTurn === openingTurn
) {
void this.reason({ id: "opening", role: "user", text: openingGreeting, partial: false }, client);
}
} catch (error) {
// A closed event may already have cleared the client; explicit cancellation is silent.
if (!(error instanceof SessionError && error.code === "aborted") && (!this.client || this.client === client)) {