Files
PaperClipAI/server/src/services/native-runtime/runtime-context.test.ts
T
DottaandPaperclip 3ec7309d91 feat(voice): add experimental Speko task connection runtime (#15727)
First PR: runtime. UI: #15728. Qualification: #15729.

## Thinking Path

> - Paperclip manages agent work through tasks and governed connections.
> - Chat connections already bind external conversations to those tasks.
> - Speko handles voice and telephony, while the selected Paperclip
agent does the work.
> - A call needs durable session authority, ordered follow-ups, and safe
result delivery.
> - This PR adds an explicit voice transport to the existing connection
runtime.
> - The UI and qualification suites follow in linked PRs. Speko stays
experimental.

## Linked Issues or Issue Description

**Problem or motivation**

Phone and browser voice conversations cannot use the chat connection
task lifecycle. A separate voice runtime is needed to preserve task
authority, retries, and reply history.

**Proposed solution**

Add company-scoped voice sessions, signed callbacks, durable replies,
inbound intake, and an agent-facing outbound-call tool. Reuse existing
tasks, queues, secrets, and publications.

**Roadmap alignment**

This extends the experimental chat connector entry points under Agent
Chat and Connected Apps. The project owner requested this feature.

Refs #11418 for the original provider catalog proposal. This
implementation uses the newer chat connection infrastructure rather than
an MCP catalog-only integration. Refs #15721 for remote OpenCode
startup.

## What Changed

- Add additive voice schema, shared contracts, session APIs, and
provider callbacks.
- Bind browser, inbound, and outbound sessions to an endpoint, task, and
selected agent.
- Add low-trust guest intake in an explicitly selected sandbox and keep
ordinary approval rules.
- Submit task work through session-scoped tools and push approved
replies through Speko POST /v1/calls/{id}/messages.
- Distinguish provider acceptance from confirmed spoken playback.
Reconcile reports without downloading recordings.
- Add protocol, migration, persistence, provider, and
delivery-diagnostic tests.

## Verification

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

## Risks

- Voice sessions add migrations and provider-dependent lifecycle
behavior. Migrations preserve existing chat and AI transports.
- Callback origins must be reachable HTTPS. Provider keys and signing
secrets remain vaulted.
- Unverified callers receive task-scoped low-trust authority. Caller ID
does not grant private task access.
- Outbound telephony incurs provider charges. Provider acceptance is not
proof that the caller heard an answer.
- Keep the feature behind the existing experimental chat setting until
the real browser, inbound, outbound, and delayed-result acceptance
matrix passes.

## Model Used

OpenAI Codex, GPT-6, with tool use and code execution. The exact serving
model ID and context window are not exposed in this session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [ ] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-09 21:54:11 -05:00

423 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { mkdtemp, mkdir, readFile, readdir, chmod, lstat, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type { Db } from "@paperclipai/db";
import {
PAPERCLIP_OPERATIONAL_SKILL_KEY,
type PaperclipSkillEntry,
} from "@paperclipai/adapter-utils/server-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const serviceMocks = vi.hoisted(() => ({
exportFiles: vi.fn(),
readCommittedForRuntime: vi.fn(),
getEffectiveProfilesForAgent: vi.fn(),
githubBotConnectionIdsForRun: vi.fn(),
spekoToolsForSession: vi.fn(),
}));
vi.mock("../voice/speko-agent-tools.js", () => ({ spekoToolsForSession: serviceMocks.spekoToolsForSession }));
vi.mock("../chat-github-tools.js", () => ({
githubBotConnectionIdsForRun: serviceMocks.githubBotConnectionIdsForRun,
}));
vi.mock("../agent-instructions.js", () => ({
agentInstructionsService: () => ({ exportFiles: serviceMocks.exportFiles }),
agentInstructionsBundleMode: (agent: { adapterConfig?: { instructionsBundleMode?: string } }) => agent.adapterConfig?.instructionsBundleMode ?? null,
}));
vi.mock("../agent-instruction-revisions.js", () => ({
agentInstructionRevisionService: () => ({ readCommittedForRuntime: serviceMocks.readCommittedForRuntime }),
}));
vi.mock("../tool-access.js", () => ({
toolAccessService: () => ({
getEffectiveProfilesForAgent: serviceMocks.getEffectiveProfilesForAgent,
}),
}));
import { createHash } from "node:crypto";
import { buildNativeRuntimeContext, resolveNativeRuntimeMcpSnapshot } from "./runtime-context.js";
const temporaryRoots: string[] = [];
let previousPaperclipHome: string | undefined;
let previousInstanceId: string | undefined;
async function makeTreeWritable(target: string): Promise<void> {
const metadata = await lstat(target).catch(() => null);
if (!metadata) return;
if (metadata.isDirectory()) {
await chmod(target, 0o700);
for (const entry of await readdir(target)) {
await makeTreeWritable(path.join(target, entry));
}
} else {
await chmod(target, 0o600);
}
}
beforeEach(async () => {
vi.clearAllMocks();
serviceMocks.readCommittedForRuntime.mockResolvedValue(null);
serviceMocks.githubBotConnectionIdsForRun.mockResolvedValue(new Set());
serviceMocks.spekoToolsForSession.mockResolvedValue([]);
previousPaperclipHome = process.env.PAPERCLIP_HOME;
previousInstanceId = process.env.PAPERCLIP_INSTANCE_ID;
const root = await mkdtemp(path.join(tmpdir(), "paperclip-native-context-"));
temporaryRoots.push(root);
process.env.PAPERCLIP_HOME = root;
process.env.PAPERCLIP_INSTANCE_ID = "runtime_context_test";
serviceMocks.getEffectiveProfilesForAgent.mockResolvedValue({
agentId: "agent-1",
profiles: [],
entries: [{ effect: "include", connectionId: "connection-1" }],
bindings: [],
allowedTools: [{ id: "tool-1", connectionId: "connection-1" }],
allowedToolNames: ["issues.read"],
installedConnections: [{
id: "connection-1",
transport: "mcp_remote",
enabled: true,
status: "active",
healthStatus: "healthy",
}],
});
});
afterEach(async () => {
if (previousPaperclipHome === undefined) delete process.env.PAPERCLIP_HOME;
else process.env.PAPERCLIP_HOME = previousPaperclipHome;
if (previousInstanceId === undefined) delete process.env.PAPERCLIP_INSTANCE_ID;
else process.env.PAPERCLIP_INSTANCE_ID = previousInstanceId;
// A rejected Promise.all does not cancel the other materializers. Let their
// bounded local writes settle before removing the read-only asset tree.
await new Promise((resolve) => setTimeout(resolve, 25));
while (temporaryRoots.length) {
const root = temporaryRoots.pop()!;
await makeTreeWritable(root);
await rm(root, { recursive: true, force: true });
}
});
describe("buildNativeRuntimeContext", () => {
it("uses committed instructions when the disk projection is stale", async () => {
serviceMocks.exportFiles.mockResolvedValue({
entryFile: "AGENTS.md",
files: { "AGENTS.md": "Old disk projection\n", "reference.md": "Sibling reference\n" },
});
serviceMocks.readCommittedForRuntime.mockResolvedValue({
revision: { entryFile: "AGENTS.md" }, content: "Saved canonical instructions\n",
});
const context = await buildNativeRuntimeContext({
db: {} as Db,
agent: { id: "agent-1", companyId: "company-1", name: "Reviewer", adapterType: "paperclip_runner", adapterConfig: { instructionsBundleMode: "managed" } },
runId: "run-1", runtimeConfig: {}, runtimeSkillEntries: [],
});
expect(await readFile(path.join(context.instructions.bundle.rootPath, context.instructions.entryPath), "utf8"))
.toBe("Saved canonical instructions\n");
expect(await readFile(path.join(context.instructions.bundle.rootPath, "reference.md"), "utf8"))
.toBe("Sibling reference\n");
expect(serviceMocks.readCommittedForRuntime).toHaveBeenCalledWith({ companyId: "company-1", agentId: "agent-1" });
});
it("does not substitute canonical managed instructions into an external bundle", async () => {
serviceMocks.exportFiles.mockResolvedValue({ entryFile: "AGENTS.md", files: { "AGENTS.md": "External instructions\n" } });
const context = await buildNativeRuntimeContext({
db: {} as Db,
agent: { id: "agent-1", companyId: "company-1", name: "Reviewer", adapterConfig: { instructionsBundleMode: "external" } },
runId: "run-1", runtimeConfig: {}, runtimeSkillEntries: [],
});
expect(await readFile(path.join(context.instructions.bundle.rootPath, context.instructions.entryPath), "utf8"))
.toBe("External instructions\n");
expect(serviceMocks.readCommittedForRuntime).not.toHaveBeenCalled();
});
it("keeps the pinned prompt immutable while exposing the registered editable instruction copy", async () => {
serviceMocks.exportFiles.mockResolvedValue({
entryFile: "instructions/CHARter.md",
files: { "instructions/CHARter.md": "Original instructions.\n" },
});
const workingRoot = path.join(temporaryRoots.at(-1)!, "private-run-copy");
await mkdir(path.join(workingRoot, "instructions"), { recursive: true });
await writeFile(path.join(workingRoot, "instructions/CHARter.md"), "Original instructions.\n");
const context = await buildNativeRuntimeContext({
db: {} as Db,
agent: { id: "agent-1", companyId: "company-1", name: "Reviewer", adapterType: "paperclip_runner", adapterConfig: {} },
runId: "run-1",
runtimeConfig: {},
runtimeSkillEntries: [],
instructionWorkingCopy: { rootPath: workingRoot, entryPath: "instructions/CHARter.md" },
});
expect(context.instructions.workingCopy).toEqual({ rootPath: workingRoot, entryPath: "instructions/CHARter.md" });
await writeFile(path.join(workingRoot, "instructions/CHARter.md"), "Persist this next time.\n");
expect(await readFile(path.join(context.instructions.bundle.rootPath, context.instructions.entryPath), "utf8"))
.toBe("Original instructions.\n");
});
it.each(["disabled", "degraded"] as const)(
"omits an unavailable native MCP connection when it is %s without aborting runtime context creation",
async (unavailableState) => {
serviceMocks.exportFiles.mockResolvedValue({
entryFile: "AGENTS.md",
files: { "AGENTS.md": "Continue work without unavailable apps.\n" },
});
serviceMocks.getEffectiveProfilesForAgent.mockResolvedValue({
agentId: "agent-1",
profiles: [],
entries: [{ effect: "include", connectionId: "connection-1" }],
bindings: [],
allowedTools: [{ id: "tool-1", connectionId: "connection-1" }],
allowedToolNames: ["issues.read"],
installedConnections: [{
id: "connection-1",
transport: "mcp_remote",
enabled: unavailableState !== "disabled",
status: unavailableState === "disabled" ? "disabled" : "active",
healthStatus: unavailableState === "degraded" ? "degraded" : "healthy",
}],
});
const context = await buildNativeRuntimeContext({
db: {} as Db,
agent: {
id: "agent-1",
companyId: "company-1",
name: "Reviewer",
adapterType: "paperclip_runner",
adapterConfig: {},
},
runId: "run-1",
runtimeConfig: {},
runtimeSkillEntries: [],
});
expect(context.mcp.bindingId).toBeNull();
expect(context.mcp.assignmentSetId).toMatch(/^sha256:[a-f0-9]{64}$/);
},
);
it("keeps healthy native MCP connections when another assigned connection is unavailable", async () => {
serviceMocks.exportFiles.mockResolvedValue({
entryFile: "AGENTS.md",
files: { "AGENTS.md": "Continue work with the apps that are available.\n" },
});
serviceMocks.getEffectiveProfilesForAgent.mockResolvedValue({
agentId: "agent-1",
profiles: [],
entries: [
{ effect: "include", connectionId: "connection-expired" },
{ effect: "include", connectionId: "connection-healthy" },
],
bindings: [],
allowedTools: [
{ id: "tool-expired", connectionId: "connection-expired" },
{ id: "tool-healthy", connectionId: "connection-healthy" },
],
allowedToolNames: ["expired.read", "healthy.read"],
installedConnections: [
{
id: "connection-expired",
transport: "mcp_remote",
enabled: true,
status: "active",
healthStatus: "degraded",
},
{
id: "connection-healthy",
transport: "mcp_remote",
enabled: true,
status: "active",
healthStatus: "healthy",
},
],
});
const context = await buildNativeRuntimeContext({
db: {} as Db,
agent: {
id: "agent-1",
companyId: "company-1",
name: "Reviewer",
adapterType: "paperclip_runner",
adapterConfig: {},
},
runId: "run-1",
runtimeConfig: {},
runtimeSkillEntries: [],
});
expect(context.mcp.bindingId).toBe("native-mcp:run-1");
expect(context.mcp.assignmentSetId).toMatch(/^sha256:[a-f0-9]{64}$/);
});
it("materializes every instruction and selected-skill file as immutable, content-addressed context", async () => {
serviceMocks.exportFiles.mockResolvedValue({
entryFile: "AGENTS.md",
files: {
"AGENTS.md": "Follow the agent instructions.\n",
"references/policy.md": "Company policy sibling.\n",
},
});
const sourceRoot = await mkdtemp(path.join(tmpdir(), "paperclip-native-skill-"));
temporaryRoots.push(sourceRoot);
const selectedRoot = path.join(sourceRoot, "reviewer");
const unselectedRoot = path.join(sourceRoot, "unused");
await mkdir(path.join(selectedRoot, "references"), { recursive: true });
await mkdir(unselectedRoot, { recursive: true });
await writeFile(path.join(selectedRoot, "SKILL.md"), "# Reviewer\nUse the checklist.\n");
await writeFile(path.join(selectedRoot, "references", "checklist.md"), "- Verify tests\n");
await writeFile(path.join(unselectedRoot, "SKILL.md"), "# Not selected\n");
const entries: PaperclipSkillEntry[] = [
{ key: "company-1/reviewer", runtimeName: "reviewer", source: selectedRoot, versionId: "version-1" },
{ key: "company-1/unused", runtimeName: "unused", source: unselectedRoot, versionId: "version-2" },
];
const input = {
db: {} as Db,
agent: {
id: "agent-1",
companyId: "company-1",
name: "Reviewer",
adapterType: "paperclip_runner",
adapterConfig: {},
},
runId: "run-1",
runtimeConfig: { paperclipSkillSync: { desiredSkills: ["company-1/reviewer"] } },
runtimeSkillEntries: entries,
};
const context = await buildNativeRuntimeContext(input);
const repeated = await buildNativeRuntimeContext(input);
expect(serviceMocks.exportFiles).toHaveBeenCalledWith(input.agent, { rejectSymlinks: true });
expect(serviceMocks.getEffectiveProfilesForAgent).toHaveBeenCalledWith("company-1", "agent-1");
expect(context.instructions.entryPath).toBe("AGENTS.md");
expect(await readFile(path.join(context.instructions.bundle.rootPath, "AGENTS.md"), "utf8"))
.toBe("Follow the agent instructions.\n");
expect(await readFile(path.join(context.instructions.bundle.rootPath, "references", "policy.md"), "utf8"))
.toBe("Company policy sibling.\n");
const unselected = await buildNativeRuntimeContext({ ...input, runtimeConfig: {} });
expect(unselected.skills).toEqual([]);
expect(context.skills).toHaveLength(1);
expect(context.skills[0]).toMatchObject({
key: "company-1/reviewer",
runtimeName: "reviewer",
versionId: "version-1",
});
expect(await readFile(path.join(context.skills[0]!.bundle.rootPath, "SKILL.md"), "utf8"))
.toContain("Use the checklist");
expect(await readFile(path.join(context.skills[0]!.bundle.rootPath, "references", "checklist.md"), "utf8"))
.toBe("- Verify tests\n");
expect((await stat(context.instructions.bundle.rootPath)).mode & 0o222).toBe(0);
expect((await stat(path.join(context.instructions.bundle.rootPath, "AGENTS.md"))).mode & 0o222).toBe(0);
expect((await stat(context.skills[0]!.bundle.rootPath)).mode & 0o222).toBe(0);
expect(context.mcp).toMatchObject({
bindingId: "native-mcp:run-1",
assignmentSetId: expect.stringMatching(/^sha256:[a-f0-9]{64}$/),
digest: expect.stringMatching(/^[a-f0-9]{64}$/),
});
expect(repeated.aggregateDigest).toBe(context.aggregateDigest);
expect(repeated.instructions.bundle.rootPath).toBe(context.instructions.bundle.rootPath);
expect(repeated.skills[0]!.bundle.rootPath).toBe(context.skills[0]!.bundle.rootPath);
});
it("fails closed for a missing assigned skill and omits a stale legacy operational skill", async () => {
serviceMocks.exportFiles.mockResolvedValue({ entryFile: "AGENTS.md", files: { "AGENTS.md": "Test\n" } });
const base = {
db: {} as Db,
agent: {
id: "agent-1",
companyId: "company-1",
name: "Reviewer",
adapterType: "paperclip_runner",
adapterConfig: {},
},
runId: "run-1",
};
await expect(buildNativeRuntimeContext({
...base,
runtimeConfig: { paperclipSkillSync: { desiredSkills: ["company-1/missing"] } },
runtimeSkillEntries: [{
key: "company-1/missing",
runtimeName: "missing",
source: "/does/not/exist",
sourceStatus: "missing",
missingDetail: "assigned skill checkout is unavailable",
}],
})).rejects.toThrow("assigned skill checkout is unavailable");
const supportedRoot = await mkdtemp(path.join(tmpdir(), "paperclip-native-supported-skill-"));
temporaryRoots.push(supportedRoot);
await writeFile(path.join(supportedRoot, "SKILL.md"), "# Supported\n");
const context = await buildNativeRuntimeContext({
...base,
runtimeConfig: {
paperclipSkillSync: {
desiredSkills: [PAPERCLIP_OPERATIONAL_SKILL_KEY, "company-1/supported"],
},
},
runtimeSkillEntries: [
{
key: PAPERCLIP_OPERATIONAL_SKILL_KEY,
runtimeName: "paperclip",
source: "/unused",
},
{
key: "company-1/supported",
runtimeName: "supported",
source: supportedRoot,
},
],
});
expect(context.skills.map((skill) => skill.key)).toEqual(["company-1/supported"]);
});
});
it("pins both permitted GitHub tool catalogs even when another user’s health probe reports missing credentials", async () => {
const connections = ["github-A", "github-B"].map((id) => ({
id, status: "active", enabled: true, healthStatus: "missing_secret", transport: "mcp_remote",
config: { sourceTemplateKey: "github" }, transportConfig: {},
}));
serviceMocks.getEffectiveProfilesForAgent.mockResolvedValue({
entries: connections.map((connection) => ({ effect: "include", connectionId: connection.id })),
installedConnections: connections,
allowedTools: connections.map((connection) => ({ id: `${connection.id}-get_me`, connectionId: connection.id })),
});
const limit = vi.fn(async () => [{ responsibleUserId: "A", activeIdentityContextId: "context-A" }]);
const db = { select: () => ({ from: () => ({ where: () => ({ limit }) }) }) } as unknown as Db;
const snapshot = await resolveNativeRuntimeMcpSnapshot({ db, agent: { id: "agent-1", companyId: "company-1" }, runId: "run-1" });
const expected = createHash("sha256").update(JSON.stringify({
version: 1, agentId: "agent-1", connections: ["github-A", "github-B"],
tools: ["github-A-get_me", "github-B-get_me"],
})).digest("hex");
expect(snapshot.digest).toBe(expected);
expect(snapshot.bindingId).toBe("native-mcp:run-1");
expect(limit).toHaveBeenCalledOnce();
});
it("pins channel tools only when the current task run is bound to that bot", async () => {
serviceMocks.getEffectiveProfilesForAgent.mockResolvedValue({
entries: [{ effect: "include", connectionId: "bot-connection" }],
installedConnections: [{
id: "bot-connection", status: "active", enabled: true, healthStatus: "healthy",
transport: "chat_sdk", config: { sourceTemplateKey: "github-chat" }, transportConfig: {},
}],
allowedTools: [{ id: "bot-read-pr", connectionId: "bot-connection" }],
});
const input = { db: {} as Db, agent: { id: "agent-1", companyId: "company-1" }, runId: "run-1" };
expect((await resolveNativeRuntimeMcpSnapshot(input)).bindingId).toBeNull();
serviceMocks.githubBotConnectionIdsForRun.mockResolvedValue(new Set(["bot-connection"]));
expect((await resolveNativeRuntimeMcpSnapshot(input)).bindingId).toBe("native-mcp:run-1");
expect(serviceMocks.githubBotConnectionIdsForRun).toHaveBeenLastCalledWith(input.db, "company-1", "agent-1", "run-1");
});
it("pins a voice tool only when the current requester can call through that assigned endpoint", async () => {
serviceMocks.getEffectiveProfilesForAgent.mockResolvedValue({ entries: [{ effect: "include", connectionId: "voice-connection" }], installedConnections: [{ id: "voice-connection", status: "active", enabled: true, healthStatus: "healthy", transport: "voice", config: { provider: "speko" }, transportConfig: {} }], allowedTools: [{ id: "phone-tool", connectionId: "voice-connection" }] });
const limit = vi.fn().mockResolvedValue([{ contextSnapshot: { issueId: "task-1" }, activeIdentityContextId: "identity-1" }]);
const db = { select: () => ({ from: () => ({ where: () => ({ limit }) }) }) } as unknown as Db;
const input = { db, agent: { id: "agent-1", companyId: "company-1" }, runId: "run-1" };
expect((await resolveNativeRuntimeMcpSnapshot(input)).bindingId).toBeNull();
serviceMocks.spekoToolsForSession.mockResolvedValue([{ connectionId: "voice-connection" }]);
expect((await resolveNativeRuntimeMcpSnapshot(input)).bindingId).toBe("native-mcp:run-1");
expect(serviceMocks.spekoToolsForSession).toHaveBeenLastCalledWith(db, { companyId: "company-1", agentId: "agent-1", runId: "run-1", issueId: "task-1", identityContextId: "identity-1" });
});