fix(team): refine provider diagnostics and model UI

This commit is contained in:
777genius 2026-05-09 08:50:16 +03:00
parent 4e3183d186
commit 96b9eab346
13 changed files with 327 additions and 31 deletions

View file

@ -1041,6 +1041,7 @@ interface AuthStatusCommandResponse {
interface RuntimeProviderLaunchFacts {
defaultModel: string | null;
modelIds: Set<string>;
modelListParsed?: boolean;
modelCatalog: CliProviderModelCatalog | null;
runtimeCapabilities: CliProviderRuntimeCapabilities | null;
providerStatus?:
@ -1141,6 +1142,20 @@ function isCodexEffortRuntimeSupported(
return reasoning?.configPassthrough === true && reasoning.values.includes(effort);
}
function hasAuthoritativeCodexLaunchCatalog(
facts: Pick<
RuntimeProviderLaunchFacts,
'modelIds' | 'modelListParsed' | 'modelCatalog' | 'runtimeCapabilities'
>
): boolean {
if (facts.modelIds.size > 0 || facts.modelCatalog != null) {
return true;
}
return (
facts.modelListParsed === true && facts.runtimeCapabilities?.modelCatalog?.dynamic === false
);
}
function getAnthropicFastModeDefault(): boolean {
return (
ConfigManager.getInstance().getConfig().providerConnections.anthropic.fastModeDefault === true
@ -6117,11 +6132,13 @@ export class TeamProvisioningService {
let defaultModel: string | null = null;
let modelIds = new Set<string>();
let modelListParsed = false;
if (modelListResult.status === 'fulfilled') {
try {
const parsed = extractJsonObjectFromCli<ProviderModelListCommandResponse>(
modelListResult.value.stdout
);
modelListParsed = true;
const provider = parsed.providers?.[params.providerId];
defaultModel =
typeof provider?.defaultModel === 'string' && provider.defaultModel.trim().length > 0
@ -6202,6 +6219,7 @@ export class TeamProvisioningService {
})
: defaultModel,
modelIds,
modelListParsed,
modelCatalog,
runtimeCapabilities,
providerStatus,
@ -6400,6 +6418,10 @@ export class TeamProvisioningService {
return;
}
if (!hasAuthoritativeCodexLaunchCatalog(params.facts)) {
return;
}
throw new Error(
`${params.actorLabel} uses Codex model "${explicitModel}", but this Agent Teams runtime does not declare dynamic Codex model launch support yet. Upgrade the runtime or pick a listed Codex model.`
);
@ -17152,9 +17174,18 @@ export class TeamProvisioningService {
const dynamicCatalog = params.runtimeFacts.runtimeCapabilities?.modelCatalog?.dynamic === true;
const hasAuthoritativeCatalog =
availableModels.size > 0 ||
params.runtimeFacts.modelCatalog != null ||
params.runtimeFacts.runtimeCapabilities?.modelCatalog?.dynamic === false;
params.providerId === 'codex'
? hasAuthoritativeCodexLaunchCatalog(params.runtimeFacts)
: availableModels.size > 0 ||
params.runtimeFacts.modelCatalog != null ||
params.runtimeFacts.runtimeCapabilities?.modelCatalog?.dynamic === false;
if (params.providerId === 'codex' && (dynamicCatalog || !hasAuthoritativeCatalog)) {
return {
kind: 'available',
resolvedModelId: trimmedModelId,
};
}
if (dynamicCatalog || !hasAuthoritativeCatalog) {
return {

View file

@ -91,7 +91,9 @@ export function getProvisioningProviderBackendSummary(
suffixes.push('runtime missing');
break;
case 'degraded':
suffixes.push('degraded');
if (inferredProviderId !== 'codex') {
suffixes.push('degraded');
}
break;
default:
break;

View file

@ -311,6 +311,7 @@ function isSuppressibleGenericPreflightWarning(detail: string): boolean {
}
return (
lower.includes('one-shot diagnostic') ||
lower.includes('preflight check failed') ||
(lower.includes('preflight check for `') && lower.includes('-p` did not complete')) ||
lower.includes('preflight ping completed but did not return the expected pong')
@ -413,6 +414,13 @@ function resolveModelResultFromBatch(
/selected model .* is compatible\. deep verification pending\./i.test(entry)
);
if (hasCompatibilityLine) {
if (providerId !== 'opencode') {
return {
status: 'ready',
line: buildModelAvailableLine(providerId, modelId),
warningLine: null,
};
}
return {
status: 'notes',
line: buildModelCompatibilityPendingLine(providerId, modelId),
@ -452,6 +460,13 @@ function resolveModelResultFromBatch(
}
if (result.ready && (result.warnings?.length ?? 0) > 0 && !hasModelScopedEntries) {
if (providerId !== 'opencode') {
return {
status: 'ready',
line: buildModelAvailableLine(providerId, modelId),
warningLine: null,
};
}
return {
status: 'notes',
line: buildModelCompatibilityPendingLine(providerId, modelId),

View file

@ -55,6 +55,8 @@ vi.mock('@renderer/hooks/useTheme', () => ({
vi.mock('@renderer/utils/teamModelCatalog', () => ({
isAnthropicHaikuTeamModel: () => false,
isAnthropicSonnetTeamModel: (model: string | undefined) =>
model === 'sonnet' || model === 'claude-sonnet-4-6' || model === 'sonnet[1m]',
}));
vi.mock('../../ui/button', () => ({
@ -78,9 +80,12 @@ vi.mock('../../ui/button', () => ({
),
}));
import { LeadModelRow } from './LeadModelRow';
import { ANTHROPIC_LONG_CONTEXT_PRICING_URL, LeadModelRow } from './LeadModelRow';
function renderLeadModelRow(): { host: HTMLDivElement; root: ReturnType<typeof createRoot> } {
function renderLeadModelRow(overrides: Partial<React.ComponentProps<typeof LeadModelRow>> = {}): {
host: HTMLDivElement;
root: ReturnType<typeof createRoot>;
} {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
@ -98,6 +103,7 @@ function renderLeadModelRow(): { host: HTMLDivElement; root: ReturnType<typeof c
onLimitContextChange: () => undefined,
syncModelsWithTeammates: true,
onSyncModelsWithTeammatesChange: () => undefined,
...overrides,
})
);
});
@ -128,4 +134,38 @@ describe('LeadModelRow', () => {
root.unmount();
});
});
it('warns that unchecked 200K limit can put Sonnet on Anthropic Extra Usage', () => {
const { host, root } = renderLeadModelRow({
providerId: 'anthropic',
model: 'sonnet',
limitContext: false,
});
expect(host.textContent).toContain('Sonnet with 1M context can use Anthropic Extra Usage');
expect(host.textContent).toContain('Requests over 200K input tokens');
const docsLink = host.querySelector(`a[href="${ANTHROPIC_LONG_CONTEXT_PRICING_URL}"]`);
expect(docsLink?.textContent).toContain('Anthropic pricing docs');
expect(docsLink?.getAttribute('target')).toBe('_blank');
expect(docsLink?.getAttribute('rel')).toBe('noreferrer');
act(() => {
root.unmount();
});
});
it('does not show the Sonnet Extra Usage warning when 200K limit is enabled', () => {
const { host, root } = renderLeadModelRow({
providerId: 'anthropic',
model: 'sonnet',
limitContext: true,
});
expect(host.textContent).not.toContain('Anthropic Extra Usage');
act(() => {
root.unmount();
});
});
});

View file

@ -14,7 +14,10 @@ import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme';
import { cn } from '@renderer/lib/utils';
import { agentAvatarUrl } from '@renderer/utils/memberHelpers';
import { isAnthropicHaikuTeamModel } from '@renderer/utils/teamModelCatalog';
import {
isAnthropicHaikuTeamModel,
isAnthropicSonnetTeamModel,
} from '@renderer/utils/teamModelCatalog';
import { resolveTeamLeadColorName } from '@shared/utils/teamMemberColors';
import { AlertTriangle, ChevronDown, ChevronRight, Info } from 'lucide-react';
@ -22,6 +25,11 @@ import { Button } from '../../ui/button';
import type { EffortLevel, TeamProviderId } from '@shared/types';
export const ANTHROPIC_SONNET_EXTRA_USAGE_WARNING =
'Sonnet with 1M context can use Anthropic Extra Usage. Requests over 200K input tokens are billed at premium long-context rates; enable Limit context to 200K tokens to avoid that billing path.';
export const ANTHROPIC_LONG_CONTEXT_PRICING_URL =
'https://platform.claude.com/docs/en/about-claude/pricing';
interface LeadModelRowProps {
providerId: TeamProviderId;
model: string;
@ -61,6 +69,12 @@ export const LeadModelRow = ({
: 'Default';
const modelButtonAriaLabel = `${getTeamProviderLabel(providerId)} provider, ${modelButtonLabel}`;
const hasModelIssue = Boolean(modelIssueText);
const showSonnetExtraUsageWarning =
providerId === 'anthropic' && !limitContext && isAnthropicSonnetTeamModel(model);
const warningMessages = [warningText?.trim() || null].filter((message): message is string =>
Boolean(message)
);
const hasWarnings = warningMessages.length > 0 || showSonnetExtraUsageWarning;
return (
<div
@ -132,11 +146,29 @@ export const LeadModelRow = ({
</Button>
</div>
</div>
{warningText ? (
{hasWarnings ? (
<div className="md:col-span-3">
<div className="bg-amber-500/8 ml-3 flex items-start gap-2 rounded-md border border-amber-500/25 px-3 py-2 text-[11px] leading-relaxed text-amber-200">
<Info className="mt-0.5 size-3.5 shrink-0 text-amber-300" />
<p>{warningText}</p>
<div className="space-y-1">
{warningMessages.map((message) => (
<p key={message}>{message}</p>
))}
{showSonnetExtraUsageWarning ? (
<p>
{ANTHROPIC_SONNET_EXTRA_USAGE_WARNING}{' '}
<a
href={ANTHROPIC_LONG_CONTEXT_PRICING_URL}
target="_blank"
rel="noreferrer"
className="font-medium text-amber-100 underline underline-offset-2 hover:text-white"
>
Read Anthropic pricing docs
</a>
.
</p>
) : null}
</div>
</div>
</div>
) : null}

View file

@ -250,10 +250,10 @@
width: 100%;
align-items: baseline;
justify-content: center;
gap: 6px;
gap: 4px;
}
#splash-status {
min-height: 16px;
min-height: 8px;
font-family:
ui-sans-serif,
system-ui,
@ -261,21 +261,21 @@
BlinkMacSystemFont,
'Segoe UI',
sans-serif;
font-size: 12px;
font-size: 8px;
font-weight: 500;
color: rgba(212, 212, 216, 0.72);
line-height: 1.35;
line-height: 1.25;
overflow-wrap: anywhere;
}
#splash-elapsed::before {
content: '·';
margin-right: 6px;
margin-right: 4px;
color: rgba(212, 212, 216, 0.34);
}
#splash-elapsed {
font-family:
ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace;
font-size: 11px;
font-size: 8px;
font-weight: 500;
color: rgba(212, 212, 216, 0.5);
white-space: nowrap;
@ -295,14 +295,18 @@
line-height: 1.35;
}
#splash-timeline {
display: flex;
display: none;
width: min(320px, 78vw);
max-height: 128px;
margin-top: 14px;
max-height: 58px;
margin-top: 8px;
flex-direction: column;
gap: 6px;
overflow: hidden;
}
#splash.splash-status-error #splash-timeline,
#splash.splash-status-slow #splash-timeline {
display: flex;
}
.splash-step {
display: grid;
grid-template-columns: 8px minmax(0, 1fr) auto;
@ -494,6 +498,18 @@
(function () {
try {
window.__claudeTeamsSplashStartedAt = performance.now();
window.__claudeTeamsSplashStaticTimer = window.setInterval(function () {
var elapsed = document.getElementById('splash-elapsed');
var splash = document.getElementById('splash');
if (!elapsed || !splash) {
window.clearInterval(window.__claudeTeamsSplashStaticTimer);
window.__claudeTeamsSplashStaticTimer = undefined;
return;
}
var startedAt = window.__claudeTeamsSplashStartedAt || performance.now();
var seconds = Math.max(0, Math.floor((performance.now() - startedAt) / 1000));
elapsed.textContent = seconds + 's';
}, 1000);
var theme =
localStorage.getItem('agent-teams-theme-cache') ||
localStorage.getItem('claude-devtools-theme-cache');

View file

@ -14,6 +14,7 @@ import type { AppStartupStatus, AppStartupStep } from '@shared/types/api';
declare global {
interface Window {
__claudeTeamsUiDidInit?: boolean;
__claudeTeamsSplashStaticTimer?: number;
}
}
@ -26,7 +27,7 @@ let startupTicker: number | undefined;
const SLOW_STEP_MS = 7_000;
const VERY_SLOW_STEP_MS = 14_000;
const TIMELINE_STEP_LIMIT = 6;
const TIMELINE_STEP_LIMIT = 3;
function getStartupErrorText(status: AppStartupStatus): string {
return status.error ? `Startup failed: ${status.error}` : 'Startup failed. Please restart.';
@ -142,6 +143,12 @@ function updateStartupSplash(status: AppStartupStatus): void {
renderStartupTimeline(status);
}
function stopStaticSplashTimer(): void {
if (window.__claudeTeamsSplashStaticTimer === undefined) return;
window.clearInterval(window.__claudeTeamsSplashStaticTimer);
window.__claudeTeamsSplashStaticTimer = undefined;
}
function startStartupTicker(): void {
if (startupTicker !== undefined) return;
startupTicker = window.setInterval(() => {
@ -194,13 +201,16 @@ async function bootstrapRenderer(): Promise<void> {
if (nextStatus.ready) {
finished = true;
cleanup();
stopStaticSplashTimer();
stopStartupTicker();
mountApp();
} else if (nextStatus.error) {
finished = true;
cleanup();
stopStaticSplashTimer();
stopStartupTicker();
} else {
stopStaticSplashTimer();
startStartupTicker();
}
};
@ -210,6 +220,7 @@ async function bootstrapRenderer(): Promise<void> {
} catch (error) {
console.warn(`[startup] status bridge unavailable: ${String(error)}`);
cleanup();
stopStaticSplashTimer();
stopStartupTicker();
mountApp();
}

View file

@ -239,6 +239,16 @@ export function isAnthropicHaikuTeamModel(model: string | undefined): boolean {
return baseModel === 'haiku' || baseModel.startsWith('claude-haiku-');
}
export function isAnthropicSonnetTeamModel(model: string | undefined): boolean {
const trimmed = model?.trim();
if (!trimmed) {
return false;
}
const { baseModel } = splitOneMillionContextSuffix(trimmed);
return baseModel === 'sonnet' || baseModel.startsWith('claude-sonnet-');
}
export function getTeamProviderLabel(
providerId: SupportedProviderId | undefined
): string | undefined {

View file

@ -2005,7 +2005,7 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
);
});
it('keeps missing models compatible when the runtime catalog is dynamic', async () => {
it('treats missing Codex models as launchable when the runtime catalog is dynamic', async () => {
const svc = new TeamProvisioningService();
vi.spyOn(svc as any, 'buildProvisioningEnv').mockResolvedValue({
env: {
@ -2033,13 +2033,77 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
});
expect(result).toEqual({
details: ['Selected model future-model is compatible. Deep verification pending.'],
details: ['Selected model future-model is available for launch.'],
warnings: [],
blockingMessages: [],
});
expect(spawnProbe).not.toHaveBeenCalled();
});
it('treats explicit Codex models as launchable when the runtime model list is unparsable', async () => {
execCliMock.mockImplementation(async (_binaryPath: string | null, args: string[]) => {
if (args[0] === 'model' && args[1] === 'list' && args.includes('codex')) {
return {
stdout: 'Codex model list is temporarily unavailable',
stderr: '',
exitCode: 0,
};
}
if (args[0] === 'runtime' && args[1] === 'status' && args.includes('codex')) {
return {
stdout: JSON.stringify({
providers: {
codex: {
runtimeCapabilities: {
modelCatalog: { dynamic: false, source: 'runtime' },
reasoningEffort: {
supported: true,
values: ['low', 'medium', 'high'],
configPassthrough: false,
},
},
},
},
}),
stderr: '',
exitCode: 0,
};
}
return defaultExecCliMockImplementation(_binaryPath, args);
});
const svc = new TeamProvisioningService();
vi.spyOn(svc as any, 'getCachedOrProbeResult').mockResolvedValue({
claudePath: '/fake/claude',
authSource: 'codex_runtime',
});
vi.spyOn(svc as any, 'buildProvisioningEnv').mockResolvedValue({
env: {
PATH: '/usr/bin',
SHELL: '/bin/zsh',
},
authSource: 'codex_runtime',
geminiRuntimeAuth: null,
});
const spawnProbe = vi.spyOn(svc as any, 'spawnProbe');
const result = await svc.prepareForProvisioning(tempRoot, {
forceFresh: true,
providerId: 'codex',
modelIds: ['gpt-5.5'],
modelVerificationMode: 'compatibility',
});
expect(result.ready).toBe(true);
expect(result.details).toEqual(['Selected model gpt-5.5 is available for launch.']);
expect(result.message).toBe('CLI is warmed up and ready to launch');
expect(spawnProbe).not.toHaveBeenCalled();
expect(vi.mocked(console.warn).mock.calls.map((call) => call.join(' '))).toEqual([
'[Service:TeamProvisioning] [codex] Failed to parse runtime model list for launch validation: No JSON object found in CLI output',
]);
vi.mocked(console.warn).mockClear();
});
it('maps ANTHROPIC_AUTH_TOKEN into ANTHROPIC_API_KEY for headless preflight', async () => {
const svc = new TeamProvisioningService();
vi.mocked(resolveInteractiveShellEnv).mockResolvedValue({

View file

@ -469,6 +469,45 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
expect(spawnCli).not.toHaveBeenCalled();
});
it('allows explicit Codex models when launch model list parsing is degraded', async () => {
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/codex');
const { child } = createFakeChild();
vi.mocked(spawnCli).mockReturnValue(child as any);
const svc = new TeamProvisioningService();
(svc as any).buildProvisioningEnv = vi.fn(async () => ({
env: {},
authSource: 'codex_runtime',
providerArgs: [],
}));
(svc as any).readRuntimeProviderLaunchFacts = vi.fn(async () => ({
defaultModel: null,
modelIds: new Set(),
modelListParsed: false,
modelCatalog: null,
runtimeCapabilities: { modelCatalog: { dynamic: false, source: 'runtime' } },
providerStatus: null,
}));
(svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {});
(svc as any).startFilesystemMonitor = vi.fn();
(svc as any).pathExists = vi.fn(async () => false);
const { runId } = await svc.createTeam(
{
teamName: 'codex-future-model-launchable',
cwd: process.cwd(),
members: [],
providerId: 'codex',
model: 'gpt-5.5',
effort: 'medium',
},
() => {}
);
expect(spawnCli).toHaveBeenCalled();
await svc.cancelProvisioning(runId);
});
it('restart teammate message keeps the exact teammate identity and avoids duplicate semantics', () => {
const message = buildRestartMemberSpawnMessage('forge-labs', 'Forge Labs', 'lead', {
name: 'alice',

View file

@ -266,6 +266,33 @@ describe('ProvisioningProviderStatusList', () => {
).toBe('Codex native');
});
it('does not show non-blocking Codex degraded backend state in provisioning summaries', () => {
expect(
getProvisioningProviderBackendSummary({
providerId: 'codex',
selectedBackendId: 'codex-native',
resolvedBackendId: 'codex-native',
backend: {
kind: 'codex-native',
label: 'Codex native',
},
availableBackends: [
{
id: 'codex-native',
label: 'Codex native',
description: 'Use codex exec JSON mode.',
selectable: false,
recommended: false,
available: true,
state: 'degraded',
audience: 'general',
statusMessage: 'Ready with degraded account verification.',
},
],
})
).toBe('Codex native');
});
it('normalizes persisted legacy codex fallback summaries to Codex native', () => {
expect(
getProvisioningProviderBackendSummary({

View file

@ -570,7 +570,7 @@ describe('runProviderPrepareDiagnostics', () => {
expect(result.details).toEqual(['5.4 Mini - verified', '5.4 - verified']);
});
it('does not synthesize verified from a generic runtime preflight note alone', async () => {
it('treats launchable Codex compatibility as ready and suppresses generic preflight notes', async () => {
const prepareProvisioning = vi.fn<
(
cwd?: string,
@ -593,16 +593,13 @@ describe('runProviderPrepareDiagnostics', () => {
prepareProvisioning,
});
expect(result.status).toBe('notes');
expect(result.warnings).toEqual(['orchestrator-cli preflight check failed (exit code 1).']);
expect(result.details).toEqual([
'orchestrator-cli preflight check failed (exit code 1).',
'5.4 - compatible, deep verification pending...',
]);
expect(result.status).toBe('ready');
expect(result.warnings).toEqual([]);
expect(result.details).toEqual(['5.4 - available for launch']);
expect(result.modelResultsById).toEqual({
'gpt-5.4': {
status: 'notes',
line: '5.4 - compatible, deep verification pending...',
status: 'ready',
line: '5.4 - available for launch',
warningLine: null,
},
});

View file

@ -1,6 +1,9 @@
import { describe, expect, it } from 'vitest';
import { getVisibleTeamProviderModels } from '@renderer/utils/teamModelCatalog';
import {
getVisibleTeamProviderModels,
isAnthropicSonnetTeamModel,
} from '@renderer/utils/teamModelCatalog';
describe('teamModelCatalog', () => {
it('filters UI-disabled Codex models from provider badge lists', () => {
@ -43,4 +46,13 @@ describe('teamModelCatalog', () => {
'claude-sonnet-4-6[1m]',
]);
});
it('detects Sonnet aliases with or without 1M suffix', () => {
expect(isAnthropicSonnetTeamModel('sonnet')).toBe(true);
expect(isAnthropicSonnetTeamModel('sonnet[1m]')).toBe(true);
expect(isAnthropicSonnetTeamModel('claude-sonnet-4-6')).toBe(true);
expect(isAnthropicSonnetTeamModel('claude-sonnet-4-6[1m]')).toBe(true);
expect(isAnthropicSonnetTeamModel('opus')).toBe(false);
expect(isAnthropicSonnetTeamModel('haiku')).toBe(false);
});
});