Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/code/src/renderer/desktop-services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ const reportModelResolverLog = logger.scope("report-model-resolver");
container.bind<ReportModelResolver>(REPORT_MODEL_RESOLVER).toConstantValue({
async resolveDefaultModel(
apiHost: string,
adapter: "claude" | "codex",
adapter: "claude" | "codex" | "opencode",
preferredModel?: string | null,
): Promise<string | undefined> {
try {
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/features/tasks/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -415,7 +415,7 @@ export interface RunTaskInCloudOptions {
pendingUserMessage?: string;
mode?: "interactive" | "background";
/** Adapter to use on the cloud runner. Currently only "claude" on mobile. */
runtimeAdapter?: "claude" | "codex";
runtimeAdapter?: "claude" | "codex" | "opencode";
/** Gateway model ID, e.g. "claude-opus-4-8". */
model?: string;
/** Reasoning effort: "low" | "medium" | "high" (model-dependent). */
Expand Down
89 changes: 88 additions & 1 deletion packages/agent/src/adapters/acp-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ import { ClaudeAcpAgent } from "./claude/claude-agent";
import type { GatewayEnv } from "./claude/session/options";
import { CodexAcpAgent } from "./codex/codex-agent";
import type { CodexProcessOptions } from "./codex/spawn";
import { OpencodeAcpAgent } from "./opencode/opencode-agent";
import type { OpencodeProcessOptions } from "./opencode/spawn";

type AgentAdapter = "claude" | "codex";
type AgentAdapter = "claude" | "codex" | "opencode";

export type AcpConnectionConfig = {
adapter?: AgentAdapter;
Expand All @@ -24,6 +26,7 @@ export type AcpConnectionConfig = {
logger?: Logger;
processCallbacks?: ProcessSpawnedCallback;
codexOptions?: CodexProcessOptions;
opencodeOptions?: OpencodeProcessOptions;
allowedModelIds?: Set<string>;
/** Callback invoked when the agent calls the create_output tool for structured output */
onStructuredOutput?: (output: Record<string, unknown>) => Promise<void>;
Expand Down Expand Up @@ -58,6 +61,10 @@ export function createAcpConnection(
return createCodexConnection(config);
}

if (adapterType === "opencode") {
return createOpencodeConnection(config);
}

return createClaudeConnection(config);
}

Expand Down Expand Up @@ -241,3 +248,83 @@ function createCodexConnection(config: AcpConnectionConfig): AcpConnection {
},
};
}

/**
* Creates an ACP connection to the `opencode acp` subprocess via an in-process
* proxy agent. opencode speaks ACP natively, so OpencodeAcpAgent forwards over a
* ClientSideConnection — the same shape as the codex connection.
*/
function createOpencodeConnection(config: AcpConnectionConfig): AcpConnection {
const logger =
config.logger?.child("OpencodeConnection") ??
new Logger({ debug: true, prefix: "[OpencodeConnection]" });

const { logWriter } = config;
const streams = createBidirectionalStreams();

let agentWritable = streams.agent.writable;
let clientWritable = streams.client.writable;

if (config.taskRunId && logWriter) {
if (!logWriter.isRegistered(config.taskRunId)) {
logWriter.register(config.taskRunId, {
taskId: config.taskId ?? config.taskRunId,
runId: config.taskRunId,
deviceType: config.deviceType,
});
}

const taskRunId = config.taskRunId;
agentWritable = createTappedWritableStream(streams.agent.writable, {
onMessage: (line) => {
logWriter.appendRawLine(taskRunId, line);
},
logger,
});

clientWritable = createTappedWritableStream(streams.client.writable, {
onMessage: (line) => {
logWriter.appendRawLine(taskRunId, line);
},
logger,
});
}

const agentStream = ndJsonStream(agentWritable, streams.agent.readable);

let agent: OpencodeAcpAgent | null = null;
const agentConnection = new AgentSideConnection((client) => {
agent = new OpencodeAcpAgent(client, {
opencodeProcessOptions: config.opencodeOptions ?? {},
processCallbacks: config.processCallbacks,
logger: config.logger?.child("OpencodeAcpAgent"),
});
return agent;
}, agentStream);

return {
agentConnection,
clientStreams: {
readable: streams.client.readable,
writable: clientWritable,
},
cleanup: async () => {
logger.info("Cleaning up Opencode connection");

if (agent) {
await agent.closeSession();
}

try {
await streams.client.writable.close();
} catch {
// Stream may already be closed
}
try {
await streams.agent.writable.close();
} catch {
// Stream may already be closed
}
},
};
}
106 changes: 106 additions & 0 deletions packages/agent/src/adapters/opencode/models.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import type { SessionConfigOption } from "@agentclientprotocol/sdk";
import { describe, expect, it } from "vitest";
import {
formatOpencodeModelName,
modelIdFromConfigOptions,
normalizeOpencodeConfigOptions,
} from "./models";

const modelSelect = (
options: unknown,
currentValue = "posthog/@cf/zai-org/glm-5.2",
): SessionConfigOption =>
({
id: "model",
name: "Model",
type: "select",
category: "model",
currentValue,
options,
}) as unknown as SessionConfigOption;

describe("formatOpencodeModelName", () => {
it("strips the posthog/ provider prefix and takes the final path segment", () => {
expect(formatOpencodeModelName("posthog/@cf/zai-org/glm-5.2")).toBe(
"glm-5.2",
);
expect(formatOpencodeModelName("@cf/zai-org/glm-5.2")).toBe("glm-5.2");
});
});

describe("modelIdFromConfigOptions", () => {
it("returns the model option's currentValue", () => {
expect(modelIdFromConfigOptions([modelSelect([])])).toBe(
"posthog/@cf/zai-org/glm-5.2",
);
});

it("returns undefined when there is no model option", () => {
expect(modelIdFromConfigOptions([])).toBeUndefined();
expect(modelIdFromConfigOptions(undefined)).toBeUndefined();
});
});

describe("normalizeOpencodeConfigOptions", () => {
it("keeps only posthog/* models and cleans their names (flat)", () => {
const result = normalizeOpencodeConfigOptions([
modelSelect([
{ value: "openai/gpt-5", name: "OpenAI/GPT-5" },
{
value: "anthropic/claude-opus-4-8",
name: "Anthropic/Claude Opus 4.8",
},
{
value: "posthog/@cf/zai-org/glm-5.2",
name: "PostHog Gateway/GLM 5.2",
},
]),
]);
const model = result?.find((o) => o.category === "model");
expect((model as { options: unknown }).options).toEqual([
{ value: "posthog/@cf/zai-org/glm-5.2", name: "glm-5.2" },
]);
});

it("filters grouped options and drops empty groups", () => {
const result = normalizeOpencodeConfigOptions([
modelSelect([
{
group: "openai",
name: "OpenAI",
options: [{ value: "openai/gpt-5", name: "GPT-5" }],
},
{
group: "posthog",
name: "PostHog",
options: [{ value: "posthog/@cf/zai-org/glm-5.2", name: "GLM 5.2" }],
},
]),
]);
const model = result?.find((o) => o.category === "model");
expect((model as { options: unknown }).options).toEqual([
{
group: "posthog",
name: "PostHog",
options: [{ value: "posthog/@cf/zai-org/glm-5.2", name: "glm-5.2" }],
},
]);
});

it("leaves non-model options untouched", () => {
const modeOption = {
id: "mode",
name: "Mode",
type: "select",
category: "mode",
currentValue: "auto",
options: [{ value: "auto", name: "Auto" }],
} as unknown as SessionConfigOption;
expect(normalizeOpencodeConfigOptions([modeOption])).toEqual([modeOption]);
});

it("returns null/undefined input unchanged", () => {
expect(normalizeOpencodeConfigOptions(null)).toBeNull();
expect(normalizeOpencodeConfigOptions(undefined)).toBeUndefined();
});
});
73 changes: 73 additions & 0 deletions packages/agent/src/adapters/opencode/models.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import type {
SessionConfigOption,
SessionConfigSelectGroup,
SessionConfigSelectOption,
} from "@agentclientprotocol/sdk";

// All gateway models are registered in the generated opencode.json under this
// provider key, so opencode surfaces them as `posthog/<modelId>` in its config
// options. We keep only those — opencode otherwise lists its ~80 built-in models
// (gpt-*, claude-*, embeddings, image) which we don't want in the Code picker.
export const OPENCODE_PROVIDER_PREFIX = "posthog/";

export function formatOpencodeModelName(value: string): string {
const withoutProvider = value.startsWith(OPENCODE_PROVIDER_PREFIX)
? value.slice(OPENCODE_PROVIDER_PREFIX.length)
: value;
// GLM ids are slash-paths ("@cf/zai-org/glm-5.2") — take the final segment.
return (withoutProvider.split("/").pop() ?? withoutProvider).toLowerCase();
}

export function modelIdFromConfigOptions(
configOptions: SessionConfigOption[] | null | undefined,
): string | undefined {
const modelOption = configOptions?.find((o) => o.category === "model");
return typeof modelOption?.currentValue === "string"
? modelOption.currentValue
: undefined;
}

function isPosthogModel(opt: SessionConfigSelectOption): boolean {
return opt.value.startsWith(OPENCODE_PROVIDER_PREFIX);
}

/**
* Restrict the model picker to our gateway provider and give each entry a clean
* label. Filters opencode's full built-in catalogue down to `posthog/*`.
*/
export function normalizeOpencodeConfigOptions(
configOptions: SessionConfigOption[] | null | undefined,
): SessionConfigOption[] | null | undefined {
if (!configOptions) return configOptions;

const formatOption = (
opt: SessionConfigSelectOption,
): SessionConfigSelectOption => ({
...opt,
name: formatOpencodeModelName(opt.value),
});

return configOptions.map((option) => {
if (option.category !== "model" || option.type !== "select") return option;
const options = option.options;
if (options.length === 0) return option;
const isGroup = "group" in options[0];

if (isGroup) {
Comment on lines +53 to +56

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The isGroup flag is set by inspecting only options[0]. If opencode ever returns a mixed-shape array — even one flat option following a group — all entries after the first will be cast to the wrong type. In the flat branch, isPosthogModel(opt) accesses opt.value which is undefined for group objects, throwing a TypeError. Using Array.prototype.every gives a safe exhaustive guard at negligible cost.

Suggested change
if (options.length === 0) return option;
const isGroup = "group" in options[0];
if (isGroup) {
if (options.length === 0) return option;
const isGroup = options.every((o) => "group" in o);
if (isGroup) {

const groups = (options as SessionConfigSelectGroup[])
.map((group) => ({
...group,
options: group.options.filter(isPosthogModel).map(formatOption),
}))
.filter((group) => group.options.length > 0);
return { ...option, options: groups } as SessionConfigOption;
}

return {
...option,
options: (options as SessionConfigSelectOption[])
.filter(isPosthogModel)
.map(formatOption),
} as SessionConfigOption;
});
}
Loading
Loading