diff --git a/packages/adapters/opencode-local/src/index.ts b/packages/adapters/opencode-local/src/index.ts
index ff326f99..885228af 100644
--- a/packages/adapters/opencode-local/src/index.ts
+++ b/packages/adapters/opencode-local/src/index.ts
@@ -5,6 +5,13 @@ export const label = "OpenCode (local)";
export const DEFAULT_OPENCODE_LOCAL_MODEL = "openai/gpt-5.2-codex";
+export function isValidOpenCodeModelId(value: unknown): value is string {
+ if (typeof value !== "string") return false;
+ const trimmed = value.trim();
+ const slashIndex = trimmed.indexOf("/");
+ return Boolean(trimmed) && slashIndex > 0 && slashIndex !== trimmed.length - 1;
+}
+
export const models: Array<{ id: string; label: string }> = [
{ id: DEFAULT_OPENCODE_LOCAL_MODEL, label: DEFAULT_OPENCODE_LOCAL_MODEL },
{ id: "openai/gpt-5.4", label: "openai/gpt-5.4" },
diff --git a/packages/adapters/opencode-local/src/server/index.ts b/packages/adapters/opencode-local/src/server/index.ts
index 3c92f753..a57970d0 100644
--- a/packages/adapters/opencode-local/src/server/index.ts
+++ b/packages/adapters/opencode-local/src/server/index.ts
@@ -67,6 +67,7 @@ export {
listOpenCodeModels,
discoverOpenCodeModels,
ensureOpenCodeModelConfiguredAndAvailable,
+ requireOpenCodeModelId,
resetOpenCodeModelsCacheForTests,
} from "./models.js";
export { parseOpenCodeJsonl, isOpenCodeUnknownSessionError } from "./parse.js";
diff --git a/packages/adapters/opencode-local/src/server/models.test.ts b/packages/adapters/opencode-local/src/server/models.test.ts
index cd49e4a2..0f0bea0c 100644
--- a/packages/adapters/opencode-local/src/server/models.test.ts
+++ b/packages/adapters/opencode-local/src/server/models.test.ts
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from "vitest";
import {
ensureOpenCodeModelConfiguredAndAvailable,
listOpenCodeModels,
+ requireOpenCodeModelId,
resetOpenCodeModelsCacheForTests,
} from "./models.js";
@@ -22,6 +23,19 @@ describe("openCode models", () => {
).rejects.toThrow("OpenCode requires `adapterConfig.model`");
});
+ it("accepts a provider/model id without running discovery", () => {
+ expect(requireOpenCodeModelId("openai/gpt-5.2-codex")).toBe("openai/gpt-5.2-codex");
+ });
+
+ it("rejects malformed provider/model ids before discovery", () => {
+ expect(() => requireOpenCodeModelId("gpt-5.2-codex")).toThrow(
+ "OpenCode requires `adapterConfig.model`",
+ );
+ expect(() => requireOpenCodeModelId("openai/")).toThrow(
+ "OpenCode requires `adapterConfig.model`",
+ );
+ });
+
it("rejects when discovery cannot run for configured model", async () => {
process.env.PAPERCLIP_OPENCODE_COMMAND = "__paperclip_missing_opencode_command__";
await expect(
diff --git a/packages/adapters/opencode-local/src/server/models.ts b/packages/adapters/opencode-local/src/server/models.ts
index 95cb1fc9..31e59ac2 100644
--- a/packages/adapters/opencode-local/src/server/models.ts
+++ b/packages/adapters/opencode-local/src/server/models.ts
@@ -6,6 +6,7 @@ import {
ensurePathInEnv,
runChildProcess,
} from "@paperclipai/adapter-utils/server-utils";
+import { isValidOpenCodeModelId } from "../index.js";
const MODELS_CACHE_TTL_MS = 60_000;
const MODELS_DISCOVERY_TIMEOUT_MS = 20_000;
@@ -23,6 +24,14 @@ const discoveryCache = new Map();
const deduped: AdapterModel[] = [];
@@ -172,10 +181,7 @@ export async function ensureOpenCodeModelConfiguredAndAvailable(input: {
cwd?: unknown;
env?: unknown;
}): Promise {
- const model = asString(input.model, "").trim();
- if (!model) {
- throw new Error("OpenCode requires `adapterConfig.model` in provider/model format.");
- }
+ const model = requireOpenCodeModelId(input.model);
const models = await discoverOpenCodeModelsCached({
command: input.command,
diff --git a/server/src/__tests__/adapter-model-refresh-routes.test.ts b/server/src/__tests__/adapter-model-refresh-routes.test.ts
index 5be7a5a0..68553e9d 100644
--- a/server/src/__tests__/adapter-model-refresh-routes.test.ts
+++ b/server/src/__tests__/adapter-model-refresh-routes.test.ts
@@ -1,8 +1,16 @@
import express from "express";
import request from "supertest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { models as openCodeFallbackModels } from "@paperclipai/adapter-opencode-local";
import type { ServerAdapterModule } from "../adapters/index.js";
+vi.mock("acpx/runtime", () => ({
+ createAcpRuntime: vi.fn(),
+ createAgentRegistry: vi.fn(),
+ createRuntimeStore: vi.fn(),
+ isAcpRuntimeError: vi.fn(() => false),
+}));
+
const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(),
hasPermission: vi.fn(),
@@ -19,6 +27,10 @@ const mockSecretService = vi.hoisted(() => ({
normalizeAdapterConfigForPersistence: vi.fn(async (_companyId: string, config: Record) => config),
resolveAdapterConfigForRuntime: vi.fn(async (_companyId: string, config: Record) => ({ config })),
}));
+const mockEnvironmentService = vi.hoisted(() => ({
+ getById: vi.fn(),
+}));
+const mockListOpenCodeModels = vi.hoisted(() => vi.fn());
const mockAgentInstructionsService = vi.hoisted(() => ({
materializeManagedBundle: vi.fn(),
@@ -55,6 +67,14 @@ const mockInstanceSettingsService = vi.hoisted(() => ({
const mockLogActivity = vi.hoisted(() => vi.fn());
function registerModuleMocks() {
+ vi.doMock("@paperclipai/adapter-opencode-local/server", async () => {
+ const actual = await vi.importActual("@paperclipai/adapter-opencode-local/server");
+ return {
+ ...actual,
+ listOpenCodeModels: mockListOpenCodeModels,
+ };
+ });
+
vi.doMock("../services/index.js", () => ({
agentService: () => ({}),
agentInstructionsService: () => mockAgentInstructionsService,
@@ -74,6 +94,10 @@ function registerModuleMocks() {
vi.doMock("../services/instance-settings.js", () => ({
instanceSettingsService: () => mockInstanceSettingsService,
}));
+
+ vi.doMock("../services/environments.js", () => ({
+ environmentService: () => mockEnvironmentService,
+ }));
}
const refreshableAdapterType = "refreshable_adapter_route_test";
@@ -147,6 +171,10 @@ describe("adapter model refresh route", () => {
mockAccessService.ensureMembership.mockResolvedValue(undefined);
mockAccessService.setPrincipalPermission.mockResolvedValue(undefined);
mockLogActivity.mockResolvedValue(undefined);
+ mockEnvironmentService.getById.mockReset();
+ mockEnvironmentService.getById.mockResolvedValue(null);
+ mockListOpenCodeModels.mockReset();
+ mockListOpenCodeModels.mockResolvedValue([{ id: "dynamic-opencode-model", label: "dynamic-opencode-model" }]);
await unregisterTestAdapter(refreshableAdapterType);
});
@@ -182,4 +210,42 @@ describe("adapter model refresh route", () => {
expect(refreshModels).toHaveBeenCalledTimes(1);
expect(listModels).not.toHaveBeenCalled();
});
+
+ it("skips OpenCode model discovery for non-local environments", async () => {
+ mockEnvironmentService.getById.mockResolvedValue({
+ id: "env-1",
+ companyId: "company-1",
+ name: "Remote SSH",
+ driver: "ssh",
+ config: {},
+ });
+
+ const app = await createApp();
+ const res = await requestApp(app, (baseUrl) =>
+ request(baseUrl).get("/api/companies/company-1/adapters/opencode_local/models?environmentId=env-1"),
+ );
+
+ expect(res.status, JSON.stringify(res.body)).toBe(200);
+ expect(res.body).toEqual(openCodeFallbackModels);
+ expect(mockListOpenCodeModels).not.toHaveBeenCalled();
+ });
+
+ it("keeps OpenCode model discovery enabled for local environments", async () => {
+ mockEnvironmentService.getById.mockResolvedValue({
+ id: "env-1",
+ companyId: "company-1",
+ name: "Local",
+ driver: "local",
+ config: {},
+ });
+
+ const app = await createApp();
+ const res = await requestApp(app, (baseUrl) =>
+ request(baseUrl).get("/api/companies/company-1/adapters/opencode_local/models?environmentId=env-1"),
+ );
+
+ expect(res.status, JSON.stringify(res.body)).toBe(200);
+ expect(res.body).toEqual([{ id: "dynamic-opencode-model", label: "dynamic-opencode-model" }]);
+ expect(mockListOpenCodeModels).toHaveBeenCalledTimes(1);
+ });
});
diff --git a/server/src/__tests__/adapter-models.test.ts b/server/src/__tests__/adapter-models.test.ts
index 2be936d9..9e0ec359 100644
--- a/server/src/__tests__/adapter-models.test.ts
+++ b/server/src/__tests__/adapter-models.test.ts
@@ -7,6 +7,13 @@ import { listAdapterModels, refreshAdapterModels } from "../adapters/index.js";
import { resetCodexModelsCacheForTests } from "../adapters/codex-models.js";
import { resetCursorModelsCacheForTests, setCursorModelsRunnerForTests } from "../adapters/cursor-models.js";
+vi.mock("acpx/runtime", () => ({
+ createAcpRuntime: vi.fn(),
+ createAgentRegistry: vi.fn(),
+ createRuntimeStore: vi.fn(),
+ isAcpRuntimeError: vi.fn(() => false),
+}));
+
describe("adapter model listing", () => {
beforeEach(() => {
delete process.env.OPENAI_API_KEY;
diff --git a/server/src/__tests__/agent-permissions-routes.test.ts b/server/src/__tests__/agent-permissions-routes.test.ts
index 624f3772..218d653f 100644
--- a/server/src/__tests__/agent-permissions-routes.test.ts
+++ b/server/src/__tests__/agent-permissions-routes.test.ts
@@ -1,6 +1,14 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
+import { DEFAULT_OPENCODE_LOCAL_MODEL } from "@paperclipai/adapter-opencode-local";
+
+vi.mock("acpx/runtime", () => ({
+ createAcpRuntime: vi.fn(),
+ createAgentRegistry: vi.fn(),
+ createRuntimeStore: vi.fn(),
+ isAcpRuntimeError: vi.fn(() => false),
+}));
const agentId = "11111111-1111-4111-8111-111111111111";
const companyId = "22222222-2222-4222-8222-222222222222";
@@ -908,6 +916,78 @@ describe.sequential("agent permission routes", () => {
);
});
+ it("seeds opencode agent creation with the static default model without live discovery", async () => {
+ mockEnsureOpenCodeModelConfiguredAndAvailable.mockRejectedValue(
+ new Error("`opencode models` should not be called during creation"),
+ );
+
+ const app = await createApp({
+ type: "board",
+ userId: "board-user",
+ source: "local_implicit",
+ isInstanceAdmin: true,
+ companyIds: [companyId],
+ });
+
+ const res = await requestApp(app, (baseUrl) => request(baseUrl)
+ .post(`/api/companies/${companyId}/agents`)
+ .send({
+ name: "OpenCode Builder",
+ role: "engineer",
+ adapterType: "opencode_local",
+ adapterConfig: {},
+ }));
+
+ expect(res.status, JSON.stringify(res.body)).toBe(201);
+ expect(mockEnsureOpenCodeModelConfiguredAndAvailable).not.toHaveBeenCalled();
+ expect(mockAgentService.create).toHaveBeenCalledWith(
+ companyId,
+ expect.objectContaining({
+ adapterType: "opencode_local",
+ adapterConfig: expect.objectContaining({
+ model: DEFAULT_OPENCODE_LOCAL_MODEL,
+ }),
+ }),
+ );
+ });
+
+ it("accepts manual opencode provider/model values without host-side discovery", async () => {
+ mockEnsureOpenCodeModelConfiguredAndAvailable.mockRejectedValue(
+ new Error("`opencode models` should not be called during creation"),
+ );
+
+ const app = await createApp({
+ type: "board",
+ userId: "board-user",
+ source: "local_implicit",
+ isInstanceAdmin: true,
+ companyIds: [companyId],
+ });
+
+ const res = await requestApp(app, (baseUrl) => request(baseUrl)
+ .post(`/api/companies/${companyId}/agents`)
+ .send({
+ name: "OpenCode Builder",
+ role: "engineer",
+ adapterType: "opencode_local",
+ adapterConfig: {
+ model: "anthropic/claude-sonnet-4-5",
+ },
+ }));
+
+ expect(res.status, JSON.stringify(res.body)).toBe(201);
+ expect(mockEnsureOpenCodeModelConfiguredAndAvailable).not.toHaveBeenCalled();
+ expect(mockAgentService.create).toHaveBeenCalledWith(
+ companyId,
+ expect.objectContaining({
+ adapterType: "opencode_local",
+ adapterConfig: expect.objectContaining({
+ model: "anthropic/claude-sonnet-4-5",
+ }),
+ }),
+ );
+ });
+
it("normalizes hire requests to disable timer heartbeats by default", async () => {
const app = await createApp({
type: "board",
diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts
index c9b755d2..3e92cacf 100644
--- a/server/src/routes/agents.ts
+++ b/server/src/routes/agents.ts
@@ -84,7 +84,8 @@ import {
} from "@paperclipai/adapter-codex-local";
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local";
-import { ensureOpenCodeModelConfiguredAndAvailable } from "@paperclipai/adapter-opencode-local/server";
+import { DEFAULT_OPENCODE_LOCAL_MODEL } from "@paperclipai/adapter-opencode-local";
+import { requireOpenCodeModelId } from "@paperclipai/adapter-opencode-local/server";
import {
loadDefaultAgentInstructionsBundle,
resolveDefaultAgentInstructionsBundleRole,
@@ -767,7 +768,6 @@ export function agentRoutes(
{ strictMode: strictSecretsMode },
);
await assertAdapterConfigConstraints(
- input.companyId,
input.adapterType,
input.constraintAdapterConfig
? { ...input.constraintAdapterConfig, ...normalizedAdapterConfig }
@@ -864,7 +864,10 @@ export function agentRoutes(
next.model = DEFAULT_GEMINI_LOCAL_MODEL;
return ensureGatewayDeviceKey(adapterType, next);
}
- // OpenCode requires explicit model selection — no default
+ if (adapterType === "opencode_local" && !asNonEmptyString(next.model)) {
+ next.model = DEFAULT_OPENCODE_LOCAL_MODEL;
+ return ensureGatewayDeviceKey(adapterType, next);
+ }
if (adapterType === "cursor" && !asNonEmptyString(next.model)) {
next.model = DEFAULT_CURSOR_LOCAL_MODEL;
}
@@ -872,20 +875,12 @@ export function agentRoutes(
}
async function assertAdapterConfigConstraints(
- companyId: string,
adapterType: string | null | undefined,
adapterConfig: Record,
) {
if (adapterType !== "opencode_local") return;
- const { config: runtimeConfig } = await secretsSvc.resolveAdapterConfigForRuntime(companyId, adapterConfig);
- const runtimeEnv = asRecord(runtimeConfig.env) ?? {};
try {
- await ensureOpenCodeModelConfiguredAndAvailable({
- model: runtimeConfig.model,
- command: runtimeConfig.command,
- cwd: runtimeConfig.cwd,
- env: runtimeEnv,
- });
+ requireOpenCodeModelId(adapterConfig.model);
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
throw unprocessable(`Invalid opencode_local adapterConfig: ${reason}`);
@@ -1194,6 +1189,17 @@ export function agentRoutes(
const refresh = typeof req.query.refresh === "string"
? ["1", "true", "yes"].includes(req.query.refresh.toLowerCase())
: false;
+ const environmentId = asNonEmptyString(req.query.environmentId);
+ const environment = environmentId ? await environmentsSvc.getById(environmentId) : null;
+ if (environmentId && (!environment || environment.companyId !== companyId)) {
+ res.status(404).json({ error: "Environment not found" });
+ return;
+ }
+ if (type === "opencode_local" && environment && environment.driver !== "local") {
+ const adapter = requireServerAdapter(type);
+ res.json(adapter.models ?? []);
+ return;
+ }
const models = refresh
? await refreshAdapterModels(type)
: await listAdapterModels(type);
diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts
index 88b66f6d..9cd1946d 100644
--- a/server/src/services/company-portability.ts
+++ b/server/src/services/company-portability.ts
@@ -48,7 +48,7 @@ import {
readPaperclipSkillSyncPreference,
writePaperclipSkillSyncPreference,
} from "@paperclipai/adapter-utils/server-utils";
-import { ensureOpenCodeModelConfiguredAndAvailable } from "@paperclipai/adapter-opencode-local/server";
+import { requireOpenCodeModelId } from "@paperclipai/adapter-opencode-local/server";
import { findServerAdapter } from "../adapters/index.js";
import { forbidden, notFound, unprocessable } from "../errors.js";
import { ghFetch, gitHubApiBase, resolveRawGitHubUrl } from "./github-fetch.js";
@@ -2781,20 +2781,12 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
}
async function assertImportAdapterConfigConstraints(
- companyId: string,
adapterType: string,
adapterConfig: Record,
) {
if (adapterType !== "opencode_local") return;
- const { config: runtimeConfig } = await secrets.resolveAdapterConfigForRuntime(companyId, adapterConfig);
- const runtimeEnv = isPlainRecord(runtimeConfig.env) ? runtimeConfig.env : {};
try {
- await ensureOpenCodeModelConfiguredAndAvailable({
- model: runtimeConfig.model,
- command: runtimeConfig.command,
- cwd: runtimeConfig.cwd,
- env: runtimeEnv,
- });
+ requireOpenCodeModelId(adapterConfig.model);
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
throw unprocessable(`Invalid opencode_local adapterConfig: ${reason}`);
@@ -2824,7 +2816,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
nextAdapterConfig,
{ strictMode: strictSecretsMode },
);
- await assertImportAdapterConfigConstraints(companyId, effectiveAdapterType, normalizedAdapterConfig);
+ await assertImportAdapterConfigConstraints(effectiveAdapterType, normalizedAdapterConfig);
return {
adapterType: effectiveAdapterType,
adapterConfig: normalizedAdapterConfig,
diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts
index d3e79fc0..f25aa5b3 100644
--- a/ui/src/api/agents.ts
+++ b/ui/src/api/agents.ts
@@ -171,10 +171,19 @@ export const agentsApi = {
api.get(agentPath(id, companyId, "/task-sessions")),
resetSession: (id: string, taskKey?: string | null, companyId?: string) =>
api.post(agentPath(id, companyId, "/runtime-state/reset-session"), { taskKey: taskKey ?? null }),
- adapterModels: (companyId: string, type: string, options?: { refresh?: boolean }) =>
- api.get(
- `/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/models${options?.refresh ? "?refresh=1" : ""}`,
- ),
+ adapterModels: (
+ companyId: string,
+ type: string,
+ options?: { refresh?: boolean; environmentId?: string | null },
+ ) => {
+ const params = new URLSearchParams();
+ if (options?.refresh) params.set("refresh", "1");
+ if (options?.environmentId) params.set("environmentId", options.environmentId);
+ const query = params.size > 0 ? `?${params.toString()}` : "";
+ return api.get(
+ `/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/models${query}`,
+ );
+ },
detectModel: (companyId: string, type: string) =>
api.get(
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/detect-model`,
diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx
index 5e232665..9f4804eb 100644
--- a/ui/src/components/AgentConfigForm.tsx
+++ b/ui/src/components/AgentConfigForm.tsx
@@ -20,6 +20,7 @@ import {
} from "@paperclipai/adapter-codex-local";
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local";
+import { DEFAULT_OPENCODE_LOCAL_MODEL } from "@paperclipai/adapter-opencode-local";
import {
Popover,
PopoverContent,
@@ -322,6 +323,17 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
() => new Set(supportedEnvironmentDriversForAdapter(adapterType)),
[adapterType],
);
+ const val = isCreate ? props.values : null;
+ const set = isCreate
+ ? (patch: Partial) => props.onChange(patch)
+ : null;
+ const currentDefaultEnvironmentId = isCreate
+ ? val!.defaultEnvironmentId ?? ""
+ : eff("identity", "defaultEnvironmentId", props.agent.defaultEnvironmentId ?? "");
+ const currentDefaultEnvironment = useMemo(
+ () => environments.find((environment) => environment.id === currentDefaultEnvironmentId) ?? null,
+ [currentDefaultEnvironmentId, environments],
+ );
const runnableEnvironments = useMemo(
() => environments.filter((environment) => {
if (!supportedEnvironmentDrivers.has(environment.driver)) return false;
@@ -334,14 +346,16 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
// Fetch adapter models for the effective adapter type
const modelQueryKey = selectedCompanyId
- ? queryKeys.agents.adapterModels(selectedCompanyId, adapterType)
+ ? queryKeys.agents.adapterModels(selectedCompanyId, adapterType, currentDefaultEnvironmentId || null)
: ["agents", "none", "adapter-models", adapterType];
const {
data: fetchedModels,
error: fetchedModelsError,
} = useQuery({
queryKey: modelQueryKey,
- queryFn: () => agentsApi.adapterModels(selectedCompanyId!, adapterType),
+ queryFn: () => agentsApi.adapterModels(selectedCompanyId!, adapterType, {
+ environmentId: currentDefaultEnvironmentId || null,
+ }),
enabled: Boolean(selectedCompanyId),
});
const [refreshModelsError, setRefreshModelsError] = useState(null);
@@ -362,7 +376,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
}
return agentsApi.detectModel(selectedCompanyId, adapterType);
},
- enabled: Boolean(selectedCompanyId && isLocal),
+ enabled: Boolean(selectedCompanyId && isLocal && adapterType !== "opencode_local"),
});
const detectedModel = detectedModelData?.model ?? null;
const detectedModelCandidates = detectedModelData?.candidates ?? [];
@@ -415,12 +429,6 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
return typeof value === "string" ? value : "";
}, [adapterCheapDefault]);
- // Create mode helpers
- const val = isCreate ? props.values : null;
- const set = isCreate
- ? (patch: Partial) => props.onChange(patch)
- : null;
-
function buildAdapterConfigForTest(): Record {
if (isCreate) {
return uiAdapter.buildAdapterConfig(val!);
@@ -446,15 +454,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
if (!selectedCompanyId) {
throw new Error("Select a company to test adapter environment");
}
- const selectedEnvironmentId = isCreate
- ? val!.defaultEnvironmentId ?? null
- : eff("identity", "defaultEnvironmentId", props.agent.defaultEnvironmentId ?? null);
return agentsApi.testEnvironment(selectedCompanyId, adapterType, {
adapterConfig: buildAdapterConfigForTest(),
- environmentId:
- typeof selectedEnvironmentId === "string" && selectedEnvironmentId.length > 0
- ? selectedEnvironmentId
- : null,
+ environmentId: currentDefaultEnvironmentId || null,
});
},
});
@@ -638,9 +640,6 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
heartbeat: mergedHeartbeat,
};
}, [isCreate, overlay.heartbeat, runtimeConfig, val]);
- const currentDefaultEnvironmentId = isCreate
- ? val!.defaultEnvironmentId ?? ""
- : eff("identity", "defaultEnvironmentId", props.agent.defaultEnvironmentId ?? "");
const effectiveHeartbeat = asObject(effectiveRuntimeConfig.heartbeat);
const maxTurnContinuation = asObject(effectiveHeartbeat.maxTurnContinuation);
const maxTurnContinuationEnabled = asBoolean(maxTurnContinuation.enabled, true);
@@ -834,7 +833,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
} else if (t === "cursor") {
nextValues.model = DEFAULT_CURSOR_LOCAL_MODEL;
} else if (t === "opencode_local") {
- nextValues.model = "";
+ nextValues.model = DEFAULT_OPENCODE_LOCAL_MODEL;
}
set!(nextValues);
} else {
@@ -850,9 +849,11 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
? DEFAULT_CODEX_LOCAL_MODEL
: t === "gemini_local"
? DEFAULT_GEMINI_LOCAL_MODEL
+ : t === "opencode_local"
+ ? DEFAULT_OPENCODE_LOCAL_MODEL
: t === "cursor"
? DEFAULT_CURSOR_LOCAL_MODEL
- : "",
+ : "",
effort: "",
modelReasoningEffort: "",
variant: "",
@@ -975,10 +976,12 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
creatable
detectedModel={detectedModel}
detectedModelCandidates={[]}
- onDetectModel={async () => {
- const result = await refetchDetectedModel();
- return result.data?.model ?? null;
- }}
+ onDetectModel={adapterType === "opencode_local"
+ ? undefined
+ : async () => {
+ const result = await refetchDetectedModel();
+ return result.data?.model ?? null;
+ }}
onRefreshModels={adapterType === "codex_local" ? handleRefreshModels : undefined}
refreshingModels={refreshingModels}
detectModelLabel="Detect model"
@@ -992,6 +995,13 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
: "Failed to load adapter models.")}
)}
+ {adapterType === "opencode_local"
+ && currentDefaultEnvironment
+ && currentDefaultEnvironment.driver !== "local" && (
+
+ Live OpenCode model discovery only runs for Local environments. Using the curated list and manual entry for {currentDefaultEnvironment.name}.
+
+ )}
{supportsModelProfiles && (
agentsApi.adapterModels(createdCompanyId!, adapterType),
+ ? queryKeys.agents.adapterModels(createdCompanyId, adapterType, null)
+ : ["agents", "none", "adapter-models", adapterType, null],
+ queryFn: () => agentsApi.adapterModels(createdCompanyId!, adapterType, { environmentId: null }),
enabled: Boolean(createdCompanyId) && effectiveOnboardingOpen && step === 2
});
const getCapabilities = useAdapterCapabilities();
@@ -329,8 +327,10 @@ export function OnboardingWizard() {
: adapterType === "gemini_local"
? model || DEFAULT_GEMINI_LOCAL_MODEL
: adapterType === "cursor"
- ? model || DEFAULT_CURSOR_LOCAL_MODEL
- : model,
+ ? model || DEFAULT_CURSOR_LOCAL_MODEL
+ : adapterType === "opencode_local"
+ ? model || DEFAULT_OPENCODE_LOCAL_MODEL
+ : model,
command,
args,
url,
@@ -427,36 +427,12 @@ export function OnboardingWizard() {
setError(null);
try {
if (adapterType === "opencode_local") {
- const selectedModelId = model.trim();
- if (!selectedModelId) {
+ if (!isValidOpenCodeModelId(model)) {
setError(
"OpenCode requires an explicit model in provider/model format."
);
return;
}
- if (adapterModelsError) {
- setError(
- adapterModelsError instanceof Error
- ? adapterModelsError.message
- : "Failed to load OpenCode models."
- );
- return;
- }
- if (adapterModelsLoading || adapterModelsFetching) {
- setError(
- "OpenCode models are still loading. Please wait and try again."
- );
- return;
- }
- const discoveredModels = adapterModels ?? [];
- if (!discoveredModels.some((entry) => entry.id === selectedModelId)) {
- setError(
- discoveredModels.length === 0
- ? "No OpenCode models discovered. Run `opencode models` and authenticate providers."
- : `Configured OpenCode model is unavailable: ${selectedModelId}`
- );
- return;
- }
}
if (isLocalAdapter) {
@@ -777,12 +753,17 @@ export function OnboardingWizard() {
onClick={() => {
const nextType = opt.type;
setAdapterType(nextType);
- if (nextType === "codex_local" && !model) {
- setModel(DEFAULT_CODEX_LOCAL_MODEL);
+ if (nextType === "codex_local") {
+ if (!model) {
+ setModel(DEFAULT_CODEX_LOCAL_MODEL);
+ }
+ return;
}
- if (nextType !== "codex_local") {
- setModel("");
+ if (nextType === "opencode_local") {
+ setModel(DEFAULT_OPENCODE_LOCAL_MODEL);
+ return;
}
+ setModel("");
}}
>
{opt.recommended && (
@@ -839,9 +820,7 @@ export function OnboardingWizard() {
return;
}
if (nextType === "opencode_local") {
- if (!model.includes("/")) {
- setModel("");
- }
+ setModel(DEFAULT_OPENCODE_LOCAL_MODEL);
return;
}
setModel("");
diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts
index 590af106..0756d7b5 100644
--- a/ui/src/lib/queryKeys.ts
+++ b/ui/src/lib/queryKeys.ts
@@ -23,8 +23,8 @@ export const queryKeys = {
["agents", "instructions-bundle", id, "file", relativePath] as const,
keys: (agentId: string) => ["agents", "keys", agentId] as const,
configRevisions: (agentId: string) => ["agents", "config-revisions", agentId] as const,
- adapterModels: (companyId: string, adapterType: string) =>
- ["agents", companyId, "adapter-models", adapterType] as const,
+ adapterModels: (companyId: string, adapterType: string, environmentId?: string | null) =>
+ ["agents", companyId, "adapter-models", adapterType, environmentId ?? null] as const,
adapterModelProfiles: (companyId: string, adapterType: string) =>
["agents", companyId, "adapter-model-profiles", adapterType] as const,
detectModel: (companyId: string, adapterType: string) =>
diff --git a/ui/src/pages/NewAgent.tsx b/ui/src/pages/NewAgent.tsx
index d238ef86..bf4cb1e9 100644
--- a/ui/src/pages/NewAgent.tsx
+++ b/ui/src/pages/NewAgent.tsx
@@ -34,6 +34,7 @@ import {
} from "@paperclipai/adapter-codex-local";
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local";
+import { DEFAULT_OPENCODE_LOCAL_MODEL, isValidOpenCodeModelId } from "@paperclipai/adapter-opencode-local";
function createValuesForAdapterType(
adapterType: CreateConfigValues["adapterType"],
@@ -49,7 +50,7 @@ function createValuesForAdapterType(
} else if (adapterType === "cursor") {
nextValues.model = DEFAULT_CURSOR_LOCAL_MODEL;
} else if (adapterType === "opencode_local") {
- nextValues.model = "";
+ nextValues.model = DEFAULT_OPENCODE_LOCAL_MODEL;
}
return nextValues;
}
@@ -86,19 +87,6 @@ export function NewAgent() {
enabled: !!selectedCompanyId,
});
- const {
- data: adapterModels,
- error: adapterModelsError,
- isLoading: adapterModelsLoading,
- isFetching: adapterModelsFetching,
- } = useQuery({
- queryKey: selectedCompanyId
- ? queryKeys.agents.adapterModels(selectedCompanyId, configValues.adapterType)
- : ["agents", "none", "adapter-models", configValues.adapterType],
- queryFn: () => agentsApi.adapterModels(selectedCompanyId!, configValues.adapterType),
- enabled: Boolean(selectedCompanyId),
- });
-
const { data: companySkills } = useQuery({
queryKey: queryKeys.companySkills.list(selectedCompanyId ?? ""),
queryFn: () => companySkillsApi.list(selectedCompanyId!),
@@ -154,32 +142,10 @@ export function NewAgent() {
if (!selectedCompanyId || !name.trim()) return;
setFormError(null);
if (configValues.adapterType === "opencode_local") {
- const selectedModel = configValues.model.trim();
- if (!selectedModel) {
+ if (!isValidOpenCodeModelId(configValues.model)) {
setFormError("OpenCode requires an explicit model in provider/model format.");
return;
}
- if (adapterModelsError) {
- setFormError(
- adapterModelsError instanceof Error
- ? adapterModelsError.message
- : "Failed to load OpenCode models.",
- );
- return;
- }
- if (adapterModelsLoading || adapterModelsFetching) {
- setFormError("OpenCode models are still loading. Please wait and try again.");
- return;
- }
- const discovered = adapterModels ?? [];
- if (!discovered.some((entry) => entry.id === selectedModel)) {
- setFormError(
- discovered.length === 0
- ? "No OpenCode models discovered. Run `opencode models` and authenticate providers."
- : `Configured OpenCode model is unavailable: ${selectedModel}`,
- );
- return;
- }
}
createAgent.mutate(
buildNewAgentHirePayload({
@@ -295,7 +261,6 @@ export function NewAgent() {
mode="create"
values={configValues}
onChange={(patch) => setConfigValues((prev) => ({ ...prev, ...patch }))}
- adapterModels={adapterModels}
onTestActionChange={handleTestAgentActionChange}
onTestActionStateChange={handleTestAgentStateChange}
onTestFeedbackChange={handleTestAgentFeedbackChange}