Files
PaperClipAI/tests/speko-voice-proof/cleanup.test.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

150 lines
6.6 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { createCleanup, createAudioSave, createReportSaver } from './cleanup.mjs';
test('failed hangup preserves evidence and retries only the unconfirmed provider operation', async () => {
let locals = 0, ends = 0;
const failures = [], reports = [];
const cleanup = createCleanup({
local: async () => { locals++; },
provider: async () => { if (++ends === 1) throw new Error('private failure'); },
report: async () => { reports.push([...failures]); },
failed: (operation) => failures.push(operation),
});
assert.equal(await cleanup.end(), false);
assert.equal(cleanup.complete, false);
assert.deepEqual(reports, [['session.end']]);
assert.equal(await cleanup.end(), true);
assert.equal(cleanup.complete, true);
assert.equal(locals, 1);
assert.equal(ends, 2);
assert.equal(reports.length, 2);
});
test('media failures do not prevent hangup or final evidence; concurrent cleanup coalesces', async () => {
let ends = 0, reports = 0;
const failures = [];
const cleanup = createCleanup({
local: async (attempt) => {
await attempt('sdk.end', async () => { throw new Error('sdk failure'); });
await attempt('tracks.stop', async () => { throw new Error('media failure'); });
},
provider: async () => { ends++; },
report: async () => { reports++; },
failed: (operation) => failures.push(operation),
});
assert.deepEqual(await Promise.all([cleanup.end(), cleanup.end()]), [false, false]);
assert.equal(ends, 1);
assert.equal(reports, 1);
assert.deepEqual(failures, ['sdk.end', 'tracks.stop']);
});
test('failed report can be retried without redialing or repeating a confirmed hangup', async () => {
let ends = 0, reports = 0;
const cleanup = createCleanup({ local: async () => {}, provider: async () => { ends++; },
report: async () => { if (++reports === 1) throw new Error('disk failure'); }, failed: () => {} });
assert.equal(await cleanup.end(), false);
assert.equal(cleanup.complete, false);
assert.equal(await cleanup.end(), true);
assert.equal(ends, 1);
assert.equal(reports, 2);
});
test('retries failed local operations and keeps End available until all media cleanup succeeds', async () => {
let tracks = 0, sdk = 0, ends = 0, reports = 0;
const cleanup = createCleanup({
local: async attempt => {
await attempt('tracks.stop', async () => { tracks++; });
await attempt('sdk.end', async () => { if (++sdk === 1) throw new Error('temporary failure'); });
},
provider: async () => { ends++; }, report: async () => { reports++; }, failed: () => {},
});
assert.equal(await cleanup.end(), false);
assert.equal(cleanup.complete, false);
assert.equal(await cleanup.end(), true);
assert.equal(cleanup.complete, true);
assert.deepEqual({tracks, sdk, ends, reports}, {tracks: 1, sdk: 2, ends: 1, reports: 2});
});
test('failed audio upload is retryable with the same finalized recording and blocks cleanup completion', async () => {
const blob = new Blob(['synthetic-audio']);
let stops = 0, uploads = 0, ends = 0;
const evidence = createAudioSave({stop: async () => { stops++; return blob; }, upload: async value => {
assert.equal(value, blob); if (++uploads === 1) throw new Error('HTTP failure');
}});
const failures = [];
const cleanup = createCleanup({local: async attempt => { await attempt('recording.save', () => evidence.save()); },
provider: async () => { ends++; }, report: async () => {}, failed: operation => failures.push(operation)});
assert.equal(await cleanup.end(), false);
assert.equal(cleanup.complete, false);
assert.deepEqual(failures, ['recording.save']);
assert.equal(await cleanup.end(), true);
assert.equal(cleanup.complete, true);
assert.deepEqual({stops, uploads, ends}, {stops: 1, uploads: 2, ends: 1});
});
test('audio evidence waits for final recording data before upload and coalesces concurrent saves', async () => {
let finish, uploads = 0;
const stopped = new Promise(resolve => { finish = resolve; });
const evidence = createAudioSave({stop: () => stopped, upload: async () => { uploads++; }});
const first = evidence.save(), second = evidence.save();
assert.equal(first, second);
await Promise.resolve(); assert.equal(uploads, 0);
finish(new Blob(['synthetic-audio']));
await first; await evidence.save();
assert.equal(uploads, 1);
});
test('final report waits for an older write and preserves its cleanup failures', async () => {
let release;
const writes = [], stored = [];
const save = createReportSaver(async report => {
writes.push(report);
if (writes.length === 1) await new Promise(resolve => { release = resolve; });
stored.push(report);
});
const earlier = {events: ['working'], cleanupFailures: []};
const oldSave = save(earlier);
await Promise.resolve();
earlier.events.push('mutated after enqueue');
const finalSave = save({events: ['ended'], cleanupFailures: ['session.end']});
await Promise.resolve();
assert.equal(writes.length, 1);
release();
await Promise.all([oldSave, finalSave]);
assert.deepEqual(stored, [{events: ['working'], cleanupFailures: []}, {events: ['ended'], cleanupFailures: ['session.end']}]);
});
test('a failed periodic write does not prevent storing final cleanup evidence', async () => {
let writes = 0;
const save = createReportSaver(async report => { if (++writes === 1) throw new Error('failed'); return report; });
await assert.rejects(save({events: []}));
assert.deepEqual(await save({events: ['ended']}), {events: ['ended']});
});
test('stalled periodic transport is aborted and final evidence advances behind its late server write', async () => {
let release, calls = 0;
const stored = [];
const serverSave = createReportSaver(async report => {
if (!stored.length && report.phase === 'periodic') await new Promise(resolve => { release = resolve; });
stored.push(report);
return report;
});
const clientSave = createReportSaver((report, signal) => {
const response = serverSave(report);
if (++calls !== 1) { release(); return response; }
return new Promise((resolve, reject) => {
signal.addEventListener('abort', () => reject(signal.reason), {once: true});
response.then(resolve, reject);
});
}, {timeoutMs: 20});
const first = clientSave({phase: 'periodic'});
const final = clientSave({phase: 'ended', cleanupFailures: ['session.end']});
await assert.rejects(first, /Report save timed out/);
assert.deepEqual(await final, {phase: 'ended', cleanupFailures: ['session.end']});
assert.deepEqual(stored, [{phase: 'periodic'}, {phase: 'ended', cleanupFailures: ['session.end']}]);
});