mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 11:01:16 +01:00
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>
This commit is contained in:
1 parent
dd02595390
commit
1bc3ed6dd5
10 files changed
+152
-20
No files matched your search
@@ -2279,12 +2279,21 @@ HTTP mutation or create another receipt. Missing, changed, pending or unknown
|
||||
outcomes remain unanswered for authoritative reconciliation; a stopped run or
|
||||
revoked authority cannot recover a result.
|
||||
|
||||
The native Claude sidecar also sets its HTTP MCP client budget to one hour,
|
||||
matching the Runner connection lease budget. This prevents Claude's default
|
||||
The native Claude sidecar also sets its HTTP MCP client budget to one hour.
|
||||
This fixed client ceiling prevents Claude's default
|
||||
60-second HTTP deadline from aborting an otherwise pending durable request.
|
||||
The setting applies to that native Claude session's MCP client; other provider
|
||||
sessions and ordinary bridge deadlines remain unchanged. Explicit cancellation
|
||||
and Runner shutdown still take precedence over that client budget.
|
||||
The native OpenCode proxy uses the same one-hour MCP client budget and leaves
|
||||
its semantic bridge request pending under Runner ownership. A controller delay
|
||||
must not turn an in-flight write into a provider-visible timeout and retry.
|
||||
Ordinary OpenCode driver calls keep their 30-second deadline. Stop, bridge close
|
||||
and Runner lease expiry still end the native wait. Unanswered effects stay
|
||||
pending and continue to block a new run; a longer wait does not certify an outcome.
|
||||
The one-hour client deadline does not renew with the Runner lease. If the client
|
||||
deadline expires while Runner still owns a call, its effect remains unknown
|
||||
until authoritative reconciliation. Expiry permits neither replay nor attachment.
|
||||
An adopted transport can receive authenticated queued semantic calls before
|
||||
the driver publishes its recovered session and tool handler. It holds those
|
||||
calls until the handler is installed, then rechecks the original authority and
|
||||
|
||||
@@ -320,6 +320,18 @@ session-control access. Deployed diagnostic usefulness remains unverified.
|
||||
|
||||
## `session`
|
||||
|
||||
Native OpenCode semantic tool requests wait under the existing durable Runner
|
||||
owner across the ordinary 30-second MCP deadline. The native MCP client uses
|
||||
a fixed one-hour ceiling; ordinary driver calls keep their deadline.
|
||||
Company, tool, run and Stop authority do not change. Unanswered effects still
|
||||
block run attachment, including after the client ceiling expires while the
|
||||
Runner lease remains valid. The [proxy regression](../../packages/paperclip-runner/src/live/runnerd-opencode-streaming-tools.test.ts)
|
||||
checks delayed delivery, one tool call, and a settled next run with a deterministic
|
||||
provider. The [bridge suite](../../packages/paperclip-runner/src/drivers/opencode/mcp-bridge.test.ts)
|
||||
covers result replay, cancellation and teardown. These are local protocol checks;
|
||||
live provider and deployed access remain separate checks in the
|
||||
[session recipe](../feature-map/runs-adapters.md#session).
|
||||
|
||||
Fresh remote Retry preparation retains the admitted strategy in its saved
|
||||
fingerprint. An unchanged follow-up must not report strategy drift; actual policy
|
||||
changes retain the replacement guard. This changes no retry, session-reset,
|
||||
|
||||
@@ -129,6 +129,14 @@ the owner's newer completed checkpoint. The
|
||||
cover checkpoint restoration and its guards; real-provider acceptance remains
|
||||
separate evidence.
|
||||
|
||||
For native OpenCode, hold an assigned tool result for more than 30 seconds.
|
||||
Verify that the provider receives the saved result once and the next run can
|
||||
attach with no pending tool call. Stop and bridge teardown must still end an
|
||||
unanswered wait without claiming the action failed or repeating it. The
|
||||
[native proxy regression](../../packages/paperclip-runner/src/live/runnerd-opencode-streaming-tools.test.ts)
|
||||
uses the real Rust Runner and proxy with a deterministic provider; live provider
|
||||
and deployed sandbox acceptance remain separate evidence.
|
||||
|
||||
After a completed warm turn, change an agent setting that invalidates its
|
||||
instruction copy, then send a follow-up. Verify the old runner suspends during
|
||||
preparation, saved files remain available, and the follow-up completes once.
|
||||
|
||||
@@ -134,6 +134,9 @@ async function open(
|
||||
),
|
||||
runtimeContext,
|
||||
dynamicTools,
|
||||
// Runner owns these calls until a durable result, cancellation, or lease
|
||||
// expiry. A shorter MCP deadline would leave an unknown effect pending.
|
||||
durableToolRequests: true,
|
||||
completionFeedback: openCodeProxyCompletionFeedback((method, params) => requestController(method, params, true)),
|
||||
dynamicToolHandler: async (call) =>
|
||||
requestController("item/tool/call", {
|
||||
|
||||
@@ -31,6 +31,59 @@ async function rpc(
|
||||
}
|
||||
|
||||
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",
|
||||
@@ -247,10 +300,11 @@ describe("OpenCode MCP bridge", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("honors MCP cancellation notifications", async () => {
|
||||
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 });
|
||||
|
||||
@@ -29,7 +29,8 @@ export interface OpenCodeMcpBridgeOptions {
|
||||
/** Runner-owned operations callable only by a trusted harness extension and never advertised to the model. */
|
||||
privateTools?: readonly Readonly<Record<string, unknown>>[];
|
||||
handler: (call: OpenCodeMcpCall) => Promise<unknown>;
|
||||
timeoutMs?: number;
|
||||
/** Null leaves durable requests with the owning Runner lease and bridge close. */
|
||||
timeoutMs?: number | null;
|
||||
/** Human-gated private operations may remain pending much longer than model tool calls. */
|
||||
privateToolTimeoutMs?: number;
|
||||
maxBodyBytes?: number;
|
||||
@@ -76,7 +77,7 @@ export async function startOpenCodeMcpBridge(
|
||||
calls,
|
||||
controllers,
|
||||
handler: options.handler,
|
||||
timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
timeoutMs: options.timeoutMs === null ? null : options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
privateToolTimeoutMs: options.privateToolTimeoutMs ?? options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
privateToolNames: new Set(privateTools.map((tool) => tool.name)),
|
||||
maxBodyBytes: options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES,
|
||||
@@ -146,7 +147,7 @@ async function handleRequest(
|
||||
calls: Map<string, { fingerprint: string; promise: Promise<unknown> }>;
|
||||
controllers: Map<string, AbortController>;
|
||||
handler: OpenCodeMcpBridgeOptions["handler"];
|
||||
timeoutMs: number;
|
||||
timeoutMs: number | null;
|
||||
privateToolTimeoutMs: number;
|
||||
privateToolNames: ReadonlySet<string>;
|
||||
maxBodyBytes: number;
|
||||
@@ -321,23 +322,23 @@ function closeServer(server: Server): Promise<void> {
|
||||
function withCancellationAndTimeout<T>(
|
||||
promise: Promise<T>,
|
||||
controller: AbortController,
|
||||
timeoutMs: number,
|
||||
timeoutMs: number | null,
|
||||
): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const finish = (callback: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
if (timer !== undefined) clearTimeout(timer);
|
||||
controller.signal.removeEventListener("abort", onAbort);
|
||||
callback();
|
||||
};
|
||||
const onAbort = () => finish(() => reject(new Error("Paperclip tool call cancelled")));
|
||||
const timer = setTimeout(() => {
|
||||
const timer = timeoutMs === null ? undefined : setTimeout(() => {
|
||||
finish(() => reject(new Error("Paperclip tool call timed out")));
|
||||
controller.abort();
|
||||
}, timeoutMs);
|
||||
timer.unref?.();
|
||||
timer?.unref?.();
|
||||
controller.signal.addEventListener("abort", onAbort, { once: true });
|
||||
promise.then(
|
||||
(value) => finish(() => resolve(value)),
|
||||
|
||||
@@ -153,6 +153,22 @@ afterAll(async () => {
|
||||
});
|
||||
|
||||
describe("OpenCodeServerDriver", () => {
|
||||
it.each([false, true])("uses the native MCP client ceiling only for durable tool requests (%s)", async durableToolRequests => {
|
||||
await chmod(fixture, 0o755);
|
||||
const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-tool-budget-"));
|
||||
roots.push(root);
|
||||
const driver = new OpenCodeServerDriver({
|
||||
model: "openrouter/test-model", command: fixture, runtimeDirectory: root, durableToolRequests,
|
||||
});
|
||||
const session = await driver.openSession({ runId: "budget", normalizedSessionId: "budget", workingDirectory: root });
|
||||
try {
|
||||
const config = JSON.parse(await readFile(join(root, "budget", "config", "opencode", "opencode.json"), "utf8"));
|
||||
expect(config.mcp.paperclip.timeout).toBe(durableToolRequests ? 60 * 60 * 1_000 : 30_000);
|
||||
} finally {
|
||||
await session.close({ reason: "test" });
|
||||
}
|
||||
});
|
||||
|
||||
it("exports company traces across warm runs without giving the ingestion key to the provider", async () => {
|
||||
await chmod(fixture, 0o755);
|
||||
const bodies: Array<Record<string, any>> = [];
|
||||
|
||||
@@ -198,6 +198,8 @@ export interface OpenCodeServerDriverOptions {
|
||||
environment?: NodeJS.ProcessEnv;
|
||||
dynamicTools?: readonly Readonly<Record<string, unknown>>[];
|
||||
dynamicToolHandler?: DynamicToolHandler;
|
||||
/** Internal native proxy capability: semantic calls have durable PRP ownership. */
|
||||
durableToolRequests?: boolean;
|
||||
completionFeedback?: OpenCodeCompletionFeedback;
|
||||
onSpawn?: (meta: {
|
||||
pid: number;
|
||||
@@ -2431,6 +2433,7 @@ async function startRuntime(input: {
|
||||
const bridge = await startOpenCodeMcpBridge({
|
||||
tools: input.options.dynamicTools,
|
||||
handler: input.dispatch,
|
||||
...(input.options.durableToolRequests ? { timeoutMs: null } : {}),
|
||||
}).catch(async (error) => {
|
||||
await removeIsolatedHome(isolatedHome).catch(() => undefined);
|
||||
throw error;
|
||||
@@ -2558,7 +2561,10 @@ async function startRuntime(input: {
|
||||
enabled: true,
|
||||
oauth: false,
|
||||
headers: { Authorization: `Bearer ${bridge.secret}` },
|
||||
timeout: 30_000,
|
||||
// Use the same fixed one-hour MCP client ceiling as native ACPX.
|
||||
// Runner ownership can outlive this deadline; an unanswered effect
|
||||
// must still remain pending and block replay after client expiry.
|
||||
timeout: input.options.durableToolRequests ? 60 * 60 * 1_000 : 30_000,
|
||||
},
|
||||
...(assignedMcp
|
||||
? {
|
||||
|
||||
@@ -12,7 +12,7 @@ const runner = defaultCapabilityRunnerdBinary();
|
||||
const compilerAvailable = process.platform !== "win32" && spawnSync("cc", ["--version"]).status === 0;
|
||||
const digest = (path: string) => `sha256:${createHash("sha256").update(readFileSync(path)).digest("hex")}`;
|
||||
|
||||
it.skipIf(!compilerAvailable || !existsSync(runner)).each(["discovery", "completion"])(
|
||||
it.skipIf(!compilerAvailable || !existsSync(runner)).each(["discovery", "completion", "durable"])(
|
||||
"acknowledges %s before its MCP deadline after a burst of streamed text",
|
||||
async tool => {
|
||||
const root = await mkdtemp(join(tmpdir(), "runnerd-opencode-streaming-"));
|
||||
@@ -44,10 +44,14 @@ it.skipIf(!compilerAvailable || !existsSync(runner)).each(["discovery", "complet
|
||||
providerNodeCommand: providerNode, providerNodeCommandSha256: digest(providerNode),
|
||||
opencodeCommand: executable, opencodeCommandSha256: digest(executable),
|
||||
opencodeProxyPath: proxy, opencodeProxySha256: digest(proxy),
|
||||
...(tool === "durable" ? { lifecyclePolicy: { mode: "warm" as const, idleTimeoutMs: 60_000 } } : {}),
|
||||
environment: { PATH: process.env.PATH, OPENROUTER_API_KEY: "synthetic-fixture-key" },
|
||||
});
|
||||
transport.setServerRequestHandler(async request => {
|
||||
seen.push(String(request.params.tool));
|
||||
// Keep the real Rust → proxy → HTTP MCP request open beyond both old
|
||||
// 30-second deadlines. The saved result must reach the provider once.
|
||||
if (tool === "durable" && seen.length === 1) await new Promise(resolve => setTimeout(resolve, 31_000));
|
||||
return dynamicToolResponse(request.params.tool === "paperclip_finish" ? "Accepted completion." : { tools: [] });
|
||||
});
|
||||
let completed: Promise<void> | undefined;
|
||||
@@ -67,10 +71,23 @@ it.skipIf(!compilerAvailable || !existsSync(runner)).each(["discovery", "complet
|
||||
await expect.poll(async () => {
|
||||
try { return JSON.parse(await readFile(join(workspace, "fake-stream-backlog-receipt.json"), "utf8")); }
|
||||
catch { return null; }
|
||||
}, { timeout: 15_000 }).toMatchObject({ acknowledged: true });
|
||||
}, { timeout: tool === "durable" ? 45_000 : 15_000 }).toMatchObject({ acknowledged: true });
|
||||
await completed;
|
||||
expect(seen).toEqual(tool === "discovery" ? ["paperclip_search_assigned_tools", "paperclip_finish"] : ["paperclip_finish"]);
|
||||
expect(seen).toEqual(tool !== "completion" ? ["paperclip_search_assigned_tools", "paperclip_finish"] : ["paperclip_finish"]);
|
||||
expect(text.join("")).toBe("Streaming progress. ".repeat(800));
|
||||
if (tool === "durable") {
|
||||
const runnerPid = transport.processInfo().pid;
|
||||
await transport.attachRun!({ runId: "next-settled-run", turnId: "next-turn", itemId: "next-item" });
|
||||
completed = (async () => {
|
||||
for await (const event of transport.notifications()) if (event.method === "turn/completed") return;
|
||||
})();
|
||||
await transport.request("turn/start", { input: [{ type: "text", text: "stream-backlog-discovery" }] });
|
||||
await completed;
|
||||
expect(transport.processInfo().pid).toBe(runnerPid);
|
||||
expect(seen).toEqual(["paperclip_search_assigned_tools", "paperclip_finish", "paperclip_search_assigned_tools", "paperclip_finish"]);
|
||||
const provider = JSON.parse(await readFile(join(root, "state", "runner", "codex-provider-state.json"), "utf8"));
|
||||
expect(provider.toolBridge.pending).toEqual({});
|
||||
}
|
||||
} finally {
|
||||
await transport.close();
|
||||
await completed;
|
||||
|
||||
@@ -160,18 +160,23 @@ function emitFrame(value) {
|
||||
for (const response of clients) response.write(frame);
|
||||
}
|
||||
|
||||
let retainedCompletionContract;
|
||||
function parsedPromptText(promptBody) {
|
||||
const text = promptBody.parts?.[0]?.text ?? "";
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
const prompt = JSON.parse(text);
|
||||
retainedCompletionContract = prompt.completionContract ?? prompt.task?.completionContract ?? retainedCompletionContract;
|
||||
return prompt;
|
||||
} catch {
|
||||
return { message: text };
|
||||
// Ordinary follow-ups contain only the new message. The real provider
|
||||
// retains its task contract in conversation history across these turns.
|
||||
return { message: text, completionContract: retainedCompletionContract };
|
||||
}
|
||||
}
|
||||
|
||||
async function mcpRequest(method, params, signal) {
|
||||
const response = await fetch(mcp.url, {
|
||||
signal,
|
||||
signal: signal ?? AbortSignal.timeout(mcp.timeout),
|
||||
method: "POST",
|
||||
headers: {
|
||||
...mcp.headers,
|
||||
@@ -478,7 +483,7 @@ const server = createServer(async (request, response) => {
|
||||
}
|
||||
setTimeout(async () => {
|
||||
const parsedPrompt = parsedPromptText(promptPayload);
|
||||
const backlogTool = String(parsedPrompt.message ?? "").match(/stream-backlog-(discovery|completion)/)?.[1];
|
||||
const backlogTool = String(parsedPrompt.message ?? parsedPrompt.task?.prompt ?? "").match(/stream-backlog-(discovery|completion|durable)/)?.[1];
|
||||
if (backlogTool) {
|
||||
const messageID = "message-stream-backlog";
|
||||
const partID = "part-stream-backlog";
|
||||
@@ -491,13 +496,14 @@ const server = createServer(async (request, response) => {
|
||||
try {
|
||||
const result = backlogTool === "completion"
|
||||
? await callTerminalTool(promptPayload, AbortSignal.timeout(5000))
|
||||
: await mcpRequest("tools/call", { name: "paperclip_search_assigned_tools", arguments: {} }, AbortSignal.timeout(5000));
|
||||
: await mcpRequest("tools/call", { name: "paperclip_search_assigned_tools", arguments: {} },
|
||||
backlogTool === "durable" ? undefined : AbortSignal.timeout(5000));
|
||||
receipt = { acknowledged: result.result?.isError !== true && !!result.result, result };
|
||||
} catch (error) {
|
||||
receipt = { acknowledged: false, timedOut: error.name === "TimeoutError" };
|
||||
}
|
||||
await writeFile(join(process.cwd(), "fake-stream-backlog-receipt.json"), JSON.stringify(receipt));
|
||||
if (backlogTool === "discovery" && receipt.acknowledged) await callTerminalTool(promptPayload);
|
||||
if (backlogTool !== "completion" && receipt.acknowledged) await callTerminalTool(promptPayload);
|
||||
emit({ type: "session.idle", properties: { sessionID: session.id } });
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user