diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index af7e3055c0..d269b0a571 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -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. diff --git a/src/adapters/devin-cli/adapter.ts b/src/adapters/devin-cli/adapter.ts index f31bf77373..93c8f691a1 100644 --- a/src/adapters/devin-cli/adapter.ts +++ b/src/adapters/devin-cli/adapter.ts @@ -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; @@ -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((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 @@ -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; } diff --git a/src/adapters/devin-cli/models.ts b/src/adapters/devin-cli/models.ts index 5d22a049b0..e8b8cb4c62 100644 --- a/src/adapters/devin-cli/models.ts +++ b/src/adapters/devin-cli/models.ts @@ -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 @@ -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", @@ -44,7 +46,9 @@ export const DEVIN_CLI_MODELS = [ */ export const DEVIN_CLI_MODEL_CONTEXT_WINDOWS: Record = { "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, @@ -55,3 +59,15 @@ export const DEVIN_CLI_MODEL_CONTEXT_WINDOWS: Record = { "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 = { + 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; +} diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index db26a7cb4d..24ae392302 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -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. diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index ee84baebee..389647414b 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -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. diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index e0b87add2d..8b6fa626c0 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -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. diff --git a/structure/providers/cursor.md b/structure/providers/cursor.md index be42793e0b..d7bc80575c 100644 --- a/structure/providers/cursor.md +++ b/structure/providers/cursor.md @@ -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. diff --git a/structure/runtime.md b/structure/runtime.md index 89276a1839..71bc09d6c6 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -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. diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index ec75ff03d4..9abb8995fa 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -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. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 6e975af2ac..b0fc735e95 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -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. diff --git a/tests/providers/devin-cli-adapter.test.ts b/tests/providers/devin-cli-adapter.test.ts index 565cd08478..08896a73fc 100644 --- a/tests/providers/devin-cli-adapter.test.ts +++ b/tests/providers/devin-cli-adapter.test.ts @@ -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"; @@ -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 () => { @@ -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'); @@ -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"); + }); +});