mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-05 03:51:54 +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>
1042 lines
37 KiB
JavaScript
1042 lines
37 KiB
JavaScript
#!/usr/bin/env node
|
|
import { createServer } from "node:http";
|
|
import { execFile } from "node:child_process";
|
|
import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
|
|
const args = process.argv.slice(2);
|
|
// Exercise diagnostic redaction without copying a real environment credential
|
|
// into stderr or the provider trace.
|
|
process.stderr.write("authorization=synthetic-redaction-sentinel\n");
|
|
const port = Number(args[args.indexOf("--port") + 1]);
|
|
const username = process.env.OPENCODE_SERVER_USERNAME ?? "opencode";
|
|
const password = process.env.OPENCODE_SERVER_PASSWORD ?? "";
|
|
const expectedAuth = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`;
|
|
const session = { id: "ses_fake_1", title: "Paperclip fake" };
|
|
const clients = new Set();
|
|
let eventConnections = 0;
|
|
const runtimeConfig = JSON.parse(
|
|
await readFile(
|
|
join(process.env.XDG_CONFIG_HOME, "opencode", "opencode.json"),
|
|
"utf8",
|
|
),
|
|
);
|
|
const mcp = runtimeConfig.mcp?.paperclip;
|
|
let mcpRequestId = 1;
|
|
const mcpEvidence = { tools: [], calls: [] };
|
|
|
|
function nativeQuestion() {
|
|
return {
|
|
id: "question-native-1",
|
|
sessionID: session.id,
|
|
questions: [
|
|
{
|
|
id: "environment",
|
|
header: "Environment",
|
|
question: "Where should we deploy?",
|
|
options: [
|
|
{ label: "Staging", description: "Deploy to staging first." },
|
|
{
|
|
label: "Production",
|
|
description: "Deploy directly to production.",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: "regions",
|
|
header: "Regions",
|
|
question: "Which regions?",
|
|
options: [{ label: "US" }, { label: "EU" }],
|
|
multiple: true,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
function nativePermission(style) {
|
|
return style === "legacy"
|
|
? {
|
|
id: "permission-native-1",
|
|
type: "tool",
|
|
pattern: "echo OK",
|
|
sessionID: session.id,
|
|
messageID: "message-permission",
|
|
callID: "call-permission",
|
|
title: "Run validation command",
|
|
metadata: {},
|
|
time: { created: Date.now() },
|
|
}
|
|
: {
|
|
id: "permission-native-1",
|
|
sessionID: session.id,
|
|
permission: "bash",
|
|
patterns: ["echo OK"],
|
|
always: ["echo *"],
|
|
metadata: {},
|
|
tool: { messageID: "message-permission", callID: "call-permission" },
|
|
};
|
|
}
|
|
|
|
let pendingQuestion = await readFile(
|
|
join(process.env.XDG_DATA_HOME, "fake-pending-question.json"),
|
|
"utf8",
|
|
)
|
|
.then((value) => JSON.parse(value))
|
|
.catch(() => null);
|
|
let pendingPermission = await readFile(
|
|
join(process.env.XDG_DATA_HOME, "fake-pending-permission.json"),
|
|
"utf8",
|
|
)
|
|
.then((value) => JSON.parse(value))
|
|
.catch(() => null);
|
|
let permissionStyle = "v2";
|
|
|
|
await mkdir(process.env.XDG_DATA_HOME, { recursive: true });
|
|
await writeFile(join(process.env.XDG_DATA_HOME, "fake-provider-custody.json"), JSON.stringify({
|
|
openrouterKey: process.env.OPENROUTER_API_KEY,
|
|
providerOptions: runtimeConfig.provider?.openrouter?.options,
|
|
}));
|
|
await writeFile(
|
|
join(process.env.XDG_DATA_HOME, "fake-environment.json"),
|
|
JSON.stringify({
|
|
keys: Object.keys(process.env).sort(),
|
|
notificationContext: process.env.PAPERCLIP_RUNTIME_NOTIFICATION_CONTEXT,
|
|
notificationHelper: process.env.PAPERCLIP_RUNTIME_NOTIFICATION_HELPER,
|
|
noProxy: process.env.NO_PROXY,
|
|
home: process.env.HOME,
|
|
configHome: process.env.XDG_CONFIG_HOME,
|
|
projectConfigDisabled: process.env.OPENCODE_DISABLE_PROJECT_CONFIG,
|
|
modelsFetchDisabled: process.env.OPENCODE_DISABLE_MODELS_FETCH,
|
|
defaultPluginsDisabled: process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS,
|
|
githubTokenPresent: Boolean(process.env.GH_TOKEN || process.env.GITHUB_TOKEN),
|
|
}),
|
|
);
|
|
|
|
function json(response, status, value) {
|
|
response.statusCode = status;
|
|
response.setHeader("Content-Type", "application/json");
|
|
response.end(JSON.stringify(value));
|
|
}
|
|
|
|
// Each simulated turn reuses the same short event ids ("event-1",
|
|
// "event-3", and so on). OpenCode's real event ids are unique, and the
|
|
// driver relies on that to drop a duplicate delivery of the same event.
|
|
// Scope every event id to the turn that produced it so a second turn's
|
|
// events do not look like duplicates of the first turn's events.
|
|
let promptTurnSeq = 0;
|
|
// Set by a "late-straggler-source" turn to simulate a distinct provider
|
|
// frame for that turn's message arriving only after a later turn's prompt
|
|
// has already been accepted. Counts down by one on each subsequent prompt
|
|
// request and flushes at the start of the request that brings it to zero.
|
|
// A plain "late-straggler-source" message flushes on the very next prompt
|
|
// (delay 1); "late-straggler-source-delay-N" flushes N prompts later, so a
|
|
// test can place the stale frame's arrival after several more turns have
|
|
// sealed.
|
|
let lateStragglerRemaining = 0;
|
|
function emit(value) {
|
|
if (value.type === "session.idle" && runtimeConfig.experimental?.openTelemetry) {
|
|
const now=Date.now();
|
|
const headers=Object.fromEntries((process.env.OTEL_EXPORTER_OTLP_HEADERS??"").split(",").map(entry=>{
|
|
const i=entry.indexOf("=");return [entry.slice(0,i),entry.slice(i+1)];
|
|
}));
|
|
void fetch(`${process.env.OTEL_EXPORTER_OTLP_ENDPOINT}/v1/traces`,{method:"POST",headers,
|
|
body:JSON.stringify({resourceSpans:[{scopeSpans:[{spans:[{name:"ai.streamText",
|
|
startTimeUnixNano:String(BigInt(now-22)*1000000n),endTimeUnixNano:String(BigInt(now)*1000000n),
|
|
attributes:[{key:"prompt",value:{stringValue:"private-performance-sentinel"}}],
|
|
}]}]}]})}).then(response=>{
|
|
if(!response.ok)throw new Error(`Performance fixture rejected: ${response.status}`);
|
|
emitFrame(value);
|
|
}).catch(error=>{process.stderr.write(String(error));process.exit(1)});
|
|
return;
|
|
}
|
|
emitFrame(value);
|
|
}
|
|
function emitFrame(value) {
|
|
const scoped =
|
|
promptTurnSeq > 0 && typeof value.id === "string"
|
|
? { ...value, id: `${value.id}#${promptTurnSeq}` }
|
|
: value;
|
|
const frame = `data: ${JSON.stringify(scoped)}\n\n`;
|
|
for (const response of clients) response.write(frame);
|
|
}
|
|
|
|
let retainedCompletionContract;
|
|
function parsedPromptText(promptBody) {
|
|
const text = promptBody.parts?.[0]?.text ?? "";
|
|
try {
|
|
const prompt = JSON.parse(text);
|
|
retainedCompletionContract = prompt.completionContract ?? prompt.task?.completionContract ?? retainedCompletionContract;
|
|
return prompt;
|
|
} catch {
|
|
// 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 ?? AbortSignal.timeout(mcp.timeout),
|
|
method: "POST",
|
|
headers: {
|
|
...mcp.headers,
|
|
"Content-Type": "application/json",
|
|
Accept: "application/json, text/event-stream",
|
|
},
|
|
body: JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id: mcpRequestId++,
|
|
method,
|
|
...(params ? { params } : {}),
|
|
}),
|
|
});
|
|
return response.json();
|
|
}
|
|
|
|
async function callFirstPaperclipTool() {
|
|
if (!mcp?.url) return;
|
|
await mcpRequest("initialize", {
|
|
protocolVersion: "2025-03-26",
|
|
capabilities: {},
|
|
clientInfo: { name: "fake-opencode", version: "1" },
|
|
});
|
|
const listed = await mcpRequest("tools/list");
|
|
mcpEvidence.tools = (listed.result?.tools ?? []).map((entry) => entry.name);
|
|
const tool = listed.result?.tools?.find(
|
|
(entry) =>
|
|
!["paperclip_finish", "paperclip_block"].includes(entry.name) &&
|
|
(entry.inputSchema?.required?.length ?? 0) === 0,
|
|
);
|
|
if (tool)
|
|
mcpEvidence.calls.push(
|
|
await mcpRequest("tools/call", {
|
|
name: `paperclip_${tool.name}`,
|
|
arguments: {},
|
|
}),
|
|
);
|
|
await writeFile(
|
|
join(process.env.XDG_DATA_HOME, "fake-mcp-evidence.json"),
|
|
JSON.stringify(mcpEvidence),
|
|
);
|
|
}
|
|
|
|
async function callTerminalTool(promptBody, signal) {
|
|
const prompt = parsedPromptText(promptBody);
|
|
const blocked = String(prompt.message ?? "").includes("block-result");
|
|
const monitored = String(prompt.message ?? "").includes("monitor-default");
|
|
const result = {
|
|
schema: "paperclip.run_result.v1",
|
|
reportedWorkDisposition: blocked ? "blocked" : monitored ? "yielded" : "done",
|
|
summary: blocked
|
|
? "Fake provider is blocked."
|
|
: "Fake provider completed the task.",
|
|
completionClaim: {
|
|
contractRevision:
|
|
prompt.completionContract?.revision ?? prompt.task?.completionContract?.revision ?? "codex-demo-v1",
|
|
objectiveSatisfied: !blocked,
|
|
criteria: (prompt.completionContract?.criteria ?? prompt.task?.completionContract?.criteria ?? []).map(
|
|
(criterion) => ({
|
|
criterionId: criterion.id,
|
|
status: blocked ? "unknown" : "satisfied",
|
|
evidenceRefs: [],
|
|
}),
|
|
),
|
|
remainingWork: blocked
|
|
? [
|
|
{
|
|
description: "Waiting on a test dependency.",
|
|
blocksCompletion: true,
|
|
},
|
|
]
|
|
: [],
|
|
},
|
|
evidence: [],
|
|
verification: [],
|
|
attentionRequests: blocked
|
|
? [{ kind: "blocker", summary: "Waiting on a test dependency." }]
|
|
: [],
|
|
artifacts: [],
|
|
...(blocked
|
|
? {
|
|
blocker: {
|
|
reasonCode: "test_dependency",
|
|
owner: { kind: "external", name: "fixture" },
|
|
unblockAction: "Release the fixture dependency.",
|
|
scope: "current_track",
|
|
},
|
|
}
|
|
: {}),
|
|
};
|
|
if (String(prompt.message ?? "").includes("repair-criteria")) {
|
|
const bad = structuredClone(result);
|
|
bad.completionClaim.criteria = String(prompt.message).includes("repair-criteria-missing") ? []
|
|
: String(prompt.message).includes("repair-criteria-duplicate") ? [...result.completionClaim.criteria, ...result.completionClaim.criteria]
|
|
: [{ criterionId: "invented", status: "satisfied", evidenceRefs: [] }];
|
|
const rejected = await mcpRequest("tools/call", { name: "paperclip_finish", arguments: bad });
|
|
await writeFile(join(process.env.XDG_DATA_HOME, "fake-criteria-repair.json"), JSON.stringify(rejected));
|
|
}
|
|
const call = () => mcpRequest("tools/call", {
|
|
name: blocked ? "paperclip_block" : "paperclip_finish",
|
|
arguments: result,
|
|
}, signal);
|
|
const first = await call();
|
|
if (String(prompt.message ?? prompt.task?.prompt ?? "").includes("completion-feedback")) {
|
|
const outcomes = [first];
|
|
if (first.result?.isError) outcomes.push(await call());
|
|
await writeFile(join(process.env.XDG_DATA_HOME, "fake-completion-feedback.json"), JSON.stringify(outcomes));
|
|
return outcomes.at(-1);
|
|
}
|
|
return first;
|
|
}
|
|
|
|
const server = createServer(async (request, response) => {
|
|
if (request.headers.authorization !== expectedAuth)
|
|
return json(response, 401, { error: "unauthorized" });
|
|
if (request.url === "/global/health")
|
|
return json(response, 200, { healthy: true, version: "1.18.34" });
|
|
if (request.url === "/event") {
|
|
eventConnections += 1;
|
|
response.writeHead(200, {
|
|
"Content-Type": "text/event-stream",
|
|
"Cache-Control": "no-cache",
|
|
});
|
|
response.write(
|
|
`data: ${JSON.stringify({ type: "server.connected", properties: {} })}\n\n`,
|
|
);
|
|
if (eventConnections === 1) {
|
|
response.end();
|
|
return;
|
|
}
|
|
clients.add(response);
|
|
request.on("close", () => clients.delete(response));
|
|
return;
|
|
}
|
|
if (request.method === "POST" && request.url === "/session")
|
|
return json(response, 200, session);
|
|
if (request.method === "GET" && request.url === `/session/${session.id}`)
|
|
return json(response, 200, session);
|
|
if (
|
|
request.method === "GET" &&
|
|
request.url === `/session/${session.id}/message`
|
|
) {
|
|
// A long-lived session can hold many megabytes of retained message parts.
|
|
const historyBytes = Number(
|
|
await readFile(join(process.env.XDG_DATA_HOME, "fake-message-history-bytes"), "utf8").catch(() => "0"),
|
|
);
|
|
return json(response, 200, historyBytes > 0
|
|
? [{ info: { id: "msg-history-1", sessionID: session.id, role: "assistant" },
|
|
parts: [{ id: "part-history-1", type: "text", text: "x".repeat(historyBytes) }] }]
|
|
: []);
|
|
}
|
|
if (request.method === "GET" && request.url === "/session/status")
|
|
return json(response, 200, { [session.id]: { type: "idle" } });
|
|
if (request.method === "GET" && request.url?.startsWith("/question?"))
|
|
return json(response, 200, pendingQuestion ? [pendingQuestion] : []);
|
|
if (request.method === "GET" && request.url?.startsWith("/permission?"))
|
|
return json(response, 200, pendingPermission ? [pendingPermission] : []);
|
|
if (
|
|
request.method === "POST" &&
|
|
request.url?.startsWith("/question/question-native-1/reply?")
|
|
) {
|
|
const chunks = [];
|
|
request.on("data", (chunk) => chunks.push(chunk));
|
|
request.on("end", async () => {
|
|
const body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
await writeFile(
|
|
join(process.env.XDG_DATA_HOME, "fake-question-reply.json"),
|
|
JSON.stringify({ url: request.url, body }),
|
|
);
|
|
const repliedQuestion = pendingQuestion;
|
|
pendingQuestion = null;
|
|
emit({
|
|
type: "question.replied",
|
|
id: "event-question-replied",
|
|
properties: repliedQuestion,
|
|
});
|
|
setTimeout(() => {
|
|
json(response, 200, true);
|
|
emit({
|
|
type: "session.idle",
|
|
id: "event-question-idle",
|
|
properties: { sessionID: session.id },
|
|
});
|
|
}, 10);
|
|
});
|
|
return;
|
|
}
|
|
if (
|
|
request.method === "POST" &&
|
|
request.url?.startsWith("/question/question-native-1/reject?")
|
|
) {
|
|
const rejectedQuestion = pendingQuestion;
|
|
pendingQuestion = null;
|
|
emit({
|
|
type: "question.rejected",
|
|
id: "event-question-rejected",
|
|
properties: rejectedQuestion,
|
|
});
|
|
setTimeout(() => {
|
|
json(response, 200, true);
|
|
emit({
|
|
type: "session.idle",
|
|
id: "event-question-rejected-idle",
|
|
properties: { sessionID: session.id },
|
|
});
|
|
}, 10);
|
|
return;
|
|
}
|
|
if (
|
|
request.method === "POST" &&
|
|
request.url?.startsWith("/permission/permission-native-1/reply?")
|
|
) {
|
|
const chunks = [];
|
|
request.on("data", (chunk) => chunks.push(chunk));
|
|
request.on("end", async () => {
|
|
const body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
await writeFile(
|
|
join(process.env.XDG_DATA_HOME, "fake-permission-reply.json"),
|
|
JSON.stringify({ url: request.url, body }),
|
|
);
|
|
pendingPermission = null;
|
|
emit({
|
|
type:
|
|
permissionStyle === "legacy"
|
|
? "permission.replied"
|
|
: "permission.v2.replied",
|
|
id: "event-permission-replied",
|
|
properties:
|
|
permissionStyle === "legacy"
|
|
? {
|
|
sessionID: session.id,
|
|
permissionID: "permission-native-1",
|
|
response: body.reply,
|
|
}
|
|
: {
|
|
sessionID: session.id,
|
|
requestID: "permission-native-1",
|
|
reply: body.reply,
|
|
},
|
|
});
|
|
setTimeout(() => {
|
|
json(response, 200, true);
|
|
emit({
|
|
type: "session.idle",
|
|
id: "event-permission-idle",
|
|
properties: { sessionID: session.id },
|
|
});
|
|
}, 10);
|
|
});
|
|
return;
|
|
}
|
|
if (
|
|
request.method === "POST" &&
|
|
request.url === `/session/${session.id}/abort`
|
|
)
|
|
return json(response, 200, true);
|
|
if (
|
|
request.method === "POST" &&
|
|
request.url === `/session/${session.id}/prompt_async`
|
|
) {
|
|
const chunks = [];
|
|
request.on("data", (chunk) => chunks.push(chunk));
|
|
request.on("end", async () => {
|
|
const promptPayload = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
await appendFile(join(process.env.XDG_DATA_HOME, "fake-prompt-requests.ndjson"), JSON.stringify(promptPayload) + "\n");
|
|
if (
|
|
`${promptPayload.providerID}/${promptPayload.modelID}` !== runtimeConfig.model ||
|
|
"model" in promptPayload
|
|
) {
|
|
return json(response, 400, {
|
|
error: "OpenCode 1.18 prompt model fields must be top-level",
|
|
});
|
|
}
|
|
promptTurnSeq += 1;
|
|
json(response, 204, null);
|
|
if (lateStragglerRemaining > 0) {
|
|
lateStragglerRemaining -= 1;
|
|
if (lateStragglerRemaining === 0) {
|
|
emit({
|
|
type: "message.part.updated",
|
|
id: "event-late-straggler-delivery",
|
|
properties: {
|
|
sessionID: session.id,
|
|
part: {
|
|
id: "part-late-straggler",
|
|
messageID: "message-late-source",
|
|
type: "text",
|
|
text: "late straggler text must not reach the next turn",
|
|
time: { start: 9, end: 10 },
|
|
},
|
|
},
|
|
});
|
|
emit({
|
|
type: "message.part.delta",
|
|
properties: {
|
|
sessionID: session.id,
|
|
messageID: "message-late-source",
|
|
partID: "part-late-straggler",
|
|
field: "text",
|
|
delta: "late straggler text must not reach the next turn",
|
|
},
|
|
});
|
|
}
|
|
}
|
|
setTimeout(async () => {
|
|
const parsedPrompt = parsedPromptText(promptPayload);
|
|
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";
|
|
emit({ type: "message.updated", properties: { sessionID: session.id, info: { id: messageID, sessionID: session.id, role: "assistant" } } });
|
|
emit({ type: "message.part.updated", properties: { sessionID: session.id, part: { id: partID, messageID, sessionID: session.id, type: "text", text: "" } } });
|
|
for (let i = 0; i < 800; i++) emit({ type: "message.part.delta", properties: {
|
|
sessionID: session.id, messageID, partID, field: "text", delta: "Streaming progress. ",
|
|
} });
|
|
let receipt;
|
|
try {
|
|
const result = backlogTool === "completion"
|
|
? await callTerminalTool(promptPayload, 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 !== "completion" && receipt.acknowledged) await callTerminalTool(promptPayload);
|
|
emit({ type: "session.idle", properties: { sessionID: session.id } });
|
|
return;
|
|
}
|
|
await callFirstPaperclipTool();
|
|
if (String(parsedPrompt.message ?? "").includes("managed-github-operation")) {
|
|
const result = await new Promise(resolve => {
|
|
execFile("gh", ["api", "user"], { timeout: 5000 }, (error, stdout, stderr) => {
|
|
resolve({ stdout, stderr, error: error?.message ?? null });
|
|
});
|
|
});
|
|
await writeFile(join(process.env.XDG_DATA_HOME, "fake-github-operation.json"), JSON.stringify(result));
|
|
}
|
|
if (String(parsedPrompt.message ?? "").includes("incremental-stream")) {
|
|
const messageID = `stream-message-${promptTurnSeq}`;
|
|
const part = { id: `stream-part-${promptTurnSeq}`, messageID, sessionID: session.id, type: "text", text: "" };
|
|
const reasoning = { ...part, id: `${part.id}-reasoning`, type: "reasoning" };
|
|
const update = (value) => emit({ type: "message.part.updated", properties: { sessionID: session.id, part: value } });
|
|
const delta = (value, text, extra = {}) => emit({ type: "message.part.delta", properties: { sessionID: session.id, messageID, partID: value.id, field: "text", delta: text, ...extra } });
|
|
const announceRole = () => emit({ type: "message.updated", properties: { sessionID: session.id, info: { id: messageID, role: "assistant" } } });
|
|
const roleFirst = String(parsedPrompt.message).includes("role-first");
|
|
const longResponse = String(parsedPrompt.message).includes("role-last-long");
|
|
if (roleFirst) announceRole();
|
|
// Parts and deltas can precede the message's role announcement.
|
|
update(part);
|
|
update(reasoning);
|
|
delta(reasoning, "Thinking");
|
|
delta(part, "Hi ");
|
|
const emojiCount = longResponse ? 120 : 2;
|
|
for (let i = 0; i < emojiCount; i++) delta(part, "👋");
|
|
delta(part, "wrong message", { messageID: "unrelated-message" });
|
|
delta(part, "wrong session", { sessionID: "unrelated-session" });
|
|
delta(part, "wrong field", { field: "output" });
|
|
delta(part, "unknown part", { partID: "unknown-part" });
|
|
const finalPart = { ...part, text: `Hi ${"👋".repeat(emojiCount)}`, time: { start: 1, end: 2 } };
|
|
if (longResponse) update(finalPart);
|
|
if (!roleFirst) announceRole();
|
|
// The consumer must observe a delta before the completed snapshot
|
|
// exists. This handshake makes the streaming regression deterministic.
|
|
for (let i = 0; i < 500; i++) {
|
|
if (await readFile(join(process.env.XDG_DATA_HOME, "release-stream"), "utf8").catch(() => "") === "ready") break;
|
|
await new Promise(resolve => setTimeout(resolve, 10));
|
|
}
|
|
await writeFile(join(process.env.XDG_DATA_HOME, "stream-released"), "ready");
|
|
update(finalPart);
|
|
emit({ type: "session.idle", properties: { sessionID: session.id } });
|
|
return;
|
|
}
|
|
if (String(parsedPrompt.message ?? "").includes("native-question")) {
|
|
pendingQuestion = nativeQuestion();
|
|
emit({
|
|
type: "question.asked",
|
|
id: "event-question-native-1",
|
|
properties: pendingQuestion,
|
|
});
|
|
emit({
|
|
type: "question.asked",
|
|
id: "event-question-native-1",
|
|
properties: pendingQuestion,
|
|
});
|
|
return;
|
|
}
|
|
if (String(parsedPrompt.message ?? "").includes("native-permission")) {
|
|
permissionStyle = String(parsedPrompt.message ?? "").includes(
|
|
"legacy",
|
|
)
|
|
? "legacy"
|
|
: "v2";
|
|
pendingPermission = nativePermission(permissionStyle);
|
|
emit({
|
|
type:
|
|
permissionStyle === "legacy"
|
|
? "permission.updated"
|
|
: "permission.v2.asked",
|
|
id: `event-permission-${permissionStyle}`,
|
|
properties: pendingPermission,
|
|
});
|
|
return;
|
|
}
|
|
if (String(parsedPrompt.message ?? "").includes("session-aborted")) {
|
|
emit({
|
|
type: "session.error",
|
|
id: "event-session-aborted",
|
|
properties: {
|
|
sessionID: session.id,
|
|
error: {
|
|
name: "MessageAbortedError",
|
|
data: { message: "Aborted" },
|
|
},
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
if (String(parsedPrompt.message ?? "").includes("session-failed")) {
|
|
emit({
|
|
type: "session.error",
|
|
id: "event-session-failed",
|
|
properties: {
|
|
sessionID: session.id,
|
|
error: {
|
|
name: "ProviderError",
|
|
message: "The fake provider failed on purpose.",
|
|
},
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
if (
|
|
String(parsedPrompt.message ?? "").includes(
|
|
"pending-request-then-turn-fails",
|
|
)
|
|
) {
|
|
pendingQuestion = nativeQuestion();
|
|
emit({
|
|
type: "question.asked",
|
|
id: "event-question-pending-then-fail",
|
|
properties: pendingQuestion,
|
|
});
|
|
setTimeout(() => {
|
|
emit({
|
|
type: "session.error",
|
|
id: "event-session-failed-with-pending-request",
|
|
properties: {
|
|
sessionID: session.id,
|
|
error: {
|
|
name: "ProviderError",
|
|
message: "The fake provider failed with a request still pending.",
|
|
},
|
|
},
|
|
});
|
|
}, 10);
|
|
return;
|
|
}
|
|
if (
|
|
String(parsedPrompt.message ?? "").includes("late-straggler-source")
|
|
) {
|
|
emit({
|
|
type: "message.updated",
|
|
id: "event-late-source-message",
|
|
properties: {
|
|
sessionID: session.id,
|
|
info: {
|
|
id: "message-late-source",
|
|
sessionID: session.id,
|
|
role: "assistant",
|
|
},
|
|
},
|
|
});
|
|
await callTerminalTool(promptPayload);
|
|
emit({
|
|
type: "message.part.updated",
|
|
id: "event-late-source-part",
|
|
properties: {
|
|
sessionID: session.id,
|
|
part: {
|
|
id: "part-late-source",
|
|
messageID: "message-late-source",
|
|
type: "text",
|
|
text: "done",
|
|
time: { start: 1, end: 2 },
|
|
},
|
|
},
|
|
});
|
|
emit({
|
|
type: "message.updated",
|
|
id: "event-late-source-usage",
|
|
properties: {
|
|
info: {
|
|
id: "message-late-source",
|
|
sessionID: session.id,
|
|
role: "assistant",
|
|
tokens: { input: 3, output: 2 },
|
|
cost: 0.001,
|
|
},
|
|
},
|
|
});
|
|
emit({
|
|
type: "session.idle",
|
|
id: "event-late-source-idle",
|
|
properties: { sessionID: session.id },
|
|
});
|
|
// Deliver the straggling frame for this message only once a later
|
|
// turn's own prompt has already been accepted. Defaults to the
|
|
// very next prompt; "-delay-N" postpones it by N prompts.
|
|
const delayMatch = String(parsedPrompt.message ?? "").match(
|
|
/late-straggler-source-delay-(\d+)/,
|
|
);
|
|
lateStragglerRemaining = delayMatch ? Number(delayMatch[1]) : 1;
|
|
return;
|
|
}
|
|
const textBeforeFinish = String(parsedPrompt.message ?? "").includes(
|
|
"text-before-finish",
|
|
);
|
|
const correlatedFinal = String(parsedPrompt.message ?? "").includes(
|
|
"correlated-final-message",
|
|
);
|
|
const finalAfterToolCommentary = String(
|
|
parsedPrompt.message ?? "",
|
|
).includes("final-after-tool-commentary");
|
|
const commentaryOnlyBeforeWork = String(
|
|
parsedPrompt.message ?? "",
|
|
).includes("commentary-only-before-work");
|
|
emit({
|
|
type: "message.updated",
|
|
id: "event-user",
|
|
properties: {
|
|
sessionID: session.id,
|
|
info: { id: "message-user", sessionID: session.id, role: "user" },
|
|
},
|
|
});
|
|
emit({
|
|
type: "message.part.updated",
|
|
id: "event-user-part",
|
|
properties: {
|
|
sessionID: session.id,
|
|
part: {
|
|
id: "part-user",
|
|
messageID: "message-user",
|
|
type: "text",
|
|
text: "submitted prompt must not become assistant text",
|
|
},
|
|
},
|
|
});
|
|
emit({
|
|
type: "message.updated",
|
|
id: "event-assistant",
|
|
properties: {
|
|
sessionID: session.id,
|
|
info: {
|
|
id: "message-assistant",
|
|
sessionID: session.id,
|
|
role: "assistant",
|
|
},
|
|
},
|
|
});
|
|
if (String(parsedPrompt.message ?? parsedPrompt.task?.prompt ?? "").includes("invalid-tool-feedback")) {
|
|
for (const [status, state] of [
|
|
["pending", {}], ["running", { title: "Checking tool name" }],
|
|
["error", { error: "Tool not found: fixture_missing" }],
|
|
]) emit({ type: "message.part.updated", id: `event-invalid-${status}`, properties: {
|
|
sessionID: session.id, part: { id: "part-invalid", messageID: "message-assistant",
|
|
type: "tool", tool: "invalid", callID: "call-invalid", state: { status, ...state } },
|
|
} });
|
|
}
|
|
emit({
|
|
type: "message.part.updated",
|
|
id: "event-patch",
|
|
properties: {
|
|
sessionID: session.id,
|
|
part: {
|
|
id: "part-patch",
|
|
messageID: "message-assistant",
|
|
type: "patch",
|
|
files: ["src/index.ts"],
|
|
},
|
|
},
|
|
});
|
|
if (commentaryOnlyBeforeWork) {
|
|
emit({
|
|
type: "message.part.updated",
|
|
id: "event-opening-commentary",
|
|
properties: {
|
|
sessionID: session.id,
|
|
part: {
|
|
id: "part-opening-commentary",
|
|
messageID: "message-assistant",
|
|
type: "text",
|
|
text: "I will inspect the workspace first.",
|
|
time: { start: 1, end: 2 },
|
|
},
|
|
},
|
|
});
|
|
emit({
|
|
type: "message.part.updated",
|
|
id: "event-work-tool",
|
|
properties: {
|
|
sessionID: session.id,
|
|
part: {
|
|
id: "part-work-tool",
|
|
messageID: "message-assistant",
|
|
type: "tool",
|
|
tool: "bash",
|
|
state: {
|
|
status: "completed",
|
|
input: { command: "pwd" },
|
|
output: "/workspace",
|
|
},
|
|
},
|
|
},
|
|
});
|
|
await callTerminalTool(promptPayload);
|
|
} else if (finalAfterToolCommentary) {
|
|
emit({
|
|
type: "message.part.updated",
|
|
id: "event-tool-commentary",
|
|
properties: {
|
|
sessionID: session.id,
|
|
part: {
|
|
id: "part-tool-commentary",
|
|
messageID: "message-assistant",
|
|
type: "text",
|
|
text: "This deliberately long pre-tool commentary discusses possible schema corrections, retries, compatibility details, and several implementation alternatives, but it is not the provider's final response.",
|
|
time: { start: 1, end: 2 },
|
|
},
|
|
},
|
|
});
|
|
await callTerminalTool(promptPayload);
|
|
emit({
|
|
type: "message.part.updated",
|
|
id: "event-tool-part",
|
|
properties: {
|
|
sessionID: session.id,
|
|
part: {
|
|
id: "part-tool",
|
|
messageID: "message-assistant",
|
|
type: "tool",
|
|
tool: "paperclip_paperclip_finish",
|
|
state: { status: "completed", output: "accepted" },
|
|
},
|
|
},
|
|
});
|
|
emit({
|
|
type: "message.updated",
|
|
id: "event-post-tool-final-message",
|
|
properties: {
|
|
sessionID: session.id,
|
|
info: {
|
|
id: "message-post-tool-final",
|
|
sessionID: session.id,
|
|
role: "assistant",
|
|
},
|
|
},
|
|
});
|
|
emit({
|
|
type: "message.part.updated",
|
|
id: "event-post-tool-final",
|
|
properties: {
|
|
sessionID: session.id,
|
|
part: {
|
|
id: "part-post-tool-final",
|
|
messageID: "message-post-tool-final",
|
|
type: "text",
|
|
text: "This is the complete substantive answer emitted after the accepted completion tool call.",
|
|
time: { start: 3, end: 4 },
|
|
},
|
|
},
|
|
});
|
|
} else if (correlatedFinal) {
|
|
emit({
|
|
type: "message.part.updated",
|
|
id: "event-commentary",
|
|
properties: {
|
|
sessionID: session.id,
|
|
part: {
|
|
id: "part-commentary",
|
|
messageID: "message-assistant",
|
|
type: "text",
|
|
text: "I will write the result now.",
|
|
time: { start: 1, end: 2 },
|
|
},
|
|
},
|
|
});
|
|
await callTerminalTool(promptPayload);
|
|
emit({
|
|
type: "message.updated",
|
|
id: "event-final-message",
|
|
properties: {
|
|
sessionID: session.id,
|
|
info: {
|
|
id: "message-final",
|
|
sessionID: session.id,
|
|
role: "assistant",
|
|
},
|
|
},
|
|
});
|
|
emit({
|
|
type: "message.part.updated",
|
|
id: "event-finish-part",
|
|
properties: {
|
|
sessionID: session.id,
|
|
part: {
|
|
id: "part-finish",
|
|
messageID: "message-final",
|
|
type: "tool",
|
|
tool: "paperclip_paperclip_finish",
|
|
state: { status: "completed", output: "accepted" },
|
|
},
|
|
},
|
|
});
|
|
emit({
|
|
type: "message.part.updated",
|
|
id: "event-correlated-answer",
|
|
properties: {
|
|
sessionID: session.id,
|
|
part: {
|
|
id: "part-correlated-answer",
|
|
messageID: "message-final",
|
|
type: "text",
|
|
text: "Correlated substantive final answer.",
|
|
time: { start: 3, end: 4 },
|
|
},
|
|
},
|
|
});
|
|
emit({
|
|
type: "message.part.updated",
|
|
id: "event-duplicate-finish",
|
|
properties: {
|
|
sessionID: session.id,
|
|
part: {
|
|
id: "part-duplicate-finish",
|
|
messageID: "message-final",
|
|
type: "tool",
|
|
tool: "unknown",
|
|
callID: "functions.paperclip_paperclip_finish:24",
|
|
state: {
|
|
status: "error",
|
|
error: "Tool execution aborted",
|
|
metadata: { interrupted: true },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
emit({
|
|
type: "message.updated",
|
|
id: "event-trailing-message",
|
|
properties: {
|
|
sessionID: session.id,
|
|
info: {
|
|
id: "message-trailing",
|
|
sessionID: session.id,
|
|
role: "assistant",
|
|
},
|
|
},
|
|
});
|
|
emit({
|
|
type: "message.part.updated",
|
|
id: "event-trailing-ack",
|
|
properties: {
|
|
sessionID: session.id,
|
|
part: {
|
|
id: "part-trailing-ack",
|
|
messageID: "message-trailing",
|
|
type: "text",
|
|
text: "Done.",
|
|
time: { start: 5, end: 6 },
|
|
},
|
|
},
|
|
});
|
|
} else if (textBeforeFinish) {
|
|
emit({
|
|
type: "message.part.updated",
|
|
id: "event-answer",
|
|
properties: {
|
|
sessionID: session.id,
|
|
part: {
|
|
id: "part-answer",
|
|
messageID: "message-assistant",
|
|
type: "text",
|
|
text: "Substantive answer before finish.",
|
|
time: { start: 1, end: 2 },
|
|
},
|
|
},
|
|
});
|
|
await callTerminalTool(promptPayload);
|
|
emit({
|
|
type: "message.part.updated",
|
|
id: "event-ack",
|
|
properties: {
|
|
sessionID: session.id,
|
|
part: {
|
|
id: "part-ack",
|
|
messageID: "message-assistant",
|
|
type: "text",
|
|
text: "Done.",
|
|
time: { start: 3, end: 4 },
|
|
},
|
|
},
|
|
});
|
|
} else {
|
|
await callTerminalTool(promptPayload);
|
|
emit({
|
|
type: "message.part.updated",
|
|
id: "event-1",
|
|
properties: {
|
|
sessionID: session.id,
|
|
part: {
|
|
id: "part-1",
|
|
messageID: "message-assistant",
|
|
type: "text",
|
|
text: "done [guide](guide.md)",
|
|
time: { start: 1, end: 2 },
|
|
},
|
|
},
|
|
});
|
|
emit({
|
|
type: "message.part.updated",
|
|
id: "event-1",
|
|
properties: {
|
|
sessionID: session.id,
|
|
part: {
|
|
id: "part-1",
|
|
messageID: "message-assistant",
|
|
type: "text",
|
|
text: "done",
|
|
},
|
|
},
|
|
});
|
|
}
|
|
emit({
|
|
type: "message.updated",
|
|
id: "event-2",
|
|
properties: {
|
|
info: {
|
|
id: "message-assistant",
|
|
sessionID: session.id,
|
|
role: "assistant",
|
|
tokens: { input: 3, output: 2 },
|
|
cost: 0.001,
|
|
},
|
|
},
|
|
});
|
|
emit({
|
|
type: "session.idle",
|
|
id: "event-3",
|
|
properties: { sessionID: session.id },
|
|
});
|
|
}, 100);
|
|
});
|
|
return;
|
|
}
|
|
json(response, 404, { error: "not found" });
|
|
});
|
|
|
|
server.listen(port, "127.0.0.1");
|
|
for (const signal of ["SIGTERM", "SIGINT"])
|
|
process.on(signal, () => server.close(() => process.exit(0)));
|