mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 17:45:53 +01:00
feat(runner): add OpenCode and durable runner UX
This commit is contained in:
1 parent
f34591016e
commit
4c3e30cdad
100 files changed
+7156
-412
No files matched your search
+1
-1
@@ -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/* \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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> = {}): 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -70,5 +70,17 @@ export function buildPaperclipRunnerConfig(v: CreateConfigValues): Record<string
|
||||
delete config.nonInteractivePermissions;
|
||||
delete config.stateDir;
|
||||
delete config.warmHandleIdleMs;
|
||||
return { ...config, provider: "codex" };
|
||||
const provider = v.paperclipRunnerProvider
|
||||
?? (v.adapterSchemaValues?.provider === "opencode" ? "opencode" : "codex");
|
||||
return {
|
||||
...config,
|
||||
provider,
|
||||
lifecycleMode: v.paperclipRunnerLifecycleMode ?? "per_turn",
|
||||
...(v.paperclipRunnerLifecycleMode === "warm"
|
||||
? { idleTimeoutMs: v.paperclipRunnerIdleTimeoutMs ?? 300_000 }
|
||||
: {}),
|
||||
...(provider === "opencode" && !v.model
|
||||
? { model: "openrouter/deepseek/deepseek-v4-flash-0731" }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
recallSession,
|
||||
rememberSession,
|
||||
type CapabilityCleanRoomIdentity,
|
||||
type CapabilityHarnessConfiguration,
|
||||
} from "./live-client";
|
||||
import { parseCapabilityRoute, capabilityRouteHref, type CapabilityRoute } from "./route";
|
||||
import { SurfaceNav, type CapabilityChatHistoryItem } from "./SurfaceNav";
|
||||
@@ -47,8 +48,13 @@ const REPLAY_STEP_MS = 800;
|
||||
const PENDING_ANNOUNCEMENT = "A request is waiting for your answer.";
|
||||
const ANSWERED_ANNOUNCEMENT = "Your answer was recorded.";
|
||||
const SETTLED_ANNOUNCEMENT = "The pending request is resolved.";
|
||||
const SCENARIOS = ["hb-baseline", "dp-documents", "ix-interactions", "ar-artifacts"];
|
||||
const SCENARIOS = ["hb-baseline", "dp-documents", "ix-interactions", "ar-artifacts", "wc-workspace-changes", "fr-file-reference"];
|
||||
const CHAT_HISTORY_KEY = "paperclip-runner.capability.chat.history.v1";
|
||||
const CHAT_HARNESS_KEY = "paperclip-runner.capability.chat.harness.v1";
|
||||
const MODEL_PRESETS = {
|
||||
codex: ["gpt-5.4-mini", "gpt-5.4"],
|
||||
opencode: ["openrouter/deepseek/deepseek-v4-flash-0731"],
|
||||
} as const;
|
||||
|
||||
interface EmbeddedEvalCheck {
|
||||
id: string;
|
||||
@@ -105,6 +111,40 @@ interface StoredChatSession {
|
||||
snapshot: CapabilityIssueThreadSnapshot;
|
||||
identity: CapabilityCleanRoomIdentity | null;
|
||||
updatedAt: string;
|
||||
configuration?: CapabilityHarnessConfiguration;
|
||||
}
|
||||
|
||||
function defaultHarness(provider: CapabilityHarnessConfiguration["provider"] = "codex"): CapabilityHarnessConfiguration {
|
||||
return {
|
||||
provider,
|
||||
model: MODEL_PRESETS[provider][0],
|
||||
lifecyclePolicy: { mode: "warm", idleTimeoutMs: 300_000 },
|
||||
};
|
||||
}
|
||||
|
||||
function readHarnessConfiguration(): CapabilityHarnessConfiguration {
|
||||
try {
|
||||
const value = JSON.parse(window.localStorage.getItem(CHAT_HARNESS_KEY) ?? "null") as Partial<CapabilityHarnessConfiguration> | 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<CapabilityIssueThreadSnapshot | null>(null);
|
||||
const [devtools, setDevtools] = useState<CapabilityDevtoolsSnapshot | null>(null);
|
||||
const [identity, setIdentity] = useState<CapabilityCleanRoomIdentity | null>(null);
|
||||
const initialHarnessRef = useRef<CapabilityHarnessConfiguration>(readHarnessConfiguration());
|
||||
const [harness, setHarness] = useState<CapabilityHarnessConfiguration>(initialHarnessRef.current);
|
||||
const [activeHarness, setActiveHarness] = useState<CapabilityHarnessConfiguration | null>(null);
|
||||
const [chatHistory, setChatHistory] = useState<StoredChatSession[]>(readChatHistory);
|
||||
const [historicSessionId, setHistoricSessionId] = useState<string | null>(null);
|
||||
const liveRoomRef = useRef<StoredChatSession | null>(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<ReturnType<typeof capabilityLiveClient.newCleanRoom>>) => {
|
||||
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() {
|
||||
<main className="pit-app-error">
|
||||
<p className="pit-composer-reason" role="alert" data-testid="surface-error">
|
||||
{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}
|
||||
</p>
|
||||
{chat ? (
|
||||
<p className="pit-muted">
|
||||
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.
|
||||
</p>
|
||||
) : null}
|
||||
@@ -859,7 +921,7 @@ export function App() {
|
||||
<main className="pit-app-error">
|
||||
<p className="pit-muted" role="status" data-testid="surface-loading">
|
||||
{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…"}
|
||||
</p>
|
||||
</main>
|
||||
@@ -917,6 +979,95 @@ export function App() {
|
||||
onSelectSegment={setSegment}
|
||||
/>
|
||||
|
||||
{chat && embeddedEval === null ? (
|
||||
<section className="pit-harness-picker" aria-label="Chat provider and model">
|
||||
<label>
|
||||
<span>Provider</span>
|
||||
<select
|
||||
data-testid="chat-provider"
|
||||
value={harness.provider}
|
||||
disabled={streamingTurn}
|
||||
onChange={(event) => {
|
||||
const provider = event.target.value as CapabilityHarnessConfiguration["provider"];
|
||||
setHarness((current) => ({
|
||||
...defaultHarness(provider),
|
||||
lifecyclePolicy: current.lifecyclePolicy,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<option value="codex">Codex</option>
|
||||
<option value="opencode">OpenCode</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Execution</span>
|
||||
<select
|
||||
data-testid="chat-lifecycle-mode"
|
||||
value={harness.lifecyclePolicy.mode}
|
||||
disabled={streamingTurn}
|
||||
onChange={(event) => setHarness((current) => ({
|
||||
...current,
|
||||
lifecyclePolicy: event.target.value === "per_turn"
|
||||
? { mode: "per_turn", idleTimeoutMs: null }
|
||||
: { mode: "warm", idleTimeoutMs: 300_000 },
|
||||
}))}
|
||||
>
|
||||
<option value="warm">Warm session</option>
|
||||
<option value="per_turn">Turn by turn</option>
|
||||
</select>
|
||||
</label>
|
||||
{harness.lifecyclePolicy.mode === "warm" ? (
|
||||
<label>
|
||||
<span>Idle timeout (seconds)</span>
|
||||
<input
|
||||
data-testid="chat-idle-timeout"
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={Math.round(harness.lifecyclePolicy.idleTimeoutMs / 1_000)}
|
||||
disabled={streamingTurn}
|
||||
onChange={(event) => {
|
||||
const seconds = Math.max(1, Number.parseInt(event.target.value || "1", 10));
|
||||
setHarness((current) => ({
|
||||
...current,
|
||||
lifecyclePolicy: { mode: "warm", idleTimeoutMs: seconds * 1_000 },
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
<label className="pit-harness-model">
|
||||
<span>Model</span>
|
||||
<input
|
||||
data-testid="chat-model"
|
||||
list={`chat-models-${harness.provider}`}
|
||||
value={harness.model ?? ""}
|
||||
disabled={streamingTurn}
|
||||
placeholder={harness.provider === "opencode" ? "provider/model" : "model name"}
|
||||
onChange={(event) => setHarness((current) => ({ ...current, model: event.target.value }))}
|
||||
/>
|
||||
<datalist id={`chat-models-${harness.provider}`}>
|
||||
{MODEL_PRESETS[harness.provider].map((model) => <option key={model} value={model} />)}
|
||||
</datalist>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="pit-button"
|
||||
data-variant="primary"
|
||||
data-testid="chat-apply-harness"
|
||||
disabled={streamingTurn || historicSessionId !== null}
|
||||
onClick={newChat}
|
||||
>
|
||||
Start new chat
|
||||
</button>
|
||||
<span className="pit-harness-active" data-testid="chat-active-harness">
|
||||
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"}` : ""}
|
||||
</span>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{embeddedEval !== null ? (
|
||||
<nav className="pit-eval-nav" aria-label="Eval result navigation">
|
||||
<a href={embeddedEval.navigation.suiteHref}>← All results</a>
|
||||
@@ -1039,7 +1190,7 @@ export function App() {
|
||||
recorded yet.
|
||||
</p>
|
||||
<p className="pit-muted">
|
||||
Your first message starts a real Codex turn through real runnerd. Codex may
|
||||
Your first message starts a real {snapshot.identity.agentLabel.replace(/^Real /, "")} turn through real runnerd. The agent may
|
||||
call the semantic tools this session exposes, and every record it creates lands
|
||||
in the mock control plane only — never a real Paperclip API. Detailed tool,
|
||||
policy, event, and state evidence stays in the Evidence drawer until you open
|
||||
|
||||
@@ -142,7 +142,7 @@ export function DevtoolsInspector({ snapshot, onFork, tab, onTabChange, evalRepo
|
||||
<div className="pit-devtools-pane pit-eval-inspector">
|
||||
<div className="pit-eval-summary-head"><a href="../../index.html">← Eval suite</a><strong>{evalReport.passed ? "PASS" : evalReport.disposition.replaceAll("_", " ").toUpperCase()}</strong><code>{evalReport.attemptId}</code></div>
|
||||
<dl className="pit-eval-run-facts">
|
||||
<div><dt>Model</dt><dd>{evalReport.run.provider}/{evalReport.run.model}</dd></div>
|
||||
<div><dt>Model</dt><dd>{evalReport.run.model.startsWith(`${evalReport.run.provider}/`) ? evalReport.run.model : `${evalReport.run.provider}/${evalReport.run.model}`}</dd></div>
|
||||
<div><dt>Configuration</dt><dd>{evalReport.run.configuration}</dd></div>
|
||||
<div><dt>Session</dt><dd>{evalReport.run.sessionId}</dd></div>
|
||||
<div><dt>Duration</dt><dd>{evalReport.run.durationMs} ms</dd></div>
|
||||
|
||||
@@ -150,6 +150,147 @@ function ProgressItem({
|
||||
);
|
||||
}
|
||||
|
||||
function FileDiff({ diff }: { diff: string | null }) {
|
||||
if (diff === null) return <p className="pit-workspace-binary">Binary or oversized file; textual diff unavailable.</p>;
|
||||
return (
|
||||
<pre className="pit-workspace-diff" aria-label="Unified file diff">
|
||||
{diff.split("\n").map((line, index) => (
|
||||
<span
|
||||
key={`${index}:${line}`}
|
||||
data-diff-line={line.startsWith("+") && !line.startsWith("+++")
|
||||
? "addition"
|
||||
: line.startsWith("-") && !line.startsWith("---")
|
||||
? "deletion"
|
||||
: line.startsWith("@@")
|
||||
? "hunk"
|
||||
: "context"}
|
||||
>{line || " "}{"\n"}</span>
|
||||
))}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceChanges({
|
||||
item,
|
||||
}: {
|
||||
item: Extract<CapabilityThreadItem, { kind: "workspace_changes" }>;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||
const files = item.changeSet.files;
|
||||
const visible = expanded ? files : files.slice(0, 3);
|
||||
const selected = files.find((file) => file.path === selectedPath) ?? null;
|
||||
const stats = (file: (typeof files)[number]) => (
|
||||
<span className="pit-workspace-stats">
|
||||
{file.additions === null ? null : <span data-stat="add">+{file.additions}</span>}
|
||||
{file.deletions === null ? null : <span data-stat="delete">-{file.deletions}</span>}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<article id={item.id} className="pit-workspace-card" data-thread-item="workspace_changes">
|
||||
<header className="pit-workspace-head">
|
||||
<span className="pit-workspace-icon" aria-hidden="true">▣</span>
|
||||
<span>
|
||||
<strong>{item.changeSet.complete ? "Edited" : "Editing"} {files.length} file{files.length === 1 ? "" : "s"}</strong>
|
||||
<small>{item.changeSet.source === "runner_verified" ? "Verified from workspace" : "Reported by harness"}</small>
|
||||
</span>
|
||||
<span className="pit-workspace-total">
|
||||
{item.changeSet.totals.additions === null ? null : <span data-stat="add">+{item.changeSet.totals.additions}</span>}
|
||||
{item.changeSet.totals.deletions === null ? null : <span data-stat="delete">-{item.changeSet.totals.deletions}</span>}
|
||||
</span>
|
||||
<button type="button" className="pit-workspace-review" onClick={() => setSelectedPath(files[0]?.path ?? null)}>Review</button>
|
||||
</header>
|
||||
<div className="pit-workspace-files">
|
||||
{visible.map((file) => (
|
||||
<button type="button" key={`${file.operation}:${file.path}`} onClick={() => setSelectedPath(file.path)}>
|
||||
<span className="pit-workspace-path">
|
||||
{file.previousPath === null ? file.path : `${file.previousPath} → ${file.path}`}
|
||||
<small>{file.operation.replace("_", " ")}</small>
|
||||
</span>
|
||||
{stats(file)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{files.length > 3 ? (
|
||||
<button type="button" className="pit-workspace-more" onClick={() => setExpanded((value) => !value)}>
|
||||
{expanded ? "Show fewer files" : `Show ${files.length - 3} more files`} <span aria-hidden="true">⌄</span>
|
||||
</button>
|
||||
) : null}
|
||||
</article>
|
||||
{selected !== null ? (
|
||||
<div className="pit-dialog-backdrop" onMouseDown={(event) => { if (event.target === event.currentTarget) setSelectedPath(null); }}>
|
||||
<div className="pit-dialog pit-workspace-dialog" role="dialog" aria-modal="true" aria-labelledby="workspace-diff-title">
|
||||
<header className="pit-tool-dialog-head">
|
||||
<div>
|
||||
<span className="pit-card-meta">{selected.operation.replace("_", " ")} · workspace diff</span>
|
||||
<h2 id="workspace-diff-title">{selected.path}</h2>
|
||||
</div>
|
||||
{stats(selected)}
|
||||
<button type="button" className="pit-icon-button" aria-label="Close diff" onClick={() => setSelectedPath(null)}>×</button>
|
||||
</header>
|
||||
{files.length > 1 ? (
|
||||
<nav className="pit-workspace-file-nav" aria-label="Changed files">
|
||||
{files.map((file) => <button type="button" key={file.path} data-selected={file.path === selected.path} onClick={() => setSelectedPath(file.path)}>{file.path}</button>)}
|
||||
</nav>
|
||||
) : null}
|
||||
<FileDiff diff={selected.diff} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceFileReference({
|
||||
item,
|
||||
}: {
|
||||
item: Extract<CapabilityThreadItem, { kind: "workspace_file_reference" }>;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const extension = item.reference.path.includes(".")
|
||||
? item.reference.path.split(".").at(-1)?.toUpperCase()
|
||||
: "FILE";
|
||||
const presentation = item.reference.presentation === "document"
|
||||
? "Document"
|
||||
: item.reference.presentation === "code"
|
||||
? "Code"
|
||||
: item.reference.presentation === "image"
|
||||
? "Image"
|
||||
: "File";
|
||||
return (
|
||||
<>
|
||||
<article id={item.id} className="pit-file-reference-card" data-thread-item="workspace_file_reference">
|
||||
<span className="pit-file-reference-icon" aria-hidden="true">▤</span>
|
||||
<span className="pit-file-reference-copy">
|
||||
<strong>{item.reference.displayName}</strong>
|
||||
<small>{presentation} · {extension}{item.reference.line === null ? "" : ` · line ${item.reference.line}`}</small>
|
||||
</span>
|
||||
<button type="button" className="pit-workspace-review" onClick={() => setOpen(true)}>Open</button>
|
||||
</article>
|
||||
{open ? (
|
||||
<div className="pit-dialog-backdrop" onMouseDown={(event) => { if (event.target === event.currentTarget) setOpen(false); }}>
|
||||
<div className="pit-dialog pit-file-reference-dialog" role="dialog" aria-modal="true" aria-labelledby="workspace-file-title">
|
||||
<header className="pit-tool-dialog-head">
|
||||
<div><span className="pit-card-meta">{item.reference.path}</span><h2 id="workspace-file-title">{item.reference.displayName}</h2></div>
|
||||
<button type="button" className="pit-icon-button" aria-label="Close file" onClick={() => setOpen(false)}>×</button>
|
||||
</header>
|
||||
<div className="pit-file-reference-preview">
|
||||
{item.reference.preview === null
|
||||
? <p className="pit-workspace-binary">Preview unavailable. The reference remains recorded with its normalized workspace path.</p>
|
||||
: item.reference.presentation === "document"
|
||||
? <MarkdownBody>{item.reference.preview}</MarkdownBody>
|
||||
: <pre className="pit-code">{item.reference.preview}</pre>}
|
||||
{item.reference.previewTruncated ? <p className="pit-withheld-note">Preview truncated by the runner.</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ThreadItemView({
|
||||
item,
|
||||
callbacks,
|
||||
@@ -230,6 +371,12 @@ function ThreadItemView({
|
||||
case "progress_activity":
|
||||
return <ProgressItem item={item} />;
|
||||
|
||||
case "workspace_changes":
|
||||
return <WorkspaceChanges item={item} />;
|
||||
|
||||
case "workspace_file_reference":
|
||||
return <WorkspaceFileReference item={item} />;
|
||||
|
||||
case "denial":
|
||||
return (
|
||||
<div id={item.id} className="pit-strip pit-denial" data-status="denied" data-thread-item="denial">
|
||||
|
||||
@@ -700,6 +700,108 @@ textarea {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pit-workspace-card {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--pit-border);
|
||||
border-radius: var(--pit-radius-lg);
|
||||
background: var(--pit-surface);
|
||||
box-shadow: var(--pit-shadow-sm);
|
||||
}
|
||||
|
||||
.pit-workspace-head {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: var(--pit-space-3);
|
||||
padding: var(--pit-space-3) var(--pit-space-4);
|
||||
border-bottom: 1px solid var(--pit-border);
|
||||
}
|
||||
|
||||
.pit-workspace-head strong,
|
||||
.pit-workspace-head small,
|
||||
.pit-workspace-path small { display: block; }
|
||||
.pit-workspace-head small,
|
||||
.pit-workspace-path small,
|
||||
.pit-workspace-binary { color: var(--pit-muted-foreground); }
|
||||
.pit-workspace-icon { font-family: var(--pit-font-symbols); font-size: var(--pit-text-xl); }
|
||||
.pit-workspace-total,
|
||||
.pit-workspace-stats { display: flex; gap: var(--pit-space-2); font-family: var(--pit-font-mono); }
|
||||
[data-stat="add"] { color: var(--pit-success); }
|
||||
[data-stat="delete"] { color: var(--pit-danger); }
|
||||
|
||||
.pit-workspace-review,
|
||||
.pit-workspace-more,
|
||||
.pit-workspace-files button,
|
||||
.pit-workspace-file-nav button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pit-workspace-review {
|
||||
min-height: 2rem;
|
||||
padding: 0 var(--pit-space-3);
|
||||
border: 1px solid var(--pit-border-strong);
|
||||
border-radius: var(--pit-radius-md);
|
||||
background: var(--pit-surface-raised);
|
||||
}
|
||||
|
||||
.pit-workspace-files button {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--pit-space-4);
|
||||
min-height: 2.75rem;
|
||||
padding: var(--pit-space-2) var(--pit-space-4);
|
||||
text-align: left;
|
||||
}
|
||||
.pit-workspace-files button:hover,
|
||||
.pit-workspace-file-nav button:hover,
|
||||
.pit-workspace-file-nav button[data-selected="true"] { background: var(--pit-surface-raised); }
|
||||
.pit-workspace-path { min-width: 0; overflow-wrap: anywhere; font-family: var(--pit-font-mono); }
|
||||
.pit-workspace-path small { font-family: var(--pit-font-sans); }
|
||||
.pit-workspace-more { width: 100%; padding: var(--pit-space-2) var(--pit-space-4); border-top: 1px solid var(--pit-border); text-align: left; }
|
||||
|
||||
.pit-dialog.pit-workspace-dialog {
|
||||
width: min(70rem, calc(100vw - 2rem));
|
||||
max-width: 70rem;
|
||||
max-height: calc(100vh - 2rem);
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.pit-workspace-dialog > .pit-tool-dialog-head { padding: var(--pit-space-4); }
|
||||
.pit-workspace-file-nav { display: flex; max-height: 9rem; overflow: auto; border-bottom: 1px solid var(--pit-border); }
|
||||
.pit-workspace-file-nav button { padding: var(--pit-space-2) var(--pit-space-3); white-space: nowrap; font-family: var(--pit-font-mono); }
|
||||
.pit-workspace-diff { max-height: min(65vh, 44rem); margin: 0; overflow: auto; padding: var(--pit-space-4); background: var(--pit-surface-sunken); font-family: var(--pit-font-mono); font-size: var(--pit-text-xs); line-height: 1.55; }
|
||||
.pit-workspace-diff span { display: block; min-width: max-content; }
|
||||
.pit-workspace-diff [data-diff-line="addition"] { color: var(--pit-success); background: var(--pit-success-surface); }
|
||||
.pit-workspace-diff [data-diff-line="deletion"] { color: var(--pit-danger); background: var(--pit-danger-surface); }
|
||||
.pit-workspace-diff [data-diff-line="hunk"] { color: var(--pit-accent); }
|
||||
.pit-workspace-binary { padding: var(--pit-space-4); }
|
||||
|
||||
.pit-file-reference-card {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: var(--pit-space-3);
|
||||
padding: var(--pit-space-3);
|
||||
border: 1px solid var(--pit-border);
|
||||
border-radius: var(--pit-radius-lg);
|
||||
background: var(--pit-surface);
|
||||
box-shadow: var(--pit-shadow-sm);
|
||||
}
|
||||
.pit-file-reference-icon { display: grid; place-items: center; width: 2.75rem; height: 2.75rem; border-radius: var(--pit-radius-md); background: var(--pit-surface-raised); font-family: var(--pit-font-symbols); font-size: var(--pit-text-xl); }
|
||||
.pit-file-reference-copy { min-width: 0; }
|
||||
.pit-file-reference-copy strong,
|
||||
.pit-file-reference-copy small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.pit-file-reference-copy small { color: var(--pit-muted-foreground); }
|
||||
.pit-dialog.pit-file-reference-dialog { width: min(58rem, calc(100vw - 2rem)); max-width: 58rem; max-height: calc(100vh - 2rem); padding: 0; gap: 0; overflow: hidden; }
|
||||
.pit-file-reference-dialog > .pit-tool-dialog-head { padding: var(--pit-space-4); border-bottom: 1px solid var(--pit-border); }
|
||||
.pit-file-reference-preview { overflow: auto; padding: var(--pit-space-4); }
|
||||
.pit-file-reference-preview > .pit-code { max-height: 65vh; overflow: auto; white-space: pre; }
|
||||
|
||||
/*
|
||||
* Chat messages follow the familiar assistant flow: the board's message is a
|
||||
* compact bubble, while the assistant answer sits directly on the page. The
|
||||
@@ -2270,7 +2372,62 @@ textarea {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.pit-harness-picker {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
gap: var(--pit-space-3);
|
||||
padding: var(--pit-space-2) var(--pit-space-4);
|
||||
border-bottom: 1px solid var(--pit-border);
|
||||
background: var(--pit-surface-sunken);
|
||||
}
|
||||
|
||||
.pit-harness-picker label {
|
||||
display: grid;
|
||||
gap: var(--pit-space-1);
|
||||
color: var(--pit-muted-foreground);
|
||||
font-size: var(--pit-text-xs);
|
||||
}
|
||||
|
||||
.pit-harness-picker select,
|
||||
.pit-harness-picker input {
|
||||
box-sizing: border-box;
|
||||
min-height: var(--pit-touch-target);
|
||||
padding: 0 var(--pit-space-2);
|
||||
border: 1px solid var(--pit-border-strong);
|
||||
border-radius: var(--pit-radius-sm);
|
||||
background: var(--pit-surface-raised);
|
||||
}
|
||||
|
||||
.pit-harness-model {
|
||||
flex: 1;
|
||||
max-width: 38rem;
|
||||
}
|
||||
|
||||
.pit-harness-active {
|
||||
align-self: center;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--pit-faint-foreground);
|
||||
font-family: var(--pit-font-mono);
|
||||
font-size: var(--pit-text-xs);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.pit-harness-picker {
|
||||
align-items: stretch;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pit-harness-model {
|
||||
min-width: 12rem;
|
||||
}
|
||||
|
||||
.pit-harness-active {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.pit-header-primary {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
@@ -41,6 +41,14 @@ export interface CapabilityCleanRoomIdentity {
|
||||
identifier: string;
|
||||
}
|
||||
|
||||
export interface CapabilityHarnessConfiguration {
|
||||
provider: "codex" | "opencode";
|
||||
model: string | null;
|
||||
lifecyclePolicy:
|
||||
| { mode: "per_turn"; idleTimeoutMs: null }
|
||||
| { mode: "warm"; idleTimeoutMs: number };
|
||||
}
|
||||
|
||||
export interface CapabilityLiveResponse {
|
||||
sessionId: string;
|
||||
view: CapabilityIssueThreadSnapshot;
|
||||
@@ -49,6 +57,7 @@ export interface CapabilityLiveResponse {
|
||||
identity?: CapabilityCleanRoomIdentity;
|
||||
limits?: { maxTurns: number; maxMessageBytes: number };
|
||||
turns?: number;
|
||||
configuration?: CapabilityHarnessConfiguration;
|
||||
}
|
||||
|
||||
export interface CapabilityToolTestResponse extends CapabilityLiveResponse {
|
||||
@@ -160,15 +169,21 @@ export const capabilityLiveClient = {
|
||||
return post("/session", { scenario });
|
||||
},
|
||||
/** Reconnect to a clean room, or open one when there is nothing to resume. */
|
||||
async loadCleanRoom(sessionId: string | null): Promise<CapabilityLiveResponse> {
|
||||
const query = sessionId === null ? "" : `?sessionId=${encodeURIComponent(sessionId)}`;
|
||||
const response = await fetch(`${BASE}/cleanroom/session${query}`, { credentials: CREDENTIALS });
|
||||
async loadCleanRoom(sessionId: string | null, configuration: CapabilityHarnessConfiguration): Promise<CapabilityLiveResponse> {
|
||||
const query = new URLSearchParams({ provider: configuration.provider });
|
||||
if (sessionId !== null) query.set("sessionId", sessionId);
|
||||
if (configuration.model !== null) query.set("model", configuration.model);
|
||||
query.set("lifecycleMode", configuration.lifecyclePolicy.mode);
|
||||
if (configuration.lifecyclePolicy.mode === "warm") {
|
||||
query.set("idleTimeoutMs", String(configuration.lifecyclePolicy.idleTimeoutMs));
|
||||
}
|
||||
const response = await fetch(`${BASE}/cleanroom/session?${query.toString()}`, { credentials: CREDENTIALS });
|
||||
if (!response.ok) throw await readError(response, "/cleanroom/session");
|
||||
return (await response.json()) as CapabilityLiveResponse;
|
||||
},
|
||||
/** `New chat`: retire the current room and mint a new mock tenant. */
|
||||
newCleanRoom(sessionId: string | null): Promise<CapabilityLiveResponse> {
|
||||
return post("/cleanroom/session", sessionId === null ? {} : { sessionId });
|
||||
newCleanRoom(sessionId: string | null, configuration: CapabilityHarnessConfiguration): Promise<CapabilityLiveResponse> {
|
||||
return post("/cleanroom/session", { ...(sessionId === null ? {} : { sessionId }), ...configuration });
|
||||
},
|
||||
/**
|
||||
* Runs one turn. `onFrame` fires for every interim projection the server
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"paperclip-runner-eval-session": "./dist/cli/eval-session.js"
|
||||
"paperclip-runner-eval-session": "./dist/cli/eval-session.js",
|
||||
"paperclip-runner-opencode-proxy": "./dist/cli/opencode-app-server-proxy.js"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"type": {
|
||||
"enum": [
|
||||
"run.prepare",
|
||||
"run.attach",
|
||||
"session.open",
|
||||
"turn.start",
|
||||
"turn.steer",
|
||||
@@ -23,6 +24,7 @@
|
||||
"session.close",
|
||||
"run.cancel",
|
||||
"runner.drain",
|
||||
"runner.suspend",
|
||||
"runner.shutdown"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -28,8 +28,8 @@
|
||||
"itemId": { "type": "string", "minLength": 1, "maxLength": 160 },
|
||||
"eventType": {
|
||||
"enum": [
|
||||
"runner.connected", "runner.reconnected", "runner.disconnected", "runner.draining", "runner.diagnostic",
|
||||
"runtime.phase.changed", "sandbox.metric", "workspace.ready",
|
||||
"runner.connected", "runner.reconnected", "runner.reconciled", "runner.disconnected", "runner.draining", "runner.suspending", "runner.suspended", "runner.stopped", "runner.diagnostic",
|
||||
"runtime.phase.changed", "sandbox.metric", "workspace.ready", "workspace.change.updated", "workspace.diff.recorded", "workspace.file.referenced",
|
||||
"harness.starting", "harness.ready", "harness.exited", "harness.diagnostic",
|
||||
"session.starting", "session.started", "session.resuming", "session.resumed", "session.reconciled", "session.closed", "session.failed",
|
||||
"turn.submitted", "turn.accepted", "turn.started", "turn.completed", "turn.failed", "turn.interrupted", "turn.cancelled",
|
||||
@@ -37,7 +37,7 @@
|
||||
"mcp_app.discovered", "mcp_app.resource.resolved", "mcp_app.initializing", "mcp_app.ready", "mcp_app.tool_input", "mcp_app.tool_result", "mcp_app.action.requested", "mcp_app.action.resolved", "mcp_app.host_context.changed", "mcp_app.failed", "mcp_app.teardown",
|
||||
"runtime_request.created", "runtime_request.resolved", "runtime_request.expired", "runtime_request.cancelled",
|
||||
"interaction.request.proposed", "interaction.request.materialized", "interaction.request.rejected", "interaction.response.progressed", "interaction.response.resolved", "interaction.response.delivered",
|
||||
"run.result.proposed", "run.result.accepted", "run.result.rejected",
|
||||
"run.attached", "run.detached", "run.result.proposed", "run.result.accepted", "run.result.rejected",
|
||||
"attention.request.proposed", "attention.request.routed", "attention.request.resolved", "attention.request.expired", "attention.request.superseded",
|
||||
"work.assessment.recorded", "issue.status.decision.recorded", "issue.status.decision.applied", "issue.status.decision.rejected", "issue.status.decision.superseded",
|
||||
"run.terminal"
|
||||
@@ -47,10 +47,47 @@
|
||||
"priority": { "enum": [0, 1, 2] },
|
||||
"emittedAt": { "type": "string", "format": "date-time" },
|
||||
"observedAt": { "type": "string", "format": "date-time" },
|
||||
"payload": { "type": "object", "additionalProperties": true },
|
||||
"payload": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"channel": { "enum": ["progress", "final", "summary", "detail", "unknown"] },
|
||||
"providerPhase": { "type": "string" },
|
||||
"providerMethod": { "type": "string" }
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"debug": { "type": "object", "additionalProperties": true }
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": { "properties": { "eventType": { "const": "workspace.file.referenced" } } },
|
||||
"then": {
|
||||
"properties": {
|
||||
"payload": { "$ref": "https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json" }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": { "properties": { "eventType": { "enum": ["workspace.change.updated", "workspace.diff.recorded"] } } },
|
||||
"then": {
|
||||
"properties": {
|
||||
"payload": { "$ref": "https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json" }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": { "properties": { "eventType": { "const": "workspace.diff.recorded" } } },
|
||||
"then": {
|
||||
"properties": {
|
||||
"payload": {
|
||||
"allOf": [
|
||||
{ "$ref": "https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json" },
|
||||
{ "type": "object", "properties": { "complete": { "const": true } } }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": { "properties": { "eventType": { "const": "mcp_app.tool_input" } } },
|
||||
"then": {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json",
|
||||
"title": "Paperclip workspace diff",
|
||||
"type": "object",
|
||||
"required": ["schema", "changeSetId", "revision", "source", "complete", "files", "totals", "patchArtifactRef"],
|
||||
"properties": {
|
||||
"schema": { "const": "paperclip.workspace.diff.v1" },
|
||||
"changeSetId": { "type": "string", "minLength": 1, "maxLength": 200 },
|
||||
"revision": { "type": "integer", "minimum": 1 },
|
||||
"source": { "enum": ["harness_reported", "runner_verified"] },
|
||||
"complete": { "type": "boolean" },
|
||||
"files": {
|
||||
"type": "array",
|
||||
"maxItems": 2000,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["path", "operation", "previousPath", "additions", "deletions", "binary", "diff"],
|
||||
"properties": {
|
||||
"path": { "type": "string", "minLength": 1, "maxLength": 1024, "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$" },
|
||||
"operation": { "enum": ["create", "modify", "delete", "rename", "mode_change"] },
|
||||
"previousPath": {
|
||||
"oneOf": [
|
||||
{ "type": "null" },
|
||||
{ "type": "string", "minLength": 1, "maxLength": 1024, "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$" }
|
||||
]
|
||||
},
|
||||
"additions": { "type": ["integer", "null"], "minimum": 0 },
|
||||
"deletions": { "type": ["integer", "null"], "minimum": 0 },
|
||||
"binary": { "type": "boolean" },
|
||||
"diff": { "type": ["string", "null"], "maxLength": 262144 }
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"totals": {
|
||||
"type": "object",
|
||||
"required": ["files", "additions", "deletions"],
|
||||
"properties": {
|
||||
"files": { "type": "integer", "minimum": 0 },
|
||||
"additions": { "type": ["integer", "null"], "minimum": 0 },
|
||||
"deletions": { "type": ["integer", "null"], "minimum": 0 }
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"patchArtifactRef": { "type": ["string", "null"], "maxLength": 2048 }
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json",
|
||||
"title": "Paperclip workspace file reference",
|
||||
"type": "object",
|
||||
"required": ["schema", "referenceId", "source", "path", "displayName", "mediaType", "presentation", "line", "preview", "previewTruncated", "contentDigest"],
|
||||
"properties": {
|
||||
"schema": { "const": "paperclip.workspace.file_reference.v1" },
|
||||
"referenceId": { "type": "string", "minLength": 1, "maxLength": 240 },
|
||||
"source": { "enum": ["harness_reported", "runner_verified"] },
|
||||
"path": { "type": "string", "minLength": 1, "maxLength": 1024, "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$" },
|
||||
"displayName": { "type": "string", "minLength": 1, "maxLength": 255 },
|
||||
"mediaType": { "type": ["string", "null"], "maxLength": 160 },
|
||||
"presentation": { "enum": ["document", "code", "image", "generic"] },
|
||||
"line": { "type": ["integer", "null"], "minimum": 1 },
|
||||
"preview": { "type": ["string", "null"], "maxLength": 262144 },
|
||||
"previewTruncated": { "type": "boolean" },
|
||||
"contentDigest": {
|
||||
"oneOf": [
|
||||
{ "type": "null" },
|
||||
{ "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" }
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
+103
-9
@@ -1,8 +1,16 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::{self, BufRead, Write};
|
||||
use std::process::ExitCode;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct Mode {
|
||||
large_event: bool,
|
||||
oversized_event: bool,
|
||||
linger_after_turn_start: bool,
|
||||
}
|
||||
|
||||
fn send(value: Value) -> io::Result<()> {
|
||||
let mut stdout = io::stdout().lock();
|
||||
serde_json::to_writer(&mut stdout, &value)?;
|
||||
@@ -11,6 +19,14 @@ fn send(value: Value) -> io::Result<()> {
|
||||
}
|
||||
|
||||
fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args = std::env::args().skip(1).collect::<Vec<_>>();
|
||||
let mode = Mode {
|
||||
large_event: args.iter().any(|arg| arg == "--large-event"),
|
||||
oversized_event: args.iter().any(|arg| arg == "--oversized-event"),
|
||||
linger_after_turn_start: args.iter().any(|arg| arg == "--linger-after-turn-start"),
|
||||
};
|
||||
let mut turn_count = 0_u64;
|
||||
let mut pending_tool_turns = BTreeMap::<String, String>::new();
|
||||
let stdin = io::stdin();
|
||||
for line in stdin.lock().lines() {
|
||||
let request: Value = serde_json::from_str(&line?)?;
|
||||
@@ -25,7 +41,7 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
"initialize" => {
|
||||
send(json!({"id": id, "result": {"user": {"sessionId": "fake-session"}}}))?
|
||||
}
|
||||
"thread/start" => {
|
||||
"thread/start" | "thread/resume" => {
|
||||
let tools = request
|
||||
.pointer("/params/dynamicTools")
|
||||
.and_then(Value::as_array)
|
||||
@@ -36,24 +52,102 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}) {
|
||||
return Err("authorized tool was not registered".into());
|
||||
}
|
||||
let config = request.pointer("/params/config").and_then(Value::as_object);
|
||||
for key in [
|
||||
"skills.include_instructions",
|
||||
"include_apps_instructions",
|
||||
"include_collaboration_mode_instructions",
|
||||
"features.apps",
|
||||
"features.plugins",
|
||||
"features.multi_agent",
|
||||
"features.memories",
|
||||
"features.image_generation",
|
||||
] {
|
||||
if config
|
||||
.and_then(|value| value.get(key))
|
||||
.and_then(Value::as_bool)
|
||||
!= Some(false)
|
||||
{
|
||||
return Err(format!("skillless thread config omitted {key}=false").into());
|
||||
}
|
||||
}
|
||||
send(
|
||||
json!({"id": id, "result": {"thread": {"id": "fake-thread", "sessionId": "fake-session"}}}),
|
||||
)?;
|
||||
}
|
||||
"turn/start" => {
|
||||
send(json!({"id": id, "result": {"turn": {"id": "fake-turn"}}}))?;
|
||||
"thread/read" => {
|
||||
send(json!({
|
||||
"id": "rpc-tool-1", "method": "item/tool/call",
|
||||
"params": {"threadId": "fake-thread", "turnId": "fake-turn", "callId": "call-1", "tool": "get_task_context", "arguments": {}}
|
||||
"id": id,
|
||||
"result": {
|
||||
"thread": {
|
||||
"id": "fake-thread",
|
||||
"sessionId": "fake-session",
|
||||
"turns": [],
|
||||
"tokenUsage": {"total": {"inputTokens": 0, "cachedInputTokens": 0, "outputTokens": 0, "reasoningOutputTokens": 0}}
|
||||
}
|
||||
}
|
||||
}))?;
|
||||
}
|
||||
"turn/start" => {
|
||||
turn_count += 1;
|
||||
let turn_id = if turn_count == 1 {
|
||||
"fake-turn".to_owned()
|
||||
} else {
|
||||
format!("fake-turn-{turn_count}")
|
||||
};
|
||||
let call_id = format!("call-{turn_count}");
|
||||
let request_id = format!("rpc-tool-{turn_count}");
|
||||
send(json!({"id": id, "result": {"turn": {"id": turn_id}}}))?;
|
||||
send(json!({
|
||||
"method": "turn/started",
|
||||
"params": {"threadId": "fake-thread", "turn": {"id": turn_id, "status": "inProgress"}}
|
||||
}))?;
|
||||
if mode.large_event || mode.oversized_event {
|
||||
let bytes = if mode.oversized_event {
|
||||
4 * 1024 * 1024 + 128
|
||||
} else {
|
||||
1_200_000
|
||||
};
|
||||
send(json!({
|
||||
"method": "item/completed",
|
||||
"params": {
|
||||
"threadId": "fake-thread",
|
||||
"turnId": turn_id,
|
||||
"item": {"id": "large-item", "type": "image", "imageUrl": format!("data:image/png;base64,{}", "A".repeat(bytes))}
|
||||
}
|
||||
}))?;
|
||||
}
|
||||
if mode.linger_after_turn_start {
|
||||
continue;
|
||||
}
|
||||
pending_tool_turns.insert(request_id.clone(), turn_id.clone());
|
||||
send(json!({
|
||||
"id": request_id, "method": "item/tool/call",
|
||||
"params": {"threadId": "fake-thread", "turnId": turn_id, "callId": call_id, "tool": "get_task_context", "arguments": {}}
|
||||
}))?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if request.get("id").and_then(Value::as_str) == Some("rpc-tool-1")
|
||||
&& request.get("result").is_some()
|
||||
{
|
||||
let completed_turn = request
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|request_id| pending_tool_turns.remove(request_id));
|
||||
if let Some(turn_id) = completed_turn.filter(|_| request.get("result").is_some()) {
|
||||
send(json!({
|
||||
"method": "paperclip/runResult",
|
||||
"params": {
|
||||
"threadId": "fake-thread",
|
||||
"turnId": turn_id,
|
||||
"itemId": "semantic-result",
|
||||
"result": {
|
||||
"schema": "paperclip.prp.result.v1",
|
||||
"reportedWorkDisposition": "done",
|
||||
"summary": "Fake provider completed the operation."
|
||||
}
|
||||
}
|
||||
}))?;
|
||||
send(
|
||||
json!({"method": "turn/completed", "params": {"threadId": "fake-thread", "turn": {"id": "fake-turn", "status": "completed"}}}),
|
||||
json!({"method": "turn/completed", "params": {"threadId": "fake-thread", "turn": {"id": turn_id, "status": "completed"}}}),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,13 @@ fn duration_value(args: &[String], name: &str, default: u64) -> Result<Duration,
|
||||
))
|
||||
}
|
||||
|
||||
fn optional_duration_value(
|
||||
args: &[String],
|
||||
name: &str,
|
||||
) -> Result<Option<Duration>, LocalRunnerError> {
|
||||
Ok(optional_u64(args, name)?.map(Duration::from_millis))
|
||||
}
|
||||
|
||||
fn repeated_values(args: &[String], name: &str) -> Result<Vec<String>, LocalRunnerError> {
|
||||
let mut values = Vec::new();
|
||||
let mut index = 0;
|
||||
@@ -189,6 +196,35 @@ fn run_durable_mode(
|
||||
args: &[String],
|
||||
bootstrap_ticket: Option<BootstrapTicket>,
|
||||
) -> Result<(), LocalRunnerError> {
|
||||
let lifecycle_mode = args
|
||||
.iter()
|
||||
.position(|argument| argument == "--lifecycle-mode")
|
||||
.map(|index| {
|
||||
args.get(index + 1)
|
||||
.cloned()
|
||||
.ok_or_else(|| LocalRunnerError::invalid("missing value for --lifecycle-mode"))
|
||||
})
|
||||
.transpose()?
|
||||
.unwrap_or_else(|| "per_turn".to_owned());
|
||||
if lifecycle_mode != "per_turn" && lifecycle_mode != "warm" {
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"--lifecycle-mode must be per_turn or warm",
|
||||
));
|
||||
}
|
||||
let idle_timeout = optional_duration_value(args, "--idle-timeout-ms")?;
|
||||
if lifecycle_mode == "warm" && idle_timeout.is_none() {
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"warm lifecycle requires --idle-timeout-ms",
|
||||
));
|
||||
}
|
||||
if lifecycle_mode == "per_turn" && idle_timeout.is_some() {
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"per_turn lifecycle does not accept --idle-timeout-ms",
|
||||
));
|
||||
}
|
||||
let max_runtime = optional_duration_value(args, "--max-lifetime-ms")?
|
||||
.or(optional_duration_value(args, "--max-runtime-ms")?)
|
||||
.unwrap_or(Duration::MAX);
|
||||
let config = DurableRunnerConfig {
|
||||
connect_url: value(args, "--connect-url")?,
|
||||
state_dir: PathBuf::from(value(args, "--state-dir")?),
|
||||
@@ -214,7 +250,9 @@ fn run_durable_mode(
|
||||
p0_reserve_bytes: usize_value(args, "--p0-reserve-bytes", 32 * 1024)?,
|
||||
max_frame_bytes: usize_value(args, "--max-frame-bytes", 1024 * 1024)?,
|
||||
reconnect_delay: duration_value(args, "--reconnect-delay-ms", 25)?,
|
||||
max_runtime: duration_value(args, "--max-runtime-ms", 15_000)?,
|
||||
max_runtime,
|
||||
lifecycle_mode,
|
||||
idle_timeout,
|
||||
};
|
||||
let bootstrap_ticket = bootstrap_ticket
|
||||
.ok_or_else(|| LocalRunnerError::invalid("runner bootstrap ticket is not available"))?;
|
||||
|
||||
@@ -9,9 +9,34 @@ use crate::local_runner::LocalRunnerError;
|
||||
use crate::process_supervisor::SupervisedProcess;
|
||||
use crate::provider_bridge::{AuthorizedTool, ToolResult};
|
||||
|
||||
pub const CODEX_APP_SERVER_MAX_FRAME_BYTES: usize = 4 * 1024 * 1024;
|
||||
|
||||
fn skillless_thread_config() -> Value {
|
||||
json!({
|
||||
"skills.include_instructions": false,
|
||||
"include_apps_instructions": false,
|
||||
"include_collaboration_mode_instructions": false,
|
||||
"features.apps": false,
|
||||
"features.plugins": false,
|
||||
"features.multi_agent": false,
|
||||
"features.memories": false,
|
||||
"features.image_generation": false,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ProviderKind {
|
||||
#[default]
|
||||
Codex,
|
||||
Opencode,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CodexProviderConfig {
|
||||
#[serde(default)]
|
||||
pub kind: ProviderKind,
|
||||
pub command: PathBuf,
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
@@ -20,6 +45,19 @@ pub struct CodexProviderConfig {
|
||||
pub instructions: String,
|
||||
}
|
||||
|
||||
pub trait Provider {
|
||||
fn kind(&self) -> ProviderKind;
|
||||
fn process_id(&self) -> u32;
|
||||
fn session_identity(&self) -> &str;
|
||||
fn provider_session_id(&self) -> Option<&str>;
|
||||
fn start_turn(&mut self, message: &str, cwd: &str) -> Result<Value, LocalRunnerError>;
|
||||
fn interrupt_turn(&mut self, turn_id: &str) -> Result<Value, LocalRunnerError>;
|
||||
fn read(&mut self) -> Result<Value, LocalRunnerError>;
|
||||
fn poll(&mut self) -> Result<Option<CodexProviderEvent>, LocalRunnerError>;
|
||||
fn deliver_tool_result(&mut self, result: &ToolResult) -> Result<(), LocalRunnerError>;
|
||||
fn shutdown(&mut self) -> Result<(), LocalRunnerError>;
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum CodexProviderEvent {
|
||||
ToolCall {
|
||||
@@ -31,10 +69,15 @@ pub enum CodexProviderEvent {
|
||||
method: String,
|
||||
params: Value,
|
||||
},
|
||||
SemanticResult {
|
||||
result: Value,
|
||||
item_id: Option<String>,
|
||||
},
|
||||
Exited,
|
||||
}
|
||||
|
||||
pub struct CodexProvider {
|
||||
kind: ProviderKind,
|
||||
process: SupervisedProcess,
|
||||
next_request_id: u64,
|
||||
thread_id: String,
|
||||
@@ -53,11 +96,12 @@ impl CodexProvider {
|
||||
resume_thread_id: Option<&str>,
|
||||
) -> Result<Self, LocalRunnerError> {
|
||||
let mut provider = Self {
|
||||
kind: config.kind,
|
||||
process: SupervisedProcess::spawn(
|
||||
Path::new(&config.command),
|
||||
&config.args,
|
||||
Duration::from_secs(2),
|
||||
1024 * 1024,
|
||||
CODEX_APP_SERVER_MAX_FRAME_BYTES,
|
||||
)?,
|
||||
next_request_id: 1,
|
||||
thread_id: String::new(),
|
||||
@@ -86,6 +130,9 @@ impl CodexProvider {
|
||||
"cwd": config.cwd,
|
||||
"model": config.model,
|
||||
"approvalPolicy": "never",
|
||||
"permissions": "paperclip-runner-workspace-only",
|
||||
"runtimeWorkspaceRoots": [config.cwd],
|
||||
"config": skillless_thread_config(),
|
||||
"baseInstructions": config.instructions,
|
||||
"dynamicTools": dynamic_tools,
|
||||
"experimentalRawEvents": true,
|
||||
@@ -99,6 +146,9 @@ impl CodexProvider {
|
||||
"cwd": config.cwd,
|
||||
"model": config.model,
|
||||
"approvalPolicy": "never",
|
||||
"permissions": "paperclip-runner-workspace-only",
|
||||
"runtimeWorkspaceRoots": [config.cwd],
|
||||
"config": skillless_thread_config(),
|
||||
"baseInstructions": config.instructions,
|
||||
"dynamicTools": dynamic_tools,
|
||||
"experimentalRawEvents": true,
|
||||
@@ -115,7 +165,11 @@ impl CodexProvider {
|
||||
provider.session_id = opened
|
||||
.pointer("/thread/sessionId")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| initialized.pointer("/user/sessionId").and_then(Value::as_str))
|
||||
.or_else(|| {
|
||||
initialized
|
||||
.pointer("/user/sessionId")
|
||||
.and_then(Value::as_str)
|
||||
})
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_owned);
|
||||
Ok(provider)
|
||||
@@ -198,6 +252,19 @@ impl CodexProvider {
|
||||
}));
|
||||
}
|
||||
if let Some(method) = message.get("method").and_then(Value::as_str) {
|
||||
if method == "paperclip/runResult" {
|
||||
let params = message.get("params").cloned().unwrap_or(Value::Null);
|
||||
let result = params.get("result").cloned().ok_or_else(|| {
|
||||
LocalRunnerError::invalid("OpenCode semantic result omitted result")
|
||||
})?;
|
||||
return Ok(Some(CodexProviderEvent::SemanticResult {
|
||||
result,
|
||||
item_id: params
|
||||
.get("itemId")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned),
|
||||
}));
|
||||
}
|
||||
return Ok(Some(CodexProviderEvent::Notification {
|
||||
method: method.to_owned(),
|
||||
params: message.get("params").cloned().unwrap_or(Value::Null),
|
||||
@@ -256,3 +323,36 @@ impl CodexProvider {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Provider for CodexProvider {
|
||||
fn kind(&self) -> ProviderKind {
|
||||
self.kind
|
||||
}
|
||||
fn process_id(&self) -> u32 {
|
||||
CodexProvider::process_id(self)
|
||||
}
|
||||
fn session_identity(&self) -> &str {
|
||||
self.thread_id()
|
||||
}
|
||||
fn provider_session_id(&self) -> Option<&str> {
|
||||
self.session_id()
|
||||
}
|
||||
fn start_turn(&mut self, message: &str, cwd: &str) -> Result<Value, LocalRunnerError> {
|
||||
CodexProvider::start_turn(self, message, cwd)
|
||||
}
|
||||
fn interrupt_turn(&mut self, turn_id: &str) -> Result<Value, LocalRunnerError> {
|
||||
CodexProvider::interrupt_turn(self, turn_id)
|
||||
}
|
||||
fn read(&mut self) -> Result<Value, LocalRunnerError> {
|
||||
self.read_thread()
|
||||
}
|
||||
fn poll(&mut self) -> Result<Option<CodexProviderEvent>, LocalRunnerError> {
|
||||
CodexProvider::poll(self)
|
||||
}
|
||||
fn deliver_tool_result(&mut self, result: &ToolResult) -> Result<(), LocalRunnerError> {
|
||||
CodexProvider::deliver_tool_result(self, result)
|
||||
}
|
||||
fn shutdown(&mut self) -> Result<(), LocalRunnerError> {
|
||||
CodexProvider::shutdown(self)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,9 @@ pub fn run_durable_runner(
|
||||
}
|
||||
let store = DurableStateStore::new(&config.state_dir)?;
|
||||
let (mut state, recovered) = store.load_or_create(&config)?;
|
||||
if state.last_activity_at_unix_ms == 0 {
|
||||
state.last_activity_at_unix_ms = current_unix_ms()?;
|
||||
}
|
||||
if recovered && state.lifecycle == "revoked" {
|
||||
// Revocation is durable and terminal. Do not normalize, rewrite, start
|
||||
// a provider, resolve a destination, or consume the fresh bootstrap.
|
||||
@@ -20,7 +23,13 @@ pub fn run_durable_runner(
|
||||
state.lifecycle = "ready".to_owned();
|
||||
state.record_diagnostic("resuming an explicitly suspended durable runner");
|
||||
}
|
||||
let mut provider = start_configured_provider(&mut state)?;
|
||||
let mut provider = match start_configured_provider(&mut state) {
|
||||
Ok(provider) => provider,
|
||||
Err(error) => {
|
||||
persist_provider_failure(&mut state, &store, &error.to_string())?;
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
if recovered {
|
||||
if let Some(runtime) = provider.as_ref() {
|
||||
enqueue_event(
|
||||
@@ -29,10 +38,10 @@ pub fn run_durable_runner(
|
||||
"provider.started",
|
||||
0,
|
||||
json!({
|
||||
"provider": "codex",
|
||||
"provider": match runtime.kind() { crate::codex_provider::ProviderKind::Codex => "codex", crate::codex_provider::ProviderKind::Opencode => "opencode" },
|
||||
"pid": runtime.process_id(),
|
||||
"threadId": runtime.thread_id(),
|
||||
"sessionId": runtime.session_id(),
|
||||
"threadId": runtime.session_identity(),
|
||||
"sessionId": runtime.provider_session_id(),
|
||||
"resumed": true,
|
||||
}),
|
||||
None,
|
||||
@@ -70,10 +79,10 @@ pub fn run_durable_runner(
|
||||
let started = Instant::now();
|
||||
|
||||
loop {
|
||||
if started.elapsed() > config.max_runtime {
|
||||
if started.elapsed() >= config.max_runtime && !state.active_turn {
|
||||
state.recoverable_failure = Some("transport_reconnect_deadline_exceeded".to_owned());
|
||||
state.lifecycle = "recoverable_failure".to_owned();
|
||||
state.record_diagnostic("transport reconnect deadline exceeded");
|
||||
state.record_diagnostic("transport reconnect deadline exceeded before a safe rotation could flush");
|
||||
store.save(&state)?;
|
||||
return Err(DurableRunnerError::invalid(
|
||||
"transport reconnect deadline exceeded; durable state is preserved",
|
||||
@@ -221,10 +230,27 @@ pub fn run_durable_runner(
|
||||
let mut revoke_deadline: Option<Instant> = None;
|
||||
let disconnected = loop {
|
||||
poll_provider(&mut provider, &mut state, &store, &config)?;
|
||||
if started.elapsed() >= config.max_runtime && !state.active_turn {
|
||||
begin_automatic_suspend(
|
||||
&mut state,
|
||||
&store,
|
||||
&config,
|
||||
&mut provider,
|
||||
"max_lifetime_rotation",
|
||||
)?;
|
||||
}
|
||||
if should_suspend_for_idle(&state)? {
|
||||
begin_automatic_suspend(&mut state, &store, &config, &mut provider, "idle_timeout")?;
|
||||
}
|
||||
if provider.is_some() {
|
||||
send_outbox(&mut client, &state, &mut sent_source_seq)?;
|
||||
}
|
||||
if (state.stop_after_flush || state.lifecycle == "revoked") && state.outbox.is_empty() {
|
||||
if let Some(mut runtime) = provider.take() {
|
||||
runtime.shutdown().map_err(|error| {
|
||||
DurableRunnerError::invalid(format!("failed to stop provider during durable exit: {error}"))
|
||||
})?;
|
||||
}
|
||||
state.lifecycle = if state.lifecycle == "revoked" {
|
||||
"revoked".to_owned()
|
||||
} else if state.lifecycle == "suspending" {
|
||||
@@ -292,6 +318,7 @@ pub fn run_durable_runner(
|
||||
DurableRunnerError::invalid("ACK cursor is required")
|
||||
})?;
|
||||
state.apply_ack(acked)?;
|
||||
state.last_activity_at_unix_ms = current_unix_ms()?;
|
||||
store.save(&state)?;
|
||||
}
|
||||
Some("command") => {
|
||||
@@ -306,6 +333,7 @@ pub fn run_durable_runner(
|
||||
command,
|
||||
) {
|
||||
Ok(processed) => {
|
||||
state.last_activity_at_unix_ms = current_unix_ms()?;
|
||||
let delivery = client
|
||||
.send_json(&command_result_envelope(&state, &processed))
|
||||
.and_then(|()| send_outbox(&mut client, &state, &mut sent_source_seq));
|
||||
@@ -414,11 +442,11 @@ pub fn run_durable_runner(
|
||||
|
||||
fn start_configured_provider(
|
||||
state: &mut DurableRunnerState,
|
||||
) -> Result<Option<crate::codex_provider::CodexProvider>, DurableRunnerError> {
|
||||
let Some(provider_config) = state.codex_provider_config.clone() else {
|
||||
) -> Result<Option<Box<dyn crate::codex_provider::Provider>>, DurableRunnerError> {
|
||||
let Some(provider_config) = state.provider_config.clone() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let resume_thread_id = state.codex_provider_thread_id.clone();
|
||||
let resume_thread_id = state.provider_session_id.clone();
|
||||
let tools = state
|
||||
.provider_tool_bridge
|
||||
.authorized_tools()
|
||||
@@ -430,8 +458,8 @@ fn start_configured_provider(
|
||||
resume_thread_id.as_deref(),
|
||||
)
|
||||
.map(|provider| {
|
||||
state.codex_provider_thread_id = Some(provider.thread_id().to_owned());
|
||||
Some(provider)
|
||||
state.provider_session_id = Some(provider.thread_id().to_owned());
|
||||
Some(Box::new(provider) as Box<dyn crate::codex_provider::Provider>)
|
||||
})
|
||||
.map_err(|error| {
|
||||
DurableRunnerError::invalid(format!("failed to start Codex provider: {error}"))
|
||||
@@ -442,7 +470,7 @@ fn process_command_and_provider(
|
||||
state: &mut DurableRunnerState,
|
||||
store: &DurableStateStore,
|
||||
config: &DurableRunnerConfig,
|
||||
provider: &mut Option<crate::codex_provider::CodexProvider>,
|
||||
provider: &mut Option<Box<dyn crate::codex_provider::Provider>>,
|
||||
command: &Value,
|
||||
) -> Result<ProcessedCommand, DurableRunnerError> {
|
||||
let command_id = command
|
||||
@@ -465,10 +493,10 @@ fn process_command_and_provider(
|
||||
"provider.started",
|
||||
0,
|
||||
json!({
|
||||
"provider": "codex",
|
||||
"provider": match runtime.kind() { crate::codex_provider::ProviderKind::Codex => "codex", crate::codex_provider::ProviderKind::Opencode => "opencode" },
|
||||
"pid": runtime.process_id(),
|
||||
"threadId": runtime.thread_id(),
|
||||
"sessionId": runtime.session_id(),
|
||||
"threadId": runtime.session_identity(),
|
||||
"sessionId": runtime.provider_session_id(),
|
||||
"resumed": state.reconnect_count > 0,
|
||||
}),
|
||||
None,
|
||||
@@ -490,7 +518,7 @@ fn process_command_and_provider(
|
||||
DurableRunnerError::invalid("turn.start payload.text is required")
|
||||
})?;
|
||||
let cwd = state
|
||||
.codex_provider_config
|
||||
.provider_config
|
||||
.as_ref()
|
||||
.map(|value| value.cwd.as_str())
|
||||
.unwrap_or(".");
|
||||
@@ -498,6 +526,7 @@ fn process_command_and_provider(
|
||||
DurableRunnerError::invalid(format!("Codex turn.start failed: {error}"))
|
||||
})?;
|
||||
}
|
||||
Some("run.attach") => {}
|
||||
Some("semantic_tool.result") => {
|
||||
let result: crate::provider_bridge::ToolResult =
|
||||
serde_json::from_value(command["payload"].clone()).map_err(|error| {
|
||||
@@ -536,7 +565,7 @@ fn process_command_and_provider(
|
||||
.ok_or_else(|| {
|
||||
DurableRunnerError::invalid("provider.thread.read requires a provider")
|
||||
})?
|
||||
.read_thread()
|
||||
.read()
|
||||
.map_err(|error| {
|
||||
DurableRunnerError::invalid(format!("Codex thread/read failed: {error}"))
|
||||
})?;
|
||||
@@ -565,7 +594,7 @@ fn process_command_and_provider(
|
||||
}
|
||||
|
||||
fn poll_provider(
|
||||
provider: &mut Option<crate::codex_provider::CodexProvider>,
|
||||
provider: &mut Option<Box<dyn crate::codex_provider::Provider>>,
|
||||
state: &mut DurableRunnerState,
|
||||
store: &DurableStateStore,
|
||||
config: &DurableRunnerConfig,
|
||||
@@ -573,16 +602,25 @@ fn poll_provider(
|
||||
let Some(runtime) = provider.as_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
while let Some(event) = runtime
|
||||
.poll()
|
||||
.map_err(|error| DurableRunnerError::invalid(format!("Codex provider failed: {error}")))?
|
||||
{
|
||||
loop {
|
||||
let event = match runtime.poll() {
|
||||
Ok(Some(event)) => event,
|
||||
Ok(None) => break,
|
||||
Err(error) => {
|
||||
let detail = error.to_string();
|
||||
persist_provider_failure(state, store, &detail)?;
|
||||
return Err(DurableRunnerError::invalid(format!(
|
||||
"Codex provider failed: {detail}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
match event {
|
||||
crate::codex_provider::CodexProviderEvent::ToolCall {
|
||||
call_id,
|
||||
operation_id,
|
||||
input,
|
||||
} => {
|
||||
state.last_activity_at_unix_ms = current_unix_ms()?;
|
||||
let call = state
|
||||
.provider_tool_bridge
|
||||
.begin_call(call_id, operation_id, input)
|
||||
@@ -601,11 +639,12 @@ fn poll_provider(
|
||||
"callId": call.call_id, "input": call.input,
|
||||
}
|
||||
}),
|
||||
Some(&config.item_id),
|
||||
Some(&state.item_id.clone()),
|
||||
)?;
|
||||
store.save(state)?;
|
||||
}
|
||||
crate::codex_provider::CodexProviderEvent::Notification { method, params } => {
|
||||
state.last_activity_at_unix_ms = current_unix_ms()?;
|
||||
let event_type = if method == "turn/completed" {
|
||||
"turn.completed"
|
||||
} else {
|
||||
@@ -624,29 +663,174 @@ fn poll_provider(
|
||||
event_type,
|
||||
priority,
|
||||
json!({"method": method, "params": params}),
|
||||
Some(&config.item_id),
|
||||
Some(&state.item_id.clone()),
|
||||
)?;
|
||||
if method == "turn/completed" {
|
||||
state.active_turn = false;
|
||||
if state.lifecycle_mode == "warm" {
|
||||
state.lifecycle = "warm_idle".to_owned();
|
||||
} else {
|
||||
state.lifecycle = "suspending".to_owned();
|
||||
state.stop_after_flush = true;
|
||||
enqueue_event(
|
||||
state,
|
||||
config,
|
||||
"runner.suspending",
|
||||
0,
|
||||
json!({ "reason": "per_turn_complete", "resumable": true }),
|
||||
None,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
store.save(state)?;
|
||||
}
|
||||
crate::codex_provider::CodexProviderEvent::SemanticResult {
|
||||
result,
|
||||
item_id,
|
||||
} => {
|
||||
state.last_activity_at_unix_ms = current_unix_ms()?;
|
||||
enqueue_event(
|
||||
state,
|
||||
config,
|
||||
"run.result.proposed",
|
||||
0,
|
||||
result,
|
||||
item_id.as_deref(),
|
||||
)?;
|
||||
store.save(state)?;
|
||||
}
|
||||
crate::codex_provider::CodexProviderEvent::Exited => {
|
||||
state.recoverable_failure = Some("provider_exited".to_owned());
|
||||
state.lifecycle = "recoverable_failure".to_owned();
|
||||
store.save(state)?;
|
||||
return Err(DurableRunnerError::invalid("Codex provider exited"));
|
||||
persist_provider_failure(state, store, "Codex provider exited unexpectedly")?;
|
||||
return Err(DurableRunnerError::invalid(
|
||||
"native_runner_process_exited: Codex provider exited unexpectedly",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn should_suspend_for_idle(state: &DurableRunnerState) -> Result<bool, DurableRunnerError> {
|
||||
should_suspend_for_idle_at(state, current_unix_ms()?)
|
||||
}
|
||||
|
||||
fn should_suspend_for_idle_at(state: &DurableRunnerState, now_unix_ms: u64) -> Result<bool, DurableRunnerError> {
|
||||
if state.lifecycle_mode != "warm"
|
||||
|| state.stop_after_flush
|
||||
|| !matches!(state.lifecycle.as_str(), "warm_idle" | "waiting_input")
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
let Some(timeout_ms) = state.idle_timeout_ms else {
|
||||
return Ok(false);
|
||||
};
|
||||
Ok(now_unix_ms.saturating_sub(state.last_activity_at_unix_ms) >= timeout_ms)
|
||||
}
|
||||
|
||||
fn begin_automatic_suspend(
|
||||
state: &mut DurableRunnerState,
|
||||
store: &DurableStateStore,
|
||||
config: &DurableRunnerConfig,
|
||||
provider: &mut Option<Box<dyn crate::codex_provider::Provider>>,
|
||||
reason: &str,
|
||||
) -> Result<(), DurableRunnerError> {
|
||||
if state.stop_after_flush {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(mut runtime) = provider.take() {
|
||||
runtime.shutdown().map_err(|error| {
|
||||
DurableRunnerError::invalid(format!("failed to checkpoint provider for suspension: {error}"))
|
||||
})?;
|
||||
}
|
||||
state.lifecycle = "suspending".to_owned();
|
||||
state.stop_after_flush = true;
|
||||
enqueue_event(
|
||||
state,
|
||||
config,
|
||||
"runner.suspending",
|
||||
0,
|
||||
json!({ "reason": reason, "afterDurableFlush": true, "resumable": true }),
|
||||
None,
|
||||
)?;
|
||||
enqueue_event(
|
||||
state,
|
||||
config,
|
||||
"runner.suspended",
|
||||
0,
|
||||
json!({ "reason": reason, "resumable": true }),
|
||||
None,
|
||||
)?;
|
||||
store.save(state)
|
||||
}
|
||||
|
||||
fn provider_failure_code(detail: &str) -> &'static str {
|
||||
if detail.contains("stdout frame exceeded") || detail.contains("provider_frame_too_large") {
|
||||
"provider_frame_too_large"
|
||||
} else {
|
||||
"provider_transport_failed"
|
||||
}
|
||||
}
|
||||
|
||||
fn persist_provider_failure(
|
||||
state: &mut DurableRunnerState,
|
||||
store: &DurableStateStore,
|
||||
detail: &str,
|
||||
) -> Result<(), DurableRunnerError> {
|
||||
let code = provider_failure_code(detail);
|
||||
state.recoverable_failure = Some(code.to_owned());
|
||||
state.lifecycle = "recoverable_failure".to_owned();
|
||||
state.record_diagnostic(format!("{code}: {detail}"));
|
||||
store.save(state)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::codex_provider::{CodexProviderEvent, Provider, ProviderKind};
|
||||
use crate::local_runner::LocalRunnerError;
|
||||
use crate::provider_bridge::ToolResult;
|
||||
use std::net::TcpListener;
|
||||
use std::sync::{mpsc, Mutex};
|
||||
|
||||
static ENVIRONMENT_TEST_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
struct OversizedFrameProvider;
|
||||
|
||||
impl Provider for OversizedFrameProvider {
|
||||
fn kind(&self) -> ProviderKind {
|
||||
ProviderKind::Codex
|
||||
}
|
||||
fn process_id(&self) -> u32 {
|
||||
1
|
||||
}
|
||||
fn session_identity(&self) -> &str {
|
||||
"test-thread"
|
||||
}
|
||||
fn provider_session_id(&self) -> Option<&str> {
|
||||
Some("test-session")
|
||||
}
|
||||
fn start_turn(&mut self, _message: &str, _cwd: &str) -> Result<Value, LocalRunnerError> {
|
||||
unreachable!()
|
||||
}
|
||||
fn interrupt_turn(&mut self, _turn_id: &str) -> Result<Value, LocalRunnerError> {
|
||||
unreachable!()
|
||||
}
|
||||
fn read(&mut self) -> Result<Value, LocalRunnerError> {
|
||||
unreachable!()
|
||||
}
|
||||
fn poll(&mut self) -> Result<Option<CodexProviderEvent>, LocalRunnerError> {
|
||||
Err(LocalRunnerError::invalid(
|
||||
"harness stdout frame exceeded 4194304 bytes",
|
||||
))
|
||||
}
|
||||
fn deliver_tool_result(&mut self, _result: &ToolResult) -> Result<(), LocalRunnerError> {
|
||||
unreachable!()
|
||||
}
|
||||
fn shutdown(&mut self) -> Result<(), LocalRunnerError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_counts_remain_observable_while_credential_tokens_are_redacted() {
|
||||
let sanitized = crate::durable::sanitize_value(&json!({
|
||||
@@ -657,6 +841,81 @@ mod tests {
|
||||
assert_eq!(sanitized.pointer("/accessToken"), Some(&json!("[REDACTED]")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn durable_events_strip_binary_payloads_but_preserve_artifact_metadata() {
|
||||
let sanitized = crate::durable::sanitize_value(&json!({
|
||||
"item": {
|
||||
"id": "image-1",
|
||||
"artifactPath": "/tmp/frog.png",
|
||||
"imageUrl": format!("data:image/png;base64,{}", "A".repeat(1_200_000)),
|
||||
}
|
||||
}));
|
||||
assert_eq!(sanitized.pointer("/item/id"), Some(&json!("image-1")));
|
||||
assert_eq!(sanitized.pointer("/item/artifactPath"), Some(&json!("/tmp/frog.png")));
|
||||
assert_eq!(
|
||||
sanitized.pointer("/item/imageUrl").and_then(Value::as_str),
|
||||
Some("[OMITTED data URL: 1200022 bytes]")
|
||||
);
|
||||
assert!(serde_json::to_vec(&sanitized).unwrap().len() < 1_024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_failure_codes_are_stable() {
|
||||
assert_eq!(provider_failure_code("harness stdout frame exceeded 4194304 bytes"), "provider_frame_too_large");
|
||||
assert_eq!(provider_failure_code("invalid JSON-RPC"), "provider_transport_failed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warm_idle_clock_expires_only_safe_inactive_states() {
|
||||
let root = temporary_root("warm-idle-clock");
|
||||
let mut runner_config = config(&root);
|
||||
runner_config.lifecycle_mode = "warm".to_owned();
|
||||
runner_config.idle_timeout = Some(Duration::from_millis(300_000));
|
||||
let store = DurableStateStore::new(&root).unwrap();
|
||||
let (mut state, _) = store.load_or_create(&runner_config).unwrap();
|
||||
state.lifecycle_mode = "warm".to_owned();
|
||||
state.idle_timeout_ms = Some(300_000);
|
||||
state.last_activity_at_unix_ms = 1_000_000;
|
||||
|
||||
for lifecycle in ["warm_idle", "waiting_input"] {
|
||||
state.lifecycle = lifecycle.to_owned();
|
||||
assert!(!should_suspend_for_idle_at(&state, 1_299_999).unwrap());
|
||||
assert!(should_suspend_for_idle_at(&state, 1_300_000).unwrap());
|
||||
}
|
||||
for lifecycle in ["active", "ready", "suspending", "suspended"] {
|
||||
state.lifecycle = lifecycle.to_owned();
|
||||
assert!(!should_suspend_for_idle_at(&state, 2_000_000).unwrap());
|
||||
}
|
||||
state.lifecycle = "warm_idle".to_owned();
|
||||
state.stop_after_flush = true;
|
||||
assert!(!should_suspend_for_idle_at(&state, 2_000_000).unwrap());
|
||||
let _ = fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_provider_frame_persists_a_recoverable_failure() {
|
||||
let root = temporary_root("oversized-provider-frame");
|
||||
let runner_config = config(&root);
|
||||
let store = DurableStateStore::new(&root).unwrap();
|
||||
let (mut state, _) = store.load_or_create(&runner_config).unwrap();
|
||||
let mut provider: Option<Box<dyn Provider>> = Some(Box::new(OversizedFrameProvider));
|
||||
|
||||
let error = poll_provider(&mut provider, &mut state, &store, &runner_config).unwrap_err();
|
||||
assert!(error.to_string().contains("stdout frame exceeded 4194304 bytes"));
|
||||
let (restored, recovered) = store.load_or_create(&runner_config).unwrap();
|
||||
assert!(recovered);
|
||||
assert_eq!(restored.lifecycle, "recoverable_failure");
|
||||
assert_eq!(
|
||||
restored.recoverable_failure.as_deref(),
|
||||
Some("provider_frame_too_large")
|
||||
);
|
||||
assert!(restored
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|entry| entry.contains("provider_frame_too_large")));
|
||||
let _ = fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
fn config(root: &Path) -> DurableRunnerConfig {
|
||||
DurableRunnerConfig {
|
||||
connect_url: "ws://127.0.0.1:1/durable_runner/connect".to_owned(),
|
||||
@@ -676,9 +935,32 @@ mod tests {
|
||||
max_frame_bytes: 1024 * 1024,
|
||||
reconnect_delay: Duration::from_millis(1),
|
||||
max_runtime: Duration::from_millis(10),
|
||||
lifecycle_mode: "per_turn".to_owned(),
|
||||
idle_timeout: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_codex_provider_fields_deserialize_as_codex() {
|
||||
let root = temporary_root("legacy-provider-state");
|
||||
let runner_config = config(&root);
|
||||
let store = DurableStateStore::new(&root).unwrap();
|
||||
let (state, _) = store.load_or_create(&runner_config).unwrap();
|
||||
let mut value = serde_json::to_value(state).unwrap();
|
||||
let object = value.as_object_mut().unwrap();
|
||||
object.remove("providerConfig");
|
||||
object.remove("providerSessionId");
|
||||
object.insert("codexProviderConfig".to_owned(), json!({
|
||||
"command": "codex", "args": ["app-server"], "cwd": "/tmp",
|
||||
"model": null, "instructions": "legacy"
|
||||
}));
|
||||
object.insert("codexProviderThreadId".to_owned(), json!("thread-legacy"));
|
||||
let restored: DurableRunnerState = serde_json::from_value(value).unwrap();
|
||||
assert_eq!(restored.provider_config.unwrap().kind, crate::codex_provider::ProviderKind::Codex);
|
||||
assert_eq!(restored.provider_session_id.as_deref(), Some("thread-legacy"));
|
||||
let _ = fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
fn temporary_root(name: &str) -> PathBuf {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"paperclip-runner-durable_runner-{name}-{}",
|
||||
|
||||
@@ -35,6 +35,7 @@ const COMPACTED_COMMAND_FILTER_BYTES: usize = 4096;
|
||||
const COMPACTED_COMMAND_FILTER_HASHES: usize = 7;
|
||||
const REVOKE_FLUSH_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
const TEMP_FILE_ATTEMPTS: usize = 16;
|
||||
const MAX_DURABLE_STRING_BYTES: usize = 32 * 1024;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
@@ -120,6 +121,16 @@ pub struct DurableRunnerConfig {
|
||||
pub max_frame_bytes: usize,
|
||||
pub reconnect_delay: Duration,
|
||||
pub max_runtime: Duration,
|
||||
pub lifecycle_mode: String,
|
||||
pub idle_timeout: Option<Duration>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AttachmentIdentity {
|
||||
pub run_id: String,
|
||||
pub turn_id: String,
|
||||
pub item_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
@@ -155,7 +166,17 @@ pub struct DurableRunnerState {
|
||||
pub normalized_session_id: String,
|
||||
pub turn_id: String,
|
||||
pub item_id: String,
|
||||
#[serde(default)]
|
||||
pub previous_attachment_identity: Option<AttachmentIdentity>,
|
||||
pub lifecycle: String,
|
||||
#[serde(default = "default_lifecycle_mode")]
|
||||
pub lifecycle_mode: String,
|
||||
#[serde(default)]
|
||||
pub idle_timeout_ms: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub last_activity_at_unix_ms: u64,
|
||||
#[serde(default)]
|
||||
pub active_turn: bool,
|
||||
pub next_source_seq: u64,
|
||||
pub acked_source_seq: u64,
|
||||
pub last_controller_command_seq: u64,
|
||||
@@ -176,10 +197,10 @@ pub struct DurableRunnerState {
|
||||
pub stop_after_flush: bool,
|
||||
#[serde(default)]
|
||||
pub provider_tool_bridge: ProviderToolBridge,
|
||||
#[serde(default)]
|
||||
pub codex_provider_config: Option<CodexProviderConfig>,
|
||||
#[serde(default)]
|
||||
pub codex_provider_thread_id: Option<String>,
|
||||
#[serde(default, alias = "codexProviderConfig")]
|
||||
pub provider_config: Option<CodexProviderConfig>,
|
||||
#[serde(default, alias = "codexProviderThreadId")]
|
||||
pub provider_session_id: Option<String>,
|
||||
}
|
||||
|
||||
impl DurableRunnerState {
|
||||
@@ -192,7 +213,12 @@ impl DurableRunnerState {
|
||||
normalized_session_id: config.normalized_session_id.clone(),
|
||||
turn_id: config.turn_id.clone(),
|
||||
item_id: config.item_id.clone(),
|
||||
previous_attachment_identity: None,
|
||||
lifecycle: "connecting".to_owned(),
|
||||
lifecycle_mode: config.lifecycle_mode.clone(),
|
||||
idle_timeout_ms: config.idle_timeout.and_then(|duration| u64::try_from(duration.as_millis()).ok()),
|
||||
last_activity_at_unix_ms: 0,
|
||||
active_turn: false,
|
||||
next_source_seq: 1,
|
||||
acked_source_seq: 0,
|
||||
last_controller_command_seq: 0,
|
||||
@@ -210,8 +236,8 @@ impl DurableRunnerState {
|
||||
harness_generation: 1,
|
||||
stop_after_flush: false,
|
||||
provider_tool_bridge: ProviderToolBridge::default(),
|
||||
codex_provider_config: None,
|
||||
codex_provider_thread_id: None,
|
||||
provider_config: None,
|
||||
provider_session_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,7 +266,7 @@ impl DurableRunnerState {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn record_diagnostic(&mut self, message: impl Into<String>) {
|
||||
pub(crate) fn record_diagnostic(&mut self, message: impl Into<String>) {
|
||||
self.diagnostics.push(redact_text(&message.into()));
|
||||
if self.diagnostics.len() > MAX_DIAGNOSTICS {
|
||||
self.diagnostics.remove(0);
|
||||
@@ -248,6 +274,10 @@ impl DurableRunnerState {
|
||||
}
|
||||
}
|
||||
|
||||
fn default_lifecycle_mode() -> String {
|
||||
"per_turn".to_owned()
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DurableStateStore {
|
||||
path: PathBuf,
|
||||
@@ -597,14 +627,11 @@ fn validate_state_binding(
|
||||
state.environment_lease_id.as_str(),
|
||||
config.environment_lease_id.as_str(),
|
||||
),
|
||||
("runId", state.run_id.as_str(), config.run_id.as_str()),
|
||||
(
|
||||
"normalizedSessionId",
|
||||
state.normalized_session_id.as_str(),
|
||||
config.normalized_session_id.as_str(),
|
||||
),
|
||||
("turnId", state.turn_id.as_str(), config.turn_id.as_str()),
|
||||
("itemId", state.item_id.as_str(), config.item_id.as_str()),
|
||||
] {
|
||||
if stored != expected {
|
||||
return Err(DurableRunnerError::invalid(format!(
|
||||
@@ -612,6 +639,11 @@ fn validate_state_binding(
|
||||
)));
|
||||
}
|
||||
}
|
||||
if state.lifecycle_mode != config.lifecycle_mode {
|
||||
return Err(DurableRunnerError::invalid(
|
||||
"durable lifecycle policy does not match the requested recovery policy",
|
||||
));
|
||||
}
|
||||
decode_compacted_command_filter(&state.compacted_command_filter)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -681,11 +713,30 @@ pub fn sanitize_value(value: &Value) -> Value {
|
||||
.collect(),
|
||||
),
|
||||
Value::Array(values) => Value::Array(values.iter().map(sanitize_value).collect()),
|
||||
Value::String(text) => Value::String(redact_text(text)),
|
||||
Value::String(text) => Value::String(sanitize_text_payload(text)),
|
||||
other => other.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_text_payload(input: &str) -> String {
|
||||
let redacted = redact_text(input);
|
||||
if redacted.starts_with("data:") && redacted.len() > 4 * 1024 {
|
||||
return format!("[OMITTED data URL: {} bytes]", redacted.len());
|
||||
}
|
||||
if redacted.len() <= MAX_DURABLE_STRING_BYTES {
|
||||
return redacted;
|
||||
}
|
||||
let mut boundary = MAX_DURABLE_STRING_BYTES;
|
||||
while !redacted.is_char_boundary(boundary) {
|
||||
boundary -= 1;
|
||||
}
|
||||
format!(
|
||||
"{}\n[TRUNCATED: {} bytes omitted]",
|
||||
&redacted[..boundary],
|
||||
redacted.len() - boundary
|
||||
)
|
||||
}
|
||||
|
||||
fn canonical_json(value: &Value) -> String {
|
||||
match value {
|
||||
Value::Object(object) => {
|
||||
@@ -1145,14 +1196,14 @@ fn execute_command_effect(
|
||||
"run.prepare provider is invalid: {error}"
|
||||
))
|
||||
})?;
|
||||
if let Some(existing) = &state.codex_provider_config {
|
||||
if let Some(existing) = &state.provider_config {
|
||||
if existing != &provider {
|
||||
return Err(DurableRunnerError::invalid(
|
||||
"provider configuration changed across a durable session",
|
||||
));
|
||||
}
|
||||
}
|
||||
state.codex_provider_config = Some(provider);
|
||||
state.provider_config = Some(provider);
|
||||
}
|
||||
state.lifecycle = "ready".to_owned();
|
||||
enqueue_event(
|
||||
@@ -1165,6 +1216,58 @@ fn execute_command_effect(
|
||||
)?;
|
||||
Ok(("completed".to_owned(), 1, "run prepared".to_owned()))
|
||||
}
|
||||
"run.attach" => {
|
||||
if state.active_turn || state.lifecycle == "active" || state.lifecycle == "waiting_input" {
|
||||
return Ok((
|
||||
"rejected".to_owned(),
|
||||
0,
|
||||
"a run cannot attach while another run or turn is active".to_owned(),
|
||||
));
|
||||
}
|
||||
let required = |name: &str| {
|
||||
payload.get(name).and_then(Value::as_str).filter(|value| !value.is_empty())
|
||||
.map(str::to_owned)
|
||||
.ok_or_else(|| DurableRunnerError::invalid(format!("run.attach payload.{name} is required")))
|
||||
};
|
||||
let run_id = required("runId")?;
|
||||
let turn_id = required("turnId")?;
|
||||
let item_id = required("itemId")?;
|
||||
if let Some(value) = payload.get("authorizedTools") {
|
||||
let tool_set: AuthorizedToolSet = serde_json::from_value(value.clone()).map_err(|error| {
|
||||
DurableRunnerError::invalid(format!("run.attach authorizedTools is invalid: {error}"))
|
||||
})?;
|
||||
state.provider_tool_bridge.attach_run(tool_set).map_err(|error| {
|
||||
DurableRunnerError::invalid(format!("run.attach tool contract rejected: {error}"))
|
||||
})?;
|
||||
} else {
|
||||
state.provider_tool_bridge.attach_existing_run().map_err(|error| {
|
||||
DurableRunnerError::invalid(format!("run.attach tool contract rejected: {error}"))
|
||||
})?;
|
||||
}
|
||||
state.previous_attachment_identity = Some(AttachmentIdentity {
|
||||
run_id: state.run_id.clone(),
|
||||
turn_id: state.turn_id.clone(),
|
||||
item_id: state.item_id.clone(),
|
||||
});
|
||||
state.run_id = run_id;
|
||||
state.turn_id = turn_id;
|
||||
state.item_id = item_id;
|
||||
state.lifecycle = "ready".to_owned();
|
||||
enqueue_event(
|
||||
state,
|
||||
config,
|
||||
"run.attached",
|
||||
0,
|
||||
json!({
|
||||
"runId": state.run_id,
|
||||
"turnId": state.turn_id,
|
||||
"itemId": state.item_id,
|
||||
"sameSession": true,
|
||||
}),
|
||||
None,
|
||||
)?;
|
||||
Ok(("completed".to_owned(), 1, "run attached to durable session".to_owned()))
|
||||
}
|
||||
"semantic_tool.result" => {
|
||||
let result: ToolResult = serde_json::from_value(payload.clone()).map_err(|error| {
|
||||
DurableRunnerError::invalid(format!("semantic tool result is invalid: {error}"))
|
||||
@@ -1283,6 +1386,8 @@ fn execute_command_effect(
|
||||
"new turns are disabled while draining or backpressured".to_owned(),
|
||||
)),
|
||||
"turn.start" => {
|
||||
state.active_turn = true;
|
||||
state.lifecycle = "active".to_owned();
|
||||
let item_id = state.item_id.clone();
|
||||
enqueue_event(
|
||||
state,
|
||||
@@ -1292,7 +1397,7 @@ fn execute_command_effect(
|
||||
json!({ "turnId": state.turn_id, "sameSession": true }),
|
||||
None,
|
||||
)?;
|
||||
if state.codex_provider_config.is_some() {
|
||||
if state.provider_config.is_some() {
|
||||
return Ok((
|
||||
"completed".to_owned(),
|
||||
1,
|
||||
@@ -1349,7 +1454,23 @@ fn execute_command_effect(
|
||||
}),
|
||||
None,
|
||||
)?;
|
||||
state.lifecycle = "terminal".to_owned();
|
||||
state.active_turn = false;
|
||||
state.lifecycle = if state.lifecycle_mode == "warm" {
|
||||
"warm_idle".to_owned()
|
||||
} else {
|
||||
"suspending".to_owned()
|
||||
};
|
||||
if state.lifecycle_mode == "per_turn" {
|
||||
state.stop_after_flush = true;
|
||||
enqueue_event(
|
||||
state,
|
||||
config,
|
||||
"runner.suspending",
|
||||
0,
|
||||
json!({ "reason": "per_turn_complete", "resumable": true }),
|
||||
None,
|
||||
)?;
|
||||
}
|
||||
Ok(("completed".to_owned(), 1, "turn completed".to_owned()))
|
||||
}
|
||||
"turn.interrupt" => Ok((
|
||||
|
||||
@@ -696,17 +696,29 @@ fn validate_control_identity(
|
||||
for (field, expected) in [
|
||||
("runnerInstanceId", state.runner_instance_id.as_str()),
|
||||
("environmentLeaseId", state.environment_lease_id.as_str()),
|
||||
("runId", state.run_id.as_str()),
|
||||
("normalizedSessionId", state.normalized_session_id.as_str()),
|
||||
("turnId", state.turn_id.as_str()),
|
||||
("itemId", state.item_id.as_str()),
|
||||
] {
|
||||
if required_string(value, field)? != expected {
|
||||
let actual = required_string(value, field)?;
|
||||
if actual != expected {
|
||||
return Err(DurableRunnerError::invalid(format!(
|
||||
"control envelope {field} does not match the authenticated session"
|
||||
"control envelope {field} does not match the authenticated session (expected {expected}, received {actual})"
|
||||
)));
|
||||
}
|
||||
}
|
||||
let current_attachment_matches = required_string(value, "runId")? == state.run_id
|
||||
&& required_string(value, "turnId")? == state.turn_id
|
||||
&& required_string(value, "itemId")? == state.item_id;
|
||||
let prior_ack_matches = value.get("kind").and_then(Value::as_str) == Some("ack")
|
||||
&& state.previous_attachment_identity.as_ref().is_some_and(|prior| {
|
||||
value.get("runId").and_then(Value::as_str) == Some(prior.run_id.as_str())
|
||||
&& value.get("turnId").and_then(Value::as_str) == Some(prior.turn_id.as_str())
|
||||
&& value.get("itemId").and_then(Value::as_str) == Some(prior.item_id.as_str())
|
||||
});
|
||||
if !current_attachment_matches && !prior_ack_matches {
|
||||
return Err(DurableRunnerError::invalid(
|
||||
"control envelope run attachment does not match the authenticated session",
|
||||
));
|
||||
}
|
||||
if let Some(connection) = connection {
|
||||
if required_string(value, "connectionId")? != connection.connection_id
|
||||
|| required_string(value, "connectionLeaseId")? != connection.lease_id
|
||||
|
||||
@@ -221,6 +221,18 @@ impl SupervisedProcess {
|
||||
"TMPDIR",
|
||||
"TEMP",
|
||||
"TMP",
|
||||
"TZ",
|
||||
"HTTP_PROXY",
|
||||
"HTTPS_PROXY",
|
||||
"NO_PROXY",
|
||||
"ALL_PROXY",
|
||||
"SSL_CERT_DIR",
|
||||
"OPENROUTER_API_KEY",
|
||||
"PAPERCLIP_OPENCODE_COMMAND",
|
||||
"PAPERCLIP_OPENCODE_RUNTIME_DIR",
|
||||
"PAPERCLIP_RUNNER_INSTANCE_ID",
|
||||
"PAPERCLIP_RUN_ID",
|
||||
"PAPERCLIP_NORMALIZED_SESSION_ID",
|
||||
] {
|
||||
if let Some(value) = std::env::var_os(key) {
|
||||
command.env(key, value);
|
||||
|
||||
@@ -72,6 +72,35 @@ impl Error for ProviderBridgeError {}
|
||||
|
||||
impl ProviderToolBridge {
|
||||
pub fn prepare(&mut self, tool_set: AuthorizedToolSet) -> Result<(), ProviderBridgeError> {
|
||||
self.prepare_internal(tool_set, false)
|
||||
}
|
||||
|
||||
pub fn attach_run(&mut self, tool_set: AuthorizedToolSet) -> Result<(), ProviderBridgeError> {
|
||||
if !self.pending.is_empty() {
|
||||
return Err(ProviderBridgeError::invalid(
|
||||
"cannot attach a new run while provider tool calls are pending",
|
||||
));
|
||||
}
|
||||
self.prepare_internal(tool_set, true)?;
|
||||
self.completed.clear();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn attach_existing_run(&mut self) -> Result<(), ProviderBridgeError> {
|
||||
if !self.pending.is_empty() {
|
||||
return Err(ProviderBridgeError::invalid(
|
||||
"cannot attach a new run while provider tool calls are pending",
|
||||
));
|
||||
}
|
||||
self.completed.clear();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn prepare_internal(
|
||||
&mut self,
|
||||
tool_set: AuthorizedToolSet,
|
||||
allow_catalog_change: bool,
|
||||
) -> Result<(), ProviderBridgeError> {
|
||||
if tool_set.schema != TOOL_SET_SCHEMA || tool_set.schema_version != 1 {
|
||||
return Err(ProviderBridgeError::invalid(
|
||||
"unsupported authorized tool-set contract",
|
||||
@@ -106,11 +135,13 @@ impl ProviderToolBridge {
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(existing) = &self.catalog_digest {
|
||||
if existing != &tool_set.catalog_digest {
|
||||
return Err(ProviderBridgeError::invalid(
|
||||
"authorized tool set changed across a durable session",
|
||||
));
|
||||
if !allow_catalog_change {
|
||||
if let Some(existing) = &self.catalog_digest {
|
||||
if existing != &tool_set.catalog_digest {
|
||||
return Err(ProviderBridgeError::invalid(
|
||||
"authorized tool set changed across a durable session",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
self.catalog_digest = Some(tool_set.catalog_digest);
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::path::PathBuf;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use paperclip_runner_core::codex_provider::{
|
||||
CodexProvider, CodexProviderConfig, CodexProviderEvent,
|
||||
CodexProvider, CodexProviderConfig, CodexProviderEvent, Provider, ProviderKind,
|
||||
};
|
||||
use paperclip_runner_core::provider_bridge::{AuthorizedTool, ToolResult};
|
||||
use serde_json::json;
|
||||
@@ -11,6 +11,7 @@ use serde_json::json;
|
||||
fn codex_dynamic_tool_round_trips_through_the_rust_provider_boundary() {
|
||||
let mut provider = CodexProvider::start(
|
||||
&CodexProviderConfig {
|
||||
kind: ProviderKind::Codex,
|
||||
command: PathBuf::from(env!("CARGO_BIN_EXE_fake-codex-app-server")),
|
||||
args: Vec::new(),
|
||||
cwd: "/tmp".to_owned(),
|
||||
@@ -56,14 +57,155 @@ fn codex_dynamic_tool_round_trips_through_the_rust_provider_boundary() {
|
||||
}
|
||||
}
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
let mut saw_semantic_result = false;
|
||||
loop {
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"fake Codex did not complete after its tool result"
|
||||
);
|
||||
if matches!(provider.poll().unwrap(), Some(CodexProviderEvent::Notification { method, .. }) if method == "turn/completed")
|
||||
{
|
||||
match provider.poll().unwrap() {
|
||||
Some(CodexProviderEvent::SemanticResult { result, item_id }) => {
|
||||
assert_eq!(result["reportedWorkDisposition"], "done");
|
||||
assert_eq!(item_id.as_deref(), Some("semantic-result"));
|
||||
saw_semantic_result = true;
|
||||
}
|
||||
Some(CodexProviderEvent::Notification { method, .. }) if method == "turn/completed" => {
|
||||
assert!(
|
||||
saw_semantic_result,
|
||||
"semantic result must precede terminal completion"
|
||||
);
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_contract_preserves_the_opencode_tag() {
|
||||
let mut provider = CodexProvider::start(
|
||||
&CodexProviderConfig {
|
||||
kind: ProviderKind::Opencode,
|
||||
command: PathBuf::from(env!("CARGO_BIN_EXE_fake-codex-app-server")),
|
||||
args: Vec::new(),
|
||||
cwd: "/tmp".to_owned(),
|
||||
model: Some("openrouter/deepseek/deepseek-v4-flash-0731".to_owned()),
|
||||
instructions: "Use the authorized Paperclip tools.".to_owned(),
|
||||
},
|
||||
vec![AuthorizedTool {
|
||||
operation_id: "get_task_context".to_owned(),
|
||||
version: 1,
|
||||
description: "Read task context.".to_owned(),
|
||||
input_schema: json!({"type": "object"}),
|
||||
response_schema: json!({"type": "object"}),
|
||||
}]
|
||||
.into_iter(),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(Provider::kind(&provider), ProviderKind::Opencode);
|
||||
Provider::shutdown(&mut provider).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_like_prompt_stays_skillless_and_accepts_a_valid_one_point_two_megabyte_event() {
|
||||
let mut provider = CodexProvider::start(
|
||||
&CodexProviderConfig {
|
||||
kind: ProviderKind::Codex,
|
||||
command: PathBuf::from(env!("CARGO_BIN_EXE_fake-codex-app-server")),
|
||||
args: vec!["--large-event".to_owned()],
|
||||
cwd: "/tmp".to_owned(),
|
||||
model: None,
|
||||
instructions: "Use only the authorized Paperclip tools.".to_owned(),
|
||||
},
|
||||
vec![AuthorizedTool {
|
||||
operation_id: "get_task_context".to_owned(),
|
||||
version: 1,
|
||||
description: "Read task context.".to_owned(),
|
||||
input_schema: json!({"type": "object"}),
|
||||
response_schema: json!({"type": "object"}),
|
||||
}]
|
||||
.into_iter(),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
provider
|
||||
.start_turn("Create an ASCII frog.", "/tmp")
|
||||
.unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(3);
|
||||
let mut saw_large_event = false;
|
||||
let mut saw_authorized_tool = false;
|
||||
loop {
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"large provider event was not received"
|
||||
);
|
||||
match provider.poll().unwrap() {
|
||||
Some(CodexProviderEvent::Notification { method, params })
|
||||
if method == "item/completed" =>
|
||||
{
|
||||
assert!(
|
||||
params
|
||||
.pointer("/item/imageUrl")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap()
|
||||
.len()
|
||||
> 1_100_000
|
||||
);
|
||||
saw_large_event = true;
|
||||
}
|
||||
Some(CodexProviderEvent::ToolCall { operation_id, .. }) => {
|
||||
assert_eq!(operation_id, "get_task_context");
|
||||
saw_authorized_tool = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if saw_large_event && saw_authorized_tool {
|
||||
break;
|
||||
}
|
||||
}
|
||||
provider.shutdown().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_provider_rejects_an_event_above_the_four_megabyte_hard_limit() {
|
||||
let mut provider = CodexProvider::start(
|
||||
&CodexProviderConfig {
|
||||
kind: ProviderKind::Codex,
|
||||
command: PathBuf::from(env!("CARGO_BIN_EXE_fake-codex-app-server")),
|
||||
args: vec!["--oversized-event".to_owned()],
|
||||
cwd: "/tmp".to_owned(),
|
||||
model: None,
|
||||
instructions: "Use only the authorized Paperclip tools.".to_owned(),
|
||||
},
|
||||
vec![AuthorizedTool {
|
||||
operation_id: "get_task_context".to_owned(),
|
||||
version: 1,
|
||||
description: "Read task context.".to_owned(),
|
||||
input_schema: json!({"type": "object"}),
|
||||
response_schema: json!({"type": "object"}),
|
||||
}]
|
||||
.into_iter(),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
provider
|
||||
.start_turn("Create an ASCII frog.", "/tmp")
|
||||
.unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(3);
|
||||
loop {
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"oversized provider event did not fail"
|
||||
);
|
||||
match provider.poll() {
|
||||
Err(error) => {
|
||||
assert!(error
|
||||
.to_string()
|
||||
.contains("stdout frame exceeded 4194304 bytes"));
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -202,6 +202,42 @@ class RouteError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
function harnessConfiguration(source, fallbackModel) {
|
||||
const provider = source.provider === undefined ? "codex" : String(source.provider).trim();
|
||||
if (provider !== "codex" && provider !== "opencode") {
|
||||
throw new RouteError(400, "invalid_provider", "Provider must be codex or opencode.");
|
||||
}
|
||||
const rawModel = source.model === undefined ? fallbackModel : source.model;
|
||||
const model = rawModel === undefined || rawModel === null ? "" : String(rawModel).trim();
|
||||
if (model.length > 256) throw new RouteError(400, "invalid_model", "Model is too long.");
|
||||
if (provider === "opencode" && (!model || !model.includes("/"))) {
|
||||
throw new RouteError(400, "invalid_model", "OpenCode requires a provider/model value.");
|
||||
}
|
||||
const suppliedLifecycle = source.lifecyclePolicy && typeof source.lifecyclePolicy === "object"
|
||||
? source.lifecyclePolicy
|
||||
: source;
|
||||
const lifecycleMode = suppliedLifecycle.mode === undefined
|
||||
? source.lifecycleMode === undefined ? "warm" : String(source.lifecycleMode).trim()
|
||||
: String(suppliedLifecycle.mode).trim();
|
||||
if (lifecycleMode !== "per_turn" && lifecycleMode !== "warm") {
|
||||
throw new RouteError(400, "invalid_lifecycle_mode", "Execution mode must be per_turn or warm.");
|
||||
}
|
||||
let lifecyclePolicy;
|
||||
if (lifecycleMode === "per_turn") {
|
||||
lifecyclePolicy = { mode: "per_turn", idleTimeoutMs: null };
|
||||
} else {
|
||||
const rawIdleTimeout = suppliedLifecycle.idleTimeoutMs === undefined
|
||||
? source.idleTimeoutMs
|
||||
: suppliedLifecycle.idleTimeoutMs;
|
||||
const idleTimeoutMs = rawIdleTimeout === undefined ? 300_000 : Number(rawIdleTimeout);
|
||||
if (!Number.isSafeInteger(idleTimeoutMs) || idleTimeoutMs <= 0) {
|
||||
throw new RouteError(400, "invalid_idle_timeout", "Warm idle timeout must be a positive integer.");
|
||||
}
|
||||
lifecyclePolicy = { mode: "warm", idleTimeoutMs };
|
||||
}
|
||||
return { provider, model: model || null, lifecyclePolicy };
|
||||
}
|
||||
|
||||
export function createCapabilityIssueThreadMiddleware(options = {}) {
|
||||
const load = options.loadRunner ?? loadCapabilityIssueThreadRunner;
|
||||
const workingDirectoryRoot = options.scratchRoot ?? scratchRoot();
|
||||
@@ -269,9 +305,19 @@ export function createCapabilityIssueThreadMiddleware(options = {}) {
|
||||
* out rather than on the way in.
|
||||
*/
|
||||
function liveView(runner, entry) {
|
||||
const snapshot = entry.session.snapshot();
|
||||
const provider = snapshot.config.provider ?? "codex";
|
||||
const expectedAgentLabel = provider === "opencode" ? "Real OpenCode" : "Real Codex";
|
||||
const projected = view(runner, entry);
|
||||
if (projected.mode !== "live" || projected.identity.agentLabel !== "Real Codex") {
|
||||
throw new RouteError(500, "live_mode_required", "The clean-room path refused a non-live view.");
|
||||
if (
|
||||
projected.mode !== "live"
|
||||
|| projected.identity.agentLabel !== expectedAgentLabel
|
||||
) {
|
||||
throw new RouteError(
|
||||
500,
|
||||
"provider_identity_mismatch",
|
||||
"The clean-room path refused a view whose provider identity did not match its immutable session.",
|
||||
);
|
||||
}
|
||||
return projected;
|
||||
}
|
||||
@@ -326,15 +372,12 @@ export function createCapabilityIssueThreadMiddleware(options = {}) {
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** Retire a session: stop its work, clear its authority, drop its workspace. */
|
||||
/** Archive a session resumably; destructive deletion is intentionally absent. */
|
||||
async function retire(service, sessionId, reason) {
|
||||
const entry = sessions.get(sessionId);
|
||||
if (entry === undefined) return;
|
||||
sessions.delete(sessionId);
|
||||
await service.stop(sessionId, reason).catch(() => undefined);
|
||||
if (entry.ownsWorkingDirectory) {
|
||||
await rm(entry.workingDirectory, { recursive: true, force: true }).catch(() => undefined);
|
||||
}
|
||||
entry.connection = { state: "suspended", attempt: entry.connection.attempt };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -351,8 +394,10 @@ export function createCapabilityIssueThreadMiddleware(options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
async function createCleanRoomSession(runner, service, capabilityHash) {
|
||||
const open = [...sessions.values()].filter((entry) => entry.surface === "cleanroom");
|
||||
async function createCleanRoomSession(runner, service, capabilityHash, configuration) {
|
||||
const open = [...sessions.values()].filter((entry) =>
|
||||
entry.surface === "cleanroom" && entry.session.snapshot().status !== "suspended",
|
||||
);
|
||||
// Bounded concurrency: the oldest clean room yields rather than refusing a
|
||||
// new board user, because an abandoned chat is the likelier tenant here.
|
||||
for (const stale of open.slice(0, Math.max(0, open.length - (MAX_CLEAN_ROOM_SESSIONS - 1)))) {
|
||||
@@ -367,7 +412,9 @@ export function createCapabilityIssueThreadMiddleware(options = {}) {
|
||||
try {
|
||||
session = await service.create({
|
||||
...input,
|
||||
...(options.requestedModel === undefined ? {} : { requestedModel: options.requestedModel }),
|
||||
provider: configuration.provider,
|
||||
lifecyclePolicy: configuration.lifecyclePolicy,
|
||||
...(configuration.model === null ? {} : { requestedModel: configuration.model }),
|
||||
});
|
||||
} catch (error) {
|
||||
await rm(workingDirectory, { recursive: true, force: true }).catch(() => undefined);
|
||||
@@ -383,6 +430,7 @@ export function createCapabilityIssueThreadMiddleware(options = {}) {
|
||||
turns: 0,
|
||||
createdAt: Date.now(),
|
||||
capabilityHash,
|
||||
configuration,
|
||||
connection: { state: "connected", attempt: 0 },
|
||||
};
|
||||
sessions.set(session.id, entry);
|
||||
@@ -390,12 +438,31 @@ export function createCapabilityIssueThreadMiddleware(options = {}) {
|
||||
}
|
||||
|
||||
function cleanRoomPayload(runner, entry) {
|
||||
const snapshot = entry.session.snapshot();
|
||||
const configuration = {
|
||||
provider: snapshot.config.provider ?? "codex",
|
||||
model: snapshot.config.requestedModel ?? null,
|
||||
lifecyclePolicy: snapshot.config.lifecyclePolicy ?? { mode: "per_turn", idleTimeoutMs: null },
|
||||
};
|
||||
if (
|
||||
entry.configuration !== undefined
|
||||
&& (entry.configuration.provider !== configuration.provider
|
||||
|| entry.configuration.model !== configuration.model
|
||||
|| JSON.stringify(entry.configuration.lifecyclePolicy) !== JSON.stringify(configuration.lifecyclePolicy))
|
||||
) {
|
||||
throw new RouteError(
|
||||
500,
|
||||
"provider_configuration_mismatch",
|
||||
"The clean-room path refused configuration that differed from its immutable session.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
sessionId: entry.session.id,
|
||||
surface: "cleanroom",
|
||||
identity: entry.identity,
|
||||
limits: { maxTurns: MAX_TURNS_PER_SESSION, maxMessageBytes: MAX_MESSAGE_BYTES },
|
||||
turns: entry.turns,
|
||||
configuration,
|
||||
view: liveView(runner, entry),
|
||||
};
|
||||
}
|
||||
@@ -542,6 +609,13 @@ export function createCapabilityIssueThreadMiddleware(options = {}) {
|
||||
typeof body.sessionId === "string" ? body.sessionId : url.searchParams.get("sessionId");
|
||||
const scenario =
|
||||
typeof body.scenario === "string" ? body.scenario : url.searchParams.get("scenario") ?? "hb-baseline";
|
||||
const requestedHarness = harnessConfiguration({
|
||||
provider: body.provider ?? url.searchParams.get("provider") ?? undefined,
|
||||
model: body.model ?? url.searchParams.get("model") ?? undefined,
|
||||
lifecyclePolicy: body.lifecyclePolicy,
|
||||
lifecycleMode: body.lifecycleMode ?? url.searchParams.get("lifecycleMode") ?? undefined,
|
||||
idleTimeoutMs: body.idleTimeoutMs ?? url.searchParams.get("idleTimeoutMs") ?? undefined,
|
||||
}, options.requestedModel);
|
||||
|
||||
/**
|
||||
* Resolves the caller's own session, or `undefined`.
|
||||
@@ -585,7 +659,7 @@ export function createCapabilityIssueThreadMiddleware(options = {}) {
|
||||
const capabilityHash = sha256(capability);
|
||||
const entry =
|
||||
surface === "cleanroom"
|
||||
? await createCleanRoomSession(runner, service, capabilityHash)
|
||||
? await createCleanRoomSession(runner, service, capabilityHash, requestedHarness)
|
||||
: await createSession(runner, service, scenario, workingDirectory, capabilityHash);
|
||||
if (!reuse) {
|
||||
response.setHeader("set-cookie", capabilityCookie(request, surface, capability));
|
||||
@@ -619,7 +693,7 @@ export function createCapabilityIssueThreadMiddleware(options = {}) {
|
||||
if (owned.state === "owned") {
|
||||
await retire(service, owned.entry.session.id, "new clean-room chat");
|
||||
}
|
||||
const entry = await mint("cleanroom", { rotate: true });
|
||||
const entry = await mint("cleanroom", { rotate: false });
|
||||
send(response, 201, cleanRoomPayload(runner, entry));
|
||||
return;
|
||||
}
|
||||
@@ -695,6 +769,7 @@ export function createCapabilityIssueThreadMiddleware(options = {}) {
|
||||
fork = await service.create({
|
||||
seed,
|
||||
workingDirectory: forkDirectory,
|
||||
provider: snapshot.config.provider ?? "codex",
|
||||
scenario: snapshot.config.scenario,
|
||||
capabilities: snapshot.config.capabilities,
|
||||
explicitClaims: snapshot.config.explicitClaims,
|
||||
@@ -766,22 +841,29 @@ export function createCapabilityIssueThreadMiddleware(options = {}) {
|
||||
await entry.session.reconnect();
|
||||
entry.connection = { state: "connected", attempt: 0 };
|
||||
} else if (route === "reset") {
|
||||
// Reset rotates the capability as well as the session: the cookie the
|
||||
// caller arrived with must stop working, here and on the replacement.
|
||||
const capability = mintCapability();
|
||||
const capabilityHash = sha256(capability);
|
||||
// Reset archives the current session and starts another session for
|
||||
// the same browser principal. Keep the capability stable so the
|
||||
// archived session remains selectable and resumable from history.
|
||||
const capabilityHash = entry.capabilityHash;
|
||||
if (entry.surface === "cleanroom") {
|
||||
// A clean-room reset is a new tenant, not a rewound one: the seed is
|
||||
// blank either way, so restoring it would hand back the same mock
|
||||
// identities the board just saw.
|
||||
await retire(service, entry.session.id, "clean-room reset");
|
||||
const replacement = await createCleanRoomSession(runner, service, capabilityHash);
|
||||
response.setHeader("set-cookie", capabilityCookie(request, "cleanroom", capability));
|
||||
const replacement = await createCleanRoomSession(
|
||||
runner,
|
||||
service,
|
||||
capabilityHash,
|
||||
entry.configuration ?? {
|
||||
provider: "codex",
|
||||
model: null,
|
||||
lifecyclePolicy: { mode: "warm", idleTimeoutMs: 300_000 },
|
||||
},
|
||||
);
|
||||
send(response, 200, cleanRoomPayload(runner, replacement));
|
||||
return;
|
||||
}
|
||||
const next = await service.reset(entry.session.id);
|
||||
sessions.delete(entry.session.id);
|
||||
const resetEntry = {
|
||||
...entry,
|
||||
session: next,
|
||||
@@ -790,7 +872,6 @@ export function createCapabilityIssueThreadMiddleware(options = {}) {
|
||||
connection: { state: "connected", attempt: 0 },
|
||||
};
|
||||
sessions.set(next.id, resetEntry);
|
||||
response.setHeader("set-cookie", capabilityCookie(request, "issue", capability));
|
||||
send(response, 200, payload(runner, resetEntry));
|
||||
return;
|
||||
} else if (route === "interaction") {
|
||||
|
||||
@@ -16,6 +16,8 @@ const schemaNames = [
|
||||
"identity",
|
||||
"capabilities",
|
||||
"command",
|
||||
"workspace-diff",
|
||||
"workspace-file-reference",
|
||||
"semantic-tool",
|
||||
"stop-reason",
|
||||
"terminal",
|
||||
|
||||
@@ -6,7 +6,7 @@ Status: canonical explanatory contract for the Paperclip runner V1 surface.
|
||||
|
||||
This document keeps three independent meanings of **group** separate. PRP families describe wire evidence and controller commands; capability placement decides who owns an operation; behavioral eval groups organize the 106 scenario corpus. None of the three axes can be used as a substitute for another.
|
||||
|
||||
The generated totals are **65 PRP events in 15 event families**, **14 controller commands in 7 command families**, **10 control-plane operations**, **41 reconciled semantic operations** (14 always, 27 optional), and **106 scenarios in 16 behavior groups**.
|
||||
The generated totals are **71 PRP events in 15 event families**, **16 controller commands in 7 command families**, **10 control-plane operations**, **41 reconciled semantic operations** (14 always, 27 optional), and **106 scenarios in 16 behavior groups**.
|
||||
|
||||
## Axis 1: PRP v1 event and command families
|
||||
|
||||
@@ -16,10 +16,10 @@ PRP records ordered, replayable execution evidence. It is not the model's Paperc
|
||||
|
||||
| Family | Purpose | Events | Count |
|
||||
| --- | --- | --- | ---: |
|
||||
| `runner` | Runner connection, drain, and diagnostic lifecycle. | `runner.connected`<br>`runner.reconnected`<br>`runner.disconnected`<br>`runner.draining`<br>`runner.diagnostic` | 5 |
|
||||
| `runner` | Runner connection, drain, and diagnostic lifecycle. | `runner.connected`<br>`runner.reconnected`<br>`runner.reconciled`<br>`runner.disconnected`<br>`runner.draining`<br>`runner.suspending`<br>`runner.suspended`<br>`runner.stopped`<br>`runner.diagnostic` | 9 |
|
||||
| `runtime` | Runner phase transitions. | `runtime.phase.changed` | 1 |
|
||||
| `sandbox` | Sandbox resource measurements. | `sandbox.metric` | 1 |
|
||||
| `workspace` | Workspace readiness. | `workspace.ready` | 1 |
|
||||
| `workspace` | Workspace readiness. | `workspace.ready`<br>`workspace.change.updated`<br>`workspace.diff.recorded`<br>`workspace.file.referenced` | 4 |
|
||||
| `harness` | Provider harness startup, readiness, exit, and diagnostics. | `harness.starting`<br>`harness.ready`<br>`harness.exited`<br>`harness.diagnostic` | 4 |
|
||||
| `session` | Provider-neutral session open, resume, reconciliation, close, and failure. | `session.starting`<br>`session.started`<br>`session.resuming`<br>`session.resumed`<br>`session.reconciled`<br>`session.closed`<br>`session.failed` | 7 |
|
||||
| `turn` | Model turn submission through terminal turn disposition. | `turn.submitted`<br>`turn.accepted`<br>`turn.started`<br>`turn.completed`<br>`turn.failed`<br>`turn.interrupted`<br>`turn.cancelled` | 7 |
|
||||
@@ -27,7 +27,7 @@ PRP records ordered, replayable execution evidence. It is not the model's Paperc
|
||||
| `mcp_app` | MCP App discovery, initialization, tool, action, host-context, and teardown evidence. | `mcp_app.discovered`<br>`mcp_app.resource.resolved`<br>`mcp_app.initializing`<br>`mcp_app.ready`<br>`mcp_app.tool_input`<br>`mcp_app.tool_result`<br>`mcp_app.action.requested`<br>`mcp_app.action.resolved`<br>`mcp_app.host_context.changed`<br>`mcp_app.failed`<br>`mcp_app.teardown` | 11 |
|
||||
| `runtime_request` | Runtime permission/input request lifecycle. | `runtime_request.created`<br>`runtime_request.resolved`<br>`runtime_request.expired`<br>`runtime_request.cancelled` | 4 |
|
||||
| `interaction` | Issue-thread interaction proposal, materialization, response, delivery, and rejection. | `interaction.request.proposed`<br>`interaction.request.materialized`<br>`interaction.request.rejected`<br>`interaction.response.progressed`<br>`interaction.response.resolved`<br>`interaction.response.delivered` | 6 |
|
||||
| `run` | Structured result negotiation and terminal run outcome. | `run.result.proposed`<br>`run.result.accepted`<br>`run.result.rejected`<br>`run.terminal` | 4 |
|
||||
| `run` | Structured result negotiation and terminal run outcome. | `run.attached`<br>`run.detached`<br>`run.result.proposed`<br>`run.result.accepted`<br>`run.result.rejected`<br>`run.terminal` | 6 |
|
||||
| `attention` | Continuation and attention routing lifecycle. | `attention.request.proposed`<br>`attention.request.routed`<br>`attention.request.resolved`<br>`attention.request.expired`<br>`attention.request.superseded` | 5 |
|
||||
| `work` | Recorded work-assessment evidence. | `work.assessment.recorded` | 1 |
|
||||
| `issue` | Issue-status decision proposal outcome and application evidence. | `issue.status.decision.recorded`<br>`issue.status.decision.applied`<br>`issue.status.decision.rejected`<br>`issue.status.decision.superseded` | 4 |
|
||||
@@ -36,13 +36,13 @@ PRP records ordered, replayable execution evidence. It is not the model's Paperc
|
||||
|
||||
| Family | Purpose | Commands | Count |
|
||||
| --- | --- | --- | ---: |
|
||||
| `run` | Prepare or cancel a run. | `run.prepare`<br>`run.cancel` | 2 |
|
||||
| `run` | Prepare or cancel a run. | `run.prepare`<br>`run.attach`<br>`run.cancel` | 3 |
|
||||
| `session` | Open, snapshot, or close a normalized provider session. | `session.open`<br>`session.snapshot`<br>`session.close` | 3 |
|
||||
| `turn` | Start, steer, interrupt, or stop a model turn. | `turn.start`<br>`turn.steer`<br>`turn.interrupt`<br>`turn.stop` | 4 |
|
||||
| `request` | Resolve a pending runtime request. | `request.resolve` | 1 |
|
||||
| `interaction` | Acknowledge delivery of an interaction response. | `interaction.receipt` | 1 |
|
||||
| `semantic_tool` | Returns an authorized, correlated Paperclip tool result to the provider through runnerd. | `semantic_tool.result` | 1 |
|
||||
| `runner` | Drain or shut down the runner process. | `runner.drain`<br>`runner.shutdown` | 2 |
|
||||
| `runner` | Drain or shut down the runner process. | `runner.drain`<br>`runner.suspend`<br>`runner.shutdown` | 3 |
|
||||
|
||||
## Axis 2: capability placement
|
||||
|
||||
@@ -91,7 +91,7 @@ Grant groups are documentation/exposure bundles, not additional authority. The o
|
||||
|
||||
### Reconciled semantic-operation ledger
|
||||
|
||||
`live_codex` means a live provider dispatcher exists against the deterministic runner/mock port; it does **not** mean a production Paperclip service is bound. Every row remains `realServiceBinding: unbound` until the shared mock/real adapter work lands. `generic_api_request` is test-only and cannot satisfy product coverage.
|
||||
`live_codex` means a live provider dispatcher exists. Production binding is tracked separately: `bound` rows are advertised by Paperclip's run-scoped authority over the shared PRP route, while `audit_pending` rows remain unavailable to production agents. `generic_api_request` is test-only and cannot satisfy product coverage.
|
||||
|
||||
| Operation | Placement | Claims | Modes / roles | Side effect | Idempotency | Redacts | Mock | Catalogs / current runner | Production / PRP evidence |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
@@ -105,37 +105,37 @@ Grant groups are documentation/exposure bundles, not additional authority. The o
|
||||
| `export_company` | `optional_agent_tool` | `portability:export` | `standard`<br>`skill_test` | `admin` | `required` | no | `mock_extension:portability.export` | `scenario`<br>`scenario_mock` | `unbound`<br>company admin/portability item event plus audit record<br>catalog PRP status: `audit_pending` |
|
||||
| `finish_task` | `always_agent_tool` | none | `standard`<br>`skill_test` | `task_write` | `required` | no | `semantic_command:finish_task` | `scenario` + `live`<br>`live_codex` | `unbound`<br>semantic-operation item event plus active-task state diff, work-assessment, and issue-status-decision events<br>catalog PRP status: `audit_pending` |
|
||||
| `generic_api_request` | `optional_agent_tool` | `test:generic_api_request` | `skill_test` | `test_escape_hatch` | `required` | yes | `mock_extension:test.generic_api` | `scenario` + `live`<br>`test_only` | `unbound`<br>test-only; excluded from product PRP evidence<br>catalog PRP status: `audit_pending` |
|
||||
| `get_agent` | `optional_agent_tool` | `discovery:agents:read` | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | inline/no mapping | `live`<br>`live_codex` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
|
||||
| `get_approval` | `optional_agent_tool` | `governance:approvals:read` | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | inline/no mapping | `live`<br>`live_codex` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
|
||||
| `get_approval_context` | `optional_agent_tool` | `governance:approvals:read` | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | inline/no mapping | `live`<br>`live_codex` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
|
||||
| `get_task_context` | `always_agent_tool` | none | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `context_read:active_task` | `scenario` + `live`<br>`live_codex` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
|
||||
| `get_task_history` | `always_agent_tool` | none | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `snapshot_read:active_task_history` | `scenario` + `live`<br>`live_codex` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
|
||||
| `get_agent` | `optional_agent_tool` | `discovery:agents:read` | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | inline/no mapping | `live`<br>`live_codex` | `agentService.getById (redacted projection)`<br>company equality + redacted actor projection<br>catalog PRP status: `bound` |
|
||||
| `get_approval` | `optional_agent_tool` | `governance:approvals:read` | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | inline/no mapping | `live`<br>`live_codex` | `approvalService.getById`<br>company equality check<br>catalog PRP status: `bound` |
|
||||
| `get_approval_context` | `optional_agent_tool` | `governance:approvals:read` | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | inline/no mapping | `live`<br>`live_codex` | `approvalService.getById + issueApprovals`<br>company equality + linked-task projection<br>catalog PRP status: `bound` |
|
||||
| `get_task_context` | `always_agent_tool` | none | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `context_read:active_task` | `scenario` + `live`<br>`live_codex` | `PaperclipRunnerToolAuthority + issue/run assignment query`<br>shared PRP route + bound-context authorization test<br>catalog PRP status: `bound` |
|
||||
| `get_task_history` | `always_agent_tool` | none | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `snapshot_read:active_task_history` | `scenario` + `live`<br>`live_codex` | `issueComments scoped by bound issue`<br>shared PRP route + bound-context authorization test<br>catalog PRP status: `bound` |
|
||||
| `get_workspace_runtime` | `optional_agent_tool` | `workspace:read` | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `snapshot_read:active_task_workspace` | `scenario` + `live`<br>`live_codex` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
|
||||
| `inspect_operation_result` | `always_agent_tool` | none | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `operation_result` | `scenario`<br>`scenario_mock` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
|
||||
| `list_agents` | `optional_agent_tool` | `discovery:agents:read` | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `snapshot_read:company_actors` | `scenario` + `live`<br>`live_codex` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
|
||||
| `list_approvals` | `optional_agent_tool` | `governance:approvals:read` | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `snapshot_read:company_approvals` | `scenario` + `live`<br>`live_codex` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
|
||||
| `list_agents` | `optional_agent_tool` | `discovery:agents:read` | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `snapshot_read:company_actors` | `scenario` + `live`<br>`live_codex` | `agentService.list (redacted projection)`<br>company-scoped actor projection<br>catalog PRP status: `bound` |
|
||||
| `list_approvals` | `optional_agent_tool` | `governance:approvals:read` | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `snapshot_read:company_approvals` | `scenario` + `live`<br>`live_codex` | `approvalService.list`<br>company-scoped approval projection<br>catalog PRP status: `bound` |
|
||||
| `list_cases` | `optional_agent_tool` | `cases:read` | `standard`<br>`skill_test` | `read` | `none` | no | `mock_extension:cases.list` | `scenario`<br>`scenario_mock` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
|
||||
| `list_company_skills` | `optional_agent_tool` | `company_skills:read` | `standard`<br>`skill_test` | `read` | `none` | no | `mock_extension:company_skills.list` | `scenario`<br>`scenario_mock` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
|
||||
| `list_document_revisions` | `always_agent_tool` | none | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `snapshot_read:active_task_document_revisions` | `scenario` + `live`<br>`live_codex` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
|
||||
| `list_documents` | `always_agent_tool` | none | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `snapshot_read:active_task_documents` | `scenario` + `live`<br>`live_codex` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
|
||||
| `list_document_revisions` | `always_agent_tool` | none | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `snapshot_read:active_task_document_revisions` | `scenario` + `live`<br>`live_codex` | `documentService.listIssueDocumentRevisions`<br>bound issue/key revision read<br>catalog PRP status: `bound` |
|
||||
| `list_documents` | `always_agent_tool` | none | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `snapshot_read:active_task_documents` | `scenario` + `live`<br>`live_codex` | `documentService.listIssueDocuments`<br>bound issue document read<br>catalog PRP status: `bound` |
|
||||
| `list_goals` | `optional_agent_tool` | `discovery:goals:read` | `standard`<br>`skill_test` | `read` | `none` | no | `mock_extension:discovery.goals` | `scenario`<br>`scenario_mock` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
|
||||
| `list_projects` | `optional_agent_tool` | `discovery:projects:read` | `standard`<br>`skill_test` | `read` | `none` | no | `mock_extension:discovery.projects` | `scenario`<br>`scenario_mock` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
|
||||
| `list_routines` | `optional_agent_tool` | `routines:read` | `standard`<br>`skill_test` | `read` | `none` | no | `mock_extension:routines.list` | `scenario`<br>`scenario_mock` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
|
||||
| `list_secret_metadata` | `optional_agent_tool` | `secrets:metadata:read` | `standard`<br>`skill_test` | `read` | `none` | no | `mock_extension:secrets.metadata` | `scenario`<br>`scenario_mock` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
|
||||
| `manage_routine` | `optional_agent_tool` | `routines:write` | `standard`<br>`skill_test` | `admin` | `required` | no | `mock_extension:routines.manage` | `scenario`<br>`scenario_mock` | `unbound`<br>company admin/portability item event plus audit record<br>catalog PRP status: `audit_pending` |
|
||||
| `read_document` | `always_agent_tool` | none | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `snapshot_read:active_task_document` | `scenario` + `live`<br>`live_codex` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
|
||||
| `read_document` | `always_agent_tool` | none | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `snapshot_read:active_task_document` | `scenario` + `live`<br>`live_codex` | `documentService.getIssueDocumentByKey`<br>bound issue/key document read<br>catalog PRP status: `bound` |
|
||||
| `read_secret_value` | `optional_agent_tool` | `secrets:values:read` | `standard`<br>`skill_test` | `secret_read` | `none` | yes | `mock_extension:secrets.value` | `scenario`<br>`scenario_mock` | `unbound`<br>redacted tool-result item event; secret value never reaches the wire<br>catalog PRP status: `audit_pending` |
|
||||
| `register_deliverable` | `always_agent_tool` | none | `standard`<br>`planning`<br>`skill_test` | `task_write` | `required` | no | `semantic_command:register_deliverable` | `scenario` + `live`<br>`live_codex` | `unbound`<br>semantic-operation item event plus active-task state diff, work-assessment, and issue-status-decision events<br>catalog PRP status: `audit_pending` |
|
||||
| `report_progress` | `always_agent_tool` | none | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `task_write` | `required` | no | `semantic_command:report_progress` | `scenario` + `live`<br>`live_codex` | `unbound`<br>semantic-operation item event plus active-task state diff, work-assessment, and issue-status-decision events<br>catalog PRP status: `audit_pending` |
|
||||
| `report_progress` | `always_agent_tool` | none | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `task_write` | `required` | no | `semantic_command:report_progress` | `scenario` + `live`<br>`live_codex` | `issueService.addComment + heartbeatRuns semanticToolReceipts`<br>real service mutation + idempotent replay test<br>catalog PRP status: `bound` |
|
||||
| `request_approval` | `optional_agent_tool` | `governance:approvals:request` | `standard`<br>`skill_test` | `governance` | `required` | no | `semantic_command:request_approval` | `scenario` + `live`<br>`live_codex` | `unbound`<br>approval lifecycle plus governed-wait continuation and audit events<br>catalog PRP status: `audit_pending` |
|
||||
| `request_human_input` | `always_agent_tool` | none | `standard`<br>`planning`<br>`skill_test` | `task_write` | `required` | no | `semantic_command:request_human_input` | `scenario` + `live`<br>`live_codex` | `unbound`<br>semantic-operation item event plus active-task state diff, work-assessment, and issue-status-decision events<br>catalog PRP status: `audit_pending` |
|
||||
| `request_human_input` | `always_agent_tool` | none | `standard`<br>`planning`<br>`skill_test` | `task_write` | `required` | no | `semantic_command:request_human_input` | `scenario` + `live`<br>`live_codex` | `issueThreadInteractionService.create`<br>real structured interaction + continuation projection test<br>catalog PRP status: `bound` |
|
||||
| `request_review` | `always_agent_tool` | none | `standard`<br>`skill_test` | `task_write` | `required` | no | `semantic_command:request_review` | `scenario` + `live`<br>`live_codex` | `unbound`<br>semantic-operation item event plus active-task state diff, work-assessment, and issue-status-decision events<br>catalog PRP status: `audit_pending` |
|
||||
| `schedule_wake` | `optional_agent_tool` | `control_plane:wakes` | `standard`<br>`skill_test` | `task_write` | `required` | no | inline/no mapping | `live`<br>`live_codex` | `unbound`<br>semantic-operation item event plus active-task state diff, work-assessment, and issue-status-decision events<br>catalog PRP status: `audit_pending` |
|
||||
| `search_tasks` | `optional_agent_tool` | `discovery:tasks:read` | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `snapshot_read:company_tasks` | `scenario` + `live`<br>`live_codex` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
|
||||
| `search_tasks` | `optional_agent_tool` | `discovery:tasks:read` | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `snapshot_read:company_tasks` | `scenario` + `live`<br>`live_codex` | `issueService.list`<br>shared PRP route + company scope test<br>catalog PRP status: `bound` |
|
||||
| `set_dependencies` | `optional_agent_tool` | `dependencies:write` | `standard`<br>`skill_test` | `company_write` | `required` | no | `semantic_command:set_dependencies` | `scenario` + `live`<br>`live_codex` | `unbound`<br>semantic-operation item event plus company-entity state diff and audit record<br>catalog PRP status: `audit_pending` |
|
||||
| `sync_company_skills` | `optional_agent_tool` | `company_skills:write` | `standard`<br>`skill_test` | `admin` | `required` | no | `mock_extension:company_skills.sync` | `scenario`<br>`scenario_mock` | `unbound`<br>company admin/portability item event plus audit record<br>catalog PRP status: `audit_pending` |
|
||||
| `upsert_case` | `optional_agent_tool` | `cases:write` | `standard`<br>`skill_test` | `company_write` | `required` | no | `mock_extension:cases.upsert` | `scenario`<br>`scenario_mock` | `unbound`<br>semantic-operation item event plus company-entity state diff and audit record<br>catalog PRP status: `audit_pending` |
|
||||
| `write_document` | `always_agent_tool` | none | `standard`<br>`planning`<br>`skill_test` | `task_write` | `required` | no | `semantic_command:write_document` | `scenario` + `live`<br>`live_codex` | `unbound`<br>semantic-operation item event plus active-task state diff, work-assessment, and issue-status-decision events<br>catalog PRP status: `audit_pending` |
|
||||
| `write_document` | `always_agent_tool` | none | `standard`<br>`planning`<br>`skill_test` | `task_write` | `required` | no | `semantic_command:write_document` | `scenario` + `live`<br>`live_codex` | `documentService.upsertIssueDocument + heartbeatRuns semanticToolReceipts`<br>real revision mutation + optimistic concurrency + idempotent replay test<br>catalog PRP status: `bound` |
|
||||
|
||||
## Axis 3: the 16 behavioral eval groups
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ export class HarnessDriverBackend implements NativeSessionBackend {
|
||||
const descriptor = await this.#driver.descriptor();
|
||||
return {
|
||||
kind: "runner",
|
||||
name: descriptor.displayName,
|
||||
name: descriptor.kind,
|
||||
version: descriptor.version,
|
||||
capabilities: structuredClone(descriptor.capabilities),
|
||||
};
|
||||
@@ -86,7 +86,7 @@ export class HarnessDriverBackend implements NativeSessionBackend {
|
||||
}
|
||||
|
||||
class HarnessNativeSession implements NativeSession {
|
||||
readonly #input: OpenNativeSessionInput;
|
||||
#input: OpenNativeSessionInput;
|
||||
readonly #session: HarnessSession;
|
||||
#terminal: PrpTerminalState | null = null;
|
||||
|
||||
@@ -115,6 +115,18 @@ class HarnessNativeSession implements NativeSession {
|
||||
};
|
||||
}
|
||||
|
||||
async attachRun(input: { identity: OpenNativeSessionInput["identity"] }): Promise<void> {
|
||||
if (input.identity.sessionId !== this.#input.identity.sessionId) {
|
||||
throw new Error("native_session_attach_binding_mismatch");
|
||||
}
|
||||
if (this.#session.attachRun === undefined) {
|
||||
throw new Error("native_session_multi_run_unavailable");
|
||||
}
|
||||
await this.#session.attachRun({ runId: input.identity.runId });
|
||||
this.#input = { ...this.#input, identity: structuredClone(input.identity) };
|
||||
this.#terminal = null;
|
||||
}
|
||||
|
||||
async *events(): AsyncIterable<PrpEvent> {
|
||||
for await (const event of this.#session.events()) {
|
||||
if (event.eventType === "run.terminal") {
|
||||
@@ -192,6 +204,10 @@ class HarnessNativeSession implements NativeSession {
|
||||
};
|
||||
}
|
||||
|
||||
async usage(): Promise<Record<string, unknown> | null> {
|
||||
return this.#session.usage?.() ?? null;
|
||||
}
|
||||
|
||||
close(input: { reason: string }) {
|
||||
return this.#session.close(input);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { NativeExecutionInputV1 } from "../contracts/native-execution.js";
|
||||
import type { NativeSessionBackend } from "../contracts/native-session-backend.js";
|
||||
import type { CodexAppServerTransport } from "../drivers/codex/app-server-transport.js";
|
||||
import { createCodexNativeSessionBackend } from "./codex-native-backend.js";
|
||||
import { createOpenCodeNativeSessionBackend } from "./opencode-native-backend.js";
|
||||
|
||||
export interface NativeBackendFactoryOptions {
|
||||
runnerInstanceId?: string;
|
||||
onSpawn?: (meta: { pid: number; processGroupId: number | null; startedAt: string }) => Promise<void>;
|
||||
dynamicTools?: readonly Readonly<Record<string, unknown>>[];
|
||||
dynamicToolHandler?: (call: {
|
||||
tool: string;
|
||||
callId: string;
|
||||
threadId: string;
|
||||
turnId: string;
|
||||
arguments: unknown;
|
||||
}) => Promise<unknown>;
|
||||
codexTransportFactory?: () => CodexAppServerTransport;
|
||||
opencodeRuntimeDirectory?: string;
|
||||
environment?: NodeJS.ProcessEnv;
|
||||
opencodeCommand?: string;
|
||||
}
|
||||
|
||||
export function createNativeSessionBackend(
|
||||
input: NativeExecutionInputV1,
|
||||
options: NativeBackendFactoryOptions = {},
|
||||
): NativeSessionBackend {
|
||||
if (input.provider.kind === "codex") {
|
||||
return createCodexNativeSessionBackend(input, {
|
||||
runnerInstanceId: options.runnerInstanceId,
|
||||
onSpawn: options.onSpawn,
|
||||
dynamicTools: options.dynamicTools,
|
||||
dynamicToolHandler: options.dynamicToolHandler,
|
||||
transportFactory: options.codexTransportFactory,
|
||||
});
|
||||
}
|
||||
if (!options.opencodeRuntimeDirectory) {
|
||||
throw new Error("OpenCode native backend requires an instance runtime directory");
|
||||
}
|
||||
return createOpenCodeNativeSessionBackend(input, {
|
||||
runnerInstanceId: options.runnerInstanceId,
|
||||
onSpawn: options.onSpawn,
|
||||
runtimeDirectory: options.opencodeRuntimeDirectory,
|
||||
environment: options.environment,
|
||||
command: options.opencodeCommand,
|
||||
dynamicTools: options.dynamicTools,
|
||||
dynamicToolHandler: options.dynamicToolHandler,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { createCodexTaskEnvelope } from "../contracts/codex.js";
|
||||
import type { NativeExecutionInputV1 } from "../contracts/native-execution.js";
|
||||
import type { NativeSessionBackend } from "../contracts/native-session-backend.js";
|
||||
import { OpenCodeServerDriver, type OpenCodeServerDriverOptions } from "../drivers/opencode/opencode-server-driver.js";
|
||||
import { HarnessDriverBackend } from "./harness-driver-backend.js";
|
||||
|
||||
export function createOpenCodeNativeSessionBackend(
|
||||
input: NativeExecutionInputV1,
|
||||
options: Omit<OpenCodeServerDriverOptions, "model" | "taskEnvelope">,
|
||||
): NativeSessionBackend {
|
||||
if (input.provider.kind !== "opencode" || !input.provider.model) {
|
||||
throw new Error("OpenCode native backend requires a persisted OpenCode provider/model selection");
|
||||
}
|
||||
return new HarnessDriverBackend(new OpenCodeServerDriver({
|
||||
...options,
|
||||
model: input.provider.model,
|
||||
taskEnvelope: createCodexTaskEnvelope({
|
||||
objective: input.completionContract.contract.objective,
|
||||
contractRevision: input.completionContract.contract.revision,
|
||||
criteria: input.completionContract.contract.criteria,
|
||||
constraints: [
|
||||
"Work only inside the supplied working directory.",
|
||||
"Do not discover or invoke skills.",
|
||||
"Do not call a control-plane API.",
|
||||
"Return one semantic completion result through paperclip_finish or paperclip_block.",
|
||||
],
|
||||
}),
|
||||
}));
|
||||
}
|
||||
@@ -68,8 +68,8 @@ describe("canonical operation-groups specification", () => {
|
||||
expect(input.source.behaviorGroups).toHaveLength(16);
|
||||
expect(input.scenarios).toHaveLength(106);
|
||||
expect(new Set(input.scenarios.map((scenario) => scenario.id)).size).toBe(106);
|
||||
expect(input.eventTypes).toHaveLength(65);
|
||||
expect(input.commandTypes).toHaveLength(14);
|
||||
expect(input.eventTypes).toHaveLength(74);
|
||||
expect(input.commandTypes).toHaveLength(16);
|
||||
});
|
||||
|
||||
it("keeps the checked-in Markdown byte-identical to the generated contract", () => {
|
||||
|
||||
@@ -99,8 +99,8 @@ export interface OperationGroupsInput {
|
||||
catalogIndexSource: string;
|
||||
}
|
||||
|
||||
const EXPECTED_EVENT_COUNT = 65;
|
||||
const EXPECTED_COMMAND_COUNT = 14;
|
||||
const EXPECTED_EVENT_COUNT = 74;
|
||||
const EXPECTED_COMMAND_COUNT = 16;
|
||||
const EXPECTED_SCENARIO_COUNT = 106;
|
||||
const EXPECTED_BEHAVIOR_GROUP_COUNT = 16;
|
||||
|
||||
@@ -282,7 +282,7 @@ export function renderOperationGroupsDocument(input: OperationGroupsInput): stri
|
||||
"",
|
||||
"This document keeps three independent meanings of **group** separate. PRP families describe wire evidence and controller commands; capability placement decides who owns an operation; behavioral eval groups organize the 106 scenario corpus. None of the three axes can be used as a substitute for another.",
|
||||
"",
|
||||
"The generated totals are **65 PRP events in 15 event families**, **14 controller commands in 7 command families**, **10 control-plane operations**, **41 reconciled semantic operations** (14 always, 27 optional), and **106 scenarios in 16 behavior groups**.",
|
||||
"The generated totals are **71 PRP events in 15 event families**, **16 controller commands in 7 command families**, **10 control-plane operations**, **41 reconciled semantic operations** (14 always, 27 optional), and **106 scenarios in 16 behavior groups**.",
|
||||
"",
|
||||
"## Axis 1: PRP v1 event and command families",
|
||||
"",
|
||||
|
||||
@@ -21,6 +21,9 @@ interface EvalSessionRequest {
|
||||
attemptId: string;
|
||||
prompt: string;
|
||||
model: string;
|
||||
provider?: "codex" | "opencode";
|
||||
driver?: "codex_app_server" | "opencode_server";
|
||||
opencodeVersion?: string;
|
||||
runnerd: { path: string; sha256: string };
|
||||
limits: { turnTimeoutMs: number; maxAgentTurns: number; maxEstimatedCostNanodollars: number };
|
||||
session: CreateCapabilityLiveSessionInput;
|
||||
@@ -61,6 +64,8 @@ try {
|
||||
attemptId: request.attemptId,
|
||||
prompt: request.prompt,
|
||||
model: request.model,
|
||||
provider: request.provider ?? "codex",
|
||||
opencodeVersion: request.opencodeVersion,
|
||||
runnerBinaryPath: runnerdPath,
|
||||
seed: request.session.seed ?? {},
|
||||
actorId: request.session.actorId ?? "",
|
||||
@@ -92,6 +97,9 @@ try {
|
||||
build: PAPERCLIP_RUNNER_BUILD_METADATA,
|
||||
runnerd: { path: "[withheld]", sha256: `sha256:${actualDigest}` },
|
||||
requestedModel: request.model,
|
||||
provider: request.provider ?? "codex",
|
||||
driver: request.driver ?? (request.provider === "opencode" ? "opencode_server" : "codex_app_server"),
|
||||
providerVersion: request.provider === "opencode" ? request.opencodeVersion ?? "1.18.17" : null,
|
||||
turn,
|
||||
snapshot,
|
||||
devtools: projectCapabilityDevtools(snapshot),
|
||||
@@ -121,6 +129,9 @@ try {
|
||||
build: PAPERCLIP_RUNNER_BUILD_METADATA,
|
||||
runnerd: { path: "[withheld]", sha256: `sha256:${actualDigest}` },
|
||||
requestedModel: request.model,
|
||||
provider: request.provider ?? "codex",
|
||||
driver: request.driver ?? (request.provider === "opencode" ? "opencode_server" : "codex_app_server"),
|
||||
providerVersion: request.provider === "opencode" ? request.opencodeVersion ?? "1.18.17" : null,
|
||||
timing: { startedAt: evalStartedAt, finishedAt: new Date().toISOString(), durationMs: Date.now() - evalStartedAtMs },
|
||||
}, null, 2)}\n`);
|
||||
process.exitCode = 2;
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
#!/usr/bin/env node
|
||||
import { createInterface } from "node:readline";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import type { HarnessSession, PersistedHarnessSession } from "../contracts/harness-driver.js";
|
||||
import { createCodexTaskEnvelope } from "../contracts/codex.js";
|
||||
import { OpenCodeServerDriver } from "../drivers/opencode/opencode-server-driver.js";
|
||||
|
||||
type RpcMessage = { id?: string | number; method?: string; params?: unknown; result?: unknown; error?: unknown };
|
||||
|
||||
const input = createInterface({ input: process.stdin, crlfDelay: Infinity });
|
||||
const pending = new Map<string, { resolve(value: unknown): void; reject(error: Error): void }>();
|
||||
let nextServerRequestId = 1;
|
||||
let driver: OpenCodeServerDriver | null = null;
|
||||
let session: HarnessSession | null = null;
|
||||
let eventPump: Promise<void> | null = null;
|
||||
let cwd = "";
|
||||
let activeTurnId: string | null = null;
|
||||
const announcedTurnIds = new Set<string>();
|
||||
|
||||
function send(value: unknown): void {
|
||||
process.stdout.write(`${JSON.stringify(value)}\n`);
|
||||
}
|
||||
|
||||
function requestController(method: string, params: unknown): Promise<unknown> {
|
||||
const id = `opencode-${nextServerRequestId++}`;
|
||||
send({ id, method, params });
|
||||
return new Promise((resolveValue, reject) => pending.set(id, { resolve: resolveValue, reject }));
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function text(value: unknown, fallback = ""): string {
|
||||
return typeof value === "string" ? value : fallback;
|
||||
}
|
||||
|
||||
function runtimeDirectory(): string {
|
||||
const configured = process.env.PAPERCLIP_OPENCODE_RUNTIME_DIR?.trim();
|
||||
if (!configured) throw new Error("PAPERCLIP_OPENCODE_RUNTIME_DIR is required");
|
||||
return resolve(configured);
|
||||
}
|
||||
|
||||
async function open(params: Record<string, unknown>, resume: boolean): Promise<Record<string, unknown>> {
|
||||
if (session) return threadResponse(session.ids().providerSessionId ?? session.ids().driverSessionId);
|
||||
cwd = resolve(text(params.cwd, process.cwd()));
|
||||
const model = text(params.model);
|
||||
if (!model.includes("/")) throw new Error("OpenCode proxy requires model in provider/model form");
|
||||
const dynamicTools = Array.isArray(params.dynamicTools) ? params.dynamicTools.map(record) : [];
|
||||
driver = new OpenCodeServerDriver({
|
||||
model,
|
||||
command: process.env.PAPERCLIP_OPENCODE_COMMAND?.trim() || "opencode",
|
||||
runtimeDirectory: runtimeDirectory(),
|
||||
environment: process.env,
|
||||
runnerInstanceId: process.env.PAPERCLIP_RUNNER_INSTANCE_ID ?? "paperclip-runnerd-opencode",
|
||||
taskEnvelope: createCodexTaskEnvelope({
|
||||
objective: "Complete the provider turn supplied by Paperclip Runner.",
|
||||
constraints: [
|
||||
text(params.baseInstructions, "Complete only the supplied task."),
|
||||
"Work only inside the supplied working directory.",
|
||||
"Use Paperclip MCP tools for semantic operations.",
|
||||
],
|
||||
}),
|
||||
dynamicTools,
|
||||
dynamicToolHandler: async (call) => requestController("item/tool/call", {
|
||||
threadId: session?.ids().driverSessionId ?? "opening",
|
||||
turnId: call.turnId,
|
||||
callId: call.callId,
|
||||
tool: call.tool,
|
||||
arguments: call.arguments,
|
||||
}),
|
||||
onDiagnostic: (message) => process.stderr.write(`[opencode] ${message}\n`),
|
||||
});
|
||||
if (resume) {
|
||||
const threadId = text(params.threadId);
|
||||
const snapshot: PersistedHarnessSession = {
|
||||
driverKind: "opencode_server",
|
||||
driverSessionId: threadId,
|
||||
providerSessionId: threadId,
|
||||
runId: process.env.PAPERCLIP_RUN_ID ?? "runnerd-run",
|
||||
normalizedSessionId: process.env.PAPERCLIP_NORMALIZED_SESSION_ID ?? threadId,
|
||||
activeTurnId: null,
|
||||
lastSourceSequence: 0,
|
||||
};
|
||||
const recovered = await driver.recoverSession(snapshot);
|
||||
if (!recovered.recovered || !recovered.session) throw new Error(recovered.reason ?? "OpenCode recovery failed");
|
||||
session = recovered.session;
|
||||
} else {
|
||||
session = await driver.openSession({
|
||||
runId: process.env.PAPERCLIP_RUN_ID ?? "runnerd-run",
|
||||
normalizedSessionId: process.env.PAPERCLIP_NORMALIZED_SESSION_ID ?? `runnerd-${Date.now()}`,
|
||||
workingDirectory: cwd,
|
||||
});
|
||||
}
|
||||
eventPump = pumpEvents(session);
|
||||
return threadResponse(session.ids().providerSessionId ?? session.ids().driverSessionId);
|
||||
}
|
||||
|
||||
function threadResponse(id: string): Record<string, unknown> {
|
||||
return { thread: { id, sessionId: id, cwd }, model: process.env.PAPERCLIP_OPENCODE_MODEL ?? null };
|
||||
}
|
||||
|
||||
function announceTurnStarted(opened: HarnessSession, turnId: string): void {
|
||||
if (announcedTurnIds.has(turnId)) return;
|
||||
announcedTurnIds.add(turnId);
|
||||
send({
|
||||
method: "turn/started",
|
||||
params: {
|
||||
threadId: opened.ids().driverSessionId,
|
||||
turnId,
|
||||
turn: { id: turnId, status: "inProgress" },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function pumpEvents(opened: HarnessSession): Promise<void> {
|
||||
for await (const event of opened.events()) {
|
||||
const payload = record(event.payload);
|
||||
if (event.eventType === "turn.started") {
|
||||
if (typeof event.turnId === "string") announceTurnStarted(opened, event.turnId);
|
||||
} else if (event.eventType === "item.started" || event.eventType === "item.delta" || event.eventType === "item.completed") {
|
||||
const assistantText = payload.kind === "text" && typeof payload.text === "string"
|
||||
? payload.text
|
||||
: null;
|
||||
const method = event.eventType === "item.delta" && assistantText !== null
|
||||
? "item/agentMessage/delta"
|
||||
: event.eventType.replace(".", "/");
|
||||
const providerItem = record(payload.item);
|
||||
const item = assistantText === null
|
||||
? payload.item ?? { id: event.itemId, type: payload.kind, text: payload.text }
|
||||
: { ...providerItem, id: event.itemId, type: "agentMessage", text: assistantText };
|
||||
send({
|
||||
method,
|
||||
params: {
|
||||
threadId: opened.ids().driverSessionId,
|
||||
turnId: event.turnId,
|
||||
itemId: event.itemId,
|
||||
...payload,
|
||||
item,
|
||||
...(method === "item/agentMessage/delta" ? { delta: assistantText } : {}),
|
||||
},
|
||||
});
|
||||
if (payload.kind === "usage") {
|
||||
const usage = record(payload.usage);
|
||||
const cache = record(usage.cache);
|
||||
send({ method: "thread/tokenUsage/updated", params: {
|
||||
threadId: opened.ids().driverSessionId,
|
||||
turnId: event.turnId,
|
||||
tokenUsage: { total: {
|
||||
inputTokens: usage.inputTokens ?? usage.input ?? 0,
|
||||
outputTokens: usage.outputTokens ?? usage.output ?? 0,
|
||||
cachedInputTokens: usage.cachedInputTokens ?? cache.read ?? 0,
|
||||
reasoningTokens: usage.reasoningTokens ?? usage.reasoning ?? 0,
|
||||
costUsd: usage.costUsd ?? usage.cost ?? null,
|
||||
} },
|
||||
} });
|
||||
}
|
||||
} else if (event.eventType === "run.result.proposed") {
|
||||
send({
|
||||
method: "paperclip/runResult",
|
||||
params: {
|
||||
threadId: opened.ids().driverSessionId,
|
||||
turnId: event.turnId,
|
||||
itemId: event.itemId ?? "semantic-result",
|
||||
result: payload,
|
||||
},
|
||||
});
|
||||
} else if (["turn.completed", "turn.failed", "turn.interrupted", "turn.cancelled"].includes(event.eventType)) {
|
||||
const status = event.eventType.slice("turn.".length);
|
||||
send({ method: "turn/completed", params: { threadId: opened.ids().driverSessionId, turnId: event.turnId, turn: { id: event.turnId, status } } });
|
||||
activeTurnId = null;
|
||||
} else if (event.eventType === "harness.diagnostic") {
|
||||
send({ method: "warning", params: { threadId: opened.ids().driverSessionId, message: payload.message ?? payload.code } });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handle(message: RpcMessage): Promise<void> {
|
||||
if (message.method === undefined && message.id !== undefined) {
|
||||
const waiter = pending.get(String(message.id));
|
||||
if (!waiter) return;
|
||||
pending.delete(String(message.id));
|
||||
if (message.error !== undefined) waiter.reject(new Error(JSON.stringify(message.error)));
|
||||
else {
|
||||
const contentItems = record(message.result).contentItems;
|
||||
waiter.resolve(Array.isArray(contentItems) ? contentItems[0] ?? message.result : message.result);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!message.method || message.id === undefined) return;
|
||||
const params = record(message.params);
|
||||
let result: unknown;
|
||||
switch (message.method) {
|
||||
case "initialize":
|
||||
result = { user: { sessionId: "opencode" }, serverInfo: { name: "opencode", version: "1.18.17" } };
|
||||
break;
|
||||
case "thread/start": result = await open(params, false); break;
|
||||
case "thread/resume": result = await open(params, true); break;
|
||||
case "turn/start": {
|
||||
if (!session) throw new Error("OpenCode thread is not open");
|
||||
const inputItems = Array.isArray(params.input) ? params.input.map(record) : [];
|
||||
const messageText = inputItems.map((entry) => text(entry.text)).filter(Boolean).join("\n");
|
||||
const turn = await session.startTurn({ message: { role: "user", text: messageText } });
|
||||
activeTurnId = turn.turnId;
|
||||
// OpenCode normally publishes session/turn startup over SSE, but a fast
|
||||
// completion can make the synchronous prompt response the only place the
|
||||
// authoritative turn id is observed. Emit the normalized notification
|
||||
// after this request's JSON-RPC response: runnerd's request reader ignores
|
||||
// notifications that arrive before the matching response. Clearing an
|
||||
// earlier SSE announcement guarantees one post-response signal, while
|
||||
// announceTurnStarted still deduplicates any later SSE echo.
|
||||
queueMicrotask(() => {
|
||||
announcedTurnIds.delete(turn.turnId);
|
||||
announceTurnStarted(session!, turn.turnId);
|
||||
});
|
||||
result = { turn: { id: turn.turnId, status: "inProgress" } };
|
||||
break;
|
||||
}
|
||||
case "turn/interrupt":
|
||||
if (!session?.interrupt) throw new Error("OpenCode interruption is unavailable");
|
||||
await session.interrupt({ turnId: text(params.turnId, activeTurnId ?? "") });
|
||||
result = true;
|
||||
break;
|
||||
case "thread/read":
|
||||
if (!session) throw new Error("OpenCode thread is not open");
|
||||
result = { thread: { id: session.ids().driverSessionId, cwd, turns: activeTurnId ? [{ id: activeTurnId, status: "inProgress" }] : [] }, transcript: await session.read?.() };
|
||||
break;
|
||||
default: throw new Error(`Unsupported OpenCode proxy method ${message.method}`);
|
||||
}
|
||||
send({ id: message.id, result });
|
||||
}
|
||||
|
||||
input.on("line", (line) => {
|
||||
if (!line.trim()) return;
|
||||
let message: RpcMessage;
|
||||
try { message = JSON.parse(line) as RpcMessage; }
|
||||
catch (error) { process.stderr.write(`Invalid JSON-RPC input: ${String(error)}\n`); return; }
|
||||
void handle(message).catch((error) => send({ id: message.id, error: { code: -32000, message: error instanceof Error ? error.message : String(error) } }));
|
||||
});
|
||||
|
||||
async function shutdown(): Promise<void> {
|
||||
await session?.close({ reason: "proxy_shutdown", force: true }).catch(() => {});
|
||||
await eventPump?.catch(() => {});
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on("SIGTERM", () => { void shutdown(); });
|
||||
process.on("SIGINT", () => { void shutdown(); });
|
||||
@@ -328,6 +328,7 @@ export interface HarnessSession {
|
||||
displayId?: string | null;
|
||||
};
|
||||
events(): AsyncIterable<PrpEvent>;
|
||||
attachRun?(input: { runId: string }): Promise<void> | void;
|
||||
startTurn(input: { message: NativeUserMessage }): Promise<{ turnId: string }>;
|
||||
steer?(input: { turnId: string; message: NativeUserMessage }): Promise<void>;
|
||||
interrupt?(input: { turnId?: string; reason?: string }): Promise<void>;
|
||||
|
||||
@@ -19,7 +19,13 @@ const input: NativeExecutionInputV1 = {
|
||||
workMode: "standard",
|
||||
},
|
||||
workspace: { cwd: "/safe/workspace", repoUrl: null, repoRef: null, branchName: null },
|
||||
session: { normalizedSessionId: null, driverKind: "codex_app_server", protocolVersion: 1 },
|
||||
session: {
|
||||
normalizedSessionId: null,
|
||||
driverKind: "codex_app_server",
|
||||
protocolVersion: 1,
|
||||
lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null },
|
||||
},
|
||||
provider: { kind: "codex", model: null },
|
||||
completionContract: {
|
||||
id: "contract-1",
|
||||
sha256: "abc123",
|
||||
@@ -61,4 +67,50 @@ describe("NativeExecutionInputV1", () => {
|
||||
workspace: { ...input.workspace, env: { PAPERCLIP_API_KEY: "canary" } },
|
||||
})).toThrow("unknown field env");
|
||||
});
|
||||
|
||||
it("accepts a persisted OpenCode driver/model pair and rejects mismatches", () => {
|
||||
const opencode = parseNativeExecutionInput({
|
||||
...input,
|
||||
session: { ...input.session, driverKind: "opencode_server" },
|
||||
provider: { kind: "opencode", model: "openrouter/deepseek/deepseek-v4-flash-0731" },
|
||||
});
|
||||
expect(opencode.provider).toEqual({
|
||||
kind: "opencode",
|
||||
model: "openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
});
|
||||
expect(() => parseNativeExecutionInput({
|
||||
...input,
|
||||
session: { ...input.session, driverKind: "opencode_server" },
|
||||
provider: { kind: "codex", model: null },
|
||||
})).toThrow("does not match");
|
||||
});
|
||||
|
||||
it("deserializes pre-provider Codex state as Codex", () => {
|
||||
const legacy = structuredClone(input) as Record<string, unknown>;
|
||||
delete legacy.provider;
|
||||
expect(parseNativeExecutionInput(legacy).provider).toEqual({ kind: "codex", model: null });
|
||||
});
|
||||
|
||||
it("defaults legacy lifecycle state to per-turn and validates warm timeouts", () => {
|
||||
const legacy = structuredClone(input) as Record<string, unknown>;
|
||||
delete (legacy.session as Record<string, unknown>).lifecyclePolicy;
|
||||
expect(parseNativeExecutionInput(legacy).session.lifecyclePolicy).toEqual({
|
||||
mode: "per_turn",
|
||||
idleTimeoutMs: null,
|
||||
});
|
||||
expect(parseNativeExecutionInput({
|
||||
...input,
|
||||
session: {
|
||||
...input.session,
|
||||
lifecyclePolicy: { mode: "warm", idleTimeoutMs: 300_000 },
|
||||
},
|
||||
}).session.lifecyclePolicy).toEqual({ mode: "warm", idleTimeoutMs: 300_000 });
|
||||
expect(() => parseNativeExecutionInput({
|
||||
...input,
|
||||
session: {
|
||||
...input.session,
|
||||
lifecyclePolicy: { mode: "warm", idleTimeoutMs: 0 },
|
||||
},
|
||||
})).toThrow("positive integer");
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,10 @@ export interface NativeCredentialBindingRef {
|
||||
displayName: string | null;
|
||||
}
|
||||
|
||||
export type NativeSessionLifecyclePolicy =
|
||||
| { mode: "per_turn"; idleTimeoutMs: null }
|
||||
| { mode: "warm"; idleTimeoutMs: number };
|
||||
|
||||
export interface NativeExecutionInputV1 {
|
||||
schema: typeof NATIVE_EXECUTION_INPUT_SCHEMA;
|
||||
binding: {
|
||||
@@ -48,8 +52,13 @@ export interface NativeExecutionInputV1 {
|
||||
};
|
||||
session: {
|
||||
normalizedSessionId: string | null;
|
||||
driverKind: "codex_app_server";
|
||||
driverKind: "codex_app_server" | "opencode_server";
|
||||
protocolVersion: 1;
|
||||
lifecyclePolicy: NativeSessionLifecyclePolicy;
|
||||
};
|
||||
provider: {
|
||||
kind: "codex" | "opencode";
|
||||
model: string | null;
|
||||
};
|
||||
completionContract: {
|
||||
id: string;
|
||||
@@ -131,6 +140,7 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInputV
|
||||
"task",
|
||||
"workspace",
|
||||
"session",
|
||||
"provider",
|
||||
"completionContract",
|
||||
"interactionResponses",
|
||||
"credentialBindings",
|
||||
@@ -146,15 +156,55 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInputV
|
||||
const workspace = record(input.workspace, "input.workspace");
|
||||
exactKeys(workspace, ["cwd", "repoUrl", "repoRef", "branchName"], "input.workspace");
|
||||
const session = record(input.session, "input.session");
|
||||
exactKeys(session, ["normalizedSessionId", "driverKind", "protocolVersion"], "input.session");
|
||||
exactKeys(session, ["normalizedSessionId", "driverKind", "protocolVersion", "lifecyclePolicy"], "input.session");
|
||||
const completionContract = record(input.completionContract, "input.completionContract");
|
||||
exactKeys(completionContract, ["id", "sha256", "schemaVersion", "contract"], "input.completionContract");
|
||||
const contract = record(completionContract.contract, "input.completionContract.contract");
|
||||
exactKeys(contract, ["revision", "objective", "criteria"], "input.completionContract.contract");
|
||||
|
||||
if (task.workMode !== "standard") throw new NativeExecutionInputError("input.task.workMode must be standard");
|
||||
if (session.driverKind !== "codex_app_server" || session.protocolVersion !== 1) {
|
||||
throw new NativeExecutionInputError("input.session must select codex_app_server protocol version 1");
|
||||
if (
|
||||
(session.driverKind !== "codex_app_server" && session.driverKind !== "opencode_server")
|
||||
|| session.protocolVersion !== 1
|
||||
) {
|
||||
throw new NativeExecutionInputError("input.session must select a supported protocol version 1 driver");
|
||||
}
|
||||
const lifecyclePolicyValue = session.lifecyclePolicy === undefined
|
||||
? { mode: "per_turn", idleTimeoutMs: null }
|
||||
: record(session.lifecyclePolicy, "input.session.lifecyclePolicy");
|
||||
exactKeys(lifecyclePolicyValue, ["mode", "idleTimeoutMs"], "input.session.lifecyclePolicy");
|
||||
let lifecyclePolicy: NativeSessionLifecyclePolicy;
|
||||
if (lifecyclePolicyValue.mode === "per_turn") {
|
||||
if (lifecyclePolicyValue.idleTimeoutMs !== null) {
|
||||
throw new NativeExecutionInputError("input.session.lifecyclePolicy.idleTimeoutMs must be null for per_turn");
|
||||
}
|
||||
lifecyclePolicy = { mode: "per_turn", idleTimeoutMs: null };
|
||||
} else if (lifecyclePolicyValue.mode === "warm") {
|
||||
if (!Number.isSafeInteger(lifecyclePolicyValue.idleTimeoutMs) || Number(lifecyclePolicyValue.idleTimeoutMs) <= 0) {
|
||||
throw new NativeExecutionInputError("input.session.lifecyclePolicy.idleTimeoutMs must be a positive integer for warm");
|
||||
}
|
||||
lifecyclePolicy = { mode: "warm", idleTimeoutMs: Number(lifecyclePolicyValue.idleTimeoutMs) };
|
||||
} else {
|
||||
throw new NativeExecutionInputError("input.session.lifecyclePolicy.mode must be per_turn or warm");
|
||||
}
|
||||
const provider = input.provider === undefined
|
||||
? { kind: "codex", model: null }
|
||||
: record(input.provider, "input.provider");
|
||||
exactKeys(provider, ["kind", "model"], "input.provider");
|
||||
if (provider.kind !== "codex" && provider.kind !== "opencode") {
|
||||
throw new NativeExecutionInputError("input.provider.kind must be codex or opencode");
|
||||
}
|
||||
if (
|
||||
(provider.kind === "codex" && session.driverKind !== "codex_app_server")
|
||||
|| (provider.kind === "opencode" && session.driverKind !== "opencode_server")
|
||||
) {
|
||||
throw new NativeExecutionInputError("input.provider.kind does not match input.session.driverKind");
|
||||
}
|
||||
const providerModel = provider.model === null || provider.model === undefined
|
||||
? null
|
||||
: text(provider.model, "input.provider.model");
|
||||
if (provider.kind === "opencode" && (providerModel === null || !providerModel.includes("/"))) {
|
||||
throw new NativeExecutionInputError("input.provider.model is required for opencode in provider/model form");
|
||||
}
|
||||
if (!Array.isArray(contract.criteria) || contract.criteria.length === 0) {
|
||||
throw new NativeExecutionInputError("input.completionContract.contract.criteria must not be empty");
|
||||
@@ -206,7 +256,10 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInputV
|
||||
identifier: text(task.identifier, "input.task.identifier"),
|
||||
title: text(task.title, "input.task.title"),
|
||||
description: nullableText(task.description, "input.task.description"),
|
||||
prompt: text(task.prompt, "input.task.prompt"),
|
||||
// `prompt` was added after native run inputs were already persisted.
|
||||
// Recovery must retain those runs, so derive the same bounded model
|
||||
// prompt from their task fields instead of making them unrecoverable.
|
||||
prompt: text(task.prompt ?? task.description ?? task.title, "input.task.prompt"),
|
||||
workMode: "standard",
|
||||
},
|
||||
workspace: {
|
||||
@@ -217,8 +270,13 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInputV
|
||||
},
|
||||
session: {
|
||||
normalizedSessionId: nullableText(session.normalizedSessionId, "input.session.normalizedSessionId"),
|
||||
driverKind: "codex_app_server",
|
||||
driverKind: session.driverKind,
|
||||
protocolVersion: 1,
|
||||
lifecyclePolicy,
|
||||
},
|
||||
provider: {
|
||||
kind: provider.kind,
|
||||
model: providerModel,
|
||||
},
|
||||
completionContract: {
|
||||
id: text(completionContract.id, "input.completionContract.id"),
|
||||
|
||||
@@ -49,6 +49,7 @@ export interface NativeSessionRecoveryResult {
|
||||
export interface NativeSession {
|
||||
identity(): NativeRunIdentity;
|
||||
capabilities(): Promise<NativeSessionCapabilities>;
|
||||
attachRun?(input: { identity: NativeRunIdentity }): Promise<void>;
|
||||
events(input?: { afterCursor?: string | null }): AsyncIterable<PrpEvent>;
|
||||
startTurn(input: { message: NativeUserMessage }): Promise<{ turnId: string }>;
|
||||
steer?(input: { turnId: string; message: NativeUserMessage }): Promise<void>;
|
||||
@@ -59,6 +60,7 @@ export interface NativeSession {
|
||||
terminal: PrpTerminalState;
|
||||
turnId: string | null;
|
||||
} | null>;
|
||||
usage?(): Promise<Record<string, unknown> | null>;
|
||||
snapshot(): Promise<PersistedNativeSession>;
|
||||
close(input: { reason: string }): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -700,6 +700,17 @@ export class DurablePrpControlPlane {
|
||||
}
|
||||
}
|
||||
|
||||
/** Forces a resumable re-authentication after an immutable run attachment rotates. */
|
||||
disconnectActiveRunner(): void {
|
||||
const connections = [...this.#connections];
|
||||
this.#connections.clear();
|
||||
for (const connection of connections) connection.close();
|
||||
}
|
||||
|
||||
activeRunnerConnectionCount(): number {
|
||||
return [...this.#connections].filter((connection) => connection.secureChannel !== null).length;
|
||||
}
|
||||
|
||||
issueBootstrapTicket(ttlMs = 5_000): string {
|
||||
const ticket = `bootstrap_${randomUUID()}`;
|
||||
const material = credentialMaterial(ticket);
|
||||
@@ -1239,10 +1250,31 @@ export class DurablePrpControlPlane {
|
||||
? status
|
||||
: "failed";
|
||||
command.result = structuredClone(result);
|
||||
if (command.status === "completed" && command.type === "run.attach") {
|
||||
this.#applyRunAttachment(command.payload);
|
||||
}
|
||||
this.store.save();
|
||||
this.#sendNextCommand(connection);
|
||||
}
|
||||
|
||||
#applyRunAttachment(payload: Record<string, unknown>): void {
|
||||
const runId = payload.runId;
|
||||
const turnId = payload.turnId;
|
||||
const itemId = payload.itemId;
|
||||
if (typeof runId !== "string" || typeof turnId !== "string" || typeof itemId !== "string") {
|
||||
throw new Error("completed run.attach omitted its identity binding");
|
||||
}
|
||||
const next = { ...this.identity, runId, turnId, itemId };
|
||||
Object.assign(this.identity, next);
|
||||
this.store.state.identity = structuredClone(next);
|
||||
for (const ticket of Object.values(this.store.state.tickets)) {
|
||||
ticket.identity = structuredClone(next);
|
||||
}
|
||||
for (const lease of Object.values(this.store.state.leases)) {
|
||||
lease.identity = structuredClone(next);
|
||||
}
|
||||
}
|
||||
|
||||
#event(connection: MockWebSocketConnection, envelope: Record<string, unknown>): void {
|
||||
const event = envelope.payload as Record<string, unknown> | undefined;
|
||||
const sourceSeq = event?.sourceSeq;
|
||||
@@ -1299,7 +1331,8 @@ export class DurablePrpControlPlane {
|
||||
) {
|
||||
const call = { callId: semantic.callId, operationId: semantic.operationId, input: semantic.input };
|
||||
void this.#onSemanticToolInput(call).then((result) => {
|
||||
this.queueCommand("semantic_tool.result", { ...call, result }, `command_tool_${call.callId}`, true);
|
||||
const runScope = createHash("sha256").update(this.identity.runId).digest("hex").slice(0, 12);
|
||||
this.queueCommand("semantic_tool.result", { ...call, result }, `command_tool_${runScope}_${call.callId}`, true);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1370,6 +1403,10 @@ export function spawnRunner(options: {
|
||||
maxOutboxBytes: number;
|
||||
p0ReserveBytes: number;
|
||||
maxRuntimeMs?: number;
|
||||
maxLifetimeMs?: number;
|
||||
lifecyclePolicy?:
|
||||
| { mode: "per_turn"; idleTimeoutMs: null }
|
||||
| { mode: "warm"; idleTimeoutMs: number };
|
||||
runnerBinaryPath?: string;
|
||||
environment?: NodeJS.ProcessEnv;
|
||||
}): RunnerProcessHandle {
|
||||
@@ -1404,9 +1441,18 @@ export function spawnRunner(options: {
|
||||
String(options.p0ReserveBytes),
|
||||
"--reconnect-delay-ms",
|
||||
"20",
|
||||
"--max-runtime-ms",
|
||||
String(options.maxRuntimeMs ?? 10_000),
|
||||
];
|
||||
if (options.maxLifetimeMs !== undefined) {
|
||||
args.push("--max-lifetime-ms", String(options.maxLifetimeMs));
|
||||
} else if (options.maxRuntimeMs !== undefined) {
|
||||
args.push("--max-runtime-ms", String(options.maxRuntimeMs));
|
||||
}
|
||||
if (options.lifecyclePolicy !== undefined) {
|
||||
args.push("--lifecycle-mode", options.lifecyclePolicy.mode);
|
||||
if (options.lifecyclePolicy.mode === "warm") {
|
||||
args.push("--idle-timeout-ms", String(options.lifecyclePolicy.idleTimeoutMs));
|
||||
}
|
||||
}
|
||||
const child = spawn(options.runnerBinaryPath ?? runnerBinary, args, {
|
||||
cwd: packageRoot,
|
||||
env: runnerEnvironment(options.ticket, options.environment),
|
||||
@@ -1643,6 +1689,10 @@ export interface DurableEvalSessionInput {
|
||||
explicitClaims: string[];
|
||||
turnTimeoutMs: number;
|
||||
toolExposure?: "eager" | "lazy";
|
||||
provider?: "codex" | "opencode";
|
||||
opencodeVersion?: string;
|
||||
opencodeCommand?: string;
|
||||
opencodeProxyPath?: string;
|
||||
}
|
||||
|
||||
export type DurableEvalInfrastructureFailureClass =
|
||||
@@ -1773,6 +1823,9 @@ export async function runDurableEvalSession(input: DurableEvalSessionInput): Pro
|
||||
"-c", "shell_environment_policy.inherit=\"none\"",
|
||||
"app-server",
|
||||
];
|
||||
const providerKind = input.provider ?? "codex";
|
||||
const opencodeProxyPath = input.opencodeProxyPath
|
||||
?? fileURLToPath(new URL("../cli/opencode-app-server-proxy.js", import.meta.url));
|
||||
core.queueCommand("run.prepare", {
|
||||
authorizedTools: {
|
||||
schema: "paperclip.runner.authorized-tools.v1", schemaVersion: 1,
|
||||
@@ -1780,7 +1833,9 @@ export async function runDurableEvalSession(input: DurableEvalSessionInput): Pro
|
||||
operations: exposedOperations,
|
||||
},
|
||||
provider: {
|
||||
command: process.env.PAPERCLIP_CODEX_COMMAND ?? "codex", args: providerArgs,
|
||||
kind: providerKind,
|
||||
command: providerKind === "opencode" ? process.execPath : process.env.PAPERCLIP_CODEX_COMMAND ?? "codex",
|
||||
args: providerKind === "opencode" ? [opencodeProxyPath] : providerArgs,
|
||||
cwd: tmpdir(), model: input.model,
|
||||
instructions: "You are a Paperclip agent. Use only the supplied Paperclip tools for company state. Follow the user request, then reply concisely. Do not use shell or filesystem tools.",
|
||||
},
|
||||
@@ -1793,6 +1848,16 @@ export async function runDurableEvalSession(input: DurableEvalSessionInput): Pro
|
||||
ticket: core.issueBootstrapTicket(), maxOutboxBytes: 256 * 1024,
|
||||
p0ReserveBytes: 64 * 1024, maxRuntimeMs: input.turnTimeoutMs + 30_000,
|
||||
runnerBinaryPath: input.runnerBinaryPath,
|
||||
environment: providerKind === "opencode"
|
||||
? {
|
||||
...process.env,
|
||||
PAPERCLIP_OPENCODE_COMMAND: input.opencodeCommand ?? "opencode",
|
||||
PAPERCLIP_OPENCODE_RUNTIME_DIR: resolve(root, "opencode"),
|
||||
PAPERCLIP_RUNNER_INSTANCE_ID: identity.runnerInstanceId,
|
||||
PAPERCLIP_RUN_ID: identity.runId,
|
||||
PAPERCLIP_NORMALIZED_SESSION_ID: identity.normalizedSessionId,
|
||||
}
|
||||
: process.env,
|
||||
});
|
||||
try {
|
||||
const deadline = Date.now() + input.turnTimeoutMs;
|
||||
@@ -1838,10 +1903,14 @@ export async function runDurableEvalSession(input: DurableEvalSessionInput): Pro
|
||||
}
|
||||
let evidenceIndex = 1;
|
||||
let assistantText = "";
|
||||
let semanticResult: Record<string, unknown> | null = null;
|
||||
let usage: Record<string, number> = {};
|
||||
const projectedCalls = new Map<string, string>();
|
||||
for (const record of records) {
|
||||
const payload = record.payload as Record<string, unknown> | undefined;
|
||||
if (record.eventType === "run.result.proposed" && payload && semanticResult === null) {
|
||||
semanticResult = structuredClone(payload);
|
||||
}
|
||||
const semantic = payload?.semantic_tool as Record<string, unknown> | undefined;
|
||||
if (semantic?.phase === "input" || semantic?.phase === "result") {
|
||||
const wrapperInput = semantic.input as Record<string, unknown> | undefined;
|
||||
@@ -1914,10 +1983,11 @@ export async function runDurableEvalSession(input: DurableEvalSessionInput): Pro
|
||||
snapshot: {
|
||||
schema: "paperclip.capability.live-session.v1", revision: authority.snapshot().revision,
|
||||
sessionId: identity.normalizedSessionId, providerThreadId: "withheld", providerSessionId: null,
|
||||
providerModel: { id: input.model, provider: "openai" }, status: "idle", activeTurnId: null,
|
||||
providerModel: { id: input.model, provider: providerKind === "opencode" ? input.model.split("/", 1)[0] : "openai" }, status: "idle", activeTurnId: null,
|
||||
semanticResult,
|
||||
createdAt: now, updatedAt: now,
|
||||
authority: { active: true, runId: identity.runId, companyId, actorId: input.actorId, taskId: input.taskId, sessionId: identity.normalizedSessionId, scenarioId: input.attemptId, capabilities: input.capabilities, explicitClaims: input.explicitClaims },
|
||||
config: { workingDirectory: tmpdir(), requestedModel: input.model, scenario: { id: input.attemptId, claims: input.capabilities }, capabilities: input.capabilities, explicitClaims: input.explicitClaims, seedState: initialState, turnTimeoutMs: input.turnTimeoutMs },
|
||||
config: { workingDirectory: tmpdir(), requestedModel: input.model, provider: providerKind, driver: providerKind === "opencode" ? "opencode_server" : "codex_app_server", providerVersion: providerKind === "opencode" ? input.opencodeVersion ?? "1.18.17" : null, scenario: { id: input.attemptId, claims: input.capabilities }, capabilities: input.capabilities, explicitClaims: input.explicitClaims, seedState: initialState, turnTimeoutMs: input.turnTimeoutMs },
|
||||
mockState: finalState,
|
||||
transcript: [
|
||||
{ id: "transcript-user", role: "user", text: input.prompt, turnId: identity.turnId, at: now },
|
||||
|
||||
@@ -20,6 +20,11 @@ export interface CodexAppServerTransport {
|
||||
setServerRequestHandler(handler: CodexServerRequestHandler): void;
|
||||
close(): Promise<void>;
|
||||
processInfo?(): CodexTransportProcessInfo;
|
||||
attachRun?(input: {
|
||||
runId: string;
|
||||
turnId: string;
|
||||
itemId: string;
|
||||
}): Promise<void>;
|
||||
}
|
||||
|
||||
export interface CodexTransportProcessInfo {
|
||||
@@ -193,7 +198,7 @@ export function redactCodexDiagnostic(message: string): string {
|
||||
.replace(/([?&](?:api[_-]?key|token|secret|password)=)[^&#\s]+/gi, "$1[REDACTED]")
|
||||
.replace(/(["'](?:api[_-]?key|token|secret|password|authorization)["']\s*:\s*["'])[^"']+/gi, "$1[REDACTED]")
|
||||
.replace(/(api[_-]?key|token|secret|password)\s*[=:]\s*[^\s,;]+/gi, "$1=[REDACTED]")
|
||||
.replace(/(PAPERCLIP_API_KEY|OPENAI_API_KEY)=[^\s]+/g, "$1=[REDACTED]");
|
||||
.replace(/(PAPERCLIP_API_KEY|OPENAI_API_KEY|OPENROUTER_API_KEY)=[^\s]+/g, "$1=[REDACTED]");
|
||||
}
|
||||
|
||||
function proxyContainsCredentials(value: string): boolean {
|
||||
|
||||
@@ -357,6 +357,7 @@ describe("Codex app-server Codex driver", () => {
|
||||
transport.push("item/started", { threadId: "thread-1", turnId: turn.turnId, item: { id: "cmd-1", type: "commandExecution", command: "printf hello" } });
|
||||
transport.push("item/commandExecution/outputDelta", { threadId: "thread-1", turnId: turn.turnId, itemId: "cmd-1", delta: "hello" });
|
||||
transport.push("item/completed", { threadId: "thread-1", turnId: turn.turnId, item: { id: "file-1", type: "fileChange", changes: [{ path: "hello.txt" }] } });
|
||||
transport.push("item/completed", { threadId: "thread-1", turnId: turn.turnId, item: { id: "reference-1", type: "agentMessage", text: "Open [hello.txt](hello.txt)." } });
|
||||
const requestResolution = transport.invoke({
|
||||
id: "request-1",
|
||||
method: "item/tool/requestUserInput",
|
||||
@@ -379,9 +380,15 @@ describe("Codex app-server Codex driver", () => {
|
||||
expect(events.filter((event) => event.eventType === "run.result.proposed")).toHaveLength(1);
|
||||
expect(events.filter((event) => event.eventType === "turn.completed")).toHaveLength(1);
|
||||
expect(events.filter((event) => event.eventType === "run.terminal")).toHaveLength(0);
|
||||
expect(events.find((event) => event.eventType === "workspace.change.updated")?.payload)
|
||||
.toMatchObject({ schema: "paperclip.workspace.diff.v1", complete: true, 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: "hello.txt" });
|
||||
expect(events.map((event) => event.eventType)).toEqual(expect.arrayContaining([
|
||||
"session.started", "turn.started", "item.started", "item.delta", "item.completed", "run.result.proposed", "turn.completed",
|
||||
"runtime_request.created", "runtime_request.resolved",
|
||||
"runtime_request.created", "runtime_request.resolved", "workspace.change.updated", "workspace.diff.recorded",
|
||||
]));
|
||||
|
||||
const capabilities: PrpCapabilities = {
|
||||
@@ -407,6 +414,40 @@ describe("Codex app-server Codex driver", () => {
|
||||
expect(await session.usage?.()).toMatchObject({ modelContextWindow: 128000 });
|
||||
});
|
||||
|
||||
it("normalizes provider message and reasoning phases onto every streamed item event", async () => {
|
||||
const transport = new FakeCodexTransport();
|
||||
const session = await makeDriver([transport]).openSession({
|
||||
runId: "run-stream-channels",
|
||||
normalizedSessionId: "normalized-stream-channels",
|
||||
workingDirectory: "/workspace",
|
||||
});
|
||||
const turn = await session.startTurn({ message: { role: "user", text: "Stream progress." } });
|
||||
transport.push("turn/started", { threadId: "thread-1", turn: { id: turn.turnId, status: "inProgress" } });
|
||||
transport.push("item/started", { threadId: "thread-1", turnId: turn.turnId, item: { id: "progress-1", type: "agentMessage", phase: "commentary", text: "" } });
|
||||
transport.push("item/agentMessage/delta", { threadId: "thread-1", turnId: turn.turnId, itemId: "progress-1", delta: "Running it now." });
|
||||
transport.push("item/completed", { threadId: "thread-1", turnId: turn.turnId, item: { id: "progress-1", type: "agentMessage", phase: "commentary", text: "Running it now." } });
|
||||
transport.push("item/started", { threadId: "thread-1", turnId: turn.turnId, item: { id: "reason-1", type: "reasoning", summary: [] } });
|
||||
transport.push("item/reasoning/summaryTextDelta", { threadId: "thread-1", turnId: turn.turnId, itemId: "reason-1", delta: "Summary" });
|
||||
transport.push("item/reasoning/textDelta", { threadId: "thread-1", turnId: turn.turnId, itemId: "reason-1", delta: "Detail" });
|
||||
transport.push("item/completed", { threadId: "thread-1", turnId: turn.turnId, item: { id: "reason-1", type: "reasoning", summary: [{ text: "Summary" }] } });
|
||||
transport.push("item/started", { threadId: "thread-1", turnId: turn.turnId, item: { id: "final-1", type: "agentMessage", phase: "final_answer", text: "" } });
|
||||
transport.push("item/agentMessage/delta", { threadId: "thread-1", turnId: turn.turnId, itemId: "final-1", delta: JSON.stringify(result) });
|
||||
transport.push("item/completed", { threadId: "thread-1", turnId: turn.turnId, item: { id: "final-1", type: "agentMessage", phase: "final_answer", text: JSON.stringify(result) } });
|
||||
transport.push("turn/completed", { threadId: "thread-1", turn: { id: turn.turnId, status: "completed", items: [] } });
|
||||
|
||||
const events = await collectUntilTerminal(session.events());
|
||||
const pick = (kind: string, channel: string) => events.find((event) =>
|
||||
event.payload.kind === kind && event.payload.channel === channel);
|
||||
expect(pick("agentMessage", "progress")?.payload).toMatchObject({ providerPhase: "commentary" });
|
||||
expect(events.find((event) => event.eventType === "item.delta" && event.payload.channel === "progress")?.payload)
|
||||
.toMatchObject({ providerMethod: "item/agentMessage/delta", text: "Running it now." });
|
||||
expect(events.find((event) => event.eventType === "item.delta" && event.payload.channel === "summary")?.payload)
|
||||
.toMatchObject({ providerMethod: "item/reasoning/summaryTextDelta" });
|
||||
expect(events.find((event) => event.eventType === "item.delta" && event.payload.channel === "detail")?.payload)
|
||||
.toMatchObject({ providerMethod: "item/reasoning/textDelta" });
|
||||
expect(pick("agentMessage", "final")?.payload).toMatchObject({ providerPhase: "final_answer" });
|
||||
});
|
||||
|
||||
it("captures an exact skillless model/environment snapshot with credentials absent", async () => {
|
||||
const transport = new FakeCodexTransport();
|
||||
const session = await makeDriver([transport]).openSession({ runId: "run-context", normalizedSessionId: "normalized-context", workingDirectory: "/workspace" });
|
||||
@@ -437,6 +478,7 @@ describe("Codex app-server Codex driver", () => {
|
||||
"skills.include_instructions": false,
|
||||
include_apps_instructions: false,
|
||||
include_collaboration_mode_instructions: false,
|
||||
"features.image_generation": false,
|
||||
},
|
||||
permissions: "paperclip-runner-workspace-only",
|
||||
runtimeWorkspaceRoots: ["/workspace"],
|
||||
@@ -459,6 +501,8 @@ describe("Codex app-server Codex driver", () => {
|
||||
"permissions.paperclip-runner-workspace-only.network.enabled=false",
|
||||
'shell_environment_policy.inherit="none"',
|
||||
expect.stringContaining('shell_environment_policy.set={PATH="/bin",LANG="C.UTF-8"}'),
|
||||
"--disable",
|
||||
"image_generation",
|
||||
]));
|
||||
expect(appServerArgs.find((arg) => arg.startsWith("permissions."))).toContain(
|
||||
'"/isolated/home"="none"',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { parse, relative, resolve } from "node:path";
|
||||
|
||||
import type {
|
||||
@@ -41,6 +42,7 @@ import {
|
||||
type PrpStructuredRunResult,
|
||||
} from "../../protocol/replay-contract.js";
|
||||
import type { NativeUserMessage } from "../../contracts/types.js";
|
||||
import { paperclipWorkspaceFileReferencesFromText } from "../../live/workspace-file-reference.js";
|
||||
import {
|
||||
ProcessCodexAppServerTransport,
|
||||
createSanitizedCodexEnvironment,
|
||||
@@ -246,6 +248,7 @@ const SKILLLESS_BASE_CONFIG = {
|
||||
"features.plugins": false,
|
||||
"features.multi_agent": false,
|
||||
"features.memories": false,
|
||||
"features.image_generation": false,
|
||||
} as const;
|
||||
|
||||
function commandEnvironment(source: NodeJS.ProcessEnv = process.env): Record<string, string> {
|
||||
@@ -298,6 +301,8 @@ export function createIsolatedCodexAppServerArgs(
|
||||
...(commandEnv.length > 0
|
||||
? ["-c", `shell_environment_policy.set={${commandEnv}}`]
|
||||
: []),
|
||||
"--disable",
|
||||
"image_generation",
|
||||
"app-server",
|
||||
];
|
||||
}
|
||||
@@ -644,7 +649,7 @@ export class CodexAppServerDriver implements HarnessDriver {
|
||||
|
||||
class CodexHarnessSession implements HarnessSession {
|
||||
readonly #transport: CodexAppServerTransport;
|
||||
readonly #runId: string;
|
||||
#runId: string;
|
||||
readonly #normalizedSessionId: string;
|
||||
readonly #opened: OpenedCodexThread;
|
||||
readonly #taskEnvelope: CodexTaskEnvelope;
|
||||
@@ -669,6 +674,9 @@ class CodexHarnessSession implements HarnessSession {
|
||||
#terminal = false;
|
||||
#turnStarted = false;
|
||||
readonly #terminalTurns = new Map<string, string>();
|
||||
readonly #workspaceChangesByTurn = new Map<string, Record<string, unknown>>();
|
||||
readonly #emittedFileReferences = new Set<string>();
|
||||
readonly #itemChannels = new Map<string, "progress" | "final" | "summary" | "detail" | "unknown">();
|
||||
readonly #pendingRuntimeRequests = new Map<string, PendingRuntimeRequest>();
|
||||
readonly #lineage = new Map<string, HarnessThreadLineageEntry>();
|
||||
#goal: HarnessThreadGoal | null = null;
|
||||
@@ -699,6 +707,7 @@ class CodexHarnessSession implements HarnessSession {
|
||||
}) {
|
||||
this.#transport = input.transport;
|
||||
this.#runId = input.runId;
|
||||
this.#sourceSequence = 0;
|
||||
this.#normalizedSessionId = input.normalizedSessionId;
|
||||
this.#opened = input.opened;
|
||||
this.#taskEnvelope = input.taskEnvelope;
|
||||
@@ -772,6 +781,30 @@ class CodexHarnessSession implements HarnessSession {
|
||||
};
|
||||
}
|
||||
|
||||
async attachRun(input: { runId: string }): Promise<void> {
|
||||
if (this.#activeTurnId !== null || this.#turnStartPending || this.#pendingRuntimeRequests.size > 0) {
|
||||
throw new Error("codex_run_attach_busy");
|
||||
}
|
||||
if (!input.runId) throw new Error("codex_run_attach_invalid");
|
||||
await this.#transport.attachRun?.({
|
||||
runId: input.runId,
|
||||
turnId: `turn_attachment_${randomUUID().replaceAll("-", "")}`,
|
||||
itemId: `item_attachment_${randomUUID().replaceAll("-", "")}`,
|
||||
});
|
||||
this.#runId = input.runId;
|
||||
this.#result = null;
|
||||
this.#resultFingerprint = null;
|
||||
this.#resultCallId = null;
|
||||
this.#resultTurnId = null;
|
||||
this.#terminal = false;
|
||||
this.#terminalTurns.clear();
|
||||
this.#turnStarted = false;
|
||||
this.#protocolFailed = false;
|
||||
this.#protocolFailureCode = null;
|
||||
this.#protocolFailureMessage = null;
|
||||
this.#emit("run.attached", { runId: input.runId, sameSession: true });
|
||||
}
|
||||
|
||||
contextSnapshot(): CodexModelContextSnapshot {
|
||||
return structuredClone(this.#opened.context);
|
||||
}
|
||||
@@ -1247,8 +1280,12 @@ class CodexHarnessSession implements HarnessSession {
|
||||
}
|
||||
if (notification.method === "item/started") {
|
||||
if (!this.#notificationNamesActiveTurn(turnId, "item start")) return;
|
||||
const channel = this.#channelForStartedItem(item);
|
||||
if (itemId) this.#itemChannels.set(itemId, channel);
|
||||
this.#emit("item.started", boundedPayload({
|
||||
kind: text(item.type, "unknown"),
|
||||
channel,
|
||||
providerPhase: text(item.phase) || undefined,
|
||||
text: itemText(item),
|
||||
item,
|
||||
}), { turnId, itemId });
|
||||
@@ -1257,11 +1294,29 @@ class CodexHarnessSession implements HarnessSession {
|
||||
if (notification.method === "item/completed") {
|
||||
if (!this.#notificationNamesActiveTurn(turnId, "item completion")) return;
|
||||
if (!this.#captureResultFromItem(item, turnId)) return;
|
||||
const channel = itemId
|
||||
? this.#itemChannels.get(itemId) ?? this.#channelForStartedItem(item)
|
||||
: this.#channelForStartedItem(item);
|
||||
this.#emit("item.completed", boundedPayload({
|
||||
kind: text(item.type, "unknown"),
|
||||
channel,
|
||||
providerPhase: text(item.phase) || undefined,
|
||||
text: itemText(item),
|
||||
item,
|
||||
}), { turnId, itemId });
|
||||
if (text(item.type) === "agentMessage") {
|
||||
for (const reference of paperclipWorkspaceFileReferencesFromText(
|
||||
this.#opened.context.workingDirectory,
|
||||
text(item.text),
|
||||
turnId,
|
||||
)) {
|
||||
if (this.#emittedFileReferences.has(reference.referenceId)) continue;
|
||||
this.#emittedFileReferences.add(reference.referenceId);
|
||||
this.#emit("workspace.file.referenced", { ...reference }, { turnId, itemId: reference.referenceId });
|
||||
}
|
||||
}
|
||||
if (itemId) this.#itemChannels.delete(itemId);
|
||||
if (text(item.type) === "fileChange") this.#recordWorkspaceChanges(turnId, item.changes, true);
|
||||
return;
|
||||
}
|
||||
const deltaKinds: Record<string, string> = {
|
||||
@@ -1278,11 +1333,22 @@ class CodexHarnessSession implements HarnessSession {
|
||||
const deltaKind = deltaKinds[notification.method];
|
||||
if (deltaKind !== undefined) {
|
||||
if (!this.#notificationNamesActiveTurn(turnId, "item update")) return;
|
||||
const methodChannel = this.#channelForDelta(notification.method);
|
||||
const channel = methodChannel !== "unknown"
|
||||
? methodChannel
|
||||
: itemId
|
||||
? this.#itemChannels.get(itemId) ?? "unknown"
|
||||
: "unknown";
|
||||
this.#emit("item.delta", boundedPayload({
|
||||
kind: deltaKind,
|
||||
channel,
|
||||
providerMethod: notification.method,
|
||||
text: text(params.delta, text(params.patch, text(params.output))),
|
||||
update: params,
|
||||
}), { turnId, itemId: itemId || `${turnId}:${deltaKind}` });
|
||||
if (notification.method === "item/fileChange/patchUpdated") {
|
||||
this.#recordWorkspaceChanges(turnId, params.changes, false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (notification.method === "thread/tokenUsage/updated") {
|
||||
@@ -1296,6 +1362,74 @@ class CodexHarnessSession implements HarnessSession {
|
||||
}
|
||||
}
|
||||
|
||||
#channelForStartedItem(item: Record<string, unknown>): "progress" | "final" | "summary" | "detail" | "unknown" {
|
||||
const type = text(item.type);
|
||||
const phase = text(item.phase).toLowerCase();
|
||||
if (type === "agentMessage") {
|
||||
if (phase === "commentary") return "progress";
|
||||
if (phase === "final_answer") return "final";
|
||||
return "unknown";
|
||||
}
|
||||
if (type === "reasoning") return "summary";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
#channelForDelta(method: string): "progress" | "final" | "summary" | "detail" | "unknown" {
|
||||
if (method === "item/reasoning/summaryTextDelta") return "summary";
|
||||
if (method === "item/reasoning/textDelta") return "detail";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
#recordWorkspaceChanges(turnId: string, value: unknown, complete: boolean): void {
|
||||
const changes = Array.isArray(value) ? value : [];
|
||||
const files = changes.slice(0, 2_000).flatMap((candidate): Record<string, unknown>[] => {
|
||||
const change = record(candidate);
|
||||
const path = text(change.path).replaceAll("\\", "/");
|
||||
if (!path || path.startsWith("/") || path.split("/").includes("..")) return [];
|
||||
const kind = change.kind;
|
||||
const kindRecord = record(kind);
|
||||
const kindText = text(kind, text(kindRecord.type, Object.keys(kindRecord)[0] ?? "update"));
|
||||
const update = record(kindRecord.update ?? change.update);
|
||||
const previousPath = text(update.move_path, text(update.movePath)) || null;
|
||||
const operation = previousPath
|
||||
? "rename"
|
||||
: kindText.toLowerCase().includes("add")
|
||||
? "create"
|
||||
: kindText.toLowerCase().includes("delete")
|
||||
? "delete"
|
||||
: "modify";
|
||||
const diff = text(change.diff).slice(0, 262_144) || null;
|
||||
const diffLines = diff?.split("\n") ?? [];
|
||||
return [{
|
||||
path,
|
||||
operation,
|
||||
previousPath,
|
||||
additions: diff === null ? null : diffLines.filter((line) => line.startsWith("+") && !line.startsWith("+++")).length,
|
||||
deletions: diff === null ? null : diffLines.filter((line) => line.startsWith("-") && !line.startsWith("---")).length,
|
||||
binary: diff === null,
|
||||
diff,
|
||||
}];
|
||||
});
|
||||
if (files.length === 0) return;
|
||||
const unknown = files.some((file) => file.additions === null || file.deletions === null);
|
||||
const payload = {
|
||||
schema: "paperclip.workspace.diff.v1",
|
||||
changeSetId: `${turnId}:workspace`,
|
||||
revision: Number(record(this.#workspaceChangesByTurn.get(turnId)).revision ?? 0) + 1,
|
||||
source: "harness_reported",
|
||||
complete,
|
||||
files,
|
||||
totals: {
|
||||
files: files.length,
|
||||
additions: unknown ? null : files.reduce((sum, file) => sum + Number(file.additions), 0),
|
||||
deletions: unknown ? null : files.reduce((sum, file) => sum + Number(file.deletions), 0),
|
||||
},
|
||||
patchArtifactRef: null,
|
||||
};
|
||||
this.#workspaceChangesByTurn.set(turnId, payload);
|
||||
this.#emit("workspace.change.updated", payload, { turnId, itemId: `${turnId}:workspace` });
|
||||
}
|
||||
|
||||
async #handleServerRequest(request: CodexRpcServerRequest): Promise<Record<string, unknown>> {
|
||||
if (request.method === "item/tool/call") {
|
||||
const tool = text(request.params.tool);
|
||||
@@ -1516,6 +1650,13 @@ class CodexHarnessSession implements HarnessSession {
|
||||
}
|
||||
}
|
||||
this.#terminalTurns.set(turnId, this.#terminalFingerprint(turn));
|
||||
const workspace = this.#workspaceChangesByTurn.get(turnId);
|
||||
if (workspace !== undefined) {
|
||||
this.#emit("workspace.diff.recorded", { ...workspace, complete: true }, {
|
||||
turnId,
|
||||
itemId: `${turnId}:workspace`,
|
||||
});
|
||||
}
|
||||
const eventType =
|
||||
status === "failed"
|
||||
? "turn.failed"
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { canonicalOpenCodeMcpToolName, startOpenCodeMcpBridge, type OpenCodeMcpBridge } from "./mcp-bridge.js";
|
||||
|
||||
const bridges: OpenCodeMcpBridge[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(bridges.splice(0).map((bridge) => bridge.close()));
|
||||
});
|
||||
|
||||
async function rpc(
|
||||
bridge: OpenCodeMcpBridge,
|
||||
body: Record<string, unknown>,
|
||||
secret = bridge.secret,
|
||||
) {
|
||||
return fetch(bridge.url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${secret}`,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json, text/event-stream",
|
||||
},
|
||||
body: JSON.stringify({ jsonrpc: "2.0", ...body }),
|
||||
});
|
||||
}
|
||||
|
||||
describe("OpenCode MCP bridge", () => {
|
||||
it("binds to loopback, requires authentication, and exposes only its closed catalog", async () => {
|
||||
const bridge = await startOpenCodeMcpBridge({
|
||||
secret: "session-secret",
|
||||
tools: [{ name: "documents.read", description: "Read", inputSchema: { type: "object" } }],
|
||||
handler: async () => ({ ok: true }),
|
||||
});
|
||||
bridges.push(bridge);
|
||||
expect(new URL(bridge.url).hostname).toBe("127.0.0.1");
|
||||
expect((await rpc(bridge, { id: 1, method: "tools/list" }, "wrong")).status).toBe(401);
|
||||
const listed = await rpc(bridge, { id: 2, method: "tools/list" });
|
||||
expect(await listed.json()).toMatchObject({
|
||||
result: { tools: [
|
||||
{ name: "documents.read" },
|
||||
{ name: "paperclip_finish" },
|
||||
{ name: "paperclip_block" },
|
||||
] },
|
||||
});
|
||||
});
|
||||
|
||||
it("maps prefixed names and replays identical duplicate calls exactly once", async () => {
|
||||
const handler = vi.fn(async () => ({ value: 7 }));
|
||||
const bridge = await startOpenCodeMcpBridge({
|
||||
tools: [{ name: "documents.read", inputSchema: { type: "object" } }],
|
||||
handler,
|
||||
});
|
||||
bridges.push(bridge);
|
||||
const request = { id: "call-1", method: "tools/call", params: { name: "paperclip_documents.read", arguments: { id: "doc" } } };
|
||||
expect(await (await rpc(bridge, request)).json()).toMatchObject({ result: { content: [{ text: "{\"value\":7}" }] } });
|
||||
expect(await (await rpc(bridge, request)).json()).toMatchObject({ result: { content: [{ text: "{\"value\":7}" }] } });
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(handler).toHaveBeenCalledWith(expect.objectContaining({ tool: "documents.read", callId: "call-1", arguments: { id: "doc" } }));
|
||||
});
|
||||
|
||||
it("fails conflicting duplicate identities and handler errors closed", async () => {
|
||||
const bridge = await startOpenCodeMcpBridge({
|
||||
tools: [{ name: "documents.read", inputSchema: { type: "object" } }],
|
||||
handler: async () => { throw new Error("denied"); },
|
||||
});
|
||||
bridges.push(bridge);
|
||||
const failed = await rpc(bridge, { id: "call-2", method: "tools/call", params: { name: "documents.read", arguments: { id: "a" } } });
|
||||
expect(await failed.json()).toMatchObject({ result: { isError: true, content: [{ text: "denied" }] } });
|
||||
const conflict = await rpc(bridge, { id: "call-2", method: "tools/call", params: { name: "documents.read", arguments: { id: "b" } } });
|
||||
expect(await conflict.json()).toMatchObject({ result: { isError: true, content: [{ text: "Duplicate call identity conflict." }] } });
|
||||
});
|
||||
|
||||
it("normalizes all supported OpenCode MCP prefixes", () => {
|
||||
expect(canonicalOpenCodeMcpToolName("paperclip_documents.read")).toBe("documents.read");
|
||||
expect(canonicalOpenCodeMcpToolName("paperclip__documents.read")).toBe("documents.read");
|
||||
expect(canonicalOpenCodeMcpToolName("paperclip.documents.read")).toBe("documents.read");
|
||||
});
|
||||
|
||||
it("validates inputs before dispatch", async () => {
|
||||
const handler = vi.fn(async () => ({ ok: true }));
|
||||
const bridge = await startOpenCodeMcpBridge({
|
||||
tools: [{
|
||||
name: "documents.read",
|
||||
inputSchema: { type: "object", required: ["id"], properties: { id: { type: "string" } }, additionalProperties: false },
|
||||
}],
|
||||
handler,
|
||||
});
|
||||
bridges.push(bridge);
|
||||
const response = await rpc(bridge, {
|
||||
id: "invalid",
|
||||
method: "tools/call",
|
||||
params: { name: "documents.read", arguments: { id: 9 } },
|
||||
});
|
||||
expect(await response.json()).toMatchObject({ result: { isError: true } });
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("times out and cancels in-flight calls", async () => {
|
||||
const observed = vi.fn();
|
||||
const bridge = await startOpenCodeMcpBridge({
|
||||
tools: [{ name: "documents.read", inputSchema: { type: "object" } }],
|
||||
timeoutMs: 20,
|
||||
handler: ({ signal }) => new Promise((_resolve, reject) => {
|
||||
signal.addEventListener("abort", () => { observed(); reject(new Error("aborted")); }, { once: true });
|
||||
}),
|
||||
});
|
||||
bridges.push(bridge);
|
||||
const response = await rpc(bridge, {
|
||||
id: "slow-call",
|
||||
method: "tools/call",
|
||||
params: { name: "documents.read", arguments: {} },
|
||||
});
|
||||
expect(await response.json()).toMatchObject({ result: { isError: true, content: [{ text: "Paperclip tool call timed out" }] } });
|
||||
expect(observed).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("honors MCP cancellation notifications", async () => {
|
||||
const started = vi.fn();
|
||||
const bridge = await startOpenCodeMcpBridge({
|
||||
tools: [{ name: "documents.read", inputSchema: { type: "object" } }],
|
||||
handler: ({ signal }) => new Promise((_resolve, reject) => {
|
||||
started();
|
||||
signal.addEventListener("abort", () => reject(new Error("handler aborted")), { once: true });
|
||||
}),
|
||||
});
|
||||
bridges.push(bridge);
|
||||
const pending = rpc(bridge, {
|
||||
id: "cancel-me",
|
||||
method: "tools/call",
|
||||
params: { name: "documents.read", arguments: {} },
|
||||
});
|
||||
await vi.waitFor(() => expect(started).toHaveBeenCalledOnce());
|
||||
expect((await rpc(bridge, {
|
||||
method: "notifications/cancelled",
|
||||
params: { requestId: "cancel-me", reason: "turn stopped" },
|
||||
})).status).toBe(202);
|
||||
expect(await (await pending).json()).toMatchObject({
|
||||
result: { isError: true, content: [{ text: "Paperclip tool call cancelled" }] },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,341 @@
|
||||
import { randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
||||
import { Ajv2020, type ValidateFunction } from "ajv/dist/2020.js";
|
||||
|
||||
import {
|
||||
CODEX_BLOCK_RESULT_OUTPUT_SCHEMA,
|
||||
CODEX_BLOCK_TOOL_NAME,
|
||||
CODEX_COMPLETION_TOOL_NAME,
|
||||
CODEX_RESULT_OUTPUT_SCHEMA,
|
||||
} from "../../contracts/codex.js";
|
||||
|
||||
export interface OpenCodeMcpToolDefinition {
|
||||
name: string;
|
||||
description?: string;
|
||||
inputSchema: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface OpenCodeMcpCall {
|
||||
tool: string;
|
||||
callId: string;
|
||||
arguments: unknown;
|
||||
signal: AbortSignal;
|
||||
}
|
||||
|
||||
export interface OpenCodeMcpBridgeOptions {
|
||||
tools?: readonly Readonly<Record<string, unknown>>[];
|
||||
handler: (call: OpenCodeMcpCall) => Promise<unknown>;
|
||||
timeoutMs?: number;
|
||||
maxBodyBytes?: number;
|
||||
secret?: string;
|
||||
}
|
||||
|
||||
export interface OpenCodeMcpBridge {
|
||||
url: string;
|
||||
secret: string;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
const DEFAULT_MAX_BODY_BYTES = 1_048_576;
|
||||
|
||||
export function canonicalOpenCodeMcpToolName(name: string): string {
|
||||
const trimmed = name.trim();
|
||||
if (trimmed === CODEX_COMPLETION_TOOL_NAME || trimmed === CODEX_BLOCK_TOOL_NAME) return trimmed;
|
||||
for (const prefix of ["paperclip__", "paperclip_", "paperclip."]) {
|
||||
if (trimmed.startsWith(prefix)) return trimmed.slice(prefix.length);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export async function startOpenCodeMcpBridge(
|
||||
options: OpenCodeMcpBridgeOptions,
|
||||
): Promise<OpenCodeMcpBridge> {
|
||||
const secret = options.secret ?? randomBytes(32).toString("base64url");
|
||||
const tools = normalizeTools(options.tools ?? []);
|
||||
const validators = compileValidators(tools);
|
||||
const calls = new Map<string, { fingerprint: string; promise: Promise<unknown> }>();
|
||||
const controllers = new Map<string, AbortController>();
|
||||
const server = createServer((request, response) => {
|
||||
void handleRequest(request, response, {
|
||||
secret,
|
||||
tools,
|
||||
validators,
|
||||
calls,
|
||||
controllers,
|
||||
handler: options.handler,
|
||||
timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
maxBodyBytes: options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES,
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
server.off("error", reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
const address = server.address();
|
||||
if (address === null || typeof address === "string") {
|
||||
await closeServer(server);
|
||||
throw new Error("OpenCode MCP bridge failed to bind a loopback port");
|
||||
}
|
||||
return {
|
||||
url: `http://127.0.0.1:${address.port}/mcp`,
|
||||
secret,
|
||||
close: async () => {
|
||||
for (const controller of controllers.values()) controller.abort();
|
||||
await closeServer(server);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTools(input: readonly Readonly<Record<string, unknown>>[]): OpenCodeMcpToolDefinition[] {
|
||||
const tools: OpenCodeMcpToolDefinition[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const raw of input) {
|
||||
const name = typeof raw.name === "string" ? canonicalOpenCodeMcpToolName(raw.name) : "";
|
||||
if (!name || seen.has(name)) continue;
|
||||
const inputSchema = isRecord(raw.inputSchema) ? structuredClone(raw.inputSchema) : { type: "object" };
|
||||
tools.push({
|
||||
name,
|
||||
...(typeof raw.description === "string" ? { description: raw.description } : {}),
|
||||
inputSchema,
|
||||
});
|
||||
seen.add(name);
|
||||
}
|
||||
for (const terminal of [
|
||||
{ name: CODEX_COMPLETION_TOOL_NAME, description: "Return the semantic completion result.", inputSchema: CODEX_RESULT_OUTPUT_SCHEMA },
|
||||
{ name: CODEX_BLOCK_TOOL_NAME, description: "Return the semantic blocked result.", inputSchema: CODEX_BLOCK_RESULT_OUTPUT_SCHEMA },
|
||||
]) {
|
||||
if (!seen.has(terminal.name)) tools.push(terminal);
|
||||
}
|
||||
return tools;
|
||||
}
|
||||
|
||||
function compileValidators(tools: readonly OpenCodeMcpToolDefinition[]): Map<string, ValidateFunction> {
|
||||
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
||||
return new Map(tools.map((tool) => [tool.name, ajv.compile(tool.inputSchema)]));
|
||||
}
|
||||
|
||||
async function handleRequest(
|
||||
request: IncomingMessage,
|
||||
response: ServerResponse,
|
||||
context: {
|
||||
secret: string;
|
||||
tools: OpenCodeMcpToolDefinition[];
|
||||
validators: Map<string, ValidateFunction>;
|
||||
calls: Map<string, { fingerprint: string; promise: Promise<unknown> }>;
|
||||
controllers: Map<string, AbortController>;
|
||||
handler: OpenCodeMcpBridgeOptions["handler"];
|
||||
timeoutMs: number;
|
||||
maxBodyBytes: number;
|
||||
},
|
||||
): Promise<void> {
|
||||
setSecurityHeaders(response);
|
||||
if (!authorized(request.headers.authorization, context.secret)) {
|
||||
response.statusCode = 401;
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
if (request.method === "GET") {
|
||||
response.statusCode = 405;
|
||||
response.setHeader("Allow", "POST, DELETE");
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
if (request.method === "DELETE") {
|
||||
response.statusCode = 204;
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
if (request.method !== "POST" || request.url !== "/mcp") {
|
||||
response.statusCode = 404;
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
let message: Record<string, unknown>;
|
||||
try {
|
||||
message = parseMessage(await readBody(request, context.maxBodyBytes));
|
||||
} catch (error) {
|
||||
writeRpc(response, null, undefined, rpcError(-32700, safeError(error)));
|
||||
return;
|
||||
}
|
||||
const id = message.id ?? null;
|
||||
const method = typeof message.method === "string" ? message.method : "";
|
||||
if (method === "notifications/cancelled") {
|
||||
const params = isRecord(message.params) ? message.params : {};
|
||||
const requestId = params.requestId;
|
||||
if (typeof requestId === "string" || typeof requestId === "number") {
|
||||
context.controllers.get(String(requestId))?.abort();
|
||||
}
|
||||
response.statusCode = 202;
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
if (method === "notifications/initialized") {
|
||||
response.statusCode = 202;
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
if (method === "initialize") {
|
||||
response.setHeader("Mcp-Session-Id", randomBytes(16).toString("hex"));
|
||||
writeRpc(response, id, {
|
||||
protocolVersion: "2025-03-26",
|
||||
capabilities: { tools: { listChanged: false } },
|
||||
serverInfo: { name: "paperclip-runner", version: "1" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (method === "ping") {
|
||||
writeRpc(response, id, {});
|
||||
return;
|
||||
}
|
||||
if (method === "tools/list") {
|
||||
writeRpc(response, id, { tools: context.tools });
|
||||
return;
|
||||
}
|
||||
if (method !== "tools/call") {
|
||||
writeRpc(response, id, undefined, rpcError(-32601, "Method not found"));
|
||||
return;
|
||||
}
|
||||
const params = isRecord(message.params) ? message.params : {};
|
||||
const rawName = typeof params.name === "string" ? params.name : "";
|
||||
const tool = canonicalOpenCodeMcpToolName(rawName);
|
||||
if (!context.tools.some((entry) => entry.name === tool)) {
|
||||
writeRpc(response, id, { isError: true, content: [{ type: "text", text: "Unsupported tool." }] });
|
||||
return;
|
||||
}
|
||||
const callId = typeof id === "string" || typeof id === "number" ? String(id) : randomBytes(12).toString("hex");
|
||||
const args = params.arguments ?? {};
|
||||
const validator = context.validators.get(tool);
|
||||
if (validator === undefined || !validator(args)) {
|
||||
const detail = validator?.errors?.map((error) => `${error.instancePath || "/"} ${error.message ?? "is invalid"}`).join("; ");
|
||||
writeRpc(response, id, {
|
||||
isError: true,
|
||||
content: [{ type: "text", text: `Invalid tool input${detail ? `: ${detail}` : "."}` }],
|
||||
});
|
||||
return;
|
||||
}
|
||||
const fingerprint = canonicalJson({ tool, args });
|
||||
const existing = context.calls.get(callId);
|
||||
if (existing && existing.fingerprint !== fingerprint) {
|
||||
writeRpc(response, id, { isError: true, content: [{ type: "text", text: "Duplicate call identity conflict." }] });
|
||||
return;
|
||||
}
|
||||
const controller = existing === undefined ? new AbortController() : undefined;
|
||||
const execution = existing?.promise ?? withCancellationAndTimeout(
|
||||
context.handler({ tool, callId, arguments: structuredClone(args), signal: controller!.signal }),
|
||||
controller!,
|
||||
context.timeoutMs,
|
||||
);
|
||||
if (!existing) {
|
||||
if (context.calls.size >= 2_048) context.calls.delete(context.calls.keys().next().value!);
|
||||
context.calls.set(callId, { fingerprint, promise: execution });
|
||||
context.controllers.set(callId, controller!);
|
||||
void execution.finally(() => context.controllers.delete(callId)).catch(() => undefined);
|
||||
}
|
||||
try {
|
||||
const result = await execution;
|
||||
writeRpc(response, id, { content: [{ type: "text", text: safeJson(result) }] });
|
||||
} catch (error) {
|
||||
writeRpc(response, id, { isError: true, content: [{ type: "text", text: safeError(error) }] });
|
||||
}
|
||||
}
|
||||
|
||||
function authorized(value: string | undefined, secret: string): boolean {
|
||||
if (!value?.startsWith("Bearer ")) return false;
|
||||
const supplied = Buffer.from(value.slice(7));
|
||||
const expected = Buffer.from(secret);
|
||||
return supplied.length === expected.length && timingSafeEqual(supplied, expected);
|
||||
}
|
||||
|
||||
function readBody(request: IncomingMessage, maxBytes: number): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let size = 0;
|
||||
request.on("data", (chunk: Buffer) => {
|
||||
size += chunk.length;
|
||||
if (size > maxBytes) {
|
||||
reject(new Error("MCP request exceeded the retained payload limit"));
|
||||
request.destroy();
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
request.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
||||
request.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function parseMessage(body: string): Record<string, unknown> {
|
||||
const parsed: unknown = JSON.parse(body);
|
||||
if (!isRecord(parsed) || parsed.jsonrpc !== "2.0") throw new Error("Invalid JSON-RPC request");
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function writeRpc(response: ServerResponse, id: unknown, result?: unknown, error?: unknown): void {
|
||||
response.statusCode = 200;
|
||||
response.setHeader("Content-Type", "application/json");
|
||||
response.end(JSON.stringify({ jsonrpc: "2.0", id, ...(error === undefined ? { result } : { error }) }));
|
||||
}
|
||||
|
||||
function rpcError(code: number, message: string) {
|
||||
return { code, message };
|
||||
}
|
||||
|
||||
function setSecurityHeaders(response: ServerResponse): void {
|
||||
response.setHeader("Cache-Control", "no-store");
|
||||
response.setHeader("X-Content-Type-Options", "nosniff");
|
||||
}
|
||||
|
||||
function closeServer(server: Server): Promise<void> {
|
||||
return new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
||||
}
|
||||
|
||||
function withCancellationAndTimeout<T>(
|
||||
promise: Promise<T>,
|
||||
controller: AbortController,
|
||||
timeoutMs: number,
|
||||
): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const finish = (callback: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
controller.signal.removeEventListener("abort", onAbort);
|
||||
callback();
|
||||
};
|
||||
const onAbort = () => finish(() => reject(new Error("Paperclip tool call cancelled")));
|
||||
const timer = setTimeout(() => {
|
||||
finish(() => reject(new Error("Paperclip tool call timed out")));
|
||||
controller.abort();
|
||||
}, timeoutMs);
|
||||
timer.unref?.();
|
||||
controller.signal.addEventListener("abort", onAbort, { once: true });
|
||||
promise.then(
|
||||
(value) => finish(() => resolve(value)),
|
||||
(error) => finish(() => reject(error)),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function safeError(error: unknown): string {
|
||||
return (error instanceof Error ? error.message : String(error)).slice(0, 2_000);
|
||||
}
|
||||
|
||||
function safeJson(value: unknown): string {
|
||||
const serialized = JSON.stringify(value);
|
||||
return (serialized ?? "null").slice(0, 64 * 1024);
|
||||
}
|
||||
|
||||
function canonicalJson(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
||||
if (isRecord(value)) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`;
|
||||
return JSON.stringify(value) ?? "undefined";
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { chmod, mkdtemp, readFile, rm, stat } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { OpenCodeServerDriver, openCodeServerDriverInternals } from "./opencode-server-driver.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
const fixture = resolve("test/fixtures/fake-opencode-server.mjs");
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("OpenCodeServerDriver", () => {
|
||||
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 spawns: Array<{ pid: number }> = [];
|
||||
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",
|
||||
},
|
||||
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 = [];
|
||||
for await (const event of session.events()) events.push(event);
|
||||
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 === "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.17",
|
||||
});
|
||||
await session.interrupt?.({ turnId: turn.turnId });
|
||||
const snapshot = await session.snapshot();
|
||||
expect(snapshot.driverKind).toBe("opencode_server");
|
||||
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 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 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.projectConfigDisabled).toBe("true");
|
||||
expect(mcpEvidence.tools).toEqual(expect.arrayContaining(["paperclip_finish", "paperclip_block"]));
|
||||
const config = await readFile(join(sessionRoot, "config", "opencode", "opencode.json"), "utf8");
|
||||
expect(config).toContain("openrouter/deepseek/deepseek-v4-flash-0731");
|
||||
expect(config).toContain('"external_directory": "deny"');
|
||||
expect(config).not.toContain("test-openrouter-key");
|
||||
expect(diagnostics.join("\n")).not.toContain("test-openrouter-key");
|
||||
expect(diagnostics.join("\n")).toContain("[REDACTED]");
|
||||
});
|
||||
|
||||
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("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 = [];
|
||||
for await (const event of session.events()) events.push(event);
|
||||
expect(events.find((event) => event.eventType === "run.result.proposed")?.payload)
|
||||
.toMatchObject({ reportedWorkDisposition: "blocked", blocker: { reasonCode: "test_dependency" } });
|
||||
await session.close({ reason: "test" });
|
||||
});
|
||||
|
||||
it("rejects malformed and oversized 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();
|
||||
await expect(consume(`data: ${"x".repeat(1_048_577)}`)).rejects.toThrow("payload limit");
|
||||
});
|
||||
|
||||
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 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) => {
|
||||
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).toHaveLength(2);
|
||||
await session.close({ reason: "test" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,890 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { createServer } from "node:net";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
|
||||
import {
|
||||
CODEX_BLOCK_TOOL_NAME,
|
||||
CODEX_COMPLETION_TOOL_NAME,
|
||||
CODEX_SKILLLESS_BASE_INSTRUCTIONS,
|
||||
createCodexTaskEnvelope,
|
||||
type CodexTaskEnvelope,
|
||||
} from "../../contracts/codex.js";
|
||||
import type {
|
||||
HarnessDriver,
|
||||
HarnessDriverConfigValidation,
|
||||
HarnessDriverDescriptor,
|
||||
HarnessSession,
|
||||
HarnessSessionRecoveryResult,
|
||||
HarnessTranscriptSnapshot,
|
||||
OpenHarnessSessionInput,
|
||||
PersistedHarnessSession,
|
||||
} from "../../contracts/harness-driver.js";
|
||||
import type { NativeSessionCapabilities, NativeUserMessage } from "../../contracts/types.js";
|
||||
import type { PrpEvent, PrpStructuredRunResult } from "../../protocol/replay-contract.js";
|
||||
import { validatePrpStructuredRunResult } from "../../protocol/replay-contract.js";
|
||||
import { paperclipWorkspaceFileReferencesFromText } from "../../live/workspace-file-reference.js";
|
||||
import { canonicalOpenCodeMcpToolName, startOpenCodeMcpBridge, type OpenCodeMcpBridge } from "./mcp-bridge.js";
|
||||
|
||||
export const OPENCODE_SERVER_DRIVER_KIND = "opencode_server" as const;
|
||||
export const QUALIFIED_OPENCODE_VERSION = "1.18.17" as const;
|
||||
export const QUALIFIED_OPENCODE_MODEL = "openrouter/deepseek/deepseek-v4-flash-0731" as const;
|
||||
|
||||
type DynamicToolHandler = (call: {
|
||||
tool: string;
|
||||
callId: string;
|
||||
threadId: string;
|
||||
turnId: string;
|
||||
arguments: unknown;
|
||||
}) => Promise<unknown>;
|
||||
|
||||
export interface OpenCodeServerDriverOptions {
|
||||
model: string;
|
||||
taskEnvelope?: CodexTaskEnvelope;
|
||||
runnerInstanceId?: string;
|
||||
command?: string;
|
||||
runtimeDirectory: string;
|
||||
environment?: NodeJS.ProcessEnv;
|
||||
dynamicTools?: readonly Readonly<Record<string, unknown>>[];
|
||||
dynamicToolHandler?: DynamicToolHandler;
|
||||
onSpawn?: (meta: { pid: number; processGroupId: number | null; startedAt: string }) => Promise<void>;
|
||||
onDiagnostic?: (message: string) => void;
|
||||
fetch?: typeof globalThis.fetch;
|
||||
now?: () => Date;
|
||||
}
|
||||
|
||||
interface OpenCodeRuntime {
|
||||
baseUrl: string;
|
||||
authHeader: string;
|
||||
version: string;
|
||||
process: ChildProcess;
|
||||
bridge: OpenCodeMcpBridge;
|
||||
sensitiveValues: readonly string[];
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
const CAPABILITIES: NativeSessionCapabilities = {
|
||||
resume: true,
|
||||
typedEvents: true,
|
||||
steering: false,
|
||||
interruption: true,
|
||||
structuredResult: true,
|
||||
read: true,
|
||||
reconciliation: true,
|
||||
usage: true,
|
||||
dynamicTools: true,
|
||||
runtimeRequestResolution: false,
|
||||
goals: false,
|
||||
threadLineage: false,
|
||||
unsupported: ["steering", "runtimeRequestResolution", "goals", "threadLineage"],
|
||||
};
|
||||
|
||||
export class OpenCodeServerDriver implements HarnessDriver {
|
||||
readonly #options: OpenCodeServerDriverOptions;
|
||||
|
||||
constructor(options: OpenCodeServerDriverOptions) {
|
||||
this.#options = options;
|
||||
}
|
||||
|
||||
async descriptor(): Promise<HarnessDriverDescriptor> {
|
||||
return {
|
||||
kind: OPENCODE_SERVER_DRIVER_KIND,
|
||||
displayName: "OpenCode server",
|
||||
version: QUALIFIED_OPENCODE_VERSION,
|
||||
protocolVersion: "http+sse/v1",
|
||||
capabilities: structuredClone(CAPABILITIES),
|
||||
};
|
||||
}
|
||||
|
||||
async validateConfig(config: unknown): Promise<HarnessDriverConfigValidation> {
|
||||
const candidate = isRecord(config) ? config : {};
|
||||
const issues = [];
|
||||
const model = typeof candidate.model === "string" ? candidate.model.trim() : "";
|
||||
if (!validModel(model)) issues.push({ path: "model", code: "invalid_model", message: "OpenCode model must use provider/model form." });
|
||||
const command = typeof candidate.command === "string" ? candidate.command.trim() : "opencode";
|
||||
if (!command) issues.push({ path: "command", code: "invalid_command", message: "OpenCode command cannot be empty." });
|
||||
return issues.length === 0
|
||||
? { ok: true, config: { model, command }, issues: [] }
|
||||
: { ok: false, config: null, issues };
|
||||
}
|
||||
|
||||
async openSession(input: OpenHarnessSessionInput): Promise<HarnessSession> {
|
||||
return this.#open(input, null);
|
||||
}
|
||||
|
||||
async recoverSession(snapshot: PersistedHarnessSession): Promise<HarnessSessionRecoveryResult> {
|
||||
if (
|
||||
snapshot.driverKind !== OPENCODE_SERVER_DRIVER_KIND
|
||||
|| !snapshot.runId
|
||||
|| !snapshot.normalizedSessionId
|
||||
|| !snapshot.driverSessionId
|
||||
) return { recovered: false, reason: "persisted OpenCode session identity is incomplete" };
|
||||
try {
|
||||
const session = await this.#open({
|
||||
runId: snapshot.runId,
|
||||
normalizedSessionId: snapshot.normalizedSessionId,
|
||||
workingDirectory: await this.#readWorkspace(snapshot.normalizedSessionId),
|
||||
}, snapshot);
|
||||
return { recovered: true, session };
|
||||
} catch (error) {
|
||||
return { recovered: false, reason: redact(String(error), [this.#options.environment?.OPENROUTER_API_KEY]) };
|
||||
}
|
||||
}
|
||||
|
||||
async #readWorkspace(normalizedSessionId: string): Promise<string> {
|
||||
const raw = await readFile(join(sessionRoot(this.#options.runtimeDirectory, normalizedSessionId), "workspace"), "utf8");
|
||||
return raw.trim();
|
||||
}
|
||||
|
||||
async #open(input: OpenHarnessSessionInput, snapshot: PersistedHarnessSession | null): Promise<HarnessSession> {
|
||||
const cwd = validateWorkspace(input.workingDirectory);
|
||||
const root = sessionRoot(this.#options.runtimeDirectory, input.normalizedSessionId);
|
||||
await mkdir(root, { recursive: true, mode: 0o700 });
|
||||
await writeFile(join(root, "workspace"), `${cwd}\n`, { mode: 0o600 });
|
||||
let lastError: unknown = null;
|
||||
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||
let session: OpenCodeHarnessSession | null = null;
|
||||
let runtime: OpenCodeRuntime | null = null;
|
||||
try {
|
||||
runtime = await startRuntime({
|
||||
options: this.#options,
|
||||
root,
|
||||
cwd,
|
||||
dispatch: (call) => {
|
||||
if (session === null) throw new Error("OpenCode session is not ready for tool calls");
|
||||
return session.dispatchTool(call);
|
||||
},
|
||||
});
|
||||
const fetcher = this.#options.fetch ?? globalThis.fetch;
|
||||
let providerSessionId = snapshot?.providerSessionId ?? snapshot?.driverSessionId ?? null;
|
||||
if (providerSessionId !== null) {
|
||||
const existing = await api(fetcher, runtime, `/session/${encodeURIComponent(providerSessionId)}`);
|
||||
if (!isRecord(existing) || text(existing.id) !== providerSessionId) throw new Error("OpenCode resumed a different session");
|
||||
} else {
|
||||
const created = await api(fetcher, runtime, "/session", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ title: `Paperclip ${input.runId}` }),
|
||||
});
|
||||
providerSessionId = text(record(created).id);
|
||||
if (!providerSessionId) throw new Error("OpenCode session creation omitted its id");
|
||||
}
|
||||
session = new OpenCodeHarnessSession({
|
||||
runtime,
|
||||
fetcher,
|
||||
runId: input.runId,
|
||||
normalizedSessionId: input.normalizedSessionId,
|
||||
providerSessionId,
|
||||
workingDirectory: cwd,
|
||||
runnerInstanceId: this.#options.runnerInstanceId ?? `paperclip-opencode-${input.runId}`,
|
||||
model: this.#options.model,
|
||||
taskEnvelope: this.#options.taskEnvelope ?? createCodexTaskEnvelope({ objective: "Complete the supplied task." }),
|
||||
dynamicToolHandler: this.#options.dynamicToolHandler,
|
||||
snapshot,
|
||||
now: this.#options.now ?? (() => new Date()),
|
||||
});
|
||||
session.startEventPump();
|
||||
return session;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await runtime?.close();
|
||||
if (attempt === 3 || !retryableOpenCodeStartupError(error)) throw error;
|
||||
this.#options.onDiagnostic?.(`OpenCode session startup attempt ${attempt} failed; retrying.`);
|
||||
await new Promise((resolveDelay) => setTimeout(resolveDelay, 100 * attempt));
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
}
|
||||
|
||||
class OpenCodeHarnessSession implements HarnessSession {
|
||||
readonly #runtime: OpenCodeRuntime;
|
||||
readonly #fetch: typeof globalThis.fetch;
|
||||
#runId: string;
|
||||
readonly #normalizedSessionId: string;
|
||||
readonly #providerSessionId: string;
|
||||
readonly #workingDirectory: string;
|
||||
readonly #runnerInstanceId: string;
|
||||
readonly #model: string;
|
||||
readonly #taskEnvelope: CodexTaskEnvelope;
|
||||
readonly #dynamicToolHandler?: DynamicToolHandler;
|
||||
readonly #now: () => Date;
|
||||
readonly #events = new AsyncQueue<PrpEvent>();
|
||||
readonly #transcript: PrpEvent[] = [];
|
||||
readonly #terminalTurns = new Map<string, string>();
|
||||
readonly #seenProviderEvents = new Set<string>();
|
||||
readonly #messageRoles = new Map<string, string>();
|
||||
readonly #pendingMessageParts = new Map<string, Array<Record<string, unknown>>>();
|
||||
readonly #partText = new Map<string, string>();
|
||||
readonly #workspaceChangesByTurn = new Map<string, Record<string, unknown>>();
|
||||
readonly #emittedFileReferences = new Set<string>();
|
||||
#sourceSequence: number;
|
||||
#activeTurnId: string | null;
|
||||
#result: PrpStructuredRunResult | null;
|
||||
#resultFingerprint: string | null;
|
||||
#resultCallId: string | null;
|
||||
#resultTurnId: string | null;
|
||||
#usage: Record<string, unknown> | null = null;
|
||||
#closed = false;
|
||||
#abort = new AbortController();
|
||||
|
||||
constructor(input: {
|
||||
runtime: OpenCodeRuntime;
|
||||
fetcher: typeof globalThis.fetch;
|
||||
runId: string;
|
||||
normalizedSessionId: string;
|
||||
providerSessionId: string;
|
||||
workingDirectory: string;
|
||||
runnerInstanceId: string;
|
||||
model: string;
|
||||
taskEnvelope: CodexTaskEnvelope;
|
||||
dynamicToolHandler?: DynamicToolHandler;
|
||||
snapshot: PersistedHarnessSession | null;
|
||||
now: () => Date;
|
||||
}) {
|
||||
this.#runtime = input.runtime;
|
||||
this.#fetch = input.fetcher;
|
||||
this.#runId = input.runId;
|
||||
this.#sourceSequence = 0;
|
||||
this.#normalizedSessionId = input.normalizedSessionId;
|
||||
this.#providerSessionId = input.providerSessionId;
|
||||
this.#workingDirectory = input.workingDirectory;
|
||||
this.#runnerInstanceId = input.runnerInstanceId;
|
||||
this.#model = input.model;
|
||||
this.#taskEnvelope = input.taskEnvelope;
|
||||
this.#dynamicToolHandler = input.dynamicToolHandler;
|
||||
this.#now = input.now;
|
||||
this.#sourceSequence = input.snapshot?.lastSourceSequence ?? 0;
|
||||
this.#activeTurnId = input.snapshot?.activeTurnId ?? null;
|
||||
const restored = input.snapshot?.semanticResult ?? null;
|
||||
this.#result = restored?.result ?? null;
|
||||
this.#resultFingerprint = restored?.fingerprint ?? null;
|
||||
this.#resultCallId = restored?.callId ?? null;
|
||||
this.#resultTurnId = restored?.turnId ?? null;
|
||||
for (const terminal of input.snapshot?.terminalTurns ?? []) this.#terminalTurns.set(terminal.turnId, terminal.fingerprint);
|
||||
this.#emit(input.snapshot ? "session.resumed" : "session.started", {
|
||||
driverSessionId: input.providerSessionId,
|
||||
providerSessionId: input.providerSessionId,
|
||||
context: {
|
||||
protocolVersion: "http+sse/v1",
|
||||
opencodeVersion: input.runtime.version,
|
||||
model: input.model,
|
||||
modelProvider: input.model.split("/", 1)[0],
|
||||
workingDirectory: input.workingDirectory,
|
||||
environmentKeys: sanitizedEnvironmentKeys(),
|
||||
},
|
||||
});
|
||||
this.#emit("item.completed", {
|
||||
kind: "model",
|
||||
text: `${input.model} (OpenCode ${input.runtime.version})`,
|
||||
model: { name: input.model, provider: input.model.split("/", 1)[0], opencodeVersion: input.runtime.version },
|
||||
}, { itemId: `${input.providerSessionId}:model` });
|
||||
}
|
||||
|
||||
ids() {
|
||||
return { driverSessionId: this.#providerSessionId, providerSessionId: this.#providerSessionId, displayId: this.#providerSessionId };
|
||||
}
|
||||
|
||||
attachRun(input: { runId: string }): void {
|
||||
if (this.#activeTurnId !== null) throw new Error("opencode_run_attach_busy");
|
||||
if (!input.runId) throw new Error("opencode_run_attach_invalid");
|
||||
this.#runId = input.runId;
|
||||
this.#result = null;
|
||||
this.#resultFingerprint = null;
|
||||
this.#resultCallId = null;
|
||||
this.#resultTurnId = null;
|
||||
this.#terminalTurns.clear();
|
||||
this.#emit("run.attached", { runId: input.runId, sameSession: true });
|
||||
}
|
||||
|
||||
events(): AsyncIterable<PrpEvent> { return this.#events; }
|
||||
|
||||
startEventPump(): void { void this.#pumpEvents(); }
|
||||
|
||||
async startTurn(input: { message: NativeUserMessage }): Promise<{ turnId: string }> {
|
||||
if (this.#activeTurnId !== null) throw new Error("OpenCode session already has an active turn");
|
||||
const turnId = `turn-${randomBytes(12).toString("hex")}`;
|
||||
this.#activeTurnId = turnId;
|
||||
this.#emit("turn.submitted", { envelopeSchema: this.#taskEnvelope.schema, text: input.message.text });
|
||||
this.#emit("turn.accepted", { turnId }, { turnId });
|
||||
this.#emit("turn.started", { status: "inProgress" }, { turnId });
|
||||
const [providerID, ...modelParts] = this.#model.split("/");
|
||||
const modelID = modelParts.join("/");
|
||||
const prompt = JSON.stringify({ task: this.#taskEnvelope, message: input.message.text });
|
||||
await api(this.#fetch, this.#runtime, `/session/${encodeURIComponent(this.#providerSessionId)}/prompt_async`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
// OpenCode 1.18's PromptPayload carries the provider and model at the
|
||||
// top level. Older examples used a nested `model` object; 1.18
|
||||
// silently ignores that shape and falls back to the configured model,
|
||||
// which can turn `openrouter/deepseek/...` into a duplicated provider
|
||||
// lookup. Keep Paperclip's persisted provider/model form, but adapt it
|
||||
// at this HTTP boundary.
|
||||
providerID,
|
||||
modelID,
|
||||
system: CODEX_SKILLLESS_BASE_INSTRUCTIONS,
|
||||
parts: [{ type: "text", text: prompt }],
|
||||
}),
|
||||
});
|
||||
return { turnId };
|
||||
}
|
||||
|
||||
async interrupt(input: { turnId?: string; reason?: string }): Promise<void> {
|
||||
if (input.turnId && this.#activeTurnId && input.turnId !== this.#activeTurnId) throw new Error("stale OpenCode turn");
|
||||
await api(this.#fetch, this.#runtime, `/session/${encodeURIComponent(this.#providerSessionId)}/abort`, { method: "POST" });
|
||||
}
|
||||
|
||||
async read(): Promise<Record<string, unknown>> {
|
||||
const messages = await api(this.#fetch, this.#runtime, `/session/${encodeURIComponent(this.#providerSessionId)}/message`);
|
||||
return { sessionId: this.#providerSessionId, messages };
|
||||
}
|
||||
|
||||
async reconcile(): Promise<Record<string, unknown>> {
|
||||
const status = await api(this.#fetch, this.#runtime, "/session/status");
|
||||
return { sessionId: this.#providerSessionId, status: record(status)[this.#providerSessionId] ?? null };
|
||||
}
|
||||
|
||||
async usage(): Promise<Record<string, unknown> | null> { return this.#usage === null ? null : structuredClone(this.#usage); }
|
||||
|
||||
async transcript(): Promise<HarnessTranscriptSnapshot> {
|
||||
return {
|
||||
schema: "paperclip-runner/harness-transcript/v1",
|
||||
complete: true,
|
||||
eventCount: this.#transcript.length,
|
||||
events: structuredClone(this.#transcript),
|
||||
omissionReason: null,
|
||||
};
|
||||
}
|
||||
|
||||
async snapshot(): Promise<PersistedHarnessSession> {
|
||||
return {
|
||||
driverKind: OPENCODE_SERVER_DRIVER_KIND,
|
||||
driverSessionId: this.#providerSessionId,
|
||||
providerSessionId: this.#providerSessionId,
|
||||
runId: this.#runId,
|
||||
normalizedSessionId: this.#normalizedSessionId,
|
||||
activeTurnId: this.#activeTurnId,
|
||||
semanticResult: this.#result && this.#resultFingerprint && this.#resultTurnId
|
||||
? { result: structuredClone(this.#result), fingerprint: this.#resultFingerprint, callId: this.#resultCallId, turnId: this.#resultTurnId }
|
||||
: null,
|
||||
terminalTurns: [...this.#terminalTurns].map(([turnId, fingerprint]) => ({ turnId, fingerprint })),
|
||||
lastSourceSequence: this.#sourceSequence,
|
||||
};
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.#closed) return;
|
||||
this.#closed = true;
|
||||
this.#abort.abort();
|
||||
this.#events.close();
|
||||
await this.#runtime.close();
|
||||
}
|
||||
|
||||
async dispatchTool(call: { tool: string; callId: string; arguments: unknown }): Promise<unknown> {
|
||||
const tool = canonicalOpenCodeMcpToolName(call.tool);
|
||||
const turnId = this.#activeTurnId;
|
||||
if (turnId === null) throw new Error("OpenCode tool call is not bound to an active turn");
|
||||
this.#emit("item.started", {
|
||||
kind: "dynamicToolCall",
|
||||
item: { type: "tool_call", id: call.callId, name: tool, arguments: call.arguments },
|
||||
}, { turnId, itemId: call.callId });
|
||||
if (tool === CODEX_COMPLETION_TOOL_NAME || tool === CODEX_BLOCK_TOOL_NAME) {
|
||||
const validation = validatePrpStructuredRunResult(call.arguments);
|
||||
if (!validation.ok) throw new Error("Invalid semantic result");
|
||||
if (
|
||||
(tool === CODEX_BLOCK_TOOL_NAME && validation.result.reportedWorkDisposition !== "blocked")
|
||||
|| (tool === CODEX_COMPLETION_TOOL_NAME && validation.result.reportedWorkDisposition === "blocked")
|
||||
) throw new Error("Semantic result disposition does not match the terminal tool");
|
||||
if (validation.result.completionClaim.contractRevision !== this.#taskEnvelope.completionContract.revision) {
|
||||
throw new Error("Semantic result completion contract revision does not match");
|
||||
}
|
||||
const fingerprint = canonicalJson(validation.result);
|
||||
if (this.#resultFingerprint && this.#resultFingerprint !== fingerprint) throw new Error("A different semantic result was already committed");
|
||||
if (!this.#resultFingerprint) {
|
||||
this.#result = structuredClone(validation.result);
|
||||
this.#resultFingerprint = fingerprint;
|
||||
this.#resultCallId = call.callId;
|
||||
this.#resultTurnId = turnId;
|
||||
this.#emit("run.result.proposed", validation.result, { turnId, itemId: call.callId });
|
||||
}
|
||||
this.#emit("item.completed", {
|
||||
kind: "dynamicToolCall",
|
||||
item: { type: "tool_result", id: call.callId, tool_use_id: call.callId, result: "Semantic completion accepted." },
|
||||
}, { turnId, itemId: call.callId });
|
||||
return { accepted: true };
|
||||
}
|
||||
if (!this.#dynamicToolHandler) throw new Error("Unsupported Paperclip operation");
|
||||
try {
|
||||
const result = await this.#dynamicToolHandler({
|
||||
tool,
|
||||
callId: call.callId,
|
||||
threadId: this.#providerSessionId,
|
||||
turnId,
|
||||
arguments: call.arguments,
|
||||
});
|
||||
this.#emit("item.completed", {
|
||||
kind: "dynamicToolCall",
|
||||
item: { type: "tool_result", id: call.callId, tool_use_id: call.callId, result },
|
||||
}, { turnId, itemId: call.callId });
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.#emit("item.completed", {
|
||||
kind: "dynamicToolCall",
|
||||
item: { type: "tool_result", id: call.callId, tool_use_id: call.callId, error: redact(String(error)), is_error: true },
|
||||
}, { turnId, itemId: call.callId });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async #pumpEvents(): Promise<void> {
|
||||
let attempts = 0;
|
||||
while (!this.#closed && !this.#abort.signal.aborted) {
|
||||
try {
|
||||
const response = await this.#fetch(`${this.#runtime.baseUrl}/event`, {
|
||||
headers: { Authorization: this.#runtime.authHeader, Accept: "text/event-stream" },
|
||||
signal: this.#abort.signal,
|
||||
});
|
||||
if (!response.ok || !response.body) throw new Error(`OpenCode event stream returned HTTP ${response.status}`);
|
||||
for await (const event of parseSse(response.body)) {
|
||||
if (this.#closed) return;
|
||||
this.#mapProviderEvent(event);
|
||||
}
|
||||
throw new Error("OpenCode event stream closed before the session became terminal");
|
||||
} catch (error) {
|
||||
if (this.#closed || this.#abort.signal.aborted) return;
|
||||
attempts += 1;
|
||||
if (attempts > 3) {
|
||||
this.#emit("harness.diagnostic", { code: "opencode_sse_failed", message: redact(String(error), this.#runtime.sensitiveValues) });
|
||||
this.#events.fail(error);
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50 * attempts));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#mapProviderEvent(value: unknown): void {
|
||||
const event = record(value);
|
||||
const properties = record(event.properties);
|
||||
const type = text(event.type);
|
||||
const eventId = text(event.id, canonicalJson(value));
|
||||
if (this.#seenProviderEvents.has(eventId)) return;
|
||||
this.#seenProviderEvents.add(eventId);
|
||||
if (this.#seenProviderEvents.size > 10_000) this.#seenProviderEvents.delete(this.#seenProviderEvents.values().next().value!);
|
||||
const sessionId = text(properties.sessionID, text(record(properties.info).sessionID));
|
||||
if (sessionId && sessionId !== this.#providerSessionId) return;
|
||||
const turnId = this.#activeTurnId;
|
||||
if (type === "message.part.updated" && turnId) {
|
||||
const part = record(properties.part);
|
||||
const messageId = text(part.messageID, text(part.messageId));
|
||||
if (!messageId) return;
|
||||
const role = this.#messageRoles.get(messageId);
|
||||
if (role === "assistant") this.#emitAssistantPart(part, turnId);
|
||||
else if (role === undefined) {
|
||||
const pending = this.#pendingMessageParts.get(messageId) ?? [];
|
||||
if (pending.length < 100) pending.push(part);
|
||||
this.#pendingMessageParts.set(messageId, pending);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (type === "message.updated" && turnId) {
|
||||
const info = record(properties.info);
|
||||
const messageId = text(info.id, text(info.messageID, text(info.messageId)));
|
||||
const role = text(info.role);
|
||||
if (messageId && role) {
|
||||
this.#messageRoles.set(messageId, role);
|
||||
const pending = this.#pendingMessageParts.get(messageId) ?? [];
|
||||
this.#pendingMessageParts.delete(messageId);
|
||||
if (role === "assistant") for (const part of pending) this.#emitAssistantPart(part, turnId);
|
||||
}
|
||||
const tokens = record(info.tokens);
|
||||
if (Object.keys(tokens).length > 0 || typeof info.cost === "number") {
|
||||
this.#usage = bounded({
|
||||
...tokens,
|
||||
costUsd: info.cost ?? null,
|
||||
model: this.#model,
|
||||
provider: this.#model.split("/", 1)[0],
|
||||
driverVersion: this.#runtime.version,
|
||||
});
|
||||
this.#emit("item.completed", { kind: "usage", usage: this.#usage }, { turnId, itemId: `${turnId}:usage` });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if ((type === "session.idle" || (type === "session.status" && text(record(properties.status).type) === "idle")) && turnId) {
|
||||
const fingerprint = canonicalJson({ status: "completed", semanticResult: this.#resultFingerprint });
|
||||
this.#terminalTurns.set(turnId, fingerprint);
|
||||
const workspace = this.#workspaceChangesByTurn.get(turnId);
|
||||
if (workspace !== undefined) this.#emit("workspace.diff.recorded", { ...workspace, complete: true }, { turnId, itemId: `${turnId}:workspace` });
|
||||
this.#emit("turn.completed", { status: "completed" }, { turnId });
|
||||
this.#activeTurnId = null;
|
||||
this.#events.close();
|
||||
return;
|
||||
}
|
||||
if (type === "session.error" && turnId) {
|
||||
this.#emit("turn.failed", { status: "failed", error: bounded(properties.error ?? properties) }, { turnId });
|
||||
this.#terminalTurns.set(turnId, canonicalJson({ status: "failed" }));
|
||||
this.#activeTurnId = null;
|
||||
this.#events.close();
|
||||
}
|
||||
}
|
||||
|
||||
#emitAssistantPart(part: Record<string, unknown>, turnId: string): void {
|
||||
const partId = text(part.id, `${turnId}:part`);
|
||||
const partType = text(part.type, "unknown");
|
||||
if (partType === "patch") {
|
||||
const paths = Array.isArray(part.files) ? part.files : [];
|
||||
const files = paths.slice(0, 2_000).flatMap((value): Record<string, unknown>[] => {
|
||||
const path = text(value).replaceAll("\\", "/");
|
||||
if (!path || path.startsWith("/") || path.split("/").includes("..")) return [];
|
||||
return [{ path, operation: "modify", previousPath: null, additions: null, deletions: null, binary: false, diff: null }];
|
||||
});
|
||||
if (files.length > 0) {
|
||||
const previous = this.#workspaceChangesByTurn.get(turnId);
|
||||
const payload = {
|
||||
schema: "paperclip.workspace.diff.v1",
|
||||
changeSetId: `${turnId}:workspace`,
|
||||
revision: Number(record(previous).revision ?? 0) + 1,
|
||||
source: "harness_reported",
|
||||
complete: false,
|
||||
files,
|
||||
totals: { files: files.length, additions: null, deletions: null },
|
||||
patchArtifactRef: null,
|
||||
};
|
||||
this.#workspaceChangesByTurn.set(turnId, payload);
|
||||
this.#emit("workspace.change.updated", payload, { turnId, itemId: `${turnId}:workspace` });
|
||||
}
|
||||
}
|
||||
const content = text(part.text, text(part.output));
|
||||
const previous = this.#partText.get(partId) ?? "";
|
||||
this.#partText.set(partId, content);
|
||||
if (partType === "text" && content.length > 0) {
|
||||
for (const reference of paperclipWorkspaceFileReferencesFromText(
|
||||
this.#workingDirectory,
|
||||
content,
|
||||
turnId,
|
||||
)) {
|
||||
if (this.#emittedFileReferences.has(reference.referenceId)) continue;
|
||||
this.#emittedFileReferences.add(reference.referenceId);
|
||||
this.#emit("workspace.file.referenced", { ...reference }, { turnId, itemId: reference.referenceId });
|
||||
}
|
||||
}
|
||||
const delta = content.startsWith(previous) ? content.slice(previous.length) : content;
|
||||
if (!delta) return;
|
||||
this.#emit("item.delta", { kind: partType, text: delta, item: bounded(part) }, { turnId, itemId: partId });
|
||||
}
|
||||
|
||||
#emit(eventType: PrpEvent["eventType"], payload: Record<string, unknown>, refs: { turnId?: string; itemId?: string } = {}): void {
|
||||
const sourceSeq = ++this.#sourceSequence;
|
||||
const event: PrpEvent = {
|
||||
schema: "paperclip.prp.event.v1",
|
||||
sourceEventId: `${this.#runnerInstanceId}:${this.#runId}:${sourceSeq}`,
|
||||
sourceSeq,
|
||||
sourceInstanceId: this.#runnerInstanceId,
|
||||
sourceKind: "runner",
|
||||
runId: this.#runId,
|
||||
normalizedSessionId: this.#normalizedSessionId,
|
||||
...(refs.turnId ? { turnId: refs.turnId } : {}),
|
||||
...(refs.itemId ? { itemId: refs.itemId } : {}),
|
||||
eventType,
|
||||
schemaVersion: 1,
|
||||
priority: eventType === "run.result.proposed" ? 0 : 1,
|
||||
emittedAt: this.#now().toISOString(),
|
||||
payload,
|
||||
};
|
||||
this.#transcript.push(structuredClone(event));
|
||||
this.#events.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
async function startRuntime(input: {
|
||||
options: OpenCodeServerDriverOptions;
|
||||
root: string;
|
||||
cwd: string;
|
||||
dispatch: (call: { tool: string; callId: string; arguments: unknown }) => Promise<unknown>;
|
||||
}): Promise<OpenCodeRuntime> {
|
||||
const bridge = await startOpenCodeMcpBridge({
|
||||
tools: input.options.dynamicTools,
|
||||
handler: input.dispatch,
|
||||
});
|
||||
const port = await reservePort();
|
||||
const password = randomBytes(32).toString("base64url");
|
||||
const username = "paperclip";
|
||||
const authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`;
|
||||
const configHome = join(input.root, "config");
|
||||
const dataHome = join(input.root, "data");
|
||||
const cacheHome = join(input.root, "cache");
|
||||
const isolatedHome = join(input.root, "home");
|
||||
await Promise.all([
|
||||
mkdir(join(configHome, "opencode"), { recursive: true, mode: 0o700 }),
|
||||
mkdir(dataHome, { recursive: true, mode: 0o700 }),
|
||||
mkdir(cacheHome, { recursive: true, mode: 0o700 }),
|
||||
mkdir(isolatedHome, { recursive: true, mode: 0o700 }),
|
||||
]);
|
||||
const config = {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
model: input.options.model,
|
||||
small_model: input.options.model,
|
||||
share: "disabled",
|
||||
instructions: [],
|
||||
plugin: [],
|
||||
permission: { "*": "allow", external_directory: "deny" },
|
||||
mcp: {
|
||||
paperclip: {
|
||||
type: "remote",
|
||||
url: bridge.url,
|
||||
enabled: true,
|
||||
oauth: false,
|
||||
headers: { Authorization: `Bearer ${bridge.secret}` },
|
||||
timeout: 30_000,
|
||||
},
|
||||
},
|
||||
};
|
||||
await writeFile(join(configHome, "opencode", "opencode.json"), `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
|
||||
const environment = sanitizedEnvironment(input.options.environment ?? process.env, {
|
||||
HOME: isolatedHome,
|
||||
XDG_CONFIG_HOME: configHome,
|
||||
XDG_DATA_HOME: dataHome,
|
||||
XDG_CACHE_HOME: cacheHome,
|
||||
OPENCODE_DISABLE_PROJECT_CONFIG: "true",
|
||||
OPENCODE_SERVER_USERNAME: username,
|
||||
OPENCODE_SERVER_PASSWORD: password,
|
||||
});
|
||||
const child = spawn(input.options.command ?? "opencode", ["serve", "--hostname", "127.0.0.1", "--port", String(port)], {
|
||||
cwd: input.cwd,
|
||||
env: environment,
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
detached: globalThis.process.platform !== "win32",
|
||||
});
|
||||
let diagnostics = "";
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
diagnostics = `${diagnostics}${String(chunk)}`.slice(-8_192);
|
||||
input.options.onDiagnostic?.(redact(String(chunk), [password, input.options.environment?.OPENROUTER_API_KEY]));
|
||||
});
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
child.once("spawn", resolve);
|
||||
child.once("error", reject);
|
||||
});
|
||||
if (child.pid) await input.options.onSpawn?.({
|
||||
pid: child.pid,
|
||||
processGroupId: globalThis.process.platform === "win32" ? null : child.pid,
|
||||
startedAt: new Date().toISOString(),
|
||||
});
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
const health = await waitForHealth(baseUrl, authHeader, input.options.fetch ?? globalThis.fetch, child, diagnostics);
|
||||
const version = text(record(health).version);
|
||||
if (!/^\d+\.\d+\.\d+$/.test(version)) throw new Error("OpenCode health response omitted a semantic version");
|
||||
const qualifiedComparison = compareVersion(version, QUALIFIED_OPENCODE_VERSION);
|
||||
if (qualifiedComparison < 0) throw new Error(`OpenCode ${version} is older than required ${QUALIFIED_OPENCODE_VERSION}`);
|
||||
if (qualifiedComparison > 0) input.options.onDiagnostic?.(`OpenCode ${version} is newer than qualified ${QUALIFIED_OPENCODE_VERSION}.`);
|
||||
return {
|
||||
baseUrl,
|
||||
authHeader,
|
||||
version,
|
||||
process: child,
|
||||
bridge,
|
||||
sensitiveValues: [password, input.options.environment?.OPENROUTER_API_KEY].filter((value): value is string => Boolean(value)),
|
||||
close: async () => {
|
||||
await bridge.close().catch(() => {});
|
||||
if (child.exitCode === null && child.signalCode === null && child.pid) {
|
||||
try {
|
||||
if (globalThis.process.platform === "win32") child.kill("SIGTERM");
|
||||
else globalThis.process.kill(-child.pid, "SIGTERM");
|
||||
} catch { child.kill("SIGTERM"); }
|
||||
}
|
||||
await waitForExit(child, 2_000);
|
||||
if (child.exitCode === null && child.signalCode === null && child.pid) {
|
||||
try {
|
||||
if (globalThis.process.platform === "win32") child.kill("SIGKILL");
|
||||
else globalThis.process.kill(-child.pid, "SIGKILL");
|
||||
} catch { child.kill("SIGKILL"); }
|
||||
}
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
await bridge.close().catch(() => {});
|
||||
child.kill("SIGKILL");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function api(fetcher: typeof globalThis.fetch, runtime: OpenCodeRuntime, path: string, init: RequestInit = {}): Promise<unknown> {
|
||||
const timeout = AbortSignal.timeout(20_000);
|
||||
const signal = init.signal ? AbortSignal.any([init.signal, timeout]) : timeout;
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetcher(`${runtime.baseUrl}${path}`, {
|
||||
...init,
|
||||
signal,
|
||||
headers: { Authorization: runtime.authHeader, "Content-Type": "application/json", ...init.headers },
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(`OpenCode API ${path} request failed: ${redact(String(error), runtime.sensitiveValues)}`);
|
||||
}
|
||||
if (!response.ok) throw new Error(`OpenCode API ${path} returned HTTP ${response.status}: ${redact(await response.text(), runtime.sensitiveValues)}`);
|
||||
if (response.status === 204) return null;
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function retryableOpenCodeStartupError(error: unknown): boolean {
|
||||
const message = String(error);
|
||||
return message.includes("request failed")
|
||||
|| message.includes("did not become healthy")
|
||||
|| message.includes("exited during startup")
|
||||
|| message.includes("HTTP 408")
|
||||
|| message.includes("HTTP 425")
|
||||
|| message.includes("HTTP 429")
|
||||
|| /HTTP 5\d\d/.test(message);
|
||||
}
|
||||
|
||||
async function waitForHealth(baseUrl: string, authHeader: string, fetcher: typeof globalThis.fetch, process: ChildProcess, diagnostics: string): Promise<unknown> {
|
||||
const deadline = Date.now() + 10_000;
|
||||
while (Date.now() < deadline) {
|
||||
if (process.exitCode !== null || process.signalCode !== null) throw new Error(`OpenCode server exited during startup: ${redact(diagnostics)}`);
|
||||
try {
|
||||
const response = await fetcher(`${baseUrl}/global/health`, {
|
||||
headers: { Authorization: authHeader },
|
||||
signal: AbortSignal.timeout(1_000),
|
||||
});
|
||||
if (response.ok) return response.json();
|
||||
} catch { /* server is still starting */ }
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
throw new Error("OpenCode server did not become healthy within 10 seconds");
|
||||
}
|
||||
|
||||
async function reservePort(): Promise<number> {
|
||||
const server = createServer();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => resolve());
|
||||
});
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("Unable to reserve OpenCode port");
|
||||
const port = address.port;
|
||||
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
||||
return port;
|
||||
}
|
||||
|
||||
async function* parseSse(stream: ReadableStream<Uint8Array>): AsyncIterable<unknown> {
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
for await (const chunk of stream as unknown as AsyncIterable<Uint8Array>) {
|
||||
buffer += decoder.decode(chunk, { stream: true });
|
||||
if (buffer.length > 1_048_576) throw new Error("OpenCode SSE event exceeded the retained payload limit");
|
||||
let boundary: number;
|
||||
while ((boundary = buffer.indexOf("\n\n")) >= 0) {
|
||||
const frame = buffer.slice(0, boundary).replaceAll("\r", "");
|
||||
buffer = buffer.slice(boundary + 2);
|
||||
const data = frame.split("\n").filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trimStart()).join("\n");
|
||||
if (!data || data === "[DONE]") continue;
|
||||
yield JSON.parse(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizedEnvironment(source: NodeJS.ProcessEnv, overrides: Record<string, string>): NodeJS.ProcessEnv {
|
||||
const allowed = ["PATH", "LANG", "LC_ALL", "LC_CTYPE", "TZ", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "ALL_PROXY", "SSL_CERT_FILE", "SSL_CERT_DIR", "OPENROUTER_API_KEY"];
|
||||
const result: NodeJS.ProcessEnv = {};
|
||||
for (const key of allowed) if (source[key] !== undefined) result[key] = source[key];
|
||||
return { ...result, ...overrides };
|
||||
}
|
||||
|
||||
function sanitizedEnvironmentKeys(): string[] {
|
||||
return ["PATH", "LANG", "LC_ALL", "LC_CTYPE", "TZ", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "ALL_PROXY", "SSL_CERT_FILE", "SSL_CERT_DIR", "OPENROUTER_API_KEY"];
|
||||
}
|
||||
|
||||
function sessionRoot(runtimeDirectory: string, normalizedSessionId: string): string {
|
||||
const safe = normalizedSessionId.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 120);
|
||||
if (!safe || safe === "." || safe === "..") throw new Error("Invalid normalized OpenCode session id");
|
||||
return join(resolve(runtimeDirectory), safe);
|
||||
}
|
||||
|
||||
function validateWorkspace(value: string): string {
|
||||
const cwd = resolve(value);
|
||||
if (!value.trim() || cwd === dirname(cwd)) throw new Error("OpenCode working directory must not be a filesystem root");
|
||||
return cwd;
|
||||
}
|
||||
|
||||
function validModel(value: string): boolean {
|
||||
const slash = value.indexOf("/");
|
||||
return slash > 0 && slash < value.length - 1;
|
||||
}
|
||||
|
||||
function compareVersion(left: string, right: string): number {
|
||||
const a = left.split(".").map(Number);
|
||||
const b = right.split(".").map(Number);
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
if (a[index] !== b[index]) return (a[index] ?? 0) - (b[index] ?? 0);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function bounded(value: unknown): Record<string, unknown> {
|
||||
const serialized = JSON.stringify(value);
|
||||
if (!serialized || serialized.length > 64 * 1024) return { omitted: true, reason: "payload_limit" };
|
||||
const parsed = JSON.parse(serialized) as unknown;
|
||||
return isRecord(parsed) ? parsed : { value: parsed };
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> { return isRecord(value) ? value : {}; }
|
||||
function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }
|
||||
function text(value: unknown, fallback = ""): string { return typeof value === "string" ? value : fallback; }
|
||||
function redact(value: string, sensitiveValues: readonly (string | undefined)[] = []): string {
|
||||
let redacted = value;
|
||||
for (const sensitive of sensitiveValues) {
|
||||
if (sensitive && sensitive.length >= 4) redacted = redacted.split(sensitive).join("[REDACTED]");
|
||||
}
|
||||
return redacted.replace(/(OPENROUTER_API_KEY|authorization|password|token|secret)\s*[:=]\s*[^\s,}\]]+/gi, "$1=[REDACTED]").slice(0, 8_192);
|
||||
}
|
||||
function canonicalJson(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
||||
if (isRecord(value)) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`;
|
||||
return JSON.stringify(value) ?? "undefined";
|
||||
}
|
||||
|
||||
async function waitForExit(process: ChildProcess, timeoutMs: number): Promise<void> {
|
||||
if (process.exitCode !== null || process.signalCode !== null) return;
|
||||
await new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(resolve, timeoutMs);
|
||||
timer.unref?.();
|
||||
process.once("exit", () => { clearTimeout(timer); resolve(); });
|
||||
});
|
||||
}
|
||||
|
||||
export const openCodeServerDriverInternals = { parseSse };
|
||||
|
||||
class AsyncQueue<T> implements AsyncIterable<T> {
|
||||
#items: T[] = [];
|
||||
#waiters: Array<{ resolve: (result: IteratorResult<T>) => void; reject: (error: unknown) => void }> = [];
|
||||
#closed = false;
|
||||
#error: unknown = null;
|
||||
push(item: T): void {
|
||||
if (this.#closed) return;
|
||||
const waiter = this.#waiters.shift();
|
||||
if (waiter) waiter.resolve({ done: false, value: item });
|
||||
else this.#items.push(item);
|
||||
}
|
||||
close(): void {
|
||||
if (this.#closed) return;
|
||||
this.#closed = true;
|
||||
for (const waiter of this.#waiters.splice(0)) waiter.resolve({ done: true, value: undefined });
|
||||
}
|
||||
fail(error: unknown): void {
|
||||
if (this.#closed) return;
|
||||
this.#closed = true;
|
||||
this.#error = error;
|
||||
for (const waiter of this.#waiters.splice(0)) waiter.reject(error);
|
||||
}
|
||||
[Symbol.asyncIterator](): AsyncIterator<T> {
|
||||
return {
|
||||
next: () => {
|
||||
const item = this.#items.shift();
|
||||
if (item !== undefined) return Promise.resolve({ done: false, value: item });
|
||||
if (this.#error !== null) return Promise.reject(this.#error);
|
||||
if (this.#closed) return Promise.resolve({ done: true, value: undefined });
|
||||
return new Promise((resolve, reject) => this.#waiters.push({ resolve, reject }));
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
export const MODEL_PRICING_VERSION = "openai-api-2026-08-20" as const;
|
||||
export const MODEL_PRICING_VERSION = "provider-list-prices-2026-08-21" as const;
|
||||
|
||||
interface TokenRatesUsdPerMillion {
|
||||
input: number;
|
||||
@@ -14,6 +14,8 @@ const RATES: Readonly<Record<string, TokenRatesUsdPerMillion>> = Object.freeze({
|
||||
"gpt-5.6-sol": { input: 5, cachedInput: 0.5, output: 30 },
|
||||
"gpt-5.6-terra": { input: 2, cachedInput: 0.2, output: 12 },
|
||||
"gpt-5.6-luna": { input: 0.2, cachedInput: 0.02, output: 1.2 },
|
||||
// OpenRouter routed list price published for DeepSeek V4 Flash 0731.
|
||||
"openrouter/deepseek/deepseek-v4-flash-0731": { input: 0.065, cachedInput: 0.02, output: 0.18 },
|
||||
});
|
||||
|
||||
export interface EstimatedModelCost {
|
||||
|
||||
@@ -8,6 +8,8 @@ export * from "./contracts/codex.js";
|
||||
export * from "./contracts/types.js";
|
||||
export * from "./backends/harness-driver-backend.js";
|
||||
export * from "./backends/codex-native-backend.js";
|
||||
export * from "./backends/opencode-native-backend.js";
|
||||
export * from "./backends/native-backend-factory.js";
|
||||
export * from "./native-session-runtime.js";
|
||||
export {
|
||||
DurablePrpControlPlane,
|
||||
@@ -15,12 +17,15 @@ export {
|
||||
} from "./control-plane/durable-prp-control-plane.js";
|
||||
export * from "./drivers/codex/app-server-transport.js";
|
||||
export * from "./drivers/codex/codex-app-server-driver.js";
|
||||
export * from "./drivers/opencode/opencode-server-driver.js";
|
||||
export * from "./drivers/opencode/mcp-bridge.js";
|
||||
export {
|
||||
createRunnerdCodexTransport,
|
||||
defaultCapabilityRunnerdBinary,
|
||||
type RunnerdCodexTransport,
|
||||
type RunnerdCodexTransportOptions,
|
||||
} from "./live/runnerd-codex-transport.js";
|
||||
export * from "./live/workspace-file-reference.js";
|
||||
export * from "./protocol/replay-contract.js";
|
||||
export * from "./protocol/replay-loader.js";
|
||||
export * from "./protocol/semantic-tool-receipts.js";
|
||||
|
||||
@@ -23,6 +23,8 @@ export const CAPABILITY_UI_SHOT_SLUGS = [
|
||||
"denial-optional-tool",
|
||||
"document-revision",
|
||||
"deliverable-registered",
|
||||
"workspace-file-changes",
|
||||
"workspace-file-reference",
|
||||
"disposition-terminal",
|
||||
"debug-panel-open",
|
||||
"reconnect-banner",
|
||||
@@ -954,6 +956,87 @@ const BUILDERS: Record<CapabilityUiShotSlug, () => CapabilityIssueThreadSnapshot
|
||||
return snapshot;
|
||||
},
|
||||
|
||||
"workspace-file-changes": () => {
|
||||
const snapshot = baseline();
|
||||
const turn = lastTurn(snapshot);
|
||||
turn.items.push({
|
||||
kind: "workspace_changes",
|
||||
id: "item-workspace-turn-3",
|
||||
at: at(4, 18),
|
||||
changeSet: {
|
||||
schema: "paperclip.workspace.diff.v1",
|
||||
changeSetId: "turn-3:workspace",
|
||||
revision: 1,
|
||||
source: "runner_verified",
|
||||
complete: true,
|
||||
files: [
|
||||
{
|
||||
path: "packages/runner/src/workspace-events.ts",
|
||||
operation: "create",
|
||||
previousPath: null,
|
||||
additions: 12,
|
||||
deletions: 0,
|
||||
binary: false,
|
||||
diff: "diff --git a/packages/runner/src/workspace-events.ts b/packages/runner/src/workspace-events.ts\n--- /dev/null\n+++ b/packages/runner/src/workspace-events.ts\n@@ -1,0 +1,12 @@\n+export const WORKSPACE_DIFF_SCHEMA = \\\"paperclip.workspace.diff.v1\\\";\n+\n+export function recordWorkspaceDiff() {\n+ return { complete: true };\n+}\n",
|
||||
},
|
||||
{
|
||||
path: "packages/runner/src/index.ts",
|
||||
operation: "modify",
|
||||
previousPath: null,
|
||||
additions: 2,
|
||||
deletions: 1,
|
||||
binary: false,
|
||||
diff: "diff --git a/packages/runner/src/index.ts b/packages/runner/src/index.ts\n--- a/packages/runner/src/index.ts\n+++ b/packages/runner/src/index.ts\n@@ -4,1 +4,2 @@\n-export * from './events';\n+export * from './events';\n+export * from './workspace-events';\n",
|
||||
},
|
||||
{
|
||||
path: "packages/runner/src/legacy-diff.ts",
|
||||
operation: "delete",
|
||||
previousPath: null,
|
||||
additions: 0,
|
||||
deletions: 8,
|
||||
binary: false,
|
||||
diff: "diff --git a/packages/runner/src/legacy-diff.ts b/packages/runner/src/legacy-diff.ts\n--- a/packages/runner/src/legacy-diff.ts\n+++ /dev/null\n@@ -1,8 +1,0 @@\n-export function legacyDiff() {\n- return null;\n-}\n",
|
||||
},
|
||||
{
|
||||
path: "packages/runner/test/workspace-events.test.ts",
|
||||
operation: "create",
|
||||
previousPath: null,
|
||||
additions: 16,
|
||||
deletions: 0,
|
||||
binary: false,
|
||||
diff: "diff --git a/packages/runner/test/workspace-events.test.ts b/packages/runner/test/workspace-events.test.ts\n--- /dev/null\n+++ b/packages/runner/test/workspace-events.test.ts\n@@ -1,0 +1,16 @@\n+import { test } from 'vitest';\n+\n+test('records workspace changes', () => {\n+ // deterministic fixture\n+});\n",
|
||||
},
|
||||
],
|
||||
totals: { files: 4, additions: 30, deletions: 9 },
|
||||
patchArtifactRef: null,
|
||||
},
|
||||
});
|
||||
return snapshot;
|
||||
},
|
||||
|
||||
"workspace-file-reference": () => {
|
||||
const snapshot = baseline();
|
||||
lastTurn(snapshot).items.push({
|
||||
kind: "workspace_file_reference",
|
||||
id: "item-file-reference-turn-3",
|
||||
at: at(4, 16),
|
||||
reference: {
|
||||
schema: "paperclip.workspace.file_reference.v1",
|
||||
referenceId: "turn-3:file:runner-plan",
|
||||
source: "runner_verified",
|
||||
path: "docs/paperclip-runner-protocol.md",
|
||||
displayName: "paperclip-runner-protocol.md",
|
||||
mediaType: "text/markdown",
|
||||
presentation: "document",
|
||||
line: null,
|
||||
preview: "# Paperclip Runner Protocol\n\nThe runner projects provider activity into durable, provider-neutral events.\n\n## Workspace references\n\nA local Markdown link becomes a `workspace.file.referenced` event only after the runner verifies that the target stays inside the authorized workspace.\n",
|
||||
previewTruncated: false,
|
||||
contentDigest: "sha256:2e7d2c03a9507ae265ecf5b5356885a53393a2029d241e48b76e557868953c97",
|
||||
},
|
||||
});
|
||||
return snapshot;
|
||||
},
|
||||
|
||||
"disposition-terminal": () => {
|
||||
const snapshot = baseline();
|
||||
const turn = lastTurn(snapshot);
|
||||
@@ -1046,7 +1129,12 @@ export function capabilityIssueThreadFixture(
|
||||
slug: string = "thread-baseline",
|
||||
fixtureProfile: string = CAPABILITY_DEFAULT_FIXTURE_PROFILE,
|
||||
): CapabilityIssueThreadSnapshot {
|
||||
const build = BUILDERS[slug as CapabilityUiShotSlug] ?? BUILDERS["thread-baseline"];
|
||||
const effectiveSlug = slug === "thread-baseline" && fixtureProfile === "wc-workspace-changes"
|
||||
? "workspace-file-changes"
|
||||
: slug === "thread-baseline" && fixtureProfile === "fr-file-reference"
|
||||
? "workspace-file-reference"
|
||||
: slug;
|
||||
const build = BUILDERS[effectiveSlug as CapabilityUiShotSlug] ?? BUILDERS["thread-baseline"];
|
||||
const snapshot = build();
|
||||
snapshot.issue.fixtureProfile = fixtureProfile;
|
||||
return snapshot;
|
||||
|
||||
@@ -22,6 +22,8 @@ const REQUIRED_ITEM_KINDS: Array<CapabilityThreadItem["kind"]> = [
|
||||
"agent_message",
|
||||
"durable_comment",
|
||||
"tool_activity",
|
||||
"workspace_changes",
|
||||
"workspace_file_reference",
|
||||
"interaction",
|
||||
"document",
|
||||
"deliverable",
|
||||
@@ -59,9 +61,9 @@ function credentialLike(value: unknown): string[] {
|
||||
}
|
||||
|
||||
describe("Capability issue-thread fixtures", () => {
|
||||
it("exposes exactly the twelve screenshot slugs from the UX contract §10.2", () => {
|
||||
expect(CAPABILITY_UI_SHOT_SLUGS).toHaveLength(12);
|
||||
expect(new Set(CAPABILITY_UI_SHOT_SLUGS).size).toBe(12);
|
||||
it("exposes every screenshot slug exactly once", () => {
|
||||
expect(CAPABILITY_UI_SHOT_SLUGS).toHaveLength(14);
|
||||
expect(new Set(CAPABILITY_UI_SHOT_SLUGS).size).toBe(14);
|
||||
});
|
||||
|
||||
it("builds a schema-valid snapshot for every slug", () => {
|
||||
@@ -164,6 +166,34 @@ describe("Capability issue-thread fixtures", () => {
|
||||
expect(credentialLike(capabilityIssueThreadFixture(slug)), slug).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("includes a reviewable workspace-change scenario", () => {
|
||||
const snapshot = capabilityIssueThreadFixture("workspace-file-changes");
|
||||
const item = allItems(snapshot).find((candidate) => candidate.kind === "workspace_changes");
|
||||
expect(item?.kind).toBe("workspace_changes");
|
||||
if (item?.kind !== "workspace_changes") return;
|
||||
expect(item.changeSet.files).toHaveLength(4);
|
||||
expect(item.changeSet.source).toBe("runner_verified");
|
||||
expect(item.changeSet.files[0]?.diff).toContain("diff --git");
|
||||
expect(allItems(capabilityIssueThreadFixture("thread-baseline", "wc-workspace-changes")))
|
||||
.toEqual(allItems(snapshot));
|
||||
});
|
||||
|
||||
it("includes a verified individual-file reference scenario", () => {
|
||||
const snapshot = capabilityIssueThreadFixture("workspace-file-reference");
|
||||
const item = allItems(snapshot).find((candidate) => candidate.kind === "workspace_file_reference");
|
||||
expect(item?.kind).toBe("workspace_file_reference");
|
||||
if (item?.kind !== "workspace_file_reference") return;
|
||||
expect(item.reference).toMatchObject({
|
||||
schema: "paperclip.workspace.file_reference.v1",
|
||||
source: "runner_verified",
|
||||
path: "docs/paperclip-runner-protocol.md",
|
||||
presentation: "document",
|
||||
});
|
||||
expect(item.reference.preview).toContain("# Paperclip Runner Protocol");
|
||||
expect(allItems(capabilityIssueThreadFixture("thread-baseline", "fr-file-reference")))
|
||||
.toEqual(allItems(snapshot));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Capability live projection", () => {
|
||||
@@ -345,7 +375,22 @@ describe("Capability live projection", () => {
|
||||
}
|
||||
|
||||
it("projects mock records into the same view contract the fixtures use", async () => {
|
||||
const view = projectCapabilityIssueThread({ snapshot: await liveSnapshot() });
|
||||
const snapshot = await liveSnapshot();
|
||||
snapshot.workspaceDiffs = [{
|
||||
turnId: "turn-1",
|
||||
at: "2026-08-09T09:00:35.000Z",
|
||||
diff: {
|
||||
schema: "paperclip.workspace.diff.v1",
|
||||
changeSetId: "turn-1:workspace",
|
||||
revision: 1,
|
||||
source: "runner_verified",
|
||||
complete: true,
|
||||
files: [{ path: "src/index.ts", operation: "modify", previousPath: null, additions: 1, deletions: 1, binary: false, diff: "-old\n+new\n" }],
|
||||
totals: { files: 1, additions: 1, deletions: 1 },
|
||||
patchArtifactRef: null,
|
||||
},
|
||||
}];
|
||||
const view = projectCapabilityIssueThread({ snapshot });
|
||||
|
||||
expect(view.schema).toBe(CAPABILITY_ISSUE_THREAD_VIEW_SCHEMA);
|
||||
expect(view.mode).toBe("live");
|
||||
@@ -357,6 +402,10 @@ describe("Capability live projection", () => {
|
||||
expect(items.filter((item) => item.kind === "user_message")).toHaveLength(1);
|
||||
expect(items.some((item) => item.kind === "agent_message")).toBe(true);
|
||||
expect(items.some((item) => item.kind === "tool_activity")).toBe(true);
|
||||
expect(items.find((item) => item.kind === "workspace_changes")).toMatchObject({
|
||||
kind: "workspace_changes",
|
||||
changeSet: { source: "runner_verified", totals: { files: 1 } },
|
||||
});
|
||||
|
||||
const progress = items.find((item) => item.kind === "progress_activity");
|
||||
expect(progress).toMatchObject({
|
||||
@@ -380,6 +429,21 @@ describe("Capability live projection", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the persisted harness provider for live agent identity", async () => {
|
||||
const snapshot = await liveSnapshot();
|
||||
snapshot.config.provider = "opencode";
|
||||
snapshot.config.driver = "opencode_server";
|
||||
snapshot.status = "running";
|
||||
snapshot.activeTurnId = "turn-1";
|
||||
const view = projectCapabilityIssueThread({ snapshot });
|
||||
|
||||
expect(view.identity.agentLabel).toBe("Real OpenCode");
|
||||
expect(allItems(view).find((item) => item.kind === "agent_message"))
|
||||
.toMatchObject({ author: "Real OpenCode" });
|
||||
expect(view.composer.helper).toBe("OpenCode is working — send to steer, or stop the turn.");
|
||||
expect(JSON.stringify(view)).not.toContain("Codex");
|
||||
});
|
||||
|
||||
it("hides the synthetic interaction-result message from the thread", async () => {
|
||||
const view = projectCapabilityIssueThread({ snapshot: await liveSnapshot() });
|
||||
const bodies = allItems(view)
|
||||
|
||||
@@ -514,6 +514,7 @@ function composerModel(
|
||||
mode: CapabilityThreadMode,
|
||||
pendingInteractionId: string | null,
|
||||
terminal: boolean,
|
||||
providerName: string,
|
||||
): CapabilityComposerModel {
|
||||
if (mode === "replay") {
|
||||
return { state: "disabled", helper: null, reason: "Replay is read-only", pendingInteractionId: null };
|
||||
@@ -534,6 +535,16 @@ function composerModel(
|
||||
pendingInteractionId: null,
|
||||
};
|
||||
}
|
||||
if (snapshot.status === "suspended" || snapshot.status === "suspending") {
|
||||
return {
|
||||
state: snapshot.status === "suspending" ? "reconnecting" : "ready",
|
||||
helper: snapshot.status === "suspended"
|
||||
? "Runner suspended — your next message will restore this provider session."
|
||||
: "Suspending runner after durable flush…",
|
||||
reason: null,
|
||||
pendingInteractionId,
|
||||
};
|
||||
}
|
||||
if (terminal) {
|
||||
return {
|
||||
state: "ready",
|
||||
@@ -556,7 +567,7 @@ function composerModel(
|
||||
if (snapshot.activeTurnId !== null || snapshot.status === "running") {
|
||||
return {
|
||||
state: "streaming",
|
||||
helper: "Codex is working — send to steer, or stop the turn.",
|
||||
helper: `${providerName} is working — send to steer, or stop the turn.`,
|
||||
reason: null,
|
||||
pendingInteractionId: null,
|
||||
};
|
||||
@@ -707,6 +718,11 @@ export function projectCapabilityIssueThread(
|
||||
): CapabilityIssueThreadSnapshot {
|
||||
const { snapshot } = input;
|
||||
const mode = input.mode ?? "live";
|
||||
const agentLabel = mode === "live"
|
||||
? snapshot.config.provider === "opencode" || snapshot.config.driver === "opencode_server"
|
||||
? "Real OpenCode"
|
||||
: "Real Codex"
|
||||
: mode === "replay" ? "Replay" : "Fake agent";
|
||||
const connection = input.connection ?? { state: "connected", attempt: 0 };
|
||||
const state = parseMockState(snapshot.mockState);
|
||||
const calls = collectCalls(snapshot.evidence);
|
||||
@@ -752,7 +768,7 @@ export function projectCapabilityIssueThread(
|
||||
kind: "agent_message",
|
||||
id: entry.id,
|
||||
at: entry.at,
|
||||
author: mode === "live" ? "Real Codex" : "Fake agent",
|
||||
author: agentLabel,
|
||||
body: entry.text,
|
||||
streaming: snapshot.activeTurnId !== null && entry.turnId === snapshot.activeTurnId,
|
||||
});
|
||||
@@ -803,6 +819,7 @@ export function projectCapabilityIssueThread(
|
||||
}
|
||||
for (const group of progressGroups.values()) {
|
||||
const copy = PROGRESS_EVENTS[group.event];
|
||||
const providerName = agentLabel.replace(/^Real /, "");
|
||||
const running = snapshot.activeTurnId === group.turnId && !group.event.endsWith("_completed");
|
||||
turnFor(group.turnId, group.at).items.push({
|
||||
kind: "progress_activity",
|
||||
@@ -810,13 +827,31 @@ export function projectCapabilityIssueThread(
|
||||
at: group.at,
|
||||
activity: copy.activity,
|
||||
status: running ? "running" : "complete",
|
||||
label: copy.label,
|
||||
summary: running ? copy.running : copy.complete,
|
||||
label: copy.label.replaceAll("Codex", providerName),
|
||||
summary: (running ? copy.running : copy.complete).replaceAll("Codex", providerName),
|
||||
eventCount: group.count,
|
||||
details: group.details,
|
||||
});
|
||||
}
|
||||
|
||||
for (const entry of snapshot.workspaceDiffs ?? []) {
|
||||
turnFor(entry.turnId, entry.at).items.push({
|
||||
kind: "workspace_changes",
|
||||
id: `item-workspace-${entry.diff.changeSetId}`,
|
||||
at: entry.at,
|
||||
changeSet: structuredClone(entry.diff),
|
||||
});
|
||||
}
|
||||
|
||||
for (const entry of snapshot.workspaceFileReferences ?? []) {
|
||||
turnFor(entry.turnId, entry.at).items.push({
|
||||
kind: "workspace_file_reference",
|
||||
id: `item-file-reference-${entry.reference.referenceId}`,
|
||||
at: entry.at,
|
||||
reference: structuredClone(entry.reference),
|
||||
});
|
||||
}
|
||||
|
||||
// The transcript, provider progress, and semantic calls are collected from
|
||||
// separate durable record sets. Put them back into time order for a chat-like
|
||||
// flow while relying on stable sort order for records sharing a timestamp.
|
||||
@@ -841,9 +876,9 @@ export function projectCapabilityIssueThread(
|
||||
sessionId: snapshot.sessionId,
|
||||
mode,
|
||||
identity: {
|
||||
agentLabel: mode === "live" ? "Real Codex" : mode === "replay" ? "Replay" : "Fake agent",
|
||||
agentLabel,
|
||||
runnerLabel: mode === "live" ? "Real runnerd" : "In-process runner",
|
||||
runnerAttached: snapshot.status === "idle" || snapshot.status === "running",
|
||||
runnerAttached: ["idle", "warm_idle", "waiting_input", "running"].includes(snapshot.status),
|
||||
controlPlaneLabel: "Mock Paperclip",
|
||||
controlPlaneTooltip: "All issue records are mock. No real Paperclip API is reachable.",
|
||||
replaySource: input.replaySource ?? null,
|
||||
@@ -866,6 +901,7 @@ export function projectCapabilityIssueThread(
|
||||
mode,
|
||||
pendingInteraction?.id ?? null,
|
||||
terminal,
|
||||
agentLabel.replace(/^Real /, ""),
|
||||
),
|
||||
evidence: evidenceModel(snapshot, state, calls, input),
|
||||
connection,
|
||||
|
||||
@@ -298,6 +298,51 @@ function publicItem(item: CapabilityThreadItem, aliases: Aliases): CapabilityThr
|
||||
eventCount: item.eventCount,
|
||||
details: item.details,
|
||||
};
|
||||
case "workspace_changes":
|
||||
return {
|
||||
kind: "workspace_changes",
|
||||
id,
|
||||
at: item.at,
|
||||
changeSet: {
|
||||
schema: item.changeSet.schema,
|
||||
changeSetId: aliases.text(item.changeSet.changeSetId),
|
||||
revision: item.changeSet.revision,
|
||||
source: item.changeSet.source,
|
||||
complete: item.changeSet.complete,
|
||||
files: item.changeSet.files.map((file) => ({
|
||||
path: clamp(file.path, 1024),
|
||||
operation: file.operation,
|
||||
previousPath: file.previousPath === null ? null : clamp(file.previousPath, 1024),
|
||||
additions: file.additions,
|
||||
deletions: file.deletions,
|
||||
binary: file.binary,
|
||||
diff: file.diff === null ? null : clamp(file.diff, 262_144),
|
||||
})),
|
||||
totals: { ...item.changeSet.totals },
|
||||
patchArtifactRef: item.changeSet.patchArtifactRef === null
|
||||
? null
|
||||
: clamp(item.changeSet.patchArtifactRef, 2048),
|
||||
},
|
||||
};
|
||||
case "workspace_file_reference":
|
||||
return {
|
||||
kind: "workspace_file_reference",
|
||||
id,
|
||||
at: item.at,
|
||||
reference: {
|
||||
schema: item.reference.schema,
|
||||
referenceId: aliases.text(item.reference.referenceId),
|
||||
source: item.reference.source,
|
||||
path: clamp(item.reference.path, 1024),
|
||||
displayName: clamp(item.reference.displayName, 255),
|
||||
mediaType: item.reference.mediaType === null ? null : clamp(item.reference.mediaType, 160),
|
||||
presentation: item.reference.presentation,
|
||||
line: item.reference.line,
|
||||
preview: item.reference.preview === null ? null : clamp(item.reference.preview, 262_144),
|
||||
previewTruncated: item.reference.previewTruncated,
|
||||
contentDigest: item.reference.contentDigest,
|
||||
},
|
||||
};
|
||||
case "interaction":
|
||||
return {
|
||||
kind: "interaction",
|
||||
|
||||
@@ -14,6 +14,9 @@ import type {
|
||||
CapabilityJsonValue,
|
||||
CapabilityTaskStatus,
|
||||
} from "../mock-core/capability-control-plane-types.js";
|
||||
import type { PaperclipWorkspaceDiff } from "../live/workspace-diff.js";
|
||||
import type { PaperclipWorkspaceFileReference } from "../live/workspace-file-reference.js";
|
||||
export type { CapabilityJsonValue } from "../mock-core/capability-control-plane-types.js";
|
||||
import type { CapabilitySemanticOperationId } from "../semantic-tools/types.js";
|
||||
|
||||
export const CAPABILITY_ISSUE_THREAD_VIEW_SCHEMA = "paperclip.capability.issue-thread-view.v1" as const;
|
||||
@@ -34,7 +37,7 @@ export const CAPABILITY_DISPOSITION_LABELS: Readonly<Record<CapabilityToolDispos
|
||||
};
|
||||
|
||||
export interface CapabilityThreadIdentity {
|
||||
/** `Real Codex` in live mode, `Fake agent` in fake mode, `Replay` in replay mode. */
|
||||
/** Provider-aware live harness label, `Fake agent` in fake mode, or `Replay`. */
|
||||
agentLabel: string;
|
||||
/** `Real runnerd` in live mode, `In-process runner` otherwise. */
|
||||
runnerLabel: string;
|
||||
@@ -208,6 +211,18 @@ export type CapabilityThreadItem =
|
||||
eventCount: number;
|
||||
details: Record<string, CapabilityJsonValue>;
|
||||
}
|
||||
| {
|
||||
kind: "workspace_changes";
|
||||
id: string;
|
||||
at: string;
|
||||
changeSet: PaperclipWorkspaceDiff;
|
||||
}
|
||||
| {
|
||||
kind: "workspace_file_reference";
|
||||
id: string;
|
||||
at: string;
|
||||
reference: PaperclipWorkspaceFileReference;
|
||||
}
|
||||
| ({ kind: "interaction"; id: string; at: string } & CapabilityThreadInteractionCard)
|
||||
| {
|
||||
kind: "document";
|
||||
|
||||
@@ -367,6 +367,11 @@ interface CleanRoomPayload {
|
||||
identity: { token: string; companyId: string; actorId: string; taskId: string; identifier: string };
|
||||
limits: { maxTurns: number; maxMessageBytes: number };
|
||||
view: CapabilityIssueThreadSnapshot;
|
||||
configuration: {
|
||||
provider: "codex" | "opencode";
|
||||
model: string | null;
|
||||
lifecyclePolicy: { mode: "per_turn"; idleTimeoutMs: null } | { mode: "warm"; idleTimeoutMs: number };
|
||||
};
|
||||
}
|
||||
|
||||
function items(view: CapabilityIssueThreadSnapshot): CapabilityThreadItem[] {
|
||||
@@ -620,6 +625,20 @@ describe("Capability clean-room chat server", () => {
|
||||
expect(opened.view.evidence.traceability).toEqual([]);
|
||||
});
|
||||
|
||||
it("creates an OpenCode room with the requested provider/model pinned", async () => {
|
||||
const model = "openrouter/deepseek/deepseek-v4-flash-0731";
|
||||
const opened = await call(
|
||||
`/api/capability/ui/cleanroom/session?provider=opencode&model=${encodeURIComponent(model)}`,
|
||||
);
|
||||
|
||||
expect(opened.configuration).toEqual({
|
||||
provider: "opencode",
|
||||
model,
|
||||
lifecyclePolicy: { mode: "warm", idleTimeoutMs: 300_000 },
|
||||
});
|
||||
expect(opened.view.identity.agentLabel).toBe("Real OpenCode");
|
||||
});
|
||||
|
||||
it("recreates a removed run scratch root before starting a later chat", async () => {
|
||||
const opened = await call("/api/capability/ui/cleanroom/session");
|
||||
|
||||
@@ -858,7 +877,7 @@ describe("Capability clean-room chat server", () => {
|
||||
expect(retried.sessionId).toBe(opened.sessionId);
|
||||
});
|
||||
|
||||
it("reset and new chat both rotate identities and retire the prior authority", async () => {
|
||||
it("reset and new chat mint new identities while keeping prior chats resumable", async () => {
|
||||
const opened = await call("/api/capability/ui/cleanroom/session");
|
||||
await call("/api/capability/ui/message", {
|
||||
sessionId: opened.sessionId,
|
||||
@@ -874,7 +893,7 @@ describe("Capability clean-room chat server", () => {
|
||||
expect(await status("/api/capability/ui/message", {
|
||||
sessionId: opened.sessionId,
|
||||
message: "still there?",
|
||||
})).toBe(404);
|
||||
})).toBe(200);
|
||||
|
||||
const fresh = await call("/api/capability/ui/cleanroom/session", { sessionId: reset.sessionId });
|
||||
expect(fresh.sessionId).not.toBe(reset.sessionId);
|
||||
@@ -883,7 +902,7 @@ describe("Capability clean-room chat server", () => {
|
||||
expect(await status("/api/capability/ui/message", {
|
||||
sessionId: reset.sessionId,
|
||||
message: "still there?",
|
||||
})).toBe(404);
|
||||
})).toBe(200);
|
||||
});
|
||||
|
||||
it("bounds turns per chat with a named reason instead of an opaque failure", async () => {
|
||||
@@ -1153,7 +1172,7 @@ describe("Capability clean-room chat server", () => {
|
||||
expect(await status(`/api/capability/ui/session?sessionId=${aliceIssue.sessionId}`, undefined, alice)).toBe(200);
|
||||
});
|
||||
|
||||
it("rotates the capability on reset and new chat and revokes the old one", async () => {
|
||||
it("keeps the capability stable when reset and new chat archive resumable sessions", async () => {
|
||||
const alice = new Jar();
|
||||
const opened = await call("/api/capability/ui/cleanroom/session", undefined, undefined, alice);
|
||||
const first = alice.value("paperclip_capability_chat");
|
||||
@@ -1165,22 +1184,19 @@ describe("Capability clean-room chat server", () => {
|
||||
|
||||
const afterReset = await call("/api/capability/ui/reset", { sessionId: opened.sessionId }, undefined, alice);
|
||||
const second = alice.value("paperclip_capability_chat");
|
||||
expect(second).not.toBe(first);
|
||||
expect(second).toBe(first);
|
||||
expect(afterReset.sessionId).not.toBe(opened.sessionId);
|
||||
// The old cookie is dead against the replacement session as well as the
|
||||
// retired one, so a stolen cookie does not survive a reset.
|
||||
expect(await status(`/api/capability/ui/cleanroom/session?sessionId=${afterReset.sessionId}`, undefined, stale)).toBe(404);
|
||||
expect(await status("/api/capability/ui/message", { sessionId: afterReset.sessionId, message: "hi" }, stale)).toBe(404);
|
||||
expect(await status(`/api/capability/ui/cleanroom/session?sessionId=${opened.sessionId}`, undefined, stale)).toBe(200);
|
||||
expect(await status(`/api/capability/ui/cleanroom/session?sessionId=${afterReset.sessionId}`, undefined, stale)).toBe(200);
|
||||
|
||||
const afterNewChat = await call("/api/capability/ui/cleanroom/session", { sessionId: afterReset.sessionId }, undefined, alice);
|
||||
const third = alice.value("paperclip_capability_chat");
|
||||
expect(third).not.toBe(second);
|
||||
expect(third).toBe(second);
|
||||
const previous = new Jar();
|
||||
previous.capture(new Response(null, {
|
||||
headers: { "set-cookie": `paperclip_capability_chat=${second}` },
|
||||
}));
|
||||
expect(await status(`/api/capability/ui/cleanroom/session?sessionId=${afterNewChat.sessionId}`, undefined, previous)).toBe(404);
|
||||
expect(await status("/api/capability/ui/reset", { sessionId: afterNewChat.sessionId }, previous)).toBe(404);
|
||||
expect(await status(`/api/capability/ui/cleanroom/session?sessionId=${afterNewChat.sessionId}`, undefined, previous)).toBe(200);
|
||||
expect(await status(`/api/capability/ui/cleanroom/session?sessionId=${afterNewChat.sessionId}`, undefined, alice)).toBe(200);
|
||||
});
|
||||
|
||||
@@ -1208,7 +1224,7 @@ describe("Capability clean-room chat server", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("rotates the issue-surface capability on reset", async () => {
|
||||
it("keeps the issue-surface capability stable so reset archives remain resumable", async () => {
|
||||
const alice = new Jar();
|
||||
const opened = await call("/api/capability/ui/session?scenario=hb-baseline", undefined, undefined, alice);
|
||||
const first = alice.value("paperclip_capability_issue");
|
||||
@@ -1218,8 +1234,9 @@ describe("Capability clean-room chat server", () => {
|
||||
}));
|
||||
|
||||
const reset = await call("/api/capability/ui/reset", { sessionId: opened.sessionId }, undefined, alice);
|
||||
expect(alice.value("paperclip_capability_issue")).not.toBe(first);
|
||||
expect(await status(`/api/capability/ui/session?sessionId=${reset.sessionId}`, undefined, stale)).toBe(404);
|
||||
expect(alice.value("paperclip_capability_issue")).toBe(first);
|
||||
expect(await status(`/api/capability/ui/session?sessionId=${opened.sessionId}`, undefined, stale)).toBe(200);
|
||||
expect(await status(`/api/capability/ui/session?sessionId=${reset.sessionId}`, undefined, stale)).toBe(200);
|
||||
expect(await status(`/api/capability/ui/session?sessionId=${reset.sessionId}`, undefined, alice)).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -57,6 +57,7 @@ interface FakeProviderState {
|
||||
transports: FakeCapabilityCodexTransport[];
|
||||
turns: Map<string, string>;
|
||||
holdAfterTool: boolean;
|
||||
onTurnStart?: () => Promise<void>;
|
||||
}
|
||||
|
||||
class FakeCapabilityCodexTransport implements CodexAppServerTransport {
|
||||
@@ -114,6 +115,7 @@ class FakeCapabilityCodexTransport implements CodexAppServerTransport {
|
||||
this.state.turns.set(turnId, "inProgress");
|
||||
const input = Array.isArray(params.input) ? params.input[0] as Record<string, unknown> : {};
|
||||
const message = String(input.text ?? "");
|
||||
await this.state.onTurnStart?.();
|
||||
void this.#runTurn(turnId, message);
|
||||
return { turn: { id: turnId, status: "inProgress" } };
|
||||
}
|
||||
@@ -213,6 +215,8 @@ class FakeCapabilityCodexTransport implements CodexAppServerTransport {
|
||||
});
|
||||
this.state.lastToolResult = String((result.contentItems as Array<Record<string, unknown>>)[0]?.text);
|
||||
assistantText = "I created a durable question and will use its typed result when resumed.";
|
||||
} else if (message.includes("reference file")) {
|
||||
assistantText = "Open [guide.md](guide.md) for the complete notes.";
|
||||
} else if (message.includes("long")) {
|
||||
return;
|
||||
} else {
|
||||
@@ -288,6 +292,111 @@ function providerState(): FakeProviderState {
|
||||
}
|
||||
|
||||
describe("Capability live runnerd and Codex session", () => {
|
||||
it("turns an assistant local-file link into a verified dynamic-chat file card record", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "paperclip-live-file-reference-"));
|
||||
await writeFile(join(root, "guide.md"), "# Dynamic guide\n\nVerified by the runner.\n");
|
||||
const state = providerState();
|
||||
const service = new CapabilityLiveSessionService({ transportFactory: fakeTransportFactory(state) });
|
||||
const session = await service.create({ workingDirectory: root });
|
||||
|
||||
const result = await session.sendMessage("reference file");
|
||||
expect(result.snapshot.workspaceFileReferences).toHaveLength(1);
|
||||
expect(result.snapshot.workspaceFileReferences?.[0]?.reference).toMatchObject({
|
||||
schema: "paperclip.workspace.file_reference.v1",
|
||||
source: "runner_verified",
|
||||
path: "guide.md",
|
||||
preview: "# Dynamic guide\n\nVerified by the runner.\n",
|
||||
});
|
||||
await service.shutdown(session.id);
|
||||
});
|
||||
|
||||
it("records runner-verified file changes for a dynamic chat turn", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "paperclip-live-workspace-"));
|
||||
await writeFile(join(root, "existing.txt"), "before\n");
|
||||
const state = providerState();
|
||||
state.onTurnStart = async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
await writeFile(join(root, "existing.txt"), "after\n");
|
||||
await writeFile(join(root, "created.ts"), "export const created = true;\n");
|
||||
};
|
||||
const service = new CapabilityLiveSessionService({ transportFactory: fakeTransportFactory(state) });
|
||||
const session = await service.create({ workingDirectory: root });
|
||||
|
||||
const result = await session.sendMessage("edit the workspace");
|
||||
expect(result.snapshot.workspaceDiffs).toHaveLength(1);
|
||||
expect(result.snapshot.workspaceDiffs?.[0]).toMatchObject({
|
||||
turnId: "turn-1",
|
||||
diff: {
|
||||
schema: "paperclip.workspace.diff.v1",
|
||||
source: "runner_verified",
|
||||
complete: true,
|
||||
totals: { files: 2 },
|
||||
},
|
||||
});
|
||||
expect(result.snapshot.workspaceDiffs?.[0]?.diff.files.map((file) => file.path))
|
||||
.toEqual(["created.ts", "existing.txt"]);
|
||||
await service.shutdown(session.id);
|
||||
});
|
||||
|
||||
it("suspends after every per-turn response and restores the same provider session", async () => {
|
||||
const state = providerState();
|
||||
const service = new CapabilityLiveSessionService({ transportFactory: fakeTransportFactory(state) });
|
||||
const session = await service.create({ lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null } });
|
||||
|
||||
await session.sendMessage("first response");
|
||||
expect(session.snapshot()).toMatchObject({ status: "suspended", providerThreadId: state.threadId });
|
||||
expect(state.transports[0]?.processInfo().exited).toBe(true);
|
||||
|
||||
await session.sendMessage("restored response");
|
||||
expect(state.transports).toHaveLength(2);
|
||||
expect(session.snapshot()).toMatchObject({ status: "suspended", providerThreadId: state.threadId });
|
||||
await service.shutdown(session.id);
|
||||
});
|
||||
|
||||
it("keeps a warm runner through active work and suspends it only after idle expiry", async () => {
|
||||
const state = providerState();
|
||||
const service = new CapabilityLiveSessionService({ transportFactory: fakeTransportFactory(state) });
|
||||
const session = await service.create({ lifecyclePolicy: { mode: "warm", idleTimeoutMs: 25 } });
|
||||
|
||||
await session.sendMessage("warm response");
|
||||
expect(session.snapshot().status).toBe("warm_idle");
|
||||
expect(state.transports[0]?.processInfo().exited).toBe(false);
|
||||
await vi.waitFor(() => expect(session.snapshot().status).toBe("suspended"), { timeout: 500 });
|
||||
expect(state.transports[0]?.processInfo().exited).toBe(true);
|
||||
|
||||
const active = session.sendMessage("start a long turn");
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
expect(session.snapshot().status).toBe("running");
|
||||
await session.interrupt("test cleanup");
|
||||
await expect(active).resolves.toMatchObject({ status: "interrupted" });
|
||||
await service.shutdown(session.id);
|
||||
});
|
||||
|
||||
it("persists the selected provider and passes it to the live runner transport", async () => {
|
||||
const state = providerState();
|
||||
const providers: Array<string | undefined> = [];
|
||||
const baseFactory = fakeTransportFactory(state);
|
||||
const service = new CapabilityLiveSessionService({
|
||||
transportFactory: (options) => {
|
||||
providers.push(options.provider);
|
||||
return baseFactory(options);
|
||||
},
|
||||
});
|
||||
const session = await service.create({
|
||||
provider: "opencode",
|
||||
requestedModel: "openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
});
|
||||
|
||||
expect(providers).toEqual(["opencode"]);
|
||||
expect(session.snapshot().config).toMatchObject({
|
||||
provider: "opencode",
|
||||
driver: "opencode_server",
|
||||
providerVersion: "1.18.17",
|
||||
requestedModel: "openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
});
|
||||
await service.shutdown(session.id);
|
||||
});
|
||||
|
||||
it("opens lazy sessions with core and discovery gateways instead of optional schemas", async () => {
|
||||
const state = providerState();
|
||||
const claim = "discovery:agents:read";
|
||||
@@ -382,7 +491,7 @@ describe("Capability live runnerd and Codex session", () => {
|
||||
await firstService.shutdown(first.id);
|
||||
});
|
||||
|
||||
it("stops active work and reset rotates authority while restoring clean mock state", async () => {
|
||||
it("stops active work and reset archives the prior authority while restoring clean mock state", async () => {
|
||||
const state = providerState();
|
||||
const store = new InMemoryCapabilityLiveSessionStore();
|
||||
const service = new CapabilityLiveSessionService({
|
||||
@@ -392,6 +501,8 @@ describe("Capability live runnerd and Codex session", () => {
|
||||
const session = await service.create({
|
||||
runId: "run-live-reset",
|
||||
sessionId: "session-live-reset",
|
||||
provider: "opencode",
|
||||
requestedModel: "openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
});
|
||||
const longTurn = session.sendMessage("Start a long turn.");
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
@@ -404,13 +515,18 @@ describe("Capability live runnerd and Codex session", () => {
|
||||
expect(replacement.snapshot().authority.runId).not.toBe("run-live-reset");
|
||||
expect(replacement.mockState().comments).toHaveLength(0);
|
||||
expect(replacement.snapshot().authority.active).toBe(true);
|
||||
expect(session.snapshot().authority.active).toBe(false);
|
||||
expect((await store.load(session.id))).toBeNull();
|
||||
expect(replacement.snapshot().config).toMatchObject({
|
||||
provider: "opencode",
|
||||
driver: "opencode_server",
|
||||
requestedModel: "openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
});
|
||||
expect(session.snapshot().authority.active).toBe(true);
|
||||
expect((await store.load(session.id))?.status).toBe("suspended");
|
||||
expect(state.transports[0]?.processInfo().exited).toBe(true);
|
||||
const stopped = await service.stop(replacement.id, "bounded test stop");
|
||||
expect(stopped.authority.active).toBe(false);
|
||||
expect(stopped.authority.active).toBe(true);
|
||||
expect(stopped.process?.runnerExited).toBe(true);
|
||||
expect((await store.load(replacement.id))?.status).toBe("closed");
|
||||
expect((await store.load(replacement.id))?.status).toBe("suspended");
|
||||
});
|
||||
|
||||
it("durably resumes a distinct attempt, preserves partial usage, and deduplicates the semantic effect", async () => {
|
||||
|
||||
@@ -36,6 +36,15 @@ import {
|
||||
type CapabilityRunnerdCodexTransportOptions,
|
||||
type CapabilityRunnerdProcessEvidence,
|
||||
} from "./runnerd-codex-transport.js";
|
||||
import {
|
||||
capturePaperclipWorkspace,
|
||||
diffPaperclipWorkspace,
|
||||
type PaperclipWorkspaceDiff,
|
||||
} from "./workspace-diff.js";
|
||||
import {
|
||||
discoverPaperclipWorkspaceFileReferences,
|
||||
type PaperclipWorkspaceFileReference,
|
||||
} from "./workspace-file-reference.js";
|
||||
|
||||
const LIVE_SESSION_SCHEMA = "paperclip.capability.live-session.v1" as const;
|
||||
const LIVE_BASE_INSTRUCTIONS = [
|
||||
@@ -51,7 +60,12 @@ const INVOKE_DISCOVERED_TOOL = "invoke_discovered_capability";
|
||||
|
||||
export type CapabilityLiveSessionStatus =
|
||||
| "starting"
|
||||
| "restoring"
|
||||
| "idle"
|
||||
| "warm_idle"
|
||||
| "waiting_input"
|
||||
| "suspending"
|
||||
| "suspended"
|
||||
| "running"
|
||||
| "stopping"
|
||||
| "closed"
|
||||
@@ -94,11 +108,18 @@ export interface CapabilityLiveSessionConfigSnapshot {
|
||||
workingDirectory: string;
|
||||
/** Exact model requested by the caller; omitted only for legacy interactive sessions. */
|
||||
requestedModel?: string;
|
||||
/** Safe harness identity used by server-side projections and eval metadata. */
|
||||
provider?: "codex" | "opencode";
|
||||
driver?: "codex_app_server" | "opencode_server";
|
||||
providerVersion?: string | null;
|
||||
scenario: CapabilitySemanticScenarioPolicy;
|
||||
capabilities: string[];
|
||||
explicitClaims: string[];
|
||||
seedState: string;
|
||||
turnTimeoutMs: number;
|
||||
lifecyclePolicy?:
|
||||
| { mode: "per_turn"; idleTimeoutMs: null }
|
||||
| { mode: "warm"; idleTimeoutMs: number };
|
||||
toolExposure?: CapabilityToolExposureMode;
|
||||
}
|
||||
|
||||
@@ -143,6 +164,18 @@ export interface CapabilityLiveStateRevision {
|
||||
state: string;
|
||||
}
|
||||
|
||||
export interface CapabilityLiveWorkspaceDiffEntry {
|
||||
turnId: string;
|
||||
at: string;
|
||||
diff: PaperclipWorkspaceDiff;
|
||||
}
|
||||
|
||||
export interface CapabilityLiveWorkspaceFileReferenceEntry {
|
||||
turnId: string;
|
||||
at: string;
|
||||
reference: PaperclipWorkspaceFileReference;
|
||||
}
|
||||
|
||||
export interface RecordCapabilityLiveUsageInput {
|
||||
receiptId: string;
|
||||
providerResponseId?: string | null;
|
||||
@@ -164,6 +197,8 @@ export interface CapabilityLiveSessionSnapshot {
|
||||
providerSessionId: string | null;
|
||||
/** Safe provider identity retained for reproducible eval bundle declarations. */
|
||||
providerModel?: { id: string; provider: string };
|
||||
/** Validated structured provider result, retained separately from assistant prose. */
|
||||
semanticResult?: Record<string, CapabilityJsonValue> | null;
|
||||
status: CapabilityLiveSessionStatus;
|
||||
activeTurnId: string | null;
|
||||
createdAt: string;
|
||||
@@ -188,12 +223,17 @@ export interface CapabilityLiveSessionSnapshot {
|
||||
usageLedger?: CapabilityLiveUsageReceipt[];
|
||||
/** Bounded mock-control-plane snapshots for DevTools time travel and diffs. */
|
||||
stateHistory?: CapabilityLiveStateRevision[];
|
||||
/** Complete, bounded per-turn workspace changes verified by the runner. */
|
||||
workspaceDiffs?: CapabilityLiveWorkspaceDiffEntry[];
|
||||
/** Assistant-authored local links verified and normalized by the runner. */
|
||||
workspaceFileReferences?: CapabilityLiveWorkspaceFileReferenceEntry[];
|
||||
loadedOperationIds?: string[];
|
||||
}
|
||||
|
||||
export interface CreateCapabilityLiveSessionInput {
|
||||
seed?: CapabilityFixtureSeed | CapabilityFixtureState;
|
||||
workingDirectory?: string;
|
||||
provider?: "codex" | "opencode";
|
||||
requestedModel?: string;
|
||||
scenario?: CapabilitySemanticScenarioPolicy;
|
||||
capabilities?: string[];
|
||||
@@ -205,6 +245,7 @@ export interface CreateCapabilityLiveSessionInput {
|
||||
sessionId?: string;
|
||||
attemptId?: string;
|
||||
turnTimeoutMs?: number;
|
||||
lifecyclePolicy?: CapabilityLiveSessionConfigSnapshot["lifecyclePolicy"];
|
||||
toolExposure?: CapabilityToolExposureMode;
|
||||
}
|
||||
|
||||
@@ -401,6 +442,55 @@ export function assertCapabilityLiveSessionSnapshot(
|
||||
if (authority.sessionId !== snapshot.sessionId) {
|
||||
throw new Error("capability_live_checkpoint_binding_mismatch");
|
||||
}
|
||||
const config = record(snapshot.config);
|
||||
const persistedProvider = config.provider;
|
||||
if (
|
||||
persistedProvider !== undefined &&
|
||||
persistedProvider !== "codex" &&
|
||||
persistedProvider !== "opencode"
|
||||
) {
|
||||
throw new Error("capability_live_checkpoint_corrupt: invalid config.provider");
|
||||
}
|
||||
const provider = persistedProvider ?? "codex";
|
||||
const expectedDriver = provider === "opencode" ? "opencode_server" : "codex_app_server";
|
||||
if (config.driver !== undefined && config.driver !== expectedDriver) {
|
||||
throw new Error("capability_live_checkpoint_corrupt: provider/driver mismatch");
|
||||
}
|
||||
if (persistedProvider !== undefined && config.driver === undefined) {
|
||||
throw new Error("capability_live_checkpoint_corrupt: missing config.driver");
|
||||
}
|
||||
if (config.requestedModel !== undefined) {
|
||||
const requestedModel = text(config.requestedModel);
|
||||
if (requestedModel.trim().length === 0) {
|
||||
throw new Error("capability_live_checkpoint_corrupt: invalid config.requestedModel");
|
||||
}
|
||||
if (provider === "opencode" && !requestedModel.includes("/")) {
|
||||
throw new Error("capability_live_checkpoint_corrupt: invalid OpenCode model");
|
||||
}
|
||||
} else if (provider === "opencode") {
|
||||
throw new Error("capability_live_checkpoint_corrupt: missing OpenCode model");
|
||||
}
|
||||
if (
|
||||
config.providerVersion !== undefined &&
|
||||
config.providerVersion !== null &&
|
||||
(typeof config.providerVersion !== "string" || config.providerVersion.trim().length === 0)
|
||||
) {
|
||||
throw new Error("capability_live_checkpoint_corrupt: invalid config.providerVersion");
|
||||
}
|
||||
if (config.lifecyclePolicy !== undefined) {
|
||||
const lifecyclePolicy = record(config.lifecyclePolicy);
|
||||
if (lifecyclePolicy.mode === "per_turn") {
|
||||
if (lifecyclePolicy.idleTimeoutMs !== null) {
|
||||
throw new Error("capability_live_checkpoint_corrupt: per-turn idle timeout must be null");
|
||||
}
|
||||
} else if (
|
||||
lifecyclePolicy.mode !== "warm"
|
||||
|| !Number.isSafeInteger(lifecyclePolicy.idleTimeoutMs)
|
||||
|| Number(lifecyclePolicy.idleTimeoutMs) <= 0
|
||||
) {
|
||||
throw new Error("capability_live_checkpoint_corrupt: invalid lifecycle policy");
|
||||
}
|
||||
}
|
||||
if (typeof snapshot.mockState !== "string") {
|
||||
throw new Error("capability_live_checkpoint_corrupt: invalid mockState");
|
||||
}
|
||||
@@ -613,6 +703,9 @@ export class CapabilityLiveSessionService {
|
||||
authority,
|
||||
config: {
|
||||
workingDirectory: input.workingDirectory ?? process.cwd(),
|
||||
provider: input.provider ?? "codex",
|
||||
driver: input.provider === "opencode" ? "opencode_server" : "codex_app_server",
|
||||
providerVersion: input.provider === "opencode" ? "1.18.17" : null,
|
||||
...(input.requestedModel === undefined
|
||||
? {}
|
||||
: { requestedModel: requireNonEmpty(input.requestedModel, "requested_model") }),
|
||||
@@ -621,6 +714,7 @@ export class CapabilityLiveSessionService {
|
||||
explicitClaims: authority.explicitClaims,
|
||||
seedState,
|
||||
turnTimeoutMs: input.turnTimeoutMs ?? 120_000,
|
||||
lifecyclePolicy: input.lifecyclePolicy ?? { mode: "warm", idleTimeoutMs: 300_000 },
|
||||
toolExposure: input.toolExposure ?? "eager",
|
||||
},
|
||||
store: this.#store,
|
||||
@@ -745,33 +839,35 @@ export class CapabilityLiveSessionService {
|
||||
async reset(sessionId: string): Promise<CapabilityLiveSession> {
|
||||
const session = await this.restore(sessionId);
|
||||
const config = session.snapshot().config;
|
||||
await session.shutdown("reset");
|
||||
this.#sessions.delete(sessionId);
|
||||
await this.#store.delete(sessionId);
|
||||
// Reset starts a distinct governed chat, but the prior chat is an archive,
|
||||
// not disposable state. Suspending preserves its provider checkpoint and
|
||||
// authority binding so selecting it from history can restore it safely.
|
||||
await session.suspend("reset archived prior session");
|
||||
const seedPort = CapabilityMockControlPlaneAdapter.restore(config.seedState);
|
||||
return this.create({
|
||||
seed: seedPort.snapshot(),
|
||||
workingDirectory: config.workingDirectory,
|
||||
provider: config.provider ?? "codex",
|
||||
...(config.requestedModel === undefined ? {} : { requestedModel: config.requestedModel }),
|
||||
scenario: config.scenario,
|
||||
capabilities: config.capabilities,
|
||||
explicitClaims: config.explicitClaims,
|
||||
turnTimeoutMs: config.turnTimeoutMs,
|
||||
lifecyclePolicy: config.lifecyclePolicy ?? { mode: "per_turn", idleTimeoutMs: null },
|
||||
toolExposure: config.toolExposure ?? "eager",
|
||||
});
|
||||
}
|
||||
|
||||
async stop(sessionId: string, reason = "operator stopped session"): Promise<CapabilityLiveSessionSnapshot> {
|
||||
const session = await this.restore(sessionId);
|
||||
await session.shutdown(reason);
|
||||
this.#sessions.delete(sessionId);
|
||||
await session.suspend(reason);
|
||||
return session.snapshot();
|
||||
}
|
||||
|
||||
async shutdown(sessionId: string, reason = "service shutdown"): Promise<void> {
|
||||
const session = this.#sessions.get(sessionId);
|
||||
if (session === undefined) return;
|
||||
await session.shutdown(reason);
|
||||
this.#sessions.delete(sessionId);
|
||||
await session.suspend(reason);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -807,6 +903,8 @@ export class CapabilityLiveSession {
|
||||
readonly #terminalTurns: CapabilityLiveTurnTerminalFact[];
|
||||
readonly #usageLedger: CapabilityLiveUsageReceipt[];
|
||||
readonly #stateHistory: CapabilityLiveStateRevision[];
|
||||
readonly #workspaceDiffs: CapabilityLiveWorkspaceDiffEntry[];
|
||||
readonly #workspaceFileReferences: CapabilityLiveWorkspaceFileReferenceEntry[];
|
||||
#currentAttemptId: string;
|
||||
#transport: CodexAppServerTransport | null = null;
|
||||
#processEvidence: CapabilityRunnerdProcessEvidence | null = null;
|
||||
@@ -826,6 +924,7 @@ export class CapabilityLiveSession {
|
||||
#turnEventCount = 0;
|
||||
readonly #rawUsageByTurn = new Map<string, { providerRequests: number; inputTokens: number; outputTokens: number; cachedInputTokens: number; reasoningTokens: number }>();
|
||||
#latestCumulativeUsage: Record<string, unknown> = {};
|
||||
#idleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
readonly #loadedOperationIds = new Set<string>();
|
||||
|
||||
private constructor(options: OpenSessionOptions & { snapshot?: CapabilityLiveSessionSnapshot }) {
|
||||
@@ -864,6 +963,8 @@ export class CapabilityLiveSession {
|
||||
operationId: "session.open",
|
||||
state: this.#port.serialize(),
|
||||
}]);
|
||||
this.#workspaceDiffs = structuredClone(options.snapshot?.workspaceDiffs ?? []);
|
||||
this.#workspaceFileReferences = structuredClone(options.snapshot?.workspaceFileReferences ?? []);
|
||||
for (const operationId of options.snapshot?.loadedOperationIds ?? []) this.#loadedOperationIds.add(operationId);
|
||||
this.#providerThreadId = options.snapshot?.providerThreadId ?? "";
|
||||
this.#providerSessionId = options.snapshot?.providerSessionId ?? null;
|
||||
@@ -1033,6 +1134,8 @@ export class CapabilityLiveSession {
|
||||
terminalTurns: structuredClone(this.#terminalTurns),
|
||||
usageLedger: structuredClone(this.#usageLedger),
|
||||
stateHistory: structuredClone(this.#stateHistory),
|
||||
workspaceDiffs: structuredClone(this.#workspaceDiffs),
|
||||
workspaceFileReferences: structuredClone(this.#workspaceFileReferences),
|
||||
loadedOperationIds: [...this.#loadedOperationIds].sort(),
|
||||
};
|
||||
}
|
||||
@@ -1093,6 +1196,9 @@ export class CapabilityLiveSession {
|
||||
async sendMessage(message: string): Promise<CapabilityLiveTurnResult> {
|
||||
const value = message.trim();
|
||||
if (value.length === 0) throw new Error("Capability live messages cannot be empty");
|
||||
if (this.#status === "suspended" || this.#transport === null) {
|
||||
await this.#connect(true);
|
||||
}
|
||||
if (this.#transport === null || this.#status === "closed" || this.#status === "failed") {
|
||||
throw new Error("Capability live session is not connected");
|
||||
}
|
||||
@@ -1100,13 +1206,23 @@ export class CapabilityLiveSession {
|
||||
throw new Error("Capability live session already has an active turn");
|
||||
}
|
||||
this.#status = "running";
|
||||
this.#clearIdleTimer();
|
||||
this.#turnEventCount = 0;
|
||||
// Start the baseline before admitting provider work, but do not make turn
|
||||
// interruption wait for a potentially large workspace walk. The provider
|
||||
// turn id is bound first; the terminal path waits for this baseline before
|
||||
// comparing the post-turn workspace.
|
||||
const workspaceBeforePromise = capturePaperclipWorkspace(this.#config.workingDirectory);
|
||||
// Held by id, not by position: a provider can start streaming before
|
||||
// `turn/start` resolves, in which case the assistant draft is already the
|
||||
// last transcript entry and the user message would never learn its turn.
|
||||
const userEntryId = this.#appendTranscript("user", value, null);
|
||||
let response: Record<string, unknown>;
|
||||
const terminal = this.#armTurnWaiter();
|
||||
// Workspace capture can outlive a deliberately tiny test timeout. Attach
|
||||
// a rejection observer immediately; the authoritative await below still
|
||||
// propagates the same error to the caller.
|
||||
void terminal.catch(() => undefined);
|
||||
try {
|
||||
response = await this.#transport.request("turn/start", {
|
||||
threadId: this.#providerThreadId,
|
||||
@@ -1136,13 +1252,77 @@ export class CapabilityLiveSession {
|
||||
const draft =
|
||||
draftId === null ? undefined : this.#transcript.find((entry) => entry.id === draftId);
|
||||
if (draft !== undefined && draft.turnId === null) draft.turnId = turnId;
|
||||
const workspaceBefore = await workspaceBeforePromise;
|
||||
await this.#persist();
|
||||
const result = await terminal;
|
||||
const workspaceFileReferences = await discoverPaperclipWorkspaceFileReferences(
|
||||
this.#config.workingDirectory,
|
||||
result.assistantText,
|
||||
result.turnId,
|
||||
);
|
||||
for (const reference of workspaceFileReferences) {
|
||||
this.#workspaceFileReferences.push({
|
||||
turnId: result.turnId,
|
||||
at: this.#now().toISOString(),
|
||||
reference: {
|
||||
...reference,
|
||||
preview: reference.preview === null ? null : redactCodexDiagnostic(reference.preview),
|
||||
},
|
||||
});
|
||||
}
|
||||
const workspaceAfter = await capturePaperclipWorkspace(this.#config.workingDirectory);
|
||||
const workspaceDiff = diffPaperclipWorkspace(
|
||||
workspaceBefore,
|
||||
workspaceAfter,
|
||||
`${this.id}:${result.turnId}:workspace`,
|
||||
);
|
||||
if (workspaceDiff !== null) {
|
||||
this.#workspaceDiffs.push({
|
||||
turnId: result.turnId,
|
||||
at: this.#now().toISOString(),
|
||||
diff: {
|
||||
...workspaceDiff,
|
||||
files: workspaceDiff.files.map((file) => ({
|
||||
...file,
|
||||
diff: file.diff === null ? null : redactCodexDiagnostic(file.diff),
|
||||
})),
|
||||
},
|
||||
});
|
||||
}
|
||||
await this.#captureTurnUsage(result.turnId);
|
||||
await this.#persist();
|
||||
await this.#afterTurnSettled();
|
||||
return { ...result, snapshot: this.snapshot() };
|
||||
}
|
||||
|
||||
async #afterTurnSettled(): Promise<void> {
|
||||
const policy = this.#config.lifecyclePolicy ?? { mode: "per_turn" as const, idleTimeoutMs: null };
|
||||
if (policy.mode === "per_turn") {
|
||||
await this.suspend("per-turn lifecycle completed");
|
||||
return;
|
||||
}
|
||||
this.#status = this.pendingInteractions().length > 0 ? "waiting_input" : "warm_idle";
|
||||
this.#armIdleTimer(policy.idleTimeoutMs);
|
||||
await this.#persist();
|
||||
}
|
||||
|
||||
#clearIdleTimer(): void {
|
||||
if (this.#idleTimer !== null) clearTimeout(this.#idleTimer);
|
||||
this.#idleTimer = null;
|
||||
}
|
||||
|
||||
#armIdleTimer(timeoutMs: number): void {
|
||||
this.#clearIdleTimer();
|
||||
this.#idleTimer = setTimeout(() => {
|
||||
this.#idleTimer = null;
|
||||
void this.suspend("warm session idle timeout").catch((error) => {
|
||||
this.#appendEvidence("diagnostic", this.#activeTurnId, {
|
||||
message: redactCodexDiagnostic(String(error)),
|
||||
});
|
||||
});
|
||||
}, timeoutMs);
|
||||
}
|
||||
|
||||
async #captureTurnUsage(turnId: string): Promise<void> {
|
||||
if (this.#transport === null) throw new Error("capability_live_usage_transport_missing");
|
||||
// Codex can emit rawResponse/completed immediately after turn/completed.
|
||||
@@ -1294,6 +1474,7 @@ export class CapabilityLiveSession {
|
||||
const result = await terminal;
|
||||
if (result.turnId !== turnId) throw new Error("capability_live_provider_session_drift: terminal turn mismatch");
|
||||
await this.#persist();
|
||||
await this.#afterTurnSettled();
|
||||
return { ...result, snapshot: this.snapshot() };
|
||||
}
|
||||
|
||||
@@ -1354,7 +1535,25 @@ export class CapabilityLiveSession {
|
||||
await this.#connect(true);
|
||||
}
|
||||
|
||||
async suspend(reason = "session suspended"): Promise<void> {
|
||||
if (this.#status === "closed" || this.#status === "failed" || this.#status === "suspended") return;
|
||||
this.#clearIdleTimer();
|
||||
this.#status = "suspending";
|
||||
await this.#persist();
|
||||
await this.#disconnect(reason);
|
||||
this.#status = "suspended";
|
||||
this.#appendEvidence("cleanup", null, {
|
||||
reason,
|
||||
authorityCleared: false,
|
||||
mockStopped: false,
|
||||
processExited: this.#processEvidence?.runnerExited ?? true,
|
||||
resumable: true,
|
||||
});
|
||||
await this.#persist();
|
||||
}
|
||||
|
||||
async shutdown(reason: string): Promise<void> {
|
||||
this.#clearIdleTimer();
|
||||
if (this.#activeTurnId !== null) {
|
||||
try {
|
||||
await this.interrupt(reason);
|
||||
@@ -1378,10 +1577,12 @@ export class CapabilityLiveSession {
|
||||
}
|
||||
|
||||
async #connect(resume: boolean): Promise<void> {
|
||||
this.#status = "starting";
|
||||
this.#status = resume ? "restoring" : "starting";
|
||||
const identityDigest = createHash("sha256").update(this.id).digest("hex").slice(0, 20);
|
||||
const transportBundle = this.#transportFactory({
|
||||
...this.#transportOptions,
|
||||
provider: this.#config.provider ?? this.#transportOptions.provider ?? "codex",
|
||||
lifecyclePolicy: this.#config.lifecyclePolicy ?? { mode: "per_turn", idleTimeoutMs: null },
|
||||
stateDirectory: resolve(this.#config.workingDirectory, ".paperclip-runner-prp", identityDigest),
|
||||
prpIdentity: {
|
||||
runnerInstanceId: `runner_lab_${identityDigest}`,
|
||||
@@ -1504,7 +1705,7 @@ export class CapabilityLiveSession {
|
||||
sessionId: this.id,
|
||||
providerThreadId: this.#providerThreadId,
|
||||
providerSessionId: this.#providerSessionId,
|
||||
mode: "live_codex",
|
||||
mode: `live_${this.#config.provider ?? "codex"}`,
|
||||
runner: "paperclip-runnerd",
|
||||
requestedModel: this.#config.requestedModel ?? null,
|
||||
controlPlane: "mock",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { resolve } from "node:path";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
@@ -84,3 +85,144 @@ it("runs the lab provider boundary through authenticated durable PRP", async ()
|
||||
}
|
||||
expect(bundle.evidence()).toMatchObject({ runnerExited: true, runnerExitCode: 0 });
|
||||
}, 30_000);
|
||||
|
||||
it("attaches a second governed run to one warm runner and provider process", async () => {
|
||||
const bundle = createCapabilityRunnerdCodexTransport({
|
||||
runnerBinary: defaultCapabilityRunnerdBinary(),
|
||||
codexCommand: fakeCodex,
|
||||
codexArgs: [],
|
||||
lifecyclePolicy: { mode: "warm", idleTimeoutMs: 60_000 },
|
||||
});
|
||||
bundle.transport.setServerRequestHandler(async () => ({ success: true, contentItems: [] }));
|
||||
try {
|
||||
await bundle.transport.request("initialize", {});
|
||||
await bundle.transport.request("thread/start", {
|
||||
cwd: tmpdir(),
|
||||
dynamicTools: [{
|
||||
name: "get_task_context",
|
||||
description: "Read the active task.",
|
||||
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
||||
}],
|
||||
});
|
||||
const runnerPid = bundle.evidence().runnerPid;
|
||||
const providerPid = bundle.evidence().codexPid;
|
||||
const notifications = bundle.transport.notifications()[Symbol.asyncIterator]();
|
||||
const waitForCompletion = async (label: string) => {
|
||||
const deadline = Date.now() + 5_000;
|
||||
while (Date.now() < deadline) {
|
||||
const next = await Promise.race([
|
||||
notifications.next(),
|
||||
new Promise<never>((_, reject) => setTimeout(() => reject(new Error(`${label} notification timeout`)), 1_000)),
|
||||
]);
|
||||
if (next.value?.method === "turn/completed") return;
|
||||
}
|
||||
throw new Error(`${label} completion timeout`);
|
||||
};
|
||||
await bundle.transport.request("turn/start", { input: [{ type: "text", text: "first run" }] });
|
||||
await waitForCompletion("first run");
|
||||
|
||||
await bundle.transport.attachRun?.({
|
||||
runId: "run-warm-second",
|
||||
turnId: "turn-binding-second",
|
||||
itemId: "item-binding-second",
|
||||
});
|
||||
await bundle.transport.request("turn/start", { input: [{ type: "text", text: "second run" }] });
|
||||
await waitForCompletion("second run");
|
||||
|
||||
expect(bundle.evidence()).toMatchObject({ runnerPid, codexPid: providerPid, runnerExited: false });
|
||||
} finally {
|
||||
await bundle.transport.close();
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it("cold-restores a suspended provider session under a new run binding", async () => {
|
||||
const stateDirectory = await mkdtemp(join(tmpdir(), "runnerd-cold-attach-"));
|
||||
const baseIdentity = {
|
||||
runnerInstanceId: "runner-cold-attach",
|
||||
environmentLeaseId: "lease-cold-attach",
|
||||
runId: "run-cold-first",
|
||||
normalizedSessionId: "session-cold-attach",
|
||||
turnId: "turn-cold-first",
|
||||
itemId: "item-cold-first",
|
||||
};
|
||||
const options = {
|
||||
runnerBinary: defaultCapabilityRunnerdBinary(),
|
||||
codexCommand: fakeCodex,
|
||||
codexArgs: [],
|
||||
stateDirectory,
|
||||
lifecyclePolicy: { mode: "warm" as const, idleTimeoutMs: 60_000 },
|
||||
};
|
||||
const dynamicTools = [{
|
||||
name: "get_task_context",
|
||||
description: "Read the active task.",
|
||||
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
||||
}];
|
||||
const first = createCapabilityRunnerdCodexTransport({ ...options, prpIdentity: baseIdentity });
|
||||
first.transport.setServerRequestHandler(async () => ({ success: true, contentItems: [] }));
|
||||
try {
|
||||
await first.transport.request("thread/start", { cwd: tmpdir(), dynamicTools });
|
||||
await first.transport.request("turn/start", { input: [{ type: "text", text: "first process" }] });
|
||||
for await (const event of first.transport.notifications()) {
|
||||
if (event.method === "turn/completed") break;
|
||||
}
|
||||
} finally {
|
||||
await first.transport.close();
|
||||
}
|
||||
|
||||
const restored = createCapabilityRunnerdCodexTransport({
|
||||
...options,
|
||||
prpIdentity: {
|
||||
...baseIdentity,
|
||||
runId: "run-cold-restored",
|
||||
turnId: "turn-cold-restored",
|
||||
itemId: "item-cold-restored",
|
||||
},
|
||||
});
|
||||
restored.transport.setServerRequestHandler(async () => ({ success: true, contentItems: [] }));
|
||||
try {
|
||||
const read = await restored.transport.request("thread/read", {});
|
||||
expect(read.thread).toMatchObject({ id: "fake-thread" });
|
||||
await restored.transport.request("turn/start", { input: [{ type: "text", text: "restored process" }] });
|
||||
for await (const event of restored.transport.notifications()) {
|
||||
if (event.method === "turn/completed") break;
|
||||
}
|
||||
expect(restored.evidence()).toMatchObject({ runnerExited: false, codexPid: expect.any(Number) });
|
||||
} finally {
|
||||
await restored.transport.close();
|
||||
await rm(stateDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it("rejects the notification stream promptly when runnerd exits after accepting a turn", async () => {
|
||||
const bundle = createCapabilityRunnerdCodexTransport({
|
||||
runnerBinary: defaultCapabilityRunnerdBinary(),
|
||||
codexCommand: fakeCodex,
|
||||
codexArgs: ["--linger-after-turn-start"],
|
||||
});
|
||||
bundle.transport.setServerRequestHandler(async () => ({ success: true, contentItems: [] }));
|
||||
try {
|
||||
await bundle.transport.request("initialize", {});
|
||||
await bundle.transport.request("thread/start", {
|
||||
cwd: tmpdir(),
|
||||
dynamicTools: [{
|
||||
name: "get_task_context",
|
||||
description: "Read the active task.",
|
||||
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
||||
}],
|
||||
});
|
||||
await bundle.transport.request("turn/start", {
|
||||
input: [{ type: "text", text: "Wait for another instruction." }],
|
||||
});
|
||||
const notifications = bundle.transport.notifications()[Symbol.asyncIterator]();
|
||||
expect((await notifications.next()).value?.method).toBe("turn/started");
|
||||
const runnerPid = bundle.evidence().runnerPid;
|
||||
expect(runnerPid).not.toBeNull();
|
||||
process.kill(runnerPid!, "SIGKILL");
|
||||
await expect(Promise.race([
|
||||
notifications.next(),
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error("notification stream hung")), 2_000)),
|
||||
])).rejects.toThrow("native_runner_process_exited");
|
||||
} finally {
|
||||
await bundle.transport.close();
|
||||
}
|
||||
}, 30_000);
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
} from "../drivers/codex/app-server-transport.js";
|
||||
import { createSanitizedCodexEnvironment } from "../drivers/codex/app-server-transport.js";
|
||||
import { createIsolatedCodexAppServerArgs } from "../drivers/codex/codex-app-server-driver.js";
|
||||
import type { DurableRecoveryIdentity } from "../contracts/durable-recovery.js";
|
||||
import {
|
||||
DurablePrpControlPlane,
|
||||
durableRecoveryInternals,
|
||||
@@ -26,6 +27,22 @@ const executableSuffix = process.platform === "win32" ? ".exe" : "";
|
||||
const MAX_NOTIFICATION_COUNT = 2_048;
|
||||
const MAX_NOTIFICATION_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
function recoveredControlPlaneIdentity(
|
||||
directory: string,
|
||||
desired: DurableRecoveryIdentity,
|
||||
): DurableRecoveryIdentity {
|
||||
const stored = record(JSON.parse(readFileSync(resolve(directory, "mock-core-state.json"), "utf8")));
|
||||
const identity = record(stored.identity) as unknown as DurableRecoveryIdentity;
|
||||
if (
|
||||
identity.runnerInstanceId !== desired.runnerInstanceId
|
||||
|| identity.environmentLeaseId !== desired.environmentLeaseId
|
||||
|| identity.normalizedSessionId !== desired.normalizedSessionId
|
||||
) {
|
||||
throw new Error("PRP recovery identity does not match the durable session binding");
|
||||
}
|
||||
return structuredClone(identity);
|
||||
}
|
||||
|
||||
export interface CapabilityRunnerdProcessEvidence {
|
||||
runnerPid: number | null;
|
||||
runnerProcessGroupId: number | null;
|
||||
@@ -50,6 +67,11 @@ export interface CapabilityRunnerdCodexTransportOptions {
|
||||
onDiagnostic?: (message: string) => void;
|
||||
onEvidence?: (evidence: Readonly<CapabilityRunnerdProcessEvidence>) => void;
|
||||
stateDirectory?: string;
|
||||
lifecyclePolicy?:
|
||||
| { mode: "per_turn"; idleTimeoutMs: null }
|
||||
| { mode: "warm"; idleTimeoutMs: number };
|
||||
/** Current run's authority catalog, used when a suspended session is rebound. */
|
||||
resumeDynamicTools?: readonly Readonly<Record<string, unknown>>[];
|
||||
prpIdentity?: {
|
||||
runnerInstanceId: string;
|
||||
environmentLeaseId: string;
|
||||
@@ -93,16 +115,20 @@ export function unwrapRunnerdProviderNotification(input: unknown): Record<string
|
||||
|
||||
class NotificationQueue implements AsyncIterable<CodexRpcNotification> {
|
||||
#values: Array<{ value: CodexRpcNotification; bytes: number }> = [];
|
||||
#waiters: Array<(value: IteratorResult<CodexRpcNotification>) => void> = [];
|
||||
#waiters: Array<{
|
||||
resolve: (value: IteratorResult<CodexRpcNotification>) => void;
|
||||
reject: (error: Error) => void;
|
||||
}> = [];
|
||||
#bytes = 0;
|
||||
#closed = false;
|
||||
#error: Error | null = null;
|
||||
|
||||
push(value: CodexRpcNotification): void {
|
||||
if (this.#closed) return;
|
||||
const bytes = Buffer.byteLength(JSON.stringify(value));
|
||||
const waiter = this.#waiters.shift();
|
||||
if (waiter !== undefined) {
|
||||
waiter({ value, done: false });
|
||||
waiter.resolve({ value, done: false });
|
||||
return;
|
||||
}
|
||||
if (this.#values.length >= MAX_NOTIFICATION_COUNT || this.#bytes + bytes > MAX_NOTIFICATION_BYTES) {
|
||||
@@ -112,12 +138,16 @@ class NotificationQueue implements AsyncIterable<CodexRpcNotification> {
|
||||
this.#bytes += bytes;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
close(error?: Error): void {
|
||||
if (this.#closed) return;
|
||||
this.#closed = true;
|
||||
this.#error = error ?? null;
|
||||
this.#values = [];
|
||||
this.#bytes = 0;
|
||||
for (const waiter of this.#waiters.splice(0)) waiter({ value: undefined, done: true });
|
||||
for (const waiter of this.#waiters.splice(0)) {
|
||||
if (this.#error !== null) waiter.reject(this.#error);
|
||||
else waiter.resolve({ value: undefined, done: true });
|
||||
}
|
||||
}
|
||||
|
||||
[Symbol.asyncIterator](): AsyncIterator<CodexRpcNotification> {
|
||||
@@ -128,8 +158,9 @@ class NotificationQueue implements AsyncIterable<CodexRpcNotification> {
|
||||
this.#bytes -= queued.bytes;
|
||||
return { value: queued.value, done: false };
|
||||
}
|
||||
if (this.#error !== null) throw this.#error;
|
||||
if (this.#closed) return { value: undefined, done: true };
|
||||
return new Promise((resolveValue) => this.#waiters.push(resolveValue));
|
||||
return new Promise((resolveValue, reject) => this.#waiters.push({ resolve: resolveValue, reject }));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -145,6 +176,22 @@ function commandDigest(value: unknown): string {
|
||||
return `sha256:${createHash("sha256").update(durableRecoveryInternals.canonicalJson(value)).digest("hex")}`;
|
||||
}
|
||||
|
||||
function authorizedToolSet(tools: readonly Readonly<Record<string, unknown>>[]): Record<string, unknown> {
|
||||
const operations = tools.map((tool) => ({
|
||||
operationId: String(tool.name ?? ""),
|
||||
version: 1,
|
||||
description: String(tool.description ?? ""),
|
||||
inputSchema: record(tool.inputSchema),
|
||||
responseSchema: {},
|
||||
}));
|
||||
return {
|
||||
schema: "paperclip.runner.authorized-tools.v1",
|
||||
schemaVersion: 1,
|
||||
catalogDigest: commandDigest(operations),
|
||||
operations,
|
||||
};
|
||||
}
|
||||
|
||||
function unwrapToolResponse(response: Record<string, unknown>): unknown {
|
||||
const items = Array.isArray(response.contentItems) ? response.contentItems : [];
|
||||
const value = record(items[0]).text;
|
||||
@@ -173,12 +220,17 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
||||
#threadId = "";
|
||||
#sessionId: string | null = null;
|
||||
#turnId = "";
|
||||
#authorizedTools: Record<string, unknown> | null = null;
|
||||
#closed = false;
|
||||
#failure: Error | null = null;
|
||||
#controlPlaneRelease: (() => Promise<void> | void) | null = null;
|
||||
|
||||
constructor(readonly options: CapabilityRunnerdCodexTransportOptions) {
|
||||
this.#ownsRoot = options.stateDirectory === undefined;
|
||||
this.#root = options.stateDirectory ?? mkdtempSync(resolve(tmpdir(), "paperclip-runner-lab-prp-"));
|
||||
if (options.resumeDynamicTools !== undefined) {
|
||||
this.#authorizedTools = authorizedToolSet(options.resumeDynamicTools);
|
||||
}
|
||||
mkdirSync(this.#root, { recursive: true, mode: 0o700 });
|
||||
this.#evidence = {
|
||||
runnerPid: null,
|
||||
@@ -198,6 +250,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
||||
|
||||
async request(method: string, params: Record<string, unknown>): Promise<Record<string, unknown>> {
|
||||
if (this.#closed) throw new Error("PRP Codex transport is closed");
|
||||
this.#throwIfFailed();
|
||||
if (method === "initialize") return { user: {} };
|
||||
if (method === "thread/start") return this.#start(params);
|
||||
if (method === "turn/start") return this.#startTurn(params);
|
||||
@@ -234,6 +287,34 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
||||
};
|
||||
}
|
||||
|
||||
async attachRun(input: { runId: string; turnId: string; itemId: string }): Promise<void> {
|
||||
if (this.#closed || this.#core === null) throw new Error("PRP provider session is not attachable");
|
||||
await this.#command("run.attach", {
|
||||
runId: input.runId,
|
||||
turnId: input.turnId,
|
||||
itemId: input.itemId,
|
||||
...(this.#authorizedTools === null ? {} : { authorizedTools: this.#authorizedTools }),
|
||||
});
|
||||
const core = this.#core;
|
||||
const attachDeadline = Date.now() + 10_000;
|
||||
let attachmentDrained = false;
|
||||
while (core !== null && Date.now() < attachDeadline) {
|
||||
const durable = record(JSON.parse(readFileSync(resolve(this.#root, "runner", "runner-state.json"), "utf8")));
|
||||
const attached = core.store.state.committedEvents.find((event) =>
|
||||
event.eventType === "run.attached"
|
||||
&& record(record(event.envelope.payload).payload).runId === input.runId
|
||||
);
|
||||
if (attached && Number(durable.ackedSourceSeq ?? 0) >= attached.sourceSeq) {
|
||||
attachmentDrained = true;
|
||||
break;
|
||||
}
|
||||
this.#throwIfFailed();
|
||||
await new Promise((resolveWait) => setTimeout(resolveWait, 10));
|
||||
}
|
||||
if (!attachmentDrained) throw new Error("runnerd did not durably acknowledge the run attachment");
|
||||
this.#turnId = input.turnId;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.#closed) return;
|
||||
this.#closed = true;
|
||||
@@ -272,13 +353,6 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
||||
itemId: `item_lab_${token}`,
|
||||
};
|
||||
const dynamicTools = Array.isArray(params.dynamicTools) ? params.dynamicTools.map(record) : [];
|
||||
const operations = dynamicTools.map((tool) => ({
|
||||
operationId: String(tool.name ?? ""),
|
||||
version: 1,
|
||||
description: String(tool.description ?? ""),
|
||||
inputSchema: record(tool.inputSchema),
|
||||
responseSchema: {},
|
||||
}));
|
||||
const core = new DurablePrpControlPlane({
|
||||
stateDirectory: resolve(this.#root, "control-plane"),
|
||||
identity,
|
||||
@@ -300,13 +374,9 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
||||
const provider = this.options.provider ?? "codex";
|
||||
const opencodeProxyPath = this.options.opencodeProxyPath
|
||||
?? fileURLToPath(new URL("../cli/opencode-app-server-proxy.js", import.meta.url));
|
||||
this.#authorizedTools = authorizedToolSet(dynamicTools);
|
||||
core.queueCommand("run.prepare", {
|
||||
authorizedTools: {
|
||||
schema: "paperclip.runner.authorized-tools.v1",
|
||||
schemaVersion: 1,
|
||||
catalogDigest: commandDigest(operations),
|
||||
operations,
|
||||
},
|
||||
authorizedTools: this.#authorizedTools,
|
||||
provider: {
|
||||
kind: provider,
|
||||
command: provider === "opencode" ? process.execPath : this.options.codexCommand ?? "codex",
|
||||
@@ -330,9 +400,11 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
||||
maxOutboxBytes: 256 * 1024,
|
||||
p0ReserveBytes: 64 * 1024,
|
||||
maxRuntimeMs: 60 * 60 * 1_000,
|
||||
lifecyclePolicy: this.options.lifecyclePolicy,
|
||||
runnerBinaryPath: this.options.runnerBinary ?? defaultCapabilityRunnerdBinary(),
|
||||
environment: provider === "opencode"
|
||||
? {
|
||||
...process.env,
|
||||
...this.options.environment,
|
||||
PAPERCLIP_OPENCODE_COMMAND: this.options.opencodeCommand ?? "opencode",
|
||||
PAPERCLIP_OPENCODE_RUNTIME_DIR: this.options.opencodeRuntimeDirectory ?? resolve(this.#root, "opencode"),
|
||||
@@ -343,24 +415,36 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
||||
: this.options.environment,
|
||||
});
|
||||
this.#handle = handle;
|
||||
this.#watchRunner(handle);
|
||||
this.#evidence.runnerPid = handle.child.pid ?? null;
|
||||
this.#evidence.runnerProcessGroupId = null;
|
||||
this.#publish();
|
||||
this.#pump = setInterval(() => this.#pumpEvents(), 5);
|
||||
this.#pump = setInterval(() => this.#pumpEventsSafely(), 5);
|
||||
await this.#waitCommand("run.prepare");
|
||||
await this.#waitCommand("session.open");
|
||||
await this.#waitForProviderIdentity();
|
||||
this.#diagnostic("runnerd authenticated to the durable PRP control plane");
|
||||
return { thread: { id: this.#threadId, sessionId: this.#sessionId, model: params.model, modelProvider: "openai" } };
|
||||
return {
|
||||
thread: {
|
||||
id: this.#threadId,
|
||||
sessionId: this.#sessionId,
|
||||
model: params.model,
|
||||
modelProvider: provider === "opencode" && typeof params.model === "string"
|
||||
? params.model.split("/", 1)[0]
|
||||
: "openai",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async #resume(): Promise<void> {
|
||||
const identity = this.options.prpIdentity;
|
||||
if (identity === undefined || !existsSync(resolve(this.#root, "runner", "runner-state.json"))) {
|
||||
const desiredIdentity = this.options.prpIdentity;
|
||||
if (desiredIdentity === undefined || !existsSync(resolve(this.#root, "runner", "runner-state.json"))) {
|
||||
throw new Error("PRP provider resume state is unavailable");
|
||||
}
|
||||
const controlPlaneDirectory = resolve(this.#root, "control-plane");
|
||||
const identity = recoveredControlPlaneIdentity(controlPlaneDirectory, desiredIdentity);
|
||||
const core = new DurablePrpControlPlane({
|
||||
stateDirectory: resolve(this.#root, "control-plane"),
|
||||
stateDirectory: controlPlaneDirectory,
|
||||
identity,
|
||||
onSemanticToolInput: async (call) => unwrapToolResponse(await this.#handler({
|
||||
id: call.callId,
|
||||
@@ -390,14 +474,39 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
||||
maxOutboxBytes: 256 * 1024,
|
||||
p0ReserveBytes: 64 * 1024,
|
||||
maxRuntimeMs: 60 * 60 * 1_000,
|
||||
lifecyclePolicy: this.options.lifecyclePolicy,
|
||||
runnerBinaryPath: this.options.runnerBinary ?? defaultCapabilityRunnerdBinary(),
|
||||
});
|
||||
this.#handle = handle;
|
||||
this.#watchRunner(handle);
|
||||
this.#evidence.runnerPid = handle.child.pid ?? null;
|
||||
this.#evidence.runnerProcessGroupId = null;
|
||||
this.#publish();
|
||||
this.#pump = setInterval(() => this.#pumpEvents(), 5);
|
||||
this.#pump = setInterval(() => this.#pumpEventsSafely(), 5);
|
||||
await this.#waitForProviderIdentity();
|
||||
if (
|
||||
identity.runId !== desiredIdentity.runId
|
||||
|| identity.turnId !== desiredIdentity.turnId
|
||||
|| identity.itemId !== desiredIdentity.itemId
|
||||
) {
|
||||
const priorEvidenceSeq = core.store.state.ackedSourceSeq;
|
||||
const drainDeadline = Date.now() + 10_000;
|
||||
while (Date.now() < drainDeadline) {
|
||||
const durable = record(JSON.parse(readFileSync(resolve(this.#root, "runner", "runner-state.json"), "utf8")));
|
||||
if (Number(durable.ackedSourceSeq ?? 0) >= priorEvidenceSeq) break;
|
||||
this.#throwIfFailed();
|
||||
await new Promise((resolveWait) => setTimeout(resolveWait, 10));
|
||||
}
|
||||
const drained = record(JSON.parse(readFileSync(resolve(this.#root, "runner", "runner-state.json"), "utf8")));
|
||||
if (Number(drained.ackedSourceSeq ?? 0) < priorEvidenceSeq) {
|
||||
throw new Error("runnerd did not drain prior-run evidence before attachment");
|
||||
}
|
||||
await this.attachRun({
|
||||
runId: desiredIdentity.runId,
|
||||
turnId: desiredIdentity.turnId,
|
||||
itemId: desiredIdentity.itemId,
|
||||
});
|
||||
}
|
||||
this.#diagnostic("runnerd restored its durable PRP session and provider thread");
|
||||
}
|
||||
|
||||
@@ -412,6 +521,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
||||
// do not expose the temporary transport identity to the strict driver.
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (this.#turnId === pendingTurnId && Date.now() < deadline) {
|
||||
this.#throwIfFailed();
|
||||
this.#pumpEvents();
|
||||
if (this.#turnId !== pendingTurnId) break;
|
||||
if (this.#handle?.child.exitCode !== null) throw new Error("runnerd exited before provider turn startup");
|
||||
@@ -437,6 +547,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
||||
await this.#waitCommand(type, commandId);
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (Date.now() < deadline) {
|
||||
this.#throwIfFailed();
|
||||
const event = core.store.state.committedEvents.find((candidate) => {
|
||||
if (candidate.eventType !== "provider.rpc_result") return false;
|
||||
return record(record(candidate.envelope.payload).payload).commandId === commandId;
|
||||
@@ -451,6 +562,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
||||
async #waitForProviderIdentity(): Promise<void> {
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (Date.now() < deadline) {
|
||||
this.#throwIfFailed();
|
||||
this.#pumpEvents();
|
||||
if (this.#threadId.length > 0 && this.#evidence.codexPid !== null) return;
|
||||
if (this.#handle?.child.exitCode !== null) throw new Error("runnerd exited before provider startup");
|
||||
@@ -462,6 +574,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
||||
async #waitCommand(type: string, commandId?: string): Promise<void> {
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (Date.now() < deadline) {
|
||||
this.#throwIfFailed();
|
||||
const command = this.#core?.store.state.commands.find((candidate) =>
|
||||
commandId === undefined ? candidate.type === type : candidate.commandId === commandId
|
||||
);
|
||||
@@ -507,6 +620,55 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
||||
}
|
||||
}
|
||||
|
||||
#pumpEventsSafely(): void {
|
||||
try {
|
||||
this.#pumpEvents();
|
||||
} catch (error) {
|
||||
this.#failTransport(
|
||||
new Error(`provider_transport_failed: ${error instanceof Error ? error.message : String(error)}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#watchRunner(handle: RunnerProcessHandle): void {
|
||||
void handle.completion.then(
|
||||
(result) => {
|
||||
this.#evidence.runnerExited = true;
|
||||
this.#evidence.runnerExitCode = result.code;
|
||||
this.#evidence.runnerSignal = result.signal as NodeJS.Signals | null;
|
||||
const detail = result.stderr.trim() || result.stdout.trim();
|
||||
if (detail) this.#diagnostic(detail.slice(-4_096));
|
||||
this.#publish();
|
||||
if (this.#closed || this.#handle !== handle) return;
|
||||
const code = /provider_frame_too_large|stdout frame exceeded/i.test(detail)
|
||||
? "provider_frame_too_large"
|
||||
: "native_runner_process_exited";
|
||||
this.#failTransport(new Error(
|
||||
`${code}: runnerd exited unexpectedly${result.code === null ? "" : ` with code ${result.code}`}${detail ? `: ${detail.slice(-1_000)}` : ""}`,
|
||||
));
|
||||
},
|
||||
(error) => {
|
||||
if (this.#closed || this.#handle !== handle) return;
|
||||
this.#failTransport(new Error(
|
||||
`native_runner_process_exited: runnerd process failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#failTransport(error: Error): void {
|
||||
if (this.#failure !== null || this.#closed) return;
|
||||
this.#failure = error;
|
||||
if (this.#pump !== null) clearInterval(this.#pump);
|
||||
this.#pump = null;
|
||||
this.#diagnostic(error.message);
|
||||
this.#queue.close(error);
|
||||
}
|
||||
|
||||
#throwIfFailed(): void {
|
||||
if (this.#failure !== null) throw this.#failure;
|
||||
}
|
||||
|
||||
#diagnostic(message: string): void {
|
||||
this.#evidence.diagnostics.push(message);
|
||||
if (this.#evidence.diagnostics.length > 64) this.#evidence.diagnostics.shift();
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { mkdtemp, mkdir, rename, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { capturePaperclipWorkspace, diffPaperclipWorkspace } from "./workspace-diff.js";
|
||||
|
||||
describe("runner-verified workspace diffs", () => {
|
||||
it("captures create, modify, delete, and rename without runner-owned noise", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "paperclip-workspace-diff-"));
|
||||
await mkdir(join(root, ".paperclip-runner-prp"));
|
||||
await writeFile(join(root, ".paperclip-runner-prp", "state.json"), "before");
|
||||
await writeFile(join(root, "modify.txt"), "old\n");
|
||||
await writeFile(join(root, "delete.txt"), "gone\n");
|
||||
await writeFile(join(root, "rename.txt"), "same\n");
|
||||
const before = await capturePaperclipWorkspace(root);
|
||||
|
||||
await writeFile(join(root, ".paperclip-runner-prp", "state.json"), "after");
|
||||
await writeFile(join(root, "modify.txt"), "new\n");
|
||||
await writeFile(join(root, "create.txt"), "created\n");
|
||||
await writeFile(join(root, "delete.txt"), "");
|
||||
await rename(join(root, "rename.txt"), join(root, "renamed.txt"));
|
||||
const after = await capturePaperclipWorkspace(root);
|
||||
const diff = diffPaperclipWorkspace(before, after, "turn-1:workspace");
|
||||
|
||||
expect(diff?.schema).toBe("paperclip.workspace.diff.v1");
|
||||
expect(diff?.source).toBe("runner_verified");
|
||||
expect(diff?.files.map((file) => [file.path, file.operation])).toEqual([
|
||||
["create.txt", "create"],
|
||||
["delete.txt", "modify"],
|
||||
["modify.txt", "modify"],
|
||||
["renamed.txt", "rename"],
|
||||
]);
|
||||
expect(diff?.files.find((file) => file.path === "renamed.txt")?.previousPath).toBe("rename.txt");
|
||||
expect(JSON.stringify(diff)).not.toContain(".paperclip-runner-prp");
|
||||
});
|
||||
|
||||
it("returns no record when the workspace did not change", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "paperclip-workspace-stable-"));
|
||||
await writeFile(join(root, "stable.txt"), "same\n");
|
||||
const before = await capturePaperclipWorkspace(root);
|
||||
const after = await capturePaperclipWorkspace(root);
|
||||
expect(diffPaperclipWorkspace(before, after, "turn-1:workspace")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { join, relative, sep } from "node:path";
|
||||
|
||||
export const PAPERCLIP_WORKSPACE_DIFF_SCHEMA = "paperclip.workspace.diff.v1" as const;
|
||||
|
||||
export type PaperclipWorkspaceChangeSource = "harness_reported" | "runner_verified";
|
||||
export type PaperclipWorkspaceFileOperation =
|
||||
| "create"
|
||||
| "modify"
|
||||
| "delete"
|
||||
| "rename"
|
||||
| "mode_change";
|
||||
|
||||
export interface PaperclipWorkspaceFileChange {
|
||||
path: string;
|
||||
operation: PaperclipWorkspaceFileOperation;
|
||||
previousPath: string | null;
|
||||
additions: number | null;
|
||||
deletions: number | null;
|
||||
binary: boolean;
|
||||
diff: string | null;
|
||||
}
|
||||
|
||||
export interface PaperclipWorkspaceDiff {
|
||||
schema: typeof PAPERCLIP_WORKSPACE_DIFF_SCHEMA;
|
||||
changeSetId: string;
|
||||
revision: number;
|
||||
source: PaperclipWorkspaceChangeSource;
|
||||
complete: boolean;
|
||||
files: PaperclipWorkspaceFileChange[];
|
||||
totals: { files: number; additions: number | null; deletions: number | null };
|
||||
patchArtifactRef: string | null;
|
||||
}
|
||||
|
||||
interface WorkspaceFileSnapshot {
|
||||
hash: string;
|
||||
content: Buffer | null;
|
||||
binary: boolean;
|
||||
}
|
||||
|
||||
export type PaperclipWorkspaceSnapshot = Map<string, WorkspaceFileSnapshot>;
|
||||
|
||||
const IGNORED_DIRECTORY_NAMES = new Set([
|
||||
".git",
|
||||
".paperclip-runner-prp",
|
||||
".paperclip-runner",
|
||||
".next",
|
||||
".turbo",
|
||||
"build",
|
||||
"coverage",
|
||||
"dist",
|
||||
"node_modules",
|
||||
"target",
|
||||
]);
|
||||
const MAX_FILES = 2_000;
|
||||
const MAX_DIRECTORIES = 500;
|
||||
const MAX_FILE_BYTES = 2 * 1024 * 1024;
|
||||
const MAX_TOTAL_BYTES = 32 * 1024 * 1024;
|
||||
const MAX_DIFF_CHARS_PER_FILE = 128 * 1024;
|
||||
|
||||
function normalizedRelativePath(root: string, absolutePath: string): string {
|
||||
return relative(root, absolutePath).split(sep).join("/");
|
||||
}
|
||||
|
||||
function appearsBinary(content: Buffer): boolean {
|
||||
return content.subarray(0, Math.min(content.length, 8_192)).includes(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures only the authorized workspace. Runner-owned state and common
|
||||
* dependency/VCS directories are excluded, symlinks are never followed, and
|
||||
* hard limits make this safe to run around an interactive turn.
|
||||
*/
|
||||
export async function capturePaperclipWorkspace(root: string): Promise<PaperclipWorkspaceSnapshot> {
|
||||
const result: PaperclipWorkspaceSnapshot = new Map();
|
||||
let retainedBytes = 0;
|
||||
let visitedDirectories = 0;
|
||||
|
||||
async function visit(directory: string): Promise<void> {
|
||||
if (result.size >= MAX_FILES || retainedBytes >= MAX_TOTAL_BYTES || visitedDirectories >= MAX_DIRECTORIES) return;
|
||||
visitedDirectories += 1;
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(directory, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
entries.sort((left, right) => left.name.localeCompare(right.name));
|
||||
for (const entry of entries) {
|
||||
if (result.size >= MAX_FILES || retainedBytes >= MAX_TOTAL_BYTES) return;
|
||||
if (IGNORED_DIRECTORY_NAMES.has(entry.name)) continue;
|
||||
const absolutePath = join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await visit(absolutePath);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) continue;
|
||||
let bytes: Buffer;
|
||||
try {
|
||||
bytes = await readFile(absolutePath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const binary = appearsBinary(bytes);
|
||||
const retain = bytes.length <= MAX_FILE_BYTES && retainedBytes + bytes.length <= MAX_TOTAL_BYTES;
|
||||
const content = retain ? bytes : null;
|
||||
if (content !== null) retainedBytes += content.length;
|
||||
result.set(normalizedRelativePath(root, absolutePath), {
|
||||
hash: createHash("sha256").update(bytes).digest("hex"),
|
||||
content,
|
||||
binary: binary || content === null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await visit(root);
|
||||
return result;
|
||||
}
|
||||
|
||||
function lines(content: Buffer | null): string[] {
|
||||
if (content === null || content.length === 0) return [];
|
||||
const value = content.toString("utf8");
|
||||
const result = value.split("\n");
|
||||
if (result.at(-1) === "") result.pop();
|
||||
return result;
|
||||
}
|
||||
|
||||
function unifiedDiff(path: string, before: Buffer | null, after: Buffer | null): {
|
||||
diff: string;
|
||||
additions: number;
|
||||
deletions: number;
|
||||
} {
|
||||
const oldLines = lines(before);
|
||||
const newLines = lines(after);
|
||||
let prefix = 0;
|
||||
while (prefix < oldLines.length && prefix < newLines.length && oldLines[prefix] === newLines[prefix]) prefix += 1;
|
||||
let suffix = 0;
|
||||
while (
|
||||
suffix < oldLines.length - prefix &&
|
||||
suffix < newLines.length - prefix &&
|
||||
oldLines[oldLines.length - 1 - suffix] === newLines[newLines.length - 1 - suffix]
|
||||
) suffix += 1;
|
||||
const removed = oldLines.slice(prefix, oldLines.length - suffix);
|
||||
const added = newLines.slice(prefix, newLines.length - suffix);
|
||||
const oldStart = Math.min(prefix + 1, Math.max(1, oldLines.length));
|
||||
const newStart = Math.min(prefix + 1, Math.max(1, newLines.length));
|
||||
const header = [
|
||||
`diff --git a/${path} b/${path}`,
|
||||
`--- ${before === null ? "/dev/null" : `a/${path}`}`,
|
||||
`+++ ${after === null ? "/dev/null" : `b/${path}`}`,
|
||||
`@@ -${oldStart},${removed.length} +${newStart},${added.length} @@`,
|
||||
];
|
||||
const patch = [...header, ...removed.map((line) => `-${line}`), ...added.map((line) => `+${line}`)].join("\n");
|
||||
return {
|
||||
diff: patch.length <= MAX_DIFF_CHARS_PER_FILE
|
||||
? `${patch}\n`
|
||||
: `${patch.slice(0, MAX_DIFF_CHARS_PER_FILE)}\n… diff truncated by runner …\n`,
|
||||
additions: added.length,
|
||||
deletions: removed.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function diffPaperclipWorkspace(
|
||||
before: PaperclipWorkspaceSnapshot,
|
||||
after: PaperclipWorkspaceSnapshot,
|
||||
changeSetId: string,
|
||||
): PaperclipWorkspaceDiff | null {
|
||||
const files: PaperclipWorkspaceFileChange[] = [];
|
||||
const paths = [...new Set([...before.keys(), ...after.keys()])].sort();
|
||||
for (const path of paths) {
|
||||
const prior = before.get(path);
|
||||
const current = after.get(path);
|
||||
if (prior?.hash === current?.hash) continue;
|
||||
const operation = prior === undefined ? "create" : current === undefined ? "delete" : "modify";
|
||||
const binary = prior?.binary === true || current?.binary === true;
|
||||
const stats = binary
|
||||
? { diff: null, additions: null, deletions: null }
|
||||
: unifiedDiff(path, prior?.content ?? null, current?.content ?? null);
|
||||
files.push({ path, operation, previousPath: null, binary, ...stats });
|
||||
}
|
||||
if (files.length === 0) return null;
|
||||
|
||||
// Exact-content delete/create pairs are represented as a rename. This keeps
|
||||
// the protocol semantic even when the harness only used shell commands.
|
||||
const deletedByHash = new Map<string, PaperclipWorkspaceFileChange>();
|
||||
for (const file of files) {
|
||||
if (file.operation === "delete") deletedByHash.set(before.get(file.path)!.hash, file);
|
||||
}
|
||||
for (const file of [...files]) {
|
||||
if (file.operation !== "create") continue;
|
||||
const deleted = deletedByHash.get(after.get(file.path)!.hash);
|
||||
if (deleted === undefined) continue;
|
||||
const deletedIndex = files.indexOf(deleted);
|
||||
if (deletedIndex >= 0) files.splice(deletedIndex, 1);
|
||||
file.operation = "rename";
|
||||
file.previousPath = deleted.path;
|
||||
file.additions = 0;
|
||||
file.deletions = 0;
|
||||
file.diff = `similarity index 100%\nrename from ${deleted.path}\nrename to ${file.path}\n`;
|
||||
deletedByHash.delete(after.get(file.path)!.hash);
|
||||
}
|
||||
|
||||
const hasUnknownStats = files.some((file) => file.additions === null || file.deletions === null);
|
||||
return {
|
||||
schema: PAPERCLIP_WORKSPACE_DIFF_SCHEMA,
|
||||
changeSetId,
|
||||
revision: 1,
|
||||
source: "runner_verified",
|
||||
complete: true,
|
||||
files,
|
||||
totals: {
|
||||
files: files.length,
|
||||
additions: hasUnknownStats ? null : files.reduce((sum, file) => sum + (file.additions ?? 0), 0),
|
||||
deletions: hasUnknownStats ? null : files.reduce((sum, file) => sum + (file.deletions ?? 0), 0),
|
||||
},
|
||||
patchArtifactRef: null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
discoverPaperclipWorkspaceFileReferences,
|
||||
paperclipWorkspaceFileReferencesFromText,
|
||||
} from "./workspace-file-reference.js";
|
||||
|
||||
describe("workspace file references", () => {
|
||||
it("normalizes Markdown links and verifies a bounded preview", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "paperclip-file-reference-"));
|
||||
await writeFile(join(root, "guide.md"), "# Guide\n\nSafe content.\n");
|
||||
const references = await discoverPaperclipWorkspaceFileReferences(
|
||||
root,
|
||||
`Read [the guide](${join(root, "guide.md")}:2).`,
|
||||
"turn-1",
|
||||
);
|
||||
expect(references).toHaveLength(1);
|
||||
expect(references[0]).toMatchObject({
|
||||
schema: "paperclip.workspace.file_reference.v1",
|
||||
source: "runner_verified",
|
||||
path: "guide.md",
|
||||
displayName: "the guide",
|
||||
presentation: "document",
|
||||
line: 2,
|
||||
preview: "# Guide\n\nSafe content.\n",
|
||||
});
|
||||
expect(references[0]?.contentDigest).toMatch(/^sha256:[a-f0-9]{64}$/);
|
||||
});
|
||||
|
||||
it("rejects external URLs and paths outside the authorized workspace", () => {
|
||||
const references = paperclipWorkspaceFileReferencesFromText(
|
||||
"/workspace",
|
||||
"[external](https://example.com/file.md) [outside](/etc/passwd) [safe](docs/safe.md)",
|
||||
"turn-1",
|
||||
);
|
||||
expect(references.map((reference) => reference.path)).toEqual(["docs/safe.md"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, stat } from "node:fs/promises";
|
||||
import { basename, extname, isAbsolute, relative, resolve, sep } from "node:path";
|
||||
|
||||
export const PAPERCLIP_WORKSPACE_FILE_REFERENCE_SCHEMA = "paperclip.workspace.file_reference.v1" as const;
|
||||
|
||||
export interface PaperclipWorkspaceFileReference {
|
||||
schema: typeof PAPERCLIP_WORKSPACE_FILE_REFERENCE_SCHEMA;
|
||||
referenceId: string;
|
||||
source: "harness_reported" | "runner_verified";
|
||||
path: string;
|
||||
displayName: string;
|
||||
mediaType: string | null;
|
||||
presentation: "document" | "code" | "image" | "generic";
|
||||
line: number | null;
|
||||
preview: string | null;
|
||||
previewTruncated: boolean;
|
||||
contentDigest: string | null;
|
||||
}
|
||||
|
||||
const MAX_REFERENCES = 32;
|
||||
const MAX_PREVIEW_BYTES = 256 * 1024;
|
||||
const MAX_READ_BYTES = 1024 * 1024;
|
||||
|
||||
function media(path: string): Pick<PaperclipWorkspaceFileReference, "mediaType" | "presentation"> {
|
||||
const extension = extname(path).toLowerCase();
|
||||
if ([".md", ".mdx", ".txt", ".rst"].includes(extension)) {
|
||||
return { mediaType: extension === ".md" || extension === ".mdx" ? "text/markdown" : "text/plain", presentation: "document" };
|
||||
}
|
||||
if ([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"].includes(extension)) {
|
||||
const imageType = extension === ".jpg" ? "jpeg" : extension.slice(1);
|
||||
return { mediaType: `image/${imageType}`, presentation: "image" };
|
||||
}
|
||||
if ([".ts", ".tsx", ".js", ".jsx", ".json", ".rs", ".py", ".go", ".java", ".css", ".html", ".yaml", ".yml", ".toml", ".sh"].includes(extension)) {
|
||||
return { mediaType: "text/plain", presentation: "code" };
|
||||
}
|
||||
return { mediaType: null, presentation: "generic" };
|
||||
}
|
||||
|
||||
function localTarget(rawTarget: string): { target: string; line: number | null } | null {
|
||||
let target = rawTarget.trim();
|
||||
if (target.startsWith("<") && target.endsWith(">")) target = target.slice(1, -1);
|
||||
if (/^[a-z][a-z0-9+.-]*:/i.test(target) && !target.startsWith("file:")) return null;
|
||||
if (target.startsWith("file://")) {
|
||||
try { target = decodeURIComponent(new URL(target).pathname); } catch { return null; }
|
||||
} else {
|
||||
try { target = decodeURIComponent(target); } catch { return null; }
|
||||
}
|
||||
let line: number | null = null;
|
||||
const hashLine = target.match(/#L(\d+)(?:C\d+)?$/i);
|
||||
if (hashLine) {
|
||||
line = Number(hashLine[1]);
|
||||
target = target.slice(0, hashLine.index);
|
||||
} else {
|
||||
const suffixLine = target.match(/:(\d+)$/);
|
||||
if (suffixLine) {
|
||||
line = Number(suffixLine[1]);
|
||||
target = target.slice(0, suffixLine.index);
|
||||
}
|
||||
}
|
||||
return target.length === 0 ? null : { target, line };
|
||||
}
|
||||
|
||||
export function paperclipWorkspaceFileReferencesFromText(
|
||||
workspace: string,
|
||||
assistantText: string,
|
||||
turnId: string,
|
||||
source: PaperclipWorkspaceFileReference["source"] = "harness_reported",
|
||||
): PaperclipWorkspaceFileReference[] {
|
||||
const root = resolve(workspace);
|
||||
const references: PaperclipWorkspaceFileReference[] = [];
|
||||
const seen = new Set<string>();
|
||||
const links = assistantText.matchAll(/\[([^\]]+)]\((<[^>]+>|[^)\s]+)(?:\s+["'][^"']*["'])?\)/g);
|
||||
for (const match of links) {
|
||||
if (references.length >= MAX_REFERENCES) break;
|
||||
const parsed = localTarget(match[2] ?? "");
|
||||
if (parsed === null) continue;
|
||||
const absolutePath = resolve(root, parsed.target);
|
||||
const relativePath = relative(root, absolutePath).split(sep).join("/");
|
||||
if (!relativePath || relativePath.startsWith("../") || isAbsolute(relativePath)) continue;
|
||||
const key = `${relativePath}:${parsed.line ?? ""}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
const identity = createHash("sha256").update(`${turnId}\0${key}`).digest("hex").slice(0, 24);
|
||||
references.push({
|
||||
schema: PAPERCLIP_WORKSPACE_FILE_REFERENCE_SCHEMA,
|
||||
referenceId: `${turnId}:file:${identity}`,
|
||||
source,
|
||||
path: relativePath,
|
||||
displayName: (match[1]?.trim() || basename(relativePath)).slice(0, 255),
|
||||
...media(relativePath),
|
||||
line: parsed.line,
|
||||
preview: null,
|
||||
previewTruncated: false,
|
||||
contentDigest: null,
|
||||
});
|
||||
}
|
||||
return references;
|
||||
}
|
||||
|
||||
export async function discoverPaperclipWorkspaceFileReferences(
|
||||
workspace: string,
|
||||
assistantText: string,
|
||||
turnId: string,
|
||||
): Promise<PaperclipWorkspaceFileReference[]> {
|
||||
const references = paperclipWorkspaceFileReferencesFromText(workspace, assistantText, turnId, "runner_verified");
|
||||
const verified: PaperclipWorkspaceFileReference[] = [];
|
||||
for (const reference of references) {
|
||||
const absolutePath = resolve(workspace, reference.path);
|
||||
let info;
|
||||
try { info = await stat(absolutePath); } catch { continue; }
|
||||
if (!info.isFile()) continue;
|
||||
if (info.size > MAX_READ_BYTES) {
|
||||
verified.push({ ...reference, previewTruncated: true });
|
||||
continue;
|
||||
}
|
||||
let bytes: Buffer;
|
||||
try { bytes = await readFile(absolutePath); } catch { continue; }
|
||||
const binary = bytes.subarray(0, Math.min(bytes.length, 8_192)).includes(0);
|
||||
verified.push({
|
||||
...reference,
|
||||
preview: binary ? null : bytes.subarray(0, MAX_PREVIEW_BYTES).toString("utf8"),
|
||||
previewTruncated: bytes.length > MAX_PREVIEW_BYTES,
|
||||
contentDigest: `sha256:${createHash("sha256").update(bytes).digest("hex")}`,
|
||||
});
|
||||
}
|
||||
return verified;
|
||||
}
|
||||
@@ -63,6 +63,7 @@ const input: NativeExecutionInputV1 = {
|
||||
},
|
||||
workspace: { cwd: "/workspace", repoUrl: null, repoRef: null, branchName: null },
|
||||
session: { normalizedSessionId: identity.sessionId, driverKind: "codex_app_server", protocolVersion: 1 },
|
||||
provider: { kind: "codex", model: null },
|
||||
completionContract: {
|
||||
id: "contract-recovery",
|
||||
sha256: "contract-recovery-sha",
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { ControlPlanePort } from "./contracts/control-plane-port.js";
|
||||
import type { NativeExecutionInputV1, NativeSessionExecutionResult } from "./contracts/native-execution.js";
|
||||
import { buildNativeModelEnvelope, parseNativeExecutionInput } from "./contracts/native-execution.js";
|
||||
import type { NativeSession, NativeSessionBackend } from "./contracts/native-session-backend.js";
|
||||
import type { PersistedNativeSession } from "./contracts/native-session-backend.js";
|
||||
import type { PrpEvent, PrpTerminalState } from "./protocol/replay-contract.js";
|
||||
|
||||
export interface ExecuteNativeSessionOptions {
|
||||
@@ -14,6 +15,10 @@ export interface ExecuteNativeSessionOptions {
|
||||
controlPlaneInstanceId: string;
|
||||
timeoutMs?: number;
|
||||
onSession?: (session: NativeSession | null) => void;
|
||||
existingSession?: NativeSession;
|
||||
persistedSession?: PersistedNativeSession | null;
|
||||
keepSessionOpen?: boolean;
|
||||
onCheckpoint?: (snapshot: PersistedNativeSession) => Promise<void> | void;
|
||||
}
|
||||
|
||||
function isTurnTerminal(event: PrpEvent): boolean {
|
||||
@@ -62,7 +67,9 @@ async function consumeTurn(session: NativeSession, controlPlane: ControlPlanePor
|
||||
export async function executeNativeSession(options: ExecuteNativeSessionOptions): Promise<NativeSessionExecutionResult> {
|
||||
const input = parseNativeExecutionInput(options.input);
|
||||
const descriptor = await options.backend.descriptor();
|
||||
const persistedSession = await options.controlPlane.loadSessionCheckpoint?.() ?? null;
|
||||
const persistedSession = options.existingSession
|
||||
? null
|
||||
: options.persistedSession ?? await options.controlPlane.loadSessionCheckpoint?.() ?? null;
|
||||
const normalizedSessionId = persistedSession?.identity.sessionId
|
||||
?? input.session.normalizedSessionId
|
||||
?? randomUUID();
|
||||
@@ -97,7 +104,14 @@ export async function executeNativeSession(options: ExecuteNativeSessionOptions)
|
||||
) throw new Error("native_session_checkpoint_binding_mismatch");
|
||||
let recovered = false;
|
||||
let session: NativeSession;
|
||||
if (persistedSession) {
|
||||
if (options.existingSession) {
|
||||
if (options.existingSession.attachRun === undefined) {
|
||||
throw new Error("native_session_multi_run_unavailable");
|
||||
}
|
||||
await options.existingSession.attachRun({ identity });
|
||||
session = options.existingSession;
|
||||
recovered = true;
|
||||
} else if (persistedSession) {
|
||||
if (!options.backend.recoverSession) {
|
||||
throw new Error("native_session_recovery_unavailable: refusing to open a second provider session");
|
||||
}
|
||||
@@ -116,12 +130,13 @@ export async function executeNativeSession(options: ExecuteNativeSessionOptions)
|
||||
options.onSession?.(session);
|
||||
try {
|
||||
const checkpoint = async () => {
|
||||
if (options.controlPlane.checkpointSession) {
|
||||
await options.controlPlane.checkpointSession(await session.snapshot());
|
||||
}
|
||||
const snapshot = await session.snapshot();
|
||||
await options.controlPlane.checkpointSession?.(snapshot);
|
||||
await options.onCheckpoint?.(snapshot);
|
||||
};
|
||||
const recoveredSnapshot = await session.snapshot();
|
||||
await options.controlPlane.checkpointSession?.(recoveredSnapshot);
|
||||
await options.onCheckpoint?.(recoveredSnapshot);
|
||||
|
||||
let consumed = { event: null as PrpEvent | null, eventCount: 0, highestContiguousSourceSeq: 0 };
|
||||
const completionSnapshot = recoveredSnapshot.semanticResult && recoveredSnapshot.terminal
|
||||
@@ -225,6 +240,12 @@ export async function executeNativeSession(options: ExecuteNativeSessionOptions)
|
||||
semanticResult: completed.result,
|
||||
terminal,
|
||||
});
|
||||
await options.onCheckpoint?.({
|
||||
...snapshot,
|
||||
semanticResult: completed.result,
|
||||
terminal,
|
||||
});
|
||||
const usage = await session.usage?.() ?? null;
|
||||
return {
|
||||
result: completed.result,
|
||||
terminal,
|
||||
@@ -232,14 +253,16 @@ export async function executeNativeSession(options: ExecuteNativeSessionOptions)
|
||||
normalizedSessionId,
|
||||
providerSessionId: snapshot.providerSessionId ?? null,
|
||||
driverKind: descriptor.name,
|
||||
driverVersion: descriptor.version,
|
||||
driverVersion: typeof usage?.driverVersion === "string" ? usage.driverVersion : descriptor.version,
|
||||
nativeEventCount: consumed.eventCount,
|
||||
highestContiguousSourceSeq: consumed.highestContiguousSourceSeq,
|
||||
usage: null,
|
||||
usage,
|
||||
};
|
||||
} finally {
|
||||
options.onSession?.(null);
|
||||
await session.close({ reason: "native session execution complete" });
|
||||
if (!options.keepSessionOpen) {
|
||||
options.onSession?.(null);
|
||||
await session.close({ reason: "native session execution complete" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -227,6 +227,7 @@ export const commandSchema = {
|
||||
"type": {
|
||||
"enum": [
|
||||
"run.prepare",
|
||||
"run.attach",
|
||||
"session.open",
|
||||
"turn.start",
|
||||
"turn.steer",
|
||||
@@ -239,6 +240,7 @@ export const commandSchema = {
|
||||
"session.close",
|
||||
"run.cancel",
|
||||
"runner.drain",
|
||||
"runner.suspend",
|
||||
"runner.shutdown"
|
||||
]
|
||||
},
|
||||
@@ -288,6 +290,245 @@ export const commandSchema = {
|
||||
"additionalProperties": true
|
||||
} as const;
|
||||
|
||||
export const workspaceDiffSchema = {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json",
|
||||
"title": "Paperclip workspace diff",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schema",
|
||||
"changeSetId",
|
||||
"revision",
|
||||
"source",
|
||||
"complete",
|
||||
"files",
|
||||
"totals",
|
||||
"patchArtifactRef"
|
||||
],
|
||||
"properties": {
|
||||
"schema": {
|
||||
"const": "paperclip.workspace.diff.v1"
|
||||
},
|
||||
"changeSetId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 200
|
||||
},
|
||||
"revision": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"source": {
|
||||
"enum": [
|
||||
"harness_reported",
|
||||
"runner_verified"
|
||||
]
|
||||
},
|
||||
"complete": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"files": {
|
||||
"type": "array",
|
||||
"maxItems": 2000,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"path",
|
||||
"operation",
|
||||
"previousPath",
|
||||
"additions",
|
||||
"deletions",
|
||||
"binary",
|
||||
"diff"
|
||||
],
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 1024,
|
||||
"pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$"
|
||||
},
|
||||
"operation": {
|
||||
"enum": [
|
||||
"create",
|
||||
"modify",
|
||||
"delete",
|
||||
"rename",
|
||||
"mode_change"
|
||||
]
|
||||
},
|
||||
"previousPath": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 1024,
|
||||
"pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$"
|
||||
}
|
||||
]
|
||||
},
|
||||
"additions": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"minimum": 0
|
||||
},
|
||||
"deletions": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"minimum": 0
|
||||
},
|
||||
"binary": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"diff": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"maxLength": 262144
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"totals": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"files",
|
||||
"additions",
|
||||
"deletions"
|
||||
],
|
||||
"properties": {
|
||||
"files": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"additions": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"minimum": 0
|
||||
},
|
||||
"deletions": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"minimum": 0
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"patchArtifactRef": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"maxLength": 2048
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
} as const;
|
||||
|
||||
export const workspaceFileReferenceSchema = {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json",
|
||||
"title": "Paperclip workspace file reference",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schema",
|
||||
"referenceId",
|
||||
"source",
|
||||
"path",
|
||||
"displayName",
|
||||
"mediaType",
|
||||
"presentation",
|
||||
"line",
|
||||
"preview",
|
||||
"previewTruncated",
|
||||
"contentDigest"
|
||||
],
|
||||
"properties": {
|
||||
"schema": {
|
||||
"const": "paperclip.workspace.file_reference.v1"
|
||||
},
|
||||
"referenceId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 240
|
||||
},
|
||||
"source": {
|
||||
"enum": [
|
||||
"harness_reported",
|
||||
"runner_verified"
|
||||
]
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 1024,
|
||||
"pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$"
|
||||
},
|
||||
"displayName": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 255
|
||||
},
|
||||
"mediaType": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"maxLength": 160
|
||||
},
|
||||
"presentation": {
|
||||
"enum": [
|
||||
"document",
|
||||
"code",
|
||||
"image",
|
||||
"generic"
|
||||
]
|
||||
},
|
||||
"line": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"minimum": 1
|
||||
},
|
||||
"preview": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"maxLength": 262144
|
||||
},
|
||||
"previewTruncated": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"contentDigest": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^sha256:[a-f0-9]{64}$"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
} as const;
|
||||
|
||||
export const semanticToolSchema = {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://paperclip.dev/schemas/prp/v1/semantic-tool.schema.json",
|
||||
@@ -1171,12 +1412,19 @@ export const eventSchema = {
|
||||
"enum": [
|
||||
"runner.connected",
|
||||
"runner.reconnected",
|
||||
"runner.reconciled",
|
||||
"runner.disconnected",
|
||||
"runner.draining",
|
||||
"runner.suspending",
|
||||
"runner.suspended",
|
||||
"runner.stopped",
|
||||
"runner.diagnostic",
|
||||
"runtime.phase.changed",
|
||||
"sandbox.metric",
|
||||
"workspace.ready",
|
||||
"workspace.change.updated",
|
||||
"workspace.diff.recorded",
|
||||
"workspace.file.referenced",
|
||||
"harness.starting",
|
||||
"harness.ready",
|
||||
"harness.exited",
|
||||
@@ -1220,6 +1468,8 @@ export const eventSchema = {
|
||||
"interaction.response.progressed",
|
||||
"interaction.response.resolved",
|
||||
"interaction.response.delivered",
|
||||
"run.attached",
|
||||
"run.detached",
|
||||
"run.result.proposed",
|
||||
"run.result.accepted",
|
||||
"run.result.rejected",
|
||||
@@ -1256,6 +1506,23 @@ export const eventSchema = {
|
||||
},
|
||||
"payload": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"channel": {
|
||||
"enum": [
|
||||
"progress",
|
||||
"final",
|
||||
"summary",
|
||||
"detail",
|
||||
"unknown"
|
||||
]
|
||||
},
|
||||
"providerPhase": {
|
||||
"type": "string"
|
||||
},
|
||||
"providerMethod": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"debug": {
|
||||
@@ -1264,6 +1531,69 @@ export const eventSchema = {
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"eventType": {
|
||||
"const": "workspace.file.referenced"
|
||||
}
|
||||
}
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"payload": {
|
||||
"$ref": "https://paperclip.dev/schemas/prp/v1/workspace-file-reference.schema.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"eventType": {
|
||||
"enum": [
|
||||
"workspace.change.updated",
|
||||
"workspace.diff.recorded"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"payload": {
|
||||
"$ref": "https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"eventType": {
|
||||
"const": "workspace.diff.recorded"
|
||||
}
|
||||
}
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"payload": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "https://paperclip.dev/schemas/prp/v1/workspace-diff.schema.json"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"complete": {
|
||||
"const": true
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
@@ -1441,6 +1771,8 @@ export const prpSchemaBundle = {
|
||||
"identity": identitySchema,
|
||||
"capabilities": capabilitiesSchema,
|
||||
"command": commandSchema,
|
||||
"workspace-diff": workspaceDiffSchema,
|
||||
"workspace-file-reference": workspaceFileReferenceSchema,
|
||||
"semantic-tool": semanticToolSchema,
|
||||
"stop-reason": stopReasonSchema,
|
||||
"terminal": terminalSchema,
|
||||
|
||||
@@ -4,4 +4,4 @@
|
||||
import equalModule from "ajv/dist/runtime/equal.js";
|
||||
import ucs2LengthModule from "ajv/dist/runtime/ucs2length.js";
|
||||
|
||||
"use strict";export const fixtureValidator = validate20;const schema31 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/fixture.schema.json","title":"PRP scripted replay fixture","type":"object","required":["schema","fixtureVersion","protocolVersion","name","description","identity","capabilities","commands","events","result"],"properties":{"schema":{"const":"paperclip.prp.fixture.v1"},"fixtureVersion":{"const":1},"protocolVersion":{"const":1},"name":{"type":"string","minLength":1,"maxLength":120},"description":{"type":"string","minLength":1,"maxLength":1000},"identity":{"$ref":"https://paperclip.dev/schemas/prp/v1/identity.schema.json"},"capabilities":{"$ref":"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json"},"commands":{"type":"array","items":{"$ref":"https://paperclip.dev/schemas/prp/v1/command.schema.json"}},"events":{"type":"array","minItems":1,"items":{"$ref":"https://paperclip.dev/schemas/prp/v1/event.schema.json"}},"requests":{"type":"array","items":{"$ref":"https://paperclip.dev/schemas/prp/v1/request.schema.json"}},"result":{"$ref":"https://paperclip.dev/schemas/prp/v1/result.schema.json"}},"additionalProperties":true};const schema41 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json","title":"PRP negotiated capabilities","type":"object","required":["schema","sessionReusePolicy","driver","steer","interrupt","resume","runtimeRequests","structuredResult","typedEvents"],"properties":{"schema":{"const":"paperclip.prp.capabilities.v1"},"sessionReusePolicy":{"enum":["new_per_run","reuse_per_issue","reuse_per_workspace"]},"driver":{"type":"object","required":["kind","version"],"properties":{"kind":{"type":"string","minLength":1,"maxLength":80},"version":{"type":"string","minLength":1,"maxLength":80}},"additionalProperties":true},"steer":{"type":"boolean"},"interrupt":{"type":"boolean"},"resume":{"type":"boolean"},"runtimeRequests":{"type":"boolean"},"structuredResult":{"type":"boolean"},"typedEvents":{"type":"boolean"},"semanticTools":{"type":"object","required":["schema","schemaVersion","operations"],"properties":{"schema":{"const":"paperclip.prp.semantic_tools.v1"},"schemaVersion":{"const":1},"operations":{"type":"array","items":{"type":"object","required":["operationId","version","availability","redactionDisposition"],"properties":{"operationId":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]*$"},"version":{"const":1},"availability":{"enum":["available","denied","unsupported"]},"redactionDisposition":{"const":"digest_only"},"requiredClaims":{"type":"array","items":{"type":"string","minLength":1,"maxLength":160},"uniqueItems":true},"reasonCode":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[a-z][a-z0-9_.:-]*$"}},"additionalProperties":true}}},"additionalProperties":true},"unsupported":{"type":"array","items":{"type":"string","minLength":1},"uniqueItems":true}},"additionalProperties":true};const schema42 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/command.schema.json","title":"PRP runner command","type":"object","required":["schema","commandId","controllerSeq","type","issuedAt","payload"],"properties":{"schema":{"const":"paperclip.prp.command.v1"},"commandId":{"type":"string","minLength":1,"maxLength":160},"controllerSeq":{"type":"integer","minimum":1},"type":{"enum":["run.prepare","session.open","turn.start","turn.steer","turn.interrupt","turn.stop","request.resolve","interaction.receipt","semantic_tool.result","session.snapshot","session.close","run.cancel","runner.drain","runner.shutdown"]},"issuedAt":{"type":"string","format":"date-time"},"deadlineAt":{"type":"string","format":"date-time"},"precondition":{"type":"object","properties":{"runnerState":{"type":"array","items":{"type":"string"}},"runState":{"type":"array","items":{"type":"string"}},"sessionState":{"type":"array","items":{"type":"string"}},"activeTurnId":{"type":["string","null"]}},"additionalProperties":true},"payload":{"type":"object","additionalProperties":true}},"additionalProperties":true};const schema70 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/request.schema.json","title":"PRP request","oneOf":[{"type":"object","required":["schema","requestKind","requestId","type","status","prompt"],"properties":{"schema":{"const":"paperclip.runtime_request.v1"},"requestKind":{"const":"runtime"},"requestId":{"type":"string","minLength":1,"maxLength":160},"type":{"enum":["permission","input"]},"status":{"enum":["pending","resolved","expired","cancelled"]},"prompt":{"type":"string","minLength":1,"maxLength":4000},"choices":{"type":"array","items":{"type":"object","required":["key","label"],"properties":{"key":{"type":"string","minLength":1},"label":{"type":"string","minLength":1}},"additionalProperties":true}}},"additionalProperties":trLine truncated
|
||||
"use strict";export const fixtureValidator = validate20;const schema31 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/fixture.schema.json","title":"PRP scripted replay fixture","type":"object","required":["schema","fixtureVersion","protocolVersion","name","description","identity","capabilities","commands","events","result"],"properties":{"schema":{"const":"paperclip.prp.fixture.v1"},"fixtureVersion":{"const":1},"protocolVersion":{"const":1},"name":{"type":"string","minLength":1,"maxLength":120},"description":{"type":"string","minLength":1,"maxLength":1000},"identity":{"$ref":"https://paperclip.dev/schemas/prp/v1/identity.schema.json"},"capabilities":{"$ref":"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json"},"commands":{"type":"array","items":{"$ref":"https://paperclip.dev/schemas/prp/v1/command.schema.json"}},"events":{"type":"array","minItems":1,"items":{"$ref":"https://paperclip.dev/schemas/prp/v1/event.schema.json"}},"requests":{"type":"array","items":{"$ref":"https://paperclip.dev/schemas/prp/v1/request.schema.json"}},"result":{"$ref":"https://paperclip.dev/schemas/prp/v1/result.schema.json"}},"additionalProperties":true};const schema41 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json","title":"PRP negotiated capabilities","type":"object","required":["schema","sessionReusePolicy","driver","steer","interrupt","resume","runtimeRequests","structuredResult","typedEvents"],"properties":{"schema":{"const":"paperclip.prp.capabilities.v1"},"sessionReusePolicy":{"enum":["new_per_run","reuse_per_issue","reuse_per_workspace"]},"driver":{"type":"object","required":["kind","version"],"properties":{"kind":{"type":"string","minLength":1,"maxLength":80},"version":{"type":"string","minLength":1,"maxLength":80}},"additionalProperties":true},"steer":{"type":"boolean"},"interrupt":{"type":"boolean"},"resume":{"type":"boolean"},"runtimeRequests":{"type":"boolean"},"structuredResult":{"type":"boolean"},"typedEvents":{"type":"boolean"},"semanticTools":{"type":"object","required":["schema","schemaVersion","operations"],"properties":{"schema":{"const":"paperclip.prp.semantic_tools.v1"},"schemaVersion":{"const":1},"operations":{"type":"array","items":{"type":"object","required":["operationId","version","availability","redactionDisposition"],"properties":{"operationId":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]*$"},"version":{"const":1},"availability":{"enum":["available","denied","unsupported"]},"redactionDisposition":{"const":"digest_only"},"requiredClaims":{"type":"array","items":{"type":"string","minLength":1,"maxLength":160},"uniqueItems":true},"reasonCode":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[a-z][a-z0-9_.:-]*$"}},"additionalProperties":true}}},"additionalProperties":true},"unsupported":{"type":"array","items":{"type":"string","minLength":1},"uniqueItems":true}},"additionalProperties":true};const schema42 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/command.schema.json","title":"PRP runner command","type":"object","required":["schema","commandId","controllerSeq","type","issuedAt","payload"],"properties":{"schema":{"const":"paperclip.prp.command.v1"},"commandId":{"type":"string","minLength":1,"maxLength":160},"controllerSeq":{"type":"integer","minimum":1},"type":{"enum":["run.prepare","run.attach","session.open","turn.start","turn.steer","turn.interrupt","turn.stop","request.resolve","interaction.receipt","semantic_tool.result","session.snapshot","session.close","run.cancel","runner.drain","runner.suspend","runner.shutdown"]},"issuedAt":{"type":"string","format":"date-time"},"deadlineAt":{"type":"string","format":"date-time"},"precondition":{"type":"object","properties":{"runnerState":{"type":"array","items":{"type":"string"}},"runState":{"type":"array","items":{"type":"string"}},"sessionState":{"type":"array","items":{"type":"string"}},"activeTurnId":{"type":["string","null"]}},"additionalProperties":true},"payload":{"type":"object","additionalProperties":true}},"additionalProperties":true};const schema73 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/request.schema.json","title":"PRP request","oneOf":[{"type":"object","required":["schema","requestKind","requestId","type","status","prompt"],"properties":{"schema":{"const":"paperclip.runtime_request.v1"},"requestKind":{"const":"runtime"},"requestId":{"type":"string","minLength":1,"maxLength":160},"type":{"enum":["permission","input"]},"status":{"enum":["pending","resolved","expired","cancelled"]},"prompt":{"type":"string","minLength":1,"maxLength":4000},"choices":{"type":"array","items":{"type":"object","required":["key","label"],"properties":{"key":{"type":"string","minLength":1},"label":{"type":"string","minLength":1}},"additionalProperties":truLine truncated
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { validatePrpEvent, type PrpEvent } from "./replay-contract.js";
|
||||
|
||||
function workspaceEvent(eventType: "workspace.change.updated" | "workspace.diff.recorded"): PrpEvent {
|
||||
return {
|
||||
schema: "paperclip.prp.event.v1",
|
||||
sourceEventId: `workspace:${eventType}`,
|
||||
sourceSeq: eventType === "workspace.change.updated" ? 1 : 2,
|
||||
sourceInstanceId: "runner-1",
|
||||
sourceKind: "runner",
|
||||
runId: "run-1",
|
||||
turnId: "turn-1",
|
||||
eventType,
|
||||
schemaVersion: 1,
|
||||
priority: 1,
|
||||
emittedAt: "2026-08-21T12:00:00.000Z",
|
||||
payload: {
|
||||
schema: "paperclip.workspace.diff.v1",
|
||||
changeSetId: "turn-1:workspace",
|
||||
revision: 1,
|
||||
source: "runner_verified",
|
||||
complete: eventType === "workspace.diff.recorded",
|
||||
files: [{
|
||||
path: "src/index.ts",
|
||||
operation: "modify",
|
||||
previousPath: null,
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
binary: false,
|
||||
diff: "--- a/src/index.ts\n+++ b/src/index.ts\n-old\n+new\n",
|
||||
}],
|
||||
totals: { files: 1, additions: 1, deletions: 1 },
|
||||
patchArtifactRef: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("PRP workspace change events", () => {
|
||||
it("accepts provisional and complete canonical workspace changes", () => {
|
||||
expect(validatePrpEvent(workspaceEvent("workspace.change.updated")).ok).toBe(true);
|
||||
expect(validatePrpEvent(workspaceEvent("workspace.diff.recorded")).ok).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects traversal paths and incomplete recorded diffs", () => {
|
||||
const traversal = workspaceEvent("workspace.diff.recorded");
|
||||
traversal.payload.files[0]!.path = "../secret";
|
||||
expect(validatePrpEvent(traversal).ok).toBe(false);
|
||||
const incomplete = workspaceEvent("workspace.diff.recorded");
|
||||
incomplete.payload.complete = false;
|
||||
expect(validatePrpEvent(incomplete).ok).toBe(false);
|
||||
});
|
||||
|
||||
it("validates a canonical individual workspace-file reference", () => {
|
||||
const event: PrpEvent = {
|
||||
schema: "paperclip.prp.event.v1",
|
||||
sourceEventId: "workspace:file:1",
|
||||
sourceSeq: 1,
|
||||
sourceInstanceId: "runner-1",
|
||||
sourceKind: "runner",
|
||||
runId: "run-1",
|
||||
turnId: "turn-1",
|
||||
eventType: "workspace.file.referenced",
|
||||
schemaVersion: 1,
|
||||
priority: 1,
|
||||
emittedAt: "2026-08-21T12:00:00.000Z",
|
||||
payload: {
|
||||
schema: "paperclip.workspace.file_reference.v1",
|
||||
referenceId: "turn-1:file:guide",
|
||||
source: "runner_verified",
|
||||
path: "docs/guide.md",
|
||||
displayName: "guide.md",
|
||||
mediaType: "text/markdown",
|
||||
presentation: "document",
|
||||
line: null,
|
||||
preview: "# Guide\n",
|
||||
previewTruncated: false,
|
||||
contentDigest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
},
|
||||
};
|
||||
expect(validatePrpEvent(event).ok).toBe(true);
|
||||
event.payload.path = "/private/guide.md";
|
||||
expect(validatePrpEvent(event).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -101,6 +101,14 @@ function handleRequest(message) {
|
||||
if (method === "turn/start") {
|
||||
const turnId = `turn-${++state.nextTurn}`;
|
||||
state.turns[turnId] = "inProgress";
|
||||
if (!state.heldOnce) {
|
||||
// Persist which provider turn is intentionally held before exposing its
|
||||
// tool call. If runnerd is killed immediately after delivering the tool
|
||||
// result, a replacement provider must not accidentally hold the next
|
||||
// turn as though the first response had never reached this process.
|
||||
state.heldOnce = true;
|
||||
state.heldTurnId = turnId;
|
||||
}
|
||||
save();
|
||||
send({ id, result: { turn: { id: turnId, status: "inProgress" } } });
|
||||
send({
|
||||
@@ -147,8 +155,8 @@ function handle(message) {
|
||||
const turnId = pendingTools.get(String(message.id));
|
||||
if (!turnId) return;
|
||||
pendingTools.delete(String(message.id));
|
||||
if (!state.heldOnce) {
|
||||
state.heldOnce = true;
|
||||
if (state.heldTurnId === turnId) {
|
||||
state.heldTurnId = null;
|
||||
save();
|
||||
return;
|
||||
}
|
||||
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env node
|
||||
import { createServer } from "node:http";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
if (process.env.OPENROUTER_API_KEY) process.stderr.write(`fixture credential=${process.env.OPENROUTER_API_KEY}\n`);
|
||||
const port = Number(args[args.indexOf("--port") + 1]);
|
||||
const username = process.env.OPENCODE_SERVER_USERNAME ?? "opencode";
|
||||
const password = process.env.OPENCODE_SERVER_PASSWORD ?? "";
|
||||
const expectedAuth = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`;
|
||||
const session = { id: "ses_fake_1", title: "Paperclip fake" };
|
||||
const clients = new Set();
|
||||
let eventConnections = 0;
|
||||
const runtimeConfig = JSON.parse(await readFile(join(process.env.XDG_CONFIG_HOME, "opencode", "opencode.json"), "utf8"));
|
||||
const mcp = runtimeConfig.mcp?.paperclip;
|
||||
let mcpRequestId = 1;
|
||||
const mcpEvidence = { tools: [], calls: [] };
|
||||
|
||||
await mkdir(process.env.XDG_DATA_HOME, { recursive: true });
|
||||
await writeFile(join(process.env.XDG_DATA_HOME, "fake-environment.json"), JSON.stringify({
|
||||
keys: Object.keys(process.env).sort(),
|
||||
home: process.env.HOME,
|
||||
configHome: process.env.XDG_CONFIG_HOME,
|
||||
projectConfigDisabled: process.env.OPENCODE_DISABLE_PROJECT_CONFIG,
|
||||
}));
|
||||
|
||||
function json(response, status, value) {
|
||||
response.statusCode = status;
|
||||
response.setHeader("Content-Type", "application/json");
|
||||
response.end(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function emit(value) {
|
||||
const frame = `data: ${JSON.stringify(value)}\n\n`;
|
||||
for (const response of clients) response.write(frame);
|
||||
}
|
||||
|
||||
async function mcpRequest(method, params) {
|
||||
const response = await fetch(mcp.url, {
|
||||
method: "POST",
|
||||
headers: { ...mcp.headers, "Content-Type": "application/json", Accept: "application/json, text/event-stream" },
|
||||
body: JSON.stringify({ jsonrpc: "2.0", id: mcpRequestId++, method, ...(params ? { params } : {}) }),
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function callFirstPaperclipTool() {
|
||||
if (!mcp?.url) return;
|
||||
await mcpRequest("initialize", { protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { name: "fake-opencode", version: "1" } });
|
||||
const listed = await mcpRequest("tools/list");
|
||||
mcpEvidence.tools = (listed.result?.tools ?? []).map((entry) => entry.name);
|
||||
const tool = listed.result?.tools?.find((entry) =>
|
||||
!["paperclip_finish", "paperclip_block"].includes(entry.name)
|
||||
&& (entry.inputSchema?.required?.length ?? 0) === 0
|
||||
);
|
||||
if (tool) mcpEvidence.calls.push(await mcpRequest("tools/call", { name: `paperclip_${tool.name}`, arguments: {} }));
|
||||
await writeFile(join(process.env.XDG_DATA_HOME, "fake-mcp-evidence.json"), JSON.stringify(mcpEvidence));
|
||||
}
|
||||
|
||||
async function callTerminalTool(promptBody) {
|
||||
const prompt = JSON.parse(promptBody.parts?.[0]?.text ?? "{}");
|
||||
const blocked = String(prompt.message ?? "").includes("block-result");
|
||||
const result = {
|
||||
schema: "paperclip.run_result.v1",
|
||||
reportedWorkDisposition: blocked ? "blocked" : "done",
|
||||
summary: blocked ? "Fake provider is blocked." : "Fake provider completed the task.",
|
||||
completionClaim: {
|
||||
contractRevision: prompt.task?.completionContract?.revision ?? "codex-demo-v1",
|
||||
objectiveSatisfied: !blocked,
|
||||
criteria: (prompt.task?.completionContract?.criteria ?? []).map((criterion) => ({
|
||||
criterionId: criterion.id,
|
||||
status: blocked ? "unknown" : "satisfied",
|
||||
evidenceRefs: [],
|
||||
})),
|
||||
remainingWork: blocked ? [{ description: "Waiting on a test dependency.", blocksCompletion: true }] : [],
|
||||
},
|
||||
evidence: [],
|
||||
verification: [],
|
||||
attentionRequests: blocked ? [{ kind: "blocker", summary: "Waiting on a test dependency." }] : [],
|
||||
artifacts: [],
|
||||
...(blocked ? { blocker: {
|
||||
reasonCode: "test_dependency",
|
||||
owner: { kind: "external", name: "fixture" },
|
||||
unblockAction: "Release the fixture dependency.",
|
||||
scope: "current_track",
|
||||
} } : {}),
|
||||
};
|
||||
return mcpRequest("tools/call", {
|
||||
name: blocked ? "paperclip_block" : "paperclip_finish",
|
||||
arguments: result,
|
||||
});
|
||||
}
|
||||
|
||||
const server = createServer(async (request, response) => {
|
||||
if (request.headers.authorization !== expectedAuth) return json(response, 401, { error: "unauthorized" });
|
||||
if (request.url === "/global/health") return json(response, 200, { healthy: true, version: "1.18.17" });
|
||||
if (request.url === "/event") {
|
||||
eventConnections += 1;
|
||||
response.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" });
|
||||
response.write(`data: ${JSON.stringify({ type: "server.connected", properties: {} })}\n\n`);
|
||||
if (eventConnections === 1) {
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
clients.add(response);
|
||||
request.on("close", () => clients.delete(response));
|
||||
return;
|
||||
}
|
||||
if (request.method === "POST" && request.url === "/session") return json(response, 200, session);
|
||||
if (request.method === "GET" && request.url === `/session/${session.id}`) return json(response, 200, session);
|
||||
if (request.method === "GET" && request.url === `/session/${session.id}/message`) return json(response, 200, []);
|
||||
if (request.method === "GET" && request.url === "/session/status") return json(response, 200, { [session.id]: { type: "idle" } });
|
||||
if (request.method === "POST" && request.url === `/session/${session.id}/abort`) return json(response, 200, true);
|
||||
if (request.method === "POST" && request.url === `/session/${session.id}/prompt_async`) {
|
||||
const chunks = [];
|
||||
request.on("data", (chunk) => chunks.push(chunk));
|
||||
request.on("end", () => {
|
||||
const promptPayload = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
||||
if (promptPayload.providerID !== "openrouter" || promptPayload.modelID !== "deepseek/deepseek-v4-flash-0731" || "model" in promptPayload) {
|
||||
return json(response, 400, { error: "OpenCode 1.18 prompt model fields must be top-level" });
|
||||
}
|
||||
json(response, 204, null);
|
||||
setTimeout(async () => {
|
||||
await callFirstPaperclipTool();
|
||||
await callTerminalTool(promptPayload);
|
||||
emit({ type: "message.updated", id: "event-user", properties: { sessionID: session.id, info: { id: "message-user", sessionID: session.id, role: "user" } } });
|
||||
emit({ type: "message.part.updated", id: "event-user-part", properties: { sessionID: session.id, part: { id: "part-user", messageID: "message-user", type: "text", text: "submitted prompt must not become assistant text" } } });
|
||||
emit({ type: "message.updated", id: "event-assistant", properties: { sessionID: session.id, info: { id: "message-assistant", sessionID: session.id, role: "assistant" } } });
|
||||
emit({ type: "message.part.updated", id: "event-patch", properties: { sessionID: session.id, part: { id: "part-patch", messageID: "message-assistant", type: "patch", files: ["src/index.ts"] } } });
|
||||
emit({ type: "message.part.updated", id: "event-1", properties: { sessionID: session.id, part: { id: "part-1", messageID: "message-assistant", type: "text", text: "done [guide](guide.md)" } } });
|
||||
emit({ type: "message.part.updated", id: "event-1", properties: { sessionID: session.id, part: { id: "part-1", messageID: "message-assistant", type: "text", text: "done" } } });
|
||||
emit({ type: "message.updated", id: "event-2", properties: { info: { sessionID: session.id, tokens: { input: 3, output: 2 }, cost: 0.001 } } });
|
||||
emit({ type: "session.idle", id: "event-3", properties: { sessionID: session.id } });
|
||||
}, 100);
|
||||
});
|
||||
return;
|
||||
}
|
||||
json(response, 404, { error: "not found" });
|
||||
});
|
||||
|
||||
server.listen(port, "127.0.0.1");
|
||||
for (const signal of ["SIGTERM", "SIGINT"]) process.on(signal, () => server.close(() => process.exit(0)));
|
||||
@@ -280,6 +280,7 @@ describe("P6-25 persisted reaper-to-finalization recovery", () => {
|
||||
},
|
||||
workspace: { cwd: repoRoot, repoUrl: null, repoRef: null, branchName: null },
|
||||
session: { normalizedSessionId: sessionId, driverKind: "codex_app_server", protocolVersion: 1 },
|
||||
provider: { kind: "codex", model: null },
|
||||
completionContract: {
|
||||
id: contractId,
|
||||
sha256: contractSha,
|
||||
|
||||
@@ -8,6 +8,19 @@ import { requireServerAdapter } from "./registry.js";
|
||||
// obey the same fail-closed contract as an external adapter.
|
||||
|
||||
describe("built-in adapter login capabilities", () => {
|
||||
it("publishes the qualified OpenCode runner configuration", async () => {
|
||||
const adapter = requireServerAdapter("paperclip_runner");
|
||||
expect(adapter.models).toContainEqual({
|
||||
id: "openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
label: "OpenRouter · DeepSeek V4 Flash 0731",
|
||||
});
|
||||
expect(adapter.getRuntimeCommandSpec?.({ provider: "opencode" }))
|
||||
.toMatchObject({ command: "opencode", installCommand: expect.stringContaining("opencode-ai@1.18.17") });
|
||||
const schema = await adapter.getConfigSchema?.();
|
||||
expect(schema?.fields.map((field) => field.key))
|
||||
.toEqual(expect.arrayContaining(["provider", "model", "command"]));
|
||||
});
|
||||
|
||||
it("registers the Codex device-login capability", () => {
|
||||
const capability = requireServerAdapter("codex_local").loginCapability;
|
||||
expect(capability).toBeDefined();
|
||||
|
||||
@@ -3,6 +3,8 @@ import type {
|
||||
AdapterRuntimeCommandSpec,
|
||||
ServerAdapterModule,
|
||||
} from "./types.js";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { parseAdapterModelsEnv } from "../services/adapter-models-env.js";
|
||||
import { stampClaudeAgentIdHeader } from "./claude-agent-id-header.js";
|
||||
import {
|
||||
@@ -133,6 +135,68 @@ import { getDisabledAdapterTypes } from "../services/adapter-plugin-store.js";
|
||||
import { processAdapter } from "./process/index.js";
|
||||
import { httpAdapter } from "./http/index.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
async function testPaperclipRunnerOpenCodeEnvironment(
|
||||
context: Parameters<typeof openCodeTestEnvironment>[0],
|
||||
) {
|
||||
const result = await openCodeTestEnvironment(context);
|
||||
if (context.executionTarget?.kind === "remote") {
|
||||
return {
|
||||
...result,
|
||||
status: result.status === "pass" ? "warn" as const : result.status,
|
||||
checks: [...result.checks, {
|
||||
code: "opencode_version_unverified_remote",
|
||||
level: "warn" as const,
|
||||
message: "OpenCode version could not be qualified by the local runner probe on a remote target.",
|
||||
}],
|
||||
};
|
||||
}
|
||||
const command = readConfiguredCommand(context.config, "opencode");
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync(command, ["--version"], {
|
||||
timeout: 5_000,
|
||||
env: process.env,
|
||||
});
|
||||
const output = `${stdout}\n${stderr}`;
|
||||
const match = output.match(/\b(\d+)\.(\d+)\.(\d+)\b/);
|
||||
if (!match) throw new Error("OpenCode returned an unrecognized version string");
|
||||
const version = `${match[1]}.${match[2]}.${match[3]}`;
|
||||
const comparison = compareSemver(version, "1.18.17");
|
||||
const check = comparison < 0
|
||||
? { code: "opencode_version_too_old", level: "error" as const, message: `OpenCode ${version} is older than required 1.18.17.` }
|
||||
: comparison > 0
|
||||
? { code: "opencode_version_unqualified", level: "warn" as const, message: `OpenCode ${version} is newer than qualified version 1.18.17.` }
|
||||
: { code: "opencode_version_qualified", level: "info" as const, message: "OpenCode 1.18.17 is installed and qualified." };
|
||||
return {
|
||||
...result,
|
||||
status: check.level === "error" ? "fail" as const
|
||||
: check.level === "warn" && result.status === "pass" ? "warn" as const
|
||||
: result.status,
|
||||
checks: [...result.checks, check],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
...result,
|
||||
status: "fail" as const,
|
||||
checks: [...result.checks, {
|
||||
code: "opencode_version_probe_failed",
|
||||
level: "error" as const,
|
||||
message: error instanceof Error ? error.message : "OpenCode version probe failed.",
|
||||
}],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function compareSemver(left: string, right: string): number {
|
||||
const a = left.split(".").map(Number);
|
||||
const b = right.split(".").map(Number);
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
if (a[index] !== b[index]) return (a[index] ?? 0) - (b[index] ?? 0);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function readConfiguredCommand(config: Record<string, unknown>, fallback: string): string {
|
||||
const value = typeof config.command === "string" ? config.command.trim() : "";
|
||||
return value.length > 0 ? value : fallback;
|
||||
@@ -345,13 +409,19 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
|
||||
};
|
||||
},
|
||||
async testEnvironment(context) {
|
||||
const result = await codexTestEnvironment(context);
|
||||
const provider = context.config.provider === "opencode" ? "opencode" : "codex";
|
||||
const result = provider === "opencode"
|
||||
? await testPaperclipRunnerOpenCodeEnvironment(context)
|
||||
: await codexTestEnvironment(context);
|
||||
return { ...result, adapterType: "paperclip_runner" };
|
||||
},
|
||||
listSkills: listCodexSkills,
|
||||
syncSkills: syncCodexSkills,
|
||||
sessionCodec: codexSessionCodec,
|
||||
models: codexModels,
|
||||
models: [
|
||||
...codexModels,
|
||||
{ id: "openrouter/deepseek/deepseek-v4-flash-0731", label: "OpenRouter · DeepSeek V4 Flash 0731" },
|
||||
],
|
||||
modelProfiles: codexModelProfiles,
|
||||
listModels: listCodexModels,
|
||||
refreshModels: refreshCodexModels,
|
||||
@@ -359,17 +429,47 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
|
||||
supportsInstructionsBundle: true,
|
||||
instructionsPathKey: "instructionsFilePath",
|
||||
requiresMaterializedRuntimeSkills: false,
|
||||
getRuntimeCommandSpec: (config) =>
|
||||
buildNpmRuntimeCommandSpec(config, "codex", "@openai/codex"),
|
||||
agentConfigurationDoc: `# Paperclip Runner\n\nAdapter: paperclip_runner\n\nRuns Codex through the Rust Paperclip runner and authenticated PRP control-plane connection.\n`,
|
||||
getRuntimeCommandSpec: (config) => config.provider === "opencode"
|
||||
? buildNpmRuntimeCommandSpec(config, "opencode", "opencode-ai@1.18.17")
|
||||
: buildNpmRuntimeCommandSpec(config, "codex", "@openai/codex"),
|
||||
agentConfigurationDoc: `# Paperclip Runner\n\nAdapter: paperclip_runner\n\nRuns Codex or OpenCode through the native Paperclip runner and authenticated PRP control-plane connection. OpenCode requires a provider/model identifier and runtime OPENROUTER_API_KEY.\n`,
|
||||
getConfigSchema: () => ({
|
||||
fields: [{
|
||||
key: "provider",
|
||||
label: "Provider",
|
||||
type: "select",
|
||||
default: "codex",
|
||||
options: [{ value: "codex", label: "Codex" }],
|
||||
hint: "Paperclip Runner currently supports the Codex app-server provider.",
|
||||
options: [{ value: "codex", label: "Codex" }, { value: "opencode", label: "OpenCode 1.18.17" }],
|
||||
hint: "Select the native provider. OpenCode runs through its authenticated loopback server.",
|
||||
}, {
|
||||
key: "lifecycleMode",
|
||||
label: "Runner lifecycle",
|
||||
type: "select",
|
||||
default: "per_turn",
|
||||
options: [
|
||||
{ value: "per_turn", label: "Turn by turn" },
|
||||
{ value: "warm", label: "Warm session" },
|
||||
],
|
||||
hint: "Warm sessions retain runnerd and the provider between separately governed runs.",
|
||||
}, {
|
||||
key: "idleTimeoutMs",
|
||||
label: "Warm idle timeout (ms)",
|
||||
type: "number",
|
||||
default: 300000,
|
||||
hint: "Warm sessions suspend resumably after this much inactivity.",
|
||||
}, {
|
||||
key: "model",
|
||||
label: "OpenCode model",
|
||||
type: "text",
|
||||
default: "openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
placeholder: "openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
hint: "Required when provider is OpenCode; use provider/model form.",
|
||||
}, {
|
||||
key: "command",
|
||||
label: "OpenCode command",
|
||||
type: "text",
|
||||
default: "opencode",
|
||||
hint: "OpenCode executable. Version 1.18.17 is qualified.",
|
||||
}],
|
||||
}),
|
||||
loginCapability: codexLoginCapability,
|
||||
|
||||
@@ -15941,6 +15941,19 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
branchName: executionWorkspace.branchName,
|
||||
},
|
||||
normalizedSessionId: nativeSessionId,
|
||||
provider: nativeRuntimeResolution.profile.backend === "opencode_server" ? "opencode" : "codex",
|
||||
model: typeof parseObject(agent.adapterConfig).model === "string"
|
||||
? String(parseObject(agent.adapterConfig).model)
|
||||
: null,
|
||||
lifecyclePolicy: parseObject(agent.adapterConfig).lifecycleMode === "warm"
|
||||
? {
|
||||
mode: "warm",
|
||||
idleTimeoutMs: Number.isSafeInteger(parseObject(agent.adapterConfig).idleTimeoutMs)
|
||||
&& Number(parseObject(agent.adapterConfig).idleTimeoutMs) > 0
|
||||
? Number(parseObject(agent.adapterConfig).idleTimeoutMs)
|
||||
: 300_000,
|
||||
}
|
||||
: { mode: "per_turn", idleTimeoutMs: null },
|
||||
interactionResponses,
|
||||
completionContract: {
|
||||
id: completionContract.row.id,
|
||||
@@ -15970,7 +15983,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
},
|
||||
runnerInstanceId: lockedRun.runnerInstanceId ?? nativeRunnerInstanceId,
|
||||
nativeSessionId: lockedRun.nativeSessionId ?? nativeSessionId,
|
||||
driverKind: lockedRun.driverKind ?? "codex_app_server",
|
||||
driverKind: lockedRun.driverKind ?? nativeExecution?.session.driverKind ?? "codex_app_server",
|
||||
driverVersion: lockedRun.driverVersion ?? "phase6-v1",
|
||||
completionContractId: lockedRun.completionContractId ?? completionContract.row.id,
|
||||
completionContractSha256: lockedRun.completionContractSha256 ?? completionContract.row.canonicalSha256,
|
||||
|
||||
@@ -26,6 +26,9 @@ export function buildNativeExecutionInput(input: {
|
||||
branchName: string | null;
|
||||
};
|
||||
normalizedSessionId: string | null;
|
||||
provider?: "codex" | "opencode";
|
||||
model?: string | null;
|
||||
lifecyclePolicy?: NativeExecutionInputV1["session"]["lifecyclePolicy"];
|
||||
interactionResponses?: NativeInteractionResponseEnvelope[];
|
||||
completionContract: {
|
||||
id: string;
|
||||
@@ -61,8 +64,13 @@ export function buildNativeExecutionInput(input: {
|
||||
},
|
||||
session: {
|
||||
normalizedSessionId: input.normalizedSessionId,
|
||||
driverKind: "codex_app_server",
|
||||
driverKind: input.provider === "opencode" ? "opencode_server" : "codex_app_server",
|
||||
protocolVersion: 1,
|
||||
lifecyclePolicy: input.lifecyclePolicy ?? { mode: "per_turn", idleTimeoutMs: null },
|
||||
},
|
||||
provider: {
|
||||
kind: input.provider ?? "codex",
|
||||
model: input.model ?? null,
|
||||
},
|
||||
completionContract: input.completionContract,
|
||||
interactionResponses: input.interactionResponses ?? [],
|
||||
|
||||
@@ -23,7 +23,7 @@ const state = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
vi.mock("@paperclipai/paperclip-runner", () => ({
|
||||
createCodexNativeSessionBackend: state.createBackend,
|
||||
createNativeSessionBackend: state.createBackend,
|
||||
executeNativeSession: state.execute,
|
||||
}));
|
||||
|
||||
@@ -31,9 +31,11 @@ import {
|
||||
cancelNativeSession,
|
||||
executePaperclipNativeSession,
|
||||
nativeSessionFailureDisposition,
|
||||
nativeSessionFailureSourceCode,
|
||||
} from "./native-session-executor.js";
|
||||
|
||||
const execution = {
|
||||
provider: { kind: "codex", model: null },
|
||||
binding: {
|
||||
companyId: "company",
|
||||
runId: "run-native-cancel",
|
||||
@@ -43,11 +45,11 @@ const execution = {
|
||||
completionContract: { id: "contract", sha256: "sha" },
|
||||
} as NativeExecutionInputV1;
|
||||
|
||||
function leaseDb(): Db {
|
||||
function leaseDb(boundExecution: NativeExecutionInputV1 = execution): Db {
|
||||
const coordinator = {
|
||||
runId: execution.binding.runId,
|
||||
companyId: execution.binding.companyId,
|
||||
issueId: execution.binding.issueId,
|
||||
runId: boundExecution.binding.runId,
|
||||
companyId: boundExecution.binding.companyId,
|
||||
issueId: boundExecution.binding.issueId,
|
||||
phase: "observed",
|
||||
attempt: 0,
|
||||
leaseOwner: null,
|
||||
@@ -135,7 +137,73 @@ describe("native session cancellation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("native warm session supervision", () => {
|
||||
it("reuses one session across distinct governed runs and closes it after idle expiry", async () => {
|
||||
const close = vi.fn(async () => undefined);
|
||||
const sharedSession = { close };
|
||||
const base = {
|
||||
...execution,
|
||||
binding: {
|
||||
...execution.binding,
|
||||
executionWorkspaceId: "workspace",
|
||||
},
|
||||
workspace: { cwd: "/tmp/warm-native", repoUrl: null, repoRef: null, branchName: null },
|
||||
session: {
|
||||
normalizedSessionId: "session-warm-native",
|
||||
driverKind: "codex_app_server" as const,
|
||||
protocolVersion: 1 as const,
|
||||
lifecyclePolicy: { mode: "warm" as const, idleTimeoutMs: 20 },
|
||||
},
|
||||
} as NativeExecutionInputV1;
|
||||
const second = {
|
||||
...base,
|
||||
binding: { ...base.binding, runId: "run-native-warm-second" },
|
||||
};
|
||||
const result = {
|
||||
result: { summary: "completed" },
|
||||
terminal: { runTerminalState: "succeeded" },
|
||||
turnId: "turn",
|
||||
normalizedSessionId: "session-warm-native",
|
||||
providerSessionId: "provider-warm-native",
|
||||
driverKind: "test",
|
||||
driverVersion: "1",
|
||||
nativeEventCount: 1,
|
||||
highestContiguousSourceSeq: 1,
|
||||
usage: null,
|
||||
};
|
||||
state.execute.mockReset()
|
||||
.mockImplementationOnce(async (options) => {
|
||||
expect(options.existingSession).toBeUndefined();
|
||||
options.onSession?.(sharedSession);
|
||||
return result;
|
||||
})
|
||||
.mockImplementationOnce(async (options) => {
|
||||
expect(options.existingSession).toBe(sharedSession);
|
||||
return result;
|
||||
});
|
||||
|
||||
await executePaperclipNativeSession({ db: leaseDb(base), execution: base, runnerInstanceId: "runner" });
|
||||
await executePaperclipNativeSession({ db: leaseDb(second), execution: second, runnerInstanceId: "runner" });
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
await vi.waitFor(() => expect(close).toHaveBeenCalledWith({
|
||||
reason: "warm native session idle timeout",
|
||||
}), { timeout: 500 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("native session bounded recovery", () => {
|
||||
it("preserves stable provider and runner failure causes", () => {
|
||||
expect(nativeSessionFailureSourceCode(new Error(
|
||||
"provider_frame_too_large: harness stdout frame exceeded 4194304 bytes",
|
||||
))).toBe("provider_frame_too_large");
|
||||
expect(nativeSessionFailureSourceCode(new Error(
|
||||
"native_runner_process_exited: runnerd exited unexpectedly with code 1",
|
||||
))).toBe("native_runner_process_exited");
|
||||
expect(nativeSessionFailureSourceCode(new Error(
|
||||
"provider_transport_failed: invalid JSON-RPC",
|
||||
))).toBe("provider_transport_failed");
|
||||
});
|
||||
|
||||
it("retries the same run twice and stops at the third failed attempt", () => {
|
||||
const now = new Date("2026-08-09T00:00:00.000Z");
|
||||
expect(nativeSessionFailureDisposition(1, now)).toEqual({
|
||||
@@ -191,10 +259,10 @@ describe("native process ownership", () => {
|
||||
onSpawn,
|
||||
});
|
||||
|
||||
expect(state.createBackend).toHaveBeenCalledWith(execution, {
|
||||
expect(state.createBackend).toHaveBeenCalledWith(execution, expect.objectContaining({
|
||||
runnerInstanceId: "runner",
|
||||
onSpawn,
|
||||
});
|
||||
}));
|
||||
expect(onSpawn).toHaveBeenCalledWith(processMetadata);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { resolve } from "node:path";
|
||||
import type { AdapterExecutionResult } from "../../adapters/index.js";
|
||||
@@ -8,9 +8,10 @@ import type {
|
||||
NativeExecutionInputV1,
|
||||
NativeSession,
|
||||
NativeSessionBackend,
|
||||
PersistedNativeSession,
|
||||
} from "../../vendor/paperclip-runner/index.js";
|
||||
import {
|
||||
createCodexNativeSessionBackend,
|
||||
createNativeSessionBackend,
|
||||
createRunnerdCodexTransport,
|
||||
executeNativeSession,
|
||||
} from "../../vendor/paperclip-runner/index.js";
|
||||
@@ -23,6 +24,7 @@ import { registerRunnerPrpAuthority } from "../../realtime/runner-prp-ws.js";
|
||||
import { issueRecoveryActionService } from "../issue-recovery-actions.js";
|
||||
import { persistActivity, publishActivity } from "../activity-log.js";
|
||||
import { commitNativeStatusDecision } from "./status-decision-committer.js";
|
||||
import { resolvePaperclipInstanceRoot } from "../../home-paths.js";
|
||||
import {
|
||||
NATIVE_STATUS_ARBITER_POLICY_VERSION,
|
||||
type NativeAuthoritativeIssueStatus,
|
||||
@@ -36,6 +38,134 @@ type ActiveNativeSession = {
|
||||
|
||||
const activeNativeSessions = new Map<string, ActiveNativeSession>();
|
||||
|
||||
type WarmNativeSession = {
|
||||
session: NativeSession;
|
||||
configDigest: string;
|
||||
busy: boolean;
|
||||
idleTimer: ReturnType<typeof setTimeout> | null;
|
||||
lastActivityAt: string;
|
||||
};
|
||||
|
||||
const warmNativeSessions = new Map<string, WarmNativeSession>();
|
||||
|
||||
class SessionToolAuthorityRouter {
|
||||
#authority: PaperclipRunnerToolAuthority;
|
||||
|
||||
constructor(authority: PaperclipRunnerToolAuthority) {
|
||||
this.#authority = authority;
|
||||
}
|
||||
|
||||
bind(authority: PaperclipRunnerToolAuthority): void {
|
||||
this.#authority = authority;
|
||||
}
|
||||
|
||||
definitions() {
|
||||
return this.#authority.definitions();
|
||||
}
|
||||
|
||||
execute(call: Parameters<PaperclipRunnerToolAuthority["execute"]>[0]) {
|
||||
return this.#authority.execute(call);
|
||||
}
|
||||
}
|
||||
|
||||
const sessionToolRouters = new Map<string, SessionToolAuthorityRouter>();
|
||||
|
||||
function nativeSessionKey(execution: NativeExecutionInputV1): string {
|
||||
return execution.session.normalizedSessionId ?? `session-${execution.binding.runId}`;
|
||||
}
|
||||
|
||||
function nativeSessionConfigDigest(execution: NativeExecutionInputV1): string {
|
||||
return `sha256:${createHash("sha256").update(JSON.stringify({
|
||||
companyId: execution.binding.companyId,
|
||||
issueId: execution.binding.issueId,
|
||||
agentId: execution.binding.agentId,
|
||||
workspaceId: execution.binding.executionWorkspaceId,
|
||||
cwd: execution.workspace.cwd,
|
||||
provider: execution.provider,
|
||||
driverKind: execution.session.driverKind,
|
||||
lifecyclePolicy: execution.session.lifecyclePolicy,
|
||||
})).digest("hex")}`;
|
||||
}
|
||||
|
||||
function nativeSessionCheckpointPath(sessionId: string): string {
|
||||
const directory = resolve(resolvePaperclipInstanceRoot(), "runtime", "paperclip-runner", "sessions");
|
||||
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
||||
chmodSync(directory, 0o700);
|
||||
return resolve(directory, `${createHash("sha256").update(sessionId).digest("hex")}.json`);
|
||||
}
|
||||
|
||||
function persistWarmNativeCheckpoint(
|
||||
sessionId: string,
|
||||
configDigest: string,
|
||||
snapshot: PersistedNativeSession,
|
||||
): void {
|
||||
const path = nativeSessionCheckpointPath(sessionId);
|
||||
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
||||
writeFileSync(temporary, JSON.stringify({
|
||||
schema: "paperclip.native-session-supervisor.v1",
|
||||
configDigest,
|
||||
updatedAt: new Date().toISOString(),
|
||||
snapshot,
|
||||
}), { encoding: "utf8", mode: 0o600 });
|
||||
renameSync(temporary, path);
|
||||
chmodSync(path, 0o600);
|
||||
}
|
||||
|
||||
function loadWarmNativeCheckpoint(
|
||||
execution: NativeExecutionInputV1,
|
||||
configDigest: string,
|
||||
): PersistedNativeSession | null {
|
||||
const path = nativeSessionCheckpointPath(nativeSessionKey(execution));
|
||||
if (!existsSync(path)) return null;
|
||||
const envelope = JSON.parse(readFileSync(path, "utf8")) as {
|
||||
schema?: string;
|
||||
configDigest?: string;
|
||||
snapshot?: PersistedNativeSession;
|
||||
};
|
||||
if (envelope.schema !== "paperclip.native-session-supervisor.v1" || envelope.configDigest !== configDigest || !envelope.snapshot) {
|
||||
throw new Error("native_session_supervisor_checkpoint_mismatch");
|
||||
}
|
||||
return {
|
||||
...envelope.snapshot,
|
||||
identity: {
|
||||
runId: execution.binding.runId,
|
||||
sessionId: nativeSessionKey(execution),
|
||||
companyId: execution.binding.companyId,
|
||||
issueId: execution.binding.issueId,
|
||||
agentId: execution.binding.agentId,
|
||||
},
|
||||
semanticResult: null,
|
||||
terminal: null,
|
||||
activeTurnId: null,
|
||||
terminalTurns: [],
|
||||
pendingRuntimeRequests: [],
|
||||
};
|
||||
}
|
||||
|
||||
async function releaseWarmNativeSession(
|
||||
sessionId: string,
|
||||
idleTimeoutMs: number,
|
||||
failed: boolean,
|
||||
): Promise<void> {
|
||||
const entry = warmNativeSessions.get(sessionId);
|
||||
if (!entry) return;
|
||||
entry.busy = false;
|
||||
entry.lastActivityAt = new Date().toISOString();
|
||||
if (entry.idleTimer !== null) clearTimeout(entry.idleTimer);
|
||||
if (failed) {
|
||||
warmNativeSessions.delete(sessionId);
|
||||
await entry.session.close({ reason: "warm native session failed" }).catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
entry.idleTimer = setTimeout(() => {
|
||||
const current = warmNativeSessions.get(sessionId);
|
||||
if (!current || current.busy) return;
|
||||
warmNativeSessions.delete(sessionId);
|
||||
void current.session.close({ reason: "warm native session idle timeout" });
|
||||
}, idleTimeoutMs);
|
||||
entry.idleTimer.unref();
|
||||
}
|
||||
|
||||
export function nativeSessionFailureDisposition(attempt: number, now = new Date()) {
|
||||
const exhausted = attempt >= 3;
|
||||
return {
|
||||
@@ -45,6 +175,24 @@ export function nativeSessionFailureDisposition(attempt: number, now = new Date(
|
||||
};
|
||||
}
|
||||
|
||||
export function nativeSessionFailureSourceCode(error: unknown):
|
||||
| "provider_frame_too_large"
|
||||
| "provider_transport_failed"
|
||||
| "native_runner_process_exited"
|
||||
| "native_session_interrupted" {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (/provider_frame_too_large|stdout frame exceeded/i.test(message)) {
|
||||
return "provider_frame_too_large";
|
||||
}
|
||||
if (/provider_transport_failed|invalid JSON-RPC|provider failed/i.test(message)) {
|
||||
return "provider_transport_failed";
|
||||
}
|
||||
if (/native_runner_process_exited|runnerd exited|runner process failed/i.test(message)) {
|
||||
return "native_runner_process_exited";
|
||||
}
|
||||
return "native_session_interrupted";
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
@@ -301,6 +449,25 @@ export async function executePaperclipNativeSession(input: {
|
||||
: undefined,
|
||||
});
|
||||
let native: Awaited<ReturnType<typeof executeNativeSession>>;
|
||||
const lifecyclePolicy = input.execution.session?.lifecyclePolicy
|
||||
?? { mode: "per_turn" as const, idleTimeoutMs: null };
|
||||
const warmSessionId = lifecyclePolicy.mode === "warm" ? nativeSessionKey(input.execution) : null;
|
||||
const warmConfigDigest = lifecyclePolicy.mode === "warm" ? nativeSessionConfigDigest(input.execution) : null;
|
||||
let existingWarmSession: NativeSession | undefined;
|
||||
let persistedWarmSession: PersistedNativeSession | null | undefined;
|
||||
if (warmSessionId !== null && warmConfigDigest !== null) {
|
||||
const entry = warmNativeSessions.get(warmSessionId);
|
||||
if (entry) {
|
||||
if (entry.configDigest !== warmConfigDigest) throw new Error("native_session_supervisor_config_mismatch");
|
||||
if (entry.busy) throw new Error("native_session_supervisor_busy");
|
||||
entry.busy = true;
|
||||
if (entry.idleTimer !== null) clearTimeout(entry.idleTimer);
|
||||
entry.idleTimer = null;
|
||||
existingWarmSession = entry.session;
|
||||
} else {
|
||||
persistedWarmSession = loadWarmNativeCheckpoint(input.execution, warmConfigDigest);
|
||||
}
|
||||
}
|
||||
try {
|
||||
const runnerdBackend = input.useRunnerd && input.backend === undefined
|
||||
? createRunnerdBackend(input)
|
||||
@@ -309,14 +476,33 @@ export async function executePaperclipNativeSession(input: {
|
||||
input: input.execution,
|
||||
backend: input.backend
|
||||
?? runnerdBackend
|
||||
?? createCodexNativeSessionBackend(input.execution, {
|
||||
?? createNativeSessionBackend(input.execution, {
|
||||
runnerInstanceId: input.runnerInstanceId,
|
||||
onSpawn: input.onSpawn,
|
||||
environment: process.env,
|
||||
opencodeRuntimeDirectory: resolve(resolvePaperclipInstanceRoot(), "runtime", "paperclip-runner", "opencode"),
|
||||
}),
|
||||
controlPlane,
|
||||
runnerInstanceId: input.runnerInstanceId,
|
||||
controlPlaneInstanceId,
|
||||
existingSession: existingWarmSession,
|
||||
persistedSession: persistedWarmSession,
|
||||
keepSessionOpen: warmSessionId !== null,
|
||||
onCheckpoint: warmSessionId !== null && warmConfigDigest !== null
|
||||
? async (snapshot) => persistWarmNativeCheckpoint(warmSessionId, warmConfigDigest, snapshot)
|
||||
: undefined,
|
||||
onSession: (session) => {
|
||||
if (session && warmSessionId !== null && warmConfigDigest !== null) {
|
||||
const existing = warmNativeSessions.get(warmSessionId);
|
||||
if (existing) existing.session = session;
|
||||
else warmNativeSessions.set(warmSessionId, {
|
||||
session,
|
||||
configDigest: warmConfigDigest,
|
||||
busy: true,
|
||||
idleTimer: null,
|
||||
lastActivityAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
if (session) activeNativeSessions.set(input.execution.binding.runId, {
|
||||
session,
|
||||
cancelRequested: false,
|
||||
@@ -324,11 +510,17 @@ export async function executePaperclipNativeSession(input: {
|
||||
else activeNativeSessions.delete(input.execution.binding.runId);
|
||||
},
|
||||
});
|
||||
activeNativeSessions.delete(input.execution.binding.runId);
|
||||
} catch (error) {
|
||||
activeNativeSessions.delete(input.execution.binding.runId);
|
||||
if (warmSessionId !== null && lifecyclePolicy.mode === "warm") {
|
||||
await releaseWarmNativeSession(warmSessionId, lifecyclePolicy.idleTimeoutMs, true);
|
||||
}
|
||||
const now = new Date();
|
||||
const { phase, failureCode, nextAttemptAt } = nativeSessionFailureDisposition(attempt, now);
|
||||
const exhausted = phase === "terminal_failure";
|
||||
const message = error instanceof Error ? error.message.slice(0, 2_000) : String(error).slice(0, 2_000);
|
||||
const sourceFailureCode = nativeSessionFailureSourceCode(error);
|
||||
await input.db.transaction(async (tx) => {
|
||||
const updated = await tx.update(nativeRunFinalizations).set({
|
||||
phase,
|
||||
@@ -337,7 +529,7 @@ export async function executePaperclipNativeSession(input: {
|
||||
failureCode,
|
||||
failureDetail: {
|
||||
message,
|
||||
originalFailureCode: "native_session_interrupted",
|
||||
originalFailureCode: sourceFailureCode,
|
||||
recoveryOwner: { kind: "agent", agentId: input.execution.binding.agentId },
|
||||
nextAction: exhausted
|
||||
? "Inspect the persisted native session after its bounded resume budget was exhausted."
|
||||
@@ -354,7 +546,7 @@ export async function executePaperclipNativeSession(input: {
|
||||
nativePhase: phase,
|
||||
nativePhaseUpdatedAt: now,
|
||||
error: message,
|
||||
errorCode: failureCode,
|
||||
errorCode: sourceFailureCode,
|
||||
updatedAt: now,
|
||||
}).where(eq(heartbeatRuns.id, input.execution.binding.runId));
|
||||
await issueRecoveryActionService(tx as unknown as Db).upsertSourceScoped({
|
||||
@@ -363,11 +555,16 @@ export async function executePaperclipNativeSession(input: {
|
||||
kind: "active_run_watchdog",
|
||||
ownerType: "agent",
|
||||
ownerAgentId: input.execution.binding.agentId,
|
||||
cause: failureCode,
|
||||
cause: sourceFailureCode,
|
||||
fingerprint: createHash("sha256")
|
||||
.update(`${input.execution.binding.runId}:${failureCode}`)
|
||||
.update(`${input.execution.binding.runId}:${sourceFailureCode}`)
|
||||
.digest("hex"),
|
||||
evidence: { runId: input.execution.binding.runId, coordinatorAttempt: attempt },
|
||||
evidence: {
|
||||
runId: input.execution.binding.runId,
|
||||
coordinatorAttempt: attempt,
|
||||
sourceFailureCode,
|
||||
recoveryDisposition: failureCode,
|
||||
},
|
||||
nextAction: exhausted
|
||||
? "Inspect or explicitly restart the exhausted persisted native session."
|
||||
: "Resume the persisted native session on the same heartbeat run.",
|
||||
@@ -405,6 +602,11 @@ export async function executePaperclipNativeSession(input: {
|
||||
highestContiguousSourceSeq: native.highestContiguousSourceSeq,
|
||||
workspaceFinalizeStatus: "pending",
|
||||
};
|
||||
// A following run cannot attach until the prior run's durable finalization
|
||||
// is committed. Provider completion alone is not an authority boundary.
|
||||
if (warmSessionId !== null && lifecyclePolicy.mode === "warm") {
|
||||
await releaseWarmNativeSession(warmSessionId, lifecyclePolicy.idleTimeoutMs, false);
|
||||
}
|
||||
return {
|
||||
exitCode: native.terminal.runTerminalState === "succeeded" ? 0 : 1,
|
||||
signal: null,
|
||||
@@ -419,12 +621,41 @@ export async function executePaperclipNativeSession(input: {
|
||||
summary: native.result.summary,
|
||||
sessionId: native.normalizedSessionId,
|
||||
sessionDisplayId: native.providerSessionId ?? native.normalizedSessionId,
|
||||
provider: "openai",
|
||||
provider: input.execution.provider.kind === "opencode"
|
||||
? input.execution.provider.model?.split("/", 1)[0] ?? "opencode"
|
||||
: "openai",
|
||||
model: input.execution.provider.model,
|
||||
usage: normalizeNativeUsage(native.usage),
|
||||
costUsd: numericUsageField(native.usage, ["costUsd", "cost"]),
|
||||
usageBasis: "per_run",
|
||||
nativeFinalization: finalization,
|
||||
};
|
||||
}
|
||||
|
||||
function numericUsageField(
|
||||
usage: Record<string, unknown> | null,
|
||||
keys: string[],
|
||||
): number | undefined {
|
||||
if (!usage) return undefined;
|
||||
for (const key of keys) {
|
||||
const value = usage[key];
|
||||
if (typeof value === "number" && Number.isFinite(value) && value >= 0) return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeNativeUsage(usage: Record<string, unknown> | null) {
|
||||
if (!usage) return undefined;
|
||||
const cache = record(usage.cache);
|
||||
const cachedInputTokens = numericUsageField(usage, ["cachedInputTokens", "cacheReadInputTokens"])
|
||||
?? numericUsageField(cache, ["read"]);
|
||||
return {
|
||||
inputTokens: numericUsageField(usage, ["inputTokens", "input", "promptTokens"]) ?? 0,
|
||||
outputTokens: numericUsageField(usage, ["outputTokens", "output", "completionTokens"]) ?? 0,
|
||||
...(cachedInputTokens === undefined ? {} : { cachedInputTokens }),
|
||||
};
|
||||
}
|
||||
|
||||
function createRunnerdBackend(input: {
|
||||
db: Db;
|
||||
execution: NativeExecutionInputV1;
|
||||
@@ -437,19 +668,28 @@ function createRunnerdBackend(input: {
|
||||
runId: input.execution.binding.runId,
|
||||
agentId: input.execution.binding.agentId,
|
||||
});
|
||||
const sessionId = nativeSessionKey(input.execution);
|
||||
const existingRouter = sessionToolRouters.get(sessionId);
|
||||
const authorityRouter = existingRouter ?? new SessionToolAuthorityRouter(authority);
|
||||
authorityRouter.bind(authority);
|
||||
sessionToolRouters.set(sessionId, authorityRouter);
|
||||
const root = resolve(
|
||||
process.env.PAPERCLIP_RUNNER_STATE_DIR ?? resolve(tmpdir(), "paperclip-runner"),
|
||||
input.execution.binding.runId,
|
||||
createHash("sha256").update(sessionId).digest("hex"),
|
||||
);
|
||||
mkdirSync(root, { recursive: true, mode: 0o700 });
|
||||
return createCodexNativeSessionBackend(input.execution, {
|
||||
return createNativeSessionBackend(input.execution, {
|
||||
runnerInstanceId: input.runnerInstanceId,
|
||||
onSpawn: input.onSpawn,
|
||||
dynamicTools: authority.definitions(),
|
||||
dynamicToolHandler: (call) => authority.execute(call),
|
||||
transportFactory: () => createRunnerdCodexTransport({
|
||||
dynamicTools: authorityRouter.definitions(),
|
||||
dynamicToolHandler: (call) => authorityRouter.execute(call),
|
||||
environment: process.env,
|
||||
opencodeRuntimeDirectory: resolve(resolvePaperclipInstanceRoot(), "runtime", "paperclip-runner", "opencode"),
|
||||
codexTransportFactory: () => createRunnerdCodexTransport({
|
||||
stateDirectory: root,
|
||||
environment: process.env,
|
||||
lifecyclePolicy: input.execution.session.lifecyclePolicy,
|
||||
resumeDynamicTools: authorityRouter.definitions(),
|
||||
prpIdentity: {
|
||||
runnerInstanceId: input.runnerInstanceId,
|
||||
environmentLeaseId: input.execution.binding.executionWorkspaceId,
|
||||
|
||||
@@ -35,7 +35,24 @@ describe("resolveNativeRuntimeMode", () => {
|
||||
runtimeConfig: {},
|
||||
adapterConfig: { provider: "claude" },
|
||||
agent: { ...eligible.agent, adapterType: "paperclip_runner" },
|
||||
})).toThrow(/provider must be codex/);
|
||||
})).toThrow(/provider must be codex or opencode/);
|
||||
});
|
||||
|
||||
it("selects OpenCode only with a provider/model value", () => {
|
||||
expect(resolveNativeRuntimeMode({
|
||||
...eligible,
|
||||
runtimeConfig: {},
|
||||
adapterConfig: { provider: "opencode", model: "openrouter/deepseek/deepseek-v4-flash-0731" },
|
||||
agent: { ...eligible.agent, adapterType: "paperclip_runner" },
|
||||
})).toEqual(expect.objectContaining({
|
||||
profile: { mode: "native", backend: "opencode_server", protocolVersion: 1 },
|
||||
}));
|
||||
expect(() => resolveNativeRuntimeMode({
|
||||
...eligible,
|
||||
runtimeConfig: {},
|
||||
adapterConfig: { provider: "opencode", model: "deepseek" },
|
||||
agent: { ...eligible.agent, adapterType: "paperclip_runner" },
|
||||
})).toThrow(/provider\/model/);
|
||||
});
|
||||
|
||||
it("preserves legacy as the default and as the kill-switch behavior", () => {
|
||||
@@ -74,6 +91,22 @@ describe("resolveNativeRuntimeMode", () => {
|
||||
expect(disabled.runtimeConfig.nativeRunner.mode).toBe("native");
|
||||
});
|
||||
|
||||
it("keeps a persisted OpenCode recovery on its immutable driver", () => {
|
||||
expect(resolveHeartbeatNativeRuntimeMode({
|
||||
...eligible,
|
||||
enabled: false,
|
||||
adapterConfig: { provider: "codex" },
|
||||
persisted: {
|
||||
runtimeMode: "native",
|
||||
runtimeModeReason: "eligible_opt_in",
|
||||
runtimeModeResolvedAt: new Date(),
|
||||
driverKind: "opencode_server",
|
||||
},
|
||||
})).toEqual(expect.objectContaining({
|
||||
profile: { mode: "native", backend: "opencode_server", protocolVersion: 1 },
|
||||
}));
|
||||
});
|
||||
|
||||
it("rejects an explicit native profile outside the approved boundary", () => {
|
||||
expect(() => resolveNativeRuntimeMode({ ...eligible, agent: { ...eligible.agent, adapterType: "claude_local" } }))
|
||||
.toThrow(NativeRuntimeEligibilityError);
|
||||
|
||||
@@ -17,7 +17,7 @@ export type NativeRuntimeResolution =
|
||||
kind: "native";
|
||||
resolverVersion: typeof NATIVE_RUNTIME_RESOLVER_VERSION;
|
||||
reason: "eligible_opt_in";
|
||||
profile: { mode: "native"; backend: "codex_app_server"; protocolVersion: 1 };
|
||||
profile: { mode: "native"; backend: "codex_app_server" | "opencode_server"; protocolVersion: 1 };
|
||||
authorityDecision: NativeStatusDecision;
|
||||
};
|
||||
|
||||
@@ -47,8 +47,14 @@ export function resolveNativeRuntimeMode(input: {
|
||||
const nativeRunner = record(record(input.runtimeConfig).nativeRunner);
|
||||
const runnerAdapterSelected = input.agent.adapterType === "paperclip_runner";
|
||||
const runnerProvider = record(input.adapterConfig).provider ?? "codex";
|
||||
if (runnerAdapterSelected && runnerProvider !== "codex") {
|
||||
throw new NativeRuntimeEligibilityError("paperclip_runner provider must be codex");
|
||||
if (runnerAdapterSelected && runnerProvider !== "codex" && runnerProvider !== "opencode") {
|
||||
throw new NativeRuntimeEligibilityError("paperclip_runner provider must be codex or opencode");
|
||||
}
|
||||
if (runnerAdapterSelected && runnerProvider === "opencode") {
|
||||
const model = record(input.adapterConfig).model;
|
||||
if (typeof model !== "string" || !model.includes("/") || model.trim().endsWith("/")) {
|
||||
throw new NativeRuntimeEligibilityError("paperclip_runner OpenCode provider requires model in provider/model form");
|
||||
}
|
||||
}
|
||||
const mode = runnerAdapterSelected ? "native" : nativeRunner.mode;
|
||||
if (!runnerAdapterSelected && !input.enabled) {
|
||||
@@ -101,7 +107,11 @@ export function resolveNativeRuntimeMode(input: {
|
||||
kind: "native",
|
||||
resolverVersion: NATIVE_RUNTIME_RESOLVER_VERSION,
|
||||
reason: "eligible_opt_in",
|
||||
profile: { mode: "native", backend: "codex_app_server", protocolVersion: 1 },
|
||||
profile: {
|
||||
mode: "native",
|
||||
backend: runnerProvider === "opencode" ? "opencode_server" : "codex_app_server",
|
||||
protocolVersion: 1,
|
||||
},
|
||||
authorityDecision: rollout,
|
||||
};
|
||||
}
|
||||
@@ -116,6 +126,7 @@ export function resolveHeartbeatNativeRuntimeMode(input: {
|
||||
runtimeMode: string | null;
|
||||
runtimeModeReason: string | null;
|
||||
runtimeModeResolvedAt: Date | null;
|
||||
driverKind?: string | null;
|
||||
};
|
||||
enabled: boolean;
|
||||
runtimeConfig: unknown;
|
||||
@@ -131,7 +142,11 @@ export function resolveHeartbeatNativeRuntimeMode(input: {
|
||||
kind: "native",
|
||||
resolverVersion: NATIVE_RUNTIME_RESOLVER_VERSION,
|
||||
reason: "eligible_opt_in",
|
||||
profile: { mode: "native", backend: "codex_app_server", protocolVersion: 1 },
|
||||
profile: {
|
||||
mode: "native",
|
||||
backend: input.persisted.driverKind === "opencode_server" ? "opencode_server" : "codex_app_server",
|
||||
protocolVersion: 1,
|
||||
},
|
||||
authorityDecision: resolveNativeMigrationStatus({
|
||||
facts: input.enabled
|
||||
? { applicationEnabled: true }
|
||||
|
||||
@@ -32,6 +32,17 @@ export function CodexLocalConfigFields({
|
||||
hideInstructionsFile,
|
||||
}: AdapterConfigFieldsProps) {
|
||||
const runnerManaged = adapterType === "paperclip_runner";
|
||||
const runnerProvider = runnerManaged
|
||||
? isCreate
|
||||
? values!.paperclipRunnerProvider ?? "codex"
|
||||
: eff("adapterConfig", "provider", String(config.provider ?? "codex"))
|
||||
: "codex";
|
||||
const openCodeRunner = runnerManaged && runnerProvider === "opencode";
|
||||
const runnerLifecycleMode = runnerManaged
|
||||
? isCreate
|
||||
? values!.paperclipRunnerLifecycleMode ?? "per_turn"
|
||||
: eff("adapterConfig", "lifecycleMode", String(config.lifecycleMode ?? "per_turn"))
|
||||
: "per_turn";
|
||||
const rawEngine = runnerManaged ? "cli" : isCreate
|
||||
? values!.codexEngine ?? "auto"
|
||||
: eff("adapterConfig", "engine", String(config.engine ?? "auto"));
|
||||
@@ -73,12 +84,88 @@ export function CodexLocalConfigFields({
|
||||
</select>
|
||||
</Field>}
|
||||
{runnerManaged && (
|
||||
<Field label="Provider" hint="Paperclip Runner currently supports Codex through its app-server protocol.">
|
||||
<select className={inputClass} value="codex" disabled>
|
||||
<Field label="Provider" hint="The runner persists this provider with each run so recovery cannot drift after configuration changes.">
|
||||
<select
|
||||
className={inputClass}
|
||||
value={runnerProvider}
|
||||
onChange={(event) => {
|
||||
const provider = event.target.value === "opencode" ? "opencode" : "codex";
|
||||
if (isCreate) {
|
||||
set!({
|
||||
paperclipRunnerProvider: provider,
|
||||
...(provider === "opencode" && !values!.model
|
||||
? { model: "openrouter/deepseek/deepseek-v4-flash-0731" }
|
||||
: {}),
|
||||
});
|
||||
} else {
|
||||
mark("adapterConfig", "provider", provider);
|
||||
if (provider === "opencode" && !String(config.model ?? "")) {
|
||||
mark("adapterConfig", "model", "openrouter/deepseek/deepseek-v4-flash-0731");
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="codex">Codex</option>
|
||||
<option value="opencode">OpenCode 1.18.17</option>
|
||||
</select>
|
||||
</Field>
|
||||
)}
|
||||
{runnerManaged && (
|
||||
<Field label="Runner lifecycle" hint="Turn by turn suspends after each run. Warm keeps the same provider process available between governed runs.">
|
||||
<select
|
||||
className={inputClass}
|
||||
value={runnerLifecycleMode}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value === "warm" ? "warm" : "per_turn";
|
||||
isCreate
|
||||
? set!({ paperclipRunnerLifecycleMode: value })
|
||||
: mark("adapterConfig", "lifecycleMode", value);
|
||||
}}
|
||||
>
|
||||
<option value="per_turn">Turn by turn</option>
|
||||
<option value="warm">Warm session</option>
|
||||
</select>
|
||||
</Field>
|
||||
)}
|
||||
{runnerManaged && runnerLifecycleMode === "warm" && (
|
||||
<Field label="Warm idle timeout (ms)" hint="After this much inactivity, runnerd checkpoints and suspends the provider session.">
|
||||
{isCreate ? (
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
className={inputClass}
|
||||
value={values!.paperclipRunnerIdleTimeoutMs ?? 300_000}
|
||||
onChange={(event) => set!({ paperclipRunnerIdleTimeoutMs: Math.max(1, Number(event.target.value)) })}
|
||||
/>
|
||||
) : (
|
||||
<DraftNumberInput
|
||||
value={eff("adapterConfig", "idleTimeoutMs", Number(config.idleTimeoutMs ?? 300_000))}
|
||||
onCommit={(value) => mark("adapterConfig", "idleTimeoutMs", Math.max(1, value || 300_000))}
|
||||
immediate
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
{openCodeRunner && (
|
||||
<Field label="OpenCode command" hint="The OpenCode executable. Version 1.18.17 is qualified for this runner.">
|
||||
<DraftInput
|
||||
value={
|
||||
isCreate
|
||||
? values!.command ?? ""
|
||||
: eff("adapterConfig", "command", String(config.command ?? ""))
|
||||
}
|
||||
onCommit={(value) =>
|
||||
isCreate
|
||||
? set!({ command: value })
|
||||
: mark("adapterConfig", "command", value || undefined)
|
||||
}
|
||||
immediate
|
||||
className={inputClass}
|
||||
placeholder="opencode"
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
{acpSelected && (
|
||||
<>
|
||||
<Field
|
||||
@@ -217,49 +304,49 @@ export function CodexLocalConfigFields({
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
<ToggleField
|
||||
label="Bypass sandbox"
|
||||
hint={help.dangerouslyBypassSandbox}
|
||||
checked={
|
||||
isCreate
|
||||
? values!.dangerouslyBypassSandbox
|
||||
: eff(
|
||||
"adapterConfig",
|
||||
"dangerouslyBypassApprovalsAndSandbox",
|
||||
bypassEnabled,
|
||||
)
|
||||
}
|
||||
onChange={(v) =>
|
||||
isCreate
|
||||
? set!({ dangerouslyBypassSandbox: v })
|
||||
: mark("adapterConfig", "dangerouslyBypassApprovalsAndSandbox", v)
|
||||
}
|
||||
/>
|
||||
<ToggleField
|
||||
label="Enable search"
|
||||
hint={help.search}
|
||||
checked={
|
||||
isCreate
|
||||
? values!.search
|
||||
: eff("adapterConfig", "search", !!config.search)
|
||||
}
|
||||
onChange={(v) =>
|
||||
isCreate
|
||||
? set!({ search: v })
|
||||
: mark("adapterConfig", "search", v)
|
||||
}
|
||||
/>
|
||||
<ToggleField
|
||||
label="Fast mode"
|
||||
hint={help.fastMode}
|
||||
checked={fastModeEnabled}
|
||||
onChange={(v) =>
|
||||
isCreate
|
||||
? set!({ fastMode: v })
|
||||
: mark("adapterConfig", "fastMode", v)
|
||||
}
|
||||
/>
|
||||
{fastModeEnabled && (
|
||||
{!openCodeRunner && <ToggleField
|
||||
label="Bypass sandbox"
|
||||
hint={help.dangerouslyBypassSandbox}
|
||||
checked={
|
||||
isCreate
|
||||
? values!.dangerouslyBypassSandbox
|
||||
: eff(
|
||||
"adapterConfig",
|
||||
"dangerouslyBypassApprovalsAndSandbox",
|
||||
bypassEnabled,
|
||||
)
|
||||
}
|
||||
onChange={(v) =>
|
||||
isCreate
|
||||
? set!({ dangerouslyBypassSandbox: v })
|
||||
: mark("adapterConfig", "dangerouslyBypassApprovalsAndSandbox", v)
|
||||
}
|
||||
/>}
|
||||
{!openCodeRunner && <ToggleField
|
||||
label="Enable search"
|
||||
hint={help.search}
|
||||
checked={
|
||||
isCreate
|
||||
? values!.search
|
||||
: eff("adapterConfig", "search", !!config.search)
|
||||
}
|
||||
onChange={(v) =>
|
||||
isCreate
|
||||
? set!({ search: v })
|
||||
: mark("adapterConfig", "search", v)
|
||||
}
|
||||
/>}
|
||||
{!openCodeRunner && <ToggleField
|
||||
label="Fast mode"
|
||||
hint={help.fastMode}
|
||||
checked={fastModeEnabled}
|
||||
onChange={(v) =>
|
||||
isCreate
|
||||
? set!({ fastMode: v })
|
||||
: mark("adapterConfig", "fastMode", v)
|
||||
}
|
||||
/>}
|
||||
{!openCodeRunner && fastModeEnabled && (
|
||||
<div className="rounded-md border border-amber-300/70 bg-amber-50/80 px-3 py-2 text-sm text-amber-900 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100">
|
||||
{fastModeMessage}
|
||||
</div>
|
||||
|
||||
@@ -29,9 +29,9 @@ describe("paperclip runner transcript projection", () => {
|
||||
}), "2026-08-21T12:00:00.000Z");
|
||||
|
||||
expect(event("item.delta", { kind: "reasoning", text: "Inspecting the runner" }, "reason-1"))
|
||||
.toEqual([{ kind: "thinking", ts: expect.any(String), text: "Inspecting the runner", delta: true }]);
|
||||
.toEqual([{ kind: "thinking", ts: expect.any(String), text: "Inspecting the runner", delta: true, channel: "unknown" }]);
|
||||
expect(event("item.delta", { kind: "agentMessage", text: "Here is" }, "message-1"))
|
||||
.toEqual([{ kind: "assistant", ts: expect.any(String), text: "Here is", delta: true }]);
|
||||
.toEqual([{ kind: "assistant", ts: expect.any(String), text: "Here is", delta: true, channel: "unknown" }]);
|
||||
expect(event("item.started", {
|
||||
kind: "commandExecution",
|
||||
item: { id: "exec-1", type: "commandExecution", command: "pnpm test", status: "inProgress" },
|
||||
@@ -54,8 +54,42 @@ describe("paperclip runner transcript projection", () => {
|
||||
}), "2026-08-21T12:00:00.000Z");
|
||||
|
||||
expect(line("run.result.proposed", { summary: "Finished the task" }))
|
||||
.toEqual([{ kind: "assistant", ts: expect.any(String), text: "Finished the task" }]);
|
||||
.toEqual([{ kind: "assistant", ts: expect.any(String), text: "Finished the task", channel: "final" }]);
|
||||
expect(line("run.result.accepted", { result: { summary: "Finished the task" } }))
|
||||
.toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves progress, final-answer, and reasoning channels across item deltas", () => {
|
||||
const parse = paperclipRunnerUIAdapter.createStdoutParser!().parseLine;
|
||||
const event = (eventType: string, payload: Record<string, unknown>, itemId: string) => parse(JSON.stringify({
|
||||
type: "paperclip.prp.event",
|
||||
event: { eventType, itemId, payload },
|
||||
}), "2026-08-21T12:00:00.000Z");
|
||||
|
||||
expect(event("item.started", { kind: "agentMessage", channel: "progress", item: { id: "p1", type: "agentMessage", phase: "commentary", text: "" } }, "p1")).toEqual([]);
|
||||
expect(event("item.delta", { kind: "agentMessage", channel: "progress", text: "Running it now." }, "p1"))
|
||||
.toEqual([{ kind: "assistant", ts: expect.any(String), text: "Running it now.", delta: true, channel: "progress" }]);
|
||||
|
||||
expect(event("item.started", { kind: "agentMessage", channel: "final", item: { id: "f1", type: "agentMessage", phase: "final_answer", text: "" } }, "f1")).toEqual([]);
|
||||
expect(event("item.delta", { kind: "agentMessage", channel: "final", text: "Completed." }, "f1"))
|
||||
.toEqual([{ kind: "assistant", ts: expect.any(String), text: "Completed.", delta: true, channel: "final" }]);
|
||||
|
||||
expect(event("item.delta", { kind: "reasoning", channel: "summary", text: "Inspecting" }, "r1"))
|
||||
.toEqual([{ kind: "thinking", ts: expect.any(String), text: "Inspecting", delta: true, channel: "summary" }]);
|
||||
expect(event("item.delta", { kind: "reasoning", channel: "detail", text: "Detailed trace" }, "r1"))
|
||||
.toEqual([{ kind: "thinking", ts: expect.any(String), text: "Detailed trace", delta: true, channel: "detail" }]);
|
||||
});
|
||||
|
||||
it("never exposes the structured task result envelope as final-response prose", () => {
|
||||
const parse = paperclipRunnerUIAdapter.createStdoutParser!().parseLine;
|
||||
const event = (eventType: string, payload: Record<string, unknown>, itemId = "result-1") => parse(JSON.stringify({
|
||||
type: "paperclip.prp.event",
|
||||
event: { eventType, itemId, payload },
|
||||
}), "2026-08-21T12:00:00.000Z");
|
||||
event("item.started", { kind: "agentMessage", channel: "final", item: { id: "result-1", type: "agentMessage", phase: "final_answer", text: "" } });
|
||||
expect(event("item.delta", { kind: "agentMessage", channel: "final", text: "{\"schema\":" })).toEqual([]);
|
||||
expect(event("item.delta", { kind: "agentMessage", channel: "final", text: "\"paperclip.run_result.v1\"}" })).toEqual([]);
|
||||
expect(event("run.result.proposed", { summary: "Human-readable completion." }))
|
||||
.toEqual([{ kind: "assistant", ts: expect.any(String), text: "Human-readable completion.", channel: "final" }]);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,27 @@ interface PaperclipRunnerParserState {
|
||||
reasoningDeltaItemIds: Set<string>;
|
||||
resultSummaries: Set<string>;
|
||||
toolOutputItemIds: Set<string>;
|
||||
itemChannels: Map<string, "progress" | "final" | "summary" | "detail" | "unknown">;
|
||||
structuredFinalItemIds: Set<string>;
|
||||
}
|
||||
|
||||
function itemChannel(payload: JsonRecord): "progress" | "final" | "summary" | "detail" | "unknown" {
|
||||
const channel = text(payload.channel);
|
||||
return channel === "progress" || channel === "final" || channel === "summary" || channel === "detail"
|
||||
? channel
|
||||
: "unknown";
|
||||
}
|
||||
|
||||
function structuredResultSummary(value: string): string | null {
|
||||
if (!value.trimStart().startsWith("{")) return null;
|
||||
try {
|
||||
const parsed = record(JSON.parse(value));
|
||||
return text(parsed.schema) === "paperclip.run_result.v1" && text(parsed.summary)
|
||||
? text(parsed.summary)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function record(value: unknown): JsonRecord {
|
||||
@@ -193,14 +214,31 @@ function parseItemEvent(
|
||||
const item = record(payload.item);
|
||||
const type = normalizedItemType(item, payload);
|
||||
const id = itemId(event, item);
|
||||
const channel = itemChannel(payload);
|
||||
if (phase === "started") state.itemChannels.set(id, channel);
|
||||
const resolvedChannel = channel === "unknown" ? state.itemChannels.get(id) ?? "unknown" : channel;
|
||||
if (type === "agentmessage") {
|
||||
const value = itemText(item, payload);
|
||||
if (phase === "completed") state.itemChannels.delete(id);
|
||||
if (!value || state.assistantDeltaItemIds.has(id)) return [];
|
||||
return [{ kind: "assistant", ts, text: value }];
|
||||
if (resolvedChannel === "final") {
|
||||
const summary = structuredResultSummary(value);
|
||||
if (summary) {
|
||||
state.resultSummaries.add(summary);
|
||||
return [{ kind: "assistant", ts, text: summary, channel: "final" }];
|
||||
}
|
||||
}
|
||||
return [{ kind: "assistant", ts, text: value, channel: resolvedChannel === "final" ? "final" : resolvedChannel === "progress" ? "progress" : "unknown" }];
|
||||
}
|
||||
if (type === "reasoning") {
|
||||
const value = itemText(item, payload);
|
||||
if (value && !state.reasoningDeltaItemIds.has(id)) return [{ kind: "thinking", ts, text: value }];
|
||||
if (phase === "completed") state.itemChannels.delete(id);
|
||||
if (value && !state.reasoningDeltaItemIds.has(id)) return [{
|
||||
kind: "thinking",
|
||||
ts,
|
||||
text: value,
|
||||
channel: resolvedChannel === "detail" ? "detail" : resolvedChannel === "summary" ? "summary" : "unknown",
|
||||
}];
|
||||
return phase === "started"
|
||||
? [{ kind: "system", ts, text: "Reasoning started" }]
|
||||
: [];
|
||||
@@ -233,14 +271,20 @@ function parseDeltaEvent(
|
||||
const kind = text(payload.kind).replaceAll("_", "").toLowerCase();
|
||||
const value = text(payload.text);
|
||||
const id = text(event.itemId, `${kind || "item"}-delta`);
|
||||
const explicitChannel = itemChannel(payload);
|
||||
const channel = explicitChannel === "unknown" ? state.itemChannels.get(id) ?? "unknown" : explicitChannel;
|
||||
if (!value) return [];
|
||||
if (kind === "agentmessage") {
|
||||
state.assistantDeltaItemIds.add(id);
|
||||
return [{ kind: "assistant", ts, text: value, delta: true }];
|
||||
if (channel === "final" && (state.structuredFinalItemIds.has(id) || value.trimStart().startsWith("{"))) {
|
||||
state.structuredFinalItemIds.add(id);
|
||||
return [];
|
||||
}
|
||||
return [{ kind: "assistant", ts, text: value, delta: true, channel: channel === "final" ? "final" : channel === "progress" ? "progress" : "unknown" }];
|
||||
}
|
||||
if (kind === "reasoning") {
|
||||
state.reasoningDeltaItemIds.add(id);
|
||||
return [{ kind: "thinking", ts, text: value, delta: true }];
|
||||
return [{ kind: "thinking", ts, text: value, delta: true, channel: channel === "detail" ? "detail" : channel === "summary" ? "summary" : "unknown" }];
|
||||
}
|
||||
if (kind === "commandexecution") {
|
||||
state.toolOutputItemIds.add(id);
|
||||
@@ -309,7 +353,7 @@ function parsePrpEvent(
|
||||
const summary = text(result.summary);
|
||||
if (!summary || state.resultSummaries.has(summary)) return [];
|
||||
state.resultSummaries.add(summary);
|
||||
return [{ kind: "assistant", ts, text: summary }];
|
||||
return [{ kind: "assistant", ts, text: summary, channel: "final" }];
|
||||
}
|
||||
if (eventType === "harness.diagnostic" || eventType === "session.failed") {
|
||||
return [{ kind: "system", ts, text: `Runner: ${text(payload.message, text(payload.code, eventType))}` }];
|
||||
@@ -323,6 +367,8 @@ function createParserState(): PaperclipRunnerParserState {
|
||||
reasoningDeltaItemIds: new Set(),
|
||||
resultSummaries: new Set(),
|
||||
toolOutputItemIds: new Set(),
|
||||
itemChannels: new Map(),
|
||||
structuredFinalItemIds: new Set(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -116,6 +116,34 @@ describe("TaskChatThread draft pass-through", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskChatThread native-runner failures", () => {
|
||||
it("replaces an empty failed transcript with actionable retry feedback and keeps the composer enabled", () => {
|
||||
render(
|
||||
<TaskChatThread
|
||||
comments={[]}
|
||||
onAdd={async () => {}}
|
||||
issueStatus="in_progress"
|
||||
linkedRuns={[{
|
||||
runId: "run-failed",
|
||||
status: "failed",
|
||||
agentId: "agent-1",
|
||||
adapterType: "paperclip-runner",
|
||||
createdAt: "2026-08-21T12:00:00.000Z",
|
||||
startedAt: "2026-08-21T12:00:00.000Z",
|
||||
finishedAt: "2026-08-21T12:00:01.000Z",
|
||||
errorCode: "provider_frame_too_large",
|
||||
hasStoredOutput: false,
|
||||
}]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain("Run failed");
|
||||
expect(container.textContent).toContain("Provider output exceeded the safe limit");
|
||||
expect(container.textContent).not.toContain("Waiting for transcript");
|
||||
expect(container.querySelector("textarea")?.disabled ?? false).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskChatThread composer alignment (PAP-498)", () => {
|
||||
it("matches the thread width on mobile and stays narrower on larger screens", () => {
|
||||
render(<TaskChatThread comments={[]} onAdd={async () => {}} />);
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type RunTranscriptSource,
|
||||
} from "@/components/transcript/useLiveRunTranscripts";
|
||||
import { TaskChatLiveTail } from "@/components/task-chat/TaskChatLiveTail";
|
||||
import { TaskChatRunnerTurn } from "@/components/task-chat/TaskChatRunnerTurn";
|
||||
import { commentsToTaskChatItems } from "@/components/task-chat/task-chat-adapter";
|
||||
import {
|
||||
assembleThreadItems,
|
||||
@@ -349,6 +350,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
// B4), so the transcript stays mounted through the settle gap without ever
|
||||
// double-rendering beside its own settled turn.
|
||||
const settledRunIds = new Set<string>();
|
||||
const entriesWithFailures = [...orderedEntries];
|
||||
// Settled turns for every terminal run whose transcript we have. Messages
|
||||
// and thinking are excluded entirely (PAP-361): the final reply already
|
||||
// landed as the run's comment bubble, interstitial updates are ephemeral
|
||||
@@ -362,8 +364,33 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
if (!isTerminalRunStatus(source.status)) continue;
|
||||
if (liveRun && source.id === liveRun.id) continue;
|
||||
const entries = transcriptByRun.get(source.id) ?? [];
|
||||
if (entries.length === 0) continue;
|
||||
const meta = linkedRunMetaById.get(source.id);
|
||||
if (entries.length === 0) {
|
||||
if (source.status === "failed" || source.status === "timed_out") {
|
||||
settledRunIds.add(source.id);
|
||||
const code = meta?.errorCode ?? "native_runner_process_exited";
|
||||
const retryDetail = meta?.scheduledRetryAt
|
||||
? "Retry scheduled automatically."
|
||||
: "You can retry this message now.";
|
||||
const detail = code === "provider_frame_too_large"
|
||||
? `Provider output exceeded the safe limit. ${retryDetail}`
|
||||
: `The runner stopped before returning an answer (${code}). ${retryDetail}`;
|
||||
const id = `${source.id}:failure`;
|
||||
entriesWithFailures.push({
|
||||
ms: toMs(meta?.finishedAt ?? meta?.startedAt ?? meta?.createdAt),
|
||||
order: 3,
|
||||
id,
|
||||
item: {
|
||||
id,
|
||||
kind: "marker",
|
||||
variant: "interrupted",
|
||||
label: "Run failed",
|
||||
detail,
|
||||
},
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const started = meta?.startedAt ? new Date(meta.startedAt).getTime() : NaN;
|
||||
const finished = meta?.finishedAt ? new Date(meta.finishedAt).getTime() : NaN;
|
||||
const durationMs =
|
||||
@@ -376,7 +403,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
running: false,
|
||||
});
|
||||
const children = settledRunChildren(parsed);
|
||||
if (children.length === 0) continue;
|
||||
if (children.length === 0 && source.adapterType !== "paperclip_runner") continue;
|
||||
settledRunIds.add(source.id);
|
||||
const failed = source.status !== "succeeded";
|
||||
const startSlotRaw = meta?.startedAt ?? meta?.createdAt;
|
||||
@@ -390,6 +417,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
id: `${source.id}:turn`,
|
||||
kind: "turn",
|
||||
settled: true,
|
||||
standaloneHeader: source.adapterType === "paperclip_runner",
|
||||
animateFold: liveSeenRef.current.has(source.id),
|
||||
items: children,
|
||||
summary: buildTurnSummary(entries, { durationMs, failed }),
|
||||
@@ -420,7 +448,10 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
// (stopped runs, or a reply not yet fetched) interleave chronologically at
|
||||
// their run's start time instead of piling up under the newest message
|
||||
// (PAP-367).
|
||||
const out = assembleThreadItems(orderedEntries, turnsByAnchor, unanchored);
|
||||
entriesWithFailures.sort(
|
||||
(a, b) => a.ms - b.ms || a.order - b.order || a.id.localeCompare(b.id),
|
||||
);
|
||||
const out = assembleThreadItems(entriesWithFailures, turnsByAnchor, unanchored);
|
||||
|
||||
// PAP-362: two runs replying back-to-back (same agent, nothing but the
|
||||
// agent's own bubbles between) fold into ONE "Worked" row below the last
|
||||
@@ -468,10 +499,12 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
// Hand off once the settled turn or its reply comment is in the thread; a
|
||||
// stopped run that yields neither is released by the backstop timeout so the
|
||||
// tail never lingers indefinitely.
|
||||
const settledRunRendered =
|
||||
settlingRun != null &&
|
||||
(settledRunIds.has(settlingRun.id) ||
|
||||
comments.some((comment) => comment.runId === settlingRun.id && !comment.deletedAt));
|
||||
const settlingHasComment = settlingRun != null &&
|
||||
comments.some((comment) => comment.runId === settlingRun.id && !comment.deletedAt);
|
||||
const settlingIsPaperclipRunner = settlingRun != null &&
|
||||
runs.find((run) => run.id === settlingRun.id)?.adapterType === "paperclip_runner";
|
||||
const settledRunRendered = settlingRun != null &&
|
||||
(settlingHasComment || (!settlingIsPaperclipRunner && settledRunIds.has(settlingRun.id)));
|
||||
useEffect(() => {
|
||||
if (!settlingRun) return;
|
||||
if (settledRunRendered) {
|
||||
@@ -492,6 +525,8 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
const tailRunId = liveRun ? liveRun.id : showSettlingTail ? settlingRun!.id : null;
|
||||
const tailStreaming = Boolean(liveRun);
|
||||
const tailEntries = tailRunId ? (transcriptByRun.get(tailRunId) ?? []) : [];
|
||||
const tailAdapterType = tailRunId ? runs.find((run) => run.id === tailRunId)?.adapterType : null;
|
||||
const paperclipRunnerTail = tailAdapterType === "paperclip_runner";
|
||||
const tailContentKey = tailEntries.reduce((total, entry) => {
|
||||
if ("text" in entry) return total + entry.text.length;
|
||||
if ("content" in entry) return total + entry.content.length;
|
||||
@@ -553,6 +588,35 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[tailRunId, tailContentKey, tailStreaming, tailAgentName],
|
||||
);
|
||||
const assigneeUsesPaperclipRunner = Boolean(
|
||||
issueAssigneeAgentId && agentMap?.get(issueAssigneeAgentId)?.adapterType === "paperclip_runner",
|
||||
);
|
||||
const [runnerSubmissionPending, setRunnerSubmissionPending] = useState(false);
|
||||
useEffect(() => {
|
||||
if (tailRunId) setRunnerSubmissionPending(false);
|
||||
}, [tailRunId]);
|
||||
useEffect(() => {
|
||||
if (!runnerSubmissionPending || tailRunId) return;
|
||||
const timeout = window.setTimeout(() => setRunnerSubmissionPending(false), 10_000);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [runnerSubmissionPending, tailRunId]);
|
||||
const handleThreadAdd = useCallback(
|
||||
async (...args: Parameters<typeof onAdd>) => {
|
||||
if (assigneeUsesPaperclipRunner) setRunnerSubmissionPending(true);
|
||||
try {
|
||||
await onAdd(...args);
|
||||
} catch (error) {
|
||||
setRunnerSubmissionPending(false);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[assigneeUsesPaperclipRunner, onAdd],
|
||||
);
|
||||
const optimisticRunnerStartup = assigneeUsesPaperclipRunner && !tailRunId && (
|
||||
runnerSubmissionPending || commentItems.some(
|
||||
(item) => item.kind === "message" && item.author === "human" && item.optimistic === "pending",
|
||||
)
|
||||
);
|
||||
|
||||
// Feedback votes keyed by the comment they target (targetType
|
||||
// "issue_comment"), mirroring IssueChatThread — the redesign attaches the
|
||||
@@ -681,24 +745,36 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
renderBrief={issueBrief ? () => <TaskChatDescriptionBubble brief={issueBrief} /> : undefined}
|
||||
renderMessageActions={renderMessageActions}
|
||||
renderQueuedAction={renderQueuedAction}
|
||||
tail={tailRunId || bottomBlockerLinks ? (
|
||||
tail={tailRunId || optimisticRunnerStartup || bottomBlockerLinks ? (
|
||||
<>
|
||||
{tailRunId ? (
|
||||
{tailRunId || optimisticRunnerStartup ? (
|
||||
<div data-testid="task-chat-live-transcript">
|
||||
<TaskChatLiveRunPill
|
||||
status={tailStatus}
|
||||
startedAtMs={tailStartedAtMs}
|
||||
finishedAtMs={tailFinishedAtMs}
|
||||
toolSummary={tailToolSummary}
|
||||
/>
|
||||
<TaskChatLiveTail
|
||||
items={tailItems}
|
||||
emptyMessage={
|
||||
tailStatus === "queued"
|
||||
? "Waiting to start..."
|
||||
: "Waiting for transcript..."
|
||||
}
|
||||
/>
|
||||
{paperclipRunnerTail || optimisticRunnerStartup ? (
|
||||
<TaskChatRunnerTurn
|
||||
items={tailItems}
|
||||
status={optimisticRunnerStartup ? "queued" : tailStatus}
|
||||
startedAtMs={tailStartedAtMs}
|
||||
finishedAtMs={tailFinishedAtMs}
|
||||
toolSummary={tailToolSummary}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<TaskChatLiveRunPill
|
||||
status={tailStatus}
|
||||
startedAtMs={tailStartedAtMs}
|
||||
finishedAtMs={tailFinishedAtMs}
|
||||
toolSummary={tailToolSummary}
|
||||
/>
|
||||
<TaskChatLiveTail
|
||||
items={tailItems}
|
||||
emptyMessage={
|
||||
tailStatus === "queued"
|
||||
? "Waiting to start..."
|
||||
: "Waiting for transcript..."
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{bottomBlockerLinks}
|
||||
@@ -733,7 +809,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
>
|
||||
{composerAccessory}
|
||||
<TaskChatComposer
|
||||
onAdd={onAdd}
|
||||
onAdd={handleThreadAdd}
|
||||
workMode={issueWorkMode}
|
||||
onWorkModeChange={onWorkModeChange}
|
||||
disabled={Boolean(composerDisabledReason)}
|
||||
|
||||
@@ -29,6 +29,8 @@ interface TaskChatBubbleProps {
|
||||
* Supplied by TaskChatThreadView when `item.attachedTurn` is set.
|
||||
*/
|
||||
attachedTurn?: ReactNode;
|
||||
/** New-runner Worked header, rendered above the durable final response. */
|
||||
beforeTurn?: ReactNode;
|
||||
/**
|
||||
* copy · 👍 · 👎 controls for an agent bubble's footer line (PAP-413).
|
||||
* Rendered here only for a runless reply (leading the bare timestamp); when
|
||||
@@ -63,7 +65,7 @@ function galleryItemForImage(src: string, name?: string): GalleryMediaItem {
|
||||
};
|
||||
}
|
||||
|
||||
export function TaskChatBubble({ item, queuedAction, attachedTurn, actions }: TaskChatBubbleProps) {
|
||||
export function TaskChatBubble({ item, queuedAction, attachedTurn, beforeTurn, actions }: TaskChatBubbleProps) {
|
||||
// Clicking an embedded image opens the full-screen lightbox (with download);
|
||||
// arrow keys walk across the other images in the same bubble.
|
||||
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null);
|
||||
@@ -96,6 +98,7 @@ export function TaskChatBubble({ item, queuedAction, attachedTurn, actions }: Ta
|
||||
: Math.max(0, galleryItems.findIndex((galleryItem) => galleryItem.contentPath === lightboxSrc));
|
||||
return (
|
||||
<div className={cn("tc-enter-bubble flex w-full flex-col gap-1", isHuman ? "items-end" : "items-start")}>
|
||||
{beforeTurn ? <div className="w-full pb-1">{beforeTurn}</div> : null}
|
||||
{!isHuman && item.authorName ? (
|
||||
<span className="flex items-center gap-2 px-1">
|
||||
<Avatar size="sm" className="shrink-0" data-testid="task-chat-agent-avatar">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { ChevronRight, Loader2 } from "lucide-react";
|
||||
import type { TranscriptEntry } from "../../adapters";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSecondTick } from "@/hooks/useSecondTick";
|
||||
@@ -47,6 +47,9 @@ export function TaskChatLiveRunPill({
|
||||
startedAtMs,
|
||||
finishedAtMs,
|
||||
toolSummary,
|
||||
runnerStyle = false,
|
||||
expanded,
|
||||
onToggle,
|
||||
}: {
|
||||
status: string;
|
||||
/** Run start (startedAt, falling back to createdAt) in ms, or null if unknown. */
|
||||
@@ -54,6 +57,10 @@ export function TaskChatLiveRunPill({
|
||||
/** Run finish in ms once terminal; drives the settled elapsed readout. */
|
||||
finishedAtMs?: number | null;
|
||||
toolSummary: string | null;
|
||||
/** Codex-style new-runner header: no spinner, whole-second compact duration. */
|
||||
runnerStyle?: boolean;
|
||||
expanded?: boolean;
|
||||
onToggle?: () => void;
|
||||
}) {
|
||||
const active = !isTerminalRunStatus(status);
|
||||
// One shared page-wide ticker drives the live elapsed readout, matching the
|
||||
@@ -62,10 +69,42 @@ export function TaskChatLiveRunPill({
|
||||
|
||||
const elapsedMs =
|
||||
startedAtMs == null ? null : (active ? Date.now() : finishedAtMs ?? Date.now()) - startedAtMs;
|
||||
const elapsed = elapsedMs != null ? formatDurationWords(elapsedMs) : null;
|
||||
const elapsed = elapsedMs != null
|
||||
? runnerStyle
|
||||
? `${Math.max(0, Math.floor(elapsedMs / 1000))}s`
|
||||
: formatDurationWords(elapsedMs)
|
||||
: null;
|
||||
const verb = active ? "Working" : "Worked";
|
||||
const suffix = elapsed ? `for ${elapsed}` : null;
|
||||
|
||||
if (runnerStyle) {
|
||||
const content = (
|
||||
<>
|
||||
<span className={cn("text-sm font-medium text-foreground/80", active && "shimmer-text")}>{verb}</span>
|
||||
{suffix ? <span className="text-sm text-muted-foreground">{suffix}</span> : null}
|
||||
<ChevronRight
|
||||
className={cn("ml-auto h-3.5 w-3.5 text-muted-foreground transition-transform", expanded && "rotate-90")}
|
||||
aria-hidden
|
||||
/>
|
||||
</>
|
||||
);
|
||||
return onToggle ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
aria-expanded={expanded}
|
||||
className="flex w-full min-w-0 items-center gap-1.5 border-b border-border/70 px-1 py-2 text-left transition-colors hover:text-foreground"
|
||||
data-testid="task-chat-live-run-pill"
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex min-w-0 items-center gap-1.5 border-b border-border/70 px-1 py-2" data-testid="task-chat-live-run-pill">
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex min-w-0 items-center gap-2.5 px-1 py-2"
|
||||
|
||||
@@ -29,12 +29,18 @@ import { buildActivityPhases } from "./transcript-adapter";
|
||||
export function TaskChatLiveTail({
|
||||
items,
|
||||
emptyMessage,
|
||||
excludeFinal = false,
|
||||
}: {
|
||||
items: readonly TaskChatItem[];
|
||||
/** Shown when nothing renderable has streamed yet (queued / pre-first-token). */
|
||||
emptyMessage?: string;
|
||||
/** New-runner turn renders final-answer messages in its dedicated response slot. */
|
||||
excludeFinal?: boolean;
|
||||
}) {
|
||||
const rows = buildActivityPhases(items, true)
|
||||
const visibleItems = excludeFinal
|
||||
? items.filter((item) => item.kind !== "message" || item.interstitial)
|
||||
: items;
|
||||
const rows = buildActivityPhases(visibleItems, true)
|
||||
.map((item) => renderTailRow(item))
|
||||
.filter((row): row is ReactElement => row != null);
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { ThemeProvider } from "@/context/ThemeContext";
|
||||
import { TaskChatRunnerTurn } from "./TaskChatRunnerTurn";
|
||||
import type { TaskChatItem } from "./task-chat-model";
|
||||
|
||||
describe("TaskChatRunnerTurn", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
const render = (items: TaskChatItem[], status = "running") => act(() => root.render(
|
||||
<ThemeProvider>
|
||||
<TaskChatRunnerTurn items={items} status={status} startedAtMs={Date.now() - 2_000} toolSummary={null} />
|
||||
</ThemeProvider>,
|
||||
));
|
||||
|
||||
it("shows immediate Thinking before the first runner event", () => {
|
||||
render([], "queued");
|
||||
expect(container.textContent).toContain("Thinking");
|
||||
expect(container.textContent).not.toContain("Working");
|
||||
expect(container.textContent).not.toContain("Waiting for transcript");
|
||||
});
|
||||
|
||||
it("keeps progress separate from the replace-in-place current command", () => {
|
||||
render([
|
||||
{ id: "p1", kind: "message", author: "agent", text: "Running the exact command now.", interstitial: true, channel: "progress", streaming: true },
|
||||
{ id: "t1", kind: "tool", name: "command", rawName: "Bash", target: "for i in 1 2 3 4; do echo STREAM-$i; done", status: "in_progress", detail: "STREAM-1\n" },
|
||||
]);
|
||||
expect(container.querySelector('[data-testid="task-chat-progress-update"]')?.textContent).toContain("Running the exact command now.");
|
||||
expect(container.querySelector('[data-testid="task-chat-current-activity"]')?.textContent).toContain("Running a command");
|
||||
expect(container.textContent).toContain("STREAM-$i");
|
||||
});
|
||||
|
||||
it("streams the final response in its durable slot and hides current activity", () => {
|
||||
render([
|
||||
{ id: "p1", kind: "message", author: "agent", text: "Checking.", interstitial: true, channel: "progress" },
|
||||
{ id: "f1", kind: "message", author: "agent", text: "Completed successfully.", channel: "final", streaming: true },
|
||||
]);
|
||||
expect(container.querySelector('[data-testid="task-chat-final-response"]')?.textContent).toContain("Completed successfully.");
|
||||
expect(container.querySelector('[data-testid="task-chat-current-activity"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useState } from "react";
|
||||
import { Brain } from "lucide-react";
|
||||
import { MarkdownBody } from "@/components/MarkdownBody";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { TaskChatItem, TaskChatMessageItem, TaskChatThinkingItem, TaskChatToolItem } from "./task-chat-model";
|
||||
import { TaskChatLiveRunPill } from "./TaskChatLiveRunPill";
|
||||
import { TaskChatLiveTail } from "./TaskChatLiveTail";
|
||||
import { toolTaxonomy } from "./tool-taxonomy";
|
||||
|
||||
function lastOf<T extends TaskChatItem>(items: readonly TaskChatItem[], predicate: (item: TaskChatItem) => item is T): T | undefined {
|
||||
for (let index = items.length - 1; index >= 0; index -= 1) {
|
||||
const item = items[index];
|
||||
if (predicate(item)) return item;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function CurrentActivity({ items }: { items: readonly TaskChatItem[] }) {
|
||||
const activity = lastOf<TaskChatThinkingItem | TaskChatToolItem>(
|
||||
items,
|
||||
(item): item is TaskChatThinkingItem | TaskChatToolItem => item.kind === "thinking" || item.kind === "tool",
|
||||
);
|
||||
if (!activity || activity.kind === "thinking") {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-1 py-1.5 text-sm text-muted-foreground" data-testid="task-chat-current-activity">
|
||||
<Brain className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
||||
<span className="shimmer-text shimmer-text-muted">Thinking</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const taxonomy = toolTaxonomy(activity.rawName ?? activity.name);
|
||||
const Icon = taxonomy.icon;
|
||||
const active = activity.status === "pending" || activity.status === "in_progress";
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2 px-1 py-1.5 text-sm text-muted-foreground" data-testid="task-chat-current-activity">
|
||||
<Icon className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
||||
<span className={cn("shrink-0", active && "shimmer-text shimmer-text-muted")}>{taxonomy.verbLabel}</span>
|
||||
{activity.target ? <span className="min-w-0 truncate font-mono text-xs">{activity.target}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskChatRunnerTurn({
|
||||
items,
|
||||
status,
|
||||
startedAtMs,
|
||||
finishedAtMs,
|
||||
toolSummary,
|
||||
}: {
|
||||
items: readonly TaskChatItem[];
|
||||
status: string;
|
||||
startedAtMs: number | null;
|
||||
finishedAtMs?: number | null;
|
||||
toolSummary: string | null;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const progress = lastOf<TaskChatMessageItem>(items, (item): item is TaskChatMessageItem => item.kind === "message" && Boolean(item.interstitial));
|
||||
const final = lastOf<TaskChatMessageItem>(items, (item): item is TaskChatMessageItem => item.kind === "message" && item.channel === "final");
|
||||
|
||||
if (status === "queued") {
|
||||
return (
|
||||
<div className="py-1" data-testid="task-chat-runner-turn" data-phase="startup">
|
||||
<CurrentActivity items={items} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col" data-testid="task-chat-runner-turn">
|
||||
<TaskChatLiveRunPill
|
||||
status={status}
|
||||
startedAtMs={startedAtMs}
|
||||
finishedAtMs={finishedAtMs}
|
||||
toolSummary={toolSummary}
|
||||
runnerStyle
|
||||
expanded={open}
|
||||
onToggle={() => setOpen((value) => !value)}
|
||||
/>
|
||||
<div className="tc-turn-fold" data-folded={open ? "false" : "true"} aria-hidden={!open}>
|
||||
<div>
|
||||
<div className="flex flex-col gap-2 py-2">
|
||||
<TaskChatLiveTail items={items as TaskChatItem[]} excludeFinal />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!open ? (
|
||||
<div className="flex min-w-0 flex-col py-1">
|
||||
{progress ? (
|
||||
<div className="tc-enter-cot-line min-w-0 px-1 py-1.5 text-sm text-foreground/90" data-testid="task-chat-progress-update">
|
||||
<MarkdownBody softBreaks linkIssueReferences>{progress.text}</MarkdownBody>
|
||||
</div>
|
||||
) : null}
|
||||
{!final ? <CurrentActivity items={items} /> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{final ? (
|
||||
<div className="tc-enter-bubble px-1 py-2 text-sm text-foreground" data-testid="task-chat-final-response">
|
||||
<MarkdownBody softBreaks linkIssueReferences>{final.text}</MarkdownBody>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,8 @@ export function TaskChatThinking({ item }: { item: TaskChatThinkingItem }) {
|
||||
const [open, setOpen] = useState(item.streaming || !item.collapsed);
|
||||
const body = item.lines.join("\n").trim();
|
||||
if (!body) return null;
|
||||
const label = item.streaming ? "Reasoning…" : item.summaryLabel ?? "Reasoning";
|
||||
const baseLabel = item.channel === "detail" ? "Reasoning detail" : "Reasoning";
|
||||
const label = item.streaming ? `${baseLabel}…` : item.summaryLabel ?? baseLabel;
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col text-xs" data-testid="task-chat-thinking">
|
||||
|
||||
@@ -69,21 +69,21 @@ function renderItem(
|
||||
// put when the tool history expands (PAP-413). The two paths are mutually
|
||||
// exclusive at runtime, so only one host ever mounts the node.
|
||||
const actions = renderMessageActions?.(item);
|
||||
const turn = item.attachedTurn ? (
|
||||
<TaskChatTurn
|
||||
item={item.attachedTurn}
|
||||
timestampPrefix={item.attachedTurn.standaloneHeader ? undefined : item.timestamp}
|
||||
leading={item.attachedTurn.standaloneHeader ? undefined : actions}
|
||||
renderChild={(child) => renderItem(child, onApprovalDecision)}
|
||||
/>
|
||||
) : undefined;
|
||||
return (
|
||||
<TaskChatBubble
|
||||
item={item}
|
||||
actions={actions}
|
||||
actions={item.attachedTurn?.standaloneHeader ? actions : item.attachedTurn ? undefined : actions}
|
||||
queuedAction={renderQueuedAction?.(item)}
|
||||
attachedTurn={
|
||||
item.attachedTurn ? (
|
||||
<TaskChatTurn
|
||||
item={item.attachedTurn}
|
||||
timestampPrefix={item.timestamp}
|
||||
leading={actions}
|
||||
renderChild={(child) => renderItem(child, onApprovalDecision)}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
beforeTurn={item.attachedTurn?.standaloneHeader ? turn : undefined}
|
||||
attachedTurn={item.attachedTurn?.standaloneHeader ? undefined : turn}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -124,6 +124,13 @@ describe("TaskChatTurn", () => {
|
||||
expect(fold()?.getAttribute("data-folded")).toBe("true");
|
||||
});
|
||||
|
||||
it("renders the new-runner settled header as Worked for N above the response", () => {
|
||||
renderTurn({ ...SETTLED, standaloneHeader: true });
|
||||
expect(summaryBtn()?.textContent).toContain("Worked for 38s");
|
||||
expect(summaryBtn()?.textContent).not.toContain("3 tools");
|
||||
expect(fold()?.getAttribute("data-folded")).toBe("true");
|
||||
});
|
||||
|
||||
it("toggles open on summary click", () => {
|
||||
renderTurn(SETTLED);
|
||||
flushSync(() => summaryBtn()!.click());
|
||||
|
||||
@@ -85,7 +85,10 @@ export function TaskChatTurn({ item, renderChild, timestampPrefix, leading }: Ta
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-expanded={open}
|
||||
className="group flex items-center gap-2 px-1 py-0.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
className={cn(
|
||||
"group flex w-full items-center gap-2 px-1 text-muted-foreground transition-colors hover:text-foreground",
|
||||
item.standaloneHeader ? "border-b border-border/70 py-2 text-sm" : "py-0.5 text-xs",
|
||||
)}
|
||||
data-testid="task-chat-turn-summary"
|
||||
>
|
||||
{timestampPrefix ? (
|
||||
@@ -94,9 +97,13 @@ export function TaskChatTurn({ item, renderChild, timestampPrefix, leading }: Ta
|
||||
<span aria-hidden className="text-(length:--text-micro)">·</span>
|
||||
</>
|
||||
) : null}
|
||||
<SummaryIcon className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>{item.summary.failed ? "Stopped" : "Worked"}</span>
|
||||
{turnSummaryMetrics(item.summary) ? (
|
||||
{!item.standaloneHeader ? <SummaryIcon className="h-3.5 w-3.5 shrink-0" /> : null}
|
||||
<span>
|
||||
{item.standaloneHeader && item.summary.durationLabel
|
||||
? `${item.summary.failed ? "Stopped" : "Worked"} for ${item.summary.durationLabel}`
|
||||
: item.summary.failed ? "Stopped" : "Worked"}
|
||||
</span>
|
||||
{!item.standaloneHeader && turnSummaryMetrics(item.summary) ? (
|
||||
// Time/tools/tokens is demoted, not deleted (PAP-502): it stays in the
|
||||
// DOM (and the accessible tree) but fades in only on hover/focus so the
|
||||
// settled line reads as "2:34 PM · ✓ Worked" at rest. Revealed too when
|
||||
@@ -110,7 +117,7 @@ export function TaskChatTurn({ item, renderChild, timestampPrefix, leading }: Ta
|
||||
{turnSummaryMetrics(item.summary)}
|
||||
</span>
|
||||
) : null}
|
||||
<ChevronRight className={cn("h-3 w-3 shrink-0 transition-transform", open ? "rotate-90" : null)} />
|
||||
<ChevronRight className={cn("h-3 w-3 shrink-0 transition-transform", item.standaloneHeader && "ml-auto", open ? "rotate-90" : null)} />
|
||||
</button>
|
||||
) : parentRow ? (
|
||||
// The pill renders the expand button itself, wrapped around only the
|
||||
|
||||
@@ -80,14 +80,10 @@ export interface TaskChatMessageItem {
|
||||
* "for {user}" chip beside the author name (the open cross-task write design (attribution)).
|
||||
*/
|
||||
onBehalfOfUserName?: string;
|
||||
/**
|
||||
* Agent text streamed inside a run turn (interstitial updates between tool
|
||||
* calls). Ephemeral in the redesigned view (PAP-361): while streaming it
|
||||
* takes the live parent row's line (TaskChatStatusItem.selfTalk); once
|
||||
* finished it renders nowhere — the run log / classic transcript remain the
|
||||
* archive. Tagged for live and settled transcripts alike.
|
||||
*/
|
||||
/** A transient provider progress update, distinct from the durable final response. */
|
||||
interstitial?: boolean;
|
||||
/** Normalized runner message channel. Only paperclip_runner currently emits it. */
|
||||
channel?: "progress" | "final" | "unknown";
|
||||
/** Epoch ms of the message's first streamed chunk. */
|
||||
atMs?: number;
|
||||
/**
|
||||
@@ -121,6 +117,8 @@ export interface TaskChatThinkingItem {
|
||||
collapsed?: boolean;
|
||||
/** Human-readable elapsed label for the collapsed header. */
|
||||
summaryLabel?: string;
|
||||
/** Provider-emitted reasoning surface; never synthesized by the UI. */
|
||||
channel?: "summary" | "detail" | "unknown";
|
||||
}
|
||||
|
||||
/** A tool invocation row (ACP tool_call / tool_call_update). */
|
||||
@@ -267,6 +265,8 @@ export interface TaskChatTurnItem {
|
||||
liveStatus?: TaskChatStatusItem;
|
||||
/** Animate the fold when settling (false = collapse instantly, e.g. history). */
|
||||
animateFold?: boolean;
|
||||
/** New-runner turns keep the Worked header standalone above the final response. */
|
||||
standaloneHeader?: boolean;
|
||||
summary: {
|
||||
/** e.g. "38s" — omitted when unknown. */
|
||||
durationLabel?: string;
|
||||
|
||||
@@ -64,10 +64,10 @@ export const TASK_CHAT_STATE_META: Record<TaskChatStateId, TaskChatStateMeta> =
|
||||
},
|
||||
"agent-message": {
|
||||
id: "agent-message",
|
||||
label: "Agent message",
|
||||
label: "Final response",
|
||||
tier: "live",
|
||||
surface: "thread",
|
||||
protocol: "text_delta stream:output (ACP agent_message_chunk)",
|
||||
protocol: 'PRP item.delta kind:"agentMessage" channel:"final"',
|
||||
},
|
||||
thinking: {
|
||||
id: "thinking",
|
||||
@@ -78,14 +78,14 @@ export const TASK_CHAT_STATE_META: Record<TaskChatStateId, TaskChatStateMeta> =
|
||||
},
|
||||
responding: {
|
||||
id: "responding",
|
||||
label: "Responding (streaming)",
|
||||
label: "Progress update (streaming)",
|
||||
tier: "live",
|
||||
surface: "thread",
|
||||
protocol: "text_delta stream:output, streaming",
|
||||
protocol: 'PRP item.delta kind:"agentMessage" channel:"progress"',
|
||||
},
|
||||
"responding-burst": {
|
||||
id: "responding-burst",
|
||||
label: "Responding (update burst)",
|
||||
label: "Progress update burst",
|
||||
tier: "live",
|
||||
surface: "thread",
|
||||
protocol: "text_delta stream:output ×N, tool calls between (PAP-368 dwell)",
|
||||
|
||||
@@ -21,6 +21,7 @@ describe("toolTaxonomy", () => {
|
||||
verbLabel: "Running a command",
|
||||
});
|
||||
expect(toolTaxonomy("Shell").family).toBe("terminal");
|
||||
expect(toolTaxonomy("command").family).toBe("terminal");
|
||||
|
||||
expect(toolTaxonomy("Grep")).toEqual({
|
||||
family: "grep",
|
||||
|
||||
@@ -88,7 +88,7 @@ function classify(n: string): Exclude<ToolFamily, "mcp"> {
|
||||
if (first === "edit" || first === "write" || n === "multiedit" || n === "notebookedit") {
|
||||
return "edit";
|
||||
}
|
||||
if (first === "bash" || first === "shell" || first === "run" || n.includes("terminal")) {
|
||||
if (first === "bash" || first === "shell" || first === "run" || first === "command" || n.includes("terminal")) {
|
||||
return "terminal";
|
||||
}
|
||||
// Content grep is its own action (search-code glyph); file-name globbing and
|
||||
|
||||
@@ -407,6 +407,24 @@ describe("settledRunChildren (PAP-361)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("paperclip runner semantic channels", () => {
|
||||
it("keeps progress/final messages and reasoning summary/detail in distinct items", () => {
|
||||
const parsed = transcriptToTaskChatItems([
|
||||
{ kind: "assistant", ts: TS, text: "Checking.", channel: "progress", delta: true },
|
||||
{ kind: "thinking", ts: TS, text: "Summary", channel: "summary", delta: true },
|
||||
{ kind: "thinking", ts: TS, text: "Detail", channel: "detail", delta: true },
|
||||
{ kind: "assistant", ts: TS, text: "Done.", channel: "final", delta: true },
|
||||
], { runId: "run-channels", running: true });
|
||||
expect(parsed.map((item) => item.kind)).toEqual(["message", "thinking", "thinking", "message"]);
|
||||
expect(parsed[0]).toMatchObject({ interstitial: true, channel: "progress" });
|
||||
expect(parsed[1]).toMatchObject({ channel: "summary" });
|
||||
expect(parsed[2]).toMatchObject({ channel: "detail" });
|
||||
expect(parsed[3]).toMatchObject({ interstitial: false, channel: "final" });
|
||||
const history = settledRunChildren(parsed);
|
||||
expect(JSON.stringify(history)).not.toContain("Done.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildMergedTurnSummary (PAP-362)", () => {
|
||||
const runA: TranscriptEntry[] = [
|
||||
{ kind: "tool_call", ts: "2026-07-31T12:00:00.000Z", name: "Read", toolUseId: "a-1" } as TranscriptEntry,
|
||||
|
||||
@@ -168,18 +168,22 @@ export function transcriptToTaskChatItems(
|
||||
const thinkingStartTs = new Map<number, string>();
|
||||
let lastToolIndex = -1;
|
||||
let thinkingIndex = -1;
|
||||
let thinkingChannel: "summary" | "detail" | "unknown" | undefined;
|
||||
let messageIndex = -1;
|
||||
let messageChannel: "progress" | "final" | "unknown" | undefined;
|
||||
|
||||
const resetInline = () => {
|
||||
thinkingIndex = -1;
|
||||
thinkingChannel = undefined;
|
||||
messageIndex = -1;
|
||||
messageChannel = undefined;
|
||||
};
|
||||
|
||||
for (const [i, entry] of entries.entries()) {
|
||||
switch (entry.kind) {
|
||||
case "thinking": {
|
||||
if (!entry.text) break;
|
||||
if (thinkingIndex >= 0) {
|
||||
if (thinkingIndex >= 0 && thinkingChannel === entry.channel) {
|
||||
const it = items[thinkingIndex];
|
||||
if (it.kind === "thinking") {
|
||||
it.lines.push(...entry.text.split("\n"));
|
||||
@@ -196,16 +200,20 @@ export function transcriptToTaskChatItems(
|
||||
// Settled history folds its thinking behind the header (v7);
|
||||
// the in-flight run streams it expanded.
|
||||
collapsed: !running,
|
||||
channel: entry.channel,
|
||||
});
|
||||
thinkingIndex = items.length - 1;
|
||||
thinkingChannel = entry.channel;
|
||||
thinkingStartTs.set(thinkingIndex, entry.ts);
|
||||
messageIndex = -1;
|
||||
messageChannel = undefined;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "assistant": {
|
||||
if (!entry.text) break;
|
||||
if (messageIndex >= 0) {
|
||||
const channel = entry.channel;
|
||||
if (messageIndex >= 0 && messageChannel === channel) {
|
||||
const it = items[messageIndex];
|
||||
if (it.kind === "message") it.text += entry.text;
|
||||
} else {
|
||||
@@ -217,12 +225,15 @@ export function transcriptToTaskChatItems(
|
||||
authorName: agentName,
|
||||
text: entry.text,
|
||||
streaming: running,
|
||||
// Everything the agent says inside a run turn is self-talk until it
|
||||
// lands as the posted comment — live and history tag it alike.
|
||||
interstitial: true,
|
||||
// Explicit final-answer items stream durably. Unclassified text
|
||||
// remains transient for backwards compatibility and fail-closed
|
||||
// handling of a delta whose start metadata was missed.
|
||||
interstitial: channel !== "final",
|
||||
channel,
|
||||
atMs: Number.isFinite(atMs) ? atMs : undefined,
|
||||
});
|
||||
messageIndex = items.length - 1;
|
||||
messageChannel = channel;
|
||||
thinkingIndex = -1;
|
||||
}
|
||||
break;
|
||||
@@ -440,6 +451,9 @@ export function buildActivityPhases(
|
||||
const lastVisible = [...parsed].reverse().find((item) => item.kind !== "thinking");
|
||||
for (const item of parsed) {
|
||||
if (item.kind === "message") {
|
||||
// Durable final-answer text is rendered beside the Worked header, never
|
||||
// duplicated inside its expandable activity history.
|
||||
if (!item.interstitial) continue;
|
||||
// A settled transcript's trailing assistant text is the posted reply.
|
||||
// Live/settle-gap tails keep it visible until that canonical reply lands.
|
||||
if (!running && item === lastVisible) continue;
|
||||
|
||||
@@ -49,6 +49,8 @@ export interface IssueChatLinkedRun {
|
||||
hasStoredOutput?: boolean;
|
||||
logBytes?: number | null;
|
||||
errorCode?: string | null;
|
||||
scheduledRetryAt?: string | null;
|
||||
nextAction?: string | null;
|
||||
resultJson?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1143,7 +1143,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
const { data: liveRuns } = useQuery({
|
||||
queryKey: queryKeys.issues.liveRuns(issueId),
|
||||
queryFn: () => heartbeatsApi.liveRunsForIssue(issueId),
|
||||
refetchInterval: 3000,
|
||||
refetchInterval: 1000,
|
||||
placeholderData: keepPreviousDataForSameQueryTail<LiveRunForIssue[]>(issueId),
|
||||
});
|
||||
const resolvedLiveRuns = liveRuns ?? [];
|
||||
@@ -1152,7 +1152,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
queryKey: queryKeys.issues.activeRun(issueId),
|
||||
queryFn: () => heartbeatsApi.activeRunForIssue(issueId),
|
||||
enabled: !!executionRunId || issueStatus === "in_progress",
|
||||
refetchInterval: liveRunCount > 0 ? false : 3000,
|
||||
refetchInterval: liveRunCount > 0 ? false : 1000,
|
||||
placeholderData: keepPreviousDataForSameQueryTail<ActiveRunForIssue | null>(issueId),
|
||||
});
|
||||
const resolvedActiveRun = useMemo(
|
||||
@@ -1163,7 +1163,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
const { data: linkedRuns } = useQuery({
|
||||
queryKey: queryKeys.issues.runs(issueId),
|
||||
queryFn: () => activityApi.runsForIssue(issueId),
|
||||
refetchInterval: hasLiveRuns ? 5000 : false,
|
||||
refetchInterval: hasLiveRuns || issueStatus === "in_progress" ? 1000 : false,
|
||||
placeholderData: keepPreviousDataForSameQueryTail<RunForIssue[]>(issueId),
|
||||
});
|
||||
const resolvedActivity = activity ?? [];
|
||||
@@ -2738,8 +2738,12 @@ export function IssueDetail() {
|
||||
mutationFn: ({ body, reopen, interrupt }: { body: string; reopen?: boolean; interrupt?: boolean }) =>
|
||||
issuesApi.addComment(issueId!, body, reopen, interrupt),
|
||||
onMutate: async ({ body, reopen, interrupt }) => {
|
||||
await queryClient.cancelQueries({ queryKey: queryKeys.issues.comments(issueId!) });
|
||||
await queryClient.cancelQueries({ queryKey: queryKeys.issues.detail(issueId!) });
|
||||
// Start cache cancellation immediately but do not put it in front of the
|
||||
// optimistic echo. The new-runner startup placeholder must paint in the
|
||||
// same frame as send, even when an in-flight comments query is slow to
|
||||
// cancel.
|
||||
const cancelComments = queryClient.cancelQueries({ queryKey: queryKeys.issues.comments(issueId!) });
|
||||
const cancelIssue = queryClient.cancelQueries({ queryKey: queryKeys.issues.detail(issueId!) });
|
||||
|
||||
const previousIssue = queryClient.getQueryData<Issue>(queryKeys.issues.detail(issueId!));
|
||||
const queuedComment = !interrupt
|
||||
@@ -2766,6 +2770,8 @@ export function IssueDetail() {
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all([cancelComments, cancelIssue]);
|
||||
|
||||
return {
|
||||
optimisticCommentId: optimisticComment?.clientId ?? null,
|
||||
queuedCommentTargetRunId: queuedComment?.id ?? null,
|
||||
|
||||
Reference in new issue
Block a user