mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 10:13:11 +02:00
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 source249cbc616a. 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. - Commit905bc5da5dhas 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>
150 lines
6.6 KiB
JavaScript
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']}]);
|
|
});
|