fix(opencode-local): pad prompts below the opencode 1.16.2 silent-exit floor (ORA-284)
PR / policy (pull_request) Successful in 51s
commitperclip PR Review / review (pull_request_target) Failing after 2m5s
PR / Typecheck + Release Registry (pull_request) Successful in 11m54s
PR / General tests (server (1/3)) (pull_request) Failing after 9m2s
PR / General tests (server (2/3)) (pull_request) Failing after 8m31s
PR / General tests (server (3/3)) (pull_request) Failing after 10m35s
PR / General tests (workspaces-a) (pull_request) Failing after 8m18s
PR / General tests (workspaces-b) (pull_request) Failing after 6m31s
PR / Build (pull_request) Successful in 13m27s
PR / Verify serialized server suites (1/4) (pull_request) Failing after 7m4s
PR / Verify serialized server suites (2/4) (pull_request) Failing after 8m12s
PR / Verify serialized server suites (3/4) (pull_request) Failing after 6m27s
PR / Verify serialized server suites (4/4) (pull_request) Failing after 6m15s
PR / Canary Dry Run (pull_request) Successful in 18m1s
PR / e2e (pull_request) Failing after 5m44s
PR / verify (pull_request) Failing after 6s

opencode 1.16.2 has a non-deterministic silent-exit mode that fires on
short stdin prompts (observed 0-byte / <200-byte run logs, ~32% stall
rate on FoundingEngineer wake-payload-only runs). The root cause lives
in the opencode binary, not the adapter. As a server-side workaround,
ensure every prompt is at least 256 bytes by appending a deterministic
Paperclip Runtime Context block when the joined sections fall short.

- New exported helper padShortPrompt(input) in execute.ts
- New exported constant OPENCODE_PROMPT_MIN_LENGTH_CHARS = 256
- New promptMetrics.promptPadded (0/1) so the server can observe how
  often the workaround fires
- Four new unit tests cover the long-prompt, short-prompt, empty-cwd,
  and trailing-newline paths

Refs: ORA-284, ORA-275, ORA-276

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Paperclip
2026-06-22 01:05:11 +00:00
parent 9ac24317e6
commit cdca784e6d
2 changed files with 101 additions and 2 deletions
@@ -1,6 +1,10 @@
import { afterEach, describe, expect, it } from "vitest";
import { ensureRemoteOpenCodeModelConfiguredAndAvailable } from "./execute.js";
import {
OPENCODE_PROMPT_MIN_LENGTH_CHARS,
padShortPrompt,
} from "./execute.js";
describe("ensureRemoteOpenCodeModelConfiguredAndAvailable", () => {
afterEach(() => {
@@ -60,3 +64,56 @@ describe("ensureRemoteOpenCodeModelConfiguredAndAvailable", () => {
).rejects.toThrow();
});
});
describe("padShortPrompt (ORA-284)", () => {
// opencode 1.16.2 has a non-deterministic silent-exit mode that fires on
// short stdin prompts. The pad forces every prompt above the threshold so
// the binary boots into a normal session instead of exiting cleanly with
// 0 bytes of output.
it("returns the input untouched when it is already at or above the floor", () => {
const longPrompt = "x".repeat(OPENCODE_PROMPT_MIN_LENGTH_CHARS);
const out = padShortPrompt({
prompt: longPrompt,
agentId: "agent-1",
runId: "run-1",
executionCwd: "/tmp",
});
expect(out).toBe(longPrompt);
});
it("pads prompts that are below the floor and embeds run / agent context", () => {
const out = padShortPrompt({
prompt: "wake payload only",
agentId: "agent-1",
runId: "run-1",
executionCwd: "/tmp/oracle",
});
expect(out.length).toBeGreaterThanOrEqual(OPENCODE_PROMPT_MIN_LENGTH_CHARS);
expect(out).toContain("run-1");
expect(out).toContain("agent-1");
expect(out).toContain("/tmp/oracle");
expect(out).toContain("ORA-284");
});
it("uses a default cwd marker when executionCwd is empty", () => {
const out = padShortPrompt({
prompt: "tiny",
agentId: "agent-2",
runId: "run-2",
executionCwd: "",
});
expect(out).toContain("(default)");
});
it("does not insert a duplicate blank line when the prompt already ends with newline", () => {
const out = padShortPrompt({
prompt: "tiny\n",
agentId: "agent-3",
runId: "run-3",
executionCwd: "/tmp",
});
// Pad block already begins with a leading blank line, so we tolerate one
// extra newline rather than concatenating two of them.
expect(out.startsWith("tiny\n\n## Paperclip Runtime Context")).toBe(true);
});
});
@@ -66,6 +66,39 @@ function firstNonEmptyLine(text: string): string {
);
}
// ORA-284: opencode 1.16.2 has a non-deterministic silent-exit mode that fires
// on short stdin prompts (observed 0-byte / <200-byte run logs in production,
// ~32% stall rate on FoundingEngineer wake-payload-only runs). The root cause
// lives in the opencode binary, not in the adapter. As a server-side workaround
// we pad any prompt shorter than this floor with a deterministic, content-
// meaningful block drawn from the run context so the prompt is well above the
// observed failure threshold (~50 bytes, with a 4x safety margin).
export const OPENCODE_PROMPT_MIN_LENGTH_CHARS = 256;
export function padShortPrompt(input: {
prompt: string;
agentId: string;
runId: string;
executionCwd: string;
}): string {
const { prompt, agentId, runId, executionCwd } = input;
if (prompt.length >= OPENCODE_PROMPT_MIN_LENGTH_CHARS) return prompt;
const padBlock = [
"",
"## Paperclip Runtime Context",
"",
`Run: \`${runId}\``,
`Agent: \`${agentId}\``,
`Cwd: \`${executionCwd || "(default)"}\``,
"",
"You are running as a Paperclip-managed agent. The previous prompt was",
`shorter than the ${OPENCODE_PROMPT_MIN_LENGTH_CHARS}-byte opencode 1.16.2`,
"stability floor (ORA-284), so this metadata block was appended to avoid the",
"binary's silent-exit mode. Continue with the task described above.",
].join("\n");
return prompt.endsWith("\n") ? prompt + padBlock : prompt + "\n" + padBlock;
}
function parseModelProvider(model: string | null): string | null {
if (!model) return null;
const trimmed = model.trim();
@@ -546,20 +579,29 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const shouldUseResumeDeltaPrompt = Boolean(sessionId) && wakePrompt.length > 0;
const renderedPrompt = shouldUseResumeDeltaPrompt ? "" : renderTemplate(promptTemplate, templateData);
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
const prompt = joinPromptSections([
const joinedSections = joinPromptSections([
instructionsPrefix,
renderedBootstrapPrompt,
wakePrompt,
sessionHandoffNote,
renderedPrompt,
]);
const promptMetrics = {
const promptWasPadded = joinedSections.length < OPENCODE_PROMPT_MIN_LENGTH_CHARS;
const prompt = padShortPrompt({
prompt: joinedSections,
agentId: agent.id,
runId,
executionCwd: effectiveExecutionCwd,
});
const promptMetrics: Record<string, number> = {
promptChars: prompt.length,
instructionsChars: instructionsPrefix.length,
bootstrapPromptChars: renderedBootstrapPrompt.length,
wakePromptChars: wakePrompt.length,
sessionHandoffChars: sessionHandoffNote.length,
heartbeatPromptChars: renderedPrompt.length,
// ORA-284: 1 when the short-prompt pad block was appended, 0 otherwise.
promptPadded: promptWasPadded ? 1 : 0,
};
// Optional diagnostic: surface OpenCode's own logs on stderr (captured into the