From 39e44a9d9b0dbbb327beecc490c0e5095f92649b Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:09:15 -0500 Subject: [PATCH] Default new OpenCode agents to Paperclip Runner (#17120) Default new OpenCode agents to Paperclip Runner on qualified macOS and Linux x64 targets. Preserve saved runner choices, explicit Legacy selection, and unsupported-target fallback. Pi remains a separate change. Verified the complete browser path with a real OpenRouter connection: create the new agent, assign a task, observe its response and Done status, and reload to confirm persistence. CI passes and Greptile is 5/5 with no unresolved findings. Co-Authored-By: Paperclip --- doc/DEVELOPING.md | 20 +++++-- maps/agent-access-map/runs-adapters.md | 7 +++ maps/feature-map/runs-adapters.md | 13 +++++ .../opencode-local/src/ui/build-config.ts | 7 +++ packages/shared/src/agent-runner.ts | 4 +- .../agent-adapter-validation-routes.test.ts | 33 ++++++++++- .../agent-hire-ai-connections.test.ts | 2 +- .../agent-permissions-routes.test.ts | 15 +++-- .../src/__tests__/company-portability.test.ts | 12 ++-- server/src/__tests__/dot-runner.test.ts | 2 +- .../native-status-arbiter-corpus.test.ts | 7 ++- ...teams-catalog-install-no-overrides.test.ts | 10 ++-- server/src/routes/agents.ts | 15 +++-- .../services/agent-runner-selection.test.ts | 51 ++++++++++++++++- server/src/services/agent-runner-selection.ts | 46 ++++++++++++++-- server/src/services/built-in-agents.ts | 2 +- server/src/services/company-portability.ts | 4 +- .../native-runtime/runtime-mode.test.ts | 16 ++++-- .../services/native-runtime/runtime-mode.ts | 2 +- .../adapters/adapter-display-registry.test.ts | 1 + ui/src/adapters/adapter-display-registry.ts | 2 +- ui/src/components/AgentActionButtons.tsx | 2 +- .../AgentConfigForm.render.test.tsx | 32 +++++------ ui/src/components/AgentConfigForm.tsx | 15 ++--- .../components/ConfigureBuiltInAgentModal.tsx | 6 +- ui/src/components/OnboardingWizard.tsx | 13 +++-- ui/src/components/new-agent/NewAgentSetup.tsx | 46 +++++++++++----- ui/src/pages/CompanyImport.test.tsx | 21 ++++++- ui/src/pages/CompanyImport.tsx | 10 ++-- ui/src/pages/NewAgent.test.tsx | 55 +++++++++++++++++++ 30 files changed, 373 insertions(+), 98 deletions(-) diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 4b500837668..bec91b9fe0c 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -1935,8 +1935,11 @@ agent conversion are outside this playbook. Paperclip Runner offers native Codex, OpenCode, and **ACPX Claude**. New Claude Code agents default to ACPX Claude on Apple Silicon, Intel Mac, Linux x64 -and Linux ARM64 with supported glibc. Unsupported or unqualified targets retain -Legacy. Darwin SSH keeps an automatic Legacy default; explicit native execution +and Linux ARM64 with supported glibc. New OpenCode agents default to native +OpenCode on Apple Silicon, Intel Mac, and Linux x64 with supported glibc; +Linux ARM64 remains Legacy until the pinned OpenCode runtime supports it. +Unsupported or unqualified targets retain Legacy. Claude over Darwin SSH keeps +an automatic Legacy default; explicit native execution requires matching remote assets. The runner override is in Advanced. Ordinary Claude models default to `claude-opus-5-5`; explicit models and provider routing remain authoritative. Existing agents keep their saved runner. Converting an existing Claude, Codex, or OpenCode agent selects its corresponding provider, preserves compatible models, credentials, workspace, @@ -1944,8 +1947,17 @@ and instructions, and resets execution sessions while retaining run history. Other adapters require an explicit provider choice. Legacy ACPX Codex agent settings normalize to native Codex on configuration updates and before fresh runs; immutable run descriptors remain readable. The **Paperclip Runner** -experimental setting applies to other native providers. Native Codex and ACPX -Claude do not require it. Company access checks still apply. +experimental setting applies to other native providers. Native Codex, OpenCode, +and ACPX Claude do not require it. Company access checks still apply. + +OpenCode requires an explicit `provider/model` before testing or finishing setup. +An inactive setup draft can be saved before choosing a model. Automatic native +selection uses `allow` permissions and a `per_turn` lifecycle. Native `ask` and +`deny` policies remain available in agent configuration. Legacy custom commands, +extra arguments, model variants, and inherited permission rules require the +Legacy runner; incompatible settings return remediation instead of being ignored. +Saved OpenCode agents keep their runner and runtime policies. Pi defaults are +unchanged. Agent configuration uses the same section layout across adapters: model and provider belong to **Adapter**, environment variables have their own section, diff --git a/maps/agent-access-map/runs-adapters.md b/maps/agent-access-map/runs-adapters.md index 3c0c53209b4..7bfef0f5e13 100644 --- a/maps/agent-access-map/runs-adapters.md +++ b/maps/agent-access-map/runs-adapters.md @@ -64,6 +64,13 @@ and explicit overrides follow the [shared runner contract](../../packages/shared Claude follows its qualified rollout described above. The board inventory does not grant a native agent an equivalent discovery binding. See [probe restrictions](./execution-environments.md#probe) for acquiring qualification. +OpenCode now follows the qualified native default without the experimental +flag on macOS and GNU/Linux x64; Linux ARM64 retains Legacy. An explicit +`provider/model` is required for test/hire; only server-owned +inactive setup drafts may omit it. Saved runners and permission/lifecycle +policies remain authoritative. Actor/company checks and board-only discovery +remain unchanged. This is scoped source/control evidence for OpenCode selection, +not live provider execution or agent authorization proof; Pi is unchanged. An absent forced-Kubernetes environment is an unqualified sandbox target, never a local target. Discovery and automatic creation keep the Legacy path without provisioning it; explicit Paperclip selection returns qualification guidance. diff --git a/maps/feature-map/runs-adapters.md b/maps/feature-map/runs-adapters.md index 2ac7ce82e92..7b40cf08ee3 100644 --- a/maps/feature-map/runs-adapters.md +++ b/maps/feature-map/runs-adapters.md @@ -61,6 +61,19 @@ and [runtime prerequisites](../../packages/paperclip-runner/README.md) define th platform/libc boundary. Windows and Alpine/musl remain Legacy. Claude follows its qualified rollout described above. Disabled adapters, external overrides, and selected-target qualification also affect availability. + +Choose OpenCode with the native-runner experimental flag off on a qualified +macOS or GNU/Linux x64 target. Linux ARM64 must retain Legacy because its pinned +OpenCode runtime is not qualified. Advanced should show Paperclip Runner as the +automatic default on qualified targets. Save an inactive draft without a model, +resume it, select an explicit `provider/model`, +test, finish setup, and reload. Verify the saved provider is `opencode`. Repeat +with explicit Legacy and an existing Legacy agent; neither should migrate on an +unrelated edit. Check that saved native permission/lifecycle choices survive +resuming setup. Custom commands, arguments, variants, and inherited permissions +must give Legacy remediation when native is selected. A live task with the +selected account is separate proof from these setup and persistence controls. +Pi retains its existing default and is outside this rollout. On a fresh forced-Kubernetes instance without an environment row, inspect discovery and create with automatic selection. Both must retain Legacy without provisioning; an explicit Paperclip request must give target-qualification guidance. Begin the diff --git a/packages/adapters/opencode-local/src/ui/build-config.ts b/packages/adapters/opencode-local/src/ui/build-config.ts index 17d9da95094..8f7085c1960 100644 --- a/packages/adapters/opencode-local/src/ui/build-config.ts +++ b/packages/adapters/opencode-local/src/ui/build-config.ts @@ -14,6 +14,13 @@ export function buildOpenCodeLocalConfig(v: CreateConfigValues): Record { expect(mockAgentService.create).not.toHaveBeenCalled(); }); + it.each(["auto", "legacy"] as const)("creates OpenCode through the public route with %s selection and the experimental flag off", async runner => { + const selection = await import("../services/agent-runner-selection.js"); + vi.spyOn(selection, "resolveNewAgentRunnerForCompany").mockImplementation(async (_db, _companyId, input) => + selection.resolveNewAgentRunner({ ...input, target: { driver: "local", platform: "darwin", architecture: "arm64" } })); + const app = await createApp(); + const model = "openrouter/anthropic/claude-sonnet-4.6"; + const response = await requestApp(app, baseUrl => request(baseUrl).post("/api/companies/company-1/agents").send({ name: "OpenCode", adapterType: "opencode_local", runner, adapterConfig: { model } })); + expect(response.status, JSON.stringify(response.body)).toBe(201); + expect(mockAgentService.create.mock.calls.at(-1)?.[1]).toMatchObject({ adapterType: runner === "legacy" ? "opencode_local" : "paperclip_runner", adapterConfig: runner === "legacy" ? { model } : { provider: "opencode", model, opencodePermissionMode: "allow" } }); + }); + + it("allows model-free OpenCode only as an inactive setup draft", async () => { + const selection = await import("../services/agent-runner-selection.js"); + vi.spyOn(selection, "resolveNewAgentRunnerForCompany").mockImplementation(async (_db, _companyId, input) => + selection.resolveNewAgentRunner({ ...input, target: { driver: "local", platform: "darwin", architecture: "arm64" } })); + const originalCreate = mockAgentService.create.getMockImplementation()!; + mockAgentService.create.mockImplementation(async (...args) => ({ ...await originalCreate(...args), lifecycleHolds: ["setup"] })); + const app = await createApp(); + const input = { name: "OpenCode draft", adapterType: "opencode_local", adapterConfig: {} }; + const draft = await requestApp(app, baseUrl => request(baseUrl).post("/api/companies/company-1/agents") + .send({ ...input, setupId: "00000000-0000-4000-8000-000000000092" })); + expect(draft.status, JSON.stringify(draft.body)).toBe(201); + expect(mockAgentService.create.mock.calls.at(-1)?.[1]).toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { provider: "opencode", model: "" } }); + expect(mockAgentService.create.mock.calls.at(-1)?.[2]).toMatchObject({ setupOnly: true, runnerResolved: true }); + mockAgentService.create.mockClear(); + const active = await requestApp(app, baseUrl => request(baseUrl).post("/api/companies/company-1/agents") + .send({ ...input, setupOnly: true })); + expect(active.status).toBe(422); + expect(mockAgentService.create).not.toHaveBeenCalled(); + }); + it("keeps other native provider selection behind its existing flag", async () => { const app = await createApp(); - const response = await requestApp(app, baseUrl => request(baseUrl).post("/api/companies/company-1/agents").send({ name: "Native OpenCode", adapterType: "paperclip_runner", adapterConfig: { provider: "opencode", model: "openrouter/deepseek/deepseek-v4-flash-0731" } })); + const response = await requestApp(app, baseUrl => request(baseUrl).post("/api/companies/company-1/agents").send({ name: "Native Pi", adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "pi", model: "openrouter/deepseek/deepseek-v4-flash-0731" } })); expect(response.status).toBe(422); expect(response.body.details).toMatchObject({ code: "paperclip_runner_rollout_disabled" }); }); diff --git a/server/src/__tests__/agent-hire-ai-connections.test.ts b/server/src/__tests__/agent-hire-ai-connections.test.ts index 7bf7d5cd102..88279b91f2e 100644 --- a/server/src/__tests__/agent-hire-ai-connections.test.ts +++ b/server/src/__tests__/agent-hire-ai-connections.test.ts @@ -154,7 +154,7 @@ describe("agent-created hires use managed AI connections", () => { }); it.each([ - ["opencode_local", {}, "PAPERCLIP_OPENCODE_PROVIDERS"], + ["opencode_local", { model: "anthropic/claude-sonnet-5" }, "PAPERCLIP_OPENCODE_PROVIDERS"], ["opencode_local", { model: "anthropic/claude-sonnet-5" }, "OPENCODE_AUTH_JSON"], ["paperclip_runner", { provider: "opencode", model: "anthropic/claude-sonnet-5" }, "OPENCODE_CONFIG_CONTENT"], ] as const)("%s hires with %j apply the host boundary to explicit %s auth", async (adapterType, config, key) => { diff --git a/server/src/__tests__/agent-permissions-routes.test.ts b/server/src/__tests__/agent-permissions-routes.test.ts index bb609d6890d..95ee50519cc 100644 --- a/server/src/__tests__/agent-permissions-routes.test.ts +++ b/server/src/__tests__/agent-permissions-routes.test.ts @@ -129,6 +129,10 @@ const mockInstanceSettingsService = vi.hoisted(() => ({ const mockRunSshCommand = vi.hoisted(() => vi.fn()); function registerModuleMocks() { + vi.doMock("../lib/agent-runner-platform.js", () => ({ + localAgentRunnerTarget: () => ({ driver: "local", platform: "linux", architecture: "x64", libc: "glibc", glibcVersion: "2.39" }), + localPaperclipRunnerSupported: () => true, + })); vi.doMock("@paperclipai/adapter-utils/ssh", async (importOriginal) => ({ ...(await importOriginal()), runSshCommand: mockRunSshCommand, @@ -1461,7 +1465,7 @@ describe("agent permission routes", () => { ); }); - it("seeds opencode agent creation with the static default model without live discovery", async () => { + it("seeds explicit legacy opencode creation with the static default model without live discovery", async () => { mockEnsureOpenCodeModelConfiguredAndAvailable.mockRejectedValue( new Error("`opencode models` should not be called during creation"), ); @@ -1480,6 +1484,7 @@ describe("agent permission routes", () => { name: "OpenCode Builder", role: "engineer", adapterType: "opencode_local", + runner: "legacy", adapterConfig: {}, })); @@ -1526,8 +1531,9 @@ describe("agent permission routes", () => { expect(mockAgentService.create).toHaveBeenCalledWith( companyId, expect.objectContaining({ - adapterType: "opencode_local", + adapterType: "paperclip_runner", adapterConfig: expect.objectContaining({ + provider: "opencode", model: "anthropic/claude-sonnet-4-5", }), }), @@ -1831,7 +1837,7 @@ describe("agent permission routes", () => { for (const adapterCase of sshCapableAdapterCases) { it(`allows creating a ${adapterCase.adapterType} agent with an SSH default environment`, async () => { - const expectedAdapterType = ["codex_local", "claude_local"].includes(adapterCase.adapterType) ? "paperclip_runner" : adapterCase.adapterType; + const expectedAdapterType = ["codex_local", "claude_local", "opencode_local"].includes(adapterCase.adapterType) ? "paperclip_runner" : adapterCase.adapterType; const environmentId = "33333333-3333-4333-8333-333333333333"; mockEnvironmentService.getById.mockResolvedValue({ id: environmentId, @@ -1873,8 +1879,9 @@ describe("agent permission routes", () => { }), { runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "board-user", responsibleUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } }, ); - if (["codex_local", "claude_local"].includes(adapterCase.adapterType)) { + if (["codex_local", "claude_local", "opencode_local"].includes(adapterCase.adapterType)) { if (adapterCase.adapterType === "codex_local") expect(mockAgentService.create.mock.calls[0][1].adapterConfig).toMatchObject({ provider: "codex" }); + else if (adapterCase.adapterType === "opencode_local") expect(mockAgentService.create.mock.calls[0][1].adapterConfig).toMatchObject({ provider: "opencode", model: adapterCase.adapterConfig.model }); else expect(mockAgentService.create.mock.calls[0][1].adapterConfig).toMatchObject({ provider: "acpx", acpxAgent: "claude", model: "claude-opus-5-5" }); const { AGENT_RUNNER_SSH_PLATFORM_COMMAND } = await import("../services/agent-runner-selection.js"); expect(mockRunSshCommand).toHaveBeenCalledWith(expect.objectContaining({ host: "qa.example.invalid" }), AGENT_RUNNER_SSH_PLATFORM_COMMAND, { timeoutMs: 10_000 }); diff --git a/server/src/__tests__/company-portability.test.ts b/server/src/__tests__/company-portability.test.ts index 76a5d0e9d41..2a50f43878f 100644 --- a/server/src/__tests__/company-portability.test.ts +++ b/server/src/__tests__/company-portability.test.ts @@ -6106,7 +6106,7 @@ describe("company portability", () => { expect(() => resolvePaperclipRunnerProviderProfile(createdConfig)).toThrow(expect.objectContaining({ code: "paperclip_runner_dot_config_invalid" })); }); - it("rejects runner imports while disabled and accepts the same selection when enabled", async () => { + it("accepts native OpenCode imports without the experimental flag", async () => { const portability = companyPortabilityService({} as any); const exported = await portability.exportBundle("company-1", { include: { company: false, agents: true, projects: false, issues: false }, @@ -6134,11 +6134,11 @@ describe("company portability", () => { }, }; - await expect(portability.importBundle(request, "user-1")).rejects.toMatchObject({ - status: 422, - details: { code: "paperclip_runner_rollout_disabled" }, - }); - expect(agentSvc.create).not.toHaveBeenCalled(); + await portability.importBundle(request, "user-1"); + expect(agentSvc.create).toHaveBeenCalledWith("company-1", expect.objectContaining({ + adapterType: "paperclip_runner", + adapterConfig: expect.objectContaining({ provider: "opencode" }), + }), { createdByUserId: "user-1" }); instanceSettingsSvc.getExperimental.mockResolvedValue({ enableNativeRunner: true }); await portability.importBundle({ diff --git a/server/src/__tests__/dot-runner.test.ts b/server/src/__tests__/dot-runner.test.ts index ff99b1f8cbd..c1e34beb646 100644 --- a/server/src/__tests__/dot-runner.test.ts +++ b/server/src/__tests__/dot-runner.test.ts @@ -109,7 +109,7 @@ describe("durable Dot Runner integration", () => { expect(() => resolveNativeRuntimeMode(input)).toThrow("experimental settings"); expect(resolveNativeRuntimeMode({ ...input, enabled: false, dotEnabled: true })).toMatchObject({ kind: "native", profile: { backend: "openai_dot_mcp" } }); expect(() => resolveNativeRuntimeMode({ ...input, enabled: false, dotEnabled: true, - adapterConfig: { provider: "opencode", model: "openrouter/deepseek/deepseek-v4-flash-0731" }, + adapterConfig: { provider: "acpx", acpxAgent: "pi", model: "anthropic/claude-sonnet-4-6" }, })).toThrow("Paperclip Runner is experimental and disabled"); expect(resolveHeartbeatNativeRuntimeMode({ ...input, enabled: false, dotEnabled: false, persisted: { runtimeMode: "native", runtimeModeReason: null, runtimeModeResolvedAt: new Date(), driverKind: "openai_dot_mcp" }, diff --git a/server/src/__tests__/native-status-arbiter-corpus.test.ts b/server/src/__tests__/native-status-arbiter-corpus.test.ts index 1451ea61559..432f04603f0 100644 --- a/server/src/__tests__/native-status-arbiter-corpus.test.ts +++ b/server/src/__tests__/native-status-arbiter-corpus.test.ts @@ -849,11 +849,12 @@ describe("P6-31 Section 18.13 executable status-authority corpus", () => { && (fixture.covers.migrationRows ?? []).includes("MIG-08") ) { const experimentalAdapterConfig = { - provider: "opencode", - model: "openrouter/deepseek/deepseek-v4-flash-0731", + provider: "acpx", + acpxAgent: "pi", + model: "anthropic/claude-sonnet-4-6", }; const runtimeConfig = { - nativeRunner: { mode: "native", backend: "opencode_server", protocolVersion: 1 }, + nativeRunner: { mode: "native", backend: "acpx_runtime", protocolVersion: 1 }, }; const enabled = options.disableLiveEntrypoint === "rollout"; const activeResolution = resolveHeartbeatNativeRuntimeMode({ diff --git a/server/src/__tests__/teams-catalog-install-no-overrides.test.ts b/server/src/__tests__/teams-catalog-install-no-overrides.test.ts index 01810075f33..c5e3445bf7f 100644 --- a/server/src/__tests__/teams-catalog-install-no-overrides.test.ts +++ b/server/src/__tests__/teams-catalog-install-no-overrides.test.ts @@ -58,7 +58,7 @@ describeEmbeddedPostgres("teams catalog install with no caller adapter overrides return companyId; } - async function listAdapterTypesByName(companyId: string) { + async function listAdapterTypesByName(companyId: string, expectedOpenCodeName?: string) { const rows = await db .select({ name: agents.name, @@ -70,7 +70,9 @@ describeEmbeddedPostgres("teams catalog install with no caller adapter overrides .from(agents) .where(eq(agents.companyId, companyId)); for (const row of rows.filter(row => row.adapterType === "paperclip_runner")) { - expect(row.adapterConfig).toMatchObject({ provider: "acpx", acpxAgent: "claude" }); + expect(row.adapterConfig).toMatchObject(row.name === expectedOpenCodeName + ? { provider: "opencode", model: "anthropic/claude-opus-4" } + : { provider: "acpx", acpxAgent: "claude" }); } return new Map(rows.map((row) => [row.name, row])); } @@ -138,10 +140,10 @@ describeEmbeddedPostgres("teams catalog install with no caller adapter overrides }, }); - const byName = await listAdapterTypesByName(companyId); + const byName = await listAdapterTypesByName(companyId, "CTO"); expect(byName.size).toBe(3); const ctoRow = Array.from(byName.values()).find((row) => row.role === "engineering-manager" || row.name === "CTO"); - expect(ctoRow?.adapterType).toBe("opencode_local"); + expect(ctoRow?.adapterType).toBe("paperclip_runner"); const otherAdapters = Array.from(byName.values()) .filter((row) => row !== ctoRow) .map((row) => row.adapterType); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index a89ed24a218..f519ed3991a 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -1863,7 +1863,7 @@ export function agentRoutes( if (experimental.enableMuse !== true || experimental.enableNativeRunner !== true) throw unprocessable("Muse is experimental and disabled on this instance.", { code: "paperclip_runner_muse_disabled" }); } else if (asRecord(config)?.provider === "grok_bot" && (experimental.enableGrokBot !== true || experimental.enablePublicMcp !== true || experimental.enableNativeRunner !== true)) { throw unprocessable("Enable Grok Bot, Assistant connections (MCP), and Paperclip Runner before connecting this agent.", { code: "paperclip_runner_grok_disabled" }); - } else if (!([undefined, "codex"].includes(asRecord(config)?.provider as string | undefined)) && + } else if (!([undefined, "codex", "opencode"].includes(asRecord(config)?.provider as string | undefined)) && !(asRecord(config)?.provider === "acpx" && asRecord(config)?.acpxAgent === "copilot") && agentHarnessType("paperclip_runner", asRecord(config) ?? {}) !== "claude_local" && experimental.enableNativeRunner !== true) { @@ -2165,6 +2165,7 @@ export function agentRoutes( adapterType: string | null | undefined; adapterConfig: Record; constraintAdapterConfig?: Record; + setupOnly?: boolean; }): Promise> { const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( input.companyId, @@ -2174,7 +2175,11 @@ export function agentRoutes( adapterType: input.adapterType ?? null, }, ); - await assertAdapterConfigConstraints( + const incompleteOpenCodeSetup = input.setupOnly && input.adapterType === "paperclip_runner" + && normalizedAdapterConfig.provider === "opencode" + && (normalizedAdapterConfig.model === undefined || normalizedAdapterConfig.model === ""); + // Inactive drafts can precede model selection. Test/hire always validate the complete profile. + if (!incompleteOpenCodeSetup) await assertAdapterConfigConstraints( input.companyId, input.adapterType, input.constraintAdapterConfig @@ -4716,7 +4721,7 @@ export function agentRoutes( assertNoAgentLocalAdapterHostCommandMutation(req, createInput.adapterType ?? "process", requestedCreateConfig, await callerUsesAiConnectionPool(req, companyId)); assertNoAgentProcessAdapterMutation(req, createInput.adapterType ?? "process", Object.keys(requestedCreateConfig).length > 0); assertExternalInstructionsAdmin(req, { id: agentId, companyId, name: createInput.name, adapterConfig: requestedCreateConfig }); - Object.assign(createInput, await resolveNewAgentRunnerForCompany(db, companyId, { ...createInput, runner: createRunner })); + Object.assign(createInput, await resolveNewAgentRunnerForCompany(db, companyId, { ...createInput, runner: createRunner, setupOnly: Boolean(setupId) })); createInput.adapterType = await assertSelectableAdapterType(createInput.adapterType, createInput.adapterConfig); const rawCreateAdapterConfig = (createInput.adapterConfig ?? {}) as Record; assertProviderTraceSettingTransition(req, createInput.runtimeConfig); @@ -4766,6 +4771,7 @@ export function agentRoutes( companyId, adapterType: createInput.adapterType, adapterConfig: desiredSkillAssignment.adapterConfig, + setupOnly: Boolean(setupId), }); const normalizedRuntimeConfig = await normalizeCreatedAgentRuntimeConfig(req, companyId, createInput.adapterType, normalizedAdapterConfig, createInput.runtimeConfig); await assertAgentEnvironmentSelection(companyId, createInput.adapterType, createInput.defaultEnvironmentId); @@ -5377,7 +5383,7 @@ export function agentRoutes( const config = hasOwn(patchData, "adapterConfig") ? { ...(replaceAdapterConfig ? {} : existing.adapterConfig), ...asRecord(patchData.adapterConfig) } : existing.adapterConfig; Object.assign(patchData, await resolveNewAgentRunnerForCompany(db, existing.companyId, { adapterType: typeof patchData.adapterType === "string" ? patchData.adapterType : existing.adapterType, - adapterConfig: config, runner, + adapterConfig: config, runner, setupOnly: existing.lifecycleHolds?.includes("setup"), defaultEnvironmentId: hasOwn(patchData, "defaultEnvironmentId") ? patchData.defaultEnvironmentId as string | null : existing.defaultEnvironmentId, })); } @@ -5502,6 +5508,7 @@ export function agentRoutes( companyId: existing.companyId, adapterType: requestedAdapterType, adapterConfig: effectiveAdapterConfig, + setupOnly: existing.lifecycleHolds?.includes("setup"), }); patchData.adapterConfig = syncInstructionsBundleConfigFromFilePath(existing, normalizedEffectiveAdapterConfig); assertExternalInstructionsAdmin(req, { diff --git a/server/src/services/agent-runner-selection.test.ts b/server/src/services/agent-runner-selection.test.ts index f4cc9021a5e..d598e0baf28 100644 --- a/server/src/services/agent-runner-selection.test.ts +++ b/server/src/services/agent-runner-selection.test.ts @@ -74,9 +74,55 @@ describe("server-owned Codex runner selection", () => { expect(resolveNewAgentRunner(resolved)).toEqual(resolved); expect(input.adapterConfig).toHaveProperty("search", false); }); - it.each(["opencode_local", "grok_local", "cursor", "pi_local", "process", "external"])("keeps %s default unchanged", adapterType => { + it.each(["grok_local", "cursor", "pi_local", "process", "external"])("keeps %s default unchanged", adapterType => { expect(resolveNewAgentRunner({ adapterType, target, adapterConfig: { custom: "preserved" } })).toEqual({ adapterType, adapterConfig: { custom: "preserved" } }); }); + it.each(qualifiedTargets.filter(target => !(target.platform === "linux" && target.architecture === "arm64")))("defaults OpenCode to its native provider on $platform/$architecture", target => { + const adapterConfig = { model: "openrouter/anthropic/claude-sonnet-4.6", dangerouslySkipPermissions: true, command: "opencode", env: { OPENROUTER_API_KEY: { type: "secret_ref", secretId: "connection" } }, cwd: "/workspace", instructionsFilePath: "/workspace/AGENTS.md" }; + const selected = resolveNewAgentRunner({ adapterType: "opencode_local", adapterConfig, target }); + expect(agentRunnerAvailability("opencode_local", target).defaultRunner).toBe("paperclip"); + expect(selected).toEqual({ adapterType: "paperclip_runner", adapterConfig: { provider: "opencode", model: adapterConfig.model, env: adapterConfig.env, cwd: adapterConfig.cwd, instructionsFilePath: adapterConfig.instructionsFilePath, opencodePermissionMode: "allow", lifecycleMode: "per_turn" } }); + expect(resolveNewAgentRunner(selected)).toEqual(selected); + expect(resolveNewAgentRunner({ ...selected, runner: "legacy" })).toMatchObject({ adapterType: "opencode_local", adapterConfig: { model: adapterConfig.model, dangerouslySkipPermissions: true } }); + }); + it.each([{ driver: "local", platform: "win32", architecture: "x64" }, { driver: "local", platform: "linux", architecture: "arm64", libc: "glibc" as const, glibcVersion: "2.39" }, { driver: "ssh", platform: "linux", architecture: "arm64", libc: "glibc" as const, glibcVersion: "2.39" }, { driver: "sandbox", platform: "linux", architecture: "arm64", libc: "glibc" as const, glibcVersion: "2.39" }, { driver: "local", platform: "linux", architecture: "arm64", libc: "musl" as const }, { driver: "local", platform: "linux", architecture: "x64", libc: "glibc" as const, glibcVersion: "2.38" }, { driver: "sandbox" }])("keeps OpenCode legacy on unqualified $driver/$platform targets", target => { + const input = { adapterType: "opencode_local", adapterConfig: { model: "custom/model", command: "custom-opencode" }, target }; + expect(agentRunnerAvailability("opencode_local", target)).toEqual({ supportedRunners: ["legacy"], defaultRunner: "legacy" }); + expect(resolveNewAgentRunner(input)).toEqual({ adapterType: input.adapterType, adapterConfig: input.adapterConfig }); + expect(() => resolveNewAgentRunner({ ...input, runner: "paperclip" })).toThrow("unavailable"); + }); + it.each([{ command: "custom-opencode" }, { variant: "high" }, { extraArgs: ["--agent", "plan"] }, { args: ["--attach", "http://localhost"] }, { dangerouslySkipPermissions: false }])("rejects unsupported OpenCode settings instead of discarding them: %j", adapterConfig => { + const input = { adapterType: "opencode_local", adapterConfig: { model: "custom/model", ...adapterConfig }, target }; + expect(() => resolveNewAgentRunner(input)).toThrow("Legacy runner"); + expect(resolveNewAgentRunner({ ...input, runner: "legacy" })).toEqual({ adapterType: input.adapterType, adapterConfig: input.adapterConfig }); + }); + it.each([undefined, "", "model", "provider/"])("requires an explicit OpenCode provider/model: %s", model => { + expect(() => resolveNewAgentRunner({ adapterType: "opencode_local", adapterConfig: { model }, target })).toThrow("provider/model"); + }); + it("stores an inactive native OpenCode setup draft before model selection, but cannot hire it", () => { + const input = { adapterType: "opencode_local", target }; + expect(resolveNewAgentRunner({ ...input, setupOnly: true })).toEqual({ adapterType: "paperclip_runner", adapterConfig: { provider: "opencode", model: "", opencodePermissionMode: "allow", lifecycleMode: "per_turn" } }); + expect(() => resolveNewAgentRunner(input)).toThrow("provider/model"); + }); + it("honors native OpenCode permissions and refuses to discard them on return to legacy", () => { + const selected = resolveNewAgentRunner({ adapterType: "opencode_local", adapterConfig: { model: "custom/model", opencodePermissionMode: "ask" }, target }); + expect(selected.adapterConfig.opencodePermissionMode).toBe("ask"); + expect(() => resolveNewAgentRunner({ ...selected, runner: "legacy" })).toThrow("opencodePermissionMode"); + }); + it("resolves OpenCode against the company sandbox qualification and preserves disabled/overridden adapters", async () => { + const input = { adapterType: "opencode_local", adapterConfig: { model: "custom/model" } }; + state.settings = { defaultEnvironmentId: "sandbox" }; + state.getEnvironment.mockResolvedValue({ id: "sandbox", driver: "sandbox", status: "active", config: { provider: "daytona" } }); + expect((await resolveNewAgentRunnerForCompany({} as never, "company", input)).adapterType).toBe("opencode_local"); + state.sandboxQualification.mockResolvedValue({ platform: "linux", architecture: "x64", libc: "glibc", glibcVersion: "2.39" }); + expect((await resolveNewAgentRunnerForCompany({} as never, "company", input)).adapterType).toBe("paperclip_runner"); + state.overridden.add("opencode_local"); + expect(resolveNewAgentRunner({ ...input, target }).adapterType).toBe("opencode_local"); + state.overridden.clear(); state.disabled = ["paperclip_runner"]; + expect(resolveNewAgentRunner({ ...input, target }).adapterType).toBe("opencode_local"); + state.disabled = ["opencode_local"]; + expect(() => resolveNewAgentRunner({ ...input, target })).toThrow("OpenCode is disabled"); + }); it.each(qualifiedTargets)("allows an explicit legacy runner and native to legacy round trip on $platform/$architecture", target => { expect(resolveNewAgentRunner({ adapterType: "codex_local", target, runner: "legacy", adapterConfig: { extraArgs: ["--search"] } }).adapterType).toBe("codex_local"); expect(resolveNewAgentRunner({ adapterType: "paperclip_runner", runner: "legacy", adapterConfig: { provider: "codex", model: "gpt-5.6-sol", lifecycleMode: "per_turn", codexPermissionMode: "never", modelReasoningEffort: "high" } })).toEqual({ adapterType: "codex_local", adapterConfig: { model: "gpt-5.6-sol", modelReasoningEffort: "high", dangerouslyBypassApprovalsAndSandbox: true } }); @@ -379,7 +425,8 @@ describe("Codex creation runner contract", () => { it("graduates only Codex and retains saved provider identity", () => { expect(paperclipRunnerProfileForHarness("codex_local")).toEqual({ provider: "codex" }); expect(paperclipRunnerProfileForHarness("claude_local")).toEqual({ provider: "acpx", acpxAgent: "claude" }); - for (const harness of ["opencode_local", "grok_local", "cursor", "pi_local", "process"]) expect(paperclipRunnerProfileForHarness(harness)).toBeUndefined(); + expect(paperclipRunnerProfileForHarness("opencode_local")).toEqual({ provider: "opencode" }); + for (const harness of ["grok_local", "cursor", "pi_local", "process"]) expect(paperclipRunnerProfileForHarness(harness)).toBeUndefined(); expect(agentHarnessType("paperclip_runner", { provider: "codex" })).toBe("codex_local"); expect(agentHarnessType("paperclip_runner", { provider: "acpx", acpxAgent: "claude" })).toBe("claude_local"); expect(agentHarnessType("paperclip_runner", { provider: "unknown" })).toBe("unknown"); diff --git a/server/src/services/agent-runner-selection.ts b/server/src/services/agent-runner-selection.ts index 8ab9694b158..27c3fb80e2f 100644 --- a/server/src/services/agent-runner-selection.ts +++ b/server/src/services/agent-runner-selection.ts @@ -17,11 +17,13 @@ export interface AgentRunnerSelection { adapterConfig?: Record; runner?: AgentRunnerChoice; target?: RunnerTarget; + /** Server-owned inactive setup drafts may precede model selection. Never use for a hire or run. */ + setupOnly?: boolean; } function runnerAdaptersAvailable(harness: string, driver: string): boolean { const disabled = new Set(getDisabledAdapterTypes()); - return ["codex_local", "claude_local"].includes(harness) + return ["codex_local", "claude_local", "opencode_local"].includes(harness) && !disabled.has(harness) && !disabled.has("paperclip_runner") && !!findActiveServerAdapter(harness) && !!findActiveServerAdapter("paperclip_runner") && !hasActiveAdapterOverride(harness) && !hasActiveAdapterOverride("paperclip_runner") @@ -43,6 +45,25 @@ function incompatible(fields: string[]): never { throw unprocessable(`Paperclip Runner cannot honor these settings: ${fields.join(", ")}. Remove them or select Legacy runner in Advanced.`, { code: "agent_runner_config_incompatible", fields }); } +function resolveOpenCodeRunnerConfig(config: Record): Record { + const unsupported = ["extraArgs", "args", "variant", "modelReasoningEffort", "reasoningEffort", "effort"] + .filter(key => config[key] !== undefined && config[key] !== "" && !(Array.isArray(config[key]) && config[key].length === 0)); + if (config.command && config.command !== "opencode") unsupported.push("command"); + // Legacy false inherits local OpenCode permission rules, which the isolated Runner cannot retain. + if (config.dangerouslySkipPermissions !== undefined && config.dangerouslySkipPermissions !== true) unsupported.push("dangerouslySkipPermissions"); + if (config.model !== undefined && typeof config.model !== "string") unsupported.push("model"); + if (unsupported.length) incompatible(unsupported); + const translated = { ...config }; + for (const key of ["command", "extraArgs", "args", "variant", "modelReasoningEffort", "reasoningEffort", "effort", "dangerouslySkipPermissions"]) delete translated[key]; + return { + ...translated, + provider: "opencode", + model: typeof config.model === "string" ? config.model.trim() : "", + opencodePermissionMode: config.opencodePermissionMode ?? "allow", + lifecycleMode: config.lifecycleMode ?? "per_turn", + }; +} + function resolveClaudeRunnerConfig(config: Record): Record { const translated = { ...config }; const legacyPermission = [config.permissionMode, config.acpPermissionMode].find(value => typeof value === "string" && value.trim()) as string | undefined; @@ -82,12 +103,19 @@ export function resolveNewAgentRunner(input: AgentRunnerSelection): { adapterTyp let config = { ...input.adapterConfig }; const harness = agentHarnessType(adapterType, config); const native = adapterType === "paperclip_runner"; - if (["codex_local", "claude_local"].includes(adapterType) && getDisabledAdapterTypes().includes(adapterType)) throw unprocessable(`${adapterType === "claude_local" ? "Claude" : "Codex"} is disabled on this instance. Enable its adapter or choose another harness.`, { code: "agent_runner_unavailable" }); + if (["codex_local", "claude_local", "opencode_local"].includes(adapterType) && getDisabledAdapterTypes().includes(adapterType)) throw unprocessable(`${adapterType === "opencode_local" ? "OpenCode" : adapterType === "claude_local" ? "Claude" : "Codex"} is disabled on this instance. Enable its adapter or choose another harness.`, { code: "agent_runner_unavailable" }); if (native && (input.runner === undefined || input.runner === "paperclip" || (harness !== "codex_local" && input.runner !== "legacy"))) return { adapterType, adapterConfig: normalizeLegacyRunnerProvider(config) }; const availability = agentRunnerAvailability(harness, input.target); const choice = input.runner ?? "auto"; const useNative = choice === "paperclip" || (choice === "auto" && availability.defaultRunner === "paperclip"); if (!useNative) { + if (harness === "opencode_local") { + if (!native) return { adapterType, adapterConfig: config }; + if (config.opencodePermissionMode !== undefined && config.opencodePermissionMode !== "allow") incompatible(["opencodePermissionMode"]); + if (config.lifecycleMode !== undefined && config.lifecycleMode !== "per_turn") incompatible(["lifecycleMode"]); + for (const key of ["provider", "opencodePermissionMode", "lifecycleMode", "idleTimeoutMs"]) delete config[key]; + return { adapterType: "opencode_local", adapterConfig: { ...config, dangerouslySkipPermissions: true } }; + } if (harness === "claude_local") { if (!native) return { adapterType, adapterConfig: config }; if (config.acpxPermissionMode !== undefined && config.acpxPermissionMode !== "approve-all") incompatible(["acpxPermissionMode"]); @@ -110,6 +138,16 @@ export function resolveNewAgentRunner(input: AgentRunnerSelection): { adapterTyp ? "Paperclip Runner is unavailable for this sandbox. Choose an immutable OCI image digest and run its environment connection probe to qualify the OS, architecture and libc, or select Legacy runner in Advanced." : "Paperclip Runner is unavailable for this harness and environment. Select Legacy runner in Advanced.", { code: "agent_runner_unavailable" }); if (native) return { adapterType, adapterConfig: normalizeLegacyRunnerProvider(config) }; + if (harness === "opencode_local") { + config = resolveOpenCodeRunnerConfig(config); + if (input.setupOnly && !config.model) return { adapterType: "paperclip_runner", adapterConfig: config }; + try { resolvePaperclipRunnerNativeProviderInput({ backend: "opencode_server", adapterConfig: config }); } + catch (error) { + if (error instanceof PaperclipRunnerProviderProfileError) throw unprocessable(error.message, { code: error.code }); + throw error; + } + return { adapterType: "paperclip_runner", adapterConfig: config }; + } if (harness === "claude_local") { config = resolveClaudeRunnerConfig(config); try { resolvePaperclipRunnerNativeProviderInput({ backend: "acpx_runtime", adapterConfig: config }); } @@ -174,7 +212,7 @@ export function resolveNewAgentRunner(input: AgentRunnerSelection): { adapterTyp } export async function resolveNewAgentRunnerForCompany(db: Db, companyId: string, input: AgentRunnerSelection & { defaultEnvironmentId?: string | null }) { - if (!["codex_local", "claude_local"].includes(agentHarnessType(input.adapterType ?? "process", input.adapterConfig)) + if (!["codex_local", "claude_local", "opencode_local"].includes(agentHarnessType(input.adapterType ?? "process", input.adapterConfig)) || input.runner === "legacy" || (input.adapterType === "paperclip_runner" && (input.runner === undefined || input.runner === "paperclip"))) return resolveNewAgentRunner(input); const target = await resolveAgentRunnerTargetForCompany(db, companyId, input.defaultEnvironmentId); return resolveNewAgentRunner({ ...input, target }); @@ -211,7 +249,7 @@ export async function resolveAgentRunnerTargetForCompany(db: Db, companyId: stri const qualified = await readSandboxPlatformQualification(db, environment, companyId); if (qualified) target = { driver, ...qualified }; } - if (driver === "ssh" && environment && ["codex_local", "claude_local"].some(harness => runnerAdaptersAvailable(harness, driver))) { + if (driver === "ssh" && environment && ["codex_local", "claude_local", "opencode_local"].some(harness => runnerAdaptersAvailable(harness, driver))) { const parsed = await resolveEnvironmentDriverConfigForRuntime(db, companyId, environment); if (parsed.driver !== "ssh") throw unprocessable("The selected SSH environment is unavailable."); let output: string; diff --git a/server/src/services/built-in-agents.ts b/server/src/services/built-in-agents.ts index 20277a48fcd..b35c86afe3c 100644 --- a/server/src/services/built-in-agents.ts +++ b/server/src/services/built-in-agents.ts @@ -1762,7 +1762,7 @@ export function builtInAgentService(db: Db) { const adapterConfig = resolvedInput.adapterConfig ?? existing.adapterConfig; assertAdapterAllowed(definition, adapterType, adapterConfig); const selectedHarness = agentHarnessType(adapterType, adapterConfig); - const sameQualifiedHarness = ["codex_local", "claude_local"].includes(selectedHarness) + const sameQualifiedHarness = ["codex_local", "claude_local", "opencode_local"].includes(selectedHarness) && agentHarnessType(existing.adapterType, existing.adapterConfig) === selectedHarness; const runner = resolvedInput.runner ?? (sameQualifiedHarness && existing.metadata?.paperclipBuiltInSetupRequired !== true ? agentRunner(existing.adapterType) : undefined); diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index a8496f36e4e..e88eb0a9332 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -5307,7 +5307,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { "OpenAI Dot is experimental and disabled on this instance.", { code: "paperclip_runner_dot_disabled" }, ); - } else if (config.provider !== "codex" && config.provider !== undefined && agentHarnessType("paperclip_runner", config) !== "claude_local" && experimental.enableNativeRunner !== true) { + } else if (config.provider !== "codex" && config.provider !== "opencode" && config.provider !== undefined && agentHarnessType("paperclip_runner", config) !== "claude_local" && experimental.enableNativeRunner !== true) { throw unprocessable( "Paperclip Runner is experimental and disabled on this instance.", { code: "paperclip_runner_rollout_disabled" }, @@ -5651,7 +5651,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { runner = agentRunner(existingImportAgent.adapterType); } } - const retainedNative = runner === "paperclip" && ["codex_local", "claude_local"].includes(selectedHarness) + const retainedNative = runner === "paperclip" && ["codex_local", "claude_local", "opencode_local"].includes(selectedHarness) ? [manifestAgent, existingImportAgent].find((agent) => agent?.adapterType === "paperclip_runner" && agentHarnessType(agent.adapterType, agent.adapterConfig) === selectedHarness) : undefined; diff --git a/server/src/services/native-runtime/runtime-mode.test.ts b/server/src/services/native-runtime/runtime-mode.test.ts index 3a76ed1fd17..8c4f02e48f3 100644 --- a/server/src/services/native-runtime/runtime-mode.test.ts +++ b/server/src/services/native-runtime/runtime-mode.test.ts @@ -90,11 +90,17 @@ describe("resolveNativeRuntimeMode", () => { })).toMatchObject({ kind: "native", profile: { backend: "grok_bot_mcp" } }); }); - it("still rejects experimental providers while the rollout flag is disabled", () => { + it("admits OpenCode without the experimental flag", () => { + expect(resolveNativeRuntimeMode({ ...eligible, enabled: false, + adapterConfig: { provider: "opencode", model: "openrouter/anthropic/claude-sonnet-4.6" }, + })).toMatchObject({ kind: "native", profile: { backend: "opencode_server" } }); + }); + + it("still rejects Pi while the rollout flag is disabled", () => { expect(() => resolveNativeRuntimeMode({ ...eligible, enabled: false, - adapterConfig: { provider: "opencode", model: "openrouter/deepseek/deepseek-v4-flash-0731" }, + adapterConfig: { provider: "acpx", acpxAgent: "pi", model: "openrouter/deepseek/deepseek-v4-flash-0731" }, })).toThrow(expect.objectContaining({ code: "paperclip_runner_rollout_disabled", })); @@ -244,7 +250,7 @@ describe("resolveNativeRuntimeMode", () => { it("keeps a persisted experimental run native while the flag rejects a fresh start", () => { const disabled = { ...eligible, enabled: false, - adapterConfig: { provider: "opencode", model: "openrouter/deepseek/deepseek-v4-flash-0731" }, + adapterConfig: { provider: "acpx", acpxAgent: "pi", model: "openrouter/deepseek/deepseek-v4-flash-0731" }, }; expect(resolveHeartbeatNativeRuntimeMode({ ...disabled, @@ -252,7 +258,7 @@ describe("resolveNativeRuntimeMode", () => { runtimeMode: "native", runtimeModeReason: "eligible_opt_in", runtimeModeResolvedAt: new Date(), - driverKind: "opencode_server", + driverKind: "acpx_runtime", }, })).toEqual(expect.objectContaining({ kind: "native", @@ -516,7 +522,7 @@ describe("resolveHeartbeatRuntimeMode compatibility", () => { ...compatibilityInput, enabled: false, adapterType: "paperclip_runner", - adapterConfig: { provider: "opencode", model: "opencode/gpt-5" }, + adapterConfig: { provider: "acpx", acpxAgent: "pi", model: "openrouter/anthropic/claude-sonnet-4.6" }, })).toThrow(NativeRunnerSelectionError); }); it("admits native Claude without enabling other ACPX providers", () => { diff --git a/server/src/services/native-runtime/runtime-mode.ts b/server/src/services/native-runtime/runtime-mode.ts index 3e23c2aa137..e1c6fb24d42 100644 --- a/server/src/services/native-runtime/runtime-mode.ts +++ b/server/src/services/native-runtime/runtime-mode.ts @@ -138,7 +138,7 @@ export function resolveNativeRuntimeMode(input: { if (input.dotEnabled !== true) throw ineligible("paperclip_runner_dot_disabled", "Enable OpenAI Dot and Assistant connections (MCP) in experimental settings before assigning new work."); } else if (runnerProfile.provider === "grok_bot" && (input.grokEnabled !== true || !input.enabled)) { throw ineligible("paperclip_runner_grok_disabled", "Enable Grok Bot, Paperclip Runner, and Assistant connections (MCP) before assigning new work."); - } else if (runnerProfile.provider !== "codex" + } else if (runnerProfile.provider !== "codex" && runnerProfile.provider !== "opencode" && !(runnerProfile.provider === "acpx" && (runnerProfile.acpxAgent === "claude" || runnerProfile.acpxAgent === "copilot")) && !input.enabled) { diff --git a/ui/src/adapters/adapter-display-registry.test.ts b/ui/src/adapters/adapter-display-registry.test.ts index d76e548360d..d2de92e6f8b 100644 --- a/ui/src/adapters/adapter-display-registry.test.ts +++ b/ui/src/adapters/adapter-display-registry.test.ts @@ -19,6 +19,7 @@ describe("adapter display registry", () => { } }); it("uses user-facing labels without the legacy local qualifier for built-in adapters", () => { + expect(agentDisplayAdapterType({ adapterType: "paperclip_runner", adapterConfig: { provider: "opencode" } })).toBe("opencode_local"); expect(getAdapterLabel("codex_local")).toBe("Codex"); expect(getAdapterLabel("claude_local")).toBe("Claude Code"); expect(getAdapterLabel("acpx_local")).toBe("ACPX (retired)"); diff --git a/ui/src/adapters/adapter-display-registry.ts b/ui/src/adapters/adapter-display-registry.ts index cf3c94a39d5..eb47db8e76c 100644 --- a/ui/src/adapters/adapter-display-registry.ts +++ b/ui/src/adapters/adapter-display-registry.ts @@ -222,7 +222,7 @@ export function agentDisplayAdapterType(agent: { adapterType: string; adapterCon return agent.adapterConfig.provider; } const harness = agentHarnessType(agent.adapterType, agent.adapterConfig); - return ["codex_local", "claude_local"].includes(harness) ? harness : agent.adapterType; + return ["codex_local", "claude_local", "opencode_local"].includes(harness) ? harness : agent.adapterType; } export function isKnownAdapterType(type: string): boolean { diff --git a/ui/src/components/AgentActionButtons.tsx b/ui/src/components/AgentActionButtons.tsx index 88031d22aa4..b2d7006512d 100644 --- a/ui/src/components/AgentActionButtons.tsx +++ b/ui/src/components/AgentActionButtons.tsx @@ -437,7 +437,7 @@ export function AgentActionButtons({ ) : ( { mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableNativeRunner: false }); const result = await renderForm([], { adapterType: "paperclip_runner", adapterConfig }, { showAdapterTypeField: true }); roots.push(result.root); - expect(result.container.querySelector('[aria-label="Runner"]')).toBeNull(); + if (adapterConfig.provider === "opencode") { + expect(result.container.querySelector('[aria-label="Harness"]')?.textContent).toContain("OpenCode"); + await clickByText(result.container, "Advanced"); + expect(result.container.querySelector('[aria-label="Runner"]')?.textContent).toContain("Paperclip Runner"); + } else expect(result.container.querySelector('[aria-label="Runner"]')).toBeNull(); await act(async () => setInputValue(result.container.querySelector('[placeholder="Agent name"]')!, "Renamed native agent")); await flushReact(); await clickByText(result.container, "Save"); @@ -976,10 +980,9 @@ describe("AgentConfigForm environment selector", () => { }); it.each([ - ["Codex", "codex", undefined, DEFAULT_CODEX_LOCAL_MODEL], - ["ACP agents", "acpx", "claude", "claude-opus-5-5"], - ["Claude Managed", "claude_managed", undefined, "claude-sonnet-5"], - ])("saves the %s harness default without the previous OpenCode model prefix", async (label, provider, acpxAgent, model) => { + ["Codex", "codex_local"], + ["Claude Code", "claude_local"], + ])("clears OpenCode model settings when choosing %s so the server can apply its default", async (_label, adapterType) => { const accountList = vi.spyOn(aiConnectionsApi, "list").mockResolvedValue({ currentUserId: "you", canManageConnections: true, @@ -990,23 +993,20 @@ describe("AgentConfigForm environment selector", () => { adapterType: "paperclip_runner", adapterConfig: { provider: "opencode", model: "openrouter/anthropic/claude-sonnet-4.6" }, runtimeConfig: { aiConnection: { mode: "responsible_user", provider: "openrouter", method: "api_key" } }, - }); + }, { showAdapterTypeField: true }); roots.push(result.root); - await act(async () => { - result.container.querySelector('[aria-label="Harness"]')! - .dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); - }); + await clickElement(result.container.querySelector('[aria-label="Harness"]')); await flushReact(); - const option = [...document.querySelectorAll('[role="option"]')] - .find(element => element.textContent === label)!; + const option = document.querySelector(`[role="option"][data-value="${adapterType}"]`); expect(option).toBeTruthy(); - await act(async () => { - option.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); - }); + await clickElement(option); await flushReact(); await clickByText(result.container, "Save"); expect(result.onSave).toHaveBeenCalledWith(expect.objectContaining({ - adapterConfig: expect.objectContaining({ provider, model, ...(acpxAgent ? { acpxAgent } : {}) }), + adapterType, + runner: "auto", + replaceAdapterConfig: true, + adapterConfig: expect.objectContaining({ model: "" }), })); } finally { accountList.mockRestore(); diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 6f6c246ca0a..e1f1223b0b9 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -1,6 +1,6 @@ import { COPILOT_MODEL_DISCOVERY_QUERY_OPTIONS } from "@/lib/copilot-task-model-catalog"; import { useConnectionModels } from "./ai-connections/useConnectionModels"; -import { agentHarnessType, agentRunner, type AgentRunnerChoice } from "@paperclipai/shared"; +import { agentHarnessType, agentRunner, paperclipRunnerProfileForHarness, type AgentRunnerChoice } from "@paperclipai/shared"; import { adaptersApi } from "../api/adapters"; import { AgentRunnerSelect } from "./AgentRunnerSelect"; import { aiRoutingHarness } from "@paperclipai/shared"; @@ -607,7 +607,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) { const effectiveConfig = isCreate ? props.values.adapterSchemaValues ?? {} : { ...config, ...overlay.adapterConfig }; const harnessType = agentHarnessType(adapterType, effectiveConfig); const isCodexHarness = harnessType === "codex_local"; - const isRunnerHarness = isCodexHarness || harnessType === "claude_local"; + const runnerProfile = paperclipRunnerProfileForHarness(harnessType); + const isRunnerHarness = Boolean(runnerProfile); const rawCurrentDefaultEnvironmentId = isCreate ? props.values.defaultEnvironmentId ?? "" : eff("identity", "defaultEnvironmentId", props.agent.defaultEnvironmentId ?? ""); @@ -627,10 +628,10 @@ export function AgentConfigForm(props: AgentConfigFormProps) { ? (runner === "paperclip" || (runner === "auto" && runnerAvailability?.defaultRunner === "paperclip") ? "paperclip_runner" : harnessType) : adapterType; useEffect(() => { - if (props.mode !== "create" || harnessType !== "claude_local") return; + if (props.mode !== "create" || !isRunnerHarness) return; const resolvedRunner = runner === "auto" ? runnerAvailability?.defaultRunner : runner; if (props.values.resolvedRunner !== resolvedRunner) props.onChange({ resolvedRunner }); - }, [props.mode, harnessType, runner, runnerAvailability?.defaultRunner, + }, [props.mode, harnessType, isRunnerHarness, runner, runnerAvailability?.defaultRunner, isCreate ? props.values.resolvedRunner : undefined, isCreate ? props.onChange : undefined]); const getCapabilities = useAdapterCapabilities(); const adapterCaps = getCapabilities(isRunnerHarness ? harnessType : adapterType); @@ -995,9 +996,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) { isCreate, adapterType: fieldsAdapterType, hideRunnerHarness: isRunnerHarness, - values: isCreate ? { ...props.values, ...(harnessType === "claude_local" && fieldsAdapterType === "paperclip_runner" ? { adapterSchemaValues: { ...props.values.adapterSchemaValues, provider: "acpx", acpxAgent: "claude" } } : {}) } : null, + values: isCreate ? { ...props.values, ...(runnerProfile && fieldsAdapterType === "paperclip_runner" ? { adapterSchemaValues: { ...props.values.adapterSchemaValues, ...runnerProfile } } : {}) } : null, set: isCreate ? (patch: Partial) => props.onChange(patch) : null, - config: harnessType === "claude_local" && fieldsAdapterType === "paperclip_runner" ? { ...config, provider: "acpx", acpxAgent: "claude" } : config, + config: runnerProfile && fieldsAdapterType === "paperclip_runner" ? { ...config, ...runnerProfile } : config, eff: eff as (group: "adapterConfig", field: string, original: T) => T, // Harness transitions supply the new harness's default model. Resolve // user-selected model IDs in ModelDropdown, not through the previous @@ -1700,7 +1701,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { setOverlay((prev) => ({ ...prev, adapterType: t, - runner: ["codex_local", "claude_local"].includes(t) ? "auto" : undefined, + runner: ["codex_local", "claude_local", "opencode_local"].includes(t) ? "auto" : undefined, adapterConfig: { model: t === "gemini_local" diff --git a/ui/src/components/ConfigureBuiltInAgentModal.tsx b/ui/src/components/ConfigureBuiltInAgentModal.tsx index a4b39e8cb2c..ad609aff737 100644 --- a/ui/src/components/ConfigureBuiltInAgentModal.tsx +++ b/ui/src/components/ConfigureBuiltInAgentModal.tsx @@ -68,7 +68,7 @@ export function ConfigureBuiltInAgentModal({ const { definition } = state; const [adapterType, setAdapterType] = useState( - () => state.agent && ["codex_local", "claude_local"].includes(agentHarnessType(state.agent.adapterType, state.agent.adapterConfig)) ? agentHarnessType(state.agent.adapterType, state.agent.adapterConfig) : state.agent?.adapterType ?? defaultAdapterType(state), + () => state.agent && ["codex_local", "claude_local", "opencode_local"].includes(agentHarnessType(state.agent.adapterType, state.agent.adapterConfig)) ? agentHarnessType(state.agent.adapterType, state.agent.adapterConfig) : state.agent?.adapterType ?? defaultAdapterType(state), ); const [runner, setRunner] = useState(); const environmentId = state.agent?.defaultEnvironmentId ?? null; @@ -143,7 +143,7 @@ export function ConfigureBuiltInAgentModal({ : { adapterType, adapterConfig }; const result = await builtInAgentsApi.provision(companyId, definition.key, { ...adapterInput, - ...(["codex_local", "claude_local"].includes(adapterType) && runner !== undefined ? { runner } : {}), + ...(["codex_local", "claude_local", "opencode_local"].includes(adapterType) && runner !== undefined ? { runner } : {}), ...(budgetMonthlyCents !== undefined ? { budgetMonthlyCents } : {}), }); return result; @@ -189,7 +189,7 @@ export function ConfigureBuiltInAgentModal({ /> - {["codex_local", "claude_local"].includes(adapterType) &&
+ {["codex_local", "claude_local", "opencode_local"].includes(adapterType) &&
Advanced { void adapters.refetch(); }} diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index 9934612f059..24014dca2e2 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -169,6 +169,7 @@ const copilotOnboardingQualified = PAPERCLIP_RUNNER_ACPX_PROFILES.some( ); function restoreOnboardingAdapterType(savedAdapterType: unknown, provider?: unknown, savedCopilot?: unknown): AdapterType { + if (savedAdapterType === "paperclip_runner" && provider === "opencode") return "opencode_local"; if (savedAdapterType === "paperclip_runner" && savedCopilot !== true && (provider === undefined || provider === "codex")) return "codex_local"; return typeof savedAdapterType === "string" && (savedAdapterType !== "paperclip_runner" || (savedCopilot === true && copilotOnboardingQualified)) ? savedAdapterType @@ -611,7 +612,7 @@ function OnboardingWizardInner({ restoreOnboardingAdapterType(saved?.adapterType, saved?.runnerProvider, saved?.onboardingCopilot), ); const [runner, setRunner] = useState(() => saved?.runner === "legacy" || saved?.runner === "paperclip" - ? saved.runner : saved?.adapterType === "paperclip_runner" && (saved?.runnerProvider === undefined || ["codex", "claude", "acpx"].includes(String(saved?.runnerProvider))) ? "paperclip" : "auto"); + ? saved.runner : saved?.adapterType === "paperclip_runner" && (saved?.runnerProvider === undefined || ["codex", "claude", "acpx", "opencode"].includes(String(saved?.runnerProvider))) ? "paperclip" : "auto"); /** * Whether a model source has been chosen, as opposed to which one * `adapterType` happens to hold. @@ -641,7 +642,7 @@ function OnboardingWizardInner({ const savedNativeRunnerDraft = saved?.adapterType === "paperclip_runner" && !(saved?.onboardingCopilot === true && copilotOnboardingQualified); const [cwd, setCwd] = useState((saved?.cwd as string) ?? ""); const [model, setModel] = useState( - savedNativeRunnerDraft && saved?.runnerProvider !== undefined && !["codex", "claude", "acpx"].includes(String(saved.runnerProvider)) ? "" : (saved?.model as string) ?? "", + savedNativeRunnerDraft && saved?.runnerProvider !== undefined && !["codex", "claude", "acpx", "opencode"].includes(String(saved.runnerProvider)) ? "" : (saved?.model as string) ?? "", ); const [command, setCommand] = useState( saved?.adapterType === "paperclip_runner" ? "" : (saved?.command as string) ?? "", @@ -1047,7 +1048,7 @@ function OnboardingWizardInner({ if (requestCopilotModels) void refreshAdapterModels(); }, [requestCopilotModels, copilotModelScope, refreshAdapterModels]); const copilotModelReady = Boolean(copilotConnection && copilotEnvironmentReady && !adapterModelsError && !adapterModelsFetching && adapterModels?.some((entry) => entry.id === model.trim())); - const needsRunnerDiscovery = ["codex_local", "claude_local"].includes(adapterType) && runner === "auto"; + const needsRunnerDiscovery = ["codex_local", "claude_local", "opencode_local"].includes(adapterType) && runner === "auto"; const resolvedRunner = runner === "auto" ? runnerDiscovery.data?.find(item => item.type === adapterType)?.defaultRunner : runner; @@ -2261,7 +2262,7 @@ function OnboardingWizardInner({ { adapterConfig: testedConfig, ...(restoredSetup?.companyId === createdCompanyId ? { agentId: restoredSetup.id } : {}), - ...(["codex_local", "claude_local"].includes(adapterType) && runner !== "auto" ? { runner } : {}), + ...(["codex_local", "claude_local", "opencode_local"].includes(adapterType) && runner !== "auto" ? { runner } : {}), ...(managedBindingForStep() ? { aiConnection: managedBindingForStep() } : {}), environmentId, } @@ -2523,7 +2524,7 @@ function OnboardingWizardInner({ ...(restoredSetup?.companyId === createdCompanyId ? { reportsTo: restoredSetup.reportsTo } : {}), adapterType, adapterConfig: hireAdapterConfig, - ...(["codex_local", "claude_local"].includes(adapterType) && runner !== "auto" ? { runner } : {}), + ...(["codex_local", "claude_local", "opencode_local"].includes(adapterType) && runner !== "auto" ? { runner } : {}), ...(shouldApplyStoredClaudeLogin ? { applyStoredClaudeLogin: true } : {}), // The server owns what the first agent is told now: this marker seeds // the chief-of-staff persona over the agent's entry instruction file. @@ -3256,7 +3257,7 @@ function OnboardingWizardInner({ ) : null} - {sourceSelected && ["codex_local", "claude_local"].includes(adapterType) &&
+ {sourceSelected && ["codex_local", "claude_local", "opencode_local"].includes(adapterType) &&
Advanced { void runnerDiscovery.refetch(); }} diff --git a/ui/src/components/new-agent/NewAgentSetup.tsx b/ui/src/components/new-agent/NewAgentSetup.tsx index b99074e2b17..a026ca9606d 100644 --- a/ui/src/components/new-agent/NewAgentSetup.tsx +++ b/ui/src/components/new-agent/NewAgentSetup.tsx @@ -93,12 +93,12 @@ export function NewAgentSetup() { if (params.get("adapterType") === "muse" || params.get("runnerProvider") === "muse") return navigate("/agents/all")} onBack={() => navigate("/agents/all")} />; return ( (initialRunner === "legacy" || initialRunner === "paperclip" ? initialRunner : "auto"); - const requestAdapterType = preserveNativeHarness && runner === "paperclip" ? "paperclip_runner" : adapterType; const isRunner = adapterType === "paperclip_runner"; const isDot = isRunner && runnerProvider === "openai_dot"; const brandType = isDot ? "openai_dot" : isRunner @@ -203,6 +202,10 @@ function Setup({ currentCreated ?? (savedAgent.data?.companyId === companyId ? savedAgent.data : null); const completed = Boolean(created && created.pauseReason !== "setup"); + const savedNativeOpenCode = brandType === "opencode_local" && (Boolean(setupAgentId) || runner === "legacy") + && created?.adapterType === "paperclip_runner"; + const requestAdapterType = savedNativeOpenCode || (preserveNativeHarness && runner === "paperclip") + ? "paperclip_runner" : adapterType; const [saving, setSaving] = useState(false); const [testState, setTestState] = useState("idle"); const [result, setResult] = useState( @@ -382,7 +385,7 @@ function Setup({ ) && (requestAdapterType !== "paperclip_runner" || adapterType === "paperclip_runner" || adapterInventory.data?.some(adapter => adapter.type === "paperclip_runner" && adapter.loaded && !adapter.disabled)); const connectionSettingsReady = Boolean( available && - (!["codex_local", "claude_local"].includes(adapterType) || runner !== "auto" || Boolean(adapters.data)) && + (!["codex_local", "claude_local", "opencode_local"].includes(adapterType) || runner !== "auto" || Boolean(adapters.data)) && !environmentError && !envs.isPending && !settings.isPending && @@ -398,7 +401,7 @@ function Setup({ !caps.error && (!(managedOnly || forced.forced) || environmentId), ); - const needsCurrentSetup = ["codex_local", "claude_local"].includes(adapterType); + const needsCurrentSetup = ["codex_local", "claude_local", "opencode_local"].includes(adapterType); const ready = Boolean(created) && connectionSettingsReady && (!needsCurrentSetup || ( envs.isSuccess && !envs.isFetching && settings.isSuccess && !settings.isFetching @@ -433,9 +436,9 @@ function Setup({ } : {}), }; - const config = getUIAdapter(preserveNativeHarness && runner === "paperclip" ? "paperclip_runner" : adapterType).buildAdapterConfig(preserveNativeHarness && runner === "paperclip" ? { ...values, adapterSchemaValues: runnerProvider === "claude" ? { provider: "acpx", acpxAgent: "claude", acpxPermissionMode: "approve-all" } : { provider: "codex" } } : values); + const config = getUIAdapter(preserveNativeHarness && runner === "paperclip" ? "paperclip_runner" : adapterType).buildAdapterConfig(preserveNativeHarness && runner === "paperclip" ? { ...values, adapterSchemaValues: runnerProvider === "claude" ? { provider: "acpx", acpxAgent: "claude", acpxPermissionMode: "approve-all" } : { provider: runnerProvider } } : values); if (preserveNativeHarness && runner === "paperclip") { - Object.assign(config, runnerProvider === "claude" ? { provider: "acpx", acpxAgent: "claude", acpxPermissionMode: "approve-all", lifecycleMode: "per_turn" } : { provider: "codex", codexPermissionMode: "never", lifecycleMode: "per_turn" }); + Object.assign(config, runnerProvider === "claude" ? { provider: "acpx", acpxAgent: "claude", acpxPermissionMode: "approve-all", lifecycleMode: "per_turn" } : runnerProvider === "opencode" ? { provider: "opencode", opencodePermissionMode: "allow", lifecycleMode: "per_turn" } : { provider: "codex", codexPermissionMode: "never", lifecycleMode: "per_turn" }); } if (isRunner) Object.assign(config, { @@ -443,6 +446,18 @@ function Setup({ ...((runnerProvider === "claude" || runnerProvider === "grok" || runnerProvider === "copilot") ? { acpxAgent: runnerProvider } : {}), ...(model ? { model } : {}), }); + if (brandType === "opencode_local" && created) { + // The simplified setup page does not edit these saved runtime policies. + const saved = created.adapterConfig; + for (const [key, value] of Object.entries(saved)) { + if (!(key in config)) config[key] = value; + } + for (const key of ["dangerouslySkipPermissions", "opencodePermissionMode", "lifecycleMode", "idleTimeoutMs"]) { + if (saved[key] !== undefined) config[key] = saved[key]; + } + config.model = model; + config.env = { ...((saved.env as object) ?? {}), ...((config.env as object) ?? {}) }; + } if (!aiBinding && !nextConnection?.aiConnection && hasCredentialField && binding) { if (adapterType === "hermes_gateway") config.apiKey = binding; else @@ -521,7 +536,7 @@ function Setup({ creating.current = true; setError(null); navigate(`/agents/new?${new URLSearchParams({ name, adapterType, runnerProvider, - ...(runner !== "auto" ? { runner } : {}), setupAgentId: setupId.current })}`, { replace: true }); + runner, setupAgentId: setupId.current })}`, { replace: true }); const existing = agents.data ?? []; const leader = existing.find(agent => agent.role === "ceo" && agent.status !== "terminated"); void agentsApi.create(companyId, { @@ -547,6 +562,7 @@ function Setup({ const agent = savedAgent.data; if (agent.pauseReason !== "setup") { setScreen("saved"); return; } const config = agent.adapterConfig; + if (brandType === "opencode_local") setRunner(agent.adapterType === "paperclip_runner" ? "paperclip" : "legacy"); setModel(typeof config.model === "string" ? config.model : ""); setEffort(String(config.modelReasoningEffort ?? config.reasoningEffort ?? config.effort ?? config.thinkingEffort ?? "")); setEnvironmentOverride(agent.defaultEnvironmentId ?? ""); @@ -568,7 +584,7 @@ function Setup({ if (binding && connectionAdapter) setConnection({ env: {}, aiConnection: binding }); else if (binding) setRuntimeAiBinding(binding); setScreen(binding || !connectionAdapter ? "runtime" : "connect"); - }, [savedAgent.data, companyId, connectionAdapter]); + }, [savedAgent.data, companyId, connectionAdapter, brandType]); async function saveDraft(nextConnection = connection, exit = false, testedConfig?: Record) { if (!created || completed) return; @@ -646,7 +662,7 @@ function Setup({ companyId, adapterType: requestAdapterType, providerAdapter: brandType, - ...(["codex_local", "claude_local"].includes(adapterType) && runner !== "auto" ? { runner } : {}), + ...(["codex_local", "claude_local", "opencode_local"].includes(adapterType) && runner !== "auto" ? { runner } : {}), adapterConfig: config, testCredentials: pendingCredentials(nextConnection), aiConnection: runtimeAiBinding ?? nextConnection?.aiConnection, @@ -701,7 +717,7 @@ function Setup({ role: created.role, reportsTo: created.reportsTo, adapterType: requestAdapterType, - ...(["codex_local", "claude_local"].includes(adapterType) && runner !== "auto" ? { runner } : {}), + ...(["codex_local", "claude_local", "opencode_local"].includes(adapterType) && runner !== "auto" ? { runner } : {}), adapterConfig: config, defaultEnvironmentId: environmentOverride || @@ -967,7 +983,7 @@ function Setup({ }).catch(cause => setError(cause instanceof Error ? cause.message : "Could not save agent setup.")); }} /> :

Loading connection settings…

} - {["codex_local", "claude_local"].includes(adapterType) &&
+ {["codex_local", "claude_local", "opencode_local"].includes(adapterType) &&
Advanced { void adapters.refetch(); }} @@ -1320,7 +1336,7 @@ function Setup({ )} - {["codex_local", "claude_local"].includes(adapterType) &&
+ {["codex_local", "claude_local", "opencode_local"].includes(adapterType) &&
Advanced { void adapters.refetch(); }} diff --git a/ui/src/pages/CompanyImport.test.tsx b/ui/src/pages/CompanyImport.test.tsx index 6c4ae12aee8..cd714efa92c 100644 --- a/ui/src/pages/CompanyImport.test.tsx +++ b/ui/src/pages/CompanyImport.test.tsx @@ -1185,7 +1185,6 @@ describe("CompanyImport", () => { }); it.each([ - { provider: "opencode", model: "provider/model" }, { provider: "acpx", acpxAgent: "codex" }, { provider: "unknown-provider" }, ])("keeps gated native $provider identity instead of falling back to another harness", async adapterConfig => { @@ -1208,6 +1207,26 @@ describe("CompanyImport", () => { expect(lastImportMeta().adapterOverrides?.coder).toBeUndefined(); }); + it("keeps native OpenCode identity and saved policies with the experimental flag off", async () => { + mockAdaptersApi.list.mockResolvedValue([ + { type: "claude_local", disabled: false }, + { type: "opencode_local", disabled: false, supportedRunners: ["paperclip", "legacy"], defaultRunner: "paperclip" }, + { type: "paperclip_runner", disabled: false }, + ]); + const preview = buildMixedAdapterPreviewResult(); + preview.manifest.agents[0]!.adapterType = "paperclip_runner"; + preview.manifest.agents[0]!.adapterConfig = { provider: "opencode", model: "provider/model", opencodePermissionMode: "ask", lifecycleMode: "persistent" }; + mockCompaniesApi.importPreview.mockResolvedValue(preview); + await renderPage(); + await enterGithubUrl(); + await clickButton(text => text === "Preview import"); + expect(findAdapterSelects()[0]!.value).toBe("opencode_local"); + expect(container.textContent).not.toContain("requires its experimental feature to be enabled"); + await clickButton(text => text.startsWith("Import 3 file")); + await settle(); + expect(lastImportMeta().adapterOverrides?.coder).toBeUndefined(); + }); + it.each([ { sourceType: "codex_local", sourceRunner: "legacy" as const, runner: "paperclip" as const, sourcePolicy: {}, expectedBypass: undefined }, { sourceType: "codex_local", sourceRunner: undefined, runner: "legacy" as const, sourcePolicy: { dangerouslyBypassApprovalsAndSandbox: false }, expectedBypass: false }, diff --git a/ui/src/pages/CompanyImport.tsx b/ui/src/pages/CompanyImport.tsx index 4077f52052b..0f4c213be81 100644 --- a/ui/src/pages/CompanyImport.tsx +++ b/ui/src/pages/CompanyImport.tsx @@ -576,7 +576,7 @@ function importAdapterValues( sourceAdapterType?: string, runner?: AgentRunnerChoice, ): CreateConfigValues { - const nativeHarness = sourceAdapterType === "paperclip_runner" && ["codex_local", "claude_local"].includes(choice); + const nativeHarness = sourceAdapterType === "paperclip_runner" && ["codex_local", "claude_local", "opencode_local"].includes(choice); const nativeCodex = nativeHarness && choice === "codex_local"; const savedBypass = sourceConfig?.dangerouslyBypassApprovalsAndSandbox ?? (nativeCodex ? undefined : sourceConfig?.dangerouslyBypassSandbox); @@ -593,6 +593,7 @@ function importAdapterValues( ? (sourceConfig.codexPermissionMode ?? PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES.codex.defaultMode) === "never" : resolveCodexLocalCliPolicy(sourceConfig).bypass, } : {}), + ...(sourceConfig && choice === "opencode_local" ? { model: typeof sourceConfig.model === "string" ? sourceConfig.model : "" } : {}), ...(sourceConfig && choice === "claude_local" ? { model: typeof sourceConfig.model === "string" ? sourceConfig.model : "", thinkingEffort: String(sourceConfig.modelReasoningEffort ?? sourceConfig.reasoningEffort ?? sourceConfig.effort ?? "") } : {}), ...(nativeHarness && sourceConfig ? { cwd: typeof sourceConfig.cwd === "string" ? sourceConfig.cwd : "", @@ -1681,7 +1682,8 @@ export function CompanyImport() { return importPreview.manifest.agents.map((a) => { const nativeCodex = a.adapterType === "paperclip_runner" && (a.adapterConfig?.provider === "codex" || a.adapterConfig?.provider === undefined); - const nativeHarness = nativeCodex || (a.adapterType === "paperclip_runner" && agentHarnessType(a.adapterType, a.adapterConfig) === "claude_local"); + const nativeHarness = nativeCodex || (a.adapterType === "paperclip_runner" + && ["claude_local", "opencode_local"].includes(agentHarnessType(a.adapterType, a.adapterConfig))); const adapterChoice = a.adapterType === "paperclip_runner" && a.adapterConfig?.provider === "openai_dot" ? "openai_dot" : a.adapterType === "paperclip_runner" && a.adapterConfig?.provider === "muse" ? "muse" : nativeHarness ? agentHarnessType(a.adapterType, a.adapterConfig) : a.adapterType; let fallbackAdapterType: string | null = null; @@ -1735,7 +1737,7 @@ export function CompanyImport() { if (selectedType === agent.adapterChoice && !adapterConfigEditedSlugs.has(agent.slug) && (!configVals || configVals.runner === importAdapterValues(selectedType, agent.adapterConfig, agent.adapterType, agent.runner).runner)) continue; const retainsNativeHarness = agent.adapterType === "paperclip_runner" - && selectedType === agent.adapterChoice && ["codex_local", "claude_local"].includes(selectedType); + && selectedType === agent.adapterChoice && ["codex_local", "claude_local", "opencode_local"].includes(selectedType); const apiType = selectedType === "openai_dot" || selectedType === "muse" || retainsNativeHarness ? "paperclip_runner" : selectedType; const override: CompanyPortabilityAdapterOverride = { adapterType: apiType }; if (configVals?.runner !== undefined) override.runner = configVals.runner; @@ -1756,7 +1758,7 @@ export function CompanyImport() { const build = getUIAdapter(nativeClaudeForm ? "paperclip_runner" : configVals.adapterType).buildAdapterConfig; const edited = build(configVals); if (agent.adapterType === "paperclip_runner" && selectedType === agent.adapterChoice - && ["codex_local", "claude_local"].includes(selectedType) && configVals.adapterType === selectedType + && ["codex_local", "claude_local", "opencode_local"].includes(selectedType) && configVals.adapterType === selectedType && agentHarnessType("paperclip_runner", configVals.adapterSchemaValues) === selectedType) { const original = importAdapterValues(selectedType, agent.adapterConfig, agent.adapterType, configVals.runner); const baseline = build(original); diff --git a/ui/src/pages/NewAgent.test.tsx b/ui/src/pages/NewAgent.test.tsx index cedd0c00dd5..3138ac41d5b 100644 --- a/ui/src/pages/NewAgent.test.tsx +++ b/ui/src/pages/NewAgent.test.tsx @@ -1022,6 +1022,61 @@ describe("New agent setup", () => { expect(secrets.create).toHaveBeenCalledTimes(1); }, ); + it.each(["auto", "legacy"] as const)("keeps OpenCode %s selection consistent between testing and hiring", async runner => { + settings.getExperimental.mockResolvedValue({ enableNativeRunner: false }); + state.adapters = state.adapters.map((adapter: any) => adapter.type === "opencode_local" + ? { ...adapter, supportedRunners: ["paperclip", "legacy"], defaultRunner: "paperclip" } : adapter); + await render("opencode_local"); + const model = "openrouter/anthropic/claude-sonnet-4.6"; + await fill("Model", model); + const select = container.querySelector('[aria-label="Runner"]')!; + expect(select.textContent).toContain("Paperclip Runner"); + if (runner === "legacy") { + await act(async () => select.click()); + await act(async () => document.querySelector('[role="option"][data-value="legacy"]')!.click()); + } + await click("Run test"); + await click("Finish setup"); + expect(api.testEnvironment).toHaveBeenCalledWith("company-1", "opencode_local", expect.objectContaining({ adapterConfig: expect.objectContaining({ model }), ...(runner === "legacy" ? { runner } : {}) })); + expect(api.hire).toHaveBeenCalledWith("company-1", expect.objectContaining({ adapterType: "opencode_local", adapterConfig: expect.objectContaining({ model }), ...(runner === "legacy" ? { runner } : {}) })); + if (runner === "auto") expect(api.hire.mock.calls[0][1]).not.toHaveProperty("runner"); + }); + + it("retains automatic OpenCode selection while the first draft request is in flight", async () => { + let release!: () => void; + const originalCreate = api.create.getMockImplementation()!; + api.create.mockImplementation(async (...args) => { + await new Promise(resolve => { release = resolve; }); + return originalCreate(...args); + }); + await render("opencode_local"); + const url = state.navigate.mock.calls[0][0] as string; + state.params = new URLSearchParams(url.split("?")[1]); + expect(state.params.get("runner")).toBe("auto"); + await renderCurrentParams(); + expect(api.create).toHaveBeenCalledOnce(); + await act(async () => release()); + await settle(); + expect(api.create.mock.calls[0][1].runner).toBe("auto"); + expect(api.create).toHaveBeenCalledOnce(); + }); + + it.each(["opencode_local", "paperclip_runner"])("resumes OpenCode's saved %s setup after a default change", async adapterType => { + const id = "00000000-0000-4000-8000-000000000092"; + state.params = new URLSearchParams({ name: "OpenCode", adapterType: "opencode_local", setupAgentId: id }); + state.adapters = state.adapters.map((adapter: any) => adapter.type === "opencode_local" + ? { ...adapter, supportedRunners: ["paperclip", "legacy"], defaultRunner: "paperclip" } : adapter); + const savedPolicy = adapterType === "paperclip_runner" + ? { provider: "opencode", opencodePermissionMode: "ask", lifecycleMode: "persistent", idleTimeoutMs: 30000 } + : { command: "custom-opencode", dangerouslySkipPermissions: false }; + api.get.mockResolvedValue({ id, companyId: "company-1", name: "OpenCode", adapterType, adapterConfig: { ...savedPolicy, model: "openrouter/anthropic/claude-sonnet-4.6" }, runtimeConfig: {}, pauseReason: "setup", status: "paused" }); + await renderCurrentParams(); + await settle(); + await click("Finish setup"); + expect(api.hire.mock.calls[0][1].runner).toBe(adapterType === "paperclip_runner" ? "paperclip" : "legacy"); + expect(api.hire.mock.calls[0][1]).toMatchObject({ adapterType, adapterConfig: savedPolicy }); + }); + it("connects OpenRouter before testing and hiring OpenCode without copying credentials into the agent", async () => { await render("opencode_local"); const model = "openrouter/anthropic/claude-sonnet-4.6";