From a6bcb37bbca003e514c3c8f66c72ae7cc3e74fc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 3 Aug 2026 22:16:12 +0200 Subject: [PATCH 1/5] feat(mobile): add continuation seed builder --- .../agents/continuation-seed.test.ts | 339 ++++++++++++++++++ .../components/agents/continuation-seed.ts | 160 +++++++++ 2 files changed, 499 insertions(+) create mode 100644 apps/mobile/src/components/agents/continuation-seed.test.ts create mode 100644 apps/mobile/src/components/agents/continuation-seed.ts diff --git a/apps/mobile/src/components/agents/continuation-seed.test.ts b/apps/mobile/src/components/agents/continuation-seed.test.ts new file mode 100644 index 0000000000..f53e1e982e --- /dev/null +++ b/apps/mobile/src/components/agents/continuation-seed.test.ts @@ -0,0 +1,339 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + type AssistantMessage, + type Part, + type StoredMessage, + type UserMessage, +} from '@kilocode/cloud-agent-sdk'; + +import { type InstancePickerInstance } from '@/lib/picker-bridge'; + +vi.mock('lucide-react-native', () => ({ + Bug: 'Bug', + Code: 'Code', + HelpCircle: 'HelpCircle', + NotebookPen: 'NotebookPen', + Workflow: 'Workflow', +})); + +import { + buildContinuationSeed, + CONTINUATION_SEED_MAX_CHARS, + resolveContinuationDestinations, +} from './continuation-seed'; + +// --------------------------------------------------------------------------- +// Fixture helpers +// --------------------------------------------------------------------------- + +function userInfo(overrides: Partial = {}): UserMessage { + return { + id: 'u-1', + sessionID: 'ses-1', + role: 'user', + time: { created: 1_700_000_000_000 }, + agent: 'build', + model: { providerID: 'kilo', modelID: 'test-model' }, + ...overrides, + }; +} + +function assistantInfo(overrides: Partial = {}): AssistantMessage { + return { + id: 'a-1', + sessionID: 'ses-1', + role: 'assistant', + time: { created: 1_700_000_000_000 }, + parentID: 'u-1', + modelID: 'test-model', + providerID: 'kilo', + mode: 'code', + agent: 'build', + path: { cwd: '/', root: '/' }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + ...overrides, + }; +} + +function textPart(text: string, overrides: Partial = {}): Part { + return { + id: 'p-text', + sessionID: 'ses-1', + messageID: 'msg-1', + type: 'text', + text, + ...overrides, + } as unknown as Part; +} + +function reasoningPart(id = 'p-reason'): Part { + return { + id, + sessionID: 'ses-1', + messageID: 'msg-1', + type: 'reasoning', + text: 'thinking...', + } as unknown as Part; +} + +function toolPart(id = 'p-tool'): Part { + return { + id, + sessionID: 'ses-1', + messageID: 'msg-1', + type: 'tool', + name: 'read', + input: {}, + callID: 'call-1', + } as unknown as Part; +} + +function storedMessage(info: UserMessage | AssistantMessage, parts: Part[] = []): StoredMessage { + return { info, parts }; +} + +const INSTANCE_A: InstancePickerInstance = { + connectionId: 'c1', + name: 'mac-mini', + projectName: 'cloud', +}; + +const INSTANCE_B: InstancePickerInstance = { + connectionId: 'c2', + name: 'linux-box', + projectName: 'prod', +}; + +// --------------------------------------------------------------------------- +// buildContinuationSeed +// --------------------------------------------------------------------------- + +describe('buildContinuationSeed', () => { + it('extracts user and assistant text turns in order, skipping non-text parts and empty texts', () => { + const messages: StoredMessage[] = [ + storedMessage(userInfo({ id: 'u1' }), [textPart('hello')]), + storedMessage(assistantInfo({ id: 'a1' }), [textPart('hi there'), reasoningPart()]), + storedMessage(userInfo({ id: 'u2' }), [ + textPart('visible', { id: 'p1' }), + textPart('synthetic', { id: 'p2', synthetic: true } as unknown as Partial), + ]), + storedMessage(assistantInfo({ id: 'a2' }), [ + textPart('shown', { id: 'p3' }), + textPart('ignored', { id: 'p4', ignored: true } as unknown as Partial), + ]), + storedMessage(userInfo({ id: 'u3' }), [reasoningPart('r1'), toolPart('t1')]), + storedMessage(userInfo({ id: 'u4' }), [textPart('')]), + ]; + + const seed = buildContinuationSeed(messages); + expect(seed).not.toBeNull(); + expect(seed!).toContain('User:\nhello'); + expect(seed!).toContain('Assistant:\nhi there'); + expect(seed!).toContain('User:\nvisible'); + expect(seed!).toContain('Assistant:\nshown'); + // Synthetic text must not appear. + expect(seed!).not.toContain('synthetic'); + // Ignored text must not appear. + expect(seed!).not.toContain('ignored'); + // Non-text-only messages produce no text, so u3 and u4 are skipped. + // The preamble must appear exactly once. + const preambleCount = seed!.split('You are continuing a conversation').length - 1; + expect(preambleCount).toBe(1); + // No omission marker for a short transcript. + expect(seed!).not.toContain('[… middle of the transcript'); + }); + + it('returns null for an empty array', () => { + expect(buildContinuationSeed([])).toBeNull(); + }); + + it('returns null for messages with no eligible text parts', () => { + const messages: StoredMessage[] = [ + storedMessage(userInfo(), [reasoningPart(), toolPart()]), + storedMessage(assistantInfo(), [textPart('')]), + ]; + expect(buildContinuationSeed(messages)).toBeNull(); + }); + + it('produces a seed with the preamble once and every turn, no omission marker, for a short transcript', () => { + const messages: StoredMessage[] = [ + storedMessage(userInfo({ id: 'u1' }), [textPart('what is the key phrase?')]), + storedMessage(assistantInfo({ id: 'a1' }), [textPart('the key phrase is "pineapple23"')]), + ]; + + const seed = buildContinuationSeed(messages); + expect(seed).not.toBeNull(); + expect(seed!).toContain('User:\nwhat is the key phrase?'); + expect(seed!).toContain('Assistant:\nthe key phrase is "pineapple23"'); + expect(seed!).toContain('You are continuing a conversation'); + // Exactly one preamble. + expect(seed!.split('You are continuing a conversation').length - 1).toBe(1); + expect(seed!).not.toContain('[… middle of the transcript'); + expect(seed!.length).toBeLessThanOrEqual(CONTINUATION_SEED_MAX_CHARS); + }); + + it('truncates a long transcript: first turn, omission marker, and last turn fit', () => { + // 5 turns, each ~1000 chars of text → full serialized transcript + // exceeds CONTINUATION_SEED_MAX_CHARS. + const chunk = 'x'.repeat(990); + const messages: StoredMessage[] = [ + storedMessage(userInfo({ id: 'first' }), [textPart(`first ${chunk}`)]), + storedMessage(assistantInfo({ id: 'mid1' }), [textPart(`middle1 ${chunk}`)]), + storedMessage(userInfo({ id: 'mid2' }), [textPart(`middle2 ${chunk}`)]), + storedMessage(assistantInfo({ id: 'mid3' }), [textPart(`middle3 ${chunk}`)]), + storedMessage(userInfo({ id: 'last' }), [textPart(`last ${'y'.repeat(500)}`)]), + ]; + + const seed = buildContinuationSeed(messages); + expect(seed).not.toBeNull(); + expect(seed!.length).toBeLessThanOrEqual(CONTINUATION_SEED_MAX_CHARS); + + // First turn must be present. + expect(seed!).toContain('first '); + // Omission marker must appear. + expect(seed!).toContain('[… middle of the transcript'); + // Last turn must be present. + expect(seed!).toContain('last '); + }); + + it('handles a two-turn transcript whose second turn is oversized', () => { + // First turn is short; second turn is so long that head + marker + second + // would exceed the body budget. Result: first turn + marker, no tail. + const oversized = 'z'.repeat(5000); + const messages: StoredMessage[] = [ + storedMessage(userInfo({ id: 'u1' }), [textPart('short first turn')]), + storedMessage(assistantInfo({ id: 'a1' }), [textPart(oversized)]), + ]; + + const seed = buildContinuationSeed(messages); + expect(seed).not.toBeNull(); + expect(seed!.length).toBeLessThanOrEqual(CONTINUATION_SEED_MAX_CHARS); + expect(seed!).toContain('User:\nshort first turn'); + expect(seed!).toContain('[… middle of the transcript'); + // The oversized second turn must not appear. + expect(seed!).not.toContain('zzzzzzzz'); + }); + + it('handles a single oversized first turn with no omission marker', () => { + const oversized = 'w'.repeat(5000); + const messages: StoredMessage[] = [ + storedMessage(userInfo({ id: 'only' }), [textPart(oversized)]), + ]; + + const seed = buildContinuationSeed(messages); + expect(seed).not.toBeNull(); + expect(seed!.length).toBeLessThanOrEqual(CONTINUATION_SEED_MAX_CHARS); + // A truncated slice of the original text must appear. + expect(seed!).toContain('wwww'); + // Single turn → no marker. + expect(seed!).not.toContain('[… middle of the transcript'); + }); +}); + +// --------------------------------------------------------------------------- +// resolveContinuationDestinations +// --------------------------------------------------------------------------- + +describe('resolveContinuationDestinations', () => { + const GIT_URL = 'https://github.com/owner/repo.git'; + const REPOS = [{ fullName: 'owner/repo' }]; + const MODELS = [{ id: 'test-model', variants: ['default'] }]; + + it('returns cloud destination first when repo and model resolve', () => { + const result = resolveContinuationDestinations({ + gitUrl: GIT_URL, + mode: 'code', + model: 'test-model', + variant: 'default', + repositories: REPOS, + models: MODELS, + instances: [INSTANCE_A], + }); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + kind: 'cloud-agent', + repo: 'owner/repo', + model: 'test-model', + variant: 'default', + }); + expect(result[1]).toEqual({ kind: 'remote', instance: INSTANCE_A }); + }); + + it('omits cloud destination when repo is absent from repositories', () => { + const result = resolveContinuationDestinations({ + gitUrl: GIT_URL, + mode: 'code', + model: 'test-model', + variant: 'default', + repositories: [], // repo "owner/repo" is not listed + models: MODELS, + instances: [INSTANCE_A], + }); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ kind: 'remote', instance: INSTANCE_A }); + }); + + it('omits cloud destination when gitUrl is null', () => { + const result = resolveContinuationDestinations({ + gitUrl: null, + mode: 'code', + model: 'test-model', + variant: 'default', + repositories: REPOS, + models: MODELS, + instances: [INSTANCE_A], + }); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ kind: 'remote', instance: INSTANCE_A }); + }); + + it('omits cloud destination when model is absent from models', () => { + const result = resolveContinuationDestinations({ + gitUrl: GIT_URL, + mode: 'code', + model: 'test-model', + variant: 'default', + repositories: REPOS, + models: [], // model not found + instances: [INSTANCE_A], + }); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ kind: 'remote', instance: INSTANCE_A }); + }); + + it('returns two remote destinations in order', () => { + const result = resolveContinuationDestinations({ + gitUrl: null, + mode: 'code', + model: 'test-model', + variant: 'default', + repositories: REPOS, + models: MODELS, + instances: [INSTANCE_A, INSTANCE_B], + }); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ kind: 'remote', instance: INSTANCE_A }); + expect(result[1]).toEqual({ kind: 'remote', instance: INSTANCE_B }); + }); + + it('returns an empty array when everything is empty', () => { + const result = resolveContinuationDestinations({ + gitUrl: null, + mode: 'code', + model: '', + variant: '', + repositories: [], + models: [], + instances: [], + }); + + expect(result).toEqual([]); + }); +}); diff --git a/apps/mobile/src/components/agents/continuation-seed.ts b/apps/mobile/src/components/agents/continuation-seed.ts new file mode 100644 index 0000000000..f065360bd5 --- /dev/null +++ b/apps/mobile/src/components/agents/continuation-seed.ts @@ -0,0 +1,160 @@ +import { type StoredMessage, type TextPart } from '@kilocode/cloud-agent-sdk'; +import { normalizeAgentMode } from '@/components/agents/mode-options'; +import { + buildContinuePrefillParams, + resolvePrefillModel, + resolvePrefillRepo, + type NewSessionPrefill, +} from '@/components/agents/new-session-prefill'; +import { type InstancePickerInstance } from '@/lib/picker-bridge'; + +export const CONTINUATION_SEED_MAX_CHARS = 3800; + +const SEED_PREAMBLE = `You are continuing a conversation that the user had with you in a previous session. The transcript of that conversation is below. Treat it as your own memory: the "User" turns are the user's messages and the "Assistant" turns are your own previous replies. + +Reply with a short confirmation of the context you carried over (one or two sentences), then wait for the user's next instruction.`; + +const SEED_OMISSION_MARKER = '[… middle of the transcript omitted for length …]'; + +interface Turn { + label: 'User' | 'Assistant'; + text: string; +} + +function joinLen(parts: readonly string[]): number { + return parts.reduce((sum, p) => sum + p.length, 0) + 2 * (parts.length - 1); +} + +function serialize(t: Turn): string { + return `${t.label}:\n${t.text}`; +} + +export function buildContinuationSeed(messages: readonly StoredMessage[]): string | null { + // Step 1: extract text turns from messages. + const turns: Turn[] = []; + for (const msg of messages) { + if (msg.info.role !== 'user' && msg.info.role !== 'assistant') { + continue; + } + + const text = msg.parts + .filter( + part => + part.type === 'text' && + typeof (part as TextPart).text === 'string' && + part.synthetic !== true && + part.ignored !== true + ) + .map(part => (part as TextPart).text) + .join('\n') + .trim(); + + if (!text) { + continue; + } + + turns.push({ + label: msg.info.role === 'user' ? 'User' : 'Assistant', + text, + }); + } + + // Step 2: no turns → null. + if (turns.length === 0) { + return null; + } + + // Step 3: full transcript fits → return as-is. + const full = turns.map(serialize).join('\n\n'); + const seed = `${SEED_PREAMBLE}\n\n${full}`; + if (seed.length <= CONTINUATION_SEED_MAX_CHARS) { + return seed; + } + + // Step 4: truncation branch. + const body = CONTINUATION_SEED_MAX_CHARS - SEED_PREAMBLE.length - 2; // the 2 is '\n\n' after the preamble + const head = serialize(turns[0]!); + + // Greedily collect trailing turns newest-first. + const tail: string[] = []; + for (let i = turns.length - 1; i >= 1; i--) { + const candidate = [serialize(turns[i]!), ...tail]; + const candidateLen = + head.length + + 2 + + SEED_OMISSION_MARKER.length + + (candidate.length > 0 ? 2 + joinLen(candidate) : 0); + if (candidateLen <= body) { + tail.length = 0; + tail.push(...candidate); + } else { + break; + } + } + + const omitted = turns.length - 1 - tail.length; + let resultBody: string; + if (omitted === 0) { + resultBody = head + (tail.length > 0 ? `\n\n${tail.join('\n\n')}` : ''); + } else { + resultBody = + head + `\n\n${SEED_OMISSION_MARKER}` + (tail.length > 0 ? `\n\n${tail.join('\n\n')}` : ''); + } + + // Safety guard: if head + marker alone exceeds body. + if (resultBody.length > body) { + resultBody = `${resultBody.slice(0, body - 1)}…`; + } + + return `${SEED_PREAMBLE}\n\n${resultBody}`; +} + +export type ContinuationDestination = + | { kind: 'cloud-agent'; repo: string; model: string; variant: string } + | { kind: 'remote'; instance: InstancePickerInstance }; + +export function resolveContinuationDestinations(args: { + gitUrl: string | null | undefined; + mode: string; + model: string; + variant: string; + repositories: { fullName: string }[]; + models: { id: string; variants: string[] }[]; + instances: InstancePickerInstance[]; +}): ContinuationDestination[] { + const { gitUrl, mode, model, variant, repositories, models, instances } = args; + + const prefillParams = buildContinuePrefillParams({ + gitUrl, + mode, + model, + variant, + }); + const prefill: NewSessionPrefill = { + mode: normalizeAgentMode(mode), + ...(prefillParams.repo ? { repo: prefillParams.repo } : {}), + ...(prefillParams.model ? { model: prefillParams.model } : {}), + ...(prefillParams.variant ? { variant: prefillParams.variant } : {}), + }; + + const result: ContinuationDestination[] = []; + + // Cloud destination when both repo and model resolve. + const repo = resolvePrefillRepo(repositories, prefill); + const resolvedModel = resolvePrefillModel(models, prefill); + if (repo !== null && resolvedModel !== null) { + result.push({ + kind: 'cloud-agent', + repo, + model: resolvedModel.model, + variant: resolvedModel.variant, + }); + } + + // Remote destinations. + for (const instance of instances) { + result.push({ kind: 'remote', instance }); + } + + return result; +} From 14d6e2b6c8e642bc06ec65633297fc5b4124afcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 3 Aug 2026 22:43:39 +0200 Subject: [PATCH 2/5] feat(mobile): continue sessions with context --- .../agents/session-detail-content.tsx | 19 +- .../components/agents/use-continue-session.ts | 301 ++++++++++++++++++ 2 files changed, 311 insertions(+), 9 deletions(-) create mode 100644 apps/mobile/src/components/agents/use-continue-session.ts diff --git a/apps/mobile/src/components/agents/session-detail-content.tsx b/apps/mobile/src/components/agents/session-detail-content.tsx index 89ae76c42c..74e71c21c5 100644 --- a/apps/mobile/src/components/agents/session-detail-content.tsx +++ b/apps/mobile/src/components/agents/session-detail-content.tsx @@ -16,11 +16,6 @@ import { toast } from 'sonner-native'; import { getBlockingInteraction } from '@/components/agents/agent-interaction-policy'; import { ChatComposer } from '@/components/agents/chat-composer'; -import { - appendNewSessionPrefill, - buildContinuePrefillParams, -} from '@/components/agents/new-session-prefill'; -import { getNewAgentSessionPath } from '@/components/agents/session-list-routes'; import { createAndNavigateAgentSession } from '@/components/agents/create-and-navigate-agent-session'; import { exitRemoteSessionWithFeedback } from '@/components/agents/exit-remote-session-with-feedback'; import { restartAgentSession } from '@/components/agents/restart-agent-session'; @@ -104,6 +99,7 @@ import { revalidateLegacyGatewayOverride, useSessionModelOptions, } from '@/lib/hooks/use-session-model-options'; +import { useContinueSession } from '@/components/agents/use-continue-session'; import { resolveSessionContextInfo } from '@/lib/session-context-info'; import { areModelPickerSelectionScopesEqual, @@ -236,6 +232,12 @@ export function SessionDetailContent({ organizationId, }); const modelOptions = sessionModels.options; + const { continueSession, isContinuing } = useContinueSession({ + organizationId, + manager, + models: modelOptions, + modelsLoading: gatewayModelsLoading, + }); const contextInfo = useMemo( () => resolveSessionContextInfo(contextUsage, sessionModels.options), [contextUsage, sessionModels.options] @@ -764,15 +766,13 @@ export function SessionDetailContent({ ); const handleContinueInNewSession = useCallback(() => { - const base = getNewAgentSessionPath(organizationId ?? null); - const params = buildContinuePrefillParams({ + void continueSession({ gitUrl: fetchedData?.gitUrl, mode: currentMode, model: currentModel, variant: currentVariant, }); - router.push(appendNewSessionPrefill(base, params) as Href); - }, [organizationId, fetchedData?.gitUrl, currentMode, currentModel, currentVariant, router]); + }, [continueSession, fetchedData?.gitUrl, currentMode, currentModel, currentVariant]); const isFocused = useIsFocused(); // Focus bounds the awake window to the visible working UI; a backgrounded @@ -946,6 +946,7 @@ export function SessionDetailContent({ variant="outline" size="sm" accessibilityLabel="Continue in a new session" + disabled={isContinuing} onPress={handleContinueInNewSession} > Continue in a new session diff --git a/apps/mobile/src/components/agents/use-continue-session.ts b/apps/mobile/src/components/agents/use-continue-session.ts new file mode 100644 index 0000000000..bb1b7e30eb --- /dev/null +++ b/apps/mobile/src/components/agents/use-continue-session.ts @@ -0,0 +1,301 @@ +import { type inferRouterOutputs, type MobileRouter } from '@kilocode/trpc/mobile'; +import { useCallback, useRef, useState } from 'react'; +import { type Href, useRouter } from 'expo-router'; +import { useActionSheet } from '@expo/react-native-action-sheet'; +import { useQueryClient } from '@tanstack/react-query'; +import { useStore } from 'jotai'; +import { toast } from 'sonner-native'; +import { generateMessageId } from '@kilocode/cloud-agent-sdk/message-id'; +import * as Haptics from 'expo-haptics'; + +import { + buildContinuationSeed, + type ContinuationDestination, + resolveContinuationDestinations, +} from '@/components/agents/continuation-seed'; +import { normalizeAgentMode } from '@/components/agents/mode-options'; +import { + appendNewSessionPrefill, + buildContinuePrefillParams, +} from '@/components/agents/new-session-prefill'; +import { getNewAgentSessionPath } from '@/components/agents/session-list-routes'; +import { + getAgentSessionPath, + getSpawnedAgentSessionPath, +} from '@/components/agents/session-detail-routes'; +import { type useSessionManager } from '@/components/agents/session-provider'; +import { putSharePayload } from '@/lib/share-payload'; +import { appendShareParams } from '@/lib/share-navigation'; +import { + buildCreateRemoteSessionInput, + useRemoteInstanceSpawn, +} from '@/lib/hooks/use-remote-instance-spawn'; +import { + REMOTE_SPAWN_NON_RETRYABLE_TOAST, + REMOTE_SPAWN_RETRYABLE_TOAST, +} from '@/lib/remote-submit-outcome'; +import { captureEvent, SESSION_CREATED_EVENT } from '@/lib/analytics/posthog'; +import { invalidateAgentSessionQueries } from '@/lib/agent-session-cache'; +import { trpcClient, useTRPC } from '@/lib/trpc'; + +type RouterOutputs = inferRouterOutputs; +type RepositoriesResult = + | RouterOutputs['organizations']['cloudAgentNext']['listGitHubRepositories'] + | RouterOutputs['cloudAgentNext']['listGitHubRepositories']; +type InstancesResult = RouterOutputs['activeSessions']['listInstances']; + +export function useContinueSession(args: { + organizationId: string | undefined; + manager: ReturnType; + models: { id: string; variants: string[] }[]; + modelsLoading: boolean; +}): { + continueSession: (input: { + gitUrl: string | null | undefined; + mode: string; + model: string; + variant: string; + }) => Promise; + isContinuing: boolean; +} { + const router = useRouter(); + const queryClient = useQueryClient(); + const trpc = useTRPC(); + const store = useStore(); + const { showActionSheetWithOptions } = useActionSheet(); + const { spawn } = useRemoteInstanceSpawn(args.organizationId ?? null); + const [isContinuing, setIsContinuing] = useState(false); + const busyRef = useRef(false); + + const runCloudCreate = useCallback( + async (seed: string, dest: { repo: string; model: string; variant: string }, mode: string) => { + const initialMessageId = generateMessageId(); + const baseInput = { + prompt: seed, + initialMessageId, + mode: normalizeAgentMode(mode), + model: dest.model, + variant: dest.variant || undefined, + githubRepo: dest.repo, + autoCommit: true, + autoInitiate: true, + }; + const result = args.organizationId + ? await trpcClient.organizations.cloudAgentNext.prepareSession.mutate({ + ...baseInput, + organizationId: args.organizationId, + }) + : await trpcClient.cloudAgentNext.prepareSession.mutate(baseInput); + captureEvent(SESSION_CREATED_EVENT, { surface: 'cloud-agent' }); + await invalidateAgentSessionQueries(queryClient, trpc); + void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + router.push(getAgentSessionPath(result.kiloSessionId, args.organizationId)); + }, + [args.organizationId, queryClient, router, trpc] + ); + + const execute = useCallback( + async ( + dest: ContinuationDestination, + seed: string, + fields: { mode: string; model: string; variant: string } + ) => { + setIsContinuing(true); + busyRef.current = true; + try { + if (dest.kind === 'cloud-agent') { + try { + await runCloudCreate(seed, dest, fields.mode); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to create session'); + } + return; + } + const outcome = await spawn( + dest.instance.connectionId, + buildCreateRemoteSessionInput({ + mode: fields.mode, + model: fields.model, + variant: fields.variant, + organizationId: args.organizationId, + }) + ); + if (outcome.status === 'ready') { + const shareId = putSharePayload({ text: seed, files: [], failedFiles: [] }); + router.push( + appendShareParams( + getSpawnedAgentSessionPath(outcome.sessionID, args.organizationId) as string, + shareId, + { autoSend: true } + ) as Href + ); + return; + } + toast.error( + outcome.status === 'retryable' + ? REMOTE_SPAWN_RETRYABLE_TOAST + : REMOTE_SPAWN_NON_RETRYABLE_TOAST + ); + } finally { + busyRef.current = false; + setIsContinuing(false); + } + }, + [args.organizationId, router, runCloudCreate, spawn] + ); + + const fallback = useCallback( + ( + fields: { gitUrl: string | null | undefined; mode: string; model: string; variant: string }, + seed: string | null + ) => { + let path = appendNewSessionPrefill( + getNewAgentSessionPath(args.organizationId ?? null), + buildContinuePrefillParams({ + gitUrl: fields.gitUrl, + mode: fields.mode, + model: fields.model, + variant: fields.variant, + }) + ); + if (seed !== null) { + const shareId = putSharePayload({ text: seed, files: [], failedFiles: [] }); + path = appendShareParams(path, shareId); + } + router.push(path as Href); + }, + [args.organizationId, router] + ); + + const continueSession = useCallback( + async (fields: { + gitUrl: string | null | undefined; + mode: string; + model: string; + variant: string; + }) => { + if (busyRef.current) { + return; + } + setIsContinuing(true); + busyRef.current = true; + let handedOff = false; + try { + for ( + let pages = 0; + pages < 10 && store.get(args.manager.atoms.hasOlderMessages); + pages += 1 + ) { + const before = store.get(args.manager.atoms.messagesList).length; + // eslint-disable-next-line no-await-in-loop -- draining pages sequentially is the intended behavior + await args.manager.loadOlderMessages(); + if (store.get(args.manager.atoms.messagesList).length === before) { + break; + } + } + const messages = store.get(args.manager.atoms.messagesList); + const seed = buildContinuationSeed(messages); + + if (seed === null) { + fallback(fields, null); + return; + } + + let repoData: RepositoriesResult | undefined = undefined; + let instancesData: InstancesResult | undefined = undefined; + try { + [repoData, instancesData] = await Promise.all([ + queryClient.fetchQuery({ + ...(args.organizationId + ? trpc.organizations.cloudAgentNext.listGitHubRepositories.queryOptions({ + organizationId: args.organizationId, + forceRefresh: false, + }) + : trpc.cloudAgentNext.listGitHubRepositories.queryOptions({ + forceRefresh: false, + })), + staleTime: 0, + }), + queryClient.fetchQuery({ + ...trpc.activeSessions.listInstances.queryOptions(undefined), + staleTime: 5000, + }), + ]); + } catch { + fallback(fields, seed); + return; + } + + const destinations = resolveContinuationDestinations({ + gitUrl: fields.gitUrl, + mode: fields.mode, + model: fields.model, + variant: fields.variant, + repositories: repoData.repositories, + models: args.modelsLoading ? [] : args.models, + instances: instancesData.instances, + }); + + if (destinations.length === 0) { + fallback(fields, seed); + return; + } + + if (destinations.length === 1) { + const dest = destinations[0]; + if (!dest) { + fallback(fields, seed); + return; + } + await execute(dest, seed, fields); + return; + } + + handedOff = true; + const labels = destinations.map(d => + d.kind === 'cloud-agent' ? 'Cloud Agent' : d.instance.name + ); + showActionSheetWithOptions( + { + title: 'Continue in a new session', + options: [...labels, 'Cancel'], + cancelButtonIndex: labels.length, + }, + selected => { + if (selected === undefined || selected >= labels.length) { + busyRef.current = false; + setIsContinuing(false); + return; + } + const dest = destinations[selected]; + if (!dest) { + busyRef.current = false; + setIsContinuing(false); + return; + } + void execute(dest, seed, fields); + } + ); + } finally { + if (!handedOff) { + busyRef.current = false; + setIsContinuing(false); + } + } + }, + [ + args.manager, + args.models, + args.modelsLoading, + args.organizationId, + store, + fallback, + queryClient, + trpc, + execute, + showActionSheetWithOptions, + ] + ); + + return { continueSession, isContinuing }; +} From e8ea637d9af3bf07bbf5eb393a515f1dc0a670fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 3 Aug 2026 22:56:43 +0200 Subject: [PATCH 3/5] fix(mobile): satisfy continuation seed lint --- .../agents/continuation-seed.test.ts | 78 +++++++++++-------- .../components/agents/continuation-seed.ts | 73 +++++++++-------- 2 files changed, 80 insertions(+), 71 deletions(-) diff --git a/apps/mobile/src/components/agents/continuation-seed.test.ts b/apps/mobile/src/components/agents/continuation-seed.test.ts index f53e1e982e..c86da438ca 100644 --- a/apps/mobile/src/components/agents/continuation-seed.test.ts +++ b/apps/mobile/src/components/agents/continuation-seed.test.ts @@ -9,6 +9,12 @@ import { import { type InstancePickerInstance } from '@/lib/picker-bridge'; +import { + buildContinuationSeed, + CONTINUATION_SEED_MAX_CHARS, + resolveContinuationDestinations, +} from './continuation-seed'; + vi.mock('lucide-react-native', () => ({ Bug: 'Bug', Code: 'Code', @@ -17,12 +23,6 @@ vi.mock('lucide-react-native', () => ({ Workflow: 'Workflow', })); -import { - buildContinuationSeed, - CONTINUATION_SEED_MAX_CHARS, - resolveContinuationDestinations, -} from './continuation-seed'; - // --------------------------------------------------------------------------- // Fixture helpers // --------------------------------------------------------------------------- @@ -129,20 +129,22 @@ describe('buildContinuationSeed', () => { const seed = buildContinuationSeed(messages); expect(seed).not.toBeNull(); - expect(seed!).toContain('User:\nhello'); - expect(seed!).toContain('Assistant:\nhi there'); - expect(seed!).toContain('User:\nvisible'); - expect(seed!).toContain('Assistant:\nshown'); + // eslint-disable-next-line typescript-eslint/no-non-null-assertion -- guarded by expect above + const s = seed!; + expect(s).toContain('User:\nhello'); + expect(s).toContain('Assistant:\nhi there'); + expect(s).toContain('User:\nvisible'); + expect(s).toContain('Assistant:\nshown'); // Synthetic text must not appear. - expect(seed!).not.toContain('synthetic'); + expect(s).not.toContain('synthetic'); // Ignored text must not appear. - expect(seed!).not.toContain('ignored'); + expect(s).not.toContain('ignored'); // Non-text-only messages produce no text, so u3 and u4 are skipped. // The preamble must appear exactly once. - const preambleCount = seed!.split('You are continuing a conversation').length - 1; + const preambleCount = s.split('You are continuing a conversation').length - 1; expect(preambleCount).toBe(1); // No omission marker for a short transcript. - expect(seed!).not.toContain('[… middle of the transcript'); + expect(s).not.toContain('[… middle of the transcript'); }); it('returns null for an empty array', () => { @@ -165,13 +167,15 @@ describe('buildContinuationSeed', () => { const seed = buildContinuationSeed(messages); expect(seed).not.toBeNull(); - expect(seed!).toContain('User:\nwhat is the key phrase?'); - expect(seed!).toContain('Assistant:\nthe key phrase is "pineapple23"'); - expect(seed!).toContain('You are continuing a conversation'); + // eslint-disable-next-line typescript-eslint/no-non-null-assertion -- guarded by expect above + const s = seed!; + expect(s).toContain('User:\nwhat is the key phrase?'); + expect(s).toContain('Assistant:\nthe key phrase is "pineapple23"'); + expect(s).toContain('You are continuing a conversation'); // Exactly one preamble. - expect(seed!.split('You are continuing a conversation').length - 1).toBe(1); - expect(seed!).not.toContain('[… middle of the transcript'); - expect(seed!.length).toBeLessThanOrEqual(CONTINUATION_SEED_MAX_CHARS); + expect(s.split('You are continuing a conversation').length - 1).toBe(1); + expect(s).not.toContain('[… middle of the transcript'); + expect(s.length).toBeLessThanOrEqual(CONTINUATION_SEED_MAX_CHARS); }); it('truncates a long transcript: first turn, omission marker, and last turn fit', () => { @@ -188,14 +192,16 @@ describe('buildContinuationSeed', () => { const seed = buildContinuationSeed(messages); expect(seed).not.toBeNull(); - expect(seed!.length).toBeLessThanOrEqual(CONTINUATION_SEED_MAX_CHARS); + // eslint-disable-next-line typescript-eslint/no-non-null-assertion -- guarded by expect above + const s = seed!; + expect(s.length).toBeLessThanOrEqual(CONTINUATION_SEED_MAX_CHARS); // First turn must be present. - expect(seed!).toContain('first '); + expect(s).toContain('first '); // Omission marker must appear. - expect(seed!).toContain('[… middle of the transcript'); + expect(s).toContain('[… middle of the transcript'); // Last turn must be present. - expect(seed!).toContain('last '); + expect(s).toContain('last '); }); it('handles a two-turn transcript whose second turn is oversized', () => { @@ -209,11 +215,13 @@ describe('buildContinuationSeed', () => { const seed = buildContinuationSeed(messages); expect(seed).not.toBeNull(); - expect(seed!.length).toBeLessThanOrEqual(CONTINUATION_SEED_MAX_CHARS); - expect(seed!).toContain('User:\nshort first turn'); - expect(seed!).toContain('[… middle of the transcript'); + // eslint-disable-next-line typescript-eslint/no-non-null-assertion -- guarded by expect above + const s = seed!; + expect(s.length).toBeLessThanOrEqual(CONTINUATION_SEED_MAX_CHARS); + expect(s).toContain('User:\nshort first turn'); + expect(s).toContain('[… middle of the transcript'); // The oversized second turn must not appear. - expect(seed!).not.toContain('zzzzzzzz'); + expect(s).not.toContain('zzzzzzzz'); }); it('handles a single oversized first turn with no omission marker', () => { @@ -224,11 +232,13 @@ describe('buildContinuationSeed', () => { const seed = buildContinuationSeed(messages); expect(seed).not.toBeNull(); - expect(seed!.length).toBeLessThanOrEqual(CONTINUATION_SEED_MAX_CHARS); + // eslint-disable-next-line typescript-eslint/no-non-null-assertion -- guarded by expect above + const s = seed!; + expect(s.length).toBeLessThanOrEqual(CONTINUATION_SEED_MAX_CHARS); // A truncated slice of the original text must appear. - expect(seed!).toContain('wwww'); + expect(s).toContain('wwww'); // Single turn → no marker. - expect(seed!).not.toContain('[… middle of the transcript'); + expect(s).not.toContain('[… middle of the transcript'); }); }); @@ -268,7 +278,8 @@ describe('resolveContinuationDestinations', () => { mode: 'code', model: 'test-model', variant: 'default', - repositories: [], // repo "owner/repo" is not listed + // repo "owner/repo" is not listed. + repositories: [], models: MODELS, instances: [INSTANCE_A], }); @@ -299,7 +310,8 @@ describe('resolveContinuationDestinations', () => { model: 'test-model', variant: 'default', repositories: REPOS, - models: [], // model not found + // model not found. + models: [], instances: [INSTANCE_A], }); diff --git a/apps/mobile/src/components/agents/continuation-seed.ts b/apps/mobile/src/components/agents/continuation-seed.ts index f065360bd5..3e0a539646 100644 --- a/apps/mobile/src/components/agents/continuation-seed.ts +++ b/apps/mobile/src/components/agents/continuation-seed.ts @@ -1,10 +1,10 @@ -import { type StoredMessage, type TextPart } from '@kilocode/cloud-agent-sdk'; +import { type Part, type StoredMessage, type TextPart } from '@kilocode/cloud-agent-sdk'; import { normalizeAgentMode } from '@/components/agents/mode-options'; import { buildContinuePrefillParams, + type NewSessionPrefill, resolvePrefillModel, resolvePrefillRepo, - type NewSessionPrefill, } from '@/components/agents/new-session-prefill'; import { type InstancePickerInstance } from '@/lib/picker-bridge'; @@ -16,9 +16,13 @@ Reply with a short confirmation of the context you carried over (one or two sent const SEED_OMISSION_MARKER = '[… middle of the transcript omitted for length …]'; -interface Turn { +type Turn = { label: 'User' | 'Assistant'; text: string; +}; + +function isTextPart(part: Part): part is TextPart { + return part.type === 'text'; } function joinLen(parts: readonly string[]): number { @@ -33,30 +37,19 @@ export function buildContinuationSeed(messages: readonly StoredMessage[]): strin // Step 1: extract text turns from messages. const turns: Turn[] = []; for (const msg of messages) { - if (msg.info.role !== 'user' && msg.info.role !== 'assistant') { - continue; - } - const text = msg.parts - .filter( - part => - part.type === 'text' && - typeof (part as TextPart).text === 'string' && - part.synthetic !== true && - part.ignored !== true - ) - .map(part => (part as TextPart).text) + .filter(part => isTextPart(part)) + .filter(part => part.synthetic !== true && part.ignored !== true) + .map(part => part.text) .join('\n') .trim(); - if (!text) { - continue; + if (text) { + turns.push({ + label: msg.info.role === 'user' ? 'User' : 'Assistant', + text, + }); } - - turns.push({ - label: msg.info.role === 'user' ? 'User' : 'Assistant', - text, - }); } // Step 2: no turns → null. @@ -65,41 +58,45 @@ export function buildContinuationSeed(messages: readonly StoredMessage[]): strin } // Step 3: full transcript fits → return as-is. - const full = turns.map(serialize).join('\n\n'); + const full = turns.map(turn => serialize(turn)).join('\n\n'); const seed = `${SEED_PREAMBLE}\n\n${full}`; if (seed.length <= CONTINUATION_SEED_MAX_CHARS) { return seed; } // Step 4: truncation branch. - const body = CONTINUATION_SEED_MAX_CHARS - SEED_PREAMBLE.length - 2; // the 2 is '\n\n' after the preamble - const head = serialize(turns[0]!); + // The 2 accounts for '\n\n' after the preamble. + const body = CONTINUATION_SEED_MAX_CHARS - SEED_PREAMBLE.length - 2; + const firstTurn = turns[0]; + if (!firstTurn) { + return null; + } + const head = serialize(firstTurn); // Greedily collect trailing turns newest-first. - const tail: string[] = []; - for (let i = turns.length - 1; i >= 1; i--) { - const candidate = [serialize(turns[i]!), ...tail]; + const midTurns = turns.slice(1); + let tailParts: string[] = []; + for (let i = midTurns.length - 1; i >= 0; i -= 1) { + const turn = midTurns[i]; + if (!turn) { + break; + } + const candidate = [serialize(turn), ...tailParts]; const candidateLen = head.length + 2 + SEED_OMISSION_MARKER.length + (candidate.length > 0 ? 2 + joinLen(candidate) : 0); if (candidateLen <= body) { - tail.length = 0; - tail.push(...candidate); + tailParts = candidate; } else { break; } } - const omitted = turns.length - 1 - tail.length; - let resultBody: string; - if (omitted === 0) { - resultBody = head + (tail.length > 0 ? `\n\n${tail.join('\n\n')}` : ''); - } else { - resultBody = - head + `\n\n${SEED_OMISSION_MARKER}` + (tail.length > 0 ? `\n\n${tail.join('\n\n')}` : ''); - } + const omitted = turns.length - 1 - tailParts.length; + const tailStr = tailParts.length > 0 ? `\n\n${tailParts.join('\n\n')}` : ''; + let resultBody = omitted === 0 ? head + tailStr : `${head}\n\n${SEED_OMISSION_MARKER}${tailStr}`; // Safety guard: if head + marker alone exceeds body. if (resultBody.length > body) { From ab27bba4b56e5ad182d5fa049a4b50052b2795c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 3 Aug 2026 23:31:31 +0200 Subject: [PATCH 4/5] fix(dev): target source session when breaking pane --- dev/local/tmux.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev/local/tmux.ts b/dev/local/tmux.ts index d32c82fea3..0e2848a9c0 100644 --- a/dev/local/tmux.ts +++ b/dev/local/tmux.ts @@ -357,7 +357,7 @@ function breakPane( newWindowName: string ): number { const output = execSync( - `tmux break-pane -d -s ${sessionName}:${windowTarget}.${pane} -n ${escapeForShell( + `tmux break-pane -d -s ${sessionName}:${windowTarget}.${pane} -t ${sessionName}: -n ${escapeForShell( newWindowName )} -P -F "#{window_index}"`, { encoding: 'utf-8' } From 76f10f272bb4950295bb9e83f1a8821d5c45c2d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 4 Aug 2026 00:14:56 +0200 Subject: [PATCH 5/5] fix(mobile): validate remote continuation model --- .../components/agents/continuation-seed.ts | 23 ++++++ .../resolve-continue-remote-model.test.ts | 75 +++++++++++++++++++ .../components/agents/use-continue-session.ts | 8 +- 3 files changed, 103 insertions(+), 3 deletions(-) create mode 100644 apps/mobile/src/components/agents/resolve-continue-remote-model.test.ts diff --git a/apps/mobile/src/components/agents/continuation-seed.ts b/apps/mobile/src/components/agents/continuation-seed.ts index 3e0a539646..f8b1d8a98f 100644 --- a/apps/mobile/src/components/agents/continuation-seed.ts +++ b/apps/mobile/src/components/agents/continuation-seed.ts @@ -110,6 +110,29 @@ export type ContinuationDestination = | { kind: 'cloud-agent'; repo: string; model: string; variant: string } | { kind: 'remote'; instance: InstancePickerInstance }; +/** + * Validate a stored model against the current gateway catalog. + * + * Returns the original model + variant when present and valid. Returns empty + * strings when the model is absent or the variant is not in its variant list, + * so the caller can omit the model override and let the remote CLI use its + * default. + */ +export function resolveContinueRemoteModel( + model: string, + variant: string, + catalog: { id: string; variants: string[] }[] +): { model: string; variant: string } { + const found = catalog.find(m => m.id === model); + if (!found) { + return { model: '', variant: '' }; + } + if (variant && !found.variants.includes(variant)) { + return { model: '', variant: '' }; + } + return { model, variant }; +} + export function resolveContinuationDestinations(args: { gitUrl: string | null | undefined; mode: string; diff --git a/apps/mobile/src/components/agents/resolve-continue-remote-model.test.ts b/apps/mobile/src/components/agents/resolve-continue-remote-model.test.ts new file mode 100644 index 0000000000..ddd44ee293 --- /dev/null +++ b/apps/mobile/src/components/agents/resolve-continue-remote-model.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { resolveContinueRemoteModel } from './continuation-seed'; + +vi.mock('lucide-react-native', () => ({ + Bug: 'Bug', + Code: 'Code', + HelpCircle: 'HelpCircle', + NotebookPen: 'NotebookPen', + Workflow: 'Workflow', +})); + +const CATALOG = [ + { id: 'model-a', variants: ['v1', 'v2'] }, + { id: 'model-b', variants: [] }, + { id: 'model-c', variants: ['latest'] }, +]; + +describe('resolveContinueRemoteModel', () => { + it('returns the model and variant when both are in the catalog', () => { + expect(resolveContinueRemoteModel('model-a', 'v1', CATALOG)).toEqual({ + model: 'model-a', + variant: 'v1', + }); + }); + + it('returns the model and empty variant when variant is empty and model is in catalog', () => { + expect(resolveContinueRemoteModel('model-a', '', CATALOG)).toEqual({ + model: 'model-a', + variant: '', + }); + }); + + it('returns empty when the model is not in the catalog', () => { + expect(resolveContinueRemoteModel('model-unknown', 'v1', CATALOG)).toEqual({ + model: '', + variant: '', + }); + }); + + it('returns empty when the variant is not in the model variant list', () => { + expect(resolveContinueRemoteModel('model-a', 'v99', CATALOG)).toEqual({ + model: '', + variant: '', + }); + }); + + it('returns empty when the catalog is empty', () => { + expect(resolveContinueRemoteModel('model-a', 'v1', [])).toEqual({ + model: '', + variant: '', + }); + }); + + it('returns the model when variant is empty and model has no variants', () => { + expect(resolveContinueRemoteModel('model-b', '', CATALOG)).toEqual({ + model: 'model-b', + variant: '', + }); + }); + + it('returns empty when variant is non-empty but model has no variants', () => { + expect(resolveContinueRemoteModel('model-b', 'any', CATALOG)).toEqual({ + model: '', + variant: '', + }); + }); + + it('returns empty model and variant when both are empty strings (empty-source behavior)', () => { + expect(resolveContinueRemoteModel('', '', CATALOG)).toEqual({ + model: '', + variant: '', + }); + }); +}); diff --git a/apps/mobile/src/components/agents/use-continue-session.ts b/apps/mobile/src/components/agents/use-continue-session.ts index bb1b7e30eb..3d3860b7fe 100644 --- a/apps/mobile/src/components/agents/use-continue-session.ts +++ b/apps/mobile/src/components/agents/use-continue-session.ts @@ -12,6 +12,7 @@ import { buildContinuationSeed, type ContinuationDestination, resolveContinuationDestinations, + resolveContinueRemoteModel, } from '@/components/agents/continuation-seed'; import { normalizeAgentMode } from '@/components/agents/mode-options'; import { @@ -111,12 +112,13 @@ export function useContinueSession(args: { } return; } + const remoteModel = resolveContinueRemoteModel(fields.model, fields.variant, args.models); const outcome = await spawn( dest.instance.connectionId, buildCreateRemoteSessionInput({ mode: fields.mode, - model: fields.model, - variant: fields.variant, + model: remoteModel.model, + variant: remoteModel.variant, organizationId: args.organizationId, }) ); @@ -141,7 +143,7 @@ export function useContinueSession(args: { setIsContinuing(false); } }, - [args.organizationId, router, runCloudCreate, spawn] + [args.organizationId, args.models, router, runCloudCreate, spawn] ); const fallback = useCallback(