Files
PaperClipAI/packages/db/src/speko-voice-migration.test.ts
T
DottaandPaperclip 3ec7309d91 feat(voice): add experimental Speko task connection runtime (#15727)
First PR: runtime. UI: #15728. Qualification: #15729.

## 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 an explicit voice transport to the existing connection
runtime.
> - The UI and qualification suites follow in linked PRs. 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 additive voice schema, shared contracts, session APIs, and
provider callbacks.
- Bind browser, inbound, and outbound sessions to an endpoint, task, and
selected agent.
- Add low-trust guest intake in an explicitly selected sandbox and keep
ordinary approval rules.
- Submit task work through session-scoped tools and push approved
replies through Speko POST /v1/calls/{id}/messages.
- Distinguish provider acceptance from confirmed spoken playback.
Reconcile reports without downloading recordings.
- Add protocol, migration, persistence, provider, and
delivery-diagnostic tests.

## Verification

- Master 3389bea708 is integrated. Database/shared builds, server
typecheck, migration upgrade/replay checks, and 46
communication-guidance/provider/protocol tests passed.
- The final persistence suite passed 85 real-database tests. Five
focused native continuation/authorization regressions passed. Five
callback ownership/rotation tests passed after the last runtime repair.
- Review repairs cover reconnect ownership, current caller authority,
duplicate delivery, multipart answers, task-page answers, guest question
handling, and saved sandbox recovery.
- A real OpenCode/OpenRouter DeepSeek task ran in Daytona and saved a
correct disk-space answer. This did not establish live-call playback.
- Full local typecheck/build passed before the final repairs; focused
typechecks passed after them. Latest-head CI runs the repository gates.
- The separate integrated browser qualification passed two journeys and
failed browser answer retrieval. The feature remains experimental. The
report in #15729 records this failure and outstanding real-provider
acceptance.
- No lockfiles are included. Speko migrations follow master as
0324–0330.

## 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-09 21:54:11 -05:00

88 lines
8.8 KiB
TypeScript

import { createHash, randomUUID } from "node:crypto";
import { readFile } from "node:fs/promises";
import postgres from "postgres";
import { describe, expect, it } from "vitest";
import { applyPendingMigrations } from "./client.js";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./test-embedded-postgres.js";
const support = await getEmbeddedPostgresTestSupport();
(support.supported ? describe : describe.skip)("Speko additive voice migration", () => {
it("upgrades existing Slack data and constrains voice transport to channel connections", async () => {
const database = await startEmbeddedPostgresTestDatabase("paperclip-speko-upgrade-");
const sql = postgres(database.connectionString, { max: 1 });
try {
expect(await sql`SELECT to_regclass('chat_voice_sessions') AS name`).toEqual([{ name: "chat_voice_sessions" }]);
// Reconstruct exactly the pre-Speko schema in this disposable database.
await sql`DROP TABLE chat_voice_inbound_calls`;
await sql`DROP TABLE chat_voice_phone_lines`;
await sql`DROP TABLE chat_voice_reports`;
await sql`DROP TABLE chat_voice_callbacks`;
await sql`DROP TABLE chat_voice_replies`;
await sql`DROP TABLE chat_voice_tool_calls`;
await sql`DROP TABLE chat_voice_sessions`;
const constraints = [
["chat_endpoints", "chat_endpoints_provider_check", "provider in ('slack', 'github', 'discord', 'microsoft-teams', 'telegram', 'agentmail', 'imessage-photon')"],
["chat_external_principals", "chat_external_principals_provider_check", "provider in ('slack', 'github', 'discord', 'microsoft-teams', 'telegram', 'agentmail', 'imessage-photon')"],
["tool_connections", "tool_connections_transport_check", "transport in ('mcp_remote', 'rest_api', 'local_stdio', 'chat_sdk', 'runtime_auth')"],
["tool_connections", "tool_connections_channel_transport_check", "(connection_purpose = 'tool' and transport not in ('chat_sdk', 'runtime_auth')) or (connection_purpose = 'channel' and (transport = 'chat_sdk' or (transport = 'rest_api' and config->>'provider' = 'agentmail'))) or (connection_purpose = 'ai' and transport = 'runtime_auth')"],
];
for (const [table, name, definition] of constraints) {
await sql.unsafe(`ALTER TABLE ${table} DROP CONSTRAINT ${name}`);
await sql.unsafe(`ALTER TABLE ${table} ADD CONSTRAINT ${name} CHECK (${definition})`);
}
for (const name of ["0324_long_franklin_storm.sql", "0325_aberrant_machine_man.sql", "0326_blushing_screwball.sql", "0327_sad_proteus.sql", "0328_chunky_mentallo.sql", "0329_free_wolf_cub.sql", "0330_spooky_white_tiger.sql"]) {
const migration = await readFile(new URL(`./migrations/${name}`, import.meta.url), "utf8");
const hash = createHash("sha256").update(migration).digest("hex");
await sql`DELETE FROM drizzle.__drizzle_migrations WHERE hash = ${hash}`;
}
const company = randomUUID(), agent = randomUUID(), application = randomUUID(), connection = randomUUID(), endpoint = randomUUID();
await sql`INSERT INTO companies (id, name, issue_prefix) VALUES (${company}, 'Existing company', 'OLD')`;
await sql`INSERT INTO agents (id, company_id, name, role, status, adapter_type) VALUES (${agent}, ${company}, 'Existing agent', 'general', 'idle', 'process')`;
await sql`INSERT INTO tool_applications (id, company_id, application_key, name, type, status) VALUES (${application}, ${company}, 'slack', 'Slack', 'chat', 'active')`;
await sql`INSERT INTO tool_connections (id, company_id, application_id, name, uid, connection_purpose, transport, status, enabled) VALUES (${connection}, ${company}, ${application}, 'Existing Slack', 'slack/existing', 'channel', 'chat_sdk', 'active', true)`;
await sql`INSERT INTO chat_endpoints (id, company_id, connection_id, provider, public_id, assigned_agent_id, status) VALUES (${endpoint}, ${company}, ${connection}, 'slack', ${randomUUID()}, ${agent}, 'active')`;
const aiConnection = randomUUID(), mailConnection = randomUUID();
await sql`INSERT INTO tool_connections (id, company_id, application_id, name, uid, connection_purpose, transport, status, enabled) VALUES (${aiConnection}, ${company}, ${application}, 'Existing AI', 'ai/existing', 'ai', 'runtime_auth', 'active', true)`;
await sql`INSERT INTO tool_connections (id, company_id, application_id, name, uid, connection_purpose, transport, status, enabled, config) VALUES (${mailConnection}, ${company}, ${application}, 'Existing email', 'email/existing', 'channel', 'rest_api', 'active', true, '{"provider":"agentmail"}'::jsonb)`;
await applyPendingMigrations(database.connectionString);
expect(await sql`SELECT transport FROM tool_connections WHERE id = ${aiConnection}`).toEqual([{ transport: "runtime_auth" }]);
expect(await sql`SELECT transport FROM tool_connections WHERE id = ${mailConnection}`).toEqual([{ transport: "rest_api" }]);
// Upstream AI/email transports and newly added chat providers remain valid.
await sql`UPDATE tool_connections SET connection_purpose = 'ai', transport = 'runtime_auth' WHERE id = ${aiConnection}`;
await sql`UPDATE tool_connections SET config = '{"provider":"agentmail"}'::jsonb WHERE id = ${mailConnection}`;
await sql`UPDATE chat_endpoints SET provider = 'imessage-photon' WHERE id = ${endpoint}`;
await sql`UPDATE chat_endpoints SET provider = 'agentmail', publication_mode = 'explicit', external_execution_policy = 'agent' WHERE id = ${endpoint}`;
await sql`UPDATE chat_endpoints SET provider = 'slack' WHERE id = ${endpoint}`;
expect(await sql`SELECT provider, assigned_agent_id FROM chat_endpoints WHERE id = ${endpoint}`).toEqual([{ provider: "slack", assigned_agent_id: agent }]);
expect(await sql`SELECT name, transport FROM tool_connections WHERE id = ${connection}`).toEqual([{ name: "Existing Slack", transport: "chat_sdk" }]);
await sql`UPDATE tool_connections SET transport = 'voice' WHERE id = ${connection}`;
await expect(sql`UPDATE tool_connections SET connection_purpose = 'tool' WHERE id = ${connection}`).rejects.toMatchObject({ code: "23514" });
await sql`UPDATE chat_endpoints SET provider = 'speko' WHERE id = ${endpoint}`;
expect(await sql`SELECT to_regclass('chat_voice_tool_calls') AS name`).toEqual([{ name: "chat_voice_tool_calls" }]);
expect(await sql`SELECT to_regclass('chat_voice_reports') AS name`).toEqual([{ name: "chat_voice_reports" }]);
const [toolConstraint] = await sql`SELECT pg_get_constraintdef(oid) AS definition FROM pg_constraint WHERE conname = 'chat_voice_tool_calls_tool_check'`;
expect(toolConstraint.definition).toContain("answer_question");
expect(await sql`SELECT to_regclass('chat_voice_callbacks') AS name`).toEqual([{ name: "chat_voice_callbacks" }]);
await sql`INSERT INTO chat_voice_callbacks (company_id, endpoint_id, user_id, phone_number) VALUES (${company}, ${endpoint}, 'member', '+12015551234')`;
expect(await sql`SELECT enabled FROM chat_voice_callbacks WHERE endpoint_id = ${endpoint}`).toEqual([{ enabled: false }]);
await expect(sql`INSERT INTO chat_voice_callbacks (company_id, endpoint_id, user_id, phone_number) VALUES (${company}, ${endpoint}, 'invalid', '555')`).rejects.toMatchObject({ code: "23514" });
await expect(sql`INSERT INTO chat_voice_callbacks (company_id, endpoint_id, user_id, phone_number) VALUES (${randomUUID()}, ${endpoint}, 'foreign', '+12015551234')`).rejects.toMatchObject({ code: "23503" });
await expect(sql`INSERT INTO chat_voice_callbacks (company_id, endpoint_id, user_id, phone_number) VALUES (${company}, ${endpoint}, 'member', '+12015559999')`).rejects.toMatchObject({ code: "23505" });
await applyPendingMigrations(database.connectionString);
// Existing installations have older migration hashes after renumbering.
// Force each stage to execute again while preserving the actual saved rows.
for (const name of ["0324_long_franklin_storm.sql", "0325_aberrant_machine_man.sql", "0326_blushing_screwball.sql", "0327_sad_proteus.sql", "0328_chunky_mentallo.sql", "0329_free_wolf_cub.sql", "0330_spooky_white_tiger.sql"]) {
const migration = await readFile(new URL(`./migrations/${name}`, import.meta.url), "utf8");
const hash = createHash("sha256").update(migration).digest("hex");
await sql`DELETE FROM drizzle.__drizzle_migrations WHERE hash = ${hash}`;
}
await applyPendingMigrations(database.connectionString);
expect(await sql`SELECT count(*)::int AS count FROM chat_endpoints WHERE id = ${endpoint}`).toEqual([{ count: 1 }]);
expect(await sql`SELECT phone_number, enabled FROM chat_voice_callbacks WHERE endpoint_id = ${endpoint} AND user_id = 'member'`).toEqual([{phone_number: "+12015551234", enabled: false}]);
} finally {
await sql.end();
await database.cleanup();
}
}, 60_000);
});