From 8fddf45a1a0ae28934512316411d32dca1819e5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 7 Aug 2026 16:46:45 +0200 Subject: [PATCH 01/13] feat(mobile): show BYOK badges for CLI models --- .../agents/model-selector-badges.test.ts | 70 +++++++++++ .../agents/model-selector-badges.ts | 31 +++++ .../agents/model-selector.mounted.test.tsx | 118 ++++++++++++++++++ .../src/components/agents/model-selector.tsx | 13 +- .../src/lib/free-model-data-disclosure.ts | 2 +- 5 files changed, 224 insertions(+), 10 deletions(-) create mode 100644 apps/mobile/src/components/agents/model-selector-badges.test.ts create mode 100644 apps/mobile/src/components/agents/model-selector-badges.ts create mode 100644 apps/mobile/src/components/agents/model-selector.mounted.test.tsx diff --git a/apps/mobile/src/components/agents/model-selector-badges.test.ts b/apps/mobile/src/components/agents/model-selector-badges.test.ts new file mode 100644 index 0000000000..5a30fe5dc9 --- /dev/null +++ b/apps/mobile/src/components/agents/model-selector-badges.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; + +import { modelSelectorBadges } from './model-selector-badges'; + +function cliCatalogOption(overrides: { hasUserByokAvailable?: boolean } = {}) { + return { id: 'remote-model-0', showGatewayMetadata: false, ...overrides }; +} + +function gatewayOption(overrides: { hasUserByokAvailable?: boolean; isFree?: boolean } = {}) { + return { id: 'anthropic/claude', showGatewayMetadata: true, ...overrides }; +} + +describe('modelSelectorBadges', () => { + it('shows BYOK for a CLI-catalog option with user BYOK available', () => { + const badges = modelSelectorBadges(cliCatalogOption({ hasUserByokAvailable: true })); + expect(badges.byok).toBe(true); + expect(badges.free).toBe(false); + expect(badges.collectsData).toBe(false); + }); + + it('hides BYOK for a CLI-catalog option without the flag', () => { + const badges = modelSelectorBadges(cliCatalogOption()); + expect(badges.byok).toBe(false); + expect(badges.free).toBe(false); + expect(badges.collectsData).toBe(false); + }); + + it('keeps free and data-collection suppressed for CLI-catalog options with the flag', () => { + const badges = modelSelectorBadges({ + id: 'remote-model-0', + showGatewayMetadata: false, + isFree: true, + mayTrainOnYourPrompts: true, + hasUserByokAvailable: true, + }); + expect(badges.byok).toBe(true); + expect(badges.free).toBe(false); + expect(badges.collectsData).toBe(false); + }); + + it('keeps BYOK gating for gateway options with the flag', () => { + const badges = modelSelectorBadges(gatewayOption({ hasUserByokAvailable: true })); + expect(badges.byok).toBe(true); + expect(badges.free).toBe(false); + }); + + it('keeps free gating for gateway options without the BYOK flag', () => { + const badges = modelSelectorBadges(gatewayOption({ isFree: true })); + expect(badges.free).toBe(true); + expect(badges.byok).toBe(false); + }); + + it('shows no badges for an unavailable option without the flag', () => { + const badges = modelSelectorBadges({ + id: 'remote-unavailable-model', + showGatewayMetadata: false, + unavailable: true, + }); + expect(badges.byok).toBe(false); + expect(badges.free).toBe(false); + expect(badges.collectsData).toBe(false); + }); + + it('shows no badges for an undefined option', () => { + const badges = modelSelectorBadges(undefined); + expect(badges.byok).toBe(false); + expect(badges.free).toBe(false); + expect(badges.collectsData).toBe(false); + }); +}); diff --git a/apps/mobile/src/components/agents/model-selector-badges.ts b/apps/mobile/src/components/agents/model-selector-badges.ts new file mode 100644 index 0000000000..a2dd2f85cd --- /dev/null +++ b/apps/mobile/src/components/agents/model-selector-badges.ts @@ -0,0 +1,31 @@ +import { + hasUserByokAvailable, + isFreeModelOption, + mayTrainOnYourPrompts, + type ModelDataDisclosure, +} from '@/lib/free-model-data-disclosure'; + +type ModelBadgeOption = ModelDataDisclosure & { + showGatewayMetadata: boolean; + unavailable?: boolean; +}; + +/** + * Badge predicates for the model selector pill and picker rows. + * Input contract: a post-normalization SessionModelOption (both call sites + * pass one: ModelSelector maps options through toSessionModelOption at + * model-selector.tsx:140, and the picker bridge carries SessionModelOption). + * BYOK is per-user account state, not gateway metadata: the CLI passes the + * backend's hasUserByokAvailable through the v1 wire catalog, so the badge + * must render for CLI-catalog options too. Free/data-collection stay gated + * on showGatewayMetadata because CLI-catalog options do not carry Kilo + * gateway pricing or data-policy semantics. + */ +export function modelSelectorBadges(option: ModelBadgeOption | undefined) { + const showGatewayMetadata = option?.showGatewayMetadata === true; + return { + byok: hasUserByokAvailable(option), + free: showGatewayMetadata && isFreeModelOption(option), + collectsData: showGatewayMetadata && mayTrainOnYourPrompts(option), + }; +} diff --git a/apps/mobile/src/components/agents/model-selector.mounted.test.tsx b/apps/mobile/src/components/agents/model-selector.mounted.test.tsx new file mode 100644 index 0000000000..ff90a5c1a9 --- /dev/null +++ b/apps/mobile/src/components/agents/model-selector.mounted.test.tsx @@ -0,0 +1,118 @@ +/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (same pattern as src/components/consent/consent-card.mounted.test.tsx) */ +import { createElement } from 'react'; +import TestRenderer from 'react-test-renderer'; +import { describe, expect, it, vi } from 'vitest'; + +import { + BYOK_MODEL_LABEL, + FREE_MODEL_DATA_LABEL, + FREE_MODEL_FREE_LABEL, +} from '@/lib/free-model-data-disclosure'; +import { type SessionModelOption } from '@/lib/hooks/use-session-model-options'; + +import { ModelPickerOptionRow } from './model-selector'; + +vi.mock('react-native', () => ({ + Pressable: 'Pressable', + ScrollView: 'ScrollView', + View: 'View', +})); +vi.mock('expo-haptics', () => ({ + selectionAsync: vi.fn(), +})); +vi.mock('expo-router', () => ({ + useRouter: () => ({ push: vi.fn() }), +})); +vi.mock('lucide-react-native', () => ({ + BookOpenCheck: 'BookOpenCheck', + Brain: 'Brain', + Check: 'Check', + ChevronDown: 'ChevronDown', + Star: 'Star', +})); +vi.mock('@/components/ui/skeleton', () => ({ Skeleton: 'Skeleton' })); +vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); +vi.mock('@/lib/hooks/use-theme-colors', () => ({ + useThemeColors: () => ({ + warn: '#9F6612', + mutedForeground: '#6F6A61', + primary: '#4F5A10', + }), +})); +vi.mock('@/lib/hooks/use-available-models', () => ({ + thinkingEffortLabel: (variant: string) => variant, +})); +vi.mock('@/lib/picker-bridge', () => ({ + setModelPickerBridge: vi.fn(), +})); +vi.mock('@/lib/utils', () => ({ + cn: (...parts: unknown[]) => parts.filter(Boolean).join(' '), +})); + +function cliCatalogOption(overrides: Partial = {}): SessionModelOption { + return { + id: 'remote-model-0', + name: 'Minimax M2.5', + displayId: 'minimax/minimax-m2.5', + variants: [], + isPreferred: false, + showGatewayMetadata: false, + ...overrides, + }; +} + +function renderRow(option: SessionModelOption): TestRenderer.ReactTestRenderer { + const ref: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined }; + TestRenderer.act(() => { + ref.current = TestRenderer.create( + createElement(ModelPickerOptionRow, { + option, + selected: false, + selectedVariant: '', + isFavorite: false, + onSelectModel: vi.fn<(option: SessionModelOption) => void>(), + onSelectVariant: vi.fn<(variant: string) => void>(), + onToggleFavorite: vi.fn<(option: SessionModelOption) => void>(), + }) + ); + }); + const renderer = ref.current; + if (!renderer) { + throw new Error('renderer was not created'); + } + return renderer; +} + +function textStrings(root: TestRenderer.ReactTestInstance): string[] { + return root + .findAll( + node => + typeof node.type === 'string' && + (node.type as string) === 'Text' && + typeof node.props.children === 'string' + ) + .map(node => node.props.children as string); +} + +function countWithAccessibilityLabel(root: TestRenderer.ReactTestInstance, label: string): number { + return root.findAll(node => (node.props.accessibilityLabel as string | undefined) === label) + .length; +} + +describe('ModelPickerOptionRow BYOK badge', () => { + it('renders the BYOK badge for a CLI-catalog option with user BYOK available', () => { + const renderer = renderRow(cliCatalogOption({ hasUserByokAvailable: true })); + expect(textStrings(renderer.root)).toContain(BYOK_MODEL_LABEL); + }); + + it('renders no BYOK badge for a CLI-catalog option without the flag', () => { + const renderer = renderRow(cliCatalogOption()); + expect(textStrings(renderer.root)).not.toContain(BYOK_MODEL_LABEL); + }); + + it('renders no Free or data-collection indicators for a CLI-catalog option', () => { + const renderer = renderRow(cliCatalogOption({ isFree: true, mayTrainOnYourPrompts: true })); + expect(textStrings(renderer.root)).not.toContain(FREE_MODEL_FREE_LABEL); + expect(countWithAccessibilityLabel(renderer.root, FREE_MODEL_DATA_LABEL)).toBe(0); + }); +}); diff --git a/apps/mobile/src/components/agents/model-selector.tsx b/apps/mobile/src/components/agents/model-selector.tsx index 294afdea29..14701f7bae 100644 --- a/apps/mobile/src/components/agents/model-selector.tsx +++ b/apps/mobile/src/components/agents/model-selector.tsx @@ -12,9 +12,6 @@ import { FREE_MODEL_DATA_LABEL, FREE_MODEL_FREE_LABEL, getFreeModelDataAccessibilityLabel, - hasUserByokAvailable, - isFreeModelOption, - mayTrainOnYourPrompts, } from '@/lib/free-model-data-disclosure'; import { type ModelOption, thinkingEffortLabel } from '@/lib/hooks/use-available-models'; import { type SessionModelOption } from '@/lib/hooks/use-session-model-options'; @@ -27,6 +24,8 @@ import { } from '@/lib/picker-bridge'; import { cn } from '@/lib/utils'; +import { modelSelectorBadges } from './model-selector-badges'; + type ModelSelectorProps = { value: string; variant: string; @@ -143,10 +142,8 @@ export function ModelSelector({ const providerAware = pickerOptions.some( option => option.modelRef !== undefined || !option.showGatewayMetadata ); - const showGatewayMetadata = selectedModel?.showGatewayMetadata ?? false; const label = selectedModel?.name ?? (!providerAware && value ? value : 'Model'); - const byok = showGatewayMetadata && hasUserByokAvailable(selectedModel); - const collectsData = showGatewayMetadata && mayTrainOnYourPrompts(selectedModel); + const { byok, collectsData } = modelSelectorBadges(selectedModel); const hasVariants = selectedModel ? selectedModel.variants.length > 1 : false; const variantLabel = variant ? thinkingEffortLabel(variant) : ''; const compactVariantLabel = variant ? compactThinkingEffortLabel(variant) : ''; @@ -227,9 +224,7 @@ export function ModelPickerOptionRow({ onToggleFavorite: (option: SessionModelOption) => void; }>) { const colors = useThemeColors(); - const free = option.showGatewayMetadata && isFreeModelOption(option); - const byok = option.showGatewayMetadata && hasUserByokAvailable(option); - const collectsData = option.showGatewayMetadata && mayTrainOnYourPrompts(option); + const { free, byok, collectsData } = modelSelectorBadges(option); const costLabel = modelPickerCostLabel(option); const accessibilityLabel = [ option.provider?.name, diff --git a/apps/mobile/src/lib/free-model-data-disclosure.ts b/apps/mobile/src/lib/free-model-data-disclosure.ts index 850e66dfb7..f33242efa9 100644 --- a/apps/mobile/src/lib/free-model-data-disclosure.ts +++ b/apps/mobile/src/lib/free-model-data-disclosure.ts @@ -2,7 +2,7 @@ export const BYOK_MODEL_LABEL = 'BYOK'; export const FREE_MODEL_DATA_LABEL = 'Data collected'; export const FREE_MODEL_FREE_LABEL = 'Free'; -type ModelDataDisclosure = { +export type ModelDataDisclosure = { id: string; isFree?: boolean; mayTrainOnYourPrompts?: boolean; From 0887634bd7870dbcfb47ae4610805275cd661931 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 7 Aug 2026 16:46:47 +0200 Subject: [PATCH 02/13] test(seed): add BYOK E2E evidence fixtures --- dev/seed/app/byok-e2e-fixture.ts | 196 ++++++++++++++++++ dev/seed/app/usage-evidence.ts | 181 ++++++++++++++++ .../coding-plans/occupied-minimax-byok.ts | 27 +-- dev/seed/lib/byok.ts | 26 +++ 4 files changed, 404 insertions(+), 26 deletions(-) create mode 100644 dev/seed/app/byok-e2e-fixture.ts create mode 100644 dev/seed/app/usage-evidence.ts create mode 100644 dev/seed/lib/byok.ts diff --git a/dev/seed/app/byok-e2e-fixture.ts b/dev/seed/app/byok-e2e-fixture.ts new file mode 100644 index 0000000000..26cb38e262 --- /dev/null +++ b/dev/seed/app/byok-e2e-fixture.ts @@ -0,0 +1,196 @@ +import { byok_api_keys, kilocode_users, modelsByProvider } from '@kilocode/db/schema'; +import { StoredModelSchema } from '@kilocode/db/schema-types'; +import { and, desc, eq, or, sql } from 'drizzle-orm'; +import { z } from 'zod'; + +import { encryptCredential, requireEncryptionKey } from '../lib/byok'; +import { getSeedDb } from '../lib/db'; +import { normalizeSeedEmail } from '../lib/email'; +import type { SeedResult } from '../index'; + +export const usage = ' '; + +const ALLOWED_PROVIDERS = ['minimax', 'moonshotai']; +const KEY_PREFIX = 'dev-seed:byok-e2e'; +const MARKER_TAG = 'dev-seed:byok-e2e'; + +function printUsage(): void { + console.log(`Usage: pnpm dev:seed app:byok-e2e-fixture ${usage}`); + console.log(''); + console.log('Seeds a personal BYOK key and one Vercel metadata snapshot entry so the'); + console.log('catalog flags exactly the given model id for the user.'); + console.log(''); + console.log(`Allowed providers: ${ALLOWED_PROVIDERS.join(', ')}`); + console.log('The key is a placeholder the upstream provider rejects on purpose; the'); + console.log('rejection still writes the is_user_byok usage row. The encrypted key and'); + console.log('plaintext are never printed or returned.'); + console.log(''); + console.log('Examples:'); + console.log(' pnpm dev:seed app:byok-e2e-fixture ada@example.com minimax minimax/minimax-m2.5'); +} + +function isValidEmail(email: string): boolean { + // Intentionally permissive; we only guard against obvious nonsense in dev. + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); +} + +async function resolveUserId(email: string): Promise { + const normalizedEmail = normalizeSeedEmail(email); + const db = getSeedDb(); + const matches = await db + .select({ + userId: kilocode_users.id, + email: kilocode_users.google_user_email, + }) + .from(kilocode_users) + .where( + or( + eq(kilocode_users.google_user_email, email), + eq(kilocode_users.normalized_email, normalizedEmail) + ) + ); + + if (matches.length === 0) { + throw new Error(`No user found for email ${email}`); + } + + const exactMatches = matches.filter(match => match.email === email); + const resolvedMatches = exactMatches.length > 0 ? exactMatches : matches; + if (resolvedMatches.length > 1) { + const matchList = resolvedMatches.map(match => `${match.email} (${match.userId})`).join(', '); + throw new Error(`Multiple users matched ${email}: ${matchList}`); + } + + const [user] = resolvedMatches; + return user.userId; +} + +export async function run(...args: string[]): Promise { + if (args.includes('--help') || args.includes('-h')) { + printUsage(); + return; + } + + const [rawEmail, rawProvider, rawModelId, ...rest] = args; + const email = rawEmail?.trim(); + if (!email) { + printUsage(); + throw new Error('email is required'); + } + if (!isValidEmail(email)) { + throw new Error(`email is not a valid address: ${email}`); + } + const provider = rawProvider?.trim(); + if (!provider) { + printUsage(); + throw new Error('provider is required'); + } + if (!ALLOWED_PROVIDERS.includes(provider)) { + throw new Error(`provider must be one of: ${ALLOWED_PROVIDERS.join(', ')}`); + } + const modelId = rawModelId?.trim(); + if (!modelId) { + printUsage(); + throw new Error('model-id is required'); + } + if (rest.length > 0) { + throw new Error(`Unknown arguments: ${rest.join(' ')}`); + } + + const userId = await resolveUserId(email); + const db = getSeedDb(); + + // Reset and replace this topic's own data in one transaction: key deletion, key + // insertion, marker cleanup, metadata validation, and snapshot insertion commit + // together, so a validation or insert failure rolls back and never leaves the user + // without the previous key or a half-cleaned snapshot. + const byokKeyId = await db.transaction(async tx => { + // Reset only this topic's key data: the dedicated test account's personal key for + // this provider. Re-running with the same user+provider is idempotent. + await tx + .delete(byok_api_keys) + .where(and(eq(byok_api_keys.kilo_user_id, userId), eq(byok_api_keys.provider_id, provider))); + + const [insertedKey] = await tx + .insert(byok_api_keys) + .values({ + organization_id: null, + kilo_user_id: userId, + provider_id: provider, + encrypted_api_key: encryptCredential(`${KEY_PREFIX}:${provider}`, requireEncryptionKey()), + management_source: 'user', + created_by: userId, + is_enabled: true, + } satisfies typeof byok_api_keys.$inferInsert) + .returning({ id: byok_api_keys.id }); + if (!insertedKey) { + throw new Error('Failed to create the fixture BYOK key'); + } + + // Remove every models_by_provider row this topic ever wrote, across all models. + // The marker tag never occurs in real snapshots, so a synced row is never deleted. + await tx.delete(modelsByProvider).where( + sql`EXISTS ( + SELECT 1 FROM jsonb_each(${modelsByProvider.vercel}) AS e(k, v) + WHERE e.v -> 'endpoints' @> ${JSON.stringify([{ tag: MARKER_TAG }])}::jsonb + )` + ); + + // Merge the fixture entry into a copy of the newest remaining snapshot so a real + // synced snapshot keeps its other models (same-provider models then stay flagged). + const [latest] = await tx + .select({ + data: modelsByProvider.data, + openrouter: modelsByProvider.openrouter, + vercel: modelsByProvider.vercel, + }) + .from(modelsByProvider) + .orderBy(desc(modelsByProvider.id)) + .limit(1); + + const mergedVercel = { + ...(latest?.vercel ?? {}), + [modelId]: { + id: modelId, + name: modelId, + type: 'language', + endpoints: [{ provider_name: provider, tag: MARKER_TAG }], + }, + }; + + // Prove the merged map parses the way the catalog endpoint later reads it. + const parsed = z.record(z.string(), StoredModelSchema).safeParse(mergedVercel); + if (!parsed.success) { + throw new Error( + `Merged vercel map failed StoredModelSchema validation: ${parsed.error.message}` + ); + } + + await tx.insert(modelsByProvider).values({ + data: latest?.data ?? { + providers: [], + total_providers: 0, + total_models: 0, + generated_at: new Date().toISOString(), + }, + openrouter: latest?.openrouter ?? null, + vercel: parsed.data, + } satisfies typeof modelsByProvider.$inferInsert); + + return insertedKey.id; + }); + + console.log(''); + console.log('This fixture represents a user who holds a personal BYOK key for the'); + console.log('provider and a catalog snapshot flagging exactly the given model id.'); + console.log('The placeholder key is rejected upstream; the usage row still records'); + console.log('is_user_byok = true for the turn.'); + + return { + userId, + byokKeyId, + providerId: provider, + modelId, + enabled: true, + }; +} diff --git a/dev/seed/app/usage-evidence.ts b/dev/seed/app/usage-evidence.ts new file mode 100644 index 0000000000..3df79fa6c6 --- /dev/null +++ b/dev/seed/app/usage-evidence.ts @@ -0,0 +1,181 @@ +import { kilocode_users, microdollar_usage, microdollar_usage_metadata } from '@kilocode/db/schema'; +import { and, desc, eq, gt, or } from 'drizzle-orm'; + +import { getSeedDb } from '../lib/db'; +import { normalizeSeedEmail } from '../lib/email'; +import type { SeedResult } from '../index'; + +export const usage = ' [--since ]'; + +function printUsage(): void { + console.log(`Usage: pnpm dev:seed app:usage-evidence ${usage}`); + console.log(''); + console.log('Reads microdollar usage rows for the user, newest first, capped at 100.'); + console.log('Left-joins usage metadata and reports BYOK evidence as flat primitives.'); + console.log('Read-only; never writes.'); + console.log(''); + console.log('Options:'); + console.log(' --since Only rows created after this instant.'); + console.log(''); + console.log('Examples:'); + console.log( + ' pnpm -s dev:seed app:usage-evidence ada@example.com --json | jq -r .byokLatestModel' + ); + console.log( + ' pnpm -s dev:seed app:usage-evidence ada@example.com --since 2026-08-07T12:00:00Z --json' + ); +} + +function isValidEmail(email: string): boolean { + // Intentionally permissive; we only guard against obvious nonsense in dev. + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); +} + +async function resolveUserId(email: string): Promise { + const normalizedEmail = normalizeSeedEmail(email); + const db = getSeedDb(); + const matches = await db + .select({ + userId: kilocode_users.id, + email: kilocode_users.google_user_email, + }) + .from(kilocode_users) + .where( + or( + eq(kilocode_users.google_user_email, email), + eq(kilocode_users.normalized_email, normalizedEmail) + ) + ); + + if (matches.length === 0) { + throw new Error(`No user found for email ${email}`); + } + + const exactMatches = matches.filter(match => match.email === email); + const resolvedMatches = exactMatches.length > 0 ? exactMatches : matches; + if (resolvedMatches.length > 1) { + const matchList = resolvedMatches.map(match => `${match.email} (${match.userId})`).join(', '); + throw new Error(`Multiple users matched ${email}: ${matchList}`); + } + + const [user] = resolvedMatches; + return user.userId; +} + +type UsageEvidenceOptions = { + email: string; + since: string | null; +}; + +function parseArgs(args: string[]): UsageEvidenceOptions { + const email = args[0]?.trim(); + if (!email) { + printUsage(); + throw new Error('email is required'); + } + if (!isValidEmail(email)) { + throw new Error(`email is not a valid address: ${email}`); + } + + let since: string | null = null; + let index = 1; + while (index < args.length) { + const arg = args[index]; + if (arg === '--since') { + const value = args[index + 1]; + if (!value) { + throw new Error('--since requires an ISO-8601 timestamp value'); + } + if (Number.isNaN(Date.parse(value))) { + throw new Error(`--since is not a valid ISO-8601 timestamp: ${value}`); + } + since = new Date(value).toISOString(); + index += 2; + continue; + } + throw new Error(`Unknown argument: ${arg}`); + } + + return { email, since }; +} + +function dedupeJoined(values: Array): string { + const seen = new Set(); + const unique: string[] = []; + for (const value of values) { + if (value === null || value === undefined) continue; + const text = String(value); + if (seen.has(text)) continue; + seen.add(text); + unique.push(text); + } + return unique.join(','); +} + +export async function run(...args: string[]): Promise { + if (args.includes('--help') || args.includes('-h')) { + printUsage(); + return; + } + + const { email, since } = parseArgs(args); + const userId = await resolveUserId(email); + const db = getSeedDb(); + + // Filter first, then cap: a --since window never discards an in-window row. + const conditions = [eq(microdollar_usage.kilo_user_id, userId)]; + if (since) { + conditions.push(gt(microdollar_usage.created_at, since)); + } + + // Plan section 317: select every plan-required per-row field. The metadata half can be + // null for a row without it, so all metadata fields stay nullable-safe in the row type. + const rows = await db + .select({ + id: microdollar_usage.id, + createdAt: microdollar_usage.created_at, + model: microdollar_usage.model, + requestedModel: microdollar_usage.requested_model, + provider: microdollar_usage.provider, + hasError: microdollar_usage.has_error, + cost: microdollar_usage.cost, + isUserByok: microdollar_usage_metadata.is_user_byok, + statusCode: microdollar_usage_metadata.status_code, + sessionId: microdollar_usage_metadata.session_id, + marketCost: microdollar_usage_metadata.market_cost, + }) + .from(microdollar_usage) + .leftJoin(microdollar_usage_metadata, eq(microdollar_usage_metadata.id, microdollar_usage.id)) + .where(and(...conditions)) + .orderBy(desc(microdollar_usage.created_at)) + .limit(100); + + // A row's model falls back to requested_model for upstream-rejected requests. + const effectiveModel = (row: (typeof rows)[number]): string | null => + row.model ?? row.requestedModel; + const byokRows = rows.filter(row => row.isUserByok === true); + const latest = rows[0]; + const byokLatest = byokRows[0]; + + return { + userId, + rows: rows.length, + byokRows: byokRows.length, + nonByokRows: rows.length - byokRows.length, + latestCreatedAt: latest ? new Date(latest.createdAt).toISOString() : null, + latestModel: latest ? effectiveModel(latest) : null, + latestProvider: latest?.provider ?? null, + latestIsUserByok: latest?.isUserByok ?? null, + latestStatusCode: latest?.statusCode ?? null, + latestSessionId: latest?.sessionId ?? null, + byokLatestCreatedAt: byokLatest ? new Date(byokLatest.createdAt).toISOString() : null, + byokLatestModel: byokLatest ? effectiveModel(byokLatest) : null, + byokLatestProvider: byokLatest?.provider ?? null, + byokLatestSessionId: byokLatest?.sessionId ?? null, + byokSessionIds: dedupeJoined(byokRows.map(row => row.sessionId)), + byokStatusCodes: dedupeJoined(byokRows.map(row => row.statusCode)), + nonByokSessionIds: dedupeJoined( + rows.filter(row => row.isUserByok !== true).map(row => row.sessionId) + ), + }; +} diff --git a/dev/seed/coding-plans/occupied-minimax-byok.ts b/dev/seed/coding-plans/occupied-minimax-byok.ts index aebd0aa068..448929dcaf 100644 --- a/dev/seed/coding-plans/occupied-minimax-byok.ts +++ b/dev/seed/coding-plans/occupied-minimax-byok.ts @@ -1,9 +1,7 @@ -import { createCipheriv, randomBytes } from 'node:crypto'; - import { byok_api_keys } from '@kilocode/db/schema'; -import type { EncryptedData } from '@kilocode/db/schema-types'; import { and, eq } from 'drizzle-orm'; +import { encryptCredential, requireEncryptionKey } from '../lib/byok'; import { getSeedDb } from '../lib/db'; import type { SeedResult } from '../index'; @@ -19,29 +17,6 @@ function printUsage(): void { console.log('The placeholder key supports subscription precondition UI testing only.'); } -function requireEncryptionKey(): Buffer { - const keyBase64 = process.env.BYOK_ENCRYPTION_KEY; - if (!keyBase64) { - throw new Error('BYOK_ENCRYPTION_KEY is not configured'); - } - const key = Buffer.from(keyBase64, 'base64'); - if (key.length !== 32) { - throw new Error('BYOK_ENCRYPTION_KEY must decode to 32 bytes'); - } - return key; -} - -function encryptCredential(plaintext: string, key: Buffer): EncryptedData { - const iv = randomBytes(12); - const cipher = createCipheriv('aes-256-gcm', key, iv); - const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); - return { - iv: iv.toString('base64'), - data: encrypted.toString('base64'), - authTag: cipher.getAuthTag().toString('base64'), - }; -} - function requireScenario(value: string | undefined): string { const scenario = value?.trim(); if (!scenario || !/^[a-zA-Z0-9_-]{1,64}$/.test(scenario)) { diff --git a/dev/seed/lib/byok.ts b/dev/seed/lib/byok.ts new file mode 100644 index 0000000000..99270e07e7 --- /dev/null +++ b/dev/seed/lib/byok.ts @@ -0,0 +1,26 @@ +import { createCipheriv, randomBytes } from 'node:crypto'; + +import type { EncryptedData } from '@kilocode/db/schema-types'; + +export function requireEncryptionKey(): Buffer { + const keyBase64 = process.env.BYOK_ENCRYPTION_KEY; + if (!keyBase64) { + throw new Error('BYOK_ENCRYPTION_KEY is not configured'); + } + const key = Buffer.from(keyBase64, 'base64'); + if (key.length !== 32) { + throw new Error('BYOK_ENCRYPTION_KEY must decode to 32 bytes'); + } + return key; +} + +export function encryptCredential(plaintext: string, key: Buffer): EncryptedData { + const iv = randomBytes(12); + const cipher = createCipheriv('aes-256-gcm', key, iv); + const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + return { + iv: iv.toString('base64'), + data: encrypted.toString('base64'), + authTag: cipher.getAuthTag().toString('base64'), + }; +} From e15c5d7ef43a2767d0027847fbe93a70ede55c2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 7 Aug 2026 19:50:49 +0200 Subject: [PATCH 03/13] feat(cloud-agent-sdk): add instance model catalog request --- packages/cloud-agent-sdk/package.json | 1 + .../src/instance-model-catalog.test.ts | 319 ++++++++++++++++++ .../src/instance-model-catalog.ts | 92 +++++ 3 files changed, 412 insertions(+) create mode 100644 packages/cloud-agent-sdk/src/instance-model-catalog.test.ts create mode 100644 packages/cloud-agent-sdk/src/instance-model-catalog.ts diff --git a/packages/cloud-agent-sdk/package.json b/packages/cloud-agent-sdk/package.json index 2216322a44..2cbaf36264 100644 --- a/packages/cloud-agent-sdk/package.json +++ b/packages/cloud-agent-sdk/package.json @@ -8,6 +8,7 @@ ".": "./src/index.ts", "./context-usage": "./src/context-usage.ts", "./create-session": "./src/create-session.ts", + "./instance-model-catalog": "./src/instance-model-catalog.ts", "./message-id": "./src/message-id.ts", "./preparation-attempts": "./src/preparation-attempts.ts", "./remote-command-catalog": "./src/remote-command-catalog.ts", diff --git a/packages/cloud-agent-sdk/src/instance-model-catalog.test.ts b/packages/cloud-agent-sdk/src/instance-model-catalog.test.ts new file mode 100644 index 0000000000..fe836f5313 --- /dev/null +++ b/packages/cloud-agent-sdk/src/instance-model-catalog.test.ts @@ -0,0 +1,319 @@ +import { listInstanceModels } from './instance-model-catalog'; +import { + REMOTE_MODEL_CATALOG_MAX_SERIALIZED_BYTES, + REMOTE_MODEL_IDENTITY_MAX_LENGTH, + REMOTE_MODEL_MAX_MODELS_PER_PROVIDER, +} from './schemas'; +import { CommandDeliveredError, UserWebCommandError } from './user-web-connection'; + +function makeFakeConnection() { + return { + sendCommandToConnection: jest.fn(), + }; +} + +function createSdkModel(providerID: string, id: string, variants: string[] = [], name = id) { + return { + id, + providerID, + api: { id, url: '', npm: '' }, + name, + capabilities: { + temperature: true, + reasoning: true, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: true }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { context: 128_000, output: 16_000 }, + status: 'active' as const, + options: {}, + headers: {}, + release_date: '', + variants: Object.fromEntries(variants.map(variant => [variant, {}])), + }; +} + +function createSdkProvider( + id: string, + models: ReturnType[] = [createSdkModel(id, `model-${id}`)] +) { + return { + id, + name: id, + source: 'custom' as const, + env: [], + options: {}, + models: Object.fromEntries(models.map(model => [model.id, model])), + }; +} + +function createWireCatalog(all: ReturnType[]) { + return { + all, + default: Object.fromEntries( + all.flatMap(provider => { + const modelID = Object.keys(provider.models)[0]; + return modelID ? [[provider.id, modelID]] : []; + }) + ), + connected: all.map(provider => provider.id), + failed: [], + protocolVersion: 1 as const, + truncated: false, + }; +} + +function getSerializedByteLength(value: unknown): number { + return new TextEncoder().encode(JSON.stringify(value)).byteLength; +} + +function createCatalogWithSerializedBytes(targetBytes: number) { + for (let count = 256; count <= 2_048; count += 64) { + const models = Array.from({ length: count }, (_, index) => + createSdkModel( + `provider-${Math.floor(index / REMOTE_MODEL_MAX_MODELS_PER_PROVIDER)}`, + `model-${index}`, + [], + '' + ) + ); + const providers = Array.from( + { length: Math.ceil(count / REMOTE_MODEL_MAX_MODELS_PER_PROVIDER) }, + (_, providerIndex) => + createSdkProvider( + `provider-${providerIndex}`, + models.slice( + providerIndex * REMOTE_MODEL_MAX_MODELS_PER_PROVIDER, + (providerIndex + 1) * REMOTE_MODEL_MAX_MODELS_PER_PROVIDER + ) + ) + ); + const catalog = createWireCatalog(providers); + let remainingBytes = targetBytes - getSerializedByteLength(catalog); + if (remainingBytes < 0 || remainingBytes > count * REMOTE_MODEL_IDENTITY_MAX_LENGTH) continue; + + for (const model of models) { + const addedBytes = Math.min(remainingBytes, REMOTE_MODEL_IDENTITY_MAX_LENGTH); + model.name = 'x'.repeat(addedBytes); + remainingBytes -= addedBytes; + if (remainingBytes === 0) break; + } + if (getSerializedByteLength(catalog) === targetBytes) return catalog; + } + throw new Error(`Cannot create a catalog with ${targetBytes} serialized bytes`); +} + +describe('listInstanceModels', () => { + it('sends exactly one sessionless list_models with protocol version 1 and no session or mutation id', async () => { + const connection = makeFakeConnection(); + connection.sendCommandToConnection.mockResolvedValue({ + protocolVersion: 1, + all: [], + default: {}, + connected: [], + failed: [], + truncated: false, + }); + + const result = await listInstanceModels(connection, 'cli-owner-1'); + + expect(result).toEqual({ + ok: true, + catalog: { protocolVersion: 1, providers: [], truncated: false }, + }); + expect(connection.sendCommandToConnection).toHaveBeenCalledTimes(1); + const recorded = connection.sendCommandToConnection.mock.calls[0]?.[0]; + expect(recorded).toEqual({ + command: 'list_models', + data: { protocolVersion: 1 }, + expectedConnectionId: 'cli-owner-1', + }); + expect(recorded).not.toHaveProperty('mutationId'); + expect(recorded).not.toHaveProperty('sessionId'); + expect(recorded?.data).not.toHaveProperty('sessionId'); + }); + + it('resolves a valid wire catalog with the transformed connected-only sorted shape', async () => { + const connection = makeFakeConnection(); + const zeta = createSdkProvider('zeta-provider'); + zeta.name = 'Zeta Provider'; + const alpha = createSdkProvider('alpha-provider', [ + createSdkModel('alpha-provider', 'beta', [], 'Beta'), + createSdkModel('alpha-provider', 'alpha', [], 'Alpha'), + ]); + alpha.name = 'Alpha Provider'; + const disconnected = createSdkProvider('disconnected'); + connection.sendCommandToConnection.mockResolvedValue({ + ...createWireCatalog([zeta, alpha, disconnected]), + connected: ['zeta-provider', 'alpha-provider'], + }); + + const result = await listInstanceModels(connection, 'cli-owner-1'); + + expect(result).toEqual({ + ok: true, + catalog: { + protocolVersion: 1, + providers: [ + { + id: 'alpha-provider', + name: 'Alpha Provider', + models: [ + { + id: 'alpha', + name: 'Alpha', + variants: [], + capabilities: { attachment: true, reasoning: true }, + limits: { context: 128_000, output: 16_000 }, + }, + { + id: 'beta', + name: 'Beta', + variants: [], + capabilities: { attachment: true, reasoning: true }, + limits: { context: 128_000, output: 16_000 }, + }, + ], + }, + { + id: 'zeta-provider', + name: 'Zeta Provider', + models: [ + { + id: 'model-zeta-provider', + name: 'model-zeta-provider', + variants: [], + capabilities: { attachment: true, reasoning: true }, + limits: { context: 128_000, output: 16_000 }, + }, + ], + }, + ], + truncated: false, + }, + }); + }); + + it('classifies the old-CLI invalid list_models command as unsupported', async () => { + const connection = makeFakeConnection(); + connection.sendCommandToConnection.mockRejectedValue( + new CommandDeliveredError('invalid list_models command') + ); + + await expect(listInstanceModels(connection, 'cli-owner-1')).resolves.toEqual({ + ok: false, + reason: 'unsupported', + }); + expect(connection.sendCommandToConnection).toHaveBeenCalledTimes(1); + }); + + it('classifies any other delivered CommandDeliveredError as transport', async () => { + const connection = makeFakeConnection(); + connection.sendCommandToConnection.mockRejectedValue( + new CommandDeliveredError('failed to list models') + ); + + await expect(listInstanceModels(connection, 'cli-owner-1')).resolves.toEqual({ + ok: false, + reason: 'transport', + }); + }); + + it('classifies a structured relay error with a non-retryable code as unsupported', async () => { + const connection = makeFakeConnection(); + connection.sendCommandToConnection.mockRejectedValue( + new UserWebCommandError({ code: 'CLI_UPGRADE_REQUIRED', message: 'upgrade required' }) + ); + + await expect(listInstanceModels(connection, 'cli-owner-1')).resolves.toEqual({ + ok: false, + reason: 'unsupported', + }); + }); + + it('classifies an over-cap catalog relay code as unsupported so it is never retried', async () => { + const connection = makeFakeConnection(); + connection.sendCommandToConnection.mockRejectedValue( + new UserWebCommandError({ code: 'CATALOG_TOO_LARGE', message: 'catalog too large' }) + ); + + await expect(listInstanceModels(connection, 'cli-owner-1')).resolves.toEqual({ + ok: false, + reason: 'unsupported', + }); + }); + + it('classifies every retryable relay code as transport', async () => { + const retryableCodes = [ + 'SESSION_OWNER_CHANGED', + 'CATALOG_REQUEST_PENDING', + 'COMMAND_EXPIRED', + 'PENDING_COMMAND_LIMIT', + ]; + + for (const code of retryableCodes) { + const connection = makeFakeConnection(); + connection.sendCommandToConnection.mockRejectedValue( + new UserWebCommandError({ code, message: 'try again' }) + ); + + await expect(listInstanceModels(connection, 'cli-owner-1')).resolves.toEqual({ + ok: false, + reason: 'transport', + }); + } + }); + + it('classifies a plain transport-level rejection as transport', async () => { + const connection = makeFakeConnection(); + connection.sendCommandToConnection.mockRejectedValue(new Error('Command timed out')); + + await expect(listInstanceModels(connection, 'cli-owner-1')).resolves.toEqual({ + ok: false, + reason: 'transport', + }); + }); + + it('classifies a resolved payload with an unknown top-level key as invalid', async () => { + const connection = makeFakeConnection(); + const wire = createWireCatalog([createSdkProvider('provider')]); + connection.sendCommandToConnection.mockResolvedValue({ ...wire, sneaky: 'value' }); + + await expect(listInstanceModels(connection, 'cli-owner-1')).resolves.toEqual({ + ok: false, + reason: 'invalid', + }); + }); + + it('classifies a resolved payload over the serialized byte limit as invalid', async () => { + const connection = makeFakeConnection(); + const overLimit = createCatalogWithSerializedBytes( + REMOTE_MODEL_CATALOG_MAX_SERIALIZED_BYTES + 1 + ); + connection.sendCommandToConnection.mockResolvedValue(overLimit); + + expect(getSerializedByteLength(overLimit)).toBe(REMOTE_MODEL_CATALOG_MAX_SERIALIZED_BYTES + 1); + await expect(listInstanceModels(connection, 'cli-owner-1')).resolves.toEqual({ + ok: false, + reason: 'invalid', + }); + }); + + it('keeps a schema-valid catalog with an empty-model connected provider SDK-valid', async () => { + const connection = makeFakeConnection(); + connection.sendCommandToConnection.mockResolvedValue( + createWireCatalog([createSdkProvider('provider', [])]) + ); + + const result = await listInstanceModels(connection, 'cli-owner-1'); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.catalog.providers).toEqual([{ id: 'provider', name: 'provider', models: [] }]); + } + }); +}); diff --git a/packages/cloud-agent-sdk/src/instance-model-catalog.ts b/packages/cloud-agent-sdk/src/instance-model-catalog.ts new file mode 100644 index 0000000000..8f0623b0fb --- /dev/null +++ b/packages/cloud-agent-sdk/src/instance-model-catalog.ts @@ -0,0 +1,92 @@ +/** + * Instance model catalog — sessionless `list_models` request and strict parse. + * + * `list_models` is a connection-scoped viewer command sent on the user-web + * socket before a session exists. The wire request is deliberately bare: + * `protocolVersion: 1` with no `sessionId` and no `mutationId` — a catalog read + * is not a mutation and does not belong to a session. The success body is + * parsed with the strict `remoteModelCatalogV1Schema`; anything outside that + * envelope (unknown keys, protocol drift, or an over-limit serialized size) + * fails closed as `invalid`. + * + * The helper never throws and never logs. It classifies every outcome so the + * caller can distinguish a permanent "this CLI cannot answer" result + * (`unsupported`) from a transient transport failure worth retrying + * (`transport`). + */ +import { remoteModelCatalogV1Schema } from './schemas'; +import type { RemoteModelCatalogV1 } from './schemas'; +import { + CommandDeliveredError, + UserWebCommandError, + type UserWebConnection, +} from './user-web-connection'; + +export { remoteModelCatalogV1Schema } from './schemas'; +export type { RemoteModelCatalogV1 } from './schemas'; + +/** Delivered error string an old CLI returns for a sessionless `list_models`. */ +const INVALID_LIST_MODELS_COMMAND = 'invalid list_models command'; + +/** + * Relay codes whose failure is transient for this connection. Every other + * structured relay error repeats identically on retry, so it must not be + * retried. + */ +const RETRYABLE_RELAY_CODES = new Set([ + 'SESSION_OWNER_CHANGED', + 'CATALOG_REQUEST_PENDING', + 'COMMAND_EXPIRED', + 'PENDING_COMMAND_LIMIT', +]); + +export type InstanceModelCatalogResult = + | { ok: true; catalog: RemoteModelCatalogV1 } + | { ok: false; reason: 'unsupported' | 'invalid' | 'transport' }; + +/** + * Request the model catalog of a specific CLI connection before a session + * exists. + * + * Sends exactly one sessionless `list_models` command with protocol version 1 + * and no session or mutation id, then classifies the outcome: + * + * - Resolved and schema-valid → `{ ok: true, catalog }` with the transformed + * catalog shape. + * - Resolved but outside the strict schema → `{ ok: false, reason: 'invalid' }`. + * - Rejected with the old-CLI `invalid list_models command` string or a + * non-retryable relay code → `{ ok: false, reason: 'unsupported' }`. + * - Rejected with a retryable relay code or a transport-level failure → + * `{ ok: false, reason: 'transport' }`. + * + * Never throws and never logs. + */ +export async function listInstanceModels( + connection: Pick, + connectionId: string +): Promise { + let raw: unknown; + try { + raw = await connection.sendCommandToConnection({ + command: 'list_models', + data: { protocolVersion: 1 }, + expectedConnectionId: connectionId, + }); + } catch (error) { + if (error instanceof CommandDeliveredError) { + return error.message === INVALID_LIST_MODELS_COMMAND + ? { ok: false, reason: 'unsupported' } + : { ok: false, reason: 'transport' }; + } + if (error instanceof UserWebCommandError) { + return RETRYABLE_RELAY_CODES.has(error.code) + ? { ok: false, reason: 'transport' } + : { ok: false, reason: 'unsupported' }; + } + return { ok: false, reason: 'transport' }; + } + + const parsed = remoteModelCatalogV1Schema.safeParse(raw); + if (!parsed.success) return { ok: false, reason: 'invalid' }; + return { ok: true, catalog: parsed.data }; +} From 84d6da50e66fc24fe8eeab35374edbb2d92fdc48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 7 Aug 2026 19:51:04 +0200 Subject: [PATCH 04/13] feat(mobile): pass selected model to remote sessions --- .../hooks/remote-instance-spawn-classifier.ts | 26 ++++++++------ .../hooks/use-remote-instance-spawn.test.ts | 36 ++++++++++--------- 2 files changed, 34 insertions(+), 28 deletions(-) diff --git a/apps/mobile/src/lib/hooks/remote-instance-spawn-classifier.ts b/apps/mobile/src/lib/hooks/remote-instance-spawn-classifier.ts index 5aa62b74fb..f73ef133e5 100644 --- a/apps/mobile/src/lib/hooks/remote-instance-spawn-classifier.ts +++ b/apps/mobile/src/lib/hooks/remote-instance-spawn-classifier.ts @@ -1,4 +1,8 @@ -import { type KiloSessionId, type UserWebConnection } from '@kilocode/cloud-agent-sdk'; +import { + type KiloSessionId, + type ModelSelection, + type UserWebConnection, +} from '@kilocode/cloud-agent-sdk'; // kilocode_change - K1/C2: these two runtime imports must come from their // narrow subpaths, not the `cloud-agent-sdk` barrel. The barrel's index.ts // also re-exports web-only transport code (`cloud-agent-connection.ts` -> @@ -137,26 +141,26 @@ export function classifyCreateSessionResult( // --------------------------------------------------------------------------- /** - * Map the new-session screen's picker strings into the SDK - * `CreateRemoteSessionInput` shape. Empty strings are omitted. Mobile model - * options are gateway models; `kilo` is their provider (same mapping - * `getRemoteModelFields` uses for legacy overrides). + * Map the new-session screen's picker state into the SDK + * `CreateRemoteSessionInput` shape. The caller resolves the picker's model + * choice into a `ModelSelection` (provider + model + optional variant); this + * builder forwards the selected provider and model as-is, without any + * hard-coded provider mapping. Empty strings are omitted. */ export function buildCreateRemoteSessionInput(fields: { mode?: string; - model?: string; - variant?: string; + selection?: ModelSelection; organizationId?: string | null; }): CreateRemoteSessionInput | undefined { const input: CreateRemoteSessionInput = {}; if (fields.mode) { input.agent = fields.mode; } - if (fields.model) { + if (fields.selection) { input.model = { - providerID: 'kilo', - modelID: fields.model, - ...(fields.variant ? { variant: fields.variant } : {}), + providerID: fields.selection.model.providerID, + modelID: fields.selection.model.modelID, + ...(fields.selection.variant ? { variant: fields.selection.variant } : {}), }; } if (fields.organizationId) { diff --git a/apps/mobile/src/lib/hooks/use-remote-instance-spawn.test.ts b/apps/mobile/src/lib/hooks/use-remote-instance-spawn.test.ts index 37dc87ec4d..ab9ea43d65 100644 --- a/apps/mobile/src/lib/hooks/use-remote-instance-spawn.test.ts +++ b/apps/mobile/src/lib/hooks/use-remote-instance-spawn.test.ts @@ -116,30 +116,34 @@ describe('classifyCreateSessionResult', () => { }); describe('buildCreateRemoteSessionInput', () => { - it('returns undefined when every field is empty or absent', () => { + it('returns undefined when no fields are provided', () => { expect(buildCreateRemoteSessionInput({})).toBeUndefined(); - expect(buildCreateRemoteSessionInput({ mode: '', model: '', variant: '' })).toBeUndefined(); + expect(buildCreateRemoteSessionInput({ mode: '' })).toBeUndefined(); }); it('maps mode to agent when non-empty', () => { expect(buildCreateRemoteSessionInput({ mode: 'code' })).toEqual({ agent: 'code' }); }); - it('maps model to kilo provider modelID without variant when variant is empty', () => { - expect(buildCreateRemoteSessionInput({ model: 'kilo-auto/efficient', variant: '' })).toEqual({ - model: { providerID: 'kilo', modelID: 'kilo-auto/efficient' }, - }); + it('emits a kilo selection without a variant when the selection has none', () => { + expect( + buildCreateRemoteSessionInput({ + selection: { model: { providerID: 'kilo', modelID: 'kilo-auto/efficient' } }, + }) + ).toEqual({ model: { providerID: 'kilo', modelID: 'kilo-auto/efficient' } }); }); - it('includes variant only when non-empty', () => { + it('emits a non-kilo selection with its variant', () => { expect( buildCreateRemoteSessionInput({ - model: 'anthropic/claude-sonnet-4', - variant: 'high', + selection: { + model: { providerID: 'anthropic', modelID: 'anthropic/claude-sonnet-4' }, + variant: 'high', + }, }) ).toEqual({ model: { - providerID: 'kilo', + providerID: 'anthropic', modelID: 'anthropic/claude-sonnet-4', variant: 'high', }, @@ -152,12 +156,14 @@ describe('buildCreateRemoteSessionInput', () => { }); }); - it('combines mode, model, variant, and organizationId', () => { + it('combines mode, selection, and organizationId', () => { expect( buildCreateRemoteSessionInput({ mode: 'plan', - model: 'kilo-auto/efficient', - variant: 'medium', + selection: { + model: { providerID: 'kilo', modelID: 'kilo-auto/efficient' }, + variant: 'medium', + }, organizationId: 'org-xyz', }) ).toEqual({ @@ -170,10 +176,6 @@ describe('buildCreateRemoteSessionInput', () => { orgId: 'org-xyz', }); }); - - it('omits model when only variant is set', () => { - expect(buildCreateRemoteSessionInput({ variant: 'high' })).toBeUndefined(); - }); }); describe('resolveSpawnOrganizationId', () => { From 564e3226bff7cfbd521e3beb5623c5d4822c5149 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 7 Aug 2026 19:51:25 +0200 Subject: [PATCH 05/13] feat(mobile): load remote instance model catalogs --- .../agents/new-session-model-view.test.ts | 335 ++++++++++++++++++ .../agents/new-session-model-view.ts | 205 +++++++++++ .../lib/hooks/use-instance-model-catalog.ts | 60 ++++ 3 files changed, 600 insertions(+) create mode 100644 apps/mobile/src/components/agents/new-session-model-view.test.ts create mode 100644 apps/mobile/src/components/agents/new-session-model-view.ts create mode 100644 apps/mobile/src/lib/hooks/use-instance-model-catalog.ts diff --git a/apps/mobile/src/components/agents/new-session-model-view.test.ts b/apps/mobile/src/components/agents/new-session-model-view.test.ts new file mode 100644 index 0000000000..7f1f7da055 --- /dev/null +++ b/apps/mobile/src/components/agents/new-session-model-view.test.ts @@ -0,0 +1,335 @@ +import { describe, expect, it } from 'vitest'; +import { type RemoteModelCatalogV1 } from '@kilocode/cloud-agent-sdk/instance-model-catalog'; +import { type ModelRef } from '@kilocode/cloud-agent-sdk/remote-model-catalog'; + +import { buildCreateRemoteSessionInput } from '@/lib/hooks/remote-instance-spawn-classifier'; +import { type ModelOption } from '@/lib/hooks/use-available-models'; +import { + resolveNewSessionModelView, + type ResolveNewSessionModelViewInput, +} from './new-session-model-view'; + +const gatewayModels: ModelOption[] = [ + { + id: 'kilo-auto/efficient', + name: 'Auto Efficient', + variants: ['low', 'high'], + isPreferred: true, + }, + { + id: 'kilo-auto/maximum', + name: 'Auto Maximum', + variants: [], + isPreferred: false, + }, +]; + +type CatalogModelInput = { + id: string; + name?: string; + variants?: string[]; +}; + +type CatalogProviderInput = { + id: string; + name?: string; + models: CatalogModelInput[]; +}; + +function createCatalog( + providers: CatalogProviderInput[], + defaultModel?: ModelRef +): RemoteModelCatalogV1 { + return { + protocolVersion: 1, + truncated: false, + providers: providers.map(provider => ({ + id: provider.id, + ...(provider.name ? { name: provider.name } : {}), + models: provider.models.map(model => ({ + id: model.id, + ...(model.name ? { name: model.name } : {}), + variants: model.variants ?? [], + capabilities: { attachment: true, reasoning: true }, + limits: { context: 200_000, output: 16_000 }, + })), + })), + ...(defaultModel ? { defaultModel } : {}), + }; +} + +const baseCatalog = createCatalog([ + { + id: 'kilo', + name: 'Kilo Gateway', + models: [ + { id: 'kilo-auto/efficient', name: 'Auto Efficient', variants: ['low', 'high'] }, + { id: 'kilo-model-a', name: 'Kilo Model A', variants: [] }, + ], + }, + { + id: 'anthropic', + name: 'Anthropic', + models: [{ id: 'claude-x', name: 'Claude X', variants: [] }], + }, + { + id: 'opencode', + name: 'OpenCode', + models: [{ id: 'opencode-model', name: 'OpenCode Model', variants: [] }], + }, +]); + +const baseInput: ResolveNewSessionModelViewInput = { + isRemoteTarget: true, + catalog: baseCatalog, + catalogLoading: false, + gatewayModels, + gatewayModelsLoading: false, + gatewayModel: 'kilo-auto/efficient', + gatewayVariant: 'high', + remoteOverride: null, +}; + +describe('resolveNewSessionModelView', () => { + it('returns the gateway options and persisted strings for a Cloud Agent target', () => { + const view = resolveNewSessionModelView({ ...baseInput, isRemoteTarget: false }); + + expect(view.options.map(option => option.id)).toEqual(gatewayModels.map(model => model.id)); + expect(view.options.some(option => option.modelRef)).toBe(false); + expect(view.selectedValue).toBe('kilo-auto/efficient'); + expect(view.selectedVariant).toBe('high'); + expect(view.spawnSelection).toBeUndefined(); + expect(view.isSelectionUnavailable).toBe(false); + }); + + it('projects the instance catalog into provider-grouped CLI options', () => { + const view = resolveNewSessionModelView(baseInput); + + expect(view.options.some(option => option.provider?.id === 'anthropic')).toBe(true); + expect(view.options.every(option => option.modelRef)).toBe(true); + expect(view.options.every(option => option.overrideSource === 'cli-catalog')).toBe(true); + }); + + it('starts on the persisted gateway model when the instance offers it', () => { + const view = resolveNewSessionModelView(baseInput); + + expect(view.spawnSelection).toEqual({ + model: { providerID: 'kilo', modelID: 'kilo-auto/efficient' }, + variant: 'high', + }); + }); + + it('falls back to the catalog default when the gateway model is absent', () => { + const catalog = createCatalog( + [ + { id: 'kilo', models: [{ id: 'kilo-model-a' }] }, + { id: 'anthropic', models: [{ id: 'claude-x' }] }, + { id: 'opencode', models: [{ id: 'opencode-model' }] }, + ], + { providerID: 'anthropic', modelID: 'claude-x' } + ); + const view = resolveNewSessionModelView({ ...baseInput, catalog }); + + expect(view.spawnSelection).toEqual({ + model: { providerID: 'anthropic', modelID: 'claude-x' }, + }); + }); + + it('honors a CLI override on a non-kilo model present in the catalog', () => { + const view = resolveNewSessionModelView({ + ...baseInput, + remoteOverride: { + source: 'cli-catalog', + selection: { model: { providerID: 'anthropic', modelID: 'claude-x' } }, + }, + }); + + expect(view.spawnSelection).toEqual({ + model: { providerID: 'anthropic', modelID: 'claude-x' }, + }); + }); + + it('blocks Start when the override model is absent from the catalog', () => { + const view = resolveNewSessionModelView({ + ...baseInput, + remoteOverride: { + source: 'cli-catalog', + selection: { model: { providerID: 'openai', modelID: 'gpt-y' } }, + }, + }); + + expect(view.isSelectionUnavailable).toBe(true); + expect(view.spawnSelection).toBeUndefined(); + }); + + it('drops a variant the selected model does not offer from the wire', () => { + const catalog = createCatalog([ + { id: 'kilo', models: [{ id: 'kilo-auto/efficient', variants: ['low'] }] }, + { id: 'anthropic', models: [{ id: 'claude-x' }] }, + { id: 'opencode', models: [{ id: 'opencode-model' }] }, + ]); + const view = resolveNewSessionModelView({ + ...baseInput, + catalog, + remoteOverride: { + source: 'cli-catalog', + selection: { + model: { providerID: 'kilo', modelID: 'kilo-auto/efficient' }, + variant: 'high', + }, + }, + }); + + expect(view.spawnSelection).toEqual({ + model: { providerID: 'kilo', modelID: 'kilo-auto/efficient' }, + }); + }); + + it('falls back to gateway-shaped options when the catalog is unavailable', () => { + const view = resolveNewSessionModelView({ ...baseInput, catalog: null }); + + expect(view.options.every(option => option.overrideSource === 'legacy-gateway')).toBe(true); + expect(view.options.every(option => option.modelRef?.providerID === 'kilo')).toBe(true); + expect(view.spawnSelection).toEqual({ + model: { providerID: 'kilo', modelID: 'kilo-auto/efficient' }, + variant: 'high', + }); + }); + + it('emits no wire model when the fallback gateway model is not in the gateway list', () => { + const view = resolveNewSessionModelView({ + ...baseInput, + catalog: null, + gatewayModel: 'unknown/model', + gatewayVariant: '', + }); + + expect(view.spawnSelection).toBeUndefined(); + }); + + it('selects the first catalog option when the catalog has no defaultModel', () => { + const catalog = createCatalog([ + { id: 'kilo', models: [{ id: 'kilo-model-a' }] }, + { id: 'anthropic', models: [{ id: 'claude-x' }] }, + { id: 'opencode', models: [{ id: 'opencode-model' }] }, + ]); + const view = resolveNewSessionModelView({ ...baseInput, catalog }); + + expect(view.selectedValue).toBe(view.options[0]?.id); + expect(view.spawnSelection).toEqual({ model: view.options[0]?.modelRef }); + }); + + it('drops a CLI override when the catalog is gone and falls back to the gateway', () => { + const view = resolveNewSessionModelView({ + ...baseInput, + catalog: null, + remoteOverride: { + source: 'cli-catalog', + selection: { model: { providerID: 'anthropic', modelID: 'claude-x' }, variant: 'high' }, + }, + }); + + expect(view.isSelectionUnavailable).toBe(false); + expect(view.options.every(option => option.modelRef?.providerID === 'kilo')).toBe(true); + expect(view.spawnSelection).toEqual({ + model: { providerID: 'kilo', modelID: 'kilo-auto/efficient' }, + variant: 'high', + }); + }); + + it('drops a stale legacy override when a catalog without that model arrives', () => { + const catalog = createCatalog( + [ + { id: 'kilo', models: [{ id: 'kilo-model-a' }] }, + { id: 'anthropic', models: [{ id: 'claude-x' }] }, + { id: 'opencode', models: [{ id: 'opencode-model' }] }, + ], + { providerID: 'anthropic', modelID: 'claude-x' } + ); + const view = resolveNewSessionModelView({ + ...baseInput, + catalog, + remoteOverride: { + source: 'legacy-gateway', + selection: { model: { providerID: 'kilo', modelID: 'stale/gateway-model' } }, + }, + }); + + expect(view.options.some(option => option.unavailable)).toBe(false); + expect(view.isSelectionUnavailable).toBe(false); + expect(view.spawnSelection).toEqual({ + model: { providerID: 'anthropic', modelID: 'claude-x' }, + }); + }); + + it('keeps the unavailable signal for a CLI pick the catalog dropped', () => { + const view = resolveNewSessionModelView({ + ...baseInput, + remoteOverride: { + source: 'cli-catalog', + selection: { model: { providerID: 'anthropic', modelID: 'removed-model' } }, + }, + }); + + expect(view.isSelectionUnavailable).toBe(true); + expect(view.spawnSelection).toBeUndefined(); + }); + + it('never leaks a previous instance model into a new instance selection', () => { + const catalogA = createCatalog([ + { id: 'kilo', models: [{ id: 'kilo-model-a' }] }, + { id: 'anthropic', models: [{ id: 'claude-x' }] }, + { id: 'opencode', models: [{ id: 'opencode-model' }] }, + ]); + const catalogB = createCatalog([ + { id: 'kilo', models: [{ id: 'kilo-model-a' }] }, + { id: 'openai', models: [{ id: 'gpt-y' }] }, + { id: 'opencode', models: [{ id: 'opencode-model' }] }, + ]); + const claudeOverride = { + source: 'cli-catalog' as const, + selection: { model: { providerID: 'anthropic', modelID: 'claude-x' } }, + }; + + const onInstanceA = resolveNewSessionModelView({ + ...baseInput, + catalog: catalogA, + remoteOverride: claudeOverride, + }); + expect(onInstanceA.spawnSelection?.model.providerID).toBe('anthropic'); + + const onInstanceB = resolveNewSessionModelView({ + ...baseInput, + catalog: catalogB, + remoteOverride: null, + }); + expect(onInstanceB.options.some(option => option.provider?.id === 'anthropic')).toBe(false); + expect(onInstanceB.spawnSelection).toBeDefined(); + expect(onInstanceB.spawnSelection?.model.providerID).not.toBe('anthropic'); + + const onInstanceBWithStaleOverride = resolveNewSessionModelView({ + ...baseInput, + catalog: catalogB, + remoteOverride: claudeOverride, + }); + expect(onInstanceBWithStaleOverride.isSelectionUnavailable).toBe(true); + expect(onInstanceBWithStaleOverride.spawnSelection).toBeUndefined(); + }); + + it('composes the view with the real wire builder end to end', () => { + const view = resolveNewSessionModelView({ + ...baseInput, + remoteOverride: { + source: 'cli-catalog', + selection: { model: { providerID: 'anthropic', modelID: 'claude-x' } }, + }, + }); + + expect(buildCreateRemoteSessionInput({ mode: 'code', selection: view.spawnSelection })).toEqual( + { + agent: 'code', + model: { providerID: 'anthropic', modelID: 'claude-x' }, + } + ); + }); +}); diff --git a/apps/mobile/src/components/agents/new-session-model-view.ts b/apps/mobile/src/components/agents/new-session-model-view.ts new file mode 100644 index 0000000000..5191260084 --- /dev/null +++ b/apps/mobile/src/components/agents/new-session-model-view.ts @@ -0,0 +1,205 @@ +import { type RemoteModelCatalogV1 } from '@kilocode/cloud-agent-sdk/instance-model-catalog'; +import { + type ModelSelection, + type RemoteModelOverride, + type RemoteModelState, +} from '@kilocode/cloud-agent-sdk/remote-model-catalog'; + +import { type ModelOption } from '@/lib/hooks/use-available-models'; +import { + buildSessionModelOptions, + type SessionModelOption, +} from '@/lib/hooks/use-session-model-options'; + +export type NewSessionModelView = { + options: SessionModelOption[]; + selectedValue: string; + selectedVariant: string; + /** Wire model for create_session; undefined means "let the CLI use its default". */ + spawnSelection?: ModelSelection; + /** True when the current selection is not in the active catalog. Blocks Start. */ + isSelectionUnavailable: boolean; +}; + +export type ResolveNewSessionRemoteOverrideInput = { + catalog: RemoteModelCatalogV1 | null; + gatewayModel: string; + gatewayVariant: string; + remoteOverride: RemoteModelOverride | null; +}; + +/** + * Resolve the new-session model override against the active catalog state. + * + * An existing override wins, except where it is a stale artifact of the + * other catalog state rather than a meaningful user pick: + * + * - `cli-catalog` with no catalog: a CLI-catalog pick has no meaning once + * the catalog is gone. + * - `legacy-gateway` with a catalog that lacks the model under `kilo`: the + * pick was made against the fallback list before a valid catalog arrived. + * + * There is deliberately no third exception: a `cli-catalog` pick against a + * real catalog that later drops the model must keep the override, because the + * visible unavailable row plus the blocked Start is the intended signal. + */ +export function resolveNewSessionRemoteOverride( + input: ResolveNewSessionRemoteOverrideInput +): RemoteModelOverride | null { + if (input.remoteOverride) { + const staleCliCatalogPick = + input.remoteOverride.source === 'cli-catalog' && input.catalog === null; + const staleLegacyGatewayPick = + input.remoteOverride.source === 'legacy-gateway' && + input.catalog !== null && + !catalogHasKiloModel(input.catalog, input.remoteOverride.selection.model.modelID); + if (!staleCliCatalogPick && !staleLegacyGatewayPick) { + return input.remoteOverride; + } + } + + if (!input.gatewayModel) { + return null; + } + + if (input.catalog === null) { + return { + source: 'legacy-gateway', + selection: { + model: { providerID: 'kilo', modelID: input.gatewayModel }, + ...(input.gatewayVariant ? { variant: input.gatewayVariant } : {}), + }, + }; + } + + const kiloModel = input.catalog.providers + .find(provider => provider.id === 'kilo') + ?.models.find(model => model.id === input.gatewayModel); + if (!kiloModel) { + return null; + } + return { + source: 'cli-catalog', + selection: { + model: { providerID: 'kilo', modelID: input.gatewayModel }, + ...(kiloModel.variants.includes(input.gatewayVariant) + ? { variant: input.gatewayVariant } + : {}), + }, + }; +} + +function catalogHasKiloModel(catalog: RemoteModelCatalogV1, modelID: string): boolean { + const kiloProvider = catalog.providers.find(provider => provider.id === 'kilo'); + return kiloProvider?.models.some(model => model.id === modelID) ?? false; +} + +export type ResolveNewSessionModelViewInput = { + isRemoteTarget: boolean; + catalog: RemoteModelCatalogV1 | null; + catalogLoading: boolean; + gatewayModels: ModelOption[]; + gatewayModelsLoading: boolean; + gatewayModel: string; + gatewayVariant: string; + remoteOverride: RemoteModelOverride | null; +}; + +/** + * Pure projection of the new-session screen's model picker. No React. + * + * Cloud Agent (`isRemoteTarget: false`) delegates to the plain gateway + * options and the persisted gateway strings, byte-identical to today. + * + * Remote target builds a `RemoteModelState` from the catalog (v1) or the + * legacy fallback, resolves the override, and derives the wire selection from + * the freshly built option list. The wire selection comes from the built + * list, never from raw strings: an option the current catalog does not + * contain exists only as the `unavailable` placeholder, which cannot produce + * a wire model. When a valid catalog has no `defaultModel` and the gateway + * model is absent, the first catalog option is selected so the picker is + * never non-empty with nothing selected. + */ +export function resolveNewSessionModelView( + input: ResolveNewSessionModelViewInput +): NewSessionModelView { + if (!input.isRemoteTarget) { + const { options } = buildSessionModelOptions({ + activeSessionType: null, + remoteModelState: { + ownerConnectionId: null, + protocol: 'unknown', + refresh: 'idle', + }, + observedModel: null, + remoteModelOverride: null, + gatewayModels: input.gatewayModels, + gatewayModelsLoading: input.gatewayModelsLoading, + }); + return { + options, + selectedValue: input.gatewayModel, + selectedVariant: input.gatewayVariant, + isSelectionUnavailable: false, + }; + } + + const remoteModelState: RemoteModelState = input.catalog + ? { + ownerConnectionId: null, + protocol: 'v1', + catalog: input.catalog, + refresh: 'idle', + } + : { + ownerConnectionId: null, + protocol: 'legacy', + refresh: input.catalogLoading ? 'loading' : 'idle', + }; + + const remoteModelOverride = resolveNewSessionRemoteOverride({ + catalog: input.catalog, + gatewayModel: input.gatewayModel, + gatewayVariant: input.gatewayVariant, + remoteOverride: input.remoteOverride, + }); + + const delegate = buildSessionModelOptions({ + activeSessionType: 'remote', + remoteModelState, + observedModel: null, + remoteModelOverride, + gatewayModels: input.gatewayModels, + gatewayModelsLoading: input.gatewayModelsLoading, + }); + + let selectedValue = delegate.selectedValue; + if (delegate.source === 'remote-cli-catalog' && selectedValue === '') { + const firstOption = delegate.options[0]; + if (firstOption) { + // A valid catalog can carry no `defaultModel`. The first option comes + // from the catalog, so it is always valid on that instance. Do not apply + // this to the legacy fallback: "no selection" there means "let the CLI + // use its own default", which is today's behavior. + selectedValue = firstOption.id; + } + } + + const selected = delegate.options.find(option => option.id === selectedValue); + const isSelectionUnavailable = selected?.unavailable === true; + const spawnSelection = + selected?.modelRef && !isSelectionUnavailable + ? { + model: selected.modelRef, + ...(delegate.selectedVariant ? { variant: delegate.selectedVariant } : {}), + } + : undefined; + + return { + options: delegate.options, + selectedValue, + selectedVariant: delegate.selectedVariant, + spawnSelection, + isSelectionUnavailable, + }; +} diff --git a/apps/mobile/src/lib/hooks/use-instance-model-catalog.ts b/apps/mobile/src/lib/hooks/use-instance-model-catalog.ts new file mode 100644 index 0000000000..9b1e64d0db --- /dev/null +++ b/apps/mobile/src/lib/hooks/use-instance-model-catalog.ts @@ -0,0 +1,60 @@ +import { useQuery } from '@tanstack/react-query'; +import { + type InstanceModelCatalogResult, + listInstanceModels, + type RemoteModelCatalogV1, +} from '@kilocode/cloud-agent-sdk/instance-model-catalog'; + +import { useUserWebConnection } from '@/components/agents/user-web-connection-provider'; + +/** + * Fetch the model catalog of one selected CLI instance before a session + * exists. The catalog is cached per `connectionId` with a short stale time, + * never globally: a `list_models` result belongs to one instance. + * + * Retry design lives here: only the retryable `transport` outcome is turned + * into a rejection so React Query owns the retry (1 attempt) and the + * refetch-on-mount. The permanent `unsupported` (old CLI) and `invalid` + * outcomes resolve and cache; retrying them would be pure waste. + * + * React Query keeps the last successful `data` for a key when a later + * refetch fails, so once an instance has answered, a transient failure keeps + * serving that catalog instead of dropping to the gateway fallback. The + * gateway fallback therefore means "this instance has never answered", not + * "the last read failed". + */ +export function useInstanceModelCatalog(connectionId: string | null): { + catalog: RemoteModelCatalogV1 | null; + isLoading: boolean; +} { + const connection = useUserWebConnection(); + const { data, isPending } = useQuery({ + queryKey: ['instance-model-catalog', connectionId], + queryFn: async () => { + // `enabled` guarantees a non-null id; the guard narrows the type for + // the SDK call and keeps the queryFn total for the impossible case. + if (connectionId === null) { + return { ok: false, reason: 'transport' as const }; + } + const result = await listInstanceModels(connection, connectionId); + if (!result.ok && result.reason === 'transport') { + // Retryable: let React Query own the retry and the refetch-on-mount. + throw new Error('instance catalog unavailable'); + } + return result; + }, + enabled: connectionId !== null, + retry: 1, + staleTime: 30_000, + }); + + // Count models, not providers. The wire schema's per-provider `models` + // record has no minimum, so a provider with an empty `models` array is + // schema-valid and must not satisfy the guard; a catalog that projects to + // zero options belongs on the gateway fallback, not in an empty picker. + const hasModel = + data?.ok === true && data.catalog.providers.some(provider => provider.models.length > 0); + const catalog = hasModel ? data.catalog : null; + + return { catalog, isLoading: connectionId !== null && isPending }; +} From e27be1c84c20cfc8413b3ab7f18efc4a221edff2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 7 Aug 2026 19:51:27 +0200 Subject: [PATCH 06/13] feat(mobile): validate continued remote models --- .../components/agents/continuation-seed.ts | 110 ++++++- .../resolve-continue-remote-model.test.ts | 290 +++++++++++++++--- .../components/agents/use-continue-session.ts | 25 +- 3 files changed, 359 insertions(+), 66 deletions(-) diff --git a/apps/mobile/src/components/agents/continuation-seed.ts b/apps/mobile/src/components/agents/continuation-seed.ts index f8b1d8a98f..544df50bcc 100644 --- a/apps/mobile/src/components/agents/continuation-seed.ts +++ b/apps/mobile/src/components/agents/continuation-seed.ts @@ -1,4 +1,13 @@ -import { type Part, type StoredMessage, type TextPart } from '@kilocode/cloud-agent-sdk'; +import { + type ModelSelection, + type Part, + type StoredMessage, + type TextPart, +} from '@kilocode/cloud-agent-sdk'; +import { + type InstanceModelCatalogResult, + type RemoteModelCatalogV1, +} from '@kilocode/cloud-agent-sdk/instance-model-catalog'; import { normalizeAgentMode } from '@/components/agents/mode-options'; import { buildContinuePrefillParams, @@ -6,6 +15,11 @@ import { resolvePrefillModel, resolvePrefillRepo, } from '@/components/agents/new-session-prefill'; +import { type SessionModelOption } from '@/lib/hooks/use-session-model-options'; +import { + buildCreateRemoteSessionInput, + type CreateRemoteSessionInput, +} from '@/lib/hooks/remote-instance-spawn-classifier'; import { type InstancePickerInstance } from '@/lib/picker-bridge'; export const CONTINUATION_SEED_MAX_CHARS = 3800; @@ -111,26 +125,88 @@ export type ContinuationDestination = | { kind: 'remote'; instance: InstancePickerInstance }; /** - * Validate a stored model against the current gateway catalog. + * Resolve the stored model + variant of a continued session against the + * target instance's model catalog. + * + * Returns a `ModelSelection` only when the selection is valid on the target: + * + * - The stored model must exist in `options`, the source session's picker + * options. A plain gateway option has no `modelRef`; its `id` is the + * gateway model id, so the selection defaults to the `kilo` provider. + * - A non-empty variant must be offered by the source option; otherwise the + * whole selection is dropped, keeping today's "never silently change a + * variant" behavior. + * - With a catalog, the provider and model must exist in it, and a set + * variant must be offered by that catalog model. + * - Without a catalog (an old CLI, or a CLI whose catalog could not be + * read), only a `kilo` selection is sent; an unvalidated non-Kilo provider + * is omitted rather than guessed. * - * Returns the original model + variant when present and valid. Returns empty - * strings when the model is absent or the variant is not in its variant list, - * so the caller can omit the model override and let the remote CLI use its - * default. + * Returns `undefined` when the selection must be omitted so the CLI uses its + * own default model. */ -export function resolveContinueRemoteModel( - model: string, - variant: string, - catalog: { id: string; variants: string[] }[] -): { model: string; variant: string } { - const found = catalog.find(m => m.id === model); - if (!found) { - return { model: '', variant: '' }; +export function resolveContinueRemoteSelection(input: { + model: string; + variant: string; + options: SessionModelOption[]; + catalog: RemoteModelCatalogV1 | null; +}): ModelSelection | undefined { + const { model, variant, options, catalog } = input; + const option = options.find(o => o.id === model); + if (!option) { + return undefined; } - if (variant && !found.variants.includes(variant)) { - return { model: '', variant: '' }; + if (variant && !option.variants.includes(variant)) { + return undefined; } - return { model, variant }; + const ref = option.modelRef ?? { providerID: 'kilo', modelID: option.id }; + if (catalog !== null) { + const catalogModel = catalog.providers + .find(provider => provider.id === ref.providerID) + ?.models.find(m => m.id === ref.modelID); + if (!catalogModel || (variant && !catalogModel.variants.includes(variant))) { + return undefined; + } + } else if (ref.providerID !== 'kilo') { + return undefined; + } + return { model: ref, ...(variant ? { variant } : {}) }; +} + +/** + * Assemble the `create_session` wire input for a continued remote session. + * + * Normalizes the catalog result with the same model-count rule as the + * new-session hook: a parsed catalog counts only when it carries at least one + * model; a catalog with no models is treated as "no catalog". Then resolves + * the stored selection against it and delegates to + * `buildCreateRemoteSessionInput`. Pure so the continue hook keeps no + * catalog logic and the behavior is testable without mounting the hook. + */ +export function buildContinueRemoteSpawnInput(input: { + mode: string; + model: string; + variant: string; + options: SessionModelOption[]; + catalogResult: InstanceModelCatalogResult; + organizationId: string | undefined; +}): CreateRemoteSessionInput | undefined { + const catalog = + input.catalogResult.ok && + input.catalogResult.catalog.providers.some(provider => provider.models.length > 0) + ? input.catalogResult.catalog + : null; + const selection = resolveContinueRemoteSelection({ + model: input.model, + variant: input.variant, + options: input.options, + catalog, + }); + return buildCreateRemoteSessionInput({ + mode: input.mode, + selection, + organizationId: input.organizationId, + }); } export function resolveContinuationDestinations(args: { diff --git a/apps/mobile/src/components/agents/resolve-continue-remote-model.test.ts b/apps/mobile/src/components/agents/resolve-continue-remote-model.test.ts index ddd44ee293..a6daeb281c 100644 --- a/apps/mobile/src/components/agents/resolve-continue-remote-model.test.ts +++ b/apps/mobile/src/components/agents/resolve-continue-remote-model.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it, vi } from 'vitest'; -import { resolveContinueRemoteModel } from './continuation-seed'; +import { + type InstanceModelCatalogResult, + type RemoteModelCatalogV1, +} from '@kilocode/cloud-agent-sdk/instance-model-catalog'; +import { type SessionModelOption } from '@/lib/hooks/use-session-model-options'; + +import { buildContinueRemoteSpawnInput, resolveContinueRemoteSelection } from './continuation-seed'; vi.mock('lucide-react-native', () => ({ Bug: 'Bug', @@ -10,66 +16,274 @@ vi.mock('lucide-react-native', () => ({ Workflow: 'Workflow', })); -const CATALOG = [ - { id: 'model-a', variants: ['v1', 'v2'] }, - { id: 'model-b', variants: [] }, - { id: 'model-c', variants: ['latest'] }, -]; +const GATEWAY_OPTION: SessionModelOption = { + id: 'gateway-model-a', + name: 'Gateway Model A', + displayId: 'gateway-model-a', + variants: ['v1', 'v2'], + isPreferred: true, + showGatewayMetadata: true, +}; + +const CLI_OPTION: SessionModelOption = { + id: 'remote-model-0', + name: 'Claude from CLI', + displayId: 'anthropic/claude-x', + variants: ['low', 'high'], + isPreferred: false, + provider: { id: 'anthropic', name: 'Anthropic' }, + modelRef: { providerID: 'anthropic', modelID: 'claude-x' }, + overrideSource: 'cli-catalog', + showGatewayMetadata: false, +}; + +const OPTIONS: SessionModelOption[] = [GATEWAY_OPTION, CLI_OPTION]; + +const CATALOG: RemoteModelCatalogV1 = { + protocolVersion: 1, + providers: [ + { + id: 'kilo', + name: 'Kilo', + models: [ + { + id: 'gateway-model-a', + variants: ['v1', 'v2'], + capabilities: { attachment: true, reasoning: true }, + limits: { context: 200_000, output: 32_000 }, + }, + ], + }, + { + id: 'anthropic', + name: 'Anthropic', + models: [ + { + id: 'claude-x', + variants: ['low', 'high'], + capabilities: { attachment: true, reasoning: true }, + limits: { context: 200_000, output: 32_000 }, + }, + ], + }, + ], + truncated: false, +}; + +function catalogWithoutAnthropic(): RemoteModelCatalogV1 { + return { + ...CATALOG, + providers: CATALOG.providers.filter(provider => provider.id !== 'anthropic'), + }; +} + +function catalogWithTargetVariants(variants: string[]): RemoteModelCatalogV1 { + return { + ...CATALOG, + providers: CATALOG.providers.map(provider => + provider.id === 'anthropic' + ? { + ...provider, + models: provider.models.map(model => ({ ...model, variants })), + } + : provider + ), + }; +} + +describe('resolveContinueRemoteSelection', () => { + it('returns undefined for a model that is not in the source options', () => { + expect( + resolveContinueRemoteSelection({ + model: 'model-unknown', + variant: 'v1', + options: OPTIONS, + catalog: null, + }) + ).toBeUndefined(); + }); -describe('resolveContinueRemoteModel', () => { - it('returns the model and variant when both are in the catalog', () => { - expect(resolveContinueRemoteModel('model-a', 'v1', CATALOG)).toEqual({ - model: 'model-a', + it('returns undefined when the variant is not offered by the source option', () => { + expect( + resolveContinueRemoteSelection({ + model: 'gateway-model-a', + variant: 'v99', + options: OPTIONS, + catalog: null, + }) + ).toBeUndefined(); + }); + + it('returns the kilo selection for a gateway option when no catalog exists', () => { + expect( + resolveContinueRemoteSelection({ + model: 'gateway-model-a', + variant: 'v1', + options: OPTIONS, + catalog: null, + }) + ).toEqual({ + model: { providerID: 'kilo', modelID: 'gateway-model-a' }, variant: 'v1', }); }); - it('returns the model and empty variant when variant is empty and model is in catalog', () => { - expect(resolveContinueRemoteModel('model-a', '', CATALOG)).toEqual({ - model: 'model-a', - variant: '', + it('returns the selection without a variant when the stored variant is empty', () => { + expect( + resolveContinueRemoteSelection({ + model: 'gateway-model-a', + variant: '', + options: OPTIONS, + catalog: null, + }) + ).toEqual({ model: { providerID: 'kilo', modelID: 'gateway-model-a' } }); + }); + + it('returns the CLI-catalog selection when the target catalog has the model', () => { + expect( + resolveContinueRemoteSelection({ + model: 'remote-model-0', + variant: 'low', + options: OPTIONS, + catalog: CATALOG, + }) + ).toEqual({ + model: { providerID: 'anthropic', modelID: 'claude-x' }, + variant: 'low', + }); + }); + + it('returns undefined when the target catalog lacks the CLI-catalog provider', () => { + expect( + resolveContinueRemoteSelection({ + model: 'remote-model-0', + variant: 'low', + options: OPTIONS, + catalog: catalogWithoutAnthropic(), + }) + ).toBeUndefined(); + }); + + it('returns undefined for a non-kilo option when no catalog exists', () => { + expect( + resolveContinueRemoteSelection({ + model: 'remote-model-0', + variant: 'low', + options: OPTIONS, + catalog: null, + }) + ).toBeUndefined(); + }); + + it('returns undefined when the variant is absent from the target catalog model', () => { + expect( + resolveContinueRemoteSelection({ + model: 'remote-model-0', + variant: 'low', + options: OPTIONS, + catalog: catalogWithTargetVariants(['high']), + }) + ).toBeUndefined(); + }); +}); + +describe('buildContinueRemoteSpawnInput', () => { + const baseInput = { + mode: 'code', + options: OPTIONS, + organizationId: undefined as string | undefined, + }; + + it('sends a validated non-kilo selection with its variant', () => { + const result = buildContinueRemoteSpawnInput({ + ...baseInput, + model: 'remote-model-0', + variant: 'low', + catalogResult: { ok: true, catalog: CATALOG }, + }); + expect(result).toEqual({ + agent: 'code', + model: { providerID: 'anthropic', modelID: 'claude-x', variant: 'low' }, }); }); - it('returns empty when the model is not in the catalog', () => { - expect(resolveContinueRemoteModel('model-unknown', 'v1', CATALOG)).toEqual({ - model: '', - variant: '', + it('omits the model on an unsupported (old CLI) catalog read', () => { + const result = buildContinueRemoteSpawnInput({ + ...baseInput, + model: 'remote-model-0', + variant: 'low', + catalogResult: { ok: false, reason: 'unsupported' }, }); + expect(result).toEqual({ agent: 'code' }); }); - it('returns empty when the variant is not in the model variant list', () => { - expect(resolveContinueRemoteModel('model-a', 'v99', CATALOG)).toEqual({ - model: '', - variant: '', + it('omits the model on a transport catalog failure', () => { + const result = buildContinueRemoteSpawnInput({ + ...baseInput, + model: 'remote-model-0', + variant: 'low', + catalogResult: { ok: false, reason: 'transport' }, }); + expect(result).toEqual({ agent: 'code' }); }); - it('returns empty when the catalog is empty', () => { - expect(resolveContinueRemoteModel('model-a', 'v1', [])).toEqual({ - model: '', - variant: '', + it('keeps today wire for a kilo gateway option when no catalog is available', () => { + const result = buildContinueRemoteSpawnInput({ + ...baseInput, + model: 'gateway-model-a', + variant: 'v1', + catalogResult: { ok: false, reason: 'unsupported' }, + }); + expect(result).toEqual({ + agent: 'code', + model: { providerID: 'kilo', modelID: 'gateway-model-a', variant: 'v1' }, }); }); - it('returns the model when variant is empty and model has no variants', () => { - expect(resolveContinueRemoteModel('model-b', '', CATALOG)).toEqual({ - model: 'model-b', - variant: '', + it('treats a parsed catalog with no models as no catalog', () => { + const emptyCatalogResult: InstanceModelCatalogResult = { + ok: true, + catalog: { protocolVersion: 1, providers: [], truncated: false }, + }; + const result = buildContinueRemoteSpawnInput({ + ...baseInput, + model: 'remote-model-0', + variant: 'low', + catalogResult: emptyCatalogResult, }); + expect(result).toEqual({ agent: 'code' }); }); - it('returns empty when variant is non-empty but model has no variants', () => { - expect(resolveContinueRemoteModel('model-b', 'any', CATALOG)).toEqual({ - model: '', - variant: '', + it('keeps today wire for a kilo gateway option when a parsed catalog has no models', () => { + const emptyCatalogResult: InstanceModelCatalogResult = { + ok: true, + catalog: { protocolVersion: 1, providers: [], truncated: false }, + }; + const result = buildContinueRemoteSpawnInput({ + ...baseInput, + model: 'gateway-model-a', + variant: 'v1', + catalogResult: emptyCatalogResult, + }); + expect(result).toEqual({ + agent: 'code', + model: { providerID: 'kilo', modelID: 'gateway-model-a', variant: 'v1' }, }); }); - it('returns empty model and variant when both are empty strings (empty-source behavior)', () => { - expect(resolveContinueRemoteModel('', '', CATALOG)).toEqual({ - model: '', - variant: '', + it('carries the organization id through', () => { + const result = buildContinueRemoteSpawnInput({ + mode: 'code', + model: 'gateway-model-a', + variant: 'v1', + options: OPTIONS, + catalogResult: { ok: false, reason: 'unsupported' }, + organizationId: 'org-1', + }); + expect(result).toEqual({ + agent: 'code', + model: { providerID: 'kilo', modelID: 'gateway-model-a', variant: 'v1' }, + orgId: 'org-1', }); }); }); diff --git a/apps/mobile/src/components/agents/use-continue-session.ts b/apps/mobile/src/components/agents/use-continue-session.ts index 3d3860b7fe..5874d8496d 100644 --- a/apps/mobile/src/components/agents/use-continue-session.ts +++ b/apps/mobile/src/components/agents/use-continue-session.ts @@ -7,12 +7,13 @@ import { useStore } from 'jotai'; import { toast } from 'sonner-native'; import { generateMessageId } from '@kilocode/cloud-agent-sdk/message-id'; import * as Haptics from 'expo-haptics'; +import { listInstanceModels } from '@kilocode/cloud-agent-sdk/instance-model-catalog'; import { buildContinuationSeed, + buildContinueRemoteSpawnInput, type ContinuationDestination, resolveContinuationDestinations, - resolveContinueRemoteModel, } from '@/components/agents/continuation-seed'; import { normalizeAgentMode } from '@/components/agents/mode-options'; import { @@ -25,12 +26,11 @@ import { getSpawnedAgentSessionPath, } from '@/components/agents/session-detail-routes'; import { type useSessionManager } from '@/components/agents/session-provider'; +import { useUserWebConnection } from '@/components/agents/user-web-connection-provider'; +import { type SessionModelOption } from '@/lib/hooks/use-session-model-options'; import { putSharePayload } from '@/lib/share-payload'; import { appendShareParams } from '@/lib/share-navigation'; -import { - buildCreateRemoteSessionInput, - useRemoteInstanceSpawn, -} from '@/lib/hooks/use-remote-instance-spawn'; +import { useRemoteInstanceSpawn } from '@/lib/hooks/use-remote-instance-spawn'; import { REMOTE_SPAWN_NON_RETRYABLE_TOAST, REMOTE_SPAWN_RETRYABLE_TOAST, @@ -48,7 +48,7 @@ type InstancesResult = RouterOutputs['activeSessions']['listInstances']; export function useContinueSession(args: { organizationId: string | undefined; manager: ReturnType; - models: { id: string; variants: string[] }[]; + models: SessionModelOption[]; modelsLoading: boolean; }): { continueSession: (input: { @@ -63,6 +63,7 @@ export function useContinueSession(args: { const queryClient = useQueryClient(); const trpc = useTRPC(); const store = useStore(); + const connection = useUserWebConnection(); const { showActionSheetWithOptions } = useActionSheet(); const { spawn } = useRemoteInstanceSpawn(args.organizationId ?? null); const [isContinuing, setIsContinuing] = useState(false); @@ -112,13 +113,15 @@ export function useContinueSession(args: { } return; } - const remoteModel = resolveContinueRemoteModel(fields.model, fields.variant, args.models); + const catalogResult = await listInstanceModels(connection, dest.instance.connectionId); const outcome = await spawn( dest.instance.connectionId, - buildCreateRemoteSessionInput({ + buildContinueRemoteSpawnInput({ mode: fields.mode, - model: remoteModel.model, - variant: remoteModel.variant, + model: fields.model, + variant: fields.variant, + options: args.models, + catalogResult, organizationId: args.organizationId, }) ); @@ -143,7 +146,7 @@ export function useContinueSession(args: { setIsContinuing(false); } }, - [args.organizationId, args.models, router, runCloudCreate, spawn] + [args.organizationId, args.models, connection, router, runCloudCreate, spawn] ); const fallback = useCallback( From 03952a8718b9863c845ece0b6849ddf30fae9f5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 7 Aug 2026 19:51:29 +0200 Subject: [PATCH 07/13] feat(mobile): use instance catalogs in new sessions --- apps/mobile/src/app/(app)/agent-chat/new.tsx | 67 +++++++- .../src/components/agents/chat-toolbar.tsx | 6 +- .../agents/new-session-configure-form.tsx | 7 +- .../agents/new-session-model-provider.tsx | 2 +- .../components/agents/new-session-prompt.tsx | 6 +- .../agents/use-remote-spawn-dispatch.test.ts | 152 ++++++++++-------- .../agents/use-remote-spawn-dispatch.ts | 41 +++-- .../src/lib/use-new-session-share-remote.ts | 9 ++ 8 files changed, 183 insertions(+), 107 deletions(-) diff --git a/apps/mobile/src/app/(app)/agent-chat/new.tsx b/apps/mobile/src/app/(app)/agent-chat/new.tsx index 099ae0cf3c..35a28e659b 100644 --- a/apps/mobile/src/app/(app)/agent-chat/new.tsx +++ b/apps/mobile/src/app/(app)/agent-chat/new.tsx @@ -3,8 +3,10 @@ import { View } from 'react-native'; import { useLocalSearchParams } from 'expo-router'; import { useActionSheet } from '@expo/react-native-action-sheet'; import { useQuery } from '@tanstack/react-query'; +import { type RemoteModelOverride } from '@kilocode/cloud-agent-sdk'; import { NewSessionConfigureForm } from '@/components/agents/new-session-configure-form'; +import { resolveNewSessionModelView } from '@/components/agents/new-session-model-view'; import { useNewSessionCreator } from '@/components/agents/use-new-session-creator'; import { NewSessionModelProvider, @@ -16,10 +18,12 @@ import { ScreenHeader } from '@/components/screen-header'; import { AGENT_ATTACHMENT_MAX_FILES } from '@/lib/agent-attachments/constants'; import { useAgentAttachmentUpload } from '@/lib/agent-attachments/use-agent-attachment-upload'; import { useAvailableModels } from '@/lib/hooks/use-available-models'; +import { useInstanceModelCatalog } from '@/lib/hooks/use-instance-model-catalog'; import { useModelPreferences } from '@/lib/hooks/use-model-preferences'; import { usePersistedAgentModel } from '@/lib/hooks/use-persisted-agent-model'; +import { createRemoteModelOverride } from '@/lib/hooks/use-session-model-options'; import { resolveNewSessionSubmitDisabled } from '@/lib/new-session-submit'; -import { type InstancePickerInstance } from '@/lib/picker-bridge'; +import { type InstancePickerInstance, type ModelPickerSelection } from '@/lib/picker-bridge'; import { shouldShowRunOnSelector } from '@/lib/should-show-run-on-selector'; import { useNewSessionShareRemote } from '@/lib/use-new-session-share-remote'; import { useNewSessionRepos } from '@/lib/use-new-session-repos'; @@ -46,6 +50,7 @@ function NewSessionScreenBody() { const shareId: string | undefined = Array.isArray(shareIdParam) ? shareIdParam[0] : shareIdParam; const [runOnInstance, setRunOnInstance] = useState(null); + const [remoteOverride, setRemoteOverride] = useState(null); const [isCreating, setIsCreating] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const [hasPrompt, setHasPrompt] = useState(false); @@ -60,6 +65,30 @@ function NewSessionScreenBody() { isError: isModelsError, refetch: refetchModels, } = useAvailableModels(organizationId); + const instanceCatalog = useInstanceModelCatalog(runOnInstance?.connectionId ?? null); + const modelView = useMemo( + () => + resolveNewSessionModelView({ + isRemoteTarget: runOnInstance !== null, + catalog: instanceCatalog.catalog, + catalogLoading: instanceCatalog.isLoading, + gatewayModels: models, + gatewayModelsLoading: isLoadingModels, + gatewayModel: model, + gatewayVariant: variant, + remoteOverride, + }), + [ + runOnInstance, + instanceCatalog.catalog, + instanceCatalog.isLoading, + models, + isLoadingModels, + model, + variant, + remoteOverride, + ] + ); const { setLastSelected: persistServerLastSelected } = useModelPreferences(organizationId); const { saveModel } = usePersistedAgentModel(); const attachments = useAgentAttachmentUpload({ organizationId }); @@ -115,10 +144,20 @@ function NewSessionScreenBody() { instanceList, promptRef, attachments: attachments.attachments, + selection: modelView.spawnSelection, }); const handleModelSelect = useCallback( - (modelId: string, newVariant: string) => { + (modelId: string, newVariant: string, pickerSelection?: ModelPickerSelection) => { + setRemoteOverride( + pickerSelection ? createRemoteModelOverride(pickerSelection.option, newVariant) : null + ); + // A CLI-catalog option id is the opaque `remote-model-N`; its model may + // not exist on the gateway. Never persist either to the gateway + // preference. + if (pickerSelection?.option.overrideSource === 'cli-catalog') { + return; + } setModel(modelId); setVariant(newVariant); saveModel(organizationId, { model: modelId, variant: newVariant }); @@ -127,6 +166,14 @@ function NewSessionScreenBody() { [organizationId, saveModel, persistServerLastSelected, setModel, setVariant] ); + const handleRunOnChange = useCallback( + (next: InstancePickerInstance | null) => { + setRemoteOverride(null); + handleRunOnInstanceChange(next); + }, + [handleRunOnInstanceChange] + ); + function handlePromptChange(text: string) { promptRef.current = text; const nextHasPrompt = text.trim().length > 0; @@ -170,7 +217,11 @@ function NewSessionScreenBody() { const isRemoteTargetSelected = runOnInstance !== null; const isStartDisabled = isRemoteTargetSelected - ? remoteSpawn.isSpawningRemote || isSubmitting || attachments.hasFailedAttachments + ? remoteSpawn.isSpawningRemote || + isSubmitting || + attachments.hasFailedAttachments || + modelView.isSelectionUnavailable || + instanceCatalog.isLoading : resolveNewSessionSubmitDisabled({ attachmentsHasFailed: attachments.hasFailedAttachments, attachmentsIsUploading: attachments.isUploading, @@ -201,11 +252,11 @@ function NewSessionScreenBody() { attachmentMax={AGENT_ATTACHMENT_MAX_FILES} isCreating={isCreating} isModelsError={isModelsError} - isLoadingModels={isLoadingModels} + isLoadingModels={isLoadingModels || instanceCatalog.isLoading} mode={mode} - model={model} - variant={variant} - modelOptions={models} + model={modelView.selectedValue} + variant={modelView.selectedVariant} + modelOptions={modelView.options} initialPrompt={promptRef.current} onChangeText={handlePromptChange} onModeChange={setMode} @@ -221,7 +272,7 @@ function NewSessionScreenBody() { runOnInstance={runOnInstance} instanceList={instanceList} isLoadingInstances={isLoadingInstances} - onChangeRunOnInstance={handleRunOnInstanceChange} + onChangeRunOnInstance={handleRunOnChange} showInstanceDisconnectedNote={remoteSpawn.showInstanceDisconnectedNote} view={view} isRetrying={isRetrying} diff --git a/apps/mobile/src/components/agents/chat-toolbar.tsx b/apps/mobile/src/components/agents/chat-toolbar.tsx index a14ae8044b..3eadf6aa05 100644 --- a/apps/mobile/src/components/agents/chat-toolbar.tsx +++ b/apps/mobile/src/components/agents/chat-toolbar.tsx @@ -4,6 +4,8 @@ import { ComposerPasteButton } from '@/components/agents/composer-paste-button'; import { type AgentMode, ModeSelector } from '@/components/agents/mode-selector'; import { ModelSelector } from '@/components/agents/model-selector'; import { type ModelOption } from '@/lib/hooks/use-available-models'; +import { type SessionModelOption } from '@/lib/hooks/use-session-model-options'; +import { type ModelPickerSelection } from '@/lib/picker-bridge'; import { cn } from '@/lib/utils'; type ChatToolbarOrder = 'mode-first' | 'model-first'; @@ -13,8 +15,8 @@ type ChatToolbarProps = { onModeChange: (mode: AgentMode) => void; model: string; variant: string; - modelOptions: ModelOption[]; - onModelSelect: (modelId: string, variant: string) => void; + modelOptions: (ModelOption | SessionModelOption)[]; + onModelSelect: (modelId: string, variant: string, pickerSelection?: ModelPickerSelection) => void; disabled?: boolean; isLoadingModels?: boolean; order?: ChatToolbarOrder; diff --git a/apps/mobile/src/components/agents/new-session-configure-form.tsx b/apps/mobile/src/components/agents/new-session-configure-form.tsx index e94141b9a8..ce2eb2a0d9 100644 --- a/apps/mobile/src/components/agents/new-session-configure-form.tsx +++ b/apps/mobile/src/components/agents/new-session-configure-form.tsx @@ -13,8 +13,9 @@ import { type AgentAttachmentCandidate, } from '@/lib/agent-attachments/use-agent-attachment-upload'; import { type ModelOption } from '@/lib/hooks/use-available-models'; +import { type SessionModelOption } from '@/lib/hooks/use-session-model-options'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; -import { type InstancePickerInstance } from '@/lib/picker-bridge'; +import { type InstancePickerInstance, type ModelPickerSelection } from '@/lib/picker-bridge'; import { REMOTE_SPAWN_INSTANCE_DISCONNECTED_NOTE } from '@/lib/remote-submit-outcome'; type NewSessionConfigureFormProps = { @@ -27,10 +28,10 @@ type NewSessionConfigureFormProps = { mode: AgentMode; model: string; variant: string; - modelOptions: ModelOption[]; + modelOptions: (ModelOption | SessionModelOption)[]; onChangeText: (text: string) => void; onModeChange: (mode: AgentMode) => void; - onModelSelect: (modelId: string, variant: string) => void; + onModelSelect: (modelId: string, variant: string, pickerSelection?: ModelPickerSelection) => void; onAddAttachment: () => void; onRemoveAttachment: (id: string) => void; onRetryAttachment: (id: string) => void; diff --git a/apps/mobile/src/components/agents/new-session-model-provider.tsx b/apps/mobile/src/components/agents/new-session-model-provider.tsx index 92e135fde1..d4f5e1a2e2 100644 --- a/apps/mobile/src/components/agents/new-session-model-provider.tsx +++ b/apps/mobile/src/components/agents/new-session-model-provider.tsx @@ -63,7 +63,7 @@ export function NewSessionModelProvider({ ); return ( - + {children} ); diff --git a/apps/mobile/src/components/agents/new-session-prompt.tsx b/apps/mobile/src/components/agents/new-session-prompt.tsx index 7e360bef02..2685076ebf 100644 --- a/apps/mobile/src/components/agents/new-session-prompt.tsx +++ b/apps/mobile/src/components/agents/new-session-prompt.tsx @@ -24,7 +24,9 @@ import { useTextHeight } from '@/components/agents/use-text-height'; import { resolveNewSessionPromptControlState } from '@/components/agents/new-session-prompt-state'; import { QueryError } from '@/components/query-error'; import { type ModelOption } from '@/lib/hooks/use-available-models'; +import { type SessionModelOption } from '@/lib/hooks/use-session-model-options'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { type ModelPickerSelection } from '@/lib/picker-bridge'; import { useSharePrefill } from '@/lib/share-prefill'; import { cn } from '@/lib/utils'; import { applyVoiceDraftToInput } from '@/lib/voice-input/voice-input-draft'; @@ -66,10 +68,10 @@ type NewSessionPromptProps = { mode: AgentMode; model: string; variant: string; - modelOptions: ModelOption[]; + modelOptions: (ModelOption | SessionModelOption)[]; onChangeText: (text: string) => void; onModeChange: (mode: AgentMode) => void; - onModelSelect: (modelId: string, variant: string) => void; + onModelSelect: (modelId: string, variant: string, pickerSelection?: ModelPickerSelection) => void; onAddAttachment: () => void; onRemoveAttachment: (id: string) => void; onRetryAttachment: (id: string) => void; diff --git a/apps/mobile/src/components/agents/use-remote-spawn-dispatch.test.ts b/apps/mobile/src/components/agents/use-remote-spawn-dispatch.test.ts index 758976e0c4..ab07cb2a34 100644 --- a/apps/mobile/src/components/agents/use-remote-spawn-dispatch.test.ts +++ b/apps/mobile/src/components/agents/use-remote-spawn-dispatch.test.ts @@ -1,5 +1,6 @@ import * as React from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { type ModelSelection } from '@kilocode/cloud-agent-sdk'; import { type InstancePickerInstance } from '@/lib/picker-bridge'; import { @@ -82,6 +83,26 @@ const INSTANCE: InstancePickerInstance = { /** Stub payload for the ready-path-with-payload case. */ const samplePayload: SharePayload = { text: 'hello', files: [], failedFiles: [] }; +/** + * Runs `onStart` and returns the arguments the spawn mock was called with. + * Extracts the wait-and-capture boilerplate shared by the spawn-input tests. + */ +async function captureSpawnCall(onStart: () => void) { + onStart(); + await vi.waitFor(() => { + expect(spawnMock).toHaveBeenCalled(); + }); + return spawnMock.mock.calls[0]; +} + +/** Runs `onStart` and waits for the ready-path navigation to the spawned session. */ +async function runStartAndWaitForReplace(onStart: () => void) { + onStart(); + await vi.waitFor(() => { + expect(routerReplace).toHaveBeenCalled(); + }); +} + /** * Minimal React hook runner. Mirrors the fake-dispatcher pattern in * `use-interaction-handlers.test.ts` so we can exercise @@ -90,13 +111,10 @@ const samplePayload: SharePayload = { text: 'hello', files: [], failedFiles: [] function runHookWithProvider(args: { organizationId: string | undefined; mode?: string; - model?: string; - variant?: string; + selection?: ModelSelection; /** When false, omit the Provider — inheritance must not leak fields. */ withProvider?: boolean; providerMode?: string; - providerModel?: string; - providerVariant?: string; getSubmitPayload?: () => SharePayload | null; }) { const reactInternals = React as typeof React & ReactInternals; @@ -104,7 +122,7 @@ function runHookWithProvider(args: { const refs: { current: unknown }[] = []; let hookIndex = 0; let refIndex = 0; - let contextValue: { mode?: string; model?: string; variant?: string } = {}; + let contextValue: { mode?: string } = {}; const dispatcher: HookDispatcher = { useCallback: hookCallback => { @@ -133,9 +151,7 @@ function runHookWithProvider(args: { useState: initialValue => { const stateIndex = hookIndex; hookIndex += 1; - if (hookState[stateIndex] === undefined) { - hookState[stateIndex] = initialValue; - } + hookState[stateIndex] ??= initialValue; const setState = ( value: typeof initialValue | ((previous: typeof initialValue) => typeof initialValue) ) => { @@ -151,11 +167,7 @@ function runHookWithProvider(args: { }; if (args.withProvider !== false) { - contextValue = { - mode: args.providerMode, - model: args.providerModel, - variant: args.providerVariant, - }; + contextValue = { mode: args.providerMode }; } const previousDispatcher = @@ -168,8 +180,7 @@ function runHookWithProvider(args: { return mountDispatch({ organizationId: args.organizationId, mode: args.mode, - model: args.model, - variant: args.variant, + selection: args.selection, runOnInstance: INSTANCE, // eslint-disable-next-line no-empty-function -- no-op setter for harness setRunOnInstance: (_next: InstancePickerInstance | null) => {}, @@ -192,25 +203,21 @@ describe('useRemoteSpawnDispatch spawn input chain', () => { __resetSharePayloadStoreForTests(); }); - it('onStart builds agent/model/variant/orgId from inheritance provider fields', async () => { + it('onStart builds agent from inherited mode and wire model from selection', async () => { const { onStart } = runHookWithProvider({ organizationId: 'org-xyz', - withProvider: true, providerMode: 'plan', - providerModel: 'kilo-auto/efficient', - providerVariant: 'medium', + selection: { model: { providerID: 'anthropic', modelID: 'claude-x' }, variant: 'high' }, }); - onStart(); - await vi.waitFor(() => { - expect(spawnMock).toHaveBeenCalled(); - }); - - expect(spawnMock).toHaveBeenCalledWith('conn-abc', { - agent: 'plan', - model: { providerID: 'kilo', modelID: 'kilo-auto/efficient', variant: 'medium' }, - orgId: 'org-xyz', - }); + expect(await captureSpawnCall(onStart)).toEqual([ + 'conn-abc', + { + agent: 'plan', + model: { providerID: 'anthropic', modelID: 'claude-x', variant: 'high' }, + orgId: 'org-xyz', + }, + ]); }); it('onStart without inheritance yields org-only input — empty context regression', async () => { @@ -219,36 +226,21 @@ describe('useRemoteSpawnDispatch spawn input chain', () => { withProvider: false, }); - onStart(); - await vi.waitFor(() => { - expect(spawnMock).toHaveBeenCalled(); - }); - - expect(spawnMock).toHaveBeenCalledWith('conn-abc', { orgId: 'org-xyz' }); + expect(await captureSpawnCall(onStart)).toEqual(['conn-abc', { orgId: 'org-xyz' }]); }); - it('explicit mode/model/variant args win over empty context', async () => { + it('explicit mode and selection args win over empty context', async () => { const { onStart } = runHookWithProvider({ organizationId: undefined, withProvider: false, mode: 'code', - model: 'anthropic/claude-sonnet-4', - variant: 'high', - }); - - onStart(); - await vi.waitFor(() => { - expect(spawnMock).toHaveBeenCalled(); + selection: { model: { providerID: 'anthropic', modelID: 'claude-sonnet-4' } }, }); - expect(spawnMock).toHaveBeenCalledWith( + expect(await captureSpawnCall(onStart)).toEqual([ 'conn-abc', - buildCreateRemoteSessionInput({ - mode: 'code', - model: 'anthropic/claude-sonnet-4', - variant: 'high', - }) - ); + { agent: 'code', model: { providerID: 'anthropic', modelID: 'claude-sonnet-4' } }, + ]); }); it('org route passes the route org into useRemoteInstanceSpawn (not inherit)', () => { @@ -261,23 +253,52 @@ describe('useRemoteSpawnDispatch spawn input chain', () => { expect(useRemoteInstanceSpawnMock).toHaveBeenCalledWith(null); }); - it('personal-route onStart omits orgId even when only mode/model are set', async () => { + it('personal-route onStart omits orgId when only mode and selection are set', async () => { const { onStart } = runHookWithProvider({ organizationId: undefined, withProvider: false, mode: 'code', - model: 'kilo-auto/efficient', + selection: { model: { providerID: 'kilo', modelID: 'kilo-auto/efficient' } }, }); - onStart(); - await vi.waitFor(() => { - expect(spawnMock).toHaveBeenCalled(); + expect(await captureSpawnCall(onStart)).toEqual([ + 'conn-abc', + { + agent: 'code', + model: { providerID: 'kilo', modelID: 'kilo-auto/efficient' }, + }, + ]); + }); + + it('a non-kilo selection reaches spawn as the provider own model with its variant', async () => { + const { onStart } = runHookWithProvider({ + organizationId: 'org-xyz', + withProvider: false, + mode: 'code', + selection: { model: { providerID: 'opencode', modelID: 'opencode-model' }, variant: 'xhigh' }, }); - expect(spawnMock).toHaveBeenCalledWith('conn-abc', { - agent: 'code', - model: { providerID: 'kilo', modelID: 'kilo-auto/efficient' }, + expect(await captureSpawnCall(onStart)).toEqual([ + 'conn-abc', + { + agent: 'code', + model: { providerID: 'opencode', modelID: 'opencode-model', variant: 'xhigh' }, + orgId: 'org-xyz', + }, + ]); + }); + + it('an omitted selection reaches spawn with no model key at all', async () => { + const { onStart } = runHookWithProvider({ + organizationId: 'org-xyz', + withProvider: false, + mode: 'code', }); + + expect(await captureSpawnCall(onStart)).toEqual([ + 'conn-abc', + { agent: 'code', orgId: 'org-xyz' }, + ]); }); it('ready path stages the press-time payload and navigates with shareId + autoSend', async () => { @@ -287,10 +308,7 @@ describe('useRemoteSpawnDispatch spawn input chain', () => { getSubmitPayload: () => samplePayload, }); - onStart(); - await vi.waitFor(() => { - expect(routerReplace).toHaveBeenCalled(); - }); + await runStartAndWaitForReplace(onStart); const calledWith = routerReplace.mock.calls[0]?.[0] as string | undefined; expect(typeof calledWith).toBe('string'); @@ -311,10 +329,7 @@ describe('useRemoteSpawnDispatch spawn input chain', () => { getSubmitPayload: () => null, }); - onStart(); - await vi.waitFor(() => { - expect(routerReplace).toHaveBeenCalled(); - }); + await runStartAndWaitForReplace(onStart); const calledWith = routerReplace.mock.calls[0]?.[0] as string | undefined; expect(typeof calledWith).toBe('string'); @@ -330,10 +345,7 @@ describe('useRemoteSpawnDispatch spawn input chain', () => { withProvider: false, }); - onStart(); - await vi.waitFor(() => { - expect(routerReplace).toHaveBeenCalled(); - }); + await runStartAndWaitForReplace(onStart); const calledWith = routerReplace.mock.calls[0]?.[0] as string | undefined; expect(typeof calledWith).toBe('string'); diff --git a/apps/mobile/src/components/agents/use-remote-spawn-dispatch.ts b/apps/mobile/src/components/agents/use-remote-spawn-dispatch.ts index 085f4101eb..c3acbd34bf 100644 --- a/apps/mobile/src/components/agents/use-remote-spawn-dispatch.ts +++ b/apps/mobile/src/components/agents/use-remote-spawn-dispatch.ts @@ -11,6 +11,7 @@ import { } from 'react'; import { type Href, useRouter } from 'expo-router'; import { toast } from 'sonner-native'; +import { type ModelSelection } from '@kilocode/cloud-agent-sdk'; import { getSpawnedAgentSessionPath } from '@/components/agents/session-detail-routes'; import { type InstancePickerInstance } from '@/lib/picker-bridge'; @@ -43,36 +44,38 @@ type InstancesRefetch = () => Promise<{ type RemoteSpawnInheritance = { mode?: string; - model?: string; - variant?: string; }; const RemoteSpawnInheritanceContext = createContext({}); /** - * Supplies the new-session screen's current mode/model/variant to - * `useRemoteSpawnDispatch` without requiring the sibling-owned - * `use-new-session-share-remote` wrapper to forward those fields. + * Supplies the new-session screen's current mode to `useRemoteSpawnDispatch` + * without requiring the sibling-owned `use-new-session-share-remote` wrapper + * to forward that field. The model half no longer rides inheritance: the + * route passes the validated `selection` explicitly. */ export function RemoteSpawnInheritanceProvider({ mode, - model, - variant, children, }: RemoteSpawnInheritance & { children: ReactNode }) { - const value = useMemo(() => ({ mode, model, variant }), [mode, model, variant]); + const value = useMemo(() => ({ mode }), [mode]); return createElement(RemoteSpawnInheritanceContext.Provider, { value }, children); } type UseRemoteSpawnDispatchArgs = { organizationId: string | undefined; /** - * Optional override for inheritance fields. When omitted, values come from - * the nearest `RemoteSpawnInheritanceProvider` (the new-session screen). + * Optional override for the inherited mode. When omitted, the value comes + * from the nearest `RemoteSpawnInheritanceProvider` (the new-session + * screen). */ mode?: string; - model?: string; - variant?: string; + /** + * The validated wire model selection for the active target. Never inherited: + * the caller owns it because it depends on the target instance's catalog. + * Undefined means "let the CLI use its default". + */ + selection?: ModelSelection; runOnInstance: InstancePickerInstance | null; setRunOnInstance: (next: InstancePickerInstance | null) => void; /** @@ -143,8 +146,7 @@ type UseRemoteSpawnDispatchResult = { export function useRemoteSpawnDispatch({ organizationId, mode: modeArg, - model: modelArg, - variant: variantArg, + selection, runOnInstance, setRunOnInstance, refetchInstances, @@ -154,8 +156,6 @@ export function useRemoteSpawnDispatch({ const router = useRouter(); const inheritance = useContext(RemoteSpawnInheritanceContext); const mode = modeArg ?? inheritance.mode; - const model = modelArg ?? inheritance.model; - const variant = variantArg ?? inheritance.variant; // Route param is frozen at navigation: missing param means personal, not // "inherit live context". `?? null` so undefined does not fall through to // `useOrganization()` after a later org switch (share-gate keeps zero-arg @@ -181,11 +181,11 @@ export function useRemoteSpawnDispatch({ }, [runOnInstance]); const getSubmitPayloadRef = useRef(getSubmitPayload); - const spawnFieldsRef = useRef({ mode, model, variant, organizationId }); + const spawnFieldsRef = useRef({ mode, selection, organizationId }); useEffect(() => { getSubmitPayloadRef.current = getSubmitPayload; - spawnFieldsRef.current = { mode, model, variant, organizationId }; - }, [getSubmitPayload, mode, model, variant, organizationId]); + spawnFieldsRef.current = { mode, selection, organizationId }; + }, [getSubmitPayload, mode, selection, organizationId]); const onStart = useCallback(() => { if (runOnInstance === null) { @@ -205,8 +205,7 @@ export function useRemoteSpawnDispatch({ } const createInput = buildCreateRemoteSessionInput({ mode: fields.mode, - model: fields.model, - variant: fields.variant, + selection: fields.selection, organizationId: fields.organizationId, }); void (async () => { diff --git a/apps/mobile/src/lib/use-new-session-share-remote.ts b/apps/mobile/src/lib/use-new-session-share-remote.ts index fcc946966a..00ed79596a 100644 --- a/apps/mobile/src/lib/use-new-session-share-remote.ts +++ b/apps/mobile/src/lib/use-new-session-share-remote.ts @@ -1,4 +1,5 @@ import { type RefObject, useCallback, useRef } from 'react'; +import { type ModelSelection } from '@kilocode/cloud-agent-sdk'; import { useRemoteSpawnDispatch } from '@/components/agents/use-remote-spawn-dispatch'; import { type AgentAttachment } from '@/lib/agent-attachments/agent-attachment-types'; @@ -19,6 +20,12 @@ type UseNewSessionShareRemoteArgs = { promptRef: RefObject; /** Live attachment list owned by `useAgentAttachmentUpload`. */ attachments: AgentAttachment[]; + /** + * The validated wire model selection for the active target, derived by the + * route from the new-session model view. Undefined means "let the CLI use + * its default". + */ + selection?: ModelSelection; }; /** @@ -34,6 +41,7 @@ export function useNewSessionShareRemote({ instanceList, promptRef, attachments, + selection, }: UseNewSessionShareRemoteArgs) { // Render-time ref assignment, the same pattern `share-prefill.ts:80` and // `share-gate-sheet.tsx:91` use, so the snapshot callback stays stable @@ -52,6 +60,7 @@ export function useNewSessionShareRemote({ const remoteSpawn = useRemoteSpawnDispatch({ organizationId, + selection, runOnInstance, setRunOnInstance, refetchInstances, From a989d07f55c9f7991ff3cbc34951343ad27908e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 7 Aug 2026 19:54:27 +0200 Subject: [PATCH 08/13] fix(mobile): remove unused model view exports --- apps/mobile/src/components/agents/new-session-model-view.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/components/agents/new-session-model-view.ts b/apps/mobile/src/components/agents/new-session-model-view.ts index 5191260084..49dbb84281 100644 --- a/apps/mobile/src/components/agents/new-session-model-view.ts +++ b/apps/mobile/src/components/agents/new-session-model-view.ts @@ -21,7 +21,7 @@ export type NewSessionModelView = { isSelectionUnavailable: boolean; }; -export type ResolveNewSessionRemoteOverrideInput = { +type ResolveNewSessionRemoteOverrideInput = { catalog: RemoteModelCatalogV1 | null; gatewayModel: string; gatewayVariant: string; @@ -43,7 +43,7 @@ export type ResolveNewSessionRemoteOverrideInput = { * real catalog that later drops the model must keep the override, because the * visible unavailable row plus the blocked Start is the intended signal. */ -export function resolveNewSessionRemoteOverride( +function resolveNewSessionRemoteOverride( input: ResolveNewSessionRemoteOverrideInput ): RemoteModelOverride | null { if (input.remoteOverride) { From 004f7c62e0b545344d022c33cf386cf01e71fc9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 7 Aug 2026 23:08:18 +0200 Subject: [PATCH 09/13] refactor(seed): share user lookup helpers --- ...ts => continue-remote-spawn-input.test.ts} | 0 dev/seed/app/byok-e2e-fixture.ts | 42 ++---------------- dev/seed/app/usage-evidence.ts | 44 ++----------------- dev/seed/lib/resolve-user.ts | 41 +++++++++++++++++ 4 files changed, 48 insertions(+), 79 deletions(-) rename apps/mobile/src/components/agents/{resolve-continue-remote-model.test.ts => continue-remote-spawn-input.test.ts} (100%) create mode 100644 dev/seed/lib/resolve-user.ts diff --git a/apps/mobile/src/components/agents/resolve-continue-remote-model.test.ts b/apps/mobile/src/components/agents/continue-remote-spawn-input.test.ts similarity index 100% rename from apps/mobile/src/components/agents/resolve-continue-remote-model.test.ts rename to apps/mobile/src/components/agents/continue-remote-spawn-input.test.ts diff --git a/dev/seed/app/byok-e2e-fixture.ts b/dev/seed/app/byok-e2e-fixture.ts index 26cb38e262..b269cf992d 100644 --- a/dev/seed/app/byok-e2e-fixture.ts +++ b/dev/seed/app/byok-e2e-fixture.ts @@ -1,11 +1,11 @@ -import { byok_api_keys, kilocode_users, modelsByProvider } from '@kilocode/db/schema'; +import { byok_api_keys, modelsByProvider } from '@kilocode/db/schema'; import { StoredModelSchema } from '@kilocode/db/schema-types'; -import { and, desc, eq, or, sql } from 'drizzle-orm'; +import { and, desc, eq, sql } from 'drizzle-orm'; import { z } from 'zod'; import { encryptCredential, requireEncryptionKey } from '../lib/byok'; import { getSeedDb } from '../lib/db'; -import { normalizeSeedEmail } from '../lib/email'; +import { isValidEmail, resolveUserId } from '../lib/resolve-user'; import type { SeedResult } from '../index'; export const usage = ' '; @@ -29,42 +29,6 @@ function printUsage(): void { console.log(' pnpm dev:seed app:byok-e2e-fixture ada@example.com minimax minimax/minimax-m2.5'); } -function isValidEmail(email: string): boolean { - // Intentionally permissive; we only guard against obvious nonsense in dev. - return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); -} - -async function resolveUserId(email: string): Promise { - const normalizedEmail = normalizeSeedEmail(email); - const db = getSeedDb(); - const matches = await db - .select({ - userId: kilocode_users.id, - email: kilocode_users.google_user_email, - }) - .from(kilocode_users) - .where( - or( - eq(kilocode_users.google_user_email, email), - eq(kilocode_users.normalized_email, normalizedEmail) - ) - ); - - if (matches.length === 0) { - throw new Error(`No user found for email ${email}`); - } - - const exactMatches = matches.filter(match => match.email === email); - const resolvedMatches = exactMatches.length > 0 ? exactMatches : matches; - if (resolvedMatches.length > 1) { - const matchList = resolvedMatches.map(match => `${match.email} (${match.userId})`).join(', '); - throw new Error(`Multiple users matched ${email}: ${matchList}`); - } - - const [user] = resolvedMatches; - return user.userId; -} - export async function run(...args: string[]): Promise { if (args.includes('--help') || args.includes('-h')) { printUsage(); diff --git a/dev/seed/app/usage-evidence.ts b/dev/seed/app/usage-evidence.ts index 3df79fa6c6..ffa6008573 100644 --- a/dev/seed/app/usage-evidence.ts +++ b/dev/seed/app/usage-evidence.ts @@ -1,8 +1,8 @@ -import { kilocode_users, microdollar_usage, microdollar_usage_metadata } from '@kilocode/db/schema'; -import { and, desc, eq, gt, or } from 'drizzle-orm'; +import { microdollar_usage, microdollar_usage_metadata } from '@kilocode/db/schema'; +import { and, desc, eq, gt } from 'drizzle-orm'; import { getSeedDb } from '../lib/db'; -import { normalizeSeedEmail } from '../lib/email'; +import { isValidEmail, resolveUserId } from '../lib/resolve-user'; import type { SeedResult } from '../index'; export const usage = ' [--since ]'; @@ -26,42 +26,6 @@ function printUsage(): void { ); } -function isValidEmail(email: string): boolean { - // Intentionally permissive; we only guard against obvious nonsense in dev. - return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); -} - -async function resolveUserId(email: string): Promise { - const normalizedEmail = normalizeSeedEmail(email); - const db = getSeedDb(); - const matches = await db - .select({ - userId: kilocode_users.id, - email: kilocode_users.google_user_email, - }) - .from(kilocode_users) - .where( - or( - eq(kilocode_users.google_user_email, email), - eq(kilocode_users.normalized_email, normalizedEmail) - ) - ); - - if (matches.length === 0) { - throw new Error(`No user found for email ${email}`); - } - - const exactMatches = matches.filter(match => match.email === email); - const resolvedMatches = exactMatches.length > 0 ? exactMatches : matches; - if (resolvedMatches.length > 1) { - const matchList = resolvedMatches.map(match => `${match.email} (${match.userId})`).join(', '); - throw new Error(`Multiple users matched ${email}: ${matchList}`); - } - - const [user] = resolvedMatches; - return user.userId; -} - type UsageEvidenceOptions = { email: string; since: string | null; @@ -128,7 +92,7 @@ export async function run(...args: string[]): Promise { conditions.push(gt(microdollar_usage.created_at, since)); } - // Plan section 317: select every plan-required per-row field. The metadata half can be + // Select every plan-required per-row field. The metadata half can be // null for a row without it, so all metadata fields stay nullable-safe in the row type. const rows = await db .select({ diff --git a/dev/seed/lib/resolve-user.ts b/dev/seed/lib/resolve-user.ts new file mode 100644 index 0000000000..1569f15b07 --- /dev/null +++ b/dev/seed/lib/resolve-user.ts @@ -0,0 +1,41 @@ +import { kilocode_users } from '@kilocode/db/schema'; +import { eq, or } from 'drizzle-orm'; + +import { getSeedDb } from './db'; +import { normalizeSeedEmail } from './email'; + +export function isValidEmail(email: string): boolean { + // Intentionally permissive; we only guard against obvious nonsense in dev. + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); +} + +export async function resolveUserId(email: string): Promise { + const normalizedEmail = normalizeSeedEmail(email); + const db = getSeedDb(); + const matches = await db + .select({ + userId: kilocode_users.id, + email: kilocode_users.google_user_email, + }) + .from(kilocode_users) + .where( + or( + eq(kilocode_users.google_user_email, email), + eq(kilocode_users.normalized_email, normalizedEmail) + ) + ); + + if (matches.length === 0) { + throw new Error(`No user found for email ${email}`); + } + + const exactMatches = matches.filter(match => match.email === email); + const resolvedMatches = exactMatches.length > 0 ? exactMatches : matches; + if (resolvedMatches.length > 1) { + const matchList = resolvedMatches.map(match => `${match.email} (${match.userId})`).join(', '); + throw new Error(`Multiple users matched ${email}: ${matchList}`); + } + + const [user] = resolvedMatches; + return user.userId; +} From 358d95b4a76b50c827c842d54af402220f5d43fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 7 Aug 2026 23:08:19 +0200 Subject: [PATCH 10/13] refactor(mobile): pass remote spawn mode explicitly --- apps/mobile/src/app/(app)/agent-chat/new.tsx | 1 + .../agents/new-session-model-provider.tsx | 5 +- .../agents/use-remote-spawn-dispatch.test.ts | 66 +++++-------------- .../agents/use-remote-spawn-dispatch.ts | 41 ++---------- .../src/lib/use-new-session-share-remote.ts | 5 ++ 5 files changed, 28 insertions(+), 90 deletions(-) diff --git a/apps/mobile/src/app/(app)/agent-chat/new.tsx b/apps/mobile/src/app/(app)/agent-chat/new.tsx index 35a28e659b..81a889fd6a 100644 --- a/apps/mobile/src/app/(app)/agent-chat/new.tsx +++ b/apps/mobile/src/app/(app)/agent-chat/new.tsx @@ -138,6 +138,7 @@ function NewSessionScreenBody() { const { remoteSpawn, handleRunOnInstanceChange } = useNewSessionShareRemote({ organizationId, + mode, runOnInstance, setRunOnInstance, refetchInstances, diff --git a/apps/mobile/src/components/agents/new-session-model-provider.tsx b/apps/mobile/src/components/agents/new-session-model-provider.tsx index d4f5e1a2e2..5b254e6226 100644 --- a/apps/mobile/src/components/agents/new-session-model-provider.tsx +++ b/apps/mobile/src/components/agents/new-session-model-provider.tsx @@ -12,7 +12,6 @@ import { import { type AgentMode } from '@/components/agents/mode-selector'; import { resolvePrefillModel } from '@/components/agents/new-session-prefill'; import { useNewSessionPrefill } from '@/components/agents/use-new-session-prefill'; -import { RemoteSpawnInheritanceProvider } from '@/components/agents/use-remote-spawn-dispatch'; import { useAvailableModels } from '@/lib/hooks/use-available-models'; import { useAutoSelectModel } from '@/lib/hooks/use-auto-select-model'; @@ -63,8 +62,6 @@ export function NewSessionModelProvider({ ); return ( - - {children} - + {children} ); } diff --git a/apps/mobile/src/components/agents/use-remote-spawn-dispatch.test.ts b/apps/mobile/src/components/agents/use-remote-spawn-dispatch.test.ts index ab07cb2a34..7730871172 100644 --- a/apps/mobile/src/components/agents/use-remote-spawn-dispatch.test.ts +++ b/apps/mobile/src/components/agents/use-remote-spawn-dispatch.test.ts @@ -10,10 +10,7 @@ import { } from '@/lib/share-payload'; import { buildCreateRemoteSessionInput } from '@/lib/hooks/remote-instance-spawn-classifier'; -import { - RemoteSpawnInheritanceProvider, - useRemoteSpawnDispatch, -} from './use-remote-spawn-dispatch'; +import { useRemoteSpawnDispatch } from './use-remote-spawn-dispatch'; const spawnMock = vi.hoisted(() => vi.fn(async () => { @@ -67,7 +64,6 @@ type ReactInternals = { type HookDispatcher = { useCallback: (callback: T, _deps?: unknown) => T; - useContext: (context: React.Context) => T; useEffect: (effect: React.EffectCallback, _deps?: unknown) => void; useMemo: (factory: () => T, _deps?: unknown) => T; useRef: (initial: T) => { current: T }; @@ -108,13 +104,10 @@ async function runStartAndWaitForReplace(onStart: () => void) { * `use-interaction-handlers.test.ts` so we can exercise * `useRemoteSpawnDispatch` without pulling react-native into vitest. */ -function runHookWithProvider(args: { +function runHook(args: { organizationId: string | undefined; mode?: string; selection?: ModelSelection; - /** When false, omit the Provider — inheritance must not leak fields. */ - withProvider?: boolean; - providerMode?: string; getSubmitPayload?: () => SharePayload | null; }) { const reactInternals = React as typeof React & ReactInternals; @@ -122,18 +115,12 @@ function runHookWithProvider(args: { const refs: { current: unknown }[] = []; let hookIndex = 0; let refIndex = 0; - let contextValue: { mode?: string } = {}; const dispatcher: HookDispatcher = { useCallback: hookCallback => { hookIndex += 1; return hookCallback; }, - useContext: context => { - hookIndex += 1; - void context; - return contextValue as never; - }, useEffect: effect => { hookIndex += 1; effect(); @@ -166,10 +153,6 @@ function runHookWithProvider(args: { }, }; - if (args.withProvider !== false) { - contextValue = { mode: args.providerMode }; - } - const previousDispatcher = reactInternals.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE.H; hookIndex = 0; @@ -203,10 +186,10 @@ describe('useRemoteSpawnDispatch spawn input chain', () => { __resetSharePayloadStoreForTests(); }); - it('onStart builds agent from inherited mode and wire model from selection', async () => { - const { onStart } = runHookWithProvider({ + it('onStart builds agent from explicit mode and wire model from selection', async () => { + const { onStart } = runHook({ organizationId: 'org-xyz', - providerMode: 'plan', + mode: 'plan', selection: { model: { providerID: 'anthropic', modelID: 'claude-x' }, variant: 'high' }, }); @@ -220,19 +203,17 @@ describe('useRemoteSpawnDispatch spawn input chain', () => { ]); }); - it('onStart without inheritance yields org-only input — empty context regression', async () => { - const { onStart } = runHookWithProvider({ + it('onStart without mode yields org-only input', async () => { + const { onStart } = runHook({ organizationId: 'org-xyz', - withProvider: false, }); expect(await captureSpawnCall(onStart)).toEqual(['conn-abc', { orgId: 'org-xyz' }]); }); - it('explicit mode and selection args win over empty context', async () => { - const { onStart } = runHookWithProvider({ + it('explicit mode and selection reach the spawn input', async () => { + const { onStart } = runHook({ organizationId: undefined, - withProvider: false, mode: 'code', selection: { model: { providerID: 'anthropic', modelID: 'claude-sonnet-4' } }, }); @@ -244,19 +225,18 @@ describe('useRemoteSpawnDispatch spawn input chain', () => { }); it('org route passes the route org into useRemoteInstanceSpawn (not inherit)', () => { - runHookWithProvider({ organizationId: 'org-route-1', withProvider: false }); + runHook({ organizationId: 'org-route-1' }); expect(useRemoteInstanceSpawnMock).toHaveBeenCalledWith('org-route-1'); }); it('personal route (no param) passes null so context org cannot win', () => { - runHookWithProvider({ organizationId: undefined, withProvider: false }); + runHook({ organizationId: undefined }); expect(useRemoteInstanceSpawnMock).toHaveBeenCalledWith(null); }); it('personal-route onStart omits orgId when only mode and selection are set', async () => { - const { onStart } = runHookWithProvider({ + const { onStart } = runHook({ organizationId: undefined, - withProvider: false, mode: 'code', selection: { model: { providerID: 'kilo', modelID: 'kilo-auto/efficient' } }, }); @@ -271,9 +251,8 @@ describe('useRemoteSpawnDispatch spawn input chain', () => { }); it('a non-kilo selection reaches spawn as the provider own model with its variant', async () => { - const { onStart } = runHookWithProvider({ + const { onStart } = runHook({ organizationId: 'org-xyz', - withProvider: false, mode: 'code', selection: { model: { providerID: 'opencode', modelID: 'opencode-model' }, variant: 'xhigh' }, }); @@ -289,9 +268,8 @@ describe('useRemoteSpawnDispatch spawn input chain', () => { }); it('an omitted selection reaches spawn with no model key at all', async () => { - const { onStart } = runHookWithProvider({ + const { onStart } = runHook({ organizationId: 'org-xyz', - withProvider: false, mode: 'code', }); @@ -302,9 +280,8 @@ describe('useRemoteSpawnDispatch spawn input chain', () => { }); it('ready path stages the press-time payload and navigates with shareId + autoSend', async () => { - const { onStart } = runHookWithProvider({ + const { onStart } = runHook({ organizationId: 'org-xyz', - withProvider: false, getSubmitPayload: () => samplePayload, }); @@ -323,9 +300,8 @@ describe('useRemoteSpawnDispatch spawn input chain', () => { }); it('ready path navigates without share params when press-time payload is null', async () => { - const { onStart } = runHookWithProvider({ + const { onStart } = runHook({ organizationId: 'org-xyz', - withProvider: false, getSubmitPayload: () => null, }); @@ -340,9 +316,8 @@ describe('useRemoteSpawnDispatch spawn input chain', () => { }); it('ready path navigates without share params when getSubmitPayload is omitted', async () => { - const { onStart } = runHookWithProvider({ + const { onStart } = runHook({ organizationId: 'org-xyz', - withProvider: false, }); await runStartAndWaitForReplace(onStart); @@ -354,10 +329,3 @@ describe('useRemoteSpawnDispatch spawn input chain', () => { expect(calledWith).not.toContain('autoSend='); }); }); - -// Smoke: Provider is a real React context provider (not a no-op export). -describe('RemoteSpawnInheritanceProvider', () => { - it('exposes a Provider component', () => { - expect(typeof RemoteSpawnInheritanceProvider).toBe('function'); - }); -}); diff --git a/apps/mobile/src/components/agents/use-remote-spawn-dispatch.ts b/apps/mobile/src/components/agents/use-remote-spawn-dispatch.ts index c3acbd34bf..9ac3fcfd30 100644 --- a/apps/mobile/src/components/agents/use-remote-spawn-dispatch.ts +++ b/apps/mobile/src/components/agents/use-remote-spawn-dispatch.ts @@ -1,14 +1,4 @@ -import { - createContext, - createElement, - type ReactNode, - useCallback, - useContext, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { type Href, useRouter } from 'expo-router'; import { toast } from 'sonner-native'; import { type ModelSelection } from '@kilocode/cloud-agent-sdk'; @@ -42,32 +32,11 @@ type InstancesRefetch = () => Promise<{ data: { instances: InstancePickerInstance[] } | undefined; }>; -type RemoteSpawnInheritance = { - mode?: string; -}; - -const RemoteSpawnInheritanceContext = createContext({}); - -/** - * Supplies the new-session screen's current mode to `useRemoteSpawnDispatch` - * without requiring the sibling-owned `use-new-session-share-remote` wrapper - * to forward that field. The model half no longer rides inheritance: the - * route passes the validated `selection` explicitly. - */ -export function RemoteSpawnInheritanceProvider({ - mode, - children, -}: RemoteSpawnInheritance & { children: ReactNode }) { - const value = useMemo(() => ({ mode }), [mode]); - return createElement(RemoteSpawnInheritanceContext.Provider, { value }, children); -} - type UseRemoteSpawnDispatchArgs = { organizationId: string | undefined; /** - * Optional override for the inherited mode. When omitted, the value comes - * from the nearest `RemoteSpawnInheritanceProvider` (the new-session - * screen). + * The current new-session agent mode for the spawn target. Omitted for + * callers without a mode (share-gate); the CLI then uses its default. */ mode?: string; /** @@ -145,7 +114,7 @@ type UseRemoteSpawnDispatchResult = { */ export function useRemoteSpawnDispatch({ organizationId, - mode: modeArg, + mode, selection, runOnInstance, setRunOnInstance, @@ -154,8 +123,6 @@ export function useRemoteSpawnDispatch({ getSubmitPayload, }: UseRemoteSpawnDispatchArgs): UseRemoteSpawnDispatchResult { const router = useRouter(); - const inheritance = useContext(RemoteSpawnInheritanceContext); - const mode = modeArg ?? inheritance.mode; // Route param is frozen at navigation: missing param means personal, not // "inherit live context". `?? null` so undefined does not fall through to // `useOrganization()` after a later org switch (share-gate keeps zero-arg diff --git a/apps/mobile/src/lib/use-new-session-share-remote.ts b/apps/mobile/src/lib/use-new-session-share-remote.ts index 00ed79596a..5c274b59ce 100644 --- a/apps/mobile/src/lib/use-new-session-share-remote.ts +++ b/apps/mobile/src/lib/use-new-session-share-remote.ts @@ -1,6 +1,7 @@ import { type RefObject, useCallback, useRef } from 'react'; import { type ModelSelection } from '@kilocode/cloud-agent-sdk'; +import { type AgentMode } from '@/components/agents/mode-selector'; import { useRemoteSpawnDispatch } from '@/components/agents/use-remote-spawn-dispatch'; import { type AgentAttachment } from '@/lib/agent-attachments/agent-attachment-types'; import { type InstancePickerInstance } from '@/lib/picker-bridge'; @@ -12,6 +13,8 @@ type InstancesRefetch = () => Promise<{ type UseNewSessionShareRemoteArgs = { organizationId: string | undefined; + /** Current new-session agent mode, passed through to the spawn dispatch. */ + mode: AgentMode; runOnInstance: InstancePickerInstance | null; setRunOnInstance: (next: InstancePickerInstance | null) => void; refetchInstances: InstancesRefetch; @@ -35,6 +38,7 @@ type UseNewSessionShareRemoteArgs = { */ export function useNewSessionShareRemote({ organizationId, + mode, runOnInstance, setRunOnInstance, refetchInstances, @@ -60,6 +64,7 @@ export function useNewSessionShareRemote({ const remoteSpawn = useRemoteSpawnDispatch({ organizationId, + mode, selection, runOnInstance, setRunOnInstance, From 1044fc9ff0948e7424d0b4d04b7a211042cb9975 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 7 Aug 2026 23:08:20 +0200 Subject: [PATCH 11/13] refactor(cloud-agent-sdk): drop duplicate catalog export --- packages/cloud-agent-sdk/src/instance-model-catalog.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/cloud-agent-sdk/src/instance-model-catalog.ts b/packages/cloud-agent-sdk/src/instance-model-catalog.ts index 8f0623b0fb..45a443f9d5 100644 --- a/packages/cloud-agent-sdk/src/instance-model-catalog.ts +++ b/packages/cloud-agent-sdk/src/instance-model-catalog.ts @@ -22,7 +22,6 @@ import { type UserWebConnection, } from './user-web-connection'; -export { remoteModelCatalogV1Schema } from './schemas'; export type { RemoteModelCatalogV1 } from './schemas'; /** Delivered error string an old CLI returns for a sessionless `list_models`. */ From 07d23cd11feb77923cd7adddc666c1d1a8ff4bf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 7 Aug 2026 23:08:20 +0200 Subject: [PATCH 12/13] refactor(mobile): remove unused model badge field --- .../components/agents/model-selector-badges.test.ts | 11 ----------- .../src/components/agents/model-selector-badges.ts | 1 - 2 files changed, 12 deletions(-) diff --git a/apps/mobile/src/components/agents/model-selector-badges.test.ts b/apps/mobile/src/components/agents/model-selector-badges.test.ts index 5a30fe5dc9..5b69c926e8 100644 --- a/apps/mobile/src/components/agents/model-selector-badges.test.ts +++ b/apps/mobile/src/components/agents/model-selector-badges.test.ts @@ -50,17 +50,6 @@ describe('modelSelectorBadges', () => { expect(badges.byok).toBe(false); }); - it('shows no badges for an unavailable option without the flag', () => { - const badges = modelSelectorBadges({ - id: 'remote-unavailable-model', - showGatewayMetadata: false, - unavailable: true, - }); - expect(badges.byok).toBe(false); - expect(badges.free).toBe(false); - expect(badges.collectsData).toBe(false); - }); - it('shows no badges for an undefined option', () => { const badges = modelSelectorBadges(undefined); expect(badges.byok).toBe(false); diff --git a/apps/mobile/src/components/agents/model-selector-badges.ts b/apps/mobile/src/components/agents/model-selector-badges.ts index a2dd2f85cd..9015ad50ae 100644 --- a/apps/mobile/src/components/agents/model-selector-badges.ts +++ b/apps/mobile/src/components/agents/model-selector-badges.ts @@ -7,7 +7,6 @@ import { type ModelBadgeOption = ModelDataDisclosure & { showGatewayMetadata: boolean; - unavailable?: boolean; }; /** From 62bb773467e7ee6fcd87ea40b9fee6c67740527b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 8 Aug 2026 01:16:29 +0200 Subject: [PATCH 13/13] fix(mobile): harden remote catalog selection --- apps/mobile/src/app/(app)/agent-chat/new.tsx | 2 +- .../agents/new-session-model-view.test.ts | 10 +++++++--- .../components/agents/new-session-model-view.ts | 16 +++++++++++----- .../src/instance-model-catalog.test.ts | 17 +++++++++++++++++ .../src/instance-model-catalog.ts | 12 +++++++++--- 5 files changed, 45 insertions(+), 12 deletions(-) diff --git a/apps/mobile/src/app/(app)/agent-chat/new.tsx b/apps/mobile/src/app/(app)/agent-chat/new.tsx index 81a889fd6a..3ab24fb7c5 100644 --- a/apps/mobile/src/app/(app)/agent-chat/new.tsx +++ b/apps/mobile/src/app/(app)/agent-chat/new.tsx @@ -253,7 +253,7 @@ function NewSessionScreenBody() { attachmentMax={AGENT_ATTACHMENT_MAX_FILES} isCreating={isCreating} isModelsError={isModelsError} - isLoadingModels={isLoadingModels || instanceCatalog.isLoading} + isLoadingModels={isLoadingModels || (isRemoteTargetSelected && instanceCatalog.isLoading)} mode={mode} model={modelView.selectedValue} variant={modelView.selectedVariant} diff --git a/apps/mobile/src/components/agents/new-session-model-view.test.ts b/apps/mobile/src/components/agents/new-session-model-view.test.ts index 7f1f7da055..2cde10efae 100644 --- a/apps/mobile/src/components/agents/new-session-model-view.test.ts +++ b/apps/mobile/src/components/agents/new-session-model-view.test.ts @@ -207,16 +207,20 @@ describe('resolveNewSessionModelView', () => { expect(view.spawnSelection).toBeUndefined(); }); - it('selects the first catalog option when the catalog has no defaultModel', () => { + it('selects the first catalog option and its first offered variant when the catalog has no defaultModel', () => { const catalog = createCatalog([ { id: 'kilo', models: [{ id: 'kilo-model-a' }] }, { id: 'anthropic', models: [{ id: 'claude-x' }] }, - { id: 'opencode', models: [{ id: 'opencode-model' }] }, + { id: 'opencode', models: [{ id: 'opencode-model', variants: ['fast', 'balanced'] }] }, ]); const view = resolveNewSessionModelView({ ...baseInput, catalog }); expect(view.selectedValue).toBe(view.options[0]?.id); - expect(view.spawnSelection).toEqual({ model: view.options[0]?.modelRef }); + expect(view.selectedVariant).toBe('fast'); + expect(view.spawnSelection).toEqual({ + model: { providerID: 'opencode', modelID: 'opencode-model' }, + variant: 'fast', + }); }); it('drops a CLI override when the catalog is gone and falls back to the gateway', () => { diff --git a/apps/mobile/src/components/agents/new-session-model-view.ts b/apps/mobile/src/components/agents/new-session-model-view.ts index 49dbb84281..080a0e490a 100644 --- a/apps/mobile/src/components/agents/new-session-model-view.ts +++ b/apps/mobile/src/components/agents/new-session-model-view.ts @@ -174,14 +174,20 @@ export function resolveNewSessionModelView( }); let selectedValue = delegate.selectedValue; + let selectedVariant = delegate.selectedVariant; if (delegate.source === 'remote-cli-catalog' && selectedValue === '') { const firstOption = delegate.options[0]; if (firstOption) { // A valid catalog can carry no `defaultModel`. The first option comes - // from the catalog, so it is always valid on that instance. Do not apply - // this to the legacy fallback: "no selection" there means "let the CLI - // use its own default", which is today's behavior. + // from the catalog, so it is always valid on that instance. Derive its + // variant with the picker rule: keep the current variant when the first + // option offers it, otherwise use its first offered variant. Do not + // apply this to the legacy fallback: "no selection" there means "let the + // CLI use its own default", which is today's behavior. selectedValue = firstOption.id; + selectedVariant = firstOption.variants.includes(selectedVariant) + ? selectedVariant + : (firstOption.variants[0] ?? ''); } } @@ -191,14 +197,14 @@ export function resolveNewSessionModelView( selected?.modelRef && !isSelectionUnavailable ? { model: selected.modelRef, - ...(delegate.selectedVariant ? { variant: delegate.selectedVariant } : {}), + ...(selectedVariant ? { variant: selectedVariant } : {}), } : undefined; return { options: delegate.options, selectedValue, - selectedVariant: delegate.selectedVariant, + selectedVariant, spawnSelection, isSelectionUnavailable, }; diff --git a/packages/cloud-agent-sdk/src/instance-model-catalog.test.ts b/packages/cloud-agent-sdk/src/instance-model-catalog.test.ts index fe836f5313..5da2c5b7c1 100644 --- a/packages/cloud-agent-sdk/src/instance-model-catalog.test.ts +++ b/packages/cloud-agent-sdk/src/instance-model-catalog.test.ts @@ -3,6 +3,7 @@ import { REMOTE_MODEL_CATALOG_MAX_SERIALIZED_BYTES, REMOTE_MODEL_IDENTITY_MAX_LENGTH, REMOTE_MODEL_MAX_MODELS_PER_PROVIDER, + remoteModelCatalogV1Schema, } from './schemas'; import { CommandDeliveredError, UserWebCommandError } from './user-web-connection'; @@ -289,6 +290,22 @@ describe('listInstanceModels', () => { }); }); + it('classifies an unexpected strict-parse throw as transport and never rejects', async () => { + const connection = makeFakeConnection(); + connection.sendCommandToConnection.mockResolvedValue({ any: 'payload' }); + const parseSpy = jest.spyOn(remoteModelCatalogV1Schema, 'safeParse').mockImplementation(() => { + throw new Error('strict parse exploded'); + }); + try { + await expect(listInstanceModels(connection, 'cli-owner-1')).resolves.toEqual({ + ok: false, + reason: 'transport', + }); + } finally { + parseSpy.mockRestore(); + } + }); + it('classifies a resolved payload over the serialized byte limit as invalid', async () => { const connection = makeFakeConnection(); const overLimit = createCatalogWithSerializedBytes( diff --git a/packages/cloud-agent-sdk/src/instance-model-catalog.ts b/packages/cloud-agent-sdk/src/instance-model-catalog.ts index 45a443f9d5..cbe06eedc8 100644 --- a/packages/cloud-agent-sdk/src/instance-model-catalog.ts +++ b/packages/cloud-agent-sdk/src/instance-model-catalog.ts @@ -53,6 +53,8 @@ export type InstanceModelCatalogResult = * - Resolved and schema-valid → `{ ok: true, catalog }` with the transformed * catalog shape. * - Resolved but outside the strict schema → `{ ok: false, reason: 'invalid' }`. + * - Resolved but the strict parse throws unexpectedly → `{ ok: false, + * reason: 'transport' }`; the parse never escapes the helper. * - Rejected with the old-CLI `invalid list_models command` string or a * non-retryable relay code → `{ ok: false, reason: 'unsupported' }`. * - Rejected with a retryable relay code or a transport-level failure → @@ -85,7 +87,11 @@ export async function listInstanceModels( return { ok: false, reason: 'transport' }; } - const parsed = remoteModelCatalogV1Schema.safeParse(raw); - if (!parsed.success) return { ok: false, reason: 'invalid' }; - return { ok: true, catalog: parsed.data }; + try { + const parsed = remoteModelCatalogV1Schema.safeParse(raw); + if (!parsed.success) return { ok: false, reason: 'invalid' }; + return { ok: true, catalog: parsed.data }; + } catch { + return { ok: false, reason: 'transport' }; + } }