mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 19:28:46 +01:00
Reconcile Claude platform packages with merged Codex defaults
Co-Authored-By: Paperclip <noreply@paperclip.ing> * codex/claude-runner-default: (85 commits) feat: default new Codex agents to Paperclip Runner on Linux and macOS (#15683) docs(skills): add slow-query diagnosis and fix skill (#16303) feat(sentry): add opt-in run failure reporting with saved run references (#16300) refactor(server): isolate provider message admission and runtime policies (#16289) refactor(server): isolate provider webhook lifecycle decisions (#16283) fix(workspaces): keep cleanup Git scans fresh and dedupe page reads (#16302) fix(sentry): keep provider-rejected Grok wakes out of run failure reports (#16297) fix(runner): report Runner disconnect age when a Dot bridge exits (#16296) refactor(server): isolate chat conversation and control policies (#16276) perf(recovery): use indexed execution identity lookups (#16291) perf(runs): bound history and reduce repeated context reads (#16286) fix(native-runtime): report which close barrier failed (#16298) feat(skills): select and validate tracked GitHub branches (#16288) fix(observability): sample native runs independently of triggers (#16285) fix(github): retry and recover review publication (#16284) perf(workspaces): wake terminal cleanup from committed changes (#16282) fix(mcp): deduplicate discovered tool descriptors (#16281) perf: remove history scans from task and chat queries (#16278) docs(skills): add maintenance bot setup workflow (#16279) fix(observability): time execution configuration steps (#16236) ... # Conflicts: # scripts/verify-grok-npm-install.mjs
This commit is contained in:
commit
b030683ca2
1176 files changed
+129461
-261897
No files matched your search
@@ -1450,8 +1450,6 @@ test.describe("Exact failed chat run retry", () => {
|
||||
: null;
|
||||
await page.goto(startPath);
|
||||
if (canonicalAgentResponse) {
|
||||
// The URL changes before the canonical query finishes. Wait for its
|
||||
// body so the loading boundary cannot discard the retry mutation.
|
||||
const response = await canonicalAgentResponse;
|
||||
expect(response.ok()).toBe(true);
|
||||
expect(await response.json()).toMatchObject({
|
||||
|
||||
@@ -383,3 +383,112 @@ test("agent chats keep personal identity and ordinary company visibility", async
|
||||
}
|
||||
} finally { await invited.context.close(); }
|
||||
});
|
||||
|
||||
test("task toasts follow personal involvement in multiplayer", async ({ browser, page }, testInfo) => {
|
||||
test.setTimeout(180_000);
|
||||
page.setDefaultTimeout(15_000);
|
||||
// Observe the real socket handshake so mutations after navigation do not
|
||||
// race subscription setup. No network responses or event payloads are mocked.
|
||||
await page.addInitScript(() => {
|
||||
const NativeWebSocket = window.WebSocket;
|
||||
const sockets: WebSocket[] = [];
|
||||
(window as unknown as { toastTestSockets: WebSocket[] }).toastTestSockets = sockets;
|
||||
window.WebSocket = class extends NativeWebSocket {
|
||||
constructor(url: string | URL, protocols?: string | string[]) {
|
||||
super(url, protocols);
|
||||
sockets.push(this);
|
||||
}
|
||||
};
|
||||
});
|
||||
await signUp(page, { ...ownerUser, email: `toasts-${ownerUser.email}` });
|
||||
const bootstrapToken = new URL(createBootstrapInvite()).pathname.split("/").at(-1);
|
||||
expect((await sessionJsonRequest(page, `${BASE}/api/invites/${bootstrapToken}/accept`, {
|
||||
method: "POST", data: { requestType: "human" },
|
||||
})).ok).toBe(true);
|
||||
const company = await createCompanyForSession(page, `Personal notifications ${runId}`);
|
||||
const prefix = company.issuePrefix ?? company.id;
|
||||
const invite = await sessionJsonRequest<{ inviteUrl: string }>(page, `${BASE}/api/companies/${company.id}/invites`, {
|
||||
method: "POST", data: { allowedJoinTypes: "human", humanRole: "operator" },
|
||||
});
|
||||
expect(invite.ok).toBe(true);
|
||||
const other = await newPage(browser);
|
||||
try {
|
||||
await signUp(other.page, { ...invitedUser, email: `toasts-${invitedUser.email}` });
|
||||
const token = new URL(invite.json!.inviteUrl, BASE).pathname.split("/").at(-1);
|
||||
expect((await sessionJsonRequest(other.page, `${BASE}/api/invites/${token}/accept`, {
|
||||
method: "POST", data: { requestType: "human" },
|
||||
})).ok).toBe(true);
|
||||
const session = await sessionJsonRequest<{ user: { id: string } }>(page, `${BASE}/api/auth/get-session`);
|
||||
const viewerId = session.json!.user.id;
|
||||
const task = await sessionJsonRequest<{ id: string; identifier: string }>(other.page, `${BASE}/api/companies/${company.id}/issues`, {
|
||||
method: "POST", data: { title: "Another person's task", status: "backlog" },
|
||||
});
|
||||
expect(task.ok).toBe(true);
|
||||
const id = task.json!.id;
|
||||
const frames: Array<{ toastEligible?: boolean; payload: { entityId?: string; action?: string } }> = [];
|
||||
page.on("websocket", socket => socket.on("framereceived", frame => {
|
||||
try { frames.push(JSON.parse(String(frame.payload))); } catch { /* ignore control frames */ }
|
||||
}));
|
||||
await page.goto(`${BASE}/${prefix}/issues?view=all`);
|
||||
await expect(page.getByText("Another person's task", { exact: true }).first()).toBeVisible();
|
||||
await other.page.goto(`${BASE}/${prefix}/issues?view=all`);
|
||||
const toasts = page.locator('aside[aria-live="polite"]');
|
||||
async function waitForSocket() {
|
||||
await page.waitForFunction((companyId) =>
|
||||
(window as unknown as { toastTestSockets: WebSocket[] }).toastTestSockets.some(socket =>
|
||||
socket.url.includes(`/companies/${companyId}/events/ws`) && socket.readyState === WebSocket.OPEN), company.id);
|
||||
}
|
||||
async function update(title: string, eligible: boolean) {
|
||||
await waitForSocket();
|
||||
// Let the existing two-second server list cache expire after navigation.
|
||||
// This case tests steady-state notification scoping, not cache TTL policy.
|
||||
await page.waitForTimeout(2200);
|
||||
const before = frames.length;
|
||||
expect((await sessionJsonRequest(other.page, `${BASE}/api/issues/${id}`, {
|
||||
method: "PATCH", data: { title },
|
||||
})).ok).toBe(true);
|
||||
await expect.poll(() => frames.slice(before).find(e => e.payload.entityId === id && e.payload.action === "issue.updated")?.toastEligible)
|
||||
.toBe(eligible);
|
||||
await expect(page.getByText(title, { exact: true }).first()).toBeVisible();
|
||||
if (eligible) {
|
||||
await expect(toasts).toContainText("Invited User updated");
|
||||
await testInfo.attach(title, { body: await page.screenshot(), contentType: "image/png" });
|
||||
await page.getByRole("button", { name: "Dismiss notification" }).click();
|
||||
} else {
|
||||
await expect(toasts).toHaveCount(0);
|
||||
}
|
||||
// Each qualifying update is a distinct observation, outside deduplication.
|
||||
await page.waitForTimeout(3600);
|
||||
}
|
||||
await update("Unrelated update still refreshes the list", false);
|
||||
// Visiting alone must not subscribe the user.
|
||||
await page.getByRole("link", { name: `Open ${task.json!.identifier}: Unrelated update still refreshes the list`, exact: true }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/issues/(${id}|${task.json!.identifier})`));
|
||||
await page.goto(`${BASE}/${prefix}/issues?view=all`);
|
||||
await update("Viewing is still quiet", false);
|
||||
// Use persisted API fixtures to vary participation, observing actual live UI.
|
||||
expect((await sessionJsonRequest(other.page, `${BASE}/api/issues/${id}/comments`, {
|
||||
method: "POST", data: { body: `Please review [Owner](user://${viewerId}).` },
|
||||
})).ok).toBe(true);
|
||||
await expect(toasts).toContainText("Invited User commented on");
|
||||
await testInfo.attach("First mention notifies", { body: await page.screenshot(), contentType: "image/png" });
|
||||
await page.getByRole("button", { name: "Dismiss notification" }).click();
|
||||
await page.reload();
|
||||
await expect(page.getByText("Viewing is still quiet", { exact: true }).first()).toBeVisible();
|
||||
await update("Historical mention survives reload", true);
|
||||
// Same event must not toast to its author in the second authenticated session.
|
||||
await expect(other.page.locator('aside[aria-live="polite"]')).toHaveCount(0);
|
||||
// Viewing the qualifying task suppresses redundant notifications.
|
||||
await page.getByRole("link", { name: `Open ${task.json!.identifier}: Historical mention survives reload`, exact: true }).click();
|
||||
await expect(page.getByRole("heading", { name: "Historical mention survives reload", exact: true })).toBeVisible();
|
||||
await waitForSocket();
|
||||
const beforeComment = frames.length;
|
||||
expect((await sessionJsonRequest(other.page, `${BASE}/api/issues/${id}/comments`, {
|
||||
method: "POST", data: { body: "Already visible in the task." },
|
||||
})).ok).toBe(true);
|
||||
await expect.poll(() => frames.slice(beforeComment).find(e => e.payload.entityId === id && e.payload.action === "issue.comment_added")?.toastEligible)
|
||||
.toBe(true);
|
||||
await expect(page.getByText("Already visible in the task.", { exact: true }).first()).toBeVisible();
|
||||
await expect(toasts).toHaveCount(0);
|
||||
} finally { await other.context.close(); }
|
||||
});
|
||||
@@ -307,28 +307,25 @@ test.describe("Onboarding wizard", () => {
|
||||
expect(pageErrors, pageErrors.join("\n")).toHaveLength(0);
|
||||
});
|
||||
test("a reload during a login resumes the same session", async ({ page }) => {
|
||||
// The last piece of the resume feature: a customer who reloads the page
|
||||
// mid-login must see the same login still running, not the tile row
|
||||
// again and not a second session started behind their back.
|
||||
//
|
||||
// A bare `/onboarding` reload is not the right way to exercise this: that
|
||||
// route always forces its own fixed entry step (see
|
||||
// `resolveRouteOnboardingOptions` — a bare `/onboarding` hit is a request
|
||||
// to start a new company, by design, and always wins over a saved draft's
|
||||
// step). A returning visit to an existing company's own onboarding path
|
||||
// (`/{prefix}/onboarding`) is the real "reopen onboarding" entry point, so
|
||||
// this test reloads through that path instead, landing on the agent step
|
||||
// with the draft's agent name already restored, then advances once to the
|
||||
// connect step — where the resumed login must already be running.
|
||||
const pageErrors: string[] = [];
|
||||
page.on("pageerror", (err) => pageErrors.push(err.message));
|
||||
const loginSessionReads: string[] = [];
|
||||
page.on("request", (request) => {
|
||||
const match = new URL(request.url()).pathname.match(/\/setup-token-login-sessions\/([^/]+)(?:\/prompt)?$/);
|
||||
if (match && match[1] !== "active") loginSessionReads.push(match[1]);
|
||||
});
|
||||
let hireCalls = 0;
|
||||
await page.route("**/agent-hires", (route) => {
|
||||
hireCalls += 1;
|
||||
return route.fulfill({ status: 400, json: { error: "Unexpected hire while login is pending." } });
|
||||
});
|
||||
|
||||
const flagRes = await page.request.patch("/api/instance/settings/experimental", {
|
||||
data: { enableConferenceRoomChat: true },
|
||||
});
|
||||
expect(flagRes.ok()).toBe(true);
|
||||
|
||||
const FAKE_SANDBOX_ENVIRONMENT_ID = "e2e-fake-sandbox-environment-reload";
|
||||
const FAKE_SANDBOX_ENVIRONMENT_ID = "a6d2e530-1e8b-4f0b-9c94-42f1d23e8750";
|
||||
const FAKE_SANDBOX_PROVIDER = "e2e-fake-provider-reload";
|
||||
|
||||
await page.route("**/environments", async (route) => {
|
||||
@@ -382,6 +379,15 @@ test.describe("Onboarding wizard", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
await page.route("**/api/adapters?**", async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.searchParams.get("environmentId") !== FAKE_SANDBOX_ENVIRONMENT_ID) return route.continue();
|
||||
url.searchParams.delete("environmentId");
|
||||
const response = await route.fetch({ url: url.toString() });
|
||||
expect(response.ok()).toBe(true);
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
|
||||
const SESSION_ID = "e2e-reload-setup-token-session";
|
||||
const AUTHORIZATION_URL = "https://claude.ai/oauth/authorize?code=true&client=e2e-reload";
|
||||
let startCalls = 0;
|
||||
@@ -390,6 +396,7 @@ test.describe("Onboarding wizard", () => {
|
||||
// route, and keeping the pre-Connect part of this test the same as the
|
||||
// ordinary sign-in test above.
|
||||
let sessionStarted = false;
|
||||
const resumedCompanyIds: string[] = [];
|
||||
let aiConnection: Record<string, unknown> | undefined;
|
||||
await page.route("**/setup-token-login-sessions", (route) => {
|
||||
if (route.request().method() === "POST") {
|
||||
@@ -441,6 +448,7 @@ test.describe("Onboarding wizard", () => {
|
||||
body: JSON.stringify({ error: "Setup-token login session not found." }),
|
||||
});
|
||||
}
|
||||
resumedCompanyIds.push(new URL(route.request().url()).pathname.split("/")[3]);
|
||||
return route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
@@ -490,7 +498,7 @@ test.describe("Onboarding wizard", () => {
|
||||
|
||||
await expect(cardInstruction).toBeVisible({ timeout: 30_000 });
|
||||
await expect(authorizationLink).toBeVisible({ timeout: 15_000 });
|
||||
await expect(authorizationLink).toHaveAttribute("href", /claude\.ai\/oauth\/authorize/);
|
||||
await expect(authorizationLink).toHaveAttribute("href", AUTHORIZATION_URL);
|
||||
expect(startCalls).toBe(1);
|
||||
|
||||
const companiesRes = await page.request.get("/api/companies");
|
||||
@@ -501,22 +509,44 @@ test.describe("Onboarding wizard", () => {
|
||||
);
|
||||
expect(company, "the created company should exist").toBeTruthy();
|
||||
|
||||
// Reload through the company's own onboarding path — the real "reopen
|
||||
// onboarding" entry point for a company that already exists.
|
||||
const savedSelection = {
|
||||
step: 4,
|
||||
companyName: `${COMPANY_NAME}-reload`,
|
||||
agentName: "Ada",
|
||||
adapterType: "claude_local",
|
||||
runner: "auto",
|
||||
credentialMode: "subscription",
|
||||
credentialModeChoice: null,
|
||||
createdCompanyId: company.id,
|
||||
createdCompanyPrefix: company.issuePrefix,
|
||||
createdAgentId: null,
|
||||
};
|
||||
const readDraft = () => page.evaluate(() => JSON.parse(localStorage.getItem("paperclip-onboarding-state") ?? "null"));
|
||||
await expect.poll(readDraft).toMatchObject(savedSelection);
|
||||
const assertResumedLogin = async () => {
|
||||
await expect(page.getByRole("heading", { name: "Connect a model", exact: true })).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByText("Ada", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("radio", { name: "Claude Subscription", exact: true })).toBeChecked();
|
||||
await expect(cardInstruction).toBeVisible({ timeout: 15_000 });
|
||||
await expect(authorizationLink).toBeVisible({ timeout: 15_000 });
|
||||
await expect(authorizationLink).toHaveAttribute("href", AUTHORIZATION_URL);
|
||||
await expect.poll(readDraft).toMatchObject(savedSelection);
|
||||
expect(startCalls, "reopening must resume the session without a second source choice").toBe(1);
|
||||
expect(hireCalls, "a pending login must not hire an agent").toBe(0);
|
||||
expect(loginSessionReads.length).toBeGreaterThan(0);
|
||||
expect(new Set(loginSessionReads)).toEqual(new Set([SESSION_ID]));
|
||||
expect(new Set(resumedCompanyIds)).toEqual(new Set([company.id]));
|
||||
};
|
||||
|
||||
const beforeReopenReads = resumedCompanyIds.length;
|
||||
await page.goto(`/${company.issuePrefix}/onboarding`);
|
||||
await expect.poll(() => resumedCompanyIds.length, { timeout: 15_000 }).toBeGreaterThan(beforeReopenReads);
|
||||
await assertResumedLogin();
|
||||
|
||||
// This entry point always re-enters on the agent step, with the agent
|
||||
// name restored from the draft. One more press reaches the connect step.
|
||||
await page.waitForSelector("#onboarding-agent-name", { timeout: 30_000 });
|
||||
await expect(page.locator("#onboarding-agent-name")).toHaveValue("Ada");
|
||||
await page.getByRole("button", { name: "Next" }).click();
|
||||
|
||||
// The resumed login shows with no new source pick and no fresh sign-in
|
||||
// press: the panel and the step both discover it from the caller's active
|
||||
// session.
|
||||
await expect(cardInstruction).toBeVisible({ timeout: 15_000 });
|
||||
await expect(authorizationLink).toBeVisible({ timeout: 15_000 });
|
||||
expect(startCalls, "the reload must resume the session, not start a new one").toBe(1);
|
||||
const beforeReloadReads = resumedCompanyIds.length;
|
||||
await page.reload();
|
||||
await expect.poll(() => resumedCompanyIds.length, { timeout: 15_000 }).toBeGreaterThan(beforeReloadReads);
|
||||
await assertResumedLogin();
|
||||
|
||||
expect(pageErrors, pageErrors.join("\n")).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -111,7 +111,7 @@ async function fetchVerifiedRun(
|
||||
candidateRunId: string | null | undefined,
|
||||
agentId: string,
|
||||
issueId: string,
|
||||
): Promise<{ id: string; status: string } | null> {
|
||||
): Promise<{ id: string; status: string; errorCode: string | null } | null> {
|
||||
if (!candidateRunId) return null;
|
||||
const runRes = await board.get(`${BASE_URL}/api/heartbeat-runs/${candidateRunId}`);
|
||||
if (!runRes.ok()) return null;
|
||||
@@ -119,7 +119,9 @@ async function fetchVerifiedRun(
|
||||
const context = candidateRun.contextSnapshot ?? {};
|
||||
if (candidateRun.agentId !== agentId) return null;
|
||||
if (context.issueId !== issueId && context.taskId !== issueId) return null;
|
||||
return { id: candidateRunId, status: candidateRun.status };
|
||||
const errorCode = typeof candidateRun.errorCode === "string" && /^[a-z][a-z0-9_.-]{0,79}$/i.test(candidateRun.errorCode)
|
||||
? candidateRun.errorCode : null;
|
||||
return { id: candidateRunId, status: candidateRun.status, errorCode };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -275,6 +277,7 @@ async function waitForFixtureRun(
|
||||
agentId: string,
|
||||
issueId: string,
|
||||
runId: string,
|
||||
allowCancelledLock = false,
|
||||
) {
|
||||
// Negative authorization cases deliberately invoke a non-participant. Leave
|
||||
// their API rejection intact instead of waiting for a run they cannot own.
|
||||
@@ -286,8 +289,9 @@ async function waitForFixtureRun(
|
||||
const run = await fetchVerifiedRun(board, runId, agentId, issueId);
|
||||
expect(run, "Fixture run must belong to the acting agent and issue").not.toBeNull();
|
||||
status = run!.status;
|
||||
if (status === "succeeded") return;
|
||||
expect(["queued", "running"], `Fixture run ${runId} ended as ${status}`).toContain(status);
|
||||
if (status === "succeeded") return true;
|
||||
if (status === "cancelled" && allowCancelledLock) return false;
|
||||
expect(["queued", "running"], `Fixture run ${runId} ended as ${status} (errorCode=${run!.errorCode ?? "unavailable"})`).toContain(status);
|
||||
await new Promise((resolve) => setTimeout(resolve, WAKEUP_POLL_INTERVAL_MS));
|
||||
} while (Date.now() < deadline);
|
||||
throw new Error(`Fixture run ${runId} did not succeed within ${waitBudgetMs}ms (last status: ${status})`);
|
||||
@@ -311,7 +315,9 @@ async function retryAgentPatchWithCurrentLockOnConflict(
|
||||
const lockedRunId = issueRunLock.checkoutRunId ?? issueRunLock.executionRunId ?? fallbackRunId;
|
||||
// A new lock can belong to a newly promoted fixture run, not the completed
|
||||
// run we first invoked. Let that exact verified run settle before PATCH.
|
||||
await waitForFixtureRun(board, agent.agentId, issueId, lockedRunId);
|
||||
// Recovery can cancel the selected lock while this wait is in flight.
|
||||
// Read the current lock again; never PATCH under that cancelled run.
|
||||
if (!(await waitForFixtureRun(board, agent.agentId, issueId, lockedRunId, true))) continue;
|
||||
res = await agent.request.patch(`${BASE_URL}/api/issues/${issueId}`, {
|
||||
headers: { "X-Paperclip-Run-Id": lockedRunId },
|
||||
data: patchData,
|
||||
@@ -368,7 +374,7 @@ async function agentPatch(
|
||||
// run-lock race — leave it for the caller to assert on.
|
||||
if (issueRunLock.assigneeAgentId !== agent.agentId) break;
|
||||
const retryRunId = issueRunLock.checkoutRunId ?? issueRunLock.executionRunId ?? runId;
|
||||
await waitForFixtureRun(board, agent.agentId, issueId, retryRunId);
|
||||
if (!(await waitForFixtureRun(board, agent.agentId, issueId, retryRunId, true))) continue;
|
||||
res = await patchWith(retryRunId);
|
||||
}
|
||||
return res;
|
||||
|
||||
@@ -124,6 +124,19 @@ attribution. Failed setup before fixture completion retains unavailable/unknown
|
||||
cost coverage and its holds. A connection-only probe or name-only snapshot cannot
|
||||
qualify native defaults.
|
||||
|
||||
The manual managed first-task cell is the exception to fixture prequalification:
|
||||
`confirmation-replies.runner-codex.daytona.task-card-accept` leaves the saved
|
||||
environment unqualified until the ordinary onboarding Connect action. It
|
||||
observes exactly one platform response and one selected-runtime response, fresh
|
||||
scoped native discovery and the saved automatic choice. Both setup leases must
|
||||
have authoritative response IDs, match this company/environment, and reach
|
||||
released/success before setup is accepted. Exact IDs join the existing task-run
|
||||
lease collector and runtime estimate, including failed setup. New uncorrelated
|
||||
or response-less leases remain unknown and unattributed; incomplete coverage
|
||||
removes the estimate and retains its budget holds. No retry or extra fixture
|
||||
probe closes that gap. The existing environment deletion, expiry backstops and
|
||||
instance cleanup still apply on every outcome.
|
||||
|
||||
Daytona creates sandbox environments through the public API. The core fixture
|
||||
keeps `reuseLease:false` and `runnerLifecycleMode:"per_turn"`. The dedicated
|
||||
warm-continuity fixture uses `reuseLease:true` and
|
||||
|
||||
@@ -2152,6 +2152,29 @@ the changed chief-of-staff persona and skill selections; it needs no fixture
|
||||
change for that default selection. Hiring from that wizard-created chief of
|
||||
staff remains a separate follow-up qualification.
|
||||
|
||||
The explicit-only cell `confirmation-replies.runner-codex.daytona.task-card-accept`
|
||||
extends that wizard journey to one fresh managed company. Select this exact ID;
|
||||
the automatic roster and all local prompts remain unchanged. Its fixture seeds
|
||||
encrypted credentials and an immutable OCI image environment, but does not
|
||||
prequalify the image or create an agent. Connect owns the existing saved
|
||||
environment probe, fresh scoped adapter discovery, and selected runtime setup.
|
||||
The case requires one confirmed platform probe and one native Codex setup lease,
|
||||
then verifies the automatically saved native configuration after reload.
|
||||
|
||||
The completed child must expose a downloaded `garden-welcome.txt` attachment
|
||||
whose byte size, SHA-256, content and scenario marker are independently checked.
|
||||
A saved document alone cannot pass. Existing task-card grading and the same-task
|
||||
follow-up continuity checks remain required. This cell has one attempt, zero
|
||||
automatic retries and a shared fifteen-minute deadline with cleanup time
|
||||
reserved. The script authors three turns; four task runs were observed in the
|
||||
historical local journey. The existing twelve-run guard and $5 company task cap
|
||||
remain, with native setup, one completion judge request, sandbox time and cleanup
|
||||
requiring separate reservation. Registration and provider-free controls do not
|
||||
establish live qualification or funding within the remaining paid reserve.
|
||||
|
||||
This OCI-image Core case does not prove Cloud signup, warm/cold provisioning or
|
||||
name-only snapshot qualification. Those remain separate acceptance gates.
|
||||
|
||||
### Hiring completion accounting evidence
|
||||
|
||||
The v3 hiring grader uses turn-accounting v2 in both executable guards. It requires complete per-run public event streams, exact native tool-use/result pairing and canonical execution IDs for completion actions. Only successful known GET issue/document/comment operations, verified reads/discovery, and attributed native chat finish are admitted. Writes, failed mutation attempts, incomplete streams and unknown actions cannot pass. Separate ACPX host request IDs and provider execution IDs are not joined by name/order/count; missing mapping is uncomparable action coverage, not a measured task failure. The original source-read and exact template checks remain unchanged.
|
||||
|
||||
@@ -126,6 +126,16 @@ The Paperclip server process also receives none; the browser posts each value
|
||||
once to the encrypted company secret API and agents/environments retain only
|
||||
secret references.
|
||||
|
||||
The explicit managed first-task cell observes the wizard's two owned setup
|
||||
responses only to correlate their exact lease IDs with same-company environment
|
||||
records. Raw probe/setup responses, account references, opaque provider lease
|
||||
identities and setup screenshots remain private. Public first-task evidence
|
||||
continues through the existing projection. Missing or foreign lease identities
|
||||
cannot acquire attribution or a zero-cost estimate; incomplete setup coverage
|
||||
stays unavailable even if later task-run billing succeeds. Platform qualification
|
||||
uses the existing permission-gated environment probe, without granting new
|
||||
permissions or accepting fixture-authored qualification metadata.
|
||||
|
||||
The context-integrity catalog lists six pending Kimi/Grok cells for discovery:
|
||||
two cases each across `legacy-kimi-cli`, `legacy-kimi-acp`, and `legacy-grok`.
|
||||
The launcher and direct Playwright runner reject these profiles before loading
|
||||
|
||||
@@ -32,6 +32,23 @@ import {
|
||||
} from "./selectors.js";
|
||||
|
||||
describe("runner E2E catalog", () => {
|
||||
it("keeps the single managed onboarding variant explicit-only without changing local onboarding coverage", () => {
|
||||
const id = "confirmation-replies.runner-codex.daytona.task-card-accept";
|
||||
const suite = runnerSuites.find(candidate => candidate.id === "confirmation-replies")!;
|
||||
const remote = runnerMatrix.filter(entry => entry.suite.id === suite.id && entry.environment.id === "daytona");
|
||||
expect(remote.map(entry => entry.id)).toEqual([id]);
|
||||
expect(suite.manualOnly).toBe(true);
|
||||
expect(remote[0]!.task.flow).toBe("first_task");
|
||||
expect(remote[0]!.task.expectedRunCount).toBe(4);
|
||||
expect(remote[0]!.task.automaticRetryPolicy).toBe("single_attempt");
|
||||
expect(remote[0]!.requiredCredentials).toEqual(["OPENAI_API_KEY", "DAYTONA_API_KEY"]);
|
||||
expect(runnerMatrix.filter(entry => entry.suite.id === "first-task")).toHaveLength(52);
|
||||
expect(runnerMatrix.filter(entry => entry.suite.id === suite.id && entry.environment.id === "local")).toHaveLength(12);
|
||||
expect(selectRunnerExecutions(parseRunnerSelectors(["--id", id])).map(entry => entry.id)).toEqual([id]);
|
||||
expect(selectRunnerExecutions(parseRunnerSelectors(["--all"])).some(entry => entry.id === id)).toBe(false);
|
||||
expect(selectRunnerExecutions(parseRunnerSelectors(["--profile", "runner-codex", "--environment", "daytona"])).some(entry => entry.id === id)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps private repository skill delivery explicit and bounded in both environments", () => {
|
||||
const cases = runnerMatrix.filter(entry => entry.suite.id === "repository-skills");
|
||||
expect(cases).toHaveLength(2);
|
||||
@@ -159,10 +176,10 @@ describe("runner E2E catalog", () => {
|
||||
expect(localIntegrityTasks).toHaveLength(2);
|
||||
expect(openRouterBreadthTasks).toHaveLength(3);
|
||||
expect(runnerSuites.map((suite) => suite.expectedMatrixSize)).toEqual([
|
||||
3, 4, 8, 10, 4, 4, 6, 63, 12, 6, 2, 30, 3, 16, 16, 2, 6, 8, 46, 2, 23, 15, 52, 6, 6, 20, 26, 52, 28, 18, 2, 6, 6, 12, 10, 48, 16, 10, 2, 1, 1, 116, 2,
|
||||
3, 4, 8, 10, 4, 4, 6, 63, 12, 6, 2, 30, 3, 16, 16, 2, 6, 8, 46, 2, 23, 15, 52, 6, 6, 20, 26, 52, 28, 18, 2, 6, 6, 13, 10, 48, 16, 10, 2, 1, 1, 116, 2,
|
||||
]);
|
||||
expect(validateRunnerCatalog()).toHaveLength(729);
|
||||
expect(new Set(runnerMatrix.map((entry) => entry.id)).size).toBe(729);
|
||||
expect(validateRunnerCatalog()).toHaveLength(730);
|
||||
expect(new Set(runnerMatrix.map((entry) => entry.id)).size).toBe(730);
|
||||
expect(
|
||||
runnerMatrix.filter((entry) => entry.suite.id === "core-compatibility"),
|
||||
).toHaveLength(48);
|
||||
|
||||
@@ -101,8 +101,6 @@ function commonAgent(
|
||||
capabilities:
|
||||
"Completes deterministic standard, planning, and ask-mode runner acceptance tasks.",
|
||||
adapterType,
|
||||
// These are explicit legacy regression controls. Ordinary Codex creation
|
||||
// defaults are exercised through the production onboarding wizard below.
|
||||
...(adapterType === "codex_local" ? { runner: "legacy" as const } : {}),
|
||||
adapterConfig,
|
||||
defaultEnvironmentId: input.environmentId,
|
||||
@@ -1498,10 +1496,14 @@ export const runnerSuites: readonly RunnerSuiteFixture[] = [
|
||||
groups: ["chat", "native"],
|
||||
profiles: runnerProfiles.filter(profile => ["runner-codex", "runner-acpx-claude"].includes(profile.id))
|
||||
.map(profile => productionStoryProfile(defaultPermissionProfile(profile))),
|
||||
environments: [localEnvironment],
|
||||
environments: [localEnvironment, runnerEnvironments.find(environment => environment.id === "daytona")!],
|
||||
excludedExecutionIds: ["runner-codex", "runner-acpx-claude"].flatMap(profile =>
|
||||
[...firstTaskTasks.filter(task => ["task-reply-accept", "interview-plan-accept", "reject-no-execution", "task-card-accept"].includes(task.id)), ...chatConfirmationTasks]
|
||||
.map(task => `confirmation-replies.${profile}.daytona.${task.id}`)
|
||||
.filter(id => id !== "confirmation-replies.runner-codex.daytona.task-card-accept")),
|
||||
tasks: [...firstTaskTasks.filter(task => ["task-reply-accept", "interview-plan-accept", "reject-no-execution", "task-card-accept"].includes(task.id)), ...chatConfirmationTasks],
|
||||
expectedMatrixSize: 12,
|
||||
definitionMetadata: { version: 6, instructions: "production", grading: "card-message-provenance-before-child-creation", unansweredQuestionComposer: "history-card-only-including-fresh-dismissal", completionObservation: "120-seconds", scheduling: "explicit-only" },
|
||||
expectedMatrixSize: 13,
|
||||
definitionMetadata: { version: 7, managedOnboarding: "ui-owned-qualification-and-physical-file", managedExpectedTaskRuns: 4, managedAutomaticRetries: 0, instructions: "production", grading: "card-message-provenance-before-child-creation", unansweredQuestionComposer: "history-card-only-including-fresh-dismissal", completionObservation: "120-seconds", scheduling: "explicit-only" },
|
||||
},
|
||||
{
|
||||
id: "completion-updates", label: "Delegated Completion Updates", manualOnly: true,
|
||||
@@ -1679,8 +1681,10 @@ export function buildRunnerMatrix(
|
||||
suiteDefinitionHash: suiteDefinitionHash(suite),
|
||||
profile,
|
||||
environment,
|
||||
task: suite.id === "extended-harnesses" && profile.qualificationCandidate === "pi" && task.id === "file-edit-validate"
|
||||
? { ...task, buildPrompt: piFilePrompt } : task,
|
||||
task: suite.id === "confirmation-replies" && profile.id === "runner-codex" && environment.id === "daytona" && task.id === "task-card-accept"
|
||||
? { ...task, expectedRunCount: 4, automaticRetryPolicy: "single_attempt" as const }
|
||||
: suite.id === "extended-harnesses" && profile.qualificationCandidate === "pi" && task.id === "file-edit-validate"
|
||||
? { ...task, buildPrompt: piFilePrompt } : task,
|
||||
groups: [
|
||||
...new Set([
|
||||
...suite.groups,
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { runsCompletionUpdateProbe, COMPLETION_QUALITY_CONFIG, completionQualityControls, completionQualityStatus, completionQualityRequest, judgeCompletionQuality, reserveCompletionQuality, validateCompletionQuality } from "./completion-quality.js";
|
||||
import { runnerMatrix } from "./catalog.js";
|
||||
import { MANAGED_FIRST_TASK_CELL } from "./first-task-fixtures.js";
|
||||
const observation = { sourceId: "chat", marker: "REF", worker: { id: "task", title: "Welcome", status: "done", completedAt: "2026-09-01T00:00:00Z" }, documents: [{ id: "doc", issueId: "task", key: "welcome", body: "Welcome to the garden. Meet at 10:30." }], comments: [{ id: "reply", issueId: "chat", authorAgentId: "agent", createdAt: "2026-09-01T00:01:00Z", body: "The note is ready at /issues/task", createdByRunId: "run" }], runs: [] };
|
||||
const criteria = Object.keys(COMPLETION_QUALITY_CONFIG.rubric).map(id => ({ id, passed: true, rationale: "Supported by the saved note and reply", evidenceIds: ["reply", "doc"] }));
|
||||
const reports = [{ replyId: "reply", rationale: "Reports the completed task", completedTaskIdsReferenced: ["task"], resultAccessTaskIds: ["task"], correctsReplyIds: [] as string[] }];
|
||||
describe("completion semantic qualification", () => {
|
||||
it.each(["runner-codex", "runner-acpx-claude"])("requires a completion judge only for accepted work on %s", profile => {
|
||||
const cells = runnerMatrix.filter(e => e.suite.id === "confirmation-replies" && e.profile.id === profile);
|
||||
const cells = runnerMatrix.filter(e => e.suite.id === "confirmation-replies" && e.profile.id === profile && e.environment.id === "local");
|
||||
expect(cells).toHaveLength(6);
|
||||
expect(cells.filter(runsCompletionUpdateProbe).map(e => e.task.id).sort()).toEqual([
|
||||
"interview-plan-accept", "task-card-accept", "task-reply-accept",
|
||||
]);
|
||||
});
|
||||
it("requires the existing completion judge for accepted managed onboarding work", () => {
|
||||
const managed = runnerMatrix.find(e => e.id === MANAGED_FIRST_TASK_CELL);
|
||||
expect(managed).toBeDefined();
|
||||
expect(runsCompletionUpdateProbe(managed!)).toBe(true);
|
||||
});
|
||||
it("retains the completion suite's gate and excludes ordinary chat journeys", () => {
|
||||
const completion = runnerMatrix.filter(e => e.suite.id === "completion-updates");
|
||||
expect(completion).toHaveLength(10);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { gradeConfirmationReply, assertAmbiguousReplyUnresolved, ambiguousConfirmationFixtures } from "./confirmation-replies.js";
|
||||
import { firstTaskScenario } from "./first-task-cases.js";
|
||||
import type { FirstTaskEvidence } from "./first-task-scoring.js";
|
||||
import { provisionFirstTaskFixtures } from "./first-task-fixtures.js";
|
||||
import { MANAGED_FIRST_TASK_CELL, provisionFirstTaskFixtures } from "./first-task-fixtures.js";
|
||||
import { runnerMatrix } from "./catalog.js";
|
||||
import { parseRunnerSelectors, selectRunnerExecutions } from "./selectors.js";
|
||||
|
||||
@@ -111,7 +111,7 @@ describe("confirmation-reply independent oracle", () => {
|
||||
payload: { questions: [{ prompt, options: [{ label: "Welcome note" }, { label: "Poster" }] }] },
|
||||
}] })).toThrow();
|
||||
});
|
||||
it.each(runnerMatrix.filter(e => e.suite.id === "confirmation-replies" && e.task.flow === "first_task"))("provisions the real onboarding fixture contract for $id", async execution => {
|
||||
it.each(runnerMatrix.filter(e => e.suite.id === "confirmation-replies" && e.task.flow === "first_task" && e.environment.id === "local"))("provisions the real local onboarding fixture contract for $id", async execution => {
|
||||
const get = vi.fn().mockResolvedValue([{ id: "local", driver: "local" }]);
|
||||
const patch = vi.fn().mockResolvedValue({ id: "company" });
|
||||
const postSensitive = vi.fn().mockResolvedValue({ id: "secret" });
|
||||
@@ -137,12 +137,14 @@ describe("confirmation-reply independent oracle", () => {
|
||||
originalIds: ["a", "b"], tasks: [], reply: "", agentId: "agent", answerId: "reply" })).toThrow();
|
||||
});
|
||||
|
||||
it("selects exactly twelve explicit-only cases using production native profiles", () => {
|
||||
it("selects twelve local and one managed explicit-only cases using production native profiles", () => {
|
||||
const cells = runnerMatrix.filter(e => e.suite.id === "confirmation-replies");
|
||||
expect(cells).toHaveLength(12);
|
||||
expect(cells).toHaveLength(13);
|
||||
expect(cells.filter(c => c.environment.id === "local")).toHaveLength(12);
|
||||
expect(cells.filter(c => c.environment.id === "daytona").map(c => c.id)).toEqual([MANAGED_FIRST_TASK_CELL]);
|
||||
expect(new Set(cells.map(c => c.profile.id))).toEqual(new Set(["runner-codex", "runner-acpx-claude"]));
|
||||
expect(new Set(cells.map(c => c.task.id)).size).toBe(6);
|
||||
expect(selectRunnerExecutions(parseRunnerSelectors(["--suite", "confirmation-replies"]))).toHaveLength(12);
|
||||
expect(selectRunnerExecutions(parseRunnerSelectors(["--suite", "confirmation-replies"]))).toHaveLength(13);
|
||||
expect(selectRunnerExecutions(parseRunnerSelectors(["--all"])).some(c => c.suite.id === "confirmation-replies")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,21 +1,70 @@
|
||||
import { normalizePaperclipOperationalSkillPreference } from "../../packages/adapter-utils/src/server-utils.js";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import type { RunnerApi } from "./api.js";
|
||||
import type { LiveFixtureValues } from "./live-fixtures.js";
|
||||
import { deleteDaytonaEnvironment, installFixtureDaytonaProvider, type LiveFixtureValues } from "./live-fixtures.js";
|
||||
import { isImmutableDaytonaImage } from "./catalog.js";
|
||||
import type { CredentialName, MatrixExecution } from "./types.js";
|
||||
|
||||
/** Provision credentials and a bounded budget for the UI-created company. The production wizard
|
||||
* remains the sole creator/configurer of its first agent and onboarding task. */
|
||||
export const MANAGED_FIRST_TASK_CELL = "confirmation-replies.runner-codex.daytona.task-card-accept";
|
||||
export function isManagedFirstTask(execution: Pick<MatrixExecution, "id">): boolean {
|
||||
return execution.id === MANAGED_FIRST_TASK_CELL;
|
||||
}
|
||||
|
||||
interface FirstTaskRuntimeBound {
|
||||
timeoutSec: 180;
|
||||
maxConcurrentRuns: 1;
|
||||
before: Record<string, unknown>;
|
||||
after: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type FirstTaskFixtureValues = LiveFixtureValues & {
|
||||
onboardingRuntimeBound?: FirstTaskRuntimeBound;
|
||||
};
|
||||
|
||||
export async function startFirstTaskWithRuntimeBound(input: {
|
||||
api: Pick<RunnerApi, "get" | "patch">;
|
||||
execution: MatrixExecution;
|
||||
agent: Record<string, any>;
|
||||
observe: (bound: FirstTaskRuntimeBound) => void;
|
||||
start: () => Promise<void>;
|
||||
}): Promise<void> {
|
||||
if (input.execution.profile.id === "runner-codex") {
|
||||
const snapshot = (agent: Record<string, any>) => structuredClone(Object.fromEntries([
|
||||
"id", "companyId", "adapterType", "adapterConfig", "runtimeConfig", "permissions", "defaultEnvironmentId", "budgetMonthlyCents", "metadata",
|
||||
].map(key => [key, agent?.[key]]))) as Record<string, any>;
|
||||
const before = snapshot(input.agent);
|
||||
if (!before.id || !before.companyId || before.adapterType !== "paperclip_runner" || before.adapterConfig?.provider !== "codex") {
|
||||
throw new Error("First-task runtime bound requires the wizard's original native Codex selection");
|
||||
}
|
||||
const agentPath = `/api/agents/${encodeURIComponent(before.id)}`;
|
||||
if (!isDeepStrictEqual(snapshot(await input.api.get(agentPath)), before)) {
|
||||
throw new Error("The automatic wizard configuration changed before the first-task runtime bound");
|
||||
}
|
||||
const runtimeConfig = { ...before.runtimeConfig, heartbeat: { ...before.runtimeConfig?.heartbeat, maxConcurrentRuns: 1 } };
|
||||
await input.api.patch(agentPath, { adapterConfig: { timeoutSec: 180 }, runtimeConfig });
|
||||
const after = snapshot(await input.api.get(agentPath));
|
||||
const expected = { ...before, adapterConfig: { ...before.adapterConfig, timeoutSec: 180 }, runtimeConfig };
|
||||
if (!isDeepStrictEqual(after, expected)) {
|
||||
throw new Error("The first-task runtime bound was not confirmed with the original execution and account settings");
|
||||
}
|
||||
input.observe({ timeoutSec: 180, maxConcurrentRuns: 1, before, after });
|
||||
}
|
||||
await input.start();
|
||||
}
|
||||
|
||||
export async function provisionFirstTaskFixtures(input: {
|
||||
api: Pick<RunnerApi, "get" | "patch" | "postSensitive">;
|
||||
api: Pick<RunnerApi, "get" | "patch" | "postSensitive"> & Partial<Pick<RunnerApi, "post" | "delete">>;
|
||||
execution: MatrixExecution;
|
||||
nonce: string;
|
||||
daytonaImage?: string;
|
||||
company: LiveFixtureValues["company"];
|
||||
credentials: Partial<Record<CredentialName, string>>;
|
||||
}): Promise<LiveFixtureValues> {
|
||||
}): Promise<FirstTaskFixtureValues> {
|
||||
const { api, execution, nonce, company, credentials } = input;
|
||||
const managed = isManagedFirstTask(execution);
|
||||
if (
|
||||
!["first-task", "completion-updates", "confirmation-replies"].includes(execution.suite.id) || execution.task.flow !== "first_task" ||
|
||||
execution.environment.id !== "local" ||
|
||||
(execution.environment.id !== "local" && !managed) ||
|
||||
![
|
||||
"legacy-codex",
|
||||
"legacy-claude",
|
||||
@@ -27,42 +76,89 @@ export async function provisionFirstTaskFixtures(input: {
|
||||
"First-task fixtures require a supported local onboarding profile",
|
||||
);
|
||||
}
|
||||
const credential = execution.profile.credential;
|
||||
const value = credentials[credential];
|
||||
if (!value) throw new Error(`Missing credential ${credential}`);
|
||||
if (managed && (!isImmutableDaytonaImage(input.daytonaImage) || !api.post || !api.delete)) {
|
||||
throw new Error("Managed onboarding requires the immutable image and normal environment create/cleanup APIs");
|
||||
}
|
||||
const required = managed ? [execution.profile.credential, "DAYTONA_API_KEY" as const] : [execution.profile.credential];
|
||||
for (const name of required) if (!credentials[name]) throw new Error(`Missing credential ${name}`);
|
||||
const previousSettings = managed ? await api.get<{
|
||||
defaultEnvironmentId: string | null;
|
||||
experimental: { enableManagedSandboxOnly: boolean };
|
||||
}>("/api/instance/settings") : null;
|
||||
if (managed && (!previousSettings ||
|
||||
(previousSettings.defaultEnvironmentId !== null && typeof previousSettings.defaultEnvironmentId !== "string") ||
|
||||
typeof previousSettings.experimental?.enableManagedSandboxOnly !== "boolean")) {
|
||||
throw new Error("Managed onboarding fixture prior instance settings could not be read");
|
||||
}
|
||||
await api.patch(`/api/companies/${company.id}`, { budgetMonthlyCents: 500 });
|
||||
const environments = await api.get<Array<LiveFixtureValues["environment"]>>(
|
||||
`/api/companies/${company.id}/environments?driver=local`,
|
||||
);
|
||||
const environment = environments.find((e) => e.driver === "local");
|
||||
if (!environment)
|
||||
throw new Error("Onboarding fixture local environment missing");
|
||||
const secret = await api.postSensitive<{ id: string }>(
|
||||
`/api/companies/${company.id}/secrets`,
|
||||
{
|
||||
name: `First task ${credential}`,
|
||||
key: credential,
|
||||
value,
|
||||
},
|
||||
);
|
||||
let localEnvironment: LiveFixtureValues["environment"] | undefined;
|
||||
if (!managed) {
|
||||
const environments = await api.get<Array<LiveFixtureValues["environment"]>>(`/api/companies/${company.id}/environments?driver=local`);
|
||||
localEnvironment = environments.find(candidate => candidate.driver === "local");
|
||||
if (!localEnvironment) throw new Error("Onboarding fixture local environment missing");
|
||||
}
|
||||
const secretRefs: LiveFixtureValues["secretRefs"] = {};
|
||||
for (const name of required) {
|
||||
const secret = await api.postSensitive<{ id: string }>(`/api/companies/${company.id}/secrets`, {
|
||||
name: `First task ${name}`, key: name, value: credentials[name],
|
||||
});
|
||||
secretRefs[name] = { type: "secret_ref", secretId: secret.id, version: "latest" };
|
||||
}
|
||||
let environment: LiveFixtureValues["environment"];
|
||||
let teardown = async () => {};
|
||||
if (managed) {
|
||||
const remoteApi = api as Pick<RunnerApi, "post" | "delete">;
|
||||
await installFixtureDaytonaProvider(remoteApi, execution);
|
||||
environment = await remoteApi.post<LiveFixtureValues["environment"]>(`/api/companies/${company.id}/environments`,
|
||||
execution.environment.buildEnvironment({ secretRefs, daytonaImage: input.daytonaImage, executionId: nonce }));
|
||||
let restoreDefault = false;
|
||||
let restoreManagedOnly = false;
|
||||
let environmentDeleted = false;
|
||||
teardown = async () => {
|
||||
const failures: unknown[] = [];
|
||||
if (restoreManagedOnly) {
|
||||
try {
|
||||
await api.patch("/api/instance/settings/experimental", { enableManagedSandboxOnly: previousSettings!.experimental.enableManagedSandboxOnly });
|
||||
restoreManagedOnly = false;
|
||||
} catch (error) { failures.push(error); }
|
||||
}
|
||||
if (restoreDefault) {
|
||||
try {
|
||||
await api.patch("/api/instance/settings", { defaultEnvironmentId: previousSettings!.defaultEnvironmentId });
|
||||
restoreDefault = false;
|
||||
} catch (error) { failures.push(error); }
|
||||
}
|
||||
if (!restoreDefault && !environmentDeleted) {
|
||||
try {
|
||||
await deleteDaytonaEnvironment(remoteApi, environment.id);
|
||||
environmentDeleted = true;
|
||||
} catch (error) { failures.push(error); }
|
||||
}
|
||||
if (failures.length) throw new AggregateError(failures, restoreDefault
|
||||
? "Managed onboarding fixture cleanup failed; instance default restoration is unconfirmed and environment deletion was not attempted"
|
||||
: "Managed onboarding fixture cleanup failed");
|
||||
};
|
||||
try {
|
||||
restoreDefault = true;
|
||||
await api.patch("/api/instance/settings", { defaultEnvironmentId: environment.id });
|
||||
restoreManagedOnly = true;
|
||||
await api.patch("/api/instance/settings/experimental", { enableManagedSandboxOnly: true });
|
||||
} catch (error) {
|
||||
try { await teardown(); } catch (cleanup) { throw new AggregateError([error, cleanup], "Managed onboarding fixture setup and cleanup failed"); }
|
||||
throw error;
|
||||
}
|
||||
} else environment = localEnvironment!;
|
||||
|
||||
return {
|
||||
company,
|
||||
environment,
|
||||
secretRefs: {
|
||||
[credential]: {
|
||||
type: "secret_ref",
|
||||
secretId: secret.id,
|
||||
version: "latest",
|
||||
},
|
||||
},
|
||||
secretRefs,
|
||||
agent: { id: "", name: `Garden lead ${nonce}`, companyId: company.id },
|
||||
teardown: async () => {}, // Existing launcher removes the complete isolated instance.
|
||||
...(managed ? { setupLeases: [], setupQualification: { recorded: false, adapterType: null, platformResponses: 0, adapterResponses: 0 } } : {}),
|
||||
teardown,
|
||||
};
|
||||
}
|
||||
|
||||
/** Reuse the qualified runtime configuration for explicit regression switches,
|
||||
* never the QA persona from buildAgent. Codex default qualification does not
|
||||
* call this helper; its wizard must persist the native selection directly. */
|
||||
export function firstTaskNativeRuntimePatch(
|
||||
execution: MatrixExecution,
|
||||
fixtures: LiveFixtureValues,
|
||||
|
||||
@@ -9,9 +9,12 @@ import { waitForFirstTaskReply } from "./first-task-replies.js";
|
||||
import { observeCompletionUpdate } from "./completion-update-flow.js";
|
||||
import {
|
||||
provisionFirstTaskFixtures,
|
||||
isManagedFirstTask,
|
||||
startFirstTaskWithRuntimeBound,
|
||||
type FirstTaskFixtureValues,
|
||||
} from "./first-task-fixtures.js";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { expect, type Page, type Request, type Response } from "@playwright/test";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { pollUntil, type RunnerApi } from "./api.js";
|
||||
import {
|
||||
@@ -33,8 +36,6 @@ import {
|
||||
type Row,
|
||||
} from "./first-task-scoring.js";
|
||||
|
||||
/** Results enter the public history bundle. Full observations stay in the
|
||||
* access-controlled snapshots and are graded before this projection. */
|
||||
export function projectFirstTaskResult(result: RunnerE2EResult): RunnerE2EResult {
|
||||
const e = result.firstTask;
|
||||
if (!e) return result;
|
||||
@@ -144,16 +145,138 @@ export function projectFirstTaskResult(result: RunnerE2EResult): RunnerE2EResult
|
||||
};
|
||||
}
|
||||
|
||||
/** The wizard creates the agent/task. Only credential provisioning uses Node fetch,
|
||||
* keeping plaintext keys out of Playwright's trace and recorded form values. */
|
||||
export function managedFirstTaskFileCheck(e: FirstTaskEvidence) {
|
||||
const last = e.checkpoints.at(-1);
|
||||
const children = last?.tasks.filter(task => task.parentId === e.onboardingIssueId && task.status === "done") ?? [];
|
||||
const marker = firstTaskScenario(e.caseId, e.nonce).marker;
|
||||
const passed = children.length === 1 && (last?.attachments ?? []).some(attachment =>
|
||||
attachment.issueId === children[0]!.id && attachment.originalFilename === "garden-welcome.txt"
|
||||
&& /^text\/(?:plain|markdown)(?:;|$)/i.test(String(attachment.contentType))
|
||||
&& attachment.contentVerified === true && typeof attachment.body === "string"
|
||||
&& attachment.contentSha256 === digestText(attachment.body) && attachment.body.includes(marker));
|
||||
return { id: "managed-onboarding-physical-artifact", passed, evidence: last ? [last.id] : [],
|
||||
detail: "The completed child has a downloaded, size/hash-verified garden-welcome.txt attachment with the requested marker." };
|
||||
}
|
||||
|
||||
export async function observeManagedFirstTaskSetup(page: Page, api: RunnerApi, fixtures: LiveFixtureValues, deadlineAt: number) {
|
||||
const before = new Set((await api.get<Row[]>(`/api/environments/${fixtures.environment.id}/leases`)).map(lease => lease.id));
|
||||
const startedAt = Date.now();
|
||||
const qualification = fixtures.setupQualification!;
|
||||
const operations: Promise<void>[] = [];
|
||||
let rejectFailure!: (error: unknown) => void;
|
||||
const failure = new Promise<never>((_resolve, reject) => { rejectFailure = reject; });
|
||||
void failure.catch(() => undefined);
|
||||
const requestStarts = new WeakMap<Request, number>();
|
||||
const pendingSetupRequests = new Set<Request>();
|
||||
let platformEndedAt = Infinity;
|
||||
let qualifiedDefault = false;
|
||||
let automaticRequest = false;
|
||||
function phase(request: Request): "platform" | "adapter" | "discovery" | null {
|
||||
const url = new URL(request.url());
|
||||
if (request.method() === "POST" && url.pathname === `/api/environments/${fixtures.environment.id}/probe`
|
||||
&& url.searchParams.get("companyId") === fixtures.company.id) return "platform";
|
||||
if (request.method() === "POST" && url.pathname === `/api/companies/${fixtures.company.id}/adapters/codex_local/test-environment`) return "adapter";
|
||||
if (request.method() === "GET" && url.pathname === "/api/adapters" && url.searchParams.get("companyId") === fixtures.company.id
|
||||
&& url.searchParams.get("environmentId") === fixtures.environment.id) return "discovery";
|
||||
return null;
|
||||
}
|
||||
const onRequest = (request: Request) => {
|
||||
const kind = phase(request);
|
||||
if (kind) requestStarts.set(request, Date.now());
|
||||
if (kind === "platform" || kind === "adapter") pendingSetupRequests.add(request);
|
||||
};
|
||||
const onRequestFailed = (request: Request) => {
|
||||
if (pendingSetupRequests.has(request)) fixtures.setupLeaseAccountingIncomplete = true;
|
||||
};
|
||||
const onResponse = (response: Response) => {
|
||||
const request = response.request();
|
||||
const kind = phase(request);
|
||||
if (!kind) return;
|
||||
const responseAt = Date.now();
|
||||
if (kind === "platform") { qualification.platformResponses++; platformEndedAt = responseAt; }
|
||||
if (kind === "adapter") qualification.adapterResponses++;
|
||||
if (qualification.platformResponses > 1 || qualification.adapterResponses > 1) rejectFailure(new Error("The UI repeated a managed setup request"));
|
||||
const operation = (async () => {
|
||||
const body = await response.json() as Row | Row[];
|
||||
if (kind === "discovery") {
|
||||
if (Array.isArray(body) && responseAt >= platformEndedAt) {
|
||||
const codex = body.find(adapter => adapter.type === "codex_local");
|
||||
qualifiedDefault = codex?.defaultRunner === "paperclip" && codex?.supportedRunners?.includes("paperclip") === true;
|
||||
if (!qualifiedDefault) rejectFailure(new Error("The selected image is not qualified for native Codex onboarding"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
const data = body as Row;
|
||||
if (kind === "platform") {
|
||||
qualification.recorded = response.ok() && data.ok === true && data.details?.platformQualificationRecorded === true;
|
||||
if (!qualification.recorded) rejectFailure(new Error("The UI environment qualification did not pass"));
|
||||
}
|
||||
else {
|
||||
qualification.adapterType = response.ok() && data.status === "pass" ? data.adapterType : null;
|
||||
if (qualification.adapterType !== "paperclip_runner") rejectFailure(new Error("The UI native runtime setup did not pass"));
|
||||
automaticRequest = request.postDataJSON()?.runner === undefined && (requestStarts.get(request) ?? 0) >= platformEndedAt;
|
||||
}
|
||||
const leaseId = kind === "platform" ? data.details?.leaseId
|
||||
: /(?:^|;\s*)paperclipLeaseId=([a-zA-Z0-9-]+)(?:;|$)/.exec(String(data.checks?.find((check: Row) => check.code === "sandbox_test_identity")?.detail ?? ""))?.[1];
|
||||
if (typeof leaseId !== "string" || !leaseId) {
|
||||
fixtures.setupLeaseAccountingIncomplete = true;
|
||||
return;
|
||||
}
|
||||
const lease = await api.get<Row>(`/api/environment-leases/${encodeURIComponent(leaseId)}`, { timeout: 10_000 });
|
||||
if (before.has(leaseId) || lease.companyId !== fixtures.company.id || lease.environmentId !== fixtures.environment.id
|
||||
|| lease.provider !== "daytona" || lease.heartbeatRunId || lease.issueId) throw new Error("Setup response references a foreign or non-setup lease");
|
||||
pendingSetupRequests.delete(request);
|
||||
if (!fixtures.setupLeases!.some(entry => entry.leaseId === leaseId)) fixtures.setupLeases!.push({ leaseId, phase: kind,
|
||||
durationMs: responseAt - (requestStarts.get(request) ?? startedAt) });
|
||||
})();
|
||||
void operation.catch(error => { fixtures.setupLeaseAccountingIncomplete = true; rejectFailure(error); });
|
||||
operations.push(operation);
|
||||
};
|
||||
page.on("request", onRequest);
|
||||
page.on("response", onResponse);
|
||||
page.on("requestfailed", onRequestFailed);
|
||||
const stop = async (requireSuccess: boolean) => {
|
||||
page.off("request", onRequest);
|
||||
page.off("response", onResponse);
|
||||
page.off("requestfailed", onRequestFailed);
|
||||
const settled = await Promise.allSettled(operations);
|
||||
if (pendingSetupRequests.size > 0) fixtures.setupLeaseAccountingIncomplete = true;
|
||||
const failures = settled.flatMap(result => result.status === "rejected" ? [result.reason] : []);
|
||||
let leases: Row[];
|
||||
try { leases = await api.get<Row[]>(`/api/environments/${fixtures.environment.id}/leases`, { timeout: 10_000 }); }
|
||||
catch (error) { fixtures.setupLeaseAccountingIncomplete = true; throw error; }
|
||||
for (const lease of leases) {
|
||||
if (before.has(lease.id) || lease.companyId !== fixtures.company.id || lease.environmentId !== fixtures.environment.id
|
||||
|| lease.provider !== "daytona" || lease.heartbeatRunId || lease.issueId) continue;
|
||||
if (!fixtures.setupLeases!.some(entry => entry.leaseId === lease.id)) fixtures.setupLeaseAccountingIncomplete = true;
|
||||
}
|
||||
if (failures.length) throw new AggregateError(failures, "Managed onboarding setup evidence could not be verified");
|
||||
if (!requireSuccess) return;
|
||||
if (!qualification.recorded || qualification.platformResponses !== 1 || qualification.adapterResponses !== 1
|
||||
|| qualification.adapterType !== "paperclip_runner" || !automaticRequest || !qualifiedDefault
|
||||
|| fixtures.setupLeaseAccountingIncomplete || fixtures.setupLeases!.length !== 2 || !["platform", "adapter"].every(kind => fixtures.setupLeases!.some(lease => lease.phase === kind))) {
|
||||
throw new Error("The UI did not prove one qualified automatic native setup with both owned leases");
|
||||
}
|
||||
await pollUntil({ label: "managed onboarding setup lease cleanup", deadlineAt: Math.min(deadlineAt, Date.now() + 120_000),
|
||||
load: () => api.get<Row[]>(`/api/environments/${fixtures.environment.id}/leases`),
|
||||
accept: rows => fixtures.setupLeases!.every(entry => rows.some(row => row.id === entry.leaseId
|
||||
&& row.companyId === fixtures.company.id && row.environmentId === fixtures.environment.id
|
||||
&& row.status === "released" && row.cleanupStatus === "success")),
|
||||
});
|
||||
};
|
||||
return Object.assign(stop, { failure });
|
||||
}
|
||||
|
||||
export async function setupFirstTaskFixtures(input: {
|
||||
page: Page;
|
||||
api: RunnerApi;
|
||||
execution: MatrixExecution;
|
||||
nonce: string;
|
||||
credentials: Partial<Record<CredentialName, string>>;
|
||||
daytonaImage?: string;
|
||||
deadlineAt?: number;
|
||||
observe: (fixtures: LiveFixtureValues) => void;
|
||||
}): Promise<LiveFixtureValues> {
|
||||
}): Promise<FirstTaskFixtureValues> {
|
||||
const { page, api, execution, nonce } = input;
|
||||
await api.patch("/api/instance/settings/experimental", {
|
||||
enableClassicTaskInterface: false,
|
||||
@@ -178,6 +301,7 @@ export async function setupFirstTaskFixtures(input: {
|
||||
execution,
|
||||
nonce,
|
||||
credentials: input.credentials,
|
||||
daytonaImage: input.daytonaImage,
|
||||
company: {
|
||||
id: company.id,
|
||||
name: company.name,
|
||||
@@ -215,13 +339,20 @@ export async function setupFirstTaskFixtures(input: {
|
||||
await page
|
||||
.getByRole("combobox", { name: "Saved API key" })
|
||||
.selectOption(`company:${secret.secretId}`);
|
||||
await page
|
||||
.getByRole("button", { name: /^(Connect|Next)$/, exact: true })
|
||||
.last()
|
||||
.click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Get started", exact: true }),
|
||||
).toBeVisible({ timeout: 120_000 });
|
||||
const managed = isManagedFirstTask(execution);
|
||||
if (managed && (!input.deadlineAt || input.deadlineAt - Date.now() <= 120_000)) throw new Error("Managed onboarding has no bounded setup deadline");
|
||||
const stopSetup = managed ? await observeManagedFirstTaskSetup(page, api, fixtures, input.deadlineAt! - 60_000) : null;
|
||||
let setupError: unknown;
|
||||
try {
|
||||
await page.getByRole("button", { name: /^(Connect|Next)$/, exact: true }).last().click();
|
||||
const visible = expect(page.getByRole("button", { name: "Get started", exact: true })).toBeVisible({
|
||||
timeout: managed ? input.deadlineAt! - Date.now() - 120_000 : 120_000,
|
||||
});
|
||||
if (stopSetup) await Promise.race([visible, stopSetup.failure]); else await visible;
|
||||
} catch (error) { setupError = error; }
|
||||
try { await stopSetup?.(!setupError); }
|
||||
catch (error) { setupError = setupError ? new AggregateError([setupError, error], "Managed onboarding and setup evidence failed") : error; }
|
||||
if (setupError) throw setupError;
|
||||
const agents = await api.get<Row[]>(`/api/companies/${company.id}/agents`);
|
||||
expect(agents).toHaveLength(1);
|
||||
const wizardAdapter =
|
||||
@@ -249,16 +380,27 @@ export async function setupFirstTaskFixtures(input: {
|
||||
name: agents[0].name,
|
||||
};
|
||||
input.observe(fixtures);
|
||||
await page.getByRole("button", { name: "Get started", exact: true }).click();
|
||||
if (managed) {
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
const persisted = await api.get<Row>(`/api/agents/${fixtures.agent.id}`);
|
||||
expect(persisted.adapterType).toBe("paperclip_runner");
|
||||
expect(persisted.adapterConfig?.provider).toBe("codex");
|
||||
}
|
||||
await startFirstTaskWithRuntimeBound({
|
||||
api, execution, agent: agents[0],
|
||||
observe: bound => { fixtures.onboardingRuntimeBound = bound; input.observe(fixtures); },
|
||||
start: () => page.getByRole("button", { name: "Get started", exact: true }).click(),
|
||||
});
|
||||
return fixtures;
|
||||
}
|
||||
|
||||
export async function runFirstTaskFlow(input: {
|
||||
page: Page;
|
||||
api: RunnerApi;
|
||||
fixtures: LiveFixtureValues;
|
||||
fixtures: FirstTaskFixtureValues;
|
||||
execution: MatrixExecution;
|
||||
nonce: string;
|
||||
deadlineAt?: number;
|
||||
observe: (issue: any, runs: any[], evidence: FirstTaskEvidence) => void;
|
||||
secrets: readonly string[];
|
||||
createOrdinary: (title: string, prompt: string) => Promise<Row>;
|
||||
@@ -267,8 +409,14 @@ export async function runFirstTaskFlow(input: {
|
||||
}) {
|
||||
const { page, api, fixtures, execution, nonce } = input;
|
||||
const scenario = firstTaskScenario(execution.task.id, nonce);
|
||||
const deadlineAt =
|
||||
Date.now() + execution.task.attemptTimeoutMs.local - 120_000;
|
||||
const managed = isManagedFirstTask(execution);
|
||||
if (managed) {
|
||||
const attachment = "Attach the finished note as a UTF-8 text file named garden-welcome.txt.";
|
||||
scenario.prompt += ` ${attachment}`;
|
||||
scenario.facts += ` ${attachment}`;
|
||||
}
|
||||
const deadlineAt = managed ? (input.deadlineAt ?? Date.now()) - 120_000
|
||||
: Date.now() + execution.task.attemptTimeoutMs.local - 120_000;
|
||||
const tasksPath = `/api/companies/${fixtures.company.id}/issues?limit=100`;
|
||||
const allRuns = async () => {
|
||||
const runs = await api.get<Row[]>(
|
||||
@@ -353,6 +501,7 @@ export async function runFirstTaskFlow(input: {
|
||||
};
|
||||
e.checkpoints.push(checkpoint);
|
||||
e.checks = gradeFirstTask(e);
|
||||
if (managed && e.checkpoints.at(-1)?.phase === "finished") e.checks.push(managedFirstTaskFileCheck(e));
|
||||
if (execution.suite.id === "confirmation-replies" && scenario.id !== "task-card-accept") e.checks.push(...gradeConfirmationReply(e));
|
||||
input.observe(issue, runs, e);
|
||||
await input.evidence("first-task.json", e);
|
||||
@@ -443,6 +592,7 @@ export async function runFirstTaskFlow(input: {
|
||||
e.runtimeSettings = {
|
||||
completionDeliveryProbe: runsCompletionUpdateProbe(execution),
|
||||
onboardingRuntime: fixtures.onboardingRuntime,
|
||||
...(fixtures.onboardingRuntimeBound ? { onboardingRuntimeBound: fixtures.onboardingRuntimeBound } : {}),
|
||||
adapterType: agent.adapterType,
|
||||
adapterConfig: agent.adapterConfig,
|
||||
runtimeConfig: agent.runtimeConfig,
|
||||
@@ -714,6 +864,7 @@ export async function runFirstTaskFlow(input: {
|
||||
await snapshot("finished");
|
||||
}
|
||||
e.checks = gradeFirstTask(e);
|
||||
if (managed && e.checkpoints.at(-1)?.phase === "finished") e.checks.push(managedFirstTaskFileCheck(e));
|
||||
if (execution.suite.id === "confirmation-replies" && scenario.id !== "task-card-accept") e.checks.push(...gradeConfirmationReply(e));
|
||||
await input.evidence("first-task.json", e);
|
||||
await input.capture(
|
||||
|
||||
@@ -6,11 +6,15 @@ import { firstTaskRejectionReplyRecorded, isFirstTaskRejectionCancellation } fro
|
||||
import { createIssueThreadInteractionSchema } from "../../packages/shared/src/validators/issue.js";
|
||||
import { renderInteractionCard } from "./interaction-report.js";
|
||||
import { main as judgeCommand } from "./first-task-judge.js";
|
||||
import { projectFirstTaskResult } from "./first-task-flow.js";
|
||||
import { projectFirstTaskResult, managedFirstTaskFileCheck, observeManagedFirstTaskSetup } from "./first-task-flow.js";
|
||||
import { validateRetainedRunnerResult } from "./result-validation.js";
|
||||
import { buildRuntimeUsage } from "./billing.js";
|
||||
import { fixtureRuntimeCostCoverage, selectFixtureRuntimeLeases } from "./live-fixtures.js";
|
||||
import {
|
||||
firstTaskNativeRuntimePatch,
|
||||
provisionFirstTaskFixtures,
|
||||
startFirstTaskWithRuntimeBound,
|
||||
MANAGED_FIRST_TASK_CELL,
|
||||
} from "./first-task-fixtures.js";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { mkdtemp, mkdir, writeFile, readFile, rm } from "node:fs/promises";
|
||||
@@ -230,6 +234,7 @@ describe("first-task public result projection", () => {
|
||||
adapterConfig: { provider: "codex", model: "gpt-5.6-sol", env: { OPENAI_API_KEY: sentinel }, codexHome: sentinel },
|
||||
runtimeConfig: { accountId: sentinel }, permissions: { privateTrace: sentinel },
|
||||
onboardingRuntime: { mode: "production-wizard", originalAdapterType: "paperclip_runner", testedAdapterType: "paperclip_runner", accountId: sentinel },
|
||||
onboardingRuntimeBound: { timeoutSec: 180, maxConcurrentRuns: 1, before: { runtimeConfig: { accountId: sentinel } }, after: { runtimeConfig: { accountId: sentinel } } },
|
||||
};
|
||||
for (const checkpoint of e.checkpoints) {
|
||||
checkpoint.tasks.forEach(t => Object.assign(t, { executionState: { sessionId: sentinel }, responsibleUserId: sentinel }));
|
||||
@@ -815,6 +820,110 @@ Accept the card above and I write it. This task stays in review until then.`;
|
||||
expect(postSensitive).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe("bounded automatic Codex onboarding admission", () => {
|
||||
function harness(environment = "local", profile = "runner-codex") {
|
||||
const execution = runnerMatrix.find(entry => entry.task.flow === "first_task" && entry.profile.id === profile && entry.environment.id === environment)!;
|
||||
const original = {
|
||||
id: "agent", companyId: "company", adapterType: "paperclip_runner",
|
||||
adapterConfig: { provider: "codex", timeoutSec: 0, model: "gpt-5.6-sol", modelReasoningEffort: "high", codexPermissionMode: "readOnly",
|
||||
env: { OPENAI_API_KEY: { type: "secret_ref", secretId: "encrypted-key", version: "latest" } }, paperclipSkillSync: { desiredSkills: ["paperclipai/paperclip/first-task"] } },
|
||||
runtimeConfig: { aiConnection: { provider: "openai", method: "api_key", mode: "responsible_user" },
|
||||
heartbeat: { maxConcurrentRuns: 20, enabled: false, intervalSec: 0, wakeOnAssignment: true, customHeartbeatSetting: "retained" }, customSetting: "retained" },
|
||||
permissions: { canCreateAgents: true }, defaultEnvironmentId: environment, budgetMonthlyCents: 500, metadata: { ownerUserId: "qa-user" },
|
||||
};
|
||||
let stored = structuredClone(original);
|
||||
const calls: string[] = [];
|
||||
const api = {
|
||||
get: vi.fn(async (_path: string) => { calls.push("GET"); return structuredClone(stored); }),
|
||||
patch: vi.fn(async (_path: string, data: any) => {
|
||||
calls.push("PATCH");
|
||||
stored.adapterConfig = { ...stored.adapterConfig, ...data.adapterConfig };
|
||||
if (data.runtimeConfig) stored.runtimeConfig = structuredClone(data.runtimeConfig);
|
||||
return structuredClone(stored);
|
||||
}),
|
||||
};
|
||||
const observe = vi.fn(() => { calls.push("OBSERVE"); });
|
||||
const start = vi.fn(async () => { calls.push("START"); });
|
||||
const admit = () => startFirstTaskWithRuntimeBound({ api: api as any, execution, agent: original, observe, start });
|
||||
return { api, original, calls, observe, start, admit, stored: () => stored };
|
||||
}
|
||||
|
||||
it.each(["local", "daytona"])("bounds %s without choosing a runner, provider, model, or account", async environment => {
|
||||
const h = harness(environment);
|
||||
const original = structuredClone(h.original);
|
||||
const expected = { ...original, adapterConfig: { ...original.adapterConfig, timeoutSec: 180 },
|
||||
runtimeConfig: { ...original.runtimeConfig, heartbeat: { ...original.runtimeConfig.heartbeat, maxConcurrentRuns: 1 } } };
|
||||
await h.admit();
|
||||
expect(h.stored()).toEqual(expected);
|
||||
expect(h.api.patch).toHaveBeenCalledExactlyOnceWith("/api/agents/agent", { adapterConfig: { timeoutSec: 180 }, runtimeConfig: expected.runtimeConfig });
|
||||
expect(h.calls).toEqual(["GET", "PATCH", "GET", "OBSERVE", "START"]);
|
||||
expect(h.original).toEqual(original);
|
||||
expect(h.observe).toHaveBeenCalledExactlyOnceWith({ timeoutSec: 180, maxConcurrentRuns: 1, before: original, after: expected });
|
||||
});
|
||||
|
||||
it.each(["read-failure", "patch-failure", "verification-failure", "missing-timeout", "missing-concurrency"])("does not admit task execution after %s", async mode => {
|
||||
const h = harness();
|
||||
if (mode === "read-failure") h.api.get.mockRejectedValue(new Error("Agent read failed"));
|
||||
if (mode === "patch-failure") h.api.patch.mockRejectedValue(new Error("Timeout patch failed"));
|
||||
if (mode === "verification-failure") h.api.get.mockResolvedValueOnce(structuredClone(h.original)).mockRejectedValueOnce(new Error("Verification read failed"));
|
||||
if (mode === "missing-timeout") h.api.patch.mockImplementation(async (_path, data) => {
|
||||
h.stored().runtimeConfig = structuredClone(data.runtimeConfig);
|
||||
return structuredClone(h.stored());
|
||||
});
|
||||
if (mode === "missing-concurrency") h.api.patch.mockImplementation(async (_path, data) => {
|
||||
h.stored().adapterConfig = { ...h.stored().adapterConfig, ...data.adapterConfig };
|
||||
return structuredClone(h.stored());
|
||||
});
|
||||
await expect(h.admit()).rejects.toThrow();
|
||||
expect(h.start).not.toHaveBeenCalled();
|
||||
expect(h.observe).not.toHaveBeenCalled();
|
||||
if (mode === "read-failure") expect(h.api.patch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["legacy", "wrong-provider", "foreign-agent", "changed-account"])("rejects %s before changing the automatic wizard choice", async mode => {
|
||||
const h = harness();
|
||||
if (mode === "legacy") h.original.adapterType = "codex_local";
|
||||
if (mode === "wrong-provider") h.original.adapterConfig.provider = "claude";
|
||||
if (mode === "foreign-agent") h.api.get.mockResolvedValue({ ...h.original, companyId: "another-company" });
|
||||
if (mode === "changed-account") h.api.get.mockResolvedValue({ ...h.original, runtimeConfig: { ...h.original.runtimeConfig,
|
||||
aiConnection: { ...h.original.runtimeConfig.aiConnection, method: "subscription" } } });
|
||||
await expect(h.admit()).rejects.toThrow();
|
||||
expect(h.api.patch).not.toHaveBeenCalled();
|
||||
expect(h.start).not.toHaveBeenCalled();
|
||||
expect(h.observe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["runner", "provider", "model", "account", "permissions", "environment", "instructions", "heartbeat-policy", "other-runtime", "missing-heartbeat"])("refuses a persisted %s change instead of treating the bound PATCH as proof", async field => {
|
||||
const h = harness();
|
||||
const altered = structuredClone(h.original);
|
||||
altered.adapterConfig.timeoutSec = 180;
|
||||
altered.runtimeConfig.heartbeat.maxConcurrentRuns = 1;
|
||||
if (field === "runner") altered.adapterType = "codex_local";
|
||||
if (field === "provider") altered.adapterConfig.provider = "claude";
|
||||
if (field === "model") altered.adapterConfig.model = "another-model";
|
||||
if (field === "account") altered.runtimeConfig.aiConnection.mode = "fixed";
|
||||
if (field === "permissions") altered.permissions.canCreateAgents = false;
|
||||
if (field === "environment") altered.defaultEnvironmentId = "another-environment";
|
||||
if (field === "instructions") altered.adapterConfig.paperclipSkillSync.desiredSkills = [];
|
||||
if (field === "heartbeat-policy") altered.runtimeConfig.heartbeat.wakeOnAssignment = false;
|
||||
if (field === "other-runtime") altered.runtimeConfig.customSetting = "lost";
|
||||
if (field === "missing-heartbeat") delete (altered.runtimeConfig as Partial<typeof altered.runtimeConfig>).heartbeat;
|
||||
h.api.get.mockResolvedValueOnce(structuredClone(h.original)).mockResolvedValueOnce(altered);
|
||||
await expect(h.admit()).rejects.toThrow("runtime bound was not confirmed");
|
||||
expect(h.start).not.toHaveBeenCalled();
|
||||
expect(h.observe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["legacy-codex", "legacy-claude", "runner-acpx-claude"])("leaves %s admission unchanged", async profile => {
|
||||
const h = harness("local", profile);
|
||||
await h.admit();
|
||||
expect(h.api.get).not.toHaveBeenCalled();
|
||||
expect(h.api.patch).not.toHaveBeenCalled();
|
||||
expect(h.observe).not.toHaveBeenCalled();
|
||||
expect(h.start).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["runner-codex", "runner-acpx-claude"])(
|
||||
"switches only the runtime for %s, preserving onboarding assets and the default model",
|
||||
(id) => {
|
||||
@@ -1442,3 +1551,342 @@ describe("recorded refusal cancellation", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("managed onboarding through the production wizard", () => {
|
||||
const image = `registry.example.test/runtime@sha256:${"a".repeat(64)}`;
|
||||
const execution = () => runnerMatrix.find(entry => entry.id === MANAGED_FIRST_TASK_CELL)!;
|
||||
|
||||
function managedFixtureApi(previousDefault: string | null = "previous-default", previousManagedOnly = false) {
|
||||
const state = { defaultEnvironmentId: previousDefault, experimental: { enableManagedSandboxOnly: previousManagedOnly, enableAgentChat: true } };
|
||||
const calls: string[] = [];
|
||||
const api = {
|
||||
get: vi.fn(async () => structuredClone(state)),
|
||||
patch: vi.fn(async (path: string, data: any) => {
|
||||
calls.push(`PATCH ${path}`);
|
||||
if (path === "/api/instance/settings") state.defaultEnvironmentId = data.defaultEnvironmentId;
|
||||
if (path === "/api/instance/settings/experimental") Object.assign(state.experimental, data);
|
||||
return structuredClone(state);
|
||||
}),
|
||||
postSensitive: vi.fn(async (_path: string, data: any) => ({ id: `encrypted-${data.key}` })),
|
||||
post: vi.fn(async (path: string, data: any) => {
|
||||
calls.push(`POST ${path}`);
|
||||
return path === "/api/plugins/install" ? { id: "plugin", status: "ready" } : { id: "environment", driver: "sandbox", config: data.config };
|
||||
}),
|
||||
delete: vi.fn(async (path: string) => { calls.push(`DELETE ${path}`); }),
|
||||
};
|
||||
const provision = () => provisionFirstTaskFixtures({ api: api as any, execution: execution(), nonce: "private", company: { id: "company", name: "Fresh" },
|
||||
daytonaImage: image, credentials: { OPENAI_API_KEY: "synthetic-key", DAYTONA_API_KEY: "synthetic-platform" } });
|
||||
return { api, state, calls, provision };
|
||||
}
|
||||
|
||||
it.each([[null, false], ["previous-default", false], ["previous-default", true]] as const)(
|
||||
"restores prior default %s and managed-only %s before deleting the managed fixture", async (previousDefault, previousManagedOnly) => {
|
||||
const h = managedFixtureApi(previousDefault, previousManagedOnly);
|
||||
const fixtures = await h.provision();
|
||||
expect(h.state).toMatchObject({ defaultEnvironmentId: "environment", experimental: { enableManagedSandboxOnly: true } });
|
||||
let deletedState: typeof h.state | undefined;
|
||||
h.api.delete.mockImplementation(async () => { deletedState = structuredClone(h.state); });
|
||||
await fixtures.teardown();
|
||||
expect(deletedState).toMatchObject({ defaultEnvironmentId: previousDefault, experimental: { enableManagedSandboxOnly: previousManagedOnly } });
|
||||
expect(h.api.get).toHaveBeenCalledExactlyOnceWith("/api/instance/settings");
|
||||
expect(h.api.patch).toHaveBeenLastCalledWith("/api/instance/settings", { defaultEnvironmentId: previousDefault });
|
||||
expect(h.state.experimental.enableAgentChat).toBe(true);
|
||||
expect(h.api.delete).toHaveBeenCalledExactlyOnceWith("/api/environments/environment?destroyReusableSandboxLeases=true", { allowNotFound: true });
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["default", "managed-only"])("restores a %s setup write whose response is lost after committing", async phase => {
|
||||
const h = managedFixtureApi();
|
||||
const patch = h.api.patch.getMockImplementation()!;
|
||||
let failed = false;
|
||||
h.api.patch.mockImplementation(async (path, data) => {
|
||||
const result = await patch(path, data);
|
||||
if (!failed && (phase === "default" ? data.defaultEnvironmentId === "environment" : data.enableManagedSandboxOnly === true)) {
|
||||
failed = true;
|
||||
throw new Error("Setup response lost after commit");
|
||||
}
|
||||
return result;
|
||||
});
|
||||
await expect(h.provision()).rejects.toThrow("Setup response lost after commit");
|
||||
expect(h.state).toEqual({ defaultEnvironmentId: "previous-default", experimental: { enableManagedSandboxOnly: false, enableAgentChat: true } });
|
||||
expect(h.api.delete).toHaveBeenCalledTimes(1);
|
||||
if (phase === "default") expect(h.api.patch.mock.calls.some(([path]) => path === "/api/instance/settings/experimental")).toBe(false);
|
||||
});
|
||||
|
||||
it("retains setup and restoration failures instead of deleting an unconfirmed instance default", async () => {
|
||||
const h = managedFixtureApi();
|
||||
const patch = h.api.patch.getMockImplementation()!;
|
||||
h.api.patch.mockImplementation(async (path, data) => {
|
||||
if (path === "/api/instance/settings/experimental" && data.enableManagedSandboxOnly === true) throw new Error("Managed mode setup failed");
|
||||
if (path === "/api/instance/settings" && data.defaultEnvironmentId === "previous-default") throw new Error("Default restoration failed");
|
||||
return patch(path, data);
|
||||
});
|
||||
const error = await h.provision().catch(error => error);
|
||||
expect(error).toBeInstanceOf(AggregateError);
|
||||
expect(error.errors[0].message).toBe("Managed mode setup failed");
|
||||
expect(error.errors[1].errors[0].message).toBe("Default restoration failed");
|
||||
expect(error.errors[1].message).toContain("environment deletion was not attempted");
|
||||
expect(h.api.delete).not.toHaveBeenCalled();
|
||||
expect(h.state.defaultEnvironmentId).toBe("environment");
|
||||
});
|
||||
|
||||
it("keeps failed default restoration outstanding and only deletes after a later confirmed restoration", async () => {
|
||||
const h = managedFixtureApi();
|
||||
const fixtures = await h.provision();
|
||||
const patch = h.api.patch.getMockImplementation()!;
|
||||
h.api.patch.mockImplementation(async (path, data) => {
|
||||
if (path === "/api/instance/settings") throw new Error("Default restoration failed");
|
||||
return patch(path, data);
|
||||
});
|
||||
await expect(fixtures.teardown()).rejects.toThrow("environment deletion was not attempted");
|
||||
expect(h.state).toMatchObject({ defaultEnvironmentId: "environment", experimental: { enableManagedSandboxOnly: false } });
|
||||
expect(h.api.delete).not.toHaveBeenCalled();
|
||||
h.api.patch.mockImplementation(patch);
|
||||
await fixtures.teardown();
|
||||
await fixtures.teardown();
|
||||
expect(h.state.defaultEnvironmentId).toBe("previous-default");
|
||||
expect(h.api.delete).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reports failed managed-mode restoration while still removing an environment that is no longer the default", async () => {
|
||||
const h = managedFixtureApi();
|
||||
const fixtures = await h.provision();
|
||||
h.state.experimental.enableAgentChat = false;
|
||||
const patch = h.api.patch.getMockImplementation()!;
|
||||
h.api.patch.mockImplementation(async (path, data) => {
|
||||
if (path === "/api/instance/settings/experimental") throw new Error("Managed mode restoration failed");
|
||||
return patch(path, data);
|
||||
});
|
||||
const error = await fixtures.teardown().catch(error => error);
|
||||
expect(error).toBeInstanceOf(AggregateError);
|
||||
expect(error.errors.map((error: Error) => error.message)).toEqual(["Managed mode restoration failed"]);
|
||||
expect(h.state.defaultEnvironmentId).toBe("previous-default");
|
||||
expect(h.api.delete).toHaveBeenCalledTimes(1);
|
||||
h.api.patch.mockImplementation(patch);
|
||||
await fixtures.teardown();
|
||||
expect(h.state.experimental).toEqual({ enableManagedSandboxOnly: false, enableAgentChat: false });
|
||||
expect(h.api.delete).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps failed environment deletion red after successful settings restoration", async () => {
|
||||
const h = managedFixtureApi();
|
||||
const fixtures = await h.provision();
|
||||
h.api.delete.mockRejectedValue(new Error("Provider cleanup unconfirmed"));
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const rejected = expect(fixtures.teardown()).rejects.toThrow("Managed onboarding fixture cleanup failed");
|
||||
await vi.runAllTimersAsync();
|
||||
await rejected;
|
||||
expect(h.state).toMatchObject({ defaultEnvironmentId: "previous-default", experimental: { enableManagedSandboxOnly: false } });
|
||||
h.api.delete.mockResolvedValue();
|
||||
await fixtures.teardown();
|
||||
const deletes = h.api.delete.mock.calls.length;
|
||||
await fixtures.teardown();
|
||||
expect(h.api.delete).toHaveBeenCalledTimes(deletes);
|
||||
} finally { vi.useRealTimers(); }
|
||||
});
|
||||
|
||||
it.each(["read-failure", "missing-default", "missing-mode"])("fails before fixture mutations when prior settings have %s", async mode => {
|
||||
const h = managedFixtureApi();
|
||||
if (mode === "read-failure") h.api.get.mockRejectedValue(new Error("Settings read failed"));
|
||||
else h.api.get.mockResolvedValue((mode === "missing-default" ? { experimental: { enableManagedSandboxOnly: false } } : { defaultEnvironmentId: null, experimental: {} }) as any);
|
||||
await expect(h.provision()).rejects.toThrow(mode === "read-failure" ? "Settings read failed" : "prior instance settings could not be read");
|
||||
expect(h.api.patch).not.toHaveBeenCalled();
|
||||
expect(h.api.postSensitive).not.toHaveBeenCalled();
|
||||
expect(h.api.post).not.toHaveBeenCalled();
|
||||
expect(h.api.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prepares only encrypted credentials and an immutable managed default, with no fixture probe or agent", async () => {
|
||||
const calls: string[] = [];
|
||||
const api = {
|
||||
get: vi.fn(async () => ({ defaultEnvironmentId: null, experimental: { enableManagedSandboxOnly: false } })),
|
||||
patch: vi.fn(async (path: string) => { calls.push(`PATCH ${path}`); return {}; }),
|
||||
postSensitive: vi.fn(async (_path: string, data: any) => ({ id: `encrypted-${data.key}` })),
|
||||
post: vi.fn(async (path: string, data: any) => {
|
||||
calls.push(`POST ${path}`);
|
||||
if (path === "/api/plugins/install") return { id: "plugin", status: "ready" };
|
||||
expect(path).toBe("/api/companies/company/environments");
|
||||
expect(data.config).toMatchObject({ image, apiKey: { type: "secret_ref", secretId: "encrypted-DAYTONA_API_KEY", version: "latest" } });
|
||||
expect(data).not.toHaveProperty("metadata");
|
||||
return { id: "environment", driver: "sandbox", config: data.config };
|
||||
}),
|
||||
delete: vi.fn(async () => {}),
|
||||
};
|
||||
const fixtures = await provisionFirstTaskFixtures({ api: api as any, execution: execution(), nonce: "private", company: { id: "company", name: "Fresh" },
|
||||
daytonaImage: image, credentials: { OPENAI_API_KEY: "synthetic-key", DAYTONA_API_KEY: "synthetic-platform" } });
|
||||
expect(calls).toEqual(["PATCH /api/companies/company", "POST /api/plugins/install", "POST /api/companies/company/environments", "PATCH /api/instance/settings", "PATCH /api/instance/settings/experimental"]);
|
||||
expect(api.patch).toHaveBeenCalledWith("/api/instance/settings", { defaultEnvironmentId: "environment" });
|
||||
expect(api.patch).toHaveBeenCalledWith("/api/instance/settings/experimental", { enableManagedSandboxOnly: true });
|
||||
expect(fixtures.agent.id).toBe("");
|
||||
expect(fixtures.setupLeases).toEqual([]);
|
||||
expect(JSON.stringify(fixtures)).not.toContain("synthetic");
|
||||
await fixtures.teardown();
|
||||
expect(api.delete).toHaveBeenCalledExactlyOnceWith("/api/environments/environment?destroyReusableSandboxLeases=true", { allowNotFound: true });
|
||||
});
|
||||
|
||||
it.each(["missing-key", "mutable-image", "other-cell"])("rejects %s before admitting a managed browser fixture", async mode => {
|
||||
const api = { get: vi.fn(), patch: vi.fn(), postSensitive: vi.fn(), post: vi.fn(), delete: vi.fn() };
|
||||
await expect(provisionFirstTaskFixtures({ api, execution: mode === "other-cell" ? { ...execution(), id: "other" } : execution(), nonce: "private", company: { id: "company", name: "Fresh" },
|
||||
daytonaImage: mode === "mutable-image" ? "ubuntu:24.04" : image,
|
||||
credentials: mode === "missing-key" ? { OPENAI_API_KEY: "synthetic" } : { OPENAI_API_KEY: "synthetic", DAYTONA_API_KEY: "synthetic" } })).rejects.toThrow();
|
||||
expect(api.post).not.toHaveBeenCalled();
|
||||
expect(api.postSensitive).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
function fileEvidence() {
|
||||
const e = recording("task-card-accept");
|
||||
const last = e.checkpoints.at(-1)!;
|
||||
last.tasks = [{ id: "child", parentId: e.onboardingIssueId, status: "done" }];
|
||||
const body = `Welcome beginners. Join our free Saturday meetup ${firstTaskScenario(e.caseId, e.nonce).marker}.`;
|
||||
last.attachments = [{ id: "file", issueId: "child", originalFilename: "garden-welcome.txt", contentType: "text/plain", body,
|
||||
contentVerified: true, contentSha256: digestText(body) }];
|
||||
return e;
|
||||
}
|
||||
it("requires the downloaded verified physical artifact", () => {
|
||||
expect(managedFirstTaskFileCheck(fileEvidence()).passed).toBe(true);
|
||||
});
|
||||
it.each(["document-only", "unverified", "wrong-hash", "wrong-child", "wrong-name", "wrong-marker"])("rejects %s as physical-file proof", async mode => {
|
||||
const e = fileEvidence();
|
||||
const last = e.checkpoints.at(-1)!;
|
||||
const file = last.attachments![0]!;
|
||||
if (mode === "document-only") { last.documents = [{ ...file, key: "welcome" }]; last.attachments = []; }
|
||||
if (mode === "unverified") file.contentVerified = false;
|
||||
if (mode === "wrong-hash") file.contentSha256 = "0".repeat(64);
|
||||
if (mode === "wrong-child") file.issueId = "foreign";
|
||||
if (mode === "wrong-name") file.originalFilename = "plan.txt";
|
||||
if (mode === "wrong-marker") { file.body = "A different welcome note"; file.contentSha256 = digestText(file.body); }
|
||||
expect(managedFirstTaskFileCheck(e).passed).toBe(false);
|
||||
});
|
||||
|
||||
function setupHarness(mode = "success") {
|
||||
const listeners = new Map<string, Set<(event: any) => void>>();
|
||||
const page = { on: (name: string, callback: (event: any) => void) => { const set = listeners.get(name) ?? new Set(); set.add(callback); listeners.set(name, set); },
|
||||
off: (name: string, callback: (event: any) => void) => listeners.get(name)?.delete(callback) };
|
||||
const fixtures = { company: { id: "company", name: "Fresh" }, environment: { id: "environment", driver: "sandbox" },
|
||||
agent: { id: "", companyId: "company", name: "Ada" }, secretRefs: {}, setupLeases: [],
|
||||
setupQualification: { recorded: false, adapterType: null, platformResponses: 0, adapterResponses: 0 }, teardown: async () => {} } as import("./live-fixtures.js").LiveFixtureValues;
|
||||
const rows = ["platform", "adapter"].map(id => ({ id, companyId: "company", environmentId: "environment", provider: "daytona", heartbeatRunId: null, issueId: null,
|
||||
status: mode === "cleanup" ? "failed" : "released", cleanupStatus: "success" }));
|
||||
if (mode === "foreign") rows[0]!.companyId = "another-company";
|
||||
let begun = false;
|
||||
const api = { get: vi.fn(async (path: string) => path.includes("environment-leases/") ? rows.find(row => path.endsWith(`/${row.id}`))
|
||||
: begun ? [...rows, { ...rows[0], id: "unrelated", issueId: "other-task" }] : []) } as unknown as RunnerApi;
|
||||
const start = (kind: "platform" | "discovery" | "adapter", runner?: string) => {
|
||||
begun = true;
|
||||
const request = { url: () => `http://fixture.test${kind === "platform" ? "/api/environments/environment/probe?companyId=company"
|
||||
: kind === "discovery" ? "/api/adapters?companyId=company&environmentId=environment" : "/api/companies/company/adapters/codex_local/test-environment"}`,
|
||||
method: () => kind === "discovery" ? "GET" : "POST", postDataJSON: () => runner ? { runner } : {} };
|
||||
for (const listener of listeners.get("request") ?? []) listener(request);
|
||||
return request;
|
||||
};
|
||||
const respond = (request: ReturnType<typeof start>, body: any) => {
|
||||
const response = { request: () => request, json: async () => body, ok: () => true };
|
||||
for (const listener of listeners.get("response") ?? []) listener(response);
|
||||
};
|
||||
const emit = (kind: "platform" | "discovery" | "adapter", body: any, runner?: string) => respond(start(kind, runner), body);
|
||||
const fail = (request: ReturnType<typeof start>) => {
|
||||
for (const listener of listeners.get("requestfailed") ?? []) listener(request);
|
||||
};
|
||||
return { fixtures, page: page as unknown as import("@playwright/test").Page, api, emit, start, respond, fail, listeners, rows };
|
||||
}
|
||||
|
||||
it.each(["missing-response", "request-failed"])("retains unknown adapter setup cost after %s without an attributable row", async mode => {
|
||||
const h = setupHarness();
|
||||
h.rows.splice(1);
|
||||
const stop = await observeManagedFirstTaskSetup(h.page, h.api, h.fixtures, Date.now() + 1000);
|
||||
h.emit("platform", { ok: true, details: { platformQualificationRecorded: true, leaseId: "platform" } });
|
||||
const request = h.start("adapter");
|
||||
if (mode === "request-failed") h.fail(request);
|
||||
await stop(false);
|
||||
expect(h.fixtures.setupLeases!.map(row => row.leaseId)).toEqual(["platform"]);
|
||||
expect(h.fixtures.setupLeaseAccountingIncomplete).toBe(true);
|
||||
expect(h.fixtures.setupQualification!.adapterResponses).toBe(0);
|
||||
const leases = selectFixtureRuntimeLeases({ leases: h.rows.map(row => ({ ...row,
|
||||
acquiredAt: "2026-10-13T00:00:00Z", releasedAt: "2026-10-13T00:00:02Z", metadata: { cpu: 4, memory: 4, disk: 10 } })),
|
||||
runIds: [], setupLeaseIds: h.fixtures.setupLeases!.map(row => row.leaseId) });
|
||||
const usage = fixtureRuntimeCostCoverage(buildRuntimeUsage({ environmentId: "daytona", runs: [], leases }), h.fixtures);
|
||||
expect(usage.leaseCount).toBe(1);
|
||||
expect(usage.costStatus).toBe("unavailable");
|
||||
expect(usage).not.toHaveProperty("estimatedListCostUsd");
|
||||
expect([...h.listeners.values()].every(listeners => listeners.size === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("retains unknown failed platform setup without a response or visible lease row", async () => {
|
||||
const h = setupHarness();
|
||||
h.rows.splice(0);
|
||||
const stop = await observeManagedFirstTaskSetup(h.page, h.api, h.fixtures, Date.now() + 1000);
|
||||
h.fail(h.start("platform"));
|
||||
await stop(false);
|
||||
expect(h.fixtures.setupLeases).toEqual([]);
|
||||
expect(h.fixtures.setupLeaseAccountingIncomplete).toBe(true);
|
||||
expect(h.fixtures.setupQualification!.recorded).toBe(false);
|
||||
});
|
||||
|
||||
it("does not clear failed-request accounting uncertainty when later responses correlate", async () => {
|
||||
const h = setupHarness();
|
||||
const stop = await observeManagedFirstTaskSetup(h.page, h.api, h.fixtures, Date.now() + 1000);
|
||||
h.emit("platform", { ok: true, details: { platformQualificationRecorded: true, leaseId: "platform" } });
|
||||
const request = h.start("adapter");
|
||||
h.fail(request);
|
||||
h.respond(request, { adapterType: "paperclip_runner", status: "pass", checks: [{ code: "sandbox_test_identity", detail: "paperclipLeaseId=adapter" }] });
|
||||
await stop(false);
|
||||
expect(h.fixtures.setupLeases!.map(row => row.leaseId)).toEqual(["platform", "adapter"]);
|
||||
expect(h.fixtures.setupLeaseAccountingIncomplete).toBe(true);
|
||||
});
|
||||
|
||||
it.each(["success", "missing-discovery", "explicit-runner", "duplicate", "foreign", "cleanup"])("verifies exact UI setup responses and owned cleanup for %s", async mode => {
|
||||
const h = setupHarness(mode);
|
||||
const stop = await observeManagedFirstTaskSetup(h.page, h.api, h.fixtures, mode === "cleanup" ? Date.now() - 1 : Date.now() + 1000);
|
||||
const platform = { ok: true, details: { platformQualificationRecorded: true, leaseId: "platform" } };
|
||||
h.emit("platform", platform);
|
||||
if (mode === "duplicate") h.emit("platform", platform);
|
||||
if (mode !== "missing-discovery") h.emit("discovery", [{ type: "codex_local", defaultRunner: "paperclip", supportedRunners: ["paperclip", "legacy"] }]);
|
||||
h.emit("adapter", { adapterType: "paperclip_runner", status: "pass", checks: [{ code: "sandbox_test_identity", detail: "paperclipLeaseId=adapter; providerLeaseId=private-provider" }] }, mode === "explicit-runner" ? "paperclip" : undefined);
|
||||
if (mode === "success") { await stop(true); expect(h.fixtures.setupLeases!.map(row => row.leaseId)).toEqual(["platform", "adapter"]); }
|
||||
else await expect(stop(true)).rejects.toThrow();
|
||||
expect(h.fixtures.setupLeases!.some(row => row.leaseId === "unrelated")).toBe(false);
|
||||
expect([...h.listeners.values()].every(listeners => listeners.size === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("reports a rejected platform setup immediately without waiting for the long browser deadline", async () => {
|
||||
const h = setupHarness();
|
||||
const stop = await observeManagedFirstTaskSetup(h.page, h.api, h.fixtures, Date.now() + 1000);
|
||||
const rejected = expect(stop.failure).rejects.toThrow("UI environment qualification did not pass");
|
||||
h.emit("platform", { ok: false, details: { leaseId: "platform", platformQualificationRecorded: false } });
|
||||
await rejected;
|
||||
await stop(false);
|
||||
expect(h.fixtures.setupLeases!.map(row => row.leaseId)).toEqual(["platform"]);
|
||||
});
|
||||
|
||||
it("keeps an uncorrelated lease out of setup billing and retains unavailable cost coverage", async () => {
|
||||
const h = setupHarness();
|
||||
const stop = await observeManagedFirstTaskSetup(h.page, h.api, h.fixtures, Date.now() + 1000);
|
||||
h.emit("platform", { ok: false, details: { leaseId: "platform", platformQualificationRecorded: false } });
|
||||
await stop(false);
|
||||
expect(h.fixtures.setupLeases!.map(row => row.leaseId)).toEqual(["platform"]);
|
||||
expect(h.fixtures.setupLeaseAccountingIncomplete).toBe(true);
|
||||
expect(h.fixtures.setupQualification!.recorded).toBe(false);
|
||||
});
|
||||
|
||||
it("retains exact failed setup leases for accounting without granting qualification", async () => {
|
||||
const h = setupHarness();
|
||||
const stop = await observeManagedFirstTaskSetup(h.page, h.api, h.fixtures, Date.now() + 1000);
|
||||
h.emit("platform", { ok: true, details: { leaseId: "platform", platformQualificationRecorded: true } });
|
||||
h.emit("adapter", { adapterType: "paperclip_runner", status: "fail", checks: [{ code: "sandbox_test_identity", detail: "paperclipLeaseId=adapter; providerLeaseId=private-provider" }] });
|
||||
await stop(false);
|
||||
expect(h.fixtures.setupLeases!.map(row => row.leaseId)).toEqual(["platform", "adapter"]);
|
||||
expect(h.fixtures.setupQualification!.adapterType).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps a missing response lease ID unknown even without visible lease history", async () => {
|
||||
const h = setupHarness();
|
||||
h.rows.splice(0);
|
||||
const stop = await observeManagedFirstTaskSetup(h.page, h.api, h.fixtures, Date.now() + 1000);
|
||||
h.emit("platform", { ok: false, details: { platformQualificationRecorded: false } });
|
||||
await stop(false);
|
||||
expect(h.fixtures.setupLeases).toEqual([]);
|
||||
expect(h.fixtures.setupLeaseAccountingIncomplete).toBe(true);
|
||||
expect(h.fixtures.setupQualification!.recorded).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import type { RunnerApi } from "./api.js";
|
||||
import { buildRuntimeUsage } from "./billing.js";
|
||||
import { runnerMatrix } from "./catalog.js";
|
||||
import { setupLiveFixtures, selectFixtureRuntimeLeases } from "./live-fixtures.js";
|
||||
import { setupLiveFixtures, selectFixtureRuntimeLeases, fixtureRuntimeCostCoverage } from "./live-fixtures.js";
|
||||
|
||||
describe("live runner fixtures", () => {
|
||||
it.each(["runner-codex", "runner-acpx-claude", "runner-opencode"].flatMap(profile =>
|
||||
@@ -398,3 +398,31 @@ describe("live runner fixtures", () => {
|
||||
await fixtures.teardown();
|
||||
});
|
||||
});
|
||||
|
||||
describe("owned browser setup lease accounting", () => {
|
||||
it("includes both exact setup IDs and task runs while excluding unrelated leases", () => {
|
||||
const leases = [{ id: "platform", provider: "daytona" }, { id: "adapter", provider: "daytona" },
|
||||
{ id: "task", provider: "daytona", heartbeatRunId: "run" }, { id: "unrelated", provider: "daytona" }];
|
||||
expect(selectFixtureRuntimeLeases({ leases, runIds: ["run"], setupLeaseIds: ["platform", "adapter"] }).map(lease => lease.id)).toEqual(["platform", "adapter", "task"]);
|
||||
expect(selectFixtureRuntimeLeases({ leases, runIds: [], setupLeaseIds: ["platform"] }).map(lease => lease.id)).toEqual(["platform"]);
|
||||
expect(selectFixtureRuntimeLeases({ leases, runIds: [], setupLeaseIds: [] })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("managed setup runtime cost coverage", () => {
|
||||
it("prices both exact setup windows with task usage and keeps missing attribution unknown", () => {
|
||||
const leases = ["platform", "adapter", "task", "unrelated"].map((id, index) => ({ id, provider: "daytona", heartbeatRunId: id === "task" ? "run" : null,
|
||||
acquiredAt: "2026-10-13T00:00:00Z", releasedAt: `2026-10-13T00:00:0${index + 1}Z`, metadata: { cpu: 4, memory: 4, disk: 10 } }));
|
||||
const selected = selectFixtureRuntimeLeases({ leases, runIds: ["run"], setupLeaseIds: ["platform", "adapter"] });
|
||||
const usage = buildRuntimeUsage({ environmentId: "daytona", runs: [], leases: selected });
|
||||
expect(usage.leaseCount).toBe(3);
|
||||
expect(usage.leaseDurationMs).toBe(6000);
|
||||
expect(usage.estimatedListCostUsd).toBeGreaterThan(0);
|
||||
expect(fixtureRuntimeCostCoverage(usage, {})).toEqual(usage);
|
||||
const incomplete = fixtureRuntimeCostCoverage(usage, { setupLeaseAccountingIncomplete: true });
|
||||
expect(incomplete.costStatus).toBe("unavailable");
|
||||
expect(incomplete.costSource).toBe("provider_cost_unavailable");
|
||||
expect(incomplete).not.toHaveProperty("estimatedListCostUsd");
|
||||
expect(incomplete.leaseCount).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
MatrixExecution,
|
||||
SecretReference,
|
||||
SecretReferenceMap,
|
||||
RunnerE2ERuntimeUsage,
|
||||
} from "./types.js";
|
||||
|
||||
interface CompanyRecord {
|
||||
@@ -65,6 +66,9 @@ export interface LiveFixtureValues {
|
||||
project?: ProjectRecord;
|
||||
aiConnection?: ManagedAccountFixture;
|
||||
setupProbe?: { leaseId: string; durationMs: number; defaultRunner?: string };
|
||||
setupLeases?: Array<{ leaseId: string; phase: "platform" | "adapter"; durationMs: number }>;
|
||||
setupLeaseAccountingIncomplete?: boolean;
|
||||
setupQualification?: { recorded: boolean; adapterType: string | null; platformResponses: number; adapterResponses: number };
|
||||
|
||||
onboardingRuntime?: {
|
||||
mode: "production-wizard" | "post-onboarding-runtime-switch";
|
||||
@@ -83,17 +87,24 @@ function value<T>(resolved: ReadonlyMap<string, unknown>, id: string): T {
|
||||
|
||||
export function selectFixtureRuntimeLeases<T extends {
|
||||
id: string; provider?: string | null; heartbeatRunId?: string | null; issueId?: string | null;
|
||||
}>(input: { leases: T[]; runIds: string[]; issueId?: string; setupProbeLeaseId?: string }): T[] {
|
||||
}>(input: { leases: T[]; runIds: string[]; issueId?: string; setupProbeLeaseId?: string; setupLeaseIds?: readonly string[] }): T[] {
|
||||
const selectedRunIds = new Set(input.runIds);
|
||||
const setupIds = new Set([...(input.setupLeaseIds ?? []), ...(input.setupProbeLeaseId ? [input.setupProbeLeaseId] : [])]);
|
||||
const relevant = input.leases.filter(lease => lease.provider === "daytona" && (
|
||||
lease.id === input.setupProbeLeaseId || selectedRunIds.size === 0
|
||||
setupIds.has(lease.id) || (input.setupLeaseIds === undefined && selectedRunIds.size === 0)
|
||||
|| (lease.heartbeatRunId && selectedRunIds.has(lease.heartbeatRunId))
|
||||
|| (input.issueId && lease.issueId === input.issueId)
|
||||
));
|
||||
return relevant.length > 0 ? relevant : input.leases;
|
||||
return input.setupLeaseIds !== undefined || relevant.length > 0 ? relevant : input.leases;
|
||||
}
|
||||
|
||||
async function deleteDaytonaEnvironment(api: RunnerApi, environmentId: string) {
|
||||
export function fixtureRuntimeCostCoverage(usage: RunnerE2ERuntimeUsage, fixtures: Pick<LiveFixtureValues, "setupLeaseAccountingIncomplete">): RunnerE2ERuntimeUsage {
|
||||
if (!fixtures.setupLeaseAccountingIncomplete) return usage;
|
||||
const { estimatedListCostUsd: _estimate, ...known } = usage;
|
||||
return { ...known, costStatus: "unavailable", costSource: "provider_cost_unavailable" };
|
||||
}
|
||||
|
||||
export async function deleteDaytonaEnvironment(api: Pick<RunnerApi, "delete">, environmentId: string) {
|
||||
const deadlineAt = Date.now() + 120_000;
|
||||
let lastError: unknown;
|
||||
while (Date.now() < deadlineAt) {
|
||||
@@ -115,6 +126,14 @@ async function deleteDaytonaEnvironment(api: RunnerApi, environmentId: string) {
|
||||
);
|
||||
}
|
||||
|
||||
export async function installFixtureDaytonaProvider(api: Pick<RunnerApi, "post">, execution: MatrixExecution) {
|
||||
const installed = await verifyInstalledDaytonaPlugin(process.env, [execution]);
|
||||
return api.post<PluginRecord>("/api/plugins/install", {
|
||||
packageName: installed?.packageRoot ?? path.resolve(import.meta.dirname, "../../packages/plugins/sandbox-providers/daytona"),
|
||||
isLocalPath: true,
|
||||
}, { timeout: 120_000 });
|
||||
}
|
||||
|
||||
export async function setupLiveFixtures(input: {
|
||||
api: RunnerApi;
|
||||
execution: MatrixExecution;
|
||||
@@ -131,14 +150,7 @@ export async function setupLiveFixtures(input: {
|
||||
registry.register<PluginRecord>({
|
||||
id: "sandbox-provider",
|
||||
async setup() {
|
||||
const installed = await verifyInstalledDaytonaPlugin(process.env, [execution]);
|
||||
return api.post<PluginRecord>("/api/plugins/install", {
|
||||
packageName: installed?.packageRoot ?? path.resolve(
|
||||
import.meta.dirname,
|
||||
"../../packages/plugins/sandbox-providers/daytona",
|
||||
),
|
||||
isLocalPath: true,
|
||||
}, { timeout: 120_000 });
|
||||
return installFixtureDaytonaProvider(api, execution);
|
||||
},
|
||||
async teardown() {
|
||||
// The plugin is installed only in the isolated instance/database. The
|
||||
|
||||
@@ -180,7 +180,7 @@ describe("Pi controls catalog admission", () => {
|
||||
expect(cells).toHaveLength(4); expect(cells.every(c => c.profile.qualificationCandidate === "pi" && c.task.expectedRunCount === 1)).toBe(true);
|
||||
expect(cells.map(c => `${c.environment.id}/${c.task.id}`).sort()).toEqual(["daytona/pending-permission-stop", "daytona/same-turn-steering", "local/pending-permission-stop", "local/same-turn-steering"]);
|
||||
expect(selectRunnerExecutions(parseRunnerSelectors(["--all"])).some(c => c.suite.id === "pi-controls")).toBe(false);
|
||||
expect(validateRunnerCatalog()).toHaveLength(729);
|
||||
expect(validateRunnerCatalog()).toHaveLength(730);
|
||||
for (const cell of cells) expect(buildRunnerE2EProcessEnvironment({}, [cell]).PAPERCLIP_RUNNER_ACPX_QUALIFICATION).toBeUndefined();
|
||||
expect(() => assertRemoteNativeEvidencePrerequisites(cells, {})).toThrow();
|
||||
});
|
||||
|
||||
@@ -52,7 +52,7 @@ import { runnerExecutionById } from "./catalog.js";
|
||||
import { classifyFailure } from "./failure-classifier.js";
|
||||
import { runnerE2EServerControlPaths } from "./harness-env.js";
|
||||
import { setupConnectionReview } from "./connection-reviews.js";
|
||||
import { setupLiveFixtures, selectFixtureRuntimeLeases, type LiveFixtureValues } from "./live-fixtures.js";
|
||||
import { setupLiveFixtures, selectFixtureRuntimeLeases, fixtureRuntimeCostCoverage, type LiveFixtureValues } from "./live-fixtures.js";
|
||||
import { evaluateMatcher, persistedFinalRunMessage, type MatcherResult } from "./matchers.js";
|
||||
import { readRegisteredArtifacts } from "./registered-artifact.js";
|
||||
import {
|
||||
@@ -701,7 +701,7 @@ for (const execution of executions) {
|
||||
);
|
||||
runtimeLeases = selectFixtureRuntimeLeases({ leases: listed,
|
||||
runIds: selectedRuns.map(run => run.id), issueId: issue?.id,
|
||||
setupProbeLeaseId: fixtures.setupProbe?.leaseId });
|
||||
setupProbeLeaseId: fixtures.setupProbe?.leaseId, setupLeaseIds: fixtures.setupLeases?.map(lease => lease.leaseId) });
|
||||
};
|
||||
|
||||
const cancelActiveRunsForCleanup = async () => {
|
||||
@@ -868,7 +868,6 @@ for (const execution of executions) {
|
||||
const receipt = verifyStockHarnessPreflight(process.env[STOCK_PREFLIGHT_ENV]);
|
||||
await writeSanitizedJson(snapshotsDir, "stock-harness-preflight.json", receipt, secrets);
|
||||
}
|
||||
// Codex default journeys must work with the experimental gate disabled.
|
||||
const nativeRolloutEnabled = execution.profile.provider !== "codex";
|
||||
const experimental = await api.patch<{
|
||||
enableNativeRunner: boolean;
|
||||
@@ -893,7 +892,7 @@ for (const execution of executions) {
|
||||
await setupGitStreamingWorkspace(workspacePath);
|
||||
}
|
||||
fixtures = execution.task.flow === "first_task"
|
||||
? await setupFirstTaskFixtures({ page, api, execution, nonce, credentials, observe: value => { fixtures = value; } })
|
||||
? await setupFirstTaskFixtures({ page, api, execution, nonce, credentials, daytonaImage: process.env.PAPERCLIP_E2E_DAYTONA_IMAGE, deadlineAt: startedAtMs + deadlineMs, observe: value => { fixtures = value; } })
|
||||
: await setupLiveFixtures({
|
||||
api,
|
||||
execution,
|
||||
@@ -1161,7 +1160,7 @@ for (const execution of executions) {
|
||||
if (matcherResults.length === 0) matcherResults = [{ matcher: { kind: "issue_status", expected: "in_review" }, passed: true, detail: "Chat workflow and durable handoff/session assertions passed" }];
|
||||
} else if (execution.task.flow === "first_task") {
|
||||
const firstTask = await runFirstTaskFlow({
|
||||
page, api, fixtures, execution, nonce, secrets,
|
||||
page, api, fixtures, execution, nonce, deadlineAt: startedAtMs + deadlineMs, secrets,
|
||||
observe: (currentIssue, runs, evidence) => {
|
||||
issue = currentIssue; selectedRuns = runs; firstTaskEvidence = evidence;
|
||||
matcherResults = evidence.checks.map(check => ({ matcher: { kind: "json_path" as const, path: `firstTask.checks.${check.id}`, expected: true }, passed: check.passed, detail: check.detail }));
|
||||
@@ -3104,12 +3103,12 @@ for (const execution of executions) {
|
||||
}
|
||||
|
||||
const finishedAtMs = Date.now();
|
||||
const runtimeUsage = buildRuntimeUsage({
|
||||
const runtimeUsage = fixtureRuntimeCostCoverage(buildRuntimeUsage({
|
||||
environmentId: execution.environment.id,
|
||||
runs: selectedRuns,
|
||||
leases: runtimeLeases,
|
||||
fallbackFinishedAt: new Date(finishedAtMs),
|
||||
});
|
||||
}), fixtures ?? {});
|
||||
const resultWithoutBilling: RunnerE2EResult = {
|
||||
schema: "paperclip.runner-e2e.result/v2",
|
||||
executionId: execution.id,
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
.instance.json
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
.auth-instance.json
|
||||
test-results-auth/
|
||||
playwright-report-auth/
|
||||
qualification/
|
||||
@@ -0,0 +1,164 @@
|
||||
/** 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());
|
||||
@@ -0,0 +1,66 @@
|
||||
import { test, expect, type APIRequestContext } from "@playwright/test";
|
||||
import { randomBytes, randomUUID } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
const origin = "http://127.0.0.1:3482";
|
||||
|
||||
test("E03/E14: real signed-in callers cannot substitute companies, private tasks or another caller; revoked members can hang up", async ({ browser, page }, info) => {
|
||||
const memberContext = await browser.newContext(), outsiderContext = await browser.newContext(), anonymousContext = await browser.newContext();
|
||||
const request = async (api: APIRequestContext, method: "POST" | "PATCH", path: string, data: unknown) => {
|
||||
const response = await api.fetch(`${origin}/api${path}`, { method, headers: { Origin: origin }, data });
|
||||
expect(response.ok(), `${method} ${path}: ${response.status()} ${await response.text()}`).toBe(true);
|
||||
return response.json();
|
||||
};
|
||||
const signUp = (api: APIRequestContext, name: string) => request(api, "POST", "/auth/sign-up/email", { name, email: `${randomUUID()}@example.test`, password: randomBytes(24).toString("base64url") });
|
||||
try {
|
||||
await signUp(page.request, "Voice operator");
|
||||
await request(page.request, "POST", "/bootstrap/claim", {});
|
||||
const member = await signUp(memberContext.request, "Voice member");
|
||||
await signUp(outsiderContext.request, "Other company member");
|
||||
const company = await request(page.request, "POST", "/companies", { name: "Private voice company" });
|
||||
const other = await request(page.request, "POST", "/companies", { name: "Other voice company" });
|
||||
const invite = async (api: APIRequestContext, companyId: string) => {
|
||||
const invitation = await request(page.request, "POST", `/companies/${companyId}/invites`, { allowedJoinTypes: "human", humanRole: "operator" });
|
||||
const token = new URL(invitation.inviteUrl, origin).pathname.split("/").at(-1);
|
||||
await request(api, "POST", `/invites/${token}/accept`, { requestType: "human" });
|
||||
};
|
||||
await invite(memberContext.request, company.id); await invite(outsiderContext.request, other.id);
|
||||
await request(page.request, "PATCH", `/companies/${company.id}`, { requireBoardApprovalForNewAgents: false });
|
||||
const agent = await request(page.request, "POST", `/companies/${company.id}/agents`, { name: "Voice worker", role: "general", adapterType: "process", adapterConfig: { command: "fixture" }, runtimeConfig: { heartbeat: { enabled: false, wakeOnDemand: true } } });
|
||||
await expect.poll(async () => (await (await page.request.get(`${origin}/api/agents/${agent.id}`)).json()).status).toBe("idle");
|
||||
const endpoint = await request(memberContext.request, "POST", `/companies/${company.id}/chat-endpoints`, { provider: "speko", assignedAgentId: agent.id });
|
||||
const configured = await request(memberContext.request, "POST", `/chat-endpoints/${endpoint.id}/setup`, { action: "configure", credentials: { apiKey: "fixture-speko-key", agentId: "agent_member", signingSecret: `whsec_${randomBytes(32).toString("base64")}` } });
|
||||
expect(configured.setup.step).toBe("test");
|
||||
const privateTask = await request(page.request, "POST", `/companies/${company.id}/issues`, { title: "Private task", status: "backlog", assigneeAgentId: agent.id, visibility: "private" });
|
||||
const start = (api: APIRequestContext, companyId = company.id, issueId?: string) => api.post(`${origin}/api/companies/${companyId}/voice-sessions`, { headers: { Origin: origin }, data: { endpointId: endpoint.id, ...(issueId ? { issueId } : {}), idempotencyKey: randomUUID() } });
|
||||
expect([401, 403]).toContain((await start(anonymousContext.request)).status());
|
||||
expect((await start(outsiderContext.request)).status()).toBe(403);
|
||||
expect([403, 404]).toContain((await start(memberContext.request, other.id)).status());
|
||||
expect([403, 404]).toContain((await start(memberContext.request, company.id, privateTask.id)).status());
|
||||
const created = await start(memberContext.request);
|
||||
expect(created.status()).toBe(201);
|
||||
const { session, media } = await created.json();
|
||||
expect(session.callerId).toBeUndefined(); // Private authority metadata is not projected into the browser session.
|
||||
expect(media.transportToken).toMatch(/^fixture:/);
|
||||
const path = `/api/companies/${company.id}/voice-sessions/${session.id}`;
|
||||
expect((await page.request.get(origin + path)).status()).toBe(403);
|
||||
expect((await outsiderContext.request.get(origin + path)).status()).toBe(403);
|
||||
expect((await memberContext.request.get(origin + path)).status()).toBe(200);
|
||||
const { members } = await (await page.request.get(`${origin}/api/companies/${company.id}/members`)).json();
|
||||
const membership = members.find((entry: any) => entry.principalId === member.user.id);
|
||||
expect(membership).toBeTruthy();
|
||||
await request(page.request, "PATCH", `/companies/${company.id}/members/${membership.id}`, { status: "suspended" });
|
||||
expect((await memberContext.request.get(origin + path + "/notification")).status()).toBe(403);
|
||||
expect((await memberContext.request.get(origin + path)).status()).toBe(403);
|
||||
const ended = await request(memberContext.request, "POST", path.replace(/^\/api/, "") + "/end", {});
|
||||
expect(ended.state).toBe("ended");
|
||||
const { home } = JSON.parse(await readFile(resolve(import.meta.dirname, ".auth-instance.json"), "utf8"));
|
||||
await expect.poll(async () => {
|
||||
const evidence = JSON.parse(await readFile(resolve(home, "evidence.json"), "utf8"));
|
||||
return { sessions: evidence.sessions.length, ended: evidence.sessions[0]?.state, accepted: evidence.tools.length };
|
||||
}).toEqual({ sessions: 1, ended: "ended", accepted: 0 });
|
||||
await info.attach("authority-results", { body: JSON.stringify({ companyId: company.id, otherCompanyId: other.id, sessionId: session.id, privateTaskId: privateTask.id, denied: ["anonymous", "cross-company", "private-task", "other-caller", "suspended-member"], revokedCallerHangup: "passed" }), contentType: "application/json" });
|
||||
} finally {
|
||||
await Promise.all([memberContext.close(), outsiderContext.close(), anonymousContext.close()]);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Explicit live-acceptance workload: compute for 65 seconds, then read the fixture.
|
||||
|
||||
Copy into the disposable agent workspace. Uses one CPU core and no network.
|
||||
This runs inside the real selected agent; it does not replace the execution adapter.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import time
|
||||
|
||||
started = time.monotonic()
|
||||
iterations = 0
|
||||
value = b"paperclip-speko-acceptance"
|
||||
while time.monotonic() - started < 65:
|
||||
for _ in range(1024):
|
||||
value = hashlib.sha256(value).digest()
|
||||
iterations += 1024
|
||||
print(json.dumps({
|
||||
"elapsedSeconds": time.monotonic() - started,
|
||||
"iterations": iterations,
|
||||
"digest": value.hex(),
|
||||
"fixture": Path("ACCEPTANCE.txt").read_text().strip(),
|
||||
}), flush=True)
|
||||
@@ -0,0 +1,364 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
const root = resolve(import.meta.dirname, "../..");
|
||||
const port = Number(process.env.SPEKO_NATIVE_TEST_PORT ?? 3480);
|
||||
const origin = `http://127.0.0.1:${port}`;
|
||||
|
||||
test("E01/E03–E11/E13/E15/E16: setup, delayed work, duplicate input, navigation, repeat, reload and hangup use real task ledgers", async ({ page }, info) => {
|
||||
const { home, supervisorPid } = JSON.parse(await readFile(resolve(import.meta.dirname, ".instance.json"), "utf8"));
|
||||
const evidence = async () => JSON.parse(await readFile(resolve(home, "evidence.json"), "utf8"));
|
||||
const post = async (path: string, data: unknown) => {
|
||||
const response = await page.request.post(`${origin}/api${path}`, { headers: { Origin: origin }, data });
|
||||
expect(response.ok(), `${path}: ${response.status()} ${await response.text()}`).toBe(true);
|
||||
return response.json();
|
||||
};
|
||||
const company = await post("/companies", { name: "Speko integrated", issuePrefix: "SPE" });
|
||||
const other = await post("/companies", { name: "Separate company", issuePrefix: "OTH" });
|
||||
const policy = await page.request.patch(`${origin}/api/companies/${company.id}`, { headers: { Origin: origin }, data: { requireBoardApprovalForNewAgents: false } });
|
||||
expect(policy.ok()).toBe(true);
|
||||
const agent = await post(`/companies/${company.id}/agents`, { name: "Existing Paperclip Agent", role: "general", adapterType: "process", adapterConfig: { command: "fixture" }, runtimeConfig: { heartbeat: { enabled: false, wakeOnDemand: true } } });
|
||||
// Discover the built SDK chunk by its export, rather than mock any Paperclip API.
|
||||
const assets = resolve(root, "ui/dist/assets");
|
||||
const candidates = await Promise.all((await readdir(assets)).filter(name => name.endsWith(".js")).map(async name => ({ name, source: await readFile(resolve(assets, name), "utf8") })));
|
||||
const sdk = candidates.filter(file => /export\{[^}]* as VoiceConversation\}/.test(file.source));
|
||||
expect(sdk).toHaveLength(1);
|
||||
const sdkSource = (await readFile(resolve(import.meta.dirname, "stub-browser-sdk.mjs"), "utf8")).replaceAll("__STUB_PORT__", String(port + 1));
|
||||
await page.route(`**/assets/${sdk[0].name}`, route => route.fulfill({ contentType: "application/javascript", body: sdkSource }));
|
||||
const external: string[] = [];
|
||||
await page.route(/^https?:\/\//, route => {
|
||||
const url = new URL(route.request().url());
|
||||
return ["127.0.0.1", "localhost"].includes(url.hostname) ? route.fallback() : route.abort();
|
||||
});
|
||||
page.on("request", request => { const url = new URL(request.url()); if (["http:", "https:", "wss:"].includes(url.protocol) && !["127.0.0.1", "localhost"].includes(url.hostname)) external.push(url.hostname); });
|
||||
await page.addInitScript(() => { navigator.mediaDevices.getUserMedia = async () => { throw new Error("Integrated fixture must not request a real microphone"); }; });
|
||||
await page.goto(`/${company.issuePrefix}/apps/chat/connect?provider=speko`);
|
||||
await page.getByRole("button", { name: "Choose an active agent", exact: true }).click();
|
||||
await page.getByRole("button", { name: `Select ${agent.name}`, exact: true }).click();
|
||||
await page.getByRole("button", { name: "Continue", exact: true }).click();
|
||||
await expect(page.getByLabel("Speko API key", { exact: true })).toHaveAttribute("type", "password");
|
||||
await page.getByRole("textbox", { name: "Speko agent ID", exact: true }).fill("agent_fixture");
|
||||
await page.getByLabel("Speko API key", { exact: true }).fill("fixture-speko-key");
|
||||
const setupResponse = page.waitForResponse(r => r.request().method() === "POST" && /\/chat-endpoints\/[^/]+\/setup$/.test(new URL(r.url()).pathname));
|
||||
await page.getByRole("button", { name: "Connect Speko", exact: true }).click();
|
||||
const endpoint = await (await setupResponse).json(); expect(endpoint.id).toBeTruthy();
|
||||
expect(JSON.stringify(endpoint)).not.toContain("fixture-speko-key");
|
||||
await page.reload();
|
||||
await expect(page.getByRole("button", { name: "Start voice", exact: true })).toBeVisible();
|
||||
const nextSession = () => page.waitForResponse(r => r.request().method() === "POST" && /\/voice-sessions$/.test(new URL(r.url()).pathname) && r.ok()).then(r => r.json());
|
||||
const pending = nextSession(); await page.getByRole("button", { name: "Start voice", exact: true }).click(); const started = await pending;
|
||||
expect(started.session.assignedAgentId).toBe(agent.id); expect(started.media.transportToken).toMatch(/^fixture:/);
|
||||
expect(JSON.stringify(started)).not.toContain("fixture-speko-key");
|
||||
await expect(page.getByRole("button", { name: "Mute", exact: true })).toBeEnabled();
|
||||
const speak = (text: string, id: string) => page.evaluate(({ text, id }) => (window as any).__spekoTransport.speak(text, id), { text, id });
|
||||
const accepted = await speak("Run the acceptance benchmark and report its verification number.", "request-one");
|
||||
expect(accepted.status).toBe("accepted");
|
||||
expect(await speak("Run the acceptance benchmark and report its verification number.", "request-one")).toEqual(accepted);
|
||||
await speak("Also count this follow-up on the same task.", "request-two");
|
||||
// A client-side route transition must preserve the host and the original media.
|
||||
await page.getByRole("link", { name: "View conversation task", exact: true }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/issues/${started.session.issueId}$`));
|
||||
await expect(page.getByRole("button", { name: "Mute", exact: true })).toBeEnabled();
|
||||
expect(await page.evaluate(() => (window as any).__spekoTransport.creates)).toBe(1);
|
||||
await expect.poll(async () => (await evidence()).tools.filter((tool: any) => tool.sessionId === started.session.id && tool.tool === "submit_request").length).toBe(2);
|
||||
const transcript = page.getByRole("list", { name: "Conversation transcript", exact: true });
|
||||
await expect(transcript).toContainText("Verification number", { timeout: 100_000 });
|
||||
await expect.poll(async () => {
|
||||
const rows = (await readFile(resolve(home, "execution.jsonl"), "utf8")).trim().split("\n").map(line => JSON.parse(line));
|
||||
return rows.some(row => row.issueId === started.session.issueId && row.event === "completed" && row.elapsedMs >= 60_000 && row.requests === 2);
|
||||
}).toBe(true);
|
||||
await expect.poll(async () => (await evidence()).comments.filter((row: any) => row.issueId === started.session.issueId && !row.authorAgentId).length).toBe(2);
|
||||
await expect(transcript).toContainText("Your follow-up was included", { timeout: 30_000 });
|
||||
const beforeRepeat = await transcript.getByRole("listitem").count();
|
||||
await page.getByRole("button", { name: "Repeat answer", exact: true }).click();
|
||||
await expect(transcript.getByRole("listitem")).toHaveCount(beforeRepeat + 1);
|
||||
await page.getByRole("button", { name: "Mute", exact: true }).click();
|
||||
expect(await page.evaluate(() => (window as any).__spekoTransport.muted)).toBe(true);
|
||||
await page.getByRole("button", { name: "End call", exact: true }).click();
|
||||
await expect(page.getByRole("status").filter({ hasText: /^Call ended$/ })).toBeVisible();
|
||||
expect(await page.evaluate(() => (window as any).__spekoTransport.open)).toBe(false);
|
||||
// Prove connection guidance reaches the ordinary execution provider without
|
||||
// installing a template or replacing the selected agent's instructions.
|
||||
await expect.poll(async () => (await evidence()).runs.find((row: any) => row.context.issueId === started.session.issueId)?.context.paperclipTaskCommunicationGuidance).toContain("Keep your existing identity, runtime, tools, and task assignment");
|
||||
const deliveredGuidance = (await evidence()).runs.find((row: any) => row.context.issueId === started.session.issueId).context.paperclipTaskCommunicationGuidance;
|
||||
expect(deliveredGuidance).toContain("one focused clarification");
|
||||
expect(deliveredGuidance).toContain("durable Paperclip continuation");
|
||||
expect(deliveredGuidance).toContain("does not cancel work");
|
||||
expect(deliveredGuidance).toContain("Spoken consent does not replace governed approval");
|
||||
const foreign = await page.request.get(`${origin}/api/companies/${other.id}/voice-sessions/${started.session.id}`);
|
||||
expect(foreign.status()).toBe(404);
|
||||
await page.getByRole("button", { name: "Close voice panel", exact: true }).click();
|
||||
await page.goto(`/${company.issuePrefix}/apps/chat/connect?provider=speko&resume=${endpoint.id}`);
|
||||
const rejoining = nextSession(); await page.getByRole("button", { name: "Start voice", exact: true }).click(); const resumed = await rejoining;
|
||||
expect(resumed.session.issueId).toBe(started.session.issueId); expect(resumed.session.id).not.toBe(started.session.id);
|
||||
await expect(page.getByRole("button", { name: "Mute", exact: true })).toBeEnabled();
|
||||
const journal = await page.evaluate(() => Object.entries(sessionStorage).filter(([key]) => key.startsWith("paperclip:voice-attempt:")));
|
||||
expect(journal).toHaveLength(1);
|
||||
expect(journal[0][1]).not.toContain(resumed.media.transportToken);
|
||||
// Reload must reconcile the exact old call before it offers a new connection.
|
||||
await page.reload();
|
||||
await page.getByRole("button", { name: "Start voice", exact: true }).click();
|
||||
await expect(page.getByRole("alert").filter({ hasText: "The previous call was closed" })).toBeVisible();
|
||||
await expect.poll(async () => (await evidence()).sessions.find((row: any) => row.id === resumed.session.id)?.state).toBe("ended");
|
||||
expect((await evidence()).sessions).toHaveLength(2);
|
||||
const recoveredStart = nextSession();
|
||||
await page.getByRole("button", { name: "Try again", exact: true }).click();
|
||||
const recovered = await recoveredStart;
|
||||
expect(recovered.session.issueId).toBe(started.session.issueId);
|
||||
await expect(page.getByRole("button", { name: "Repeat answer", exact: true })).toBeEnabled();
|
||||
await page.getByRole("button", { name: "Repeat answer", exact: true }).click();
|
||||
await expect(transcript).toContainText("Your follow-up was included");
|
||||
await page.getByRole("button", { name: "End call", exact: true }).click();
|
||||
await expect.poll(async () => (await evidence()).sessions.filter((row: any) => row.issueId === started.session.issueId).every((row: any) => row.state === "ended")).toBe(true);
|
||||
await page.getByRole("button", { name: "Close voice panel", exact: true }).click();
|
||||
// Explicit New creates one new task; hanging up does not cancel accepted work.
|
||||
await page.getByRole("combobox", { name: "Conversation", exact: true }).selectOption("new");
|
||||
const newStart = nextSession(); await page.getByRole("button", { name: "Start voice", exact: true }).click(); const fresh = await newStart;
|
||||
expect(fresh.session.issueId).not.toBe(started.session.issueId);
|
||||
await expect(page.getByRole("button", { name: "Mute", exact: true })).toBeEnabled();
|
||||
await speak("Do the benchmark; I will return after it finishes.", "after-hangup");
|
||||
const executionRows = async () => (await readFile(resolve(home, "execution.jsonl"), "utf8")).trim().split("\n").map(line => JSON.parse(line));
|
||||
await expect.poll(async () => (await executionRows()).some(row => row.issueId === fresh.session.issueId && row.event === "started")).toBe(true);
|
||||
await page.getByRole("button", { name: "End call", exact: true }).click();
|
||||
await expect(page.getByRole("status").filter({ hasText: /^Call ended$/ })).toBeVisible();
|
||||
await expect.poll(async () => (await executionRows()).some(row => row.issueId === fresh.session.issueId && row.event === "completed" && row.elapsedMs >= 60_000), { timeout: 90_000 }).toBe(true);
|
||||
await page.getByRole("button", { name: "Close voice panel", exact: true }).click();
|
||||
await page.getByRole("combobox", { name: "Conversation", exact: true }).selectOption("continue");
|
||||
const missedStart = nextSession(); await page.getByRole("button", { name: "Start voice", exact: true }).click(); const missed = await missedStart;
|
||||
expect(missed.session.issueId).toBe(fresh.session.issueId);
|
||||
await expect(transcript).toContainText("Verification number");
|
||||
expect((await evidence()).comments.filter((row: any) => row.issueId === fresh.session.issueId && !row.authorAgentId)).toHaveLength(1);
|
||||
await speak("Ask the structured acceptance question before continuing.", "request-question");
|
||||
await expect.poll(() => page.evaluate(() => (window as any).__spekoTransport.question?.questions[0]?.id)).toBe("color");
|
||||
await expect(transcript).toContainText("Which color should I use?");
|
||||
const answer = () => page.evaluate(() => {
|
||||
const transport = (window as any).__spekoTransport;
|
||||
return transport.answer(transport.question.interactionId, [{ questionId: "color", optionIds: ["cobalt"] }], "answer-one");
|
||||
});
|
||||
const answered = await answer(); expect(answered.status).toBe("answered");
|
||||
expect(await answer()).toEqual(answered);
|
||||
await expect(transcript).toContainText("Your structured answer was received", { timeout: 30_000 });
|
||||
await expect(transcript).toContainText("cobalt");
|
||||
await expect.poll(async () => (await evidence()).questionDeliveries.filter((row: any) => row.interactionId === answered.interactionId).length).toBe(1);
|
||||
const questionEvidence = await evidence();
|
||||
expect(questionEvidence.interactions.find((row: any) => row.id === answered.interactionId)?.status).toBe("answered");
|
||||
expect(questionEvidence.tools.filter((row: any) => row.tool === "answer_question")).toHaveLength(1);
|
||||
const delivery = questionEvidence.questionDeliveries.find((row: any) => row.interactionId === answered.interactionId);
|
||||
expect(["delivered", "fallback_queued"]).toContain(delivery.status);
|
||||
expect(delivery.targetRunId).toBeTruthy();
|
||||
await expect.poll(async () => (await evidence()).runs.find((row: any) => row.id === delivery.targetRunId)?.status).toBe("succeeded");
|
||||
const beforeRestart = await evidence();
|
||||
process.kill(supervisorPid, "SIGUSR2");
|
||||
// Restart has the same startup budget as the initial fixture server.
|
||||
await expect.poll(async () => (await evidence()).appPid, { timeout: 120_000 }).not.toBe(beforeRestart.appPid);
|
||||
await expect.poll(async () => {
|
||||
try { return (await page.request.get(`${origin}/api/health`)).ok(); } catch { return false; }
|
||||
}).toBe(true);
|
||||
// Same session and signed tool identity cross the real process boundary.
|
||||
expect(await answer()).toEqual(answered);
|
||||
const beforeRecoveredRepeat = await transcript.getByRole("listitem").count();
|
||||
await page.getByRole("button", { name: "Repeat answer", exact: true }).click();
|
||||
await expect(transcript.getByRole("listitem")).toHaveCount(beforeRecoveredRepeat + 1);
|
||||
await expect(transcript).toContainText("Your structured answer was received");
|
||||
expect(await page.evaluate(() => (window as any).__spekoTransport.creates)).toBe(3);
|
||||
const afterRestart = await evidence();
|
||||
expect(afterRestart.sessions).toHaveLength(beforeRestart.sessions.length);
|
||||
expect(afterRestart.comments).toHaveLength(beforeRestart.comments.length);
|
||||
expect(afterRestart.runs).toHaveLength(beforeRestart.runs.length);
|
||||
expect(afterRestart.tools.filter((row: any) => row.tool === "answer_question")).toHaveLength(1);
|
||||
expect(afterRestart.questionDeliveries).toHaveLength(1);
|
||||
await page.getByRole("button", { name: "End call", exact: true }).click();
|
||||
await expect.poll(async () => (await evidence()).sessions.every((row: any) => row.state === "ended")).toBe(true);
|
||||
await page.getByRole("button", { name: "Close voice panel", exact: true }).click();
|
||||
const activated = await post(`/chat-endpoints/${endpoint.id}/test`, {});
|
||||
expect(activated.status).toBe("active");
|
||||
await page.goto(`/${company.issuePrefix}/apps/chat/${endpoint.id}/settings`);
|
||||
await expect(page.getByRole("textbox", { name: "Your callback number", exact: true })).toBeEnabled();
|
||||
await expect(page.getByRole("button", { name: "Start voice", exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole("combobox", { name: "Conversation", exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole("link", {name: "Open Speko"})).toHaveAttribute("href", "https://platform.speko.ai/agents");
|
||||
await expect(page.locator('header img[src="/brands/apps/speko.svg"]').first()).toBeVisible();
|
||||
await page.getByRole("textbox", { name: "Your callback number", exact: true }).fill("+12015551234");
|
||||
await page.getByRole("checkbox", { name: "Allow this agent to call me about my tasks", exact: true }).check();
|
||||
await page.getByRole("button", { name: "Save phone setting", exact: true }).click();
|
||||
await expect(page.getByRole("status").filter({ hasText: "Callback setting saved" })).toBeVisible();
|
||||
await page.reload();
|
||||
await expect(page.getByRole("textbox", { name: "Your callback number", exact: true })).toHaveValue("+12015551234");
|
||||
await expect(page.getByRole("checkbox", { name: "Allow this agent to call me about my tasks", exact: true })).toBeChecked();
|
||||
const updatedAgent = await page.request.patch(`${origin}/api/agents/${agent.id}`, { headers: { Origin: origin }, data: { adapterConfig: { command: "fixture-phone" } } });
|
||||
expect(updatedAgent.ok()).toBe(true);
|
||||
const callTask = await post(`/companies/${company.id}/issues`, { title: "Call my saved phone", status: "todo", assigneeAgentId: agent.id, responsibleUserId: "local-board" });
|
||||
await expect.poll(async () => (await executionRows()).some(row => row.event === "phone_called" && row.issueId === callTask.id), { timeout: 30_000 }).toBe(true);
|
||||
const phoneExecution = (await executionRows()).find(row => row.event === "phone_called" && row.issueId === callTask.id);
|
||||
expect(phoneExecution.secondRejected).toBe(true);
|
||||
expect(JSON.stringify(phoneExecution.result)).not.toContain("+12015551234");
|
||||
expect(JSON.stringify(phoneExecution.result)).not.toContain("fixture-speko-key");
|
||||
expect(JSON.stringify(phoneExecution.result)).not.toContain("transportToken");
|
||||
await expect.poll(async () => (await evidence()).sessions.filter((row: any) => row.issueId === callTask.id && row.mode === "outbound_phone").length).toBe(1);
|
||||
const phone = (await evidence()).sessions.find((row: any) => row.issueId === callTask.id && row.mode === "outbound_phone");
|
||||
const providerState = JSON.parse(await readFile(resolve(home, "speko-fixture-state.json"), "utf8"));
|
||||
expect(providerState.sessions.filter((entry: any) => entry[1].mode === "outbound_phone")).toHaveLength(1);
|
||||
const inspectedPhone = await (await page.request.get(`${origin}/api/companies/${company.id}/voice-sessions/${phone.id}`)).json();
|
||||
expect(inspectedPhone).toMatchObject({ id: phone.id, issueId: callTask.id, mode: "outbound_phone" });
|
||||
expect(JSON.stringify(inspectedPhone)).not.toContain("transportToken");
|
||||
const endedPhone = await post(`/companies/${company.id}/voice-sessions/${phone.id}/end`, {});
|
||||
expect(endedPhone.state).toBe("ended");
|
||||
const retainedTask = await (await page.request.get(`${origin}/api/issues/${callTask.id}`)).json();
|
||||
expect(retainedTask.status).not.toBe("cancelled");
|
||||
expect(retainedTask.assigneeAgentId).toBe(agent.id);
|
||||
await page.getByRole("checkbox", { name: "Allow this agent to call me about my tasks", exact: true }).uncheck();
|
||||
await page.getByRole("button", { name: "Save phone setting", exact: true }).click();
|
||||
await expect(page.getByRole("status").filter({ hasText: "Callback setting saved" })).toBeVisible();
|
||||
await expect.poll(async () => (await evidence()).sessions.every((row: any) => row.state === "ended")).toBe(true);
|
||||
// E08: a real signed pre-call route creates NO task before explicit live approval.
|
||||
await page.getByRole("combobox", {name: "Company phone number", exact: true}).selectOption("pn_fixture");
|
||||
await page.getByRole("checkbox", {name: "Enable incoming calls on this number", exact: true}).check();
|
||||
await page.getByRole("button", {name: "Save incoming call setting", exact: true}).click();
|
||||
await expect(page.getByRole("status").filter({hasText: "Incoming call setting saved"})).toBeVisible();
|
||||
await page.request.patch(`${origin}/api/agents/${agent.id}`, {headers: {Origin: origin}, data: {adapterConfig: {command: "fixture"}}});
|
||||
const providerCommand = async (data: unknown) => {
|
||||
const response = await page.request.post(`http://127.0.0.1:${port + 1}/command`, {headers: {Origin: origin}, data});
|
||||
expect(response.ok(), await response.text()).toBe(true); return response.json();
|
||||
};
|
||||
const beforeIncoming = (await evidence()).sessions.length;
|
||||
const incoming = await providerCommand({action: "inbound", agentId: "agent_fixture", callerId: "+12015551234"});
|
||||
await expect(page.getByRole("heading", {name: "Incoming call awaiting approval", exact: true})).toBeVisible();
|
||||
await expect.poll(async () => (await evidence()).incoming.filter((call: any) => call.endpointId === endpoint.id).length).toBe(1);
|
||||
expect((await evidence()).sessions).toHaveLength(beforeIncoming);
|
||||
const unapproved = await providerCommand({sessionId: incoming.sessionId, tool: "submit_request", args: {text: "Spoofed caller ID must not grant task access"}, toolCallId: "before-approval"});
|
||||
expect(unapproved).toMatchObject({requestAccepted: false, authorization: "awaiting_approval", updates: []});
|
||||
await page.getByRole("textbox", {name: "Code spoken on your call", exact: true}).fill("000000");
|
||||
await page.getByRole("button", {name: "Approve live call", exact: true}).click();
|
||||
await expect(page.getByRole("alert").filter({hasText: "does not match"})).toBeVisible();
|
||||
const code = incoming.firstMessage.replace(/\D/g, ""); expect(code).toHaveLength(6);
|
||||
await page.getByRole("textbox", {name: "Code spoken on your call", exact: true}).fill(code);
|
||||
const inboundTask = await post(`/companies/${company.id}/issues`, {title: "Approved incoming phone conversation", assigneeAgentId: agent.id, status: "todo"});
|
||||
// Refresh task inventory after this fixture task is created.
|
||||
await page.reload();
|
||||
await page.getByRole("textbox", {name: "Code spoken on your call", exact: true}).fill(code);
|
||||
await page.getByRole("combobox", {name: "Conversation task", exact: true}).selectOption(inboundTask.id);
|
||||
await page.getByRole("button", {name: "Approve live call", exact: true}).click();
|
||||
await expect(page.getByRole("heading", {name: "Call approved", exact: true})).toBeFocused();
|
||||
await expect.poll(async () => (await evidence()).sessions.find((call: any) => call.providerSessionId === incoming.sessionId)?.issueId).toBe(inboundTask.id);
|
||||
const phoneWork = {sessionId: incoming.sessionId, tool: "submit_request", args: {text: "Perform this approved intake task"}, toolCallId: "approved-work"};
|
||||
const incomingAccepted = await providerCommand(phoneWork); expect(incomingAccepted).toMatchObject({status: "accepted", authorization: "approved"});
|
||||
expect(await providerCommand(phoneWork)).toEqual(incomingAccepted);
|
||||
const boundIncoming = (await evidence()).sessions.find((call: any) => call.providerSessionId === incoming.sessionId);
|
||||
await expect.poll(async () => (await evidence()).tools.filter((call: any) => call.sessionId === boundIncoming.id && call.tool === "submit_request").length).toBe(1);
|
||||
// A saved task answer must reach the live phone transport before hangup.
|
||||
// Provider acceptance is asserted separately from actual spoken playback.
|
||||
await expect.poll(async () => (await evidence()).comments.some((row: any) => row.issueId === inboundTask.id && row.authorAgentId === agent.id && row.body.includes("Verification number")), {timeout: 100_000}).toBe(true);
|
||||
await expect.poll(async () => (await providerCommand({action: "messages", sessionId: incoming.sessionId})).some((message: any) => message.text.includes("Verification number")), {timeout: 30_000}).toBe(true);
|
||||
await expect.poll(async () => (await evidence()).pushActions.some((action: any) => action.payload.sessionId === boundIncoming.id && action.status === "completed" && action.result?.messageId && action.result.playback === "unknown")).toBe(true);
|
||||
await post(`/companies/${company.id}/voice-sessions/${boundIncoming.id}/end`, {});
|
||||
// E15: report reconciliation persists one report, excludes recordings and keeps task assignment.
|
||||
await expect.poll(async () => (await evidence()).reports.find((report: any) => report.sessionId === boundIncoming.id)?.status, {timeout: 45000}).toBe("available");
|
||||
const reportResponse = await page.request.get(`${origin}/api/companies/${company.id}/voice-sessions/${boundIncoming.id}/report`);
|
||||
const report = await reportResponse.json(); expect(report.session.issueId).toBe(inboundTask.id);
|
||||
expect(report.report).toMatchObject({costMicroUsd: "152340", transcript: [{text: "Fixture call transcript"}]});
|
||||
expect(JSON.stringify(report)).not.toContain("recording"); expect(JSON.stringify(report)).not.toContain("fixture-speko-key");
|
||||
await expect(page.getByRole("region", {name: "Your calls", exact: true})).toHaveCount(0);
|
||||
await page.goto(`/${company.issuePrefix}/apps/chat/${endpoint.id}/activity`);
|
||||
const history = page.getByRole("region", {name: "Connection activity", exact: true});
|
||||
await history.getByRole("button", {name: "Refresh", exact: true}).click();
|
||||
await expect(history).toContainText("Incoming call · Completed");
|
||||
await history.locator("summary").filter({hasText: "Incoming call · Completed"}).click();
|
||||
await expect(history.getByRole("list", {name: "Call transcript", exact: true})).toContainText("Fixture call transcript");
|
||||
expect((await evidence()).reports.filter((row: any) => row.sessionId === boundIncoming.id)).toHaveLength(1);
|
||||
const retainedIncomingTask = await (await page.request.get(`${origin}/api/issues/${inboundTask.id}`)).json();
|
||||
expect(retainedIncomingTask.assigneeAgentId).toBe(agent.id); expect(retainedIncomingTask.status).not.toBe("cancelled");
|
||||
await page.goto(`/${company.issuePrefix}/apps/chat/${endpoint.id}/settings`);
|
||||
// E09: opted-in callers start a task-scoped, low-trust conversation without sign-in.
|
||||
await page.getByRole("checkbox", {name: "Let callers start new low-trust tasks without sign-in", exact: true}).check();
|
||||
await page.getByRole("button", {name: "Save incoming call setting", exact: true}).click();
|
||||
await expect(page.getByRole("status").filter({hasText: "Incoming call setting saved"})).toBeVisible();
|
||||
const guest = await providerCommand({action: "inbound", agentId: "agent_fixture", callerId: "+12015559876"});
|
||||
await expect(page.getByRole("heading", {name: "Incoming conversation", exact: true})).toBeVisible();
|
||||
const guestRequest = {sessionId: guest.sessionId, tool: "submit_request", args: {text: "Please contact me about a shipping question. Do not start private work."}, toolCallId: "guest-message"};
|
||||
const guestAccepted = await providerCommand(guestRequest);
|
||||
expect(guestAccepted).toMatchObject({authorization: "guest_intake", status: "accepted"});
|
||||
expect(await providerCommand(guestRequest)).toEqual(guestAccepted);
|
||||
expect(await providerCommand({sessionId: guest.sessionId, tool: "get_updates", args: {cursor: 0}, toolCallId: "guest-read"})).toMatchObject({authorization: "guest_intake", updates: []});
|
||||
await expect.poll(async () => (await evidence()).sessions.filter((call: any) => call.providerSessionId === guest.sessionId).length).toBe(1);
|
||||
const guestSession = (await evidence()).sessions.find((call: any) => call.providerSessionId === guest.sessionId);
|
||||
const guestTaskResponse = await page.request.get(`${origin}/api/issues/${guestSession.issueId}`);
|
||||
const guestTask = await guestTaskResponse.json();
|
||||
expect(guestTask).toMatchObject({ assigneeAgentId: agent.id, responsibleUserId: null, sourceTrust: {disposition: "quarantined"}, executionPolicy: {authorizationPolicy: {trustBoundary: {allowedToolClasses: [], allowedSecretBindingIds: []}}}});
|
||||
await expect.poll(async () => (await evidence()).comments.filter((row: any) => row.issueId === guestTask.id && row.body === (guestRequest.args as {text: string}).text).length).toBe(1);
|
||||
// This fixture has only Local, so containment must block execution rather
|
||||
// than silently grant the public caller the selected agent's host privileges.
|
||||
await expect.poll(async () => (await evidence()).runs.find((run: any) => run.context?.issueId === guestTask.id)?.errorCode).toBe("low_trust_isolation_unavailable");
|
||||
const guestRun = (await evidence()).runs.find((run: any) => run.context?.issueId === guestTask.id);
|
||||
expect((await evidence()).wakeups.some((row: any) => row.runId === guestRun.id)).toBe(true);
|
||||
expect((await evidence()).tools.filter((row: any) => row.sessionId === guestSession.id && row.tool === "submit_request")).toHaveLength(1);
|
||||
await page.getByRole("button", {name: "End call", exact: true}).click();
|
||||
await expect(page.getByRole("heading", {name: "Caller hung up", exact: true})).toBeFocused();
|
||||
await expect.poll(async () => (await evidence()).sessions.find((call: any) => call.id === guestSession.id)?.state, {timeout: 45000}).toBe("ended");
|
||||
await expect.poll(async () => (await evidence()).reports.find((report: any) => report.sessionId === guestSession.id)?.status, {timeout: 45000}).toBe("available");
|
||||
await page.goto(`/${company.issuePrefix}/apps/chat/${endpoint.id}/activity`);
|
||||
await history.getByRole("button", {name: "Refresh", exact: true}).click();
|
||||
await expect(history.locator("summary").filter({hasText: "Incoming call · Completed"})).toHaveCount(2);
|
||||
expect(external).toEqual([]);
|
||||
await info.attach("durable-records", { body: JSON.stringify(await evidence(), null, 2), contentType: "application/json" });
|
||||
await page.screenshot({ path: info.outputPath("completed.png"), fullPage: true });
|
||||
});
|
||||
|
||||
// Connection setup selects an existing agent without changing its runtime or
|
||||
// reassigning tasks created before the connection.
|
||||
test("E02: selecting an existing agent preserves its configuration and historical assignment", async ({ page }) => {
|
||||
const company = await (await page.request.post("/api/companies", { data: { name: "Speko existing agent" } })).json();
|
||||
const created = await page.request.post(`/api/companies/${company.id}/agents`, { data: { name: "Existing support agent", role: "general", adapterType: "process", adapterConfig: { command: "true" }, runtimeConfig: { heartbeat: { enabled: false, wakeOnDemand: false } } } });
|
||||
expect(created.ok()).toBe(true);
|
||||
const agent = await created.json();
|
||||
const task = await (await page.request.post(`/api/companies/${company.id}/issues`, { data: { title: "Preserve existing work", status: "backlog", assigneeAgentId: agent.id } })).json();
|
||||
await page.goto(`/${company.issuePrefix}/apps/chat/connect?provider=speko`);
|
||||
await expect(page.getByRole("button", { name: "Review recommended agent" })).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "Choose an active agent", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Select Existing support agent", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Continue", exact: true }).click();
|
||||
await expect(page.getByText(/Existing support agent keeps its existing runtime/)).toBeVisible();
|
||||
await page.reload();
|
||||
await expect(page.getByText(/Existing support agent keeps its existing runtime/)).toBeVisible();
|
||||
const agents = await (await page.request.get(`/api/companies/${company.id}/agents`)).json();
|
||||
expect(agents).toHaveLength(1);
|
||||
expect(agents[0].id).toBe(agent.id);
|
||||
expect(agents[0].adapterConfig).toEqual(agent.adapterConfig);
|
||||
expect(agents[0].reportsTo).toBe(agent.reportsTo);
|
||||
expect((await (await page.request.get(`/api/issues/${task.id}`)).json()).assigneeAgentId).toBe(agent.id);
|
||||
const endpoints = await (await page.request.get(`/api/companies/${company.id}/chat-endpoints`)).json();
|
||||
expect(endpoints).toHaveLength(1);
|
||||
expect(endpoints[0].assignedAgentId).toBe(agent.id);
|
||||
});
|
||||
|
||||
|
||||
test("E12: insufficient credits explains funding and never blindly retries a rejected call", async ({page}) => {
|
||||
const company=await (await page.request.post("/api/companies",{data:{name:"Speko credit rejection"}})).json();
|
||||
const agent=await (await page.request.post(`/api/companies/${company.id}/agents`,{data:{name:"Credit-test agent",role:"general",adapterType:"process",adapterConfig:{command:"true"},runtimeConfig:{heartbeat:{enabled:false,wakeOnDemand:false}}}})).json();
|
||||
await page.addInitScript(() => { navigator.mediaDevices.getUserMedia=async () => {throw new Error("Rejected call must not request microphone");}; });
|
||||
await page.goto(`/${company.issuePrefix}/apps/chat/connect?provider=speko`);
|
||||
await page.getByRole("button",{name:"Choose an active agent",exact:true}).click();
|
||||
await page.getByRole("button",{name:"Select Credit-test agent",exact:true}).click();
|
||||
await page.getByRole("button",{name:"Continue",exact:true}).click();
|
||||
await page.getByRole("textbox",{name:"Speko agent ID",exact:true}).fill("agent_credit_fixture");
|
||||
await page.getByLabel("Speko API key",{exact:true}).fill("fixture-speko-key");
|
||||
await page.getByRole("button",{name:"Connect Speko",exact:true}).click();
|
||||
await expect(page.getByRole("button",{name:"Start voice",exact:true})).toBeVisible();
|
||||
const creditState=async () => (await page.request.post(`http://127.0.0.1:${port+1}/command`,{headers:{Origin:origin},data:{action:"credits_required",agentId:"agent_credit_fixture",enabled:true}})).json();
|
||||
expect((await creditState()).creationAttempts).toBe(0);
|
||||
const responsePromise=page.waitForResponse(r => r.request().method()==="POST" && /\/voice-sessions$/.test(new URL(r.url()).pathname));
|
||||
await page.getByRole("button",{name:"Start voice",exact:true}).click();
|
||||
const response=await responsePromise;expect(response.status()).toBe(409);
|
||||
const error=await response.json();expect(error.details.code).toBe("voice_credits_required");
|
||||
expect(JSON.stringify(error)).not.toContain("private fixture provider detail");
|
||||
await expect(page.getByRole("alert").filter({hasText:"Add credits in Speko"})).toBeVisible();
|
||||
await expect(page.getByRole("button",{name:"Mute",exact:true})).toHaveCount(0);
|
||||
const inspected=await (await page.request.get(`/api/companies/${company.id}/voice-sessions/${error.details.sessionId}`)).json();
|
||||
expect(inspected).not.toHaveProperty("providerSessionId");expect(inspected.errorCode).toBe("insufficient_credits");
|
||||
const {home}=JSON.parse(await readFile(resolve(import.meta.dirname,".instance.json"),"utf8"));
|
||||
await expect.poll(async () => JSON.parse(await readFile(resolve(home,"evidence.json"),"utf8")).sessions.find((row: {id: string}) => row.id===error.details.sessionId)?.providerSessionId).toBeNull();
|
||||
expect(["failed","ended"]).toContain(inspected.state);
|
||||
// Replay the exact already-rejected creation, as after a lost response.
|
||||
const replay=await page.request.post(response.url(),{data:response.request().postDataJSON()});
|
||||
expect(replay.ok()).toBe(true);expect((await replay.json()).session.id).toBe(error.details.sessionId);
|
||||
expect((await creditState()).creationAttempts).toBe(1);
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
/** Explicit live acceptance; real browser UI + Speko, synthetic microphone only.
|
||||
* Requires the disposable server and prior provider-persona authorization.
|
||||
* No Playwright traces: setup carries a provider credential.
|
||||
*/
|
||||
import { chromium, expect } from '@playwright/test';
|
||||
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
const origin = `http://127.0.0.1:${Number(process.env.SPEKO_NATIVE_PORT ?? 3449)}`;
|
||||
const privateRoot = resolve(homedir(), '.paperclip/speko-proof');
|
||||
const resumeIssueId = process.env.SPEKO_NATIVE_RESUME_ISSUE_ID;
|
||||
const diskScenario = process.env.SPEKO_NATIVE_SCENARIO === 'disk';
|
||||
const delayedTemplate = process.env.SPEKO_NATIVE_SCENARIO === 'template-delayed';
|
||||
const templateScenario = diskScenario || delayedTemplate || process.env.SPEKO_NATIVE_SCENARIO === 'template';
|
||||
const expectedWord = process.env.SPEKO_NATIVE_EXPECTED_WORD ?? 'marigold';
|
||||
const evidencePath = process.env.SPEKO_NATIVE_EVIDENCE_PATH;
|
||||
if (delayedTemplate && !evidencePath) throw new Error('The delayed real-agent scenario requires SPEKO_NATIVE_EVIDENCE_PATH for durable follow-up assertions');
|
||||
const fixturePath = process.env.SPEKO_NATIVE_FIXTURE_PATH ?? resolve(privateRoot, templateScenario ? 'native-template-fixture.json' : 'native-fixture.json');
|
||||
const fixture = JSON.parse(await readFile(fixturePath, 'utf8'));
|
||||
const out = resolve(privateRoot, `native-browser-${Date.now()}`);
|
||||
await mkdir(out, { mode: 0o700 });
|
||||
const changedFiles = execFileSync('git', ['ls-files', '-m', '-o', '--exclude-standard', '-z'], { encoding: 'utf8' }).split('\0').filter(Boolean);
|
||||
const hashes = {};
|
||||
for (const file of changedFiles) { try { hashes[file] = createHash('sha256').update(await readFile(file)).digest('hex'); } catch { hashes[file] = 'deleted'; } }
|
||||
await writeFile(resolve(out, 'source.json'), JSON.stringify({ commit: execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(), changedFiles: hashes }, null, 2), { mode: 0o600 });
|
||||
const key = (await readFile(resolve(homedir(), '.secrets'), 'utf8')).match(/^export SPEKO_MCP_API_KEY=['"]([^'"]+)['"]/m)?.[1];
|
||||
if (!key) throw new Error('Saved Speko credential missing');
|
||||
const browser = await chromium.launch({ headless: true, args: ['--autoplay-policy=no-user-gesture-required'] });
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 960 } });
|
||||
const events = [], started = Date.now();
|
||||
const idleProbe = process.env.SPEKO_NATIVE_IDLE_PROBE === "1";
|
||||
if (idleProbe) await page.route("**/voice-sessions/*/notification", (route) => route.fulfill({ status: 200, contentType: "application/json", body: "null" }));
|
||||
let session, progress;
|
||||
let executionProvider = "unknown";
|
||||
const event = (kind, details = {}) => { events.push({ kind, elapsedMs: Date.now() - started, ...details }); console.log(JSON.stringify({ kind, ...details })); };
|
||||
page.on('response', async (response) => {
|
||||
if (response.request().method() === 'POST' && /\/voice-sessions$/.test(new URL(response.url()).pathname) && response.ok()) {
|
||||
const result = await response.json(); session = result.session;
|
||||
await writeFile(resolve(out, 'session.json'), JSON.stringify(session), { mode: 0o600 });
|
||||
event('session_created', { sessionId: session.id, issueId: session.issueId });
|
||||
}
|
||||
});
|
||||
await page.addInitScript(() => {
|
||||
const state = window.spekoFixture = { tracks: [], events: [], context: null, destination: null, chunks: [], recorder: null, audioEdges: [], recordingStartedAt: null };
|
||||
navigator.mediaDevices.getUserMedia = async () => {
|
||||
state.context ??= new AudioContext({ sampleRate: 48000 });
|
||||
await state.context.resume();
|
||||
if (!state.destination) {
|
||||
state.destination = state.context.createMediaStreamDestination();
|
||||
const silence = state.context.createConstantSource(); silence.offset.value = 0.00001; silence.connect(state.destination); silence.start(); state.silence = silence;
|
||||
}
|
||||
const stream = state.destination.stream.clone(); state.tracks.push(...stream.getTracks());
|
||||
return stream;
|
||||
};
|
||||
new MutationObserver(() => {
|
||||
const audio = [...document.querySelectorAll('audio')].find((node) => node.srcObject);
|
||||
if (!audio || state.recorder) return;
|
||||
state.recorder = new MediaRecorder(audio.srcObject);
|
||||
state.recorder.ondataavailable = (event) => { if (event.data.size) state.chunks.push(event.data); };
|
||||
state.recordingStartedAt = Date.now();
|
||||
state.recorder.start();
|
||||
// Measure received audio, independently of the model's transcript/state.
|
||||
state.context ??= new AudioContext({ sampleRate: 48000 });
|
||||
const source = state.context.createMediaStreamSource(audio.srcObject);
|
||||
const analyser = state.context.createAnalyser(); analyser.fftSize = 1024;
|
||||
const sink = state.context.createGain(); sink.gain.value = 0;
|
||||
source.connect(analyser); analyser.connect(sink); sink.connect(state.context.destination);
|
||||
const samples = new Float32Array(analyser.fftSize); let audible = false;
|
||||
state.audioTimer = setInterval(() => {
|
||||
analyser.getFloatTimeDomainData(samples);
|
||||
const rms = Math.sqrt(samples.reduce((sum, value) => sum + value * value, 0) / samples.length);
|
||||
const next = rms > 0.01;
|
||||
if (next) state.lastAudibleAt = Date.now();
|
||||
if (next !== audible) { state.audioEdges.push({ at: Date.now(), audible: next }); audible = next; }
|
||||
}, 20);
|
||||
}).observe(document, { childList: true, subtree: true });
|
||||
});
|
||||
async function speak(name) {
|
||||
const audioPath = process.env.SPEKO_NATIVE_AUDIO_DIR ? resolve(process.env.SPEKO_NATIVE_AUDIO_DIR, `${name}.pcm`) : new URL(`../../.paperclip-local/speko-proof-audio/${name}.pcm`, import.meta.url);
|
||||
const bytes = [...await readFile(audioPath)];
|
||||
event('input_started', { name });
|
||||
await page.evaluate(async (bytes) => {
|
||||
const state = window.spekoFixture, raw = new DataView(new Uint8Array(bytes).buffer);
|
||||
const buffer = state.context.createBuffer(1, bytes.length / 2, 48000);
|
||||
const channel = buffer.getChannelData(0);
|
||||
for (let i = 0; i < channel.length; i++) channel[i] = raw.getInt16(i * 2, true) / 32768;
|
||||
const source = state.context.createBufferSource(); source.buffer = buffer; source.connect(state.destination);
|
||||
await new Promise((done) => { source.onended = done; source.start(); }); source.disconnect();
|
||||
}, bytes);
|
||||
event('input_ended', { name });
|
||||
}
|
||||
try {
|
||||
await expect.poll(async () => fetch(`${origin}/api/health`).then((response) => response.ok).catch(() => false), { timeout: 30000 }).toBe(true);
|
||||
await page.goto(origin);
|
||||
if (resumeIssueId) {
|
||||
await page.goto(`${origin}/${fixture.companyPrefix}/issues/${resumeIssueId}`);
|
||||
await page.getByRole('button', { name: `Talk to ${fixture.agentName}`, exact: true }).click();
|
||||
} else if (process.env.SPEKO_NATIVE_CONNECTED === '1') {
|
||||
if (!fixture.endpointId) throw new Error('Connected qualification requires the verified endpoint fixture');
|
||||
const taskResponse = await page.request.post(`${origin}/api/companies/${fixture.companyId}/issues`, {headers: {Origin: origin}, data: {title: "Speko live browser qualification", assigneeAgentId: fixture.agentId, status: "todo"}});
|
||||
if (!taskResponse.ok()) throw new Error(`Qualification task creation rejected (${taskResponse.status()})`);
|
||||
const task = await taskResponse.json();
|
||||
await page.goto(`${origin}/${fixture.companyPrefix}/issues/${task.id}`);
|
||||
await page.getByRole('button', {name: `Talk to ${fixture.agentName}`, exact: true}).click();
|
||||
event('qualification_task_created', {issueId: task.id});
|
||||
event('existing_connection_used', {endpointId: fixture.endpointId});
|
||||
} else {
|
||||
await page.getByRole('link', { name: 'Connectors', exact: true }).first().click();
|
||||
if (fixture.endpointId) {
|
||||
// Resume the saved setup using the same UI route after an earlier failed test.
|
||||
await page.goto(`${origin}/${fixture.companyPrefix}/apps/chat/connect?provider=speko&resume=${fixture.endpointId}`);
|
||||
} else {
|
||||
await page.locator('[data-app-slug="speko"]').getByRole('button', { name: /^(Connect Speko|Add connection Speko)$/ }).click();
|
||||
await page.getByRole('button', { name: 'Choose an active agent', exact: true }).click();
|
||||
await page.getByRole('button', { name: `Select ${fixture.agentName ?? 'Existing Paperclip Agent'}`, exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Continue', exact: true }).click();
|
||||
}
|
||||
await expect(page.getByLabel('Speko API key', { exact: true }).or(page.getByRole('button', { name: 'Start voice', exact: true })).filter({ visible: true }).first()).toBeVisible({ timeout: 30000 });
|
||||
if (await page.getByLabel('Speko API key', { exact: true }).isVisible()) {
|
||||
await page.getByRole('textbox', { name: 'Speko agent ID', exact: true }).fill(fixture.providerAgentId);
|
||||
await page.getByLabel('Speko API key', { exact: true }).fill(key);
|
||||
const responsePromise = page.waitForResponse((r) => r.request().method() === 'POST' && /\/chat-endpoints\/[^/]+\/setup$/.test(new URL(r.url()).pathname), { timeout: 60000 });
|
||||
await page.getByRole('button', { name: /^(Connect|Reconnect) Speko$/ }).click();
|
||||
const response = await responsePromise;
|
||||
if (!response.ok()) throw new Error(`Setup rejected (${response.status()}): ${(await response.json()).error}`);
|
||||
const endpoint = await response.json(); fixture.endpointId = endpoint.id;
|
||||
await writeFile(fixturePath, JSON.stringify(fixture), { mode: 0o600 });
|
||||
event('native_setup_verified', { endpointId: endpoint.id, status: endpoint.status });
|
||||
}
|
||||
}
|
||||
await expect(page.getByRole('button', { name: 'Start voice', exact: true })).toBeVisible({ timeout: 30000 });
|
||||
await page.screenshot({ path: resolve(out, 'before-call.png'), fullPage: true });
|
||||
await writeFile(resolve(out, 'intent.json'), JSON.stringify({ action: 'start_browser_voice', at: new Date().toISOString(), endpointId: fixture.endpointId }), { mode: 0o600 });
|
||||
if (process.env.SPEKO_NATIVE_FRESH_CONVERSATION === '1') await page.getByRole('combobox', { name: 'Conversation', exact: true }).selectOption('new');
|
||||
await page.getByRole('button', { name: 'Start voice', exact: true }).click();
|
||||
await expect(page.getByRole('button', { name: 'Mute', exact: true })).toBeEnabled({ timeout: 30000 });
|
||||
expect(session?.assignedAgentId).toBe(fixture.agentId);
|
||||
event('media_connected');
|
||||
const agentResponse = await page.request.get(`${origin}/api/agents/${session.assignedAgentId}`);
|
||||
if (agentResponse.ok()) {
|
||||
const agent = await agentResponse.json();
|
||||
if (typeof agent.adapterType === 'string') executionProvider = agent.adapterType;
|
||||
}
|
||||
event('execution_provider_inspected', {executionProvider});
|
||||
if (resumeIssueId) { expect(session?.issueId).toBe(resumeIssueId); event('existing_task_resumed', { issueId: resumeIssueId }); }
|
||||
progress = setInterval(() => { void page.locator('body').innerText().then((text) => writeFile(resolve(out, 'latest-visible.txt'), text, { mode: 0o600 })).catch(() => {}); }, 3000);
|
||||
await expect(page.getByRole('list', { name: 'Conversation transcript' }).getByRole('listitem')).not.toHaveCount(0, { timeout: 20000 });
|
||||
await expect(page.getByRole('status').filter({ hasText: /^Listening$/ })).toBeVisible({ timeout: 20000 });
|
||||
await page.waitForFunction(() => window.spekoFixture.lastAudibleAt && Date.now() - window.spekoFixture.lastAudibleAt > 900, undefined, { timeout: 15000 });
|
||||
await speak(diskScenario ? 'disk-request' : resumeIssueId ? 'template-resume' : delayedTemplate ? 'template-delayed-start' : templateScenario ? 'template-start' : 'start');
|
||||
await expect(page.getByRole('status').filter({ hasText: /^Speaking$/ })).toBeVisible({ timeout: 20000 });
|
||||
event('acknowledgment_speaking');
|
||||
if (evidencePath) {
|
||||
await expect.poll(async () => {
|
||||
const evidence = JSON.parse(await readFile(evidencePath, 'utf8'));
|
||||
return evidence.tools.filter(tool => tool.sessionId === session.id && tool.tool === 'submit_request' && tool.response?.status === 'accepted').length;
|
||||
}, { timeout: 20000 }).toBeGreaterThanOrEqual(1);
|
||||
event('first_request_durably_accepted');
|
||||
}
|
||||
if (diskScenario) await speak('disk-followup');
|
||||
if (!resumeIssueId && !diskScenario) await speak(delayedTemplate ? 'template-delayed-followup' : templateScenario ? 'template-followup' : 'followup');
|
||||
const transcript = page.getByRole('list', { name: 'Conversation transcript', exact: true });
|
||||
if (evidencePath && !resumeIssueId) {
|
||||
await expect.poll(async () => {
|
||||
try {
|
||||
const evidence = JSON.parse(await readFile(evidencePath, 'utf8'));
|
||||
return evidence.tools.filter((tool) => tool.sessionId === session.id && tool.tool === 'submit_request' && tool.response?.status === 'accepted').length;
|
||||
} catch { return 0; }
|
||||
}, { timeout: 30000 }).toBeGreaterThanOrEqual(2);
|
||||
event('followup_durably_accepted');
|
||||
}
|
||||
const agentTranscript = transcript.getByRole('listitem').filter({ has: page.locator('span').filter({ hasText: fixture.agentName ?? 'Existing Paperclip Agent' }) });
|
||||
await expect(agentTranscript).toContainText([diskScenario ? /(?:10|ten) gigabytes[\s\S]*?(?:9[.,]7|nine point seven) gigabytes/i : templateScenario ? new RegExp(expectedWord.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i') : /verification number/i], { timeout: templateScenario ? 180000 : 100000 });
|
||||
if (diskScenario) {
|
||||
await expect.poll(async () => {
|
||||
const response = await page.request.get(`${origin}/api/issues/${session.issueId}/comments`);
|
||||
if (!response.ok()) return false;
|
||||
return (await response.json()).some(comment => comment.authorAgentId === fixture.agentId
|
||||
&& Date.parse(comment.createdAt) >= started && /(?:disk|space|storage|filesystem)/i.test(comment.body)
|
||||
&& /(?:available|free)/i.test(comment.body));
|
||||
}, {timeout: 180000}).toBe(true);
|
||||
event('real_agent_disk_result_persisted');
|
||||
}
|
||||
if (delayedTemplate) {
|
||||
await expect.poll(async () => {
|
||||
const response = await page.request.get(`${origin}/api/issues/${session.issueId}/comments`);
|
||||
if (!response.ok()) return false;
|
||||
return (await response.json()).some((comment) => comment.authorAgentId === fixture.agentId && /silver lantern/i.test(comment.body));
|
||||
}, { timeout: 60000 }).toBe(true);
|
||||
await expect(agentTranscript.filter({ hasText: new RegExp(expectedWord, "i") })).toContainText([/silver lantern/i], { timeout: 60000 });
|
||||
event('followup_applied_by_agent');
|
||||
const comments = await (await page.request.get(`${origin}/api/issues/${session.issueId}/comments`)).json();
|
||||
const runIds = [...new Set(comments.map((comment) => comment.createdByRunId).filter(Boolean))];
|
||||
const runs = await Promise.all(runIds.map(async (id) => (await page.request.get(`${origin}/api/heartbeat-runs/${id}`)).json()));
|
||||
const executionMs = Math.max(...runs.filter((run) => run.status === 'succeeded').map((run) => Date.parse(run.finishedAt) - Date.parse(run.startedAt)));
|
||||
expect(executionMs).toBeGreaterThanOrEqual(65000);
|
||||
event('real_delayed_execution_verified', { executionMs, runIds });
|
||||
}
|
||||
if (!templateScenario && evidencePath) {
|
||||
const executionPath = resolve(dirname(evidencePath), 'execution.jsonl');
|
||||
await expect.poll(async () => {
|
||||
const executions = (await readFile(executionPath, 'utf8')).trim().split('\n').map(line => JSON.parse(line));
|
||||
return executions.some(run => run.event === 'completed' && run.issueId === session.issueId && run.elapsedMs >= 60_000 && run.requests >= 2);
|
||||
}, { timeout: 30_000 }).toBe(true);
|
||||
const executions = (await readFile(executionPath, 'utf8')).trim().split('\n').map(line => JSON.parse(line));
|
||||
const execution = executions.find(run => run.event === 'completed' && run.issueId === session.issueId);
|
||||
await expect.poll(async () => (await agentTranscript.allTextContents()).join(' ').replaceAll('*', '').includes(String(execution.number)), { timeout: 30_000 }).toBe(true);
|
||||
// Wait for the queued amendment to be retrieved too. Ending as soon as the
|
||||
// first answer appears would hide the very self-interruption being tested.
|
||||
await expect.poll(async () => {
|
||||
const evidence = JSON.parse(await readFile(evidencePath, 'utf8'));
|
||||
return new Set(evidence.tools.filter(tool => tool.sessionId === session.id && tool.tool === 'get_updates').flatMap(tool => (tool.response?.updates ?? []).map(update => update.publicationId))).size;
|
||||
}, { timeout: 30_000 }).toBeGreaterThanOrEqual(2);
|
||||
event('delayed_execution_and_two_publications_verified', { executionMs: execution.elapsedMs, requests: execution.requests, runId: execution.runId });
|
||||
}
|
||||
if (!templateScenario) await expect(transcript).toContainText(/(?:received|included)[\s\S]{0,100}(?:one|1)[\s\S]{0,60}follow.up|(?:one|1)[\s\S]{0,60}follow.up[\s\S]{0,100}(?:received|included)/i, { timeout: 30000 });
|
||||
await expect(page.getByRole('status').filter({ hasText: /^Listening$/ })).toBeVisible({ timeout: 30000 });
|
||||
await expect(agentTranscript.last()).not.toContainText(' · Speaking', { timeout: 30000 });
|
||||
await page.waitForFunction(() => window.spekoFixture.lastAudibleAt && Date.now() - window.spekoFixture.lastAudibleAt > 1800, undefined, { timeout: 20000 });
|
||||
event('delayed_result_visible');
|
||||
await page.screenshot({ path: resolve(out, 'delayed-result.png'), fullPage: true });
|
||||
await page.getByRole('button', { name: 'Mute', exact: true }).click();
|
||||
await expect(page.getByRole('button', { name: 'Unmute', exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(await page.evaluate(() => window.spekoFixture.tracks.every((track) => !track.enabled))).toBe(true);
|
||||
await page.getByRole('button', { name: 'Unmute', exact: true }).click();
|
||||
event('mute_verified');
|
||||
} catch (error) {
|
||||
event('failed', { message: error.message.replaceAll(key, '[redacted]') });
|
||||
await page.screenshot({ path: resolve(out, 'failure.png'), fullPage: true }).catch(() => {});
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
clearInterval(progress);
|
||||
try {
|
||||
const text = await page.locator('body').innerText({ timeout: 2000 }).catch(() => 'unavailable');
|
||||
await writeFile(resolve(out, 'visible.txt'), text.replaceAll(key, '[redacted]'), { mode: 0o600 });
|
||||
const audio = await page.evaluate(async () => {
|
||||
const state = window.spekoFixture;
|
||||
if (!state?.recorder) return null;
|
||||
if (state.recorder.state !== 'inactive') await new Promise((done) => { state.recorder.onstop = done; state.recorder.stop(); });
|
||||
return [...new Uint8Array(await new Blob(state.chunks, { type: 'audio/webm' }).arrayBuffer())];
|
||||
}).catch(() => null);
|
||||
const audioMeasurements = await page.evaluate(() => {
|
||||
const state = window.spekoFixture; if (!state) return null;
|
||||
clearInterval(state.audioTimer);
|
||||
return { recordingStartedAt: state.recordingStartedAt, edges: state.audioEdges, resolutionMs: 20, rmsThreshold: 0.01 };
|
||||
}).catch(() => null);
|
||||
if (audioMeasurements) await writeFile(resolve(out, 'audio-measurements.json'), JSON.stringify(audioMeasurements), { mode: 0o600 });
|
||||
if (audio) await writeFile(resolve(out, 'received.webm'), Buffer.from(audio), { mode: 0o600 });
|
||||
} catch { event('evidence_failed'); process.exitCode = 1; } finally {
|
||||
// Evidence failures must never bypass hangup of a paid provider session.
|
||||
if (session) {
|
||||
try {
|
||||
const button = page.getByRole('button', { name: /^(End call|Retry ending call)$/ });
|
||||
if (await button.count()) await button.click();
|
||||
} catch { event('ui_cleanup_unavailable'); }
|
||||
try {
|
||||
const response = await page.request.post(`${origin}/api/companies/${fixture.companyId}/voice-sessions/${session.id}/end`, { headers: { Origin: origin }, data: {} });
|
||||
event('end_requested', { status: response.status() });
|
||||
await expect.poll(async () => {
|
||||
const inspected = await page.request.get(`${origin}/api/companies/${fixture.companyId}/voice-sessions/${session.id}`);
|
||||
return inspected.ok() ? (await inspected.json()).state : 'unavailable';
|
||||
}, { timeout: 30000, intervals: [500, 1000, 2000] }).toMatch(/^(ended|failed|expired)$/);
|
||||
expect(await page.evaluate(() => window.spekoFixture.tracks.every((track) => track.readyState === 'ended'))).toBe(true);
|
||||
event('cleanup_verified');
|
||||
} catch { event('cleanup_unconfirmed'); process.exitCode = 1; }
|
||||
}
|
||||
try {
|
||||
await writeFile(resolve(out, 'report.json'), JSON.stringify({ startedAt: new Date(started).toISOString(), events, session, browserNotificationDisabledForIdleProbe: idleProbe, syntheticMicrophone: true, audioReview: 'required independently; script success does not confirm complete spoken playback', resumedIssueId: resumeIssueId, diskScenario, delayedTemplate, expectedWord: templateScenario ? expectedWord : undefined, executionProvider, server: 'real', speko: 'live' }, null, 2), { mode: 0o600 });
|
||||
console.log(JSON.stringify({ evidence: out }));
|
||||
} finally { await browser.close(); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from "@playwright/test";
|
||||
import { resolve } from "node:path";
|
||||
const root = resolve(import.meta.dirname, "../..");
|
||||
const port = 3482;
|
||||
export default defineConfig({
|
||||
testDir: ".", testMatch: "authenticated.spec.ts", workers: 1, retries: 0, timeout: 90_000,
|
||||
outputDir: "./test-results-auth", reporter: [["list"], ["html", { outputFolder: "./playwright-report-auth", open: "never" }]],
|
||||
use: { baseURL: `http://127.0.0.1:${port}`, serviceWorkers: "block", trace: "off" },
|
||||
webServer: {
|
||||
command: "server/node_modules/.bin/tsx tests/speko-native/server.ts", cwd: root,
|
||||
env: { ...process.env, SPEKO_NATIVE_WATCH: "0", SPEKO_NATIVE_AUTH: "1", SPEKO_NATIVE_STUB: "1", SPEKO_NATIVE_PORT: String(port), SPEKO_NATIVE_STUB_PORT: String(port + 1), SPEKO_NATIVE_CALLBACK_ORIGIN: "https://speko-fixture.invalid", SPEKO_NATIVE_INSTANCE_FILE: resolve(root, "tests/speko-native/.auth-instance.json") },
|
||||
url: `http://127.0.0.1:${port}/api/health`, reuseExistingServer: false, timeout: 120_000,
|
||||
gracefulShutdown: { signal: "SIGTERM", timeout: 15_000 },
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from "@playwright/test";
|
||||
import { resolve } from "node:path";
|
||||
const root = resolve(import.meta.dirname, "../..");
|
||||
const port = Number(process.env.SPEKO_NATIVE_TEST_PORT ?? 3480);
|
||||
export default defineConfig({
|
||||
testDir: ".", testMatch: "integrated.spec.ts", workers: 1, fullyParallel: false, retries: 0,
|
||||
timeout: 360_000, expect: { timeout: 15_000 },
|
||||
outputDir: "./test-results", reporter: [["list"], ["html", { outputFolder: "./playwright-report", open: "never" }]],
|
||||
use: { serviceWorkers: "block", baseURL: `http://127.0.0.1:${port}`, viewport: { width: 1200, height: 900 }, trace: "retain-on-failure" },
|
||||
webServer: {
|
||||
command: "server/node_modules/.bin/tsx tests/speko-native/server.ts", cwd: root,
|
||||
env: { ...process.env, SPEKO_NATIVE_WATCH: "0", SPEKO_NATIVE_STUB: "1", SPEKO_NATIVE_PORT: String(port), SPEKO_NATIVE_STUB_PORT: String(port + 1), SPEKO_NATIVE_CALLBACK_ORIGIN: "https://speko-fixture.invalid", SPEKO_NATIVE_INSTANCE_FILE: resolve(root, "tests/speko-native/.instance.json") },
|
||||
url: `http://127.0.0.1:${port}/api/health`, reuseExistingServer: false, timeout: 120_000,
|
||||
gracefulShutdown: { signal: "SIGTERM", timeout: 15_000 },
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
/** Throwaway acceptance instance: real app, DB, queues and publications.
|
||||
* The execution provider is synthetic. Speko is live unless SPEKO_NATIVE_STUB=1.
|
||||
* Run with server/node_modules/.bin/tsx tests/speko-native/server.ts.
|
||||
*/
|
||||
import { mkdtemp, writeFile, mkdir } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { resolve } from "node:path";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { once } from "node:events";
|
||||
|
||||
const home = await mkdtemp(resolve(tmpdir(), "paperclip-speko-native-"));
|
||||
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;
|
||||
if (!callbackOrigin || new URL(callbackOrigin).protocol !== "https:") throw new Error("Explicit HTTPS callback origin required");
|
||||
// Set isolation before importing anything that reads Paperclip configuration.
|
||||
Object.assign(process.env, {
|
||||
NODE_ENV: "test", PAPERCLIP_HOME: home, PAPERCLIP_INSTANCE_ID: "speko-native-acceptance",
|
||||
PAPERCLIP_CONFIG: resolve(home, "config.json"), PAPERCLIP_DISABLE_CWD_ENV_FILE: "true",
|
||||
PAPERCLIP_AGENT_JWT_SECRET: randomBytes(32).toString("hex"),
|
||||
PAPERCLIP_DECISION_SIGNING_SECRET: randomBytes(32).toString("hex"),
|
||||
PAPERCLIP_TOOL_ACTION_SIGNING_SECRET: randomBytes(32).toString("hex"),
|
||||
PAPERCLIP_SECRETS_MASTER_KEY_FILE: resolve(home, "master.key"),
|
||||
PAPERCLIP_API_URL: origin, PAPERCLIP_CHAT_WEBHOOK_PUBLIC_URL: callbackOrigin,
|
||||
PAPERCLIP_DEPLOYMENT_MODE: process.env.SPEKO_NATIVE_AUTH === "1" ? "authenticated" : "local_trusted",
|
||||
PAPERCLIP_AUTH_PUBLIC_BASE_URL: origin, PAPERCLIP_AUTH_BASE_URL_MODE: "explicit", PAPERCLIP_DEPLOYMENT_EXPOSURE: "private",
|
||||
PAPERCLIP_VITE_CACHE_DIR: resolve(home, "vite-cache"), PAPERCLIP_OPEN_ON_LISTEN: "false",
|
||||
PAPERCLIP_TELEMETRY_ENABLED: "false",
|
||||
});
|
||||
delete process.env.DATABASE_URL;
|
||||
delete process.env.PAPERCLIP_IN_WORKTREE;
|
||||
delete process.env.PAPERCLIP_MANAGED_CONFIG;
|
||||
await mkdir(resolve(home, "storage"));
|
||||
const { startEmbeddedPostgresTestDatabase, applyPendingMigrations } = await import("../../packages/db/src/index.js");
|
||||
const database = await startEmbeddedPostgresTestDatabase("paperclip-speko-native-db-");
|
||||
const { spawn } = await import("node:child_process");
|
||||
// The supervisor owns the temporary database. Restarting app code preserves
|
||||
// calls, credentials and delivery receipts so recovery can be qualified.
|
||||
const spawnApp = () => spawn("server/node_modules/.bin/tsx", [...(process.env.SPEKO_NATIVE_WATCH === "0" ? [] : ["watch"]), "tests/speko-native/app.ts"], {
|
||||
cwd: resolve(import.meta.dirname, "../.."), stdio: "inherit",
|
||||
env: { ...process.env, DATABASE_URL: database.connectionString },
|
||||
});
|
||||
let child = spawnApp();
|
||||
let stopping = false, restarting = false;
|
||||
function watchExit() {
|
||||
child.once("exit", () => {
|
||||
if (stopping || restarting) return;
|
||||
if (process.env.SPEKO_NATIVE_PERSIST === "1") { console.error("Live test application stopped; database retained. SIGUSR2 restarts the app."); return; }
|
||||
void database.cleanup().then(() => process.exit(1));
|
||||
});
|
||||
}
|
||||
watchExit();
|
||||
await writeFile(resolve(home, "instance.json"), JSON.stringify({ origin, callbackOrigin, startedAt: new Date().toISOString(), pid: process.pid }), { mode: 0o600 });
|
||||
if (process.env.SPEKO_NATIVE_INSTANCE_FILE) await writeFile(process.env.SPEKO_NATIVE_INSTANCE_FILE, JSON.stringify({ home, origin, callbackOrigin, supervisorPid: process.pid }), { mode: 0o600 });
|
||||
async function stopChild() {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
const exited = once(child, "exit");
|
||||
child.kill("SIGTERM");
|
||||
await exited;
|
||||
}
|
||||
// Only explicitly isolated fixture instances accept this restart signal.
|
||||
if ((process.env.SPEKO_NATIVE_STUB === "1" || process.env.SPEKO_NATIVE_PERSIST === "1") && process.env.SPEKO_NATIVE_WATCH === "0") {
|
||||
process.on("SIGUSR2", () => {
|
||||
if (stopping || restarting) return;
|
||||
restarting = true;
|
||||
void stopChild().then(async () => {
|
||||
await applyPendingMigrations(database.connectionString);
|
||||
if (!stopping) { child = spawnApp(); watchExit(); }
|
||||
restarting = false;
|
||||
}).catch(error => {
|
||||
restarting = false;
|
||||
if (process.env.SPEKO_NATIVE_PERSIST === "1") console.error("Live fixture restart failed; database retained:", error.message);
|
||||
else void stop();
|
||||
});
|
||||
});
|
||||
}
|
||||
async function stop() {
|
||||
if (stopping) return; stopping = true;
|
||||
await stopChild();
|
||||
await database.cleanup();
|
||||
process.exit(0);
|
||||
}
|
||||
process.once("SIGINT", () => void stop());
|
||||
process.once("SIGTERM", () => void stop());
|
||||
@@ -0,0 +1,40 @@
|
||||
/** Replaces only @spekoai/client's external media boundary in browser tests. */
|
||||
export class VoiceConversation {
|
||||
static async create(options) {
|
||||
const state = window.__spekoTransport = { open: true, muted: false, creates: (window.__spekoTransport?.creates ?? 0) + 1, closes: window.__spekoTransport?.closes ?? 0, cursor: 0 };
|
||||
const sessionId = options.transportToken.replace('fixture:', '');
|
||||
const command = async (tool, args, toolCallId) => {
|
||||
const response = await fetch('http://127.0.0.1:__STUB_PORT__/command', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ sessionId, tool, args, toolCallId }) });
|
||||
const result = await response.json();
|
||||
if (!response.ok) throw new Error(`Signed callback rejected: ${response.status}`);
|
||||
return result;
|
||||
};
|
||||
state.speak = async (text, toolCallId) => {
|
||||
options.onMessage({ source: 'user', text, segmentId: crypto.randomUUID(), isFinal: true });
|
||||
const result = await command('submit_request', { text }, toolCallId);
|
||||
options.onModeChange('speaking');
|
||||
options.onMessage({ source: 'agent', text: result.message, segmentId: crypto.randomUUID(), isFinal: true });
|
||||
options.onModeChange('listening');
|
||||
return result;
|
||||
};
|
||||
state.answer = (interactionId, answers, toolCallId) => command('answer_question', { interactionId, answers }, toolCallId);
|
||||
options.onModeChange('listening');
|
||||
return {
|
||||
canPlaybackAudio: true,
|
||||
async endSession() { if (state.open) { state.open = false; state.closes++; } },
|
||||
async setMicMuted(value) { state.muted = value; },
|
||||
async startAudioPlayback() {},
|
||||
async sendChatMessage(message) {
|
||||
if (!state.open) throw new Error('Fixture media already closed');
|
||||
const result = await command('get_updates', { cursor: state.cursor, ...(message.includes('repeat=true') ? { repeat: true } : {}) });
|
||||
state.cursor = result.cursor;
|
||||
for (const update of result.updates) {
|
||||
if (update.question) state.question = update.question;
|
||||
options.onModeChange('speaking');
|
||||
options.onMessage({ source: 'agent', text: update.text, segmentId: crypto.randomUUID(), isFinal: true });
|
||||
options.onModeChange('listening');
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/** Test-only external Speko transport. Paperclip routes/DB/permissions stay real. */
|
||||
import { createServer } from "node:http";
|
||||
import { createHmac, randomUUID } from "node:crypto";
|
||||
import { once } from "node:events";
|
||||
import { existsSync, readFileSync, writeFileSync, renameSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
type Tool = { id: string; name: string; source: { kind: string; url: string; secret: string }; [key: string]: unknown };
|
||||
type Session = { id: string; agentId: string; token: string; endedAt: string | null; mode: "browser" | "outbound_phone" | "inbound_phone" };
|
||||
export async function installSpekoTestProvider(origin: string, port: number) {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const tools = new Map<string, Tool[]>(), sessions = new Map<string, Session>(), hooks = new Map<string, any>();
|
||||
const messages = new Map<string, {id: string; sessionId: string; text: string; mode: string}[]>();
|
||||
let numberAgentId: string | null = null;
|
||||
const creditRejectedAgents = new Set<string>(), creationAttempts = new Map<string, number>();
|
||||
// The simulated external provider outlives an app restart, just as Speko does.
|
||||
// This file contains only deterministic fixture credentials, never a real key.
|
||||
const stateFile = resolve(process.env.PAPERCLIP_HOME!, "speko-fixture-state.json");
|
||||
if (existsSync(stateFile)) {
|
||||
const state = JSON.parse(readFileSync(stateFile, "utf8"));
|
||||
for (const [key, value] of state.tools) tools.set(key, value);
|
||||
numberAgentId = state.numberAgentId ?? null;
|
||||
for (const [key, value] of state.hooks ?? []) hooks.set(key, value);
|
||||
for (const [key, value] of state.messages ?? []) messages.set(key, value);
|
||||
for (const [key, value] of state.sessions) sessions.set(key, value);
|
||||
}
|
||||
const persist = () => {
|
||||
writeFileSync(`${stateFile}.pending`, JSON.stringify({ tools: [...tools], sessions: [...sessions], hooks: [...hooks], messages: [...messages], numberAgentId }), { mode: 0o600 });
|
||||
renameSync(`${stateFile}.pending`, stateFile);
|
||||
};
|
||||
const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
|
||||
globalThis.fetch = async (input, init) => {
|
||||
const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url);
|
||||
if (url.origin !== "https://api.speko.dev") return originalFetch(input, init);
|
||||
if (new Headers(init?.headers).get("authorization") !== "Bearer fixture-speko-key") return json({ error: "fixture credential rejected" }, 401);
|
||||
const method = init?.method ?? "GET", body = init?.body ? JSON.parse(String(init.body)) : undefined;
|
||||
if (url.pathname === "/v1/phone-numbers" && method === "GET") return json([{id: "pn_fixture", organizationId: "org_fixture", e164: "+12015550123", label: "Fixture company line", agentId: numberAgentId, suspendedAt: null, direction: "both", setupStatus: {status: "ready", inboundReady: true, outboundReady: true, issues: []}}]);
|
||||
if (url.pathname === "/v1/phone-numbers/pn_fixture" && method === "PATCH") {numberAgentId = body.agentId; persist(); return json({id: "pn_fixture"});}
|
||||
if (url.pathname === "/v1/webhooks" && method === "GET") return json({data: [...hooks.values()].map(({signingSecret: _secret, ...hook}) => hook)});
|
||||
const hook = /^\/v1\/webhooks(?:\/([^/]+))?$/.exec(url.pathname);
|
||||
if (hook && ["POST", "PATCH"].includes(method)) {const id = hook[1] ?? `hook_${randomUUID()}`; hooks.set(id, {filterTags: {}, ...body, id}); persist(); return json({id});}
|
||||
const agent = /^\/v1\/agents\/([^/]+)$/.exec(url.pathname);
|
||||
if (agent && method === "GET") return json({ id: agent[1], organizationId: "org_fixture", name: "Fixture voice persona" });
|
||||
if (agent && method === "PATCH") return json({ id: agent[1] });
|
||||
const tool = /^\/v1\/agents\/([^/]+)\/tools(?:\/([^/]+))?$/.exec(url.pathname);
|
||||
if (tool) {
|
||||
const entries = tools.get(tool[1]) ?? [];
|
||||
if (method === "GET") return json(entries.map(({ source: { secret: _secret, ...source }, ...value }) => ({ ...value, source })));
|
||||
if (["POST", "PATCH"].includes(method)) {
|
||||
const id = tool[2] ?? `tool_${randomUUID()}`;
|
||||
tools.set(tool[1], [...entries.filter(entry => entry.id !== id), { ...body, id }]);
|
||||
persist();
|
||||
return json({ id });
|
||||
}
|
||||
}
|
||||
if (["/v1/sessions", "/v1/sessions/phone"].includes(url.pathname) && method === "POST") {
|
||||
creationAttempts.set(body.agentId, (creationAttempts.get(body.agentId) ?? 0) + 1);
|
||||
if (creditRejectedAgents.has(body.agentId)) return json({error: "private fixture provider detail", code: "INSUFFICIENT_CREDITS"},402);
|
||||
const id = randomUUID();
|
||||
sessions.set(id, { id, agentId: body.agentId, token: body.toolSecrets.paperclip_session_token, endedAt: null, mode: url.pathname.endsWith("/phone") ? "outbound_phone" : "browser" });
|
||||
persist();
|
||||
if (url.pathname.endsWith("/phone")) return json({ sessionId: id, status: "dialing" });
|
||||
return json({ sessionId: id, transportToken: `fixture:${id}`, transportUrl: "wss://speko-transport.invalid" });
|
||||
}
|
||||
const messageCall = /^\/v1\/calls\/([^/]+)\/messages$/.exec(url.pathname);
|
||||
if (messageCall && method === "POST") {
|
||||
const session = sessions.get(messageCall[1]);
|
||||
if (!session) return json({error: "unknown fixture call"}, 404);
|
||||
if (session.endedAt) return json({error: "call already ended"}, 409);
|
||||
if (typeof body.text !== "string" || !body.text.trim() || body.mode !== "respond") return json({error: "invalid message"}, 400);
|
||||
const message = {id: `message_${randomUUID()}`, sessionId: session.id, text: body.text, mode: body.mode};
|
||||
messages.set(session.id, [...messages.get(session.id) ?? [], message]); persist();
|
||||
return json({message_id: message.id}, 202);
|
||||
}
|
||||
const call = /^\/v1\/calls\/([^/]+)(\/end)?$/.exec(url.pathname);
|
||||
if (call) {
|
||||
const session = sessions.get(call[1]); if (!session) return json({ error: "unknown fixture call" }, 404);
|
||||
if (call[2] && method === "POST") { session.endedAt = new Date().toISOString(); persist(); return json({ status: "already_ended" }); }
|
||||
if (method === "GET") return json({ id: session.id, status: session.endedAt ? "ended" : "active", endedAt: session.endedAt, ended_at: session.endedAt, duration_seconds: session.endedAt ? 60 : null, report: session.endedAt ? {session_id: session.id, cost_micro_usd: "152340", updated_at: session.endedAt, transcript: {entries: [{id: "fixture-report-turn", index: 0, source: "user", text: "Fixture call transcript", started_at: session.endedAt, ended_at: session.endedAt}]} } : null, recording_resource_uri: "private-fixture-recording-do-not-fetch" });
|
||||
}
|
||||
throw new Error(`Unimplemented Speko fixture request: ${method} ${url.pathname}`);
|
||||
};
|
||||
const server = createServer(async (req, res) => {
|
||||
if (req.headers.origin !== origin) { res.writeHead(403).end(); return; }
|
||||
res.setHeader("access-control-allow-origin", origin);
|
||||
res.setHeader("access-control-allow-headers", "content-type");
|
||||
res.setHeader("access-control-allow-methods", "POST, OPTIONS");
|
||||
if (req.method === "OPTIONS") { res.writeHead(204).end(); return; }
|
||||
if (req.method !== "POST" || req.url !== "/command") { res.writeHead(404).end(); return; }
|
||||
try {
|
||||
const chunks: Buffer[] = []; let size = 0;
|
||||
for await (const chunk of req) { size += chunk.length; if (size > 64 * 1024) throw new Error("Fixture payload too large"); chunks.push(chunk); }
|
||||
const command = JSON.parse(Buffer.concat(chunks).toString());
|
||||
if (command.action === "credits_required") {
|
||||
if (command.enabled) creditRejectedAgents.add(command.agentId); else creditRejectedAgents.delete(command.agentId);
|
||||
res.writeHead(200, {"content-type": "application/json"}).end(JSON.stringify({creationAttempts: creationAttempts.get(command.agentId) ?? 0})); return;
|
||||
}
|
||||
if (command.action === "inbound") {
|
||||
const hook = [...hooks.values()].find(h => h.agentIds.includes(command.agentId) && h.events.includes("call.pre_call"));
|
||||
if (!hook || numberAgentId !== command.agentId) {res.writeHead(409).end(); return;}
|
||||
const id = randomUUID(), body = JSON.stringify({type: "call.pre_call", call_id: id, session_id: id, organization_id: "org_fixture", direction: "inbound", phone_number_id: "pn_fixture", dialed_number: "+12015550123", from: command.callerId ?? "+12015551234"});
|
||||
const timestamp = String(Math.floor(Date.now()/1000)), webhookId = `msg_${id}`;
|
||||
const signature = createHmac("sha256", Buffer.from(hook.signingSecret.slice(6), "base64")).update(`${webhookId}.${timestamp}.${body}`).digest("base64");
|
||||
const response = await originalFetch(`${origin}${new URL(hook.url).pathname}`, {method: "POST", headers: {"content-type": "application/json", "webhook-id": webhookId, "webhook-timestamp": timestamp, "webhook-signature": `v1,${signature}`}, body});
|
||||
const payload = await response.json() as any;
|
||||
if (!response.ok) {res.writeHead(response.status).end(JSON.stringify(payload)); return;}
|
||||
sessions.set(id, {id, agentId: command.agentId, token: payload.toolSecrets.paperclip_session_token, endedAt: null, mode: "inbound_phone"}); persist();
|
||||
res.writeHead(200, {"content-type": "application/json"}).end(JSON.stringify({sessionId: id, firstMessage: payload.firstMessage})); return;
|
||||
}
|
||||
if (command.action === "messages") {
|
||||
res.writeHead(200, {"content-type": "application/json"}).end(JSON.stringify(messages.get(command.sessionId) ?? [])); return;
|
||||
}
|
||||
const session = sessions.get(command.sessionId);
|
||||
const tool = session && tools.get(session.agentId)?.find(item => item.name === command.tool);
|
||||
if (!session || session.endedAt || !tool) { res.writeHead(404).end(); return; }
|
||||
const toolId = command.toolCallId ?? randomUUID();
|
||||
const body = JSON.stringify({ session_id: session.id, tool_call_id: toolId, idempotency_key: `${session.id}:${toolId}`, tool: command.tool, args: command.args });
|
||||
const timestamp = String(Math.floor(Date.now() / 1000)), webhookId = `msg_${toolId}`;
|
||||
const signature = createHmac("sha256", Buffer.from(tool.source.secret.slice(6), "base64")).update(`${webhookId}.${timestamp}.${body}`).digest("base64");
|
||||
// Only external transport is stubbed: enter the production signed route.
|
||||
const response = await originalFetch(`${origin}${new URL(tool.source.url).pathname}`, { method: "POST", headers: {
|
||||
"content-type": "application/json", "webhook-id": webhookId, "webhook-timestamp": timestamp,
|
||||
"webhook-signature": `v1,${signature}`, authorization: `Bearer ${session.token}`,
|
||||
}, body });
|
||||
res.writeHead(response.status, { "content-type": "application/json" }).end(await response.text());
|
||||
} catch { res.writeHead(500).end(JSON.stringify({ error: "Fixture command failed" })); }
|
||||
}).listen(port, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
return async () => { globalThis.fetch = originalFetch; server.close(); server.closeAllConnections(); };
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"rootDir": "../..",
|
||||
"types": ["node"],
|
||||
"typeRoots": ["../../cli/node_modules/@types", "../../server/node_modules/@types"]
|
||||
},
|
||||
"include": ["./**/*.ts", "../../server/src/types/express.d.ts"]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
reports/
|
||||
.env
|
||||
*.pcm
|
||||
*.wav
|
||||
@@ -0,0 +1,149 @@
|
||||
# Speko delayed voice feasibility probe
|
||||
|
||||
This isolated harness implements the first gate in the approved Speko connection
|
||||
plan. It does **not** implement a Paperclip connection, touch Paperclip tasks,
|
||||
or qualify browser/phone acceptance on its own. A hosted Speko agent must keep
|
||||
retrieving a result for at least 60 seconds, accept an interruption and follow-up,
|
||||
then speak the result without another request from the caller.
|
||||
|
||||
The harness deliberately has no UI, microphone capture, database, company
|
||||
credentials, private task data, or recording download. Production integration is
|
||||
gated on real-provider evidence; its tests must not be reused as claims about
|
||||
Paperclip permissions, persistence, or actual spoken playback.
|
||||
|
||||
## Local protocol checks
|
||||
|
||||
```sh
|
||||
cd tests/speko-voice-proof
|
||||
npm install --ignore-scripts --no-package-lock
|
||||
npm test
|
||||
```
|
||||
|
||||
The delay test uses an injected clock, so it checks the 60-second withholding
|
||||
contract without a minute-long unit test. It cannot prove that Speko continues
|
||||
polling, that its media runtime supports interruptions, or that audio plays.
|
||||
|
||||
## Live setup
|
||||
|
||||
1. Set `SPEKO_API_KEY` or `SPEKO_MCP_API_KEY` in the shell environment from a
|
||||
private secret store. Never paste it into a command argument, fixture, trace,
|
||||
screenshot, or issue. The API key stays in the provisioning process only.
|
||||
2. Run `npm run init`. State defaults to
|
||||
`~/.paperclip/speko-proof/state.json` (mode 0600, parent directory 0700).
|
||||
`SPEKO_PROOF_STATE` can select a separate private path. Initialization refuses
|
||||
to overwrite an existing run. The signing secret is generated locally.
|
||||
3. Run `npm run serve`; it listens only on `127.0.0.1:3198` (override with
|
||||
`SPEKO_PROOF_PORT`). It stops after 15 minutes. Only `/health` and the signed
|
||||
`POST /speko-proof/tool` exist. Do not expose a Paperclip development server.
|
||||
4. Configure an explicitly scoped HTTPS reverse proxy to this receiver. Verify
|
||||
`POST /speko-proof/tool` with an unsigned JSON body returns 401. Set
|
||||
`SPEKO_PROOF_PUBLIC_ORIGIN` to that HTTPS origin, with no path or credentials.
|
||||
5. Run `npm run provision`. It creates a dedicated synthetic test persona and
|
||||
two tools. It does not provision phone numbers or place calls. Partial
|
||||
creation is recorded. If an API result is uncertain, it refuses blind retries;
|
||||
reconcile the dedicated agent/tools in Speko before clearing the pending
|
||||
marker. Use a fresh state/agent when changing the callback origin or secret.
|
||||
6. Open the named **Paperclip delayed voice proof** agent in Speko. Its Browser
|
||||
test runs the hosted deployed agent. Use only synthetic test speech.
|
||||
|
||||
State contains secrets. Do not copy it into reports. The final `evidence.json`
|
||||
contains event kinds, timings, counts, cursors, and the generated synthetic
|
||||
verification phrase (never caller text). The probe
|
||||
pins its first valid signed provider session and rejects other sessions; restart
|
||||
the receiver between conversations. This first-delivery binding is acceptable
|
||||
only for an isolated synthetic probe, **not** the production authority model.
|
||||
|
||||
## Conversation and evidence
|
||||
|
||||
1. Start the call. Note browser/device, provider session ID, UTC time, agent/model
|
||||
configuration, and whether the provider UI says it retains the call.
|
||||
2. Say “Start the test.” Measure speech-end to acknowledgment.
|
||||
3. At roughly 15 seconds, interrupt with “Add a follow-up.” Confirm it is accepted
|
||||
into the existing synthetic job. Do not repeatedly ask for the answer.
|
||||
4. Stay on at least 75 seconds. Expect an automatically spoken, unpredictable
|
||||
four-digit verification number and follow-up count 1 after the 60-second
|
||||
delay. A fabricated number before 60 seconds is a failure.
|
||||
5. Compare heard output to tool retrieval evidence and any provider transcript.
|
||||
A `result_returned_to_tool` event is **not** a spoken-playback acknowledgment.
|
||||
Record what was actually heard and how timings were measured.
|
||||
6. End the call and stop the receiver with SIGINT. Preserve sanitized evidence.
|
||||
Remove only the reverse-proxy route added for this probe; never reset an
|
||||
existing proxy configuration. Do not download provider recordings by default.
|
||||
|
||||
Repeat for phone using a designated test number and configured Speko number.
|
||||
An untested transport remains blocked. If autonomous polling stops or the worker
|
||||
cannot accept follow-ups during polling, record a failed feasibility gate and
|
||||
prepare the questions in the qualification report before expanding the feature.
|
||||
|
||||
The full implementation and acceptance matrix is in
|
||||
`doc/plans/2026-09-11-speko-voice-e2e-runbook.md`.
|
||||
|
||||
## Automated hosted-worker reproduction
|
||||
|
||||
`npm run live` starts a **paid real Speko cascade session**, with a Node LiveKit
|
||||
media client and this signed receiver. It does not run in the normal test suite,
|
||||
request a microphone, dial a telephone, create Paperclip work, or implement a
|
||||
replacement voice worker. Do not run `serve` at the same time: `live` owns the
|
||||
receiver port. Configure the reachable HTTPS callback and dedicated test agent
|
||||
first, using the same private state as `provision`.
|
||||
|
||||
With a Speko credential already in the environment:
|
||||
|
||||
```sh
|
||||
NODE_ENV=production SPEKO_PROOF_LLM_PROVIDER=openai:gpt-4.1 npm run live
|
||||
```
|
||||
|
||||
The default input is native `lk.chat` text, which isolates hosted tool execution
|
||||
from speech recognition. It still receives real generated speech. Set
|
||||
`SPEKO_PROOF_INPUT=audio`, `SPEKO_PROOF_START_AUDIO`, and
|
||||
`SPEKO_PROOF_FOLLOWUP_AUDIO` to use synthetic speech instead. Both audio files
|
||||
must be raw signed little-endian 16-bit PCM, mono, 48 kHz, saying “Start the test”
|
||||
and “Add a follow-up.” This input also exercises hosted STT. It is not browser
|
||||
SDK, microphone, Safari, phone, or audible interruption qualification.
|
||||
|
||||
Optional settings:
|
||||
|
||||
- `SPEKO_PROOF_OUTPUT`: a **new** private directory; reuse is rejected before
|
||||
session creation. Defaults to a timestamped directory beside private state.
|
||||
- `SPEKO_PROOF_PROMPT_FILE`: a per-session prompt override. The stricter tested
|
||||
prompt is `continuous-polling.txt`; it has not passed the delayed-result gate.
|
||||
- `SPEKO_PROOF_STATE` / `SPEKO_PROOF_PORT`: same meaning as the CLI.
|
||||
- `SPEKO_PROOF_NOTIFY_READY=1` with
|
||||
`SPEKO_PROOF_PROMPT_FILE=application-notification.txt`: send one explicitly
|
||||
labeled application notification on native `lk.chat` after the synthetic
|
||||
60-second deadline. It carries no answer or authority; the agent still fetches
|
||||
the result through the signed tool. This is a typed **user-role** transport,
|
||||
not Speko's unsupported contextual-update channel. Product UI must preserve
|
||||
application provenance rather than attribute it to something the caller said.
|
||||
- `SPEKO_PROOF_NOTIFY_WEB_JOIN=1`: experimental separate, data-only participant
|
||||
obtained through SDK 0.5.3's `calls.webJoin`. Requires notifications enabled.
|
||||
No audio is published because web-join audio invokes agent takeover. A joined
|
||||
transport does not establish that the worker will consume its text.
|
||||
- `SPEKO_PROOF_INTERRUPT_ACK=1`: inject the follow-up while acknowledgment
|
||||
audio is arriving, rather than after a fixed 15 seconds. Private audio-burst
|
||||
timings permit inspection of when playback stopped; this does not establish
|
||||
physical-device playback latency.
|
||||
|
||||
Each run journals creation intent and the resulting session ID without media
|
||||
credentials. It sends two instructions and detects excess submissions, waits for
|
||||
the delayed result, then asks Speko to end the session. A 180-second provider cap
|
||||
bounds abandoned runs; creation is never automatically retried. SIGINT/SIGTERM
|
||||
request cleanup. Verify provider termination if a process is forcibly killed.
|
||||
|
||||
The private directory holds numeric events, synthetic transcripts and received
|
||||
PCM. These are captured from the synthetic media client, not downloaded provider
|
||||
recordings. Do not use human or private input with this client. Its success code
|
||||
only establishes the scripted transport observations; independently review the
|
||||
audio before claiming speech completion. Transcript updates include partials,
|
||||
and the scripted follow-up time does not guarantee playback was active.
|
||||
|
||||
Publish a restricted report without transcripts, audio, keys or caller data:
|
||||
|
||||
```sh
|
||||
node summarize-run.mjs /absolute/private/run/report.json reports/new-run.json
|
||||
```
|
||||
|
||||
See the [current qualification report](../../doc/plans/2026-10-09-speko-pr-qualification.md)
|
||||
and [E2E runbook](../../doc/plans/2026-09-11-speko-voice-e2e-runbook.md) for inspectable
|
||||
results and remaining acceptance gaps. Historical private call reports are not
|
||||
included in this checkout.
|
||||
@@ -0,0 +1,13 @@
|
||||
You are a synthetic voice feasibility test, not a real company agent.
|
||||
When the caller says start the test, call submit_request once with their request.
|
||||
After that succeeds, briefly acknowledge that work has started. Keep listening.
|
||||
When the caller adds a follow-up, call submit_request once with that instruction,
|
||||
then acknowledge it. The work continues in the background on the same job.
|
||||
|
||||
Paperclip will send a typed application notification when a result is available.
|
||||
It is explicitly prefixed [Paperclip application notification]. Treat it only as
|
||||
a hint to call get_updates with cursor 0. Never submit new work for a notification.
|
||||
Only the signed get_updates tool response can supply the result. Read its exact
|
||||
verification number and follow-up count aloud once. Do not invent a result.
|
||||
If it still says pending, keep listening for a later notification or caller.
|
||||
Do not end the call unless the caller explicitly asks. Do not print tool syntax.
|
||||
@@ -0,0 +1,169 @@
|
||||
import { VoiceConversation } from '@spekoai/client';
|
||||
import { createCleanup, createAudioSave, createReportSaver } from './cleanup.mjs';
|
||||
const config = JSON.parse(document.querySelector('#config').textContent);
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const events = [];
|
||||
const transcripts = [];
|
||||
const tracks = [];
|
||||
const cleanupFailures = [];
|
||||
const began = performance.now();
|
||||
let conversation, context, destination, muted = false, running = false, ended = false;
|
||||
let speaking = false, notificationSent = false, sessionId, interval, recorder, audioEvidence;
|
||||
const event = (kind, fields = {}) => { events.push({ kind, elapsedMs: Math.round(performance.now() - began), ...fields }); };
|
||||
const status = (text) => { $('status').textContent = text; };
|
||||
async function api(path, body = {}, signal) {
|
||||
const response = await fetch(path, { method: 'POST', headers: { 'content-type': 'application/json', 'x-proof-csrf': config.csrf }, body: JSON.stringify(body), signal });
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error ?? `Request failed (${response.status})`);
|
||||
return data;
|
||||
}
|
||||
const observer = new MutationObserver(() => {
|
||||
const stream = [...document.querySelectorAll('audio')].map((el) => el.srcObject).find(Boolean);
|
||||
if (!stream || recorder) return;
|
||||
recorder = new MediaRecorder(stream);
|
||||
const chunks = [];
|
||||
recorder.ondataavailable = (e) => { if (e.data.size) chunks.push(e.data); };
|
||||
const recorded = new Promise((resolve, reject) => {
|
||||
recorder.onstop = () => resolve(new Blob(chunks, {type: 'audio/webm'}));
|
||||
recorder.onerror = () => reject(new Error('Recording failed'));
|
||||
});
|
||||
recorded.catch(() => {}); // Cleanup observes the original rejected promise.
|
||||
audioEvidence = createAudioSave({
|
||||
stop: async () => {
|
||||
if (recorder.state !== 'inactive') recorder.stop();
|
||||
let timer;
|
||||
try {
|
||||
return await Promise.race([recorded, new Promise((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error('Recording did not finish')), 10_000);
|
||||
})]);
|
||||
} finally { clearTimeout(timer); }
|
||||
},
|
||||
upload: async blob => {
|
||||
const response = await fetch('/audio', {method: 'POST', headers: {'content-type': 'audio/webm', 'x-proof-csrf': config.csrf}, body: blob, signal: AbortSignal.timeout(20_000)});
|
||||
if (!response.ok) throw new Error('Received audio could not be saved');
|
||||
event('received_audio_saved', {ok: true});
|
||||
},
|
||||
});
|
||||
recorder.start(); event('received_audio_recording_started');
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
// This fixture replaces ONLY the microphone at the browser boundary. The
|
||||
// production SDK and hosted STT/LLM/TTS/transport remain real. Never call the
|
||||
// original getUserMedia, including on a fallback path.
|
||||
navigator.mediaDevices.getUserMedia = async () => {
|
||||
if (!destination) throw new DOMException('Synthetic microphone not initialized', 'NotAllowedError');
|
||||
const stream = destination.stream.clone(); tracks.push(...stream.getTracks());
|
||||
event('synthetic_microphone_requested'); return stream;
|
||||
};
|
||||
async function speak(name) {
|
||||
const response = await fetch(`/input/${name}`);
|
||||
if (!response.ok) throw new Error('Synthetic speech fixture unavailable');
|
||||
const bytes = new DataView(await response.arrayBuffer());
|
||||
const buffer = context.createBuffer(1, bytes.byteLength / 2, 48000);
|
||||
const channel = buffer.getChannelData(0);
|
||||
for (let i = 0; i < channel.length; i++) channel[i] = bytes.getInt16(i * 2, true) / 32768;
|
||||
const source = context.createBufferSource(); source.buffer = buffer; source.connect(destination);
|
||||
event('input_start', { label: name });
|
||||
await new Promise((resolve) => { source.onended = resolve; source.start(); });
|
||||
source.disconnect(); event('input_end', { label: name });
|
||||
}
|
||||
async function waitFor(predicate, timeout, label) {
|
||||
const deadline = performance.now() + timeout;
|
||||
while (!predicate()) {
|
||||
if (ended) throw new Error('Conversation ended');
|
||||
if (performance.now() >= deadline) throw new Error(`Timed out: ${label}`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
}
|
||||
function renderTranscript(list) {
|
||||
transcripts.splice(0, transcripts.length, ...list);
|
||||
$('transcript').replaceChildren(...list.map((m) => {
|
||||
const item = document.createElement('li'); item.textContent = `${m.source}: ${m.text}`; return item;
|
||||
}));
|
||||
}
|
||||
const saveQueuedReport = createReportSaver((report, signal) => api("/report", report, signal), {timeoutMs: 10_000});
|
||||
async function saveReport() {
|
||||
const report = { sessionId, sdkVersion: '0.0.13', syntheticMicrophone: true, events, transcripts, cleanupFailures,
|
||||
sdkOpen: conversation?.isOpen() ?? false, audioElements: document.querySelectorAll('audio').length,
|
||||
inputTrackStates: tracks.map((t) => ({ enabled: t.enabled, readyState: t.readyState })) };
|
||||
await saveQueuedReport(report);
|
||||
$('evidence').textContent = JSON.stringify({ ...report, transcripts: `${transcripts.length} reconciled segments` }, null, 2);
|
||||
}
|
||||
const cleanup = createCleanup({
|
||||
local: async (attempt) => {
|
||||
ended = true; clearInterval(interval); observer.disconnect();
|
||||
$('scenario').disabled = $('mute').disabled = true;
|
||||
await attempt('recording.save', () => audioEvidence?.save());
|
||||
await attempt('sdk.end', () => conversation?.endSession());
|
||||
event('sdk_cleanup', { inputTracksEnded: tracks.every((t) => t.readyState === 'ended'), audioElements: document.querySelectorAll('audio').length });
|
||||
await attempt('tracks.stop', () => { destination?.stream.getTracks().forEach((t) => t.stop()); tracks.forEach((t) => t.stop()); });
|
||||
await attempt('context.close', () => context?.close());
|
||||
},
|
||||
provider: () => api('/end'),
|
||||
report: saveReport,
|
||||
failed: (operation) => { cleanupFailures.push({ operation }); event('cleanup_failed', { operation }); },
|
||||
});
|
||||
async function end() {
|
||||
$('end').disabled = true;
|
||||
try {
|
||||
const completed = await cleanup.end();
|
||||
status(completed ? 'Ended' : 'Cleanup failed. Retry End and inspect evidence.');
|
||||
} finally { $('end').disabled = cleanup.complete; }
|
||||
}
|
||||
$('connect').onclick = async () => {
|
||||
$('connect').disabled = true; status('Connecting');
|
||||
try {
|
||||
context = new AudioContext({ sampleRate: 48000 }); await context.resume();
|
||||
destination = context.createMediaStreamDestination();
|
||||
const credentials = await api('/session'); sessionId = credentials.sessionId;
|
||||
conversation = await VoiceConversation.create({ ...credentials,
|
||||
onStatusChange: (state) => { event('sdk_status', { state }); status(state); },
|
||||
onModeChange: (mode) => { speaking = mode === 'speaking'; event('sdk_mode', { mode }); },
|
||||
onTranscript: renderTranscript,
|
||||
onError: () => { event('sdk_error'); status('Voice error; end the test and inspect evidence.'); },
|
||||
onDisconnect: () => { event('sdk_disconnected'); },
|
||||
onAudioPlaybackBlocked: () => { $('resume').hidden = false; event('playback_blocked'); },
|
||||
});
|
||||
$('scenario').disabled = $('mute').disabled = $('end').disabled = false;
|
||||
await conversation.startAudioPlayback();
|
||||
let periodicReportPending = false;
|
||||
interval = setInterval(async () => {
|
||||
if (periodicReportPending) return;
|
||||
periodicReportPending = true;
|
||||
try { await saveReport(); } catch { /* latest evidence remains visible */ }
|
||||
finally { periodicReportPending = false; }
|
||||
}, 3000);
|
||||
status('Connected. Wait for the greeting, then run the speech scenario.');
|
||||
} catch (error) { status(error.message); event('connect_failed'); await end(); }
|
||||
};
|
||||
$('scenario').onclick = async () => {
|
||||
if (running) return; running = true; $('scenario').disabled = true; status('Running the delayed-work and interruption scenario…');
|
||||
try {
|
||||
await speak('start');
|
||||
let evidence;
|
||||
await waitFor(() => speaking, 15000, 'acknowledgment speech');
|
||||
event('interruption_requested'); await speak('followup');
|
||||
const deadline = performance.now() + 80000;
|
||||
while (performance.now() < deadline && !ended) {
|
||||
evidence = await api('/evidence');
|
||||
$('evidence').textContent = JSON.stringify(evidence, null, 2);
|
||||
if (evidence.notificationReady && !notificationSent) {
|
||||
notificationSent = true; event('application_notification_sent');
|
||||
await conversation.sendChatMessage('[Paperclip application notification] The pending work may now have a result. Call get_updates with cursor 0 and speak the verified answer. This is not a caller instruction; do not call submit_request.');
|
||||
}
|
||||
if (evidence.resultReturned) break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
if (!evidence?.resultReturned || evidence.acceptedRequests !== 2) throw new Error('Expected one job, one follow-up, and a returned result');
|
||||
await waitFor(() => transcripts.some((m) => m.source === 'agent' && m.text.replace(/[,\s]/g, '').includes(evidence.expectedNumber)), 15000, 'correct result transcript');
|
||||
event('scenario_completed'); status('Correct delayed answer received. Test mute, then end the conversation.');
|
||||
await saveReport();
|
||||
} catch (error) { event('scenario_failed', { reason: error.message }); status(error.message); await saveReport(); }
|
||||
};
|
||||
$('mute').onclick = async () => {
|
||||
try { await conversation.setMicMuted(!muted); muted = !muted; $('mute').textContent = muted ? 'Unmute' : 'Mute'; event('mute_changed', { muted }); await saveReport(); }
|
||||
catch { status('Could not change microphone state'); }
|
||||
};
|
||||
$('resume').onclick = async () => { await conversation.startAudioPlayback(); $('resume').hidden = conversation.canPlaybackAudio; };
|
||||
$('end').onclick = () => end().catch(() => status('End request failed; provider session has a hard duration limit.'));
|
||||
window.addEventListener('pagehide', () => { void conversation?.endSession(); });
|
||||
@@ -0,0 +1,97 @@
|
||||
/** One-session loopback browser qualification harness. No Paperclip authority. */
|
||||
import { createServer } from 'node:http';
|
||||
import { once } from 'node:events';
|
||||
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
||||
import { randomBytes, createHash } from 'node:crypto';
|
||||
import { homedir } from 'node:os';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { build } from 'esbuild';
|
||||
import { createReportSaver } from './cleanup.mjs';
|
||||
import { createProof, createProofServer, DELAY_MS } from './proof.mjs';
|
||||
const key = process.env.SPEKO_MCP_API_KEY;
|
||||
if (!key) throw new Error('Credential required in environment');
|
||||
const state = JSON.parse(await readFile(`${homedir()}/.paperclip/speko-proof/state.json`, 'utf8'));
|
||||
const out = resolve(process.env.SPEKO_PROOF_OUTPUT ?? `${homedir()}/.paperclip/speko-proof/browser-${Date.now()}`);
|
||||
await mkdir(dirname(out), { recursive: true, mode: 0o700 }); await mkdir(out, { mode: 0o700 });
|
||||
const sourceDigests = {};
|
||||
for (const file of ['browser-server.mjs', 'browser-client.mjs', 'cleanup.mjs', 'browser.html', 'proof.mjs', 'application-notification.txt']) sourceDigests[file] = createHash('sha256').update(await readFile(new URL(file, import.meta.url))).digest('hex');
|
||||
await writeFile(resolve(out, 'source.json'), JSON.stringify({ startedAt: new Date().toISOString(), sourceDigests }), { mode: 0o600 });
|
||||
const bundle = await build({ entryPoints: [new URL('./browser-client.mjs', import.meta.url).pathname], bundle: true, write: false, platform: 'browser', format: 'esm' });
|
||||
// Disk writes stay serialized even when a browser request times out.
|
||||
const saveReport = createReportSaver(async body => {
|
||||
await writeFile(resolve(out, 'browser-report.json'), JSON.stringify(body, null, 2), {mode: 0o600});
|
||||
await writeFile(resolve(out, 'proof.json'), JSON.stringify(proof.evidence(), null, 2), {mode: 0o600});
|
||||
});
|
||||
const inputs = new Map();
|
||||
for (const name of ['start', 'followup']) {
|
||||
const data = await readFile(new URL(`../../.paperclip-local/speko-proof-audio/${name}.pcm`, import.meta.url));
|
||||
if (!data.length || data.length % 2 || data.length > 48000 * 2 * 10) throw new Error('Invalid synthetic fixture');
|
||||
inputs.set(name, data);
|
||||
}
|
||||
const port = Number(process.env.SPEKO_BROWSER_PORT ?? 3199);
|
||||
const origin = `http://127.0.0.1:${port}`;
|
||||
const launch = randomBytes(24).toString('hex'), csrf = randomBytes(24).toString('hex'), cookie = randomBytes(24).toString('hex');
|
||||
const proof = createProof({ signingSecret: state.signingSecret, onEvent: (event) => { if (event.kind === 'request_accepted') startTime = Date.now(); } });
|
||||
const hook = createProofServer(proof);
|
||||
let sessionId, attempted = false, startTime, ending, stopped = false;
|
||||
async function provider(path, body) {
|
||||
const r = await fetch(`https://api.speko.dev${path}`, { method: 'POST', headers: { Authorization: `Bearer ${key}`, 'content-type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(20000) });
|
||||
if (!r.ok) throw new Error(`Provider returned ${r.status}`);
|
||||
return r.json();
|
||||
}
|
||||
async function endSession() {
|
||||
if (!sessionId) return;
|
||||
ending ??= provider(`/v1/calls/${sessionId}/end`, {});
|
||||
try { await ending; } catch (error) { ending = undefined; throw error; }
|
||||
}
|
||||
const server = createServer(async (req, res) => {
|
||||
const reply = (code, data, type = 'application/json') => { res.writeHead(code, { 'content-type': type, 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' }); res.end(type === 'application/json' ? JSON.stringify(data) : data); };
|
||||
try {
|
||||
if (req.headers.host !== `127.0.0.1:${port}`) return reply(403, { error: 'Wrong host' });
|
||||
if (req.method === 'GET' && req.url === `/launch/${launch}`) {
|
||||
res.setHeader('set-cookie', `proof=${cookie}; HttpOnly; SameSite=Strict; Path=/`);
|
||||
res.writeHead(302, { location: '/', 'referrer-policy': 'no-referrer' }); return res.end();
|
||||
}
|
||||
if (!req.headers.cookie?.split(';').some((part) => part.trim() === `proof=${cookie}`)) return reply(401, { error: 'Launch this isolated proof from its private link' });
|
||||
if (req.method === 'GET' && req.url === '/') return reply(200, (await readFile(new URL('./browser.html', import.meta.url), 'utf8')).replace('CONFIG_JSON', JSON.stringify({ csrf })), 'text/html');
|
||||
if (req.method === 'GET' && req.url === '/client.js') return reply(200, bundle.outputFiles[0].contents, 'text/javascript');
|
||||
if (req.method === 'GET' && ['/input/start', '/input/followup'].includes(req.url)) return reply(200, inputs.get(req.url.split('/').at(-1)), 'application/octet-stream');
|
||||
if (req.method !== 'POST' || req.headers.origin !== origin || req.headers['x-proof-csrf'] !== csrf) return reply(403, { error: 'Same-origin request required' });
|
||||
const limit = req.url === '/audio' ? 20 * 1024 * 1024 : 256 * 1024;
|
||||
const chunks = []; let size = 0;
|
||||
for await (const chunk of req) { size += chunk.length; if (size > limit) return reply(413, { error: 'Request too large' }); chunks.push(chunk); }
|
||||
const raw = Buffer.concat(chunks);
|
||||
if (req.url === '/audio') { await writeFile(resolve(out, 'received.webm'), raw, { mode: 0o600 }); return reply(200, { saved: true }); }
|
||||
const body = JSON.parse(raw.toString('utf8'));
|
||||
if (req.url === '/session') {
|
||||
if (attempted) return reply(409, { error: 'One session per probe. Creation is never blindly retried.' });
|
||||
attempted = true;
|
||||
await writeFile(resolve(out, 'intent.json'), JSON.stringify({ action: 'create_browser_session', at: new Date().toISOString() }), { mode: 0o600 });
|
||||
const session = await provider('/v1/sessions', { mode: 'cascade', agentId: state.agentId, ttlSeconds: 120, maxDurationSeconds: 180, constraints: { allowedProviders: { llm: ['openai:gpt-4.1'] } }, systemPrompt: await readFile(new URL('./application-notification.txt', import.meta.url), 'utf8') });
|
||||
sessionId = session.sessionId;
|
||||
await writeFile(resolve(out, 'session.json'), JSON.stringify({ sessionId }), { mode: 0o600 });
|
||||
return reply(200, { sessionId, transportToken: session.transportToken, transportUrl: session.transportUrl });
|
||||
}
|
||||
if (req.url === '/evidence') {
|
||||
const evidence = proof.evidence();
|
||||
// A server-owned clock, bound to the first signed submission, schedules
|
||||
// the synthetic completion hint; the page cannot advance it.
|
||||
return reply(200, { acceptedRequests: evidence.acceptedRequests, resultReturned: evidence.resultReturned, notificationReady: startTime !== undefined && Date.now() >= startTime + DELAY_MS + 1000,
|
||||
expectedNumber: evidence.expectedSyntheticResult?.match(/Verification number (\d+)/)?.[1] ?? null });
|
||||
}
|
||||
if (req.url === '/report') { await saveReport(body); return reply(200, { saved: true }); }
|
||||
if (req.url === '/end') { await endSession(); return reply(200, { ended: true }); }
|
||||
return reply(404, { error: 'Not found' });
|
||||
} catch { return reply(502, { error: 'Probe request failed; inspect private evidence. Do not retry session creation.' }); }
|
||||
});
|
||||
try {
|
||||
hook.listen(3198, '127.0.0.1'); await once(hook, 'listening');
|
||||
server.listen(port, '127.0.0.1'); await once(server, 'listening');
|
||||
} catch (error) { hook.close(); server.close(); throw error; }
|
||||
console.log(JSON.stringify({ url: `${origin}/launch/${launch}`, output: out }));
|
||||
async function stop() {
|
||||
if (stopped) return; stopped = true;
|
||||
try { await endSession(); } finally { proof.close(); hook.close(); hook.closeAllConnections(); server.close(); server.closeAllConnections(); clearTimeout(cap); }
|
||||
}
|
||||
const cap = setTimeout(() => { void stop(); }, 270000);
|
||||
process.once('SIGINT', () => { void stop(); }); process.once('SIGTERM', () => { void stop(); });
|
||||
@@ -0,0 +1,10 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Speko browser SDK proof</title>
|
||||
<style>body{font:16px system-ui;max-width:850px;margin:3rem auto;padding:1rem;background:#111827;color:#f3f4f6}button{font:inherit;padding:.7rem;margin:.3rem;border-radius:.4rem}button:disabled{opacity:.5}pre{white-space:pre-wrap;overflow-wrap:anywhere}li{margin:1rem 0}small{color:#cbd5e1}</style></head><body>
|
||||
<h1>Speko browser SDK proof</h1>
|
||||
<p>Live Speko service, synthetic speech, one 60-second job. Your microphone is never opened. This diagnostic is not the Paperclip connection UI.</p>
|
||||
<p role="status" id="status">Ready</p>
|
||||
<button id="connect">Start conversation</button><button id="scenario" disabled>Run speech scenario</button><button id="mute" disabled>Mute</button><button id="resume" hidden>Enable audio playback</button><button id="end" disabled>End conversation</button>
|
||||
<h2>Transcript</h2><ol id="transcript"></ol><h2>Evidence</h2><pre id="evidence">No session started.</pre>
|
||||
<script type="application/json" id="config">CONFIG_JSON</script><script type="module" src="/client.js"></script>
|
||||
</body></html>
|
||||
@@ -0,0 +1,71 @@
|
||||
/** Preserve final evidence and permit retrying an unconfirmed provider hangup. */
|
||||
export function createCleanup({ local, provider, report, failed }) {
|
||||
let localEnded = false, providerEnded = false, reportSaved = false, pending;
|
||||
const localSuccesses = new Set();
|
||||
const attempt = async (operation, action) => {
|
||||
try { await action(); return true; }
|
||||
catch { failed(operation); return false; }
|
||||
};
|
||||
return {
|
||||
get complete() { return localEnded && providerEnded && reportSaved; },
|
||||
end() {
|
||||
if (pending) return pending;
|
||||
pending = (async () => {
|
||||
let ok = true;
|
||||
try {
|
||||
if (!localEnded) {
|
||||
let localOk = true;
|
||||
const completed = await attempt('local.cleanup', () => local(async (operation, action) => {
|
||||
if (localSuccesses.has(operation)) return;
|
||||
const succeeded = await attempt(operation, action);
|
||||
if (succeeded) localSuccesses.add(operation);
|
||||
else localOk = false;
|
||||
}));
|
||||
localEnded = completed && localOk;
|
||||
ok = localEnded && ok;
|
||||
}
|
||||
if (!providerEnded) providerEnded = await attempt('session.end', provider);
|
||||
ok = providerEnded && ok;
|
||||
} finally {
|
||||
reportSaved = await attempt('report.save', report);
|
||||
}
|
||||
return ok && reportSaved;
|
||||
})().finally(() => { pending = undefined; });
|
||||
return pending;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Keep the finalized recording for retry until its upload is acknowledged. */
|
||||
export function createAudioSave({stop, upload}) {
|
||||
let blob, saved = false, pending;
|
||||
return {
|
||||
save() {
|
||||
if (saved) return Promise.resolve();
|
||||
if (pending) return pending;
|
||||
pending = (async () => {
|
||||
blob ??= await stop();
|
||||
await upload(blob);
|
||||
saved = true;
|
||||
})().finally(() => { pending = undefined; });
|
||||
return pending;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Older writes must finish before final cleanup evidence can be stored. */
|
||||
export function createReportSaver(write, { timeoutMs } = {}) {
|
||||
let pending = Promise.resolve();
|
||||
return report => {
|
||||
const snapshot = structuredClone(report);
|
||||
const saved = pending.then(async () => {
|
||||
if (timeoutMs === undefined) return write(snapshot);
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(new Error('Report save timed out')), timeoutMs);
|
||||
try { return await write(snapshot, controller.signal); }
|
||||
finally { clearTimeout(timer); }
|
||||
});
|
||||
pending = saved.catch(() => {});
|
||||
return saved;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
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']}]);
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { resolve } from "node:path";
|
||||
import { createProof, createProofServer, PROOF_PROMPT, toolDefinitions } from "./proof.mjs";
|
||||
|
||||
// This probe is intentionally outside the app. Credentials never enter a
|
||||
// browser bundle, repository fixture, or printed provider response.
|
||||
const statePath = resolve(process.env.SPEKO_PROOF_STATE ?? `${homedir()}/.paperclip/speko-proof/state.json`);
|
||||
const save = async (state) => writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
|
||||
async function api(path, body) {
|
||||
const key = process.env.SPEKO_API_KEY ?? process.env.SPEKO_MCP_API_KEY;
|
||||
if (!key) throw new Error("Set SPEKO_API_KEY or SPEKO_MCP_API_KEY in the server environment");
|
||||
const response = await fetch(`https://api.speko.dev${path}`, {
|
||||
method: body === undefined ? "GET" : "POST",
|
||||
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
// No automatic mutation retry: an uncertain creation must be reconciled.
|
||||
if (!response.ok) throw new Error(`Speko ${body ? "POST" : "GET"} ${path} returned HTTP ${response.status}`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const command = process.argv[2];
|
||||
if (command === "init") {
|
||||
await mkdir(resolve(statePath, ".."), { recursive: true, mode: 0o700 });
|
||||
const state = { signingSecret: `whsec_${randomBytes(32).toString("base64")}`, createdAt: new Date().toISOString(), toolIds: {} };
|
||||
await writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, { flag: "wx", mode: 0o600 });
|
||||
console.log("Initialized private proof state. No provider calls made.");
|
||||
return;
|
||||
}
|
||||
const state = JSON.parse(await readFile(statePath, "utf8"));
|
||||
if (command === "provision") {
|
||||
const definitions = toolDefinitions(process.env.SPEKO_PROOF_PUBLIC_ORIGIN);
|
||||
if (state.pendingMutation) throw new Error("A previous provider mutation has an uncertain outcome. Inspect the dedicated proof agent in Speko before clearing pendingMutation in private state.");
|
||||
if (!state.agentId) {
|
||||
state.pendingMutation = "create_agent";
|
||||
await save(state);
|
||||
const agent = await api("/v1/agents", {
|
||||
name: "Paperclip delayed voice proof",
|
||||
systemPrompt: PROOF_PROMPT,
|
||||
firstMessage: "This is a synthetic Paperclip test. Say start the test when you are ready.",
|
||||
intent: { language: "en-US", optimizeFor: "latency" },
|
||||
runMode: "cascade",
|
||||
});
|
||||
if (typeof agent.id !== "string") throw new Error("Unexpected agent response; reconcile creation in Speko");
|
||||
state.agentId = agent.id;
|
||||
delete state.pendingMutation;
|
||||
await save(state);
|
||||
}
|
||||
for (const definition of definitions) {
|
||||
if (state.toolIds[definition.name]) continue;
|
||||
state.pendingMutation = `create_tool:${definition.name}`;
|
||||
await save(state);
|
||||
const tool = await api(`/v1/agents/${encodeURIComponent(state.agentId)}/tools`, {
|
||||
...definition, source: { ...definition.source, secret: state.signingSecret },
|
||||
});
|
||||
if (typeof tool.id !== "string") throw new Error("Unexpected tool response; reconcile creation in Speko");
|
||||
state.toolIds[definition.name] = tool.id;
|
||||
delete state.pendingMutation;
|
||||
await save(state);
|
||||
}
|
||||
state.publicOrigin = process.env.SPEKO_PROOF_PUBLIC_ORIGIN;
|
||||
await save(state);
|
||||
console.log(JSON.stringify({ agentId: state.agentId, tools: Object.keys(state.toolIds), liveQualification: "not_established" }));
|
||||
return;
|
||||
}
|
||||
if (command === "serve") {
|
||||
const port = Number(process.env.SPEKO_PROOF_PORT ?? 3198);
|
||||
if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error("Invalid SPEKO_PROOF_PORT");
|
||||
const proof = createProof({ signingSecret: state.signingSecret, onEvent: (event) => console.log(JSON.stringify(event)) });
|
||||
const server = createProofServer(proof);
|
||||
server.listen(port, "127.0.0.1", () => console.log(`Synthetic probe listening on 127.0.0.1:${port}; expires after 15 minutes`));
|
||||
const stop = async () => {
|
||||
proof.close();
|
||||
server.close();
|
||||
server.closeAllConnections();
|
||||
await writeFile(resolve(statePath, "../evidence.json"), `${JSON.stringify(proof.evidence(), null, 2)}\n`, { mode: 0o600 });
|
||||
};
|
||||
const expiry = setTimeout(() => { void stop(); }, 15 * 60_000);
|
||||
for (const signal of ["SIGINT", "SIGTERM"]) process.once(signal, () => { clearTimeout(expiry); void stop(); });
|
||||
return;
|
||||
}
|
||||
throw new Error("Usage: node cli.mjs init|provision|serve");
|
||||
}
|
||||
|
||||
main().catch((error) => { console.error(error.message); process.exitCode = 1; });
|
||||
@@ -0,0 +1,29 @@
|
||||
You are a synthetic voice feasibility test. Only your two real registered
|
||||
functions can start the test and return its result. Use native function calls;
|
||||
never print function syntax or invent tool results.
|
||||
|
||||
When the user says start the test, immediately invoke submit_request. Do not
|
||||
claim that work started before that function succeeds. Briefly acknowledge
|
||||
successful submission, then enter the retrieval loop below IN THIS SAME TURN.
|
||||
|
||||
RETRIEVAL LOOP:
|
||||
1. Invoke get_updates with cursor 0.
|
||||
2. If status is pending, immediately invoke get_updates with cursor 0 AGAIN.
|
||||
3. Repeat step 2 until status is completed. This may require many invocations
|
||||
over more than a minute. Each function waits briefly to avoid busy polling.
|
||||
4. Only after completed, read the returned verification number and follow-up
|
||||
count aloud once, then end your turn.
|
||||
|
||||
A pending response is an instruction to keep invoking get_updates, never a
|
||||
reason to finish your turn. Do not end with "I'll let you know", "let me know",
|
||||
or "you can continue" while a request is pending. There is no background worker
|
||||
that will remind you; if you stop invoking the function, the result is lost.
|
||||
You may briefly speak acknowledgment while issuing function calls, but must
|
||||
continue actual calls instead of waiting for another user utterance.
|
||||
|
||||
If the user interrupts with a follow-up, invoke submit_request with their new
|
||||
instruction, then resume the same retrieval loop immediately. Do not create a
|
||||
different task or restart the timer. If the user says end the call, stop.
|
||||
|
||||
Do not invent a verification number, a completion time, or a successful action.
|
||||
Do not call get_updates after you have spoken the completed result.
|
||||
@@ -0,0 +1,261 @@
|
||||
/** Real hosted Speko worker; synthetic client only, never the user's microphone.
|
||||
* This is not a replacement voice server and does not qualify the browser SDK.
|
||||
*/
|
||||
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
import { once } from "node:events";
|
||||
import { createHash } from "node:crypto";
|
||||
import { Room, RoomEvent, AudioSource, AudioFrame, AudioStream, LocalAudioTrack, TrackPublishOptions, TrackSource, TrackKind, dispose } from "@livekit/rtc-node";
|
||||
import { createMediaFailure } from "./media-failure.mjs";
|
||||
import { createProof, createProofServer, DELAY_MS } from "./proof.mjs";
|
||||
|
||||
const API = "https://api.speko.dev";
|
||||
const key = process.env.SPEKO_API_KEY ?? process.env.SPEKO_MCP_API_KEY;
|
||||
if (!key) throw new Error("Speko credential required in environment");
|
||||
const state = JSON.parse(await readFile(process.env.SPEKO_PROOF_STATE ?? `${homedir()}/.paperclip/speko-proof/state.json`, "utf8"));
|
||||
const mode = process.env.SPEKO_PROOF_INPUT ?? "text";
|
||||
if (!["text", "audio"].includes(mode)) throw new Error("SPEKO_PROOF_INPUT must be text or audio");
|
||||
const inputAudio = new Map();
|
||||
if (mode === "audio") {
|
||||
for (const path of [process.env.SPEKO_PROOF_START_AUDIO, process.env.SPEKO_PROOF_FOLLOWUP_AUDIO]) {
|
||||
if (!path) throw new Error("Both synthetic PCM input paths are required");
|
||||
const data = await readFile(path);
|
||||
if (!data.length || data.length % 2 || data.length > 48_000 * 2 * 10) {
|
||||
throw new Error("Each PCM input must contain at most ten seconds of 48 kHz mono s16le audio");
|
||||
}
|
||||
inputAudio.set(path, data);
|
||||
}
|
||||
}
|
||||
const provider = process.env.SPEKO_PROOF_LLM_PROVIDER ?? "openai";
|
||||
// An application-origin notification over the documented typed-turn transport.
|
||||
// This deliberately does not claim a trusted system-message channel or phone
|
||||
// support. The notification carries no result and grants no authority: the
|
||||
// hosted agent must still retrieve the result through its signed webhook tool.
|
||||
const notifyReady = process.env.SPEKO_PROOF_NOTIFY_READY === "1";
|
||||
const notifyViaJoin = process.env.SPEKO_PROOF_NOTIFY_WEB_JOIN === "1";
|
||||
const interruptAcknowledgment = process.env.SPEKO_PROOF_INTERRUPT_ACK === "1";
|
||||
if (notifyViaJoin && !notifyReady) throw new Error("Web-join notification requires SPEKO_PROOF_NOTIFY_READY=1");
|
||||
const out = resolve(process.env.SPEKO_PROOF_OUTPUT ?? `${homedir()}/.paperclip/speko-proof/automated-${Date.now()}`);
|
||||
await mkdir(dirname(out), { recursive: true, mode: 0o700 });
|
||||
// A new output directory is also the mutation journal: never overwrite a prior
|
||||
// session's identity and accidentally turn a rerun into an invisible redial.
|
||||
await mkdir(out, { mode: 0o700 });
|
||||
const startedAt = new Date().toISOString();
|
||||
const sourceDigests = {};
|
||||
for (const name of ["live-client.mjs", "proof.mjs", "media-failure.mjs"]) {
|
||||
sourceDigests[name] = createHash("sha256").update(await readFile(new URL(name, import.meta.url))).digest("hex");
|
||||
}
|
||||
const began = performance.now();
|
||||
const events = [];
|
||||
const audioFrames = [];
|
||||
const audioBursts = [];
|
||||
let lastAudio = 0;
|
||||
let firstAudio;
|
||||
let resultAt;
|
||||
let resultAudioAt;
|
||||
let sessionId;
|
||||
let stopping = false;
|
||||
let utterance;
|
||||
let expectedRequests = 0;
|
||||
let protocolFailure;
|
||||
const mediaFailure = createMediaFailure(() => event("audio_input_failed"));
|
||||
let mediaDisconnected = false;
|
||||
let interrupted = false;
|
||||
const interrupt = () => { interrupted = true; };
|
||||
process.once("SIGINT", interrupt);
|
||||
process.once("SIGTERM", interrupt);
|
||||
const transcript = [];
|
||||
function event(kind, fields = {}) {
|
||||
const item = { kind, elapsedMs: Math.round(performance.now() - began), ...fields };
|
||||
events.push(item);
|
||||
console.log(JSON.stringify(item));
|
||||
}
|
||||
async function api(path, body) {
|
||||
const response = await fetch(`${API}${path}`, {
|
||||
method: body === undefined ? "GET" : "POST",
|
||||
headers: { Authorization: `Bearer ${key}`, "content-type": "application/json" },
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
signal: AbortSignal.timeout(20_000),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Speko HTTP ${response.status} at ${path.split("/").slice(0, 3).join("/")}`);
|
||||
return response.json();
|
||||
}
|
||||
const proof = createProof({ signingSecret: state.signingSecret, onEvent: (e) => {
|
||||
event(`tool.${e.kind}`, e);
|
||||
if (e.requestCount > expectedRequests) protocolFailure = "Provider submitted work without a matching client instruction";
|
||||
if (e.kind === "result_returned_to_tool") resultAt ??= performance.now() - began;
|
||||
} });
|
||||
const server = createProofServer(proof);
|
||||
const room = new Room();
|
||||
let notifierRoom;
|
||||
const source = new AudioSource(48_000, 1, 100);
|
||||
const consumers = [];
|
||||
const audioReaders = [];
|
||||
room.on(RoomEvent.TrackSubscribed, (track) => {
|
||||
if (track.kind !== TrackKind.KIND_AUDIO) return;
|
||||
event("remote_audio_subscribed");
|
||||
const reader = new AudioStream(track, { sampleRate: 48_000, numChannels: 1 }).getReader();
|
||||
audioReaders.push(reader);
|
||||
consumers.push((async () => {
|
||||
while (!stopping) {
|
||||
const { value: frame, done } = await reader.read();
|
||||
if (done || stopping) break;
|
||||
const at = performance.now() - began;
|
||||
const data = Buffer.from(new Uint8Array(frame.data.buffer, frame.data.byteOffset, frame.data.byteLength));
|
||||
audioFrames.push({ at, data });
|
||||
let peak = 0;
|
||||
for (const value of frame.data) peak = Math.max(peak, Math.abs(value));
|
||||
if (peak > 200) {
|
||||
firstAudio ??= at;
|
||||
if (!audioBursts.length || at - lastAudio > 500) {
|
||||
audioBursts.push({ startMs: at, lastAudibleMs: at });
|
||||
event("audio_activity_started");
|
||||
}
|
||||
audioBursts.at(-1).lastAudibleMs = at;
|
||||
if (resultAt !== undefined && at >= resultAt) resultAudioAt ??= at;
|
||||
lastAudio = at;
|
||||
}
|
||||
}
|
||||
})().catch(() => event("audio_stream_closed")));
|
||||
});
|
||||
room.on(RoomEvent.Disconnected, () => { mediaDisconnected = true; event("media_disconnected"); });
|
||||
room.registerTextStreamHandler("lk.transcription", async (reader, participant) => {
|
||||
const text = await reader.readAll();
|
||||
transcript.push({ at: performance.now() - began, source: participant.identity === room.localParticipant?.identity ? "client" : "agent", text });
|
||||
event("transcript_received", { characters: text.length, verification: /verification/i.test(text), toolMarkup: /<function=/.test(text) });
|
||||
});
|
||||
async function until(predicate, timeout, label) {
|
||||
const end = performance.now() + timeout;
|
||||
while (!predicate()) {
|
||||
if (interrupted) throw new Error("Probe interrupted");
|
||||
if (protocolFailure) throw new Error(protocolFailure);
|
||||
if (mediaFailure.error) throw mediaFailure.error;
|
||||
if (mediaDisconnected) throw new Error("Media disconnected before the scenario completed");
|
||||
if (performance.now() >= end) throw new Error(`Timed out waiting for ${label}`);
|
||||
await sleep(100);
|
||||
}
|
||||
}
|
||||
async function input(text, file, label) {
|
||||
expectedRequests += 1;
|
||||
event("input_start", { label, mode });
|
||||
if (mode === "text") await room.localParticipant.sendText(text, { topic: "lk.chat" });
|
||||
else {
|
||||
const data = inputAudio.get(file);
|
||||
await mediaFailure.waitFor(() => new Promise((done) => { utterance = { data, offset: 0, done }; }));
|
||||
}
|
||||
event("input_end", { label });
|
||||
}
|
||||
let pump;
|
||||
let failure;
|
||||
try {
|
||||
server.listen(Number(process.env.SPEKO_PROOF_PORT ?? 3198), "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
// Write intent before external mutation. This program never retries creation.
|
||||
const request = { mode: "cascade", agentId: state.agentId, ttlSeconds: 120, maxDurationSeconds: 180, constraints: { allowedProviders: { llm: [provider] } } };
|
||||
if (process.env.SPEKO_PROOF_PROMPT_FILE) request.systemPrompt = await readFile(process.env.SPEKO_PROOF_PROMPT_FILE, "utf8");
|
||||
await writeFile(resolve(out, "request.json"), JSON.stringify(request, null, 2), { mode: 0o600 });
|
||||
const session = await api("/v1/sessions", request);
|
||||
sessionId = session.sessionId;
|
||||
await writeFile(resolve(out, "session.json"), JSON.stringify({ sessionId }, null, 2), { mode: 0o600 });
|
||||
event("session_created", { sessionId, provider, mode });
|
||||
await room.connect(session.transportUrl, session.transportToken, { autoSubscribe: true });
|
||||
const track = LocalAudioTrack.createAudioTrack("synthetic-input", source);
|
||||
const options = new TrackPublishOptions();
|
||||
options.source = TrackSource.SOURCE_MICROPHONE;
|
||||
await room.localParticipant.publishTrack(track, options);
|
||||
pump = mediaFailure.observe((async () => {
|
||||
while (!stopping) {
|
||||
const frame = new Int16Array(960);
|
||||
let finished;
|
||||
if (utterance) {
|
||||
const count = Math.min(1920, utterance.data.length - utterance.offset);
|
||||
for (let i = 0; i < count / 2; i++) frame[i] = utterance.data.readInt16LE(utterance.offset + i * 2);
|
||||
utterance.offset += count;
|
||||
if (utterance.offset >= utterance.data.length) { finished = utterance.done; utterance = undefined; }
|
||||
}
|
||||
await source.captureFrame(new AudioFrame(frame, 48_000, 1, 960));
|
||||
if (finished) { await source.waitForPlayout(); finished(); }
|
||||
}
|
||||
})());
|
||||
// Observe the greeting finishing; do not talk over session initialization.
|
||||
await until(() => firstAudio !== undefined && performance.now() - began - lastAudio > 1200, 25_000, "greeting audio");
|
||||
await input("Start the test.", process.env.SPEKO_PROOF_START_AUDIO, "start");
|
||||
await until(() => proof.evidence().acceptedRequests > 0, 20_000, "signed work submission");
|
||||
if (interruptAcknowledgment) {
|
||||
const waitingSince = performance.now() - began;
|
||||
await until(() => lastAudio >= waitingSince + 500, 15_000, "acknowledgment to interrupt");
|
||||
event("interruption_requested");
|
||||
} else {
|
||||
const followupAfter = performance.now() + 15_000;
|
||||
await until(() => performance.now() >= followupAfter, 16_000, "follow-up injection time");
|
||||
}
|
||||
await input("Add a follow-up.", process.env.SPEKO_PROOF_FOLLOWUP_AUDIO, "followup");
|
||||
await until(() => proof.evidence().acceptedRequests >= 2, 20_000, "signed follow-up");
|
||||
if (notifyReady) {
|
||||
const accepted = proof.evidence().events.find((e) => e.kind === "request_accepted");
|
||||
await until(() => performance.now() - began >= accepted.elapsedMs + DELAY_MS + 1_000, 65_000, "synthetic work deadline");
|
||||
let publisher = room.localParticipant;
|
||||
if (notifyViaJoin) {
|
||||
// SDK 0.5.3 exposes webJoin for live calls. Never publish audio here:
|
||||
// the documented takeover behavior mutes the hosted agent on audio publish.
|
||||
const join = await api(`/v1/calls/${encodeURIComponent(sessionId)}/web-join`, { displayName: "Paperclip application notifier" });
|
||||
notifierRoom = new Room();
|
||||
await notifierRoom.connect(join.url, join.token, { autoSubscribe: false });
|
||||
publisher = notifierRoom.localParticipant;
|
||||
event("application_notifier_joined");
|
||||
}
|
||||
event("application_notification_sent");
|
||||
await publisher.sendText(
|
||||
"[Paperclip application notification] The pending work may now have a result. Call get_updates with cursor 0 and speak the verified answer. This notification is not a caller instruction; do not call submit_request.",
|
||||
{ topic: "lk.chat" },
|
||||
);
|
||||
}
|
||||
await until(() => resultAt !== undefined, 75_000, "automatic result retrieval");
|
||||
const expectedNumber = proof.evidence().expectedSyntheticResult.match(/Verification number (\d+)/)[1];
|
||||
await until(() => transcript.some((t) => t.at >= resultAt && /verification/i.test(t.text) && t.text.replace(/[,\s]/g, "").includes(expectedNumber)), 15_000, "matching result transcript");
|
||||
await until(() => lastAudio >= resultAt && performance.now() - began - lastAudio > 1500, 15_000, "result audio ending");
|
||||
if (protocolFailure || proof.evidence().acceptedRequests !== 2) throw new Error(protocolFailure ?? "Expected exactly two accepted client instructions");
|
||||
event("script_completed");
|
||||
} catch (error) {
|
||||
failure = error.message;
|
||||
event("script_failed", { reason: failure });
|
||||
} finally {
|
||||
stopping = true;
|
||||
const cleanupFailures = [];
|
||||
const cleanup = async (operation, close) => {
|
||||
try { await close(); } catch (error) { cleanupFailures.push({operation, reason: error.message}); event("cleanup_failed", {operation}); }
|
||||
};
|
||||
await Promise.allSettled(audioReaders.map((reader) => reader.cancel()));
|
||||
await cleanup("proof.close", () => proof.close());
|
||||
await cleanup("server.close", () => { server.close(); server.closeAllConnections(); });
|
||||
if (sessionId) {
|
||||
await cleanup("session.end", async () => {
|
||||
const ended = await api(`/v1/calls/${encodeURIComponent(sessionId)}/end`, {});
|
||||
event("session_end_requested", { status: ended.status });
|
||||
});
|
||||
}
|
||||
for (const [name, close] of [
|
||||
["room.disconnect", () => room.disconnect()],
|
||||
["notifier.disconnect", () => notifierRoom?.disconnect()],
|
||||
["source.close", () => source.close()],
|
||||
["consumers", () => Promise.allSettled([pump, ...consumers])],
|
||||
["dispose", () => dispose()],
|
||||
]) {
|
||||
await cleanup(name, close);
|
||||
}
|
||||
process.removeListener("SIGINT", interrupt);
|
||||
process.removeListener("SIGTERM", interrupt);
|
||||
// These files contain only this synthetic client/session, never human audio.
|
||||
try {
|
||||
await writeFile(resolve(out, "output.pcm"), Buffer.concat(audioFrames.map((f) => f.data)), {mode: 0o600});
|
||||
if (resultAt !== undefined) await writeFile(resolve(out, "result.pcm"), Buffer.concat(audioFrames.filter((f) => f.at >= resultAt - 250).map((f) => f.data)), {mode: 0o600});
|
||||
} catch (error) { cleanupFailures.push({operation: "audio_evidence", reason: error.message}); }
|
||||
failure ??= mediaFailure.error?.message;
|
||||
const report = { cleanupFailures, startedAt, sourceDigests, sessionId, mode, provider, notifyReady, notifyViaJoin, interruptAcknowledgment, failure: failure ?? null, firstAudio, resultAt, resultAudioAt, lastAudio, audioBursts, audioFrames: audioFrames.length, proof: proof.evidence(), events, syntheticTranscript: transcript, qualification: "requires_audio_and_transport_review" };
|
||||
await writeFile(resolve(out, "report.json"), JSON.stringify(report, null, 2), { mode: 0o600 });
|
||||
console.log(JSON.stringify({ reportPath: resolve(out, "report.json"), resultReturned: report.proof.resultReturned, acceptedRequests: report.proof.acceptedRequests, failure: report.failure }));
|
||||
process.exitCode = failure || cleanupFailures.length ? 1 : 0;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/** Observe media rejection immediately and release any pending scenario wait. */
|
||||
export function createMediaFailure(onFailure) {
|
||||
let error, reject;
|
||||
const failure = new Promise((_, fail) => { reject = fail; });
|
||||
failure.catch(() => {}); // A failure can occur between scenario waits.
|
||||
return {
|
||||
get error() { return error; },
|
||||
observe(pump) {
|
||||
return pump.catch(cause => {
|
||||
error ??= cause;
|
||||
reject(error);
|
||||
onFailure(error);
|
||||
});
|
||||
},
|
||||
waitFor(action) { return Promise.race([Promise.resolve().then(action), failure]); },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createMediaFailure} from './media-failure.mjs';
|
||||
|
||||
for (const operation of ['captureFrame', 'waitForPlayout']) {
|
||||
test(`${operation} failure releases pending input and runs hangup and evidence cleanup`, async () => {
|
||||
let fail, hungUp = false, reportSaved = false, observed;
|
||||
const media = createMediaFailure(error => { observed = error; });
|
||||
const sourceFailure = new Error(`${operation} failed`);
|
||||
const source = {captureFrame: async () => {}, waitForPlayout: async () => {}};
|
||||
source[operation] = () => new Promise((_, reject) => { fail = reject; });
|
||||
const pump = media.observe((async () => { await source.captureFrame(); await source.waitForPlayout(); })());
|
||||
const input = media.waitFor(() => new Promise(() => {}));
|
||||
while (!fail) await Promise.resolve();
|
||||
fail(sourceFailure);
|
||||
let failure;
|
||||
try { await input; }
|
||||
catch (error) { failure = error; }
|
||||
finally { hungUp = true; reportSaved = true; }
|
||||
await pump;
|
||||
assert.equal(failure, sourceFailure);
|
||||
assert.equal(observed, sourceFailure);
|
||||
assert.equal(media.error, sourceFailure);
|
||||
assert.equal(hungUp && reportSaved, true);
|
||||
await assert.rejects(media.waitFor(() => new Promise(() => {})), sourceFailure);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@paperclipai/speko-voice-proof",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "node --test *.test.mjs",
|
||||
"init": "node cli.mjs init",
|
||||
"serve": "node cli.mjs serve",
|
||||
"provision": "node cli.mjs provision",
|
||||
"live": "node live-client.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.11.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"standardwebhooks": "1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@livekit/rtc-node": "1.0.0",
|
||||
"@spekoai/client": "0.0.13",
|
||||
"esbuild": "0.25.12"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { once } from "node:events";
|
||||
import test from "node:test";
|
||||
import { Webhook } from "standardwebhooks";
|
||||
import { BODY_LIMIT, createProof, createProofServer, TOOL_PATH, toolDefinitions, WAIT_MS } from "./proof.mjs";
|
||||
|
||||
const secret = () => `whsec_${randomBytes(32).toString("base64")}`;
|
||||
function fixture(options = {}) {
|
||||
const signingSecret = secret();
|
||||
const proof = createProof({ signingSecret, ...options });
|
||||
const signer = new Webhook(signingSecret);
|
||||
let sequence = 0;
|
||||
function request(tool, args, { session = "session_1", call = `call_${++sequence}`, timestamp = new Date(), signing = signer } = {}) {
|
||||
const body = JSON.stringify({ tool, args, session_id: session, tool_call_id: call, idempotency_key: `${session}:${call}` });
|
||||
const id = `${session}:${call}`;
|
||||
const headers = { "webhook-id": id, "webhook-timestamp": String(Math.floor(timestamp.getTime() / 1000)), "webhook-signature": signing.sign(id, timestamp, body) };
|
||||
return { body, headers, send: () => proof.handle(body, headers) };
|
||||
}
|
||||
return { proof, request };
|
||||
}
|
||||
|
||||
test("requires HTTPS and keeps each fetch below the four-second provider limit", () => {
|
||||
for (const origin of ["http://example.com", "https://user:secret@example.com", "https://example.com/path", "https://example.com?key=secret"]) {
|
||||
assert.throws(() => toolDefinitions(origin));
|
||||
}
|
||||
const tools = toolDefinitions("https://example.com");
|
||||
assert.equal(tools.length, 2);
|
||||
assert.ok(tools.every(({ source }) => source.timeoutMs < 4000 && source.timeoutMs > WAIT_MS && !source.secret));
|
||||
});
|
||||
|
||||
test("rejects missing, wrong, stale, future and tampered signatures", async () => {
|
||||
const { proof, request } = fixture();
|
||||
const valid = request("submit_request", { text: "synthetic request" });
|
||||
assert.equal((await proof.handle(valid.body, {})).status, 401);
|
||||
assert.equal((await proof.handle(valid.body + " ", valid.headers)).status, 401);
|
||||
for (const options of [{ signing: new Webhook(secret()) }, { timestamp: new Date(Date.now() - 360_000) }, { timestamp: new Date(Date.now() + 360_000) }]) {
|
||||
assert.equal((await request("submit_request", { text: "test" }, options).send()).status, 401);
|
||||
}
|
||||
assert.equal(proof.evidence().acceptedRequests, 0);
|
||||
});
|
||||
|
||||
test("deduplicates concurrent retries and rejects changed arguments and session substitution", async () => {
|
||||
const { proof, request } = fixture();
|
||||
const original = request("submit_request", { text: "private phrase" }, { call: "same" });
|
||||
const responses = await Promise.all([original.send(), original.send(), original.send()]);
|
||||
assert.ok(responses.every((response) => response.body.requestCount === 1));
|
||||
assert.equal((await request("submit_request", { text: "changed" }, { call: "same" }).send()).status, 409);
|
||||
assert.equal((await request("submit_request", { text: "test" }, { session: "other" }).send()).status, 403);
|
||||
assert.equal(proof.evidence().acceptedRequests, 1);
|
||||
assert.ok(!JSON.stringify(proof.evidence()).includes("private phrase"));
|
||||
});
|
||||
|
||||
test("waits 60 seconds, admits follow-ups to one job and distinguishes result retrieval from speech", async () => {
|
||||
let clock = 0;
|
||||
const waits = [];
|
||||
const { proof, request } = fixture({ now: () => clock, wait: async (duration) => { waits.push(duration); clock += duration; } });
|
||||
assert.equal((await request("get_updates", { cursor: 0 }).send()).status, 409);
|
||||
assert.equal((await request("submit_request", { text: "start" }).send()).body.status, "pending");
|
||||
assert.equal((await request("get_updates", { cursor: 1 }).send()).status, 400);
|
||||
let response;
|
||||
for (let i = 0; i < 24; i++) {
|
||||
if (i === 6) assert.equal((await request("submit_request", { text: "followup" }).send()).body.requestCount, 2);
|
||||
response = await request("get_updates", { cursor: 0 }).send();
|
||||
if (i < 23) assert.equal(response.body.status, "pending");
|
||||
}
|
||||
assert.equal(clock, 60_000);
|
||||
assert.ok(waits.every((duration) => duration <= 2500));
|
||||
assert.equal(response.body.status, "completed");
|
||||
assert.match(response.body.updates[0].text, /Verification number \d{4}\. Follow-up count 1/);
|
||||
assert.deepEqual((await request("get_updates", { cursor: 1 }).send()).body.updates, []);
|
||||
assert.equal(proof.evidence().liveQualification, "not_established");
|
||||
assert.equal(proof.evidence().resultReturned, true);
|
||||
});
|
||||
|
||||
test("close during a bounded wait prevents further result delivery", async () => {
|
||||
let release;
|
||||
const { proof, request } = fixture({ wait: () => new Promise((resolve) => { release = resolve; }) });
|
||||
await request("submit_request", { text: "start" }).send();
|
||||
const polling = request("get_updates", { cursor: 0 }).send();
|
||||
await Promise.resolve();
|
||||
proof.close();
|
||||
release();
|
||||
assert.equal((await polling).status, 410);
|
||||
assert.equal((await request("submit_request", { text: "late" }).send()).status, 410);
|
||||
});
|
||||
|
||||
test("expires the single-run receiver and caps authenticated deliveries", async () => {
|
||||
let clock = 0;
|
||||
const { proof, request } = fixture({ now: () => clock });
|
||||
for (let i = 0; i < 500; i++) {
|
||||
assert.equal((await request("submit_request", { text: "synthetic" }).send()).status, 200);
|
||||
}
|
||||
assert.equal((await request("submit_request", { text: "over limit" }).send()).status, 429);
|
||||
clock = 15 * 60_000;
|
||||
assert.equal((await request("submit_request", { text: "expired" }).send()).status, 410);
|
||||
assert.equal(proof.evidence().acceptedRequests, 500);
|
||||
});
|
||||
|
||||
test("rejects invalid args and bodies before accepting work", async () => {
|
||||
const { proof, request } = fixture();
|
||||
for (const [tool, args] of [["unknown", {}], ["submit_request", { text: "" }], ["submit_request", { text: "ok", authority: "admin" }], ["get_updates", { cursor: -1 }], ["get_updates", { cursor: 0.5 }]]) {
|
||||
assert.equal((await request(tool, args).send()).status, 400);
|
||||
}
|
||||
assert.equal((await proof.handle("x".repeat(BODY_LIMIT + 1), {})).status, 413);
|
||||
assert.equal(proof.evidence().acceptedRequests, 0);
|
||||
});
|
||||
|
||||
test("HTTP surface only accepts JSON on the signed tool route", async (t) => {
|
||||
const { proof, request } = fixture();
|
||||
const server = createProofServer(proof);
|
||||
t.after(() => { server.closeAllConnections(); server.close(); });
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
assert.equal((await fetch(`${base}/health`)).status, 200);
|
||||
assert.equal((await fetch(`${base}/api/companies`)).status, 404);
|
||||
assert.equal((await fetch(`${base}${TOOL_PATH}`, { method: "POST", body: "{}" })).status, 415);
|
||||
const signed = request("submit_request", { text: "start" });
|
||||
const response = await fetch(`${base}${TOOL_PATH}`, { method: "POST", headers: { ...signed.headers, "content-type": "application/json" }, body: signed.body });
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.headers.get("cache-control"), "no-store");
|
||||
assert.equal((await response.json()).requestCount, 1);
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
/** Publish only an allowlisted, content-free view of a private synthetic run. */
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
|
||||
const [input, output] = process.argv.slice(2);
|
||||
if (!input || !output) throw new Error("Usage: node summarize-run.mjs private/report.json new-report.json");
|
||||
const run = JSON.parse(await readFile(input, "utf8"));
|
||||
const eventKinds = new Set([
|
||||
"session_created", "remote_audio_subscribed", "input_start", "input_end",
|
||||
"request_accepted", "followup_accepted", "poll_returned", "result_ready",
|
||||
"result_returned_to_tool", "tool_rejected", "transcript_received",
|
||||
"script_completed", "script_failed", "media_disconnected", "session_end_requested",
|
||||
"session_end_failed", "audio_stream_closed", "cleanup_failed",
|
||||
"application_notification_sent",
|
||||
"application_notifier_joined", "audio_activity_started",
|
||||
"interruption_requested",
|
||||
]);
|
||||
const events = run.events.filter((event) => eventKinds.has(event.kind)).map((event) => {
|
||||
const item = { kind: event.kind, elapsedMs: event.elapsedMs };
|
||||
for (const key of ["requestCount", "cursor", "jobElapsedMs", "characters", "verification", "toolMarkup"]) {
|
||||
if (["number", "boolean"].includes(typeof event[key])) item[key] = event[key];
|
||||
}
|
||||
if (["start", "followup"].includes(event.label)) item.label = event.label;
|
||||
return item;
|
||||
});
|
||||
const cleanupFailureCount = Math.max(run.cleanupFailures?.length ?? 0, run.events.filter(event => event.kind === "cleanup_failed").length);
|
||||
const report = {
|
||||
date: run.startedAt ?? "2026-09-11 (exact start available from provider session)",
|
||||
sourceDigests: run.sourceDigests ? Object.fromEntries(
|
||||
["live-client.mjs", "proof.mjs", "media-failure.mjs"]
|
||||
.filter((name) => /^[a-f0-9]{64}$/.test(run.sourceDigests[name] ?? ""))
|
||||
.map((name) => [name, run.sourceDigests[name]]),
|
||||
) : null,
|
||||
sessionId: run.sessionId,
|
||||
input: run.mode,
|
||||
applicationNotification: run.notifyReady === true,
|
||||
applicationNotifierWebJoin: run.notifyViaJoin === true,
|
||||
interruptAcknowledgment: run.interruptAcknowledgment === true,
|
||||
llmConstraint: run.provider,
|
||||
status: run.failure || cleanupFailureCount ? "failed" : "requires_audio_review",
|
||||
cleanupFailureCount,
|
||||
failure: run.failure ? "Scenario did not complete; see qualification report for diagnosis" : cleanupFailureCount ? "Run cleanup did not complete; see private evidence for diagnosis" : null,
|
||||
acceptedRequests: run.proof.acceptedRequests,
|
||||
resultReturned: run.proof.resultReturned,
|
||||
synthesizedAudioReceived: run.audioFrames > 0,
|
||||
// A transcript event and RTP frames cannot establish that the answer was heard.
|
||||
confirmedResultPlayback: false,
|
||||
events,
|
||||
};
|
||||
await writeFile(output, `${JSON.stringify(report, null, 2)}\n`, { flag: "wx" });
|
||||
@@ -0,0 +1,54 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, readFile, writeFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
test("shared reports exclude private content and never promote transport to playback proof", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "speko-redaction-"));
|
||||
try {
|
||||
const input = join(dir, "private.json");
|
||||
const output = join(dir, "public.json");
|
||||
await writeFile(input, JSON.stringify({
|
||||
sessionId: "11111111-1111-4111-8111-111111111111", mode: "text", provider: "openai:gpt-4.1",
|
||||
failure: null, audioFrames: 42, transportToken: "never-publish-token",
|
||||
syntheticTranscript: [{ text: "never-publish-transcript" }],
|
||||
sourceDigests: { "proof.mjs": "a".repeat(64), "media-failure.mjs": "b".repeat(64), accidentalKey: "never-publish-key" },
|
||||
proof: { acceptedRequests: 2, resultReturned: true, expectedSyntheticResult: "never-publish-phrase" },
|
||||
events: [
|
||||
{ kind: "request_accepted", elapsedMs: 10, requestCount: 1, text: "never-publish-caller" },
|
||||
{ kind: "script_failed", elapsedMs: 20, reason: "never-publish-error-detail" },
|
||||
{ kind: "unknown_future_event", elapsedMs: 30, text: "never-publish-future" },
|
||||
],
|
||||
}));
|
||||
const script = new URL("./summarize-run.mjs", import.meta.url);
|
||||
const run = () => spawnSync(process.execPath, [script.pathname, input, output], { encoding: "utf8" });
|
||||
assert.equal(run().status, 0);
|
||||
const text = await readFile(output, "utf8");
|
||||
assert.equal(text.includes("never-publish"), false);
|
||||
const report = JSON.parse(text);
|
||||
assert.equal(report.status, "requires_audio_review");
|
||||
assert.equal(report.confirmedResultPlayback, false);
|
||||
assert.equal(report.resultReturned, true);
|
||||
assert.equal(report.events.length, 2);
|
||||
assert.deepEqual(report.sourceDigests, { "proof.mjs": "a".repeat(64), "media-failure.mjs": "b".repeat(64) });
|
||||
assert.notEqual(run().status, 0, "must refuse to overwrite evidence");
|
||||
assert.equal(await readFile(output, "utf8"), text);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("shared reports mark cleanup failures without leaking private diagnostics", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "speko-cleanup-redaction-"));
|
||||
try {
|
||||
const input = join(dir, "private.json"), output = join(dir, "public.json");
|
||||
await writeFile(input, JSON.stringify({failure: null, audioFrames: 1, proof: {acceptedRequests: 1, resultReturned: true}, cleanupFailures: [{operation: "source.close", reason: "secret detail"}], events: [{kind: "cleanup_failed", elapsedMs: 25, operation: "private operation"}]}));
|
||||
assert.equal(spawnSync(process.execPath, [new URL("./summarize-run.mjs", import.meta.url).pathname, input, output]).status, 0);
|
||||
const text = await readFile(output, "utf8"), report = JSON.parse(text);
|
||||
assert.equal(report.status, "failed"); assert.equal(report.cleanupFailureCount, 1);
|
||||
assert.equal(report.events[0].kind, "cleanup_failed");
|
||||
assert.equal(text.includes("secret detail"), false); assert.equal(text.includes("private operation"), false);
|
||||
} finally {await rm(dir, {recursive: true, force: true});}
|
||||
});
|
||||
@@ -0,0 +1,321 @@
|
||||
import { expect, type FrameLocator } from "@playwright/test";
|
||||
|
||||
// These expectations name shipped UI states, not captured visual baselines.
|
||||
// Explicit fallback cases document where production has no separate error UI.
|
||||
type State = {
|
||||
text?: string | RegExp;
|
||||
absent?: string | RegExp;
|
||||
skeleton?: true;
|
||||
selector?: string;
|
||||
emptyRootReason?: string;
|
||||
tokenHistory?: "active" | "expired" | "empty";
|
||||
editable?: true;
|
||||
defaultEnvironment?: true;
|
||||
};
|
||||
const failed: State = { text: "The request failed. Please try again." };
|
||||
const denied: State = {
|
||||
text: "You do not have permission to perform this action.",
|
||||
};
|
||||
const skeleton: State = { skeleton: true };
|
||||
const documentFallback: State = {
|
||||
text: "New document",
|
||||
absent: "Run the release checks.",
|
||||
};
|
||||
const environmentFallback: State = {
|
||||
defaultEnvironment: true,
|
||||
absent: "Release sandbox",
|
||||
};
|
||||
const states: Record<string, Record<string, State>> = {
|
||||
"feature-cli-authorization": {
|
||||
pending: { text: "Approve Paperclip CLI access" },
|
||||
loading: { text: "Loading CLI auth challenge..." },
|
||||
expired: { text: "CLI auth challenge expired" },
|
||||
unavailable: { text: "CLI auth challenge unavailable" },
|
||||
},
|
||||
"feature-membership": {
|
||||
members: { text: "Release reviewer" },
|
||||
loading: { text: "Loading organization access…" },
|
||||
empty: { text: "No user memberships found for this organization yet." },
|
||||
failed,
|
||||
"permission-denied": {
|
||||
text: "You do not have permission to manage organization members.",
|
||||
},
|
||||
},
|
||||
"feature-sign-in": {
|
||||
"signed-out": { text: "Sign in to Paperclip" },
|
||||
loading: { text: "Loading…" },
|
||||
"session-failed": {
|
||||
text: "Unable to check sign-in. Refresh and try again.",
|
||||
},
|
||||
},
|
||||
"feature-run-audit": {
|
||||
loading: { text: "Loading runs…" },
|
||||
empty: { text: "No runs yet." },
|
||||
},
|
||||
"feature-conference-room": {
|
||||
empty: { text: "Conference Room", editable: true },
|
||||
},
|
||||
"feature-catalog-review": {
|
||||
quarantined: { text: "Review 1 new action" },
|
||||
empty: { text: "No actions match “”. Clear the search to see them all." },
|
||||
refreshing: { text: "Review 1 new action", selector: ".animate-spin" },
|
||||
saving: { text: "Saving…" },
|
||||
"read-only": { text: "Actions", absent: "Review 1 new action" },
|
||||
},
|
||||
"feature-budget-resolution": {
|
||||
loading: skeleton,
|
||||
empty: { text: /No budget policies yet\./ },
|
||||
"load-failed": {
|
||||
text: "Budget data could not be loaded. Please try again shortly.",
|
||||
},
|
||||
"permission-denied": {
|
||||
text: "Budget data could not be loaded. Please try again shortly.",
|
||||
},
|
||||
},
|
||||
// Cases currently uses its empty presentation after a failed list query.
|
||||
"feature-case-browse": {
|
||||
loading: skeleton,
|
||||
empty: { text: "No cases yet" },
|
||||
failed: { text: "No cases yet" },
|
||||
"permission-denied": { text: "No cases yet" },
|
||||
},
|
||||
"feature-case-detail": {
|
||||
loading: skeleton,
|
||||
failed: { text: "Case not found." },
|
||||
"permission-denied": { text: "Case not found." },
|
||||
"arbitrary-fields": { text: "0.3.1" },
|
||||
},
|
||||
"feature-channel-attachments": {
|
||||
"no-files": { text: "Here is the release report.", absent: /Files ·/ },
|
||||
"multiple-files": { text: "Files · 2" },
|
||||
"missing-filename": { text: "attachment" },
|
||||
},
|
||||
"feature-company-import": { "choose-source": { text: "Import source" } },
|
||||
"feature-company-selection": {
|
||||
organizations: { text: "Archived Research" },
|
||||
loading: { text: "Loading organizations..." },
|
||||
empty: { text: "New Organization", absent: "Paperclip Storybook" },
|
||||
failed,
|
||||
"permission-denied": {
|
||||
text: "New Organization",
|
||||
absent: "Paperclip Storybook",
|
||||
},
|
||||
},
|
||||
"feature-document-revisions": {
|
||||
empty: documentFallback,
|
||||
loading: documentFallback,
|
||||
"read-failed": documentFallback,
|
||||
"permission-denied": documentFallback,
|
||||
edit: { text: "Run the release checks.", editable: true },
|
||||
},
|
||||
"feature-environment-images": {
|
||||
"no-image": { text: "Not configured" },
|
||||
"image-read-failed": { text: "Image metadata is unavailable." },
|
||||
"unsupported-provider": { text: "Unsupported provider" },
|
||||
},
|
||||
"feature-execution-environments": {
|
||||
loading: environmentFallback,
|
||||
empty: environmentFallback,
|
||||
failed: environmentFallback,
|
||||
"permission-denied": environmentFallback,
|
||||
"unsupported-provider": { text: "Isolated release checks" },
|
||||
"edit-configuration": {
|
||||
text: "Edit environment",
|
||||
selector: '[data-testid="environment-form-page"]',
|
||||
},
|
||||
},
|
||||
"feature-gateway-tokens": {
|
||||
"active-token": { tokenHistory: "active" },
|
||||
"no-tokens": { tokenHistory: "empty" },
|
||||
"expired-token": { tokenHistory: "expired" },
|
||||
},
|
||||
"feature-goal-alignment": {
|
||||
"empty-relationships": { text: "No sub-goals." },
|
||||
loading: skeleton,
|
||||
failed,
|
||||
"permission-denied": denied,
|
||||
},
|
||||
"feature-inbox-views": {
|
||||
mine: { text: "Create super-detailed storybooks for the project" },
|
||||
recent: { text: "Create super-detailed storybooks for the project" },
|
||||
unread: { text: "Create super-detailed storybooks for the project" },
|
||||
blocked: { text: "No work is stopped." },
|
||||
empty: { text: "Inbox zero." },
|
||||
loading: skeleton,
|
||||
failed: { text: "Inbox zero." },
|
||||
"permission-denied": { text: "Inbox zero." },
|
||||
},
|
||||
// Order reads are allowed to fall back to navigation; section collapse is local.
|
||||
"feature-sidebar-preferences": {
|
||||
"saved-order": {
|
||||
text: "Paperclip Storybook",
|
||||
selector: 'a[href="/PAP/dashboard"]',
|
||||
},
|
||||
"loading-order": {
|
||||
text: "Paperclip Storybook",
|
||||
selector: 'a[href="/PAP/dashboard"]',
|
||||
},
|
||||
"order-read-failed": {
|
||||
text: "Paperclip Storybook",
|
||||
selector: 'a[href="/PAP/dashboard"]',
|
||||
},
|
||||
"order-permission-denied": {
|
||||
text: "Paperclip Storybook",
|
||||
selector: 'a[href="/PAP/dashboard"]',
|
||||
},
|
||||
},
|
||||
"feature-onboarding-resume": {
|
||||
"malformed-draft": {
|
||||
text: "Create your first agent",
|
||||
selector: '[data-testid="onboarding-wizard"]',
|
||||
},
|
||||
},
|
||||
"feature-pipeline-learnings": {
|
||||
loading: skeleton,
|
||||
empty: { text: "No learnings yet." },
|
||||
failed: { text: "Could not load learnings." },
|
||||
"permission-denied": { text: "Could not load learnings." },
|
||||
},
|
||||
"feature-pipeline-board": {
|
||||
loading: skeleton,
|
||||
empty: { text: "0 total items", absent: "Ship the release notes" },
|
||||
failed: { text: "Pipeline not found." },
|
||||
"permission-denied": { text: "Pipeline not found." },
|
||||
},
|
||||
"feature-pipeline-review": {
|
||||
loading: skeleton,
|
||||
empty: { text: "Nothing needs you right now." },
|
||||
failed: { text: "Some items need attention. Try again in a moment." },
|
||||
"permission-denied": {
|
||||
text: "Some items need attention. Try again in a moment.",
|
||||
},
|
||||
},
|
||||
"feature-pipeline-stages": {
|
||||
loading: skeleton,
|
||||
failed,
|
||||
"permission-denied": denied,
|
||||
},
|
||||
"feature-plugin-configuration": {
|
||||
configured: {
|
||||
text: "Preview plugin",
|
||||
selector: 'input[value="Release preview"]',
|
||||
},
|
||||
loading: { text: "Loading plugin details..." },
|
||||
unavailable: { text: "Failed to load plugins." },
|
||||
},
|
||||
"feature-plugin-contributions": {
|
||||
loading: { text: "Loading…" },
|
||||
"missing-contribution": { text: "Preview plugin" },
|
||||
"discovery-failure": { text: "Loading…" },
|
||||
},
|
||||
"feature-plugin-management": {
|
||||
installed: { text: "Preview plugin" },
|
||||
empty: { text: "No plugins installed" },
|
||||
loading: { text: "Loading plugins..." },
|
||||
failed: { text: "Failed to load plugins." },
|
||||
"permission-denied": { text: "Failed to load plugins." },
|
||||
},
|
||||
"feature-routine-run-now": {
|
||||
overview: { text: "Review a release" },
|
||||
loading: skeleton,
|
||||
failed,
|
||||
"permission-denied": denied,
|
||||
},
|
||||
"feature-secret-proposal-review": {
|
||||
pending: { text: "dev/github/token" },
|
||||
loading: { text: "Loading proposals…" },
|
||||
empty: { text: "No pending proposals" },
|
||||
failed: { text: "Couldn’t load proposals. Try again." },
|
||||
},
|
||||
"feature-skill-test-runs": {
|
||||
"completed-test": { text: "succeeded" },
|
||||
queued: { text: "queued" },
|
||||
running: { text: "running" },
|
||||
"test-failed": { text: "failed" },
|
||||
empty: { text: "No test runs yet. Pick an agent and Run." },
|
||||
loading: { text: "Loading skill…" },
|
||||
"load-failed": { text: "Skill not found." },
|
||||
"permission-denied": { text: "Skill not found." },
|
||||
},
|
||||
"feature-status-card-states": {
|
||||
loading: { text: "Loading cards…" },
|
||||
empty: { text: "No status cards yet" },
|
||||
"load-failed": { text: "Could not load status cards" },
|
||||
"permission-denied": {
|
||||
text: "You do not have permission to perform this action.",
|
||||
},
|
||||
"setting-up": { text: "Setting up your card…" },
|
||||
"generation-failed": { text: "Last update failed" },
|
||||
"stale-summary": { text: "3 changes since last update" },
|
||||
},
|
||||
"feature-composer-draft-recovery": {
|
||||
restored: { text: "Review the release before publishing.", editable: true },
|
||||
empty: { editable: true, absent: "Review the release before publishing." },
|
||||
"uncertain-submission": {
|
||||
text: /We couldn’t confirm whether this comment was saved\./,
|
||||
selector: '[role="alert"]',
|
||||
},
|
||||
},
|
||||
"feature-durable-response-queue": {
|
||||
empty: {
|
||||
emptyRootReason:
|
||||
"The shipped queued-message component returns null for zero entries.",
|
||||
},
|
||||
},
|
||||
};
|
||||
export async function assertRenderState(frame: FrameLocator, id: string) {
|
||||
const [suite, variant] = id.split("--");
|
||||
const state = states[suite]?.[variant];
|
||||
if (!state)
|
||||
throw new Error(`Declare an explicit render expectation for ${id}`);
|
||||
expect(
|
||||
state,
|
||||
`Declare an explicit render expectation for ${id}`,
|
||||
).toBeDefined();
|
||||
if (state.emptyRootReason) {
|
||||
await expect(frame.locator("#storybook-root")).toBeEmpty();
|
||||
return;
|
||||
}
|
||||
if (state.text)
|
||||
await expect(
|
||||
frame
|
||||
.getByText(state.text, { exact: typeof state.text === "string" })
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
if (state.absent)
|
||||
await expect(
|
||||
frame.getByText(state.absent, {
|
||||
exact: typeof state.absent === "string",
|
||||
}),
|
||||
).toHaveCount(0);
|
||||
if (state.skeleton)
|
||||
await expect(frame.locator('[data-slot="skeleton"]').first()).toBeVisible();
|
||||
if (state.selector)
|
||||
await expect(frame.locator(state.selector).first()).toBeVisible();
|
||||
if (state.defaultEnvironment) {
|
||||
const select = frame.getByRole("combobox", { name: "Default environment" });
|
||||
await expect(select).toBeVisible();
|
||||
await expect(select).toHaveValue("");
|
||||
await expect(
|
||||
select.getByRole("option", { name: "Local", exact: true }),
|
||||
).toBeAttached();
|
||||
}
|
||||
if (state.editable)
|
||||
await expect(frame.getByRole("textbox").first()).toBeVisible();
|
||||
if (state.tokenHistory) {
|
||||
await frame.getByRole("button", { name: /Token history/ }).click();
|
||||
if (state.tokenHistory === "empty")
|
||||
await expect(
|
||||
frame.getByText("No tokens have been issued for this gateway."),
|
||||
).toBeVisible();
|
||||
else {
|
||||
await expect(
|
||||
frame.getByText("Preview client", { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
if (state.tokenHistory === "expired")
|
||||
await expect(
|
||||
frame.getByText("Expired", { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from "@playwright/test";
|
||||
export default defineConfig({
|
||||
testDir: ".",
|
||||
testMatch: ["feature-gap-stories.spec.ts", "map-navigation.spec.ts"],
|
||||
timeout: 60_000,
|
||||
expect: { timeout: 15_000 },
|
||||
retries: 0,
|
||||
fullyParallel: true,
|
||||
workers: 4,
|
||||
reporter: "list",
|
||||
outputDir: "./test-results/feature-gaps",
|
||||
use: {
|
||||
baseURL: process.env.PAPERCLIP_GAP_STORYBOOK_URL ?? "http://localhost:6136",
|
||||
viewport: { width: 1280, height: 900 },
|
||||
reducedMotion: "reduce",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import { assertRenderState } from "./feature-gap-render-states";
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { generateStorybook } from "../../scripts/storybook-catalog.mjs";
|
||||
const root = new URL("../../", import.meta.url).pathname;
|
||||
const { catalog, stories: parsed } = generateStorybook(root, { write: false });
|
||||
const suites = catalog.entries.filter(
|
||||
(entry: { stableId: string }) =>
|
||||
entry.stableId.startsWith("feature-") &&
|
||||
!entry.stableId.startsWith("feature-map-"),
|
||||
);
|
||||
let index: { entries: Record<string, { tags: string[] }> };
|
||||
test.beforeAll(async ({ request }) => {
|
||||
const response = await request.get("/index.json");
|
||||
expect(response.ok()).toBe(true);
|
||||
index = await response.json();
|
||||
});
|
||||
test.afterEach(async ({ page }, info) => {
|
||||
if (info.status !== info.expectedStatus) {
|
||||
const managerPath = info.outputPath("manager.txt");
|
||||
writeFileSync(managerPath, await page.locator("body").innerText());
|
||||
await info.attach("storybook-page", {
|
||||
path: managerPath,
|
||||
contentType: "text/plain",
|
||||
});
|
||||
const frame = page.frameLocator("#storybook-preview-iframe");
|
||||
const subjectPath = info.outputPath("subject.txt");
|
||||
writeFileSync(subjectPath, await frame.locator("body").innerText());
|
||||
await info.attach("storybook-subject", {
|
||||
path: subjectPath,
|
||||
contentType: "text/plain",
|
||||
});
|
||||
}
|
||||
});
|
||||
for (const suite of suites) {
|
||||
const file = parsed.find(
|
||||
(story: { file: string }) => story.file === suite.file,
|
||||
);
|
||||
if (!file) throw new Error(`Missing parsed story: ${suite.file}`);
|
||||
for (const id of file.ids) {
|
||||
test(id, async ({ page }, info) => {
|
||||
expect(
|
||||
index.entries[id],
|
||||
`Rebuild Storybook to include ${id}`,
|
||||
).toBeDefined();
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
// Opening an addon during play steals focus from inputs and dismisses menus.
|
||||
await page.goto(
|
||||
`/?path=/story/${id}&addonPanel=storybook/interactions/panel`,
|
||||
);
|
||||
const frame = page.frameLocator("#storybook-preview-iframe");
|
||||
await expect(frame.locator("body")).toHaveClass(/sb-show-main/);
|
||||
await expect(frame.locator(".sb-show-errordisplay")).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole("tab", { name: /^Interactions/ }),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
const status = page.locator('[aria-label^="Story status:"]');
|
||||
if (index.entries[id]?.tags.includes("play-fn")) {
|
||||
await expect(status).toHaveAttribute(
|
||||
"aria-label",
|
||||
"Story status: Pass",
|
||||
);
|
||||
}
|
||||
if (!index.entries[id].tags.includes("play-fn"))
|
||||
await assertRenderState(frame, id);
|
||||
await expect(frame.locator("#storybook-root")).toBeAttached();
|
||||
expect(errors).toEqual([]);
|
||||
if (
|
||||
id.includes("accepted") ||
|
||||
id.includes("permission") ||
|
||||
id.includes("save-retention")
|
||||
)
|
||||
await page.screenshot({ path: info.outputPath(`${id}.png`) });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test("token fixtures reset when switching stories in one browser", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(
|
||||
"/?path=/story/feature-gateway-tokens--issued-token&addonPanel=storybook/interactions/panel",
|
||||
);
|
||||
await expect(
|
||||
page.getByRole("tab", { name: /^Interactions/ }),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
await expect(page.locator('[aria-label^="Story status:"]')).toHaveAttribute(
|
||||
"aria-label",
|
||||
"Story status: Pass",
|
||||
);
|
||||
await page.getByRole("link", { name: "Active Token", exact: true }).click();
|
||||
const frame = page.frameLocator("#storybook-preview-iframe");
|
||||
await frame.getByRole("button", { name: /Token history/ }).click();
|
||||
await expect(
|
||||
frame.getByText("Preview client", { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(frame.getByText("Review client", { exact: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test("draft fixtures reset when switching away from uncertain submission", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(
|
||||
"/?path=/story/feature-composer-draft-recovery--uncertain-submission&addonPanel=storybook/interactions/panel",
|
||||
);
|
||||
const frame = page.frameLocator("#storybook-preview-iframe");
|
||||
const warning = frame.getByRole("alert").filter({
|
||||
hasText: "We couldn’t confirm whether this comment was saved.",
|
||||
});
|
||||
const editor = frame.getByRole("textbox");
|
||||
await expect(warning).toContainText(
|
||||
"We couldn’t confirm whether this comment was saved.",
|
||||
);
|
||||
await page.getByRole("link", { name: "Empty", exact: true }).click();
|
||||
await expect(warning).toHaveCount(0);
|
||||
await expect(editor).toHaveText("");
|
||||
for (const story of ["Sent Clears Draft", "Send Failed Keeps Draft"]) {
|
||||
await page
|
||||
.getByRole("link", { name: "Uncertain Submission", exact: true })
|
||||
.click();
|
||||
await expect(warning).toContainText(
|
||||
"We couldn’t confirm whether this comment was saved.",
|
||||
);
|
||||
// Keep the browser/storage context, but reload for play stories so the
|
||||
// manager cannot report the previous no-play story's Pass during navigation.
|
||||
const id =
|
||||
story === "Sent Clears Draft"
|
||||
? "sent-clears-draft"
|
||||
: "send-failed-keeps-draft";
|
||||
await page.goto(
|
||||
`/?path=/story/feature-composer-draft-recovery--${id}&addonPanel=storybook/interactions/panel`,
|
||||
);
|
||||
await expect(frame.locator("body")).toHaveClass(/sb-show-main/);
|
||||
await expect(editor).toBeVisible();
|
||||
await expect(warning).toHaveCount(0);
|
||||
await expect(page.locator('[aria-label^="Story status:"]')).toHaveAttribute(
|
||||
"aria-label",
|
||||
"Story status: Pass",
|
||||
);
|
||||
await expect(editor).toHaveText(
|
||||
story === "Sent Clears Draft"
|
||||
? ""
|
||||
: "Review the release before publishing.",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
for (const theme of ["light", "dark"]) {
|
||||
test(`backup retention renders on mobile in ${theme}`, async ({
|
||||
page,
|
||||
}, info) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto(
|
||||
`/iframe.html?id=feature-backup-retention--current-policy&viewMode=story&globals=theme:${theme}`,
|
||||
);
|
||||
await expect(page.getByRole("button", { name: "14 days" })).toBeVisible();
|
||||
await expect(page.locator(".sb-show-errordisplay")).toHaveCount(0);
|
||||
await page.screenshot({
|
||||
path: info.outputPath(`backup-mobile-${theme}.png`),
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
// Exercise the generated guide through the built Storybook manager. These checks
|
||||
// need no image baselines and do not imply production acceptance.
|
||||
test("guide filters and opens an area without story examples", async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", error => errors.push(error.message));
|
||||
await page.goto("/?path=/story/overview-storybook-guide--coverage-guide");
|
||||
const frame = page.frameLocator("#storybook-preview-iframe");
|
||||
await expect(frame.getByRole("heading", { name: "Paperclip feature map" })).toBeVisible();
|
||||
await frame.getByRole("textbox", { name: "Find a feature or capability" }).fill("cli-operations");
|
||||
await frame.getByRole("link", { name: "CLI/API and local worktrees", exact: true }).click();
|
||||
await expect(page).toHaveURL(/feature-map-cli-operations--overview/);
|
||||
await expect(frame.getByRole("heading", { name: "CLI/API and local worktrees", exact: true })).toBeVisible();
|
||||
const capability = frame.locator("section#context");
|
||||
await expect(capability.getByText(/No cataloged example yet/)).toBeVisible();
|
||||
await capability.getByText("Verification scope", { exact: true }).click();
|
||||
await expect(capability.getByText("No authored story required for CLI profile/context selection.", { exact: true })).toBeVisible();
|
||||
await expect(capability.getByRole("link", { name: "context.ts", exact: true })).toHaveAttribute("href", "https://github.com/paperclipai/paperclip/blob/master/cli/src/commands/client/context.ts");
|
||||
await expect(frame.getByRole("link", { name: "Agent access contract" })).toHaveAttribute("href", /agent-access-map\/cli-operations\.md$/);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("capability order and related foundations links survive generation", async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", error => errors.push(error.message));
|
||||
await page.goto("/?path=/story/feature-map-developer-labs--overview");
|
||||
const frame = page.frameLocator("#storybook-preview-iframe");
|
||||
await expect(frame.getByRole("navigation", { name: "Capabilities" }).getByRole("link")).toHaveText(["Design", "Interaction labs", "Performance", "Route gates"]);
|
||||
const design = frame.locator("section#design");
|
||||
const related = design.getByRole("link", { name: "Primitive Matrix", exact: true });
|
||||
await expect(related).toHaveAttribute("href", /foundations-primitive-matrix--/);
|
||||
await related.click();
|
||||
await expect(page).toHaveURL(/foundations-primitive-matrix--/);
|
||||
await expect(frame.locator("body")).toHaveClass(/sb-show-main/);
|
||||
await expect(frame.locator("#storybook-root")).not.toBeEmpty();
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { dirname, isAbsolute, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
const storybookPort = Number(process.env.STORYBOOK_VISUAL_PORT ?? 6106);
|
||||
const testDir = dirname(fileURLToPath(import.meta.url));
|
||||
const snapshotDir = process.env.STORYBOOK_VISUAL_SNAPSHOT_DIR
|
||||
? isAbsolute(process.env.STORYBOOK_VISUAL_SNAPSHOT_DIR)
|
||||
@@ -47,11 +48,11 @@ export default defineConfig({
|
||||
// JS-driven tickers/timers key off prefers-reduced-motion for
|
||||
// deterministic captures (CSS animations are already disabled).
|
||||
reducedMotion: "reduce",
|
||||
baseURL: "http://localhost:6106",
|
||||
baseURL: process.env.STORYBOOK_TEST_BASE_URL ?? `http://localhost:${storybookPort}`,
|
||||
},
|
||||
webServer: {
|
||||
command: "node ../../scripts/serve-storybook-static.mjs --port 6106",
|
||||
url: "http://localhost:6106/index.json",
|
||||
webServer: process.env.STORYBOOK_TEST_BASE_URL ? undefined : {
|
||||
command: `node ../../scripts/serve-storybook-static.mjs --port ${storybookPort}`,
|
||||
url: `http://localhost:${storybookPort}/index.json`,
|
||||
reuseExistingServer: true,
|
||||
timeout: 30_000,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const story = (name: string) => `/iframe.html?id=skills-project-repository-skills-02-journeys--${name}&viewMode=story&globals=theme:dark`;
|
||||
|
||||
test('groups matching project repositories and keeps progress visible through adoption', async ({ page }) => {
|
||||
await page.goto(story('duplicate-projects'));
|
||||
await expect(page.getByRole('button', { name: 'Import skills', exact: true })).toHaveCount(1);
|
||||
await expect(page.getByText('Acme platform, Sentry · main', { exact: true })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Import skills', exact: true }).click();
|
||||
await expect(page.getByRole('status')).toContainText('Starting import');
|
||||
await expect(page.getByRole('region', { name: 'acme/team-skills', exact: true }).getByText('Code review', { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Import skills', exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('repairs a missing branch without prompting for another connection', async ({ page }) => {
|
||||
await page.goto(story('missing-branch'));
|
||||
await expect(page.getByRole('button', { name: 'Choose connection', exact: true })).toHaveCount(0);
|
||||
await page.getByRole('button', { name: 'Choose a new branch', exact: true }).click();
|
||||
await page.getByRole('combobox', { name: 'Branch', exact: true }).click();
|
||||
await page.getByRole('option', { name: 'master Default', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Change branch', exact: true }).click();
|
||||
await expect(page.getByRole('alert')).toHaveCount(0);
|
||||
await expect(page.getByText(/master · Updated/)).toBeVisible();
|
||||
await expect(page.getByRole('region', { name: 'acme/team-skills', exact: true }).getByText('Code review', { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'More actions for acme/team-skills', exact: true })).toBeFocused();
|
||||
});
|
||||
|
||||
test('creating a project imports repository skills without another setup flow', async ({ page }) => {
|
||||
await page.goto(story('create-project'));
|
||||
await page.getByRole('button', { name: 'Add Project', exact: true }).first().click();
|
||||
const dialog = page.getByRole('dialog', { name: 'Create project', exact: true });
|
||||
await dialog.getByRole('textbox', { name: 'Project name', exact: true }).fill('Repository project');
|
||||
await dialog.getByRole('button', { name: 'Add GitHub repo', exact: true }).click();
|
||||
await page.getByRole('option', { name: 'acme/team-skills My GitHub account · Acme engineering', exact: true }).click();
|
||||
await dialog.getByRole('button', { name: 'Create project', exact: true }).click();
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await page.getByRole('link', { name: 'Skills', exact: true }).click();
|
||||
await page.getByLabel('Skills', { exact: true }).getByRole('link', { name: 'Sources', exact: true }).click();
|
||||
const source = page.getByRole('region', { name: 'acme/team-skills', exact: true });
|
||||
await expect(source.getByText('Code review', { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Import skills', exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('attaching another repository imports skills and keeps the original source', async ({ page }) => {
|
||||
await page.goto(story('attach-repository'));
|
||||
await page.getByRole('button', { name: 'Add another repo', exact: true }).click();
|
||||
await page.getByRole('option', { name: 'maya/research-skills My GitHub account', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Save changes', exact: true }).click();
|
||||
await expect(page.getByText('Changes saved', { exact: true })).toBeVisible();
|
||||
await page.getByRole('link', { name: 'Skills', exact: true }).click();
|
||||
await page.getByLabel('Skills', { exact: true }).getByRole('link', { name: 'Sources', exact: true }).click();
|
||||
const original = page.getByRole('region', { name: 'acme/team-skills', exact: true });
|
||||
const added = page.getByRole('region', { name: 'maya/research-skills', exact: true });
|
||||
await expect(original.getByText('Code review', { exact: true })).toBeVisible();
|
||||
await expect(added.getByText('Code review', { exact: true })).toBeVisible();
|
||||
expect(await original.getByRole('link', { name: /^Code review / }).getAttribute('href'))
|
||||
.not.toBe(await added.getByRole('link', { name: /^Code review / }).getAttribute('href'));
|
||||
});
|
||||
|
||||
test('empty repositories explain automatic discovery of future skills', async ({ page }) => {
|
||||
await page.goto(story('empty-repository'));
|
||||
await expect(page.getByText('No skills found yet. New skills will be imported automatically.', { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('routine instructions stay stable while new executions follow updated skill content', async ({ page }) => {
|
||||
await page.goto(story('routine-reference'));
|
||||
const description = page.getByRole('region', { name: 'Description', exact: true });
|
||||
await expect(description.getByRole('link', { name: '/code-review', exact: true })).toBeVisible();
|
||||
const instructions = await description.innerText();
|
||||
await page.getByRole('button', { name: 'Run now', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Run routine', exact: true }).click();
|
||||
await page.getByRole('link', { name: 'Open PAP-124: Review project changes', exact: true }).click();
|
||||
const outcome = page.getByText(/^Completed using code review version /);
|
||||
await expect(outcome).toContainText('version-entry-1 at commit a17d36');
|
||||
await expect(outcome).toBeVisible();
|
||||
const first = await outcome.textContent() ?? '';
|
||||
expect(first).toContain('version-entry-1 at commit a17d36');
|
||||
await expect(page.getByRole('alert')).toHaveCount(0);
|
||||
await page.getByRole('link', { name: 'Skills', exact: true }).click();
|
||||
await page.getByLabel('Skills', { exact: true }).getByRole('link', { name: 'Sources', exact: true }).click();
|
||||
await page.getByRole('region', { name: 'acme/team-skills', exact: true }).getByRole('button', { name: 'Refresh', exact: true }).click();
|
||||
await expect(page.getByText('Security review', { exact: true })).toBeVisible();
|
||||
await page.getByRole('link', { name: 'Routines', exact: true }).click();
|
||||
await page.getByRole('main').getByText('Review project changes', { exact: true }).click();
|
||||
await page.getByLabel('Routine navigation', { exact: true }).getByRole('link', { name: 'Overview', exact: true }).click();
|
||||
await expect(description).toHaveText(instructions, { useInnerText: true });
|
||||
await page.getByRole('button', { name: 'Run now', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Run routine', exact: true }).click();
|
||||
await page.getByRole('link', { name: 'Open PAP-125: Review project changes', exact: true }).click();
|
||||
await expect(outcome).toContainText('version-entry-1-updated at commit bbbbbbbb');
|
||||
await page.getByRole('link', { name: 'Routine', exact: true }).click();
|
||||
await page.getByLabel('Routine navigation', { exact: true }).getByRole('link', { name: 'Runs', exact: true }).click();
|
||||
await page.getByRole('link', { name: 'Open PAP-124: Review project changes', exact: true }).click();
|
||||
await expect(outcome).toHaveText(first);
|
||||
});
|
||||
|
||||
test('legacy adoption imports directly and survives navigation within the journey', async ({ page }, info) => {
|
||||
await page.goto(story('legacy-adoption'));
|
||||
const source = page.getByRole('region', { name: 'acme/team-skills', exact: true });
|
||||
const action = page.getByRole('button', { name: 'Import skills', exact: true });
|
||||
await action.click();
|
||||
await expect(source).toBeVisible();
|
||||
await expect(source.getByText('Code review', { exact: true })).toBeVisible();
|
||||
await expect(action).toHaveCount(0);
|
||||
await page.getByRole('link', { name: 'Installed skills', exact: true }).click();
|
||||
await expect(source).toHaveCount(0);
|
||||
await expect(page.getByText('Code review', { exact: true }).first()).toBeVisible();
|
||||
await page.getByLabel('Skills', { exact: true }).getByRole('link', { name: 'Sources', exact: true }).click();
|
||||
await expect(source).toBeVisible();
|
||||
await page.screenshot({ path: info.outputPath('legacy-adopted.png') });
|
||||
});
|
||||
|
||||
test('background refresh imports an addition without clicking refresh', async ({ page }, info) => {
|
||||
await page.goto(story('automatic-update'));
|
||||
const source = page.getByRole('region', { name: 'acme/team-skills', exact: true });
|
||||
await expect(source.getByText('Security review', { exact: true })).toBeVisible({ timeout: 15000 });
|
||||
await expect(source.getByRole('button', { name: 'Refresh', exact: true })).toBeEnabled();
|
||||
await page.screenshot({ path: info.outputPath('automatic-update.png') });
|
||||
});
|
||||
|
||||
test('refresh failure retains content and retry recovers', async ({ page }, info) => {
|
||||
await page.goto(story('failed-refresh'));
|
||||
const source = page.getByRole('region', { name: 'acme/team-skills', exact: true });
|
||||
await expect(source.getByText('Code review', { exact: true })).toBeVisible();
|
||||
await expect(source.getByRole('alert')).toContainText('installed skills were kept');
|
||||
await source.getByRole('button', { name: 'Refresh', exact: true }).click();
|
||||
await expect(source.getByRole('button', { name: 'Refresh', exact: true })).toBeEnabled();
|
||||
await source.getByRole('button', { name: 'Refresh', exact: true }).click();
|
||||
await expect(source.getByText('Security review', { exact: true })).toBeVisible();
|
||||
await expect(source.getByRole('alert')).toHaveCount(0);
|
||||
await page.screenshot({ path: info.outputPath('refresh-recovered.png') });
|
||||
});
|
||||
|
||||
test('automatic update preference is accessible and changes in place', async ({ page }) => {
|
||||
await page.goto(story('synced'));
|
||||
await page.getByRole('button', { name: 'More actions for acme/team-skills', exact: true }).click();
|
||||
const toggle = page.getByRole('menuitemcheckbox', { name: 'Automatic updates', exact: true });
|
||||
await expect(toggle).toBeChecked();
|
||||
await toggle.click();
|
||||
await expect(page.getByText('Automatic updates paused', { exact: true })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'More actions for acme/team-skills', exact: true }).click();
|
||||
await expect(toggle).not.toBeChecked();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.getByRole('button', { name: 'More actions for acme/team-skills', exact: true })).toBeFocused();
|
||||
});
|
||||
|
||||
test('mobile touch actions stay usable without horizontal overflow', async ({ browser }, info) => {
|
||||
const context = await browser.newContext({ baseURL: info.project.use.baseURL, viewport: { width: 390, height: 844 }, hasTouch: true, reducedMotion: 'reduce' });
|
||||
const page = await context.newPage();
|
||||
try {
|
||||
await page.goto(story('mobile'));
|
||||
await expect(page.getByRole('heading', { name: 'Skill sources', exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Refresh', exact: true })).toBeVisible();
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||
await page.getByRole('button', { name: 'More actions for acme/team-skills', exact: true }).tap();
|
||||
await page.getByRole('menuitemcheckbox', { name: 'Automatic updates', exact: true }).tap();
|
||||
await expect(page.getByText('Automatic updates paused', { exact: true })).toBeVisible();
|
||||
await page.screenshot({ path: info.outputPath('sources-mobile.png') });
|
||||
} finally { await context.close(); }
|
||||
});
|
||||
|
||||
test('connection repair keeps the installed skills and selections', async ({ page }) => {
|
||||
await page.goto(story('connection-repair'));
|
||||
const source = page.getByRole('region', { name: 'acme/team-skills', exact: true });
|
||||
await source.getByRole('button', { name: 'Choose connection', exact: true }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog.getByRole('radio', { name: 'My GitHub account', exact: true })).toBeChecked();
|
||||
await expect(dialog.getByRole('button', { name: 'Save connection', exact: true })).toBeEnabled();
|
||||
await dialog.getByRole('radio', { name: 'My GitHub account', exact: true }).click();
|
||||
await dialog.getByRole('button', { name: 'Save connection', exact: true }).click();
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect(source.getByRole('button', { name: 'More actions for acme/team-skills', exact: true })).toBeFocused();
|
||||
await expect(source.getByRole('alert')).toHaveCount(0);
|
||||
await expect(source.getByText('Code review', { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('connection repair recovers from a concurrent update without losing the selected account', async ({ page }) => {
|
||||
await page.goto(story('repair-conflict'));
|
||||
const source = page.getByRole('region', { name: 'acme/team-skills', exact: true });
|
||||
await source.getByRole('button', { name: 'Choose connection', exact: true }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
const account = dialog.getByRole('radio', { name: 'My GitHub account', exact: true });
|
||||
await account.click();
|
||||
await dialog.getByRole('button', { name: 'Save connection', exact: true }).click();
|
||||
await expect(dialog.getByRole('alert')).toContainText('This source was updated');
|
||||
await expect(account).toBeChecked();
|
||||
await dialog.getByRole('button', { name: 'Save connection', exact: true }).click();
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect(source.getByRole('alert')).toHaveCount(0);
|
||||
await expect(source.getByText('Code review', { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('keeping project skill copies explains retained access and removes tracking', async ({ page }) => {
|
||||
await page.goto(story('private-project'));
|
||||
await page.getByRole('button', { name: 'More actions for acme/team-skills', exact: true }).click();
|
||||
await page.getByRole('menuitem', { name: 'Disconnect source', exact: true }).click();
|
||||
const dialog = page.getByRole('dialog', { name: 'Disconnect acme/team-skills?', exact: true });
|
||||
await expect(dialog.getByRole('button', { name: 'Disconnect source', exact: true })).toBeDisabled();
|
||||
await expect(dialog.getByText('Move them to My Skills, keeping project access restrictions. Assigned agents keep using them.', { exact: true })).toBeVisible();
|
||||
await dialog.getByRole('radio', { name: /Keep editable copies/ }).click();
|
||||
await dialog.getByRole('button', { name: 'Disconnect source', exact: true }).click();
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect(page.getByRole('region', { name: 'acme/team-skills', exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('cancelling source removal keeps tracking and allows refresh', async ({ page }) => {
|
||||
await page.goto(story('synced'));
|
||||
await page.getByRole('button', { name: 'More actions for acme/team-skills', exact: true }).click();
|
||||
await page.getByRole('menuitem', { name: 'Disconnect source', exact: true }).click();
|
||||
const dialog = page.getByRole('dialog', { name: 'Disconnect acme/team-skills?', exact: true });
|
||||
await dialog.getByRole('radio', { name: /Delete skills/ }).click();
|
||||
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await expect(dialog).toHaveCount(0);
|
||||
const source = page.getByRole('region', { name: 'acme/team-skills', exact: true });
|
||||
await source.getByRole('button', { name: 'Refresh', exact: true }).click();
|
||||
await expect(source.getByText('Security review', { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
|
||||
test('withdrawn setup requests remain terminal on a narrow light layout', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto('/iframe.html?id=connections-in-task-connections--ai-withdrawn&viewMode=story&globals=theme:light');
|
||||
await expect(page.getByTestId('connection-intent-terminal')).toBeVisible();
|
||||
await expect(page.getByText('Connection request withdrawn', { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Fix connection', exact: true })).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
|
||||
const story = (name = 'choose-branch', theme = 'dark') => `/iframe.html?id=skills-project-repository-skills-04-branch-selection-journeys--${name}&viewMode=story&globals=theme:${theme}`;
|
||||
|
||||
async function openRepair(page: Page) {
|
||||
await page.getByRole('button', { name: 'Choose a new branch', exact: true }).click();
|
||||
const dialog = page.getByRole('dialog', { name: 'Choose a new branch', exact: true });
|
||||
await expect(dialog).toBeVisible();
|
||||
return dialog;
|
||||
}
|
||||
|
||||
async function search(page: Page, name: string) {
|
||||
const picker = page.getByRole('combobox', { name: 'Branch', exact: true });
|
||||
if (await picker.getAttribute('aria-expanded') !== 'true') await picker.click();
|
||||
await page.getByPlaceholder('Search or enter a branch name…', { exact: true }).fill(name);
|
||||
}
|
||||
|
||||
test('marks the default branch and saves a searched listed branch without replacing skills', async ({ page }) => {
|
||||
await page.goto(story());
|
||||
const source = page.getByRole('region', { name: 'acme/team-skills', exact: true });
|
||||
const skill = source.getByRole('link', { name: /^Code review / });
|
||||
const skillHref = await skill.getAttribute('href');
|
||||
const dialog = await openRepair(page);
|
||||
await expect(dialog.getByRole('button', { name: 'Change branch', exact: true })).toBeDisabled();
|
||||
await search(page, '');
|
||||
await expect(page.getByRole('option', { name: 'master Default', exact: true })).toBeVisible();
|
||||
await search(page, 'release');
|
||||
await page.getByRole('option', { name: 'release/2026.10', exact: true }).click();
|
||||
await dialog.getByRole('button', { name: 'Change branch', exact: true }).click();
|
||||
await expect(dialog.getByRole('button', { name: 'Changing branch…', exact: true })).toBeDisabled();
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect(source.getByText(/release\/2026\.10 · Updated/)).toBeVisible();
|
||||
await expect(skill).toHaveAttribute('href', skillHref!);
|
||||
await expect(page.getByRole('button', { name: 'More actions for acme/team-skills', exact: true })).toBeFocused();
|
||||
});
|
||||
|
||||
test('allows an unlisted branch while discovery is still loading', async ({ page }) => {
|
||||
await page.goto(story('slow-branch-list'));
|
||||
const dialog = await openRepair(page);
|
||||
await expect(dialog.getByRole('status')).toContainText('Loading branches');
|
||||
await search(page, 'feature/not-in-the-list');
|
||||
await page.getByRole('option', { name: 'Use “feature/not-in-the-list”', exact: true }).click();
|
||||
await expect(dialog.getByRole('combobox', { name: 'Branch', exact: true })).toContainText('feature/not-in-the-list');
|
||||
await dialog.getByRole('button', { name: 'Change branch', exact: true }).click();
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect(page.getByText(/feature\/not-in-the-list · Updated/)).toBeVisible();
|
||||
});
|
||||
|
||||
test('retains manual entry when discovery fails', async ({ page }) => {
|
||||
await page.goto(story('branch-list-unavailable'));
|
||||
const dialog = await openRepair(page);
|
||||
await expect(dialog.getByRole('alert')).toContainText('You can still enter a branch name');
|
||||
await search(page, 'private/release');
|
||||
await page.getByRole('option', { name: 'Use “private/release”', exact: true }).click();
|
||||
await dialog.getByRole('button', { name: 'Change branch', exact: true }).click();
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect(page.getByText(/private\/release · Updated/)).toBeVisible();
|
||||
});
|
||||
|
||||
test('retries discovery and loads the default branch', async ({ page }) => {
|
||||
await page.goto(story('branch-list-unavailable'));
|
||||
const dialog = await openRepair(page);
|
||||
await expect(dialog.getByRole('alert')).toBeVisible();
|
||||
await dialog.getByRole('button', { name: 'Try again', exact: true }).click();
|
||||
await expect(dialog.getByRole('alert')).toHaveCount(0);
|
||||
await search(page, 'master');
|
||||
await page.getByRole('option', { name: 'master Default', exact: true }).click();
|
||||
await dialog.getByRole('button', { name: 'Change branch', exact: true }).click();
|
||||
await expect(page.getByText(/master · Updated/)).toBeVisible();
|
||||
});
|
||||
|
||||
test('keeps an invalid selection and permits correction after a save error', async ({ page }) => {
|
||||
await page.goto(story('validation-failure'));
|
||||
const dialog = await openRepair(page);
|
||||
await search(page, 'feature/missing');
|
||||
await page.getByRole('option', { name: 'Use “feature/missing”', exact: true }).click();
|
||||
await dialog.getByRole('button', { name: 'Change branch', exact: true }).click();
|
||||
await expect(dialog.getByRole('alert')).toContainText('GitHub could not find that branch or revision');
|
||||
await expect(dialog.getByRole('combobox', { name: 'Branch', exact: true })).toContainText('feature/missing');
|
||||
await expect(page.getByText(/main · Updated/)).toBeVisible();
|
||||
await search(page, 'master');
|
||||
await page.getByRole('option', { name: 'master Default', exact: true }).click();
|
||||
await expect(dialog.getByRole('alert')).toHaveCount(0);
|
||||
await dialog.getByRole('button', { name: 'Change branch', exact: true }).click();
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect(page.getByText(/master · Updated/)).toBeVisible();
|
||||
});
|
||||
|
||||
test('keyboard input accepts an unlisted branch and Escape closes only the picker', async ({ page }) => {
|
||||
await page.goto(story('empty-branch-list'));
|
||||
const dialog = await openRepair(page);
|
||||
await search(page, 'feature/keyboard');
|
||||
const input = page.getByPlaceholder('Search or enter a branch name…', { exact: true });
|
||||
await input.press('Escape');
|
||||
await expect(dialog).toBeVisible();
|
||||
await search(page, 'feature/keyboard');
|
||||
await input.press('ArrowDown');
|
||||
await input.press('Enter');
|
||||
await expect(dialog.getByRole('combobox', { name: 'Branch', exact: true })).toContainText('feature/keyboard');
|
||||
await dialog.getByRole('button', { name: 'Change branch', exact: true }).press('Enter');
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect(page.getByText(/feature\/keyboard · Updated/)).toBeVisible();
|
||||
});
|
||||
|
||||
test('cancel preserves the tracked branch and clears the next draft', async ({ page }) => {
|
||||
await page.goto(story());
|
||||
const dialog = await openRepair(page);
|
||||
await search(page, 'master');
|
||||
await page.getByRole('option', { name: 'master Default', exact: true }).click();
|
||||
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect(page.getByText(/main · Updated/)).toBeVisible();
|
||||
await openRepair(page);
|
||||
await expect(dialog.getByRole('combobox', { name: 'Branch', exact: true })).toContainText('Choose a branch');
|
||||
await expect(dialog.getByRole('button', { name: 'Change branch', exact: true })).toBeDisabled();
|
||||
});
|
||||
|
||||
test('retains a selection across a concurrent source update and retries with the new revision', async ({ page }) => {
|
||||
await page.goto(story('save-conflict'));
|
||||
const dialog = await openRepair(page);
|
||||
await search(page, 'master');
|
||||
await page.getByRole('option', { name: 'master Default', exact: true }).click();
|
||||
await dialog.getByRole('button', { name: 'Change branch', exact: true }).click();
|
||||
await expect(dialog.getByRole('alert')).toContainText('Your branch is still selected');
|
||||
await expect(dialog.getByRole('combobox', { name: 'Branch', exact: true })).toContainText('master');
|
||||
await dialog.getByRole('button', { name: 'Change branch', exact: true }).click();
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect(page.getByText(/master · Updated/)).toBeVisible();
|
||||
});
|
||||
|
||||
test('uses the selected connection when repairing an unavailable branch', async ({ page }) => {
|
||||
await page.goto(story('connection-repair'));
|
||||
await page.getByRole('button', { name: 'Choose connection', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Save connection', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Choose a new branch', exact: true }).click();
|
||||
const dialog = page.getByRole('dialog', { name: 'Choose a new branch', exact: true });
|
||||
await search(page, 'master');
|
||||
await page.getByRole('option', { name: 'master Default', exact: true }).click();
|
||||
await dialog.getByRole('button', { name: 'Change branch', exact: true }).click();
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect(page.getByRole('dialog', { name: 'Choose GitHub connection', exact: true })).toHaveCount(0);
|
||||
await expect(page.getByText(/master · Updated/)).toBeVisible();
|
||||
});
|
||||
|
||||
test.describe('mobile', () => {
|
||||
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true });
|
||||
test('selects a long typed branch using touch without horizontal overflow', async ({ page }) => {
|
||||
await page.goto(story('choose-branch', 'light'));
|
||||
await page.getByRole('button', { name: 'Choose a new branch', exact: true }).tap();
|
||||
const dialog = page.getByRole('dialog', { name: 'Choose a new branch', exact: true });
|
||||
const branch = 'feature/platform-services-new-skill-refresh-and-accessibility';
|
||||
await search(page, branch);
|
||||
await page.getByRole('option', { name: `Use “${branch}”`, exact: true }).tap();
|
||||
await expect(dialog.getByRole('button', { name: 'Change branch', exact: true })).toBeInViewport();
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||
await dialog.getByRole('button', { name: 'Change branch', exact: true }).tap();
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect(page.getByText(new RegExp(`${branch} · Updated`))).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { resolve } from "node:path";
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const root = resolve(import.meta.dirname, "../..");
|
||||
const addon = require.resolve("@storybook/addon-a11y/package.json", { paths: [resolve(root, "ui")] });
|
||||
const axePath = require.resolve("axe-core/axe.min.js", { paths: [addon] });
|
||||
const index = JSON.parse(readFileSync(resolve(root, "ui/storybook-static/index.json"), "utf8"));
|
||||
const stories = (Object.values(index.entries) as { id: string; type: string }[]).filter((entry) => entry.type === "story" && entry.id.startsWith("connections-speko-"));
|
||||
async function protectStoryOrigin(page: Page, baseURL: string, external: string[]) {
|
||||
const storyOrigin = new URL(baseURL).origin;
|
||||
await page.route(/^https?:\/\//, (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.origin !== storyOrigin) {
|
||||
external.push(url.hostname);
|
||||
return route.abort();
|
||||
}
|
||||
return route.fallback();
|
||||
});
|
||||
}
|
||||
|
||||
test("Speko network isolation permits hosted Storybook and blocks providers", async ({ page }) => {
|
||||
const baseURL = "https://storybook.example.invalid";
|
||||
const external: string[] = [];
|
||||
const served: string[] = [];
|
||||
await page.route(/^https?:\/\//, (route) => {
|
||||
served.push(route.request().url());
|
||||
return route.fulfill({ contentType: "text/html", body: "<!doctype html><title>Hosted fixture</title>" });
|
||||
});
|
||||
await protectStoryOrigin(page, baseURL, external);
|
||||
await page.goto(`${baseURL}/iframe.html`);
|
||||
await expect(page).toHaveTitle("Hosted fixture");
|
||||
expect(external).toEqual([]);
|
||||
expect(await page.evaluate(async (origin) => (await fetch(`${origin}/asset`)).ok, baseURL)).toBe(true);
|
||||
expect(await page.evaluate(async () => {
|
||||
try { await fetch("https://api.speko.dev/v1/calls"); return false; }
|
||||
catch { return true; }
|
||||
})).toBe(true);
|
||||
expect(external).toEqual(["api.speko.dev"]);
|
||||
expect(served).toEqual([`${baseURL}/iframe.html`, `${baseURL}/asset`]);
|
||||
});
|
||||
|
||||
for (const story of stories) for (const theme of ["light", "dark"]) for (const width of [390, 1200]) {
|
||||
test(`Speko accessibility ${story.id} ${theme} ${width}`, async ({ page }, info) => {
|
||||
const errors: string[] = [], external: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
await protectStoryOrigin(page, info.project.use.baseURL!, external);
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(navigator.mediaDevices, "getUserMedia", { value: () => { throw new Error("Story attempted real microphone access"); } });
|
||||
});
|
||||
await page.setViewportSize({ width, height: 844 });
|
||||
await page.goto(`/iframe.html?id=${story.id}&viewMode=story&globals=theme:${theme}`);
|
||||
await expect(page.locator("#storybook-root")).not.toBeEmpty({ timeout: 15_000 });
|
||||
await expect(page.locator(".sb-errordisplay")).not.toBeVisible();
|
||||
// Storybook's play function exercises the actual controls and controller.
|
||||
await page.waitForFunction((id) => document.body.dataset.spekoStoryReady === id, story.id);
|
||||
expect(await page.locator("body").getAttribute("data-speko-story-error")).toBeNull();
|
||||
if (story.id === "connections-speko-call-history--keyboard-disclosure") {
|
||||
const summary = page.locator("#storybook-root summary");
|
||||
await summary.press("Enter");
|
||||
await expect(page.getByRole("link", {name: "Conversation task", exact: true})).toBeVisible();
|
||||
await summary.press("Enter");
|
||||
await expect(page.getByRole("link", {name: "Conversation task", exact: true})).not.toBeVisible();
|
||||
}
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
await page.addScriptTag({ path: axePath });
|
||||
let violations: unknown[] = [];
|
||||
// The addon also scans after play. Wait for that scan rather than racing
|
||||
// its shared axe instance; retry only the explicit concurrent-run signal.
|
||||
await expect.poll(async () => page.evaluate(async () => {
|
||||
try {
|
||||
const result = await (window as any).axe.run({ include: ["#storybook-root", "[role=dialog]"] }, { runOnly: { type: "tag", values: ["wcag2a", "wcag2aa", "wcag21aa"] } });
|
||||
return { complete: true, violations: result.violations.map((v: any) => ({ id: v.id, impact: v.impact, nodes: v.nodes.map((n: any) => n.target) })) };
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.startsWith("Axe is already running")) return { complete: false, violations: [] };
|
||||
throw error;
|
||||
}
|
||||
}).then((result) => { violations = result.violations; return result.complete; })).toBe(true);
|
||||
expect(violations).toEqual([]);
|
||||
expect(errors).toEqual([]); expect(external).toEqual([]);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||
await page.screenshot({ path: info.outputPath(`${story.id}-${theme}-${width}.png`), fullPage: true });
|
||||
});
|
||||
}
|
||||
@@ -37,8 +37,6 @@ const EXTRA_SETTLE_MS: Record<string, number> = {
|
||||
// waiting. The affected element is masked (solid overlay in both baseline and
|
||||
// comparison) so the rest of the story still snapshot-verifies.
|
||||
const MASKED_SELECTORS: Record<string, string> = {
|
||||
// The unchanged dialog character animates through its SVG runtime even with
|
||||
// reduced motion. Keep the harness picker and footer fully visible.
|
||||
"agents-codex-runner-creation--experimental-native-harnesses":
|
||||
'[role="dialog"] [data-slot="agent-avatar"]',
|
||||
// DocumentAnnotationLayer's ::highlight range over "two selectors" ends 1-2
|
||||
@@ -55,11 +53,16 @@ async function renderStory(page: Page, storyId: string, theme: (typeof THEMES)[n
|
||||
await page.addInitScript(`{
|
||||
const fixedNow = ${FIXED_TIME.getTime()};
|
||||
const RealDate = Date;
|
||||
// Speko play functions exercise quiet-period gates. Keep a fixed calendar
|
||||
// origin while advancing elapsed time, so settling those gates is real.
|
||||
const advanceDate = ${storyId.startsWith("connections-speko-")};
|
||||
const began = performance.now();
|
||||
const stableNow = () => fixedNow + (advanceDate ? Math.floor(performance.now() - began) : 0);
|
||||
class FixedDate extends RealDate {
|
||||
constructor(...args) {
|
||||
if (args.length === 0) { super(fixedNow); } else { super(...args); }
|
||||
if (args.length === 0) { super(stableNow()); } else { super(...args); }
|
||||
}
|
||||
static now() { return fixedNow; }
|
||||
static now() { return stableNow(); }
|
||||
}
|
||||
FixedDate.parse = RealDate.parse;
|
||||
FixedDate.UTC = RealDate.UTC;
|
||||
@@ -84,8 +87,6 @@ async function renderStory(page: Page, storyId: string, theme: (typeof THEMES)[n
|
||||
const codexCreation = storyId.startsWith("agents-codex-runner-creation--");
|
||||
const codexSettings = /^agents-configuration-refresh--(?:codex-runtime|runner-runtime|codex-(?:legacy|native)-)/.test(storyId);
|
||||
if (codexCreation || codexSettings) {
|
||||
// These production journeys fetch accounts and then exercise controls in
|
||||
// play. sb-show-main precedes both; a stable loading frame proves neither.
|
||||
await page.waitForFunction(id => {
|
||||
const preview = (window as unknown as { __STORYBOOK_PREVIEW__?: { storyRenders?: Array<{ id: string; phase: string }> } }).__STORYBOOK_PREVIEW__;
|
||||
return preview?.storyRenders?.some(render => render.id === id && render.phase === "finished");
|
||||
@@ -95,6 +96,10 @@ async function renderStory(page: Page, storyId: string, theme: (typeof THEMES)[n
|
||||
: page.getByRole("button", { name: codexCreation || storyId.endsWith("--codex-legacy-advanced") ? "Runner" : "Harness", exact: true });
|
||||
await expect(expected).toBeVisible();
|
||||
}
|
||||
if (storyId.startsWith("connections-speko-")) {
|
||||
await page.waitForFunction((id) => document.body.dataset.spekoStoryReady === id, storyId);
|
||||
expect(await page.locator("body").getAttribute("data-speko-story-error")).toBeNull();
|
||||
}
|
||||
await page.evaluate(() => document.fonts.ready.then(() => undefined));
|
||||
const settleMs = EXTRA_SETTLE_MS[storyId];
|
||||
if (settleMs) await page.waitForTimeout(settleMs);
|
||||
@@ -104,9 +109,6 @@ for (const entry of entries) {
|
||||
for (const theme of THEMES) {
|
||||
test(`${entry.id} [${theme}]`, async ({ page }) => {
|
||||
await renderStory(page, entry.id, theme);
|
||||
// The fixture avatar's SVG snooze marks animate independently of reduced
|
||||
// motion. Exclude the unchanged avatar; keep harness branding and every
|
||||
// setup control in these production creation captures.
|
||||
const maskSelector = MASKED_SELECTORS[entry.id] ?? (entry.id.startsWith("agents-codex-runner-creation--") ? 'header [data-slot="agent-avatar"]' : undefined);
|
||||
await expect(page).toHaveScreenshot(`${entry.id}--${theme}.png`, {
|
||||
fullPage: true,
|
||||
|
||||
Reference in new issue
Block a user