diff --git a/apps/web/src/app/(app)/cloud/sessions/SessionsPageContent.tsx b/apps/web/src/app/(app)/cloud/sessions/SessionsPageContent.tsx index 9cfe83a819..96dcc35fbc 100644 --- a/apps/web/src/app/(app)/cloud/sessions/SessionsPageContent.tsx +++ b/apps/web/src/app/(app)/cloud/sessions/SessionsPageContent.tsx @@ -11,7 +11,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { Search, Cloud, Terminal, Puzzle, Bot, Workflow } from 'lucide-react'; +import { Search, Cloud, Terminal, Puzzle, Bot, Workflow, Loader2 } from 'lucide-react'; import { SetPageTitle } from '@/components/SetPageTitle'; import type { SessionsListItem } from '@/components/cloud-agent/SessionsList'; import { SessionsList } from '@/components/cloud-agent/SessionsList'; @@ -31,6 +31,7 @@ import Link from 'next/link'; import { PageContainer } from '@/components/layouts/PageContainer'; import { toast } from 'sonner'; import { useConfirm } from '@/components/ui/confirm'; +import { useCloudSessionFork } from '@/components/cloud-agent-next/use-cloud-session-fork'; /** Platform filter options matching the badge logic in SessionsList */ const PLATFORM_OPTIONS: readonly { @@ -59,7 +60,10 @@ export function SessionsPageContent() { const [platformFilter, setPlatformFilter] = useState('all'); const [sessionFilter, setSessionFilter] = useState('all'); const [pendingSessionIds, setPendingSessionIds] = useState(() => new Set()); - type SessionWithSource = SessionsListItem & { source: 'v2' }; + type SessionWithSource = SessionsListItem & { + source: 'v2'; + cloudAgentSessionId?: string | null; + }; const [selectedSession, setSelectedSession] = useState(null); const [isDialogOpen, setIsDialogOpen] = useState(false); @@ -74,6 +78,8 @@ export function SessionsPageContent() { // Determine if we're in an organization context const organizationId = pathname.match(/^\/organizations\/([^/]+)/)?.[1]; + const { forkSessionToNewCloudSession, forkingSessionId } = useCloudSessionFork(organizationId); + // When in organization context, OrganizationTrialWrapper already provides PageContainer const shouldUsePageContainer = !organizationId; @@ -183,6 +189,7 @@ export function SessionsPageContent() { return { createdAt: session.created_at, createdOnPlatform: session.created_on_platform, + cloudAgentSessionId: session.cloud_agent_session_id, prompt, repository, sessionId: session.session_id, @@ -203,6 +210,14 @@ export function SessionsPageContent() { setIsDialogOpen(true); }; + const handleForkToCloud = async () => { + if (!selectedSession) return; + const forked = await forkSessionToNewCloudSession(selectedSession.sessionId); + if (forked) { + setIsDialogOpen(false); + } + }; + const content = ( <> @@ -352,9 +367,31 @@ export function SessionsPageContent() {

Fork Session

- Fork this session to continue working on it in your editor or CLI + {selectedSession.cloudAgentSessionId + ? 'Fork this session to continue working on it in your editor, CLI, or a new Cloud Agent session' + : 'Fork this session to continue working on it in your editor or CLI'}

+ {/* Fork into a new Cloud Agent session */} + {selectedSession.cloudAgentSessionId && ( + + )} + {/* Open in Editor */}
{sandboxStatusEligible && ( diff --git a/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx b/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx index 8c1c91b703..55a252a48f 100644 --- a/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx +++ b/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx @@ -1264,7 +1264,11 @@ export default function CloudChatPage({

- Fork this session to continue working on it in your editor or CLI + Fork this session to continue working on it in your editor, CLI, or a new Cloud Agent + session

{kiloSessionId ? (
+ +
{ void navigator.clipboard.writeText(cliCommand); @@ -20,6 +32,13 @@ function SessionContinuationPanel({ sessionId }: SessionContinuationPanelProps) setTimeout(() => setCopied(false), 2000); }, [cliCommand]); + const handleForkToCloud = useCallback(async () => { + const forked = await forkSessionToNewCloudSession(sessionId); + if (forked) { + setExpanded(false); + } + }, [forkSessionToNewCloudSession, sessionId]); + return (
+ )} +
@@ -56,10 +94,6 @@ function SessionContinuationPanel({ sessionId }: SessionContinuationPanelProps)
- -

- Continue in Cloud Agent coming soon -

)}
diff --git a/apps/web/src/components/cloud-agent-next/cloud-session-fork.test.ts b/apps/web/src/components/cloud-agent-next/cloud-session-fork.test.ts new file mode 100644 index 0000000000..acb00e3fc8 --- /dev/null +++ b/apps/web/src/components/cloud-agent-next/cloud-session-fork.test.ts @@ -0,0 +1,682 @@ +import { + deriveCloudSessionForkFields, + parseGitLabProjectPath, + cloudForkRejectionMessage, + continueInNewCloudSession, + runCloudForkFlow, + buildCloudChatSessionPath, + type CloudRuntimeConfig, + type CloudForkRejectionReason, + type CloudSessionForkRuntimeStateResult, +} from './cloud-session-fork'; + +const CLOUD_SESSION = { + session_id: 'ses_1234567890abcdef', + cloud_agent_session_id: 'agent_12345678-1234-4234-9234-123456789abc', + organization_id: null, +}; +const CLI_SESSION = { + session_id: 'ses_1234567890abcdef', + cloud_agent_session_id: null, + organization_id: null, +}; + +function runtime(overrides: Partial = {}): CloudRuntimeConfig { + return { + platform: 'github', + githubRepo: 'kilocode/kilo', + mode: 'code', + model: 'kilocode/claude-sonnet-4', + ...overrides, + }; +} + +const githubRuntime = runtime({ autoCommit: true }); + +describe('deriveCloudSessionForkFields', () => { + it('rejects sessions that are not Cloud Agent sessions', () => { + const result = deriveCloudSessionForkFields({ session: CLI_SESSION, runtime: runtime() }); + + expect(result).toEqual({ ok: false, reason: 'not-a-cloud-session' }); + }); + + it('rejects cloud sessions whose runtime configuration is unavailable', () => { + const result = deriveCloudSessionForkFields({ session: CLOUD_SESSION, runtime: null }); + + expect(result).toEqual({ ok: false, reason: 'runtime-unavailable' }); + }); + + it('derives a GitHub fork when the runtime exposes a GitHub repository', () => { + const result = deriveCloudSessionForkFields({ + session: CLOUD_SESSION, + runtime: runtime({ autoCommit: true }), + }); + + expect(result).toEqual({ + ok: true, + fields: { + mode: 'code', + model: 'kilocode/claude-sonnet-4', + variant: undefined, + autoCommit: true, + repository: { kind: 'github', fullName: 'kilocode/kilo' }, + }, + }); + }); + + it('derives a GitHub fork from the runtime git URL when githubRepo is absent', () => { + const result = deriveCloudSessionForkFields({ + session: CLOUD_SESSION, + runtime: runtime({ + githubRepo: undefined, + gitUrl: 'https://github.com/kilocode/kilo.git', + }), + }); + + expect(result).toEqual({ + ok: true, + fields: { + mode: 'code', + model: 'kilocode/claude-sonnet-4', + variant: undefined, + autoCommit: false, + repository: { kind: 'github', fullName: 'kilocode/kilo' }, + }, + }); + }); + + it('infers a GitHub platform from a GitHub git URL when platform is missing', () => { + const result = deriveCloudSessionForkFields({ + session: CLOUD_SESSION, + runtime: runtime({ + platform: undefined, + githubRepo: undefined, + gitUrl: 'https://github.com/kilocode/kilo.git', + }), + }); + + expect(result).toEqual({ + ok: true, + fields: { + mode: 'code', + model: 'kilocode/claude-sonnet-4', + variant: undefined, + autoCommit: false, + repository: { kind: 'github', fullName: 'kilocode/kilo' }, + }, + }); + }); + + it('drops a malformed runtime variant instead of failing the fork', () => { + const result = deriveCloudSessionForkFields({ + session: CLOUD_SESSION, + runtime: runtime({ variant: 'thinking-v2' }), + }); + + expect(result).toEqual({ + ok: true, + fields: { + mode: 'code', + model: 'kilocode/claude-sonnet-4', + variant: undefined, + autoCommit: false, + repository: { kind: 'github', fullName: 'kilocode/kilo' }, + }, + }); + }); + + it('forwards runtime agents so custom agent modes remain forkable', () => { + const result = deriveCloudSessionForkFields({ + session: CLOUD_SESSION, + runtime: runtime({ + mode: 'security-review', + runtimeAgents: [ + { + slug: 'security-review', + name: 'Security Review', + model: 'a-model', + variant: 'thinking', + }, + { slug: 'plain-agent', name: 'Plain Agent' }, + ], + }), + }); + + expect(result).toEqual({ + ok: true, + fields: { + mode: 'security-review', + model: 'kilocode/claude-sonnet-4', + variant: undefined, + autoCommit: false, + runtimeAgents: [ + { + slug: 'security-review', + name: 'Security Review', + config: { model: 'a-model', variant: 'thinking' }, + }, + { slug: 'plain-agent', name: 'Plain Agent', config: {} }, + ], + repository: { kind: 'github', fullName: 'kilocode/kilo' }, + }, + }); + }); + + it('carries the runtime variant and default autoCommit to false when unset', () => { + const result = deriveCloudSessionForkFields({ + session: CLOUD_SESSION, + runtime: runtime({ variant: 'thinking' }), + }); + + expect(result).toEqual({ + ok: true, + fields: { + mode: 'code', + model: 'kilocode/claude-sonnet-4', + variant: 'thinking', + autoCommit: false, + repository: { kind: 'github', fullName: 'kilocode/kilo' }, + }, + }); + }); + + it('rejects when the runtime has no model', () => { + const result = deriveCloudSessionForkFields({ + session: CLOUD_SESSION, + runtime: runtime({ model: undefined }), + }); + + expect(result).toEqual({ ok: false, reason: 'missing-model' }); + }); + + it('rejects when the runtime has no mode', () => { + const result = deriveCloudSessionForkFields({ + session: CLOUD_SESSION, + runtime: runtime({ mode: undefined }), + }); + + expect(result).toEqual({ ok: false, reason: 'missing-mode' }); + }); + + it('rejects when the runtime mode is not a valid mode slug', () => { + const result = deriveCloudSessionForkFields({ + session: CLOUD_SESSION, + runtime: runtime({ mode: 'Code Agent' }), + }); + + expect(result).toEqual({ ok: false, reason: 'invalid-mode' }); + }); + + it('rejects a GitHub session whose runtime has no repository', () => { + const result = deriveCloudSessionForkFields({ + session: CLOUD_SESSION, + runtime: runtime({ githubRepo: undefined }), + }); + + expect(result).toEqual({ ok: false, reason: 'missing-repository' }); + }); + + it('derives a GitLab fork from the runtime git URL', () => { + const result = deriveCloudSessionForkFields({ + session: CLOUD_SESSION, + runtime: runtime({ + platform: 'gitlab', + githubRepo: undefined, + gitUrl: 'https://gitlab.com/acme/widgets.git', + }), + }); + + expect(result).toEqual({ + ok: true, + fields: { + mode: 'code', + model: 'kilocode/claude-sonnet-4', + variant: undefined, + autoCommit: false, + repository: { kind: 'gitlab', projectPath: 'acme/widgets' }, + }, + }); + }); + + it('rejects a GitLab session whose git URL cannot be parsed into a project path', () => { + const result = deriveCloudSessionForkFields({ + session: CLOUD_SESSION, + runtime: runtime({ + platform: 'gitlab', + githubRepo: undefined, + gitUrl: 'https://gitlab.com/not-a-nested-project', + }), + }); + + expect(result).toEqual({ ok: false, reason: 'unparseable-repository' }); + }); + + it('rejects a GitLab session with no git URL', () => { + const result = deriveCloudSessionForkFields({ + session: CLOUD_SESSION, + runtime: runtime({ platform: 'gitlab', githubRepo: undefined, gitUrl: undefined }), + }); + + expect(result).toEqual({ ok: false, reason: 'missing-repository' }); + }); + + it('rejects Bitbucket sessions as unsupported for cloud-to-cloud forks', () => { + const result = deriveCloudSessionForkFields({ + session: CLOUD_SESSION, + runtime: runtime({ + platform: 'bitbucket', + githubRepo: undefined, + gitUrl: 'https://bitbucket.org/acme/widgets.git', + }), + }); + + expect(result).toEqual({ ok: false, reason: 'unsupported-platform' }); + }); + + it('rejects an unrecognized platform', () => { + const result = deriveCloudSessionForkFields({ + session: CLOUD_SESSION, + runtime: runtime({ + platform: 'gitea', + githubRepo: undefined, + gitUrl: 'https://gitea.example/a/b.git', + }), + }); + + expect(result).toEqual({ ok: false, reason: 'unsupported-platform' }); + }); + + it('caps forwarded runtime agents at the prepare schema limit', () => { + const manyAgents = Array.from({ length: 25 }, (_, index) => ({ + slug: `agent-${index}`, + name: `Agent ${index}`, + })); + const result = deriveCloudSessionForkFields({ + session: CLOUD_SESSION, + runtime: runtime({ runtimeAgents: manyAgents }), + }); + + expect(result).toEqual({ + ok: true, + fields: expect.objectContaining({ + runtimeAgents: manyAgents.slice(0, 20).map(agent => ({ + ...agent, + config: {}, + })), + }), + }); + }); + + it('skips runtime agents whose slug cannot round trip through the prepare schema', () => { + const result = deriveCloudSessionForkFields({ + session: CLOUD_SESSION, + runtime: runtime({ + runtimeAgents: [ + { slug: 'Invalid Slug!', name: 'Bad' }, + { slug: 'good-agent', name: 'Good' }, + ], + }), + }); + + expect(result).toEqual({ + ok: true, + fields: expect.objectContaining({ + runtimeAgents: [{ slug: 'good-agent', name: 'Good', config: {} }], + }), + }); + }); +}); + +describe('parseGitLabProjectPath', () => { + it.each([ + ['https://gitlab.com/group/project.git', 'group/project'], + ['https://gitlab.com/group/project', 'group/project'], + ['https://gitlab.com/group/project/', 'group/project'], + ['https://gitlab.example.com/group/subgroup/project.git', 'group/subgroup/project'], + ['ssh://git@gitlab.com/group/project.git', 'group/project'], + ['git@gitlab.com:group/project.git', 'group/project'], + ])('parses %s into %s', (url, expected) => { + expect(parseGitLabProjectPath(url)).toBe(expected); + }); + + it.each([ + ['https://gitlab.com/project.git', 'project name has no namespace'], + ['not a url', 'not a URL'], + ['https://gitlab.com/', 'empty path'], + ['', 'empty input'], + ])('returns null for %s (%s)', url => { + expect(parseGitLabProjectPath(url)).toBeNull(); + }); +}); + +describe('cloudForkRejectionMessage', () => { + const expected: Record = { + 'not-a-cloud-session': 'Only Cloud Agent sessions can be forked to a new Cloud Agent session.', + 'runtime-unavailable': 'This session has no saved Cloud Agent configuration to copy.', + 'missing-model': 'This session has no model selected to copy.', + 'missing-mode': 'This session has no agent mode selected to copy.', + 'invalid-mode': "This session's agent mode cannot be reused.", + 'missing-repository': 'This session has no repository to copy.', + 'unsupported-platform': + "Forking this session's repository to a new Cloud Agent session is not supported yet.", + 'unparseable-repository': "This session's repository cannot be reused.", + 'organization-mismatch': 'You can only fork this session inside its own organization.', + }; + + it('maps every rejection reason to a user-facing message', () => { + for (const reason of Object.keys(expected) as CloudForkRejectionReason[]) { + expect(cloudForkRejectionMessage(reason)).toBe(expected[reason]); + } + }); +}); + +describe('continueInNewCloudSession', () => { + const createDeps = ( + overrides: { + session?: CloudSessionForkRuntimeStateResult['session']; + runtimeState?: CloudRuntimeConfig | null; + } = {} + ) => { + const getRuntimeState = jest.fn().mockResolvedValue({ + session: overrides.session ?? { + session_id: CLOUD_SESSION.session_id, + cloud_agent_session_id: CLOUD_SESSION.cloud_agent_session_id, + organization_id: null, + }, + runtimeState: overrides.runtimeState === undefined ? githubRuntime : overrides.runtimeState, + }); + const createSession = jest + .fn() + .mockResolvedValue({ kiloSessionId: 'ses_aaaaaaaaaaaaaaaaaaaaaa' }); + return { getRuntimeState, createSession }; + }; + + it('forks a personal GitHub cloud session through the caller-provided create mutation', async () => { + const { getRuntimeState, createSession } = createDeps(); + + const result = await continueInNewCloudSession({ + sessionId: CLOUD_SESSION.session_id, + operationKey: '6b2c3e10-0000-4000-8000-000000000000', + deps: { getRuntimeState, createSession }, + }); + + expect(result).toEqual({ ok: true, kiloSessionId: 'ses_aaaaaaaaaaaaaaaaaaaaaa' }); + expect(getRuntimeState).toHaveBeenCalledWith(CLOUD_SESSION.session_id); + expect(createSession).toHaveBeenCalledWith({ + mode: 'code', + model: 'kilocode/claude-sonnet-4', + variant: undefined, + autoCommit: true, + cloneFromKiloSessionId: CLOUD_SESSION.session_id, + autoInitiate: true, + operationKey: '6b2c3e10-0000-4000-8000-000000000000', + githubRepo: 'kilocode/kilo', + }); + }); + + it('maps a GitLab repository to a gitlabProject field', async () => { + const { getRuntimeState, createSession } = createDeps({ + runtimeState: runtime({ + platform: 'gitlab', + githubRepo: undefined, + gitUrl: 'https://gitlab.com/acme/widgets.git', + }), + }); + + const result = await continueInNewCloudSession({ + sessionId: CLOUD_SESSION.session_id, + operationKey: '6b2c3e10-0000-4000-8000-000000000000', + deps: { getRuntimeState, createSession }, + }); + + expect(result).toEqual({ ok: true, kiloSessionId: 'ses_aaaaaaaaaaaaaaaaaaaaaa' }); + expect(createSession).toHaveBeenCalledWith( + expect.objectContaining({ gitlabProject: 'acme/widgets' }) + ); + }); + + it('allows an org-scoped fork when the caller organization matches the session organization', async () => { + const { getRuntimeState, createSession } = createDeps({ + session: { + session_id: CLOUD_SESSION.session_id, + cloud_agent_session_id: CLOUD_SESSION.cloud_agent_session_id, + organization_id: '11111111-1111-4111-8111-111111111111', + }, + }); + + const result = await continueInNewCloudSession({ + sessionId: CLOUD_SESSION.session_id, + organizationId: '11111111-1111-4111-8111-111111111111', + operationKey: '6b2c3e10-0000-4000-8000-000000000000', + deps: { getRuntimeState, createSession }, + }); + + expect(result).toEqual({ ok: true, kiloSessionId: 'ses_aaaaaaaaaaaaaaaaaaaaaa' }); + expect(createSession).toHaveBeenCalledWith( + expect.objectContaining({ organizationId: '11111111-1111-4111-8111-111111111111' }) + ); + }); + + it('rejects forking an org session from a personal context', async () => { + const { getRuntimeState, createSession } = createDeps({ + session: { + session_id: CLOUD_SESSION.session_id, + cloud_agent_session_id: CLOUD_SESSION.cloud_agent_session_id, + organization_id: '11111111-1111-4111-8111-111111111111', + }, + }); + + const result = await continueInNewCloudSession({ + sessionId: CLOUD_SESSION.session_id, + operationKey: '6b2c3e10-0000-4000-8000-000000000000', + deps: { getRuntimeState, createSession }, + }); + + expect(result).toEqual({ ok: false, reason: 'organization-mismatch' }); + expect(createSession).not.toHaveBeenCalled(); + }); + + it('rejects forking an org session from a different organization context', async () => { + const { getRuntimeState, createSession } = createDeps({ + session: { + session_id: CLOUD_SESSION.session_id, + cloud_agent_session_id: CLOUD_SESSION.cloud_agent_session_id, + organization_id: '11111111-1111-4111-8111-111111111111', + }, + }); + + const result = await continueInNewCloudSession({ + sessionId: CLOUD_SESSION.session_id, + organizationId: '22222222-2222-4222-8222-222222222222', + operationKey: '6b2c3e10-0000-4000-8000-000000000000', + deps: { getRuntimeState, createSession }, + }); + + expect(result).toEqual({ ok: false, reason: 'organization-mismatch' }); + expect(createSession).not.toHaveBeenCalled(); + }); + + it('rejects a personal fork into an organization context', async () => { + const { getRuntimeState, createSession } = createDeps(); + + const result = await continueInNewCloudSession({ + sessionId: CLOUD_SESSION.session_id, + organizationId: '11111111-1111-4111-8111-111111111111', + operationKey: '6b2c3e10-0000-4000-8000-000000000000', + deps: { getRuntimeState, createSession }, + }); + + expect(result).toEqual({ ok: false, reason: 'organization-mismatch' }); + expect(createSession).not.toHaveBeenCalled(); + }); + + it('does not call createSession when the source session cannot be derived', async () => { + const { getRuntimeState, createSession } = createDeps({ + session: { ...CLOUD_SESSION, cloud_agent_session_id: null }, + }); + + const result = await continueInNewCloudSession({ + sessionId: CLOUD_SESSION.session_id, + operationKey: '6b2c3e10-0000-4000-8000-000000000000', + deps: { getRuntimeState, createSession }, + }); + + expect(result).toEqual({ ok: false, reason: 'not-a-cloud-session' }); + expect(createSession).not.toHaveBeenCalled(); + }); +}); + +describe('buildCloudChatSessionPath', () => { + it('builds a personal chat path from a session id', () => { + expect(buildCloudChatSessionPath(undefined, 'ses_1234567890abcdef')).toBe( + '/cloud/chat?sessionId=ses_1234567890abcdef' + ); + }); + + it('builds an organization chat path from a session id', () => { + expect( + buildCloudChatSessionPath('11111111-1111-4111-8111-111111111111', 'ses_1234567890abcdef') + ).toBe( + '/organizations/11111111-1111-4111-8111-111111111111/cloud/chat?sessionId=ses_1234567890abcdef' + ); + }); +}); + +describe('runCloudForkFlow', () => { + const baseDeps = () => { + const getRuntimeState = jest.fn().mockResolvedValue({ + session: { + session_id: CLOUD_SESSION.session_id, + cloud_agent_session_id: CLOUD_SESSION.cloud_agent_session_id, + organization_id: null, + }, + runtimeState: githubRuntime, + }); + const createSession = jest + .fn() + .mockResolvedValue({ kiloSessionId: 'ses_bbbbbbbbbbbbbbbbbbbbbbbb' }); + const invalidateSessionQueries = jest.fn().mockResolvedValue(undefined); + const navigateToSession = jest.fn(); + const notifyError = jest.fn(); + return { + getRuntimeState, + createSession, + invalidateSessionQueries, + navigateToSession, + notifyError, + }; + }; + + it('navigates to the new session after a successful fork', async () => { + const deps = baseDeps(); + + const ok = await runCloudForkFlow({ + sessionId: CLOUD_SESSION.session_id, + operationKey: '6b2c3e10-0000-4000-8000-000000000000', + deps, + }); + + expect(ok).toBe(true); + expect(deps.invalidateSessionQueries).toHaveBeenCalled(); + expect(deps.navigateToSession).toHaveBeenCalledWith('ses_bbbbbbbbbbbbbbbbbbbbbbbb'); + expect(deps.notifyError).not.toHaveBeenCalled(); + }); + + it('notifies the mapped reason and does not navigate on a rejection', async () => { + const deps = baseDeps(); + deps.getRuntimeState.mockResolvedValue({ + session: { ...CLOUD_SESSION, cloud_agent_session_id: null }, + runtimeState: githubRuntime, + }); + + const ok = await runCloudForkFlow({ + sessionId: CLOUD_SESSION.session_id, + operationKey: '6b2c3e10-0000-4000-8000-000000000000', + deps, + }); + + expect(ok).toBe(false); + expect(deps.notifyError).toHaveBeenCalledWith( + 'Only Cloud Agent sessions can be forked to a new Cloud Agent session.' + ); + expect(deps.navigateToSession).not.toHaveBeenCalled(); + }); + + it('still navigates when cache invalidation fails', async () => { + const deps = baseDeps(); + deps.invalidateSessionQueries.mockRejectedValue(new Error('invalidate boom')); + + const ok = await runCloudForkFlow({ + sessionId: CLOUD_SESSION.session_id, + operationKey: '6b2c3e10-0000-4000-8000-000000000000', + deps, + }); + + expect(ok).toBe(true); + expect(deps.navigateToSession).toHaveBeenCalledWith('ses_bbbbbbbbbbbbbbbbbbbbbbbb'); + }); +}); + +describe('continueInNewCloudSession configuration forwarding', () => { + const depsFor = (runtimeState: CloudRuntimeConfig) => { + const getRuntimeState = jest.fn().mockResolvedValue({ + session: CLOUD_SESSION, + runtimeState, + }); + const createSession = jest + .fn() + .mockResolvedValue({ kiloSessionId: 'ses_cccccccccccccccccccccccc' }); + return { getRuntimeState, createSession }; + }; + + it('forwards runtime agents so a custom agent mode stays forkable', async () => { + const { getRuntimeState, createSession } = depsFor( + runtime({ + mode: 'security-review', + runtimeAgents: [ + { + slug: 'security-review', + name: 'Security Review', + model: 'a-model', + variant: 'thinking', + }, + ], + }) + ); + + const result = await continueInNewCloudSession({ + sessionId: CLOUD_SESSION.session_id, + operationKey: '6b2c3e10-0000-4000-8000-000000000000', + deps: { getRuntimeState, createSession }, + }); + + expect(result).toEqual({ ok: true, kiloSessionId: 'ses_cccccccccccccccccccccccc' }); + expect(createSession).toHaveBeenCalledWith( + expect.objectContaining({ + mode: 'security-review', + runtimeAgents: [ + { + slug: 'security-review', + name: 'Security Review', + config: { model: 'a-model', variant: 'thinking' }, + }, + ], + }) + ); + }); + + it('omits a malformed runtime variant from the create request', async () => { + const { getRuntimeState, createSession } = depsFor(runtime({ variant: 'thinking-v2' })); + + const result = await continueInNewCloudSession({ + sessionId: CLOUD_SESSION.session_id, + operationKey: '6b2c3e10-0000-4000-8000-000000000000', + deps: { getRuntimeState, createSession }, + }); + + expect(result).toEqual({ ok: true, kiloSessionId: 'ses_cccccccccccccccccccccccc' }); + expect(createSession).not.toHaveBeenCalledWith( + expect.objectContaining({ variant: 'thinking-v2' }) + ); + }); +}); diff --git a/apps/web/src/components/cloud-agent-next/cloud-session-fork.ts b/apps/web/src/components/cloud-agent-next/cloud-session-fork.ts new file mode 100644 index 0000000000..d499ec55e6 --- /dev/null +++ b/apps/web/src/components/cloud-agent-next/cloud-session-fork.ts @@ -0,0 +1,427 @@ +/** + * Pure decision logic for "continue this session in a new Cloud Agent + * session" (a cloud-to-cloud fork). The web UI mirrors the mobile Continue + * flow: the source session's own runtime configuration (repository, model, + * mode) is copied into a `prepareSession` clone request instead of asking the + * user to re-pick a destination. + * + * This module stays free of tRPC, React, and server-only imports so the + * derivation rules can be unit tested in isolation. + */ + +/** The subset of a cloud session's runtime state this feature reads. */ +export type CloudRuntimeConfig = { + platform?: 'github' | 'gitlab' | 'bitbucket' | (string & {}); + githubRepo?: string; + gitUrl?: string; + mode?: string; + model?: string; + variant?: string; + autoCommit?: boolean; + runtimeAgents?: Array<{ slug: string; name: string; model?: string; variant?: string }>; +}; + +/** Runtime agent in the shape the `prepareSession` schema expects. */ +export type CloudRuntimeAgentInput = { + slug: string; + name: string; + config: { model?: string; variant?: string }; +}; + +export type CloudForkRejectionReason = + | 'not-a-cloud-session' + | 'runtime-unavailable' + | 'missing-model' + | 'missing-mode' + | 'invalid-mode' + | 'missing-repository' + | 'unparseable-repository' + | 'unsupported-platform' + | 'organization-mismatch'; + +export type CloudForkRepository = + | { kind: 'github'; fullName: string } + | { kind: 'gitlab'; projectPath: string }; + +export type CloudForkFields = { + mode: string; + model: string; + variant?: string; + autoCommit: boolean; + repository: CloudForkRepository; + runtimeAgents?: CloudRuntimeAgentInput[]; +}; + +export type DeriveCloudSessionForkResult = + | { ok: true; fields: CloudForkFields } + | { ok: false; reason: CloudForkRejectionReason }; + +const MODE_SLUG_PATTERN = /^[a-z][a-z0-9-]*$/; +const VARIANT_PATTERN = /^[a-zA-Z]+$/; +const GITHUB_REPO_PATTERN = /^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/; +const GITLAB_PROJECT_PATTERN = /^[a-zA-Z0-9_.-]+(?:\/[a-zA-Z0-9_.-]+)+$/; + +export function deriveCloudSessionForkFields(input: { + session: { cloud_agent_session_id: string | null }; + runtime: CloudRuntimeConfig | null; +}): DeriveCloudSessionForkResult { + const { session, runtime } = input; + + if (!session.cloud_agent_session_id) { + return { ok: false, reason: 'not-a-cloud-session' }; + } + + if (!runtime) { + return { ok: false, reason: 'runtime-unavailable' }; + } + + if (!runtime.model) { + return { ok: false, reason: 'missing-model' }; + } + + const mode = runtime.mode; + if (!mode) { + return { ok: false, reason: 'missing-mode' }; + } + if (!MODE_SLUG_PATTERN.test(mode)) { + return { ok: false, reason: 'invalid-mode' }; + } + + const repository = deriveRepository(runtime); + if (!repository.ok) { + return repository; + } + + return { + ok: true, + fields: { + mode, + model: runtime.model, + // `variant` is optional and model-specific; a runtime variant that does + // not fit the prepare schema charset would reject the whole fork at the + // zod boundary, so drop malformed variants instead of failing the fork. + ...(isValidVariant(runtime.variant) ? { variant: runtime.variant } : {}), + autoCommit: runtime.autoCommit ?? false, + repository: repository.fields, + ...(runtime.runtimeAgents && runtime.runtimeAgents.length > 0 + ? { runtimeAgents: toPrepareRuntimeAgents(runtime.runtimeAgents) } + : {}), + }, + }; +} + +/** Map the flat runtime-agent shape into the nested `prepareSession` input shape. */ +function toPrepareRuntimeAgents( + agents: Array<{ slug: string; name: string; model?: string; variant?: string }> +): CloudRuntimeAgentInput[] { + const prepared: CloudRuntimeAgentInput[] = []; + for (const agent of agents) { + if (!/^[a-z][a-z0-9-]*$/.test(agent.slug) || agent.slug.length > 50) continue; + if (!agent.name || agent.name.length > 100) continue; + const config: CloudRuntimeAgentInput['config'] = {}; + if (agent.model && agent.model.length <= 200) { + config.model = agent.model; + // A variant is model-specific; only forward it alongside its model. + if (isValidVariant(agent.variant)) { + config.variant = agent.variant; + } + } + prepared.push({ slug: agent.slug, name: agent.name, config }); + if (prepared.length === 20) { + break; + } + } + return prepared; +} + +function isValidVariant(variant: string | undefined): variant is string { + return typeof variant === 'string' && variant.length <= 50 && VARIANT_PATTERN.test(variant); +} + +function deriveRepository( + runtime: CloudRuntimeConfig +): { ok: true; fields: CloudForkRepository } | { ok: false; reason: CloudForkRejectionReason } { + const platform = resolvePlatform(runtime); + + switch (platform) { + case 'github': { + const fullName = + runtime.githubRepo ?? + (runtime.gitUrl ? parseGitHubRepoFromGitUrl(runtime.gitUrl) : undefined); + if (!fullName) { + return { ok: false, reason: 'missing-repository' }; + } + if (!GITHUB_REPO_PATTERN.test(fullName)) { + return { ok: false, reason: 'unparseable-repository' }; + } + return { ok: true, fields: { kind: 'github', fullName } }; + } + case 'gitlab': { + if (!runtime.gitUrl) { + return { ok: false, reason: 'missing-repository' }; + } + const projectPath = parseGitLabProjectPath(runtime.gitUrl); + if (!projectPath) { + return { ok: false, reason: 'unparseable-repository' }; + } + return { ok: true, fields: { kind: 'gitlab', projectPath } }; + } + case 'bitbucket': + return { ok: false, reason: 'unsupported-platform' }; + default: + return { ok: false, reason: 'unsupported-platform' }; + } +} + +function resolvePlatform( + runtime: CloudRuntimeConfig +): 'github' | 'gitlab' | 'bitbucket' | undefined { + const platform = runtime.platform; + if (platform === 'github') return 'github'; + if (platform === 'gitlab') return 'gitlab'; + if (platform === 'bitbucket') return 'bitbucket'; + if (runtime.githubRepo) { + return 'github'; + } + if (!runtime.gitUrl) { + return undefined; + } + const host = gitHostOf(runtime.gitUrl); + if (host === 'github.com') return 'github'; + if (host === 'bitbucket.org') return 'bitbucket'; + if (host !== null && (host === 'gitlab.com' || host.endsWith('.gitlab.com'))) return 'gitlab'; + return undefined; +} + +function gitHostOf(url: string): string | null { + const trimmed = url.trim(); + if (!trimmed) { + return null; + } + const scpStyle = /^[^@/]+@([^:]+):.+$/.exec(trimmed); + if (scpStyle) { + return scpStyle[1].toLowerCase(); + } + try { + return new URL(trimmed).hostname.toLowerCase(); + } catch { + return null; + } +} + +/** + * Extract the `owner/repo` path from a GitHub clone URL. Accepts https and + * SCP-style URLs and strips a trailing `.git` or slash. Returns `null` when + * the path is not exactly two segments. + */ +export function parseGitHubRepoFromGitUrl(url: string): string | null { + const trimmed = url.trim(); + if (!trimmed) { + return null; + } + + let path: string; + const scpStyle = /^[^@/]+@[^:]+:(.+)$/.exec(trimmed); + if (scpStyle) { + path = scpStyle[1]; + } else { + let parsed: URL; + try { + parsed = new URL(trimmed); + } catch { + return null; + } + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:' && parsed.protocol !== 'ssh:') { + return null; + } + path = parsed.pathname.replace(/^\/+/, ''); + } + + const repo = path.replace(/\.git$/i, '').replace(/\/+$/, ''); + return GITHUB_REPO_PATTERN.test(repo) ? repo : null; +} + +/** + * Extract the namespace/project path from a GitLab clone URL. Accepts https, + * ssh://, and SCP-style URLs and strips a trailing `.git` or slash. + * Returns `null` when the path does not contain at least a `group/project`. + */ +export function parseGitLabProjectPath(url: string): string | null { + const trimmed = url.trim(); + if (!trimmed) { + return null; + } + + let path: string; + const scpStyle = /^[^@/]+@[^:]+:(.+)$/.exec(trimmed); + if (scpStyle) { + path = scpStyle[1]; + } else { + let parsed: URL; + try { + parsed = new URL(trimmed); + } catch { + return null; + } + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:' && parsed.protocol !== 'ssh:') { + return null; + } + path = parsed.pathname.replace(/^\/+/, ''); + } + + const projectPath = path.replace(/\.git$/i, '').replace(/\/+$/, ''); + return GITLAB_PROJECT_PATTERN.test(projectPath) ? projectPath : null; +} + +export function cloudForkRejectionMessage(reason: CloudForkRejectionReason): string { + switch (reason) { + case 'not-a-cloud-session': + return 'Only Cloud Agent sessions can be forked to a new Cloud Agent session.'; + case 'runtime-unavailable': + return 'This session has no saved Cloud Agent configuration to copy.'; + case 'missing-model': + return 'This session has no model selected to copy.'; + case 'missing-mode': + return 'This session has no agent mode selected to copy.'; + case 'invalid-mode': + return "This session's agent mode cannot be reused."; + case 'missing-repository': + return 'This session has no repository to copy.'; + case 'unsupported-platform': + return "Forking this session's repository to a new Cloud Agent session is not supported yet."; + case 'unparseable-repository': + return "This session's repository cannot be reused."; + case 'organization-mismatch': + return 'You can only fork this session inside its own organization.'; + } +} + +/** The pieces of a `getWithRuntimeState` result this flow reads. */ +export type CloudSessionForkRuntimeStateResult = { + session: { + session_id: string; + cloud_agent_session_id: string | null; + organization_id: string | null; + }; + runtimeState: CloudRuntimeConfig | null; +}; + +/** Input the caller-bound `prepareSession` mutation must accept. */ +export type CloudSessionForkCreateInput = { + mode: string; + model: string; + variant?: string; + autoCommit: boolean; + cloneFromKiloSessionId: string; + autoInitiate: true; + operationKey: string; + organizationId?: string; + githubRepo?: string; + gitlabProject?: string; + runtimeAgents?: CloudRuntimeAgentInput[]; +}; + +export type CloudSessionForkDeps = { + getRuntimeState: (sessionId: string) => Promise; + createSession: (input: CloudSessionForkCreateInput) => Promise<{ kiloSessionId: string }>; +}; + +/** UI glue around `continueInNewCloudSession`: invalidation, navigation, errors. */ +export type CloudForkFlowDeps = CloudSessionForkDeps & { + invalidateSessionQueries: () => Promise | unknown; + navigateToSession: (kiloSessionId: string) => void; + notifyError: (message: string) => void; +}; + +export type ContinueInNewCloudSessionResult = + | { ok: true; kiloSessionId: string } + | { ok: false; reason: CloudForkRejectionReason }; + +/** + * Run a cloud-to-cloud fork and drive the success UI. Returns `true` when the + * fork settled and navigation was requested; `false` when the source session + * cannot be forked (after notifying the user why). Cache invalidation is + * best-effort — a failure must not block navigation to the new session. + */ +export async function runCloudForkFlow(params: { + sessionId: string; + organizationId?: string; + operationKey: string; + deps: CloudForkFlowDeps; +}): Promise { + const { deps } = params; + const result = await continueInNewCloudSession(params); + + if (!result.ok) { + deps.notifyError(cloudForkRejectionMessage(result.reason)); + return false; + } + + try { + await deps.invalidateSessionQueries(); + } catch { + // A failed cache invalidation is cosmetic; the fork already exists. + } + + deps.navigateToSession(result.kiloSessionId); + return true; +} + +/** + * Fork a source session into a brand-new Cloud Agent session and return the + * new session id. The destination is cloned from the source transcript and + * inherits the source runtime's repository, model, mode, and custom agents. + * + * `organizationId` describes the context the user is acting from: a personal + * listing passes nothing, an organization listing passes the organization id. + * A fork must stay inside the source session's own organization, so a context + * mismatch is rejected before any create call. + */ +export async function continueInNewCloudSession(params: { + sessionId: string; + organizationId?: string; + operationKey: string; + deps: CloudSessionForkDeps; +}): Promise { + const { sessionId, organizationId, operationKey, deps } = params; + const { session, runtimeState } = await deps.getRuntimeState(sessionId); + + if (session.organization_id !== (organizationId ?? null)) { + return { ok: false, reason: 'organization-mismatch' }; + } + + const derived = deriveCloudSessionForkFields({ session, runtime: runtimeState }); + if (!derived.ok) { + return derived; + } + + const { fields } = derived; + const repositoryField = + fields.repository.kind === 'github' + ? { githubRepo: fields.repository.fullName } + : { gitlabProject: fields.repository.projectPath }; + + const { kiloSessionId } = await deps.createSession({ + mode: fields.mode, + model: fields.model, + ...(fields.variant ? { variant: fields.variant } : {}), + autoCommit: fields.autoCommit, + ...(fields.runtimeAgents ? { runtimeAgents: fields.runtimeAgents } : {}), + cloneFromKiloSessionId: sessionId, + autoInitiate: true, + operationKey, + ...(organizationId ? { organizationId } : {}), + ...repositoryField, + }); + + return { ok: true, kiloSessionId }; +} + +/** Relative chat URL for a session id in the given personal or org context. */ +export function buildCloudChatSessionPath( + organizationId: string | undefined, + sessionId: string +): string { + const basePath = organizationId ? `/organizations/${organizationId}/cloud` : '/cloud'; + return `${basePath}/chat?sessionId=${sessionId}`; +} diff --git a/apps/web/src/components/cloud-agent-next/use-cloud-session-fork.ts b/apps/web/src/components/cloud-agent-next/use-cloud-session-fork.ts new file mode 100644 index 0000000000..bf27594125 --- /dev/null +++ b/apps/web/src/components/cloud-agent-next/use-cloud-session-fork.ts @@ -0,0 +1,103 @@ +'use client'; + +import { useCallback, useRef, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { useRawTRPCClient, useTRPC } from '@/lib/trpc/utils'; +import { + runCloudForkFlow, + buildCloudChatSessionPath, + type CloudSessionForkCreateInput, +} from './cloud-session-fork'; +import { invalidateSessionQueries } from './session-deletion'; + +/** + * Fork a source session into a brand-new Cloud Agent session and navigate to + * it. `organizationId` is the context the caller renders in (personal when + * omitted, an organization otherwise) and decides both which `prepareSession` + * endpoint runs and where the new session opens. + */ +export function useCloudSessionFork(organizationId?: string) { + const router = useRouter(); + const queryClient = useQueryClient(); + const trpc = useTRPC(); + const trpcClient = useRawTRPCClient(); + const [forkingSessionId, setForkingSessionId] = useState(null); + // Reuse one `operationKey` per (context, session) while an attempt may have + // committed server-side: after an ambiguous failure a retry with the same + // key replays the settled create instead of minting a second session. The + // key rotates once the fork settles (success or a definite rejection). + const pendingOperationRef = useRef<{ fingerprint: string; operationKey: string } | null>(null); + + const forkSessionToNewCloudSession = useCallback( + async (sessionId: string): Promise => { + setForkingSessionId(sessionId); + const fingerprint = `${organizationId ?? 'personal'}:${sessionId}`; + const pending = pendingOperationRef.current; + const operationKey = + pending?.fingerprint === fingerprint ? pending.operationKey : crypto.randomUUID(); + pendingOperationRef.current = { fingerprint, operationKey }; + + const rotateOperationKey = () => { + if (pendingOperationRef.current?.fingerprint === fingerprint) { + pendingOperationRef.current = null; + } + }; + + try { + const createSession = organizationId + ? (input: CloudSessionForkCreateInput) => + trpcClient.organizations.cloudAgentNext.prepareSession.mutate({ + ...input, + organizationId, + }) + : (input: CloudSessionForkCreateInput) => + trpcClient.cloudAgentNext.prepareSession.mutate(input); + + const settled = await runCloudForkFlow({ + sessionId, + organizationId, + operationKey, + deps: { + getRuntimeState: async id => { + const result = await trpcClient.cliSessionsV2.getWithRuntimeState.query({ + session_id: id, + }); + return { + session: { + session_id: result.session_id, + cloud_agent_session_id: result.cloud_agent_session_id, + organization_id: result.organization_id, + }, + runtimeState: result.runtimeState, + }; + }, + createSession, + invalidateSessionQueries: () => invalidateSessionQueries({ queryClient, trpc }), + navigateToSession: (kiloSessionId: string) => + router.push(buildCloudChatSessionPath(organizationId, kiloSessionId)), + notifyError: message => toast.error(message), + }, + }); + if (settled) { + rotateOperationKey(); + } + return settled; + } catch (error) { + // The error is ambiguous: the server may have committed the create but + // the response was lost. Keep the operation key so a user retry replays + // the same intent instead of duplicating the session. Show a generic + // message rather than leaking internal zod/worker error text. + console.error('Failed to fork session into a new Cloud Agent session:', error); + toast.error('Failed to fork the session into a new Cloud Agent session. Please try again.'); + return false; + } finally { + setForkingSessionId(null); + } + }, + [organizationId, queryClient, router, trpc, trpcClient] + ); + + return { forkSessionToNewCloudSession, forkingSessionId }; +}