diff --git a/.changeset/dynamic-model-footer.md b/.changeset/dynamic-model-footer.md new file mode 100644 index 000000000..436825c83 --- /dev/null +++ b/.changeset/dynamic-model-footer.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Show the model selected by a dynamic resolver beside `dynamic model` in the TUI footer. Update it as each model step starts and clear the previous selection when starting a new turn or resetting the session. diff --git a/docs/guides/evaluate.md b/docs/guides/evaluate.md index 970eb43a2..7805ce700 100644 --- a/docs/guides/evaluate.md +++ b/docs/guides/evaluate.md @@ -37,7 +37,8 @@ does not add a TypeSafe credential or transport layer. During `eve dev`, a Gateway evaluator uses the same connection selected through `/login` as Gateway language models. A configured AI SDK default provider still owns string model resolution during development. The TUI footer displays `dynamic model` when the -agent uses `autoModel`. +agent uses `autoModel`, then adds the resolved model for the current turn, such as +`dynamic model · openai/gpt-5.6-luna`. ## Use a provider directly diff --git a/packages/eve/src/cli/dev/tui/runner.test.ts b/packages/eve/src/cli/dev/tui/runner.test.ts index 7ec2d2f17..ba283ff57 100644 --- a/packages/eve/src/cli/dev/tui/runner.test.ts +++ b/packages/eve/src/cli/dev/tui/runner.test.ts @@ -955,6 +955,8 @@ describe("EveTUIRunner idle session follow", () => { try { await vi.advanceTimersByTimeAsync(130_000); expect(connections).toBeGreaterThanOrEqual(8); + expect(idleEvents).toContainEqual({ type: "turn-start", turnId: "wake-turn" }); + expect(idleEvents).toContainEqual({ type: "step-start", modelId: "test-model" }); expect(idleEvents).toContainEqual({ type: "assistant-complete", id: "text:wake-turn:0", @@ -2503,6 +2505,10 @@ describe("EveTUIRunner reused step indexes", () => { event.type === "assistant-complete" && event.text !== undefined ? [event.text] : [], ), ).toEqual(["First answer.", "Follow-up answer."]); + expect(emitted.filter((event) => event.type === "turn-start")).toEqual([ + { type: "turn-start", turnId: "turn_0" }, + { type: "turn-start", turnId: "turn_1" }, + ]); }); it("renders the post-subagent message that the harness emits under a reused stepIndex", async () => { diff --git a/packages/eve/src/cli/dev/tui/runner.ts b/packages/eve/src/cli/dev/tui/runner.ts index e46eeb050..dfd2ebf86 100644 --- a/packages/eve/src/cli/dev/tui/runner.ts +++ b/packages/eve/src/cli/dev/tui/runner.ts @@ -170,7 +170,8 @@ export type AgentTUIStreamUsage = { }; export type AgentTUIStreamEvent = - | { type: "step-start" } + | { type: "turn-start"; turnId: string } + | { type: "step-start"; modelId?: string } | { type: "step-finish"; usage?: AgentTUIStreamUsage } | { type: "assistant-delta"; id: string; delta: string } | { type: "assistant-complete"; id: string; text?: string | null } @@ -2231,11 +2232,12 @@ async function* eveEventsToTUIStream( // boundary then no-ops instead of hitting the next turn. if (event.data.turnId !== turnState.turnId) visibleTurnCompleted = false; turnState.turnId = event.data.turnId; + yield { type: "turn-start", turnId: event.data.turnId }; break; case "step.started": stepEpoch += 1; - yield { type: "step-start" }; + yield { type: "step-start", modelId: event.data.modelId }; break; case "step.completed": { diff --git a/packages/eve/src/cli/dev/tui/terminal-renderer.test.ts b/packages/eve/src/cli/dev/tui/terminal-renderer.test.ts index 828d488ff..da3654bbc 100644 --- a/packages/eve/src/cli/dev/tui/terminal-renderer.test.ts +++ b/packages/eve/src/cli/dev/tui/terminal-renderer.test.ts @@ -27,6 +27,29 @@ function streamOf(events: AgentTUIStreamEvent[]): AgentTUIStreamResult { }; } +function agentInfoWithDynamicModel(): AgentInfoResult { + const info = createTestAgentInfoResult({ name: "Weather Agent" }); + return { + ...info, + agent: { + ...info.agent, + model: { + routing: { + kind: "dynamic", + resolver: { + eventNames: ["step.started"], + slug: "model", + logicalPath: "agent.ts", + owner: { kind: "application" }, + sourceId: "agent-model", + sourceKind: "module", + }, + }, + }, + }, + }; +} + function makeRenderer(columns = 80, rows = 30) { const screen = new MockScreen({ columns, rows }); const input = new MockUserInput(); @@ -5356,28 +5379,7 @@ describe("TerminalRenderer status line", () => { const header = { name: "Weather Agent", serverUrl: "http://localhost:3000", info }; renderer.renderAgentHeader(header); const prompt = renderer.readPrompt(); - renderer.renderAgentHeader({ - ...header, - info: { - ...info, - agent: { - ...info.agent, - model: { - routing: { - kind: "dynamic", - resolver: { - eventNames: ["step.started"], - slug: "model", - logicalPath: "agent.ts", - owner: { kind: "application" }, - sourceId: "agent-model", - sourceKind: "module", - }, - }, - }, - }, - }, - }); + renderer.renderAgentHeader({ ...header, info: agentInfoWithDynamicModel() }); const lines = screen.snapshot().split("\n"); const promptRow = lines.findIndex((line) => line.includes("❯")); expect(promptRow).toBeGreaterThan(-1); @@ -5388,9 +5390,94 @@ describe("TerminalRenderer status line", () => { input.type("done"); input.enter(); await prompt; + await renderer.renderStream( + { + events: (async function* (): AsyncIterable { + yield { type: "step-start", modelId: "openai/gpt-5.6-luna" }; + expect(screen.snapshot()).toContain("dynamic model · openai/gpt-5.6-luna"); + yield { type: "step-start", modelId: "openai/gpt-5.6-sol" }; + expect(screen.snapshot()).toContain("dynamic model · openai/gpt-5.6-sol"); + expect(screen.snapshot()).not.toContain("openai/gpt-5.6-luna"); + yield { type: "finish" }; + })(), + }, + { submittedPrompt: "hi", continueSession: true }, + ); + expect(screen.snapshot()).toContain("dynamic model · openai/gpt-5.6-sol"); + await renderer.renderStream( + { + events: (async function* (): AsyncIterable { + yield { type: "turn-start", turnId: "next-turn" }; + expect(screen.snapshot()).toContain("dynamic model"); + expect(screen.snapshot()).not.toContain("openai/gpt-5.6-sol"); + yield { type: "step-start", modelId: "openai/gpt-5.6-luna" }; + yield { type: "finish" }; + })(), + }, + { submittedPrompt: "hello again", continueSession: true }, + ); + expect(screen.snapshot()).toContain("dynamic model · openai/gpt-5.6-luna"); + renderer.renderSessionBoundary(); + expect(screen.snapshot()).toContain("dynamic model"); + expect(screen.snapshot()).not.toContain("openai/gpt-5.6-luna"); renderer.shutdown(); }); + it("clears an idle turn's model before resolution and preserves same-turn continuations", async () => { + const { screen, renderer } = makeRenderer(); + renderer.renderAgentHeader({ + name: "Weather Agent", + serverUrl: "http://localhost:3000", + info: agentInfoWithDynamicModel(), + }); + await renderer.renderIdleStream( + streamOf([ + { type: "turn-start", turnId: "first" }, + { type: "step-start", modelId: "openai/gpt-5.6-sol" }, + ]), + ); + expect(screen.snapshot()).toContain("dynamic model · openai/gpt-5.6-sol"); + await renderer.renderIdleStream(streamOf([{ type: "turn-start", turnId: "first" }])); + expect(screen.snapshot()).toContain("dynamic model · openai/gpt-5.6-sol"); + await renderer.renderIdleStream( + streamOf([ + { type: "turn-start", turnId: "wake" }, + { type: "error", errorText: "Model selection failed" }, + ]), + ); + expect(screen.snapshot()).toContain("dynamic model"); + expect(screen.snapshot()).not.toContain("openai/gpt-5.6-sol"); + expect(screen.snapshot()).toContain("Model selection failed"); + renderer.shutdown(); + }); + + it.each([null, 42, {}, "x".repeat(10_000)])( + "bounds model display data without interrupting the stream (%#)", + async (modelId) => { + const { screen, renderer } = makeRenderer(320); + renderer.renderAgentHeader({ + name: "Weather Agent", + serverUrl: "http://localhost:3000", + info: agentInfoWithDynamicModel(), + }); + await renderer.renderIdleStream( + streamOf([ + { type: "step-start", modelId } as AgentTUIStreamEvent, + { type: "assistant-complete", id: "answer", text: "Hello Alice." }, + ]), + ); + expect(screen.snapshot()).toContain("Hello Alice."); + expect(screen.snapshot()).toContain("dynamic model"); + if (typeof modelId === "string") { + expect(screen.snapshot()).toContain("x".repeat(256)); + expect(screen.snapshot()).not.toContain("x".repeat(257)); + } else { + expect(screen.snapshot()).not.toContain("dynamic model ·"); + } + renderer.shutdown(); + }, + ); + it("suppresses the status line while a setup flow panel is open", () => { const { screen, renderer } = makeRenderer(); renderer.renderNotice("anchor"); diff --git a/packages/eve/src/cli/dev/tui/terminal-renderer.ts b/packages/eve/src/cli/dev/tui/terminal-renderer.ts index 2b9525d07..dbda6ba63 100644 --- a/packages/eve/src/cli/dev/tui/terminal-renderer.ts +++ b/packages/eve/src/cli/dev/tui/terminal-renderer.ts @@ -550,6 +550,8 @@ export class TerminalRenderer implements AgentTUIRenderer { #setupFlow?: SetupFlowState; /** The clearable setup attention line (`⚠ … · /deploy`), rendered in the live footer. */ #setupAttention?: string; + #resolvedModelId?: string; + #modelTurnId?: string; /** * The pinned todo panel above the input, replaced wholesale by each `todo` * tool-call input. Cleared (and committed to the transcript) once every @@ -669,6 +671,12 @@ export class TerminalRenderer implements AgentTUIRenderer { renderAgentHeader(options: AgentHeaderOptions): void { this.#startupHeader = undefined; this.#title = options.name; + if ( + this.#agentHeader?.info?.agent.model.routing.kind !== "dynamic" || + options.info?.agent.model.routing.kind !== "dynamic" + ) { + this.#resolvedModelId = undefined; + } this.#agentHeader = options; this.#start(); const body = this.#renderAgentHeaderRows().join("\n"); @@ -1763,6 +1771,8 @@ export class TerminalRenderer implements AgentTUIRenderer { * not count across a cut), tool-call ownership maps, and the turn clock. */ #clearConversationState(): void { + this.#resolvedModelId = undefined; + this.#modelTurnId = undefined; this.#childToolCallIds.clear(); this.#parentToolBlockIds.clear(); this.#subagentHeaders.clear(); @@ -3597,6 +3607,23 @@ export class TerminalRenderer implements AgentTUIRenderer { turnState: RenderTurnState, ): void { switch (event.type) { + case "turn-start": + if (event.turnId !== this.#modelTurnId) { + this.#modelTurnId = event.turnId; + this.#resolvedModelId = undefined; + this.#paint(); + } + break; + + case "step-start": + this.#resolvedModelId = + typeof event.modelId === "string" + ? stripTerminalControls(event.modelId.slice(0, 256)).replace(/\s+/gu, " ").trim() || + undefined + : undefined; + this.#paint(); + break; + case "step-finish": // Step usage reports are per-step deltas (extractStepUsage in the // harness), so summing them yields true session totals. The @@ -4533,8 +4560,12 @@ export class TerminalRenderer implements AgentTUIRenderer { } if (this.#logLevelHintActive) input.logLevel = this.#logs; const agentModel = this.#agentHeader?.info?.agent.model; - if (agentModel?.routing.kind === "dynamic") input.model = "dynamic model"; - else if (agentModel?.id !== undefined) input.model = agentModel.id; + if (agentModel?.routing.kind === "dynamic") { + input.model = + this.#resolvedModelId === undefined + ? "dynamic model" + : `dynamic model · ${this.#resolvedModelId}`; + } else if (agentModel?.id !== undefined) input.model = agentModel.id; // "provider-default" is the absent-setting sentinel, not a level worth showing. if (agentModel?.reasoning !== undefined && agentModel.reasoning !== "provider-default") { input.reasoning = agentModel.reasoning;