diff --git a/src/tui/tui-event-handlers.test.ts b/src/tui/tui-event-handlers.test.ts index 279c8d325fe..9aeb997de1a 100644 --- a/src/tui/tui-event-handlers.test.ts +++ b/src/tui/tui-event-handlers.test.ts @@ -213,6 +213,82 @@ describe("tui-event-handlers: handleAgentEvent", () => { expect(tui.requestRender).toHaveBeenCalledTimes(1); }); + it("updates the displayed model from fallback lifecycle steps", () => { + const { state, tui, handleAgentEvent } = createHandlersHarness({ + state: { + activeChatRunId: "run-fallback", + sessionInfo: { + verboseLevel: "on", + modelProvider: "llamaforge", + model: "qwen/qwen3.5-9b", + }, + }, + }); + + handleAgentEvent({ + runId: "run-fallback", + stream: "lifecycle", + data: { + phase: "fallback_step", + fallbackStepFinalOutcome: "next_fallback", + fallbackStepFromModel: "openai-codex/gpt-5.5", + fallbackStepToModel: "openrouter/meta-llama/llama-3.1-70b", + }, + }); + + expect(state.sessionInfo.modelProvider).toBe("openrouter"); + expect(state.sessionInfo.model).toBe("meta-llama/llama-3.1-70b"); + expect(tui.requestRender).toHaveBeenCalled(); + }); + + it("accepts fallback model updates for the pending run before chat registration", () => { + const { state, tui, handleAgentEvent } = createHandlersHarness({ + state: { + activeChatRunId: null, + pendingChatRunId: "run-pending", + sessionInfo: { + verboseLevel: "on", + modelProvider: "llamaforge", + model: "qwen/qwen3.5-9b", + }, + }, + }); + + handleAgentEvent({ + runId: "run-pending", + stream: "lifecycle", + data: { + phase: "fallback_step", + fallbackStepFinalOutcome: "succeeded", + fallbackStepFromModel: "openrouter/meta-llama/llama-3.1-70b", + fallbackStepToModel: "nvidia/deepseek-ai/deepseek-v3.2", + }, + }); + + expect(state.sessionInfo.modelProvider).toBe("nvidia"); + expect(state.sessionInfo.model).toBe("deepseek-ai/deepseek-v3.2"); + expect(tui.requestRender).toHaveBeenCalled(); + }); + + it("ignores fallback model updates for unrelated runs", () => { + const { state, tui, handleAgentEvent } = createHandlersHarness({ + state: { + activeChatRunId: "run-active", + sessionInfo: { verboseLevel: "on", modelProvider: "openai", model: "gpt-5.5" }, + }, + }); + + handleAgentEvent({ + runId: "run-other", + stream: "lifecycle", + data: { phase: "fallback_step", fallbackStepToModel: "openrouter/other-model" }, + }); + + expect(state.sessionInfo.modelProvider).toBe("openai"); + expect(state.sessionInfo.model).toBe("gpt-5.5"); + expect(tui.requestRender).not.toHaveBeenCalled(); + }); + it("captures runId from chat events when activeChatRunId is unset", () => { const { state, chatLog, handleChatEvent, handleAgentEvent } = createHandlersHarness({ state: { activeChatRunId: null }, diff --git a/src/tui/tui-event-handlers.ts b/src/tui/tui-event-handlers.ts index 852a6b24887..619e68cb378 100644 --- a/src/tui/tui-event-handlers.ts +++ b/src/tui/tui-event-handlers.ts @@ -191,6 +191,36 @@ export function createEventHandlers(context: EventHandlerContext) { : "auth or provider access failed for the current provider. Run /auth to refresh credentials; if you already re-authed, switch models/providers because this account may still be blocked for inference."; }; + const parseProviderModelRef = ( + modelRef: unknown, + ): { provider: string; model: string } | undefined => { + if (typeof modelRef !== "string") { + return undefined; + } + const trimmed = modelRef.trim(); + const separator = trimmed.indexOf("/"); + if (separator <= 0 || separator >= trimmed.length - 1) { + return undefined; + } + const provider = trimmed.slice(0, separator).trim(); + const model = trimmed.slice(separator + 1).trim(); + return provider && model ? { provider, model } : undefined; + }; + + const applyFallbackStepModelUpdate = (evt: AgentEvent): boolean => { + const data = evt.data ?? {}; + if (evt.stream !== "lifecycle" || asString(data.phase, "") !== "fallback_step") { + return false; + } + const target = parseProviderModelRef(data.fallbackStepToModel); + if (!target) { + return false; + } + state.sessionInfo.modelProvider = target.provider; + state.sessionInfo.model = target.model; + return true; + }; + const noteSessionRun = (runId: string) => { sessionRuns.set(runId, Date.now()); pruneRunMap(sessionRuns); @@ -471,7 +501,16 @@ export function createEventHandlers(context: EventHandlerContext) { // active chat run id, not the session id. Tool results can arrive after the chat // final event, so accept finalized runs for tool updates. const isActiveRun = evt.runId === state.activeChatRunId; - const isKnownRun = isActiveRun || sessionRuns.has(evt.runId) || finalizedRuns.has(evt.runId); + const isPendingRun = evt.runId === state.pendingChatRunId; + const isSessionRun = sessionRuns.has(evt.runId); + if ((isActiveRun || isPendingRun || isSessionRun) && applyFallbackStepModelUpdate(evt)) { + if (isActiveRun) { + armStreamingWatchdog(evt.runId); + } + tui.requestRender(); + return; + } + const isKnownRun = isActiveRun || isSessionRun || finalizedRuns.has(evt.runId); if (!isKnownRun) { return; }