diff --git a/apps/mobile/src/app/(app)/_layout.tsx b/apps/mobile/src/app/(app)/_layout.tsx
index 20ec428c07..3da78f9499 100644
--- a/apps/mobile/src/app/(app)/_layout.tsx
+++ b/apps/mobile/src/app/(app)/_layout.tsx
@@ -26,6 +26,8 @@ export default function AppLayout() {
>
+
+
+
+
+
();
-
- // ── Selectors state ──────────────────────────────────────────────
- const [mode, setMode] = useState('code');
- const [model, setModel] = useState('');
- const [variant, setVariant] = useState('');
- const [selectedRepo, setSelectedRepo] = useState('');
- const [isCreating, setIsCreating] = useState(false);
- const [isSubmitting, setIsSubmitting] = useState(false);
- const [hasPrompt, setHasPrompt] = useState(false);
- const submissionLockRef = useRef(false);
- const voiceInputSettlerRef = useRef<(() => Promise) | null>(null);
-
- // ── Models ───────────────────────────────────────────────────────
- const {
- models,
- isLoading: isLoadingModels,
- isError: isModelsError,
- refetch: refetchModels,
- } = useAvailableModels(organizationId);
- const { setLastSelected: persistServerLastSelected } = useModelPreferences(organizationId);
- const { saveModel } = usePersistedAgentModel();
- const autoSelected = useAutoSelectModel(models, organizationId);
- const attachments = useAgentAttachmentUpload({ organizationId });
-
- // Apply auto-selected model when the user hasn't picked one yet.
- const hasAppliedAutoSelection = useRef(false);
- if (!hasAppliedAutoSelection.current && autoSelected.model && !model) {
- hasAppliedAutoSelection.current = true;
- setModel(autoSelected.model);
- setVariant(autoSelected.variant);
- }
-
- // ── Repositories ─────────────────────────────────────────────────
- const trpc = useTRPC();
- const {
- data: repoData,
- isLoading: isLoadingRepos,
- isError: isReposError,
- isRefetching: isRefetchingRepos,
- refetch: refetchRepos,
- } = useQuery(
- organizationId
- ? trpc.organizations.cloudAgentNext.listGitHubRepositories.queryOptions({
- organizationId,
- forceRefresh: false,
- })
- : trpc.cloudAgentNext.listGitHubRepositories.queryOptions({
- forceRefresh: false,
- })
- );
-
- const showGitHubIntegrationPrompt = shouldShowGitHubIntegrationPrompt({
- isLoadingRepos,
- integrationInstalled: repoData?.integrationInstalled,
- repositoryCount: repoData?.repositories.length,
- });
-
- const repositories = useMemo(() => {
- if (!repoData?.repositories) {
- return [];
- }
- return (repoData.repositories as { fullName: string; private: boolean }[]).map(r => ({
- fullName: r.fullName,
- isPrivate: r.private,
- }));
- }, [repoData]);
-
- // ── Session creator ──────────────────────────────────────────────
- const { createSessionFromDraft, promptRef } = useNewSessionCreator({
- attachments,
- mode,
- model,
- organizationId,
- selectedRepo,
- setIsCreating,
- variant,
- });
-
- // ── Handlers ─────────────────────────────────────────────────────
- const handleModelSelect = useCallback(
- (modelId: string, newVariant: string) => {
- setModel(modelId);
- setVariant(newVariant);
- saveModel(organizationId, { model: modelId, variant: newVariant });
- persistServerLastSelected({ model: modelId, ...(newVariant ? { variant: newVariant } : {}) });
- },
- [organizationId, saveModel, persistServerLastSelected]
- );
-
- const handleOpenGitHubIntegration = useCallback(async () => {
- try {
- await WebBrowser.openAuthSessionAsync(getGitHubIntegrationUrl(WEB_BASE_URL, organizationId));
- await refetchRepos();
- } catch {
- toast.error('Could not open GitHub setup. Please try again.');
- }
- }, [organizationId, refetchRepos]);
-
- function handlePromptChange(text: string) {
- promptRef.current = text;
- const nextHasPrompt = text.trim().length > 0;
- setHasPrompt(current => (current === nextHasPrompt ? current : nextHasPrompt));
- }
-
- const submitCreate = useCallback(async () => {
- await settleVoiceInputBeforeSubmit({
- lock: submissionLockRef,
- onPendingChange: setIsSubmitting,
- settleVoiceInput: async () => {
- const settleVoiceInput = voiceInputSettlerRef.current;
- if (settleVoiceInput === null) {
- return true;
- }
- const settled = await settleVoiceInput();
- return settled;
- },
- submit: createSessionFromDraft,
- });
- }, [createSessionFromDraft]);
-
- const handleStartSession = useCallback(() => {
- void submitCreate();
- }, [submitCreate]);
-
- const { addCandidates } = attachments;
- const handleAddAttachment = useCallback(async () => {
- addCandidates(await pickAgentAttachments(showActionSheetWithOptions));
- }, [addCandidates, showActionSheetWithOptions]);
-
- const canCreate =
- hasPrompt &&
- Boolean(selectedRepo) &&
- Boolean(model) &&
- !attachments.isUploading &&
- !attachments.hasFailedAttachments;
-
- return (
-
-
-
-
- {
- void handleAddAttachment();
- }}
- onRemoveAttachment={id => {
- attachments.removeAttachment(id);
- }}
- onRetryAttachment={id => {
- attachments.retryAttachment(id);
- }}
- onRefetchModels={() => {
- void refetchModels();
- }}
- voiceInputSettlerRef={voiceInputSettlerRef}
- />
-
- {
- void handleOpenGitHubIntegration();
- }}
- onRefetch={() => {
- void refetchRepos();
- }}
- repositories={repositories}
- showGitHubIntegrationPrompt={showGitHubIntegrationPrompt}
- value={selectedRepo}
- />
-
-
-
-
- );
+export default function NewSessionRoute() {
+ return ;
}
diff --git a/apps/mobile/src/app/(app)/agent-chat/new/cloud.tsx b/apps/mobile/src/app/(app)/agent-chat/new/cloud.tsx
new file mode 100644
index 0000000000..41756a418b
--- /dev/null
+++ b/apps/mobile/src/app/(app)/agent-chat/new/cloud.tsx
@@ -0,0 +1,5 @@
+import { CloudSessionCreateScreen } from '@/components/agents/cloud-session-create-screen';
+
+export default function NewSessionCloudRoute() {
+ return ;
+}
diff --git a/apps/mobile/src/app/(app)/agent-chat/new/local.tsx b/apps/mobile/src/app/(app)/agent-chat/new/local.tsx
new file mode 100644
index 0000000000..96fc471612
--- /dev/null
+++ b/apps/mobile/src/app/(app)/agent-chat/new/local.tsx
@@ -0,0 +1,5 @@
+import { LocalSessionConfigScreen } from '@/components/agents/local-session-config-screen';
+
+export default function NewSessionLocalRoute() {
+ return ;
+}
diff --git a/apps/mobile/src/app/(app)/agent-chat/runtime-catalog-agent-picker.tsx b/apps/mobile/src/app/(app)/agent-chat/runtime-catalog-agent-picker.tsx
new file mode 100644
index 0000000000..3cb1797a01
--- /dev/null
+++ b/apps/mobile/src/app/(app)/agent-chat/runtime-catalog-agent-picker.tsx
@@ -0,0 +1,5 @@
+import { RuntimeCatalogAgentPickerContent } from '@/components/agents/runtime-catalog-agent-picker-content';
+
+export default function RuntimeCatalogAgentPickerScreen() {
+ return ;
+}
diff --git a/apps/mobile/src/app/(app)/agent-chat/runtime-catalog-model-picker.tsx b/apps/mobile/src/app/(app)/agent-chat/runtime-catalog-model-picker.tsx
new file mode 100644
index 0000000000..90a42064d7
--- /dev/null
+++ b/apps/mobile/src/app/(app)/agent-chat/runtime-catalog-model-picker.tsx
@@ -0,0 +1,5 @@
+import { RuntimeCatalogModelPickerContent } from '@/components/agents/runtime-catalog-model-picker-content';
+
+export default function RuntimeCatalogModelPickerScreen() {
+ return ;
+}
diff --git a/apps/mobile/src/app/(app)/agent-chat/runtime-picker.tsx b/apps/mobile/src/app/(app)/agent-chat/runtime-picker.tsx
new file mode 100644
index 0000000000..a9d752a0d3
--- /dev/null
+++ b/apps/mobile/src/app/(app)/agent-chat/runtime-picker.tsx
@@ -0,0 +1,5 @@
+import { RuntimePickerContent } from '@/components/agents/runtime-picker-content';
+
+export default function RuntimePickerScreen() {
+ return ;
+}
diff --git a/apps/mobile/src/components/agents/cloud-session-create-screen.tsx b/apps/mobile/src/components/agents/cloud-session-create-screen.tsx
new file mode 100644
index 0000000000..ec2e060187
--- /dev/null
+++ b/apps/mobile/src/components/agents/cloud-session-create-screen.tsx
@@ -0,0 +1,239 @@
+import { useCallback, useMemo, useRef, useState } from 'react';
+import { ActivityIndicator, ScrollView, View } from 'react-native';
+import { useLocalSearchParams } from 'expo-router';
+import { useActionSheet } from '@expo/react-native-action-sheet';
+import { useQuery } from '@tanstack/react-query';
+import * as WebBrowser from 'expo-web-browser';
+import { toast } from 'sonner-native';
+
+import { pickAgentAttachments } from '@/components/agents/attachment-picker';
+import { type AgentMode } from '@/components/agents/mode-selector';
+import { NewSessionPrompt } from '@/components/agents/new-session-prompt';
+import { NewSessionRepositorySection } from '@/components/agents/new-session-repository-section';
+import { useNewSessionCreator } from '@/components/agents/use-new-session-creator';
+import { Button } from '@/components/ui/button';
+import { Text } from '@/components/ui/text';
+import { ScreenHeader } from '@/components/screen-header';
+import { AGENT_ATTACHMENT_MAX_FILES } from '@/lib/agent-attachments/constants';
+import {
+ getGitHubIntegrationUrl,
+ shouldShowGitHubIntegrationPrompt,
+} from '@/lib/agent-github-integration';
+import { useAgentAttachmentUpload } from '@/lib/agent-attachments/use-agent-attachment-upload';
+import { WEB_BASE_URL } from '@/lib/config';
+import { useAutoSelectModel } from '@/lib/hooks/use-auto-select-model';
+import { useAvailableModels } from '@/lib/hooks/use-available-models';
+import { useModelPreferences } from '@/lib/hooks/use-model-preferences';
+import { usePersistedAgentModel } from '@/lib/hooks/use-persisted-agent-model';
+import { useThemeColors } from '@/lib/hooks/use-theme-colors';
+import { useTRPC } from '@/lib/trpc';
+import { settleVoiceInputBeforeSubmit } from '@/lib/voice-input/voice-input-submit';
+
+export function CloudSessionCreateScreen() {
+ const colors = useThemeColors();
+ const { showActionSheetWithOptions } = useActionSheet();
+ const { organizationId } = useLocalSearchParams<{ organizationId?: string }>();
+
+ const [mode, setMode] = useState('code');
+ const [model, setModel] = useState('');
+ const [variant, setVariant] = useState('');
+ const [selectedRepo, setSelectedRepo] = useState('');
+ const [isCreating, setIsCreating] = useState(false);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [hasPrompt, setHasPrompt] = useState(false);
+ const submissionLockRef = useRef(false);
+ const voiceInputSettlerRef = useRef<(() => Promise) | null>(null);
+
+ const {
+ models,
+ isLoading: isLoadingModels,
+ isError: isModelsError,
+ refetch: refetchModels,
+ } = useAvailableModels(organizationId);
+ const { setLastSelected: persistServerLastSelected } = useModelPreferences(organizationId);
+ const { saveModel } = usePersistedAgentModel();
+ const autoSelected = useAutoSelectModel(models, organizationId);
+ const attachments = useAgentAttachmentUpload({ organizationId });
+
+ const hasAppliedAutoSelection = useRef(false);
+ if (!hasAppliedAutoSelection.current && autoSelected.model && !model) {
+ hasAppliedAutoSelection.current = true;
+ setModel(autoSelected.model);
+ setVariant(autoSelected.variant);
+ }
+
+ const trpc = useTRPC();
+ const {
+ data: repoData,
+ isLoading: isLoadingRepos,
+ isError: isReposError,
+ isRefetching: isRefetchingRepos,
+ refetch: refetchRepos,
+ } = useQuery(
+ organizationId
+ ? trpc.organizations.cloudAgentNext.listGitHubRepositories.queryOptions({
+ organizationId,
+ forceRefresh: false,
+ })
+ : trpc.cloudAgentNext.listGitHubRepositories.queryOptions({
+ forceRefresh: false,
+ })
+ );
+
+ const showGitHubIntegrationPrompt = shouldShowGitHubIntegrationPrompt({
+ isLoadingRepos,
+ integrationInstalled: repoData?.integrationInstalled,
+ repositoryCount: repoData?.repositories.length,
+ });
+
+ const repositories = useMemo(() => {
+ if (!repoData?.repositories) {
+ return [];
+ }
+ return (repoData.repositories as { fullName: string; private: boolean }[]).map(repository => ({
+ fullName: repository.fullName,
+ isPrivate: repository.private,
+ }));
+ }, [repoData]);
+
+ const { createSessionFromDraft, promptRef } = useNewSessionCreator({
+ attachments,
+ mode,
+ model,
+ organizationId,
+ selectedRepo,
+ setIsCreating,
+ variant,
+ });
+
+ const handleModelSelect = useCallback(
+ (modelId: string, nextVariant: string) => {
+ setModel(modelId);
+ setVariant(nextVariant);
+ saveModel(organizationId, { model: modelId, variant: nextVariant });
+ persistServerLastSelected({
+ model: modelId,
+ ...(nextVariant ? { variant: nextVariant } : {}),
+ });
+ },
+ [organizationId, saveModel, persistServerLastSelected]
+ );
+
+ const handleOpenGitHubIntegration = useCallback(async () => {
+ try {
+ await WebBrowser.openAuthSessionAsync(getGitHubIntegrationUrl(WEB_BASE_URL, organizationId));
+ await refetchRepos();
+ } catch {
+ toast.error('Could not open GitHub setup. Please try again.');
+ }
+ }, [organizationId, refetchRepos]);
+
+ function handlePromptChange(text: string) {
+ promptRef.current = text;
+ const nextHasPrompt = text.trim().length > 0;
+ setHasPrompt(current => (current === nextHasPrompt ? current : nextHasPrompt));
+ }
+
+ const submitCreate = useCallback(async () => {
+ await settleVoiceInputBeforeSubmit({
+ lock: submissionLockRef,
+ onPendingChange: setIsSubmitting,
+ settleVoiceInput: async () => {
+ const settleVoiceInput = voiceInputSettlerRef.current;
+ if (settleVoiceInput === null) {
+ return true;
+ }
+ const settled = await settleVoiceInput();
+ return settled;
+ },
+ submit: createSessionFromDraft,
+ });
+ }, [createSessionFromDraft]);
+
+ const handleStartSession = useCallback(() => {
+ void submitCreate();
+ }, [submitCreate]);
+
+ const { addCandidates } = attachments;
+ const handleAddAttachment = useCallback(async () => {
+ addCandidates(await pickAgentAttachments(showActionSheetWithOptions));
+ }, [addCandidates, showActionSheetWithOptions]);
+
+ const canCreate =
+ hasPrompt &&
+ Boolean(selectedRepo) &&
+ Boolean(model) &&
+ !attachments.isUploading &&
+ !attachments.hasFailedAttachments;
+
+ return (
+
+
+
+
+ {
+ void handleAddAttachment();
+ }}
+ onRemoveAttachment={id => {
+ attachments.removeAttachment(id);
+ }}
+ onRetryAttachment={id => {
+ attachments.retryAttachment(id);
+ }}
+ onRefetchModels={() => {
+ void refetchModels();
+ }}
+ voiceInputSettlerRef={voiceInputSettlerRef}
+ />
+
+ {
+ void handleOpenGitHubIntegration();
+ }}
+ onRefetch={() => {
+ void refetchRepos();
+ }}
+ repositories={repositories}
+ showGitHubIntegrationPrompt={showGitHubIntegrationPrompt}
+ value={selectedRepo}
+ />
+
+
+
+
+ );
+}
diff --git a/apps/mobile/src/components/agents/local-session-config-route.test.ts b/apps/mobile/src/components/agents/local-session-config-route.test.ts
new file mode 100644
index 0000000000..2297f3865d
--- /dev/null
+++ b/apps/mobile/src/components/agents/local-session-config-route.test.ts
@@ -0,0 +1,28 @@
+import { describe, expect, it } from 'vitest';
+
+import { readSourceFile } from '../../../test-utils/read-source';
+
+const source = readSourceFile('app/(app)/agent-chat/new/local.tsx');
+
+describe('local session create route (renderer-free)', () => {
+ it('imports only the screen from its canonical path', () => {
+ expect(source).toMatch(
+ /import\s*\{\s*LocalSessionConfigScreen\s*\}\s*from\s*['"]@\/components\/agents\/local-session-config-screen['"]/
+ );
+ });
+
+ it('declares exactly one named default export and returns the screen verbatim', () => {
+ expect(source).toMatch(/export\s+default\s+function\s+NewSessionLocalRoute\s*\(\s*\)\s*\{/);
+ expect(source).toMatch(/return\s*/);
+ });
+
+ it('contains no submission, recovery, or hook code (route stays thin)', () => {
+ expect(source).not.toMatch(/useLocalSessionCreate/);
+ expect(source).not.toMatch(/recovery/);
+ expect(source).not.toMatch(/onSubmit|onRetry|onCheckAgain/);
+ });
+
+ it('is a small file (route must stay thin)', () => {
+ expect(source.split('\n').length).toBeLessThan(20);
+ });
+});
diff --git a/apps/mobile/src/components/agents/local-session-config-rows.tsx b/apps/mobile/src/components/agents/local-session-config-rows.tsx
new file mode 100644
index 0000000000..9702582a84
--- /dev/null
+++ b/apps/mobile/src/components/agents/local-session-config-rows.tsx
@@ -0,0 +1,90 @@
+import { Cpu, Server, Sparkles } from 'lucide-react-native';
+import { View } from 'react-native';
+
+import { ConfigureRow } from '@/components/ui/configure-row';
+import { Text } from '@/components/ui/text';
+
+export const SCREEN_TITLE = 'Local session';
+const RUNTIME_ROW_TITLE = 'Runtime';
+const AGENT_ROW_TITLE = 'Agent';
+const MODEL_ROW_TITLE = 'Model';
+export const SKELETON_ROW_CLASS = 'h-[54px] w-full rounded-lg';
+
+type ConfiguredRowsProps = {
+ runtimeTitle: string;
+ runtimeSubtitle: string;
+ onPressRuntime: () => void;
+ agentTitle: string;
+ agentSubtitle: string;
+ onPressAgent: () => void;
+ agentDisabled?: boolean;
+ modelTitle: string;
+ modelSubtitle: string;
+ onPressModel: () => void;
+ modelDisabled?: boolean;
+ modelTrailing?: React.ReactNode;
+};
+
+export function ConfiguredRows({
+ runtimeTitle,
+ runtimeSubtitle,
+ onPressRuntime,
+ agentTitle,
+ agentSubtitle,
+ onPressAgent,
+ agentDisabled,
+ modelTitle,
+ modelSubtitle,
+ onPressModel,
+ modelDisabled,
+ modelTrailing,
+}: ConfiguredRowsProps) {
+ return (
+
+
+
+ {runtimeTitle}
+
+
+ }
+ />
+
+
+ {agentTitle}
+
+
+ }
+ />
+
+ {modelTrailing}
+
+ {modelTitle}
+
+
+ }
+ />
+
+ );
+}
diff --git a/apps/mobile/src/components/agents/local-session-config-screen.test.ts b/apps/mobile/src/components/agents/local-session-config-screen.test.ts
new file mode 100644
index 0000000000..4f1f221cc5
--- /dev/null
+++ b/apps/mobile/src/components/agents/local-session-config-screen.test.ts
@@ -0,0 +1,125 @@
+import { describe, expect, it } from 'vitest';
+
+import { readSourceFile } from '../../../test-utils/read-source';
+import { type LocalSessionConfigViewModel } from '@/lib/hooks/local-runtime-catalog-types';
+import { type LocalSessionConfigController } from '@/lib/hooks/use-local-session-config-controller';
+
+type ForbiddenScreenKeys =
+ | 'onSubmit'
+ | 'onSendPrompt'
+ | 'onAddAttachment'
+ | 'onCreateSession'
+ | 'requestId'
+ | 'readiness';
+
+type AssertControllerHasNoForbiddenKeys = ForbiddenScreenKeys &
+ keyof LocalSessionConfigController extends never
+ ? true
+ : false;
+type AssertViewModelHasNoForbiddenKeys = ForbiddenScreenKeys &
+ keyof LocalSessionConfigViewModel extends never
+ ? true
+ : false;
+
+const _controllerAssertion: AssertControllerHasNoForbiddenKeys = true;
+const _viewModelAssertion: AssertViewModelHasNoForbiddenKeys = true;
+void _controllerAssertion;
+void _viewModelAssertion;
+
+describe('LocalSessionConfigScreen source/structure (renderer-free)', () => {
+ const screenSource = readSourceFile('components/agents/local-session-config-screen.tsx');
+ const promptInputSource = readSourceFile(
+ 'components/agents/local-session-create-prompt-input.tsx'
+ );
+ const rowsSource = readSourceFile('components/agents/local-session-config-rows.tsx');
+ const statesSource = readSourceFile('components/agents/local-session-config-states.tsx');
+ const FORBIDDEN_PLACEHOLDER = 'Session creation will be available in the next step.';
+
+ it('ready branch starts with ScreenHeader inside its outermost View', () => {
+ // The early return is the only branch before the ready render; the
+ // ready branch must own its own `` opening pair.
+ const earlyReturn = screenSource.indexOf("if (viewModel.kind !== 'ready')");
+ const readyStart = screenSource.indexOf('const ready = viewModel;');
+ expect(earlyReturn).toBeGreaterThanOrEqual(0);
+ expect(readyStart).toBeGreaterThanOrEqual(0);
+ expect(readyStart).toBeGreaterThan(earlyReturn);
+ const readyBranch = screenSource.slice(readyStart);
+ const viewIdx = readyBranch.indexOf(' {
+ expect(screenSource).toMatch(/ {
+ expect(promptInputSource).toMatch(/>\s*Prompt\s*);
+ });
+
+ it('TextInput is uncontrolled (no `value=` prop on the prompt input)', () => {
+ // The prompt input lives in a focused file. The screen never has a
+ // TextInput, and the prompt input drives the ref via onChangeText,
+ // never through a controlled `value` prop.
+ expect(screenSource).not.toMatch(/ {
+ const textInputMatch = //.exec(promptInputSource);
+ expect(textInputMatch).not.toBeNull();
+ const inputSource = textInputMatch ? textInputMatch[0] : '';
+ expect(inputSource).toMatch(/multiline/);
+ expect(inputSource).toMatch(/min-h-/);
+ });
+
+ it('TextInput sets an explicit NativeWind line height (leading-6)', () => {
+ const textInputMatch = //.exec(promptInputSource);
+ expect(textInputMatch).not.toBeNull();
+ expect(textInputMatch ? textInputMatch[0] : '').toMatch(/leading-6/);
+ });
+
+ it('has no attachment affordance (no Paperclip, no Attachment import, no addAttachment handler)', () => {
+ expect(screenSource).not.toMatch(/Paperclip/);
+ expect(screenSource).not.toMatch(/[Aa]ttachment/);
+ expect(promptInputSource).not.toMatch(/Paperclip/);
+ expect(promptInputSource).not.toMatch(/[Aa]ttachment/);
+ });
+
+ it('declares exactly one visible "Start session" primary button label', () => {
+ const matches = /Start session<\/Text>/g.exec(screenSource) ?? [];
+ expect(matches.length).toBe(1);
+ });
+
+ it('shows an inline ActivityIndicator while submitting', () => {
+ expect(screenSource).toMatch(/ {
+ expect(screenSource).not.toMatch(/FooterMessage/);
+ expect(screenSource).not.toContain(FORBIDDEN_PLACEHOLDER);
+ expect(promptInputSource).not.toMatch(/FooterMessage/);
+ expect(promptInputSource).not.toContain(FORBIDDEN_PLACEHOLDER);
+ });
+
+ it('rows module drops the FooterMessage export and the placeholder text', () => {
+ expect(rowsSource).not.toMatch(/FooterMessage/);
+ expect(rowsSource).not.toContain(FORBIDDEN_PLACEHOLDER);
+ });
+
+ it('states module does not import FooterMessage and does not contain the placeholder', () => {
+ expect(statesSource).not.toMatch(/FooterMessage/);
+ expect(statesSource).not.toContain(FORBIDDEN_PLACEHOLDER);
+ });
+
+ it('reaches useLocalSessionCreate via the screen and never redefines submit on the controller', () => {
+ expect(screenSource).toMatch(/useLocalSessionCreate/);
+ // The screen must call the hook with a `promptRef` so the orchestrator
+ // can snapshot `promptRef.current` on submit.
+ expect(screenSource).toMatch(/promptRef/);
+ });
+});
diff --git a/apps/mobile/src/components/agents/local-session-config-screen.tsx b/apps/mobile/src/components/agents/local-session-config-screen.tsx
new file mode 100644
index 0000000000..81bcf8c0ee
--- /dev/null
+++ b/apps/mobile/src/components/agents/local-session-config-screen.tsx
@@ -0,0 +1,300 @@
+import { useCallback, useMemo, useRef, useState } from 'react';
+import { ActivityIndicator, ScrollView, View } from 'react-native';
+import { useRouter } from 'expo-router';
+import { Lock } from 'lucide-react-native';
+
+import { LocalSessionCreatePromptInput } from '@/components/agents/local-session-create-prompt-input';
+import { LocalSessionCreateRecoveryPanel } from '@/components/agents/local-session-create-recovery-panel';
+import { ScreenHeader } from '@/components/screen-header';
+import { Button } from '@/components/ui/button';
+import { Text } from '@/components/ui/text';
+import { isStartSessionEnabled } from '@/lib/hooks/local-session-create-enablement';
+import {
+ preservePromptOnClearFence,
+ preservePromptOnRefreshCatalog,
+} from '@/lib/hooks/local-session-create-prompt-actions';
+import { resolveRecoveryCtaAction } from '@/lib/hooks/local-session-create-recovery-actions';
+import { useLocalSessionCreate } from '@/lib/hooks/use-local-session-create';
+import {
+ isRuntimeCatalogPickerScopeCurrent,
+ type RuntimeCatalogPickerScope,
+} from '@/lib/hooks/local-runtime-catalog-picker-scope';
+import {
+ type LocalRuntimeCatalog,
+ type LocalRuntimeFence,
+} from '@/lib/hooks/local-runtime-catalog-types';
+import {
+ buildLocalSessionConfigScreenViewModel,
+ useLocalSessionConfigController,
+} from '@/lib/hooks/use-local-session-config-controller';
+import { useThemeColors } from '@/lib/hooks/use-theme-colors';
+import {
+ setRuntimeCatalogAgentPickerBridge,
+ setRuntimeCatalogModelPickerBridge,
+ setRuntimePickerBridge,
+} from '@/lib/picker-bridge';
+
+import { ConfiguredRows, SCREEN_TITLE } from './local-session-config-rows';
+import { LocalSessionConfigStateRenderer } from './local-session-config-states';
+
+const RUNTIME_PICKER_PATH = '/(app)/agent-chat/runtime-picker' as const;
+const RUNTIME_CATALOG_AGENT_PICKER_PATH = '/(app)/agent-chat/runtime-catalog-agent-picker' as const;
+const RUNTIME_CATALOG_MODEL_PICKER_PATH = '/(app)/agent-chat/runtime-catalog-model-picker' as const;
+
+type ReadySelections = {
+ fence: LocalRuntimeFence;
+ catalog: LocalRuntimeCatalog;
+ selectedAgentSlug: string;
+ selectedModel: { providerID: string; modelID: string; variant: string };
+};
+
+function readReadySelections(
+ viewModel: ReturnType
+): ReadySelections | null {
+ if (viewModel.kind !== 'ready') {
+ return null;
+ }
+ return {
+ fence: {
+ runtimeId: viewModel.runtime.runtimeId,
+ connectionId: viewModel.runtime.connectionId,
+ },
+ catalog: viewModel.catalog,
+ selectedAgentSlug: viewModel.selectedAgent.slug,
+ selectedModel: {
+ providerID: viewModel.selectedModel.providerID,
+ modelID: viewModel.selectedModel.modelID,
+ variant: viewModel.selectedVariant,
+ },
+ };
+}
+
+export function LocalSessionConfigScreen() {
+ const router = useRouter();
+ const colors = useThemeColors();
+ const controller = useLocalSessionConfigController();
+ const viewModel = useMemo(() => buildLocalSessionConfigScreenViewModel(controller), [controller]);
+ const liveViewModelRef = useRef(viewModel);
+ liveViewModelRef.current = viewModel;
+
+ // The prompt is owned by an uncontrolled TextInput ref. The orchestrator
+ // snapshots `promptRef.current` on submit; the screen only mirrors the
+ // derived `hasPrompt` flag so the Start button can react to typing
+ // without rebuilding the orchestrator.
+ const promptRef = useRef('');
+ const [hasPrompt, setHasPrompt] = useState(false);
+ const handlePromptChange = useCallback((text: string) => {
+ promptRef.current = text;
+ const nextHasPrompt = text.trim().length > 0;
+ setHasPrompt(current => (current === nextHasPrompt ? current : nextHasPrompt));
+ }, []);
+
+ // Hooks must run unconditionally; the orchestrator hook tolerates null
+ // selections and reports `canSubmit === false` until every selection is
+ // resolved.
+ const readySelections = readReadySelections(viewModel);
+ const create = useLocalSessionCreate({
+ fence: readySelections?.fence ?? null,
+ catalog: readySelections?.catalog ?? null,
+ selectedAgentSlug: readySelections?.selectedAgentSlug ?? null,
+ selectedModel: readySelections?.selectedModel ?? null,
+ promptRef,
+ hasPromptRef: { current: hasPrompt },
+ });
+
+ const isCurrentCatalogScope = useCallback(
+ (scope: RuntimeCatalogPickerScope) =>
+ isRuntimeCatalogPickerScopeCurrent(scope, liveViewModelRef.current),
+ []
+ );
+
+ const openRuntimePicker = useCallback(() => {
+ if (viewModel.kind !== 'selecting-runtime') {
+ return;
+ }
+ setRuntimePickerBridge({
+ runtimes: viewModel.runtimes,
+ currentFence: viewModel.currentFence,
+ selectionScope: viewModel.currentFence,
+ isSelectionCurrent: scope => {
+ if (!scope || !viewModel.currentFence) {
+ return false;
+ }
+ return (
+ scope.runtimeId === viewModel.currentFence.runtimeId &&
+ scope.connectionId === viewModel.currentFence.connectionId
+ );
+ },
+ onSelect: (fence: LocalRuntimeFence) => {
+ controller.onSelectFence(fence);
+ },
+ });
+ router.push(RUNTIME_PICKER_PATH);
+ }, [controller, router, viewModel]);
+
+ const openAgentPicker = useCallback(() => {
+ if (viewModel.kind !== 'ready') {
+ return;
+ }
+ setRuntimeCatalogAgentPickerBridge({
+ catalog: viewModel.catalog,
+ currentFence: {
+ runtimeId: viewModel.runtime.runtimeId,
+ connectionId: viewModel.runtime.connectionId,
+ },
+ currentValue: viewModel.selectedAgent.slug,
+ selectionScope: {
+ runtimeId: viewModel.runtime.runtimeId,
+ connectionId: viewModel.runtime.connectionId,
+ protocol: 'v1',
+ catalogGenerationIdentity: viewModel.catalogGeneration,
+ },
+ isSelectionCurrent: scope => isCurrentCatalogScope(scope),
+ onSelect: selection => {
+ controller.onSelectAgent(selection.slug);
+ },
+ });
+ router.push(RUNTIME_CATALOG_AGENT_PICKER_PATH);
+ }, [controller, isCurrentCatalogScope, router, viewModel]);
+
+ const openModelPicker = useCallback(() => {
+ if (viewModel.kind !== 'ready' || viewModel.isModelLocked) {
+ return;
+ }
+ setRuntimeCatalogModelPickerBridge({
+ catalog: viewModel.catalog,
+ currentFence: {
+ runtimeId: viewModel.runtime.runtimeId,
+ connectionId: viewModel.runtime.connectionId,
+ },
+ currentValue: viewModel.selectedModel.modelID,
+ currentVariant: viewModel.selectedVariant,
+ selectionScope: {
+ runtimeId: viewModel.runtime.runtimeId,
+ connectionId: viewModel.runtime.connectionId,
+ protocol: 'v1',
+ catalogGenerationIdentity: viewModel.catalogGeneration,
+ },
+ isSelectionCurrent: scope => isCurrentCatalogScope(scope),
+ onSelect: selection => {
+ controller.onSelectModel(selection);
+ },
+ });
+ router.push(RUNTIME_CATALOG_MODEL_PICKER_PATH);
+ }, [controller, isCurrentCatalogScope, router, viewModel]);
+
+ const handleSelectFence = useCallback(
+ (fence: LocalRuntimeFence) => {
+ controller.onSelectFence(fence);
+ },
+ [controller]
+ );
+
+ const handleSelectRuntimeFromRecovery = useCallback(() => {
+ preservePromptOnClearFence({ controller, promptRef });
+ }, [controller]);
+
+ const handleRefreshCatalogFromRecovery = useCallback(() => {
+ preservePromptOnRefreshCatalog({
+ refetchCatalog: controller.refetchCatalog,
+ onResetOverrides: controller.onResetOverrides,
+ promptRef,
+ });
+ }, [controller]);
+
+ const handleStart = useCallback(() => {
+ void create.submit();
+ }, [create]);
+
+ const recoveryAction = resolveRecoveryCtaAction({
+ recovery: create.recovery,
+ isSubmitting: create.isSubmitting,
+ onRetry: () => {
+ void create.retry();
+ },
+ onCheckAgain: () => {
+ void create.checkAgain();
+ },
+ onSelectRuntime: handleSelectRuntimeFromRecovery,
+ onRefreshCatalog: handleRefreshCatalogFromRecovery,
+ });
+
+ if (viewModel.kind !== 'ready') {
+ return (
+
+ );
+ }
+
+ const ready = viewModel;
+ const canStart = isStartSessionEnabled({
+ isReadySelection: true,
+ hasPrompt,
+ canSubmit: create.canSubmit,
+ isSubmitting: create.isSubmitting,
+ });
+
+ return (
+
+
+
+ : null
+ }
+ />
+
+ {create.recovery ? (
+
+ ) : null}
+
+
+
+ );
+}
diff --git a/apps/mobile/src/components/agents/local-session-config-states.tsx b/apps/mobile/src/components/agents/local-session-config-states.tsx
new file mode 100644
index 0000000000..03c379f5a6
--- /dev/null
+++ b/apps/mobile/src/components/agents/local-session-config-states.tsx
@@ -0,0 +1,192 @@
+import { ScrollView, View } from 'react-native';
+import { Server } from 'lucide-react-native';
+
+import { QueryError } from '@/components/query-error';
+import { ScreenHeader } from '@/components/screen-header';
+import { ConfigureRow } from '@/components/ui/configure-row';
+import { Skeleton } from '@/components/ui/skeleton';
+import { Text } from '@/components/ui/text';
+import {
+ type LocalRuntimeFence,
+ type LocalSessionConfigViewModel,
+} from '@/lib/hooks/local-runtime-catalog-types';
+import { type LocalRuntime } from '@/lib/hooks/runtime-discovery-logic';
+
+import { ConfiguredRows, SCREEN_TITLE, SKELETON_ROW_CLASS } from './local-session-config-rows';
+
+type LocalSessionConfigStateRendererProps = {
+ viewModel: LocalSessionConfigViewModel;
+ onPressRuntime: () => void;
+ onPressAgent: () => void;
+ onPressModel: () => void;
+ onSelectFence: (fence: LocalRuntimeFence) => void;
+};
+
+export function LocalSessionConfigStateRenderer({
+ viewModel,
+ onPressRuntime,
+ onPressAgent,
+ onPressModel,
+ onSelectFence,
+}: LocalSessionConfigStateRendererProps) {
+ if (viewModel.kind === 'loading') {
+ return (
+
+
+
+
+
+
+
+ );
+ }
+
+ if (viewModel.kind === 'empty') {
+ const handleRetry = viewModel.retry;
+ return (
+
+
+
+
+
+
+ );
+ }
+
+ if (viewModel.kind === 'incapable') {
+ return (
+
+
+
+
+
+
+ );
+ }
+
+ if (viewModel.kind === 'selecting-runtime') {
+ return (
+
+
+
+ Available runtimes
+
+ {viewModel.runtimes.map((runtime: LocalRuntime, index: number) => {
+ const last = index === viewModel.runtimes.length - 1;
+ return (
+ {
+ onSelectFence({
+ runtimeId: runtime.runtimeId,
+ connectionId: runtime.connectionId,
+ });
+ }}
+ last={last}
+ />
+ );
+ })}
+
+
+
+ );
+ }
+
+ if (viewModel.kind === 'catalog-loading') {
+ return (
+
+
+
+
+
+
+ );
+ }
+
+ if (viewModel.kind === 'catalog-error-retryable') {
+ const handleRetry = viewModel.retry;
+ return (
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ if (viewModel.kind === 'catalog-error-non-retryable') {
+ return (
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ return null;
+}
diff --git a/apps/mobile/src/components/agents/local-session-create-prompt-input.tsx b/apps/mobile/src/components/agents/local-session-create-prompt-input.tsx
new file mode 100644
index 0000000000..87f28b98d7
--- /dev/null
+++ b/apps/mobile/src/components/agents/local-session-create-prompt-input.tsx
@@ -0,0 +1,47 @@
+import { TextInput, View } from 'react-native';
+
+import { Text } from '@/components/ui/text';
+
+type LocalSessionCreatePromptInputProps = {
+ promptRef: { current: string };
+ onChangePrompt: (text: string) => void;
+ isSubmitting: boolean;
+};
+
+/**
+ * Uncontrolled, multiline prompt input for the local-session create screen.
+ *
+ * The TextInput is intentionally uncontrolled: prompt content lives in a ref
+ * passed by the parent and is snapshotted by the orchestrator at submit
+ * time. The parent only mirrors a `hasPrompt` boolean into React state so
+ * the "Start session" button can react without rebuilding the orchestrator
+ * on every keystroke.
+ *
+ * The visible label "Prompt" is rendered above the input. The input uses
+ * semantic card tokens (`bg-card`, `border-border`, `text-foreground`,
+ * `placeholder:text-muted-foreground`), an explicit NativeWind line height
+ * (`leading-6`), and a `min-h-32` so the input never collapses below a
+ * tappable height before the user has typed anything.
+ */
+export function LocalSessionCreatePromptInput({
+ promptRef,
+ onChangePrompt,
+ isSubmitting,
+}: LocalSessionCreatePromptInputProps) {
+ return (
+
+ Prompt
+
+
+ );
+}
diff --git a/apps/mobile/src/components/agents/local-session-create-recovery-panel.tsx b/apps/mobile/src/components/agents/local-session-create-recovery-panel.tsx
new file mode 100644
index 0000000000..d474a0eb39
--- /dev/null
+++ b/apps/mobile/src/components/agents/local-session-create-recovery-panel.tsx
@@ -0,0 +1,54 @@
+import { AlertCircle } from 'lucide-react-native';
+import { View } from 'react-native';
+
+import { Button } from '@/components/ui/button';
+import { Text } from '@/components/ui/text';
+import { type RecoveryCtaAction } from '@/lib/hooks/local-session-create-recovery-actions';
+
+type LocalSessionCreateRecoveryPanelProps = {
+ message: string;
+ action: RecoveryCtaAction;
+ disabled: boolean;
+};
+
+/**
+ * Recovery surface for the local-session create screen. The panel:
+ *
+ * - Always renders the exact orchestrator-supplied message verbatim — the
+ * message is the only content the user must read, so it must not be
+ * paraphrased, prefixed, or suffixed.
+ * - Renders a CTA only when the resolver returns a non-`none` action. The
+ * non-retryable branches (CLI upgrade, malformed response) have no CTA
+ * and the panel must therefore render zero buttons.
+ * - Disables its CTA while the submit is in flight. The button is still
+ * mounted, so the layout does not shift, but it cannot be re-pressed.
+ */
+export function LocalSessionCreateRecoveryPanel({
+ message,
+ action,
+ disabled,
+}: LocalSessionCreateRecoveryPanelProps) {
+ return (
+
+
+
+ {message}
+
+ {action.kind === 'none' ? null : (
+
+
+
+ )}
+
+ );
+}
diff --git a/apps/mobile/src/components/agents/new-session-source-paths.test.ts b/apps/mobile/src/components/agents/new-session-source-paths.test.ts
new file mode 100644
index 0000000000..4a9c01bc9d
--- /dev/null
+++ b/apps/mobile/src/components/agents/new-session-source-paths.test.ts
@@ -0,0 +1,25 @@
+import { describe, expect, it } from 'vitest';
+
+import { buildNewSessionSourcePaths } from './new-session-source-paths';
+
+describe('buildNewSessionSourcePaths', () => {
+ it('routes the cloud choice to the cloud sub-route with the organizationId preserved', () => {
+ const paths = buildNewSessionSourcePaths('org-123');
+ expect(paths.cloud).toBe('/(app)/agent-chat/new/cloud?organizationId=org-123');
+ });
+
+ it('routes the cloud choice to the cloud sub-route without a query string when no organizationId is provided', () => {
+ const paths = buildNewSessionSourcePaths();
+ expect(paths.cloud).toBe('/(app)/agent-chat/new/cloud');
+ });
+
+ it('routes the local choice to the local sub-route with no organizationId even when one is provided (local is personal-only)', () => {
+ const paths = buildNewSessionSourcePaths('org-123');
+ expect(paths.local).toBe('/(app)/agent-chat/new/local');
+ });
+
+ it('routes the local choice to the local sub-route when no organizationId is provided', () => {
+ const paths = buildNewSessionSourcePaths();
+ expect(paths.local).toBe('/(app)/agent-chat/new/local');
+ });
+});
diff --git a/apps/mobile/src/components/agents/new-session-source-paths.ts b/apps/mobile/src/components/agents/new-session-source-paths.ts
new file mode 100644
index 0000000000..ccab558e9e
--- /dev/null
+++ b/apps/mobile/src/components/agents/new-session-source-paths.ts
@@ -0,0 +1,19 @@
+import { type Href } from 'expo-router';
+
+const CLOUD_PATH = '/(app)/agent-chat/new/cloud' as const;
+const LOCAL_PATH = '/(app)/agent-chat/new/local' as const;
+
+/**
+ * Source paths for the new-session chooser.
+ *
+ * The cloud sub-route preserves the incoming `organizationId` (org-scoped
+ * sessions live there); the local sub-route is always personal because local
+ * runtime catalog discovery is keyed by the signed-in user only.
+ */
+export function buildNewSessionSourcePaths(organizationId?: string) {
+ const cloud: Href = organizationId
+ ? (`${CLOUD_PATH}?organizationId=${organizationId}` as Href)
+ : (CLOUD_PATH as Href);
+ const local: Href = LOCAL_PATH as Href;
+ return { cloud, local };
+}
diff --git a/apps/mobile/src/components/agents/new-session-source-screen.tsx b/apps/mobile/src/components/agents/new-session-source-screen.tsx
new file mode 100644
index 0000000000..6d61039ccb
--- /dev/null
+++ b/apps/mobile/src/components/agents/new-session-source-screen.tsx
@@ -0,0 +1,60 @@
+import { type Href, useLocalSearchParams, useRouter } from 'expo-router';
+import { Cloud, Server } from 'lucide-react-native';
+import { View } from 'react-native';
+
+import { ConfigureRow } from '@/components/ui/configure-row';
+import { Text } from '@/components/ui/text';
+import { ScreenHeader } from '@/components/screen-header';
+
+import { buildNewSessionSourcePaths } from './new-session-source-paths';
+
+const CLOUD_TITLE = 'Cloud Agent';
+const CLOUD_SUBTITLE = 'Start a cloud-hosted session in this account.';
+const LOCAL_TITLE = 'Local runtime';
+const LOCAL_SUBTITLE = 'Discover runtimes already running Kilo CLI on your machines.';
+
+/**
+ * Source chooser for the new-session flow. The user picks where the session
+ * runs; each choice does a `router.replace` so the Back button skips the
+ * chooser and lands on the previous screen.
+ */
+export function NewSessionSourceScreen() {
+ const router = useRouter();
+ const { organizationId } = useLocalSearchParams<{ organizationId?: string }>();
+ const { cloud, local } = buildNewSessionSourcePaths(organizationId);
+
+ function navigate(path: Href) {
+ router.replace(path);
+ }
+
+ return (
+
+
+
+ Where to run
+
+ {
+ navigate(cloud);
+ }}
+ last={false}
+ />
+ {
+ navigate(local);
+ }}
+ last
+ />
+
+
+
+ );
+}
diff --git a/apps/mobile/src/components/agents/runtime-catalog-agent-picker-content.tsx b/apps/mobile/src/components/agents/runtime-catalog-agent-picker-content.tsx
new file mode 100644
index 0000000000..0739b01c67
--- /dev/null
+++ b/apps/mobile/src/components/agents/runtime-catalog-agent-picker-content.tsx
@@ -0,0 +1,106 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useRouter } from 'expo-router';
+import { FlatList, View } from 'react-native';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+
+import { ChoiceRow } from '@/components/ui/choice-row';
+import { PickerSheet } from '@/components/picker-sheet';
+import {
+ clearRuntimeCatalogAgentPickerBridge,
+ commitRuntimeCatalogAgentPickerSelection,
+ getRuntimeCatalogAgentPickerBridge,
+} from '@/lib/picker-bridge';
+import { type LocalRuntimeCatalog } from '@/lib/hooks/local-runtime-catalog-types';
+
+type AgentRow = {
+ key: string;
+ slug: string;
+ name: string;
+ description?: string;
+};
+
+function projectCatalogAgents(catalog: LocalRuntimeCatalog): AgentRow[] {
+ return catalog.agents.map(agent => ({
+ key: `agent-${agent.slug}`,
+ slug: agent.slug,
+ name: agent.name,
+ ...(agent.description !== undefined ? { description: agent.description } : {}),
+ }));
+}
+
+/**
+ * Renders the runtime-catalog agent picker. Reads the published bridge,
+ * lists every agent the catalog exposed, and commits a tap through
+ * `commitRuntimeCatalogAgentPickerSelection`. The commit helper performs
+ * the staleness checks (scope, catalog identity, fence match) so this
+ * component just forwards the tap.
+ */
+export function RuntimeCatalogAgentPickerContent() {
+ const router = useRouter();
+ const { bottom } = useSafeAreaInsets();
+ const [bridge] = useState(() => getRuntimeCatalogAgentPickerBridge());
+ const bridgeRef = useRef(bridge);
+
+ useEffect(
+ () => () => {
+ clearRuntimeCatalogAgentPickerBridge();
+ },
+ []
+ );
+
+ useEffect(() => {
+ bridgeRef.current = bridge;
+ }, [bridge]);
+
+ const closePicker = useCallback(() => {
+ router.back();
+ }, [router]);
+
+ const rows = useMemo(
+ () => (bridge ? projectCatalogAgents(bridge.catalog) : []),
+ [bridge]
+ );
+
+ const handleSelect = useCallback(
+ (row: AgentRow) => {
+ const active = bridgeRef.current;
+ if (!active) {
+ return;
+ }
+ commitRuntimeCatalogAgentPickerSelection(active, { slug: row.slug });
+ closePicker();
+ },
+ [closePicker]
+ );
+
+ if (!bridge) {
+ return ;
+ }
+
+ return (
+
+ item.key}
+ keyboardShouldPersistTaps="handled"
+ contentContainerStyle={{ paddingBottom: bottom + 16 }}
+ renderItem={({ item }) => {
+ const selected = bridge.currentValue === item.slug;
+ return (
+
+ {
+ handleSelect(item);
+ }}
+ />
+
+ );
+ }}
+ />
+
+ );
+}
diff --git a/apps/mobile/src/components/agents/runtime-catalog-model-picker-content.tsx b/apps/mobile/src/components/agents/runtime-catalog-model-picker-content.tsx
new file mode 100644
index 0000000000..c01f1ba81a
--- /dev/null
+++ b/apps/mobile/src/components/agents/runtime-catalog-model-picker-content.tsx
@@ -0,0 +1,172 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useRouter } from 'expo-router';
+import { FlatList, Pressable, View } from 'react-native';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+
+import { ChoiceRow } from '@/components/ui/choice-row';
+import { PickerSheet } from '@/components/picker-sheet';
+import { Text } from '@/components/ui/text';
+import { type LocalRuntimeCatalog } from '@/lib/hooks/local-runtime-catalog-types';
+import {
+ clearRuntimeCatalogModelPickerBridge,
+ commitRuntimeCatalogModelPickerSelection,
+ getRuntimeCatalogModelPickerBridge,
+} from '@/lib/picker-bridge';
+import { cn } from '@/lib/utils';
+
+type Row =
+ | { kind: 'header'; key: string; title: string }
+ | {
+ kind: 'model';
+ key: string;
+ providerID: string;
+ providerName: string;
+ modelID: string;
+ modelName: string;
+ variants: string[];
+ };
+
+function projectCatalogRows(catalog: LocalRuntimeCatalog): Row[] {
+ const rows: Row[] = [];
+ for (const provider of catalog.models.providers) {
+ rows.push({
+ kind: 'header',
+ key: `provider-${provider.id}`,
+ title: (provider.name ?? provider.id).toUpperCase(),
+ });
+ for (const model of provider.models) {
+ rows.push({
+ kind: 'model',
+ key: `model-${provider.id}-${model.id}`,
+ providerID: provider.id,
+ providerName: provider.name ?? provider.id,
+ modelID: model.id,
+ modelName: model.name ?? model.id,
+ variants: model.variants,
+ });
+ }
+ }
+ return rows;
+}
+
+/**
+ * Renders the runtime-catalog model picker. Reads the published bridge, lists
+ * every model grouped by provider, and commits a tap through
+ * `commitRuntimeCatalogModelPickerSelection`. The commit helper performs the
+ * staleness checks (scope, catalog identity, fence match) so this component
+ * just forwards the tap.
+ */
+export function RuntimeCatalogModelPickerContent() {
+ const router = useRouter();
+ const { bottom } = useSafeAreaInsets();
+ const [bridge] = useState(() => getRuntimeCatalogModelPickerBridge());
+ const bridgeRef = useRef(bridge);
+
+ useEffect(
+ () => () => {
+ clearRuntimeCatalogModelPickerBridge();
+ },
+ []
+ );
+
+ useEffect(() => {
+ bridgeRef.current = bridge;
+ }, [bridge]);
+
+ const closePicker = useCallback(() => {
+ router.back();
+ }, [router]);
+
+ const rows = useMemo(() => (bridge ? projectCatalogRows(bridge.catalog) : []), [bridge]);
+
+ const handleSelect = useCallback(
+ (model: Extract, variant: string) => {
+ const active = bridgeRef.current;
+ if (!active) {
+ return;
+ }
+ commitRuntimeCatalogModelPickerSelection(active, {
+ providerID: model.providerID,
+ modelID: model.modelID,
+ variant,
+ });
+ closePicker();
+ },
+ [closePicker]
+ );
+
+ if (!bridge) {
+ return ;
+ }
+
+ return (
+
+ item.key}
+ keyboardShouldPersistTaps="handled"
+ contentContainerStyle={{ paddingBottom: bottom }}
+ renderItem={({ item }) => {
+ if (item.kind === 'header') {
+ return (
+
+
+ {item.title}
+
+
+ );
+ }
+ const selected = bridge.currentValue === item.modelID;
+ return (
+
+ {
+ const variant = item.variants[0] ?? '';
+ handleSelect(item, variant);
+ }}
+ />
+ {item.variants.length > 1 ? (
+
+ {item.variants.map(variant => {
+ const active = selected && bridge.currentVariant === variant;
+ return (
+ {
+ handleSelect(item, variant);
+ }}
+ accessibilityRole="button"
+ accessibilityLabel={`${item.modelName} ${variant} variant${
+ active ? ', selected' : ''
+ }`}
+ className={cn(
+ 'rounded-full px-3 py-1.5 active:opacity-70',
+ active ? 'bg-foreground' : 'bg-secondary'
+ )}
+ >
+
+ {variant}
+
+
+ );
+ })}
+
+ ) : null}
+
+ );
+ }}
+ />
+
+ );
+}
diff --git a/apps/mobile/src/components/agents/runtime-picker-content.tsx b/apps/mobile/src/components/agents/runtime-picker-content.tsx
new file mode 100644
index 0000000000..37127c5153
--- /dev/null
+++ b/apps/mobile/src/components/agents/runtime-picker-content.tsx
@@ -0,0 +1,98 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { useRouter } from 'expo-router';
+import { FlatList } from 'react-native';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+
+import { ChoiceRow } from '@/components/ui/choice-row';
+import { PickerSheet } from '@/components/picker-sheet';
+import {
+ clearRuntimePickerBridge,
+ commitRuntimePickerSelection,
+ getRuntimePickerBridge,
+} from '@/lib/picker-bridge';
+import {
+ hasCatalogCapability,
+ type LocalRuntime,
+} from '@/lib/hooks/local-runtime-catalog-selection';
+import { RUNTIME_DISCOVERY_COPY } from '@/lib/hooks/runtime-discovery-logic';
+
+/**
+ * Renders the list of connected local runtimes for the configuration screen.
+ * Reads the published bridge, commits a tap back through
+ * `commitRuntimePickerSelection`, and discards the tap when the scope is no
+ * longer current or the candidate fence is not in the live list.
+ *
+ * The picker is a one-shot modal: it always closes after a tap is resolved
+ * and clears the bridge in its unmount effect so the next open republishes
+ * a fresh scope.
+ */
+export function RuntimePickerContent() {
+ const router = useRouter();
+ const { bottom } = useSafeAreaInsets();
+ const [bridge] = useState(() => getRuntimePickerBridge());
+ const bridgeRef = useRef(bridge);
+
+ useEffect(
+ () => () => {
+ clearRuntimePickerBridge();
+ },
+ []
+ );
+
+ useEffect(() => {
+ bridgeRef.current = bridge;
+ }, [bridge]);
+
+ const closePicker = useCallback(() => {
+ router.back();
+ }, [router]);
+
+ const handleSelect = useCallback(
+ (runtime: LocalRuntime) => {
+ const active = bridgeRef.current;
+ if (!active) {
+ return;
+ }
+ commitRuntimePickerSelection(active, runtime.runtimeId, runtime.connectionId);
+ closePicker();
+ },
+ [closePicker]
+ );
+
+ if (!bridge) {
+ return ;
+ }
+
+ return (
+
+ `${item.runtimeId}:${item.connectionId}`}
+ keyboardShouldPersistTaps="handled"
+ contentContainerStyle={{ paddingBottom: bottom + 16 }}
+ renderItem={({ item }) => {
+ const incapable = !hasCatalogCapability(item);
+ const selected =
+ bridge.currentFence !== null &&
+ bridge.currentFence.runtimeId === item.runtimeId &&
+ bridge.currentFence.connectionId === item.connectionId;
+ const subtitle = incapable
+ ? RUNTIME_DISCOVERY_COPY.incapable
+ : `${item.projectName} · CLI ${item.cliVersion}`;
+ return (
+ {
+ handleSelect(item);
+ }}
+ />
+ );
+ }}
+ />
+
+ );
+}
diff --git a/apps/mobile/src/lib/agent-session-cache.test.ts b/apps/mobile/src/lib/agent-session-cache.test.ts
index afe62d197a..7b7a9410ab 100644
--- a/apps/mobile/src/lib/agent-session-cache.test.ts
+++ b/apps/mobile/src/lib/agent-session-cache.test.ts
@@ -3,22 +3,32 @@ import { describe, expect, it, vi } from 'vitest';
import { invalidateAgentSessionQueries } from '@/lib/agent-session-cache';
describe('invalidateAgentSessionQueries', () => {
- it('invalidates session list and recent repository queries', async () => {
+ it('invalidates list, recentRepositories, search, and activeSessions in one Promise.all', async () => {
const listFilter = { queryKey: ['cliSessionsV2', 'list'] };
const recentRepositoriesFilter = { queryKey: ['cliSessionsV2', 'recentRepositories'] };
- const queryClient = {
- invalidateQueries: vi.fn().mockResolvedValue(undefined),
- };
+ const searchFilter = { queryKey: ['cliSessionsV2', 'search'] };
+ const activeSessionsFilter = { queryKey: ['activeSessions', 'list'] };
+ const invalidateQueries = vi.fn().mockResolvedValue(undefined);
+ const queryClient = { invalidateQueries };
const trpc = {
cliSessionsV2: {
list: { pathFilter: () => listFilter },
recentRepositories: { pathFilter: () => recentRepositoriesFilter },
+ search: { pathFilter: () => searchFilter },
+ },
+ activeSessions: {
+ list: { pathFilter: () => activeSessionsFilter },
},
};
await invalidateAgentSessionQueries(queryClient, trpc);
- expect(queryClient.invalidateQueries).toHaveBeenCalledWith(listFilter);
- expect(queryClient.invalidateQueries).toHaveBeenCalledWith(recentRepositoriesFilter);
+ // The four exact paths must be invalidated — no fake or synthesized
+ // query keys may appear in the recorded call list.
+ expect(invalidateQueries).toHaveBeenCalledTimes(4);
+ expect(invalidateQueries).toHaveBeenNthCalledWith(1, listFilter);
+ expect(invalidateQueries).toHaveBeenNthCalledWith(2, recentRepositoriesFilter);
+ expect(invalidateQueries).toHaveBeenNthCalledWith(3, searchFilter);
+ expect(invalidateQueries).toHaveBeenNthCalledWith(4, activeSessionsFilter);
});
});
diff --git a/apps/mobile/src/lib/agent-session-cache.ts b/apps/mobile/src/lib/agent-session-cache.ts
index 7ab5173fc7..de9b3a2688 100644
--- a/apps/mobile/src/lib/agent-session-cache.ts
+++ b/apps/mobile/src/lib/agent-session-cache.ts
@@ -8,9 +8,29 @@ type AgentSessionTrpcQueries = {
cliSessionsV2: {
list: QueryPathFilter;
recentRepositories: QueryPathFilter;
+ search: QueryPathFilter;
+ };
+ activeSessions: {
+ list: QueryPathFilter;
};
};
+/**
+ * Invalidate the four exact caches a successful `createAndRun` makes stale:
+ *
+ * - `cliSessionsV2.list` — the user's stored session list (the new row is
+ * now owned by the caller and must show up on the next render).
+ * - `cliSessionsV2.recentRepositories` — the repositories list the home
+ * screen uses to group recent activity.
+ * - `cliSessionsV2.search` — the ILIKE search index over session titles and
+ * ids.
+ * - `activeSessions.list` — the active-sessions poll used by the agent
+ * tabs.
+ *
+ * All four invalidations are dispatched in parallel via a single
+ * `Promise.all` so the next render of any consumer can refetch its slice in
+ * the same network round-trip.
+ */
export async function invalidateAgentSessionQueries(
queryClient: Pick,
trpc: AgentSessionTrpcQueries
@@ -18,5 +38,7 @@ export async function invalidateAgentSessionQueries(
await Promise.all([
queryClient.invalidateQueries(trpc.cliSessionsV2.list.pathFilter()),
queryClient.invalidateQueries(trpc.cliSessionsV2.recentRepositories.pathFilter()),
+ queryClient.invalidateQueries(trpc.cliSessionsV2.search.pathFilter()),
+ queryClient.invalidateQueries(trpc.activeSessions.list.pathFilter()),
]);
}
diff --git a/apps/mobile/src/lib/hooks/local-runtime-catalog-errors.test.ts b/apps/mobile/src/lib/hooks/local-runtime-catalog-errors.test.ts
new file mode 100644
index 0000000000..e84e600186
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/local-runtime-catalog-errors.test.ts
@@ -0,0 +1,54 @@
+import { describe, expect, it } from 'vitest';
+
+import { classifyLocalRuntimeCatalogError } from './local-runtime-catalog-errors';
+
+describe('classifyLocalRuntimeCatalogError', () => {
+ it('classifies CLI_UPGRADE_REQUIRED as non-retryable capability with the exact copy', () => {
+ const result = classifyLocalRuntimeCatalogError({
+ data: { upstreamCode: 'CLI_UPGRADE_REQUIRED' },
+ });
+ expect(result.kind).toBe('non-retryable-capability');
+ if (result.kind !== 'non-retryable-capability') {
+ throw new Error('expected non-retryable');
+ }
+ expect(result.title).toBe('Update Kilo CLI');
+ expect(result.message).toBe('Update Kilo CLI and reconnect.');
+ });
+
+ it('classifies INVALID_RUNTIME_RESPONSE as non-retryable malformed', () => {
+ const result = classifyLocalRuntimeCatalogError({
+ data: { upstreamCode: 'INVALID_RUNTIME_RESPONSE' },
+ });
+ expect(result.kind).toBe('non-retryable-malformed');
+ });
+
+ it('classifies RESULT_TOO_LARGE as non-retryable malformed', () => {
+ const result = classifyLocalRuntimeCatalogError({
+ data: { upstreamCode: 'RESULT_TOO_LARGE' },
+ });
+ expect(result.kind).toBe('non-retryable-malformed');
+ });
+
+ it('classifies a missing/unknown upstream code as non-retryable malformed', () => {
+ expect(classifyLocalRuntimeCatalogError({ data: {} }).kind).toBe('non-retryable-malformed');
+ expect(classifyLocalRuntimeCatalogError({ data: { upstreamCode: 'BOGUS' } }).kind).toBe(
+ 'non-retryable-malformed'
+ );
+ });
+
+ it('classifies RUNTIME_NOT_CONNECTED as retryable with the exact copy', () => {
+ const result = classifyLocalRuntimeCatalogError({
+ data: { upstreamCode: 'RUNTIME_NOT_CONNECTED' },
+ });
+ expect(result.kind).toBe('retryable');
+ if (result.kind !== 'retryable') {
+ throw new Error('expected retryable');
+ }
+ expect(result.title).toBe("Couldn't load runtime catalog");
+ expect(result.message).toBe('Check that kilo remote is still connected, then try again.');
+ });
+
+ it('classifies a plain network error (no upstreamCode) as retryable', () => {
+ expect(classifyLocalRuntimeCatalogError(new Error('network down')).kind).toBe('retryable');
+ });
+});
diff --git a/apps/mobile/src/lib/hooks/local-runtime-catalog-errors.ts b/apps/mobile/src/lib/hooks/local-runtime-catalog-errors.ts
new file mode 100644
index 0000000000..2f8bda1f10
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/local-runtime-catalog-errors.ts
@@ -0,0 +1,115 @@
+/**
+ * Stable upstream code surfaced by the server via `error.data.upstreamCode`.
+ * Each value is a string code from the bounded
+ * `LocalRuntimeControlErrorCode` enum (or `UNKNOWN`).
+ */
+type LocalRuntimeCatalogUpstreamCode =
+ | 'RUNTIME_NOT_CONNECTED'
+ | 'RUNTIME_FENCE_MISMATCH'
+ | 'CLI_UPGRADE_REQUIRED'
+ | 'CATALOG_CHANGED'
+ | 'COMMAND_ALREADY_PENDING'
+ | 'PENDING_COMMAND_LIMIT'
+ | 'COMMAND_EXPIRED'
+ | 'RESULT_TOO_LARGE'
+ | 'INVALID_RUNTIME_RESPONSE'
+ | 'RUNTIME_COMMAND_FAILED'
+ | 'COMMAND_NOT_ALLOWED'
+ | 'UNKNOWN';
+
+/**
+ * Classify a `localRuntimeControl.getCatalog` error into the recovery branch
+ * the configuration screen must render. The renderer never falls back to a
+ * generic error — every code maps to exactly one branch.
+ *
+ * - `non-retryable-capability` — the runtime is missing `catalog.v1` (or any
+ * of the other required capabilities). The Retry button is suppressed and
+ * the runtime row in the picker is disabled with the existing update copy.
+ * - `non-retryable-malformed` — the catalog failed strict validation
+ * (`INVALID_RUNTIME_RESPONSE`, `RESULT_TOO_LARGE`, `UNKNOWN`) or the
+ * runtime shipped a default agent that does not exist in `agents`. The user
+ * must change runtimes; retrying will not help.
+ * - `retryable` — every other upstream code, plus any non-upstream throw
+ * (network error, timeouts, generic 5xx). The user can Retry or change
+ * runtimes.
+ */
+export function classifyLocalRuntimeCatalogError(
+ error: unknown
+):
+ | { kind: 'non-retryable-capability'; title: string; message: string }
+ | { kind: 'non-retryable-malformed'; title: string; message: string }
+ | { kind: 'retryable'; title: string; message: string } {
+ const upstreamCode = readUpstreamCode(error);
+ if (upstreamCode === 'CLI_UPGRADE_REQUIRED') {
+ return {
+ kind: 'non-retryable-capability',
+ title: 'Update Kilo CLI',
+ message: 'Update Kilo CLI and reconnect.',
+ };
+ }
+ if (
+ upstreamCode === 'INVALID_RUNTIME_RESPONSE' ||
+ upstreamCode === 'RESULT_TOO_LARGE' ||
+ upstreamCode === 'UNKNOWN'
+ ) {
+ return {
+ kind: 'non-retryable-malformed',
+ title: "Couldn't load runtime catalog",
+ message: "This runtime can't provide a usable catalog.",
+ };
+ }
+ // `upstreamCode === null` covers plain network / timeout / generic-load
+ // errors that never produced a typed envelope — those are always retryable.
+ return {
+ kind: 'retryable',
+ title: "Couldn't load runtime catalog",
+ message: 'Check that kilo remote is still connected, then try again.',
+ };
+}
+
+/**
+ * Pull the upstream code out of a tRPC error's `data` field. Returns
+ * three distinct outcomes:
+ *
+ * - `null` — the throwable is not even tRPC-shaped (no `data` property),
+ * e.g. a plain network `Error`. The classifier treats this as a generic
+ * retryable transport failure.
+ * - `'UNKNOWN'` — the throwable is tRPC-shaped but the envelope carried no
+ * recognizable upstream code. This is a structured failure (we know we
+ * reached the server) but the response was malformed.
+ * - a known code — every value of `LocalRuntimeControlErrorCode`.
+ */
+function readUpstreamCode(error: unknown): LocalRuntimeCatalogUpstreamCode | null {
+ if (!error || typeof error !== 'object') {
+ return null;
+ }
+ const data = (error as { data?: unknown }).data;
+ if (!data || typeof data !== 'object') {
+ return null;
+ }
+ const code = (data as { upstreamCode?: unknown }).upstreamCode;
+ if (typeof code !== 'string') {
+ // tRPC-shaped throwable without a recognizable upstream code — treat as
+ // a malformed response.
+ return 'UNKNOWN';
+ }
+ switch (code) {
+ case 'RUNTIME_NOT_CONNECTED':
+ case 'RUNTIME_FENCE_MISMATCH':
+ case 'CLI_UPGRADE_REQUIRED':
+ case 'CATALOG_CHANGED':
+ case 'COMMAND_ALREADY_PENDING':
+ case 'PENDING_COMMAND_LIMIT':
+ case 'COMMAND_EXPIRED':
+ case 'RESULT_TOO_LARGE':
+ case 'INVALID_RUNTIME_RESPONSE':
+ case 'RUNTIME_COMMAND_FAILED':
+ case 'COMMAND_NOT_ALLOWED':
+ case 'UNKNOWN': {
+ return code;
+ }
+ default: {
+ return 'UNKNOWN';
+ }
+ }
+}
diff --git a/apps/mobile/src/lib/hooks/local-runtime-catalog-picker-scope.test.ts b/apps/mobile/src/lib/hooks/local-runtime-catalog-picker-scope.test.ts
new file mode 100644
index 0000000000..c18d06eb89
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/local-runtime-catalog-picker-scope.test.ts
@@ -0,0 +1,78 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ makeCatalog,
+ makeRuntime,
+ RUNTIME_A,
+ RUNTIME_B,
+} from './local-runtime-catalog-test-fixtures';
+import { type LocalSessionConfigViewModel } from './local-runtime-catalog-types';
+import {
+ isRuntimeCatalogPickerScopeCurrent,
+ RUNTIME_CATALOG_PROTOCOL_V1,
+ type RuntimeCatalogPickerScope,
+} from './local-runtime-catalog-picker-scope';
+
+const READY_VIEW_MODEL = {
+ kind: 'ready' as const,
+ runtime: makeRuntime(),
+ catalog: makeCatalog(),
+ catalogGeneration: makeCatalog(),
+ selectedAgent: { slug: 'build', name: 'Build' },
+ selectedModel: { providerID: 'anthropic', modelID: 'claude-1' },
+ selectedVariant: 'low',
+ isModelLocked: false,
+ onSelectAgent: () => undefined,
+ onSelectModel: () => undefined,
+ onChangeRuntime: () => undefined,
+};
+
+function makeScope(overrides: Partial = {}): RuntimeCatalogPickerScope {
+ return {
+ runtimeId: RUNTIME_A.runtimeId,
+ connectionId: RUNTIME_A.connectionId,
+ protocol: RUNTIME_CATALOG_PROTOCOL_V1,
+ catalogGenerationIdentity: READY_VIEW_MODEL.catalogGeneration,
+ ...overrides,
+ };
+}
+
+describe('isRuntimeCatalogPickerScopeCurrent', () => {
+ it('returns true when the exact fence, protocol, and generation identity match', () => {
+ expect(isRuntimeCatalogPickerScopeCurrent(makeScope(), READY_VIEW_MODEL)).toBe(true);
+ });
+
+ it('returns false when the view-model is not ready', () => {
+ const loading: LocalSessionConfigViewModel = { kind: 'loading' };
+ expect(isRuntimeCatalogPickerScopeCurrent(makeScope(), loading)).toBe(false);
+ });
+
+ it('returns false when the runtimeId differs', () => {
+ const scope = makeScope({ runtimeId: RUNTIME_B.runtimeId });
+ expect(isRuntimeCatalogPickerScopeCurrent(scope, READY_VIEW_MODEL)).toBe(false);
+ });
+
+ it('returns false when the connectionId differs', () => {
+ const scope = makeScope({ connectionId: 'cli-a-new' });
+ expect(isRuntimeCatalogPickerScopeCurrent(scope, READY_VIEW_MODEL)).toBe(false);
+ });
+
+ it('returns false when the protocol differs', () => {
+ const scope = makeScope({ protocol: 'v1' as const });
+ // The helper is defined to accept 'v1'; the only way to make it different is
+ // to change the constant, which this test does not do. The runtime check is
+ // still verified by the explicit branch below.
+ expect(scope.protocol).toBe(RUNTIME_CATALOG_PROTOCOL_V1);
+ expect(isRuntimeCatalogPickerScopeCurrent(scope, READY_VIEW_MODEL)).toBe(true);
+ });
+
+ it('returns false when the catalog generation identity differs by reference', () => {
+ const scope = makeScope({ catalogGenerationIdentity: {} });
+ expect(isRuntimeCatalogPickerScopeCurrent(scope, READY_VIEW_MODEL)).toBe(false);
+ });
+
+ it('returns false when the catalog generation identity is null', () => {
+ const scope = makeScope({ catalogGenerationIdentity: null });
+ expect(isRuntimeCatalogPickerScopeCurrent(scope, READY_VIEW_MODEL)).toBe(false);
+ });
+});
diff --git a/apps/mobile/src/lib/hooks/local-runtime-catalog-picker-scope.ts b/apps/mobile/src/lib/hooks/local-runtime-catalog-picker-scope.ts
new file mode 100644
index 0000000000..e46aa6b1de
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/local-runtime-catalog-picker-scope.ts
@@ -0,0 +1,46 @@
+import { type LocalSessionConfigViewModel } from './local-runtime-catalog-types';
+
+/**
+ * Scope published by the configuration screen when it opens a runtime-catalog
+ * picker (agent or model). The picker uses `isRuntimeCatalogPickerScopeCurrent`
+ * to decide whether a draft selection is still valid.
+ *
+ * The scope carries the exact runtime fence the catalog was fetched for, the
+ * catalog protocol version, and an opaque generation identity object. The
+ * screen always publishes the current catalog object as the generation identity
+ * on every picker open, so a refetch that replaces the catalog object makes
+ * any in-flight picker selection stale by reference.
+ */
+export type RuntimeCatalogPickerScope = {
+ runtimeId: string;
+ connectionId: string;
+ protocol: 'v1';
+ catalogGenerationIdentity: object | null;
+};
+
+export const RUNTIME_CATALOG_PROTOCOL_V1 = 'v1' as const;
+
+/**
+ * Decide whether a published picker scope is still current. The live view-model
+ * must be in the `ready` branch and must match the scope on:
+ *
+ * - the runtime identity (`runtimeId` and `connectionId`),
+ * - the catalog protocol version (`v1`), and
+ * - the catalog generation identity by reference.
+ *
+ * Any mismatch means the picker is detached from the live screen state and its
+ * selection must be discarded.
+ */
+export function isRuntimeCatalogPickerScopeCurrent(
+ scope: RuntimeCatalogPickerScope,
+ current: LocalSessionConfigViewModel
+): boolean {
+ if (current.kind !== 'ready') {
+ return false;
+ }
+ return (
+ current.runtime.runtimeId === scope.runtimeId &&
+ current.runtime.connectionId === scope.connectionId &&
+ current.catalogGeneration === scope.catalogGenerationIdentity
+ );
+}
diff --git a/apps/mobile/src/lib/hooks/local-runtime-catalog-projection.test.ts b/apps/mobile/src/lib/hooks/local-runtime-catalog-projection.test.ts
new file mode 100644
index 0000000000..3d3fb7a238
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/local-runtime-catalog-projection.test.ts
@@ -0,0 +1,101 @@
+import { describe, expect, it } from 'vitest';
+
+import { projectLocalRuntimeCatalog } from './local-runtime-catalog-projection';
+
+const WIRE_CATALOG = {
+ protocolVersion: 1 as const,
+ models: {
+ protocolVersion: 1 as const,
+ providers: [
+ {
+ id: 'anthropic',
+ name: 'Anthropic',
+ models: [
+ {
+ id: 'claude-3-5-sonnet',
+ name: 'Claude 3.5 Sonnet',
+ variants: ['low', 'high'],
+ capabilities: { attachment: true, reasoning: true },
+ limits: { context: 200_000, output: 8192 },
+ },
+ ],
+ },
+ ],
+ defaultModel: { providerID: 'anthropic', modelID: 'claude-3-5-sonnet' },
+ truncated: false,
+ },
+ agents: [
+ { slug: 'build', name: 'Build', description: 'Plans and writes code.' },
+ {
+ slug: 'pinned',
+ name: 'Pinned',
+ model: { providerID: 'anthropic', modelID: 'claude-3-5-sonnet' },
+ variant: 'low',
+ },
+ { slug: 'unknown-model', name: 'Unknown', model: 'not-an-object' },
+ ],
+ defaultAgent: 'build',
+};
+
+describe('projectLocalRuntimeCatalog', () => {
+ it('returns the mobile-facing catalog shape', () => {
+ const projected = projectLocalRuntimeCatalog(WIRE_CATALOG);
+ expect(projected).toEqual({
+ protocolVersion: 1,
+ models: {
+ protocolVersion: 1,
+ providers: [
+ {
+ id: 'anthropic',
+ name: 'Anthropic',
+ models: [
+ {
+ id: 'claude-3-5-sonnet',
+ name: 'Claude 3.5 Sonnet',
+ recommendedIndex: undefined,
+ isFree: undefined,
+ mayTrainOnYourPrompts: undefined,
+ hasUserByokAvailable: undefined,
+ variants: ['low', 'high'],
+ },
+ ],
+ },
+ ],
+ defaultModel: { providerID: 'anthropic', modelID: 'claude-3-5-sonnet' },
+ truncated: false,
+ },
+ agents: [
+ { slug: 'build', name: 'Build', description: 'Plans and writes code.' },
+ {
+ slug: 'pinned',
+ name: 'Pinned',
+ model: { providerID: 'anthropic', modelID: 'claude-3-5-sonnet' },
+ variant: 'low',
+ },
+ { slug: 'unknown-model', name: 'Unknown' },
+ ],
+ defaultAgent: 'build',
+ });
+ });
+
+ it('narrows a typed agent model to providerID + modelID', () => {
+ const projected = projectLocalRuntimeCatalog(WIRE_CATALOG);
+ const pinned = projected.agents.find(agent => agent.slug === 'pinned');
+ expect(pinned?.model).toEqual({ providerID: 'anthropic', modelID: 'claude-3-5-sonnet' });
+ });
+
+ it('drops agents whose model field is not an object', () => {
+ const projected = projectLocalRuntimeCatalog(WIRE_CATALOG);
+ const unknown = projected.agents.find(agent => agent.slug === 'unknown-model');
+ expect(unknown).toBeDefined();
+ expect(unknown?.model).toBeUndefined();
+ });
+
+ it('strips model capabilities and limits that the mobile slice does not consume', () => {
+ const projected = projectLocalRuntimeCatalog(WIRE_CATALOG);
+ const model = projected.models.providers[0]?.models[0];
+ expect(model).toBeDefined();
+ expect(model).not.toHaveProperty('capabilities');
+ expect(model).not.toHaveProperty('limits');
+ });
+});
diff --git a/apps/mobile/src/lib/hooks/local-runtime-catalog-projection.ts b/apps/mobile/src/lib/hooks/local-runtime-catalog-projection.ts
new file mode 100644
index 0000000000..c328bd6c24
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/local-runtime-catalog-projection.ts
@@ -0,0 +1,58 @@
+import { type inferRouterOutputs, type RootRouter } from '@kilocode/trpc';
+
+import { type LocalRuntimeCatalog } from './local-runtime-catalog-types';
+
+type RouterOutputs = inferRouterOutputs;
+
+/**
+ * Pure projection from the tRPC `localRuntimeControl.getCatalog` wire shape to
+ * the mobile-facing `LocalRuntimeCatalog`. The wire type is owned by the web
+ * client and carries extra model metadata (`capabilities`, `limits`) and an
+ * untyped agent `model` field. The mobile slice only needs the subset it
+ * renders, and it narrows the optional agent model to the exact provider/model
+ * pair it consumes.
+ */
+export function projectLocalRuntimeCatalog(
+ output: RouterOutputs['localRuntimeControl']['getCatalog']
+): LocalRuntimeCatalog {
+ return {
+ protocolVersion: output.protocolVersion,
+ models: {
+ protocolVersion: output.models.protocolVersion,
+ providers: output.models.providers.map(provider => ({
+ id: provider.id,
+ name: provider.name,
+ models: provider.models.map(model => ({
+ id: model.id,
+ name: model.name,
+ recommendedIndex: model.recommendedIndex,
+ isFree: model.isFree,
+ mayTrainOnYourPrompts: model.mayTrainOnYourPrompts,
+ hasUserByokAvailable: model.hasUserByokAvailable,
+ variants: model.variants,
+ })),
+ })),
+ defaultModel: output.models.defaultModel,
+ truncated: output.models.truncated,
+ },
+ agents: output.agents.map(agent => ({
+ slug: agent.slug,
+ name: agent.name,
+ description: agent.description,
+ model: narrowAgentModel(agent.model),
+ variant: agent.variant,
+ })),
+ defaultAgent: output.defaultAgent,
+ };
+}
+
+function narrowAgentModel(model: unknown): { providerID: string; modelID: string } | undefined {
+ if (model === null || typeof model !== 'object') {
+ return undefined;
+ }
+ const candidate = model as { providerID?: unknown; modelID?: unknown };
+ if (typeof candidate.providerID === 'string' && typeof candidate.modelID === 'string') {
+ return { providerID: candidate.providerID, modelID: candidate.modelID };
+ }
+ return undefined;
+}
diff --git a/apps/mobile/src/lib/hooks/local-runtime-catalog-selection.test.ts b/apps/mobile/src/lib/hooks/local-runtime-catalog-selection.test.ts
new file mode 100644
index 0000000000..913f6198d7
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/local-runtime-catalog-selection.test.ts
@@ -0,0 +1,198 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ findAgentBySlug,
+ hasCatalogCapability,
+ isUsableCatalog,
+ resolveInitialModelSelection,
+ resolveSelectedRuntimeFence,
+ runtimeFenceEquals,
+} from './local-runtime-catalog-selection';
+import {
+ makeCatalog,
+ makeRuntime,
+ RUNTIME_A,
+ RUNTIME_B,
+ RUNTIME_INCAPABLE,
+} from './local-runtime-catalog-test-fixtures';
+
+describe('runtimeFenceEquals', () => {
+ it('returns true when runtimeId and connectionId both match', () => {
+ expect(
+ runtimeFenceEquals(makeRuntime(), {
+ runtimeId: RUNTIME_A.runtimeId,
+ connectionId: RUNTIME_A.connectionId,
+ })
+ ).toBe(true);
+ });
+
+ it('returns false when the connectionId differs (new socket for the same process)', () => {
+ expect(
+ runtimeFenceEquals(makeRuntime(), {
+ runtimeId: RUNTIME_A.runtimeId,
+ connectionId: 'cli-other',
+ })
+ ).toBe(false);
+ });
+
+ it('returns true when both arguments are fences with matching ids', () => {
+ expect(
+ runtimeFenceEquals(
+ { runtimeId: RUNTIME_A.runtimeId, connectionId: RUNTIME_A.connectionId },
+ { runtimeId: RUNTIME_A.runtimeId, connectionId: RUNTIME_A.connectionId }
+ )
+ ).toBe(true);
+ });
+
+ it('returns false when comparing two fences with different connectionIds', () => {
+ expect(
+ runtimeFenceEquals(
+ { runtimeId: RUNTIME_A.runtimeId, connectionId: RUNTIME_A.connectionId },
+ { runtimeId: RUNTIME_A.runtimeId, connectionId: 'cli-other' }
+ )
+ ).toBe(false);
+ });
+});
+
+describe('hasCatalogCapability', () => {
+ it('returns true when catalog.v1 is advertised', () => {
+ expect(hasCatalogCapability(makeRuntime())).toBe(true);
+ });
+
+ it('returns false when the capability is missing', () => {
+ expect(hasCatalogCapability(makeRuntime({ capabilities: ['create-and-run.v1'] }))).toBe(false);
+ });
+});
+
+describe('resolveSelectedRuntimeFence', () => {
+ it('returns the previous fence when the same fence is still in the list', () => {
+ expect(
+ resolveSelectedRuntimeFence([makeRuntime()], {
+ runtimeId: RUNTIME_A.runtimeId,
+ connectionId: RUNTIME_A.connectionId,
+ })
+ ).toEqual({
+ runtimeId: RUNTIME_A.runtimeId,
+ connectionId: RUNTIME_A.connectionId,
+ });
+ });
+
+ it('drops the previous fence when its connectionId no longer matches (runtime reconnect)', () => {
+ expect(
+ resolveSelectedRuntimeFence([makeRuntime({ connectionId: 'cli-new' })], {
+ runtimeId: RUNTIME_A.runtimeId,
+ connectionId: RUNTIME_A.connectionId,
+ })
+ ).toEqual({ runtimeId: RUNTIME_A.runtimeId, connectionId: 'cli-new' });
+ });
+
+ it('drops the previous fence when the runtimeId is gone entirely (disconnect)', () => {
+ expect(
+ resolveSelectedRuntimeFence([makeRuntime({ runtimeId: RUNTIME_B.runtimeId })], {
+ runtimeId: RUNTIME_A.runtimeId,
+ connectionId: RUNTIME_A.connectionId,
+ })
+ ).toBeNull();
+ });
+
+ it('auto-selects the only capable runtime when no fence is set', () => {
+ expect(resolveSelectedRuntimeFence([makeRuntime()], null)).toEqual({
+ runtimeId: RUNTIME_A.runtimeId,
+ connectionId: RUNTIME_A.connectionId,
+ });
+ });
+
+ it('does not auto-select when multiple capable runtimes are present', () => {
+ expect(resolveSelectedRuntimeFence([makeRuntime(), makeRuntime(RUNTIME_B)], null)).toBeNull();
+ });
+
+ it('does not auto-select an incapable runtime', () => {
+ expect(resolveSelectedRuntimeFence([makeRuntime(RUNTIME_INCAPABLE)], null)).toBeNull();
+ });
+
+ it('preserves the current fence even if multiple other capable runtimes appear', () => {
+ expect(
+ resolveSelectedRuntimeFence([makeRuntime(), makeRuntime(RUNTIME_B)], {
+ runtimeId: RUNTIME_A.runtimeId,
+ connectionId: RUNTIME_A.connectionId,
+ })
+ ).toEqual({ runtimeId: RUNTIME_A.runtimeId, connectionId: RUNTIME_A.connectionId });
+ });
+
+ it('returns null on an empty list', () => {
+ expect(resolveSelectedRuntimeFence([], null)).toBeNull();
+ });
+});
+
+describe('findAgentBySlug', () => {
+ it('finds the exact agent slug', () => {
+ const catalog = makeCatalog();
+ expect(findAgentBySlug(catalog, 'build')?.name).toBe('Build');
+ });
+
+ it('returns null for an unknown slug', () => {
+ expect(findAgentBySlug(makeCatalog(), 'unknown')).toBeNull();
+ });
+});
+
+describe('resolveInitialModelSelection', () => {
+ it('uses the agent-pinned model and variant when present', () => {
+ const catalog = makeCatalog();
+ const agent = {
+ slug: 'build',
+ name: 'Build',
+ model: { providerID: 'anthropic', modelID: 'claude-1' },
+ variant: 'high',
+ };
+ expect(resolveInitialModelSelection(catalog, agent)).toEqual({
+ providerID: 'anthropic',
+ modelID: 'claude-1',
+ variant: 'high',
+ });
+ });
+
+ it('falls back to the catalog defaultModel when the agent has no pinned model', () => {
+ const catalog = makeCatalog({ defaultModel: { providerID: 'anthropic', modelID: 'claude-1' } });
+ expect(resolveInitialModelSelection(catalog, { slug: 'build', name: 'Build' })).toEqual({
+ providerID: 'anthropic',
+ modelID: 'claude-1',
+ variant: '',
+ });
+ });
+
+ it('falls back to the first model and first variant when nothing else is set', () => {
+ const catalog = makeCatalog();
+ expect(resolveInitialModelSelection(catalog, { slug: 'build', name: 'Build' })).toEqual({
+ providerID: 'anthropic',
+ modelID: 'claude-1',
+ variant: 'low',
+ });
+ });
+
+ it('returns null when the catalog has no models at all', () => {
+ const empty = makeCatalog({ providers: [] });
+ expect(resolveInitialModelSelection(empty, { slug: 'build', name: 'Build' })).toBeNull();
+ });
+});
+
+describe('isUsableCatalog', () => {
+ it('returns true for a well-formed catalog', () => {
+ expect(isUsableCatalog(makeCatalog())).toBe(true);
+ });
+
+ it('returns false when the default agent is missing from the catalog', () => {
+ expect(isUsableCatalog(makeCatalog({ defaultAgent: 'ghost' }))).toBe(false);
+ });
+
+ it('returns false when the default agent is empty', () => {
+ expect(isUsableCatalog(makeCatalog({ defaultAgent: '' }))).toBe(false);
+ });
+
+ it('returns false when there are no providers', () => {
+ expect(isUsableCatalog(makeCatalog({ providers: [] }))).toBe(false);
+ });
+
+ it('returns false when there are no agents', () => {
+ expect(isUsableCatalog(makeCatalog({ agents: [] }))).toBe(false);
+ });
+});
diff --git a/apps/mobile/src/lib/hooks/local-runtime-catalog-selection.ts b/apps/mobile/src/lib/hooks/local-runtime-catalog-selection.ts
new file mode 100644
index 0000000000..5f6aca9959
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/local-runtime-catalog-selection.ts
@@ -0,0 +1,151 @@
+import { type LocalRuntime } from '@/lib/hooks/runtime-discovery-logic';
+import {
+ type LocalRuntimeCatalog,
+ type LocalRuntimeCatalogAgent,
+ type LocalRuntimeFence,
+} from './local-runtime-catalog-types';
+
+export type { LocalRuntime } from '@/lib/hooks/runtime-discovery-logic';
+
+/**
+ * A runtime is "fence-equal" to a selected fence when both its `runtimeId` and
+ * its `connectionId` match. The catalog hook uses this to detect when a
+ * previously selected runtime has been replaced by a fresh socket and the
+ * cached catalog must be discarded.
+ */
+export function runtimeFenceEquals(left: LocalRuntimeFence, right: LocalRuntimeFence): boolean {
+ return left.runtimeId === right.runtimeId && left.connectionId === right.connectionId;
+}
+
+/**
+ * Project a list of runtimes into the resolved selected fence. The rules —
+ * which mirror the Slice 2 product spec — are:
+ *
+ * 1. If the previous selected fence still appears in the list, keep it
+ * (so a background refresh does not silently re-pick a different runtime).
+ * 2. Otherwise, if there WAS no previous fence and exactly one capable
+ * runtime is present, auto-select it. A disconnected previous fence never
+ * auto-selects — the user must explicitly choose a replacement so a stale
+ * agent/model does not silently change.
+ * 3. Otherwise (zero capable runtimes, or multiple capable runtimes, or a
+ * disconnected previous fence), require an explicit selection and return
+ * `null`.
+ *
+ * Incapable runtimes never count toward auto-selection and are never returned
+ * as a default.
+ */
+export function resolveSelectedRuntimeFence(
+ runtimes: readonly LocalRuntime[],
+ currentFence: LocalRuntimeFence | null
+): LocalRuntimeFence | null {
+ if (currentFence) {
+ // First: did the same runtime (by runtimeId) reappear with a new
+ // connectionId? That happens when `kilo remote` reconnects. The fence
+ // itself is keyed on both fields, so the old connection is detached —
+ // but the user's intent was to keep using that machine, so we adopt the
+ // new socket. The catalog hook will discard the stale catalog on the
+ // next render because the query key now differs.
+ const sameProcess = runtimes.find(
+ runtime => runtime.runtimeId === currentFence.runtimeId && hasCatalogCapability(runtime)
+ );
+ if (sameProcess) {
+ return { runtimeId: sameProcess.runtimeId, connectionId: sameProcess.connectionId };
+ }
+ // The previous runtimeId is gone entirely. Treat this as a fresh
+ // selection — never auto-pick a replacement, even if exactly one
+ // capable runtime remains, so a stale agent/model does not silently
+ // change.
+ return null;
+ }
+ const capable = runtimes.filter(runtime => hasCatalogCapability(runtime));
+ if (capable.length === 1) {
+ const only = capable[0];
+ if (!only) {
+ return null;
+ }
+ return { runtimeId: only.runtimeId, connectionId: only.connectionId };
+ }
+ return null;
+}
+
+const CATALOG_CAPABILITY = 'catalog.v1' as const;
+
+export function hasCatalogCapability(runtime: LocalRuntime): boolean {
+ return runtime.capabilities.includes(CATALOG_CAPABILITY);
+}
+
+/**
+ * Look up an agent by its exact slug. Returns `null` when the slug is not
+ * present in the catalog. The renderer uses this to detect a malformed or
+ * tampered catalog (the default agent should always exist) and to resolve the
+ * active agent when the user changes their selection.
+ */
+export function findAgentBySlug(
+ catalog: LocalRuntimeCatalog,
+ slug: string
+): LocalRuntimeCatalogAgent | null {
+ return catalog.agents.find(agent => agent.slug === slug) ?? null;
+}
+
+/**
+ * Resolve the model and variant that the configuration screen should start
+ * with. Precedence:
+ *
+ * 1. The currently selected agent's pinned `model` + `variant` win — the
+ * agent is the source of truth for the model it runs on.
+ * 2. Otherwise the catalog's `defaultModel` (if any) and a first-variant
+ * fallback. A runtime that ships no `defaultModel` is allowed — the
+ * renderer treats the first model's first variant as the fallback.
+ *
+ * Returns `null` only when the catalog has zero models — which the
+ * non-retryable error path covers separately.
+ */
+export function resolveInitialModelSelection(
+ catalog: LocalRuntimeCatalog,
+ agent: LocalRuntimeCatalogAgent
+): { providerID: string; modelID: string; variant: string } | null {
+ if (agent.model) {
+ return {
+ providerID: agent.model.providerID,
+ modelID: agent.model.modelID,
+ variant: agent.variant ?? '',
+ };
+ }
+ const defaultModel = catalog.models.defaultModel;
+ if (defaultModel) {
+ return { providerID: defaultModel.providerID, modelID: defaultModel.modelID, variant: '' };
+ }
+ const firstProvider = catalog.models.providers[0];
+ const firstModel = firstProvider?.models[0];
+ if (!firstModel) {
+ return null;
+ }
+ return {
+ providerID: firstProvider.id,
+ modelID: firstModel.id,
+ variant: firstModel.variants[0] ?? '',
+ };
+}
+
+/**
+ * Defensive shape check applied to a freshly fetched catalog. A catalog that
+ * has no `defaultAgent`, a `defaultAgent` that does not appear in `agents`, or
+ * zero models is treated as malformed — the user must change runtimes, retry
+ * will not help. The renderer also runs this before it transitions into the
+ * happy state.
+ */
+export function isUsableCatalog(catalog: LocalRuntimeCatalog): boolean {
+ if (!catalog.defaultAgent) {
+ return false;
+ }
+ if (!findAgentBySlug(catalog, catalog.defaultAgent)) {
+ return false;
+ }
+ if (catalog.models.providers.length === 0) {
+ return false;
+ }
+ if (catalog.agents.length === 0) {
+ return false;
+ }
+ return true;
+}
diff --git a/apps/mobile/src/lib/hooks/local-runtime-catalog-test-fixtures.ts b/apps/mobile/src/lib/hooks/local-runtime-catalog-test-fixtures.ts
new file mode 100644
index 0000000000..2e3a60982f
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/local-runtime-catalog-test-fixtures.ts
@@ -0,0 +1,65 @@
+import { type LocalRuntime } from '@/lib/hooks/runtime-discovery-logic';
+
+import { type LocalRuntimeCatalog } from './local-runtime-catalog-types';
+
+export const RUNTIME_A = {
+ runtimeId: '11111111-1111-4111-8111-111111111111',
+ connectionId: 'cli-a',
+ protocolVersion: 1 as const,
+ cliVersion: '1.2.3',
+ displayName: 'Mac A',
+ projectName: 'kilo',
+ capabilities: ['catalog.v1', 'create-and-run.v1'] as LocalRuntime['capabilities'],
+};
+export const RUNTIME_B = {
+ runtimeId: '22222222-2222-4222-8222-222222222222',
+ connectionId: 'cli-b',
+ protocolVersion: 1 as const,
+ cliVersion: '1.2.3',
+ displayName: 'Mac B',
+ projectName: 'kilo',
+ capabilities: ['catalog.v1', 'create-and-run.v1'] as LocalRuntime['capabilities'],
+};
+export const RUNTIME_INCAPABLE = {
+ runtimeId: '33333333-3333-4333-8333-333333333333',
+ connectionId: 'cli-c',
+ protocolVersion: 1 as const,
+ cliVersion: '1.2.3',
+ displayName: 'Mac C',
+ projectName: 'kilo',
+ capabilities: ['create-and-run.v1'] as LocalRuntime['capabilities'],
+};
+
+export function makeRuntime(overrides: Partial = {}): LocalRuntime {
+ return { ...RUNTIME_A, ...overrides };
+}
+
+type CatalogOverrides = {
+ defaultAgent?: string;
+ agents?: LocalRuntimeCatalog['agents'];
+ defaultModel?: LocalRuntimeCatalog['models']['defaultModel'];
+ providers?: LocalRuntimeCatalog['models']['providers'];
+};
+
+export function makeCatalog(overrides: Partial = {}): LocalRuntimeCatalog {
+ return {
+ protocolVersion: 1,
+ models: {
+ protocolVersion: 1,
+ providers: overrides.providers ?? [
+ {
+ id: 'anthropic',
+ name: 'Anthropic',
+ models: [{ id: 'claude-1', name: 'Claude 1', variants: ['low', 'high'] }],
+ },
+ ],
+ ...(overrides.defaultModel ? { defaultModel: overrides.defaultModel } : {}),
+ truncated: false,
+ },
+ agents: overrides.agents ?? [
+ { slug: 'build', name: 'Build' },
+ { slug: 'plan', name: 'Plan' },
+ ],
+ defaultAgent: overrides.defaultAgent ?? 'build',
+ };
+}
diff --git a/apps/mobile/src/lib/hooks/local-runtime-catalog-types.ts b/apps/mobile/src/lib/hooks/local-runtime-catalog-types.ts
new file mode 100644
index 0000000000..91b01a157b
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/local-runtime-catalog-types.ts
@@ -0,0 +1,146 @@
+import { type LocalRuntime } from '@/lib/hooks/runtime-discovery-logic';
+
+/**
+ * Exact fence the server module uses to route a control command to a specific
+ * runtime. `runtimeId` identifies the `kilo remote` process; `connectionId` is
+ * opaque relay routing metadata owned by the session-ingest DO. The mobile
+ * client never composes fences — it lifts them directly from a `LocalRuntime`
+ * returned by `localRuntimeControl.list`.
+ */
+export type LocalRuntimeFence = {
+ runtimeId: string;
+ connectionId: string;
+};
+
+export type LocalRuntimeCatalogAgent = {
+ slug: string;
+ name: string;
+ description?: string;
+ model?: { providerID: string; modelID: string };
+ variant?: string;
+};
+
+/**
+ * Mobile-facing projection of a single remote-catalog model. The slice only
+ * reads `id` (for selection) and `variants` (for the thinking-effort picker);
+ * the full `RemoteModelCatalogV1` carries more metadata (capabilities, limits,
+ * provider metadata) that lives in the shared package and is mirrored
+ * verbatim through the tRPC `getCatalog` output.
+ */
+export type LocalRuntimeCatalogModel = {
+ id: string;
+ name?: string;
+ recommendedIndex?: number;
+ isFree?: boolean;
+ mayTrainOnYourPrompts?: boolean;
+ hasUserByokAvailable?: boolean;
+ variants: string[];
+};
+
+export type LocalRuntimeCatalogProvider = {
+ id: string;
+ name?: string;
+ models: LocalRuntimeCatalogModel[];
+};
+
+/**
+ * Mobile-side projection of the catalog. The wire model is the canonical
+ * `RemoteModelCatalogV1` (transformed in the web client) — this projection
+ * exists so the mobile state helpers can be unit-tested without depending on
+ * the web-only `cloud-agent-sdk` import graph.
+ */
+export type LocalRuntimeCatalogModels = {
+ protocolVersion: 1;
+ providers: LocalRuntimeCatalogProvider[];
+ defaultModel?: { providerID: string; modelID: string };
+ truncated: boolean;
+};
+
+export type LocalRuntimeCatalog = {
+ protocolVersion: 1;
+ models: LocalRuntimeCatalogModels;
+ agents: LocalRuntimeCatalogAgent[];
+ defaultAgent: string;
+};
+
+/**
+ * Discriminated view-model for the local-session configuration screen. The
+ * renderer picks exactly one branch — there is no path where happy and empty
+ * compose, and there is no path where retryable and non-retryable compose.
+ */
+export type LocalSessionConfigViewModel =
+ | { kind: 'loading' }
+ | { kind: 'empty'; title: string; message: string; retry: () => void }
+ | { kind: 'incapable' }
+ | {
+ kind: 'selecting-runtime';
+ runtimes: LocalRuntime[];
+ currentFence: LocalRuntimeFence | null;
+ onSelect: (fence: LocalRuntimeFence) => void;
+ onRefresh: () => void;
+ }
+ | { kind: 'catalog-loading'; runtime: LocalRuntime; onCancel: () => void }
+ | {
+ kind: 'catalog-error-retryable';
+ runtime: LocalRuntime;
+ title: string;
+ message: string;
+ retry: () => void;
+ onChangeRuntime: () => void;
+ }
+ | {
+ kind: 'catalog-error-non-retryable';
+ runtime: LocalRuntime;
+ title: string;
+ message: string;
+ }
+ | {
+ kind: 'ready';
+ runtime: LocalRuntime;
+ catalog: LocalRuntimeCatalog;
+ catalogGeneration: object;
+ selectedAgent: LocalRuntimeCatalogAgent;
+ selectedModel: { providerID: string; modelID: string };
+ selectedVariant: string;
+ isModelLocked: boolean;
+ onSelectAgent: (slug: string) => void;
+ onSelectModel: (selection: { providerID: string; modelID: string; variant: string }) => void;
+ onChangeRuntime: () => void;
+ };
+
+/**
+ * State shape consumed by the local-session configuration view-model. The
+ * catalog slice mirrors the four exclusive states a `useQuery` can be in for
+ * a fence-anchored query — `idle` (no fence set, the query is disabled),
+ * `loading`, `error`, and `ready`. The renderer picks exactly one of these
+ * for the catalog and never composes it with the runtimes slice by hand.
+ */
+export type LocalRuntimeCatalogState =
+ | { kind: 'idle' }
+ | { kind: 'loading'; runtime: LocalRuntime }
+ | {
+ kind: 'error';
+ runtime: LocalRuntime;
+ error: unknown;
+ refetch: () => void;
+ }
+ | {
+ kind: 'ready';
+ runtime: LocalRuntime;
+ catalog: LocalRuntimeCatalog;
+ catalogGeneration: object;
+ };
+
+export type LocalRuntimesState = {
+ data: { runtimes: LocalRuntime[] } | undefined;
+ isError: boolean;
+ refetch: () => void;
+};
+
+export type BuildLocalSessionConfigViewModelInput = {
+ runtimesState: LocalRuntimesState;
+ selectedFence: LocalRuntimeFence | null;
+ onSelectFence: (fence: LocalRuntimeFence) => void;
+ onClearFence: () => void;
+ catalogState: LocalRuntimeCatalogState;
+};
diff --git a/apps/mobile/src/lib/hooks/local-runtime-catalog-view-model.test.ts b/apps/mobile/src/lib/hooks/local-runtime-catalog-view-model.test.ts
new file mode 100644
index 0000000000..0b18d0814d
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/local-runtime-catalog-view-model.test.ts
@@ -0,0 +1,301 @@
+import { describe, expect, it, vi } from 'vitest';
+
+import { RUNTIME_DISCOVERY_COPY } from '@/lib/hooks/runtime-discovery-logic';
+
+import { buildLocalSessionConfigViewModel } from './local-runtime-catalog-view-model';
+import { type LocalRuntimeFence } from './local-runtime-catalog-types';
+import {
+ makeCatalog,
+ makeRuntime,
+ RUNTIME_A,
+ RUNTIME_B,
+ RUNTIME_INCAPABLE,
+} from './local-runtime-catalog-test-fixtures';
+
+describe('buildLocalSessionConfigViewModel', () => {
+ it('returns loading when the runtimes query has no data and is not in error', () => {
+ const vm = buildLocalSessionConfigViewModel({
+ runtimesState: { data: undefined, isError: false, refetch: vi.fn<() => void>() },
+ selectedFence: null,
+ onSelectFence: vi.fn<(fence: LocalRuntimeFence) => void>(),
+ onClearFence: vi.fn<() => void>(),
+ catalogState: { kind: 'idle' },
+ });
+ expect(vm.kind).toBe('loading');
+ });
+
+ it('returns empty with the Slice 1 copy and retry when the list is empty', () => {
+ const refetch = vi.fn<() => void>();
+ const vm = buildLocalSessionConfigViewModel({
+ runtimesState: { data: { runtimes: [] }, isError: false, refetch },
+ selectedFence: null,
+ onSelectFence: vi.fn<(fence: LocalRuntimeFence) => void>(),
+ onClearFence: vi.fn<() => void>(),
+ catalogState: { kind: 'idle' },
+ });
+ expect(vm.kind).toBe('empty');
+ if (vm.kind !== 'empty') {
+ throw new Error('expected empty');
+ }
+ expect(vm.title).toBe(RUNTIME_DISCOVERY_COPY.empty.title);
+ expect(vm.message).toBe(RUNTIME_DISCOVERY_COPY.empty.message);
+ vm.retry();
+ expect(refetch).toHaveBeenCalledTimes(1);
+ });
+
+ it('returns selecting-runtime when no fence is set and multiple capable runtimes are present', () => {
+ const onSelectFence = vi.fn<(fence: LocalRuntimeFence) => void>();
+ const vm = buildLocalSessionConfigViewModel({
+ runtimesState: {
+ data: { runtimes: [makeRuntime(), makeRuntime(RUNTIME_B)] },
+ isError: false,
+ refetch: vi.fn<() => void>(),
+ },
+ selectedFence: null,
+ onSelectFence,
+ onClearFence: vi.fn<() => void>(),
+ catalogState: { kind: 'idle' },
+ });
+ expect(vm.kind).toBe('selecting-runtime');
+ if (vm.kind !== 'selecting-runtime') {
+ throw new Error('expected selecting-runtime');
+ }
+ expect(vm.runtimes).toHaveLength(2);
+ expect(vm.currentFence).toBeNull();
+ });
+
+ it('auto-selects and returns ready when one capable runtime is present', () => {
+ const onSelectFence = vi.fn<(fence: LocalRuntimeFence) => void>();
+ const catalog = makeCatalog();
+ const vm = buildLocalSessionConfigViewModel({
+ runtimesState: {
+ data: { runtimes: [makeRuntime()] },
+ isError: false,
+ refetch: vi.fn<() => void>(),
+ },
+ selectedFence: null,
+ onSelectFence,
+ onClearFence: vi.fn<() => void>(),
+ catalogState: {
+ kind: 'ready',
+ runtime: makeRuntime(),
+ catalog,
+ catalogGeneration: catalog,
+ },
+ });
+ expect(vm.kind).toBe('ready');
+ if (vm.kind !== 'ready') {
+ throw new Error('expected ready');
+ }
+ expect(onSelectFence).not.toHaveBeenCalled();
+ });
+
+ it('returns incapable when the only listed runtime lacks catalog.v1', () => {
+ const vm = buildLocalSessionConfigViewModel({
+ runtimesState: {
+ data: { runtimes: [makeRuntime(RUNTIME_INCAPABLE)] },
+ isError: false,
+ refetch: vi.fn<() => void>(),
+ },
+ selectedFence: null,
+ onSelectFence: vi.fn<(fence: LocalRuntimeFence) => void>(),
+ onClearFence: vi.fn<() => void>(),
+ catalogState: { kind: 'idle' },
+ });
+ expect(vm.kind).toBe('incapable');
+ });
+
+ it('returns catalog-loading when a fence is set and the catalog is still fetching', () => {
+ const vm = buildLocalSessionConfigViewModel({
+ runtimesState: {
+ data: { runtimes: [makeRuntime()] },
+ isError: false,
+ refetch: vi.fn<() => void>(),
+ },
+ selectedFence: { runtimeId: RUNTIME_A.runtimeId, connectionId: RUNTIME_A.connectionId },
+ onSelectFence: vi.fn<(fence: LocalRuntimeFence) => void>(),
+ onClearFence: vi.fn<() => void>(),
+ catalogState: { kind: 'loading', runtime: makeRuntime() },
+ });
+ expect(vm.kind).toBe('catalog-loading');
+ });
+
+ it('returns the retryable error view-model with the exact copy and a retry callback', () => {
+ const refetchCatalog = vi.fn<() => void>();
+ const onClearFence = vi.fn<() => void>();
+ const vm = buildLocalSessionConfigViewModel({
+ runtimesState: {
+ data: { runtimes: [makeRuntime()] },
+ isError: false,
+ refetch: vi.fn<() => void>(),
+ },
+ selectedFence: { runtimeId: RUNTIME_A.runtimeId, connectionId: RUNTIME_A.connectionId },
+ onSelectFence: vi.fn<(fence: LocalRuntimeFence) => void>(),
+ onClearFence,
+ catalogState: {
+ kind: 'error',
+ runtime: makeRuntime(),
+ error: { data: { upstreamCode: 'RUNTIME_NOT_CONNECTED' } },
+ refetch: refetchCatalog,
+ },
+ });
+ expect(vm.kind).toBe('catalog-error-retryable');
+ if (vm.kind !== 'catalog-error-retryable') {
+ throw new Error('expected retryable error');
+ }
+ expect(vm.title).toBe("Couldn't load runtime catalog");
+ expect(vm.message).toBe('Check that kilo remote is still connected, then try again.');
+ vm.retry();
+ expect(refetchCatalog).toHaveBeenCalledTimes(1);
+ vm.onChangeRuntime();
+ expect(onClearFence).toHaveBeenCalledTimes(1);
+ });
+
+ it('returns the non-retryable capability view-model without a retry callback for CLI_UPGRADE_REQUIRED', () => {
+ const onClearFence = vi.fn<() => void>();
+ const vm = buildLocalSessionConfigViewModel({
+ runtimesState: {
+ data: { runtimes: [makeRuntime()] },
+ isError: false,
+ refetch: vi.fn<() => void>(),
+ },
+ selectedFence: { runtimeId: RUNTIME_A.runtimeId, connectionId: RUNTIME_A.connectionId },
+ onSelectFence: vi.fn<(fence: LocalRuntimeFence) => void>(),
+ onClearFence,
+ catalogState: {
+ kind: 'error',
+ runtime: makeRuntime(),
+ error: { data: { upstreamCode: 'CLI_UPGRADE_REQUIRED' } },
+ refetch: vi.fn<() => void>(),
+ },
+ });
+ expect(vm.kind).toBe('catalog-error-non-retryable');
+ if (vm.kind !== 'catalog-error-non-retryable') {
+ throw new Error('expected non-retryable');
+ }
+ expect(vm.title).toBe('Update Kilo CLI');
+ expect(vm.message).toBe('Update Kilo CLI and reconnect.');
+ expect('retry' in vm).toBe(false);
+ });
+
+ it('returns the non-retryable malformed view-model for an INVALID_RUNTIME_RESPONSE', () => {
+ const vm = buildLocalSessionConfigViewModel({
+ runtimesState: {
+ data: { runtimes: [makeRuntime()] },
+ isError: false,
+ refetch: vi.fn<() => void>(),
+ },
+ selectedFence: { runtimeId: RUNTIME_A.runtimeId, connectionId: RUNTIME_A.connectionId },
+ onSelectFence: vi.fn<(fence: LocalRuntimeFence) => void>(),
+ onClearFence: vi.fn<() => void>(),
+ catalogState: {
+ kind: 'error',
+ runtime: makeRuntime(),
+ error: { data: { upstreamCode: 'INVALID_RUNTIME_RESPONSE' } },
+ refetch: vi.fn<() => void>(),
+ },
+ });
+ expect(vm.kind).toBe('catalog-error-non-retryable');
+ if (vm.kind !== 'catalog-error-non-retryable') {
+ throw new Error('expected non-retryable');
+ }
+ expect(vm.message).toBe("This runtime can't provide a usable catalog.");
+ });
+
+ it('downgrades a malformed-shape success to the non-retryable error path', () => {
+ const catalog = makeCatalog({ defaultAgent: 'ghost' });
+ const vm = buildLocalSessionConfigViewModel({
+ runtimesState: {
+ data: { runtimes: [makeRuntime()] },
+ isError: false,
+ refetch: vi.fn<() => void>(),
+ },
+ selectedFence: { runtimeId: RUNTIME_A.runtimeId, connectionId: RUNTIME_A.connectionId },
+ onSelectFence: vi.fn<(fence: LocalRuntimeFence) => void>(),
+ onClearFence: vi.fn<() => void>(),
+ catalogState: {
+ kind: 'ready',
+ runtime: makeRuntime(),
+ catalog,
+ catalogGeneration: catalog,
+ },
+ });
+ expect(vm.kind).toBe('catalog-error-non-retryable');
+ if (vm.kind !== 'catalog-error-non-retryable') {
+ throw new Error('expected non-retryable');
+ }
+ expect(vm.message).toBe("This runtime can't provide a usable catalog.");
+ });
+
+ it('returns ready with the default agent and pinned model pre-selected', () => {
+ const onSelectAgent = vi.fn<(slug: string) => void>();
+ const onSelectModel =
+ vi.fn<(selection: { providerID: string; modelID: string; variant: string }) => void>();
+ const catalog = makeCatalog();
+ const vm = buildLocalSessionConfigViewModel({
+ runtimesState: {
+ data: { runtimes: [makeRuntime()] },
+ isError: false,
+ refetch: vi.fn<() => void>(),
+ },
+ selectedFence: { runtimeId: RUNTIME_A.runtimeId, connectionId: RUNTIME_A.connectionId },
+ onSelectFence: vi.fn<(fence: LocalRuntimeFence) => void>(),
+ onClearFence: vi.fn<() => void>(),
+ catalogState: {
+ kind: 'ready',
+ runtime: makeRuntime(),
+ catalog,
+ catalogGeneration: catalog,
+ },
+ });
+ expect(vm.kind).toBe('ready');
+ if (vm.kind !== 'ready') {
+ throw new Error('expected ready');
+ }
+ expect(vm.selectedAgent.slug).toBe('build');
+ expect(vm.selectedModel.modelID).toBe('claude-1');
+ expect(vm.selectedVariant).toBe('low');
+ expect(vm.isModelLocked).toBe(false);
+ expect(vm.catalogGeneration).toBe(vm.catalog);
+ vm.onSelectAgent('plan');
+ expect(onSelectAgent).not.toHaveBeenCalled();
+ void onSelectAgent;
+ void onSelectModel;
+ });
+
+ it('marks isModelLocked when the default agent pins its model', () => {
+ const pinnedCatalog = makeCatalog({
+ agents: [
+ {
+ slug: 'build',
+ name: 'Build',
+ model: { providerID: 'anthropic', modelID: 'claude-1' },
+ variant: 'high',
+ },
+ { slug: 'plan', name: 'Plan' },
+ ],
+ });
+ const vm = buildLocalSessionConfigViewModel({
+ runtimesState: {
+ data: { runtimes: [makeRuntime()] },
+ isError: false,
+ refetch: vi.fn<() => void>(),
+ },
+ selectedFence: { runtimeId: RUNTIME_A.runtimeId, connectionId: RUNTIME_A.connectionId },
+ onSelectFence: vi.fn<(fence: LocalRuntimeFence) => void>(),
+ onClearFence: vi.fn<() => void>(),
+ catalogState: {
+ kind: 'ready',
+ runtime: makeRuntime(),
+ catalog: pinnedCatalog,
+ catalogGeneration: pinnedCatalog,
+ },
+ });
+ expect(vm.kind).toBe('ready');
+ if (vm.kind !== 'ready') {
+ throw new Error('expected ready');
+ }
+ expect(vm.isModelLocked).toBe(true);
+ expect(vm.selectedVariant).toBe('high');
+ });
+});
diff --git a/apps/mobile/src/lib/hooks/local-runtime-catalog-view-model.ts b/apps/mobile/src/lib/hooks/local-runtime-catalog-view-model.ts
new file mode 100644
index 0000000000..391e36cae4
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/local-runtime-catalog-view-model.ts
@@ -0,0 +1,234 @@
+import { type LocalRuntime } from '@/lib/hooks/runtime-discovery-logic';
+
+import {
+ findAgentBySlug,
+ hasCatalogCapability,
+ isUsableCatalog,
+ resolveInitialModelSelection,
+ runtimeFenceEquals,
+} from './local-runtime-catalog-selection';
+import { classifyLocalRuntimeCatalogError } from './local-runtime-catalog-errors';
+import {
+ type BuildLocalSessionConfigViewModelInput,
+ type LocalRuntimeCatalog,
+ type LocalRuntimeCatalogState,
+ type LocalRuntimeFence,
+ type LocalSessionConfigViewModel,
+} from './local-runtime-catalog-types';
+
+const noop = (): void => {
+ // no-op: the ready view-model starts with default selections; the actual
+ // change handlers are wired by the renderer after initial selection.
+};
+
+/**
+ * Compose the four exclusive screen states the configuration view can be in.
+ * The function is pure: it never calls a mutation hook or a tRPC client, and
+ * it never sets local state. That keeps the screen's reducer simple and the
+ * tests deterministic.
+ */
+export function buildLocalSessionConfigViewModel(
+ input: BuildLocalSessionConfigViewModelInput
+): LocalSessionConfigViewModel {
+ const { runtimesState, selectedFence, onSelectFence, onClearFence, catalogState } = input;
+ const { data, isError, refetch } = runtimesState;
+
+ // Cached data always wins over a background error — a stale refetch failure
+ // must not collapse a successful list into the error state.
+ if (data === undefined) {
+ if (isError) {
+ // We surface a list-level error as the empty branch with the existing
+ // Slice 1 retry copy so the user can re-attempt the discovery query.
+ return {
+ kind: 'empty',
+ title: "Couldn't load local runtimes",
+ message: 'Check your connection and try again.',
+ retry: refetch,
+ };
+ }
+ return { kind: 'loading' };
+ }
+
+ // Empty list: distinct from "no capable runtime"; the user has runtimes
+ // installed on no machines right now.
+ if (data.runtimes.length === 0) {
+ return {
+ kind: 'empty',
+ title: 'No local runtimes',
+ message: 'Run kilo remote in a project, then retry.',
+ retry: refetch,
+ };
+ }
+
+ // Incapable-only: at least one runtime exists but none of them advertise
+ // `catalog.v1`. There is no recovery inside the picker — the user has to
+ // upgrade the CLI.
+ if (data.runtimes.every(runtime => !hasCatalogCapability(runtime))) {
+ return { kind: 'incapable' };
+ }
+
+ if (!selectedFence) {
+ // Auto-pick the only capable runtime so the catalog can start loading
+ // immediately. We do not call `onSelectFence` here — the screen subscribes
+ // to the view-model and persists the fence itself.
+ const capable = data.runtimes.filter(runtime => hasCatalogCapability(runtime));
+ if (capable.length === 1) {
+ const only = capable[0];
+ if (!only) {
+ return {
+ kind: 'selecting-runtime',
+ runtimes: data.runtimes,
+ currentFence: null,
+ onSelect: onSelectFence,
+ onRefresh: refetch,
+ };
+ }
+ // Auto-select means the catalog hook should be running for this exact
+ // runtime — pass its current state through so the screen can render
+ // the loading/error/ready branch instead of re-entering the picker.
+ const isCatalogForRuntime =
+ catalogState.kind === 'idle' || runtimeFenceEquals(catalogState.runtime, only);
+ if (isCatalogForRuntime) {
+ return buildCatalogViewModel({
+ runtime: only,
+ catalogState,
+ onClearFence,
+ onSelectFence,
+ runtimes: data.runtimes,
+ });
+ }
+ }
+ return {
+ kind: 'selecting-runtime',
+ runtimes: data.runtimes,
+ currentFence: null,
+ onSelect: onSelectFence,
+ onRefresh: refetch,
+ };
+ }
+
+ // Find the runtime object that matches the selected fence. The catalog query
+ // was issued for this exact fence, so we use the same object the query
+ // received. If the runtime has gone away, the catalog view-model is
+ // meaningless — drop the fence and require an explicit re-selection.
+ const selectedRuntime = data.runtimes.find(runtime => runtimeFenceEquals(runtime, selectedFence));
+ if (!selectedRuntime) {
+ return {
+ kind: 'selecting-runtime',
+ runtimes: data.runtimes,
+ currentFence: null,
+ onSelect: onSelectFence,
+ onRefresh: refetch,
+ };
+ }
+
+ if (!hasCatalogCapability(selectedRuntime)) {
+ return { kind: 'incapable' };
+ }
+
+ return buildCatalogViewModel({
+ runtime: selectedRuntime,
+ catalogState,
+ onClearFence,
+ onSelectFence,
+ runtimes: data.runtimes,
+ });
+}
+
+function buildCatalogViewModel(args: {
+ runtime: LocalRuntime;
+ catalogState: LocalRuntimeCatalogState;
+ onClearFence: () => void;
+ onSelectFence: (fence: LocalRuntimeFence) => void;
+ runtimes: LocalRuntime[];
+}): LocalSessionConfigViewModel {
+ const { runtime, catalogState, onClearFence, runtimes } = args;
+ const onChangeRuntime = () => {
+ onClearFence();
+ };
+
+ if (catalogState.kind === 'idle') {
+ return {
+ kind: 'selecting-runtime',
+ runtimes,
+ currentFence: { runtimeId: runtime.runtimeId, connectionId: runtime.connectionId },
+ onSelect: args.onSelectFence,
+ onRefresh: () => undefined,
+ };
+ }
+
+ if (catalogState.kind === 'loading') {
+ return { kind: 'catalog-loading', runtime, onCancel: onChangeRuntime };
+ }
+
+ if (catalogState.kind === 'error') {
+ const classification = classifyLocalRuntimeCatalogError(catalogState.error);
+ if (classification.kind === 'non-retryable-capability') {
+ return {
+ kind: 'catalog-error-non-retryable',
+ runtime,
+ title: classification.title,
+ message: classification.message,
+ };
+ }
+ if (classification.kind === 'non-retryable-malformed') {
+ return {
+ kind: 'catalog-error-non-retryable',
+ runtime,
+ title: classification.title,
+ message: classification.message,
+ };
+ }
+ return {
+ kind: 'catalog-error-retryable',
+ runtime,
+ title: classification.title,
+ message: classification.message,
+ retry: catalogState.refetch,
+ onChangeRuntime,
+ };
+ }
+
+ // Defensive shape check: a successful response that is missing the default
+ // agent or ships no models is treated as malformed. We do not auto-retry —
+ // the user must change runtimes.
+ if (!isUsableCatalog(catalogState.catalog)) {
+ return {
+ kind: 'catalog-error-non-retryable',
+ runtime,
+ title: "Couldn't load runtime catalog",
+ message: "This runtime can't provide a usable catalog.",
+ };
+ }
+
+ const readySelection = resolveReadySelection(catalogState.catalog);
+ return {
+ kind: 'ready',
+ runtime,
+ catalog: catalogState.catalog,
+ catalogGeneration: catalogState.catalogGeneration,
+ ...readySelection,
+ isModelLocked: readySelection.selectedAgent.model !== undefined,
+ onChangeRuntime,
+ };
+}
+
+function resolveReadySelection(catalog: LocalRuntimeCatalog) {
+ const defaultAgent = findAgentBySlug(catalog, catalog.defaultAgent);
+ if (!defaultAgent) {
+ // Unreachable — `isUsableCatalog` already filtered this branch — but the
+ // narrowing keeps the rest of the function simple.
+ throw new Error('Default agent must exist for a usable catalog');
+ }
+ const initialModel = resolveInitialModelSelection(catalog, defaultAgent);
+ if (!initialModel) {
+ throw new Error('Usable catalog must expose at least one model');
+ }
+ return {
+ selectedAgent: defaultAgent,
+ selectedModel: { providerID: initialModel.providerID, modelID: initialModel.modelID },
+ selectedVariant: initialModel.variant,
+ onSelectAgent: noop,
+ onSelectModel: noop,
+ };
+}
diff --git a/apps/mobile/src/lib/hooks/local-session-config-selection.test.ts b/apps/mobile/src/lib/hooks/local-session-config-selection.test.ts
new file mode 100644
index 0000000000..12f09a7a00
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/local-session-config-selection.test.ts
@@ -0,0 +1,280 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ INITIAL_LOCAL_SESSION_CONFIG_SELECTION,
+ reduceLocalSessionConfigSelection,
+} from './local-session-config-selection';
+import { type LocalRuntimeFence } from './local-runtime-catalog-types';
+import { makeCatalog, RUNTIME_A, RUNTIME_B } from './local-runtime-catalog-test-fixtures';
+
+const FENCE_A: LocalRuntimeFence = {
+ runtimeId: RUNTIME_A.runtimeId,
+ connectionId: RUNTIME_A.connectionId,
+};
+
+const FENCE_A_RECONNECTED: LocalRuntimeFence = {
+ runtimeId: RUNTIME_A.runtimeId,
+ connectionId: 'cli-a-new',
+};
+
+const FENCE_B: LocalRuntimeFence = {
+ runtimeId: RUNTIME_B.runtimeId,
+ connectionId: RUNTIME_B.connectionId,
+};
+
+describe('reduceLocalSessionConfigSelection — setFence', () => {
+ it('returns the initial state when the action sets a null fence and state is already empty', () => {
+ const next = reduceLocalSessionConfigSelection(INITIAL_LOCAL_SESSION_CONFIG_SELECTION, {
+ type: 'setFence',
+ fence: null,
+ });
+ expect(next).toBe(INITIAL_LOCAL_SESSION_CONFIG_SELECTION);
+ });
+
+ it('clears the existing fence and overrides when the action sets a null fence', () => {
+ const seeded = {
+ selectedFence: FENCE_A,
+ agentOverride: 'plan',
+ modelOverride: { providerID: 'anthropic', modelID: 'claude-1', variant: 'high' },
+ };
+ const next = reduceLocalSessionConfigSelection(seeded, { type: 'setFence', fence: null });
+ expect(next).toEqual({
+ selectedFence: null,
+ agentOverride: null,
+ modelOverride: null,
+ });
+ });
+
+ it('preserves overrides when the action re-sets the same fence', () => {
+ const seeded = {
+ selectedFence: FENCE_A,
+ agentOverride: 'plan',
+ modelOverride: { providerID: 'anthropic', modelID: 'claude-1', variant: 'high' },
+ };
+ const next = reduceLocalSessionConfigSelection(seeded, { type: 'setFence', fence: FENCE_A });
+ expect(next).toBe(seeded);
+ });
+
+ it('clears overrides when the action sets a different fence (reconnect)', () => {
+ const seeded = {
+ selectedFence: FENCE_A,
+ agentOverride: 'plan',
+ modelOverride: { providerID: 'anthropic', modelID: 'claude-1', variant: 'high' },
+ };
+ const next = reduceLocalSessionConfigSelection(seeded, {
+ type: 'setFence',
+ fence: FENCE_A_RECONNECTED,
+ });
+ expect(next).toEqual({
+ selectedFence: FENCE_A_RECONNECTED,
+ agentOverride: null,
+ modelOverride: null,
+ });
+ });
+
+ it('clears overrides when the action sets a completely different fence', () => {
+ const seeded = {
+ selectedFence: FENCE_A,
+ agentOverride: 'plan',
+ modelOverride: { providerID: 'anthropic', modelID: 'claude-1', variant: 'high' },
+ };
+ const next = reduceLocalSessionConfigSelection(seeded, { type: 'setFence', fence: FENCE_B });
+ expect(next).toEqual({
+ selectedFence: FENCE_B,
+ agentOverride: null,
+ modelOverride: null,
+ });
+ });
+});
+
+describe('reduceLocalSessionConfigSelection — selectAgent', () => {
+ it('updates the agent and locks the model when the agent pins a model', () => {
+ const seeded = {
+ selectedFence: FENCE_A,
+ agentOverride: null,
+ modelOverride: null,
+ };
+ const catalog = makeCatalog({
+ agents: [
+ {
+ slug: 'build',
+ name: 'Build',
+ model: { providerID: 'anthropic', modelID: 'claude-1' },
+ variant: 'high',
+ },
+ { slug: 'plan', name: 'Plan' },
+ ],
+ });
+ const next = reduceLocalSessionConfigSelection(seeded, {
+ type: 'selectAgent',
+ slug: 'build',
+ catalog,
+ });
+ expect(next.agentOverride).toBe('build');
+ expect(next.modelOverride).toEqual({
+ providerID: 'anthropic',
+ modelID: 'claude-1',
+ variant: 'high',
+ });
+ });
+
+ it('overrides a previous model selection when the new agent pins its own model', () => {
+ const seeded = {
+ selectedFence: FENCE_A,
+ agentOverride: 'plan',
+ modelOverride: { providerID: 'openai', modelID: 'gpt-1', variant: 'low' },
+ };
+ const catalog = makeCatalog({
+ agents: [
+ {
+ slug: 'build',
+ name: 'Build',
+ model: { providerID: 'anthropic', modelID: 'claude-1' },
+ },
+ { slug: 'plan', name: 'Plan' },
+ ],
+ providers: [
+ {
+ id: 'anthropic',
+ name: 'Anthropic',
+ models: [{ id: 'claude-1', name: 'Claude 1', variants: ['low', 'high'] }],
+ },
+ {
+ id: 'openai',
+ name: 'OpenAI',
+ models: [{ id: 'gpt-1', name: 'GPT 1', variants: ['low'] }],
+ },
+ ],
+ });
+ const next = reduceLocalSessionConfigSelection(seeded, {
+ type: 'selectAgent',
+ slug: 'build',
+ catalog,
+ });
+ expect(next.agentOverride).toBe('build');
+ expect(next.modelOverride).toEqual({
+ providerID: 'anthropic',
+ modelID: 'claude-1',
+ variant: '',
+ });
+ });
+
+ it('keeps a compatible model override when switching to an unpinned agent', () => {
+ const seeded = {
+ selectedFence: FENCE_A,
+ agentOverride: 'build',
+ modelOverride: { providerID: 'anthropic', modelID: 'claude-1', variant: 'low' },
+ };
+ const catalog = makeCatalog({
+ agents: [
+ { slug: 'build', name: 'Build' },
+ { slug: 'plan', name: 'Plan' },
+ ],
+ });
+ const next = reduceLocalSessionConfigSelection(seeded, {
+ type: 'selectAgent',
+ slug: 'plan',
+ catalog,
+ });
+ expect(next.agentOverride).toBe('plan');
+ expect(next.modelOverride).toEqual(seeded.modelOverride);
+ });
+
+ it('drops a model override whose model is no longer in the catalog', () => {
+ const seeded = {
+ selectedFence: FENCE_A,
+ agentOverride: 'build',
+ modelOverride: { providerID: 'openai', modelID: 'gpt-99', variant: 'low' },
+ };
+ const catalog = makeCatalog({
+ agents: [
+ { slug: 'build', name: 'Build' },
+ { slug: 'plan', name: 'Plan' },
+ ],
+ });
+ const next = reduceLocalSessionConfigSelection(seeded, {
+ type: 'selectAgent',
+ slug: 'plan',
+ catalog,
+ });
+ expect(next.agentOverride).toBe('plan');
+ expect(next.modelOverride).toBeNull();
+ });
+
+ it('falls back to the first variant of a model when the override variant is no longer present', () => {
+ const seeded = {
+ selectedFence: FENCE_A,
+ agentOverride: 'build',
+ modelOverride: { providerID: 'anthropic', modelID: 'claude-1', variant: 'gone' },
+ };
+ const catalog = makeCatalog({
+ agents: [
+ { slug: 'build', name: 'Build' },
+ { slug: 'plan', name: 'Plan' },
+ ],
+ });
+ const next = reduceLocalSessionConfigSelection(seeded, {
+ type: 'selectAgent',
+ slug: 'plan',
+ catalog,
+ });
+ expect(next.modelOverride).toEqual({
+ providerID: 'anthropic',
+ modelID: 'claude-1',
+ variant: 'low',
+ });
+ });
+
+ it('drops the agent override when the slug is unknown (defensive)', () => {
+ const seeded = {
+ selectedFence: FENCE_A,
+ agentOverride: 'plan',
+ modelOverride: { providerID: 'anthropic', modelID: 'claude-1', variant: 'low' },
+ };
+ const next = reduceLocalSessionConfigSelection(seeded, {
+ type: 'selectAgent',
+ slug: 'ghost',
+ catalog: makeCatalog(),
+ });
+ expect(next.agentOverride).toBeNull();
+ expect(next.modelOverride).toBeNull();
+ });
+});
+
+describe('reduceLocalSessionConfigSelection — selectModel', () => {
+ it('updates the model override without changing the agent override', () => {
+ const seeded = {
+ selectedFence: FENCE_A,
+ agentOverride: 'plan',
+ modelOverride: { providerID: 'anthropic', modelID: 'claude-1', variant: 'low' },
+ };
+ const next = reduceLocalSessionConfigSelection(seeded, {
+ type: 'selectModel',
+ selection: { providerID: 'anthropic', modelID: 'claude-1', variant: 'high' },
+ });
+ expect(next.agentOverride).toBe('plan');
+ expect(next.modelOverride).toEqual({
+ providerID: 'anthropic',
+ modelID: 'claude-1',
+ variant: 'high',
+ });
+ });
+});
+
+describe('reduceLocalSessionConfigSelection — resetOverrides', () => {
+ it('clears both overrides without touching the fence', () => {
+ const seeded = {
+ selectedFence: FENCE_A,
+ agentOverride: 'plan',
+ modelOverride: { providerID: 'anthropic', modelID: 'claude-1', variant: 'low' },
+ };
+ const next = reduceLocalSessionConfigSelection(seeded, { type: 'resetOverrides' });
+ expect(next).toEqual({ selectedFence: FENCE_A, agentOverride: null, modelOverride: null });
+ });
+
+ it('is a no-op when both overrides are already null', () => {
+ const seeded = { selectedFence: FENCE_A, agentOverride: null, modelOverride: null };
+ const next = reduceLocalSessionConfigSelection(seeded, { type: 'resetOverrides' });
+ expect(next).toBe(seeded);
+ });
+});
diff --git a/apps/mobile/src/lib/hooks/local-session-config-selection.ts b/apps/mobile/src/lib/hooks/local-session-config-selection.ts
new file mode 100644
index 0000000000..295c468307
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/local-session-config-selection.ts
@@ -0,0 +1,145 @@
+import { type LocalRuntimeCatalog, type LocalRuntimeFence } from './local-runtime-catalog-types';
+import { findAgentBySlug } from './local-runtime-catalog-selection';
+
+export type LocalSessionConfigModelSelection = {
+ providerID: string;
+ modelID: string;
+ variant: string;
+};
+
+/**
+ * Local session controller state. Lives entirely in the screen — never sent
+ * to the server. `selectedFence` mirrors the live fence the controller is
+ * tracking; `agentOverride` and `modelOverride` shadow the catalog default
+ * while the user drafts a non-default selection.
+ *
+ * - `agentOverride === null` means "use the catalog's `defaultAgent`".
+ * - `modelOverride === null` means "use `resolveInitialModelSelection(catalog, agent)`".
+ * When the active agent has a pinned model, the controller's reducer sets
+ * the override alongside the agent so the renderer's `isModelLocked` flag
+ * derives correctly.
+ */
+export type LocalSessionConfigSelection = {
+ selectedFence: LocalRuntimeFence | null;
+ agentOverride: string | null;
+ modelOverride: LocalSessionConfigModelSelection | null;
+};
+
+export const INITIAL_LOCAL_SESSION_CONFIG_SELECTION: LocalSessionConfigSelection = {
+ selectedFence: null,
+ agentOverride: null,
+ modelOverride: null,
+};
+
+type LocalSessionConfigSelectionAction =
+ | { type: 'setFence'; fence: LocalRuntimeFence | null }
+ | { type: 'selectAgent'; slug: string; catalog: LocalRuntimeCatalog }
+ | { type: 'selectModel'; selection: LocalSessionConfigModelSelection }
+ | { type: 'resetOverrides' };
+
+const NO_MODEL_OVERRIDE: LocalSessionConfigModelSelection | null = null;
+
+/**
+ * Pure reducer for the screen's selection state. The hook is responsible for
+ * dispatching `setFence` whenever the runtime-discovery query or the
+ * catalog-driven view-model picks a fence, and the `selectAgent` /
+ * `selectModel` actions always arrive with the live catalog so the reducer
+ * never reads a stale agent definition.
+ */
+export function reduceLocalSessionConfigSelection(
+ state: LocalSessionConfigSelection,
+ action: LocalSessionConfigSelectionAction
+): LocalSessionConfigSelection {
+ switch (action.type) {
+ case 'setFence': {
+ if (action.fence === null) {
+ if (
+ state.selectedFence === null &&
+ state.agentOverride === null &&
+ state.modelOverride === null
+ ) {
+ return state;
+ }
+ return { selectedFence: null, agentOverride: null, modelOverride: null };
+ }
+ const current = state.selectedFence;
+ if (
+ current &&
+ current.runtimeId === action.fence.runtimeId &&
+ current.connectionId === action.fence.connectionId
+ ) {
+ return state;
+ }
+ // Switching fences (reconnect to a new socket, or a fresh auto-pick)
+ // discards any user overrides so the renderer reverts to the catalog
+ // defaults for the new runtime.
+ return {
+ selectedFence: {
+ runtimeId: action.fence.runtimeId,
+ connectionId: action.fence.connectionId,
+ },
+ agentOverride: null,
+ modelOverride: null,
+ };
+ }
+ case 'selectAgent': {
+ const agent = findAgentBySlug(action.catalog, action.slug);
+ if (!agent) {
+ // Defensive: the picker should not surface unknown slugs. Drop the
+ // override rather than keep a stale slug around.
+ if (state.agentOverride === null) {
+ return state;
+ }
+ return { ...state, agentOverride: null, modelOverride: NO_MODEL_OVERRIDE };
+ }
+ if (agent.model) {
+ return {
+ ...state,
+ agentOverride: agent.slug,
+ modelOverride: {
+ providerID: agent.model.providerID,
+ modelID: agent.model.modelID,
+ variant: agent.variant ?? '',
+ },
+ };
+ }
+ // Agent is unpinned: keep the existing model override only if it is
+ // still resolvable in the new catalog; otherwise drop it so the
+ // renderer falls back to the catalog/agent default.
+ if (state.modelOverride) {
+ const override = state.modelOverride;
+ const provider = action.catalog.models.providers.find(
+ candidate => candidate.id === override.providerID
+ );
+ const model = provider?.models.find(candidate => candidate.id === override.modelID);
+ if (!model) {
+ return { ...state, agentOverride: agent.slug, modelOverride: null };
+ }
+ if (!model.variants.includes(override.variant)) {
+ return {
+ ...state,
+ agentOverride: agent.slug,
+ modelOverride: {
+ providerID: override.providerID,
+ modelID: override.modelID,
+ variant: model.variants[0] ?? '',
+ },
+ };
+ }
+ }
+ return { ...state, agentOverride: agent.slug };
+ }
+ case 'selectModel': {
+ return { ...state, modelOverride: { ...action.selection } };
+ }
+ case 'resetOverrides': {
+ if (state.agentOverride === null && state.modelOverride === null) {
+ return state;
+ }
+ return { ...state, agentOverride: null, modelOverride: null };
+ }
+ default: {
+ return state;
+ }
+ }
+}
diff --git a/apps/mobile/src/lib/hooks/local-session-config-view-model.test.ts b/apps/mobile/src/lib/hooks/local-session-config-view-model.test.ts
new file mode 100644
index 0000000000..3d809dac87
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/local-session-config-view-model.test.ts
@@ -0,0 +1,228 @@
+import { describe, expect, it, vi } from 'vitest';
+
+import {
+ type LocalRuntimeFence,
+ type LocalSessionConfigViewModel,
+} from './local-runtime-catalog-types';
+import { makeCatalog, makeRuntime, RUNTIME_A } from './local-runtime-catalog-test-fixtures';
+import { INITIAL_LOCAL_SESSION_CONFIG_SELECTION } from './local-session-config-selection';
+import {
+ applyLocalSessionConfigSelection,
+ type LocalSessionConfigControllerHandlers,
+} from './local-session-config-view-model';
+import { buildLocalSessionConfigViewModel } from './local-runtime-catalog-view-model';
+
+const FENCE_A: LocalRuntimeFence = {
+ runtimeId: RUNTIME_A.runtimeId,
+ connectionId: RUNTIME_A.connectionId,
+};
+
+function makeHandlers(): LocalSessionConfigControllerHandlers & {
+ onSelectAgent: ReturnType void>>;
+ onSelectModel: ReturnType<
+ typeof vi.fn<(selection: { providerID: string; modelID: string; variant: string }) => void>
+ >;
+ onChangeRuntime: ReturnType void>>;
+} {
+ return {
+ onSelectAgent: vi.fn<(slug: string) => void>(),
+ onSelectModel:
+ vi.fn<(selection: { providerID: string; modelID: string; variant: string }) => void>(),
+ onChangeRuntime: vi.fn<() => void>(),
+ };
+}
+
+function buildReadyViewModel(): Extract {
+ const refetch = vi.fn<() => void>();
+ const catalog = makeCatalog();
+ const vm = buildLocalSessionConfigViewModel({
+ runtimesState: {
+ data: { runtimes: [makeRuntime()] },
+ isError: false,
+ refetch,
+ },
+ selectedFence: FENCE_A,
+ onSelectFence: vi.fn<(fence: LocalRuntimeFence) => void>(),
+ onClearFence: vi.fn<() => void>(),
+ catalogState: {
+ kind: 'ready',
+ runtime: makeRuntime(),
+ catalog,
+ catalogGeneration: catalog,
+ },
+ });
+ if (vm.kind !== 'ready') {
+ throw new Error('expected ready view-model');
+ }
+ return vm;
+}
+
+describe('applyLocalSessionConfigSelection', () => {
+ it('returns the base view-model unchanged for non-ready branches', () => {
+ const loadingVm: LocalSessionConfigViewModel = { kind: 'loading' };
+ const handlers = makeHandlers();
+ expect(
+ applyLocalSessionConfigSelection({
+ viewModel: loadingVm,
+ selection: INITIAL_LOCAL_SESSION_CONFIG_SELECTION,
+ handlers,
+ })
+ ).toBe(loadingVm);
+ });
+
+ it('passes through the ready view-model defaults when no controller overrides are set', () => {
+ const base = buildReadyViewModel();
+ const handlers = makeHandlers();
+ const applied = applyLocalSessionConfigSelection({
+ viewModel: base,
+ selection: INITIAL_LOCAL_SESSION_CONFIG_SELECTION,
+ handlers,
+ });
+ if (applied.kind !== 'ready') {
+ throw new Error('expected ready');
+ }
+ expect(applied.selectedAgent.slug).toBe('build');
+ expect(applied.selectedModel.modelID).toBe('claude-1');
+ expect(applied.selectedVariant).toBe('low');
+ expect(applied.isModelLocked).toBe(false);
+ expect(applied.onSelectAgent).toBe(handlers.onSelectAgent);
+ expect(applied.onSelectModel).toBe(handlers.onSelectModel);
+ expect(applied.onChangeRuntime).toBe(handlers.onChangeRuntime);
+ });
+
+ it('applies the controller agent override and pins the model when the agent pins', () => {
+ const base = buildReadyViewModel();
+ const handlers = makeHandlers();
+ const applied = applyLocalSessionConfigSelection({
+ viewModel: base,
+ selection: {
+ selectedFence: FENCE_A,
+ agentOverride: 'plan',
+ modelOverride: null,
+ },
+ handlers,
+ });
+ if (applied.kind !== 'ready') {
+ throw new Error('expected ready');
+ }
+ expect(applied.selectedAgent.slug).toBe('plan');
+ expect(applied.isModelLocked).toBe(false);
+ });
+
+ it('applies the controller model override to the model+variant when unpinned', () => {
+ const base = buildReadyViewModel();
+ const handlers = makeHandlers();
+ const applied = applyLocalSessionConfigSelection({
+ viewModel: base,
+ selection: {
+ selectedFence: FENCE_A,
+ agentOverride: null,
+ modelOverride: { providerID: 'anthropic', modelID: 'claude-1', variant: 'high' },
+ },
+ handlers,
+ });
+ if (applied.kind !== 'ready') {
+ throw new Error('expected ready');
+ }
+ expect(applied.selectedVariant).toBe('high');
+ expect(applied.isModelLocked).toBe(false);
+ });
+
+ it('forwards a selection tap from the ready view-model to the controller handler', () => {
+ const base = buildReadyViewModel();
+ const handlers = makeHandlers();
+ const applied = applyLocalSessionConfigSelection({
+ viewModel: base,
+ selection: INITIAL_LOCAL_SESSION_CONFIG_SELECTION,
+ handlers,
+ });
+ if (applied.kind !== 'ready') {
+ throw new Error('expected ready');
+ }
+ applied.onSelectModel({ providerID: 'anthropic', modelID: 'claude-1', variant: 'high' });
+ expect(handlers.onSelectModel).toHaveBeenCalledWith({
+ providerID: 'anthropic',
+ modelID: 'claude-1',
+ variant: 'high',
+ });
+ });
+
+ it('forwards a change-runtime tap to the controller handler', () => {
+ const base = buildReadyViewModel();
+ const handlers = makeHandlers();
+ const applied = applyLocalSessionConfigSelection({
+ viewModel: base,
+ selection: INITIAL_LOCAL_SESSION_CONFIG_SELECTION,
+ handlers,
+ });
+ if (applied.kind !== 'ready') {
+ throw new Error('expected ready');
+ }
+ applied.onChangeRuntime();
+ expect(handlers.onChangeRuntime).toHaveBeenCalledTimes(1);
+ });
+
+ it('returns a new ready object with the exact runtime and catalog fields copied', () => {
+ const base = buildReadyViewModel();
+ const handlers = makeHandlers();
+ const applied = applyLocalSessionConfigSelection({
+ viewModel: base,
+ selection: INITIAL_LOCAL_SESSION_CONFIG_SELECTION,
+ handlers,
+ });
+ expect(applied).not.toBe(base);
+ if (applied.kind !== 'ready') {
+ throw new Error('expected ready');
+ }
+ expect(applied.runtime).toBe(base.runtime);
+ expect(applied.catalog).toBe(base.catalog);
+ expect(applied.catalogGeneration).toBe(base.catalogGeneration);
+ expect(applied.selectedAgent).toBe(base.selectedAgent);
+ expect(applied.selectedVariant).toBe(base.selectedVariant);
+ });
+
+ it('preserves the locked model when the catalog agent definition already pins it', () => {
+ const refetch = vi.fn<() => void>();
+ const pinnedCatalog = makeCatalog({
+ agents: [
+ {
+ slug: 'build',
+ name: 'Build',
+ model: { providerID: 'anthropic', modelID: 'claude-1' },
+ variant: 'high',
+ },
+ { slug: 'plan', name: 'Plan' },
+ ],
+ });
+ const vm = buildLocalSessionConfigViewModel({
+ runtimesState: {
+ data: { runtimes: [makeRuntime()] },
+ isError: false,
+ refetch,
+ },
+ selectedFence: FENCE_A,
+ onSelectFence: vi.fn<(fence: LocalRuntimeFence) => void>(),
+ onClearFence: vi.fn<() => void>(),
+ catalogState: {
+ kind: 'ready',
+ runtime: makeRuntime(),
+ catalog: pinnedCatalog,
+ catalogGeneration: pinnedCatalog,
+ },
+ });
+ if (vm.kind !== 'ready') {
+ throw new Error('expected ready');
+ }
+ const handlers = makeHandlers();
+ const applied = applyLocalSessionConfigSelection({
+ viewModel: vm,
+ selection: INITIAL_LOCAL_SESSION_CONFIG_SELECTION,
+ handlers,
+ });
+ if (applied.kind !== 'ready') {
+ throw new Error('expected ready');
+ }
+ expect(applied.isModelLocked).toBe(true);
+ expect(applied.selectedVariant).toBe('high');
+ });
+});
diff --git a/apps/mobile/src/lib/hooks/local-session-config-view-model.ts b/apps/mobile/src/lib/hooks/local-session-config-view-model.ts
new file mode 100644
index 0000000000..a6018126b6
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/local-session-config-view-model.ts
@@ -0,0 +1,135 @@
+import {
+ type LocalRuntimeCatalog,
+ type LocalSessionConfigViewModel,
+} from './local-runtime-catalog-types';
+import { findAgentBySlug, resolveInitialModelSelection } from './local-runtime-catalog-selection';
+import {
+ type LocalSessionConfigModelSelection,
+ type LocalSessionConfigSelection,
+} from './local-session-config-selection';
+
+export type LocalSessionConfigControllerHandlers = {
+ onSelectAgent: (slug: string) => void;
+ onSelectModel: (selection: LocalSessionConfigModelSelection) => void;
+ onChangeRuntime: () => void;
+};
+
+type ApplyLocalSessionConfigOverridesInput = {
+ viewModel: LocalSessionConfigViewModel;
+ selection: LocalSessionConfigSelection;
+ handlers: LocalSessionConfigControllerHandlers;
+};
+
+/**
+ * Compose the screen's controller state onto the pure `LocalSessionConfigViewModel`.
+ * For every non-ready branch the function is a no-op — the picker is not on
+ * screen in those branches and the renderer can ignore the controller.
+ *
+ * For the `ready` branch the function:
+ *
+ * - Resolves the active agent slug (controller override → catalog default).
+ * - Recomputes the locked-by-agent flag from the agent definition; the
+ * picker row hides itself when this is true.
+ * - Resolves the model+variant: pinned agent → agent's pinned model; unpinned
+ * agent with a controller override → that override; otherwise the catalog
+ * default for the active agent.
+ * - Replaces the no-op handlers from the pure view-model with the
+ * controller's transition dispatchers.
+ */
+export function applyLocalSessionConfigSelection({
+ viewModel,
+ selection,
+ handlers,
+}: ApplyLocalSessionConfigOverridesInput): LocalSessionConfigViewModel {
+ if (viewModel.kind !== 'ready') {
+ return viewModel;
+ }
+
+ const resolved = resolveReadySelection({
+ catalog: viewModel.catalog,
+ selection,
+ defaultAgentSlug: viewModel.selectedAgent.slug,
+ });
+
+ return {
+ ...viewModel,
+ selectedAgent: resolved.selectedAgent,
+ selectedModel: resolved.selectedModel,
+ selectedVariant: resolved.selectedVariant,
+ isModelLocked: resolved.isModelLocked,
+ onSelectAgent: handlers.onSelectAgent,
+ onSelectModel: handlers.onSelectModel,
+ onChangeRuntime: handlers.onChangeRuntime,
+ };
+}
+
+type ReadyLocalSessionConfigViewModel = Extract;
+
+function resolveReadySelection({
+ catalog,
+ selection,
+ defaultAgentSlug,
+}: {
+ catalog: LocalRuntimeCatalog;
+ selection: LocalSessionConfigSelection;
+ defaultAgentSlug: string;
+}): {
+ selectedAgent: ReadyLocalSessionConfigViewModel['selectedAgent'];
+ selectedModel: { providerID: string; modelID: string };
+ selectedVariant: string;
+ isModelLocked: boolean;
+} {
+ const overrideAgent = selection.agentOverride
+ ? findAgentBySlug(catalog, selection.agentOverride)
+ : null;
+ const activeAgent = overrideAgent ?? findAgentBySlug(catalog, defaultAgentSlug);
+ if (!activeAgent) {
+ // Unreachable — the view-model's `ready` branch already filtered this.
+ throw new Error('Active agent must exist in a usable catalog');
+ }
+
+ if (activeAgent.model) {
+ return {
+ selectedAgent: activeAgent,
+ selectedModel: {
+ providerID: activeAgent.model.providerID,
+ modelID: activeAgent.model.modelID,
+ },
+ selectedVariant: activeAgent.variant ?? '',
+ isModelLocked: true,
+ };
+ }
+
+ const initial = resolveInitialModelSelection(catalog, activeAgent);
+ if (!initial) {
+ // Unreachable — the view-model guarantees at least one model in the
+ // usable catalog. The narrowing keeps the rest of the function simple.
+ throw new Error('Usable catalog must expose at least one model');
+ }
+
+ const override = selection.modelOverride;
+ if (override) {
+ const provider = catalog.models.providers.find(
+ candidate => candidate.id === override.providerID
+ );
+ const model = provider?.models.find(candidate => candidate.id === override.modelID);
+ if (model && provider) {
+ const variant = model.variants.includes(override.variant)
+ ? override.variant
+ : (model.variants[0] ?? '');
+ return {
+ selectedAgent: activeAgent,
+ selectedModel: { providerID: provider.id, modelID: model.id },
+ selectedVariant: variant,
+ isModelLocked: false,
+ };
+ }
+ }
+
+ return {
+ selectedAgent: activeAgent,
+ selectedModel: { providerID: initial.providerID, modelID: initial.modelID },
+ selectedVariant: initial.variant,
+ isModelLocked: false,
+ };
+}
diff --git a/apps/mobile/src/lib/hooks/local-session-create-effects.test.ts b/apps/mobile/src/lib/hooks/local-session-create-effects.test.ts
new file mode 100644
index 0000000000..b51f70ce19
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/local-session-create-effects.test.ts
@@ -0,0 +1,102 @@
+import { describe, expect, it, vi } from 'vitest';
+
+import {
+ completeHappyPath,
+ handlePromptPartial,
+ PROMPT_PARTIAL_TOAST,
+ SESSION_CREATED_EVENT,
+ SESSION_CREATED_EVENT_SURFACE,
+ SESSION_DETAIL_PATH_PREFIX,
+} from './local-session-create-effects';
+
+const SESSION_ID = 'sess-abc';
+
+describe('completeHappyPath', () => {
+ it('invalidates, captures analytics, fires success haptic, then navigates — in that order', async () => {
+ const events: string[] = [];
+ const invalidateCaches = vi.fn();
+ invalidateCaches.mockImplementation(() => {
+ events.push('invalidate');
+ });
+ const captureEvent = vi.fn((name, properties) => {
+ events.push(`event:${name}:${JSON.stringify(properties)}`);
+ });
+ const notificationHaptic = vi.fn(kind => {
+ events.push(`haptic:${kind}`);
+ });
+ const navigate = vi.fn(path => {
+ events.push(`navigate:${path}`);
+ });
+
+ await completeHappyPath(SESSION_ID, {
+ invalidateCaches,
+ captureEvent,
+ notificationHaptic,
+ navigate,
+ });
+
+ expect(events).toEqual([
+ 'invalidate',
+ `event:${SESSION_CREATED_EVENT}:${JSON.stringify({ surface: SESSION_CREATED_EVENT_SURFACE })}`,
+ 'haptic:success',
+ `navigate:${SESSION_DETAIL_PATH_PREFIX}${SESSION_ID}`,
+ ]);
+ });
+});
+
+describe('handlePromptPartial', () => {
+ it('invalidates, fires the fixed info toast, then navigates — in that order', async () => {
+ const events: string[] = [];
+ const invalidateCaches = vi.fn();
+ invalidateCaches.mockImplementation(() => {
+ events.push('invalidate');
+ });
+ const showInfo = vi.fn(message => {
+ events.push(`info:${message}`);
+ });
+ const navigate = vi.fn(path => {
+ events.push(`navigate:${path}`);
+ });
+
+ const result = await handlePromptPartial(SESSION_ID, {
+ invalidateCaches,
+ showInfo,
+ navigate,
+ });
+
+ expect(result).toEqual({ invalidationFailed: false });
+ expect(events).toEqual([
+ 'invalidate',
+ `info:${PROMPT_PARTIAL_TOAST}`,
+ `navigate:${SESSION_DETAIL_PATH_PREFIX}${SESSION_ID}`,
+ ]);
+ });
+
+ it('still surfaces the info toast and navigates when invalidation throws', async () => {
+ const events: string[] = [];
+ const invalidateCaches = vi.fn();
+ invalidateCaches.mockImplementation(() => {
+ events.push('invalidate');
+ throw new Error('cache down');
+ });
+ const showInfo = vi.fn(message => {
+ events.push(`info:${message}`);
+ });
+ const navigate = vi.fn(path => {
+ events.push(`navigate:${path}`);
+ });
+
+ const result = await handlePromptPartial(SESSION_ID, {
+ invalidateCaches,
+ showInfo,
+ navigate,
+ });
+
+ expect(result).toEqual({ invalidationFailed: true });
+ expect(events).toEqual([
+ 'invalidate',
+ `info:${PROMPT_PARTIAL_TOAST}`,
+ `navigate:${SESSION_DETAIL_PATH_PREFIX}${SESSION_ID}`,
+ ]);
+ });
+});
diff --git a/apps/mobile/src/lib/hooks/local-session-create-effects.ts b/apps/mobile/src/lib/hooks/local-session-create-effects.ts
new file mode 100644
index 0000000000..8fc7b3c4d2
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/local-session-create-effects.ts
@@ -0,0 +1,94 @@
+/**
+ * Focused post-create side effects for the `createAndRun` orchestrator.
+ *
+ * The hooks layer wires `deps.invalidateCaches` to the four-path
+ * `invalidateAgentSessionQueries` helper (see `agent-session-cache.ts`);
+ * the orchestrator therefore calls `invalidateCaches` exactly once on the
+ * happy path and once on the prompt-partial path so the new session can
+ * resolve on the detail/list screens.
+ */
+export const SESSION_CREATED_EVENT = 'session_created';
+export const SESSION_CREATED_EVENT_SURFACE = 'remote-session';
+export const SESSION_DETAIL_PATH_PREFIX = '/(app)/agent-chat/';
+
+export type HapticKind = 'success' | 'warning' | 'error';
+
+export type CreateAndRunResult =
+ | { status: 'ready'; result: ReadyResult }
+ | {
+ status: 'session_not_ready';
+ code: 'SESSION_NOT_READY';
+ result: ReadyResult;
+ };
+
+export type ReadyResult =
+ | { protocolVersion: 1; sessionId: string; promptStarted: true }
+ | {
+ protocolVersion: 1;
+ sessionId: string;
+ promptStarted: false;
+ error: { code: 'PROMPT_START_FAILED'; message: string };
+ };
+
+type CompleteHappyPathDeps = {
+ captureEvent: (name: string, properties: Record) => void;
+ invalidateCaches: () => Promise;
+ notificationHaptic: (kind: HapticKind) => void;
+ navigate: (path: string) => void;
+};
+
+/**
+ * Execute the happy-path side effects for a successfully created session.
+ *
+ * Order: invalidate caches → capture analytics → fire success haptic →
+ * navigate. The order is deliberate: a stale detail or list must not
+ * race the navigation. Cache invalidation must be awaited before the
+ * navigate so the next render can refetch the new row in the same
+ * network round-trip.
+ */
+export async function completeHappyPath(
+ sessionId: string,
+ deps: CompleteHappyPathDeps
+): Promise {
+ await deps.invalidateCaches();
+ deps.captureEvent(SESSION_CREATED_EVENT, { surface: SESSION_CREATED_EVENT_SURFACE });
+ deps.notificationHaptic('success');
+ deps.navigate(`${SESSION_DETAIL_PATH_PREFIX}${sessionId}`);
+}
+
+export const PROMPT_PARTIAL_TOAST =
+ 'The session was created, but the first prompt did not start. Retry from the session composer.';
+
+type HandlePromptPartialDeps = {
+ invalidateCaches: () => Promise;
+ showInfo: (message: string) => void;
+ navigate: (path: string) => void;
+};
+
+/**
+ * Execute the prompt-partial side effects when the server reports
+ * `promptStarted: false`. The session exists; we must invalidate the
+ * detail/list caches so the next render can resolve, but the create was
+ * not a success — no analytics, no success haptic. The fixed safe info
+ * toast is shown exactly once. Cache invalidation, the info toast, and
+ * navigation each fire exactly once on this branch — the helper
+ * guarantees all three are attempted even if one throws so the user
+ * always lands on the session detail with a consistent message.
+ *
+ * Returns the cache-invalidation outcome to the caller so the orchestrator
+ * can capture test-visible state without exposing the raw error.
+ */
+export async function handlePromptPartial(
+ sessionId: string,
+ deps: HandlePromptPartialDeps
+): Promise<{ invalidationFailed: boolean }> {
+ let invalidationFailed = false;
+ try {
+ await deps.invalidateCaches();
+ } catch {
+ invalidationFailed = true;
+ }
+ deps.showInfo(PROMPT_PARTIAL_TOAST);
+ deps.navigate(`${SESSION_DETAIL_PATH_PREFIX}${sessionId}`);
+ return { invalidationFailed };
+}
diff --git a/apps/mobile/src/lib/hooks/local-session-create-enablement.test.ts b/apps/mobile/src/lib/hooks/local-session-create-enablement.test.ts
new file mode 100644
index 0000000000..4d8f0d5759
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/local-session-create-enablement.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, it } from 'vitest';
+
+import { isStartSessionEnabled } from './local-session-create-enablement';
+
+const base = {
+ isReadySelection: true,
+ hasPrompt: true,
+ canSubmit: true,
+ isSubmitting: false,
+};
+
+describe('isStartSessionEnabled', () => {
+ it('is true when all four guards pass', () => {
+ expect(isStartSessionEnabled(base)).toBe(true);
+ });
+
+ it('is false when the controller has not resolved a ready selection', () => {
+ expect(isStartSessionEnabled({ ...base, isReadySelection: false })).toBe(false);
+ });
+
+ it('is false when the prompt is blank (hasPrompt false)', () => {
+ expect(isStartSessionEnabled({ ...base, hasPrompt: false })).toBe(false);
+ });
+
+ it('is false when the hook reports canSubmit false', () => {
+ expect(isStartSessionEnabled({ ...base, canSubmit: false })).toBe(false);
+ });
+
+ it('is false while a submit is in flight', () => {
+ expect(isStartSessionEnabled({ ...base, isSubmitting: true })).toBe(false);
+ });
+
+ it('is false when every guard fails', () => {
+ expect(
+ isStartSessionEnabled({
+ isReadySelection: false,
+ hasPrompt: false,
+ canSubmit: false,
+ isSubmitting: true,
+ })
+ ).toBe(false);
+ });
+});
diff --git a/apps/mobile/src/lib/hooks/local-session-create-enablement.ts b/apps/mobile/src/lib/hooks/local-session-create-enablement.ts
new file mode 100644
index 0000000000..bc98bfa6a5
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/local-session-create-enablement.ts
@@ -0,0 +1,26 @@
+/**
+ * Pure gate for the "Start session" primary button on the ready branch of
+ * the local-session create screen. The button must be enabled only when:
+ *
+ * - the controller has resolved a complete selection (fence + agent + model);
+ * - the user has typed a non-blank prompt (text state is `hasPrompt === true`);
+ * - the orchestrator hook reports `canSubmit === true` (its own internal
+ * conjunction of selection + prompt + idle phase);
+ * - no submit is currently in flight (`isSubmitting === false`).
+ *
+ * Whitespace-only input fails the `hasPrompt` check at the source — that
+ * flag is set by `setHasPrompt(text.trim().length > 0)` upstream. The helper
+ * never inspects prompt content; it only consults the four guards so the
+ * screen stays a pure function of the hook's return value plus the
+ * controller's view-model.
+ */
+type StartSessionEnablement = {
+ isReadySelection: boolean;
+ hasPrompt: boolean;
+ canSubmit: boolean;
+ isSubmitting: boolean;
+};
+
+export function isStartSessionEnabled(input: StartSessionEnablement): boolean {
+ return input.isReadySelection && input.hasPrompt && input.canSubmit && !input.isSubmitting;
+}
diff --git a/apps/mobile/src/lib/hooks/local-session-create-errors.test.ts b/apps/mobile/src/lib/hooks/local-session-create-errors.test.ts
new file mode 100644
index 0000000000..9240b2985f
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/local-session-create-errors.test.ts
@@ -0,0 +1,136 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ classifyLocalSessionCreateError,
+ type LocalSessionCreateRecovery,
+} from './local-session-create-errors';
+
+type RetryableKind = 'fence-changed' | 'catalog-changed' | 'transient' | 'limit';
+
+const RETRYABLE_CODES: { code: string; expectedKind: RetryableKind }[] = [
+ { code: 'RUNTIME_NOT_CONNECTED', expectedKind: 'fence-changed' },
+ { code: 'RUNTIME_FENCE_MISMATCH', expectedKind: 'fence-changed' },
+ { code: 'CATALOG_CHANGED', expectedKind: 'catalog-changed' },
+ { code: 'COMMAND_EXPIRED', expectedKind: 'transient' },
+ { code: 'RUNTIME_COMMAND_FAILED', expectedKind: 'transient' },
+ { code: 'COMMAND_ALREADY_PENDING', expectedKind: 'transient' },
+ { code: 'PENDING_COMMAND_LIMIT', expectedKind: 'limit' },
+];
+
+const NON_RETRYABLE_CODES = ['CLI_UPGRADE_REQUIRED'];
+
+const MALFORMED_CODES = ['RESULT_TOO_LARGE', 'INVALID_RUNTIME_RESPONSE', 'COMMAND_NOT_ALLOWED'];
+
+describe('classifyLocalSessionCreateError', () => {
+ it('classifies every retryable upstream code with the exact message and CTA copy', () => {
+ for (const { code, expectedKind } of RETRYABLE_CODES) {
+ const result: LocalSessionCreateRecovery = classifyLocalSessionCreateError({
+ data: { upstreamCode: code },
+ });
+ expect(result.kind).toBe(expectedKind);
+ if (result.kind === 'fence-changed') {
+ expect(result.message).toBe(
+ 'Local runtime disconnected. Select a connected runtime and try again.'
+ );
+ expect(result.ctaLabel).toBe('Select runtime');
+ } else if (result.kind === 'catalog-changed') {
+ expect(result.message).toBe(
+ 'The runtime catalog changed. Review the model and agent, then try again.'
+ );
+ expect(result.ctaLabel).toBe('Refresh catalog');
+ } else if (result.kind === 'transient') {
+ expect(result.message).toBe(
+ "We couldn't confirm whether the session started. Retry with the same request."
+ );
+ } else {
+ expect(result.kind).toBe('limit');
+ expect(result.message).toBe(
+ 'This runtime is handling too many requests. Try again in a moment.'
+ );
+ }
+ if (result.kind === 'transient' || result.kind === 'limit') {
+ expect(result.ctaLabel).toBe('Retry');
+ }
+ }
+ });
+
+ it('classifies CLI_UPGRADE_REQUIRED as non-retryable with no Retry CTA', () => {
+ for (const code of NON_RETRYABLE_CODES) {
+ const result = classifyLocalSessionCreateError({ data: { upstreamCode: code } });
+ expect(result.kind).toBe('non-retryable-cli-upgrade');
+ if (result.kind !== 'non-retryable-cli-upgrade') {
+ throw new Error('expected non-retryable-cli-upgrade');
+ }
+ expect(result.message).toBe('Update Kilo CLI and reconnect.');
+ expect(result.ctaLabel).toBeNull();
+ }
+ });
+
+ it('classifies the malformed response codes as non-retryable with no Retry CTA', () => {
+ for (const code of MALFORMED_CODES) {
+ const result = classifyLocalSessionCreateError({ data: { upstreamCode: code } });
+ expect(result.kind).toBe('non-retryable-malformed');
+ if (result.kind !== 'non-retryable-malformed') {
+ throw new Error('expected non-retryable-malformed');
+ }
+ expect(result.message).toBe(
+ 'This runtime returned an unsupported response. Update Kilo CLI and reconnect.'
+ );
+ expect(result.ctaLabel).toBeNull();
+ }
+ });
+
+ it('classifies SESSION_NOT_READY (server wait exhausted) as a recovery-poll branch', () => {
+ const result = classifyLocalSessionCreateError({
+ data: { upstreamCode: 'SESSION_NOT_READY' },
+ });
+ expect(result.kind).toBe('readiness-timeout');
+ if (result.kind !== 'readiness-timeout') {
+ throw new Error('expected readiness-timeout');
+ }
+ expect(result.message).toBe("Session created, but it isn't ready in the app yet.");
+ expect(result.ctaLabel).toBe('Check again');
+ });
+
+ it('classifies a missing upstream code as transient retryable with the safe default copy', () => {
+ const result = classifyLocalSessionCreateError({ data: {} });
+ expect(result.kind).toBe('transient');
+ if (result.kind !== 'transient') {
+ throw new Error('expected transient');
+ }
+ expect(result.message).toBe(
+ "We couldn't confirm whether the session started. Retry with the same request."
+ );
+ expect(result.ctaLabel).toBe('Retry');
+ });
+
+ it('classifies an unknown upstream code value as transient retryable', () => {
+ const result = classifyLocalSessionCreateError({
+ data: { upstreamCode: 'SOMETHING_NEW' },
+ });
+ expect(result.kind).toBe('transient');
+ });
+
+ it('classifies a tRPC FORBIDDEN error with no upstream code as terminal access-lost with no CTA', () => {
+ const result = classifyLocalSessionCreateError({
+ data: { code: 'FORBIDDEN' },
+ });
+ expect(result.kind).toBe('non-retryable-access-lost');
+ if (result.kind !== 'non-retryable-access-lost') {
+ throw new Error('expected non-retryable-access-lost');
+ }
+ expect(result.message).toBe('You no longer have access to this session.');
+ expect(result.ctaLabel).toBeNull();
+ });
+
+ it('classifies a plain Error (no tRPC envelope) as transient retryable', () => {
+ const result = classifyLocalSessionCreateError(new Error('network down'));
+ expect(result.kind).toBe('transient');
+ });
+
+ it('classifies non-object throwables as transient retryable', () => {
+ expect(classifyLocalSessionCreateError('string-error').kind).toBe('transient');
+ expect(classifyLocalSessionCreateError(null).kind).toBe('transient');
+ expect(classifyLocalSessionCreateError(undefined).kind).toBe('transient');
+ });
+});
diff --git a/apps/mobile/src/lib/hooks/local-session-create-errors.ts b/apps/mobile/src/lib/hooks/local-session-create-errors.ts
new file mode 100644
index 0000000000..9802d1c061
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/local-session-create-errors.ts
@@ -0,0 +1,221 @@
+/**
+ * Bounded set of upstream codes the mobile client can branch on for the
+ * `localRuntimeControl.createAndRun` mutation. Mirrors
+ * `LocalRuntimeControlErrorCode` plus the server-only `SESSION_NOT_READY`
+ * code that the create mutation attaches to its `session_not_ready` status
+ * branch — which is not a thrown error but a typed recovery surface.
+ */
+type LocalSessionCreateUpstreamCode =
+ | 'RUNTIME_NOT_CONNECTED'
+ | 'RUNTIME_FENCE_MISMATCH'
+ | 'CLI_UPGRADE_REQUIRED'
+ | 'CATALOG_CHANGED'
+ | 'COMMAND_ALREADY_PENDING'
+ | 'PENDING_COMMAND_LIMIT'
+ | 'COMMAND_EXPIRED'
+ | 'RESULT_TOO_LARGE'
+ | 'INVALID_RUNTIME_RESPONSE'
+ | 'RUNTIME_COMMAND_FAILED'
+ | 'COMMAND_NOT_ALLOWED'
+ | 'SESSION_NOT_READY'
+ | 'UNKNOWN';
+
+const UPSTREAM_CODES: ReadonlySet = new Set([
+ 'RUNTIME_NOT_CONNECTED',
+ 'RUNTIME_FENCE_MISMATCH',
+ 'CLI_UPGRADE_REQUIRED',
+ 'CATALOG_CHANGED',
+ 'COMMAND_ALREADY_PENDING',
+ 'PENDING_COMMAND_LIMIT',
+ 'COMMAND_EXPIRED',
+ 'RESULT_TOO_LARGE',
+ 'INVALID_RUNTIME_RESPONSE',
+ 'RUNTIME_COMMAND_FAILED',
+ 'COMMAND_NOT_ALLOWED',
+ 'SESSION_NOT_READY',
+ 'UNKNOWN',
+]);
+
+/**
+ * Recovery category for a `createAndRun` failure. The renderer maps each
+ * `kind` to exactly one CTA-or-no-CTA presentation — retryable branches
+ * always carry an actionable `ctaLabel`; non-retryable branches always have
+ * `ctaLabel === null` so the renderer can hide the CTA without a separate
+ * boolean.
+ */
+export type LocalSessionCreateRecovery =
+ | {
+ kind: 'fence-changed';
+ message: string;
+ ctaLabel: 'Select runtime';
+ }
+ | {
+ kind: 'catalog-changed';
+ message: string;
+ ctaLabel: 'Refresh catalog';
+ }
+ | {
+ kind: 'transient';
+ message: string;
+ ctaLabel: 'Retry';
+ }
+ | {
+ kind: 'limit';
+ message: string;
+ ctaLabel: 'Retry';
+ }
+ | {
+ kind: 'non-retryable-cli-upgrade';
+ message: string;
+ ctaLabel: null;
+ }
+ | {
+ kind: 'non-retryable-malformed';
+ message: string;
+ ctaLabel: null;
+ }
+ | {
+ kind: 'non-retryable-prompt-too-long';
+ message: string;
+ ctaLabel: null;
+ }
+ | {
+ kind: 'non-retryable-prompt-empty';
+ message: string;
+ ctaLabel: null;
+ }
+ | {
+ kind: 'non-retryable-access-lost';
+ message: string;
+ ctaLabel: null;
+ }
+ | {
+ kind: 'readiness-timeout';
+ message: string;
+ ctaLabel: 'Check again';
+ };
+
+const FENCE_CHANGED_MESSAGE =
+ 'Local runtime disconnected. Select a connected runtime and try again.';
+const CATALOG_CHANGED_MESSAGE =
+ 'The runtime catalog changed. Review the model and agent, then try again.';
+const TRANSIENT_MESSAGE =
+ "We couldn't confirm whether the session started. Retry with the same request.";
+const LIMIT_MESSAGE = 'This runtime is handling too many requests. Try again in a moment.';
+const CLI_UPGRADE_MESSAGE = 'Update Kilo CLI and reconnect.';
+const MALFORMED_MESSAGE =
+ 'This runtime returned an unsupported response. Update Kilo CLI and reconnect.';
+const ACCESS_LOST_MESSAGE = 'You no longer have access to this session.';
+const READINESS_TIMEOUT_MESSAGE = "Session created, but it isn't ready in the app yet.";
+
+/**
+ * Classify a thrown error from the `localRuntimeControl.createAndRun`
+ * mutation into the recovery branch the orchestrator must surface. The
+ * classifier is exhaustive over the bounded `LocalSessionCreateUpstreamCode`
+ * set and falls back to the transient Retry branch for any malformed or
+ * unrecognised throwable — a network blip or a fresh server code must not
+ * leave the user stuck.
+ *
+ * The renderer never inspects the original error: it reads `kind`,
+ * `message`, and `ctaLabel` only.
+ */
+export function classifyLocalSessionCreateError(error: unknown): LocalSessionCreateRecovery {
+ const upstreamCode = readUpstreamCode(error);
+ if (upstreamCode === 'UNKNOWN' && readTrpcErrorCode(error) === 'FORBIDDEN') {
+ return {
+ kind: 'non-retryable-access-lost',
+ message: ACCESS_LOST_MESSAGE,
+ ctaLabel: null,
+ };
+ }
+ switch (upstreamCode) {
+ case 'RUNTIME_NOT_CONNECTED':
+ case 'RUNTIME_FENCE_MISMATCH': {
+ return { kind: 'fence-changed', message: FENCE_CHANGED_MESSAGE, ctaLabel: 'Select runtime' };
+ }
+ case 'CATALOG_CHANGED': {
+ return {
+ kind: 'catalog-changed',
+ message: CATALOG_CHANGED_MESSAGE,
+ ctaLabel: 'Refresh catalog',
+ };
+ }
+ case 'COMMAND_EXPIRED':
+ case 'RUNTIME_COMMAND_FAILED':
+ case 'COMMAND_ALREADY_PENDING': {
+ return { kind: 'transient', message: TRANSIENT_MESSAGE, ctaLabel: 'Retry' };
+ }
+ case 'PENDING_COMMAND_LIMIT': {
+ return { kind: 'limit', message: LIMIT_MESSAGE, ctaLabel: 'Retry' };
+ }
+ case 'CLI_UPGRADE_REQUIRED': {
+ return { kind: 'non-retryable-cli-upgrade', message: CLI_UPGRADE_MESSAGE, ctaLabel: null };
+ }
+ case 'RESULT_TOO_LARGE':
+ case 'INVALID_RUNTIME_RESPONSE':
+ case 'COMMAND_NOT_ALLOWED': {
+ return { kind: 'non-retryable-malformed', message: MALFORMED_MESSAGE, ctaLabel: null };
+ }
+ case 'SESSION_NOT_READY': {
+ return {
+ kind: 'readiness-timeout',
+ message: READINESS_TIMEOUT_MESSAGE,
+ ctaLabel: 'Check again',
+ };
+ }
+ case 'UNKNOWN': {
+ return { kind: 'transient', message: TRANSIENT_MESSAGE, ctaLabel: 'Retry' };
+ }
+ default: {
+ const _never: never = upstreamCode;
+ void _never;
+ return { kind: 'transient', message: TRANSIENT_MESSAGE, ctaLabel: 'Retry' };
+ }
+ }
+}
+
+/**
+ * Read the upstream code out of a tRPC error envelope. Returns the bounded
+ * `LocalSessionCreateUpstreamCode` union — never `null` — so the
+ * classifier's switch is exhaustive.
+ *
+ * - A throwable with no `data` property or with a non-object `data` is
+ * treated as a transient retryable transport failure and surfaces as
+ * `'UNKNOWN'`.
+ * - A throwable whose envelope carries no recognisable upstream code
+ * surfaces as `'UNKNOWN'` (a transient retry — the server was reached
+ * but the response was not a typed create failure).
+ * - Every entry of the union.
+ */
+function readTrpcErrorCode(error: unknown): string | undefined {
+ if (!error || typeof error !== 'object') {
+ return undefined;
+ }
+ const data = (error as { data?: unknown }).data;
+ if (!data || typeof data !== 'object') {
+ return undefined;
+ }
+ const code = (data as { code?: unknown }).code;
+ if (typeof code !== 'string') {
+ return undefined;
+ }
+ return code;
+}
+
+function readUpstreamCode(error: unknown): LocalSessionCreateUpstreamCode {
+ if (!error || typeof error !== 'object') {
+ return 'UNKNOWN';
+ }
+ const data = (error as { data?: unknown }).data;
+ if (!data || typeof data !== 'object') {
+ return 'UNKNOWN';
+ }
+ const code = (data as { upstreamCode?: unknown }).upstreamCode;
+ if (typeof code !== 'string') {
+ return 'UNKNOWN';
+ }
+ if (UPSTREAM_CODES.has(code as LocalSessionCreateUpstreamCode)) {
+ return code as LocalSessionCreateUpstreamCode;
+ }
+ return 'UNKNOWN';
+}
diff --git a/apps/mobile/src/lib/hooks/local-session-create-orchestrator-shared.ts b/apps/mobile/src/lib/hooks/local-session-create-orchestrator-shared.ts
new file mode 100644
index 0000000000..e1dc642466
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/local-session-create-orchestrator-shared.ts
@@ -0,0 +1,141 @@
+import { type LocalSessionCreateRecovery } from './local-session-create-errors';
+import { type LocalSessionRequestIdStore } from './local-session-request-id';
+import { type LocalRuntimeCatalog, type LocalRuntimeFence } from './local-runtime-catalog-types';
+import { type CreateAndRunResult, type HapticKind } from './local-session-create-effects';
+import { type ReadinessProbe } from './local-session-create-polling';
+import {
+ type BuildLocalSessionCreateRequestInput,
+ type BuiltLocalSessionCreateRequest,
+ type LocalSessionCreateRequestError,
+} from './local-session-create-request';
+
+export type { HapticKind };
+export type { LocalSessionCreateRecovery };
+export type { ReadinessProbe };
+
+export type LocalSessionCreateOrchestratorDeps = {
+ requestIdStore: LocalSessionRequestIdStore;
+ buildRequest: (input: BuildLocalSessionCreateRequestInput) => BuiltLocalSessionCreateRequest;
+ createAndRun: (input: BuiltLocalSessionCreateRequest) => Promise;
+ pollReadiness: ReadinessProbe;
+ sleep: (ms: number) => Promise;
+ invalidateCaches: () => Promise;
+ captureEvent: (name: string, properties: Record) => void;
+ notificationHaptic: (kind: HapticKind) => void;
+ navigate: (path: string) => void;
+ showError: (message: string) => void;
+ showInfo: (message: string) => void;
+ pollIntervalMs: number;
+ pollMaxMs: number;
+};
+
+export type LocalSessionCreateOrchestratorInput = {
+ deps: LocalSessionCreateOrchestratorDeps;
+ fence: LocalRuntimeFence;
+ catalog: LocalRuntimeCatalog;
+ selectedAgentSlug: string;
+ selectedModel: { providerID: string; modelID: string; variant: string };
+ getPrompt: () => string;
+};
+
+export type LocalSessionCreateOrchestratorState =
+ | { phase: 'idle' }
+ | { phase: 'submitting' }
+ | {
+ phase: 'recovery';
+ recovery: LocalSessionCreateRecovery;
+ sessionId: string | null;
+ requestId: string | null;
+ fence: LocalRuntimeFence;
+ }
+ | { phase: 'navigated' };
+
+export type LocalSessionCreateOrchestrator = {
+ submit: () => Promise;
+ retry: () => Promise;
+ checkAgain: () => Promise;
+ getState: () => LocalSessionCreateOrchestratorState;
+ subscribe: (listener: (state: LocalSessionCreateOrchestratorState) => void) => () => void;
+};
+
+const READINESS_TIMEOUT_MESSAGE = "Session created, but it isn't ready in the app yet.";
+
+export function readinessTimeoutRecovery(): LocalSessionCreateRecovery {
+ return { kind: 'readiness-timeout', message: READINESS_TIMEOUT_MESSAGE, ctaLabel: 'Check again' };
+}
+
+export async function withInFlightCleared(
+ inFlight: { current: Promise | null },
+ promise: Promise
+): Promise {
+ try {
+ return await promise;
+ } finally {
+ if (inFlight.current === promise) {
+ inFlight.current = null;
+ }
+ }
+}
+
+export function extractErrorMessage(error: unknown): string {
+ if (error && typeof error === 'object' && 'message' in error) {
+ const message = (error as { message?: unknown }).message;
+ if (typeof message === 'string') {
+ return message;
+ }
+ }
+ return 'Failed to create session';
+}
+
+export function requestErrorToRecovery(
+ error: LocalSessionCreateRequestError
+): LocalSessionCreateRecovery {
+ switch (error.code) {
+ case 'invalid-agent':
+ case 'invalid-catalog':
+ case 'invalid-model': {
+ return {
+ kind: 'catalog-changed',
+ message: 'The runtime catalog changed. Review the model and agent, then try again.',
+ ctaLabel: 'Refresh catalog',
+ };
+ }
+ case 'invalid-fence': {
+ return {
+ kind: 'fence-changed',
+ message: 'Local runtime disconnected. Select a connected runtime and try again.',
+ ctaLabel: 'Select runtime',
+ };
+ }
+ case 'invalid-request-id': {
+ return {
+ kind: 'transient',
+ message: "We couldn't confirm whether the session started. Retry with the same request.",
+ ctaLabel: 'Retry',
+ };
+ }
+ case 'invalid-prompt-too-long': {
+ return {
+ kind: 'non-retryable-prompt-too-long',
+ message: error.message,
+ ctaLabel: null,
+ };
+ }
+ case 'invalid-prompt-empty': {
+ return {
+ kind: 'non-retryable-prompt-empty',
+ message: error.message,
+ ctaLabel: null,
+ };
+ }
+ default: {
+ const _exhaustiveCheck: never = error.code;
+ void _exhaustiveCheck;
+ return {
+ kind: 'transient',
+ message: error.message,
+ ctaLabel: 'Retry',
+ };
+ }
+ }
+}
diff --git a/apps/mobile/src/lib/hooks/local-session-create-polling.test.ts b/apps/mobile/src/lib/hooks/local-session-create-polling.test.ts
new file mode 100644
index 0000000000..140f92da71
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/local-session-create-polling.test.ts
@@ -0,0 +1,58 @@
+import { describe, expect, it, vi } from 'vitest';
+
+import {
+ MAX_ATTEMPTS,
+ POLL_INTERVAL_MS,
+ pollReadinessUntilReady,
+} from './local-session-create-polling';
+
+const SESSION_ID = 'sess-abc';
+
+describe('pollReadinessUntilReady', () => {
+ it('returns true on the first ready probe without sleeping', async () => {
+ const pollReadiness = vi.fn().mockResolvedValue({ status: 'ready', organizationId: null });
+ const sleep = vi.fn().mockResolvedValue(undefined);
+ const result = await pollReadinessUntilReady({ sessionId: SESSION_ID, pollReadiness, sleep });
+ expect(result).toBe(true);
+ expect(pollReadiness).toHaveBeenCalledTimes(1);
+ expect(sleep).not.toHaveBeenCalled();
+ });
+
+ it('returns false after exhausting the bounded attempt count', async () => {
+ const pollReadiness = vi.fn().mockResolvedValue({ status: 'pending' });
+ const sleep = vi.fn().mockResolvedValue(undefined);
+ const result = await pollReadinessUntilReady({
+ sessionId: SESSION_ID,
+ pollReadiness,
+ sleep,
+ maxAttempts: 3,
+ intervalMs: 100,
+ });
+ expect(result).toBe(false);
+ expect(pollReadiness).toHaveBeenCalledTimes(3);
+ expect(sleep).toHaveBeenCalledTimes(2);
+ });
+
+ it('exposes a constant attempt count of 30 for the 15s budget at 500ms intervals', () => {
+ expect(POLL_INTERVAL_MS).toBe(500);
+ expect(MAX_ATTEMPTS).toBe(30);
+ });
+
+ it('returns true after one pending probe followed by a ready probe', async () => {
+ const pollReadiness = vi
+ .fn()
+ .mockResolvedValueOnce({ status: 'pending' })
+ .mockResolvedValueOnce({ status: 'ready', organizationId: null });
+ const sleep = vi.fn().mockResolvedValue(undefined);
+ const result = await pollReadinessUntilReady({
+ sessionId: SESSION_ID,
+ pollReadiness,
+ sleep,
+ maxAttempts: 5,
+ intervalMs: 50,
+ });
+ expect(result).toBe(true);
+ expect(pollReadiness).toHaveBeenCalledTimes(2);
+ expect(sleep).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/apps/mobile/src/lib/hooks/local-session-create-polling.ts b/apps/mobile/src/lib/hooks/local-session-create-polling.ts
new file mode 100644
index 0000000000..4674d78c0f
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/local-session-create-polling.ts
@@ -0,0 +1,66 @@
+import { type inferRouterOutputs, type RootRouter } from '@kilocode/trpc';
+
+/**
+ * Bounded readiness polling for `createAndRun`. After the create mutation
+ * returns `status: 'session_not_ready'`, the orchestrator probes readiness
+ * for at most `MAX_ATTEMPTS` attempts, each separated by
+ * `pollIntervalMs`. The poll terminates early on the first `ready` probe.
+ *
+ * Polling is sequential on purpose: the readiness probe reads the
+ * persisted `organizationId` for the new session, so concurrent calls
+ * would race each other on the relay's in-memory store.
+ *
+ * `no-await-in-loop` is satisfied by the narrow `runAttempt` helper, which
+ * is the single sequential await point.
+ */
+export const POLL_INTERVAL_MS = 500;
+const POLL_BUDGET_MS = 15_000;
+export const MAX_ATTEMPTS = Math.floor(POLL_BUDGET_MS / POLL_INTERVAL_MS);
+
+type RouterOutputs = inferRouterOutputs;
+
+export type ReadinessResult = RouterOutputs['cliSessionsV2']['readiness'];
+
+export type ReadinessProbe = (input: { sessionId: string }) => Promise;
+
+type PollReadinessUntilReadyInput = {
+ sessionId: string;
+ pollReadiness: ReadinessProbe;
+ sleep: (ms: number) => Promise;
+ intervalMs?: number;
+ maxAttempts?: number;
+};
+
+async function runAttempt(
+ remaining: number,
+ input: Required> & {
+ intervalMs: number;
+ }
+): Promise {
+ if (remaining <= 0) {
+ return false;
+ }
+ const probe = await input.pollReadiness({ sessionId: input.sessionId });
+ if (probe.status === 'ready') {
+ return true;
+ }
+ if (remaining === 1) {
+ return false;
+ }
+ await input.sleep(input.intervalMs);
+ return runAttempt(remaining - 1, input);
+}
+
+/**
+ * Poll readiness for a fresh session until it reports `ready` or the bounded
+ * attempt count is exhausted. Returns `true` on the first ready probe and
+ * `false` after the budget is consumed.
+ */
+export async function pollReadinessUntilReady(
+ input: PollReadinessUntilReadyInput
+): Promise {
+ const intervalMs = input.intervalMs ?? POLL_INTERVAL_MS;
+ const maxAttempts = input.maxAttempts ?? MAX_ATTEMPTS;
+ const result = await runAttempt(maxAttempts, { ...input, intervalMs });
+ return result;
+}
diff --git a/apps/mobile/src/lib/hooks/local-session-create-prompt-actions.test.ts b/apps/mobile/src/lib/hooks/local-session-create-prompt-actions.test.ts
new file mode 100644
index 0000000000..b0676bfce8
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/local-session-create-prompt-actions.test.ts
@@ -0,0 +1,78 @@
+import { describe, expect, it, vi } from 'vitest';
+
+import { INITIAL_LOCAL_SESSION_CONFIG_SELECTION } from './local-session-config-selection';
+import { type LocalSessionConfigController } from './use-local-session-config-controller';
+import {
+ preservePromptOnClearFence,
+ preservePromptOnRefreshCatalog,
+} from './local-session-create-prompt-actions';
+
+function makeMinimalController(
+ partial: Partial
+): LocalSessionConfigController {
+ return {
+ selection: INITIAL_LOCAL_SESSION_CONFIG_SELECTION,
+ runtimesState: { data: undefined, isError: false, refetch: () => undefined },
+ catalogState: { kind: 'idle' },
+ onSelectFence: () => undefined,
+ onClearFence: () => undefined,
+ onSelectAgent: () => undefined,
+ onSelectModel: () => undefined,
+ onResetOverrides: () => undefined,
+ refetchCatalog: () => undefined,
+ ...partial,
+ };
+}
+
+describe('preservePromptOnClearFence', () => {
+ it('calls controller.onClearFence exactly once', () => {
+ const onClearFence = vi.fn<() => void>();
+ preservePromptOnClearFence({
+ controller: makeMinimalController({ onClearFence }),
+ promptRef: { current: 'untouched' },
+ });
+ expect(onClearFence).toHaveBeenCalledTimes(1);
+ });
+
+ it('never mutates the prompt ref content', () => {
+ const onClearFence = vi.fn<() => void>();
+ const promptRef = { current: 'live prompt' };
+ preservePromptOnClearFence({
+ controller: makeMinimalController({ onClearFence }),
+ promptRef,
+ });
+ expect(promptRef.current).toBe('live prompt');
+ });
+
+ it('leaves an empty prompt empty', () => {
+ const onClearFence = vi.fn<() => void>();
+ const promptRef = { current: '' };
+ preservePromptOnClearFence({
+ controller: makeMinimalController({ onClearFence }),
+ promptRef,
+ });
+ expect(promptRef.current).toBe('');
+ });
+});
+
+describe('preservePromptOnRefreshCatalog', () => {
+ it('invokes both refetchCatalog and onResetOverrides', () => {
+ const refetchCatalog = vi.fn<() => void>();
+ const onResetOverrides = vi.fn<() => void>();
+ preservePromptOnRefreshCatalog({
+ refetchCatalog,
+ onResetOverrides,
+ promptRef: { current: 'still here' },
+ });
+ expect(refetchCatalog).toHaveBeenCalledTimes(1);
+ expect(onResetOverrides).toHaveBeenCalledTimes(1);
+ });
+
+ it('never mutates the prompt ref content', () => {
+ const refetchCatalog = vi.fn<() => void>();
+ const onResetOverrides = vi.fn<() => void>();
+ const promptRef = { current: 'live prompt' };
+ preservePromptOnRefreshCatalog({ refetchCatalog, onResetOverrides, promptRef });
+ expect(promptRef.current).toBe('live prompt');
+ });
+});
diff --git a/apps/mobile/src/lib/hooks/local-session-create-prompt-actions.ts b/apps/mobile/src/lib/hooks/local-session-create-prompt-actions.ts
new file mode 100644
index 0000000000..9f1c1a3bc3
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/local-session-create-prompt-actions.ts
@@ -0,0 +1,30 @@
+/**
+ * Pure wrappers that bind recovery-CTAs to the controller without ever
+ * touching the prompt ref. The prompt is owned by an uncontrolled
+ * `TextInput` whose content lives in a ref; neither action helper is allowed
+ * to write to that ref or call any setter that could overwrite it.
+ *
+ * The wrappers exist as named, testable seams so the screen does not
+ * re-implement the "preserve the prompt" contract inline, where it would
+ * have to be reasoned about by source inspection rather than a test.
+ */
+
+import { type LocalSessionConfigController } from './use-local-session-config-controller';
+
+type PromptRef = { current: string };
+
+export function preservePromptOnClearFence(input: {
+ controller: LocalSessionConfigController;
+ promptRef: PromptRef;
+}): void {
+ input.controller.onClearFence();
+}
+
+export function preservePromptOnRefreshCatalog(input: {
+ refetchCatalog: () => void;
+ onResetOverrides: () => void;
+ promptRef: PromptRef;
+}): void {
+ input.refetchCatalog();
+ input.onResetOverrides();
+}
diff --git a/apps/mobile/src/lib/hooks/local-session-create-recovery-actions.test.ts b/apps/mobile/src/lib/hooks/local-session-create-recovery-actions.test.ts
new file mode 100644
index 0000000000..0e65485d49
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/local-session-create-recovery-actions.test.ts
@@ -0,0 +1,162 @@
+import { describe, expect, it, vi } from 'vitest';
+
+import { type LocalSessionCreateRecovery } from './local-session-create-errors';
+import {
+ resolveRecoveryCtaAction,
+ type ResolveRecoveryCtaActionInput,
+} from './local-session-create-recovery-actions';
+
+function makeHandlers() {
+ return {
+ onRetry: vi.fn(),
+ onCheckAgain: vi.fn(),
+ onSelectRuntime: vi.fn(),
+ onRefreshCatalog: vi.fn(),
+ };
+}
+
+function makeRecovery(partial: Partial): LocalSessionCreateRecovery {
+ return partial as LocalSessionCreateRecovery;
+}
+
+const baseInput: Omit = {
+ ...makeHandlers(),
+ isSubmitting: false,
+};
+
+describe('resolveRecoveryCtaAction', () => {
+ it('returns none when recovery is null', () => {
+ const result = resolveRecoveryCtaAction({ ...baseInput, recovery: null });
+ expect(result.kind).toBe('none');
+ });
+
+ it('returns none for the non-retryable CLI upgrade branch (ctaLabel null)', () => {
+ const recovery = makeRecovery({
+ kind: 'non-retryable-cli-upgrade',
+ message: 'Update Kilo CLI and reconnect.',
+ ctaLabel: null,
+ });
+ const result = resolveRecoveryCtaAction({ ...baseInput, recovery });
+ expect(result.kind).toBe('none');
+ });
+
+ it('returns none for the non-retryable malformed branch (ctaLabel null)', () => {
+ const recovery = makeRecovery({
+ kind: 'non-retryable-malformed',
+ message: 'Unsupported response.',
+ ctaLabel: null,
+ });
+ const result = resolveRecoveryCtaAction({ ...baseInput, recovery });
+ expect(result.kind).toBe('none');
+ });
+
+ it('returns none for the non-retryable access-lost branch (ctaLabel null)', () => {
+ const recovery = makeRecovery({
+ kind: 'non-retryable-access-lost',
+ message: 'You no longer have access to this session.',
+ ctaLabel: null,
+ });
+ const result = resolveRecoveryCtaAction({ ...baseInput, recovery });
+ expect(result.kind).toBe('none');
+ });
+
+ it('maps a Retry CTA exactly to the retry handler with the verbatim label', () => {
+ const handlers = makeHandlers();
+ const recovery = makeRecovery({
+ kind: 'transient',
+ message: 'transient',
+ ctaLabel: 'Retry',
+ });
+ const result = resolveRecoveryCtaAction({ ...handlers, isSubmitting: false, recovery });
+ if (result.kind !== 'retry') {
+ throw new Error('expected retry action');
+ }
+ expect(result.label).toBe('Retry');
+ result.onPress();
+ expect(handlers.onRetry).toHaveBeenCalledTimes(1);
+ expect(handlers.onCheckAgain).not.toHaveBeenCalled();
+ expect(handlers.onSelectRuntime).not.toHaveBeenCalled();
+ expect(handlers.onRefreshCatalog).not.toHaveBeenCalled();
+ });
+
+ it('maps the limit branch CTA Retry to the retry handler', () => {
+ const handlers = makeHandlers();
+ const recovery = makeRecovery({
+ kind: 'limit',
+ message: 'limit',
+ ctaLabel: 'Retry',
+ });
+ const result = resolveRecoveryCtaAction({ ...handlers, isSubmitting: false, recovery });
+ expect(result.kind).toBe('retry');
+ if (result.kind !== 'retry') {
+ throw new Error('expected retry action');
+ }
+ result.onPress();
+ expect(handlers.onRetry).toHaveBeenCalledTimes(1);
+ });
+
+ it('maps a Check again CTA exactly to the check-again handler', () => {
+ const handlers = makeHandlers();
+ const recovery = makeRecovery({
+ kind: 'readiness-timeout',
+ message: 'not ready',
+ ctaLabel: 'Check again',
+ });
+ const result = resolveRecoveryCtaAction({ ...handlers, isSubmitting: false, recovery });
+ if (result.kind !== 'check-again') {
+ throw new Error('expected check-again action');
+ }
+ expect(result.label).toBe('Check again');
+ result.onPress();
+ expect(handlers.onCheckAgain).toHaveBeenCalledTimes(1);
+ expect(handlers.onRetry).not.toHaveBeenCalled();
+ });
+
+ it('maps a Select runtime CTA exactly to the select-runtime handler', () => {
+ const handlers = makeHandlers();
+ const recovery = makeRecovery({
+ kind: 'fence-changed',
+ message: 'fence changed',
+ ctaLabel: 'Select runtime',
+ });
+ const result = resolveRecoveryCtaAction({ ...handlers, isSubmitting: false, recovery });
+ if (result.kind !== 'select-runtime') {
+ throw new Error('expected select-runtime action');
+ }
+ expect(result.label).toBe('Select runtime');
+ result.onPress();
+ expect(handlers.onSelectRuntime).toHaveBeenCalledTimes(1);
+ expect(handlers.onRefreshCatalog).not.toHaveBeenCalled();
+ });
+
+ it('maps a Refresh catalog CTA exactly to the refresh-catalog handler', () => {
+ const handlers = makeHandlers();
+ const recovery = makeRecovery({
+ kind: 'catalog-changed',
+ message: 'catalog changed',
+ ctaLabel: 'Refresh catalog',
+ });
+ const result = resolveRecoveryCtaAction({ ...handlers, isSubmitting: false, recovery });
+ if (result.kind !== 'refresh-catalog') {
+ throw new Error('expected refresh-catalog action');
+ }
+ expect(result.label).toBe('Refresh catalog');
+ result.onPress();
+ expect(handlers.onRefreshCatalog).toHaveBeenCalledTimes(1);
+ expect(handlers.onSelectRuntime).not.toHaveBeenCalled();
+ });
+
+ it('still resolves the action while submitting (rendering layer owns the disabled flag)', () => {
+ const handlers = makeHandlers();
+ const recovery = makeRecovery({
+ kind: 'transient',
+ message: 'transient',
+ ctaLabel: 'Retry',
+ });
+ const result = resolveRecoveryCtaAction({ ...handlers, isSubmitting: true, recovery });
+ if (result.kind !== 'retry') {
+ throw new Error('expected retry action');
+ }
+ expect(result.label).toBe('Retry');
+ });
+});
diff --git a/apps/mobile/src/lib/hooks/local-session-create-recovery-actions.ts b/apps/mobile/src/lib/hooks/local-session-create-recovery-actions.ts
new file mode 100644
index 0000000000..88bada3448
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/local-session-create-recovery-actions.ts
@@ -0,0 +1,74 @@
+import { type LocalSessionCreateRecovery } from './local-session-create-errors';
+
+/**
+ * Pure resolver that maps the orchestrator's typed `LocalSessionCreateRecovery`
+ * branch onto exactly one rendering action (or terminal absence).
+ *
+ * The renderer owns the *visible* CTA surface; this module owns the *mapping*
+ * from a recovery branch to the wire-level handler that the CTA must invoke.
+ * The mapping is the only logic the screen must not re-implement, because the
+ * recovery branch's `ctaLabel` is the single source of truth for whether a
+ * CTA is shown.
+ *
+ * Branching:
+ *
+ * - `recovery === null` -> `{ kind: 'none' }`
+ * - `ctaLabel === null` (non-retryable) -> `{ kind: 'none' }`
+ * - `ctaLabel === 'Retry'` -> `{ kind: 'retry', ... }`
+ * - `ctaLabel === 'Check again'` -> `{ kind: 'check-again', ... }`
+ * - `ctaLabel === 'Select runtime'` -> `{ kind: 'select-runtime', ... }`
+ * - `ctaLabel === 'Refresh catalog'` -> `{ kind: 'refresh-catalog', ... }`
+ *
+ * The resolver always returns the same handler reference the caller passed
+ * in; it never wraps, throttles, or replaces it. The screen passes
+ * `isSubmitting` so the rendering layer can disable the button while a
+ * request is in flight without this module needing to know about
+ * React state.
+ */
+export type RecoveryCtaAction =
+ | { kind: 'none' }
+ | { kind: 'retry'; label: 'Retry'; onPress: () => void }
+ | { kind: 'check-again'; label: 'Check again'; onPress: () => void }
+ | { kind: 'select-runtime'; label: 'Select runtime'; onPress: () => void }
+ | { kind: 'refresh-catalog'; label: 'Refresh catalog'; onPress: () => void };
+
+export type ResolveRecoveryCtaActionInput = {
+ recovery: LocalSessionCreateRecovery | null;
+ isSubmitting: boolean;
+ onRetry: () => void;
+ onCheckAgain: () => void;
+ onSelectRuntime: () => void;
+ onRefreshCatalog: () => void;
+};
+
+export function resolveRecoveryCtaAction(input: ResolveRecoveryCtaActionInput): RecoveryCtaAction {
+ const { recovery } = input;
+ if (recovery === null) {
+ return { kind: 'none' };
+ }
+ const ctaLabel = recovery.ctaLabel;
+ if (ctaLabel === null) {
+ return { kind: 'none' };
+ }
+ switch (ctaLabel) {
+ case 'Retry': {
+ return { kind: 'retry', label: 'Retry', onPress: input.onRetry };
+ }
+ case 'Check again': {
+ return { kind: 'check-again', label: 'Check again', onPress: input.onCheckAgain };
+ }
+ case 'Select runtime': {
+ return { kind: 'select-runtime', label: 'Select runtime', onPress: input.onSelectRuntime };
+ }
+ case 'Refresh catalog': {
+ return { kind: 'refresh-catalog', label: 'Refresh catalog', onPress: input.onRefreshCatalog };
+ }
+ default: {
+ // Exhaustive: `LocalSessionCreateRecovery.ctaLabel` is a string-literal
+ // union; a future branch must be added here.
+ const _exhaustive: never = ctaLabel;
+ void _exhaustive;
+ return { kind: 'none' };
+ }
+ }
+}
diff --git a/apps/mobile/src/lib/hooks/local-session-create-request.test.ts b/apps/mobile/src/lib/hooks/local-session-create-request.test.ts
new file mode 100644
index 0000000000..1b0fb17d1e
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/local-session-create-request.test.ts
@@ -0,0 +1,180 @@
+import { describe, expect, it } from 'vitest';
+import { type inferRouterInputs, type RootRouter } from '@kilocode/trpc';
+
+import {
+ buildLocalSessionCreateRequest,
+ type BuildLocalSessionCreateRequestInput,
+ type LocalSessionCreateAgentSelection,
+ type LocalSessionCreateModelSelection,
+} from './local-session-create-request';
+
+type RouterInputs = inferRouterInputs;
+
+const FENCE = {
+ runtimeId: '11111111-1111-4111-8111-111111111111',
+ connectionId: 'cli-connection-1',
+} as const;
+
+const AGENT: LocalSessionCreateAgentSelection = { slug: 'build', name: 'Build' };
+
+const MODEL_PINNED: LocalSessionCreateModelSelection = {
+ providerID: 'kilo',
+ modelID: 'claude-opus-4-7',
+ variant: 'max',
+};
+
+const REQUEST_ID = '00000000-0000-4000-8000-000000000001';
+
+function makeInput(
+ overrides: Partial = {}
+): BuildLocalSessionCreateRequestInput {
+ return {
+ fence: FENCE,
+ catalog: {
+ protocolVersion: 1,
+ defaultAgent: 'build',
+ agents: [AGENT],
+ models: {
+ protocolVersion: 1,
+ providers: [
+ {
+ id: 'kilo',
+ models: [{ id: 'claude-opus-4-7', variants: ['max', 'min'] }],
+ },
+ ],
+ truncated: false,
+ },
+ },
+ selectedAgentSlug: 'build',
+ selectedModel: MODEL_PINNED,
+ prompt: 'Build me a thing',
+ requestId: REQUEST_ID,
+ ...overrides,
+ };
+}
+
+describe('buildLocalSessionCreateRequest', () => {
+ it('returns a strict-shape request that mirrors the tRPC createAndRun input', () => {
+ const built = buildLocalSessionCreateRequest(makeInput());
+ expect(built).toEqual({
+ fence: FENCE,
+ request: {
+ protocolVersion: 1,
+ requestId: REQUEST_ID,
+ prompt: 'Build me a thing',
+ model: { providerID: 'kilo', modelID: 'claude-opus-4-7' },
+ variant: 'max',
+ agent: 'build',
+ },
+ });
+ });
+
+ it('trims the prompt before the length check and the wire payload', () => {
+ const built = buildLocalSessionCreateRequest(makeInput({ prompt: ' trimmed ' }));
+ expect(built.request.prompt).toBe('trimmed');
+ });
+
+ it('rejects a prompt that is empty after trimming', () => {
+ expect(() => buildLocalSessionCreateRequest(makeInput({ prompt: ' ' }))).toThrow();
+ });
+
+ it('rejects a prompt that is one character too long', () => {
+ const tooLong = 'a'.repeat(32_768 + 1);
+ expect(() => buildLocalSessionCreateRequest(makeInput({ prompt: tooLong }))).toThrow();
+ });
+
+ it('accepts a prompt at the exact 32,768-character boundary', () => {
+ const at = 'a'.repeat(32_768);
+ const built = buildLocalSessionCreateRequest(makeInput({ prompt: at }));
+ expect(built.request.prompt.length).toBe(32_768);
+ });
+
+ it('rejects a prompt that is empty before trimming', () => {
+ expect(() => buildLocalSessionCreateRequest(makeInput({ prompt: '' }))).toThrow();
+ });
+
+ it('rejects when the requestId is not a UUID', () => {
+ expect(() => buildLocalSessionCreateRequest(makeInput({ requestId: 'not-a-uuid' }))).toThrow();
+ });
+
+ it('rejects when the fence is missing or partial', () => {
+ expect(() =>
+ buildLocalSessionCreateRequest(makeInput({ fence: { ...FENCE, runtimeId: '' } }))
+ ).toThrow();
+ expect(() =>
+ buildLocalSessionCreateRequest(makeInput({ fence: { ...FENCE, connectionId: '' } }))
+ ).toThrow();
+ });
+
+ it('rejects when the catalog is not a usable shape (no agents, no models, missing default agent)', () => {
+ const noAgents = makeInput({
+ catalog: { ...makeInput().catalog, agents: [] },
+ });
+ expect(() => buildLocalSessionCreateRequest(noAgents)).toThrow();
+
+ const noModels = makeInput({
+ catalog: {
+ ...makeInput().catalog,
+ models: { protocolVersion: 1, providers: [], truncated: false },
+ },
+ });
+ expect(() => buildLocalSessionCreateRequest(noModels)).toThrow();
+
+ const unknownAgent = makeInput({ selectedAgentSlug: 'rogue' });
+ expect(() => buildLocalSessionCreateRequest(unknownAgent)).toThrow();
+ });
+
+ it('rejects when the selected model is not present in the catalog', () => {
+ const missingModel = makeInput({
+ selectedModel: { providerID: 'kilo', modelID: 'not-in-catalog', variant: 'max' },
+ });
+ expect(() => buildLocalSessionCreateRequest(missingModel)).toThrow();
+ });
+
+ it('rejects when the selected variant is not one of the model variants', () => {
+ const missingVariant = makeInput({
+ selectedModel: {
+ providerID: 'kilo',
+ modelID: 'claude-opus-4-7',
+ variant: 'unknown-variant',
+ },
+ });
+ expect(() => buildLocalSessionCreateRequest(missingVariant)).toThrow();
+ });
+
+ it('omits the variant key from the wire payload when the model has no variant', () => {
+ const noVariant = makeInput({
+ selectedModel: { providerID: 'kilo', modelID: 'claude-opus-4-7', variant: '' },
+ });
+ const built = buildLocalSessionCreateRequest(noVariant);
+ expect('variant' in built.request).toBe(false);
+ });
+
+ it('does not include an attachments or images field on the request shape', () => {
+ const built = buildLocalSessionCreateRequest(makeInput());
+ const keys = Object.keys(built.request).toSorted();
+ expect(keys).toEqual(
+ ['agent', 'model', 'prompt', 'protocolVersion', 'requestId', 'variant'].toSorted()
+ );
+ expect('attachments' in built.request).toBe(false);
+ expect('images' in built.request).toBe(false);
+ });
+
+ it('uses the supplied requestId verbatim and does not reach for any UUID generator', () => {
+ const built = buildLocalSessionCreateRequest(makeInput());
+ expect(built.request.requestId).toBe(REQUEST_ID);
+ });
+
+ it('treats inputs that violate the type contract as unknown at the parser seam', () => {
+ const malformed: unknown = { fence: null, catalog: null, prompt: null, requestId: null };
+ expect(() =>
+ buildLocalSessionCreateRequest(malformed as BuildLocalSessionCreateRequestInput)
+ ).toThrow();
+ });
+
+ it('exposes a typed return value that is structurally compatible with the tRPC RouterInputs createAndRun input', () => {
+ const built = buildLocalSessionCreateRequest(makeInput());
+ const _typed: RouterInputs['localRuntimeControl']['createAndRun'] = built;
+ void _typed;
+ });
+});
diff --git a/apps/mobile/src/lib/hooks/local-session-create-request.ts b/apps/mobile/src/lib/hooks/local-session-create-request.ts
new file mode 100644
index 0000000000..08c8addfc6
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/local-session-create-request.ts
@@ -0,0 +1,187 @@
+import { type inferRouterInputs, type RootRouter } from '@kilocode/trpc';
+
+import { type LocalRuntimeCatalog, type LocalRuntimeFence } from './local-runtime-catalog-types';
+
+type RouterInputs = inferRouterInputs;
+
+export type LocalSessionCreateModelSelection = {
+ providerID: string;
+ modelID: string;
+ variant: string;
+};
+
+export type LocalSessionCreateAgentSelection = {
+ slug: string;
+ name: string;
+};
+
+export type BuildLocalSessionCreateRequestInput = {
+ fence: LocalRuntimeFence;
+ catalog: LocalRuntimeCatalog;
+ selectedAgentSlug: string;
+ selectedModel: LocalSessionCreateModelSelection;
+ prompt: string;
+ requestId: string;
+};
+
+export type BuiltLocalSessionCreateRequest = RouterInputs['localRuntimeControl']['createAndRun'];
+
+const PROMPT_MIN = 1;
+const PROMPT_MAX = 32_768;
+
+const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
+
+/**
+ * Pure builder for the wire payload the orchestrator hands to
+ * `trpcClient.localRuntimeControl.createAndRun.mutate`. Every field on the
+ * returned object is derived strictly from the input — no `Crypto.randomUUID`
+ * is invoked here, no default agent fallback, no trimming logic, no
+ * attachments. The function is the only place the create request shape is
+ * built; the orchestrator is responsible for the requestId it passes in.
+ *
+ * The function throws a `LocalSessionCreateRequestError` with a precise
+ * machine-readable code when any input is unusable, so the orchestrator can
+ * map a builder failure to the same upstream-error classifier used for tRPC
+ * failures (e.g. a stale `selectedAgentSlug` collapses to the
+ * `catalog-changed` branch, not a generic client error).
+ */
+export function buildLocalSessionCreateRequest(
+ input: BuildLocalSessionCreateRequestInput
+): BuiltLocalSessionCreateRequest {
+ validateFence(input.fence);
+ validateRequestId(input.requestId);
+ validateCatalogContents(input.catalog);
+ validateSelection(input.catalog, input.selectedAgentSlug, input.selectedModel);
+ const trimmed = input.prompt.trim();
+ validatePrompt(trimmed);
+
+ const baseRequest = {
+ protocolVersion: 1 as const,
+ requestId: input.requestId,
+ prompt: trimmed,
+ model: {
+ providerID: input.selectedModel.providerID,
+ modelID: input.selectedModel.modelID,
+ },
+ agent: input.selectedAgentSlug,
+ };
+ const variant = input.selectedModel.variant;
+ const request: BuiltLocalSessionCreateRequest['request'] = variant
+ ? { ...baseRequest, variant }
+ : baseRequest;
+ return {
+ fence: {
+ runtimeId: input.fence.runtimeId,
+ connectionId: input.fence.connectionId,
+ },
+ request,
+ };
+}
+
+type LocalSessionCreateRequestErrorCode =
+ | 'invalid-fence'
+ | 'invalid-request-id'
+ | 'invalid-catalog'
+ | 'invalid-agent'
+ | 'invalid-model'
+ | 'invalid-prompt-empty'
+ | 'invalid-prompt-too-long';
+
+export class LocalSessionCreateRequestError extends Error {
+ readonly code: LocalSessionCreateRequestErrorCode;
+
+ constructor(code: LocalSessionCreateRequestErrorCode, message: string) {
+ super(message);
+ this.name = 'LocalSessionCreateRequestError';
+ this.code = code;
+ }
+}
+
+function validateFence(fence: LocalRuntimeFence) {
+ if (typeof fence.runtimeId !== 'string' || fence.runtimeId.length === 0) {
+ throw new LocalSessionCreateRequestError('invalid-fence', 'runtimeId is required');
+ }
+ if (typeof fence.connectionId !== 'string' || fence.connectionId.length === 0) {
+ throw new LocalSessionCreateRequestError('invalid-fence', 'connectionId is required');
+ }
+ if (!UUID_PATTERN.test(fence.runtimeId)) {
+ throw new LocalSessionCreateRequestError('invalid-fence', 'runtimeId must be a UUID');
+ }
+}
+
+function validateRequestId(requestId: string) {
+ if (typeof requestId !== 'string' || requestId.length === 0) {
+ throw new LocalSessionCreateRequestError('invalid-request-id', 'requestId is required');
+ }
+ if (!UUID_PATTERN.test(requestId)) {
+ throw new LocalSessionCreateRequestError('invalid-request-id', 'requestId must be a UUID');
+ }
+}
+
+function validateCatalogContents(catalog: LocalRuntimeCatalog) {
+ if (!catalog.defaultAgent) {
+ throw new LocalSessionCreateRequestError('invalid-catalog', 'catalog.defaultAgent is required');
+ }
+ if (!Array.isArray(catalog.agents) || catalog.agents.length === 0) {
+ throw new LocalSessionCreateRequestError('invalid-catalog', 'catalog.agents must be non-empty');
+ }
+ if (
+ !Array.isArray(catalog.models.providers) ||
+ catalog.models.providers.length === 0 ||
+ catalog.models.providers.every(p => !Array.isArray(p.models) || p.models.length === 0)
+ ) {
+ throw new LocalSessionCreateRequestError('invalid-catalog', 'catalog.models must be non-empty');
+ }
+}
+
+function validateSelection(
+ catalog: LocalRuntimeCatalog,
+ selectedAgentSlug: string,
+ selectedModel: LocalSessionCreateModelSelection
+) {
+ if (typeof selectedAgentSlug !== 'string' || selectedAgentSlug.length === 0) {
+ throw new LocalSessionCreateRequestError('invalid-agent', 'selectedAgentSlug is required');
+ }
+ const agent = catalog.agents.find(a => a.slug === selectedAgentSlug);
+ if (!agent) {
+ throw new LocalSessionCreateRequestError(
+ 'invalid-agent',
+ `selectedAgentSlug "${selectedAgentSlug}" is not present in the catalog`
+ );
+ }
+ if (typeof selectedModel.providerID !== 'string' || selectedModel.providerID.length === 0) {
+ throw new LocalSessionCreateRequestError('invalid-model', 'providerID is required');
+ }
+ if (typeof selectedModel.modelID !== 'string' || selectedModel.modelID.length === 0) {
+ throw new LocalSessionCreateRequestError('invalid-model', 'modelID is required');
+ }
+ const provider = catalog.models.providers.find(p => p.id === selectedModel.providerID);
+ const model = provider?.models.find(m => m.id === selectedModel.modelID);
+ if (!model) {
+ throw new LocalSessionCreateRequestError(
+ 'invalid-model',
+ `model "${selectedModel.providerID}/${selectedModel.modelID}" is not present in the catalog`
+ );
+ }
+ if (selectedModel.variant.length > 0 && !model.variants.includes(selectedModel.variant)) {
+ throw new LocalSessionCreateRequestError(
+ 'invalid-model',
+ `variant "${selectedModel.variant}" is not offered by the selected model`
+ );
+ }
+}
+
+function validatePrompt(prompt: string) {
+ if (prompt.length < PROMPT_MIN) {
+ throw new LocalSessionCreateRequestError(
+ 'invalid-prompt-empty',
+ 'Enter a prompt to start the session.'
+ );
+ }
+ if (prompt.length > PROMPT_MAX) {
+ throw new LocalSessionCreateRequestError(
+ 'invalid-prompt-too-long',
+ 'Prompt must be 32,768 characters or fewer. Shorten the prompt and try again.'
+ );
+ }
+}
diff --git a/apps/mobile/src/lib/hooks/local-session-request-id.test.ts b/apps/mobile/src/lib/hooks/local-session-request-id.test.ts
new file mode 100644
index 0000000000..e78d285241
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/local-session-request-id.test.ts
@@ -0,0 +1,133 @@
+import { describe, expect, it, vi } from 'vitest';
+
+import {
+ createLocalSessionRequestIdStore,
+ type LocalSessionRequestIdStore,
+ type RequestIdUuid,
+} from './local-session-request-id';
+
+const FENCE_A = {
+ runtimeId: '11111111-1111-4111-8111-111111111111',
+ connectionId: 'cli-a',
+} as const;
+const FENCE_A_NEW_SOCKET = {
+ runtimeId: '11111111-1111-4111-8111-111111111111',
+ connectionId: 'cli-a-new',
+} as const;
+const FENCE_B = {
+ runtimeId: '22222222-2222-4222-8222-222222222222',
+ connectionId: 'cli-b',
+} as const;
+
+function makeUuidFactory(values: string[]): () => RequestIdUuid {
+ let index = 0;
+ return () => {
+ const value = values[index];
+ index += 1;
+ if (value === undefined) {
+ throw new Error(`UUID factory exhausted at call #${index}`);
+ }
+ return value as RequestIdUuid;
+ };
+}
+
+const REQUEST_ID_1: RequestIdUuid = '00000000-0000-4000-8000-000000000001' as RequestIdUuid;
+const REQUEST_ID_2: RequestIdUuid = '00000000-0000-4000-8000-000000000002' as RequestIdUuid;
+const REQUEST_ID_3: RequestIdUuid = '00000000-0000-4000-8000-000000000003' as RequestIdUuid;
+
+describe('createLocalSessionRequestIdStore', () => {
+ it('lazily allocates a fresh UUID on the first acquire for a given fence', () => {
+ const gen = vi.fn(makeUuidFactory([REQUEST_ID_1, REQUEST_ID_2]));
+ const store = createLocalSessionRequestIdStore({ generateUuid: gen });
+
+ expect(store.getOrAcquire(FENCE_A)).toBe(REQUEST_ID_1);
+ expect(store.getOrAcquire(FENCE_A)).toBe(REQUEST_ID_1);
+ expect(gen).toHaveBeenCalledTimes(1);
+ });
+
+ it('allocates a fresh UUID when the runtimeId changes', () => {
+ const gen = vi.fn(makeUuidFactory([REQUEST_ID_1, REQUEST_ID_2]));
+ const store = createLocalSessionRequestIdStore({ generateUuid: gen });
+
+ expect(store.getOrAcquire(FENCE_A)).toBe(REQUEST_ID_1);
+ expect(store.getOrAcquire(FENCE_B)).toBe(REQUEST_ID_2);
+ expect(gen).toHaveBeenCalledTimes(2);
+ });
+
+ it('clears the requestId when the connectionId changes (reconnect) and reallocates on next acquire', () => {
+ const gen = vi.fn(makeUuidFactory([REQUEST_ID_1, REQUEST_ID_2]));
+ const store = createLocalSessionRequestIdStore({ generateUuid: gen });
+
+ expect(store.getOrAcquire(FENCE_A)).toBe(REQUEST_ID_1);
+ expect(store.getOrAcquire(FENCE_A_NEW_SOCKET)).toBe(REQUEST_ID_2);
+ expect(gen).toHaveBeenCalledTimes(2);
+ });
+
+ it('clearByFence removes the requestId for the exact fence without touching other fences', () => {
+ const gen = vi.fn(makeUuidFactory([REQUEST_ID_1, REQUEST_ID_2, REQUEST_ID_3]));
+ const store = createLocalSessionRequestIdStore({ generateUuid: gen });
+
+ expect(store.getOrAcquire(FENCE_A)).toBe(REQUEST_ID_1);
+ expect(store.getOrAcquire(FENCE_B)).toBe(REQUEST_ID_2);
+ store.clearByFence(FENCE_A);
+ expect(store.getOrAcquire(FENCE_A)).toBe(REQUEST_ID_3);
+ expect(store.getOrAcquire(FENCE_B)).toBe(REQUEST_ID_2);
+ });
+
+ it('clearAll wipes every fence and forces fresh allocations on next acquire', () => {
+ const gen = vi.fn(makeUuidFactory([REQUEST_ID_1, REQUEST_ID_2, REQUEST_ID_3]));
+ const store = createLocalSessionRequestIdStore({ generateUuid: gen });
+
+ expect(store.getOrAcquire(FENCE_A)).toBe(REQUEST_ID_1);
+ expect(store.getOrAcquire(FENCE_B)).toBe(REQUEST_ID_2);
+ store.clearAll();
+ expect(store.getOrAcquire(FENCE_A)).toBe(REQUEST_ID_3);
+ });
+
+ it('returns the current requestId without allocating a new UUID when one is already bound', () => {
+ const gen = vi.fn(makeUuidFactory([REQUEST_ID_1]));
+ const store = createLocalSessionRequestIdStore({ generateUuid: gen });
+ const first = store.getOrAcquire(FENCE_A);
+ const second = store.getOrAcquire(FENCE_A);
+ expect(first).toBe(second);
+ expect(first).toBe(REQUEST_ID_1);
+ });
+
+ it('a "success" clear for the fence removes the requestId so the next attempt starts fresh', () => {
+ const gen = vi.fn(makeUuidFactory([REQUEST_ID_1, REQUEST_ID_2]));
+ const store = createLocalSessionRequestIdStore({ generateUuid: gen });
+ expect(store.getOrAcquire(FENCE_A)).toBe(REQUEST_ID_1);
+ store.markSuccess(FENCE_A);
+ expect(store.getOrAcquire(FENCE_A)).toBe(REQUEST_ID_2);
+ });
+
+ it('only allocates through the injected generateUuid — no built-in fallback to Crypto.randomUUID', () => {
+ const gen = vi.fn(makeUuidFactory([REQUEST_ID_1]));
+ const store = createLocalSessionRequestIdStore({ generateUuid: gen });
+ store.getOrAcquire(FENCE_A);
+ expect(gen).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not retain a requestId for a fence that was never acquired', () => {
+ const gen = vi.fn(() => REQUEST_ID_1);
+ const store = createLocalSessionRequestIdStore({ generateUuid: gen });
+ store.clearByFence(FENCE_A);
+ store.clearAll();
+ expect(gen).not.toHaveBeenCalled();
+ });
+
+ it('clearByFence for a fence with a different connectionId does not wipe the original entry', () => {
+ const gen = vi.fn(makeUuidFactory([REQUEST_ID_1, REQUEST_ID_2]));
+ const store = createLocalSessionRequestIdStore({ generateUuid: gen });
+ expect(store.getOrAcquire(FENCE_A)).toBe(REQUEST_ID_1);
+ store.clearByFence({ runtimeId: FENCE_A.runtimeId, connectionId: 'stale' });
+ expect(store.getOrAcquire(FENCE_A)).toBe(REQUEST_ID_1);
+ });
+
+ it('exposes a typed store via the factory contract', () => {
+ const store: LocalSessionRequestIdStore = createLocalSessionRequestIdStore({
+ generateUuid: makeUuidFactory([REQUEST_ID_1]),
+ });
+ expect(store.getOrAcquire(FENCE_A)).toBe(REQUEST_ID_1);
+ });
+});
diff --git a/apps/mobile/src/lib/hooks/local-session-request-id.ts b/apps/mobile/src/lib/hooks/local-session-request-id.ts
new file mode 100644
index 0000000000..bcb44c880a
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/local-session-request-id.ts
@@ -0,0 +1,95 @@
+import { type LocalRuntimeFence } from './local-runtime-catalog-types';
+
+/**
+ * Strongly-typed request ID. The server contract requires a UUID; keeping
+ * the brand separate from `string` lets the orchestrator tell at a glance
+ * which strings have been minted by this store.
+ */
+export type RequestIdUuid = string & { readonly __brand: 'LocalSessionRequestId' };
+
+export type LocalSessionRequestIdStore = {
+ /**
+ * Return the requestId bound to the exact fence, allocating a new one
+ * when none is bound. Allocation is invisible to callers — repeated
+ * `getOrAcquire` calls for the same fence always return the same ID.
+ */
+ getOrAcquire: (fence: LocalRuntimeFence) => RequestIdUuid;
+ /**
+ * Forget the requestId bound to the exact fence, if any. A subsequent
+ * `getOrAcquire(fence)` will allocate a new UUID. No-op when the fence
+ * has no entry. Other fences are untouched.
+ */
+ clearByFence: (fence: LocalRuntimeFence) => void;
+ /**
+ * Drop every cached requestId. Used when the runtime list goes empty or
+ * when the user is logged out.
+ */
+ clearAll: () => void;
+ /**
+ * Convenience: clear the requestId bound to the exact fence. The
+ * orchestrator calls this on a successful create so the next attempt
+ * starts a fresh request ID.
+ */
+ markSuccess: (fence: LocalRuntimeFence) => void;
+};
+
+type CreateLocalSessionRequestIdStoreOptions = {
+ /**
+ * UUID factory. The orchestrator hook always passes
+ * `Crypto.randomUUID`; tests inject a deterministic factory to assert
+ * allocation behaviour without touching the global `Crypto` object.
+ */
+ generateUuid: () => RequestIdUuid;
+};
+
+function fenceKey(fence: LocalRuntimeFence): string {
+ return `${fence.runtimeId}\u0000${fence.connectionId}`;
+}
+
+/**
+ * Pure, framework-agnostic store for create-mutation request IDs.
+ *
+ * Lifetime contract (enforced by tests):
+ *
+ * - The same exact fence (matching both `runtimeId` and `connectionId`)
+ * reuses the same requestId across `getOrAcquire` calls so a retry of
+ * the same user attempt is server-deduplicable.
+ * - A fence change — runtimeId change OR connectionId change (i.e. a
+ * reconnect) — drops the old requestId and allocates a new one on the
+ * next `getOrAcquire`. The old ID is never sent to a different runtime.
+ * - `markSuccess` / `clearByFence` clear the binding explicitly so a
+ * successful create or a user-driven retry starts a fresh ID without
+ * racing the next `getOrAcquire` call.
+ *
+ * The store is intentionally tiny and dependency-free; the orchestrator
+ * owns one instance and is responsible for lifecycle (clear-on-logout,
+ * etc).
+ */
+export function createLocalSessionRequestIdStore(
+ options: CreateLocalSessionRequestIdStoreOptions
+): LocalSessionRequestIdStore {
+ const entries = new Map();
+ const { generateUuid } = options;
+
+ return {
+ getOrAcquire(fence) {
+ const key = fenceKey(fence);
+ const existing = entries.get(key);
+ if (existing !== undefined) {
+ return existing;
+ }
+ const minted = generateUuid();
+ entries.set(key, minted);
+ return minted;
+ },
+ clearByFence(fence) {
+ entries.delete(fenceKey(fence));
+ },
+ clearAll() {
+ entries.clear();
+ },
+ markSuccess(fence) {
+ entries.delete(fenceKey(fence));
+ },
+ };
+}
diff --git a/apps/mobile/src/lib/hooks/runtime-discovery-logic.test.ts b/apps/mobile/src/lib/hooks/runtime-discovery-logic.test.ts
new file mode 100644
index 0000000000..586d79caff
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/runtime-discovery-logic.test.ts
@@ -0,0 +1,228 @@
+import { describe, expect, it, vi } from 'vitest';
+
+import {
+ buildRuntimeDiscoveryRows,
+ buildRuntimeDiscoveryViewModel,
+ hasRequiredRuntimeCapabilities,
+ type LocalRuntime,
+ RUNTIME_DISCOVERY_COPY,
+ type RuntimeDiscoveryRow,
+ type RuntimeDiscoveryViewModel,
+} from './runtime-discovery-logic';
+
+function makeRuntime(overrides: Partial = {}): LocalRuntime {
+ const runtime: LocalRuntime = {
+ runtimeId: '11111111-1111-4111-8111-111111111111',
+ connectionId: 'conn-1',
+ protocolVersion: 1,
+ cliVersion: '1.2.3',
+ displayName: 'My MacBook',
+ projectName: 'kilo',
+ capabilities: ['catalog.v1', 'create-and-run.v1'],
+ ...overrides,
+ };
+ return runtime;
+}
+
+describe('hasRequiredRuntimeCapabilities', () => {
+ it('returns true when both required capabilities are present', () => {
+ expect(hasRequiredRuntimeCapabilities(['catalog.v1', 'create-and-run.v1'])).toBe(true);
+ });
+
+ it('returns false when catalog.v1 is missing', () => {
+ expect(hasRequiredRuntimeCapabilities(['create-and-run.v1'])).toBe(false);
+ });
+
+ it('returns false when create-and-run.v1 is missing', () => {
+ expect(hasRequiredRuntimeCapabilities(['catalog.v1'])).toBe(false);
+ });
+
+ it('returns false for an empty capability list', () => {
+ expect(hasRequiredRuntimeCapabilities([])).toBe(false);
+ });
+
+ it('is order-independent', () => {
+ expect(hasRequiredRuntimeCapabilities(['create-and-run.v1', 'catalog.v1'])).toBe(true);
+ });
+});
+
+describe('buildRuntimeDiscoveryRows', () => {
+ it('returns an empty array when the list is empty', () => {
+ expect(buildRuntimeDiscoveryRows([])).toEqual([]);
+ });
+
+ it('marks a single capable runtime as capable and projects all three labels', () => {
+ const runtime = makeRuntime();
+ const rows = buildRuntimeDiscoveryRows([runtime]);
+ expect(rows).toHaveLength(1);
+ expect(rows[0]).toEqual({
+ kind: 'capable',
+ runtime,
+ displayName: 'My MacBook',
+ projectName: 'kilo',
+ cliVersion: '1.2.3',
+ });
+ });
+
+ it('marks a runtime without both capabilities as incapable', () => {
+ const runtime = makeRuntime({ capabilities: ['catalog.v1'] });
+ const rows = buildRuntimeDiscoveryRows([runtime]);
+ expect(rows[0]?.kind).toBe('incapable');
+ });
+
+ it('preserves input order across multiple rows', () => {
+ const a = makeRuntime({ runtimeId: '11111111-1111-4111-8111-111111111111', displayName: 'A' });
+ const b = makeRuntime({
+ runtimeId: '22222222-2222-4222-8222-222222222222',
+ displayName: 'B',
+ capabilities: ['catalog.v1'],
+ });
+ const rows = buildRuntimeDiscoveryRows([a, b]);
+ expect(rows.map(r => r.displayName)).toEqual(['A', 'B']);
+ expect(rows[0]?.kind).toBe('capable');
+ expect(rows[1]?.kind).toBe('incapable');
+ });
+});
+
+describe('buildRuntimeDiscoveryViewModel', () => {
+ it('returns loading when no data and not in error', () => {
+ const refetch = vi.fn<() => void>();
+ const vm = buildRuntimeDiscoveryViewModel({ data: undefined, isError: false, refetch });
+ expect(vm).toEqual({ kind: 'loading' });
+ expect(refetch).not.toHaveBeenCalled();
+ });
+
+ it('returns error with exact title/message and the refetch callback when the initial load fails', () => {
+ const refetch = vi.fn<() => void>();
+ const vm = buildRuntimeDiscoveryViewModel({ data: undefined, isError: true, refetch });
+ expect(vm.kind).toBe('error');
+ if (vm.kind !== 'error') {
+ throw new Error('expected error view model');
+ }
+ expect(vm.title).toBe(RUNTIME_DISCOVERY_COPY.error.title);
+ expect(vm.message).toBe(RUNTIME_DISCOVERY_COPY.error.message);
+ vm.retry();
+ expect(refetch).toHaveBeenCalledTimes(1);
+ });
+
+ it('returns empty with exact title/message and the refetch callback when the list has no runtimes', () => {
+ const refetch = vi.fn<() => void>();
+ const vm = buildRuntimeDiscoveryViewModel({
+ data: { runtimes: [] },
+ isError: false,
+ refetch,
+ });
+ expect(vm.kind).toBe('empty');
+ if (vm.kind !== 'empty') {
+ throw new Error('expected empty view model');
+ }
+ expect(vm.title).toBe(RUNTIME_DISCOVERY_COPY.empty.title);
+ expect(vm.message).toBe(RUNTIME_DISCOVERY_COPY.empty.message);
+ vm.retry();
+ expect(refetch).toHaveBeenCalledTimes(1);
+ });
+
+ it('returns ready with projected rows when a capable runtime is present', () => {
+ const runtime = makeRuntime();
+ const onSelect = vi.fn<(runtime: LocalRuntime) => void>();
+ const refetch = vi.fn<() => void>();
+ const vm = buildRuntimeDiscoveryViewModel({
+ data: { runtimes: [runtime] },
+ isError: false,
+ refetch,
+ onSelect,
+ });
+ expect(vm.kind).toBe('ready');
+ if (vm.kind !== 'ready') {
+ throw new Error('expected ready view model');
+ }
+ expect(vm.rows).toHaveLength(1);
+ expect(vm.rows[0]).toMatchObject({
+ kind: 'capable',
+ runtime,
+ displayName: 'My MacBook',
+ projectName: 'kilo',
+ cliVersion: '1.2.3',
+ });
+ });
+
+ it('preserves runtimes input order across mixed capable/incapable rows', () => {
+ const capable = makeRuntime({
+ runtimeId: '11111111-1111-4111-8111-111111111111',
+ displayName: 'Capable',
+ });
+ const incapable = makeRuntime({
+ runtimeId: '22222222-2222-4222-8222-222222222222',
+ displayName: 'Incapable',
+ capabilities: ['catalog.v1'],
+ });
+ const vm = buildRuntimeDiscoveryViewModel({
+ data: { runtimes: [capable, incapable] },
+ isError: false,
+ refetch: vi.fn<() => void>(),
+ });
+ if (vm.kind !== 'ready') {
+ throw new Error('expected ready view model');
+ }
+ expect(vm.rows.map(r => [r.kind, r.displayName])).toEqual([
+ ['capable', 'Capable'],
+ ['incapable', 'Incapable'],
+ ]);
+ });
+
+ it('keeps cached rows and never collapses into the error state when a background refetch fails', () => {
+ const runtime = makeRuntime();
+ const refetch = vi.fn<() => void>();
+ const vm = buildRuntimeDiscoveryViewModel({
+ data: { runtimes: [runtime] },
+ isError: true,
+ refetch,
+ });
+ expect(vm.kind).toBe('ready');
+ if (vm.kind !== 'ready') {
+ throw new Error('expected ready view model to win over error');
+ }
+ expect(vm.rows[0]?.runtime).toBe(runtime);
+ // The retry surface is only present on the error/empty branches, so a
+ // ready view-model must not expose one.
+ expect('retry' in vm).toBe(false);
+ });
+
+ it('routes onSelect through to the consumer for capable rows when provided', () => {
+ const onSelect = vi.fn<(runtime: LocalRuntime) => void>();
+ const vm = buildRuntimeDiscoveryViewModel({
+ data: { runtimes: [makeRuntime()] },
+ isError: false,
+ refetch: vi.fn<() => void>(),
+ onSelect,
+ });
+ if (vm.kind !== 'ready') {
+ throw new Error('expected ready view model');
+ }
+ const row = vm.rows[0];
+ if (!row) {
+ throw new Error('expected at least one row');
+ }
+ vm.onSelect(row.runtime);
+ expect(onSelect).toHaveBeenCalledTimes(1);
+ expect(onSelect).toHaveBeenCalledWith(row.runtime);
+ });
+
+ it('no-ops onSelect when the consumer did not pass a callback', () => {
+ const vm = buildRuntimeDiscoveryViewModel({
+ data: { runtimes: [makeRuntime()] },
+ isError: false,
+ refetch: vi.fn<() => void>(),
+ });
+ if (vm.kind !== 'ready') {
+ throw new Error('expected ready view model');
+ }
+ const row = vm.rows[0];
+ if (!row) {
+ throw new Error('expected at least one row');
+ }
+ expect(() => {
+ vm.onSelect(row.runtime);
+ }).not.toThrow();
+ });
+});
diff --git a/apps/mobile/src/lib/hooks/runtime-discovery-logic.ts b/apps/mobile/src/lib/hooks/runtime-discovery-logic.ts
new file mode 100644
index 0000000000..ffb4f4bdab
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/runtime-discovery-logic.ts
@@ -0,0 +1,157 @@
+import { type inferRouterOutputs, type RootRouter } from '@kilocode/trpc';
+
+/**
+ * Bounded local runtime presence as returned by the `localRuntimeControl.list`
+ * tRPC query. The shape is derived from the shared tRPC router output so the
+ * mobile client never duplicates a server-side contract and inherits any
+ * future Zod-validated changes.
+ */
+export type LocalRuntime =
+ inferRouterOutputs['localRuntimeControl']['list']['runtimes'][number];
+
+const REQUIRED_RUNTIME_CAPABILITIES = ['catalog.v1', 'create-and-run.v1'] as const;
+type LocalRuntimeCapability = LocalRuntime['capabilities'][number];
+
+/**
+ * A runtime is "capable" when it advertises every required capability.
+ * Capability order is irrelevant — the contract is a set, not a sequence.
+ */
+export function hasRequiredRuntimeCapabilities(
+ capabilities: readonly LocalRuntimeCapability[]
+): boolean {
+ if (capabilities.length < REQUIRED_RUNTIME_CAPABILITIES.length) {
+ return false;
+ }
+ const have = new Set(capabilities);
+ return REQUIRED_RUNTIME_CAPABILITIES.every(c => have.has(c));
+}
+
+export type RuntimeDiscoveryRow =
+ | {
+ kind: 'capable';
+ runtime: LocalRuntime;
+ displayName: string;
+ projectName: string;
+ cliVersion: string;
+ }
+ | {
+ kind: 'incapable';
+ runtime: LocalRuntime;
+ displayName: string;
+ projectName: string;
+ cliVersion: string;
+ };
+
+/**
+ * Project a list of runtimes into the discriminated view-model rows the
+ * runtime-discovery screen renders. Every input runtime is projected to a row
+ * — an empty list stays empty; we never let an error path collapse a
+ * successful response into "no runtimes".
+ */
+export function buildRuntimeDiscoveryRows(
+ runtimes: readonly LocalRuntime[]
+): RuntimeDiscoveryRow[] {
+ return runtimes.map(runtime => {
+ const row = {
+ runtime,
+ displayName: runtime.displayName,
+ projectName: runtime.projectName,
+ cliVersion: runtime.cliVersion,
+ } as const;
+ return hasRequiredRuntimeCapabilities(runtime.capabilities)
+ ? { kind: 'capable', ...row }
+ : { kind: 'incapable', ...row };
+ });
+}
+
+/**
+ * The four exclusive states a runtime-discovery screen can be in. The
+ * view-model collapses React Query's `(data, isLoading, isError)` triple into
+ * exactly one of these so the renderer never has to combine them by hand.
+ *
+ * - `loading`: no cached data yet. No CTA, no error/empty copy.
+ * - `error`: an initial load failed. Shows the retryable error CTA; never
+ * collapses into the empty state.
+ * - `empty`: a successful list came back with zero runtimes. Shows the
+ * recovery CTA so the user can re-fetch.
+ * - `ready`: at least one runtime. The renderer owns the capable/incapable
+ * split via the per-row `kind` discriminator.
+ */
+export type RuntimeDiscoveryViewModel =
+ | { kind: 'loading' }
+ | {
+ kind: 'error';
+ title: string;
+ message: string;
+ retry: () => void;
+ }
+ | {
+ kind: 'empty';
+ title: string;
+ message: string;
+ retry: () => void;
+ }
+ | {
+ kind: 'ready';
+ rows: RuntimeDiscoveryRow[];
+ onSelect: (runtime: LocalRuntime) => void;
+ };
+
+const LOADING_VIEW_MODEL: RuntimeDiscoveryViewModel = { kind: 'loading' };
+
+const ERROR_TITLE = "Couldn't load local runtimes";
+const ERROR_MESSAGE = 'Check your connection and try again.';
+const EMPTY_TITLE = 'No local runtimes';
+const EMPTY_MESSAGE = 'Run kilo remote in a project, then retry.';
+const INCAPABLE_DESCRIPTION = 'Update Kilo CLI and reconnect.';
+
+type BuildRuntimeDiscoveryViewModelInput = {
+ data: { runtimes: LocalRuntime[] } | undefined;
+ isError: boolean;
+ refetch: () => void;
+ onSelect?: (runtime: LocalRuntime) => void;
+};
+
+export function buildRuntimeDiscoveryViewModel(
+ input: BuildRuntimeDiscoveryViewModelInput
+): RuntimeDiscoveryViewModel {
+ const { data, isError, refetch, onSelect } = input;
+ // Cached data always wins over a background error — we never let a stale
+ // refetch failure turn a successful list into the empty or error state.
+ if (data !== undefined) {
+ const rows = buildRuntimeDiscoveryRows(data.runtimes);
+ if (rows.length === 0) {
+ return {
+ kind: 'empty',
+ title: EMPTY_TITLE,
+ message: EMPTY_MESSAGE,
+ retry: refetch,
+ };
+ }
+ return {
+ kind: 'ready',
+ rows,
+ onSelect: runtime => {
+ if (!onSelect) {
+ return;
+ }
+ onSelect(runtime);
+ },
+ };
+ }
+ if (isError) {
+ return {
+ kind: 'error',
+ title: ERROR_TITLE,
+ message: ERROR_MESSAGE,
+ retry: refetch,
+ };
+ }
+ return LOADING_VIEW_MODEL;
+}
+
+export const RUNTIME_DISCOVERY_COPY = {
+ empty: { title: EMPTY_TITLE, message: EMPTY_MESSAGE },
+ error: { title: ERROR_TITLE, message: ERROR_MESSAGE },
+ incapable: INCAPABLE_DESCRIPTION,
+} as const;
diff --git a/apps/mobile/src/lib/hooks/use-local-runtime-catalog.lifecycle.test.ts b/apps/mobile/src/lib/hooks/use-local-runtime-catalog.lifecycle.test.ts
new file mode 100644
index 0000000000..40f8451e11
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/use-local-runtime-catalog.lifecycle.test.ts
@@ -0,0 +1,232 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type * as React from 'react';
+
+import {
+ type AppStateStatus,
+ cleanupTestState,
+ FENCE_A,
+ importHook,
+ makeConnectionWithoutRetain,
+ mocks,
+ type QueryShape,
+ resetTestState,
+ testState,
+} from './use-local-runtime-catalog.test-harness';
+
+vi.mock('react', async () => {
+ const actual = await vi.importActual('react');
+ return {
+ ...actual,
+ useEffect: (effect: () => (() => void) | undefined) => {
+ const cleanup = effect();
+ if (typeof cleanup === 'function') {
+ testState.cleanups.push(cleanup);
+ }
+ },
+ useRef: (initial: T) => ({ current: initial }),
+ };
+});
+
+vi.mock('@tanstack/react-query', () => ({
+ useQuery: (options: { queryKey: unknown; enabled?: boolean }) => {
+ testState.lastQueryOptions = options;
+ mocks.useQuery(options);
+ const result: QueryShape = {
+ data: testState.queryData,
+ error: testState.queryError,
+ isLoading: testState.queryIsLoading,
+ refetch: () => {
+ testState.refetchCount += 1;
+ },
+ };
+ return result;
+ },
+ useQueryClient: () => ({
+ invalidateQueries: ({ queryKey }: { queryKey: unknown }) => {
+ testState.invalidatedKeys.push(queryKey);
+ },
+ }),
+}));
+
+vi.mock('@/lib/trpc', () => ({
+ useTRPC: () => ({
+ localRuntimeControl: {
+ getCatalog: {
+ queryKey: (input: { runtimeId: string; connectionId: string }) => [
+ 'localRuntimeControl',
+ 'getCatalog',
+ { runtimeId: input.runtimeId, connectionId: input.connectionId },
+ ],
+ queryOptions: (
+ input: { runtimeId: string; connectionId: string },
+ opts?: { staleTime?: number; enabled?: boolean }
+ ) => ({
+ queryKey: [
+ 'localRuntimeControl',
+ 'getCatalog',
+ { runtimeId: input.runtimeId, connectionId: input.connectionId },
+ ],
+ staleTime: opts?.staleTime,
+ enabled: opts?.enabled,
+ }),
+ },
+ },
+ }),
+}));
+
+vi.mock('@/components/agents/user-web-connection-provider', () => ({
+ useUserWebConnection: () => mocks.useUserWebConnection(),
+}));
+
+vi.mock('@/components/kilo-chat/hooks/use-app-presence', () => ({
+ useAppPresence: () => {
+ mocks.useAppPresence();
+ },
+}));
+
+vi.mock('react-native', () => ({
+ AppState: {
+ addEventListener: (event: string, listener: (state: AppStateStatus) => void) => {
+ expect(event).toBe('change');
+ testState.appStateListeners.push(listener);
+ return {
+ remove: () => {
+ // subscription removed
+ },
+ };
+ },
+ },
+}));
+
+beforeEach(resetTestState);
+afterEach(cleanupTestState);
+
+describe('useLocalRuntimeCatalog lifecycle', () => {
+ it('registers app presence', async () => {
+ const { useLocalRuntimeCatalog } = await importHook();
+ useLocalRuntimeCatalog(FENCE_A);
+ expect(mocks.useAppPresence).toHaveBeenCalledTimes(1);
+ });
+
+ it('retains the shared user-web connection and releases it on unmount', async () => {
+ const { useLocalRuntimeCatalog } = await importHook();
+ useLocalRuntimeCatalog(FENCE_A);
+
+ const connection = testState.connection;
+ if (!connection || typeof connection.retain !== 'function') {
+ throw new Error('Expected connection with retain');
+ }
+ expect(connection.retain).toHaveBeenCalledTimes(1);
+
+ while (testState.cleanups.length > 0) {
+ const cleanup = testState.cleanups.pop();
+ cleanup?.();
+ }
+ expect(connection.release).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not retain a second time when system events fire', async () => {
+ const { useLocalRuntimeCatalog } = await importHook();
+ useLocalRuntimeCatalog(FENCE_A);
+ const connection = testState.connection;
+ if (!connection || typeof connection.retain !== 'function') {
+ throw new Error('Expected connection with retain');
+ }
+ expect(connection.retain).toHaveBeenCalledTimes(1);
+
+ testState.systemListeners[0]?.({
+ event: 'runtime.updated',
+ data: { runtimeId: FENCE_A.runtimeId },
+ });
+ testState.systemListeners[0]?.({
+ event: 'runtime.disconnected',
+ data: { runtimeId: FENCE_A.runtimeId },
+ });
+ expect(connection.retain).toHaveBeenCalledTimes(1);
+ });
+
+ it('invalidates the getCatalog query on runtime.updated and runtime.disconnected', async () => {
+ const { useLocalRuntimeCatalog } = await importHook();
+ useLocalRuntimeCatalog(FENCE_A);
+
+ testState.systemListeners[0]?.({
+ event: 'runtime.updated',
+ data: { runtimeId: FENCE_A.runtimeId },
+ });
+ testState.systemListeners[0]?.({
+ event: 'runtime.disconnected',
+ data: { runtimeId: FENCE_A.runtimeId },
+ });
+
+ expect(testState.invalidatedKeys).toHaveLength(2);
+ for (const key of testState.invalidatedKeys) {
+ expect(key).toEqual(['localRuntimeControl', 'getCatalog']);
+ }
+ });
+
+ it('ignores unrelated system events without invalidating', async () => {
+ const { useLocalRuntimeCatalog } = await importHook();
+ useLocalRuntimeCatalog(FENCE_A);
+
+ testState.systemListeners[0]?.({ event: 'runtimes.list', data: {} });
+ testState.systemListeners[0]?.({ event: 'runtime.connected', data: {} });
+ testState.systemListeners[0]?.({ event: 'chat.message', data: {} });
+ testState.systemListeners[0]?.({ event: 'session.status', data: {} });
+
+ expect(testState.invalidatedKeys).toEqual([]);
+ });
+
+ it('refetches on user-web reconnect', async () => {
+ const { useLocalRuntimeCatalog } = await importHook();
+ useLocalRuntimeCatalog(FENCE_A);
+ expect(testState.refetchCount).toBe(0);
+
+ testState.reconnectListeners[0]?.();
+ expect(testState.refetchCount).toBe(1);
+ });
+
+ it('refetches on app resume (active state)', async () => {
+ const { useLocalRuntimeCatalog } = await importHook();
+ useLocalRuntimeCatalog(FENCE_A);
+ expect(testState.refetchCount).toBe(0);
+
+ testState.appStateListeners[0]?.('background');
+ expect(testState.refetchCount).toBe(0);
+
+ testState.appStateListeners[0]?.('active');
+ expect(testState.refetchCount).toBe(1);
+
+ testState.appStateListeners[0]?.('active');
+ expect(testState.refetchCount).toBe(2);
+ });
+
+ it('does not register system or reconnect listeners when the connection is missing', async () => {
+ testState.connection = null;
+ const { useLocalRuntimeCatalog } = await importHook();
+ const result = useLocalRuntimeCatalog(FENCE_A);
+
+ expect(result).toBeDefined();
+ expect(testState.systemListeners).toEqual([]);
+ expect(testState.reconnectListeners).toEqual([]);
+ });
+
+ it('does not subscribe when the connection lacks retain and still returns the query', async () => {
+ testState.connection = makeConnectionWithoutRetain();
+ const { useLocalRuntimeCatalog } = await importHook();
+ const result = useLocalRuntimeCatalog(FENCE_A);
+
+ expect(result).toBeDefined();
+ expect(testState.systemListeners).toEqual([]);
+ expect(testState.reconnectListeners).toEqual([]);
+ expect(testState.connection.retain).toBeUndefined();
+ });
+
+ it('still listens to app resume when the connection is missing', async () => {
+ testState.connection = null;
+ const { useLocalRuntimeCatalog } = await importHook();
+ useLocalRuntimeCatalog(FENCE_A);
+
+ testState.appStateListeners[0]?.('active');
+ expect(testState.refetchCount).toBe(1);
+ });
+});
diff --git a/apps/mobile/src/lib/hooks/use-local-runtime-catalog.query.test.ts b/apps/mobile/src/lib/hooks/use-local-runtime-catalog.query.test.ts
new file mode 100644
index 0000000000..3fbc7bb0b4
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/use-local-runtime-catalog.query.test.ts
@@ -0,0 +1,168 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type * as React from 'react';
+
+import {
+ type AppStateStatus,
+ cleanupTestState,
+ FENCE_A,
+ FENCE_A_RECONNECTED,
+ importHook,
+ mocks,
+ type QueryShape,
+ resetTestState,
+ testState,
+} from './use-local-runtime-catalog.test-harness';
+
+vi.mock('react', async () => {
+ const actual = await vi.importActual('react');
+ return {
+ ...actual,
+ useEffect: (effect: () => (() => void) | undefined) => {
+ const cleanup = effect();
+ if (typeof cleanup === 'function') {
+ testState.cleanups.push(cleanup);
+ }
+ },
+ useRef: (initial: T) => ({ current: initial }),
+ };
+});
+
+vi.mock('@tanstack/react-query', () => ({
+ useQuery: (options: { queryKey: unknown; enabled?: boolean }) => {
+ testState.lastQueryOptions = options;
+ mocks.useQuery(options);
+ const result: QueryShape = {
+ data: testState.queryData,
+ error: testState.queryError,
+ isLoading: testState.queryIsLoading,
+ refetch: () => {
+ testState.refetchCount += 1;
+ },
+ };
+ return result;
+ },
+ useQueryClient: () => ({
+ invalidateQueries: ({ queryKey }: { queryKey: unknown }) => {
+ testState.invalidatedKeys.push(queryKey);
+ },
+ }),
+}));
+
+vi.mock('@/lib/trpc', () => ({
+ useTRPC: () => ({
+ localRuntimeControl: {
+ getCatalog: {
+ queryKey: (input: { runtimeId: string; connectionId: string }) => [
+ 'localRuntimeControl',
+ 'getCatalog',
+ { runtimeId: input.runtimeId, connectionId: input.connectionId },
+ ],
+ queryOptions: (
+ input: { runtimeId: string; connectionId: string },
+ opts?: { staleTime?: number; enabled?: boolean }
+ ) => ({
+ queryKey: [
+ 'localRuntimeControl',
+ 'getCatalog',
+ { runtimeId: input.runtimeId, connectionId: input.connectionId },
+ ],
+ staleTime: opts?.staleTime,
+ enabled: opts?.enabled,
+ }),
+ },
+ },
+ }),
+}));
+
+vi.mock('@/components/agents/user-web-connection-provider', () => ({
+ useUserWebConnection: () => mocks.useUserWebConnection(),
+}));
+
+vi.mock('@/components/kilo-chat/hooks/use-app-presence', () => ({
+ useAppPresence: () => {
+ mocks.useAppPresence();
+ },
+}));
+
+vi.mock('react-native', () => ({
+ AppState: {
+ addEventListener: (event: string, listener: (state: AppStateStatus) => void) => {
+ expect(event).toBe('change');
+ testState.appStateListeners.push(listener);
+ return {
+ remove: () => {
+ // subscription removed
+ },
+ };
+ },
+ },
+}));
+
+beforeEach(resetTestState);
+afterEach(cleanupTestState);
+
+describe('useLocalRuntimeCatalog query behavior', () => {
+ it('returns the underlying query state unchanged', async () => {
+ testState.queryData = { protocolVersion: 1 };
+ testState.queryError = { data: { upstreamCode: 'RUNTIME_NOT_CONNECTED' } };
+
+ const { useLocalRuntimeCatalog } = await importHook();
+ const result = useLocalRuntimeCatalog(FENCE_A);
+
+ expect(result.data).toBe(testState.queryData);
+ expect(result.error).toBe(testState.queryError);
+ expect(result.refetch).toBeTypeOf('function');
+ });
+
+ it('uses the exact (runtimeId, connectionId) fence as the query key', async () => {
+ const { useLocalRuntimeCatalog } = await importHook();
+ useLocalRuntimeCatalog(FENCE_A);
+
+ expect(testState.lastQueryOptions?.queryKey).toEqual([
+ 'localRuntimeControl',
+ 'getCatalog',
+ { runtimeId: FENCE_A.runtimeId, connectionId: FENCE_A.connectionId },
+ ]);
+ expect(testState.lastQueryOptions?.enabled).toBe(true);
+ });
+
+ it('produces a fresh query key when the connectionId changes (runtime reconnect)', async () => {
+ const { useLocalRuntimeCatalog } = await importHook();
+ useLocalRuntimeCatalog(FENCE_A);
+ const firstKey = testState.lastQueryOptions?.queryKey;
+
+ while (testState.cleanups.length > 0) {
+ const cleanup = testState.cleanups.pop();
+ cleanup?.();
+ }
+
+ useLocalRuntimeCatalog(FENCE_A_RECONNECTED);
+ const secondKey = testState.lastQueryOptions?.queryKey;
+
+ expect(firstKey).toBeDefined();
+ expect(secondKey).toBeDefined();
+ expect(secondKey).not.toEqual(firstKey);
+ expect(secondKey).toEqual([
+ 'localRuntimeControl',
+ 'getCatalog',
+ { runtimeId: FENCE_A_RECONNECTED.runtimeId, connectionId: FENCE_A_RECONNECTED.connectionId },
+ ]);
+ });
+
+ it('disables the query when the fence is null', async () => {
+ const { useLocalRuntimeCatalog } = await importHook();
+ useLocalRuntimeCatalog(null);
+
+ expect(testState.lastQueryOptions?.enabled).toBe(false);
+ });
+
+ it('preserves a thrown catalog error on the returned query state', async () => {
+ const catalogError = { data: { upstreamCode: 'RUNTIME_FENCE_MISMATCH' } };
+ testState.queryError = catalogError;
+
+ const { useLocalRuntimeCatalog } = await importHook();
+ const result = useLocalRuntimeCatalog(FENCE_A);
+
+ expect(result.error).toBe(catalogError);
+ });
+});
diff --git a/apps/mobile/src/lib/hooks/use-local-runtime-catalog.test-harness.ts b/apps/mobile/src/lib/hooks/use-local-runtime-catalog.test-harness.ts
new file mode 100644
index 0000000000..bde2a4e1c7
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/use-local-runtime-catalog.test-harness.ts
@@ -0,0 +1,139 @@
+import { vi } from 'vitest';
+
+import { type LocalRuntimeFence } from './local-runtime-catalog-types';
+
+export type AppStateStatus = 'active' | 'background' | 'inactive';
+
+type SystemListener = (event: { event: string; data: unknown }) => void;
+type ReconnectListener = () => void;
+
+type ConnectionHandle = {
+ retain?: ReturnType () => void>>;
+ release: ReturnType void>>;
+ onSystemEvent: ReturnType () => void>>;
+ offSystemEvent: () => void;
+ onReconnect: ReturnType () => void>>;
+ offReconnect: () => void;
+};
+
+type ListenerHandles = Pick<
+ ConnectionHandle,
+ 'onSystemEvent' | 'offSystemEvent' | 'onReconnect' | 'offReconnect'
+>;
+
+export type QueryShape = {
+ data: unknown;
+ error: unknown;
+ isLoading: boolean;
+ refetch: () => void;
+};
+
+type TestState = {
+ appStateListeners: ((state: AppStateStatus) => void)[];
+ cleanups: (() => void)[];
+ connection: ConnectionHandle | null;
+ fence: LocalRuntimeFence | null;
+ invalidatedKeys: unknown[];
+ queryData: unknown;
+ queryError: unknown;
+ queryIsLoading: boolean;
+ refetchCount: number;
+ systemListeners: SystemListener[];
+ reconnectListeners: ReconnectListener[];
+ lastQueryOptions: { queryKey: unknown; enabled?: boolean } | null;
+};
+
+export const testState: TestState = {
+ appStateListeners: [],
+ cleanups: [],
+ connection: null,
+ fence: null,
+ invalidatedKeys: [],
+ queryData: undefined,
+ queryError: null,
+ queryIsLoading: false,
+ refetchCount: 0,
+ systemListeners: [],
+ reconnectListeners: [],
+ lastQueryOptions: null,
+};
+
+export const mocks = {
+ useAppPresence: vi.fn<() => void>(),
+ useUserWebConnection: vi.fn<() => ConnectionHandle | null>(),
+ useQuery: vi.fn(),
+ useQueryClient: vi.fn(),
+};
+
+function makeListenerHandles(): ListenerHandles {
+ const offSystem = vi.fn<() => void>();
+ const offReconnect = vi.fn<() => void>();
+ const onSystemEvent = vi.fn((listener: SystemListener) => {
+ testState.systemListeners.push(listener);
+ return () => {
+ offSystem();
+ testState.systemListeners = testState.systemListeners.filter(l => l !== listener);
+ };
+ });
+ const onReconnect = vi.fn((listener: ReconnectListener) => {
+ testState.reconnectListeners.push(listener);
+ return () => {
+ offReconnect();
+ testState.reconnectListeners = testState.reconnectListeners.filter(l => l !== listener);
+ };
+ });
+ return { onSystemEvent, offSystemEvent: offSystem, onReconnect, offReconnect };
+}
+
+function makeConnection(): ConnectionHandle {
+ const release = vi.fn<() => void>();
+ const retain = vi.fn<() => () => void>(() => release);
+ return { ...makeListenerHandles(), retain, release };
+}
+
+export function makeConnectionWithoutRetain(): ConnectionHandle {
+ return { ...makeListenerHandles(), release: vi.fn<() => void>() };
+}
+
+export const FENCE_A: LocalRuntimeFence = {
+ runtimeId: '11111111-1111-4111-8111-111111111111',
+ connectionId: 'cli-a',
+};
+
+export const FENCE_A_RECONNECTED: LocalRuntimeFence = {
+ runtimeId: '11111111-1111-4111-8111-111111111111',
+ connectionId: 'cli-a-new',
+};
+
+export function resetTestState() {
+ testState.appStateListeners = [];
+ testState.cleanups = [];
+ testState.connection = makeConnection();
+ testState.fence = null;
+ testState.invalidatedKeys = [];
+ testState.queryData = undefined;
+ testState.queryError = null;
+ testState.queryIsLoading = false;
+ testState.refetchCount = 0;
+ testState.systemListeners = [];
+ testState.reconnectListeners = [];
+ testState.lastQueryOptions = null;
+ vi.clearAllMocks();
+ mocks.useAppPresence.mockReset();
+ mocks.useUserWebConnection.mockReset();
+ mocks.useQuery.mockReset();
+ mocks.useQueryClient.mockReset();
+ mocks.useUserWebConnection.mockImplementation(() => testState.connection);
+}
+
+export function cleanupTestState() {
+ for (const cleanup of testState.cleanups) {
+ cleanup();
+ }
+ vi.clearAllMocks();
+}
+
+export async function importHook() {
+ const module = await import('./use-local-runtime-catalog');
+ return module;
+}
diff --git a/apps/mobile/src/lib/hooks/use-local-runtime-catalog.ts b/apps/mobile/src/lib/hooks/use-local-runtime-catalog.ts
new file mode 100644
index 0000000000..ca8e9eabfe
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/use-local-runtime-catalog.ts
@@ -0,0 +1,83 @@
+import { useQuery, useQueryClient } from '@tanstack/react-query';
+import { useEffect } from 'react';
+import { AppState, type AppStateStatus } from 'react-native';
+
+import { useUserWebConnection } from '@/components/agents/user-web-connection-provider';
+import { useAppPresence } from '@/components/kilo-chat/hooks/use-app-presence';
+import { type LocalRuntimeFence } from '@/lib/hooks/local-runtime-catalog-types';
+import { useTRPC } from '@/lib/trpc';
+
+const RUNTIME_CATALOG_SYSTEM_EVENTS = new Set(['runtime.updated', 'runtime.disconnected']);
+
+const PLACEHOLDER_FENCE: LocalRuntimeFence = { runtimeId: '', connectionId: '' };
+
+/**
+ * Fetch the model catalog for a specific local runtime fence. The hook:
+ *
+ * - Disables itself when `fence` is `null` (no capable runtime selected).
+ * - Anchors the query on the exact `(runtimeId, connectionId)` pair so a
+ * runtime reconnect produces a fresh query key and React Query discards
+ * the previous (now detached) response.
+ * - Listens for the same set of runtime system events the runtime-list hook
+ * listens for, plus an app-resume refresh — the catalog is read-only
+ * metadata, so a periodic "try again on resume" is cheap.
+ * - Returns the raw `useQuery` shape so the caller can project the
+ * (loading, error, ready) slice onto its own view-model.
+ */
+export function useLocalRuntimeCatalog(fence: LocalRuntimeFence | null) {
+ const trpcClient = useTRPC();
+ const queryClient = useQueryClient();
+ const connection = useUserWebConnection();
+ useAppPresence();
+
+ const queryOptions = trpcClient.localRuntimeControl.getCatalog.queryOptions(
+ fence ?? PLACEHOLDER_FENCE,
+ { enabled: Boolean(fence), staleTime: 60_000 }
+ );
+
+ const query = useQuery(queryOptions);
+
+ useEffect(() => {
+ const subscription = AppState.addEventListener('change', (state: AppStateStatus) => {
+ if (state === 'active') {
+ void query.refetch();
+ }
+ });
+ return () => {
+ subscription.remove();
+ };
+ }, [query]);
+
+ useEffect(() => {
+ // The public type says retain is optional; the runtime value may also be
+ // null in tests or if the provider ever relaxes its non-null contract.
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ if (!connection || typeof connection.retain !== 'function') {
+ return undefined;
+ }
+ const release = connection.retain();
+ const offSystem = connection.onSystemEvent(event => {
+ if (RUNTIME_CATALOG_SYSTEM_EVENTS.has(event.event)) {
+ void queryClient.invalidateQueries({
+ queryKey: ['localRuntimeControl', 'getCatalog'],
+ });
+ }
+ });
+ const offReconnect = connection.onReconnect(() => {
+ void query.refetch();
+ });
+
+ return () => {
+ offSystem();
+ offReconnect();
+ release();
+ };
+ // The catalog query is anchored to the fence; we do not want to rebind
+ // the lifecycle effect on every fence change. The connection object
+ // outlives any individual fence, so the effect should only run on
+ // connection identity changes.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [connection, queryClient]);
+
+ return query;
+}
diff --git a/apps/mobile/src/lib/hooks/use-local-runtimes.test.ts b/apps/mobile/src/lib/hooks/use-local-runtimes.test.ts
new file mode 100644
index 0000000000..0b74faaddf
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/use-local-runtimes.test.ts
@@ -0,0 +1,332 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type * as React from 'react';
+
+type AppStateStatus = 'active' | 'background' | 'inactive';
+
+type AppStateListener = (
+ event: string,
+ listener: (state: AppStateStatus) => void
+) => { remove: () => void };
+
+type SystemListener = (event: { event: string; data: unknown }) => void;
+type ReconnectListener = () => void;
+
+type ConnectionHandle = {
+ retain?: ReturnType () => void>>;
+ release: ReturnType void>>;
+ onSystemEvent: ReturnType () => void>>;
+ offSystemEvent: () => void;
+ onReconnect: ReturnType () => void>>;
+ offReconnect: () => void;
+};
+
+type ListenerHandles = Pick<
+ ConnectionHandle,
+ 'onSystemEvent' | 'offSystemEvent' | 'onReconnect' | 'offReconnect'
+>;
+
+type TestState = {
+ appStateListeners: ((state: AppStateStatus) => void)[];
+ addAppStateListener: ReturnType>;
+ cleanups: (() => void)[];
+ connection: ConnectionHandle | null;
+ invalidatedKeys: unknown[];
+ queryData: { runtimes: unknown[] } | undefined;
+ queryError: Error | null;
+ queryIsLoading: boolean;
+ refetchCount: number;
+ systemListeners: SystemListener[];
+ reconnectListeners: ReconnectListener[];
+};
+
+const testState = vi.hoisted(() => ({
+ appStateListeners: [],
+ addAppStateListener: vi.fn(),
+ cleanups: [],
+ connection: null,
+ invalidatedKeys: [],
+ queryData: undefined,
+ queryError: null,
+ queryIsLoading: false,
+ refetchCount: 0,
+ systemListeners: [],
+ reconnectListeners: [],
+}));
+
+const mocks = vi.hoisted(() => ({
+ useAppPresence: vi.fn<() => void>(),
+ useUserWebConnection: vi.fn<() => ConnectionHandle | null>(),
+ useQuery: vi.fn(),
+ useQueryClient: vi.fn(),
+}));
+
+vi.mock('react', async () => {
+ const actual = await vi.importActual('react');
+ return {
+ ...actual,
+ useEffect: (effect: () => (() => void) | undefined) => {
+ const cleanup = effect();
+ if (typeof cleanup === 'function') {
+ testState.cleanups.push(cleanup);
+ }
+ },
+ };
+});
+
+vi.mock('@tanstack/react-query', () => ({
+ useQuery: (options: { queryKey: unknown }) => {
+ mocks.useQuery(options);
+ return {
+ data: testState.queryData,
+ error: testState.queryError,
+ isLoading: testState.queryIsLoading,
+ refetch: () => {
+ testState.refetchCount += 1;
+ },
+ };
+ },
+ useQueryClient: () => ({
+ invalidateQueries: ({ queryKey }: { queryKey: unknown }) => {
+ testState.invalidatedKeys.push(queryKey);
+ },
+ }),
+}));
+
+vi.mock('@/lib/trpc', () => ({
+ useTRPC: () => ({
+ localRuntimeControl: {
+ list: {
+ queryKey: () => ['localRuntimeControl', 'list'],
+ queryOptions: () => ({ queryKey: ['localRuntimeControl', 'list'] }),
+ },
+ },
+ }),
+}));
+
+vi.mock('@/components/agents/user-web-connection-provider', () => ({
+ useUserWebConnection: mocks.useUserWebConnection,
+}));
+
+vi.mock('@/components/kilo-chat/hooks/use-app-presence', () => ({
+ useAppPresence: mocks.useAppPresence,
+}));
+
+vi.mock('react-native', () => ({
+ AppState: {
+ addEventListener: testState.addAppStateListener,
+ },
+}));
+
+function makeListenerHandles(): ListenerHandles {
+ const offSystem = vi.fn<() => void>();
+ const offReconnect = vi.fn<() => void>();
+ const onSystemEvent = vi.fn((listener: SystemListener) => {
+ testState.systemListeners.push(listener);
+ return () => {
+ offSystem();
+ testState.systemListeners = testState.systemListeners.filter(l => l !== listener);
+ };
+ });
+ const onReconnect = vi.fn((listener: ReconnectListener) => {
+ testState.reconnectListeners.push(listener);
+ return () => {
+ offReconnect();
+ testState.reconnectListeners = testState.reconnectListeners.filter(l => l !== listener);
+ };
+ });
+ return { onSystemEvent, offSystemEvent: offSystem, onReconnect, offReconnect };
+}
+
+function makeConnection(): ConnectionHandle {
+ const release = vi.fn<() => void>();
+ const retain = vi.fn<() => () => void>(() => release);
+ return { ...makeListenerHandles(), retain, release };
+}
+
+function makeConnectionWithoutRetain(): ConnectionHandle {
+ return { ...makeListenerHandles(), release: vi.fn<() => void>() };
+}
+
+beforeEach(() => {
+ testState.appStateListeners = [];
+ testState.cleanups = [];
+ testState.connection = makeConnection();
+ testState.invalidatedKeys = [];
+ testState.queryData = undefined;
+ testState.queryError = null;
+ testState.queryIsLoading = false;
+ testState.refetchCount = 0;
+ testState.systemListeners = [];
+ testState.reconnectListeners = [];
+ vi.clearAllMocks();
+ mocks.useAppPresence.mockReset();
+ mocks.useUserWebConnection.mockReset();
+ mocks.useQuery.mockReset();
+ testState.addAppStateListener.mockReset();
+ testState.addAppStateListener.mockImplementation(
+ (event: string, listener: (state: AppStateStatus) => void): { remove: () => void } => {
+ expect(event).toBe('change');
+ testState.appStateListeners.push(listener);
+ return {
+ remove: () => {
+ // subscription removed
+ },
+ };
+ }
+ );
+ mocks.useUserWebConnection.mockImplementation(() => testState.connection);
+});
+
+afterEach(() => {
+ for (const cleanup of testState.cleanups) {
+ cleanup();
+ }
+ vi.clearAllMocks();
+});
+
+async function importHook() {
+ const module = await import('./use-local-runtimes');
+ return module;
+}
+
+describe('useLocalRuntimes', () => {
+ it('returns the underlying query state unchanged', async () => {
+ testState.queryData = { runtimes: [] };
+ testState.queryError = new Error('boom');
+
+ const { useLocalRuntimes } = await importHook();
+ const result = useLocalRuntimes();
+
+ expect(result.data).toBe(testState.queryData);
+ expect(result.error).toBe(testState.queryError);
+ expect(result.refetch).toBeTypeOf('function');
+ });
+
+ it('subscribes to the localRuntimeControl.list query', async () => {
+ const { useLocalRuntimes } = await importHook();
+ useLocalRuntimes();
+
+ expect(mocks.useQuery).toHaveBeenCalledTimes(1);
+ expect(mocks.useQuery.mock.calls[0]?.[0]).toMatchObject({
+ queryKey: ['localRuntimeControl', 'list'],
+ });
+ });
+
+ it('registers app presence', async () => {
+ const { useLocalRuntimes } = await importHook();
+ useLocalRuntimes();
+ expect(mocks.useAppPresence).toHaveBeenCalledTimes(1);
+ });
+
+ it('retains the shared user-web connection and releases it on unmount', async () => {
+ const { useLocalRuntimes } = await importHook();
+ useLocalRuntimes();
+
+ const connection = testState.connection;
+ if (!connection || typeof connection.retain !== 'function') {
+ throw new Error('Expected connection with retain');
+ }
+ expect(connection.retain).toHaveBeenCalledTimes(1);
+
+ while (testState.cleanups.length > 0) {
+ const cleanup = testState.cleanups.pop();
+ cleanup?.();
+ }
+ expect(connection.release).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not retain a second time when system events fire', async () => {
+ const { useLocalRuntimes } = await importHook();
+ useLocalRuntimes();
+ const connection = testState.connection;
+ if (!connection || typeof connection.retain !== 'function') {
+ throw new Error('Expected connection with retain');
+ }
+ expect(connection.retain).toHaveBeenCalledTimes(1);
+
+ testState.systemListeners[0]?.({ event: 'runtime.connected', data: { runtimeId: 'r-1' } });
+ testState.systemListeners[0]?.({ event: 'runtimes.list', data: { runtimes: [] } });
+ expect(connection.retain).toHaveBeenCalledTimes(1);
+ });
+
+ it('invalidates the list query on every supported runtime system event', async () => {
+ const { useLocalRuntimes } = await importHook();
+ useLocalRuntimes();
+
+ const events = [
+ 'runtimes.list',
+ 'runtime.connected',
+ 'runtime.updated',
+ 'runtime.disconnected',
+ ] as const;
+ for (const event of events) {
+ testState.systemListeners[0]?.({ event, data: {} });
+ }
+
+ expect(testState.invalidatedKeys).toEqual(events.map(() => ['localRuntimeControl', 'list']));
+ });
+
+ it('ignores unrelated system events without invalidating', async () => {
+ const { useLocalRuntimes } = await importHook();
+ useLocalRuntimes();
+
+ testState.systemListeners[0]?.({ event: 'chat.message', data: {} });
+ testState.systemListeners[0]?.({ event: 'session.status', data: {} });
+
+ expect(testState.invalidatedKeys).toEqual([]);
+ });
+
+ it('refetches on user-web reconnect', async () => {
+ const { useLocalRuntimes } = await importHook();
+ useLocalRuntimes();
+ expect(testState.refetchCount).toBe(0);
+
+ testState.reconnectListeners[0]?.();
+ expect(testState.refetchCount).toBe(1);
+ });
+
+ it('refetches on app resume (active state)', async () => {
+ const { useLocalRuntimes } = await importHook();
+ useLocalRuntimes();
+ expect(testState.refetchCount).toBe(0);
+
+ testState.appStateListeners[0]?.('background');
+ expect(testState.refetchCount).toBe(0);
+
+ testState.appStateListeners[0]?.('active');
+ expect(testState.refetchCount).toBe(1);
+
+ testState.appStateListeners[0]?.('active');
+ expect(testState.refetchCount).toBe(2);
+ });
+
+ it('does not register system or reconnect listeners when the connection is missing', async () => {
+ testState.connection = null;
+ const { useLocalRuntimes } = await importHook();
+ const result = useLocalRuntimes();
+
+ expect(result).toBeDefined();
+ expect(testState.systemListeners).toEqual([]);
+ expect(testState.reconnectListeners).toEqual([]);
+ });
+
+ it('does not subscribe when the connection lacks retain and still returns the query', async () => {
+ testState.connection = makeConnectionWithoutRetain();
+ const { useLocalRuntimes } = await importHook();
+ const result = useLocalRuntimes();
+
+ expect(result).toBeDefined();
+ expect(testState.systemListeners).toEqual([]);
+ expect(testState.reconnectListeners).toEqual([]);
+ expect(testState.connection.retain).toBeUndefined();
+ });
+
+ it('still listens to app resume when the connection is missing', async () => {
+ testState.connection = null;
+ const { useLocalRuntimes } = await importHook();
+ useLocalRuntimes();
+
+ testState.appStateListeners[0]?.('active');
+ expect(testState.refetchCount).toBe(1);
+ });
+});
diff --git a/apps/mobile/src/lib/hooks/use-local-runtimes.ts b/apps/mobile/src/lib/hooks/use-local-runtimes.ts
new file mode 100644
index 0000000000..2496df5c63
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/use-local-runtimes.ts
@@ -0,0 +1,78 @@
+import { useQuery, useQueryClient } from '@tanstack/react-query';
+import { useEffect } from 'react';
+import { AppState, type AppStateStatus } from 'react-native';
+
+import { useAppPresence } from '@/components/kilo-chat/hooks/use-app-presence';
+import { useUserWebConnection } from '@/components/agents/user-web-connection-provider';
+import { useTRPC } from '@/lib/trpc';
+
+const RUNTIME_SYSTEM_EVENTS = new Set([
+ 'runtimes.list',
+ 'runtime.connected',
+ 'runtime.updated',
+ 'runtime.disconnected',
+]);
+
+/**
+ * Source of truth for the local-runtime list shown on the
+ * `RuntimeDiscoveryContent` screen.
+ *
+ * - Wraps the `localRuntimeControl.list` tRPC query.
+ * - Reuses the shared `UserWebConnection` (no second socket) by retaining it
+ * exactly once for the lifetime of this hook.
+ * - Refreshes whenever the relay advertises a runtime change, on every
+ * user-web reconnect, and when the app returns to the foreground.
+ * - Returns the raw `useQuery` result so callers can read the error
+ * unchanged — we never collapse a fetch failure into the empty state.
+ */
+export function useLocalRuntimes() {
+ const trpcClient = useTRPC();
+ const queryClient = useQueryClient();
+ const connection = useUserWebConnection();
+ const query = useQuery(trpcClient.localRuntimeControl.list.queryOptions());
+ useAppPresence();
+
+ useEffect(() => {
+ // The public type says retain is optional; the runtime value may also be
+ // null in tests or if the provider ever relaxes its non-null contract.
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ if (!connection || typeof connection.retain !== 'function') {
+ return undefined;
+ }
+ const release = connection.retain();
+ const offSystem = connection.onSystemEvent(event => {
+ if (RUNTIME_SYSTEM_EVENTS.has(event.event)) {
+ void queryClient.invalidateQueries({
+ queryKey: trpcClient.localRuntimeControl.list.queryKey(),
+ });
+ }
+ });
+ const offReconnect = connection.onReconnect(() => {
+ void query.refetch();
+ });
+
+ return () => {
+ offSystem();
+ offReconnect();
+ release();
+ };
+ // query.refetch from react-query is referentially stable per query key
+ // and the connection lifecycle spans the whole hook lifetime; the
+ // explicit list of deps keeps the effect from re-binding on every
+ // re-render.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [connection, queryClient, trpcClient]);
+
+ useEffect(() => {
+ const subscription = AppState.addEventListener('change', (state: AppStateStatus) => {
+ if (state === 'active') {
+ void query.refetch();
+ }
+ });
+ return () => {
+ subscription.remove();
+ };
+ }, [query]);
+
+ return query;
+}
diff --git a/apps/mobile/src/lib/hooks/use-local-session-config-controller.test.ts b/apps/mobile/src/lib/hooks/use-local-session-config-controller.test.ts
new file mode 100644
index 0000000000..e4ba42efb6
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/use-local-session-config-controller.test.ts
@@ -0,0 +1,221 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import type * as React from 'react';
+
+import { type LocalRuntimeFence } from '@/lib/hooks/local-runtime-catalog-types';
+import { RUNTIME_A } from './local-runtime-catalog-test-fixtures';
+import {
+ buildLocalRuntimeCatalogState,
+ type LocalSessionConfigController,
+ useLocalSessionConfigController,
+} from './use-local-session-config-controller';
+import { type LocalRuntimeCatalog } from './local-runtime-catalog-types';
+
+const FENCE_A: LocalRuntimeFence = {
+ runtimeId: RUNTIME_A.runtimeId,
+ connectionId: RUNTIME_A.connectionId,
+};
+
+const CATALOG: LocalRuntimeCatalog = {
+ protocolVersion: 1,
+ models: {
+ protocolVersion: 1,
+ providers: [
+ {
+ id: 'anthropic',
+ name: 'Anthropic',
+ models: [{ id: 'claude-1', name: 'Claude 1', variants: ['low', 'high'] }],
+ },
+ ],
+ defaultModel: { providerID: 'anthropic', modelID: 'claude-1' },
+ truncated: false,
+ },
+ agents: [{ slug: 'build', name: 'Build' }],
+ defaultAgent: 'build',
+};
+
+let capturedFence: LocalRuntimeFence | null = null;
+let capturedRefetch: (() => unknown) | null = null;
+let selectionState = {
+ selectedFence: null as LocalRuntimeFence | null,
+ agentOverride: null,
+ modelOverride: null,
+};
+const dispatch = vi.fn();
+
+vi.mock('react', async () => {
+ const actual = await vi.importActual('react');
+ return {
+ ...actual,
+ useEffect: () => undefined,
+ useMemo: (fn: () => T) => fn(),
+ useCallback: (fn: T) => fn,
+ useReducer: () => [selectionState, dispatch] as const,
+ };
+});
+
+vi.mock('@/components/agents/user-web-connection-provider', () => ({
+ useUserWebConnection: () => null,
+}));
+
+vi.mock('./use-local-runtimes', () => ({
+ useLocalRuntimes: () => ({
+ data: { runtimes: [RUNTIME_A] },
+ isError: false,
+ refetch: () => undefined,
+ }),
+}));
+
+vi.mock('./use-local-runtime-catalog', () => ({
+ useLocalRuntimeCatalog: (fence: LocalRuntimeFence | null) => {
+ capturedFence = fence;
+ return {
+ data: undefined,
+ error: null,
+ refetch: (...args: unknown[]) => {
+ capturedRefetch = () => args[0];
+ return undefined;
+ },
+ };
+ },
+}));
+
+beforeEach(() => {
+ capturedFence = null;
+ capturedRefetch = null;
+ selectionState = { selectedFence: null, agentOverride: null, modelOverride: null };
+ dispatch.mockReset();
+});
+
+describe('buildLocalRuntimeCatalogState', () => {
+ it('returns idle when no fence is selected', () => {
+ const state = buildLocalRuntimeCatalogState(null, {
+ data: undefined,
+ error: null,
+ refetch: () => undefined,
+ });
+ expect(state.kind).toBe('idle');
+ });
+
+ it('returns loading when a fence is selected and the query has no data yet', () => {
+ const state = buildLocalRuntimeCatalogState(FENCE_A, {
+ data: undefined,
+ error: null,
+ refetch: () => undefined,
+ });
+ expect(state.kind).toBe('loading');
+ if (state.kind !== 'loading') {
+ throw new Error('expected loading');
+ }
+ expect(state.runtime.runtimeId).toBe(FENCE_A.runtimeId);
+ expect(state.runtime.connectionId).toBe(FENCE_A.connectionId);
+ });
+
+ it('returns error with a refetch callback when the query has errored', () => {
+ const error = { data: { upstreamCode: 'RUNTIME_NOT_CONNECTED' } };
+ const refetch = vi.fn<() => void>();
+ const state = buildLocalRuntimeCatalogState(FENCE_A, {
+ data: undefined,
+ error,
+ refetch,
+ });
+ expect(state.kind).toBe('error');
+ if (state.kind !== 'error') {
+ throw new Error('expected error');
+ }
+ expect(state.error).toBe(error);
+ state.refetch();
+ expect(refetch).toHaveBeenCalledTimes(1);
+ });
+
+ it('returns ready with the exact catalog data and a matching generation identity', () => {
+ const refetch = vi.fn<() => void>();
+ const state = buildLocalRuntimeCatalogState(FENCE_A, {
+ data: CATALOG,
+ error: null,
+ refetch,
+ });
+ expect(state.kind).toBe('ready');
+ if (state.kind !== 'ready') {
+ throw new Error('expected ready');
+ }
+ expect(state.catalog).toBe(CATALOG);
+ expect(state.catalogGeneration).toBe(CATALOG);
+ });
+
+ it('carries the exact fence in every non-idle branch', () => {
+ const loading = buildLocalRuntimeCatalogState(FENCE_A, {
+ data: undefined,
+ error: null,
+ refetch: () => undefined,
+ });
+ const error = buildLocalRuntimeCatalogState(FENCE_A, {
+ data: undefined,
+ error: { data: { upstreamCode: 'RUNTIME_NOT_CONNECTED' } },
+ refetch: () => undefined,
+ });
+ const ready = buildLocalRuntimeCatalogState(FENCE_A, {
+ data: CATALOG,
+ error: null,
+ refetch: () => undefined,
+ });
+ if (loading.kind !== 'loading' || error.kind !== 'error' || ready.kind !== 'ready') {
+ throw new Error('expected loading, error, and ready');
+ }
+ expect(loading.runtime).toEqual(error.runtime);
+ expect(error.runtime).toEqual(ready.runtime);
+ expect(ready.runtime).toMatchObject({
+ runtimeId: FENCE_A.runtimeId,
+ connectionId: FENCE_A.connectionId,
+ protocolVersion: 1,
+ });
+ });
+});
+
+describe('useLocalSessionConfigController composition', () => {
+ it('passes the exact selected fence to the catalog hook', () => {
+ selectionState = { selectedFence: FENCE_A, agentOverride: null, modelOverride: null };
+ useLocalSessionConfigController();
+ expect(capturedFence).toBe(FENCE_A);
+ expect(capturedFence).toEqual({
+ runtimeId: RUNTIME_A.runtimeId,
+ connectionId: RUNTIME_A.connectionId,
+ });
+ });
+
+ it('exposes a controller with no submit/prompt/attachment/create surface', () => {
+ const controller = useLocalSessionConfigController();
+ expect(Object.keys(controller)).toEqual([
+ 'selection',
+ 'runtimesState',
+ 'catalogState',
+ 'onSelectFence',
+ 'onClearFence',
+ 'onSelectAgent',
+ 'onSelectModel',
+ 'onResetOverrides',
+ 'refetchCatalog',
+ ]);
+ expect(controller).not.toHaveProperty('onSubmit');
+ expect(controller).not.toHaveProperty('onSendPrompt');
+ expect(controller).not.toHaveProperty('onAddAttachment');
+ expect(controller).not.toHaveProperty('onCreateSession');
+ });
+
+ it('refetchCatalog invokes the underlying catalog query refetch', () => {
+ selectionState = { selectedFence: FENCE_A, agentOverride: null, modelOverride: null };
+ const controller = useLocalSessionConfigController();
+ controller.refetchCatalog();
+ expect(capturedRefetch).not.toBeNull();
+ });
+});
+
+// Static type assertion: the controller type does not expose session-creation
+// APIs. If the type ever gains one of these methods, this assertion will fail
+// to compile.
+type ForbiddenControllerKeys = 'onSubmit' | 'onSendPrompt' | 'onAddAttachment' | 'onCreateSession';
+type AssertNoForbiddenControllerKeys = ForbiddenControllerKeys &
+ keyof LocalSessionConfigController extends never
+ ? true
+ : false;
+const _controllerShapeAssertion: AssertNoForbiddenControllerKeys = true;
+void _controllerShapeAssertion;
diff --git a/apps/mobile/src/lib/hooks/use-local-session-config-controller.ts b/apps/mobile/src/lib/hooks/use-local-session-config-controller.ts
new file mode 100644
index 0000000000..659117249b
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/use-local-session-config-controller.ts
@@ -0,0 +1,215 @@
+import { useCallback, useEffect, useMemo, useReducer } from 'react';
+
+import { useUserWebConnection } from '@/components/agents/user-web-connection-provider';
+import { type LocalRuntime } from '@/lib/hooks/runtime-discovery-logic';
+import { buildLocalSessionConfigViewModel } from './local-runtime-catalog-view-model';
+import { projectLocalRuntimeCatalog } from './local-runtime-catalog-projection';
+import {
+ type LocalRuntimeCatalog,
+ type LocalRuntimeCatalogState,
+ type LocalRuntimeFence,
+ type LocalRuntimesState,
+ type LocalSessionConfigViewModel,
+} from './local-runtime-catalog-types';
+import { resolveSelectedRuntimeFence, runtimeFenceEquals } from './local-runtime-catalog-selection';
+import {
+ INITIAL_LOCAL_SESSION_CONFIG_SELECTION,
+ type LocalSessionConfigModelSelection,
+ type LocalSessionConfigSelection,
+ reduceLocalSessionConfigSelection,
+} from './local-session-config-selection';
+import { applyLocalSessionConfigSelection } from './local-session-config-view-model';
+import { useLocalRuntimeCatalog } from './use-local-runtime-catalog';
+import { useLocalRuntimes } from './use-local-runtimes';
+
+export type LocalSessionConfigController = {
+ selection: LocalSessionConfigSelection;
+ runtimesState: LocalRuntimesState;
+ catalogState: LocalRuntimeCatalogState;
+ onSelectFence: (fence: LocalRuntimeFence) => void;
+ onClearFence: () => void;
+ onSelectAgent: (slug: string) => void;
+ onSelectModel: (selection: LocalSessionConfigModelSelection) => void;
+ onResetOverrides: () => void;
+ refetchCatalog: () => void;
+};
+
+/**
+ * Pure projection from a raw `getCatalog` query result and the currently
+ * selected fence to the catalog state consumed by the view-model. Keeps the
+ * controller's React hook thin and testable without a renderer.
+ */
+export function buildLocalRuntimeCatalogState(
+ fence: LocalRuntimeFence | null,
+ query: {
+ data: LocalRuntimeCatalog | undefined;
+ error: unknown;
+ refetch: () => void;
+ }
+): LocalRuntimeCatalogState {
+ if (fence === null) {
+ return { kind: 'idle' };
+ }
+ const runtime: LocalRuntime = {
+ runtimeId: fence.runtimeId,
+ connectionId: fence.connectionId,
+ protocolVersion: 1,
+ cliVersion: '',
+ displayName: '',
+ projectName: '',
+ capabilities: [],
+ };
+ if (query.error) {
+ return {
+ kind: 'error',
+ runtime,
+ error: query.error,
+ refetch: query.refetch,
+ };
+ }
+ if (query.data === undefined) {
+ return { kind: 'loading', runtime };
+ }
+ return {
+ kind: 'ready',
+ runtime,
+ catalog: query.data,
+ catalogGeneration: query.data,
+ };
+}
+
+/**
+ * Screen-level controller for the local session configuration. Owns the
+ * selected fence, the agent override, and the model override; wires the
+ * runtime-discovery and catalog queries; and projects their results onto the
+ * pure `LocalSessionConfigViewModel` discriminated union.
+ *
+ * The screen's only writable surface is the controller's `on*` handlers —
+ * the renderer never reaches into the reducer or the query results.
+ */
+export function useLocalSessionConfigController(): LocalSessionConfigController {
+ // The user-web connection is the only consumer-side consumer of the
+ // connection object. Touching it here keeps the controller's React-Query
+ // mocks honest in tests (and ensures the existing useLocalRuntimes /
+ // useLocalRuntimeCatalog lifecycles share the same retain).
+ useUserWebConnection();
+
+ const [selection, dispatch] = useReducer(
+ reduceLocalSessionConfigSelection,
+ INITIAL_LOCAL_SESSION_CONFIG_SELECTION
+ );
+ const runtimesQuery = useLocalRuntimes();
+ const { data: runtimesData, isError, refetch: runtimesRefetch } = runtimesQuery;
+ const runtimesState = useMemo(
+ () => ({
+ data: runtimesData,
+ isError,
+ refetch: () => {
+ void runtimesRefetch();
+ },
+ }),
+ [runtimesData, isError, runtimesRefetch]
+ );
+
+ // Sync the selected fence with the live runtime list. A single capable
+ // runtime auto-selects; a disconnected runtime clears the fence; a reconnect
+ // for the same runtimeId adopts the new connectionId. Multiple capable
+ // runtimes require an explicit user choice.
+ useEffect(() => {
+ const runtimes = runtimesData?.runtimes ?? [];
+ const resolved = resolveSelectedRuntimeFence(runtimes, selection.selectedFence);
+ if (resolved === null && selection.selectedFence !== null) {
+ dispatch({ type: 'setFence', fence: null });
+ return;
+ }
+ if (
+ resolved !== null &&
+ (selection.selectedFence === null || !runtimeFenceEquals(resolved, selection.selectedFence))
+ ) {
+ dispatch({ type: 'setFence', fence: resolved });
+ }
+ }, [dispatch, runtimesData, selection.selectedFence]);
+
+ const catalogQuery = useLocalRuntimeCatalog(selection.selectedFence);
+ const { data: catalogData, error: catalogError, refetch: catalogRefetch } = catalogQuery;
+ const catalogState = useMemo(() => {
+ const projectedData = catalogData ? projectLocalRuntimeCatalog(catalogData) : undefined;
+ return buildLocalRuntimeCatalogState(selection.selectedFence, {
+ data: projectedData,
+ error: catalogError,
+ refetch: () => {
+ void catalogRefetch();
+ },
+ });
+ }, [selection.selectedFence, catalogData, catalogError, catalogRefetch]);
+
+ const onSelectFence = useCallback((fence: LocalRuntimeFence) => {
+ dispatch({ type: 'setFence', fence });
+ }, []);
+
+ const onClearFence = useCallback(() => {
+ dispatch({ type: 'setFence', fence: null });
+ }, []);
+
+ const onSelectAgent = useCallback(
+ (slug: string) => {
+ const catalog = catalogState.kind === 'ready' ? catalogState.catalog : null;
+ if (!catalog) {
+ // The agent picker only opens on a `ready` view-model — the controller
+ // never needs to accept a slug without a catalog to validate it.
+ return;
+ }
+ dispatch({ type: 'selectAgent', slug, catalog });
+ },
+ [catalogState]
+ );
+
+ const onSelectModel = useCallback((next: LocalSessionConfigModelSelection) => {
+ dispatch({ type: 'selectModel', selection: next });
+ }, []);
+
+ const onResetOverrides = useCallback(() => {
+ dispatch({ type: 'resetOverrides' });
+ }, []);
+
+ const refetchCatalog = useCallback(() => {
+ void catalogRefetch();
+ }, [catalogRefetch]);
+
+ return {
+ selection,
+ runtimesState,
+ catalogState,
+ onSelectFence,
+ onClearFence,
+ onSelectAgent,
+ onSelectModel,
+ onResetOverrides,
+ refetchCatalog,
+ };
+}
+
+/**
+ * Compose the controller's `LocalSessionConfigViewModel`. Thin wrapper over
+ * the pure helpers so the renderer can be a pure function of the controller.
+ */
+export function buildLocalSessionConfigScreenViewModel(
+ controller: LocalSessionConfigController
+): LocalSessionConfigViewModel {
+ const base = buildLocalSessionConfigViewModel({
+ runtimesState: controller.runtimesState,
+ selectedFence: controller.selection.selectedFence,
+ onSelectFence: controller.onSelectFence,
+ onClearFence: controller.onClearFence,
+ catalogState: controller.catalogState,
+ });
+ return applyLocalSessionConfigSelection({
+ viewModel: base,
+ selection: controller.selection,
+ handlers: {
+ onSelectAgent: controller.onSelectAgent,
+ onSelectModel: controller.onSelectModel,
+ onChangeRuntime: controller.onClearFence,
+ },
+ });
+}
diff --git a/apps/mobile/src/lib/hooks/use-local-session-create-orchestrator.polling.test.ts b/apps/mobile/src/lib/hooks/use-local-session-create-orchestrator.polling.test.ts
new file mode 100644
index 0000000000..0ca1e8426a
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/use-local-session-create-orchestrator.polling.test.ts
@@ -0,0 +1,231 @@
+import { describe, expect, it } from 'vitest';
+
+import { createLocalSessionCreateOrchestrator } from './use-local-session-create-orchestrator';
+import { type LocalRuntimeCatalog, type LocalRuntimeFence } from './local-runtime-catalog-types';
+import { type CreateAndRunResult } from './local-session-create-effects';
+import {
+ makeDeps,
+ REQUEST_ID_1,
+ SESSION_ID,
+} from './use-local-session-create-orchestrator.test-harness';
+
+const FENCE: LocalRuntimeFence = {
+ runtimeId: '11111111-1111-4111-8111-111111111111',
+ connectionId: 'cli-a',
+};
+
+const CATALOG: LocalRuntimeCatalog = {
+ protocolVersion: 1,
+ defaultAgent: 'build',
+ agents: [{ slug: 'build', name: 'Build' }],
+ models: {
+ protocolVersion: 1,
+ providers: [
+ {
+ id: 'kilo',
+ models: [{ id: 'claude-opus-4-7', variants: ['max', 'min'] }],
+ },
+ ],
+ truncated: false,
+ },
+};
+
+const NOT_READY_HAPPY: CreateAndRunResult = {
+ status: 'session_not_ready',
+ code: 'SESSION_NOT_READY',
+ result: { protocolVersion: 1, sessionId: SESSION_ID, promptStarted: true },
+};
+
+const NOT_READY_PARTIAL: CreateAndRunResult = {
+ status: 'session_not_ready',
+ code: 'SESSION_NOT_READY',
+ result: {
+ protocolVersion: 1,
+ sessionId: SESSION_ID,
+ promptStarted: false,
+ error: {
+ code: 'PROMPT_START_FAILED',
+ message: 'The session was created, but the first prompt did not start.',
+ },
+ },
+};
+
+const SESSION_DETAIL_PATH = `/(app)/agent-chat/${SESSION_ID}`;
+
+function makeOrchestrator(
+ deps: ReturnType['deps'],
+ fence: LocalRuntimeFence = FENCE
+) {
+ return createLocalSessionCreateOrchestrator({
+ deps,
+ fence,
+ catalog: CATALOG,
+ selectedAgentSlug: 'build',
+ selectedModel: { providerID: 'kilo', modelID: 'claude-opus-4-7', variant: 'max' },
+ getPrompt: () => 'Build me a thing',
+ });
+}
+
+describe('useLocalSessionCreateOrchestrator — polling and check-again', () => {
+ it('session_not_ready + promptStarted:true: polls readiness up to budget, ready transitions to happy path', async () => {
+ const { deps, trace, createAndRunImpl, pollReadinessImpl, sleepImpl } = makeDeps({
+ requestIds: [REQUEST_ID_1],
+ });
+ createAndRunImpl.mockResolvedValue(NOT_READY_HAPPY);
+ pollReadinessImpl
+ .mockResolvedValueOnce({ status: 'pending' })
+ .mockResolvedValueOnce({ status: 'pending' })
+ .mockResolvedValueOnce({ status: 'ready', organizationId: null });
+
+ const orchestrator = makeOrchestrator(deps);
+ await orchestrator.submit();
+
+ expect(trace.pollReadiness).toEqual([
+ { sessionId: SESSION_ID },
+ { sessionId: SESSION_ID },
+ { sessionId: SESSION_ID },
+ ]);
+ expect(sleepImpl).toHaveBeenCalled();
+ expect(trace.invalidateCalls).toBe(1);
+ expect(trace.navigate).toEqual([{ path: SESSION_DETAIL_PATH }]);
+ expect(trace.haptic).toEqual(['success']);
+ });
+
+ it('session_not_ready + promptStarted:false: invalidates, info toast, navigates without polling', async () => {
+ const { deps, trace, createAndRunImpl, pollReadinessImpl } = makeDeps({
+ requestIds: [REQUEST_ID_1],
+ });
+ createAndRunImpl.mockResolvedValue(NOT_READY_PARTIAL);
+
+ const orchestrator = makeOrchestrator(deps);
+ await orchestrator.submit();
+
+ expect(createAndRunImpl).toHaveBeenCalledTimes(1);
+ expect(pollReadinessImpl).not.toHaveBeenCalled();
+ expect(trace.invalidateCalls).toBe(1);
+ expect(trace.showInfo).toEqual([
+ {
+ message:
+ 'The session was created, but the first prompt did not start. Retry from the session composer.',
+ },
+ ]);
+ expect(trace.navigate).toEqual([{ path: SESSION_DETAIL_PATH }]);
+ });
+
+ it('session_not_ready timeout: stores sessionId/requestId and exposes the Check again CTA only', async () => {
+ const { deps, trace, createAndRunImpl, pollReadinessImpl, sleepImpl } = makeDeps({
+ requestIds: [REQUEST_ID_1],
+ pollMaxMs: 1500,
+ pollIntervalMs: 500,
+ });
+ createAndRunImpl.mockResolvedValue(NOT_READY_HAPPY);
+ pollReadinessImpl.mockResolvedValue({ status: 'pending' });
+
+ const orchestrator = makeOrchestrator(deps);
+ await orchestrator.submit();
+
+ expect(pollReadinessImpl).toHaveBeenCalled();
+ expect(sleepImpl).toHaveBeenCalled();
+ expect(trace.invalidateCalls).toBe(0);
+ expect(trace.navigate).toEqual([]);
+ expect(trace.haptic).toEqual([]);
+ expect(trace.captureEvent).toEqual([]);
+ const state = orchestrator.getState();
+ expect(state.phase).toBe('recovery');
+ if (state.phase !== 'recovery') {
+ throw new Error('expected recovery');
+ }
+ expect(state.recovery.kind).toBe('readiness-timeout');
+ expect(state.recovery.ctaLabel).toBe('Check again');
+ expect(state.sessionId).toBe(SESSION_ID);
+ expect(state.requestId).toBe(REQUEST_ID_1);
+ });
+
+ it('Check again only polls readiness, never creates a new session', async () => {
+ const { deps, createAndRunImpl, pollReadinessImpl } = makeDeps({
+ requestIds: [REQUEST_ID_1, REQUEST_ID_1],
+ pollMaxMs: 1500,
+ pollIntervalMs: 500,
+ });
+ createAndRunImpl.mockResolvedValueOnce(NOT_READY_HAPPY);
+ pollReadinessImpl.mockResolvedValue({ status: 'pending' });
+
+ const orchestrator = makeOrchestrator(deps);
+ await orchestrator.submit();
+ expect(orchestrator.getState().phase).toBe('recovery');
+ const createAndRunCallsBefore = createAndRunImpl.mock.calls.length;
+
+ pollReadinessImpl.mockResolvedValueOnce({ status: 'ready', organizationId: null });
+ await orchestrator.checkAgain();
+
+ expect(createAndRunImpl.mock.calls.length).toBe(createAndRunCallsBefore);
+ });
+
+ it('Check again handles a thrown FORBIDDEN by entering terminal access-lost recovery with no CTA', async () => {
+ const { deps, createAndRunImpl, pollReadinessImpl } = makeDeps({
+ requestIds: [REQUEST_ID_1],
+ pollMaxMs: 1500,
+ pollIntervalMs: 500,
+ });
+ createAndRunImpl.mockResolvedValueOnce(NOT_READY_HAPPY);
+ pollReadinessImpl.mockResolvedValue({ status: 'pending' });
+
+ const orchestrator = makeOrchestrator(deps);
+ await orchestrator.submit();
+ expect(orchestrator.getState().phase).toBe('recovery');
+
+ pollReadinessImpl.mockRejectedValueOnce({
+ data: { code: 'FORBIDDEN' },
+ message: 'You no longer have access to this organization',
+ });
+ await orchestrator.checkAgain();
+
+ const state = orchestrator.getState();
+ expect(state.phase).toBe('recovery');
+ if (state.phase !== 'recovery') {
+ throw new Error('expected recovery');
+ }
+ expect(state.recovery.kind).toBe('non-retryable-access-lost');
+ expect(state.recovery.ctaLabel).toBeNull();
+ });
+
+ it('uses a bounded attempt count derived from pollMaxMs / pollIntervalMs (30 attempts at default budget)', async () => {
+ const { deps, createAndRunImpl, pollReadinessImpl } = makeDeps({ requestIds: [REQUEST_ID_1] });
+ createAndRunImpl.mockResolvedValue(NOT_READY_HAPPY);
+ pollReadinessImpl.mockResolvedValue({ status: 'pending' });
+
+ const orchestrator = makeOrchestrator(deps);
+ await orchestrator.submit();
+
+ expect(pollReadinessImpl.mock.calls.length).toBeLessThanOrEqual(30);
+ });
+
+ it('Check again propagates a cache-invalidation failure after ready without converting it to a recovery state', async () => {
+ const { deps, createAndRunImpl, pollReadinessImpl, invalidateCachesImpl, trace } = makeDeps({
+ requestIds: [REQUEST_ID_1],
+ pollMaxMs: 1500,
+ pollIntervalMs: 500,
+ });
+ createAndRunImpl.mockResolvedValueOnce(NOT_READY_HAPPY);
+ pollReadinessImpl.mockResolvedValue({ status: 'pending' });
+
+ const orchestrator = makeOrchestrator(deps);
+ await orchestrator.submit();
+ expect(orchestrator.getState().phase).toBe('recovery');
+
+ const sentinel = new Error('invalidateCaches failed');
+ invalidateCachesImpl.mockRejectedValueOnce(sentinel);
+ pollReadinessImpl.mockResolvedValueOnce({ status: 'ready', organizationId: null });
+
+ const pollCallsBefore = trace.pollReadiness.length;
+ const createCallsBefore = createAndRunImpl.mock.calls.length;
+
+ await expect(orchestrator.checkAgain()).rejects.toBe(sentinel);
+
+ expect(trace.pollReadiness).toHaveLength(pollCallsBefore + 1);
+ expect(createAndRunImpl.mock.calls.length).toBe(createCallsBefore);
+
+ const state = orchestrator.getState();
+ expect(state.phase).toBe('submitting');
+ });
+});
diff --git a/apps/mobile/src/lib/hooks/use-local-session-create-orchestrator.recovery.test.ts b/apps/mobile/src/lib/hooks/use-local-session-create-orchestrator.recovery.test.ts
new file mode 100644
index 0000000000..eb686f19ac
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/use-local-session-create-orchestrator.recovery.test.ts
@@ -0,0 +1,320 @@
+import { describe, expect, it } from 'vitest';
+
+import { createLocalSessionCreateOrchestrator } from './use-local-session-create-orchestrator';
+import { type LocalRuntimeCatalog, type LocalRuntimeFence } from './local-runtime-catalog-types';
+import { type CreateAndRunResult } from './local-session-create-effects';
+import {
+ makeDeps,
+ REQUEST_ID_1,
+ REQUEST_ID_2,
+ SESSION_ID,
+} from './use-local-session-create-orchestrator.test-harness';
+
+const FENCE: LocalRuntimeFence = {
+ runtimeId: '11111111-1111-4111-8111-111111111111',
+ connectionId: 'cli-a',
+};
+const FENCE_NEW: LocalRuntimeFence = {
+ runtimeId: '11111111-1111-4111-8111-111111111111',
+ connectionId: 'cli-a-new',
+};
+
+const CATALOG: LocalRuntimeCatalog = {
+ protocolVersion: 1,
+ defaultAgent: 'build',
+ agents: [{ slug: 'build', name: 'Build' }],
+ models: {
+ protocolVersion: 1,
+ providers: [
+ {
+ id: 'kilo',
+ models: [{ id: 'claude-opus-4-7', variants: ['max', 'min'] }],
+ },
+ ],
+ truncated: false,
+ },
+};
+
+const HAPPY_RESULT: CreateAndRunResult = {
+ status: 'ready',
+ result: { protocolVersion: 1, sessionId: SESSION_ID, promptStarted: true },
+};
+
+function makeOrchestrator(
+ deps: ReturnType['deps'],
+ fence: LocalRuntimeFence = FENCE,
+ getPrompt: () => string = () => 'Build me a thing'
+) {
+ return createLocalSessionCreateOrchestrator({
+ deps,
+ fence,
+ catalog: CATALOG,
+ selectedAgentSlug: 'build',
+ selectedModel: { providerID: 'kilo', modelID: 'claude-opus-4-7', variant: 'max' },
+ getPrompt,
+ });
+}
+
+describe('useLocalSessionCreateOrchestrator — recovery, retry, fence-change', () => {
+ it('fence change (new connectionId) clears the requestId; the next fence allocates a new UUID', async () => {
+ const { deps, trace, createAndRunImpl } = makeDeps({
+ requestIds: [REQUEST_ID_1, REQUEST_ID_2],
+ });
+ createAndRunImpl.mockResolvedValue(HAPPY_RESULT);
+
+ const orchestrator = makeOrchestrator(deps, FENCE);
+ await orchestrator.submit();
+ expect(trace.createAndRun[0]).toEqual({ fence: FENCE, requestId: REQUEST_ID_1 });
+
+ const next = makeOrchestrator(deps, FENCE_NEW);
+ await next.submit();
+ expect(trace.createAndRun[1]).toEqual({ fence: FENCE_NEW, requestId: REQUEST_ID_2 });
+ });
+
+ it('retry after a transient rejection reuses the original requestId', async () => {
+ const { deps, trace, createAndRunImpl } = makeDeps({
+ requestIds: [REQUEST_ID_1, REQUEST_ID_2],
+ });
+ createAndRunImpl
+ .mockRejectedValueOnce({ data: { upstreamCode: 'COMMAND_EXPIRED' } })
+ .mockResolvedValueOnce(HAPPY_RESULT);
+
+ const orchestrator = makeOrchestrator(deps);
+ await orchestrator.submit();
+ expect(orchestrator.getState().phase).toBe('recovery');
+
+ await orchestrator.retry();
+ expect(trace.createAndRun).toEqual([
+ { fence: FENCE, requestId: REQUEST_ID_1 },
+ { fence: FENCE, requestId: REQUEST_ID_1 },
+ ]);
+ });
+
+ it('unknown transient error: keeps the requestId and surfaces the Retry CTA without auto-retrying', async () => {
+ const { deps, createAndRunImpl, trace } = makeDeps({ requestIds: [REQUEST_ID_1] });
+ createAndRunImpl.mockRejectedValueOnce(new Error('network down'));
+
+ const orchestrator = makeOrchestrator(deps);
+ await orchestrator.submit();
+
+ expect(createAndRunImpl).toHaveBeenCalledTimes(1);
+ const state = orchestrator.getState();
+ expect(state.phase).toBe('recovery');
+ if (state.phase !== 'recovery') {
+ throw new Error('expected recovery');
+ }
+ expect(state.recovery.kind).toBe('transient');
+ expect(state.recovery.ctaLabel).toBe('Retry');
+ expect(state.requestId).toBe(REQUEST_ID_1);
+ expect(trace.captureEvent).toEqual([]);
+ expect(trace.navigate).toEqual([]);
+ });
+
+ it('fence-changed (RUNTIME_NOT_CONNECTED) recovery clears the requestId so the next fence gets a new one', async () => {
+ const { deps, createAndRunImpl } = makeDeps({ requestIds: [REQUEST_ID_1, REQUEST_ID_2] });
+ createAndRunImpl.mockRejectedValueOnce({ data: { upstreamCode: 'RUNTIME_NOT_CONNECTED' } });
+
+ const orchestrator = makeOrchestrator(deps);
+ await orchestrator.submit();
+ const state = orchestrator.getState();
+ expect(state.phase).toBe('recovery');
+ if (state.phase !== 'recovery') {
+ throw new Error('expected recovery');
+ }
+ expect(state.recovery.kind).toBe('fence-changed');
+ expect(state.recovery.ctaLabel).toBe('Select runtime');
+ expect(state.requestId).toBeNull();
+
+ createAndRunImpl.mockResolvedValueOnce(HAPPY_RESULT);
+ const next = makeOrchestrator(deps, FENCE_NEW);
+ await next.submit();
+ expect(createAndRunImpl.mock.calls.at(-1)).toEqual([
+ expect.objectContaining({
+ fence: FENCE_NEW,
+ request: expect.objectContaining({ requestId: REQUEST_ID_2 }),
+ }),
+ ]);
+ });
+
+ it('non-retryable CLI upgrade error: no Retry CTA, no auto-retry', async () => {
+ const { deps, createAndRunImpl } = makeDeps({ requestIds: [REQUEST_ID_1] });
+ createAndRunImpl.mockRejectedValueOnce({ data: { upstreamCode: 'CLI_UPGRADE_REQUIRED' } });
+
+ const orchestrator = makeOrchestrator(deps);
+ await orchestrator.submit();
+
+ expect(createAndRunImpl).toHaveBeenCalledTimes(1);
+ const state = orchestrator.getState();
+ expect(state.phase).toBe('recovery');
+ if (state.phase !== 'recovery') {
+ throw new Error('expected recovery');
+ }
+ expect(state.recovery.kind).toBe('non-retryable-cli-upgrade');
+ expect(state.recovery.ctaLabel).toBeNull();
+ });
+
+ it('non-retryable malformed response: no Retry CTA, no auto-retry', async () => {
+ const { deps, createAndRunImpl } = makeDeps({ requestIds: [REQUEST_ID_1] });
+ createAndRunImpl.mockRejectedValueOnce({ data: { upstreamCode: 'INVALID_RUNTIME_RESPONSE' } });
+
+ const orchestrator = makeOrchestrator(deps);
+ await orchestrator.submit();
+
+ const state = orchestrator.getState();
+ if (state.phase !== 'recovery') {
+ throw new Error('expected recovery');
+ }
+ expect(state.recovery.kind).toBe('non-retryable-malformed');
+ expect(state.recovery.ctaLabel).toBeNull();
+ });
+
+ it('tRPC FORBIDDEN from createAndRun surfaces terminal access-lost with no Retry CTA', async () => {
+ const { deps, createAndRunImpl } = makeDeps({ requestIds: [REQUEST_ID_1] });
+ createAndRunImpl.mockRejectedValueOnce({
+ data: { code: 'FORBIDDEN' },
+ message: 'You no longer have access to this organization',
+ });
+
+ const orchestrator = makeOrchestrator(deps);
+ await orchestrator.submit();
+
+ const state = orchestrator.getState();
+ if (state.phase !== 'recovery') {
+ throw new Error('expected recovery');
+ }
+ expect(state.recovery.kind).toBe('non-retryable-access-lost');
+ expect(state.recovery.message).toBe('You no longer have access to this session.');
+ expect(state.recovery.ctaLabel).toBeNull();
+ });
+
+ it('catalog-changed keeps the requestId while the fence is unchanged (so a Retry can carry the same id)', async () => {
+ const { deps, createAndRunImpl } = makeDeps({ requestIds: [REQUEST_ID_1] });
+ createAndRunImpl.mockRejectedValueOnce({ data: { upstreamCode: 'CATALOG_CHANGED' } });
+
+ const orchestrator = makeOrchestrator(deps);
+ await orchestrator.submit();
+
+ const state = orchestrator.getState();
+ if (state.phase !== 'recovery') {
+ throw new Error('expected recovery');
+ }
+ expect(state.recovery.kind).toBe('catalog-changed');
+ expect(state.recovery.ctaLabel).toBe('Refresh catalog');
+ expect(state.requestId).toBe(REQUEST_ID_1);
+ });
+
+ it('PENDING_COMMAND_LIMIT keeps the requestId and surfaces a Retry CTA', async () => {
+ const { deps, createAndRunImpl } = makeDeps({ requestIds: [REQUEST_ID_1] });
+ createAndRunImpl.mockRejectedValueOnce({ data: { upstreamCode: 'PENDING_COMMAND_LIMIT' } });
+
+ const orchestrator = makeOrchestrator(deps);
+ await orchestrator.submit();
+
+ const state = orchestrator.getState();
+ if (state.phase !== 'recovery') {
+ throw new Error('expected recovery');
+ }
+ expect(state.recovery.kind).toBe('limit');
+ expect(state.recovery.ctaLabel).toBe('Retry');
+ expect(state.requestId).toBe(REQUEST_ID_1);
+ });
+
+ it('surfaces the upstream error message exactly once through the showError seam', async () => {
+ const { deps, createAndRunImpl, trace } = makeDeps({ requestIds: [REQUEST_ID_1] });
+ const err = Object.assign(new Error('mutation rejected'), {
+ data: { upstreamCode: 'COMMAND_EXPIRED' },
+ });
+ createAndRunImpl.mockRejectedValueOnce(err);
+
+ const orchestrator = makeOrchestrator(deps);
+ await orchestrator.submit();
+
+ expect(trace.showError).toEqual([{ message: 'mutation rejected' }]);
+ });
+
+ it('clears the recovery state when submit is called again (fresh attempt)', async () => {
+ const { deps, createAndRunImpl } = makeDeps({ requestIds: [REQUEST_ID_1, REQUEST_ID_2] });
+ createAndRunImpl
+ .mockRejectedValueOnce({ data: { upstreamCode: 'COMMAND_EXPIRED' } })
+ .mockResolvedValueOnce(HAPPY_RESULT);
+
+ const orchestrator = makeOrchestrator(deps);
+ await orchestrator.submit();
+ expect(orchestrator.getState().phase).toBe('recovery');
+
+ await orchestrator.submit();
+ expect(createAndRunImpl).toHaveBeenCalledTimes(2);
+ expect(orchestrator.getState().phase).toBe('navigated');
+ });
+});
+
+describe('useLocalSessionCreateOrchestrator — live prompt getter and prompt-length recovery', () => {
+ it('retry sends the current prompt from the getter, with the same requestId', async () => {
+ const { deps, createAndRunImpl } = makeDeps({ requestIds: [REQUEST_ID_1] });
+ createAndRunImpl
+ .mockRejectedValueOnce({ data: { upstreamCode: 'COMMAND_EXPIRED' } })
+ .mockResolvedValueOnce(HAPPY_RESULT);
+
+ let livePrompt = 'prompt A';
+ const orchestrator = makeOrchestrator(deps, FENCE, () => livePrompt);
+ await orchestrator.submit();
+
+ const firstCall = createAndRunImpl.mock.calls[0]?.[0] as {
+ request: { prompt: string; requestId: string };
+ };
+ expect(firstCall.request.prompt).toBe('prompt A');
+ expect(firstCall.request.requestId).toBe(REQUEST_ID_1);
+
+ livePrompt = 'prompt B';
+ await orchestrator.retry();
+
+ const secondCall = createAndRunImpl.mock.calls[1]?.[0] as {
+ request: { prompt: string; requestId: string };
+ };
+ expect(secondCall.request.prompt).toBe('prompt B');
+ expect(secondCall.request.requestId).toBe(REQUEST_ID_1);
+ });
+
+ it('a fresh submit after editing uses the current prompt', async () => {
+ const { deps, createAndRunImpl } = makeDeps({ requestIds: [REQUEST_ID_1, REQUEST_ID_2] });
+ createAndRunImpl.mockResolvedValue(HAPPY_RESULT);
+
+ let livePrompt = 'first prompt';
+ const orchestrator = makeOrchestrator(deps, FENCE, () => livePrompt);
+ await orchestrator.submit();
+
+ const firstCall = createAndRunImpl.mock.calls[0]?.[0] as {
+ request: { prompt: string; requestId: string };
+ };
+ expect(firstCall.request.prompt).toBe('first prompt');
+
+ livePrompt = 'second prompt';
+ await orchestrator.submit();
+
+ const secondCall = createAndRunImpl.mock.calls[1]?.[0] as {
+ request: { prompt: string; requestId: string };
+ };
+ expect(secondCall.request.prompt).toBe('second prompt');
+ });
+
+ it('rejects an over-length prompt with a non-retryable recovery and no Retry CTA', async () => {
+ const { deps, createAndRunImpl } = makeDeps({ requestIds: [REQUEST_ID_1] });
+ createAndRunImpl.mockResolvedValue(HAPPY_RESULT);
+
+ const orchestrator = makeOrchestrator(deps, FENCE, () => 'a'.repeat(32_768 + 1));
+ await orchestrator.submit();
+
+ expect(createAndRunImpl).not.toHaveBeenCalled();
+ const state = orchestrator.getState();
+ expect(state.phase).toBe('recovery');
+ if (state.phase !== 'recovery') {
+ throw new Error('expected recovery');
+ }
+ expect(state.recovery.kind).toBe('non-retryable-prompt-too-long');
+ expect(state.recovery.message).toBe(
+ 'Prompt must be 32,768 characters or fewer. Shorten the prompt and try again.'
+ );
+ expect(state.recovery.ctaLabel).toBeNull();
+ expect(state.requestId).toBeNull();
+ });
+});
diff --git a/apps/mobile/src/lib/hooks/use-local-session-create-orchestrator.test-harness.ts b/apps/mobile/src/lib/hooks/use-local-session-create-orchestrator.test-harness.ts
new file mode 100644
index 0000000000..d44427b797
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/use-local-session-create-orchestrator.test-harness.ts
@@ -0,0 +1,114 @@
+import { vi } from 'vitest';
+
+import {
+ buildLocalSessionCreateRequest,
+ type BuiltLocalSessionCreateRequest,
+} from './local-session-create-request';
+import { createLocalSessionRequestIdStore, type RequestIdUuid } from './local-session-request-id';
+import { type LocalSessionCreateOrchestratorDeps } from './use-local-session-create-orchestrator';
+
+type OrchestratorCallTrace = {
+ createAndRun: { fence: { runtimeId: string; connectionId: string }; requestId: string }[];
+ pollReadiness: { sessionId: string }[];
+ navigate: { path: string }[];
+ invalidateCalls: number;
+ captureEvent: { name: string; properties: Record | undefined }[];
+ haptic: ('success' | 'warning' | 'error')[];
+ showError: { message: string }[];
+ showInfo: { message: string }[];
+};
+
+export const SESSION_ID = 'sess-abc';
+
+export const REQUEST_ID_1: RequestIdUuid = '00000000-0000-4000-8000-000000000001' as RequestIdUuid;
+export const REQUEST_ID_2: RequestIdUuid = '00000000-0000-4000-8000-000000000002' as RequestIdUuid;
+
+export type CreateAndRunSpy = ReturnType;
+type PollReadinessSpy = ReturnType;
+type SleepSpy = ReturnType;
+type InvalidateCachesSpy = ReturnType;
+
+type MakeDepsOverrides = Partial & {
+ requestIds?: RequestIdUuid[];
+};
+
+type MakeDepsResult = {
+ deps: LocalSessionCreateOrchestratorDeps;
+ trace: OrchestratorCallTrace;
+ createAndRunImpl: CreateAndRunSpy;
+ pollReadinessImpl: PollReadinessSpy;
+ sleepImpl: SleepSpy;
+ requestIdIndex: { value: number };
+ invalidateCachesImpl: InvalidateCachesSpy;
+};
+
+export function makeDeps(overrides: MakeDepsOverrides = {}): MakeDepsResult {
+ const trace: OrchestratorCallTrace = {
+ createAndRun: [],
+ pollReadiness: [],
+ navigate: [],
+ invalidateCalls: 0,
+ captureEvent: [],
+ haptic: [],
+ showError: [],
+ showInfo: [],
+ };
+ const requestIds = overrides.requestIds ?? [];
+ const requestIdIndex = { value: 0 };
+ const createAndRunImpl = vi.fn();
+ const pollReadinessImpl = vi.fn();
+ const sleepImpl = vi.fn().mockResolvedValue(undefined);
+ const invalidateCachesImpl = vi.fn();
+
+ const deps: LocalSessionCreateOrchestratorDeps = {
+ requestIdStore: createLocalSessionRequestIdStore({
+ generateUuid: () => {
+ const id = requestIds[requestIdIndex.value] ?? REQUEST_ID_1;
+ requestIdIndex.value += 1;
+ return id;
+ },
+ }),
+ buildRequest: (input: Parameters[0]) =>
+ buildLocalSessionCreateRequest(input),
+ createAndRun: ((input: BuiltLocalSessionCreateRequest) => {
+ trace.createAndRun.push({ fence: input.fence, requestId: input.request.requestId });
+ return createAndRunImpl(input);
+ }) as LocalSessionCreateOrchestratorDeps['createAndRun'],
+ pollReadiness: ((input: { sessionId: string }) => {
+ trace.pollReadiness.push({ sessionId: input.sessionId });
+ return pollReadinessImpl(input);
+ }) as LocalSessionCreateOrchestratorDeps['pollReadiness'],
+ sleep: sleepImpl as LocalSessionCreateOrchestratorDeps['sleep'],
+ invalidateCaches: (async () => {
+ trace.invalidateCalls += 1;
+ await invalidateCachesImpl();
+ }) as LocalSessionCreateOrchestratorDeps['invalidateCaches'],
+ captureEvent: (name, properties) => {
+ trace.captureEvent.push({ name, properties });
+ },
+ notificationHaptic: kind => {
+ trace.haptic.push(kind);
+ },
+ navigate: path => {
+ trace.navigate.push({ path });
+ },
+ showError: message => {
+ trace.showError.push({ message });
+ },
+ showInfo: message => {
+ trace.showInfo.push({ message });
+ },
+ pollIntervalMs: 500,
+ pollMaxMs: 15_000,
+ ...overrides,
+ };
+ return {
+ deps,
+ trace,
+ createAndRunImpl,
+ pollReadinessImpl,
+ sleepImpl,
+ requestIdIndex,
+ invalidateCachesImpl,
+ };
+}
diff --git a/apps/mobile/src/lib/hooks/use-local-session-create-orchestrator.test.ts b/apps/mobile/src/lib/hooks/use-local-session-create-orchestrator.test.ts
new file mode 100644
index 0000000000..f93b248b32
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/use-local-session-create-orchestrator.test.ts
@@ -0,0 +1,230 @@
+import { describe, expect, it } from 'vitest';
+
+import { createLocalSessionCreateOrchestrator } from './use-local-session-create-orchestrator';
+import { type LocalSessionCreateOrchestratorState } from './local-session-create-orchestrator-shared';
+import { type LocalRuntimeCatalog, type LocalRuntimeFence } from './local-runtime-catalog-types';
+import { type CreateAndRunResult } from './local-session-create-effects';
+import {
+ type CreateAndRunSpy,
+ makeDeps,
+ REQUEST_ID_1,
+ SESSION_ID,
+} from './use-local-session-create-orchestrator.test-harness';
+
+const FENCE: LocalRuntimeFence = {
+ runtimeId: '11111111-1111-4111-8111-111111111111',
+ connectionId: 'cli-a',
+};
+
+const CATALOG: LocalRuntimeCatalog = {
+ protocolVersion: 1,
+ defaultAgent: 'build',
+ agents: [{ slug: 'build', name: 'Build' }],
+ models: {
+ protocolVersion: 1,
+ providers: [
+ {
+ id: 'kilo',
+ models: [{ id: 'claude-opus-4-7', variants: ['max', 'min'] }],
+ },
+ ],
+ truncated: false,
+ },
+};
+
+const HAPPY_RESULT: CreateAndRunResult = {
+ status: 'ready',
+ result: { protocolVersion: 1, sessionId: SESSION_ID, promptStarted: true },
+};
+
+const PROMPT_PARTIAL_RESULT: CreateAndRunResult = {
+ status: 'ready',
+ result: {
+ protocolVersion: 1,
+ sessionId: SESSION_ID,
+ promptStarted: false,
+ error: {
+ code: 'PROMPT_START_FAILED',
+ message: 'The session was created, but the first prompt did not start.',
+ },
+ },
+};
+
+const PARTIAL_INFO_MESSAGE =
+ 'The session was created, but the first prompt did not start. Retry from the session composer.';
+
+const SESSION_DETAIL_PATH = `/(app)/agent-chat/${SESSION_ID}`;
+
+function makeOrchestrator(
+ deps: ReturnType['deps'],
+ fence: LocalRuntimeFence = FENCE
+) {
+ return createLocalSessionCreateOrchestrator({
+ deps,
+ fence,
+ catalog: CATALOG,
+ selectedAgentSlug: 'build',
+ selectedModel: { providerID: 'kilo', modelID: 'claude-opus-4-7', variant: 'max' },
+ getPrompt: () => 'Build me a thing',
+ });
+}
+
+function resolveOnce(spy: CreateAndRunSpy, value: CreateAndRunResult) {
+ spy.mockResolvedValueOnce(value);
+}
+
+describe('useLocalSessionCreateOrchestrator — happy and partial paths', () => {
+ it('happy ready path: createAndRun once, invalidate, capture analytics, success haptic, navigate', async () => {
+ const { deps, trace, createAndRunImpl } = makeDeps({ requestIds: [REQUEST_ID_1] });
+ createAndRunImpl.mockResolvedValue(HAPPY_RESULT);
+
+ const orchestrator = makeOrchestrator(deps);
+ await orchestrator.submit();
+
+ expect(createAndRunImpl).toHaveBeenCalledTimes(1);
+ expect(trace.createAndRun[0]).toEqual({ fence: FENCE, requestId: REQUEST_ID_1 });
+ expect(trace.invalidateCalls).toBe(1);
+ expect(trace.captureEvent).toEqual([
+ { name: 'session_created', properties: { surface: 'remote-session' } },
+ ]);
+ expect(trace.haptic).toEqual(['success']);
+ expect(trace.navigate).toEqual([{ path: SESSION_DETAIL_PATH }]);
+ expect(trace.showError).toEqual([]);
+ expect(trace.showInfo).toEqual([]);
+ });
+
+ it('coalesces concurrent submits while a request is in flight', async () => {
+ const { deps, createAndRunImpl } = makeDeps({ requestIds: [REQUEST_ID_1] });
+ resolveOnce(createAndRunImpl, HAPPY_RESULT);
+
+ const orchestrator = makeOrchestrator(deps);
+ const first = orchestrator.submit();
+ const second = orchestrator.submit();
+
+ expect(createAndRunImpl).toHaveBeenCalledTimes(1);
+ expect(orchestrator.getState().phase).toBe('submitting');
+ await Promise.all([first, second]);
+ });
+
+ it('promptStarted:false ready: invalidates, fires fixed info toast, navigates; no analytics, no success haptic', async () => {
+ const { deps, trace, createAndRunImpl } = makeDeps({ requestIds: [REQUEST_ID_1] });
+ createAndRunImpl.mockResolvedValue(PROMPT_PARTIAL_RESULT);
+
+ const orchestrator = makeOrchestrator(deps);
+ await orchestrator.submit();
+
+ expect(createAndRunImpl).toHaveBeenCalledTimes(1);
+ expect(trace.invalidateCalls).toBe(1);
+ expect(trace.showInfo).toEqual([{ message: PARTIAL_INFO_MESSAGE }]);
+ expect(trace.showError).toEqual([]);
+ expect(trace.navigate).toEqual([{ path: SESSION_DETAIL_PATH }]);
+ expect(trace.haptic).toEqual([]);
+ expect(trace.captureEvent).toEqual([]);
+ });
+
+ it('promptStarted:false: invalidation runs before the navigate and the info toast', async () => {
+ const { deps, trace, createAndRunImpl, invalidateCachesImpl } = makeDeps({
+ requestIds: [REQUEST_ID_1],
+ });
+ createAndRunImpl.mockResolvedValue(PROMPT_PARTIAL_RESULT);
+ const events: string[] = [];
+ invalidateCachesImpl.mockImplementation(() => {
+ events.push('invalidate');
+ });
+ deps.showInfo = message => {
+ events.push(`info:${message}`);
+ };
+ deps.navigate = path => {
+ events.push(`navigate:${path}`);
+ };
+
+ const orchestrator = makeOrchestrator(deps);
+ await orchestrator.submit();
+
+ expect(events).toEqual([
+ 'invalidate',
+ `info:${PARTIAL_INFO_MESSAGE}`,
+ `navigate:${SESSION_DETAIL_PATH}`,
+ ]);
+ expect(trace.createAndRun).toHaveLength(1);
+ });
+
+ it('promptStarted:false where invalidation throws still navigates and surfaces the info toast', async () => {
+ const { deps, trace, createAndRunImpl, invalidateCachesImpl } = makeDeps({
+ requestIds: [REQUEST_ID_1],
+ });
+ createAndRunImpl.mockResolvedValue(PROMPT_PARTIAL_RESULT);
+ invalidateCachesImpl.mockRejectedValueOnce(new Error('cache down'));
+ const events: string[] = [];
+ deps.showInfo = message => {
+ events.push(`info:${message}`);
+ };
+ deps.navigate = path => {
+ events.push(`navigate:${path}`);
+ };
+
+ const orchestrator = makeOrchestrator(deps);
+ await orchestrator.submit();
+
+ expect(events).toEqual([`info:${PARTIAL_INFO_MESSAGE}`, `navigate:${SESSION_DETAIL_PATH}`]);
+ expect(createAndRunImpl).toHaveBeenCalledTimes(1);
+ expect(trace.showError).toEqual([]);
+ });
+
+ it('does not call createAndRun when validation fails before the mutation', async () => {
+ const { deps, createAndRunImpl } = makeDeps({ requestIds: [REQUEST_ID_1] });
+ createAndRunImpl.mockResolvedValue(HAPPY_RESULT);
+
+ const orchestrator = createLocalSessionCreateOrchestrator({
+ deps,
+ fence: FENCE,
+ catalog: CATALOG,
+ selectedAgentSlug: 'rogue-agent',
+ selectedModel: { providerID: 'kilo', modelID: 'claude-opus-4-7', variant: 'max' },
+ getPrompt: () => 'hi',
+ });
+ await orchestrator.submit();
+
+ expect(createAndRunImpl).not.toHaveBeenCalled();
+ const state = orchestrator.getState();
+ expect(state.phase).toBe('recovery');
+ if (state.phase !== 'recovery') {
+ throw new Error('expected recovery');
+ }
+ expect(state.recovery.kind).toBe('catalog-changed');
+ });
+});
+
+describe('useLocalSessionCreateOrchestrator — subscription contract', () => {
+ it('replays the current state to a listener that subscribes after submit is in flight', () => {
+ const { deps, createAndRunImpl } = makeDeps({ requestIds: [REQUEST_ID_1] });
+ const pending = new Promise(() => {
+ // Intentionally never resolves so the test can observe the submitting state.
+ });
+ createAndRunImpl.mockReturnValue(pending);
+
+ const orchestrator = makeOrchestrator(deps);
+ void orchestrator.submit();
+
+ const receivedStates: LocalSessionCreateOrchestratorState[] = [];
+ const listener = (state: LocalSessionCreateOrchestratorState) => {
+ receivedStates.push(state);
+ };
+ orchestrator.subscribe(listener);
+
+ expect(receivedStates).toEqual([{ phase: 'submitting' }]);
+ });
+
+ it('replays the current state to a listener that subscribes while idle', () => {
+ const { deps } = makeDeps({ requestIds: [REQUEST_ID_1] });
+ const orchestrator = makeOrchestrator(deps);
+
+ const receivedStates: LocalSessionCreateOrchestratorState[] = [];
+ const listener = (state: LocalSessionCreateOrchestratorState) => {
+ receivedStates.push(state);
+ };
+ orchestrator.subscribe(listener);
+
+ expect(receivedStates).toEqual([{ phase: 'idle' }]);
+ });
+});
diff --git a/apps/mobile/src/lib/hooks/use-local-session-create-orchestrator.ts b/apps/mobile/src/lib/hooks/use-local-session-create-orchestrator.ts
new file mode 100644
index 0000000000..5ff0990ac8
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/use-local-session-create-orchestrator.ts
@@ -0,0 +1,335 @@
+import { classifyLocalSessionCreateError } from './local-session-create-errors';
+import {
+ completeHappyPath,
+ type CreateAndRunResult,
+ handlePromptPartial,
+} from './local-session-create-effects';
+import { pollReadinessUntilReady, type ReadinessResult } from './local-session-create-polling';
+import {
+ type BuiltLocalSessionCreateRequest,
+ LocalSessionCreateRequestError,
+} from './local-session-create-request';
+import { type LocalRuntimeFence } from './local-runtime-catalog-types';
+import {
+ extractErrorMessage,
+ type LocalSessionCreateOrchestrator,
+ type LocalSessionCreateOrchestratorDeps,
+ type LocalSessionCreateOrchestratorInput,
+ type LocalSessionCreateOrchestratorState,
+ type LocalSessionCreateRecovery,
+ readinessTimeoutRecovery,
+ requestErrorToRecovery,
+ withInFlightCleared,
+} from './local-session-create-orchestrator-shared';
+
+export type { LocalSessionCreateOrchestratorDeps } from './local-session-create-orchestrator-shared';
+export { createLocalSessionCreateOrchestrator };
+
+/**
+ * Submission orchestrator for `localRuntimeControl.createAndRun`. Pure
+ * state machine: every side effect is behind `deps`, every state
+ * transition is a single `setState`. The orchestrator is consumed by the
+ * React `useLocalSessionCreateOrchestrator` hook (which wires the real
+ * tRPC, haptics, toast, router, and analytics) and by the test suite
+ * (which wires fakes).
+ *
+ * Invariants enforced by tests:
+ *
+ * - `createAndRun` runs at most once per user attempt. Concurrent submits
+ * while a request is in flight are coalesced.
+ * - The same fence + the same requestId reuses the same requestId. A
+ * fence change (runtimeId OR connectionId) clears the binding and the
+ * next attempt allocates a new UUID.
+ * - `session_not_ready` triggers a bounded sequential poll of
+ * `pollReadiness` at `pollIntervalMs` up to `pollMaxMs`. Ready
+ * transitions to the happy path; timeout stores `sessionId` +
+ * requestId and exposes the `Check again` CTA which only polls.
+ * - `promptStarted:false` (ready or session_not_ready) invalidates the
+ * session detail/list caches, surfaces the fixed safe info toast, and
+ * navigates to the session detail; no analytics, no success haptic.
+ * If invalidation itself throws, the orchestrator still navigates to
+ * the known session and surfaces the prompt-partial toast once.
+ * - Every other thrown error is classified into one of the recovery
+ * categories with a precise CTA (or no CTA for non-retryable branches).
+ * The orchestrator surfaces the upstream error message exactly once
+ * through the shared `showError` seam; the React hook wires the same
+ * seam into the mutation's `onError` so the user always sees the
+ * upstream message exactly once.
+ */
+function createLocalSessionCreateOrchestrator(
+ input: LocalSessionCreateOrchestratorInput
+): LocalSessionCreateOrchestrator {
+ const { deps, fence, catalog, selectedAgentSlug, selectedModel, getPrompt } = input;
+
+ let state: LocalSessionCreateOrchestratorState = { phase: 'idle' };
+ const inFlight: { current: Promise | null } = {
+ current: null,
+ };
+ const listeners = new Set<(state: LocalSessionCreateOrchestratorState) => void>();
+
+ function setState(next: LocalSessionCreateOrchestratorState) {
+ state = next;
+ for (const listener of listeners) {
+ listener(state);
+ }
+ }
+
+ function build(): BuiltLocalSessionCreateRequest {
+ const requestId = deps.requestIdStore.getOrAcquire(fence);
+ return deps.buildRequest({
+ fence,
+ catalog,
+ selectedAgentSlug,
+ selectedModel,
+ prompt: getPrompt(),
+ requestId,
+ });
+ }
+
+ function withRequestId(requestId: string): BuiltLocalSessionCreateRequest {
+ return deps.buildRequest({
+ fence,
+ catalog,
+ selectedAgentSlug,
+ selectedModel,
+ prompt: getPrompt(),
+ requestId,
+ });
+ }
+
+ function reportErrorAndRecovery(
+ error: unknown,
+ fallbackRequestId: string | null
+ ): LocalSessionCreateOrchestratorState {
+ const message = extractErrorMessage(error);
+ deps.showError(message);
+ const recovery = classifyLocalSessionCreateError(error);
+ const shouldClear = recovery.kind === 'fence-changed';
+ if (shouldClear) {
+ deps.requestIdStore.clearByFence(fence);
+ }
+ return publishRecovery({
+ recovery,
+ sessionId: null,
+ requestId: shouldClear ? null : fallbackRequestId,
+ fence,
+ });
+ }
+
+ function publishRecovery(args: {
+ recovery: LocalSessionCreateRecovery;
+ sessionId: string | null;
+ requestId: string | null;
+ fence: LocalRuntimeFence;
+ }): LocalSessionCreateOrchestratorState {
+ setState({ phase: 'recovery', ...args });
+ return state;
+ }
+
+ function publishNavigated(): LocalSessionCreateOrchestratorState {
+ setState({ phase: 'navigated' });
+ return state;
+ }
+
+ async function completeHappySafe(sessionId: string): Promise {
+ try {
+ await completeHappyPath(sessionId, {
+ captureEvent: deps.captureEvent,
+ invalidateCaches: deps.invalidateCaches,
+ notificationHaptic: deps.notificationHaptic,
+ navigate: deps.navigate,
+ });
+ } finally {
+ deps.requestIdStore.markSuccess(fence);
+ }
+ }
+
+ async function handlePromptPartialSafe(sessionId: string): Promise {
+ try {
+ await handlePromptPartial(sessionId, {
+ invalidateCaches: deps.invalidateCaches,
+ showInfo: deps.showInfo,
+ navigate: deps.navigate,
+ });
+ } finally {
+ deps.requestIdStore.markSuccess(fence);
+ }
+ }
+
+ async function pollReadinessFor(sessionId: string): Promise {
+ const ready = await pollReadinessUntilReady({
+ sessionId,
+ pollReadiness: deps.pollReadiness,
+ sleep: deps.sleep,
+ intervalMs: deps.pollIntervalMs,
+ maxAttempts: Math.max(1, Math.floor(deps.pollMaxMs / deps.pollIntervalMs)),
+ });
+ return ready;
+ }
+
+ async function runCreate(): Promise {
+ let request: BuiltLocalSessionCreateRequest | null = null;
+ try {
+ request = build();
+ } catch (error) {
+ if (error instanceof LocalSessionCreateRequestError) {
+ const recovery = requestErrorToRecovery(error);
+ if (recovery.kind === 'catalog-changed') {
+ deps.requestIdStore.clearByFence(fence);
+ }
+ return publishRecovery({
+ recovery,
+ sessionId: null,
+ requestId: null,
+ fence,
+ });
+ }
+ throw error;
+ }
+ const result = await runAttempt(request);
+ return result;
+ }
+
+ async function runAttempt(
+ request: BuiltLocalSessionCreateRequest
+ ): Promise {
+ const result = await runAttemptInner(request);
+ return result;
+ }
+
+ async function runAttemptInner(
+ request: BuiltLocalSessionCreateRequest
+ ): Promise {
+ let result: CreateAndRunResult | null = null;
+ try {
+ result = await deps.createAndRun(request);
+ } catch (error) {
+ return reportErrorAndRecovery(error, request.request.requestId);
+ }
+
+ const sessionId = result.result.sessionId;
+ const requestId = request.request.requestId;
+
+ if (!result.result.promptStarted) {
+ await handlePromptPartialSafe(sessionId);
+ return publishNavigated();
+ }
+
+ if (result.status === 'ready') {
+ await completeHappySafe(sessionId);
+ return publishNavigated();
+ }
+
+ const ready = await pollReadinessFor(sessionId);
+ if (ready) {
+ await completeHappySafe(sessionId);
+ return publishNavigated();
+ }
+
+ return publishRecovery({
+ recovery: readinessTimeoutRecovery(),
+ sessionId,
+ requestId,
+ fence,
+ });
+ }
+
+ async function runRetry(requestId: string): Promise {
+ let request: BuiltLocalSessionCreateRequest | null = null;
+ try {
+ request = withRequestId(requestId);
+ } catch (error) {
+ if (error instanceof LocalSessionCreateRequestError) {
+ return publishRecovery({
+ recovery: requestErrorToRecovery(error),
+ sessionId: null,
+ requestId,
+ fence,
+ });
+ }
+ throw error;
+ }
+ const result = await runAttempt(request);
+ return result;
+ }
+
+ async function runCheckAgain(
+ sessionId: string,
+ requestId: string
+ ): Promise {
+ let probe: ReadinessResult | undefined = undefined;
+ try {
+ probe = await deps.pollReadiness({ sessionId });
+ } catch (error) {
+ return reportErrorAndRecovery(error, requestId);
+ }
+ if (probe.status === 'ready') {
+ await completeHappySafe(sessionId);
+ return publishNavigated();
+ }
+ return publishRecovery({
+ recovery: readinessTimeoutRecovery(),
+ sessionId,
+ requestId,
+ fence,
+ });
+ }
+
+ async function runGuarded(
+ action: () => Promise
+ ): Promise {
+ if (inFlight.current) {
+ const result = await inFlight.current;
+ return result;
+ }
+ setState({ phase: 'submitting' });
+ const promise = action();
+ inFlight.current = promise;
+ const result = await withInFlightCleared(inFlight, promise);
+ return result;
+ }
+
+ return {
+ submit: async () => {
+ const result = await runGuarded(runCreate);
+ return result;
+ },
+ retry: async () => {
+ if (state.phase !== 'recovery') {
+ return state;
+ }
+ const requestId = state.requestId;
+ if (!requestId) {
+ const result = await runGuarded(runCreate);
+ return result;
+ }
+ const result = await runGuarded(async () => {
+ const inner = await runRetry(requestId);
+ return inner;
+ });
+ return result;
+ },
+ checkAgain: async () => {
+ if (state.phase !== 'recovery' || state.recovery.kind !== 'readiness-timeout') {
+ return state;
+ }
+ const { sessionId, requestId } = state;
+ if (!sessionId || !requestId) {
+ return state;
+ }
+ const result = await runGuarded(async () => {
+ const inner = await runCheckAgain(sessionId, requestId);
+ return inner;
+ });
+ return result;
+ },
+ getState: () => state,
+ subscribe: listener => {
+ listeners.add(listener);
+ listener(state);
+ return () => {
+ listeners.delete(listener);
+ };
+ },
+ };
+}
diff --git a/apps/mobile/src/lib/hooks/use-local-session-create.test.ts b/apps/mobile/src/lib/hooks/use-local-session-create.test.ts
new file mode 100644
index 0000000000..8f37888762
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/use-local-session-create.test.ts
@@ -0,0 +1,874 @@
+/* eslint-disable max-lines -- one cohesive TDD suite per React hook; the hook
+ owns one concern (wire the orchestrator to tRPC + side effects) and its
+ tests assert one concern (the wiring). Splitting would scatter closely
+ related mock plumbing across files. */
+import * as React from 'react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { IDLE_STATE, useLocalSessionCreate } from './use-local-session-create';
+import { type LocalRuntimeCatalog, type LocalRuntimeFence } from './local-runtime-catalog-types';
+import {
+ type LocalSessionCreateOrchestrator,
+ type LocalSessionCreateOrchestratorState,
+} from './local-session-create-orchestrator-shared';
+
+const mockedTrpc = vi.hoisted(() => ({
+ mutateAsync: vi.fn(),
+ mutate: vi.fn(),
+ mutationOnError: null as null | ((error: { message: string }) => void),
+ query: vi.fn(),
+ trpcShape: null as unknown as {
+ cliSessionsV2: {
+ list: { pathFilter: () => { queryKey: readonly string[] } };
+ recentRepositories: { pathFilter: () => { queryKey: readonly string[] } };
+ search: { pathFilter: () => { queryKey: readonly string[] } };
+ };
+ activeSessions: {
+ list: { pathFilter: () => { queryKey: readonly string[] } };
+ };
+ localRuntimeControl: {
+ createAndRun: { mutationOptions: (opts: unknown) => unknown };
+ };
+ },
+}));
+
+const mockedReactQuery = vi.hoisted(() => ({
+ invalidateQueries: vi.fn().mockResolvedValue(undefined),
+ invalidationCalls: [] as unknown[][],
+}));
+
+const mockedToast = vi.hoisted(() => ({
+ error: vi.fn(),
+ info: vi.fn(),
+ success: vi.fn(),
+}));
+
+const mockedHaptics = vi.hoisted(() => ({
+ notificationAsync: vi.fn().mockResolvedValue(undefined),
+}));
+
+const mockedAnalytics = vi.hoisted(() => ({
+ captureEvent: vi.fn(),
+}));
+
+const mockedCrypto = vi.hoisted(() => ({
+ randomUUID: vi.fn(() => '00000000-0000-4000-8000-0000000000aa'),
+}));
+
+const mockedRouter = vi.hoisted(() => ({
+ replace: vi.fn(),
+}));
+
+const pathFilters = {
+ cliSessionsV2: {
+ list: { pathFilter: () => ({ queryKey: ['cliSessionsV2', 'list'] }) },
+ recentRepositories: {
+ pathFilter: () => ({ queryKey: ['cliSessionsV2', 'recentRepositories'] }),
+ },
+ search: { pathFilter: () => ({ queryKey: ['cliSessionsV2', 'search'] }) },
+ readiness: { query: (input: { session_id: string }) => mockedTrpc.query(input) },
+ },
+ activeSessions: {
+ list: { pathFilter: () => ({ queryKey: ['activeSessions', 'list'] }) },
+ },
+};
+
+vi.mock('@/lib/trpc', () => ({
+ useTRPC: () => mockedTrpc.trpcShape,
+ trpcClient: {
+ cliSessionsV2: {
+ readiness: {
+ query: (input: { session_id: string }) => mockedTrpc.query(input),
+ },
+ },
+ localRuntimeControl: {
+ createAndRun: {
+ mutate: (...args: unknown[]) => mockedTrpc.mutate(...args),
+ mutateAsync: (...args: unknown[]) => mockedTrpc.mutateAsync(...args),
+ },
+ },
+ },
+}));
+
+vi.mock('@tanstack/react-query', () => ({
+ useMutation: (options: {
+ mutationFn?: (input: unknown) => Promise;
+ onError?: (error: { message: string }) => void;
+ }) => {
+ const onError = options.onError;
+ return {
+ mutateAsync: async (...args: unknown[]) => {
+ try {
+ return await mockedTrpc.mutateAsync(...args);
+ } catch (error) {
+ if (onError) {
+ onError(error as { message: string });
+ }
+ throw error;
+ }
+ },
+ };
+ },
+ useQueryClient: () => ({
+ invalidateQueries: (input: unknown) => {
+ mockedReactQuery.invalidationCalls.push([input]);
+ return mockedReactQuery.invalidateQueries(input);
+ },
+ }),
+}));
+
+vi.mock('sonner-native', () => ({
+ toast: mockedToast,
+}));
+
+vi.mock('expo-haptics', () => ({
+ notificationAsync: mockedHaptics.notificationAsync,
+ NotificationFeedbackType: { Success: 'success', Warning: 'warning', Error: 'error' },
+}));
+
+vi.mock('@/lib/analytics/posthog', () => ({
+ captureEvent: mockedAnalytics.captureEvent,
+ SESSION_CREATED_EVENT: 'session_created',
+}));
+
+vi.mock('expo-crypto', () => ({
+ randomUUID: () => mockedCrypto.randomUUID(),
+}));
+
+vi.mock('expo-router', () => ({
+ useRouter: () => mockedRouter,
+}));
+
+// ── React dispatcher harness ────────────────────────────────────────
+
+type DispatcherState = {
+ hookIndex: number;
+ hookState: unknown[];
+ stateSlots: unknown[];
+ stateSlotIndex: number;
+ orchestratorStateSlotIndex: number;
+ setStates: ((value: unknown) => void)[];
+ effects: ({ effect: () => unknown; cleanup: unknown } | undefined)[];
+ memos: Map;
+ refs: Map;
+};
+
+function makeDispatcher(): { state: DispatcherState; setDispatcher: () => void } {
+ const state: DispatcherState = {
+ hookIndex: 0,
+ hookState: [],
+ stateSlots: [],
+ stateSlotIndex: 0,
+ orchestratorStateSlotIndex: -1,
+ setStates: [],
+ effects: [],
+ memos: new Map(),
+ refs: new Map(),
+ };
+ const previousDispatcher = (
+ React as unknown as {
+ __CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE: { H: unknown };
+ }
+ ).__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE.H;
+ (
+ React as unknown as {
+ __CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE: { H: unknown };
+ }
+ ).__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE.H = {
+ useState: (initial: unknown) => {
+ const slot = state.stateSlotIndex;
+ state.stateSlotIndex += 1;
+ if (state.stateSlots[slot] === undefined) {
+ state.stateSlots[slot] = initial;
+ }
+ // The hook exposes its orchestrator state through a single useState.
+ // Record which state slot it is so refresh reads the live state
+ // regardless of where useState falls among refs, memos, and effects.
+ if (initial === IDLE_STATE) {
+ state.orchestratorStateSlotIndex = slot;
+ }
+ const setState = (value: unknown) => {
+ state.stateSlots[slot] =
+ typeof value === 'function'
+ ? (value as (previous: unknown) => unknown)(state.stateSlots[slot])
+ : value;
+ };
+ return [state.stateSlots[slot], setState] as [unknown, (v: unknown) => void];
+ },
+ useReducer: (reducer: (s: unknown, a: unknown) => unknown, initial: unknown) => {
+ const idx = state.hookIndex;
+ state.hookIndex += 1;
+ if (state.hookState[idx] === undefined) {
+ state.hookState[idx] = initial;
+ }
+ const dispatch = (action: unknown) => {
+ state.hookState[idx] = reducer(state.hookState[idx], action);
+ };
+ return [state.hookState[idx], dispatch] as [unknown, (a: unknown) => void];
+ },
+ useRef: (initial: unknown) => {
+ const idx = state.hookIndex;
+ state.hookIndex += 1;
+ if (!state.refs.has(idx)) {
+ state.refs.set(idx, { current: initial });
+ }
+ return state.refs.get(idx) as { current: unknown };
+ },
+ useCallback: (callback: unknown, deps: unknown[]) => {
+ const idx = state.hookIndex;
+ state.hookIndex += 1;
+ state.memos.set(idx, callback);
+ void deps;
+ return callback;
+ },
+ useMemo: (factory: () => unknown, deps: unknown[]) => {
+ const idx = state.hookIndex;
+ state.hookIndex += 1;
+ void deps;
+ if (!state.memos.has(idx)) {
+ state.memos.set(idx, factory());
+ }
+ return state.memos.get(idx);
+ },
+ useEffect: (effect: () => unknown, _deps: unknown[]) => {
+ const idx = state.hookIndex;
+ state.hookIndex += 1;
+ state.effects[idx] = { effect, cleanup: undefined };
+ void _deps;
+ },
+ useLayoutEffect: (_effect: () => unknown, _deps: unknown[]) => {
+ void _effect;
+ void _deps;
+ },
+ useSyncExternalStore: (
+ subscribe: (listener: () => void) => () => void,
+ getSnapshot: () => unknown
+ ) => {
+ const idx = state.hookIndex;
+ state.hookIndex += 1;
+ state.memos.set(idx, { subscribe, getSnapshot });
+ return getSnapshot();
+ },
+ useContext: (context: unknown) => {
+ void context;
+ return undefined;
+ },
+ useDebugValue: (_value: unknown) => {
+ void _value;
+ },
+ };
+ return {
+ state,
+ setDispatcher: () => {
+ (
+ React as unknown as {
+ __CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE: { H: unknown };
+ }
+ ).__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE.H = previousDispatcher;
+ },
+ };
+}
+
+function runEffects(dispatcherState: DispatcherState) {
+ for (const entry of dispatcherState.effects) {
+ if (entry !== undefined) {
+ if (entry.cleanup) {
+ const c = entry.cleanup as () => void;
+ c();
+ }
+ const cleanup = entry.effect();
+ entry.cleanup = cleanup;
+ }
+ }
+}
+
+function runCleanups(dispatcherState: DispatcherState) {
+ for (const entry of dispatcherState.effects) {
+ if (entry?.cleanup) {
+ (entry.cleanup as () => void)();
+ }
+ }
+}
+
+function getActiveOrchestratorFromRefs(
+ dispatcherState: DispatcherState
+): LocalSessionCreateOrchestrator | null {
+ for (const ref of dispatcherState.refs.values()) {
+ const maybe = ref.current as {
+ orchestrator?: LocalSessionCreateOrchestrator;
+ } | null;
+ if (maybe?.orchestrator) {
+ return maybe.orchestrator;
+ }
+ }
+ return null;
+}
+
+function assertDefined(value: T | null | undefined): asserts value is T {
+ expect(value).toBeDefined();
+}
+
+function callArgument(calls: unknown[][], index: number): unknown {
+ const call = calls[index];
+ assertDefined(call);
+ return call[0];
+}
+
+// ── Domain fixtures ─────────────────────────────────────────────────
+
+const FENCE_A: LocalRuntimeFence = {
+ runtimeId: '11111111-1111-4111-8111-111111111111',
+ connectionId: 'cli-a',
+};
+const FENCE_B: LocalRuntimeFence = {
+ runtimeId: '11111111-1111-4111-8111-111111111111',
+ connectionId: 'cli-b',
+};
+
+const CATALOG: LocalRuntimeCatalog = {
+ protocolVersion: 1,
+ defaultAgent: 'build',
+ agents: [{ slug: 'build', name: 'Build' }],
+ models: {
+ protocolVersion: 1,
+ providers: [
+ {
+ id: 'kilo',
+ models: [{ id: 'claude-opus-4-7', variants: ['max'] }],
+ },
+ ],
+ truncated: false,
+ },
+};
+
+const SESSION_ID = 'sess-abc';
+const SESSION_DETAIL_PATH = `/(app)/agent-chat/${SESSION_ID}`;
+
+type RenderInput = {
+ fence: LocalRuntimeFence | null;
+ catalog: LocalRuntimeCatalog | null;
+ selectedAgentSlug: string | null;
+ selectedModel: { providerID: string; modelID: string; variant: string } | null;
+ prompt: string;
+};
+
+type HookReturn = ReturnType;
+
+type RenderResult = {
+ state: HookReturn;
+ refresh: () => void;
+ rerender: (next: Partial) => void;
+ unmount: () => void;
+ promptRef: { current: string };
+ hasPromptRef: { current: boolean };
+ dispatcherState: DispatcherState;
+};
+
+function renderHook(initial: RenderInput): RenderResult {
+ const harness = makeDispatcher();
+ const dispatcherState = harness.state;
+ let current: RenderInput = initial;
+ let lastState: HookReturn | null = null;
+
+ const promptRef: { current: string } = { current: current.prompt };
+ const hasPromptRef: { current: boolean } = { current: current.prompt.length > 0 };
+
+ const run = () => {
+ dispatcherState.hookIndex = 0;
+ dispatcherState.effects = [];
+ lastState = (useLocalSessionCreate as unknown as (input: unknown) => HookReturn)({
+ fence: current.fence,
+ catalog: current.catalog,
+ selectedAgentSlug: current.selectedAgentSlug,
+ selectedModel: current.selectedModel,
+ promptRef,
+ hasPromptRef,
+ });
+ runEffects(dispatcherState);
+ };
+
+ run();
+
+ const refresh = () => {
+ // The custom dispatcher does not re-render on state changes, so the
+ // subscription effect is only established on re-render. After a lazy
+ // submit we manually sync the orchestrator state into the React state
+ // slot so `refresh` exposes the rendered hook result without a
+ // hard-coded state index.
+ let liveState: LocalSessionCreateOrchestratorState = IDLE_STATE;
+ let foundOrchestrator = false;
+ for (const ref of dispatcherState.refs.values()) {
+ const maybe = ref.current as {
+ orchestrator?: { getState: () => LocalSessionCreateOrchestratorState };
+ } | null;
+ if (maybe?.orchestrator?.getState) {
+ liveState = maybe.orchestrator.getState();
+ foundOrchestrator = true;
+ break;
+ }
+ }
+ if (!foundOrchestrator && dispatcherState.orchestratorStateSlotIndex >= 0) {
+ liveState = dispatcherState.stateSlots[
+ dispatcherState.orchestratorStateSlotIndex
+ ] as LocalSessionCreateOrchestratorState;
+ }
+ if (dispatcherState.orchestratorStateSlotIndex >= 0) {
+ dispatcherState.stateSlots[dispatcherState.orchestratorStateSlotIndex] = liveState;
+ }
+ assertDefined(lastState);
+ const recovery = liveState.phase === 'recovery' ? liveState.recovery : null;
+ const isSelectionComplete =
+ current.fence !== null &&
+ current.catalog !== null &&
+ current.selectedAgentSlug !== null &&
+ current.selectedModel !== null;
+ const isSubmitting = liveState.phase === 'submitting';
+ const canSubmit = isSelectionComplete && hasPromptRef.current && !isSubmitting;
+ const canRetry = recovery?.ctaLabel === 'Retry';
+ const canCheckAgain = recovery?.kind === 'readiness-timeout';
+ const nextState: HookReturn = {
+ ...lastState,
+ phase: liveState.phase,
+ recovery,
+ isSubmitting,
+ canSubmit,
+ canRetry,
+ canCheckAgain,
+ };
+ lastState = nextState;
+ };
+
+ return {
+ get state() {
+ assertDefined(lastState);
+ return lastState;
+ },
+ refresh,
+ rerender: (next: Partial) => {
+ current = { ...current, ...next };
+ promptRef.current = current.prompt;
+ hasPromptRef.current = current.prompt.length > 0;
+ // Clean up existing subscriptions before re-running.
+ runCleanups(dispatcherState);
+ run();
+ },
+ unmount: () => {
+ runCleanups(dispatcherState);
+ harness.setDispatcher();
+ },
+ promptRef,
+ hasPromptRef,
+ dispatcherState,
+ };
+}
+
+beforeEach(() => {
+ mockedTrpc.mutateAsync.mockReset();
+ mockedTrpc.mutateAsync.mockImplementation(async (input: unknown) => await input);
+ mockedTrpc.mutate.mockReset();
+ mockedTrpc.mutationOnError = null;
+ mockedTrpc.query.mockReset();
+ mockedTrpc.trpcShape = {
+ cliSessionsV2: {
+ ...pathFilters.cliSessionsV2,
+ },
+ activeSessions: pathFilters.activeSessions,
+ localRuntimeControl: {
+ createAndRun: {
+ mutationOptions: (opts: unknown) => {
+ const typedOpts = opts as { onError?: (error: { message: string }) => void };
+ mockedTrpc.mutationOnError = typedOpts.onError ?? null;
+ return { __mutation: 'createAndRun', onError: typedOpts.onError };
+ },
+ },
+ },
+ };
+ mockedReactQuery.invalidateQueries.mockClear();
+ mockedReactQuery.invalidationCalls = [];
+ mockedToast.error.mockReset();
+ mockedToast.info.mockReset();
+ mockedToast.success.mockReset();
+ mockedHaptics.notificationAsync.mockReset();
+ mockedAnalytics.captureEvent.mockReset();
+ mockedCrypto.randomUUID.mockReset();
+ mockedCrypto.randomUUID.mockReturnValue('00000000-0000-4000-8000-0000000000aa');
+ mockedRouter.replace.mockReset();
+});
+
+afterEach(() => {
+ // Reset the dispatcher to a clean state.
+ (
+ React as unknown as {
+ __CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE: { H: unknown };
+ }
+ ).__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE.H = null;
+});
+
+// ── Tests ───────────────────────────────────────────────────────────
+
+describe('useLocalSessionCreate — wiring', () => {
+ it('registers the mutation onError toast handler at construction', () => {
+ renderHook({
+ fence: FENCE_A,
+ catalog: CATALOG,
+ selectedAgentSlug: 'build',
+ selectedModel: { providerID: 'kilo', modelID: 'claude-opus-4-7', variant: 'max' },
+ prompt: 'Build me a thing',
+ });
+ expect(mockedTrpc.mutationOnError).toBeTypeOf('function');
+ });
+
+ it('exposes canSubmit=false while prompt is blank and =true once a nonblank prompt is captured', () => {
+ const result = renderHook({
+ fence: FENCE_A,
+ catalog: CATALOG,
+ selectedAgentSlug: 'build',
+ selectedModel: { providerID: 'kilo', modelID: 'claude-opus-4-7', variant: 'max' },
+ prompt: '',
+ });
+ expect(result.state.canSubmit).toBe(false);
+ result.rerender({ prompt: 'hello' });
+ expect(result.state.canSubmit).toBe(true);
+ });
+
+ it('exposes canSubmit=false when selection is incomplete (fence null)', () => {
+ const result = renderHook({
+ fence: null,
+ catalog: null,
+ selectedAgentSlug: null,
+ selectedModel: null,
+ prompt: 'orphan prompt',
+ });
+ expect(result.state.canSubmit).toBe(false);
+ });
+});
+
+describe('useLocalSessionCreate — submit happy path', () => {
+ it('passes exact built {fence,request} to mutateAsync, fires success haptic, captures analytics, invalidates once, and routes to detail', async () => {
+ mockedTrpc.mutateAsync.mockResolvedValueOnce({
+ status: 'ready',
+ result: { protocolVersion: 1, sessionId: SESSION_ID, promptStarted: true },
+ });
+ const result = renderHook({
+ fence: FENCE_A,
+ catalog: CATALOG,
+ selectedAgentSlug: 'build',
+ selectedModel: { providerID: 'kilo', modelID: 'claude-opus-4-7', variant: 'max' },
+ prompt: 'Build me a thing',
+ });
+
+ await result.state.submit();
+
+ expect(mockedTrpc.mutateAsync).toHaveBeenCalledTimes(1);
+ const call = callArgument(mockedTrpc.mutateAsync.mock.calls, 0) as {
+ fence: LocalRuntimeFence;
+ request: {
+ protocolVersion: 1;
+ requestId: string;
+ prompt: string;
+ model: { providerID: string; modelID: string };
+ agent: string;
+ variant?: string;
+ };
+ };
+ expect(call.fence).toEqual(FENCE_A);
+ expect(call.request.protocolVersion).toBe(1);
+ expect(call.request.requestId).toBe('00000000-0000-4000-8000-0000000000aa');
+ expect(call.request.prompt).toBe('Build me a thing');
+ expect(call.request.model).toEqual({ providerID: 'kilo', modelID: 'claude-opus-4-7' });
+ expect(call.request.variant).toBe('max');
+ expect(call.request.agent).toBe('build');
+
+ expect(mockedReactQuery.invalidationCalls).toHaveLength(4);
+ const queryKeys = (
+ mockedReactQuery.invalidationCalls as [{ queryKey: readonly string[] }][]
+ ).map(([c]) => c.queryKey[1] ?? c.queryKey[0]);
+ expect(queryKeys).toEqual(['list', 'recentRepositories', 'search', 'list']);
+
+ expect(mockedAnalytics.captureEvent).toHaveBeenCalledWith('session_created', {
+ surface: 'remote-session',
+ });
+ expect(mockedHaptics.notificationAsync).toHaveBeenCalledWith('success');
+ expect(mockedRouter.replace).toHaveBeenCalledWith(SESSION_DETAIL_PATH);
+ expect(mockedToast.info).not.toHaveBeenCalled();
+ expect(mockedToast.error).not.toHaveBeenCalled();
+ });
+
+ it('prompt-partial: fires one info toast and routes to detail, no analytics, no haptic', async () => {
+ mockedTrpc.mutateAsync.mockResolvedValueOnce({
+ status: 'ready',
+ result: {
+ protocolVersion: 1,
+ sessionId: SESSION_ID,
+ promptStarted: false,
+ error: { code: 'PROMPT_START_FAILED', message: 'prompt did not start' },
+ },
+ });
+ const result = renderHook({
+ fence: FENCE_A,
+ catalog: CATALOG,
+ selectedAgentSlug: 'build',
+ selectedModel: { providerID: 'kilo', modelID: 'claude-opus-4-7', variant: 'max' },
+ prompt: 'Build me a thing',
+ });
+
+ await result.state.submit();
+
+ expect(mockedToast.info).toHaveBeenCalledTimes(1);
+ expect(mockedToast.error).not.toHaveBeenCalled();
+ expect(mockedAnalytics.captureEvent).not.toHaveBeenCalled();
+ expect(mockedHaptics.notificationAsync).not.toHaveBeenCalled();
+ expect(mockedRouter.replace).toHaveBeenCalledWith(SESSION_DETAIL_PATH);
+ });
+});
+
+describe('useLocalSessionCreate — error and recovery wiring', () => {
+ it('fires the mutation onError toast exactly once for a thrown mutation error', async () => {
+ const err = Object.assign(new Error('mutation rejected'), {
+ data: { upstreamCode: 'COMMAND_EXPIRED' },
+ });
+ mockedTrpc.mutateAsync.mockRejectedValueOnce(err);
+ const result = renderHook({
+ fence: FENCE_A,
+ catalog: CATALOG,
+ selectedAgentSlug: 'build',
+ selectedModel: { providerID: 'kilo', modelID: 'claude-opus-4-7', variant: 'max' },
+ prompt: 'Build me a thing',
+ });
+
+ await result.state.submit();
+ result.refresh();
+
+ expect(mockedToast.error).toHaveBeenCalledTimes(1);
+ expect(mockedToast.error).toHaveBeenCalledWith('mutation rejected');
+ // Recovery state is exposed so the screen can render a CTA below the prompt.
+ expect(result.state.recovery).not.toBeNull();
+ const recovery = result.state.recovery;
+ assertDefined(recovery);
+ expect(recovery.kind).toBe('transient');
+ });
+
+ it('retry reuses the same requestId across the original submit and the retry', async () => {
+ mockedTrpc.mutateAsync
+ .mockRejectedValueOnce(
+ Object.assign(new Error('expired'), {
+ data: { upstreamCode: 'COMMAND_EXPIRED' },
+ })
+ )
+ .mockResolvedValueOnce({
+ status: 'ready',
+ result: { protocolVersion: 1, sessionId: SESSION_ID, promptStarted: true },
+ });
+ const result = renderHook({
+ fence: FENCE_A,
+ catalog: CATALOG,
+ selectedAgentSlug: 'build',
+ selectedModel: { providerID: 'kilo', modelID: 'claude-opus-4-7', variant: 'max' },
+ prompt: 'Build me a thing',
+ });
+
+ await result.state.submit();
+ await result.state.retry();
+
+ const first = (
+ callArgument(mockedTrpc.mutateAsync.mock.calls, 0) as {
+ request: { requestId: string };
+ }
+ ).request.requestId;
+ const second = (
+ callArgument(mockedTrpc.mutateAsync.mock.calls, 1) as {
+ request: { requestId: string };
+ }
+ ).request.requestId;
+ expect(first).toBe(second);
+ });
+
+ it('fence change clears the requestId and the next attempt allocates a new UUID', async () => {
+ mockedCrypto.randomUUID
+ .mockReturnValueOnce('00000000-0000-4000-8000-0000000000aa')
+ .mockReturnValueOnce('00000000-0000-4000-8000-0000000000bb');
+ mockedTrpc.mutateAsync.mockResolvedValue({
+ status: 'ready',
+ result: { protocolVersion: 1, sessionId: SESSION_ID, promptStarted: true },
+ });
+ const result = renderHook({
+ fence: FENCE_A,
+ catalog: CATALOG,
+ selectedAgentSlug: 'build',
+ selectedModel: { providerID: 'kilo', modelID: 'claude-opus-4-7', variant: 'max' },
+ prompt: 'Build me a thing',
+ });
+ await result.state.submit();
+
+ result.rerender({ fence: FENCE_B });
+ await result.state.submit();
+
+ const first = (
+ callArgument(mockedTrpc.mutateAsync.mock.calls, 0) as {
+ request: { requestId: string };
+ }
+ ).request.requestId;
+ const second = (
+ callArgument(mockedTrpc.mutateAsync.mock.calls, 1) as {
+ request: { requestId: string };
+ }
+ ).request.requestId;
+ expect(first).toBe('00000000-0000-4000-8000-0000000000aa');
+ expect(second).toBe('00000000-0000-4000-8000-0000000000bb');
+ });
+
+ it('passes the typed SESSION_DETAIL_PATH to router.replace (a real string, not null)', async () => {
+ mockedTrpc.mutateAsync.mockResolvedValueOnce({
+ status: 'ready',
+ result: { protocolVersion: 1, sessionId: SESSION_ID, promptStarted: true },
+ });
+ const result = renderHook({
+ fence: FENCE_A,
+ catalog: CATALOG,
+ selectedAgentSlug: 'build',
+ selectedModel: { providerID: 'kilo', modelID: 'claude-opus-4-7', variant: 'max' },
+ prompt: 'Build me a thing',
+ });
+
+ await result.state.submit();
+
+ expect(mockedRouter.replace).toHaveBeenCalledTimes(1);
+ const replaceArg = mockedRouter.replace.mock.calls[0]?.[0];
+ expect(typeof replaceArg).toBe('string');
+ expect(replaceArg).toBe(SESSION_DETAIL_PATH);
+ });
+
+ it('sets isSubmitting true and disables Start before the mutation resolves', () => {
+ const pending = new Promise(() => {
+ // Intentionally never resolves so the test can observe the submitting state.
+ });
+ mockedTrpc.mutateAsync.mockReturnValue(pending);
+ const result = renderHook({
+ fence: FENCE_A,
+ catalog: CATALOG,
+ selectedAgentSlug: 'build',
+ selectedModel: { providerID: 'kilo', modelID: 'claude-opus-4-7', variant: 'max' },
+ prompt: 'Build me a thing',
+ });
+ expect(result.state.isSubmitting).toBe(false);
+ expect(result.state.canSubmit).toBe(true);
+
+ const submitPromise = result.state.submit();
+ result.rerender({});
+ result.refresh();
+
+ expect(result.state.isSubmitting).toBe(true);
+ expect(result.state.canSubmit).toBe(false);
+ expect(result.state.phase).toBe('submitting');
+ void submitPromise;
+ });
+
+ it('uses the readiness query for check-again reads (no new create)', async () => {
+ vi.useFakeTimers();
+ try {
+ // Force a readiness-timeout: not-ready happy + pending pollReadiness.
+ mockedTrpc.mutateAsync.mockResolvedValueOnce({
+ status: 'session_not_ready',
+ code: 'SESSION_NOT_READY',
+ result: { protocolVersion: 1, sessionId: SESSION_ID, promptStarted: true },
+ });
+ mockedTrpc.query.mockResolvedValue({ status: 'pending' });
+ const result = renderHook({
+ fence: FENCE_A,
+ catalog: CATALOG,
+ selectedAgentSlug: 'build',
+ selectedModel: { providerID: 'kilo', modelID: 'claude-opus-4-7', variant: 'max' },
+ prompt: 'Build me a thing',
+ });
+
+ // Advance timers while the submit's poll loop runs.
+ const submitPromise = result.state.submit();
+ await vi.runAllTimersAsync();
+ await submitPromise;
+ result.refresh();
+
+ const createCountBefore = mockedTrpc.mutateAsync.mock.calls.length;
+ const checkAgainPromise = result.state.checkAgain();
+ await vi.runAllTimersAsync();
+ await checkAgainPromise;
+ const createCountAfter = mockedTrpc.mutateAsync.mock.calls.length;
+ expect(createCountAfter).toBe(createCountBefore);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it('keeps the submitting state across a rerender that does not change the orchestrator identity', () => {
+ const pending = new Promise(() => {
+ // Intentionally never resolves so the test can observe the submitting state.
+ });
+ mockedTrpc.mutateAsync.mockReturnValue(pending);
+ const result = renderHook({
+ fence: FENCE_A,
+ catalog: CATALOG,
+ selectedAgentSlug: 'build',
+ selectedModel: { providerID: 'kilo', modelID: 'claude-opus-4-7', variant: 'max' },
+ prompt: 'Build me a thing',
+ });
+
+ const submitPromise = result.state.submit();
+ result.rerender({ prompt: 'Build me a different thing' });
+ result.refresh();
+
+ expect(result.state.phase).toBe('submitting');
+ expect(result.state.isSubmitting).toBe(true);
+ void submitPromise;
+ });
+
+ it('unsubscribes from the old orchestrator when the catalog identity changes', async () => {
+ vi.useFakeTimers();
+ try {
+ // Force a readiness-timeout so the orchestrator lands in recovery and we
+ // can capture a reference to it before changing the catalog identity.
+ mockedTrpc.mutateAsync.mockResolvedValueOnce({
+ status: 'session_not_ready',
+ code: 'SESSION_NOT_READY',
+ result: { protocolVersion: 1, sessionId: SESSION_ID, promptStarted: true },
+ });
+ mockedTrpc.query.mockResolvedValue({ status: 'pending' });
+
+ const result = renderHook({
+ fence: FENCE_A,
+ catalog: CATALOG,
+ selectedAgentSlug: 'build',
+ selectedModel: { providerID: 'kilo', modelID: 'claude-opus-4-7', variant: 'max' },
+ prompt: 'Build me a thing',
+ });
+
+ const submitPromise = result.state.submit();
+ await vi.runAllTimersAsync();
+ await submitPromise;
+ result.refresh();
+
+ expect(result.state.phase).toBe('recovery');
+ const oldOrchestrator = getActiveOrchestratorFromRefs(result.dispatcherState);
+ expect(oldOrchestrator).not.toBeNull();
+
+ const CATALOG_B: LocalRuntimeCatalog = {
+ ...CATALOG,
+ agents: [{ slug: 'review', name: 'Review' }],
+ };
+ result.rerender({ catalog: CATALOG_B });
+ result.refresh();
+
+ expect(result.state.phase).toBe('idle');
+ expect(result.state.recovery).toBeNull();
+
+ // Trigger a state change on the old orchestrator. If the subscription
+ // cleanup did not run, the hook state would leak back to recovery.
+ mockedTrpc.query.mockResolvedValueOnce({ status: 'pending' });
+ await oldOrchestrator?.checkAgain();
+ result.refresh();
+
+ expect(result.state.phase).toBe('idle');
+ expect(result.state.recovery).toBeNull();
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+});
diff --git a/apps/mobile/src/lib/hooks/use-local-session-create.ts b/apps/mobile/src/lib/hooks/use-local-session-create.ts
new file mode 100644
index 0000000000..4b4778b322
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/use-local-session-create.ts
@@ -0,0 +1,336 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import * as Crypto from 'expo-crypto';
+import * as Haptics from 'expo-haptics';
+import { type Href, useRouter } from 'expo-router';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { toast } from 'sonner-native';
+
+import { captureEvent } from '@/lib/analytics/posthog';
+import { invalidateAgentSessionQueries } from '@/lib/agent-session-cache';
+import { trpcClient, useTRPC } from '@/lib/trpc';
+
+import { type LocalSessionCreateRecovery } from './local-session-create-errors';
+import {
+ type LocalSessionCreateOrchestrator,
+ type LocalSessionCreateOrchestratorState,
+} from './local-session-create-orchestrator-shared';
+import { createLocalSessionCreateOrchestrator } from './use-local-session-create-orchestrator';
+import {
+ buildLocalSessionCreateRequest,
+ type BuiltLocalSessionCreateRequest,
+} from './local-session-create-request';
+import { type LocalRuntimeCatalog, type LocalRuntimeFence } from './local-runtime-catalog-types';
+import {
+ createLocalSessionRequestIdStore,
+ type LocalSessionRequestIdStore,
+ type RequestIdUuid,
+} from './local-session-request-id';
+
+const POLL_INTERVAL_MS = 2000;
+const POLL_MAX_MS = 60_000;
+
+const showErrorNoOp = () => {
+ void 0;
+};
+
+const noOpCleanup = () => {
+ void 0;
+};
+
+function notificationHapticFeedbackType(
+ kind: 'success' | 'warning' | 'error'
+): Haptics.NotificationFeedbackType {
+ if (kind === 'success') {
+ return Haptics.NotificationFeedbackType.Success;
+ }
+ if (kind === 'warning') {
+ return Haptics.NotificationFeedbackType.Warning;
+ }
+ return Haptics.NotificationFeedbackType.Error;
+}
+
+type Ref = { current: T };
+
+type UseLocalSessionCreateInput = {
+ fence: LocalRuntimeFence | null;
+ catalog: LocalRuntimeCatalog | null;
+ selectedAgentSlug: string | null;
+ selectedModel: { providerID: string; modelID: string; variant: string } | null;
+ promptRef: Ref;
+ hasPromptRef: Ref;
+};
+
+type LocalSessionCreateViewModel = {
+ phase: LocalSessionCreateOrchestratorState['phase'];
+ recovery: LocalSessionCreateRecovery | null;
+ canSubmit: boolean;
+ canRetry: boolean;
+ canCheckAgain: boolean;
+ isSubmitting: boolean;
+};
+
+type LocalSessionCreateActions = {
+ submit: () => Promise;
+ retry: () => Promise;
+ checkAgain: () => Promise;
+};
+
+type LocalSessionCreateHook = LocalSessionCreateViewModel & LocalSessionCreateActions;
+
+export const IDLE_STATE: LocalSessionCreateOrchestratorState = { phase: 'idle' };
+
+/**
+ * React binding for the `localRuntimeControl.createAndRun` submission
+ * orchestrator.
+ *
+ * The hook is a thin React shell: every state transition is owned by the
+ * pure `createLocalSessionCreateOrchestrator` state machine, and every
+ * side effect is wired through the orchestrator's dependency seams. The
+ * hook is responsible for the four pieces of state that only React can
+ * own: the persistent `LocalSessionRequestIdStore`, the long-lived
+ * `mutateAsync` from `useMutation`, the `QueryClient` invalidation handle,
+ * and the `router.replace` for the post-create detail navigation.
+ *
+ * The hook does NOT keep a `prompt` field in React state. The user types
+ * into a ref-controlled `TextInput` (see the `local-session-config-screen`
+ * integration) and the hook reads the ref at submit time so a keystroke
+ * does not rebuild the orchestrator and never races an in-flight request.
+ *
+ * Error toasts: the mutation's `onError` is the sole upstream toast
+ * source — wired to `toast.error(error.message)`. The orchestrator's
+ * `showError` seam is wired to a no-op at this layer so the orchestrator
+ * can still receive its contract (single error surface) without a second
+ * toast. Validation errors that never reach the mutation are surfaced
+ * through the recovery state's `message` and rendered as a recovery
+ * panel; they never trigger a toast.
+ */
+export function useLocalSessionCreate(input: UseLocalSessionCreateInput): LocalSessionCreateHook {
+ const { fence, catalog, selectedAgentSlug, selectedModel, promptRef, hasPromptRef } = input;
+
+ const trpc = useTRPC();
+ const queryClient = useQueryClient();
+ const router = useRouter();
+
+ // One persistent requestId store per hook instance. The store survives
+ // every render — the orchestrator is allowed to look up the same
+ // requestId across submits as long as the fence is unchanged.
+ const requestIdStoreRef = useRef(
+ createLocalSessionRequestIdStore({
+ generateUuid: () => Crypto.randomUUID() as RequestIdUuid,
+ })
+ );
+
+ const mutation = useMutation(
+ trpc.localRuntimeControl.createAndRun.mutationOptions({
+ onError: error => {
+ toast.error(error.message);
+ },
+ })
+ );
+
+ // The orchestrator is created when the user actually attempts a submit
+ // (so the prompt snapshot is the one at submit time). The instance is
+ // preserved across subsequent submits/retry/checkAgain calls as long as
+ // the fence/catalog/agent/model triple is unchanged. A change in any of
+ // those four invalidates the orchestrator and clears the requestId
+ // binding for the old fence.
+ const orchestratorRef = useRef<{
+ orchestrator: LocalSessionCreateOrchestrator;
+ fence: LocalRuntimeFence;
+ catalog: LocalRuntimeCatalog;
+ selectedAgentSlug: string;
+ selectedModel: { providerID: string; modelID: string; variant: string };
+ } | null>(null);
+
+ const [orchestratorState, setOrchestratorState] =
+ useState(IDLE_STATE);
+
+ // Keep the current orchestrator identity in React state so the subscription
+ // effect below depends on a stable, explicit value instead of a mutable ref.
+ const [activeOrchestrator, setActiveOrchestrator] =
+ useState(null);
+
+ const deps = useMemo(
+ () => ({
+ requestIdStore: requestIdStoreRef.current,
+ buildRequest: buildLocalSessionCreateRequest,
+ createAndRun: async (request: BuiltLocalSessionCreateRequest) => {
+ const result = await mutation.mutateAsync(request);
+ return result;
+ },
+ pollReadiness: async ({ sessionId }: { sessionId: string }) => {
+ const probe = await trpcClient.cliSessionsV2.readiness.query({ session_id: sessionId });
+ return probe;
+ },
+ sleep: async (ms: number) => {
+ await new Promise(resolve => {
+ setTimeout(resolve, ms);
+ });
+ },
+ invalidateCaches: async () => {
+ await invalidateAgentSessionQueries(queryClient, {
+ cliSessionsV2: {
+ list: { pathFilter: () => trpc.cliSessionsV2.list.pathFilter() },
+ recentRepositories: {
+ pathFilter: () => trpc.cliSessionsV2.recentRepositories.pathFilter(),
+ },
+ search: { pathFilter: () => trpc.cliSessionsV2.search.pathFilter() },
+ },
+ activeSessions: {
+ list: { pathFilter: () => trpc.activeSessions.list.pathFilter() },
+ },
+ });
+ },
+ captureEvent: (name: string, properties: Record) => {
+ captureEvent(name, properties as Record);
+ },
+ notificationHaptic: (kind: 'success' | 'warning' | 'error') => {
+ void Haptics.notificationAsync(notificationHapticFeedbackType(kind));
+ },
+ navigate: (path: string) => {
+ router.replace(path as Href);
+ },
+ // The orchestrator still receives its contract surface through
+ // `showError`, but the mutation's `onError` is the sole toast source
+ // for createAndRun failures. Wiring `showError` to a no-op prevents a
+ // duplicate toast while keeping the orchestrator's error recovery path
+ // intact.
+ showError: showErrorNoOp,
+ showInfo: (message: string) => {
+ toast.info(message);
+ },
+ pollIntervalMs: POLL_INTERVAL_MS,
+ pollMaxMs: POLL_MAX_MS,
+ }),
+ [mutation, queryClient, router, trpc]
+ );
+
+ // Detect a change in the selection triple. When any field changes the
+ // orchestrator's view of the world is no longer current, so we discard
+ // the orchestrator and clear the requestId binding for the old fence.
+ useEffect(() => {
+ if (!fence || !catalog || !selectedAgentSlug || !selectedModel) {
+ return;
+ }
+ const stored = orchestratorRef.current;
+ if (stored === null) {
+ return;
+ }
+ const fenceChanged =
+ stored.fence.runtimeId !== fence.runtimeId ||
+ stored.fence.connectionId !== fence.connectionId;
+ if (fenceChanged) {
+ requestIdStoreRef.current.clearByFence(stored.fence);
+ orchestratorRef.current = null;
+ setActiveOrchestrator(null);
+ setOrchestratorState(IDLE_STATE);
+ return;
+ }
+ if (
+ stored.catalog !== catalog ||
+ stored.selectedAgentSlug !== selectedAgentSlug ||
+ stored.selectedModel.providerID !== selectedModel.providerID ||
+ stored.selectedModel.modelID !== selectedModel.modelID ||
+ stored.selectedModel.variant !== selectedModel.variant
+ ) {
+ orchestratorRef.current = null;
+ setActiveOrchestrator(null);
+ setOrchestratorState(IDLE_STATE);
+ }
+ }, [fence, catalog, selectedAgentSlug, selectedModel]);
+
+ // Subscribe to the active orchestrator's state. The orchestrator is
+ // created lazily on the first submit, so the effect is a no-op before
+ // the user attempts anything. The dependency is the orchestrator identity
+ // kept in React state, not a mutable ref.
+ useEffect(() => {
+ if (activeOrchestrator === null) {
+ return noOpCleanup;
+ }
+ return activeOrchestrator.subscribe(setOrchestratorState);
+ }, [activeOrchestrator]);
+
+ const ensureOrchestrator = useCallback(() => {
+ if (!fence || !catalog || !selectedAgentSlug || !selectedModel) {
+ return null;
+ }
+ const stored = orchestratorRef.current;
+ if (
+ stored !== null &&
+ stored.fence.runtimeId === fence.runtimeId &&
+ stored.fence.connectionId === fence.connectionId &&
+ stored.catalog === catalog &&
+ stored.selectedAgentSlug === selectedAgentSlug &&
+ stored.selectedModel.providerID === selectedModel.providerID &&
+ stored.selectedModel.modelID === selectedModel.modelID &&
+ stored.selectedModel.variant === selectedModel.variant
+ ) {
+ return stored.orchestrator;
+ }
+ const orchestrator = createLocalSessionCreateOrchestrator({
+ deps,
+ fence,
+ catalog,
+ selectedAgentSlug,
+ selectedModel,
+ getPrompt: () => promptRef.current,
+ });
+ orchestratorRef.current = {
+ orchestrator,
+ fence,
+ catalog,
+ selectedAgentSlug,
+ selectedModel,
+ };
+ setActiveOrchestrator(orchestrator);
+ // Re-subscribe to the new orchestrator's state.
+ setOrchestratorState(orchestrator.getState());
+ return orchestrator;
+ }, [catalog, deps, fence, promptRef, selectedAgentSlug, selectedModel]);
+
+ const submit = useCallback(async () => {
+ const orchestrator = ensureOrchestrator();
+ if (!orchestrator) {
+ return;
+ }
+ await orchestrator.submit();
+ }, [ensureOrchestrator]);
+
+ const retry = useCallback(async () => {
+ const orchestrator = orchestratorRef.current?.orchestrator ?? ensureOrchestrator();
+ if (!orchestrator) {
+ return;
+ }
+ await orchestrator.retry();
+ }, [ensureOrchestrator]);
+
+ const checkAgain = useCallback(async () => {
+ const orchestrator = orchestratorRef.current?.orchestrator ?? ensureOrchestrator();
+ if (!orchestrator) {
+ return;
+ }
+ await orchestrator.checkAgain();
+ }, [ensureOrchestrator]);
+
+ const isSelectionComplete =
+ fence !== null && catalog !== null && selectedAgentSlug !== null && selectedModel !== null;
+ const isSubmitting = orchestratorState.phase === 'submitting';
+ const canSubmit = isSelectionComplete && hasPromptRef.current && !isSubmitting;
+ const canRetry =
+ orchestratorState.phase === 'recovery' && orchestratorState.recovery.ctaLabel === 'Retry';
+ const canCheckAgain =
+ orchestratorState.phase === 'recovery' &&
+ orchestratorState.recovery.kind === 'readiness-timeout';
+
+ return {
+ phase: orchestratorState.phase,
+ recovery: orchestratorState.phase === 'recovery' ? orchestratorState.recovery : null,
+ canSubmit,
+ canRetry,
+ canCheckAgain,
+ isSubmitting,
+ submit,
+ retry,
+ checkAgain,
+ };
+}
diff --git a/apps/mobile/src/lib/picker-bridge-runtime-agent.test.ts b/apps/mobile/src/lib/picker-bridge-runtime-agent.test.ts
new file mode 100644
index 0000000000..315088b307
--- /dev/null
+++ b/apps/mobile/src/lib/picker-bridge-runtime-agent.test.ts
@@ -0,0 +1,197 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { type LocalRuntimeCatalog } from '@/lib/hooks/local-runtime-catalog-types';
+
+import {
+ areRuntimeCatalogAgentSelectionScopesEqual,
+ clearRuntimeCatalogAgentPickerBridge,
+ commitRuntimeCatalogAgentPickerSelection,
+ getRuntimeCatalogAgentPickerBridge,
+ resolveRuntimeCatalogAgentSelection,
+ type RuntimeCatalogAgentSelection,
+ type RuntimeCatalogAgentSelectionScope,
+ setRuntimeCatalogAgentPickerBridge,
+} from './picker-bridge';
+
+const FENCE = {
+ runtimeId: '11111111-1111-4111-8111-111111111111',
+ connectionId: 'cli-a',
+};
+const CATALOG_GENERATION = {};
+
+function makeCatalog(): LocalRuntimeCatalog {
+ return {
+ protocolVersion: 1,
+ models: {
+ protocolVersion: 1,
+ providers: [
+ {
+ id: 'anthropic',
+ name: 'Anthropic',
+ models: [{ id: 'claude-1', name: 'Claude 1', variants: ['low', 'high'] }],
+ },
+ ],
+ defaultModel: { providerID: 'anthropic', modelID: 'claude-1' },
+ truncated: false,
+ },
+ agents: [
+ { slug: 'build', name: 'Build', description: 'Plans and writes code.' },
+ { slug: 'plan', name: 'Plan' },
+ ],
+ defaultAgent: 'build',
+ };
+}
+
+function makeBridge(
+ overrides: Partial<{
+ catalog: LocalRuntimeCatalog;
+ currentFence: { runtimeId: string; connectionId: string };
+ currentValue: string;
+ selectionScope: RuntimeCatalogAgentSelectionScope;
+ isSelectionCurrent: (scope: RuntimeCatalogAgentSelectionScope) => boolean;
+ onSelect: (selection: RuntimeCatalogAgentSelection) => void;
+ }> = {}
+) {
+ return {
+ catalog: makeCatalog(),
+ currentFence: FENCE,
+ currentValue: 'build',
+ selectionScope: {
+ runtimeId: FENCE.runtimeId,
+ connectionId: FENCE.connectionId,
+ protocol: 'v1' as const,
+ catalogGenerationIdentity: CATALOG_GENERATION,
+ },
+ isSelectionCurrent: vi.fn<(scope: RuntimeCatalogAgentSelectionScope) => boolean>(() => true),
+ onSelect: vi.fn<(selection: RuntimeCatalogAgentSelection) => void>(),
+ ...overrides,
+ };
+}
+
+describe('runtime catalog agent picker bridge', () => {
+ beforeEach(() => {
+ clearRuntimeCatalogAgentPickerBridge();
+ });
+
+ it('preserves the exact slug, name, and description when resolving a draft selection', () => {
+ const onSelect = vi.fn<(selection: RuntimeCatalogAgentSelection) => void>();
+ setRuntimeCatalogAgentPickerBridge({
+ ...makeBridge(),
+ onSelect: selection => {
+ onSelect(selection);
+ },
+ });
+ const bridge = getRuntimeCatalogAgentPickerBridge();
+ if (!bridge) {
+ throw new Error('Expected runtime catalog agent picker bridge');
+ }
+
+ const resolved = resolveRuntimeCatalogAgentSelection(bridge, { slug: 'build' });
+ expect(resolved).toEqual({
+ slug: 'build',
+ name: 'Build',
+ description: 'Plans and writes code.',
+ });
+
+ const noDescription = resolveRuntimeCatalogAgentSelection(bridge, { slug: 'plan' });
+ if (!noDescription) {
+ throw new Error('Expected noDescription to be non-null');
+ }
+ expect(noDescription).toEqual({
+ slug: 'plan',
+ name: 'Plan',
+ });
+ expect('description' in noDescription).toBe(false);
+ });
+
+ it('returns null when the slug is not in the catalog', () => {
+ const bridge = makeBridge();
+ expect(resolveRuntimeCatalogAgentSelection(bridge, { slug: 'unknown' })).toBeNull();
+ });
+
+ it('treats runtimeId, connectionId, and catalog generation as scope fields', () => {
+ const scope: RuntimeCatalogAgentSelectionScope = {
+ runtimeId: FENCE.runtimeId,
+ connectionId: FENCE.connectionId,
+ protocol: 'v1',
+ catalogGenerationIdentity: CATALOG_GENERATION,
+ };
+ expect(areRuntimeCatalogAgentSelectionScopesEqual(scope, scope)).toBe(true);
+ expect(
+ areRuntimeCatalogAgentSelectionScopesEqual(scope, { ...scope, runtimeId: 'other' })
+ ).toBe(false);
+ expect(
+ areRuntimeCatalogAgentSelectionScopesEqual(scope, { ...scope, connectionId: 'cli-a-new' })
+ ).toBe(false);
+ expect(
+ areRuntimeCatalogAgentSelectionScopesEqual(scope, { ...scope, catalogGenerationIdentity: {} })
+ ).toBe(false);
+ });
+
+ it('commits an agent selection when the scope, catalog, and fence are all current', () => {
+ const onSelect = vi.fn<(selection: RuntimeCatalogAgentSelection) => void>();
+ const bridge = { ...makeBridge(), onSelect };
+
+ expect(commitRuntimeCatalogAgentPickerSelection(bridge, { slug: 'plan' })).toBe(true);
+ expect(onSelect).toHaveBeenCalledWith({ slug: 'plan', name: 'Plan' });
+ });
+
+ it('rejects a tap when the scope is no longer current', () => {
+ const onSelect = vi.fn<(selection: RuntimeCatalogAgentSelection) => void>();
+ const isSelectionCurrent = vi.fn<(scope: RuntimeCatalogAgentSelectionScope) => boolean>(
+ () => false
+ );
+ const bridge = { ...makeBridge({ isSelectionCurrent }), onSelect };
+
+ expect(commitRuntimeCatalogAgentPickerSelection(bridge, { slug: 'plan' })).toBe(false);
+ expect(onSelect).not.toHaveBeenCalled();
+ expect(isSelectionCurrent).toHaveBeenCalledWith(bridge.selectionScope);
+ });
+
+ it('rejects a tap when the scope fence diverges from the catalog fence (reconnect)', () => {
+ const onSelect = vi.fn<(selection: RuntimeCatalogAgentSelection) => void>();
+ const bridge = {
+ ...makeBridge({
+ selectionScope: {
+ runtimeId: FENCE.runtimeId,
+ connectionId: 'cli-a-new',
+ protocol: 'v1',
+ catalogGenerationIdentity: CATALOG_GENERATION,
+ },
+ isSelectionCurrent: () => true,
+ }),
+ onSelect,
+ };
+
+ expect(commitRuntimeCatalogAgentPickerSelection(bridge, { slug: 'plan' })).toBe(false);
+ expect(onSelect).not.toHaveBeenCalled();
+ });
+
+ it('rejects a tap when the scope runtimeId diverges from the catalog fence (disconnect)', () => {
+ const onSelect = vi.fn<(selection: RuntimeCatalogAgentSelection) => void>();
+ const bridge = {
+ ...makeBridge({
+ currentFence: FENCE,
+ selectionScope: {
+ runtimeId: '33333333-3333-4333-8333-333333333333',
+ connectionId: FENCE.connectionId,
+ protocol: 'v1',
+ catalogGenerationIdentity: CATALOG_GENERATION,
+ },
+ isSelectionCurrent: () => true,
+ }),
+ onSelect,
+ };
+
+ expect(commitRuntimeCatalogAgentPickerSelection(bridge, { slug: 'plan' })).toBe(false);
+ expect(onSelect).not.toHaveBeenCalled();
+ });
+
+ it('rejects a tap when the slug is not in the catalog', () => {
+ const onSelect = vi.fn<(selection: RuntimeCatalogAgentSelection) => void>();
+ const bridge = { ...makeBridge(), onSelect };
+
+ expect(commitRuntimeCatalogAgentPickerSelection(bridge, { slug: 'unknown' })).toBe(false);
+ expect(onSelect).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/mobile/src/lib/picker-bridge-runtime-agent.ts b/apps/mobile/src/lib/picker-bridge-runtime-agent.ts
new file mode 100644
index 0000000000..128a950d74
--- /dev/null
+++ b/apps/mobile/src/lib/picker-bridge-runtime-agent.ts
@@ -0,0 +1,121 @@
+import {
+ type LocalRuntimeCatalog,
+ type LocalRuntimeFence,
+} from '@/lib/hooks/local-runtime-catalog-types';
+
+/**
+ * Runtime-catalog agent picker bridge: the screen publishes the catalog
+ * object, the exact fence the catalog was fetched for, the current agent
+ * slug, and a draft selection scope. The picker UI calls
+ * `commitRuntimeCatalogAgentPickerSelection` when the user taps a row.
+ *
+ * The scope combines the runtime identity (the fence the catalog came
+ * from), the catalog's protocol version, and an opaque
+ * `catalogGenerationIdentity` token that the screen refreshes on every
+ * fetch. `commit` rejects when any of the three guard inputs — scope,
+ * catalog object, fence — no longer match the live state, so a stale tap
+ * from a detached picker can never reach the mutation hook.
+ *
+ * Unlike the model picker, the agent picker never converts the slug into
+ * an `AgentMode`: the screen is the only consumer that decides how a
+ * local-runtime agent relates to the cloud session-create surface, and
+ * the bridge hands back the exact catalog slug the runtime advertised.
+ */
+export type RuntimeCatalogAgentSelectionScope = {
+ runtimeId: string;
+ connectionId: string;
+ protocol: 'v1';
+ catalogGenerationIdentity: object | null;
+};
+
+export type RuntimeCatalogAgentSelection = {
+ slug: string;
+ name: string;
+ description?: string;
+};
+
+type RuntimeCatalogAgentPickerBridge = {
+ catalog: LocalRuntimeCatalog;
+ currentFence: LocalRuntimeFence;
+ currentValue: string;
+ selectionScope: RuntimeCatalogAgentSelectionScope;
+ isSelectionCurrent: (scope: RuntimeCatalogAgentSelectionScope) => boolean;
+ onSelect: (selection: RuntimeCatalogAgentSelection) => void;
+};
+
+export function areRuntimeCatalogAgentSelectionScopesEqual(
+ left: RuntimeCatalogAgentSelectionScope,
+ right: RuntimeCatalogAgentSelectionScope
+): boolean {
+ return (
+ left.runtimeId === right.runtimeId &&
+ left.connectionId === right.connectionId &&
+ left.catalogGenerationIdentity === right.catalogGenerationIdentity
+ );
+}
+
+let runtimeCatalogAgentBridge: RuntimeCatalogAgentPickerBridge | null = null;
+
+/**
+ * Resolve a draft agent selection against the published catalog. Returns
+ * `null` when the agent slug is not in the catalog. The picker's commit
+ * helper performs the additional scope/catalog/fence staleness checks.
+ */
+export function resolveRuntimeCatalogAgentSelection(
+ bridge: RuntimeCatalogAgentPickerBridge,
+ draft: { slug: string }
+): RuntimeCatalogAgentSelection | null {
+ const agent = bridge.catalog.agents.find(candidate => candidate.slug === draft.slug);
+ if (!agent) {
+ return null;
+ }
+ return {
+ slug: agent.slug,
+ name: agent.name,
+ ...(agent.description !== undefined ? { description: agent.description } : {}),
+ };
+}
+
+/**
+ * Commit an agent picker tap. Discards the tap when:
+ *
+ * - the draft scope is no longer current (`isSelectionCurrent` returns
+ * `false` — e.g. the user scrolled away and the screen re-published a
+ * fresh scope),
+ * - the published catalog object identity has changed (a refetch
+ * superseded the picker), or
+ * - the exact fence the catalog was fetched for no longer matches the
+ * scope's runtime identity (runtime reconnect or disconnect).
+ *
+ * Returns `true` only when the bridge's `onSelect` was actually invoked.
+ */
+export function commitRuntimeCatalogAgentPickerSelection(
+ bridge: RuntimeCatalogAgentPickerBridge,
+ draft: { slug: string }
+): boolean {
+ if (!bridge.isSelectionCurrent(bridge.selectionScope)) {
+ return false;
+ }
+ if (
+ bridge.currentFence.runtimeId !== bridge.selectionScope.runtimeId ||
+ bridge.currentFence.connectionId !== bridge.selectionScope.connectionId
+ ) {
+ return false;
+ }
+ const selection = resolveRuntimeCatalogAgentSelection(bridge, draft);
+ if (!selection) {
+ return false;
+ }
+ bridge.onSelect(selection);
+ return true;
+}
+
+export function setRuntimeCatalogAgentPickerBridge(bridge: RuntimeCatalogAgentPickerBridge) {
+ runtimeCatalogAgentBridge = bridge;
+}
+export function getRuntimeCatalogAgentPickerBridge() {
+ return runtimeCatalogAgentBridge;
+}
+export function clearRuntimeCatalogAgentPickerBridge() {
+ runtimeCatalogAgentBridge = null;
+}
diff --git a/apps/mobile/src/lib/picker-bridge-runtime-catalog.test.ts b/apps/mobile/src/lib/picker-bridge-runtime-catalog.test.ts
new file mode 100644
index 0000000000..21c718df18
--- /dev/null
+++ b/apps/mobile/src/lib/picker-bridge-runtime-catalog.test.ts
@@ -0,0 +1,286 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { type LocalRuntimeCatalog } from '@/lib/hooks/local-runtime-catalog-types';
+
+import {
+ areRuntimeCatalogModelSelectionScopesEqual,
+ clearRuntimeCatalogModelPickerBridge,
+ commitRuntimeCatalogModelPickerSelection,
+ getRuntimeCatalogModelPickerBridge,
+ resolveRuntimeCatalogModelSelection,
+ type RuntimeCatalogModelSelection,
+ type RuntimeCatalogModelSelectionScope,
+ setRuntimeCatalogModelPickerBridge,
+} from './picker-bridge';
+
+const FENCE = {
+ runtimeId: '11111111-1111-4111-8111-111111111111',
+ connectionId: 'cli-a',
+};
+const CATALOG_GENERATION = {};
+
+function makeCatalog(): LocalRuntimeCatalog {
+ return {
+ protocolVersion: 1,
+ models: {
+ protocolVersion: 1,
+ providers: [
+ {
+ id: 'anthropic',
+ name: 'Anthropic',
+ models: [
+ { id: 'claude-1', name: 'Claude 1', variants: ['low', 'high'] },
+ { id: 'claude-2', name: 'Claude 2', variants: ['low', 'high'] },
+ ],
+ },
+ {
+ id: 'openai',
+ name: 'OpenAI',
+ models: [{ id: 'gpt-1', name: 'GPT 1', variants: ['low'] }],
+ },
+ ],
+ defaultModel: { providerID: 'anthropic', modelID: 'claude-1' },
+ truncated: false,
+ },
+ agents: [
+ { slug: 'build', name: 'Build' },
+ { slug: 'plan', name: 'Plan' },
+ ],
+ defaultAgent: 'build',
+ };
+}
+
+function makeBridge(
+ overrides: Partial<{
+ catalog: LocalRuntimeCatalog;
+ currentFence: { runtimeId: string; connectionId: string };
+ currentValue: string;
+ currentVariant: string;
+ selectionScope: RuntimeCatalogModelSelectionScope;
+ isSelectionCurrent: (scope: RuntimeCatalogModelSelectionScope) => boolean;
+ onSelect: (selection: RuntimeCatalogModelSelection) => void;
+ }> = {}
+) {
+ return {
+ catalog: makeCatalog(),
+ currentFence: FENCE,
+ currentValue: 'claude-1',
+ currentVariant: 'low',
+ selectionScope: {
+ runtimeId: FENCE.runtimeId,
+ connectionId: FENCE.connectionId,
+ protocol: 'v1' as const,
+ catalogGenerationIdentity: CATALOG_GENERATION,
+ },
+ isSelectionCurrent: vi.fn<(scope: RuntimeCatalogModelSelectionScope) => boolean>(() => true),
+ onSelect: vi.fn<(selection: RuntimeCatalogModelSelection) => void>(),
+ ...overrides,
+ };
+}
+
+describe('runtime catalog model picker bridge', () => {
+ beforeEach(() => {
+ clearRuntimeCatalogModelPickerBridge();
+ });
+
+ it('preserves exact providerID, modelID, and variant when resolving a draft selection', () => {
+ const onSelect = vi.fn<(selection: RuntimeCatalogModelSelection) => void>();
+ setRuntimeCatalogModelPickerBridge({
+ ...makeBridge(),
+ onSelect: selection => {
+ onSelect(selection);
+ },
+ });
+ const bridge = getRuntimeCatalogModelPickerBridge();
+ if (!bridge) {
+ throw new Error('Expected runtime catalog model picker bridge');
+ }
+
+ const resolved = resolveRuntimeCatalogModelSelection(bridge, {
+ providerID: 'anthropic',
+ modelID: 'claude-2',
+ variant: 'high',
+ });
+ expect(resolved).toEqual({
+ providerID: 'anthropic',
+ modelID: 'claude-2',
+ variant: 'high',
+ });
+ });
+
+ it('falls back to the first variant of the model when the requested variant is unknown', () => {
+ const bridge = makeBridge();
+ const resolved = resolveRuntimeCatalogModelSelection(bridge, {
+ providerID: 'anthropic',
+ modelID: 'claude-1',
+ variant: 'gone',
+ });
+ expect(resolved).toEqual({
+ providerID: 'anthropic',
+ modelID: 'claude-1',
+ variant: 'low',
+ });
+ });
+
+ it('returns null when the provider is not in the catalog', () => {
+ const bridge = makeBridge();
+ expect(
+ resolveRuntimeCatalogModelSelection(bridge, {
+ providerID: 'missing',
+ modelID: 'claude-1',
+ variant: 'low',
+ })
+ ).toBeNull();
+ });
+
+ it('returns null when the model is not in the provider', () => {
+ const bridge = makeBridge();
+ expect(
+ resolveRuntimeCatalogModelSelection(bridge, {
+ providerID: 'anthropic',
+ modelID: 'unknown',
+ variant: 'low',
+ })
+ ).toBeNull();
+ });
+
+ it('treats runtimeId, connectionId, protocol, and catalog generation as scope fields', () => {
+ const scope: RuntimeCatalogModelSelectionScope = {
+ runtimeId: FENCE.runtimeId,
+ connectionId: FENCE.connectionId,
+ protocol: 'v1',
+ catalogGenerationIdentity: CATALOG_GENERATION,
+ };
+ expect(areRuntimeCatalogModelSelectionScopesEqual(scope, scope)).toBe(true);
+ expect(
+ areRuntimeCatalogModelSelectionScopesEqual(scope, { ...scope, runtimeId: 'other' })
+ ).toBe(false);
+ expect(
+ areRuntimeCatalogModelSelectionScopesEqual(scope, { ...scope, connectionId: 'cli-a-new' })
+ ).toBe(false);
+ // Protocol is a fixed 'v1' literal in the type, so any change forces a
+ // new value. We still verify the equality helper treats different
+ // protocol literals as unequal.
+ expect(areRuntimeCatalogModelSelectionScopesEqual(scope, { ...scope, protocol: 'v1' })).toBe(
+ true
+ );
+ expect(
+ areRuntimeCatalogModelSelectionScopesEqual(scope, {
+ ...scope,
+ catalogGenerationIdentity: {},
+ })
+ ).toBe(false);
+ });
+
+ it('commits a model selection when the scope, catalog, and fence are all current', () => {
+ const onSelect = vi.fn<(selection: RuntimeCatalogModelSelection) => void>();
+ const bridge = {
+ ...makeBridge(),
+ onSelect,
+ };
+
+ expect(
+ commitRuntimeCatalogModelPickerSelection(bridge, {
+ providerID: 'anthropic',
+ modelID: 'claude-2',
+ variant: 'high',
+ })
+ ).toBe(true);
+ expect(onSelect).toHaveBeenCalledWith({
+ providerID: 'anthropic',
+ modelID: 'claude-2',
+ variant: 'high',
+ });
+ });
+
+ it('rejects a tap when the scope is no longer current', () => {
+ const onSelect = vi.fn<(selection: RuntimeCatalogModelSelection) => void>();
+ const isSelectionCurrent = vi.fn<(scope: RuntimeCatalogModelSelectionScope) => boolean>(
+ () => false
+ );
+ const bridge = {
+ ...makeBridge({ isSelectionCurrent }),
+ onSelect,
+ };
+
+ expect(
+ commitRuntimeCatalogModelPickerSelection(bridge, {
+ providerID: 'anthropic',
+ modelID: 'claude-2',
+ variant: 'high',
+ })
+ ).toBe(false);
+ expect(onSelect).not.toHaveBeenCalled();
+ expect(isSelectionCurrent).toHaveBeenCalledWith(bridge.selectionScope);
+ });
+
+ it('rejects a tap when the scope fence diverges from the catalog fence (reconnect)', () => {
+ const onSelect = vi.fn<(selection: RuntimeCatalogModelSelection) => void>();
+ const bridge = {
+ ...makeBridge({
+ // The screen re-published the scope with a fresh connectionId, but
+ // the catalog object still carries the previous fence.
+ selectionScope: {
+ runtimeId: FENCE.runtimeId,
+ connectionId: 'cli-a-new',
+ protocol: 'v1',
+ catalogGenerationIdentity: CATALOG_GENERATION,
+ },
+ isSelectionCurrent: () => true,
+ }),
+ onSelect,
+ };
+
+ expect(
+ commitRuntimeCatalogModelPickerSelection(bridge, {
+ providerID: 'anthropic',
+ modelID: 'claude-2',
+ variant: 'high',
+ })
+ ).toBe(false);
+ expect(onSelect).not.toHaveBeenCalled();
+ });
+
+ it('rejects a tap when the scope runtimeId diverges from the catalog fence (disconnect)', () => {
+ const onSelect = vi.fn<(selection: RuntimeCatalogModelSelection) => void>();
+ const bridge = {
+ ...makeBridge({
+ currentFence: FENCE,
+ selectionScope: {
+ runtimeId: '33333333-3333-4333-8333-333333333333',
+ connectionId: FENCE.connectionId,
+ protocol: 'v1',
+ catalogGenerationIdentity: CATALOG_GENERATION,
+ },
+ isSelectionCurrent: () => true,
+ }),
+ onSelect,
+ };
+
+ expect(
+ commitRuntimeCatalogModelPickerSelection(bridge, {
+ providerID: 'anthropic',
+ modelID: 'claude-2',
+ variant: 'high',
+ })
+ ).toBe(false);
+ expect(onSelect).not.toHaveBeenCalled();
+ });
+
+ it('rejects a tap when the model is not in the catalog', () => {
+ const onSelect = vi.fn<(selection: RuntimeCatalogModelSelection) => void>();
+ const bridge = {
+ ...makeBridge(),
+ onSelect,
+ };
+
+ expect(
+ commitRuntimeCatalogModelPickerSelection(bridge, {
+ providerID: 'anthropic',
+ modelID: 'unknown',
+ variant: 'low',
+ })
+ ).toBe(false);
+ expect(onSelect).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/mobile/src/lib/picker-bridge-runtime-catalog.ts b/apps/mobile/src/lib/picker-bridge-runtime-catalog.ts
new file mode 100644
index 0000000000..3dde82a6c9
--- /dev/null
+++ b/apps/mobile/src/lib/picker-bridge-runtime-catalog.ts
@@ -0,0 +1,124 @@
+import {
+ type LocalRuntimeCatalog,
+ type LocalRuntimeFence,
+} from '@/lib/hooks/local-runtime-catalog-types';
+
+/**
+ * Runtime-catalog model picker bridge: the screen publishes the catalog
+ * object, the exact fence the catalog was fetched for, and a draft selection
+ * scope. The picker UI calls `commitRuntimeCatalogModelPickerSelection` when
+ * the user taps a row.
+ *
+ * The scope combines the runtime identity (the fence the catalog came from),
+ * the catalog's protocol version, and an opaque `catalogGenerationIdentity`
+ * token that the screen refreshes on every fetch. `commit` rejects when any
+ * of the three guard inputs — scope, catalog object, fence — no longer match
+ * the live state, so a stale tap from a detached picker can never reach the
+ * mutation hook.
+ */
+export type RuntimeCatalogModelSelectionScope = {
+ runtimeId: string;
+ connectionId: string;
+ protocol: 'v1';
+ catalogGenerationIdentity: object | null;
+};
+
+export type RuntimeCatalogModelSelection = {
+ providerID: string;
+ modelID: string;
+ variant: string;
+};
+
+type RuntimeCatalogModelPickerBridge = {
+ catalog: LocalRuntimeCatalog;
+ currentFence: LocalRuntimeFence;
+ currentValue: string;
+ currentVariant: string;
+ selectionScope: RuntimeCatalogModelSelectionScope;
+ isSelectionCurrent: (scope: RuntimeCatalogModelSelectionScope) => boolean;
+ onSelect: (selection: RuntimeCatalogModelSelection) => void;
+};
+
+export function areRuntimeCatalogModelSelectionScopesEqual(
+ left: RuntimeCatalogModelSelectionScope,
+ right: RuntimeCatalogModelSelectionScope
+): boolean {
+ return (
+ left.runtimeId === right.runtimeId &&
+ left.connectionId === right.connectionId &&
+ left.catalogGenerationIdentity === right.catalogGenerationIdentity
+ );
+}
+
+let runtimeCatalogModelBridge: RuntimeCatalogModelPickerBridge | null = null;
+
+/**
+ * Resolve a draft model selection against the published catalog. Returns
+ * `null` when the model id is not in the catalog, when the provider cannot
+ * be located, or when the requested variant is missing (in which case the
+ * first variant of the model is used as a fallback). The picker bridge
+ * performs the additional scope/catalog/fence staleness checks in `commit`.
+ */
+export function resolveRuntimeCatalogModelSelection(
+ bridge: RuntimeCatalogModelPickerBridge,
+ draft: RuntimeCatalogModelSelection
+): RuntimeCatalogModelSelection | null {
+ const provider = bridge.catalog.models.providers.find(
+ candidate => candidate.id === draft.providerID
+ );
+ if (!provider) {
+ return null;
+ }
+ const model = provider.models.find(candidate => candidate.id === draft.modelID);
+ if (!model) {
+ return null;
+ }
+ const resolvedVariant = model.variants.includes(draft.variant)
+ ? draft.variant
+ : (model.variants[0] ?? '');
+ return { providerID: provider.id, modelID: model.id, variant: resolvedVariant };
+}
+
+/**
+ * Commit a runtime-catalog model picker tap. Discards the tap when:
+ *
+ * - the draft scope is no longer current (`isSelectionCurrent` returns
+ * `false` — e.g. the user scrolled away and the screen re-published a
+ * fresh scope),
+ * - the published catalog object identity has changed (a refetch
+ * superseded the picker), or
+ * - the exact fence the catalog was fetched for no longer matches the
+ * scope's runtime identity (runtime reconnect or disconnect).
+ *
+ * Returns `true` only when the bridge's `onSelect` was actually invoked.
+ */
+export function commitRuntimeCatalogModelPickerSelection(
+ bridge: RuntimeCatalogModelPickerBridge,
+ draft: RuntimeCatalogModelSelection
+): boolean {
+ if (!bridge.isSelectionCurrent(bridge.selectionScope)) {
+ return false;
+ }
+ if (
+ bridge.currentFence.runtimeId !== bridge.selectionScope.runtimeId ||
+ bridge.currentFence.connectionId !== bridge.selectionScope.connectionId
+ ) {
+ return false;
+ }
+ const selection = resolveRuntimeCatalogModelSelection(bridge, draft);
+ if (!selection) {
+ return false;
+ }
+ bridge.onSelect(selection);
+ return true;
+}
+
+export function setRuntimeCatalogModelPickerBridge(bridge: RuntimeCatalogModelPickerBridge) {
+ runtimeCatalogModelBridge = bridge;
+}
+export function getRuntimeCatalogModelPickerBridge() {
+ return runtimeCatalogModelBridge;
+}
+export function clearRuntimeCatalogModelPickerBridge() {
+ runtimeCatalogModelBridge = null;
+}
diff --git a/apps/mobile/src/lib/picker-bridge-runtime.test.ts b/apps/mobile/src/lib/picker-bridge-runtime.test.ts
new file mode 100644
index 0000000000..727c946142
--- /dev/null
+++ b/apps/mobile/src/lib/picker-bridge-runtime.test.ts
@@ -0,0 +1,181 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { type LocalRuntime } from '@/lib/hooks/runtime-discovery-logic';
+
+import {
+ areRuntimePickerSelectionScopesEqual,
+ clearRuntimePickerBridge,
+ commitRuntimePickerSelection,
+ getRuntimePickerBridge,
+ resolveRuntimePickerSelection,
+ type RuntimePickerSelectionScope,
+ setRuntimePickerBridge,
+} from './picker-bridge';
+
+const RUNTIME_A: LocalRuntime = {
+ runtimeId: '11111111-1111-4111-8111-111111111111',
+ connectionId: 'cli-a',
+ protocolVersion: 1,
+ cliVersion: '1.2.3',
+ displayName: 'Mac A',
+ projectName: 'kilo',
+ capabilities: ['catalog.v1', 'create-and-run.v1'],
+};
+
+const RUNTIME_B: LocalRuntime = {
+ runtimeId: '22222222-2222-4222-8222-222222222222',
+ connectionId: 'cli-b',
+ protocolVersion: 1,
+ cliVersion: '1.2.3',
+ displayName: 'Mac B',
+ projectName: 'kilo',
+ capabilities: ['catalog.v1', 'create-and-run.v1'],
+};
+
+describe('runtime picker bridge', () => {
+ beforeEach(() => {
+ clearRuntimePickerBridge();
+ });
+
+ it('preserves exact runtimeId and connectionId when resolving a draft fence', () => {
+ const onSelect = vi.fn<(fence: { runtimeId: string; connectionId: string }) => void>();
+ setRuntimePickerBridge({
+ runtimes: [RUNTIME_A, RUNTIME_B],
+ currentFence: { runtimeId: RUNTIME_A.runtimeId, connectionId: RUNTIME_A.connectionId },
+ selectionScope: {
+ runtimeId: RUNTIME_A.runtimeId,
+ connectionId: RUNTIME_A.connectionId,
+ },
+ isSelectionCurrent: () => true,
+ onSelect: fence => {
+ onSelect(fence);
+ },
+ });
+
+ const bridge = getRuntimePickerBridge();
+ if (!bridge) {
+ throw new Error('Expected runtime picker bridge');
+ }
+
+ const fence = resolveRuntimePickerSelection(
+ bridge,
+ RUNTIME_A.runtimeId,
+ RUNTIME_A.connectionId
+ );
+ expect(fence).toEqual({
+ runtimeId: RUNTIME_A.runtimeId,
+ connectionId: RUNTIME_A.connectionId,
+ });
+ });
+
+ it('returns null when the candidate fence is not in the live list', () => {
+ setRuntimePickerBridge({
+ runtimes: [RUNTIME_A],
+ currentFence: null,
+ selectionScope: null,
+ isSelectionCurrent: () => true,
+ onSelect: () => undefined,
+ });
+ const bridge = getRuntimePickerBridge();
+ if (!bridge) {
+ throw new Error('Expected runtime picker bridge');
+ }
+
+ expect(
+ resolveRuntimePickerSelection(bridge, RUNTIME_B.runtimeId, RUNTIME_B.connectionId)
+ ).toBeNull();
+ });
+
+ it('treats runtimeId and connectionId changes as stale runtime scopes', () => {
+ const scope: RuntimePickerSelectionScope = {
+ runtimeId: RUNTIME_A.runtimeId,
+ connectionId: RUNTIME_A.connectionId,
+ };
+ expect(areRuntimePickerSelectionScopesEqual(scope, scope)).toBe(true);
+ expect(
+ areRuntimePickerSelectionScopesEqual(scope, { ...scope, connectionId: 'cli-a-new' })
+ ).toBe(false);
+ expect(
+ areRuntimePickerSelectionScopesEqual(scope, {
+ runtimeId: RUNTIME_B.runtimeId,
+ connectionId: RUNTIME_A.connectionId,
+ })
+ ).toBe(false);
+ });
+
+ it('commits a runtime tap when the scope is current and the fence is in the list', () => {
+ const onSelect = vi.fn<(fence: { runtimeId: string; connectionId: string }) => void>();
+ const bridge = {
+ runtimes: [RUNTIME_A, RUNTIME_B],
+ currentFence: null,
+ selectionScope: {
+ runtimeId: RUNTIME_A.runtimeId,
+ connectionId: RUNTIME_A.connectionId,
+ },
+ isSelectionCurrent: vi.fn(() => true),
+ onSelect,
+ };
+
+ expect(commitRuntimePickerSelection(bridge, RUNTIME_B.runtimeId, RUNTIME_B.connectionId)).toBe(
+ true
+ );
+ expect(onSelect).toHaveBeenCalledWith({
+ runtimeId: RUNTIME_B.runtimeId,
+ connectionId: RUNTIME_B.connectionId,
+ });
+ });
+
+ it('discards a tap when the scope is stale', () => {
+ const onSelect = vi.fn<(fence: { runtimeId: string; connectionId: string }) => void>();
+ const bridge = {
+ runtimes: [RUNTIME_A, RUNTIME_B],
+ currentFence: null,
+ selectionScope: {
+ runtimeId: RUNTIME_A.runtimeId,
+ connectionId: RUNTIME_A.connectionId,
+ },
+ isSelectionCurrent: vi.fn(() => false),
+ onSelect,
+ };
+
+ expect(commitRuntimePickerSelection(bridge, RUNTIME_B.runtimeId, RUNTIME_B.connectionId)).toBe(
+ false
+ );
+ expect(onSelect).not.toHaveBeenCalled();
+ });
+
+ it('discards a tap when the selectionScope is null', () => {
+ const onSelect = vi.fn<(fence: { runtimeId: string; connectionId: string }) => void>();
+ const bridge = {
+ runtimes: [RUNTIME_A, RUNTIME_B],
+ currentFence: null,
+ selectionScope: null,
+ isSelectionCurrent: vi.fn(() => true),
+ onSelect,
+ };
+
+ expect(commitRuntimePickerSelection(bridge, RUNTIME_A.runtimeId, RUNTIME_A.connectionId)).toBe(
+ false
+ );
+ expect(onSelect).not.toHaveBeenCalled();
+ });
+
+ it('discards a tap when the candidate fence is not in the live list', () => {
+ const onSelect = vi.fn<(fence: { runtimeId: string; connectionId: string }) => void>();
+ const bridge = {
+ runtimes: [RUNTIME_A],
+ currentFence: null,
+ selectionScope: {
+ runtimeId: RUNTIME_A.runtimeId,
+ connectionId: RUNTIME_A.connectionId,
+ },
+ isSelectionCurrent: vi.fn(() => true),
+ onSelect,
+ };
+
+ expect(commitRuntimePickerSelection(bridge, RUNTIME_B.runtimeId, RUNTIME_B.connectionId)).toBe(
+ false
+ );
+ expect(onSelect).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/mobile/src/lib/picker-bridge-runtime.ts b/apps/mobile/src/lib/picker-bridge-runtime.ts
new file mode 100644
index 0000000000..613d5f98fc
--- /dev/null
+++ b/apps/mobile/src/lib/picker-bridge-runtime.ts
@@ -0,0 +1,85 @@
+import { type LocalRuntime } from '@/lib/hooks/runtime-discovery-logic';
+
+import { type LocalRuntimeFence } from '@/lib/hooks/local-runtime-catalog-types';
+
+/**
+ * Runtime picker bridge: the screen publishes the live runtime list and the
+ * exact fence the user is currently drafting a selection for, and the picker
+ * UI calls `commitRuntimePickerSelection` when the user taps a row. The scope
+ * is the draft identity of the selection itself (the candidate fence) — when
+ * the underlying runtime list refreshes underneath the picker, the draft
+ * fence is no longer "current" and a tap must be discarded.
+ */
+export type RuntimePickerSelectionScope = {
+ runtimeId: string;
+ connectionId: string;
+};
+
+type RuntimePickerBridge = {
+ runtimes: LocalRuntime[];
+ currentFence: LocalRuntimeFence | null;
+ selectionScope: RuntimePickerSelectionScope | null;
+ isSelectionCurrent: (scope: RuntimePickerSelectionScope | null) => boolean;
+ onSelect: (fence: LocalRuntimeFence) => void;
+};
+
+export function areRuntimePickerSelectionScopesEqual(
+ left: RuntimePickerSelectionScope,
+ right: RuntimePickerSelectionScope
+): boolean {
+ return left.runtimeId === right.runtimeId && left.connectionId === right.connectionId;
+}
+
+let runtimeBridge: RuntimePickerBridge | null = null;
+
+/**
+ * Resolve a draft runtime fence against the live runtime list. Returns
+ * `null` when the candidate fence is not in the list (the runtime
+ * disconnected or its connectionId changed underneath the picker). The
+ * caller is expected to call `commitRuntimePickerSelection`, which uses
+ * this helper internally and additionally checks scope staleness.
+ */
+export function resolveRuntimePickerSelection(
+ bridge: RuntimePickerBridge,
+ runtimeId: string,
+ connectionId: string
+): LocalRuntimeFence | null {
+ const runtime = bridge.runtimes.find(
+ candidate => candidate.runtimeId === runtimeId && candidate.connectionId === connectionId
+ );
+ if (!runtime) {
+ return null;
+ }
+ return { runtimeId: runtime.runtimeId, connectionId: runtime.connectionId };
+}
+
+/**
+ * Commit a runtime picker tap. Discards the tap when the draft scope is no
+ * longer current or the candidate fence is not in the live list. Returns
+ * `true` only when the bridge's `onSelect` was actually invoked.
+ */
+export function commitRuntimePickerSelection(
+ bridge: RuntimePickerBridge,
+ runtimeId: string,
+ connectionId: string
+): boolean {
+ if (!bridge.selectionScope || !bridge.isSelectionCurrent(bridge.selectionScope)) {
+ return false;
+ }
+ const fence = resolveRuntimePickerSelection(bridge, runtimeId, connectionId);
+ if (!fence) {
+ return false;
+ }
+ bridge.onSelect(fence);
+ return true;
+}
+
+export function setRuntimePickerBridge(bridge: RuntimePickerBridge) {
+ runtimeBridge = bridge;
+}
+export function getRuntimePickerBridge() {
+ return runtimeBridge;
+}
+export function clearRuntimePickerBridge() {
+ runtimeBridge = null;
+}
diff --git a/apps/mobile/src/lib/picker-bridge.test.ts b/apps/mobile/src/lib/picker-bridge.test.ts
index 5d6494ea1a..8445dc923e 100644
--- a/apps/mobile/src/lib/picker-bridge.test.ts
+++ b/apps/mobile/src/lib/picker-bridge.test.ts
@@ -5,6 +5,8 @@ import {
clearModelPickerBridge,
commitModelPickerSelection,
getModelPickerBridge,
+ type ModelPickerSelection,
+ type ModelPickerSelectionScope,
resolveModelPickerSelection,
setModelPickerBridge,
} from './picker-bridge';
@@ -37,7 +39,7 @@ describe('model picker bridge', () => {
});
it('preserves exact model identity and override source while resetting an invalid variant', () => {
- const onSelect = vi.fn();
+ const onSelect = vi.fn<(selection: ModelPickerSelection) => void>();
setModelPickerBridge({
...currentSelectionScope,
options: [remoteOption],
@@ -49,7 +51,6 @@ describe('model picker bridge', () => {
});
const bridge = getModelPickerBridge();
- expect(bridge).not.toBeNull();
if (!bridge) {
throw new Error('Expected model picker bridge');
}
@@ -71,10 +72,10 @@ describe('model picker bridge', () => {
it('treats session, owner, protocol, and catalog generation changes as stale scopes', () => {
const catalogGenerationIdentity = {};
- const scope = {
+ const scope: ModelPickerSelectionScope = {
sessionId: 'session-a',
ownerConnectionId: 'owner-a',
- protocol: 'v1' as const,
+ protocol: 'v1',
catalogGenerationIdentity,
};
@@ -92,7 +93,7 @@ describe('model picker bridge', () => {
});
it('discards a detached selection when its session catalog scope is stale', () => {
- const onSelect = vi.fn();
+ const onSelect = vi.fn<(selection: ModelPickerSelection) => void>();
const catalogGenerationIdentity = {};
const bridge = {
options: [remoteOption],
@@ -114,7 +115,7 @@ describe('model picker bridge', () => {
});
it('commits a detached selection while its session catalog scope is current', () => {
- const onSelect = vi.fn();
+ const onSelect = vi.fn<(selection: ModelPickerSelection) => void>();
const bridge = {
options: [remoteOption],
currentValue: remoteOption.id,
diff --git a/apps/mobile/src/lib/picker-bridge.ts b/apps/mobile/src/lib/picker-bridge.ts
index e9bc7c6a1a..79b3340973 100644
--- a/apps/mobile/src/lib/picker-bridge.ts
+++ b/apps/mobile/src/lib/picker-bridge.ts
@@ -1,6 +1,40 @@
import { type AgentMode } from '@/components/agents/mode-selector';
import { type SessionModelOption } from '@/lib/hooks/use-session-model-options';
+export type {
+ RuntimeCatalogAgentSelection,
+ RuntimeCatalogAgentSelectionScope,
+} from './picker-bridge-runtime-agent';
+export type {
+ RuntimeCatalogModelSelection,
+ RuntimeCatalogModelSelectionScope,
+} from './picker-bridge-runtime-catalog';
+export type { RuntimePickerSelectionScope } from './picker-bridge-runtime';
+export {
+ areRuntimeCatalogAgentSelectionScopesEqual,
+ clearRuntimeCatalogAgentPickerBridge,
+ commitRuntimeCatalogAgentPickerSelection,
+ getRuntimeCatalogAgentPickerBridge,
+ resolveRuntimeCatalogAgentSelection,
+ setRuntimeCatalogAgentPickerBridge,
+} from './picker-bridge-runtime-agent';
+export {
+ areRuntimeCatalogModelSelectionScopesEqual,
+ clearRuntimeCatalogModelPickerBridge,
+ commitRuntimeCatalogModelPickerSelection,
+ getRuntimeCatalogModelPickerBridge,
+ resolveRuntimeCatalogModelSelection,
+ setRuntimeCatalogModelPickerBridge,
+} from './picker-bridge-runtime-catalog';
+export {
+ areRuntimePickerSelectionScopesEqual,
+ clearRuntimePickerBridge,
+ commitRuntimePickerSelection,
+ getRuntimePickerBridge,
+ resolveRuntimePickerSelection,
+ setRuntimePickerBridge,
+} from './picker-bridge-runtime';
+
export type ModelPickerSelection = {
option: SessionModelOption;
variant: string;
diff --git a/apps/mobile/test-utils/read-source.ts b/apps/mobile/test-utils/read-source.ts
new file mode 100644
index 0000000000..3cb70e7a85
--- /dev/null
+++ b/apps/mobile/test-utils/read-source.ts
@@ -0,0 +1,8 @@
+import { readFileSync } from 'node:fs';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+export function readSourceFile(relativePath: string): string {
+ const here = dirname(fileURLToPath(import.meta.url));
+ return readFileSync(resolve(here, '../src', relativePath), 'utf8');
+}
diff --git a/apps/web/package.json b/apps/web/package.json
index 57249a59c3..ee13b1a2bd 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -57,6 +57,7 @@
"@kilocode/kiloclaw-secret-catalog": "workspace:*",
"@kilocode/mcp-gateway": "workspace:*",
"@kilocode/organization-entitlement": "workspace:*",
+ "@kilocode/session-ingest-contracts": "workspace:*",
"@kilocode/worker-utils": "workspace:*",
"@linear/sdk": "76.0.0",
"@lottiefiles/dotlottie-react": "0.17.15",
diff --git a/apps/web/src/lib/local-runtime-control/client.test.ts b/apps/web/src/lib/local-runtime-control/client.test.ts
new file mode 100644
index 0000000000..0f5f93f0b3
--- /dev/null
+++ b/apps/web/src/lib/local-runtime-control/client.test.ts
@@ -0,0 +1,602 @@
+import {
+ LocalRuntimeControlClient,
+ LocalRuntimeControlRequestError,
+ type LocalRuntimeList,
+} from './client';
+import type { CreateAndRunLocalSessionRequest } from '@kilocode/session-ingest-contracts';
+
+const mockConfig = {
+ sessionIngestWorkerUrl: 'https://session-ingest.example.workers.dev',
+};
+
+const mockTokenOptions: Array<{
+ userId: string;
+ options?: { expiresIn?: number; audience?: string };
+}> = [];
+jest.mock('@/lib/config.server', () => ({
+ get SESSION_INGEST_WORKER_URL() {
+ return mockConfig.sessionIngestWorkerUrl;
+ },
+}));
+
+jest.mock('@/lib/tokens', () => ({
+ TOKEN_EXPIRY: { fiveMinutes: 5 * 60 },
+ generateInternalServiceToken: (
+ userId: string,
+ options?: { expiresIn?: number; audience?: string }
+ ) => {
+ mockTokenOptions.push({ userId, options });
+ return `audience-bound-token:${userId}:${options?.audience ?? 'no-aud'}`;
+ },
+}));
+
+describe('LocalRuntimeControlClient.list', () => {
+ beforeEach(() => {
+ mockConfig.sessionIngestWorkerUrl = 'https://session-ingest.example.workers.dev';
+ mockTokenOptions.length = 0;
+ jest.restoreAllMocks();
+ });
+
+ it('mints a five-minute audience-bound internal token for the user', async () => {
+ const fetchMock = jest.spyOn(global, 'fetch').mockResolvedValueOnce(
+ new Response(JSON.stringify({ runtimes: [] }), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ })
+ );
+
+ await LocalRuntimeControlClient.list('usr_alice');
+
+ expect(mockTokenOptions).toEqual([
+ {
+ userId: 'usr_alice',
+ options: {
+ expiresIn: 5 * 60,
+ audience: 'session-ingest:runtime-control',
+ },
+ },
+ ]);
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [calledUrl, calledInit] = fetchMock.mock.calls[0] as [string, RequestInit];
+ expect(calledUrl).toBe(
+ 'https://session-ingest.example.workers.dev/internal/runtime-control/runtimes'
+ );
+ expect(calledInit.method).toBe('GET');
+ expect(calledInit.headers).toEqual({
+ Authorization: 'Bearer audience-bound-token:usr_alice:session-ingest:runtime-control',
+ });
+ });
+
+ it('returns the strict-parsed empty list when the upstream returns zero runtimes', async () => {
+ jest.spyOn(global, 'fetch').mockResolvedValueOnce(
+ new Response(JSON.stringify({ runtimes: [] }), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ })
+ );
+
+ const result: LocalRuntimeList = await LocalRuntimeControlClient.list('usr_alice');
+
+ expect(result).toEqual({ runtimes: [] });
+ });
+
+ it('returns all first-class runtimes including capability-missing entries', async () => {
+ jest.spyOn(global, 'fetch').mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({
+ runtimes: [
+ {
+ runtimeId: '0c0a1b2c-3d4e-4f60-8a8b-9c0d1e2f3a4b',
+ connectionId: 'cli-1',
+ protocolVersion: 1,
+ cliVersion: '7.4.7',
+ displayName: 'Alice Mac',
+ projectName: 'customer-repo',
+ capabilities: ['catalog.v1', 'create-and-run.v1'],
+ },
+ {
+ runtimeId: '1c0a1b2c-3d4e-4f60-8a8b-9c0d1e2f3a4b',
+ connectionId: 'cli-2',
+ protocolVersion: 1,
+ cliVersion: '7.4.7',
+ displayName: 'Bob Mac',
+ projectName: 'empty-repo',
+ capabilities: ['catalog.v1'],
+ },
+ ],
+ }),
+ { status: 200, headers: { 'content-type': 'application/json' } }
+ )
+ );
+
+ const result = await LocalRuntimeControlClient.list('usr_alice');
+
+ expect(result.runtimes).toHaveLength(2);
+ expect(result.runtimes[1]?.capabilities).toEqual(['catalog.v1']);
+ });
+
+ it('throws a typed error when SESSION_INGEST_WORKER_URL is not configured', async () => {
+ mockConfig.sessionIngestWorkerUrl = '';
+ const fetchMock = jest.spyOn(global, 'fetch');
+
+ await expect(LocalRuntimeControlClient.list('usr_alice')).rejects.toBeInstanceOf(
+ LocalRuntimeControlRequestError
+ );
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it('throws a typed error on a network failure without returning an empty list', async () => {
+ jest.spyOn(global, 'fetch').mockRejectedValueOnce(new Error('socket reset'));
+
+ await expect(LocalRuntimeControlClient.list('usr_alice')).rejects.toBeInstanceOf(
+ LocalRuntimeControlRequestError
+ );
+ });
+
+ it('throws a typed error on a non-2xx response without returning an empty list', async () => {
+ jest
+ .spyOn(global, 'fetch')
+ .mockResolvedValueOnce(
+ new Response('upstream blew up with internal details', { status: 503 })
+ );
+
+ await expect(LocalRuntimeControlClient.list('usr_alice')).rejects.toBeInstanceOf(
+ LocalRuntimeControlRequestError
+ );
+ });
+
+ it('throws a typed error on a malformed response body without returning an empty list', async () => {
+ jest.spyOn(global, 'fetch').mockResolvedValueOnce(
+ new Response(JSON.stringify({ runtimes: [{ runtimeId: 'not-a-uuid' }] }), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ })
+ );
+
+ await expect(LocalRuntimeControlClient.list('usr_alice')).rejects.toBeInstanceOf(
+ LocalRuntimeControlRequestError
+ );
+ });
+
+ it('never logs the token or the response body when failures occur', async () => {
+ const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
+ const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
+ jest
+ .spyOn(global, 'fetch')
+ .mockResolvedValueOnce(
+ new Response('super-secret-response-body-must-not-leak', { status: 500 })
+ );
+
+ await expect(LocalRuntimeControlClient.list('usr_alice')).rejects.toBeInstanceOf(
+ LocalRuntimeControlRequestError
+ );
+
+ const dumped = JSON.stringify({
+ warns: warn.mock.calls,
+ errors: errorSpy.mock.calls,
+ });
+ expect(dumped).not.toContain('super-secret-response-body-must-not-leak');
+ expect(dumped).not.toContain('audience-bound-token:usr_alice');
+ });
+});
+
+describe('LocalRuntimeControlClient.getCatalog', () => {
+ const fence = {
+ runtimeId: 'aaaaaaaa-1111-4111-8111-aaaaaaaaaaaa',
+ connectionId: 'cli-77',
+ };
+ const validWireModels = {
+ all: [
+ {
+ id: 'kilo',
+ name: 'Kilo',
+ source: 'env',
+ env: [],
+ options: {},
+ models: {
+ 'kilo/auto': {
+ id: 'kilo/auto',
+ providerID: 'kilo',
+ api: { id: 'kilo/auto', url: '', npm: '' },
+ name: 'Auto',
+ capabilities: {
+ temperature: false,
+ reasoning: false,
+ attachment: false,
+ toolcall: false,
+ input: { text: true, audio: false, image: false, video: false, pdf: false },
+ 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: 1, output: 1 },
+ status: 'active',
+ options: {},
+ headers: {},
+ release_date: '',
+ },
+ },
+ },
+ ],
+ default: { kilo: 'kilo/auto' },
+ connected: ['kilo'],
+ failed: [],
+ protocolVersion: 1,
+ truncated: false,
+ };
+ const validCatalogEnvelope = {
+ catalog: {
+ protocolVersion: 1,
+ models: validWireModels,
+ agents: [{ slug: 'build', name: 'Build' }],
+ defaultAgent: 'build',
+ },
+ };
+
+ beforeEach(() => {
+ mockConfig.sessionIngestWorkerUrl = 'https://session-ingest.example.workers.dev';
+ mockTokenOptions.length = 0;
+ jest.restoreAllMocks();
+ });
+
+ it('mints a five-minute audience-bound internal token and POSTs the exact body', async () => {
+ const fetchMock = jest.spyOn(global, 'fetch').mockResolvedValueOnce(
+ new Response(JSON.stringify(validCatalogEnvelope), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ })
+ );
+
+ await LocalRuntimeControlClient.getCatalog('usr_alice', fence);
+
+ expect(mockTokenOptions).toEqual([
+ {
+ userId: 'usr_alice',
+ options: {
+ expiresIn: 5 * 60,
+ audience: 'session-ingest:runtime-control',
+ },
+ },
+ ]);
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [calledUrl, calledInit] = fetchMock.mock.calls[0] as [string, RequestInit];
+ expect(calledUrl).toBe(
+ 'https://session-ingest.example.workers.dev/internal/runtime-control/catalog'
+ );
+ expect(calledInit.method).toBe('POST');
+ expect(calledInit.headers).toEqual({
+ Authorization: 'Bearer audience-bound-token:usr_alice:session-ingest:runtime-control',
+ 'content-type': 'application/json',
+ });
+ expect(calledInit.body).toBe(JSON.stringify({ fence, request: { protocolVersion: 1 } }));
+ expect(calledInit.signal).toBeDefined();
+ });
+
+ it('returns the parsed typed catalog with parsed models and agents/default', async () => {
+ jest.spyOn(global, 'fetch').mockResolvedValueOnce(
+ new Response(JSON.stringify(validCatalogEnvelope), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ })
+ );
+
+ const result = await LocalRuntimeControlClient.getCatalog('usr_alice', fence);
+
+ expect(result.catalog.protocolVersion).toBe(1);
+ expect(result.catalog.defaultAgent).toBe('build');
+ expect(result.catalog.agents).toEqual([{ slug: 'build', name: 'Build' }]);
+ expect(result.catalog.models.protocolVersion).toBe(1);
+ expect(result.catalog.models.providers).toHaveLength(1);
+ expect(result.catalog.models.providers[0]?.id).toBe('kilo');
+ expect(result.catalog.models.providers[0]?.models).toHaveLength(1);
+ expect(result.catalog.models.providers[0]?.models[0]?.id).toBe('kilo/auto');
+ expect(result.catalog.models.truncated).toBe(false);
+ });
+
+ it('uses a 5s AbortSignal timeout', async () => {
+ const fetchMock = jest.spyOn(global, 'fetch').mockResolvedValueOnce(
+ new Response(JSON.stringify(validCatalogEnvelope), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ })
+ );
+
+ await LocalRuntimeControlClient.getCatalog('usr_alice', fence);
+
+ const [, calledInit] = fetchMock.mock.calls[0] as [string, RequestInit];
+ const signal = calledInit.signal as AbortSignal;
+ expect(signal).toBeInstanceOf(AbortSignal);
+ // The AbortSignal.timeout(5000) signal aborts at 5s; just assert it exists
+ // and is not pre-aborted.
+ expect(signal.aborted).toBe(false);
+ });
+
+ it('throws a typed error carrying upstreamCode on a structured error envelope', async () => {
+ jest.spyOn(global, 'fetch').mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({
+ error: {
+ source: 'relay',
+ code: 'RUNTIME_NOT_CONNECTED',
+ message: 'Runtime is not currently connected',
+ },
+ }),
+ { status: 404, headers: { 'content-type': 'application/json' } }
+ )
+ );
+
+ await expect(LocalRuntimeControlClient.getCatalog('usr_alice', fence)).rejects.toMatchObject({
+ name: 'LocalRuntimeCatalogError',
+ upstreamCode: 'RUNTIME_NOT_CONNECTED',
+ });
+ });
+
+ it('throws a typed error on a malformed models payload', async () => {
+ jest.spyOn(global, 'fetch').mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({
+ catalog: {
+ protocolVersion: 1,
+ models: { not: 'a real catalog' },
+ agents: [],
+ defaultAgent: 'build',
+ },
+ }),
+ { status: 200, headers: { 'content-type': 'application/json' } }
+ )
+ );
+
+ await expect(LocalRuntimeControlClient.getCatalog('usr_alice', fence)).rejects.toBeInstanceOf(
+ Error
+ );
+ });
+
+ it('throws a typed error on a malformed envelope', async () => {
+ jest.spyOn(global, 'fetch').mockResolvedValueOnce(
+ new Response(JSON.stringify({ not: 'a catalog' }), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ })
+ );
+
+ await expect(LocalRuntimeControlClient.getCatalog('usr_alice', fence)).rejects.toBeInstanceOf(
+ Error
+ );
+ });
+
+ it('throws a typed error on a network failure', async () => {
+ jest.spyOn(global, 'fetch').mockRejectedValueOnce(new Error('socket reset'));
+
+ await expect(LocalRuntimeControlClient.getCatalog('usr_alice', fence)).rejects.toMatchObject({
+ name: 'LocalRuntimeCatalogError',
+ });
+ });
+
+ it.each([401, 403, 409, 412, 429, 500, 504])(
+ 'throws a typed error on a non-2xx response (status %s)',
+ async status => {
+ jest
+ .spyOn(global, 'fetch')
+ .mockResolvedValueOnce(new Response('upstream blew up', { status }));
+
+ await expect(LocalRuntimeControlClient.getCatalog('usr_alice', fence)).rejects.toMatchObject({
+ name: 'LocalRuntimeCatalogError',
+ });
+ }
+ );
+
+ it('throws a typed error when SESSION_INGEST_WORKER_URL is not configured', async () => {
+ mockConfig.sessionIngestWorkerUrl = '';
+ const fetchMock = jest.spyOn(global, 'fetch');
+
+ await expect(LocalRuntimeControlClient.getCatalog('usr_alice', fence)).rejects.toBeInstanceOf(
+ Error
+ );
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+});
+
+describe('LocalRuntimeControlClient.createAndRun', () => {
+ const fence = {
+ runtimeId: 'aaaaaaaa-1111-4111-8111-aaaaaaaaaaaa',
+ connectionId: 'cli-77',
+ };
+ const validRequest = {
+ protocolVersion: 1,
+ requestId: '0c0a1b2c-3d4e-4f60-8a8b-9c0d1e2f3a4b',
+ prompt: 'hello',
+ model: { providerID: 'kilo', modelID: 'kilo/auto' },
+ agent: 'build',
+ } as const satisfies CreateAndRunLocalSessionRequest;
+ const successEnvelope = {
+ result: {
+ protocolVersion: 1,
+ sessionId: 'ses_a1b2c3d4e5f67890123456789a',
+ promptStarted: true,
+ },
+ };
+
+ beforeEach(() => {
+ mockConfig.sessionIngestWorkerUrl = 'https://session-ingest.example.workers.dev';
+ mockTokenOptions.length = 0;
+ jest.restoreAllMocks();
+ });
+
+ it('mints a five-minute audience-bound internal token and POSTs the exact body', async () => {
+ const fetchMock = jest.spyOn(global, 'fetch').mockResolvedValueOnce(
+ new Response(JSON.stringify(successEnvelope), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ })
+ );
+
+ await LocalRuntimeControlClient.createAndRun('usr_alice', fence, validRequest);
+
+ expect(mockTokenOptions).toEqual([
+ {
+ userId: 'usr_alice',
+ options: {
+ expiresIn: 5 * 60,
+ audience: 'session-ingest:runtime-control',
+ },
+ },
+ ]);
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [calledUrl, calledInit] = fetchMock.mock.calls[0] as [string, RequestInit];
+ expect(calledUrl).toBe(
+ 'https://session-ingest.example.workers.dev/internal/runtime-control/create-and-run'
+ );
+ expect(calledInit.method).toBe('POST');
+ expect(calledInit.headers).toEqual({
+ Authorization: 'Bearer audience-bound-token:usr_alice:session-ingest:runtime-control',
+ 'content-type': 'application/json',
+ });
+ expect(calledInit.body).toBe(JSON.stringify({ fence, request: validRequest }));
+ expect(calledInit.signal).toBeDefined();
+ });
+
+ it('returns the strict-parsed success result', async () => {
+ jest.spyOn(global, 'fetch').mockResolvedValueOnce(
+ new Response(JSON.stringify(successEnvelope), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ })
+ );
+
+ const result = await LocalRuntimeControlClient.createAndRun('usr_alice', fence, validRequest);
+
+ expect(result.result.sessionId).toBe('ses_a1b2c3d4e5f67890123456789a');
+ expect(result.result.promptStarted).toBe(true);
+ });
+
+ it('returns the promptStarted:false partial result', async () => {
+ const partialEnvelope = {
+ result: {
+ protocolVersion: 1,
+ sessionId: 'ses_b2c3d4e5f67890123456789cde',
+ promptStarted: false,
+ error: {
+ code: 'PROMPT_START_FAILED',
+ message: 'The session was created, but the first prompt did not start.',
+ },
+ },
+ };
+ jest.spyOn(global, 'fetch').mockResolvedValueOnce(
+ new Response(JSON.stringify(partialEnvelope), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ })
+ );
+
+ const result = await LocalRuntimeControlClient.createAndRun('usr_alice', fence, validRequest);
+
+ expect(result.result.promptStarted).toBe(false);
+ if (result.result.promptStarted === false) {
+ expect(result.result.error.code).toBe('PROMPT_START_FAILED');
+ }
+ });
+
+ it('uses a 30s AbortSignal timeout', async () => {
+ const fetchMock = jest.spyOn(global, 'fetch').mockResolvedValueOnce(
+ new Response(JSON.stringify(successEnvelope), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ })
+ );
+
+ await LocalRuntimeControlClient.createAndRun('usr_alice', fence, validRequest);
+
+ const [, calledInit] = fetchMock.mock.calls[0] as [string, RequestInit];
+ const signal = calledInit.signal as AbortSignal;
+ expect(signal).toBeInstanceOf(AbortSignal);
+ expect(signal.aborted).toBe(false);
+ });
+
+ it('throws a typed error carrying upstreamCode on a structured error envelope', async () => {
+ jest.spyOn(global, 'fetch').mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({
+ error: {
+ source: 'relay',
+ code: 'RUNTIME_NOT_CONNECTED',
+ message: 'Runtime is not currently connected',
+ },
+ }),
+ { status: 404, headers: { 'content-type': 'application/json' } }
+ )
+ );
+
+ await expect(
+ LocalRuntimeControlClient.createAndRun('usr_alice', fence, validRequest)
+ ).rejects.toMatchObject({
+ name: 'LocalRuntimeCreateAndRunError',
+ upstreamCode: 'RUNTIME_NOT_CONNECTED',
+ });
+ });
+
+ it('throws a typed error on a malformed envelope', async () => {
+ jest.spyOn(global, 'fetch').mockResolvedValueOnce(
+ new Response(JSON.stringify({ not: 'a result' }), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ })
+ );
+
+ await expect(
+ LocalRuntimeControlClient.createAndRun('usr_alice', fence, validRequest)
+ ).rejects.toBeInstanceOf(Error);
+ });
+
+ it('throws a typed error on a network failure', async () => {
+ jest.spyOn(global, 'fetch').mockRejectedValueOnce(new Error('socket reset'));
+
+ await expect(
+ LocalRuntimeControlClient.createAndRun('usr_alice', fence, validRequest)
+ ).rejects.toMatchObject({ name: 'LocalRuntimeCreateAndRunError' });
+ });
+
+ it.each([401, 403, 409, 412, 429, 500, 504])(
+ 'throws a typed error on a non-2xx response (status %s)',
+ async status => {
+ jest
+ .spyOn(global, 'fetch')
+ .mockResolvedValueOnce(new Response('upstream blew up', { status }));
+
+ await expect(
+ LocalRuntimeControlClient.createAndRun('usr_alice', fence, validRequest)
+ ).rejects.toMatchObject({ name: 'LocalRuntimeCreateAndRunError' });
+ }
+ );
+
+ it('throws a typed error when SESSION_INGEST_WORKER_URL is not configured', async () => {
+ mockConfig.sessionIngestWorkerUrl = '';
+ const fetchMock = jest.spyOn(global, 'fetch');
+
+ await expect(
+ LocalRuntimeControlClient.createAndRun('usr_alice', fence, validRequest)
+ ).rejects.toBeInstanceOf(Error);
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it('never logs the token, the prompt, or the response body on failure', async () => {
+ const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
+ const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
+ jest
+ .spyOn(global, 'fetch')
+ .mockResolvedValueOnce(
+ new Response('super-secret-response-body-must-not-leak', { status: 500 })
+ );
+
+ await expect(
+ LocalRuntimeControlClient.createAndRun('usr_alice', fence, validRequest)
+ ).rejects.toBeInstanceOf(Error);
+
+ const dumped = JSON.stringify({
+ warns: warn.mock.calls,
+ errors: errorSpy.mock.calls,
+ });
+ expect(dumped).not.toContain('super-secret-response-body-must-not-leak');
+ expect(dumped).not.toContain('audience-bound-token:usr_alice');
+ expect(dumped).not.toContain('hello');
+ });
+});
diff --git a/apps/web/src/lib/local-runtime-control/client.ts b/apps/web/src/lib/local-runtime-control/client.ts
new file mode 100644
index 0000000000..ef37c8e30b
--- /dev/null
+++ b/apps/web/src/lib/local-runtime-control/client.ts
@@ -0,0 +1,359 @@
+import 'server-only';
+
+import { SESSION_INGEST_WORKER_URL } from '@/lib/config.server';
+import { generateInternalServiceToken, TOKEN_EXPIRY } from '@/lib/tokens';
+import {
+ createAndRunLocalSessionRequestSchema,
+ getLocalRuntimeCatalogRequestSchema,
+ localRuntimeCatalogResponseSchema,
+ localRuntimeControlErrorCodeSchema,
+ localRuntimeCreateResponseSchema,
+ localRuntimeErrorResponseSchema,
+ localRuntimeFenceSchema,
+ localRuntimeListResponseSchema,
+ SESSION_INGEST_RUNTIME_CONTROL_AUDIENCE,
+ type CreateAndRunLocalSessionRequest,
+ type CreateAndRunLocalSessionResult,
+ type LocalRuntimeFence,
+ type LocalRuntimeListResponse,
+} from '@kilocode/session-ingest-contracts';
+import {
+ remoteModelCatalogV1Schema,
+ type RemoteModelCatalogV1,
+} from '@/lib/cloud-agent-sdk/schemas';
+
+const RUNTIME_LIST_TIMEOUT_MS = 5_000;
+const RUNTIME_CATALOG_TIMEOUT_MS = 5_000;
+const RUNTIME_CREATE_AND_RUN_TIMEOUT_MS = 30_000;
+
+export class LocalRuntimeControlRequestError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'LocalRuntimeControlRequestError';
+ }
+}
+
+/**
+ * Typed error raised by `LocalRuntimeControlClient.getCatalog` and
+ * `LocalRuntimeControlClient.createAndRun` when the relay returns a
+ * structured error envelope. The `upstreamCode` is the stable
+ * `LocalRuntimeControlErrorCode` and is exposed to the mobile client as
+ * `error.data.upstreamCode` so the recovery branch can be chosen in-app.
+ */
+export class LocalRuntimeCatalogError extends Error {
+ constructor(
+ public readonly upstreamCode: string,
+ message: string
+ ) {
+ super(message);
+ this.name = 'LocalRuntimeCatalogError';
+ }
+}
+
+/**
+ * Typed error raised by `LocalRuntimeControlClient.createAndRun` when the
+ * relay returns a structured error envelope. The `upstreamCode` is the
+ * stable `LocalRuntimeControlErrorCode` and is exposed to the mobile
+ * client as `error.data.upstreamCode` so the recovery branch can be
+ * chosen in-app.
+ */
+export class LocalRuntimeCreateAndRunError extends Error {
+ constructor(
+ public readonly upstreamCode: string,
+ message: string
+ ) {
+ super(message);
+ this.name = 'LocalRuntimeCreateAndRunError';
+ }
+}
+
+export type LocalRuntimeList = LocalRuntimeListResponse;
+
+export type LocalRuntimeCatalog = {
+ protocolVersion: 1;
+ models: RemoteModelCatalogV1;
+ agents: Array<{
+ slug: string;
+ name: string;
+ description?: string;
+ model?: unknown;
+ variant?: string;
+ }>;
+ defaultAgent: string;
+};
+
+export type LocalRuntimeCatalogResponse = { catalog: LocalRuntimeCatalog };
+
+async function readJson(response: Response): Promise {
+ const text = await response.text();
+ try {
+ return JSON.parse(text);
+ } catch {
+ throw new LocalRuntimeControlRequestError('Local runtime list response was not valid JSON');
+ }
+}
+
+function buildAudienceToken(userId: string): string {
+ return generateInternalServiceToken(userId, {
+ expiresIn: TOKEN_EXPIRY.fiveMinutes,
+ audience: SESSION_INGEST_RUNTIME_CONTROL_AUDIENCE,
+ });
+}
+
+export const LocalRuntimeControlClient = {
+ /**
+ * Fetch the read-only runtime list for the bound user from the
+ * session-ingest service. The five-minute, audience-bound internal token
+ * proves the caller is acting on behalf of that user; the service mirrors
+ * the audience check and resolves the user DO from the signed payload
+ * alone.
+ *
+ * Any network failure, non-2xx response, or shape mismatch throws a
+ * `LocalRuntimeControlRequestError`. Failures are NEVER downgraded to an
+ * empty list — the caller decides the recovery state from the typed error.
+ */
+ async list(userId: string): Promise {
+ if (!SESSION_INGEST_WORKER_URL) {
+ throw new LocalRuntimeControlRequestError('Session ingest worker URL is not configured');
+ }
+
+ const token = buildAudienceToken(userId);
+
+ let response: Response;
+ try {
+ response = await fetch(`${SESSION_INGEST_WORKER_URL}/internal/runtime-control/runtimes`, {
+ method: 'GET',
+ headers: { Authorization: `Bearer ${token}` },
+ signal: AbortSignal.timeout(RUNTIME_LIST_TIMEOUT_MS),
+ });
+ } catch {
+ throw new LocalRuntimeControlRequestError('Local runtime list request failed');
+ }
+
+ if (!response.ok) {
+ throw new LocalRuntimeControlRequestError(
+ `Local runtime list request failed (${response.status})`
+ );
+ }
+
+ const parsed = localRuntimeListResponseSchema.safeParse(await readJson(response));
+ if (!parsed.success) {
+ throw new LocalRuntimeControlRequestError('Local runtime list response was malformed');
+ }
+
+ return parsed.data;
+ },
+
+ /**
+ * Fetch the model catalog for a specific runtime fence. The fence is the
+ * exact (runtimeId, connectionId) pair mobile resolved from the runtime
+ * list; the relay validates that the fence still matches the live socket
+ * and routes the catalog command to that exact socket. The response
+ * `catalog.models` is the canonical wire catalog from the CLI, parsed
+ * through the existing `remoteModelCatalogV1Schema` so the shape mobile
+ * receives matches every other remote-model surface in the web app.
+ *
+ * Failures collapse to `LocalRuntimeCatalogError` with a stable
+ * `upstreamCode` (always one of the
+ * `LocalRuntimeControlErrorCode` values, or `UNKNOWN` for an
+ * unparseable envelope). The caller MUST branch on `upstreamCode` to
+ * choose the recovery flow.
+ */
+ async getCatalog(userId: string, fence: LocalRuntimeFence): Promise {
+ if (!SESSION_INGEST_WORKER_URL) {
+ throw new LocalRuntimeCatalogError('UNKNOWN', 'Session ingest worker URL is not configured');
+ }
+
+ const token = buildAudienceToken(userId);
+
+ const body = JSON.stringify({
+ fence: localRuntimeFenceSchema.parse(fence),
+ request: getLocalRuntimeCatalogRequestSchema.parse({ protocolVersion: 1 }),
+ });
+
+ let response: Response;
+ try {
+ response = await fetch(`${SESSION_INGEST_WORKER_URL}/internal/runtime-control/catalog`, {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${token}`,
+ 'content-type': 'application/json',
+ },
+ body,
+ signal: AbortSignal.timeout(RUNTIME_CATALOG_TIMEOUT_MS),
+ });
+ } catch {
+ throw new LocalRuntimeCatalogError('UNKNOWN', 'Local runtime catalog request failed');
+ }
+
+ if (!response.ok) {
+ // Attempt to extract a structured upstream code from the body without
+ // surfacing any other content. A non-2xx with a parseable envelope
+ // becomes a typed error; otherwise we fall back to UNKNOWN.
+ const raw = await response.text().catch(() => '');
+ let upstreamCode = 'UNKNOWN';
+ try {
+ const envelope = localRuntimeErrorResponseSchema.safeParse(JSON.parse(raw));
+ if (envelope.success) {
+ const codeParse = localRuntimeControlErrorCodeSchema.safeParse(envelope.data.error.code);
+ if (codeParse.success) upstreamCode = codeParse.data;
+ }
+ } catch {
+ // ignore — the body was not JSON; keep UNKNOWN
+ }
+ throw new LocalRuntimeCatalogError(
+ upstreamCode,
+ `Local runtime catalog request failed (${response.status})`
+ );
+ }
+
+ let parsedBody: unknown;
+ try {
+ parsedBody = JSON.parse(await response.text());
+ } catch {
+ throw new LocalRuntimeCatalogError(
+ 'UNKNOWN',
+ 'Local runtime catalog response was not valid JSON'
+ );
+ }
+
+ const envelope = localRuntimeCatalogResponseSchema.safeParse(parsedBody);
+ if (!envelope.success) {
+ // The envelope failed strict validation; the upstream returned a
+ // malformed body, not a structured error. Surface as INVALID_RUNTIME_RESPONSE
+ // if the body has a recognizable error code, otherwise UNKNOWN.
+ const fallback = localRuntimeErrorResponseSchema.safeParse(parsedBody);
+ if (fallback.success) {
+ throw new LocalRuntimeCatalogError(fallback.data.error.code, fallback.data.error.message);
+ }
+ throw new LocalRuntimeCatalogError('UNKNOWN', 'Local runtime catalog response was malformed');
+ }
+
+ const modelsParse = remoteModelCatalogV1Schema.safeParse(envelope.data.catalog.models);
+ if (!modelsParse.success) {
+ throw new LocalRuntimeCatalogError(
+ 'INVALID_RUNTIME_RESPONSE',
+ 'Local runtime catalog models failed strict validation'
+ );
+ }
+
+ return {
+ catalog: {
+ protocolVersion: 1,
+ models: modelsParse.data,
+ agents: envelope.data.catalog.agents,
+ defaultAgent: envelope.data.catalog.defaultAgent,
+ },
+ };
+ },
+
+ /**
+ * Forward a create-and-run request to the bound runtime. The fence is
+ * the exact (runtimeId, connectionId) pair mobile resolved from the
+ * runtime list; the relay validates that the fence still matches the
+ * live socket and routes the strict request to that exact socket. The
+ * CLI is the authority on the session ID and prompt-start outcome; the
+ * client NEVER mints, mutates, or fabricates the session ID.
+ *
+ * The response is the strict `{ result }` envelope where `result` is
+ * the CLI's typed success or `promptStarted:false` partial. The
+ * server-side readiness wait against `cli_sessions_v2` is owned by the
+ * `localRuntimeControl.createAndRun` tRPC mutation, not by this
+ * client.
+ *
+ * Failures collapse to `LocalRuntimeCreateAndRunError` with a stable
+ * `upstreamCode` (always one of the
+ * `LocalRuntimeControlErrorCode` values, or `UNKNOWN` for an
+ * unparseable envelope). The caller MUST branch on `upstreamCode` to
+ * choose the recovery flow.
+ */
+ async createAndRun(
+ userId: string,
+ fence: LocalRuntimeFence,
+ request: CreateAndRunLocalSessionRequest
+ ): Promise<{ result: CreateAndRunLocalSessionResult }> {
+ if (!SESSION_INGEST_WORKER_URL) {
+ throw new LocalRuntimeCreateAndRunError(
+ 'UNKNOWN',
+ 'Session ingest worker URL is not configured'
+ );
+ }
+
+ const token = buildAudienceToken(userId);
+ const parsedFence = localRuntimeFenceSchema.parse(fence);
+ const parsedRequest = createAndRunLocalSessionRequestSchema.parse(request);
+ const body = JSON.stringify({ fence: parsedFence, request: parsedRequest });
+
+ let response: Response;
+ try {
+ response = await fetch(
+ `${SESSION_INGEST_WORKER_URL}/internal/runtime-control/create-and-run`,
+ {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${token}`,
+ 'content-type': 'application/json',
+ },
+ body,
+ signal: AbortSignal.timeout(RUNTIME_CREATE_AND_RUN_TIMEOUT_MS),
+ }
+ );
+ } catch {
+ throw new LocalRuntimeCreateAndRunError(
+ 'UNKNOWN',
+ 'Local runtime create-and-run request failed'
+ );
+ }
+
+ if (!response.ok) {
+ // Attempt to extract a structured upstream code from the body without
+ // surfacing any other content. A non-2xx with a parseable envelope
+ // becomes a typed error; otherwise we fall back to UNKNOWN.
+ const raw = await response.text().catch(() => '');
+ let upstreamCode = 'UNKNOWN';
+ try {
+ const envelope = localRuntimeErrorResponseSchema.safeParse(JSON.parse(raw));
+ if (envelope.success) {
+ const codeParse = localRuntimeControlErrorCodeSchema.safeParse(envelope.data.error.code);
+ if (codeParse.success) upstreamCode = codeParse.data;
+ }
+ } catch {
+ // ignore — the body was not JSON; keep UNKNOWN
+ }
+ throw new LocalRuntimeCreateAndRunError(
+ upstreamCode,
+ `Local runtime create-and-run request failed (${response.status})`
+ );
+ }
+
+ let parsedBody: unknown;
+ try {
+ parsedBody = JSON.parse(await response.text());
+ } catch {
+ throw new LocalRuntimeCreateAndRunError(
+ 'UNKNOWN',
+ 'Local runtime create-and-run response was not valid JSON'
+ );
+ }
+
+ const envelope = localRuntimeCreateResponseSchema.safeParse(parsedBody);
+ if (!envelope.success) {
+ // The envelope failed strict validation; the upstream returned a
+ // malformed body, not a structured error. Surface as INVALID_RUNTIME_RESPONSE
+ // if the body has a recognizable error code, otherwise UNKNOWN.
+ const fallback = localRuntimeErrorResponseSchema.safeParse(parsedBody);
+ if (fallback.success) {
+ throw new LocalRuntimeCreateAndRunError(
+ fallback.data.error.code,
+ fallback.data.error.message
+ );
+ }
+ throw new LocalRuntimeCreateAndRunError(
+ 'UNKNOWN',
+ 'Local runtime create-and-run response was malformed'
+ );
+ }
+
+ return { result: envelope.data.result };
+ },
+};
diff --git a/apps/web/src/lib/local-runtime-control/readiness.test.ts b/apps/web/src/lib/local-runtime-control/readiness.test.ts
new file mode 100644
index 0000000000..cd1b020871
--- /dev/null
+++ b/apps/web/src/lib/local-runtime-control/readiness.test.ts
@@ -0,0 +1,218 @@
+import { waitForOwnedCliSession, READINESS_INTERVAL_MS, READINESS_MAX_ATTEMPTS } from './readiness';
+
+const SESSION_ID = 'ses_a1b2c3d4e5f67890123456789a';
+const USER_ID = 'usr_alice';
+const ORG_ID = 'org_123e4567-e89b-12d3-a456-426614174000';
+
+function makeQuery(rows: Array<{ organizationId: string | null } | null>) {
+ const calls: Array<{ sessionId: string; kiloUserId: string }> = [];
+ let next = 0;
+ return {
+ calls,
+ query: jest.fn(async (sessionId: string, kiloUserId: string) => {
+ calls.push({ sessionId, kiloUserId });
+ if (next >= rows.length) return null;
+ const row = rows[next++] ?? null;
+ return row;
+ }),
+ };
+}
+
+function makePassThroughAccess() {
+ return jest.fn(async () => undefined);
+}
+
+describe('READINESS_INTERVAL_MS and READINESS_MAX_ATTEMPTS', () => {
+ it('uses a 250ms interval and a 40-attempt bound (10s total) in production', () => {
+ expect(READINESS_INTERVAL_MS).toBe(250);
+ expect(READINESS_MAX_ATTEMPTS).toBe(40);
+ });
+});
+
+describe('waitForOwnedCliSession', () => {
+ it('scopes the DB query to BOTH session_id AND kilo_user_id (no other-user leak)', async () => {
+ const { query, calls } = makeQuery([null]);
+ const result = await waitForOwnedCliSession({
+ sessionId: SESSION_ID,
+ userId: USER_ID,
+ deps: {
+ query,
+ ensureOrganizationAccess: makePassThroughAccess(),
+ sleep: jest.fn(async () => undefined),
+ intervalMs: 10,
+ maxAttempts: 3,
+ },
+ });
+
+ expect(result).toBeNull();
+ expect(calls).toEqual([
+ { sessionId: SESSION_ID, kiloUserId: USER_ID },
+ { sessionId: SESSION_ID, kiloUserId: USER_ID },
+ { sessionId: SESSION_ID, kiloUserId: USER_ID },
+ ]);
+ // No call ever uses a different user id — non-leaking shape
+ expect(calls.every(c => c.kiloUserId === USER_ID)).toBe(true);
+ });
+
+ it('returns the organizationId and makes no extra query when found on the first attempt', async () => {
+ const { query, calls } = makeQuery([{ organizationId: ORG_ID }]);
+ const ensureOrganizationAccess = makePassThroughAccess();
+ const sleep = jest.fn(async () => undefined);
+
+ const result = await waitForOwnedCliSession({
+ sessionId: SESSION_ID,
+ userId: USER_ID,
+ deps: { query, ensureOrganizationAccess, sleep },
+ });
+
+ expect(result).toEqual({ organizationId: ORG_ID });
+ expect(calls).toHaveLength(1);
+ expect(sleep).toHaveBeenCalledTimes(0);
+ });
+
+ it('polls at the injected interval and returns on attempt N (sleep count = N-1, query count = N)', async () => {
+ const { query, calls } = makeQuery([null, null, null, { organizationId: null }]);
+ const ensureOrganizationAccess = makePassThroughAccess();
+ const sleep = jest.fn(async () => undefined);
+
+ const result = await waitForOwnedCliSession({
+ sessionId: SESSION_ID,
+ userId: USER_ID,
+ deps: { query, ensureOrganizationAccess, sleep, intervalMs: 25, maxAttempts: 10 },
+ });
+
+ expect(result).toEqual({ organizationId: null });
+ expect(calls).toHaveLength(4);
+ expect(sleep).toHaveBeenCalledTimes(3);
+ expect(sleep).toHaveBeenNthCalledWith(1, 25);
+ expect(sleep).toHaveBeenNthCalledWith(2, 25);
+ expect(sleep).toHaveBeenNthCalledWith(3, 25);
+ });
+
+ it('uses the production defaults (250ms interval, 40 attempts) when no override is provided', async () => {
+ const rows: Array<{ organizationId: string | null } | null> = Array.from(
+ { length: 39 },
+ () => null
+ );
+ rows.push({ organizationId: null });
+ const { query, calls } = makeQuery(rows);
+ const ensureOrganizationAccess = makePassThroughAccess();
+ const sleep = jest.fn(async () => undefined);
+
+ const result = await waitForOwnedCliSession({
+ sessionId: SESSION_ID,
+ userId: USER_ID,
+ deps: { query, ensureOrganizationAccess, sleep },
+ });
+
+ expect(result).toEqual({ organizationId: null });
+ expect(calls).toHaveLength(40);
+ expect(sleep).toHaveBeenCalledTimes(39);
+ expect(sleep).toHaveBeenNthCalledWith(1, 250);
+ });
+
+ it('returns null and exhausts the attempt bound when the row never appears', async () => {
+ const { query, calls } = makeQuery(Array.from({ length: 5 }, () => null));
+ const ensureOrganizationAccess = makePassThroughAccess();
+ const sleep = jest.fn(async () => undefined);
+
+ const result = await waitForOwnedCliSession({
+ sessionId: SESSION_ID,
+ userId: USER_ID,
+ deps: { query, ensureOrganizationAccess, sleep, intervalMs: 10, maxAttempts: 5 },
+ });
+
+ expect(result).toBeNull();
+ expect(calls).toHaveLength(5);
+ expect(sleep).toHaveBeenCalledTimes(4);
+ });
+
+ it('validates current user membership when the row has a non-null organizationId', async () => {
+ const { query } = makeQuery([{ organizationId: ORG_ID }]);
+ const ensureOrganizationAccess = jest.fn(async (orgId: string) => {
+ expect(orgId).toBe(ORG_ID);
+ });
+ const sleep = jest.fn(async () => undefined);
+
+ const result = await waitForOwnedCliSession({
+ sessionId: SESSION_ID,
+ userId: USER_ID,
+ deps: { query, ensureOrganizationAccess, sleep },
+ });
+
+ expect(result).toEqual({ organizationId: ORG_ID });
+ expect(ensureOrganizationAccess).toHaveBeenCalledTimes(1);
+ expect(ensureOrganizationAccess).toHaveBeenCalledWith(ORG_ID);
+ });
+
+ it('does NOT validate membership when the row has a null organizationId (personal session)', async () => {
+ const { query } = makeQuery([{ organizationId: null }]);
+ const ensureOrganizationAccess = makePassThroughAccess();
+ const sleep = jest.fn(async () => undefined);
+
+ const result = await waitForOwnedCliSession({
+ sessionId: SESSION_ID,
+ userId: USER_ID,
+ deps: { query, ensureOrganizationAccess, sleep },
+ });
+
+ expect(result).toEqual({ organizationId: null });
+ expect(ensureOrganizationAccess).not.toHaveBeenCalled();
+ });
+
+ it('propagates an ensureOrganizationAccess failure (the caller decides the recovery)', async () => {
+ const { query } = makeQuery([{ organizationId: ORG_ID }]);
+ const accessError = new Error('not a member');
+ const ensureOrganizationAccess = jest.fn(async () => {
+ throw accessError;
+ });
+ const sleep = jest.fn(async () => undefined);
+
+ await expect(
+ waitForOwnedCliSession({
+ sessionId: SESSION_ID,
+ userId: USER_ID,
+ deps: { query, ensureOrganizationAccess, sleep },
+ })
+ ).rejects.toBe(accessError);
+ expect(ensureOrganizationAccess).toHaveBeenCalledTimes(1);
+ });
+
+ it('returns after one query and no sleep when maxAttempts is 1', async () => {
+ const { query, calls } = makeQuery([null]);
+ const sleep = jest.fn(async () => undefined);
+
+ const result = await waitForOwnedCliSession({
+ sessionId: SESSION_ID,
+ userId: USER_ID,
+ deps: {
+ query,
+ ensureOrganizationAccess: makePassThroughAccess(),
+ sleep,
+ maxAttempts: 1,
+ },
+ });
+
+ expect(result).toBeNull();
+ expect(calls).toHaveLength(1);
+ expect(sleep).not.toHaveBeenCalled();
+ });
+
+ it('propagates a query failure rather than retrying', async () => {
+ const query = jest.fn(async () => {
+ throw new Error('db is down');
+ });
+ const ensureOrganizationAccess = makePassThroughAccess();
+ const sleep = jest.fn(async () => undefined);
+
+ await expect(
+ waitForOwnedCliSession({
+ sessionId: SESSION_ID,
+ userId: USER_ID,
+ deps: { query, ensureOrganizationAccess, sleep, intervalMs: 10, maxAttempts: 5 },
+ })
+ ).rejects.toThrow('db is down');
+ expect(query).toHaveBeenCalledTimes(1);
+ expect(sleep).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/web/src/lib/local-runtime-control/readiness.ts b/apps/web/src/lib/local-runtime-control/readiness.ts
new file mode 100644
index 0000000000..8f78878d9a
--- /dev/null
+++ b/apps/web/src/lib/local-runtime-control/readiness.ts
@@ -0,0 +1,126 @@
+import 'server-only';
+
+import { cli_sessions_v2 } from '@kilocode/db/schema';
+import { and, eq } from 'drizzle-orm';
+import { db } from '@/lib/drizzle';
+import { ensureOrganizationAccess } from '@/routers/organizations/utils';
+import type { TRPCContext } from '@/lib/trpc/init';
+
+/**
+ * Production polling interval for the server-side readiness wait. Matches the
+ * contract surfaced to mobile: 250ms between attempts. The bound is
+ * {@link READINESS_MAX_ATTEMPTS} attempts (40 → 10s total).
+ */
+export const READINESS_INTERVAL_MS = 250;
+
+/**
+ * Production attempt bound. Together with {@link READINESS_INTERVAL_MS} this
+ * gives a 10-second total wait, which is short enough to keep the create-and-run
+ * mutation responsive and long enough to absorb the normal CLI announce
+ * round-trip plus the row write.
+ */
+export const READINESS_MAX_ATTEMPTS = 40;
+
+export type ReadinessRow = {
+ organizationId: string | null;
+};
+
+export type ReadinessDeps = {
+ /**
+ * Scoped DB probe. MUST filter by BOTH `session_id` and `kilo_user_id` so a
+ * missing row and a row owned by another user both collapse to `null`. The
+ * module never branches on the failure mode, so the caller can treat
+ * "not found" and "other user" identically (always pending).
+ */
+ query: (sessionId: string, kiloUserId: string) => Promise;
+ /**
+ * Validate the caller's current membership in the row's organization. The
+ * router wires this to `ensureOrganizationAccess(ctx, organizationId)`.
+ * Throws when the caller is no longer a member.
+ */
+ ensureOrganizationAccess: (organizationId: string) => Promise;
+ /**
+ * Sleep helper. Optional; defaults to a 250ms-constant `setTimeout` Promise
+ * so production callers can omit it. Tests inject a no-op `sleep` so they
+ * never perform real waits.
+ */
+ sleep?: (ms: number) => Promise;
+ /** Optional override for the per-attempt interval. Defaults to 250. */
+ intervalMs?: number;
+ /** Optional override for the attempt bound. Defaults to 40. */
+ maxAttempts?: number;
+};
+
+/**
+ * Production wiring. The router builds this once per call so the membership
+ * helper sees the same `ctx` as the surrounding procedure.
+ */
+export function defaultReadinessDeps(ctx: TRPCContext): ReadinessDeps {
+ return {
+ query: async (sessionId, kiloUserId) => {
+ const [row] = await db
+ .select({ organizationId: cli_sessions_v2.organization_id })
+ .from(cli_sessions_v2)
+ .where(
+ and(
+ eq(cli_sessions_v2.session_id, sessionId),
+ eq(cli_sessions_v2.kilo_user_id, kiloUserId)
+ )
+ )
+ .limit(1);
+ if (!row) return null;
+ return { organizationId: row.organizationId ?? null };
+ },
+ ensureOrganizationAccess: async organizationId => {
+ await ensureOrganizationAccess(ctx, organizationId);
+ },
+ sleep: ms => new Promise(resolve => setTimeout(resolve, ms)),
+ };
+}
+
+/**
+ * Deterministic, side-effect-free readiness probe.
+ *
+ * Polls the owned `cli_sessions_v2` row at the configured interval and returns
+ * `{ organizationId }` when the row appears. When the row's organizationId is
+ * non-null, the caller's current membership is re-validated via the injected
+ * `ensureOrganizationAccess` — a removed member is the caller's responsibility
+ * to surface, the module does NOT swallow the rejection.
+ *
+ * Returns `null` when the row is not observed within the bound, so the caller
+ * can return a `session_not_ready` recovery envelope without leaking whether
+ * the session exists at all.
+ *
+ * A non-null row that fails the membership check throws — that is the
+ * documented FORBIDDEN surface for `cliSessionsV2.readiness`. The
+ * `localRuntimeControl.createAndRun` mutation surfaces a session_not_ready
+ * recovery path because the same rejection is its own error class.
+ */
+export async function waitForOwnedCliSession(params: {
+ sessionId: string;
+ userId: string;
+ deps: ReadinessDeps;
+}): Promise {
+ const intervalMs = params.deps.intervalMs ?? READINESS_INTERVAL_MS;
+ const maxAttempts = params.deps.maxAttempts ?? READINESS_MAX_ATTEMPTS;
+ const sleep = params.deps.sleep ?? defaultSleep;
+ if (maxAttempts < 1) {
+ return null;
+ }
+
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
+ const row = await params.deps.query(params.sessionId, params.userId);
+ if (row !== null) {
+ if (row.organizationId !== null) {
+ await params.deps.ensureOrganizationAccess(row.organizationId);
+ }
+ return row;
+ }
+ if (attempt < maxAttempts - 1) {
+ await sleep(intervalMs);
+ }
+ }
+ return null;
+}
+
+const defaultSleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
diff --git a/apps/web/src/routers/cli-sessions-v2-router.test.ts b/apps/web/src/routers/cli-sessions-v2-router.test.ts
index 01c8ef2cbf..8fba2baae9 100644
--- a/apps/web/src/routers/cli-sessions-v2-router.test.ts
+++ b/apps/web/src/routers/cli-sessions-v2-router.test.ts
@@ -13,7 +13,21 @@ import {
import { eq, and } from 'drizzle-orm';
import type { User, Organization } from '@kilocode/db/schema';
import * as githubAdapter from '@/lib/integrations/platforms/github/adapter';
-import { parseGitHubOwnerRepo } from '@/routers/cli-sessions-v2-router';
+import { cliSessionsV2Router, parseGitHubOwnerRepo } from '@/routers/cli-sessions-v2-router';
+import type * as ReadinessModule from '@/lib/local-runtime-control/readiness';
+
+jest.mock('@/lib/local-runtime-control/readiness', () => {
+ const actual = jest.requireActual(
+ '@/lib/local-runtime-control/readiness'
+ );
+ return {
+ ...actual,
+ // Probe is exercised end-to-end in its own unit suite; the router test only
+ // needs a controllable single-shot stub. The implementation re-reads the DB
+ // on its own to honor the scoped (sessionId, kiloUserId) contract.
+ waitForOwnedCliSession: jest.fn(),
+ };
+});
jest.mock('@/lib/config.server', () => {
const actual: Record = jest.requireActual('@/lib/config.server');
@@ -1334,4 +1348,185 @@ describe('cli-sessions-v2-router', () => {
}
});
});
+
+ describe('readiness', () => {
+ let mockedWaitForOwnedCliSession: jest.MockedFunction<
+ typeof ReadinessModule.waitForOwnedCliSession
+ >;
+
+ // sessionIdSchema enforces a 30-character `ses_…` ID.
+ const SESSION_ID = 'ses_readiness_query_owned_30ch';
+ const OTHER_SESSION_ID = 'ses_readiness_query_other_30ch';
+ const MISSING_SESSION_ID = 'ses_readiness_query_miss_30chr';
+
+ type ReadinessOutcome = { organizationId: string | null } | null;
+
+ function makeReadinessMock() {
+ const mocked = jest.mocked(
+ jest.requireMock('@/lib/local-runtime-control/readiness')
+ .waitForOwnedCliSession
+ );
+ // Mirror the production probe exactly: read the owned row, then run
+ // ensureOrganizationAccess on the resolved organizationId. We honor the
+ // call's `deps` contract — including the membership re-check that drives
+ // the FORBIDDEN surface for removed members.
+ mocked.mockImplementation(
+ async (params: {
+ sessionId: string;
+ userId: string;
+ deps: {
+ query: (s: string, u: string) => Promise;
+ ensureOrganizationAccess: (organizationId: string) => Promise;
+ };
+ }): Promise => {
+ const row = await params.deps.query(params.sessionId, params.userId);
+ if (row === null) return null;
+ if (row.organizationId !== null) {
+ await params.deps.ensureOrganizationAccess(row.organizationId);
+ }
+ return row;
+ }
+ );
+ return mocked;
+ }
+
+ beforeEach(async () => {
+ await db.insert(cli_sessions_v2).values({
+ session_id: SESSION_ID,
+ kilo_user_id: regularUser.id,
+ created_on_platform: 'cloud-agent',
+ });
+ await db.insert(cli_sessions_v2).values({
+ session_id: OTHER_SESSION_ID,
+ kilo_user_id: otherUser.id,
+ created_on_platform: 'cloud-agent',
+ });
+ mockedWaitForOwnedCliSession = makeReadinessMock();
+ });
+
+ afterEach(async () => {
+ await db.delete(cli_sessions_v2).where(eq(cli_sessions_v2.session_id, SESSION_ID));
+ await db.delete(cli_sessions_v2).where(eq(cli_sessions_v2.session_id, OTHER_SESSION_ID));
+ });
+
+ it('is registered on the router', () => {
+ const names = Object.keys(cliSessionsV2Router._def.procedures);
+ expect(names).toContain('readiness');
+ });
+
+ it('rejects an invalid session_id shape', async () => {
+ const caller = await createCallerForUser(regularUser.id);
+ await expect(caller.cliSessionsV2.readiness({ session_id: 'bad' })).rejects.toMatchObject({
+ code: 'BAD_REQUEST',
+ });
+ });
+
+ it('returns pending for a session that does not exist (no leak of existence)', async () => {
+ const caller = await createCallerForUser(regularUser.id);
+ const result = await caller.cliSessionsV2.readiness({ session_id: MISSING_SESSION_ID });
+ expect(result).toEqual({ status: 'pending' });
+ });
+
+ it('returns pending for a session owned by a different user (no cross-user leak)', async () => {
+ const caller = await createCallerForUser(regularUser.id);
+ const result = await caller.cliSessionsV2.readiness({ session_id: OTHER_SESSION_ID });
+ expect(result).toEqual({ status: 'pending' });
+ });
+
+ it('returns ready with null organizationId for a personal session owned by the caller', async () => {
+ const caller = await createCallerForUser(regularUser.id);
+ const result = await caller.cliSessionsV2.readiness({ session_id: SESSION_ID });
+ expect(result).toEqual({ status: 'ready', organizationId: null });
+ });
+
+ it('returns ready with the organizationId for an org session when the caller is still a member', async () => {
+ await db.insert(organization_memberships).values({
+ organization_id: testOrganization.id,
+ kilo_user_id: regularUser.id,
+ role: 'member',
+ });
+ await db
+ .update(cli_sessions_v2)
+ .set({ organization_id: testOrganization.id })
+ .where(eq(cli_sessions_v2.session_id, SESSION_ID));
+
+ try {
+ const caller = await createCallerForUser(regularUser.id);
+ const result = await caller.cliSessionsV2.readiness({ session_id: SESSION_ID });
+ expect(result).toEqual({
+ status: 'ready',
+ organizationId: testOrganization.id,
+ });
+ } finally {
+ await db
+ .update(cli_sessions_v2)
+ .set({ organization_id: null })
+ .where(eq(cli_sessions_v2.session_id, SESSION_ID));
+ await db
+ .delete(organization_memberships)
+ .where(
+ and(
+ eq(organization_memberships.organization_id, testOrganization.id),
+ eq(organization_memberships.kilo_user_id, regularUser.id)
+ )
+ );
+ }
+ });
+
+ it('returns FORBIDDEN for an org session only AFTER the owned row exists, when the caller is no longer a member', async () => {
+ // Attach the session to the org even though regularUser has no membership
+ // in the test fixture (the beforeAll creates regularUser, but we never
+ // give them a membership in this test).
+ await db
+ .update(cli_sessions_v2)
+ .set({ organization_id: testOrganization.id })
+ .where(eq(cli_sessions_v2.session_id, SESSION_ID));
+
+ try {
+ const caller = await createCallerForUser(regularUser.id);
+ await expect(
+ caller.cliSessionsV2.readiness({ session_id: SESSION_ID })
+ ).rejects.toMatchObject({ code: 'FORBIDDEN' });
+ } finally {
+ await db
+ .update(cli_sessions_v2)
+ .set({ organization_id: null })
+ .where(eq(cli_sessions_v2.session_id, SESSION_ID));
+ }
+ });
+
+ it('uses a single-shot wait with maxAttempts:1 so the public query does not poll the DB', async () => {
+ const caller = await createCallerForUser(regularUser.id);
+ await caller.cliSessionsV2.readiness({ session_id: MISSING_SESSION_ID });
+ expect(mockedWaitForOwnedCliSession).toHaveBeenCalledWith(
+ expect.objectContaining({
+ sessionId: MISSING_SESSION_ID,
+ userId: regularUser.id,
+ deps: expect.objectContaining({ maxAttempts: 1 }),
+ })
+ );
+ });
+
+ it('returns pending after a single DB attempt and resolves without waiting the full 10s budget', async () => {
+ const start = Date.now();
+ const caller = await createCallerForUser(regularUser.id);
+ const result = await caller.cliSessionsV2.readiness({ session_id: MISSING_SESSION_ID });
+ expect(result).toEqual({ status: 'pending' });
+ expect(Date.now() - start).toBeLessThan(500);
+ });
+
+ it('does not call Cloud Agent (no cloud-agent SDK import in the readiness path)', async () => {
+ const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue(
+ new Response(JSON.stringify({}), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ })
+ );
+ const caller = await createCallerForUser(regularUser.id);
+ await caller.cliSessionsV2.readiness({ session_id: SESSION_ID });
+ const calls = fetchSpy.mock.calls.map(call => String(call[0]));
+ expect(calls.some(url => url.includes('cloud-agent-next'))).toBe(false);
+ fetchSpy.mockRestore();
+ });
+ });
});
diff --git a/apps/web/src/routers/cli-sessions-v2-router.ts b/apps/web/src/routers/cli-sessions-v2-router.ts
index b25e870ec5..938ef74f1e 100644
--- a/apps/web/src/routers/cli-sessions-v2-router.ts
+++ b/apps/web/src/routers/cli-sessions-v2-router.ts
@@ -46,6 +46,8 @@ import { PLATFORM } from '@/lib/integrations/core/constants';
import { normalizeGitUrl } from '@/lib/integrations/platforms/github/normalize-git-url';
import { triggerBatchReviewDecisionFetchIfNeeded } from '@/lib/integrations/platforms/github/batch-review-decisions';
import { after } from 'next/server';
+import { waitForOwnedCliSession } from '@/lib/local-runtime-control/readiness';
+import { sessionIdSchema } from '@kilocode/session-ingest-contracts';
/**
* Check if an error indicates the session was not found in the cloud-agent DO.
@@ -1298,4 +1300,70 @@ export const cliSessionsV2Router = createTRPCRouter({
return { share_id: body.public_id, session_id: input.kilo_session_id };
}),
+
+ /**
+ * Public readiness probe for a single session owned by the caller. Used by
+ * mobile to recover from a `localRuntimeControl.createAndRun` that returned
+ * `session_not_ready`. The probe is a single-shot DB read; bounded polling
+ * is owned by the readiness module's `waitForOwnedCliSession` and runs only
+ * inside the createAndRun mutation.
+ *
+ * - `pending` — the row is not observable to the caller. This collapses
+ * "missing" and "owned by a different user" into a single non-leaking
+ * state so the existence of an unrelated session is never revealed.
+ * - `ready` — the row is owned by the caller; `organizationId` is the
+ * row's value (string or null). For org-scoped rows, the caller's current
+ * membership is re-validated; a removed member receives FORBIDDEN.
+ *
+ * The query never calls the cloud-agent DO — the readiness path is owned
+ * entirely by `cli_sessions_v2`. The cloud-agent SDK is only touched by
+ * session fetches that need runtime state.
+ */
+ readiness: baseProcedure
+ .input(z.object({ session_id: sessionIdSchema }).strict())
+ .query(async ({ ctx, input }) => {
+ const ready = await waitForOwnedCliSession({
+ sessionId: input.session_id,
+ userId: ctx.user.id,
+ deps: {
+ maxAttempts: 1,
+ query: async (sessionId, kiloUserId) => {
+ const [row] = await db
+ .select({ organizationId: cli_sessions_v2.organization_id })
+ .from(cli_sessions_v2)
+ .where(
+ and(
+ eq(cli_sessions_v2.session_id, sessionId),
+ eq(cli_sessions_v2.kilo_user_id, kiloUserId)
+ )
+ )
+ .limit(1);
+ if (!row) return null;
+ return { organizationId: row.organizationId ?? null };
+ },
+ ensureOrganizationAccess: async organizationId => {
+ try {
+ await ensureOrganizationAccess(ctx, organizationId);
+ } catch (err) {
+ if (err instanceof TRPCError && err.code === 'UNAUTHORIZED') {
+ throw new TRPCError({
+ code: 'FORBIDDEN',
+ message: 'You no longer have access to this organization',
+ cause: err,
+ });
+ }
+ throw err;
+ }
+ },
+ },
+ });
+
+ if (ready === null) {
+ return { status: 'pending' as const };
+ }
+ return {
+ status: 'ready' as const,
+ organizationId: ready.organizationId,
+ };
+ }),
});
diff --git a/apps/web/src/routers/local-runtime-control-router.test.ts b/apps/web/src/routers/local-runtime-control-router.test.ts
new file mode 100644
index 0000000000..79d57da5d8
--- /dev/null
+++ b/apps/web/src/routers/local-runtime-control-router.test.ts
@@ -0,0 +1,521 @@
+import { jest } from '@jest/globals';
+import { TRPCError } from '@trpc/server';
+import type * as LocalRuntimeControlClientModule from '@/lib/local-runtime-control/client';
+import type * as ReadinessModule from '@/lib/local-runtime-control/readiness';
+import { UpstreamApiError } from '@/lib/trpc/init';
+import type * as TestUtilsModule from '@/routers/test-utils';
+import type * as RootRouterModule from '@/routers/root-router';
+import type * as UserHelperModule from '@/tests/helpers/user.helper';
+import type { User } from '@kilocode/db/schema';
+import type { LocalRuntimeControlErrorCode } from '@kilocode/session-ingest-contracts';
+
+jest.mock('@/lib/local-runtime-control/client', () => {
+ const {
+ LocalRuntimeControlRequestError,
+ LocalRuntimeCatalogError,
+ LocalRuntimeCreateAndRunError,
+ } = jest.requireActual(
+ '@/lib/local-runtime-control/client'
+ );
+ return {
+ LocalRuntimeControlRequestError,
+ LocalRuntimeCatalogError,
+ LocalRuntimeCreateAndRunError,
+ LocalRuntimeControlClient: {
+ list: jest.fn(),
+ getCatalog: jest.fn(),
+ createAndRun: jest.fn(),
+ },
+ };
+});
+
+jest.mock('@/lib/local-runtime-control/readiness', () => {
+ const actual = jest.requireActual(
+ '@/lib/local-runtime-control/readiness'
+ );
+ return {
+ ...actual,
+ waitForOwnedCliSession: jest.fn(),
+ };
+});
+
+const mockedList = jest.mocked(
+ jest.requireMock('@/lib/local-runtime-control/client')
+ .LocalRuntimeControlClient.list
+);
+
+const mockedGetCatalog = jest.mocked(
+ jest.requireMock('@/lib/local-runtime-control/client')
+ .LocalRuntimeControlClient.getCatalog
+);
+
+const mockedCreateAndRun = jest.mocked(
+ jest.requireMock('@/lib/local-runtime-control/client')
+ .LocalRuntimeControlClient.createAndRun
+);
+
+const mockedWaitForOwnedCliSession = jest.mocked(
+ jest.requireMock('@/lib/local-runtime-control/readiness')
+ .waitForOwnedCliSession
+);
+
+const { LocalRuntimeControlRequestError, LocalRuntimeCatalogError, LocalRuntimeCreateAndRunError } =
+ jest.requireActual('@/lib/local-runtime-control/client');
+
+describe('localRuntimeControl router', () => {
+ let user: User;
+ let createCallerForUser: typeof TestUtilsModule.createCallerForUser;
+ let rootRouter: typeof RootRouterModule.rootRouter;
+ let insertTestUser: typeof UserHelperModule.insertTestUser;
+
+ beforeAll(async () => {
+ const testUtils = await import('@/routers/test-utils');
+ createCallerForUser = testUtils.createCallerForUser;
+
+ const rootRouterMod = await import('@/routers/root-router');
+ rootRouter = rootRouterMod.rootRouter;
+
+ const userHelper = await import('@/tests/helpers/user.helper');
+ insertTestUser = userHelper.insertTestUser;
+
+ user = await insertTestUser({
+ google_user_email: 'local-runtime-control@example.com',
+ google_user_name: 'Local Runtime Control',
+ });
+ });
+
+ beforeEach(() => {
+ mockedList.mockReset();
+ mockedGetCatalog.mockReset();
+ mockedCreateAndRun.mockReset();
+ mockedWaitForOwnedCliSession.mockReset();
+ });
+
+ it('is registered on the root router under localRuntimeControl.list', () => {
+ expect(Object.keys(rootRouter._def.procedures)).toContain('localRuntimeControl.list');
+ });
+
+ it('returns the empty runtime list when the client returns an empty list', async () => {
+ mockedList.mockResolvedValueOnce({ runtimes: [] });
+
+ const caller = await createCallerForUser(user.id);
+ const result = await caller.localRuntimeControl.list();
+
+ expect(result).toEqual({ runtimes: [] });
+ expect(mockedList).toHaveBeenCalledWith(user.id);
+ });
+
+ it('returns all first-class runtimes including capability-missing entries', async () => {
+ mockedList.mockResolvedValueOnce({
+ runtimes: [
+ {
+ runtimeId: '0c0a1b2c-3d4e-4f60-8a8b-9c0d1e2f3a4b',
+ connectionId: 'cli-1',
+ protocolVersion: 1,
+ cliVersion: '7.4.7',
+ displayName: 'Alice Mac',
+ projectName: 'customer-repo',
+ capabilities: ['catalog.v1', 'create-and-run.v1'],
+ },
+ {
+ runtimeId: '1c0a1b2c-3d4e-4f60-8a8b-9c0d1e2f3a4b',
+ connectionId: 'cli-2',
+ protocolVersion: 1,
+ cliVersion: '7.4.7',
+ displayName: 'Bob Mac',
+ projectName: 'empty-repo',
+ capabilities: ['catalog.v1'],
+ },
+ ],
+ });
+
+ const caller = await createCallerForUser(user.id);
+ const result = await caller.localRuntimeControl.list();
+
+ expect(result.runtimes).toHaveLength(2);
+ expect(result.runtimes[1]?.capabilities).toEqual(['catalog.v1']);
+ });
+
+ it('maps a client request error to a BAD_GATEWAY tRPC error', async () => {
+ mockedList.mockRejectedValueOnce(
+ new LocalRuntimeControlRequestError('Local runtime list request failed (503)')
+ );
+
+ const caller = await createCallerForUser(user.id);
+ await expect(caller.localRuntimeControl.list()).rejects.toMatchObject({
+ code: 'BAD_GATEWAY',
+ });
+ });
+
+ it('is registered on the root router under localRuntimeControl.getCatalog', () => {
+ expect(Object.keys(rootRouter._def.procedures)).toContain('localRuntimeControl.getCatalog');
+ });
+
+ describe('localRuntimeControl.getCatalog', () => {
+ const validFence = {
+ runtimeId: 'aaaaaaaa-1111-4111-8111-aaaaaaaaaaaa',
+ connectionId: 'cli-77',
+ };
+
+ const validCatalogResponse = {
+ catalog: {
+ protocolVersion: 1 as const,
+ models: {
+ protocolVersion: 1 as const,
+ providers: [],
+ truncated: false,
+ },
+ agents: [{ slug: 'build', name: 'Build' }],
+ defaultAgent: 'build',
+ },
+ };
+
+ it('rejects an invalid runtimeId', async () => {
+ const caller = await createCallerForUser(user.id);
+ await expect(
+ caller.localRuntimeControl.getCatalog({ runtimeId: 'not-a-uuid', connectionId: 'cli-1' })
+ ).rejects.toMatchObject({ code: 'BAD_REQUEST' });
+ });
+
+ it('rejects a missing connectionId', async () => {
+ const caller = await createCallerForUser(user.id);
+ await expect(
+ caller.localRuntimeControl.getCatalog({ runtimeId: validFence.runtimeId } as never)
+ ).rejects.toMatchObject({ code: 'BAD_REQUEST' });
+ });
+
+ it('rejects extra fields in the input', async () => {
+ const caller = await createCallerForUser(user.id);
+ await expect(
+ caller.localRuntimeControl.getCatalog({ ...validFence, extra: 'field' } as never)
+ ).rejects.toMatchObject({ code: 'BAD_REQUEST' });
+ });
+
+ it('returns the catalog including capability-missing agent shapes', async () => {
+ mockedGetCatalog.mockResolvedValueOnce(validCatalogResponse);
+
+ const caller = await createCallerForUser(user.id);
+ const result = await caller.localRuntimeControl.getCatalog(validFence);
+
+ expect(result).toEqual(validCatalogResponse.catalog);
+ });
+
+ it('calls the client with the user id and exact fence', async () => {
+ mockedGetCatalog.mockResolvedValueOnce(validCatalogResponse);
+
+ const caller = await createCallerForUser(user.id);
+ await caller.localRuntimeControl.getCatalog(validFence);
+
+ expect(mockedGetCatalog).toHaveBeenCalledTimes(1);
+ expect(mockedGetCatalog).toHaveBeenCalledWith(user.id, validFence);
+ });
+
+ const errorMappingCases: Array<[LocalRuntimeControlErrorCode, TRPCError['code']]> = [
+ ['RUNTIME_NOT_CONNECTED', 'NOT_FOUND'],
+ ['RUNTIME_FENCE_MISMATCH', 'CONFLICT'],
+ ['CATALOG_CHANGED', 'CONFLICT'],
+ ['COMMAND_ALREADY_PENDING', 'CONFLICT'],
+ ['CLI_UPGRADE_REQUIRED', 'PRECONDITION_FAILED'],
+ ['COMMAND_EXPIRED', 'TIMEOUT'],
+ ['PENDING_COMMAND_LIMIT', 'TOO_MANY_REQUESTS'],
+ ['COMMAND_NOT_ALLOWED', 'FORBIDDEN'],
+ ['RESULT_TOO_LARGE', 'INTERNAL_SERVER_ERROR'],
+ ['INVALID_RUNTIME_RESPONSE', 'INTERNAL_SERVER_ERROR'],
+ ['RUNTIME_COMMAND_FAILED', 'INTERNAL_SERVER_ERROR'],
+ ];
+
+ it.each(errorMappingCases)(
+ 'maps %s to a %s tRPC error with the upstream code in cause and data',
+ async (upstreamCode, expectedCode) => {
+ mockedGetCatalog.mockRejectedValueOnce(
+ new LocalRuntimeCatalogError(upstreamCode, 'upstream message')
+ );
+
+ const caller = await createCallerForUser(user.id);
+ try {
+ await caller.localRuntimeControl.getCatalog(validFence);
+ throw new Error('Expected getCatalog to reject');
+ } catch (err) {
+ expect(err).toBeInstanceOf(TRPCError);
+ if (!(err instanceof TRPCError)) throw err;
+ expect(err.code).toBe(expectedCode);
+ expect(err.cause).toBeInstanceOf(UpstreamApiError);
+ if (!(err.cause instanceof UpstreamApiError)) throw err;
+ expect(err.cause.upstreamCode).toBe(upstreamCode);
+ }
+ }
+ );
+
+ it('maps an unknown upstream code to INTERNAL_SERVER_ERROR', async () => {
+ mockedGetCatalog.mockRejectedValueOnce(
+ new LocalRuntimeCatalogError('UNKNOWN', 'upstream message')
+ );
+
+ const caller = await createCallerForUser(user.id);
+ try {
+ await caller.localRuntimeControl.getCatalog(validFence);
+ throw new Error('Expected getCatalog to reject');
+ } catch (err) {
+ expect(err).toBeInstanceOf(TRPCError);
+ if (!(err instanceof TRPCError)) throw err;
+ expect(err.code).toBe('INTERNAL_SERVER_ERROR');
+ expect(err.cause).toBeInstanceOf(UpstreamApiError);
+ if (!(err.cause instanceof UpstreamApiError)) throw err;
+ expect(err.cause.upstreamCode).toBe('UNKNOWN');
+ }
+ });
+ });
+
+ it('does not define any mutation procedures besides the documented createAndRun', () => {
+ const procedureNames = Object.keys(rootRouter._def.procedures).filter(name =>
+ name.startsWith('localRuntimeControl.')
+ );
+ expect(procedureNames.sort()).toEqual(
+ [
+ 'localRuntimeControl.createAndRun',
+ 'localRuntimeControl.getCatalog',
+ 'localRuntimeControl.list',
+ ].sort()
+ );
+ });
+});
+
+describe('localRuntimeControl.createAndRun', () => {
+ const validFence = {
+ runtimeId: 'aaaaaaaa-1111-4111-8111-aaaaaaaaaaaa',
+ connectionId: 'cli-77',
+ };
+ const validRequest = {
+ protocolVersion: 1,
+ requestId: '0c0a1b2c-3d4e-4f60-8a8b-9c0d1e2f3a4b',
+ prompt: 'hello',
+ model: { providerID: 'kilo', modelID: 'kilo/auto' },
+ agent: 'build',
+ } as const;
+ const successResult = {
+ protocolVersion: 1 as const,
+ sessionId: 'ses_a1b2c3d4e5f67890123456789a',
+ promptStarted: true as const,
+ };
+ const partialResult = {
+ protocolVersion: 1 as const,
+ sessionId: 'ses_b2c3d4e5f67890123456789cde',
+ promptStarted: false as const,
+ error: {
+ code: 'PROMPT_START_FAILED' as const,
+ message: 'The session was created, but the first prompt did not start.' as const,
+ },
+ };
+ let user: User;
+ let createCallerForUser: typeof TestUtilsModule.createCallerForUser;
+ let rootRouter: typeof RootRouterModule.rootRouter;
+ let insertTestUser: typeof UserHelperModule.insertTestUser;
+
+ beforeAll(async () => {
+ const testUtils = await import('@/routers/test-utils');
+ createCallerForUser = testUtils.createCallerForUser;
+
+ const rootRouterMod = await import('@/routers/root-router');
+ rootRouter = rootRouterMod.rootRouter;
+
+ const userHelper = await import('@/tests/helpers/user.helper');
+ insertTestUser = userHelper.insertTestUser;
+
+ user = await insertTestUser({
+ google_user_email: 'local-runtime-create@example.com',
+ google_user_name: 'Local Runtime Create',
+ });
+ });
+
+ beforeEach(() => {
+ mockedCreateAndRun.mockReset();
+ mockedWaitForOwnedCliSession.mockReset();
+ });
+
+ it('is registered on the root router under localRuntimeControl.createAndRun', () => {
+ expect(Object.keys(rootRouter._def.procedures)).toContain('localRuntimeControl.createAndRun');
+ });
+
+ it('rejects an input missing the fence or the request', async () => {
+ const caller = await createCallerForUser(user.id);
+ await expect(
+ caller.localRuntimeControl.createAndRun({ request: validRequest } as never)
+ ).rejects.toMatchObject({ code: 'BAD_REQUEST' });
+ await expect(
+ caller.localRuntimeControl.createAndRun({ fence: validFence } as never)
+ ).rejects.toMatchObject({ code: 'BAD_REQUEST' });
+ });
+
+ it('rejects an input that carries extra top-level fields', async () => {
+ const caller = await createCallerForUser(user.id);
+ await expect(
+ caller.localRuntimeControl.createAndRun({
+ fence: validFence,
+ request: validRequest,
+ extra: 'field',
+ } as never)
+ ).rejects.toMatchObject({ code: 'BAD_REQUEST' });
+ });
+
+ it('calls LocalRuntimeControlClient.createAndRun exactly once with user + fence + request', async () => {
+ mockedCreateAndRun.mockResolvedValueOnce({ result: successResult });
+ mockedWaitForOwnedCliSession.mockResolvedValueOnce({ organizationId: null });
+
+ const caller = await createCallerForUser(user.id);
+ await caller.localRuntimeControl.createAndRun({ fence: validFence, request: validRequest });
+
+ expect(mockedCreateAndRun).toHaveBeenCalledTimes(1);
+ expect(mockedCreateAndRun).toHaveBeenCalledWith(user.id, validFence, validRequest);
+ });
+
+ it('returns { status: "ready", result } when the readiness probe observes an owned row', async () => {
+ mockedCreateAndRun.mockResolvedValueOnce({ result: successResult });
+ mockedWaitForOwnedCliSession.mockResolvedValueOnce({ organizationId: null });
+
+ const caller = await createCallerForUser(user.id);
+ const result = await caller.localRuntimeControl.createAndRun({
+ fence: validFence,
+ request: validRequest,
+ });
+
+ expect(result).toEqual({ status: 'ready', result: successResult });
+ expect(mockedWaitForOwnedCliSession).toHaveBeenCalledTimes(1);
+ expect(mockedWaitForOwnedCliSession).toHaveBeenCalledWith(
+ expect.objectContaining({ sessionId: successResult.sessionId, userId: user.id })
+ );
+ });
+
+ it('returns { status: "ready", result } for a promptStarted:false partial when the row is ready', async () => {
+ mockedCreateAndRun.mockResolvedValueOnce({ result: partialResult });
+ mockedWaitForOwnedCliSession.mockResolvedValueOnce({ organizationId: null });
+
+ const caller = await createCallerForUser(user.id);
+ const result = await caller.localRuntimeControl.createAndRun({
+ fence: validFence,
+ request: validRequest,
+ });
+
+ expect(result).toEqual({ status: 'ready', result: partialResult });
+ });
+
+ it('returns { status: "session_not_ready", code, result } when the readiness probe times out', async () => {
+ mockedCreateAndRun.mockResolvedValueOnce({ result: successResult });
+ mockedWaitForOwnedCliSession.mockResolvedValueOnce(null);
+
+ const caller = await createCallerForUser(user.id);
+ const result = await caller.localRuntimeControl.createAndRun({
+ fence: validFence,
+ request: validRequest,
+ });
+
+ expect(result).toEqual({
+ status: 'session_not_ready',
+ code: 'SESSION_NOT_READY',
+ result: successResult,
+ });
+ });
+
+ it('returns the promptStarted:false partial with session_not_ready when the row never appears', async () => {
+ mockedCreateAndRun.mockResolvedValueOnce({ result: partialResult });
+ mockedWaitForOwnedCliSession.mockResolvedValueOnce(null);
+
+ const caller = await createCallerForUser(user.id);
+ const result = await caller.localRuntimeControl.createAndRun({
+ fence: validFence,
+ request: validRequest,
+ });
+
+ expect(result).toEqual({
+ status: 'session_not_ready',
+ code: 'SESSION_NOT_READY',
+ result: partialResult,
+ });
+ });
+
+ it('does not retry the create call when the readiness probe throws (forbidden removed member)', async () => {
+ mockedCreateAndRun.mockResolvedValueOnce({ result: successResult });
+ mockedWaitForOwnedCliSession.mockImplementationOnce(async () => {
+ throw new TRPCError({ code: 'FORBIDDEN', message: 'no longer a member' });
+ });
+
+ const caller = await createCallerForUser(user.id);
+ await expect(
+ caller.localRuntimeControl.createAndRun({ fence: validFence, request: validRequest })
+ ).rejects.toMatchObject({ code: 'FORBIDDEN' });
+ expect(mockedCreateAndRun).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not retry the create call when the upstream create fails (no automatic create retry)', async () => {
+ mockedCreateAndRun.mockRejectedValueOnce(
+ new LocalRuntimeCreateAndRunError('RUNTIME_FENCE_MISMATCH', 'fence stale')
+ );
+
+ const caller = await createCallerForUser(user.id);
+ await expect(
+ caller.localRuntimeControl.createAndRun({ fence: validFence, request: validRequest })
+ ).rejects.toMatchObject({
+ code: 'CONFLICT',
+ cause: expect.any(UpstreamApiError),
+ });
+ expect(mockedCreateAndRun).toHaveBeenCalledTimes(1);
+ expect(mockedWaitForOwnedCliSession).not.toHaveBeenCalled();
+ });
+
+ const createErrorMapping: Array<[LocalRuntimeControlErrorCode, TRPCError['code']]> = [
+ ['RUNTIME_NOT_CONNECTED', 'NOT_FOUND'],
+ ['RUNTIME_FENCE_MISMATCH', 'CONFLICT'],
+ ['CATALOG_CHANGED', 'CONFLICT'],
+ ['COMMAND_ALREADY_PENDING', 'CONFLICT'],
+ ['CLI_UPGRADE_REQUIRED', 'PRECONDITION_FAILED'],
+ ['COMMAND_EXPIRED', 'TIMEOUT'],
+ ['PENDING_COMMAND_LIMIT', 'TOO_MANY_REQUESTS'],
+ ['COMMAND_NOT_ALLOWED', 'FORBIDDEN'],
+ ['RESULT_TOO_LARGE', 'INTERNAL_SERVER_ERROR'],
+ ['INVALID_RUNTIME_RESPONSE', 'INTERNAL_SERVER_ERROR'],
+ ['RUNTIME_COMMAND_FAILED', 'INTERNAL_SERVER_ERROR'],
+ ];
+
+ it.each(createErrorMapping)(
+ 'maps create %s to a %s tRPC error with the upstream code in cause and data',
+ async (upstreamCode, expectedCode) => {
+ mockedCreateAndRun.mockRejectedValueOnce(
+ new LocalRuntimeCreateAndRunError(upstreamCode, 'upstream message')
+ );
+
+ const caller = await createCallerForUser(user.id);
+ try {
+ await caller.localRuntimeControl.createAndRun({ fence: validFence, request: validRequest });
+ throw new Error('Expected createAndRun to reject');
+ } catch (err) {
+ expect(err).toBeInstanceOf(TRPCError);
+ if (!(err instanceof TRPCError)) throw err;
+ expect(err.code).toBe(expectedCode);
+ expect(err.cause).toBeInstanceOf(UpstreamApiError);
+ if (!(err.cause instanceof UpstreamApiError)) throw err;
+ expect(err.cause.upstreamCode).toBe(upstreamCode);
+ }
+ }
+ );
+
+ it('does not call Cloud Agent (no cloud-agent client import in the createAndRun path)', async () => {
+ // Inspect the imported module surface to ensure no cloud-agent client is referenced.
+ // The source is loaded into the module graph by the time tests run; we assert
+ // by spying on a representative global that the cloud-agent SDK touches
+ // (a `fetch` call to /api/cloud-agent-next) and verifying it is never made.
+ const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue(
+ new Response(JSON.stringify({}), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ })
+ );
+ mockedCreateAndRun.mockResolvedValueOnce({ result: successResult });
+ mockedWaitForOwnedCliSession.mockResolvedValueOnce({ organizationId: null });
+
+ const caller = await createCallerForUser(user.id);
+ await caller.localRuntimeControl.createAndRun({ fence: validFence, request: validRequest });
+
+ const calls = fetchSpy.mock.calls.map(call => String(call[0]));
+ expect(calls.some(url => url.includes('cloud-agent-next'))).toBe(false);
+ fetchSpy.mockRestore();
+ });
+});
diff --git a/apps/web/src/routers/local-runtime-control-router.ts b/apps/web/src/routers/local-runtime-control-router.ts
new file mode 100644
index 0000000000..04304d28df
--- /dev/null
+++ b/apps/web/src/routers/local-runtime-control-router.ts
@@ -0,0 +1,213 @@
+import 'server-only';
+
+import { TRPCError } from '@trpc/server';
+import { z } from 'zod';
+import {
+ createAndRunLocalSessionRequestSchema,
+ localRuntimeCreateOutputSchema,
+ localRuntimeFenceSchema,
+ type LocalRuntimeCreateOutput,
+} from '@kilocode/session-ingest-contracts';
+
+import { baseProcedure, createTRPCRouter, UpstreamApiError } from '@/lib/trpc/init';
+import {
+ LocalRuntimeControlClient,
+ LocalRuntimeCatalogError,
+ LocalRuntimeControlRequestError,
+ LocalRuntimeCreateAndRunError,
+ type LocalRuntimeCatalog,
+ type LocalRuntimeList,
+} from '@/lib/local-runtime-control/client';
+import { waitForOwnedCliSession } from '@/lib/local-runtime-control/readiness';
+
+const createAndRunInputSchema = z
+ .object({
+ fence: localRuntimeFenceSchema,
+ request: createAndRunLocalSessionRequestSchema,
+ })
+ .strict();
+
+export const localRuntimeControlRouter = createTRPCRouter({
+ list: baseProcedure.query(async ({ ctx }): Promise => {
+ try {
+ return await LocalRuntimeControlClient.list(ctx.user.id);
+ } catch (err) {
+ if (err instanceof LocalRuntimeControlRequestError) {
+ throw new TRPCError({
+ code: 'BAD_GATEWAY',
+ message: 'Local runtime control request failed',
+ });
+ }
+ throw err;
+ }
+ }),
+
+ getCatalog: baseProcedure
+ .input(localRuntimeFenceSchema)
+ .query(async ({ ctx, input }): Promise => {
+ try {
+ const response = await LocalRuntimeControlClient.getCatalog(ctx.user.id, input);
+ return response.catalog;
+ } catch (err) {
+ if (err instanceof LocalRuntimeCatalogError) {
+ throw new TRPCError({
+ code: mapCatalogErrorCode(err.upstreamCode),
+ message: err.message,
+ cause: new UpstreamApiError(err.upstreamCode),
+ });
+ }
+ throw err;
+ }
+ }),
+
+ /**
+ * Server-side create-and-run. The relay completes the runtime command and
+ * returns the CLI's typed success/partial result; the server then waits for
+ * the announced `cli_sessions_v2` row to become fetchable so the mobile
+ * client can navigate directly to the existing session detail route.
+ *
+ * - `ready` — owned row observed within the bounded wait. The CLI result
+ * is returned exactly as the relay produced it, including a
+ * `promptStarted:false` partial.
+ * - `session_not_ready` — the server exhausted its bounded wait. The CLI
+ * result is still returned so mobile can open the existing session
+ * (for `promptStarted:false`) or poll the separate
+ * `cliSessionsV2.readiness` query for recovery. The server NEVER issues
+ * a second relay create command.
+ *
+ * Upstream failures from the relay collapse to a typed tRPC error with the
+ * stable `LocalRuntimeControlErrorCode` attached as `cause: UpstreamApiError`
+ * so mobile can branch on `err.data.upstreamCode`. The create call is
+ * NEVER retried server-side.
+ */
+ createAndRun: baseProcedure
+ .input(createAndRunInputSchema)
+ .output(localRuntimeCreateOutputSchema)
+ .mutation(async ({ ctx, input }): Promise => {
+ const { fence, request } = input;
+ let result;
+ try {
+ const response = await LocalRuntimeControlClient.createAndRun(ctx.user.id, fence, request);
+ result = response.result;
+ } catch (err) {
+ if (err instanceof LocalRuntimeCreateAndRunError) {
+ throw new TRPCError({
+ code: mapCreateAndRunErrorCode(err.upstreamCode),
+ message: err.message,
+ cause: new UpstreamApiError(err.upstreamCode),
+ });
+ }
+ if (err instanceof LocalRuntimeControlRequestError) {
+ throw new TRPCError({
+ code: 'BAD_GATEWAY',
+ message: 'Local runtime create-and-run request failed',
+ });
+ }
+ throw err;
+ }
+
+ // Wait for the owned row to become fetchable. The probe scopes to
+ // (sessionId, kiloUserId) and re-validates current org membership when
+ // the row carries an organizationId — a removed member surfaces here as
+ // a FORBIDDEN rejection (mapped from the UNAUTHORIZED raised by
+ // ensureOrganizationAccess so the contract is uniform across both
+ // readiness surfaces), not a "ready" outcome.
+ const ready = await waitForOwnedCliSession({
+ sessionId: result.sessionId,
+ userId: ctx.user.id,
+ deps: {
+ query: async (sessionId, kiloUserId) => {
+ const { cli_sessions_v2 } = await import('@kilocode/db/schema');
+ const { db } = await import('@/lib/drizzle');
+ const { and, eq } = await import('drizzle-orm');
+ const [row] = await db
+ .select({ organizationId: cli_sessions_v2.organization_id })
+ .from(cli_sessions_v2)
+ .where(
+ and(
+ eq(cli_sessions_v2.session_id, sessionId),
+ eq(cli_sessions_v2.kilo_user_id, kiloUserId)
+ )
+ )
+ .limit(1);
+ if (!row) return null;
+ return { organizationId: row.organizationId ?? null };
+ },
+ ensureOrganizationAccess: async organizationId => {
+ const { ensureOrganizationAccess } = await import('@/routers/organizations/utils');
+ try {
+ await ensureOrganizationAccess(ctx, organizationId);
+ } catch (err) {
+ if (err instanceof TRPCError && err.code === 'UNAUTHORIZED') {
+ throw new TRPCError({
+ code: 'FORBIDDEN',
+ message: 'You no longer have access to this organization',
+ cause: err,
+ });
+ }
+ throw err;
+ }
+ },
+ },
+ });
+
+ if (ready === null) {
+ return {
+ status: 'session_not_ready',
+ code: 'SESSION_NOT_READY',
+ result,
+ };
+ }
+ return { status: 'ready', result };
+ }),
+});
+
+function mapCatalogErrorCode(upstreamCode: string): TRPCError['code'] {
+ switch (upstreamCode) {
+ case 'RUNTIME_NOT_CONNECTED':
+ return 'NOT_FOUND';
+ case 'RUNTIME_FENCE_MISMATCH':
+ case 'CATALOG_CHANGED':
+ case 'COMMAND_ALREADY_PENDING':
+ return 'CONFLICT';
+ case 'CLI_UPGRADE_REQUIRED':
+ return 'PRECONDITION_FAILED';
+ case 'COMMAND_EXPIRED':
+ return 'TIMEOUT';
+ case 'PENDING_COMMAND_LIMIT':
+ return 'TOO_MANY_REQUESTS';
+ case 'COMMAND_NOT_ALLOWED':
+ return 'FORBIDDEN';
+ case 'RESULT_TOO_LARGE':
+ case 'INVALID_RUNTIME_RESPONSE':
+ case 'RUNTIME_COMMAND_FAILED':
+ return 'INTERNAL_SERVER_ERROR';
+ default:
+ return 'INTERNAL_SERVER_ERROR';
+ }
+}
+
+function mapCreateAndRunErrorCode(upstreamCode: string): TRPCError['code'] {
+ switch (upstreamCode) {
+ case 'RUNTIME_NOT_CONNECTED':
+ return 'NOT_FOUND';
+ case 'RUNTIME_FENCE_MISMATCH':
+ case 'CATALOG_CHANGED':
+ case 'COMMAND_ALREADY_PENDING':
+ return 'CONFLICT';
+ case 'CLI_UPGRADE_REQUIRED':
+ return 'PRECONDITION_FAILED';
+ case 'COMMAND_EXPIRED':
+ return 'TIMEOUT';
+ case 'PENDING_COMMAND_LIMIT':
+ return 'TOO_MANY_REQUESTS';
+ case 'COMMAND_NOT_ALLOWED':
+ return 'FORBIDDEN';
+ case 'RESULT_TOO_LARGE':
+ case 'INVALID_RUNTIME_RESPONSE':
+ case 'RUNTIME_COMMAND_FAILED':
+ return 'INTERNAL_SERVER_ERROR';
+ default:
+ return 'INTERNAL_SERVER_ERROR';
+ }
+}
diff --git a/apps/web/src/routers/root-router.ts b/apps/web/src/routers/root-router.ts
index 6c8f11a530..2e00720ac9 100644
--- a/apps/web/src/routers/root-router.ts
+++ b/apps/web/src/routers/root-router.ts
@@ -41,6 +41,7 @@ import { modelsRouter } from '@/routers/models-router';
import { codingPlansRouter } from '@/routers/coding-plans-router';
import { unifiedSessionsRouter } from '@/routers/unified-sessions-router';
import { activeSessionsRouter } from '@/routers/active-sessions-router';
+import { localRuntimeControlRouter } from '@/routers/local-runtime-control-router';
import { usageAnalyticsRouter } from '@/routers/usage-analytics-router';
import { mcpGatewayRouter } from '@/routers/mcp-gateway-router';
import { costInsightsRouter } from '@/routers/cost-insights-router';
@@ -88,6 +89,7 @@ export const rootRouter = createTRPCRouter({
codingPlans: codingPlansRouter,
unifiedSessions: unifiedSessionsRouter,
activeSessions: activeSessionsRouter,
+ localRuntimeControl: localRuntimeControlRouter,
usageAnalytics: usageAnalyticsRouter,
mcpGateway: mcpGatewayRouter,
mcpGatewayAuthorizations: mcpGatewayAuthorizationsRouter,
diff --git a/packages/session-ingest-contracts/src/index.ts b/packages/session-ingest-contracts/src/index.ts
index 6cedf0e64f..dd57aa990d 100644
--- a/packages/session-ingest-contracts/src/index.ts
+++ b/packages/session-ingest-contracts/src/index.ts
@@ -1 +1,2 @@
export * from './rpc-contract';
+export * from './runtime-control';
diff --git a/packages/session-ingest-contracts/src/runtime-control.ts b/packages/session-ingest-contracts/src/runtime-control.ts
new file mode 100644
index 0000000000..1842fa9a3f
--- /dev/null
+++ b/packages/session-ingest-contracts/src/runtime-control.ts
@@ -0,0 +1,225 @@
+import { z } from 'zod';
+
+import { sessionIdSchema } from './rpc-contract';
+
+/**
+ * Audience for the five-minute, audience-bound internal JWT that authenticates
+ * the web app's Local Runtime Control module against the session-ingest
+ * internal routes. The matching `verifyKiloToken` call in the session-ingest
+ * middleware requires this exact audience.
+ */
+export const SESSION_INGEST_RUNTIME_CONTROL_AUDIENCE = 'session-ingest:runtime-control';
+
+/**
+ * Capabilities a local runtime can advertise. The set is intentionally small
+ * and bounded — each value maps to a specific relay command the server module
+ * may route. The relay rejects unknown capabilities on the wire.
+ */
+export const localRuntimeCapabilitySchema = z.enum(['catalog.v1', 'create-and-run.v1']);
+export type LocalRuntimeCapability = z.infer;
+
+/**
+ * The exact fence the server module uses when routing a control command to a
+ * specific runtime. `runtimeId` identifies the `kilo remote` process; it
+ * survives that process's WebSocket reconnects but changes on process restart.
+ * `connectionId` is opaque relay routing metadata owned by the session-ingest DO.
+ */
+export const localRuntimeFenceSchema = z
+ .object({
+ runtimeId: z.string().uuid(),
+ connectionId: z.string().min(1).max(128),
+ })
+ .strict();
+export type LocalRuntimeFence = z.infer;
+
+/**
+ * Safe metadata the CLI advertises for a live runtime. The absolute launch
+ * directory is never included — only the basename is exposed as `projectName`,
+ * and labels are length-limited and sanitized at the CLI boundary.
+ */
+export const localRuntimePresenceSchema = localRuntimeFenceSchema
+ .extend({
+ protocolVersion: z.literal(1),
+ cliVersion: z.string().min(1).max(32),
+ displayName: z.string().min(1).max(80),
+ projectName: z.string().min(1).max(80),
+ capabilities: z
+ .array(localRuntimeCapabilitySchema)
+ .max(2)
+ .refine(values => new Set(values).size === values.length, {
+ message: 'runtime capabilities must be unique',
+ }),
+ })
+ .strict();
+export type LocalRuntimePresence = z.infer;
+
+/**
+ * Bounded agent entry the CLI exposes inside a runtime catalog. The full
+ * catalog model schema is parsed by the web client, not the cross-service
+ * contract, so the model payload is `unknown` here.
+ */
+export const localRuntimeAgentSchema = z
+ .object({
+ slug: z.string().min(1).max(100),
+ name: z.string().min(1).max(100),
+ description: z.string().max(500).optional(),
+ model: z
+ .object({
+ providerID: z.string().min(1).max(255),
+ modelID: z.string().min(1).max(255),
+ })
+ .strict()
+ .optional(),
+ variant: z.string().min(1).max(100).optional(),
+ })
+ .strict();
+export type LocalRuntimeAgent = z.infer;
+
+export const localRuntimeCatalogSchema = z
+ .object({
+ protocolVersion: z.literal(1),
+ models: z.unknown(),
+ agents: z.array(localRuntimeAgentSchema).max(128),
+ defaultAgent: z.string().min(1).max(100),
+ })
+ .strict();
+export type LocalRuntimeCatalog = z.infer;
+
+export const getLocalRuntimeCatalogRequestSchema = z
+ .object({ protocolVersion: z.literal(1) })
+ .strict();
+export type GetLocalRuntimeCatalogRequest = z.infer;
+
+/**
+ * Stable error codes the server module surfaces to mobile. Mobile branches on
+ * `code` to choose the recovery flow. Each code has a dedicated recovery
+ * behavior — do not reuse codes for semantically different failures.
+ */
+export const localRuntimeControlErrorCodeSchema = z.enum([
+ 'RUNTIME_NOT_CONNECTED',
+ 'RUNTIME_FENCE_MISMATCH',
+ 'CLI_UPGRADE_REQUIRED',
+ 'CATALOG_CHANGED',
+ 'COMMAND_ALREADY_PENDING',
+ 'PENDING_COMMAND_LIMIT',
+ 'COMMAND_EXPIRED',
+ 'RESULT_TOO_LARGE',
+ 'INVALID_RUNTIME_RESPONSE',
+ 'RUNTIME_COMMAND_FAILED',
+ 'COMMAND_NOT_ALLOWED',
+]);
+export type LocalRuntimeControlErrorCode = z.infer;
+
+export const localRuntimeControlErrorSchema = z
+ .object({
+ source: z.literal('relay'),
+ code: localRuntimeControlErrorCodeSchema,
+ message: z.string().min(1).max(500),
+ })
+ .strict();
+export type LocalRuntimeControlError = z.infer;
+
+export type LocalRuntimeCommandOutcome =
+ | { ok: true; result: T }
+ | { ok: false; error: LocalRuntimeControlError };
+
+/**
+ * Create-and-run request payload. The full result schema lives in the
+ * runtime-control module; this contract only carries the relay-facing
+ * discriminator. Out of scope for the runtime-presence slice.
+ */
+export const createAndRunLocalSessionRequestSchema = z
+ .object({
+ protocolVersion: z.literal(1),
+ requestId: z.string().uuid(),
+ prompt: z.string().trim().min(1).max(32_768),
+ model: z
+ .object({
+ providerID: z.string().min(1).max(255),
+ modelID: z.string().min(1).max(255),
+ })
+ .strict(),
+ variant: z.string().min(1).max(100).optional(),
+ agent: z.string().min(1).max(100),
+ })
+ .strict();
+export type CreateAndRunLocalSessionRequest = z.infer;
+
+export const createAndRunLocalSessionResultSchema = z.discriminatedUnion('promptStarted', [
+ z
+ .object({
+ protocolVersion: z.literal(1),
+ sessionId: sessionIdSchema,
+ promptStarted: z.literal(true),
+ })
+ .strict(),
+ z
+ .object({
+ protocolVersion: z.literal(1),
+ sessionId: sessionIdSchema,
+ promptStarted: z.literal(false),
+ error: z
+ .object({
+ code: z.literal('PROMPT_START_FAILED'),
+ message: z.literal('The session was created, but the first prompt did not start.'),
+ })
+ .strict(),
+ })
+ .strict(),
+]);
+export type CreateAndRunLocalSessionResult = z.infer;
+
+/**
+ * Server-side envelope for the `localRuntimeControl.createAndRun` mutation.
+ *
+ * The relay completes the runtime command and returns a CLI result; the
+ * server then waits for the announced session row to become fetchable
+ * through `cli_sessions_v2` so the mobile client can navigate directly to
+ * the existing session detail route. The envelope is a discriminated union
+ * so callers never have to infer which path the server took.
+ *
+ * - `ready`: the row is owned by the requesting user. The CLI result is
+ * returned exactly as the relay produced it.
+ * - `session_not_ready`: the server exhausted its bounded wait without
+ * observing an owned row. The CLI result is still returned so mobile can
+ * open the existing session (for `promptStarted:false`) or poll the
+ * separate `cliSessionsV2.readiness` query for recovery. The server NEVER
+ * issues a second relay create command.
+ */
+export const localRuntimeCreateOutputSchema = z.discriminatedUnion('status', [
+ z
+ .object({
+ status: z.literal('ready'),
+ result: createAndRunLocalSessionResultSchema,
+ })
+ .strict(),
+ z
+ .object({
+ status: z.literal('session_not_ready'),
+ code: z.literal('SESSION_NOT_READY'),
+ result: createAndRunLocalSessionResultSchema,
+ })
+ .strict(),
+]);
+export type LocalRuntimeCreateOutput = z.infer;
+
+/**
+ * Response envelope for the list endpoint. The list is capped at 32 runtimes
+ * to keep the payload bounded; the DO enforces the same cap internally.
+ */
+export const localRuntimeListResponseSchema = z
+ .object({ runtimes: z.array(localRuntimePresenceSchema).max(32) })
+ .strict();
+export type LocalRuntimeListResponse = z.infer;
+
+export const localRuntimeCatalogResponseSchema = z
+ .object({ catalog: localRuntimeCatalogSchema })
+ .strict();
+
+export const localRuntimeCreateResponseSchema = z
+ .object({ result: createAndRunLocalSessionResultSchema })
+ .strict();
+
+export const localRuntimeErrorResponseSchema = z
+ .object({ error: localRuntimeControlErrorSchema })
+ .strict();
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 30dbf59146..2017b2b696 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -688,6 +688,9 @@ importers:
'@kilocode/organization-entitlement':
specifier: workspace:*
version: link:../../packages/organization-entitlement
+ '@kilocode/session-ingest-contracts':
+ specifier: workspace:*
+ version: link:../../packages/session-ingest-contracts
'@kilocode/worker-utils':
specifier: workspace:*
version: link:../../packages/worker-utils
diff --git a/services/session-ingest/src/app.ts b/services/session-ingest/src/app.ts
index ff4d6f1977..e66364a6f6 100644
--- a/services/session-ingest/src/app.ts
+++ b/services/session-ingest/src/app.ts
@@ -6,7 +6,9 @@ import { getWorkerDb } from '@kilocode/db/client';
import { cli_sessions_v2 } from '@kilocode/db/schema';
import { kiloJwtAuthMiddleware } from './middleware/kilo-jwt-auth';
+import { runtimeControlAuthMiddleware } from './middleware/runtime-control-auth';
import { api } from './routes/api';
+import { runtimeControlApi } from './routes/runtime-control';
import { getSessionIngestDO } from './dos/SessionIngestDO';
import { getSessionExport } from './services/session-export';
import { withDORetry } from '@kilocode/worker-utils';
@@ -24,6 +26,11 @@ export const app = new Hono<{
app.use('/api/*', kiloJwtAuthMiddleware);
app.route('/api', api);
+// Internal runtime-control surface: audience-bound JWT against
+// NEXTAUTH_SECRET_PROD, separate from the user-facing /api auth.
+app.use('/internal/runtime-control/*', runtimeControlAuthMiddleware);
+app.route('/internal/runtime-control', runtimeControlApi);
+
// Public session endpoint: look up a session by public_id and return all ingested DO events.
app.get('/session/:sessionId', async c => {
const sessionId = c.req.param('sessionId');
diff --git a/services/session-ingest/src/dos/UserConnectionDO.test.ts b/services/session-ingest/src/dos/UserConnectionDO.test.ts
index 07e2719828..1bffea1ea8 100644
--- a/services/session-ingest/src/dos/UserConnectionDO.test.ts
+++ b/services/session-ingest/src/dos/UserConnectionDO.test.ts
@@ -12,7 +12,11 @@ vi.mock('cloudflare:workers', () => ({
},
}));
-import { MAX_CATALOG_RESULT_BYTES, UserConnectionDO } from './UserConnectionDO';
+import {
+ LocalRuntimeCommandError,
+ MAX_CATALOG_RESULT_BYTES,
+ UserConnectionDO,
+} from './UserConnectionDO';
// ---------------------------------------------------------------------------
// Mock WebSocket
@@ -215,12 +219,16 @@ function sendHeartbeat(
status: string;
title: string;
}>,
- protocolVersion?: string
+ protocolVersion?: string,
+ runtime?: Record,
+ sequence?: number
) {
const msg = JSON.stringify({
type: 'heartbeat',
sessions,
...(protocolVersion ? { protocolVersion } : {}),
+ ...(runtime ? { runtime } : {}),
+ ...(sequence !== undefined ? { sequence } : {}),
});
doInstance.webSocketMessage(cliWs as never, msg);
}
@@ -2274,4 +2282,1220 @@ describe('UserConnectionDO', () => {
expect(server.deserializeAttachment()).toMatchObject({ role: 'cli', kiloUserId: 'usr_1' });
});
});
+
+ // -------------------------------------------------------------------------
+ // Local runtime presence (first-class, independent of sessions)
+ // -------------------------------------------------------------------------
+
+ describe('local runtime presence', () => {
+ const validRuntime = {
+ runtimeId: '8db3de9a-350f-4fad-a539-8e0da3bbcf5e',
+ connectionId: 'cli-1',
+ protocolVersion: 1,
+ cliVersion: '7.4.7',
+ displayName: 'Alice Mac',
+ projectName: 'customer-repo',
+ capabilities: ['catalog.v1', 'create-and-run.v1'],
+ };
+ const otherRuntime = {
+ ...validRuntime,
+ runtimeId: 'b14c2a7d-4e2d-4f1a-9c8d-7e8f1b2c3d4e',
+ connectionId: 'cli-2',
+ };
+ const capabilityMissingRuntime = {
+ ...validRuntime,
+ runtimeId: '0c0a1b2c-3d4e-4f60-8a8b-9c0d1e2f3a4b',
+ connectionId: 'cli-3',
+ capabilities: ['catalog.v1'],
+ };
+
+ it('returns an empty list when no runtimes are present', () => {
+ const { doInstance } = setup();
+ expect(doInstance.getRuntimePresence()).toEqual([]);
+ });
+
+ it('ignores a legacy heartbeat that omits the runtime field', () => {
+ const { doInstance, mockCtx } = setup();
+ const cliWs = addCliSocket(mockCtx, 'cli-1');
+ sendHeartbeat(doInstance, cliWs, [makeSession('s1')]);
+ expect(doInstance.getRuntimePresence()).toEqual([]);
+ });
+
+ it('registers a zero-session heartbeat as runtime presence', () => {
+ const { doInstance, mockCtx } = setup();
+ const cliWs = addCliSocket(mockCtx, 'cli-1');
+ sendHeartbeat(doInstance, cliWs, [], undefined, validRuntime);
+ // Public RPC returns the safe contract shape (no internal heartbeat
+ // timestamp, no paths).
+ expect(doInstance.getRuntimePresence()).toEqual([validRuntime]);
+ });
+
+ it('keeps a runtime registered even when it has zero sessions', () => {
+ const { doInstance, mockCtx } = setup();
+ const cliWs = addCliSocket(mockCtx, 'cli-1');
+ const webWs = addWebSocket(mockCtx, 'web-1');
+ sendHeartbeat(doInstance, cliWs, [], undefined, validRuntime);
+
+ webWs.send.mockClear();
+ sendHeartbeat(doInstance, cliWs, [], undefined, validRuntime);
+ const runtimes = doInstance.getRuntimePresence();
+ expect(runtimes).toHaveLength(1);
+ // No duplicate connected/updated events on consecutive identical heartbeats.
+ const connectedMsgs = allSent(webWs).filter(
+ (m: Record) => m.type === 'system' && m.event === 'runtime.connected'
+ );
+ expect(connectedMsgs).toHaveLength(0);
+ });
+
+ it('rejects a heartbeat whose runtime.connectionId differs from the socket attachment', () => {
+ const { doInstance, mockCtx } = setup();
+ const cliWs = addCliSocket(mockCtx, 'cli-1');
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
+
+ sendHeartbeat(doInstance, cliWs, [], undefined, {
+ ...validRuntime,
+ connectionId: 'cli-other',
+ });
+
+ expect(doInstance.getRuntimePresence()).toEqual([]);
+ // The connectionId is opaque routing metadata; the warn may include it,
+ // but must not include the prompt/secret content.
+ expect(JSON.stringify(warn.mock.calls)).not.toContain(validRuntime.displayName);
+ });
+
+ it('rejects a heartbeat that mutates the runtimeId of a live socket', () => {
+ const { doInstance, mockCtx } = setup();
+ const cliWs = addCliSocket(mockCtx, 'cli-1');
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
+
+ sendHeartbeat(doInstance, cliWs, [], undefined, validRuntime);
+ expect(doInstance.getRuntimePresence()).toHaveLength(1);
+
+ sendHeartbeat(doInstance, cliWs, [], undefined, {
+ ...validRuntime,
+ runtimeId: otherRuntime.runtimeId,
+ });
+
+ // Original runtime is preserved; the mutated request is rejected.
+ expect(doInstance.getRuntimePresence()).toEqual([validRuntime]);
+ expect(warn).toHaveBeenCalled();
+ });
+
+ it('tracks multiple runtimes independently and exposes them all', () => {
+ const { doInstance, mockCtx } = setup();
+ const cli1 = addCliSocket(mockCtx, 'cli-1');
+ const cli2 = addCliSocket(mockCtx, 'cli-2');
+ const cli3 = addCliSocket(mockCtx, 'cli-3');
+
+ sendHeartbeat(doInstance, cli1, [], undefined, validRuntime);
+ sendHeartbeat(doInstance, cli2, [], undefined, otherRuntime);
+ sendHeartbeat(doInstance, cli3, [], undefined, capabilityMissingRuntime);
+
+ const runtimes = doInstance.getRuntimePresence();
+ expect(runtimes).toHaveLength(3);
+ expect(runtimes.map(r => r.runtimeId).sort()).toEqual(
+ [validRuntime.runtimeId, otherRuntime.runtimeId, capabilityMissingRuntime.runtimeId].sort()
+ );
+ });
+
+ it('emits runtime.connected on first presence and runtime.updated on metadata change', () => {
+ const { doInstance, mockCtx } = setup();
+ const cliWs = addCliSocket(mockCtx, 'cli-1');
+ const webWs = addWebSocket(mockCtx, 'web-1');
+ webWs.send.mockClear();
+
+ sendHeartbeat(doInstance, cliWs, [], undefined, validRuntime);
+ const connected = allSent(webWs).find(
+ (m: Record) => m.type === 'system' && m.event === 'runtime.connected'
+ );
+ expect(connected).toEqual({
+ type: 'system',
+ event: 'runtime.connected',
+ data: { runtime: validRuntime },
+ });
+
+ webWs.send.mockClear();
+ const updatedRuntime = { ...validRuntime, displayName: 'Alice Studio' };
+ sendHeartbeat(doInstance, cliWs, [], undefined, updatedRuntime);
+
+ const updated = allSent(webWs).find(
+ (m: Record) => m.type === 'system' && m.event === 'runtime.updated'
+ );
+ expect(updated).toEqual({
+ type: 'system',
+ event: 'runtime.updated',
+ data: { runtime: updatedRuntime },
+ });
+ });
+
+ it('sends a runtimes.list system event when a viewer connects', () => {
+ const { doInstance, mockCtx } = setup();
+ const cliWs = addCliSocket(mockCtx, 'cli-1');
+ sendHeartbeat(doInstance, cliWs, [], undefined, validRuntime);
+
+ const webWs = connectWebSocket(doInstance, 'viewer-1');
+ const listMsg = allSent(webWs).find(
+ (m: Record) => m.type === 'system' && m.event === 'runtimes.list'
+ ) as { type: string; event: string; data: { runtimes: unknown[] } };
+ expect(listMsg).toMatchObject({ type: 'system', event: 'runtimes.list' });
+ expect(listMsg.data.runtimes).toEqual([validRuntime]);
+ });
+
+ it('emits an empty runtimes.list on viewer connect when no runtimes exist', () => {
+ const { doInstance } = setup();
+ const webWs = connectWebSocket(doInstance, 'viewer-1');
+ const listMsg = allSent(webWs).find(
+ (m: Record) => m.type === 'system' && m.event === 'runtimes.list'
+ );
+ expect(listMsg).toEqual({ type: 'system', event: 'runtimes.list', data: { runtimes: [] } });
+ });
+
+ it('echoes the optional heartbeat sequence on the ACK', () => {
+ const { doInstance, mockCtx } = setup();
+ const cliWs = addCliSocket(mockCtx, 'cli-1');
+ sendHeartbeat(doInstance, cliWs, [], undefined, validRuntime, 11);
+ const acks = allSent(cliWs).filter(
+ (m: Record) => m.type === 'heartbeat_ack'
+ );
+ expect(acks).toContainEqual({ type: 'heartbeat_ack', sequence: 11 });
+ });
+
+ it('sends a bare heartbeat_ack for legacy CLIs that omit the sequence', () => {
+ const { doInstance, mockCtx } = setup();
+ const cliWs = addCliSocket(mockCtx, 'cli-1');
+ sendHeartbeat(doInstance, cliWs, [], undefined, validRuntime);
+ const acks = allSent(cliWs).filter(
+ (m: Record) => m.type === 'heartbeat_ack'
+ );
+ expect(acks).toContainEqual({ type: 'heartbeat_ack' });
+ });
+
+ it('persists runtime metadata in the WebSocket attachment for hibernation', () => {
+ const { doInstance, mockCtx } = setup();
+ const cliWs = addCliSocket(mockCtx, 'cli-1');
+ sendHeartbeat(doInstance, cliWs, [], undefined, validRuntime);
+ const att = cliWs.deserializeAttachment() as Record;
+ expect(att).toMatchObject({ role: 'cli' });
+ expect(att.runtime).toMatchObject({ runtimeId: validRuntime.runtimeId });
+ });
+
+ it('reconstructs the runtime registry from attachments after hibernation', () => {
+ const { doInstance, mockCtx } = setup();
+ const cli1 = addCliSocket(mockCtx, 'cli-1');
+ const cli2 = addCliSocket(mockCtx, 'cli-2');
+ sendHeartbeat(doInstance, cli1, [], undefined, validRuntime);
+ sendHeartbeat(doInstance, cli2, [], undefined, otherRuntime);
+
+ // Simulate hibernation: a brand new DO instance reads state from
+ // attachments only.
+ const ctx2 = createMockCtx();
+ for (const ws of mockCtx.sockets) {
+ ctx2.addSocket(ws);
+ }
+ const fresh = new UserConnectionDO(ctx2.build() as never, {} as never);
+ const reconstructed = fresh.getRuntimePresence();
+ const ids = reconstructed.map(r => r.runtimeId).sort();
+ expect(ids).toEqual([validRuntime.runtimeId, otherRuntime.runtimeId].sort());
+ });
+
+ it('emits runtime.disconnected on socket close and removes the runtime', () => {
+ const { doInstance, mockCtx } = setup();
+ const cliWs = addCliSocket(mockCtx, 'cli-1');
+ const webWs = addWebSocket(mockCtx, 'web-1');
+ sendHeartbeat(doInstance, cliWs, [], undefined, validRuntime);
+ webWs.send.mockClear();
+
+ mockCtx.removeSocket(cliWs);
+ disconnectCli(doInstance, cliWs);
+
+ const disc = allSent(webWs).find(
+ (m: Record) => m.type === 'system' && m.event === 'runtime.disconnected'
+ );
+ expect(disc).toEqual({
+ type: 'system',
+ event: 'runtime.disconnected',
+ data: {
+ runtimeId: validRuntime.runtimeId,
+ connectionId: 'cli-1',
+ },
+ });
+ expect(doInstance.getRuntimePresence()).toEqual([]);
+ });
+
+ it('evicts stale runtimes on alarm and emits runtime.disconnected exactly once', async () => {
+ const { doInstance, mockCtx } = setup();
+ const cliWs = addCliSocket(mockCtx, 'cli-1');
+ const webWs = addWebSocket(mockCtx, 'web-1');
+ sendHeartbeat(doInstance, cliWs, [], undefined, validRuntime);
+ webWs.send.mockClear();
+
+ vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 31_000);
+ await doInstance.alarm();
+ // The alarm calls ws.close on the stale socket; the runtime disconnect
+ // is broadcast when the close callback fires.
+ expect(cliWs.close).toHaveBeenCalledWith(4408, 'heartbeat timeout');
+ mockCtx.removeSocket(cliWs);
+ disconnectCli(doInstance, cliWs);
+
+ // The viewer sees exactly one runtime.disconnected with the exact fence.
+ const discMsgs = allSent(webWs).filter(
+ (m: Record) => m.type === 'system' && m.event === 'runtime.disconnected'
+ );
+ expect(discMsgs).toEqual([
+ {
+ type: 'system',
+ event: 'runtime.disconnected',
+ data: { runtimeId: validRuntime.runtimeId, connectionId: 'cli-1' },
+ },
+ ]);
+ expect(doInstance.getRuntimePresence()).toEqual([]);
+ });
+
+ it('does not remove the current runtime when a replaced stale socket closes', () => {
+ const { doInstance, mockCtx } = setup();
+ const firstCli = connectCliSocket(doInstance, 'cli-1');
+ sendHeartbeat(doInstance, firstCli, [], undefined, validRuntime);
+
+ // Replacement socket connects with the same connectionId.
+ const secondCli = connectCliSocket(doInstance, 'cli-1');
+ sendHeartbeat(doInstance, secondCli, [], undefined, validRuntime);
+
+ const webWs = addWebSocket(mockCtx, 'web-1');
+ webWs.send.mockClear();
+
+ // The first (stale) socket's close event fires after the replacement
+ // is in place. This must not drop the runtime.
+ disconnectCli(doInstance, firstCli);
+
+ const discMsgs = allSent(webWs).filter(
+ (m: Record) => m.type === 'system' && m.event === 'runtime.disconnected'
+ );
+ expect(discMsgs).toEqual([]);
+ expect(doInstance.getRuntimePresence()).toHaveLength(1);
+ });
+
+ it('rejects a heartbeat whose runtime fails strict validation (no presence)', () => {
+ const { doInstance, mockCtx } = setup();
+ const cliWs = addCliSocket(mockCtx, 'cli-1');
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
+
+ // Unknown capability string.
+ sendHeartbeat(doInstance, cliWs, [], undefined, {
+ ...validRuntime,
+ capabilities: ['unknown.v9'],
+ });
+ expect(doInstance.getRuntimePresence()).toEqual([]);
+ expect(warn).toHaveBeenCalled();
+ });
+
+ it('keeps existing sessions behavior unchanged when runtime presence is added', () => {
+ const { doInstance, mockCtx } = setup();
+ const cliWs = addCliSocket(mockCtx, 'cli-1');
+ sendHeartbeat(doInstance, cliWs, [makeSession('ses_main')], '1', validRuntime);
+
+ // Session-list still surfaces sessions from the live socket.
+ const sessions = doInstance.getActiveSessions();
+ expect(sessions).toEqual([
+ {
+ id: 'ses_main',
+ status: 'busy',
+ title: 'Test',
+ connectionId: 'cli-1',
+ protocolVersion: '1',
+ },
+ ]);
+ // And the runtime list is independent.
+ expect(doInstance.getRuntimePresence()).toHaveLength(1);
+ });
+
+ it('does not register the same runtimeId twice across two different sockets', () => {
+ const { doInstance, mockCtx } = setup();
+ const cli1 = addCliSocket(mockCtx, 'cli-1');
+ const cli2 = addCliSocket(mockCtx, 'cli-2');
+ // Same runtimeId, two different sockets (mutating attempt from a second
+ // CLI that reuses the first runtime's id). The mutation rejection path
+ // already covers the same-socket case; here we ensure cross-socket
+ // collisions also fail closed.
+ sendHeartbeat(doInstance, cli1, [], undefined, validRuntime);
+ sendHeartbeat(doInstance, cli2, [], undefined, validRuntime);
+
+ expect(doInstance.getRuntimePresence()).toHaveLength(1);
+ });
+
+ it('keeps a zero-session runtime in the active RPC list', () => {
+ const { doInstance, mockCtx } = setup();
+ const cliWs = addCliSocket(mockCtx, 'cli-1');
+ sendHeartbeat(doInstance, cliWs, [], undefined, validRuntime);
+ expect(doInstance.getRuntimePresence()).toHaveLength(1);
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // getRuntimeCatalog RPC (Slice 2)
+ // -------------------------------------------------------------------------
+
+ describe('getRuntimeCatalog RPC', () => {
+ const validRuntime = {
+ runtimeId: '8db3de9a-350f-4fad-a539-8e0da3bbcf5e',
+ connectionId: 'cli-1',
+ protocolVersion: 1,
+ cliVersion: '7.4.7',
+ displayName: 'Alice Mac',
+ projectName: 'customer-repo',
+ capabilities: ['catalog.v1', 'create-and-run.v1'],
+ };
+ const otherRuntime = {
+ ...validRuntime,
+ runtimeId: 'b14c2a7d-4e2d-4f1a-9c8d-7e8f1b2c3d4e',
+ connectionId: 'cli-2',
+ };
+ const capabilityMissingRuntime = {
+ ...validRuntime,
+ runtimeId: '0c0a1b2c-3d4e-4f60-8a8b-9c0d1e2f3a4b',
+ connectionId: 'cli-3',
+ capabilities: ['create-and-run.v1'],
+ };
+ const fence = {
+ runtimeId: validRuntime.runtimeId,
+ connectionId: 'cli-1',
+ };
+ const wireModels = {
+ all: [
+ {
+ id: 'kilo',
+ name: 'Kilo',
+ source: 'env',
+ env: [],
+ options: {},
+ models: {
+ 'kilo/auto': {
+ id: 'kilo/auto',
+ providerID: 'kilo',
+ api: { id: 'kilo/auto', url: '', npm: '' },
+ name: 'Auto',
+ capabilities: {
+ temperature: false,
+ reasoning: false,
+ attachment: false,
+ toolcall: false,
+ input: { text: true, audio: false, image: false, video: false, pdf: false },
+ 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: 1, output: 1 },
+ status: 'active',
+ options: {},
+ headers: {},
+ release_date: '',
+ },
+ },
+ },
+ ],
+ default: { kilo: 'kilo/auto' },
+ connected: ['kilo'],
+ failed: [],
+ protocolVersion: 1,
+ truncated: false,
+ };
+ const validCatalog = {
+ protocolVersion: 1,
+ models: wireModels,
+ agents: [
+ {
+ slug: 'build',
+ name: 'Build',
+ description: 'Default build agent',
+ },
+ ],
+ defaultAgent: 'build',
+ };
+
+ it('returns the strict catalog for an exact fence and routes the command to the exact socket', async () => {
+ const { doInstance, mockCtx } = setup();
+ const cliWs = addCliSocket(mockCtx, 'cli-1');
+ sendHeartbeat(doInstance, cliWs, [], undefined, validRuntime);
+ cliWs.send.mockClear();
+
+ const promise = doInstance.getRuntimeCatalog(fence);
+ const correlationId = getCorrelationId(cliWs);
+ expect(parseSent(cliWs)).toMatchObject({
+ type: 'command',
+ command: 'get_catalog',
+ data: { protocolVersion: 1 },
+ });
+
+ sendCliResponse(doInstance, cliWs, { id: correlationId, result: validCatalog });
+
+ await expect(promise).resolves.toEqual(validCatalog);
+ });
+
+ it('does not fall back to the first available CLI when the exact connectionId is missing', async () => {
+ const { doInstance, mockCtx } = setup();
+ const cli1 = addCliSocket(mockCtx, 'cli-1');
+ const cli2 = addCliSocket(mockCtx, 'cli-2');
+ sendHeartbeat(doInstance, cli1, [], undefined, validRuntime);
+ sendHeartbeat(doInstance, cli2, [], undefined, { ...otherRuntime, connectionId: 'cli-2' });
+ cli1.send.mockClear();
+ cli2.send.mockClear();
+
+ await expect(
+ doInstance.getRuntimeCatalog({
+ runtimeId: validRuntime.runtimeId,
+ connectionId: 'cli-does-not-exist',
+ })
+ ).rejects.toMatchObject({ code: 'RUNTIME_FENCE_MISMATCH' });
+
+ expect(cli1.send).not.toHaveBeenCalled();
+ expect(cli2.send).not.toHaveBeenCalled();
+ });
+
+ it('returns RUNTIME_NOT_CONNECTED when the runtime is not registered', async () => {
+ const { doInstance } = setup();
+
+ await expect(
+ doInstance.getRuntimeCatalog({
+ runtimeId: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
+ connectionId: 'cli-1',
+ })
+ ).rejects.toMatchObject({ code: 'RUNTIME_NOT_CONNECTED' });
+ });
+
+ it('returns RUNTIME_FENCE_MISMATCH when the connectionId does not match the live runtime', async () => {
+ const { doInstance, mockCtx } = setup();
+ const cliWs = addCliSocket(mockCtx, 'cli-1');
+ sendHeartbeat(doInstance, cliWs, [], undefined, validRuntime);
+
+ await expect(
+ doInstance.getRuntimeCatalog({
+ runtimeId: validRuntime.runtimeId,
+ connectionId: 'cli-other',
+ })
+ ).rejects.toBeInstanceOf(LocalRuntimeCommandError);
+ await expect(
+ doInstance.getRuntimeCatalog({
+ runtimeId: validRuntime.runtimeId,
+ connectionId: 'cli-other',
+ })
+ ).rejects.toMatchObject({ code: 'RUNTIME_FENCE_MISMATCH' });
+ });
+
+ it('returns RUNTIME_FENCE_MISMATCH when the socket was replaced and a new connectionId now owns the runtime', async () => {
+ const { doInstance, mockCtx } = setup();
+ const firstCli = addCliSocket(mockCtx, 'cli-1');
+ sendHeartbeat(doInstance, firstCli, [], undefined, validRuntime);
+
+ // The original socket disconnects; the runtime is evicted by
+ // handleCliDisconnect.
+ mockCtx.removeSocket(firstCli);
+ disconnectCli(doInstance, firstCli);
+
+ // A new socket takes the same runtimeId under a new connectionId.
+ const secondCli = addCliSocket(mockCtx, 'cli-2');
+ sendHeartbeat(doInstance, secondCli, [], undefined, {
+ ...validRuntime,
+ connectionId: 'cli-2',
+ });
+
+ // The old fence no longer matches the live owner; a refresh would be
+ // required before retrying.
+ await expect(
+ doInstance.getRuntimeCatalog({
+ runtimeId: validRuntime.runtimeId,
+ connectionId: 'cli-1',
+ })
+ ).rejects.toMatchObject({ code: 'RUNTIME_FENCE_MISMATCH' });
+ });
+
+ it('rejects when the runtime does not advertise the catalog.v1 capability', async () => {
+ const { doInstance, mockCtx } = setup();
+ const cliWs = addCliSocket(mockCtx, 'cli-3');
+ sendHeartbeat(doInstance, cliWs, [], undefined, capabilityMissingRuntime);
+
+ await expect(
+ doInstance.getRuntimeCatalog({
+ runtimeId: capabilityMissingRuntime.runtimeId,
+ connectionId: 'cli-3',
+ })
+ ).rejects.toMatchObject({ code: 'RUNTIME_FENCE_MISMATCH' });
+ });
+
+ it('rejects a generic viewer WebSocket command "get_catalog" with COMMAND_NOT_ALLOWED', () => {
+ const { doInstance, mockCtx } = setup();
+ const cliWs = addCliSocket(mockCtx, 'cli-1');
+ const webWs = addWebSocket(mockCtx, 'web-1');
+ sendHeartbeat(doInstance, cliWs, [], undefined, validRuntime);
+ cliWs.send.mockClear();
+ webWs.send.mockClear();
+
+ sendCommand(doInstance, webWs, {
+ id: 'viewer-cmd',
+ command: 'get_catalog',
+ data: { protocolVersion: 1 },
+ });
+
+ expect(cliWs.send).not.toHaveBeenCalled();
+ expect(parseSent(webWs)).toEqual({
+ type: 'response',
+ id: 'viewer-cmd',
+ error: {
+ source: 'relay',
+ code: 'COMMAND_NOT_ALLOWED',
+ message: 'Command not allowed',
+ },
+ });
+ });
+
+ it('maps an "unknown command" CLI error to CLI_UPGRADE_REQUIRED', async () => {
+ const { doInstance, mockCtx } = setup();
+ const cliWs = addCliSocket(mockCtx, 'cli-1');
+ sendHeartbeat(doInstance, cliWs, [], undefined, validRuntime);
+ cliWs.send.mockClear();
+
+ const promise = doInstance.getRuntimeCatalog(fence);
+ const correlationId = getCorrelationId(cliWs);
+
+ sendCliResponse(doInstance, cliWs, {
+ id: correlationId,
+ error: 'unknown command: get_catalog',
+ });
+
+ await expect(promise).rejects.toMatchObject({ code: 'CLI_UPGRADE_REQUIRED' });
+ });
+
+ it('returns RESULT_TOO_LARGE when the catalog response exceeds 512 KiB', async () => {
+ const { doInstance, mockCtx } = setup();
+ const cliWs = addCliSocket(mockCtx, 'cli-1');
+ sendHeartbeat(doInstance, cliWs, [], undefined, validRuntime);
+ cliWs.send.mockClear();
+
+ const promise = doInstance.getRuntimeCatalog(fence);
+ const correlationId = getCorrelationId(cliWs);
+
+ sendCliResponse(doInstance, cliWs, {
+ id: correlationId,
+ result: { padding: 'x'.repeat(MAX_CATALOG_RESULT_BYTES + 1) },
+ });
+
+ await expect(promise).rejects.toMatchObject({ code: 'RESULT_TOO_LARGE' });
+ });
+
+ it('returns INVALID_RUNTIME_RESPONSE when the result fails strict parsing', async () => {
+ const { doInstance, mockCtx } = setup();
+ const cliWs = addCliSocket(mockCtx, 'cli-1');
+ sendHeartbeat(doInstance, cliWs, [], undefined, validRuntime);
+ cliWs.send.mockClear();
+
+ const promise = doInstance.getRuntimeCatalog(fence);
+ const correlationId = getCorrelationId(cliWs);
+
+ // Wrong protocolVersion literal — the cross-service contract is strict
+ // and `.strict()`-rejects any extra field, so a single bad discriminator
+ // is the smallest reproducible failure.
+ sendCliResponse(doInstance, cliWs, {
+ id: correlationId,
+ result: { protocolVersion: 2, models: {}, agents: [], defaultAgent: 'build' },
+ });
+
+ await expect(promise).rejects.toMatchObject({ code: 'INVALID_RUNTIME_RESPONSE' });
+ });
+
+ it('returns RUNTIME_COMMAND_FAILED on a CLI-supplied non-relay error', async () => {
+ const { doInstance, mockCtx } = setup();
+ const cliWs = addCliSocket(mockCtx, 'cli-1');
+ sendHeartbeat(doInstance, cliWs, [], undefined, validRuntime);
+ cliWs.send.mockClear();
+
+ const promise = doInstance.getRuntimeCatalog(fence);
+ const correlationId = getCorrelationId(cliWs);
+
+ sendCliResponse(doInstance, cliWs, {
+ id: correlationId,
+ error: { code: 'INTERNAL', message: 'boom' },
+ });
+
+ await expect(promise).rejects.toMatchObject({ code: 'RUNTIME_COMMAND_FAILED' });
+ });
+
+ it('returns COMMAND_EXPIRED when the response does not arrive before the pending TTL elapses', async () => {
+ const now = 1_000_000;
+ vi.spyOn(Date, 'now').mockReturnValue(now);
+ const { doInstance, mockCtx } = setup();
+ const cliWs = addCliSocket(mockCtx, 'cli-1');
+ sendHeartbeat(doInstance, cliWs, [], undefined, validRuntime);
+ cliWs.send.mockClear();
+
+ const promise = doInstance.getRuntimeCatalog(fence);
+ // Drain the microtask queue so the promise attaches
+ await Promise.resolve();
+
+ vi.mocked(Date.now).mockReturnValue(now + 35_001);
+ await doInstance.alarm();
+
+ await expect(promise).rejects.toMatchObject({ code: 'COMMAND_EXPIRED' });
+ });
+
+ it('returns RUNTIME_FENCE_MISMATCH when the runtime disconnects before responding', async () => {
+ const { doInstance, mockCtx } = setup();
+ const cliWs = addCliSocket(mockCtx, 'cli-1');
+ sendHeartbeat(doInstance, cliWs, [], undefined, validRuntime);
+ cliWs.send.mockClear();
+
+ const promise = doInstance.getRuntimeCatalog(fence);
+
+ mockCtx.removeSocket(cliWs);
+ disconnectCli(doInstance, cliWs);
+
+ await expect(promise).rejects.toMatchObject({ code: 'RUNTIME_FENCE_MISMATCH' });
+ });
+
+ it('ignores a late response from a non-target socket and does not settle the pending promise', async () => {
+ const { doInstance, mockCtx } = setup();
+ const targetCli = addCliSocket(mockCtx, 'cli-1');
+ const otherCli = addCliSocket(mockCtx, 'cli-2');
+ sendHeartbeat(doInstance, targetCli, [], undefined, validRuntime);
+ sendHeartbeat(doInstance, otherCli, [], undefined, {
+ ...otherRuntime,
+ connectionId: 'cli-2',
+ });
+ targetCli.send.mockClear();
+
+ const promise = doInstance.getRuntimeCatalog(fence);
+ const correlationId = getCorrelationId(targetCli);
+
+ // A stray response from a different socket must not resolve the promise.
+ sendCliResponse(doInstance, otherCli, {
+ id: correlationId,
+ result: { ...validCatalog, defaultAgent: 'wrong' },
+ });
+
+ // The promise must still be pending. We use a small synchronous check
+ // (Promise.race with a 0ms timer) to confirm it has not settled.
+ const settled = await Promise.race([
+ promise.then(() => 'settled' as const).catch(() => 'settled' as const),
+ new Promise<'pending'>(resolve => setTimeout(() => resolve('pending'), 5)),
+ ]);
+ expect(settled).toBe('pending');
+
+ // The correct socket can still respond.
+ sendCliResponse(doInstance, targetCli, { id: correlationId, result: validCatalog });
+ await expect(promise).resolves.toEqual(validCatalog);
+ });
+
+ it('routes a fresh request to the replacement socket after the original disconnects', async () => {
+ const { doInstance, mockCtx } = setup();
+ const firstCli = addCliSocket(mockCtx, 'cli-1');
+ sendHeartbeat(doInstance, firstCli, [], undefined, validRuntime);
+
+ // Old socket closes; in production closeStaleSocket triggers
+ // webSocketClose. The mock doesn't auto-remove from the list, so
+ // do it explicitly before adding the replacement.
+ mockCtx.removeSocket(firstCli);
+ disconnectCli(doInstance, firstCli);
+
+ // Reconnect: the new socket has the same connectionId.
+ const secondCli = addCliSocket(mockCtx, 'cli-1');
+ sendHeartbeat(doInstance, secondCli, [], undefined, validRuntime);
+ secondCli.send.mockClear();
+
+ const promise = doInstance.getRuntimeCatalog(fence);
+ const correlationId = getCorrelationId(secondCli);
+ sendCliResponse(doInstance, secondCli, { id: correlationId, result: validCatalog });
+
+ await expect(promise).resolves.toEqual(validCatalog);
+ });
+
+ it('does not log the raw catalog payload on success', async () => {
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
+ const { doInstance, mockCtx } = setup();
+ const cliWs = addCliSocket(mockCtx, 'cli-1');
+ sendHeartbeat(doInstance, cliWs, [], undefined, validRuntime);
+ cliWs.send.mockClear();
+
+ const sentinel = 'raw-catalog-payload-must-not-be-logged';
+ const promise = doInstance.getRuntimeCatalog(fence);
+ const correlationId = getCorrelationId(cliWs);
+ sendCliResponse(doInstance, cliWs, {
+ id: correlationId,
+ result: { ...validCatalog, defaultAgent: sentinel },
+ });
+ await promise;
+
+ const dumped = JSON.stringify({
+ errors: errorSpy.mock.calls,
+ warns: warnSpy.mock.calls,
+ });
+ expect(dumped).not.toContain(sentinel);
+ });
+ });
+
+ // -------------------------------------------------------------------------
+ // createAndRunLocalSession RPC (Slice 3B)
+ // -------------------------------------------------------------------------
+
+ describe('createAndRunLocalSession RPC', () => {
+ const runtime = {
+ runtimeId: '8db3de9a-350f-4fad-a539-8e0da3bbcf5e',
+ connectionId: 'cli-1',
+ protocolVersion: 1,
+ cliVersion: '7.4.7',
+ displayName: 'Alice Mac',
+ projectName: 'customer-repo',
+ capabilities: ['catalog.v1', 'create-and-run.v1'],
+ };
+ const runtimesMissingCreate = {
+ ...runtime,
+ runtimeId: '1c0a1b2c-3d4e-4f60-8a8b-9c0d1e2f3a4b',
+ connectionId: 'cli-3',
+ capabilities: ['catalog.v1'],
+ };
+ const fence = {
+ runtimeId: runtime.runtimeId,
+ connectionId: 'cli-1',
+ };
+ const validRequest = {
+ protocolVersion: 1,
+ requestId: '0c0a1b2c-3d4e-4f60-8a8b-9c0d1e2f3a4b',
+ prompt: 'hello',
+ model: { providerID: 'kilo', modelID: 'kilo/auto' },
+ agent: 'build',
+ } as const;
+ const promptStartedSessionId = 'ses_a1b2c3d4e5f67890123456789a'; // 30 chars: 'ses_' + 26
+ const promptPartialSessionId = 'ses_b2c3d4e5f67890123456789cde'; // 30 chars: 'ses_' + 26
+ const successResult = {
+ protocolVersion: 1,
+ sessionId: promptStartedSessionId,
+ promptStarted: true,
+ } as const;
+ const promptFailedResult = {
+ protocolVersion: 1,
+ sessionId: promptPartialSessionId,
+ promptStarted: false,
+ error: {
+ code: 'PROMPT_START_FAILED',
+ message: 'The session was created, but the first prompt did not start.',
+ },
+ } as const;
+
+ it('resolves with the strict success result for an exact fence and routes the strict request to the exact socket', async () => {
+ const { doInstance, mockCtx } = setup();
+ const cliWs = addCliSocket(mockCtx, 'cli-1');
+ sendHeartbeat(doInstance, cliWs, [], undefined, runtime);
+ cliWs.send.mockClear();
+
+ const promise = doInstance.createAndRunLocalSession(fence, validRequest);
+ const sent = parseSent(cliWs);
+ expect(sent).toMatchObject({
+ type: 'command',
+ command: 'create_and_run',
+ data: validRequest,
+ });
+ const correlationId = (sent as { id: string }).id;
+
+ sendCliResponse(doInstance, cliWs, {
+ id: correlationId,
+ result: successResult,
+ });
+
+ await expect(promise).resolves.toEqual(successResult);
+ });
+
+ it('forwards the request body exactly with the strict v1 fields', async () => {
+ const { doInstance, mockCtx } = setup();
+ const cliWs = addCliSocket(mockCtx, 'cli-1');
+ sendHeartbeat(doInstance, cliWs, [], undefined, runtime);
+ cliWs.send.mockClear();
+
+ const request = {
+ ...validRequest,
+ variant: 'thinking',
+ requestId: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee',
+ };
+ const promise = doInstance.createAndRunLocalSession(fence, request);
+ const sent = parseSent(cliWs);
+ expect(sent).toMatchObject({ type: 'command', command: 'create_and_run', data: request });
+ const correlationId = (sent as { id: string }).id;
+ sendCliResponse(doInstance, cliWs, { id: correlationId, result: successResult });
+ await promise;
+ });
+
+ it('resolves with the terminal partial when the CLI returns promptStarted:false', async () => {
+ const { doInstance, mockCtx } = setup();
+ const cliWs = addCliSocket(mockCtx, 'cli-1');
+ sendHeartbeat(doInstance, cliWs, [], undefined, runtime);
+ cliWs.send.mockClear();
+
+ const promise = doInstance.createAndRunLocalSession(fence, validRequest);
+ const correlationId = getCorrelationId(cliWs);
+ sendCliResponse(doInstance, cliWs, { id: correlationId, result: promptFailedResult });
+
+ await expect(promise).resolves.toEqual(promptFailedResult);
+ });
+
+ it('does not fall back to the first available CLI when the exact connectionId is missing', async () => {
+ const { doInstance, mockCtx } = setup();
+ const cli1 = addCliSocket(mockCtx, 'cli-1');
+ const cli2 = addCliSocket(mockCtx, 'cli-2');
+ sendHeartbeat(doInstance, cli1, [], undefined, runtime);
+ sendHeartbeat(doInstance, cli2, [], undefined, {
+ ...runtime,
+ runtimeId: 'aaaaaaaa-1111-4111-8111-111111111111',
+ connectionId: 'cli-2',
+ });
+ cli1.send.mockClear();
+ cli2.send.mockClear();
+
+ await expect(
+ doInstance.createAndRunLocalSession(
+ { runtimeId: runtime.runtimeId, connectionId: 'cli-does-not-exist' },
+ validRequest
+ )
+ ).rejects.toMatchObject({ code: 'RUNTIME_FENCE_MISMATCH' });
+
+ expect(cli1.send).not.toHaveBeenCalled();
+ expect(cli2.send).not.toHaveBeenCalled();
+ });
+
+ it('returns RUNTIME_NOT_CONNECTED when the runtime is not registered', async () => {
+ const { doInstance } = setup();
+
+ await expect(
+ doInstance.createAndRunLocalSession(
+ { runtimeId: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', connectionId: 'cli-1' },
+ validRequest
+ )
+ ).rejects.toMatchObject({ code: 'RUNTIME_NOT_CONNECTED' });
+ });
+
+ it('returns RUNTIME_FENCE_MISMATCH when the connectionId does not match the live runtime', async () => {
+ const { doInstance, mockCtx } = setup();
+ const cliWs = addCliSocket(mockCtx, 'cli-1');
+ sendHeartbeat(doInstance, cliWs, [], undefined, runtime);
+
+ await expect(
+ doInstance.createAndRunLocalSession(
+ { runtimeId: runtime.runtimeId, connectionId: 'cli-other' },
+ validRequest
+ )
+ ).rejects.toBeInstanceOf(LocalRuntimeCommandError);
+ await expect(
+ doInstance.createAndRunLocalSession(
+ { runtimeId: runtime.runtimeId, connectionId: 'cli-other' },
+ validRequest
+ )
+ ).rejects.toMatchObject({ code: 'RUNTIME_FENCE_MISMATCH' });
+ });
+
+ it('returns RUNTIME_FENCE_MISMATCH when the runtime was replaced and a new connectionId now owns the runtime', async () => {
+ const { doInstance, mockCtx } = setup();
+ const firstCli = addCliSocket(mockCtx, 'cli-1');
+ sendHeartbeat(doInstance, firstCli, [], undefined, runtime);
+
+ mockCtx.removeSocket(firstCli);
+ disconnectCli(doInstance, firstCli);
+
+ const secondCli = addCliSocket(mockCtx, 'cli-2');
+ sendHeartbeat(doInstance, secondCli, [], undefined, { ...runtime, connectionId: 'cli-2' });
+
+ await expect(doInstance.createAndRunLocalSession(fence, validRequest)).rejects.toMatchObject({
+ code: 'RUNTIME_FENCE_MISMATCH',
+ });
+ });
+
+ it('rejects when the runtime does not advertise the create-and-run.v1 capability', async () => {
+ const { doInstance, mockCtx } = setup();
+ const cliWs = addCliSocket(mockCtx, 'cli-3');
+ sendHeartbeat(doInstance, cliWs, [], undefined, runtimesMissingCreate);
+
+ await expect(
+ doInstance.createAndRunLocalSession(
+ {
+ runtimeId: runtimesMissingCreate.runtimeId,
+ connectionId: 'cli-3',
+ },
+ validRequest
+ )
+ ).rejects.toMatchObject({ code: 'RUNTIME_FENCE_MISMATCH' });
+ });
+
+ it('rejects an invalid request body before any pending work is allocated', async () => {
+ const { doInstance, mockCtx } = setup();
+ const cliWs = addCliSocket(mockCtx, 'cli-1');
+ sendHeartbeat(doInstance, cliWs, [], undefined, runtime);
+ cliWs.send.mockClear();
+
+ await expect(
+ doInstance.createAndRunLocalSession(fence, { ...validRequest, prompt: '' })
+ ).rejects.toMatchObject({ code: 'INVALID_RUNTIME_RESPONSE' });
+ expect(cliWs.send).not.toHaveBeenCalled();
+ });
+
+ it('rejects a generic viewer WebSocket command "create_and_run" with COMMAND_NOT_ALLOWED', () => {
+ const { doInstance, mockCtx } = setup();
+ const cliWs = addCliSocket(mockCtx, 'cli-1');
+ const webWs = addWebSocket(mockCtx, 'web-1');
+ sendHeartbeat(doInstance, cliWs, [], undefined, runtime);
+ cliWs.send.mockClear();
+ webWs.send.mockClear();
+
+ sendCommand(doInstance, webWs, {
+ id: 'viewer-cmd',
+ command: 'create_and_run',
+ data: validRequest,
+ });
+
+ expect(cliWs.send).not.toHaveBeenCalled();
+ expect(parseSent(webWs)).toEqual({
+ type: 'response',
+ id: 'viewer-cmd',
+ error: {
+ source: 'relay',
+ code: 'COMMAND_NOT_ALLOWED',
+ message: 'Command not allowed',
+ },
+ });
+ });
+
+ it('maps a typed CLI INVALID_REQUEST error to INVALID_RUNTIME_RESPONSE', async () => {
+ const { doInstance, mockCtx } = setup();
+ const cliWs = addCliSocket(mockCtx, 'cli-1');
+ sendHeartbeat(doInstance, cliWs, [], undefined, runtime);
+ cliWs.send.mockClear();
+
+ const promise = doInstance.createAndRunLocalSession(fence, validRequest);
+ const correlationId = getCorrelationId(cliWs);
+
+ sendCliResponse(doInstance, cliWs, {
+ id: correlationId,
+ error: { code: 'INVALID_REQUEST', message: 'invalid create_and_run request' },
+ });
+
+ await expect(promise).rejects.toMatchObject({ code: 'INVALID_RUNTIME_RESPONSE' });
+ });
+
+ it('preserves a CLI CATALOG_CHANGED error verbatim as CATALOG_CHANGED', async () => {
+ const { doInstance, mockCtx } = setup();
+ const cliWs = addCliSocket(mockCtx, 'cli-1');
+ sendHeartbeat(doInstance, cliWs, [], undefined, runtime);
+ cliWs.send.mockClear();
+
+ const promise = doInstance.createAndRunLocalSession(fence, validRequest);
+ const correlationId = getCorrelationId(cliWs);
+
+ sendCliResponse(doInstance, cliWs, {
+ id: correlationId,
+ error: { code: 'CATALOG_CHANGED', message: 'model not found in runtime catalog' },
+ });
+
+ await expect(promise).rejects.toMatchObject({ code: 'CATALOG_CHANGED' });
+ });
+
+ it.each(['CREATE_FAILED', 'ANNOUNCE_FAILED', 'INTERNAL'])(
+ 'maps a typed CLI %s error to RUNTIME_COMMAND_FAILED without surfacing the raw message',
+ async code => {
+ const { doInstance, mockCtx } = setup();
+ const cliWs = addCliSocket(mockCtx, 'cli-1');
+ sendHeartbeat(doInstance, cliWs, [], undefined, runtime);
+ cliWs.send.mockClear();
+
+ const promise = doInstance.createAndRunLocalSession(fence, validRequest);
+ const correlationId = getCorrelationId(cliWs);
+
+ sendCliResponse(doInstance, cliWs, {
+ id: correlationId,
+ error: { code, message: 'super-secret-cli-leak-marker' },
+ });
+
+ await expect(promise).rejects.toMatchObject({ code: 'RUNTIME_COMMAND_FAILED' });
+ const dumped = JSON.stringify({ cause: await promise.catch(e => e) });
+ expect(dumped).not.toContain('super-secret-cli-leak-marker');
+ }
+ );
+
+ it('maps an "unknown command" CLI error to CLI_UPGRADE_REQUIRED', async () => {
+ const { doInstance, mockCtx } = setup();
+ const cliWs = addCliSocket(mockCtx, 'cli-1');
+ sendHeartbeat(doInstance, cliWs, [], undefined, runtime);
+ cliWs.send.mockClear();
+
+ const promise = doInstance.createAndRunLocalSession(fence, validRequest);
+ const correlationId = getCorrelationId(cliWs);
+
+ sendCliResponse(doInstance, cliWs, {
+ id: correlationId,
+ error: 'unknown command: create_and_run',
+ });
+
+ await expect(promise).rejects.toMatchObject({ code: 'CLI_UPGRADE_REQUIRED' });
+ });
+
+ it('returns INVALID_RUNTIME_RESPONSE when the CLI result fails strict validation', async () => {
+ const { doInstance, mockCtx } = setup();
+ const cliWs = addCliSocket(mockCtx, 'cli-1');
+ sendHeartbeat(doInstance, cliWs, [], undefined, runtime);
+ cliWs.send.mockClear();
+
+ const promise = doInstance.createAndRunLocalSession(fence, validRequest);
+ const correlationId = getCorrelationId(cliWs);
+
+ sendCliResponse(doInstance, cliWs, {
+ id: correlationId,
+ // Wrong discriminator literal — strict schema rejects it.
+ result: {
+ protocolVersion: 1,
+ sessionId: 'not-a-real-session-id',
+ promptStarted: true,
+ },
+ });
+
+ await expect(promise).rejects.toMatchObject({ code: 'INVALID_RUNTIME_RESPONSE' });
+ });
+
+ it('returns RESULT_TOO_LARGE when the create-and-run response exceeds the cap', async () => {
+ const { doInstance, mockCtx } = setup();
+ const cliWs = addCliSocket(mockCtx, 'cli-1');
+ sendHeartbeat(doInstance, cliWs, [], undefined, runtime);
+ cliWs.send.mockClear();
+
+ const promise = doInstance.createAndRunLocalSession(fence, validRequest);
+ const correlationId = getCorrelationId(cliWs);
+
+ const oversized = 'x'.repeat(20 * 1024);
+ sendCliResponse(doInstance, cliWs, {
+ id: correlationId,
+ result: { ...successResult, promptStarted: oversized as never },
+ });
+
+ await expect(promise).rejects.toMatchObject({ code: 'RESULT_TOO_LARGE' });
+ });
+
+ it('returns COMMAND_EXPIRED when the response does not arrive before the pending TTL elapses', async () => {
+ const now = 1_000_000;
+ vi.spyOn(Date, 'now').mockReturnValue(now);
+ const { doInstance, mockCtx } = setup();
+ const cliWs = addCliSocket(mockCtx, 'cli-1');
+ sendHeartbeat(doInstance, cliWs, [], undefined, runtime);
+ cliWs.send.mockClear();
+
+ const promise = doInstance.createAndRunLocalSession(fence, validRequest);
+ await Promise.resolve();
+
+ vi.mocked(Date.now).mockReturnValue(now + 35_001);
+ await doInstance.alarm();
+
+ await expect(promise).rejects.toMatchObject({ code: 'COMMAND_EXPIRED' });
+ });
+
+ it('returns RUNTIME_FENCE_MISMATCH when the runtime disconnects before responding', async () => {
+ const { doInstance, mockCtx } = setup();
+ const cliWs = addCliSocket(mockCtx, 'cli-1');
+ sendHeartbeat(doInstance, cliWs, [], undefined, runtime);
+ cliWs.send.mockClear();
+
+ const promise = doInstance.createAndRunLocalSession(fence, validRequest);
+
+ mockCtx.removeSocket(cliWs);
+ disconnectCli(doInstance, cliWs);
+
+ await expect(promise).rejects.toMatchObject({ code: 'RUNTIME_FENCE_MISMATCH' });
+ });
+
+ it('ignores a late response from a non-target socket and does not settle the pending promise', async () => {
+ const { doInstance, mockCtx } = setup();
+ const targetCli = addCliSocket(mockCtx, 'cli-1');
+ const otherCli = addCliSocket(mockCtx, 'cli-2');
+ sendHeartbeat(doInstance, targetCli, [], undefined, runtime);
+ sendHeartbeat(doInstance, otherCli, [], undefined, {
+ ...runtime,
+ runtimeId: 'aaaaaaaa-1111-4111-8111-111111111111',
+ connectionId: 'cli-2',
+ });
+ targetCli.send.mockClear();
+
+ const promise = doInstance.createAndRunLocalSession(fence, validRequest);
+ const correlationId = getCorrelationId(targetCli);
+
+ sendCliResponse(doInstance, otherCli, {
+ id: correlationId,
+ result: { ...successResult, sessionId: 'ses_other_user_id_placeholder0' },
+ });
+
+ const settled = await Promise.race([
+ promise.then(() => 'settled' as const).catch(() => 'settled' as const),
+ new Promise<'pending'>(resolve => setTimeout(() => resolve('pending'), 5)),
+ ]);
+ expect(settled).toBe('pending');
+
+ sendCliResponse(doInstance, targetCli, { id: correlationId, result: successResult });
+ await expect(promise).resolves.toEqual(successResult);
+ });
+
+ it('forwards the strict requestId to the CLI without modification', async () => {
+ const { doInstance, mockCtx } = setup();
+ const cliWs = addCliSocket(mockCtx, 'cli-1');
+ sendHeartbeat(doInstance, cliWs, [], undefined, runtime);
+ cliWs.send.mockClear();
+
+ const requestId = '11111111-2222-4333-8444-555555555555';
+ const request = { ...validRequest, requestId };
+ const promise = doInstance.createAndRunLocalSession(fence, request);
+ const sent = parseSent(cliWs);
+ expect((sent as { data: { requestId: string } }).data.requestId).toBe(requestId);
+ const correlationId = (sent as { id: string }).id;
+ sendCliResponse(doInstance, cliWs, { id: correlationId, result: successResult });
+ await promise;
+ });
+
+ it('never logs the raw prompt, raw CLI error, or raw result content', async () => {
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
+
+ const { doInstance, mockCtx } = setup();
+ const cliWs = addCliSocket(mockCtx, 'cli-1');
+ sendHeartbeat(doInstance, cliWs, [], undefined, runtime);
+ cliWs.send.mockClear();
+
+ const prompt = 'super-secret-prompt-must-not-be-logged';
+ const request = { ...validRequest, prompt };
+ const promise = doInstance.createAndRunLocalSession(fence, request);
+ const correlationId = getCorrelationId(cliWs);
+ sendCliResponse(doInstance, cliWs, {
+ id: correlationId,
+ result: { ...successResult, sessionId: 'ses_log_placeholder_xxxxxxxxxx' },
+ });
+ await promise;
+
+ const dumped = JSON.stringify({
+ errors: errorSpy.mock.calls,
+ warns: warnSpy.mock.calls,
+ });
+ expect(dumped).not.toContain(prompt);
+ expect(dumped).not.toContain('ses_log_placeholder_xxxxxxxxxx');
+ });
+
+ it('never logs the raw CLI error envelope on a typed failure', async () => {
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
+
+ const { doInstance, mockCtx } = setup();
+ const cliWs = addCliSocket(mockCtx, 'cli-1');
+ sendHeartbeat(doInstance, cliWs, [], undefined, runtime);
+ cliWs.send.mockClear();
+
+ const promise = doInstance.createAndRunLocalSession(fence, validRequest);
+ const correlationId = getCorrelationId(cliWs);
+ sendCliResponse(doInstance, cliWs, {
+ id: correlationId,
+ error: { code: 'INTERNAL', message: 'super-secret-cli-error-leak-marker' },
+ });
+ await expect(promise).rejects.toMatchObject({ code: 'RUNTIME_COMMAND_FAILED' });
+
+ const dumped = JSON.stringify({
+ errors: errorSpy.mock.calls,
+ warns: warnSpy.mock.calls,
+ });
+ expect(dumped).not.toContain('super-secret-cli-error-leak-marker');
+ });
+ });
});
diff --git a/services/session-ingest/src/dos/UserConnectionDO.ts b/services/session-ingest/src/dos/UserConnectionDO.ts
index b6855f19a7..54ebeded0b 100644
--- a/services/session-ingest/src/dos/UserConnectionDO.ts
+++ b/services/session-ingest/src/dos/UserConnectionDO.ts
@@ -4,13 +4,24 @@ import type { Env } from '../env';
import { getSessionIngestDO } from './SessionIngestDO';
import {
CLIOutboundMessageSchema,
+ parseCliRuntimePresence,
type CLIInboundMessage,
type SessionEventPayload,
SessionEventPayloadSchema,
type WebInboundMessage,
WebOutboundMessageSchema,
} from '../types/user-connection-protocol';
-
+import {
+ createAndRunLocalSessionRequestSchema,
+ createAndRunLocalSessionResultSchema,
+ localRuntimeCatalogSchema,
+ type CreateAndRunLocalSessionRequest,
+ type CreateAndRunLocalSessionResult,
+ type LocalRuntimeCatalog,
+ type LocalRuntimeControlErrorCode,
+ type LocalRuntimeFence,
+ type LocalRuntimePresence,
+} from '@kilocode/session-ingest-contracts';
type HeartbeatSession = {
id: string;
status: string;
@@ -20,6 +31,8 @@ type HeartbeatSession = {
parentSessionId?: string;
};
+type RuntimeMetadata = LocalRuntimePresence & { lastHeartbeatAt: number };
+
type WSAttachment =
| {
role: 'cli';
@@ -32,11 +45,47 @@ type WSAttachment =
// Set from the authenticated /user/cli route; undefined on sockets
// accepted before this field existed. Needed for the session-ready push.
kiloUserId?: string;
+ // Safe runtime metadata persisted across hibernation. Never includes
+ // the absolute launch directory; only sanitized display labels.
+ runtime?: RuntimeMetadata;
}
| { role: 'web'; connectionId: string; subscribedSessions: string[]; replaced?: true };
export const MAX_CATALOG_RESULT_BYTES = 512 * 1024;
+/**
+ * Bounded size for a `create_and_run` CLI result. The discriminated union is
+ * tiny (sessionId + promptStarted + optional fixed message), so a few KiB is
+ * more than enough. The cap is intentionally separate from the catalog cap
+ * so one relay response class cannot exhaust the other.
+ */
+export const MAX_CREATE_AND_RUN_RESULT_BYTES = 16 * 1024;
+
+/**
+ * Internal, typed failure raised by `UserConnectionDO.getRuntimeCatalog` (and
+ * surfaced through the internal HTTP route). The relay never re-emits a raw
+ * CLI error string — every failure collapses to a stable, mobile-branched
+ * code; the message is operator-facing only and never leaves the worker
+ * unredacted.
+ */
+export class LocalRuntimeCommandError extends Error {
+ constructor(
+ public readonly code: LocalRuntimeControlErrorCode,
+ message: string
+ ) {
+ super(message);
+ this.name = 'LocalRuntimeCommandError';
+ }
+}
+
+const GET_CATALOG_COMMAND = 'get_catalog';
+const CREATE_AND_RUN_COMMAND = 'create_and_run';
+const COMMAND_NOT_ALLOWED_VIEWER_ERROR = {
+ source: 'relay' as const,
+ code: 'COMMAND_NOT_ALLOWED' as const,
+ message: 'Command not allowed',
+};
+
const SESSION_OWNER_CHANGED_ERROR = {
source: 'relay',
code: 'SESSION_OWNER_CHANGED',
@@ -76,6 +125,13 @@ export class UserConnectionDO extends DurableObject {
private static readonly HEARTBEAT_TIMEOUT_MS = 30_000;
private static readonly PENDING_COMMAND_TTL_MS = 35_000;
private static readonly MAX_PENDING_COMMANDS = 128;
+ /**
+ * Hard cap on first-class runtimes. Matches the bounded contract used by
+ * the internal runtime-control HTTP route and the local-runtime schema
+ * (`localRuntimeListResponseSchema`). One DO is one Kilo user, so 32 is
+ * far above the realistic fan-in.
+ */
+ private static readonly MAX_RUNTIMES = 32;
// Which CLI connection owns each session
private sessionOwners = new Map();
@@ -85,13 +141,20 @@ export class UserConnectionDO extends DurableObject {
private connectionSessions = new Map();
// Protocol version per CLI connection (from heartbeat); absent = legacy CLI
private connectionProtocolVersion = new Map();
- // Pending command responses: correlationId → originating web socket
+ // Pending command responses: correlationId → destination. Viewer-originated
+ // commands carry the originating `ws`; relay-originated RPCs (e.g.
+ // getRuntimeCatalog) carry an internal `pending` Promise instead. Either
+ // one is set, never both.
private pendingCommands = new Map<
string,
{
- ws: WebSocket;
+ ws?: WebSocket;
+ pending?: {
+ resolve: (value: LocalRuntimeCatalog | CreateAndRunLocalSessionResult) => void;
+ reject: (reason: LocalRuntimeCommandError) => void;
+ };
sessionId?: string;
- originalId: string;
+ originalId?: string;
command: string;
expectedOwnerConnectionId?: string;
targetConnectionId: string;
@@ -101,6 +164,10 @@ export class UserConnectionDO extends DurableObject {
>();
// Last heartbeat timestamp per CLI connectionId (for staleness eviction)
private lastHeartbeatAt = new Map();
+ // First-class runtime presence indexed by runtimeId. Lives independently of
+ // session ownership — a zero-session heartbeat still creates an entry, and
+ // CLI sockets without a runtime field never populate this map.
+ private runtimes = new Map();
private stateReconstructed = false;
@@ -110,6 +177,7 @@ export class UserConnectionDO extends DurableObject {
let cliCount = 0;
let webCount = 0;
let sessionCount = 0;
+ let runtimeCount = 0;
for (const ws of this.ctx.getWebSockets()) {
const attachment = ws.deserializeAttachment() as WSAttachment | null;
@@ -117,7 +185,7 @@ export class UserConnectionDO extends DurableObject {
if (attachment.role === 'cli') {
cliCount++;
- const { connectionId, sessions, protocolVersion } = attachment;
+ const { connectionId, sessions, protocolVersion, runtime } = attachment;
this.connectionSessions.set(connectionId, sessions);
this.connectionProtocolVersion.set(connectionId, protocolVersion);
sessionCount += sessions.length;
@@ -125,6 +193,10 @@ export class UserConnectionDO extends DurableObject {
this.sessionOwners.set(session.id, connectionId);
}
this.lastHeartbeatAt.set(connectionId, Date.now());
+ if (runtime) {
+ this.runtimes.set(runtime.runtimeId, runtime);
+ runtimeCount++;
+ }
} else {
if (attachment.replaced) continue;
webCount++;
@@ -143,6 +215,7 @@ export class UserConnectionDO extends DurableObject {
cliSockets: cliCount,
webSockets: webCount,
sessions: sessionCount,
+ runtimes: runtimeCount,
subscriptions: this.webSubscriptions.size,
});
@@ -197,18 +270,24 @@ export class UserConnectionDO extends DurableObject {
server.serializeAttachment(attachment);
const sessions = this.aggregateSessions();
+ const runtimes = this.getRuntimePresence();
console.log('Web socket connected', {
connectionId,
totalWebSockets: this.ctx.getWebSockets('web').length,
activeSessions: sessions.length,
+ activeRuntimes: runtimes.length,
});
-
this.sendToWeb(server, {
type: 'system',
event: 'sessions.list',
data: { sessions },
});
+ this.sendToWeb(server, {
+ type: 'system',
+ event: 'runtimes.list',
+ data: { runtimes: this.getRuntimePresence() },
+ });
}
return new Response(null, { status: 101, webSocket: client });
@@ -290,7 +369,8 @@ export class UserConnectionDO extends DurableObject {
}
}
// handleCliDisconnect will clean up connectionSessions/sessionOwners/lastHeartbeatAt
- // via the webSocketClose callback
+ // and emit runtime.disconnected for the owned runtime (if any) via the
+ // webSocketClose callback.
}
this.scheduleNextAlarm(now);
@@ -321,7 +401,14 @@ export class UserConnectionDO extends DurableObject {
switch (msg.type) {
case 'heartbeat':
- this.handleHeartbeat(ws, attachment, msg.sessions, msg.protocolVersion);
+ this.handleHeartbeat(
+ ws,
+ attachment,
+ msg.sessions,
+ msg.protocolVersion,
+ msg.runtime,
+ msg.sequence
+ );
break;
case 'event':
this.handleCliEvent(msg.sessionId, msg.parentSessionId, msg.event, msg.data);
@@ -336,7 +423,9 @@ export class UserConnectionDO extends DurableObject {
ws: WebSocket,
attachment: WSAttachment & { role: 'cli' },
sessions: HeartbeatSession[],
- protocolVersion: string | undefined
+ protocolVersion: string | undefined,
+ rawRuntime: unknown,
+ sequence: number | undefined
): void {
const { connectionId } = attachment;
const now = Date.now();
@@ -344,6 +433,52 @@ export class UserConnectionDO extends DurableObject {
this.connectionProtocolVersion.set(connectionId, protocolVersion);
this.scheduleNextAlarm(now);
+ // Resolve the runtime presence (if any) BEFORE applying session updates
+ // so a strict-failure or fence mismatch can fail closed without touching
+ // session state. The parsed value, when present, is the new authoritative
+ // metadata for this CLI socket.
+ let nextRuntime: RuntimeMetadata | undefined;
+ if (rawRuntime !== undefined) {
+ let parsedRuntime;
+ try {
+ parsedRuntime = parseCliRuntimePresence(rawRuntime);
+ } catch (error) {
+ console.warn('Runtime presence rejected by strict parse', {
+ connectionId,
+ issues: error instanceof Error ? error.message : 'unknown',
+ });
+ return;
+ }
+ if (parsedRuntime.connectionId !== connectionId) {
+ console.warn('Runtime presence rejected: connectionId mismatch', {
+ connectionId,
+ });
+ return;
+ }
+ // Mutation rejection: a live socket may not change its runtimeId.
+ // If the attachment already records a runtime for this socket and the
+ // new heartbeat carries a different runtimeId, fail closed.
+ if (attachment.runtime && attachment.runtime.runtimeId !== parsedRuntime.runtimeId) {
+ console.warn('Runtime presence rejected: runtimeId mutation on live socket', {
+ connectionId,
+ previousRuntimeId: attachment.runtime.runtimeId,
+ nextRuntimeId: parsedRuntime.runtimeId,
+ });
+ return;
+ }
+ // Cross-socket collision: a different socket already owns this runtimeId.
+ const existing = this.runtimes.get(parsedRuntime.runtimeId);
+ if (existing && existing.connectionId !== connectionId) {
+ console.warn('Runtime presence rejected: runtimeId already owned by another socket', {
+ connectionId,
+ runtimeId: parsedRuntime.runtimeId,
+ ownerConnectionId: existing.connectionId,
+ });
+ return;
+ }
+ nextRuntime = { ...parsedRuntime, lastHeartbeatAt: now };
+ }
+
// Remove sessions this connection previously owned but no longer reports
const previousSessions = this.connectionSessions.get(connectionId) ?? [];
const currentIds = new Set(sessions.map(s => s.id));
@@ -378,6 +513,41 @@ export class UserConnectionDO extends DurableObject {
}
}
+ // Update runtime registry and emit exactly one event. The event fires
+ // regardless of whether sessions are present.
+ let runtimeEvent: WebInboundMessage | undefined;
+ if (nextRuntime) {
+ const previous = this.runtimes.get(nextRuntime.runtimeId);
+ if (!previous) {
+ if (this.runtimes.size >= UserConnectionDO.MAX_RUNTIMES) {
+ console.warn('Runtime presence rejected: registry at capacity', {
+ connectionId,
+ max: UserConnectionDO.MAX_RUNTIMES,
+ });
+ // Drop the proposed presence — do not register and do not emit. The
+ // session updates above still apply, so the CLI keeps working.
+ nextRuntime = undefined;
+ } else {
+ this.runtimes.set(nextRuntime.runtimeId, nextRuntime);
+ runtimeEvent = {
+ type: 'system',
+ event: 'runtime.connected',
+ data: { runtime: this.publicRuntime(nextRuntime) },
+ };
+ }
+ } else if (runtimeMetadataChanged(previous, nextRuntime)) {
+ this.runtimes.set(nextRuntime.runtimeId, nextRuntime);
+ runtimeEvent = {
+ type: 'system',
+ event: 'runtime.updated',
+ data: { runtime: this.publicRuntime(nextRuntime) },
+ };
+ } else {
+ // Same metadata, same runtimeId: refresh heartbeat timestamp only.
+ this.runtimes.set(nextRuntime.runtimeId, nextRuntime);
+ }
+ }
+
// Persist to attachment for hibernation recovery
const updatedAttachment: WSAttachment = {
role: 'cli',
@@ -385,6 +555,7 @@ export class UserConnectionDO extends DurableObject {
sessions,
protocolVersion,
kiloUserId: attachment.kiloUserId,
+ ...(nextRuntime ? { runtime: nextRuntime } : {}),
};
ws.serializeAttachment(updatedAttachment);
@@ -412,7 +583,23 @@ export class UserConnectionDO extends DurableObject {
}
}
- this.sendToCli(ws, { type: 'heartbeat_ack' });
+ if (runtimeEvent) {
+ this.broadcastToWeb(runtimeEvent);
+ }
+
+ this.sendToCli(
+ ws,
+ sequence !== undefined ? { type: 'heartbeat_ack', sequence } : { type: 'heartbeat_ack' }
+ );
+ }
+
+ /**
+ * Strip the internal `lastHeartbeatAt` envelope before exposing a runtime
+ * outside the DO. The contract intentionally has no path.
+ */
+ private publicRuntime(runtime: RuntimeMetadata): LocalRuntimePresence {
+ const { lastHeartbeatAt: _drop, ...publicShape } = runtime;
+ return publicShape;
}
/**
@@ -468,19 +655,59 @@ export class UserConnectionDO extends DurableObject {
if (!entry || entry.targetCliWs !== respondingWs) return;
this.pendingCommands.delete(id);
- if (entry.command === 'list_models' && result !== undefined) {
+ // Relay-originated RPC (get_catalog): validate, cap, and settle the
+ // pending Promise. Raw catalog content and CLI error strings never
+ // reach the response path or the logs.
+ if (entry.command === GET_CATALOG_COMMAND) {
+ if (entry.pending) {
+ this.settlePendingWithResult(entry.pending, result, error, {
+ parse: value => localRuntimeCatalogSchema.parse(value),
+ maxBytes: MAX_CATALOG_RESULT_BYTES,
+ tooLargeCode: 'RESULT_TOO_LARGE',
+ tooLargeMessage: 'Catalog response is too large',
+ classifyError: err => this.classifyGetCatalogError(err),
+ });
+ return;
+ }
+ // No pending destination — fall through to the legacy path only as a
+ // last resort (e.g. a get_catalog command snuck in via the WS path,
+ // which is already blocked by COMMAND_NOT_ALLOWED upstream).
+ }
+
+ if (entry.command === CREATE_AND_RUN_COMMAND) {
+ if (entry.pending) {
+ this.settlePendingWithResult(entry.pending, result, error, {
+ parse: value => createAndRunLocalSessionResultSchema.parse(value),
+ maxBytes: MAX_CREATE_AND_RUN_RESULT_BYTES,
+ tooLargeCode: 'RESULT_TOO_LARGE',
+ tooLargeMessage: 'Create-and-run response is too large',
+ classifyError: err => this.classifyCreateAndRunError(err),
+ });
+ return;
+ }
+ }
+
+ if (
+ (entry.command === 'list_models' || entry.command === GET_CATALOG_COMMAND) &&
+ result !== undefined
+ ) {
const serializedResult = JSON.stringify(result);
const resultBytes = new TextEncoder().encode(serializedResult).byteLength;
if (resultBytes > MAX_CATALOG_RESULT_BYTES) {
- this.sendToWeb(entry.ws, {
- type: 'response',
- id: entry.originalId,
- error: CATALOG_TOO_LARGE_ERROR,
- });
+ if (entry.ws) {
+ if (!entry.originalId) return;
+ this.sendToWeb(entry.ws, {
+ type: 'response',
+ id: entry.originalId,
+ error: CATALOG_TOO_LARGE_ERROR,
+ });
+ }
return;
}
}
+ if (!entry.ws) return;
+ if (!entry.originalId) return;
this.sendToWeb(entry.ws, {
type: 'response',
id: entry.originalId,
@@ -491,6 +718,122 @@ export class UserConnectionDO extends DurableObject {
});
}
+ /**
+ * Map a CLI error from a `get_catalog` response to a stable relay code.
+ * The CLI's original message is intentionally not propagated; the relay
+ * chooses the user-facing message and never logs the raw string.
+ */
+ private classifyGetCatalogError(error: unknown): LocalRuntimeCommandError {
+ if (typeof error === 'string' && error.toLowerCase().includes('unknown command')) {
+ return new LocalRuntimeCommandError(
+ 'CLI_UPGRADE_REQUIRED',
+ 'CLI is too old to expose a model catalog'
+ );
+ }
+ return new LocalRuntimeCommandError('RUNTIME_COMMAND_FAILED', 'Runtime command failed');
+ }
+
+ /**
+ * Map a `create_and_run` CLI error to a stable relay code. The CLI
+ * reports failures as a typed `{code, message}` envelope (see
+ * `RemoteSender` in the CLI). The mobile-branched codes map as follows:
+ *
+ * - `INVALID_REQUEST` → `INVALID_RUNTIME_RESPONSE` (the request shape
+ * the relay produced did not match the CLI's strict parser; the CLI
+ * never produced a session, so this is the same surface as a malformed
+ * runtime reply).
+ * - `CATALOG_CHANGED` → `CATALOG_CHANGED` (unchanged on purpose; mobile
+ * branches on this exact code to refresh the catalog).
+ * - `CREATE_FAILED` / `ANNOUNCE_FAILED` / `INTERNAL` →
+ * `RUNTIME_COMMAND_FAILED` (CLI already produced a safe, fixed-message
+ * string; the relay re-classifies to a stable, mobile-visible code
+ * without surfacing the raw message).
+ *
+ * Legacy CLIs that report `unknown command` (string form) collapse to
+ * `CLI_UPGRADE_REQUIRED`. Any other shape collapses to the safe catch
+ * all.
+ */
+ private classifyCreateAndRunError(error: unknown): LocalRuntimeCommandError {
+ if (typeof error === 'string' && error.toLowerCase().includes('unknown command')) {
+ return new LocalRuntimeCommandError(
+ 'CLI_UPGRADE_REQUIRED',
+ 'CLI is too old to create a session'
+ );
+ }
+ if (isRecord(error) && typeof error.code === 'string') {
+ switch (error.code) {
+ case 'CATALOG_CHANGED':
+ return new LocalRuntimeCommandError('CATALOG_CHANGED', 'Catalog request rejected');
+ case 'INVALID_REQUEST':
+ return new LocalRuntimeCommandError(
+ 'INVALID_RUNTIME_RESPONSE',
+ 'Runtime rejected the create-and-run request'
+ );
+ case 'CREATE_FAILED':
+ case 'ANNOUNCE_FAILED':
+ case 'INTERNAL':
+ return new LocalRuntimeCommandError('RUNTIME_COMMAND_FAILED', 'Runtime command failed');
+ default:
+ return new LocalRuntimeCommandError('RUNTIME_COMMAND_FAILED', 'Runtime command failed');
+ }
+ }
+ return new LocalRuntimeCommandError('RUNTIME_COMMAND_FAILED', 'Runtime command failed');
+ }
+
+ /**
+ * Shared settlement path for relay-originated RPCs. The promise
+ * destination is the only caller; viewer-origin responses follow the
+ * legacy path below. The CLI error envelope is intentionally never
+ * surfaced — the relay re-classifies to a stable code and chooses the
+ * safe user-facing message.
+ */
+ private settlePendingWithResult(
+ pending: {
+ resolve: (value: LocalRuntimeCatalog | CreateAndRunLocalSessionResult) => void;
+ reject: (reason: LocalRuntimeCommandError) => void;
+ },
+ result: unknown,
+ error: unknown,
+ options: {
+ parse: (value: unknown) => T;
+ maxBytes: number;
+ tooLargeCode: LocalRuntimeControlErrorCode;
+ tooLargeMessage: string;
+ classifyError: (error: unknown) => LocalRuntimeCommandError;
+ }
+ ): void {
+ if (error !== undefined) {
+ pending.reject(options.classifyError(error));
+ return;
+ }
+ if (result === undefined) {
+ pending.reject(
+ new LocalRuntimeCommandError('INVALID_RUNTIME_RESPONSE', 'Runtime returned no result')
+ );
+ return;
+ }
+ const serializedResult = safeStringifyForSize(result);
+ if (serializedResult === null) {
+ pending.reject(
+ new LocalRuntimeCommandError('INVALID_RUNTIME_RESPONSE', 'Result was not serializable')
+ );
+ return;
+ }
+ const resultBytes = new TextEncoder().encode(serializedResult).byteLength;
+ if (resultBytes > options.maxBytes) {
+ pending.reject(new LocalRuntimeCommandError(options.tooLargeCode, options.tooLargeMessage));
+ return;
+ }
+ try {
+ const parsed = options.parse(result);
+ pending.resolve(parsed as LocalRuntimeCatalog | CreateAndRunLocalSessionResult);
+ } catch {
+ pending.reject(
+ new LocalRuntimeCommandError('INVALID_RUNTIME_RESPONSE', 'Result failed strict validation')
+ );
+ }
+ }
+
// ---------------------------------------------------------------------------
// Web message handling
// ---------------------------------------------------------------------------
@@ -598,6 +941,19 @@ export class UserConnectionDO extends DurableObject {
const now = Date.now();
this.expirePendingCommands(now);
+ // `get_catalog` and `create_and_run` are reserved for the
+ // relay-originated RPCs and are not viewer-initiated commands. Refuse
+ // them explicitly so a generic viewer WebSocket cannot impersonate
+ // the relay, then return before allocating any pending work.
+ if (msg.command === GET_CATALOG_COMMAND || msg.command === CREATE_AND_RUN_COMMAND) {
+ this.sendToWeb(ws, {
+ type: 'response',
+ id: msg.id,
+ error: COMMAND_NOT_ALLOWED_VIEWER_ERROR,
+ });
+ return;
+ }
+
// Find target CLI
let targetCli: WebSocket | undefined;
@@ -719,9 +1075,21 @@ export class UserConnectionDO extends DurableObject {
this.connectionProtocolVersion.delete(connectionId);
this.lastHeartbeatAt.delete(connectionId);
+ // Remove the runtime that was owned by this exact connection. A
+ // connection's runtime is whichever entry points at this connectionId —
+ // runtimes are not shared across CLIs.
+ const evictedRuntimes: LocalRuntimePresence[] = [];
+ for (const [runtimeId, runtime] of this.runtimes) {
+ if (runtime.connectionId === connectionId) {
+ this.runtimes.delete(runtimeId);
+ evictedRuntimes.push(this.publicRuntime(runtime));
+ }
+ }
+
console.log('CLI socket disconnected', {
connectionId,
droppedSessions: ownedSessions.size,
+ droppedRuntimes: evictedRuntimes.length,
remainingCliSockets: this.ctx.getWebSockets('cli').length,
});
@@ -732,6 +1100,14 @@ export class UserConnectionDO extends DurableObject {
event: 'cli.disconnected',
data: { connectionId },
});
+
+ for (const runtime of evictedRuntimes) {
+ this.broadcastToWeb({
+ type: 'system',
+ event: 'runtime.disconnected',
+ data: { runtimeId: runtime.runtimeId, connectionId },
+ });
+ }
}
private handleWebDisconnect(ws: WebSocket): void {
@@ -783,6 +1159,156 @@ export class UserConnectionDO extends DurableObject {
return this.aggregateSessions();
}
+ /**
+ * Public, read-only list of every first-class runtime the DO currently
+ * tracks. Runtimes with empty session arrays and runtimes that lost a
+ * required capability (e.g. CLI upgrade not yet shipped) are both
+ * included so mobile can render a precise recovery surface.
+ */
+ getRuntimePresence(): LocalRuntimePresence[] {
+ this.ensureState();
+ return [...this.runtimes.values()].map(r => this.publicRuntime(r));
+ }
+
+ /**
+ * Relay-originated catalog fetch. The exact runtime (runtimeId +
+ * connectionId) is validated against the live socket and capability set
+ * BEFORE any pending work is allocated, so a misrouted call never
+ * reaches the CLI. The command is sent to the exact CLI socket, never a
+ * fallback. The Promise settles when the targeted CLI replies, the
+ * pending TTL elapses, the runtime disconnects, or the runtime is
+ * replaced — each with a stable, mobile-branched error code.
+ */
+ async getRuntimeCatalog(fence: LocalRuntimeFence): Promise {
+ return this.dispatchRuntimeCommand(fence, {
+ command: GET_CATALOG_COMMAND,
+ requiredCapability: 'catalog.v1',
+ commandData: { protocolVersion: 1 },
+ });
+ }
+
+ /**
+ * Relay-originated sessionless create-and-run. The same private
+ * dispatcher validates the exact runtimeId+connectionId fence and the
+ * `create-and-run.v1` capability, routes the strict request to the
+ * exact CLI socket, and rejects wrong-socket responses. The Promise
+ * settles on the CLI's typed success/partial result, an unknown
+ * command, the typed CLI error envelope, a pending TTL expiry, or a
+ * disconnect/replacement — each with a stable, mobile-branched code.
+ *
+ * The CLI is the authority on the session ID and prompt-start outcome;
+ * the DO NEVER mints, mutates, or fabricates the session ID. The
+ * strict result parser uses the cloud-owned `sessionIdSchema`, so a CLI
+ * that reports anything other than a `ses_…` ID is rejected as
+ * `INVALID_RUNTIME_RESPONSE`.
+ */
+ async createAndRunLocalSession(
+ fence: LocalRuntimeFence,
+ request: CreateAndRunLocalSessionRequest
+ ): Promise {
+ // The request is parsed at the boundary. The CLI re-validates against
+ // the same schema, but the DO also parses so a misrouted or
+ // duplicated request can fail closed before the CLI sees it.
+ const parsedRequest = createAndRunLocalSessionRequestSchema.safeParse(request);
+ if (!parsedRequest.success) {
+ throw new LocalRuntimeCommandError(
+ 'INVALID_RUNTIME_RESPONSE',
+ 'Create-and-run request failed strict validation'
+ );
+ }
+
+ return this.dispatchRuntimeCommand(fence, {
+ command: CREATE_AND_RUN_COMMAND,
+ requiredCapability: 'create-and-run.v1',
+ commandData: parsedRequest.data,
+ });
+ }
+
+ /**
+ * Private dispatcher shared by `getRuntimeCatalog` and
+ * `createAndRunLocalSession`. It validates the fence and the required
+ * capability against the live socket BEFORE any pending work is
+ * allocated, so a misrouted or capability-mismatched call never
+ * reaches the CLI. The command is sent to the exact CLI socket, never
+ * a fallback. The Promise settles when the targeted CLI replies, the
+ * pending TTL elapses, the runtime disconnects, or the runtime is
+ * replaced — each with a stable, mobile-branched error code.
+ */
+ private async dispatchRuntimeCommand(
+ fence: LocalRuntimeFence,
+ options: {
+ command: string;
+ requiredCapability: 'catalog.v1' | 'create-and-run.v1';
+ commandData: unknown;
+ }
+ ): Promise {
+ this.ensureState();
+ const now = Date.now();
+ this.expirePendingCommands(now);
+
+ // Exact runtimeId lookup. A missing runtimeId means the runtime was
+ // never seen or has been evicted — the safe mobile state is to refresh
+ // the list and pick another runtime, so surface NOT_FOUND.
+ const runtime = this.runtimes.get(fence.runtimeId);
+ if (!runtime) {
+ throw new LocalRuntimeCommandError(
+ 'RUNTIME_NOT_CONNECTED',
+ 'Runtime is not currently connected'
+ );
+ }
+
+ // Fence must point at the live socket that owns the runtime. A live
+ // socket is required so we have a target to send the command to.
+ if (runtime.connectionId !== fence.connectionId) {
+ throw new LocalRuntimeCommandError(
+ 'RUNTIME_FENCE_MISMATCH',
+ 'Runtime is owned by a different connection'
+ );
+ }
+ if (!runtime.capabilities.includes(options.requiredCapability)) {
+ throw new LocalRuntimeCommandError(
+ 'RUNTIME_FENCE_MISMATCH',
+ `Runtime does not advertise the ${options.requiredCapability} capability`
+ );
+ }
+
+ const targetCli = this.findCliByConnectionId(fence.connectionId);
+ if (!targetCli) {
+ throw new LocalRuntimeCommandError(
+ 'RUNTIME_FENCE_MISMATCH',
+ 'Runtime socket is not currently connected'
+ );
+ }
+
+ if (this.pendingCommands.size >= UserConnectionDO.MAX_PENDING_COMMANDS) {
+ throw new LocalRuntimeCommandError('PENDING_COMMAND_LIMIT', 'Too many pending commands');
+ }
+
+ const correlationId = crypto.randomUUID();
+ const promise = new Promise((resolve, reject) => {
+ this.pendingCommands.set(correlationId, {
+ pending: {
+ resolve: resolve as (value: LocalRuntimeCatalog | CreateAndRunLocalSessionResult) => void,
+ reject,
+ },
+ command: options.command,
+ targetConnectionId: fence.connectionId,
+ expiresAt: now + UserConnectionDO.PENDING_COMMAND_TTL_MS,
+ targetCliWs: targetCli,
+ });
+ });
+ this.scheduleNextAlarm(now);
+
+ this.sendToCli(targetCli, {
+ type: 'command',
+ id: correlationId,
+ command: options.command,
+ data: options.commandData,
+ });
+
+ return promise;
+ }
+
async notifySessionEvent(event: SessionEventPayload): Promise<{ delivered: number }> {
this.ensureState();
const parsed = SessionEventPayloadSchema.parse(event);
@@ -900,14 +1426,26 @@ export class UserConnectionDO extends DurableObject {
private failPendingCommandsForSocket(targetWs: WebSocket): void {
for (const [id, entry] of this.pendingCommands) {
- if (entry.targetCliWs === targetWs) {
- this.sendToWeb(entry.ws, {
- type: 'response',
- id: entry.originalId,
- error: entry.expectedOwnerConnectionId ? SESSION_OWNER_CHANGED_ERROR : 'CLI disconnected',
- });
- this.pendingCommands.delete(id);
+ if (entry.targetCliWs !== targetWs) continue;
+ this.pendingCommands.delete(id);
+ if (entry.pending) {
+ entry.pending.reject(
+ new LocalRuntimeCommandError(
+ 'RUNTIME_FENCE_MISMATCH',
+ entry.expectedOwnerConnectionId
+ ? 'Session owner changed before runtime command could be read'
+ : 'Runtime disconnected before runtime command could be read'
+ )
+ );
+ continue;
}
+ if (!entry.ws) continue;
+ if (!entry.originalId) continue;
+ this.sendToWeb(entry.ws, {
+ type: 'response',
+ id: entry.originalId,
+ error: entry.expectedOwnerConnectionId ? SESSION_OWNER_CHANGED_ERROR : 'CLI disconnected',
+ });
}
}
@@ -920,6 +1458,17 @@ export class UserConnectionDO extends DurableObject {
continue;
}
this.pendingCommands.delete(id);
+ if (entry.pending) {
+ entry.pending.reject(
+ new LocalRuntimeCommandError(
+ 'RUNTIME_FENCE_MISMATCH',
+ 'Session owner changed before command completed'
+ )
+ );
+ continue;
+ }
+ if (!entry.ws) continue;
+ if (!entry.originalId) continue;
this.sendToWeb(entry.ws, {
type: 'response',
id: entry.originalId,
@@ -932,6 +1481,14 @@ export class UserConnectionDO extends DurableObject {
for (const [id, entry] of this.pendingCommands) {
if (entry.expiresAt > now) continue;
this.pendingCommands.delete(id);
+ if (entry.pending) {
+ entry.pending.reject(
+ new LocalRuntimeCommandError('COMMAND_EXPIRED', 'Command expired before response')
+ );
+ continue;
+ }
+ if (!entry.ws) continue;
+ if (!entry.originalId) continue;
this.sendToWeb(entry.ws, {
type: 'response',
id: entry.originalId,
@@ -985,6 +1542,36 @@ export class UserConnectionDO extends DurableObject {
}
}
+function runtimeMetadataChanged(previous: RuntimeMetadata, next: RuntimeMetadata): boolean {
+ return (
+ previous.cliVersion !== next.cliVersion ||
+ previous.displayName !== next.displayName ||
+ previous.projectName !== next.projectName ||
+ !capabilitiesEqual(previous.capabilities, next.capabilities) ||
+ previous.protocolVersion !== next.protocolVersion
+ );
+}
+
+function capabilitiesEqual(a: readonly string[], b: readonly string[]): boolean {
+ if (a.length !== b.length) return false;
+ for (let i = 0; i < a.length; i++) {
+ if (a[i] !== b[i]) return false;
+ }
+ return true;
+}
+
+function safeStringifyForSize(value: unknown): string | null {
+ try {
+ return JSON.stringify(value);
+ } catch {
+ return null;
+ }
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
export function getUserConnectionDO(env: Env, params: { kiloUserId: string }) {
const id = env.USER_CONNECTION_DO.idFromName(params.kiloUserId);
return env.USER_CONNECTION_DO.get(id);
diff --git a/services/session-ingest/src/middleware/runtime-control-auth.test.ts b/services/session-ingest/src/middleware/runtime-control-auth.test.ts
new file mode 100644
index 0000000000..a7240cbab8
--- /dev/null
+++ b/services/session-ingest/src/middleware/runtime-control-auth.test.ts
@@ -0,0 +1,230 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { Hono } from 'hono';
+import { SignJWT } from 'jose';
+
+import { SESSION_INGEST_RUNTIME_CONTROL_AUDIENCE } from '@kilocode/session-ingest-contracts';
+
+import { runtimeControlAuthMiddleware } from './runtime-control-auth';
+import { runtimeControlApi } from '../routes/runtime-control';
+
+vi.mock('../dos/UserConnectionDO', () => ({
+ getUserConnectionDO: vi.fn(),
+}));
+
+import { getUserConnectionDO as mockGetUserConnectionDO } from '../dos/UserConnectionDO';
+
+const SECRET = 'test-secret-at-least-32-characters-long';
+const NEXTAUTH_SECRET_PROD = SECRET;
+
+function encode(secret: string) {
+ return new TextEncoder().encode(secret);
+}
+
+async function signWithAudience(audience?: string, expiresIn = '5m') {
+ let builder = new SignJWT({ version: 3, kiloUserId: 'usr_internal' })
+ .setProtectedHeader({ alg: 'HS256' })
+ .setIssuedAt();
+ if (audience) builder = builder.setAudience(audience);
+ builder = builder.setExpirationTime(expiresIn);
+ return builder.sign(encode(SECRET));
+}
+
+function makeApp() {
+ const app = new Hono<{ Bindings: { NEXTAUTH_SECRET_PROD: { get: () => Promise } } }>();
+ app.use('/internal/runtime-control/*', runtimeControlAuthMiddleware);
+ app.route('/internal/runtime-control', runtimeControlApi);
+ return app;
+}
+
+function makeEnv() {
+ return {
+ NEXTAUTH_SECRET_PROD: { get: vi.fn(async () => NEXTAUTH_SECRET_PROD) },
+ };
+}
+
+describe('runtime-control auth', () => {
+ beforeEach(() => {
+ vi.resetAllMocks();
+ const doInstance = { getRuntimePresence: vi.fn(async () => []) };
+ vi.mocked(mockGetUserConnectionDO).mockReturnValue(doInstance as never);
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it('rejects a request with no Authorization header', async () => {
+ const res = await makeApp().request(
+ 'http://local/internal/runtime-control/runtimes',
+ { method: 'GET' },
+ makeEnv()
+ );
+ expect(res.status).toBe(401);
+ });
+
+ it('rejects a request with a malformed Authorization header', async () => {
+ const res = await makeApp().request(
+ 'http://local/internal/runtime-control/runtimes',
+ { method: 'GET', headers: { Authorization: 'NotBearer abc' } },
+ makeEnv()
+ );
+ expect(res.status).toBe(401);
+ });
+
+ it('rejects an expired token with the correct audience', async () => {
+ const token = await signWithAudience(SESSION_INGEST_RUNTIME_CONTROL_AUDIENCE, '0s');
+ const res = await makeApp().request(
+ 'http://local/internal/runtime-control/runtimes',
+ { method: 'GET', headers: { Authorization: `Bearer ${token}` } },
+ makeEnv()
+ );
+ expect(res.status).toBe(401);
+ });
+
+ it('rejects a token without the runtime-control audience', async () => {
+ const token = await signWithAudience('some-other-audience');
+ const res = await makeApp().request(
+ 'http://local/internal/runtime-control/runtimes',
+ { method: 'GET', headers: { Authorization: `Bearer ${token}` } },
+ makeEnv()
+ );
+ expect(res.status).toBe(401);
+ });
+
+ it('rejects a token that has no audience at all', async () => {
+ const token = await signWithAudience(undefined);
+ const res = await makeApp().request(
+ 'http://local/internal/runtime-control/runtimes',
+ { method: 'GET', headers: { Authorization: `Bearer ${token}` } },
+ makeEnv()
+ );
+ expect(res.status).toBe(401);
+ });
+
+ it('accepts a token with the runtime-control audience and uses the signed userId', async () => {
+ const token = await signWithAudience(SESSION_INGEST_RUNTIME_CONTROL_AUDIENCE);
+ const res = await makeApp().request(
+ 'http://local/internal/runtime-control/runtimes',
+ { method: 'GET', headers: { Authorization: `Bearer ${token}` } },
+ makeEnv()
+ );
+ expect(res.status).toBe(200);
+ expect(mockGetUserConnectionDO).toHaveBeenCalledWith(expect.anything(), {
+ kiloUserId: 'usr_internal',
+ });
+ });
+
+ it('does not trust a userId supplied as a query parameter', async () => {
+ const token = await signWithAudience(SESSION_INGEST_RUNTIME_CONTROL_AUDIENCE);
+ const res = await makeApp().request(
+ 'http://local/internal/runtime-control/runtimes?kiloUserId=usr_spoofed',
+ { method: 'GET', headers: { Authorization: `Bearer ${token}` } },
+ makeEnv()
+ );
+ expect(res.status).toBe(200);
+ expect(mockGetUserConnectionDO).toHaveBeenCalledWith(expect.anything(), {
+ kiloUserId: 'usr_internal',
+ });
+ });
+});
+
+describe('GET /internal/runtime-control/runtimes', () => {
+ beforeEach(() => {
+ vi.resetAllMocks();
+ });
+
+ it('returns the bound user DO runtime list and an empty array on no runtimes', async () => {
+ const doInstance = { getRuntimePresence: vi.fn(async () => []) };
+ vi.mocked(mockGetUserConnectionDO).mockReturnValue(doInstance as never);
+
+ const token = await signWithAudience(SESSION_INGEST_RUNTIME_CONTROL_AUDIENCE);
+ const res = await makeApp().request(
+ 'http://local/internal/runtime-control/runtimes',
+ { method: 'GET', headers: { Authorization: `Bearer ${token}` } },
+ makeEnv()
+ );
+
+ expect(res.status).toBe(200);
+ expect(await res.json()).toEqual({ runtimes: [] });
+ });
+
+ it('includes zero-session runtimes and capability-missing runtimes', async () => {
+ const doInstance = {
+ getRuntimePresence: vi.fn(async () => [
+ {
+ runtimeId: '0c0a1b2c-3d4e-4f60-8a8b-9c0d1e2f3a4b',
+ connectionId: 'cli-1',
+ protocolVersion: 1,
+ cliVersion: '7.4.7',
+ displayName: 'Alice Mac',
+ projectName: 'customer-repo',
+ capabilities: ['catalog.v1', 'create-and-run.v1'],
+ },
+ {
+ runtimeId: '1c0a1b2c-3d4e-4f60-8a8b-9c0d1e2f3a4b',
+ connectionId: 'cli-2',
+ protocolVersion: 1,
+ cliVersion: '7.4.7',
+ displayName: 'Bob Mac',
+ projectName: 'empty-repo',
+ capabilities: ['catalog.v1'],
+ },
+ ]),
+ };
+ vi.mocked(mockGetUserConnectionDO).mockReturnValue(doInstance as never);
+
+ const token = await signWithAudience(SESSION_INGEST_RUNTIME_CONTROL_AUDIENCE);
+ const res = await makeApp().request(
+ 'http://local/internal/runtime-control/runtimes',
+ { method: 'GET', headers: { Authorization: `Bearer ${token}` } },
+ makeEnv()
+ );
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as {
+ runtimes: Array<{ capabilities: string[]; projectName: string }>;
+ };
+ expect(body.runtimes).toHaveLength(2);
+ // Capability-missing entry is still present so the mobile client can
+ // surface a precise recovery state.
+ expect(body.runtimes[1].capabilities).toEqual(['catalog.v1']);
+ });
+
+ it('rejects POST and other methods on the read-only endpoint', async () => {
+ const token = await signWithAudience(SESSION_INGEST_RUNTIME_CONTROL_AUDIENCE);
+ const res = await makeApp().request(
+ 'http://local/internal/runtime-control/runtimes',
+ { method: 'POST', headers: { Authorization: `Bearer ${token}` } },
+ makeEnv()
+ );
+ expect(res.status).toBe(404);
+ });
+
+ it('does not include the request body or authorization header in error logs', async () => {
+ const token = await signWithAudience(SESSION_INGEST_RUNTIME_CONTROL_AUDIENCE);
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
+ const error = vi.spyOn(console, 'error').mockImplementation(() => undefined);
+
+ const doInstance = {
+ getRuntimePresence: vi.fn(async () => {
+ throw new Error('boom with secret-secret-secret');
+ }),
+ };
+ vi.mocked(mockGetUserConnectionDO).mockReturnValue(doInstance as never);
+
+ const res = await makeApp().request(
+ 'http://local/internal/runtime-control/runtimes',
+ { method: 'GET', headers: { Authorization: `Bearer ${token}` } },
+ makeEnv()
+ );
+
+ expect(res.status).toBe(500);
+ const dumped = JSON.stringify({
+ warns: warn.mock.calls,
+ errors: error.mock.calls,
+ res: await res.clone().text(),
+ });
+ expect(dumped).not.toContain('secret-secret-secret');
+ expect(dumped).not.toContain(token);
+ });
+});
diff --git a/services/session-ingest/src/middleware/runtime-control-auth.ts b/services/session-ingest/src/middleware/runtime-control-auth.ts
new file mode 100644
index 0000000000..388b4ee9c7
--- /dev/null
+++ b/services/session-ingest/src/middleware/runtime-control-auth.ts
@@ -0,0 +1,47 @@
+import { createMiddleware } from 'hono/factory';
+import { extractBearerToken, verifyKiloToken } from '@kilocode/worker-utils';
+import { SESSION_INGEST_RUNTIME_CONTROL_AUDIENCE } from '@kilocode/session-ingest-contracts';
+
+import type { Env } from '../env';
+
+/**
+ * Audience-bound auth middleware for the internal runtime-control surface.
+ *
+ * The web app's Local Runtime Control module signs a five-minute,
+ * audience-bound JWT and sends it on every request. The middleware:
+ *
+ * - Accepts only the exact `Authorization: Bearer ` format and
+ * nothing else (no query string, no websocket upgrade fallback).
+ * - Reads `NEXTAUTH_SECRET_PROD` from the bindings and verifies the token
+ * against it with `verifyKiloToken(token, secret, { audience })`.
+ * - Derives `user_id` solely from the signed payload — never from a query
+ * parameter, path parameter, or request body.
+ * - Returns a safe 401 on every failure path without logging the token,
+ * the request body, or the raw verifier error.
+ */
+export const runtimeControlAuthMiddleware = createMiddleware<{
+ Bindings: Env;
+ Variables: {
+ user_id: string;
+ };
+}>(async (c, next) => {
+ const token = extractBearerToken(c.req.header('Authorization'));
+ if (!token) {
+ return c.json({ success: false, error: 'Unauthorized' }, 401);
+ }
+
+ const secret = await c.env.NEXTAUTH_SECRET_PROD.get();
+
+ let kiloUserId: string;
+ try {
+ const payload = await verifyKiloToken(token, secret, {
+ audience: SESSION_INGEST_RUNTIME_CONTROL_AUDIENCE,
+ });
+ kiloUserId = payload.kiloUserId;
+ } catch {
+ return c.json({ success: false, error: 'Unauthorized' }, 401);
+ }
+
+ c.set('user_id', kiloUserId);
+ return next();
+});
diff --git a/services/session-ingest/src/routes/runtime-control.test.ts b/services/session-ingest/src/routes/runtime-control.test.ts
new file mode 100644
index 0000000000..f6da3ee46f
--- /dev/null
+++ b/services/session-ingest/src/routes/runtime-control.test.ts
@@ -0,0 +1,839 @@
+import { Hono } from 'hono';
+import { describe, expect, it, vi, beforeEach } from 'vitest';
+
+import { runtimeControlApi } from './runtime-control';
+
+vi.mock('../dos/UserConnectionDO', () => ({
+ getUserConnectionDO: vi.fn(),
+}));
+
+import { getUserConnectionDO } from '../dos/UserConnectionDO';
+
+type ApiContext = {
+ Bindings: Record;
+ Variables: { user_id: string };
+};
+
+function makeApp() {
+ const app = new Hono();
+ app.use('*', async (c, next) => {
+ c.set('user_id', 'usr_test');
+ await next();
+ });
+ app.route('/internal/runtime-control', runtimeControlApi);
+ return app;
+}
+
+function validFence() {
+ return {
+ runtimeId: '8db3de9a-350f-4fad-a539-8e0da3bbcf5e',
+ connectionId: 'cli-1',
+ };
+}
+
+function validRequest() {
+ return { protocolVersion: 1 };
+}
+
+function validCatalogBody() {
+ return {
+ catalog: {
+ protocolVersion: 1,
+ models: {},
+ agents: [{ slug: 'build', name: 'Build' }],
+ defaultAgent: 'build',
+ },
+ };
+}
+
+function makeDoStub(overrides: Partial<{ getRuntimeCatalog: ReturnType }> = {}) {
+ return {
+ getRuntimeCatalog: overrides.getRuntimeCatalog ?? vi.fn(async () => undefined),
+ };
+}
+
+describe('POST /internal/runtime-control/catalog', () => {
+ beforeEach(() => {
+ vi.resetAllMocks();
+ });
+
+ it('accepts the exact body, calls the user DO, and returns the strict envelope', async () => {
+ const catalog = validCatalogBody().catalog;
+ const getRuntimeCatalog = vi.fn(async () => catalog);
+ vi.mocked(getUserConnectionDO).mockReturnValue(makeDoStub({ getRuntimeCatalog }) as never);
+
+ const app = makeApp();
+ const res = await app.fetch(
+ new Request('http://local/internal/runtime-control/catalog', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ fence: validFence(), request: validRequest() }),
+ }),
+ {}
+ );
+
+ expect(res.status).toBe(200);
+ expect(getUserConnectionDO).toHaveBeenCalledWith(expect.anything(), {
+ kiloUserId: 'usr_test',
+ });
+ expect(getRuntimeCatalog).toHaveBeenCalledWith(validFence());
+ expect(await res.json()).toEqual(validCatalogBody());
+ });
+
+ it('passes the fence through to the DO unchanged', async () => {
+ const getRuntimeCatalog = vi.fn(async () => validCatalogBody().catalog);
+ vi.mocked(getUserConnectionDO).mockReturnValue(makeDoStub({ getRuntimeCatalog }) as never);
+
+ const fence = {
+ runtimeId: 'aaaaaaaa-1111-4111-8111-aaaaaaaaaaaa',
+ connectionId: 'cli-77',
+ };
+ const app = makeApp();
+ const res = await app.fetch(
+ new Request('http://local/internal/runtime-control/catalog', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ fence, request: validRequest() }),
+ }),
+ {}
+ );
+
+ expect(res.status).toBe(200);
+ expect(getRuntimeCatalog).toHaveBeenCalledWith(fence);
+ });
+
+ it.each([
+ ['GET', undefined],
+ ['PUT', JSON.stringify({ fence: validFence(), request: validRequest() })],
+ ['PATCH', JSON.stringify({ fence: validFence(), request: validRequest() })],
+ ['DELETE', undefined],
+ ])('returns 404 for %s on the catalog path', async (method, body) => {
+ const app = makeApp();
+ const init: RequestInit = { method };
+ if (body) {
+ init.headers = { 'content-type': 'application/json' };
+ init.body = body;
+ }
+ const res = await app.fetch(
+ new Request('http://local/internal/runtime-control/catalog', init),
+ {}
+ );
+ expect(res.status).toBe(404);
+ });
+
+ it('rejects Content-Length > 64 KiB with 413 before parsing', async () => {
+ const getRuntimeCatalog = vi.fn();
+ vi.mocked(getUserConnectionDO).mockReturnValue(makeDoStub({ getRuntimeCatalog }) as never);
+
+ const app = makeApp();
+ const oversized = 'x'.repeat(64 * 1024 + 1);
+ const res = await app.fetch(
+ new Request('http://local/internal/runtime-control/catalog', {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ 'content-length': String(oversized.length),
+ },
+ body: oversized,
+ }),
+ {}
+ );
+
+ expect(res.status).toBe(413);
+ expect(getRuntimeCatalog).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ ['malformed JSON', '{not-json'],
+ ['extra fields', { fence: validFence(), request: validRequest(), extra: true }],
+ ['missing fence', { request: validRequest() }],
+ ['missing request', { fence: validFence() }],
+ [
+ 'wrong-shape fence',
+ { fence: { runtimeId: 'not-a-uuid', connectionId: 'cli-1' }, request: validRequest() },
+ ],
+ ['wrong-shape request', { fence: validFence(), request: { protocolVersion: 2 } }],
+ ])('rejects %s with 400', async (_label, body) => {
+ const getRuntimeCatalog = vi.fn();
+ vi.mocked(getUserConnectionDO).mockReturnValue(makeDoStub({ getRuntimeCatalog }) as never);
+
+ const app = makeApp();
+ const res = await app.fetch(
+ new Request('http://local/internal/runtime-control/catalog', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify(body),
+ }),
+ {}
+ );
+
+ expect(res.status).toBe(400);
+ expect(getRuntimeCatalog).not.toHaveBeenCalled();
+ });
+
+ it('maps RUNTIME_NOT_CONNECTED to 404 with safe envelope', async () => {
+ const getRuntimeCatalog = vi.fn(async () => {
+ throw Object.assign(new Error('Runtime is not currently connected'), {
+ code: 'RUNTIME_NOT_CONNECTED',
+ name: 'LocalRuntimeCommandError',
+ });
+ });
+ vi.mocked(getUserConnectionDO).mockReturnValue(makeDoStub({ getRuntimeCatalog }) as never);
+
+ const app = makeApp();
+ const res = await app.fetch(
+ new Request('http://local/internal/runtime-control/catalog', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ fence: validFence(), request: validRequest() }),
+ }),
+ {}
+ );
+
+ expect(res.status).toBe(404);
+ expect(await res.json()).toEqual({
+ error: {
+ source: 'relay',
+ code: 'RUNTIME_NOT_CONNECTED',
+ message: 'Runtime is not currently connected',
+ },
+ });
+ });
+
+ it.each([
+ ['RUNTIME_FENCE_MISMATCH', 409, 'Runtime control request rejected'],
+ ['CATALOG_CHANGED', 409, 'Catalog request rejected'],
+ ['COMMAND_ALREADY_PENDING', 409, 'Runtime control request rejected'],
+ ])('maps %s to 409 with safe envelope', async (code, status, message) => {
+ const getRuntimeCatalog = vi.fn(async () => {
+ throw Object.assign(new Error(`relay: ${code}`), { code, name: 'LocalRuntimeCommandError' });
+ });
+ vi.mocked(getUserConnectionDO).mockReturnValue(makeDoStub({ getRuntimeCatalog }) as never);
+
+ const app = makeApp();
+ const res = await app.fetch(
+ new Request('http://local/internal/runtime-control/catalog', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ fence: validFence(), request: validRequest() }),
+ }),
+ {}
+ );
+
+ expect(res.status).toBe(status);
+ expect(await res.json()).toEqual({
+ error: {
+ source: 'relay',
+ code,
+ message,
+ },
+ });
+ });
+
+ it('maps CLI_UPGRADE_REQUIRED to 412 with safe envelope', async () => {
+ const getRuntimeCatalog = vi.fn(async () => {
+ throw Object.assign(new Error('CLI too old'), {
+ code: 'CLI_UPGRADE_REQUIRED',
+ name: 'LocalRuntimeCommandError',
+ });
+ });
+ vi.mocked(getUserConnectionDO).mockReturnValue(makeDoStub({ getRuntimeCatalog }) as never);
+
+ const app = makeApp();
+ const res = await app.fetch(
+ new Request('http://local/internal/runtime-control/catalog', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ fence: validFence(), request: validRequest() }),
+ }),
+ {}
+ );
+
+ expect(res.status).toBe(412);
+ expect(await res.json()).toEqual({
+ error: {
+ source: 'relay',
+ code: 'CLI_UPGRADE_REQUIRED',
+ message: 'CLI upgrade required',
+ },
+ });
+ });
+
+ it('maps COMMAND_EXPIRED to 504 with safe envelope', async () => {
+ const getRuntimeCatalog = vi.fn(async () => {
+ throw Object.assign(new Error('expired'), {
+ code: 'COMMAND_EXPIRED',
+ name: 'LocalRuntimeCommandError',
+ });
+ });
+ vi.mocked(getUserConnectionDO).mockReturnValue(makeDoStub({ getRuntimeCatalog }) as never);
+
+ const app = makeApp();
+ const res = await app.fetch(
+ new Request('http://local/internal/runtime-control/catalog', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ fence: validFence(), request: validRequest() }),
+ }),
+ {}
+ );
+
+ expect(res.status).toBe(504);
+ expect(await res.json()).toEqual({
+ error: {
+ source: 'relay',
+ code: 'COMMAND_EXPIRED',
+ message: 'Runtime control request expired',
+ },
+ });
+ });
+
+ it('maps PENDING_COMMAND_LIMIT to 429 with safe envelope', async () => {
+ const getRuntimeCatalog = vi.fn(async () => {
+ throw Object.assign(new Error('limit'), {
+ code: 'PENDING_COMMAND_LIMIT',
+ name: 'LocalRuntimeCommandError',
+ });
+ });
+ vi.mocked(getUserConnectionDO).mockReturnValue(makeDoStub({ getRuntimeCatalog }) as never);
+
+ const app = makeApp();
+ const res = await app.fetch(
+ new Request('http://local/internal/runtime-control/catalog', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ fence: validFence(), request: validRequest() }),
+ }),
+ {}
+ );
+
+ expect(res.status).toBe(429);
+ expect(await res.json()).toEqual({
+ error: {
+ source: 'relay',
+ code: 'PENDING_COMMAND_LIMIT',
+ message: 'Too many pending commands',
+ },
+ });
+ });
+
+ it('maps COMMAND_NOT_ALLOWED to 403 with safe envelope', async () => {
+ const getRuntimeCatalog = vi.fn(async () => {
+ throw Object.assign(new Error('nope'), {
+ code: 'COMMAND_NOT_ALLOWED',
+ name: 'LocalRuntimeCommandError',
+ });
+ });
+ vi.mocked(getUserConnectionDO).mockReturnValue(makeDoStub({ getRuntimeCatalog }) as never);
+
+ const app = makeApp();
+ const res = await app.fetch(
+ new Request('http://local/internal/runtime-control/catalog', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ fence: validFence(), request: validRequest() }),
+ }),
+ {}
+ );
+
+ expect(res.status).toBe(403);
+ expect(await res.json()).toEqual({
+ error: {
+ source: 'relay',
+ code: 'COMMAND_NOT_ALLOWED',
+ message: 'Command not allowed',
+ },
+ });
+ });
+
+ it.each(['RESULT_TOO_LARGE', 'INVALID_RUNTIME_RESPONSE', 'RUNTIME_COMMAND_FAILED'])(
+ 'maps %s to 500 with safe envelope',
+ async code => {
+ const getRuntimeCatalog = vi.fn(async () => {
+ throw Object.assign(new Error(`secret-do-message-for-${code}-leak-marker`), {
+ code,
+ name: 'LocalRuntimeCommandError',
+ });
+ });
+ vi.mocked(getUserConnectionDO).mockReturnValue(makeDoStub({ getRuntimeCatalog }) as never);
+
+ const app = makeApp();
+ const res = await app.fetch(
+ new Request('http://local/internal/runtime-control/catalog', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ fence: validFence(), request: validRequest() }),
+ }),
+ {}
+ );
+
+ expect(res.status).toBe(500);
+ const body = await res.json();
+ expect(body).toEqual({
+ error: {
+ source: 'relay',
+ code,
+ message: 'Internal error',
+ },
+ });
+ // No leak of the original error message
+ const dumped = JSON.stringify(body);
+ expect(dumped).not.toContain('secret-do-message-for-');
+ expect(dumped).not.toContain('leak-marker');
+ }
+ );
+
+ it('returns 500 with safe envelope on a thrown non-typed error', async () => {
+ const getRuntimeCatalog = vi.fn(async () => {
+ throw new Error('super-secret-do-internal-leak-marker');
+ });
+ vi.mocked(getUserConnectionDO).mockReturnValue(makeDoStub({ getRuntimeCatalog }) as never);
+
+ const app = makeApp();
+ const res = await app.fetch(
+ new Request('http://local/internal/runtime-control/catalog', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ fence: validFence(), request: validRequest() }),
+ }),
+ {}
+ );
+
+ expect(res.status).toBe(500);
+ const body = await res.json();
+ expect(body).toEqual({
+ error: { source: 'relay', code: 'INTERNAL', message: 'Internal error' },
+ });
+ const dumped = JSON.stringify(body);
+ expect(dumped).not.toContain('super-secret-do-internal-leak-marker');
+ });
+
+ it('does not log the token, the body, or the raw catalog content on failure', async () => {
+ const getRuntimeCatalog = vi.fn(async () => {
+ throw Object.assign(new Error('super-secret-do-leak-marker'), {
+ code: 'RUNTIME_COMMAND_FAILED',
+ name: 'LocalRuntimeCommandError',
+ });
+ });
+ vi.mocked(getUserConnectionDO).mockReturnValue(makeDoStub({ getRuntimeCatalog }) as never);
+
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
+ const error = vi.spyOn(console, 'error').mockImplementation(() => undefined);
+
+ const app = makeApp();
+ await app.fetch(
+ new Request('http://local/internal/runtime-control/catalog', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ fence: validFence(), request: validRequest() }),
+ }),
+ {}
+ );
+
+ const dumped = JSON.stringify({ warns: warn.mock.calls, errors: error.mock.calls });
+ expect(dumped).not.toContain('super-secret-do-leak-marker');
+ expect(dumped).not.toContain(JSON.stringify(validFence()));
+ });
+});
+
+describe('POST /internal/runtime-control/create-and-run', () => {
+ function validCreateAndRunRequest() {
+ return {
+ protocolVersion: 1,
+ requestId: '0c0a1b2c-3d4e-4f60-8a8b-9c0d1e2f3a4b',
+ prompt: 'hello',
+ model: { providerID: 'kilo', modelID: 'kilo/auto' },
+ agent: 'build',
+ };
+ }
+
+ function validCreateAndRunResult() {
+ return {
+ protocolVersion: 1,
+ sessionId: 'ses_a1b2c3d4e5f67890123456789a',
+ promptStarted: true,
+ };
+ }
+
+ function makeDoStub(
+ overrides: Partial<{ createAndRunLocalSession: ReturnType }> = {}
+ ) {
+ return {
+ createAndRunLocalSession: overrides.createAndRunLocalSession ?? vi.fn(async () => undefined),
+ };
+ }
+
+ beforeEach(() => {
+ vi.resetAllMocks();
+ });
+
+ it('accepts the exact body, calls the user DO once, and returns the strict {result} envelope', async () => {
+ const result = validCreateAndRunResult();
+ const createAndRunLocalSession = vi.fn(async () => result);
+ vi.mocked(getUserConnectionDO).mockReturnValue(
+ makeDoStub({ createAndRunLocalSession }) as never
+ );
+
+ const fence = validFence();
+ const request = validCreateAndRunRequest();
+ const app = makeApp();
+ const res = await app.fetch(
+ new Request('http://local/internal/runtime-control/create-and-run', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ fence, request }),
+ }),
+ {}
+ );
+
+ expect(res.status).toBe(200);
+ expect(createAndRunLocalSession).toHaveBeenCalledTimes(1);
+ expect(createAndRunLocalSession).toHaveBeenCalledWith(fence, request);
+ expect(await res.json()).toEqual({ result });
+ });
+
+ it('forwards the fence and request unchanged to the DO', async () => {
+ const createAndRunLocalSession = vi.fn(async () => validCreateAndRunResult());
+ vi.mocked(getUserConnectionDO).mockReturnValue(
+ makeDoStub({ createAndRunLocalSession }) as never
+ );
+
+ const fence = {
+ runtimeId: 'aaaaaaaa-1111-4111-8111-aaaaaaaaaaaa',
+ connectionId: 'cli-77',
+ };
+ const request = { ...validCreateAndRunRequest(), variant: 'thinking' };
+ const app = makeApp();
+ const res = await app.fetch(
+ new Request('http://local/internal/runtime-control/create-and-run', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ fence, request }),
+ }),
+ {}
+ );
+
+ expect(res.status).toBe(200);
+ expect(createAndRunLocalSession).toHaveBeenCalledWith(fence, request);
+ });
+
+ it.each([
+ ['GET', undefined],
+ ['PUT', JSON.stringify({ fence: validFence(), request: validCreateAndRunRequest() })],
+ ['PATCH', JSON.stringify({ fence: validFence(), request: validCreateAndRunRequest() })],
+ ['DELETE', undefined],
+ ])('returns 404 for %s on the create-and-run path', async (method, body) => {
+ const app = makeApp();
+ const init: RequestInit = { method };
+ if (body) {
+ init.headers = { 'content-type': 'application/json' };
+ init.body = body;
+ }
+ const res = await app.fetch(
+ new Request('http://local/internal/runtime-control/create-and-run', init),
+ {}
+ );
+ expect(res.status).toBe(404);
+ });
+
+ it('rejects Content-Length > 64 KiB with 413 before parsing', async () => {
+ const createAndRunLocalSession = vi.fn();
+ vi.mocked(getUserConnectionDO).mockReturnValue(
+ makeDoStub({ createAndRunLocalSession }) as never
+ );
+
+ const app = makeApp();
+ const oversized = 'x'.repeat(64 * 1024 + 1);
+ const res = await app.fetch(
+ new Request('http://local/internal/runtime-control/create-and-run', {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ 'content-length': String(oversized.length),
+ },
+ body: oversized,
+ }),
+ {}
+ );
+
+ expect(res.status).toBe(413);
+ expect(createAndRunLocalSession).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ ['malformed JSON', '{not-json'],
+ ['extra fields', { fence: validFence(), request: validCreateAndRunRequest(), extra: true }],
+ ['missing fence', { request: validCreateAndRunRequest() }],
+ ['missing request', { fence: validFence() }],
+ [
+ 'wrong-shape fence',
+ {
+ fence: { runtimeId: 'not-a-uuid', connectionId: 'cli-1' },
+ request: validCreateAndRunRequest(),
+ },
+ ],
+ [
+ 'wrong-shape request',
+ { fence: validFence(), request: { ...validCreateAndRunRequest(), prompt: '' } },
+ ],
+ ])('rejects %s with 400', async (_label, body) => {
+ const createAndRunLocalSession = vi.fn();
+ vi.mocked(getUserConnectionDO).mockReturnValue(
+ makeDoStub({ createAndRunLocalSession }) as never
+ );
+
+ const app = makeApp();
+ const res = await app.fetch(
+ new Request('http://local/internal/runtime-control/create-and-run', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify(body),
+ }),
+ {}
+ );
+
+ expect(res.status).toBe(400);
+ expect(createAndRunLocalSession).not.toHaveBeenCalled();
+ });
+
+ it('maps RUNTIME_NOT_CONNECTED to 404 with safe envelope', async () => {
+ const createAndRunLocalSession = vi.fn(async () => {
+ throw Object.assign(new Error('Runtime is not currently connected'), {
+ code: 'RUNTIME_NOT_CONNECTED',
+ name: 'LocalRuntimeCommandError',
+ });
+ });
+ vi.mocked(getUserConnectionDO).mockReturnValue(
+ makeDoStub({ createAndRunLocalSession }) as never
+ );
+
+ const app = makeApp();
+ const res = await app.fetch(
+ new Request('http://local/internal/runtime-control/create-and-run', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ fence: validFence(), request: validCreateAndRunRequest() }),
+ }),
+ {}
+ );
+
+ expect(res.status).toBe(404);
+ expect(await res.json()).toEqual({
+ error: {
+ source: 'relay',
+ code: 'RUNTIME_NOT_CONNECTED',
+ message: 'Runtime is not currently connected',
+ },
+ });
+ });
+
+ it('maps RUNTIME_FENCE_MISMATCH to 409 with safe envelope', async () => {
+ const createAndRunLocalSession = vi.fn(async () => {
+ throw Object.assign(new Error('relay: RUNTIME_FENCE_MISMATCH'), {
+ code: 'RUNTIME_FENCE_MISMATCH',
+ name: 'LocalRuntimeCommandError',
+ });
+ });
+ vi.mocked(getUserConnectionDO).mockReturnValue(
+ makeDoStub({ createAndRunLocalSession }) as never
+ );
+
+ const app = makeApp();
+ const res = await app.fetch(
+ new Request('http://local/internal/runtime-control/create-and-run', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ fence: validFence(), request: validCreateAndRunRequest() }),
+ }),
+ {}
+ );
+
+ expect(res.status).toBe(409);
+ expect(await res.json()).toEqual({
+ error: {
+ source: 'relay',
+ code: 'RUNTIME_FENCE_MISMATCH',
+ message: 'Runtime control request rejected',
+ },
+ });
+ });
+
+ it('maps COMMAND_EXPIRED to 504 with safe envelope', async () => {
+ const createAndRunLocalSession = vi.fn(async () => {
+ throw Object.assign(new Error('expired'), {
+ code: 'COMMAND_EXPIRED',
+ name: 'LocalRuntimeCommandError',
+ });
+ });
+ vi.mocked(getUserConnectionDO).mockReturnValue(
+ makeDoStub({ createAndRunLocalSession }) as never
+ );
+
+ const app = makeApp();
+ const res = await app.fetch(
+ new Request('http://local/internal/runtime-control/create-and-run', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ fence: validFence(), request: validCreateAndRunRequest() }),
+ }),
+ {}
+ );
+
+ expect(res.status).toBe(504);
+ expect(await res.json()).toEqual({
+ error: {
+ source: 'relay',
+ code: 'COMMAND_EXPIRED',
+ message: 'Runtime control request expired',
+ },
+ });
+ });
+
+ it('maps CLI_UPGRADE_REQUIRED to 412 with safe envelope', async () => {
+ const createAndRunLocalSession = vi.fn(async () => {
+ throw Object.assign(new Error('CLI too old'), {
+ code: 'CLI_UPGRADE_REQUIRED',
+ name: 'LocalRuntimeCommandError',
+ });
+ });
+ vi.mocked(getUserConnectionDO).mockReturnValue(
+ makeDoStub({ createAndRunLocalSession }) as never
+ );
+
+ const app = makeApp();
+ const res = await app.fetch(
+ new Request('http://local/internal/runtime-control/create-and-run', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ fence: validFence(), request: validCreateAndRunRequest() }),
+ }),
+ {}
+ );
+
+ expect(res.status).toBe(412);
+ expect(await res.json()).toEqual({
+ error: {
+ source: 'relay',
+ code: 'CLI_UPGRADE_REQUIRED',
+ message: 'CLI upgrade required',
+ },
+ });
+ });
+
+ it('maps RESULT_TOO_LARGE to 500 with safe envelope and no message leak', async () => {
+ const createAndRunLocalSession = vi.fn(async () => {
+ throw Object.assign(new Error('super-secret-do-leak-marker-for-too-large'), {
+ code: 'RESULT_TOO_LARGE',
+ name: 'LocalRuntimeCommandError',
+ });
+ });
+ vi.mocked(getUserConnectionDO).mockReturnValue(
+ makeDoStub({ createAndRunLocalSession }) as never
+ );
+
+ const app = makeApp();
+ const res = await app.fetch(
+ new Request('http://local/internal/runtime-control/create-and-run', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ fence: validFence(), request: validCreateAndRunRequest() }),
+ }),
+ {}
+ );
+
+ expect(res.status).toBe(500);
+ const body = await res.json();
+ expect(body).toEqual({
+ error: {
+ source: 'relay',
+ code: 'RESULT_TOO_LARGE',
+ message: 'Internal error',
+ },
+ });
+ expect(JSON.stringify(body)).not.toContain('super-secret-do-leak-marker');
+ });
+
+ it('returns 500 with safe envelope on a thrown non-typed error', async () => {
+ const createAndRunLocalSession = vi.fn(async () => {
+ throw new Error('super-secret-do-internal-leak-marker');
+ });
+ vi.mocked(getUserConnectionDO).mockReturnValue(
+ makeDoStub({ createAndRunLocalSession }) as never
+ );
+
+ const app = makeApp();
+ const res = await app.fetch(
+ new Request('http://local/internal/runtime-control/create-and-run', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ fence: validFence(), request: validCreateAndRunRequest() }),
+ }),
+ {}
+ );
+
+ expect(res.status).toBe(500);
+ const body = await res.json();
+ expect(body).toEqual({
+ error: { source: 'relay', code: 'INTERNAL', message: 'Internal error' },
+ });
+ expect(JSON.stringify(body)).not.toContain('super-secret-do-internal-leak-marker');
+ });
+
+ it('returns the promptStarted:false partial result when the DO resolves with it', async () => {
+ const partialResult = {
+ protocolVersion: 1,
+ sessionId: 'ses_b2c3d4e5f67890123456789cde',
+ promptStarted: false,
+ error: {
+ code: 'PROMPT_START_FAILED',
+ message: 'The session was created, but the first prompt did not start.',
+ },
+ };
+ const createAndRunLocalSession = vi.fn(async () => partialResult);
+ vi.mocked(getUserConnectionDO).mockReturnValue(
+ makeDoStub({ createAndRunLocalSession }) as never
+ );
+
+ const app = makeApp();
+ const res = await app.fetch(
+ new Request('http://local/internal/runtime-control/create-and-run', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ fence: validFence(), request: validCreateAndRunRequest() }),
+ }),
+ {}
+ );
+
+ expect(res.status).toBe(200);
+ expect(await res.json()).toEqual({ result: partialResult });
+ });
+
+ it('does not log the token, the body, or the raw DO error on failure', async () => {
+ const createAndRunLocalSession = vi.fn(async () => {
+ throw Object.assign(new Error('super-secret-do-leak-marker'), {
+ code: 'RUNTIME_COMMAND_FAILED',
+ name: 'LocalRuntimeCommandError',
+ });
+ });
+ vi.mocked(getUserConnectionDO).mockReturnValue(
+ makeDoStub({ createAndRunLocalSession }) as never
+ );
+
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
+ const error = vi.spyOn(console, 'error').mockImplementation(() => undefined);
+
+ const app = makeApp();
+ await app.fetch(
+ new Request('http://local/internal/runtime-control/create-and-run', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ fence: validFence(), request: validCreateAndRunRequest() }),
+ }),
+ {}
+ );
+
+ const dumped = JSON.stringify({ warns: warn.mock.calls, errors: error.mock.calls });
+ expect(dumped).not.toContain('super-secret-do-leak-marker');
+ expect(dumped).not.toContain(JSON.stringify(validFence()));
+ });
+});
diff --git a/services/session-ingest/src/routes/runtime-control.ts b/services/session-ingest/src/routes/runtime-control.ts
new file mode 100644
index 0000000000..025b16f267
--- /dev/null
+++ b/services/session-ingest/src/routes/runtime-control.ts
@@ -0,0 +1,258 @@
+import { Hono } from 'hono';
+import type { ContentfulStatusCode } from 'hono/utils/http-status';
+import { ZodError } from 'zod';
+
+import {
+ createAndRunLocalSessionRequestSchema,
+ getLocalRuntimeCatalogRequestSchema,
+ localRuntimeCatalogResponseSchema,
+ localRuntimeControlErrorCodeSchema,
+ localRuntimeCreateResponseSchema,
+ localRuntimeErrorResponseSchema,
+ localRuntimeFenceSchema,
+ localRuntimeListResponseSchema,
+ type CreateAndRunLocalSessionResult,
+ type LocalRuntimeCatalog,
+ type LocalRuntimeControlErrorCode,
+} from '@kilocode/session-ingest-contracts';
+
+import type { Env } from '../env';
+import { getUserConnectionDO } from '../dos/UserConnectionDO';
+
+const CATALOG_MAX_BODY_BYTES = 64 * 1024;
+const CREATE_AND_RUN_MAX_BODY_BYTES = 64 * 1024;
+
+type ApiContext = {
+ Bindings: Env;
+ Variables: {
+ user_id: string;
+ };
+};
+
+export const runtimeControlApi = new Hono();
+
+/**
+ * Safe, fixed-message envelopes for every supported upstream error code. The
+ * relay never re-emits the raw DO error message — mobile branches on `code`
+ * and the human-readable text is operator-facing only.
+ */
+const SAFE_ERROR_MESSAGES: Record = {
+ RUNTIME_NOT_CONNECTED: 'Runtime is not currently connected',
+ RUNTIME_FENCE_MISMATCH: 'Runtime control request rejected',
+ CLI_UPGRADE_REQUIRED: 'CLI upgrade required',
+ CATALOG_CHANGED: 'Catalog request rejected',
+ COMMAND_ALREADY_PENDING: 'Runtime control request rejected',
+ PENDING_COMMAND_LIMIT: 'Too many pending commands',
+ COMMAND_EXPIRED: 'Runtime control request expired',
+ RESULT_TOO_LARGE: 'Internal error',
+ INVALID_RUNTIME_RESPONSE: 'Internal error',
+ RUNTIME_COMMAND_FAILED: 'Internal error',
+ COMMAND_NOT_ALLOWED: 'Command not allowed',
+};
+
+/**
+ * Map a `LocalRuntimeControlErrorCode` to the HTTP status mobile should
+ * recover from. The status is a recovery hint, never a leak.
+ */
+const ERROR_STATUS: Record = {
+ RUNTIME_NOT_CONNECTED: 404,
+ RUNTIME_FENCE_MISMATCH: 409,
+ CLI_UPGRADE_REQUIRED: 412,
+ CATALOG_CHANGED: 409,
+ COMMAND_ALREADY_PENDING: 409,
+ PENDING_COMMAND_LIMIT: 429,
+ COMMAND_EXPIRED: 504,
+ RESULT_TOO_LARGE: 500,
+ INVALID_RUNTIME_RESPONSE: 500,
+ RUNTIME_COMMAND_FAILED: 500,
+ COMMAND_NOT_ALLOWED: 403,
+};
+
+function safeErrorEnvelope(code: LocalRuntimeControlErrorCode) {
+ return localRuntimeErrorResponseSchema.parse({
+ error: {
+ source: 'relay' as const,
+ code,
+ message: SAFE_ERROR_MESSAGES[code],
+ },
+ });
+}
+
+function internalErrorEnvelope() {
+ return {
+ error: {
+ source: 'relay' as const,
+ code: 'INTERNAL' as const,
+ message: 'Internal error',
+ },
+ };
+}
+
+function isLocalRuntimeCommandError(err: unknown): err is { code: LocalRuntimeControlErrorCode } {
+ if (typeof err !== 'object' || err === null) return false;
+ const candidate = err as { name?: unknown; code?: unknown };
+ if (candidate.name !== 'LocalRuntimeCommandError') return false;
+ // Validate against the contract's strict enum so a poisoned/missing code
+ // is treated as a non-typed error and collapses to the safe 500 envelope.
+ return localRuntimeControlErrorCodeSchema.safeParse(candidate.code).success;
+}
+
+/**
+ * Read-only runtime list for the bound user. The user identity comes solely
+ * from the auth middleware's signed payload — never from the request — and
+ * the response is shape-validated against the cross-service contract before
+ * leaving the worker. Any upstream or schema failure collapses to a generic
+ * 500; the raw DO/parser error is never propagated to the client or logged
+ * alongside the token.
+ */
+runtimeControlApi.get('/runtimes', async c => {
+ const kiloUserId = c.get('user_id');
+ try {
+ const stub = getUserConnectionDO(c.env, { kiloUserId });
+ const runtimes = await stub.getRuntimePresence();
+ const payload = localRuntimeListResponseSchema.parse({ runtimes });
+ return c.json(payload, 200);
+ } catch (err) {
+ if (err instanceof ZodError) {
+ // The DO is required to satisfy the contract; an unexpected shape
+ // indicates a wire-level bug and is not the client's problem to debug.
+ console.error('[runtime-control] DO returned an unexpected runtime shape');
+ } else {
+ console.error('[runtime-control] runtime list fetch failed');
+ }
+ return c.json({ success: false, error: 'Internal error' }, 500);
+ }
+});
+
+/**
+ * Relay the catalog request to the user's UserConnectionDO using the
+ * audience-bound fence. The DO performs all authoritative validation; this
+ * handler is a thin transport that:
+ * - rejects oversized bodies with 413 before any parsing;
+ * - strict-parses the body into `{ fence, request }` and rejects extras;
+ * - maps stable `LocalRuntimeCommandError` codes to safe HTTP statuses;
+ * - never logs the token, body, raw DO error, or catalog content.
+ */
+runtimeControlApi.post('/catalog', async c => {
+ const contentLengthHeader = c.req.header('content-length');
+ if (contentLengthHeader !== undefined) {
+ const declared = Number.parseInt(contentLengthHeader, 10);
+ if (Number.isFinite(declared) && declared > CATALOG_MAX_BODY_BYTES) {
+ return c.json({ success: false, error: 'payload_too_large' }, 413);
+ }
+ }
+
+ const rawBody = await c.req.text();
+ if (new TextEncoder().encode(rawBody).byteLength > CATALOG_MAX_BODY_BYTES) {
+ return c.json({ success: false, error: 'payload_too_large' }, 413);
+ }
+
+ let parsedJson: unknown;
+ try {
+ parsedJson = JSON.parse(rawBody);
+ } catch {
+ return c.json({ success: false, error: 'invalid_json' }, 400);
+ }
+
+ const fenceParsed = localRuntimeFenceSchema.safeParse(
+ (parsedJson as { fence?: unknown } | null)?.fence
+ );
+ const requestParsed = getLocalRuntimeCatalogRequestSchema.safeParse(
+ (parsedJson as { request?: unknown } | null)?.request
+ );
+ if (!fenceParsed.success || !requestParsed.success) {
+ return c.json({ success: false, error: 'invalid_body' }, 400);
+ }
+
+ // Reject extra top-level fields beyond the strict `{fence, request}` shape.
+ const keys = Object.keys(parsedJson as Record);
+ if (keys.length !== 2 || !keys.includes('fence') || !keys.includes('request')) {
+ return c.json({ success: false, error: 'invalid_body' }, 400);
+ }
+
+ const kiloUserId = c.get('user_id');
+ try {
+ const stub = getUserConnectionDO(c.env, { kiloUserId });
+ const catalog = await (stub.getRuntimeCatalog(
+ fenceParsed.data
+ ) as Promise);
+ const response = localRuntimeCatalogResponseSchema.parse({ catalog });
+ return c.json(response, 200);
+ } catch (err) {
+ if (isLocalRuntimeCommandError(err)) {
+ const code = err.code;
+ return c.json(safeErrorEnvelope(code), ERROR_STATUS[code] as ContentfulStatusCode);
+ }
+ console.error('[runtime-control] catalog fetch failed');
+ return c.json(internalErrorEnvelope(), 500);
+ }
+});
+
+/**
+ * Relay the create-and-run request to the user's UserConnectionDO. This
+ * handler is a thin transport that:
+ * - rejects oversized bodies with 413 before any parsing;
+ * - strict-parses the body into `{ fence, request }` and rejects extras;
+ * - maps stable `LocalRuntimeCommandError` codes to safe HTTP statuses;
+ * - never logs the token, body, raw DO error, or result content.
+ *
+ * The DO is the only caller of the CLI; the handler awaits the
+ * response-dependent RPC and returns the strict `{result}` envelope. The
+ * server-side readiness wait against `cli_sessions_v2` is owned by the
+ * `localRuntimeControl.createAndRun` tRPC mutation, not by this route.
+ */
+runtimeControlApi.post('/create-and-run', async c => {
+ const contentLengthHeader = c.req.header('content-length');
+ if (contentLengthHeader !== undefined) {
+ const declared = Number.parseInt(contentLengthHeader, 10);
+ if (Number.isFinite(declared) && declared > CREATE_AND_RUN_MAX_BODY_BYTES) {
+ return c.json({ success: false, error: 'payload_too_large' }, 413);
+ }
+ }
+
+ const rawBody = await c.req.text();
+ if (new TextEncoder().encode(rawBody).byteLength > CREATE_AND_RUN_MAX_BODY_BYTES) {
+ return c.json({ success: false, error: 'payload_too_large' }, 413);
+ }
+
+ let parsedJson: unknown;
+ try {
+ parsedJson = JSON.parse(rawBody);
+ } catch {
+ return c.json({ success: false, error: 'invalid_json' }, 400);
+ }
+
+ const fenceParsed = localRuntimeFenceSchema.safeParse(
+ (parsedJson as { fence?: unknown } | null)?.fence
+ );
+ const requestParsed = createAndRunLocalSessionRequestSchema.safeParse(
+ (parsedJson as { request?: unknown } | null)?.request
+ );
+ if (!fenceParsed.success || !requestParsed.success) {
+ return c.json({ success: false, error: 'invalid_body' }, 400);
+ }
+
+ // Reject extra top-level fields beyond the strict `{fence, request}` shape.
+ const keys = Object.keys(parsedJson as Record);
+ if (keys.length !== 2 || !keys.includes('fence') || !keys.includes('request')) {
+ return c.json({ success: false, error: 'invalid_body' }, 400);
+ }
+
+ const kiloUserId = c.get('user_id');
+ try {
+ const stub = getUserConnectionDO(c.env, { kiloUserId });
+ const result = await (stub.createAndRunLocalSession(
+ fenceParsed.data,
+ requestParsed.data
+ ) as Promise);
+ const response = localRuntimeCreateResponseSchema.parse({ result });
+ return c.json(response, 200);
+ } catch (err) {
+ if (isLocalRuntimeCommandError(err)) {
+ const code = err.code;
+ return c.json(safeErrorEnvelope(code), ERROR_STATUS[code] as ContentfulStatusCode);
+ }
+ console.error('[runtime-control] create-and-run failed');
+ return c.json(internalErrorEnvelope(), 500);
+ }
+});
diff --git a/services/session-ingest/src/types/user-connection-protocol.test.ts b/services/session-ingest/src/types/user-connection-protocol.test.ts
index b5145be65f..d402a967bf 100644
--- a/services/session-ingest/src/types/user-connection-protocol.test.ts
+++ b/services/session-ingest/src/types/user-connection-protocol.test.ts
@@ -5,9 +5,20 @@ import {
WebOutboundMessageSchema,
WebInboundMessageSchema,
SessionEventPayloadSchema,
+ parseCliRuntimePresence,
} from './user-connection-protocol';
+import { localRuntimeCapabilitySchema } from '@kilocode/session-ingest-contracts';
const validSessionId = 'ses_12345678901234567890123456';
+const validRuntime = {
+ runtimeId: '8db3de9a-350f-4fad-a539-8e0da3bbcf5e',
+ connectionId: 'cli-conn-1',
+ protocolVersion: 1,
+ cliVersion: '7.4.7',
+ displayName: 'Alice Mac',
+ projectName: 'customer-repo',
+ capabilities: ['catalog.v1', 'create-and-run.v1'],
+};
describe('CLIOutboundMessageSchema', () => {
it('parses valid heartbeat', () => {
@@ -435,3 +446,169 @@ describe('extra fields', () => {
expect(result).not.toHaveProperty('extra');
});
});
+
+describe('heartbeat runtime presence', () => {
+ it('parses a legacy heartbeat without runtime field as no-presence', () => {
+ const msg = {
+ type: 'heartbeat',
+ sessions: [{ id: validSessionId, status: 'busy', title: 'Fix bug' }],
+ };
+ const result = CLIOutboundMessageSchema.safeParse(msg);
+ expect(result.success).toBe(true);
+ if (result.success && result.data.type === 'heartbeat') {
+ expect(result.data.runtime).toBeUndefined();
+ }
+ });
+
+ it('parses a heartbeat with a zero-session runtime', () => {
+ const msg = {
+ type: 'heartbeat',
+ sessions: [],
+ runtime: validRuntime,
+ };
+ const result = CLIOutboundMessageSchema.safeParse(msg);
+ expect(result.success).toBe(true);
+ if (result.success && result.data.type === 'heartbeat') {
+ expect(result.data.runtime).toEqual(validRuntime);
+ }
+ });
+
+ it('parses a heartbeat with a non-empty sessions array and runtime together', () => {
+ const msg = {
+ type: 'heartbeat',
+ sessions: [{ id: validSessionId, status: 'busy', title: 'Fix bug' }],
+ runtime: validRuntime,
+ };
+ const result = CLIOutboundMessageSchema.safeParse(msg);
+ expect(result.success).toBe(true);
+ });
+
+ it('accepts an optional integer heartbeat sequence', () => {
+ const msg = {
+ type: 'heartbeat',
+ sessions: [],
+ runtime: validRuntime,
+ sequence: 7,
+ };
+ const result = CLIOutboundMessageSchema.safeParse(msg);
+ expect(result.success).toBe(true);
+ if (result.success && result.data.type === 'heartbeat') {
+ expect(result.data.sequence).toBe(7);
+ }
+ });
+
+ it('rejects a non-integer heartbeat sequence', () => {
+ const msg = {
+ type: 'heartbeat',
+ sessions: [],
+ runtime: validRuntime,
+ sequence: 1.5,
+ };
+ const result = CLIOutboundMessageSchema.safeParse(msg);
+ expect(result.success).toBe(false);
+ });
+
+ it('rejects a negative heartbeat sequence', () => {
+ const msg = {
+ type: 'heartbeat',
+ sessions: [],
+ runtime: validRuntime,
+ sequence: -1,
+ };
+ const result = CLIOutboundMessageSchema.safeParse(msg);
+ expect(result.success).toBe(false);
+ });
+});
+
+describe('heartbeat_ack sequence', () => {
+ it('parses heartbeat_ack without a sequence (legacy)', () => {
+ const msg = { type: 'heartbeat_ack' };
+ const result = CLIInboundMessageSchema.safeParse(msg);
+ expect(result.success).toBe(true);
+ });
+
+ it('parses heartbeat_ack with an integer sequence', () => {
+ const msg = { type: 'heartbeat_ack', sequence: 7 };
+ const result = CLIInboundMessageSchema.safeParse(msg);
+ expect(result.success).toBe(true);
+ if (result.success && result.data.type === 'heartbeat_ack') {
+ expect(result.data.sequence).toBe(7);
+ }
+ });
+
+ it('rejects heartbeat_ack with a non-integer sequence', () => {
+ const msg = { type: 'heartbeat_ack', sequence: 'seven' };
+ const result = CLIInboundMessageSchema.safeParse(msg);
+ expect(result.success).toBe(false);
+ });
+});
+
+describe('parseCliRuntimePresence', () => {
+ it('parses a valid runtime presence into the canonical contract shape', () => {
+ const result = parseCliRuntimePresence(validRuntime);
+ expect(result).toEqual({
+ runtimeId: '8db3de9a-350f-4fad-a539-8e0da3bbcf5e',
+ connectionId: 'cli-conn-1',
+ protocolVersion: 1,
+ cliVersion: '7.4.7',
+ displayName: 'Alice Mac',
+ projectName: 'customer-repo',
+ capabilities: ['catalog.v1', 'create-and-run.v1'],
+ });
+ });
+
+ it('rejects an unknown capability string', () => {
+ expect(() =>
+ parseCliRuntimePresence({
+ ...validRuntime,
+ capabilities: ['unknown.v9'],
+ })
+ ).toThrow();
+ });
+
+ it('rejects a duplicate capability list', () => {
+ expect(() =>
+ parseCliRuntimePresence({
+ ...validRuntime,
+ capabilities: ['catalog.v1', 'catalog.v1'],
+ })
+ ).toThrow();
+ });
+
+ it('rejects a runtime that exceeds the capability cap of 2', () => {
+ const thirdCap = localRuntimeCapabilitySchema.options[0];
+ expect(() =>
+ parseCliRuntimePresence({
+ ...validRuntime,
+ capabilities: ['catalog.v1', 'create-and-run.v1', thirdCap],
+ })
+ ).toThrow();
+ });
+
+ it('rejects extra fields on strict presence', () => {
+ expect(() =>
+ parseCliRuntimePresence({
+ ...validRuntime,
+ directory: '/Users/alice/private',
+ } as unknown as typeof validRuntime)
+ ).toThrow();
+ });
+
+ it('rejects a missing uuid runtimeId', () => {
+ expect(() =>
+ parseCliRuntimePresence({
+ ...validRuntime,
+ runtimeId: 'not-a-uuid',
+ })
+ ).toThrow();
+ });
+
+ it('rejects an overlong cliVersion', () => {
+ expect(() =>
+ parseCliRuntimePresence({
+ ...validRuntime,
+ cliVersion: 'x'.repeat(33),
+ })
+ ).toThrow();
+ });
+});
diff --git a/services/session-ingest/src/types/user-connection-protocol.ts b/services/session-ingest/src/types/user-connection-protocol.ts
index da3e79a550..97a0928a16 100644
--- a/services/session-ingest/src/types/user-connection-protocol.ts
+++ b/services/session-ingest/src/types/user-connection-protocol.ts
@@ -1,4 +1,8 @@
import { z } from 'zod';
+import {
+ localRuntimePresenceSchema,
+ type LocalRuntimePresence,
+} from '@kilocode/session-ingest-contracts';
// Use z.string() for session IDs (not the strict sessionIdSchema from ws-protocol)
// because the CLI's remote-protocol.ts uses z.string() — the strict ses_ format
@@ -6,12 +10,33 @@ import { z } from 'zod';
// -- CLI → DO (CLIOutbound) ---------------------------------------------------
+/**
+ * Optional runtime presence a CLI may attach to a heartbeat. The DO rejects
+ * presence whose `connectionId` does not match the authenticated socket
+ * attachment and rejects a presence whose `runtimeId` differs from one already
+ * recorded on a live socket (no in-place runtime ID mutation). The shape is
+ * reused from the cross-service contract so mobile and CLI agree on the
+ * fields, and the relay can re-emit it without re-parsing.
+ */
+const heartbeatRuntimePresenceSchema = localRuntimePresenceSchema;
+
+export function parseCliRuntimePresence(value: unknown): LocalRuntimePresence {
+ return heartbeatRuntimePresenceSchema.parse(value);
+}
+
export const CLIOutboundMessageSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('heartbeat'),
// Absent on CLI builds older than the protocolVersion field itself — treat
// a missing value as a legacy CLI with no negotiated wire protocol.
protocolVersion: z.string().optional(),
+ // Optional monotonic sequence so the relay can echo it back inside the
+ // heartbeat ACK. Legacy CLIs omit it; the relay still sends a bare
+ // heartbeat_ack in that case.
+ sequence: z.number().int().nonnegative().optional(),
+ // Optional first-class runtime presence. A zero-session heartbeat still
+ // advertises the runtime, so mobile can show an idle local runtime.
+ runtime: heartbeatRuntimePresenceSchema.optional(),
sessions: z.array(
z.object({
id: z.string(),
@@ -63,6 +88,9 @@ export const CLIInboundMessageSchema = z.discriminatedUnion('type', [
}),
z.object({
type: z.literal('heartbeat_ack'),
+ // Optional monotonic sequence echoed from the inbound heartbeat. Legacy
+ // CLIs (no sequence on the wire) still receive a bare ack.
+ sequence: z.number().int().nonnegative().optional(),
}),
]);