diff --git a/CHANGELOG.md b/CHANGELOG.md index 25f72f1f4f9..61c2cef5bb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/commands/doctor-legacy-config.migrations.test.ts b/src/commands/doctor-legacy-config.migrations.test.ts index f4814244179..a0f103af250 100644 --- a/src/commands/doctor-legacy-config.migrations.test.ts +++ b/src/commands/doctor-legacy-config.migrations.test.ts @@ -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: { diff --git a/src/commands/doctor/shared/legacy-config-core-normalizers.ts b/src/commands/doctor/shared/legacy-config-core-normalizers.ts index 33d30a7bf0f..e24e2abb464 100644 --- a/src/commands/doctor/shared/legacy-config-core-normalizers.ts +++ b/src/commands/doctor/shared/legacy-config-core-normalizers.ts @@ -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; changed: boolean } { let changed = false; const next: Record = { ...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`, diff --git a/src/commands/doctor/shared/legacy-config-migrate.test.ts b/src/commands/doctor/shared/legacy-config-migrate.test.ts index af4182dd477..716b7860b17 100644 --- a/src/commands/doctor/shared/legacy-config-migrate.test.ts +++ b/src/commands/doctor/shared/legacy-config-migrate.test.ts @@ -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: { diff --git a/src/commands/doctor/shared/legacy-config-migrations.runtime.agents.ts b/src/commands/doctor/shared/legacy-config-migrations.runtime.agents.ts index c3b085cb18a..9920eb28de2 100644 --- a/src/commands/doctor/shared/legacy-config-migrations.runtime.agents.ts +++ b/src/commands/doctor/shared/legacy-config-migrations.runtime.agents.ts @@ -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; +} { + const base = getRecord(entry) ? { ...(entry as Record) } : {}; + 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, + 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 = 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,