From 435d0d4e1d0871d9ccfc40aea4a2dba7b54f8824 Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 20 May 2026 10:10:58 +0300 Subject: [PATCH 01/44] docs(release): clarify user-facing notes guidance --- docs/RELEASE.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 7d5c317b..acef573b 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -194,6 +194,22 @@ EOF Write changelog entries from the **user's perspective**, not the developer's. +Release notes must stay short, concrete, and user-facing. Do not include internal +maintenance details unless they directly change what users can do or clearly fix +a user-visible problem. + +Avoid entries about: + +- CI/lint/test gates, smoke tests, or validation infrastructure. +- README/docs cleanup, roadmap checkbox changes, or release-process polish. +- Runtime artifact internals, bundled runtime version numbers, stable aliases, + compatibility aliases, or updater plumbing. +- Refactors, dependency bumps, or workflow changes without a user-visible effect. + +If a change only made future releases, tests, packaging, or developer validation +more reliable, keep it out of the public notes or fold it into one concise +user-facing line only when it explains a real fix. + **Good:** - "Add team member activity timeline with live status tracking" @@ -205,6 +221,11 @@ Write changelog entries from the **user's perspective**, not the developer's. - "Refactor ChunkBuilder to use new pipeline" - "Update dependencies" - "Fix bug in useEffect cleanup" +- "Fix CI lint gate" +- "Stabilize provider smoke tests" +- "Update README install guidance" +- "Bundled runtime remains vX.Y.Z" +- "Compatibility aliases are still included" Group entries by type: `What's New` > `Improvements` > `Bug Fixes` > `Breaking Changes` (if any). From 948e00aedbd96577bd98ab2fb863591e628a444f Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 20 May 2026 12:30:06 +0300 Subject: [PATCH 02/44] fix(updater): improve release notes and banner spacing --- .../services/infrastructure/UpdaterService.ts | 12 ++- .../components/common/UpdateBanner.tsx | 33 +++++-- .../components/common/UpdateDialog.tsx | 6 +- src/shared/utils/releaseNotes.ts | 88 +++++++++++++++++++ test/shared/utils/releaseNotes.test.ts | 86 ++++++++++++++++++ 5 files changed, 210 insertions(+), 15 deletions(-) create mode 100644 src/shared/utils/releaseNotes.ts create mode 100644 test/shared/utils/releaseNotes.test.ts diff --git a/src/main/services/infrastructure/UpdaterService.ts b/src/main/services/infrastructure/UpdaterService.ts index 495ba50d..da24fed6 100644 --- a/src/main/services/infrastructure/UpdaterService.ts +++ b/src/main/services/infrastructure/UpdaterService.ts @@ -13,6 +13,10 @@ import { safeSendToRenderer } from '@main/utils/safeWebContentsSend'; import { getErrorMessage } from '@shared/utils/errorHandling'; import { createLogger } from '@shared/utils/logger'; +import { + formatUpdaterReleaseNotes, + getUpdaterReleaseNoteForVersion, +} from '@shared/utils/releaseNotes'; import { isVersionOlder, normalizeVersion } from '@shared/utils/version'; import { app, net } from 'electron'; import electronUpdater from 'electron-updater'; @@ -97,6 +101,7 @@ export class UpdaterService { constructor() { autoUpdater.autoDownload = false; autoUpdater.autoInstallOnAppQuit = true; + autoUpdater.fullChangelog = true; this.bindEvents(); } @@ -241,11 +246,14 @@ export class UpdaterService { return; } + const latestReleaseNote = getUpdaterReleaseNoteForVersion(info.releaseNotes, info.version); + const releaseNotes = formatUpdaterReleaseNotes(info.releaseNotes); + if ( shouldSkipReleaseForUpdater({ tag_name: `v${info.version}`, name: typeof info.releaseName === 'string' ? info.releaseName : undefined, - body: typeof info.releaseNotes === 'string' ? info.releaseNotes : undefined, + body: latestReleaseNote, }) ) { logger.warn(`Suppressing updater notification for locally marked release ${info.version}`); @@ -278,7 +286,7 @@ export class UpdaterService { this.sendStatus({ type: 'available', version: info.version, - releaseNotes: typeof info.releaseNotes === 'string' ? info.releaseNotes : undefined, + releaseNotes, }); } diff --git a/src/renderer/components/common/UpdateBanner.tsx b/src/renderer/components/common/UpdateBanner.tsx index 62d6a0b2..c3234fe7 100644 --- a/src/renderer/components/common/UpdateBanner.tsx +++ b/src/renderer/components/common/UpdateBanner.tsx @@ -4,6 +4,7 @@ * Visible during download and after the update is ready to install. */ +import { isElectronMode } from '@renderer/api'; import { useStore } from '@renderer/store'; import { CheckCircle, Loader2, X } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; @@ -34,14 +35,20 @@ export const UpdateBanner = (): React.JSX.Element | null => { const isDownloading = updateStatus === 'downloading'; const percent = Math.round(downloadProgress); const clampedPercent = Math.max(0, Math.min(percent, 100)); + const isMacElectron = + isElectronMode() && window.navigator.userAgent.toLowerCase().includes('mac'); return (
{isDownloading ? (
@@ -79,10 +86,13 @@ export const UpdateBanner = (): React.JSX.Element | null => { @@ -93,7 +103,12 @@ export const UpdateBanner = (): React.JSX.Element | null => { diff --git a/src/renderer/components/common/UpdateDialog.tsx b/src/renderer/components/common/UpdateDialog.tsx index 13138050..72fa533d 100644 --- a/src/renderer/components/common/UpdateDialog.tsx +++ b/src/renderer/components/common/UpdateDialog.tsx @@ -13,6 +13,7 @@ import { isElectronMode } from '@renderer/api'; import { markdownComponents } from '@renderer/components/chat/markdownComponents'; import { useStore } from '@renderer/store'; import { REHYPE_PLUGINS } from '@renderer/utils/markdownPlugins'; +import { stripDownloadsSection } from '@shared/utils/releaseNotes'; import { ExternalLink, X } from 'lucide-react'; import remarkGfm from 'remark-gfm'; import { useShallow } from 'zustand/react/shallow'; @@ -94,10 +95,7 @@ export const UpdateDialog = (): React.JSX.Element | null => { const isDownloaded = updateStatus === 'downloaded'; - // Strip "Downloads" section (and everything after it) from release notes - const filteredNotes = releaseNotes - ? releaseNotes.replace(/\n#{1,3}\s+Downloads[\s\S]*$/i, '').trimEnd() - : releaseNotes; + const filteredNotes = releaseNotes ? stripDownloadsSection(releaseNotes) : releaseNotes; const releaseUrl = availableVersion ? `https://github.com/777genius/agent-teams-ai/releases/tag/v${availableVersion}` diff --git a/src/shared/utils/releaseNotes.ts b/src/shared/utils/releaseNotes.ts new file mode 100644 index 00000000..4bee0313 --- /dev/null +++ b/src/shared/utils/releaseNotes.ts @@ -0,0 +1,88 @@ +interface ReleaseNoteEntry { + readonly version?: unknown; + readonly note?: unknown; +} + +function isDownloadsHeading(line: string): boolean { + const trimmed = line.trim(); + if (!trimmed) { + return false; + } + + const markdownHeading = /^#{1,6}\s*(.+?)\s*#*\s*$/.exec(trimmed); + const htmlHeading = /^]*>\s*(.+?)\s*<\/h[1-6]>\s*$/i.exec(trimmed); + const headingText = markdownHeading?.[1] ?? htmlHeading?.[1]; + if (!headingText) { + return false; + } + + const words = headingText.toLowerCase().match(/[a-z0-9]+/g) ?? []; + return words.length > 0 && words.every((word) => word === 'download' || word === 'downloads'); +} + +export function stripDownloadsSection(markdown: string): string { + const lines = markdown.split(/\r?\n/u); + const downloadsHeadingIndex = lines.findIndex(isDownloadsHeading); + if (downloadsHeadingIndex === -1) { + return markdown.trimEnd(); + } + + return lines.slice(0, downloadsHeadingIndex).join('\n').trimEnd(); +} + +function normalizeReleaseNoteEntry(entry: unknown): { version: string; note: string } | null { + if (!entry || typeof entry !== 'object') { + return null; + } + + const { version, note } = entry as ReleaseNoteEntry; + if (typeof version !== 'string' || version.trim() === '') { + return null; + } + + return { + version: version.trim().replace(/^v/i, ''), + note: typeof note === 'string' ? note : '', + }; +} + +export function getUpdaterReleaseNoteForVersion( + releaseNotes: unknown, + version: string +): string | undefined { + if (typeof releaseNotes === 'string') { + return releaseNotes; + } + + if (!Array.isArray(releaseNotes)) { + return undefined; + } + + const normalizedVersion = version.trim().replace(/^v/i, ''); + return ( + releaseNotes + .map(normalizeReleaseNoteEntry) + .find((entry) => entry?.version === normalizedVersion)?.note || undefined + ); +} + +export function formatUpdaterReleaseNotes(releaseNotes: unknown): string | undefined { + if (typeof releaseNotes === 'string') { + const stripped = stripDownloadsSection(releaseNotes); + return stripped || undefined; + } + + if (!Array.isArray(releaseNotes)) { + return undefined; + } + + const formattedNotes = releaseNotes + .map(normalizeReleaseNoteEntry) + .filter((entry): entry is { version: string; note: string } => entry !== null) + .map(({ version, note }) => { + const strippedNote = stripDownloadsSection(note); + return [`## v${version}`, strippedNote || '_No release notes provided._'].join('\n\n'); + }); + + return formattedNotes.length > 0 ? formattedNotes.join('\n\n') : undefined; +} diff --git a/test/shared/utils/releaseNotes.test.ts b/test/shared/utils/releaseNotes.test.ts new file mode 100644 index 00000000..395ae1db --- /dev/null +++ b/test/shared/utils/releaseNotes.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; + +import { + formatUpdaterReleaseNotes, + getUpdaterReleaseNoteForVersion, + stripDownloadsSection, +} from '../../../src/shared/utils/releaseNotes'; + +describe('releaseNotes utilities', () => { + it('strips markdown Downloads sections even when they start the note', () => { + expect( + stripDownloadsSection(`### Downloads + + + +
installer links
`) + ).toBe(''); + }); + + it('strips Downloads sections without removing inline download mentions', () => { + const notes = `Patch release focused on smoother downloads. + +### Fixed + +- Download progress no longer gets stuck. + +### Downloads + + + +
installer links
`; + + expect(stripDownloadsSection(notes)).toBe(`Patch release focused on smoother downloads. + +### Fixed + +- Download progress no longer gets stuck.`); + }); + + it('strips html Downloads headings', () => { + expect(stripDownloadsSection('Fixed\n\n

Downloads

\nlinks
')).toBe( + 'Fixed' + ); + }); + + it('formats full-changelog updater notes as a version list', () => { + const notes = formatUpdaterReleaseNotes([ + { + version: '2.0.2', + note: `Fixed launch reliability. + +### Downloads + +links
`, + }, + { + version: '2.0.1', + note: 'Improved provider settings.', + }, + ]); + + expect(notes).toBe(`## v2.0.2 + +Fixed launch reliability. + +## v2.0.1 + +Improved provider settings.`); + }); + + it('strips Downloads from single-release string notes', () => { + expect(formatUpdaterReleaseNotes('Fixed\n\n### Downloads\nlinks
')).toBe('Fixed'); + }); + + it('reads only the requested release note from full-changelog arrays', () => { + expect( + getUpdaterReleaseNoteForVersion( + [ + { version: '2.0.2', note: 'Latest note' }, + { version: '2.0.1', note: 'Older [skip-updater] note' }, + ], + 'v2.0.2' + ) + ).toBe('Latest note'); + }); +}); From c3b6d2dea858ec546b030744675f7dd45280b0db Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 20 May 2026 13:03:24 +0300 Subject: [PATCH 03/44] feat(opencode): support local model readiness --- .../readiness/OpenCodeTeamLaunchReadiness.ts | 38 +++++++- src/shared/utils/opencodeModelRef.ts | 27 ++++-- .../team/OpenCodeTeamLaunchReadiness.test.ts | 87 ++++++++++++++++++- test/shared/utils/opencodeModelRef.test.ts | 36 ++++++++ 4 files changed, 175 insertions(+), 13 deletions(-) create mode 100644 test/shared/utils/opencodeModelRef.test.ts diff --git a/src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness.ts b/src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness.ts index 7ebc877f..dd6277ef 100644 --- a/src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness.ts +++ b/src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness.ts @@ -1,3 +1,5 @@ +import { parseOpenCodeQualifiedModelRef } from '@shared/utils/opencodeModelRef'; + import { evaluateOpenCodeSupport, OPENCODE_TEAM_LAUNCH_VERSION_POLICY, @@ -34,7 +36,7 @@ export interface OpenCodeRuntimeInventory { } export interface OpenCodeModelExecutionProbeResult { - outcome: 'available' | 'unavailable' | 'unknown'; + outcome: 'available' | 'unavailable' | 'not_authenticated' | 'unknown'; reason: string | null; diagnostics: string[]; } @@ -103,8 +105,20 @@ const OPENCODE_RUNTIME_BINARY_UNREACHABLE_DIAGNOSTIC = 'OpenCode runtime binary is not installed or not reachable by launch preflight.'; const OPENCODE_UNAUTHENTICATED_FREE_MODEL_DIAGNOSTIC = 'No connected OpenCode provider found. Proceeding with a free OpenCode model route that does not require provider authentication.'; +const OPENCODE_AUTHLESS_LOCAL_MODEL_DIAGNOSTIC = + 'No connected OpenCode provider found. Proceeding with a configured local OpenCode model route after execution proof.'; const OPENCODE_UNAUTHENTICATED_PAID_MODEL_DIAGNOSTIC = 'No connected OpenCode provider found. Choose a free OpenCode model such as Big Pickle, or connect a provider in OpenCode for provider-backed models.'; +const AUTHLESS_LOCAL_OPEN_CODE_PROVIDER_IDS = new Set([ + 'atomic-chat', + 'llama.cpp', + 'llamacpp', + 'lmstudio', + 'lm-studio', + 'local', + 'ollama', + 'vllm', +]); export class OpenCodeTeamLaunchReadinessService { constructor( @@ -155,8 +169,14 @@ export class OpenCodeTeamLaunchReadinessService { const usingFreeModelWithoutProvider = !hasConnectedProvider && isFreeOpenCodeModelRoute(modelId); + const usingConfiguredAuthlessModelWithoutProvider = + !hasConnectedProvider && isConfiguredAuthlessOpenCodeModelRoute(modelId); - if (!hasConnectedProvider && !usingFreeModelWithoutProvider) { + if ( + !hasConnectedProvider && + !usingFreeModelWithoutProvider && + !(usingConfiguredAuthlessModelWithoutProvider && input.requireExecutionProbe) + ) { return readiness({ state: 'not_authenticated', inventory, @@ -233,7 +253,10 @@ export class OpenCodeTeamLaunchReadinessService { }); if (modelProbe.outcome !== 'available') { return readiness({ - state: 'model_unavailable', + state: + modelProbe.outcome === 'not_authenticated' + ? 'not_authenticated' + : 'model_unavailable', inventory, modelId, capabilities, @@ -259,7 +282,9 @@ export class OpenCodeTeamLaunchReadinessService { ? appendDiagnostics(inventory.diagnostics, [ OPENCODE_UNAUTHENTICATED_FREE_MODEL_DIAGNOSTIC, ]) - : inventory.diagnostics, + : usingConfiguredAuthlessModelWithoutProvider + ? appendDiagnostics(inventory.diagnostics, [OPENCODE_AUTHLESS_LOCAL_MODEL_DIAGNOSTIC]) + : inventory.diagnostics, }); } catch (error) { return readiness({ @@ -282,6 +307,11 @@ function isFreeOpenCodeModelRoute(modelId: string): boolean { ); } +function isConfiguredAuthlessOpenCodeModelRoute(modelId: string): boolean { + const parsed = parseOpenCodeQualifiedModelRef(modelId); + return parsed ? AUTHLESS_LOCAL_OPEN_CODE_PROVIDER_IDS.has(parsed.sourceId) : false; +} + function readiness(input: { state: OpenCodeTeamLaunchReadinessState; inventory: OpenCodeRuntimeInventory | null; diff --git a/src/shared/utils/opencodeModelRef.ts b/src/shared/utils/opencodeModelRef.ts index d6dfd5b1..45dd3ecd 100644 --- a/src/shared/utils/opencodeModelRef.ts +++ b/src/shared/utils/opencodeModelRef.ts @@ -4,7 +4,7 @@ export interface OpenCodeQualifiedModelRef { raw: string; } -const OPEN_CODE_MODEL_REF_PATTERN = /^(?[a-z0-9-]+)\/(?\S.*)$/i; +const OPEN_CODE_SOURCE_ID_PATTERN = /^[a-z0-9._-]+$/i; const OPEN_CODE_SOURCE_LABELS: Record = { anthropic: 'Anthropic', @@ -14,6 +14,10 @@ const OPEN_CODE_SOURCE_LABELS: Record = { gemini: 'Gemini', google: 'Google', groq: 'Groq', + 'llama.cpp': 'llama.cpp', + llamacpp: 'llama.cpp', + lmstudio: 'LM Studio', + 'lm-studio': 'LM Studio', minimax: 'MiniMax', mistral: 'Mistral', moonshot: 'Moonshot', @@ -24,6 +28,7 @@ const OPEN_CODE_SOURCE_LABELS: Record = { openrouter: 'OpenRouter', together: 'Together', vertex: 'Vertex', + vllm: 'vLLM', xai: 'xAI', 'z-ai': 'Z.AI', }; @@ -40,7 +45,7 @@ function humanizeOpenCodeSourceId(sourceId: string): string { } return normalized - .split('-') + .split(/[-._]/g) .filter(Boolean) .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) .join(' '); @@ -50,19 +55,25 @@ export function parseOpenCodeQualifiedModelRef( model: string | undefined | null ): OpenCodeQualifiedModelRef | null { const trimmed = model?.trim(); - if (!trimmed) { + if (!trimmed || /\s/.test(trimmed)) { return null; } - const match = OPEN_CODE_MODEL_REF_PATTERN.exec(trimmed); - if (!match?.groups?.source || !match.groups.model) { + const separatorIndex = trimmed.indexOf('/'); + if (separatorIndex <= 0 || separatorIndex >= trimmed.length - 1) { + return null; + } + + const sourceId = trimmed.slice(0, separatorIndex).toLowerCase(); + const modelId = trimmed.slice(separatorIndex + 1); + if (!OPEN_CODE_SOURCE_ID_PATTERN.test(sourceId) || !modelId) { return null; } return { - raw: trimmed, - sourceId: match.groups.source.toLowerCase(), - modelId: match.groups.model, + raw: `${sourceId}/${modelId}`, + sourceId, + modelId, }; } diff --git a/test/main/services/team/OpenCodeTeamLaunchReadiness.test.ts b/test/main/services/team/OpenCodeTeamLaunchReadiness.test.ts index d4efa6fb..2edfbe00 100644 --- a/test/main/services/team/OpenCodeTeamLaunchReadiness.test.ts +++ b/test/main/services/team/OpenCodeTeamLaunchReadiness.test.ts @@ -114,6 +114,91 @@ describe('OpenCodeTeamLaunchReadinessService', () => { expect(ports.mcpTools.prove).not.toHaveBeenCalled(); }); + it('requires execution probe before allowing unauthenticated configured local models', async () => { + const ports = createPorts({ + inventory: { + authenticated: false, + connectedProviders: [], + models: ['llama.cpp/qwen-test:0.5b'], + }, + }); + + await expect( + service(ports).check( + readinessInput({ + selectedModel: 'llama.cpp/qwen-test:0.5b', + requireExecutionProbe: false, + }) + ) + ).resolves.toMatchObject({ + state: 'not_authenticated', + launchAllowed: false, + modelId: 'llama.cpp/qwen-test:0.5b', + }); + expect(ports.modelExecution.verify).not.toHaveBeenCalled(); + }); + + it('allows unauthenticated configured local models after execution proof', async () => { + const ports = createPorts({ + inventory: { + authenticated: false, + connectedProviders: [], + models: ['llama.cpp/qwen-test:0.5b'], + }, + }); + + await expect( + service(ports).check( + readinessInput({ + selectedModel: 'llama.cpp/qwen-test:0.5b', + requireExecutionProbe: true, + }) + ) + ).resolves.toMatchObject({ + state: 'ready', + launchAllowed: true, + modelId: 'llama.cpp/qwen-test:0.5b', + diagnostics: [ + 'No connected OpenCode provider found. Proceeding with a configured local OpenCode model route after execution proof.', + ], + }); + expect(ports.modelExecution.verify).toHaveBeenCalledWith({ + projectPath: '/repo', + modelId: 'llama.cpp/qwen-test:0.5b', + inventory: expect.objectContaining({ + connectedProviders: [], + }), + }); + }); + + it('maps execution probe authentication failures to not_authenticated', async () => { + const ports = createPorts({ + inventory: { + authenticated: false, + connectedProviders: [], + models: ['llama.cpp/qwen-test:0.5b'], + }, + modelProbe: { + outcome: 'not_authenticated', + reason: 'local server rejected request', + diagnostics: ['local server rejected request'], + }, + }); + + await expect( + service(ports).check( + readinessInput({ + selectedModel: 'llama.cpp/qwen-test:0.5b', + requireExecutionProbe: true, + }) + ) + ).resolves.toMatchObject({ + state: 'not_authenticated', + launchAllowed: false, + missing: ['local server rejected request'], + }); + }); + it('blocks unsupported versions before MCP and model probes', async () => { const ports = createPorts({ inventory: { version: '1.4.0' }, @@ -271,7 +356,7 @@ function createPorts( toolProof?: OpenCodeMcpToolProof; runtimeStores?: RuntimeStoreReadinessCheck; modelProbe?: { - outcome: 'available' | 'unavailable' | 'unknown'; + outcome: 'available' | 'unavailable' | 'not_authenticated' | 'unknown'; reason: string | null; diagnostics: string[]; }; diff --git a/test/shared/utils/opencodeModelRef.test.ts b/test/shared/utils/opencodeModelRef.test.ts new file mode 100644 index 00000000..ce2ce383 --- /dev/null +++ b/test/shared/utils/opencodeModelRef.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; + +import { + getOpenCodeQualifiedModelSourceLabel, + parseOpenCodeQualifiedModelRef, +} from '../../../src/shared/utils/opencodeModelRef'; + +describe('opencodeModelRef', () => { + it('parses dotted providers and model ids with nested slashes', () => { + expect(parseOpenCodeQualifiedModelRef('llama.cpp/qwen3-coder:a3b')).toEqual({ + raw: 'llama.cpp/qwen3-coder:a3b', + sourceId: 'llama.cpp', + modelId: 'qwen3-coder:a3b', + }); + + expect(parseOpenCodeQualifiedModelRef('lmstudio/google/gemma-3n-e4b')).toEqual({ + raw: 'lmstudio/google/gemma-3n-e4b', + sourceId: 'lmstudio', + modelId: 'google/gemma-3n-e4b', + }); + }); + + it('rejects whitespace and unscoped OpenCode model refs', () => { + expect(parseOpenCodeQualifiedModelRef('llama.cpp/qwen test')).toBeNull(); + expect(parseOpenCodeQualifiedModelRef('qwen3-coder:a3b')).toBeNull(); + }); + + it('labels common local OpenCode providers', () => { + expect(getOpenCodeQualifiedModelSourceLabel('llama.cpp/qwen3-coder:a3b')).toBe( + 'llama.cpp' + ); + expect(getOpenCodeQualifiedModelSourceLabel('lmstudio/google/gemma-3n-e4b')).toBe( + 'LM Studio' + ); + }); +}); From d7f82e54d1a0a89ee5592712af19737338352114 Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 20 May 2026 15:15:42 +0300 Subject: [PATCH 04/44] fix(opencode): hydrate summary model catalog --- .../runtime/providerConnectionUi.ts | 18 +- .../store/slices/cliInstallerSlice.ts | 22 ++- .../runtime/providerConnectionUi.test.ts | 15 +- test/renderer/store/cliInstallerSlice.test.ts | 155 ++++++++++++++++++ 4 files changed, 191 insertions(+), 19 deletions(-) diff --git a/src/renderer/components/runtime/providerConnectionUi.ts b/src/renderer/components/runtime/providerConnectionUi.ts index 3b6d49e4..cfe58b58 100644 --- a/src/renderer/components/runtime/providerConnectionUi.ts +++ b/src/renderer/components/runtime/providerConnectionUi.ts @@ -120,21 +120,13 @@ export function isOpenCodeCatalogHydrating( return false; } - if ( - provider.modelCatalogRefreshState === 'ready' || - provider.modelCatalogRefreshState === 'error' - ) { + if (provider.modelCatalogRefreshState === 'error') { return false; } - const hasOnlySummaryFallback = - provider.models.length === 0 || - provider.models.every((model) => model.trim() === 'opencode/big-pickle'); - return ( - hasOnlySummaryFallback && - (provider.modelCatalogRefreshState === 'loading' || - provider.runtimeCapabilities?.modelCatalog?.dynamic === true) + provider.modelCatalogRefreshState === 'loading' || + provider.runtimeCapabilities?.modelCatalog?.dynamic === true ); } @@ -142,7 +134,7 @@ export function isConnectionManagedRuntimeProvider(provider: CliProviderStatus): return provider.providerId === 'codex'; } -function getCodexCurrentRuntimeLabel(provider: CliProviderStatus): string { +function getCodexCurrentRuntimeLabel(): string { return CODEX_NATIVE_LABEL; } @@ -213,7 +205,7 @@ export function getProviderCurrentRuntimeSummary(provider: CliProviderStatus): s } const prefix = provider.authenticated ? 'Current runtime' : 'Selected runtime'; - return `${prefix}: ${getCodexCurrentRuntimeLabel(provider)}`; + return `${prefix}: ${getCodexCurrentRuntimeLabel()}`; } export function formatProviderStatusText(provider: CliProviderStatus): string { diff --git a/src/renderer/store/slices/cliInstallerSlice.ts b/src/renderer/store/slices/cliInstallerSlice.ts index 9949e853..1d6469df 100644 --- a/src/renderer/store/slices/cliInstallerSlice.ts +++ b/src/renderer/store/slices/cliInstallerSlice.ts @@ -93,6 +93,18 @@ function isModelOnlyFallbackProviderStatus(provider: CliProviderStatus | undefin ); } +function isOpenCodeSummaryOnlyCatalogStatus(provider: CliProviderStatus | undefined): boolean { + if (provider?.providerId !== 'opencode') { + return false; + } + + if (provider.modelCatalog?.providerId === 'opencode' && provider.modelCatalog.models.length > 0) { + return false; + } + + return provider.runtimeCapabilities?.modelCatalog?.dynamic === true; +} + function isHydratedMultimodelProviderStatus(provider: CliProviderStatus | undefined): boolean { if (!provider) { return false; @@ -102,6 +114,10 @@ function isHydratedMultimodelProviderStatus(provider: CliProviderStatus | undefi return false; } + if (isOpenCodeSummaryOnlyCatalogStatus(provider)) { + return false; + } + return !( provider.supported === false && provider.authenticated === false && @@ -131,7 +147,11 @@ function getProviderStatus( } function hasOpenCodeModels(provider: CliProviderStatus | undefined): boolean { - return provider?.providerId === 'opencode' && provider.models.length > 0; + return ( + provider?.providerId === 'opencode' && + provider.models.length > 0 && + !isOpenCodeSummaryOnlyCatalogStatus(provider) + ); } function hasCodexRuntimeReady(provider: CliProviderStatus | undefined): boolean { diff --git a/test/renderer/components/runtime/providerConnectionUi.test.ts b/test/renderer/components/runtime/providerConnectionUi.test.ts index 330c3759..3d55d33f 100644 --- a/test/renderer/components/runtime/providerConnectionUi.test.ts +++ b/test/renderer/components/runtime/providerConnectionUi.test.ts @@ -1,16 +1,15 @@ -import { describe, expect, it } from 'vitest'; - import { formatProviderStatusText, getProviderConnectionModeSummary, getProviderCredentialSummary, getProviderCurrentRuntimeSummary, - isProviderInventoryOnlyFallback, - isOpenCodeCatalogHydrating, isConnectionManagedRuntimeProvider, + isOpenCodeCatalogHydrating, + isProviderInventoryOnlyFallback, shouldShowProviderConnectAction, } from '@renderer/components/runtime/providerConnectionUi'; import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities'; +import { describe, expect, it } from 'vitest'; import type { CliProviderStatus } from '@shared/types'; @@ -259,12 +258,18 @@ describe('providerConnectionUi', () => { ...provider, modelCatalogRefreshState: 'ready', }) - ).toBe(false); + ).toBe(true); expect( isOpenCodeCatalogHydrating({ ...provider, models: ['opencode/big-pickle', 'openrouter/qwen/qwen3-coder-plus'], }) + ).toBe(true); + expect( + isOpenCodeCatalogHydrating({ + ...provider, + modelCatalogRefreshState: 'error', + }) ).toBe(false); }); diff --git a/test/renderer/store/cliInstallerSlice.test.ts b/test/renderer/store/cliInstallerSlice.test.ts index 60cf298f..f7c08bba 100644 --- a/test/renderer/store/cliInstallerSlice.test.ts +++ b/test/renderer/store/cliInstallerSlice.test.ts @@ -237,6 +237,28 @@ describe('cliInstallerSlice', () => { expect(getModelOnlyFallbackProviderIds(status)).toEqual(['opencode']); }); + it('classifies OpenCode summary-only model lists as incomplete until catalog hydration', () => { + const status = createMultimodelStatus([ + createMultimodelProvider({ + providerId: 'opencode', + displayName: 'OpenCode', + authenticated: true, + authMethod: 'opencode_managed', + models: ['opencode/big-pickle'], + backend: { kind: 'opencode-cli', label: 'OpenCode CLI' }, + runtimeCapabilities: { + modelCatalog: { + dynamic: true, + source: 'app-server', + }, + }, + }), + ]); + + expect(getIncompleteMultimodelProviderIds(status)).toEqual(['opencode']); + expect(getModelOnlyFallbackProviderIds(status)).toEqual([]); + }); + it('keeps connection-enriched checking placeholders incomplete until provider hydration finishes', () => { const status = createMultimodelStatus([ createMultimodelProvider({ @@ -1002,6 +1024,139 @@ describe('cliInstallerSlice', () => { backend: { kind: 'opencode-cli', label: 'OpenCode CLI' }, }); }); + + it('refreshes OpenCode when bootstrap metadata has summary-only big-pickle models', async () => { + const mockStatus = createMultimodelStatus([ + createMultimodelProvider({ + providerId: 'anthropic', + displayName: 'Anthropic', + authenticated: true, + authMethod: 'oauth_token', + models: ['claude-sonnet-4-5'], + backend: { kind: 'anthropic', label: 'Anthropic' }, + }), + createMultimodelProvider({ + providerId: 'codex', + displayName: 'Codex', + authenticated: true, + authMethod: 'chatgpt', + models: ['gpt-5.4'], + backend: { kind: 'codex-native', label: 'Codex' }, + }), + createMultimodelProvider({ + providerId: 'opencode', + displayName: 'OpenCode', + authenticated: true, + authMethod: 'opencode_managed', + models: ['opencode/big-pickle'], + backend: { kind: 'opencode-cli', label: 'OpenCode CLI' }, + runtimeCapabilities: { + modelCatalog: { + dynamic: true, + source: 'app-server', + }, + }, + }), + ]); + vi.mocked(api.cliInstaller.getStatus).mockResolvedValue(mockStatus); + vi.mocked(api.cliInstaller.getProviderStatus).mockImplementation((providerId) => { + if (providerId === 'opencode') { + return Promise.resolve( + createMultimodelProvider({ + providerId: 'opencode', + displayName: 'OpenCode', + authenticated: true, + authMethod: 'opencode_managed', + models: [ + 'opencode/big-pickle', + 'openai/gpt-5.4', + 'openrouter/openai/gpt-oss-20b:free', + ], + modelCatalogRefreshState: 'ready', + modelCatalog: { + schemaVersion: 1, + providerId: 'opencode', + source: 'app-server', + status: 'ready', + fetchedAt: '2026-05-20T00:00:00.000Z', + staleAt: '2026-05-20T00:10:00.000Z', + defaultModelId: 'opencode/big-pickle', + defaultLaunchModel: 'opencode/big-pickle', + models: [ + { + id: 'opencode/big-pickle', + launchModel: 'opencode/big-pickle', + displayName: 'opencode/big-pickle', + hidden: false, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + inputModalities: ['text'], + supportsPersonality: true, + isDefault: true, + upgrade: false, + source: 'app-server', + badgeLabel: 'Free', + }, + { + id: 'openai/gpt-5.4', + launchModel: 'openai/gpt-5.4', + displayName: 'openai/gpt-5.4', + hidden: false, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + inputModalities: ['text'], + supportsPersonality: true, + isDefault: false, + upgrade: false, + source: 'app-server', + }, + { + id: 'openrouter/openai/gpt-oss-20b:free', + launchModel: 'openrouter/openai/gpt-oss-20b:free', + displayName: 'openrouter/openai/gpt-oss-20b:free', + hidden: false, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + inputModalities: ['text'], + supportsPersonality: true, + isDefault: false, + upgrade: false, + source: 'app-server', + badgeLabel: 'Free', + }, + ], + diagnostics: { + configReadState: 'ready', + appServerState: 'healthy', + }, + }, + backend: { kind: 'opencode-cli', label: 'OpenCode CLI' }, + runtimeCapabilities: { + modelCatalog: { + dynamic: true, + source: 'app-server', + }, + }, + }) + ); + } + return Promise.reject(new Error(`Unexpected provider status request for ${providerId}`)); + }); + + await useStore.getState().bootstrapCliStatus({ multimodelEnabled: true }); + + expect(api.cliInstaller.getProviderStatus).toHaveBeenCalledTimes(1); + expect(api.cliInstaller.getProviderStatus).toHaveBeenCalledWith('opencode'); + const opencode = useStore + .getState() + .cliStatus?.providers.find((provider) => provider.providerId === 'opencode'); + expect(opencode?.models).toEqual([ + 'opencode/big-pickle', + 'openai/gpt-5.4', + 'openrouter/openai/gpt-oss-20b:free', + ]); + expect(opencode?.modelCatalog?.models).toHaveLength(3); + }); }); describe('installCli', () => { From 16c1e4b47d05f5adb6c6bea62dfa6f5124e748e7 Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 20 May 2026 15:31:20 +0300 Subject: [PATCH 05/44] fix(opencode): settle empty catalog hydration --- .../runtime/providerConnectionUi.ts | 2 +- .../store/slices/cliInstallerSlice.ts | 6 +- .../runtime/providerConnectionUi.test.ts | 20 +++++++ test/renderer/store/cliInstallerSlice.test.ts | 59 +++++++++++++++++++ 4 files changed, 85 insertions(+), 2 deletions(-) diff --git a/src/renderer/components/runtime/providerConnectionUi.ts b/src/renderer/components/runtime/providerConnectionUi.ts index cfe58b58..643122af 100644 --- a/src/renderer/components/runtime/providerConnectionUi.ts +++ b/src/renderer/components/runtime/providerConnectionUi.ts @@ -116,7 +116,7 @@ export function isOpenCodeCatalogHydrating( return false; } - if (provider.modelCatalog?.providerId === 'opencode' && provider.modelCatalog.models.length > 0) { + if (provider.modelCatalog?.providerId === 'opencode') { return false; } diff --git a/src/renderer/store/slices/cliInstallerSlice.ts b/src/renderer/store/slices/cliInstallerSlice.ts index 1d6469df..94fb1995 100644 --- a/src/renderer/store/slices/cliInstallerSlice.ts +++ b/src/renderer/store/slices/cliInstallerSlice.ts @@ -98,7 +98,11 @@ function isOpenCodeSummaryOnlyCatalogStatus(provider: CliProviderStatus | undefi return false; } - if (provider.modelCatalog?.providerId === 'opencode' && provider.modelCatalog.models.length > 0) { + if (provider.modelCatalog?.providerId === 'opencode') { + return false; + } + + if (provider.modelCatalogRefreshState === 'error') { return false; } diff --git a/test/renderer/components/runtime/providerConnectionUi.test.ts b/test/renderer/components/runtime/providerConnectionUi.test.ts index 3d55d33f..6847eddf 100644 --- a/test/renderer/components/runtime/providerConnectionUi.test.ts +++ b/test/renderer/components/runtime/providerConnectionUi.test.ts @@ -271,6 +271,26 @@ describe('providerConnectionUi', () => { modelCatalogRefreshState: 'error', }) ).toBe(false); + expect( + isOpenCodeCatalogHydrating({ + ...provider, + modelCatalog: { + schemaVersion: 1, + providerId: 'opencode', + source: 'app-server', + status: 'ready', + fetchedAt: '2026-05-20T00:00:00.000Z', + staleAt: '2026-05-20T00:10:00.000Z', + defaultModelId: null, + defaultLaunchModel: null, + models: [], + diagnostics: { + configReadState: 'ready', + appServerState: 'healthy', + }, + }, + }) + ).toBe(false); }); it('does not describe Anthropic API key mode as subscription connected when the key is missing', () => { diff --git a/test/renderer/store/cliInstallerSlice.test.ts b/test/renderer/store/cliInstallerSlice.test.ts index f7c08bba..3be8043c 100644 --- a/test/renderer/store/cliInstallerSlice.test.ts +++ b/test/renderer/store/cliInstallerSlice.test.ts @@ -259,6 +259,65 @@ describe('cliInstallerSlice', () => { expect(getModelOnlyFallbackProviderIds(status)).toEqual([]); }); + it('treats an empty OpenCode model catalog as hydrated', () => { + const status = createMultimodelStatus([ + createMultimodelProvider({ + providerId: 'opencode', + displayName: 'OpenCode', + authenticated: false, + authMethod: null, + models: [], + backend: { kind: 'opencode-cli', label: 'OpenCode CLI' }, + modelCatalogRefreshState: 'ready', + modelCatalog: { + schemaVersion: 1, + providerId: 'opencode', + source: 'app-server', + status: 'ready', + fetchedAt: '2026-05-20T00:00:00.000Z', + staleAt: '2026-05-20T00:10:00.000Z', + defaultModelId: null, + defaultLaunchModel: null, + models: [], + diagnostics: { + configReadState: 'ready', + appServerState: 'healthy', + }, + }, + runtimeCapabilities: { + modelCatalog: { + dynamic: true, + source: 'app-server', + }, + }, + }), + ]); + + expect(getIncompleteMultimodelProviderIds(status)).toEqual([]); + }); + + it('does not keep OpenCode catalog errors marked as incomplete', () => { + const status = createMultimodelStatus([ + createMultimodelProvider({ + providerId: 'opencode', + displayName: 'OpenCode', + verificationState: 'error', + statusMessage: 'Catalog hydration failed', + models: [], + modelCatalog: null, + modelCatalogRefreshState: 'error', + runtimeCapabilities: { + modelCatalog: { + dynamic: true, + source: 'app-server', + }, + }, + }), + ]); + + expect(getIncompleteMultimodelProviderIds(status)).toEqual([]); + }); + it('keeps connection-enriched checking placeholders incomplete until provider hydration finishes', () => { const status = createMultimodelStatus([ createMultimodelProvider({ From e3ff8e1df5cfe7a0874e31074224b624a31a1c79 Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 20 May 2026 15:38:46 +0300 Subject: [PATCH 06/44] fix(opencode): preserve hydrated model lists --- .../infrastructure/CliInstallerService.ts | 8 +- .../store/slices/cliInstallerSlice.ts | 8 +- .../CliInstallerService.test.ts | 139 ++++++++++++++++++ test/renderer/store/cliInstallerSlice.test.ts | 105 +++++++++++++ 4 files changed, 258 insertions(+), 2 deletions(-) diff --git a/src/main/services/infrastructure/CliInstallerService.ts b/src/main/services/infrastructure/CliInstallerService.ts index ee902cb7..24efe352 100644 --- a/src/main/services/infrastructure/CliInstallerService.ts +++ b/src/main/services/infrastructure/CliInstallerService.ts @@ -224,10 +224,16 @@ function mergeProviderStatusCatalogCache( ): CliProviderStatus { const modelCatalog = incomingProvider.modelCatalog ?? currentProvider.modelCatalog ?? null; const incomingRefreshState = incomingProvider.modelCatalogRefreshState ?? null; + const shouldPreserveCurrentModels = + incomingProvider.models.length === 0 || + (incomingProvider.providerId === 'opencode' && + incomingProvider.modelCatalog == null && + incomingProvider.runtimeCapabilities?.modelCatalog?.dynamic === true && + currentProvider.models.length > incomingProvider.models.length); return { ...incomingProvider, - models: incomingProvider.models.length > 0 ? incomingProvider.models : currentProvider.models, + models: shouldPreserveCurrentModels ? currentProvider.models : incomingProvider.models, modelCatalog, modelCatalogRefreshState: modelCatalog && incomingRefreshState !== 'error' diff --git a/src/renderer/store/slices/cliInstallerSlice.ts b/src/renderer/store/slices/cliInstallerSlice.ts index 94fb1995..4172dd2b 100644 --- a/src/renderer/store/slices/cliInstallerSlice.ts +++ b/src/renderer/store/slices/cliInstallerSlice.ts @@ -204,9 +204,15 @@ function mergeProviderCatalogCache( ): CliProviderStatus { const modelCatalog = incomingProvider.modelCatalog ?? currentProvider.modelCatalog ?? null; const incomingRefreshState = incomingProvider.modelCatalogRefreshState ?? null; + const shouldPreserveCurrentModels = + incomingProvider.models.length === 0 || + (incomingProvider.providerId === 'opencode' && + incomingProvider.modelCatalog == null && + incomingProvider.runtimeCapabilities?.modelCatalog?.dynamic === true && + currentProvider.models.length > incomingProvider.models.length); return { ...incomingProvider, - models: incomingProvider.models.length > 0 ? incomingProvider.models : currentProvider.models, + models: shouldPreserveCurrentModels ? currentProvider.models : incomingProvider.models, modelCatalog, modelCatalogRefreshState: modelCatalog && incomingRefreshState !== 'error' diff --git a/test/main/services/infrastructure/CliInstallerService.test.ts b/test/main/services/infrastructure/CliInstallerService.test.ts index 8eaca617..f02489ad 100644 --- a/test/main/services/infrastructure/CliInstallerService.test.ts +++ b/test/main/services/infrastructure/CliInstallerService.test.ts @@ -712,6 +712,145 @@ describe('CliInstallerService', () => { expect(verifiedProvider?.modelVerificationState).toBe('idle'); expect(verifiedProvider?.modelAvailability).toEqual([]); }); + + it('does not shrink cached OpenCode models when a provider refresh returns summary-only models', async () => { + allowConsoleLogs(); + vi.mocked(getConfiguredCliFlavor).mockReturnValue('agent_teams_orchestrator'); + vi.mocked(getCliFlavorUiOptions).mockReturnValue({ + displayName: 'agent_teams_orchestrator', + supportsSelfUpdate: false, + showVersionDetails: false, + showBinaryPath: false, + }); + vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/usr/local/bin/claude'); + vi.mocked(execCli).mockImplementation(async (_binaryPath, args) => { + const normalizedArgs = Array.isArray(args) ? args.join(' ') : ''; + if (normalizedArgs === '--version') { + return { stdout: '2.3.4', stderr: '' }; + } + throw new Error(`Unexpected execCli call: ${normalizedArgs}`); + }); + + vi.spyOn(ClaudeMultimodelBridgeService.prototype, 'getProviderStatuses').mockResolvedValue([ + { + providerId: 'opencode', + displayName: 'OpenCode', + supported: true, + authenticated: true, + authMethod: 'opencode_managed', + verificationState: 'verified', + modelVerificationState: 'idle', + statusMessage: null, + detailMessage: null, + models: [ + 'opencode/big-pickle', + 'openai/gpt-5.4', + 'openrouter/openai/gpt-oss-20b:free', + ], + modelCatalog: { + schemaVersion: 1, + providerId: 'opencode', + source: 'app-server', + status: 'ready', + fetchedAt: '2026-05-20T00:00:00.000Z', + staleAt: '2026-05-20T00:10:00.000Z', + defaultModelId: 'opencode/big-pickle', + defaultLaunchModel: 'opencode/big-pickle', + models: [ + { + id: 'opencode/big-pickle', + launchModel: 'opencode/big-pickle', + displayName: 'opencode/big-pickle', + hidden: false, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + inputModalities: ['text'], + supportsPersonality: true, + isDefault: true, + upgrade: false, + source: 'app-server', + badgeLabel: 'Free', + }, + { + id: 'openai/gpt-5.4', + launchModel: 'openai/gpt-5.4', + displayName: 'openai/gpt-5.4', + hidden: false, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + inputModalities: ['text'], + supportsPersonality: true, + isDefault: false, + upgrade: false, + source: 'app-server', + }, + ], + diagnostics: { + configReadState: 'ready', + appServerState: 'healthy', + }, + }, + modelCatalogRefreshState: 'ready', + modelAvailability: [], + runtimeCapabilities: { modelCatalog: { dynamic: true, source: 'app-server' } }, + canLoginFromUi: false, + capabilities: { teamLaunch: true, oneShot: false, extensions: undefined as never }, + selectedBackendId: null, + resolvedBackendId: null, + availableBackends: [], + externalRuntimeDiagnostics: [], + backend: { kind: 'opencode-cli', label: 'OpenCode CLI' }, + connection: null, + }, + ] as never); + + vi.spyOn(ClaudeMultimodelBridgeService.prototype, 'getProviderStatus').mockResolvedValue({ + providerId: 'opencode', + displayName: 'OpenCode', + supported: true, + authenticated: true, + authMethod: 'opencode_managed', + verificationState: 'verified', + modelVerificationState: 'idle', + statusMessage: null, + detailMessage: null, + models: ['opencode/big-pickle'], + modelCatalog: null, + modelCatalogRefreshState: 'loading', + modelAvailability: [], + runtimeCapabilities: { modelCatalog: { dynamic: true, source: 'app-server' } }, + canLoginFromUi: false, + capabilities: { teamLaunch: true, oneShot: false, extensions: undefined as never }, + selectedBackendId: null, + resolvedBackendId: null, + availableBackends: [], + externalRuntimeDiagnostics: [], + backend: { kind: 'opencode-cli', label: 'OpenCode CLI' }, + connection: null, + } as never); + + await service.getStatus(); + await service.getProviderStatus('opencode'); + + const latestSnapshot = ( + service as unknown as { + latestStatusSnapshot?: Awaited>; + } + ).latestStatusSnapshot; + const opencode = latestSnapshot?.providers.find( + (provider) => provider.providerId === 'opencode' + ); + expect(opencode?.models).toEqual([ + 'opencode/big-pickle', + 'openai/gpt-5.4', + 'openrouter/openai/gpt-oss-20b:free', + ]); + expect(opencode?.modelCatalog?.models.map((model) => model.id)).toEqual([ + 'opencode/big-pickle', + 'openai/gpt-5.4', + ]); + expect(opencode?.modelCatalogRefreshState).toBe('ready'); + }); }); describe('install mutex', () => { diff --git a/test/renderer/store/cliInstallerSlice.test.ts b/test/renderer/store/cliInstallerSlice.test.ts index 3be8043c..e86929f2 100644 --- a/test/renderer/store/cliInstallerSlice.test.ts +++ b/test/renderer/store/cliInstallerSlice.test.ts @@ -1486,6 +1486,111 @@ describe('cliInstallerSlice', () => { expect(provider?.modelCatalog?.defaultModelId).toBe('gpt-5.4'); }); + it('keeps cached OpenCode model list when summary refresh only reports big-pickle', async () => { + const currentProvider = createMultimodelProvider({ + providerId: 'opencode', + displayName: 'OpenCode', + authenticated: true, + authMethod: 'opencode_managed', + statusMessage: null, + models: [ + 'opencode/big-pickle', + 'openai/gpt-5.4', + 'openrouter/openai/gpt-oss-20b:free', + ], + modelCatalogRefreshState: 'ready', + modelCatalog: { + schemaVersion: 1, + providerId: 'opencode', + source: 'app-server', + status: 'ready', + fetchedAt: '2026-05-20T00:00:00.000Z', + staleAt: '2026-05-20T00:10:00.000Z', + defaultModelId: 'opencode/big-pickle', + defaultLaunchModel: 'opencode/big-pickle', + models: [ + { + id: 'opencode/big-pickle', + launchModel: 'opencode/big-pickle', + displayName: 'opencode/big-pickle', + hidden: false, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + inputModalities: ['text'], + supportsPersonality: true, + isDefault: true, + upgrade: false, + source: 'app-server', + badgeLabel: 'Free', + }, + { + id: 'openai/gpt-5.4', + launchModel: 'openai/gpt-5.4', + displayName: 'openai/gpt-5.4', + hidden: false, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + inputModalities: ['text'], + supportsPersonality: true, + isDefault: false, + upgrade: false, + source: 'app-server', + }, + ], + diagnostics: { + configReadState: 'ready', + appServerState: 'healthy', + }, + }, + runtimeCapabilities: { + modelCatalog: { + dynamic: true, + source: 'app-server', + }, + }, + backend: { kind: 'opencode-cli', label: 'OpenCode CLI' }, + }); + + useStore.setState({ + cliStatus: createMultimodelStatus([currentProvider]), + }); + vi.mocked(api.cliInstaller.getProviderStatus).mockResolvedValue( + createMultimodelProvider({ + providerId: 'opencode', + displayName: 'OpenCode', + authenticated: true, + authMethod: 'opencode_managed', + statusMessage: null, + models: ['opencode/big-pickle'], + modelCatalog: null, + modelCatalogRefreshState: 'loading', + runtimeCapabilities: { + modelCatalog: { + dynamic: true, + source: 'app-server', + }, + }, + backend: { kind: 'opencode-cli', label: 'OpenCode CLI' }, + }) + ); + + await useStore.getState().fetchCliProviderStatus('opencode'); + + const provider = useStore + .getState() + .cliStatus?.providers.find((candidate) => candidate.providerId === 'opencode'); + expect(provider?.models).toEqual([ + 'opencode/big-pickle', + 'openai/gpt-5.4', + 'openrouter/openai/gpt-oss-20b:free', + ]); + expect(provider?.modelCatalog?.models.map((model) => model.id)).toEqual([ + 'opencode/big-pickle', + 'openai/gpt-5.4', + ]); + expect(provider?.modelCatalogRefreshState).toBe('ready'); + }); + it('keeps OpenCode refresh status-only even when model verification is requested', async () => { const nextProvider = createMultimodelProvider({ providerId: 'opencode', From 147af0e0e5df18e99678c025f36a17ca1f091785 Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 20 May 2026 17:15:14 +0300 Subject: [PATCH 07/44] fix(opencode): honor free model metadata --- .../runtime/ProviderModelBadges.tsx | 6 +- src/renderer/utils/teamModelCatalog.ts | 11 ++- .../runtime/ProviderModelBadges.test.tsx | 50 ++++++++++- test/renderer/utils/teamModelCatalog.test.ts | 86 ++++++++++++++++++- 4 files changed, 143 insertions(+), 10 deletions(-) diff --git a/src/renderer/components/runtime/ProviderModelBadges.tsx b/src/renderer/components/runtime/ProviderModelBadges.tsx index 93e7b174..08f1a8bc 100644 --- a/src/renderer/components/runtime/ProviderModelBadges.tsx +++ b/src/renderer/components/runtime/ProviderModelBadges.tsx @@ -53,7 +53,11 @@ function getCatalogBadgeLabel( const catalogItem = providerStatus?.modelCatalog?.models.find( (item) => item.launchModel === model || item.id === model ); - return catalogItem?.badgeLabel?.trim() || null; + const badgeLabel = catalogItem?.badgeLabel?.trim(); + if (badgeLabel) { + return badgeLabel; + } + return catalogItem?.metadata?.free === true ? 'Free' : null; } function normalizeBadgeText(value: string): string { diff --git a/src/renderer/utils/teamModelCatalog.ts b/src/renderer/utils/teamModelCatalog.ts index 3cd56890..daa18a03 100644 --- a/src/renderer/utils/teamModelCatalog.ts +++ b/src/renderer/utils/teamModelCatalog.ts @@ -4,12 +4,7 @@ import { getOpenCodeQualifiedModelSourceLabel, parseOpenCodeQualifiedModelRef, } from '@shared/utils/opencodeModelRef'; -import { - filterVisibleProviderRuntimeModels, - GPT_5_1_CODEX_MINI_UI_DISABLED_MODEL, - GPT_5_2_CODEX_UI_DISABLED_MODEL, - GPT_5_3_CODEX_SPARK_UI_DISABLED_MODEL, -} from '@shared/utils/providerModelVisibility'; +import { filterVisibleProviderRuntimeModels } from '@shared/utils/providerModelVisibility'; import type { CliProviderId, CliProviderStatus, TeamProviderId } from '@shared/types'; @@ -420,6 +415,10 @@ function isFreeOpenCodeModelForOrdering( } const runtimeModel = getRuntimeCatalogModel(providerId, model, providerStatus); + if (runtimeModel?.metadata?.free === true) { + return true; + } + const badgeLabel = runtimeModel?.badgeLabel?.trim().toLowerCase(); if (badgeLabel) { return badgeLabel === 'free'; diff --git a/test/renderer/components/runtime/ProviderModelBadges.test.tsx b/test/renderer/components/runtime/ProviderModelBadges.test.tsx index 00301067..159a5add 100644 --- a/test/renderer/components/runtime/ProviderModelBadges.test.tsx +++ b/test/renderer/components/runtime/ProviderModelBadges.test.tsx @@ -1,8 +1,8 @@ import React, { act } from 'react'; import { createRoot } from 'react-dom/client'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ProviderModelBadges } from '@renderer/components/runtime/ProviderModelBadges'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; function render(element: React.ReactElement): HTMLDivElement { const host = document.createElement('div'); @@ -171,6 +171,54 @@ describe('ProviderModelBadges', () => { expect(host.textContent?.match(/Free/g)).toHaveLength(1); }); + it('renders OpenCode free badges from metadata when badgeLabel is absent', () => { + const host = render( + + ); + + expect(host.textContent).toContain('gpt-oss'); + expect(host.textContent).toContain('Free'); + }); + it('does not duplicate a catalog badge that matches the displayed model label', () => { const host = render( { it('filters UI-disabled Codex models from provider badge lists', () => { @@ -139,6 +138,89 @@ describe('teamModelCatalog', () => { ]); }); + it('orders OpenCode free models by metadata when badge labels are absent', () => { + expect( + getVisibleTeamProviderModels( + 'opencode', + [ + 'openai/gpt-5.4', + 'opencode/big-pickle', + 'openrouter/openai/gpt-oss-20b', + ], + { + providerId: 'opencode', + authMethod: 'opencode_managed', + backend: { kind: 'opencode-cli', label: 'OpenCode CLI' }, + modelCatalog: { + schemaVersion: 1, + providerId: 'opencode', + source: 'app-server', + status: 'ready', + fetchedAt: '2026-05-12T00:00:00.000Z', + staleAt: '2026-05-12T00:10:00.000Z', + defaultModelId: 'opencode/big-pickle', + defaultLaunchModel: 'opencode/big-pickle', + models: [ + { + id: 'openai/gpt-5.4', + launchModel: 'openai/gpt-5.4', + displayName: 'openai/gpt-5.4', + hidden: false, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + inputModalities: ['text'], + supportsPersonality: true, + isDefault: false, + upgrade: false, + source: 'app-server', + badgeLabel: null, + metadata: { free: false }, + }, + { + id: 'openrouter/openai/gpt-oss-20b', + launchModel: 'openrouter/openai/gpt-oss-20b', + displayName: 'openrouter/openai/gpt-oss-20b', + hidden: false, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + inputModalities: ['text'], + supportsPersonality: true, + isDefault: false, + upgrade: false, + source: 'app-server', + badgeLabel: null, + metadata: { free: true }, + }, + { + id: 'opencode/big-pickle', + launchModel: 'opencode/big-pickle', + displayName: 'opencode/big-pickle', + hidden: false, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + inputModalities: ['text'], + supportsPersonality: true, + isDefault: true, + upgrade: false, + source: 'app-server', + badgeLabel: null, + metadata: { free: true }, + }, + ], + diagnostics: { + configReadState: 'ready', + appServerState: 'healthy', + }, + }, + } + ) + ).toEqual([ + 'opencode/big-pickle', + 'openrouter/openai/gpt-oss-20b', + 'openai/gpt-5.4', + ]); + }); + it('detects Sonnet aliases with or without 1M suffix', () => { expect(isAnthropicSonnetTeamModel('sonnet')).toBe(true); expect(isAnthropicSonnetTeamModel('sonnet[1m]')).toBe(true); From 9289afd01ee7d6a4dea071d3ff0044b15c25f9a8 Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 20 May 2026 17:20:55 +0300 Subject: [PATCH 08/44] fix(opencode): use catalog models in selector --- src/renderer/utils/teamModelAvailability.ts | 7 +- src/renderer/utils/teamModelCatalog.ts | 22 ++++- .../utils/teamModelAvailability.test.ts | 88 ++++++++++++++++++- test/renderer/utils/teamModelCatalog.test.ts | 68 ++++++++++++++ 4 files changed, 180 insertions(+), 5 deletions(-) diff --git a/src/renderer/utils/teamModelAvailability.ts b/src/renderer/utils/teamModelAvailability.ts index 9353f4c5..9f7f6220 100644 --- a/src/renderer/utils/teamModelAvailability.ts +++ b/src/renderer/utils/teamModelAvailability.ts @@ -235,13 +235,16 @@ function getRuntimeCatalogModels( return null; } - if (providerId !== 'codex' || providerStatus?.modelCatalog?.providerId !== 'codex') { + if ( + (providerId !== 'codex' && providerId !== 'opencode') || + providerStatus?.modelCatalog?.providerId !== providerId + ) { return null; } const models = providerStatus.modelCatalog.models .filter((model) => !model.hidden) - .map((model) => model.launchModel.trim()) + .map((model) => model.launchModel.trim() || model.id.trim()) .filter(Boolean); return models.length > 0 ? models : null; } diff --git a/src/renderer/utils/teamModelCatalog.ts b/src/renderer/utils/teamModelCatalog.ts index daa18a03..1f34c84a 100644 --- a/src/renderer/utils/teamModelCatalog.ts +++ b/src/renderer/utils/teamModelCatalog.ts @@ -494,6 +494,21 @@ function isRuntimeHiddenTeamModel( ); } +function getRuntimeCatalogLaunchModels( + providerId: SupportedProviderId, + providerStatus?: RuntimeAwareProviderStatus | null +): string[] | null { + if (providerStatus?.modelCatalog?.providerId !== providerId) { + return null; + } + + const models = providerStatus.modelCatalog.models + .filter((model) => !model.hidden) + .map((model) => model.launchModel.trim() || model.id.trim()) + .filter(Boolean); + return models.length > 0 ? models : null; +} + function getSupplementalVisibleModels( providerId: SupportedProviderId, models: readonly string[] @@ -510,11 +525,16 @@ export function getVisibleTeamProviderModels( models: readonly string[], providerStatus?: RuntimeAwareProviderStatus | null ): string[] { + const sourceModels = + providerId === 'opencode' && models.length === 0 + ? (getRuntimeCatalogLaunchModels(providerId, providerStatus) ?? models) + : models; + return sortTeamProviderModels( providerId, filterVisibleProviderRuntimeModels( providerId, - getSupplementalVisibleModels(providerId, models) + getSupplementalVisibleModels(providerId, sourceModels) ), providerStatus ).filter((model) => !isRuntimeHiddenTeamModel(providerId, model, providerStatus)); diff --git a/test/renderer/utils/teamModelAvailability.test.ts b/test/renderer/utils/teamModelAvailability.test.ts index 7cc8a6c4..48548670 100644 --- a/test/renderer/utils/teamModelAvailability.test.ts +++ b/test/renderer/utils/teamModelAvailability.test.ts @@ -1,5 +1,3 @@ -import { describe, expect, it } from 'vitest'; - import { getAvailableTeamProviderModelOptions, getAvailableTeamProviderModels, @@ -10,6 +8,7 @@ import { normalizeTeamModelForUi, type TeamModelRuntimeProviderStatus, } from '@renderer/utils/teamModelAvailability'; +import { describe, expect, it } from 'vitest'; function createCodexProviderStatus( models: string[], @@ -246,6 +245,91 @@ describe('teamModelAvailability', () => { ).toBe('openrouter/moonshotai/kimi-k2'); }); + it('uses the OpenCode model catalog when runtime models are missing', () => { + const providerStatus = createOpenCodeProviderStatus([], { + modelCatalog: { + schemaVersion: 1, + providerId: 'opencode', + source: 'app-server', + status: 'ready', + fetchedAt: '2026-05-12T00:00:00.000Z', + staleAt: '2026-05-12T00:10:00.000Z', + defaultModelId: 'opencode/big-pickle', + defaultLaunchModel: 'opencode/big-pickle', + models: [ + { + id: 'openai/gpt-5.4', + launchModel: 'openai/gpt-5.4', + displayName: 'openai/gpt-5.4', + hidden: false, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + inputModalities: ['text'], + supportsPersonality: true, + isDefault: false, + upgrade: false, + source: 'app-server', + badgeLabel: null, + }, + { + id: 'opencode/big-pickle', + launchModel: 'opencode/big-pickle', + displayName: 'opencode/big-pickle', + hidden: false, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + inputModalities: ['text'], + supportsPersonality: true, + isDefault: true, + upgrade: false, + source: 'app-server', + badgeLabel: 'Free', + }, + { + id: 'openrouter/hidden-model', + launchModel: 'openrouter/hidden-model', + displayName: 'openrouter/hidden-model', + hidden: true, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + inputModalities: ['text'], + supportsPersonality: true, + isDefault: false, + upgrade: false, + source: 'app-server', + badgeLabel: null, + }, + ], + diagnostics: { + configReadState: 'ready', + appServerState: 'healthy', + }, + }, + }); + + expect(getAvailableTeamProviderModels('opencode', providerStatus)).toEqual([ + 'opencode/big-pickle', + 'openai/gpt-5.4', + ]); + expect(getAvailableTeamProviderModelOptions('opencode', providerStatus)).toEqual([ + { value: '', label: 'Default', badgeLabel: 'Default' }, + { + value: 'opencode/big-pickle', + label: 'big-pickle', + badgeLabel: 'OpenCode', + availabilityStatus: 'available', + availabilityReason: null, + }, + { + value: 'openai/gpt-5.4', + label: 'GPT-5.4', + badgeLabel: 'OpenAI', + availabilityStatus: 'available', + availabilityReason: null, + }, + ]); + }); + it('reports OpenCode openai routes unavailable when OpenAI auth is invalid', () => { const providerStatus = createOpenCodeProviderStatus(['openai/gpt-5.4', 'opencode/big-pickle'], { statusMessage: 'OpenAI token invalid', diff --git a/test/renderer/utils/teamModelCatalog.test.ts b/test/renderer/utils/teamModelCatalog.test.ts index 950c035f..ed92275d 100644 --- a/test/renderer/utils/teamModelCatalog.test.ts +++ b/test/renderer/utils/teamModelCatalog.test.ts @@ -221,6 +221,74 @@ describe('teamModelCatalog', () => { ]); }); + it('uses the OpenCode model catalog when the runtime model list is empty', () => { + expect( + getVisibleTeamProviderModels('opencode', [], { + providerId: 'opencode', + authMethod: 'opencode_managed', + backend: { kind: 'opencode-cli', label: 'OpenCode CLI' }, + modelCatalog: { + schemaVersion: 1, + providerId: 'opencode', + source: 'app-server', + status: 'ready', + fetchedAt: '2026-05-12T00:00:00.000Z', + staleAt: '2026-05-12T00:10:00.000Z', + defaultModelId: 'opencode/big-pickle', + defaultLaunchModel: 'opencode/big-pickle', + models: [ + { + id: 'openai/gpt-5.4', + launchModel: 'openai/gpt-5.4', + displayName: 'openai/gpt-5.4', + hidden: false, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + inputModalities: ['text'], + supportsPersonality: true, + isDefault: false, + upgrade: false, + source: 'app-server', + badgeLabel: null, + }, + { + id: 'opencode/big-pickle', + launchModel: 'opencode/big-pickle', + displayName: 'opencode/big-pickle', + hidden: false, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + inputModalities: ['text'], + supportsPersonality: true, + isDefault: true, + upgrade: false, + source: 'app-server', + badgeLabel: 'Free', + }, + { + id: 'openrouter/hidden-model', + launchModel: 'openrouter/hidden-model', + displayName: 'openrouter/hidden-model', + hidden: true, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + inputModalities: ['text'], + supportsPersonality: true, + isDefault: false, + upgrade: false, + source: 'app-server', + badgeLabel: null, + }, + ], + diagnostics: { + configReadState: 'ready', + appServerState: 'healthy', + }, + }, + }) + ).toEqual(['opencode/big-pickle', 'openai/gpt-5.4']); + }); + it('detects Sonnet aliases with or without 1M suffix', () => { expect(isAnthropicSonnetTeamModel('sonnet')).toBe(true); expect(isAnthropicSonnetTeamModel('sonnet[1m]')).toBe(true); From 3b120e74c77cfefdcfc0c7ff811964b521a1bf6c Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 20 May 2026 17:25:00 +0300 Subject: [PATCH 09/44] fix(opencode): prefer catalog for summary model lists --- src/renderer/utils/teamModelCatalog.ts | 2 +- test/renderer/utils/teamModelAvailability.test.ts | 4 ++-- test/renderer/utils/teamModelCatalog.test.ts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/renderer/utils/teamModelCatalog.ts b/src/renderer/utils/teamModelCatalog.ts index 1f34c84a..c0693ec5 100644 --- a/src/renderer/utils/teamModelCatalog.ts +++ b/src/renderer/utils/teamModelCatalog.ts @@ -526,7 +526,7 @@ export function getVisibleTeamProviderModels( providerStatus?: RuntimeAwareProviderStatus | null ): string[] { const sourceModels = - providerId === 'opencode' && models.length === 0 + providerId === 'opencode' ? (getRuntimeCatalogLaunchModels(providerId, providerStatus) ?? models) : models; diff --git a/test/renderer/utils/teamModelAvailability.test.ts b/test/renderer/utils/teamModelAvailability.test.ts index 48548670..081ee217 100644 --- a/test/renderer/utils/teamModelAvailability.test.ts +++ b/test/renderer/utils/teamModelAvailability.test.ts @@ -245,8 +245,8 @@ describe('teamModelAvailability', () => { ).toBe('openrouter/moonshotai/kimi-k2'); }); - it('uses the OpenCode model catalog when runtime models are missing', () => { - const providerStatus = createOpenCodeProviderStatus([], { + it('uses the OpenCode model catalog when runtime models are summary-only', () => { + const providerStatus = createOpenCodeProviderStatus(['opencode/big-pickle'], { modelCatalog: { schemaVersion: 1, providerId: 'opencode', diff --git a/test/renderer/utils/teamModelCatalog.test.ts b/test/renderer/utils/teamModelCatalog.test.ts index ed92275d..b2476477 100644 --- a/test/renderer/utils/teamModelCatalog.test.ts +++ b/test/renderer/utils/teamModelCatalog.test.ts @@ -221,9 +221,9 @@ describe('teamModelCatalog', () => { ]); }); - it('uses the OpenCode model catalog when the runtime model list is empty', () => { + it('uses the OpenCode model catalog when the runtime model list is summary-only', () => { expect( - getVisibleTeamProviderModels('opencode', [], { + getVisibleTeamProviderModels('opencode', ['opencode/big-pickle'], { providerId: 'opencode', authMethod: 'opencode_managed', backend: { kind: 'opencode-cli', label: 'OpenCode CLI' }, From c88a11c1d85a475f330c060529b36a25d0efe318 Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 20 May 2026 17:30:51 +0300 Subject: [PATCH 10/44] test(opencode): cover catalog-backed selection --- test/renderer/utils/teamModelAvailability.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/renderer/utils/teamModelAvailability.test.ts b/test/renderer/utils/teamModelAvailability.test.ts index 081ee217..fdbee879 100644 --- a/test/renderer/utils/teamModelAvailability.test.ts +++ b/test/renderer/utils/teamModelAvailability.test.ts @@ -328,6 +328,10 @@ describe('teamModelAvailability', () => { availabilityReason: null, }, ]); + expect(normalizeTeamModelForUi('opencode', 'openai/gpt-5.4', providerStatus)).toBe( + 'openai/gpt-5.4' + ); + expect(getTeamModelSelectionError('opencode', 'openai/gpt-5.4', providerStatus)).toBeNull(); }); it('reports OpenCode openai routes unavailable when OpenAI auth is invalid', () => { From c632208dbaacc30d82229f7c8ebfc82a9d194158 Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 20 May 2026 17:39:56 +0300 Subject: [PATCH 11/44] fix(opencode): render catalog-backed provider badges --- .../components/dashboard/CliStatusBanner.tsx | 12 +- .../settings/sections/CliStatusSection.tsx | 15 +- .../cli/CliStatusVisibility.test.ts | 186 ++++++++++++++++++ .../runtime/ProviderModelBadges.test.tsx | 76 +++++++ 4 files changed, 283 insertions(+), 6 deletions(-) diff --git a/src/renderer/components/dashboard/CliStatusBanner.tsx b/src/renderer/components/dashboard/CliStatusBanner.tsx index b7904727..ea46303c 100644 --- a/src/renderer/components/dashboard/CliStatusBanner.tsx +++ b/src/renderer/components/dashboard/CliStatusBanner.tsx @@ -53,6 +53,7 @@ import { isMultimodelRuntimeStatus } from '@renderer/utils/multimodelProviderVis import { resolveProjectPathById } from '@renderer/utils/projectLookup'; import { refreshCliStatusForCurrentMode } from '@renderer/utils/refreshCliStatus'; import { getRuntimeDisplayName as getHumanRuntimeDisplayName } from '@renderer/utils/runtimeDisplayName'; +import { getVisibleTeamProviderModels } from '@renderer/utils/teamModelCatalog'; import { AlertTriangle, CheckCircle, @@ -836,12 +837,17 @@ const InstalledBanner = ({ const modelCatalogLoading = provider.modelCatalogRefreshState === 'loading' || isOpenCodeCatalogHydrating(provider); + const hasProviderModels = + provider.providerId === 'opencode' + ? getVisibleTeamProviderModels(provider.providerId, provider.models, provider) + .length > 0 + : provider.models.length > 0; const hasDetailContent = Boolean( (provider.backend?.label && !runtimeSummary) || runtimeSummary || connectionModeSummary || credentialSummary || - provider.models.length === 0 || + !hasProviderModels || modelCatalogLoading ); @@ -905,7 +911,7 @@ const InstalledBanner = ({ {connectionModeSummary ? {connectionModeSummary} : null} {credentialSummary ? {credentialSummary} : null} {modelCatalogLoading ? Loading models... : null} - {provider.models.length === 0 && !modelCatalogLoading && ( + {!hasProviderModels && !modelCatalogLoading && ( Models unavailable for this runtime build )}
@@ -1087,7 +1093,7 @@ const InstalledBanner = ({
- {!showSkeleton && !modelCatalogLoading && provider.models.length > 0 && ( + {!showSkeleton && !modelCatalogLoading && hasProviderModels && (
{ const modelCatalogLoading = provider.modelCatalogRefreshState === 'loading' || isOpenCodeCatalogHydrating(provider); + const hasProviderModels = + provider.providerId === 'opencode' + ? getVisibleTeamProviderModels( + provider.providerId, + provider.models, + provider + ).length > 0 + : provider.models.length > 0; const connectionModeSummary = getProviderConnectionModeSummary(provider); const credentialSummary = getProviderCredentialSummary(provider); const disconnectAction = getProviderDisconnectAction(provider); @@ -499,7 +508,7 @@ export const CliStatusSection = (): React.JSX.Element | null => { runtimeSummary || connectionModeSummary || credentialSummary || - provider.models.length === 0 || + !hasProviderModels || modelCatalogLoading ); @@ -553,7 +562,7 @@ export const CliStatusSection = (): React.JSX.Element | null => { ) : null} {credentialSummary ? {credentialSummary} : null} {modelCatalogLoading ? Loading models... : null} - {provider.models.length === 0 && !modelCatalogLoading && ( + {!hasProviderModels && !modelCatalogLoading && ( Models unavailable for this runtime build )}
@@ -612,7 +621,7 @@ export const CliStatusSection = (): React.JSX.Element | null => { {!effectiveShowSkeleton && !modelCatalogLoading && - provider.models.length > 0 && ( + hasProviderModels && (
{ }); }); + it('shows OpenCode catalog models on the dashboard when provider models are empty', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliInstallerState = 'idle'; + storeState.cliStatus = createInstalledCliStatus({ + flavor: 'agent_teams_orchestrator', + displayName: 'Multimodel runtime', + supportsSelfUpdate: false, + showVersionDetails: false, + showBinaryPath: false, + authLoggedIn: true, + providers: [ + { + providerId: 'opencode', + displayName: 'OpenCode (200+ models)', + supported: true, + authenticated: true, + authMethod: 'opencode_managed', + verificationState: 'verified', + statusMessage: 'Ready', + models: [], + canLoginFromUi: false, + capabilities: { + teamLaunch: true, + oneShot: false, + }, + backend: { kind: 'opencode-cli', label: 'OpenCode CLI' }, + modelCatalog: { + schemaVersion: 1, + providerId: 'opencode', + source: 'app-server', + status: 'ready', + fetchedAt: '2026-05-12T00:00:00.000Z', + staleAt: '2026-05-12T00:10:00.000Z', + defaultModelId: 'opencode/big-pickle', + defaultLaunchModel: 'opencode/big-pickle', + models: [ + { + id: 'opencode/big-pickle', + launchModel: 'opencode/big-pickle', + displayName: 'opencode/big-pickle', + hidden: false, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + inputModalities: ['text'], + supportsPersonality: true, + isDefault: true, + upgrade: false, + source: 'app-server', + badgeLabel: 'Free', + }, + { + id: 'openai/gpt-5.4', + launchModel: 'openai/gpt-5.4', + displayName: 'openai/gpt-5.4', + hidden: false, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + inputModalities: ['text'], + supportsPersonality: true, + isDefault: false, + upgrade: false, + source: 'app-server', + badgeLabel: null, + }, + ], + diagnostics: { + configReadState: 'ready', + appServerState: 'healthy', + }, + }, + }, + ], + }); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(CliStatusBanner)); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('big-pickle'); + expect(host.textContent).toContain('GPT-5.4'); + expect(host.textContent).not.toContain('Models unavailable for this runtime build'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('shows OpenCode catalog models in settings when provider models are empty', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliInstallerState = 'idle'; + storeState.cliStatus = createInstalledCliStatus({ + flavor: 'agent_teams_orchestrator', + displayName: 'Multimodel runtime', + supportsSelfUpdate: false, + showVersionDetails: false, + showBinaryPath: false, + authLoggedIn: true, + providers: [ + { + providerId: 'opencode', + displayName: 'OpenCode (200+ models)', + supported: true, + authenticated: true, + authMethod: 'opencode_managed', + verificationState: 'verified', + statusMessage: 'Ready', + models: [], + canLoginFromUi: false, + capabilities: { + teamLaunch: true, + oneShot: false, + }, + backend: { kind: 'opencode-cli', label: 'OpenCode CLI' }, + modelCatalog: { + schemaVersion: 1, + providerId: 'opencode', + source: 'app-server', + status: 'ready', + fetchedAt: '2026-05-12T00:00:00.000Z', + staleAt: '2026-05-12T00:10:00.000Z', + defaultModelId: 'opencode/big-pickle', + defaultLaunchModel: 'opencode/big-pickle', + models: [ + { + id: 'opencode/big-pickle', + launchModel: 'opencode/big-pickle', + displayName: 'opencode/big-pickle', + hidden: false, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + inputModalities: ['text'], + supportsPersonality: true, + isDefault: true, + upgrade: false, + source: 'app-server', + badgeLabel: 'Free', + }, + { + id: 'openai/gpt-5.4', + launchModel: 'openai/gpt-5.4', + displayName: 'openai/gpt-5.4', + hidden: false, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + inputModalities: ['text'], + supportsPersonality: true, + isDefault: false, + upgrade: false, + source: 'app-server', + badgeLabel: null, + }, + ], + diagnostics: { + configReadState: 'ready', + appServerState: 'healthy', + }, + }, + }, + ], + }); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(CliStatusSection)); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('big-pickle'); + expect(host.textContent).toContain('GPT-5.4'); + expect(host.textContent).not.toContain('Models unavailable for this runtime build'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('preserves dashboard runtime backend refresh errors for the manage dialog', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.cliInstallerState = 'idle'; diff --git a/test/renderer/components/runtime/ProviderModelBadges.test.tsx b/test/renderer/components/runtime/ProviderModelBadges.test.tsx index 159a5add..3366ffa7 100644 --- a/test/renderer/components/runtime/ProviderModelBadges.test.tsx +++ b/test/renderer/components/runtime/ProviderModelBadges.test.tsx @@ -171,6 +171,82 @@ describe('ProviderModelBadges', () => { expect(host.textContent?.match(/Free/g)).toHaveLength(1); }); + it('uses the OpenCode catalog when provider models are summary-only', () => { + const host = render( + + ); + + expect(host.textContent).toContain('big-pickle'); + expect(host.textContent).toContain('GPT-5.4'); + expect(host.textContent).not.toContain('hidden-model'); + }); + it('renders OpenCode free badges from metadata when badgeLabel is absent', () => { const host = render( Date: Wed, 20 May 2026 17:47:52 +0300 Subject: [PATCH 12/44] test(opencode): cover empty summary catalog selection --- .../TeamModelSelectorDisabledState.test.ts | 90 +++++++++++++++++++ .../utils/teamModelAvailability.test.ts | 57 ++++++++++++ 2 files changed, 147 insertions(+) diff --git a/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts b/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts index e52ad71b..651a2a91 100644 --- a/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts +++ b/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts @@ -2236,6 +2236,96 @@ describe('TeamModelSelector disabled Codex models', () => { }); }); + it('renders OpenCode model options from a ready catalog when runtime models are empty', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliStatus = { + providers: [ + { + providerId: 'opencode', + supported: true, + authenticated: true, + detailMessage: null, + statusMessage: null, + capabilities: { + teamLaunch: true, + }, + models: [], + modelCatalog: { + schemaVersion: 1, + providerId: 'opencode', + source: 'app-server', + status: 'ready', + fetchedAt: '2026-05-13T00:00:00.000Z', + staleAt: '2026-05-13T00:10:00.000Z', + defaultModelId: 'opencode/big-pickle', + defaultLaunchModel: 'opencode/big-pickle', + models: [ + { + id: 'opencode/big-pickle', + launchModel: 'opencode/big-pickle', + displayName: 'big-pickle', + hidden: false, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + inputModalities: ['text'], + supportsPersonality: false, + isDefault: true, + upgrade: false, + source: 'app-server', + badgeLabel: 'Free', + }, + { + id: 'openai/gpt-5.4', + launchModel: 'openai/gpt-5.4', + displayName: 'openai/gpt-5.4', + hidden: false, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + inputModalities: ['text'], + supportsPersonality: false, + isDefault: false, + upgrade: false, + source: 'app-server', + badgeLabel: null, + }, + ], + diagnostics: { + configReadState: 'ready', + appServerState: 'healthy', + message: null, + code: null, + }, + }, + }, + ], + }; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(TeamModelSelector, { + providerId: 'opencode', + onProviderChange: () => undefined, + value: '', + onValueChange: () => undefined, + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('big-pickle'); + expect(host.textContent).toContain('GPT-5.4'); + expect(host.textContent).not.toContain('Loading models'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('filters OpenCode model groups by selected source providers', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.cliStatus = { diff --git a/test/renderer/utils/teamModelAvailability.test.ts b/test/renderer/utils/teamModelAvailability.test.ts index fdbee879..f3f6c418 100644 --- a/test/renderer/utils/teamModelAvailability.test.ts +++ b/test/renderer/utils/teamModelAvailability.test.ts @@ -334,6 +334,63 @@ describe('teamModelAvailability', () => { expect(getTeamModelSelectionError('opencode', 'openai/gpt-5.4', providerStatus)).toBeNull(); }); + it('uses the OpenCode model catalog when runtime models are empty', () => { + const providerStatus = createOpenCodeProviderStatus([], { + modelCatalog: { + schemaVersion: 1, + providerId: 'opencode', + source: 'app-server', + status: 'ready', + fetchedAt: '2026-05-12T00:00:00.000Z', + staleAt: '2026-05-12T00:10:00.000Z', + defaultModelId: 'opencode/big-pickle', + defaultLaunchModel: 'opencode/big-pickle', + models: [ + { + id: 'opencode/big-pickle', + launchModel: 'opencode/big-pickle', + displayName: 'opencode/big-pickle', + hidden: false, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + inputModalities: ['text'], + supportsPersonality: true, + isDefault: true, + upgrade: false, + source: 'app-server', + badgeLabel: 'Free', + }, + { + id: 'openai/gpt-5.4', + launchModel: 'openai/gpt-5.4', + displayName: 'openai/gpt-5.4', + hidden: false, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + inputModalities: ['text'], + supportsPersonality: true, + isDefault: false, + upgrade: false, + source: 'app-server', + badgeLabel: null, + }, + ], + diagnostics: { + configReadState: 'ready', + appServerState: 'healthy', + }, + }, + }); + + expect(getAvailableTeamProviderModels('opencode', providerStatus)).toEqual([ + 'opencode/big-pickle', + 'openai/gpt-5.4', + ]); + expect( + getAvailableTeamProviderModelOptions('opencode', providerStatus).map((option) => option.value) + ).toEqual(['', 'opencode/big-pickle', 'openai/gpt-5.4']); + }); + it('reports OpenCode openai routes unavailable when OpenAI auth is invalid', () => { const providerStatus = createOpenCodeProviderStatus(['openai/gpt-5.4', 'opencode/big-pickle'], { statusMessage: 'OpenAI token invalid', From a88e9f74b72d30cfd25b9096a67a884149fafd9e Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 20 May 2026 17:54:08 +0300 Subject: [PATCH 13/44] test(opencode): prove live team launch path --- .../OpenCodeTeamProvisioning.live.test.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/test/main/services/team/OpenCodeTeamProvisioning.live.test.ts b/test/main/services/team/OpenCodeTeamProvisioning.live.test.ts index 176b47b1..a80f4837 100644 --- a/test/main/services/team/OpenCodeTeamProvisioning.live.test.ts +++ b/test/main/services/team/OpenCodeTeamProvisioning.live.test.ts @@ -142,16 +142,41 @@ liveDescribe('OpenCode team provisioning live e2e', () => { ), progressDump ).toBe(true); + expect( + progressEvents.some((progress) => + progress.message.includes('Validating OpenCode team launch gate') + ), + progressDump + ).toBe(true); + expect( + progressEvents.some((progress) => + progress.message.includes('Starting OpenCode sessions through runtime adapter') + ), + progressDump + ).toBe(true); + expect(progressDump).not.toContain('Starting Claude CLI process for team launch'); + expect(progressDump).not.toContain('OpenCode team launch is not enabled'); const runtimeSnapshot = await svc.getTeamAgentRuntimeSnapshot(teamName); + expect(runtimeSnapshot.runId).toBe(runId); expect(runtimeSnapshot.members.alice).toMatchObject({ alive: true, + providerId: 'opencode', + laneId: 'primary', + laneKind: 'primary', runtimeModel: selectedModel, + historicalBootstrapConfirmed: true, }); expect(runtimeSnapshot.members.bob).toMatchObject({ alive: true, + providerId: 'opencode', + laneId: 'primary', + laneKind: 'primary', runtimeModel: selectedModel, + historicalBootstrapConfirmed: true, }); + expect(hasOpenCodeRuntimeHandle(runtimeSnapshot.members.alice)).toBe(true); + expect(hasOpenCodeRuntimeHandle(runtimeSnapshot.members.bob)).toBe(true); await expect( readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName) ).resolves.toMatchObject({ @@ -254,3 +279,15 @@ function createStableBridgeEnv(): NodeJS.ProcessEnv { USERPROFILE: realHome, }; } + +function hasOpenCodeRuntimeHandle(member: { + readonly pid?: number; + readonly runtimePid?: number; + readonly runtimeSessionId?: string; +}): boolean { + return ( + (typeof member.pid === 'number' && member.pid > 0) || + (typeof member.runtimePid === 'number' && member.runtimePid > 0) || + (typeof member.runtimeSessionId === 'string' && member.runtimeSessionId.trim().length > 0) + ); +} From 187a2697f796a70541f8e954dcf90debf3d9cfee Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 20 May 2026 18:24:13 +0300 Subject: [PATCH 14/44] test(opencode): harden deep live launch smoke --- .../model-gauntlet-results.json | 234 +++++++++--------- .../model-gauntlet-results.md | 18 +- ...penCodeAcceptFastDelivery.live-e2e.test.ts | 2 +- 3 files changed, 125 insertions(+), 129 deletions(-) diff --git a/test-results/opencode-semantic-model-gauntlet/model-gauntlet-results.json b/test-results/opencode-semantic-model-gauntlet/model-gauntlet-results.json index 2804f982..1a73aaae 100644 --- a/test-results/opencode-semantic-model-gauntlet/model-gauntlet-results.json +++ b/test-results/opencode-semantic-model-gauntlet/model-gauntlet-results.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-05-18T13:16:59.867Z", + "generatedAt": "2026-05-20T15:19:19.600Z", "runsPerModel": 1, "qualification": { "minimumAverageScore": 80, @@ -10,23 +10,23 @@ "models": [ { "model": "opencode/big-pickle", - "verdict": "tested-only", + "verdict": "recommended", "confidence": "low", - "qualified": false, - "readinessScore": 54, - "averageScore": 35, + "qualified": true, + "readinessScore": 100, + "averageScore": 100, "consistencyScore": 100, - "behavioralAverageScore": 35, - "minScore": 35, - "successfulRuns": 0, + "behavioralAverageScore": 100, + "minScore": 100, + "successfulRuns": 1, "countedRuns": 1, - "hardFailures": 1, + "hardFailures": 0, "providerInfraFailures": 0, "runtimeTransportFailures": 0, - "modelBehaviorFailures": 1, + "modelBehaviorFailures": 0, "harnessFailures": 0, - "p50DurationMs": 133048, - "p95DurationMs": 133048, + "p50DurationMs": 201546, + "p95DurationMs": 201546, "stagePassRates": { "launchBootstrap": { "passed": 1, @@ -39,19 +39,19 @@ "rate": 100 }, "peerRelayAB": { - "passed": 0, + "passed": 1, "total": 1, - "rate": 0 + "rate": 100 }, "peerRelayBC": { - "passed": 0, + "passed": 1, "total": 1, - "rate": 0 + "rate": 100 }, "concurrentReplies": { - "passed": 0, + "passed": 1, "total": 1, - "rate": 0 + "rate": 100 }, "taskRefs": { "passed": 1, @@ -59,19 +59,19 @@ "rate": 100 }, "cleanTranscript": { - "passed": 0, + "passed": 1, "total": 1, - "rate": 0 + "rate": 100 }, "noDuplicateTokens": { - "passed": 0, + "passed": 1, "total": 1, - "rate": 0 + "rate": 100 }, "latencyStable": { - "passed": 0, + "passed": 1, "total": 1, - "rate": 0 + "rate": 100 } }, "taskRefPassRates": { @@ -81,24 +81,24 @@ "rate": 100 }, "peerRelayAB": { - "passed": 0, - "total": 0, - "rate": null + "passed": 1, + "total": 1, + "rate": 100 }, "peerRelayBC": { - "passed": 0, - "total": 0, - "rate": null + "passed": 1, + "total": 1, + "rate": 100 }, "concurrentBob": { - "passed": 0, - "total": 0, - "rate": null + "passed": 1, + "total": 1, + "rate": 100 }, "concurrentTom": { - "passed": 0, - "total": 0, - "rate": null + "passed": 1, + "total": 1, + "rate": 100 } }, "protocolViolationTotals": { @@ -107,64 +107,24 @@ "affectedRuns": 0 }, "stageFailureImpact": [ - { - "stage": "concurrentReplies", - "failedRuns": 1, - "weightedLoss": 15, - "passRate": { - "passed": 0, - "total": 1, - "rate": 0 - } - }, - { - "stage": "peerRelayAB", - "failedRuns": 1, - "weightedLoss": 15, - "passRate": { - "passed": 0, - "total": 1, - "rate": 0 - } - }, - { - "stage": "peerRelayBC", - "failedRuns": 1, - "weightedLoss": 15, - "passRate": { - "passed": 0, - "total": 1, - "rate": 0 - } - }, { "stage": "cleanTranscript", - "failedRuns": 1, - "weightedLoss": 10, + "failedRuns": 0, + "weightedLoss": 0, "passRate": { - "passed": 0, + "passed": 1, "total": 1, - "rate": 0 + "rate": 100 } }, { - "stage": "latencyStable", - "failedRuns": 1, - "weightedLoss": 5, + "stage": "concurrentReplies", + "failedRuns": 0, + "weightedLoss": 0, "passRate": { - "passed": 0, + "passed": 1, "total": 1, - "rate": 0 - } - }, - { - "stage": "noDuplicateTokens", - "failedRuns": 1, - "weightedLoss": 5, - "passRate": { - "passed": 0, - "total": 1, - "rate": 0 + "rate": 100 } }, { @@ -177,6 +137,16 @@ "rate": 100 } }, + { + "stage": "latencyStable", + "failedRuns": 0, + "weightedLoss": 0, + "passRate": { + "passed": 1, + "total": 1, + "rate": 100 + } + }, { "stage": "launchBootstrap", "failedRuns": 0, @@ -187,6 +157,36 @@ "rate": 100 } }, + { + "stage": "noDuplicateTokens", + "failedRuns": 0, + "weightedLoss": 0, + "passRate": { + "passed": 1, + "total": 1, + "rate": 100 + } + }, + { + "stage": "peerRelayAB", + "failedRuns": 0, + "weightedLoss": 0, + "passRate": { + "passed": 1, + "total": 1, + "rate": 100 + } + }, + { + "stage": "peerRelayBC", + "failedRuns": 0, + "weightedLoss": 0, + "passRate": { + "passed": 1, + "total": 1, + "rate": 100 + } + }, { "stage": "taskRefs", "failedRuns": 0, @@ -200,45 +200,42 @@ ], "scoreStability": { "sampleSize": 1, - "minScore": 35, - "maxScore": 35, + "minScore": 100, + "maxScore": 100, "spread": 0, "standardDeviation": 0, "consistencyScore": 100 }, - "dominantFailureCategory": "model-behavior", - "recommendationBlockers": [ - "overall average 35 < 80", - "behavioral average 35 < 80", - "successful runs 0 < 1", - "hard failures 1", - "model-behavior failures 1", - "highest weighted stage loss concurrentReplies=15" - ], + "dominantFailureCategory": "none", + "recommendationBlockers": [], "runs": [ { "runIndex": 1, - "passed": false, - "score": 35, + "passed": true, + "score": 100, "countedForRecommendation": true, - "outcome": "behavioral-fail", - "failureCategory": "model-behavior", - "primaryFailure": "Timed out waiting for OpenCode member bob to become idle. Last durableState: reply_pending", - "durationMs": 133048, - "hardFailure": true, + "outcome": "passed", + "failureCategory": "none", + "primaryFailure": null, + "durationMs": 201546, + "hardFailure": false, "stageDurationsMs": { - "setup": 371, - "launchBootstrap": 23735, - "materializeTasks": 36, - "directReply": 17327 + "setup": 171, + "launchBootstrap": 41905, + "materializeTasks": 39, + "directReply": 29109, + "peerRelayAB": 45148, + "peerRelayBC": 39967, + "concurrentReplies": 28807, + "hygiene": 1 }, "stageFailures": {}, "taskRefChecks": { "directReply": true, - "peerRelayAB": null, - "peerRelayBC": null, - "concurrentBob": null, - "concurrentTom": null + "peerRelayAB": true, + "peerRelayBC": true, + "concurrentBob": true, + "concurrentTom": true }, "protocolViolations": { "badMessages": 0, @@ -247,17 +244,16 @@ "stages": { "launchBootstrap": true, "directReply": true, - "peerRelayAB": false, - "peerRelayBC": false, - "concurrentReplies": false, + "peerRelayAB": true, + "peerRelayBC": true, + "concurrentReplies": true, "taskRefs": true, - "cleanTranscript": false, - "noDuplicateTokens": false, - "latencyStable": false + "cleanTranscript": true, + "noDuplicateTokens": true, + "latencyStable": true }, "diagnostics": [ - "runId=9fb17ac5-fc66-4b01-831e-90a04b1e2304", - "Timed out waiting for OpenCode member bob to become idle. Last durableState: reply_pending" + "runId=1d6a50c3-c5cc-4c1e-91a0-d0e34a2229a3" ] } ] diff --git a/test-results/opencode-semantic-model-gauntlet/model-gauntlet-results.md b/test-results/opencode-semantic-model-gauntlet/model-gauntlet-results.md index 93862987..b43a451c 100644 --- a/test-results/opencode-semantic-model-gauntlet/model-gauntlet-results.md +++ b/test-results/opencode-semantic-model-gauntlet/model-gauntlet-results.md @@ -1,6 +1,6 @@ # OpenCode Model Gauntlet Results -Generated: 2026-05-18T13:16:59.867Z +Generated: 2026-05-20T15:19:19.600Z Runs per model: 1 Recommended threshold: average >= 80, successful runs >= 1, consistency >= 85, hard failures = 0 @@ -13,25 +13,25 @@ Scoring weights: launchBootstrap=15, directReply=10, peerRelayAB=15, peerRelayBC | Model | Verdict | Confidence | Readiness | Consistency | Score Spread | Behavior Avg | Overall Avg | Counted | Pass Runs | Weakest Stage | Weakest TaskRef | Dominant Failure | Blockers | Provider Infra | Runtime Transport | Model Fails | Protocol Runs | p50 | p95 | | --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- | --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | -| `opencode/big-pickle` | Tested only | low | 54 | 100 | 0 | 35 | 35 | 1/1 | 0/1 | cleanTranscript 0/1 (0%) | directReply 1/1 (100%) | model-behavior | overall average 35 < 80; behavioral average 35 < 80; successful runs 0 < 1; hard failures 1; model-behavior failures 1; highest weighted stage loss concurrentReplies=15 | 0 | 0 | 1 | 0 | 133048ms | 133048ms | +| `opencode/big-pickle` | Recommended | low | 100 | 100 | 0 | 100 | 100 | 1/1 | 1/1 | cleanTranscript 1/1 (100%) | concurrentBob 1/1 (100%) | none | - | 0 | 0 | 0 | 0 | 201546ms | 201546ms | ## opencode/big-pickle -Readiness score: 54. +Readiness score: 100. -Score stability: consistency=100, min=35, max=35, spread=0, stdDev=0, samples=1. +Score stability: consistency=100, min=100, max=100, spread=0, stdDev=0, samples=1. -Recommendation blockers: overall average 35 < 80; behavioral average 35 < 80; successful runs 0 < 1; hard failures 1; model-behavior failures 1; highest weighted stage loss concurrentReplies=15. +Recommendation blockers: -. -Weighted stage impact: concurrentReplies:loss=15, failed=1, pass=0/1 (0%); peerRelayAB:loss=15, failed=1, pass=0/1 (0%); peerRelayBC:loss=15, failed=1, pass=0/1 (0%); cleanTranscript:loss=10, failed=1, pass=0/1 (0%); latencyStable:loss=5, failed=1, pass=0/1 (0%). +Weighted stage impact: -. -Stage pass rates: launchBootstrap:1/1 (100%), directReply:1/1 (100%), peerRelayAB:0/1 (0%), peerRelayBC:0/1 (0%), concurrentReplies:0/1 (0%), taskRefs:1/1 (100%), cleanTranscript:0/1 (0%), noDuplicateTokens:0/1 (0%), latencyStable:0/1 (0%). +Stage pass rates: launchBootstrap:1/1 (100%), directReply:1/1 (100%), peerRelayAB:1/1 (100%), peerRelayBC:1/1 (100%), concurrentReplies:1/1 (100%), taskRefs:1/1 (100%), cleanTranscript:1/1 (100%), noDuplicateTokens:1/1 (100%), latencyStable:1/1 (100%). -TaskRef pass rates: directReply:1/1 (100%), peerRelayAB:n/a, peerRelayBC:n/a, concurrentBob:n/a, concurrentTom:n/a. +TaskRef pass rates: directReply:1/1 (100%), peerRelayAB:1/1 (100%), peerRelayBC:1/1 (100%), concurrentBob:1/1 (100%), concurrentTom:1/1 (100%). Protocol totals: badMessages=0, duplicateOrMissingTokens=0, affectedRuns=0. | Run | Outcome | Category | Score | Counted | Duration | Failed Stages | Slowest Stage | TaskRefs | Protocol | Diagnostics | | ---: | --- | --- | ---: | --- | ---: | --- | --- | --- | --- | --- | -| 1 | behavioral-fail | model-behavior | 35 | yes | 133048ms | peerRelayAB, peerRelayBC, concurrentReplies, cleanTranscript, noDuplicateTokens, latencyStable | launchBootstrap:23735ms | directReply:ok | - | Timed out waiting for OpenCode member bob to become idle. Last durableState: reply_pending | +| 1 | passed | none | 100 | yes | 201546ms | - | peerRelayAB:45148ms | directReply:ok, peerRelayAB:ok, peerRelayBC:ok, concurrentBob:ok, concurrentTom:ok | - | runId=1d6a50c3-c5cc-4c1e-91a0-d0e34a2229a3 | diff --git a/test/main/services/team/OpenCodeAcceptFastDelivery.live-e2e.test.ts b/test/main/services/team/OpenCodeAcceptFastDelivery.live-e2e.test.ts index ce320b64..2d234a28 100644 --- a/test/main/services/team/OpenCodeAcceptFastDelivery.live-e2e.test.ts +++ b/test/main/services/team/OpenCodeAcceptFastDelivery.live-e2e.test.ts @@ -78,7 +78,7 @@ liveDescribe('OpenCode accept-fast delivery live e2e', () => { } else { await fs.rm(tempDir, { recursive: true, force: true }); } - }); + }, 90_000); it( 'returns after prompt acceptance and later projects exact-session task logs', From fd50f736b8c064cb29fd24aac7cc881b7ee3aa6b Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 20 May 2026 22:49:42 +0300 Subject: [PATCH 15/44] fix(opencode): harden local provider launches --- .../runtime/OpenCodeTeamRuntimeAdapter.ts | 18 +- .../team/MemberWorkSyncOpenCode.live.test.ts | 6 +- ...odeDefaultModelResolution.live-e2e.test.ts | 182 ++++++++ ...penCodeLocalProviderAppLaunch.live.test.ts | 391 ++++++++++++++++++ .../OpenCodeTeamProvisioning.live.test.ts | 183 ++++++++ .../team/OpenCodeTeamRuntimeAdapter.test.ts | 99 ++++- 6 files changed, 868 insertions(+), 11 deletions(-) create mode 100644 test/main/services/team/OpenCodeDefaultModelResolution.live-e2e.test.ts create mode 100644 test/main/services/team/OpenCodeLocalProviderAppLaunch.live.test.ts diff --git a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts index 52d21fa0..de4a649a 100644 --- a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts +++ b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts @@ -102,11 +102,12 @@ const SECRET_FLAG_PATTERN = const BEARER_TOKEN_PATTERN = /\bBearer\s+\S+/gi; const SECRET_KEY_PATTERN = /\bsk-[A-Za-z0-9_-]{16,}\b/g; const OPEN_CODE_CAPABILITY_SNAPSHOT_REFRESH_RETRY_WARNING = - 'OpenCode capability snapshot changed between readiness and launch; refreshed readiness and retried once.'; + 'OpenCode capability snapshot changed between readiness and launch; refreshed readiness and retried launch.'; const OPEN_CODE_CAPABILITY_SNAPSHOT_PRELAUNCH_MISMATCH_MARKERS = [ 'Bridge server capability snapshot mismatch', 'OpenCode bridge capability snapshot precondition mismatch', ]; +const OPEN_CODE_CAPABILITY_SNAPSHOT_REFRESH_RETRY_LIMIT = 3; const OPEN_CODE_READINESS_RETRY_DELAYS_MS = [750, 2_000] as const; type OpenCodeTeamLaunchReadinessInput = Parameters< @@ -303,7 +304,13 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter { let data = await this.bridge.launchOpenCodeTeam( buildLaunchCommand(runtimeSnapshot, selectedModel) ); - if (!skipReadinessPreflight && isOpenCodePreLaunchCapabilitySnapshotMismatchData(data)) { + let capabilitySnapshotRefreshAttempts = 0; + while ( + !skipReadinessPreflight && + isOpenCodePreLaunchCapabilitySnapshotMismatchData(data) && + capabilitySnapshotRefreshAttempts < OPEN_CODE_CAPABILITY_SNAPSHOT_REFRESH_RETRY_LIMIT + ) { + capabilitySnapshotRefreshAttempts += 1; const refreshed = await this.prepare(input); if (!refreshed.ok) { return blockedLaunchResult( @@ -335,6 +342,8 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter { `opencode-capability-recovery-${randomUUID()}` ) ); + } else { + break; } } @@ -689,6 +698,7 @@ function mapOpenCodeLaunchDataToRuntimeResult( member.name, fallbackLaunchState, bridgeMember?.sessionId, + bridgeMember?.model, bridgeMember?.runtimePid, bridgeMember?.pendingPermissionRequestIds, bridgeMember != null, @@ -792,6 +802,7 @@ function mapBridgeMemberToRuntimeEvidence( memberName: string, launchState: OpenCodeTeamMemberLaunchBridgeState, sessionId: string | undefined, + model: string | undefined, runtimePid: number | undefined, pendingPermissionRequestIds: string[] | undefined, runtimeMaterialized: boolean, @@ -855,6 +866,7 @@ function mapBridgeMemberToRuntimeEvidence( return { memberName, providerId: 'opencode', + ...(isNonEmptyString(model) ? { model: model.trim() } : {}), launchState: failed ? 'failed_to_start' : confirmed @@ -1074,6 +1086,7 @@ function buildOpenCodeRuntimeMessageText(input: OpenCodeTeamRuntimeMessageInput) 'Do not mark the review complete from this prompt alone.', 'A visible agent-teams_message_send reply is optional. Concrete review progress, review tool usage, or agent-teams_member_work_sync_report (or mcp__agent-teams__member_work_sync_report) is sufficient response proof.', `If you cannot pick up the review now, call agent-teams_member_work_sync_status (or mcp__agent-teams__member_work_sync_status) with ${workSyncToolArgs}, then report state "blocked" or "still_working" only for the real current state.`, + 'Do not stop after member_work_sync_status. A status-only tool call is incomplete; member_work_sync_report is the required proof.', taskIds.length ? `Relevant taskIds: ${taskIds.map((id) => `"${id}"`).join(', ')}.` : null, `Do not use provider names, runtime names, or team names as memberName; use exactly "${input.memberName}".`, 'Do not reply only with acknowledgement.', @@ -1084,6 +1097,7 @@ function buildOpenCodeRuntimeMessageText(input: OpenCodeTeamRuntimeMessageInput) 'A visible agent-teams_message_send reply is optional. Concrete task progress or agent-teams_member_work_sync_report (or mcp__agent-teams__member_work_sync_report) is sufficient response proof.', `Call agent-teams_member_work_sync_status (or mcp__agent-teams__member_work_sync_status) with ${workSyncToolArgs}.`, `Then call agent-teams_member_work_sync_report (or mcp__agent-teams__member_work_sync_report) with ${workSyncToolArgs}, the returned agendaFingerprint/reportToken, and state "still_working" or "blocked".`, + 'Do not stop after member_work_sync_status. A status-only tool call is incomplete; member_work_sync_report is the required proof.', taskIds.length ? `When reporting, include taskIds: ${taskIds.map((id) => `"${id}"`).join(', ')}.` : null, diff --git a/test/main/services/team/MemberWorkSyncOpenCode.live.test.ts b/test/main/services/team/MemberWorkSyncOpenCode.live.test.ts index 56d2ed58..92fea81f 100644 --- a/test/main/services/team/MemberWorkSyncOpenCode.live.test.ts +++ b/test/main/services/team/MemberWorkSyncOpenCode.live.test.ts @@ -1,7 +1,6 @@ import { promises as fs } from 'fs'; import * as os from 'os'; import * as path from 'path'; - import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { @@ -17,6 +16,7 @@ import { getTeamsBasePath, setClaudeBasePathOverride, } from '../../../../src/main/utils/pathDecoder'; + import { formatMemberWorkSyncDiagnostics, formatProgressDump, @@ -25,9 +25,9 @@ import { } from './memberWorkSyncLiveHarness'; import { createOpenCodeLiveHarness, + type OpenCodeLiveHarness, readInboxMessages, waitForOpenCodeLanesStopped, - type OpenCodeLiveHarness, } from './openCodeLiveTestHarness'; import type { TeamChangeEvent, TeamProvisioningProgress } from '../../../../src/shared/types'; @@ -69,7 +69,7 @@ liveDescribe('Member work sync OpenCode live e2e', () => { } else { await fs.rm(tempDir, { recursive: true, force: true }); } - }); + }, 90_000); it( 'delivers a work-sync nudge to a real OpenCode member and accepts its still-working report', diff --git a/test/main/services/team/OpenCodeDefaultModelResolution.live-e2e.test.ts b/test/main/services/team/OpenCodeDefaultModelResolution.live-e2e.test.ts new file mode 100644 index 00000000..b5ec4a49 --- /dev/null +++ b/test/main/services/team/OpenCodeDefaultModelResolution.live-e2e.test.ts @@ -0,0 +1,182 @@ +import { execFile } from 'child_process'; +import { constants as fsConstants, promises as fs } from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { promisify } from 'util'; +import { describe, expect, it } from 'vitest'; + +import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService'; + +const liveDescribe = + process.env.OPENCODE_DEFAULT_MODEL_RESOLUTION_LIVE_E2E === '1' ? describe : describe.skip; + +const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli'; +const execFileAsync = promisify(execFile); + +interface ProviderModelListResponse { + providers?: { + opencode?: { + defaultModel?: string; + models?: Array<{ id?: string }>; + }; + }; +} + +interface RuntimeStatusResponse { + providers?: { + opencode?: { + models?: string[]; + modelCatalog?: { + defaultLaunchModel?: string | null; + models?: Array<{ id?: string; launchModel?: string; displayName?: string }>; + }; + }; + }; +} + +type DefaultModelResolver = { + resolveProviderDefaultModel: ( + claudePath: string, + cwd: string, + providerId: string, + env: NodeJS.ProcessEnv, + providerArgs: string[], + limitContext: boolean + ) => Promise; + materializeEffectiveTeamMemberSpecs: (params: { + claudePath: string; + cwd: string; + members: Array<{ name: string; providerId: 'opencode'; model?: string }>; + defaults: { providerId: 'anthropic' }; + primaryProviderId: 'opencode'; + primaryEnv: { + env: NodeJS.ProcessEnv; + authSource: string; + providerArgs: string[]; + geminiRuntimeAuth: null; + }; + providerArgsResolver: () => string[]; + limitContext: boolean; + }) => Promise>; +}; + +liveDescribe('OpenCode default model resolution live e2e', () => { + it('materializes an OpenCode Default teammate through the real model-list pipe', async () => { + const orchestratorCli = + process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI; + await assertExecutable(orchestratorCli); + + const env: NodeJS.ProcessEnv = { + ...process.env, + PATH: withBunOnPath(process.env.PATH ?? ''), + OPENCODE_DISABLE_AUTOUPDATE: process.env.OPENCODE_DISABLE_AUTOUPDATE ?? '1', + }; + const svc = new TeamProvisioningService() as unknown as DefaultModelResolver; + + const defaultModel = await svc.resolveProviderDefaultModel( + orchestratorCli, + process.cwd(), + 'opencode', + env, + [], + false + ); + + expect(defaultModel).toMatch(/^opencode\/.+/); + + await expect( + svc.materializeEffectiveTeamMemberSpecs({ + claudePath: orchestratorCli, + cwd: process.cwd(), + members: [{ name: 'atlas', providerId: 'opencode' }], + defaults: { providerId: 'anthropic' }, + primaryProviderId: 'opencode', + primaryEnv: { + env, + authSource: 'opencode_managed', + providerArgs: [], + geminiRuntimeAuth: null, + }, + providerArgsResolver: () => [], + limitContext: false, + }) + ).resolves.toEqual([{ name: 'atlas', providerId: 'opencode', model: defaultModel }]); + }, 60_000); + + it('keeps the real OpenCode catalog hydrated instead of summary-only big-pickle', async () => { + const orchestratorCli = + process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI; + await assertExecutable(orchestratorCli); + + const env = buildOpenCodeLiveEnv(); + const modelList = await runJsonCommand( + orchestratorCli, + ['model', 'list', '--json', '--provider', 'opencode'], + env + ); + const modelListIds = + modelList.providers?.opencode?.models + ?.map((model) => model.id?.trim()) + .filter((id): id is string => Boolean(id)) ?? []; + + expect(modelList.providers?.opencode?.defaultModel).toBe('opencode/big-pickle'); + expect(modelListIds.length).toBeGreaterThan(50); + expect(modelListIds).toContain('opencode/big-pickle'); + expect(modelListIds.some((id) => id !== 'opencode/big-pickle')).toBe(true); + + const runtimeStatus = await runJsonCommand( + orchestratorCli, + ['runtime', 'status', '--json', '--provider', 'opencode'], + env + ); + const provider = runtimeStatus.providers?.opencode; + const catalogIds = + provider?.modelCatalog?.models + ?.map((model) => model.launchModel?.trim() || model.id?.trim()) + .filter((id): id is string => Boolean(id)) ?? []; + + expect(provider?.modelCatalog?.defaultLaunchModel).toBe('opencode/big-pickle'); + expect(provider?.models?.length ?? 0).toBeGreaterThan(50); + expect(catalogIds.length).toBeGreaterThan(50); + expect(catalogIds).toContain('opencode/big-pickle'); + }, 90_000); +}); + +async function assertExecutable(filePath: string): Promise { + await fs.access(filePath, fsConstants.X_OK); +} + +function withBunOnPath(value: string): string { + const candidates = [ + process.env.BUN_INSTALL ? path.join(process.env.BUN_INSTALL, 'bin') : null, + process.env.HOME ? path.join(process.env.HOME, '.bun', 'bin') : null, + '/opt/homebrew/bin', + '/usr/local/bin', + ].filter((candidate): candidate is string => Boolean(candidate)); + return [...candidates, value].join(path.delimiter); +} + +function buildOpenCodeLiveEnv(): NodeJS.ProcessEnv { + const realHome = os.userInfo().homedir; + return { + ...process.env, + HOME: realHome, + USERPROFILE: realHome, + PATH: withBunOnPath(process.env.PATH ?? ''), + OPENCODE_DISABLE_AUTOUPDATE: process.env.OPENCODE_DISABLE_AUTOUPDATE ?? '1', + }; +} + +async function runJsonCommand( + binaryPath: string, + args: string[], + env: NodeJS.ProcessEnv +): Promise { + const { stdout } = await execFileAsync(binaryPath, args, { + cwd: process.cwd(), + env, + timeout: 60_000, + maxBuffer: 1024 * 1024, + }); + return JSON.parse(stdout) as T; +} diff --git a/test/main/services/team/OpenCodeLocalProviderAppLaunch.live.test.ts b/test/main/services/team/OpenCodeLocalProviderAppLaunch.live.test.ts new file mode 100644 index 00000000..82ff752e --- /dev/null +++ b/test/main/services/team/OpenCodeLocalProviderAppLaunch.live.test.ts @@ -0,0 +1,391 @@ +import { promises as fs } from 'node:fs'; +import * as http from 'node:http'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + getTeamsBasePath, + setClaudeBasePathOverride, +} from '../../../../src/main/utils/pathDecoder'; + +import { formatProgressDump } from './memberWorkSyncLiveHarness'; +import { + createOpenCodeLiveHarness, + type OpenCodeLiveHarness, + waitForOpenCodeLanesStopped, + waitUntil, +} from './openCodeLiveTestHarness'; + +import type { TeamProvisioningProgress } from '../../../../src/shared/types'; + +const liveDescribe = + process.env.OPENCODE_E2E === '1' && process.env.OPENCODE_E2E_LOCAL_PROVIDER_APP_LAUNCH === '1' + ? describe + : describe.skip; + +const LOCAL_MODEL = 'llama.cpp/qwen-test:0.5b'; + +liveDescribe('OpenCode local provider app launch live e2e', () => { + let tempDir: string; + let tempClaudeRoot: string; + let fakeServer: FakeOpenAiCompatibleServer | null; + let harness: OpenCodeLiveHarness | null; + let teamName: string | null; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-local-provider-app-launch-')); + tempClaudeRoot = path.join(tempDir, '.claude'); + await fs.mkdir(tempClaudeRoot, { recursive: true }); + setClaudeBasePathOverride(tempClaudeRoot); + fakeServer = null; + harness = null; + teamName = null; + }); + + afterEach(async () => { + if (harness && teamName) { + await harness.svc.stopTeam(teamName).catch(() => undefined); + await waitForOpenCodeLanesStopped(teamName); + } + await harness?.dispose().catch(() => undefined); + await fakeServer?.close().catch(() => undefined); + setClaudeBasePathOverride(null); + if (process.env.OPENCODE_E2E_KEEP_TEMP === '1') { + console.info(`[OpenCodeLocalProviderAppLaunch.live] preserved temp dir: ${tempDir}`); + } else { + await fs.rm(tempDir, { recursive: true, force: true }); + } + clearBenignSlowConfigReadWarnings(); + }, 90_000); + + it( + 'creates and stops an OpenCode team through the app service using a configured authless local provider', + async () => { + const projectPath = path.join(tempDir, 'project'); + await fs.mkdir(projectPath, { recursive: true }); + await fs.writeFile( + path.join(projectPath, 'README.md'), + '# OpenCode local provider app launch live e2e\n', + 'utf8' + ); + fakeServer = await startFakeOpenAiCompatibleServer(); + await writeFakeLocalOpenCodeConfig({ + projectPath, + baseUrl: fakeServer.baseUrl, + }); + + harness = await createOpenCodeLiveHarness({ + tempDir, + selectedModel: LOCAL_MODEL, + projectPath, + }); + + teamName = `opencode-local-provider-app-${Date.now()}`; + const progressEvents: TeamProvisioningProgress[] = []; + const { runId } = await harness.svc.createTeam( + { + teamName, + cwd: projectPath, + providerId: 'opencode', + model: LOCAL_MODEL, + skipPermissions: true, + members: [ + { + name: 'bob', + role: 'Developer', + providerId: 'opencode', + model: LOCAL_MODEL, + mcpPolicy: { mode: 'appOnly' }, + }, + ], + }, + (progress) => progressEvents.push(progress) + ); + + const progressDump = formatProgressDump(progressEvents); + expect(runId, progressDump).toBeTruthy(); + expect( + progressEvents.some((progress) => + progress.message.includes('OpenCode team launch is ready') + ), + progressDump + ).toBe(true); + expect(progressDump).not.toContain('provider not connected'); + expect(progressDump).not.toContain('not authenticated'); + expect(progressDump).not.toContain('OpenCode team launch is not enabled'); + expect(fakeServer.requests, progressDump).toContain('POST /v1/chat/completions'); + + const runtimeSnapshot = await harness.svc.getTeamAgentRuntimeSnapshot(teamName); + expect(runtimeSnapshot.runId).toBe(runId); + expect(runtimeSnapshot.members.bob).toMatchObject({ + alive: true, + providerId: 'opencode', + laneId: 'primary', + laneKind: 'primary', + runtimeModel: LOCAL_MODEL, + historicalBootstrapConfirmed: true, + }); + + const deliveryMarker = `local-provider-delivery-${Date.now()}`; + const chatBodyCountBeforeDelivery = fakeServer.chatBodies.length; + const delivery = await harness.svc.deliverOpenCodeMemberMessage(teamName, { + memberName: 'bob', + messageId: `local-provider-delivery-${Date.now()}`, + replyRecipient: 'user', + source: 'manual', + text: [ + `Local provider delivery marker: ${deliveryMarker}`, + 'Answer with PONG. Do not edit files.', + ].join('\n'), + }); + expect(delivery.delivered, JSON.stringify(delivery, null, 2)).toBe(true); + await waitUntil( + async () => + fakeServer!.chatBodies.length > chatBodyCountBeforeDelivery && + fakeServer!.chatBodies.some((body) => JSON.stringify(body).includes(deliveryMarker)), + 60_000, + 500 + ); + + await harness.svc.stopTeam(teamName); + await waitForOpenCodeLanesStopped(teamName); + clearBenignSlowConfigReadWarnings(); + }, + 300_000 + ); + + it( + 'fails app service launch for an unknown local model before creating OpenCode lanes', + async () => { + const projectPath = path.join(tempDir, 'unknown-model-project'); + await fs.mkdir(projectPath, { recursive: true }); + fakeServer = await startFakeOpenAiCompatibleServer(); + await writeFakeLocalOpenCodeConfig({ + projectPath, + baseUrl: fakeServer.baseUrl, + }); + + harness = await createOpenCodeLiveHarness({ + tempDir, + selectedModel: 'llama.cpp/missing-test:0.5b', + projectPath, + }); + + teamName = `opencode-local-provider-unknown-${Date.now()}`; + const progressEvents: TeamProvisioningProgress[] = []; + const { runId } = await harness.svc.createTeam( + { + teamName, + cwd: projectPath, + providerId: 'opencode', + model: 'llama.cpp/missing-test:0.5b', + skipPermissions: true, + members: [ + { + name: 'bob', + role: 'Developer', + providerId: 'opencode', + model: 'llama.cpp/missing-test:0.5b', + }, + ], + }, + (progress) => progressEvents.push(progress) + ); + expect(runId).toBeTruthy(); + await waitUntil( + async () => progressEvents.some((progress) => progress.state === 'failed'), + 30_000, + 500 + ); + + const progressDump = formatProgressDump(progressEvents); + expect(progressEvents.some((progress) => progress.state === 'failed'), progressDump).toBe( + true + ); + expect(progressDump).toMatch(/missing-test:0\.5b|not available|unavailable/i); + expect(fakeServer.requests, progressDump).not.toContain('POST /v1/chat/completions'); + await waitUntil( + async () => { + const laneIndexPath = path.join( + getTeamsBasePath(), + teamName!, + 'runtime', + 'opencode', + 'lanes.json' + ); + try { + const parsed = JSON.parse(await fs.readFile(laneIndexPath, 'utf8')) as { + lanes?: Record; + }; + return Object.keys(parsed.lanes ?? {}).length === 0; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return true; + } + throw error; + } + }, + 15_000, + 500 + ); + clearBenignSlowConfigReadWarnings(); + }, + 180_000 + ); +}); + +interface FakeOpenAiCompatibleServer { + baseUrl: string; + requests: string[]; + chatBodies: unknown[]; + close: () => Promise; +} + +async function startFakeOpenAiCompatibleServer(): Promise { + const requests: string[] = []; + const chatBodies: unknown[] = []; + const server = http.createServer(async (request, response) => { + requests.push(`${request.method ?? 'GET'} ${request.url ?? '/'}`); + if (request.url === '/v1/models') { + sendJson(response, 200, { + object: 'list', + data: [{ id: 'qwen-test:0.5b', object: 'model' }], + }); + return; + } + + if (request.method === 'POST' && request.url === '/v1/chat/completions') { + const body = JSON.parse((await readRequestBody(request)) || '{}') as { stream?: boolean }; + chatBodies.push(body); + if (body.stream) { + const created = Math.floor(Date.now() / 1000); + response.writeHead(200, { + 'content-type': 'text/event-stream', + 'cache-control': 'no-cache', + }); + response.write( + `data: ${JSON.stringify({ + id: 'chatcmpl-test', + object: 'chat.completion.chunk', + created, + model: 'qwen-test:0.5b', + choices: [ + { + index: 0, + delta: { role: 'assistant', content: 'PONG' }, + finish_reason: null, + }, + ], + })}\n\n` + ); + response.write( + `data: ${JSON.stringify({ + id: 'chatcmpl-test', + object: 'chat.completion.chunk', + created, + model: 'qwen-test:0.5b', + choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], + })}\n\n` + ); + response.end('data: [DONE]\n\n'); + return; + } + + sendJson(response, 200, { + id: 'chatcmpl-test', + object: 'chat.completion', + model: 'qwen-test:0.5b', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'PONG' }, + finish_reason: 'stop', + }, + ], + }); + return; + } + + sendJson(response, 404, { error: { message: 'not found' } }); + }); + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + if (!address || typeof address === 'string') { + await closeServer(server); + throw new Error('Fake OpenAI-compatible server did not bind to a TCP port'); + } + + return { + baseUrl: `http://127.0.0.1:${address.port}`, + requests, + chatBodies, + close: () => closeServer(server), + }; +} + +async function writeFakeLocalOpenCodeConfig(input: { + projectPath: string; + baseUrl: string; +}): Promise { + const configPath = path.join(input.projectPath, 'opencode.json'); + await fs.writeFile( + configPath, + `${JSON.stringify( + { + provider: { + 'llama.cpp': { + npm: '@ai-sdk/openai-compatible', + options: { + baseURL: `${input.baseUrl}/v1`, + }, + models: { + 'qwen-test:0.5b': {}, + }, + }, + }, + model: LOCAL_MODEL, + small_model: LOCAL_MODEL, + }, + null, + 2 + )}\n`, + 'utf8' + ); +} + +async function readRequestBody(request: http.IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of request) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks).toString('utf8'); +} + +function sendJson(response: http.ServerResponse, status: number, body: unknown): void { + response.writeHead(status, { 'content-type': 'application/json' }); + response.end(JSON.stringify(body)); +} + +function closeServer(server: http.Server): Promise { + return new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); +} + +function clearBenignSlowConfigReadWarnings(): void { + const warn = vi.mocked(console.warn); + if ( + warn.mock.calls.length > 0 && + warn.mock.calls.every((call) => + call.map((part) => String(part)).join(' ').includes('[getConfig] slow read diag=') + ) + ) { + warn.mockClear(); + } +} diff --git a/test/main/services/team/OpenCodeTeamProvisioning.live.test.ts b/test/main/services/team/OpenCodeTeamProvisioning.live.test.ts index a80f4837..57ed3cce 100644 --- a/test/main/services/team/OpenCodeTeamProvisioning.live.test.ts +++ b/test/main/services/team/OpenCodeTeamProvisioning.live.test.ts @@ -40,6 +40,8 @@ const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_ const DEFAULT_MODEL = 'opencode/big-pickle'; liveDescribe('OpenCode team provisioning live e2e', () => { + const liveDefaultModelIt = + process.env.OPENCODE_E2E_DEFAULT_MODEL_LAUNCH === '1' ? it : it.skip; let tempDir: string; let tempClaudeRoot: string; @@ -205,6 +207,187 @@ liveDescribe('OpenCode team provisioning live e2e', () => { .catch(() => undefined); } }, 300_000); + + liveDefaultModelIt( + 'creates and stops a pure OpenCode team when all OpenCode model selections are Default', + async () => { + const orchestratorCli = + process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI; + await assertExecutable(orchestratorCli); + const projectPath = path.join(tempDir, 'default-model-project'); + await fs.mkdir(projectPath, { recursive: true }); + await fs.writeFile( + path.join(projectPath, 'opencode.json'), + `${JSON.stringify({ model: DEFAULT_MODEL, small_model: DEFAULT_MODEL }, null, 2)}\n` + ); + + const mcpLaunchSpec = await resolveAgentTeamsMcpLaunchSpec(); + const bridgeEnv = { + ...createStableBridgeEnv(), + PATH: withBunOnPath(process.env.PATH ?? ''), + XDG_DATA_HOME: path.join(tempDir, 'xdg-data-default-model'), + AGENT_TEAMS_MCP_CLAUDE_DIR: tempClaudeRoot, + CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND: mcpLaunchSpec.command, + CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: mcpLaunchSpec.args[0] ?? '', + CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON: JSON.stringify(mcpLaunchSpec.args), + }; + const bridgeClient = new OpenCodeBridgeCommandClient({ + binaryPath: orchestratorCli, + tempDirectory: path.join(tempDir, 'bridge-input-default-model'), + env: bridgeEnv, + }); + const stateChangingCommands = createStateChangingCommands({ + bridge: bridgeClient, + controlDir: path.join(tempDir, 'control-default-model'), + }); + const readinessBridge = new OpenCodeReadinessBridge(bridgeClient, { + stateChangingCommands, + timeoutMs: 180_000, + launchTimeoutMs: 180_000, + reconcileTimeoutMs: 90_000, + stopTimeoutMs: 90_000, + }); + const adapter = new OpenCodeTeamRuntimeAdapter(readinessBridge); + const svc = new TeamProvisioningService(); + svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); + + const teamName = `opencode-team-default-model-${Date.now()}`; + const progressEvents: TeamProvisioningProgress[] = []; + + try { + const { runId } = await svc.createTeam( + { + teamName, + cwd: projectPath, + providerId: 'opencode', + skipPermissions: true, + members: [ + { + name: 'atlas', + role: 'Developer', + providerId: 'opencode', + }, + ], + }, + (progress) => { + progressEvents.push(progress); + } + ); + + expect(runId).toBeTruthy(); + const progressDump = progressEvents + .map((progress) => + [ + progress.state, + progress.message, + progress.messageSeverity, + progress.error, + progress.cliLogsTail, + ] + .filter(Boolean) + .join(' | ') + ) + .join('\n'); + expect( + progressEvents.some((progress) => + progress.message.includes('OpenCode team launch is ready') + ), + progressDump + ).toBe(true); + expect(progressDump).not.toContain('OpenCode launch requires a selected raw model id'); + expect(progressDump).not.toContain('Failed to parse runtime default model list'); + expect(progressDump).not.toContain('Failed to load runtime default model list'); + + const runtimeSnapshot = await svc.getTeamAgentRuntimeSnapshot(teamName); + expect(runtimeSnapshot.runId).toBe(runId); + expect(runtimeSnapshot.members.atlas).toMatchObject({ + alive: true, + providerId: 'opencode', + laneId: 'primary', + laneKind: 'primary', + runtimeModel: DEFAULT_MODEL, + historicalBootstrapConfirmed: true, + }); + expect(hasOpenCodeRuntimeHandle(runtimeSnapshot.members.atlas)).toBe(true); + + await svc.stopTeam(teamName); + await waitUntil(async () => { + const laneIndex = await readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName); + return Object.keys(laneIndex.lanes).length === 0; + }, 90_000); + + const relaunchProgressEvents: TeamProvisioningProgress[] = []; + const { runId: relaunchRunId } = await svc.launchTeam( + { + teamName, + cwd: projectPath, + providerId: 'opencode', + skipPermissions: true, + }, + (progress) => { + relaunchProgressEvents.push(progress); + } + ); + expect(relaunchRunId).toBeTruthy(); + expect(relaunchRunId).not.toBe(runId); + const relaunchProgressDump = relaunchProgressEvents + .map((progress) => + [ + progress.state, + progress.message, + progress.messageSeverity, + progress.error, + progress.cliLogsTail, + ] + .filter(Boolean) + .join(' | ') + ) + .join('\n'); + expect( + relaunchProgressEvents.some((progress) => + progress.message.includes('OpenCode team launch is ready') + ), + relaunchProgressDump + ).toBe(true); + expect(relaunchProgressDump).not.toContain( + 'OpenCode launch requires a selected raw model id' + ); + expect(relaunchProgressDump).not.toContain('No OpenCode model is available'); + expect(relaunchProgressDump).not.toContain('Failed to parse runtime default model list'); + expect(relaunchProgressDump).not.toContain('Failed to load runtime default model list'); + + const relaunchedSnapshot = await svc.getTeamAgentRuntimeSnapshot(teamName); + expect(relaunchedSnapshot.runId).toBe(relaunchRunId); + expect(relaunchedSnapshot.members.atlas).toMatchObject({ + alive: true, + providerId: 'opencode', + laneId: 'primary', + laneKind: 'primary', + runtimeModel: DEFAULT_MODEL, + historicalBootstrapConfirmed: true, + }); + expect(hasOpenCodeRuntimeHandle(relaunchedSnapshot.members.atlas)).toBe(true); + + await svc.stopTeam(teamName); + await waitUntil(async () => { + const laneIndex = await readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName); + return Object.keys(laneIndex.lanes).length === 0; + }, 90_000); + } finally { + await svc.stopTeam(teamName).catch(() => undefined); + await readinessBridge + .cleanupOpenCodeHosts({ + reason: 'opencode-team-default-model-live-e2e-cleanup', + mode: 'force', + projectPath, + staleAgeMs: null, + leaseStaleAgeMs: null, + }) + .catch(() => undefined); + } + }, + 300_000 + ); }); function createStateChangingCommands(input: { diff --git a/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts index c95a6329..8250b9ff 100644 --- a/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts +++ b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts @@ -1,15 +1,15 @@ import { describe, expect, it, vi } from 'vitest'; +import { REQUIRED_AGENT_TEAMS_APP_TOOL_IDS } from '../../../../src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability'; import { OpenCodeTeamRuntimeAdapter, type OpenCodeTeamRuntimeBridgePort, type TeamRuntimeLaunchInput, } from '../../../../src/main/services/team/runtime'; -import type { OpenCodeTeamLaunchReadiness } from '../../../../src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness'; import type { OpenCodeLaunchTeamCommandData } from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract'; +import type { OpenCodeTeamLaunchReadiness } from '../../../../src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness'; import type { PersistedTeamLaunchSnapshot } from '../../../../src/shared/types'; -import { REQUIRED_AGENT_TEAMS_APP_TOOL_IDS } from '../../../../src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability'; describe('OpenCodeTeamRuntimeAdapter', () => { it('maps readiness failures to a structured prepare block', async () => { @@ -376,6 +376,44 @@ describe('OpenCodeTeamRuntimeAdapter', () => { ); }); + it('launches model-less Default selections with the readiness-resolved model', async () => { + const launchOpenCodeTeam = vi.fn< + NonNullable + >(async () => successfulOpenCodeLaunchData({ model: 'opencode/big-pickle' })); + const adapter = new OpenCodeTeamRuntimeAdapter( + bridgePort( + readiness({ + state: 'ready', + launchAllowed: true, + modelId: 'opencode/big-pickle', + availableModels: ['opencode/big-pickle'], + }), + { launchOpenCodeTeam } + ) + ); + + const result = await adapter.launch( + launchInput({ + model: undefined, + expectedMembers: [ + { + name: 'alice', + providerId: 'opencode', + cwd: '/repo', + }, + ], + }) + ); + + expect(result.teamLaunchState).toBe('clean_success'); + expect(launchOpenCodeTeam).toHaveBeenCalledWith( + expect.objectContaining({ + selectedModel: 'opencode/big-pickle', + }) + ); + expect(result.members.alice?.model).toBe('opencode/big-pickle'); + }); + it('uses concrete member diagnostics as failed OpenCode hard failure reasons', async () => { const concreteReason = 'Latest assistant message msg_123 failed with APIError - Insufficient credits. Add more using https://openrouter.ai/settings/credits'; @@ -607,7 +645,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => { expect(result.teamLaunchState).toBe('clean_success'); expect(result.warnings).toContain( - 'OpenCode capability snapshot changed between readiness and launch; refreshed readiness and retried once.' + 'OpenCode capability snapshot changed between readiness and launch; refreshed readiness and retried launch.' ); expect(checkReadiness).toHaveBeenCalledTimes(2); expect(launchOpenCodeTeam).toHaveBeenCalledTimes(2); @@ -637,6 +675,50 @@ describe('OpenCodeTeamRuntimeAdapter', () => { ); }); + it('keeps refreshing bounded capability snapshot churn until launch observes the current snapshot', async () => { + let readinessCalls = 0; + const capabilitySnapshots = ['cap-1', 'cap-2', 'cap-3', 'cap-4']; + const checkReadiness = vi.fn< + OpenCodeTeamRuntimeBridgePort['checkOpenCodeTeamLaunchReadiness'] + >(() => { + readinessCalls += 1; + return Promise.resolve(readiness({ state: 'ready', launchAllowed: true })); + }); + const launchOpenCodeTeam = vi.fn< + NonNullable + >((input) => + Promise.resolve( + input.expectedCapabilitySnapshotId === 'cap-3' + ? successfulOpenCodeLaunchData() + : failedCapabilitySnapshotLaunchData('Bridge server capability snapshot mismatch') + ) + ); + const adapter = new OpenCodeTeamRuntimeAdapter({ + checkOpenCodeTeamLaunchReadiness: checkReadiness, + getLastOpenCodeRuntimeSnapshot: vi.fn( + () => runtimeSnapshot(capabilitySnapshots[Math.max(0, Math.min(readinessCalls - 1, 3))] ?? 'cap-4') + ), + launchOpenCodeTeam, + }); + + const result = await adapter.launch(launchInput()); + + expect(result.teamLaunchState).toBe('clean_success'); + expect(result.warnings).toContain( + 'OpenCode capability snapshot changed between readiness and launch; refreshed readiness and retried launch.' + ); + expect(checkReadiness).toHaveBeenCalledTimes(3); + expect(launchOpenCodeTeam).toHaveBeenCalledTimes(3); + expect(launchOpenCodeTeam.mock.calls.map((call) => call[0].expectedCapabilitySnapshotId)).toEqual( + ['cap-1', 'cap-2', 'cap-3'] + ); + expect( + launchOpenCodeTeam.mock.calls.slice(1).every((call) => + /^opencode-capability-recovery-/.test(call[0].capabilitySnapshotRecoveryAttemptId ?? '') + ) + ).toBe(true); + }); + it('uses a fresh recovery attempt id when capability refresh returns the same snapshot', async () => { let readinessCalls = 0; const checkReadiness = vi.fn< @@ -775,7 +857,8 @@ describe('OpenCodeTeamRuntimeAdapter', () => { const result = await adapter.launch(launchInput()); expect(result.teamLaunchState).toBe('partial_failure'); - expect(launchOpenCodeTeam).toHaveBeenCalledTimes(2); + expect(checkReadiness).toHaveBeenCalledTimes(4); + expect(launchOpenCodeTeam).toHaveBeenCalledTimes(4); expect(result.diagnostics).toContain( 'error:opencode_bridge: OpenCode bridge failed: OpenCode bridge capability snapshot precondition mismatch' ); @@ -1094,6 +1177,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => { expect(sentText).toContain('agent-teams_member_work_sync_status'); expect(sentText).toContain('agent-teams_member_work_sync_report'); expect(sentText).toContain('mcp__agent-teams__member_work_sync_report'); + expect(sentText).toContain('A status-only tool call is incomplete'); expect(sentText).toContain('teamName="team-a"'); expect(sentText).toContain('memberName="bob"'); expect(sentText).toContain('controlUrl="http://127.0.0.1:43123"'); @@ -1143,6 +1227,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => { expect(sentText).toContain('review workflow tools'); expect(sentText).toContain('Do not mark the review complete from this prompt alone.'); expect(sentText).toContain('agent-teams_member_work_sync_report'); + expect(sentText).toContain('A status-only tool call is incomplete'); expect(sentText).not.toContain('This delivered app message is a member-work-sync nudge.'); }); @@ -1544,7 +1629,9 @@ function runtimeSnapshot(capabilitySnapshotId: string) { }; } -function successfulOpenCodeLaunchData(): OpenCodeLaunchTeamCommandData { +function successfulOpenCodeLaunchData( + overrides: { model?: string } = {} +): OpenCodeLaunchTeamCommandData { return { runId: 'run-1', teamLaunchState: 'ready', @@ -1553,7 +1640,7 @@ function successfulOpenCodeLaunchData(): OpenCodeLaunchTeamCommandData { sessionId: 'oc-session-1', launchState: 'confirmed_alive', runtimePid: 123, - model: 'openai/gpt-5.4-mini', + model: overrides.model ?? 'openai/gpt-5.4-mini', evidence: [ { kind: 'required_tools_proven', observedAt: '2026-04-21T00:00:00.000Z' }, { kind: 'delivery_ready', observedAt: '2026-04-21T00:00:00.000Z' }, From 635d13d804aad5d90a68da4ebeaccd3386689215 Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 20 May 2026 23:02:13 +0300 Subject: [PATCH 16/44] fix(opencode): harden default model resolution --- .../services/team/TeamProvisioningService.ts | 362 ++++++++++++++---- .../team/runtime/TeamRuntimeAdapter.ts | 1 + .../TeamProvisioningServicePrepare.test.ts | 326 ++++++++++++++++ 3 files changed, 620 insertions(+), 69 deletions(-) diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 33daae73..ef1f62e7 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -674,6 +674,8 @@ const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/; const RUN_TIMEOUT_MS = 300_000; const VERIFY_TIMEOUT_MS = 15_000; const MCP_PREFLIGHT_INITIALIZE_TIMEOUT_MS = 45_000; +const PROVIDER_MODEL_LIST_TIMEOUT_MS = 30_000; +const PROVIDER_RUNTIME_STATUS_TIMEOUT_MS = 20_000; const TASK_ACTIVITY_RUNTIME_PAUSE_GRACE_MS = 5_000; function asRuntimeRecord(value: unknown): Record { @@ -1344,16 +1346,77 @@ function extractJsonObjectFromCli(raw: string): T { const trimmed = raw.trim(); try { return JSON.parse(trimmed) as T; - } catch { - const start = trimmed.indexOf('{'); - const end = trimmed.lastIndexOf('}'); - if (start >= 0 && end > start) { - return JSON.parse(trimmed.slice(start, end + 1)) as T; + } catch (initialError) { + const candidates: T[] = []; + let lastParseError: unknown = null; + for (let start = trimmed.indexOf('{'); start >= 0; start = trimmed.indexOf('{', start + 1)) { + const end = findJsonObjectEnd(trimmed, start); + if (end < 0) { + continue; + } + try { + candidates.push(JSON.parse(trimmed.slice(start, end + 1)) as T); + } catch (error) { + lastParseError = error; + } + } + + let providerResponse: T | null = null; + for (let index = candidates.length - 1; index >= 0; index -= 1) { + const record = candidates[index] as Record | null; + const providers = record && typeof record === 'object' ? record.providers : null; + if (providers && typeof providers === 'object' && !Array.isArray(providers)) { + providerResponse = candidates[index]; + break; + } + } + if (providerResponse) { + return providerResponse; + } + if (candidates.length > 0) { + throw new Error('No provider JSON object found in CLI output'); + } + if (lastParseError instanceof Error) { + throw lastParseError; + } + if (trimmed.includes('{') && initialError instanceof Error) { + throw initialError; } throw new Error('No JSON object found in CLI output'); } } +function findJsonObjectEnd(source: string, start: number): number { + let depth = 0; + let inString = false; + let escaped = false; + for (let index = start; index < source.length; index += 1) { + const char = source[index]; + if (inString) { + if (escaped) { + escaped = false; + } else if (char === '\\') { + escaped = true; + } else if (char === '"') { + inString = false; + } + continue; + } + + if (char === '"') { + inString = true; + } else if (char === '{') { + depth += 1; + } else if (char === '}') { + depth -= 1; + if (depth === 0) { + return index; + } + } + } + return -1; +} + function getExplicitLaunchModelSelection(model: string | undefined): string | undefined { const trimmed = model?.trim(); if (!trimmed || isDefaultProviderModelSelection(trimmed)) { @@ -7256,7 +7319,7 @@ export class TeamProvisioningService { { cwd: params.cwd, env: params.env, - timeout: 10_000, + timeout: PROVIDER_MODEL_LIST_TIMEOUT_MS, } ); const runtimeStatusPromise = @@ -20440,28 +20503,39 @@ export class TeamProvisioningService { providerArgs: string[] = [], limitContext: boolean ): Promise { - const { stdout } = await execCli( - claudePath, - buildProviderCliCommandArgs(providerArgs, [ - 'model', - 'list', - '--json', - '--provider', - providerId, - ]), - { - cwd, - env, - timeout: 10_000, - } - ); let parsed: ProviderModelListCommandResponse; try { + const { stdout } = await execCli( + claudePath, + buildProviderCliCommandArgs(providerArgs, [ + 'model', + 'list', + '--json', + '--provider', + providerId, + ]), + { + cwd, + env, + timeout: PROVIDER_MODEL_LIST_TIMEOUT_MS, + } + ); parsed = extractJsonObjectFromCli(stdout); } catch (error) { + const fallbackDefaultModel = await this.resolveProviderDefaultModelFromRuntimeStatus( + claudePath, + cwd, + providerId, + env, + providerArgs, + limitContext + ).catch(() => null); + if (fallbackDefaultModel) { + return fallbackDefaultModel; + } const message = error instanceof Error ? error.message : String(error); throw new Error( - `Failed to parse runtime default model list for ${getTeamProviderLabel(providerId)} (${providerId}): ${message}` + `Failed to load runtime default model list for ${getTeamProviderLabel(providerId)} (${providerId}): ${message}` ); } const defaultModel = parsed.providers?.[providerId]?.defaultModel; @@ -20482,6 +20556,46 @@ export class TeamProvisioningService { return normalizedDefaultModel; } + private async resolveProviderDefaultModelFromRuntimeStatus( + claudePath: string, + cwd: string, + providerId: TeamProviderId, + env: NodeJS.ProcessEnv, + providerArgs: string[] = [], + limitContext: boolean + ): Promise { + const { stdout } = await execCli( + claudePath, + buildProviderCliCommandArgs(providerArgs, [ + 'runtime', + 'status', + '--json', + '--provider', + providerId, + ]), + { + cwd, + env, + timeout: PROVIDER_RUNTIME_STATUS_TIMEOUT_MS, + } + ); + const parsed = extractJsonObjectFromCli(stdout); + const providerStatus = parsed.providers?.[providerId] ?? null; + const modelCatalog = + providerStatus?.modelCatalog?.providerId === providerId ? providerStatus.modelCatalog : null; + const defaultLaunchModel = modelCatalog?.defaultLaunchModel?.trim() || null; + + if (providerId === 'anthropic') { + return resolveAnthropicLaunchModel({ + limitContext, + availableLaunchModels: modelCatalog?.models.map((model) => model.launchModel) ?? [], + defaultLaunchModel, + }); + } + + return defaultLaunchModel; + } + private async materializeEffectiveTeamMemberSpecs(params: { claudePath: string; cwd: string; @@ -20605,6 +20719,111 @@ export class TeamProvisioningService { ); } + private async materializeOpenCodeRuntimeAdapterDefaults< + TRequest extends TeamCreateRequest | TeamLaunchRequest, + >(params: { + request: TRequest; + members: TeamCreateRequest['members']; + }): Promise<{ + request: TRequest; + members: TeamCreateRequest['members']; + }> { + const effectiveMembers = buildEffectiveTeamMemberSpecs(params.members, { + providerId: params.request.providerId, + model: params.request.model, + effort: params.request.effort, + }); + const explicitRootModel = getExplicitLaunchModelSelection(params.request.model); + const memberModels = [ + ...new Set( + effectiveMembers + .map((member) => member.model?.trim()) + .filter((model): model is string => Boolean(model)) + ), + ]; + if (!explicitRootModel && memberModels.length > 1) { + throw new Error( + 'OpenCode runtime adapter launch supports one selected model per lane. Select one team model or align OpenCode teammate models.' + ); + } + const inheritedRootModel = explicitRootModel ? undefined : memberModels[0]; + const rootModel = explicitRootModel ?? inheritedRootModel; + const needsMemberModel = effectiveMembers.some((member) => { + const providerId = normalizeTeamMemberProviderId(member.providerId) ?? 'opencode'; + return providerId === 'opencode' && !member.model?.trim(); + }); + if (rootModel && !needsMemberModel) { + return { + request: { + ...params.request, + model: rootModel, + } as TRequest, + members: effectiveMembers, + }; + } + if (rootModel) { + return { + request: { + ...params.request, + model: rootModel, + } as TRequest, + members: effectiveMembers.map((member) => { + const providerId = normalizeTeamMemberProviderId(member.providerId) ?? 'opencode'; + if (providerId !== 'opencode' || member.model?.trim()) { + return member; + } + return { + ...member, + model: rootModel, + }; + }), + }; + } + + const claudePath = await ClaudeBinaryResolver.resolve(); + if (!claudePath) { + throw buildMissingCliError(); + } + const provisioningEnv = await this.buildProvisioningEnv( + 'opencode', + params.request.providerBackendId + ); + if (provisioningEnv.warning) { + throw new Error(provisioningEnv.warning); + } + const resolvedDefaultModel = await this.resolveProviderDefaultModel( + claudePath, + params.request.cwd, + 'opencode', + provisioningEnv.env, + provisioningEnv.providerArgs ?? [], + params.request.limitContext === true + ); + const normalizedDefaultModel = resolvedDefaultModel?.trim(); + if (!normalizedDefaultModel) { + throw new Error( + 'Could not resolve the runtime default model for OpenCode teammates. Select an explicit model and retry.' + ); + } + + return { + request: { + ...params.request, + model: normalizedDefaultModel, + } as TRequest, + members: effectiveMembers.map((member) => { + const providerId = normalizeTeamMemberProviderId(member.providerId) ?? 'opencode'; + if (providerId !== 'opencode' || member.model?.trim()) { + return member; + } + return { + ...member, + model: normalizedDefaultModel, + }; + }), + }; + } + private async resolveOpenCodeMemberWorkspacesForRuntime(params: { teamName: string; baseCwd: string; @@ -22228,46 +22447,47 @@ export class TeamProvisioningService { } await ensureCwdExists(request.cwd); - const effectiveMembers = await this.resolveOpenCodeMemberWorkspacesForRuntime({ - teamName: request.teamName, - baseCwd: request.cwd, - leadProviderId: request.providerId, - members: buildEffectiveTeamMemberSpecs(request.members, { - providerId: request.providerId, - model: request.model, - effort: request.effort, - }), + const materialized = await this.materializeOpenCodeRuntimeAdapterDefaults({ + request, + members: request.members, }); - const teamDir = path.join(getTeamsBasePath(), request.teamName); - const tasksDir = path.join(getTasksBasePath(), request.teamName); + const launchRequest = materialized.request; + const effectiveMembers = await this.resolveOpenCodeMemberWorkspacesForRuntime({ + teamName: launchRequest.teamName, + baseCwd: launchRequest.cwd, + leadProviderId: launchRequest.providerId, + members: materialized.members, + }); + const teamDir = path.join(getTeamsBasePath(), launchRequest.teamName); + const tasksDir = path.join(getTasksBasePath(), launchRequest.teamName); await fs.promises.mkdir(teamDir, { recursive: true }); await fs.promises.mkdir(tasksDir, { recursive: true }); - await this.teamMetaStore.writeMeta(request.teamName, { - displayName: request.displayName, - description: request.description, - color: request.color, - cwd: request.cwd, - prompt: request.prompt, - providerId: request.providerId, - providerBackendId: request.providerBackendId, - model: request.model, - effort: request.effort, - skipPermissions: request.skipPermissions, - worktree: request.worktree, - extraCliArgs: request.extraCliArgs, - limitContext: request.limitContext, + await this.teamMetaStore.writeMeta(launchRequest.teamName, { + displayName: launchRequest.displayName, + description: launchRequest.description, + color: launchRequest.color, + cwd: launchRequest.cwd, + prompt: launchRequest.prompt, + providerId: launchRequest.providerId, + providerBackendId: launchRequest.providerBackendId, + model: launchRequest.model, + effort: launchRequest.effort, + skipPermissions: launchRequest.skipPermissions, + worktree: launchRequest.worktree, + extraCliArgs: launchRequest.extraCliArgs, + limitContext: launchRequest.limitContext, createdAt: Date.now(), }); const membersToWrite = this.buildMembersMetaWritePayload(effectiveMembers); - await this.membersMetaStore.writeMembers(request.teamName, membersToWrite, { - providerBackendId: request.providerBackendId, + await this.membersMetaStore.writeMembers(launchRequest.teamName, membersToWrite, { + providerBackendId: launchRequest.providerBackendId, }); - await this.writeOpenCodeTeamConfig(request, effectiveMembers); + await this.writeOpenCodeTeamConfig(launchRequest, effectiveMembers); return this.runOpenCodeTeamRuntimeAdapterLaunch({ - request, + request: launchRequest, members: effectiveMembers, - prompt: request.prompt?.trim() ?? '', + prompt: launchRequest.prompt?.trim() ?? '', sourceWarning: undefined, onProgress, }); @@ -22291,17 +22511,18 @@ export class TeamProvisioningService { configRaw, request.providerId ); - const effectiveMembers = await this.resolveOpenCodeMemberWorkspacesForRuntime({ - teamName: request.teamName, - baseCwd: request.cwd, - leadProviderId: request.providerId, - members: buildEffectiveTeamMemberSpecs(members, { - providerId: request.providerId, - model: request.model, - effort: request.effort, - }), + const materialized = await this.materializeOpenCodeRuntimeAdapterDefaults({ + request, + members, }); - await this.updateConfigProjectPath(request.teamName, request.cwd); + const launchRequest = materialized.request; + const effectiveMembers = await this.resolveOpenCodeMemberWorkspacesForRuntime({ + teamName: launchRequest.teamName, + baseCwd: launchRequest.cwd, + leadProviderId: launchRequest.providerId, + members: materialized.members, + }); + await this.updateConfigProjectPath(launchRequest.teamName, launchRequest.cwd); let existingTasks: TeamTask[] = []; try { @@ -22312,14 +22533,14 @@ export class TeamProvisioningService { ); } const prompt = buildDeterministicLaunchHydrationPrompt( - request, + launchRequest, effectiveMembers, existingTasks, false ); return this.runOpenCodeTeamRuntimeAdapterLaunch({ - request, + request: launchRequest, members: effectiveMembers, prompt, sourceWarning: warning, @@ -22695,7 +22916,7 @@ export class TeamProvisioningService { name: member.name, providerId: 'opencode', providerBackendId: undefined, - model: member.model?.trim() || undefined, + model: member.model?.trim() || evidence?.model?.trim() || undefined, effort: member.effort, cwd: member.cwd?.trim() || undefined, laneId: 'primary', @@ -26842,9 +27063,10 @@ export class TeamProvisioningService { } const activeRunMember = this.findEffectiveRunMember(run, memberName); const activeRunModel = activeRunMember?.model?.trim(); + const evidenceModel = currentRuntimeAdapterRun?.members?.[memberName]?.model?.trim(); const activeRunProviderId = normalizeOptionalTeamProviderId(activeRunMember?.providerId) ?? - inferTeamProviderIdFromModel(activeRunModel); + inferTeamProviderIdFromModel(activeRunModel ?? evidenceModel); const effectiveProviderId = activeRunProviderId ?? persistedMember.providerId; const currentRuntimeAdapterEvidence = currentRuntimeAdapterRun?.members?.[memberName]; upsertMetadata(memberName, { @@ -26865,9 +27087,11 @@ export class TeamProvisioningService { persistedMember.lastRuntimeAliveAt, ...(activeRunModel ? { model: activeRunModel } - : persistedMember.model?.trim() - ? { model: persistedMember.model.trim() } - : {}), + : evidenceModel + ? { model: evidenceModel } + : persistedMember.model?.trim() + ? { model: persistedMember.model.trim() } + : {}), ...(typeof currentRuntimeAdapterEvidence?.runtimePid === 'number' && currentRuntimeAdapterEvidence.runtimePid > 0 ? { metricsPid: currentRuntimeAdapterEvidence.runtimePid } diff --git a/src/main/services/team/runtime/TeamRuntimeAdapter.ts b/src/main/services/team/runtime/TeamRuntimeAdapter.ts index d98a8cf6..808e7ce8 100644 --- a/src/main/services/team/runtime/TeamRuntimeAdapter.ts +++ b/src/main/services/team/runtime/TeamRuntimeAdapter.ts @@ -74,6 +74,7 @@ export type TeamRuntimePrepareResult = TeamRuntimePrepareSuccess | TeamRuntimePr export interface TeamRuntimeMemberLaunchEvidence { memberName: string; providerId: TeamRuntimeProviderId; + model?: string; launchState: MemberLaunchState; agentToolAccepted: boolean; runtimeAlive: boolean; diff --git a/test/main/services/team/TeamProvisioningServicePrepare.test.ts b/test/main/services/team/TeamProvisioningServicePrepare.test.ts index 45404b49..f340c039 100644 --- a/test/main/services/team/TeamProvisioningServicePrepare.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrepare.test.ts @@ -2821,6 +2821,332 @@ describe('TeamProvisioningService prepare/auth behavior', () => { vi.mocked(console.warn).mockClear(); }); + it('resolves the OpenCode default model when CLI JSON is surrounded by noisy structured logs', async () => { + const modelList = { + schemaVersion: 1, + providers: { + opencode: { + defaultModel: 'opencode/big-pickle', + models: [ + { + id: 'opencode/big-pickle', + label: 'Big Pickle', + description: 'Default OpenCode free model', + }, + { + id: 'opencode/minimax-m2.5-free', + label: 'MiniMax M2.5 Free', + description: 'Free OpenCode model', + }, + ], + }, + }, + }; + execCliMock.mockResolvedValue({ + stdout: [ + 'debug {"event":"starting model list"}', + JSON.stringify(modelList), + 'debug {"providers":"log-only"}', + ].join('\n'), + stderr: '', + exitCode: 0, + }); + + const svc = new TeamProvisioningService(); + const serviceWithDefaultModelResolver = svc as unknown as { + resolveProviderDefaultModel: ( + claudePath: string, + cwd: string, + providerId: string, + env: NodeJS.ProcessEnv, + providerArgs: string[], + limitContext: boolean + ) => Promise; + }; + await expect( + serviceWithDefaultModelResolver.resolveProviderDefaultModel( + '/fake/claude', + tempRoot, + 'opencode', + { PATH: '/usr/bin' }, + [], + false + ) + ).resolves.toBe('opencode/big-pickle'); + }); + + it('falls back to OpenCode runtime status when the default model list is truncated', async () => { + execCliMock.mockImplementation(async (_binaryPath: string | null, args: string[]) => { + if (args[0] === 'model' && args[1] === 'list' && args.includes('opencode')) { + return { + stdout: [ + '{', + ' "schemaVersion": 1,', + ' "providers": {', + ' "opencode": {', + ' "defaultModel": "opencode/big-pickle",', + ' "models": [', + ' {"id":"opencode/big-pickle","label":"Big Pickle","description":"Free"}', + ].join('\n'), + stderr: '', + exitCode: 0, + }; + } + if (args[0] === 'runtime' && args[1] === 'status' && args.includes('opencode')) { + return { + stdout: JSON.stringify({ + providers: { + opencode: { + providerId: 'opencode', + modelCatalog: { + schemaVersion: 1, + providerId: 'opencode', + source: 'app-server', + status: 'ready', + fetchedAt: new Date(0).toISOString(), + staleAt: new Date(60_000).toISOString(), + defaultModelId: 'opencode/big-pickle', + defaultLaunchModel: 'opencode/big-pickle', + models: [ + { + id: 'opencode/big-pickle', + launchModel: 'opencode/big-pickle', + displayName: 'Big Pickle', + hidden: false, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + inputModalities: ['text'], + supportsPersonality: true, + isDefault: true, + upgrade: false, + source: 'app-server', + badgeLabel: 'Free', + statusMessage: null, + metadata: { free: true }, + }, + ], + diagnostics: { + configReadState: 'ready', + appServerState: 'healthy', + }, + }, + }, + }, + }), + stderr: '', + exitCode: 0, + }; + } + return defaultExecCliMockImplementation(_binaryPath, args); + }); + + const svc = new TeamProvisioningService(); + const serviceWithDefaultModelResolver = svc as unknown as { + resolveProviderDefaultModel: ( + claudePath: string, + cwd: string, + providerId: string, + env: NodeJS.ProcessEnv, + providerArgs: string[], + limitContext: boolean + ) => Promise; + }; + + await expect( + serviceWithDefaultModelResolver.resolveProviderDefaultModel( + '/fake/claude', + tempRoot, + 'opencode', + { PATH: '/usr/bin' }, + [], + false + ) + ).resolves.toBe('opencode/big-pickle'); + }); + + it('falls back to OpenCode runtime status when the default model list command fails', async () => { + execCliMock.mockImplementation(async (_binaryPath: string | null, args: string[]) => { + if (args[0] === 'model' && args[1] === 'list' && args.includes('opencode')) { + const error = new Error('stdout maxBuffer exceeded'); + Object.assign(error, { stdout: '{"providers":', stderr: '' }); + throw error; + } + if (args[0] === 'runtime' && args[1] === 'status' && args.includes('opencode')) { + return { + stdout: JSON.stringify({ + providers: { + opencode: { + providerId: 'opencode', + modelCatalog: { + schemaVersion: 1, + providerId: 'opencode', + source: 'app-server', + status: 'ready', + fetchedAt: new Date(0).toISOString(), + staleAt: new Date(60_000).toISOString(), + defaultModelId: 'opencode/big-pickle', + defaultLaunchModel: 'opencode/big-pickle', + models: [], + diagnostics: { + configReadState: 'ready', + appServerState: 'healthy', + }, + }, + }, + }, + }), + stderr: '', + exitCode: 0, + }; + } + return defaultExecCliMockImplementation(_binaryPath, args); + }); + + const svc = new TeamProvisioningService(); + const serviceWithDefaultModelResolver = svc as unknown as { + resolveProviderDefaultModel: ( + claudePath: string, + cwd: string, + providerId: string, + env: NodeJS.ProcessEnv, + providerArgs: string[], + limitContext: boolean + ) => Promise; + }; + + await expect( + serviceWithDefaultModelResolver.resolveProviderDefaultModel( + '/fake/claude', + tempRoot, + 'opencode', + { PATH: '/usr/bin' }, + [], + false + ) + ).resolves.toBe('opencode/big-pickle'); + }); + + it('materializes pure OpenCode runtime adapter Default selections before launch', async () => { + execCliMock.mockImplementation(async (_binaryPath: string | null, args: string[]) => { + if (args[0] === 'model' && args[1] === 'list' && args.includes('opencode')) { + return { + stdout: JSON.stringify({ + schemaVersion: 1, + providers: { + opencode: { + defaultModel: 'opencode/big-pickle', + models: [ + { + id: 'opencode/big-pickle', + label: 'Big Pickle', + description: 'Free OpenCode model', + }, + ], + }, + }, + }), + stderr: '', + exitCode: 0, + }; + } + return defaultExecCliMockImplementation(_binaryPath, args); + }); + + const svc = new TeamProvisioningService(); + const serviceWithMaterializer = svc as unknown as { + materializeOpenCodeRuntimeAdapterDefaults: (params: { + request: { + teamName: string; + cwd: string; + providerId: 'opencode'; + skipPermissions: boolean; + model?: string; + }; + members: Array<{ + name: string; + providerId?: 'opencode'; + model?: string; + }>; + }) => Promise<{ + request: { model?: string }; + members: Array<{ name: string; model?: string }>; + }>; + }; + + const result = await serviceWithMaterializer.materializeOpenCodeRuntimeAdapterDefaults({ + request: { + teamName: 'default-opencode-team', + cwd: tempRoot, + providerId: 'opencode', + skipPermissions: true, + }, + members: [ + { + name: 'atlas', + providerId: 'opencode', + }, + ], + }); + + expect(result.request.model).toBe('opencode/big-pickle'); + expect(result.members).toEqual([ + { + name: 'atlas', + providerId: 'opencode', + model: 'opencode/big-pickle', + effort: undefined, + }, + ]); + expect(execCliMock).toHaveBeenCalledWith( + '/fake/claude', + ['model', 'list', '--json', '--provider', 'opencode'], + expect.objectContaining({ cwd: tempRoot }) + ); + }); + + it('materializes pure OpenCode runtime adapter root model from a saved teammate model', async () => { + const svc = new TeamProvisioningService(); + const serviceWithMaterializer = svc as unknown as { + materializeOpenCodeRuntimeAdapterDefaults: (params: { + request: { + teamName: string; + cwd: string; + providerId: 'opencode'; + skipPermissions: boolean; + model?: string; + }; + members: Array<{ + name: string; + providerId?: 'opencode'; + model?: string; + }>; + }) => Promise<{ + request: { model?: string }; + members: Array<{ name: string; model?: string }>; + }>; + }; + + const result = await serviceWithMaterializer.materializeOpenCodeRuntimeAdapterDefaults({ + request: { + teamName: 'saved-opencode-team', + cwd: tempRoot, + providerId: 'opencode', + skipPermissions: true, + }, + members: [ + { + name: 'atlas', + providerId: 'opencode', + model: 'opencode/big-pickle', + }, + ], + }); + + expect(result.request.model).toBe('opencode/big-pickle'); + expect(result.members[0]?.model).toBe('opencode/big-pickle'); + expect(execCliMock).not.toHaveBeenCalled(); + }); + it('maps ANTHROPIC_AUTH_TOKEN into ANTHROPIC_API_KEY for headless preflight', async () => { const svc = new TeamProvisioningService(); vi.mocked(resolveInteractiveShellEnv).mockResolvedValue({ From 824f420cf54653964baf0ac17fab2d4736ee93d9 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 21 May 2026 00:23:15 +0300 Subject: [PATCH 17/44] feat(landing): refresh hero video and provider support --- landing/README.md | 2 + landing/assets/styles/cyberpunk-hero.scss | 263 ++++---- landing/components/common/ThemeToggle.vue | 2 +- .../components/hero/CyberHeroFeatureStrip.vue | 21 +- .../hero/CyberHeroMontereyBackground.vue | 150 ++++- landing/components/hero/CyberHeroRobot.vue | 4 +- landing/components/hero/CyberHeroScene.vue | 7 +- .../components/hero/CyberHeroVideoFrame.vue | 11 +- landing/components/hero/CyberProviderIcon.vue | 52 ++ landing/components/layout/AppFooter.vue | 3 +- landing/components/layout/AppHeader.vue | 27 +- .../components/sections/ComparisonSection.vue | 377 +++++++---- .../components/sections/DownloadSection.vue | 3 +- landing/components/sections/HeroSection.vue | 59 +- .../sections/ScreenshotsSection.vue | 18 +- landing/components/ui/HeroDemoVideo.vue | 617 +++++++----------- landing/data/heroScene.ts | 111 ++++ landing/data/screenshots.ts | 25 +- landing/locales/ru.json | 2 +- landing/nuxt.config.ts | 8 +- landing/package.json | 1 + landing/plugins/init-theme-locale.client.ts | 16 +- landing/plugins/vuetify.ts | 9 +- 23 files changed, 1101 insertions(+), 687 deletions(-) create mode 100644 landing/components/hero/CyberProviderIcon.vue diff --git a/landing/README.md b/landing/README.md index 0dcfcd38..fb679a08 100644 --- a/landing/README.md +++ b/landing/README.md @@ -18,3 +18,5 @@ pnpm preview - Static-first (SSG) by design. - Locale auto-detection: cookie -> browser settings -> fallback `en`. - Theme auto-detection: localStorage -> system preference -> fallback `light`. +- Hero video uses the Mux Player embed. Set `NUXT_PUBLIC_MUX_PLAYBACK_ID` to override the default playback id without changing the code. +- Hero background can use a separate Mux asset via `NUXT_PUBLIC_MUX_BACKGROUND_PLAYBACK_ID`; otherwise it reuses `NUXT_PUBLIC_MUX_PLAYBACK_ID`. diff --git a/landing/assets/styles/cyberpunk-hero.scss b/landing/assets/styles/cyberpunk-hero.scss index 5929e57f..e92ae270 100644 --- a/landing/assets/styles/cyberpunk-hero.scss +++ b/landing/assets/styles/cyberpunk-hero.scss @@ -34,8 +34,13 @@ --cyber-monterey-after-bg: linear-gradient(180deg, rgba(2, 5, 13, 0.92) 0%, rgba(2, 5, 13, 0.62) 15%, rgba(2, 5, 13, 0.08) 44%, rgba(2, 5, 13, 0.68) 100%), radial-gradient(circle at 64% 42%, transparent 0 26%, rgba(2, 5, 13, 0.24) 70%, rgba(2, 5, 13, 0.54) 100%); - --cyber-monterey-canvas-opacity: 0.42; - --cyber-monterey-canvas-filter: blur(4px) saturate(0.78) brightness(0.62) contrast(1.08); + --cyber-monterey-video-opacity: 1; + --cyber-monterey-video-filter: blur(0.8px) saturate(1.12) contrast(1.04); + --cyber-monterey-video-blend: normal; + --cyber-monterey-video-position: 74% 50%; + --cyber-monterey-video-scale: 1.16; + --cyber-monterey-canvas-opacity: 0.36; + --cyber-monterey-canvas-filter: blur(0.5px) saturate(0.92) brightness(0.78) contrast(1.08); --cyber-monterey-canvas-blend: normal; --cyber-background-bg: radial-gradient(circle at 72% 28%, rgba(0, 234, 255, 0.1), transparent 30%), @@ -116,113 +121,6 @@ --cyber-frame-cut: 18px; } -.v-theme--light .cyber-hero { - --cyber-bg-0: #f8fcff; - --cyber-bg-1: #eaf7fb; - --cyber-panel-weak: rgba(255, 255, 255, 0.68); - --cyber-panel: rgba(255, 255, 255, 0.78); - --cyber-panel-strong: rgba(255, 255, 255, 0.92); - --cyber-cyan: #008fb3; - --cyber-blue: #2563eb; - --cyber-magenta: #b832d8; - --cyber-violet: #7c3aed; - --cyber-text: #132238; - --cyber-muted: #596b83; - --cyber-border-cyan: rgba(8, 145, 178, 0.34); - --cyber-border-magenta: rgba(184, 50, 216, 0.28); - --cyber-panel-bg: - linear-gradient(135deg, rgba(255, 255, 255, 0.9), rgba(237, 249, 252, 0.68)); - --cyber-panel-shadow: - 0 0 0 1px rgba(8, 145, 178, 0.1) inset, - 0 18px 48px rgba(8, 145, 178, 0.12), - 0 0 22px rgba(184, 50, 216, 0.07); - --cyber-hero-bg: - radial-gradient(circle at 76% 30%, rgba(0, 178, 214, 0.14), transparent 32%), - radial-gradient(circle at 86% 70%, rgba(184, 50, 216, 0.13), transparent 36%), - linear-gradient(180deg, #fbfdff 0%, #edf8fb 56%, #f8fcff 100%); - --cyber-monterey-bg: - radial-gradient(circle at 78% 24%, rgba(113, 185, 255, 0.28), transparent 31%), - radial-gradient(circle at 22% 72%, rgba(221, 170, 255, 0.24), transparent 38%), - radial-gradient(circle at 8% 32%, rgba(101, 218, 255, 0.18), transparent 34%), - linear-gradient(180deg, #f8fcff 0%, #eaf7fb 48%, #fbf7ff 100%); - --cyber-monterey-before-bg: - radial-gradient(circle at 18% 34%, rgba(255, 255, 255, 0.46), rgba(255, 255, 255, 0.14) 34%, transparent 62%), - linear-gradient(90deg, rgba(255, 255, 255, 0.22) 0%, rgba(255, 255, 255, 0.08) 42%, rgba(255, 255, 255, 0.03) 64%, rgba(237, 247, 252, 0.2) 100%); - --cyber-monterey-after-bg: - linear-gradient(180deg, rgba(248, 252, 255, 0.52) 0%, rgba(248, 252, 255, 0.22) 18%, rgba(248, 252, 255, 0.04) 48%, rgba(248, 252, 255, 0.58) 100%), - radial-gradient(circle at 64% 42%, transparent 0 26%, rgba(255, 255, 255, 0.16) 70%, rgba(235, 247, 252, 0.42) 100%); - --cyber-monterey-canvas-opacity: 1; - --cyber-monterey-canvas-filter: blur(1px) saturate(1.34) brightness(1.12) contrast(1.16); - --cyber-monterey-canvas-blend: multiply; - --cyber-background-bg: - radial-gradient(circle at 72% 28%, rgba(0, 178, 214, 0.12), transparent 31%), - radial-gradient(circle at 88% 62%, rgba(184, 50, 216, 0.1), transparent 33%), - linear-gradient(90deg, transparent 0 64px, rgba(8, 145, 178, 0.06) 65px 66px, transparent 67px 160px), - linear-gradient(180deg, rgba(255, 255, 255, 0.5) 0%, rgba(255, 255, 255, 0.08) 38%, rgba(237, 247, 252, 0.64) 100%); - --cyber-background-opacity: 0.5; - --cyber-background-before-bg: - linear-gradient(90deg, transparent 0 8%, rgba(8, 145, 178, 0.16) 8.1% 8.22%, transparent 8.34% 18%, rgba(184, 50, 216, 0.12) 18.1% 18.22%, transparent 18.34% 31%, rgba(8, 145, 178, 0.13) 31.1% 31.24%, transparent 31.36% 44%, rgba(37, 99, 235, 0.12) 44.1% 44.2%, transparent 44.34% 62%, rgba(184, 50, 216, 0.1) 62.1% 62.22%, transparent 62.34% 78%, rgba(8, 145, 178, 0.12) 78.1% 78.22%, transparent 78.34%), - repeating-linear-gradient(90deg, transparent 0 78px, rgba(8, 145, 178, 0.06) 80px 82px, transparent 84px 116px), - linear-gradient(to top, rgba(8, 145, 178, 0.12) 0%, rgba(8, 145, 178, 0.07) 13%, transparent 31%), - linear-gradient(to top, rgba(184, 50, 216, 0.1) 0%, rgba(184, 50, 216, 0.05) 16%, transparent 38%), - linear-gradient(to top, rgba(37, 99, 235, 0.09) 0%, rgba(37, 99, 235, 0.04) 20%, transparent 48%); - --cyber-background-before-opacity: 0.58; - --cyber-background-before-blend: multiply; - --cyber-background-after-bg: - repeating-linear-gradient(90deg, transparent 0 34px, rgba(8, 145, 178, 0.1) 35px 36px, transparent 37px 110px), - repeating-linear-gradient(180deg, transparent 0 28px, rgba(184, 50, 216, 0.08) 29px 30px, transparent 31px 78px), - linear-gradient(90deg, transparent, rgba(8, 145, 178, 0.07), transparent); - --cyber-background-after-opacity: 0.18; - --cyber-background-after-blend: multiply; - --cyber-wash-bg: - radial-gradient(circle at 18% 44%, rgba(255, 255, 255, 0.28), rgba(255, 255, 255, 0.12) 36%, transparent 60%), - linear-gradient(90deg, rgba(255, 255, 255, 0.18) 0%, rgba(237, 248, 252, 0.1) 36%, rgba(255, 255, 255, 0.03) 68%, rgba(251, 247, 255, 0.16) 100%), - linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(248, 252, 255, 0.04) 58%, rgba(248, 252, 255, 0.72)); - --cyber-gridlines-bg: - linear-gradient(rgba(8, 145, 178, 0.055) 1px, transparent 1px), - linear-gradient(90deg, rgba(8, 145, 178, 0.045) 1px, transparent 1px); - --cyber-gridlines-opacity: 0.2; - --cyber-scanlines-bg: repeating-linear-gradient( - to bottom, - rgba(8, 35, 50, 0.035) 0, - rgba(8, 35, 50, 0.035) 1px, - transparent 1px, - transparent 4px - ); - --cyber-scanlines-opacity: 0.08; - --cyber-copy-aura: radial-gradient(circle at 28% 38%, rgba(255, 255, 255, 0.92), rgba(255, 255, 255, 0.38) 58%, transparent 78%); - --cyber-title-color: rgba(19, 34, 56, 0.98); - --cyber-description-color: rgba(50, 65, 88, 0.82); - --cyber-action-primary-color: #061722; - --cyber-action-secondary-bg: rgba(255, 255, 255, 0.62); - --cyber-action-secondary-hover-bg: rgba(8, 145, 178, 0.08); - --cyber-release-color: rgba(50, 65, 88, 0.68); - --cyber-scene-floor-bg: - radial-gradient(ellipse at 58% 84%, rgba(184, 50, 216, 0.16), transparent 18%), - radial-gradient(ellipse at 56% 84%, rgba(8, 145, 178, 0.14), transparent 32%), - repeating-radial-gradient(ellipse at 58% 84%, rgba(8, 145, 178, 0.08) 0 1px, transparent 1px 20px); - --cyber-scene-floor-opacity: 0.38; - --cyber-scene-foreground-bg: - linear-gradient(90deg, transparent 0 4%, rgba(8, 145, 178, 0.07) 4.1%, transparent 4.4%), - linear-gradient(180deg, transparent 0 88%, rgba(184, 50, 216, 0.06)); - --cyber-scene-foreground-opacity: 0.48; - --cyber-video-frame-bg: rgba(255, 255, 255, 0.7); - --cyber-video-content-bg: rgba(8, 20, 34, 0.9); - --cyber-card-text: rgba(19, 34, 56, 0.88); - --cyber-card-muted: rgba(67, 82, 105, 0.76); - --cyber-card-subtle: rgba(67, 82, 105, 0.64); - --cyber-card-inset: rgba(255, 255, 255, 0.62); - --cyber-feature-shell-bg: transparent; - --cyber-feature-rail-bg: transparent; - --cyber-feature-rail-shadow: - 0 0 0 1px rgba(8, 145, 178, 0.12) inset, - 0 -1px 0 rgba(8, 145, 178, 0.18), - 0 1px 0 rgba(8, 145, 178, 0.16); - --cyber-feature-divider: rgba(8, 145, 178, 0.18); - --cyber-feature-title: rgba(19, 34, 56, 0.94); - --cyber-feature-text: rgba(67, 82, 105, 0.72); -} - .cyber-panel { position: relative; border: 1px solid var(--cyber-border-cyan); @@ -294,6 +192,65 @@ background: var(--cyber-monterey-after-bg); } +.cyber-hero__background, +.cyber-hero__wash, +.cyber-hero__monterey::before, +.cyber-hero__monterey::after { + display: none; +} + +.cyber-hero__monterey-video { + position: absolute; + inset: 0; + z-index: 0; + width: 100%; + height: 100%; + min-width: 100%; + min-height: 100%; + overflow: hidden; + opacity: var(--cyber-monterey-video-opacity); + filter: var(--cyber-monterey-video-filter); + mix-blend-mode: var(--cyber-monterey-video-blend); + background: + var(--cyber-monterey-video-poster) var(--cyber-monterey-video-position) / cover no-repeat, + var(--cyber-monterey-bg); + transform: scale(var(--cyber-monterey-video-scale)); + will-change: opacity, transform; +} + +.cyber-hero__monterey-video::after { + display: none; +} + +.cyber-hero__monterey-video-player { + position: absolute; + inset: 0; + z-index: 1; + display: block; + width: 100%; + height: 100%; + min-width: 100%; + min-height: 100%; + opacity: 0; + background: transparent; + object-fit: cover; + object-position: var(--cyber-monterey-video-position); + --media-object-fit: cover; + --media-object-position: var(--cyber-monterey-video-position); + transition: opacity 0.45s ease; +} + +.cyber-hero__monterey-video-player::part(video) { + width: 100%; + height: 100%; + object-fit: cover; + object-position: var(--cyber-monterey-video-position); +} + +.cyber-hero__monterey-video-player--ready { + opacity: 1; +} + .cyber-hero__monterey-canvas { position: absolute; inset: 0; @@ -490,12 +447,72 @@ .cyber-hero__description { max-width: 560px; - margin: 0 0 30px; + margin: 0 0 20px; color: var(--cyber-description-color); font-size: clamp(1rem, 1.08vw, 1.22rem); line-height: 1.7; } +.cyber-hero__providers { + width: min(680px, 100%); + margin: 0 0 20px; +} + +.cyber-hero__provider-list { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 14px 26px; +} + +.cyber-hero__provider { + --provider-accent: var(--cyber-cyan); + + display: inline-flex; + align-items: center; + min-width: 0; + gap: 9px; + color: var(--cyber-text); + text-shadow: + 0 0 18px color-mix(in srgb, var(--provider-accent) 26%, transparent), + 0 0 1px rgba(255, 255, 255, 0.3); +} + +.cyber-hero__provider--cyan { + --provider-accent: var(--cyber-cyan); +} + +.cyber-hero__provider--amber { + --provider-accent: #ffcf5a; +} + +.cyber-hero__provider--magenta { + --provider-accent: var(--cyber-magenta); +} + +.cyber-hero__provider-icon { + display: grid; + flex: 0 0 auto; + width: 30px; + height: 30px; + place-items: center; + color: var(--provider-accent); + filter: drop-shadow(0 0 10px color-mix(in srgb, var(--provider-accent) 44%, transparent)); +} + +.cyber-hero__provider-icon svg { + width: 24px; + height: 24px; +} + +.cyber-hero__provider-name { + overflow-wrap: anywhere; + color: var(--cyber-text); + font-size: 0.98rem; + font-weight: 900; + line-height: 1.1; +} + .cyber-hero__actions { display: flex; flex-wrap: wrap; @@ -1630,6 +1647,32 @@ line-height: 1.62; } + .cyber-hero__providers { + margin-bottom: 22px; + } + + .cyber-hero__provider-list { + gap: 12px 18px; + } + + .cyber-hero__provider { + gap: 7px; + } + + .cyber-hero__provider-icon { + width: 24px; + height: 24px; + } + + .cyber-hero__provider-icon svg { + width: 20px; + height: 20px; + } + + .cyber-hero__provider-name { + font-size: 0.82rem; + } + .cyber-hero__actions { display: grid; grid-template-columns: 1fr; diff --git a/landing/components/common/ThemeToggle.vue b/landing/components/common/ThemeToggle.vue index 1bc10b99..bdde56ce 100644 --- a/landing/components/common/ThemeToggle.vue +++ b/landing/components/common/ThemeToggle.vue @@ -33,7 +33,7 @@ const onToggle = () => { :icon="mdiWeatherSunny" variant="text" size="small" - aria-label="Toggle theme" + :aria-label="t('theme.light')" /> diff --git a/landing/components/hero/CyberHeroFeatureStrip.vue b/landing/components/hero/CyberHeroFeatureStrip.vue index 33e75cd5..bf68c512 100644 --- a/landing/components/hero/CyberHeroFeatureStrip.vue +++ b/landing/components/hero/CyberHeroFeatureStrip.vue @@ -8,8 +8,8 @@ import { } from "@mdi/js"; import { heroCollaborationFeature, - heroFeatureRail, - heroReviewerFeatureCard, + getLocalizedHeroFeatureRail, + getLocalizedHeroReviewerFeatureCard, type HeroMessage, type HeroMessagePhase, } from "~/data/heroScene"; @@ -20,6 +20,11 @@ const props = defineProps<{ reducedMotion?: boolean; }>(); +const { locale } = useI18n(); +const localizedHeroFeatureRail = computed(() => getLocalizedHeroFeatureRail(locale.value)); +const localizedHeroReviewerFeatureCard = computed(() => getLocalizedHeroReviewerFeatureCard(locale.value)); +const statusLabel = computed(() => locale.value === "ru" ? "Статус:" : "Status:"); + const icons = [ mdiRobotOutline, mdiViewDashboardOutline, @@ -76,18 +81,18 @@ const reviewerBubbleText = computed(() => {
-
{{ heroReviewerFeatureCard.label }}
+
{{ localizedHeroReviewerFeatureCard.label }}
    -
  • {{ task }}
  • +
  • {{ task }}
- Status: - {{ heroReviewerFeatureCard.status }} + {{ statusLabel }} + {{ localizedHeroReviewerFeatureCard.status }}
{
diff --git a/landing/components/hero/CyberHeroMontereyBackground.vue b/landing/components/hero/CyberHeroMontereyBackground.vue index 6e2165fa..1928d8ea 100644 --- a/landing/components/hero/CyberHeroMontereyBackground.vue +++ b/landing/components/hero/CyberHeroMontereyBackground.vue @@ -3,6 +3,22 @@ import type { NeatConfig, NeatController } from "@firecms/neat"; const canvasRef = ref(null); const isLive = ref(false); +const shouldMountBackgroundVideo = ref(false); +const isBackgroundVideoReady = ref(false); +const hasBackgroundVideoError = ref(false); +const config = useRuntimeConfig(); +const backgroundPlaybackId = computed(() => ( + String(config.public.muxBackgroundPlaybackId || config.public.muxPlaybackId || "").trim() +)); +const backgroundPosterUrl = computed(() => { + if (!backgroundPlaybackId.value) return ""; + + const url = new URL(`https://image.mux.com/${encodeURIComponent(backgroundPlaybackId.value)}/thumbnail.jpg`); + url.searchParams.set("time", "0.1"); + url.searchParams.set("width", "1600"); + url.searchParams.set("fit_mode", "preserve"); + return url.toString(); +}); let gradient: NeatController | null = null; let heroObserver: IntersectionObserver | null = null; @@ -12,6 +28,8 @@ let isVisible = false; let isInitializing = false; let initToken = 0; let revealTimer: number | null = null; +let backgroundVideoTimer: number | null = null; +let backgroundVideoIdleId: number | null = null; const montereyConfig: NeatConfig = { colors: [ @@ -35,7 +53,7 @@ const montereyConfig: NeatConfig = { wireframe: false, colorBlending: 9, backgroundColor: "#030012", - backgroundAlpha: 1, + backgroundAlpha: 0, grainScale: 6, grainSparsity: 0, grainIntensity: 0.1, @@ -137,28 +155,120 @@ function syncGradient() { destroyGradient(); } +function clearBackgroundVideoSchedule() { + if (backgroundVideoTimer !== null) { + window.clearTimeout(backgroundVideoTimer); + backgroundVideoTimer = null; + } + + if (backgroundVideoIdleId !== null) { + const idleWindow = window as Window & { cancelIdleCallback?: (handle: number) => void }; + idleWindow.cancelIdleCallback?.(backgroundVideoIdleId); + backgroundVideoIdleId = null; + } +} + +function shouldUseBackgroundVideo() { + return Boolean( + backgroundPlaybackId.value && + isVisible && + !motionQuery?.matches && + !hasBackgroundVideoError.value, + ); +} + +async function mountBackgroundVideo() { + clearBackgroundVideoSchedule(); + if (shouldMountBackgroundVideo.value || !shouldUseBackgroundVideo()) return; + + try { + await import("@mux/mux-video"); + if (shouldUseBackgroundVideo()) { + shouldMountBackgroundVideo.value = true; + } + } catch (error) { + console.warn("Mux hero background video is unavailable", error); + hasBackgroundVideoError.value = true; + } +} + +function scheduleBackgroundVideo() { + clearBackgroundVideoSchedule(); + if (shouldMountBackgroundVideo.value || !shouldUseBackgroundVideo()) return; + + const idleWindow = window as Window & { + requestIdleCallback?: (callback: () => void, options?: { timeout: number }) => number; + }; + + if (idleWindow.requestIdleCallback) { + backgroundVideoIdleId = idleWindow.requestIdleCallback(() => { + backgroundVideoIdleId = null; + void mountBackgroundVideo(); + }, { timeout: 1600 }); + return; + } + + backgroundVideoTimer = window.setTimeout(() => { + backgroundVideoTimer = null; + void mountBackgroundVideo(); + }, 450); +} + +function stopBackgroundVideo() { + clearBackgroundVideoSchedule(); + shouldMountBackgroundVideo.value = false; + isBackgroundVideoReady.value = false; +} + +function syncBackgroundVideo() { + if (shouldUseBackgroundVideo()) { + scheduleBackgroundVideo(); + return; + } + + stopBackgroundVideo(); +} + +function markBackgroundVideoReady() { + if (!shouldUseBackgroundVideo()) return; + isBackgroundVideoReady.value = true; +} + +function markBackgroundVideoError() { + hasBackgroundVideoError.value = true; + stopBackgroundVideo(); +} + +function syncMotionState() { + syncGradient(); + syncBackgroundVideo(); +} + onMounted(() => { motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)"); mobileQuery = window.matchMedia("(max-width: 700px)"); - motionQuery.addEventListener("change", syncGradient); + motionQuery.addEventListener("change", syncMotionState); mobileQuery.addEventListener("change", syncGradient); heroObserver = new IntersectionObserver( ([entry]) => { isVisible = Boolean(entry?.isIntersecting); syncGradient(); + syncBackgroundVideo(); }, { rootMargin: "160px 0px", threshold: 0.01 }, ); const target = canvasRef.value?.closest(".cyber-hero"); if (target) heroObserver.observe(target); + syncBackgroundVideo(); }); onBeforeUnmount(() => { heroObserver?.disconnect(); - motionQuery?.removeEventListener("change", syncGradient); + motionQuery?.removeEventListener("change", syncMotionState); mobileQuery?.removeEventListener("change", syncGradient); + stopBackgroundVideo(); destroyGradient(); }); @@ -169,6 +279,40 @@ onBeforeUnmount(() => { :class="{ 'cyber-hero__monterey--live': isLive }" aria-hidden="true" > +
+ + + +
diff --git a/landing/components/hero/CyberHeroRobot.vue b/landing/components/hero/CyberHeroRobot.vue index a19f03ad..d6547404 100644 --- a/landing/components/hero/CyberHeroRobot.vue +++ b/landing/components/hero/CyberHeroRobot.vue @@ -7,10 +7,12 @@ const props = defineProps<{ activeReceiver?: HeroAgentRole | "video" | null; }>(); +const { locale } = useI18n(); const isSender = computed(() => props.activeSender === props.agent.id); const isReceiver = computed(() => props.activeReceiver === props.agent.id); const imageLoading = computed(() => (props.agent.priority ? "eager" : "lazy")); const imageFetchPriority = computed(() => (props.agent.priority ? "high" : "auto")); +const statusLabel = computed(() => locale.value === "ru" ? "Статус:" : "Status:"); const rootStyle = computed(() => ({ "--agent-x": String(props.agent.desktop.x), @@ -62,7 +64,7 @@ const rootStyle = computed(() => ({
  • {{ task }}
  • - Status: + {{ statusLabel }} {{ agent.status }}
    diff --git a/landing/components/hero/CyberHeroScene.vue b/landing/components/hero/CyberHeroScene.vue index 3f97b340..3df6650a 100644 --- a/landing/components/hero/CyberHeroScene.vue +++ b/landing/components/hero/CyberHeroScene.vue @@ -1,11 +1,14 @@ +