From 2bdbec8246d8d4b49fea3660c695b4c3a7ea55f2 Mon Sep 17 00:00:00 2001 From: brokemac79 Date: Thu, 7 May 2026 21:21:28 +0100 Subject: [PATCH] fix(status): treat CLI runtime aliases as selected route --- CHANGELOG.md | 1 + src/agents/model-runtime-aliases.ts | 22 ++++++++ src/auto-reply/fallback-state.test.ts | 34 +++++++++++ src/auto-reply/fallback-state.ts | 13 +++-- .../agent-runner.runreplyagent.e2e.test.ts | 56 +++++++++++++++++++ src/auto-reply/reply/agent-runner.ts | 6 +- src/auto-reply/status.test.ts | 37 ++++++++++++ src/status/fallback-notice-state.ts | 3 +- src/status/status-message.ts | 21 +++++-- src/status/status-text.ts | 15 ++++- 10 files changed, 194 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4baf9d59c50..6cc5a1f1976 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -286,6 +286,7 @@ Docs: https://docs.openclaw.ai - Installer: when npm installs `openclaw` outside the parent shell PATH, print follow-up commands with the resolved binary path instead of telling users to run `openclaw` from a shell that will report `command not found`. Fixes #72382. Thanks @jbob762. - Plugins/runtime: share MIME and JSON Schema helpers across bundled plugins while preserving canonical media MIME inference, browser URL wildcard semantics, migration home-path resolution, QA request-limit responses, and extensionless text file previews. - Agents/memory flush: persist the pre-increment compaction counter after flush-triggered compaction so consecutive eligible compaction cycles run memoryFlush instead of alternating. Fixes #12590. Refs #12760, #26145, and #46513. Thanks @Kaspre, @lailoo, @drvoss, @Br1an67, and @dial481. +- Status: treat CLI runtime aliases such as `claude-cli/` as the canonical selected provider route in `/status`, avoiding spurious fallback/unknown-auth display and preserving fresh context usage from CLI usage snapshots. Fixes #79015. Thanks @ItsThierry. - Compute plugin callback authorization dynamically [AI]. (#78866) Thanks @pgondhi987. - Gateway/auth: allow `gateway.auth.mode: "none"` loopback backend RPC clients to skip device identity only for local non-browser backend connections, restoring subagent spawns and gateway tools without opening remote or browser-origin bypasses. Fixes #75780. Thanks @yozakura-ava. - Canvas plugin: keep legacy root `canvasHost` configs valid until `openclaw doctor --fix` migrates them into `plugins.entries.canvas.config.host`, move Canvas/A2UI clients to gateway protocol v4 plugin surfaces, and refresh the generated A2UI bundle hash so normal builds stay clean. diff --git a/src/agents/model-runtime-aliases.ts b/src/agents/model-runtime-aliases.ts index 6265dc2af22..61385f54d9e 100644 --- a/src/agents/model-runtime-aliases.ts +++ b/src/agents/model-runtime-aliases.ts @@ -97,6 +97,28 @@ export function isCliRuntimeAlias(runtime: string | undefined): boolean { return normalized ? CLI_RUNTIME_ALIASES.has(normalizeProviderId(normalized)) : false; } +function canonicalizeRuntimeAliasProvider(provider: string): string { + return resolveLegacyRuntimeModelProviderAlias(provider)?.provider ?? provider; +} + +function normalizeRuntimeModelRefForComparison(raw: string): string { + const trimmed = raw.trim(); + const slash = trimmed.indexOf("/"); + if (slash <= 0 || slash >= trimmed.length - 1) { + return normalizeProviderId(canonicalizeRuntimeAliasProvider(trimmed)); + } + const provider = trimmed.slice(0, slash).trim(); + const model = trimmed.slice(slash + 1).trim(); + const canonicalProvider = normalizeProviderId(canonicalizeRuntimeAliasProvider(provider)); + return model ? `${canonicalProvider}/${model}` : canonicalProvider; +} + +export function areRuntimeModelRefsEquivalent(left: string, right: string): boolean { + return ( + normalizeRuntimeModelRefForComparison(left) === normalizeRuntimeModelRefForComparison(right) + ); +} + function resolveConfiguredRuntime(params: { cfg?: OpenClawConfig; provider: string; diff --git a/src/auto-reply/fallback-state.test.ts b/src/auto-reply/fallback-state.test.ts index 774e71a6ed8..5cf0c268365 100644 --- a/src/auto-reply/fallback-state.test.ts +++ b/src/auto-reply/fallback-state.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + buildFallbackNotice, resolveActiveFallbackState, resolveFallbackTransition, type FallbackNoticeState, @@ -120,4 +121,37 @@ describe("fallback-state", () => { expect(resolved.nextState.activeModel).toBeUndefined(); expect(resolved.nextState.reason).toBeUndefined(); }); + + it("does not treat a CLI runtime alias as a model fallback", () => { + const resolved = resolveFallbackTransition({ + selectedProvider: "anthropic", + selectedModel: "claude-opus-4-7", + activeProvider: "claude-cli", + activeModel: "claude-opus-4-7", + attempts: [], + state: { + fallbackNoticeSelectedModel: "anthropic/claude-opus-4-7", + fallbackNoticeActiveModel: "claude-cli/claude-opus-4-7", + fallbackNoticeReason: "selected model unavailable", + }, + }); + + expect(resolved.fallbackActive).toBe(false); + expect(resolved.fallbackCleared).toBe(false); + expect(resolved.stateChanged).toBe(true); + expect(resolved.nextState.selectedModel).toBeUndefined(); + expect(resolved.nextState.activeModel).toBeUndefined(); + }); + + it("does not build a fallback notice for equivalent CLI runtime aliases", () => { + expect( + buildFallbackNotice({ + selectedProvider: "anthropic", + selectedModel: "claude-opus-4-7", + activeProvider: "claude-cli", + activeModel: "claude-opus-4-7", + attempts: [], + }), + ).toBeNull(); + }); }); diff --git a/src/auto-reply/fallback-state.ts b/src/auto-reply/fallback-state.ts index 0140cc69f24..aa39c68fcf3 100644 --- a/src/auto-reply/fallback-state.ts +++ b/src/auto-reply/fallback-state.ts @@ -1,3 +1,4 @@ +import { areRuntimeModelRefsEquivalent } from "../agents/model-runtime-aliases.js"; import { formatRawAssistantErrorForUi } from "../agents/pi-embedded-helpers.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; import type { FallbackNoticeState } from "../status/fallback-notice-state.js"; @@ -96,7 +97,7 @@ export function buildFallbackNotice(params: { }): string | null { const selected = formatProviderModelRef(params.selectedProvider, params.selectedModel); const active = formatProviderModelRef(params.activeProvider, params.activeModel); - if (selected === active) { + if (areRuntimeModelRefsEquivalent(selected, active)) { return null; } const reasonSummary = buildFallbackReasonSummary(params.attempts); @@ -152,13 +153,17 @@ export function resolveFallbackTransition(params: { activeModel: normalizeOptionalString(params.state?.fallbackNoticeActiveModel), reason: normalizeOptionalString(params.state?.fallbackNoticeReason), }; - const fallbackActive = selectedModelRef !== activeModelRef; + const fallbackActive = !areRuntimeModelRefsEquivalent(selectedModelRef, activeModelRef); const fallbackTransitioned = fallbackActive && (previousState.selectedModel !== selectedModelRef || previousState.activeModel !== activeModelRef); - const fallbackCleared = - !fallbackActive && Boolean(previousState.selectedModel || previousState.activeModel); + const previousStateWasRealFallback = Boolean( + previousState.selectedModel && + previousState.activeModel && + !areRuntimeModelRefsEquivalent(previousState.selectedModel, previousState.activeModel), + ); + const fallbackCleared = !fallbackActive && previousStateWasRealFallback; const reasonSummary = buildFallbackReasonSummary(params.attempts); const attemptSummaries = buildFallbackAttemptSummaries(params.attempts); const nextState = fallbackActive diff --git a/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts b/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts index b1eeb91c57f..a9effdbcd73 100644 --- a/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts +++ b/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts @@ -1163,6 +1163,62 @@ describe("runReplyAgent typing (heartbeat)", () => { } }); + it("does not persist fallback state for an equivalent CLI runtime alias", async () => { + const sessionEntry: SessionEntry = { + sessionId: "session", + updatedAt: Date.now(), + fallbackNoticeSelectedModel: "anthropic/claude-opus-4-7", + fallbackNoticeActiveModel: "claude-cli/claude-opus-4-7", + fallbackNoticeReason: "selected model unavailable", + }; + const sessionStore = { main: sessionEntry }; + const dir = await mkdtemp(join(tmpdir(), "openclaw-agent-runner-cli-alias-")); + const storePath = join(dir, "sessions.json"); + await writeFile(storePath, JSON.stringify({ main: sessionEntry }), "utf8"); + + state.runEmbeddedPiAgentMock.mockResolvedValue({ + payloads: [{ text: "final" }], + meta: { + agentMeta: { + provider: "claude-cli", + model: "claude-opus-4-7", + usage: { input: 36_000, output: 19_000 }, + }, + }, + }); + + const { run } = createMinimalRun({ + sessionEntry, + sessionStore, + sessionKey: "main", + storePath, + runOverrides: { + provider: "anthropic", + model: "claude-opus-4-7", + config: { + agents: { + defaults: { + cliBackends: { + "claude-cli": { command: "claude" }, + }, + }, + }, + }, + }, + }); + await run(); + + const stored = JSON.parse(await readFile(storePath, "utf8")).main as SessionEntry; + expect(sessionEntry.fallbackNoticeSelectedModel).toBeUndefined(); + expect(sessionEntry.fallbackNoticeActiveModel).toBeUndefined(); + expect(stored.fallbackNoticeSelectedModel).toBeUndefined(); + expect(stored.fallbackNoticeActiveModel).toBeUndefined(); + expect(stored.modelProvider).toBe("claude-cli"); + expect(stored.model).toBe("claude-opus-4-7"); + expect(stored.totalTokens).toBe(36_000); + expect(stored.totalTokensFresh).toBe(true); + }); + it("surfaces overflow fallback when embedded run returns empty payloads", async () => { state.runEmbeddedPiAgentMock.mockImplementationOnce(async () => ({ payloads: [], diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index 0c9253171c8..b37cab18dd9 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -1416,10 +1416,11 @@ export async function runReplyAgent(params: { }); } } - const cliSessionId = isCliProvider(providerUsed, cfg) + const usedCliProvider = isCliProvider(providerUsed, cfg); + const cliSessionId = usedCliProvider ? normalizeOptionalString(runResult.meta?.agentMeta?.sessionId) : undefined; - const cliSessionBinding = isCliProvider(providerUsed, cfg) + const cliSessionBinding = usedCliProvider ? runResult.meta?.agentMeta?.cliSessionBinding : undefined; const runtimeContextTokens = @@ -1447,6 +1448,7 @@ export async function runReplyAgent(params: { usage, lastCallUsage: runResult.meta?.agentMeta?.lastCallUsage, promptTokens, + usageIsContextSnapshot: usedCliProvider ? true : undefined, modelUsed, providerUsed, contextTokensUsed, diff --git a/src/auto-reply/status.test.ts b/src/auto-reply/status.test.ts index 05136902714..06aa0cb780f 100644 --- a/src/auto-reply/status.test.ts +++ b/src/auto-reply/status.test.ts @@ -774,6 +774,43 @@ describe("buildStatusMessage", () => { expect(normalized).not.toContain("Context: 49k/1.0m"); }); + it("renders CLI runtime aliases as the selected model route", () => { + const text = buildStatusMessage({ + agent: { + model: "anthropic/claude-opus-4-7", + }, + sessionEntry: { + sessionId: "claude-cli-runtime-alias", + updatedAt: 0, + providerOverride: "anthropic", + modelOverride: "claude-opus-4-7", + modelProvider: "claude-cli", + model: "claude-opus-4-7", + fallbackNoticeSelectedModel: "anthropic/claude-opus-4-7", + fallbackNoticeActiveModel: "claude-cli/claude-opus-4-7", + fallbackNoticeReason: "selected model unavailable", + inputTokens: 29, + outputTokens: 19_000, + cacheRead: 3_000_000, + totalTokens: 36_000, + totalTokensFresh: true, + contextTokens: 1_000_000, + }, + sessionKey: "agent:main:main", + sessionScope: "per-sender", + queue: { mode: "collect", depth: 0 }, + modelAuth: "unknown", + activeModelAuth: "oauth (anthropic:claude-cli)", + }); + + const normalized = normalizeTestText(text); + expect(normalized).toContain("Model: anthropic/claude-opus-4-7"); + expect(normalized).toContain("oauth (anthropic:claude-cli)"); + expect(normalized).not.toContain("Fallback: claude-cli/claude-opus-4-7"); + expect(normalized).not.toContain("unknown"); + expect(normalized).toContain("Context: 36k/1.0m (4%)"); + }); + it("keeps an explicit runtime context limit when fallback status already computed one", () => { const text = buildStatusMessage({ config: { diff --git a/src/status/fallback-notice-state.ts b/src/status/fallback-notice-state.ts index 3857899c365..62f8f296c21 100644 --- a/src/status/fallback-notice-state.ts +++ b/src/status/fallback-notice-state.ts @@ -1,3 +1,4 @@ +import { areRuntimeModelRefsEquivalent } from "../agents/model-runtime-aliases.js"; import type { SessionEntry } from "../config/sessions.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; @@ -15,7 +16,7 @@ export function resolveActiveFallbackState(params: { const active = normalizeOptionalString(params.state?.fallbackNoticeActiveModel); const reason = normalizeOptionalString(params.state?.fallbackNoticeReason); const fallbackActive = - params.selectedModelRef !== params.activeModelRef && + !areRuntimeModelRefsEquivalent(params.selectedModelRef, params.activeModelRef) && selected === params.selectedModelRef && active === params.activeModelRef; return { diff --git a/src/status/status-message.ts b/src/status/status-message.ts index a65c56f9a55..6940ebfae01 100644 --- a/src/status/status-message.ts +++ b/src/status/status-message.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import { resolveContextTokensForModel } from "../agents/context.js"; import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; import { resolveModelAuthMode } from "../agents/model-auth.js"; +import { areRuntimeModelRefsEquivalent } from "../agents/model-runtime-aliases.js"; import { buildModelAliasIndex, resolveConfiguredModelRef, @@ -849,17 +850,25 @@ export function buildStatusMessage(args: StatusArgs): string { ]; const activationLine = activationParts.filter(Boolean).join(" ยท "); + const selectedModelLabel = modelRefs.selected.label || "unknown"; + const runtimeAliasModelEquivalent = areRuntimeModelRefsEquivalent( + selectedModelLabel, + activeModelLabel, + ); const selectedAuthMode = normalizeAuthMode(args.modelAuth) ?? resolveModelAuthMode(selectedProvider, args.config); - const selectedAuthLabelValue = - args.modelAuth ?? - (selectedAuthMode && selectedAuthMode !== "unknown" ? selectedAuthMode : undefined); + const rawSelectedAuthLabelValue = + selectedAuthMode && selectedAuthMode !== "unknown" + ? (args.modelAuth ?? selectedAuthMode) + : undefined; const activeAuthMode = normalizeAuthMode(args.activeModelAuth) ?? resolveModelAuthMode(activeProvider, args.config); const activeAuthLabelValue = - args.activeModelAuth ?? - (activeAuthMode && activeAuthMode !== "unknown" ? activeAuthMode : undefined); - const selectedModelLabel = modelRefs.selected.label || "unknown"; + activeAuthMode && activeAuthMode !== "unknown" + ? (args.activeModelAuth ?? activeAuthMode) + : undefined; + const selectedAuthLabelValue = + rawSelectedAuthLabelValue ?? (runtimeAliasModelEquivalent ? activeAuthLabelValue : undefined); const fallbackState = resolveActiveFallbackState({ selectedModelRef: selectedModelLabel, activeModelRef: activeModelLabel, diff --git a/src/status/status-text.ts b/src/status/status-text.ts index e4c2a3adfba..165722c213c 100644 --- a/src/status/status-text.ts +++ b/src/status/status-text.ts @@ -10,6 +10,7 @@ import { import { resolveContextTokensForModel } from "../agents/context.js"; import { resolveFastModeState } from "../agents/fast-mode.js"; import { resolveModelAuthLabel } from "../agents/model-auth-label.js"; +import { areRuntimeModelRefsEquivalent } from "../agents/model-runtime-aliases.js"; import { resolveInternalSessionKey, resolveMainSessionAlias, @@ -238,7 +239,7 @@ export async function buildStatusText(params: BuildStatusTextParams): Promise