diff --git a/README.md b/README.md
index 15b6223586..0065c9422d 100644
--- a/README.md
+++ b/README.md
@@ -80,7 +80,10 @@ approval flow.
## Agent Model Routing
-Ao usar `jev-router`, a CLI mostra o modelo físico escolhido em cada resposta
+Ao usar `jev-router`, a CLI mostra uma única vez o modelo físico escolhido por completion,
+no primeiro bloco visível de texto ou tool. Blocos de thinking ocultos não criam cabeçalhos,
+e uma nova completion pode mostrar outra escolha. A apresentação funciona também no
+histórico retomado e no transcript. O modelo aparece
como `jev-router → glm-5.3-flash` (exemplo). O alias solicitado permanece no
protocolo; a escolha vem do metadado da resposta do roteador.
diff --git a/package.json b/package.json
index e132319397..8cfe37e7c2 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@verboo/code",
- "version": "0.15.32",
+ "version": "0.15.33",
"description": "Verboo Code — coding agent for the Verboo platform",
"type": "module",
"bin": {
diff --git a/scripts/e2e/cli.e2e.mjs b/scripts/e2e/cli.e2e.mjs
index 333241e67c..ac1dbd5fde 100644
--- a/scripts/e2e/cli.e2e.mjs
+++ b/scripts/e2e/cli.e2e.mjs
@@ -22,6 +22,16 @@ async function metadata(cli) {
const estimated = cli => cli.frames.some(frame => /~[1-9][\d.,k]* tokens/.test(frame.text))
const completed = cli => cli.frames.some(frame => frame.text.includes('E2E_COMPLETE'))
+for (const fullscreen of [false, true]) test(`routing header appears once with thinking and text, fullscreen=${fullscreen}`, { timeout: 60_000 }, async () => {
+ const cli = await startCli({ columns: 120, rows: 40, fullscreen, model: 'jev-router', routerOptions: { routedCompletion: true }, args: ['oi'] })
+ try {
+ await cli.waitFor(() => cli.screen().includes('E2E_ROUTED_COMPLETE'))
+ assert.equal(cli.screen().match(/jev-router → glm-5\.3-flash/g)?.length, 1)
+ assert.ok(!cli.screen().includes('Synthetic hidden reasoning.'))
+ assert.deepEqual(cli.router.unexpected, [])
+ } finally { await cli.stop() }
+})
+
for (const fullscreen of [false, true]) for (const [columns, rows] of [[40, 12], [80, 24], [120, 40]]) for (const agents of [1, 2, 8, 20]) {
test(`installed CLI: ${agents} agents, ${columns}x${rows}, fullscreen=${fullscreen}`, { timeout: 60_000 }, async () => {
const cli = await startCli({ columns, rows, fullscreen, routerOptions: { agents }, args: ['E2E_PARENT: delegate to the fixture agents.'] })
diff --git a/scripts/e2e/fake-router.mjs b/scripts/e2e/fake-router.mjs
index 16994d0d1b..c3416a2b11 100644
--- a/scripts/e2e/fake-router.mjs
+++ b/scripts/e2e/fake-router.mjs
@@ -1,19 +1,20 @@
import { createServer } from 'node:http'
import { setTimeout as delay } from 'node:timers/promises'
-export async function createFakeRouter({ agents = 2, omitUsage = false, zeroUsage = false, partialUsage = false, stall = false, background = false, childFailure = false, childToolLoop = false } = {}) {
+export async function createFakeRouter({ agents = 2, omitUsage = false, zeroUsage = false, partialUsage = false, stall = false, background = false, childFailure = false, childToolLoop = false, routedCompletion = false } = {}) {
const requests = []
const unexpected = []
let sequence = 0
const activeAgents = new Set()
const activeRequests = new Set()
+ const model = routedCompletion ? 'jev-router' : 'fixture-model'
async function handleRequest(req, res) {
const path = new URL(req.url, 'http://fixture').pathname
const json = value => { res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(value)) }
if (path === '/') return json({ ok: true })
if (path === '/api/claude_code/settings') return json({ settings: {} })
if (path === '/api/plugins/marketplace.json') return json({ name: 'fixture', owner: { name: 'Fixture' }, plugins: [] })
- if (path.endsWith('/models')) return json({ data: [{ id: 'fixture-model', object: 'model' }], agent_model_roles: { explore: 'fixture-model', balanced: 'fixture-model', powerful: 'fixture-model' } })
+ if (path.endsWith('/models')) return json({ data: [{ id: model, object: 'model' }], agent_model_roles: { explore: model, balanced: model, powerful: model } })
if (path === '/api/me') return json({ data: { id: '11111111-1111-4111-8111-111111111111', email: 'fixture@example.test', name: 'Fixture', confirmed: true } })
if (path === '/api/me/subscriptions') return json({ data: [{ id: '11111111-1111-4111-8111-111111111111', groupId: '22222222-2222-4222-8222-222222222222', status: 'active', currentPeriodEnd: '2099-01-01T00:00:00Z' }] })
if (path === '/api/me/terms/status') return json({ data: { configured: false, mustAccept: false, pendingReacceptance: false } })
@@ -31,10 +32,14 @@ export async function createFakeRouter({ agents = 2, omitUsage = false, zeroUsag
}
if (isChild) { activeAgents.add(id); res.once('close', () => activeAgents.delete(id)) }
const hasResult = body.messages.some(message => message.role === 'tool')
- res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' })
- const emit = (delta, finish_reason = null, usage) => res.write(`data: ${JSON.stringify({ id, object: 'chat.completion.chunk', model: 'fixture-model', choices: [{ index: 0, delta, finish_reason }], ...(usage && { usage }) })}\n\n`)
+ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', ...(routedCompletion && { 'X-Verboo-Selected-Model': 'glm-5.3-flash' }) })
+ const emit = (delta, finish_reason = null, usage) => res.write(`data: ${JSON.stringify({ id, object: 'chat.completion.chunk', model, choices: [{ index: 0, delta, finish_reason }], ...(usage && { usage }) })}\n\n`)
emit({ role: 'assistant' })
- if (isChild && childToolLoop) {
+ if (routedCompletion && body.tools?.length) {
+ emit({ reasoning_content: 'Synthetic hidden reasoning.' })
+ emit({ content: 'Oi! E2E_ROUTED_COMPLETE' })
+ emit({}, 'stop', { prompt_tokens: 10, completion_tokens: 4 })
+ } else if (isChild && childToolLoop) {
emit({ tool_calls: [{ index: 0, id: `read-${sequence}`, type: 'function', function: { name: 'Read', arguments: JSON.stringify({ file_path: 'README.md' }) } }] })
emit({}, 'tool_calls', { prompt_tokens: 120, completion_tokens: 24 })
} else if (!isChild && !hasResult && body.tools?.length) {
diff --git a/scripts/e2e/terminal.mjs b/scripts/e2e/terminal.mjs
index dfe2dc6779..4e7f88365f 100644
--- a/scripts/e2e/terminal.mjs
+++ b/scripts/e2e/terminal.mjs
@@ -9,7 +9,7 @@ import { spawn, spawnSync } from 'node:child_process'
import { once } from 'node:events'
import { createFakeRouter } from './fake-router.mjs'
-export async function startCli({ columns = 80, rows = 24, fullscreen = false, usePty = true, args = [], routerOptions } = {}) {
+export async function startCli({ columns = 80, rows = 24, fullscreen = false, usePty = true, model = 'fixture-model', args = [], routerOptions } = {}) {
const consumer = (await readFile(resolve('.artifacts/package/consumer-path.txt'), 'utf8')).trim()
const root = resolve('.artifacts/pty')
await mkdir(root, { recursive: true })
@@ -49,7 +49,7 @@ export async function startCli({ columns = 80, rows = 24, fullscreen = false, us
terminal.unicode.activeVersion = '11'
let child
let stderr = ''
- const cliArgs = ['--import', pathToFileURL(resolve('scripts/e2e/transport-preload.mjs')).href, join(consumer, 'node_modules/@verboo/code/bin/verboo'), '--model', 'fixture-model', '--dangerously-skip-permissions', '--setting-sources', 'user', '--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}', '--disable-slash-commands', '--debug-file', join(dir, 'debug.log'), ...args]
+ const cliArgs = ['--import', pathToFileURL(resolve('scripts/e2e/transport-preload.mjs')).href, join(consumer, 'node_modules/@verboo/code/bin/verboo'), '--model', model, '--dangerously-skip-permissions', '--setting-sources', 'user', '--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}', '--disable-slash-commands', '--debug-file', join(dir, 'debug.log'), ...args]
try {
if (usePty) {
child = pty.spawn(process.execPath, cliArgs, { cwd: project, env, cols: columns, rows, name: 'xterm-256color' })
diff --git a/src/components/MessageModel.test.tsx b/src/components/MessageModel.test.tsx
index 6f50a79746..63e9dc42eb 100644
--- a/src/components/MessageModel.test.tsx
+++ b/src/components/MessageModel.test.tsx
@@ -2,6 +2,7 @@ import { expect, test } from 'bun:test'
import React from 'react'
import { renderToString } from '../utils/staticRender.js'
import { MessageModel } from './MessageModel.js'
+import { routingModelHeaderOwners } from '../utils/routingModelHeaders.js'
test('shows the physical Jev choice during a normal CLI conversation', async () => {
const message = {
@@ -18,3 +19,45 @@ test('shows the physical Jev choice during a normal CLI conversation', async ()
)
expect(rendered).toContain('jev-router → glm-5.3-flash')
})
+
+function assistant(uuid: string, id: string, content: Array<{ type: string; text?: string; thinking?: string }>, requestId = 'request-one') {
+ return { type: 'assistant', uuid, requestId, message: { id, model: 'ultra/jev-router', metadata: { selected_model: 'glm-5.3' }, content } }
+}
+
+test.each([false, true])('renders one routing header per completion across normalized blocks (transcript=%s)', async isTranscriptMode => {
+ const messages = [
+ assistant('thinking', 'completion-one', [{ type: 'thinking', thinking: 'internal reasoning' }]),
+ assistant('redacted', 'completion-one', [{ type: 'redacted_thinking' }]),
+ assistant('blank', 'completion-one', [{ type: 'text', text: ' ' }]),
+ assistant('first-text', 'completion-one', [{ type: 'text', text: 'Oi!' }]),
+ assistant('second-text', 'completion-one', [{ type: 'text', text: 'Em que posso ajudar?' }]),
+ assistant('tool', 'completion-one', [{ type: 'tool_use' }]),
+ assistant('next-request', 'completion-two', [{ type: 'text', text: 'Outra resposta' }], 'request-two'),
+ ]
+ const owners = routingModelHeaderOwners(messages)
+ expect([...owners]).toEqual(['first-text', 'next-request'])
+ const rendered = await renderToString(<>{messages.map(message =>
+ ,
+ )}>, 100)
+ expect(rendered.match(/ultra\/jev-router → glm-5\.3/g)?.length).toBe(2)
+})
+
+test('resumed and grouped tool blocks preserve completion identity', () => {
+ const first = assistant('tool-first', 'completion-one', [{ type: 'tool_use' }])
+ const later = assistant('tool-later', 'completion-one', [{ type: 'tool_use' }])
+ const messages = [
+ { type: 'grouped_tool_use', uuid: 'group', displayMessage: first, messages: [first, later] },
+ assistant('text-after-tools', 'completion-one', [{ type: 'text', text: 'Done' }]),
+ assistant('next', 'completion-two', [{ type: 'text', text: 'Next' }]),
+ ]
+ expect([...routingModelHeaderOwners(messages)]).toEqual(['group', 'next'])
+ expect([...routingModelHeaderOwners(structuredClone(messages))]).toEqual(['group', 'next'])
+})
+
+test('equal provider message IDs in independent requests are not deduplicated', () => {
+ const messages = [
+ assistant('one', 'provider-id', [{ type: 'text', text: 'One' }], 'request-one'),
+ assistant('two', 'provider-id', [{ type: 'text', text: 'Two' }], 'request-two'),
+ ]
+ expect([...routingModelHeaderOwners(messages)]).toEqual(['one', 'two'])
+})
diff --git a/src/components/MessageModel.tsx b/src/components/MessageModel.tsx
index b35b774d18..acde7e467b 100644
--- a/src/components/MessageModel.tsx
+++ b/src/components/MessageModel.tsx
@@ -2,20 +2,20 @@ import React from 'react'
import { stringWidth } from '../ink/stringWidth.js'
import { Box, Text } from '../ink.js'
import type { NormalizedMessage } from '../types/message.js'
+import { hasVisibleModelContent } from '../utils/routingModelHeaders.js'
type Props = {
message: NormalizedMessage;
isTranscriptMode: boolean;
+ showRoutingModel?: boolean;
};
-export function MessageModel({ message, isTranscriptMode }: Props) {
- if (
- message.type !== 'assistant' ||
- message.message.content.length === 0
- ) {
+export function MessageModel({ message, isTranscriptMode, showRoutingModel = true }: Props) {
+ if (!hasVisibleModelContent(message)) {
return null
}
const requested = message.message.model
const selected = message.message.metadata?.selected_model
+ if (typeof selected === 'string' && !showRoutingModel) return null
const display =
typeof selected === 'string' && selected.length > 0
? `${requested} → ${selected}`
diff --git a/src/components/MessageRow.routing.test.tsx b/src/components/MessageRow.routing.test.tsx
new file mode 100644
index 0000000000..5454eb7a55
--- /dev/null
+++ b/src/components/MessageRow.routing.test.tsx
@@ -0,0 +1,27 @@
+import { expect, test } from 'bun:test'
+import React from 'react'
+import { buildMessageLookups } from '../utils/messages.js'
+import { routingModelHeaderOwners } from '../utils/routingModelHeaders.js'
+import { renderToString } from '../utils/staticRender.js'
+import { MessageRow } from './MessageRow.js'
+
+test('a hidden thinking row leaves no routing header or metadata margin', async () => {
+ const messages = ['thinking', 'text'].map(type => ({
+ type: 'assistant', uuid: `block-${type}`, requestId: 'one-request',
+ message: { id: 'one-completion', model: 'ultra/jev-router', metadata: { selected_model: 'glm-5.3' }, content: type === 'thinking' ? [{ type, thinking: 'internal', signature: '' }] : [{ type, text: 'Oi!' }] },
+ }))
+ const owners = routingModelHeaderOwners(messages)
+ const props = {
+ isUserContinuation: false, hasContentAfter: false, tools: [], commands: [], verbose: false,
+ inProgressToolUseIDs: new Set(), streamingToolUseIDs: new Set(),
+ screen: 'prompt', canAnimate: false, lastThinkingBlockId: null, latestBashOutputUUID: null,
+ columns: 100, isLoading: false, lookups: buildMessageLookups(messages, messages),
+ }
+ const hidden = await renderToString(, 100)
+ expect(hidden.trim()).toBe('')
+ const rendered = await renderToString(<>{messages.map(message =>
+ ,
+ )}>, 100)
+ expect(rendered.match(/ultra\/jev-router → glm-5\.3/g)?.length).toBe(1)
+ expect(rendered).toContain('Oi!')
+})
diff --git a/src/components/MessageRow.tsx b/src/components/MessageRow.tsx
index 0fbd24f490..bc35802070 100644
--- a/src/components/MessageRow.tsx
+++ b/src/components/MessageRow.tsx
@@ -12,6 +12,7 @@ import { MessageModel } from './MessageModel.js';
import { shouldRenderStatically } from './Messages.js';
import { MessageTimestamp } from './MessageTimestamp.js';
import { OffscreenFreeze } from './OffscreenFreeze.js';
+import { hasVisibleModelContent } from '../utils/routingModelHeaders.js';
export type Props = {
message: RenderableMessage;
/** Whether the previous message in renderableMessages is also a user message. */
@@ -22,6 +23,7 @@ export type Props = {
* if the collapsed group spinner should stay active. Pass `false` otherwise.
*/
hasContentAfter: boolean;
+ showRoutingModel?: boolean;
tools: Tools;
commands: Command[];
verbose: boolean;
@@ -96,6 +98,7 @@ function MessageRowImpl(t0) {
message: msg,
isUserContinuation,
hasContentAfter,
+ showRoutingModel = true,
tools,
commands,
verbose,
@@ -216,16 +219,10 @@ function MessageRowImpl(t0) {
}
}
}
- let t5;
- if ($[34] !== displayMsg || $[35] !== isTranscriptMode) {
- t5 = displayMsg.type === "assistant" && (isTranscriptMode && displayMsg.message.content.some(_temp) && (displayMsg.timestamp || displayMsg.message.model) || displayMsg.message.content.length > 0 && typeof displayMsg.message.metadata?.selected_model === "string");
- $[34] = displayMsg;
- $[35] = isTranscriptMode;
- $[36] = t5;
- } else {
- t5 = $[36];
- }
- const hasMetadata = t5;
+ const hasMetadata = hasVisibleModelContent(displayMsg) && (
+ isTranscriptMode && displayMsg.message.content.some(_temp) && (displayMsg.timestamp || displayMsg.message.model) ||
+ showRoutingModel && typeof displayMsg.message.metadata?.selected_model === 'string'
+ );
const t6 = !hasMetadata;
const t7 = hasMetadata ? undefined : columns;
let t8;
@@ -265,8 +262,9 @@ function MessageRowImpl(t0) {
return t9;
}
let t9;
- if ($[57] !== displayMsg || $[58] !== isTranscriptMode) {
- t9 = ;
+ if ($[57] !== displayMsg || $[58] !== isTranscriptMode || $[34] !== showRoutingModel) {
+ t9 = ;
+ $[34] = showRoutingModel;
$[57] = displayMsg;
$[58] = isTranscriptMode;
$[59] = t9;
@@ -348,6 +346,7 @@ export function areMessageRowPropsEqual(prev: Props, next: Props): boolean {
// Verbose toggle changes thinking block visibility
if (prev.verbose !== next.verbose) return false;
+ if (prev.showRoutingModel !== next.showRoutingModel) return false;
// collapsed_read_search is never static in prompt mode (matches shouldRenderStatically)
if (prev.message.type === 'collapsed_read_search' && next.screen !== 'transcript') {
diff --git a/src/components/Messages.tsx b/src/components/Messages.tsx
index 3c31441a25..a6f067b3af 100644
--- a/src/components/Messages.tsx
+++ b/src/components/Messages.tsx
@@ -83,6 +83,7 @@ const SEND_USER_FILE_TOOL_NAME: string | null = feature('KAIROS') ? (require('..
/* eslint-enable @typescript-eslint/no-require-imports */
import { VirtualMessageList } from './VirtualMessageList.js';
+import { routingModelHeaderOwners } from '../utils/routingModelHeaders.js';
/**
* In brief-only mode, filter messages to show ONLY Brief tool_use blocks,
@@ -529,6 +530,8 @@ const MessagesImpl = ({
};
}, [verbose, normalizedMessages, isTranscriptMode, syntheticStreamingToolUseMessages, shouldTruncate, tools, isBriefOnly]);
+ const routingHeaderOwners = useMemo(() => routingModelHeaderOwners(collapsed_0), [collapsed_0]);
+
// Cheap slice — only runs when scroll range or slice config changes.
const renderableMessages = useMemo(() => {
// Safety cap for the non-virtualized render path. Applied here (not at
@@ -622,7 +625,7 @@ const MessagesImpl = ({
// streaming instead of waiting for the block to finalize.
const hasContentAfter = msg_8.type === 'collapsed_read_search' && (!!streamingText || hasContentAfterIndex(renderableMessages, index, tools, streamingToolUseIDs));
const k_0 = messageKey(msg_8);
- const row = ;
+ const row = ;
// Per-row Provider — only 2 rows re-render on selection change.
// Wrapped BEFORE divider branch so both return paths get it.
diff --git a/src/services/api/openaiShim.test.ts b/src/services/api/openaiShim.test.ts
index a35aca350b..2c04071f5c 100644
--- a/src/services/api/openaiShim.test.ts
+++ b/src/services/api/openaiShim.test.ts
@@ -322,6 +322,7 @@ test.each([false, true])('jev-router exposes the selected model in message metad
const headers = { 'X-Verboo-Selected-Model': 'glm-5.3-flash' }
if (stream) {
return makeSseResponse(makeStreamChunks([
+ { choices: [{ delta: { reasoning_content: 'internal reasoning' }, finish_reason: null }] },
{ choices: [{ delta: { content: 'ok' }, finish_reason: null }] },
{ choices: [{ delta: {}, finish_reason: 'stop' }] },
]), headers)
@@ -329,7 +330,7 @@ test.each([false, true])('jev-router exposes the selected model in message metad
return new Response(JSON.stringify({
id: 'chatcmpl-selected',
model: 'jev-router',
- choices: [{ message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }],
+ choices: [{ message: { role: 'assistant', reasoning_content: 'internal reasoning', content: 'ok' }, finish_reason: 'stop' }],
}), { headers: { ...headers, 'Content-Type': 'application/json' } })
}) as unknown as FetchType
diff --git a/src/utils/routingModelHeaders.ts b/src/utils/routingModelHeaders.ts
new file mode 100644
index 0000000000..f264170a87
--- /dev/null
+++ b/src/utils/routingModelHeaders.ts
@@ -0,0 +1,38 @@
+import type { NormalizedMessage, RenderableMessage } from '../types/message.js'
+import { getDisplayMessageFromCollapsed } from './collapseReadSearch.js'
+
+// Thinking, redacted thinking and whitespace-only blocks do not own a model
+// header. Keeping this predicate shared also avoids empty metadata wrappers.
+export function hasVisibleModelContent(message: NormalizedMessage): boolean {
+ return message.type === 'assistant' && message.message.content.some(
+ (block: { type: string; text?: string }) =>
+ (block.type === 'text' && !!block.text?.trim()) ||
+ block.type === 'tool_use',
+ )
+}
+
+function displayMessage(message: RenderableMessage): NormalizedMessage {
+ if (message.type === 'grouped_tool_use') return message.displayMessage
+ if (message.type === 'collapsed_read_search') return getDisplayMessageFromCollapsed(message)
+ return message
+}
+
+// Compute ownership before viewport slicing. Scrolling, chunked transcript
+// exports and resume must not assign another header to a later content block.
+export function routingModelHeaderOwners(messages: RenderableMessage[]): Set {
+ const owners = new Set()
+ const completions = new Set()
+ for (const row of messages) {
+ const message = displayMessage(row)
+ if (!hasVisibleModelContent(message) ||
+ typeof message.message.metadata?.selected_model !== 'string') continue
+ const completion = JSON.stringify([
+ message.requestId ?? null,
+ message.message.id || message.uuid,
+ ])
+ if (completions.has(completion)) continue
+ completions.add(completion)
+ owners.add(row.uuid)
+ }
+ return owners
+}