fix(auto-reply): emit fallback lifecycle events with verbose off

This commit is contained in:
Gustavo Madeira Santana
2026-02-19 03:01:26 -05:00
parent 6d9ecdf432
commit e4251b0d25
3 changed files with 112 additions and 19 deletions

View File

@@ -12,7 +12,7 @@ Docs: https://docs.openclaw.ai
- iOS/Watch: add an Apple Watch companion MVP with watch inbox UI, watch notification relay handling, and gateway command surfaces for watch status/send flows. (#20054) Thanks @mbelinky.
- Gateway/CLI: add paired-device hygiene flows with `device.pair.remove`, plus `openclaw devices remove` and guarded `openclaw devices clear --yes [--pending]` commands for removing paired entries and optionally rejecting pending requests. (#20057) Thanks @mbelinky.
- Skills: harden coding-agent skill guidance by removing shell-command examples that interpolate untrusted issue text directly into command strings.
- Auto-reply/UI: add model fallback lifecycle visibility in verbose logs, /status active-model context with fallback reason, and cohesive WebUI fallback indicators. Thanks @joshavant.
- Auto-reply/UI: add model fallback lifecycle visibility in verbose logs, /status active-model context with fallback reason, and cohesive WebUI fallback indicators. (#20704) thanks @joshavant.
### Fixes

View File

@@ -578,6 +578,7 @@ describe("runReplyAgent typing (heartbeat)", () => {
});
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(
@@ -599,9 +600,18 @@ describe("runReplyAgent typing (heartbeat)", () => {
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 () => {
@@ -814,6 +824,85 @@ describe("runReplyAgent typing (heartbeat)", () => {
}
});
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",

View File

@@ -606,7 +606,7 @@ export async function runReplyAgent(params: {
verboseNotices.push({ text: `🧭 New session: ${followupRun.run.sessionId}` });
}
if (verboseEnabled && fallbackTransition.fallbackTransitioned) {
if (fallbackTransition.fallbackTransitioned) {
emitAgentEvent({
runId,
sessionKey,
@@ -622,18 +622,20 @@ export async function runReplyAgent(params: {
attempts: fallbackAttempts,
},
});
const fallbackNotice = buildFallbackNotice({
selectedProvider,
selectedModel,
activeProvider: providerUsed,
activeModel: modelUsed,
attempts: fallbackAttempts,
});
if (fallbackNotice) {
verboseNotices.push({ text: fallbackNotice });
if (verboseEnabled) {
const fallbackNotice = buildFallbackNotice({
selectedProvider,
selectedModel,
activeProvider: providerUsed,
activeModel: modelUsed,
attempts: fallbackAttempts,
});
if (fallbackNotice) {
verboseNotices.push({ text: fallbackNotice });
}
}
}
if (verboseEnabled && fallbackTransition.fallbackCleared) {
if (fallbackTransition.fallbackCleared) {
emitAgentEvent({
runId,
sessionKey,
@@ -647,13 +649,15 @@ export async function runReplyAgent(params: {
previousActiveModel: fallbackTransition.previousState.activeModel,
},
});
verboseNotices.push({
text: buildFallbackClearedNotice({
selectedProvider,
selectedModel,
previousActiveModel: fallbackTransition.previousState.activeModel,
}),
});
if (verboseEnabled) {
verboseNotices.push({
text: buildFallbackClearedNotice({
selectedProvider,
selectedModel,
previousActiveModel: fallbackTransition.previousState.activeModel,
}),
});
}
}
if (autoCompactionCompleted) {