From 4c3e30cdad559d159adee0e8fda013f90bf74405 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 21 Aug 2026 15:17:39 -0500 Subject: [PATCH] feat(runner): add OpenCode and durable runner UX --- Dockerfile | 2 +- docker/agent-runtime/Dockerfile.opencode | 2 +- packages/adapter-utils/src/types.ts | 7 +- .../codex-local/src/ui/build-config.test.ts | 30 +- .../codex-local/src/ui/build-config.ts | 14 +- .../devtools/issue-thread/src/App.tsx | 203 +++- .../issue-thread/src/DevtoolsInspector.tsx | 2 +- .../devtools/issue-thread/src/ThreadItems.tsx | 147 +++ .../issue-thread/src/issue-thread.css | 157 +++ .../devtools/issue-thread/src/live-client.ts | 25 +- packages/paperclip-runner/package.json | 3 +- .../protocol/schemas/command.schema.json | 2 + .../protocol/schemas/event.schema.json | 45 +- .../schemas/workspace-diff.schema.json | 49 + .../workspace-file-reference.schema.json | 26 + .../src/bin/fake-codex-app-server.rs | 112 ++- .../runner-core/src/bin/paperclip-runnerd.rs | 40 +- .../crates/runner-core/src/codex_provider.rs | 104 +- .../crates/runner-core/src/durable/runner.rs | 338 ++++++- .../crates/runner-core/src/durable/state.rs | 151 ++- .../runner-core/src/durable/transport.rs | 22 +- .../runner-core/src/process_supervisor.rs | 12 + .../crates/runner-core/src/provider_bridge.rs | 41 +- .../runner-core/tests/codex_provider.rs | 148 ++- .../capability-issue-thread-server.mjs | 121 ++- .../generate-protocol-schema-module.mjs | 2 + .../spec/paperclip-agent-operation-groups.md | 42 +- .../src/backends/harness-driver-backend.ts | 20 +- .../src/backends/native-backend-factory.ts | 49 + .../src/backends/opencode-native-backend.ts | 29 + .../src/catalog/operation-groups-doc.test.ts | 4 +- .../src/catalog/operation-groups-doc.ts | 6 +- .../paperclip-runner/src/cli/eval-session.ts | 11 + .../src/cli/opencode-app-server-proxy.ts | 249 +++++ .../src/contracts/harness-driver.ts | 1 + .../src/contracts/native-execution.test.ts | 54 +- .../src/contracts/native-execution.ts | 70 +- .../src/contracts/native-session-backend.ts | 2 + .../durable-prp-control-plane.ts | 82 +- .../src/drivers/codex/app-server-transport.ts | 7 +- .../codex/codex-app-server-driver.test.ts | 46 +- .../drivers/codex/codex-app-server-driver.ts | 143 ++- .../src/drivers/opencode/mcp-bridge.test.ts | 141 +++ .../src/drivers/opencode/mcp-bridge.ts | 341 +++++++ .../opencode/opencode-server-driver.test.ts | 157 +++ .../opencode/opencode-server-driver.ts | 890 ++++++++++++++++++ .../src/evals/model-pricing.ts | 4 +- packages/paperclip-runner/src/index.ts | 5 + .../src/issue-thread/fixtures.ts | 90 +- .../src/issue-thread/issue-thread.test.ts | 72 +- .../src/issue-thread/live-projection.ts | 48 +- .../src/issue-thread/public-view.ts | 45 + .../src/issue-thread/types.ts | 17 +- .../src/live/clean-room-server.test.ts | 47 +- .../src/live/live-session.test.ts | 126 ++- .../paperclip-runner/src/live/live-session.ts | 219 ++++- .../src/live/runnerd-codex-transport.test.ts | 144 ++- .../src/live/runnerd-codex-transport.ts | 212 ++++- .../src/live/workspace-diff.test.ts | 46 + .../src/live/workspace-diff.ts | 219 +++++ .../src/live/workspace-file-reference.test.ts | 42 + .../src/live/workspace-file-reference.ts | 128 +++ .../src/native-session-runtime.test.ts | 1 + .../src/native-session-runtime.ts | 41 +- .../src/protocol/generated/schema-bundle.ts | 332 +++++++ .../generated/standalone-validators.ts | 2 +- .../protocol/workspace-diff-events.test.ts | 85 ++ .../fake-durable-codex-app-server.mjs | 12 +- .../test/fixtures/fake-opencode-server.mjs | 143 +++ .../native-session-resumption.test.ts | 1 + server/src/adapters/registry.test.ts | 13 + server/src/adapters/registry.ts | 114 ++- server/src/services/heartbeat.ts | 15 +- .../native-runtime/native-execution-input.ts | 10 +- .../native-session-executor.test.ts | 82 +- .../native-runtime/native-session-executor.ts | 268 +++++- .../native-runtime/runtime-mode.test.ts | 35 +- .../services/native-runtime/runtime-mode.ts | 25 +- ui/src/adapters/codex-local/config-fields.tsx | 177 +++- .../adapters/paperclip-runner/index.test.ts | 40 +- ui/src/adapters/paperclip-runner/index.ts | 56 +- ui/src/components/TaskChatThread.test.tsx | 28 + ui/src/components/TaskChatThread.tsx | 124 ++- .../components/task-chat/TaskChatBubble.tsx | 5 +- .../task-chat/TaskChatLiveRunPill.tsx | 43 +- .../components/task-chat/TaskChatLiveTail.tsx | 8 +- .../task-chat/TaskChatRunnerTurn.test.tsx | 56 ++ .../task-chat/TaskChatRunnerTurn.tsx | 103 ++ .../components/task-chat/TaskChatThinking.tsx | 3 +- .../task-chat/TaskChatThreadView.tsx | 22 +- .../task-chat/TaskChatTurn.test.tsx | 7 + ui/src/components/task-chat/TaskChatTurn.tsx | 17 +- .../components/task-chat/task-chat-model.ts | 14 +- .../components/task-chat/task-chat-states.ts | 10 +- .../task-chat/tool-taxonomy.test.ts | 1 + ui/src/components/task-chat/tool-taxonomy.ts | 2 +- .../task-chat/transcript-adapter.test.ts | 18 + .../task-chat/transcript-adapter.ts | 24 +- ui/src/lib/issue-chat-messages.ts | 2 + ui/src/pages/IssueDetail.tsx | 16 +- 100 files changed, 7156 insertions(+), 412 deletions(-) create mode 100644 packages/paperclip-runner/protocol/schemas/workspace-diff.schema.json create mode 100644 packages/paperclip-runner/protocol/schemas/workspace-file-reference.schema.json create mode 100644 packages/paperclip-runner/src/backends/native-backend-factory.ts create mode 100644 packages/paperclip-runner/src/backends/opencode-native-backend.ts create mode 100644 packages/paperclip-runner/src/cli/opencode-app-server-proxy.ts create mode 100644 packages/paperclip-runner/src/drivers/opencode/mcp-bridge.test.ts create mode 100644 packages/paperclip-runner/src/drivers/opencode/mcp-bridge.ts create mode 100644 packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts create mode 100644 packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts create mode 100644 packages/paperclip-runner/src/live/workspace-diff.test.ts create mode 100644 packages/paperclip-runner/src/live/workspace-diff.ts create mode 100644 packages/paperclip-runner/src/live/workspace-file-reference.test.ts create mode 100644 packages/paperclip-runner/src/live/workspace-file-reference.ts create mode 100644 packages/paperclip-runner/src/protocol/workspace-diff-events.test.ts create mode 100755 packages/paperclip-runner/test/fixtures/fake-opencode-server.mjs create mode 100644 ui/src/components/task-chat/TaskChatRunnerTurn.test.tsx create mode 100644 ui/src/components/task-chat/TaskChatRunnerTurn.tsx diff --git a/Dockerfile b/Dockerfile index 01976d8d48c..a1c2fb82d95 100644 --- a/Dockerfile +++ b/Dockerfile @@ -80,7 +80,7 @@ WORKDIR /app # (the single most expensive layer: four CLI toolchains + apt, per arch) can # never hit the layer cache and rebuilds on every build. RUN echo "cli-tools-epoch: ${CLI_TOOLS_CACHE_EPOCH}" \ - && npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest opencode-ai @google/gemini-cli@latest \ + && npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest opencode-ai@1.18.17 @google/gemini-cli@latest \ && apt-get update \ && apt-get install -y --no-install-recommends openssh-client jq \ && rm -rf /var/lib/apt/lists/* \ diff --git a/docker/agent-runtime/Dockerfile.opencode b/docker/agent-runtime/Dockerfile.opencode index f024f0c0077..30a125eeb28 100644 --- a/docker/agent-runtime/Dockerfile.opencode +++ b/docker/agent-runtime/Dockerfile.opencode @@ -4,7 +4,7 @@ FROM paperclipai/agent-runtime-base:${BASE_TAG} USER root # opencode-ai npm package; binary on PATH is `opencode`. -RUN npm install -g opencode-ai@latest \ +RUN npm install -g opencode-ai@1.18.17 \ && { chown -R 1000:1000 /usr/lib/node_modules || true; } USER 1000:1000 diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index be317de5cbd..11a6fcfdeb4 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -519,8 +519,8 @@ export interface ServerAdapterModule { // --------------------------------------------------------------------------- export type TranscriptEntry = - | { kind: "assistant"; ts: string; text: string; delta?: boolean } - | { kind: "thinking"; ts: string; text: string; delta?: boolean } + | { kind: "assistant"; ts: string; text: string; delta?: boolean; channel?: "progress" | "final" | "unknown" } + | { kind: "thinking"; ts: string; text: string; delta?: boolean; channel?: "summary" | "detail" | "unknown" } | { kind: "user"; ts: string; text: string } | { kind: "tool_call"; ts: string; name: string; input: unknown; toolUseId?: string; invocationId?: string; actionRequestId?: string } | { kind: "tool_result"; ts: string; toolUseId: string; toolName?: string; content: string; isError: boolean; delta?: boolean } @@ -548,6 +548,9 @@ export interface CLIAdapterModule { export interface CreateConfigValues { adapterType: string; + paperclipRunnerProvider?: "codex" | "opencode"; + paperclipRunnerLifecycleMode?: "per_turn" | "warm"; + paperclipRunnerIdleTimeoutMs?: number; cwd: string; instructionsFilePath?: string; promptTemplate: string; diff --git a/packages/adapters/codex-local/src/ui/build-config.test.ts b/packages/adapters/codex-local/src/ui/build-config.test.ts index afb7326551c..8d314284fe6 100644 --- a/packages/adapters/codex-local/src/ui/build-config.test.ts +++ b/packages/adapters/codex-local/src/ui/build-config.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { buildCodexLocalConfig } from "./build-config.js"; +import { buildCodexLocalConfig, buildPaperclipRunnerConfig } from "./build-config.js"; import type { CreateConfigValues } from "@paperclipai/adapter-utils"; function makeValues(overrides: Partial = {}): CreateConfigValues { @@ -69,3 +69,31 @@ describe("buildCodexLocalConfig", () => { expect(config).not.toHaveProperty("model"); }); }); + +describe("buildPaperclipRunnerConfig", () => { + it("preserves the Codex provider default", () => { + expect(buildPaperclipRunnerConfig(makeValues())).toMatchObject({ + provider: "codex", + model: "gpt-5.4", + lifecycleMode: "per_turn", + }); + }); + + it("persists a warm lifecycle and its idle timeout", () => { + expect(buildPaperclipRunnerConfig(makeValues({ + paperclipRunnerLifecycleMode: "warm", + paperclipRunnerIdleTimeoutMs: 45_000, + }))).toMatchObject({ lifecycleMode: "warm", idleTimeoutMs: 45_000 }); + }); + + it("persists OpenCode and supplies the qualified model when blank", () => { + expect(buildPaperclipRunnerConfig(makeValues({ + adapterType: "paperclip_runner", + paperclipRunnerProvider: "opencode", + model: "", + }))).toMatchObject({ + provider: "opencode", + model: "openrouter/deepseek/deepseek-v4-flash-0731", + }); + }); +}); diff --git a/packages/adapters/codex-local/src/ui/build-config.ts b/packages/adapters/codex-local/src/ui/build-config.ts index 7c9b98a7b6b..bb09573bc66 100644 --- a/packages/adapters/codex-local/src/ui/build-config.ts +++ b/packages/adapters/codex-local/src/ui/build-config.ts @@ -70,5 +70,17 @@ export function buildPaperclipRunnerConfig(v: CreateConfigValues): Record | null; + if (value && (value.provider === "codex" || value.provider === "opencode") && typeof value.model === "string") { + const lifecyclePolicy = value.lifecyclePolicy?.mode === "per_turn" + ? { mode: "per_turn" as const, idleTimeoutMs: null } + : value.lifecyclePolicy?.mode === "warm" && Number.isSafeInteger(value.lifecyclePolicy.idleTimeoutMs) && Number(value.lifecyclePolicy.idleTimeoutMs) > 0 + ? { mode: "warm" as const, idleTimeoutMs: Number(value.lifecyclePolicy.idleTimeoutMs) } + : { mode: "warm" as const, idleTimeoutMs: 300_000 }; + return { provider: value.provider, model: value.model, lifecyclePolicy }; + } + } catch { + // Invalid preferences fall back to the qualified defaults. + } + return defaultHarness(); +} + +function persistHarnessConfiguration(configuration: CapabilityHarnessConfiguration): void { + try { + window.localStorage.setItem(CHAT_HARNESS_KEY, JSON.stringify(configuration)); + } catch { + // Provider/model preference is convenient but not authority-bearing. + } } function readChatHistory(): StoredChatSession[] { @@ -180,8 +220,9 @@ function settledAnnouncement(snapshot: CapabilityIssueThreadSnapshot, interactio } function currentTurnActivity(snapshot: CapabilityIssueThreadSnapshot): string { + const harness = snapshot.identity.agentLabel.replace(/^Real /, ""); const turn = snapshot.turns.at(-1); - if (turn === undefined) return "Dispatching turn to Codex"; + if (turn === undefined) return `Dispatching turn to ${harness}`; const activity = [...turn.items].reverse().find((item) => item.kind === "tool_activity" || item.kind === "progress_activity" || (item.kind === "agent_message" && item.streaming), @@ -192,8 +233,8 @@ function currentTurnActivity(snapshot: CapabilityIssueThreadSnapshot): string { : `Paperclip tool completed · ${activity.operationId}`; } if (activity?.kind === "progress_activity") return activity.summary; - if (activity?.kind === "agent_message") return "Receiving Codex response"; - return "Waiting for Codex activity"; + if (activity?.kind === "agent_message") return `Receiving ${harness} response`; + return `Waiting for ${harness} activity`; } function useRoute(): CapabilityRoute { @@ -242,6 +283,9 @@ export function App() { const [snapshot, setSnapshot] = useState(null); const [devtools, setDevtools] = useState(null); const [identity, setIdentity] = useState(null); + const initialHarnessRef = useRef(readHarnessConfiguration()); + const [harness, setHarness] = useState(initialHarnessRef.current); + const [activeHarness, setActiveHarness] = useState(null); const [chatHistory, setChatHistory] = useState(readChatHistory); const [historicSessionId, setHistoricSessionId] = useState(null); const liveRoomRef = useRef(null); @@ -315,10 +359,14 @@ export function App() { // cannot start, the surface says so rather than rendering a canned thread. void (async () => { try { - const response = await capabilityLiveClient.loadCleanRoom(recallSession("cleanroom")); + const response = await capabilityLiveClient.loadCleanRoom( + recallSession("cleanroom"), + initialHarnessRef.current, + ); if (cancelled) return; rememberSession(response.sessionId, "cleanroom"); setIdentity(response.identity ?? null); + setActiveHarness(response.configuration ?? initialHarnessRef.current); setHistoricSessionId(null); setSnapshot(response.view); setSnapshotSurface("chat"); @@ -434,6 +482,7 @@ export function App() { snapshot, identity, updatedAt: snapshot.renderedAt, + ...(activeHarness === null ? {} : { configuration: activeHarness }), }; liveRoomRef.current = record; setChatHistory((current) => { @@ -441,7 +490,7 @@ export function App() { persistChatHistory(next); return next; }); - }, [chat, historicSessionId, identity, snapshot?.renderedAt, snapshot?.sessionId]); + }, [activeHarness, chat, historicSessionId, identity, snapshot?.renderedAt, snapshot?.sessionId]); useEffect(() => { if (snapshot === null) return; @@ -659,6 +708,7 @@ export function App() { const adoptCleanRoom = useCallback((next: Awaited>) => { rememberSession(next.sessionId, "cleanroom"); setIdentity(next.identity ?? null); + setActiveHarness(next.configuration ?? harness); setHistoricSessionId(null); setSnapshot(next.view); setDevtools(null); @@ -666,25 +716,37 @@ export function App() { setAnnouncement( `New clean-room chat started on ${next.view.issue.identifier}. The previous session was closed.`, ); - }, []); + }, [harness]); const newChat = useCallback(() => { + const model = harness.model?.trim() ?? ""; + if (!model) { + setActionError("Choose or enter a model before starting a new chat."); + return; + } + if (harness.provider === "opencode" && !model.includes("/")) { + setActionError("OpenCode models must use provider/model form."); + return; + } + const configuration = { ...harness, model }; + persistHarnessConfiguration(configuration); setConfirmReset(false); // The room this turn belongs to is about to be retired, so the stream is // dropped before the request that retires it. abandonTurn(); setAnnouncement("Starting a new clean-room chat…"); void capabilityLiveClient - .newCleanRoom(liveRoomRef.current?.sessionId ?? snapshot?.sessionId ?? null) + .newCleanRoom(liveRoomRef.current?.sessionId ?? snapshot?.sessionId ?? null, configuration) .then(adoptCleanRoom) .catch((cause) => setActionError(describe(cause))); - }, [abandonTurn, adoptCleanRoom, snapshot]); + }, [abandonTurn, adoptCleanRoom, harness, snapshot]); const selectChatHistory = useCallback((sessionId: string) => { const live = liveRoomRef.current; if (live?.sessionId === sessionId) { setSnapshot(live.snapshot); setIdentity(live.identity); + setActiveHarness(live.configuration ?? null); setDevtools(null); setHistoricSessionId(null); setActionError(null); @@ -693,19 +755,19 @@ export function App() { const archived = chatHistory.find((item) => item.sessionId === sessionId); if (archived === undefined) return; abandonTurn(); - setSnapshot({ - ...archived.snapshot, - composer: { - state: "disabled", - helper: null, - reason: "Historical session is read-only", - pendingInteractionId: null, - }, - }); - setIdentity(archived.identity); - setDevtools(null); - setHistoricSessionId(sessionId); - setActionError(null); + const configuration = archived.configuration ?? defaultHarness(); + void capabilityLiveClient.loadCleanRoom(sessionId, configuration) + .then((restored) => { + rememberSession(restored.sessionId, "cleanroom"); + setSnapshot(restored.view); + setIdentity(restored.identity ?? archived.identity); + setActiveHarness(restored.configuration ?? configuration); + setDevtools(null); + setHistoricSessionId(null); + setActionError(null); + setAnnouncement("Archived chat restored. The runner will resume when you send a message."); + }) + .catch((cause) => setActionError(describe(cause))); }, [abandonTurn, chatHistory]); const reset = useCallback(() => { @@ -829,12 +891,12 @@ export function App() {

{chat - ? `The clean-room chat could not start a real Codex session: ${error}` + ? `The clean-room chat could not start the selected provider: ${error}` : error}

{chat ? (

- The clean room only runs against real Codex through real runnerd, so it does not fall + The clean room only runs against the selected real provider through real runnerd, so it does not fall back to a fixture or a recording.

) : null} @@ -859,7 +921,7 @@ export function App() {

{chat - ? "Starting real runnerd and a real Codex session for a fresh mock tenant…" + ? "Starting real runnerd and the selected provider for a fresh mock tenant…" : "Loading…"}

@@ -917,6 +979,95 @@ export function App() { onSelectSegment={setSegment} /> + {chat && embeddedEval === null ? ( +
+ + + {harness.lifecyclePolicy.mode === "warm" ? ( + + ) : null} + + + + Active: {activeHarness?.provider ?? "starting"} + {activeHarness?.model ? ` · ${activeHarness.model}` : ""} + {activeHarness ? ` · ${activeHarness.lifecyclePolicy.mode === "warm" ? `warm ${Math.round(activeHarness.lifecyclePolicy.idleTimeoutMs / 1_000)}s` : "turn by turn"}` : ""} + +
+ ) : null} + {embeddedEval !== null ? (