Files
PaperClipAI/tests/speko-native/app.ts
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

165 lines
14 KiB
TypeScript

/** Production app with a synthetic execution provider; launched by server.ts. */
import { appendFile, writeFile, rename } from "node:fs/promises";
import { resolve } from "node:path";
import { randomInt } from "node:crypto";
import { once } from "node:events";
import { createRequire } from "node:module";
import { setTimeout as delay } from "node:timers/promises";
import { createDb, authUsers, instanceUserRoles, issueComments, issues, issueThreadInteractions, issueQuestionResponseDeliveries, chatVoiceInboundCalls, chatVoiceReports, chatVoiceSessions, chatVoiceReplies, chatVoiceToolCalls, chatActions, chatDeliveries, chatPublications, heartbeatRuns, agentWakeupRequests } from "../../packages/db/src/index.js";
const home = process.env.PAPERCLIP_HOME!;
const port = Number(process.env.SPEKO_NATIVE_PORT ?? 3449);
const origin = `http://127.0.0.1:${port}`;
const callbackOrigin = process.env.SPEKO_NATIVE_CALLBACK_ORIGIN!;
const requireServer = createRequire(new URL("../../server/package.json", import.meta.url));
const { eq } = requireServer("drizzle-orm");
const db = createDb(process.env.DATABASE_URL!);
const authenticated = process.env.SPEKO_NATIVE_AUTH === "1";
const deploymentMode = authenticated ? "authenticated" : "local_trusted";
// Match index.ts's local-trusted bootstrap: agent JWTs retain this real
// responsible user and the memberships created by the normal company route.
if (!authenticated) await db.insert(authUsers).values({ id: "local-board", name: "Board", email: "local@paperclip.local", emailVerified: true, createdAt: new Date(), updatedAt: new Date() }).onConflictDoNothing();
if (!authenticated) await db.insert(instanceUserRoles).values({ userId: "local-board", role: "instance_admin" }).onConflictDoNothing();
const { registerServerAdapter, waitForExternalAdapters } = await import("../../server/src/adapters/registry.js");
await waitForExternalAdapters();
const { instanceSettingsService } = await import("../../server/src/services/instance-settings.js");
await instanceSettingsService(db).updateExperimental({ enableChatConnectors: true });
const firstRun = new Map<string, { began: number; number: number }>();
registerServerAdapter({
type: "process", supportsLocalAgentJwt: true,
async testEnvironment() { return { adapterType: "process", status: "pass", checks: [], testedAt: new Date().toISOString() }; },
async execute(ctx) {
const issueId = String(ctx.context.issueId ?? "");
if (!issueId || !ctx.authToken) throw new Error("Expected real task and run-scoped agent identity");
ctx.onDispatch?.();
await ctx.onCancellationReady?.();
if (ctx.config.command === "fixture-phone") {
// Only execution is synthetic. Discover and invoke through the real
// run-scoped MCP transport; do not reach into the voice service.
const server = ctx.runtimeMcp?.getServers().find(server => server.name === "paperclip-assigned");
if (!server) throw new Error("The assigned phone tool was not delivered to the runtime");
const { Client } = requireServer("@modelcontextprotocol/sdk/client/index.js");
const { StreamableHTTPClientTransport } = requireServer("@modelcontextprotocol/sdk/client/streamableHttp.js");
const client = new Client({ name: "speko-phone-fixture", version: "1.0.0" });
try {
await client.connect(new StreamableHTTPClientTransport(new URL(server.url), { requestInit: { headers: { authorization: `Bearer ${server.token}` } } }));
const listed = await client.listTools();
const tool = listed.tools.find((tool: { name: string }) => tool.name.endsWith(":call_my_phone"));
if (!tool) throw new Error("The callback tool was not discoverable");
const result = await client.callTool({ name: tool.name, arguments: {} });
if (result.isError) throw new Error("The fixture phone call failed");
// A second click must not dial again while this exact task is on a call.
let secondRejected = false;
try { const repeated = await client.callTool({ name: tool.name, arguments: {} }); secondRejected = !!repeated.isError; } catch { secondRejected = true; }
if (!secondRejected) throw new Error("A simultaneous duplicate phone request was not rejected");
await appendFile(resolve(home, "execution.jsonl"), JSON.stringify({ event: "phone_called", issueId, runId: ctx.runId, result, secondRejected }) + "\n", { mode: 0o600 });
return { exitCode: 0, signal: null, timedOut: false, summary: "The assigned phone tool placed one call; the duplicate request was rejected." };
} finally { await client.close(); }
}
let run = firstRun.get(issueId);
const followupRun = !!run;
if (!run) { run = { began: Date.now(), number: randomInt(1000, 10000) }; firstRun.set(issueId, run); }
await appendFile(resolve(home, "execution.jsonl"), JSON.stringify({ event: "started", issueId, runId: ctx.runId, at: new Date().toISOString() }) + "\n", { mode: 0o600 });
await ctx.onLog("stdout", "Synthetic execution provider is processing the voice request.\n");
const remaining = run.began + 61_000 - Date.now();
if (remaining > 0) await delay(remaining, undefined, { signal: ctx.signal });
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
const requests = comments.filter((comment) => !comment.authorAgentId);
if (requests.some((comment) => comment.body.includes("Ask the structured acceptance question"))) {
const interactions = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.issueId, issueId));
const question = interactions.find((item) => item.idempotencyKey === `fixture-question:${issueId}`);
if (!question) {
const [task] = await db.select().from(issues).where(eq(issues.id, issueId));
const response = await fetch(`${origin}/api/issues/${issueId}/interactions`, {
method: "POST", headers: { authorization: `Bearer ${ctx.authToken}`, "content-type": "application/json", "x-paperclip-run-id": ctx.runId },
body: JSON.stringify({ kind: "ask_user_questions", idempotencyKey: `fixture-question:${issueId}`, addresseeUserId: task.responsibleUserId,
payload: { version: 1, questions: [{ id: "color", prompt: "Which color should I use?", selectionMode: "single", required: true, allowOther: false, options: [{ id: "cobalt", label: "Cobalt" }, { id: "amber", label: "Amber" }] }] } }), signal: ctx.signal,
});
if (!response.ok) throw new Error(`Fixture question API rejected: ${response.status}`);
return { exitCode: 0, signal: null, timedOut: false, summary: "" };
}
if (question.status !== "answered") return { exitCode: 0, signal: null, timedOut: false, summary: "" };
}
const answeredQuestion = (await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.issueId, issueId))).find((item) => item.idempotencyKey === `fixture-question:${issueId}` && item.status === "answered");
const summary = answeredQuestion ? `Your structured answer was received: ${JSON.stringify(answeredQuestion.result)}.` : followupRun ? "Your follow-up was included in the completed result." : `The delayed work is complete. Verification number ${run.number}. I received ${Math.max(0, requests.length - 1)} follow-up instructions on this same task.`;
const response = await fetch(`${origin}/api/issues/${issueId}/comments`, {
method: "POST", headers: { authorization: `Bearer ${ctx.authToken}`, "content-type": "application/json", "x-paperclip-run-id": ctx.runId },
body: JSON.stringify({ body: summary }), signal: ctx.signal,
});
if (!response.ok) throw new Error(`Normal agent comment API rejected fixture: ${response.status} ${(await response.json() as { error: string }).error}`);
const comment = await response.json() as { id: string };
await appendFile(resolve(home, "execution.jsonl"), JSON.stringify({ event: "completed", issueId, runId: ctx.runId, commentId: comment.id, number: run.number, requests: requests.length, elapsedMs: Date.now() - run.began, at: new Date().toISOString() }) + "\n", { mode: 0o600 });
return { exitCode: 0, signal: null, timedOut: false, summary, resultJson: { summary } };
},
});
const closeStub = process.env.SPEKO_NATIVE_STUB === "1"
? await (await import("./stub-provider.js")).installSpekoTestProvider(origin, Number(process.env.SPEKO_NATIVE_STUB_PORT ?? port + 1))
: undefined;
const { createApp } = await import("../../server/src/app.js");
const { createStorageService } = await import("../../server/src/storage/service.js");
const { createLocalDiskStorageProvider } = await import("../../server/src/storage/local-disk-provider.js");
const authModule = await import("../../server/src/auth/better-auth.js");
const { loadConfig } = await import("../../server/src/config.js");
const auth = authenticated ? authModule.createBetterAuthInstance(db, loadConfig(), [origin]) : undefined;
const app = await createApp(db, {
uiMode: "static", serverPort: port, bindHost: "127.0.0.1", allowedHostnames: ["127.0.0.1", "localhost", new URL(callbackOrigin).hostname],
deploymentMode,
...(auth ? { betterAuthHandler: authModule.createBetterAuthHandler(auth), resolveSession: (req: import("express").Request) => authModule.resolveBetterAuthSession(auth, req) } : {}),
deploymentExposure: "private", authReady: true, companyDeletionEnabled: true,
chatWebhookPublicBaseUrl: callbackOrigin, authPublicBaseUrl: origin,
storageService: createStorageService(createLocalDiskStorageProvider(resolve(home, "storage"))),
decisionServiceOptions: { wakeOriginAgent: async () => {} }, managedPluginAutoInstall: [],
});
let evidenceWriting = false;
async function writeEvidence() {
if (evidenceWriting) return;
evidenceWriting = true;
try {
const [reports, incoming, sessions, replies, tools, deliveries, publications, runs, wakeups, comments, interactions, questionDeliveries, pushActions] = await Promise.all([
db.select({sessionId: chatVoiceReports.sessionId, companyId: chatVoiceReports.companyId, status: chatVoiceReports.status, costMicroUsd: chatVoiceReports.costMicroUsd, transcript: chatVoiceReports.transcript}).from(chatVoiceReports),
db.select({id: chatVoiceInboundCalls.id, companyId: chatVoiceInboundCalls.companyId, endpointId: chatVoiceInboundCalls.endpointId, state: chatVoiceInboundCalls.state, sessionId: chatVoiceInboundCalls.sessionId}).from(chatVoiceInboundCalls),
db.select({ id: chatVoiceSessions.id, issueId: chatVoiceSessions.issueId, providerSessionId: chatVoiceSessions.providerSessionId, mode: chatVoiceSessions.mode, state: chatVoiceSessions.state }).from(chatVoiceSessions),
db.select().from(chatVoiceReplies),
db.select({ id: chatVoiceToolCalls.id, sessionId: chatVoiceToolCalls.sessionId, tool: chatVoiceToolCalls.tool, response: chatVoiceToolCalls.response }).from(chatVoiceToolCalls),
db.select({ id: chatDeliveries.id, state: chatDeliveries.state, conversationId: chatDeliveries.conversationId, redactedError: chatDeliveries.redactedError }).from(chatDeliveries),
db.select({ id: chatPublications.id, issueId: chatPublications.issueId, state: chatPublications.state, payload: chatPublications.payload, redactedError: chatPublications.redactedError }).from(chatPublications),
db.select({ id: heartbeatRuns.id, agentId: heartbeatRuns.agentId, status: heartbeatRuns.status, error: heartbeatRuns.error, errorCode: heartbeatRuns.errorCode, runtimeMode: heartbeatRuns.runtimeMode, nativeIssueId: heartbeatRuns.nativeIssueId, contextSnapshot: heartbeatRuns.contextSnapshot }).from(heartbeatRuns),
db.select({ id: agentWakeupRequests.id, status: agentWakeupRequests.status, runId: agentWakeupRequests.runId }).from(agentWakeupRequests),
db.select({ id: issueComments.id, issueId: issueComments.issueId, body: issueComments.body, authorAgentId: issueComments.authorAgentId, createdByRunId: issueComments.createdByRunId }).from(issueComments),
db.select().from(issueThreadInteractions),
db.select().from(issueQuestionResponseDeliveries),
db.select().from(chatActions).where(eq(chatActions.kind, "speko_voice_reply_push")),
]);
const runEvidence = runs.map(({ contextSnapshot, ...row }) => {
const context = (contextSnapshot ?? {}) as Record<string, unknown>;
const wake = (context.paperclipWake ?? {}) as Record<string, unknown>;
return { ...row, context: { source: context.source, issueId: context.issueId, wakeCommentIds: context.wakeCommentIds,
paperclipTaskCommunicationGuidance: context.paperclipTaskCommunicationGuidance, paperclipHarnessCheckedOut: context.paperclipHarnessCheckedOut, paperclipExternalChatExecutionBound: context.paperclipExternalChatExecutionBound,
wake: { externalChatProvider: wake.externalChatProvider, checkedOutByHarness: wake.checkedOutByHarness, externalChatExecutionBound: wake.externalChatExecutionBound } } };
});
await writeFile(resolve(home, "evidence.pending.json"), JSON.stringify({ appPid: process.pid, at: new Date().toISOString(), reports, incoming, sessions, replies, tools, deliveries, publications, runs: runEvidence, wakeups, comments, interactions, questionDeliveries, pushActions }), { mode: 0o600 });
await rename(resolve(home, "evidence.pending.json"), resolve(home, "evidence.json"));
} finally { evidenceWriting = false; }
}
await writeEvidence();
const evidenceTimer = setInterval(() => { void writeEvidence().catch((error) => console.error("Evidence snapshot failed:", error.message)); }, 2000);
evidenceTimer.unref();
const { createPaperclipHttpServer } = await import("../../server/src/http/server.js");
const server = createPaperclipHttpServer(app, { apiUrl: origin });
server.listen(port, "127.0.0.1");
const { setupLiveEventsWebSocketServer } = await import("../../server/src/realtime/live-events-ws.js");
const liveEvents = setupLiveEventsWebSocketServer(server, db, { deploymentMode, ...(auth ? { resolveSessionFromHeaders: (headers: Headers) => authModule.resolveBetterAuthSessionFromHeaders(auth, headers) } : {}) });
await once(server, "listening");
console.log(JSON.stringify({ ready: true, origin, home }));
let stopping = false;
async function stop() {
if (stopping) return; stopping = true;
clearInterval(evidenceTimer);
for (const client of liveEvents.clients) client.terminate();
server.close(); server.closeAllConnections();
await app.locals.paperclipShutdown?.();
await closeStub?.();
process.exit(0);
}
process.once("SIGINT", () => void stop());
process.once("SIGTERM", () => void stop());