mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +02:00
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>
423 lines
19 KiB
TypeScript
423 lines
19 KiB
TypeScript
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" });
|
||
});
|