mirror of
https://github.com/vercel/eve.git
synced 2026-09-20 05:35:39 +08:00
feat(eve): tui - show the resolved dynamic model (#3455)
Signed-off-by: Andrew Barba <barba@hey.com>
This commit is contained in:
@@ -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.
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user