mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-03 03:03:24 -04:00
fix(auto-reply): emit fallback lifecycle events with verbose off
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user