mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 09:39:25 +00:00
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>
This commit is contained in:
parent
2c6626c618
commit
cc522878b3
13 changed files with 518 additions and 54 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
64
extensions/acpx/src/model-ref.ts
Normal file
64
extensions/acpx/src/model-ref.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import {
|
||||
isRequestedModelUnsupportedError,
|
||||
type AcpxRuntime as BaseAcpxRuntime,
|
||||
} from "acpx/runtime";
|
||||
import type { AcpRuntime } from "../runtime-api.js";
|
||||
|
||||
type DelegateEnsureInput = Parameters<BaseAcpxRuntime["ensureSession"]>[0];
|
||||
type EnsureInput = Parameters<AcpRuntime["ensureSession"]>[0] &
|
||||
Pick<DelegateEnsureInput, "sessionOptions">;
|
||||
|
||||
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<T>(
|
||||
requested: string,
|
||||
apply: (model: string) => Promise<T>,
|
||||
): Promise<T> {
|
||||
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<Awaited<ReturnType<AcpRuntime["ensureSession"]>>> {
|
||||
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" } };
|
||||
}
|
||||
}
|
||||
257
extensions/acpx/src/runtime-advertised-model.process.test.ts
Normal file
257
extensions/acpx/src/runtime-advertised-model.process.test.ts
Normal file
|
|
@ -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<void>,
|
||||
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<ReturnType<AcpxRuntime["ensureSession"]>>,
|
||||
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);
|
||||
});
|
||||
|
|
@ -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<void>();
|
||||
const release = createDeferred<void>();
|
||||
const rejectModel = async (): Promise<never> => {
|
||||
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<string, unknown> = {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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<AcpRuntime["ensureSession"]>[0] & {
|
|||
type OpenClawRuntimeHandle = Awaited<ReturnType<AcpRuntime["ensureSession"]>> & {
|
||||
bridgeSession?: BridgeSession | null;
|
||||
};
|
||||
type AcpxDelegateEnsureInput = Parameters<BaseAcpxRuntime["ensureSession"]>[0];
|
||||
type AcpxMcpServers = Extract<NonNullable<AcpRuntimeOptions["mcpServers"]>, 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<OpenClawRuntimeHandle> {
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
|
|||
20
extensions/acpx/test/fixtures/cursor-model-catalog.json
vendored
Normal file
20
extensions/acpx/test/fixtures/cursor-model-catalog.json
vendored
Normal file
|
|
@ -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]"
|
||||
]
|
||||
}
|
||||
93
extensions/acpx/test/fixtures/model-catalog-agent.mjs
vendored
Normal file
93
extensions/acpx/test/fixtures/model-catalog-agent.mjs
vendored
Normal file
|
|
@ -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;
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
14
pnpm-lock.yaml
generated
14
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue