fix(doctor): preserve legacy Claude CLI runtime intent

This commit is contained in:
Peter Steinberger
2026-05-18 15:58:55 +01:00
committed by GitHub
parent ae29d14abf
commit cce00498cd
5 changed files with 329 additions and 0 deletions

View File

@@ -48,6 +48,7 @@ Docs: https://docs.openclaw.ai
- Codex app-server: complete OpenClaw dynamic tool diagnostics at the request boundary so successful, failed, timed out, aborted, and blocked tool calls do not leave active tool state behind. Fixes #83474. Thanks @rozmiarD.
- Gateway/config: keep config writes from failing on unrelated unresolved auth-profile SecretRefs while preserving live auth-profile runtime snapshots.
- Gateway/sessions: clear stored CLI provider resume bindings on non-subagent `/reset` so the next turn starts a fresh provider-side CLI conversation instead of resuming old context. (#83448) Thanks @jasonyliu.
- Doctor: preserve legacy whole-agent Claude CLI intent by moving matching Anthropic model selections to model-scoped runtime policy before removing stale runtime pins. Fixes #83491. Thanks @danielcrick.
- Discord/OpenAI: keep realtime Discord voice sessions hearing follow-up turns with OpenAI realtime and prebuffer assistant playback to avoid choppy starts. (#80505) Thanks @Solvely-Colin.
- Discord/subagents: route the initial reply from thread-bound delegated sessions into the bound Discord thread instead of the parent channel. Fixes #83170. (#83172) Thanks @100menotu001.
- Gateway/sessions: rotate failed agent sessions when their transcript file is missing instead of wedging per-channel lanes. Fixes #83488. (#83553) Thanks @LLagoon3.

View File

@@ -639,6 +639,60 @@ describe("normalizeCompatibilityConfigValues", () => {
});
});
it("preserves legacy whole-agent Claude CLI intent for canonical Anthropic defaults", () => {
const res = normalizeCompatibilityConfigValues({
agents: {
defaults: {
agentRuntime: { id: "claude-cli" },
model: {
primary: "anthropic/claude-opus-4-7",
fallbacks: ["anthropic/claude-sonnet-4-6", "openai/gpt-5.5"],
},
models: {
"anthropic/claude-opus-4-7": { alias: "Opus" },
},
},
},
} as unknown as OpenClawConfig);
expect(res.config.agents?.defaults?.agentRuntime).toEqual({ id: "claude-cli" });
expect(res.config.agents?.defaults?.models).toEqual({
"anthropic/claude-opus-4-7": {
alias: "Opus",
agentRuntime: { id: "claude-cli" },
},
"anthropic/claude-sonnet-4-6": {
agentRuntime: { id: "claude-cli" },
},
});
expect(res.changes).toContain(
"Moved agents.defaults.agentRuntime.id claude-cli to matching anthropic model runtime policy.",
);
});
it("does not overwrite explicit model runtime while preserving legacy whole-agent CLI intent", () => {
const res = normalizeCompatibilityConfigValues({
agents: {
list: [
{
id: "paige",
agentRuntime: { id: "claude-cli" },
model: "anthropic/claude-opus-4-7",
models: {
"anthropic/claude-opus-4-7": { agentRuntime: { id: "pi" } },
},
},
],
},
} as unknown as OpenClawConfig);
expect(res.config.agents?.list?.[0]?.agentRuntime).toEqual({ id: "claude-cli" });
expect(res.config.agents?.list?.[0]?.models).toEqual({
"anthropic/claude-opus-4-7": { agentRuntime: { id: "pi" } },
});
expect(res.changes).toStrictEqual([]);
});
it("migrates legacy Codex CLI primary refs to the Codex app-server route", () => {
const res = normalizeCompatibilityConfigValues({
agents: {

View File

@@ -1,5 +1,6 @@
import {
legacyRuntimeModelAliasRequiresRuntimePolicy,
listLegacyRuntimeModelProviderAliases,
migrateLegacyRuntimeModelRef,
} from "../../../agents/model-runtime-aliases.js";
import { normalizeProviderId } from "../../../agents/provider-id.js";
@@ -205,6 +206,32 @@ type SelectedRuntimeRef = {
const LEGACY_CODEX_CLI_RUNTIME_ID = "codex-cli";
const CODEX_APP_SERVER_RUNTIME_ID = "codex";
function resolveLegacyWholeAgentRuntimePolicy(raw: unknown):
| {
provider: string;
runtime: string;
requiresRuntimePolicy: boolean;
}
| undefined {
if (!isRecord(raw)) {
return undefined;
}
const runtime = normalizeOptionalLowercaseString(raw.id);
if (!runtime || runtime === "auto" || runtime === "pi") {
return undefined;
}
const alias = listLegacyRuntimeModelProviderAliases().find(
(entry) => entry.cli && normalizeProviderId(entry.runtime) === runtime,
);
return alias
? {
provider: alias.provider,
runtime: alias.runtime,
requiresRuntimePolicy: alias.requiresRuntimePolicy,
}
: undefined;
}
function migratedRuntimeRequiresPolicy(legacyProvider: string): boolean {
return legacyRuntimeModelAliasRequiresRuntimePolicy(legacyProvider);
}
@@ -417,6 +444,44 @@ function ensureSelectedModelRuntimePolicies(
return { value: next, changed };
}
function selectedCanonicalModelRefsForRuntimePolicy(
rawModel: unknown,
provider: string,
runtime: string,
requiresRuntimePolicy: boolean,
): SelectedRuntimeRef[] {
const refs: SelectedRuntimeRef[] = [];
const addRef = (rawRef: unknown) => {
if (typeof rawRef !== "string") {
return;
}
const trimmed = rawRef.trim();
const slash = trimmed.indexOf("/");
if (slash <= 0 || slash >= trimmed.length - 1) {
return;
}
if (normalizeProviderId(trimmed.slice(0, slash)) !== normalizeProviderId(provider)) {
return;
}
refs.push({ ref: trimmed, runtime, requiresRuntimePolicy });
};
if (typeof rawModel === "string") {
addRef(rawModel);
return refs;
}
if (!isRecord(rawModel)) {
return refs;
}
addRef(rawModel.primary);
if (Array.isArray(rawModel.fallbacks)) {
for (const fallback of rawModel.fallbacks) {
addRef(fallback);
}
}
return refs;
}
function normalizeLegacyCodexCliRuntimePinsInModels(
rawModels: unknown,
path: string,
@@ -451,6 +516,7 @@ function normalizeLegacyRuntimeAgentContainer(
): { value: Record<string, unknown>; changed: boolean } {
let changed = false;
const next: Record<string, unknown> = { ...raw };
const legacyWholeAgentRuntime = resolveLegacyWholeAgentRuntimePolicy(raw.agentRuntime);
const model = normalizeLegacyRuntimeAgentModelConfig(raw.model);
if (model.changed) {
@@ -484,6 +550,23 @@ function normalizeLegacyRuntimeAgentContainer(
}
}
if (legacyWholeAgentRuntime) {
const selectedRefs = selectedCanonicalModelRefsForRuntimePolicy(
next.model ?? raw.model,
legacyWholeAgentRuntime.provider,
legacyWholeAgentRuntime.runtime,
legacyWholeAgentRuntime.requiresRuntimePolicy,
);
const modelRuntimes = ensureSelectedModelRuntimePolicies(next.models, selectedRefs);
if (modelRuntimes.changed) {
next.models = modelRuntimes.value;
changed = true;
changes.push(
`Moved ${path}.agentRuntime.id ${legacyWholeAgentRuntime.runtime} to matching ${legacyWholeAgentRuntime.provider} model runtime policy.`,
);
}
}
const codexCliRuntimePins = normalizeLegacyCodexCliRuntimePinsInModels(
next.models,
`${path}.models`,

View File

@@ -602,6 +602,85 @@ describe("legacy migrate sandbox scope aliases", () => {
});
});
it("moves recoverable whole-agent Claude CLI runtime policy before removing stale pins", () => {
const res = migrateLegacyConfigForTest({
agents: {
defaults: {
agentRuntime: { id: "claude-cli" },
model: {
primary: "anthropic/claude-opus-4-7",
fallbacks: ["anthropic/claude-sonnet-4-6", "openai/gpt-5.5"],
},
models: {
"anthropic/claude-opus-4-7": { alias: "Opus" },
},
},
list: [
{
id: "paige",
agentRuntime: { id: "claude-cli" },
model: "anthropic/claude-sonnet-4-6",
},
],
},
});
expect(res.changes).toStrictEqual([
"Moved agents.defaults.agentRuntime.id claude-cli to matching anthropic model runtime policy.",
"Removed agents.defaults.agentRuntime; runtime is now provider/model scoped.",
"Moved agents.list.0.agentRuntime.id claude-cli to matching anthropic model runtime policy.",
"Removed agents.list.0.agentRuntime; runtime is now provider/model scoped.",
]);
expect(res.config?.agents?.defaults).toEqual({
model: {
primary: "anthropic/claude-opus-4-7",
fallbacks: ["anthropic/claude-sonnet-4-6", "openai/gpt-5.5"],
},
models: {
"anthropic/claude-opus-4-7": {
alias: "Opus",
agentRuntime: { id: "claude-cli" },
},
"anthropic/claude-sonnet-4-6": {
agentRuntime: { id: "claude-cli" },
},
},
});
expect(res.config?.agents?.list?.[0]).toEqual({
id: "paige",
model: "anthropic/claude-sonnet-4-6",
models: {
"anthropic/claude-sonnet-4-6": {
agentRuntime: { id: "claude-cli" },
},
},
});
});
it("does not overwrite explicit model runtime when removing stale whole-agent policy", () => {
const res = migrateLegacyConfigForTest({
agents: {
defaults: {
agentRuntime: { id: "claude-cli" },
model: "anthropic/claude-opus-4-7",
models: {
"anthropic/claude-opus-4-7": { agentRuntime: { id: "pi" } },
},
},
},
});
expect(res.changes).toStrictEqual([
"Removed agents.defaults.agentRuntime; runtime is now provider/model scoped.",
]);
expect(res.config?.agents?.defaults).toEqual({
model: "anthropic/claude-opus-4-7",
models: {
"anthropic/claude-opus-4-7": { agentRuntime: { id: "pi" } },
},
});
});
it("moves agents.defaults.sandbox.perSession into scope", () => {
const res = migrateLegacyConfigForTest({
agents: {

View File

@@ -1,3 +1,5 @@
import { listLegacyRuntimeModelProviderAliases } from "../../../agents/model-runtime-aliases.js";
import { normalizeProviderId } from "../../../agents/provider-id.js";
import {
defineLegacyConfigMigration,
ensureRecord,
@@ -27,6 +29,11 @@ const AGENT_HEARTBEAT_KEYS = new Set([
const CHANNEL_HEARTBEAT_KEYS = new Set(["showOk", "showAlerts", "useIndicator"]);
type LegacyAgentRuntimeIntent = {
provider: string;
runtime: string;
};
const MEMORY_SEARCH_RULE: LegacyConfigRule = {
path: ["memorySearch"],
message:
@@ -275,11 +282,116 @@ function removeLegacyAgentRuntimePolicy(
changes.push(`Removed ${pathLabel}.embeddedHarness; runtime is now provider/model scoped.`);
}
if (getRecord(container.agentRuntime) !== null) {
preserveLegacyWholeAgentRuntimePolicy(container, pathLabel, changes);
delete container.agentRuntime;
changes.push(`Removed ${pathLabel}.agentRuntime; runtime is now provider/model scoped.`);
}
}
function resolveLegacyAgentRuntimeIntent(raw: unknown): LegacyAgentRuntimeIntent | undefined {
const record = getRecord(raw);
if (!record) {
return undefined;
}
const runtime = typeof record.id === "string" ? record.id.trim().toLowerCase() : "";
if (!runtime || runtime === "auto" || runtime === "pi") {
return undefined;
}
const alias = listLegacyRuntimeModelProviderAliases().find(
(entry) => entry.cli && normalizeProviderId(entry.runtime) === runtime,
);
return alias ? { provider: alias.provider, runtime: alias.runtime } : undefined;
}
function selectedCanonicalModelRefsForRuntimePolicy(rawModel: unknown, provider: string): string[] {
const refs: string[] = [];
const addRef = (rawRef: unknown) => {
if (typeof rawRef !== "string") {
return;
}
const trimmed = rawRef.trim();
const slash = trimmed.indexOf("/");
if (slash <= 0 || slash >= trimmed.length - 1) {
return;
}
if (normalizeProviderId(trimmed.slice(0, slash)) !== normalizeProviderId(provider)) {
return;
}
refs.push(trimmed);
};
if (typeof rawModel === "string") {
addRef(rawModel);
return refs;
}
const model = getRecord(rawModel);
if (!model) {
return refs;
}
addRef(model.primary);
if (Array.isArray(model.fallbacks)) {
for (const fallback of model.fallbacks) {
addRef(fallback);
}
}
return refs;
}
function modelEntryWithRuntimePolicy(
entry: unknown,
runtime: string,
): {
changed: boolean;
entry: Record<string, unknown>;
} {
const base = getRecord(entry) ? { ...(entry as Record<string, unknown>) } : {};
const currentRuntime = getRecord(base.agentRuntime);
const currentRuntimeId =
typeof currentRuntime?.id === "string" ? currentRuntime.id.trim().toLowerCase() : "";
if (currentRuntimeId && currentRuntimeId !== "auto") {
return { changed: false, entry: base };
}
base.agentRuntime = {
...currentRuntime,
id: runtime,
};
return { changed: true, entry: base };
}
function preserveLegacyWholeAgentRuntimePolicy(
container: Record<string, unknown>,
pathLabel: string,
changes: string[],
): void {
const intent = resolveLegacyAgentRuntimeIntent(container.agentRuntime);
if (!intent) {
return;
}
const selectedRefs = selectedCanonicalModelRefsForRuntimePolicy(container.model, intent.provider);
if (selectedRefs.length === 0) {
return;
}
const currentModels = getRecord(container.models);
const nextModels: Record<string, unknown> = currentModels ? { ...currentModels } : {};
let changed = false;
for (const ref of selectedRefs) {
const updated = modelEntryWithRuntimePolicy(nextModels[ref], intent.runtime);
if (!updated.changed) {
continue;
}
nextModels[ref] = updated.entry;
changed = true;
}
if (!changed) {
return;
}
container.models = nextModels;
changes.push(
`Moved ${pathLabel}.agentRuntime.id ${intent.runtime} to matching ${intent.provider} model runtime policy.`,
);
}
function removeIgnoredAgentModelTimeout(
model: unknown,
pathLabel: string,