status: compact fallback model presentation

This commit is contained in:
Gustavo Madeira Santana
2026-02-19 03:15:39 -05:00
committed by joshavant
parent 848511fda7
commit 9baae38704
10 changed files with 106 additions and 65 deletions

View File

@@ -15,30 +15,30 @@ const baseAttempt = {
describe("fallback-state", () => {
it("treats fallback as active only when state matches selected and active refs", () => {
const state: FallbackNoticeState = {
fallbackNoticeSelectedModel: "fireworks/fireworks/minimax-m2p5",
fallbackNoticeSelectedModel: "fireworks/minimax-m2p5",
fallbackNoticeActiveModel: "deepinfra/moonshotai/Kimi-K2.5",
fallbackNoticeReason: "fireworks/fireworks/minimax-m2p5 rate limit",
fallbackNoticeReason: "rate limit",
};
const resolved = resolveActiveFallbackState({
selectedModelRef: "fireworks/fireworks/minimax-m2p5",
selectedModelRef: "fireworks/minimax-m2p5",
activeModelRef: "deepinfra/moonshotai/Kimi-K2.5",
state,
});
expect(resolved.active).toBe(true);
expect(resolved.reason).toBe("fireworks/fireworks/minimax-m2p5 rate limit");
expect(resolved.reason).toBe("rate limit");
});
it("does not treat runtime drift as fallback when persisted state does not match", () => {
const state: FallbackNoticeState = {
fallbackNoticeSelectedModel: "anthropic/claude",
fallbackNoticeActiveModel: "deepinfra/moonshotai/Kimi-K2.5",
fallbackNoticeReason: "anthropic/claude rate limit",
fallbackNoticeReason: "rate limit",
};
const resolved = resolveActiveFallbackState({
selectedModelRef: "fireworks/fireworks/minimax-m2p5",
selectedModelRef: "fireworks/minimax-m2p5",
activeModelRef: "deepinfra/moonshotai/Kimi-K2.5",
state,
});
@@ -61,8 +61,8 @@ describe("fallback-state", () => {
expect(resolved.fallbackTransitioned).toBe(true);
expect(resolved.fallbackCleared).toBe(false);
expect(resolved.stateChanged).toBe(true);
expect(resolved.reasonSummary).toBe("fireworks/fireworks/minimax-m2p5 rate limit");
expect(resolved.nextState.selectedModel).toBe("fireworks/fireworks/minimax-m2p5");
expect(resolved.reasonSummary).toBe("rate limit");
expect(resolved.nextState.selectedModel).toBe("fireworks/minimax-m2p5");
expect(resolved.nextState.activeModel).toBe("deepinfra/moonshotai/Kimi-K2.5");
});
@@ -74,15 +74,15 @@ describe("fallback-state", () => {
activeModel: "moonshotai/Kimi-K2.5",
attempts: [{ ...baseAttempt, reason: "timeout" }],
state: {
fallbackNoticeSelectedModel: "fireworks/fireworks/minimax-m2p5",
fallbackNoticeSelectedModel: "fireworks/minimax-m2p5",
fallbackNoticeActiveModel: "deepinfra/moonshotai/Kimi-K2.5",
fallbackNoticeReason: "fireworks/fireworks/minimax-m2p5 rate limit",
fallbackNoticeReason: "rate limit",
},
});
expect(resolved.fallbackTransitioned).toBe(false);
expect(resolved.stateChanged).toBe(true);
expect(resolved.nextState.reason).toBe("fireworks/fireworks/minimax-m2p5 timeout");
expect(resolved.nextState.reason).toBe("timeout");
});
it("marks fallback as cleared when runtime returns to selected model", () => {
@@ -93,9 +93,9 @@ describe("fallback-state", () => {
activeModel: "fireworks/minimax-m2p5",
attempts: [],
state: {
fallbackNoticeSelectedModel: "fireworks/fireworks/minimax-m2p5",
fallbackNoticeSelectedModel: "fireworks/minimax-m2p5",
fallbackNoticeActiveModel: "deepinfra/moonshotai/Kimi-K2.5",
fallbackNoticeReason: "fireworks/fireworks/minimax-m2p5 rate limit",
fallbackNoticeReason: "rate limit",
},
});

View File

@@ -1,4 +1,5 @@
import type { SessionEntry } from "../config/sessions.js";
import { formatProviderModelRef } from "./model-runtime.js";
import type { RuntimeFallbackAttempt } from "./reply/agent-runner-execution.js";
const FALLBACK_REASON_PART_MAX = 80;
@@ -37,13 +38,13 @@ export function formatFallbackAttemptReason(attempt: RuntimeFallbackAttempt): st
}
function formatFallbackAttemptSummary(attempt: RuntimeFallbackAttempt): string {
return `${attempt.provider}/${attempt.model} ${formatFallbackAttemptReason(attempt)}`;
return `${formatProviderModelRef(attempt.provider, attempt.model)} ${formatFallbackAttemptReason(attempt)}`;
}
export function buildFallbackReasonSummary(attempts: RuntimeFallbackAttempt[]): string {
const firstAttempt = attempts[0];
const firstReason = firstAttempt
? formatFallbackAttemptSummary(firstAttempt)
? formatFallbackAttemptReason(firstAttempt)
: "selected model unavailable";
const moreAttempts = attempts.length > 1 ? ` (+${attempts.length - 1} more attempts)` : "";
return `${truncateFallbackReasonPart(firstReason)}${moreAttempts}`;
@@ -62,8 +63,8 @@ export function buildFallbackNotice(params: {
activeModel: string;
attempts: RuntimeFallbackAttempt[];
}): string | null {
const selected = `${params.selectedProvider}/${params.selectedModel}`;
const active = `${params.activeProvider}/${params.activeModel}`;
const selected = formatProviderModelRef(params.selectedProvider, params.selectedModel);
const active = formatProviderModelRef(params.activeProvider, params.activeModel);
if (selected === active) {
return null;
}
@@ -76,7 +77,7 @@ export function buildFallbackClearedNotice(params: {
selectedModel: string;
previousActiveModel?: string;
}): string {
const selected = `${params.selectedProvider}/${params.selectedModel}`;
const selected = formatProviderModelRef(params.selectedProvider, params.selectedModel);
const previous = normalizeFallbackModelRef(params.previousActiveModel);
if (previous && previous !== selected) {
return `↪️ Model Fallback cleared: ${selected} (was ${previous})`;
@@ -131,8 +132,8 @@ export function resolveFallbackTransition(params: {
attempts: RuntimeFallbackAttempt[];
state?: FallbackNoticeState;
}): ResolvedFallbackTransition {
const selectedModelRef = `${params.selectedProvider}/${params.selectedModel}`;
const activeModelRef = `${params.activeProvider}/${params.activeModel}`;
const selectedModelRef = formatProviderModelRef(params.selectedProvider, params.selectedModel);
const activeModelRef = formatProviderModelRef(params.activeProvider, params.activeModel);
const previousState = {
selectedModel: normalizeFallbackModelRef(params.state?.fallbackNoticeSelectedModel),
activeModel: normalizeFallbackModelRef(params.state?.fallbackNoticeActiveModel),

View File

@@ -1,11 +1,45 @@
import type { SessionEntry } from "../config/sessions.js";
export function formatProviderModelRef(providerRaw: string, modelRaw: string): string {
const provider = String(providerRaw ?? "").trim();
const model = String(modelRaw ?? "").trim();
if (!provider) {
return model;
}
if (!model) {
return provider;
}
const prefix = `${provider}/`;
if (model.toLowerCase().startsWith(prefix.toLowerCase())) {
const normalizedModel = model.slice(prefix.length).trim();
if (normalizedModel) {
return `${provider}/${normalizedModel}`;
}
}
return `${provider}/${model}`;
}
type ModelRef = {
provider: string;
model: string;
label: string;
};
function normalizeModelWithinProvider(provider: string, modelRaw: string): string {
const model = String(modelRaw ?? "").trim();
if (!provider || !model) {
return model;
}
const prefix = `${provider}/`;
if (model.toLowerCase().startsWith(prefix.toLowerCase())) {
const withoutPrefix = model.slice(prefix.length).trim();
if (withoutPrefix) {
return withoutPrefix;
}
}
return model;
}
function normalizeModelRef(
rawModel: string,
fallbackProvider: string,
@@ -25,10 +59,11 @@ function normalizeModelRef(
}
}
const provider = String(fallbackProvider ?? "").trim();
const dedupedModel = normalizeModelWithinProvider(provider, trimmed);
return {
provider,
model: trimmed,
label: provider ? `${provider}/${trimmed}` : trimmed,
model: dedupedModel || trimmed,
label: provider ? formatProviderModelRef(provider, dedupedModel || trimmed) : trimmed,
};
}

View File

@@ -574,7 +574,7 @@ describe("runReplyAgent typing (heartbeat)", () => {
const payloads = res as { text?: string }[];
expect(payloads[0]?.text).toContain("Model Fallback:");
expect(payloads[0]?.text).toContain("deepinfra/moonshotai/Kimi-K2.5");
expect(sessionEntry.fallbackNoticeReason).toBe("fireworks/fireworks/minimax-m2p5 rate limit");
expect(sessionEntry.fallbackNoticeReason).toBe("rate limit");
});
it("does not announce model fallback when verbose is off", async () => {
@@ -946,7 +946,7 @@ describe("runReplyAgent typing (heartbeat)", () => {
const res = await run();
const firstText = Array.isArray(res) ? res[0]?.text : res?.text;
expect(firstText).not.toContain("Model Fallback:");
expect(sessionEntry.fallbackNoticeReason).toBe("anthropic/claude rate limit");
expect(sessionEntry.fallbackNoticeReason).toBe("rate limit");
} finally {
fallbackSpy.mockRestore();
}
@@ -958,7 +958,7 @@ describe("runReplyAgent typing (heartbeat)", () => {
updatedAt: Date.now(),
fallbackNoticeSelectedModel: "anthropic/claude",
fallbackNoticeActiveModel: "deepinfra/moonshotai/Kimi-K2.5",
fallbackNoticeReason: "anthropic/claude rate limit",
fallbackNoticeReason: "rate limit",
modelProvider: "deepinfra",
model: "moonshotai/Kimi-K2.5",
};
@@ -996,7 +996,7 @@ describe("runReplyAgent typing (heartbeat)", () => {
const res = await run();
const firstText = Array.isArray(res) ? res[0]?.text : res?.text;
expect(firstText).not.toContain("Model Fallback:");
expect(sessionEntry.fallbackNoticeReason).toBe("anthropic/claude timeout");
expect(sessionEntry.fallbackNoticeReason).toBe("timeout");
} finally {
fallbackSpy.mockRestore();
}
@@ -1011,9 +1011,9 @@ describe("runReplyAgent typing (heartbeat)", () => {
sessionId,
updatedAt: Date.now(),
sessionFile: transcriptPath,
fallbackNoticeSelectedModel: "fireworks/fireworks/minimax-m2p5",
fallbackNoticeSelectedModel: "fireworks/minimax-m2p5",
fallbackNoticeActiveModel: "deepinfra/moonshotai/Kimi-K2.5",
fallbackNoticeReason: "fireworks/fireworks/minimax-m2p5 rate limit",
fallbackNoticeReason: "rate limit",
};
const sessionStore = { main: sessionEntry };

View File

@@ -85,7 +85,7 @@ describe("/model chat UX", () => {
},
});
expect(reply?.text).toContain("Current: fireworks/fireworks/minimax-m2p5 (selected)");
expect(reply?.text).toContain("Current: fireworks/minimax-m2p5 (selected)");
expect(reply?.text).toContain("Active: deepinfra/moonshotai/Kimi-K2.5 (runtime)");
});

View File

@@ -205,7 +205,7 @@ describe("buildStatusMessage", () => {
model: "claude-haiku-4-5",
fallbackNoticeSelectedModel: "openai/gpt-4.1-mini",
fallbackNoticeActiveModel: "anthropic/claude-haiku-4-5",
fallbackNoticeReason: "fireworks/fireworks/minimax-m2p5 rate limit",
fallbackNoticeReason: "rate limit",
contextTokens: 32_000,
},
sessionKey: "agent:main:main",
@@ -217,11 +217,10 @@ describe("buildStatusMessage", () => {
const normalized = normalizeTestText(text);
expect(normalized).toContain("Model: openai/gpt-4.1-mini");
expect(normalized).toContain("(selected)");
expect(normalized).toContain("Active: anthropic/claude-haiku-4-5");
expect(normalized).toContain("(Reason: fireworks/fireworks/minimax-m2p5 rate limit)");
expect(normalized).toContain("Fallback: anthropic/claude-haiku-4-5");
expect(normalized).toContain("(rate limit)");
expect(normalized).not.toContain(" - Reason:");
expect(normalized).not.toContain("Active Reason:");
expect(normalized).not.toContain("Active:");
expect(normalized).toContain("di_123...abc");
});
@@ -236,9 +235,9 @@ describe("buildStatusMessage", () => {
updatedAt: 0,
modelProvider: "anthropic",
model: "claude-haiku-4-5",
fallbackNoticeSelectedModel: "fireworks/fireworks/minimax-m2p5",
fallbackNoticeSelectedModel: "fireworks/minimax-m2p5",
fallbackNoticeActiveModel: "deepinfra/moonshotai/Kimi-K2.5",
fallbackNoticeReason: "fireworks/fireworks/minimax-m2p5 rate limit",
fallbackNoticeReason: "rate limit",
},
sessionKey: "agent:main:main",
sessionScope: "per-sender",
@@ -249,9 +248,8 @@ describe("buildStatusMessage", () => {
const normalized = normalizeTestText(text);
expect(normalized).toContain("Model: openai/gpt-4.1-mini");
expect(normalized).not.toContain("(selected)");
expect(normalized).not.toContain("Active:");
expect(normalized).not.toContain("Reason:");
expect(normalized).not.toContain("Fallback:");
expect(normalized).not.toContain("(rate limit)");
});
it("omits active lines when runtime matches selected model", () => {
@@ -265,7 +263,7 @@ describe("buildStatusMessage", () => {
updatedAt: 0,
modelProvider: "openai",
model: "gpt-4.1-mini",
fallbackNoticeReason: "openai/gpt-4.1-mini unknown",
fallbackNoticeReason: "unknown",
},
sessionKey: "agent:main:main",
sessionScope: "per-sender",
@@ -274,8 +272,7 @@ describe("buildStatusMessage", () => {
});
const normalized = normalizeTestText(text);
expect(normalized).not.toContain("Active:");
expect(normalized).not.toContain("Active Reason:");
expect(normalized).not.toContain("Fallback:");
});
it("keeps provider prefix from configured model", () => {

View File

@@ -41,7 +41,7 @@ import {
} from "./commands-registry.js";
import type { CommandCategory } from "./commands-registry.types.js";
import { resolveActiveFallbackState } from "./fallback-state.js";
import { resolveSelectedAndActiveModel } from "./model-runtime.js";
import { formatProviderModelRef, resolveSelectedAndActiveModel } from "./model-runtime.js";
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "./thinking.js";
type AgentDefaults = NonNullable<NonNullable<OpenClawConfig["agents"]>["defaults"]>;
@@ -492,19 +492,19 @@ export function buildStatusMessage(args: StatusArgs): string {
const costLabel = showCost && hasUsage ? formatUsd(cost) : undefined;
const selectedModelLabel = modelRefs.selected.label || "unknown";
const activeModelLabel = formatProviderModelRef(activeProvider, activeModel) || "unknown";
const fallbackState = resolveActiveFallbackState({
selectedModelRef: `${selectedProvider}/${selectedModel}`,
activeModelRef: `${activeProvider}/${activeModel}`,
selectedModelRef: selectedModelLabel,
activeModelRef: activeModelLabel,
state: entry,
});
const selectedAuthLabel = selectedAuthLabelValue ? ` · 🔑 ${selectedAuthLabelValue}` : "";
const modelLine = `🧠 Model: ${selectedModelLabel}${selectedAuthLabel}${
fallbackState.active ? " (selected)" : ""
}`;
const activeModelLine = fallbackState.active
? `🧠 Active: ${activeProvider}/${activeModel}${
activeAuthLabelValue ? ` · 🔑 ${activeAuthLabelValue}` : ""
} (Reason: ${fallbackState.reason ?? "selected model unavailable"})`
const modelLine = `🧠 Model: ${selectedModelLabel}${selectedAuthLabel}`;
const showFallbackAuth = activeAuthLabelValue && activeAuthLabelValue !== selectedAuthLabelValue;
const fallbackLine = fallbackState.active
? `↪️ Fallback: ${activeModelLabel}${
showFallbackAuth ? ` · 🔑 ${activeAuthLabelValue}` : ""
} (${fallbackState.reason ?? "selected model unavailable"})`
: null;
const commit = resolveCommitHash();
const versionLine = `🦞 OpenClaw ${VERSION}${commit ? ` (${commit})` : ""}`;
@@ -519,7 +519,7 @@ export function buildStatusMessage(args: StatusArgs): string {
versionLine,
args.timeLine,
modelLine,
activeModelLine,
fallbackLine,
usageCostLine,
`📚 ${contextLine}`,
mediaLine,

View File

@@ -51,13 +51,13 @@ describe("app-tool-stream fallback lifecycle handling", () => {
selectedModel: "fireworks/minimax-m2p5",
activeProvider: "deepinfra",
activeModel: "moonshotai/Kimi-K2.5",
reasonSummary: "fireworks/fireworks/minimax-m2p5 rate limit",
reasonSummary: "rate limit",
},
});
expect(host.fallbackStatus?.selected).toBe("fireworks/fireworks/minimax-m2p5");
expect(host.fallbackStatus?.selected).toBe("fireworks/minimax-m2p5");
expect(host.fallbackStatus?.active).toBe("deepinfra/moonshotai/Kimi-K2.5");
expect(host.fallbackStatus?.reason).toBe("fireworks/fireworks/minimax-m2p5 rate limit");
expect(host.fallbackStatus?.reason).toBe("rate limit");
vi.useRealTimers();
});

View File

@@ -49,6 +49,13 @@ function resolveModelLabel(provider: unknown, model: unknown): string | null {
}
const providerValue = toTrimmedString(provider);
if (providerValue) {
const prefix = `${providerValue}/`;
if (modelValue.toLowerCase().startsWith(prefix.toLowerCase())) {
const trimmedModel = modelValue.slice(prefix.length).trim();
if (trimmedModel) {
return `${providerValue}/${trimmedModel}`;
}
}
return `${providerValue}/${modelValue}`;
}
const slashIndex = modelValue.indexOf("/");
@@ -347,9 +354,10 @@ function handleLifecycleFallbackEvent(host: CompactionHost, payload: AgentEventP
if (summaries.length > 0) {
return summaries;
}
return parseFallbackAttempts(data.attempts).map(
(attempt) => `${attempt.provider}/${attempt.model}: ${attempt.reason}`,
);
return parseFallbackAttempts(data.attempts).map((attempt) => {
const modelRef = resolveModelLabel(attempt.provider, attempt.model);
return `${modelRef ?? `${attempt.provider}/${attempt.model}`}: ${attempt.reason}`;
});
})();
if (host.fallbackClearTimer != null) {

View File

@@ -119,9 +119,9 @@ describe("chat view", () => {
renderChat(
createProps({
fallbackStatus: {
selected: "fireworks/fireworks/minimax-m2p5",
selected: "fireworks/minimax-m2p5",
active: "deepinfra/moonshotai/Kimi-K2.5",
attempts: ["fireworks/fireworks/minimax-m2p5: rate limit"],
attempts: ["fireworks/minimax-m2p5: rate limit"],
occurredAt: 900,
},
}),
@@ -142,7 +142,7 @@ describe("chat view", () => {
renderChat(
createProps({
fallbackStatus: {
selected: "fireworks/fireworks/minimax-m2p5",
selected: "fireworks/minimax-m2p5",
active: "deepinfra/moonshotai/Kimi-K2.5",
attempts: [],
occurredAt: 0,
@@ -164,8 +164,8 @@ describe("chat view", () => {
createProps({
fallbackStatus: {
phase: "cleared",
selected: "fireworks/fireworks/minimax-m2p5",
active: "fireworks/fireworks/minimax-m2p5",
selected: "fireworks/minimax-m2p5",
active: "fireworks/minimax-m2p5",
previous: "deepinfra/moonshotai/Kimi-K2.5",
attempts: [],
occurredAt: 900,
@@ -177,7 +177,7 @@ describe("chat view", () => {
const indicator = container.querySelector(".compaction-indicator--fallback-cleared");
expect(indicator).not.toBeNull();
expect(indicator?.textContent).toContain("Fallback cleared: fireworks/fireworks/minimax-m2p5");
expect(indicator?.textContent).toContain("Fallback cleared: fireworks/minimax-m2p5");
nowSpy.mockRestore();
});