Skip to content
Closed
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
7 changes: 7 additions & 0 deletions docs-site/src/content/docs/reference/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,13 @@ api-server URL beside it, and never the file's other fields.
notifications streaming in between and a unary reply carrying the stop reason and usage. The
conversation is flattened into the single prompt string a session takes, with role labels fenced
so a message body cannot forge one.
- The ACP adapter selects the model with `devin acp --model`, before creating the session.
For SWE-2, an explicit reasoning effort selects `swe-2-medium`, `swe-2-high`, or
`swe-2-max`, overriding an effort suffix in the requested model. `xhigh` and
`ultra` select `max`; lower-than-medium values select `medium`. With no effort,
the requested model id is preserved, including the CLI's `swe-2` default alias.
If the CLI acknowledges a different exact SWE-2 variant, the turn fails before
the user prompt is sent. This mapping is local to the ACP adapter.
- The CLI's own tool calls stay internal. Devin executes them inside its session, so forwarding
them as client tools would either fail the turn — the bridge rejects a tool Codex never declared —
or ask Codex to run something the agent already ran.
Expand Down
22 changes: 17 additions & 5 deletions src/adapters/devin-cli/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
sessionPromptFrame,
} from "./acp";
import { DEVIN_CLI_INSTALL_HINT, resolveDevinCliBinary } from "./binary";
import { resolveDevinCliModel } from "./models";

