feat(eve): tui - show the resolved dynamic model (#3455)

Signed-off-by: Andrew Barba <barba@hey.com>
This commit is contained in:
Andrew Barba
2026-09-17 10:10:14 -04:00
committed by GitHub
parent b096f514f5
commit 3121ca1bf3
6 changed files with 159 additions and 27 deletions
+5
View File
@@ -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.
+2 -1
View File
@@ -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
@@ -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 () => {
+4 -2
View File
@@ -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": {
@@ -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<AgentTUIStreamEvent> {
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<AgentTUIStreamEvent> {
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");
@@ -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;