Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
10 changes: 10 additions & 0 deletions scripts/e2e/cli.e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.'] })
Expand Down
15 changes: 10 additions & 5 deletions scripts/e2e/fake-router.mjs
Original file line number Diff line number Diff line change
@@ -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 } })
Expand All @@ -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) {
Expand Down
4 changes: 2 additions & 2 deletions scripts/e2e/terminal.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down Expand Up @@ -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' })
Expand Down
43 changes: 43 additions & 0 deletions src/components/MessageModel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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 =>
<MessageModel key={message.uuid} message={message} isTranscriptMode={isTranscriptMode} showRoutingModel={owners.has(message.uuid)} />,
)}</>, 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'])
})
10 changes: 5 additions & 5 deletions src/components/MessageModel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}`
Expand Down
27 changes: 27 additions & 0 deletions src/components/MessageRow.routing.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string>(), streamingToolUseIDs: new Set<string>(),
screen: 'prompt', canAnimate: false, lastThinkingBlockId: null, latestBashOutputUUID: null,
columns: 100, isLoading: false, lookups: buildMessageLookups(messages, messages),
}
const hidden = await renderToString(<MessageRow {...props} message={messages[0]} showRoutingModel={false} />, 100)
expect(hidden.trim()).toBe('')
const rendered = await renderToString(<>{messages.map(message =>
<MessageRow key={message.uuid} {...props} message={message} showRoutingModel={owners.has(message.uuid)} />,
)}</>, 100)
expect(rendered.match(/ultra\/jev-router → glm-5\.3/g)?.length).toBe(1)
expect(rendered).toContain('Oi!')
})
23 changes: 11 additions & 12 deletions src/components/MessageRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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;
Expand Down Expand Up @@ -96,6 +98,7 @@ function MessageRowImpl(t0) {
message: msg,
isUserContinuation,
hasContentAfter,
showRoutingModel = true,
tools,
commands,
verbose,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -265,8 +262,9 @@ function MessageRowImpl(t0) {
return t9;
}
let t9;
if ($[57] !== displayMsg || $[58] !== isTranscriptMode) {
t9 = <Box flexDirection="row" justifyContent="flex-end" gap={1} marginTop={1}><MessageTimestamp message={displayMsg} isTranscriptMode={isTranscriptMode} /><MessageModel message={displayMsg} isTranscriptMode={isTranscriptMode} /></Box>;
if ($[57] !== displayMsg || $[58] !== isTranscriptMode || $[34] !== showRoutingModel) {
t9 = <Box flexDirection="row" justifyContent="flex-end" gap={1} marginTop={1}><MessageTimestamp message={displayMsg} isTranscriptMode={isTranscriptMode} /><MessageModel message={displayMsg} isTranscriptMode={isTranscriptMode} showRoutingModel={showRoutingModel} /></Box>;
$[34] = showRoutingModel;
$[57] = displayMsg;
$[58] = isTranscriptMode;
$[59] = t9;
Expand Down Expand Up @@ -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') {
Expand Down
5 changes: 4 additions & 1 deletion src/components/Messages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 = <MessageRow key={k_0} message={msg_8} isUserContinuation={isUserContinuation} hasContentAfter={hasContentAfter} tools={tools} commands={commands} verbose={verbose || isItemExpanded(msg_8) || cursor?.expanded === true && index === selectedIdx} inProgressToolUseIDs={inProgressToolUseIDs} streamingToolUseIDs={streamingToolUseIDs} screen={screen} canAnimate={canAnimate} onOpenRateLimitOptions={onOpenRateLimitOptions} lastThinkingBlockId={lastThinkingBlockId} latestBashOutputUUID={latestBashOutputUUID} columns={columns} isLoading={isLoading} lookups={lookups_0} />;
const row = <MessageRow key={k_0} message={msg_8} isUserContinuation={isUserContinuation} hasContentAfter={hasContentAfter} showRoutingModel={routingHeaderOwners.has(msg_8.uuid)} tools={tools} commands={commands} verbose={verbose || isItemExpanded(msg_8) || cursor?.expanded === true && index === selectedIdx} inProgressToolUseIDs={inProgressToolUseIDs} streamingToolUseIDs={streamingToolUseIDs} screen={screen} canAnimate={canAnimate} onOpenRateLimitOptions={onOpenRateLimitOptions} lastThinkingBlockId={lastThinkingBlockId} latestBashOutputUUID={latestBashOutputUUID} columns={columns} isLoading={isLoading} lookups={lookups_0} />;

// Per-row Provider — only 2 rows re-render on selection change.
// Wrapped BEFORE divider branch so both return paths get it.
Expand Down
3 changes: 2 additions & 1 deletion src/services/api/openaiShim.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,14 +322,15 @@ 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)
}
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

Expand Down
Loading
Loading