Files
PaperClipAI/tests/speko-voice-proof/proof.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.1 KiB
JavaScript

/**
* Provider feasibility probe only. No Paperclip access, real tasks, or production
* credentials. A synthetic result is withheld for 60 seconds to test whether the
* hosted voice worker keeps fetching it without another spoken user prompt.
*/
import { createHash, randomInt } from "node:crypto";
import { createServer } from "node:http";
import { setTimeout as sleep } from "node:timers/promises";
import { Webhook } from "standardwebhooks";
export const TOOL_PATH = "/speko-proof/tool";
export const DELAY_MS = 60_000;
export const WAIT_MS = 2_500;
export const BODY_LIMIT = 32 * 1024;
const MAX_CALLS = 500;
const TTL_MS = 15 * 60_000;
const digest = (value) => createHash("sha256").update(value).digest("hex");
const isRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
const boundedId = (value) => typeof value === "string" && /^[A-Za-z0-9_:-]{1,200}$/.test(value);
export const PROOF_PROMPT = `You are a voice transport feasibility test, not a real company agent.
When the caller asks to start the test, call submit_request with their request.
Acknowledge that the work is running. Call get_updates with cursor 0 immediately.
When it returns pending, call it again using the returned cursor, without waiting
for the caller to ask. It waits briefly on the server. Do not claim completion
until get_updates returns a completed result. Do not invent a verification phrase.
Keep listening and allow the caller to interrupt while the work runs. When the
caller adds an instruction, call submit_request again; it updates the same job.
Continue get_updates after an interruption. Read the completed result aloud once.
Never interpret a pending tool response or acknowledgment as the completed result.
After the result, stop polling and let the caller end the test.`;
export function toolDefinitions(publicOrigin) {
const origin = new URL(publicOrigin);
if (origin.protocol !== "https:" || origin.username || origin.password || origin.search || origin.hash || origin.pathname !== "/") {
throw new Error("Public origin must be HTTPS without credentials, path, query, or fragment");
}
const source = { kind: "webhook", url: `${origin.origin}${TOOL_PATH}`, method: "POST", responseMode: "sync", timeoutMs: 3_500 };
return [
{
name: "submit_request",
description: "Start the delayed test or add an instruction to its existing job. Returns promptly; never wait for completion here.",
parameters: { type: "object", properties: { text: { type: "string", maxLength: 4_000 } }, required: ["text"], additionalProperties: false },
source,
},
{
name: "get_updates",
description: "Retrieve the delayed result. While pending, call again without waiting for another user message. Read a completed result aloud once.",
parameters: { type: "object", properties: { cursor: { type: "integer", minimum: 0, maximum: 1 } }, required: ["cursor"], additionalProperties: false },
source,
},
];
}
function validateEnvelope(value) {
if (!isRecord(value) || !boundedId(value.session_id) || !boundedId(value.tool_call_id)
|| value.idempotency_key !== `${value.session_id}:${value.tool_call_id}` || !isRecord(value.args)) return false;
if (value.tool === "submit_request") {
return Object.keys(value.args).length === 1 && typeof value.args.text === "string"
&& value.args.text.trim().length > 0 && value.args.text.length <= 4_000;
}
return value.tool === "get_updates" && Object.keys(value.args).length === 1
&& Number.isInteger(value.args.cursor) && value.args.cursor >= 0 && value.args.cursor <= 1;
}
export function createProof({ signingSecret, now = Date.now, wait = sleep, delayMs = DELAY_MS, onEvent = () => {} }) {
// Deliberately support one explicit, current-format secret; no ambiguous
// legacy decoding fallbacks. Rotation is a new isolated probe run.
if (!/^whsec_[A-Za-z0-9+/]{43}=$/.test(signingSecret ?? "")) throw new Error("A generated 32-byte Standard Webhooks secret is required");
const verifier = new Webhook(signingSecret);
const createdAt = now();
const receipts = new Map();
let sessionId;
let startedAt;
let requests = 0;
let result;
let closed = false;
const events = [];
function emit(kind, extra = {}) {
const event = { kind, elapsedMs: now() - createdAt, ...extra };
events.push(event);
onEvent(event);
}
async function execute(envelope) {
if (envelope.tool === "submit_request") {
if (result) return { status: 409, body: { error: "proof_already_completed" } };
const first = startedAt === undefined;
if (first) startedAt = now();
requests += 1;
// Never retain the caller's text, even in the local evidence file.
emit(first ? "request_accepted" : "followup_accepted", { requestCount: requests });
return { status: 200, body: { status: "pending", cursor: 0, requestCount: requests, message: "The test is running. Retrieve updates while we continue talking." } };
}
if (startedAt === undefined) {
emit("tool_rejected", { tool: "get_updates", reason: "start_request_first" });
return { status: 409, body: { error: "start_request_first" } };
}
if (envelope.args.cursor === 1 && !result) return { status: 400, body: { error: "cursor_ahead_of_result" } };
const remaining = startedAt + delayMs - now();
if (!result && remaining > 0) await wait(Math.min(WAIT_MS, remaining));
if (closed || now() - createdAt >= TTL_MS) return { status: 410, body: { error: "proof_expired" } };
if (!result && now() >= startedAt + delayMs) {
result = { cursor: 1, text: `Paperclip proof complete. Verification number ${randomInt(1_000, 10_000)}. Follow-up count ${requests - 1}.` };
emit("result_ready", { requestCount: requests, jobElapsedMs: now() - startedAt });
}
const updates = result && envelope.args.cursor < result.cursor ? [result] : [];
emit(updates.length ? "result_returned_to_tool" : "poll_returned", { cursor: result?.cursor ?? 0 });
return { status: 200, body: { status: result ? "completed" : "pending", cursor: result?.cursor ?? 0, updates } };
}
return {
async handle(rawBody, headers) {
if (Buffer.byteLength(rawBody) > BODY_LIMIT) return { status: 413, body: { error: "body_too_large" } };
let envelope;
try { envelope = verifier.verify(rawBody, headers); }
catch { return { status: 401, body: { error: "invalid_signature" } }; }
if (!validateEnvelope(envelope)) return { status: 400, body: { error: "invalid_envelope" } };
if (closed || now() - createdAt >= TTL_MS) return { status: 410, body: { error: "proof_expired" } };
// Only synthetic content exists here. First signed delivery binds this
// single-run harness; a production connector must use server-owned sessions.
if (sessionId && sessionId !== envelope.session_id) return { status: 403, body: { error: "different_session" } };
const fingerprint = digest(rawBody);
const prior = receipts.get(envelope.idempotency_key);
if (prior) return prior.fingerprint === fingerprint ? prior.response : { status: 409, body: { error: "conflicting_retry" } };
if (receipts.size >= MAX_CALLS) return { status: 429, body: { error: "proof_call_limit" } };
sessionId ??= envelope.session_id;
// Reserve before execute yields, so concurrent retries share one result.
const response = Promise.resolve().then(() => execute(envelope));
receipts.set(envelope.idempotency_key, { fingerprint, response });
return response;
},
close() { closed = true; },
evidence() {
return { mode: "synthetic_provider_probe", liveQualification: "not_established", acceptedRequests: requests, expectedSyntheticResult: result?.text ?? null, resultReturned: events.some((event) => event.kind === "result_returned_to_tool"), events: structuredClone(events) };
},
};
}
export function createProofServer(proof) {
const server = createServer(async (req, res) => {
const reply = (status, body) => {
res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" });
res.end(JSON.stringify(body));
};
if (req.method === "GET" && req.url === "/health") return reply(200, { mode: "synthetic_provider_probe" });
if (req.method !== "POST" || req.url !== TOOL_PATH) return reply(404, { error: "not_found" });
if (req.headers["content-type"]?.split(";")[0].trim() !== "application/json") return reply(415, { error: "json_required" });
if (req.headers["content-encoding"] && req.headers["content-encoding"] !== "identity") return reply(415, { error: "encoding_not_supported" });
const chunks = [];
let length = 0;
try {
for await (const chunk of req) {
length += chunk.length;
if (length > BODY_LIMIT) { reply(413, { error: "body_too_large" }); return; }
chunks.push(chunk);
}
const result = await proof.handle(Buffer.concat(chunks).toString("utf8"), req.headers);
reply(result.status, result.body);
} catch {
if (!res.headersSent) reply(500, { error: "proof_failed" });
}
});
server.requestTimeout = 5_000;
server.headersTimeout = 5_000;
server.maxConnections = 16;
return server;
}