From cc522878b3749c3373f4ab263188bc3ec9e65335 Mon Sep 17 00:00:00 2001 From: Bobby Bones <159389674+saariuslystoned@users.noreply.github.com> Date: Sun, 20 Sep 2026 18:09:54 -0400 Subject: [PATCH] fix: ACP model requests fail for aliases and provider references (#152850) Resolve ACP model references against the connected harness catalog before useful turns, preserving exact model IDs, accepted settings, and conversation history. Refs #124843. Reviewed-by: @shakkernerd Co-authored-by: Shakker <165377636+shakkernerd@users.noreply.github.com> --- config/assertion-safety-baseline.txt | 2 +- docs/tools/acp-agents/controls.md | 6 + extensions/acpx/package.json | 2 +- extensions/acpx/src/model-ref.ts | 64 +++++ .../runtime-advertised-model.process.test.ts | 257 ++++++++++++++++++ extensions/acpx/src/runtime.reset.test.ts | 48 +++- extensions/acpx/src/runtime.test.ts | 7 +- extensions/acpx/src/runtime.ts | 53 +--- .../test/fixtures/cursor-model-catalog.json | 20 ++ .../test/fixtures/model-catalog-agent.mjs | 93 +++++++ package.json | 2 +- pnpm-lock.yaml | 14 +- pnpm-workspace.yaml | 4 +- 13 files changed, 518 insertions(+), 54 deletions(-) create mode 100644 extensions/acpx/src/model-ref.ts create mode 100644 extensions/acpx/src/runtime-advertised-model.process.test.ts create mode 100644 extensions/acpx/test/fixtures/cursor-model-catalog.json create mode 100644 extensions/acpx/test/fixtures/model-catalog-agent.mjs diff --git a/config/assertion-safety-baseline.txt b/config/assertion-safety-baseline.txt index 32920a04bd85..b82748d65773 100644 --- a/config/assertion-safety-baseline.txt +++ b/config/assertion-safety-baseline.txt @@ -5,7 +5,7 @@ extensions/acpx/src/codex-auth-bridge.ts 7 extensions/acpx/src/codex-trust-config.ts 1 extensions/acpx/src/pi-session-catalog-runtime.ts 1 extensions/acpx/src/process-lease.ts 3 -extensions/acpx/src/runtime.ts 4 +extensions/acpx/src/runtime.ts 2 extensions/acpx/src/service.ts 1 extensions/acpx/src/state.ts 1 extensions/active-memory/config.ts 2 diff --git a/docs/tools/acp-agents/controls.md b/docs/tools/acp-agents/controls.md index 44f6d2fd344d..c454c11d1664 100644 --- a/docs/tools/acp-agents/controls.md +++ b/docs/tools/acp-agents/controls.md @@ -95,6 +95,12 @@ thinking level in sync with that response. A model switch may lower the level or remove thinking support; subsequent turns and reconnects use the accepted selection instead of replaying the old level. Backend defaults do not become new session overrides, and the model reference keeps its OpenClaw provider prefix. + +With Cursor, model requests can use exact advertised IDs, selectors with one +advertised variant, or OpenClaw `provider/model` references to either. Unknown or +ambiguous requests fail visibly. An exact advertised ID that contains `/` takes +precedence over interpreting it as a provider-qualified reference. + Model overrides are validated before prompt submission, including after reconnect. Unsupported inherited defaults dropped during new session initialization are not saved as overrides. diff --git a/extensions/acpx/package.json b/extensions/acpx/package.json index f7a8a44917f3..f5d728fe50f4 100644 --- a/extensions/acpx/package.json +++ b/extensions/acpx/package.json @@ -10,7 +10,7 @@ "dependencies": { "@agentclientprotocol/claude-agent-acp": "0.76.0", "@agentclientprotocol/codex-acp": "1.10.0", - "acpx": "0.17.1", + "acpx": "0.18.0", "smol-toml": "1.8.0", "zod": "4.6.4" }, diff --git a/extensions/acpx/src/model-ref.ts b/extensions/acpx/src/model-ref.ts new file mode 100644 index 000000000000..3b393a085cbb --- /dev/null +++ b/extensions/acpx/src/model-ref.ts @@ -0,0 +1,64 @@ +import { + isRequestedModelUnsupportedError, + type AcpxRuntime as BaseAcpxRuntime, +} from "acpx/runtime"; +import type { AcpRuntime } from "../runtime-api.js"; + +type DelegateEnsureInput = Parameters[0]; +type EnsureInput = Parameters[0] & + Pick; + +export function withAcpxSessionOptions(input: EnsureInput): DelegateEnsureInput { + const model = input.model?.trim() || input.sessionOptions?.model; + const sessionOptions = model ? { ...input.sessionOptions, model } : input.sessionOptions; + const { modelExplicit: _modelExplicit, thinkingExplicit: _thinkingExplicit, ...rest } = input; + return { ...rest, ...(sessionOptions ? { sessionOptions } : {}) }; +} + +// Try the exact harness id first. ACPX owns live catalog validation and vendor aliases; +// only its typed rejection allows retrying an OpenClaw provider/model reference. +export async function withOpenClawModelRef( + requested: string, + apply: (model: string) => Promise, +): Promise { + try { + return await apply(requested); + } catch (error) { + const model = requested.trim(); + const slash = model.indexOf("/"); + if ( + !isRequestedModelUnsupportedError(error) || + error.reason !== "unadvertised-model" || + error.ambiguous === true || + slash <= 0 || + slash === model.length - 1 + ) { + throw error; + } + return await apply(model.slice(slash + 1)); + } +} + +export async function ensureSessionWithModelRef( + ensureSession: BaseAcpxRuntime["ensureSession"], + input: EnsureInput, +): Promise>> { + const ensure = (model: string | undefined) => + ensureSession(withAcpxSessionOptions({ ...input, model })); + const requested = input.model?.trim(); + try { + return requested + ? await withOpenClawModelRef(requested, ensure) + : await ensureSession(withAcpxSessionOptions(input)); + } catch (error) { + if ( + !requested || + input.modelExplicit || + !isRequestedModelUnsupportedError(error) || + error.reason !== "missing-capability" + ) { + throw error; + } + return { ...(await ensure(undefined)), appliedModel: { kind: "dropped" } }; + } +} diff --git a/extensions/acpx/src/runtime-advertised-model.process.test.ts b/extensions/acpx/src/runtime-advertised-model.process.test.ts new file mode 100644 index 000000000000..86e41de67b39 --- /dev/null +++ b/extensions/acpx/src/runtime-advertised-model.process.test.ts @@ -0,0 +1,257 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { createAgentRegistry, createFileSessionStore, type AcpProcessStarted } from "acpx/runtime"; +import { withOpenClawTestState } from "openclaw/plugin-sdk/test-state"; +import { expect, it } from "vitest"; +import { AcpxRuntime } from "./runtime.js"; + +const script = fileURLToPath(new URL("../test/fixtures/model-catalog-agent.mjs", import.meta.url)); + +function isAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +async function withRuntime( + run: ( + runtime: AcpxRuntime, + spawned: AcpProcessStarted[], + restart: () => AcpxRuntime, + ) => Promise, + cursor = true, +) { + await withOpenClawTestState({ label: "acpx-advertised-model" }, async (state) => { + let executable = process.execPath; + if (cursor) { + executable = path.join( + state.root, + process.platform === "win32" ? "cursor-agent.exe" : "cursor-agent", + ); + if (process.platform === "win32") { + await fs.copyFile(process.execPath, executable); + } else { + // Preserve Node's executable-relative libraries while exercising ACPX's Cursor contract. + await fs.symlink(process.execPath, executable); + } + } + const spawned: AcpProcessStarted[] = []; + const runtimes: AcpxRuntime[] = []; + const create = () => { + const created = new AcpxRuntime({ + cwd: state.root, + sessionStore: createFileSessionStore({ stateDir: state.root }), + agentRegistry: createAgentRegistry({ overrides: { catalog: [executable, script] } }), + permissionMode: "deny-all", + timeoutMs: 10_000, + processLifecycle: { onSpawned: (started) => void spawned.push(started) }, + }); + runtimes.push(created); + return created; + }; + try { + await run(create(), spawned, create); + } finally { + for (const runtime of runtimes) { + await runtime.shutdown(); + } + } + }); +} + +async function prompt( + runtime: AcpxRuntime, + handle: Awaited>, + text: string, +) { + const turn = runtime.startTurn({ handle, text, mode: "prompt", requestId: text }); + const chunks: string[] = []; + for await (const event of turn.events) { + if (event.type === "text_delta") { + chunks.push(event.text); + } + } + expect(await turn.result).toMatchObject({ status: "completed" }); + return JSON.parse(chunks.join("")); +} + +it("selects the unique advertised id for an explicit model ref", async () => { + await withRuntime(async (runtime) => { + const handle = await runtime.ensureSession({ + sessionKey: "agent:main:acp:catalog-explicit", + agent: "catalog", + mode: "persistent", + model: "cursor/composer-2.5", + modelExplicit: true, + }); + // Session metadata keeps the OpenClaw ref; the harness reports the advertised id. + expect(handle.appliedModel).toBeUndefined(); + expect(await runtime.getStatus({ handle })).toMatchObject({ + models: { currentModelId: "composer-2.5[fast=true]" }, + }); + // Replay the original OpenClaw ref before the first useful turn. + await runtime.setConfigOption({ handle, key: "model", value: "cursor/composer-2.5" }); + expect(await prompt(runtime, handle, "first")).toMatchObject({ + model: "composer-2.5[fast=true]", + }); + const accepted = await runtime.setConfigOption({ + handle, + key: "model", + value: "xai/grok-4.5", + }); + expect(accepted).toMatchObject({ + configOptions: expect.arrayContaining([ + expect.objectContaining({ + id: "model", + currentValue: "grok-4.5[effort=high,fast=true]", + }), + ]), + }); + expect(await prompt(runtime, handle, "changed")).toMatchObject({ + model: "grok-4.5[effort=high,fast=true]", + }); + }); +}); + +it("matches a derived provider-prefixed model ref to the advertised id", async () => { + await withRuntime(async (runtime) => { + const handle = await runtime.ensureSession({ + sessionKey: "agent:main:acp:catalog-derived", + agent: "catalog", + mode: "persistent", + model: "xai/grok-4.5", + }); + expect(handle.appliedModel).toBeUndefined(); + expect(await runtime.getStatus({ handle })).toMatchObject({ + models: { currentModelId: "grok-4.5[effort=high,fast=true]" }, + }); + }); +}); + +it.each(["gpt-5.5", "missing-model", "vendor/ambiguous"])( + "rejects an ambiguous or unknown model %s", + async (model) => { + await withRuntime(async (runtime) => { + await expect( + runtime.ensureSession({ + sessionKey: "agent:main:acp:catalog-missing", + agent: "catalog", + mode: "persistent", + model, + modelExplicit: true, + }), + ).rejects.toMatchObject({ code: "ACP_MODEL_UNSUPPORTED" }); + }); + }, +); + +it("rejects a model that becomes ambiguous on reconnect and preserves the conversation", async () => { + await withRuntime(async (runtime, _spawned, restart) => { + const input = { + sessionKey: "agent:main:acp:catalog-reconnect", + agent: "catalog", + mode: "persistent" as const, + }; + const original = await runtime.ensureSession(input); + expect(await prompt(runtime, original, "before")).toMatchObject({ history: ["before"] }); + await runtime.shutdown(); + const resumed = restart(); + const handle = await resumed.ensureSession(input); + expect(handle.backendSessionId).toBe(original.backendSessionId); + + // session/new has one variant; session/load advertises a second on the same conversation. + await expect( + resumed.setConfigOption({ handle, key: "model", value: "composer-2.5" }), + ).rejects.toMatchObject({ code: "ACP_MODEL_UNSUPPORTED" }); + await resumed.setConfigOption({ handle, key: "model", value: "composer-2.5[fast=false]" }); + expect(await prompt(resumed, handle, "after")).toEqual({ + model: "composer-2.5[fast=false]", + history: ["before", "after"], + }); + }); +}); + +it("releases rejected startup attempts when an advertised model cannot be selected", async () => { + await withRuntime(async (runtime, spawned) => { + await expect( + runtime.ensureSession({ + sessionKey: "agent:main:acp:catalog-locked", + agent: "catalog", + mode: "persistent", + model: "provider/locked-1", + modelExplicit: true, + }), + ).rejects.toThrow(/not available on this plan/); + // The original reference and stripped retry both failed before session publication. + expect(spawned).toHaveLength(2); + await expect.poll(() => spawned.filter(({ pid }) => isAlive(pid))).toEqual([]); + }); +}); + +it("keeps rejecting a failed selection on a same-key retry or after restart", async () => { + await withRuntime(async (runtime, _spawned, restart) => { + const input = { + sessionKey: "agent:main:acp:catalog-retry", + agent: "catalog", + mode: "persistent" as const, + model: "provider/locked-1", + modelExplicit: true, + }; + await expect(runtime.ensureSession(input)).rejects.toThrow(/not available on this plan/); + // A published incomplete record would make these succeed without the requested model. + await expect(runtime.ensureSession(input)).rejects.toThrow(/not available on this plan/); + await runtime.shutdown(); + await expect(restart().ensureSession(input)).rejects.toThrow(/not available on this plan/); + }); +}); + +it("preserves an advertised slash id before considering a provider-stripped reference", async () => { + await withRuntime(async (runtime) => { + const handle = await runtime.ensureSession({ + sessionKey: "agent:main:acp:catalog-native-id", + agent: "catalog", + mode: "persistent", + model: "vendor/native-model", + modelExplicit: true, + }); + expect(await prompt(runtime, handle, "startup")).toMatchObject({ + model: "vendor/native-model", + }); + await runtime.setConfigOption({ handle, key: "model", value: "native-model" }); + await runtime.setConfigOption({ handle, key: "model", value: "vendor/native-model" }); + expect(await prompt(runtime, handle, "control")).toMatchObject({ + model: "vendor/native-model", + }); + }); +}); + +it("does not interpret another harness's opaque ids as Cursor aliases", async () => { + await withRuntime(async (runtime) => { + await expect( + runtime.ensureSession({ + sessionKey: "agent:main:acp:generic-alias", + agent: "catalog", + mode: "persistent", + model: "composer-2.5", + modelExplicit: true, + }), + ).rejects.toMatchObject({ reason: "unadvertised-model" }); + const handle = await runtime.ensureSession({ + sessionKey: "agent:main:acp:generic-exact", + agent: "catalog", + mode: "persistent", + model: "composer-2.5[fast=true]", + modelExplicit: true, + }); + await expect( + runtime.setConfigOption({ handle, key: "model", value: "grok-4.5" }), + ).rejects.toMatchObject({ reason: "unadvertised-model" }); + expect(await prompt(runtime, handle, "exact")).toMatchObject({ + model: "composer-2.5[fast=true]", + }); + }, false); +}); diff --git a/extensions/acpx/src/runtime.reset.test.ts b/extensions/acpx/src/runtime.reset.test.ts index 62b18d3a05b3..dc141c936f16 100644 --- a/extensions/acpx/src/runtime.reset.test.ts +++ b/extensions/acpx/src/runtime.reset.test.ts @@ -1,4 +1,4 @@ -import { AcpxRuntime as BaseAcpxRuntime } from "acpx/runtime"; +import { AcpxRuntime as BaseAcpxRuntime, RequestedModelUnsupportedError } from "acpx/runtime"; import type { AcpSessionStore } from "acpx/runtime"; import { createDeferred } from "openclaw/plugin-sdk/extension-shared"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -78,6 +78,52 @@ describe("AcpxRuntime reset generation custody", () => { expect(persisted).toMatchObject({ acpSessionId: "fresh-session" }); }); + it.each(["startup", "control"])( + "does not retry a model reference after reset during %s rejection", + async (operation) => { + const sessionKey = "agent:catalog:acp:model-reset"; + const { runtime, delegate } = makeRuntime({ + load: vi.fn(async () => undefined), + save: vi.fn(async () => {}), + }); + const started = createDeferred(); + const release = createDeferred(); + const rejectModel = async (): Promise => { + started.resolve(); + await release.promise; + throw new RequestedModelUnsupportedError("Model is not advertised", "unadvertised-model"); + }; + const ensure = vi.spyOn(delegate, "ensureSession").mockImplementation(rejectModel); + const control = vi.spyOn(delegate, "setConfigOption").mockImplementation(rejectModel); + const pending = + operation === "startup" + ? runtime.ensureSession({ + sessionKey, + agent: "catalog", + mode: "persistent", + model: "provider/model", + modelExplicit: true, + }) + : runtime.setConfigOption({ + handle: { sessionKey, backend: "acpx", runtimeSessionName: sessionKey }, + key: "model", + value: "provider/model", + }); + const rejected = expect(pending).rejects.toThrow("superseded by reset"); + try { + await started.promise; + await runtime.prepareFreshSession({ sessionKey }); + release.resolve(); + await rejected; + expect(operation === "startup" ? ensure : control).toHaveBeenCalledOnce(); + } finally { + release.resolve(); + await Promise.allSettled([pending]); + await runtime.shutdown(); + } + }, + ); + it("keeps a fresh generation owned when an older discard close finishes late", async () => { const sessionKey = "agent:codex:acp:binding:test"; const oldRecord: Record = { diff --git a/extensions/acpx/src/runtime.test.ts b/extensions/acpx/src/runtime.test.ts index 2e352a6bba32..360e53cfc953 100644 --- a/extensions/acpx/src/runtime.test.ts +++ b/extensions/acpx/src/runtime.test.ts @@ -1107,7 +1107,7 @@ describe("AcpxRuntime fresh reset wrapper", () => { }, ); - it("does not retry when ACPX rejects an explicitly unsupported model id", async () => { + it("keeps rejecting an unsupported model after retrying its OpenClaw reference", async () => { const baseStore: TestSessionStore = makeEmptySessionStore(); const { runtime, delegate } = makeRuntime(baseStore, { agentRegistry: { @@ -1117,7 +1117,7 @@ describe("AcpxRuntime fresh reset wrapper", () => { }); const ensure = vi .spyOn(delegate, "ensureSession") - .mockRejectedValueOnce( + .mockRejectedValue( new RequestedModelUnsupportedError( "Cannot apply --model: the ACP agent did not advertise that model", "unadvertised-model", @@ -1132,7 +1132,8 @@ describe("AcpxRuntime fresh reset wrapper", () => { model: "unknown/model", }), ).rejects.toThrow("did not advertise that model"); - expect(ensure).toHaveBeenCalledTimes(1); + // Both attempts carry a model; failed startup never publishes a model-less session. + expect(ensure.mock.calls.map(([input]) => input.model)).toEqual(["unknown/model", "model"]); }); it("does not retry an unrelated error with similar wording", async () => { diff --git a/extensions/acpx/src/runtime.ts b/extensions/acpx/src/runtime.ts index f82ac9b9e8d4..2070e974a2fb 100644 --- a/extensions/acpx/src/runtime.ts +++ b/extensions/acpx/src/runtime.ts @@ -10,7 +10,6 @@ import { isDeepStrictEqual } from "node:util"; import { AcpxRuntime as BaseAcpxRuntime, decodeAcpxRuntimeHandleState, - isRequestedModelUnsupportedError, type AcpAgentRegistry, type AcpRuntimeDoctorReport, type AcpRuntimeEvent, @@ -19,7 +18,6 @@ import { type AcpProcessStarted, type AcpRuntimeStatus, type AcpRuntimeTurnResult, - type SessionAgentOptions, } from "acpx/runtime"; import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue"; import { redactSensitiveText } from "openclaw/plugin-sdk/security-runtime"; @@ -40,6 +38,11 @@ import { splitCommandParts, type AcpxAgentCommand, } from "./command-line.js"; +import { + ensureSessionWithModelRef, + withAcpxSessionOptions, + withOpenClawModelRef, +} from "./model-ref.js"; import { ACPX_PROBE_LEASE_SESSION_KEY, hashAcpxProcessCommand, @@ -99,7 +102,6 @@ type OpenClawRuntimeEnsureInput = Parameters[0] & { type OpenClawRuntimeHandle = Awaited> & { bridgeSession?: BridgeSession | null; }; -type AcpxDelegateEnsureInput = Parameters[0]; type AcpxMcpServers = Extract, unknown[]>; type AcpxMcpServer = AcpxMcpServers[number]; @@ -316,40 +318,6 @@ function normalizeClaudeAcpModelOverride(rawModel: string | undefined): string | return raw.slice(prefix[0].length).trim() || undefined; } -function withAcpxSessionOptions(input: OpenClawRuntimeEnsureInput): AcpxDelegateEnsureInput { - const existingOptions = (input as { sessionOptions?: SessionAgentOptions }).sessionOptions; - const model = input.model?.trim() || existingOptions?.model; - const sessionOptions = model ? { ...existingOptions, model } : existingOptions; - const { modelExplicit: _modelExplicit, thinkingExplicit: _thinkingExplicit, ...rest } = input; - return { - ...rest, - ...(sessionOptions ? { sessionOptions } : {}), - } as AcpxDelegateEnsureInput; -} - -function isAcpModelCapabilityMissingError(error: unknown): boolean { - return isRequestedModelUnsupportedError(error) && error.reason === "missing-capability"; -} - -// Only inherited defaults may be dropped when a harness has no model control; -// explicit selections and invalid model ids must remain visible failures. -async function ensureDelegateSessionWithModelFallback( - delegate: BaseAcpxRuntime, - input: OpenClawRuntimeEnsureInput, -): Promise { - try { - return await delegate.ensureSession(withAcpxSessionOptions(input)); - } catch (error) { - if (input.modelExplicit || !input.model || !isAcpModelCapabilityMissingError(error)) { - throw error; - } - return { - ...(await delegate.ensureSession(withAcpxSessionOptions({ ...input, model: undefined }))), - appliedModel: { kind: "dropped" }, - }; - } -} - function appendCodexAcpConfigOverrides( command: AcpxAgentCommand, override: CodexAcpModelOverride, @@ -1037,7 +1005,10 @@ export class AcpxRuntime implements CompleteAcpRuntime { run: () => codexModelOverride ? delegate.ensureSession(withAcpxSessionOptions(ensureInput)) - : ensureDelegateSessionWithModelFallback(delegate, ensureInput), + : ensureSessionWithModelRef((request) => { + this.generationRegistry.assertCurrentGeneration(generation); + return delegate.ensureSession(request); + }, ensureInput), }), }); return { @@ -1262,6 +1233,12 @@ export class AcpxRuntime implements CompleteAcpRuntime { value: normalizeClaudeAcpModelOverride(input.value) ?? input.value, }); } + if (key === "model") { + return await withOpenClawModelRef(input.value, (value) => { + this.generationRegistry.assertCurrentGeneration(snapshot.generation); + return delegate.setConfigOption({ ...input, value }); + }); + } return await delegate.setConfigOption(input); } diff --git a/extensions/acpx/test/fixtures/cursor-model-catalog.json b/extensions/acpx/test/fixtures/cursor-model-catalog.json new file mode 100644 index 000000000000..8bac91217d59 --- /dev/null +++ b/extensions/acpx/test/fixtures/cursor-model-catalog.json @@ -0,0 +1,20 @@ +{ + "currentModelId": "grok-4.6[effort=high,fast=true]", + "availableModelIds": [ + "default[]", + "grok-4.6[effort=high,fast=true]", + "composer-2.5[fast=true]", + "grok-4.5[effort=high,fast=true]", + "gpt-5.5[context=272k,reasoning=medium,fast=false]", + "gpt-5.5[context=272k,reasoning=high,fast=false]", + "locked-1[tier=pro]", + "vendor/native-model", + "native-model", + "vendor/ambiguous[fast=true]", + "vendor/ambiguous[fast=false]", + "ambiguous[fast=true]" + ], + "unselectableModelIds": [ + "locked-1[tier=pro]" + ] +} diff --git a/extensions/acpx/test/fixtures/model-catalog-agent.mjs b/extensions/acpx/test/fixtures/model-catalog-agent.mjs new file mode 100644 index 000000000000..24a1bb2f3715 --- /dev/null +++ b/extensions/acpx/test/fixtures/model-catalog-agent.mjs @@ -0,0 +1,93 @@ +#!/usr/bin/env node +// Synthetic ACP peer shaped like Cursor's `agent acp`: it advertises only `mode` and a +// `category: "model"` select whose values are opaque parameterized IDs, and rejects any +// value it did not advertise with -32602, as Cursor does. +import { randomUUID } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { Readable, Writable } from "node:stream"; +import { + AgentSideConnection, + ndJsonStream, + PROTOCOL_VERSION, + RequestError, +} from "@agentclientprotocol/sdk"; + +const catalog = JSON.parse( + fs.readFileSync(new URL("./cursor-model-catalog.json", import.meta.url), "utf8"), +); +const sessions = new Map(); +let availableModelIds = catalog.availableModelIds; +const sessionPath = (sessionId) => path.join(process.cwd(), `catalog-${sessionId}.json`); +const save = (sessionId, state) => fs.writeFileSync(sessionPath(sessionId), JSON.stringify(state)); +const configOptions = (state) => [ + { + id: "mode", + name: "Mode", + category: "mode", + type: "select", + currentValue: "agent", + options: [{ value: "agent", name: "Agent" }], + }, + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: state.model, + options: availableModelIds.map((value) => ({ value, name: value })), + }, +]; +const connection = new AgentSideConnection( + (client) => ({ + async initialize() { + return { + protocolVersion: PROTOCOL_VERSION, + agentCapabilities: { loadSession: true }, + authMethods: [], + }; + }, + async newSession() { + const sessionId = randomUUID(); + const state = { model: catalog.currentModelId, history: [] }; + sessions.set(sessionId, state); + save(sessionId, state); + return { sessionId, configOptions: configOptions(state) }; + }, + async loadSession({ sessionId }) { + const state = JSON.parse(fs.readFileSync(sessionPath(sessionId), "utf8")); + sessions.set(sessionId, state); + availableModelIds = [...catalog.availableModelIds, "composer-2.5[fast=false]"]; + return { configOptions: configOptions(state) }; + }, + async setSessionConfigOption({ sessionId, configId, value }) { + const state = sessions.get(sessionId); + if (configId !== "model" || !availableModelIds.includes(value)) { + throw RequestError.invalidParams(undefined, `unsupported ${configId} value`); + } + if (catalog.unselectableModelIds.includes(value)) { + // Advertised but not selectable for this account (for example a plan limit). + throw RequestError.internalError(undefined, `${value} is not available on this plan`); + } + state.model = value; + save(sessionId, state); + return { configOptions: configOptions(state) }; + }, + async prompt({ sessionId, prompt }) { + const state = sessions.get(sessionId); + state.history.push(...prompt.filter((part) => part.type === "text").map((part) => part.text)); + save(sessionId, state); + await client.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: JSON.stringify(state) }, + }, + }); + return { stopReason: "end_turn" }; + }, + async cancel() {}, + }), + ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin)), +); +void connection; diff --git a/package.json b/package.json index aaca3d52059a..5dd2e72741bd 100644 --- a/package.json +++ b/package.json @@ -2299,7 +2299,7 @@ "@types/web-push": "3.6.4", "@types/ws": "8.18.1", "@vitest/coverage-v8": "5.0.0", - "acpx": "0.17.1", + "acpx": "0.18.0", "apache-arrow": "21.2.0", "baileys": "7.0.0-rc14", "esbuild": "0.28.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c4ce52a63d04..5dcfda9b110b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -495,8 +495,8 @@ importers: specifier: 5.0.0 version: 5.0.0(@vitest/browser@5.0.0)(vitest@5.0.0) acpx: - specifier: 0.17.1 - version: 0.17.1 + specifier: 0.18.0 + version: 0.18.0 apache-arrow: specifier: 21.2.0 version: 21.2.0 @@ -635,8 +635,8 @@ importers: specifier: 1.10.0 version: 1.10.0 acpx: - specifier: 0.17.1 - version: 0.17.1 + specifier: 0.18.0 + version: 0.18.0 smol-toml: specifier: 1.8.0 version: 1.8.0 @@ -6168,8 +6168,8 @@ packages: engines: {node: '>=0.4.0'} hasBin: true - acpx@0.17.1: - resolution: {integrity: sha512-VafoVDYO8xE/Pd/8hTwAR6eHoSJf3K119knfFfAjCVqGy8ilGtWoVRI8z7fm+Q1o+R1vKDo58MJoxDx6YIt1tQ==} + acpx@0.18.0: + resolution: {integrity: sha512-O5L2ldiSijytxsot9IIuViHCeZGNZpexlWKTD6za/flqrswotfl7RJ5Ch0NaU6ceSxFqAnLT/KVPwUF9KGVZlQ==} engines: {node: '>=22.13.0'} hasBin: true @@ -13560,7 +13560,7 @@ snapshots: acorn@8.18.0: {} - acpx@0.17.1: + acpx@0.18.0: dependencies: '@agentclientprotocol/sdk': 1.4.0(zod@4.6.4) '@openclaw/fs-safe': 0.12.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index aafed85e2efd..d35b1a157105 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -10,8 +10,8 @@ minimumReleaseAgeStrict: true # Trusted Codex runtimes are outside the dependency cooldown. minimumReleaseAgeExclude: - # Reviewed ACPX release; remove after 2026-09-27T04:05:14Z. - - acpx@0.17.1 + # Reviewed ACPX release; remove after 2026-09-27T20:56:00Z. + - acpx@0.18.0 # Published ACPX runtime dependencies; remove after 2026-09-22T12:38:43Z. - "@openclaw/fs-safe@0.12.0" - "@openclaw/fs-safe-darwin-arm64@0.12.0"