mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 19:35:13 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Native agents use assigned connection tools through a governed gateway. > - OpenCode received the gateway catalog directly, outside the bounded server tool projection. > - A large catalog can exceed a provider grammar compiler's capacity even when it fits the transport limits. > - Synthetic requests reproduced the Fireworks grammar timeout on the configured DeepSeek model. > - This pull request gives OpenCode two assigned-tool discovery and call schemas through the existing server relay. > - The gateway keeps the same grants, validation, approvals, revocation, and audit rules. ## Linked Issues or Issue Description **What happened?** A native OpenCode run could fail during provider grammar compilation before its first assigned-tool call. OpenCode advertised the full assigned gateway catalog. A synthetic catalog of 741 tool schemas reproduced `Grammar processing took too much time` on Fireworks through OpenRouter with `deepseek/deepseek-v4.1-flash-1017`. The same model and backend accepted a compact two-tool discovery/call catalog. This reproduces the mechanism; it does not replay a historical private request. **Expected behavior** Assigned-tool discovery must keep provider grammar size bounded. Every call must retain the run's original gateway binding and permissions. **Steps to reproduce** 1. Give a native OpenCode run a large assigned connection catalog. 2. Use a provider that compiles tool schemas into a constrained output grammar. 3. Observe the full catalog in the provider tool surface before this change. 4. After this change, observe only `paperclip_search_assigned_tools` and `paperclip_call_assigned_tool` for assigned tools. Discover and call a permitted tool through its pinned alias. **Paperclip version or commit** Source checkout based on current `master`. **Deployment mode** Native local or sandbox execution with OpenCode 1.18.34. Related work: Refs #14258 (discovery paging), #16678 (local adapter MCP launch), and #17688 (gateway capability advertisement). This change covers native OpenCode catalog projection. ## What Changed - Route native OpenCode assigned tools through the existing server-owned authenticated relay. - Use bounded discovery/call schemas for OpenCode, including small catalogs. Preserve transport capacity checks. - Keep assigned gateway credentials on the controller. Ignore gateway and credential values in tool arguments. - Preserve exact declared MCP operation names through admission and driver dispatch. Resolve transport prefixes only against the closed catalog. Reject unknown or shortened names. - Refresh former OpenCode direct-catalog and shortened-name checkpoints. Keep the existing fingerprints for other providers. - Add local and remote wiring, catalog capacity, alias, scope, work-mode, approval, and revocation tests. - Allow the native test files to use a validated dedicated external test database when embedded PostgreSQL is unavailable. - Document the assigned-tool execution contract. - Bound OpenCode discovery pages to 32 KiB and large-schema chunks to 16K characters. Keep other harness budgets unchanged. - Test medium pages and escaped large schemas through the actual HTTP MCP bridge on the explicit Runner testing surface. ## Verification - Red before the catalog fix: OpenCode did not create the server relay, and forced compact delivery advertised 1 or 741 direct schemas. - Red before the alias repair: HTTP MCP changed declared operation names, and the driver sent `search_assigned_tools` instead of the authorized `paperclip_search_assigned_tools`. - Current repair tests: 112 OpenCode/proxy tests pass, plus one actual Rust/proxy/HTTP MCP regression. Two checkpoint regressions fail with the former scoped revision and pass with the new revision. Other providers keep their prior fingerprints. - The combined QA candidate with this exact PR head passes all 916 native tests and all 113 transport tests on supported Node 24.22. An earlier Node 26.4/macOS run passed the 113 assertions but had an uncaught networking error. That non-clean run remains recorded; its stack matches [the upstream Undici defect](https://github.com/nodejs/undici/issues/5544). No production workaround or error suppression was added. - `pnpm -r typecheck`, `pnpm build`, and `git diff --check` pass on `bc41ba72be2195eabeb0d7727227ee0cd02e8532`. The maps structural check passes. - The local `pnpm test:run` attempt stopped after host embedded PostgreSQL initialization failures. The relevant database suites pass with isolated PostgreSQL 18. All repository CI gates, including aggregate `ci / verify`, pass on the current head. - Synthetic live provider comparison: 741 invented schemas produced the exact Fireworks grammar timeout in 20.9 seconds. Two compact invented schemas produced a discovery call in 585 ms on the same model and backend. - The first hosted QA attempt exposed the alias failure described above. On the patched source, a real local Rust runner → proxy → OpenCode 1.18.34 → exact DeepSeek smoke completed discovery, one synthetic gateway read, and `paperclip_finish`, then emitted `turn/completed`. It used the production compact projection and unchanged Rust allowlist. - Two earlier local smoke attempts exposed missing terminal authority and completion-contract fields in the smoke harness. Those failures remain recorded. The corrected ordinary bindings produced the clean result. - Fresh Greptile: 5/5 on `bc41ba72be2195eabeb0d7727227ee0cd02e8532`, with no new actionable findings or unresolved threads. - Hosted QA publication and lifecycle acceptance remain pending. The separate deployed-agent `Paperclip Review` check is also pending; it follows a task that failed on the earlier deployed runtime. - Local OpenCode/OpenRouter acceptance at `bc41ba72`: 530 synthetic tools were authorized and installed. A simple model-ID reply completed in 17.3 seconds with no app calls. A discovery task completed in 37.6 seconds, used the search/call relay, made one upstream call, and returned the exact synthetic value. This is local source acceptance, not a staging sandbox timing comparison. - The large-result review finding has a failing-before/passing-after HTTP MCP regression. Five medium schemas now page without invalid JSON. A large schema with escaped text reconstructs exactly through bounded chunks. All 35 assigned-tool tests and the server typecheck pass after this follow-up. - Final head `d84ce4856a6e65a35ad5fb8b561492598b525f4e`: all CI gates pass. Greptile and Paperclip Review both rate the change 5/5. No review threads remain open. The final full build, server typecheck, and 35 assigned-tool tests pass locally. ## Risks - OpenCode uses discovery before it calls an assigned tool, including for small catalogs. This can add a model turn. - An existing OpenCode provider checkpoint rotates once because it retains the former tool declarations. Paperclip task history remains available. - The historical filtered catalog was not captured. The synthetic comparison proves the failure mechanism, not the exact cardinality of an earlier private request. - No model, provider route, permission, or automatic retry policy changes. ## Model Used The bounded-result follow-up also used OpenAI Codex (GPT-6 family). The exact backend model ID and context-window size were not exposed for that session. It used reasoning, code execution, and tool use. OpenAI `gpt-6.1-sol` in Codex, with reasoning, tool use, code execution, and source inspection. The runtime does not expose the context-window size. ## 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>
2871 lines
113 KiB
TypeScript
2871 lines
113 KiB
TypeScript
import {
|
|
chmod,
|
|
copyFile,
|
|
lstat,
|
|
mkdir,
|
|
mkdtemp,
|
|
readFile,
|
|
readdir,
|
|
realpath,
|
|
rm,
|
|
stat,
|
|
symlink,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { createServer as createHttpServer } from "node:http";
|
|
import { execFile, type ChildProcess } from "node:child_process";
|
|
import { EventEmitter } from "node:events";
|
|
import { promisify } from "node:util";
|
|
import { getCACertificates } from "node:tls";
|
|
import { basename, dirname, join, resolve } from "node:path";
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
NATIVE_RUNTIME_ASSET_SCHEMA,
|
|
PAPERCLIP_EXECUTION_PROMPT,
|
|
PAPERCLIP_EXECUTION_PROMPT_REVISION,
|
|
canonicalNativeRuntimeContextDigest,
|
|
nativeRuntimePromptDigest,
|
|
type NativeRuntimeContextSnapshot,
|
|
} from "../../contracts/runtime-context.js";
|
|
import { createCodexTaskEnvelope } from "../../contracts/codex.js";
|
|
import { localIntegrityBoundaryGolden } from "../../../test-support/local-integrity-boundary-golden.js";
|
|
import type { PrpEvent } from "../../protocol/replay-contract.js";
|
|
import {
|
|
OpenCodeServerDriver,
|
|
openCodeServerDriverInternals,
|
|
} from "./opencode-server-driver.js";
|
|
|
|
const roots: string[] = [];
|
|
let fixtureRoot: string | undefined;
|
|
let fixture: string;
|
|
|
|
beforeAll(async () => {
|
|
fixtureRoot = await mkdtemp(join(tmpdir(), "paperclip-opencode-fixture-"));
|
|
fixture = join(fixtureRoot, "fake-opencode-server.mjs");
|
|
await copyFile(resolve("test/fixtures/fake-opencode-server.mjs"), fixture);
|
|
});
|
|
|
|
const TURN_TERMINAL_EVENT_TYPES = new Set([
|
|
"turn.completed",
|
|
"turn.failed",
|
|
"turn.interrupted",
|
|
"turn.cancelled",
|
|
]);
|
|
|
|
/**
|
|
* The session event stream stays open past a turn boundary so a session can
|
|
* run more turns. Read one turn's events by stopping at its terminal event,
|
|
* the same rule the production consumer applies.
|
|
*/
|
|
async function collectTurnEvents(
|
|
events: AsyncIterable<PrpEvent>,
|
|
): Promise<PrpEvent[]> {
|
|
const collected: PrpEvent[] = [];
|
|
for await (const event of events) {
|
|
collected.push(event);
|
|
if (TURN_TERMINAL_EVENT_TYPES.has(event.eventType)) break;
|
|
}
|
|
return collected;
|
|
}
|
|
|
|
function runtimeContext(
|
|
skillRoot: string,
|
|
instructionRoot: string,
|
|
): NativeRuntimeContextSnapshot {
|
|
const digest = "0".repeat(64);
|
|
const value = {
|
|
prompt: {
|
|
revision: PAPERCLIP_EXECUTION_PROMPT_REVISION,
|
|
text: PAPERCLIP_EXECUTION_PROMPT,
|
|
digest: nativeRuntimePromptDigest(),
|
|
},
|
|
instructions: {
|
|
entryPath: "AGENTS.md",
|
|
bundle: {
|
|
schema: NATIVE_RUNTIME_ASSET_SCHEMA,
|
|
digest,
|
|
manifestDigest: digest,
|
|
rootPath: instructionRoot,
|
|
fileCount: 2,
|
|
totalBytes: 2,
|
|
},
|
|
},
|
|
skills: [
|
|
{
|
|
key: "company/assigned",
|
|
runtimeName: "assigned",
|
|
versionId: "version-1",
|
|
bundle: {
|
|
schema: NATIVE_RUNTIME_ASSET_SCHEMA,
|
|
digest,
|
|
manifestDigest: digest,
|
|
rootPath: skillRoot,
|
|
fileCount: 2,
|
|
totalBytes: 2,
|
|
},
|
|
},
|
|
],
|
|
mcp: { assignmentSetId: "assigned", digest, bindingId: "binding" },
|
|
} satisfies Omit<NativeRuntimeContextSnapshot, "aggregateDigest">;
|
|
return {
|
|
...value,
|
|
aggregateDigest: canonicalNativeRuntimeContextDigest(value),
|
|
};
|
|
}
|
|
|
|
async function makeWritable(root: string): Promise<void> {
|
|
const info = await lstat(root).catch(() => null);
|
|
if (!info) return;
|
|
if (info.isDirectory()) {
|
|
await chmod(root, 0o700);
|
|
for (const entry of await readdir(root))
|
|
await makeWritable(join(root, entry));
|
|
} else {
|
|
await chmod(root, 0o600);
|
|
}
|
|
}
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(
|
|
roots.splice(0).map(async (root) => {
|
|
await makeWritable(root);
|
|
await rm(root, { recursive: true, force: true });
|
|
}),
|
|
);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (fixtureRoot) await rm(fixtureRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("OpenCodeServerDriver", () => {
|
|
it("advertises within-turn plans as unsupported", async () => {
|
|
const driver = new OpenCodeServerDriver({
|
|
model: "openrouter/deepseek/deepseek-v4-flash-0731",
|
|
runtimeDirectory: "/tmp/paperclip-opencode-capabilities",
|
|
command: fixture,
|
|
});
|
|
const descriptor = await driver.descriptor();
|
|
expect(
|
|
descriptor.capabilities.typedEventFamilies.find(
|
|
(family) => family.family === "plan",
|
|
),
|
|
).toMatchObject({ availability: "unsupported" });
|
|
});
|
|
|
|
it("normalizes raw OpenCode boundary events into the local-integrity golden identities", async () => {
|
|
await chmod(fixture, 0o755);
|
|
const profile = localIntegrityBoundaryGolden.profiles.find(
|
|
(candidate) => candidate.id === "runner-opencode",
|
|
);
|
|
if (!profile) throw new Error("missing runner-opencode golden profile");
|
|
const goldenEvent = (sourceEventId: string) => {
|
|
const event = localIntegrityBoundaryGolden.events.find(
|
|
(candidate) => candidate.sourceEventId === sourceEventId,
|
|
);
|
|
if (!event) throw new Error(`missing golden event ${sourceEventId}`);
|
|
return event;
|
|
};
|
|
const progress = localIntegrityBoundaryGolden.expected.assistantItems.find(
|
|
(item) => item.channel === "progress",
|
|
);
|
|
const final = localIntegrityBoundaryGolden.expected.assistantItems.find(
|
|
(item) => item.channel === "final",
|
|
);
|
|
if (!progress || !final)
|
|
throw new Error("missing golden assistant messages");
|
|
|
|
const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-boundary-"));
|
|
const workspace = await mkdtemp(
|
|
join(tmpdir(), "paperclip-opencode-boundary-workspace-"),
|
|
);
|
|
roots.push(root, workspace);
|
|
const tracePath = join(root, "provider-trace.ndjson");
|
|
const providerSessionId = "provider-session-boundary";
|
|
const nativeEvents: Array<Record<string, unknown>> = [];
|
|
const queuedFrames: Uint8Array[] = [];
|
|
const encoder = new TextEncoder();
|
|
let streamController: ReadableStreamDefaultController<Uint8Array> | null =
|
|
null;
|
|
let streamClosed = false;
|
|
const emitNative = (event: Record<string, unknown>) => {
|
|
nativeEvents.push(structuredClone(event));
|
|
const frame = encoder.encode(`data: ${JSON.stringify(event)}\n\n`);
|
|
if (streamController) streamController.enqueue(frame);
|
|
else queuedFrames.push(frame);
|
|
};
|
|
const closeNativeStream = () => {
|
|
streamClosed = true;
|
|
streamController?.close();
|
|
};
|
|
const nativeQuestion = {
|
|
type: "question.asked",
|
|
id: "native-question-created",
|
|
properties: {
|
|
id: localIntegrityBoundaryGolden.expected.questionRequestId,
|
|
sessionID: providerSessionId,
|
|
title: "Boundary mode",
|
|
questions: [
|
|
{
|
|
id: "boundary-mode",
|
|
question: "Which mode should continue?",
|
|
required: true,
|
|
options: [{ id: "lossless", label: "Lossless" }],
|
|
},
|
|
],
|
|
},
|
|
} satisfies Record<string, unknown>;
|
|
const emitBeforeQuestion = () => {
|
|
emitNative({
|
|
type: "message.updated",
|
|
id: "native-progress-message-role",
|
|
properties: {
|
|
info: {
|
|
id: "provider-message-progress",
|
|
sessionID: providerSessionId,
|
|
role: "assistant",
|
|
},
|
|
},
|
|
});
|
|
emitNative({
|
|
type: "message.part.updated",
|
|
id: "native-progress-part",
|
|
properties: {
|
|
sessionID: providerSessionId,
|
|
part: {
|
|
id: progress.itemId,
|
|
messageID: "provider-message-progress",
|
|
type: "text",
|
|
text: progress.text,
|
|
time: { start: 1, end: 2 },
|
|
},
|
|
},
|
|
});
|
|
emitNative({
|
|
type: "message.part.updated",
|
|
id: "native-reasoning-part",
|
|
properties: {
|
|
sessionID: providerSessionId,
|
|
part: {
|
|
id: localIntegrityBoundaryGolden.expected.reasoningItemId,
|
|
messageID: "provider-message-progress",
|
|
type: "reasoning",
|
|
text: "Verified event identity.",
|
|
time: { start: 2, end: 3 },
|
|
},
|
|
},
|
|
});
|
|
for (const [id, status, state] of [
|
|
["native-tool-started", "pending", { title: "Inspecting" }],
|
|
["native-tool-progressed", "running", { title: "Inspecting" }],
|
|
[
|
|
"native-tool-completed",
|
|
"completed",
|
|
{ output: "boundary-ok", time: 12, exit: 0 },
|
|
],
|
|
] as const) {
|
|
emitNative({
|
|
type: "message.part.updated",
|
|
id,
|
|
properties: {
|
|
sessionID: providerSessionId,
|
|
part: {
|
|
id: localIntegrityBoundaryGolden.expected.toolExecutionId,
|
|
messageID: "provider-message-progress",
|
|
type: "tool",
|
|
tool: "boundary-check",
|
|
state: { status, ...state },
|
|
},
|
|
},
|
|
});
|
|
}
|
|
emitNative(nativeQuestion);
|
|
};
|
|
const emitAfterQuestion = () => {
|
|
emitNative({
|
|
...nativeQuestion,
|
|
id: "native-question-resolved",
|
|
type: "question.replied",
|
|
});
|
|
emitNative({
|
|
type: "message.updated",
|
|
id: "native-final-message-role",
|
|
properties: {
|
|
info: {
|
|
id: "provider-message-final",
|
|
sessionID: providerSessionId,
|
|
role: "assistant",
|
|
},
|
|
},
|
|
});
|
|
emitNative({
|
|
type: "message.part.updated",
|
|
id: "native-final-part",
|
|
properties: {
|
|
sessionID: providerSessionId,
|
|
part: {
|
|
id: final.itemId,
|
|
messageID: "provider-message-final",
|
|
type: "text",
|
|
text: final.text,
|
|
time: { start: 4, end: 5 },
|
|
},
|
|
},
|
|
});
|
|
emitNative({
|
|
type: "message.updated",
|
|
id: "native-usage",
|
|
properties: {
|
|
info: {
|
|
id: "provider-message-final",
|
|
sessionID: providerSessionId,
|
|
role: "assistant",
|
|
tokens: {
|
|
input: 40,
|
|
output: 12,
|
|
cache: { read: 3, write: 0 },
|
|
},
|
|
cost: 0.02,
|
|
},
|
|
},
|
|
});
|
|
emitNative({
|
|
type: "session.idle",
|
|
id: "native-turn-completed",
|
|
properties: { sessionID: providerSessionId },
|
|
});
|
|
closeNativeStream();
|
|
};
|
|
let submittedQuestionReply: unknown = null;
|
|
const fetcher: typeof globalThis.fetch = async (input, init) => {
|
|
const url = new URL(String(input));
|
|
const method = String(init?.method ?? "GET").toUpperCase();
|
|
const json = (value: unknown, status = 200) =>
|
|
new Response(JSON.stringify(value), {
|
|
status,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
if (url.pathname === "/global/health")
|
|
return json({ healthy: true, version: "1.18.34" });
|
|
if (url.pathname === "/event") {
|
|
return new Response(
|
|
new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
streamController = controller;
|
|
for (const frame of queuedFrames.splice(0))
|
|
controller.enqueue(frame);
|
|
if (streamClosed) controller.close();
|
|
},
|
|
}),
|
|
{ status: 200, headers: { "Content-Type": "text/event-stream" } },
|
|
);
|
|
}
|
|
if (method === "POST" && url.pathname === "/session")
|
|
return json({ id: providerSessionId });
|
|
if (method === "GET" && url.pathname === "/question") return json([]);
|
|
if (method === "GET" && url.pathname === "/permission") return json([]);
|
|
if (
|
|
method === "POST" &&
|
|
url.pathname === `/session/${providerSessionId}/prompt_async`
|
|
) {
|
|
emitBeforeQuestion();
|
|
return new Response(null, { status: 204 });
|
|
}
|
|
if (
|
|
method === "POST" &&
|
|
url.pathname ===
|
|
`/question/${localIntegrityBoundaryGolden.expected.questionRequestId}/reply`
|
|
) {
|
|
submittedQuestionReply = JSON.parse(String(init?.body));
|
|
emitAfterQuestion();
|
|
return json(true);
|
|
}
|
|
if (
|
|
method === "POST" &&
|
|
url.pathname === `/session/${providerSessionId}/abort`
|
|
)
|
|
return json(true);
|
|
throw new Error(`unexpected OpenCode boundary request ${method} ${url}`);
|
|
};
|
|
const driver = new OpenCodeServerDriver({
|
|
model: "openrouter/deepseek/deepseek-v4-flash-0731",
|
|
runtimeDirectory: root,
|
|
command: fixture,
|
|
runnerInstanceId: profile.sourceInstanceId,
|
|
fetch: fetcher,
|
|
environment: {
|
|
PATH: process.env.PATH,
|
|
OPENROUTER_API_KEY: "fixture-key",
|
|
PAPERCLIP_PROVIDER_TRACE_PATH: tracePath,
|
|
},
|
|
});
|
|
const session = await driver.openSession({
|
|
runId: profile.runId,
|
|
normalizedSessionId: profile.normalizedSessionId,
|
|
workingDirectory: workspace,
|
|
});
|
|
const { turnId } = await session.startTurn({
|
|
message: { role: "user", text: "exercise native golden boundary" },
|
|
});
|
|
const events = [];
|
|
const iterator = session.events()[Symbol.asyncIterator]();
|
|
let requestCreated = false;
|
|
while (!requestCreated) {
|
|
const next = await iterator.next();
|
|
if (next.done)
|
|
throw new Error("OpenCode ended before asking its question");
|
|
events.push(next.value);
|
|
requestCreated = next.value.eventType === "runtime_request.created";
|
|
}
|
|
await session.resolveRuntimeRequest?.({
|
|
requestId: localIntegrityBoundaryGolden.expected.questionRequestId,
|
|
turnId,
|
|
resolution: {
|
|
action: "submit",
|
|
response: {
|
|
schema: "paperclip.question_response.v1",
|
|
answers: {
|
|
"boundary-mode": { selectedOptionIds: ["lossless"] },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
for (;;) {
|
|
const next = await iterator.next();
|
|
if (next.done) break;
|
|
events.push(next.value);
|
|
if (TURN_TERMINAL_EVENT_TYPES.has(next.value.eventType)) break;
|
|
}
|
|
await session.close({ reason: "boundary-golden-test" });
|
|
|
|
expect(submittedQuestionReply).toEqual({ answers: [["Lossless"]] });
|
|
expect(
|
|
events.every(
|
|
(event) =>
|
|
event.runId === profile.runId &&
|
|
event.normalizedSessionId === profile.normalizedSessionId,
|
|
),
|
|
).toBe(true);
|
|
expect(events).toContainEqual(
|
|
expect.objectContaining({
|
|
itemId: progress.itemId,
|
|
eventType: "item.delta",
|
|
payload: expect.objectContaining({
|
|
kind: "agentMessage",
|
|
channel: progress.channel,
|
|
providerPhase: "commentary",
|
|
text: progress.text,
|
|
}),
|
|
}),
|
|
);
|
|
expect(events).toContainEqual(
|
|
expect.objectContaining({
|
|
itemId: localIntegrityBoundaryGolden.expected.reasoningItemId,
|
|
eventType: "item.completed",
|
|
payload: expect.objectContaining({
|
|
kind: "reasoning",
|
|
channel: "detail",
|
|
providerPhase: "reasoning",
|
|
text: String(goldenEvent("reasoning-completed").payload.text),
|
|
}),
|
|
}),
|
|
);
|
|
expect(
|
|
events
|
|
.filter(
|
|
(event) =>
|
|
event.itemId ===
|
|
localIntegrityBoundaryGolden.expected.toolExecutionId &&
|
|
event.eventType.startsWith("tool.execution."),
|
|
)
|
|
.map((event) => [event.eventType, event.payload.status]),
|
|
).toEqual([
|
|
["tool.execution.started", "running"],
|
|
["tool.execution.progressed", "running"],
|
|
["tool.execution.completed", "completed"],
|
|
]);
|
|
expect(events).toContainEqual(
|
|
expect.objectContaining({
|
|
itemId: localIntegrityBoundaryGolden.expected.questionRequestId,
|
|
eventType: "runtime_request.created",
|
|
payload: {
|
|
request: expect.objectContaining({
|
|
requestId: localIntegrityBoundaryGolden.expected.questionRequestId,
|
|
origin: {
|
|
adapter: profile.adapter,
|
|
provider: profile.originProvider,
|
|
method: profile.originMethod,
|
|
},
|
|
input: expect.objectContaining({
|
|
schema: "paperclip.question_set.v1",
|
|
title: "Boundary mode",
|
|
questions: [
|
|
expect.objectContaining({
|
|
id: "boundary-mode",
|
|
answerMode: "single_select",
|
|
}),
|
|
],
|
|
}),
|
|
}),
|
|
},
|
|
}),
|
|
);
|
|
expect(
|
|
events.filter(
|
|
(event) =>
|
|
event.itemId ===
|
|
localIntegrityBoundaryGolden.expected.questionRequestId &&
|
|
event.eventType === "runtime_request.resolved",
|
|
),
|
|
).toHaveLength(1);
|
|
expect(events).toContainEqual(
|
|
expect.objectContaining({
|
|
itemId: final.itemId,
|
|
eventType: "item.completed",
|
|
payload: expect.objectContaining({
|
|
kind: "agentMessage",
|
|
channel: final.channel,
|
|
providerPhase: "final_answer",
|
|
text: final.text,
|
|
}),
|
|
}),
|
|
);
|
|
expect(
|
|
events.find(
|
|
(event) =>
|
|
event.eventType === "item.completed" &&
|
|
event.payload.kind === "usage",
|
|
),
|
|
).toMatchObject({
|
|
itemId: `${turnId}:usage`,
|
|
payload: {
|
|
usageMessageId: "provider-message-final",
|
|
usage: {
|
|
input: 40,
|
|
output: 12,
|
|
cache: { read: 3, write: 0 },
|
|
costUsd: 0.02,
|
|
},
|
|
},
|
|
});
|
|
expect(events).toContainEqual(
|
|
expect.objectContaining({
|
|
eventType: "turn.completed",
|
|
turnId,
|
|
payload: { status: "completed" },
|
|
}),
|
|
);
|
|
|
|
// OpenCode 1.18 does not expose a qualified structured Plan event, and
|
|
// run.terminal is owned by the native-session layer above this driver.
|
|
// Keep both absences explicit instead of synthesizing Codex-shaped input.
|
|
expect(
|
|
(await driver.descriptor()).capabilities.typedEventFamilies.find(
|
|
(family) => family.family === "plan",
|
|
),
|
|
).toMatchObject({ availability: "unsupported" });
|
|
expect(events.some((event) => event.eventType === "plan.updated")).toBe(
|
|
false,
|
|
);
|
|
expect(events.some((event) => event.eventType === "run.terminal")).toBe(
|
|
false,
|
|
);
|
|
|
|
const traceEntries = (await readFile(tracePath, "utf8"))
|
|
.trim()
|
|
.split("\n")
|
|
.map((line) => JSON.parse(line) as Record<string, unknown>);
|
|
const traceFrames = traceEntries.filter(
|
|
(entry) =>
|
|
entry.kind === "frame" && entry.direction === "provider_to_client",
|
|
);
|
|
const normalization = traceEntries.filter(
|
|
(entry) =>
|
|
entry.kind === "interpretation" &&
|
|
entry.stage === "typescript_opencode_driver_normalization",
|
|
);
|
|
expect(nativeEvents.map((event) => event.type)).toEqual([
|
|
"message.updated",
|
|
"message.part.updated",
|
|
"message.part.updated",
|
|
"message.part.updated",
|
|
"message.part.updated",
|
|
"message.part.updated",
|
|
"question.asked",
|
|
"question.replied",
|
|
"message.updated",
|
|
"message.part.updated",
|
|
"message.updated",
|
|
"session.idle",
|
|
]);
|
|
for (const nativeEvent of nativeEvents) {
|
|
const frame = traceFrames.find((entry) => {
|
|
if (typeof entry.rawBase64 !== "string") return false;
|
|
const raw = Buffer.from(entry.rawBase64, "base64").toString("utf8");
|
|
return raw.includes(`\"id\":\"${String(nativeEvent.id)}\"`);
|
|
});
|
|
expect(frame, String(nativeEvent.id)).toBeDefined();
|
|
const interpretation = normalization.find(
|
|
(entry) => entry.frameId === frame?.frameId,
|
|
);
|
|
expect(interpretation, String(nativeEvent.id)).toMatchObject({
|
|
disposition: expect.stringMatching(/^(mapped|ignored)$/),
|
|
});
|
|
if (interpretation?.disposition === "ignored") {
|
|
expect(
|
|
[
|
|
"native-progress-message-role",
|
|
"native-final-message-role",
|
|
"native-question-resolved",
|
|
],
|
|
`${String(nativeEvent.id)} must be a structural or duplicate echo`,
|
|
).toContain(nativeEvent.id);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("projects a custom connection into the isolated OpenCode config", async () => {
|
|
await chmod(fixture, 0o755);
|
|
const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-routing-"));
|
|
roots.push(root);
|
|
const driver = new OpenCodeServerDriver({
|
|
model: "paperclip/team/model-alias",
|
|
runtimeDirectory: root,
|
|
command: fixture,
|
|
environment: { PATH: process.env.PATH, PAPERCLIP_AI_PROVIDER_URL: "https://gateway.example/v1", PAPERCLIP_AI_PROVIDER_KEY: "selected-gateway-key" },
|
|
});
|
|
const session = await driver.openSession({ runId: "routing", normalizedSessionId: "routing", workingDirectory: root });
|
|
try {
|
|
const configPath = join(root, "routing", "config", "opencode", "opencode.json");
|
|
expect(JSON.parse(await readFile(configPath, "utf8"))).toMatchObject({
|
|
model: "paperclip/team/model-alias", small_model: "paperclip/team/model-alias", plugin: [],
|
|
provider: { paperclip: { npm: "@ai-sdk/openai-compatible", options: { baseURL: expect.stringMatching(/^http:\/\/127\.0\.0\.1:\d+\/v1$/), apiKey: expect.any(String) }, models: { "team/model-alias": { name: "team/model-alias" } } } },
|
|
});
|
|
expect((await stat(configPath)).mode & 0o777).toBe(0o600);
|
|
const shell = await promisify(execFile)("sh", ["-c", 'cat "$1"', "sh", configPath]);
|
|
expect(shell.stdout).not.toContain("selected-gateway-key");
|
|
const environment = JSON.parse(await readFile(join(root, "routing", "data", "fake-environment.json"), "utf8"));
|
|
expect(environment.keys).not.toContain("PAPERCLIP_AI_PROVIDER_KEY");
|
|
} finally {
|
|
await session.close({ reason: "test" });
|
|
}
|
|
});
|
|
|
|
it.each(["gateway-key", ""])("forwards selected-model streams without exposing the reusable key and revokes the proxy on close (%s)", async key => {
|
|
const received: Array<{ path: string; authorization?: string; body: unknown }> = [];
|
|
const upstream = createHttpServer((request, response) => {
|
|
void (async () => {
|
|
const chunks = [];
|
|
for await (const chunk of request) chunks.push(Buffer.from(chunk));
|
|
const body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
received.push({ path: request.url!, authorization: request.headers.authorization, body });
|
|
response.writeHead(200, { "content-type": "text/event-stream", "x-provider-private-header": "private" });
|
|
response.write('data: {"choices":[]}\n\n');
|
|
if (body.messages[0].content !== "hold") response.end('data: [DONE]\n\n');
|
|
})().catch(() => response.destroy());
|
|
});
|
|
await new Promise<void>(resolve => upstream.listen(0, "127.0.0.1", resolve));
|
|
const address = upstream.address();
|
|
if (!address || typeof address === "string") throw new Error("Missing fixture address");
|
|
const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-proxy-"));
|
|
roots.push(root);
|
|
let session: Awaited<ReturnType<OpenCodeServerDriver["openSession"]>> | undefined;
|
|
try {
|
|
const driver = new OpenCodeServerDriver({
|
|
model: "paperclip/team/model-alias", runtimeDirectory: root, command: fixture,
|
|
environment: { PATH: process.env.PATH, PAPERCLIP_AI_PROVIDER_URL: `http://127.0.0.1:${address.port}/custom/v1`, PAPERCLIP_AI_PROVIDER_KEY: key },
|
|
});
|
|
session = await driver.openSession({ runId: "proxy", normalizedSessionId: "proxy", workingDirectory: root });
|
|
const config = JSON.parse(await readFile(join(root, "proxy", "config", "opencode", "opencode.json"), "utf8"));
|
|
const { baseURL, apiKey } = config.provider.paperclip.options;
|
|
expect(apiKey).not.toBe(key);
|
|
const url = `${baseURL}/chat/completions`;
|
|
const headers = { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" };
|
|
const payload = { model: "team/model-alias", stream: true, messages: [{ role: "user", content: "test" }] };
|
|
expect((await fetch(url, { method: "POST", body: JSON.stringify(payload) })).status).toBe(401);
|
|
expect((await fetch(`${baseURL}/models`, { headers })).status).toBe(404);
|
|
expect((await fetch(url, { method: "POST", headers, body: JSON.stringify({ ...payload, model: "other" }) })).status).toBe(400);
|
|
expect(received).toHaveLength(0);
|
|
const result = await fetch(url, { method: "POST", headers, body: JSON.stringify(payload) });
|
|
expect(result.headers.get("x-provider-private-header")).toBeNull();
|
|
expect(await result.text()).toBe('data: {"choices":[]}\n\ndata: [DONE]\n\n');
|
|
expect(received).toEqual([{ path: "/custom/v1/chat/completions", authorization: key ? `Bearer ${key}` : undefined, body: payload }]);
|
|
const pending = await fetch(url, { method: "POST", headers, body: JSON.stringify({ ...payload, messages: [{ role: "user", content: "hold" }] }) });
|
|
const pendingText = pending.text().then(() => "unexpected completion", () => "aborted");
|
|
await session.close({ reason: "test" });
|
|
session = undefined;
|
|
expect(await pendingText).toBe("aborted");
|
|
await expect(fetch(url, { method: "POST", headers, body: JSON.stringify(payload) })).rejects.toThrow();
|
|
} finally {
|
|
await session?.close({ reason: "test" });
|
|
await new Promise<void>(resolve => { upstream.close(() => resolve()); upstream.closeAllConnections(); });
|
|
}
|
|
});
|
|
|
|
it.each([["HTTP_PROXY", "file"], ["ALL_PROXY", "directory"]] as const)("uses the runtime's %s, %s trust, and NO_PROXY without changing global transport", async (proxySetting, trust) => {
|
|
const requests: string[] = [];
|
|
const outgoingProxy = createHttpServer((request, response) => {
|
|
requests.push(request.url!);
|
|
request.resume();
|
|
response.writeHead(200, { "content-type": "application/json" }).end('{"choices":[]}');
|
|
});
|
|
await new Promise<void>(resolve => outgoingProxy.listen(0, "127.0.0.1", resolve));
|
|
const address = outgoingProxy.address();
|
|
if (!address || typeof address === "string") throw new Error("Missing proxy fixture address");
|
|
const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-outgoing-proxy-"));
|
|
roots.push(root);
|
|
const certificateDir = join(root, "certificates");
|
|
await mkdir(certificateDir);
|
|
const certificatePath = join(certificateDir, "runtime-ca.pem");
|
|
await writeFile(certificatePath, getCACertificates("default")[0]!);
|
|
await writeFile(join(certificateDir, "README"), "Non-certificate directory entries must be ignored.");
|
|
const trustEnvironment = trust === "file" ? { SSL_CERT_FILE: certificatePath } : { SSL_CERT_DIR: certificateDir };
|
|
const headersAndUrl = async (sessionId: string, noProxy: string) => {
|
|
const driver = new OpenCodeServerDriver({
|
|
model: "paperclip/team/model-alias", runtimeDirectory: root, command: fixture,
|
|
environment: { ...trustEnvironment, PATH: process.env.PATH, PAPERCLIP_AI_PROVIDER_URL: "http://gateway.invalid/v1", PAPERCLIP_AI_PROVIDER_KEY: "fixture-key", [proxySetting]: `http://127.0.0.1:${address.port}`, NO_PROXY: noProxy },
|
|
});
|
|
const session = await driver.openSession({ runId: sessionId, normalizedSessionId: sessionId, workingDirectory: root });
|
|
const config = JSON.parse(await readFile(join(root, sessionId, "config", "opencode", "opencode.json"), "utf8"));
|
|
return { session, url: `${config.provider.paperclip.options.baseURL}/chat/completions`, headers: { Authorization: `Bearer ${config.provider.paperclip.options.apiKey}`, "Content-Type": "application/json" } };
|
|
};
|
|
try {
|
|
const proxied = await headersAndUrl("proxied", "");
|
|
try {
|
|
const response = await fetch(proxied.url, { method: "POST", headers: proxied.headers, body: JSON.stringify({ model: "team/model-alias", messages: [] }) });
|
|
expect(response.status).toBe(200);
|
|
expect(await response.json()).toEqual({ choices: [] });
|
|
expect(requests).toEqual(["http://gateway.invalid/v1/chat/completions"]);
|
|
} finally { await proxied.session.close({ reason: "test" }); }
|
|
const bypassed = await headersAndUrl("bypassed", "gateway.invalid");
|
|
try {
|
|
const response = await fetch(bypassed.url, { method: "POST", headers: bypassed.headers, body: JSON.stringify({ model: "team/model-alias", messages: [] }) });
|
|
expect(response.status).toBe(502);
|
|
expect(requests).toHaveLength(1);
|
|
} finally { await bypassed.session.close({ reason: "test" }); }
|
|
} finally {
|
|
await new Promise<void>(resolve => { outgoingProxy.close(() => resolve()); outgoingProxy.closeAllConnections(); });
|
|
}
|
|
});
|
|
|
|
it("starts an authenticated isolated server, creates a session, streams usage, aborts, and cleans up", async () => {
|
|
await chmod(fixture, 0o755);
|
|
const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-driver-"));
|
|
const workspace = await mkdtemp(
|
|
join(tmpdir(), "paperclip-opencode-workspace-"),
|
|
);
|
|
roots.push(root, workspace);
|
|
const tracePath = join(root, "provider-trace.ndjson");
|
|
const spawns: Array<{ pid: number; processGroupId: number | null }> = [];
|
|
const diagnostics: string[] = [];
|
|
const driver = new OpenCodeServerDriver({
|
|
model: "openrouter/deepseek/deepseek-v4-flash-0731",
|
|
runtimeDirectory: root,
|
|
command: fixture,
|
|
environment: {
|
|
PATH: process.env.PATH,
|
|
OPENROUTER_API_KEY: "test-openrouter-key",
|
|
PAPERCLIP_API_KEY: "must-not-leak",
|
|
UNRELATED_SECRET: "must-not-leak",
|
|
PAPERCLIP_PROVIDER_TRACE_PATH: tracePath,
|
|
PAPERCLIP_PROVIDER_TRACE_MAX_BYTES: String(64 * 1024 * 1024),
|
|
},
|
|
onSpawn: async (meta) => {
|
|
spawns.push(meta);
|
|
},
|
|
onDiagnostic: (message) => diagnostics.push(message),
|
|
});
|
|
const session = await driver.openSession({
|
|
runId: "run-1",
|
|
normalizedSessionId: "normalized/1",
|
|
workingDirectory: workspace,
|
|
});
|
|
expect(session.ids()).toMatchObject({ providerSessionId: "ses_fake_1" });
|
|
expect(spawns).toHaveLength(1);
|
|
const turn = await session.startTurn({
|
|
message: { role: "user", text: "finish" },
|
|
});
|
|
const events = await collectTurnEvents(session.events());
|
|
expect(events.map((event) => event.eventType)).toContain("turn.completed");
|
|
expect(events).toContainEqual(
|
|
expect.objectContaining({ eventType: "run.result.proposed" }),
|
|
);
|
|
expect(
|
|
events.filter((event) => event.eventType === "item.delta"),
|
|
).toHaveLength(1);
|
|
expect(
|
|
events.find((event) => event.eventType === "item.delta")?.payload.text,
|
|
).toBe("done [guide](guide.md)");
|
|
expect(
|
|
events.find(
|
|
(event) =>
|
|
event.eventType === "item.completed" &&
|
|
event.payload.kind === "agentMessage",
|
|
)?.payload,
|
|
).toMatchObject({
|
|
channel: "final",
|
|
providerPhase: "final_answer",
|
|
text: "done [guide](guide.md)",
|
|
});
|
|
const usageEvents = events.filter(
|
|
(event) =>
|
|
event.eventType === "item.completed" && event.payload.kind === "usage",
|
|
);
|
|
expect(usageEvents).toHaveLength(1);
|
|
expect(usageEvents[0]?.payload).toMatchObject({
|
|
usageMessageId: expect.any(String),
|
|
});
|
|
expect(
|
|
events.find((event) => event.eventType === "workspace.change.updated")
|
|
?.payload,
|
|
).toMatchObject({
|
|
schema: "paperclip.workspace.diff.v1",
|
|
source: "harness_reported",
|
|
totals: { files: 1 },
|
|
});
|
|
expect(
|
|
events.find((event) => event.eventType === "workspace.diff.recorded")
|
|
?.payload,
|
|
).toMatchObject({ schema: "paperclip.workspace.diff.v1", complete: true });
|
|
expect(
|
|
events.find((event) => event.eventType === "workspace.file.referenced")
|
|
?.payload,
|
|
).toMatchObject({
|
|
schema: "paperclip.workspace.file_reference.v1",
|
|
path: "guide.md",
|
|
});
|
|
expect(JSON.stringify(events)).not.toContain(
|
|
"submitted prompt must not become assistant text",
|
|
);
|
|
expect(await session.usage()).toMatchObject({
|
|
input: 3,
|
|
output: 2,
|
|
costUsd: 0.001,
|
|
provider: "openrouter",
|
|
driverVersion: "1.18.34",
|
|
});
|
|
await session.interrupt?.({ turnId: turn.turnId });
|
|
const snapshot = await session.snapshot();
|
|
expect(snapshot.driverKind).toBe("opencode_server");
|
|
const sessionRoot = join(root, "normalized_1");
|
|
expect((await stat(sessionRoot)).mode & 0o777).toBe(0o700);
|
|
expect(
|
|
(await stat(join(sessionRoot, "config", "opencode", "opencode.json")))
|
|
.mode & 0o777,
|
|
).toBe(0o600);
|
|
const config = await readFile(
|
|
join(sessionRoot, "config", "opencode", "opencode.json"),
|
|
"utf8",
|
|
);
|
|
await session.close({ reason: "test" });
|
|
const recovered = await driver.recoverSession?.(snapshot);
|
|
expect(recovered).toMatchObject({ recovered: true });
|
|
await expect(recovered?.session?.read?.()).resolves.toMatchObject({
|
|
sessionId: "ses_fake_1",
|
|
messages: [],
|
|
});
|
|
await recovered?.session?.close({ reason: "recovery-test" });
|
|
|
|
const traceEntries = (await readFile(tracePath, "utf8"))
|
|
.trim()
|
|
.split("\n")
|
|
.map((line) => JSON.parse(line) as Record<string, unknown>);
|
|
expect(JSON.stringify(traceEntries)).not.toContain("test-openrouter-key");
|
|
const traceFrames = traceEntries.filter((entry) => entry.kind === "frame");
|
|
expect(traceFrames.map((entry) => entry.direction)).toEqual(
|
|
expect.arrayContaining([
|
|
"client_to_provider",
|
|
"provider_to_client",
|
|
"provider_stderr",
|
|
]),
|
|
);
|
|
expect(
|
|
traceFrames.some((entry) => entry.nativeMethod === "SSE /event"),
|
|
).toBe(true);
|
|
expect(
|
|
traceEntries.some(
|
|
(entry) =>
|
|
entry.stage === "typescript_opencode_driver_normalization" &&
|
|
Array.isArray(entry.emittedEventIds) &&
|
|
entry.emittedEventIds.length > 0,
|
|
),
|
|
).toBe(true);
|
|
expect(traceEntries.at(-1)).toMatchObject({
|
|
kind: "trace_status",
|
|
debugChannel: "typescript_opencode_native",
|
|
status: "complete",
|
|
});
|
|
|
|
const environment = JSON.parse(
|
|
await readFile(
|
|
join(sessionRoot, "data", "fake-environment.json"),
|
|
"utf8",
|
|
),
|
|
);
|
|
const mcpEvidence = JSON.parse(
|
|
await readFile(
|
|
join(sessionRoot, "data", "fake-mcp-evidence.json"),
|
|
"utf8",
|
|
),
|
|
);
|
|
expect(environment.keys).toContain("OPENROUTER_API_KEY");
|
|
expect(environment.keys).not.toContain("PAPERCLIP_API_KEY");
|
|
expect(environment.keys).not.toContain("UNRELATED_SECRET");
|
|
expect(environment.keys).not.toContain("PAPERCLIP_PROVIDER_TRACE_PATH");
|
|
expect(environment.projectConfigDisabled).toBe("true");
|
|
expect(environment.modelsFetchDisabled).toBe("true");
|
|
expect(environment.defaultPluginsDisabled).toBe("true");
|
|
expect(mcpEvidence.tools).toEqual(
|
|
expect.arrayContaining(["paperclip_finish", "paperclip_block"]),
|
|
);
|
|
expect(config).toContain("openrouter/deepseek/deepseek-v4-flash-0731");
|
|
expect(config).toContain('"*": "allow"');
|
|
expect(JSON.parse(config).permission.external_directory).toMatchObject({ "*": "deny", [`${workspace}/**`]: "allow" });
|
|
expect(
|
|
events.some((event) => event.eventType === "runtime_request.created"),
|
|
).toBe(false);
|
|
expect(config).not.toContain("test-openrouter-key");
|
|
expect(diagnostics.join("\n")).not.toContain("test-openrouter-key");
|
|
expect(diagnostics.join("\n")).toContain("[REDACTED]");
|
|
});
|
|
|
|
it.each(["role-first", "role-last", "role-last-long"])("preserves incremental text and completion with identity checks (%s)", async (roleOrder) => {
|
|
await chmod(fixture, 0o755);
|
|
const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-stream-"));
|
|
const workspace = await mkdtemp(join(tmpdir(), "paperclip-opencode-stream-workspace-"));
|
|
roots.push(root, workspace);
|
|
const driver = new OpenCodeServerDriver({
|
|
model: "openrouter/deepseek/deepseek-v4-flash-0731",
|
|
runtimeDirectory: root,
|
|
command: fixture,
|
|
environment: { PATH: process.env.PATH },
|
|
});
|
|
const session = await driver.openSession({ runId: "stream", normalizedSessionId: "stream", workingDirectory: workspace });
|
|
try {
|
|
await session.startTurn({ message: { role: "user", text: `incremental-stream ${roleOrder}` } });
|
|
const deltas: string[] = [];
|
|
const reasoning: string[] = [];
|
|
const events: PrpEvent[] = [];
|
|
for await (const event of session.events()) {
|
|
events.push(event);
|
|
if (event.eventType === "item.delta" && event.payload.kind === "reasoning") reasoning.push(String(event.payload.text));
|
|
if (event.eventType === "item.delta" && event.payload.kind === "agentMessage") {
|
|
deltas.push(String(event.payload.text));
|
|
// The fixture cannot finish its turn until a chunk reaches us.
|
|
if (deltas.length === 1) {
|
|
expect(await readFile(join(root, "stream", "data", "stream-released"), "utf8").catch(() => null)).toBeNull();
|
|
}
|
|
await writeFile(join(root, "stream", "data", "release-stream"), "ready");
|
|
}
|
|
if (TURN_TERMINAL_EVENT_TYPES.has(event.eventType)) break;
|
|
}
|
|
const expectedText = `Hi ${"👋".repeat(roleOrder === "role-last-long" ? 120 : 2)}`;
|
|
expect(deltas).toEqual(roleOrder === "role-first" ? ["Hi ", "👋", "👋"] : [expectedText]);
|
|
expect(reasoning).toEqual(["Thinking"]);
|
|
expect(events.filter(event => event.eventType === "item.completed" && event.payload.kind === "agentMessage")
|
|
.map(event => event.payload.text)).toEqual([expectedText]);
|
|
} finally {
|
|
await session.close({ reason: "stream test complete" });
|
|
}
|
|
});
|
|
|
|
it("maps OpenCode's normal abort error to cancellation without a false provider failure notice", async () => {
|
|
await chmod(fixture, 0o755);
|
|
const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-abort-"));
|
|
const workspace = await mkdtemp(
|
|
join(tmpdir(), "paperclip-opencode-abort-workspace-"),
|
|
);
|
|
roots.push(root, workspace);
|
|
const driver = new OpenCodeServerDriver({
|
|
model: "openrouter/deepseek/deepseek-v4-flash-0731",
|
|
runtimeDirectory: root,
|
|
command: fixture,
|
|
environment: {
|
|
PATH: process.env.PATH,
|
|
OPENROUTER_API_KEY: "fixture-key",
|
|
},
|
|
});
|
|
const session = await driver.openSession({
|
|
runId: "run-aborted",
|
|
normalizedSessionId: "aborted",
|
|
workingDirectory: workspace,
|
|
});
|
|
await session.startTurn({
|
|
message: { role: "user", text: "session-aborted" },
|
|
});
|
|
const events = await collectTurnEvents(session.events());
|
|
expect(events.map((event) => event.eventType)).toContain("turn.cancelled");
|
|
expect(events.map((event) => event.eventType)).not.toContain("turn.failed");
|
|
expect(events.map((event) => event.eventType)).not.toContain(
|
|
"provider.notice.recorded",
|
|
);
|
|
await session.close({ reason: "test" });
|
|
});
|
|
|
|
it("runs two consecutive turns on one session and delivers exactly one terminal event per turn", async () => {
|
|
await chmod(fixture, 0o755);
|
|
const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-multi-turn-"));
|
|
const workspace = await mkdtemp(
|
|
join(tmpdir(), "paperclip-opencode-multi-turn-workspace-"),
|
|
);
|
|
roots.push(root, workspace);
|
|
const driver = new OpenCodeServerDriver({
|
|
model: "openrouter/deepseek/deepseek-v4-flash-0731",
|
|
runtimeDirectory: root,
|
|
command: fixture,
|
|
environment: {
|
|
PATH: process.env.PATH,
|
|
OPENROUTER_API_KEY: "fixture-key",
|
|
},
|
|
});
|
|
const session = await driver.openSession({
|
|
runId: "run-multi-turn",
|
|
normalizedSessionId: "multi-turn",
|
|
workingDirectory: workspace,
|
|
});
|
|
|
|
const firstTurn = await session.startTurn({
|
|
message: { role: "user", text: "finish" },
|
|
});
|
|
const firstTurnEvents = await collectTurnEvents(session.events());
|
|
expect(
|
|
firstTurnEvents.filter((event) => event.eventType === "turn.completed"),
|
|
).toMatchObject([{ turnId: firstTurn.turnId }]);
|
|
expect(
|
|
firstTurnEvents.find(
|
|
(event) =>
|
|
event.eventType === "item.completed" &&
|
|
event.payload.kind === "agentMessage",
|
|
)?.payload,
|
|
).toMatchObject({ text: "done [guide](guide.md)" });
|
|
|
|
const secondTurn = await session.startTurn({
|
|
message: { role: "user", text: "finish" },
|
|
});
|
|
expect(secondTurn.turnId).not.toBe(firstTurn.turnId);
|
|
const secondTurnEvents = await collectTurnEvents(session.events());
|
|
expect(
|
|
secondTurnEvents.filter((event) => event.eventType === "turn.completed"),
|
|
).toMatchObject([{ turnId: secondTurn.turnId }]);
|
|
expect(
|
|
secondTurnEvents.find(
|
|
(event) =>
|
|
event.eventType === "item.completed" &&
|
|
event.payload.kind === "agentMessage",
|
|
)?.payload,
|
|
).toMatchObject({ text: "done [guide](guide.md)" });
|
|
expect(
|
|
secondTurnEvents.some((event) => event.turnId === firstTurn.turnId),
|
|
).toBe(false);
|
|
expect(
|
|
secondTurnEvents.some((event) => event.eventType === "harness.diagnostic"),
|
|
).toBe(false);
|
|
|
|
await session.close({ reason: "test" });
|
|
});
|
|
|
|
it("drops a distinct late frame for a completed turn instead of attributing it to the next turn", async () => {
|
|
await chmod(fixture, 0o755);
|
|
const root = await mkdtemp(
|
|
join(tmpdir(), "paperclip-opencode-late-frame-"),
|
|
);
|
|
const workspace = await mkdtemp(
|
|
join(tmpdir(), "paperclip-opencode-late-frame-workspace-"),
|
|
);
|
|
roots.push(root, workspace);
|
|
const driver = new OpenCodeServerDriver({
|
|
model: "openrouter/deepseek/deepseek-v4-flash-0731",
|
|
runtimeDirectory: root,
|
|
command: fixture,
|
|
environment: {
|
|
PATH: process.env.PATH,
|
|
OPENROUTER_API_KEY: "fixture-key",
|
|
},
|
|
});
|
|
const session = await driver.openSession({
|
|
runId: "run-late-frame",
|
|
normalizedSessionId: "late-frame",
|
|
workingDirectory: workspace,
|
|
});
|
|
|
|
const firstTurn = await session.startTurn({
|
|
message: { role: "user", text: "late-straggler-source" },
|
|
});
|
|
const firstTurnEvents = await collectTurnEvents(session.events());
|
|
expect(
|
|
firstTurnEvents.filter((event) => event.eventType === "turn.completed"),
|
|
).toMatchObject([{ turnId: firstTurn.turnId }]);
|
|
|
|
// The fixture holds a distinct frame for the first turn's message and
|
|
// delivers it only once this second turn's prompt has been accepted,
|
|
// simulating a provider frame that arrives after its own turn is
|
|
// already sealed.
|
|
const secondTurn = await session.startTurn({
|
|
message: { role: "user", text: "finish" },
|
|
});
|
|
expect(secondTurn.turnId).not.toBe(firstTurn.turnId);
|
|
const secondTurnEvents = await collectTurnEvents(session.events());
|
|
|
|
expect(
|
|
secondTurnEvents.some((event) =>
|
|
JSON.stringify(event.payload).includes(
|
|
"late straggler text must not reach the next turn",
|
|
),
|
|
),
|
|
).toBe(false);
|
|
expect(
|
|
secondTurnEvents.some((event) => event.turnId === firstTurn.turnId),
|
|
).toBe(false);
|
|
expect(
|
|
secondTurnEvents.filter((event) => event.eventType === "turn.completed"),
|
|
).toMatchObject([{ turnId: secondTurn.turnId }]);
|
|
expect(
|
|
secondTurnEvents.find(
|
|
(event) => event.eventType === "harness.diagnostic",
|
|
)?.payload,
|
|
).toMatchObject({
|
|
code: "opencode_late_terminal_turn_event_dropped",
|
|
turnId: firstTurn.turnId,
|
|
});
|
|
|
|
await session.close({ reason: "test" });
|
|
});
|
|
|
|
it("keeps rejecting a late frame for a sealed turn no matter how many later turns have already sealed", async () => {
|
|
await chmod(fixture, 0o755);
|
|
const root = await mkdtemp(
|
|
join(tmpdir(), "paperclip-opencode-sealed-many-turns-"),
|
|
);
|
|
const workspace = await mkdtemp(
|
|
join(tmpdir(), "paperclip-opencode-sealed-many-turns-workspace-"),
|
|
);
|
|
roots.push(root, workspace);
|
|
const driver = new OpenCodeServerDriver({
|
|
model: "openrouter/deepseek/deepseek-v4-flash-0731",
|
|
runtimeDirectory: root,
|
|
command: fixture,
|
|
environment: {
|
|
PATH: process.env.PATH,
|
|
OPENROUTER_API_KEY: "fixture-key",
|
|
},
|
|
});
|
|
const session = await driver.openSession({
|
|
runId: "run-sealed-many-turns",
|
|
normalizedSessionId: "sealed-many-turns",
|
|
workingDirectory: workspace,
|
|
});
|
|
|
|
// Turn A schedules its own late frame to arrive four prompts later, so
|
|
// it lands only after three more turns have sealed. The late-frame gate
|
|
// in `#emit` compares directly against the current active turn, not a
|
|
// bounded history, so it must still reject this frame no matter how
|
|
// many turns sealed in between.
|
|
const turnA = await session.startTurn({
|
|
message: { role: "user", text: "late-straggler-source-delay-4" },
|
|
});
|
|
await collectTurnEvents(session.events());
|
|
|
|
for (let index = 0; index < 3; index += 1) {
|
|
await session.startTurn({ message: { role: "user", text: "finish" } });
|
|
await collectTurnEvents(session.events());
|
|
}
|
|
|
|
// This turn's prompt request is what finally delivers turn A's scheduled
|
|
// late frame, well after turn A's own turn sealed.
|
|
const turnE = await session.startTurn({
|
|
message: { role: "user", text: "finish" },
|
|
});
|
|
const turnEEvents = await collectTurnEvents(session.events());
|
|
|
|
expect(
|
|
turnEEvents.some((event) =>
|
|
JSON.stringify(event.payload).includes(
|
|
"late straggler text must not reach the next turn",
|
|
),
|
|
),
|
|
).toBe(false);
|
|
expect(turnEEvents.some((event) => event.turnId === turnA.turnId)).toBe(
|
|
false,
|
|
);
|
|
expect(
|
|
turnEEvents.find((event) => event.eventType === "harness.diagnostic")
|
|
?.payload,
|
|
).toMatchObject({
|
|
code: "opencode_late_terminal_turn_event_dropped",
|
|
turnId: turnA.turnId,
|
|
});
|
|
expect(
|
|
turnEEvents.filter((event) => event.eventType === "turn.completed"),
|
|
).toMatchObject([{ turnId: turnE.turnId }]);
|
|
|
|
await session.close({ reason: "test" });
|
|
});
|
|
|
|
it("delivers the settlement event for a runtime request whose turn fails through the same single-pass consumer that reads the turn", async () => {
|
|
await chmod(fixture, 0o755);
|
|
const root = await mkdtemp(
|
|
join(tmpdir(), "paperclip-opencode-pending-then-fail-"),
|
|
);
|
|
const workspace = await mkdtemp(
|
|
join(tmpdir(), "paperclip-opencode-pending-then-fail-workspace-"),
|
|
);
|
|
roots.push(root, workspace);
|
|
const driver = new OpenCodeServerDriver({
|
|
model: "openrouter/deepseek/deepseek-v4-flash-0731",
|
|
runtimeDirectory: root,
|
|
command: fixture,
|
|
environment: {
|
|
PATH: process.env.PATH,
|
|
OPENROUTER_API_KEY: "fixture-key",
|
|
},
|
|
});
|
|
const session = await driver.openSession({
|
|
runId: "run-pending-then-fail",
|
|
normalizedSessionId: "pending-then-fail",
|
|
workingDirectory: workspace,
|
|
});
|
|
|
|
const { turnId } = await session.startTurn({
|
|
message: { role: "user", text: "pending-request-then-turn-fails" },
|
|
});
|
|
// The fixture asks a native question, which leaves a runtime request
|
|
// pending, then fails the same turn through `session.error` without
|
|
// ever resolving that request. `collectTurnEvents` reads exactly one
|
|
// pass and stops at the turn's terminal event, the same rule the
|
|
// production consumer applies (see its doc comment above). The
|
|
// settlement event must arrive inside that same pass: a production
|
|
// consumer that stops at `turn.failed` never opens a second read
|
|
// afterward, so a settlement event that only `close()` produced later
|
|
// would never reach it.
|
|
const turnEvents = await collectTurnEvents(session.events());
|
|
expect(
|
|
turnEvents.some(
|
|
(event) => event.eventType === "runtime_request.created",
|
|
),
|
|
).toBe(true);
|
|
const settlementIndex = turnEvents.findIndex(
|
|
(event) => event.eventType === "runtime_request.expired",
|
|
);
|
|
const terminalIndex = turnEvents.findIndex(
|
|
(event) => event.eventType === "turn.failed",
|
|
);
|
|
expect(settlementIndex).toBeGreaterThanOrEqual(0);
|
|
expect(terminalIndex).toBeGreaterThanOrEqual(0);
|
|
// The settlement fact must precede the turn's terminal event, or a
|
|
// consumer that stops reading at that terminal event misses it.
|
|
expect(settlementIndex).toBeLessThan(terminalIndex);
|
|
expect(turnEvents[settlementIndex]).toMatchObject({
|
|
turnId,
|
|
itemId: "question-native-1",
|
|
});
|
|
expect(turnEvents[terminalIndex]).toMatchObject({ turnId });
|
|
expect(
|
|
turnEvents.some(
|
|
(event) =>
|
|
event.eventType === "harness.diagnostic" &&
|
|
event.payload.code === "opencode_late_terminal_turn_event_dropped" &&
|
|
event.payload.turnId === turnId,
|
|
),
|
|
).toBe(false);
|
|
// The request already settled with the turn; nothing is left pending
|
|
// for `close()` to settle a second time.
|
|
expect(session.pendingRuntimeRequests?.()).toHaveLength(0);
|
|
|
|
await session.close({ reason: "test" });
|
|
const closeEvents = await collectTurnEvents(session.events());
|
|
expect(
|
|
closeEvents.some((event) =>
|
|
["runtime_request.expired", "runtime_request.cancelled"].includes(
|
|
event.eventType,
|
|
),
|
|
),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("keeps the session usable after a cancelled turn so the next turn on the same session still completes", async () => {
|
|
await chmod(fixture, 0o755);
|
|
const root = await mkdtemp(
|
|
join(tmpdir(), "paperclip-opencode-cancel-then-finish-"),
|
|
);
|
|
const workspace = await mkdtemp(
|
|
join(tmpdir(), "paperclip-opencode-cancel-then-finish-workspace-"),
|
|
);
|
|
roots.push(root, workspace);
|
|
const driver = new OpenCodeServerDriver({
|
|
model: "openrouter/deepseek/deepseek-v4-flash-0731",
|
|
runtimeDirectory: root,
|
|
command: fixture,
|
|
environment: {
|
|
PATH: process.env.PATH,
|
|
OPENROUTER_API_KEY: "fixture-key",
|
|
},
|
|
});
|
|
const session = await driver.openSession({
|
|
runId: "run-cancel-then-finish",
|
|
normalizedSessionId: "cancel-then-finish",
|
|
workingDirectory: workspace,
|
|
});
|
|
|
|
const cancelledTurn = await session.startTurn({
|
|
message: { role: "user", text: "session-aborted" },
|
|
});
|
|
const cancelledTurnEvents = await collectTurnEvents(session.events());
|
|
expect(
|
|
cancelledTurnEvents.filter(
|
|
(event) => event.eventType === "turn.cancelled",
|
|
),
|
|
).toMatchObject([{ turnId: cancelledTurn.turnId }]);
|
|
|
|
const secondTurn = await session.startTurn({
|
|
message: { role: "user", text: "finish" },
|
|
});
|
|
const secondTurnEvents = await collectTurnEvents(session.events());
|
|
expect(
|
|
secondTurnEvents.filter((event) => event.eventType === "turn.completed"),
|
|
).toMatchObject([{ turnId: secondTurn.turnId }]);
|
|
expect(
|
|
secondTurnEvents.some((event) => event.turnId === cancelledTurn.turnId),
|
|
).toBe(false);
|
|
|
|
await session.close({ reason: "test" });
|
|
});
|
|
|
|
it("keeps the session usable after a failed turn so the next turn on the same session still completes", async () => {
|
|
await chmod(fixture, 0o755);
|
|
const root = await mkdtemp(
|
|
join(tmpdir(), "paperclip-opencode-fail-then-finish-"),
|
|
);
|
|
const workspace = await mkdtemp(
|
|
join(tmpdir(), "paperclip-opencode-fail-then-finish-workspace-"),
|
|
);
|
|
roots.push(root, workspace);
|
|
const driver = new OpenCodeServerDriver({
|
|
model: "openrouter/deepseek/deepseek-v4-flash-0731",
|
|
runtimeDirectory: root,
|
|
command: fixture,
|
|
environment: {
|
|
PATH: process.env.PATH,
|
|
OPENROUTER_API_KEY: "fixture-key",
|
|
},
|
|
});
|
|
const session = await driver.openSession({
|
|
runId: "run-fail-then-finish",
|
|
normalizedSessionId: "fail-then-finish",
|
|
workingDirectory: workspace,
|
|
});
|
|
|
|
const failedTurn = await session.startTurn({
|
|
message: { role: "user", text: "session-failed" },
|
|
});
|
|
const failedTurnEvents = await collectTurnEvents(session.events());
|
|
expect(
|
|
failedTurnEvents.filter((event) => event.eventType === "turn.failed"),
|
|
).toMatchObject([{ turnId: failedTurn.turnId }]);
|
|
|
|
const secondTurn = await session.startTurn({
|
|
message: { role: "user", text: "finish" },
|
|
});
|
|
const secondTurnEvents = await collectTurnEvents(session.events());
|
|
expect(
|
|
secondTurnEvents.filter((event) => event.eventType === "turn.completed"),
|
|
).toMatchObject([{ turnId: secondTurn.turnId }]);
|
|
expect(
|
|
secondTurnEvents.some((event) => event.turnId === failedTurn.turnId),
|
|
).toBe(false);
|
|
|
|
await session.close({ reason: "test" });
|
|
});
|
|
|
|
it("still ends the event stream once the session closes", async () => {
|
|
await chmod(fixture, 0o755);
|
|
const root = await mkdtemp(
|
|
join(tmpdir(), "paperclip-opencode-close-ends-stream-"),
|
|
);
|
|
const workspace = await mkdtemp(
|
|
join(tmpdir(), "paperclip-opencode-close-ends-stream-workspace-"),
|
|
);
|
|
roots.push(root, workspace);
|
|
const driver = new OpenCodeServerDriver({
|
|
model: "openrouter/deepseek/deepseek-v4-flash-0731",
|
|
runtimeDirectory: root,
|
|
command: fixture,
|
|
environment: {
|
|
PATH: process.env.PATH,
|
|
OPENROUTER_API_KEY: "fixture-key",
|
|
},
|
|
});
|
|
const session = await driver.openSession({
|
|
runId: "run-close-ends-stream",
|
|
normalizedSessionId: "close-ends-stream",
|
|
workingDirectory: workspace,
|
|
});
|
|
|
|
await session.startTurn({ message: { role: "user", text: "finish" } });
|
|
await collectTurnEvents(session.events());
|
|
await session.close({ reason: "test" });
|
|
|
|
const eventsAfterClose: PrpEvent[] = [];
|
|
for await (const event of session.events()) eventsAfterClose.push(event);
|
|
expect(eventsAfterClose).toEqual([]);
|
|
});
|
|
|
|
it("keeps OpenCode in an outer supervisor process group when requested", async () => {
|
|
await chmod(fixture, 0o755);
|
|
const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-driver-"));
|
|
const workspace = await mkdtemp(
|
|
join(tmpdir(), "paperclip-opencode-workspace-"),
|
|
);
|
|
roots.push(root, workspace);
|
|
let processGroupId: number | null | undefined;
|
|
const driver = new OpenCodeServerDriver({
|
|
model: "openrouter/deepseek/deepseek-v4-flash-0731",
|
|
runtimeDirectory: root,
|
|
command: fixture,
|
|
environment: {
|
|
PATH: process.env.PATH,
|
|
OPENROUTER_API_KEY: "fixture-key",
|
|
},
|
|
isolateProcessGroup: false,
|
|
onSpawn: async (meta) => {
|
|
processGroupId = meta.processGroupId;
|
|
},
|
|
});
|
|
const session = await driver.openSession({
|
|
runId: "run-supervised",
|
|
normalizedSessionId: "supervised",
|
|
workingDirectory: workspace,
|
|
});
|
|
expect(processGroupId).toBeNull();
|
|
await session.close({ reason: "test" });
|
|
});
|
|
|
|
it.each([false, true])("uses the native system channel, selected skill tree, instruction root, and assigned MCP gateway after startup retry=%s", async (retryStartup) => {
|
|
await chmod(fixture, 0o755);
|
|
const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-context-"));
|
|
const workspace = await mkdtemp(
|
|
join(tmpdir(), "paperclip-opencode-context-workspace-"),
|
|
);
|
|
const skillRoot = join(root, "skill-source");
|
|
const instructionRoot = join(root, "instruction-source");
|
|
roots.push(root, workspace);
|
|
await Promise.all([
|
|
mkdir(join(skillRoot, "references"), { recursive: true }),
|
|
mkdir(instructionRoot, { recursive: true }),
|
|
]);
|
|
await Promise.all([
|
|
writeFile(
|
|
join(skillRoot, "SKILL.md"),
|
|
"# Assigned\nRead references/support.md\n",
|
|
),
|
|
writeFile(join(skillRoot, "references", "support.md"), "skill support\n"),
|
|
writeFile(join(instructionRoot, "AGENTS.md"), "Read sibling.md\n"),
|
|
writeFile(join(instructionRoot, "sibling.md"), "instruction sibling\n"),
|
|
]);
|
|
const systemInstructions = `${PAPERCLIP_EXECUTION_PROMPT}\n\nRead sibling.md\n\nRead-only instruction sibling root: ${instructionRoot}`;
|
|
let submittedPrompt: Record<string, unknown> | null = null;
|
|
let startupFailurePending = retryStartup;
|
|
const driver = new OpenCodeServerDriver({
|
|
model: "openrouter/deepseek/deepseek-v4-flash-0731",
|
|
runtimeDirectory: root,
|
|
command: fixture,
|
|
systemInstructions,
|
|
runtimeContext: runtimeContext(skillRoot, instructionRoot),
|
|
environment: {
|
|
PATH: process.env.PATH,
|
|
OPENROUTER_API_KEY: "fixture-key",
|
|
PAPERCLIP_NATIVE_MCP_NAME: "paperclip-assigned",
|
|
PAPERCLIP_NATIVE_MCP_URL: "https://paperclip.example/mcp",
|
|
PAPERCLIP_NATIVE_MCP_TOKEN: "x".repeat(40),
|
|
},
|
|
fetch: async (input, init) => {
|
|
if (startupFailurePending && String(input).endsWith("/session") && init?.method === "POST") {
|
|
startupFailurePending = false;
|
|
return new Response("Fixture startup failure", { status: 503 });
|
|
}
|
|
if (String(input).endsWith("/prompt_async"))
|
|
submittedPrompt = JSON.parse(String(init?.body));
|
|
return fetch(input, init);
|
|
},
|
|
});
|
|
const session = await driver.openSession({
|
|
runId: "run-context",
|
|
normalizedSessionId: "context",
|
|
workingDirectory: workspace,
|
|
});
|
|
await session.startTurn({ message: { role: "user", text: "finish" } });
|
|
for await (const event of session.events()) {
|
|
if (event.eventType === "turn.completed") break;
|
|
}
|
|
expect(submittedPrompt).toMatchObject({
|
|
system: systemInstructions,
|
|
});
|
|
// A prompt tools map replaces OpenCode's session permissions. Question is
|
|
// enabled in config without overwriting the allow/ask/deny or path policy.
|
|
expect(submittedPrompt).not.toHaveProperty("tools");
|
|
expect(JSON.stringify(submittedPrompt?.parts ?? null)).not.toContain(
|
|
PAPERCLIP_EXECUTION_PROMPT,
|
|
);
|
|
const sessionRoot = join(root, "context");
|
|
const runtimeEnvironment = JSON.parse(
|
|
await readFile(join(sessionRoot, "data", "fake-environment.json"), "utf8"),
|
|
);
|
|
const isolatedHome = runtimeEnvironment.home;
|
|
expect(startupFailurePending).toBe(false);
|
|
expect(dirname(isolatedHome)).toBe(join(sessionRoot, "homes"));
|
|
expect(basename(isolatedHome)).toMatch(/^home-/);
|
|
await expect(
|
|
readFile(
|
|
join(
|
|
isolatedHome,
|
|
".claude",
|
|
"skills",
|
|
"assigned",
|
|
"references",
|
|
"support.md",
|
|
),
|
|
"utf8",
|
|
),
|
|
).resolves.toBe("skill support\n");
|
|
const config = JSON.parse(
|
|
await readFile(
|
|
join(sessionRoot, "config", "opencode", "opencode.json"),
|
|
"utf8",
|
|
),
|
|
);
|
|
expect(config).toMatchObject({
|
|
instructions: [],
|
|
plugin: [],
|
|
tools: { question: true },
|
|
permission: {
|
|
external_directory: { "*": "deny", [`${instructionRoot}/**`]: "allow" },
|
|
},
|
|
mcp: {
|
|
paperclip: { enabled: true },
|
|
"paperclip-assigned": {
|
|
url: "https://paperclip.example/mcp",
|
|
enabled: true,
|
|
},
|
|
},
|
|
});
|
|
await expect(
|
|
readFile(join(instructionRoot, "sibling.md"), "utf8"),
|
|
).resolves.toBe("instruction sibling\n");
|
|
await session.close({ reason: "test" });
|
|
await expect(readdir(join(sessionRoot, "homes"))).resolves.toEqual([]);
|
|
});
|
|
|
|
it("removes isolated homes left by earlier launches when a session reopens", async () => {
|
|
await chmod(fixture, 0o755);
|
|
const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-legacy-home-"));
|
|
const workspace = await mkdtemp(
|
|
join(tmpdir(), "paperclip-opencode-legacy-home-workspace-"),
|
|
);
|
|
const skillRoot = join(root, "skill-source");
|
|
const instructionRoot = join(root, "instruction-source");
|
|
roots.push(root, workspace);
|
|
await Promise.all([
|
|
mkdir(skillRoot, { recursive: true }),
|
|
mkdir(instructionRoot, { recursive: true }),
|
|
]);
|
|
await writeFile(join(skillRoot, "SKILL.md"), "# Assigned\n");
|
|
await writeFile(join(instructionRoot, "AGENTS.md"), "instructions\n");
|
|
const sessionRoot = join(root, "legacy");
|
|
const legacyHome = join(sessionRoot, "home-AbC123");
|
|
const sealedSkill = join(legacyHome, ".claude", "skills", "assigned");
|
|
await mkdir(sealedSkill, { recursive: true });
|
|
await mkdir(join(legacyHome, ".npm", "_cacache"), { recursive: true });
|
|
await writeFile(join(legacyHome, ".npm", "_cacache", "entry"), "cache");
|
|
await writeFile(join(sealedSkill, "SKILL.md"), "# Assigned\n");
|
|
await chmod(join(sealedSkill, "SKILL.md"), 0o444);
|
|
await chmod(sealedSkill, 0o555);
|
|
await mkdir(join(sessionRoot, "data", "opencode"), { recursive: true });
|
|
await writeFile(join(sessionRoot, "data", "opencode", "opencode.db"), "history");
|
|
const driver = new OpenCodeServerDriver({
|
|
model: "openrouter/deepseek/deepseek-v4-flash-0731",
|
|
runtimeDirectory: root,
|
|
command: fixture,
|
|
runtimeContext: runtimeContext(skillRoot, instructionRoot),
|
|
environment: {
|
|
PATH: process.env.PATH,
|
|
OPENROUTER_API_KEY: "fixture-key",
|
|
},
|
|
});
|
|
const session = await driver.openSession({
|
|
runId: "run-legacy-home",
|
|
normalizedSessionId: "legacy",
|
|
workingDirectory: workspace,
|
|
});
|
|
await expect(lstat(legacyHome)).rejects.toMatchObject({ code: "ENOENT" });
|
|
await session.close({ reason: "test" });
|
|
expect(
|
|
(await readdir(sessionRoot)).filter((entry) => entry.startsWith("home-")),
|
|
).toEqual([]);
|
|
await expect(readdir(join(sessionRoot, "homes"))).resolves.toEqual([]);
|
|
await expect(
|
|
readFile(join(sessionRoot, "data", "opencode", "opencode.db"), "utf8"),
|
|
).resolves.toBe("history");
|
|
});
|
|
|
|
it("resends system instructions without repeating the task envelope on recovery", async () => {
|
|
await chmod(fixture, 0o755);
|
|
const root = await mkdtemp(
|
|
join(tmpdir(), "paperclip-opencode-resume-context-"),
|
|
);
|
|
const workspace = await mkdtemp(
|
|
join(tmpdir(), "paperclip-opencode-resume-workspace-"),
|
|
);
|
|
roots.push(root, workspace);
|
|
let submittedPrompt: Record<string, unknown> | null = null;
|
|
const driver = new OpenCodeServerDriver({
|
|
model: "openrouter/deepseek/deepseek-v4-flash-0731",
|
|
runtimeDirectory: root,
|
|
command: fixture,
|
|
systemInstructions: "large original system context",
|
|
environment: {
|
|
PATH: process.env.PATH,
|
|
OPENROUTER_API_KEY: "fixture-key",
|
|
},
|
|
fetch: async (input, init) => {
|
|
if (String(input).endsWith("/prompt_async"))
|
|
submittedPrompt = JSON.parse(String(init?.body));
|
|
return fetch(input, init);
|
|
},
|
|
});
|
|
const seed = await driver.openSession({
|
|
runId: "run-seed",
|
|
normalizedSessionId: "resume-context",
|
|
workingDirectory: workspace,
|
|
});
|
|
const snapshot = await seed.snapshot();
|
|
await seed.close({ reason: "seed complete" });
|
|
|
|
const recovered = await driver.recoverSession?.(snapshot);
|
|
expect(recovered).toMatchObject({ recovered: true });
|
|
await recovered!.session!.startTurn({
|
|
message: {
|
|
role: "user",
|
|
text: '{"interactionResponses":[{"answer":"friendly"}]}',
|
|
},
|
|
});
|
|
for await (const event of recovered!.session!.events()) {
|
|
if (event.eventType === "turn.completed") break;
|
|
}
|
|
|
|
expect(submittedPrompt).toMatchObject({
|
|
providerID: "openrouter",
|
|
modelID: "deepseek/deepseek-v4-flash-0731",
|
|
parts: [
|
|
{
|
|
type: "text",
|
|
text: '{"interactionResponses":[{"answer":"friendly"}]}',
|
|
},
|
|
],
|
|
});
|
|
expect(submittedPrompt).not.toHaveProperty("tools");
|
|
expect(submittedPrompt).toHaveProperty("system", "large original system context");
|
|
await recovered!.session!.close({ reason: "recovery-test" });
|
|
});
|
|
|
|
it("normalizes native question.asked events and replies with ordered OpenCode answer arrays", async () => {
|
|
await chmod(fixture, 0o755);
|
|
const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-question-"));
|
|
const workspace = await mkdtemp(
|
|
join(tmpdir(), "paperclip-opencode-question-workspace-"),
|
|
);
|
|
roots.push(root, workspace);
|
|
const driver = new OpenCodeServerDriver({
|
|
model: "openrouter/deepseek/deepseek-v4-flash-0731",
|
|
runtimeDirectory: root,
|
|
command: fixture,
|
|
environment: {
|
|
PATH: process.env.PATH,
|
|
OPENROUTER_API_KEY: "fixture-key",
|
|
},
|
|
});
|
|
const session = await driver.openSession({
|
|
runId: "run-native-question",
|
|
normalizedSessionId: "native-question",
|
|
workingDirectory: workspace,
|
|
});
|
|
const { turnId } = await session.startTurn({
|
|
message: { role: "user", text: "native-question" },
|
|
});
|
|
const iterator = session.events()[Symbol.asyncIterator]();
|
|
let requestEvent:
|
|
Awaited<ReturnType<typeof iterator.next>>["value"] | null = null;
|
|
for (let count = 0; count < 30; count += 1) {
|
|
const event = await iterator.next();
|
|
if (event.done) break;
|
|
if (event.value.eventType === "runtime_request.created") {
|
|
requestEvent = event.value;
|
|
break;
|
|
}
|
|
}
|
|
expect(requestEvent?.payload).toMatchObject({
|
|
request: {
|
|
schema: "paperclip.runtime_request.v2",
|
|
requestKind: "runtime",
|
|
type: "input",
|
|
input: {
|
|
schema: "paperclip.question_set.v1",
|
|
questions: [
|
|
{
|
|
id: "environment",
|
|
answerMode: "single_select",
|
|
required: false,
|
|
customAnswer: { enabled: true },
|
|
},
|
|
{ id: "regions", answerMode: "multi_select", required: false },
|
|
],
|
|
},
|
|
},
|
|
});
|
|
await new Promise((resolveDelay) => setTimeout(resolveDelay, 20));
|
|
expect(session.pendingRuntimeRequests?.()).toHaveLength(1);
|
|
await session.resolveRuntimeRequest?.({
|
|
requestId: "question-native-1",
|
|
turnId,
|
|
resolution: {
|
|
action: "submit",
|
|
response: {
|
|
schema: "paperclip.question_response.v1",
|
|
answers: {
|
|
environment: { customText: "Canary" },
|
|
regions: { selectedOptionIds: ["option-1", "option-2"] },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
const reply = JSON.parse(
|
|
await readFile(
|
|
join(root, "native-question", "data", "fake-question-reply.json"),
|
|
"utf8",
|
|
),
|
|
);
|
|
expect(reply).toEqual({
|
|
url: expect.stringContaining(
|
|
`directory=${encodeURIComponent(workspace)}`,
|
|
),
|
|
body: { answers: [["Canary"], ["US", "EU"]] },
|
|
});
|
|
const terminalQuestionEvents =
|
|
(await session.transcript?.())?.events.filter(
|
|
(event) =>
|
|
event.payload.requestId === "question-native-1" &&
|
|
["runtime_request.resolved", "runtime_request.cancelled"].includes(
|
|
event.eventType,
|
|
),
|
|
) ?? [];
|
|
expect(terminalQuestionEvents).toHaveLength(1);
|
|
expect(terminalQuestionEvents[0]?.payload).toMatchObject({
|
|
action: "submit",
|
|
response: {
|
|
schema: "paperclip.question_response.v1",
|
|
answers: {
|
|
environment: { customText: "Canary" },
|
|
regions: { selectedOptionIds: ["option-1", "option-2"] },
|
|
},
|
|
},
|
|
});
|
|
await session.close({ reason: "test" });
|
|
});
|
|
|
|
it("hands a native question to the durable interaction path without touching permissions", async () => {
|
|
await chmod(fixture, 0o755);
|
|
const root = await mkdtemp(
|
|
join(tmpdir(), "paperclip-opencode-question-handoff-"),
|
|
);
|
|
const workspace = await mkdtemp(
|
|
join(tmpdir(), "paperclip-opencode-question-handoff-workspace-"),
|
|
);
|
|
roots.push(root, workspace);
|
|
const driver = new OpenCodeServerDriver({
|
|
model: "openrouter/deepseek/deepseek-v4-flash-0731",
|
|
runtimeDirectory: root,
|
|
command: fixture,
|
|
environment: {
|
|
PATH: process.env.PATH,
|
|
OPENROUTER_API_KEY: "fixture-key",
|
|
},
|
|
});
|
|
const session = await driver.openSession({
|
|
runId: "run-native-question-handoff",
|
|
normalizedSessionId: "native-question-handoff",
|
|
workingDirectory: workspace,
|
|
});
|
|
const { turnId } = await session.startTurn({
|
|
message: { role: "user", text: "native-question" },
|
|
});
|
|
const iterator = session.events()[Symbol.asyncIterator]();
|
|
let created: Awaited<ReturnType<typeof iterator.next>>["value"] | null =
|
|
null;
|
|
for (let count = 0; count < 30; count += 1) {
|
|
const next = await iterator.next();
|
|
if (next.done) break;
|
|
if (next.value.eventType === "runtime_request.created") {
|
|
created = next.value;
|
|
break;
|
|
}
|
|
}
|
|
expect(created).toMatchObject({
|
|
payload: { request: { requestId: "question-native-1", type: "input" } },
|
|
});
|
|
|
|
const firstHandoff = session.handoffRuntimeRequest?.({
|
|
requestId: "question-native-1",
|
|
turnId,
|
|
reason: "durable_handoff",
|
|
signal: new AbortController().signal,
|
|
});
|
|
expect(firstHandoff?.result).toBe("handed_off");
|
|
await firstHandoff?.cleanup;
|
|
const repeatedHandoff = session.handoffRuntimeRequest?.({
|
|
requestId: "question-native-1",
|
|
turnId,
|
|
reason: "durable_handoff",
|
|
signal: new AbortController().signal,
|
|
});
|
|
expect(repeatedHandoff?.result).toBe("already_settled");
|
|
await repeatedHandoff?.cleanup;
|
|
|
|
let expired: Awaited<ReturnType<typeof iterator.next>>["value"] | null =
|
|
null;
|
|
for (let count = 0; count < 20; count += 1) {
|
|
const next = await iterator.next();
|
|
if (next.done) break;
|
|
if (next.value.eventType === "runtime_request.expired") {
|
|
expired = next.value;
|
|
break;
|
|
}
|
|
}
|
|
expect(expired).toMatchObject({
|
|
payload: {
|
|
requestId: "question-native-1",
|
|
reason: "durable_handoff",
|
|
replayAllowed: false,
|
|
requestType: "input",
|
|
request: {
|
|
schema: "paperclip.runtime_request.v2",
|
|
requestId: "question-native-1",
|
|
type: "input",
|
|
},
|
|
},
|
|
});
|
|
expect(session.pendingRuntimeRequests?.()).toEqual([]);
|
|
const terminal = (await session.transcript?.())?.events.filter(
|
|
(event) =>
|
|
event.payload.requestId === "question-native-1" &&
|
|
[
|
|
"runtime_request.resolved",
|
|
"runtime_request.cancelled",
|
|
"runtime_request.expired",
|
|
].includes(event.eventType),
|
|
);
|
|
expect(terminal).toHaveLength(1);
|
|
await session.close({ reason: "handoff test complete" });
|
|
});
|
|
|
|
it("recovers a missed pending question from the list endpoint and rejects it", async () => {
|
|
await chmod(fixture, 0o755);
|
|
const root = await mkdtemp(
|
|
join(tmpdir(), "paperclip-opencode-question-recovery-"),
|
|
);
|
|
const workspace = await mkdtemp(
|
|
join(tmpdir(), "paperclip-opencode-question-recovery-workspace-"),
|
|
);
|
|
roots.push(root, workspace);
|
|
const normalizedSessionId = "question-recovery";
|
|
const driver = new OpenCodeServerDriver({
|
|
model: "openrouter/deepseek/deepseek-v4-flash-0731",
|
|
runtimeDirectory: root,
|
|
command: fixture,
|
|
environment: {
|
|
PATH: process.env.PATH,
|
|
OPENROUTER_API_KEY: "fixture-key",
|
|
},
|
|
});
|
|
const seed = await driver.openSession({
|
|
runId: "run-question-recovery",
|
|
normalizedSessionId,
|
|
workingDirectory: workspace,
|
|
});
|
|
const snapshot = await seed.snapshot();
|
|
await seed.close({ reason: "simulate reconnect" });
|
|
const dataRoot = join(root, normalizedSessionId, "data");
|
|
await mkdir(dataRoot, { recursive: true });
|
|
await writeFile(
|
|
join(dataRoot, "fake-pending-question.json"),
|
|
JSON.stringify({
|
|
id: "question-native-1",
|
|
sessionID: "ses_fake_1",
|
|
questions: [
|
|
{
|
|
id: "environment",
|
|
header: "Environment",
|
|
question: "Where should we deploy?",
|
|
options: [{ label: "Staging" }, { label: "Production" }],
|
|
custom: true,
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
|
|
const recovered = await driver.recoverSession?.({
|
|
...snapshot,
|
|
activeTurnId: "turn-recovered-question",
|
|
});
|
|
expect(recovered).toMatchObject({ recovered: true });
|
|
const session = recovered!.session!;
|
|
const iterator = session.events()[Symbol.asyncIterator]();
|
|
let created: Awaited<ReturnType<typeof iterator.next>>["value"] | null =
|
|
null;
|
|
for (let count = 0; count < 20; count += 1) {
|
|
const next = await iterator.next();
|
|
if (next.done) break;
|
|
if (next.value.eventType === "runtime_request.created") {
|
|
created = next.value;
|
|
break;
|
|
}
|
|
}
|
|
expect(created).toMatchObject({
|
|
payload: { request: { requestId: "question-native-1", type: "input" } },
|
|
});
|
|
await session.resolveRuntimeRequest?.({
|
|
requestId: "question-native-1",
|
|
turnId: "turn-recovered-question",
|
|
resolution: { action: "decline" },
|
|
});
|
|
expect(session.pendingRuntimeRequests?.()).toEqual([]);
|
|
await session.close({ reason: "recovery test complete" });
|
|
});
|
|
|
|
it.each([
|
|
{ style: "legacy", action: "accept" as const, reply: "once" },
|
|
{ style: "v2", action: "accept_for_session" as const, reply: "always" },
|
|
{ style: "v2", action: "decline" as const, reply: "reject" },
|
|
])(
|
|
"normalizes $style permission events and maps $action to $reply",
|
|
async ({ style, action, reply }) => {
|
|
await chmod(fixture, 0o755);
|
|
const root = await mkdtemp(
|
|
join(tmpdir(), `paperclip-opencode-permission-${style}-`),
|
|
);
|
|
const workspace = await mkdtemp(
|
|
join(tmpdir(), `paperclip-opencode-permission-${style}-workspace-`),
|
|
);
|
|
roots.push(root, workspace);
|
|
const driver = new OpenCodeServerDriver({
|
|
model: "openrouter/deepseek/deepseek-v4-flash-0731",
|
|
permissionMode: "ask",
|
|
runtimeDirectory: root,
|
|
command: fixture,
|
|
environment: {
|
|
PATH: process.env.PATH,
|
|
OPENROUTER_API_KEY: "fixture-key",
|
|
},
|
|
});
|
|
const normalizedSessionId = `native-permission-${style}-${action}`;
|
|
const session = await driver.openSession({
|
|
runId: `run-${normalizedSessionId}`,
|
|
normalizedSessionId,
|
|
workingDirectory: workspace,
|
|
});
|
|
const { turnId } = await session.startTurn({
|
|
message: { role: "user", text: `native-permission-${style}` },
|
|
});
|
|
const iterator = session.events()[Symbol.asyncIterator]();
|
|
let created: Awaited<ReturnType<typeof iterator.next>>["value"] | null =
|
|
null;
|
|
for (let count = 0; count < 30; count += 1) {
|
|
const next = await iterator.next();
|
|
if (next.done) break;
|
|
if (next.value.eventType === "runtime_request.created") {
|
|
created = next.value;
|
|
break;
|
|
}
|
|
}
|
|
expect(created).toMatchObject({
|
|
payload: {
|
|
request: {
|
|
requestId: "permission-native-1",
|
|
type: "permission",
|
|
actions: ["accept", "accept_for_session", "decline", "cancel"],
|
|
},
|
|
},
|
|
});
|
|
await session.resolveRuntimeRequest?.({
|
|
requestId: "permission-native-1",
|
|
turnId,
|
|
resolution: { action },
|
|
});
|
|
const persisted = JSON.parse(
|
|
await readFile(
|
|
join(root, normalizedSessionId, "data", "fake-permission-reply.json"),
|
|
"utf8",
|
|
),
|
|
);
|
|
expect(persisted).toEqual({
|
|
url: expect.stringContaining(
|
|
`directory=${encodeURIComponent(workspace)}`,
|
|
),
|
|
body: { reply },
|
|
});
|
|
expect(session.pendingRuntimeRequests?.()).toEqual([]);
|
|
await session.close({ reason: "permission test complete" });
|
|
},
|
|
);
|
|
|
|
it.each(["allow", "ask", "deny"] as const)(
|
|
"pins the %s permission mode while retaining external-directory denial",
|
|
async (permissionMode) => {
|
|
await chmod(fixture, 0o755);
|
|
const root = await mkdtemp(
|
|
join(tmpdir(), `paperclip-opencode-mode-${permissionMode}-`),
|
|
);
|
|
const workspace = await mkdtemp(
|
|
join(tmpdir(), `paperclip-opencode-mode-${permissionMode}-workspace-`),
|
|
);
|
|
roots.push(root, workspace);
|
|
const driver = new OpenCodeServerDriver({
|
|
model: "openrouter/deepseek/deepseek-v4-flash-0731",
|
|
permissionMode,
|
|
runtimeDirectory: root,
|
|
command: fixture,
|
|
environment: {
|
|
PATH: process.env.PATH,
|
|
OPENROUTER_API_KEY: "fixture-key",
|
|
},
|
|
});
|
|
const session = await driver.openSession({
|
|
runId: `run-${permissionMode}`,
|
|
normalizedSessionId: permissionMode,
|
|
workingDirectory: workspace,
|
|
});
|
|
const config = JSON.parse(
|
|
await readFile(
|
|
join(root, permissionMode, "config", "opencode", "opencode.json"),
|
|
"utf8",
|
|
),
|
|
);
|
|
expect(config.permission).toMatchObject({
|
|
"*": permissionMode,
|
|
external_directory: { "*": "deny", [`${workspace}/**`]: "allow" },
|
|
});
|
|
expect(config.autoupdate).toBe(false);
|
|
expect(config.provider.openrouter.options.headers).toMatchObject({
|
|
"HTTP-Referer": "https://paperclip.ing", "X-Title": "Paperclip", "X-OpenRouter-Title": "Paperclip",
|
|
});
|
|
expect(config.provider.openrouter.models).toHaveProperty(
|
|
"deepseek/deepseek-v4-flash-0731",
|
|
);
|
|
expect(config.provider.openrouter.models["deepseek/deepseek-v4-flash-0731"].options).toBeUndefined();
|
|
expect(config.permission.paperclip_finish).toBeUndefined();
|
|
expect(config.permission["paperclip_*"]).toBe("allow");
|
|
await session.close({ reason: "permission mode test complete" });
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32")("allows the selected workspace alias and its canonical path while denying other directories", async () => {
|
|
await chmod(fixture, 0o755);
|
|
const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-workspace-alias-"));
|
|
roots.push(root);
|
|
const workspace = join(root, "actual-workspace");
|
|
const alias = join(root, "workspace-alias");
|
|
await mkdir(workspace);
|
|
await symlink(workspace, alias, "dir");
|
|
const canonical = await realpath(workspace);
|
|
const driver = new OpenCodeServerDriver({
|
|
model: "openrouter/deepseek/deepseek-v4-flash-0731",
|
|
permissionMode: "allow",
|
|
runtimeDirectory: root,
|
|
command: fixture,
|
|
environment: { PATH: process.env.PATH, OPENROUTER_API_KEY: "fixture-key" },
|
|
});
|
|
const session = await driver.openSession({
|
|
runId: "run-workspace-alias", normalizedSessionId: "alias-session",
|
|
workingDirectory: alias,
|
|
});
|
|
try {
|
|
const config = JSON.parse(await readFile(join(root, "alias-session", "config", "opencode", "opencode.json"), "utf8"));
|
|
expect(config.permission.external_directory).toEqual({
|
|
"*": "deny", [alias]: "allow", [`${alias}/**`]: "allow",
|
|
[canonical]: "allow", [`${canonical}/**`]: "allow",
|
|
});
|
|
} finally { await session.close({ reason: "workspace alias test complete" }); }
|
|
});
|
|
|
|
it("clears a stale active turn that already has a persisted terminal fingerprint", async () => {
|
|
await chmod(fixture, 0o755);
|
|
const root = await mkdtemp(
|
|
join(tmpdir(), "paperclip-opencode-stale-turn-"),
|
|
);
|
|
const workspace = await mkdtemp(
|
|
join(tmpdir(), "paperclip-opencode-stale-workspace-"),
|
|
);
|
|
roots.push(root, workspace);
|
|
const driver = new OpenCodeServerDriver({
|
|
model: "openrouter/deepseek/deepseek-v4-flash-0731",
|
|
runtimeDirectory: root,
|
|
command: fixture,
|
|
environment: {
|
|
PATH: process.env.PATH,
|
|
OPENROUTER_API_KEY: "fixture-key",
|
|
},
|
|
});
|
|
const seed = await driver.openSession({
|
|
runId: "run-stale-turn",
|
|
normalizedSessionId: "stale-turn",
|
|
workingDirectory: workspace,
|
|
});
|
|
const snapshot = await seed.snapshot();
|
|
await seed.close({ reason: "seed complete" });
|
|
|
|
const recoveryDriver = new OpenCodeServerDriver({
|
|
model: "openrouter/deepseek/deepseek-v4-flash-0731",
|
|
runtimeDirectory: root,
|
|
command: fixture,
|
|
environment: {
|
|
PATH: process.env.PATH,
|
|
OPENROUTER_API_KEY: "fixture-key",
|
|
},
|
|
});
|
|
const recovery = await recoveryDriver.recoverSession?.({
|
|
...snapshot,
|
|
activeTurnId: "turn-already-terminal",
|
|
terminalTurns: [
|
|
{
|
|
turnId: "turn-already-terminal",
|
|
fingerprint: "terminal-fingerprint",
|
|
},
|
|
],
|
|
});
|
|
expect(recovery).toMatchObject({ recovered: true });
|
|
await expect(recovery!.session!.snapshot()).resolves.toMatchObject({
|
|
activeTurnId: null,
|
|
});
|
|
await recovery!.session!.close({ reason: "recovery-test" });
|
|
});
|
|
|
|
it("validates provider/model form", async () => {
|
|
const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-driver-"));
|
|
roots.push(root);
|
|
const driver = new OpenCodeServerDriver({
|
|
model: "bad",
|
|
runtimeDirectory: root,
|
|
});
|
|
await expect(
|
|
driver.validateConfig?.({ model: "bad" }),
|
|
).resolves.toMatchObject({ ok: false });
|
|
await expect(
|
|
driver.validateConfig?.({ model: "openrouter/model" }),
|
|
).resolves.toMatchObject({ ok: true });
|
|
});
|
|
|
|
it.each(["wrong", "missing", "duplicate"])("returns a repairable tool error for %s criteria without committing a bad semantic result", async (mode) => {
|
|
await chmod(fixture, 0o755);
|
|
const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-criteria-"));
|
|
const workspace = await mkdtemp(join(tmpdir(), "paperclip-opencode-workspace-"));
|
|
roots.push(root, workspace);
|
|
const driver = new OpenCodeServerDriver({ model: "openrouter/deepseek/deepseek-v4-flash-0731", runtimeDirectory: root, command: fixture, environment: { PATH: process.env.PATH, OPENROUTER_API_KEY: "fixture-key" }, taskEnvelope: createCodexTaskEnvelope({ objective: "Apply the accepted decision", contractRevision: "approval-v2", criteria: [{ id: "human_response", requirement: "Apply the approved response" }] }) });
|
|
const session = await driver.openSession({ runId: "criteria-repair", normalizedSessionId: "criteria-repair", workingDirectory: workspace });
|
|
await session.startTurn({ message: { role: "user", text: `repair-criteria-${mode}` } });
|
|
const events = await collectTurnEvents(session.events());
|
|
const results = events.filter((event) => event.eventType === "run.result.proposed");
|
|
expect(results).toHaveLength(1);
|
|
expect(events.some((event) => event.eventType === "item.completed" &&
|
|
(event.payload as { item?: { is_error?: boolean } }).item?.is_error === true)).toBe(true);
|
|
expect(results[0].payload).toMatchObject({ completionClaim: { criteria: [{ criterionId: "human_response" }] } });
|
|
const files = await readdir(root, { recursive: true });
|
|
const evidence = files.find((name) => name.endsWith("fake-criteria-repair.json"));
|
|
expect(evidence).toBeDefined();
|
|
expect(JSON.parse(await readFile(join(root, evidence!), "utf8"))).toMatchObject({ result: { isError: true, content: [{ text: expect.stringContaining('"human_response"') }] } });
|
|
await session.close({ reason: "test" });
|
|
});
|
|
|
|
it.each([true, false])("resolves an omitted continuation through the real OpenCode MCP boundary only with a saved monitor: %s", async eligible => {
|
|
await chmod(fixture, 0o755);
|
|
const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-monitor-"));
|
|
const workspace = await mkdtemp(join(tmpdir(), "paperclip-opencode-monitor-workspace-"));
|
|
roots.push(root, workspace);
|
|
const continuation = { kind: "monitor", summary: "Wait for the saved task monitor.", idempotencyKey: "task-monitor:saved" };
|
|
const reads: string[] = [];
|
|
const accepted: unknown[] = [];
|
|
const driver = new OpenCodeServerDriver({
|
|
model: "openrouter/deepseek/deepseek-v4-flash-0731", runtimeDirectory: root, command: fixture,
|
|
environment: { PATH: process.env.PATH, OPENROUTER_API_KEY: "fixture-key" },
|
|
dynamicToolHandler: async call => {
|
|
reads.push(call.tool);
|
|
return { run: { id: "monitor-default" }, activeTask: { executionRunId: "monitor-default" },
|
|
completionContinuation: eligible ? continuation : null };
|
|
},
|
|
completionFeedback: async result => { accepted.push(result); return "Accepted monitor wait."; },
|
|
});
|
|
const session = await driver.openSession({ runId: "monitor-default", normalizedSessionId: "monitor-default", workingDirectory: workspace });
|
|
try {
|
|
await session.startTurn({ message: { role: "user", text: "monitor-default" } });
|
|
const events = await collectTurnEvents(session.events());
|
|
expect(reads).toEqual(["get_task_context"]);
|
|
if (eligible) {
|
|
expect(accepted).toMatchObject([{ reportedWorkDisposition: "yielded", continuation }]);
|
|
expect(events.filter(event => event.eventType === "run.result.proposed")).toHaveLength(1);
|
|
expect((await session.snapshot()).semanticResult?.result).toMatchObject({ continuation });
|
|
} else {
|
|
expect(accepted).toEqual([]);
|
|
expect(events.filter(event => event.eventType === "run.result.proposed")).toEqual([]);
|
|
expect((await session.snapshot()).semanticResult).toBeNull();
|
|
}
|
|
} finally { await session.close({ reason: "monitor default test complete" }); }
|
|
});
|
|
|
|
it("keeps assigned discovery operation IDs at the authenticated MCP and controller boundary", async () => {
|
|
await chmod(fixture, 0o755);
|
|
const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-assigned-"));
|
|
const workspace = await mkdtemp(join(tmpdir(), "paperclip-opencode-workspace-"));
|
|
roots.push(root, workspace);
|
|
const calls: string[] = [];
|
|
const operation = "paperclip_search_assigned_tools";
|
|
const driver = new OpenCodeServerDriver({ model: "openrouter/deepseek/deepseek-v4.1-flash-1017", runtimeDirectory: root, command: fixture,
|
|
environment: { PATH: process.env.PATH, OPENROUTER_API_KEY: "fixture-key" },
|
|
dynamicTools: [{ name: operation, inputSchema: { type: "object" } }],
|
|
dynamicToolHandler: async call => { calls.push(call.tool); if (call.tool !== operation) throw new Error("unauthorized operation"); return { tools: [] }; },
|
|
});
|
|
const session = await driver.openSession({ runId: "assigned-name", normalizedSessionId: "assigned-name", workingDirectory: workspace });
|
|
try {
|
|
await session.startTurn({ message: { role: "user", text: "Discover assigned tools." } });
|
|
const events = await collectTurnEvents(session.events());
|
|
expect(calls).toEqual([operation]);
|
|
expect(events.some(event => event.eventType === "item.completed" && (event.payload as { item?: { is_error?: boolean } }).item?.is_error)).toBe(false);
|
|
} finally { await session.close({ reason: "assigned operation identity test" }); }
|
|
});
|
|
|
|
it("normalizes a structured block result", async () => {
|
|
await chmod(fixture, 0o755);
|
|
const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-driver-"));
|
|
const workspace = await mkdtemp(
|
|
join(tmpdir(), "paperclip-opencode-workspace-"),
|
|
);
|
|
roots.push(root, workspace);
|
|
const driver = new OpenCodeServerDriver({
|
|
model: "openrouter/deepseek/deepseek-v4-flash-0731",
|
|
runtimeDirectory: root,
|
|
command: fixture,
|
|
environment: {
|
|
PATH: process.env.PATH,
|
|
OPENROUTER_API_KEY: "fixture-key",
|
|
},
|
|
});
|
|
const session = await driver.openSession({
|
|
runId: "run-block",
|
|
normalizedSessionId: "block",
|
|
workingDirectory: workspace,
|
|
});
|
|
await session.startTurn({
|
|
message: { role: "user", text: "block-result" },
|
|
});
|
|
const events = await collectTurnEvents(session.events());
|
|
expect(
|
|
events.find((event) => event.eventType === "run.result.proposed")
|
|
?.payload,
|
|
).toMatchObject({
|
|
reportedWorkDisposition: "blocked",
|
|
blocker: { reasonCode: "test_dependency" },
|
|
});
|
|
await session.close({ reason: "test" });
|
|
});
|
|
|
|
it("designates the substantive pre-finish response instead of a trailing acknowledgement", async () => {
|
|
await chmod(fixture, 0o755);
|
|
const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-driver-"));
|
|
const workspace = await mkdtemp(
|
|
join(tmpdir(), "paperclip-opencode-workspace-"),
|
|
);
|
|
roots.push(root, workspace);
|
|
const driver = new OpenCodeServerDriver({
|
|
model: "openrouter/deepseek/deepseek-v4-flash-0731",
|
|
runtimeDirectory: root,
|
|
command: fixture,
|
|
environment: {
|
|
PATH: process.env.PATH,
|
|
OPENROUTER_API_KEY: "fixture-key",
|
|
},
|
|
});
|
|
const session = await driver.openSession({
|
|
runId: "run-final-selection",
|
|
normalizedSessionId: "final-selection",
|
|
workingDirectory: workspace,
|
|
});
|
|
await session.startTurn({
|
|
message: { role: "user", text: "text-before-finish" },
|
|
});
|
|
const events = await collectTurnEvents(session.events());
|
|
const finalMessages = events.filter(
|
|
(event) =>
|
|
event.eventType === "item.completed" &&
|
|
event.payload.kind === "agentMessage" &&
|
|
event.payload.channel === "final",
|
|
);
|
|
expect(finalMessages).toHaveLength(1);
|
|
expect(finalMessages[0]?.payload.text).toBe(
|
|
"Substantive answer before finish.",
|
|
);
|
|
expect(
|
|
events
|
|
.filter((event) => event.eventType === "item.delta")
|
|
.map((event) => event.payload.text),
|
|
).toEqual(
|
|
expect.arrayContaining(["Substantive answer before finish.", "Done."]),
|
|
);
|
|
await session.close({ reason: "test" });
|
|
});
|
|
|
|
it("does not promote opening commentary to the final response when work follows it", async () => {
|
|
await chmod(fixture, 0o755);
|
|
const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-driver-"));
|
|
const workspace = await mkdtemp(
|
|
join(tmpdir(), "paperclip-opencode-workspace-"),
|
|
);
|
|
roots.push(root, workspace);
|
|
const driver = new OpenCodeServerDriver({
|
|
model: "openrouter/deepseek/deepseek-v4-flash-0731",
|
|
runtimeDirectory: root,
|
|
command: fixture,
|
|
environment: {
|
|
PATH: process.env.PATH,
|
|
OPENROUTER_API_KEY: "fixture-key",
|
|
},
|
|
});
|
|
const session = await driver.openSession({
|
|
runId: "run-commentary-only",
|
|
normalizedSessionId: "commentary-only",
|
|
workingDirectory: workspace,
|
|
});
|
|
await session.startTurn({
|
|
message: { role: "user", text: "commentary-only-before-work" },
|
|
});
|
|
const events = await collectTurnEvents(session.events());
|
|
expect(
|
|
events.filter(
|
|
(event) =>
|
|
event.eventType === "item.completed" &&
|
|
event.payload.kind === "agentMessage" &&
|
|
event.payload.channel === "final",
|
|
),
|
|
).toHaveLength(0);
|
|
expect(
|
|
events
|
|
.filter((event) => event.eventType === "item.delta")
|
|
.map((event) => event.payload.text),
|
|
).toContain("I will inspect the workspace first.");
|
|
expect(events).toContainEqual(
|
|
expect.objectContaining({ eventType: "run.result.proposed" }),
|
|
);
|
|
await session.close({ reason: "test" });
|
|
});
|
|
|
|
it("correlates the final response to the native message containing the semantic tool", async () => {
|
|
await chmod(fixture, 0o755);
|
|
const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-driver-"));
|
|
const workspace = await mkdtemp(
|
|
join(tmpdir(), "paperclip-opencode-workspace-"),
|
|
);
|
|
roots.push(root, workspace);
|
|
const driver = new OpenCodeServerDriver({
|
|
model: "openrouter/deepseek/deepseek-v4-flash-0731",
|
|
runtimeDirectory: root,
|
|
command: fixture,
|
|
environment: {
|
|
PATH: process.env.PATH,
|
|
OPENROUTER_API_KEY: "fixture-key",
|
|
},
|
|
});
|
|
const session = await driver.openSession({
|
|
runId: "run-correlated-final-selection",
|
|
normalizedSessionId: "correlated-final-selection",
|
|
workingDirectory: workspace,
|
|
});
|
|
await session.startTurn({
|
|
message: { role: "user", text: "correlated-final-message" },
|
|
});
|
|
const events = await collectTurnEvents(session.events());
|
|
const finalMessages = events.filter(
|
|
(event) =>
|
|
event.eventType === "item.completed" &&
|
|
event.payload.kind === "agentMessage" &&
|
|
event.payload.channel === "final",
|
|
);
|
|
expect(finalMessages).toHaveLength(1);
|
|
expect(finalMessages[0]?.payload.text).toBe(
|
|
"Correlated substantive final answer.",
|
|
);
|
|
const assistantDeltas = events.filter(
|
|
(event) =>
|
|
event.eventType === "item.delta" &&
|
|
event.payload.kind === "agentMessage",
|
|
);
|
|
expect(assistantDeltas.map((event) => event.payload.text)).toEqual([
|
|
"I will write the result now.",
|
|
"Correlated substantive final answer.",
|
|
"Done.",
|
|
]);
|
|
expect(assistantDeltas).toHaveLength(3);
|
|
expect(
|
|
assistantDeltas.every(
|
|
(event) =>
|
|
event.payload.channel === "progress" &&
|
|
event.payload.providerPhase === "commentary",
|
|
),
|
|
).toBe(true);
|
|
expect(assistantDeltas.map((event) => event.payload.item)).toEqual([
|
|
expect.objectContaining({
|
|
type: "agentMessage",
|
|
channel: "progress",
|
|
phase: "commentary",
|
|
}),
|
|
expect.objectContaining({
|
|
type: "agentMessage",
|
|
channel: "progress",
|
|
phase: "commentary",
|
|
}),
|
|
expect.objectContaining({
|
|
type: "agentMessage",
|
|
channel: "progress",
|
|
phase: "commentary",
|
|
}),
|
|
]);
|
|
expect(finalMessages[0]?.itemId).toBe(
|
|
assistantDeltas.find(
|
|
(event) =>
|
|
event.payload.text === "Correlated substantive final answer.",
|
|
)?.itemId,
|
|
);
|
|
expect(finalMessages[0]?.payload).toMatchObject({
|
|
kind: "agentMessage",
|
|
channel: "final",
|
|
providerPhase: "final_answer",
|
|
});
|
|
await session.close({ reason: "test" });
|
|
});
|
|
|
|
it("uses provider structure rather than prose length to select a post-tool answer", async () => {
|
|
await chmod(fixture, 0o755);
|
|
const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-driver-"));
|
|
const workspace = await mkdtemp(
|
|
join(tmpdir(), "paperclip-opencode-workspace-"),
|
|
);
|
|
roots.push(root, workspace);
|
|
const driver = new OpenCodeServerDriver({
|
|
model: "openrouter/deepseek/deepseek-v4-flash-0731",
|
|
runtimeDirectory: root,
|
|
command: fixture,
|
|
environment: {
|
|
PATH: process.env.PATH,
|
|
OPENROUTER_API_KEY: "fixture-key",
|
|
},
|
|
});
|
|
const session = await driver.openSession({
|
|
runId: "run-post-tool-final-selection",
|
|
normalizedSessionId: "post-tool-final-selection",
|
|
workingDirectory: workspace,
|
|
});
|
|
await session.startTurn({
|
|
message: { role: "user", text: "final-after-tool-commentary" },
|
|
});
|
|
const events = await collectTurnEvents(session.events());
|
|
const finalMessages = events.filter(
|
|
(event) =>
|
|
event.eventType === "item.completed" &&
|
|
event.payload.kind === "agentMessage" &&
|
|
event.payload.channel === "final",
|
|
);
|
|
expect(finalMessages).toHaveLength(1);
|
|
expect(finalMessages[0]?.payload.text).toBe(
|
|
"This is the complete substantive answer emitted after the accepted completion tool call.",
|
|
);
|
|
await session.close({ reason: "test" });
|
|
});
|
|
|
|
it.each(["session.idle", "session.status", "session.error", "aborted"])("settles accepted completion before a racing %s event", async terminalType => {
|
|
await chmod(fixture, 0o755);
|
|
const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-completion-race-"));
|
|
const workspace = await mkdtemp(join(tmpdir(), "paperclip-opencode-completion-race-workspace-"));
|
|
roots.push(root, workspace);
|
|
let stream: ReadableStreamDefaultController<Uint8Array>;
|
|
let releaseFeedback!: () => void;
|
|
let feedbackStarted!: () => void;
|
|
const started = new Promise<void>(resolve => { feedbackStarted = resolve; });
|
|
const release = new Promise<void>(resolve => { releaseFeedback = resolve; });
|
|
const feedback = "Accepted. Keep the exact saved document link in the final response.";
|
|
const driver = new OpenCodeServerDriver({
|
|
model: "openrouter/deepseek/deepseek-v4-flash-0731", runtimeDirectory: root, command: fixture,
|
|
environment: { PATH: process.env.PATH, OPENROUTER_API_KEY: "fixture-key" },
|
|
fetch: async (input, init) => String(input).endsWith("/event")
|
|
? new Response(new ReadableStream<Uint8Array>({ start(controller) { stream = controller; } }), { headers: { "Content-Type": "text/event-stream" } })
|
|
: fetch(input, init),
|
|
completionFeedback: async () => { feedbackStarted(); await release; return feedback; },
|
|
});
|
|
const session = await driver.openSession({ runId: "completion-race", normalizedSessionId: "completion-race", workingDirectory: workspace });
|
|
try {
|
|
const events = collectTurnEvents(session.events());
|
|
await session.startTurn({ message: { role: "user", text: "completion-feedback" } });
|
|
await started;
|
|
stream!.enqueue(new TextEncoder().encode(`data: ${JSON.stringify({ type: terminalType === "aborted" ? "session.error" : terminalType, id: "terminal-during-feedback", properties: { sessionID: session.ids().providerSessionId, status: { type: "idle" }, error: terminalType === "aborted" ? { name: "MessageAbortedError", data: { message: "Aborted" } } : { name: "FixtureError", message: "Provider ended during feedback." } } })}\n\n`));
|
|
// Drain the queued SSE frame before releasing the controller response.
|
|
await new Promise<void>(resolve => setImmediate(resolve));
|
|
releaseFeedback();
|
|
const observed = await events;
|
|
const proposed = observed.findIndex(event => event.eventType === "run.result.proposed");
|
|
const completed = observed.findIndex(event => event.eventType === "item.completed" && event.payload.kind === "dynamicToolCall" && (event.payload.item as { result?: unknown })?.result === feedback);
|
|
const terminal = observed.findIndex(event => TURN_TERMINAL_EVENT_TYPES.has(event.eventType));
|
|
expect(proposed).toBeGreaterThanOrEqual(0);
|
|
expect(completed).toBeGreaterThan(proposed);
|
|
expect(terminal).toBeGreaterThan(completed);
|
|
expect(observed.filter(event => event.eventType === "run.result.proposed")).toHaveLength(1);
|
|
expect((await session.snapshot()).semanticResult).not.toBeNull();
|
|
const sessionRoots = (await readdir(root, { withFileTypes: true })).filter(entry => entry.isDirectory());
|
|
await expect.poll(async () => JSON.parse(await readFile(join(root, sessionRoots[0]!.name, "data/fake-completion-feedback.json"), "utf8"))).toMatchObject([
|
|
{ result: { content: [{ text: expect.stringContaining(feedback) }] } },
|
|
]);
|
|
} finally {
|
|
releaseFeedback();
|
|
await session.close({ reason: "test complete" });
|
|
}
|
|
});
|
|
|
|
it.each(["close", "interrupt"] as const)("settles bound completion before explicit %s", async operation => {
|
|
await chmod(fixture, 0o755);
|
|
const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-completion-stop-"));
|
|
const workspace = await mkdtemp(join(tmpdir(), "paperclip-opencode-completion-stop-workspace-"));
|
|
roots.push(root, workspace);
|
|
let releaseFeedback!: () => void, feedbackStarted!: () => void;
|
|
const started = new Promise<void>(resolve => { feedbackStarted = resolve; });
|
|
const release = new Promise<void>(resolve => { releaseFeedback = resolve; });
|
|
const driver = new OpenCodeServerDriver({
|
|
model: "openrouter/deepseek/deepseek-v4-flash-0731", runtimeDirectory: root, command: fixture,
|
|
environment: { PATH: process.env.PATH, OPENROUTER_API_KEY: "fixture-key" },
|
|
completionFeedback: async () => { feedbackStarted(); await release; return "Accepted completion."; },
|
|
});
|
|
const session = await driver.openSession({ runId: "completion-stop", normalizedSessionId: "completion-stop", workingDirectory: workspace });
|
|
try {
|
|
await session.startTurn({ message: { role: "user", text: "finish" } });
|
|
await started;
|
|
let stopped = false;
|
|
const stop = session[operation]({ reason: "fixture stop" }).then(() => { stopped = true; });
|
|
await new Promise<void>(resolve => setImmediate(resolve));
|
|
expect(stopped).toBe(false);
|
|
releaseFeedback();
|
|
await stop;
|
|
expect((await session.snapshot()).semanticResult).not.toBeNull();
|
|
const transcript = await session.transcript();
|
|
expect(transcript.events.filter(event => event.eventType === "run.result.proposed")).toHaveLength(1);
|
|
expect(transcript.events.some(event => event.eventType === "item.completed" && (event.payload.item as { is_error?: boolean })?.is_error)).toBe(false);
|
|
} finally {
|
|
releaseFeedback();
|
|
await session.close({ reason: "test complete" });
|
|
}
|
|
});
|
|
|
|
it("rejects malformed SSE frames", async () => {
|
|
const stream = (value: string) =>
|
|
new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
controller.enqueue(new TextEncoder().encode(value));
|
|
controller.close();
|
|
},
|
|
});
|
|
const consume = async (value: string) => {
|
|
for await (const _event of openCodeServerDriverInternals.parseSse(
|
|
stream(value),
|
|
)) {
|
|
// Consume the complete stream so parse failures are observed.
|
|
}
|
|
};
|
|
await expect(consume("data: {bad json}\n\n")).rejects.toThrow();
|
|
});
|
|
|
|
it("keeps the turn and the tool outcome when OpenCode sends an event above the payload limit", async () => {
|
|
await chmod(fixture, 0o755);
|
|
const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-oversized-sse-"));
|
|
const workspace = await mkdtemp(join(tmpdir(), "paperclip-opencode-oversized-sse-workspace-"));
|
|
roots.push(root, workspace);
|
|
let stream!: ReadableStreamDefaultController<Uint8Array>;
|
|
let streamOpened!: () => void;
|
|
const opened = new Promise<void>(resolve => { streamOpened = resolve; });
|
|
const driver = new OpenCodeServerDriver({
|
|
model: "openrouter/deepseek/deepseek-v4-flash-0731", runtimeDirectory: root, command: fixture,
|
|
environment: { PATH: process.env.PATH, OPENROUTER_API_KEY: "fixture-key" },
|
|
fetch: async (input, init) => String(input).endsWith("/event")
|
|
? new Response(new ReadableStream<Uint8Array>({ start(controller) { stream = controller; streamOpened(); } }), { headers: { "Content-Type": "text/event-stream" } })
|
|
: fetch(input, init),
|
|
});
|
|
const session = await driver.openSession({ runId: "oversized-sse", normalizedSessionId: "oversized-sse", workingDirectory: workspace });
|
|
try {
|
|
const sessionID = session.ids().providerSessionId;
|
|
const events = collectTurnEvents(session.events());
|
|
await session.startTurn({ message: { role: "user", text: "run the full suite" } });
|
|
await opened;
|
|
const tail = "\nTests: 1 failed, 812 passed\nexit status 1";
|
|
const output = "test output line\n".repeat(150_000) + tail;
|
|
const send = (value: unknown) => {
|
|
const bytes = new TextEncoder().encode(`data: ${JSON.stringify(value)}\n\n`);
|
|
for (let index = 0; index < bytes.length; index += 64 * 1024)
|
|
stream.enqueue(bytes.subarray(index, index + 64 * 1024));
|
|
};
|
|
send({ type: "message.updated", properties: { info: { id: "msg_oversized", role: "assistant", sessionID } } });
|
|
send({ type: "message.part.updated", properties: { part: {
|
|
id: "prt_oversized", messageID: "msg_oversized", sessionID, type: "tool", callID: "call_oversized", tool: "bash",
|
|
state: { status: "completed", input: { command: "pnpm test" }, output, title: "pnpm test", metadata: { exit: 1, output }, time: { start: 1, end: 2 } },
|
|
} } });
|
|
send({ type: "session.idle", properties: { sessionID } });
|
|
const observed = await events;
|
|
expect(observed.at(-1)?.eventType).toBe("turn.completed");
|
|
const diagnostics = observed.filter(event => event.eventType === "harness.diagnostic");
|
|
expect(diagnostics.map(event => event.payload.code)).toEqual(["opencode_sse_event_compacted"]);
|
|
const tool = observed.find(event => event.eventType === "tool.execution.completed" && event.payload.executionId === "prt_oversized");
|
|
expect(tool?.payload).toMatchObject({
|
|
name: "bash",
|
|
status: "completed",
|
|
outputBytes: Buffer.byteLength(output),
|
|
outputTruncated: true,
|
|
outputDigest: null,
|
|
});
|
|
expect(String(tool?.payload.output).endsWith(tail)).toBe(true);
|
|
} finally {
|
|
await session.close({ reason: "test complete" });
|
|
}
|
|
});
|
|
|
|
it("fails the running turn with the exit cause when OpenCode stops mid-turn", async () => {
|
|
await chmod(fixture, 0o755);
|
|
const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-server-lost-"));
|
|
const workspace = await mkdtemp(join(tmpdir(), "paperclip-opencode-server-lost-workspace-"));
|
|
roots.push(root, workspace);
|
|
let stream!: ReadableStreamDefaultController<Uint8Array>;
|
|
let streamOpened!: () => void;
|
|
const opened = new Promise<void>(resolve => { streamOpened = resolve; });
|
|
let serverPid: number | undefined;
|
|
let eventRequests = 0;
|
|
const refused = () => new TypeError("fetch failed", {
|
|
cause: Object.assign(new Error("connect ECONNREFUSED 127.0.0.1"), { code: "ECONNREFUSED" }),
|
|
});
|
|
const driver = new OpenCodeServerDriver({
|
|
model: "openrouter/deepseek/deepseek-v4-flash-0731", runtimeDirectory: root, command: fixture,
|
|
environment: { PATH: process.env.PATH, OPENROUTER_API_KEY: "fixture-key" },
|
|
onSpawn: async ({ pid }) => { serverPid = pid; },
|
|
// Own the event stream so the fixture cannot finish the turn on its own,
|
|
// then report the refused reconnects a dead server produces.
|
|
fetch: async (input, init) => {
|
|
if (!String(input).endsWith("/event")) return fetch(input, init);
|
|
eventRequests += 1;
|
|
if (eventRequests > 1) throw refused();
|
|
return new Response(new ReadableStream<Uint8Array>({ start(controller) { stream = controller; streamOpened(); } }),
|
|
{ headers: { "Content-Type": "text/event-stream" } });
|
|
},
|
|
});
|
|
const session = await driver.openSession({ runId: "server-lost", normalizedSessionId: "server-lost", workingDirectory: workspace });
|
|
try {
|
|
const events = collectTurnEvents(session.events());
|
|
const turn = await session.startTurn({ message: { role: "user", text: "install dependencies" } });
|
|
await opened;
|
|
process.kill(serverPid!, "SIGKILL");
|
|
stream.error(refused());
|
|
const observed = await events;
|
|
const failed = observed.at(-1);
|
|
expect(failed).toMatchObject({
|
|
eventType: "turn.failed",
|
|
turnId: turn.turnId,
|
|
payload: {
|
|
status: "failed",
|
|
providerTerminalObserved: false,
|
|
error: {
|
|
code: "opencode_server_exited",
|
|
opencodeFailure: { kind: "server_exited", signal: "SIGKILL", connectionError: "ECONNREFUSED" },
|
|
},
|
|
},
|
|
});
|
|
const diagnostic = observed.find(event => event.eventType === "harness.diagnostic" && event.payload.code === "opencode_sse_failed");
|
|
expect(diagnostic?.payload.failure).toEqual({ kind: "server_exited", signal: "SIGKILL", connectionError: "ECONNREFUSED" });
|
|
expect(eventRequests).toBe(4);
|
|
} finally {
|
|
await session.close({ reason: "test complete" });
|
|
}
|
|
});
|
|
|
|
it("classifies a lost event stream from closed error fields only", async () => {
|
|
const { openCodeEventStreamFailure } = openCodeServerDriverInternals;
|
|
const running = Object.assign(new EventEmitter(), { exitCode: null, signalCode: null }) as unknown as ChildProcess;
|
|
const exited = Object.assign(new EventEmitter(), { exitCode: 137, signalCode: null }) as unknown as ChildProcess;
|
|
const reset = new TypeError("fetch failed", { cause: Object.assign(new Error("private socket detail"), { code: "ECONNRESET" }) });
|
|
expect(await openCodeEventStreamFailure(reset, exited)).toEqual({ kind: "server_exited", exitCode: 137, connectionError: "ECONNRESET" });
|
|
expect(await openCodeEventStreamFailure(Object.assign(new Error("HTTP 502"), { httpStatus: 502 }), running))
|
|
.toEqual({ kind: "http_error", httpStatus: 502 });
|
|
expect(await openCodeEventStreamFailure(Object.assign(new Error("closed"), { streamClosed: true }), running))
|
|
.toEqual({ kind: "stream_closed" });
|
|
expect(await openCodeEventStreamFailure(new Error("private provider text"), running)).toEqual({ kind: "stream_error" });
|
|
const unknownCode = new TypeError("fetch failed", { cause: Object.assign(new Error("x"), { code: "EPRIVATE" }) });
|
|
const started = Date.now();
|
|
expect(await openCodeEventStreamFailure(unknownCode, running)).toEqual({ kind: "connection_failed", connectionError: "unrecognized" });
|
|
expect(Date.now() - started).toBeGreaterThanOrEqual(900);
|
|
});
|
|
|
|
it("restarts OpenCode when session startup fails transiently", async () => {
|
|
await chmod(fixture, 0o755);
|
|
const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-driver-"));
|
|
const workspace = await mkdtemp(
|
|
join(tmpdir(), "paperclip-opencode-workspace-"),
|
|
);
|
|
roots.push(root, workspace);
|
|
let failSessionCreate = true;
|
|
const spawns: number[] = [];
|
|
const commandLifecycle: string[] = [];
|
|
const driver = new OpenCodeServerDriver({
|
|
model: "openrouter/deepseek/deepseek-v4-flash-0731",
|
|
runtimeDirectory: root,
|
|
command: fixture,
|
|
environment: {
|
|
PATH: process.env.PATH,
|
|
OPENROUTER_API_KEY: "fixture-key",
|
|
},
|
|
commandLifecycle: {
|
|
beforeSpawn: () => {
|
|
commandLifecycle.push("before");
|
|
},
|
|
afterSpawn: () => {
|
|
commandLifecycle.push("after");
|
|
},
|
|
afterExit: () => {
|
|
commandLifecycle.push("exit");
|
|
},
|
|
},
|
|
fetch: async (input, init) => {
|
|
if (
|
|
failSessionCreate &&
|
|
String(input).endsWith("/session") &&
|
|
init?.method === "POST"
|
|
) {
|
|
failSessionCreate = false;
|
|
throw new Error("transient session initialization failure");
|
|
}
|
|
return fetch(input, init);
|
|
},
|
|
onSpawn: async ({ pid }) => {
|
|
spawns.push(pid);
|
|
},
|
|
});
|
|
const session = await driver.openSession({
|
|
runId: "run-retry",
|
|
normalizedSessionId: "retry",
|
|
workingDirectory: workspace,
|
|
});
|
|
expect(session.ids().providerSessionId).toBe("ses_fake_1");
|
|
expect(spawns.length).toBeGreaterThanOrEqual(2);
|
|
expect(spawns.length).toBeLessThanOrEqual(3);
|
|
expect(commandLifecycle).toEqual(
|
|
[...Array.from({ length: spawns.length - 1 }, () => ["before", "after", "exit"]).flat(), "before", "after"],
|
|
);
|
|
await session.close({ reason: "test" });
|
|
expect(commandLifecycle).toEqual(
|
|
Array.from({ length: spawns.length }, () => ["before", "after", "exit"]).flat(),
|
|
);
|
|
});
|
|
|
|
it("releases the executable binding when spawning fails before health checks", async () => {
|
|
const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-spawn-error-"));
|
|
roots.push(root);
|
|
const lifecycle: string[] = [];
|
|
const driver = new OpenCodeServerDriver({
|
|
model: "openrouter/deepseek/deepseek-v4-flash-0731",
|
|
runtimeDirectory: root,
|
|
command: join(root, "missing-executable"),
|
|
commandLifecycle: {
|
|
beforeSpawn: () => { lifecycle.push("before"); },
|
|
afterSpawn: () => { lifecycle.push("spawned"); },
|
|
afterExit: () => { lifecycle.push("released"); },
|
|
},
|
|
});
|
|
await expect(driver.openSession({ runId: "spawn-error", normalizedSessionId: "spawn-error",
|
|
workingDirectory: root })).rejects.toThrow("ENOENT");
|
|
expect(lifecycle).toEqual(["before", "released"]);
|
|
});
|
|
|
|
it("reports startup exit details with stderr captured after spawn and redacted", async () => {
|
|
await chmod(fixture, 0o755);
|
|
const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-driver-"));
|
|
const workspace = await mkdtemp(
|
|
join(tmpdir(), "paperclip-opencode-workspace-"),
|
|
);
|
|
roots.push(root, workspace);
|
|
const exitingFixture = join(root, "exit-before-health.mjs");
|
|
await writeFile(
|
|
exitingFixture,
|
|
"#!/usr/bin/env node\nimport { readFileSync } from 'node:fs';\nconst config = JSON.parse(readFileSync(`${process.env.XDG_CONFIG_HOME}/opencode/opencode.json`, 'utf8'));\nprocess.stderr.write(`credential=${process.env.OPENROUTER_API_KEY}\\ngateway=${config.provider.paperclip.options.apiKey}\\nauthorization=super-secret-opencode-token\\n`);\nprocess.exit(17);\n",
|
|
{ mode: 0o755 },
|
|
);
|
|
const diagnostics: string[] = [];
|
|
const driver = new OpenCodeServerDriver({
|
|
model: "paperclip/team/model-alias",
|
|
runtimeDirectory: root,
|
|
command: exitingFixture,
|
|
onDiagnostic: (message) => { diagnostics.push(message); },
|
|
environment: {
|
|
PATH: process.env.PATH,
|
|
OPENROUTER_API_KEY: "fixture-key",
|
|
PAPERCLIP_AI_PROVIDER_KEY: "fixture-custom-gateway-key",
|
|
PAPERCLIP_AI_PROVIDER_URL: "https://gateway.example/v1",
|
|
},
|
|
});
|
|
const error = await driver
|
|
.openSession({
|
|
runId: "run-startup-exit",
|
|
normalizedSessionId: "startup-exit",
|
|
workingDirectory: workspace,
|
|
})
|
|
.then(
|
|
() => "provider unexpectedly started",
|
|
(cause: unknown) => String(cause),
|
|
);
|
|
expect(error).toContain("provider_process_exited");
|
|
expect(error).toContain("provider=opencode");
|
|
expect(error).toContain("stage=health");
|
|
expect(error).toContain("[REDACTED]");
|
|
expect(error).not.toContain("fixture-key");
|
|
expect(error).not.toContain("fixture-custom-gateway-key");
|
|
expect(error).not.toContain("super-secret-opencode-token");
|
|
expect(diagnostics.join("")).toContain("gateway=[REDACTED]");
|
|
expect(diagnostics.join("")).not.toContain("fixture-custom-gateway-key");
|
|
});
|
|
|
|
it.each(["network", "response"])("redacts custom gateway keys in %s errors", async (failure) => {
|
|
await chmod(fixture, 0o755);
|
|
const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-driver-"));
|
|
const workspace = await mkdtemp(join(tmpdir(), "paperclip-opencode-workspace-"));
|
|
roots.push(root, workspace);
|
|
const key = "fixture-custom-gateway-key";
|
|
const driver = new OpenCodeServerDriver({
|
|
model: "paperclip/team/model-alias",
|
|
runtimeDirectory: root,
|
|
command: fixture,
|
|
environment: {
|
|
PATH: process.env.PATH,
|
|
PAPERCLIP_AI_PROVIDER_KEY: key,
|
|
PAPERCLIP_AI_PROVIDER_URL: "https://gateway.example/v1",
|
|
},
|
|
fetch: async (input, init) => {
|
|
if (String(input).endsWith("/session") && init?.method === "POST") {
|
|
if (failure === "network") throw new Error(`Gateway rejected ${key}`);
|
|
return new Response(`Gateway rejected ${key}`, { status: 400 });
|
|
}
|
|
return fetch(input, init);
|
|
},
|
|
});
|
|
const error = await driver.openSession({
|
|
runId: "run-gateway-error",
|
|
normalizedSessionId: "gateway-error",
|
|
workingDirectory: workspace,
|
|
}).then(() => "provider unexpectedly started", (cause: unknown) => String(cause));
|
|
expect(error).toContain("Gateway rejected [REDACTED]");
|
|
expect(error).not.toContain(key);
|
|
});
|
|
});
|