mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 00:53:38 +02:00
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 <noreply@paperclip.ing>
This commit is contained in:
1 parent
0a5f1546ee
commit
39e44a9d9b
30 files changed
+373
-98
No files matched your search
+16
-4
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -14,6 +14,13 @@ export function buildOpenCodeLocalConfig(v: CreateConfigValues): Record<string,
|
||||
if (v.model) ac.model = v.model;
|
||||
if (v.thinkingEffort) ac.variant = v.thinkingEffort;
|
||||
ac.dangerouslySkipPermissions = v.dangerouslySkipPermissions;
|
||||
const nativeSelected = v.runner === "paperclip"
|
||||
|| ((v.runner === undefined || v.runner === "auto") && v.resolvedRunner === "paperclip");
|
||||
if (nativeSelected) {
|
||||
if (v.adapterSchemaValues?.opencodePermissionMode !== undefined) ac.opencodePermissionMode = v.adapterSchemaValues.opencodePermissionMode;
|
||||
if (v.paperclipRunnerLifecycleMode !== undefined) ac.lifecycleMode = v.paperclipRunnerLifecycleMode;
|
||||
if (v.paperclipRunnerIdleTimeoutMs !== undefined) ac.idleTimeoutMs = v.paperclipRunnerIdleTimeoutMs;
|
||||
}
|
||||
// OpenCode sessions can run until the CLI exits naturally; keep timeout disabled (0)
|
||||
// and rely on graceSec for termination handling when a timeout is configured elsewhere.
|
||||
ac.timeoutSec = 0;
|
||||
|
||||
@@ -6,9 +6,10 @@ export interface AgentRunnerAvailability {
|
||||
defaultRunner: AgentRunner;
|
||||
}
|
||||
|
||||
export function paperclipRunnerProfileForHarness(harness: string): { provider: "codex" } | { provider: "acpx"; acpxAgent: "claude" } | undefined {
|
||||
export function paperclipRunnerProfileForHarness(harness: string): { provider: "codex" | "opencode" } | { provider: "acpx"; acpxAgent: "claude" } | undefined {
|
||||
if (harness === "codex_local") return { provider: "codex" };
|
||||
if (harness === "claude_local") return { provider: "acpx", acpxAgent: "claude" };
|
||||
if (harness === "opencode_local") return { provider: "opencode" };
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -27,6 +28,7 @@ function supportsLinuxLibc(libc?: "glibc" | "musl" | "unknown", version?: string
|
||||
|
||||
export function paperclipRunnerSupportsPlatform(harness: string, platform: string, architecture: string, libc?: "glibc" | "musl" | "unknown", glibcVersion?: string): boolean {
|
||||
return !!paperclipRunnerProfileForHarness(harness)
|
||||
&& !(harness === "opencode_local" && platform === "linux" && architecture === "arm64")
|
||||
&& ((platform === "linux" && supportsLinuxLibc(libc, glibcVersion) && (architecture === "x64" || architecture === "arm64"))
|
||||
|| (platform === "darwin" && (architecture === "arm64" || architecture === "x64")));
|
||||
}
|
||||
|
||||
@@ -762,9 +762,40 @@ describe("agent routes adapter validation", () => {
|
||||
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" });
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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<typeof import("@paperclipai/adapter-utils/ssh")>()),
|
||||
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 });
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
constraintAdapterConfig?: Record<string, unknown>;
|
||||
setupOnly?: boolean;
|
||||
}): Promise<Record<string, unknown>> {
|
||||
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<string, unknown>;
|
||||
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, {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -17,11 +17,13 @@ export interface AgentRunnerSelection {
|
||||
adapterConfig?: Record<string, unknown>;
|
||||
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<string, unknown>): Record<string, unknown> {
|
||||
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<string, unknown>): Record<string, unknown> {
|
||||
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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -437,7 +437,7 @@ export function AgentActionButtons({
|
||||
<Button variant="outline" size={size} disabled={disabled} onClick={() => navigate(`/agents/new?${new URLSearchParams({
|
||||
name: agent.name, adapterType: agent.adapterType, setupAgentId: agent.id,
|
||||
runnerProvider: String(agent.adapterConfig.provider === "acpx" ? agent.adapterConfig.acpxAgent : agent.adapterConfig.provider ?? "codex"),
|
||||
...(agent.adapterType === "paperclip_runner" ? { runner: "paperclip" } : ["codex_local", "claude_local"].includes(agent.adapterType) ? { runner: "legacy" } : {}),
|
||||
...(agent.adapterType === "paperclip_runner" ? { runner: "paperclip" } : ["codex_local", "claude_local", "opencode_local"].includes(agent.adapterType) ? { runner: "legacy" } : {}),
|
||||
})}`)}>Finish setup</Button>
|
||||
) : (
|
||||
<PauseResumeButton
|
||||
|
||||
@@ -814,7 +814,11 @@ describe("AgentConfigForm environment selector", () => {
|
||||
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<HTMLInputElement>('[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<HTMLElement>('[role="option"]')]
|
||||
.find(element => element.textContent === label)!;
|
||||
const option = document.querySelector<HTMLElement>(`[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();
|
||||
|
||||
@@ -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<CreateConfigValues>) => 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 <T>(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"
|
||||
|
||||
@@ -68,7 +68,7 @@ export function ConfigureBuiltInAgentModal({
|
||||
const { definition } = state;
|
||||
|
||||
const [adapterType, setAdapterType] = useState<string>(
|
||||
() => 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<AgentRunnerChoice | undefined>();
|
||||
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({
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{["codex_local", "claude_local"].includes(adapterType) && <details className="space-y-3">
|
||||
{["codex_local", "claude_local", "opencode_local"].includes(adapterType) && <details className="space-y-3">
|
||||
<summary className="cursor-pointer text-sm text-muted-foreground">Advanced</summary>
|
||||
<AgentRunnerSelect value={runner ?? (state.agent ? agentRunner(state.agent.adapterType) : "auto")}
|
||||
pending={adapters.isFetching} error={adapters.error?.message} onRetry={() => { void adapters.refetch(); }}
|
||||
|
||||
@@ -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<AgentRunnerChoice>(() => 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}
|
||||
</motion.div>
|
||||
|
||||
{sourceSelected && ["codex_local", "claude_local"].includes(adapterType) && <details className="space-y-3">
|
||||
{sourceSelected && ["codex_local", "claude_local", "opencode_local"].includes(adapterType) && <details className="space-y-3">
|
||||
<summary className="cursor-pointer text-sm text-muted-foreground">Advanced</summary>
|
||||
<AgentRunnerSelect value={runner} disabled={loading || adapterEnvLoading}
|
||||
pending={runnerDiscovery.isFetching} error={runnerDiscovery.error?.message} onRetry={() => { void runnerDiscovery.refetch(); }}
|
||||
|
||||
@@ -93,12 +93,12 @@ export function NewAgentSetup() {
|
||||
if (params.get("adapterType") === "muse" || params.get("runnerProvider") === "muse") return <ExternalAgentInviteDialog companyId={selectedCompanyId} initialPreset="muse" initialMuseName={params.get("name") ?? ""} onClose={() => navigate("/agents/all")} onBack={() => navigate("/agents/all")} />;
|
||||
return (
|
||||
<Setup
|
||||
key={`${selectedCompanyId}:${params.get("name")}:${params.get("adapterType")}:${params.get("runnerProvider")}:${params.get("runner")}`}
|
||||
key={`${selectedCompanyId}:${params.get("name")}:${params.get("adapterType")}:${params.get("runnerProvider") ?? "codex"}:${params.get("runner") ?? "auto"}`}
|
||||
companyId={selectedCompanyId}
|
||||
name={params.get("name") ?? ""}
|
||||
adapterType={params.get("adapterType") === "paperclip_runner" && ["codex", "claude"].includes(params.get("runnerProvider") ?? "codex") ? params.get("runnerProvider") === "claude" ? "claude_local" : "codex_local" : params.get("adapterType") ?? ""}
|
||||
initialRunner={params.get("runner") ?? (params.get("adapterType") === "paperclip_runner" && ["codex", "claude"].includes(params.get("runnerProvider") ?? "codex") ? "paperclip" : params.has("setupAgentId") && ["codex_local", "claude_local"].includes(params.get("adapterType") ?? "") ? "legacy" : "auto")}
|
||||
preserveNativeHarness={params.get("adapterType") === "paperclip_runner" && ["codex", "claude"].includes(params.get("runnerProvider") ?? "codex")}
|
||||
adapterType={params.get("adapterType") === "paperclip_runner" && ["codex", "claude", "opencode"].includes(params.get("runnerProvider") ?? "codex") ? params.get("runnerProvider") === "claude" ? "claude_local" : params.get("runnerProvider") === "opencode" ? "opencode_local" : "codex_local" : params.get("adapterType") ?? ""}
|
||||
initialRunner={params.get("runner") ?? (params.get("adapterType") === "paperclip_runner" && ["codex", "claude", "opencode"].includes(params.get("runnerProvider") ?? "codex") ? "paperclip" : params.has("setupAgentId") && ["codex_local", "claude_local", "opencode_local"].includes(params.get("adapterType") ?? "") ? "legacy" : "auto")}
|
||||
preserveNativeHarness={params.get("adapterType") === "paperclip_runner" && ["codex", "claude", "opencode"].includes(params.get("runnerProvider") ?? "codex")}
|
||||
runnerProvider={params.get("runnerProvider") ?? "codex"}
|
||||
createdAgentId={params.get("createdAgentId")}
|
||||
setupAgentId={params.get("setupAgentId")}
|
||||
@@ -130,7 +130,6 @@ function Setup({
|
||||
const { openNewIssue } = useDialogActions();
|
||||
const appearanceDraft = useAgentAppearanceDraft(`${companyId}:new-agent`);
|
||||
const [runner, setRunner] = useState<AgentRunnerChoice>(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<TestState | "warn">("idle");
|
||||
const [result, setResult] = useState<AdapterEnvironmentTestResult | null>(
|
||||
@@ -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<string, unknown>) {
|
||||
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."));
|
||||
}}
|
||||
/></fieldset> : <p role="status" className="text-sm text-muted-foreground">Loading connection settings…</p>}
|
||||
{["codex_local", "claude_local"].includes(adapterType) && <details className="mt-5 space-y-3">
|
||||
{["codex_local", "claude_local", "opencode_local"].includes(adapterType) && <details className="mt-5 space-y-3">
|
||||
<summary className="cursor-pointer text-sm text-muted-foreground">Advanced</summary>
|
||||
<AgentRunnerSelect value={runner} disabled={busy}
|
||||
pending={adapters.isFetching} error={adapters.error?.message} onRetry={() => { void adapters.refetch(); }}
|
||||
@@ -1320,7 +1336,7 @@ function Setup({
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
{["codex_local", "claude_local"].includes(adapterType) && <details className="space-y-3">
|
||||
{["codex_local", "claude_local", "opencode_local"].includes(adapterType) && <details className="space-y-3">
|
||||
<summary className="cursor-pointer text-sm text-muted-foreground">Advanced</summary>
|
||||
<AgentRunnerSelect value={runner} disabled={busy}
|
||||
pending={adapters.isFetching} error={adapters.error?.message} onRetry={() => { void adapters.refetch(); }}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<HTMLButtonElement>('[aria-label="Runner"]')!;
|
||||
expect(select.textContent).toContain("Paperclip Runner");
|
||||
if (runner === "legacy") {
|
||||
await act(async () => select.click());
|
||||
await act(async () => document.querySelector<HTMLButtonElement>('[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<void>(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";
|
||||
|
||||
Reference in new issue
Block a user