/** A turn that has not produced a prompt reply by this point is abandoned. */
const DEVIN_CLI_TURN_TIMEOUT_MS = 10 * 60 * 1000;
Expand Down Expand Up @@ -103,16 +104,14 @@ export function createDevinCliAdapter(provider: OcxProviderConfig, deps?: { spaw
return;
}

const modelId = parsed.modelId.includes("/")
? parsed.modelId.slice(parsed.modelId.lastIndexOf("/") + 1)
: parsed.modelId;
const modelId = resolveDevinCliModel(parsed.modelId, parsed.options.reasoning);
const cwd = process.env.OPENCODEX_DEVIN_CLI_CWD?.trim() || process.cwd();
const toolsAllowed = devinCliToolsAllowed();

await new Promise<void>((resolve) => {
let child: ChildProcessWithoutNullStreams;
try {
child = spawnChild(binary, ["acp"], {
child = spawnChild(binary, ["acp", "--model", modelId], {
cwd,
env: {
// A scoped environment, not the proxy's. The child would
Expand Down Expand Up @@ -306,13 +305,26 @@ export function createDevinCliAdapter(provider: OcxProviderConfig, deps?: { spaw

if (id === ACP_INITIALIZE_ID) {
if (error) return finish(`Devin CLI initialize failed: ${error.message ?? "unknown error"}`);
send(sessionNewFrame(cwd, modelId));
send(sessionNewFrame(cwd));
return;
}
if (id === ACP_SESSION_NEW_ID) {
if (error) return finish(`Devin CLI session/new failed: ${error.message ?? "unknown error"}`);
const sessionId = (frame.result as { sessionId?: string } | undefined)?.sessionId;
if (!sessionId) return finish("Devin CLI session/new returned no sessionId.");
// CLI 3000.10.21 ignores the non-standard session/new model field.
// Check its acknowledgement before sending user content when an
// exact SWE-2 effort variant was requested. Older ACP peers may omit
// model metadata; --model remains authoritative in that case.
const result = frame.result as {
configOptions?: Array<{ id?: string; currentValue?: string }>;
models?: { currentModelId?: string };
};
const selected = result.configOptions?.find((option) => option.id === "model")?.currentValue
?? result.models?.currentModelId;
if (/^swe-2-(?:medium|high|max)$/.test(modelId) && selected && selected !== modelId) {
return finish(`Devin CLI selected ${selected} instead of requested ${modelId}.`);
}
send(sessionPromptFrame(sessionId, buildAcpPrompt(parsed)));
return;
}
Expand Down
18 changes: 17 additions & 1 deletion src/adapters/devin-cli/models.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* Models the Devin CLI accepts on `session/new`.
* Models the Devin CLI accepts through `devin acp --model`.
*
* The CLI picks its own default when no model is named, so this roster exists
* for the picker rather than as a gate. It is a static list on purpose: ACP has
Expand All @@ -10,7 +10,9 @@ export const DEVIN_CLI_DEFAULT_MODEL = "swe-2";

export const DEVIN_CLI_MODELS = [
"swe-2",
"swe-2-medium",
"swe-2-high",
"swe-2-max",
"claude-opus-5-medium",
"claude-fable-5-1-medium",
"claude-sonnet-5-medium",
Expand Down Expand Up @@ -44,7 +46,9 @@ export const DEVIN_CLI_MODELS = [
*/
export const DEVIN_CLI_MODEL_CONTEXT_WINDOWS: Record<string, number> = {
"swe-2": 262_000,
"swe-2-medium": 262_000,
"swe-2-high": 262_000,
"swe-2-max": 262_000,
"claude-opus-5-medium": 1_000_000,
"claude-fable-5-1-medium": 1_000_000,
"claude-sonnet-5-medium": 1_000_000,
Expand All @@ -55,3 +59,15 @@ export const DEVIN_CLI_MODEL_CONTEXT_WINDOWS: Record<string, number> = {
"glm-5-3-low": 1_048_576,
"kimi-k3-high": 1_048_576,
};

/** SWE-2 represents effort as a native model id, not a session/new extension. */
export function resolveDevinCliModel(modelId: string, reasoning?: string): string {
const model = modelId.slice(modelId.lastIndexOf("/") + 1);
if (!/^swe-2(?:-(?:medium|high|max))?$/.test(model)) return model;
const efforts: Record<string, string> = {
none: "medium", off: "medium", minimal: "medium", low: "medium",
medium: "medium", high: "high", xhigh: "max", ultra: "max", max: "max",
};
const effort = reasoning ? efforts[reasoning] : undefined;
return effort ? `swe-2-${effort}` : model;
}
9 changes: 9 additions & 0 deletions structure/adapters/registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,12 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi
Claude replay carries [Go conversation affinity](../data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch)
privately to final dispatch; preliminary route selection does not inject Go-only headers.
Native Chat applies qualifying effort ceilings independently of model pins; pin selection precedes the cap and only pins or cap rewrites enter wire mapping. The [catalog effort contract](../catalog.md#ultra-reasoning-level) records the V1/compaction exemptions and caller-preservation boundary.

## Devin ACP model selection

`src/adapters/devin-cli/models.ts` maps SWE-2 reasoning effort to the CLI's native
medium/high/max model ids. The ACP adapter passes the selected id through the
child's `--model` argument, not the non-standard `session/new.model` extension.
An advertised model acknowledgement that differs from an exact requested SWE-2
variant fails before `session/prompt`. Omitted acknowledgement metadata remains
compatible with older peers. Non-SWE-2 ids retain their own effort semantics.
2 changes: 2 additions & 0 deletions structure/data-planes/inbound-compat.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,3 +138,5 @@ Instruction notice extraction scans fence ranges once and walks original lines b
a decreasing cursor. It accepts exactly one ASCII space inside the token notice, preserves
unmatched prefix bytes, and does not repeatedly scan or copy shrinking prompt prefixes.
Native Chat applies qualifying effort ceilings independently of model pins; pin selection precedes the cap and only pins or cap rewrites enter wire mapping. The [catalog effort contract](../catalog.md#ultra-reasoning-level) records the V1/compaction exemptions and caller-preservation boundary.

The local Devin ACP adapter owns [SWE-2 effort selection](../adapters/registry.md#devin-acp-model-selection); shared routing and other adapter effort policies do not apply that model-id rewrite.
2 changes: 2 additions & 0 deletions structure/providers/chat-compat.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,3 +264,5 @@ fragments are not guessed onto pending ID-only calls.
parallel/colliding identities, distinct unsafe raw JSON index literals, the maximum
safe-integer boundary, invalid index types, missing/null continuations and UTF-8
byte-limit boundaries.

The local Devin ACP adapter owns [SWE-2 effort selection](../adapters/registry.md#devin-acp-model-selection); shared routing and other adapter effort policies do not apply that model-id rewrite.
2 changes: 2 additions & 0 deletions structure/providers/cursor.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,5 @@ constraints cannot widen the canonical shape. Bare shell bridge names are reject
on the freeform path.
Namespaced tools do not acquire bare-shell behavior. Regression coverage lives in
`tests/providers/cursor/cursor-tool-definitions.test.ts`.

The local Devin ACP adapter owns [SWE-2 effort selection](../adapters/registry.md#devin-acp-model-selection); shared routing and other adapter effort policies do not apply that model-id rewrite.
2 changes: 2 additions & 0 deletions structure/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,3 +237,5 @@ The lightweight top-level CLI help counts Cline CLI among the fifteen registered
Devin CLI credential path composition in `src/oauth/devin-cli.ts` follows the selected platform: Windows uses Win32 APPDATA paths, other platforms use POSIX XDG-data paths. The explicit absolute override remains verbatim; credential parsing and login behavior are unchanged.

Native Chat applies qualifying effort ceilings independently of model pins; pin selection precedes the cap and only pins or cap rewrites enter wire mapping. The [catalog effort contract](catalog.md#ultra-reasoning-level) records the V1/compaction exemptions and caller-preservation boundary.

The local Devin ACP adapter owns [SWE-2 effort selection](adapters/registry.md#devin-acp-model-selection); shared routing and other adapter effort policies do not apply that model-id rewrite.
2 changes: 2 additions & 0 deletions structure/transports/inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,5 @@ privately to final dispatch; preliminary route selection does not inject Go-only
Devin CLI credential path composition in `src/oauth/devin-cli.ts` follows the selected platform: Windows uses Win32 APPDATA paths, other platforms use POSIX XDG-data paths. The explicit absolute override remains verbatim; credential parsing and login behavior are unchanged.

Native Chat applies qualifying effort ceilings independently of model pins; pin selection precedes the cap and only pins or cap rewrites enter wire mapping. The [catalog effort contract](../catalog.md#ultra-reasoning-level) records the V1/compaction exemptions and caller-preservation boundary.

The local Devin ACP adapter owns [SWE-2 effort selection](../adapters/registry.md#devin-acp-model-selection); shared routing and other adapter effort policies do not apply that model-id rewrite.
2 changes: 2 additions & 0 deletions structure/transports/responses.md
Original file line number Diff line number Diff line change
Expand Up @@ -524,3 +524,5 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi
Claude replay carries [Go conversation affinity](../data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch)
privately to final dispatch; preliminary route selection does not inject Go-only headers.
Native Chat applies qualifying effort ceilings independently of model pins; pin selection precedes the cap and only pins or cap rewrites enter wire mapping. The [catalog effort contract](../catalog.md#ultra-reasoning-level) records the V1/compaction exemptions and caller-preservation boundary.

The local Devin ACP adapter owns [SWE-2 effort selection](../adapters/registry.md#devin-acp-model-selection); shared routing and other adapter effort policies do not apply that model-id rewrite.
49 changes: 44 additions & 5 deletions tests/providers/devin-cli-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
import { DEVIN_CLI_BIN_ENV, resolveDevinCliBinary } from "../../src/adapters/devin-cli/binary";
import { createDevinCliAdapter } from "../../src/adapters/devin-cli/adapter";
import { PROVIDER_REGISTRY } from "../../src/providers/registry";
import { DEVIN_CLI_MODELS, DEVIN_CLI_MODEL_CONTEXT_WINDOWS, DEVIN_CLI_DEFAULT_MODEL } from "../../src/adapters/devin-cli/models";
import { DEVIN_CLI_MODELS, DEVIN_CLI_MODEL_CONTEXT_WINDOWS, DEVIN_CLI_DEFAULT_MODEL, resolveDevinCliModel } from "../../src/adapters/devin-cli/models";
import { DEVIN_MODEL_CONTEXT_WINDOWS } from "../../src/adapters/devin/live-models";
import { formatProviderDisplayName, providerIconSrc } from "../../gui/src/provider-icons";
import type { AdapterEvent, OcxParsedRequest } from "../../src/types";
Expand Down Expand Up @@ -269,16 +269,17 @@ describe("devin-cli runTurn", () => {
options: {},
} as unknown as OcxParsedRequest;

async function run(script: (stdout: PassThrough, child: EventEmitter) => void) {
async function run(script: (stdout: PassThrough, child: EventEmitter) => void, request = parsed) {
const { child, stdout, stdinWrites } = fakeChild();
const events: AdapterEvent[] = [];
let args: string[] = [];
process.env[DEVIN_CLI_BIN_ENV] = "/fake/devin";
const adapter = createDevinCliAdapter({ adapter: "devin-cli", baseUrl: "https://cli.devin.ai" }, {
spawn: () => { queueMicrotask(() => script(stdout, child as unknown as EventEmitter)); return child; },
spawn: (_binary, spawnArgs) => { args = spawnArgs; queueMicrotask(() => script(stdout, child as unknown as EventEmitter)); return child; },
});
await adapter.runTurn!(parsed, {} as never, (e) => events.push(e));
await adapter.runTurn!(request, {} as never, (e) => events.push(e));
delete process.env[DEVIN_CLI_BIN_ENV];
return { events, stdinWrites };
return { events, stdinWrites, args };
}

test("a complete handshake produces exactly one terminal event, carrying usage", async () => {
Expand All @@ -304,6 +305,30 @@ describe("devin-cli runTurn", () => {
expect(stdinWrites.join("")).toContain('"method":"session/prompt"');
});

test.each(["medium", "high", "max"])("selects SWE-2 %s before starting the prompt", async (effort) => {
const model = `swe-2-${effort}`;
const { events, stdinWrites, args } = await run((stdout) => {
stdout.write(JSON.stringify({ id: 1, result: {} }) + "\n");
stdout.write(JSON.stringify({ id: 2, result: { sessionId: "s1", configOptions: [{ id: "model", currentValue: model }] } }) + "\n");
stdout.write(JSON.stringify({ id: 3, result: { stopReason: "end_turn" } }) + "\n");
}, { ...parsed, modelId: "devin-acp/swe-2-high", options: { reasoning: effort } });
expect(args).toEqual(["acp", "--model", model]);
expect(JSON.parse(stdinWrites[1]!).params).not.toHaveProperty("model");
expect(events.at(-1)?.type).toBe("done");
});

test.each(["configOptions", "models"])("rejects a mismatched %s acknowledgement before sending user content", async (shape) => {
const { events, stdinWrites } = await run((stdout) => {
stdout.write(JSON.stringify({ id: 1, result: {} }) + "\n");
const metadata = shape === "models"
? { models: { currentModelId: "swe-2-high" } }
: { configOptions: [{ id: "model", currentValue: "swe-2-high" }] };
stdout.write(JSON.stringify({ id: 2, result: { sessionId: "s1", ...metadata } }) + "\n");
}, { ...parsed, options: { reasoning: "medium" } });
expect(events).toEqual([{ type: "error", message: "Devin CLI selected swe-2-high instead of requested swe-2-medium." }]);
expect(stdinWrites.some((frame) => JSON.parse(frame).method === "session/prompt")).toBe(false);
});

test("a crash before the prompt reply is an error, not an empty success", async () => {
const { events } = await run((stdout, child) => {
stdout.write('{"jsonrpc":"2.0","id":1,"result":{}}\n');
Expand Down Expand Up @@ -340,3 +365,17 @@ describe("devin-cli runTurn", () => {
expect((events[0] as { message: string }).message).toMatch(/malformed ACP frame/);
});
});

describe("Devin CLI effort selection", () => {
test("explicit effort overrides a SWE-2 suffix and unrelated models pass through", () => {
expect(resolveDevinCliModel("devin-acp/swe-2-max", "medium")).toBe("swe-2-medium");
expect(resolveDevinCliModel("swe-2-high", "xhigh")).toBe("swe-2-max");
expect(resolveDevinCliModel("swe-2-high", "ultra")).toBe("swe-2-max");
expect(resolveDevinCliModel("swe-2", "low")).toBe("swe-2-medium");
expect(resolveDevinCliModel("swe-2-max")).toBe("swe-2-max");
expect(resolveDevinCliModel("swe-2")).toBe("swe-2");
expect(resolveDevinCliModel("custom/claude-opus-5-medium", "high")).toBe("claude-opus-5-medium");
expect(resolveDevinCliModel("swe-20", "medium")).toBe("swe-20");
expect(resolveDevinCliModel("swe-2-high", "future-effort")).toBe("swe-2-high");
});
});
Loading