mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-06 14:02:36 +01:00
## 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>
329 lines
16 KiB
TypeScript
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" }] },
|
|
});
|
|
});
|
|
});
|