Files
PaperClipAI/tests/speko-voice-proof/browser-client.mjs
T
DottaandPaperclip 2238831ecd test(voice): qualify Speko task conversations and Storybook surfaces (#15729)
Third PR: depends on #15728 and #15727. The user has reviewed and
authorized merging this experimental feature stack.

## Thinking Path

> - Paperclip manages agent work through tasks and governed connections.
> - Chat connections already bind external conversations to those tasks.
> - Speko handles voice and telephony, while the selected Paperclip
agent does the work.
> - A call needs durable session authority, ordered follow-ups, and safe
result delivery.
> - This PR adds repeatable qualification for the Speko voice task
integration.
> - This PR depends on #15728, which depends on #15727. Speko stays
experimental.

## Linked Issues or Issue Description

**Problem or motivation**

Phone and browser voice conversations cannot use the chat connection
task lifecycle. A separate voice runtime is needed to preserve task
authority, retries, and reply history.

**Proposed solution**

Add company-scoped voice sessions, signed callbacks, durable replies,
inbound intake, and an agent-facing outbound-call tool. Reuse existing
tasks, queues, secrets, and publications.

**Roadmap alignment**

This extends the experimental chat connector entry points under Agent
Chat and Connected Apps. The project owner requested this feature.

Refs #11418 for the original provider catalog proposal. This
implementation uses the newer chat connection infrastructure rather than
an MCP catalog-only integration. Refs #15721 for remote OpenCode
startup.

## What Changed

- Add a real Paperclip server/database E2E fixture that stubs only
external Speko transport and the execution provider.
- Add authenticated multi-user and cross-company qualification journeys.
- Add Storybook interaction, accessibility, responsive, and visual-suite
integration.
- Add an E01–E16 runbook, component-to-story inventory, and current
redacted qualification report.
- Keep historical private call traces out of the PR. Provider proof
tools are opt-in.

## Verification

- All 21 standalone proof tests and browser client/server syntax checks
pass. They cover signatures, redaction, cleanup retry, final audio
saves, bounded report transport and late server write ordering. No live
calls were placed.
- All 27 focused UI/controller regressions, UI typecheck, token gates,
and 28 map/catalog structural tests pass. The shared connection cache
test covers pause/resume invalidation during the 30-second fresh period.
- Storybook builds 157 Speko production-component stories. The complete
prior 156-story set passed all 624 checks on UI source 249cbc616a. After
the final cache repair, all 152 affected page/launcher checks passed
across both themes, 390/1200 widths and reduced motion. Real microphone
access and external provider requests are blocked.
- Hosted Storybook origin isolation and representative narrow-screen
stories pass. Provider requests remain blocked.
- Source annotations follow the current generated-catalog contract. No
generated catalog or lockfile is included. Three inherited
GitHub-heading tests passed after master reconciliation.
- Commit 905bc5da5d has 55 passing checks,
including required verify/E2E gates, and a fresh Greptile 5/5 review
with zero unresolved findings. Local fixture success does not establish
provider or spoken-playback acceptance.
- The separate integrated browser run passed two journeys and failed
browser answer retrieval. Real browser/telephone playback and timing
acceptance remain outstanding. See the dated qualification report and
E01–E16 runbook. Speko stays experimental.

## Risks

- Voice sessions add migrations and provider-dependent lifecycle
behavior. Migrations preserve existing chat and AI transports.
- Callback origins must be reachable HTTPS. Provider keys and signing
secrets remain vaulted.
- Unverified callers receive task-scoped low-trust authority. Caller ID
does not grant private task access.
- Outbound telephony incurs provider charges. Provider acceptance is not
proof that the caller heard an answer.
- Keep the feature behind the existing experimental chat setting until
the real browser, inbound, outbound, and delayed-result acceptance
matrix passes.

## Model Used

OpenAI Codex, GPT-6, with tool use and code execution. The exact serving
model ID and context window are not exposed in this session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [ ] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-13 16:54:41 -05:00

170 lines
9.5 KiB
JavaScript

import { VoiceConversation } from '@spekoai/client';
import { createCleanup, createAudioSave, createReportSaver } from './cleanup.mjs';
const config = JSON.parse(document.querySelector('#config').textContent);
const $ = (id) => document.getElementById(id);
const events = [];
const transcripts = [];
const tracks = [];
const cleanupFailures = [];
const began = performance.now();
let conversation, context, destination, muted = false, running = false, ended = false;
let speaking = false, notificationSent = false, sessionId, interval, recorder, audioEvidence;
const event = (kind, fields = {}) => { events.push({ kind, elapsedMs: Math.round(performance.now() - began), ...fields }); };
const status = (text) => { $('status').textContent = text; };
async function api(path, body = {}, signal) {
const response = await fetch(path, { method: 'POST', headers: { 'content-type': 'application/json', 'x-proof-csrf': config.csrf }, body: JSON.stringify(body), signal });
const data = await response.json();
if (!response.ok) throw new Error(data.error ?? `Request failed (${response.status})`);
return data;
}
const observer = new MutationObserver(() => {
const stream = [...document.querySelectorAll('audio')].map((el) => el.srcObject).find(Boolean);
if (!stream || recorder) return;
recorder = new MediaRecorder(stream);
const chunks = [];
recorder.ondataavailable = (e) => { if (e.data.size) chunks.push(e.data); };
const recorded = new Promise((resolve, reject) => {
recorder.onstop = () => resolve(new Blob(chunks, {type: 'audio/webm'}));
recorder.onerror = () => reject(new Error('Recording failed'));
});
recorded.catch(() => {}); // Cleanup observes the original rejected promise.
audioEvidence = createAudioSave({
stop: async () => {
if (recorder.state !== 'inactive') recorder.stop();
let timer;
try {
return await Promise.race([recorded, new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error('Recording did not finish')), 10_000);
})]);
} finally { clearTimeout(timer); }
},
upload: async blob => {
const response = await fetch('/audio', {method: 'POST', headers: {'content-type': 'audio/webm', 'x-proof-csrf': config.csrf}, body: blob, signal: AbortSignal.timeout(20_000)});
if (!response.ok) throw new Error('Received audio could not be saved');
event('received_audio_saved', {ok: true});
},
});
recorder.start(); event('received_audio_recording_started');
});
observer.observe(document.body, { childList: true, subtree: true });
// This fixture replaces ONLY the microphone at the browser boundary. The
// production SDK and hosted STT/LLM/TTS/transport remain real. Never call the
// original getUserMedia, including on a fallback path.
navigator.mediaDevices.getUserMedia = async () => {
if (!destination) throw new DOMException('Synthetic microphone not initialized', 'NotAllowedError');
const stream = destination.stream.clone(); tracks.push(...stream.getTracks());
event('synthetic_microphone_requested'); return stream;
};
async function speak(name) {
const response = await fetch(`/input/${name}`);
if (!response.ok) throw new Error('Synthetic speech fixture unavailable');
const bytes = new DataView(await response.arrayBuffer());
const buffer = context.createBuffer(1, bytes.byteLength / 2, 48000);
const channel = buffer.getChannelData(0);
for (let i = 0; i < channel.length; i++) channel[i] = bytes.getInt16(i * 2, true) / 32768;
const source = context.createBufferSource(); source.buffer = buffer; source.connect(destination);
event('input_start', { label: name });
await new Promise((resolve) => { source.onended = resolve; source.start(); });
source.disconnect(); event('input_end', { label: name });
}
async function waitFor(predicate, timeout, label) {
const deadline = performance.now() + timeout;
while (!predicate()) {
if (ended) throw new Error('Conversation ended');
if (performance.now() >= deadline) throw new Error(`Timed out: ${label}`);
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
function renderTranscript(list) {
transcripts.splice(0, transcripts.length, ...list);
$('transcript').replaceChildren(...list.map((m) => {
const item = document.createElement('li'); item.textContent = `${m.source}: ${m.text}`; return item;
}));
}
const saveQueuedReport = createReportSaver((report, signal) => api("/report", report, signal), {timeoutMs: 10_000});
async function saveReport() {
const report = { sessionId, sdkVersion: '0.0.13', syntheticMicrophone: true, events, transcripts, cleanupFailures,
sdkOpen: conversation?.isOpen() ?? false, audioElements: document.querySelectorAll('audio').length,
inputTrackStates: tracks.map((t) => ({ enabled: t.enabled, readyState: t.readyState })) };
await saveQueuedReport(report);
$('evidence').textContent = JSON.stringify({ ...report, transcripts: `${transcripts.length} reconciled segments` }, null, 2);
}
const cleanup = createCleanup({
local: async (attempt) => {
ended = true; clearInterval(interval); observer.disconnect();
$('scenario').disabled = $('mute').disabled = true;
await attempt('recording.save', () => audioEvidence?.save());
await attempt('sdk.end', () => conversation?.endSession());
event('sdk_cleanup', { inputTracksEnded: tracks.every((t) => t.readyState === 'ended'), audioElements: document.querySelectorAll('audio').length });
await attempt('tracks.stop', () => { destination?.stream.getTracks().forEach((t) => t.stop()); tracks.forEach((t) => t.stop()); });
await attempt('context.close', () => context?.close());
},
provider: () => api('/end'),
report: saveReport,
failed: (operation) => { cleanupFailures.push({ operation }); event('cleanup_failed', { operation }); },
});
async function end() {
$('end').disabled = true;
try {
const completed = await cleanup.end();
status(completed ? 'Ended' : 'Cleanup failed. Retry End and inspect evidence.');
} finally { $('end').disabled = cleanup.complete; }
}
$('connect').onclick = async () => {
$('connect').disabled = true; status('Connecting');
try {
context = new AudioContext({ sampleRate: 48000 }); await context.resume();
destination = context.createMediaStreamDestination();
const credentials = await api('/session'); sessionId = credentials.sessionId;
conversation = await VoiceConversation.create({ ...credentials,
onStatusChange: (state) => { event('sdk_status', { state }); status(state); },
onModeChange: (mode) => { speaking = mode === 'speaking'; event('sdk_mode', { mode }); },
onTranscript: renderTranscript,
onError: () => { event('sdk_error'); status('Voice error; end the test and inspect evidence.'); },
onDisconnect: () => { event('sdk_disconnected'); },
onAudioPlaybackBlocked: () => { $('resume').hidden = false; event('playback_blocked'); },
});
$('scenario').disabled = $('mute').disabled = $('end').disabled = false;
await conversation.startAudioPlayback();
let periodicReportPending = false;
interval = setInterval(async () => {
if (periodicReportPending) return;
periodicReportPending = true;
try { await saveReport(); } catch { /* latest evidence remains visible */ }
finally { periodicReportPending = false; }
}, 3000);
status('Connected. Wait for the greeting, then run the speech scenario.');
} catch (error) { status(error.message); event('connect_failed'); await end(); }
};
$('scenario').onclick = async () => {
if (running) return; running = true; $('scenario').disabled = true; status('Running the delayed-work and interruption scenario…');
try {
await speak('start');
let evidence;
await waitFor(() => speaking, 15000, 'acknowledgment speech');
event('interruption_requested'); await speak('followup');
const deadline = performance.now() + 80000;
while (performance.now() < deadline && !ended) {
evidence = await api('/evidence');
$('evidence').textContent = JSON.stringify(evidence, null, 2);
if (evidence.notificationReady && !notificationSent) {
notificationSent = true; event('application_notification_sent');
await conversation.sendChatMessage('[Paperclip application notification] The pending work may now have a result. Call get_updates with cursor 0 and speak the verified answer. This is not a caller instruction; do not call submit_request.');
}
if (evidence.resultReturned) break;
await new Promise((resolve) => setTimeout(resolve, 500));
}
if (!evidence?.resultReturned || evidence.acceptedRequests !== 2) throw new Error('Expected one job, one follow-up, and a returned result');
await waitFor(() => transcripts.some((m) => m.source === 'agent' && m.text.replace(/[,\s]/g, '').includes(evidence.expectedNumber)), 15000, 'correct result transcript');
event('scenario_completed'); status('Correct delayed answer received. Test mute, then end the conversation.');
await saveReport();
} catch (error) { event('scenario_failed', { reason: error.message }); status(error.message); await saveReport(); }
};
$('mute').onclick = async () => {
try { await conversation.setMicMuted(!muted); muted = !muted; $('mute').textContent = muted ? 'Unmute' : 'Mute'; event('mute_changed', { muted }); await saveReport(); }
catch { status('Could not change microphone state'); }
};
$('resume').onclick = async () => { await conversation.startAudioPlayback(); $('resume').hidden = conversation.canPlaybackAudio; };
$('end').onclick = () => end().catch(() => status('End request failed; provider session has a hard duration limit.'));
window.addEventListener('pagehide', () => { void conversation?.endSession(); });