From f19dc537dd34f536c4ac2668e6d07fc9aeb0e7d9 Mon Sep 17 00:00:00 2001 From: dragonfsky Date: Sun, 12 Jul 2026 12:12:20 +0800 Subject: [PATCH 1/3] fix(browser): report verified model labels --- CHANGELOG.md | 1 + src/browser/modelDisplay.ts | 102 ++++++++++++++ src/browser/sessionRunner.ts | 20 +-- src/cli/dryRun.ts | 15 +- src/cli/sessionDisplay.ts | 29 ++-- src/cli/sessionTable.ts | 7 +- src/cli/tui/index.ts | 28 ++-- tests/browser/modelDisplay.test.ts | 160 ++++++++++++++++++++++ tests/browser/sessionRunner.test.ts | 7 +- tests/cli/dryRun.coverage.test.ts | 32 +++++ tests/cli/integrationCli.test.ts | 6 +- tests/cli/sessionDisplay.coverage.test.ts | 38 +++++ tests/cli/sessionDisplay.test.ts | 2 +- tests/cli/sessionTable.test.ts | 32 +++++ tests/cli/tui/index.test.ts | 53 +++++++ 15 files changed, 494 insertions(+), 38 deletions(-) create mode 100644 src/browser/modelDisplay.ts create mode 100644 tests/browser/modelDisplay.test.ts create mode 100644 tests/cli/sessionTable.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ac060ca3..3c44b3343 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ ### Fixed +- Browser: distinguish requested CLI model keys from verified ChatGPT picker labels in launch logs, stored-session output, status tables, and completion summaries without inferring a server-side GPT version from the generic `Pro` label. Fixes #317. - Browser: scope the fallback stop-control selector to the composer so read-aloud, dictation, and voice controls cannot hold completed responses open until timeout. Thanks @StartupBros! - Browser: support ChatGPT GPT-5.6's unified Intelligence picker, where the menu wraps `composer-intelligence-picker-content` and the highest effort is labeled `Pro` instead of `Pro Extended`; recognize the current Chinese effort labels (`极速5.5`, `中`, `高`, and `极高`) without prefix collisions and verify switches against React-replaced composer pills. Fixes #303. Thanks @DragonFSKY! - GPT-5.6: add first-class `gpt-5.6` and `gpt-5.6-sol` aliases for the OpenAI API and ChatGPT's Sol picker entry, including navigation through the current-version submenu and strict selection evidence that cannot be replaced by a localized effort label. Fixes #305. Thanks @DragonFSKY! diff --git a/src/browser/modelDisplay.ts b/src/browser/modelDisplay.ts new file mode 100644 index 000000000..f14e839bc --- /dev/null +++ b/src/browser/modelDisplay.ts @@ -0,0 +1,102 @@ +import type { BrowserModelSelectionEvidence, SessionMetadata } from "../sessionStore.js"; +import type { BrowserModelStrategy } from "./types.js"; + +interface BrowserModelDisplayInput { + model?: string | null; + desiredModel?: string | null; + modelStrategy?: BrowserModelStrategy; + evidence?: BrowserModelSelectionEvidence; +} + +function cleanLabel(value?: string | null): string | null { + const label = value?.trim(); + return label ? label : null; +} + +function sameLabel(left: string, right: string): boolean { + return left.localeCompare(right, undefined, { sensitivity: "accent" }) === 0; +} + +/** + * Describe what a browser run will try to select without presenting the target as observed fact. + */ +export function formatBrowserModelTarget({ + model, + desiredModel, + modelStrategy, +}: BrowserModelDisplayInput): string { + const requested = cleanLabel(model) ?? "n/a"; + if (modelStrategy === "current" || modelStrategy === "ignore") { + return `picker=${modelStrategy}; requested=${requested}`; + } + const target = cleanLabel(desiredModel); + if (!target) { + return requested; + } + return `target=${target}; requested=${requested}`; +} + +/** + * Prefer picker evidence only when Oracle verified it. Otherwise retain the requested CLI key. + * In particular, a bare `Pro` picker label must not be expanded to a server-side model version. + */ +export function resolveBrowserModelDisplayName({ + model, + evidence, +}: BrowserModelDisplayInput): string { + const verifiedLabel = evidence?.verified ? cleanLabel(evidence.resolvedLabel) : null; + return verifiedLabel ?? cleanLabel(model) ?? "n/a"; +} + +export function formatBrowserModelWithRequestedKey(input: BrowserModelDisplayInput): string { + const displayName = resolveBrowserModelDisplayName(input); + const requested = cleanLabel(input.model); + if (!requested || sameLabel(displayName, requested)) { + return displayName; + } + return `${displayName} (requested ${requested})`; +} + +export function resolveSessionBrowserModelDisplayName( + metadata: SessionMetadata, + model = metadata.model, +): string { + const sessionModel = cleanLabel(metadata.model); + const requestedModel = cleanLabel(model); + const evidenceApplies = + requestedModel === null + ? sessionModel === null + : sessionModel !== null && sameLabel(requestedModel, sessionModel); + return resolveBrowserModelDisplayName({ + model, + evidence: evidenceApplies ? metadata.browser?.modelSelection : undefined, + }); +} + +export function formatSessionBrowserModelWithRequestedKey( + metadata: SessionMetadata, + model = metadata.model, +): string { + const sessionModel = cleanLabel(metadata.model); + const requestedModel = cleanLabel(model); + const evidenceApplies = + requestedModel === null + ? sessionModel === null + : sessionModel !== null && sameLabel(requestedModel, sessionModel); + return formatBrowserModelWithRequestedKey({ + model, + evidence: evidenceApplies ? metadata.browser?.modelSelection : undefined, + }); +} + +export function formatBrowserModelSelectionEvidence( + evidence: BrowserModelSelectionEvidence, + model?: string | null, +): string { + const requestedKey = cleanLabel(model) ?? "(none)"; + const target = cleanLabel(evidence.requestedModel) ?? "(none)"; + const resolvedLabel = cleanLabel(evidence.resolvedLabel) ?? "(unavailable)"; + const strategy = evidence.strategy ?? "(default)"; + const verified = evidence.verified ? "yes" : "no"; + return `requestedKey=${requestedKey}; target=${target}; resolvedLabel=${resolvedLabel}; status=${evidence.status}; strategy=${strategy}; verified=${verified}; source=${evidence.source}; capturedAt=${evidence.capturedAt}`; +} diff --git a/src/browser/sessionRunner.ts b/src/browser/sessionRunner.ts index 5a35e7988..9ec1986b1 100644 --- a/src/browser/sessionRunner.ts +++ b/src/browser/sessionRunner.ts @@ -19,6 +19,7 @@ import { saveBrowserTranscriptArtifact, saveDeepResearchReportArtifact, } from "./artifacts.js"; +import { formatBrowserModelSelectionEvidence, formatBrowserModelTarget } from "./modelDisplay.js"; export interface BrowserExecutionResult { usage: { @@ -72,14 +73,6 @@ function buildUnavailableModelSelectionEvidence( }; } -function formatModelSelectionEvidence(evidence: BrowserModelSelectionEvidence): string { - const requested = evidence.requestedModel ?? "(none)"; - const resolved = evidence.resolvedLabel ?? "(unavailable)"; - const strategy = evidence.strategy ?? "(default)"; - const verified = evidence.verified ? "yes" : "no"; - return `[browser] Model selection evidence: requested=${requested}; resolved=${resolved}; status=${evidence.status}; strategy=${strategy}; verified=${verified}.`; -} - function isRequestedProBrowserRun( runOptions: RunOracleOptions, browserConfig: BrowserSessionConfig, @@ -176,7 +169,12 @@ export async function runBrowserSessionExecution( ), ); } - const headerLine = `Launching browser mode (${runOptions.model}) with ~${promptArtifacts.estimatedInputTokens.toLocaleString()} tokens.`; + const launchModel = formatBrowserModelTarget({ + model: runOptions.model, + desiredModel: browserConfig.desiredModel, + modelStrategy: browserConfig.modelStrategy, + }); + const headerLine = `Launching browser mode (${launchModel}) with ~${promptArtifacts.estimatedInputTokens.toLocaleString()} tokens.`; const automationLogger: BrowserLogger = ((message?: string) => { if (typeof message !== "string") return; const shouldAlwaysPrint = @@ -240,7 +238,9 @@ export async function runBrowserSessionExecution( const modelSelection = browserResult.modelSelection ?? buildUnavailableModelSelectionEvidence(browserConfig); if (modelSelection) { - log(formatModelSelectionEvidence(modelSelection)); + log( + `[browser] Model selection evidence: ${formatBrowserModelSelectionEvidence(modelSelection, runOptions.model)}`, + ); } const warnings = buildBrowserRunWarnings({ runOptions, diff --git a/src/cli/dryRun.ts b/src/cli/dryRun.ts index a1386691c..0b2762ace 100644 --- a/src/cli/dryRun.ts +++ b/src/cli/dryRun.ts @@ -17,6 +17,7 @@ import type { BrowserSessionConfig } from "../sessionStore.js"; import { buildTokenEstimateSuffix, formatAttachmentLabel } from "../browser/promptSummary.js"; import { buildCookiePlan } from "../browser/policies.js"; import { describeBrowserControlPlan, formatBrowserControlPlan } from "../browser/controlPlan.js"; +import { formatBrowserModelTarget } from "../browser/modelDisplay.js"; interface DryRunDeps { readFilesImpl?: typeof readFiles; @@ -113,7 +114,12 @@ async function runBrowserDryRun( const assemblePromptImpl = deps.assembleBrowserPromptImpl ?? assembleBrowserPrompt; const artifacts = await assemblePromptImpl(runOptions, { cwd }); const suffix = buildTokenEstimateSuffix(artifacts); - const headerLine = `[dry-run] Oracle (${version}) would launch browser mode (${runOptions.model}) with ~${artifacts.estimatedInputTokens.toLocaleString()} tokens${suffix}.`; + const displayModel = formatBrowserModelTarget({ + model: runOptions.model, + desiredModel: browserConfig?.desiredModel, + modelStrategy: browserConfig?.modelStrategy, + }); + const headerLine = `[dry-run] Oracle (${version}) would launch browser mode (${displayModel}) with ~${artifacts.estimatedInputTokens.toLocaleString()} tokens${suffix}.`; log(chalk.cyan(headerLine)); logBrowserControlPlan(browserConfig, log, "dry-run"); logBrowserFollowUpSummary(runOptions.browserFollowUps, log, "dry-run"); @@ -206,7 +212,12 @@ export async function runBrowserPreview( const assemblePromptImpl = deps.assembleBrowserPromptImpl ?? assembleBrowserPrompt; const artifacts = await assemblePromptImpl(runOptions, { cwd }); const suffix = buildTokenEstimateSuffix(artifacts); - const headerLine = `[preview] Oracle (${version}) browser mode (${runOptions.model}) with ~${artifacts.estimatedInputTokens.toLocaleString()} tokens${suffix}.`; + const displayModel = formatBrowserModelTarget({ + model: runOptions.model, + desiredModel: browserConfig?.desiredModel, + modelStrategy: browserConfig?.modelStrategy, + }); + const headerLine = `[preview] Oracle (${version}) browser mode (${displayModel}) with ~${artifacts.estimatedInputTokens.toLocaleString()} tokens${suffix}.`; log(chalk.cyan(headerLine)); logBrowserControlPlan(browserConfig, log, "preview"); logBrowserFollowUpSummary(runOptions.browserFollowUps, log, "preview"); diff --git a/src/cli/sessionDisplay.ts b/src/cli/sessionDisplay.ts index a783a75dd..e7cf294b0 100644 --- a/src/cli/sessionDisplay.ts +++ b/src/cli/sessionDisplay.ts @@ -31,6 +31,11 @@ import { resolveSessionLineage, } from "./sessionLineage.js"; import { formatSessionExecutionLabel } from "./sessionLifecycle.js"; +import { + formatBrowserModelSelectionEvidence, + formatSessionBrowserModelWithRequestedKey, + resolveSessionBrowserModelDisplayName, +} from "../browser/modelDisplay.js"; const isTty = (): boolean => Boolean(process.stdout.isTTY); const dim = (text: string): string => (isTty() ? kleur.dim(text) : text); @@ -422,10 +427,18 @@ export async function attachSession( const usage = run.usage ? ` tok=${formatTokenCount(run.usage.outputTokens ?? 0)}/${formatTokenCount(run.usage.totalTokens ?? 0)}` : ""; - console.log(`- ${chalk.cyan(run.model)} — ${run.status}${usage}`); + const modelLabel = + (metadata.mode ?? metadata.options?.mode) === "browser" + ? formatSessionBrowserModelWithRequestedKey(metadata, run.model) + : run.model; + console.log(`- ${chalk.cyan(modelLabel)} — ${run.status}${usage}`); } } else if (metadata.model) { - console.log(`Model: ${metadata.model}`); + const modelLabel = + (metadata.mode ?? metadata.options?.mode) === "browser" + ? formatSessionBrowserModelWithRequestedKey(metadata) + : metadata.model; + console.log(`Model: ${modelLabel}`); } const browserEvidence = formatBrowserEvidence(metadata); if (browserEvidence) { @@ -686,13 +699,7 @@ export function formatBrowserEvidence(metadata: SessionMetadata): string[] | nul const lines: string[] = []; const evidence = browser.modelSelection; if (evidence) { - const requested = evidence.requestedModel ?? "(none)"; - const resolved = evidence.resolvedLabel ?? "(unavailable)"; - const strategy = evidence.strategy ?? "(default)"; - const verified = evidence.verified ? "yes" : "no"; - lines.push( - `model requested=${requested}; resolved=${resolved}; status=${evidence.status}; strategy=${strategy}; verified=${verified}`, - ); + lines.push(`model ${formatBrowserModelSelectionEvidence(evidence, metadata.model)}`); } for (const warning of browser.warnings ?? []) { lines.push(`warning ${warning.code}: ${warning.message}`); @@ -998,7 +1005,9 @@ export function formatCompletionSummary( return null; } const modeLabel = - metadata.mode === "browser" ? `${metadata.model ?? "n/a"}[browser]` : (metadata.model ?? "n/a"); + (metadata.mode ?? metadata.options?.mode) === "browser" + ? `${resolveSessionBrowserModelDisplayName(metadata)}[browser]` + : (metadata.model ?? "n/a"); const usage = metadata.usage; const cost = resolveSessionCost(metadata); const tokensDisplay = [ diff --git a/src/cli/sessionTable.ts b/src/cli/sessionTable.ts index 8eb50716b..fc245d902 100644 --- a/src/cli/sessionTable.ts +++ b/src/cli/sessionTable.ts @@ -4,6 +4,7 @@ import { MODEL_CONFIGS } from "../oracle.js"; import type { SessionMetadata } from "../sessionStore.js"; import { estimateUsdCost } from "tokentally"; import { formatSessionExecutionLabel } from "./sessionLifecycle.js"; +import { resolveSessionBrowserModelDisplayName } from "../browser/modelDisplay.js"; const isRich = (rich?: boolean): boolean => rich ?? Boolean(process.stdout.isTTY && chalk.level > 0); @@ -34,7 +35,11 @@ export function formatSessionTableRow( ): string { const rich = isRich(options?.rich); const status = colorStatus(meta.status ?? "unknown", rich); - const modelLabel = (meta.model ?? "n/a").padEnd(MODEL_PAD); + const displayModel = + (meta.mode ?? meta.options?.mode) === "browser" + ? resolveSessionBrowserModelDisplayName(meta) + : (meta.model ?? "n/a"); + const modelLabel = displayModel.padEnd(MODEL_PAD); const model = rich ? chalk.white(modelLabel) : modelLabel; const modeLabel = formatSessionExecutionLabel(meta).padEnd(MODE_PAD); const mode = rich ? chalk.gray(modeLabel) : modeLabel; diff --git a/src/cli/tui/index.ts b/src/cli/tui/index.ts index 3f5d2b55d..c5b8a7a88 100644 --- a/src/cli/tui/index.ts +++ b/src/cli/tui/index.ts @@ -11,12 +11,7 @@ import { type RunOracleOptions, } from "../../oracle.js"; import { renderMarkdownAnsi } from "../markdownRenderer.js"; -import type { - SessionMetadata, - SessionMode, - BrowserSessionConfig, - SessionModelRun, -} from "../../sessionStore.js"; +import type { SessionMetadata, SessionMode, BrowserSessionConfig } from "../../sessionStore.js"; import { sessionStore, pruneOldSessions } from "../../sessionStore.js"; import { performSessionRun } from "../sessionRunner.js"; import { MAX_RENDER_BYTES, trimBeforeFirstAnswer } from "../sessionDisplay.js"; @@ -26,6 +21,10 @@ import { resolveNotificationSettings } from "../notifier.js"; import { loadUserConfig, type UserConfig } from "../../config.js"; import { resolveConfiguredMaxFileSizeBytes } from "../fileSize.js"; import { formatTokenCount } from "../../oracle/runUtils.js"; +import { + formatSessionBrowserModelWithRequestedKey, + resolveSessionBrowserModelDisplayName, +} from "../../browser/modelDisplay.js"; const isTty = (): boolean => Boolean(process.stdout.isTTY && chalk.level > 0); const dim = (text: string): string => (isTty() ? kleur.dim(text) : text); @@ -203,7 +202,7 @@ async function showSessionDetail(sessionId: string): Promise { console.clear(); printSessionHeader(meta); if (meta.models && meta.models.length > 0) { - printModelSummaries(meta.models); + printModelSummaries(meta); } const prompt = await readStoredPrompt(sessionId); if (prompt) { @@ -305,7 +304,11 @@ function printSessionHeader(meta: SessionMetadata): void { console.log(`${chalk.white("Status:")} ${meta.status}`); console.log(`${chalk.white("Created:")} ${meta.createdAt}`); if (meta.model) { - console.log(`${chalk.white("Model:")} ${meta.model}`); + const modelLabel = + (meta.mode ?? meta.options?.mode) === "browser" + ? resolveSessionBrowserModelDisplayName(meta) + : meta.model; + console.log(`${chalk.white("Model:")} ${modelLabel}`); } const mode = meta.mode ?? meta.options?.mode; if (mode) { @@ -316,7 +319,8 @@ function printSessionHeader(meta: SessionMetadata): void { } } -function printModelSummaries(models: SessionModelRun[]): void { +function printModelSummaries(meta: SessionMetadata): void { + const models = meta.models ?? []; if (models.length === 0) { return; } @@ -325,7 +329,11 @@ function printModelSummaries(models: SessionModelRun[]): void { const usage = run.usage ? ` tok=${formatTokenCount(run.usage.outputTokens ?? 0)}/${formatTokenCount(run.usage.totalTokens ?? 0)}` : ""; - console.log(` - ${chalk.cyan(run.model)} — ${run.status}${usage}`); + const modelLabel = + (meta.mode ?? meta.options?.mode) === "browser" + ? formatSessionBrowserModelWithRequestedKey(meta, run.model) + : run.model; + console.log(` - ${chalk.cyan(modelLabel)} — ${run.status}${usage}`); } console.log(""); } diff --git a/tests/browser/modelDisplay.test.ts b/tests/browser/modelDisplay.test.ts new file mode 100644 index 000000000..1a3307e71 --- /dev/null +++ b/tests/browser/modelDisplay.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, test } from "vitest"; +import { + formatBrowserModelSelectionEvidence, + formatBrowserModelTarget, + formatBrowserModelWithRequestedKey, + formatSessionBrowserModelWithRequestedKey, + resolveBrowserModelDisplayName, + resolveSessionBrowserModelDisplayName, +} from "../../src/browser/modelDisplay.js"; + +describe("browser model display", () => { + test("labels the configured picker target separately from the requested CLI key", () => { + expect(formatBrowserModelTarget({ model: "gpt-5.5-pro", desiredModel: "Pro" })).toBe( + "target=Pro; requested=gpt-5.5-pro", + ); + expect(formatBrowserModelTarget({ model: "gpt-5.6", desiredModel: "GPT-5.6 Sol" })).toBe( + "target=GPT-5.6 Sol; requested=gpt-5.6", + ); + expect(formatBrowserModelTarget({ model: "custom", desiredModel: "custom" })).toBe( + "target=custom; requested=custom", + ); + }); + + test("does not present ignored or current picker state as a selection target", () => { + expect( + formatBrowserModelTarget({ + model: "gpt-5.5-pro", + desiredModel: "Pro", + modelStrategy: "current", + }), + ).toBe("picker=current; requested=gpt-5.5-pro"); + expect( + formatBrowserModelTarget({ + model: "gpt-5.5-pro", + desiredModel: "Pro", + modelStrategy: "ignore", + }), + ).toBe("picker=ignore; requested=gpt-5.5-pro"); + }); + + test("uses verified picker labels without expanding generic Pro to a GPT version", () => { + const input = { + model: "gpt-5.5-pro", + evidence: { + requestedModel: "Pro", + resolvedLabel: "Pro", + strategy: "select" as const, + status: "already-selected" as const, + verified: true, + source: "chatgpt-model-picker" as const, + capturedAt: "2026-07-12T00:00:00.000Z", + }, + }; + + expect(resolveBrowserModelDisplayName(input)).toBe("Pro"); + expect(formatBrowserModelWithRequestedKey(input)).toBe("Pro (requested gpt-5.5-pro)"); + }); + + test("does not present unverified observed labels as the selected model", () => { + expect( + resolveBrowserModelDisplayName({ + model: "gpt-5.5-pro", + evidence: { + requestedModel: "gpt-5.5-pro", + resolvedLabel: "Thinking 5.5 Heavy", + strategy: "current", + status: "already-selected", + verified: false, + source: "chatgpt-model-picker", + capturedAt: "2026-07-12T00:00:00.000Z", + }, + }), + ).toBe("gpt-5.5-pro"); + + expect( + resolveBrowserModelDisplayName({ + model: "gpt-5.5-pro", + evidence: { + requestedModel: "Pro", + resolvedLabel: " ", + strategy: "select", + status: "already-selected", + verified: true, + source: "chatgpt-model-picker", + capturedAt: "2026-07-12T00:00:00.000Z", + }, + }), + ).toBe("gpt-5.5-pro"); + }); + + test("derives stored-session labels from verified evidence", () => { + expect( + resolveSessionBrowserModelDisplayName({ + id: "session", + createdAt: "2026-07-12T00:00:00.000Z", + status: "completed", + mode: "browser", + model: "gpt-5.6", + options: {}, + browser: { + modelSelection: { + requestedModel: "GPT-5.6 Sol", + resolvedLabel: "GPT-5.6 Sol", + strategy: "select", + status: "switched", + verified: true, + source: "chatgpt-model-picker", + capturedAt: "2026-07-12T00:00:00.000Z", + }, + }, + }), + ).toBe("GPT-5.6 Sol"); + }); + + test("does not apply session-level picker evidence to a different model run", () => { + const metadata = { + id: "session", + createdAt: "2026-07-12T00:00:00.000Z", + status: "completed" as const, + mode: "browser" as const, + model: "gpt-5.5-pro", + options: {}, + browser: { + modelSelection: { + requestedModel: "Pro", + resolvedLabel: "Pro", + strategy: "select" as const, + status: "already-selected" as const, + verified: true, + source: "chatgpt-model-picker" as const, + capturedAt: "2026-07-12T00:00:00.000Z", + }, + }, + }; + + expect(formatSessionBrowserModelWithRequestedKey(metadata, "gpt-5.5-pro")).toBe( + "Pro (requested gpt-5.5-pro)", + ); + expect(formatSessionBrowserModelWithRequestedKey(metadata, "gpt-5.6-sol")).toBe("gpt-5.6-sol"); + }); + + test("formats model-selection provenance with stable field names", () => { + expect( + formatBrowserModelSelectionEvidence( + { + requestedModel: "Pro", + resolvedLabel: "Pro", + strategy: "select", + status: "already-selected", + verified: true, + source: "chatgpt-model-picker", + capturedAt: "2026-07-12T00:00:00.000Z", + }, + "gpt-5.5-pro", + ), + ).toBe( + "requestedKey=gpt-5.5-pro; target=Pro; resolvedLabel=Pro; status=already-selected; strategy=select; verified=yes; source=chatgpt-model-picker; capturedAt=2026-07-12T00:00:00.000Z", + ); + }); +}); diff --git a/tests/browser/sessionRunner.test.ts b/tests/browser/sessionRunner.test.ts index 0790b7b0b..bbc96ee1f 100644 --- a/tests/browser/sessionRunner.test.ts +++ b/tests/browser/sessionRunner.test.ts @@ -176,7 +176,12 @@ describe("runBrowserSessionExecution", () => { verified: true, }); expect(log).toHaveBeenCalledWith( - expect.stringContaining("[browser] Model selection evidence: requested=GPT-5.5 Pro"), + expect.stringContaining("Launching browser mode (target=GPT-5.5 Pro; requested=gpt-5.2-pro)"), + ); + expect(log).toHaveBeenCalledWith( + expect.stringContaining( + "[browser] Model selection evidence: requestedKey=gpt-5.2-pro; target=GPT-5.5 Pro; resolvedLabel=Pro", + ), ); }); diff --git a/tests/cli/dryRun.coverage.test.ts b/tests/cli/dryRun.coverage.test.ts index 0ad052052..6a79ce60e 100644 --- a/tests/cli/dryRun.coverage.test.ts +++ b/tests/cli/dryRun.coverage.test.ts @@ -62,6 +62,38 @@ describe("runDryRunSummary", () => { expect(joined).toContain("Cookies: inline payload (1) via test"); }); + test("browser dry run distinguishes its picker target from the requested model key", async () => { + const log = vi.fn(); + const assembleBrowserPromptImpl = vi.fn().mockResolvedValue({ + markdown: "[USER]", + composerText: "Do it", + estimatedInputTokens: 42, + attachments: [], + inlineFileCount: 0, + tokenEstimateIncludesInlineFiles: false, + attachmentsPolicy: "auto", + attachmentMode: "inline", + fallback: null, + bundled: null, + }); + + await runDryRunSummary( + { + engine: "browser", + runOptions: { ...baseRunOptions, model: "gpt-5.6" }, + cwd: "/repo", + version: "0.15.2", + log, + browserConfig: { desiredModel: "GPT-5.6 Sol" }, + }, + { assembleBrowserPromptImpl }, + ); + + expect(log.mock.calls.flat().join("\n")).toContain( + "browser mode (target=GPT-5.6 Sol; requested=gpt-5.6)", + ); + }); + test("browser dry run falls back to inline composer summary when no attachments", async () => { const log = vi.fn(); const assembleBrowserPromptImpl = vi.fn().mockResolvedValue({ diff --git a/tests/cli/integrationCli.test.ts b/tests/cli/integrationCli.test.ts index 14363e011..9d907bb55 100644 --- a/tests/cli/integrationCli.test.ts +++ b/tests/cli/integrationCli.test.ts @@ -406,7 +406,7 @@ module.exports = () => ({ ); expect(stdout).toContain("[preview] Oracle"); - expect(stdout).toContain("browser mode (gemini-3.1-pro)"); + expect(stdout).toContain("browser mode (target=Gemini 3.1 Pro; requested=gemini-3.1-pro)"); await rm(oracleHome, { recursive: true, force: true }); }, @@ -454,7 +454,7 @@ module.exports = () => ({ ); expect(stdout).toContain("[preview] Oracle"); - expect(stdout).toContain("browser mode (gpt-5.1)"); + expect(stdout).toContain("browser mode (target=GPT-5.2; requested=gpt-5.1)"); expect(stdout).not.toContain("Provider: Azure OpenAI"); await rm(oracleHome, { recursive: true, force: true }); @@ -501,7 +501,7 @@ module.exports = () => ({ ); expect(stdout).toContain("[preview] Oracle"); - expect(stdout).toContain("browser mode (gpt-5.1)"); + expect(stdout).toContain("browser mode (target=GPT-5.2; requested=gpt-5.1)"); expect(stdout).not.toContain("Provider: Azure OpenAI"); await rm(oracleHome, { recursive: true, force: true }); diff --git a/tests/cli/sessionDisplay.coverage.test.ts b/tests/cli/sessionDisplay.coverage.test.ts index 06026defd..568bbeba9 100644 --- a/tests/cli/sessionDisplay.coverage.test.ts +++ b/tests/cli/sessionDisplay.coverage.test.ts @@ -246,5 +246,43 @@ describe("sessionDisplay helpers", () => { const summary = formatCompletionSummary(summaryMeta, { includeSlug: true }); expect(summary).toContain("↑10 ↓20 ↻0 Δ30"); expect(summary).toContain("slug=s2"); + + const browserSummary = formatCompletionSummary({ + ...summaryMeta, + mode: "browser", + model: "gpt-5.5-pro", + browser: { + modelSelection: { + requestedModel: "Pro", + resolvedLabel: "Pro", + strategy: "select", + status: "already-selected", + verified: true, + source: "chatgpt-model-picker", + capturedAt: "2026-07-12T00:00:00.000Z", + }, + }, + }); + expect(browserSummary).toContain("Pro[browser]"); + expect(browserSummary).not.toContain("GPT-5.6 Pro"); + + const unverifiedSummary = formatCompletionSummary({ + ...summaryMeta, + mode: "browser", + model: "gpt-5.5-pro", + browser: { + modelSelection: { + requestedModel: "gpt-5.5-pro", + resolvedLabel: "Thinking 5.5 Heavy", + strategy: "current", + status: "already-selected", + verified: false, + source: "chatgpt-model-picker", + capturedAt: "2026-07-12T00:00:00.000Z", + }, + }, + }); + expect(unverifiedSummary).toContain("gpt-5.5-pro[browser]"); + expect(unverifiedSummary).not.toContain("Thinking 5.5 Heavy[browser]"); }); }); diff --git a/tests/cli/sessionDisplay.test.ts b/tests/cli/sessionDisplay.test.ts index 7606897f1..34bd0428f 100644 --- a/tests/cli/sessionDisplay.test.ts +++ b/tests/cli/sessionDisplay.test.ts @@ -142,7 +142,7 @@ describe("formatBrowserEvidence", () => { }; expect(formatBrowserEvidence(metadata)).toEqual([ - "model requested=GPT-5.5 Pro; resolved=Pro; status=already-selected; strategy=select; verified=yes", + "model requestedKey=(none); target=GPT-5.5 Pro; resolvedLabel=Pro; status=already-selected; strategy=select; verified=yes; source=chatgpt-model-picker; capturedAt=2026-05-13T00:00:00.000Z", "warning browser-pro-fast-large-run: Large browser Pro run completed quickly.", ]); }); diff --git a/tests/cli/sessionTable.test.ts b/tests/cli/sessionTable.test.ts new file mode 100644 index 000000000..ccf1a9e67 --- /dev/null +++ b/tests/cli/sessionTable.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from "vitest"; +import { formatSessionTableRow } from "../../src/cli/sessionTable.js"; + +describe("formatSessionTableRow", () => { + test("shows a verified browser label instead of the requested model key", () => { + const row = formatSessionTableRow( + { + id: "browser-session", + createdAt: "2026-07-12T00:00:00.000Z", + status: "completed", + mode: "browser", + model: "gpt-5.6", + options: {}, + browser: { + modelSelection: { + requestedModel: "GPT-5.6 Sol", + resolvedLabel: "GPT-5.6 Sol", + strategy: "select", + status: "already-selected", + verified: true, + source: "chatgpt-model-picker", + capturedAt: "2026-07-12T00:00:00.000Z", + }, + }, + }, + { rich: false }, + ); + + expect(row).toContain("GPT-5.6 Sol"); + expect(row).not.toContain("gpt-5.6 "); + }); +}); diff --git a/tests/cli/tui/index.test.ts b/tests/cli/tui/index.test.ts index 78e90496a..e86adb4a7 100644 --- a/tests/cli/tui/index.test.ts +++ b/tests/cli/tui/index.test.ts @@ -184,4 +184,57 @@ describe("showSessionDetail", () => { consoleSpy.mockRestore(); }); + + test("uses verified browser labels in detail views while retaining raw log action keys", async () => { + const { showSessionDetail } = await import("../../../src/cli/tui/index.ts"); + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + promptMock.mockResolvedValueOnce({ next: "back" }); + + readSessionMock.mockResolvedValueOnce({ + id: "browser-session", + createdAt: "2026-07-12T00:00:00.000Z", + status: "completed", + model: "gpt-5.5-pro", + options: { mode: "browser" }, + models: [ + { model: "gpt-5.5-pro", status: "completed" }, + { model: "gpt-5.6-sol", status: "completed" }, + ], + browser: { + modelSelection: { + requestedModel: "Pro", + resolvedLabel: "Pro", + strategy: "select", + status: "already-selected", + verified: true, + source: "chatgpt-model-picker", + capturedAt: "2026-07-12T00:00:00.000Z", + }, + }, + }); + readLogMock.mockResolvedValueOnce("Answer: hello"); + getPathsMock.mockResolvedValueOnce({ + dir: "/tmp", + metadata: "/tmp/meta.json", + log: "/tmp/output.log", + request: "/tmp/request.json", + }); + + await showSessionDetail("browser-session"); + + const output = consoleSpy.mock.calls.flat().join("\n"); + expect(output).toContain("Model: Pro"); + expect(output).toContain("Pro (requested gpt-5.5-pro)"); + expect(output).toContain("gpt-5.6-sol"); + expect(promptMock).toHaveBeenCalledWith([ + expect.objectContaining({ + choices: expect.arrayContaining([ + expect.objectContaining({ value: "log:gpt-5.5-pro" }), + expect.objectContaining({ value: "log:gpt-5.6-sol" }), + ]), + }), + ]); + + consoleSpy.mockRestore(); + }); }); From ec844ca3c0566d9543e88d8557467e5172f55bae Mon Sep 17 00:00:00 2001 From: dragonfsky Date: Sun, 12 Jul 2026 15:08:25 +0800 Subject: [PATCH 2/3] fix(browser): keep model labels evidence-backed --- src/browser/actions/modelSelection.ts | 44 ++++++------- src/browser/sessionRunner.ts | 8 ++- tests/browser/modelSelection.test.ts | 42 +++++++++--- tests/browser/sessionRunner.test.ts | 94 +++++++++++++++++++++++++++ 4 files changed, 154 insertions(+), 34 deletions(-) diff --git a/src/browser/actions/modelSelection.ts b/src/browser/actions/modelSelection.ts index 6bc0bf099..7c8c981e2 100644 --- a/src/browser/actions/modelSelection.ts +++ b/src/browser/actions/modelSelection.ts @@ -67,17 +67,18 @@ export async function ensureModelSelection( case "already-selected": case "switched": { const observedLabel = result.label?.trim() || null; - const label = observedLabel || (strategy === "current" ? null : desiredModel); if (strategy !== "current") { assertResolvedModelSelection(desiredModel, observedLabel ?? ""); } - logger(`Model picker: ${label ?? "current model (label unavailable)"}`); + logger(`Model picker: ${observedLabel ?? "current model (label unavailable)"}`); return { requestedModel: desiredModel, - resolvedLabel: label, + // A picker target is intent, not observed UI evidence. Keep it separate from the + // resolved label so display code cannot turn a fallback into a claimed selection. + resolvedLabel: observedLabel, strategy, status: result.status, - verified: strategy !== "current", + verified: strategy !== "current" && observedLabel !== null, source: "chatgpt-model-picker", capturedAt: new Date().toISOString(), }; @@ -354,12 +355,10 @@ function buildModelSelectionExpression( const getComposerModelLabel = () => (document.querySelector(COMPOSER_MODEL_SIGNAL_SELECTOR)?.textContent ?? '').trim(); const readComposerModelSignal = () => normalizeText(getComposerModelLabel()); - const isIntelligenceEffortLabel = (label) => - label === 'instant' || + const isEffortOnlyLabel = (label) => label === 'medium' || label === 'high' || label === 'extra high' || - label === 'pro' || label === 'extended' || label === 'standard' || label === 'heavy' || @@ -448,7 +447,7 @@ function buildModelSelectionExpression( } return true; }; - const getResolvedLabel = (fallback) => { + const getResolvedLabel = (observedOptionLabel = '') => { if (configuredSelectionMatchesTarget()) { const variant = getConfiguredVariantLabel(); const version = formatModelOptionLabel(getConfiguredVersionLabel()); @@ -474,21 +473,18 @@ function buildModelSelectionExpression( ) { return withProPillSignal(buttonLabel); } - const fallbackLabel = formatModelOptionLabel(fallback); - const normalizedFallback = normalizeText(fallbackLabel); - if ( - desiredVersion && - desiredModelVariant && - versionFromLabel(normalizedFallback) === desiredVersion && - normalizedFallback.split(' ').includes(desiredModelVariant) - ) { - return fallbackLabel; - } - if (composerLabel) return withProPillSignal(composerLabel); - if (fallbackLabel && !wantsPro && isIntelligenceEffortLabel(normalizedButton)) { - return fallbackLabel; - } - return withProPillSignal(buttonLabel || fallbackLabel || fallback); + const observedLabel = (label) => { + const formatted = formatModelOptionLabel(label); + return formatted && !isEffortOnlyLabel(normalizeText(formatted)) + ? withProPillSignal(formatted) + : ''; + }; + return ( + observedLabel(observedOptionLabel) || + observedLabel(composerLabel) || + observedLabel(buttonLabel) || + (wantsPro && hasProComposerPill() ? 'Pro' : '') + ); }; if (MODEL_STRATEGY === 'current') { const currentLabel = getResolvedLabel('') || null; @@ -604,7 +600,7 @@ function buildModelSelectionExpression( }; if (activeSelectionMatchesTarget()) { - return { status: 'already-selected', label: getResolvedLabel(PRIMARY_LABEL) }; + return { status: 'already-selected', label: getResolvedLabel() }; } let lastPointerClick = 0; diff --git a/src/browser/sessionRunner.ts b/src/browser/sessionRunner.ts index 9ec1986b1..6d2b1b5f0 100644 --- a/src/browser/sessionRunner.ts +++ b/src/browser/sessionRunner.ts @@ -19,7 +19,11 @@ import { saveBrowserTranscriptArtifact, saveDeepResearchReportArtifact, } from "./artifacts.js"; -import { formatBrowserModelSelectionEvidence, formatBrowserModelTarget } from "./modelDisplay.js"; +import { + formatBrowserModelSelectionEvidence, + formatBrowserModelTarget, + resolveBrowserModelDisplayName, +} from "./modelDisplay.js"; export interface BrowserExecutionResult { usage: { @@ -288,7 +292,7 @@ export async function runBrowserSessionExecution( })(); const { line1, line2 } = formatFinishLine({ elapsedMs: browserResult.tookMs, - model: `${runOptions.model}[browser]`, + model: `${resolveBrowserModelDisplayName({ model: runOptions.model, evidence: modelSelection })}[browser]`, tokensPart, detailParts: [ runOptions.file && runOptions.file.length > 0 ? `files=${runOptions.file.length}` : null, diff --git a/tests/browser/modelSelection.test.ts b/tests/browser/modelSelection.test.ts index 806e815e9..4930e09f7 100644 --- a/tests/browser/modelSelection.test.ts +++ b/tests/browser/modelSelection.test.ts @@ -1173,14 +1173,14 @@ describe("browser model selection matchers", () => { expect(result).toEqual({ status: "already-selected", label: "Pro" }); }); - it("accepts a Pro pill plus effort label as the current Pro model", () => { + it("uses the observed Pro pill instead of its effort label as the current model", () => { const result = evaluateImmediateModelSelectionExpression( "gpt-5.5-pro", "Extended", "", "Pro, click to remove", ); - expect(result).toEqual({ status: "already-selected", label: "Extended + Pro" }); + expect(result).toEqual({ status: "already-selected", label: "Pro" }); }); it("hard-rejects Thinking candidates when targeting Pro", () => { @@ -1234,9 +1234,9 @@ describe("browser model selection matchers", () => { expect(result).toEqual({ status: "already-selected", label: "Thinking Heavy" }); }); - it("finds the new effort-only composer pill when ChatGPT omits aria-haspopup", () => { + it("does not treat an effort-only composer pill as a model label when aria-haspopup is absent", () => { const result = evaluateComposerPillFallbackExpression("Thinking 5.5", "Extra High", "current"); - expect(result).toEqual({ status: "already-selected", label: "Extra High" }); + expect(result).toEqual({ status: "already-selected", label: null }); }); it("allows the explicit current strategy when ChatGPT hides the model picker", () => { @@ -1417,6 +1417,26 @@ describe("browser model selection matchers", () => { expect(logger).toHaveBeenCalledWith("Model picker: current model (label unavailable)"); }); + it("does not promote the requested picker target to verified evidence without a label", async () => { + const runtime = { + evaluate: vi.fn().mockResolvedValue({ + result: { value: { status: "already-selected", label: null } }, + }), + }; + const logger = vi.fn(); + + await expect( + ensureModelSelection(runtime as never, "gpt-5.5-pro", logger as never, "select"), + ).resolves.toMatchObject({ + requestedModel: "gpt-5.5-pro", + resolvedLabel: null, + status: "already-selected", + strategy: "select", + verified: false, + }); + expect(logger).toHaveBeenCalledWith("Model picker: current model (label unavailable)"); + }); + it("builds composer footer matchers for generic ChatGPT header states", () => { expect(buildComposerSignalMatchersForTest("GPT-5.5 Pro")).toEqual({ includesAny: ["pro"], @@ -1435,6 +1455,12 @@ describe("browser model selection matchers", () => { }); }); + it("does not use the picker target as a DOM resolved-label fallback", () => { + const expression = buildModelSelectionExpressionForTest("GPT-5.6 Sol"); + expect(expression).toContain("getResolvedLabel()"); + expect(expression).not.toContain("getResolvedLabel(PRIMARY_LABEL)"); + }); + it("waits for composer footer state when the header button stays generic", () => { const expression = buildModelSelectionExpressionForTest("GPT-5.5 Pro"); expect(expression).toContain("const readComposerModelSignal = () =>"); @@ -1463,10 +1489,10 @@ describe("browser model selection matchers", () => { expect(expression).toContain("button.__composer-pill')).find(looksLikeModelPill)"); }); - it("recognizes GPT-5.5 from the new Intelligence submenu while the button shows effort", async () => { + it("does not claim a model label when the new Intelligence picker exposes only effort", async () => { await expect(evaluateIntelligenceModelSelectionExpression("Thinking 5.5")).resolves.toEqual({ status: "already-selected", - label: "Thinking 5.5", + label: "", }); }); @@ -1486,12 +1512,12 @@ describe("browser model selection matchers", () => { }); }); - it("uses the non-Pro Intelligence effort row when switching from Pro to Thinking 5.5", async () => { + it("does not treat a non-Pro Intelligence effort row as a model label after switching", async () => { await expect( evaluateIntelligenceModelSelectionExpression("Thinking 5.5", "Pro Extended"), ).resolves.toEqual({ status: "switched", - label: "Extra High", + label: "", }); }); diff --git a/tests/browser/sessionRunner.test.ts b/tests/browser/sessionRunner.test.ts index bbc96ee1f..15aaf0b98 100644 --- a/tests/browser/sessionRunner.test.ts +++ b/tests/browser/sessionRunner.test.ts @@ -814,6 +814,100 @@ describe("runBrowserSessionExecution", () => { expect(finishedLine).not.toContain("tokens ("); }); + test("uses a verified picker label in the live browser finish line", async () => { + const log = vi.fn(); + await runBrowserSessionExecution( + { + runOptions: { ...baseRunOptions, model: "gpt-5.5-pro" }, + browserConfig: { desiredModel: "Pro", modelStrategy: "select" }, + cwd: "/repo", + log, + }, + { + assemblePrompt: async () => ({ + markdown: "prompt", + composerText: "prompt", + estimatedInputTokens: 10, + attachments: [], + inlineFileCount: 0, + tokenEstimateIncludesInlineFiles: false, + attachmentsPolicy: "auto", + attachmentMode: "inline", + fallback: null, + }), + executeBrowser: async () => ({ + answerText: "text", + answerMarkdown: "markdown", + tookMs: 100, + answerTokens: 5, + answerChars: 10, + modelSelection: { + requestedModel: "Pro", + resolvedLabel: "Pro", + strategy: "select", + status: "already-selected", + verified: true, + source: "chatgpt-model-picker", + capturedAt: "2026-07-12T00:00:00.000Z", + }, + }), + }, + ); + + const finishedLine = log.mock.calls + .map((call) => String(call[0])) + .find((line) => line.includes("↑") && line.includes("↓") && line.includes("Δ")); + expect(finishedLine).toContain("Pro[browser]"); + expect(finishedLine).not.toContain("gpt-5.5-pro[browser]"); + }); + + test("keeps the requested key in the live finish line when picker evidence is unverified", async () => { + const log = vi.fn(); + await runBrowserSessionExecution( + { + runOptions: { ...baseRunOptions, model: "gpt-5.5-pro" }, + browserConfig: { desiredModel: "Pro", modelStrategy: "current" }, + cwd: "/repo", + log, + }, + { + assemblePrompt: async () => ({ + markdown: "prompt", + composerText: "prompt", + estimatedInputTokens: 10, + attachments: [], + inlineFileCount: 0, + tokenEstimateIncludesInlineFiles: false, + attachmentsPolicy: "auto", + attachmentMode: "inline", + fallback: null, + }), + executeBrowser: async () => ({ + answerText: "text", + answerMarkdown: "markdown", + tookMs: 100, + answerTokens: 5, + answerChars: 10, + modelSelection: { + requestedModel: "Pro", + resolvedLabel: "Thinking 5.5 Heavy", + strategy: "current", + status: "already-selected", + verified: false, + source: "chatgpt-model-picker", + capturedAt: "2026-07-12T00:00:00.000Z", + }, + }), + }, + ); + + const finishedLine = log.mock.calls + .map((call) => String(call[0])) + .find((line) => line.includes("↑") && line.includes("↓") && line.includes("Δ")); + expect(finishedLine).toContain("gpt-5.5-pro[browser]"); + expect(finishedLine).not.toContain("Thinking 5.5 Heavy[browser]"); + }); + test("passes heartbeat interval through to browser runner", async () => { const log = vi.fn(); const executeBrowser = vi.fn(async () => ({ From f1d12438d8f2b1cba53baf137bfccc880d15c194 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 1 Aug 2026 22:28:34 -0700 Subject: [PATCH 3/3] fix(browser): keep unlabeled model selections unverified --- src/browser/actions/modelSelection.ts | 4 ++-- tests/browser/modelSelection.test.ts | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/browser/actions/modelSelection.ts b/src/browser/actions/modelSelection.ts index 7c8c981e2..75f456149 100644 --- a/src/browser/actions/modelSelection.ts +++ b/src/browser/actions/modelSelection.ts @@ -67,8 +67,8 @@ export async function ensureModelSelection( case "already-selected": case "switched": { const observedLabel = result.label?.trim() || null; - if (strategy !== "current") { - assertResolvedModelSelection(desiredModel, observedLabel ?? ""); + if (strategy !== "current" && observedLabel !== null) { + assertResolvedModelSelection(desiredModel, observedLabel); } logger(`Model picker: ${observedLabel ?? "current model (label unavailable)"}`); return { diff --git a/tests/browser/modelSelection.test.ts b/tests/browser/modelSelection.test.ts index 4930e09f7..0e73fc3fb 100644 --- a/tests/browser/modelSelection.test.ts +++ b/tests/browser/modelSelection.test.ts @@ -1437,6 +1437,26 @@ describe("browser model selection matchers", () => { expect(logger).toHaveBeenCalledWith("Model picker: current model (label unavailable)"); }); + it("does not reject GPT-5.6 Sol when the picker reports success without a label", async () => { + const runtime = { + evaluate: vi.fn().mockResolvedValue({ + result: { value: { status: "already-selected", label: null } }, + }), + }; + const logger = vi.fn(); + + await expect( + ensureModelSelection(runtime as never, "gpt-5.6-sol", logger as never, "select"), + ).resolves.toMatchObject({ + requestedModel: "gpt-5.6-sol", + resolvedLabel: null, + status: "already-selected", + strategy: "select", + verified: false, + }); + expect(logger).toHaveBeenCalledWith("Model picker: current model (label unavailable)"); + }); + it("builds composer footer matchers for generic ChatGPT header states", () => { expect(buildComposerSignalMatchersForTest("GPT-5.5 Pro")).toEqual({ includesAny: ["pro"],