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

@@ -40,12 +40,23 @@ import type { FollowupRun } from "./queue.js";
import { createBlockReplyDeliveryHandler } from "./reply-delivery.js";
import type { TypingSignaler } from "./typing-mode.js";
export type RuntimeFallbackAttempt = {
provider: string;
model: string;
error: string;
reason?: string;
status?: number;
code?: string;
};
export type AgentRunLoopResult =
| {
kind: "success";
runId: string;
runResult: Awaited<ReturnType<typeof runEmbeddedPiAgent>>;
fallbackProvider?: string;
fallbackModel?: string;
fallbackAttempts: RuntimeFallbackAttempt[];
didLogHeartbeatStrip: boolean;
autoCompactionCompleted: boolean;
/** Payload keys sent directly (not via pipeline) during tool flush. */
@@ -106,6 +117,7 @@ export async function runAgentTurnWithFallback(params: {
let runResult: Awaited<ReturnType<typeof runEmbeddedPiAgent>>;
let fallbackProvider = params.followupRun.run.provider;
let fallbackModel = params.followupRun.run.model;
let fallbackAttempts: RuntimeFallbackAttempt[] = [];
let didResetAfterCompactionFailure = false;
let didRetryTransientHttpError = false;
@@ -397,6 +409,16 @@ export async function runAgentTurnWithFallback(params: {
runResult = fallbackResult.result;
fallbackProvider = fallbackResult.provider;
fallbackModel = fallbackResult.model;
fallbackAttempts = Array.isArray(fallbackResult.attempts)
? fallbackResult.attempts.map((attempt) => ({
provider: String(attempt.provider ?? ""),
model: String(attempt.model ?? ""),
error: String(attempt.error ?? ""),
reason: attempt.reason ? String(attempt.reason) : undefined,
status: typeof attempt.status === "number" ? attempt.status : undefined,
code: attempt.code ? String(attempt.code) : undefined,
}))
: [];
// Some embedded runs surface context overflow as an error payload instead of throwing.
// Treat those as a session-level failure and auto-recover by starting a fresh session.
@@ -543,9 +565,11 @@ export async function runAgentTurnWithFallback(params: {
return {
kind: "success",
runId,
runResult,
fallbackProvider,
fallbackModel,
fallbackAttempts,
didLogHeartbeatStrip,
autoCompactionCompleted,
directlySentBlockKeys: directlySentBlockKeys.size > 0 ? directlySentBlockKeys : undefined,

View File

@@ -54,6 +54,7 @@ vi.mock("../../agents/model-fallback.js", () => ({
result: await run(provider, model),
provider,
model,
attempts: [],
}),
}));
@@ -508,6 +509,30 @@ describe("runReplyAgent typing (heartbeat)", () => {
expect(onToolResult).not.toHaveBeenCalled();
});
it("retries transient HTTP failures once with timer-driven backoff", async () => {
vi.useFakeTimers();
let calls = 0;
state.runEmbeddedPiAgentMock.mockImplementation(async () => {
calls += 1;
if (calls === 1) {
throw new Error("502 Bad Gateway");
}
return { payloads: [{ text: "final" }], meta: {} };
});
const { run } = createMinimalRun({
typingMode: "message",
});
const runPromise = run();
await vi.advanceTimersByTimeAsync(2_499);
expect(calls).toBe(1);
await vi.advanceTimersByTimeAsync(1);
await runPromise;
expect(calls).toBe(2);
vi.useRealTimers();
});
it("announces auto-compaction in verbose mode and tracks count", async () => {
await withTempStateDir(async (stateDir) => {
const storePath = path.join(stateDir, "sessions", "sessions.json");
@@ -538,12 +563,482 @@ describe("runReplyAgent typing (heartbeat)", () => {
});
});
it("announces model fallback in verbose mode", async () => {
const sessionEntry: SessionEntry = {
sessionId: "session",
updatedAt: Date.now(),
};
const sessionStore = { main: sessionEntry };
state.runEmbeddedPiAgentMock.mockResolvedValueOnce({ payloads: [{ text: "final" }], meta: {} });
const modelFallback = await import("../../agents/model-fallback.js");
vi.spyOn(modelFallback, "runWithModelFallback").mockImplementationOnce(
async ({ run }: { run: (provider: string, model: string) => Promise<unknown> }) => ({
result: await run("deepinfra", "moonshotai/Kimi-K2.5"),
provider: "deepinfra",
model: "moonshotai/Kimi-K2.5",
attempts: [
{
provider: "fireworks",
model: "fireworks/minimax-m2p5",
error: "Provider fireworks is in cooldown (all profiles unavailable)",
reason: "rate_limit",
},
],
}),
);
const { run } = createMinimalRun({
resolvedVerboseLevel: "on",
sessionEntry,
sessionStore,
sessionKey: "main",
});
const res = await run();
expect(Array.isArray(res)).toBe(true);
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("rate limit");
});
it("does not announce model fallback when verbose is off", async () => {
const { onAgentEvent } = await import("../../infra/agent-events.js");
state.runEmbeddedPiAgentMock.mockResolvedValueOnce({ payloads: [{ text: "final" }], meta: {} });
const modelFallback = await import("../../agents/model-fallback.js");
vi.spyOn(modelFallback, "runWithModelFallback").mockImplementationOnce(
async ({ run }: { run: (provider: string, model: string) => Promise<unknown> }) => ({
result: await run("deepinfra", "moonshotai/Kimi-K2.5"),
provider: "deepinfra",
model: "moonshotai/Kimi-K2.5",
attempts: [
{
provider: "fireworks",
model: "fireworks/minimax-m2p5",
error: "Provider fireworks is in cooldown (all profiles unavailable)",
reason: "rate_limit",
},
],
}),
);
const { run } = createMinimalRun({
resolvedVerboseLevel: "off",
});
const phases: string[] = [];
const off = onAgentEvent((evt) => {
const phase = typeof evt.data?.phase === "string" ? evt.data.phase : null;
if (evt.stream === "lifecycle" && phase) {
phases.push(phase);
}
});
const res = await run();
off();
const payload = Array.isArray(res) ? (res[0] as { text?: string }) : (res as { text?: string });
expect(payload.text).not.toContain("Model Fallback:");
expect(phases.filter((phase) => phase === "fallback")).toHaveLength(1);
});
it("announces model fallback only once per active fallback state", async () => {
const { onAgentEvent } = await import("../../infra/agent-events.js");
const sessionEntry: SessionEntry = {
sessionId: "session",
updatedAt: Date.now(),
};
const sessionStore = { main: sessionEntry };
state.runEmbeddedPiAgentMock.mockResolvedValue({
payloads: [{ text: "final" }],
meta: {},
});
const modelFallback = await import("../../agents/model-fallback.js");
const fallbackSpy = vi
.spyOn(modelFallback, "runWithModelFallback")
.mockImplementation(
async ({ run }: { run: (provider: string, model: string) => Promise<unknown> }) => ({
result: await run("deepinfra", "moonshotai/Kimi-K2.5"),
provider: "deepinfra",
model: "moonshotai/Kimi-K2.5",
attempts: [
{
provider: "fireworks",
model: "fireworks/minimax-m2p5",
error: "Provider fireworks is in cooldown (all profiles unavailable)",
reason: "rate_limit",
},
],
}),
);
try {
const { run } = createMinimalRun({
resolvedVerboseLevel: "on",
sessionEntry,
sessionStore,
sessionKey: "main",
});
const fallbackEvents: Array<Record<string, unknown>> = [];
const off = onAgentEvent((evt) => {
if (evt.stream === "lifecycle" && evt.data?.phase === "fallback") {
fallbackEvents.push(evt.data);
}
});
const first = await run();
const second = await run();
off();
const firstText = Array.isArray(first) ? first[0]?.text : first?.text;
const secondText = Array.isArray(second) ? second[0]?.text : second?.text;
expect(firstText).toContain("Model Fallback:");
expect(secondText).not.toContain("Model Fallback:");
expect(fallbackEvents).toHaveLength(1);
} finally {
fallbackSpy.mockRestore();
}
});
it("re-announces model fallback after returning to selected model", async () => {
const sessionEntry: SessionEntry = {
sessionId: "session",
updatedAt: Date.now(),
};
const sessionStore = { main: sessionEntry };
let callCount = 0;
state.runEmbeddedPiAgentMock.mockResolvedValue({
payloads: [{ text: "final" }],
meta: {},
});
const modelFallback = await import("../../agents/model-fallback.js");
const fallbackSpy = vi
.spyOn(modelFallback, "runWithModelFallback")
.mockImplementation(
async ({
provider,
model,
run,
}: {
provider: string;
model: string;
run: (provider: string, model: string) => Promise<unknown>;
}) => {
callCount += 1;
if (callCount === 2) {
return {
result: await run(provider, model),
provider,
model,
attempts: [],
};
}
return {
result: await run("deepinfra", "moonshotai/Kimi-K2.5"),
provider: "deepinfra",
model: "moonshotai/Kimi-K2.5",
attempts: [
{
provider: "fireworks",
model: "fireworks/minimax-m2p5",
error: "Provider fireworks is in cooldown (all profiles unavailable)",
reason: "rate_limit",
},
],
};
},
);
try {
const { run } = createMinimalRun({
resolvedVerboseLevel: "on",
sessionEntry,
sessionStore,
sessionKey: "main",
});
const first = await run();
const second = await run();
const third = await run();
const firstText = Array.isArray(first) ? first[0]?.text : first?.text;
const secondText = Array.isArray(second) ? second[0]?.text : second?.text;
const thirdText = Array.isArray(third) ? third[0]?.text : third?.text;
expect(firstText).toContain("Model Fallback:");
expect(secondText).not.toContain("Model Fallback:");
expect(thirdText).toContain("Model Fallback:");
} finally {
fallbackSpy.mockRestore();
}
});
it("announces fallback-cleared once when runtime returns to selected model", async () => {
const { onAgentEvent } = await import("../../infra/agent-events.js");
const sessionEntry: SessionEntry = {
sessionId: "session",
updatedAt: Date.now(),
};
const sessionStore = { main: sessionEntry };
let callCount = 0;
state.runEmbeddedPiAgentMock.mockResolvedValue({
payloads: [{ text: "final" }],
meta: {},
});
const modelFallback = await import("../../agents/model-fallback.js");
const fallbackSpy = vi
.spyOn(modelFallback, "runWithModelFallback")
.mockImplementation(
async ({
provider,
model,
run,
}: {
provider: string;
model: string;
run: (provider: string, model: string) => Promise<unknown>;
}) => {
callCount += 1;
if (callCount === 1) {
return {
result: await run("deepinfra", "moonshotai/Kimi-K2.5"),
provider: "deepinfra",
model: "moonshotai/Kimi-K2.5",
attempts: [
{
provider: "fireworks",
model: "fireworks/minimax-m2p5",
error: "Provider fireworks is in cooldown (all profiles unavailable)",
reason: "rate_limit",
},
],
};
}
return {
result: await run(provider, model),
provider,
model,
attempts: [],
};
},
);
try {
const { run } = createMinimalRun({
resolvedVerboseLevel: "on",
sessionEntry,
sessionStore,
sessionKey: "main",
});
const phases: string[] = [];
const off = onAgentEvent((evt) => {
const phase = typeof evt.data?.phase === "string" ? evt.data.phase : null;
if (evt.stream === "lifecycle" && phase) {
phases.push(phase);
}
});
const first = await run();
const second = await run();
const third = await run();
off();
const firstText = Array.isArray(first) ? first[0]?.text : first?.text;
const secondText = Array.isArray(second) ? second[0]?.text : second?.text;
const thirdText = Array.isArray(third) ? third[0]?.text : third?.text;
expect(firstText).toContain("Model Fallback:");
expect(secondText).toContain("Model Fallback cleared:");
expect(thirdText).not.toContain("Model Fallback cleared:");
expect(phases.filter((phase) => phase === "fallback")).toHaveLength(1);
expect(phases.filter((phase) => phase === "fallback_cleared")).toHaveLength(1);
} finally {
fallbackSpy.mockRestore();
}
});
it("emits fallback lifecycle events while verbose is off", async () => {
const { onAgentEvent } = await import("../../infra/agent-events.js");
const sessionEntry: SessionEntry = {
sessionId: "session",
updatedAt: Date.now(),
};
const sessionStore = { main: sessionEntry };
let callCount = 0;
state.runEmbeddedPiAgentMock.mockResolvedValue({
payloads: [{ text: "final" }],
meta: {},
});
const modelFallback = await import("../../agents/model-fallback.js");
const fallbackSpy = vi
.spyOn(modelFallback, "runWithModelFallback")
.mockImplementation(
async ({
provider,
model,
run,
}: {
provider: string;
model: string;
run: (provider: string, model: string) => Promise<unknown>;
}) => {
callCount += 1;
if (callCount === 1) {
return {
result: await run("deepinfra", "moonshotai/Kimi-K2.5"),
provider: "deepinfra",
model: "moonshotai/Kimi-K2.5",
attempts: [
{
provider: "fireworks",
model: "fireworks/minimax-m2p5",
error: "Provider fireworks is in cooldown (all profiles unavailable)",
reason: "rate_limit",
},
],
};
}
return {
result: await run(provider, model),
provider,
model,
attempts: [],
};
},
);
try {
const { run } = createMinimalRun({
resolvedVerboseLevel: "off",
sessionEntry,
sessionStore,
sessionKey: "main",
});
const phases: string[] = [];
const off = onAgentEvent((evt) => {
const phase = typeof evt.data?.phase === "string" ? evt.data.phase : null;
if (evt.stream === "lifecycle" && phase) {
phases.push(phase);
}
});
const first = await run();
const second = await run();
off();
const firstText = Array.isArray(first) ? first[0]?.text : first?.text;
const secondText = Array.isArray(second) ? second[0]?.text : second?.text;
expect(firstText).not.toContain("Model Fallback:");
expect(secondText).not.toContain("Model Fallback cleared:");
expect(phases.filter((phase) => phase === "fallback")).toHaveLength(1);
expect(phases.filter((phase) => phase === "fallback_cleared")).toHaveLength(1);
} finally {
fallbackSpy.mockRestore();
}
});
it("backfills fallback reason when fallback is already active", async () => {
const sessionEntry: SessionEntry = {
sessionId: "session",
updatedAt: Date.now(),
fallbackNoticeSelectedModel: "anthropic/claude",
fallbackNoticeActiveModel: "deepinfra/moonshotai/Kimi-K2.5",
modelProvider: "deepinfra",
model: "moonshotai/Kimi-K2.5",
};
const sessionStore = { main: sessionEntry };
state.runEmbeddedPiAgentMock.mockResolvedValue({
payloads: [{ text: "final" }],
meta: {},
});
const modelFallback = await import("../../agents/model-fallback.js");
const fallbackSpy = vi
.spyOn(modelFallback, "runWithModelFallback")
.mockImplementation(
async ({ run }: { run: (provider: string, model: string) => Promise<unknown> }) => ({
result: await run("deepinfra", "moonshotai/Kimi-K2.5"),
provider: "deepinfra",
model: "moonshotai/Kimi-K2.5",
attempts: [
{
provider: "anthropic",
model: "claude",
error: "Provider anthropic is in cooldown (all profiles unavailable)",
reason: "rate_limit",
},
],
}),
);
try {
const { run } = createMinimalRun({
resolvedVerboseLevel: "on",
sessionEntry,
sessionStore,
sessionKey: "main",
});
const res = await run();
const firstText = Array.isArray(res) ? res[0]?.text : res?.text;
expect(firstText).not.toContain("Model Fallback:");
expect(sessionEntry.fallbackNoticeReason).toBe("rate limit");
} finally {
fallbackSpy.mockRestore();
}
});
it("refreshes fallback reason summary while fallback stays active", async () => {
const sessionEntry: SessionEntry = {
sessionId: "session",
updatedAt: Date.now(),
fallbackNoticeSelectedModel: "anthropic/claude",
fallbackNoticeActiveModel: "deepinfra/moonshotai/Kimi-K2.5",
fallbackNoticeReason: "rate limit",
modelProvider: "deepinfra",
model: "moonshotai/Kimi-K2.5",
};
const sessionStore = { main: sessionEntry };
state.runEmbeddedPiAgentMock.mockResolvedValue({
payloads: [{ text: "final" }],
meta: {},
});
const modelFallback = await import("../../agents/model-fallback.js");
const fallbackSpy = vi
.spyOn(modelFallback, "runWithModelFallback")
.mockImplementation(
async ({ run }: { run: (provider: string, model: string) => Promise<unknown> }) => ({
result: await run("deepinfra", "moonshotai/Kimi-K2.5"),
provider: "deepinfra",
model: "moonshotai/Kimi-K2.5",
attempts: [
{
provider: "anthropic",
model: "claude",
error: "Provider anthropic is in cooldown (all profiles unavailable)",
reason: "timeout",
},
],
}),
);
try {
const { run } = createMinimalRun({
resolvedVerboseLevel: "on",
sessionEntry,
sessionStore,
sessionKey: "main",
});
const res = await run();
const firstText = Array.isArray(res) ? res[0]?.text : res?.text;
expect(firstText).not.toContain("Model Fallback:");
expect(sessionEntry.fallbackNoticeReason).toBe("timeout");
} finally {
fallbackSpy.mockRestore();
}
});
it("retries after compaction failure by resetting the session", async () => {
await withTempStateDir(async (stateDir) => {
const sessionId = "session";
const storePath = path.join(stateDir, "sessions", "sessions.json");
const transcriptPath = sessions.resolveSessionTranscriptPath(sessionId);
const sessionEntry = { sessionId, updatedAt: Date.now(), sessionFile: transcriptPath };
const sessionEntry = {
sessionId,
updatedAt: Date.now(),
sessionFile: transcriptPath,
fallbackNoticeSelectedModel: "fireworks/minimax-m2p5",
fallbackNoticeActiveModel: "deepinfra/moonshotai/Kimi-K2.5",
fallbackNoticeReason: "rate limit",
};
const sessionStore = { main: sessionEntry };
await fs.mkdir(path.dirname(storePath), { recursive: true });
@@ -575,9 +1070,15 @@ describe("runReplyAgent typing (heartbeat)", () => {
}
expect(payload.text?.toLowerCase()).toContain("reset");
expect(sessionStore.main.sessionId).not.toBe(sessionId);
expect(sessionStore.main.fallbackNoticeSelectedModel).toBeUndefined();
expect(sessionStore.main.fallbackNoticeActiveModel).toBeUndefined();
expect(sessionStore.main.fallbackNoticeReason).toBeUndefined();
const persisted = JSON.parse(await fs.readFile(storePath, "utf-8"));
expect(persisted.main.sessionId).toBe(sessionStore.main.sessionId);
expect(persisted.main.fallbackNoticeSelectedModel).toBeUndefined();
expect(persisted.main.fallbackNoticeActiveModel).toBeUndefined();
expect(persisted.main.fallbackNoticeReason).toBeUndefined();
});
});

View File

@@ -15,10 +15,16 @@ import {
updateSessionStoreEntry,
} from "../../config/sessions.js";
import type { TypingMode } from "../../config/types.js";
import { emitAgentEvent } from "../../infra/agent-events.js";
import { emitDiagnosticEvent, isDiagnosticsEnabled } from "../../infra/diagnostic-events.js";
import { enqueueSystemEvent } from "../../infra/system-events.js";
import { defaultRuntime } from "../../runtime.js";
import { estimateUsageCost, resolveModelCostConfig } from "../../utils/usage-format.js";
import {
buildFallbackClearedNotice,
buildFallbackNotice,
resolveFallbackTransition,
} from "../fallback-state.js";
import type { OriginatingChannelType, TemplateContext } from "../templating.js";
import { resolveResponseUsageMode, type VerboseLevel } from "../thinking.js";
import type { GetReplyOptions, ReplyPayload } from "../types.js";
@@ -290,6 +296,9 @@ export async function runReplyAgent(params: {
updatedAt: Date.now(),
systemSent: false,
abortedLastRun: false,
fallbackNoticeSelectedModel: undefined,
fallbackNoticeActiveModel: undefined,
fallbackNoticeReason: undefined,
};
const agentId = resolveAgentIdFromSessionKey(sessionKey);
const nextSessionFile = resolveSessionTranscriptPath(
@@ -373,7 +382,14 @@ export async function runReplyAgent(params: {
return finalizeWithFollowup(runOutcome.payload, queueKey, runFollowupTurn);
}
const { runResult, fallbackProvider, fallbackModel, directlySentBlockKeys } = runOutcome;
const {
runId,
runResult,
fallbackProvider,
fallbackModel,
fallbackAttempts,
directlySentBlockKeys,
} = runOutcome;
let { didLogHeartbeatStrip, autoCompactionCompleted } = runOutcome;
if (
@@ -414,6 +430,42 @@ export async function runReplyAgent(params: {
const modelUsed = runResult.meta?.agentMeta?.model ?? fallbackModel ?? defaultModel;
const providerUsed =
runResult.meta?.agentMeta?.provider ?? fallbackProvider ?? followupRun.run.provider;
const verboseEnabled = resolvedVerboseLevel !== "off";
const selectedProvider = followupRun.run.provider;
const selectedModel = followupRun.run.model;
const fallbackStateEntry =
activeSessionEntry ?? (sessionKey ? activeSessionStore?.[sessionKey] : undefined);
const fallbackTransition = resolveFallbackTransition({
selectedProvider,
selectedModel,
activeProvider: providerUsed,
activeModel: modelUsed,
attempts: fallbackAttempts,
state: fallbackStateEntry,
});
if (fallbackTransition.stateChanged) {
if (fallbackStateEntry) {
fallbackStateEntry.fallbackNoticeSelectedModel = fallbackTransition.nextState.selectedModel;
fallbackStateEntry.fallbackNoticeActiveModel = fallbackTransition.nextState.activeModel;
fallbackStateEntry.fallbackNoticeReason = fallbackTransition.nextState.reason;
fallbackStateEntry.updatedAt = Date.now();
activeSessionEntry = fallbackStateEntry;
}
if (sessionKey && fallbackStateEntry && activeSessionStore) {
activeSessionStore[sessionKey] = fallbackStateEntry;
}
if (sessionKey && storePath) {
await updateSessionStoreEntry({
storePath,
sessionKey,
update: async () => ({
fallbackNoticeSelectedModel: fallbackTransition.nextState.selectedModel,
fallbackNoticeActiveModel: fallbackTransition.nextState.activeModel,
fallbackNoticeReason: fallbackTransition.nextState.reason,
}),
});
}
}
const cliSessionId = isCliProvider(providerUsed, cfg)
? runResult.meta?.agentMeta?.sessionId?.trim()
: undefined;
@@ -546,9 +598,68 @@ export async function runReplyAgent(params: {
}
}
// If verbose is enabled and this is a new session, prepend a session hint.
// If verbose is enabled, prepend operational run notices.
let finalPayloads = guardedReplyPayloads;
const verboseEnabled = resolvedVerboseLevel !== "off";
const verboseNotices: ReplyPayload[] = [];
if (verboseEnabled && activeIsNewSession) {
verboseNotices.push({ text: `🧭 New session: ${followupRun.run.sessionId}` });
}
if (fallbackTransition.fallbackTransitioned) {
emitAgentEvent({
runId,
sessionKey,
stream: "lifecycle",
data: {
phase: "fallback",
selectedProvider,
selectedModel,
activeProvider: providerUsed,
activeModel: modelUsed,
reasonSummary: fallbackTransition.reasonSummary,
attemptSummaries: fallbackTransition.attemptSummaries,
attempts: fallbackAttempts,
},
});
if (verboseEnabled) {
const fallbackNotice = buildFallbackNotice({
selectedProvider,
selectedModel,
activeProvider: providerUsed,
activeModel: modelUsed,
attempts: fallbackAttempts,
});
if (fallbackNotice) {
verboseNotices.push({ text: fallbackNotice });
}
}
}
if (fallbackTransition.fallbackCleared) {
emitAgentEvent({
runId,
sessionKey,
stream: "lifecycle",
data: {
phase: "fallback_cleared",
selectedProvider,
selectedModel,
activeProvider: providerUsed,
activeModel: modelUsed,
previousActiveModel: fallbackTransition.previousState.activeModel,
},
});
if (verboseEnabled) {
verboseNotices.push({
text: buildFallbackClearedNotice({
selectedProvider,
selectedModel,
previousActiveModel: fallbackTransition.previousState.activeModel,
}),
});
}
}
if (autoCompactionCompleted) {
const count = await incrementRunCompactionCount({
sessionEntry: activeSessionEntry,
@@ -578,11 +689,11 @@ export async function runReplyAgent(params: {
if (verboseEnabled) {
const suffix = typeof count === "number" ? ` (count ${count})` : "";
finalPayloads = [{ text: `🧹 Auto-compaction complete${suffix}.` }, ...finalPayloads];
verboseNotices.push({ text: `🧹 Auto-compaction complete${suffix}.` });
}
}
if (verboseEnabled && activeIsNewSession) {
finalPayloads = [{ text: `🧭 New session: ${followupRun.run.sessionId}` }, ...finalPayloads];
if (verboseNotices.length > 0) {
finalPayloads = [...verboseNotices, ...finalPayloads];
}
if (responseUsageLine) {
finalPayloads = appendUsageLine(finalPayloads, responseUsageLine);

View File

@@ -19,6 +19,7 @@ import {
} from "../../infra/provider-usage.js";
import type { MediaUnderstandingDecision } from "../../media-understanding/types.js";
import { normalizeGroupActivation } from "../group-activation.js";
import { resolveSelectedAndActiveModel } from "../model-runtime.js";
import { buildStatusMessage } from "../status.js";
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "../thinking.js";
import type { ReplyPayload } from "../types.js";
@@ -136,6 +137,25 @@ export async function buildStatusReply(params: {
const groupActivation = isGroup
? (normalizeGroupActivation(sessionEntry?.groupActivation) ?? defaultGroupActivation())
: undefined;
const modelRefs = resolveSelectedAndActiveModel({
selectedProvider: provider,
selectedModel: model,
sessionEntry,
});
const selectedModelAuth = resolveModelAuthLabel({
provider,
cfg,
sessionEntry,
agentDir: statusAgentDir,
});
const activeModelAuth = modelRefs.activeDiffers
? resolveModelAuthLabel({
provider: modelRefs.active.provider,
cfg,
sessionEntry,
agentDir: statusAgentDir,
})
: selectedModelAuth;
const agentDefaults = cfg.agents?.defaults ?? {};
const statusText = buildStatusMessage({
config: cfg,
@@ -160,12 +180,8 @@ export async function buildStatusReply(params: {
resolvedVerbose: resolvedVerboseLevel,
resolvedReasoning: resolvedReasoningLevel,
resolvedElevated: resolvedElevatedLevel,
modelAuth: resolveModelAuthLabel({
provider,
cfg,
sessionEntry,
agentDir: statusAgentDir,
}),
modelAuth: selectedModelAuth,
activeModelAuth,
usageLine: usageLine ?? undefined,
queue: {
mode: queueSettings.mode,

View File

@@ -106,6 +106,7 @@ export async function handleDirectiveOnly(
allowedModelCatalog,
resetModelOverride,
surface: params.surface,
sessionEntry,
});
if (modelInfo) {
return modelInfo;

View File

@@ -63,6 +63,32 @@ describe("/model chat UX", () => {
expect(reply?.text).toContain("Switch: /model <provider/model>");
});
it("shows active runtime model when different from selected model", async () => {
const directives = parseInlineDirectives("/model");
const cfg = { commands: { text: true } } as unknown as OpenClawConfig;
const reply = await maybeHandleModelDirectiveInfo({
directives,
cfg,
agentDir: "/tmp/agent",
activeAgentId: "main",
provider: "fireworks",
model: "fireworks/minimax-m2p5",
defaultProvider: "fireworks",
defaultModel: "fireworks/minimax-m2p5",
aliasIndex: baseAliasIndex(),
allowedModelCatalog: [],
resetModelOverride: false,
sessionEntry: {
modelProvider: "deepinfra",
model: "moonshotai/Kimi-K2.5",
},
});
expect(reply?.text).toContain("Current: fireworks/minimax-m2p5 (selected)");
expect(reply?.text).toContain("Active: deepinfra/moonshotai/Kimi-K2.5 (runtime)");
});
it("auto-applies closest match for typos", () => {
const directives = parseInlineDirectives("/model anthropic/claud-opus-4-5");
const cfg = { commands: { text: true } } as unknown as OpenClawConfig;

View File

@@ -7,8 +7,10 @@ import {
resolveModelRefFromString,
} from "../../agents/model-selection.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { SessionEntry } from "../../config/sessions.js";
import { buildBrowseProvidersButton } from "../../telegram/model-buttons.js";
import { shortenHomePath } from "../../utils.js";
import { resolveSelectedAndActiveModel } from "../model-runtime.js";
import type { ReplyPayload } from "../types.js";
import { resolveModelsCommandReply } from "./commands-models.js";
import {
@@ -198,6 +200,7 @@ export async function maybeHandleModelDirectiveInfo(params: {
allowedModelCatalog: Array<{ provider: string; id?: string; name?: string }>;
resetModelOverride: boolean;
surface?: string;
sessionEntry?: Pick<SessionEntry, "modelProvider" | "model">;
}): Promise<ReplyPayload | undefined> {
if (!params.directives.hasModelDirective) {
return undefined;
@@ -233,31 +236,45 @@ export async function maybeHandleModelDirectiveInfo(params: {
}
if (wantsSummary) {
const current = `${params.provider}/${params.model}`;
const modelRefs = resolveSelectedAndActiveModel({
selectedProvider: params.provider,
selectedModel: params.model,
sessionEntry: params.sessionEntry,
});
const current = modelRefs.selected.label;
const isTelegram = params.surface === "telegram";
const activeRuntimeLine = modelRefs.activeDiffers
? `Active: ${modelRefs.active.label} (runtime)`
: null;
if (isTelegram) {
const buttons = buildBrowseProvidersButton();
return {
text: [
`Current: ${current}`,
`Current: ${current}${modelRefs.activeDiffers ? " (selected)" : ""}`,
activeRuntimeLine,
"",
"Tap below to browse models, or use:",
"/model <provider/model> to switch",
"/model status for details",
].join("\n"),
]
.filter(Boolean)
.join("\n"),
channelData: { telegram: { buttons } },
};
}
return {
text: [
`Current: ${current}`,
`Current: ${current}${modelRefs.activeDiffers ? " (selected)" : ""}`,
activeRuntimeLine,
"",
"Switch: /model <provider/model>",
"Browse: /models (providers) or /models <provider> (models)",
"More: /model status",
].join("\n"),
]
.filter(Boolean)
.join("\n"),
};
}
@@ -284,14 +301,20 @@ export async function maybeHandleModelDirectiveInfo(params: {
authByProvider.set(provider, formatAuthLabel(auth));
}
const current = `${params.provider}/${params.model}`;
const modelRefs = resolveSelectedAndActiveModel({
selectedProvider: params.provider,
selectedModel: params.model,
sessionEntry: params.sessionEntry,
});
const current = modelRefs.selected.label;
const defaultLabel = `${params.defaultProvider}/${params.defaultModel}`;
const lines = [
`Current: ${current}`,
`Current: ${current}${modelRefs.activeDiffers ? " (selected)" : ""}`,
modelRefs.activeDiffers ? `Active: ${modelRefs.active.label} (runtime)` : null,
`Default: ${defaultLabel}`,
`Agent: ${params.activeAgentId}`,
`Auth file: ${formatPath(resolveAuthStorePathForDisplay(params.agentDir))}`,
];
].filter((line): line is string => Boolean(line));
if (params.resetModelOverride) {
lines.push(`(previous selection reset to default)`);
}