From 9cd5b3e67f03745e27a89d4db9677caed58a92de Mon Sep 17 00:00:00 2001 From: maphew <486200+maphew@users.noreply.github.com> Date: Sun, 6 Sep 2026 05:45:10 +0000 Subject: [PATCH 1/5] feat(cloud-agent): derive inputs for forking a session into a new cloud session Extract the pure decision logic that turns a source session's runtime configuration into a clone-style prepareSession request: repository and model/mode inheritance, per-platform mapping, and rejection reasons with user-facing messages. The orchestrator guards organization boundaries and drives invalidation, navigation, and error reporting. --- .../cloud-session-fork.test.ts | 480 ++++++++++++++++++ .../cloud-agent-next/cloud-session-fork.ts | 304 +++++++++++ 2 files changed, 784 insertions(+) create mode 100644 apps/web/src/components/cloud-agent-next/cloud-session-fork.test.ts create mode 100644 apps/web/src/components/cloud-agent-next/cloud-session-fork.ts 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..01d7bbc44f --- /dev/null +++ b/apps/web/src/components/cloud-agent-next/cloud-session-fork.test.ts @@ -0,0 +1,480 @@ +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('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' }); + }); +}); + +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 Bitbucket sessions 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'); + }); +}); 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..cd3e440bb6 --- /dev/null +++ b/apps/web/src/components/cloud-agent-next/cloud-session-fork.ts @@ -0,0 +1,304 @@ +/** + * 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; +}; + +export type CloudForkRejectionReason = + | 'not-a-cloud-session' + | 'runtime-unavailable' + | 'missing-model' + | 'missing-mode' + | 'invalid-mode' + | 'missing-repository' + | 'unsupported-platform' + | 'unparseable-repository' + | '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; +}; + +export type DeriveCloudSessionForkResult = + | { ok: true; fields: CloudForkFields } + | { ok: false; reason: CloudForkRejectionReason }; + +const MODE_SLUG_PATTERN = /^[a-z][a-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, + ...(runtime.variant ? { variant: runtime.variant } : {}), + autoCommit: runtime.autoCommit ?? false, + repository: repository.fields, + }, + }; +} + +function deriveRepository( + runtime: CloudRuntimeConfig +): { ok: true; fields: CloudForkRepository } | { ok: false; reason: CloudForkRejectionReason } { + switch (runtime.platform) { + case 'github': { + if (!runtime.githubRepo) { + return { ok: false, reason: 'missing-repository' }; + } + return { ok: true, fields: { kind: 'github', fullName: runtime.githubRepo } }; + } + 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' }; + } +} + +/** + * 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 Bitbucket sessions 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; +}; + +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; +}; + +/** + * 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; +} + +export type ContinueInNewCloudSessionResult = + | { ok: true; kiloSessionId: string } + | { ok: false; reason: CloudForkRejectionReason }; + +/** + * 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, and mode. + * + * `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, + 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}`; +} From 816d1cc5c4f1ebf39cef2a11ddecdc6310a1ff71 Mon Sep 17 00:00:00 2001 From: maphew <486200+maphew@users.noreply.github.com> Date: Sun, 6 Sep 2026 05:45:16 +0000 Subject: [PATCH 2/5] feat(cloud): fork a session directly into a new cloud session Adds the web UI for the cloud-to-cloud fork that previously only worked via CLI/VS Code round-trips. The sessions listing, session actions dialog, and read-only session continuation panel now offer 'Fork to a new Cloud Agent session', which clones the source session and navigates to the new session. --- .../cloud/sessions/SessionsPageContent.tsx | 34 +++++++- .../cloud-agent-next/ChatHeader.tsx | 1 + .../cloud-agent-next/CloudChatPage.tsx | 5 +- .../cloud-agent-next/SessionActionsDialog.tsx | 36 +++++++- .../SessionContinuationPanel.tsx | 38 +++++++-- .../use-cloud-session-fork.ts | 82 +++++++++++++++++++ 6 files changed, 185 insertions(+), 11 deletions(-) create mode 100644 apps/web/src/components/cloud-agent-next/use-cloud-session-fork.ts diff --git a/apps/web/src/app/(app)/cloud/sessions/SessionsPageContent.tsx b/apps/web/src/app/(app)/cloud/sessions/SessionsPageContent.tsx index 9cfe83a819..f4ec9350dd 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 { @@ -74,6 +75,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; @@ -203,6 +206,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 +363,28 @@ export function SessionsPageContent() {

Fork Session

- 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

+ {/* Fork into a new Cloud Agent session */} + + {/* 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 376acfb81b..9237dc1b92 100644 --- a/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx +++ b/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx @@ -1204,7 +1204,10 @@ export default function CloudChatPage({ <> {isReadOnly ? ( !isLoading && sessionIdFromParams && fetchedSessionData ? ( - + ) : null ) : ( <> diff --git a/apps/web/src/components/cloud-agent-next/SessionActionsDialog.tsx b/apps/web/src/components/cloud-agent-next/SessionActionsDialog.tsx index 2e0017d956..15121d627c 100644 --- a/apps/web/src/components/cloud-agent-next/SessionActionsDialog.tsx +++ b/apps/web/src/components/cloud-agent-next/SessionActionsDialog.tsx @@ -9,11 +9,12 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; -import { Loader2, Copy, Check, Share2, GitFork } from 'lucide-react'; +import { Loader2, Copy, Check, Share2, GitFork, Cloud } from 'lucide-react'; import { useRawTRPCClient } from '@/lib/trpc/utils'; import { toast } from 'sonner'; import { CopyableCommand } from '@/components/CopyableCommand'; import { OpenInEditorButton } from '@/app/share/[shareId]/open-in-editor-button'; +import { useCloudSessionFork } from './use-cloud-session-fork'; type SessionActionsDialogProps = { open: boolean; @@ -22,6 +23,8 @@ type SessionActionsDialogProps = { kiloSessionId?: string; sessionTitle?: string; repository?: string; + /** Organization context the dialog renders in; omitted for personal sessions. */ + organizationId?: string; }; export function SessionActionsDialog({ @@ -30,11 +33,14 @@ export function SessionActionsDialog({ kiloSessionId, sessionTitle, repository, + organizationId, }: SessionActionsDialogProps) { const [isSharing, setIsSharing] = useState(false); const [shareUrl, setShareUrl] = useState(null); const [isCopied, setIsCopied] = useState(false); const trpc = useRawTRPCClient(); + const { forkSessionToNewCloudSession, forkingSessionId } = useCloudSessionFork(organizationId); + const isForkingToCloud = forkingSessionId === kiloSessionId; const handleShare = async () => { if (!kiloSessionId) { @@ -82,6 +88,14 @@ export function SessionActionsDialog({ }, 200); }; + const handleForkToCloud = async () => { + if (!kiloSessionId) return; + const forked = await forkSessionToNewCloudSession(kiloSessionId); + if (forked) { + handleClose(); + } + }; + const truncateId = (id: string, length: number = 8): string => { if (id.length <= length) return id; return `${id.slice(0, length)}...`; @@ -183,11 +197,29 @@ export function SessionActionsDialog({

Fork Session

- 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 +26,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 +86,6 @@ function SessionContinuationPanel({ sessionId }: SessionContinuationPanelProps)
- -

- Continue in Cloud Agent coming soon -

)}
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..3f231f5e2e --- /dev/null +++ b/apps/web/src/components/cloud-agent-next/use-cloud-session-fork.ts @@ -0,0 +1,82 @@ +'use client'; + +import { useCallback, 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); + + const forkSessionToNewCloudSession = useCallback( + async (sessionId: string): Promise => { + setForkingSessionId(sessionId); + try { + const operationKey = crypto.randomUUID(); + const createSession = organizationId + ? (input: CloudSessionForkCreateInput) => + trpcClient.organizations.cloudAgentNext.prepareSession.mutate({ + ...input, + organizationId, + }) + : (input: CloudSessionForkCreateInput) => + trpcClient.cloudAgentNext.prepareSession.mutate(input); + + return 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), + }, + }); + } catch (error) { + const message = + error instanceof Error && error.message + ? error.message + : 'Failed to fork the session into a new Cloud Agent session'; + toast.error(message); + return false; + } finally { + setForkingSessionId(null); + } + }, + [organizationId, queryClient, router, trpc, trpcClient] + ); + + return { forkSessionToNewCloudSession, forkingSessionId }; +} From ecc7715b8dac4ca0cd1f95347ee0632a355f6d4d Mon Sep 17 00:00:00 2001 From: maphew <486200+maphew@users.noreply.github.com> Date: Sun, 6 Sep 2026 05:48:23 +0000 Subject: [PATCH 3/5] feat(cloud): only offer cloud-to-cloud forks for Cloud Agent sessions Non-cloud (CLI, Agent Manager, Gastown) rows cannot be cloned into a new cloud session: they have no cloud runtime configuration to inherit. Hide the new fork action unless the source row has a cloud_agent_session_id, keeping editor/CLI fork options intact. --- .../cloud/sessions/SessionsPageContent.tsx | 45 +++++++++++-------- .../cloud-agent-next/CloudChatPage.tsx | 1 + .../SessionContinuationPanel.tsx | 42 ++++++++++------- 3 files changed, 52 insertions(+), 36 deletions(-) diff --git a/apps/web/src/app/(app)/cloud/sessions/SessionsPageContent.tsx b/apps/web/src/app/(app)/cloud/sessions/SessionsPageContent.tsx index f4ec9350dd..96dcc35fbc 100644 --- a/apps/web/src/app/(app)/cloud/sessions/SessionsPageContent.tsx +++ b/apps/web/src/app/(app)/cloud/sessions/SessionsPageContent.tsx @@ -60,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); @@ -186,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, @@ -363,27 +367,30 @@ export function SessionsPageContent() {

Fork Session

- Fork this session to continue working on it in your editor, CLI, or a new Cloud - Agent session + {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 */}
diff --git a/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx b/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx index 9237dc1b92..4fce29f840 100644 --- a/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx +++ b/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx @@ -1207,6 +1207,7 @@ export default function CloudChatPage({ ) : null ) : ( diff --git a/apps/web/src/components/cloud-agent-next/SessionContinuationPanel.tsx b/apps/web/src/components/cloud-agent-next/SessionContinuationPanel.tsx index c892927935..459b7f90eb 100644 --- a/apps/web/src/components/cloud-agent-next/SessionContinuationPanel.tsx +++ b/apps/web/src/components/cloud-agent-next/SessionContinuationPanel.tsx @@ -10,9 +10,15 @@ type SessionContinuationPanelProps = { sessionId: string; /** Organization context the panel renders in; omitted for personal sessions. */ organizationId?: string; + /** Whether the source session is a Cloud Agent session that can be forked. */ + canForkToCloud?: boolean; }; -function SessionContinuationPanel({ sessionId, organizationId }: SessionContinuationPanelProps) { +function SessionContinuationPanel({ + sessionId, + organizationId, + canForkToCloud = true, +}: SessionContinuationPanelProps) { const [copied, setCopied] = useState(false); const [expanded, setExpanded] = useState(false); const { forkSessionToNewCloudSession, forkingSessionId } = useCloudSessionFork(organizationId); @@ -46,22 +52,24 @@ function SessionContinuationPanel({ sessionId, organizationId }: SessionContinua {expanded && (
- + {canForkToCloud && ( + + )} From 83556eb0479845d7459bfe9218a2210eabd064f2 Mon Sep 17 00:00:00 2001 From: maphew <486200+maphew@users.noreply.github.com> Date: Sun, 6 Sep 2026 06:08:10 +0000 Subject: [PATCH 4/5] fix(cloud): harden cloud-to-cloud fork against review findings - Forward runtime agents alongside a custom agent mode so profile-defined custom modes stay forkable (prepare requires the destination to carry the matching runtimeAgents). - Drop malformed runtime variants and validate repository fields instead of letting the fork fail at the zod boundary; fall back to the runtime git URL when githubRepo is absent, and infer the platform when runtime metadata omits it. - Reuse one operationKey per (context, session) after an ambiguous failure so a retry replays the settled create instead of minting a duplicate session; rotate the key once the fork settles. - Default SessionContinuationPanel.canForkToCloud to false so future callers must opt in, and only surface the new-session fork for rows that carry a cloud_agent_session_id. - Toast a generic message for unexpected failures instead of leaking internal zod/worker error text. --- .../SessionContinuationPanel.tsx | 2 +- .../cloud-session-fork.test.ts | 164 +++++++++++++++++- .../cloud-agent-next/cloud-session-fork.ts | 138 +++++++++++++-- .../use-cloud-session-fork.ts | 37 +++- 4 files changed, 320 insertions(+), 21 deletions(-) diff --git a/apps/web/src/components/cloud-agent-next/SessionContinuationPanel.tsx b/apps/web/src/components/cloud-agent-next/SessionContinuationPanel.tsx index 459b7f90eb..19c5ed6554 100644 --- a/apps/web/src/components/cloud-agent-next/SessionContinuationPanel.tsx +++ b/apps/web/src/components/cloud-agent-next/SessionContinuationPanel.tsx @@ -17,7 +17,7 @@ type SessionContinuationPanelProps = { function SessionContinuationPanel({ sessionId, organizationId, - canForkToCloud = true, + canForkToCloud = false, }: SessionContinuationPanelProps) { const [copied, setCopied] = useState(false); const [expanded, setExpanded] = useState(false); 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 index 01d7bbc44f..4707fd3f69 100644 --- 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 @@ -64,6 +64,104 @@ describe('deriveCloudSessionForkFields', () => { }); }); + 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, @@ -220,7 +318,7 @@ describe('cloudForkRejectionMessage', () => { 'invalid-mode': "This session's agent mode cannot be reused.", 'missing-repository': 'This session has no repository to copy.', 'unsupported-platform': - 'Forking Bitbucket sessions to a new Cloud Agent session is not supported yet.', + "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.', }; @@ -478,3 +576,67 @@ describe('runCloudForkFlow', () => { 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 index cd3e440bb6..8dc784a1bf 100644 --- a/apps/web/src/components/cloud-agent-next/cloud-session-fork.ts +++ b/apps/web/src/components/cloud-agent-next/cloud-session-fork.ts @@ -18,6 +18,14 @@ export type CloudRuntimeConfig = { 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 = @@ -27,8 +35,8 @@ export type CloudForkRejectionReason = | 'missing-mode' | 'invalid-mode' | 'missing-repository' - | 'unsupported-platform' | 'unparseable-repository' + | 'unsupported-platform' | 'organization-mismatch'; export type CloudForkRepository = @@ -41,6 +49,7 @@ export type CloudForkFields = { variant?: string; autoCommit: boolean; repository: CloudForkRepository; + runtimeAgents?: CloudRuntimeAgentInput[]; }; export type DeriveCloudSessionForkResult = @@ -48,6 +57,8 @@ export type DeriveCloudSessionForkResult = | { 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: { @@ -86,22 +97,57 @@ export function deriveCloudSessionForkFields(input: { fields: { mode, model: runtime.model, - ...(runtime.variant ? { variant: runtime.variant } : {}), + // `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[] { + return agents.map(agent => { + const config: CloudRuntimeAgentInput['config'] = {}; + if (agent.model) { + config.model = agent.model; + } + // A variant is model-specific; only forward it alongside its model. + if (agent.model && agent.variant) { + config.variant = agent.variant; + } + return { slug: agent.slug, name: agent.name, config }; + }); +} + +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 } { - switch (runtime.platform) { + const platform = resolvePlatform(runtime); + + switch (platform) { case 'github': { - if (!runtime.githubRepo) { + const fullName = + runtime.githubRepo ?? + (runtime.gitUrl ? parseGitHubRepoFromGitUrl(runtime.gitUrl) : undefined); + if (!fullName) { return { ok: false, reason: 'missing-repository' }; } - return { ok: true, fields: { kind: 'github', fullName: runtime.githubRepo } }; + if (!GITHUB_REPO_PATTERN.test(fullName)) { + return { ok: false, reason: 'unparseable-repository' }; + } + return { ok: true, fields: { kind: 'github', fullName } }; } case 'gitlab': { if (!runtime.gitUrl) { @@ -120,6 +166,74 @@ function deriveRepository( } } +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. @@ -167,7 +281,7 @@ export function cloudForkRejectionMessage(reason: CloudForkRejectionReason): str case 'missing-repository': return 'This session has no repository to copy.'; case 'unsupported-platform': - return 'Forking Bitbucket sessions to a new Cloud Agent session is not supported yet.'; + 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': @@ -197,6 +311,7 @@ export type CloudSessionForkCreateInput = { organizationId?: string; githubRepo?: string; gitlabProject?: string; + runtimeAgents?: CloudRuntimeAgentInput[]; }; export type CloudSessionForkDeps = { @@ -211,6 +326,10 @@ export type CloudForkFlowDeps = CloudSessionForkDeps & { 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 @@ -241,14 +360,10 @@ export async function runCloudForkFlow(params: { return true; } -export type ContinueInNewCloudSessionResult = - | { ok: true; kiloSessionId: string } - | { ok: false; reason: CloudForkRejectionReason }; - /** * 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, and mode. + * 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. @@ -284,6 +399,7 @@ export async function continueInNewCloudSession(params: { model: fields.model, ...(fields.variant ? { variant: fields.variant } : {}), autoCommit: fields.autoCommit, + ...(fields.runtimeAgents ? { runtimeAgents: fields.runtimeAgents } : {}), cloneFromKiloSessionId: sessionId, autoInitiate: true, operationKey, 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 index 3f231f5e2e..bf27594125 100644 --- 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 @@ -1,6 +1,6 @@ 'use client'; -import { useCallback, useState } from 'react'; +import { useCallback, useRef, useState } from 'react'; import { useRouter } from 'next/navigation'; import { useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; @@ -24,12 +24,28 @@ export function useCloudSessionFork(organizationId?: string) { 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 operationKey = crypto.randomUUID(); const createSession = organizationId ? (input: CloudSessionForkCreateInput) => trpcClient.organizations.cloudAgentNext.prepareSession.mutate({ @@ -39,7 +55,7 @@ export function useCloudSessionFork(organizationId?: string) { : (input: CloudSessionForkCreateInput) => trpcClient.cloudAgentNext.prepareSession.mutate(input); - return await runCloudForkFlow({ + const settled = await runCloudForkFlow({ sessionId, organizationId, operationKey, @@ -64,12 +80,17 @@ export function useCloudSessionFork(organizationId?: string) { notifyError: message => toast.error(message), }, }); + if (settled) { + rotateOperationKey(); + } + return settled; } catch (error) { - const message = - error instanceof Error && error.message - ? error.message - : 'Failed to fork the session into a new Cloud Agent session'; - toast.error(message); + // 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); From 844cf4a0bba61be27200750d451bf79e3d4d4f2c Mon Sep 17 00:00:00 2001 From: maphew <486200+maphew@users.noreply.github.com> Date: Sun, 6 Sep 2026 06:15:31 +0000 Subject: [PATCH 5/5] fix(cloud): prune runtime agents to schema-safe entries before forking Custom agent lists read from runtime state are unconstrained while the prepare schema bounds each agent (slug/name/model/variant length and charset, 20 agents max). Prune invalid agents, cap the list at 20, and only forward variants alongside their model so a pathological session cannot reject the whole fork at the zod boundary. --- .../cloud-session-fork.test.ts | 40 +++++++++++++++++++ .../cloud-agent-next/cloud-session-fork.ts | 21 ++++++---- 2 files changed, 54 insertions(+), 7 deletions(-) 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 index 4707fd3f69..acb00e3fc8 100644 --- 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 @@ -285,6 +285,46 @@ describe('deriveCloudSessionForkFields', () => { 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', () => { 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 index 8dc784a1bf..d499ec55e6 100644 --- a/apps/web/src/components/cloud-agent-next/cloud-session-fork.ts +++ b/apps/web/src/components/cloud-agent-next/cloud-session-fork.ts @@ -114,17 +114,24 @@ export function deriveCloudSessionForkFields(input: { function toPrepareRuntimeAgents( agents: Array<{ slug: string; name: string; model?: string; variant?: string }> ): CloudRuntimeAgentInput[] { - return agents.map(agent => { + 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) { + 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; + } } - // A variant is model-specific; only forward it alongside its model. - if (agent.model && agent.variant) { - config.variant = agent.variant; + prepared.push({ slug: agent.slug, name: agent.name, config }); + if (prepared.length === 20) { + break; } - return { slug: agent.slug, name: agent.name, config }; - }); + } + return prepared; } function isValidVariant(variant: string | undefined): variant is string {