diff --git a/apps/mobile/src/app/(app)/agent-chat/new.tsx b/apps/mobile/src/app/(app)/agent-chat/new.tsx index 099ae0cf3c..3ab24fb7c5 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 }); @@ -109,16 +138,27 @@ function NewSessionScreenBody() { const { remoteSpawn, handleRunOnInstanceChange } = useNewSessionShareRemote({ organizationId, + mode, runOnInstance, setRunOnInstance, refetchInstances, 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 +167,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 +218,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 +253,11 @@ function NewSessionScreenBody() { attachmentMax={AGENT_ATTACHMENT_MAX_FILES} isCreating={isCreating} isModelsError={isModelsError} - isLoadingModels={isLoadingModels} + isLoadingModels={isLoadingModels || (isRemoteTargetSelected && 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 +273,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/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/continue-remote-spawn-input.test.ts b/apps/mobile/src/components/agents/continue-remote-spawn-input.test.ts new file mode 100644 index 0000000000..a6daeb281c --- /dev/null +++ b/apps/mobile/src/components/agents/continue-remote-spawn-input.test.ts @@ -0,0 +1,289 @@ +import { describe, expect, it, vi } from 'vitest'; + +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', + Code: 'Code', + HelpCircle: 'HelpCircle', + NotebookPen: 'NotebookPen', + Workflow: 'Workflow', +})); + +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(); + }); + + 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 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('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('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('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('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('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('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/model-selector-badges.test.ts b/apps/mobile/src/components/agents/model-selector-badges.test.ts index f7ffc75137..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,16 +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, - }); - 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/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..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/new-session-model-view.test.ts b/apps/mobile/src/components/agents/new-session-model-view.test.ts new file mode 100644 index 0000000000..2cde10efae --- /dev/null +++ b/apps/mobile/src/components/agents/new-session-model-view.test.ts @@ -0,0 +1,339 @@ +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 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', variants: ['fast', 'balanced'] }] }, + ]); + const view = resolveNewSessionModelView({ ...baseInput, catalog }); + + expect(view.selectedValue).toBe(view.options[0]?.id); + 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', () => { + 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..080a0e490a --- /dev/null +++ b/apps/mobile/src/components/agents/new-session-model-view.ts @@ -0,0 +1,211 @@ +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; +}; + +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. + */ +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; + 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. 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] ?? ''); + } + } + + const selected = delegate.options.find(option => option.id === selectedValue); + const isSelectionUnavailable = selected?.unavailable === true; + const spawnSelection = + selected?.modelRef && !isSelectionUnavailable + ? { + model: selected.modelRef, + ...(selectedVariant ? { variant: selectedVariant } : {}), + } + : undefined; + + return { + options: delegate.options, + selectedValue, + selectedVariant, + spawnSelection, + isSelectionUnavailable, + }; +} 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/resolve-continue-remote-model.test.ts b/apps/mobile/src/components/agents/resolve-continue-remote-model.test.ts deleted file mode 100644 index ddd44ee293..0000000000 --- a/apps/mobile/src/components/agents/resolve-continue-remote-model.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; - -import { resolveContinueRemoteModel } from './continuation-seed'; - -vi.mock('lucide-react-native', () => ({ - Bug: 'Bug', - Code: 'Code', - HelpCircle: 'HelpCircle', - NotebookPen: 'NotebookPen', - Workflow: 'Workflow', -})); - -const CATALOG = [ - { id: 'model-a', variants: ['v1', 'v2'] }, - { id: 'model-b', variants: [] }, - { id: 'model-c', variants: ['latest'] }, -]; - -describe('resolveContinueRemoteModel', () => { - it('returns the model and variant when both are in the catalog', () => { - expect(resolveContinueRemoteModel('model-a', 'v1', CATALOG)).toEqual({ - model: '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 empty when the model is not in the catalog', () => { - expect(resolveContinueRemoteModel('model-unknown', 'v1', CATALOG)).toEqual({ - model: '', - variant: '', - }); - }); - - it('returns empty when the variant is not in the model variant list', () => { - expect(resolveContinueRemoteModel('model-a', 'v99', CATALOG)).toEqual({ - model: '', - variant: '', - }); - }); - - it('returns empty when the catalog is empty', () => { - expect(resolveContinueRemoteModel('model-a', 'v1', [])).toEqual({ - model: '', - variant: '', - }); - }); - - it('returns the model when variant is empty and model has no variants', () => { - expect(resolveContinueRemoteModel('model-b', '', CATALOG)).toEqual({ - model: 'model-b', - variant: '', - }); - }); - - it('returns empty when variant is non-empty but model has no variants', () => { - expect(resolveContinueRemoteModel('model-b', 'any', CATALOG)).toEqual({ - model: '', - variant: '', - }); - }); - - it('returns empty model and variant when both are empty strings (empty-source behavior)', () => { - expect(resolveContinueRemoteModel('', '', CATALOG)).toEqual({ - model: '', - variant: '', - }); - }); -}); 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( 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..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 @@ -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 { @@ -9,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 () => { @@ -66,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 }; @@ -82,21 +79,35 @@ 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 * `useRemoteSpawnDispatch` without pulling react-native into vitest. */ -function runHookWithProvider(args: { +function runHook(args: { organizationId: string | undefined; mode?: string; - model?: string; - variant?: string; - /** When false, omit the Provider — inheritance must not leak fields. */ - withProvider?: boolean; - providerMode?: string; - providerModel?: string; - providerVariant?: string; + selection?: ModelSelection; getSubmitPayload?: () => SharePayload | null; }) { const reactInternals = React as typeof React & ReactInternals; @@ -104,18 +115,12 @@ function runHookWithProvider(args: { const refs: { current: unknown }[] = []; let hookIndex = 0; let refIndex = 0; - let contextValue: { mode?: string; model?: string; variant?: 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(); @@ -133,9 +138,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) ) => { @@ -150,14 +153,6 @@ function runHookWithProvider(args: { }, }; - if (args.withProvider !== false) { - contextValue = { - mode: args.providerMode, - model: args.providerModel, - variant: args.providerVariant, - }; - } - const previousDispatcher = reactInternals.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE.H; hookIndex = 0; @@ -168,8 +163,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,105 +186,106 @@ describe('useRemoteSpawnDispatch spawn input chain', () => { __resetSharePayloadStoreForTests(); }); - it('onStart builds agent/model/variant/orgId from inheritance provider fields', async () => { - const { onStart } = runHookWithProvider({ + it('onStart builds agent from explicit mode and wire model from selection', async () => { + const { onStart } = runHook({ organizationId: 'org-xyz', - withProvider: true, - providerMode: 'plan', - providerModel: 'kilo-auto/efficient', - providerVariant: 'medium', - }); - - onStart(); - await vi.waitFor(() => { - expect(spawnMock).toHaveBeenCalled(); + mode: 'plan', + selection: { model: { providerID: 'anthropic', modelID: 'claude-x' }, variant: 'high' }, }); - 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 () => { - const { onStart } = runHookWithProvider({ + it('onStart without mode yields org-only input', async () => { + const { onStart } = runHook({ organizationId: 'org-xyz', - 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 () => { - const { onStart } = runHookWithProvider({ + it('explicit mode and selection reach the spawn input', async () => { + const { onStart } = runHook({ organizationId: undefined, - withProvider: false, mode: 'code', - model: 'anthropic/claude-sonnet-4', - variant: 'high', + selection: { model: { providerID: 'anthropic', modelID: 'claude-sonnet-4' } }, }); - onStart(); - await vi.waitFor(() => { - expect(spawnMock).toHaveBeenCalled(); - }); - - 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)', () => { - 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 even when only mode/model are set', async () => { - const { onStart } = runHookWithProvider({ + it('personal-route onStart omits orgId when only mode and selection are set', async () => { + const { onStart } = runHook({ 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 } = runHook({ + organizationId: 'org-xyz', + 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 } = runHook({ + organizationId: 'org-xyz', + 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 () => { - const { onStart } = runHookWithProvider({ + const { onStart } = runHook({ organizationId: 'org-xyz', - withProvider: false, 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'); @@ -305,16 +300,12 @@ 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, }); - 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'); @@ -325,15 +316,11 @@ 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, }); - 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'); @@ -342,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 085f4101eb..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,16 +1,7 @@ -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'; import { getSpawnedAgentSessionPath } from '@/components/agents/session-detail-routes'; import { type InstancePickerInstance } from '@/lib/picker-bridge'; @@ -41,38 +32,19 @@ type InstancesRefetch = () => Promise<{ data: { instances: InstancePickerInstance[] } | undefined; }>; -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. - */ -export function RemoteSpawnInheritanceProvider({ - mode, - model, - variant, - children, -}: RemoteSpawnInheritance & { children: ReactNode }) { - const value = useMemo(() => ({ mode, model, variant }), [mode, model, variant]); - 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). + * 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; - 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; /** @@ -142,9 +114,8 @@ type UseRemoteSpawnDispatchResult = { */ export function useRemoteSpawnDispatch({ organizationId, - mode: modeArg, - model: modelArg, - variant: variantArg, + mode, + selection, runOnInstance, setRunOnInstance, refetchInstances, @@ -152,10 +123,6 @@ export function useRemoteSpawnDispatch({ getSubmitPayload, }: UseRemoteSpawnDispatchArgs): UseRemoteSpawnDispatchResult { 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 +148,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 +172,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/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-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 }; +} 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', () => { 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..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,5 +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'; @@ -11,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; @@ -19,6 +23,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; }; /** @@ -28,12 +38,14 @@ type UseNewSessionShareRemoteArgs = { */ export function useNewSessionShareRemote({ organizationId, + mode, runOnInstance, setRunOnInstance, refetchInstances, 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 +64,8 @@ export function useNewSessionShareRemote({ const remoteSpawn = useRemoteSpawnDispatch({ organizationId, + mode, + selection, runOnInstance, setRunOnInstance, refetchInstances, diff --git a/dev/seed/app/usage-evidence.ts b/dev/seed/app/usage-evidence.ts index a4f7cdcc4e..fd30d07bfb 100644 --- a/dev/seed/app/usage-evidence.ts +++ b/dev/seed/app/usage-evidence.ts @@ -63,8 +63,18 @@ function parseArgs(args: string[]): UsageEvidenceOptions { return { email, since }; } -const dedupeJoined = (values: Array): string => - [...new Set(values.filter(v => v !== null).map(String))].join(','); +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')) { @@ -82,15 +92,21 @@ export async function run(...args: string[]): Promise { conditions.push(gt(microdollar_usage.created_at, since)); } + // 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)) 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..5da2c5b7c1 --- /dev/null +++ b/packages/cloud-agent-sdk/src/instance-model-catalog.test.ts @@ -0,0 +1,336 @@ +import { listInstanceModels } from './instance-model-catalog'; +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'; + +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 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( + 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..cbe06eedc8 --- /dev/null +++ b/packages/cloud-agent-sdk/src/instance-model-catalog.ts @@ -0,0 +1,97 @@ +/** + * 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 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' }`. + * - 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 → + * `{ 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' }; + } + + 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' }; + } +}