Files
PaperClipAI/packages/paperclip-runner/src/drivers/opencode/mcp-bridge.test.ts
T
DottaandPaperclip 1bc3ed6dd5 fix(opencode): keep native tool calls pending until Runner settlement (#18711)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Native OpenCode sends semantic tool calls through the durable Runner
protocol.
> - The Runner retains a call until its result is known.
> - OpenCode and its HTTP bridge each stopped waiting after 30 seconds.
> - A slow response could leave a call pending after the provider had
moved on.
> - A later run then failed the pending-call guard during attachment.
> - This pull request keeps native calls under their Runner owner until
settlement or cancellation.
> - The benefit is one delivered result and a safe next run.

## Linked Issues or Issue Description

Related: #18343 applies the durable wait policy to native ACPX calls.
This change applies that policy to native OpenCode.

**What happened?**

An assigned tool call took more than 30 seconds. OpenCode reported an
MCP timeout while the Runner still retained the request. The source turn
could finish, but suspension failed. A later `run.attach` failed because
provider tool calls were still pending.

**Expected behavior**

The native proxy must wait for the authoritative result under the Runner
owner. Stop, bridge close, and lease expiry must still end the wait.
Unknown effects must still block attachment.

**Steps to reproduce**

1. Start a native OpenCode run with an assigned semantic tool.
2. Hold the controller's result for 31 seconds.
3. Observe the provider's MCP result and attach a later run to the
session.

**Paperclip version or commit**

Reproduced against `6d72243d8af0362d73fbcb5331fd42a384800d4e`. The
branch is based on `36df8933f0`.

## What Changed

- The native OpenCode proxy enables a driver capability for durable tool
requests.
- That capability removes the bridge's separate request timer and sets
OpenCode's fixed MCP client ceiling to one hour, consistent with the
native ACPX sidecar.
- Ordinary driver calls keep their 30-second deadline. Assigned external
MCP connections keep their existing budget.
- Cancellation, result replay, tool authorization, and the pending-call
attachment guard keep their existing contracts.
- The native regression holds a result for 31 seconds, then completes a
second run with the same Runner process and no pending provider calls.
- The deterministic provider fixture honors the emitted MCP budget and
retains the initial completion contract across ordinary follow-ups.
- Execution semantics and the session verification maps describe the
wait and its safety checks.

## Verification

- Red before production edits: the bridge aborted a durable request at
the ordinary 30-second boundary. The driver emitted `30000` instead of
the expected `3600000` client budget.
- The final combined bridge, driver, proxy, and native protocol run
passed: 95 tests in five files (71.17 seconds).
- The native delayed-result probe passed with the current Rust Runner
and current bundled proxy. It completed the later run with the same
Runner PID and an empty pending-call ledger.
- Actual provider check passed on macOS arm64 with
`opencode-ai@1.18.34`, the changed source, and a local deterministic
OpenAI-compatible model. One assigned write waited 31 seconds and saved
one file. Both turns completed. The second turn retained the saved tool
receipt. The write ran once across five model requests.
- Bridge safety cases cover result replay without another action,
ordinary timeout behavior, MCP cancellation, and teardown of an
unanswered durable request.
- Both runner TypeScript projects passed typecheck. The first attempt
lacked the eval-kernel build; it passed after building that declared
dependency.
- Map alignment, module boundaries, and `git diff --check` passed.
- The first native follow-up probe failed because its synthetic provider
forgot the initial completion-contract revision. This caused a repair
turn and another discovery call. The fixture now retains the contract,
and the same probe passed. The failure is retained here.
- Runtime acceptance used the implementation at `583d316ad4`. The later
clarification commit changes comments, documentation, and a test name
only. It does not change runtime behavior.
- Full current-head CI passes, including typecheck, build, general and
serialized tests, Rust/Runner checks, all eight E2E shards, and the `ci
/ e2e` and `ci / verify` aggregate gates. Greptile is 5/5 on this
commit, with no unresolved review threads. No deployment or retry of the
affected historical task was performed. The actual provider check used a
local model; it does not qualify a deployed Linux sandbox or a live
model service.

## Risks

- A native call can wait longer while its Runner owner remains valid.
Stop, teardown, and lease expiry still end the wait. The MCP client has
a separate fixed one-hour ceiling. Runner ownership can renew beyond
that ceiling. If the client deadline expires first, the operation
outcome remains unknown and the existing hold must still block replay
and run attachment.
- This prevents new premature timeouts. It does not classify or replay
an old request whose effect remains unknown.
- No schema, migration, credential, or permission change is required.

## Model Used

- OpenAI Codex, GPT-6 family. The harness does not expose the exact
model ID or context window. Capabilities used: code generation,
tool-assisted diagnosis, local execution, and test analysis.

## 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
- [x] 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-11-03 06:11:24 -06:00

329 lines
16 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest";
import {
PRP_BLOCK_RESULT_PROVIDER_INPUT_SCHEMA,
PRP_BLOCK_TOOL_DESCRIPTION,
PRP_COMPLETION_RESULT_PROVIDER_INPUT_SCHEMA,
PRP_COMPLETION_TOOL_DESCRIPTION,
} from "../../contracts/completion-result.js";
import { canonicalOpenCodeMcpToolName, startOpenCodeMcpBridge, type OpenCodeMcpBridge } from "./mcp-bridge.js";
const bridges: OpenCodeMcpBridge[] = [];
afterEach(async () => {
await Promise.all(bridges.splice(0).map((bridge) => bridge.close()));
});
async function rpc(
bridge: OpenCodeMcpBridge,
body: Record<string, unknown>,
secret = bridge.secret,
) {
return fetch(bridge.url, {
method: "POST",
headers: {
Authorization: `Bearer ${secret}`,
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
},
body: JSON.stringify({ jsonrpc: "2.0", ...body }),
});
}
describe("OpenCode MCP bridge", () => {
it("keeps a durable call pending across the ordinary timeout and replays its one result", async () => {
let started!: () => void;
const begun = new Promise<void>(resolve => { started = resolve; });
let complete!: (value: unknown) => void;
const result = new Promise<unknown>(resolve => { complete = resolve; });
const aborted = vi.fn();
const handler = vi.fn(({ signal }) => {
signal.addEventListener("abort", aborted, { once: true });
started();
return result;
});
const bridge = await startOpenCodeMcpBridge({
tools: [{ name: "documents.write", inputSchema: { type: "object" } }],
timeoutMs: null,
handler,
});
bridges.push(bridge);
const request = { id: "durable-write", method: "tools/call", params: { name: "documents.write", arguments: {} } };
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
const pending = rpc(bridge, request).then(response => response.json());
try {
await begun;
await vi.advanceTimersByTimeAsync(30_001);
expect(aborted).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
complete({ receipt: "saved-once" });
}
const delivered = await pending;
expect(delivered).toMatchObject({ result: { content: [{ text: '{"receipt":"saved-once"}' }] } });
expect(await (await rpc(bridge, request)).json()).toEqual(delivered);
expect(handler).toHaveBeenCalledOnce();
});
it("cancels an unanswered durable call when the bridge closes without inventing a result", async () => {
let started!: () => void;
const begun = new Promise<void>(resolve => { started = resolve; });
const aborted = vi.fn();
const handler = vi.fn(({ signal }) => new Promise((_resolve, reject) => {
signal.addEventListener("abort", () => { aborted(); reject(new Error("unknown outcome")); }, { once: true });
started();
}));
const bridge = await startOpenCodeMcpBridge({
tools: [{ name: "documents.write", inputSchema: { type: "object" } }], timeoutMs: null, handler,
});
const pending = rpc(bridge, { id: "close-durable", method: "tools/call", params: { name: "documents.write", arguments: {} } });
await begun;
await bridge.close();
expect(await (await pending).json()).toMatchObject({ result: { isError: true, content: [{ text: "Paperclip tool call cancelled" }] } });
expect(aborted).toHaveBeenCalledOnce();
expect(handler).toHaveBeenCalledOnce();
});
it("binds to loopback, requires authentication, and exposes only its closed catalog", async () => {
const bridge = await startOpenCodeMcpBridge({
secret: "session-secret",
tools: [{ name: "documents.read", description: "Read", inputSchema: { type: "object" } }],
handler: async () => ({ ok: true }),
});
bridges.push(bridge);
expect(new URL(bridge.url).hostname).toBe("127.0.0.1");
expect((await rpc(bridge, { id: 1, method: "tools/list" }, "wrong")).status).toBe(401);
const listed = await rpc(bridge, { id: 2, method: "tools/list" });
expect(await listed.json()).toMatchObject({
result: { tools: [
{ name: "documents.read" },
{ name: "paperclip_finish", description: PRP_COMPLETION_TOOL_DESCRIPTION, inputSchema: PRP_COMPLETION_RESULT_PROVIDER_INPUT_SCHEMA },
{ name: "paperclip_block", description: PRP_BLOCK_TOOL_DESCRIPTION, inputSchema: PRP_BLOCK_RESULT_PROVIDER_INPUT_SCHEMA },
] },
});
});
it("delivers a complete large assigned-tool result and replays its receipt without repeating the action", async () => {
const files = Array.from({ length: 27 }, (_, index) => ({
filename: `src/public-fixture-${index}.ts`,
patch: `@@ synthetic public fixture ${index} @@\n${"+ documented change\n".repeat(220)}`,
}));
const data = { items: files, page: 1, hasMore: false, receipt: "LAST_FILE_AND_RECEIPT_INTACT" };
const result = { content: [{ type: "text", text: JSON.stringify(data) }], data };
expect(JSON.stringify(result).length).toBeGreaterThan(111_877);
const handler = vi.fn(async () => result);
const bridge = await startOpenCodeMcpBridge({
tools: [{ name: "paperclip_call_assigned_tool", inputSchema: { type: "object" } }], handler,
});
bridges.push(bridge);
const request = { id: "large-result", method: "tools/call", params: { name: "paperclip_call_assigned_tool", arguments: {} } };
for (let attempt = 0; attempt < 2; attempt++) {
const response = await (await rpc(bridge, request)).json() as { result: { content: Array<{ text: string }> } };
expect(JSON.parse(response.result.content[0]!.text)).toEqual(result);
}
expect(handler).toHaveBeenCalledTimes(1);
});
it("preserves producer-sized pages with UTF-8 and heavily escaped nested data", async () => {
const chunk = '\"\\\n\t\u0000😀';
const result = { responseText: { nextOffsetBytes: 300_000, totalBytes: 600_000 }, data: chunk.repeat(25_000) };
expect(Buffer.byteLength(JSON.stringify(result))).toBeLessThanOrEqual(500_000);
expect(Buffer.byteLength(JSON.stringify(result))).toBeGreaterThan(64 * 1024);
// The driver returns a controller text item, not the raw app page. Its
// encoded JSON must survive a second layer of escaping at this boundary.
const controllerResult = { text: JSON.stringify(result), type: "inputText" };
expect(Buffer.byteLength(JSON.stringify(controllerResult))).toBeGreaterThan(500_000);
const bridge = await startOpenCodeMcpBridge({
tools: [{ name: "read_tool_result", inputSchema: { type: "object" } }], handler: async () => controllerResult,
});
bridges.push(bridge);
const response = await (await rpc(bridge, { id: "page", method: "tools/call", params: { name: "read_tool_result", arguments: {} } })).json() as { result: { content: Array<{ text: string }> } };
const delivered = JSON.parse(response.result.content[0]!.text) as typeof controllerResult;
expect(delivered).toEqual(controllerResult);
expect(JSON.parse(delivered.text)).toEqual(result);
});
it.each([
["UTF-8 scalar", () => ({ data: "😀".repeat(300_000) })],
["escaped scalar", () => ({ data: "\u0000".repeat(200_000) })],
["many fields", () => Array.from({ length: 600_000 }, () => 0)],
])("rejects an oversized %s without a partial success or repeating the completed call", async (_name, makeResult) => {
const handler = vi.fn(async () => makeResult());
const bridge = await startOpenCodeMcpBridge({
tools: [{ name: "assigned.write", inputSchema: { type: "object" } }], handler,
});
bridges.push(bridge);
const request = { id: "write-receipt", method: "tools/call", params: { name: "assigned.write", arguments: {} } };
for (let attempt = 0; attempt < 2; attempt++) {
expect(await (await rpc(bridge, request)).json()).toMatchObject({
result: { isError: true, content: [{ text: expect.stringContaining("may already have completed; do not repeat it") }] },
});
}
expect(handler).toHaveBeenCalledTimes(1);
});
it("admits runner-private extension operations without advertising them to the model", async () => {
const handler = vi.fn(async ({ tool }) => ({ decision: tool === "__paperclip_permission" ? "accept" : "decline" }));
const bridge = await startOpenCodeMcpBridge({
tools: [{ name: "documents.read", inputSchema: { type: "object" } }],
privateTools: [{ name: "__paperclip_permission", inputSchema: { type: "object" } }],
handler,
});
bridges.push(bridge);
const listed = await (await rpc(bridge, { id: 1, method: "tools/list" })).json() as { result: { tools: Array<{ name: string }> } };
expect(listed.result.tools.map((tool) => tool.name)).not.toContain("__paperclip_permission");
expect(await (await rpc(bridge, {
id: "permission-1",
method: "tools/call",
params: { name: "__paperclip_permission", arguments: {} },
})).json()).toMatchObject({ result: { content: [{ text: "{\"decision\":\"accept\"}" }] } });
expect(handler).toHaveBeenCalledWith(expect.objectContaining({ tool: "__paperclip_permission" }));
});
it("maps prefixed names and replays identical duplicate calls exactly once", async () => {
const handler = vi.fn(async () => ({ value: 7 }));
const bridge = await startOpenCodeMcpBridge({
tools: [{ name: "documents.read", inputSchema: { type: "object" } }],
handler,
});
bridges.push(bridge);
const request = { id: "call-1", method: "tools/call", params: { name: "paperclip_documents.read", arguments: { id: "doc" } } };
expect(await (await rpc(bridge, request)).json()).toMatchObject({ result: { content: [{ text: "{\"value\":7}" }] } });
expect(await (await rpc(bridge, request)).json()).toMatchObject({ result: { content: [{ text: "{\"value\":7}" }] } });
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(expect.objectContaining({ tool: "documents.read", callId: "call-1", arguments: { id: "doc" } }));
});
it("fails conflicting duplicate identities and handler errors closed", async () => {
const bridge = await startOpenCodeMcpBridge({
tools: [{ name: "documents.read", inputSchema: { type: "object" } }],
handler: async () => { throw new Error("denied"); },
});
bridges.push(bridge);
const failed = await rpc(bridge, { id: "call-2", method: "tools/call", params: { name: "documents.read", arguments: { id: "a" } } });
expect(await failed.json()).toMatchObject({ result: { isError: true, content: [{ text: "denied" }] } });
const conflict = await rpc(bridge, { id: "call-2", method: "tools/call", params: { name: "documents.read", arguments: { id: "b" } } });
expect(await conflict.json()).toMatchObject({ result: { isError: true, content: [{ text: "Duplicate call identity conflict." }] } });
});
it("preserves declared prefixed operation IDs through HTTP MCP and rejects undeclared aliases", async () => {
const names = ["paperclip_search_assigned_tools", "paperclip_call_assigned_tool", "search_assigned_tools"];
const handler = vi.fn(async call => ({ operation: call.tool }));
const bridge = await startOpenCodeMcpBridge({ tools: names.map(name => ({ name, inputSchema: { type: "object" } })), handler });
bridges.push(bridge);
const listed = await (await rpc(bridge, { id: "list", method: "tools/list" })).json() as { result: { tools: Array<{ name: string }> } };
expect(listed.result.tools.map(tool => tool.name)).toEqual([...names, "paperclip_finish", "paperclip_block"]);
for (const [i, name] of names.entries()) {
for (const [j, prefix] of ["", "paperclip_", "paperclip__", "paperclip."].entries()) {
const called = prefix === "paperclip_" && name === "search_assigned_tools" ? name : prefix + name;
const result = await (await rpc(bridge, { id: `call-${i}-${j}`, method: "tools/call", params: { name: called, arguments: {} } })).json();
expect(result).toMatchObject({ result: { content: [{ text: JSON.stringify({ operation: name }) }] } });
}
}
const before = handler.mock.calls.length;
expect(await (await rpc(bridge, { id: "unknown", method: "tools/call", params: { name: "paperclip_unassigned_tool", arguments: {} } })).json()).toMatchObject({ result: { isError: true } });
expect(handler).toHaveBeenCalledTimes(before);
});
it("does not authorize a shortened operation when only its prefixed name is declared", async () => {
const handler = vi.fn(async () => ({ ok: true }));
const bridge = await startOpenCodeMcpBridge({ tools: [{ name: "paperclip_search_assigned_tools", inputSchema: { type: "object" } }], handler });
bridges.push(bridge);
expect(await (await rpc(bridge, { id: "short", method: "tools/call", params: { name: "search_assigned_tools", arguments: {} } })).json()).toMatchObject({ result: { isError: true } });
expect(handler).not.toHaveBeenCalled();
});
it("normalizes all supported OpenCode MCP prefixes", () => {
expect(canonicalOpenCodeMcpToolName("paperclip_documents.read")).toBe("documents.read");
expect(canonicalOpenCodeMcpToolName("paperclip__documents.read")).toBe("documents.read");
expect(canonicalOpenCodeMcpToolName("paperclip.documents.read")).toBe("documents.read");
expect(canonicalOpenCodeMcpToolName("paperclip_paperclip_finish")).toBe("paperclip_finish");
});
it("validates inputs before dispatch", async () => {
const handler = vi.fn(async () => ({ ok: true }));
const bridge = await startOpenCodeMcpBridge({
tools: [{
name: "documents.read",
inputSchema: { type: "object", required: ["id"], properties: { id: { type: "string" } }, additionalProperties: false },
}],
handler,
});
bridges.push(bridge);
const response = await rpc(bridge, {
id: "invalid",
method: "tools/call",
params: { name: "documents.read", arguments: { id: 9 } },
});
expect(await response.json()).toMatchObject({ result: { isError: true } });
expect(handler).not.toHaveBeenCalled();
});
it("times out and cancels in-flight calls", async () => {
const observed = vi.fn();
const bridge = await startOpenCodeMcpBridge({
tools: [{ name: "documents.read", inputSchema: { type: "object" } }],
timeoutMs: 20,
handler: ({ signal }) => new Promise((_resolve, reject) => {
signal.addEventListener("abort", () => { observed(); reject(new Error("aborted")); }, { once: true });
}),
});
bridges.push(bridge);
const response = await rpc(bridge, {
id: "slow-call",
method: "tools/call",
params: { name: "documents.read", arguments: {} },
});
expect(await response.json()).toMatchObject({ result: { isError: true, content: [{ text: "Paperclip tool call timed out" }] } });
expect(observed).toHaveBeenCalledOnce();
});
it("allows human-gated private calls to outlive ordinary tool timeouts", async () => {
const bridge = await startOpenCodeMcpBridge({
tools: [{ name: "documents.read", inputSchema: { type: "object" } }],
privateTools: [{ name: "__paperclip_runtime_input", inputSchema: { type: "object" } }],
timeoutMs: 20,
privateToolTimeoutMs: 200,
handler: async ({ tool }) => {
await new Promise((resolve) => setTimeout(resolve, 50));
return { tool, resolved: true };
},
});
bridges.push(bridge);
const response = await rpc(bridge, {
id: "human-input",
method: "tools/call",
params: { name: "__paperclip_runtime_input", arguments: {} },
});
expect(await response.json()).toMatchObject({
result: { content: [{ text: expect.stringContaining("\"resolved\":true") }] },
});
});
it.each([undefined, null])("honors MCP cancellation notifications (timeout=%s)", async timeoutMs => {
const started = vi.fn();
const bridge = await startOpenCodeMcpBridge({
tools: [{ name: "documents.read", inputSchema: { type: "object" } }],
timeoutMs,
handler: ({ signal }) => new Promise((_resolve, reject) => {
started();
signal.addEventListener("abort", () => reject(new Error("handler aborted")), { once: true });
}),
});
bridges.push(bridge);
const pending = rpc(bridge, {
id: "cancel-me",
method: "tools/call",
params: { name: "documents.read", arguments: {} },
});
await vi.waitFor(() => expect(started).toHaveBeenCalledOnce());
expect((await rpc(bridge, {
method: "notifications/cancelled",
params: { requestId: "cancel-me", reason: "turn stopped" },
})).status).toBe(202);
expect(await (await pending).json()).toMatchObject({
result: { isError: true, content: [{ text: "Paperclip tool call cancelled" }] },
});
});
});