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 +}