feat(auto-reply): add model fallback lifecycle visibility in status, verbose logs, and WebUI (#20704)

This commit is contained in:
Josh Avant
2026-02-19 14:33:02 -08:00
committed by GitHub
parent 6cdcb5904d
commit c2876b69fb
24 changed files with 1855 additions and 55 deletions

View File

@@ -635,6 +635,16 @@
border-color: rgba(34, 197, 94, 0.35);
}
.compaction-indicator--fallback {
color: #d97706;
border-color: rgba(217, 119, 6, 0.35);
}
.compaction-indicator--fallback-cleared {
color: var(--ok);
border-color: rgba(34, 197, 94, 0.35);
}
@keyframes compaction-spin {
to {
transform: rotate(360deg);

View File

@@ -824,6 +824,7 @@ export function renderApp(state: AppViewState) {
loading: state.chatLoading,
sending: state.chatSending,
compactionStatus: state.compactionStatus,
fallbackStatus: state.fallbackStatus,
assistantAvatarUrl: chatAvatarUrl,
messages: state.chatMessages,
toolMessages: state.chatToolMessages,

View File

@@ -0,0 +1,139 @@
import { beforeAll, describe, expect, it, vi } from "vitest";
import { handleAgentEvent, type FallbackStatus, type ToolStreamEntry } from "./app-tool-stream.ts";
type ToolStreamHost = Parameters<typeof handleAgentEvent>[0];
type MutableHost = ToolStreamHost & {
compactionStatus?: unknown;
compactionClearTimer?: number | null;
fallbackStatus?: FallbackStatus | null;
fallbackClearTimer?: number | null;
};
function createHost(overrides?: Partial<MutableHost>): MutableHost {
return {
sessionKey: "main",
chatRunId: null,
toolStreamById: new Map<string, ToolStreamEntry>(),
toolStreamOrder: [],
chatToolMessages: [],
toolStreamSyncTimer: null,
compactionStatus: null,
compactionClearTimer: null,
fallbackStatus: null,
fallbackClearTimer: null,
...overrides,
};
}
describe("app-tool-stream fallback lifecycle handling", () => {
beforeAll(() => {
const globalWithWindow = globalThis as typeof globalThis & {
window?: Window & typeof globalThis;
};
if (!globalWithWindow.window) {
globalWithWindow.window = globalThis as unknown as Window & typeof globalThis;
}
});
it("accepts session-scoped fallback lifecycle events when no run is active", () => {
vi.useFakeTimers();
const host = createHost();
handleAgentEvent(host, {
runId: "run-1",
seq: 1,
stream: "lifecycle",
ts: Date.now(),
sessionKey: "main",
data: {
phase: "fallback",
selectedProvider: "fireworks",
selectedModel: "fireworks/minimax-m2p5",
activeProvider: "deepinfra",
activeModel: "moonshotai/Kimi-K2.5",
reasonSummary: "rate limit",
},
});
expect(host.fallbackStatus?.selected).toBe("fireworks/minimax-m2p5");
expect(host.fallbackStatus?.active).toBe("deepinfra/moonshotai/Kimi-K2.5");
expect(host.fallbackStatus?.reason).toBe("rate limit");
vi.useRealTimers();
});
it("rejects idle fallback lifecycle events for other sessions", () => {
vi.useFakeTimers();
const host = createHost();
handleAgentEvent(host, {
runId: "run-1",
seq: 1,
stream: "lifecycle",
ts: Date.now(),
sessionKey: "agent:other:main",
data: {
phase: "fallback",
selectedProvider: "fireworks",
selectedModel: "fireworks/minimax-m2p5",
activeProvider: "deepinfra",
activeModel: "moonshotai/Kimi-K2.5",
},
});
expect(host.fallbackStatus).toBeNull();
vi.useRealTimers();
});
it("auto-clears fallback status after toast duration", () => {
vi.useFakeTimers();
const host = createHost();
handleAgentEvent(host, {
runId: "run-1",
seq: 1,
stream: "lifecycle",
ts: Date.now(),
sessionKey: "main",
data: {
phase: "fallback",
selectedProvider: "fireworks",
selectedModel: "fireworks/minimax-m2p5",
activeProvider: "deepinfra",
activeModel: "moonshotai/Kimi-K2.5",
},
});
expect(host.fallbackStatus).not.toBeNull();
vi.advanceTimersByTime(7_999);
expect(host.fallbackStatus).not.toBeNull();
vi.advanceTimersByTime(1);
expect(host.fallbackStatus).toBeNull();
vi.useRealTimers();
});
it("builds previous fallback label from provider + model on fallback_cleared", () => {
vi.useFakeTimers();
const host = createHost();
handleAgentEvent(host, {
runId: "run-1",
seq: 1,
stream: "lifecycle",
ts: Date.now(),
sessionKey: "main",
data: {
phase: "fallback_cleared",
selectedProvider: "fireworks",
selectedModel: "fireworks/minimax-m2p5",
activeProvider: "fireworks",
activeModel: "fireworks/minimax-m2p5",
previousActiveProvider: "deepinfra",
previousActiveModel: "moonshotai/Kimi-K2.5",
},
});
expect(host.fallbackStatus?.phase).toBe("cleared");
expect(host.fallbackStatus?.previous).toBe("deepinfra/moonshotai/Kimi-K2.5");
vi.useRealTimers();
});
});

View File

@@ -34,6 +34,82 @@ type ToolStreamHost = {
toolStreamSyncTimer: number | null;
};
function toTrimmedString(value: unknown): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed ? trimmed : null;
}
function resolveModelLabel(provider: unknown, model: unknown): string | null {
const modelValue = toTrimmedString(model);
if (!modelValue) {
return 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("/");
if (slashIndex > 0) {
const p = modelValue.slice(0, slashIndex).trim();
const m = modelValue.slice(slashIndex + 1).trim();
if (p && m) {
return `${p}/${m}`;
}
}
return modelValue;
}
type FallbackAttempt = {
provider: string;
model: string;
reason: string;
};
function parseFallbackAttemptSummaries(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return value
.map((entry) => toTrimmedString(entry))
.filter((entry): entry is string => Boolean(entry));
}
function parseFallbackAttempts(value: unknown): FallbackAttempt[] {
if (!Array.isArray(value)) {
return [];
}
const out: FallbackAttempt[] = [];
for (const entry of value) {
if (!entry || typeof entry !== "object") {
continue;
}
const item = entry as Record<string, unknown>;
const provider = toTrimmedString(item.provider);
const model = toTrimmedString(item.model);
if (!provider || !model) {
continue;
}
const reason =
toTrimmedString(item.reason)?.replace(/_/g, " ") ??
toTrimmedString(item.code) ??
(typeof item.status === "number" ? `HTTP ${item.status}` : null) ??
toTrimmedString(item.error) ??
"error";
out.push({ provider, model, reason });
}
return out;
}
function extractToolOutputText(value: unknown): string | null {
if (!value || typeof value !== "object") {
return null;
@@ -167,12 +243,25 @@ export type CompactionStatus = {
completedAt: number | null;
};
export type FallbackStatus = {
phase?: "active" | "cleared";
selected: string;
active: string;
previous?: string;
reason?: string;
attempts: string[];
occurredAt: number;
};
type CompactionHost = ToolStreamHost & {
compactionStatus?: CompactionStatus | null;
compactionClearTimer?: number | null;
fallbackStatus?: FallbackStatus | null;
fallbackClearTimer?: number | null;
};
const COMPACTION_TOAST_DURATION_MS = 5000;
const FALLBACK_TOAST_DURATION_MS = 8000;
export function handleCompactionEvent(host: CompactionHost, payload: AgentEventPayload) {
const data = payload.data ?? {};
@@ -204,6 +293,95 @@ export function handleCompactionEvent(host: CompactionHost, payload: AgentEventP
}
}
function resolveAcceptedSession(
host: ToolStreamHost,
payload: AgentEventPayload,
options?: {
allowSessionScopedWhenIdle?: boolean;
},
): { accepted: boolean; sessionKey?: string } {
const sessionKey = typeof payload.sessionKey === "string" ? payload.sessionKey : undefined;
if (sessionKey && sessionKey !== host.sessionKey) {
return { accepted: false };
}
if (!host.chatRunId && options?.allowSessionScopedWhenIdle && sessionKey) {
return { accepted: true, sessionKey };
}
// Fallback: only accept session-less events for the active run.
if (!sessionKey && host.chatRunId && payload.runId !== host.chatRunId) {
return { accepted: false };
}
if (host.chatRunId && payload.runId !== host.chatRunId) {
return { accepted: false };
}
if (!host.chatRunId) {
return { accepted: false };
}
return { accepted: true, sessionKey };
}
function handleLifecycleFallbackEvent(host: CompactionHost, payload: AgentEventPayload) {
const data = payload.data ?? {};
const phase = payload.stream === "fallback" ? "fallback" : toTrimmedString(data.phase);
if (payload.stream === "lifecycle" && phase !== "fallback" && phase !== "fallback_cleared") {
return;
}
const accepted = resolveAcceptedSession(host, payload, { allowSessionScopedWhenIdle: true });
if (!accepted.accepted) {
return;
}
const selected =
resolveModelLabel(data.selectedProvider, data.selectedModel) ??
resolveModelLabel(data.fromProvider, data.fromModel);
const active =
resolveModelLabel(data.activeProvider, data.activeModel) ??
resolveModelLabel(data.toProvider, data.toModel);
const previous =
resolveModelLabel(data.previousActiveProvider, data.previousActiveModel) ??
toTrimmedString(data.previousActiveModel);
if (!selected || !active) {
return;
}
if (phase === "fallback" && selected === active) {
return;
}
const reason = toTrimmedString(data.reasonSummary) ?? toTrimmedString(data.reason);
const attempts = (() => {
const summaries = parseFallbackAttemptSummaries(data.attemptSummaries);
if (summaries.length > 0) {
return summaries;
}
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) {
window.clearTimeout(host.fallbackClearTimer);
host.fallbackClearTimer = null;
}
host.fallbackStatus = {
phase: phase === "fallback_cleared" ? "cleared" : "active",
selected,
active: phase === "fallback_cleared" ? selected : active,
previous:
phase === "fallback_cleared"
? (previous ?? (active !== selected ? active : undefined))
: undefined,
reason: reason ?? undefined,
attempts,
occurredAt: Date.now(),
};
host.fallbackClearTimer = window.setTimeout(() => {
host.fallbackStatus = null;
host.fallbackClearTimer = null;
}, FALLBACK_TOAST_DURATION_MS);
}
export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPayload) {
if (!payload) {
return;
@@ -215,23 +393,19 @@ export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPaylo
return;
}
if (payload.stream === "lifecycle" || payload.stream === "fallback") {
handleLifecycleFallbackEvent(host as CompactionHost, payload);
return;
}
if (payload.stream !== "tool") {
return;
}
const sessionKey = typeof payload.sessionKey === "string" ? payload.sessionKey : undefined;
if (sessionKey && sessionKey !== host.sessionKey) {
return;
}
// Fallback: only accept session-less events for the active run.
if (!sessionKey && host.chatRunId && payload.runId !== host.chatRunId) {
return;
}
if (host.chatRunId && payload.runId !== host.chatRunId) {
return;
}
if (!host.chatRunId) {
const accepted = resolveAcceptedSession(host, payload);
if (!accepted.accepted) {
return;
}
const sessionKey = accepted.sessionKey;
const data = payload.data ?? {};
const toolCallId = typeof data.toolCallId === "string" ? data.toolCallId : "";

View File

@@ -1,5 +1,5 @@
import type { EventLogEntry } from "./app-events.ts";
import type { CompactionStatus } from "./app-tool-stream.ts";
import type { CompactionStatus, FallbackStatus } from "./app-tool-stream.ts";
import type { DevicePairingList } from "./controllers/devices.ts";
import type { ExecApprovalRequest } from "./controllers/exec-approval.ts";
import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "./controllers/exec-approvals.ts";
@@ -61,6 +61,7 @@ export type AppViewState = {
chatStreamStartedAt: number | null;
chatRunId: string | null;
compactionStatus: CompactionStatus | null;
fallbackStatus: FallbackStatus | null;
chatAvatarUrl: string | null;
chatThinkingLevel: string | null;
chatQueue: ChatQueueItem[];

View File

@@ -48,6 +48,7 @@ import {
resetToolStream as resetToolStreamInternal,
type ToolStreamEntry,
type CompactionStatus,
type FallbackStatus,
} from "./app-tool-stream.ts";
import type { AppViewState } from "./app-view-state.ts";
import { normalizeAssistantIdentity } from "./assistant-identity.ts";
@@ -140,6 +141,7 @@ export class OpenClawApp extends LitElement {
@state() chatStreamStartedAt: number | null = null;
@state() chatRunId: string | null = null;
@state() compactionStatus: CompactionStatus | null = null;
@state() fallbackStatus: FallbackStatus | null = null;
@state() chatAvatarUrl: string | null = null;
@state() chatThinkingLevel: string | null = null;
@state() chatQueue: ChatQueueItem[] = [];

View File

@@ -23,6 +23,7 @@ function createProps(overrides: Partial<ChatProps> = {}): ChatProps {
sending: false,
canAbort: false,
compactionStatus: null,
fallbackStatus: null,
messages: [],
toolMessages: [],
stream: null,
@@ -111,6 +112,75 @@ describe("chat view", () => {
nowSpy.mockRestore();
});
it("renders fallback indicator shortly after fallback event", () => {
const container = document.createElement("div");
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_000);
render(
renderChat(
createProps({
fallbackStatus: {
selected: "fireworks/minimax-m2p5",
active: "deepinfra/moonshotai/Kimi-K2.5",
attempts: ["fireworks/minimax-m2p5: rate limit"],
occurredAt: 900,
},
}),
),
container,
);
const indicator = container.querySelector(".compaction-indicator--fallback");
expect(indicator).not.toBeNull();
expect(indicator?.textContent).toContain("Fallback active: deepinfra/moonshotai/Kimi-K2.5");
nowSpy.mockRestore();
});
it("hides stale fallback indicator", () => {
const container = document.createElement("div");
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(20_000);
render(
renderChat(
createProps({
fallbackStatus: {
selected: "fireworks/minimax-m2p5",
active: "deepinfra/moonshotai/Kimi-K2.5",
attempts: [],
occurredAt: 0,
},
}),
),
container,
);
expect(container.querySelector(".compaction-indicator--fallback")).toBeNull();
nowSpy.mockRestore();
});
it("renders fallback-cleared indicator shortly after transition", () => {
const container = document.createElement("div");
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_000);
render(
renderChat(
createProps({
fallbackStatus: {
phase: "cleared",
selected: "fireworks/minimax-m2p5",
active: "fireworks/minimax-m2p5",
previous: "deepinfra/moonshotai/Kimi-K2.5",
attempts: [],
occurredAt: 900,
},
}),
),
container,
);
const indicator = container.querySelector(".compaction-indicator--fallback-cleared");
expect(indicator).not.toBeNull();
expect(indicator?.textContent).toContain("Fallback cleared: fireworks/minimax-m2p5");
nowSpy.mockRestore();
});
it("shows a stop button when aborting is available", () => {
const container = document.createElement("div");
const onAbort = vi.fn();

View File

@@ -21,6 +21,16 @@ export type CompactionIndicatorStatus = {
completedAt: number | null;
};
export type FallbackIndicatorStatus = {
phase?: "active" | "cleared";
selected: string;
active: string;
previous?: string;
reason?: string;
attempts: string[];
occurredAt: number;
};
export type ChatProps = {
sessionKey: string;
onSessionKeyChange: (next: string) => void;
@@ -30,6 +40,7 @@ export type ChatProps = {
sending: boolean;
canAbort?: boolean;
compactionStatus?: CompactionIndicatorStatus | null;
fallbackStatus?: FallbackIndicatorStatus | null;
messages: unknown[];
toolMessages: unknown[];
stream: string | null;
@@ -72,6 +83,7 @@ export type ChatProps = {
};
const COMPACTION_TOAST_DURATION_MS = 5000;
const FALLBACK_TOAST_DURATION_MS = 8000;
function adjustTextareaHeight(el: HTMLTextAreaElement) {
el.style.height = "auto";
@@ -107,6 +119,45 @@ function renderCompactionIndicator(status: CompactionIndicatorStatus | null | un
return nothing;
}
function renderFallbackIndicator(status: FallbackIndicatorStatus | null | undefined) {
if (!status) {
return nothing;
}
const phase = status.phase ?? "active";
const elapsed = Date.now() - status.occurredAt;
if (elapsed >= FALLBACK_TOAST_DURATION_MS) {
return nothing;
}
const details = [
`Selected: ${status.selected}`,
phase === "cleared" ? `Active: ${status.selected}` : `Active: ${status.active}`,
phase === "cleared" && status.previous ? `Previous fallback: ${status.previous}` : null,
status.reason ? `Reason: ${status.reason}` : null,
status.attempts.length > 0 ? `Attempts: ${status.attempts.slice(0, 3).join(" | ")}` : null,
]
.filter(Boolean)
.join(" • ");
const message =
phase === "cleared"
? `Fallback cleared: ${status.selected}`
: `Fallback active: ${status.active}`;
const className =
phase === "cleared"
? "compaction-indicator compaction-indicator--fallback-cleared"
: "compaction-indicator compaction-indicator--fallback";
const icon = phase === "cleared" ? icons.check : icons.brain;
return html`
<div
class=${className}
role="status"
aria-live="polite"
title=${details}
>
${icon} ${message}
</div>
`;
}
function generateAttachmentId(): string {
return `att-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}
@@ -352,6 +403,7 @@ export function renderChat(props: ChatProps) {
: nothing
}
${renderFallbackIndicator(props.fallbackStatus)}
${renderCompactionIndicator(props.compactionStatus)}
${