From 56f8365a19c33825897fe6b9c23e0eb2e5958303 Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 15 Oct 2026 07:47:31 -0500 Subject: [PATCH 1/2] fix(runner): preserve prepared turns for v7 native input Co-Authored-By: Paperclip --- .../src/backends/codex-native-backend.ts | 5 +- .../backends/native-backend-factory.test.ts | 57 ++++++++++++++++--- .../src/backends/opencode-native-backend.ts | 5 +- 3 files changed, 55 insertions(+), 12 deletions(-) diff --git a/packages/paperclip-runner/src/backends/codex-native-backend.ts b/packages/paperclip-runner/src/backends/codex-native-backend.ts index a8d89cf4300..72766ec8761 100644 --- a/packages/paperclip-runner/src/backends/codex-native-backend.ts +++ b/packages/paperclip-runner/src/backends/codex-native-backend.ts @@ -1,6 +1,6 @@ import { createCodexTaskEnvelope } from "../contracts/codex.js"; import { ACPX_CAPABILITY_PROFILES } from "../drivers/acpx/capability-profiles.js"; -import { NATIVE_EXECUTION_INPUT_SCHEMA } from "../contracts/native-execution.js"; +import { NATIVE_EXECUTION_INPUT_SCHEMA, NATIVE_EXECUTION_INPUT_SCHEMA_V7 } from "../contracts/native-execution.js"; import type { NativeExecutionInput } from "../contracts/native-execution.js"; import type { PersistedHarnessSession } from "../contracts/harness-driver.js"; import type { @@ -115,7 +115,8 @@ function createTransportBackedNativeSessionBackend( } const driverIdentity = transportDriverIdentity(input); const isCodex = input.provider.kind === "codex"; - const preparedContext = input.schema === NATIVE_EXECUTION_INPUT_SCHEMA; + const preparedContext = input.schema === NATIVE_EXECUTION_INPUT_SCHEMA + || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V7; const supportsCollaborativePlanning = isCodex || input.provider.kind === "opencode" || diff --git a/packages/paperclip-runner/src/backends/native-backend-factory.test.ts b/packages/paperclip-runner/src/backends/native-backend-factory.test.ts index d7f0c1c9d95..90b4eb77a68 100644 --- a/packages/paperclip-runner/src/backends/native-backend-factory.test.ts +++ b/packages/paperclip-runner/src/backends/native-backend-factory.test.ts @@ -4,13 +4,13 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { parseNativeExecutionInput, type NativeExecutionInput } from "../contracts/native-execution.js"; +import { buildNativeModelEnvelope, parseNativeExecutionInput, type NativeExecutionInput } from "../contracts/native-execution.js"; import { NATIVE_RUNTIME_ASSET_SCHEMA, PAPERCLIP_EXECUTION_PROMPT, PAPERCLIP_EXECUTION_PROMPT_REVISION, nativeRuntimePromptDigest, canonicalNativeRuntimeContextDigest, composeNativeSystemInstructions } from "../contracts/runtime-context.js"; const contextRoots: string[] = []; afterEach(() => { for (const root of contextRoots.splice(0)) rmSync(root, { recursive: true, force: true }); }); -function preparedExecution(base: NativeExecutionInput): NativeExecutionInput { +function preparedExecution(base: NativeExecutionInput, version: "v5" | "v7" = "v5"): NativeExecutionInput { const root = mkdtempSync(join(tmpdir(), "paperclip-prepared-backend-")); contextRoots.push(root); writeFileSync(join(root, "AGENTS.md"), "Keep assigned instructions."); @@ -21,7 +21,7 @@ function preparedExecution(base: NativeExecutionInput): NativeExecutionInput { skills: [], mcp: { assignmentSetId: "none", digest, bindingId: null }, }; return parseNativeExecutionInput({ - ...base, schema: "paperclip.native-execution-input.v5", executionMode: "default", planningContext: null, + ...base, schema: `paperclip.native-execution-input.${version}`, executionMode: "default", planningContext: null, runtimeContext: { ...context, aggregateDigest: canonicalNativeRuntimeContextDigest(context) }, completionContract: { ...base.completionContract, contract: { ...base.completionContract.contract, criteria: [{ id: "objective", requirement: "Complete the task." }] } }, }); @@ -31,7 +31,7 @@ import { WORKSPACE, } from "../drivers/codex/codex-app-server-driver.test-support.js"; import { createNativeSessionBackend } from "../index.js"; -import { createCodexNativeSessionBackend } from "./codex-native-backend.js"; +import { createCodexNativeSessionBackend, createRunnerdNativeSessionBackend } from "./codex-native-backend.js"; import { createOpenCodeNativeSessionBackend } from "./opencode-native-backend.js"; function execution( @@ -597,8 +597,8 @@ describe("native backend factory", () => { ).toThrow("Codex native backend requires provider kind codex"); }); - it("selects prepared transport only for v5 native input and retains prepared constraints", async () => { - const input = preparedExecution(execution()); + it.each(["v5", "v7"] as const)("selects prepared transport for %s native input and retains prepared constraints", async version => { + const input = preparedExecution(execution(), version); const transport = new FakeCodexTransport(); const backend = createCodexNativeSessionBackend(input, { transportFactory: () => transport, @@ -626,8 +626,49 @@ describe("native backend factory", () => { ); }); - it("projects v5 OpenCode constraints to the prepared runtime boundary", () => { - const input = preparedExecution(opencodeExecution()); + it("allows a completion-only turn after v7 Claude work without wrapping prepared input", async () => { + const { qualificationModel: _model, reportedModelId: _reported, permissionPolicy: _policy, ...profile } = resolveQualifiedAcpxProfile("claude", "claude-opus-5-5"); + const input = preparedExecution({ ...acpxExecution("claude"), provider: { + kind: "acpx", agent: "claude", model: "claude-opus-5-5", permissionMode: "approve-all", profile, + } }, "v7"); + if (input.schema !== "paperclip.native-execution-input.v7" || input.provider.kind !== "acpx") throw new Error("invalid fixture"); + input.provider.claudeEffort = "high"; + const transport = new FakeCodexTransport("thread-1", "provider-session-1", { + kind: "acpx", normalizedSessionId: "session", acpxRecordId: "fixture-record", + backendSessionId: "fixture-backend", agentSessionId: "fixture-agent", + profileDigest: `sha256:${"a".repeat(64)}`, workspaceDigest: `sha256:${"b".repeat(64)}`, + requestedModel: input.provider.model, effectiveModel: input.provider.model, permissionMode: "approve-all", + providerLifetimeFenceCandidates: [60001, 60002, 60003], + }); + const backend = createRunnerdNativeSessionBackend(input, { + transportFactory: () => transport, + environment: { PAPERCLIP_WORKSPACE_CWD: WORKSPACE }, + }); + const session = await backend.openSession({ + identity: { companyId: "company", agentId: "agent", runId: "run", sessionId: "session" }, + workingDirectory: WORKSPACE, + }); + const first = JSON.stringify(buildNativeModelEnvelope(input)); + const completion = "Call paperclip_finish with the result of the work already completed."; + try { + for (const [index, text] of [first, completion].entries()) { + transport.turnStartResponse = Promise.resolve({ turn: { id: `turn-${index + 1}`, status: "inProgress", items: [] } }); + const turn = await session.startTurn({ message: { role: "user", text } }); + transport.push("turn/completed", { threadId: "thread-1", turn: { id: turn.turnId, status: "completed", items: [] } }); + for await (const event of session.events()) { + if (["turn.completed", "turn.failed", "turn.cancelled"].includes(event.eventType)) break; + } + } + const turns = transport.calls.filter(call => call.method === "turn/start"); + expect(turns.map(call => call.params.input)).toEqual([first, completion].map(text => [{ type: "text", text, text_elements: [] }])); + expect(backend.preparedTaskConstraints).toEqual(expect.arrayContaining([expect.stringContaining("Obtain one accepted result")])); + } finally { + await session.close({ reason: "test-completed" }); + } + }); + + it.each(["v5", "v7"] as const)("projects %s OpenCode constraints to the prepared runtime boundary", version => { + const input = preparedExecution(opencodeExecution(), version); const backend = createOpenCodeNativeSessionBackend(input, { runtimeDirectory: "/tmp/paperclip-opencode-prepared-factory", }); diff --git a/packages/paperclip-runner/src/backends/opencode-native-backend.ts b/packages/paperclip-runner/src/backends/opencode-native-backend.ts index 329cd7fafb2..02906c3d95c 100644 --- a/packages/paperclip-runner/src/backends/opencode-native-backend.ts +++ b/packages/paperclip-runner/src/backends/opencode-native-backend.ts @@ -1,5 +1,5 @@ import { createCodexTaskEnvelope } from "../contracts/codex.js"; -import { NATIVE_EXECUTION_INPUT_SCHEMA } from "../contracts/native-execution.js"; +import { NATIVE_EXECUTION_INPUT_SCHEMA, NATIVE_EXECUTION_INPUT_SCHEMA_V7 } from "../contracts/native-execution.js"; import type { NativeExecutionInput } 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"; @@ -13,7 +13,8 @@ export function createOpenCodeNativeSessionBackend( if (input.provider.kind !== "opencode" || !input.provider.model) { throw new Error("OpenCode native backend requires a persisted OpenCode provider/model selection"); } - const preparedContext = input.schema === NATIVE_EXECUTION_INPUT_SCHEMA; + const preparedContext = input.schema === NATIVE_EXECUTION_INPUT_SCHEMA + || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V7; const constraints = nativeTaskConstraints(input); return new HarnessDriverBackend(new OpenCodeServerDriver({ From c882275a50930a23231557b40a2ed714028202d4 Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 15 Oct 2026 07:47:34 -0500 Subject: [PATCH 2/2] fix(ui): save the configuration admitted during agent setup Co-Authored-By: Paperclip --- ui/src/components/new-agent/NewAgentSetup.tsx | 22 ++++++------- ui/src/pages/NewAgent.test.tsx | 31 ++++++++++++++++--- 2 files changed, 37 insertions(+), 16 deletions(-) diff --git a/ui/src/components/new-agent/NewAgentSetup.tsx b/ui/src/components/new-agent/NewAgentSetup.tsx index 96f5a04ade9..ae2b13cbddb 100644 --- a/ui/src/components/new-agent/NewAgentSetup.tsx +++ b/ui/src/components/new-agent/NewAgentSetup.tsx @@ -481,8 +481,8 @@ function Setup({ }; } - async function runTest(nextConnection = connection): Promise { - if (!ready) return false; + async function runTest(nextConnection = connection): Promise | null> { + if (!ready) return null; const run = ++generation.current; const setupEnvironment = setupEnvironmentRef.current; setTestState("running"); @@ -497,7 +497,7 @@ function Setup({ isCurrent: () => run === generation.current && setupEnvironmentRef.current === setupEnvironment, }); if (run !== generation.current || setupEnvironmentRef.current !== setupEnvironment - || (needsClaudeSetupQualification(selection) && !qualified)) return false; + || (needsClaudeSetupQualification(selection) && !qualified)) return null; if (qualified) { qualificationTransition.current = { run, companyId, environmentId, runner: qualified.runner }; cache.setQueryData(queryKeys.adapters.availability(companyId, environmentId), qualified.adapters); @@ -513,7 +513,7 @@ function Setup({ aiConnection: runtimeAiBinding ?? nextConnection?.aiConnection, environmentId, }); - if (run !== generation.current) return false; + if (run !== generation.current) return null; setResult(tested); setTestState(blocking(tested) ? "fail" : tested.status); return ( @@ -522,7 +522,7 @@ function Setup({ tested.checks.some((check) => check.code.endsWith("hello_probe_passed"), )) - ); + ) ? config : null; } catch (cause) { if (run === generation.current) { setError( @@ -530,7 +530,7 @@ function Setup({ ); setTestState("fail"); } - return false; + return null; } } async function finish() { @@ -551,11 +551,11 @@ function Setup({ const staged: Array<{ remove: () => Promise }> = []; let hired = false; try { - if (connectionAdapter && testState === "idle" && !(await runTest())) return; - const config = preparedConfig(); const credentials = pendingCredentials(); - // Untested entered keys must pass a probe before they can be stored. - if (Object.keys(credentials).length && !(await runTest())) return; + const config = (connectionAdapter && testState === "idle") || Object.keys(credentials).length + ? await runTest() + : preparedConfig(); + if (!config) return; for (const [key, value] of Object.entries(credentials)) { const store = connectionAdapter ? storeProviderApiKey @@ -828,7 +828,7 @@ function Setup({ setRuntimeAiBinding(undefined); resetTest(); }} - testConnection={runTest} + testConnection={async next => (await runTest(next)) !== null} testError={ error ?? ( diff --git a/ui/src/pages/NewAgent.test.tsx b/ui/src/pages/NewAgent.test.tsx index b1ae00b2177..4679b870724 100644 --- a/ui/src/pages/NewAgent.test.tsx +++ b/ui/src/pages/NewAgent.test.tsx @@ -9,7 +9,10 @@ import { } from "./NewAgent.test-support"; describe("New agent setup", () => { - it.each(["paperclip", "legacy"] as const)("qualifies new managed Claude on explicit setup and saves measured %s defaults", async measured => { + it.each([ + ["paperclip", "Connect"], ["legacy", "Connect"], + ["paperclip", "Finish setup"], ["legacy", "Finish setup"], + ] as const)("qualifies new managed Claude and saves measured %s defaults through %s", async (measured, step) => { const image = `registry.example.test/runtime@sha256:${"a".repeat(64)}`; envApi.list.mockResolvedValue([{ id: "sandbox-1", driver: "sandbox", config: { provider: "daytona", image } }]); settings.get.mockResolvedValue({ defaultEnvironmentId: "sandbox-1" }); @@ -32,12 +35,30 @@ describe("New agent setup", () => { api.testEnvironment.mockImplementation(async () => { calls.push("adapter"); return pass; }); await render("claude_local"); expect(envApi.probe).not.toHaveBeenCalled(); - await click("ClaudeSubscription"); - calls.length = 0; - await click("Use saved subscription"); + if (step === "Connect") { + await click("ClaudeSubscription"); + calls.length = 0; + await click("Use saved subscription"); + } else { + managedApi.list.mockResolvedValue({ currentUserId: "user-1", connections: [{ + id: "00000000-0000-4000-8000-000000000021", grantId: "00000000-0000-4000-8000-000000000022", companyId: "company-1", + provider: "openrouter", method: "api_key", name: "Company OpenRouter", ownership: "shared", isDefault: false, status: "connected", + routing: { kind: "openrouter", protocol: "messages", auth: "bearer", models: [] }, + }] } as any); + await click("AdvancedCustom Gateway"); + const picker = container.querySelector('[role="combobox"][aria-label="Connection"]')!; + await act(async () => picker.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }))); + await settle(); + const option = [...document.querySelectorAll('[role="option"]')].find(node => node.textContent?.includes("Company OpenRouter")) as HTMLElement; + await act(async () => option.click()); + await settle(); + await click("Use connection"); + expect(api.testEnvironment).not.toHaveBeenCalled(); + calls.length = 0; + } + await click("Finish setup"); expect(calls).toEqual(["discovery-before", "platform", "discovery-after", "adapter"]); expect(envApi.probe).toHaveBeenCalledExactlyOnceWith("sandbox-1", "company-1"); - await click("Finish setup"); expect(api.testEnvironment).toHaveBeenCalledTimes(1); expect(api.hire).toHaveBeenCalledTimes(1); const tested = api.testEnvironment.mock.calls[0][2].adapterConfig;